Files
SocialPub/PrivaPub/Domain/Moderation/ModerationFlags.cs
T
thepraandClaude Opus 5.5 9f68ca3ed0 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
2026-10-17 05:23:45 +02:00

76 lines
4.5 KiB
C#

using MongoDB.Bson;
using MongoDB.Driver;
namespace PrivaPub.Domain.Moderation
{
// Every post's AuthorSuspended and AuthorLimited worked out again from its author's and its server's moderation (R6),
// and every account's AccountState from when it was silenced or suspended: after a restore merged the moderation it keeps
// (ProtectiveMerge), and once by migration _020 for the servers silenced before posts carried the flags. Raw documents,
// since a restore runs before the entities are used. A banned login's personas are never flagged (Restrictions).
public static class ModerationFlags
{
public static async Task<long> Recompute(IMongoDatabase database, CancellationToken token)
{
var posts = database.GetCollection<BsonDocument>("Post");
var flagged = 0L;
await posts.UpdateManyAsync(new BsonDocument("AuthorSuspended", true), Builders<BsonDocument>.Update.Set("AuthorSuspended", false), cancellationToken: token);
await posts.UpdateManyAsync(new BsonDocument("AuthorLimited", true), Builders<BsonDocument>.Update.Set("AuthorLimited", false), cancellationToken: token);
var moderated = new BsonDocument("$or", new BsonArray
{
new BsonDocument("SuspendedAt", new BsonDocument("$ne", BsonNull.Value)),
new BsonDocument("SilencedAt", new BsonDocument("$ne", BsonNull.Value))
});
// personas here: their own posts and boosts
var avatars = database.GetCollection<BsonDocument>("Avatar");
await avatars.UpdateManyAsync(new BsonDocument("AccountState", new BsonDocument("$in", new BsonArray { 1, 2 })),
Builders<BsonDocument>.Update.Set("AccountState", 0), cancellationToken: token);
foreach (var avatar in await (await avatars.FindAsync(moderated, cancellationToken: token)).ToListAsync(token))
{
var (suspended, silenced) = (IsSet(avatar, "SuspendedAt"), IsSet(avatar, "SilencedAt"));
await avatars.UpdateOneAsync(new BsonDocument("_id", avatar["_id"]), Builders<BsonDocument>.Update.Set("AccountState", suspended ? 2 : 1),
cancellationToken: token);
flagged += (await posts.UpdateManyAsync(new BsonDocument { { "GroupUserId", avatar["_id"].ToString() }, { "IsFederatedCopy", false } },
Builders<BsonDocument>.Update.Set("AuthorSuspended", suspended).Set("AuthorLimited", silenced), cancellationToken: token)).ModifiedCount;
}
// accounts elsewhere: what they wrote and boosted (an account that deleted itself stays Deleted)
var foreign = database.GetCollection<BsonDocument>("ForeignAvatar");
await foreign.UpdateManyAsync(new BsonDocument("AccountState", new BsonDocument("$in", new BsonArray { 1, 2 })),
Builders<BsonDocument>.Update.Set("AccountState", 0), cancellationToken: token);
foreach (var account in await (await foreign.FindAsync(moderated, cancellationToken: token)).ToListAsync(token))
{
if (account.GetValue("ActorURI", BsonNull.Value) is not BsonString actor)
continue;
var (suspended, silenced) = (IsSet(account, "SuspendedAt"), IsSet(account, "SilencedAt"));
if (account.GetValue("AccountState", 0).ToInt32() != 4)
await foreign.UpdateOneAsync(new BsonDocument("_id", account["_id"]), Builders<BsonDocument>.Update.Set("AccountState", suspended ? 2 : 1),
cancellationToken: token);
var update = Builders<BsonDocument>.Update.Set("AuthorSuspended", suspended);
if (silenced)
update = update.Set("AuthorLimited", true);
flagged += (await posts.UpdateManyAsync(new BsonDocument { { "ActorURI", actor }, { "IsFederatedCopy", true } }, update, cancellationToken: token))
.ModifiedCount;
}
// servers silenced: every post of theirs, and of their subdomains
var blocks = database.GetCollection<BsonDocument>("DomainBlock");
foreach (var block in await (await blocks.FindAsync(new BsonDocument("Severity", 1), cancellationToken: token)).ToListAsync(token))
if (block.GetValue("Domain", BsonNull.Value) is BsonString domain && !string.IsNullOrWhiteSpace(domain.AsString))
flagged += (await posts.UpdateManyAsync(new BsonDocument
{
{ "ActorURI", OfDomain(domain.AsString) },
{ "IsFederatedCopy", true }
}, Builders<BsonDocument>.Update.Set("AuthorLimited", true), cancellationToken: token)).ModifiedCount;
return flagged;
}
// an actor's address on a server or one of its subdomains
public static BsonRegularExpression OfDomain(string domain) =>
new($"^https?://([^/]*\\.)?{System.Text.RegularExpressions.Regex.Escape(Federation.Moderation.DomainBlocks.Normalise(domain))}(:[0-9]+)?(/|$)", "i");
static bool IsSet(BsonDocument row, string field) => row.TryGetValue(field, out var value) && !value.IsBsonNull;
}
}