Audio and video are processed off the request

Every audio and video upload was probed and remuxed inside the request, whatever its length; ffmpeg would read any
protocol and probe any format; `-map 0` kept the data tracks iPhones add, which mp4 refuses; nothing was ever
transcoded, so HEVC or MPEG-4 Part 2 reached browsers that can't play them, and the advertised video_matrix_limit
and frame rate limit were never applied; the output was read whole into memory, the video was saved before its
poster could fail, and the poster's frame leaked in /tmp. FLAC uploads were served as 404.

Now an upload sent to /api/v2/media is stored as sent in media-incoming (beside the media root, never served) and
answered with 202 and no url, while a ProcessMedia job, one at a time, does the work; GET /api/v1/media/:id answers 206
until it is ready, or 422 with why, and media still processing can't be posted. v1 processes before answering.
ffmpeg reads only that file (protocol whitelist, format forced from the probe) and drops data and subtitle tracks. A
video browsers play as it is (H.264, VP8, VP9, AV1 within Media:MaxVideoPixels and MaxFrameRate) is remuxed, anything
else transcoded to H.264 that fits, as Mastodon does; longer than Media:MaxSeconds is refused. Outputs move into place
only once everything succeeded, every temporary file goes, durations are kept, FLAC is served as audio/flac, and the
unit gets PrivateTmp. The instance API advertises the limits that are now applied.

No pasture scenario uploads audio or video through PrivaPub, so the sweep could not see this. MastodonMediaTests: v2
answers 202 then the job makes it playable (and an unreadable file 422 once processed), media still processing can't
be posted, MPEG-4 Part 2 becomes H.264, a video over the limit is made smaller, FLAC is served.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-07 10:48:56 +02:00
1 parent e4f9d0b61e
commit 8e59145826
14 files changed
+281 -48

No files matched your search

