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
349 lines
18 KiB
C#
349 lines
18 KiB
C#
using System.Diagnostics;
|
|
using System.Globalization;
|
|
using System.Net;
|
|
using System.Net.Http.Json;
|
|
|
|
using collAnon.Client.Services;
|
|
|
|
using decePubClient.Helpers;
|
|
using decePubClient.Models;
|
|
using decePubClient.Models.Mastodon;
|
|
|
|
namespace decePubClient.Services
|
|
{
|
|
/// <summary>
|
|
/// 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<HttpContent> (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
|
|
{
|
|
Task<HttpResponseMessage> Get(string uri, string[] queryParams = default, bool retryOnError = false, CancellationToken cancellationToken = default);
|
|
|
|
Task<HttpResponseMessage> GetAnon(string uri, string[] queryParams = default, bool retryOnError = false, CancellationToken cancellationToken = default);
|
|
|
|
Task<HttpResponseMessage> GetTotallyAnon(string uri, string[] queryParams = default, bool retryOnError = false,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
Task<HttpResponseMessage> Post(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default);
|
|
|
|
Task<HttpResponseMessage> PostAnon(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default);
|
|
|
|
Task<HttpResponseMessage> PostTotallyAnon(string uri, object payload = default, bool retryOnError = false,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
Task<HttpResponseMessage> Delete(string uri, string[] queryParams = default, object payload = default, bool retryOnError = false,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
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,
|
|
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;
|
|
static readonly TimeSpan RetryDeadline = TimeSpan.FromMinutes(10);
|
|
static readonly int[] RetryableStatusCodes = [408, 429, 502, 503, 504];
|
|
|
|
readonly HttpClient httpClient = httpClientFactory.CreateClient("default");
|
|
|
|
public Task<HttpResponseMessage> Get(string uri, string[] queryParams = default, bool retryOnError = false, CancellationToken cancellationToken = default) =>
|
|
Send(nameof(Get), () => BuildRequest(HttpMethod.Get, uri, queryParams: queryParams), SendAuthRequest, retryOnError, cancellationToken);
|
|
|
|
public Task<HttpResponseMessage> GetAnon(string uri, string[] queryParams = default, bool retryOnError = false, CancellationToken cancellationToken = default) =>
|
|
Send(nameof(GetAnon), () => BuildRequest(HttpMethod.Get, uri, queryParams: queryParams), SendAuthIfAvailableRequest, retryOnError,
|
|
cancellationToken);
|
|
|
|
public Task<HttpResponseMessage> GetTotallyAnon(string uri, string[] queryParams = default, bool retryOnError = false,
|
|
CancellationToken cancellationToken = default) =>
|
|
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) =>
|
|
Send(nameof(Post), () => BuildRequest(HttpMethod.Post, uri, payload, forceContent: true), SendAuthRequest, retryOnError, cancellationToken);
|
|
|
|
public Task<HttpResponseMessage> PostAnon(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) =>
|
|
Send(nameof(PostAnon), () => BuildRequest(HttpMethod.Post, uri, payload, forceContent: true), SendAuthIfAvailableRequest, retryOnError,
|
|
cancellationToken);
|
|
|
|
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), SendAnonymousRequest, retryOnError,
|
|
cancellationToken);
|
|
|
|
public Task<HttpResponseMessage> Delete(string uri, string[] queryParams = default, object payload = default, bool retryOnError = false,
|
|
CancellationToken cancellationToken = default) =>
|
|
Send(nameof(Delete), () => BuildRequest(HttpMethod.Delete, uri, payload, queryParams), SendAuthRequest, retryOnError, cancellationToken);
|
|
|
|
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), 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<Func<HttpRequestMessage>, CancellationToken, Task<HttpResponseMessage>> send, bool retryOnError, CancellationToken cancellationToken)
|
|
{
|
|
var deadline = DateTime.UtcNow + RetryDeadline;
|
|
var attempt = 0;
|
|
while (true)
|
|
{
|
|
var where = $"{nameof(HttpService)}.{caller}";
|
|
var failure = default(string);
|
|
var transportException = default(Exception);
|
|
try
|
|
{
|
|
// 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 && !response.Content.Headers.Contains(SUtility.FailureHeader) ? ServerError(response) : response;
|
|
|
|
failure = $"HTTP {status}";
|
|
response.Dispose();
|
|
}
|
|
catch (Exception) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
return SUtility.DefaultTaskCancelled();
|
|
}
|
|
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
|
{
|
|
if (!retryOnError)
|
|
{
|
|
await logger.ProcessError(ex, where, cancellationToken);
|
|
return await DescribeUnreachable(ex, 1, cancellationToken);
|
|
}
|
|
|
|
failure = ex.Message;
|
|
transportException = ex;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
await logger.ProcessError(ex, where, cancellationToken);
|
|
return SUtility.ClassifiedFailure(FailureCodes.ClientError,
|
|
localizer["The request could not be prepared. Reload the page and try again."], HttpStatusCode.ServiceUnavailable);
|
|
}
|
|
|
|
var delay = attempt < RetryRampUpDelaysMs.Length ? RetryRampUpDelaysMs[attempt] : RetryIndefiniteDelayMs;
|
|
delay += Random.Shared.Next(0, delay / 4);
|
|
if (deadline - DateTime.UtcNow <= TimeSpan.FromMilliseconds(delay))
|
|
{
|
|
await logger.ProcessError($"Gave up after {attempt + 1} attempts. {failure}", where, cancellationToken);
|
|
if (transportException is not null)
|
|
return await DescribeUnreachable(transportException, attempt + 1, cancellationToken);
|
|
return SUtility.ClassifiedFailure(FailureCodes.RetriesExhausted,
|
|
localizer["The server is still not answering after {0} attempts. Try again later.", attempt + 1], HttpStatusCode.ServiceUnavailable);
|
|
}
|
|
|
|
await logger.ProcessWarning($"Attempt {attempt + 1} failed, retrying in {delay}ms. {failure}", where, cancellationToken);
|
|
try
|
|
{
|
|
await Task.Delay(delay, cancellationToken);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return SUtility.DefaultTaskCancelled();
|
|
}
|
|
|
|
attempt++;
|
|
}
|
|
}
|
|
|
|
HttpResponseMessage ServerError(HttpResponseMessage response)
|
|
{
|
|
var status = response.StatusCode;
|
|
response.Dispose();
|
|
return SUtility.ClassifiedFailure(FailureCodes.ServerError, localizer["The server reported an internal error (HTTP {0}).", (int)status], status);
|
|
}
|
|
|
|
/// <summary>Why a request got no answer: the device is offline, it timed out, retries ran out, or the server is unreachable.</summary>
|
|
async Task<HttpResponseMessage> DescribeUnreachable(Exception exception, int attempts, CancellationToken cancellationToken)
|
|
{
|
|
if (!await statusService.IsOnline(cancellationToken))
|
|
return SUtility.ClassifiedFailure(FailureCodes.DeviceOffline,
|
|
localizer["This device is offline. Check the connection and try again."], HttpStatusCode.ServiceUnavailable);
|
|
|
|
if (exception is TaskCanceledException { InnerException: TimeoutException })
|
|
return SUtility.ClassifiedFailure(FailureCodes.Timeout,
|
|
localizer["The server took too long to answer. Try again later."], HttpStatusCode.ServiceUnavailable);
|
|
|
|
if (attempts > 1)
|
|
return SUtility.ClassifiedFailure(FailureCodes.RetriesExhausted,
|
|
localizer["The server is still not answering after {0} attempts. Try again later.", attempts], HttpStatusCode.ServiceUnavailable);
|
|
|
|
return SUtility.ClassifiedFailure(FailureCodes.ApiUnreachable,
|
|
localizer["The server can't be reached. Try again later."], HttpStatusCode.ServiceUnavailable);
|
|
}
|
|
|
|
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)
|
|
{
|
|
logger.Process($"NO_TOKEN:{request.RequestUri?.OriginalString}", nameof(SendAuthRequest), LogLevel.Warning);
|
|
return SUtility.DefaultUnauthorized(localizer["Your session has expired. Sign in again."]);
|
|
}
|
|
|
|
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.LogoutAsync(cancellationToken: cancellationToken);
|
|
return SUtility.DefaultUnauthorized(localizer["Your session has expired. Sign in again."]);
|
|
}
|
|
|
|
/// <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 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 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);
|
|
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;
|
|
}
|
|
}
|