float.TryParse takes "NaN" and "Infinity", and Math.Clamp keeps a NaN, so `focus=NaN,NaN` on an upload or a media PUT was stored as given. Once the media was posted, every status and timeline response holding the post, and the Note and its delivery, failed to serialise, for every viewer. FocalPoint.Parse takes finite numbers only (anything else is ignored, as an unreadable focus was); the mapper and the renderer leave out a focus that isn't sound, and migration _016 removes the ones stored before, from the uploads and from the copy each post keeps. What PrivaPub sends is unchanged for any focus that could be sent before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
125 lines
5.0 KiB
C#
125 lines
5.0 KiB
C#
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 System.Globalization;
|
|
|
|
namespace PrivaPub.Api.Mastodon.Controllers
|
|
{
|
|
public class MediaController : MastodonController
|
|
{
|
|
const long UploadLimit = 100L * 1024 * 1024;
|
|
|
|
readonly IMediaService _media;
|
|
readonly IMediaProxy _proxy;
|
|
|
|
readonly IInteractionLedger _ledger;
|
|
|
|
public MediaController(IMediaService media, IMediaProxy proxy, IInteractionLedger ledger = default)
|
|
{
|
|
_media = media;
|
|
_proxy = proxy;
|
|
_ledger = ledger;
|
|
}
|
|
|
|
[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 (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, 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)
|
|
{
|
|
_ledger?.Count(Interactions.HostOf(url), "media:hit");
|
|
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) => 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
|
|
};
|
|
}
|
|
}
|