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

+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)