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

+154 -23
View File
@@ -1,3 +1,5 @@
using System.Diagnostics;
using System.Globalization;
using System.Net;
using System.Net.Http.Json;
@@ -5,16 +7,22 @@ using collAnon.Client.Services;
using decePubClient.Helpers;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
namespace decePubClient.Services
{
/// <summary>
/// Requests to the PrivaPub API through the "default" HttpClient (AppConfiguration.ApiBaseAddress), at three
/// authentication levels: Get/Post/Delete need a valid token and sign out on 401, *Anon add the token when there is
/// one, *TotallyAnon never send it. Nothing throws: a failure comes back as a response carrying an invalid WebResult
/// (SUtility.ClassifiedFailure, read with ExtensionMethods.ReadWebResult), a cancelled request as HTTP 410.
/// With retryOnError, 408/429/502/503/504 and transport errors are retried after 1, 2, 4, 8, 16 and 29 seconds, then
/// every 60, plus jitter, for at most 10 minutes.
/// Requests to PrivaPub through the "default" HttpClient (AppConfiguration.ApiBaseAddress), at three authentication
/// levels: Get/Post/Delete need credentials and sign out when PrivaPub refuses them, *Anon add them when there are
/// some, *TotallyAnon never send them. The route picks the credentials: clientapi/ the root's JWT, api/ the current
/// persona's Mastodon token (exchanged from the JWT when missing, and once more when PrivaPub refuses it), oauth/
/// none. It also picks the JSON: snake_case for api/ and oauth/ (SUtility.MastodonSerializer), camelCase for
/// clientapi/. A payload can be an object (JSON), name/value pairs (a form) or a Func&lt;HttpContent&gt; (e.g. multipart),
/// called once per attempt.
/// Nothing throws: a failure comes back as a response carrying an invalid WebResult (SUtility.ClassifiedFailure, read
/// with ExtensionMethods.ReadWebResult), a cancelled request as HTTP 410. With retryOnError, 408/429/502/503/504 and
/// transport errors are retried after 1, 2, 4, 8, 16 and 29 seconds, then every 60, plus jitter, for at most 10
/// minutes. Every request PrivaPub answers is recorded in RequestStats.
/// </summary>
public interface IHttpService
{
@@ -37,10 +45,16 @@ namespace decePubClient.Services
Task<HttpResponseMessage> DeleteTotallyAnon(string uri, string[] queryParams = default, object payload = default, bool retryOnError = false,
CancellationToken cancellationToken = default);
/// <summary>
/// Exchanges the root's JWT for the persona's Mastodon token (PrivaPub's PersonaExchange) and makes the persona
/// current. A failure comes back like any other.
/// </summary>
Task<HttpResponseMessage> ExchangePersonaToken(string personaId, CancellationToken cancellationToken = default);
}
public class HttpService(IHttpClientFactory httpClientFactory, TokenAuthStateProvider authStateProvider, AppStatusService statusService,
ILoggingService logger, CoalescingStringLocalizer localizer) : IHttpService
AppConfiguration appConfiguration, RequestStats requestStats, ILoggingService logger, CoalescingStringLocalizer localizer) : IHttpService
{
static readonly int[] RetryRampUpDelaysMs = [1_000, 2_000, 4_000, 8_000, 16_000, 29_000];
const int RetryIndefiniteDelayMs = 60_000;
@@ -58,7 +72,7 @@ namespace decePubClient.Services
public Task<HttpResponseMessage> GetTotallyAnon(string uri, string[] queryParams = default, bool retryOnError = false,
CancellationToken cancellationToken = default) =>
Send(nameof(GetTotallyAnon), () => BuildRequest(HttpMethod.Get, uri, queryParams: queryParams), httpClient.SendAsync, retryOnError,
Send(nameof(GetTotallyAnon), () => BuildRequest(HttpMethod.Get, uri, queryParams: queryParams), SendAnonymousRequest, retryOnError,
cancellationToken);
public Task<HttpResponseMessage> Post(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) =>
@@ -70,7 +84,7 @@ namespace decePubClient.Services
public Task<HttpResponseMessage> PostTotallyAnon(string uri, object payload = default, bool retryOnError = false,
CancellationToken cancellationToken = default) =>
Send(nameof(PostTotallyAnon), () => BuildRequest(HttpMethod.Post, uri, payload, forceContent: true), httpClient.SendAsync, retryOnError,
Send(nameof(PostTotallyAnon), () => BuildRequest(HttpMethod.Post, uri, payload, forceContent: true), SendAnonymousRequest, retryOnError,
cancellationToken);
public Task<HttpResponseMessage> Delete(string uri, string[] queryParams = default, object payload = default, bool retryOnError = false,
@@ -79,27 +93,52 @@ namespace decePubClient.Services
public Task<HttpResponseMessage> DeleteTotallyAnon(string uri, string[] queryParams = default, object payload = default, bool retryOnError = false,
CancellationToken cancellationToken = default) =>
Send(nameof(DeleteTotallyAnon), () => BuildRequest(HttpMethod.Delete, uri, payload, queryParams), httpClient.SendAsync, retryOnError,
Send(nameof(DeleteTotallyAnon), () => BuildRequest(HttpMethod.Delete, uri, payload, queryParams), SendAnonymousRequest, retryOnError,
cancellationToken);
public async Task<HttpResponseMessage> ExchangePersonaToken(string personaId, CancellationToken cancellationToken = default)
{
var jwt = await authStateProvider.GetToken(cancellationToken);
if (jwt is null)
return SUtility.DefaultUnauthorized(localizer["Your session has expired. Sign in again."]);
var response = await PostTotallyAnon(APIs.OAuth.POST_Token, new Dictionary<string, string>
{
["grant_type"] = APIs.OAuth.TokenExchangeGrant,
["client_id"] = appConfiguration.ClientId,
["subject_token"] = jwt,
["subject_token_type"] = APIs.OAuth.JwtTokenType,
["avatar_id"] = personaId,
["scope"] = APIs.OAuth.Scopes
}, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return response;
var token = await response.Content.ReadFromJsonAsync<TokenResponse>(SUtility.MastodonSerializer, cancellationToken);
if (string.IsNullOrEmpty(token?.AccessToken))
return SUtility.ClassifiedFailure(FailureCodes.ServerError, localizer["The server sent an invalid response."], HttpStatusCode.BadGateway);
await authStateProvider.SetPersona(personaId, token.AccessToken, cancellationToken);
return response;
}
async Task<HttpResponseMessage> Send(string caller, Func<HttpRequestMessage> requestFactory,
Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> send, bool retryOnError, CancellationToken cancellationToken)
Func<Func<HttpRequestMessage>, CancellationToken, Task<HttpResponseMessage>> send, bool retryOnError, CancellationToken cancellationToken)
{
var deadline = DateTime.UtcNow + RetryDeadline;
var attempt = 0;
while (true)
{
// A request message can be sent once, so every attempt builds its own.
var request = requestFactory();
var where = $"{nameof(HttpService)}.{caller}:{request.RequestUri?.OriginalString}";
var where = $"{nameof(HttpService)}.{caller}";
var failure = default(string);
var transportException = default(Exception);
try
{
var response = await send(request, cancellationToken);
// A request message can be sent once, so every attempt (and every re-authentication) builds its own.
var response = await send(requestFactory, cancellationToken);
var status = (int)response.StatusCode;
if (!retryOnError || !RetryableStatusCodes.Contains(status))
return status > 499 ? ServerError(response) : response;
return status > 499 && !response.Content.Headers.Contains(SUtility.FailureHeader) ? ServerError(response) : response;
failure = $"HTTP {status}";
response.Dispose();
@@ -177,8 +216,15 @@ namespace decePubClient.Services
localizer["The server can't be reached. Try again later."], HttpStatusCode.ServiceUnavailable);
}
async Task<HttpResponseMessage> SendAuthRequest(HttpRequestMessage request, CancellationToken cancellationToken)
Task<HttpResponseMessage> SendAnonymousRequest(Func<HttpRequestMessage> requestFactory, CancellationToken cancellationToken) =>
Measure(requestFactory(), cancellationToken);
async Task<HttpResponseMessage> SendAuthRequest(Func<HttpRequestMessage> requestFactory, CancellationToken cancellationToken)
{
var request = requestFactory();
if (IsMastodon(request))
return await SendAsPersona(request, requestFactory, cancellationToken);
var token = await authStateProvider.GetToken(cancellationToken);
if (token is null)
{
@@ -187,7 +233,7 @@ namespace decePubClient.Services
}
request.Headers.Authorization = new("Bearer", token);
var response = await httpClient.SendAsync(request, cancellationToken);
var response = await Measure(request, cancellationToken);
if (response.StatusCode is not HttpStatusCode.Unauthorized)
return response;
@@ -196,22 +242,107 @@ namespace decePubClient.Services
return SUtility.DefaultUnauthorized(localizer["Your session has expired. Sign in again."]);
}
async Task<HttpResponseMessage> SendAuthIfAvailableRequest(HttpRequestMessage request, CancellationToken cancellationToken)
/// <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.
/// </summary>
async Task<HttpResponseMessage> SendAsPersona(HttpRequestMessage request, Func<HttpRequestMessage> requestFactory, CancellationToken cancellationToken)
{
var token = await authStateProvider.GetToken(cancellationToken);
var session = await authStateProvider.GetAuthData(cancellationToken);
var token = await authStateProvider.GetPersonaToken(cancellationToken) ?? await Exchange(session.PersonaId, cancellationToken);
if (token is null)
return await EndSession(cancellationToken);
request.Headers.Authorization = new("Bearer", token);
var response = await Measure(request, cancellationToken);
if (response.StatusCode is not HttpStatusCode.Unauthorized)
return response;
response.Dispose();
await authStateProvider.ForgetPersonaToken(session.PersonaId, cancellationToken);
token = await Exchange(session.PersonaId, cancellationToken);
if (token is null)
return await EndSession(cancellationToken);
var retry = requestFactory();
retry.Headers.Authorization = new("Bearer", token);
response = await Measure(retry, cancellationToken);
if (response.StatusCode is not HttpStatusCode.Unauthorized)
return response;
response.Dispose();
return await EndSession(cancellationToken);
}
async Task<string> Exchange(string personaId, CancellationToken cancellationToken)
{
if (personaId is null)
return null;
using var response = await ExchangePersonaToken(personaId, cancellationToken);
return response.IsSuccessStatusCode ? await authStateProvider.GetPersonaToken(cancellationToken) : null;
}
async Task<HttpResponseMessage> EndSession(CancellationToken cancellationToken)
{
if (await authStateProvider.GetToken(cancellationToken) is null)
await authStateProvider.LogoutAsync(cancellationToken: cancellationToken);
return SUtility.DefaultUnauthorized(localizer["Your session has expired. Sign in again."]);
}
async Task<HttpResponseMessage> SendAuthIfAvailableRequest(Func<HttpRequestMessage> requestFactory, CancellationToken cancellationToken)
{
var request = requestFactory();
var token = IsMastodon(request) ? await authStateProvider.GetPersonaToken(cancellationToken) : await authStateProvider.GetToken(cancellationToken);
if (token is not null)
request.Headers.Authorization = new("Bearer", token);
return await httpClient.SendAsync(request, cancellationToken);
return await Measure(request, cancellationToken);
}
/// <summary>Sends the request and records it in RequestStats.</summary>
async Task<HttpResponseMessage> Measure(HttpRequestMessage request, CancellationToken cancellationToken)
{
var started = Stopwatch.GetTimestamp();
var response = await httpClient.SendAsync(request, cancellationToken);
var elapsed = Stopwatch.GetElapsedTime(started).TotalMilliseconds;
requestStats.Record(new(DateTime.UtcNow, request.Method.Method, RequestStats.Template(request.RequestUri?.OriginalString), (int)response.StatusCode,
elapsed, response.Content.Headers.ContentLength, HeaderInt(response, "X-RateLimit-Remaining"), HeaderTime(response, "X-RateLimit-Reset")));
return response;
}
static int? HeaderInt(HttpResponseMessage response, string name) =>
response.Headers.TryGetValues(name, out var values) && int.TryParse(values.FirstOrDefault(), out var number) ? number : null;
static DateTime? HeaderTime(HttpResponseMessage response, string name)
{
if (!response.Headers.TryGetValues(name, out var values))
return null;
var value = values.FirstOrDefault();
if (DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var time))
return time;
return long.TryParse(value, out var seconds) ? DateTimeOffset.FromUnixTimeSeconds(seconds).UtcDateTime : null;
}
static bool IsMastodon(HttpRequestMessage request) =>
request.RequestUri?.OriginalString.StartsWith(APIs.MastodonPrefix, StringComparison.Ordinal) == true;
static HttpRequestMessage BuildRequest(HttpMethod method, string uri, object payload = default, string[] queryParams = default,
bool forceContent = false)
{
var target = queryParams is { Length: > 0 } ? $"{uri}?{string.Join('&', queryParams)}" : uri;
var request = new HttpRequestMessage(method, target);
if (payload is not null || forceContent)
request.Content = JsonContent.Create(payload, payload?.GetType() ?? typeof(object), options: SUtility.DefaultSerializer);
request.Content = payload switch
{
Func<HttpContent> content => content(),
IEnumerable<KeyValuePair<string, string>> fields => new FormUrlEncodedContent(fields),
null when !forceContent => null,
_ => JsonContent.Create(payload, payload?.GetType() ?? typeof(object), options: SerializerFor(uri))
};
return request;
}
/// <summary>snake_case for the Mastodon API and /oauth, camelCase for /clientapi.</summary>
static System.Text.Json.JsonSerializerOptions SerializerFor(string uri) =>
uri.StartsWith(APIs.ClientApiPrefix, StringComparison.Ordinal) ? SUtility.DefaultSerializer : SUtility.MastodonSerializer;
}
}