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

@@ -34,6 +34,11 @@ namespace PrivaPub.Federation.Actors
public bool PostingRestrictedToModerators { get; init; }
public bool IsBot { get; init; }
public bool Indexable { get; init; }
// the staff's moderation of a persona (completion program R6): suspended (by itself or with its banned login), it
// delivers nothing, its posts and collections answer 404 and its tokens are refused, while its actor stays as it was;
// silenced, its posts stay out of the public timelines, trends, search and the relays
public bool Suspended { get; init; }
public bool Silenced { get; init; }
public AvatarSettings Settings { get; init; } = new();
public DateTime Published { get; init; }
public string BaseAddress { get; init; }
@@ -335,6 +340,8 @@ namespace PrivaPub.Federation.Actors
IsBot = avatar.Settings?.IsBot == true,
Indexable = avatar.Settings?.IsIndexable == true,
Settings = avatar.Settings ?? new AvatarSettings(),
Suspended = avatar.SuspendedAt.HasValue || Domain.Moderation.Restrictions.IsSuspended(avatar.ID),
Silenced = avatar.SilencedAt.HasValue,
BaseAddress = BaseAddress
};
@@ -110,6 +110,8 @@ namespace PrivaPub.Federation.Controllers
var items = new List<JsonNode>();
foreach (var post in latest)
{
if (VisibilityPolicy.Barred(post))
continue;
if (local.Kind == LocalActorKind.Group)
{
items.Add(ActivityPubRenderer.Announce(local, post.ObjectURI, $"announce-{post.ID}", post.CreationDate));
@@ -152,7 +154,8 @@ namespace PrivaPub.Federation.Controllers
if (local is not { HasWall: true })
return NotFound();
var wall = local.Wall;
System.Linq.Expressions.Expression<Func<PostEntity, bool>> onWall = p => !p.DeletedAt.HasValue && !p.AuthorGone && !p.IsLocalOnly
System.Linq.Expressions.Expression<Func<PostEntity, bool>> onWall = p => !p.DeletedAt.HasValue && !p.AuthorGone && !p.AuthorSuspended
&& !p.ModeratedAt.HasValue && !p.IsLocalOnly
&& (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted)
&& (p.WallURI == wall
|| p.GroupUserId == local.Id && !p.IsFederatedCopy && p.ReblogOfPostId == null && p.AnsweringToPostId == null && p.InReplyToURI == null && p.GroupId == null);
@@ -282,6 +285,7 @@ namespace PrivaPub.Federation.Controllers
return NotFound();
var replies = await _dbEntities.Posts.Match(p => p.AnsweringToPostId == post.ID && !p.IsLocalOnly && p.ReblogOfPostId == null)
.Match(VisibilityPolicy.IsPublic).Sort(p => p.CreationDate, Order.Ascending).Limit(MaxThread).ExecuteAsync(token);
replies.RemoveAll(VisibilityPolicy.Barred);
return Activity(ActivityPubRenderer.OrderedCollection(local.PostUri(post.ID) + "/replies", replies.Count,
replies.Select(r => (JsonNode)r.ObjectURI)));
}
@@ -299,7 +303,7 @@ namespace PrivaPub.Federation.Controllers
{
var next = await _dbEntities.Posts.Match(p => level.Contains(p.AnsweringToPostId) && !p.IsLocalOnly && p.ReblogOfPostId == null)
.Match(VisibilityPolicy.IsPublic).Sort(p => p.CreationDate, Order.Ascending).Limit(MaxThread - thread.Count).ExecuteAsync(token);
thread.AddRange(next);
thread.AddRange(next.Where(p => !VisibilityPolicy.Barred(p)));
level = next.Select(p => p.ID).ToList();
}
var collection = ActivityPubRenderer.OrderedCollection(root.ContextURI, thread.Count, thread.Select(p => (JsonNode)p.ObjectURI));
@@ -541,6 +545,15 @@ namespace PrivaPub.Federation.Controllers
context.Result = StatusCode(StatusCodes.Status401Unauthorized);
return;
}
// a suspended persona (by itself or with its banned login) answers 404 for everything but its actor and its inbox:
// nothing new is told of it, and lifting the suspension serves it all again (owner decision 2026-10-17)
if (HttpMethods.IsGet(Request.Method) && context.RouteData.Values["actor"] is string name
&& context.ActionDescriptor is Microsoft.AspNetCore.Mvc.Controllers.ControllerActionDescriptor { ActionName: not nameof(GetActor) }
&& await _localActors.FindByUserName(name, HttpContext.RequestAborted) is { Kind: LocalActorKind.Person, Suspended: true })
{
context.Result = NotFound();
return;
}
await next();
}
@@ -39,10 +39,13 @@ namespace PrivaPub.Federation.Inbox.Handlers
public IEnumerable<IActivityHandler> Relays { get; set; }
readonly PrivaPub.Federation.Relays.IRelays _relayService;
readonly PrivaPub.Federation.Moderation.IDomainBlocks _domainBlocks;
public AnnounceHandler(DbEntities dbEntities, ILocalActorService localActors, IRemotePosts remotePosts, IFanout fanout, IRemoteActorService remoteActors,
IObjectRecords records, IQuoteService quotes, IServiceProvider services = default, PrivaPub.Federation.Relays.IRelays relays = default)
IObjectRecords records, IQuoteService quotes, IServiceProvider services = default, PrivaPub.Federation.Relays.IRelays relays = default,
PrivaPub.Federation.Moderation.IDomainBlocks domainBlocks = default)
{
_domainBlocks = domainBlocks;
_relayService = relays;
_services = services;
_records = records;
@@ -121,6 +124,8 @@ namespace PrivaPub.Federation.Inbox.Handlers
To = to,
Cc = cc,
IsFederatedCopy = true,
AuthorSuspended = actor.SuspendedAt.HasValue,
AuthorLimited = RemotePosts.Limited(actor, _domainBlocks),
CreationDate = published,
UpdateDate = published
};
@@ -49,6 +49,13 @@ namespace PrivaPub.Federation.Inbox.Handlers
Arrival.Drop("unknown-recipient");
return;
}
// a suspended persona answers nothing, so it takes no follower it could not answer (R6): the request stays
// pending on the follower's side, as it would for any persona that has not answered yet
if (target is { Kind: LocalActorKind.Person, Suspended: true })
{
Arrival.Drop("recipient-suspended");
return;
}
if (target.Kind == LocalActorKind.Person
&& await DB.Default.Find<Models.Social.Block>().Match(b => b.AvatarId == target.Id && b.TargetActorURI == follower.ActorURI).ExecuteAnyAsync(token))
{
@@ -65,6 +65,13 @@ namespace PrivaPub.Federation.Inbox
Record(job, payload, activity, type, started, new ArrivalVerdict(), Interactions.Deferred, "actor-unavailable");
return JobOutcome.Retry("the actor could not be loaded");
}
// an account the staff suspended (R6): what it sends is dropped, but a Delete or an Undo of what it sent, which only
// take away what we hold (a post, a follow, a like)
if (actor.SuspendedAt.HasValue && type is not ("Delete" or "Undo"))
{
Record(job, payload, activity, type, started, new ArrivalVerdict(), Interactions.Dropped, "account-suspended");
return JobOutcome.Done;
}
if (payload.ForwardedBy != default && Forwarded.NamesUnknownKey(activity, actor))
actor = await _remoteActors.GetActor(actor.ActorURI, refresh: true, token) ?? actor;
if (payload.ForwardedBy != default && !Forwarded.Proven(activity, actor))
+8
View File
@@ -133,6 +133,9 @@ namespace PrivaPub.Federation.Inbox
InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId,
IsFederatedCopy = true,
AuthorGone = author.AccountState == AvatarAccountState.Deleted,
// the staff's moderation of its author or its server (R6), as AccountModeration sets it on what came before
AuthorSuspended = author.SuspendedAt.HasValue,
AuthorLimited = Limited(author, _domainBlocks),
CreationDate = note.Published,
UpdateDate = note.Updated ?? note.Published,
EditedAt = note.Updated,
@@ -143,6 +146,11 @@ namespace PrivaPub.Federation.Inbox
return post;
}
// an account elsewhere is limited when the staff silenced it or its server
public static bool Limited(ForeignAvatar author, IDomainBlocks domainBlocks) =>
author.SilencedAt.HasValue || Uri.TryCreate(author.ActorURI, UriKind.Absolute, out var uri)
&& domainBlocks?.Find(uri.Host)?.Severity == DomainBlockSeverity.Silence;
// a post's flairs, filled in from its community's list
public static async Task<List<PostFlair>> Flairs(NoteDocument note, string communityUri, CancellationToken token)
{
+24 -1
View File
@@ -43,12 +43,24 @@ namespace PrivaPub.Federation.Outbox
_queue = queue;
}
// what the staff's moderation holds back (completion program R6): a suspended persona (by itself or with its banned
// login) delivers nothing, but the Delete of itself, which its deletion sends; and nothing goes to the own inbox of an
// account elsewhere the staff suspended (its server's shared inbox still serves the others there)
public static bool HeldBack(LocalActor signer, JsonNode activity) =>
signer is { Kind: LocalActorKind.Person, Suspended: true } && !DeletesItself(signer, activity);
static bool DeletesItself(LocalActor signer, JsonNode activity) =>
ActivityJson.Value(activity, "type") == "Delete" && ActivityJson.Id(activity?["object"]) == signer.Uri;
public async Task Enqueue(LocalActor signer, IEnumerable<string> inboxes, JsonObject activity, CancellationToken token, string again = default)
{
if (HeldBack(signer, activity))
return;
var body = activity.ToJsonString();
var activityId = activity["id"] is JsonValue id && id.TryGetValue<string>(out var text) ? text : default;
var targets = inboxes
.Where(i => !string.IsNullOrEmpty(i) && !i.StartsWith(signer.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
.Where(i => !string.IsNullOrEmpty(i) && !i.StartsWith(signer.BaseAddress + "/", StringComparison.OrdinalIgnoreCase)
&& !Domain.Moderation.Restrictions.IsSuspendedInbox(i))
.Distinct(StringComparer.Ordinal)
.ToList();
// what goes to a relay carries the signer's proof (FEP-8b32): the relay passes it on as it came, signed with its own
@@ -223,6 +235,17 @@ namespace PrivaPub.Federation.Outbox
attempt.Reason = "signer-gone";
return JobOutcome.Dead("the signing actor no longer exists");
}
// suspended since it was queued, or the account it goes to (R6): never sent
if (signer.Suspended && DeliveryService.HeldBack(signer, JsonNode.Parse(payload.Body)))
{
attempt.Reason = "signer-suspended";
return JobOutcome.Dead("the signing persona is suspended");
}
if (Domain.Moderation.Restrictions.IsSuspendedInbox(payload.Inbox))
{
attempt.Reason = "inbox-suspended";
return JobOutcome.Dead("the inbox's account is suspended");
}
var body = Encoding.UTF8.GetBytes(payload.Body);
using var request = new HttpRequestMessage(HttpMethod.Post, inbox) { Content = new ByteArrayContent(body) };
@@ -52,9 +52,11 @@ namespace PrivaPub.Federation.Outbox
//nothing is addressed to an account that blocked the author (it would refuse it, and Mastodon counts on that)
var blockers = (await DB.Default.Find<BlockedBy>().Match(b => b.AvatarId == author.Id).ExecuteAsync(token))
.Select(b => b.ActorURI).ToHashSet(StringComparer.Ordinal);
//nor to an account elsewhere the staff suspended (R6), through its server's shared inbox either
var addressed = post.Mentions.Where(m => !m.IsLocal).Select(m => m.ActorURI)
.Concat(post.Visibility == PostVisibility.Direct ? post.To.Concat(post.Cc) : Enumerable.Empty<string>())
.Where(uri => !uri.StartsWith(author.BaseAddress + "/", StringComparison.OrdinalIgnoreCase) && !blockers.Contains(uri))
.Where(uri => !uri.StartsWith(author.BaseAddress + "/", StringComparison.OrdinalIgnoreCase) && !blockers.Contains(uri)
&& !Domain.Moderation.Restrictions.IsSuspendedActor(uri))
.Distinct(StringComparer.Ordinal)
.ToList();
foreach (var uri in addressed)
@@ -67,7 +69,7 @@ namespace PrivaPub.Federation.Outbox
if (post.Visibility != PostVisibility.Direct && !string.IsNullOrEmpty(post.InReplyToAccountId))
{
var parentAuthor = await _dbEntities.ForeignAvatars.MatchID(post.InReplyToAccountId).ExecuteFirstAsync(token);
if (Preferred(parentAuthor) is { } inbox && !blockers.Contains(parentAuthor.ActorURI))
if (Preferred(parentAuthor) is { } inbox && !blockers.Contains(parentAuthor.ActorURI) && !parentAuthor.SuspendedAt.HasValue)
inboxes.Add(inbox);
}
@@ -77,14 +79,15 @@ namespace PrivaPub.Federation.Outbox
var quotedAuthor = quoted is { IsFederatedCopy: true }
? await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == quoted.ActorURI).ExecuteFirstAsync(token)
: default;
if (Preferred(quotedAuthor) is { } inbox)
if (Preferred(quotedAuthor) is { } inbox && !quotedAuthor.SuspendedAt.HasValue)
inboxes.Add(inbox);
}
// a persona's own public post, outside any group, also goes to the relays we subscribe to (owner decision
// 2026-10-06), its edits and deletion with it
// 2026-10-06), its edits and deletion with it; a silenced persona's never does (R6): the relays are where strangers
// read
if (_relays != default && post.Visibility == PostVisibility.Public && author.Kind == LocalActorKind.Person
&& string.IsNullOrEmpty(post.ReblogOfPostId) && string.IsNullOrEmpty(post.GroupId))
&& string.IsNullOrEmpty(post.ReblogOfPostId) && string.IsNullOrEmpty(post.GroupId) && !post.AuthorLimited && !author.Silenced)
inboxes.AddRange(await _relays.Inboxes(token));
return inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList();