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
@@ -0,0 +1,339 @@
|
||||
using Microsoft.Extensions.Localization;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using OpenIddict.Abstractions;
|
||||
|
||||
using PrivaPub.ClientModels;
|
||||
using PrivaPub.ClientModels.Admin;
|
||||
using PrivaPub.Domain.Statuses;
|
||||
using PrivaPub.Domain.Timelines;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Moderation;
|
||||
using PrivaPub.Models;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.Resources;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Domain.Moderation
|
||||
{
|
||||
public interface IAccountModeration
|
||||
{
|
||||
/// <summary>Silences, unsilences, suspends or unsuspends a persona here or an account elsewhere (ModerationActions);
|
||||
/// Data is the account as moderators see it.</summary>
|
||||
Task<WebResult> Moderate(string byRootId, string accountId, string action, string note, string warning, string reportId, CancellationToken token);
|
||||
|
||||
/// <summary>Takes a post down: a post here is deleted as its author deletes it, a copy of one from elsewhere is hidden
|
||||
/// here (Post.ModeratedAt).</summary>
|
||||
Task<WebResult> RemovePost(string byRootId, string postId, string note, string warning, string reportId, CancellationToken token);
|
||||
|
||||
/// <summary>A server's block changed: its accounts' posts are limited while it is silenced, and only then.</summary>
|
||||
Task DomainChanged(string domain, CancellationToken token);
|
||||
|
||||
Task<ViewModeratedAccount> View(string accountId, CancellationToken token);
|
||||
ViewModeratedAccount View(Avatar avatar);
|
||||
ViewModeratedAccount View(ForeignAvatar account);
|
||||
}
|
||||
|
||||
// The staff's acts on accounts and posts (completion program R6; moderators' and administrators' alike). What they do,
|
||||
// following the AuthorGone pattern: the account carries when it was silenced or suspended, its posts carry
|
||||
// AuthorLimited or AuthorSuspended so every query can leave them out, and Restrictions knows it in memory.
|
||||
//
|
||||
// A persona here:
|
||||
// - silenced: out of the public and hashtag timelines, trends, the directory, search and the relays; its followers still
|
||||
// get what it posts, and it is told (a moderation_warning);
|
||||
// - suspended: its tokens are revoked and refused, its open streams close, it delivers nothing, its posts and collections
|
||||
// answer 404 to everyone, and nothing new is told to other servers (owner decision 2026-10-17): its actor stays as it
|
||||
// was, so lifting the suspension gives everything back as it was. It is warned; it reads the warning once it is lifted,
|
||||
// and its login sees it suspended among its personas meanwhile.
|
||||
// An account elsewhere: the same flags on its posts here; suspended, what it sends is dropped (its own Delete aside)
|
||||
// and nothing is delivered to its own inbox. It is told nothing.
|
||||
//
|
||||
// Every act is kept in the staff's log (ModerationLog), without who acted when the persona is the acting login's own.
|
||||
public class AccountModeration : IAccountModeration
|
||||
{
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly IModerationLog _log;
|
||||
readonly IOpenIddictTokenManager _tokens;
|
||||
readonly IOpenIddictAuthorizationManager _authorizations;
|
||||
readonly IStatusService _statuses;
|
||||
readonly IDomainBlocks _domainBlocks;
|
||||
readonly IStringLocalizer<GenericRes> _localizer;
|
||||
readonly ILogger<AccountModeration> _logger;
|
||||
|
||||
public AccountModeration(DbEntities dbEntities, ILocalActorService localActors, IModerationLog log, IOpenIddictTokenManager tokens,
|
||||
IOpenIddictAuthorizationManager authorizations, IStatusService statuses, IDomainBlocks domainBlocks, IStringLocalizer<GenericRes> localizer,
|
||||
ILogger<AccountModeration> logger)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
_log = log;
|
||||
_tokens = tokens;
|
||||
_authorizations = authorizations;
|
||||
_statuses = statuses;
|
||||
_domainBlocks = domainBlocks;
|
||||
_localizer = localizer;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<WebResult> Moderate(string byRootId, string accountId, string action, string note, string warning, string reportId,
|
||||
CancellationToken token)
|
||||
{
|
||||
var result = new WebResult();
|
||||
if (!ModerationActions.IsAccountAction(action))
|
||||
return result.Invalidate(_localizer["Unknown moderation action."], StatusCodes.Status422UnprocessableEntity);
|
||||
try
|
||||
{
|
||||
var avatar = string.IsNullOrEmpty(accountId) ? default : await _dbEntities.Avatars.MatchID(accountId).Match(a => !a.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
||||
if (avatar != default)
|
||||
{
|
||||
result.Data = await Persona(byRootId, avatar, action, note, warning, reportId, token);
|
||||
return result;
|
||||
}
|
||||
var account = string.IsNullOrEmpty(accountId) ? default : await _dbEntities.ForeignAvatars.MatchID(accountId).ExecuteFirstAsync(token);
|
||||
if (account == default)
|
||||
return result.Invalidate(_localizer["Account not found."], StatusCodes.Status404NotFound);
|
||||
result.Data = await Remote(byRootId, account, action, note, reportId, token);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "{Service}.{Method}({Action})", nameof(AccountModeration), nameof(Moderate), action);
|
||||
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
async Task<ViewModeratedAccount> Persona(string byRootId, Avatar avatar, string action, string note, string warning, string reportId,
|
||||
CancellationToken token)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var (silenced, suspended) = (avatar.SilencedAt, avatar.SuspendedAt);
|
||||
switch (action)
|
||||
{
|
||||
case ModerationActions.Silence:
|
||||
silenced ??= now;
|
||||
break;
|
||||
case ModerationActions.Unsilence:
|
||||
silenced = null;
|
||||
break;
|
||||
case ModerationActions.Suspend:
|
||||
suspended ??= now;
|
||||
break;
|
||||
case ModerationActions.Unsuspend:
|
||||
suspended = null;
|
||||
break;
|
||||
}
|
||||
var changed = silenced.HasValue != avatar.SilencedAt.HasValue || suspended.HasValue != avatar.SuspendedAt.HasValue;
|
||||
avatar.SilencedAt = silenced;
|
||||
avatar.SuspendedAt = suspended;
|
||||
avatar.AccountState = suspended.HasValue ? AvatarAccountState.Suspended : silenced.HasValue ? AvatarAccountState.Silenced : AvatarAccountState.Normal;
|
||||
if (!string.IsNullOrWhiteSpace(note))
|
||||
avatar.ModerationNote = note.Trim();
|
||||
await DB.Default.Update<Avatar>().MatchID(avatar.ID)
|
||||
.Modify(a => a.SilencedAt, avatar.SilencedAt)
|
||||
.Modify(a => a.SuspendedAt, avatar.SuspendedAt)
|
||||
.Modify(a => a.AccountState, avatar.AccountState)
|
||||
.Modify(a => a.ModerationNote, avatar.ModerationNote)
|
||||
.ExecuteAsync(CancellationToken.None);
|
||||
if (!changed)
|
||||
return View(avatar);
|
||||
|
||||
// its own posts and boosts, everywhere they are read
|
||||
await DB.Default.Update<PostEntity>().Match(p => p.GroupUserId == avatar.ID && !p.IsFederatedCopy)
|
||||
.Modify(p => p.AuthorSuspended, suspended.HasValue)
|
||||
.Modify(p => p.AuthorLimited, silenced.HasValue)
|
||||
.ExecuteAsync(CancellationToken.None);
|
||||
await Restrictions.Reload(CancellationToken.None);
|
||||
var persona = _localActors.FromAvatar(avatar);
|
||||
if (action == ModerationActions.Suspend)
|
||||
{
|
||||
// nothing acts as it from now on: its Mastodon tokens end and its open streams close
|
||||
await _tokens.RevokeBySubjectAsync(avatar.ID, CancellationToken.None);
|
||||
await _authorizations.RevokeBySubjectAsync(avatar.ID, CancellationToken.None);
|
||||
Streams.CloseFor(avatar.ID);
|
||||
}
|
||||
if (action is ModerationActions.Silence or ModerationActions.Suspend)
|
||||
await Social.Notifications.Warn(avatar.ID, persona.Uri, action, warning, default, token);
|
||||
await _log.Write(byRootId, action switch
|
||||
{
|
||||
ModerationActions.Silence => AdminActions.PersonaSilence,
|
||||
ModerationActions.Unsilence => AdminActions.PersonaUnsilence,
|
||||
ModerationActions.Suspend => AdminActions.PersonaSuspend,
|
||||
_ => AdminActions.PersonaUnsuspend
|
||||
}, AdminTargets.Persona, avatar.ID, "@" + persona.Handle, reportId, note, CancellationToken.None);
|
||||
return View(avatar);
|
||||
}
|
||||
|
||||
async Task<ViewModeratedAccount> Remote(string byRootId, ForeignAvatar account, string action, string note, string reportId, CancellationToken token)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var (silenced, suspended) = (account.SilencedAt, account.SuspendedAt);
|
||||
switch (action)
|
||||
{
|
||||
case ModerationActions.Silence:
|
||||
silenced ??= now;
|
||||
break;
|
||||
case ModerationActions.Unsilence:
|
||||
silenced = null;
|
||||
break;
|
||||
case ModerationActions.Suspend:
|
||||
suspended ??= now;
|
||||
break;
|
||||
case ModerationActions.Unsuspend:
|
||||
suspended = null;
|
||||
break;
|
||||
}
|
||||
var changed = silenced.HasValue != account.SilencedAt.HasValue || suspended.HasValue != account.SuspendedAt.HasValue;
|
||||
account.SilencedAt = silenced;
|
||||
account.SuspendedAt = suspended;
|
||||
// an account that deleted itself stays deleted, whatever is done to it
|
||||
if (account.AccountState != AvatarAccountState.Deleted)
|
||||
account.AccountState = suspended.HasValue ? AvatarAccountState.Suspended : silenced.HasValue ? AvatarAccountState.Silenced : AvatarAccountState.Normal;
|
||||
if (!string.IsNullOrWhiteSpace(note))
|
||||
account.ModerationNote = note.Trim();
|
||||
await DB.Default.Update<ForeignAvatar>().MatchID(account.ID)
|
||||
.Modify(a => a.SilencedAt, account.SilencedAt)
|
||||
.Modify(a => a.SuspendedAt, account.SuspendedAt)
|
||||
.Modify(a => a.AccountState, account.AccountState)
|
||||
.Modify(a => a.ModerationNote, account.ModerationNote)
|
||||
.ExecuteAsync(CancellationToken.None);
|
||||
if (!changed)
|
||||
return View(account);
|
||||
|
||||
// what it wrote and boosted, as it arrived and arrives (RemotePosts)
|
||||
var limited = silenced.HasValue || IsDomainSilenced(account.ActorURI);
|
||||
await DB.Default.Update<PostEntity>().Match(p => p.ActorURI == account.ActorURI && p.IsFederatedCopy)
|
||||
.Modify(p => p.AuthorSuspended, suspended.HasValue)
|
||||
.Modify(p => p.AuthorLimited, limited)
|
||||
.ExecuteAsync(CancellationToken.None);
|
||||
await Restrictions.Reload(CancellationToken.None);
|
||||
await _log.Write(byRootId, action switch
|
||||
{
|
||||
ModerationActions.Silence => AdminActions.AccountSilence,
|
||||
ModerationActions.Unsilence => AdminActions.AccountUnsilence,
|
||||
ModerationActions.Suspend => AdminActions.AccountSuspend,
|
||||
_ => AdminActions.AccountUnsuspend
|
||||
}, AdminTargets.Account, account.ID, Handle(account), reportId, note, CancellationToken.None);
|
||||
return View(account);
|
||||
}
|
||||
|
||||
public async Task<WebResult> RemovePost(string byRootId, string postId, string note, string warning, string reportId, CancellationToken token)
|
||||
{
|
||||
var result = new WebResult();
|
||||
try
|
||||
{
|
||||
var post = string.IsNullOrEmpty(postId) ? default : await _dbEntities.Posts.MatchID(postId).ExecuteFirstAsync(token);
|
||||
if (post == default || post.DeletedAt.HasValue || post.ModeratedAt.HasValue)
|
||||
return result.Invalidate(_localizer["Post not found."], StatusCodes.Status404NotFound);
|
||||
|
||||
if (!post.IsFederatedCopy)
|
||||
{
|
||||
var author = await _localActors.FindById(LocalActorKind.Person, post.GroupUserId, token);
|
||||
if (author == default)
|
||||
return result.Invalidate(_localizer["Post not found."], StatusCodes.Status404NotFound);
|
||||
// deleted as its author deletes it: emptied, out of every timeline, and a Delete to its audience (but from a
|
||||
// suspended persona, which delivers nothing)
|
||||
var outcome = await _statuses.Remove(author, post.ID, CancellationToken.None);
|
||||
if (!outcome.Ok)
|
||||
return result.Invalidate(_localizer["Post not found."], outcome.Status);
|
||||
await Social.Notifications.Warn(author.Id, author.Uri, "delete_statuses", warning, new[] { post.ID }, token);
|
||||
await _log.Write(byRootId, AdminActions.PostRemove, AdminTargets.Post, post.ID, $"@{author.Handle}: {post.ObjectURI}", reportId, note,
|
||||
CancellationToken.None);
|
||||
return result;
|
||||
}
|
||||
|
||||
// a copy of a post from elsewhere: kept (its address stays known, so it is never taken in again), hidden here
|
||||
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.ModeratedAt, DateTime.UtcNow).ExecuteAsync(CancellationToken.None);
|
||||
await Fanout.Deleting(post, CancellationToken.None);
|
||||
await _log.Write(byRootId, AdminActions.PostRemove, AdminTargets.Post, post.ID, post.Url ?? post.ObjectURI, reportId, note, CancellationToken.None);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "{Service}.{Method}()", nameof(AccountModeration), nameof(RemovePost));
|
||||
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DomainChanged(string domain, CancellationToken token)
|
||||
{
|
||||
domain = DomainBlocks.Normalise(domain);
|
||||
if (string.IsNullOrEmpty(domain))
|
||||
return;
|
||||
var silenced = _domainBlocks.Find(domain)?.Severity == DomainBlockSeverity.Silence;
|
||||
// the posts of that server and of its subdomains, by their authors' addresses
|
||||
var pattern = ModerationFlags.OfDomain(domain);
|
||||
await DB.Default.Update<PostEntity>().Match(f => f.Regex(p => p.ActorURI, pattern) & f.Eq(p => p.IsFederatedCopy, true))
|
||||
.Modify(p => p.AuthorLimited, silenced)
|
||||
.ExecuteAsync(token);
|
||||
if (silenced)
|
||||
return;
|
||||
// an account silenced on its own stays limited, whatever its server is
|
||||
var own = (await DB.Default.Find<ForeignAvatar>().Match(f => f.Regex(a => a.ActorURI, pattern) & f.Ne(a => a.SilencedAt, null))
|
||||
.Project(a => a.Include(x => x.ActorURI)).ExecuteAsync(token)).Select(a => a.ActorURI).Where(u => !string.IsNullOrEmpty(u)).ToList();
|
||||
foreach (var batch in own.Chunk(500))
|
||||
await DB.Default.Update<PostEntity>().Match(p => batch.Contains(p.ActorURI) && p.IsFederatedCopy)
|
||||
.Modify(p => p.AuthorLimited, true)
|
||||
.ExecuteAsync(token);
|
||||
}
|
||||
|
||||
bool IsDomainSilenced(string actorUri) =>
|
||||
Uri.TryCreate(actorUri, UriKind.Absolute, out var uri) && _domainBlocks.Find(uri.Host)?.Severity == DomainBlockSeverity.Silence;
|
||||
|
||||
public async Task<ViewModeratedAccount> View(string accountId, CancellationToken token)
|
||||
{
|
||||
var avatar = await _dbEntities.Avatars.MatchID(accountId).ExecuteFirstAsync(token);
|
||||
if (avatar != default)
|
||||
return View(avatar);
|
||||
var account = await _dbEntities.ForeignAvatars.MatchID(accountId).ExecuteFirstAsync(token);
|
||||
return account == default ? default : View(account);
|
||||
}
|
||||
|
||||
// a persona as moderators see it: never its login
|
||||
public ViewModeratedAccount View(Avatar avatar)
|
||||
{
|
||||
var persona = _localActors.FromAvatar(avatar);
|
||||
return new ViewModeratedAccount
|
||||
{
|
||||
Id = avatar.ID,
|
||||
IsLocal = true,
|
||||
UserName = avatar.UserName,
|
||||
DisplayName = persona.Name,
|
||||
Handle = persona.Handle,
|
||||
Url = persona.Uri,
|
||||
Domain = persona.Domain,
|
||||
SilencedAt = avatar.SilencedAt,
|
||||
SuspendedAt = avatar.SuspendedAt,
|
||||
Note = avatar.ModerationNote
|
||||
};
|
||||
}
|
||||
|
||||
public ViewModeratedAccount View(ForeignAvatar account)
|
||||
{
|
||||
var block = Uri.TryCreate(account.ActorURI, UriKind.Absolute, out var uri) ? _domainBlocks.Find(uri.Host) : default;
|
||||
return new ViewModeratedAccount
|
||||
{
|
||||
Id = account.ID,
|
||||
IsLocal = false,
|
||||
UserName = account.UserName,
|
||||
DisplayName = account.Name ?? account.UserName,
|
||||
Handle = Handle(account),
|
||||
Url = account.ActorURI,
|
||||
Domain = account.Domain ?? uri?.Host,
|
||||
SilencedAt = account.SilencedAt,
|
||||
SuspendedAt = account.SuspendedAt,
|
||||
DomainSilenced = block?.Severity == DomainBlockSeverity.Silence,
|
||||
DomainSuspended = block?.Severity == DomainBlockSeverity.Suspend,
|
||||
Note = account.ModerationNote
|
||||
};
|
||||
}
|
||||
|
||||
static string Handle(ForeignAvatar account) =>
|
||||
string.IsNullOrEmpty(account.UserName) ? account.ActorURI
|
||||
: $"{account.UserName}@{account.Domain ?? (Uri.TryCreate(account.ActorURI, UriKind.Absolute, out var uri) ? uri.Host : string.Empty)}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,8 @@ namespace PrivaPub.Domain.Moderation
|
||||
|
||||
// The staff's log (completion program R5): administrators read all of it, moderators what isn't about a login
|
||||
// (ModeratorController's /clientapi/moderator/log). An entry never holds an IP address, and never pairs a root with its
|
||||
// own personas: an act on a persona that the acting root owns is kept without who acted.
|
||||
// own personas: an act on a persona that the acting root owns, on a post of it, a group it owns or a report about it
|
||||
// (R6), is kept without who acted.
|
||||
public class ModerationLog : IModerationLog
|
||||
{
|
||||
const int MaxLabel = 200;
|
||||
@@ -33,8 +34,7 @@ namespace PrivaPub.Domain.Moderation
|
||||
{
|
||||
try
|
||||
{
|
||||
if (targetKind == AdminTargets.Persona && byRootId != default && byRootId != AdminAction.Cli && targetId != default
|
||||
&& await DB.Default.Find<RootToAvatar>().Match(r => r.RootId == byRootId && r.AvatarId == targetId).ExecuteAnyAsync(token))
|
||||
if (byRootId != default && byRootId != AdminAction.Cli && await OwnPersona(byRootId, targetKind, targetId, reportId, token))
|
||||
byRootId = default;
|
||||
await DB.Default.SaveAsync(new AdminAction
|
||||
{
|
||||
@@ -53,6 +53,37 @@ namespace PrivaPub.Domain.Moderation
|
||||
}
|
||||
}
|
||||
|
||||
// whether what an entry is about is a persona of the acting login: the persona itself, a post of it, a group it owns,
|
||||
// or a report about it (R6) — any of them would pair the login with that persona
|
||||
static async Task<bool> OwnPersona(string rootId, string targetKind, string targetId, string reportId, CancellationToken token)
|
||||
{
|
||||
var personas = new List<string>();
|
||||
if (targetId != default)
|
||||
switch (targetKind)
|
||||
{
|
||||
case AdminTargets.Persona:
|
||||
personas.Add(targetId);
|
||||
break;
|
||||
case AdminTargets.Post when MongoDB.Bson.ObjectId.TryParse(targetId, out _):
|
||||
if (await DB.Default.Find<Models.Post.Post>().MatchID(targetId).Match(p => !p.IsFederatedCopy)
|
||||
.Project(p => p.Include(x => x.GroupUserId)).ExecuteFirstAsync(token) is { GroupUserId: { } author })
|
||||
personas.Add(author);
|
||||
break;
|
||||
case AdminTargets.Group when MongoDB.Bson.ObjectId.TryParse(targetId, out _):
|
||||
if (await DB.Default.Find<Models.Group.Group>().MatchID(targetId).Project(g => g.Include(x => x.OwnerAvatarId))
|
||||
.ExecuteFirstAsync(token) is { OwnerAvatarId: { } owner })
|
||||
personas.Add(owner);
|
||||
break;
|
||||
}
|
||||
var report = reportId ?? (targetKind == AdminTargets.Report ? targetId : default);
|
||||
if (report != default && MongoDB.Bson.ObjectId.TryParse(report, out _)
|
||||
&& await DB.Default.Find<Models.Social.Report>().MatchID(report).Project(r => r.Include(x => x.TargetAccountId)).ExecuteFirstAsync(token)
|
||||
is { TargetAccountId: { } reported })
|
||||
personas.Add(reported);
|
||||
return personas.Count > 0
|
||||
&& await DB.Default.Find<RootToAvatar>().Match(r => r.RootId == rootId && personas.Contains(r.AvatarId)).ExecuteAnyAsync(token);
|
||||
}
|
||||
|
||||
static string Cut(string text, int max) =>
|
||||
string.IsNullOrEmpty(text) ? text : text.Length <= max ? text : text[..(max - 1)] + "…";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user