[backend/federation] Add support for incoming, outgoing & local account migrations (ISH-118)
This commit is contained in:
parent
4448e00333
commit
1883f426a7
6 changed files with 242 additions and 39 deletions
105
Iceshrimp.Backend/Controllers/Web/MigrationController.cs
Normal file
105
Iceshrimp.Backend/Controllers/Web/MigrationController.cs
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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; }
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue