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:
1 parent
f41ebf2160
commit
827ecb770e
56 files changed
+5367
-832
No files matched your search
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user