diff --git a/CLAUDE.md b/CLAUDE.md index 1071a61..941e333 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/FEDERATION.md b/FEDERATION.md index dc11325..a394a62 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -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}`, diff --git a/PrivaPub.Tests/Federation/MoveTests.cs b/PrivaPub.Tests/Federation/MoveTests.cs new file mode 100644 index 0000000..848a6f1 --- /dev/null +++ b/PrivaPub.Tests/Federation/MoveTests.cs @@ -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 Stored(RemoteActor actor) => + DB.Default.Find().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)); + } + } +} diff --git a/PrivaPub.Tests/Support/Harness.cs b/PrivaPub.Tests/Support/Harness.cs index 2d6ab22..dcd912a 100644 --- a/PrivaPub.Tests/Support/Harness.cs +++ b/PrivaPub.Tests/Support/Harness.cs @@ -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.Instance, Ledger, Local); diff --git a/PrivaPub/Api/Mastodon/Entities/Entities.cs b/PrivaPub/Api/Mastodon/Entities/Entities.cs index 8298152..fefa922 100644 --- a/PrivaPub/Api/Mastodon/Entities/Entities.cs +++ b/PrivaPub/Api/Mastodon/Entities/Entities.cs @@ -32,6 +32,7 @@ public string HeaderDescription { get; set; } = string.Empty; public List 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 diff --git a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs index 9739267..d2021d4 100644 --- a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs +++ b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs @@ -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; } diff --git a/PrivaPub/Federation/Actors/ActorDocument.cs b/PrivaPub/Federation/Actors/ActorDocument.cs index 18825d7..221606e 100644 --- a/PrivaPub/Federation/Actors/ActorDocument.cs +++ b/PrivaPub/Federation/Actors/ActorDocument.cs @@ -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 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 Emojis { get; init; } = new(); public Dictionary 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 Uris(JsonElement value) => (value.ValueKind == JsonValueKind.Array ? value.EnumerateArray().ToList() : new List { 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?), diff --git a/PrivaPub/Federation/Actors/RemoteActorService.cs b/PrivaPub/Federation/Actors/RemoteActorService.cs index 547f60c..80580a5 100644 --- a/PrivaPub/Federation/Actors/RemoteActorService.cs +++ b/PrivaPub/Federation/Actors/RemoteActorService.cs @@ -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) diff --git a/PrivaPub/Federation/Inbox/Handlers/MoveHandler.cs b/PrivaPub/Federation/Inbox/Handlers/MoveHandler.cs new file mode 100644 index 0000000..ae62bf3 --- /dev/null +++ b/PrivaPub/Federation/Inbox/Handlers/MoveHandler.cs @@ -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().MatchID(actor.ID).Modify(a => a.MovedToURL, target).ExecuteAsync(token); + Arrival.Accept("moved"); + } + } +} diff --git a/PrivaPub/Middleware/SocialPubConfigurations.cs b/PrivaPub/Middleware/SocialPubConfigurations.cs index b10cb3b..24227a0 100644 --- a/PrivaPub/Middleware/SocialPubConfigurations.cs +++ b/PrivaPub/Middleware/SocialPubConfigurations.cs @@ -94,6 +94,7 @@ namespace PrivaPub.Middleware .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/PrivaPub/Models/User/Avatar.cs b/PrivaPub/Models/User/Avatar.cs index d27f0a7..e9cf1b5 100644 --- a/PrivaPub/Models/User/Avatar.cs +++ b/PrivaPub/Models/User/Avatar.cs @@ -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 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; diff --git a/docs/INTEROP.md b/docs/INTEROP.md index 6120de0..d86748e 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -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). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7e026a4..bbe8315 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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; diff --git a/tools/pasture/scenarios/moves.sh b/tools/pasture/scenarios/moves.sh new file mode 100644 index 0000000..54aaec7 --- /dev/null +++ b/tools/pasture/scenarios/moves.sh @@ -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 : 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"