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
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-07 11:03:25 +02:00
1 parent 5252b8f320
commit dc63fa57b8
11 files changed
+134 -3

No files matched your search

+25
View File
@@ -100,6 +100,31 @@ namespace PrivaPub.Tests.Domain
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()
@@ -176,6 +176,25 @@ namespace PrivaPub.Tests.Http
Assert.False(await DB.Default.Find<Models.Social.ScheduledStatus>().Match(s => s.AvatarId == gone.Persona.Id).ExecuteAnyAsync(Token));
}
// what a login's media take: every persona's uploads and pictures, trashed ones no longer
[Fact]
public async Task A_logins_storage_counts_what_its_personas_keep()
{
var alice = await _host.Mastodon($"store{Guid.NewGuid():N}"[..12]);
using var root = _host.As(alice.Persona.Root.Jwt);
async Task<System.Text.Json.Nodes.JsonObject> Storage() => await (await root.GetAsync("/clientapi/user/storage", Token)).JsonBody();
Assert.Equal(0, Storage().Result["mediaBytes"]!.GetValue<long>());
var id = await Upload(alice, "a.jpg");
var stored = await Storage();
Assert.True(stored["mediaBytes"]!.GetValue<long>() > 0);
Assert.Equal(1, stored["mediaFiles"]!.GetValue<long>());
Assert.Null(stored["quotaBytes"]);
await _host.Get<IMediaService>().Trash(m => m.ID == id, "test", Token);
Assert.Equal(0, (await Storage())["mediaFiles"]!.GetValue<long>());
}
[Fact]
public async Task The_audit_adopts_todays_pictures_and_trashes_what_nothing_holds()
{