Build / Build (push) Successful in 32s
Completion program R4 (the client catching up), the server's part.
- /clientapi/admin/statistics/hosts/{host}, /events and /server answer typed views (ViewHostDetail, ViewHostInstance,
ViewHostDay, ViewInteractionEvent, ViewServerDay in ClientModels) instead of an anonymous object and raw entities:
the instance's public description, never its raw instance API document; the events without their actor hash. The
JSON names stay, so lib/interop.sh's stats_check reads them as before.
- DELETE /api/v1/media/{id} (Mastodon 4.4): a persona's own upload goes to the trash while nothing holds it; a posted
or scheduled one answers 422.
- GET /api/v1/accounts?id[]= (Mastodon 4.3): up to 40 accounts, those that exist.
- /api/v2/search's hashtags carry their week of use (TagsController.History), not an empty history.
Verified: dotnet build PrivaPub.sln -c Release; the pasture: gts 64/64 (statistics included); by hand, an unattached
upload deleted (then 404), a posted one refused (422), accounts by ids, a hashtag's history.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
178 lines
7.8 KiB
C#
178 lines
7.8 KiB
C#
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;
|
|
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;
|
|
}
|
|
|
|
[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))
|
|
};
|
|
}
|
|
|
|
// Mastodon 4.4: a persona's own upload, while nothing holds it (not posted, not scheduled, not a profile picture), goes to
|
|
// the trash as the janitor would take it
|
|
[HttpDelete("/api/v1/media/{id}"), Scope("write:media")]
|
|
public async Task<IActionResult> Delete(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 (attachment.PostId != null || attachment.ScheduledStatusId != null)
|
|
return Error(StatusCodes.Status422UnprocessableEntity, "Media attachment is currently used by a status");
|
|
await _media.Trash(m => m.ID == attachment.ID && m.PostId == null && m.ScheduledStatusId == null, "deleted by its owner", token);
|
|
return Json(new { });
|
|
}
|
|
|
|
// as on Mastodon, only media not posted yet: a posted one's description and focus change by editing its post (media_attributes)
|
|
[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 && m.PostId == 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;
|
|
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, EnableRateLimiting(RateLimiting.Proxy), ApiExplorerSettings(IgnoreApi = true)]
|
|
public async Task<IActionResult> Proxy(string signature, string encoded, CancellationToken token)
|
|
{
|
|
var url = _proxy.Verified(signature, encoded);
|
|
if (url == default || _proxy.Refuses(url))
|
|
return NotFound();
|
|
Response.Headers["X-Content-Type-Options"] = "nosniff";
|
|
Response.Headers["Content-Security-Policy"] = "default-src 'none'; sandbox";
|
|
if (_proxy.Cached(url) is { Path: not null } cached && Serve(cached.Path, cached.ContentType) is { } hit)
|
|
{
|
|
_ledger?.Count(Interactions.HostOf(url), "media:hit");
|
|
return hit;
|
|
}
|
|
if (Request.Headers.Range.Count == 0)
|
|
{
|
|
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);
|
|
}
|
|
|
|
// 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();
|
|
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
|
|
};
|
|
}
|
|
}
|