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 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 Get(string id, CancellationToken token) { var attachment = await DB.Default.Find().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 Update(string id, CancellationToken token) { var attachment = await DB.Default.Find().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 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 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 }; } }