diff --git a/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs b/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs index ce4cab83..b2a25870 100644 --- a/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs +++ b/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs @@ -21,33 +21,35 @@ 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(); - - //TODO: make this prettier - services.AddScoped(); - services.AddScoped(); + services + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped(); // Singleton = instantiated once across application lifetime - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + services + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton(); // Hosted services = long running background tasks // Note: These need to be added as a singleton as well to ensure data consistency diff --git a/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityFetcherService.cs b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityFetcherService.cs index 8810cda3..f26ff777 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityFetcherService.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityFetcherService.cs @@ -8,15 +8,16 @@ using Newtonsoft.Json.Linq; namespace Iceshrimp.Backend.Core.Federation.ActivityPub; -//TODO: required attribute doesn't work with Newtonsoft.Json it appears -//TODO: enforce @type values - -public class ActivityFetcherService(HttpClient client, HttpRequestService httpRqSvc) { - private static readonly JsonSerializerSettings JsonSerializerSettings = - new(); +public class ActivityFetcherService(HttpClient client, HttpRequestService httpRqSvc, SystemUserService systemUserSvc) { + private static readonly JsonSerializerSettings JsonSerializerSettings = new(); //FIXME: not doing this breaks ld signatures, but doing this breaks mapping the object to datetime properties //new() { DateParseHandling = DateParseHandling.None }; + public async Task> FetchActivityAsync(string url) { + var (actor, keypair) = await systemUserSvc.GetInstanceActorWithKeypairAsync(); + return await FetchActivityAsync(url, actor, keypair); + } + public async Task> FetchActivityAsync(string url, User actor, UserKeypair keypair) { var request = httpRqSvc.GetSigned(url, ["application/activity+json"], actor, keypair); var response = await client.SendAsync(request); @@ -42,8 +43,21 @@ public class ActivityFetcherService(HttpClient client, HttpRequestService httpRq throw new GracefulException("Failed to fetch actor"); } + public async Task FetchActorAsync(string uri) { + var (actor, keypair) = await systemUserSvc.GetInstanceActorWithKeypairAsync(); + var activity = await FetchActivityAsync(uri, actor, keypair); + return activity.OfType().FirstOrDefault() ?? + throw new GracefulException("Failed to fetch actor"); + } + public async Task FetchNoteAsync(string uri, User actor, UserKeypair keypair) { var activity = await FetchActivityAsync(uri, actor, keypair); return activity.OfType().FirstOrDefault(); } + + public async Task FetchNoteAsync(string uri) { + var (actor, keypair) = await systemUserSvc.GetInstanceActorWithKeypairAsync(); + var activity = await FetchActivityAsync(uri, actor, keypair); + return activity.OfType().FirstOrDefault(); + } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityHandlerService.cs b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityHandlerService.cs index 59f1f8fb..167d4327 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityHandlerService.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityHandlerService.cs @@ -20,7 +20,8 @@ public class ActivityHandlerService( QueueService queueService, ActivityRenderer activityRenderer, IOptions config, - FederationControlService federationCtrl + FederationControlService federationCtrl, + ObjectResolver resolver ) { public async Task PerformActivityAsync(ASActivity activity, string? inboxUserId) { logger.LogDebug("Processing activity: {activity}", activity.Id); @@ -28,6 +29,11 @@ public class ActivityHandlerService( throw GracefulException.UnprocessableEntity("Cannot perform activity as actor 'null'"); if (await federationCtrl.ShouldBlockAsync(activity.Actor.Id)) throw GracefulException.UnprocessableEntity("Instance is blocked"); + if (activity.Object == null) + throw GracefulException.UnprocessableEntity("Activity object is null"); + if (activity.Object.IsUnresolved) + activity.Object = await resolver.ResolveObject(activity.Object) ?? + throw GracefulException.UnprocessableEntity("Failed to resolve activity object"); //TODO: validate inboxUserId @@ -40,11 +46,22 @@ public class ActivityHandlerService( return; } case ASDelete: { - //TODO: handle user deletes - if (activity.Object is not ASNote note) + if (activity.Object is not ASTombstone tombstone) throw GracefulException.UnprocessableEntity("Delete activity object is invalid"); - await noteSvc.DeleteNoteAsync(note, activity.Actor); - return; + if (await db.Notes.AnyAsync(p => p.Uri == tombstone.Id)) { + await noteSvc.DeleteNoteAsync(tombstone, activity.Actor); + return; + } + + if (await db.Users.AnyAsync(p => p.Uri == tombstone.Id)) { + if (tombstone.Id != activity.Actor.Id) + throw GracefulException.UnprocessableEntity("Refusing to delete user: actor doesn't match"); + + //TODO: handle user deletes + throw new NotImplementedException("User deletes aren't supported yet"); + } + + throw GracefulException.UnprocessableEntity("Delete activity object is unknown or invalid"); } case ASFollow: { if (activity.Object is not { } obj) @@ -73,7 +90,7 @@ public class ActivityHandlerService( case ASUndo: { //TODO: what other types of undo objects are there? if (activity.Object is not ASActivity { Type: ASActivity.Types.Follow, Object: not null } undoActivity) - throw new NotImplementedException("Unsupported undo operation"); + throw new NotImplementedException("Undo activity object is invalid"); await UnfollowAsync(undoActivity.Object, activity.Actor); return; } diff --git a/Iceshrimp.Backend/Core/Federation/ActivityPub/ObjectResolver.cs b/Iceshrimp.Backend/Core/Federation/ActivityPub/ObjectResolver.cs new file mode 100644 index 00000000..88d16b79 --- /dev/null +++ b/Iceshrimp.Backend/Core/Federation/ActivityPub/ObjectResolver.cs @@ -0,0 +1,39 @@ +using Iceshrimp.Backend.Core.Database; +using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types; +using Microsoft.EntityFrameworkCore; + +namespace Iceshrimp.Backend.Core.Federation.ActivityPub; + +public class ObjectResolver( + ILogger logger, + ActivityFetcherService fetchSvc, + DatabaseContext db, + FederationControlService federationCtrl +) { + public async Task ResolveObject(ASIdObject idObj) { + if (idObj is ASObject obj) return obj; + if (idObj.Id == null) { + logger.LogDebug("Refusing to resolve object with null id property"); + return null; + } + + if (await federationCtrl.ShouldBlockAsync(idObj.Id)) { + logger.LogDebug("Instance is blocked"); + return null; + } + + if (await db.Notes.AnyAsync(p => p.Uri == idObj.Id)) + return new ASNote { Id = idObj.Id }; + if (await db.Users.AnyAsync(p => p.Uri == idObj.Id)) + return new ASActor { Id = idObj.Id }; + + try { + var result = await fetchSvc.FetchActivityAsync(idObj.Id); + return result.FirstOrDefault(); + } + catch (Exception e) { + logger.LogDebug("Failed to resolve object {id}: {error}", idObj.Id, e); + return null; + } + } +} \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASActivity.cs b/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASActivity.cs index 120b8d8a..f2eedf18 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASActivity.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASActivity.cs @@ -13,7 +13,7 @@ public class ASActivity : ASObject { [JC(typeof(ASObjectConverter))] public ASObject? Object { get; set; } - public static class Types { + public new static class Types { private const string Ns = Constants.ActivityStreamsNs; public const string Create = $"{Ns}#Create"; diff --git a/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASActor.cs b/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASActor.cs index 9a0e4596..d62c4196 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASActor.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASActor.cs @@ -134,7 +134,7 @@ public class ASActor : ASObject { }; } - public static class Types { + public new static class Types { private const string Ns = Constants.ActivityStreamsNs; public const string Application = $"{Ns}#Application"; diff --git a/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASIdObject.cs b/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASIdObject.cs index 9991e199..41423338 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASIdObject.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASIdObject.cs @@ -2,9 +2,8 @@ using J = Newtonsoft.Json.JsonPropertyAttribute; namespace Iceshrimp.Backend.Core.Federation.ActivityStreams.Types; -//TODO: handle object | link correctly (we don't need to resolve it if it's already the full object) public class ASIdObject() { - public ASIdObject(string id) : this() { + public ASIdObject(string? id) : this() { Id = id; } diff --git a/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASNote.cs b/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASNote.cs index a56d91bf..f3c178b8 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASNote.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASNote.cs @@ -55,7 +55,7 @@ public class ASNote : ASObject { return Note.NoteVisibility.Specified; } - public static class Types { + public new static class Types { private const string Ns = Constants.ActivityStreamsNs; public const string Note = $"{Ns}#Note"; diff --git a/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASObject.cs b/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASObject.cs index 399c82ed..008e40e4 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASObject.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASObject.cs @@ -1,3 +1,4 @@ +using Iceshrimp.Backend.Core.Configuration; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using J = Newtonsoft.Json.JsonPropertyAttribute; @@ -8,11 +9,13 @@ namespace Iceshrimp.Backend.Core.Federation.ActivityStreams.Types; public class ASObject : ASIdObject { [J("@id")] [JR] public new required string Id { get; set; } - + [J("@type")] [JC(typeof(LDTypeConverter))] public string? Type { get; set; } + public bool IsUnresolved => GetType() == typeof(ASObject) && Type == null; + //FIXME: don't recurse creates and co public static ASObject? Deserialize(JToken token) { return token.Type switch { @@ -23,6 +26,7 @@ public class ASObject : ASIdObject { ASActor.Types.Organization => token.ToObject(), ASActor.Types.Application => token.ToObject(), ASNote.Types.Note => token.ToObject(), + Types.Tombstone => token.ToObject(), ASActivity.Types.Create => token.ToObject(), ASActivity.Types.Delete => token.ToObject(), ASActivity.Types.Follow => token.ToObject(), @@ -33,13 +37,24 @@ public class ASObject : ASIdObject { ASActivity.Types.Like => token.ToObject(), _ => token.ToObject() }, - JTokenType.Array => Deserialize(token.First()), - JTokenType.String => new ASObject { Id = token.Value() ?? "" }, - _ => throw new ArgumentOutOfRangeException() + JTokenType.Array => Deserialize(token.First()), + JTokenType.String => new ASObject { + Id = token.Value() ?? + throw new Exception("Encountered JTokenType.String with Value null") + }, + _ => throw new Exception($"Encountered JTokenType {token.Type}, which is not valid at this point") }; } + + public static class Types { + private const string Ns = Constants.ActivityStreamsNs; + + public const string Tombstone = $"{Ns}#Tombstone"; + } } +public class ASTombstone : ASObject; + public sealed class LDTypeConverter : ASSerializer.ListSingleObjectConverter; internal sealed class ASObjectConverter : JsonConverter { diff --git a/Iceshrimp.Backend/Core/Middleware/AuthorizedFetchMiddleware.cs b/Iceshrimp.Backend/Core/Middleware/AuthorizedFetchMiddleware.cs index da05ca8c..31a30ec1 100644 --- a/Iceshrimp.Backend/Core/Middleware/AuthorizedFetchMiddleware.cs +++ b/Iceshrimp.Backend/Core/Middleware/AuthorizedFetchMiddleware.cs @@ -14,7 +14,7 @@ public class AuthorizedFetchMiddleware( IOptionsSnapshot config, DatabaseContext db, ActivityPub.UserResolver userResolver, - UserService userSvc, + SystemUserService systemUserSvc, ActivityPub.FederationControlService fedCtrlSvc, ILogger logger) : IMiddleware { public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) { @@ -24,7 +24,7 @@ public class AuthorizedFetchMiddleware( var request = ctx.Request; //TODO: cache this somewhere - var instanceActorUri = $"/users/{(await userSvc.GetInstanceActorAsync()).Id}"; + var instanceActorUri = $"/users/{(await systemUserSvc.GetInstanceActorAsync()).Id}"; if (ctx.Request.Path.Value == instanceActorUri) { await next(ctx); return; diff --git a/Iceshrimp.Backend/Core/Services/NoteService.cs b/Iceshrimp.Backend/Core/Services/NoteService.cs index f2c233a3..d056c19e 100644 --- a/Iceshrimp.Backend/Core/Services/NoteService.cs +++ b/Iceshrimp.Backend/Core/Services/NoteService.cs @@ -60,7 +60,7 @@ public class NoteService( return note; } - public async Task DeleteNoteAsync(ASNote note, ASActor actor) { + public async Task DeleteNoteAsync(ASTombstone note, ASActor actor) { // ReSharper disable once EntityFramework.NPlusOne.IncompleteDataQuery (it doesn't know about IncludeCommonProperties()) var dbNote = await db.Notes.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Uri == note.Id); if (dbNote == null) { @@ -157,9 +157,7 @@ public class NoteService( if (note != null) return note; //TODO: should we fall back to a regular user's keypair if fetching with instance actor fails & a local user is following the actor? - var instanceActor = await userSvc.GetInstanceActorAsync(); - var instanceActorKeypair = await db.UserKeypairs.FirstAsync(p => p.User == instanceActor); - var fetchedNote = await fetchSvc.FetchNoteAsync(uri, instanceActor, instanceActorKeypair); + var fetchedNote = await fetchSvc.FetchNoteAsync(uri); if (fetchedNote?.AttributedTo is not [{ Id: not null } attrTo]) { logger.LogDebug("Invalid Note.AttributedTo, skipping"); return null; @@ -171,7 +169,7 @@ public class NoteService( } //TODO: we don't need to fetch the actor every time, we can use userResolver here - var actor = await fetchSvc.FetchActorAsync(attrTo.Id, instanceActor, instanceActorKeypair); + var actor = await fetchSvc.FetchActorAsync(attrTo.Id); try { return await ProcessNoteAsync(fetchedNote, actor); diff --git a/Iceshrimp.Backend/Core/Services/SystemUserService.cs b/Iceshrimp.Backend/Core/Services/SystemUserService.cs new file mode 100644 index 00000000..f65df8a1 --- /dev/null +++ b/Iceshrimp.Backend/Core/Services/SystemUserService.cs @@ -0,0 +1,84 @@ +using System.Security.Cryptography; +using Iceshrimp.Backend.Core.Database; +using Iceshrimp.Backend.Core.Database.Tables; +using Iceshrimp.Backend.Core.Extensions; +using Iceshrimp.Backend.Core.Helpers; +using Iceshrimp.Backend.Core.Middleware; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Distributed; + +namespace Iceshrimp.Backend.Core.Services; + +public class SystemUserService(ILogger logger, DatabaseContext db, IDistributedCache cache) { + public async Task GetInstanceActorAsync() { + return await GetOrCreateSystemUserAsync("instance.actor"); + } + + public async Task GetRelayActorAsync() { + return await GetOrCreateSystemUserAsync("relay.actor"); + } + + public async Task<(User user, UserKeypair keypair)> GetInstanceActorWithKeypairAsync() { + return await GetOrCreateSystemUserAndKeypairAsync("instance.actor"); + } + + public async Task<(User user, UserKeypair keypair)> GetRelayActorWithKeypairAsync() { + return await GetOrCreateSystemUserAndKeypairAsync("relay.actor"); + } + + private async Task<(User user, UserKeypair keypair)> GetOrCreateSystemUserAndKeypairAsync(string username) { + var user = await GetOrCreateSystemUserAsync(username); + var keypair = await db.UserKeypairs.FirstAsync(p => p.User == user); //TODO: cache this in redis as well + + return (user, keypair); + } + + private async Task GetOrCreateSystemUserAsync(string username) { + return await cache.FetchAsync($"systemUser:{username}", TimeSpan.FromHours(24), async () => { + logger.LogTrace("GetOrCreateSystemUser delegate method called for user {username}", username); + return await db.Users.FirstOrDefaultAsync(p => p.UsernameLower == username && + p.Host == null) ?? + await CreateSystemUserAsync(username); + }); + } + + private async Task CreateSystemUserAsync(string username) { + if (await db.Users.AnyAsync(p => p.UsernameLower == username.ToLowerInvariant() && p.Host == null)) + throw new GracefulException($"System user {username} already exists"); + + var keypair = RSA.Create(4096); + var user = new User { + Id = IdHelpers.GenerateSlowflakeId(), + CreatedAt = DateTime.UtcNow, + Username = username, + UsernameLower = username.ToLowerInvariant(), + Host = null, + IsAdmin = false, + IsLocked = true, + IsExplorable = false, + IsBot = true + }; + + var userKeypair = new UserKeypair { + UserId = user.Id, + PrivateKey = keypair.ExportPkcs8PrivateKeyPem(), + PublicKey = keypair.ExportSubjectPublicKeyInfoPem() + }; + + var userProfile = new UserProfile { + UserId = user.Id, + AutoAcceptFollowed = false, + Password = null + }; + + var usedUsername = new UsedUsername { + CreatedAt = DateTime.UtcNow, + Username = username.ToLowerInvariant() + }; + + await db.AddRangeAsync(user, userKeypair, userProfile, usedUsername); + await db.SaveChangesAsync(); + + return user; + } +} \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Services/UserService.cs b/Iceshrimp.Backend/Core/Services/UserService.cs index 4167c235..97be72e2 100644 --- a/Iceshrimp.Backend/Core/Services/UserService.cs +++ b/Iceshrimp.Backend/Core/Services/UserService.cs @@ -52,9 +52,7 @@ public class UserService( public async Task CreateUserAsync(string uri, string acct) { logger.LogDebug("Creating user {acct} with uri {uri}", acct, uri); - var instanceActor = await GetInstanceActorAsync(); - var instanceActorKeypair = await db.UserKeypairs.FirstAsync(p => p.User == instanceActor); - var actor = await fetchSvc.FetchActorAsync(uri, instanceActor, instanceActorKeypair); + var actor = await fetchSvc.FetchActorAsync(uri); logger.LogDebug("Got actor: {url}", actor.Url); actor.Normalize(uri, acct); @@ -63,7 +61,7 @@ public class UserService( Id = IdHelpers.GenerateSlowflakeId(), CreatedAt = DateTime.UtcNow, LastFetchedAt = DateTime.UtcNow, - DisplayName = actor.DisplayName, + DisplayName = actor.DisplayName, IsLocked = actor.IsLocked ?? false, IsBot = actor.IsBot, Username = actor.Username!, @@ -153,62 +151,4 @@ public class UserService( return user; } - - - public async Task GetInstanceActorAsync() { - return await GetOrCreateSystemUserAsync("instance.actor"); - } - - public async Task GetRelayActorAsync() { - return await GetOrCreateSystemUserAsync("relay.actor"); - } - - private async Task GetOrCreateSystemUserAsync(string username) { - return await cache.FetchAsync($"systemUser:{username}", TimeSpan.FromHours(24), async () => { - logger.LogTrace("GetOrCreateSystemUser delegate method called for user {username}", username); - return await db.Users.FirstOrDefaultAsync(p => p.UsernameLower == username && - p.Host == null) ?? - await CreateSystemUserAsync(username); - }); - } - - private async Task CreateSystemUserAsync(string username) { - if (await db.Users.AnyAsync(p => p.UsernameLower == username.ToLowerInvariant() && p.Host == null)) - throw new GracefulException($"System user {username} already exists"); - - var keypair = RSA.Create(4096); - var user = new User { - Id = IdHelpers.GenerateSlowflakeId(), - CreatedAt = DateTime.UtcNow, - Username = username, - UsernameLower = username.ToLowerInvariant(), - Host = null, - IsAdmin = false, - IsLocked = true, - IsExplorable = false, - IsBot = true - }; - - var userKeypair = new UserKeypair { - UserId = user.Id, - PrivateKey = keypair.ExportPkcs8PrivateKeyPem(), - PublicKey = keypair.ExportSubjectPublicKeyInfoPem() - }; - - var userProfile = new UserProfile { - UserId = user.Id, - AutoAcceptFollowed = false, - Password = null - }; - - var usedUsername = new UsedUsername { - CreatedAt = DateTime.UtcNow, - Username = username.ToLowerInvariant() - }; - - await db.AddRangeAsync(user, userKeypair, userProfile, usedUsername); - await db.SaveChangesAsync(); - - return user; - } } \ No newline at end of file