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
112 lines
4.7 KiB
C#
112 lines
4.7 KiB
C#
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);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|