Anyone could mint signed proxy URLs (a remote account changes its icon, an anonymous lookup returns the URL), and each anonymous request held up to 40 MB in memory; the cache grew without bound between hourly trims; two clients asking for the same new file downloaded it twice and wrote over each other in place, so a reader could get half a file with a 7-day cache header; a file over the limit was downloaded twice on every request; a failed fetch, a 404, was cached by browsers for a week; cached media of a server suspended later were still served, and RejectMedia skipped avatars, emoji, covers, video variants, link cards and remote edits; /clientapi/group/members returned remote pictures raw. Now a download is shared by everyone asking at once, streamed into a .part file and renamed into place (FederationHttp.DownloadMedia copies bounded, never into memory), at most eight at a time; a file too big to cache is remembered for an hour and only streamed, a failure for five minutes; the cache's size is counted as it grows and trimmed as soon as it passes the cap; a cached file is opened before it is answered; browsers may cache only a success; nothing of a suspended server, or of one whose media are rejected, is proxied (everything remote a client sees goes through the proxy, so that covers every kind), and blocking one purges its cache; the proxy has its own rate limit per client address; group members' pictures are proxied; the proxy's key is loaded once, the oldest if two were made. This changes what PrivaPub serves its clients, not what it sends to other servers. Tests: clients asking at once share one download, a failure isn't cached by browsers, an over-limit file is fetched three times for two requests instead of four, a blocked server's media are refused and its cache purged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
163 lines
6.8 KiB
C#
163 lines
6.8 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))
|
|
};
|
|
}
|
|
|
|
[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).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
|
|
};
|
|
}
|
|
}
|