Files
thepraandClaude Opus 5.5 dc63fa57b8 A login's storage is counted, and can have a quota
MediaAttachment.Size was stored and never summed: nobody, the administrator included, could tell what media took,
and nothing bounded it. Counted.Media and MediaOfRoot sum what is kept (not trashed). A login sees what its personas'
uploads and pictures take at /clientapi/user/storage (ViewStorage), with its quota when the server sets one;
Media:QuotaBytesPerRoot (0, no quota, by default) refuses an upload or a picture over it with 422, whichever persona
sends it; the statistics overview gains the media totals, the proxy cache and the trash (ViewMediaTotals). Tests: a
login's storage counts what its personas keep and forgets what is trashed; two uploads from two personas of one login
are refused together past the quota.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 11:03:25 +02:00

161 lines
6.7 KiB
C#

using PrivaPub.Models.Federation;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Memory;
using MongoDB.Entities;
using NetVips;
using PrivaPub.Domain.Media;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Media;
using PrivaPub.Tests.Support;
namespace PrivaPub.Tests.Domain
{
[Trait("Category", "Integration")]
public sealed class MediaFlowTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
static byte[] Png(int width, int height)
{
using var image = (Image.Black(width, height, bands: 3) + new double[] { 10, 120, 200 }).Cast(Enums.BandFormat.Uchar);
return image.WriteToBuffer(".png");
}
static IFormFile Upload(byte[] bytes, string contentType) =>
new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "picture") { Headers = new HeaderDictionary(), ContentType = contentType };
[Fact]
public async Task An_upload_is_attached_once_and_federated_with_its_alt_text()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var (_, mallory) = await _harness.Persona("mallory");
var upload = await _harness.Media.Upload(alice, Upload(Png(300, 200), "image/png"), "a blue square", "0.25,-0.5", false, token);
Assert.True(upload.Ok);
Assert.True(File.Exists(Path.Combine(_harness.Media.Root, upload.Attachment.FilePath)));
var stolen = await _harness.Statuses.Publish(mallory, new StatusDraft { Text = "mine", MediaIds = new[] { upload.Attachment.ID } }, token);
Assert.False(stolen.Ok);
var posted = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "look", MediaIds = new[] { upload.Attachment.ID } }, token);
Assert.True(posted.Ok);
Assert.Equal(posted.Post.ID, (await DB.Default.Find<MediaAttachment>().OneAsync(upload.Attachment.ID, token)).PostId);
var note = ActivityPubRenderer.Note(posted.Post, alice, default, default);
var attachment = note["attachment"]![0]!;
Assert.Equal("a blue square", attachment["name"]!.GetValue<string>());
Assert.Equal(300, attachment["width"]!.GetValue<int>());
Assert.Equal(-0.5f, attachment["focalPoint"]![1]!.GetValue<float>());
Assert.StartsWith("https://privapub.test/media/files/", attachment["url"]!.GetValue<string>());
var reused = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "again", MediaIds = new[] { upload.Attachment.ID } }, token);
Assert.False(reused.Ok);
}
[Fact]
public async Task Unsupported_files_are_refused()
{
var (_, alice) = await _harness.Persona("alice");
var outcome = await _harness.Media.Upload(alice, Upload(System.Text.Encoding.UTF8.GetBytes("<svg/>"), "image/svg+xml"), default, default, false, TestContext.Current.CancellationToken);
Assert.False(outcome.Ok);
Assert.Equal(422, outcome.Status);
}
[Fact]
public async Task The_proxy_serves_signed_remote_media_and_nothing_else()
{
var token = TestContext.Current.CancellationToken;
var path = $"/files/{Guid.NewGuid():N}.png";
_harness.Peer.ServeFile(path, Png(10, 10), "image/png");
var proxy = new MediaProxy(_harness.Local, Peer.Http(), _harness.Media, new StaticOptions<MediaOptions>(new MediaOptions()));
var wrapped = proxy.Wrap(_harness.Peer.A + path);
var parts = new Uri(wrapped).AbsolutePath.Split('/');
var (file, contentType) = await proxy.Fetch(parts[3], parts[4], token);
var (tampered, _) = await proxy.Fetch(parts[3].Replace(parts[3][0], parts[3][0] == 'A' ? 'B' : 'A'), parts[4], token);
Assert.StartsWith("https://privapub.test/media/proxy/", wrapped);
Assert.Equal("image/png", contentType);
Assert.True(File.Exists(file));
Assert.StartsWith(_harness.Media.ProxyRoot, file);
Assert.Null(tampered);
}
// a login over its quota uploads nothing more, whichever persona tries
[Fact]
public async Task A_login_over_its_quota_uploads_nothing_more()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var (_, sibling) = await _harness.Persona("sibling", root);
var quota = new MediaService(new StaticOptions<MediaOptions>(new MediaOptions { Root = _harness.Media.Root, QuotaBytesPerRoot = 3000 }),
_harness.Local, default, Microsoft.Extensions.Logging.Abstractions.NullLogger<MediaService>.Instance);
// noise: its PNG is about as big as its pixels, so two of them are over the quota and one is not
static byte[] Noise()
{
var pixels = new byte[30 * 20 * 3];
Random.Shared.NextBytes(pixels);
using var image = NetVips.Image.NewFromMemory(pixels, 30, 20, 3, NetVips.Enums.BandFormat.Uchar);
return image.WriteToBuffer(".png");
}
Assert.True((await quota.Upload(alice, Upload(Noise(), "image/png"), default, default, false, token)).Ok);
var full = await quota.Upload(sibling, Upload(Noise(), "image/png"), default, default, false, token);
Assert.False(full.Ok);
Assert.Contains("storage is full", full.Error);
}
// nothing of a suspended server, or of one whose media are rejected, is proxied; blocking one purges what was cached
[Fact]
public async Task A_blocked_servers_media_are_not_proxied_and_their_cache_goes()
{
var token = TestContext.Current.CancellationToken;
var path = $"/files/{Guid.NewGuid():N}.png";
_harness.Peer.ServeFile(path, Png(10, 10), "image/png");
var remote = _harness.Peer.A + path;
var host = new Uri(remote).Host;
var blocks = new Blocks();
var proxy = new MediaProxy(_harness.Local, Peer.Http(), _harness.Media, new StaticOptions<MediaOptions>(new MediaOptions()), blocks);
Assert.Equal(ProxyOutcome.Cached, (await proxy.Download(remote, token)).Outcome);
blocks.Blocked[host] = new DomainBlock { Domain = host, Severity = DomainBlockSeverity.Silence, RejectMedia = true };
Assert.True(proxy.Refuses(remote));
Assert.True(proxy.Purge(host) >= 1);
Assert.Equal(default, proxy.Cached(remote));
blocks.Blocked[host] = new DomainBlock { Domain = host, Severity = DomainBlockSeverity.Silence };
Assert.False(proxy.Refuses(remote));
blocks.Blocked[host] = new DomainBlock { Domain = host, Severity = DomainBlockSeverity.Suspend };
Assert.True(proxy.Refuses(remote));
}
sealed class Blocks : PrivaPub.Federation.Moderation.IDomainBlocks
{
public Dictionary<string, DomainBlock> Blocked { get; } = new();
public DomainBlock Find(string host) => Blocked.GetValueOrDefault(host);
public bool IsSuspended(string host) => Find(host)?.Severity == DomainBlockSeverity.Suspend;
public Task Reload(CancellationToken token) => Task.CompletedTask;
}
}
}