Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/changelog/migrate-permalink-ids-to-query-param
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: changed

Use a stable Fediverse profile ID that no longer breaks your followers when you change your handle, and migrate existing profiles to it automatically.
46 changes: 46 additions & 0 deletions includes/class-migration.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
use Activitypub\Collection\Inbox;
use Activitypub\Collection\Outbox;
use Activitypub\Collection\Remote_Actors;
use Activitypub\Model\Blog;
use Activitypub\Model\User;
use Activitypub\Transformer\Factory;

/**
Expand Down Expand Up @@ -224,6 +226,9 @@ public static function maybe_migrate() {
self::migrate_application_keypair_option();
self::delete_application_outbox_items();
}
if ( \version_compare( $version_from_db, 'unreleased', '<' ) ) {
self::migrate_permalink_ids_to_query_param();
}

/*
* Defer the flush to late in the `init` cycle (priority 20). Migration::init
Expand Down Expand Up @@ -1394,4 +1399,45 @@ public static function delete_application_outbox_items() {
\wp_delete_post( $item_id, true );
}
}

/**
* Migrate actors from a permalink-based id to the stable query-param id.
*
* A permalink id (`/@handle` for the blog, the author archive URL for a user) changes whenever
* the handle changes, which strands followers. This moves every actor that used one to the
* stable `?author=ID` form: it federates a Move to the old id's audience and keeps the old id
* resolving with a `movedTo`. Actors whose old id already matched the query-param form are
* skipped.
*
* @since unreleased
*/
public static function migrate_permalink_ids_to_query_param() {
if ( \get_option( 'activitypub_use_permalink_as_id_for_blog', false ) ) {
$blog = new Blog();
$old_id = \esc_url_raw( \home_url( '/@' . $blog->get_preferred_username() ) );

if ( $old_id !== $blog->get_id() ) {
Move::internally_by_actor( $blog, $blog, $old_id, $blog->get_id() );
Move::store_retired_permalink( $blog, $old_id );
}
}

$users = \get_users( array( 'capability__in' => array( 'activitypub' ) ) );

foreach ( $users as $wp_user ) {
if ( '1' !== \get_user_option( 'activitypub_use_permalink_as_id', $wp_user->ID ) ) {
continue;
}

$user = new User( $wp_user->ID );
$old_id = \esc_url_raw( \get_author_posts_url( $wp_user->ID ) );

if ( $old_id === $user->get_id() ) {
continue;
}

Move::internally_by_actor( $user, $user, $old_id, $user->get_id() );
Move::store_retired_permalink( $user, $old_id );
}
}
}
123 changes: 100 additions & 23 deletions includes/class-move.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ public static function init() {
}
}
}

// Serve the retired representation (with movedTo) when an actor is fetched via its old
// permalink id. Always registered, since the id migration is not gated by domain moves.
\add_action( 'activitypub_construct_model_actor', array( self::class, 'maybe_initiate_retired_actor' ) );
}

