Files
SocialPub/PrivaPub/Api/Mastodon/Controllers/MediaController.cs
T

162 lines
6.8 KiB
C#
Raw Normal View History

2026-10-07 10:48:44 +02:00
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;
2026-10-07 10:48:44 +02:00
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;
}
2026-10-07 10:48:44 +02:00
[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;
2026-10-07 10:23:09 +02:00
if (FocalPoint.Parse(Params.Get("focus")) is { } focus)
attachment.Focus = focus;
await DB.Default.SaveAsync(attachment, token);
return Json(View(attachment));
}
2026-10-07 10:54:33 +02:00
[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);
2026-10-07 10:54:33 +02:00
if (url == default || _proxy.Refuses(url))
return NotFound();
Response.Headers["X-Content-Type-Options"] = "nosniff";
Response.Headers["Content-Security-Policy"] = "default-src 'none'; sandbox";
2026-10-07 10:54:33 +02:00
if (_proxy.Cached(url) is { Path: not null } cached && Serve(cached.Path, cached.ContentType) is { } hit)
{
_ledger?.Count(Interactions.HostOf(url), "media:hit");
2026-10-07 10:54:33 +02:00
return hit;
}
if (Request.Headers.Range.Count == 0)
{
2026-10-07 10:54:33 +02:00
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);
}
2026-10-07 10:54:33 +02:00
// 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();
2026-10-07 10:54:33 +02:00
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
};
}
}