using MongoDB.Entities; using PrivaPub.Models.User; using PrivaPub.StaticServices; using System.Net.Http.Headers; using System.Text.Json; namespace PrivaPub.Services.Federation { public interface IRemoteActorService { Task Fetch(string uri, LocalActor signAs, CancellationToken token); Task GetActor(string actorUri, LocalActor signAs, bool refresh, CancellationToken token); Task GetActorByKeyId(string keyId, LocalActor signAs, bool refresh, CancellationToken token); Task ResolveHandle(string handle, CancellationToken token); } public class RemoteActorService : IRemoteActorService { public const string HttpClientName = "ActivityPub"; 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); readonly IHttpClientFactory _httpClientFactory; readonly DbEntities _dbEntities; readonly ILogger _logger; public RemoteActorService(IHttpClientFactory httpClientFactory, DbEntities dbEntities, ILogger logger) { _httpClientFactory = httpClientFactory; _dbEntities = dbEntities; _logger = logger; } public async Task Fetch(string uri, LocalActor signAs, CancellationToken token) { if (!Uri.TryCreate(uri, UriKind.Absolute, out var target) || !IsFetchable(target)) return default; using var request = new HttpRequestMessage(HttpMethod.Get, target); request.Headers.Accept.ParseAdd(Accept); if (signAs != default) HttpSignatures.Sign(request, signAs, body: null); using var response = await _httpClientFactory.CreateClient(HttpClientName).SendAsync(request, token); if (!response.IsSuccessStatusCode) { _logger.LogInformation("GET {Uri} answered {Status}", uri, (int)response.StatusCode); return default; } await using var stream = await response.Content.ReadAsStreamAsync(token); return await JsonDocument.ParseAsync(stream, cancellationToken: token); } public async Task GetActor(string actorUri, LocalActor signAs, 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; using var document = await Fetch(actorUri, signAs, token); if (document == default) return cached; return await Upsert(document.RootElement, cached, token); } public async Task GetActorByKeyId(string keyId, LocalActor signAs, bool refresh, CancellationToken token) { if (string.IsNullOrEmpty(keyId)) return default; if (!refresh) { var cached = await _dbEntities.ForeignAvatars.Match(a => a.PublicKeyId == keyId).ExecuteFirstAsync(token); if (cached != default && !string.IsNullOrEmpty(cached.PublicKey)) return cached; } using var document = await Fetch(StripFragment(keyId), signAs, token); if (document == default) return default; var root = document.RootElement; if (root.TryGetProperty("publicKey", out _)) { var actorUri = Text(root, "id"); var existing = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == actorUri).ExecuteFirstAsync(token); return await Upsert(root, existing, token); } var owner = Text(root, "owner"); return owner == default ? default : await GetActor(owner, signAs, refresh: true, token); } 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; var url = $"https://{parts[1]}/.well-known/webfinger?resource={Uri.EscapeDataString($"acct:{parts[0]}@{parts[1]}")}"; if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsFetchable(target)) return default; using var request = new HttpRequestMessage(HttpMethod.Get, target); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/jrd+json")); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); using var response = await _httpClientFactory.CreateClient(HttpClientName).SendAsync(request, token); if (!response.IsSuccessStatusCode) return default; await using var stream = await response.Content.ReadAsStreamAsync(token); using var document = await JsonDocument.ParseAsync(stream, cancellationToken: token); 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 Upsert(JsonElement actor, ForeignAvatar existing, CancellationToken token) { var actorUri = Text(actor, "id"); if (string.IsNullOrEmpty(actorUri)) return existing; var avatar = existing ?? new ForeignAvatar { ActorURI = actorUri, CreatedAt = DateTime.UtcNow }; avatar.ActorURI = actorUri; avatar.UserName = Text(actor, "preferredUsername"); avatar.Name = Text(actor, "name"); avatar.Biography = Text(actor, "summary"); avatar.Url = Text(actor, "url") ?? actorUri; avatar.Domain = new Uri(actorUri).Authority; avatar.InboxURL = Text(actor, "inbox"); avatar.OutboxURL = Text(actor, "outbox"); avatar.IsDiscoverable = !actor.TryGetProperty("discoverable", out var discoverable) || discoverable.ValueKind != JsonValueKind.False; avatar.AvatarType = Enum.TryParse(Text(actor, "type"), out var type) ? type : AvatarType.Person; if (actor.TryGetProperty("endpoints", out var endpoints) && endpoints.ValueKind == JsonValueKind.Object) avatar.SharedInboxURL = Text(endpoints, "sharedInbox"); if (actor.TryGetProperty("publicKey", out var publicKey) && publicKey.ValueKind == JsonValueKind.Object) { avatar.PublicKeyId = Text(publicKey, "id"); avatar.PublicKey = Text(publicKey, "publicKeyPem"); } if (actor.TryGetProperty("icon", out var icon) && icon.ValueKind == JsonValueKind.Object) avatar.PictureURL = Text(icon, "url"); avatar.UpdatedAt = DateTime.UtcNow; await DB.Default.SaveAsync(avatar, token); return avatar; } public static bool IsFetchable(Uri target) => target.Scheme == Uri.UriSchemeHttps && target.HostNameType == UriHostNameType.Dns && !target.IsLoopback && !target.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase) && target.Host.Contains('.'); 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 }; } } }