/**
Expand Down Expand Up @@ -151,49 +155,64 @@ public static function internally( $from, $to ) {
return $user;
}

// The old id is the input when it is a URL, otherwise the source's canonical id.
$old_id = \filter_var( $from, FILTER_VALIDATE_URL ) ? $from : $user->get_id();

return self::internally_by_actor( $user, Actors::get_by_various( $to ), $old_id, $to );
}

/**
* Perform an internal Move for already-resolved actors.
*
* Used when the caller already holds the source and target actors and the exact old/new ids,
* rather than a URL that still resolves. The id migration relies on this because the old
* permalink URL no longer resolves once `get_id()` stops emitting it.
*
* @since unreleased
*
* @param User|Blog $source The actor being moved (the old identity).
* @param User|Blog|\WP_Error $target The actor the move points to; may equal the source for a self re-identification.
* @param string $old_id The old actor id (the Move's `object`).
* @param string $new_id The new actor id (the Move's `target`).
*
* @return int|bool|\WP_Error The ID of the outbox item or false or WP_Error on failure.
*/
public static function internally_by_actor( $source, $target, $old_id, $new_id ) {
// Point the old actor at the new one.
if ( $user->get__id() > 0 ) {
\update_user_option( $user->get__id(), 'activitypub_moved_to', $to );
if ( $source->get__id() > 0 ) {
\update_user_option( $source->get__id(), 'activitypub_moved_to', $new_id );
} else {
\update_option( 'activitypub_blog_user_moved_to', $to );
\update_option( 'activitypub_blog_user_moved_to', $new_id );
}

/*
* The old account URL belongs in the *target's* alsoKnownAs, not the source's: receiving
* servers accept the Move only when the new actor links back to the old one. For a domain
* change the source and target resolve to the same actor, so it is still recorded there.
* The old id belongs in the *target's* alsoKnownAs, not the source's: receiving servers
* accept the Move only when the new actor links back to the old one. For a self
* re-identification the source and target are the same actor, so it is recorded there.
*/
$target = Actors::get_by_various( $to );
if ( ! \is_wp_error( $target ) ) {
if ( $target->get__id() > 0 ) {
self::update_user_also_known_as( $target->get__id(), $from );
self::update_user_also_known_as( $target->get__id(), $old_id );
} else {
self::update_blog_also_known_as( $from );
self::update_blog_also_known_as( $old_id );
}
}

// check if `$from` is a URL or an ID.
if ( \filter_var( $from, FILTER_VALIDATE_URL ) ) {
$actor = $from;
} else {
$actor = $user->get_id();
}

$activity = new Activity();
$activity->set_type( 'Move' );
$activity->set_actor( $actor );
$activity->set_origin( $actor );
$activity->set_object( $actor );
$activity->set_target( $to );
$activity->set_actor( $old_id );
$activity->set_origin( $old_id );
$activity->set_object( $old_id );
$activity->set_target( $new_id );

$outbox_id = add_to_outbox( $activity, null, $user->get__id(), ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC );
$outbox_id = add_to_outbox( $activity, null, $source->get__id(), ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC );

/*
* Notify followers of the changed profile on both actors by federating an Update (FEP-7628).
* Queued after the Move so a follower that reacts to `movedTo` still processes the migration first.
*/
Actor_Scheduler::schedule_profile_update( $user->get__id() );
if ( ! \is_wp_error( $target ) && $target->get__id() !== $user->get__id() ) {
Actor_Scheduler::schedule_profile_update( $source->get__id() );
if ( ! \is_wp_error( $target ) && $target->get__id() !== $source->get__id() ) {
Actor_Scheduler::schedule_profile_update( $target->get__id() );
}

Expand Down Expand Up @@ -313,6 +332,64 @@ public static function maybe_initiate_old_user( $instance ) {
}
}

/**
* Store the retired representation of an actor's old permalink id.
*
* Snapshots the actor document with its id set to the old permalink URL, so a later request to
* that URL can serve it. The `movedTo` is not stored here: it is derived at read time from the
* `activitypub_moved_to` option (set by the move) against this old id, exactly as the domain
* move does.
*
* @since unreleased
*
* @param User|Blog $actor The migrated actor (already resolved to its new id).
* @param string $old_id The old permalink id to keep serving.
*/
public static function store_retired_permalink( $actor, $old_id ) {
$data = \json_decode( $actor->to_json(), true );

if ( ! \is_array( $data ) ) {
return;
}

$data['id'] = $old_id;
$json = \wp_json_encode( $data );

if ( $actor->get__id() > 0 ) {
\update_user_option( $actor->get__id(), 'activitypub_retired_permalink_data', $json );
} else {
\update_option( 'activitypub_blog_user_retired_permalink_data', $json );
}
}

/**
* Serve the retired permalink representation when the actor is fetched via its old id.
*
* Mirrors {@see self::maybe_initiate_old_user()}: the model stays unaware of the request, and
* this loads the stored snapshot only when {@see Query::is_permalink_actor_request()} matches.
*
* @since unreleased
*
* @param Blog|User $instance The Blog or User instance to populate.
*/
public static function maybe_initiate_retired_actor( $instance ) {
if ( ! Query::get_instance()->is_permalink_actor_request() ) {
return;
}

if ( $instance instanceof Blog ) {
$data = \get_option( 'activitypub_blog_user_retired_permalink_data' );
} elseif ( $instance instanceof User ) {
$data = \get_user_option( 'activitypub_retired_permalink_data', $instance->get__id() );
} else {
return;
}

if ( ! empty( $data ) ) {
$instance->from_json( $data );
}
}

/**
* Pre-send to inboxes.
*
Expand Down
17 changes: 17 additions & 0 deletions includes/class-query.php
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,23 @@ public function should_negotiate_content() {
return \apply_filters( 'activitypub_should_negotiate_content', $return );
}

/**
* Check if the current request is for an actor's retired permalink id.
*
* After an actor migrates from a permalink-based id to the query-param id, the old URL must
* keep resolving with a `movedTo`. Those URLs carry a distinct query var: the blog `/@handle`
* form sets `actor`, and the pretty author-archive `/author/handle` form sets `author_name`,
* whereas the canonical `?author=ID` sets neither. This lets the model serve the retired
* representation without inspecting the raw request itself.
*
* @since unreleased
*
* @return bool True if the request targets a retired permalink id.
*/
public function is_permalink_actor_request() {
return (bool) \get_query_var( 'actor' ) || (bool) \get_query_var( 'author_name' );
}

/**
* Check if the current request is from the old host.
*
Expand Down
12 changes: 10 additions & 2 deletions includes/collection/class-followers.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

use function Activitypub\get_remote_metadata_by_actor;
use function Activitypub\get_rest_url_by_path;
use function Activitypub\is_same_domain;
use function Activitypub\is_same_host;
use function Activitypub\object_to_uri;

Expand Down Expand Up @@ -409,8 +410,15 @@ public static function get_inboxes( $user_id ) {
*/
public static function get_inboxes_for_activity( $json, $actor_id, $batch_size = 50, $offset = 0 ) {
$activity = \json_decode( $json, true );
// Only if this is a Delete. Create handles its own "Announce" in dual user mode.
if ( 'Delete' === ( $activity['type'] ?? null ) ) {
$type = $activity['type'] ?? null;

/*
* A Delete, and a Move that re-identifies a local actor (an id-format, host, or handle
* change, whose target is on this site), must reach every server that cached the actor, not
* just its followers. A Move to a remote account only needs the followers, who re-follow the
* target per FEP-7628. Create handles its own "Announce" in dual user mode.
*/
if ( 'Delete' === $type || ( 'Move' === $type && is_same_domain( $activity['target'] ?? '' ) ) ) {
$inboxes = Remote_Actors::get_inboxes();
} else {
$inboxes = self::get_inboxes( $actor_id );
Expand Down
6 changes: 0 additions & 6 deletions includes/model/class-blog.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,6 @@ public function get_id() {
return $id;
}

$permalink = \get_option( 'activitypub_use_permalink_as_id_for_blog', false );

if ( $permalink ) {
return \esc_url_raw( \home_url( '/@' . $this->get_preferred_username() ) );
}

return \add_query_arg( 'author', $this->_id, \home_url( '/' ) );
}

Expand Down
6 changes: 0 additions & 6 deletions includes/model/class-user.php
Original file line number Diff line number Diff line change
Expand Up @@ -116,12 +116,6 @@ public function get_id() {
return $id;
}

$permalink = \get_user_option( 'activitypub_use_permalink_as_id', $this->_id );

if ( '1' === $permalink ) {
return $this->get_url();
}

return \add_query_arg( 'author', $this->_id, \home_url( '/' ) );
}

Expand Down
51 changes: 51 additions & 0 deletions tests/phpunit/tests/includes/class-test-migration.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
use Activitypub\Collection\Remote_Actors;
use Activitypub\Comment;
use Activitypub\Migration;
use Activitypub\Model\Blog;
use Activitypub\Move;
use Activitypub\Scheduler;
use Activitypub\Tombstone;

Expand Down Expand Up @@ -1685,4 +1687,53 @@ public function test_migrate_tombstones_to_cpt_halts_on_no_progress() {
$this->assertIsArray( $remaining, 'Legacy option must remain to back exists_local().' );
$this->assertEqualsCanonicalizing( $urls, $remaining );
}

/**
* Migrating a permalink-id blog actor moves it to the query-param id and keeps the old id resolving.
*
* @covers ::migrate_permalink_ids_to_query_param
*/
public function test_migrate_permalink_ids_to_query_param() {
\update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_AND_BLOG_MODE );
\update_option( 'activitypub_use_permalink_as_id_for_blog', '1' );

$blog = new Blog();
$canonical = $blog->get_id();
$old_id = \esc_url_raw( \home_url( '/@' . $blog->get_preferred_username() ) );

$this->assertNotEquals( $old_id, $canonical, 'Precondition: the old permalink id differs from the query-param id.' );

Migration::migrate_permalink_ids_to_query_param();

// The blog now points at the query-param id, and a Move was queued for the old id.
$this->assertEquals( $canonical, \get_option( 'activitypub_blog_user_moved_to' ) );
$moves = \get_posts(
array(
'post_type' => Outbox::POST_TYPE,
'post_status' => 'any',
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
array(
'key' => '_activitypub_activity_type',
'value' => 'Move',
),
),
)
);
$this->assertNotEmpty( $moves, 'A Move should be queued for the migrated blog actor.' );

// Fetched via its old permalink URL, the actor serves the old id with a movedTo, and the
// model never inspects the request to do it.
\set_query_var( 'actor', $blog->get_preferred_username() );
$retired = new Blog();
$this->assertEquals( $old_id, $retired->get_id(), 'The old permalink URL serves the old id.' );
$this->assertEquals( $canonical, $retired->get_moved_to(), 'The old id advertises movedTo to the query-param id.' );

\set_query_var( 'actor', '' );
\delete_option( 'activitypub_actor_mode' );
\delete_option( 'activitypub_use_permalink_as_id_for_blog' );
\delete_option( 'activitypub_blog_user_moved_to' );
\delete_option( 'activitypub_blog_user_retired_permalink_data' );
\delete_option( 'activitypub_blog_user_also_known_as' );
}
}
Loading