Featured tags per persona (/api/v1/featured_tags, suggestions, tags/:name/feature, accounts' featured tags, max 10),
listed at /tattoos as Hashtags and sent as Add/Remove{Hashtag} to followers on the featured collection, as Mastodon
sends them; remote accounts' tags received and read from featuredTags. Endorsements (pin/endorse) and private notes
(PersonaNote) kept per persona and never federated; the relationship's notifying, languages, endorsed and note, with
follow's notify (status notifications) and languages (home filter). Reading preferences set on /clientapi. Suggestions
v1/v2 with dismissals and staff picks, never the persona or a sibling. conversation events on the direct stream.
translate, annual reports and the admin API answer. New collections backed up and removed with their persona.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
182 lines
9.0 KiB
C#
182 lines
9.0 KiB
C#
using MongoDB.Entities;
|
|
|
|
using OpenIddict.Abstractions;
|
|
|
|
using PrivaPub.Domain.Media;
|
|
using PrivaPub.Domain.Timelines;
|
|
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 IPersonaRemoval
|
|
{
|
|
/// <summary>Deletes one persona, and every group it owns: false when there is no such persona, or it is gone.</summary>
|
|
Task<bool> Remove(string avatarId, CancellationToken token);
|
|
}
|
|
|
|
public interface IGroupRemoval
|
|
{
|
|
/// <summary>Deletes one group: false when there is no such group, or it is gone.</summary>
|
|
Task<bool> Remove(string groupId, CancellationToken token);
|
|
}
|
|
|
|
// What deleting an actor tells: Delete{Actor} to its followers' (shared) inboxes, and to the inboxes of the other accounts
|
|
// named (those a persona follows, a group's members elsewhere). A suspended persona sends it too: it is the one thing a
|
|
// suspension lets through (DeliveryService.HeldBack).
|
|
public static class ActorDeletion
|
|
{
|
|
public static async Task Announce(LocalActor actor, IEnumerable<string> others, DbEntities dbEntities, IDeliveryService delivery, 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);
|
|
}
|
|
}
|
|
|
|
// Deleting a persona (completion program R6: by its own login with its password, by an administrator with theirs, and
|
|
// for each persona of a deleted login through RootRemoval) deletes everything public it had, as a deleted login's
|
|
// personas always were: its Mastodon tokens end first, each group it owns goes (GroupRemoval), a Delete of the actor goes
|
|
// to its followers and to the accounts it follows, its posts are emptied, and its name stays reserved while its documents
|
|
// answer 410. What it kept for itself (lists, filters, followed hashtags, scheduled posts, what it did with the
|
|
// announcements, the feeds it read, its featured hashtags, endorsements, notes and dismissed suggestions, its media)
|
|
// goes with it. Its link to its login stays, so the login keeps no other
|
|
// trace than that.
|
|
public class PersonaRemoval : IPersonaRemoval
|
|
{
|
|
readonly DbEntities _dbEntities;
|
|
readonly ILocalActorService _localActors;
|
|
readonly IDeliveryService _delivery;
|
|
readonly IMediaService _media;
|
|
readonly IGroupRemoval _groups;
|
|
readonly IOpenIddictTokenManager _tokens;
|
|
readonly IOpenIddictAuthorizationManager _authorizations;
|
|
|
|
public PersonaRemoval(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery, IMediaService media, IGroupRemoval groups,
|
|
IOpenIddictTokenManager tokens, IOpenIddictAuthorizationManager authorizations)
|
|
{
|
|
_dbEntities = dbEntities;
|
|
_localActors = localActors;
|
|
_delivery = delivery;
|
|
_media = media;
|
|
_groups = groups;
|
|
_tokens = tokens;
|
|
_authorizations = authorizations;
|
|
}
|
|
|
|
public async Task<bool> Remove(string avatarId, CancellationToken token)
|
|
{
|
|
var avatar = string.IsNullOrEmpty(avatarId) ? default : await _dbEntities.Avatars.MatchID(avatarId).Match(a => !a.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
|
if (avatar == default)
|
|
return false;
|
|
var now = DateTime.UtcNow;
|
|
// nothing acts as it while it goes
|
|
await _tokens.RevokeBySubjectAsync(avatar.ID, token);
|
|
await _authorizations.RevokeBySubjectAsync(avatar.ID, token);
|
|
Streams.CloseFor(avatar.ID);
|
|
|
|
foreach (var group in await _dbEntities.Groups.Match(g => g.OwnerAvatarId == avatar.ID && !g.DeletionAt.HasValue).ExecuteAsync(token))
|
|
await _groups.Remove(group.ID, 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 ActorDeletion.Announce(persona, followed, _dbEntities, _delivery, 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<PersonaListMember>(m => m.AvatarId == avatar.ID);
|
|
await DB.Default.DeleteAsync<PersonaList>(l => l.AvatarId == avatar.ID);
|
|
await DB.Default.DeleteAsync<PersonaFilter>(f => f.AvatarId == avatar.ID);
|
|
await DB.Default.DeleteAsync<FollowedTag>(t => t.AvatarId == avatar.ID);
|
|
// what it did with the server's announcements, kept for it alone
|
|
await DB.Default.DeleteAsync<AnnouncementRead>(r => r.AvatarId == avatar.ID);
|
|
await DB.Default.DeleteAsync<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<FeedSubscription>(s => s.AvatarId == avatar.ID);
|
|
// what the Mastodon API kept for it (R8): its featured hashtags (the Delete of the actor takes them from other
|
|
// servers), its endorsements, its private notes and its dismissed suggestions, and what other personas here kept
|
|
// about it: their endorsements of it and their notes on it
|
|
await DB.Default.DeleteAsync<FeaturedTag>(t => t.AvatarId == avatar.ID);
|
|
await DB.Default.DeleteAsync<Endorsement>(e => e.AvatarId == avatar.ID || e.TargetAccountId == avatar.ID);
|
|
await DB.Default.DeleteAsync<PersonaNote>(n => n.AvatarId == avatar.ID || n.TargetAccountId == avatar.ID);
|
|
await DB.Default.DeleteAsync<SuggestionDismissal>(d => d.AvatarId == avatar.ID || d.AccountId == 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<ScheduledStatus>(s => s.AvatarId == avatar.ID);
|
|
await _media.Trash(m => m.OwnerAvatarId == avatar.ID, "persona removed", token);
|
|
return true;
|
|
}
|
|
|
|
// 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));
|
|
}
|
|
}
|
|
|
|
// Deleting a group (completion program R6: by its owner with the login's password, by an administrator with theirs, and
|
|
// with the persona that owns it): a Delete of the group's actor to its followers and its members elsewhere, its name kept
|
|
// reserved while its documents answer 410, and the personas here that followed or joined it no longer do. The posts made
|
|
// in it stay their authors'.
|
|
public class GroupRemoval : IGroupRemoval
|
|
{
|
|
readonly DbEntities _dbEntities;
|
|
readonly ILocalActorService _localActors;
|
|
readonly IDeliveryService _delivery;
|
|
|
|
public GroupRemoval(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery)
|
|
{
|
|
_dbEntities = dbEntities;
|
|
_localActors = localActors;
|
|
_delivery = delivery;
|
|
}
|
|
|
|
public async Task<bool> Remove(string groupId, CancellationToken token)
|
|
{
|
|
var group = string.IsNullOrEmpty(groupId) ? default : await _dbEntities.Groups.MatchID(groupId).Match(g => !g.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
|
if (group == default)
|
|
return false;
|
|
var actor = _localActors.FromGroup(group);
|
|
await ActorDeletion.Announce(actor, group.Members.Where(m => m.IsForeign).Select(m => m.AvatarId), _dbEntities, _delivery, token);
|
|
await DB.Default.Update<GroupEntity>().MatchID(group.ID).Modify(g => g.DeletionAt, DateTime.UtcNow).ExecuteAsync(token);
|
|
await DB.Default.DeleteAsync<Following>(f => f.TargetActorURI == actor.Uri);
|
|
await DB.Default.DeleteAsync<Follower>(f => f.LocalActorId == group.ID && f.LocalActorKind == LocalActorKind.Group);
|
|
return true;
|
|
}
|
|
}
|
|
}
|