- /api/v1/media and /api/v2/media (and GET/PUT /api/v1/media/:id):
images go through libvips (NetVips, its native build bundled):
autorotated, every kind of metadata dropped (EXIF, GPS, XMP, IPTC,
comments), capped at 4096 px, with a 640 px preview and a blurhash
(own encoder, the reference algorithm); animated GIFs are re-encoded;
video and audio are remuxed by ffmpeg with -map_metadata -1, never
re-encoded, and a video gets a still preview. Files get random names
under /var/lib/privapub/media, outside the web root deploys replace, and
are served at /media/files with nosniff and a sandbox CSP.
- media_ids on create and edit (four at most, the persona's own, each used
once); notes carry them as Document attachments with alt text, blurhash,
focalPoint and size; inbound attachments were already kept.
- avatar and header uploads in update_credentials, cropped to 400x400 and
1500x500, federated with Update{Person}.
- Remote media reaches clients only through /media/proxy/{hmac}/{url},
fetched by the guarded client (no SVG, 40 MB cap) and cached outside the
served root, trimmed to 5 GB; foreign avatars and headers use it too, so
a client never contacts another server.
- MediaJanitor deletes uploads left unattached for a day.
- nginx accepts 100 MB bodies on the upload endpoints only (applied on Max).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
92 lines
3.7 KiB
C#
92 lines
3.7 KiB
C#
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 System.Globalization;
|
|
|
|
namespace PrivaPub.Api.Mastodon.Controllers
|
|
{
|
|
public class MediaController : MastodonController
|
|
{
|
|
const long UploadLimit = 100L * 1024 * 1024;
|
|
|
|
readonly IMediaService _media;
|
|
readonly IMediaProxy _proxy;
|
|
|
|
public MediaController(IMediaService media, IMediaProxy proxy)
|
|
{
|
|
_media = media;
|
|
_proxy = proxy;
|
|
}
|
|
|
|
[HttpPost("/api/v1/media"), HttpPost("/api/v2/media"), Scope("write:media"), 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);
|
|
var outcome = await _media.Upload(Me, form.Files["file"], form["description"], form["focus"], token);
|
|
return outcome.Ok ? Json(View(outcome.Attachment)) : Error(outcome.Status, outcome.Error);
|
|
}
|
|
|
|
[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).ExecuteFirstAsync(token);
|
|
return attachment == default ? NotFoundError() : 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).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 (Params.Get("focus")?.Split(',') is [var x, var y]
|
|
&& float.TryParse(x, NumberStyles.Float, CultureInfo.InvariantCulture, out var fx) && float.TryParse(y, NumberStyles.Float, CultureInfo.InvariantCulture, out var fy))
|
|
attachment.Focus = new[] { Math.Clamp(fx, -1, 1), Math.Clamp(fy, -1, 1) };
|
|
await DB.Default.SaveAsync(attachment, token);
|
|
return Json(View(attachment));
|
|
}
|
|
|
|
[HttpGet("/media/proxy/{signature}/{encoded}"), AllowAnonymous, ApiExplorerSettings(IgnoreApi = true)]
|
|
public async Task<IActionResult> Proxy(string signature, string encoded, CancellationToken token)
|
|
{
|
|
var (path, contentType) = await _proxy.Fetch(signature, encoded, token);
|
|
if (path == default)
|
|
return NotFound();
|
|
Response.Headers["X-Content-Type-Options"] = "nosniff";
|
|
Response.Headers["Content-Security-Policy"] = "default-src 'none'; sandbox";
|
|
Response.Headers["Cache-Control"] = "public, max-age=604800";
|
|
return PhysicalFile(path, contentType);
|
|
}
|
|
|
|
object View(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
|
|
};
|
|
}
|
|
}
|