Wire the client to PrivaPub: one sign-in, real feeds, a globe, nerd stats

One sign-in for both of PrivaPub's APIs: the root logs in on /clientapi,
picks a persona, and each persona gets its own Mastodon token in
exchange for the JWT (PersonaExchange). HttpService picks credentials
and JSON by route (JWT and camelCase for /clientapi, the persona token
and snake_case for /api, nothing for /oauth), retries a refused persona
token once, and records every request for the nerd stats.

The feed page is three columns: navigation with a persona switcher, the
feed with a tab per kind (home, local, federated, nearby, communities
and circles, direct, notifications, favourites, bookmarks, hashtags),
and a 3D globe that looks at the spherical mean of the posts in view
and draws a pulse and an arc to a hovered one. Posts sit at their own
place, their event's venue, the reader (located posts) or their
author's server as PrivaPub publishes it. Cards favourite, boost,
bookmark, reply, mute, block and delete; the composer posts with a
content warning, visibility, media and alt text, or as a located post.
Older pages load as the end nears; newer ones are polled while visible.
Nerd stats in tooltips: provenance, author and server, place, media,
counts, paging, the globe's camera and frame rate.

The mock (Faker, MessagesService, the Message models and store) and the
OIDC template leftovers are gone. Strings in English and Italian.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-04 11:03:14 +02:00
1 parent f41ebf2160
commit 827ecb770e
56 files changed
+5367 -832

No files matched your search

+139
View File
@@ -0,0 +1,139 @@
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Helpers;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.User;
using PrivaPub.ClientModels.User.Avatar;
namespace decePubClient.Services
{
/// <summary>
/// One sign-in for both of PrivaPub's APIs: the root signs in on /clientapi, then each persona it uses gets its own
/// Mastodon token in exchange for the JWT (PrivaPub's PersonaExchange), so the Mastodon API never learns the root.
/// Results are WebResults, lists come back empty on failure; nothing throws.
/// </summary>
public class AuthService(IHttpService httpService, TokenAuthStateProvider authStateProvider, AppConfiguration appConfiguration,
ILoggingService logger, CoalescingStringLocalizer localizer)
{
/// <summary>The JWT is renewed (sniff/again) once less than this, or a quarter of its lifetime, is left.</summary>
static readonly TimeSpan RenewBefore = TimeSpan.FromDays(2);
/// <summary>Signs the root in; the JWT never goes with the request (PrivaPub redirects a login that carries one).</summary>
public async Task<WebResult> Login(LoginForm form, CancellationToken cancellationToken = default)
{
using var response = await httpService.PostTotallyAnon(APIs.ClientApi.POST_Login, form, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
var jwtUser = await response.DefaultReadFromJsonAsync<JwtUser>(cancellationToken);
if (string.IsNullOrEmpty(jwtUser?.Token))
return new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway);
await authStateProvider.SetSession(jwtUser, cancellationToken);
return new WebResult { Data = jwtUser };
}
/// <summary>The root's personas.</summary>
public async Task<List<ViewAvatar>> Personas(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_Personas, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
{
await LogFailure(response, nameof(Personas), cancellationToken);
return [];
}
return await response.DefaultReadFromJsonAsync<List<ViewAvatar>>(cancellationToken) ?? [];
}
/// <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)
{
var session = await authStateProvider.GetAuthData(cancellationToken);
if (session.PersonaTokens.ContainsKey(personaId))
{
await authStateProvider.SetPersona(personaId, cancellationToken: cancellationToken);
return new();
}
using var response = await httpService.ExchangePersonaToken(personaId, cancellationToken);
return response.IsSuccessStatusCode ? new() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>The current persona's Mastodon account, or null.</summary>
public async Task<Account> CurrentAccount(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_VerifyCredentials, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
{
await LogFailure(response, nameof(CurrentAccount), cancellationToken);
return null;
}
return await response.MastodonReadFromJsonAsync<Account>(cancellationToken);
}
/// <summary>Renews the JWT when it is about to expire; persona tokens do not expire and are kept.</summary>
public async Task RefreshIfNeeded(CancellationToken cancellationToken = default)
{
var session = await authStateProvider.GetAuthData(cancellationToken);
if (session.TokenExpiration is not { } expiration)
return;
var lifetime = session.TokenIssuedAt is { } issued ? TimeSpan.FromTicks(expiration - issued) : RenewBefore * 4;
var renewBefore = lifetime / 4 < RenewBefore ? lifetime / 4 : RenewBefore;
if (new DateTime(expiration, DateTimeKind.Utc) - DateTime.UtcNow > renewBefore)
return;
if (await authStateProvider.GetToken(cancellationToken) is null)
return;
using var response = await httpService.Get(APIs.ClientApi.GET_SniffAgain, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
{
await LogFailure(response, nameof(RefreshIfNeeded), cancellationToken);
return;
}
var jwtUser = await response.DefaultReadFromJsonAsync<JwtUser>(cancellationToken);
if (!string.IsNullOrEmpty(jwtUser?.Token))
await authStateProvider.SetSession(jwtUser, cancellationToken);
}
/// <summary>Revokes every persona token the client holds, tells PrivaPub, then forgets the session and the local data.</summary>
public async Task Logout(CancellationToken cancellationToken = default)
{
var session = await authStateProvider.GetAuthData(cancellationToken);
foreach (var token in session.PersonaTokens.Values)
{
using var revoked = await httpService.PostTotallyAnon(APIs.OAuth.POST_Revoke, new Dictionary<string, string>
{
["token"] = token,
["client_id"] = appConfiguration.ClientId
}, cancellationToken: cancellationToken);
if (!revoked.IsSuccessStatusCode)
await LogFailure(revoked, nameof(Logout), cancellationToken);
}
if (await authStateProvider.GetToken(cancellationToken) is not null)
{
using var response = await httpService.Get(APIs.ClientApi.GET_Logout, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
await LogFailure(response, nameof(Logout), cancellationToken);
}
await authStateProvider.LogoutAsync(deleteDb: true, cancellationToken);
}
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)
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(AuthService)}.{caller}", cancellationToken);
}
}
}