[backend/federation] Handle incoming ASMove activities (ISH-118)

This commit is contained in:
Laura Hausmann 2024-09-22 22:25:46 +02:00
parent f6309148b7
commit 1d9864a214
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
5 changed files with 90 additions and 2 deletions

View file

@ -16,6 +16,30 @@ namespace Iceshrimp.Backend.Core.Extensions;
public static class QueryableExtensions
{
/// <summary>
/// This helper method allows consumers to obtain the performance &amp; memory footprint benefits of chunked DB transactions,
/// while not requiring them to work with chunks instead of a regular enumerator.
/// </summary>
/// <remarks>
/// Make sure to call .OrderBy() on the query, otherwise the results will be unpredictable.
/// </remarks>
/// <returns>
/// The result set as an IAsyncEnumerable. Makes one DB roundtrip at the start of each chunk.
/// Successive items of the chunk are yielded instantaneously.
/// </returns>
public static async IAsyncEnumerable<T> AsChunkedAsyncEnumerable<T>(this IQueryable<T> 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<T> Paginate<T>(
this IQueryable<T> query,
MastodonPaginationQuery pq,

View file

@ -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

View file

@ -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<ASTag>? 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; }
}

View file

@ -53,6 +53,7 @@ public class ASObject : ASObjectBase
ASActivity.Types.Announce => token.ToObject<ASAnnounce>(),
ASActivity.Types.EmojiReact => token.ToObject<ASEmojiReact>(),
ASActivity.Types.Block => token.ToObject<ASBlock>(),
ASActivity.Types.Move => token.ToObject<ASMove>(),
_ => token.ToObject<ASObject>()
};
case JTokenType.Array:

View file

@ -251,6 +251,7 @@ public class UserService(
public async Task<User> 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