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:
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))
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user