An account that moves shows where it went

PrivaPub dropped Move as an unknown type and showed no `moved` on accounts.
Now a Move is believed as Mastodon believes it: the moving account sends it
about itself, and the new account, read again from its own server, names it
in alsoKnownAs (now kept on remote accounts). The old account then shows the
new one as `moved` in the Mastodon API. The personas following it keep
following it: following the new account on their behalf would tell another
server about them, so that waits for the owner.

Checked live against GoToSocial (scenarios/moves.sh: an alias, a move, 6
checks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-05 18:39:46 +02:00
1 parent 1265611f9e
commit f1e743c331
14 files changed
+224 -3

No files matched your search

@@ -32,6 +32,7 @@
public string HeaderDescription { get; set; } = string.Empty;
public List<Field> Fields { get; set; } = new();
public Source Source { get; set; }
public Account Moved { get; set; }//the account it moved to, when that account names it among its aliases
}
public class CustomEmojiEntity
@@ -166,8 +166,19 @@ namespace PrivaPub.Api.Mastodon.Mappers
accounts[avatar.ID] = await Local(_localActors.FromAvatar(avatar), false, token);
foreach (var group in await _dbEntities.Groups.Match(g => wanted.Contains(g.ID) && !g.DeletionAt.HasValue && g.Kind == GroupKind.Community).ExecuteAsync(token))
accounts[group.ID] = await Local(_localActors.FromGroup(group), false, token);
foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => wanted.Contains(f.ID) && !f.DeletionAt.HasValue).ExecuteAsync(token))
var foreigns = await _dbEntities.ForeignAvatars.Match(f => wanted.Contains(f.ID) && !f.DeletionAt.HasValue).ExecuteAsync(token);
foreach (var foreign in foreigns)
accounts[foreign.ID] = Foreign(foreign);
// an account that moved shows where to, once the new account names it as one of its own (as a Move is believed)
var movedTo = foreigns.Where(f => !string.IsNullOrEmpty(f.MovedToURL)).Select(f => f.MovedToURL).Distinct().ToList();
if (movedTo.Count > 0)
{
var targets = (await _dbEntities.ForeignAvatars.Match(f => movedTo.Contains(f.ActorURI) && !f.DeletionAt.HasValue).ExecuteAsync(token))
.ToDictionary(f => f.ActorURI);
foreach (var foreign in foreigns)
if (foreign.MovedToURL != default && targets.TryGetValue(foreign.MovedToURL, out var target) && target.AlsoKnownAs.Contains(foreign.ActorURI))
accounts[foreign.ID].Moved = Foreign(target);
}
return accounts;
}
@@ -33,6 +33,7 @@ namespace PrivaPub.Federation.Actors
public string IconDescription { get; init; }
public string HeaderDescription { get; init; }
public string MovedTo { get; init; }
public List<string> AlsoKnownAs { get; init; } = new();//the accounts it says it also is (a Move's target names the moved one)
public DateTime? Published { get; init; }
public List<CustomEmoji> Emojis { get; init; } = new();
public Dictionary<string, string> Fields { get; init; } = new();
@@ -41,6 +42,14 @@ namespace PrivaPub.Federation.Actors
public ActorKey Key(string keyId) => Keys.FirstOrDefault(k => k.Id == keyId);
// a property of URIs: one, an array of them, or objects carrying an id
static List<string> Uris(JsonElement value) => (value.ValueKind == JsonValueKind.Array ? value.EnumerateArray().ToList() : new List<JsonElement> { value })
.Select(v => v.ValueKind == JsonValueKind.String ? v.GetString() : RemoteActorService.Text(v, "id"))
.Where(v => Uri.TryCreate(v, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http")
.Distinct(StringComparer.Ordinal)
.Take(20)
.ToList();
public static ActorDocument Parse(JsonElement root)
{
if (root.ValueKind != JsonValueKind.Object)
@@ -75,6 +84,7 @@ namespace PrivaPub.Federation.Actors
IconDescription = root.TryGetProperty("icon", out var described) ? Alt(described) : default,
HeaderDescription = root.TryGetProperty("image", out var headerImage) ? Alt(headerImage) : default,
MovedTo = RemoteActorService.Text(root, "movedTo"),
AlsoKnownAs = root.TryGetProperty("alsoKnownAs", out var aliases) ? Uris(aliases) : new(),
Published = DateTimeOffset.TryParse(RemoteActorService.Text(root, "published"), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var published)
? published.UtcDateTime
: default(DateTime?),
@@ -214,6 +214,7 @@ namespace PrivaPub.Federation.Actors
.Modify(a => a.IsLocked, actor.Locked)
.Modify(a => a.IsMemorial, actor.Memorial)
.Modify(a => a.MovedToURL, Origin.Of(actor.MovedTo) == default ? default : actor.MovedTo)
.Modify(a => a.AlsoKnownAs, actor.AlsoKnownAs)
.Modify(a => a.Published, actor.Published)
.Modify(a => a.Emojis, actor.Emojis)
.Modify(a => a.Fields, actor.Fields)
@@ -0,0 +1,50 @@
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Models.User;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
namespace PrivaPub.Federation.Inbox.Handlers
{
// An account moving to another (Mastodon's Move{object: itself, target: the new account}), believed as Mastodon
// believes it: the moving account sends it about itself, and the new account, read again from its own server, names
// it among its alsoKnownAs. The old account then shows where it went (`moved`). The personas following it keep
// following it: following the new one is theirs to do.
public class MoveHandler : IActivityHandler
{
readonly IRemoteActorService _remoteActors;
public MoveHandler(IRemoteActorService remoteActors)
{
_remoteActors = remoteActors;
}
public string Type => "Move";
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
{
var target = Id(activity["target"]);
if (Id(activity["object"]) != actor.ActorURI || string.IsNullOrEmpty(target) || target == actor.ActorURI)
{
Arrival.Drop("not-self");
return;
}
var moved = await _remoteActors.GetActor(target, refresh: true, token);
if (moved == default)
{
Arrival.Drop("target-unavailable");
return;
}
if (!moved.AlsoKnownAs.Contains(actor.ActorURI))
{
Arrival.Drop("not-aliased");
return;
}
await DB.Default.Update<ForeignAvatar>().MatchID(actor.ID).Modify(a => a.MovedToURL, target).ExecuteAsync(token);
Arrival.Accept("moved");
}
}
}
@@ -94,6 +94,7 @@ namespace PrivaPub.Middleware
.AddSingleton<IActivityHandler, FlagHandler>()
.AddSingleton<IActivityHandler, BlockHandler>()
.AddSingleton<IActivityHandler, LockHandler>()
.AddSingleton<IActivityHandler, MoveHandler>()
.AddSingleton<IActivityHandler, CreateHandler>()
.AddSingleton<IActivityHandler, DeleteHandler>()
.AddSingleton<IActivityHandler, UpdateHandler>()
+1
View File
@@ -76,6 +76,7 @@ namespace PrivaPub.Models.User
public string InboxURL { get; set; }
public string OutboxURL { get; set; }
public string MovedToURL { get; set; }
public List<string> AlsoKnownAs { get; set; } = new();//alsoKnownAs: the accounts it says it also is
public string PictureURL { get; set; }//icon
public string ThumbnailURL { get; set; }//image
public AvatarType AvatarType { get; set; } = AvatarType.Person;