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 { /// Silences, unsilences, suspends or unsuspends a persona here or an account elsewhere (ModerationActions); /// Data is the account as moderators see it. Task Moderate(string byRootId, string accountId, string action, string note, string warning, string reportId, CancellationToken token); /// 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). Task RemovePost(string byRootId, string postId, string note, string warning, string reportId, CancellationToken token); /// A server's block changed: its accounts' posts are limited while it is silenced, and only then. Task DomainChanged(string domain, CancellationToken token); Task 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 _localizer; readonly ILogger _logger; public AccountModeration(DbEntities dbEntities, ILocalActorService localActors, IModerationLog log, IOpenIddictTokenManager tokens, IOpenIddictAuthorizationManager authorizations, IStatusService statuses, IDomainBlocks domainBlocks, IStringLocalizer localizer, ILogger logger) { _dbEntities = dbEntities; _localActors = localActors; _log = log; _tokens = tokens; _authorizations = authorizations; _statuses = statuses; _domainBlocks = domainBlocks; _localizer = localizer; _logger = logger; } public async Task 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 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().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().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 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().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().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 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().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().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().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().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 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)}"; } }