An image is checked before it is decoded

An upload went straight to libvips: whatever loader recognised the bytes ran (an SVG sent as image/png was rasterised),
nothing bounded how many pixels it would decode to (a small PNG could decode to gigabytes, three times over), a GIF
was loaded frame by frame and never resized, and all of it ran inside the request with nothing limiting how many at
once. A GIF was typed gifv but stayed a .gif, which a gifv player can't play; its metadata was kept; colours lost
their ICC profile without being converted; HEIC was advertised but the bundled libvips can't decode it.

Now:
- only libvips' JPEG, PNG, GIF, WebP and HEIF loaders ever run on an upload (every other loader is blocked);
- the header alone says how big an image would decode, refused above Media:MaxPixels (40 MP) or MaxFrames;
- a still image is shrunk on load, turned by its orientation and brought into sRGB (thumbnail), then written without
  metadata, a profile picture the same way;
- an animated GIF becomes a looping silent H.264 mp4 typed gifv, as on Mastodon (PostMedia.Kind keeps it a gifv),
  and a remote GIF is an image;
- processing runs Media:Concurrency at a time, and uploads have their own rate limit per credential;
- HEIC and HEIF are no longer offered.

Tests: only the upload formats load, the header tells the size, an SVG posing as a PNG and an image too large are
refused before decoding, an animated GIF becomes a gifv and a still one an image, HEIC isn't advertised. The media
scenarios against the pasture (GoToSocial, Mastodon, Misskey, Akkoma, Pixelfed, Smithereen, Vernissage, Castopod,
PeerTube) pass: 329 checks.

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:44 +02:00
1 parent 65938bb2a0
commit e4f9d0b61e
14 files changed
+283 -54

No files matched your search

