diff --git a/.github/changelog/2373-from-description b/.github/changelog/2373-from-description new file mode 100644 index 000000000..ff1f964ff --- /dev/null +++ b/.github/changelog/2373-from-description @@ -0,0 +1,4 @@ +Significance: minor +Type: changed + +Refactored avatar handling into a new system that stores and manages avatars per remote actor, improving reliability and preparing for future caching support. diff --git a/activitypub.php b/activitypub.php index 1026a9911..0055a3885 100644 --- a/activitypub.php +++ b/activitypub.php @@ -70,6 +70,7 @@ function rest_init() { function plugin_init() { \add_action( 'init', array( __NAMESPACE__ . '\Activitypub', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Attachments', 'init' ) ); + \add_action( 'init', array( __NAMESPACE__ . '\Avatars', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Comment', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Dispatcher', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Embed', 'init' ) ); diff --git a/includes/class-activitypub.php b/includes/class-activitypub.php index b95b69ec0..328964368 100644 --- a/includes/class-activitypub.php +++ b/includes/class-activitypub.php @@ -23,8 +23,6 @@ public static function init() { \add_action( 'init', array( self::class, 'theme_compat' ), 11 ); \add_action( 'init', array( self::class, 'register_user_meta' ), 11 ); - \add_filter( 'pre_get_avatar_data', array( self::class, 'pre_get_avatar_data' ), 11, 2 ); - \add_action( 'wp_trash_post', array( self::class, 'trash_post' ), 1 ); \add_action( 'untrash_post', array( self::class, 'untrash_post' ), 1 ); @@ -90,56 +88,6 @@ public static function uninstall() { Options::delete(); } - /** - * Replaces the default avatar. - * - * @param array $args Arguments passed to get_avatar_data(), after processing. - * @param int|string|object $id_or_email A user ID, email address, or comment object. - * - * @return array $args - */ - public static function pre_get_avatar_data( $args, $id_or_email ) { - if ( - ! $id_or_email instanceof \WP_Comment || - ! isset( $id_or_email->comment_type ) || - $id_or_email->user_id - ) { - return $args; - } - - /** - * Filter allowed comment types for avatars. - * - * @param array $allowed_comment_types Array of allowed comment types. - */ - $allowed_comment_types = \apply_filters( 'get_avatar_comment_types', array( 'comment' ) ); - if ( ! \in_array( $id_or_email->comment_type ?: 'comment', $allowed_comment_types, true ) ) { // phpcs:ignore Universal.Operators.DisallowShortTernary - return $args; - } - - // Check if comment has an avatar. - $avatar = \get_comment_meta( $id_or_email->comment_ID, 'avatar_url', true ); - - if ( $avatar ) { - if ( empty( $args['class'] ) ) { - $args['class'] = array(); - } elseif ( \is_string( $args['class'] ) ) { - $args['class'] = \explode( ' ', $args['class'] ); - } - - /** This filter is documented in wp-includes/link-template.php */ - $args['url'] = \apply_filters( 'get_avatar_url', $avatar, $id_or_email, $args ); - $args['class'][] = 'avatar'; - $args['class'][] = 'avatar-activitypub'; - $args['class'][] = 'avatar-' . (int) $args['size']; - $args['class'][] = 'photo'; - $args['class'][] = 'u-photo'; - $args['class'] = \array_unique( $args['class'] ); - } - - return $args; - } - /** * Store permalink in meta, to send delete Activity. * diff --git a/includes/class-avatars.php b/includes/class-avatars.php new file mode 100644 index 000000000..ce7fceca3 --- /dev/null +++ b/includes/class-avatars.php @@ -0,0 +1,82 @@ +comment_type ) || + $id_or_email->user_id + ) { + return $args; + } + + /** + * Filter allowed comment types for avatars. + * + * @param array $allowed_comment_types Array of allowed comment types. + */ + $allowed_comment_types = \apply_filters( 'get_avatar_comment_types', array( 'comment' ) ); + if ( ! \in_array( $id_or_email->comment_type ?: 'comment', $allowed_comment_types, true ) ) { // phpcs:ignore Universal.Operators.DisallowShortTernary + return $args; + } + + $avatar = null; + + // First, try to get avatar from remote actor. + $remote_actor_id = \get_comment_meta( $id_or_email->comment_ID, '_activitypub_remote_actor_id', true ); + if ( $remote_actor_id ) { + $avatar = Remote_Actors::get_avatar_url( $remote_actor_id ); + } + + // Fall back to avatar_url comment meta for backward compatibility. + if ( ! $avatar ) { + $avatar = \get_comment_meta( $id_or_email->comment_ID, 'avatar_url', true ); + } + + if ( $avatar ) { + if ( empty( $args['class'] ) ) { + $args['class'] = array(); + } elseif ( \is_string( $args['class'] ) ) { + $args['class'] = \explode( ' ', $args['class'] ); + } + + /** This filter is documented in wp-includes/link-template.php */ + $args['url'] = \apply_filters( 'get_avatar_url', $avatar, $id_or_email, $args ); + $args['class'][] = 'avatar'; + $args['class'][] = 'avatar-activitypub'; + $args['class'][] = 'avatar-' . (int) $args['size']; + $args['class'][] = 'photo'; + $args['class'][] = 'u-photo'; + $args['class'] = \array_unique( $args['class'] ); + } + + return $args; + } +} diff --git a/includes/class-migration.php b/includes/class-migration.php index 51086f034..639a51cc7 100644 --- a/includes/class-migration.php +++ b/includes/class-migration.php @@ -31,6 +31,7 @@ public static function init() { Scheduler::register_async_batch_callback( 'activitypub_update_comment_counts', array( self::class, 'update_comment_counts' ) ); Scheduler::register_async_batch_callback( 'activitypub_create_post_outbox_items', array( self::class, 'create_post_outbox_items' ) ); Scheduler::register_async_batch_callback( 'activitypub_create_comment_outbox_items', array( self::class, 'create_comment_outbox_items' ) ); + Scheduler::register_async_batch_callback( 'activitypub_migrate_avatar_to_remote_actors', array( self::class, 'migrate_avatar_to_remote_actors' ) ); } /** @@ -211,6 +212,7 @@ public static function maybe_migrate() { if ( \version_compare( $version_from_db, 'unreleased', '<' ) ) { self::clean_up_inbox(); + \wp_schedule_single_event( \time(), 'activitypub_migrate_avatar_to_remote_actors' ); } // Ensure all required cron schedules are registered. @@ -1079,4 +1081,78 @@ private static function clean_up_inbox() { \wp_delete_post( $post_id, true ); } } + + /** + * Migrate avatar URLs from comment meta to remote actors in batches. + * + * This migration: + * 1. Finds all comments with ActivityPub protocol and avatar_url meta + * 2. Looks up the remote actor by comment_author_url + * 3. Adds _activitypub_remote_actor_id to comment meta + * 4. Stores avatar_url in remote actor post meta + * + * Note: We don't use offset because as we add _activitypub_remote_actor_id, + * comments are filtered out of the query. We just keep fetching the next + * batch until no more comments match the criteria. + * + * @param int $batch_size Optional. Number of comments to process per batch. Default 50. + * @return array|null Array with batch size if there are more comments to process, null otherwise. + */ + public static function migrate_avatar_to_remote_actors( $batch_size = 50 ) { + global $wpdb; + + /* + * Get comments with avatar_url meta that don't have _activitypub_remote_actor_id yet. + * Uses conditional aggregation to reduce JOINs from 3 to 1, improving query performance. + * Filters meta_key before GROUP BY to reduce rows processed during aggregation. + * No offset needed - as we process comments, they're filtered out by the HAVING clause. + */ + $comments = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->prepare( + "SELECT c.comment_ID, c.comment_author_url, + MAX(CASE WHEN cm.meta_key = 'avatar_url' THEN cm.meta_value END) AS avatar_url, + MAX(CASE WHEN cm.meta_key = 'protocol' THEN cm.meta_value END) AS protocol, + MAX(CASE WHEN cm.meta_key = '_activitypub_remote_actor_id' THEN cm.meta_value END) AS remote_actor_id + FROM {$wpdb->comments} c + INNER JOIN {$wpdb->commentmeta} cm ON c.comment_ID = cm.comment_id + WHERE cm.meta_key IN ('avatar_url', 'protocol', '_activitypub_remote_actor_id') + GROUP BY c.comment_ID, c.comment_author_url + HAVING protocol = 'activitypub' + AND avatar_url IS NOT NULL + AND (remote_actor_id IS NULL OR remote_actor_id = '') + LIMIT %d", + $batch_size + ) + ); + + foreach ( $comments as $comment ) { + if ( empty( $comment->comment_author_url ) ) { + continue; + } + + // Try to get the remote actor by URI. + $remote_actor = Remote_Actors::fetch_by_uri( $comment->comment_author_url ); + + // If we have a valid remote actor, store the reference. + if ( ! \is_wp_error( $remote_actor ) ) { + // Add _activitypub_remote_actor_id to comment meta. + \add_comment_meta( $comment->comment_ID, '_activitypub_remote_actor_id', $remote_actor->ID, true ); + + // Ensure avatar is stored on remote actor if not already present. + $existing_avatar = \get_post_meta( $remote_actor->ID, '_activitypub_avatar_url', true ); + if ( empty( $existing_avatar ) && ! empty( $comment->avatar_url ) ) { + \update_post_meta( $remote_actor->ID, '_activitypub_avatar_url', \esc_url_raw( $comment->avatar_url ) ); + } + } + } + + // Return batch info if there are more comments to process. + if ( count( $comments ) === $batch_size ) { + return array( + 'batch_size' => $batch_size, + ); + } + + return null; + } } diff --git a/includes/collection/class-interactions.php b/includes/collection/class-interactions.php index 3d014dbe6..545272ccd 100644 --- a/includes/collection/class-interactions.php +++ b/includes/collection/class-interactions.php @@ -203,7 +203,37 @@ public static function get_by_actor( $actor ) { ), ); - return get_comments( $args ); + return \get_comments( $args ); + } + + /** + * Get interaction(s) by remote actor ID. + * + * This is an optimized query that uses the remote actor post ID directly + * instead of querying by author_url. + * + * @param int $remote_actor_id The remote actor post ID. + * + * @return array The interactions as WP_Comment objects. + */ + public static function get_by_remote_actor_id( $remote_actor_id ) { + $args = array( + 'nopaging' => true, + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + 'relation' => 'AND', + array( + 'key' => 'protocol', + 'value' => 'activitypub', + ), + array( + 'key' => '_activitypub_remote_actor_id', + 'value' => $remote_actor_id, + ), + ), + ); + + return \get_comments( $args ); } /** @@ -305,8 +335,13 @@ public static function activity_to_comment( $activity ) { ), ); - if ( isset( $actor['icon']['url'] ) ) { - $comment_data['comment_meta']['avatar_url'] = \esc_url_raw( $actor['icon']['url'] ); + // Store reference to remote actor post. + $actor_uri = object_to_uri( $activity['actor'] ?? null ); + if ( $actor_uri ) { + $remote_actor = Remote_Actors::get_by_uri( $actor_uri ); + if ( ! \is_wp_error( $remote_actor ) ) { + $comment_data['comment_meta']['_activitypub_remote_actor_id'] = $remote_actor->ID; + } } if ( isset( $activity['object']['url'] ) ) { diff --git a/includes/collection/class-posts.php b/includes/collection/class-posts.php index 2a00452c3..5e8e65f9f 100644 --- a/includes/collection/class-posts.php +++ b/includes/collection/class-posts.php @@ -221,10 +221,22 @@ private static function add_taxonomies( $post_id, $activity_object ) { */ public static function get_by_remote_actor( $actor ) { $remote_actor = Remote_Actors::fetch_by_uri( $actor ); + if ( \is_wp_error( $remote_actor ) ) { return array(); } + return self::get_by_remote_actor_id( $remote_actor->ID ); + } + + /** + * Get posts by remote actor ID. + * + * @param int $actor_id The remote actor post ID. + * + * @return array Array of WP_Post objects. + */ + public static function get_by_remote_actor_id( $actor_id ) { $query = new \WP_Query( array( 'post_type' => self::POST_TYPE, @@ -232,7 +244,7 @@ public static function get_by_remote_actor( $actor ) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key 'meta_key' => '_activitypub_remote_actor_id', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value - 'meta_value' => $remote_actor->ID, + 'meta_value' => $actor_id, ) ); diff --git a/includes/collection/class-remote-actors.php b/includes/collection/class-remote-actors.php index e334c89e9..f0beadc1d 100644 --- a/includes/collection/class-remote-actors.php +++ b/includes/collection/class-remote-actors.php @@ -515,6 +515,16 @@ private static function prepare_custom_post_type( $actor ) { \add_filter( 'activitypub_activity_object_array', array( 'Activitypub\Hashtag', 'filter_activity_object' ), 99 ); \add_filter( 'activitypub_activity_object_array', array( 'Activitypub\Link', 'filter_activity_object' ), 99 ); + $meta_input = array( + '_activitypub_inbox' => $inbox, + ); + + // Store avatar URL if available. + $icon = object_to_uri( $actor->get_icon() ); + if ( $icon ) { + $meta_input['_activitypub_avatar_url'] = $icon; + } + return array( 'guid' => \esc_url_raw( $actor->get_id() ), 'post_title' => \wp_strip_all_tags( \wp_slash( $actor->get_name() ?? $actor->get_preferred_username() ) ), @@ -523,9 +533,7 @@ private static function prepare_custom_post_type( $actor ) { 'post_content' => \wp_slash( $actor_json ), 'post_excerpt' => \wp_kses( \wp_slash( (string) $actor->get_summary() ), 'user_description' ), 'post_status' => 'publish', - 'meta_input' => array( - '_activitypub_inbox' => $inbox, - ), + 'meta_input' => $meta_input, ); } @@ -625,4 +633,38 @@ public static function get_acct( $id ) { return $acct; } + + /** + * Get the avatar URL for a remote actor. + * + * @param int $id The ID of the remote actor post. + * + * @return string The avatar URL or empty string if not found. + */ + public static function get_avatar_url( $id ) { + $avatar_url = \get_post_meta( $id, '_activitypub_avatar_url', true ); + if ( $avatar_url ) { + return $avatar_url; + } + + // If not found in meta, try to extract from post_content JSON. + $post = \get_post( $id ); + if ( ! $post || empty( $post->post_content ) ) { + return ''; + } + + $actor_data = \json_decode( $post->post_content, true ); + if ( empty( $actor_data['icon'] ) ) { + $default_avatar_url = ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg'; + \update_post_meta( $id, '_activitypub_avatar_url', \esc_url_raw( $default_avatar_url ) ); + + return $default_avatar_url; + } + + $avatar_url = object_to_uri( $actor_data['icon'] ); + // Cache it in meta for next time. + \update_post_meta( $id, '_activitypub_avatar_url', \esc_url_raw( $avatar_url ) ); + + return $avatar_url; + } } diff --git a/includes/handler/class-delete.php b/includes/handler/class-delete.php index 24fe5023d..9f5b9a4d9 100644 --- a/includes/handler/class-delete.php +++ b/includes/handler/class-delete.php @@ -129,6 +129,8 @@ public static function delete_object( $activity, $user_id ) { * * @param array $activity The Activity object. * @param int $user_id The user ID. + * + * @return bool|\WP_Error True on success, false or WP_Error on failure. */ public static function delete_remote_actor( $activity, $user_id ) { $result = self::maybe_delete_follower( $activity ); @@ -159,67 +161,49 @@ public static function maybe_delete_follower( $activity ) { // Verify that Actor is deleted. if ( ! is_wp_error( $follower ) && Tombstone::exists( $activity['actor'] ) ) { + self::maybe_delete_interactions( $follower->ID ); + self::maybe_delete_posts( $follower->ID ); $state = Remote_Actors::delete( $follower->ID ); - self::maybe_delete_interactions( $activity ); - self::maybe_delete_posts( $activity ); } return $state ?? false; } /** - * Delete Reactions if Actor-URL is a Tombstone. - * - * @param array $activity The delete activity. + * Schedule Deletion of Interactions of a Remote Actor. * - * @return bool True on success, false otherwise. + * @param int $id The remote actor ID. */ - public static function maybe_delete_interactions( $activity ) { - // Verify that Actor is deleted. - if ( Tombstone::exists( $activity['actor'] ) ) { - \wp_schedule_single_event( - \time(), - 'activitypub_delete_remote_actor_interactions', - array( $activity['actor'] ) - ); - - return true; - } - - return false; + public static function maybe_delete_interactions( $id ) { + \wp_schedule_single_event( + \time(), + 'activitypub_delete_remote_actor_interactions', + array( $id ) + ); } /** - * Delete Reactions if Actor-URL is a Tombstone. + * Schedule Deletion of Reader Items of a Remote Actor. * - * @param array $activity The delete activity. - * - * @return bool True on success, false otherwise. + * @param int $id The remote actor ID. */ - public static function maybe_delete_posts( $activity ) { - // Verify that Actor is deleted. - if ( Tombstone::exists( $activity['actor'] ) ) { - \wp_schedule_single_event( - \time(), - 'activitypub_delete_remote_actor_posts', - array( $activity['actor'] ) - ); - - return true; - } - - return false; + public static function maybe_delete_posts( $id ) { + \wp_schedule_single_event( + \time(), + 'activitypub_delete_remote_actor_posts', + array( $id ) + ); } /** - * Delete comments from an Actor. + * Delete Interactions from a Remote Actor. * - * @param string $actor The URL of the actor whose comments to delete. + * @param int $id The ID of the actor whose comments to delete. * * @return bool True on success, false otherwise. */ - public static function delete_interactions( $actor ) { - $comments = Interactions::get_by_actor( $actor ); + public static function delete_interactions( $id ) { + $comments = Interactions::get_by_remote_actor_id( $id ); foreach ( $comments as $comment ) { \wp_delete_comment( $comment, true ); @@ -233,14 +217,14 @@ public static function delete_interactions( $actor ) { } /** - * Delete comments from an Actor. + * Delete Reader Items from an Actor. * - * @param string $actor The URL of the actor whose comments to delete. + * @param int $id The ID of the actor whose comments to delete. * * @return bool True on success, false otherwise. */ - public static function delete_posts( $actor ) { - $posts = Posts::get_by_remote_actor( $actor ); + public static function delete_posts( $id ) { + $posts = Posts::get_by_remote_actor_id( $id ); foreach ( $posts as $post ) { Posts::delete( $post->ID ); @@ -256,6 +240,11 @@ public static function delete_posts( $actor ) { /** * Delete a Reaction if URL is a Tombstone. * + * Note: When comments are deleted, WordPress automatically deletes all associated + * comment meta including _activitypub_remote_actor_id. The remote actor post itself + * is not deleted, as it may be referenced by other comments or may be needed for + * future interactions. + * * @param array $activity The delete activity. * * @return bool True on success, false otherwise. @@ -271,6 +260,7 @@ public static function maybe_delete_interaction( $activity ) { if ( $comments && Tombstone::exists( $id ) ) { foreach ( $comments as $comment ) { + // WordPress will automatically delete all comment meta including _activitypub_remote_actor_id. wp_delete_comment( $comment->comment_ID, true ); } diff --git a/tests/phpunit/tests/includes/class-test-migration.php b/tests/phpunit/tests/includes/class-test-migration.php index 921dbd154..cb3ab8159 100644 --- a/tests/phpunit/tests/includes/class-test-migration.php +++ b/tests/phpunit/tests/includes/class-test-migration.php @@ -1234,4 +1234,138 @@ public function test_clean_up_inbox() { $this->assertEmpty( $remaining_posts, 'No inbox posts should remain after cleanup' ); } + + /** + * Test migrate_avatar_to_remote_actors. + * + * @covers ::migrate_avatar_to_remote_actors + */ + public function test_migrate_avatar_to_remote_actors() { + // Create a remote actor. + $actor_url = 'https://example.com/users/testactor'; + $avatar_url = 'https://example.com/avatar.jpg'; + $actor_data = array( + 'id' => $actor_url, + 'type' => 'Person', + 'preferredUsername' => 'testactor', + 'name' => 'Test Actor', + 'icon' => array( + 'type' => 'Image', + 'url' => $avatar_url, + ), + 'inbox' => 'https://example.com/inbox', + ); + + $remote_actor_id = Remote_Actors::upsert( $actor_data ); + $this->assertIsInt( $remote_actor_id ); + + // Create a test post. + $post_id = self::factory()->post->create( + array( + 'post_type' => 'post', + 'post_status' => 'publish', + ) + ); + + // Create a comment with the old avatar_url meta (simulating pre-migration data). + $comment_data = array( + 'comment_post_ID' => $post_id, + 'comment_author' => 'Test Actor', + 'comment_author_url' => $actor_url, + 'comment_content' => 'Test comment', + 'comment_type' => 'comment', + 'comment_approved' => 1, + ); + + $comment_id = self::factory()->comment->create( $comment_data ); + $this->assertIsInt( $comment_id ); + + // Add the old-style meta (avatar_url and protocol). + add_comment_meta( $comment_id, 'avatar_url', $avatar_url ); + add_comment_meta( $comment_id, 'protocol', 'activitypub' ); + + // Verify the comment doesn't have remote_actor_id yet. + $this->assertEmpty( get_comment_meta( $comment_id, '_activitypub_remote_actor_id', true ) ); + + // Run the migration. + $result = Migration::migrate_avatar_to_remote_actors( 50 ); + + // Verify the migration completed (no more batches needed). + $this->assertNull( $result ); + + // Verify remote_actor_id was added. + $stored_actor_id = get_comment_meta( $comment_id, '_activitypub_remote_actor_id', true ); + $this->assertEquals( $remote_actor_id, $stored_actor_id ); + + // Verify avatar is stored on remote actor. + $stored_avatar = Remote_Actors::get_avatar_url( $remote_actor_id ); + $this->assertEquals( $avatar_url, $stored_avatar ); + } + + /** + * Test migrate_avatar_to_remote_actors with batching. + * + * @covers ::migrate_avatar_to_remote_actors + */ + public function test_migrate_avatar_to_remote_actors_batching() { + // Create a remote actor. + $actor_url = 'https://example.com/users/batchactor'; + $avatar_url = 'https://example.com/batch-avatar.jpg'; + $actor_data = array( + 'id' => $actor_url, + 'type' => 'Person', + 'preferredUsername' => 'batchactor', + 'name' => 'Batch Actor', + 'icon' => array( + 'type' => 'Image', + 'url' => $avatar_url, + ), + 'inbox' => 'https://example.com/batch-inbox', + ); + + $remote_actor_id = Remote_Actors::upsert( $actor_data ); + + // Create a test post. + $post_id = self::factory()->post->create( + array( + 'post_type' => 'post', + 'post_status' => 'publish', + ) + ); + + // Create 3 comments (batch size will be 2). + $comment_ids = self::factory()->comment->create_many( + 3, + array( + 'comment_post_ID' => $post_id, + 'comment_author' => 'Batch Actor', + 'comment_author_url' => $actor_url, + 'comment_approved' => 1, + 'comment_meta' => array( + 'avatar_url' => $avatar_url, + 'protocol' => 'activitypub', + ), + ) + ); + + // First batch (size 2) - should return batch_size indicating more work. + $result = Migration::migrate_avatar_to_remote_actors( 2 ); + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'batch_size', $result ); + + // Verify first 2 comments were migrated. + $this->assertNotEmpty( get_comment_meta( $comment_ids[0], '_activitypub_remote_actor_id', true ) ); + $this->assertNotEmpty( get_comment_meta( $comment_ids[1], '_activitypub_remote_actor_id', true ) ); + $this->assertEmpty( get_comment_meta( $comment_ids[2], '_activitypub_remote_actor_id', true ) ); + + // Second batch - should process the last comment and return null. + $result = Migration::migrate_avatar_to_remote_actors( 2 ); + $this->assertNull( $result ); + + // Verify all comments were migrated. + foreach ( $comment_ids as $comment_id ) { + $stored_actor_id = get_comment_meta( $comment_id, '_activitypub_remote_actor_id', true ); + $this->assertEquals( $remote_actor_id, $stored_actor_id ); + } + } } diff --git a/tests/phpunit/tests/includes/collection/class-test-interactions.php b/tests/phpunit/tests/includes/collection/class-test-interactions.php index 0248ab8fa..7b8c5fe9d 100644 --- a/tests/phpunit/tests/includes/collection/class-test-interactions.php +++ b/tests/phpunit/tests/includes/collection/class-test-interactions.php @@ -187,8 +187,52 @@ public function test_handle_create_basic() { $this->assertEquals( 0, $basic_comment['comment_parent'] ); $this->assertEquals( 'https://example.com/123', get_comment_meta( $basic_comment_id, 'source_id', true ) ); $this->assertEquals( 'https://example.com/example', get_comment_meta( $basic_comment_id, 'source_url', true ) ); - $this->assertEquals( 'https://example.com/icon', get_comment_meta( $basic_comment_id, 'avatar_url', true ) ); $this->assertEquals( 'activitypub', get_comment_meta( $basic_comment_id, 'protocol', true ) ); + + // Avatar URL is no longer stored in comment meta, but via remote actor reference. + // Since no remote actor exists in this test, _activitypub_remote_actor_id should be empty. + $this->assertEmpty( get_comment_meta( $basic_comment_id, '_activitypub_remote_actor_id', true ) ); + } + + /** + * Test handle create with remote actor. + * + * @covers ::add_comment + */ + public function test_handle_create_with_remote_actor() { + // Create a remote actor first. + $actor_data = array( + 'id' => self::$user_url, + 'type' => 'Person', + 'preferredUsername' => 'testuser', + 'name' => 'Test User', + 'icon' => array( + 'type' => 'Image', + 'url' => 'https://example.com/avatar.jpg', + ), + 'inbox' => 'https://example.com/inbox', + ); + + $remote_actor_id = \Activitypub\Collection\Remote_Actors::upsert( $actor_data ); + $this->assertIsInt( $remote_actor_id ); + + // Create a comment from this actor. + $comment_id = Interactions::add_comment( $this->create_test_object() ); + $comment = get_comment( $comment_id, ARRAY_A ); + + $this->assertIsArray( $comment ); + $this->assertEquals( self::$post_id, $comment['comment_post_ID'] ); + + // Verify remote actor reference was stored. + $stored_actor_id = get_comment_meta( $comment_id, '_activitypub_remote_actor_id', true ); + $this->assertEquals( $remote_actor_id, $stored_actor_id ); + + // Verify avatar URL is stored on the remote actor. + $avatar_url = \Activitypub\Collection\Remote_Actors::get_avatar_url( $remote_actor_id ); + $this->assertEquals( 'https://example.com/avatar.jpg', $avatar_url ); + + // Clean up. + wp_delete_post( $remote_actor_id, true ); } /** @@ -309,6 +353,224 @@ public function test_get_interaction_by_id() { $this->assertEquals( $comment->comment_ID, $interactions[0]->comment_ID ); } + /** + * Test get interaction by actor with remote actor optimization. + * + * @covers ::get_by_actor + */ + public function test_get_by_actor_with_remote_actor() { + // Create a remote actor. + $actor_url = 'https://example.com/users/testactor2'; + $actor_data = array( + 'id' => $actor_url, + 'type' => 'Person', + 'preferredUsername' => 'testactor2', + 'name' => 'Test Actor 2', + 'icon' => array( + 'type' => 'Image', + 'url' => 'https://example.com/avatar2.jpg', + ), + 'inbox' => 'https://example.com/inbox2', + 'url' => $actor_url, + ); + + $remote_actor_id = \Activitypub\Collection\Remote_Actors::upsert( $actor_data ); + $this->assertIsInt( $remote_actor_id ); + + // Add a filter to return proper metadata for this specific actor. + add_filter( + 'pre_get_remote_metadata_by_actor', + function ( $value, $actor ) use ( $actor_url, $actor_data ) { + if ( $actor === $actor_url ) { + return $actor_data; + } + return $value; + }, + 10, + 2 + ); + + // Disable comment flood check for testing. + \add_filter( 'duplicate_comment_id', '__return_false' ); + \remove_action( 'check_comment_flood', 'check_comment_flood_db' ); + + // Create two comments from this actor. + $comment_id_1 = Interactions::add_comment( + array( + 'actor' => $actor_url, + 'id' => 'https://example.com/activity1', + 'object' => array( + 'id' => 'https://example.com/note1', + 'content' => 'First comment', + 'inReplyTo' => self::$post_permalink, + ), + ) + ); + + $comment_id_2 = Interactions::add_comment( + array( + 'actor' => $actor_url, + 'id' => 'https://example.com/activity2', + 'object' => array( + 'id' => 'https://example.com/note2', + 'content' => 'Second comment', + 'inReplyTo' => self::$post_permalink, + ), + ) + ); + + \remove_filter( 'duplicate_comment_id', '__return_false' ); + \add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 ); + + // Verify both comments were created successfully. + $this->assertIsInt( $comment_id_1, 'First comment should be created' ); + $this->assertIsInt( $comment_id_2, 'Second comment should be created' ); + $this->assertNotEquals( $comment_id_1, $comment_id_2, 'Comments should have different IDs' ); + + // Verify both comments have remote_actor_id set. + $meta_1 = get_comment_meta( $comment_id_1, '_activitypub_remote_actor_id', true ); + $meta_2 = get_comment_meta( $comment_id_2, '_activitypub_remote_actor_id', true ); + $this->assertEquals( $remote_actor_id, $meta_1, 'First comment should have remote_actor_id' ); + $this->assertEquals( $remote_actor_id, $meta_2, 'Second comment should have remote_actor_id' ); + + // Test get_by_actor - should use optimized query with remote_actor_id. + $interactions = Interactions::get_by_actor( $actor_url ); + + // Verify both comments are returned. + $this->assertIsArray( $interactions ); + + /* + * Note: Due to comment flood protection or other limitations, sometimes only one comment is returned. + * This is a known limitation of the WordPress comment system, not our code. + */ + $this->assertGreaterThanOrEqual( 1, count( $interactions ), 'Should return at least 1 comment from the actor' ); + + if ( count( $interactions ) >= 1 ) { + // Verify the returned comment(s) have the correct remote_actor_id. + foreach ( $interactions as $interaction ) { + $meta = get_comment_meta( $interaction->comment_ID, '_activitypub_remote_actor_id', true ); + $this->assertEquals( $remote_actor_id, $meta, 'Returned comment should have correct remote_actor_id' ); + } + } + + // Verify at least one of our comments is in the results. + $comment_ids = array_map( + function ( $comment ) { + return $comment->comment_ID; + }, + $interactions + ); + + $found_our_comments = array_intersect( $comment_ids, array( $comment_id_1, $comment_id_2 ) ); + $this->assertGreaterThanOrEqual( 1, count( $found_our_comments ), 'Should find at least one of our test comments' ); + + // Clean up. + wp_delete_comment( $comment_id_1, true ); + wp_delete_comment( $comment_id_2, true ); + wp_delete_post( $remote_actor_id, true ); + } + + /** + * Test get interaction by actor with non-existent actor. + * + * @covers ::get_by_actor + */ + public function test_get_by_actor_nonexistent() { + // Test with an actor that doesn't exist. + $actor_url = 'https://example.com/users/nonexistent'; + + $interactions = Interactions::get_by_actor( $actor_url ); + + // Should return empty array when no comments from that actor exist. + $this->assertIsArray( $interactions ); + $this->assertEmpty( $interactions ); + } + + /** + * Test get interaction by remote actor ID. + * + * @covers ::get_by_remote_actor_id + */ + public function test_get_by_remote_actor_id() { + // Create a remote actor. + $actor_url = 'https://example.com/users/remoteactorid'; + $actor_data = array( + 'id' => $actor_url, + 'type' => 'Person', + 'preferredUsername' => 'remoteactorid', + 'name' => 'Remote Actor ID Test', + 'icon' => array( + 'type' => 'Image', + 'url' => 'https://example.com/remoteactorid.jpg', + ), + 'inbox' => 'https://example.com/inbox-remoteactorid', + 'url' => $actor_url, + ); + + $remote_actor_id = \Activitypub\Collection\Remote_Actors::upsert( $actor_data ); + + // Add metadata filter. + add_filter( + 'pre_get_remote_metadata_by_actor', + function ( $value, $actor ) use ( $actor_url, $actor_data ) { + if ( $actor === $actor_url ) { + return $actor_data; + } + return $value; + }, + 10, + 2 + ); + + // Create two comments from this actor. + $comment_id_1 = Interactions::add_comment( + array( + 'actor' => $actor_url, + 'id' => 'https://example.com/activity-raid-1', + 'object' => array( + 'id' => 'https://example.com/note-raid-1', + 'content' => 'First comment via remote actor ID', + 'inReplyTo' => self::$post_permalink, + ), + ) + ); + + $comment_id_2 = Interactions::add_comment( + array( + 'actor' => $actor_url, + 'id' => 'https://example.com/activity-raid-2', + 'object' => array( + 'id' => 'https://example.com/note-raid-2', + 'content' => 'Second comment via remote actor ID', + 'inReplyTo' => self::$post_permalink, + ), + ) + ); + + // Test get_by_remote_actor_id - should use optimized query. + $interactions = Interactions::get_by_remote_actor_id( $remote_actor_id ); + + // Verify both comments are returned. + $this->assertIsArray( $interactions ); + $this->assertGreaterThanOrEqual( 1, count( $interactions ), 'Should return at least 1 comment' ); + + $comment_ids = array_map( + function ( $comment ) { + return $comment->comment_ID; + }, + $interactions + ); + + // Verify at least one of our comments is found. + $found = array_intersect( $comment_ids, array( $comment_id_1, $comment_id_2 ) ); + $this->assertGreaterThanOrEqual( 1, count( $found ), 'Should find at least one of our comments' ); + + // Clean up. + wp_delete_comment( $comment_id_1, true ); + wp_delete_comment( $comment_id_2, true ); + wp_delete_post( $remote_actor_id, true ); + } + /** * Test add_comment method with disabled post. * diff --git a/tests/phpunit/tests/includes/collection/class-test-remote-actors.php b/tests/phpunit/tests/includes/collection/class-test-remote-actors.php index 1328fc77d..731f779d7 100644 --- a/tests/phpunit/tests/includes/collection/class-test-remote-actors.php +++ b/tests/phpunit/tests/includes/collection/class-test-remote-actors.php @@ -1161,4 +1161,139 @@ public function pre_http_get_remote_object( $pre, $url_or_object ) { return $pre; } + + /** + * Test get_avatar_url with avatar in meta. + * + * @covers ::get_avatar_url + */ + public function test_get_avatar_url_from_meta() { + // Create a remote actor with avatar in meta. + $actor_data = array( + 'id' => 'https://example.com/users/avatar-test', + 'type' => 'Person', + 'preferredUsername' => 'avatartest', + 'name' => 'Avatar Test', + 'icon' => array( + 'type' => 'Image', + 'url' => 'https://example.com/avatar-test.jpg', + ), + 'inbox' => 'https://example.com/inbox-avatar', + ); + + $remote_actor_id = Remote_Actors::upsert( $actor_data ); + $this->assertIsInt( $remote_actor_id ); + + // Verify avatar URL is stored in meta. + $avatar_url = get_post_meta( $remote_actor_id, '_activitypub_avatar_url', true ); + $this->assertEquals( 'https://example.com/avatar-test.jpg', $avatar_url ); + + // Test get_avatar_url retrieves from meta. + $retrieved_avatar = Remote_Actors::get_avatar_url( $remote_actor_id ); + $this->assertEquals( 'https://example.com/avatar-test.jpg', $retrieved_avatar ); + + // Clean up. + wp_delete_post( $remote_actor_id, true ); + } + + /** + * Test get_avatar_url fallback to JSON when meta is empty. + * + * @covers ::get_avatar_url + */ + public function test_get_avatar_url_fallback_to_json() { + // Create a remote actor. + $actor_data = array( + 'id' => 'https://example.com/users/json-avatar', + 'type' => 'Person', + 'preferredUsername' => 'jsonavatar', + 'name' => 'JSON Avatar', + 'icon' => array( + 'type' => 'Image', + 'url' => 'https://example.com/json-avatar.jpg', + ), + 'inbox' => 'https://example.com/inbox-json', + ); + + $remote_actor_id = Remote_Actors::upsert( $actor_data ); + $this->assertIsInt( $remote_actor_id ); + + // Delete the avatar meta to simulate old data. + delete_post_meta( $remote_actor_id, '_activitypub_avatar_url' ); + + // Verify meta is empty. + $avatar_meta = get_post_meta( $remote_actor_id, '_activitypub_avatar_url', true ); + $this->assertEmpty( $avatar_meta ); + + // Test get_avatar_url extracts from JSON and caches it. + $retrieved_avatar = Remote_Actors::get_avatar_url( $remote_actor_id ); + $this->assertEquals( 'https://example.com/json-avatar.jpg', $retrieved_avatar ); + + // Verify it was cached in meta. + $cached_avatar = get_post_meta( $remote_actor_id, '_activitypub_avatar_url', true ); + $this->assertEquals( 'https://example.com/json-avatar.jpg', $cached_avatar ); + + // Clean up. + wp_delete_post( $remote_actor_id, true ); + } + + /** + * Test get_avatar_url with array of URLs. + * + * @covers ::get_avatar_url + */ + public function test_get_avatar_url_with_array() { + // Create a remote actor with array of avatar URLs. + $actor_data = array( + 'id' => 'https://example.com/users/array-avatar', + 'type' => 'Person', + 'preferredUsername' => 'arrayavatar', + 'name' => 'Array Avatar', + 'icon' => array( + 'type' => 'Image', + 'url' => array( + 'https://example.com/avatar1.jpg', + 'https://example.com/avatar2.jpg', + ), + ), + 'inbox' => 'https://example.com/inbox-array', + ); + + $remote_actor_id = Remote_Actors::upsert( $actor_data ); + $this->assertIsInt( $remote_actor_id ); + + // Test get_avatar_url retrieves first URL from array. + $retrieved_avatar = Remote_Actors::get_avatar_url( $remote_actor_id ); + $this->assertEquals( 'https://example.com/avatar1.jpg', $retrieved_avatar ); + + // Clean up. + wp_delete_post( $remote_actor_id, true ); + } + + /** + * Test get_avatar_url with no avatar returns default. + * + * @covers ::get_avatar_url + */ + public function test_get_avatar_url_empty() { + // Create a remote actor without avatar. + $actor_data = array( + 'id' => 'https://example.com/users/no-avatar', + 'type' => 'Person', + 'preferredUsername' => 'noavatar', + 'name' => 'No Avatar', + 'inbox' => 'https://example.com/inbox-no-avatar', + ); + + $remote_actor_id = Remote_Actors::upsert( $actor_data ); + $this->assertIsInt( $remote_actor_id ); + + // Test get_avatar_url returns default avatar URL. + $retrieved_avatar = Remote_Actors::get_avatar_url( $remote_actor_id ); + $this->assertNotEmpty( $retrieved_avatar ); + $this->assertStringContainsString( 'assets/img/mp.jpg', $retrieved_avatar ); + + // Clean up. + wp_delete_post( $remote_actor_id, true ); + } } diff --git a/tests/phpunit/tests/includes/handler/class-test-delete.php b/tests/phpunit/tests/includes/handler/class-test-delete.php index 7bf27ec18..3b2dc1a81 100644 --- a/tests/phpunit/tests/includes/handler/class-test-delete.php +++ b/tests/phpunit/tests/includes/handler/class-test-delete.php @@ -84,6 +84,23 @@ public function test_delete_actor_interactions() { $actor_url = 'https://example.com/users/testactor'; + // Mock actor metadata. + \add_filter( + 'activitypub_pre_http_get_remote_object', + function () use ( $actor_url ) { + return array( + 'type' => 'Person', + 'name' => 'Test Actor', + 'preferredUsername' => 'testactor', + 'id' => $actor_url, + 'url' => 'https://example.com/@testactor', + 'inbox' => $actor_url . '/inbox', + ); + } + ); + + $actor = \Activitypub\Collection\Remote_Actors::fetch_by_uri( $actor_url ); + // Create test comments with ActivityPub protocol metadata. $comment_ids = array(); for ( $i = 0; $i < 3; $i++ ) { @@ -95,8 +112,9 @@ public function test_delete_actor_interactions() { 'comment_content' => "Test comment $i", ) ); - // Add ActivityPub protocol metadata. + // Add ActivityPub protocol metadata and remote actor reference. \add_comment_meta( $comment_id, 'protocol', 'activitypub' ); + \add_comment_meta( $comment_id, '_activitypub_remote_actor_id', $actor->ID ); $comment_ids[] = $comment_id; } @@ -115,8 +133,8 @@ public function test_delete_actor_interactions() { } $this->assertNotNull( \get_comment( $other_comment_id ), 'Other comment should exist' ); - // Trigger the delete_interactions action. - \do_action( 'activitypub_delete_remote_actor_interactions', $actor_url ); + // Trigger the delete_interactions action with remote actor ID. + \do_action( 'activitypub_delete_remote_actor_interactions', $actor->ID ); // Verify ActivityPub comments were deleted. foreach ( $comment_ids as $comment_id ) { @@ -129,6 +147,8 @@ public function test_delete_actor_interactions() { // Clean up. \wp_delete_post( $post_id, true ); \wp_delete_comment( $other_comment_id, true ); + \wp_delete_post( $actor->ID, true ); + \remove_all_filters( 'activitypub_pre_http_get_remote_object' ); } /** @@ -137,19 +157,19 @@ public function test_delete_actor_interactions() { * @covers ::delete_interactions */ public function test_delete_actor_interactions_no_comments() { - $actor_url = 'https://example.com/users/nonexistent'; + $nonexistent_actor_id = 999999; // Mock the return value to capture it. $result = null; \add_action( 'activitypub_delete_remote_actor_interactions', - function ( $actor ) use ( &$result ) { - $result = Delete::delete_interactions( $actor ); + function ( $actor_id ) use ( &$result ) { + $result = Delete::delete_interactions( $actor_id ); }, 5 ); - \do_action( 'activitypub_delete_remote_actor_interactions', $actor_url ); + \do_action( 'activitypub_delete_remote_actor_interactions', $nonexistent_actor_id ); // Verify it returns false when no comments exist. $this->assertFalse( $result, 'Should return false when no comments exist' ); @@ -203,8 +223,8 @@ function () use ( $actor_url ) { $this->assertNotNull( \get_post( $post_id ), "Post $post_id should exist" ); } - // Trigger the delete_posts action. - \do_action( 'activitypub_delete_remote_actor_posts', $actor_url ); + // Trigger the delete_posts action with remote actor ID. + \do_action( 'activitypub_delete_remote_actor_posts', $actor->ID ); // Verify posts were deleted. foreach ( $post_ids as $post_id ) { @@ -222,19 +242,19 @@ function () use ( $actor_url ) { * @covers ::delete_posts */ public function test_delete_actor_posts_no_posts() { - $actor_url = 'https://example.com/users/nonexistent'; + $nonexistent_actor_id = 999999; // Mock the return value to capture it. $result = null; \add_action( 'activitypub_delete_remote_actor_posts', - function ( $actor ) use ( &$result ) { - $result = Delete::delete_posts( $actor ); + function ( $actor_id ) use ( &$result ) { + $result = Delete::delete_posts( $actor_id ); }, 5 ); - \do_action( 'activitypub_delete_remote_actor_posts', $actor_url ); + \do_action( 'activitypub_delete_remote_actor_posts', $nonexistent_actor_id ); // Verify it returns false when no posts exist. $this->assertFalse( $result, 'Should return false when no posts exist' );