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
104 lines
3.9 KiB
C#
104 lines
3.9 KiB
C#
using NetVips;
|
|
|
|
using PrivaPub.Domain.Media;
|
|
|
|
namespace PrivaPub.Tests.Domain
|
|
{
|
|
public class MediaProcessingTests
|
|
{
|
|
static byte[] JpegWithMetadata(int width, int height)
|
|
{
|
|
using var image = (Image.Black(width, height, bands: 3) + new double[] { 200, 40, 90 }).Cast(Enums.BandFormat.Uchar);
|
|
using var tagged = image.Mutate(m =>
|
|
{
|
|
m.Set(GValue.GStrType, "exif-ifd0-ImageDescription", "where I live");
|
|
m.Set(GValue.GStrType, "exif-ifd0-Artist", "Alice Smith");
|
|
m.Set(GValue.GStrType, "exif-ifd2-UserComment", "at home");
|
|
m.Set(GValue.BlobType, "xmp-data", System.Text.Encoding.UTF8.GetBytes("<x:xmpmeta xmlns:x='adobe:ns:meta/'><secret/></x:xmpmeta>"));
|
|
});
|
|
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()
|
|
{
|
|
var input = JpegWithMetadata(800, 600);
|
|
using (var original = Image.NewFromBuffer(input))
|
|
Assert.Contains("exif-data", original.GetFields());
|
|
|
|
var processed = MediaService.ProcessImage(input, 4096, 640);
|
|
|
|
using var output = Image.NewFromBuffer(processed.Bytes);
|
|
var fields = output.GetFields();
|
|
Assert.DoesNotContain("exif-data", fields);
|
|
Assert.DoesNotContain("xmp-data", fields);
|
|
Assert.DoesNotContain("iptc-data", fields);
|
|
Assert.DoesNotContain(fields, f => f.StartsWith("exif-ifd"));
|
|
Assert.Equal((800, 600), (processed.Width, processed.Height));
|
|
Assert.Equal("image/jpeg", processed.ContentType);
|
|
using var preview = Image.NewFromBuffer(processed.Preview);
|
|
Assert.Equal(640, Math.Max(preview.Width, preview.Height));
|
|
Assert.DoesNotContain("exif-data", preview.GetFields());
|
|
}
|
|
|
|
[Fact]
|
|
public void Large_images_are_capped()
|
|
{
|
|
var processed = MediaService.ProcessImage(JpegWithMetadata(5000, 2500), 4096, 640);
|
|
|
|
Assert.Equal((4096, 2048), (processed.Width, processed.Height));
|
|
}
|
|
|
|
[Fact]
|
|
public void A_blurhash_is_well_formed()
|
|
{
|
|
var processed = MediaService.ProcessImage(JpegWithMetadata(64, 64), 4096, 640);
|
|
|
|
Assert.Equal(28, processed.Blurhash.Length);
|
|
Assert.Equal('L', processed.Blurhash[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_white_image_encodes_white_as_its_average()
|
|
{
|
|
var pixels = Enumerable.Range(0, 16 * 16).SelectMany(_ => new byte[] { 255, 255, 255 }).ToArray();
|
|
|
|
var hash = Blurhash.Encode(pixels, 16, 16);
|
|
|
|
Assert.Equal(28, hash.Length);
|
|
Assert.Equal("TSUA", hash[2..6]);
|
|
}
|
|
}
|
|
}
|