+18 -6
View File
@@ -296,9 +296,21 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
1. **No upload keeps its metadata.** Images are re-encoded by libvips with `keep=none`; audio and video are remuxed with 1. **No upload keeps its metadata.** Images are re-encoded by libvips with `keep=none`; audio and video are remuxed with
`-map_metadata -1`. `MediaProcessingTests` checks EXIF and XMP are gone. `-map_metadata -1`. `MediaProcessingTests` checks EXIF and XMP are gone.
2. Files live under `Media:Root` (`/var/lib/privapub/media`), never in the published directory; the proxy cache is the 2. **An image is checked before it is decoded.**
- **Loaders:** only libvips' JPEG, PNG, GIF, WebP and HEIF loaders ever run on an upload (`MediaService`'s static
constructor blocks every other loader), so an SVG, PDF or TIFF claiming another type never loads.
- **Size:** the header alone tells width × height (× frames for a GIF), refused above `Media:MaxPixels` (40 MP) or
`Media:MaxFrames`.
- **Then:** the image is shrunk on load to `MaxImageSide`, turned by its orientation, and its colours brought into
sRGB (`thumbnail`).
- **GIFs:** an animated GIF becomes a looping silent H.264 mp4 typed `gifv`, as on Mastodon (ffmpeg with libx264;
`PostMedia.Kind` says so), and a still GIF is an image.
- **Types:** HEIC and HEIF are not accepted, since the bundled libvips has no HEVC decoder.
- **Load:** processing runs `Media:Concurrency` at a time, and uploads have their own rate limit (`uploads`, per
credential).
3. Files live under `Media:Root` (`/var/lib/privapub/media`), never in the published directory; the proxy cache is the
sibling `media-proxy` and the trash the sibling `media-trash`, neither of which `/media/files` serves. sibling `media-proxy` and the trash the sibling `media-trash`, neither of which `/media/files` serves.
3. **A file lives exactly as long as something holds it.** Every upload is a `MediaAttachment` row, profile pictures 4. **A file lives exactly as long as something holds it.** Every upload is a `MediaAttachment` row, profile pictures
too (`Kind` avatar or header, `ProfileOfAvatarId`). Deleting a post, an edit leaving media out, a replaced picture, too (`Kind` avatar or header, `ProfileOfAvatarId`). Deleting a post, an edit leaving media out, a replaced picture,
a dropped scheduled post, a removed root, and an upload never posted for a day each trash theirs a dropped scheduled post, a removed root, and an upload never posted for a day each trash theirs
(`IMediaService.Trash`): (`IMediaService.Trash`):
@@ -309,18 +321,18 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
Nothing is deleted because it looks unused. `PrivaPub admin media audit [--fix]` compares disk and database: with Nothing is deleted because it looks unused. `PrivaPub admin media audit [--fix]` compares disk and database: with
`--fix` (as www-data) it gives pictures shown from before their rows a row, and trashes media of deleted posts or `--fix` (as www-data) it gives pictures shown from before their rows a row, and trashes media of deleted posts or
personas, rows whose files are missing, and files nothing holds. personas, rows whose files are missing, and files nothing holds.
4. **A client never contacts a remote server for media:** every remote URL the API returns goes through 5. **A client never contacts a remote server for media:** every remote URL the API returns goes through
`IMediaProxy.Wrap`, an HMAC-signed `/media/proxy/` URL fetched by `IFederationHttp.GetMedia`. `IMediaProxy.Wrap`, an HMAC-signed `/media/proxy/` URL fetched by `IFederationHttp.GetMedia`.
5. **The proxy serves three ways:** 6. **The proxy serves three ways:**
- **Cached:** a file already cached is served from disk, ranges included. - **Cached:** a file already cached is served from disk, ranges included.
- **Downloaded:** a request without a `Range` is downloaded whole, up to `Media:MaxProxiedBytes`, then cached. - **Downloaded:** a request without a `Range` is downloaded whole, up to `Media:MaxProxiedBytes`, then cached.
- **Streamed:** a ranged request, or anything too big to cache, is streamed from the origin with the range passed on, - **Streamed:** a ranged request, or anything too big to cache, is streamed from the origin with the range passed on,
and never cached. That is how remote video plays. and never cached. That is how remote video plays.
nginx has a `/media/proxy/` location with `proxy_buffering off` and a 600 s read timeout for those streams. nginx has a `/media/proxy/` location with `proxy_buffering off` and a 600 s read timeout for those streams.
6. **A focal point is two finite numbers** within -1..1 (`FocalPoint.Parse`); anything else is ignored. A stored NaN made 7. **A focal point is two finite numbers** within -1..1 (`FocalPoint.Parse`); anything else is ignored. A stored NaN made
every status, timeline and Note holding its post fail to serialise; migration `_016` removed the ones stored before. every status, timeline and Note holding its post fail to serialise; migration `_016` removed the ones stored before.
7. **Remote video and audio become one playable attachment** in the Mastodon API (`MastodonMapper.Playable`): the best MP4 8. **Remote video and audio become one playable attachment** in the Mastodon API (`MastodonMapper.Playable`): the best MP4
up to 720p that carries both sound and picture, including PeerTube's fragmented files inside an HLS entry. HLS up to 720p that carries both sound and picture, including PeerTube's fragmented files inside an HLS entry. HLS
playlists themselves are not rewritten. playlists themselves are not rewritten.
+34 -3
View File
@@ -19,6 +19,37 @@ namespace PrivaPub.Tests.Domain
return tagged.WriteToBuffer(".jpg"); return tagged.WriteToBuffer(".jpg");
} }
// SVG, PDF and the other formats libvips could read never load from an upload, whatever it claims to be
[Fact]
public void Only_the_upload_formats_are_ever_loaded()
{
var svg = "<svg xmlns='http://www.w3.org/2000/svg' width='20000' height='20000'><rect width='10' height='10'/></svg>"u8.ToArray();
Assert.Throws<VipsException>(() => MediaService.Inspect(svg));
Assert.Throws<VipsException>(() => MediaService.Inspect("%PDF-1.4\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF"u8.ToArray()));
using var colour = (Image.Black(8, 8, bands: 3) + 100).Cast(Enums.BandFormat.Uchar);
foreach (var format in new[] { ".jpg", ".png", ".gif", ".webp" })
Assert.Equal(8, MediaService.Inspect(colour.WriteToBuffer(format)).Width);
Assert.Throws<VipsException>(() => MediaService.Inspect(colour.WriteToBuffer(".tif")));
}
// the header tells how big an image would decode before anything is decoded
[Fact]
public void The_header_says_how_big_an_image_would_decode()
{
using var huge = Image.Black(8000, 6000);
var header = MediaService.Inspect(huge.WriteToBuffer(".png"));
Assert.Equal(48_000_000, header.Pixels);
Assert.False(header.Animated);
using var frame = (Image.Black(40, 30, bands: 3) + 60).Cast(Enums.BandFormat.Uchar);
using var frames = Image.Arrayjoin(new[] { frame, frame + 80, frame + 160 }, across: 1).Cast(Enums.BandFormat.Uchar);
using var paged = frames.Mutate(m => m.Set(GValue.GIntType, "page-height", 30));
var gif = MediaService.Inspect(paged.WriteToBuffer(".gif"));
Assert.True(gif.Animated);
Assert.Equal(3, gif.Pages);
Assert.Equal(40 * 30 * 3, gif.Pixels);
}
[Fact] [Fact]
public void Uploaded_images_lose_every_kind_of_metadata() public void Uploaded_images_lose_every_kind_of_metadata()
{ {
@@ -26,7 +57,7 @@ namespace PrivaPub.Tests.Domain
using (var original = Image.NewFromBuffer(input)) using (var original = Image.NewFromBuffer(input))
Assert.Contains("exif-data", original.GetFields()); Assert.Contains("exif-data", original.GetFields());
var processed = MediaService.ProcessImage(input, 4096, 640, animated: false); var processed = MediaService.ProcessImage(input, 4096, 640);
using var output = Image.NewFromBuffer(processed.Bytes); using var output = Image.NewFromBuffer(processed.Bytes);
var fields = output.GetFields(); var fields = output.GetFields();
@@ -44,7 +75,7 @@ namespace PrivaPub.Tests.Domain
[Fact] [Fact]
public void Large_images_are_capped() public void Large_images_are_capped()
{ {
var processed = MediaService.ProcessImage(JpegWithMetadata(5000, 2500), 4096, 640, animated: false); var processed = MediaService.ProcessImage(JpegWithMetadata(5000, 2500), 4096, 640);
Assert.Equal((4096, 2048), (processed.Width, processed.Height)); Assert.Equal((4096, 2048), (processed.Width, processed.Height));
} }
@@ -52,7 +83,7 @@ namespace PrivaPub.Tests.Domain
[Fact] [Fact]
public void A_blurhash_is_well_formed() public void A_blurhash_is_well_formed()
{ {
var processed = MediaService.ProcessImage(JpegWithMetadata(64, 64), 4096, 640, animated: false); var processed = MediaService.ProcessImage(JpegWithMetadata(64, 64), 4096, 640);
Assert.Equal(28, processed.Blurhash.Length); Assert.Equal(28, processed.Blurhash.Length);
Assert.Equal('L', processed.Blurhash[0]); Assert.Equal('L', processed.Blurhash[0]);
@@ -45,6 +45,8 @@ namespace PrivaPub.Tests.Http
Assert.Equal($"wss://{PrivaPubHost.Host}", v1.Body["urls"]!.Text("streaming_api")); Assert.Equal($"wss://{PrivaPubHost.Host}", v1.Body["urls"]!.Text("streaming_api"));
Assert.Equal(PrivaPub.Api.Mastodon.Controllers.StatusesController.MaxPins, v2.Body["configuration"]!["accounts"].Number("max_pinned_statuses")); Assert.Equal(PrivaPub.Api.Mastodon.Controllers.StatusesController.MaxPins, v2.Body["configuration"]!["accounts"].Number("max_pinned_statuses"));
Assert.Contains("image/avif", v2.Body["configuration"]!["media_attachments"]!["supported_mime_types"]!.AsArray().Select(t => t!.GetValue<string>())); Assert.Contains("image/avif", v2.Body["configuration"]!["media_attachments"]!["supported_mime_types"]!.AsArray().Select(t => t!.GetValue<string>()));
// what the bundled libvips can't decode is not offered
Assert.DoesNotContain("image/heic", v2.Body["configuration"]!["media_attachments"]!["supported_mime_types"]!.AsArray().Select(t => t!.GetValue<string>()));
Assert.True(v2.Body["registrations"].Flag("enabled")); Assert.True(v2.Body["registrations"].Flag("enabled"));
Assert.True((await anonymous.Get("/nodeinfo/2.1")).Ok().Body.Flag("openRegistrations")); Assert.True((await anonymous.Get("/nodeinfo/2.1")).Ok().Body.Flag("openRegistrations"));
Assert.False(v2.Body["configuration"]!["translation"].Flag("enabled")); Assert.False(v2.Body["configuration"]!["translation"].Flag("enabled"));
+34
View File
@@ -170,6 +170,40 @@ namespace PrivaPub.Tests.Http
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/media")).Status); Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/media")).Status);
} }
// refused before anything is decoded: an SVG claiming to be a PNG, and an image that would decode to too many pixels
[Fact]
public async Task An_image_that_is_not_one_or_too_large_is_refused_before_decoding()
{
var alice = await _host.Mastodon("alice");
var svg = "<svg xmlns='http://www.w3.org/2000/svg' width='30000' height='30000'><rect width='1' height='1'/></svg>"u8.ToArray();
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await Upload(alice, "/api/v2/media", svg, "image/png", "a.png")).Status);
using var huge = NetVips.Image.Black(8000, 6000);
var tooLarge = await Upload(alice, "/api/v2/media", huge.WriteToBuffer(".png"), "image/png", "huge.png");
Assert.Equal(HttpStatusCode.UnprocessableEntity, tooLarge.Status);
Assert.Contains("too large", tooLarge.Body.Text("error"));
}
// an animated GIF becomes what Mastodon makes of one, a looping mp4 typed gifv; a still GIF stays an image
[Fact]
public async Task An_animated_gif_becomes_a_gifv_and_a_still_one_an_image()
{
var alice = await _host.Mastodon("alice");
using var frame = (NetVips.Image.Black(64, 48, bands: 3) + 60).Cast(NetVips.Enums.BandFormat.Uchar);
using var frames = NetVips.Image.Arrayjoin(new[] { frame, frame + 80, frame + 160 }, across: 1).Cast(NetVips.Enums.BandFormat.Uchar);
using var animated = frames.Mutate(m => m.Set(NetVips.GValue.GIntType, "page-height", 48));
var gifv = (await Upload(alice, "/api/v2/media", animated.WriteToBuffer(".gif"), "image/gif", "dance.gif")).Ok();
Assert.Equal("gifv", gifv.Body.Text("type"));
Assert.EndsWith(".mp4", gifv.Body.Text("url"));
Assert.Equal(64, gifv.Body["meta"]!["original"]!["width"]!.GetValue<int>());
var status = (await alice.Client.Post("/api/v1/statuses", ("status", "dancing"), ("media_ids[]", gifv.Body.Text("id")))).Ok();
Assert.Equal("gifv", status.Body["media_attachments"]![0]!.Text("type"));
var still = (await Upload(alice, "/api/v2/media", frame.WriteToBuffer(".gif"), "image/gif", "still.gif")).Ok();
Assert.Equal("image", still.Body.Text("type"));
}
[Fact] [Fact]
public async Task Video_is_remuxed_without_its_metadata() public async Task Video_is_remuxed_without_its_metadata()
{ {
@@ -78,6 +78,7 @@ namespace PrivaPub.Tests.Support.Host
["Statistics:Geo:AutoUpdate"] = "false", ["Statistics:Geo:AutoUpdate"] = "false",
["Statistics:Cdn:AutoUpdate"] = "false", ["Statistics:Cdn:AutoUpdate"] = "false",
["Media:Root"] = _mediaRoot, ["Media:Root"] = _mediaRoot,
["RateLimits:UploadsBurst"] = "1000",
["Logging:LogLevel:Default"] = "Warning", ["Logging:LogLevel:Default"] = "Warning",
["Serilog:MinimumLevel:Default"] = Environment.GetEnvironmentVariable("PRIVAPUB_TEST_LOGS") == "1" ? "Information" : "Fatal" ["Serilog:MinimumLevel:Default"] = Environment.GetEnvironmentVariable("PRIVAPUB_TEST_LOGS") == "1" ? "Information" : "Fatal"
}; };
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using MongoDB.Entities; using MongoDB.Entities;
@@ -16,6 +17,7 @@ using PrivaPub.Models.Post;
using PrivaPub.Models.Social; using PrivaPub.Models.Social;
using PrivaPub.Models.User; using PrivaPub.Models.User;
using PrivaPub.StaticServices; using PrivaPub.StaticServices;
using PrivaPub.Infrastructure;
using PostEntity = PrivaPub.Models.Post.Post; using PostEntity = PrivaPub.Models.Post.Post;
@@ -46,7 +48,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpGet("/api/v1/accounts/verify_credentials"), Scope("read:accounts")] [HttpGet("/api/v1/accounts/verify_credentials"), Scope("read:accounts")]
public async Task<IActionResult> VerifyCredentials(CancellationToken token) => Json(await _mapper.Local(Me, withSource: true, token)); public async Task<IActionResult> VerifyCredentials(CancellationToken token) => Json(await _mapper.Local(Me, withSource: true, token));
[HttpPatch("/api/v1/accounts/update_credentials"), Scope("write:accounts"), RequestSizeLimit(20 * 1024 * 1024), [HttpPatch("/api/v1/accounts/update_credentials"), Scope("write:accounts"), EnableRateLimiting(RateLimiting.Uploads), RequestSizeLimit(20 * 1024 * 1024),
RequestFormLimits(MultipartBodyLengthLimit = 20 * 1024 * 1024)] RequestFormLimits(MultipartBodyLengthLimit = 20 * 1024 * 1024)]
public async Task<IActionResult> UpdateCredentials([FromServices] IMediaService media, CancellationToken token) public async Task<IActionResult> UpdateCredentials([FromServices] IMediaService media, CancellationToken token)
{ {
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.RateLimiting;
using PrivaPub.Infrastructure.Statistics; using PrivaPub.Infrastructure.Statistics;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -7,6 +8,7 @@ using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Infrastructure; using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Domain.Media; using PrivaPub.Domain.Media;
using PrivaPub.Models.Media; using PrivaPub.Models.Media;
using PrivaPub.Infrastructure;
using System.Globalization; using System.Globalization;
@@ -28,7 +30,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
_ledger = ledger; _ledger = ledger;
} }
[HttpPost("/api/v1/media"), HttpPost("/api/v2/media"), Scope("write:media"), RequestSizeLimit(UploadLimit), [HttpPost("/api/v1/media"), HttpPost("/api/v2/media"), Scope("write:media"), EnableRateLimiting(RateLimiting.Uploads), RequestSizeLimit(UploadLimit),
RequestFormLimits(MultipartBodyLengthLimit = UploadLimit)] RequestFormLimits(MultipartBodyLengthLimit = UploadLimit)]
public async Task<IActionResult> Upload(CancellationToken token) public async Task<IActionResult> Upload(CancellationToken token)
{ {
@@ -650,9 +650,9 @@ namespace PrivaPub.Api.Mastodon.Mappers
MediaAttachment Media(PostMedia media) => new() MediaAttachment Media(PostMedia media) => new()
{ {
Id = media.AttachmentId ?? media.Id.ToString("N"), Id = media.AttachmentId ?? media.Id.ToString("N"),
Type = media.ContentType switch // a GIF is a gifv only once it is an mp4 (ours): a GIF itself is an image, which a gifv player can't play
Type = media.Kind == "gifv" ? "gifv" : media.ContentType switch
{ {
{ } type when type.StartsWith("image/gif") => "gifv",
{ } type when type.StartsWith("image/") => "image", { } type when type.StartsWith("image/") => "image",
{ } type when type.StartsWith("video/") => "video", { } type when type.StartsWith("video/") => "video",
{ } type when type.StartsWith("audio/") => "audio", { } type when type.StartsWith("audio/") => "audio",
+4
View File
@@ -9,5 +9,9 @@ namespace PrivaPub.Domain.Media
public long ProxyCacheBytes { get; set; } = 5L * 1024 * 1024 * 1024; public long ProxyCacheBytes { get; set; } = 5L * 1024 * 1024 * 1024;
public int MaxImageSide { get; set; } = 4096; public int MaxImageSide { get; set; } = 4096;
public int PreviewSide { get; set; } = 640; public int PreviewSide { get; set; } = 640;
public long MaxPixels { get; set; } = 40_000_000;//an image (or a GIF's frames together) may not decode to more
public int MaxFrames { get; set; } = 500;
public int MaxGifSide { get; set; } = 1280;//a GIF becomes an mp4 at most this wide
public int Concurrency { get; set; } = 2;//uploads processed at once
} }
} }
+157 -40
View File
@@ -22,6 +22,13 @@ namespace PrivaPub.Domain.Media
public sealed record ProcessedImage(byte[] Bytes, string Extension, string ContentType, int Width, int Height, byte[] Preview, string Blurhash); public sealed record ProcessedImage(byte[] Bytes, string Extension, string ContentType, int Width, int Height, byte[] Preview, string Blurhash);
// what libvips makes of an upload's header alone, before anything is decoded
public sealed record ImageHeader(string Loader, int Width, int PageHeight, int Pages)
{
public bool Animated => Pages > 1 && Loader?.StartsWith("gifload", StringComparison.Ordinal) == true;
public long Pixels => (long)Width * PageHeight * (Animated ? Pages : 1);
}
public interface IMediaService public interface IMediaService
{ {
string Root { get; } string Root { get; }
@@ -45,7 +52,11 @@ namespace PrivaPub.Domain.Media
public class MediaService : IMediaService public class MediaService : IMediaService
{ {
static readonly string[] ImageTypes = { "image/jpeg", "image/png", "image/gif", "image/webp", "image/heic", "image/heif", "image/avif" }; // HEIC and HEIF are not among them: the libvips bundled here decodes AVIF but has no HEVC decoder
static readonly string[] ImageTypes = { "image/jpeg", "image/png", "image/gif", "image/webp", "image/avif" };
// the only loaders an upload ever reaches: everything else libvips could read (SVG, PDF, TIFF, ImageMagick, ...) is
// blocked, whatever type the upload claims
static readonly string[] Loaders = { "VipsForeignLoadJpeg", "VipsForeignLoadPng", "VipsForeignLoadNsgif", "VipsForeignLoadWebp", "VipsForeignLoadHeif" };
static readonly Dictionary<string, string> AvTypes = new() static readonly Dictionary<string, string> AvTypes = new()
{ {
["video/mp4"] = "mp4", ["video/quicktime"] = "mp4", ["video/webm"] = "webm", ["video/mp4"] = "mp4", ["video/quicktime"] = "mp4", ["video/webm"] = "webm",
@@ -56,8 +67,18 @@ namespace PrivaPub.Domain.Media
// what an upload may be, as the instance API advertises it // what an upload may be, as the instance API advertises it
public static IReadOnlyList<string> SupportedTypes => ImageTypes.Concat(AvTypes.Keys).ToList(); public static IReadOnlyList<string> SupportedTypes => ImageTypes.Concat(AvTypes.Keys).ToList();
static MediaService()
{
NetVips.NetVips.BlockUntrusted = true;
Operation.Block("VipsForeignLoad", true);
foreach (var loader in Loaders)
Operation.Block(loader, false);
Cache.Max = 0;//an upload is decoded once: nothing to keep between operations
}
readonly IOptionsMonitor<MediaOptions> _options; readonly IOptionsMonitor<MediaOptions> _options;
readonly ILocalActorService _localActors; readonly ILocalActorService _localActors;
readonly SemaphoreSlim _processing;
readonly IWebHostEnvironment _environment; readonly IWebHostEnvironment _environment;
readonly ILogger<MediaService> _logger; readonly ILogger<MediaService> _logger;
@@ -65,6 +86,7 @@ namespace PrivaPub.Domain.Media
{ {
_options = options; _options = options;
_localActors = localActors; _localActors = localActors;
_processing = new SemaphoreSlim(Math.Max(1, options.CurrentValue.Concurrency));
_environment = environment; _environment = environment;
_logger = logger; _logger = logger;
} }
@@ -94,20 +116,23 @@ namespace PrivaPub.Domain.Media
{ {
if (file.Length > options.MaxImageBytes) if (file.Length > options.MaxImageBytes)
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File is too big"); return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File is too big");
await using var stream = file.OpenReadStream(); var bytes = await Read(file, token);
using var buffer = new MemoryStream(); if (Refusal(bytes, options) is { } refused)
await stream.CopyToAsync(buffer, token); return refused;
ProcessedImage processed; await _processing.WaitAsync(token);
try try
{ {
processed = ProcessImage(buffer.ToArray(), options.MaxImageSide, options.PreviewSide, contentType == "image/gif"); var header = Inspect(bytes);
} if (header.Animated)
catch (VipsException ex)
{ {
_logger.LogInformation("Refused an image upload: {Error}", ex.Message); var animated = await ProcessAnimation(bytes, header, attachment, token);
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The file is not a readable image"); if (!animated.Ok)
return animated;
} }
attachment.Kind = contentType == "image/gif" ? "gifv" : "image"; else
{
var processed = ProcessImage(bytes, options.MaxImageSide, options.PreviewSide);
attachment.Kind = "image";
attachment.ContentType = processed.ContentType; attachment.ContentType = processed.ContentType;
attachment.FilePath = await Save(processed.Bytes, processed.Extension, token); attachment.FilePath = await Save(processed.Bytes, processed.Extension, token);
attachment.PreviewPath = await Save(processed.Preview, "jpg", token); attachment.PreviewPath = await Save(processed.Preview, "jpg", token);
@@ -116,6 +141,17 @@ namespace PrivaPub.Domain.Media
attachment.Blurhash = processed.Blurhash; attachment.Blurhash = processed.Blurhash;
attachment.Size = processed.Bytes.Length; attachment.Size = processed.Bytes.Length;
} }
}
catch (VipsException ex)
{
_logger.LogInformation("Refused an image upload: {Error}", ex.Message);
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The file is not a readable image");
}
finally
{
_processing.Release();
}
}
else if (contentType != default && AvTypes.TryGetValue(contentType, out var extension)) else if (contentType != default && AvTypes.TryGetValue(contentType, out var extension))
{ {
if (file.Length > options.MaxVideoBytes) if (file.Length > options.MaxVideoBytes)
@@ -140,14 +176,15 @@ namespace PrivaPub.Domain.Media
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File type is not supported"); return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File type is not supported");
if (file.Length > _options.CurrentValue.MaxImageBytes) if (file.Length > _options.CurrentValue.MaxImageBytes)
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File is too big"); return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File is too big");
await using var stream = file.OpenReadStream(); var input = await Read(file, token);
using var buffer = new MemoryStream(); if (Refusal(input, _options.CurrentValue) is { } refused)
await stream.CopyToAsync(buffer, token); return refused;
byte[] bytes; byte[] bytes;
int outWidth, outHeight; int outWidth, outHeight;
await _processing.WaitAsync(token);
try try
{ {
using var image = Image.ThumbnailBuffer(buffer.ToArray(), width, height: height, crop: Enums.Interesting.Centre, size: Enums.Size.Down); using var image = Image.ThumbnailBuffer(input, width, height: height, crop: Enums.Interesting.Centre, size: Enums.Size.Down);
using var flat = Flatten(image); using var flat = Flatten(image);
bytes = flat.WriteToBuffer(".jpg[Q=85,keep=none]"); bytes = flat.WriteToBuffer(".jpg[Q=85,keep=none]");
(outWidth, outHeight) = (flat.Width, flat.Height); (outWidth, outHeight) = (flat.Width, flat.Height);
@@ -157,6 +194,10 @@ namespace PrivaPub.Domain.Media
_logger.LogInformation("Refused a profile picture: {Error}", ex.Message); _logger.LogInformation("Refused a profile picture: {Error}", ex.Message);
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The file is not a readable image"); return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The file is not a readable image");
} }
finally
{
_processing.Release();
}
var attachment = new MediaAttachment var attachment = new MediaAttachment
{ {
OwnerAvatarId = avatarId, OwnerAvatarId = avatarId,
@@ -213,38 +254,101 @@ namespace PrivaPub.Domain.Media
await DB.Default.DeleteAsync<MediaAttachment>(m => m.ID == trashed.ID && m.TrashedAt != null); await DB.Default.DeleteAsync<MediaAttachment>(m => m.ID == trashed.ID && m.TrashedAt != null);
} }
public static ProcessedImage ProcessImage(byte[] input, int maxSide, int previewSide, bool animated) // reads only the header: which loader takes the bytes and how big they would decode (throws VipsException when none)
public static ImageHeader Inspect(byte[] input)
{ {
using var loaded = animated ? Image.NewFromBuffer(input, kwargs: new VOption { { "n", -1 } }) : Image.NewFromBuffer(input); using var image = Image.NewFromBuffer(input, access: Enums.Access.Sequential);
using var rotated = animated ? loaded.Copy() : loaded.Autorot(); var pages = image.Contains("n-pages") ? Math.Max(1, (int)image.Get("n-pages")) : 1;
var pageHeight = animated && rotated.Contains("page-height") ? (int)rotated.Get("page-height") : rotated.Height; var pageHeight = image.Contains("page-height") ? (int)image.Get("page-height") : image.Height;
var longest = Math.Max(rotated.Width, pageHeight); return new ImageHeader(image.Contains("vips-loader") ? (string)image.Get("vips-loader") : default, image.Width, pageHeight, pages);
byte[] bytes;
string extension, contentType;
int width, height;
if (animated)
{
bytes = rotated.WriteToBuffer(".gif");
(extension, contentType, width, height) = ("gif", "image/gif", rotated.Width, pageHeight);
} }
else
// an upload refused before anything is decoded: no loader takes it, or it would decode to too many pixels or frames
static MediaOutcome Refusal(byte[] input, MediaOptions options)
{ {
using var resized = longest > maxSide ? rotated.ThumbnailImage(maxSide, height: maxSide, size: Enums.Size.Down) : rotated.Copy(); ImageHeader header;
try
{
header = Inspect(input);
}
catch (VipsException)
{
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The file is not a readable image");
}
if (header.Pixels > options.MaxPixels || header.Pages > options.MaxFrames)
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The image is too large");
return default;
}
// a still image: shrunk on load to the largest side allowed, turned by its orientation, its colours brought into sRGB
// (thumbnail does all three), then written without its metadata, with a preview and a blurhash
public static ProcessedImage ProcessImage(byte[] input, int maxSide, int previewSide)
{
// rendered into memory once (it is read three times below, and a shrunk-on-load image can be read only in order)
using var shrunk = Image.ThumbnailBuffer(input, maxSide, height: maxSide, size: Enums.Size.Down);
using var resized = shrunk.CopyMemory();
var keepsAlpha = resized.HasAlpha(); var keepsAlpha = resized.HasAlpha();
bytes = keepsAlpha ? resized.WriteToBuffer(".png[keep=none]") : resized.WriteToBuffer(".jpg[Q=88,keep=none]"); var bytes = keepsAlpha ? resized.WriteToBuffer(".png[keep=none]") : resized.WriteToBuffer(".jpg[Q=88,keep=none]");
(extension, contentType, width, height) = keepsAlpha ? ("png", "image/png", resized.Width, resized.Height) : ("jpg", "image/jpeg", resized.Width, resized.Height); var (extension, contentType) = keepsAlpha ? ("png", "image/png") : ("jpg", "image/jpeg");
}
using var firstFrame = animated ? rotated.Crop(0, 0, rotated.Width, pageHeight) : rotated.Copy(); using var preview = resized.ThumbnailImage(previewSide, height: previewSide, size: Enums.Size.Down);
using var preview = firstFrame.ThumbnailImage(previewSide, height: previewSide, size: Enums.Size.Down);
using var previewFlat = Flatten(preview); using var previewFlat = Flatten(preview);
var previewBytes = previewFlat.WriteToBuffer(".jpg[Q=80,keep=none]"); var previewBytes = previewFlat.WriteToBuffer(".jpg[Q=80,keep=none]");
using var tiny = firstFrame.ThumbnailImage(32, height: 32); using var tiny = resized.ThumbnailImage(32, height: 32);
using var tinyFlat = Flatten(tiny); using var tinyFlat = Flatten(tiny);
var pixels = tinyFlat.WriteToMemory(); var pixels = tinyFlat.WriteToMemory();
return new ProcessedImage(bytes, extension, contentType, width, height, previewBytes, Blurhash.Encode(pixels, tinyFlat.Width, tinyFlat.Height)); return new ProcessedImage(bytes, extension, contentType, resized.Width, resized.Height, previewBytes, Blurhash.Encode(pixels, tinyFlat.Width, tinyFlat.Height));
}
// an animated GIF becomes what Mastodon makes of one: a silent, looping H.264 mp4 (a gifv) at most MaxGifSide wide,
// its first frame the poster
async Task<MediaOutcome> ProcessAnimation(byte[] input, ImageHeader header, MediaAttachment attachment, CancellationToken token)
{
var options = _options.CurrentValue;
var temp = Path.Combine(Path.GetTempPath(), $"privapub-{Guid.NewGuid():N}");
var gif = temp + ".gif";
var mp4 = temp + ".mp4";
try
{
await File.WriteAllBytesAsync(gif, input, token);
var width = Math.Min(header.Width, options.MaxGifSide) / 2 * 2;
var height = Math.Max(2, (int)Math.Round(header.PageHeight * (double)width / header.Width / 2) * 2);
await FFMpegArguments.FromFileInput(gif, true, o => o.ForceFormat("gif").WithCustomArgument("-protocol_whitelist file"))
.OutputToFile(mp4, true, o => o.WithCustomArgument(
$"-an -map_metadata -1 -map_chapters -1 -c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p -movflags +faststart -vf scale={width}:{height}"))
.CancellableThrough(token)
.ProcessAsynchronously();
var poster = ProcessImage(input, options.PreviewSide, options.PreviewSide);
attachment.Kind = "gifv";
attachment.ContentType = "video/mp4";
attachment.Width = width;
attachment.Height = height;
attachment.Size = new FileInfo(mp4).Length;
attachment.Blurhash = poster.Blurhash;
attachment.PreviewPath = await Save(poster.Preview, "jpg", token);
attachment.FilePath = SaveFile(mp4, "mp4");
return new MediaOutcome(attachment);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogInformation(ex, "Refused a GIF upload");
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The file could not be processed");
}
finally
{
foreach (var path in new[] { gif, mp4 })
if (File.Exists(path))
File.Delete(path);
}
}
static async Task<byte[]> Read(IFormFile file, CancellationToken token)
{
await using var stream = file.OpenReadStream();
using var buffer = new MemoryStream((int)Math.Min(file.Length, int.MaxValue));
await stream.CopyToAsync(buffer, token);
return buffer.ToArray();
} }
static Image Flatten(Image image) static Image Flatten(Image image)
@@ -284,7 +388,7 @@ namespace PrivaPub.Domain.Media
attachment.Height = probe.PrimaryVideoStream.Height; attachment.Height = probe.PrimaryVideoStream.Height;
var frame = temp + ".png"; var frame = temp + ".png";
await FFMpeg.SnapshotAsync(input, frame, captureTime: TimeSpan.FromSeconds(Math.Min(1, probe.Duration.TotalSeconds / 2))); await FFMpeg.SnapshotAsync(input, frame, captureTime: TimeSpan.FromSeconds(Math.Min(1, probe.Duration.TotalSeconds / 2)));
var still = ProcessImage(await File.ReadAllBytesAsync(frame, token), 640, _options.CurrentValue.PreviewSide, false); var still = ProcessImage(await File.ReadAllBytesAsync(frame, token), 640, _options.CurrentValue.PreviewSide);
attachment.PreviewPath = await Save(still.Preview, "jpg", token); attachment.PreviewPath = await Save(still.Preview, "jpg", token);
attachment.Blurhash = still.Blurhash; attachment.Blurhash = still.Blurhash;
File.Delete(frame); File.Delete(frame);
@@ -304,15 +408,28 @@ namespace PrivaPub.Domain.Media
} }
} }
async Task<string> Save(byte[] bytes, string extension, CancellationToken token) string SaveFile(string source, string extension)
{
var (relative, full) = NewPath(extension);
File.Move(source, full);
return relative;
}
(string Relative, string Full) NewPath(string extension)
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var relative = Path.Combine(now.ToString("yyyy", CultureInfo.InvariantCulture), now.ToString("MM", CultureInfo.InvariantCulture), var relative = Path.Combine(now.ToString("yyyy", CultureInfo.InvariantCulture), now.ToString("MM", CultureInfo.InvariantCulture),
$"{Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16))}.{extension}"); $"{Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16))}.{extension}");
var full = Path.Combine(Root, relative); var full = Path.Combine(Root, relative);
Directory.CreateDirectory(Path.GetDirectoryName(full)!); Directory.CreateDirectory(Path.GetDirectoryName(full)!);
return (relative.Replace('\\', '/'), full);
}
async Task<string> Save(byte[] bytes, string extension, CancellationToken token)
{
var (relative, full) = NewPath(extension);
await File.WriteAllBytesAsync(full, bytes, token); await File.WriteAllBytesAsync(full, bytes, token);
return relative.Replace('\\', '/'); return relative;
} }
static string Clean(string value, int max) => static string Clean(string value, int max) =>
@@ -731,6 +731,7 @@ namespace PrivaPub.Domain.Statuses
{ {
AttachmentId = attachment.ID, AttachmentId = attachment.ID,
ContentType = attachment.ContentType, ContentType = attachment.ContentType,
Kind = attachment.Kind,
URL = _media.Url(attachment.FilePath), URL = _media.Url(attachment.FilePath),
PreviewURL = _media.Url(attachment.PreviewPath ?? attachment.FilePath), PreviewURL = _media.Url(attachment.PreviewPath ?? attachment.FilePath),
Description = attachment.Description, Description = attachment.Description,
+22
View File
@@ -15,12 +15,15 @@ namespace PrivaPub.Infrastructure
public int AccountsPerMinute { get; set; } = 10;//sign-ups, sign-ins and recoveries per client address public int AccountsPerMinute { get; set; } = 10;//sign-ups, sign-ins and recoveries per client address
public int InboxBurst { get; set; } = 300;//deliveries a sending origin may make at once public int InboxBurst { get; set; } = 300;//deliveries a sending origin may make at once
public int InboxPerTenSeconds { get; set; } = 50;//and the rate it earns them back public int InboxPerTenSeconds { get; set; } = 50;//and the rate it earns them back
public int UploadsBurst { get; set; } = 30;//uploads (media, profile pictures) a session may make at once
public int UploadsPerMinute { get; set; } = 10;//and the rate it earns them back
} }
public static class RateLimiting public static class RateLimiting
{ {
public const string Accounts = "accounts"; public const string Accounts = "accounts";
public const string Inbox = "inbox"; public const string Inbox = "inbox";
public const string Uploads = "uploads";
static RateLimitOptions Limits(HttpContext context) => context.RequestServices.GetRequiredService<IOptions<RateLimitOptions>>().Value; static RateLimitOptions Limits(HttpContext context) => context.RequestServices.GetRequiredService<IOptions<RateLimitOptions>>().Value;
@@ -59,8 +62,27 @@ namespace PrivaPub.Infrastructure
ReplenishmentPeriod = TimeSpan.FromSeconds(10), ReplenishmentPeriod = TimeSpan.FromSeconds(10),
QueueLimit = 0 QueueLimit = 0
})); }));
// per session: the limiter runs before authentication, so the credential sent stands for whoever sends it
options.AddPolicy(Uploads, context => RateLimitPartition.GetTokenBucketLimiter(
Credential(context.Request) ?? "anonymous:" + context.Connection.RemoteIpAddress,
_ => new TokenBucketRateLimiterOptions
{
TokenLimit = Limits(context).UploadsBurst,
TokensPerPeriod = Limits(context).UploadsPerMinute,
ReplenishmentPeriod = TimeSpan.FromMinutes(1),
QueueLimit = 0
}));
}); });
// a hash of the credential, never the credential itself
static string Credential(HttpRequest request)
{
var authorization = request.Headers.Authorization.ToString();
return string.IsNullOrEmpty(authorization)
? default
: Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(authorization)))[..32];
}
static string SenderOrigin(HttpRequest request) static string SenderOrigin(HttpRequest request)
{ {
var signature = request.Headers["Signature"].ToString(); var signature = request.Headers["Signature"].ToString();
+1
View File
@@ -4,6 +4,7 @@ namespace PrivaPub.Models.Post
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public string ContentType { get; set; } public string ContentType { get; set; }
public string Kind { get; set; }//a local upload's kind ("gifv" for a GIF made an mp4), where the type alone can't tell
public string FileName { get; set; } public string FileName { get; set; }
public string Extension { get; set; } public string Extension { get; set; }
public string Path { get; set; } public string Path { get; set; }
+1 -1
View File
@@ -26,7 +26,7 @@
"Relays": [ "https://relay.test/actor", "https://aoderelay.test/actor" ] "Relays": [ "https://relay.test/actor", "https://aoderelay.test/actor" ]
}, },
"Media": { "Root": "/tmp/privapub-media" }, "Media": { "Root": "/tmp/privapub-media" },
"RateLimits": { "AccountsPerMinute": 1000 }, "RateLimits": { "AccountsPerMinute": 1000, "UploadsBurst": 1000, "UploadsPerMinute": 1000 },
"Registrations": { "Mode": "Open" }, "Registrations": { "Mode": "Open" },
"Statistics": { "Geo": { "AutoUpdate": false } }, "Statistics": { "Geo": { "AutoUpdate": false } },
"Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:80", "Protocols": "Http1AndHttp2" } } }, "Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:80", "Protocols": "Http1AndHttp2" } } },