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