An account on a server whose handles are not its host's (Mastodon's LOCAL_DOMAIN apart from WEB_DOMAIN) showed as user@host. The actor's `webfinger` names the handle; its domain is kept once WebFinger there points back to the actor, and asked again when the name changes. A persona's blocked servers match a handle's domain as well as the actor's host, as the lists Mastodon exports name handles' domains. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
271 lines
11 KiB
C#
271 lines
11 KiB
C#
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Models.User;
|
|
using PrivaPub.StaticServices;
|
|
|
|
using System.Text.Json;
|
|
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
using PrivaPub.Federation.Objects;
|
|
using PrivaPub.Federation.Signing;
|
|
using PrivaPub.Infrastructure.Http;
|
|
|
|
namespace PrivaPub.Federation.Actors
|
|
{
|
|
public interface IRemoteActorService
|
|
{
|
|
Task<FetchedJson> FetchObject(string uri, CancellationToken token);
|
|
Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token);
|
|
Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token);
|
|
bool KeyTemporarilyUnavailable(string keyId) => false;
|
|
Task<bool> IsGone(string uri, CancellationToken token) => Task.FromResult(false);
|
|
Task<string> ResolveHandle(string handle, CancellationToken token);
|
|
}
|
|
|
|
public class RemoteActorService : IRemoteActorService
|
|
{
|
|
public const string ActivityJson = "application/activity+json";
|
|
const string Accept = "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"";
|
|
static readonly TimeSpan CacheLifetime = TimeSpan.FromDays(1);
|
|
static readonly TimeSpan RefetchInterval = TimeSpan.FromMinutes(5);
|
|
|
|
readonly IFederationHttp _http;
|
|
readonly ILocalActorService _localActors;
|
|
readonly IMemoryCache _cache;
|
|
readonly DbEntities _dbEntities;
|
|
readonly IOptionsMonitor<FederationOptions> _options;
|
|
|
|
readonly Infrastructure.Jobs.IJobQueue _jobs;
|
|
|
|
public RemoteActorService(IFederationHttp http, ILocalActorService localActors, IMemoryCache cache, DbEntities dbEntities,
|
|
IOptionsMonitor<FederationOptions> options = default, Infrastructure.Jobs.IJobQueue jobs = default)
|
|
{
|
|
_jobs = jobs;
|
|
_http = http;
|
|
_localActors = localActors;
|
|
_cache = cache;
|
|
_dbEntities = dbEntities;
|
|
_options = options;
|
|
}
|
|
|
|
public async Task<FetchedJson> FetchObject(string uri, CancellationToken token)
|
|
{
|
|
using var scope = HttpScope.Default("object");
|
|
var signer = await _localActors.GetInstanceActor(token);
|
|
var fetched = await Get(uri, signer, token);
|
|
if (fetched == default)
|
|
return default;
|
|
|
|
var id = Text(fetched.Root, "id");
|
|
if (Origin.IsDocumentAt(id, fetched.FinalUri))
|
|
return fetched;
|
|
|
|
var finalUri = fetched.FinalUri;
|
|
fetched.Dispose();
|
|
if (!Origin.Same(id, finalUri.AbsoluteUri))
|
|
return default;
|
|
|
|
var named = await Get(id, signer, token);
|
|
if (named != default && Origin.IsDocumentAt(Text(named.Root, "id"), named.FinalUri))
|
|
return named;
|
|
named?.Dispose();
|
|
return default;
|
|
}
|
|
|
|
public async Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token)
|
|
{
|
|
if (string.IsNullOrEmpty(actorUri))
|
|
return default;
|
|
actorUri = StripFragment(actorUri);
|
|
|
|
var cached = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == actorUri).ExecuteFirstAsync(token);
|
|
if (cached != default && !refresh && DateTime.UtcNow - cached.UpdatedAt < CacheLifetime)
|
|
return cached;
|
|
if (!MayFetch(actorUri))
|
|
return cached;
|
|
|
|
using var scope = HttpScope.For("actor");
|
|
using var fetched = await FetchObject(actorUri, token);
|
|
var actor = fetched == default ? default : ActorDocument.Parse(fetched.Root);
|
|
if (actor == default)
|
|
return cached;
|
|
|
|
var key = cached == default ? default : actor.Key(cached.PublicKeyId);
|
|
return await Counted(await Upsert(actor, key ?? actor.Keys.FirstOrDefault(), token), token);
|
|
}
|
|
|
|
// its counts are read once a day, after it was fetched (AccountCountsJob)
|
|
async Task<ForeignAvatar> Counted(ForeignAvatar stored, CancellationToken token)
|
|
{
|
|
if (_jobs != default && stored is { DeletionAt: null } && Uri.TryCreate(stored.ActorURI, UriKind.Absolute, out var uri))
|
|
await _jobs.Enqueue(Models.Jobs.JobKind.CountAccount, stored.ActorURI, uri.Host.ToLowerInvariant(),
|
|
AccountCountsJob.DedupeKey(stored.ActorURI, DateTime.UtcNow), token);
|
|
return stored;
|
|
}
|
|
|
|
public async Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token)
|
|
{
|
|
if (Origin.Of(keyId) == default)
|
|
return default;
|
|
|
|
var cached = await _dbEntities.ForeignAvatars.Match(a => a.PublicKeyId == keyId).ExecuteFirstAsync(token);
|
|
if (cached != default && !string.IsNullOrEmpty(cached.PublicKey) && !refresh)
|
|
return cached;
|
|
if (!MayFetch(keyId))
|
|
return cached;
|
|
|
|
using var scope = HttpScope.For("key");
|
|
using var fetched = await FetchObject(StripFragment(keyId), token);
|
|
if (fetched == default)
|
|
return cached;
|
|
|
|
var actor = ActorDocument.Parse(fetched.Root);
|
|
if (actor == default)
|
|
{
|
|
var owner = Text(fetched.Root, "owner");
|
|
if (Text(fetched.Root, "id") != keyId || !Origin.Same(owner, keyId))
|
|
return default;
|
|
using var ownerDocument = await FetchObject(owner, token);
|
|
actor = ownerDocument == default ? default : ActorDocument.Parse(ownerDocument.Root);
|
|
}
|
|
|
|
var key = actor?.Key(keyId);
|
|
if (key == default)
|
|
return default;
|
|
return await Counted(await Upsert(actor, key, token), token);
|
|
}
|
|
|
|
public bool KeyTemporarilyUnavailable(string keyId) => Origin.Of(keyId) != default && _http.FailedTemporarily(StripFragment(keyId));
|
|
|
|
// whether an object's origin says it is gone: 404 or 410 to our instance actor, or a Tombstone in its place
|
|
public async Task<bool> IsGone(string uri, CancellationToken token)
|
|
{
|
|
if (Origin.Of(uri) == default)
|
|
return false;
|
|
using var scope = HttpScope.Default("object");
|
|
var signer = await _localActors.GetInstanceActor(token);
|
|
var (status, fetched) = await _http.GetJsonStatus(uri, Accept, request => HttpSignatures.Sign(request, signer, body: null), token);
|
|
using (fetched)
|
|
return status is StatusCodes.Status404NotFound or StatusCodes.Status410Gone || fetched != default && Text(fetched.Root, "type") == "Tombstone";
|
|
}
|
|
|
|
public async Task<string> ResolveHandle(string handle, CancellationToken token)
|
|
{
|
|
var parts = handle?.TrimStart('@').Split('@');
|
|
if (parts is not { Length: 2 } || string.IsNullOrEmpty(parts[0]) || string.IsNullOrEmpty(parts[1]))
|
|
return default;
|
|
|
|
using var scope = HttpScope.For("webfinger");
|
|
var query = $"/.well-known/webfinger?resource={Uri.EscapeDataString($"acct:{parts[0]}@{parts[1]}")}";
|
|
using var fetched = await _http.GetJson($"https://{parts[1]}{query}", "application/jrd+json, application/json", sign: default, token)
|
|
?? (_options?.CurrentValue.AllowPlainHttp == true
|
|
? await _http.GetJson($"http://{parts[1]}{query}", "application/jrd+json, application/json", sign: default, token)
|
|
: default);
|
|
if (fetched == default)
|
|
return default;
|
|
var document = fetched.Document;
|
|
if (!document.RootElement.TryGetProperty("links", out var links) || links.ValueKind != JsonValueKind.Array)
|
|
return default;
|
|
|
|
foreach (var link in links.EnumerateArray())
|
|
{
|
|
var type = Text(link, "type") ?? string.Empty;
|
|
if (Text(link, "rel") == "self" && (type.Contains("activity+json") || type.Contains("ld+json")))
|
|
return Text(link, "href");
|
|
}
|
|
return default;
|
|
}
|
|
|
|
async Task<FetchedJson> Get(string uri, LocalActor signer, CancellationToken token) =>
|
|
await _http.GetJson(uri, Accept, request => HttpSignatures.Sign(request, signer, body: null), token);
|
|
|
|
bool MayFetch(string uri)
|
|
{
|
|
var key = "remote-actor:fetched:" + uri;
|
|
if (_cache.TryGetValue(key, out _))
|
|
return false;
|
|
_cache.Set(key, true, RefetchInterval);
|
|
return true;
|
|
}
|
|
|
|
// the domain of its handle: its own host, or the one its `webfinger` names (FEP-2c59; a server whose handles are not
|
|
// its host's, as Mastodon's LOCAL_DOMAIN) once that domain's WebFinger says the handle is this actor. Asked again
|
|
// only when the handle changes, a rename among them.
|
|
async Task<string> HandleDomain(ActorDocument actor, CancellationToken token)
|
|
{
|
|
var host = new Uri(actor.Id).Authority;
|
|
var named = actor.WebFinger?.Split('@');
|
|
if (named is not { Length: 2 } || named[0].Length == 0 || named[1].Length == 0 || named[1].Equals(host, StringComparison.OrdinalIgnoreCase))
|
|
return host;
|
|
var domain = named[1].ToLowerInvariant();
|
|
var stored = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == actor.Id).ExecuteFirstAsync(token);
|
|
if (stored?.Domain == domain && stored.UserName == actor.PreferredUsername)
|
|
return domain;
|
|
return await ResolveHandle($"{named[0]}@{domain}", token) == actor.Id ? domain : host;
|
|
}
|
|
|
|
async Task<ForeignAvatar> Upsert(ActorDocument actor, ActorKey key, CancellationToken token)
|
|
{
|
|
var domain = await HandleDomain(actor, token);
|
|
var now = DateTime.UtcNow;
|
|
return await DB.Default.UpdateAndGet<ForeignAvatar>()
|
|
.Match(a => a.ActorURI == actor.Id)
|
|
.Modify(a => a.UserName, actor.PreferredUsername)
|
|
.Modify(a => a.Name, actor.Name)
|
|
.Modify(a => a.Biography, ContentSanitizer.Html(actor.Summary))
|
|
.Modify(a => a.Url, actor.Url)
|
|
.Modify(a => a.Domain, domain)
|
|
.Modify(a => a.InboxURL, actor.Inbox)
|
|
.Modify(a => a.OutboxURL, actor.Outbox)
|
|
.Modify(a => a.FollowersURL, actor.Followers)
|
|
.Modify(a => a.FollowingURL, actor.Following)
|
|
.Modify(a => a.FeaturedURL, Origin.Same(actor.Featured, actor.Id) ? actor.Featured : default)
|
|
.Modify(a => a.WallURL, Origin.Same(actor.Wall, actor.Id) ? actor.Wall : default)
|
|
.Modify(a => a.AssertionKeys, actor.AssertionKeys)
|
|
.Modify(a => a.SharedInboxURL, actor.SharedInbox)
|
|
.Modify(a => a.PictureURL, actor.Icon)
|
|
.Modify(a => a.ThumbnailURL, actor.Header)
|
|
.Modify(a => a.PictureDescription, actor.IconDescription)
|
|
.Modify(a => a.HeaderDescription, actor.HeaderDescription)
|
|
.Modify(a => a.IsDiscoverable, actor.Discoverable)
|
|
.Modify(a => a.IsIndexable, actor.Indexable)
|
|
.Modify(a => a.IsLocked, actor.Locked)
|
|
.Modify(a => a.IsMemorial, actor.Memorial)
|
|
.Modify(a => a.MovedToURL, Origin.Of(actor.MovedTo) == default ? default : actor.MovedTo)
|
|
.Modify(a => a.AlsoKnownAs, actor.AlsoKnownAs)
|
|
.Modify(a => a.Published, actor.Published)
|
|
.Modify(a => a.Emojis, actor.Emojis)
|
|
.Modify(a => a.Fields, actor.Fields)
|
|
.Modify(a => a.Features, actor.Features)
|
|
.Modify(a => a.AvatarType, Enum.TryParse<AvatarType>(actor.Type, out var type) ? type : AvatarType.Person)
|
|
.Modify(a => a.PublicKeyId, key?.Id)
|
|
.Modify(a => a.PublicKey, key?.Pem)
|
|
.Modify(a => a.UpdatedAt, now)
|
|
.Modify(b => b.SetOnInsert(a => a.CreatedAt, now))
|
|
.Option(o => o.IsUpsert = true)
|
|
.ExecuteAsync(token);
|
|
}
|
|
|
|
public static string StripFragment(string uri)
|
|
{
|
|
var hash = uri.IndexOf('#');
|
|
return hash < 0 ? uri : uri[..hash];
|
|
}
|
|
|
|
public static string Text(JsonElement element, string property)
|
|
{
|
|
if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(property, out var value))
|
|
return default;
|
|
return value.ValueKind switch
|
|
{
|
|
JsonValueKind.String => value.GetString(),
|
|
JsonValueKind.Object => Text(value, "id") ?? Text(value, "href"),
|
|
JsonValueKind.Array => value.EnumerateArray().Select(v => v.ValueKind == JsonValueKind.String ? v.GetString() : Text(v, "id")).FirstOrDefault(v => v != null),
|
|
_ => default
|
|
};
|
|
}
|
|
}
|
|
}
|