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
281 lines
9.7 KiB
C#
281 lines
9.7 KiB
C#
using Blazored.LocalStorage;
|
|
|
|
using collAnon.Client.Services;
|
|
|
|
using decePubClient.Helpers;
|
|
using decePubClient.Models;
|
|
using decePubClient.Models.Mastodon;
|
|
using decePubClient.Services;
|
|
|
|
using Markdig;
|
|
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Components;
|
|
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
|
|
|
using PrivaPub.ClientModels;
|
|
|
|
using System.Collections.Specialized;
|
|
using System.ComponentModel;
|
|
using System.Globalization;
|
|
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
using System.Web;
|
|
|
|
namespace decePubClient.Extensions
|
|
{
|
|
public static partial class ExtensionMethods
|
|
{
|
|
#region Navigation Manager
|
|
|
|
public static NameValueCollection QueryString(this NavigationManager navigationManager) =>
|
|
HttpUtility.ParseQueryString(new Uri(navigationManager.Uri).Query);
|
|
|
|
public static string QueryString(this NavigationManager navigationManager, string key) =>
|
|
navigationManager.QueryString()[key];
|
|
|
|
public static T QueryString<T>(this NavigationManager navigationManager, string key)
|
|
{
|
|
var value = navigationManager.QueryString()[key];
|
|
if (!TypeDescriptor.GetConverter(typeof(T)).IsValid(value))
|
|
return default;
|
|
return (T)Convert.ChangeType(value, typeof(T));
|
|
}
|
|
|
|
#endregion Navigation Manager
|
|
|
|
#region Setups
|
|
|
|
/// <summary>The languages with resources (Resources/AllStrings.*.resx); any other browser language gets English.</summary>
|
|
public static readonly string[] SupportedLanguages = ["en", "it"];
|
|
|
|
public static async Task SetDefaultCulture(this WebAssemblyHost host, CancellationToken cancellationToken = default)
|
|
{
|
|
var culture = ToCulture(default);
|
|
try
|
|
{
|
|
var storage = host.Services.GetRequiredService<ILocalStorageService>();
|
|
var publicCache = await storage.GetItemAsync<PublicCacheData>(nameof(PublicCacheData), cancellationToken);
|
|
if (publicCache is null)
|
|
{
|
|
var browserLanguage = host.Services.GetRequiredService<AppStatusService>().Language();
|
|
publicCache = new();
|
|
publicCache.PageSettings.CurrentLanguageCode =
|
|
SupportedLanguages.FirstOrDefault(language => browserLanguage?.StartsWith(language) ?? false) ?? "en";
|
|
await storage.SetItemAsync(nameof(PublicCacheData), publicCache, cancellationToken);
|
|
}
|
|
|
|
culture = ToCulture(publicCache.PageSettings.CurrentLanguageCode);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine(ex);
|
|
}
|
|
|
|
CultureInfo.DefaultThreadCurrentCulture = culture;
|
|
CultureInfo.DefaultThreadCurrentUICulture = culture;
|
|
}
|
|
|
|
static CultureInfo ToCulture(string languageCode) => languageCode is "it" ? new("it-IT") : new("en-GB");
|
|
|
|
#endregion Setups
|
|
|
|
public static string GetLogLevelClass(this string logType) => logType switch
|
|
{
|
|
"Debug" => "text-base-content/60",
|
|
"Information" => "text-info",
|
|
"Warning" => "text-warning",
|
|
"Error" => "text-error",
|
|
"Fatal" => "text-error font-bold",
|
|
_ => string.Empty
|
|
};
|
|
|
|
|
|
#region Formats
|
|
|
|
public static MarkdownPipeline Pipeline =>
|
|
new MarkdownPipelineBuilder().UseGenericAttributes().Build();
|
|
|
|
public static string ToJson(this object value) =>
|
|
JsonSerializer.Serialize(value);
|
|
|
|
public static string ToBase64PngImage(this string base64Image) =>
|
|
$"data:image/png;base64,{base64Image}";
|
|
|
|
#endregion Formats
|
|
|
|
#region Models
|
|
|
|
public static string ToString(this WebResult webResult, CoalescingStringLocalizer localizer) =>
|
|
localizer["{0} - {1}", webResult.StatusCode, webResult.ErrorMessage];
|
|
|
|
/// <summary>
|
|
/// Reads a failed response as a WebResult: a failure HttpService made up (FailureHeader) as is, 401/403 and 5xx
|
|
/// as a generic message, validation ProblemDetails as their errors, anything else as the server's WebResult.
|
|
/// A cancelled request (410) becomes SUtility.DefaultTaskCancelledResult.
|
|
/// </summary>
|
|
public static async Task<WebResult> ReadWebResult(this HttpContent content, HttpStatusCode statusCode,
|
|
CoalescingStringLocalizer localizer, CancellationToken cancellationToken = default)
|
|
{
|
|
if (statusCode is HttpStatusCode.Gone)
|
|
return SUtility.DefaultTaskCancelledResult;
|
|
|
|
var result = new WebResult();
|
|
try
|
|
{
|
|
var json = await content.ReadAsStringAsync(cancellationToken);
|
|
if (content.Headers.Contains(SUtility.FailureHeader))
|
|
return JsonSerializer.Deserialize<WebResult>(json, SUtility.DefaultSerializer);
|
|
|
|
if (statusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
|
return result.Invalidate(localizer["You are not authorized to do this."], (int)statusCode, FailureCodes.Unauthorized);
|
|
|
|
if ((int)statusCode > 499)
|
|
return result.Invalidate(localizer["The server reported an internal error (HTTP {0}).", (int)statusCode], (int)statusCode,
|
|
FailureCodes.ServerError);
|
|
|
|
if (TryReadMastodonError(json) is { } mastodonError)
|
|
return result.Invalidate(mastodonError, (int)statusCode);
|
|
|
|
if (json.Contains("\"title\""))
|
|
{
|
|
var validationErrors = JsonSerializer.Deserialize<ValidationErrors>(json, SUtility.DefaultSerializer);
|
|
var lines = validationErrors.Errors.Select(error => $"{error.Key}: {string.Join(", ", error.Value)}").Prepend(validationErrors.Title);
|
|
return result.Invalidate(string.Join('\n', lines), validationErrors.Status);
|
|
}
|
|
|
|
return JsonSerializer.Deserialize<WebResult>(json, SUtility.DefaultSerializer) ??
|
|
result.Invalidate(localizer["The server sent an invalid response."], (int)statusCode);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return SUtility.DefaultTaskCancelledResult;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return result.Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadRequest, exception: ex);
|
|
}
|
|
}
|
|
|
|
/// <summary>The message of a Mastodon API or /oauth error ({"error": ..., "error_description": ...}), or null.</summary>
|
|
static string TryReadMastodonError(string json)
|
|
{
|
|
if (!json.Contains("\"error\"") || json.Contains("\"errorMessage\""))
|
|
return null;
|
|
try
|
|
{
|
|
var error = JsonSerializer.Deserialize<MastodonError>(json, SUtility.MastodonSerializer);
|
|
return error?.ErrorDescription ?? error?.Error;
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads a successful Mastodon API body (snake_case); a cancelled read, or a body that does not fit T (written to the
|
|
/// browser console), returns SUtility.CancellationDefault.
|
|
/// </summary>
|
|
public static async Task<T> MastodonReadFromJsonAsync<T>(this HttpResponseMessage response, CancellationToken cancellationToken = default)
|
|
{
|
|
try
|
|
{
|
|
return await response.Content.ReadFromJsonAsync<T>(SUtility.MastodonSerializer, cancellationToken);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return SUtility.CancellationDefault<T>();
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
Console.Error.WriteLine($"{nameof(MastodonReadFromJsonAsync)}<{typeof(T).Name}> {response.RequestMessage?.RequestUri}: {ex.Message}");
|
|
return SUtility.CancellationDefault<T>();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The paging cursors of a Mastodon list: max_id from the Link header's next (older items), min_id from its prev
|
|
/// (newer items). Either is null when the header does not name it.
|
|
/// </summary>
|
|
public static (string MaxId, string MinId) PageCursors(this HttpResponseMessage response)
|
|
{
|
|
if (!response.Headers.TryGetValues("Link", out var values))
|
|
return (null, null);
|
|
string maxId = null, minId = null;
|
|
foreach (Match link in LinkHeader().Matches(string.Join(',', values)))
|
|
{
|
|
var query = HttpUtility.ParseQueryString(new Uri(link.Groups[1].Value, UriKind.Absolute).Query);
|
|
if (link.Groups[2].Value == "next")
|
|
maxId = query["max_id"];
|
|
else if (link.Groups[2].Value == "prev")
|
|
minId = query["min_id"] ?? query["since_id"];
|
|
}
|
|
return (maxId, minId);
|
|
}
|
|
|
|
[GeneratedRegex("<([^>]+)>\\s*;\\s*rel=\"([a-z]+)\"")]
|
|
private static partial Regex LinkHeader();
|
|
|
|
/// <summary>
|
|
/// Reads a successful /clientapi body; a cancelled read, or a body that does not fit T (written to the browser
|
|
/// console), returns SUtility.CancellationDefault.
|
|
/// </summary>
|
|
public static async Task<T> DefaultReadFromJsonAsync<T>(this HttpResponseMessage response, CancellationToken cancellationToken = default)
|
|
{
|
|
try
|
|
{
|
|
return await response.Content.ReadFromJsonAsync<T>(SUtility.DefaultSerializer, cancellationToken);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return SUtility.CancellationDefault<T>();
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
Console.Error.WriteLine($"{nameof(DefaultReadFromJsonAsync)}<{typeof(T).Name}> {response.RequestMessage?.RequestUri}: {ex.Message}");
|
|
return SUtility.CancellationDefault<T>();
|
|
}
|
|
}
|
|
|
|
public static string Timeframe(this DateTime creationDate, CoalescingStringLocalizer localizer)
|
|
{
|
|
var timeframe = DateTime.Now - creationDate.ToLocalTime();
|
|
switch ((int)timeframe.TotalHours)
|
|
{
|
|
case >= 24:
|
|
return localizer["{0} day/s", (int)timeframe.TotalDays];
|
|
|
|
case >= 1 and < 24:
|
|
return localizer["{0} hour/s", (int)timeframe.TotalHours];
|
|
|
|
case 0:
|
|
return localizer["{0} minute/s", (int)timeframe.TotalMinutes];
|
|
|
|
default:
|
|
return string.Empty;
|
|
}
|
|
}
|
|
|
|
public static AuthorizationPolicy IsAdminPolicy() =>
|
|
new AuthorizationPolicyBuilder().RequireAuthenticatedUser()
|
|
.RequireClaim(Policies.IsAdmin, "true")
|
|
.RequireClaim(Policies.IsUser, "true")
|
|
.RequireClaim(Policies.IsModerator, "true")
|
|
.Build();
|
|
|
|
public static AuthorizationPolicy IsUserPolicy() =>
|
|
new AuthorizationPolicyBuilder().RequireAuthenticatedUser()
|
|
.RequireClaim(Policies.IsUser, "true")
|
|
.Build();
|
|
|
|
public static AuthorizationPolicy IsUserModeratorPolicy() =>
|
|
new AuthorizationPolicyBuilder().RequireAuthenticatedUser()
|
|
.RequireClaim(Policies.IsModerator, "true")
|
|
.Build();
|
|
|
|
#endregion
|
|
}
|
|
} |