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
View File
@@ -0,0 +1,111 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Domain.Privacy;
using PrivaPub.Models.User;
namespace PrivaPub.Domain.Moderation
{
// Who the staff's moderation holds back right now, in memory (completion program R6), for the places that decide one
// item at a time and must not ask the database each time: a token's persona, a delivery's signer and inbox, a post
// shown or not, a stream. The posts of a suspended or silenced account carry their own flags (Post.AuthorSuspended,
// AuthorLimited) for queries; a banned login's personas never do, since flagging all of them at once would pair them
// in stored data, so they are suspended here alone (Counted.HeldBackPersonas).
//
// Read at start, again after every act that changes it (AccountModeration, a ban, a removal) and when five minutes old;
// a process that never loaded it (the CLI) starts with nobody held back and loads it on first use.
public static class Restrictions
{
static readonly TimeSpan Staleness = TimeSpan.FromMinutes(5);
public sealed class Set
{
public static readonly Set Empty = new(new HashSet<string>(), new HashSet<string>(), new HashSet<string>(), new HashSet<string>());
public Set(IReadOnlySet<string> suspendedPersonas, IReadOnlySet<string> silencedPersonas, IReadOnlySet<string> suspendedActors,
IReadOnlySet<string> suspendedInboxes)
{
SuspendedPersonas = suspendedPersonas;
SilencedPersonas = silencedPersonas;
SuspendedActors = suspendedActors;
SuspendedInboxes = suspendedInboxes;
}
// suspended themselves, or held back with their login (banned or deleted)
public IReadOnlySet<string> SuspendedPersonas { get; }
public IReadOnlySet<string> SilencedPersonas { get; }
// accounts elsewhere the staff suspended: their actors' ids, and their own inboxes (never a shared one, which
// serves the rest of their server)
public IReadOnlySet<string> SuspendedActors { get; }
public IReadOnlySet<string> SuspendedInboxes { get; }
}
static volatile Set _current = Set.Empty;
static DateTime _loadedAt = DateTime.MinValue;
static int _reloading;
public static Set Current
{
get
{
RefreshIfStale();
return _current;
}
}
public static bool IsSuspended(string personaId) => !string.IsNullOrEmpty(personaId) && Current.SuspendedPersonas.Contains(personaId);
public static bool IsSilenced(string personaId) => !string.IsNullOrEmpty(personaId) && Current.SilencedPersonas.Contains(personaId);
public static bool IsSuspendedActor(string actorUri) => !string.IsNullOrEmpty(actorUri) && Current.SuspendedActors.Contains(actorUri);
public static bool IsSuspendedInbox(string inbox) => !string.IsNullOrEmpty(inbox) && Current.SuspendedInboxes.Contains(inbox);
public static async Task Reload(CancellationToken token = default)
{
var suspended = (await Counted.HeldBackPersonas(token)).ToHashSet(StringComparer.Ordinal);
var silenced = (await DB.Default.Find<Avatar>().Match(a => a.SilencedAt != null && a.DeletionAt == null)
.Project(a => a.Include(x => x.ID)).ExecuteAsync(token)).Select(a => a.ID).ToHashSet(StringComparer.Ordinal);
var actors = await DB.Default.Find<ForeignAvatar>().Match(f => f.SuspendedAt != null)
.Project(f => f.Include(x => x.ActorURI).Include(x => x.InboxURL).Include(x => x.SharedInboxURL)).ExecuteAsync(token);
// a single-user server's actor may name its server's shared inbox as its own: that one still serves the others there
var inboxes = actors.Where(a => !string.IsNullOrEmpty(a.InboxURL) && a.InboxURL != a.SharedInboxURL)
.Select(a => a.InboxURL).ToHashSet(StringComparer.Ordinal);
if (inboxes.Count > 0)
{
var candidates = inboxes.ToList();
var shared = await DB.Default.Find<ForeignAvatar>().Match(f => candidates.Contains(f.SharedInboxURL))
.Project(f => f.Include(x => x.SharedInboxURL)).ExecuteAsync(token);
inboxes.ExceptWith(shared.Select(f => f.SharedInboxURL));
}
_current = new Set(suspended, silenced,
actors.Select(a => a.ActorURI).Where(u => !string.IsNullOrEmpty(u)).ToHashSet(StringComparer.Ordinal),
inboxes);
_loadedAt = DateTime.UtcNow;
}
// what a reload says, without waiting for it where waiting is not possible
public static void ReloadSoon() => _loadedAt = DateTime.MinValue;
static void RefreshIfStale()
{
if (DateTime.UtcNow - _loadedAt < Staleness || Interlocked.Exchange(ref _reloading, 1) == 1)
return;
_ = Task.Run(async () =>
{
try
{
await Reload(CancellationToken.None);
}
catch (Exception ex)
{
Serilog.Log.ForContext(typeof(Restrictions)).Warning(ex, "The moderation's restrictions could not be reloaded");
}
finally
{
Interlocked.Exchange(ref _reloading, 0);
}
});
}
}
}