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);
}
}
}
+35
View File
@@ -0,0 +1,35 @@
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using PrivaPub.ClientModels.Group;
namespace decePubClient.Services
{
/// <summary>The communities and circles a persona belongs to (/clientapi/group/list).</summary>
public class GroupService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
/// <summary>The persona's groups, communities first; empty on failure.</summary>
public async Task<List<ViewGroup>> List(string personaId, CancellationToken cancellationToken = default)
{
if (personaId is null)
return [];
using var response = await httpService.Get(APIs.ClientApi.GET_Groups, [$"avatarId={Uri.EscapeDataString(personaId)}"],
cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
{
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(GroupService)}.{nameof(List)}", cancellationToken);
return [];
}
var groups = await response.DefaultReadFromJsonAsync<List<ViewGroup>>(cancellationToken) ?? [];
return [.. groups.OrderByDescending(g => g.IsCommunity).ThenBy(g => g.Name ?? g.UserName)];
}
}
}
+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;
}
}
+7 -71
View File
@@ -1,4 +1,4 @@
using decePubClient.Helpers;
using decePubClient.Helpers;
using decePubClient.Models;
using PrivaPub.ClientModels;
@@ -6,22 +6,12 @@ using PrivaPub.ClientModels;
namespace decePubClient.Services
{
/// <summary>
/// The IndexedDB database "data" (schema in GenericExtensions.AddIndexedDb): messages and the client log. Lists
/// come back empty on failure, mutations as an invalid WebResult; nothing throws. Failures are logged with ILogger
/// only, because ILoggingService writes its log through this class.
/// The IndexedDB database "data" (schema in GenericExtensions.AddIndexedDb): the client log. Lists come back empty on
/// failure, mutations as an invalid WebResult; nothing throws. Failures are logged with ILogger only, because
/// ILoggingService writes its log through this class.
/// </summary>
public interface IStorage
{
Task<List<Message>> GetMessages(CancellationToken cancellationToken = default);
Task<Message> GetMessage(string messageId, CancellationToken cancellationToken = default);
Task<WebResult> AddMessages(List<Message> messages, CancellationToken cancellationToken = default);
Task<WebResult> UpdateMessages(List<Message> messages, CancellationToken cancellationToken = default);
Task<WebResult> RemoveMessage(string messageId, CancellationToken cancellationToken = default);
Task<List<ClientLogs>> GetClientLogs(CancellationToken cancellationToken = default);
Task AddLog(Exception exception, string where, CancellationToken cancellationToken = default);
@@ -36,59 +26,6 @@ namespace decePubClient.Services
/// <summary>The client log keeps at most this many entries; past it the oldest half is dropped.</summary>
const int MaxLogs = 100;
#region Messages
public async Task<Message> GetMessage(string messageId, CancellationToken cancellationToken = default)
{
try
{
cancellationToken.ThrowIfCancellationRequested();
await db.OpenIndexedDb();
var message = await db.GetByKey<string, Message>(nameof(Message), messageId);
if (message is not null)
return message;
var messages = await GetMessages(cancellationToken);
return messages.FirstOrDefault(m => m.MessageId == messageId);
}
catch (Exception ex)
{
logger.LogError(ex, $"{nameof(Storage)}.{nameof(GetMessage)}");
return default;
}
}
/// <summary>The stored messages, newest first; while none are stored, the mock feed (Faker.SeedMessages).</summary>
public async Task<List<Message>> GetMessages(CancellationToken cancellationToken = default)
{
try
{
cancellationToken.ThrowIfCancellationRequested();
await db.OpenIndexedDb();
var messages = await db.GetAll<Message>(nameof(Message)) ?? [];
if (messages.Count == 0)
messages = Faker.SeedMessages();
return [.. messages.OrderByDescending(m => m.CreatedAt)];
}
catch (Exception ex)
{
logger.LogError(ex, $"{nameof(Storage)}.{nameof(GetMessages)}");
return [];
}
}
public Task<WebResult> AddMessages(List<Message> messages, CancellationToken cancellationToken = default) =>
Run(nameof(AddMessages), async () => await db.AddItems(nameof(Message), messages), cancellationToken);
public Task<WebResult> UpdateMessages(List<Message> messages, CancellationToken cancellationToken = default) =>
Run(nameof(UpdateMessages), async () => await db.UpdateItems(nameof(Message), messages), cancellationToken);
public Task<WebResult> RemoveMessage(string messageId, CancellationToken cancellationToken = default) =>
Run(nameof(RemoveMessage), async () => await db.DeleteByKey(nameof(Message), messageId), cancellationToken);
#endregion
#region Logs
public async Task<List<ClientLogs>> GetClientLogs(CancellationToken cancellationToken = default)
@@ -156,10 +93,9 @@ namespace decePubClient.Services
public Task<WebResult> RemoveAll(bool includeClientLogs = false, CancellationToken cancellationToken = default) =>
Run(nameof(RemoveAll), async () =>
{
var result = await db.DeleteAll(nameof(Message));
if (includeClientLogs)
result = await db.DeleteAll(nameof(ClientLogs));
return result;
if (!includeClientLogs)
return "nothing to remove";
return await db.DeleteAll(nameof(ClientLogs));
}, cancellationToken);
/// <summary>Runs a write and turns its outcome into a WebResult.</summary>
+177
View File
@@ -0,0 +1,177 @@
using System.Text.Json.Nodes;
using Blazored.LocalStorage;
using decePubClient.Extensions;
using decePubClient.Helpers;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
namespace decePubClient.Services
{
/// <summary>
/// What PrivaPub knows about the servers the feed shows (/api/privapub/v1/instances, up to 40 at once), cached in
/// memory and localStorage for a day, and where each feed item is drawn. A post is placed, best first: at its own
/// place (Pixelfed), at its event's venue, at the reader for a located post, at its author's server (0.1°), or at a
/// point in the server's country when PrivaPub publishes only that. A CDN-fronted server has no place.
/// </summary>
public class InstanceService(IHttpService httpService, ILocalStorageService localStorage, AppConfiguration appConfiguration, ILoggingService logger)
{
const int MaxHostsPerRead = 40;
const string StorageKey = "ServerInfos";
static readonly TimeSpan Lifetime = TimeSpan.FromDays(1);
static readonly TimeSpan UnknownLifetime = TimeSpan.FromHours(1);
readonly Dictionary<string, ServerInfo> servers = new(StringComparer.OrdinalIgnoreCase);
bool loaded;
string selfHost;
/// <summary>
/// PrivaPub's own host, the point every arc starts from: the host of the signed-in persona's account (CascadingState
/// sets it), since the API may be reached under another name; the API's host until then.
/// </summary>
public string SelfHost
{
get => selfHost ?? appConfiguration.ApiHost;
set => selfHost = value;
}
public ServerInfo Known(string host) => host is not null && servers.TryGetValue(host, out var server) && !server.Unknown ? server : null;
public async Task<ServerInfo> Self(CancellationToken cancellationToken = default)
{
await Resolve([SelfHost], cancellationToken);
return Known(SelfHost);
}
/// <summary>Reads the servers not known yet (or known for too long) from PrivaPub.</summary>
public async Task Resolve(IEnumerable<string> hosts, CancellationToken cancellationToken = default)
{
await Load(cancellationToken);
var now = DateTime.UtcNow;
var wanted = hosts.Where(host => host is { Length: > 0 }).Distinct(StringComparer.OrdinalIgnoreCase)
.Where(host => !servers.TryGetValue(host, out var server) || now - server.FetchedAt > (server.Unknown ? UnknownLifetime : Lifetime))
.ToList();
if (wanted.Count == 0)
return;
foreach (var chunk in wanted.Chunk(MaxHostsPerRead))
{
using var response = await httpService.GetAnon(APIs.PrivaPub.GET_Instances, [.. chunk.Select(host => $"host[]={Uri.EscapeDataString(host)}")],
cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
continue;
var described = await response.MastodonReadFromJsonAsync<List<InstanceDescription>>(cancellationToken) ?? [];
foreach (var host in chunk)
servers[host] = described.FirstOrDefault(d => string.Equals(d.Host, host, StringComparison.OrdinalIgnoreCase)) is { } description
? ToServerInfo(description, now)
: new() { Host = host, FetchedAt = now, Unknown = true };
}
await Save(cancellationToken);
}
/// <summary>Places each post; nearYou is the reader's position, for located posts.</summary>
public void Locate(IEnumerable<FeedPost> posts, (double Latitude, double Longitude)? nearYou = default)
{
foreach (var post in posts)
post.Location = Locate(post, nearYou);
}
FeedLocation Locate(FeedPost post, (double Latitude, double Longitude)? nearYou)
{
if (post.Status?.Privapub?.Place is { Latitude: { } placeLatitude, Longitude: { } placeLongitude } place)
return new(placeLatitude, placeLongitude, LocationSource.Place, JoinLabel(place.Name, place.Country), 1);
if (post.Status?.Privapub?.Event?.Places.FirstOrDefault(p => p.Latitude is not null && p.Longitude is not null) is { } venue)
return new(venue.Latitude!.Value, venue.Longitude!.Value, LocationSource.Event, venue.Name ?? venue.Address, 1);
if (post.Located && nearYou is { } reader)
return new(reader.Latitude, reader.Longitude, LocationSource.NearYou, null, post.RangeKm ?? 5);
var geo = Known(post.Host)?.Geo;
if (geo is { Latitude: { } latitude, Longitude: { } longitude })
return new(latitude, longitude, LocationSource.Server, JoinLabel(geo.City, CountryName(geo.Country)), 11);
if (CountryPoints.Of(geo?.Country) is { } country)
return new(country.Latitude, country.Longitude, LocationSource.Country, CountryName(geo.Country), 500);
return null;
}
/// <summary>
/// The country's English name, or the code when .NET does not know it. Not DisplayName: in the browser it falls back
/// to the country's own language ("日本").
/// </summary>
public static string CountryName(string countryCode)
{
if (countryCode is not { Length: 2 })
return countryCode;
try
{
return new System.Globalization.RegionInfo(countryCode).EnglishName;
}
catch (ArgumentException)
{
return countryCode;
}
}
static string JoinLabel(params string[] parts) => string.Join(", ", parts.Where(part => !string.IsNullOrWhiteSpace(part)));
static ServerInfo ToServerInfo(InstanceDescription description, DateTime fetchedAt) => new()
{
Host = description.Host,
Software = description.Software,
Version = description.Version,
NodeName = description.NodeName,
OpenRegistrations = description.OpenRegistrations,
Users = Number(description.NodeInfo, "usage", "users", "total"),
ActiveMonth = Number(description.NodeInfo, "usage", "users", "activeMonth"),
LocalPosts = Number(description.NodeInfo, "usage", "localPosts"),
Geo = description.Geo,
ConsecutiveFailures = description.Delivery?.ConsecutiveFailures ?? 0,
LastSuccessAt = description.Delivery?.LastSuccessAt,
LastFailureAt = description.Delivery?.LastFailureAt,
DescribedAt = description.DescribedAt,
DescriptionError = description.DescriptionError,
FetchedAt = fetchedAt
};
static long? Number(JsonNode node, params string[] path)
{
foreach (var step in path)
node = node is JsonObject obj ? obj[step] : null;
return node is JsonValue value && value.TryGetValue<long>(out var number) ? number : null;
}
async Task Load(CancellationToken cancellationToken)
{
if (loaded)
return;
loaded = true;
try
{
foreach (var server in await localStorage.GetItemAsync<List<ServerInfo>>(StorageKey, cancellationToken) ?? [])
servers.TryAdd(server.Host, server);
}
catch (Exception ex)
{
await logger.ProcessWarning(ex.Message, $"{nameof(InstanceService)}.{nameof(Load)}", cancellationToken);
}
}
async Task Save(CancellationToken cancellationToken)
{
try
{
var fresh = servers.Values.Where(server => DateTime.UtcNow - server.FetchedAt <= Lifetime).ToList();
await localStorage.SetItemAsync(StorageKey, fresh, cancellationToken);
}
catch (Exception ex)
{
await logger.ProcessWarning(ex.Message, $"{nameof(InstanceService)}.{nameof(Save)}", cancellationToken);
}
}
}
}
+38
View File
@@ -0,0 +1,38 @@
using System.Net;
using System.Net.Http.Headers;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
using PrivaPub.ClientModels;
namespace decePubClient.Services
{
/// <summary>Uploads a picked file to PrivaPub (/api/v2/media, multipart) before the post that uses it.</summary>
public class MediaService(IHttpService httpService, CoalescingStringLocalizer localizer)
{
/// <summary>The uploaded attachment as Data; its id goes in the post's media_ids.</summary>
public async Task<WebResult> Upload(UploadMedia media, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_Media, (Func<HttpContent>)(() =>
{
var file = new ByteArrayContent(media.Blob);
file.Headers.ContentType = MediaTypeHeaderValue.Parse(media.ContentType);
var content = new MultipartFormDataContent { { file, "file", media.FileName } };
if (media.AltText is { Length: > 0 } description)
content.Add(new StringContent(description), "description");
return content;
}), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
var attachment = await response.MastodonReadFromJsonAsync<MediaAttachment>(cancellationToken);
return attachment?.Id is null
? new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway)
: new WebResult { Data = attachment };
}
}
}
+30
View File
@@ -0,0 +1,30 @@
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
namespace decePubClient.Services
{
/// <summary>How a post reached PrivaPub (/api/privapub/v1/statuses/{id}/provenance), read on demand and kept for the session.</summary>
public class ProvenanceService(IHttpService httpService)
{
readonly Dictionary<string, Provenance> provenances = [];
public Provenance Known(string statusId) => statusId is not null ? provenances.GetValueOrDefault(statusId) : null;
/// <summary>The post's provenance, or null when PrivaPub has none to show.</summary>
public async Task<Provenance> Get(string statusId, CancellationToken cancellationToken = default)
{
if (Known(statusId) is { } known)
return known;
using var response = await httpService.GetAnon(APIs.PrivaPub.GET_M_Provenance(statusId), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return null;
var provenance = await response.MastodonReadFromJsonAsync<Provenance>(cancellationToken);
if (provenance is not null)
provenances[statusId] = provenance;
return provenance;
}
}
}
+70
View File
@@ -0,0 +1,70 @@
using System.Text.RegularExpressions;
namespace decePubClient.Services
{
/// <summary>One request HttpService sent, for the nerd stats.</summary>
public sealed record RequestStat(DateTime At, string Method, string Route, int Status, double DurationMs, long? Bytes,
int? RateLimitRemaining, DateTime? RateLimitReset);
/// <summary>
/// The last requests HttpService sent (a ring of Capacity entries), with route templates instead of ids. It holds no
/// bodies and no tokens: method, route, status, duration, size and PrivaPub's rate limit headers only.
/// </summary>
public partial class RequestStats
{
public const int Capacity = 200;
readonly RequestStat[] ring = new RequestStat[Capacity];
int next;
int count;
public event Action Recorded;
public int Count => count;
public void Record(RequestStat stat)
{
lock (ring)
{
ring[next] = stat;
next = (next + 1) % Capacity;
count = Math.Min(count + 1, Capacity);
}
Recorded?.Invoke();
}
/// <summary>The recorded requests, newest first.</summary>
public List<RequestStat> Latest(int take = Capacity)
{
lock (ring)
{
var latest = new List<RequestStat>(Math.Min(take, count));
for (var i = 1; i <= Math.Min(take, count); i++)
latest.Add(ring[(next - i + Capacity) % Capacity]);
return latest;
}
}
/// <summary>The newest request to a route template, or null.</summary>
public RequestStat LastOf(string route) => Latest().FirstOrDefault(stat => stat.Route == route);
/// <summary>The mean duration of the successful requests recorded, in milliseconds.</summary>
public double? AverageMs()
{
var answered = Latest().Where(stat => stat.Status is >= 200 and < 400).ToList();
return answered.Count == 0 ? null : answered.Average(stat => stat.DurationMs);
}
/// <summary>The route without its query, with ids replaced by :id, so requests to the same endpoint group.</summary>
public static string Template(string uri)
{
var path = uri?.Split('?', 2)[0] ?? string.Empty;
if (Uri.TryCreate(path, UriKind.Absolute, out var absolute))
path = absolute.AbsolutePath.TrimStart('/');
return Id().Replace(path, "/:id$1");
}
[GeneratedRegex(@"/(?:[0-9a-f]{24}|[0-9a-f]{32}|\d{6,}|[0-9A-Za-z_-]{20,})(/|$)")]
private static partial Regex Id();
}
}
+159
View File
@@ -0,0 +1,159 @@
using System.Globalization;
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
using decePubClient.Models.Types;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Post;
namespace decePubClient.Services
{
/// <summary>The Mastodon actions a status has, by their route segment.</summary>
public enum StatusAction
{
Favourite,
Unfavourite,
Reblog,
Unreblog,
Bookmark,
Unbookmark
}
/// <summary>
/// Writing as the current persona: publishing (through the Mastodon API, or /clientapi for located and group posts),
/// favourites, boosts, bookmarks, deleting, threads, and muting or blocking an author. Results are WebResults whose
/// Data is the updated Status (a FeedPost when publishing); nothing throws.
/// </summary>
public class StatusService(IHttpService httpService, MediaService mediaService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
/// <summary>Publishes the form as the persona; its media are uploaded first.</summary>
public async Task<WebResult> Publish(ComposeForm form, string personaId, CancellationToken cancellationToken = default)
{
if (form.IsLocated || form.GroupId is not null)
return await PublishThroughClientApi(form, personaId, cancellationToken);
var mediaIds = new List<string>();
foreach (var media in form.Media)
{
var uploaded = await mediaService.Upload(media, cancellationToken);
if (uploaded.IsInvalid)
return uploaded;
mediaIds.Add(((MediaAttachment)uploaded.Data).Id);
}
using var response = await httpService.Post(APIs.Mastodon.POST_Statuses, new PostStatusForm
{
Status = form.Content ?? string.Empty,
MediaIds = mediaIds,
InReplyToId = form.InReplyToId,
Sensitive = form.HasContentWarning,
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
Visibility = form.Visibility,
Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName
}, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
var status = await response.MastodonReadFromJsonAsync<Status>(cancellationToken);
return new WebResult { Data = FeedPost.Of(status) };
}
/// <summary>A located or group post: text only, through /clientapi/post/insert.</summary>
async Task<WebResult> PublishThroughClientApi(ComposeForm form, string personaId, CancellationToken cancellationToken)
{
if (form.IsLocated && (form.Latitude is null || form.Longitude is null))
return new WebResult().Invalidate(localizer["A located post needs this device's position."]);
using var response = await httpService.Post(APIs.ClientApi.POST_InsertPost, new InsertPostForm
{
AvatarId = personaId,
Text = form.Content ?? string.Empty,
GroupId = form.GroupId,
AnsweringToPostId = form.InReplyToId,
HasContentWarning = form.HasContentWarning,
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
Visibility = form.MessageType switch
{
MessageType.FollowersOnly => "followersonly",
MessageType.Unlisted => "unlisted",
_ => "public"
},
Latitude = form.IsLocated ? form.Latitude : null,
Longitude = form.IsLocated ? form.Longitude : null,
RangeKm = form.IsLocated ? form.RangeKm : null
}, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewPost>(cancellationToken) };
}
public async Task<WebResult> Act(string statusId, StatusAction action, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_M_StatusAction(statusId, action.ToString().ToLowerInvariant()),
cancellationToken: cancellationToken);
return await StatusResult(response, cancellationToken);
}
/// <summary>Deletes the persona's own status.</summary>
public async Task<WebResult> Delete(string statusId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Delete(APIs.Mastodon.DELETE_M_Status(statusId), cancellationToken: cancellationToken);
return await StatusResult(response, cancellationToken);
}
public async Task<Status> Get(string statusId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_M_Status(statusId), cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<Status>(cancellationToken);
await LogFailure(response, nameof(Get), cancellationToken);
return null;
}
/// <summary>The thread around a status, or null when it cannot be read.</summary>
public async Task<StatusContext> Context(string statusId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_M_Context(statusId), cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<StatusContext>(cancellationToken);
await LogFailure(response, nameof(Context), cancellationToken);
return null;
}
/// <summary>Mutes or blocks an account for the current persona only (block federates, as PrivaPub's owner decided).</summary>
public async Task<WebResult> AccountAction(string accountId, string action, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_M_AccountAction(accountId, action), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Relationship>(cancellationToken) };
}
async Task<WebResult> StatusResult(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
var status = await response.MastodonReadFromJsonAsync<Status>(cancellationToken);
return status is null
? new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway)
: new WebResult { Data = status };
}
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(StatusService)}.{caller}", cancellationToken);
}
}
}
+209
View File
@@ -0,0 +1,209 @@
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
using decePubClient.Models.Types;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Post;
namespace decePubClient.Services
{
/// <summary>What a feed shows: its kind, and the group, hashtag or position some kinds need.</summary>
public sealed record FeedQuery(TimelineType Type, string PersonaId, string GroupId = default, bool IsCommunity = false, string Tag = default,
double? Latitude = default, double? Longitude = default);
/// <summary>
/// Pages of every feed the client shows, as FeedPosts. Mastodon lists page through max_id (older) and min_id (newer)
/// from their Link header. A circle's posts come from /clientapi as ids and are read through /api/v1/statuses (a
/// circle post reads as private there); located posts come from /clientapi/post/nearby with their authors read from
/// /api/v1/accounts, and have no next page. Results are WebResults with a FeedPage as Data; nothing throws.
/// </summary>
public class TimelineService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
public const int PageSize = 20;
/// <summary>The most ids /api/v1/statuses reads at once.</summary>
const int MaxIdsPerRead = 20;
public async Task<WebResult> Load(FeedQuery query, string maxId = default, string minId = default, CancellationToken cancellationToken = default)
{
try
{
return query.Type switch
{
TimelineType.Home => await Statuses(APIs.Mastodon.GET_HomeTimeline, [], maxId, minId, cancellationToken),
TimelineType.Local => await Statuses(APIs.Mastodon.GET_PublicTimeline, ["local=true"], maxId, minId, cancellationToken),
TimelineType.Federation => await Statuses(APIs.Mastodon.GET_PublicTimeline, ["remote=true"], maxId, minId, cancellationToken),
TimelineType.Favourites => await Statuses(APIs.Mastodon.GET_Favourites, [], maxId, minId, cancellationToken),
TimelineType.Bookmarks => await Statuses(APIs.Mastodon.GET_Bookmarks, [], maxId, minId, cancellationToken),
TimelineType.Tag when query.Tag is { Length: > 0 } tag =>
await Statuses(APIs.Mastodon.GET_M_TagTimeline(tag), [], maxId, minId, cancellationToken),
TimelineType.Groups when query.GroupId is { Length: > 0 } groupId && query.IsCommunity =>
await Statuses(APIs.Mastodon.GET_M_AccountStatuses(groupId), [], maxId, minId, cancellationToken),
TimelineType.Groups when query.GroupId is { Length: > 0 } && maxId is null => await Circle(query, cancellationToken),
TimelineType.Notifications => await Notifications(maxId, minId, cancellationToken),
TimelineType.Direct when maxId is null => await Conversations(cancellationToken),
TimelineType.Nearby when maxId is null && query.Latitude is not null && query.Longitude is not null => await Nearby(query, cancellationToken),
_ => new WebResult { Data = new FeedPage() }
};
}
catch (Exception ex)
{
await logger.ProcessError(ex, $"{nameof(TimelineService)}.{nameof(Load)}:{query.Type}", cancellationToken);
return new WebResult().Invalidate(localizer["The feed could not be read."], (int)HttpStatusCode.BadGateway, exception: ex);
}
}
async Task<WebResult> Statuses(string route, string[] parameters, string maxId, string minId, CancellationToken cancellationToken)
{
using var response = await httpService.Get(route, Paging(parameters, maxId, minId), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
var statuses = await response.MastodonReadFromJsonAsync<List<Status>>(cancellationToken) ?? [];
var (nextMaxId, prevMinId) = response.PageCursors();
return new WebResult
{
Data = new FeedPage
{
Posts = [.. statuses.Select(FeedPost.Of)],
MaxId = statuses.Count < PageSize ? null : nextMaxId ?? statuses.LastOrDefault()?.Id,
MinId = prevMinId ?? statuses.FirstOrDefault()?.Id ?? minId
}
};
}
async Task<WebResult> Notifications(string maxId, string minId, CancellationToken cancellationToken)
{
using var response = await httpService.Get(APIs.Mastodon.GET_Notifications, Paging([], maxId, minId), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
var notifications = await response.MastodonReadFromJsonAsync<List<Notification>>(cancellationToken) ?? [];
var (nextMaxId, prevMinId) = response.PageCursors();
return new WebResult
{
Data = new FeedPage
{
Posts = [.. notifications.Select(FeedPost.Of)],
MaxId = notifications.Count < PageSize ? null : nextMaxId ?? notifications.LastOrDefault()?.Id,
MinId = prevMinId ?? notifications.FirstOrDefault()?.Id ?? minId
}
};
}
/// <summary>Direct conversations, newest first; PrivaPub answers one page.</summary>
async Task<WebResult> Conversations(CancellationToken cancellationToken)
{
using var response = await httpService.Get(APIs.Mastodon.GET_Conversations, [$"limit={PageSize}"], cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
var conversations = await response.MastodonReadFromJsonAsync<List<Conversation>>(cancellationToken) ?? [];
return new WebResult { Data = new FeedPage { Posts = [.. conversations.Where(c => c.LastStatus is not null).Select(FeedPost.Of)] } };
}
/// <summary>A circle's posts: their ids from /clientapi, the posts from the Mastodon API, newest first.</summary>
async Task<WebResult> Circle(FeedQuery query, CancellationToken cancellationToken)
{
using var response = await httpService.Get(APIs.ClientApi.GET_Posts,
[$"avatarId={Uri.EscapeDataString(query.PersonaId)}", $"groupId={Uri.EscapeDataString(query.GroupId)}"], cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
var posts = await response.DefaultReadFromJsonAsync<List<ViewPost>>(cancellationToken) ?? [];
var ids = posts.OrderByDescending(p => p.CreationDate).Select(p => p.Id).Take(PageSize * 2).ToList();
var statuses = await Read(ids, cancellationToken);
return new WebResult { Data = new FeedPage { Posts = [.. ids.Select(id => statuses.GetValueOrDefault(id)).Where(s => s is not null).Select(FeedPost.Of)] } };
}
/// <summary>Located posts within their range of the reader, nearest first, with their authors.</summary>
async Task<WebResult> Nearby(FeedQuery query, CancellationToken cancellationToken)
{
using var response = await httpService.Get(APIs.ClientApi.GET_Nearby,
[
$"avatarId={Uri.EscapeDataString(query.PersonaId)}",
$"latitude={query.Latitude!.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)}",
$"longitude={query.Longitude!.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)}"
], cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
var posts = await response.DefaultReadFromJsonAsync<List<ViewPost>>(cancellationToken) ?? [];
var authors = new Dictionary<string, Account>();
foreach (var authorId in posts.Select(p => p.AuthorAvatarId).Where(id => id is not null).Distinct())
if (await Account(authorId, cancellationToken) is { } account)
authors[authorId] = account;
return new WebResult
{
Data = new FeedPage
{
Posts =
[
.. posts.OrderBy(p => p.DistanceKm).Select(post => new FeedPost
{
Key = post.Id,
CursorId = post.Id,
Located = true,
DistanceKm = post.DistanceKm,
RangeKm = post.RangeKm,
Status = new()
{
Id = post.Id,
Uri = post.ObjectURI,
Account = authors.GetValueOrDefault(post.AuthorAvatarId ?? string.Empty),
InReplyToId = post.AnsweringToPostId,
Content = post.ContentHtml,
CreatedAt = post.CreationDate,
Sensitive = post.HasContentWarning,
Visibility = post.Visibility,
Privapub = post.Title is { Length: > 0 } ? new() { Title = post.Title } : null
}
})
]
}
};
}
/// <summary>Statuses by id, PrivaPub's batch read (/api/v1/statuses?id[]=), in chunks it accepts.</summary>
public async Task<Dictionary<string, Status>> Read(List<string> ids, CancellationToken cancellationToken = default)
{
var statuses = new Dictionary<string, Status>();
foreach (var chunk in ids.Chunk(MaxIdsPerRead))
{
using var response = await httpService.Get(APIs.Mastodon.GET_Statuses, [.. chunk.Select(id => $"id[]={Uri.EscapeDataString(id)}")],
cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
{
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(TimelineService)}.{nameof(Read)}", cancellationToken);
continue;
}
foreach (var status in await response.MastodonReadFromJsonAsync<List<Status>>(cancellationToken) ?? [])
statuses[status.Id] = status;
}
return statuses;
}
async Task<Account> Account(string accountId, CancellationToken cancellationToken)
{
using var response = await httpService.Get(APIs.Mastodon.GET_M_Account(accountId), cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? await response.MastodonReadFromJsonAsync<Account>(cancellationToken) : null;
}
static string[] Paging(string[] parameters, string maxId, string minId)
{
var paging = new List<string>(parameters) { $"limit={PageSize}" };
if (maxId is not null)
paging.Add($"max_id={Uri.EscapeDataString(maxId)}");
if (minId is not null)
paging.Add($"min_id={Uri.EscapeDataString(minId)}");
return [.. paging];
}
}
}
+89 -37
View File
@@ -1,50 +1,99 @@
using Blazored.LocalStorage;
using decePubClient.Helpers;
using decePubClient.Models;
using Microsoft.AspNetCore.Components.Authorization;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.User;
using System.Security.Claims;
using System.Text.Json;
namespace decePubClient.Services
{
/// <summary>
/// The session (AuthData in localStorage, cached in memory) and the authentication state built from it. It only
/// stores and hands out tokens; signing in, exchanging and revoking them is AuthService's job.
/// </summary>
public class TokenAuthStateProvider(ILocalStorageService storage, IStorage dbStorage, ILogger<TokenAuthStateProvider> logger)
: AuthenticationStateProvider
{
public void SetToken(/*string token, long expirationTicks = default*/)
{
//AuthData = await Storage.GetItemAsync<AuthData>(nameof(AuthData));
//if (string.IsNullOrEmpty(token))
//{
// Logger.LogInformation($"set null({nameof(SetToken)})");
// AuthData.Token = null;
// AuthData.TokenExpiration = null;
//}
//else
//{
// AuthData.Token = token;
// AuthData.TokenExpiration = expirationTicks;
//}
//await Storage.SetItemAsync(nameof(AuthData), AuthData);
/// <summary>The claim naming the persona the client acts as (an avatar id).</summary>
public const string PersonaClaim = "persona";
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
AuthData authData;
bool loaded;
/// <summary>The stored session, or an empty one.</summary>
public async Task<AuthData> GetAuthData(CancellationToken cancellationToken = default)
{
if (loaded)
return authData ?? new();
authData = await storage.GetItemAsync<AuthData>(nameof(AuthData), cancellationToken);
loaded = true;
return authData ?? new();
}
/// <summary>The stored token while it is still valid, otherwise null.</summary>
/// <summary>The root's JWT while it is still valid, otherwise null.</summary>
public async Task<string> GetToken(CancellationToken cancellationToken = default)
{
var authData = await storage.GetItemAsync<AuthData>(nameof(AuthData), cancellationToken);
if (string.IsNullOrEmpty(authData?.Token) || authData.TokenExpiration is not { } expiration || expiration <= DateTime.UtcNow.Ticks)
var session = await GetAuthData(cancellationToken);
if (string.IsNullOrEmpty(session.Token) || session.TokenExpiration is not { } expiration || expiration <= DateTime.UtcNow.Ticks)
return null;
return authData.Token;
return session.Token;
}
/// <summary>The current persona's Mastodon token, or null when the persona has none yet.</summary>
public async Task<string> GetPersonaToken(CancellationToken cancellationToken = default)
{
var session = await GetAuthData(cancellationToken);
if (session.PersonaId is null)
return null;
return session.PersonaTokens.GetValueOrDefault(session.PersonaId);
}
public async Task<bool> IsAuthenticatedAsync(CancellationToken cancellationToken = default) =>
await GetToken(cancellationToken) is not null;
/// <summary>Starts or renews the session with a JWT from login or sniff/again; persona tokens are kept.</summary>
public async Task SetSession(JwtUser jwtUser, CancellationToken cancellationToken = default)
{
var session = await GetAuthData(cancellationToken);
session.Token = jwtUser.Token;
session.TokenExpiration = jwtUser.Expiration;
session.TokenIssuedAt = DateTime.UtcNow.Ticks;
session.UserName = jwtUser.Username;
session.Policies = jwtUser.Policies ?? [];
if (jwtUser.UserSettings?.LanguageCode is { Length: > 0 } language)
session.CurrentLanguageCode = language;
await Save(session, cancellationToken);
}
/// <summary>Makes the persona current, remembering its token when one is given.</summary>
public async Task SetPersona(string personaId, string token = default, CancellationToken cancellationToken = default)
{
var session = await GetAuthData(cancellationToken);
session.PersonaId = personaId;
if (token is not null)
session.PersonaTokens[personaId] = token;
await Save(session, cancellationToken);
}
/// <summary>Drops a persona's token that PrivaPub no longer accepts.</summary>
public async Task ForgetPersonaToken(string personaId, CancellationToken cancellationToken = default)
{
var session = await GetAuthData(cancellationToken);
if (!session.PersonaTokens.Remove(personaId))
return;
await Save(session, cancellationToken);
}
public async Task LogoutAsync(bool deleteDb = false, CancellationToken cancellationToken = default)
{
logger.LogInformation($"set null({nameof(LogoutAsync)})");
//await Storage.RemoveItemAsync(nameof(PrivateCacheData));
authData = null;
loaded = true;
await storage.RemoveItemAsync(nameof(AuthData), cancellationToken);
if (deleteDb)
await dbStorage.RemoveAll(includeClientLogs: true, cancellationToken: cancellationToken);
@@ -54,23 +103,26 @@ namespace decePubClient.Services
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
var authData = await storage.GetItemAsync<AuthData>(nameof(AuthData));
if (string.IsNullOrEmpty(authData?.Token))
{
logger.LogInformation($"set null({nameof(GetAuthenticationStateAsync)})");
var token = await GetToken();
if (token is null)
return new(new());
}
var claims = new List<Claim>
{
new(ClaimTypes.UserData, JsonSerializer.Serialize(authData.User, SUtility.DefaultSerializer))
};
//claims.Add(new(Policies.IsUser, (AuthData.User.Policies.Contains(Policies.IsUser)).ToString().ToLower()));
//claims.Add(new(Policies.UserPlus, (AuthData.User.Policies.Contains(Policies.UserPlus)).ToString().ToLower()));
//claims.Add(new(Policies.IsAdmin, (AuthData.User.Policies.Contains(Policies.IsAdmin)).ToString().ToLower()));
var session = await GetAuthData();
var claims = new List<Claim> { new(ClaimTypes.Name, session.UserName ?? string.Empty) };
foreach (var policy in new[] { Policies.IsUser, Policies.IsModerator, Policies.IsAdmin })
claims.Add(new(policy, session.Policies.Contains(policy) ? "true" : "false"));
if (session.PersonaId is not null)
claims.Add(new(PersonaClaim, session.PersonaId));
var identity = new ClaimsIdentity(claims, "jwt");
return new(new(identity));
return new(new(new ClaimsIdentity(claims, "jwt")));
}
async Task Save(AuthData session, CancellationToken cancellationToken)
{
authData = session;
loaded = true;
await storage.SetItemAsync(nameof(AuthData), session, cancellationToken);
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
}
}