[backend/federation] Add support for incoming, outgoing & local account migrations (ISH-118)

This commit is contained in:
Laura Hausmann 2024-09-26 22:13:36 +02:00
parent 4448e00333
commit 1883f426a7
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
6 changed files with 242 additions and 39 deletions

View file

@ -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.InstanceSection> 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);
}
}

View file

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

View file

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

View file

@ -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)
};
}

View file

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

View file

@ -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<string> Aliases { get; set; }
public required string? MovedTo { get; set; }
}
}