Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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/fix-account-move-verification
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: fixed

Fix Fediverse account migration so a move is verified before it takes effect and reliably reaches your followers on other servers.
73 changes: 58 additions & 15 deletions includes/class-move.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Activitypub\Collection\Actors;
use Activitypub\Model\Blog;
use Activitypub\Model\User;
use Activitypub\Scheduler\Actor as Actor_Scheduler;

/**
* ActivityPub (Account) Move Class
Expand Down Expand Up @@ -81,13 +82,6 @@ public static function externally( $from, $to ) {
return $user;
}

// Update the movedTo property.
if ( $user->get__id() > 0 ) {
\update_user_option( $user->get__id(), 'activitypub_moved_to', $to );
} else {
\update_option( 'activitypub_blog_user_moved_to', $to );
}

$response = Http::get_remote_object( $to );

if ( \is_wp_error( $response ) ) {
Expand All @@ -97,21 +91,43 @@ public static function externally( $from, $to ) {
$target_actor = new Actor();
$target_actor->from_array( $response );

// Check if the `Move` Activity is valid.
/*
* The move is only valid if the target links back. Receiving servers accept it only when the
* id we send as the Move's `object` is listed in the target's `alsoKnownAs`, so verify that
* exact id, not the (possibly non-canonical) input URL.
*/
$also_known_as = $target_actor->get_also_known_as() ?? array();
if ( ! \in_array( $from, $also_known_as, true ) ) {
if ( ! \in_array( $user->get_id(), $also_known_as, true ) ) {
return new \WP_Error( 'invalid_target', \__( 'Invalid target', 'activitypub' ) );
}

// Advertise the move only after the target is verified, so a failed attempt never leaves the actor pointing at an unverified target.
if ( $user->get__id() > 0 ) {
\update_user_option( $user->get__id(), 'activitypub_moved_to', $to );
} else {
\update_option( 'activitypub_blog_user_moved_to', $to );
Comment thread
pfefferle marked this conversation as resolved.
Outdated
}

$activity = new Activity();
$activity->set_type( 'Move' );
$activity->set_actor( $user->get_id() );
$activity->set_origin( $user->get_id() );
$activity->set_object( $user->get_id() );
$activity->set_target( $target_actor->get_id() );

// Add to outbox.
return add_to_outbox( $activity, null, $user->get__id(), ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC );
$outbox_id = add_to_outbox( $activity, null, $user->get__id(), ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC );

if ( ! $outbox_id || \is_wp_error( $outbox_id ) ) {
return $outbox_id;
}

/*
* Notify followers of the new movedTo by federating a profile Update (FEP-7628). Queue it
* after the Move so a follower that reacts to `movedTo` still processes the migration first.
*/
Actor_Scheduler::schedule_profile_update( $user->get__id() );

return $outbox_id;
}

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

// Add the old account URL to alsoKnownAs.
// Point the old actor at the new one.
if ( $user->get__id() > 0 ) {
self::update_user_also_known_as( $user->get__id(), $from );
\update_user_option( $user->get__id(), 'activitypub_moved_to', $to );
} else {
self::update_blog_also_known_as( $from );
\update_option( 'activitypub_blog_user_moved_to', $to );
}

/*
* 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.
*/
$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 );
} else {
self::update_blog_also_known_as( $from );
}
}

// check if `$from` is a URL or an ID.
if ( \filter_var( $from, FILTER_VALIDATE_URL ) ) {
$actor = $from;
Expand All @@ -162,7 +190,22 @@ public static function internally( $from, $to ) {
$activity->set_object( $actor );
$activity->set_target( $to );

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

if ( ! $outbox_id || \is_wp_error( $outbox_id ) ) {
return $outbox_id;
}

/*
* 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( $target->get__id() );
}

return $outbox_id;
}

/**
Expand Down
164 changes: 163 additions & 1 deletion tests/phpunit/tests/includes/class-test-move.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
namespace Activitypub\Tests;

use Activitypub\Collection\Actors;
use Activitypub\Collection\Outbox;
use Activitypub\Move;

/**
Expand Down Expand Up @@ -40,11 +41,20 @@ public function test_account_with_valid_input() {
$from = Actors::get_by_id( self::$user_id )->get_id();
$to = 'https://newsite.com/user/1';

add_filter( 'pre_http_request', '__return_false' );
$filter = function () use ( $from ) {
Comment thread
pfefferle marked this conversation as resolved.
Outdated
return array(
'body' => wp_json_encode( array( 'alsoKnownAs' => array( $from ) ) ),
'response' => array( 'code' => 200 ),
);
};
add_filter( 'pre_http_request', $filter );

Move::externally( $from, $to );

$moved_to = Actors::get_by_id( self::$user_id )->get_moved_to();
$this->assertEquals( $to, $moved_to );

remove_filter( 'pre_http_request', $filter );
}

/**
Expand Down Expand Up @@ -81,9 +91,129 @@ public function test_account_with_invalid_target() {
$this->assertWPError( $result );
$this->assertEquals( 'http_request_failed', $result->get_error_code() );

// A move that never verified must not leave the actor pointing at the target.
$this->assertNull( Actors::get_by_id( self::$user_id )->get_moved_to() );

\remove_filter( 'activitypub_pre_http_get_remote_object', $filter );
}

/**
* A verified move federates a profile Update so followers refresh the actor (FEP-7628).
*
* @covers ::externally
*/
public function test_move_federates_profile_update() {
$from = Actors::get_by_id( self::$user_id )->get_id();
$to = 'https://newsite.com/user/1';

$filter = function () use ( $from ) {
return array(
'body' => wp_json_encode( array( 'alsoKnownAs' => array( $from ) ) ),
'response' => array( 'code' => 200 ),
);
};
add_filter( 'pre_http_request', $filter );

Move::externally( $from, $to );

remove_filter( 'pre_http_request', $filter );

$updates = get_posts(
array(
'post_type' => Outbox::POST_TYPE,
'post_status' => 'any',
'author' => self::$user_id,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
array(
'key' => '_activitypub_activity_type',
'value' => 'Update',
),
),
)
);

$this->assertCount( 1, $updates, 'A move should federate exactly one profile Update.' );
}

/**
* When the Move itself is not federated, no follower notification is sent.
*
* @covers ::externally
*/
public function test_move_does_not_notify_followers_when_move_fails() {
$from = Actors::get_by_id( self::$user_id )->get_id();
$to = 'https://newsite.com/user/1';

// Target links back, so verification passes.
$http = function () use ( $from ) {
return array(
'body' => wp_json_encode( array( 'alsoKnownAs' => array( $from ) ) ),
'response' => array( 'code' => 200 ),
);
};
add_filter( 'pre_http_request', $http );

// Fail only the Move's outbox insert; a follow-up profile Update would still succeed.
$fail_move = function ( $maybe_empty, $postarr ) {
$data = json_decode( stripslashes( (string) ( $postarr['post_content'] ?? '' ) ), true );

return ( is_array( $data ) && isset( $data['type'] ) && 'Move' === $data['type'] ) ? true : $maybe_empty;
};
add_filter( 'wp_insert_post_empty_content', $fail_move, 10, 2 );

$result = Move::externally( $from, $to );

remove_filter( 'wp_insert_post_empty_content', $fail_move, 10 );
remove_filter( 'pre_http_request', $http );

$this->assertTrue( empty( $result ) || \is_wp_error( $result ), 'A Move that could not be federated must not return a success id.' );

$updates = get_posts(
array(
'post_type' => Outbox::POST_TYPE,
'post_status' => 'any',
'author' => self::$user_id,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
array(
'key' => '_activitypub_activity_type',
'value' => 'Update',
),
),
)
);

$this->assertEmpty( $updates, 'No profile Update should be federated when the Move was not.' );
}
Comment thread
pfefferle marked this conversation as resolved.

/**
* A target that does not link back is rejected and the actor is not moved.
*
* @covers ::externally
*/
public function test_account_rejects_unlinked_target() {
$from = Actors::get_by_id( self::$user_id )->get_id();
$to = 'https://newsite.com/user/1';

// Target resolves, but its alsoKnownAs does not list this actor.
$filter = function () {
return array(
'body' => wp_json_encode( array( 'alsoKnownAs' => array( 'https://newsite.com/user/999' ) ) ),
'response' => array( 'code' => 200 ),
);
};
\add_filter( 'pre_http_request', $filter );

$result = Move::externally( $from, $to );

$this->assertWPError( $result );
$this->assertEquals( 'invalid_target', $result->get_error_code() );
$this->assertNull( Actors::get_by_id( self::$user_id )->get_moved_to() );

\remove_filter( 'pre_http_request', $filter );
}

/**
* Test the account() method with duplicate moves.
*
Expand Down Expand Up @@ -123,11 +253,20 @@ public function test_account_with_blog_author_as_actor() {
$from = Actors::get_by_id( Actors::BLOG_USER_ID )->get_id();
$to = 'https://newsite.com/user/0';

$filter = function () use ( $from ) {
return array(
'body' => wp_json_encode( array( 'alsoKnownAs' => array( $from ) ) ),
'response' => array( 'code' => 200 ),
);
};
\add_filter( 'pre_http_request', $filter );

Move::externally( $from, $to );

$moved_to = Actors::get_by_id( Actors::BLOG_USER_ID )->get_moved_to();
$this->assertEquals( $to, $moved_to );

\remove_filter( 'pre_http_request', $filter );
\delete_option( 'activitypub_actor_mode' );
}

Expand All @@ -153,6 +292,29 @@ public function test_internally_with_valid_input() {
$this->assertContains( $from, $also_known_as );
}

/**
* An internal move between two different local users links the target back to the source.
*
* @covers ::internally
*/
public function test_internally_between_distinct_users() {
$target_id = self::factory()->user->create( array( 'role' => 'author' ) );

$from = Actors::get_by_id( self::$user_id )->get_id();
$to = Actors::get_by_id( $target_id )->get_id();

Move::internally( $from, $to );

wp_cache_delete( self::$user_id, 'users' );
wp_cache_delete( $target_id, 'users' );

// The source points at the target.
$this->assertEquals( $to, Actors::get_by_id( self::$user_id )->get_moved_to() );

// The target links back to the source via alsoKnownAs, so receiving servers accept the move.
$this->assertContains( $from, Actors::get_by_id( $target_id )->get_also_known_as() );
}

/**
* Test that the Move Activity created by internally() has the correct properties.
*
Expand Down