diff --git a/Iceshrimp.Backend/Controllers/Renderers/ActivityPub/ActivityRenderer.cs b/Iceshrimp.Backend/Controllers/Renderers/ActivityPub/ActivityRenderer.cs new file mode 100644 index 00000000..c5c0e268 --- /dev/null +++ b/Iceshrimp.Backend/Controllers/Renderers/ActivityPub/ActivityRenderer.cs @@ -0,0 +1,29 @@ +using Iceshrimp.Backend.Core.Configuration; +using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types; +using Microsoft.Extensions.Options; + +namespace Iceshrimp.Backend.Controllers.Renderers.ActivityPub; + +public class ActivityRenderer(IOptions config) { + public static ASActivity RenderCreate(ASObject obj, ASObject actor) { + return new ASActivity { + Id = $"{obj.Id}#Create", + Type = "https://www.w3.org/ns/activitystreams#Create", + Actor = new ASActor { Id = actor.Id }, + Object = obj + }; + } + + public ASActivity RenderAccept(ASObject followeeActor, string requestId) { + return new ASActivity { + Id = $"https://{config.Value.WebDomain}/activities/{new Guid().ToString().ToLowerInvariant()}", + Type = "https://www.w3.org/ns/activitystreams#Accept", + Actor = new ASActor { + Id = followeeActor.Id + }, + Object = new ASObject { + Id = requestId + } + }; + } +} \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs b/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs index 54dc1f36..8bd725d6 100644 --- a/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs +++ b/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs @@ -14,19 +14,21 @@ public static class ServiceExtensions { //services.AddTransient(); // Scoped = instantiated per request - services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); // Singleton = instantiated once across application lifetime services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityDeliverService.cs b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityDeliverService.cs new file mode 100644 index 00000000..faeb71fa --- /dev/null +++ b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityDeliverService.cs @@ -0,0 +1,38 @@ +using Iceshrimp.Backend.Core.Database; +using Iceshrimp.Backend.Core.Database.Tables; +using Iceshrimp.Backend.Core.Federation.ActivityStreams; +using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types; +using Iceshrimp.Backend.Core.Queues; +using Iceshrimp.Backend.Core.Services; +using Microsoft.EntityFrameworkCore; + +namespace Iceshrimp.Backend.Core.Federation.ActivityPub; + +public class ActivityDeliverService( + ILogger logger, + DatabaseContext db, + HttpRequestService httpRqSvc, + QueueService queueService +) { + public async Task DeliverToFollowers(ASActivity activity, User actor) { + logger.LogDebug("Delivering activity {id} to followers", activity.Id); + if (activity.Actor == null) throw new Exception("Actor must not be null"); + + var inboxUrls = await db.Followings.Where(p => p.Followee == actor) + .Select(p => p.FollowerSharedInbox ?? p.FollowerInbox) + .Where(p => p != null) + .Select(p => p!) + .Distinct() + .ToListAsync(); + + if (inboxUrls.Count == 0) return; + + var keypair = await db.UserKeypairs.FirstAsync(p => p.User == actor); + var payload = await activity.SignAndCompact(keypair); + + foreach (var inboxUrl in inboxUrls) { + var request = await httpRqSvc.PostSigned(inboxUrl, payload, "application/activity+json", actor, keypair); + queueService.DeliverQueue.Enqueue(new DeliverJob(request)); + } + } +} \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Federation/ActivityPub/APFetchService.cs b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityFetcherService.cs similarity index 95% rename from Iceshrimp.Backend/Core/Federation/ActivityPub/APFetchService.cs rename to Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityFetcherService.cs index 23cd8db8..c9ba9238 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityPub/APFetchService.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityFetcherService.cs @@ -11,7 +11,7 @@ namespace Iceshrimp.Backend.Core.Federation.ActivityPub; //TODO: required attribute doesn't work with Newtonsoft.Json it appears //TODO: enforce @type values -public class APFetchService(HttpClient client, HttpRequestService httpRqSvc) { +public class ActivityFetcherService(HttpClient client, HttpRequestService httpRqSvc) { private static readonly JsonSerializerSettings JsonSerializerSettings = new(); //FIXME: not doing this breaks ld signatures, but doing this breaks mapping the object to datetime properties diff --git a/Iceshrimp.Backend/Core/Federation/ActivityPub/APService.cs b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityHandlerService.cs similarity index 66% rename from Iceshrimp.Backend/Core/Federation/ActivityPub/APService.cs rename to Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityHandlerService.cs index f67732b3..4b1d8339 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityPub/APService.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityHandlerService.cs @@ -1,4 +1,4 @@ -using Iceshrimp.Backend.Core.Configuration; +using Iceshrimp.Backend.Controllers.Renderers.ActivityPub; using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Federation.ActivityStreams; @@ -8,18 +8,17 @@ using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Queues; using Iceshrimp.Backend.Core.Services; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; namespace Iceshrimp.Backend.Core.Federation.ActivityPub; -public class APService( - IOptions config, - ILogger logger, +public class ActivityHandlerService( + ILogger logger, NoteService noteSvc, UserResolver userResolver, DatabaseContext db, HttpRequestService httpRqSvc, - QueueService queueService + QueueService queueService, + ActivityRenderer activityRenderer ) { public Task PerformActivity(ASActivity activity, string? inboxUserId) { logger.LogDebug("Processing activity: {activity}", activity.Id); @@ -29,7 +28,7 @@ public class APService( switch (activity.Type) { case "https://www.w3.org/ns/activitystreams#Create": { - if (activity.Object is ASNote note) return noteSvc.CreateNote(note, activity.Actor); + if (activity.Object is ASNote note) return noteSvc.ProcessNote(note, activity.Actor); throw new NotImplementedException(); } case "https://www.w3.org/ns/activitystreams#Like": { @@ -49,28 +48,6 @@ public class APService( } } - public async Task DeliverToFollowers(ASActivity activity, User actor) { - logger.LogDebug("Delivering activity {id} to followers", activity.Id); - if (activity.Actor == null) throw new Exception("Actor must not be null"); - - var inboxUrls = await db.Followings.Where(p => p.Followee == actor) - .Select(p => p.FollowerSharedInbox ?? p.FollowerInbox) - .Where(p => p != null) - .Select(p => p!) - .Distinct() - .ToListAsync(); - - if (inboxUrls.Count == 0) return; - - var keypair = await db.UserKeypairs.FirstAsync(p => p.User == actor); - var payload = await activity.SignAndCompact(keypair); - - foreach (var inboxUrl in inboxUrls) { - var request = await httpRqSvc.PostSigned(inboxUrl, payload, "application/activity+json", actor, keypair); - queueService.DeliverQueue.Enqueue(new DeliverJob(request)); - } - } - private async Task Follow(ASObject followeeActor, ASObject followerActor, string requestId) { var follower = await userResolver.Resolve(followerActor.Id); var followee = await userResolver.Resolve(followeeActor.Id); @@ -79,19 +56,9 @@ public class APService( //TODO: handle follow requests - var acceptActivity = new ASActivity { - Id = $"https://{config.Value.WebDomain}/activities/{new Guid().ToString().ToLowerInvariant()}", - Type = "https://www.w3.org/ns/activitystreams#Accept", - Actor = new ASActor { - Id = followeeActor.Id - }, - Object = new ASObject { - Id = requestId - } - }; - - var keypair = await db.UserKeypairs.FirstAsync(p => p.User == followee); - var payload = await acceptActivity.SignAndCompact(keypair); + var acceptActivity = activityRenderer.RenderAccept(followeeActor, requestId); + var keypair = await db.UserKeypairs.FirstAsync(p => p.User == followee); + var payload = await acceptActivity.SignAndCompact(keypair); var inboxUri = follower.SharedInbox ?? follower.Inbox ?? throw new Exception("Can't accept follow: user has no inbox"); var request = await httpRqSvc.PostSigned(inboxUri, payload, "application/activity+json", followee, keypair); diff --git a/Iceshrimp.Backend/Core/Queues/InboxQueue.cs b/Iceshrimp.Backend/Core/Queues/InboxQueue.cs index f84adb24..3340eb6f 100644 --- a/Iceshrimp.Backend/Core/Queues/InboxQueue.cs +++ b/Iceshrimp.Backend/Core/Queues/InboxQueue.cs @@ -18,10 +18,10 @@ public class InboxQueue { if (obj == null) throw new Exception("Failed to deserialize ASObject"); if (obj is not ASActivity activity) throw new NotImplementedException("Job data is not an ASActivity"); - var apSvc = scope.GetRequiredService(); - var logger = scope.GetRequiredService>(); + var apHandler = scope.GetRequiredService(); + var logger = scope.GetRequiredService>(); logger.LogTrace("Preparation took {ms} ms", job.Duration); - await apSvc.PerformActivity(activity, job.InboxUserId); + await apHandler.PerformActivity(activity, job.InboxUserId); } } diff --git a/Iceshrimp.Backend/Core/Services/NoteService.cs b/Iceshrimp.Backend/Core/Services/NoteService.cs index 056b3548..faf6896d 100644 --- a/Iceshrimp.Backend/Core/Services/NoteService.cs +++ b/Iceshrimp.Backend/Core/Services/NoteService.cs @@ -1,3 +1,4 @@ +using Iceshrimp.Backend.Controllers.Renderers.ActivityPub; using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database.Tables; @@ -16,12 +17,41 @@ public class NoteService( UserResolver userResolver, IOptions config, UserService userSvc, - APFetchService fetchSvc + ActivityFetcherService fetchSvc, + ActivityDeliverService deliverSvc, + NoteRenderer noteRenderer, + UserRenderer userRenderer ) { private readonly List _resolverHistory = []; private int _recursionLimit = 100; - public async Task CreateNote(ASNote note, ASActor actor) { + public async Task CreateNote(User user, Note.NoteVisibility visibility, string? text = null, + string? cw = null, Note? reply = null, Note? renote = null) { + var actor = await userRenderer.Render(user); + + var note = new Note { + Id = IdHelpers.GenerateSlowflakeId(), + Text = text, + Cw = cw, + Reply = reply, + Renote = renote, + UserId = user.Id, + CreatedAt = DateTime.UtcNow, + UserHost = null, + Visibility = visibility + }; + await db.AddAsync(note); + await db.SaveChangesAsync(); + + var obj = noteRenderer.Render(note); + var activity = ActivityRenderer.RenderCreate(obj, actor); + + await deliverSvc.DeliverToFollowers(activity, user); + + return note; + } + + public async Task ProcessNote(ASNote note, ASActor actor) { if (await db.Notes.AnyAsync(p => p.Uri == note.Id)) { logger.LogDebug("Note '{id}' already exists, skipping", note.Id); return null; @@ -100,7 +130,7 @@ public class NoteService( var actor = await fetchSvc.FetchActor(attrTo.Id, instanceActor, instanceActorKeypair); try { - return await CreateNote(fetchedNote, actor); + return await ProcessNote(fetchedNote, actor); } catch (Exception e) { logger.LogDebug("Failed to create resolved note: {error}", e.Message); diff --git a/Iceshrimp.Backend/Core/Services/UserService.cs b/Iceshrimp.Backend/Core/Services/UserService.cs index 0d8e02cd..61ddb669 100644 --- a/Iceshrimp.Backend/Core/Services/UserService.cs +++ b/Iceshrimp.Backend/Core/Services/UserService.cs @@ -16,7 +16,7 @@ public class UserService( IOptions config, ILogger logger, DatabaseContext db, - APFetchService fetchSvc) { + ActivityFetcherService fetchSvc) { private (string Username, string? Host) AcctToTuple(string acct) { if (!acct.StartsWith("acct:")) throw new GracefulException(HttpStatusCode.BadRequest, "Invalid query"); diff --git a/Iceshrimp.NET.sln.DotSettings b/Iceshrimp.NET.sln.DotSettings index f7d27287..82533647 100644 --- a/Iceshrimp.NET.sln.DotSettings +++ b/Iceshrimp.NET.sln.DotSettings @@ -38,4 +38,5 @@ True True True + True True \ No newline at end of file