Files
SocialPub/PrivaPub/Federation/Actors/RemoteActorService.cs
T
thepraandClaude Opus 5.5 587997dd67
Build / Build (push) Successful in 8m37s
P7: Lemmy's Warn and Resolve{Flag}
- A Warn from a community's moderator about a persona's own post there becomes that persona's moderation_warning
  notification (Mastodon's AccountWarning, with the reason and the post), believed from the community's own server or
  from an account the community's moderators collection lists. Remote communities keep that collection's address
  (attributedTo) as ForeignAvatar.ModeratorsURL; it is read only when a warning needs it.
- A Resolve{Flag} for one of our reports (`/grunts/flag-<report id>`) is kept as the report's remote resolution when it
  comes from the server holding what was reported, or the community it was reported to; our own moderators'
  resolution is never replaced. The moderators' report list shows both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-07 21:10:58 +02:00

272 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.ModeratorsURL, Origin.Same(actor.Moderators, actor.Id) ? actor.Moderators : default)
.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
};
}
}
}