update( $wpdb->posts, array( 'guid' => \sanitize_url( $target_uri ) ), array( 'ID' => (int) $origin_object->ID ) ); // Clear the cache. \wp_cache_delete( $origin_object->ID, 'posts' ); $success = true; // Proxy::get() already self-confirmed the target, so it is safe to cache. $result = Remote_Actors::upsert( $target_json ); } // If both the target and origin are followed, merge them. if ( ! \is_wp_error( $target_object ) && ! \is_wp_error( $origin_object ) ) { $origin_users = \get_post_meta( $origin_object->ID, Followers::FOLLOWER_META_KEY, false ); $target_users = \get_post_meta( $target_object->ID, Followers::FOLLOWER_META_KEY, false ); // Get all user ids from $origin_users that are not in $target_users. $users = \array_diff( $origin_users, $target_users ); foreach ( $users as $follower_user_id ) { \add_post_meta( $target_object->ID, Followers::FOLLOWER_META_KEY, $follower_user_id ); } // Comments and Reader posts of the origin belong to the target now. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value $where = array( 'meta_key' => '_activitypub_remote_actor_id', 'meta_value' => (string) $origin_object->ID, ); // The meta type and the cache group of its queries, which core names differently. $types = array( 'comment' => 'comment', 'post' => 'posts', ); foreach ( $types as $type => $cache_group ) { $table = $wpdb->{ \sprintf( '%smeta', $type ) }; $ids = $wpdb->get_col( $wpdb->prepare( 'SELECT %i FROM %i WHERE meta_key = %s AND meta_value = %s', \sprintf( '%s_id', $type ), $table, $where['meta_key'], $where['meta_value'] ) ); if ( $ids ) { $wpdb->update( $table, array( 'meta_value' => (string) $target_object->ID ), $where ); \wp_cache_delete_multiple( $ids, \sprintf( '%s_meta', $type ) ); \wp_cache_set_last_changed( $cache_group ); } } // phpcs:enable $success = true; $result = Remote_Actors::delete( $origin_object->ID ); } /** * Fires after an ActivityPub Move activity has been handled. * * @param array $activity The ActivityPub activity data. * @param int[] $user_ids The local user IDs. * @param bool $success True on success, false otherwise. * @param mixed $result The result of the operation (e.g., post ID, WP_Error, or status). */ \do_action( 'activitypub_handled_move', $activity, (array) $user_ids, $success, $result ); } /** * Extract the target from the activity. * * The ActivityStreams spec define the `target` attribute as the * destination of the activity, but Mastodon uses the `object` * attribute to move profiles. * * @param array $activity The JSON "Move" Activity. * * @return string|null The target URI or null if not found. */ private static function extract_target( $activity ) { if ( ! empty( $activity['target'] ) ) { return object_to_uri( $activity['target'] ); } if ( ! empty( $activity['object'] ) ) { return object_to_uri( $activity['object'] ); } return null; } /** * Extract the origin from the activity. * * The ActivityStreams spec define the `origin` attribute as source * of the activity, but Mastodon uses the `actor` attribute as source * to move profiles. * * @param array $activity The JSON "Move" Activity. * * @return string|null The origin URI or null if not found. */ private static function extract_origin( $activity ) { if ( ! empty( $activity['origin'] ) ) { return object_to_uri( $activity['origin'] ); } if ( ! empty( $activity['actor'] ) ) { return object_to_uri( $activity['actor'] ); } return null; } /** * Verify the move. * * @param array $target_object The target object. * @param array $origin_object The origin object. * * @return bool True if the move is verified, false otherwise. */ private static function verify_move( $target_object, $origin_object ) { // Check if both objects are valid. if ( \is_wp_error( $target_object ) || \is_wp_error( $origin_object ) ) { return false; } // Check if both objects are persons. if ( 'Person' !== $target_object['type'] || 'Person' !== $origin_object['type'] ) { return false; } // Check if the target and origin are not the same. if ( $target_object['id'] === $origin_object['id'] ) { return false; } // Normalize alsoKnownAs to a list of ids (some JSON-LD payloads use a string or objects). $also_known_as = \array_map( '\Activitypub\object_to_uri', (array) ( $target_object['alsoKnownAs'] ?? array() ) ); if ( empty( $also_known_as ) ) { return false; } /* * The target must list the origin by its id, which the fetch confirmed. The origin's url and * webfinger are its own claims: matching them would let an actor claim an alias of the * account it pretends to move to. Mastodon compares the id only as well. */ if ( ! \in_array( $origin_object['id'], $also_known_as, true ) ) { return false; } // Check if the origin has a movedTo property. if ( empty( $origin_object['movedTo'] ) ) { return false; } // Check if the movedTo property of the origin is the target. if ( $origin_object['movedTo'] !== $target_object['id'] ) { return false; } return true; } }