diff --git a/CLAUDE.md b/CLAUDE.md index be5a6f4..5236c11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `-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. -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, a dropped scheduled post, a removed root, and an upload never posted for a day each trash theirs (`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 `--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. -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`. -5. **The proxy serves three ways:** +6. **The proxy serves three ways:** - **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. - **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. 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. -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 playlists themselves are not rewritten. diff --git a/PrivaPub.Tests/Domain/MediaProcessingTests.cs b/PrivaPub.Tests/Domain/MediaProcessingTests.cs index 65c80af..1563a4c 100644 --- a/PrivaPub.Tests/Domain/MediaProcessingTests.cs +++ b/PrivaPub.Tests/Domain/MediaProcessingTests.cs @@ -19,6 +19,37 @@ namespace PrivaPub.Tests.Domain 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 = ""u8.ToArray(); + Assert.Throws(() => MediaService.Inspect(svg)); + Assert.Throws(() => 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(() => 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] public void Uploaded_images_lose_every_kind_of_metadata() { @@ -26,7 +57,7 @@ namespace PrivaPub.Tests.Domain using (var original = Image.NewFromBuffer(input)) 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); var fields = output.GetFields(); @@ -44,7 +75,7 @@ namespace PrivaPub.Tests.Domain [Fact] 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)); } @@ -52,7 +83,7 @@ namespace PrivaPub.Tests.Domain [Fact] 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('L', processed.Blurhash[0]); diff --git a/PrivaPub.Tests/Http/MastodonInstanceTests.cs b/PrivaPub.Tests/Http/MastodonInstanceTests.cs index 3231071..23b354a 100644 --- a/PrivaPub.Tests/Http/MastodonInstanceTests.cs +++ b/PrivaPub.Tests/Http/MastodonInstanceTests.cs @@ -45,6 +45,8 @@ namespace PrivaPub.Tests.Http 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.Contains("image/avif", v2.Body["configuration"]!["media_attachments"]!["supported_mime_types"]!.AsArray().Select(t => t!.GetValue())); + // 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())); Assert.True(v2.Body["registrations"].Flag("enabled")); Assert.True((await anonymous.Get("/nodeinfo/2.1")).Ok().Body.Flag("openRegistrations")); Assert.False(v2.Body["configuration"]!["translation"].Flag("enabled")); diff --git a/PrivaPub.Tests/Http/MastodonMediaTests.cs b/PrivaPub.Tests/Http/MastodonMediaTests.cs index cbd03b8..2e87c0e 100644 --- a/PrivaPub.Tests/Http/MastodonMediaTests.cs +++ b/PrivaPub.Tests/Http/MastodonMediaTests.cs @@ -170,6 +170,40 @@ namespace PrivaPub.Tests.Http 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 = ""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()); + 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] public async Task Video_is_remuxed_without_its_metadata() { diff --git a/PrivaPub.Tests/Support/Host/PrivaPubHost.cs b/PrivaPub.Tests/Support/Host/PrivaPubHost.cs index 19d7bc4..975b434 100644 --- a/PrivaPub.Tests/Support/Host/PrivaPubHost.cs +++ b/PrivaPub.Tests/Support/Host/PrivaPubHost.cs @@ -78,6 +78,7 @@ namespace PrivaPub.Tests.Support.Host ["Statistics:Geo:AutoUpdate"] = "false", ["Statistics:Cdn:AutoUpdate"] = "false", ["Media:Root"] = _mediaRoot, + ["RateLimits:UploadsBurst"] = "1000", ["Logging:LogLevel:Default"] = "Warning", ["Serilog:MinimumLevel:Default"] = Environment.GetEnvironmentVariable("PRIVAPUB_TEST_LOGS") == "1" ? "Information" : "Fatal" }; diff --git a/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs index 3f6aa78..ec3e426 100644 --- a/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.Mvc; using MongoDB.Entities; @@ -16,6 +17,7 @@ using PrivaPub.Models.Post; using PrivaPub.Models.Social; using PrivaPub.Models.User; using PrivaPub.StaticServices; +using PrivaPub.Infrastructure; using PostEntity = PrivaPub.Models.Post.Post; @@ -46,7 +48,7 @@ namespace PrivaPub.Api.Mastodon.Controllers [HttpGet("/api/v1/accounts/verify_credentials"), Scope("read:accounts")] public async Task 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)] public async Task UpdateCredentials([FromServices] IMediaService media, CancellationToken token) { diff --git a/PrivaPub/Api/Mastodon/Controllers/MediaController.cs b/PrivaPub/Api/Mastodon/Controllers/MediaController.cs index 2aee672..4b9cd7f 100644 --- a/PrivaPub/Api/Mastodon/Controllers/MediaController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/MediaController.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.RateLimiting; using PrivaPub.Infrastructure.Statistics; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -7,6 +8,7 @@ using MongoDB.Entities; using PrivaPub.Api.Mastodon.Infrastructure; using PrivaPub.Domain.Media; using PrivaPub.Models.Media; +using PrivaPub.Infrastructure; using System.Globalization; @@ -28,7 +30,7 @@ namespace PrivaPub.Api.Mastodon.Controllers _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)] public async Task Upload(CancellationToken token) { diff --git a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs index eefc19f..bf68a26 100644 --- a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs +++ b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs @@ -650,9 +650,9 @@ namespace PrivaPub.Api.Mastodon.Mappers MediaAttachment Media(PostMedia media) => new() { 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("video/") => "video", { } type when type.StartsWith("audio/") => "audio", diff --git a/PrivaPub/Domain/Media/MediaOptions.cs b/PrivaPub/Domain/Media/MediaOptions.cs index 3200d37..a739883 100644 --- a/PrivaPub/Domain/Media/MediaOptions.cs +++ b/PrivaPub/Domain/Media/MediaOptions.cs @@ -9,5 +9,9 @@ namespace PrivaPub.Domain.Media public long ProxyCacheBytes { get; set; } = 5L * 1024 * 1024 * 1024; public int MaxImageSide { get; set; } = 4096; 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 } } diff --git a/PrivaPub/Domain/Media/MediaService.cs b/PrivaPub/Domain/Media/MediaService.cs index 4ad63ce..14b2f61 100644 --- a/PrivaPub/Domain/Media/MediaService.cs +++ b/PrivaPub/Domain/Media/MediaService.cs @@ -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); + // 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 { string Root { get; } @@ -45,7 +52,11 @@ namespace PrivaPub.Domain.Media 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 AvTypes = new() { ["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 public static IReadOnlyList 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 _options; readonly ILocalActorService _localActors; + readonly SemaphoreSlim _processing; readonly IWebHostEnvironment _environment; readonly ILogger _logger; @@ -65,6 +86,7 @@ namespace PrivaPub.Domain.Media { _options = options; _localActors = localActors; + _processing = new SemaphoreSlim(Math.Max(1, options.CurrentValue.Concurrency)); _environment = environment; _logger = logger; } @@ -94,27 +116,41 @@ namespace PrivaPub.Domain.Media { if (file.Length > options.MaxImageBytes) return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File is too big"); - await using var stream = file.OpenReadStream(); - using var buffer = new MemoryStream(); - await stream.CopyToAsync(buffer, token); - ProcessedImage processed; + var bytes = await Read(file, token); + if (Refusal(bytes, options) is { } refused) + return refused; + await _processing.WaitAsync(token); try { - processed = ProcessImage(buffer.ToArray(), options.MaxImageSide, options.PreviewSide, contentType == "image/gif"); + var header = Inspect(bytes); + if (header.Animated) + { + var animated = await ProcessAnimation(bytes, header, attachment, token); + if (!animated.Ok) + return animated; + } + else + { + var processed = ProcessImage(bytes, options.MaxImageSide, options.PreviewSide); + attachment.Kind = "image"; + attachment.ContentType = processed.ContentType; + attachment.FilePath = await Save(processed.Bytes, processed.Extension, token); + attachment.PreviewPath = await Save(processed.Preview, "jpg", token); + attachment.Width = processed.Width; + attachment.Height = processed.Height; + attachment.Blurhash = processed.Blurhash; + 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"); } - attachment.Kind = contentType == "image/gif" ? "gifv" : "image"; - attachment.ContentType = processed.ContentType; - attachment.FilePath = await Save(processed.Bytes, processed.Extension, token); - attachment.PreviewPath = await Save(processed.Preview, "jpg", token); - attachment.Width = processed.Width; - attachment.Height = processed.Height; - attachment.Blurhash = processed.Blurhash; - attachment.Size = processed.Bytes.Length; + finally + { + _processing.Release(); + } } else if (contentType != default && AvTypes.TryGetValue(contentType, out var extension)) { @@ -140,14 +176,15 @@ namespace PrivaPub.Domain.Media return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File type is not supported"); if (file.Length > _options.CurrentValue.MaxImageBytes) return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File is too big"); - await using var stream = file.OpenReadStream(); - using var buffer = new MemoryStream(); - await stream.CopyToAsync(buffer, token); + var input = await Read(file, token); + if (Refusal(input, _options.CurrentValue) is { } refused) + return refused; byte[] bytes; int outWidth, outHeight; + await _processing.WaitAsync(token); 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); bytes = flat.WriteToBuffer(".jpg[Q=85,keep=none]"); (outWidth, outHeight) = (flat.Width, flat.Height); @@ -157,6 +194,10 @@ namespace PrivaPub.Domain.Media _logger.LogInformation("Refused a profile picture: {Error}", ex.Message); return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The file is not a readable image"); } + finally + { + _processing.Release(); + } var attachment = new MediaAttachment { OwnerAvatarId = avatarId, @@ -213,38 +254,101 @@ namespace PrivaPub.Domain.Media await DB.Default.DeleteAsync(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 rotated = animated ? loaded.Copy() : loaded.Autorot(); - var pageHeight = animated && rotated.Contains("page-height") ? (int)rotated.Get("page-height") : rotated.Height; - var longest = Math.Max(rotated.Width, pageHeight); + using var image = Image.NewFromBuffer(input, access: Enums.Access.Sequential); + var pages = image.Contains("n-pages") ? Math.Max(1, (int)image.Get("n-pages")) : 1; + var pageHeight = image.Contains("page-height") ? (int)image.Get("page-height") : image.Height; + 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) + // 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) + { + ImageHeader header; + try { - bytes = rotated.WriteToBuffer(".gif"); - (extension, contentType, width, height) = ("gif", "image/gif", rotated.Width, pageHeight); + header = Inspect(input); } - else + catch (VipsException) { - using var resized = longest > maxSide ? rotated.ThumbnailImage(maxSide, height: maxSide, size: Enums.Size.Down) : rotated.Copy(); - var keepsAlpha = resized.HasAlpha(); - 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); + 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; + } - using var firstFrame = animated ? rotated.Crop(0, 0, rotated.Width, pageHeight) : rotated.Copy(); - using var preview = firstFrame.ThumbnailImage(previewSide, height: previewSide, size: Enums.Size.Down); + // 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 bytes = keepsAlpha ? resized.WriteToBuffer(".png[keep=none]") : resized.WriteToBuffer(".jpg[Q=88,keep=none]"); + var (extension, contentType) = keepsAlpha ? ("png", "image/png") : ("jpg", "image/jpeg"); + + using var preview = resized.ThumbnailImage(previewSide, height: previewSide, size: Enums.Size.Down); using var previewFlat = Flatten(preview); 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); 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 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 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) @@ -284,7 +388,7 @@ namespace PrivaPub.Domain.Media attachment.Height = probe.PrimaryVideoStream.Height; var frame = temp + ".png"; 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.Blurhash = still.Blurhash; File.Delete(frame); @@ -304,15 +408,28 @@ namespace PrivaPub.Domain.Media } } - async Task 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 relative = Path.Combine(now.ToString("yyyy", CultureInfo.InvariantCulture), now.ToString("MM", CultureInfo.InvariantCulture), $"{Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16))}.{extension}"); var full = Path.Combine(Root, relative); Directory.CreateDirectory(Path.GetDirectoryName(full)!); + return (relative.Replace('\\', '/'), full); + } + + async Task Save(byte[] bytes, string extension, CancellationToken token) + { + var (relative, full) = NewPath(extension); await File.WriteAllBytesAsync(full, bytes, token); - return relative.Replace('\\', '/'); + return relative; } static string Clean(string value, int max) => diff --git a/PrivaPub/Domain/Statuses/StatusService.cs b/PrivaPub/Domain/Statuses/StatusService.cs index 1cf98d1..d810b02 100644 --- a/PrivaPub/Domain/Statuses/StatusService.cs +++ b/PrivaPub/Domain/Statuses/StatusService.cs @@ -731,6 +731,7 @@ namespace PrivaPub.Domain.Statuses { AttachmentId = attachment.ID, ContentType = attachment.ContentType, + Kind = attachment.Kind, URL = _media.Url(attachment.FilePath), PreviewURL = _media.Url(attachment.PreviewPath ?? attachment.FilePath), Description = attachment.Description, diff --git a/PrivaPub/Infrastructure/RateLimiting.cs b/PrivaPub/Infrastructure/RateLimiting.cs index ae5e442..ab731e3 100644 --- a/PrivaPub/Infrastructure/RateLimiting.cs +++ b/PrivaPub/Infrastructure/RateLimiting.cs @@ -15,12 +15,15 @@ namespace PrivaPub.Infrastructure 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 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 const string Accounts = "accounts"; public const string Inbox = "inbox"; + public const string Uploads = "uploads"; static RateLimitOptions Limits(HttpContext context) => context.RequestServices.GetRequiredService>().Value; @@ -59,8 +62,27 @@ namespace PrivaPub.Infrastructure ReplenishmentPeriod = TimeSpan.FromSeconds(10), 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) { var signature = request.Headers["Signature"].ToString(); diff --git a/PrivaPub/Models/Post/PostMedia.cs b/PrivaPub/Models/Post/PostMedia.cs index 1c42a65..3c0b2ab 100644 --- a/PrivaPub/Models/Post/PostMedia.cs +++ b/PrivaPub/Models/Post/PostMedia.cs @@ -4,6 +4,7 @@ namespace PrivaPub.Models.Post { public Guid Id { get; set; } = Guid.NewGuid(); 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 Extension { get; set; } public string Path { get; set; } diff --git a/tools/pasture/appsettings.Pasture.json b/tools/pasture/appsettings.Pasture.json index 2b553e8..b1aade4 100644 --- a/tools/pasture/appsettings.Pasture.json +++ b/tools/pasture/appsettings.Pasture.json @@ -26,7 +26,7 @@ "Relays": [ "https://relay.test/actor", "https://aoderelay.test/actor" ] }, "Media": { "Root": "/tmp/privapub-media" }, - "RateLimits": { "AccountsPerMinute": 1000 }, + "RateLimits": { "AccountsPerMinute": 1000, "UploadsBurst": 1000, "UploadsPerMinute": 1000 }, "Registrations": { "Mode": "Open" }, "Statistics": { "Geo": { "AutoUpdate": false } }, "Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:80", "Protocols": "Http1AndHttp2" } } },