using DysonNetwork.Shared.Models; using Microsoft.EntityFrameworkCore; using System.Security.Cryptography; using System.Text; using System.Text.Json; namespace DysonNetwork.Sphere.ActivityPub; public class ActivityPubDeliveryService( AppDatabase db, ActivityPubSignatureService signatureService, ActivityPubDiscoveryService discoveryService, IHttpClientFactory httpClientFactory, IConfiguration configuration, ILogger logger ) { private string Domain => configuration["ActivityPub:Domain"] ?? "localhost"; private HttpClient HttpClient { get { var client = httpClientFactory.CreateClient(); client.DefaultRequestHeaders.Clear(); return client; } } public async Task SendAcceptActivityAsync( Guid publisherId, string followerActorUri, string followActivityId ) { var publisher = await db.Publishers.FindAsync(publisherId); if (publisher == null) return false; var actorUrl = $"https://{Domain}/activitypub/actors/{publisher.Name}"; var followerActor = await db.FediverseActors .FirstOrDefaultAsync(a => a.Uri == followerActorUri); if (followerActor?.InboxUri == null) { logger.LogWarning("Follower actor or inbox not found: {Uri}", followerActorUri); return false; } var activity = new Dictionary { ["@context"] = "https://www.w3.org/ns/activitystreams", ["id"] = $"{actorUrl}/accepts/{Guid.NewGuid()}", ["type"] = "Accept", ["actor"] = actorUrl, ["object"] = followActivityId }; return await SendActivityToInboxAsync(activity, followerActor.InboxUri, actorUrl); } public async Task SendFollowActivityAsync( Guid publisherId, string targetActorUri ) { var publisher = await db.Publishers.FindAsync(publisherId); if (publisher == null) return false; var actorUrl = $"https://{Domain}/activitypub/actors/{publisher.Name}"; var targetActor = await GetOrFetchActorAsync(targetActorUri); var localActor = await GetOrCreateLocalActorAsync(publisher); if (targetActor?.InboxUri == null || localActor == null) { logger.LogWarning("Target actor or inbox not found: {Uri}", targetActorUri); return false; } var activity = new Dictionary { ["@context"] = "https://www.w3.org/ns/activitystreams", ["id"] = $"{actorUrl}/follows/{Guid.NewGuid()}", ["type"] = "Follow", ["actor"] = actorUrl, ["object"] = targetActorUri }; await db.FediverseRelationships.AddAsync(new SnFediverseRelationship { IsLocalActor = true, LocalPublisherId = publisher.Id, ActorId = localActor.Id, TargetActorId = targetActor.Id, State = RelationshipState.Pending, IsFollowing = true, IsFollowedBy = false }); await db.SaveChangesAsync(); return await SendActivityToInboxAsync(activity, targetActor.InboxUri, actorUrl); } public async Task SendCreateActivityAsync(SnPost post) { var publisher = await db.Publishers.FindAsync(post.PublisherId); if (publisher == null) return false; var actorUrl = $"https://{Domain}/activitypub/actors/{publisher.Name}"; var postUrl = $"https://{Domain}/posts/{post.Id}"; var activity = new Dictionary { ["@context"] = "https://www.w3.org/ns/activitystreams", ["id"] = $"{postUrl}/activity", ["type"] = "Create", ["actor"] = actorUrl, ["published"] = (post.PublishedAt ?? post.CreatedAt).ToDateTimeOffset(), ["to"] = new[] { "https://www.w3.org/ns/activitystreams#Public" }, ["cc"] = new[] { $"{actorUrl}/followers" }, ["object"] = new Dictionary { ["id"] = postUrl, ["type"] = post.Type == PostType.Article ? "Article" : "Note", ["published"] = (post.PublishedAt ?? post.CreatedAt).ToDateTimeOffset(), ["attributedTo"] = actorUrl, ["content"] = post.Content ?? "", ["to"] = new[] { "https://www.w3.org/ns/activitystreams#Public" }, ["cc"] = new[] { $"{actorUrl}/followers" }, ["attachment"] = post.Attachments.Select(a => new Dictionary { ["type"] = "Document", ["mediaType"] = "image/jpeg", ["url"] = $"https://{Domain}/api/files/{a.Id}" }).ToList() } }; var followers = await GetRemoteFollowersAsync(publisher.Id); var successCount = 0; foreach (var follower in followers) { if (follower.InboxUri == null) continue; var success = await SendActivityToInboxAsync(activity, follower.InboxUri, actorUrl); if (success) successCount++; } logger.LogInformation("Sent Create activity to {Count}/{Total} followers", successCount, followers.Count); return successCount > 0; } public async Task SendLikeActivityAsync( Guid postId, Guid accountId, string targetActorUri ) { var publisher = await db.Publishers .Include(p => p.Members) .Where(p => p.Members.Any(m => m.AccountId == accountId)) .FirstOrDefaultAsync(); if (publisher == null) return false; var actorUrl = $"https://{Domain}/activitypub/actors/{publisher.Name}"; var postUrl = $"https://{Domain}/posts/{postId}"; var targetActor = await GetOrFetchActorAsync(targetActorUri); if (targetActor?.InboxUri == null) return false; var activity = new Dictionary { ["@context"] = "https://www.w3.org/ns/activitystreams", ["id"] = $"{actorUrl}/likes/{Guid.NewGuid()}", ["type"] = "Like", ["actor"] = actorUrl, ["object"] = postUrl }; return await SendActivityToInboxAsync(activity, targetActor.InboxUri, actorUrl); } public async Task SendUndoActivityAsync( string activityType, string objectUri, Guid publisherId ) { var publisher = await db.Publishers.FindAsync(publisherId); if (publisher == null) return false; var actorUrl = $"https://{Domain}/activitypub/actors/{publisher.Name}"; var followers = await GetRemoteFollowersAsync(publisher.Id); var activity = new Dictionary { ["@context"] = "https://www.w3.org/ns/activitystreams", ["id"] = $"{actorUrl}/undo/{Guid.NewGuid()}", ["type"] = "Undo", ["actor"] = actorUrl, ["object"] = new Dictionary { ["type"] = activityType, ["object"] = objectUri } }; var successCount = 0; foreach (var follower in followers) { if (follower.InboxUri != null) { var success = await SendActivityToInboxAsync(activity, follower.InboxUri, actorUrl); if (success) successCount++; } } return successCount > 0; } private async Task SendActivityToInboxAsync( Dictionary activity, string inboxUrl, string actorUri ) { try { var json = JsonSerializer.Serialize(activity); var request = new HttpRequestMessage(HttpMethod.Post, inboxUrl); request.Content = new StringContent(json, Encoding.UTF8, "application/activity+json"); request.Headers.Date = DateTimeOffset.UtcNow; var bodyBytes = Encoding.UTF8.GetBytes(json); var hash = SHA256.HashData(bodyBytes); var digest = $"SHA-256={Convert.ToBase64String(hash)}"; request.Headers.Add("Digest", digest); request.Headers.Host = new Uri(inboxUrl).Host; logger.LogInformation("Preparing request to {Inbox}", inboxUrl); logger.LogInformation("Request body (truncated): {Body}", json[..Math.Min(200, json.Length)] + "..."); logger.LogInformation("Request headers before signing: Date={Date}, Digest={Digest}, Host={Host}", request.Headers.Date, digest, request.Headers.Host); var signatureHeaders = await signatureService.SignOutgoingRequest(request, actorUri); var signatureString = $"keyId=\"{signatureHeaders["keyId"]}\"," + $"algorithm=\"{signatureHeaders["algorithm"]}\"," + $"headers=\"{signatureHeaders["headers"]}\"," + $"signature=\"{signatureHeaders["signature"]}\""; request.Headers.Add("Signature", signatureString); logger.LogInformation("Full signature header: {Signature}", signatureString); logger.LogInformation("Request headers after signing:"); foreach (var header in request.Headers) { var value = header.Value.Any() ? header.Value.First() : string.Empty; if (header.Key == "signature") value = value[..Math.Min(100, value.Length)] + "..."; logger.LogInformation(" {Key}: {Value}", header.Key, value); } var response = await HttpClient.SendAsync(request); var responseContent = await response.Content.ReadAsStringAsync(); logger.LogInformation("Response from {Inbox}. Status: {Status}", inboxUrl, response.StatusCode); if (!response.IsSuccessStatusCode) { logger.LogError("Failed to send activity to {Inbox}. Status: {Status}, Response: {Response}", inboxUrl, response.StatusCode, responseContent); logger.LogError("Full request details: Method={Method}, Uri={Uri}, ContentType={ContentType}", request.Method, request.RequestUri, request.Content?.Headers.ContentType); return false; } logger.LogInformation("Successfully sent activity to {Inbox}", inboxUrl); return true; } catch (Exception ex) { logger.LogError(ex, "Error sending activity to {Inbox}. Exception: {Message}", inboxUrl, ex.Message); return false; } } private async Task> GetRemoteFollowersAsync(Guid publisherId) { return await db.FediverseRelationships .Include(r => r.TargetActor) .Where(r => r.LocalPublisherId == publisherId && r.IsFollowedBy && r.IsLocalActor) .Select(r => r.TargetActor) .ToListAsync(); } private async Task GetOrCreateLocalActorAsync(SnPublisher publisher) { var actorUrl = $"https://{Domain}/activitypub/actors/{publisher.Name}"; var localActor = await db.FediverseActors .FirstOrDefaultAsync(a => a.Uri == actorUrl); if (localActor != null) return localActor; 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 assetsBaseUrl = configuration["ActivityPub:FileBaseUrl"] ?? $"https://{Domain}/files"; localActor = new SnFediverseActor { Uri = actorUrl, Username = publisher.Name, DisplayName = publisher.Name, Bio = publisher.Bio, InboxUri = $"{actorUrl}/inbox", OutboxUri = $"{actorUrl}/outbox", FollowersUri = $"{actorUrl}/followers", FollowingUri = $"{actorUrl}/following", AvatarUrl = publisher.Picture != null ? $"{assetsBaseUrl}/{publisher.Picture.Id}" : null, HeaderUrl = publisher.Background != null ? $"{assetsBaseUrl}/{publisher.Background.Id}" : null, InstanceId = instance.Id }; db.FediverseActors.Add(localActor); await db.SaveChangesAsync(); return localActor; } private async Task GetOrFetchActorAsync(string actorUri) { var actor = await db.FediverseActors .FirstOrDefaultAsync(a => a.Uri == actorUri); if (actor != null) return actor; try { var response = await HttpClient.GetAsync(actorUri); if (!response.IsSuccessStatusCode) return null; var json = await response.Content.ReadAsStringAsync(); var actorData = JsonSerializer.Deserialize>(json); if (actorData == null) 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(); await discoveryService.FetchInstanceMetadataAsync(instance); } actor = new SnFediverseActor { Uri = actorUri, Username = 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(), AvatarUrl = actorData.GetValueOrDefault("icon")?.ToString(), InstanceId = instance.Id }; db.FediverseActors.Add(actor); await db.SaveChangesAsync(); return actor; } catch (Exception ex) { logger.LogError(ex, "Failed to fetch actor: {Uri}", actorUri); return null; } } private string ExtractUsername(string actorUri) { return actorUri.Split('/').Last(); } }