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

Ensure Extra Fields links always include `rel="me"` for Mastodon verification.
38 changes: 36 additions & 2 deletions includes/collection/class-extra-fields.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 <a> 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();
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rel attribute value should be trimmed before exploding to handle cases where the attribute has leading/trailing whitespace (e.g., rel=' nofollow noopener '). Without trimming, explode will create empty string elements that could cause issues. Use trim( $rel ) before splitting.

Suggested change
$rel_parts = $rel && \is_string( $rel ) ? \explode( ' ', $rel ) : array();
$rel_parts = $rel && \is_string( $rel ) ? \explode( ' ', \trim( $rel ) ) : array();

Copilot uses AI. Check for mistakes.

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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a> 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' => '<a href="https://mastodon.social/@user">Mastodon</a>',
'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' => '<a href="https://example.com" rel="nofollow noopener">Example</a>',
'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' => '<a href="https://example.com" rel="me nofollow">Example</a>',
'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' ) );
Comment on lines +120 to +121
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test logic is fragile and could produce false positives. It counts occurrences of ' me' and '"me' separately, which could match unintended strings like 'some' or 'home'. A more reliable approach would be to parse the rel attribute value and count 'me' as a distinct token within it, or use a regex that specifically matches 'me' as a word boundary within the rel attribute.

Suggested change
// PropertyValue should have "me" exactly once in the HTML.
$this->assertEquals( 1, \substr_count( $attachments[0]['value'], ' me' ) + \substr_count( $attachments[0]['value'], '"me' ) );
// PropertyValue should have "me" exactly once in the rel attribute.
$rel_match = array();
\preg_match( '/\brel="([^"]*)"/', $attachments[0]['value'], $rel_match );
$rel_value = isset( $rel_match[1] ) ? $rel_match[1] : '';
$this->assertEquals( 1, \preg_match_all( '/\bme\b/', $rel_value ) );

Copilot uses AI. Check for mistakes.

// 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.
*
Expand Down