The composer uploads each file as soon as it is attached (streamed from the browser file, under its own ten-minute
timeout), shows it uploading, processing (a 202 followed until ready), ready or failed with Retry, and cancels one
removed; publishing sends the ids, so a failed publish or a retried upload sends nothing twice. Size limits and types
come from /api/v2/instance, a file without a browser type gets one from its extension, and each refusal names the file
and why. Previews are 320 px thumbnails, not whole files in memory. A picture without a description is asked about once
("Publish anyway"; a setting turns it off), descriptions are sent with PUT, and an edit keeps the post's media, language
and sensitive flag.
Profile pictures are checked against the server's types and can be removed. A card shows "Media unavailable" with the
original's link when a picture fails, the blurhash behind a sensitive placeholder, and plays a GIF only on tap under
reduced motion. A failed export says so instead of downloading part of a file.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
373 lines
19 KiB
C#
373 lines
19 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> Patch(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> Patch(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) =>
|
|
Send(nameof(Patch), () => BuildRequest(HttpMethod.Patch, 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>
|
|
static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(100);
|
|
static readonly TimeSpan UploadTimeout = TimeSpan.FromMinutes(10);
|
|
|
|
async Task<HttpResponseMessage> Measure(HttpRequestMessage request, CancellationToken cancellationToken)
|
|
{
|
|
// as HttpClient's own timeout would, but per request: a multipart body (an upload) may take minutes
|
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
timeout.CancelAfter(request.Content is MultipartContent ? UploadTimeout : RequestTimeout);
|
|
var started = Stopwatch.GetTimestamp();
|
|
HttpResponseMessage response;
|
|
try
|
|
{
|
|
response = await httpClient.SendAsync(request, timeout.Token);
|
|
}
|
|
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
throw new TaskCanceledException("The request timed out.", new TimeoutException(ex.Message));
|
|
}
|
|
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;
|
|
}
|
|
}
|