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:
1 parent
4c88980374
commit
c572471b88
30 files changed
+2604
-67
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user