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:
1 parent
1265611f9e
commit
f1e743c331
14 files changed
+224
-3
No files matched your search
@@ -553,6 +553,9 @@ tools/pasture/run.sh down # removes e
|
||||
`connection`) and its API is v3 (`/api/v3`, `sort=New`, `resolve_object` answering views). It takes private messages
|
||||
only as `ChatMessage`: a reply goes out as one, a first message cannot (G-0008). `scenarios/lemmy19.sh`, 29 checks
|
||||
and that gap.
|
||||
- **Account moves (`scenarios/moves.sh`, needs gts):** two fresh GoToSocial accounts made by its admin CLI; alice follows
|
||||
the old one, the new one names it as an alias (`/api/v1/accounts/alias`), the old one moves (`/api/v1/accounts/move`),
|
||||
and PrivaPub shows it `moved` while alice's follow stays. 6 checks.
|
||||
- **Hollo (0.9.19):** Fedify's microblog server on the shared Postgres, set up through its web form (which
|
||||
checks `Origin` against `Host`, so the request names `hollo.test` without the port). It needs a 44-character
|
||||
`SECRET_KEY`, a media directory and a `themeColor`; statuses and votes go as JSON. Town only, no scenario.
|
||||
|
||||
@@ -147,6 +147,7 @@ Received:
|
||||
| `Announce` | counted and notified for local posts; shown to followers of the announcer, with the original refetched from its origin |
|
||||
| `Delete` | deletes the object, or the actor and its follows; a deleted object id is remembered for 90 days, so a late `Create` cannot bring it back |
|
||||
| `Flag` | becomes a report for this server's moderators |
|
||||
| `Move` | an account moving: believed as Mastodon believes it, when the account sends it about itself and the new account, read again from its server, names it in `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 |
|
||||
| `Block` of a persona | the follows between them end, the blocker's posts and notifications are hidden from the persona, nothing of the persona's is addressed to the blocker, and the relationship says `blocked_by`; `Undo{Block}` lifts it |
|
||||
|
||||
Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Create{Question}` and poll votes, `EmojiReact` and its `Undo`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Mappers;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.Tests.Support;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using static PrivaPub.Tests.Support.FederatedSeeds;
|
||||
|
||||
namespace PrivaPub.Tests.Federation
|
||||
{
|
||||
// An account moving to another (Mastodon's Move), believed as Mastodon believes it: sent by the moving account about
|
||||
// itself, and the new account naming it among its alsoKnownAs
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class MoveTests : IAsyncLifetime
|
||||
{
|
||||
Harness _harness;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
_harness = await Harness.Start();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_harness != default)
|
||||
await _harness.DisposeAsync();
|
||||
}
|
||||
|
||||
// the new account, serving a document that names the old one (or not)
|
||||
RemoteActor Target(string name, RemoteActor aliasOf)
|
||||
{
|
||||
var target = new RemoteActor(_harness.Peer, name);
|
||||
var document = target.Document();
|
||||
if (aliasOf != default)
|
||||
document["alsoKnownAs"] = new JsonArray(aliasOf.Id);
|
||||
_harness.Peer.Serve(new Uri(target.Id).AbsolutePath, document.ToJsonString());
|
||||
return target;
|
||||
}
|
||||
|
||||
Task Move(RemoteActor sender, RemoteActor moved, RemoteActor target) =>
|
||||
_harness.Deliver(sender, "/human-centipede", new JsonObject
|
||||
{
|
||||
["id"] = NewId(sender, "moves"), ["type"] = "Move", ["actor"] = sender.Id, ["object"] = moved.Id, ["target"] = target.Id
|
||||
});
|
||||
|
||||
static Task<ForeignAvatar> Stored(RemoteActor actor) =>
|
||||
DB.Default.Find<ForeignAvatar>().Match(a => a.ActorURI == actor.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
[Fact]
|
||||
public async Task A_move_the_new_account_confirms_shows_the_old_account_moved_there()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (_, alice) = await _harness.Persona("alice");
|
||||
var old = new RemoteActor(_harness.Peer, "old");
|
||||
await Follows(alice.Id, old);
|
||||
var target = Target("new", aliasOf: old);
|
||||
|
||||
await Move(old, old, target);
|
||||
|
||||
var stored = await Stored(old);
|
||||
Assert.Equal(target.Id, stored.MovedToURL);
|
||||
var account = await new MastodonMapper(_harness.Db, _harness.Local).Account(stored.ID, token);
|
||||
Assert.Equal(target.Name, account.Moved?.Username);
|
||||
Assert.Contains(_harness.Ledger.Of("in"), e => e.Activity == "Move" && e.Reason == "moved");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_move_the_new_account_does_not_confirm_or_someone_else_sends_changes_nothing()
|
||||
{
|
||||
var (_, alice) = await _harness.Persona("alice");
|
||||
var old = new RemoteActor(_harness.Peer, "old");
|
||||
var stranger = new RemoteActor(_harness.Peer, "stranger");
|
||||
await Follows(alice.Id, old);
|
||||
var unconfirmed = Target("new", aliasOf: default);
|
||||
var hijack = Target("hijack", aliasOf: stranger);
|
||||
|
||||
await Move(old, old, unconfirmed);
|
||||
await Move(stranger, old, hijack);
|
||||
|
||||
Assert.Null((await Stored(old)).MovedToURL);
|
||||
Assert.Equal(new[] { "not-aliased", "not-self" }, _harness.Ledger.Of("in").Where(e => e.Activity == "Move").Select(e => e.Reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,8 @@ namespace PrivaPub.Tests.Support
|
||||
new UpdateHandler(Db, Local, Remote, Groups, Records, Quotes, Delivery),
|
||||
new FlagHandler(Db, Local),
|
||||
new BlockHandler(Db, Local),
|
||||
new LockHandler(Db)
|
||||
new LockHandler(Db),
|
||||
new MoveHandler(Remote)
|
||||
};
|
||||
((AnnounceHandler)Handlers.First(h => h is AnnounceHandler)).Relays = Handlers;
|
||||
Processor = new InboxProcessor(Remote, Handlers, NullLogger<InboxProcessor>.Instance, Ledger, Local);
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -257,6 +257,11 @@ neither the public nor the author's followers as a direct message, like our DMs,
|
||||
mentions. Being in `cc` stored it but left it invisible, so each member's copy also mentions that member silently.
|
||||
Such posts are then found in the member's conversations, never by a search on their URI.
|
||||
|
||||
**Account moves (2026-10-05, `tools/pasture/scenarios/moves.sh`):** GoToSocial moves an account through its API (the new
|
||||
one names the old in `alsoKnownAs` first, `/api/v1/accounts/alias`) and sends `Move{object: old, target: new}` to the
|
||||
old one's followers. PrivaPub reads the new account again, finds the old one among its aliases and shows the old account
|
||||
`moved`; 6 checks pass. PrivaPub dropped `Move` as an unknown type until then.
|
||||
|
||||
### Misskey family: Misskey 2026.10.0, Sharkey 2025.4.7, Iceshrimp.NET 2026.1.2-beta, CherryPick 4.17
|
||||
|
||||
Firefish is dead (its site has answered 410 since February 2025).
|
||||
|
||||
+2
-1
@@ -651,7 +651,8 @@ it, raw where it doesn't.
|
||||
`interaction_policy`; `ReplyRequest`/`LikeRequest`/`AnnounceRequest` where approval is needed; `Accept{result}`
|
||||
verified and carried; replies a policy does not let in kept only with an authorization.
|
||||
- **Accounts and follows:**
|
||||
- inbound `Move` with Mastodon's checks;
|
||||
- inbound `Move` with Mastodon's checks: **done 2026-10-05** (the old account shows `moved`, checked live against
|
||||
GoToSocial); moving the personas' follows to the new account is left to them until the owner decides;
|
||||
- re-run WebFinger on a rename;
|
||||
- inbound `Block`, plus `Add`/`Remove` of pins;
|
||||
- FEP-8fcf followers sync;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Account moves into PrivaPub's view: an account alice follows on GoToSocial moves to a new account there. The new one
|
||||
# names the old among its aliases (/api/v1/accounts/alias), the old moves (/api/v1/accounts/move), GoToSocial sends its
|
||||
# Move to the old one's followers, and PrivaPub shows the old account moved to the new (`moved`). Needs the gts peer.
|
||||
G=https://gts.test:6443
|
||||
gcurl() { curl -k --resolve gts.test:6443:127.0.0.1 "$@"; }
|
||||
MOVE_PASSWORD='Gts-Pasture-Pass-1!'
|
||||
# gts_account <name>: a confirmed GoToSocial account and its token
|
||||
gts_account() {
|
||||
podman exec pasture-gts /gotosocial/gotosocial admin account create --username "$1" --email "$1@gts.test" --password "$MOVE_PASSWORD" >/dev/null 2>&1
|
||||
podman exec pasture-gts /gotosocial/gotosocial admin account confirm --username "$1" >/dev/null 2>&1
|
||||
local app id secret code jar
|
||||
jar=$(mktemp); app=$(gcurl -s -X POST $G/api/v1/apps -d 'client_name=pasture&redirect_uris=urn:ietf:wg:oauth:2.0:oob&scopes=read+write+follow')
|
||||
id=$(echo "$app" | j "print(d['client_id'])"); secret=$(echo "$app" | j "print(d['client_secret'])")
|
||||
gcurl -s -o /dev/null -c "$jar" -b "$jar" "$G/oauth/authorize?client_id=$id&redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&scope=read+write+follow"
|
||||
gcurl -s -o /dev/null -c "$jar" -b "$jar" -X POST $G/auth/sign_in --data-urlencode "username=$1@gts.test" --data-urlencode "password=$MOVE_PASSWORD"
|
||||
code=$(gcurl -s -o /dev/null -w '%{redirect_url}' -c "$jar" -b "$jar" -X POST "$G/oauth/authorize" | sed -n 's/.*[?&]code=\([^&]*\).*/\1/p')
|
||||
rm -f "$jar"
|
||||
gcurl -s -X POST $G/oauth/token -d "grant_type=authorization_code&code=$code&client_id=$id&client_secret=$secret&redirect_uri=urn:ietf:wg:oauth:2.0:oob&scope=read+write+follow" | j "print(d['access_token'])"
|
||||
}
|
||||
|
||||
echo "moves"
|
||||
PT=$(privapub_token alice)
|
||||
PH="Authorization: Bearer $PT"
|
||||
[ -n "$PT" ] && ok "PrivaPub token for alice" || { ko "PrivaPub token"; return 1; }
|
||||
run=$(date +%s)
|
||||
old="mvold$run"; new="mvnew$run"
|
||||
OT=$(gts_account "$old"); NT=$(gts_account "$new")
|
||||
[ -n "$OT" ] && [ -n "$NT" ] && ok "two GoToSocial accounts, the one that moves and the one it moves to" || { ko "GoToSocial accounts"; return 1; }
|
||||
|
||||
echo " following the old account"
|
||||
old_on_p=$(curl -s -H "$PH" "$P/api/v2/search?q=$old@gts.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
|
||||
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$old_on_p/follow"
|
||||
# (GoToSocial makes its accounts locked: the old one approves alice)
|
||||
until_true 30 '[ -n "$(gcurl -s -H "Authorization: Bearer $OT" "$G/api/v1/follow_requests" | j "print(d[0][\"id\"])")" ]'
|
||||
gcurl -s -o /dev/null -X POST -H "Authorization: Bearer $OT" "$G/api/v1/follow_requests/$(gcurl -s -H "Authorization: Bearer $OT" "$G/api/v1/follow_requests" | j "print(d[0]['id'])")/authorize"
|
||||
until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$old_on_p" | j "print(d[0][\"following\"])")" = "True" ]' \
|
||||
&& ok "alice follows the account that will move" || ko "alice's follow of the old account never took"
|
||||
|
||||
echo " the move"
|
||||
gcurl -s -o /dev/null -X POST -H "Authorization: Bearer $NT" "$G/api/v1/accounts/alias" -H 'Content-Type: application/json' \
|
||||
-d "{\"also_known_as_uris\":[\"https://gts.test/users/$old\"]}"
|
||||
moved=$(gcurl -s -o /dev/null -w '%{http_code}' -X POST -H "Authorization: Bearer $OT" "$G/api/v1/accounts/move" -H 'Content-Type: application/json' \
|
||||
-d "{\"password\":\"$MOVE_PASSWORD\",\"moved_to_uri\":\"https://gts.test/users/$new\"}")
|
||||
[ "$moved" = "202" ] || [ "$moved" = "200" ] && ok "the old account moves on GoToSocial" || ko "GoToSocial refused the move ($moved)"
|
||||
until_true 60 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/$old_on_p" | j "print((d.get(\"moved\") or {}).get(\"acct\"))")" = "$new@gts.test" ]' \
|
||||
&& ok "PrivaPub shows the old account moved to the new one" || ko "PrivaPub does not show the move ($(curl -s -H "$PH" "$P/api/v1/accounts/$old_on_p" | j "print(d.get('moved'))"))"
|
||||
[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$old_on_p" | j "print(d[0]['following'])")" = "True" ] \
|
||||
&& ok "and alice still follows the old account: following the new one is hers to do" || ko "alice's follow of the old account changed"
|
||||
Reference in new issue
Block a user