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 { /// /// 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. /// public class ModerationService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer) { /// 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. public async Task 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(cancellationToken); await LogFailure(response, nameof(SearchAccounts), cancellationToken); return null; } /// 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. public async Task 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(cancellationToken); await LogFailure(response, nameof(ModeratedAccounts), cancellationToken); return null; } /// Silences, unsilences, suspends or unsuspends a persona here or an account elsewhere (ModerationActions); /// Data is the account as moderators see it now (ViewModeratedAccount). public async Task 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(cancellationToken) }; } /// 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. public async Task 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); } /// 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. public async Task 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); } /// 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. public async Task Roots(string query, string before, int limit, CancellationToken cancellationToken = default) { var parameters = new List { $"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(cancellationToken); await LogFailure(response, nameof(Roots), cancellationToken); return null; } /// One login as it is now; null when it is gone (deleted) or can't be read. public async Task 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(cancellationToken); if (response.StatusCode is not HttpStatusCode.NotFound) await LogFailure(response, nameof(Root), cancellationToken); return null; } /// 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). public async Task 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(cancellationToken) }; } /// Turns another login's two-factor sign-in off (it lost its authenticator and its recovery codes); its /// password and sessions stay. public async Task 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); } /// Bans a login: its sessions end and its personas are held back as suspended ones, without a word written on /// any of them. public async Task 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); } /// Lifts a login's ban: its personas are given back as they were. public async Task 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); } /// Deletes a login, every persona of it with everything public they had; its names stay taken. public async Task 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); } /// 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. public async Task 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); } /// Deletes a group, the administrator's own password given (a wrong one is a 403): its members are told, its /// name stays taken. public async Task 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); } /// PrivaPub refuses a wrong password with its own 403, which ReadWebResult would take for a missing /// permission. async Task 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); } } }