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
161 lines
6.7 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|