PUT /api/v1/media/:id on a picture already posted changed the upload but never the post, which keeps its own copy, so the new description or focal point reached nobody. As on Mastodon, PUT now takes only media not posted yet (404 otherwise), and a status edit takes media_attributes[] (id, description, focus; from a JSON body or a form): the uploads and the post's copies change, and the Update the edit federates carries them. An edit that sends no media keeps them as they were described. No pasture scenario edits a description, so the sweep could not see this. MediaLifecycleTests: PUT on a posted picture is refused, an edit describes it again and keeps its warning, flag and language as sent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
164 lines
6.9 KiB
C#
164 lines
6.9 KiB
C#
using Microsoft.AspNetCore.RateLimiting;
|
|
using PrivaPub.Infrastructure.Statistics;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
|
|
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Api.Mastodon.Infrastructure;
|
|
using PrivaPub.Domain.Media;
|
|
using PrivaPub.Models.Media;
|
|
using PrivaPub.Infrastructure;
|
|
using PrivaPub.Infrastructure.Jobs;
|
|
|
|
using System.Globalization;
|
|
|
|
namespace PrivaPub.Api.Mastodon.Controllers
|
|
{
|
|
public class MediaController : MastodonController
|
|
{
|
|
const long UploadLimit = 100L * 1024 * 1024;
|
|
|
|
readonly IMediaService _media;
|
|
readonly IMediaProxy _proxy;
|
|
readonly IJobQueue _jobs;
|
|
|
|
readonly IInteractionLedger _ledger;
|
|
|
|
public MediaController(IMediaService media, IMediaProxy proxy, IJobQueue jobs, IInteractionLedger ledger = default)
|
|
{
|
|
_media = media;
|
|
_proxy = proxy;
|
|
_jobs = jobs;
|
|
_ledger = ledger;
|
|
}
|
|
|
|
[HttpPost("/api/v1/media"), HttpPost("/api/v2/media"), Scope("write:media"), EnableRateLimiting(RateLimiting.Uploads), RequestSizeLimit(UploadLimit),
|
|
RequestFormLimits(MultipartBodyLengthLimit = UploadLimit)]
|
|
public async Task<IActionResult> Upload(CancellationToken token)
|
|
{
|
|
if (!Request.HasFormContentType)
|
|
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: File can't be blank");
|
|
var form = await Request.ReadFormAsync(token);
|
|
// v2 may answer before audio or video is processed (202, its url null until then), as Mastodon does; v1 waits
|
|
var later = Request.Path.StartsWithSegments("/api/v2/media");
|
|
var outcome = await _media.Upload(Me, form.Files["file"], form["description"], form["focus"], later, token);
|
|
if (!outcome.Ok)
|
|
return Error(outcome.Status, outcome.Error);
|
|
if (outcome.Attachment.ProcessingState != "pending")
|
|
return Json(View(outcome.Attachment));
|
|
await _jobs.EnqueueMany(new[] { ProcessMediaJob.JobFor(outcome.Attachment, new Uri(Me.BaseAddress).Host) }, token);
|
|
return new JsonResult(View(outcome.Attachment)) { StatusCode = StatusCodes.Status202Accepted };
|
|
}
|
|
|
|
[HttpGet("/api/v1/media/{id}"), Scope("write:media")]
|
|
public async Task<IActionResult> Get(string id, CancellationToken token)
|
|
{
|
|
var attachment = await DB.Default.Find<MediaAttachment>().Match(m => m.ID == id && m.OwnerAvatarId == MyId && m.TrashedAt == null && m.ProfileOfAvatarId == null).ExecuteFirstAsync(token);
|
|
return attachment?.ProcessingState switch
|
|
{
|
|
null when attachment == default => NotFoundError(),
|
|
"pending" => new JsonResult(View(attachment)) { StatusCode = StatusCodes.Status206PartialContent },
|
|
"failed" => Error(StatusCodes.Status422UnprocessableEntity, attachment.ProcessingError ?? "Validation failed: The file could not be processed"),
|
|
_ => Json(View(attachment))
|
|
};
|
|
}
|
|
|
|
// as on Mastodon, only media not posted yet: a posted one's description and focus change by editing its post (media_attributes)
|
|
[HttpPut("/api/v1/media/{id}"), Scope("write:media")]
|
|
public async Task<IActionResult> Update(string id, CancellationToken token)
|
|
{
|
|
var attachment = await DB.Default.Find<MediaAttachment>().Match(m => m.ID == id && m.OwnerAvatarId == MyId && m.TrashedAt == null && m.ProfileOfAvatarId == null && m.PostId == null).ExecuteFirstAsync(token);
|
|
if (attachment == default)
|
|
return NotFoundError();
|
|
if (Params.Has("description"))
|
|
attachment.Description = Params.Get("description")?.Trim() is { Length: > 0 } description ? description[..Math.Min(description.Length, 1500)] : default;
|
|
if (FocalPoint.Parse(Params.Get("focus")) is { } focus)
|
|
attachment.Focus = focus;
|
|
await DB.Default.SaveAsync(attachment, token);
|
|
return Json(View(attachment));
|
|
}
|
|
|
|
[HttpGet("/media/proxy/{signature}/{encoded}"), AllowAnonymous, EnableRateLimiting(RateLimiting.Proxy), ApiExplorerSettings(IgnoreApi = true)]
|
|
public async Task<IActionResult> Proxy(string signature, string encoded, CancellationToken token)
|
|
{
|
|
var url = _proxy.Verified(signature, encoded);
|
|
if (url == default || _proxy.Refuses(url))
|
|
return NotFound();
|
|
Response.Headers["X-Content-Type-Options"] = "nosniff";
|
|
Response.Headers["Content-Security-Policy"] = "default-src 'none'; sandbox";
|
|
if (_proxy.Cached(url) is { Path: not null } cached && Serve(cached.Path, cached.ContentType) is { } hit)
|
|
{
|
|
_ledger?.Count(Interactions.HostOf(url), "media:hit");
|
|
return hit;
|
|
}
|
|
if (Request.Headers.Range.Count == 0)
|
|
{
|
|
var (outcome, path, contentType) = await _proxy.Download(url, token);
|
|
if (outcome == ProxyOutcome.Cached && Serve(path, contentType) is { } fetched)
|
|
return fetched;
|
|
if (outcome == ProxyOutcome.Failed)
|
|
return NotFound();
|
|
}
|
|
return await Stream(url, token);
|
|
}
|
|
|
|
// a cached file, opened before it is answered: trimmed meanwhile, what is open still reads; cached by browsers only
|
|
// once there is something to cache
|
|
IActionResult Serve(string path, string contentType)
|
|
{
|
|
FileStream file;
|
|
try
|
|
{
|
|
file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 64 * 1024, useAsync: true);
|
|
}
|
|
catch (IOException)
|
|
{
|
|
return default;
|
|
}
|
|
Response.Headers["Cache-Control"] = "public, max-age=604800";
|
|
return File(file, contentType, enableRangeProcessing: true);
|
|
}
|
|
|
|
async Task<IActionResult> Stream(string url, CancellationToken token)
|
|
{
|
|
var range = System.Net.Http.Headers.RangeHeaderValue.TryParse(Request.Headers.Range.ToString(), out var asked) ? asked : default;
|
|
using var upstream = await _proxy.Open(url, range, token);
|
|
if (upstream == default)
|
|
return NotFound();
|
|
if (upstream.IsSuccessStatusCode)
|
|
Response.Headers["Cache-Control"] = "public, max-age=604800";
|
|
Response.StatusCode = (int)upstream.StatusCode;
|
|
Response.ContentType = upstream.Content.Headers.ContentType?.ToString() ?? "application/octet-stream";
|
|
if (upstream.Content.Headers.ContentLength is { } length)
|
|
Response.ContentLength = length;
|
|
if (upstream.Content.Headers.ContentRange is { } contentRange)
|
|
Response.Headers.ContentRange = contentRange.ToString();
|
|
Response.Headers.AcceptRanges = "bytes";
|
|
await upstream.Content.CopyToAsync(Response.Body, token);
|
|
return new EmptyResult();
|
|
}
|
|
|
|
object View(MediaAttachment attachment) => View(_media, attachment);
|
|
|
|
internal static object View(IMediaService media, MediaAttachment attachment) => new
|
|
{
|
|
id = attachment.ID,
|
|
type = attachment.Kind,
|
|
url = media.Url(attachment.FilePath),
|
|
preview_url = media.Url(attachment.PreviewPath ?? attachment.FilePath),
|
|
remote_url = default(string),
|
|
text_url = default(string),
|
|
meta = new
|
|
{
|
|
original = attachment.Width.HasValue && attachment.Height > 0
|
|
? new { width = attachment.Width, height = attachment.Height, size = $"{attachment.Width}x{attachment.Height}", aspect = (double)attachment.Width / attachment.Height.Value }
|
|
: default,
|
|
focus = attachment.Focus is { Length: 2 } ? new { x = attachment.Focus[0], y = attachment.Focus[1] } : default
|
|
},
|
|
description = attachment.Description,
|
|
blurhash = attachment.Blurhash
|
|
};
|
|
}
|
|
}
|