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 FetchObject(string uri, CancellationToken token); Task GetActor(string actorUri, bool refresh, CancellationToken token); Task GetActorByKeyId(string keyId, bool refresh, CancellationToken token); bool KeyTemporarilyUnavailable(string keyId) => false; Task 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 _options; readonly Infrastructure.Jobs.IJobQueue _jobs; public RemoteActorService(IFederationHttp http, ILocalActorService localActors, IMemoryCache cache, DbEntities dbEntities, IOptionsMonitor options = default, Infrastructure.Jobs.IJobQueue jobs = default) { _jobs = jobs; _http = http; _localActors = localActors; _cache = cache; _dbEntities = dbEntities; _options = options; } public async Task 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 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 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 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)); public async Task 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 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; } static async Task Upsert(ActorDocument actor, ActorKey key, CancellationToken token) { var now = DateTime.UtcNow; return await DB.Default.UpdateAndGet() .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, new Uri(actor.Id).Authority) .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.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.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(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 }; } } }