diff --git a/DysonNetwork.Sphere/ActivityPub/ActivityPubDiscoveryService.cs b/DysonNetwork.Sphere/ActivityPub/ActivityPubDiscoveryService.cs new file mode 100644 index 0000000..d3c4962 --- /dev/null +++ b/DysonNetwork.Sphere/ActivityPub/ActivityPubDiscoveryService.cs @@ -0,0 +1,255 @@ +using DysonNetwork.Shared.Models; +using Microsoft.EntityFrameworkCore; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace DysonNetwork.Sphere.ActivityPub; + +public class ActivityPubDiscoveryService( + AppDatabase db, + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + ILogger logger +) +{ + private string Domain => configuration["ActivityPub:Domain"] ?? "localhost"; + private HttpClient HttpClient => httpClientFactory.CreateClient(); + + private static readonly Regex HandlePattern = new(@"^@?(\w+)@([\w.-]+)$", RegexOptions.Compiled); + + public async Task DiscoverActorAsync(string query) + { + var (username, domain) = ParseHandle(query); + if (username == null || domain == null) + return null; + + var actorUri = await GetActorUriFromWebfingerAsync(username, domain); + if (actorUri == null) + return null; + + return await FetchAndStoreActorAsync(actorUri); + } + + public async Task> SearchActorsAsync( + string query, + int limit = 20, + bool includeRemoteDiscovery = true + ) + { + var localResults = await db.FediverseActors + .Where(a => + a.Username.Contains(query) || + a.DisplayName != null && a.DisplayName.Contains(query)) + .OrderByDescending(a => a.LastActivityAt ?? a.CreatedAt) + .Take(limit) + .ToListAsync(); + + if (!includeRemoteDiscovery) + return localResults; + + var (username, domain) = ParseHandle(query); + if (username != null && domain != null) + { + var actorUri = await GetActorUriFromWebfingerAsync(username, domain); + if (actorUri != null) + { + var remoteActor = await FetchAndStoreActorAsync(actorUri); + if (remoteActor != null && !localResults.Any(a => a.Uri == actorUri)) + { + var combined = new List(localResults) { remoteActor }; + return combined.Take(limit).ToList(); + } + } + } + + return localResults; + } + + private (string? username, string? domain) ParseHandle(string query) + { + var match = HandlePattern.Match(query.Trim()); + if (!match.Success) + return (null, null); + + return (match.Groups[1].Value, match.Groups[2].Value); + } + + private async Task GetActorUriFromWebfingerAsync(string username, string domain) + { + if (domain == Domain) + return null; + + try + { + var webfingerUrl = $"https://{domain}/.well-known/webfinger?resource=acct:{username}@{domain}"; + logger.LogInformation("Querying Webfinger: {Url}", webfingerUrl); + + var response = await HttpClient.GetAsync(webfingerUrl); + if (!response.IsSuccessStatusCode) + { + logger.LogWarning("Webfinger request failed: {Url} - {StatusCode}", webfingerUrl, response.StatusCode); + return null; + } + + var json = await response.Content.ReadAsStringAsync(); + var webfingerData = JsonSerializer.Deserialize(json); + + if (webfingerData?.Links == null) + { + logger.LogWarning("Invalid Webfinger response from {Url}", webfingerUrl); + return null; + } + + var selfLink = webfingerData.Links.FirstOrDefault(l => + l.Rel == "self" && + l.Type == "application/activity+json"); + + if (selfLink == null) + { + logger.LogWarning("No self link found in Webfinger response from {Url}", webfingerUrl); + return null; + } + + logger.LogInformation("Found actor URI via Webfinger: {ActorUri}", selfLink.Href); + return selfLink.Href; + } + catch (Exception ex) + { + logger.LogError(ex, "Error querying Webfinger for {Username}@{Domain}", username, domain); + return null; + } + } + + private async Task FetchAndStoreActorAsync(string actorUri) + { + var existingActor = await db.FediverseActors + .FirstOrDefaultAsync(a => a.Uri == actorUri); + + if (existingActor != null) + return existingActor; + + try + { + logger.LogInformation("Fetching actor: {ActorUri}", actorUri); + + var response = await HttpClient.GetAsync(actorUri); + if (!response.IsSuccessStatusCode) + { + logger.LogWarning("Failed to fetch actor: {Url} - {StatusCode}", actorUri, response.StatusCode); + return null; + } + + var json = await response.Content.ReadAsStringAsync(); + var actorData = JsonSerializer.Deserialize>(json); + + if (actorData == null) + { + logger.LogWarning("Invalid actor response from {Url}", actorUri); + return null; + } + + var domain = new Uri(actorUri).Host; + var instance = await db.FediverseInstances + .FirstOrDefaultAsync(i => i.Domain == domain); + + if (instance == null) + { + instance = new SnFediverseInstance + { + Domain = domain, + Name = domain + }; + db.FediverseInstances.Add(instance); + await db.SaveChangesAsync(); + } + + var actor = new SnFediverseActor + { + Uri = actorUri, + Username = actorData.GetValueOrDefault("preferredUsername")?.ToString() ?? ExtractUsername(actorUri), + DisplayName = actorData.GetValueOrDefault("name")?.ToString(), + Bio = actorData.GetValueOrDefault("summary")?.ToString(), + InboxUri = actorData.GetValueOrDefault("inbox")?.ToString(), + OutboxUri = actorData.GetValueOrDefault("outbox")?.ToString(), + FollowersUri = actorData.GetValueOrDefault("followers")?.ToString(), + FollowingUri = actorData.GetValueOrDefault("following")?.ToString(), + FeaturedUri = actorData.GetValueOrDefault("featured")?.ToString(), + AvatarUrl = ExtractAvatarUrl(actorData.GetValueOrDefault("icon")), + HeaderUrl = ExtractImageUrl(actorData.GetValueOrDefault("image")), + PublicKeyId = ExtractPublicKeyId(actorData.GetValueOrDefault("publicKey")), + PublicKey = ExtractPublicKeyPem(actorData.GetValueOrDefault("publicKey")), + IsBot = actorData.GetValueOrDefault("type")?.ToString() == "Service", + IsLocked = actorData.GetValueOrDefault("manuallyApprovesFollowers")?.ToString() == "true", + IsDiscoverable = actorData.GetValueOrDefault("discoverable")?.ToString() != "false", + InstanceId = instance.Id, + LastFetchedAt = NodaTime.SystemClock.Instance.GetCurrentInstant() + }; + + db.FediverseActors.Add(actor); + await db.SaveChangesAsync(); + + logger.LogInformation("Successfully fetched and stored actor: {Username}@{Domain}", actor.Username, domain); + return actor; + } + catch (Exception ex) + { + logger.LogError(ex, "Error fetching actor: {Uri}", actorUri); + return null; + } + } + + private string ExtractUsername(string actorUri) + { + return actorUri.Split('/').Last(); + } + + private string? ExtractAvatarUrl(object? iconData) + { + if (iconData == null) + return null; + + if (iconData is JsonElement element) + { + if (element.ValueKind == JsonValueKind.String) + return element.GetString(); + + if (element.TryGetProperty("url", out var urlElement)) + return urlElement.GetString(); + } + + return iconData.ToString(); + } + + private string? ExtractImageUrl(object? imageData) + { + return ExtractAvatarUrl(imageData); + } + + private string? ExtractPublicKeyId(object? publicKeyData) + { + if (publicKeyData == null) + return null; + + if (publicKeyData is JsonElement element && element.TryGetProperty("id", out var idElement)) + return idElement.GetString(); + + if (publicKeyData is Dictionary dict && dict.TryGetValue("id", out var idValue)) + return idValue?.ToString(); + + return null; + } + + private string? ExtractPublicKeyPem(object? publicKeyData) + { + if (publicKeyData == null) + return null; + + if (publicKeyData is JsonElement element && element.TryGetProperty("publicKeyPem", out var pemElement)) + return pemElement.GetString(); + + if (publicKeyData is Dictionary dict && dict.TryGetValue("publicKeyPem", out var pemValue)) + return pemValue?.ToString(); + + return null; + } +} diff --git a/DysonNetwork.Sphere/ActivityPub/ActivityPubFollowController.cs b/DysonNetwork.Sphere/ActivityPub/ActivityPubFollowController.cs index e42b2c6..f409a11 100644 --- a/DysonNetwork.Sphere/ActivityPub/ActivityPubFollowController.cs +++ b/DysonNetwork.Sphere/ActivityPub/ActivityPubFollowController.cs @@ -7,11 +7,12 @@ using NodaTime; namespace DysonNetwork.Sphere.ActivityPub; [ApiController] -[Route("api/activitypub")] +[Route("/api/activitypub")] [Authorize] public class ActivityPubFollowController( AppDatabase db, ActivityPubDeliveryService deliveryService, + ActivityPubDiscoveryService discoveryService, IConfiguration configuration, ILogger logger ) : ControllerBase @@ -160,13 +161,7 @@ public class ActivityPubFollowController( if (string.IsNullOrWhiteSpace(query)) return BadRequest(new { error = "Query is required" }); - var actors = await db.FediverseActors - .Where(a => - a.Username.Contains(query) || - a.DisplayName != null && a.DisplayName.Contains(query)) - .OrderByDescending(a => a.LastActivityAt ?? a.CreatedAt) - .Take(limit) - .ToListAsync(); + var actors = await discoveryService.SearchActorsAsync(query, limit, includeRemoteDiscovery: true); return Ok(actors); } diff --git a/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs b/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs index b1c544e..780a119 100644 --- a/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs +++ b/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs @@ -107,6 +107,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped();