Files
decePubClient/Services/InstanceService.cs
T
thepraandClaude Opus 5.5 620cbef219
Build / Build (push) Successful in 26s
Deploy / decepub.thepra.dev (push) Successful in 1m21s
CDN-fronted servers grouped per CDN, and servers through time
A post whose server is behind a CDN has no place of its own: the globe
groups such posts per CDN in labels on a ring above the Arctic, and
hovering one draws its arc to the CDN's group and a ring where the
server was before the CDN. The card's CDN chip names the CDN, its
domain, how PrivaPub found it (published ranges, response headers or
network), the place before the CDN and the CDN's servers week by week
(/api/privapub/v1/cdns/:domain); the author's tooltip lists the server's
changes of place and CDN week by week (/instances/:host/history). Both
are read on the first hover and kept for the session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-04 11:44:51 +02:00

219 lines
8.8 KiB
C#

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 located it only to a country. A CDN-fronted server has no place: its
/// posts join their CDN's group. A server's weekly history and a CDN's page are read on demand, once per session.
/// </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);
readonly Dictionary<string, List<InstanceWeek>> histories = new(StringComparer.OrdinalIgnoreCase);
readonly Dictionary<string, CdnDetail> cdns = 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);
}
public List<InstanceWeek> KnownHistory(string host) => host is not null ? histories.GetValueOrDefault(host) : null;
public CdnDetail KnownCdn(string key) => key is not null ? cdns.GetValueOrDefault(key) : null;
/// <summary>The server's weekly snapshots, newest first; empty when PrivaPub has none.</summary>
public async Task<List<InstanceWeek>> History(string host, CancellationToken cancellationToken = default)
{
if (host is null)
return [];
if (KnownHistory(host) is { } known)
return known;
using var response = await httpService.GetAnon(APIs.PrivaPub.GET_M_InstanceHistory(host), [$"weeks=52"], cancellationToken: cancellationToken);
var history = response.IsSuccessStatusCode ? await response.MastodonReadFromJsonAsync<List<InstanceWeek>>(cancellationToken) ?? [] : [];
histories[host] = history;
return history;
}
/// <summary>A CDN's servers and its weeks, or null when PrivaPub does not know it.</summary>
public async Task<CdnDetail> Cdn(string key, CancellationToken cancellationToken = default)
{
if (key is null)
return null;
if (KnownCdn(key) is { } known)
return known;
using var response = await httpService.GetAnon(APIs.PrivaPub.GET_M_Cdn(key), [$"weeks=12"], cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return null;
var cdn = await response.MastodonReadFromJsonAsync<CdnDetail>(cancellationToken);
if (cdn is not null)
cdns[key] = cdn;
return cdn;
}
/// <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?.Cdn is { Length: > 0 } cdn)
return new(0, 0, LocationSource.Cdn, cdn, 0, geo.CdnDomain ?? cdn.ToLowerInvariant(), geo.BeforeCdn);
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);
}
}
}
}