diff --git a/Iceshrimp.Backend/Core/Extensions/QueryableExtensions.cs b/Iceshrimp.Backend/Core/Extensions/QueryableExtensions.cs index 4caa3774..9109eb48 100644 --- a/Iceshrimp.Backend/Core/Extensions/QueryableExtensions.cs +++ b/Iceshrimp.Backend/Core/Extensions/QueryableExtensions.cs @@ -16,6 +16,30 @@ namespace Iceshrimp.Backend.Core.Extensions; public static class QueryableExtensions { + /// + /// This helper method allows consumers to obtain the performance & memory footprint benefits of chunked DB transactions, + /// while not requiring them to work with chunks instead of a regular enumerator. + /// + /// + /// Make sure to call .OrderBy() on the query, otherwise the results will be unpredictable. + /// + /// + /// The result set as an IAsyncEnumerable. Makes one DB roundtrip at the start of each chunk. + /// Successive items of the chunk are yielded instantaneously. + /// + public static async IAsyncEnumerable AsChunkedAsyncEnumerable(this IQueryable query, int chunkSize) + { + var offset = 0; + while (true) + { + var res = await query.Skip(offset).Take(chunkSize).ToArrayAsync(); + if (res.Length == 0) break; + foreach (var item in res) yield return item; + if (res.Length < chunkSize) break; + offset += chunkSize; + } + } + public static IQueryable Paginate( this IQueryable query, MastodonPaginationQuery pq, diff --git a/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityHandlerService.cs b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityHandlerService.cs index b3318223..34092b41 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityHandlerService.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityHandlerService.cs @@ -76,6 +76,7 @@ public class ActivityHandlerService( ASEmojiReact react => HandleReact(react, resolvedActor), ASFollow follow => HandleFollow(follow, resolvedActor), ASLike like => HandleLike(like, resolvedActor), + ASMove move => HandleMove(move, resolvedActor), ASReject reject => HandleReject(reject, resolvedActor), ASUndo undo => HandleUndo(undo, resolvedActor), ASUnfollow unfollow => HandleUnfollow(unfollow, resolvedActor), @@ -204,8 +205,13 @@ public class ActivityHandlerService( .FirstOrDefaultAsync(p => p.Followee == actor && p.FollowerId == ids[0]); if (request == null) - throw GracefulException - .UnprocessableEntity($"No follow request matching follower '{ids[0]}' and followee '{actor.Id}' found"); + { + if (await db.Followings.AnyAsync(p => p.Followee == actor && p.FollowerId == ids[0])) + return; + + throw GracefulException.UnprocessableEntity($"No follow request matching follower '{ids[0]}'" + + $"and followee '{actor.Id}' found"); + } await userSvc.AcceptFollowRequestAsync(request); } @@ -478,6 +484,51 @@ public class ActivityHandlerService( await userSvc.BlockUserAsync(resolvedActor, resolvedBlockee); } + private async Task HandleMove(ASMove activity, User resolvedActor) + { + if (activity.Target.Id is null) throw GracefulException.UnprocessableEntity("Move target must have an ID"); + var target = await userResolver.ResolveAsync(activity.Target.Id); + var source = await userSvc.UpdateUserAsync(resolvedActor, force: true); + target = await userSvc.UpdateUserAsync(target, force: true); + + var sourceUri = source.Uri ?? source.GetPublicUri(config.Value.WebDomain); + var targetUri = target.Uri ?? target.GetPublicUri(config.Value.WebDomain); + var aliases = target.AlsoKnownAs ?? []; + if (!aliases.Contains(sourceUri)) + throw GracefulException.UnprocessableEntity("Refusing to process move activity:" + + "source uri not listed in target aliases"); + + 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); + + 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); + } + } + } + private async Task UnfollowAsync(ASActor followeeActor, User follower) { //TODO: send reject? or do we not want to copy that part of the old ap core diff --git a/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASActivity.cs b/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASActivity.cs index dc22763b..31749dad 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASActivity.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASActivity.cs @@ -34,6 +34,7 @@ public class ASActivity : ASObjectWithId public const string Undo = $"{Ns}#Undo"; public const string Like = $"{Ns}#Like"; public const string Block = $"{Ns}#Block"; + public const string Move = $"{Ns}#Move"; // Extensions public const string Bite = "https://ns.mia.jetzt/as#Bite"; @@ -194,4 +195,14 @@ public class ASEmojiReact : ASActivity [J($"{Constants.ActivityStreamsNs}#tag")] [JC(typeof(ASTagConverter))] public List? Tags { get; set; } +} + +public class ASMove : ASActivity +{ + public ASMove() => Type = Types.Move; + + [JR] + [J($"{Constants.ActivityStreamsNs}#target")] + [JC(typeof(ASLinkConverter))] + public required ASLink Target { get; set; } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASObject.cs b/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASObject.cs index 32cd5633..6fdb249c 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASObject.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASObject.cs @@ -53,6 +53,7 @@ public class ASObject : ASObjectBase ASActivity.Types.Announce => token.ToObject(), ASActivity.Types.EmojiReact => token.ToObject(), ASActivity.Types.Block => token.ToObject(), + ASActivity.Types.Move => token.ToObject(), _ => token.ToObject() }; case JTokenType.Array: diff --git a/Iceshrimp.Backend/Core/Services/UserService.cs b/Iceshrimp.Backend/Core/Services/UserService.cs index ccabdfd3..b9b99505 100644 --- a/Iceshrimp.Backend/Core/Services/UserService.cs +++ b/Iceshrimp.Backend/Core/Services/UserService.cs @@ -251,6 +251,7 @@ public class UserService( public async Task UpdateUserAsync(User user, ASActor? actor = null, bool force = false) { if (!user.NeedsUpdate && actor == null && !force) return user; + if (user.IsLocalUser) return user; if (actor is { IsUnresolved: true } or { Username: null }) actor = null; // This will trigger a fetch a couple lines down