Files
SocialPub/PrivaPub/Services/RootRemoval.cs
T
thepraandClaude Opus 5.5 bb680e8cb8 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
2026-10-07 10:31:14 +02:00

131 lines
6.0 KiB
C#

using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using PrivaPub.Domain.Media;
using System.Text.Json.Nodes;
using GroupEntity = PrivaPub.Models.Group.Group;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Services
{
public interface IRootRemoval
{
Task<bool> Remove(string rootId, CancellationToken token);
}
// Deleting a root deletes everything public it had (owner decision 2026-10-04): each persona, and each group a persona
// owns, is announced deleted with a Delete of the actor to everyone who follows it, every member and everyone it
// follows; their posts are emptied; their names stay reserved, and their documents answer 410 from then on. The root's
// sessions end first, so nothing acts as it while it goes.
public class RootRemoval : IRootRemoval
{
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IDeliveryService _delivery;
readonly IRootSessions _sessions;
readonly IMediaService _media;
public RootRemoval(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery, IRootSessions sessions, IMediaService media)
{
_media = media;
_dbEntities = dbEntities;
_localActors = localActors;
_delivery = delivery;
_sessions = sessions;
}
public async Task<bool> Remove(string rootId, CancellationToken token)
{
var root = await _dbEntities.RootUsers.MatchID(rootId).Match(u => u.DeletedAt == null).ExecuteFirstAsync(token);
if (root == default)
return false;
await _sessions.Revoke(root.ID, token);
var now = DateTime.UtcNow;
var avatarIds = (await _dbEntities.RootToAvatars.Match(ra => ra.RootId == root.ID).ExecuteAsync(token)).Select(ra => ra.AvatarId).ToList();
var avatars = await _dbEntities.Avatars.Match(a => avatarIds.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync(token);
foreach (var avatar in avatars)
{
foreach (var group in await _dbEntities.Groups.Match(g => g.OwnerAvatarId == avatar.ID && !g.DeletionAt.HasValue).ExecuteAsync(token))
{
await Announce(_localActors.FromGroup(group), group.Members.Where(m => m.IsForeign).Select(m => m.AvatarId), token);
await DB.Default.Update<GroupEntity>().MatchID(group.ID).Modify(g => g.DeletionAt, now).ExecuteAsync(token);
}
var persona = _localActors.FromAvatar(avatar);
var followed = (await _dbEntities.Followings.Match(f => f.AvatarId == avatar.ID && !f.TargetIsLocal).ExecuteAsync(token))
.Select(f => f.TargetActorURI);
await Announce(persona, followed, token);
await DB.Default.Update<Avatar>().MatchID(avatar.ID).Modify(a => a.DeletionAt, now).ExecuteAsync(token);
await Empty(avatar.ID, now, token);
await DB.Default.DeleteAsync<Models.Social.PersonaListMember>(m => m.AvatarId == avatar.ID);
await DB.Default.DeleteAsync<Models.Social.PersonaList>(l => l.AvatarId == avatar.ID);
await DB.Default.DeleteAsync<Models.Social.PersonaFilter>(f => f.AvatarId == avatar.ID);
await DB.Default.DeleteAsync<Models.Social.FollowedTag>(t => t.AvatarId == avatar.ID);
// nothing of it publishes later, and none of its files stays served: its posts' media, its pictures and
// what its scheduled posts held
await DB.Default.DeleteAsync<Models.Social.ScheduledStatus>(s => s.AvatarId == avatar.ID);
await _media.Trash(m => m.OwnerAvatarId == avatar.ID, "root removed", token);
}
await DB.Default.Update<RootUser>().MatchID(root.ID)
.Modify(u => u.UserName, $"deleted-{root.ID}")//unique, so a second deletion never collides with the first
.Modify(u => u.Email, null)
.Modify(u => u.HashedPassword, null)
.Modify(u => u.Policies, new List<string>())
.Modify(u => u.IsBanned, false)
.Modify(u => u.IsEmailValidated, false)
.Modify(u => u.DeletedAt, now)
.ExecuteAsync(token);
await DB.Default.DeleteAsync<EmailRecovery>(r => r.RootUserId == root.ID);
return true;
}
// Delete{Actor}: to its followers' (shared) inboxes, and to the inboxes of the other accounts named
async Task Announce(LocalActor actor, IEnumerable<string> others, CancellationToken token)
{
var inboxes = (await _delivery.FollowerInboxes(actor, token)).ToList();
var named = others.Where(uri => !string.IsNullOrEmpty(uri)).Distinct().ToList();
if (named.Count > 0)
inboxes.AddRange((await _dbEntities.ForeignAvatars.Match(a => named.Contains(a.ActorURI)).ExecuteAsync(token))
.Select(a => string.IsNullOrEmpty(a.SharedInboxURL) ? a.InboxURL : a.SharedInboxURL));
await _delivery.Enqueue(actor, inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(), new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = actor.ActivityUri("delete-actor"),
["type"] = "Delete",
["actor"] = actor.Uri,
["object"] = actor.Uri,
["to"] = new JsonArray(ActivityPubRenderer.Public),
["cc"] = new JsonArray(actor.Followers)
}, token);
}
// a persona's posts keep their ids and lose their content, like a single deleted post; a group writes none of its own
static async Task Empty(string avatarId, DateTime now, CancellationToken token)
{
var postIds = (await DB.Default.Find<PostEntity>().Match(p => p.GroupUserId == avatarId && !p.IsFederatedCopy && !p.DeletedAt.HasValue)
.Project(p => p.Include(x => x.ID)).ExecuteAsync(token)).Select(p => p.ID).ToList();
if (postIds.Count == 0)
return;
await DB.Default.Update<PostEntity>().Match(p => postIds.Contains(p.ID))
.Modify(p => p.DeletedAt, now)
.Modify(p => p.Text, null)
.Modify(p => p.ContentHtml, null)
.Modify(p => p.Title, null)
.Modify(p => p.SpoilerText, null)
.Modify(p => p.Media, new List<Models.Post.PostMedia>())
.Modify(p => p.Revisions, new List<Models.Post.PostRevision>())
.ExecuteAsync(token);
await DB.Default.DeleteAsync<TimelineEntry>(e => postIds.Contains(e.PostId) || postIds.Contains(e.ReblogOfPostId));
}
}
}