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

@@ -64,22 +64,25 @@ namespace PrivaPub.Api.Mastodon.Mappers
var actorUri = await _remoteActors.ResolveHandle(q, token);
known = actorUri == default ? default : await _remoteActors.GetActor(actorUri, refresh: false, token);
}
if (known is { DeletionAt: null } && offset == 0)
if (known is { DeletionAt: null, SuspendedAt: null } && offset == 0)
results.Add(_mapper.Foreign(known));
return results;
}
var prefix = parts[0].ToLowerInvariant();
var barred = await Domain.Privacy.Counted.BarredPersonas(token);
var barred = await Domain.Privacy.Counted.HeldBackPersonas(token);
var locals = await _dbEntities.Avatars
.Match(a => a.UserName.StartsWith(prefix) && !a.DeletionAt.HasValue && a.Settings.IsDiscoverable != false && !barred.Contains(a.ID))
// a silenced persona is found only by its exact name (R6), as its followers look for it
.Match(a => a.UserName.StartsWith(prefix) && !a.DeletionAt.HasValue && a.Settings.IsDiscoverable != false && !barred.Contains(a.ID)
&& (!a.SilencedAt.HasValue || a.UserName == prefix))
.Sort(a => a.UserName, MongoDB.Entities.Order.Ascending)
.Limit(offset + limit).ExecuteAsync(token);
foreach (var avatar in locals.Skip(offset))
results.Add(await _mapper.Local(_localActors.FromAvatar(avatar), false, token));
var skipRemote = Math.Max(0, offset - locals.Count);
if (results.Count < limit)
foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => f.UserName.StartsWith(parts[0]) && f.IsDiscoverable && !f.DeletionAt.HasValue)
foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => f.UserName.StartsWith(parts[0]) && f.IsDiscoverable && !f.DeletionAt.HasValue
&& !f.SuspendedAt.HasValue && !f.SilencedAt.HasValue)
.Sort(f => f.UserName, MongoDB.Entities.Order.Ascending)
.Skip(skipRemote).Limit(limit - results.Count).ExecuteAsync(token))
results.Add(_mapper.Foreign(foreign));
@@ -28,12 +28,15 @@ namespace PrivaPub.Api.Mastodon.Mappers
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IMediaProxy _proxy;
readonly PrivaPub.Federation.Moderation.IDomainBlocks _domainBlocks;
public MastodonMapper(DbEntities dbEntities, ILocalActorService localActors, IMediaProxy proxy = default)
public MastodonMapper(DbEntities dbEntities, ILocalActorService localActors, IMediaProxy proxy = default,
PrivaPub.Federation.Moderation.IDomainBlocks domainBlocks = default)
{
_dbEntities = dbEntities;
_localActors = localActors;
_proxy = proxy;
_domainBlocks = domainBlocks;
}
string Proxied(string url) => _proxy == default ? url : _proxy.Wrap(url);
@@ -51,7 +54,9 @@ namespace PrivaPub.Api.Mastodon.Mappers
var statuses = (await Statuses(posts, viewerId, token)).ToDictionary(s => s.Id);
var warned = notifications.Any(n => n.Type == NotificationType.ModerationWarning) && viewerId != default ? await Account(viewerId, token) : default;
return notifications
.Where(n => accounts.ContainsKey(n.FromAccountId ?? string.Empty) && (n.PostId == default || statuses.ContainsKey(n.PostId)))
// a warning names the posts it is about, which the staff may have removed
.Where(n => accounts.ContainsKey(n.FromAccountId ?? string.Empty)
&& (n.PostId == default || n.Type == NotificationType.ModerationWarning || statuses.ContainsKey(n.PostId)))
.Select(n => new Entities.Notification
{
Id = n.ID,
@@ -66,8 +71,9 @@ namespace PrivaPub.Api.Mastodon.Mappers
ModerationWarning = n.Type != NotificationType.ModerationWarning ? default : new AccountWarning
{
Id = n.ID,
Action = n.WarningAction ?? "none",
Text = n.Text ?? string.Empty,
StatusIds = n.PostId == default ? new List<string>() : new List<string> { n.PostId },
StatusIds = n.StatusIds ?? (n.PostId == default ? new List<string>() : new List<string> { n.PostId }),
TargetAccount = warned,
CreatedAt = MastodonJson.Time(n.CreatedAt)
}
@@ -104,6 +110,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
Noindex = !actor.Indexable,
HideCollections = true,//follower and following lists are never shown to others, only their counts
Group = actor.Kind == LocalActorKind.Group,
Limited = actor.Silenced ? true : default(bool?),//silenced by the staff (R6), as Mastodon says it
CreatedAt = MastodonJson.Day(actor.Published),
Note = ActivityPubRenderer.Html(actor.Summary),
Url = actor.HtmlUrl,
@@ -144,6 +151,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
Discoverable = foreign.IsDiscoverable,
Indexable = foreign.IsIndexable,
Memorial = foreign.IsMemorial ? true : default(bool?),
Limited = foreign.SilencedAt.HasValue || DomainSilenced(foreign) ? true : default(bool?),
CreatedAt = MastodonJson.Day(foreign.Published ?? foreign.CreatedAt),
Emojis = Emojis(foreign.Emojis),
Fields = foreign.Fields.Select(f => new Field { Name = f.Key, Value = f.Value }).ToList(),
@@ -161,6 +169,10 @@ namespace PrivaPub.Api.Mastodon.Mappers
StatusesCount = foreign.StatusesCount ?? 0
};
bool DomainSilenced(ForeignAvatar foreign) =>
_domainBlocks != default && Uri.TryCreate(foreign.ActorURI, UriKind.Absolute, out var uri)
&& _domainBlocks.Find(uri.Host)?.Severity == DomainBlockSeverity.Silence;
public async Task<Account> Account(string id, CancellationToken token) =>
(await Accounts(new[] { id }, token)).GetValueOrDefault(id);
@@ -171,11 +183,14 @@ namespace PrivaPub.Api.Mastodon.Mappers
if (wanted.Count == 0)
return accounts;
foreach (var avatar in await _dbEntities.Avatars.Match(a => wanted.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync(token))
accounts[avatar.ID] = await Local(_localActors.FromAvatar(avatar), false, token);
// a suspended account, here or elsewhere, and a banned login's personas are shown to nobody (R6): their posts and
// what they notify are left out with them, wherever a list holds them
foreach (var avatar in await _dbEntities.Avatars.Match(a => wanted.Contains(a.ID) && !a.DeletionAt.HasValue && !a.SuspendedAt.HasValue).ExecuteAsync(token))
if (_localActors.FromAvatar(avatar) is { Suspended: false } persona)
accounts[avatar.ID] = await Local(persona, false, token);
foreach (var group in await _dbEntities.Groups.Match(g => wanted.Contains(g.ID) && !g.DeletionAt.HasValue && g.Kind == GroupKind.Community).ExecuteAsync(token))
accounts[group.ID] = await Local(_localActors.FromGroup(group), false, token);
var foreigns = await _dbEntities.ForeignAvatars.Match(f => wanted.Contains(f.ID) && !f.DeletionAt.HasValue).ExecuteAsync(token);
var foreigns = await _dbEntities.ForeignAvatars.Match(f => wanted.Contains(f.ID) && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue).ExecuteAsync(token);
foreach (var foreign in foreigns)
accounts[foreign.ID] = Foreign(foreign);
// an account that moved shows where to, once the new account names it as one of its own (as a Move is believed)