From 1883f426a797ee83fa7731c7f73d2814c71948a9 Mon Sep 17 00:00:00 2001 From: Laura Hausmann Date: Thu, 26 Sep 2024 22:13:36 +0200 Subject: [PATCH] [backend/federation] Add support for incoming, outgoing & local account migrations (ISH-118) --- .../Controllers/Web/MigrationController.cs | 105 ++++++++++++++++++ .../ActivityPub/ActivityDeliverService.cs | 25 +++-- .../ActivityPub/ActivityHandlerService.cs | 28 +---- .../ActivityPub/ActivityRenderer.cs | 8 ++ .../Core/Services/UserService.cs | 103 +++++++++++++++++ .../Schemas/Web/MigrationSchemas.cs | 12 +- 6 files changed, 242 insertions(+), 39 deletions(-) create mode 100644 Iceshrimp.Backend/Controllers/Web/MigrationController.cs diff --git a/Iceshrimp.Backend/Controllers/Web/MigrationController.cs b/Iceshrimp.Backend/Controllers/Web/MigrationController.cs new file mode 100644 index 00000000..ad33f2de --- /dev/null +++ b/Iceshrimp.Backend/Controllers/Web/MigrationController.cs @@ -0,0 +1,105 @@ +using System.Net; +using System.Net.Mime; +using Iceshrimp.Backend.Controllers.Shared.Attributes; +using Iceshrimp.Backend.Core.Configuration; +using Iceshrimp.Backend.Core.Database; +using Iceshrimp.Backend.Core.Database.Tables; +using Iceshrimp.Backend.Core.Extensions; +using Iceshrimp.Backend.Core.Middleware; +using Iceshrimp.Backend.Core.Services; +using Iceshrimp.Shared.Schemas.Web; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Iceshrimp.Backend.Controllers.Web; + +[ApiController] +[Authenticate] +[Authorize] +[EnableRateLimiting("sliding")] +[Route("/api/iceshrimp/migration")] +[Produces(MediaTypeNames.Application.Json)] +public class MigrationController( + DatabaseContext db, + UserService userSvc, + ActivityPub.UserResolver userResolver, + IOptions config +) : ControllerBase +{ + [HttpGet] + [ProducesResults(HttpStatusCode.OK)] + public MigrationSchemas.MigrationStatusResponse GetMigrationStatus() + { + var user = HttpContext.GetUserOrFail(); + return new MigrationSchemas.MigrationStatusResponse + { + Aliases = user.AlsoKnownAs ?? [], MovedTo = user.MovedToUri + }; + } + + [HttpPost("aliases")] + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound)] + public async Task AddAlias(MigrationSchemas.MigrationRequest rq) + { + var user = HttpContext.GetUserOrFail(); + User? aliasUser = null; + + if (rq.UserId is not null) + aliasUser = await db.Users.IncludeCommonProperties().Where(p => p.Id == rq.UserId).FirstOrDefaultAsync(); + if (rq.UserUri is not null) + aliasUser ??= await userResolver.ResolveAsyncOrNull(rq.UserUri); + if (aliasUser is null) + throw GracefulException.NotFound("Alias user not found or not specified"); + + await userSvc.AddAliasAsync(user, aliasUser); + } + + [HttpDelete("aliases")] + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task RemoveAlias(MigrationSchemas.MigrationRequest rq) + { + var user = HttpContext.GetUserOrFail(); + var aliasUri = rq.UserUri; + + if (rq.UserId is not null) + { + aliasUri ??= await db.Users.IncludeCommonProperties() + .Where(p => p.Id == rq.UserId) + .FirstOrDefaultAsync() + .ContinueWithResult(p => p is null ? null : p.Uri ?? p.GetPublicUri(config.Value)); + } + + if (aliasUri is null) throw GracefulException.NotFound("Alias user not found"); + await userSvc.RemoveAliasAsync(user, aliasUri); + } + + [HttpPost("move")] + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound)] + public async Task MoveTo(MigrationSchemas.MigrationRequest rq) + { + var user = HttpContext.GetUserOrFail(); + User? targetUser = null; + + if (rq.UserId is not null) + targetUser = await db.Users.IncludeCommonProperties().Where(p => p.Id == rq.UserId).FirstOrDefaultAsync(); + if (rq.UserUri is not null) + targetUser ??= await userResolver.ResolveAsyncOrNull(rq.UserUri); + if (targetUser is null) + throw GracefulException.NotFound("Target user not found"); + + await userSvc.MoveToUserAsync(user, targetUser); + } + + [HttpDelete("move")] + [ProducesResults(HttpStatusCode.OK)] + public async Task UndoMove() + { + var user = HttpContext.GetUserOrFail(); + await userSvc.UndoMoveAsync(user); + } +} \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityDeliverService.cs b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityDeliverService.cs index 7e31d12a..62412d68 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityDeliverService.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityDeliverService.cs @@ -20,15 +20,15 @@ public class ActivityDeliverService( logger.LogDebug("Queuing deliver-to-followers jobs for activity {id}", activity.Id); if (activity.Actor == null) throw new Exception("Actor must not be null"); + // @formatter:off await queueService.PreDeliverQueue.EnqueueAsync(new PreDeliverJobData { - ActorId = actor.Id, - RecipientIds = recipients.Select(p => p.Id).ToList(), - SerializedActivity = - JsonConvert.SerializeObject(activity, - LdHelpers.JsonSerializerSettings), + ActorId = actor.Id, + RecipientIds = recipients.Select(p => p.Id).ToList(), + SerializedActivity = JsonConvert.SerializeObject(activity, LdHelpers.JsonSerializerSettings), DeliverToFollowers = true }); + // @formatter:on } public async Task DeliverToAsync(ASActivity activity, User actor, params User[] recipients) @@ -36,15 +36,15 @@ public class ActivityDeliverService( logger.LogDebug("Queuing deliver-to-recipients jobs for activity {id}", activity.Id); if (activity.Actor == null) throw new Exception("Actor must not be null"); + // @formatter:off await queueService.PreDeliverQueue.EnqueueAsync(new PreDeliverJobData { - ActorId = actor.Id, - RecipientIds = recipients.Select(p => p.Id).ToList(), - SerializedActivity = - JsonConvert.SerializeObject(activity, - LdHelpers.JsonSerializerSettings), + ActorId = actor.Id, + RecipientIds = recipients.Select(p => p.Id).ToList(), + SerializedActivity = JsonConvert.SerializeObject(activity, LdHelpers.JsonSerializerSettings), DeliverToFollowers = false }); + // @formatter:on } public async Task DeliverToConditionalAsync(ASActivity activity, User actor, Note note) @@ -56,7 +56,10 @@ public class ActivityDeliverService( } var recipients = await db.Users - .Where(p => note.VisibleUserIds.Prepend(note.User.Id).Contains(p.Id) && p.IsRemoteUser) + .Where(p => p.IsRemoteUser && + note.VisibleUserIds + .Prepend(note.User.Id) + .Contains(p.Id)) .ToArrayAsync(); await DeliverToAsync(activity, actor, recipients); diff --git a/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityHandlerService.cs b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityHandlerService.cs index 5eb6cf53..0e768c26 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityHandlerService.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityHandlerService.cs @@ -500,33 +500,7 @@ public class ActivityHandlerService( source.MovedToUri = targetUri; await db.SaveChangesAsync(); - - var followers = db.Followings - .Where(p => p.Followee == source) - .Where(p => p.Follower.IsLocalUser) - .OrderBy(p => p.Id) - .Select(p => p.Follower) - .PrecomputeRelationshipData(source) - .AsChunkedAsyncEnumerable(50, p => p.Id, isOrdered: true); - - await foreach (var follower in followers) - { - try - { - await userSvc.FollowUserAsync(follower, target); - - // We need to transfer the precomputed properties to the source user for each follower so that the unfollow method works correctly - source.PrecomputedIsFollowedBy = follower.PrecomputedIsFollowing; - source.PrecomputedIsRequestedBy = follower.PrecomputedIsRequested; - - await userSvc.UnfollowUserAsync(follower, source); - } - catch (Exception e) - { - logger.LogWarning("Failed to process move ({sourceUri} -> {targetUri}) for user {id}: {error}", - sourceUri, targetUri, follower.Id, e); - } - } + await userSvc.MoveRelationshipsAsync(source, target, sourceUri, targetUri); } private async Task UnfollowAsync(ASActor followeeActor, User follower) diff --git a/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityRenderer.cs b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityRenderer.cs index 7d109a38..2745a6a3 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityRenderer.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityRenderer.cs @@ -212,4 +212,12 @@ public class ActivityRenderer( InReplyTo = new ASObjectBase(note.Uri ?? note.GetPublicUri(config.Value)), Name = poll.Choices[vote.Choice] }; + + public ASMove RenderMove(ASActor actor, ASActor target) => new() + { + Id = GenerateActivityId(), + Actor = actor.Compact(), + Object = actor.Compact(), + Target = new ASLink(target.Id) + }; } \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Services/UserService.cs b/Iceshrimp.Backend/Core/Services/UserService.cs index 0e862729..1277a2db 100644 --- a/Iceshrimp.Backend/Core/Services/UserService.cs +++ b/Iceshrimp.Backend/Core/Services/UserService.cs @@ -1193,6 +1193,57 @@ public class UserService( } } + public async Task AddAliasAsync(User user, User alias) + { + if (user.IsRemoteUser) throw GracefulException.BadRequest("Cannot add alias for remote user"); + if (user.Id == alias.Id) throw GracefulException.BadRequest("You cannot add an alias to yourself"); + + user.AlsoKnownAs ??= []; + var uri = alias.Uri ?? alias.GetPublicUri(instance.Value); + + if (!user.AlsoKnownAs.Contains(uri)) user.AlsoKnownAs.Add(uri); + await UpdateLocalUserAsync(user, user.AvatarId, user.BannerId); + } + + public async Task RemoveAliasAsync(User user, string aliasUri) + { + if (user.IsRemoteUser) throw GracefulException.BadRequest("Cannot manage aliases for remote user"); + if (user.AlsoKnownAs is null or []) return; + if (!user.AlsoKnownAs.Contains(aliasUri)) return; + + user.AlsoKnownAs.RemoveAll(p => p == aliasUri); + await UpdateLocalUserAsync(user, user.AvatarId, user.BannerId); + } + + public async Task MoveToUserAsync(User source, User target) + { + if (source.IsRemoteUser) throw GracefulException.BadRequest("Cannot initiate move for remote user"); + if (source.Id == target.Id) throw GracefulException.BadRequest("You cannot migrate to yourself"); + + target = await UpdateUserAsync(target, force: true); + if (target.AlsoKnownAs is null || !target.AlsoKnownAs.Contains(source.GetPublicUri(instance.Value))) + throw GracefulException.BadRequest("Target user has not added you as an account alias"); + + var sourceUri = source.Uri ?? source.GetPublicUri(instance.Value); + var targetUri = target.Uri ?? target.GetPublicUri(instance.Value); + if (source.MovedToUri is not null && source.MovedToUri != targetUri) + throw GracefulException.BadRequest("You can only initiate repeated migrations to the same target account"); + + source.MovedToUri = targetUri; + await db.SaveChangesAsync(); + + await MoveRelationshipsAsync(source, target, sourceUri, targetUri); + var move = activityRenderer.RenderMove(userRenderer.RenderLite(source), userRenderer.RenderLite(target)); + await deliverSvc.DeliverToFollowersAsync(move, source, []); + } + + public async Task UndoMoveAsync(User user) + { + if (user.MovedToUri is null) return; + user.MovedToUri = null; + await UpdateLocalUserAsync(user, user.AvatarId, user.BannerId); + } + private async Task UpdateUserHostAsync(User user) { if (user.IsLocalUser || user.Uri == null || user.SplitDomainResolved) @@ -1243,4 +1294,56 @@ public class UserService( user.SplitDomainResolved = true; return split[1]; } + + public async Task MoveRelationshipsAsync(User source, User target, string sourceUri, string targetUri) + { + var followers = db.Followings + .Where(p => p.Followee == source && p.Follower.IsLocalUser) + .Select(p => p.Follower) + .OrderBy(p => p.Id) + .PrecomputeRelationshipData(source) + .AsChunkedAsyncEnumerable(50, p => p.Id, isOrdered: true); + + await foreach (var follower in followers) + { + try + { + await FollowUserAsync(follower, target); + + // We need to transfer the precomputed properties to the source user for each follower so that the unfollow method works correctly + source.PrecomputedIsFollowedBy = follower.PrecomputedIsFollowing; + source.PrecomputedIsRequestedBy = follower.PrecomputedIsRequested; + + await UnfollowUserAsync(follower, source); + } + catch (Exception e) + { + logger.LogWarning("Failed to process move ({sourceUri} -> {targetUri}) for follower {id}: {error}", + sourceUri, targetUri, follower.Id, e); + } + } + + if (source.IsRemoteUser || target.IsRemoteUser) return; + + var following = db.Followings + .Where(p => p.Follower == source) + .Select(p => p.Follower) + .OrderBy(p => p.Id) + .PrecomputeRelationshipData(source) + .AsChunkedAsyncEnumerable(50, p => p.Id, isOrdered: true); + + await foreach (var followee in following) + { + try + { + await FollowUserAsync(target, followee); + await UnfollowUserAsync(source, followee); + } + catch (Exception e) + { + logger.LogWarning("Failed to process move ({sourceUri} -> {targetUri}) for followee {id}: {error}", + sourceUri, targetUri, followee.Id, e); + } + } + } } \ No newline at end of file diff --git a/Iceshrimp.Shared/Schemas/Web/MigrationSchemas.cs b/Iceshrimp.Shared/Schemas/Web/MigrationSchemas.cs index 5fd28a59..c3cf172d 100644 --- a/Iceshrimp.Shared/Schemas/Web/MigrationSchemas.cs +++ b/Iceshrimp.Shared/Schemas/Web/MigrationSchemas.cs @@ -2,5 +2,15 @@ namespace Iceshrimp.Shared.Schemas.Web; public class MigrationSchemas { - + public class MigrationRequest + { + public string? UserId { get; set; } + public string? UserUri { get; set; } + } + + public class MigrationStatusResponse + { + public required List Aliases { get; set; } + public required string? MovedTo { get; set; } + } } \ No newline at end of file