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

@@ -328,6 +328,15 @@ namespace PrivaPub.Controllers.ClientToServer
// }
//}
// what the login's media take, every persona's, and its quota when the server sets one
[HttpGet, Route("/clientapi/user/storage"), Authorize(Policy = Policies.IsUser)]
public async Task<IActionResult> GetStorage([FromServices] Microsoft.Extensions.Options.IOptionsMonitor<Domain.Media.MediaOptions> media, CancellationToken token)
{
var (bytes, files) = await Domain.Privacy.Counted.MediaOfRoot(User.GetUserId(), token);
var quota = media.CurrentValue.QuotaBytesPerRoot;
return Ok(new PrivaPub.ClientModels.User.ViewStorage { MediaBytes = bytes, MediaFiles = files, QuotaBytes = quota > 0 ? quota : null });
}
[HttpGet, Route("/clientapi/user/settings"), Authorize(Policy = Policies.IsUser)]
public async Task<IActionResult> GetUserSettings()
{
@@ -32,8 +32,16 @@ namespace PrivaPub.Controllers.ClientToServer
Ok(await _queries.Crawler(_options.CurrentValue.Crawler.Enabled, _options.CurrentValue.Crawler.RevisitDays, token));
[HttpGet, Route("/clientapi/admin/statistics/overview")]
public async Task<IActionResult> Overview([FromQuery] int days = 30, CancellationToken token = default) =>
Ok(await _queries.Overview(days, token));
public async Task<IActionResult> Overview([FromServices] Domain.Media.IMediaService media, [FromQuery] int days = 30, CancellationToken token = default)
{
var overview = await _queries.Overview(days, token);
overview.Media.ProxyCacheBytes = Bytes(media.ProxyRoot);
overview.Media.TrashBytes = Bytes(media.TrashRoot);
return Ok(overview);
}
static long Bytes(string root) =>
Directory.Exists(root) ? new DirectoryInfo(root).EnumerateFiles("*", SearchOption.AllDirectories).Sum(f => f.Length) : 0;
[HttpGet, Route("/clientapi/admin/statistics/hosts")]
public async Task<IActionResult> Hosts([FromQuery] int days = 30, [FromQuery] string sort = default, [FromQuery] string software = default,
+1
View File
@@ -16,5 +16,6 @@ namespace PrivaPub.Domain.Media
public long MaxVideoPixels { get; set; } = 2_304_000;//a larger video is made smaller (as Mastodon's video_matrix_limit)
public double MaxFrameRate { get; set; } = 60;
public int MaxSeconds { get; set; } = 3600;//audio or video may not last longer
public long QuotaBytesPerRoot { get; set; }//what a login's media may take (0: no quota)
}
}
+17
View File
@@ -120,6 +120,8 @@ namespace PrivaPub.Domain.Media
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File can't be blank");
var options = _options.CurrentValue;
var contentType = file.ContentType?.Split(';')[0].Trim().ToLowerInvariant();
if (await OverQuota(owner.Id, file.Length, token) is { } full)
return full;
var attachment = new MediaAttachment
{
@@ -203,6 +205,8 @@ 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");
if (await OverQuota(avatarId, file.Length, token) is { } full)
return full;
var input = await Read(file, token);
if (Refusal(input, _options.CurrentValue) is { } refused)
return refused;
@@ -241,6 +245,19 @@ namespace PrivaPub.Domain.Media
return new MediaOutcome(attachment);
}
// a login over its quota (Media:QuotaBytesPerRoot; none when 0) uploads nothing more
async Task<MediaOutcome> OverQuota(string avatarId, long incoming, CancellationToken token)
{
var quota = _options.CurrentValue.QuotaBytesPerRoot;
if (quota <= 0)
return default;
var rootId = (await DB.Default.Find<Models.User.RootToAvatar>().Match(r => r.AvatarId == avatarId).ExecuteFirstAsync(token))?.RootId;
if (rootId == default)
return default;
var (bytes, _) = await Privacy.Counted.MediaOfRoot(rootId, token);
return bytes + incoming > quota ? MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Your storage is full") : default;
}
public async Task<long> Trash(System.Linq.Expressions.Expression<Func<MediaAttachment, bool>> which, string reason, CancellationToken token)
{
var trashed = 0L;
+19
View File
@@ -1,3 +1,4 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
@@ -64,6 +65,24 @@ namespace PrivaPub.Domain.Privacy
return distinct.Count == 0 ? 0 : await DB.Default.CountAsync<Avatar>(a => distinct.Contains(a.ID) && !a.DeletionAt.HasValue, token);
}
// the media kept (not trashed) that which holds: their bytes and how many
public static async Task<(long Bytes, long Files)> Media(Expression<Func<Models.Media.MediaAttachment, bool>> which, CancellationToken token)
{
var totals = await DB.Default.Fluent<Models.Media.MediaAttachment>()
.Match(m => m.TrashedAt == null)
.Match(which)
.Group(m => true, g => new { Bytes = g.Sum(m => m.Size), Files = g.LongCount() })
.FirstOrDefaultAsync(token);
return totals == default ? (0, 0) : (totals.Bytes, totals.Files);
}
// a login's media: every persona's uploads and pictures
public static async Task<(long Bytes, long Files)> MediaOfRoot(string rootId, CancellationToken token)
{
var personas = (await DB.Default.Find<Models.User.RootToAvatar>().Match(r => r.RootId == rootId).ExecuteAsync(token)).Select(r => r.AvatarId).ToList();
return await Media(m => personas.Contains(m.OwnerAvatarId), token);
}
public static async Task<long> LocalPosts(CancellationToken token)
{
var barred = await BarredPersonas(token);
@@ -74,10 +74,18 @@ namespace PrivaPub.Domain.Statistics
Written = server.Sum(d => d.LedgerWritten),
Dropped = server.Sum(d => d.LedgerDropped),
Failed = server.Sum(d => d.LedgerFailed)
}
},
Media = await MediaTotals(token)
};
}
// the media rows kept; the disk's caches are added by whoever knows where they are
static async Task<ViewMediaTotals> MediaTotals(CancellationToken token)
{
var (bytes, files) = await Domain.Privacy.Counted.Media(m => true, token);
return new ViewMediaTotals { MediaBytes = bytes, MediaFiles = files };
}
public async Task<ViewHostsPage> Hosts(int days, string sort, string software, int page, int limit, CancellationToken token)
{
var hostDays = await Days(days, default, token);