From 0519f2a2e654b37009243ee7b020be7de817f059 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Wed, 31 Dec 2025 00:24:08 +0800 Subject: [PATCH] :sparkles: Post activitypub, and publisher update events --- .../ActivityPubActivityProcessor.cs | 6 +- .../ActivityPub/ActivityPubDeliveryService.cs | 196 +++++++++++++++++- DysonNetwork.Sphere/Post/PostService.cs | 56 +++++ .../Publisher/PublisherController.cs | 107 +++++++++- .../Publisher/PublisherService.cs | 151 +++++++++++++- 5 files changed, 510 insertions(+), 6 deletions(-) diff --git a/DysonNetwork.Sphere/ActivityPub/ActivityPubActivityProcessor.cs b/DysonNetwork.Sphere/ActivityPub/ActivityPubActivityProcessor.cs index 9364227..33d6613 100644 --- a/DysonNetwork.Sphere/ActivityPub/ActivityPubActivityProcessor.cs +++ b/DysonNetwork.Sphere/ActivityPub/ActivityPubActivityProcessor.cs @@ -96,15 +96,15 @@ public class ActivityPubActivityProcessor( if (targetPublisher == null) { - logger.LogWarning("Target publisher not found: {Uri}, ExtractedUsername: {Username}", + logger.LogWarning("Target publisher not found: {Uri}, ExtractedUsername: {Username}", objectUri, targetUsername); return false; } - var localActor = await deliveryService.GetOrCreateLocalActorAsync(targetPublisher); + var localActor = await deliveryService.GetLocalActorAsync(targetPublisher.Id); if (localActor == null) { - logger.LogWarning("Target publisher has no actor..."); + logger.LogWarning("Target publisher has no enabled fediverse actor"); return false; } diff --git a/DysonNetwork.Sphere/ActivityPub/ActivityPubDeliveryService.cs b/DysonNetwork.Sphere/ActivityPub/ActivityPubDeliveryService.cs index cd72c2c..dcc3c65 100644 --- a/DysonNetwork.Sphere/ActivityPub/ActivityPubDeliveryService.cs +++ b/DysonNetwork.Sphere/ActivityPub/ActivityPubDeliveryService.cs @@ -16,6 +16,7 @@ public class ActivityPubDeliveryService( ) { private string Domain => configuration["ActivityPub:Domain"] ?? "localhost"; + private string AssetsBaseUrl => configuration["ActivityPub:FileBaseUrl"] ?? $"https://{Domain}/files"; private HttpClient HttpClient { @@ -70,7 +71,7 @@ public class ActivityPubDeliveryService( var actorUrl = $"https://{Domain}/activitypub/actors/{publisher.Name}"; var targetActor = await GetOrFetchActorAsync(targetActorUri); - var localActor = await GetOrCreateLocalActorAsync(publisher); + var localActor = await GetLocalActorAsync(publisher.Id); if (targetActor?.InboxUri == null || localActor == null) { @@ -168,6 +169,192 @@ public class ActivityPubDeliveryService( return successCount > 0; } + public async Task SendUpdateActivityAsync(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/{Guid.NewGuid()}", + ["type"] = "Update", + ["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(), + ["updated"] = post.EditedAt?.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(); + 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 Update activity to {Count}/{Total} followers", + successCount, followers.Count); + + return successCount > 0; + } + + public async Task SendDeleteActivityAsync(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}/delete/{Guid.NewGuid()}", + ["type"] = "Delete", + ["actor"] = actorUrl, + ["to"] = new[] { "https://www.w3.org/ns/activitystreams#Public" }, + ["cc"] = new[] { $"{actorUrl}/followers" }, + ["object"] = new Dictionary + { + ["id"] = postUrl, + ["type"] = "Tombstone" + } + }; + + var followers = await GetRemoteFollowersAsync(); + 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 Delete activity to {Count}/{Total} followers", + successCount, followers.Count); + + return successCount > 0; + } + + public async Task SendUpdateActorActivityAsync(SnFediverseActor actor) + { + var publisher = await db.Publishers + .Include(p => p.Picture) + .Include(p => p.Background) + .FirstOrDefaultAsync(p => p.Id == actor.PublisherId); + + if (publisher == null) + return false; + + var actorUrl = actor.Uri; + + var actorObject = new Dictionary + { + ["id"] = actorUrl, + ["type"] = actor.Type, + ["name"] = publisher.Nick, + ["preferredUsername"] = publisher.Name, + ["summary"] = publisher.Bio ?? "", + ["published"] = publisher.CreatedAt.ToDateTimeOffset(), + ["updated"] = publisher.UpdatedAt.ToDateTimeOffset(), + ["inbox"] = actor.InboxUri, + ["outbox"] = actor.OutboxUri, + ["followers"] = actor.FollowersUri, + ["following"] = actor.FollowingUri, + ["publicKey"] = new Dictionary + { + ["id"] = actor.PublicKeyId, + ["owner"] = actorUrl, + ["publicKeyPem"] = actor.PublicKey + } + }; + + if (publisher.Picture != null) + { + actorObject["icon"] = new Dictionary + { + ["type"] = "Image", + ["mediaType"] = publisher.Picture.MimeType, + ["url"] = $"{AssetsBaseUrl}/{publisher.Picture.Id}" + }; + } + + if (publisher.Background != null) + { + actorObject["image"] = new Dictionary + { + ["type"] = "Image", + ["mediaType"] = publisher.Background.MimeType, + ["url"] = $"{AssetsBaseUrl}/{publisher.Background.Id}" + }; + } + + var activity = new Dictionary + { + ["@context"] = new List + { + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1" + }, + ["id"] = $"{actorUrl}#update-{Guid.NewGuid()}", + ["type"] = "Update", + ["actor"] = actorUrl, + ["published"] = DateTimeOffset.UtcNow, + ["to"] = new[] { "https://www.w3.org/ns/activitystreams#Public" }, + ["cc"] = new[] { $"{actorUrl}/followers" }, + ["object"] = actorObject + }; + + var followers = await db.FediverseRelationships + .Include(r => r.TargetActor) + .Where(r => r.ActorId == actor.Id && r.IsFollowedBy) + .Select(r => r.TargetActor) + .ToListAsync(); + + 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 Update actor activity to {Count}/{Total} followers", + successCount, followers.Count); + + return successCount > 0; + } + public async Task SendLikeActivityAsync( Guid postId, Guid accountId, @@ -318,6 +505,13 @@ public class ActivityPubDeliveryService( .ToListAsync(); } + public async Task GetLocalActorAsync(Guid publisherId) + { + return await db.FediverseActors + .Include(a => a.Instance) + .FirstOrDefaultAsync(a => a.PublisherId == publisherId); + } + public async Task GetOrCreateLocalActorAsync(SnPublisher publisher) { var actorUrl = $"https://{Domain}/activitypub/actors/{publisher.Name}"; diff --git a/DysonNetwork.Sphere/Post/PostService.cs b/DysonNetwork.Sphere/Post/PostService.cs index 0fcf808..829a0ec 100644 --- a/DysonNetwork.Sphere/Post/PostService.cs +++ b/DysonNetwork.Sphere/Post/PostService.cs @@ -6,6 +6,7 @@ using DysonNetwork.Shared.Registry; using DysonNetwork.Sphere.WebReader; using DysonNetwork.Sphere.Localization; using DysonNetwork.Sphere.Publisher; +using DysonNetwork.Sphere.ActivityPub; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Localization; using NodaTime; @@ -207,6 +208,25 @@ public partial class PostService( // Process link preview in the background to avoid delaying post creation _ = Task.Run(async () => await CreateLinkPreviewAsync(post)); + // Send ActivityPub Create activity in background for public posts + if (post.PublishedAt is not null && post.PublishedAt.Value.ToDateTimeUtc() <= DateTime.UtcNow && + post.Visibility == Shared.Models.PostVisibility.Public) + { + _ = Task.Run(async () => + { + try + { + using var scope = factory.CreateScope(); + var deliveryService = scope.ServiceProvider.GetRequiredService(); + await deliveryService.SendCreateActivityAsync(post); + } + catch (Exception err) + { + logger.LogError($"Error when sending ActivityPub Create activity: {err.Message}"); + } + }); + } + return post; } @@ -284,6 +304,24 @@ public partial class PostService( // Process link preview in the background to avoid delaying post update _ = Task.Run(async () => await CreateLinkPreviewAsync(post)); + // Send ActivityPub Update activity in background for public posts + if (post.Visibility == Shared.Models.PostVisibility.Public) + { + _ = Task.Run(async () => + { + try + { + using var scope = factory.CreateScope(); + var deliveryService = scope.ServiceProvider.GetRequiredService(); + await deliveryService.SendUpdateActivityAsync(post); + } + catch (Exception err) + { + logger.LogError($"Error when sending ActivityPub Update activity: {err.Message}"); + } + }); + } + return post; } @@ -414,6 +452,24 @@ public partial class PostService( await transaction.RollbackAsync(); throw; } + + // Send ActivityPub Delete activity in background for public posts + if (post.Visibility == Shared.Models.PostVisibility.Public) + { + _ = Task.Run(async () => + { + try + { + using var scope = factory.CreateScope(); + var deliveryService = scope.ServiceProvider.GetRequiredService(); + await deliveryService.SendDeleteActivityAsync(post); + } + catch (Exception err) + { + logger.LogError($"Error when sending ActivityPub Delete activity: {err.Message}"); + } + }); + } } public async Task PinPostAsync(SnPost post, Account currentUser, Shared.Models.PostPinMode pinMode) diff --git a/DysonNetwork.Sphere/Publisher/PublisherController.cs b/DysonNetwork.Sphere/Publisher/PublisherController.cs index d303896..dac5db2 100644 --- a/DysonNetwork.Sphere/Publisher/PublisherController.cs +++ b/DysonNetwork.Sphere/Publisher/PublisherController.cs @@ -3,6 +3,7 @@ using DysonNetwork.Shared.Auth; using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Registry; +using DysonNetwork.Sphere.ActivityPub; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -20,7 +21,8 @@ public class PublisherController( FileService.FileServiceClient files, FileReferenceService.FileReferenceServiceClient fileRefs, ActionLogService.ActionLogServiceClient als, - RemoteRealmService remoteRealmService + RemoteRealmService remoteRealmService, + IServiceScopeFactory factory ) : ControllerBase { [HttpGet("{name}")] @@ -668,6 +670,27 @@ public class PublisherController( } ); + // Send ActivityPub Update activity if actor exists + var actor = await db.FediverseActors + .FirstOrDefaultAsync(a => a.PublisherId == publisher.Id); + + if (actor != null) + { + _ = Task.Run(async () => + { + try + { + using var scope = factory.CreateScope(); + var deliveryService = scope.ServiceProvider.GetRequiredService(); + await deliveryService.SendUpdateActorActivityAsync(actor); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error sending ActivityPub Update actor activity for publisher {publisher.Id}: {ex.Message}"); + } + }); + } + return Ok(publisher); } @@ -886,5 +909,87 @@ public class PublisherController( await ps.SettlePublisherRewards(); return Ok(); } + + [HttpGet("{name}/fediverse")] + [Authorize] + public async Task> GetFediverseStatus(string name) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return Unauthorized(); + + var publisher = await db.Publishers.Where(p => p.Name == name).FirstOrDefaultAsync(); + if (publisher is null) + return NotFound(); + + var status = await ps.GetFediverseStatusAsync(publisher.Id, Guid.Parse(currentUser.Id)); + return Ok(status); + } + + [HttpPost("{name}/fediverse")] + [Authorize] + public async Task> EnableFediverse(string name) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return Unauthorized(); + + var accountId = Guid.Parse(currentUser.Id); + + var publisher = await db.Publishers.Where(p => p.Name == name).FirstOrDefaultAsync(); + if (publisher is null) + return NotFound(); + + var member = await db + .PublisherMembers.Where(m => m.AccountId == accountId) + .Where(m => m.PublisherId == publisher.Id) + .FirstOrDefaultAsync(); + if (member is null) + return StatusCode(403, "You are not even a member of targeted publisher."); + if (member.Role < Shared.Models.PublisherMemberRole.Manager) + return StatusCode(403, "You need at least be manager to enable fediverse for this publisher."); + + try + { + var actor = await ps.EnableFediverseAsync(publisher.Id, accountId); + var status = await ps.GetFediverseStatusAsync(publisher.Id); + return Ok(status); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + [HttpDelete("{name}/fediverse")] + [Authorize] + public async Task DisableFediverse(string name) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return Unauthorized(); + + var accountId = Guid.Parse(currentUser.Id); + + var publisher = await db.Publishers.Where(p => p.Name == name).FirstOrDefaultAsync(); + if (publisher is null) + return NotFound(); + + var member = await db + .PublisherMembers.Where(m => m.AccountId == accountId) + .Where(m => m.PublisherId == publisher.Id) + .FirstOrDefaultAsync(); + if (member is null) + return StatusCode(403, "You are not even a member of targeted publisher."); + if (member.Role < Shared.Models.PublisherMemberRole.Manager) + return StatusCode(403, "You need at least be manager to disable fediverse for this publisher."); + + try + { + await ps.DisableFediverseAsync(publisher.Id, accountId); + return NoContent(); + } + catch (UnauthorizedAccessException ex) + { + return StatusCode(403, ex.Message); + } + } } diff --git a/DysonNetwork.Sphere/Publisher/PublisherService.cs b/DysonNetwork.Sphere/Publisher/PublisherService.cs index 618812a..d9766bb 100644 --- a/DysonNetwork.Sphere/Publisher/PublisherService.cs +++ b/DysonNetwork.Sphere/Publisher/PublisherService.cs @@ -3,6 +3,7 @@ using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Registry; +using DysonNetwork.Sphere.ActivityPub; using Microsoft.EntityFrameworkCore; using NodaTime; using NodaTime.Serialization.Protobuf; @@ -11,13 +12,23 @@ using PublisherType = DysonNetwork.Shared.Models.PublisherType; namespace DysonNetwork.Sphere.Publisher; +public class FediverseStatus +{ + public bool Enabled { get; set; } + public SnFediverseActor? Actor { get; set; } + public int FollowerCount { get; set; } + public string? ActorUri { get; set; } +} + public class PublisherService( AppDatabase db, FileReferenceService.FileReferenceServiceClient fileRefs, SocialCreditService.SocialCreditServiceClient socialCredits, ExperienceService.ExperienceServiceClient experiences, ICacheService cache, - RemoteAccountService remoteAccounts + RemoteAccountService remoteAccounts, + ActivityPubKeyService keyService, + IConfiguration configuration ) { public async Task GetPublisherLoaded(Guid id) @@ -668,4 +679,142 @@ public class PublisherService( } } } + + private string Domain => configuration["ActivityPub:Domain"] ?? "localhost"; + + public async Task EnableFediverseAsync(Guid publisherId, Guid requesterAccountId) + { + var member = await db.PublisherMembers + .Where(m => m.PublisherId == publisherId && m.AccountId == requesterAccountId) + .FirstOrDefaultAsync(); + + if (member == null || member.Role < PublisherMemberRole.Manager) + throw new UnauthorizedAccessException("You need at least Manager role to enable fediverse for this publisher"); + + var existingActor = await db.FediverseActors + .FirstOrDefaultAsync(a => a.PublisherId == publisherId); + + if (existingActor != null) + throw new InvalidOperationException("Fediverse actor already exists for this publisher"); + + var publisher = await db.Publishers + .Include(p => p.Picture) + .Include(p => p.Background) + .FirstOrDefaultAsync(p => p.Id == publisherId); + + if (publisher == null) + throw new InvalidOperationException("Publisher not found"); + + 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 (privateKey, publicKey) = keyService.GenerateKeyPair(); + publisher.PrivateKeyPem = privateKey; + publisher.PublicKeyPem = publicKey; + + var assetsBaseUrl = configuration["ActivityPub:FileBaseUrl"] ?? $"https://{Domain}/files"; + + var actorUrl = $"https://{Domain}/activitypub/actors/{publisher.Name}"; + + var actor = new SnFediverseActor + { + Uri = actorUrl, + Username = publisher.Name, + DisplayName = publisher.Nick, + Bio = publisher.Bio, + Type = "Person", + InboxUri = $"{actorUrl}/inbox", + OutboxUri = $"{actorUrl}/outbox", + FollowersUri = $"{actorUrl}/followers", + FollowingUri = $"{actorUrl}/following", + PublicKeyId = $"{actorUrl}#main-key", + PublicKey = publicKey, + AvatarUrl = publisher.Picture != null ? $"{assetsBaseUrl}/{publisher.Picture.Id}" : null, + HeaderUrl = publisher.Background != null ? $"{assetsBaseUrl}/{publisher.Background.Id}" : null, + InstanceId = instance.Id, + PublisherId = publisher.Id + }; + + db.FediverseActors.Add(actor); + db.Update(publisher); + await db.SaveChangesAsync(); + + return actor; + } + + public async Task DisableFediverseAsync(Guid publisherId, Guid requesterAccountId) + { + var member = await db.PublisherMembers + .Where(m => m.PublisherId == publisherId && m.AccountId == requesterAccountId) + .FirstOrDefaultAsync(); + + if (member == null || member.Role < PublisherMemberRole.Manager) + throw new UnauthorizedAccessException("You need at least Manager role to disable fediverse for this publisher"); + + var actor = await db.FediverseActors + .FirstOrDefaultAsync(a => a.PublisherId == publisherId); + + if (actor == null) + return true; + + var publisher = await db.Publishers + .FirstOrDefaultAsync(p => p.Id == publisherId); + + if (publisher != null) + { + publisher.PrivateKeyPem = null; + publisher.PublicKeyPem = null; + db.Update(publisher); + } + + db.FediverseActors.Remove(actor); + await db.SaveChangesAsync(); + + return true; + } + + public async Task GetFediverseStatusAsync(Guid publisherId, Guid? requesterAccountId = null) + { + var actor = await db.FediverseActors + .Include(a => a.Instance) + .FirstOrDefaultAsync(a => a.PublisherId == publisherId); + + var followerCount = await db.FediverseRelationships + .Where(r => r.Actor.PublisherId == publisherId && r.IsFollowedBy) + .CountAsync(); + + var publisher = await db.Publishers + .FirstOrDefaultAsync(p => p.Id == publisherId); + + if (publisher == null) + return null; + + var assetsBaseUrl = configuration["ActivityPub:FileBaseUrl"] ?? $"https://{Domain}/files"; + + return new FediverseStatus + { + Enabled = actor != null, + Actor = actor, + FollowerCount = followerCount, + ActorUri = actor != null ? actor.Uri : null + }; + } + + public async Task GetLocalActorAsync(Guid publisherId) + { + return await db.FediverseActors + .Include(a => a.Instance) + .FirstOrDefaultAsync(a => a.PublisherId == publisherId); + } } \ No newline at end of file