Build / Build (push) Successful in 1m3s
A singleton ServerSettings (title, descriptions, contact, thumbnail, privacy policy, terms, rules, relays, media overrides, PublishActivity), cached by InstanceSettings and read by the instance API, NodeInfo, the instance actor, the web pages and the mailer; its defaults are the old literals, so nothing changes until an administrator edits. /clientapi/admin/settings (and /thumbnail), /announcements, /relays for administrators; /clientapi/moderator/log, the staff's log (AdminAction, written by domain blocks, backups, settings and the CLI; no IP, never a root paired with its personas). The Mastodon API's rules, extended description, privacy policy, terms of service, domain blocks (Published, Obfuscate), announcements (dismiss, reactions, streaming) and activity (only when published). Reports carry rule ids. Media overrides clamped to hard caps. Relays named by the settings reconcile next to the configured ones. Verified: interop.sh instance accounts relay with mastodon, relay, aoderelay and mailpit, 119 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
136 lines
6.4 KiB
C#
136 lines
6.4 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);
|
|
// what it did with the server's announcements, kept for it alone
|
|
await DB.Default.DeleteAsync<Models.Social.AnnouncementRead>(r => r.AvatarId == avatar.ID);
|
|
await DB.Default.DeleteAsync<Models.Social.AnnouncementReaction>(r => r.AvatarId == avatar.ID);
|
|
// (the reader of feeds stops following what nobody reads any more at its next round)
|
|
await DB.Default.DeleteAsync<Models.Social.FeedSubscription>(s => s.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));
|
|
}
|
|
}
|
|
}
|