+2 -2
View File
@@ -46,7 +46,7 @@ namespace PrivaPub.Tests.Domain
var (_, alice) = await _harness.Persona("alice");
var (_, mallory) = await _harness.Persona("mallory");
var upload = await _harness.Media.Upload(alice, Upload(Png(300, 200), "image/png"), "a blue square", "0.25,-0.5", token);
var upload = await _harness.Media.Upload(alice, Upload(Png(300, 200), "image/png"), "a blue square", "0.25,-0.5", false, token);
Assert.True(upload.Ok);
Assert.True(File.Exists(Path.Combine(_harness.Media.Root, upload.Attachment.FilePath)));
@@ -73,7 +73,7 @@ namespace PrivaPub.Tests.Domain
{
var (_, alice) = await _harness.Persona("alice");
var outcome = await _harness.Media.Upload(alice, Upload(System.Text.Encoding.UTF8.GetBytes("<svg/>"), "image/svg+xml"), default, default, TestContext.Current.CancellationToken);
var outcome = await _harness.Media.Upload(alice, Upload(System.Text.Encoding.UTF8.GetBytes("<svg/>"), "image/svg+xml"), default, default, false, TestContext.Current.CancellationToken);
Assert.False(outcome.Ok);
Assert.Equal(422, outcome.Status);
+54 -3
View File
@@ -1,3 +1,4 @@
using PrivaPub.Models.Jobs;
using PrivaPub.Domain.Media;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
@@ -31,6 +32,14 @@ namespace PrivaPub.Tests.Http
static CancellationToken Token => TestContext.Current.CancellationToken;
// what GET /api/v1/media/:id answers once the job processing an upload sent to v2 has run
async Task<ApiAnswer> Processed(Mastodon account, ApiAnswer accepted)
{
var id = accepted.Body.Text("id");
await _host.Run(j => j.Kind == JobKind.ProcessMedia && j.Payload == id, Token);
return await account.Client.Get($"/api/v1/media/{id}");
}
static Task<ApiAnswer> Upload(Mastodon account, string path, byte[] bytes, string contentType, string fileName, params (string Key, string Value)[] fields)
{
var form = MastodonHelpers.Multipart(("file", bytes, contentType, fileName));
@@ -164,7 +173,11 @@ namespace PrivaPub.Tests.Http
Assert.Equal(HttpStatusCode.UnprocessableEntity, text.Status);
Assert.Contains("not supported", text.Body.Text("error"));
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await Upload(alice, "/api/v2/media", Encoding.UTF8.GetBytes("not a jpeg"), "image/jpeg", "a.jpg")).Status);
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await Upload(alice, "/api/v2/media", Encoding.UTF8.GetBytes("not a video"), "video/mp4", "a.mp4")).Status);
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await Upload(alice, "/api/v1/media", Encoding.UTF8.GetBytes("not a video"), "video/mp4", "a.mp4")).Status);
// sent to v2, it is taken, then refused once processed
var later = await Upload(alice, "/api/v2/media", Encoding.UTF8.GetBytes("not a video"), "video/mp4", "a.mp4");
Assert.Equal(HttpStatusCode.Accepted, later.Status);
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await Processed(alice, later)).Status);
var empty = new MultipartFormDataContent { { new StringContent("no file"), "description" } };
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Exchange(new HttpRequestMessage(HttpMethod.Post, "/api/v2/media") { Content = empty })).Status);
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/media")).Status);
@@ -213,7 +226,13 @@ namespace PrivaPub.Tests.Http
"-c:v", "mpeg4", "-c:a", "aac", "-shortest");
Assert.Contains("secret title", Encoding.Latin1.GetString(video));
var uploaded = (await Upload(alice, "/api/v2/media", video, "video/mp4", "clip.mp4")).Ok();
// v2 answers before it is processed: 202, no url yet, and it can't be posted until it is
var accepted = await Upload(alice, "/api/v2/media", video, "video/mp4", "clip.mp4");
Assert.Equal(HttpStatusCode.Accepted, accepted.Status);
Assert.Null(accepted.Body["url"]);
Assert.Equal(HttpStatusCode.PartialContent, (await alice.Client.Get($"/api/v1/media/{accepted.Body.Text("id")}")).Status);
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/statuses", ("status", "too soon"), ("media_ids[]", accepted.Body.Text("id")))).Status);
var uploaded = (await Processed(alice, accepted)).Ok();
Assert.Equal("video", uploaded.Body.Text("type"));
Assert.Equal(320, uploaded.Body["meta"]!["original"].Number("width"));
@@ -221,6 +240,8 @@ namespace PrivaPub.Tests.Http
Assert.EndsWith(".jpg", uploaded.Body.Text("preview_url"));
var probe = await Probe(uploaded.Body.Text("url"));
Assert.Contains("\"codec_type\": \"video\"", probe);
// MPEG-4 Part 2, which browsers don't play, made H.264
Assert.Contains("\"codec_name\": \"h264\"", probe);
Assert.DoesNotContain("secret title", probe);
Assert.DoesNotContain("filmed at home", probe);
Assert.DoesNotContain("hidden handler", probe);
@@ -233,7 +254,8 @@ namespace PrivaPub.Tests.Http
var audio = await Made("m4a", "-f", "lavfi", "-i", "sine=frequency=330:duration=1", "-metadata", "title=secret song", "-metadata", "artist=Alice Smith",
"-c:a", "aac");
var uploaded = (await Upload(alice, "/api/v2/media", audio, "audio/mp4", "song.m4a")).Ok();
// v1 waits for it
var uploaded = (await Upload(alice, "/api/v1/media", audio, "audio/mp4", "song.m4a")).Ok();
Assert.Equal("audio", uploaded.Body.Text("type"));
var probe = await Probe(uploaded.Body.Text("url"));
@@ -242,6 +264,35 @@ namespace PrivaPub.Tests.Http
Assert.DoesNotContain("Alice Smith", probe);
}
// FLAC is served as what it is (ASP.NET's map has no entry for it, so it was a 404)
[Fact]
public async Task Flac_is_served()
{
var alice = await _host.Mastodon("alice");
var flac = await Made("flac", "-f", "lavfi", "-i", "sine=frequency=500:duration=1", "-c:a", "flac");
var uploaded = (await Upload(alice, "/api/v1/media", flac, "audio/flac", "song.flac")).Ok();
var served = await _host.Client().GetAsync(new Uri(uploaded.Body.Text("url")).PathAndQuery, Token);
Assert.Equal(HttpStatusCode.OK, served.StatusCode);
Assert.Equal("audio/flac", served.Content.Headers.ContentType?.MediaType);
}
// a video larger than video_matrix_limit is made smaller, its shape kept
[Fact]
public async Task A_video_larger_than_the_limit_is_made_smaller()
{
var alice = await _host.Mastodon("alice");
var video = await Made("mp4", "-f", "lavfi", "-i", "testsrc=duration=1:size=2000x1500:rate=5", "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p");
var uploaded = (await Upload(alice, "/api/v1/media", video, "video/mp4", "big.mp4")).Ok();
var width = uploaded.Body["meta"]!["original"].Number("width");
var height = uploaded.Body["meta"]!["original"].Number("height");
Assert.True((long)width * height <= 2_304_000, $"{width}x{height}");
Assert.InRange((double)width / height, 1.3, 1.37);
}
static byte[] Bytes(int length)
{
var bytes = new byte[length];