R6 moderation: roles, silence and suspension, bans that write nothing on the personas, deletions, report actions

Roles (user, moderator, administrator) from the CLI and /clientapi/admin/roots/{id}/role, never one's own nor the last
administrator. Silence keeps an account's posts out of public timelines, hashtags, trends, search and relays while its
followers still get them; suspension refuses and revokes a local persona's tokens, answers 404 for its posts and
collections, holds back every delivery and tells other servers nothing new, its actor unchanged; a suspended remote
account's activities are dropped and nothing goes to its own inbox (a shared one still serves its server). A banned
login's personas are held back in memory alone (Restrictions), so nothing pairs them in stored data. Personas and groups
are deleted one at a time (PersonaRemoval, GroupRemoval), by their login or an administrator with the password. Reports
resolve with an action and a note; posts are taken down, a remote copy hidden. Warnings reach the persona as Mastodon's
moderation_warning. Every staff act goes to the staff's log. Migration _020 limits posts from servers already silenced;
a restore keeps the moderation made since the backup.

Verified: interop.sh moderation gts mastodon with SecureMode, 194 passed; restore.sh, 25 passed, now with a suspension,
a silence and a takedown made after the backup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-17 05:23:45 +02:00
1 parent 70d1ffe9a2
commit 9f68ca3ed0
71 files changed
+2229 -219

No files matched your search

@@ -111,6 +111,9 @@ namespace PrivaPub.Services.ClientToServer.Private
var avatar = await _dbEntities.Avatars.MatchID(form.AvatarId).ExecuteFirstAsync();
if (avatar == default || avatar.DeletionAt.HasValue)
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
// a suspended persona's actor stays as it was until the suspension is lifted (R6)
if (avatar.SuspendedAt.HasValue)
return result.Invalidate(_localizer["This persona is suspended."], StatusCodes.Status403Forbidden);
avatar.Name = form.Name;
avatar.Biography = form.Biography;
+2 -1
View File
@@ -476,8 +476,9 @@ namespace PrivaPub.Services
return role is GroupRole.Owner or GroupRole.Moderator ? group : default;
}
// a suspended persona does nothing in its groups (R6)
async Task<bool> OwnsAvatar(string rootUserId, string avatarId, CancellationToken token) =>
!string.IsNullOrEmpty(rootUserId) && !string.IsNullOrEmpty(avatarId)
!string.IsNullOrEmpty(rootUserId) && !string.IsNullOrEmpty(avatarId) && !Domain.Moderation.Restrictions.IsSuspended(avatarId)
&& await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootUserId && ra.AvatarId == avatarId).ExecuteAnyAsync(token);
static PostingPolicy? Policy(string value) => value?.ToLowerInvariant() switch
+3
View File
@@ -18,6 +18,9 @@ namespace PrivaPub.Services
Task<WebResult> RemoveUserAsync(UsersIds usersIds);
Task<WebResult> RemoveSelfAsync(string rootId, string password);
/// <summary>Whether a login's password is this one, after the same hashing whatever the answer.</summary>
Task<bool> PasswordMatches(string rootId, string password);
Task<WebResult> UpdateUserPasswordAsync(UserPasswordForm userPasswordForm, string userId);
Task<WebResult> GetUserSettingsAsync(string userId, LoginForm loginForm = default);
+173
View File
@@ -0,0 +1,173 @@
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 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);
// 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;
}
}
}
+4 -3
View File
@@ -125,7 +125,7 @@ namespace PrivaPub.Services
}
var posts = await query.Match(VisibilityPolicy.IsShown).Sort(p => p.CreationDate, Order.Descending).Limit(PageSize).ExecuteAsync(token);
result.Data = posts.Select(ToView).ToList();
result.Data = posts.Where(p => !VisibilityPolicy.Barred(p)).Select(ToView).ToList();
return result;
}
catch (Exception ex)
@@ -188,7 +188,7 @@ namespace PrivaPub.Services
.Sort(p => p.CreationDate, Order.Descending)
.Limit(PageSize)
.ExecuteAsync(token);
result.Data = dms.Select(ToView).ToList();
result.Data = dms.Where(p => !VisibilityPolicy.Barred(p)).Select(ToView).ToList();
return result;
}
catch (Exception ex)
@@ -263,7 +263,8 @@ namespace PrivaPub.Services
return default;
if (!await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootUserId && ra.AvatarId == avatarId).ExecuteAnyAsync(token))
return default;
return await _localActors.FindById(LocalActorKind.Person, avatarId, token);
// a suspended persona does nothing through the client API either (R6)
return await _localActors.FindById(LocalActorKind.Person, avatarId, token) is { Suspended: false } persona ? persona : default;
}
static ViewPost ToView(PostEntity post) => new()
+10 -89
View File
@@ -1,18 +1,7 @@
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
{
@@ -21,25 +10,21 @@ namespace PrivaPub.Services
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.
// Deleting a root deletes everything public it had (owner decision 2026-10-04): each of its personas goes as a persona
// is deleted (PersonaRemoval: each group it owns, a Delete of the actor to everyone who follows it and everyone it
// follows, its posts emptied, its name kept reserved and its documents answering 410). The root's sessions end first, so
// nothing acts as it while it goes, and it keeps only a deleted-{id} name.
public class RootRemoval : IRootRemoval
{
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IDeliveryService _delivery;
readonly IRootSessions _sessions;
readonly IMediaService _media;
readonly IPersonaRemoval _personas;
public RootRemoval(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery, IRootSessions sessions, IMediaService media)
public RootRemoval(DbEntities dbEntities, IRootSessions sessions, IPersonaRemoval personas)
{
_media = media;
_dbEntities = dbEntities;
_localActors = localActors;
_delivery = delivery;
_sessions = sessions;
_personas = personas;
}
public async Task<bool> Remove(string rootId, CancellationToken token)
@@ -51,34 +36,8 @@ namespace PrivaPub.Services
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);
}
foreach (var avatarId in avatarIds)
await _personas.Remove(avatarId, 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
@@ -90,46 +49,8 @@ namespace PrivaPub.Services
.Modify(u => u.DeletedAt, now)
.ExecuteAsync(token);
await DB.Default.DeleteAsync<EmailRecovery>(r => r.RootUserId == root.ID);
await Domain.Moderation.Restrictions.Reload(CancellationToken.None);
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));
}
}
}
+8
View File
@@ -258,6 +258,14 @@ namespace PrivaPub.Services
}
}
// what can't be undone asks for the password again (a persona's or a group's deletion, by its login or an administrator)
public async Task<bool> PasswordMatches(string rootId, string password)
{
var user = string.IsNullOrEmpty(rootId) ? default : await DbEntities.RootUsers.MatchID(rootId).Match(u => u.DeletedAt == null).ExecuteFirstAsync();
var (verified, _) = PasswordHasher.Check(user?.HashedPassword ?? Decoy, password ?? string.Empty);
return user?.HashedPassword != default && verified;
}
public async Task<WebResult> BanUserAsync(UsersIds usersIds)
{
var result = new WebResult();