R6 moderation in decePub: users and personas, accounts, report actions, deletions, suspended personas

Administration's Users & personas (administrators): logins searched and opened into their personas and groups; roles,
bans, two-factor reset and deletion of a login; silence, suspension and deletion of a persona; a group deleted. Accounts
(moderators): local personas and remote accounts searched or listed as moderated, silenced and suspended with a staff
note and a warning. Reports resolve with an action. ActDialog asks for the note, the warning or the password and is the
act's confirmation. A login deletes its own persona and the groups it owns with its password. Suspended personas can't
be chosen; one refused a token mid-session moves the session to another, or to none, once. Warnings show in
notifications, limited accounts on their profile, and moderators remove a post from its card.

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:41:10 +02:00
1 parent 4c88980374
commit c572471b88
30 files changed
+2604 -67

No files matched your search

+184
View File
@@ -0,0 +1,184 @@
using System.Net;
using decePubClient.Extensions;
using decePubClient.Helpers;
using decePubClient.Models;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Admin;
using PrivaPub.ClientModels.User;
namespace decePubClient.Services
{
/// <summary>
/// The staff's acts on accounts, posts and logins (PrivaPub's completion program R6). Moderators (/clientapi/moderator)
/// find accounts here and elsewhere, read the ones the staff moderated, silence and suspend them and undo it, remove
/// posts and resolve reports with one of those acts; a Note is the staff's own (kept in the log and on the account), a
/// Warning is what a persona here is told (Mastodon's moderation_warning; an account elsewhere is told nothing).
/// Administrators (/clientapi/admin) also read the logins with their personas and the groups those own, change a login's
/// role, ban, unban and delete it, turn its two-factor sign-in off, and delete a persona or a group with their own
/// password. PrivaPub checks the role on every call. Lists and pages come back null on failure; nothing throws.
/// </summary>
public class ModerationService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
/// <summary>Accounts found by a name, user@host or an actor's address: personas here (never their logins) and the
/// accounts elsewhere PrivaPub holds. Null on failure.</summary>
public async Task<ViewModeratedAccountsPage> SearchAccounts(string query, int limit, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_ModeratorAccounts, [$"q={Uri.EscapeDataString(query ?? "")}", $"limit={limit}"],
cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<ViewModeratedAccountsPage>(cancellationToken);
await LogFailure(response, nameof(SearchAccounts), cancellationToken);
return null;
}
/// <summary>What the staff silenced or suspended, here and elsewhere, most recently first; Next is the next page's
/// offset, null on the last. Null on failure.</summary>
public async Task<ViewModeratedAccountsPage> ModeratedAccounts(int offset, int limit, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_ModeratedAccounts, [$"offset={offset}", $"limit={limit}"],
cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<ViewModeratedAccountsPage>(cancellationToken);
await LogFailure(response, nameof(ModeratedAccounts), cancellationToken);
return null;
}
/// <summary>Silences, unsilences, suspends or unsuspends a persona here or an account elsewhere (ModerationActions);
/// Data is the account as moderators see it now (ViewModeratedAccount).</summary>
public async Task<WebResult> Moderate(ModerateAccountForm form, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_ModerateAccount, form, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewModeratedAccount>(cancellationToken) };
}
/// <summary>Takes a post down: a post here is deleted as its author would delete it (and its author warned), a copy of
/// one from elsewhere is hidden here only.</summary>
public async Task<WebResult> RemovePost(RemovePostForm form, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_ModeratorRemovePost, form, cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>Resolves a report, and with it what the staff does (none, silence, suspend, remove_posts). The act comes
/// first, so one that fails leaves the report open; remove_posts on a report naming no post is a 422.</summary>
public async Task<WebResult> ResolveReport(string reportId, ResolveReportForm form, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_M_ResolveReport(reportId), form, cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>The logins, newest first, each with its personas and the groups they own; query finds a login by its name
/// or by one of its personas' names, before is the previous page's Next. Null on failure.</summary>
public async Task<ViewAdminRootsPage> Roots(string query, string before, int limit, CancellationToken cancellationToken = default)
{
var parameters = new List<string> { $"limit={limit}" };
if (query is { Length: > 0 })
parameters.Add($"q={Uri.EscapeDataString(query)}");
if (before is { Length: > 0 })
parameters.Add($"before={Uri.EscapeDataString(before)}");
using var response = await httpService.Get(APIs.ClientApi.GET_AdminRoots, [.. parameters], cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<ViewAdminRootsPage>(cancellationToken);
await LogFailure(response, nameof(Roots), cancellationToken);
return null;
}
/// <summary>One login as it is now; null when it is gone (deleted) or can't be read.</summary>
public async Task<ViewAdminRoot> Root(string rootId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_M_AdminRoot(rootId), cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<ViewAdminRoot>(cancellationToken);
if (response.StatusCode is not HttpStatusCode.NotFound)
await LogFailure(response, nameof(Root), cancellationToken);
return null;
}
/// <summary>Makes a login a user, a moderator or an administrator; never one's own nor the last administrator's (409).
/// Data is the login as it is now (ViewAdminRoot).</summary>
public async Task<WebResult> SetRole(string rootId, string role, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminRootRole(rootId), new RootRoleForm { Role = role },
cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewAdminRoot>(cancellationToken) };
}
/// <summary>Turns another login's two-factor sign-in off (it lost its authenticator and its recovery codes); its
/// password and sessions stay.</summary>
public async Task<WebResult> ResetTotp(string rootId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminRootTotpReset(rootId), cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>Bans a login: its sessions end and its personas are held back as suspended ones, without a word written on
/// any of them.</summary>
public async Task<WebResult> Ban(string rootId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_AdminBanRoots, new UsersIds { UserIdList = [rootId] },
cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>Lifts a login's ban: its personas are given back as they were.</summary>
public async Task<WebResult> Unban(string rootId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_AdminUnbanRoots, new UsersIds { UserIdList = [rootId] },
cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>Deletes a login, every persona of it with everything public they had; its names stay taken.</summary>
public async Task<WebResult> DeleteRoot(string rootId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Delete(APIs.ClientApi.DELETE_AdminRemoveRoots, payload: new UsersIds { UserIdList = [rootId] },
cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>Deletes a persona, the groups it owns with it, the administrator's own password given (a wrong one is a
/// 403); the note is the staff's.</summary>
public async Task<WebResult> DeletePersona(string personaId, AdminPasswordForm form, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminPersonaDelete(personaId), form, cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await ReadPasswordFailure(response, cancellationToken);
}
/// <summary>Deletes a group, the administrator's own password given (a wrong one is a 403): its members are told, its
/// name stays taken.</summary>
public async Task<WebResult> DeleteGroup(string groupId, AdminPasswordForm form, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminGroupDelete(groupId), form, cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await ReadPasswordFailure(response, cancellationToken);
}
/// <summary>PrivaPub refuses a wrong password with its own 403, which ReadWebResult would take for a missing
/// permission.</summary>
async Task<WebResult> ReadPasswordFailure(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (response.StatusCode is not HttpStatusCode.Forbidden || response.Content.Headers.Contains(SUtility.FailureHeader))
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult().Invalidate(localizer["Wrong password."], (int)HttpStatusCode.Forbidden);
}
async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken cancellationToken)
{
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
if (result.StatusCode is not ((int)HttpStatusCode.Gone or (int)HttpStatusCode.Forbidden))
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(ModerationService)}.{caller}", cancellationToken);
}
}
}