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:
thepraandClaude Opus 5.5 committed 2026-10-17 05:23:45 +02:00
1 parent 70d1ffe9a2
commit 9f68ca3ed0
71 files changed
+2229 -219

No files matched your search

@@ -21,13 +21,22 @@ namespace PrivaPub.Controllers.ClientToServer
{
const int MaxLogPage = 100;
const int MaxAccountsPage = 100;
readonly IInstanceSettings _settings;
readonly IModerationLog _log;
readonly IAccountModeration _moderation;
readonly Federation.Actors.ILocalActorService _localActors;
readonly Microsoft.Extensions.Localization.IStringLocalizer<Resources.GenericRes> _localizer;
public ModeratorController(IInstanceSettings settings, IModerationLog log)
public ModeratorController(IInstanceSettings settings, IModerationLog log, IAccountModeration moderation,
Federation.Actors.ILocalActorService localActors, Microsoft.Extensions.Localization.IStringLocalizer<Resources.GenericRes> localizer)
{
_settings = settings;
_log = log;
_moderation = moderation;
_localActors = localActors;
_localizer = localizer;
}
[HttpGet, Route("/clientapi/moderator/reports")]
@@ -49,6 +58,8 @@ namespace PrivaPub.Controllers.ClientToServer
Comment = r.Comment,
Forwarded = r.Forwarded,
IsResolved = r.IsResolved,
Resolution = r.Resolution,
ResolutionNote = r.ResolutionNote,
CreatedAt = r.CreatedAt,
ResolvedAt = r.ResolvedAt,
RemoteResolvedAt = r.RemoteResolvedAt,
@@ -56,22 +67,136 @@ namespace PrivaPub.Controllers.ClientToServer
}));
}
// A report resolved, and with it what the staff does about it (R6): nothing more, the reported account silenced or
// suspended, or the reported posts removed. The act comes first: one that fails leaves the report open. The body is
// optional (a report only marked resolved, as before R6).
[HttpPost, Route("/clientapi/moderator/reports/{id}/resolve")]
public async Task<IActionResult> Resolve(string id, CancellationToken token)
public async Task<IActionResult> Resolve(string id, [FromBody(EmptyBodyBehavior = Microsoft.AspNetCore.Mvc.ModelBinding.EmptyBodyBehavior.Allow)] ResolveReportForm form,
CancellationToken token)
{
form ??= new ResolveReportForm();
var action = string.IsNullOrWhiteSpace(form.Action) ? ModerationActions.None : form.Action.Trim().ToLowerInvariant();
if (!ModerationActions.IsResolution(action))
return UnprocessableEntity(new WebResult().Invalidate(_localizer["Unknown moderation action."], StatusCodes.Status422UnprocessableEntity));
var by = User.GetUserId();
var result = await DB.Default.Update<Report>().MatchID(id)
var report = await DB.Default.Find<Report>().MatchID(id).ExecuteFirstAsync(token);
if (report == default)
return NotFound();
switch (action)
{
case ModerationActions.Silence or ModerationActions.Suspend:
var moderated = await _moderation.Moderate(by, report.TargetAccountId, action, form.Note, form.Warning, report.ID, token);
if (!moderated.IsValid)
return StatusCode(moderated.StatusCode, moderated);
break;
case ModerationActions.RemovePosts:
if (report.PostIds.Count == 0)
return UnprocessableEntity(new WebResult().Invalidate(_localizer["The report names no posts."], StatusCodes.Status422UnprocessableEntity));
foreach (var postId in report.PostIds)
{
var removed = await _moderation.RemovePost(by, postId, form.Note, form.Warning, report.ID, token);
if (!removed.IsValid && removed.StatusCode != StatusCodes.Status404NotFound)//one already gone is removed
return StatusCode(removed.StatusCode, removed);
}
break;
}
await DB.Default.Update<Report>().MatchID(id)
.Modify(r => r.IsResolved, true)
.Modify(r => r.ResolvedAt, DateTime.UtcNow)
.Modify(r => r.ResolvedBy, by)
.ExecuteAsync(token);
if (result.MatchedCount == 0)
return NotFound();
var report = await DB.Default.Find<Report>().MatchID(id).ExecuteFirstAsync(token);
await _log.Write(by, AdminActions.ReportResolve, AdminTargets.Report, id, report?.Category, reportId: id, token: token);
.Modify(r => r.Resolution, action)
.Modify(r => r.ResolutionNote, string.IsNullOrWhiteSpace(form.Note) ? null : form.Note.Trim())
.ExecuteAsync(CancellationToken.None);
await _log.Write(by, AdminActions.ReportResolve, AdminTargets.Report, id, report.Category, reportId: id,
note: action == ModerationActions.None ? form.Note : string.IsNullOrWhiteSpace(form.Note) ? action : $"{action}: {form.Note.Trim()}",
token: CancellationToken.None);
return Ok();
}
// Accounts to moderate, found by a name (a persona's, or user@host for an account elsewhere) or an actor's address:
// personas here, never their logins, and accounts elsewhere PrivaPub holds
[HttpGet, Route("/clientapi/moderator/accounts")]
public async Task<IActionResult> Accounts([FromQuery] string q, [FromQuery] int limit = 20, CancellationToken token = default)
{
limit = Math.Clamp(limit, 1, 40);
q = q?.Trim().TrimStart('@');
var items = new List<ViewModeratedAccount>();
if (string.IsNullOrEmpty(q) || q.Length > 300)
return Ok(new ViewModeratedAccountsPage { Items = items });
if (Uri.TryCreate(q, UriKind.Absolute, out var address) && address.Scheme is "https" or "http")
{
if (await _localActors.FindByUri(q, token) is { Kind: Models.Federation.LocalActorKind.Person } local
&& await DB.Default.Find<Avatar>().MatchID(local.Id).ExecuteFirstAsync(token) is { } persona)
items.Add(_moderation.View(persona));
items.AddRange((await DB.Default.Find<ForeignAvatar>().Match(f => f.ActorURI == q || f.Url == q).Limit(limit).ExecuteAsync(token)).Select(_moderation.View));
return Ok(new ViewModeratedAccountsPage { Items = items.Take(limit).ToList() });
}
var parts = q.Split('@', 2);
var name = parts[0];
var domain = parts.Length > 1 ? parts[1].ToLowerInvariant() : default;
var here = new Uri(_localActors.BaseAddress).Authority;
if (domain == default || domain == here)
{
var lowered = name.ToLowerInvariant();
items.AddRange((await DB.Default.Find<Avatar>().Match(a => a.UserName.StartsWith(lowered) && !a.DeletionAt.HasValue)
.Sort(a => a.UserName, Order.Ascending).Limit(limit).ExecuteAsync(token)).Select(_moderation.View));
}
if (domain != here && items.Count < limit && name.Length > 0)
{
var userName = new MongoDB.Bson.BsonRegularExpression("^" + System.Text.RegularExpressions.Regex.Escape(name), "i");
var remote = DB.Default.Find<ForeignAvatar>().Match(f => f.Regex(a => a.UserName, userName));
if (domain != default)
remote.Match(f => f.Regex(a => a.Domain, new MongoDB.Bson.BsonRegularExpression("^" + System.Text.RegularExpressions.Regex.Escape(domain), "i")));
items.AddRange((await remote.Match(f => !f.DeletionAt.HasValue).Sort(f => f.UserName, Order.Ascending).Limit(limit - items.Count).ExecuteAsync(token))
.Select(_moderation.View));
}
return Ok(new ViewModeratedAccountsPage { Items = items });
}
// what the staff silenced or suspended, here and elsewhere, most recently moderated first (a banned login's personas
// are never among them: the ban is the login's, and only administrators see logins)
[HttpGet, Route("/clientapi/moderator/accounts/moderated")]
public async Task<IActionResult> Moderated([FromQuery] int offset = 0, [FromQuery] int limit = 50, CancellationToken token = default)
{
offset = Math.Max(0, offset);
limit = Math.Clamp(limit, 1, MaxAccountsPage);
var locals = await DB.Default.Find<Avatar>().Match(a => (a.SilencedAt != null || a.SuspendedAt != null) && a.DeletionAt == null).ExecuteAsync(token);
var remotes = await DB.Default.Find<ForeignAvatar>().Match(f => f.SilencedAt != null || f.SuspendedAt != null).ExecuteAsync(token);
var all = locals.Select(_moderation.View).Concat(remotes.Select(_moderation.View))
.OrderByDescending(a => Latest(a.SilencedAt, a.SuspendedAt)).ThenBy(a => a.Id, StringComparer.Ordinal).ToList();
return Ok(new ViewModeratedAccountsPage
{
Items = all.Skip(offset).Take(limit).ToList(),
Next = all.Count > offset + limit ? offset + limit : default(int?)
});
}
static DateTime Latest(DateTime? a, DateTime? b) => (a ?? DateTime.MinValue) > (b ?? DateTime.MinValue) ? a ?? DateTime.MinValue : b ?? DateTime.MinValue;
[HttpGet, Route("/clientapi/moderator/accounts/{id}")]
public async Task<IActionResult> Account(string id, CancellationToken token) =>
await _moderation.View(id, token) is { } account ? Ok(account) : NotFound();
// silence, unsilence, suspend or unsuspend a persona here or an account elsewhere
[HttpPost, Route("/clientapi/moderator/accounts/moderate")]
public async Task<IActionResult> Moderate(ModerateAccountForm form, CancellationToken token)
{
if (!ModelState.IsValid)
return BadRequest(new WebResult().Invalidate(_localizer["Invalid model."]));
var result = await _moderation.Moderate(User.GetUserId(), form.AccountId, form.Action.Trim().ToLowerInvariant(), form.Note, form.Warning, default, token);
return result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result);
}
// a post taken down: deleted when it is ours (the deletion federates), hidden here when it is a copy
[HttpPost, Route("/clientapi/moderator/posts/remove")]
public async Task<IActionResult> RemovePost(RemovePostForm form, CancellationToken token)
{
if (!ModelState.IsValid)
return BadRequest(new WebResult().Invalidate(_localizer["Invalid model."]));
var result = await _moderation.RemovePost(User.GetUserId(), form.PostId, form.Note, form.Warning, default, token);
return result.IsValid ? Ok() : StatusCode(result.StatusCode, result);
}
// The staff's log, newest first, a page at a time (before: the last id of the page before). Administrators read all
// of it; a moderator who isn't one reads what isn't about a login, and who acted only when it was the command line or
// themselves.