From 6eb6bfaca8ff18c9725a8f7c83ec2fc16abd3042 Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Mon, 10 Aug 2026 18:24:09 +0600 Subject: [PATCH] fix(migration): prevent duplicate actor fields --- .../changelog/fix-3339-extra-field-duplicates | 4 + includes/class-migration.php | 113 +++++++++++++++++- .../tests/includes/class-test-migration.php | 109 ++++++++++++++++- 3 files changed, 214 insertions(+), 12 deletions(-) create mode 100644 .github/changelog/fix-3339-extra-field-duplicates diff --git a/.github/changelog/fix-3339-extra-field-duplicates b/.github/changelog/fix-3339-extra-field-duplicates new file mode 100644 index 0000000000..2c94db0887 --- /dev/null +++ b/.github/changelog/fix-3339-extra-field-duplicates @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Fixed the "Powered by WordPress" profile field being added multiple times, and cleaned up any existing duplicates on update. diff --git a/includes/class-migration.php b/includes/class-migration.php index 39f1d5fef9..d969dcc110 100644 --- a/includes/class-migration.php +++ b/includes/class-migration.php @@ -224,6 +224,9 @@ public static function maybe_migrate() { self::migrate_application_keypair_option(); self::delete_application_outbox_items(); } + if ( \version_compare( $version_from_db, 'unreleased', '<' ) ) { + self::deduplicate_default_extra_fields(); + } /* * Defer the flush to late in the `init` cycle (priority 20). Migration::init @@ -787,6 +790,10 @@ private static function add_default_extra_field() { // Add a default extra field for each user. foreach ( $users as $user ) { + if ( self::has_default_extra_field( Extra_Fields::USER_POST_TYPE, $title, $user->ID ) ) { + continue; + } + \wp_insert_post( array( 'post_type' => Extra_Fields::USER_POST_TYPE, @@ -798,15 +805,109 @@ private static function add_default_extra_field() { ); } - \wp_insert_post( + if ( ! self::has_default_extra_field( Extra_Fields::BLOG_POST_TYPE, $title ) ) { + \wp_insert_post( + array( + 'post_type' => Extra_Fields::BLOG_POST_TYPE, + 'post_author' => 0, + 'post_status' => 'publish', + 'post_title' => $title, + 'post_content' => $content, + ) + ); + } + } + + /** + * Check whether a "Powered by" extra field already exists for an actor. + * + * Queries the custom post type directly with suppress_filters to avoid + * triggering WordPress SQL filters. Note: suppress_filters does not + * block arbitrary filters such as `activitypub_get_actor_extra_fields`, + * so this method avoids calling Extra_Fields::get_actor_fields(). + * + * @param string $post_type The extra fields post type. + * @param string $title The extra field title to look for. + * @param int $user_id Optional. The user ID. Default 0 (blog actor). + * + * @return bool Whether a matching extra field exists. + */ + private static function has_default_extra_field( $post_type, $title, $user_id = 0 ) { + $args = array( + 'post_type' => $post_type, + 'post_status' => 'publish', + 'title' => $title, + 'posts_per_page' => 1, + 'fields' => 'ids', + 'suppress_filters' => true, + ); + + if ( Extra_Fields::USER_POST_TYPE === $post_type && $user_id ) { + $args['author'] = $user_id; + } + + $query = new \WP_Query( $args ); + + return $query->found_posts > 0; + } + + /** + * Remove duplicate default extra fields. + */ + private static function deduplicate_default_extra_fields() { + $title = \__( 'Powered by', 'activitypub' ); + + self::delete_duplicate_extra_fields( Extra_Fields::BLOG_POST_TYPE, $title ); + + $users = \get_users( array( - 'post_type' => Extra_Fields::BLOG_POST_TYPE, - 'post_author' => 0, - 'post_status' => 'publish', - 'post_title' => $title, - 'post_content' => $content, + 'capability__in' => array( 'activitypub' ), ) ); + + foreach ( $users as $user ) { + self::delete_duplicate_extra_fields( Extra_Fields::USER_POST_TYPE, $title, $user->ID ); + } + } + + /** + * Remove duplicate extra fields with the same title. + * + * Queries the custom post type directly with suppress_filters to avoid + * triggering WordPress SQL filters. Avoids Extra_Fields::get_actor_fields() + * to prevent the `activitypub_get_actor_extra_fields` filter from seeding + * default fields as a side effect. + * + * @param string $post_type The extra fields post type. + * @param string $title The extra field title to deduplicate. + * @param int $user_id Optional. The user ID. Default 0 (blog actor). + */ + private static function delete_duplicate_extra_fields( $post_type, $title, $user_id = 0 ) { + $args = array( + 'post_type' => $post_type, + 'post_status' => 'publish', + 'title' => $title, + 'orderby' => array( 'date' => 'ASC', 'ID' => 'ASC' ), + 'posts_per_page' => -1, + 'suppress_filters' => true, + ); + + if ( Extra_Fields::USER_POST_TYPE === $post_type && $user_id ) { + $args['author'] = $user_id; + } + + $query = new \WP_Query( $args ); + + if ( $query->found_posts < 2 ) { + return; + } + + // Keep the oldest field, delete the rest. + $posts = $query->posts; + array_shift( $posts ); + foreach ( $posts as $post ) { + \wp_delete_post( $post->ID, true ); + } } /** diff --git a/tests/phpunit/tests/includes/class-test-migration.php b/tests/phpunit/tests/includes/class-test-migration.php index 5bfaf2962d..21d706bae2 100644 --- a/tests/phpunit/tests/includes/class-test-migration.php +++ b/tests/phpunit/tests/includes/class-test-migration.php @@ -653,9 +653,10 @@ public function test_add_default_extra_field() { // Check the extra field for the user. $user_fields = get_posts( array( - 'post_type' => Extra_Fields::USER_POST_TYPE, - 'author' => $user_id, - 'posts_per_page' => -1, + 'post_type' => Extra_Fields::USER_POST_TYPE, + 'author' => $user_id, + 'posts_per_page' => -1, + 'suppress_filters' => true, ) ); @@ -666,9 +667,10 @@ public function test_add_default_extra_field() { // Check the extra field for the blog user. $blog_fields = get_posts( array( - 'post_type' => Extra_Fields::BLOG_POST_TYPE, - 'author' => 0, - 'posts_per_page' => -1, + 'post_type' => Extra_Fields::BLOG_POST_TYPE, + 'author' => 0, + 'posts_per_page' => -1, + 'suppress_filters' => true, ) ); @@ -676,6 +678,101 @@ public function test_add_default_extra_field() { $this->assertEquals( 'Powered by', $blog_fields[0]->post_title, 'The title should be "Powered by"' ); $this->assertEquals( 'WordPress', $blog_fields[0]->post_content, 'The content should be "WordPress"' ); + $method->invoke( null ); + + $user_fields = get_posts( + array( + 'post_type' => Extra_Fields::USER_POST_TYPE, + 'author' => $user_id, + 'posts_per_page' => -1, + 'suppress_filters' => true, + ) + ); + $blog_fields = get_posts( + array( + 'post_type' => Extra_Fields::BLOG_POST_TYPE, + 'author' => 0, + 'posts_per_page' => -1, + 'suppress_filters' => true, + ) + ); + + $this->assertCount( 1, $user_fields, 'Running the seeder again should not duplicate the user field' ); + $this->assertCount( 1, $blog_fields, 'Running the seeder again should not duplicate the blog field' ); + + _delete_all_data(); + } + + /** + * Test duplicate default extra fields are removed. + */ + public function test_deduplicate_default_extra_fields() { + $user_id = self::factory()->user->create(); + $user = get_user_by( 'id', $user_id ); + $user->add_cap( 'activitypub' ); + + for ( $i = 0; $i < 3; $i++ ) { + wp_insert_post( + array( + 'post_type' => Extra_Fields::USER_POST_TYPE, + 'post_author' => $user_id, + 'post_status' => 'publish', + 'post_title' => 'Powered by', + 'post_content' => 'WordPress', + ) + ); + } + + wp_insert_post( + array( + 'post_type' => Extra_Fields::USER_POST_TYPE, + 'post_author' => $user_id, + 'post_status' => 'publish', + 'post_title' => 'Custom field', + 'post_content' => 'Custom value', + ) + ); + + for ( $i = 0; $i < 2; $i++ ) { + wp_insert_post( + array( + 'post_type' => Extra_Fields::BLOG_POST_TYPE, + 'post_status' => 'publish', + 'post_title' => 'Powered by', + 'post_content' => 'WordPress', + ) + ); + } + + $reflection = new \ReflectionClass( Migration::class ); + $method = $reflection->getMethod( 'deduplicate_default_extra_fields' ); + if ( \PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + $method->invoke( null ); + + $user_fields = get_posts( + array( + 'post_type' => Extra_Fields::USER_POST_TYPE, + 'author' => $user_id, + 'posts_per_page' => -1, + 'suppress_filters' => true, + ) + ); + $blog_fields = get_posts( + array( + 'post_type' => Extra_Fields::BLOG_POST_TYPE, + 'posts_per_page' => -1, + 'suppress_filters' => true, + ) + ); + + $this->assertCount( 2, $user_fields, 'Only one default user field should remain' ); + $this->assertCount( 1, $blog_fields, 'Only one default blog field should remain' ); + + $user_titles = wp_list_pluck( $user_fields, 'post_title' ); + $this->assertContains( 'Custom field', $user_titles, 'Custom user fields should be preserved' ); + _delete_all_data(); }