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 System.Text.Json.Nodes; using GroupEntity = PrivaPub.Models.Group.Group; using PostEntity = PrivaPub.Models.Post.Post; namespace PrivaPub.Services { public interface IRootRemoval { Task 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; public RootRemoval(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery, IRootSessions sessions) { _dbEntities = dbEntities; _localActors = localActors; _delivery = delivery; _sessions = sessions; } public async Task 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().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().MatchID(avatar.ID).Modify(a => a.DeletionAt, now).ExecuteAsync(token); await Empty(avatar.ID, now, token); } await DB.Default.Update().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()) .Modify(u => u.IsBanned, false) .Modify(u => u.IsEmailValidated, false) .Modify(u => u.DeletedAt, now) .ExecuteAsync(token); await DB.Default.DeleteAsync(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 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().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().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()) .Modify(p => p.Revisions, new List()) .ExecuteAsync(token); await DB.Default.DeleteAsync(e => postIds.Contains(e.PostId) || postIds.Contains(e.ReblogOfPostId)); } } }