A file lives exactly as long as something holds it

Deleting a post, editing media out, replacing an avatar or a header, and removing a whole root deleted no file: every
one stayed on disk and publicly served from /media/files with a year-long immutable cache, its row orphaned. Now every
upload is a row, profile pictures too (Kind avatar or header, ProfileOfAvatarId), and each of those acts trashes what
it held, as does an upload never posted for a day and a dropped scheduled post. A trashed row is marked in one
conditional update (an upload attached meanwhile is left alone), its files move into media-trash, beside the media
root and outside what /media/files serves, and the janitor deletes them a day later. Nothing is deleted for looking
unused.

Along the way: a profile picture that isn't an image, or can't be read, answers 422 instead of being silently ignored
with a 200; a removed root's scheduled posts are dropped, so nothing of it publishes later; media rows get indexes
(they had none), and the janitor's first pass comes five minutes after boot instead of an hour.

`PrivaPub admin media audit [--fix]` compares the disk with the database. With --fix (as www-data) it gives the
pictures personas show today a row, and trashes media of deleted posts or personas, rows whose files are missing, and
files nothing holds: the leftovers of every deletion until now. MediaLifecycleTests covers each act, that the trash is
never served, and the audit.

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 10:31:14 +02:00
1 parent 52d201eee9
commit bb680e8cb8
15 files changed
+462 -36

No files matched your search

@@ -51,13 +51,28 @@ namespace PrivaPub.Api.Mastodon.Controllers
public async Task<IActionResult> UpdateCredentials([FromServices] IMediaService media, CancellationToken token)
{
var avatar = await _dbEntities.Avatars.MatchID(Me.Id).ExecuteFirstAsync(token);
// a new picture is a row of its own; the one it replaces goes to the trash once the profile is saved
var replaced = new List<Models.Media.MediaAttachment>();
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync(token);
if (form.Files["avatar"] is { } picture && await media.ProfileImage(picture, 400, 400, token) is { } pictureUrl)
avatar.PictureURL = pictureUrl;
if (form.Files["header"] is { } header && await media.ProfileImage(header, 1500, 500, token) is { } headerUrl)
avatar.ThumbnailURL = headerUrl;
foreach (var (field, kind, width, height) in new[] { ("avatar", "avatar", 400, 400), ("header", "header", 1500, 500) })
{
if (form.Files[field] is not { } file)
continue;
var outcome = await media.ProfileImage(avatar.ID, kind, file, width, height, token);
if (!outcome.Ok)
{
foreach (var made in replaced)
await media.Trash(m => m.ID == made.ID, "profile not saved", token);
return Error(outcome.Status, outcome.Error);
}
replaced.Add(outcome.Attachment);
if (kind == "avatar")
avatar.PictureURL = media.Url(outcome.Attachment.FilePath);
else
avatar.ThumbnailURL = media.Url(outcome.Attachment.FilePath);
}
}
if (Params.Has("display_name"))
avatar.Name = Params.Get("display_name")?.Trim();
@@ -98,6 +113,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
avatar.Fields = fields;
avatar.UpdatedAt = DateTime.UtcNow;
await DB.Default.SaveAsync(avatar, token);
foreach (var made in replaced)
await media.Trash(m => m.ProfileOfAvatarId == avatar.ID && m.Kind == made.Kind && m.ID != made.ID, "replaced", token);
var actor = _localActors.FromAvatar(avatar);
await _outbox.PublishProfile(actor, token);
@@ -42,14 +42,14 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpGet("/api/v1/media/{id}"), Scope("write:media")]
public async Task<IActionResult> Get(string id, CancellationToken token)
{
var attachment = await DB.Default.Find<MediaAttachment>().Match(m => m.ID == id && m.OwnerAvatarId == MyId).ExecuteFirstAsync(token);
var attachment = await DB.Default.Find<MediaAttachment>().Match(m => m.ID == id && m.OwnerAvatarId == MyId && m.TrashedAt == null && m.ProfileOfAvatarId == null).ExecuteFirstAsync(token);
return attachment == default ? NotFoundError() : Json(View(attachment));
}
[HttpPut("/api/v1/media/{id}"), Scope("write:media")]
public async Task<IActionResult> Update(string id, CancellationToken token)
{
var attachment = await DB.Default.Find<MediaAttachment>().Match(m => m.ID == id && m.OwnerAvatarId == MyId).ExecuteFirstAsync(token);
var attachment = await DB.Default.Find<MediaAttachment>().Match(m => m.ID == id && m.OwnerAvatarId == MyId && m.TrashedAt == null && m.ProfileOfAvatarId == null).ExecuteFirstAsync(token);
if (attachment == default)
return NotFoundError();
if (Params.Has("description"))
@@ -96,7 +96,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
if (await Mine(id, token) is not { } scheduled)
return NotFoundError();
await DB.Default.DeleteAsync<ScheduledStatus>(scheduled.ID);
await ScheduledStatuses.Release(scheduled.ID, token);
// its media were the scheduled post's alone, never posted
await _media.Trash(m => m.ScheduledStatusId == scheduled.ID, "scheduled post dropped", token);
return Json(new { });
}
}
@@ -111,7 +111,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Text can't be blank");
var mediaIds = asked.MediaIds.Distinct().ToList();
if (mediaIds.Count > 4 || mediaIds.Count > 0 && await DB.Default.CountAsync<Models.Media.MediaAttachment>(
m => mediaIds.Contains(m.ID) && m.OwnerAvatarId == MyId && m.PostId == null && m.ScheduledStatusId == null, token) != mediaIds.Count)
m => mediaIds.Contains(m.ID) && m.OwnerAvatarId == MyId && m.PostId == null && m.ScheduledStatusId == null
&& m.TrashedAt == null && m.ProfileOfAvatarId == null, token) != mediaIds.Count)
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are not yours, already posted or too many");
var scheduled = new ScheduledStatus { AvatarId = MyId, ScheduledAt = at, Params = asked };