tests/e2e (Playwright for .NET 1.61.0 on xunit v3, the Chromium 1228 already cached, never downloaded, never in CI) runs the published app against SocialPub's pasture and its town: run.sh publishes it with the Pasture environment (wwwroot/appsettings.Pasture.json, published only then; the deploy fails if it ever ships) and serves it as https://decepub.test. The tests sign in through the real login page and persona chooser, check that every request goes only to decepub.test and privapub.test with the right credential on the right API, render one post of each kind from each platform as its author wrote it, and check every action on the other side of the wire (town.sh stored): favourites, boosts and replies with their undoing, follows from a profile, votes, reactions. Each test is also a cell of the town's report (out/client.jsonl). They found two PrivaPub bugs fixed there (a blocked author's status answered null; sensitive remote media invented a content warning) and a reply addressed to nobody. New in the app, all checked by those tests: - profiles at /account/{id}: header, bio, fields, counts, the account's posts as posts, posts and replies, or media, and follow, unfollow, cancel a request, mute and block (AccountService); post authors and notifications open them in the app; - search at /search: accounts, posts and hashtags, a handle or an address resolved by PrivaPub; - poll voting on the card, and emoji reactions shown and given with one tap; before a persona's first reaction or vote it is told once that both are public (PublicNoticeService, the owner's decision); - follow requests answered from their notification. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
354 lines
18 KiB
C#
354 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> Put(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> Put(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) =>
|
|
Send(nameof(Put), () => BuildRequest(HttpMethod.Put, 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;
|
|
}
|
|
}
|