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

@@ -2,50 +2,90 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using MongoDB.Entities;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Admin;
using PrivaPub.ClientModels.User;
using PrivaPub.Domain.Moderation;
using PrivaPub.Domain.Timelines;
using PrivaPub.Extensions;
using PrivaPub.Federation.Actors;
using PrivaPub.Models;
using PrivaPub.Models.Group;
using PrivaPub.Models.User;
using PrivaPub.Resources;
using PrivaPub.Services;
using PrivaPub.StaticServices;
using System.ComponentModel.DataAnnotations;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Controllers.ClientToServer
{
// What only administrators do to logins (completion program R6): read them with their personas (the one place a login
// is shown next to its personas), change their role, ban and unban them, delete them, a persona or a group, and turn a
// login's two-factor sign-in off. Every act is kept in the staff's log; an entry about a login names none of its
// personas.
[ApiController,
Route("clientapi/admin")]
Route("clientapi/admin"),
Authorize(Policy = Policies.IsAdmin)]
public class AdminController : ControllerBase
{
const int MaxRootsPage = 100;
readonly IRootUsersService RootUsersService;
readonly ILogger<RootUserController> Logger;
readonly IStringLocalizer Localizer;
readonly IModerationLog _log;
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
public AdminController(ILogger<RootUserController> logger,
IStringLocalizer<GenericRes> localizer,
IRootUsersService rootUsersService)
IRootUsersService rootUsersService,
IModerationLog log,
DbEntities dbEntities,
ILocalActorService localActors)
{
Logger = logger;
Localizer = localizer;
RootUsersService = rootUsersService;
_log = log;
_dbEntities = dbEntities;
_localActors = localActors;
}
// an administrator never removes, bans or unbans itself; the request's own list is left as it came
UsersIds Others(UsersIds usersIds)
{
var selfId = User.GetUserId();
return new UsersIds { UserIdList = usersIds.UserIdList.Where(id => id != selfId).ToList() };
return new UsersIds { UserIdList = usersIds.UserIdList.Where(id => id != selfId).Distinct().ToList() };
}
[HttpDelete, Route("/clientapi/admin/remove/users"), Authorize(Policy = Policies.IsAdmin)]
public async Task<IActionResult> RemoveUsers([Required] UsersIds usersIds)
// the logins a request names that exist and are not deleted, by id: what the log names them
static async Task<Dictionary<string, string>> Named(UsersIds usersIds, CancellationToken token)
{
var ids = usersIds.UserIdList;
return ids.Count == 0
? new Dictionary<string, string>()
: (await DB.Default.Find<RootUser>().Match(u => ids.Contains(u.ID) && u.DeletedAt == null).ExecuteAsync(token)).ToDictionary(u => u.ID, u => u.UserName);
}
[HttpDelete, Route("/clientapi/admin/remove/users")]
public async Task<IActionResult> RemoveUsers([Required] UsersIds usersIds, CancellationToken token)
{
var result = new WebResult();
try
{
result = await RootUsersService.RemoveUserAsync(Others(usersIds));
var others = Others(usersIds);
var named = await Named(others, token);
result = await RootUsersService.RemoveUserAsync(others);
if (!result.IsValid)
return StatusCode(result.StatusCode, result);
foreach (var (id, name) in named)
await _log.Write(User.GetUserId(), AdminActions.RootDelete, AdminTargets.Root, id, name, token: CancellationToken.None);
return Ok();
}
catch (Exception ex)
@@ -55,19 +95,31 @@ namespace PrivaPub.Controllers.ClientToServer
}
}
[HttpPost, Authorize(Policy = Policies.IsAdmin), Route("/clientapi/admin/ban/users")]
public async Task<IActionResult> BanUsers([Required] UsersIds usersIds)
// A ban ends the login's sessions and holds its personas back as suspended ones (Restrictions), without writing a word
// on any of them; unbanning gives them back as they were.
[HttpPost, Route("/clientapi/admin/ban/users")]
public async Task<IActionResult> BanUsers([Required] UsersIds usersIds, [FromServices] IRootSessions sessions, CancellationToken token)
{
if (!ModelState.IsValid)
return BadRequest(usersIds);
var result = new WebResult();
try
{
result = await RootUsersService.BanUserAsync(Others(usersIds));
var others = Others(usersIds);
var named = await Named(others, token);
var banned = (await DB.Default.Find<RootUser>().Match(u => others.UserIdList.Contains(u.ID) && u.IsBanned).ExecuteAsync(token)).Select(u => u.ID).ToHashSet();
result = await RootUsersService.BanUserAsync(others);
if (!result.IsValid)
return StatusCode(result.StatusCode, result);
await Restrictions.Reload(CancellationToken.None);
foreach (var (id, name) in named.Where(n => !banned.Contains(n.Key)))
{
await sessions.Revoke(id, CancellationToken.None);
foreach (var link in await _dbEntities.RootToAvatars.Match(ra => ra.RootId == id).ExecuteAsync(token))
Streams.CloseFor(link.AvatarId);
await _log.Write(User.GetUserId(), AdminActions.RootBan, AdminTargets.Root, id, name, token: CancellationToken.None);
}
return Ok();
}
catch (Exception ex)
@@ -77,18 +129,23 @@ namespace PrivaPub.Controllers.ClientToServer
}
}
[HttpPost, Authorize(Policy = Policies.IsAdmin), Route("/clientapi/admin/unban/users")]
public async Task<IActionResult> UnbanUsers([Required] UsersIds usersIds)
[HttpPost, Route("/clientapi/admin/unban/users")]
public async Task<IActionResult> UnbanUsers([Required] UsersIds usersIds, CancellationToken token)
{
if (!ModelState.IsValid)
return BadRequest(usersIds);
var result = new WebResult();
try
{
result = await RootUsersService.UnbanUserAsync(Others(usersIds));
var others = Others(usersIds);
var named = await Named(others, token);
var banned = (await DB.Default.Find<RootUser>().Match(u => others.UserIdList.Contains(u.ID) && u.IsBanned).ExecuteAsync(token)).Select(u => u.ID).ToHashSet();
result = await RootUsersService.UnbanUserAsync(others);
if (!result.IsValid)
return StatusCode(result.StatusCode, result);
await Restrictions.Reload(CancellationToken.None);
foreach (var (id, name) in named.Where(n => banned.Contains(n.Key)))
await _log.Write(User.GetUserId(), AdminActions.RootUnban, AdminTargets.Root, id, name, token: CancellationToken.None);
return Ok();
}
catch (Exception ex)
@@ -97,5 +154,187 @@ namespace PrivaPub.Controllers.ClientToServer
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
}
}
// The logins, newest first, each with its personas and the groups they own; q finds a login by its name, or by the
// name of one of its personas.
[HttpGet, Route("/clientapi/admin/roots")]
public async Task<IActionResult> Roots([FromQuery] string q, [FromQuery] string before, [FromQuery] int limit = 50, CancellationToken token = default)
{
limit = Math.Clamp(limit, 1, MaxRootsPage);
var query = DB.Default.Find<RootUser>().Match(u => u.DeletedAt == null);
var name = q?.Trim().TrimStart('@').Split('@')[0].ToLowerInvariant();
if (!string.IsNullOrEmpty(name))
{
var personaIds = (await DB.Default.Find<Avatar>().Match(a => a.UserName.StartsWith(name) && !a.DeletionAt.HasValue)
.Project(a => a.Include(x => x.ID)).Limit(500).ExecuteAsync(token)).Select(a => a.ID).ToList();
var owners = personaIds.Count == 0
? new List<string>()
: (await DB.Default.Find<RootToAvatar>().Match(ra => personaIds.Contains(ra.AvatarId)).ExecuteAsync(token)).Select(ra => ra.RootId).Distinct().ToList();
query.Match(u => u.UserName.StartsWith(name) || owners.Contains(u.ID));
}
if (!string.IsNullOrEmpty(before))
{
if (!MongoDB.Bson.ObjectId.TryParse(before, out _))
return BadRequest();
query.Match(f => f.Lt(u => u.ID, before));
}
var roots = await query.Sort(u => u.ID, Order.Descending).Limit(limit).ExecuteAsync(token);
return Ok(new ViewAdminRootsPage
{
Items = await Views(roots, token),
Next = roots.Count == limit ? roots[^1].ID : default
});
}
[HttpGet, Route("/clientapi/admin/roots/{id}")]
public async Task<IActionResult> Root(string id, CancellationToken token)
{
var root = await DB.Default.Find<RootUser>().MatchID(id).Match(u => u.DeletedAt == null).ExecuteFirstAsync(token);
return root == default ? NotFound() : Ok((await Views(new List<RootUser> { root }, token))[0]);
}
// A login's role: user, moderator or administrator. Never one's own, and never the last administrator's.
[HttpPost, Route("/clientapi/admin/roots/{id}/role")]
public async Task<IActionResult> Role(string id, RootRoleForm form, CancellationToken token)
{
if (!ModelState.IsValid || !RootUser.TryParseRole(form.Role, out var role))
return UnprocessableEntity(new WebResult().Invalidate(Localizer["Unknown role."], StatusCodes.Status422UnprocessableEntity));
if (id == User.GetUserId())
return Conflict(new WebResult().Invalidate(Localizer["You can't change your own role."], StatusCodes.Status409Conflict));
var root = await DB.Default.Find<RootUser>().MatchID(id).Match(u => u.DeletedAt == null).ExecuteFirstAsync(token);
if (root == default)
return NotFound();
var was = root.Role;
if (was == role)
return Ok((await Views(new List<RootUser> { root }, token))[0]);
if (was == RootRole.Admin && !await OtherAdministrators(root.ID, token))
return Conflict(new WebResult().Invalidate(Localizer["The last administrator can't be demoted."], StatusCodes.Status409Conflict));
root.SetRole(role);
await DB.Default.Update<RootUser>().MatchID(root.ID).Modify(u => u.Policies, root.Policies).Modify(u => u.UpdatedAt, root.UpdatedAt)
.ExecuteAsync(CancellationToken.None);
// two administrators demoting each other at once: this one's act is undone, so one always remains
if (was == RootRole.Admin && !await OtherAdministrators(root.ID, CancellationToken.None))
{
root.SetRole(was);
await DB.Default.Update<RootUser>().MatchID(root.ID).Modify(u => u.Policies, root.Policies).ExecuteAsync(CancellationToken.None);
return Conflict(new WebResult().Invalidate(Localizer["The last administrator can't be demoted."], StatusCodes.Status409Conflict));
}
if (role == RootRole.Admin)
await ServerSetup.Seal(root.ID, default);//an administrator exists: the client's setup is closed
await _log.Write(User.GetUserId(), AdminActions.RootRole, AdminTargets.Root, root.ID, root.UserName,
note: $"{RoleName(was)} → {RoleName(role)}", token: CancellationToken.None);
return Ok((await Views(new List<RootUser> { root }, token))[0]);
}
// an administrator other than this login that can still act
static Task<bool> OtherAdministrators(string rootId, CancellationToken token) =>
DB.Default.Find<RootUser>().Match(u => u.ID != rootId && u.DeletedAt == null && !u.IsBanned && u.Policies.Contains(Policies.IsAdmin)).ExecuteAnyAsync(token);
// A login that lost its authenticator and its recovery codes, as `PrivaPub admin reset-totp` does it: its two-factor
// sign-in goes, its password and sessions stay. Never one's own (that is /clientapi/user/totp/disable, with a code).
[HttpPost, Route("/clientapi/admin/roots/{id}/totp/reset")]
public async Task<IActionResult> ResetTotp(string id, CancellationToken token)
{
if (id == User.GetUserId())
return Conflict(new WebResult().Invalidate(Localizer["Turn your own two-factor sign-in off from your account."], StatusCodes.Status409Conflict));
var root = await DB.Default.Find<RootUser>().MatchID(id).Match(u => u.DeletedAt == null).ExecuteFirstAsync(token);
if (root == default)
return NotFound();
await DB.Default.Update<RootUser>().MatchID(root.ID).Modify(u => u.Totp, null).ExecuteAsync(CancellationToken.None);
await _log.Write(User.GetUserId(), AdminActions.RootResetTotp, AdminTargets.Root, root.ID, root.UserName, token: CancellationToken.None);
return Ok();
}
// A persona deleted by an administrator, who types their own password again: as its login would delete it
// (PersonaRemoval), the groups it owns with it. The log keeps it without who acted when it is the administrator's own.
[HttpPost, Route("/clientapi/admin/personas/{id}/delete")]
public async Task<IActionResult> DeletePersona(string id, AdminPasswordForm form, [FromServices] IPersonaRemoval personas, CancellationToken token)
{
if (!ModelState.IsValid)
return BadRequest(new WebResult().Invalidate(Localizer["Invalid model."]));
if (!await RootUsersService.PasswordMatches(User.GetUserId(), form.Password))
return StatusCode(StatusCodes.Status403Forbidden, new WebResult().Invalidate(Localizer[Services.RootUsersService.NoMatch], StatusCodes.Status403Forbidden));
var avatar = await _dbEntities.Avatars.MatchID(id).Match(a => !a.DeletionAt.HasValue).ExecuteFirstAsync(token);
if (avatar == default)
return NotFound();
var label = "@" + _localActors.FromAvatar(avatar).Handle;
if (!await personas.Remove(avatar.ID, CancellationToken.None))
return NotFound();
await _log.Write(User.GetUserId(), AdminActions.PersonaDelete, AdminTargets.Persona, avatar.ID, label, note: form.Note, token: CancellationToken.None);
return Ok();
}
[HttpPost, Route("/clientapi/admin/groups/{id}/delete")]
public async Task<IActionResult> DeleteGroup(string id, AdminPasswordForm form, [FromServices] IGroupRemoval groups, CancellationToken token)
{
if (!ModelState.IsValid)
return BadRequest(new WebResult().Invalidate(Localizer["Invalid model."]));
if (!await RootUsersService.PasswordMatches(User.GetUserId(), form.Password))
return StatusCode(StatusCodes.Status403Forbidden, new WebResult().Invalidate(Localizer[Services.RootUsersService.NoMatch], StatusCodes.Status403Forbidden));
var group = await _dbEntities.Groups.MatchID(id).Match(g => !g.DeletionAt.HasValue).ExecuteFirstAsync(token);
if (group == default)
return NotFound();
var label = "@" + _localActors.FromGroup(group).Handle;
if (!await groups.Remove(group.ID, CancellationToken.None))
return NotFound();
await _log.Write(User.GetUserId(), AdminActions.GroupDelete, AdminTargets.Group, group.ID, label, note: form.Note, token: CancellationToken.None);
return Ok();
}
async Task<List<ViewAdminRoot>> Views(List<RootUser> roots, CancellationToken token)
{
var ids = roots.Select(r => r.ID).ToList();
var links = ids.Count == 0 ? new List<RootToAvatar>() : await _dbEntities.RootToAvatars.Match(ra => ids.Contains(ra.RootId)).ExecuteAsync(token);
var avatarIds = links.Select(l => l.AvatarId).Distinct().ToList();
var avatars = avatarIds.Count == 0
? new Dictionary<string, Avatar>()
: (await _dbEntities.Avatars.Match(a => avatarIds.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync(token)).ToDictionary(a => a.ID);
var owned = avatarIds.Count == 0
? new List<GroupEntity>()
: await _dbEntities.Groups.Match(g => avatarIds.Contains(g.OwnerAvatarId) && !g.DeletionAt.HasValue).ExecuteAsync(token);
var me = User.GetUserId();
return roots.Select(root => new ViewAdminRoot
{
Id = root.ID,
UserName = root.UserName,
Role = RoleName(root.Role),
IsBanned = root.IsBanned,
HasEmail = !string.IsNullOrEmpty(root.Email),
IsEmailValidated = root.IsEmailValidated,
HasTotp = root.Totp?.EnabledAt != default,
IsYou = root.ID == me,
CreatedAt = root.CreatedAt,
Personas = links.Where(l => l.RootId == root.ID && avatars.ContainsKey(l.AvatarId)).Select(l => avatars[l.AvatarId])
.OrderBy(a => a.UserName, StringComparer.Ordinal)
.Select(a =>
{
var persona = _localActors.FromAvatar(a);
return new ViewAdminPersona
{
Id = a.ID,
UserName = a.UserName,
DisplayName = persona.Name,
Handle = persona.Handle,
SilencedAt = a.SilencedAt,
SuspendedAt = a.SuspendedAt,
Groups = owned.Where(g => g.OwnerAvatarId == a.ID).Select(g => new ViewAdminGroup
{
Id = g.ID,
UserName = g.UserName,
Name = g.Name,
IsCommunity = g.Kind == GroupKind.Community,
MembersCount = g.Members.Count
}).ToList()
};
}).ToList()
}).ToList();
}
static string RoleName(RootRole role) => role switch
{
RootRole.Admin => "admin",
RootRole.Moderator => "moderator",
_ => "user"
};
}
}