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

+4 -9
View File
@@ -9,11 +9,12 @@ using PrivaPub.ClientModels.Admin;
namespace decePubClient.Services
{
/// <summary>
/// What PrivaPub's moderators and administrators do as the root: reports and the staff's log (/clientapi/moderator),
/// What PrivaPub's moderators and administrators read as the root: reports and the staff's log (/clientapi/moderator),
/// servers blocked for the whole instance, the federation statistics (the overview, the servers, one server's days and
/// events, the ledger's events, the server's own days) and the backups (/clientapi/admin). The server's own settings,
/// announcements and relays are InstanceAdminService's. PrivaPub checks the root's role on every call. Lists come back
/// empty and values null on failure; nothing throws.
/// announcements and relays are InstanceAdminService's; resolving reports and every act on accounts, posts and logins
/// are ModerationService's. PrivaPub checks the root's role on every call. Lists come back empty and values null on
/// failure; nothing throws.
/// </summary>
public class AdminService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
@@ -29,12 +30,6 @@ namespace decePubClient.Services
return [];
}
public async Task<WebResult> Resolve(string reportId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_M_ResolveReport(reportId), cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>One page of the staff's log, newest first: those before `before` (the previous page's Next) when given.
/// Administrators read all of it with login names; a moderator reads what isn't about a login. Null on failure.</summary>
public async Task<ViewAdminActionsPage> Log(string before, int limit, CancellationToken cancellationToken = default)
+15
View File
@@ -106,6 +106,21 @@ namespace decePubClient.Services
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewAvatar>(cancellationToken) };
}
/// <summary>
/// Deletes one of the root's personas, the root's password given (a wrong one is PrivaPub's 403): everything public it
/// had goes, here and, as PrivaPub tells them, on the servers that hold it, and the groups it owns with it. Its name
/// stays taken. A suspended persona may be deleted too. The caller drops it from the session (CascadingState.PersonaDeleted).
/// </summary>
public async Task<WebResult> DeletePersona(string personaId, string password, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_DeletePersona, new DeletePersonaForm { AvatarId = personaId, Password = password },
cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await ReadPasswordFailure(response, cancellationToken);
return new WebResult();
}
/// <summary>Acts as the persona from now on, exchanging a token for it unless it has one.</summary>
public async Task<WebResult> UsePersona(string personaId, CancellationToken cancellationToken = default)
{
+19
View File
@@ -1,6 +1,7 @@
using System.Net;
using decePubClient.Extensions;
using decePubClient.Helpers;
using decePubClient.Models;
using PrivaPub.ClientModels;
@@ -59,6 +60,24 @@ namespace decePubClient.Services
public Task<WebResult> Remove(string personaId, string groupId, string actorUri, CancellationToken cancellationToken = default) =>
Membership(APIs.ClientApi.POST_RemoveGroupMember, personaId, groupId, actorUri, cancellationToken);
/// <summary>
/// Deletes a group the persona owns, the login's password given: its members and followers are told, its name stays
/// taken. PrivaPub answers a wrong password, or a persona that isn't the owner, with 403.
/// </summary>
public async Task<WebResult> Delete(string personaId, string groupId, string password, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_DeleteGroup,
new DeleteGroupForm { AvatarId = personaId, GroupId = groupId, Password = password }, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return new WebResult();
// the owner is the only one offered it, so PrivaPub's own 403 is the password's
if (response.StatusCode is HttpStatusCode.Forbidden && !response.Content.Headers.Contains(SUtility.FailureHeader))
return new WebResult().Invalidate(localizer["Wrong password."], (int)HttpStatusCode.Forbidden);
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>Who is in a group the persona runs, and who asks to join; empty on failure.</summary>
public async Task<List<ViewGroupMember>> Members(string personaId, string groupId, CancellationToken cancellationToken = default)
{
+37 -6
View File
@@ -239,12 +239,23 @@ namespace decePubClient.Services
/// <summary>
/// A Mastodon API request as the current persona. A missing token is exchanged first; a refused one (revoked,
/// or PrivaPub's database was reset) is exchanged once more and the request sent again. Without a usable JWT the
/// session ends.
/// session ends. A persona PrivaPub refuses a token to while the JWT is good (suspended by the staff, or gone) is
/// marked refused (TokenAuthStateProvider.Refuse, which tells CascadingState once) and no request acts as it again.
/// </summary>
async Task<HttpResponseMessage> SendAsPersona(HttpRequestMessage request, Func<HttpRequestMessage> requestFactory, CancellationToken cancellationToken)
{
var session = await authStateProvider.GetAuthData(cancellationToken);
var token = await authStateProvider.GetPersonaToken(cancellationToken) ?? await Exchange(session.PersonaId, cancellationToken);
if (authStateProvider.IsRefused(session.PersonaId))
return PersonaRefused();
var token = await authStateProvider.GetPersonaToken(cancellationToken);
if (token is null)
{
var exchanged = await Exchange(session.PersonaId, cancellationToken);
if (exchanged.Refused)
return PersonaRefused(session.PersonaId);
token = exchanged.Token;
}
if (token is null)
return await EndSession(cancellationToken);
@@ -255,7 +266,10 @@ namespace decePubClient.Services
response.Dispose();
await authStateProvider.ForgetPersonaToken(session.PersonaId, cancellationToken);
token = await Exchange(session.PersonaId, cancellationToken);
var renewed = await Exchange(session.PersonaId, cancellationToken);
if (renewed.Refused)
return PersonaRefused(session.PersonaId);
token = renewed.Token;
if (token is null)
return await EndSession(cancellationToken);
@@ -269,12 +283,29 @@ namespace decePubClient.Services
return await EndSession(cancellationToken);
}
async Task<string> Exchange(string personaId, CancellationToken cancellationToken)
/// <summary>A new token for the persona, or none; Refused when PrivaPub itself answered no (its 400 invalid_grant or a
/// 403) to a JWT the client still holds as good, rather than the request failing on the way.</summary>
async Task<(string Token, bool Refused)> Exchange(string personaId, CancellationToken cancellationToken)
{
if (personaId is null)
return null;
return (null, false);
using var response = await ExchangePersonaToken(personaId, cancellationToken);
return response.IsSuccessStatusCode ? await authStateProvider.GetPersonaToken(cancellationToken) : null;
if (response.IsSuccessStatusCode)
return (await authStateProvider.GetPersonaToken(cancellationToken), false);
var refused = response.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.Forbidden
&& !response.Content.Headers.Contains(SUtility.FailureHeader)
&& await authStateProvider.GetToken(cancellationToken) is not null;
return (null, refused);
}
/// <summary>The answer for a request as a persona PrivaPub refuses, marking it so (once).</summary>
HttpResponseMessage PersonaRefused(string personaId = default)
{
if (personaId is not null)
authStateProvider.Refuse(personaId);
return SUtility.ClassifiedFailure(FailureCodes.PersonaRefused,
localizer["This persona can't act now: the server's staff may have suspended it."], HttpStatusCode.Forbidden);
}
async Task<HttpResponseMessage> EndSession(CancellationToken cancellationToken)
+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);
}
}
}
+26
View File
@@ -23,6 +23,12 @@ namespace decePubClient.Services
AuthData authData;
bool loaded;
/// <summary>The personas PrivaPub refused a token to while the JWT was good (suspended, or gone): no request acts as
/// them again until the reader picks one anew or the session is read again.</summary>
readonly HashSet<string> refusedPersonas = [];
/// <summary>Raised once for a persona PrivaPub refused a token to (Refuse); CascadingState moves the session on.</summary>
public event Action<string> PersonaRefused;
/// <summary>The stored session, or an empty one.</summary>
public async Task<AuthData> GetAuthData(CancellationToken cancellationToken = default)
@@ -89,6 +95,25 @@ namespace decePubClient.Services
await Save(session, cancellationToken);
}
/// <summary>Whether PrivaPub refused the persona a token in this session (Refuse).</summary>
public bool IsRefused(string personaId) => personaId is not null && refusedPersonas.Contains(personaId);
/// <summary>PrivaPub refused the persona a token while the JWT was good: no request acts as it from now on, and
/// PersonaRefused tells the session, once.</summary>
public void Refuse(string personaId)
{
if (personaId is null || !refusedPersonas.Add(personaId))
return;
PersonaRefused?.Invoke(personaId);
}
/// <summary>The persona may be tried again: the reader picked it, or PrivaPub lists it as usable again.</summary>
public void Unrefuse(string personaId)
{
if (personaId is not null)
refusedPersonas.Remove(personaId);
}
/// <summary>Drops every persona's token: PrivaPub revoked them all (a password change), and each is exchanged anew when used.</summary>
public async Task ForgetPersonaTokens(CancellationToken cancellationToken = default)
{
@@ -104,6 +129,7 @@ namespace decePubClient.Services
logger.LogInformation($"set null({nameof(LogoutAsync)})");
authData = null;
loaded = true;
refusedPersonas.Clear();
await storage.RemoveItemAsync(nameof(AuthData), cancellationToken);
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());