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)}";
}
}