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:
1 parent
65938bb2a0
commit
e4f9d0b61e
14 files changed
+283
-54
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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 = "<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]
|
||||
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]);
|
||||
|
||||
@@ -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<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((await anonymous.Get("/nodeinfo/2.1")).Ok().Body.Flag("openRegistrations"));
|
||||
Assert.False(v2.Body["configuration"]!["translation"].Flag("enabled"));
|
||||
|
||||
@@ -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 = "<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]
|
||||
public async Task Video_is_remuxed_without_its_metadata()
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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<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)]
|
||||
public async Task<IActionResult> UpdateCredentials([FromServices] IMediaService media, CancellationToken token)
|
||||
{
|
||||
|
||||
@@ -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<IActionResult> Upload(CancellationToken token)
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> 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<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 ILocalActorService _localActors;
|
||||
readonly SemaphoreSlim _processing;
|
||||
readonly IWebHostEnvironment _environment;
|
||||
readonly ILogger<MediaService> _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,20 +116,23 @@ 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");
|
||||
}
|
||||
catch (VipsException ex)
|
||||
var header = Inspect(bytes);
|
||||
if (header.Animated)
|
||||
{
|
||||
_logger.LogInformation("Refused an image upload: {Error}", ex.Message);
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The file is not a readable image");
|
||||
var animated = await ProcessAnimation(bytes, header, attachment, token);
|
||||
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.FilePath = await Save(processed.Bytes, processed.Extension, token);
|
||||
attachment.PreviewPath = await Save(processed.Preview, "jpg", token);
|
||||
@@ -116,6 +141,17 @@ namespace PrivaPub.Domain.Media
|
||||
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");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_processing.Release();
|
||||
}
|
||||
}
|
||||
else if (contentType != default && AvTypes.TryGetValue(contentType, out var extension))
|
||||
{
|
||||
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");
|
||||
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<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 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);
|
||||
|
||||
byte[] bytes;
|
||||
string extension, contentType;
|
||||
int width, height;
|
||||
if (animated)
|
||||
{
|
||||
bytes = rotated.WriteToBuffer(".gif");
|
||||
(extension, contentType, width, height) = ("gif", "image/gif", 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);
|
||||
}
|
||||
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();
|
||||
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 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 firstFrame = animated ? rotated.Crop(0, 0, rotated.Width, pageHeight) : rotated.Copy();
|
||||
using var preview = firstFrame.ThumbnailImage(previewSide, height: previewSide, size: Enums.Size.Down);
|
||||
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<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)
|
||||
@@ -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<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 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<string> 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) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<IOptions<RateLimitOptions>>().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();
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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" } } },
|
||||
|
||||
Reference in new issue
Block a user