diff --git a/.github/changelog/2874-from-description b/.github/changelog/2874-from-description
new file mode 100644
index 0000000000..b608462eca
--- /dev/null
+++ b/.github/changelog/2874-from-description
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Ensure Extra Fields links always include `rel="me"` for Mastodon verification.
diff --git a/includes/collection/class-extra-fields.php b/includes/collection/class-extra-fields.php
index 2fad0fad38..ab6649d2b9 100644
--- a/includes/collection/class-extra-fields.php
+++ b/includes/collection/class-extra-fields.php
@@ -107,8 +107,12 @@ public static function fields_to_attachments( $fields ) {
\add_filter( 'activitypub_link_rel', array( self::class, 'add_rel_me' ) );
foreach ( $fields as $post ) {
- $title = \html_entity_decode( \get_the_title( $post ), \ENT_QUOTES, 'UTF-8' );
- $content = self::get_formatted_content( $post );
+ $title = \html_entity_decode( \get_the_title( $post ), \ENT_QUOTES, 'UTF-8' );
+ $content = self::get_formatted_content( $post );
+
+ // Ensure all links have rel="me" for verification (both in HTML and Link attachment).
+ $content = self::add_rel_me_to_links( $content );
+
$attachments[] = array(
'type' => 'PropertyValue',
'name' => $title,
@@ -292,6 +296,36 @@ public static function add_rel_me( $rel ) {
return $rel . ' me';
}
+ /**
+ * Add rel="me" to all links in HTML content.
+ *
+ * This ensures verification works for links that were already wrapped
+ * in tags (e.g., from the block editor) and didn't go through
+ * the activitypub_link_rel filter.
+ *
+ * @param string $content The HTML content.
+ * @return string The content with rel="me" added to all links.
+ */
+ public static function add_rel_me_to_links( $content ) {
+ if ( ! \class_exists( '\WP_HTML_Tag_Processor' ) ) {
+ return $content;
+ }
+
+ $tags = new \WP_HTML_Tag_Processor( $content );
+
+ while ( $tags->next_tag( 'A' ) ) {
+ $rel = $tags->get_attribute( 'rel' );
+ $rel_parts = $rel && \is_string( $rel ) ? \explode( ' ', $rel ) : array();
+
+ if ( ! \in_array( 'me', $rel_parts, true ) ) {
+ $rel_parts[] = 'me';
+ $tags->set_attribute( 'rel', \implode( ' ', $rel_parts ) );
+ }
+ }
+
+ return $tags->get_updated_html();
+ }
+
/**
* Checks if the user is the blog user.
*
diff --git a/tests/phpunit/tests/includes/collection/class-test-extra-fields.php b/tests/phpunit/tests/includes/collection/class-test-extra-fields.php
index 0b78f646c1..2dae76bfb2 100644
--- a/tests/phpunit/tests/includes/collection/class-test-extra-fields.php
+++ b/tests/phpunit/tests/includes/collection/class-test-extra-fields.php
@@ -39,6 +39,92 @@ public function test_get_attachment() {
$this->assertEquals( 1, $value_count['me'] );
}
+ /**
+ * Test that pre-linked URLs get rel="me" added to both PropertyValue and Link.
+ *
+ * When users create extra fields in the block editor, URLs are often
+ * already wrapped in tags without rel="me". This test ensures
+ * verification works by always adding "me" to the rel attribute in both
+ * the PropertyValue HTML and the Link attachment.
+ *
+ * @covers ::fields_to_attachments
+ * @covers ::add_rel_me_to_links
+ */
+ public function test_prelinked_url_gets_rel_me() {
+ $post = self::factory()->post->create_and_get(
+ array(
+ 'post_type' => Extra_Fields::BLOG_POST_TYPE,
+ 'post_content' => 'Mastodon',
+ 'post_title' => 'Mastodon',
+ )
+ );
+
+ $attachments = Extra_Fields::fields_to_attachments( array( $post ) );
+
+ // The PropertyValue HTML should have rel="me" for verification.
+ $this->assertEquals( 'PropertyValue', $attachments[0]['type'] );
+ $this->assertStringContainsString( 'rel="me"', $attachments[0]['value'] );
+
+ // The Link attachment should also have rel="me".
+ $this->assertEquals( 'Link', $attachments[1]['type'] );
+ $this->assertEquals( 'https://mastodon.social/@user', $attachments[1]['href'] );
+ $this->assertContains( 'me', $attachments[1]['rel'] );
+ }
+
+ /**
+ * Test that pre-linked URLs with existing rel attributes get "me" added.
+ *
+ * @covers ::fields_to_attachments
+ * @covers ::add_rel_me_to_links
+ */
+ public function test_prelinked_url_with_rel_gets_me_added() {
+ $post = self::factory()->post->create_and_get(
+ array(
+ 'post_type' => Extra_Fields::BLOG_POST_TYPE,
+ 'post_content' => 'Example',
+ 'post_title' => 'Example',
+ )
+ );
+
+ $attachments = Extra_Fields::fields_to_attachments( array( $post ) );
+
+ // The PropertyValue HTML should have rel with "me" added.
+ $this->assertEquals( 'PropertyValue', $attachments[0]['type'] );
+ $this->assertMatchesRegularExpression( '/rel="[^"]*me[^"]*"/', $attachments[0]['value'] );
+ $this->assertStringContainsString( 'nofollow', $attachments[0]['value'] );
+
+ // The Link attachment should have original rel values plus "me".
+ $this->assertEquals( 'Link', $attachments[1]['type'] );
+ $this->assertContains( 'nofollow', $attachments[1]['rel'] );
+ $this->assertContains( 'noopener', $attachments[1]['rel'] );
+ $this->assertContains( 'me', $attachments[1]['rel'] );
+ }
+
+ /**
+ * Test that pre-linked URLs with existing rel="me" don't get duplicate.
+ *
+ * @covers ::fields_to_attachments
+ * @covers ::add_rel_me_to_links
+ */
+ public function test_prelinked_url_with_rel_me_no_duplicate() {
+ $post = self::factory()->post->create_and_get(
+ array(
+ 'post_type' => Extra_Fields::BLOG_POST_TYPE,
+ 'post_content' => 'Example',
+ 'post_title' => 'Example',
+ )
+ );
+
+ $attachments = Extra_Fields::fields_to_attachments( array( $post ) );
+
+ // PropertyValue should have "me" exactly once in the HTML.
+ $this->assertEquals( 1, \substr_count( $attachments[0]['value'], ' me' ) + \substr_count( $attachments[0]['value'], '"me' ) );
+
+ // Link attachment should have "me" exactly once.
+ $value_count = array_count_values( $attachments[1]['rel'] );
+ $this->assertEquals( 1, $value_count['me'] );
+ }
+
/**
* Test that HTML entities are decoded in field names and values.
*