- The media proxy streams ranged requests from the origin (passing the range on, never caching), downloads and caches whole files otherwise, and serves cached files with range support. A PeerTube video is never fetched whole for one viewer, and clients still never contact the remote host. - PeerTube's fragmented MP4 files inside an HLS entry are read as variants, so HLS-only instances play too. - A remote Video or Audio post becomes one playable Mastodon attachment: the best MP4 up to 720p that carries both sound and picture, with its poster and duration; the card is kept only when nothing is playable. - nginx: /media/proxy/ with proxy_buffering off and a 600 s read timeout (applied on the box, with a backup). Checked live: a GoToSocial image through the proxy answers 206 with exactly the asked range when streamed, 200 when cached, and 206 with the right Content-Range from the cache. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
117 lines
4.9 KiB
C#
117 lines
4.9 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 url = _proxy.Verified(signature, encoded);
|
|
if (url == 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";
|
|
if (_proxy.Cached(url) is { Path: not null } cached)
|
|
return PhysicalFile(cached.Path, cached.ContentType, enableRangeProcessing: true);
|
|
if (Request.Headers.Range.Count == 0)
|
|
{
|
|
var (path, contentType) = await _proxy.Fetch(signature, encoded, token);
|
|
if (path != default)
|
|
return PhysicalFile(path, contentType, enableRangeProcessing: true);
|
|
}
|
|
return await Stream(url, token);
|
|
}
|
|
|
|
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();
|
|
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) => 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
|
|
};
|
|
}
|
|
}
|