From a89f6dc82e549a5a46ee7377adf0624c6d531a26 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 1 Oct 2026 18:30:42 +0200 Subject: [PATCH 01/33] Add images to federated comments using the existing media cache --- .github/changelog/fix-comment-images | 4 + includes/cache/class-media.php | 10 +- includes/class-blocks.php | 28 +++-- includes/class-comment.php | 19 +++- includes/collection/class-interactions.php | 17 ++- includes/collection/class-remote-posts.php | 15 ++- includes/functions-media.php | 16 ++- .../tests/includes/cache/class-test-media.php | 11 +- .../tests/includes/class-test-comment.php | 63 +++++++++++ .../includes/class-test-functions-media.php | 39 ++++++- .../collection/class-test-interactions.php | 100 ++++++++++++++++++ 11 files changed, 277 insertions(+), 45 deletions(-) create mode 100644 .github/changelog/fix-comment-images diff --git a/.github/changelog/fix-comment-images b/.github/changelog/fix-comment-images new file mode 100644 index 0000000000..4ec52dc425 --- /dev/null +++ b/.github/changelog/fix-comment-images @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Display images attached to Fediverse comments, with local caching and image descriptions. diff --git a/includes/cache/class-media.php b/includes/cache/class-media.php index e2bc0df811..05e9d284bc 100644 --- a/includes/cache/class-media.php +++ b/includes/cache/class-media.php @@ -7,17 +7,13 @@ namespace Activitypub\Cache; -use Activitypub\Collection\Remote_Posts; - /** * Media cache class. * * Handles lazy caching of remote post and comment media locally. * Media is cached on-demand when URLs pass through the `activitypub_remote_media_url` filter. * - * Storage locations: - * - Posts: /wp-content/uploads/activitypub/posts/{post_id}/ - * - Comments: /wp-content/uploads/activitypub/comments/{comment_id}/ + * Post and comment images share /wp-content/uploads/activitypub/posts/{post_id}/. * * Files are cleaned up automatically when the parent post is deleted. * @@ -173,10 +169,6 @@ public static function maybe_cache( $url, $context, $entity_id = null, $options * @return void */ public static function maybe_cleanup( $post_id ) { - if ( Remote_Posts::POST_TYPE !== \get_post_type( $post_id ) ) { - return; - } - self::invalidate_entity( $post_id ); } diff --git a/includes/class-blocks.php b/includes/class-blocks.php index 8a5f2fa059..7340f5894d 100644 --- a/includes/class-blocks.php +++ b/includes/class-blocks.php @@ -229,7 +229,9 @@ public static function register_blocks() { array( 'attributes' => array( 'url' => array( 'type' => 'string' ), + 'alt' => array( 'type' => 'string' ), ), + 'uses_context' => array( 'postId' ), 'render_callback' => array( self::class, 'render_image_block' ), ) ); @@ -644,22 +646,28 @@ public static function render_emoji_block( $attrs, $content ) { * * Replaces remote image URL with cached URL at runtime. * - * @param array $attrs The block attributes. - * @param string $content The block inner content (img tag). + * @param array $attrs The block attributes. + * @param string $content The block inner content (img tag). + * @param \WP_Block|null $block Optional. The block instance. * * @return string The rendered content with cached URL. */ - public static function render_image_block( $attrs, $content ) { - if ( empty( $attrs['url'] ) || empty( $content ) ) { + public static function render_image_block( $attrs, $content, $block = null ) { + if ( empty( $attrs['url'] ) ) { return $content; } - $url = $attrs['url']; + $url = \wp_specialchars_decode( $attrs['url'], ENT_QUOTES ); + $processor = new \WP_HTML_Tag_Processor( $content ); + if ( isset( $attrs['alt'] ) && ( ! $processor->next_tag( array( 'tag_name' => 'IMG' ) ) || ! $processor->get_attribute( 'src' ) ) ) { + // Comment sanitization removes img attributes; retain them in the block instead. + $content = \sprintf( '%s', \esc_url( $url ), \esc_attr( $attrs['alt'] ) ); + } // Get entity ID from context. - $entity_id = null; + $entity_id = $block->context['postId'] ?? null; $post = \get_post(); - if ( $post ) { + if ( ! $entity_id && $post ) { $entity_id = $post->ID; } @@ -674,7 +682,11 @@ public static function render_image_block( $attrs, $content ) { $cached_url = \apply_filters( 'activitypub_remote_media_url', $url, 'media', $entity_id, array() ); if ( $cached_url && $cached_url !== $url ) { - return \str_replace( $url, $cached_url, $content ); + $processor = new \WP_HTML_Tag_Processor( $content ); + if ( $processor->next_tag( array( 'tag_name' => 'IMG' ) ) ) { + $processor->set_attribute( 'src', $cached_url ); + return $processor->get_updated_html(); + } } return $content; diff --git a/includes/class-comment.php b/includes/class-comment.php index c3942632bf..4a8d5f182d 100644 --- a/includes/class-comment.php +++ b/includes/class-comment.php @@ -40,7 +40,7 @@ public static function init() { \add_filter( 'get_comment_author', array( static::class, 'render_emoji' ), 10, 2 ); \add_filter( 'comment_author', array( static::class, 'unescape_emoji' ), 20, 2 ); // After esc_html(). \add_filter( 'rest_comment_query', array( static::class, 'rest_comment_query' ) ); - \add_filter( 'comment_text', array( static::class, 'render_blocks' ), 5 ); // Before other filters. + \add_filter( 'comment_text', array( static::class, 'render_blocks' ), 5, 2 ); // Before other filters. } /** @@ -50,17 +50,25 @@ public static function init() { * This filter applies do_blocks() to render activitypub/emoji * and activitypub/image blocks in comment content. * - * @param string $content The comment content. + * @param string $content The comment content. + * @param \WP_Comment|null $comment Optional. The comment being rendered. * * @return string The content with blocks rendered. */ - public static function render_blocks( $content ) { + public static function render_blocks( $content, $comment = null ) { if ( empty( $content ) || ! \str_contains( $content, '%s', - \wp_json_encode( array( 'url' => $url ) ), + $processor = new \WP_HTML_Tag_Processor( $img_html ); + $processor->next_tag( array( 'tag_name' => 'IMG' ) ); + + return \get_comment_delimited_block_content( + 'activitypub/image', + array( + 'url' => $url, + 'alt' => $processor->get_attribute( 'alt' ) ?? '', + ), $img_html ); } @@ -148,6 +154,10 @@ function ( $matches ) { // Append attachments not already in content. if ( ! empty( $attachments ) ) { foreach ( $attachments as $attachment ) { + if ( 'image' !== ( $attachment['type'] ?? 'image' ) ) { + continue; + } + $url = $attachment['url'] ?? ''; if ( empty( $url ) || isset( $seen_urls[ $url ] ) ) { continue; diff --git a/tests/phpunit/tests/includes/cache/class-test-media.php b/tests/phpunit/tests/includes/cache/class-test-media.php index b7ecfeb2be..0f98c7a853 100644 --- a/tests/phpunit/tests/includes/cache/class-test-media.php +++ b/tests/phpunit/tests/includes/cache/class-test-media.php @@ -84,9 +84,9 @@ public function test_get_storage_paths_for_context_comment() { } /** - * Test maybe_cleanup only runs for ap_post post type. + * Test cleanup also removes comment images attached to a regular post. */ - public function test_maybe_cleanup_wrong_post_type() { + public function test_maybe_cleanup_regular_post() { // Create a regular post. $post_id = self::factory()->post->create(); @@ -96,11 +96,10 @@ public function test_maybe_cleanup_wrong_post_type() { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents file_put_contents( $paths['basedir'] . '/test.txt', 'test' ); - // Call cleanup. - Media::maybe_cleanup( $post_id ); + Media::init(); + wp_delete_post( $post_id, true ); - // File should still exist (not cleaned up because wrong post type). - $this->assertTrue( file_exists( $paths['basedir'] . '/test.txt' ) ); + $this->assertFalse( is_dir( $paths['basedir'] ) ); // Clean up. Media::invalidate_entity( $post_id ); diff --git a/tests/phpunit/tests/includes/class-test-comment.php b/tests/phpunit/tests/includes/class-test-comment.php index c695265424..3380e9c59c 100644 --- a/tests/phpunit/tests/includes/class-test-comment.php +++ b/tests/phpunit/tests/includes/class-test-comment.php @@ -10,12 +10,75 @@ use Activitypub\Collection\Remote_Posts; use Activitypub\Comment; +use function Activitypub\generate_image_block; + /** * Test class for Activitypub Comment. * * @coversDefaultClass \Activitypub\Comment */ class Test_Comment extends \WP_UnitTestCase { + /** + * Images use the comment's parent post, not the current global post. + * + * @group activitypub + * @covers ::render_blocks + */ + public function test_render_image_with_parent_post_context() { + $parent = self::factory()->post->create(); + $comment = self::factory()->comment->create_and_get( array( 'comment_post_ID' => $parent ) ); + $this->go_to( \get_permalink( self::factory()->post->create() ) ); + $content = generate_image_block( 'https://example.com/photo.jpg', 'Cat' ); + $seen_id = null; + $filter = static function ( $url, $context, $entity_id ) use ( &$seen_id ) { + $seen_id = $entity_id; + return 'https://cdn.example.com/photo.jpg'; + }; + $download = static function () { + return new \WP_Error( 'cache_skipped', 'Test uses a CDN.' ); + }; + \add_filter( 'activitypub_pre_download_url', $download ); + \add_filter( 'activitypub_remote_media_url', $filter, 1, 3 ); + $rendered = Comment::render_blocks( $content, $comment ); + \remove_filter( 'activitypub_remote_media_url', $filter, 1 ); + \remove_filter( 'activitypub_pre_download_url', $download ); + $this->assertSame( (int) $comment->comment_post_ID, $seen_id ); + $this->assertStringContainsString( 'https://cdn.example.com/photo.jpg', $rendered ); + } + + /** + * Core block filters remain available to extensions when rendering comments. + * + * @group activitypub + * @covers ::render_blocks + */ + public function test_render_blocks_preserves_core_filters() { + $content = generate_image_block( 'https://example.com/photo.jpg', 'Cat' ); + $filters = array( 'pre_render_block', 'render_block_data', 'render_block_context' ); + $seen = array(); + $filter = static function ( $value ) use ( &$seen ) { + $seen[] = \current_filter(); + return $value; + }; + foreach ( $filters as $hook ) { + \add_filter( $hook, $filter ); + } + $pre_render = static function () { + return 'Filtered comment image.'; + }; + Comment::render_blocks( '' ); + \add_filter( 'pre_render_block', $pre_render, 20 ); + $rendered = Comment::render_blocks( $content ); + \remove_filter( 'pre_render_block', $pre_render, 20 ); + foreach ( $filters as $hook ) { + \remove_filter( $hook, $filter ); + } + + $this->assertSame( 'Filtered comment image.', $rendered ); + foreach ( $filters as $hook ) { + $this->assertContains( $hook, $seen ); + } + } /** * Test get source id or url. diff --git a/tests/phpunit/tests/includes/class-test-functions-media.php b/tests/phpunit/tests/includes/class-test-functions-media.php index 8436c7511f..6aaefa6eee 100644 --- a/tests/phpunit/tests/includes/class-test-functions-media.php +++ b/tests/phpunit/tests/includes/class-test-functions-media.php @@ -152,7 +152,44 @@ public function test_generate_image_block() { $this->assertStringStartsWith( '', $result ); $this->assertStringContainsString( $img_html, $result ); - $this->assertStringContainsString( '"url":"https:\/\/example.com\/image.jpg"', $result ); + $block = \parse_blocks( $result )[0]; + $this->assertSame( $url, $block['attrs']['url'] ); + $this->assertSame( 'Test', $block['attrs']['alt'] ); + } + + /** + * Image descriptions cannot escape the block attributes. + * + * @group activitypub + */ + public function test_generate_image_block_escapes_attributes() { + $result = \Activitypub\generate_image_block( 'https://example.com/image.jpg', '--> "cat" & kittens' ); + $blocks = \parse_blocks( $result ); + $this->assertCount( 1, $blocks ); + $this->assertSame( '--> "cat" & kittens', $blocks[0]['attrs']['alt'] ); + } + + /** + * Passing the shared attachment list still processes images only. + * + * @group activitypub + */ + public function test_process_remote_images_skips_other_media() { + $result = \Activitypub\process_remote_images( + '', + array( + array( + 'url' => 'https://example.com/video.mp4', + 'type' => 'video', + ), + array( + 'url' => 'https://example.com/image.jpg', + 'type' => 'image', + ), + ) + ); + $this->assertStringNotContainsString( 'video.mp4', $result ); + $this->assertStringContainsString( 'image.jpg', $result ); } /** diff --git a/tests/phpunit/tests/includes/collection/class-test-interactions.php b/tests/phpunit/tests/includes/collection/class-test-interactions.php index c26750fa54..9f04b152cb 100644 --- a/tests/phpunit/tests/includes/collection/class-test-interactions.php +++ b/tests/phpunit/tests/includes/collection/class-test-interactions.php @@ -7,6 +7,7 @@ namespace Activitypub\Tests\Collection; +use Activitypub\Cache\Media; use Activitypub\Collection\Interactions; use Activitypub\Collection\Remote_Actors; use Activitypub\Tests\Equal_Html; @@ -21,6 +22,105 @@ class Test_Interactions extends \WP_UnitTestCase { use Equal_Html; + /** + * Image-only comments reuse the post's lazy image cache and retain alt text. + * + * @group activitypub + * @covers ::add_comment + */ + public function test_image_only_comment_uses_post_cache() { + $url = 'https://example.com/photo.jpg?a=1&b=2'; + $activity = $this->create_test_object( 'https://example.com/image-reply' ); + $activity['object']['attachment'] = array( + array( + 'type' => 'Document', + 'mediaType' => 'image/jpeg', + 'url' => $url, + 'name' => 'A "cat" & her kittens', + ), + ); + unset( $activity['object']['content'] ); + $downloads = 0; + $download = static function () use ( &$downloads ) { + ++$downloads; + $file = \wp_tempnam( 'comment-image.jpg' ); + \copy( AP_TESTS_DIR . '/data/assets/test.jpg', $file ); + return array( + 'file' => $file, + 'mime_type' => 'image/jpeg', + ); + }; + Media::init(); + \add_filter( 'activitypub_pre_download_url', $download ); + $comment_id = Interactions::add_comment( $activity ); + $comment = \get_comment( $comment_id ); + $before = $downloads; + $rendered = \apply_filters( 'comment_text', $comment->comment_content, $comment ); + $again = \apply_filters( 'comment_text', $comment->comment_content, $comment ); + \remove_filter( 'activitypub_pre_download_url', $download ); + $cached = Media::get( $url, self::$post_id ); + \wp_delete_comment( $comment_id, true ); + $after_delete = Media::get( $url, self::$post_id ); + Media::invalidate_entity( self::$post_id ); + + $this->assertIsInt( $comment_id ); + $this->assertStringContainsString( '

Safe text.

'; + $comment_id = Interactions::add_comment( $activity ); + $this->assertIsInt( $comment_id ); + $content = \get_comment( $comment_id )->comment_content; + $this->assertStringNotContainsString( '/s', '', $content ) ?? ''; + /* + * A separator prevents the surrounding text from forming another comment delimiter. + * preg_replace() returns null if PCRE bails; an empty string is the safe reading. + */ + return \preg_replace( '/|$)/s', ' ', $content ) ?? ''; } /** diff --git a/includes/collection/class-remote-posts.php b/includes/collection/class-remote-posts.php index 9ca9696bcc..c838c82550 100644 --- a/includes/collection/class-remote-posts.php +++ b/includes/collection/class-remote-posts.php @@ -440,13 +440,16 @@ public static function extract_attachments( $activity_object ) { continue; } - $mime_type = \is_string( $attachment['mediaType'] ?? null ) ? $attachment['mediaType'] : ''; + $mime_type = \is_string( $attachment['mediaType'] ?? null ) ? \strtolower( $attachment['mediaType'] ) : ''; - if ( \str_starts_with( $mime_type, 'video/' ) ) { + if ( '' === $mime_type ) { + $type = \is_string( $attachment['type'] ?? null ) ? \strtolower( $attachment['type'] ) : 'image'; + $type = \in_array( $type, array( 'image', 'audio', 'video' ), true ) ? $type : 'document'; + } elseif ( \str_starts_with( $mime_type, 'video/' ) ) { $type = 'video'; } elseif ( \str_starts_with( $mime_type, 'audio/' ) ) { $type = 'audio'; - } elseif ( '' === $mime_type || \str_starts_with( $mime_type, 'image/' ) ) { + } elseif ( \str_starts_with( $mime_type, 'image/' ) ) { $type = 'image'; } else { $type = 'document'; diff --git a/includes/functions-media.php b/includes/functions-media.php index 0f2fbe2213..c476884f78 100644 --- a/includes/functions-media.php +++ b/includes/functions-media.php @@ -57,8 +57,10 @@ function generate_image_block( $url, $img_html ) { return \get_comment_delimited_block_content( 'activitypub/image', array( - 'url' => $url, - 'alt' => $processor->get_attribute( 'alt' ) ?? '', + // Core runs block attribute strings through KSES when saving comments. + 'url' => \htmlspecialchars( $url, ENT_QUOTES, 'UTF-8', true ), + 'urlEncoded' => true, + 'alt' => $processor->get_attribute( 'alt' ) ?? '', ), $img_html ); @@ -140,9 +142,12 @@ function ( $matches ) { // Reconstruct img tag without the marker attribute. $img_html = ''; - // Extract src URL from the img tag. - if ( \preg_match( '/src=["\']([^"\']+)["\']/', $img_html, $src_match ) ) { - return generate_image_block( $src_match[1], $img_html ); + $processor = new \WP_HTML_Tag_Processor( $img_html ); + if ( $processor->next_tag( array( 'tag_name' => 'IMG' ) ) ) { + $src = $processor->get_attribute( 'src' ); + if ( \is_string( $src ) && $src ) { + return generate_image_block( $src, $img_html ); + } } return $matches[0]; @@ -170,8 +175,8 @@ function ( $matches ) { $alt = ! empty( $attachment['alt'] ) ? \esc_attr( $attachment['alt'] ) : ''; $img_tag = $alt - ? \sprintf( '%s', \esc_url( $url ), $alt ) - : \sprintf( '', \esc_url( $url ) ); + ? \sprintf( '%s', \esc_url( \htmlspecialchars( $url, ENT_QUOTES, 'UTF-8', true ) ), $alt ) + : \sprintf( '', \esc_url( \htmlspecialchars( $url, ENT_QUOTES, 'UTF-8', true ) ) ); $content .= "\n\n" . generate_image_block( $url, $img_tag ); $seen_urls[ $url ] = true; diff --git a/tests/phpunit/tests/includes/class-test-blocks.php b/tests/phpunit/tests/includes/class-test-blocks.php index 3964691c3a..6111bdf925 100644 --- a/tests/phpunit/tests/includes/class-test-blocks.php +++ b/tests/phpunit/tests/includes/class-test-blocks.php @@ -13,6 +13,7 @@ use Activitypub\Transformer\Post; use function Activitypub\object_to_uri; +use function Activitypub\process_remote_images; /** * Test class for Blocks. @@ -20,6 +21,56 @@ * @coversDefaultClass \Activitypub\Blocks */ class Test_Blocks extends \WP_UnitTestCase { + /** + * URLs supplied by JSON are not HTML entity encoded. + * + * @covers ::render_image_block + */ + public function test_render_image_block_preserves_literal_url_entities() { + $url = 'https://example.com/a&b.jpg'; + $seen = null; + $filter = static function ( $value ) use ( &$seen ) { + $seen = $value; + return false; + }; + $content = ''; + \add_filter( 'activitypub_remote_media_url', $filter, 1 ); + Blocks::render_image_block( array( 'url' => $url ), $content ); + \remove_filter( 'activitypub_remote_media_url', $filter, 1 ); + + $this->assertSame( $url, $seen ); + } + + /** + * Generated image URLs survive comment KSES without changing the resource. + * + * @covers ::render_image_block + */ + public function test_render_image_block_preserves_generated_urls_through_kses() { + $seen = null; + $filter = static function ( $value ) use ( &$seen ) { + $seen = $value; + return false; + }; + \add_filter( 'activitypub_remote_media_url', $filter, 1 ); + $results = array(); + foreach ( array( 'https://example.com/photo.jpg?a=1&b=2', 'https://example.com/a&b.jpg' ) as $url ) { + $content = process_remote_images( '', array( array( 'url' => $url ) ) ); + $content = \wp_kses( $content, array() ); + $block = \parse_blocks( \trim( $content ) )[0]; + $output = Blocks::render_image_block( $block['attrs'], $block['innerHTML'] ); + $img = new \WP_HTML_Tag_Processor( $output ); + $img->next_tag( 'IMG' ); + $results[] = array( $url, $seen, $img->get_attribute( 'src' ) ); + } + \remove_filter( 'activitypub_remote_media_url', $filter, 1 ); + + foreach ( $results as $result ) { + $this->assertSame( $result[0], $result[1] ); + $this->assertSame( $result[0], $result[2] ); + } + } + /** * User ID for Extra Fields block tests. diff --git a/tests/phpunit/tests/includes/class-test-comment.php b/tests/phpunit/tests/includes/class-test-comment.php index 3380e9c59c..350dedae1b 100644 --- a/tests/phpunit/tests/includes/class-test-comment.php +++ b/tests/phpunit/tests/includes/class-test-comment.php @@ -18,6 +18,28 @@ * @coversDefaultClass \Activitypub\Comment */ class Test_Comment extends \WP_UnitTestCase { + /** + * Only leaf image and emoji blocks may invoke render callbacks in comments. + * + * @covers ::render_blocks + */ + public function test_render_blocks_rejects_other_and_nested_blocks() { + $calls = 0; + $callback = static function () use ( &$calls ) { + ++$calls; + return 'Unexpected callback.'; + }; + \register_block_type( 'activitypub/test-probe', array( 'render_callback' => $callback ) ); + \register_block_type( 'core/test-probe', array( 'render_callback' => $callback ) ); + Comment::render_blocks( '' ); + Comment::render_blocks( '' ); + Comment::render_blocks( '' ); + \unregister_block_type( 'activitypub/test-probe' ); + \unregister_block_type( 'core/test-probe' ); + + $this->assertSame( 0, $calls ); + } + /** * Images use the comment's parent post, not the current global post. * diff --git a/tests/phpunit/tests/includes/class-test-functions-media.php b/tests/phpunit/tests/includes/class-test-functions-media.php index 6aaefa6eee..38a78adae3 100644 --- a/tests/phpunit/tests/includes/class-test-functions-media.php +++ b/tests/phpunit/tests/includes/class-test-functions-media.php @@ -13,6 +13,20 @@ * @coversDefaultClass \Activitypub */ class Test_Functions_Media extends \WP_UnitTestCase { + /** + * URLs read from HTML are decoded exactly once before becoming block attributes. + * + * @covers \Activitypub\process_remote_images + */ + public function test_process_remote_images_decodes_html_urls_once() { + foreach ( array( 'https://example.com/image.jpg?a=1&b=2', 'https://example.com/a&b.jpg' ) as $url ) { + $html = ''; + $blocks = \parse_blocks( \Activitypub\process_remote_images( $html ) ); + $this->assertTrue( $blocks[0]['attrs']['urlEncoded'] ); + $this->assertSame( $url, \wp_specialchars_decode( $blocks[0]['attrs']['url'], ENT_QUOTES ) ); + } + } + /** * Test process_remote_images wraps remote images. diff --git a/tests/phpunit/tests/includes/class-test-sanitize.php b/tests/phpunit/tests/includes/class-test-sanitize.php index 32866b4b73..c9b6b7426f 100644 --- a/tests/phpunit/tests/includes/class-test-sanitize.php +++ b/tests/phpunit/tests/includes/class-test-sanitize.php @@ -18,6 +18,29 @@ class Test_Sanitize extends \WP_UnitTestCase { use Equal_Html; + /** + * Removing remote markup must not construct executable block delimiters. + * + * @covers ::content + * @covers ::comment_content + */ + public function test_remote_sanitizers_strip_reconstructed_blocks() { + $payloads = array( + '', + '<!-- wp:activitypub/image /-->', + '-- wp:activitypub/image /-->', + '', + '!-- wp:activitypub/test-probe /-->

Safe text.

'; + $comment_id = Interactions::add_comment( $activity ); + $this->assertIsInt( $comment_id ); + $content = \get_comment( $comment_id )->comment_content; + $this->assertStringNotContainsString( '

Updated text.

'; + $result = Interactions::update_comment( $activity ); + $this->assertNotWPError( $result ); + $this->assertNotFalse( $result ); + $content = \get_comment( $comment_id )->comment_content; + $this->assertStringNotContainsString( '' ); + Comment::render_blocks( '', $comment ); \add_filter( 'pre_render_block', $pre_render, 20 ); - $rendered = Comment::render_blocks( $content ); + $rendered = Comment::render_blocks( $content, $comment ); \remove_filter( 'pre_render_block', $pre_render, 20 ); foreach ( $filters as $hook ) { \remove_filter( $hook, $filter ); @@ -102,6 +108,59 @@ public function test_render_blocks_preserves_core_filters() { } } + /** + * Ordinary comment submission cannot turn block attributes into an image. + * + * @covers ::render_blocks + */ + public function test_ordinary_comment_cannot_render_image_blocks() { + \wp_set_current_user( 0 ); + \kses_init(); + $content = ''; + $comment_id = \wp_new_comment( + \wp_slash( + array( + 'comment_post_ID' => self::factory()->post->create(), + 'comment_author' => 'Local commenter', + 'comment_author_email' => 'local@example.com', + 'comment_author_url' => '', + 'comment_content' => $content, + ) + ), + true + ); + $this->assertIsInt( $comment_id ); + $comment = \get_comment( $comment_id ); + $this->assertFalse( Comment::was_received( $comment ) ); + $this->assertStringContainsString( ':party:'; + $comment = self::factory()->comment->create_and_get(); + $rendered = Comment::render_blocks( $content, $comment ); + + $this->assertStringContainsString( 'assertStringContainsString( 'class="emoji"', $rendered ); + $this->assertSame( $rendered, Comment::render_blocks( $content ) ); + } + /** * Test get source id or url. * diff --git a/tests/phpunit/tests/includes/collection/class-test-interactions.php b/tests/phpunit/tests/includes/collection/class-test-interactions.php index 62b563eacb..d11a375e46 100644 --- a/tests/phpunit/tests/includes/collection/class-test-interactions.php +++ b/tests/phpunit/tests/includes/collection/class-test-interactions.php @@ -148,6 +148,56 @@ public function test_update_comment_images() { $this->assertStringNotContainsString( '' ); - Comment::render_blocks( '' ); - Comment::render_blocks( '' ); + Comment::render_blocks( '', $comment ); + Comment::render_blocks( '', $comment ); + Comment::render_blocks( '', $comment ); \unregister_block_type( 'activitypub/test-probe' ); \unregister_block_type( 'core/test-probe' ); diff --git a/tests/phpunit/tests/includes/class-test-functions-media.php b/tests/phpunit/tests/includes/class-test-functions-media.php index 38a78adae3..a00cab56cd 100644 --- a/tests/phpunit/tests/includes/class-test-functions-media.php +++ b/tests/phpunit/tests/includes/class-test-functions-media.php @@ -180,7 +180,7 @@ public function test_generate_image_block_escapes_attributes() { $result = \Activitypub\generate_image_block( 'https://example.com/image.jpg', '--> "cat" & kittens' ); $blocks = \parse_blocks( $result ); $this->assertCount( 1, $blocks ); - $this->assertSame( '--> "cat" & kittens', $blocks[0]['attrs']['alt'] ); + $this->assertSame( '--> "cat" & kittens', $blocks[0]['attrs']['alt'] ); } /** diff --git a/tests/phpunit/tests/includes/handler/class-test-create.php b/tests/phpunit/tests/includes/handler/class-test-create.php index fa74117d64..d5de7c1219 100644 --- a/tests/phpunit/tests/includes/handler/class-test-create.php +++ b/tests/phpunit/tests/includes/handler/class-test-create.php @@ -20,6 +20,102 @@ * @coversDefaultClass \Activitypub\Handler\Create */ class Test_Create extends \WP_UnitTestCase { + /** + * Missing text is allowed only when there is an image the import path can use. + * + * @dataProvider image_only_object_provider + * @covers ::validate_object + * + * @param array $fields The object fields in addition to its id. + * @param bool $expected Whether the object is valid. + */ + public function test_validate_image_only_objects( $fields, $expected ) { + $object = \array_merge( array( 'id' => 'https://example.com/note' ), $fields ); + $request = new \WP_REST_Request( 'POST' ); + $request->set_header( 'Content-Type', 'application/activity+json' ); + $request->set_body( + \wp_json_encode( + array( + 'type' => 'Create', + 'object' => $object, + ) + ) + ); + $this->assertSame( $expected, Create::validate_object( true, $object, $request ) ); + $this->assertFalse( Create::validate_object( false, $object, $request ), 'Earlier validators retain their veto.' ); + unset( $object['id'] ); + $request->set_body( + \wp_json_encode( + array( + 'type' => 'Create', + 'object' => $object, + ) + ) + ); + $this->assertFalse( Create::validate_object( true, $object, $request ), 'Images cannot replace the required object id.' ); + } + + /** + * Text, supported images, and unsupported or malformed media. + * + * @return array The object fields and validation result. + */ + public function image_only_object_provider() { + return array( + 'empty text' => array( array( 'content' => '' ), true ), + 'missing text and image' => array( array(), false ), + 'Image object' => array( + array( + 'attachment' => array( + 'type' => 'Image', + 'url' => 'https://example.com/photo.jpg', + ), + ), + true, + ), + 'image Document' => array( + array( + 'attachment' => array( + array( + 'type' => 'Document', + 'mediaType' => 'IMAGE/JPEG', + 'url' => 'https://example.com/photo.jpg', + ), + ), + ), + true, + ), + 'image fallback' => array( array( 'image' => 'https://example.com/photo.jpg' ), true ), + 'Audio object' => array( + array( + 'attachment' => array( + 'type' => 'Audio', + 'url' => 'https://example.com/audio.mp3', + ), + ), + false, + ), + 'Video object' => array( + array( + 'attachment' => array( + 'type' => 'Video', + 'url' => 'https://example.com/video.mp4', + ), + ), + false, + ), + 'missing image URL' => array( array( 'attachment' => array( 'type' => 'Image' ) ), false ), + 'unsafe protocol' => array( + array( + 'attachment' => array( + 'type' => 'Image', + 'url' => 'javascript:alert(1)', + ), + ), + false, + ), + ); + } /** * User ID. diff --git a/tests/phpunit/tests/includes/rest/class-test-actors-inbox-controller.php b/tests/phpunit/tests/includes/rest/class-test-actors-inbox-controller.php index bfe4ae27a5..db89690e8b 100644 --- a/tests/phpunit/tests/includes/rest/class-test-actors-inbox-controller.php +++ b/tests/phpunit/tests/includes/rest/class-test-actors-inbox-controller.php @@ -320,6 +320,19 @@ public function test_create_request() { $response = \rest_do_request( $request ); $this->assertEquals( 202, $response->get_status() ); + // Image-only replies must pass the same REST validation as text replies. + unset( $json['object']['content'] ); + $json['id'] .= '/image'; + $json['object']['id'] .= '/image'; + $json['object']['attachment'] = array( + 'type' => 'Image', + 'url' => 'https://remote.example/photo.jpg', + ); + $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/users/1/inbox' ); + $request->set_header( 'Content-Type', 'application/activity+json' ); + $request->set_body( \wp_json_encode( $json ) ); + $this->assertSame( 202, \rest_do_request( $request )->get_status() ); + \remove_filter( 'activitypub_defer_signature_verification', '__return_true' ); } @@ -470,7 +483,11 @@ public function test_user_inbox_post_verification() { $request->set_body( $args['body'] ); $request->set_headers( $args['headers'] ); - $response = \rest_do_request( $request ); + // Signature verification reads the query string from the actual HTTP request URI. + $server = $_SERVER; + $_SERVER['REQUEST_URI'] = $route; + $response = \rest_do_request( $request ); + $_SERVER = $server; $this->assertEquals( 202, $response->get_status() ); \remove_filter( 'activitypub_pre_http_get_remote_object', $remote_object_filter ); diff --git a/tests/phpunit/tests/includes/rest/class-test-inbox-controller.php b/tests/phpunit/tests/includes/rest/class-test-inbox-controller.php index 7352b2b3c2..e7c35b3dd6 100644 --- a/tests/phpunit/tests/includes/rest/class-test-inbox-controller.php +++ b/tests/phpunit/tests/includes/rest/class-test-inbox-controller.php @@ -114,6 +114,19 @@ public function test_create_item() { $response = \rest_do_request( $request ); $this->assertEquals( 202, $response->get_status() ); + // Image-only replies must pass the same REST validation as text replies. + unset( $json['object']['content'] ); + $json['id'] .= '/image'; + $json['object']['id'] .= '/image'; + $json['object']['attachment'] = array( + 'type' => 'Image', + 'url' => 'https://remote.example/photo.jpg', + ); + $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/inbox' ); + $request->set_header( 'Content-Type', 'application/activity+json' ); + $request->set_body( \wp_json_encode( $json ) ); + $this->assertSame( 202, \rest_do_request( $request )->get_status() ); + \remove_filter( 'activitypub_defer_signature_verification', '__return_true' ); } From 4bd6520d51377bf95b50b987bf61945b8bb26789 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 2 Oct 2026 16:46:32 +0200 Subject: [PATCH 07/33] Reject malformed and relative remote attachment URLs --- includes/collection/class-remote-posts.php | 3 ++ .../collection/class-test-remote-posts.php | 42 +++++++++++++++++++ .../includes/handler/class-test-create.php | 25 ++++++++++- .../class-test-actors-inbox-controller.php | 14 +++++++ .../rest/class-test-inbox-controller.php | 14 +++++++ 5 files changed, 97 insertions(+), 1 deletion(-) diff --git a/includes/collection/class-remote-posts.php b/includes/collection/class-remote-posts.php index 618810daac..3c49b28675 100644 --- a/includes/collection/class-remote-posts.php +++ b/includes/collection/class-remote-posts.php @@ -430,6 +430,9 @@ public static function extract_attachments( $activity_object ) { if ( empty( $url ) || ! \is_string( $url ) ) { continue; } + if ( ! \filter_var( $url, FILTER_VALIDATE_URL ) || ! \esc_url_raw( $url, array( 'http', 'https' ) ) ) { + continue; + } $mime_type = \is_string( $attachment['mediaType'] ?? null ) ? \strtolower( $attachment['mediaType'] ) : ''; diff --git a/tests/phpunit/tests/includes/collection/class-test-remote-posts.php b/tests/phpunit/tests/includes/collection/class-test-remote-posts.php index aae3406318..70d0b5df47 100644 --- a/tests/phpunit/tests/includes/collection/class-test-remote-posts.php +++ b/tests/phpunit/tests/includes/collection/class-test-remote-posts.php @@ -22,6 +22,48 @@ class Test_Remote_Posts extends \WP_UnitTestCase { use Uri_Test_Cases; + /** + * Invalid attachment URLs are skipped for both attachments and representative images. + * + * @covers ::extract_attachments + */ + public function test_extract_attachments_rejects_invalid_urls() { + $valid = array( + 'type' => 'Image', + 'url' => 'https://example.com/photo.jpg', + 'name' => 'Photo', + ); + foreach ( array( + 42, + true, + array( + 'type' => 'Image', + 'url' => 42, + ), + '/photo.jpg', + '//remote.example/photo.jpg', + 'ftp://remote.example/photo.jpg', + 'https://', + ) as $url ) { + $invalid = array( + 'type' => 'Image', + 'url' => $url, + ); + $this->assertSame( array(), Remote_Posts::extract_attachments( array( 'attachment' => $invalid ) ) ); + $this->assertSame( array(), Remote_Posts::extract_attachments( array( 'image' => $invalid ) ) ); + $this->assertSame( + array( + array( + 'url' => $valid['url'], + 'alt' => 'Photo', + 'type' => 'image', + ), + ), + Remote_Posts::extract_attachments( array( 'attachment' => array( $invalid, $valid ) ) ) + ); + } + } + /** * Lists and single attachments retain URL, description, and type regardless of MIME casing. * diff --git a/tests/phpunit/tests/includes/handler/class-test-create.php b/tests/phpunit/tests/includes/handler/class-test-create.php index d5de7c1219..e0abe4a620 100644 --- a/tests/phpunit/tests/includes/handler/class-test-create.php +++ b/tests/phpunit/tests/includes/handler/class-test-create.php @@ -61,7 +61,7 @@ public function test_validate_image_only_objects( $fields, $expected ) { * @return array The object fields and validation result. */ public function image_only_object_provider() { - return array( + $cases = array( 'empty text' => array( array( 'content' => '' ), true ), 'missing text and image' => array( array(), false ), 'Image object' => array( @@ -115,6 +115,29 @@ public function image_only_object_provider() { false, ), ); + foreach ( array( + 42, + true, + array( + 'type' => 'Image', + 'url' => 42, + ), + '/photo.jpg', + '//remote.example/photo.jpg', + 'ftp://remote.example/photo.jpg', + 'https://', + ) as $url ) { + $cases[] = array( + array( + 'attachment' => array( + 'type' => 'Image', + 'url' => $url, + ), + ), + false, + ); + } + return $cases; } /** diff --git a/tests/phpunit/tests/includes/rest/class-test-actors-inbox-controller.php b/tests/phpunit/tests/includes/rest/class-test-actors-inbox-controller.php index db89690e8b..066bbbf323 100644 --- a/tests/phpunit/tests/includes/rest/class-test-actors-inbox-controller.php +++ b/tests/phpunit/tests/includes/rest/class-test-actors-inbox-controller.php @@ -332,6 +332,20 @@ public function test_create_request() { $request->set_header( 'Content-Type', 'application/activity+json' ); $request->set_body( \wp_json_encode( $json ) ); $this->assertSame( 202, \rest_do_request( $request )->get_status() ); + foreach ( array( + 42, + true, + array( + 'type' => 'Image', + 'url' => 42, + ), + '/photo.jpg', + '//remote.example/photo.jpg', + ) as $url ) { + $json['object']['attachment']['url'] = $url; + $request->set_body( \wp_json_encode( $json ) ); + $this->assertSame( 400, \rest_do_request( $request )->get_status() ); + } \remove_filter( 'activitypub_defer_signature_verification', '__return_true' ); } diff --git a/tests/phpunit/tests/includes/rest/class-test-inbox-controller.php b/tests/phpunit/tests/includes/rest/class-test-inbox-controller.php index e7c35b3dd6..ec9aa31471 100644 --- a/tests/phpunit/tests/includes/rest/class-test-inbox-controller.php +++ b/tests/phpunit/tests/includes/rest/class-test-inbox-controller.php @@ -126,6 +126,20 @@ public function test_create_item() { $request->set_header( 'Content-Type', 'application/activity+json' ); $request->set_body( \wp_json_encode( $json ) ); $this->assertSame( 202, \rest_do_request( $request )->get_status() ); + foreach ( array( + 42, + true, + array( + 'type' => 'Image', + 'url' => 42, + ), + '/photo.jpg', + '//remote.example/photo.jpg', + ) as $url ) { + $json['object']['attachment']['url'] = $url; + $request->set_body( \wp_json_encode( $json ) ); + $this->assertSame( 400, \rest_do_request( $request )->get_status() ); + } \remove_filter( 'activitypub_defer_signature_verification', '__return_true' ); } From 25a784b4c1796dc6c3f765320fe4132bc8ca3828 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Sat, 3 Oct 2026 10:41:15 +0200 Subject: [PATCH 08/33] Limit image attachments in Fediverse comments --- .github/changelog/fix-comment-images | 2 +- includes/collection/class-interactions.php | 28 ++++++- .../collection/class-test-interactions.php | 84 +++++++++++++++++++ 3 files changed, 111 insertions(+), 3 deletions(-) diff --git a/.github/changelog/fix-comment-images b/.github/changelog/fix-comment-images index 4ec52dc425..639235146d 100644 --- a/.github/changelog/fix-comment-images +++ b/.github/changelog/fix-comment-images @@ -1,4 +1,4 @@ Significance: minor Type: added -Display images attached to Fediverse comments, with local caching and image descriptions. +Display up to three images attached to Fediverse comments by default, with local caching and image descriptions. diff --git a/includes/collection/class-interactions.php b/includes/collection/class-interactions.php index d4d950b48a..660b2d039d 100644 --- a/includes/collection/class-interactions.php +++ b/includes/collection/class-interactions.php @@ -161,7 +161,7 @@ public static function update_comment( $activity ) { */ $content = Sanitize::comment_content( $activity['object']['content'] ?? '' ); $content = Emoji::wrap_in_content( $content, $activity['object'] ); - $content .= process_remote_images( '', Remote_Posts::extract_attachments( $activity['object'] ) ); + $content .= process_remote_images( '', self::get_image_attachments( $activity['object'] ) ); $comment_data['comment_content'] = \addslashes( $content ); $result = self::persist( $comment_data, self::UPDATE ); @@ -525,7 +525,7 @@ private static function prepare_remote_comment_data( $activity ) { // Sanitize remote HTML before adding our own emoji and image blocks. $content = Sanitize::comment_content( $activity['object']['content'] ?? '' ); $content = Emoji::wrap_in_content( $content, $activity['object'] ); - $content .= process_remote_images( '', Remote_Posts::extract_attachments( $activity['object'] ) ); + $content .= process_remote_images( '', self::get_image_attachments( $activity['object'] ) ); $comment_content = \addslashes( $content ); return array( @@ -537,6 +537,30 @@ private static function prepare_remote_comment_data( $activity ) { ); } + /** + * Get the image attachments displayed in a remote comment. + * + * @since unreleased + * + * @param array $activity_object The ActivityPub object. + * @return array Image attachments. + */ + private static function get_image_attachments( $activity_object ) { + $attachments = \wp_list_filter( Remote_Posts::extract_attachments( $activity_object ), array( 'type' => 'image' ) ); + + /** + * Filters the maximum number of image attachments displayed in a Fediverse comment. + * + * @since unreleased + * + * @param int $limit Maximum number of images. Default 3. Zero disables image attachments. + * @param array $activity_object The ActivityPub object. + */ + $limit = \max( 0, (int) \apply_filters( 'activitypub_comment_image_limit', 3, $activity_object ) ); + + return \array_slice( $attachments, 0, $limit ); + } + /** * Prepare ActivityPub-specific comment metadata. * diff --git a/tests/phpunit/tests/includes/collection/class-test-interactions.php b/tests/phpunit/tests/includes/collection/class-test-interactions.php index d11a375e46..444d00136e 100644 --- a/tests/phpunit/tests/includes/collection/class-test-interactions.php +++ b/tests/phpunit/tests/includes/collection/class-test-interactions.php @@ -148,6 +148,90 @@ public function test_update_comment_images() { $this->assertStringNotContainsString( '' ), + ); + } + /** * Create and update strip remote blocks before adding our own image blocks. * diff --git a/tests/phpunit/tests/includes/collection/class-test-remote-posts.php b/tests/phpunit/tests/includes/collection/class-test-remote-posts.php index 70d0b5df47..6e7eb6022c 100644 --- a/tests/phpunit/tests/includes/collection/class-test-remote-posts.php +++ b/tests/phpunit/tests/includes/collection/class-test-remote-posts.php @@ -44,6 +44,15 @@ public function test_extract_attachments_rejects_invalid_urls() { '//remote.example/photo.jpg', 'ftp://remote.example/photo.jpg', 'https://', + 'https:photo.jpg', + 'javascript:alert(1)', + 'http://127.0.0.1/photo.jpg', + 'http://10.0.0.1/photo.jpg', + 'http://172.16.0.1/photo.jpg', + 'http://192.168.1.1/photo.jpg', + 'http://[::1]/photo.jpg', + 'https://user:pass@example.com/photo.jpg', + 'https://example.com:22/photo.jpg', ) as $url ) { $invalid = array( 'type' => 'Image', @@ -64,6 +73,34 @@ public function test_extract_attachments_rejects_invalid_urls() { } } + /** + * Unicode paths remain usable for image, audio, and video attachments. + * + * @covers ::extract_attachments + */ + public function test_extract_attachments_preserves_unicode_urls() { + foreach ( array( + 'image' => 'https://example.com/uploads/写真.jpg', + 'audio' => 'http://example.com/uploads/音声.mp3', + 'video' => 'HTTPS://example.com/uploads/動画.mp4', + ) as $type => $url ) { + $attachment = array( + 'type' => \ucfirst( $type ), + 'url' => $url, + ); + $this->assertSame( + array( + array( + 'url' => $url, + 'alt' => '', + 'type' => $type, + ), + ), + Remote_Posts::extract_attachments( array( 'attachment' => $attachment ) ) + ); + } + } + /** * Lists and single attachments retain URL, description, and type regardless of MIME casing. * @@ -75,6 +112,11 @@ public function test_extract_attachments_classifies_object_types_and_mime_casing array( array( 'type' => 'Video' ), 'video' ), array( array( 'type' => 'Image' ), 'image' ), array( array( 'type' => 'Document' ), 'document' ), + array( array( 'type' => 42 ), 'document' ), + array( array( 'type' => null ), 'document' ), + array( array( 'type' => true ), 'document' ), + array( array( 'type' => array( 'Image' ) ), 'document' ), + array( array(), 'image' ), array( array( 'mediaType' => 'IMAGE/JPEG' ), 'image' ), array( array( 'mediaType' => 'AUDIO/MPEG' ), 'audio' ), array( array( 'mediaType' => 'VIDEO/MP4' ), 'video' ), diff --git a/tests/phpunit/tests/includes/handler/class-test-create.php b/tests/phpunit/tests/includes/handler/class-test-create.php index e0abe4a620..551951d7d4 100644 --- a/tests/phpunit/tests/includes/handler/class-test-create.php +++ b/tests/phpunit/tests/includes/handler/class-test-create.php @@ -86,6 +86,16 @@ public function image_only_object_provider() { true, ), 'image fallback' => array( array( 'image' => 'https://example.com/photo.jpg' ), true ), + 'Unicode image URL' => array( + array( + 'attachment' => array( + 'type' => 'Image', + 'url' => 'https://example.com/uploads/写真.jpg', + ), + ), + true, + ), + 'Unicode image fallback' => array( array( 'image' => 'https://example.com/uploads/写真.jpg' ), true ), 'Audio object' => array( array( 'attachment' => array( @@ -126,6 +136,9 @@ public function image_only_object_provider() { '//remote.example/photo.jpg', 'ftp://remote.example/photo.jpg', 'https://', + 'http://127.0.0.1/photo.jpg', + 'http://192.168.1.1/photo.jpg', + 'https://user:pass@example.com/photo.jpg', ) as $url ) { $cases[] = array( array( @@ -137,6 +150,17 @@ public function image_only_object_provider() { false, ); } + foreach ( array( 42, true, null, array( 'Image' ) ) as $type ) { + $cases[] = array( + array( + 'attachment' => array( + 'type' => $type, + 'url' => 'https://example.com/photo.jpg', + ), + ), + false, + ); + } return $cases; } diff --git a/tests/phpunit/tests/includes/rest/class-test-actors-inbox-controller.php b/tests/phpunit/tests/includes/rest/class-test-actors-inbox-controller.php index 066bbbf323..7c23e7b9ff 100644 --- a/tests/phpunit/tests/includes/rest/class-test-actors-inbox-controller.php +++ b/tests/phpunit/tests/includes/rest/class-test-actors-inbox-controller.php @@ -326,7 +326,7 @@ public function test_create_request() { $json['object']['id'] .= '/image'; $json['object']['attachment'] = array( 'type' => 'Image', - 'url' => 'https://remote.example/photo.jpg', + 'url' => 'https://example.com/photo.jpg', ); $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/users/1/inbox' ); $request->set_header( 'Content-Type', 'application/activity+json' ); @@ -341,6 +341,7 @@ public function test_create_request() { ), '/photo.jpg', '//remote.example/photo.jpg', + 'http://127.0.0.1/photo.jpg', ) as $url ) { $json['object']['attachment']['url'] = $url; $request->set_body( \wp_json_encode( $json ) ); diff --git a/tests/phpunit/tests/includes/rest/class-test-inbox-controller.php b/tests/phpunit/tests/includes/rest/class-test-inbox-controller.php index ec9aa31471..840f4ee1d8 100644 --- a/tests/phpunit/tests/includes/rest/class-test-inbox-controller.php +++ b/tests/phpunit/tests/includes/rest/class-test-inbox-controller.php @@ -120,7 +120,7 @@ public function test_create_item() { $json['object']['id'] .= '/image'; $json['object']['attachment'] = array( 'type' => 'Image', - 'url' => 'https://remote.example/photo.jpg', + 'url' => 'https://example.com/photo.jpg', ); $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/inbox' ); $request->set_header( 'Content-Type', 'application/activity+json' ); @@ -135,6 +135,7 @@ public function test_create_item() { ), '/photo.jpg', '//remote.example/photo.jpg', + 'http://127.0.0.1/photo.jpg', ) as $url ) { $json['object']['attachment']['url'] = $url; $request->set_body( \wp_json_encode( $json ) ); From 4b240d6a5a1e65634057601ddf1eb17dcf5b8a4b Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 8 Oct 2026 12:50:29 +0200 Subject: [PATCH 10/33] Bound comment attachment validation and accept singular URLs --- includes/collection/class-interactions.php | 4 +- includes/collection/class-remote-posts.php | 45 +++++++---- includes/handler/class-create.php | 2 +- .../collection/class-test-interactions.php | 23 ++++-- .../collection/class-test-remote-posts.php | 76 +++++++++++++++++++ .../includes/handler/class-test-create.php | 45 +++++++++++ 6 files changed, 170 insertions(+), 25 deletions(-) diff --git a/includes/collection/class-interactions.php b/includes/collection/class-interactions.php index 660b2d039d..bed73db051 100644 --- a/includes/collection/class-interactions.php +++ b/includes/collection/class-interactions.php @@ -546,8 +546,6 @@ private static function prepare_remote_comment_data( $activity ) { * @return array Image attachments. */ private static function get_image_attachments( $activity_object ) { - $attachments = \wp_list_filter( Remote_Posts::extract_attachments( $activity_object ), array( 'type' => 'image' ) ); - /** * Filters the maximum number of image attachments displayed in a Fediverse comment. * @@ -558,7 +556,7 @@ private static function get_image_attachments( $activity_object ) { */ $limit = \max( 0, (int) \apply_filters( 'activitypub_comment_image_limit', 3, $activity_object ) ); - return \array_slice( $attachments, 0, $limit ); + return Remote_Posts::extract_attachments( $activity_object, $limit, 'image' ); } /** diff --git a/includes/collection/class-remote-posts.php b/includes/collection/class-remote-posts.php index 9d19ba0f97..b6dd897c7b 100644 --- a/includes/collection/class-remote-posts.php +++ b/includes/collection/class-remote-posts.php @@ -381,12 +381,20 @@ private static function add_taxonomies( $post_id, $activity_object ) { * Extract media attachments from an activity object. * * Extracts attachments with URL, alt text, and media type for appending to content. + * Inspects at most 20 candidates, or the requested limit if higher, including unusable items. * - * @param array $activity_object The activity object data. + * @param array $activity_object The activity object data. + * @param int $limit Maximum number of attachments. Default 20. + * @param string $attachment_type Optional media type to include, such as 'image'. * * @return array Array of attachments with 'url', 'alt', and 'type' keys. */ - public static function extract_attachments( $activity_object ) { + public static function extract_attachments( $activity_object, $limit = 20, $attachment_type = '' ) { + $limit = (int) $limit; + if ( $limit <= 0 ) { + return array(); + } + $items = $activity_object['attachment'] ?? array(); /* @@ -395,13 +403,11 @@ public static function extract_attachments( $activity_object ) { */ if ( empty( $items ) && ! empty( $activity_object['image'] ) ) { $items = $activity_object['image']; - - // AS2 allows a bare URL string for `image`. - if ( \is_string( $items ) ) { - $items = array( 'url' => $items ); - } } + if ( \is_string( $items ) ) { + $items = array( 'url' => $items ); + } if ( \is_object( $items ) ) { $items = \get_object_vars( $items ); } @@ -414,7 +420,7 @@ public static function extract_attachments( $activity_object ) { } $attachments = array(); - foreach ( $items as $attachment ) { + foreach ( \array_slice( $items, 0, \max( 20, $limit ) ) as $attachment ) { if ( \is_object( $attachment ) ) { $attachment = \get_object_vars( $attachment ); } @@ -430,14 +436,6 @@ public static function extract_attachments( $activity_object ) { if ( empty( $url ) || ! \is_string( $url ) ) { continue; } - if ( - ! \wp_http_validate_url( $url ) || - ! \in_array( \strtolower( \wp_parse_url( $url, PHP_URL_SCHEME ) ?? '' ), array( 'http', 'https' ), true ) || - ! \esc_url_raw( $url, array( 'http', 'https' ) ) - ) { - continue; - } - $mime_type = \is_string( $attachment['mediaType'] ?? null ) ? \strtolower( $attachment['mediaType'] ) : ''; if ( '' === $mime_type ) { @@ -454,12 +452,27 @@ public static function extract_attachments( $activity_object ) { $type = 'document'; } + if ( $attachment_type && $attachment_type !== $type ) { + continue; + } + + if ( + ! \wp_http_validate_url( $url ) || + ! \in_array( \strtolower( \wp_parse_url( $url, PHP_URL_SCHEME ) ?? '' ), array( 'http', 'https' ), true ) || + ! \esc_url_raw( $url, array( 'http', 'https' ) ) + ) { + continue; + } + $attachments[] = array( 'url' => $url, // Descriptions are plain text; HTML attribute escaping happens when building the image. 'alt' => \is_string( $attachment['name'] ?? null ) ? \wp_check_invalid_utf8( $attachment['name'] ) : '', 'type' => $type, ); + if ( \count( $attachments ) >= $limit ) { + break; + } } return $attachments; diff --git a/includes/handler/class-create.php b/includes/handler/class-create.php index bc69d5ef46..9450da2e43 100644 --- a/includes/handler/class-create.php +++ b/includes/handler/class-create.php @@ -154,7 +154,7 @@ public static function validate_object( $valid, $param, $request ) { return false; } if ( ! isset( $activity['object']['content'] ) ) { - foreach ( Remote_Posts::extract_attachments( $activity['object'] ) as $attachment ) { + foreach ( Remote_Posts::extract_attachments( $activity['object'], 1, 'image' ) as $attachment ) { if ( 'image' === $attachment['type'] && \esc_url_raw( $attachment['url'], array( 'http', 'https' ) ) ) { return $valid; } diff --git a/tests/phpunit/tests/includes/collection/class-test-interactions.php b/tests/phpunit/tests/includes/collection/class-test-interactions.php index f9d5628caf..a406f54f10 100644 --- a/tests/phpunit/tests/includes/collection/class-test-interactions.php +++ b/tests/phpunit/tests/includes/collection/class-test-interactions.php @@ -274,18 +274,18 @@ public function test_comment_image_attachment_limit( $limit, $expected ) { ), array( 'type' => 'Audio', - 'url' => 'https://example.com/audio.mp3', + 'url' => 'https://8.8.4.1:443/audio.mp3', ), array( 'type' => 'Video', - 'url' => 'https://example.com/video.mp4', + 'url' => 'https://8.8.4.1:443/video.mp4', ), ); for ( $i = 1; $i <= 5; ++$i ) { $activity['object']['attachment'][] = array( 'type' => 'Document', 'mediaType' => 'image/jpeg', - 'url' => 'https://example.com/photo-' . $i . '.jpg', + 'url' => 'https://8.8.4.1:443/photo-' . $i . '.jpg', ); } $limit_filter = static function () use ( $limit ) { @@ -295,8 +295,18 @@ public function test_comment_image_attachment_limit( $limit, $expected ) { \add_filter( 'activitypub_comment_image_limit', $limit_filter ); } - $comment_id = Interactions::add_comment( $activity ); - $created = \get_comment( $comment_id )->comment_content; + $validations = 0; + $track = static function ( $ports, $host ) use ( &$validations ) { + if ( '8.8.4.1' === $host ) { + ++$validations; + } + return $ports; + }; + \add_filter( 'http_allowed_safe_ports', $track, 10, 2 ); + $comment_id = Interactions::add_comment( $activity ); + $created = \get_comment( $comment_id )->comment_content; + $create_validations = $validations; + $validations = 0; $activity['object']['content'] = 'Updated text.'; foreach ( $activity['object']['attachment'] as &$attachment ) { @@ -306,7 +316,10 @@ public function test_comment_image_attachment_limit( $limit, $expected ) { $result = Interactions::update_comment( $activity ); $updated = \get_comment( $comment_id )->comment_content; \remove_filter( 'activitypub_comment_image_limit', $limit_filter ); + \remove_filter( 'http_allowed_safe_ports', $track ); + $this->assertSame( $expected, $create_validations, 'Creation validates only the images it will display.' ); + $this->assertSame( $expected, $validations, 'Updates validate only the images they will display.' ); $this->assertIsInt( $comment_id ); $this->assertNotWPError( $result ); $this->assertNotFalse( $result ); diff --git a/tests/phpunit/tests/includes/collection/class-test-remote-posts.php b/tests/phpunit/tests/includes/collection/class-test-remote-posts.php index 6e7eb6022c..3a1198606f 100644 --- a/tests/phpunit/tests/includes/collection/class-test-remote-posts.php +++ b/tests/phpunit/tests/includes/collection/class-test-remote-posts.php @@ -22,6 +22,82 @@ class Test_Remote_Posts extends \WP_UnitTestCase { use Uri_Test_Cases; + /** + * A single attachment URL is handled like the same URL in a list. + * + * @covers ::extract_attachments + */ + public function test_extract_single_attachment_url() { + $url = 'https://example.com/photo.jpg'; + $expected = array( + array( + 'url' => $url, + 'alt' => '', + 'type' => 'image', + ), + ); + foreach ( array( 'attachment', 'image' ) as $field ) { + foreach ( array( $url, array( $url ) ) as $value ) { + $this->assertSame( $expected, Remote_Posts::extract_attachments( array( $field => $value ) ) ); + } + $this->assertSame( array(), Remote_Posts::extract_attachments( array( $field => 'http://127.0.0.1/photo.jpg' ) ) ); + } + } + + /** + * Extraction bounds validation attempts, including lists with no usable images. + * + * @dataProvider bounded_attachment_provider + * @covers ::extract_attachments + * + * @param int $limit Requested result limit. + * @param string $type Requested attachment type. + * @param string $item_type Type of each supplied attachment. + * @param int $port Attachment URL port. + * @param int $expected Expected attachment count. + * @param int $validations Expected URL validation count. + */ + public function test_extract_attachments_bounds_validation( $limit, $type, $item_type, $port, $expected, $validations ) { + $items = array(); + for ( $i = 1; $i <= 50; ++$i ) { + $items[] = array( + 'type' => $item_type, + 'url' => 'https://8.8.4.' . $i . ':' . $port . '/bounded.jpg', + ); + } + $count = 0; + $track = static function ( $ports ) use ( &$count ) { + ++$count; + return $ports; + }; + foreach ( array( 'attachment', 'image' ) as $field ) { + \add_filter( 'http_allowed_safe_ports', $track ); + $count = 0; + $result = Remote_Posts::extract_attachments( array( $field => $items ), $limit, $type ); + \remove_filter( 'http_allowed_safe_ports', $track ); + $this->assertCount( $expected, $result ); + $this->assertSame( $validations, $count ); + } + } + + /** + * Result limits and candidate limits are separate. + * + * @return array Test cases. + */ + public function bounded_attachment_provider() { + return array( + 'first image' => array( 1, 'image', 'Image', 443, 1, 1 ), + 'three images' => array( 3, 'image', 'Image', 443, 3, 3 ), + 'no images' => array( 0, 'image', 'Image', 443, 0, 0 ), + 'negative limit' => array( -1, 'image', 'Image', 443, 0, 0 ), + 'invalid images' => array( 3, 'image', 'Image', 22, 0, 20 ), + 'other media' => array( 3, 'image', 'Video', 443, 0, 0 ), + 'default media' => array( 20, '', 'Video', 443, 20, 20 ), + 'larger site limit' => array( 25, 'image', 'Image', 443, 25, 25 ), + ); + } + /** * Invalid attachment URLs are skipped for both attachments and representative images. * diff --git a/tests/phpunit/tests/includes/handler/class-test-create.php b/tests/phpunit/tests/includes/handler/class-test-create.php index 551951d7d4..0e7d085701 100644 --- a/tests/phpunit/tests/includes/handler/class-test-create.php +++ b/tests/phpunit/tests/includes/handler/class-test-create.php @@ -20,6 +20,49 @@ * @coversDefaultClass \Activitypub\Handler\Create */ class Test_Create extends \WP_UnitTestCase { + /** + * Image-only validation stops at the first usable image and bounds rejected candidates. + * + * @covers ::validate_object + */ + public function test_image_only_validation_is_bounded() { + foreach ( array( + 443 => 1, + 22 => 20, + ) as $port => $expected ) { + $object = array( + 'id' => 'https://example.com/bounded-images', + 'attachment' => array(), + ); + for ( $i = 1; $i <= 50; ++$i ) { + $object['attachment'][] = array( + 'type' => 'Image', + 'url' => 'https://8.8.4.' . $i . ':' . $port . '/photo.jpg', + ); + } + $request = new \WP_REST_Request( 'POST' ); + $request->set_header( 'Content-Type', 'application/activity+json' ); + $request->set_body( + \wp_json_encode( + array( + 'type' => 'Create', + 'object' => $object, + ) + ) + ); + $count = 0; + $track = static function ( $ports ) use ( &$count ) { + ++$count; + return $ports; + }; + \add_filter( 'http_allowed_safe_ports', $track ); + $valid = Create::validate_object( true, $object, $request ); + \remove_filter( 'http_allowed_safe_ports', $track ); + $this->assertSame( 443 === $port, $valid ); + $this->assertSame( $expected, $count ); + } + } + /** * Missing text is allowed only when there is an image the import path can use. * @@ -86,6 +129,8 @@ public function image_only_object_provider() { true, ), 'image fallback' => array( array( 'image' => 'https://example.com/photo.jpg' ), true ), + 'single attachment URL' => array( array( 'attachment' => 'https://example.com/photo.jpg' ), true ), + 'unsafe attachment URL' => array( array( 'attachment' => 'http://127.0.0.1/photo.jpg' ), false ), 'Unicode image URL' => array( array( 'attachment' => array( From a28dd613513251f6b0a533a765dbc695564e1eff Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 19 Oct 2026 18:09:26 +0200 Subject: [PATCH 11/33] keep the host check for comment images --- includes/collection/class-interactions.php | 17 ++++++++++++++++- includes/handler/class-create.php | 5 +++-- .../includes/handler/class-test-create.php | 3 ++- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/includes/collection/class-interactions.php b/includes/collection/class-interactions.php index bed73db051..4f3fc75808 100644 --- a/includes/collection/class-interactions.php +++ b/includes/collection/class-interactions.php @@ -556,7 +556,22 @@ private static function get_image_attachments( $activity_object ) { */ $limit = \max( 0, (int) \apply_filters( 'activitypub_comment_image_limit', 3, $activity_object ) ); - return Remote_Posts::extract_attachments( $activity_object, $limit, 'image' ); + if ( ! $limit ) { + return array(); + } + + $images = array(); + foreach ( Remote_Posts::extract_attachments( $activity_object, \max( 20, $limit ), 'image' ) as $attachment ) { + // Comments show images to every visitor, so the host must be public. + if ( \wp_http_validate_url( $attachment['url'] ) ) { + $images[] = $attachment; + if ( \count( $images ) >= $limit ) { + break; + } + } + } + + return $images; } /** diff --git a/includes/handler/class-create.php b/includes/handler/class-create.php index 9450da2e43..d94510e312 100644 --- a/includes/handler/class-create.php +++ b/includes/handler/class-create.php @@ -154,8 +154,9 @@ public static function validate_object( $valid, $param, $request ) { return false; } if ( ! isset( $activity['object']['content'] ) ) { - foreach ( Remote_Posts::extract_attachments( $activity['object'], 1, 'image' ) as $attachment ) { - if ( 'image' === $attachment['type'] && \esc_url_raw( $attachment['url'], array( 'http', 'https' ) ) ) { + foreach ( Remote_Posts::extract_attachments( $activity['object'], 20, 'image' ) as $attachment ) { + // Comments show images to every visitor, so the host must be public. + if ( \wp_http_validate_url( $attachment['url'] ) ) { return $valid; } } diff --git a/tests/phpunit/tests/includes/handler/class-test-create.php b/tests/phpunit/tests/includes/handler/class-test-create.php index 0e7d085701..a02cb094b7 100644 --- a/tests/phpunit/tests/includes/handler/class-test-create.php +++ b/tests/phpunit/tests/includes/handler/class-test-create.php @@ -195,6 +195,7 @@ public function image_only_object_provider() { false, ); } + // Without a media type, anything but Audio and Video is read as an image. foreach ( array( 42, true, null, array( 'Image' ) ) as $type ) { $cases[] = array( array( @@ -203,7 +204,7 @@ public function image_only_object_provider() { 'url' => 'https://example.com/photo.jpg', ), ), - false, + true, ); } return $cases; From 73ebc0bef4d1d24e482e0b36affceeeff6db48bc Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 19 Oct 2026 19:23:22 +0200 Subject: [PATCH 12/33] document the attachment shape --- includes/collection/class-remote-posts.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/collection/class-remote-posts.php b/includes/collection/class-remote-posts.php index f418adb43b..5a8d09ed91 100644 --- a/includes/collection/class-remote-posts.php +++ b/includes/collection/class-remote-posts.php @@ -387,7 +387,7 @@ private static function add_taxonomies( $post_id, $activity_object ) { * @param int $limit Maximum number of attachments. Default 20. * @param string $attachment_type Optional media type to include, such as 'image'. * - * @return array Array of attachments with 'url', 'alt', and 'type' keys. + * @return array Array of attachments with 'url', 'alt', and 'type' keys. */ public static function extract_attachments( $activity_object, $limit = 20, $attachment_type = '' ) { $limit = (int) $limit; From ad9a7960a4769dba0b0fa6c10a54bddc497105aa Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 20 Oct 2026 17:17:36 +0200 Subject: [PATCH 13/33] make comment images opt-in --- .github/changelog/fix-comment-images | 2 +- includes/class-options.php | 11 ++++++++ includes/collection/class-interactions.php | 5 ++++ includes/handler/class-create.php | 5 ++++ includes/wp-admin/class-settings-fields.php | 8 ++++++ .../collection/class-test-interactions.php | 25 +++++++++++++++++ .../includes/handler/class-test-create.php | 28 +++++++++++++++++++ .../class-test-actors-inbox-controller.php | 2 ++ .../rest/class-test-inbox-controller.php | 2 ++ 9 files changed, 87 insertions(+), 1 deletion(-) diff --git a/.github/changelog/fix-comment-images b/.github/changelog/fix-comment-images index 639235146d..7dbae3f081 100644 --- a/.github/changelog/fix-comment-images +++ b/.github/changelog/fix-comment-images @@ -1,4 +1,4 @@ Significance: minor Type: added -Display up to three images attached to Fediverse comments by default, with local caching and image descriptions. +Add an option to show images attached to Fediverse replies in comments, with local caching and image descriptions. diff --git a/includes/class-options.php b/includes/class-options.php index c0a187a684..d918b942f6 100644 --- a/includes/class-options.php +++ b/includes/class-options.php @@ -191,6 +191,17 @@ public static function register_settings() { ) ); + \register_setting( + 'activitypub', + 'activitypub_allow_comment_images', + array( + 'type' => 'integer', + 'description' => 'Show images attached to replies.', + 'default' => '0', + 'sanitize_callback' => 'absint', + ) + ); + \register_setting( 'activitypub', 'activitypub_default_quote_policy', diff --git a/includes/collection/class-interactions.php b/includes/collection/class-interactions.php index 4f3fc75808..521212f7b0 100644 --- a/includes/collection/class-interactions.php +++ b/includes/collection/class-interactions.php @@ -546,6 +546,11 @@ private static function prepare_remote_comment_data( $activity ) { * @return array Image attachments. */ private static function get_image_attachments( $activity_object ) { + // WordPress does not show images in comments, so this is opt-in. + if ( ! \get_option( 'activitypub_allow_comment_images' ) ) { + return array(); + } + /** * Filters the maximum number of image attachments displayed in a Fediverse comment. * diff --git a/includes/handler/class-create.php b/includes/handler/class-create.php index d94510e312..dc4260fd2e 100644 --- a/includes/handler/class-create.php +++ b/includes/handler/class-create.php @@ -154,6 +154,11 @@ public static function validate_object( $valid, $param, $request ) { return false; } if ( ! isset( $activity['object']['content'] ) ) { + // Image-only replies have something to show only when comment images are enabled. + if ( ! \get_option( 'activitypub_allow_comment_images' ) ) { + return false; + } + foreach ( Remote_Posts::extract_attachments( $activity['object'], 20, 'image' ) as $attachment ) { // Comments show images to every visitor, so the host must be public. if ( \wp_http_validate_url( $attachment['url'] ) ) { diff --git a/includes/wp-admin/class-settings-fields.php b/includes/wp-admin/class-settings-fields.php index 5d6dff20a4..61e0ada2bd 100644 --- a/includes/wp-admin/class-settings-fields.php +++ b/includes/wp-admin/class-settings-fields.php @@ -381,6 +381,7 @@ public static function render_allow_interactions_field() { $allow_likes = \get_option( 'activitypub_allow_likes', '1' ); $allow_reposts = \get_option( 'activitypub_allow_reposts', '1' ); $auto_approve = \get_option( 'activitypub_auto_approve_reactions', '0' ); + $images = \get_option( 'activitypub_allow_comment_images', '0' ); ?>

@@ -402,6 +403,13 @@ public static function render_allow_interactions_field() {

+

+ +

+

assertStringContainsString( 'Updated text.', $content ); } + /** + * Image attachments are ignored while comment images are disabled. + * + * @group activitypub + * @covers ::add_comment + */ + public function test_comment_images_disabled_by_default() { + \delete_option( 'activitypub_allow_comment_images' ); + + $activity = $this->create_test_object( 'https://example.com/images-disabled' ); + $activity['object']['attachment'] = array( + array( + 'type' => 'Image', + 'url' => 'https://example.com/photo.jpg', + ), + ); + + $comment = \get_comment( Interactions::add_comment( $activity ) ); + + $this->assertStringNotContainsString( 'activitypub/image', $comment->comment_content ); + $this->assertStringNotContainsString( 'photo.jpg', $comment->comment_content ); + } + /** * Image-only comments reuse the post's lazy image cache and retain alt text. * @@ -503,6 +526,7 @@ public function set_up() { \add_filter( 'pre_get_remote_metadata_by_actor', array( __CLASS__, 'get_remote_metadata_by_actor' ), 0, 2 ); \add_filter( 'activitypub_pre_import_emoji', array( $this, 'mock_emoji_import' ), 10, 2 ); + \update_option( 'activitypub_allow_comment_images', '1' ); } /** @@ -510,6 +534,7 @@ public function set_up() { */ public function tear_down() { \remove_filter( 'activitypub_pre_import_emoji', array( $this, 'mock_emoji_import' ) ); + \delete_option( 'activitypub_allow_comment_images' ); parent::tear_down(); } diff --git a/tests/phpunit/tests/includes/handler/class-test-create.php b/tests/phpunit/tests/includes/handler/class-test-create.php index a02cb094b7..1e566dc094 100644 --- a/tests/phpunit/tests/includes/handler/class-test-create.php +++ b/tests/phpunit/tests/includes/handler/class-test-create.php @@ -98,6 +98,32 @@ public function test_validate_image_only_objects( $fields, $expected ) { $this->assertFalse( Create::validate_object( true, $object, $request ), 'Images cannot replace the required object id.' ); } + /** + * Image-only replies are rejected while comment images are disabled. + * + * @covers ::validate_object + */ + public function test_image_only_object_rejected_without_comment_images() { + \delete_option( 'activitypub_allow_comment_images' ); + + $object = array( + 'id' => 'https://example.com/note', + 'image' => 'https://example.com/photo.jpg', + ); + $request = new \WP_REST_Request( 'POST' ); + $request->set_header( 'Content-Type', 'application/activity+json' ); + $request->set_body( + \wp_json_encode( + array( + 'type' => 'Create', + 'object' => $object, + ) + ) + ); + + $this->assertFalse( Create::validate_object( true, $object, $request ) ); + } + /** * Text, supported images, and unsupported or malformed media. * @@ -262,6 +288,7 @@ public function set_up() { $this->post_permalink = \get_permalink( $this->post_id ); \add_filter( 'pre_get_remote_metadata_by_actor', array( get_called_class(), 'get_remote_metadata_by_actor' ), 0, 2 ); + \update_option( 'activitypub_allow_comment_images', '1' ); } /** @@ -269,6 +296,7 @@ public function set_up() { */ public function tear_down() { \remove_filter( 'pre_get_remote_metadata_by_actor', array( get_called_class(), 'get_remote_metadata_by_actor' ) ); + \delete_option( 'activitypub_allow_comment_images' ); parent::tear_down(); } diff --git a/tests/phpunit/tests/includes/rest/class-test-actors-inbox-controller.php b/tests/phpunit/tests/includes/rest/class-test-actors-inbox-controller.php index c20d04b610..a6eadb2c84 100644 --- a/tests/phpunit/tests/includes/rest/class-test-actors-inbox-controller.php +++ b/tests/phpunit/tests/includes/rest/class-test-actors-inbox-controller.php @@ -336,6 +336,7 @@ public function test_create_request() { $this->assertEquals( 202, $response->get_status() ); // Image-only replies must pass the same REST validation as text replies. + \update_option( 'activitypub_allow_comment_images', '1' ); unset( $json['object']['content'] ); $json['id'] .= '/image'; $json['object']['id'] .= '/image'; @@ -362,6 +363,7 @@ public function test_create_request() { $request->set_body( \wp_json_encode( $json ) ); $this->assertSame( 400, \rest_do_request( $request )->get_status() ); } + \delete_option( 'activitypub_allow_comment_images' ); \remove_filter( 'activitypub_defer_signature_verification', '__return_true' ); } diff --git a/tests/phpunit/tests/includes/rest/class-test-inbox-controller.php b/tests/phpunit/tests/includes/rest/class-test-inbox-controller.php index 840f4ee1d8..5318b89ef8 100644 --- a/tests/phpunit/tests/includes/rest/class-test-inbox-controller.php +++ b/tests/phpunit/tests/includes/rest/class-test-inbox-controller.php @@ -115,6 +115,7 @@ public function test_create_item() { $this->assertEquals( 202, $response->get_status() ); // Image-only replies must pass the same REST validation as text replies. + \update_option( 'activitypub_allow_comment_images', '1' ); unset( $json['object']['content'] ); $json['id'] .= '/image'; $json['object']['id'] .= '/image'; @@ -141,6 +142,7 @@ public function test_create_item() { $request->set_body( \wp_json_encode( $json ) ); $this->assertSame( 400, \rest_do_request( $request )->get_status() ); } + \delete_option( 'activitypub_allow_comment_images' ); \remove_filter( 'activitypub_defer_signature_verification', '__return_true' ); } From c0fb23eaba62caeb564b9b9c64dd1bd66dc08335 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 21 Oct 2026 11:44:10 +0200 Subject: [PATCH 14/33] accept image-only objects only as replies --- includes/handler/class-create.php | 4 +-- .../includes/handler/class-test-create.php | 34 ++++++++++++++++++- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/includes/handler/class-create.php b/includes/handler/class-create.php index dc4260fd2e..bebb0d55ae 100644 --- a/includes/handler/class-create.php +++ b/includes/handler/class-create.php @@ -154,8 +154,8 @@ public static function validate_object( $valid, $param, $request ) { return false; } if ( ! isset( $activity['object']['content'] ) ) { - // Image-only replies have something to show only when comment images are enabled. - if ( ! \get_option( 'activitypub_allow_comment_images' ) ) { + // Image-only replies have something to show only when comment images are enabled; other posts still need text. + if ( ! \get_option( 'activitypub_allow_comment_images' ) || ! is_activity_reply( $activity ) ) { return false; } diff --git a/tests/phpunit/tests/includes/handler/class-test-create.php b/tests/phpunit/tests/includes/handler/class-test-create.php index 1e566dc094..13490d7fb9 100644 --- a/tests/phpunit/tests/includes/handler/class-test-create.php +++ b/tests/phpunit/tests/includes/handler/class-test-create.php @@ -32,6 +32,7 @@ public function test_image_only_validation_is_bounded() { ) as $port => $expected ) { $object = array( 'id' => 'https://example.com/bounded-images', + 'inReplyTo' => 'https://example.org/post', 'attachment' => array(), ); for ( $i = 1; $i <= 50; ++$i ) { @@ -73,7 +74,13 @@ public function test_image_only_validation_is_bounded() { * @param bool $expected Whether the object is valid. */ public function test_validate_image_only_objects( $fields, $expected ) { - $object = \array_merge( array( 'id' => 'https://example.com/note' ), $fields ); + $object = \array_merge( + array( + 'id' => 'https://example.com/note', + 'inReplyTo' => 'https://example.org/post', + ), + $fields + ); $request = new \WP_REST_Request( 'POST' ); $request->set_header( 'Content-Type', 'application/activity+json' ); $request->set_body( @@ -106,6 +113,31 @@ public function test_validate_image_only_objects( $fields, $expected ) { public function test_image_only_object_rejected_without_comment_images() { \delete_option( 'activitypub_allow_comment_images' ); + $object = array( + 'id' => 'https://example.com/note', + 'inReplyTo' => 'https://example.org/post', + 'image' => 'https://example.com/photo.jpg', + ); + $request = new \WP_REST_Request( 'POST' ); + $request->set_header( 'Content-Type', 'application/activity+json' ); + $request->set_body( + \wp_json_encode( + array( + 'type' => 'Create', + 'object' => $object, + ) + ) + ); + + $this->assertFalse( Create::validate_object( true, $object, $request ) ); + } + + /** + * Image-only posts that are not replies still need text, even with comment images enabled. + * + * @covers ::validate_object + */ + public function test_image_only_object_rejected_when_not_a_reply() { $object = array( 'id' => 'https://example.com/note', 'image' => 'https://example.com/photo.jpg', From 2971a2a1fd61a81507a0824048f3401b7c7344cb Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 21 Oct 2026 12:27:29 +0200 Subject: [PATCH 15/33] hide stored images when comment images are off --- includes/class-comment.php | 2 +- .../tests/includes/class-test-comment.php | 57 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/includes/class-comment.php b/includes/class-comment.php index eba41ff1e6..1e77984b1c 100644 --- a/includes/class-comment.php +++ b/includes/class-comment.php @@ -71,7 +71,7 @@ public static function render_blocks( $content, $comment = null ) { \add_filter( 'render_block_context', $context_filter ); $allowed_blocks = array( 'activitypub/emoji' ); - if ( $comment instanceof \WP_Comment && self::was_received( $comment ) ) { + if ( $comment instanceof \WP_Comment && self::was_received( $comment ) && \get_option( 'activitypub_allow_comment_images' ) ) { $allowed_blocks[] = 'activitypub/image'; } diff --git a/tests/phpunit/tests/includes/class-test-comment.php b/tests/phpunit/tests/includes/class-test-comment.php index 05c7e875a2..f63078b2d0 100644 --- a/tests/phpunit/tests/includes/class-test-comment.php +++ b/tests/phpunit/tests/includes/class-test-comment.php @@ -19,6 +19,24 @@ * @coversDefaultClass \Activitypub\Comment */ class Test_Comment extends \WP_UnitTestCase { + /** + * Set up the test. + */ + public function set_up() { + parent::set_up(); + + \update_option( 'activitypub_allow_comment_images', '1' ); + } + + /** + * Tear down the test. + */ + public function tear_down() { + \delete_option( 'activitypub_allow_comment_images' ); + + parent::tear_down(); + } + /** * Descriptions survive comment creation and updates with core block-attribute KSES active. * @@ -211,6 +229,45 @@ public function test_ordinary_comment_cannot_render_image_blocks() { $this->assertSame( $comment->comment_content, Comment::render_blocks( $comment->comment_content ) ); } + /** + * Stored images are not shown or fetched while comment images are disabled. + * + * @covers ::render_blocks + */ + public function test_stored_images_hidden_when_comment_images_disabled() { + \wp_set_current_user( 0 ); + \kses_init(); + $comment_id = \wp_new_comment( + \wp_slash( + array( + 'comment_post_ID' => self::factory()->post->create(), + 'comment_author' => 'Remote author', + 'comment_author_email' => 'remote@example.com', + 'comment_author_url' => '', + 'comment_content' => generate_image_block( 'https://example.com/photo.jpg', 'Cat' ), + 'comment_meta' => array( 'protocol' => 'activitypub' ), + ) + ), + true + ); + $this->assertIsInt( $comment_id ); + \delete_option( 'activitypub_allow_comment_images' ); + + $comment = \get_comment( $comment_id ); + $content = $comment->comment_content; + $calls = 0; + $filter = static function ( $url ) use ( &$calls ) { + ++$calls; + return $url; + }; + \add_filter( 'activitypub_remote_media_url', $filter, 1 ); + $rendered = Comment::render_blocks( $content, $comment ); + \remove_filter( 'activitypub_remote_media_url', $filter, 1 ); + + $this->assertStringNotContainsString( 'assertSame( 0, $calls ); + } + /** * Emoji rendering still works without a received comment. * From db3b76d3aa26c7ae0f61f3371bfef91228fa2a5b Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 21 Oct 2026 14:38:31 +0200 Subject: [PATCH 16/33] reuse the image check in validation --- includes/class-comment.php | 22 ++++++++++--------- includes/collection/class-interactions.php | 2 +- includes/handler/class-create.php | 10 +-------- .../tests/includes/class-test-blocks.php | 1 - .../includes/class-test-functions-media.php | 1 - .../includes/handler/class-test-create.php | 4 ++-- 6 files changed, 16 insertions(+), 24 deletions(-) diff --git a/includes/class-comment.php b/includes/class-comment.php index 1e77984b1c..54dcc12bab 100644 --- a/includes/class-comment.php +++ b/includes/class-comment.php @@ -62,17 +62,17 @@ public static function render_blocks( $content, $comment = null ) { $blocks = \parse_blocks( $content ); $output = ''; - $context_filter = static function ( $context ) use ( $comment ) { - if ( $comment instanceof \WP_Comment ) { - $context['postId'] = (int) $comment->comment_post_ID; - } - return $context; - }; - \add_filter( 'render_block_context', $context_filter ); - $allowed_blocks = array( 'activitypub/emoji' ); - if ( $comment instanceof \WP_Comment && self::was_received( $comment ) && \get_option( 'activitypub_allow_comment_images' ) ) { + $context_filter = null; + if ( \get_option( 'activitypub_allow_comment_images' ) && $comment instanceof \WP_Comment && self::was_received( $comment ) ) { $allowed_blocks[] = 'activitypub/image'; + + // Images are cached with the comment's post. + $context_filter = static function ( $context ) use ( $comment ) { + $context['postId'] = (int) $comment->comment_post_ID; + return $context; + }; + \add_filter( 'render_block_context', $context_filter ); } foreach ( $blocks as $block ) { @@ -83,7 +83,9 @@ public static function render_blocks( $content, $comment = null ) { $output .= \serialize_block( $block ); } } - \remove_filter( 'render_block_context', $context_filter ); + if ( $context_filter ) { + \remove_filter( 'render_block_context', $context_filter ); + } return $output; } diff --git a/includes/collection/class-interactions.php b/includes/collection/class-interactions.php index 521212f7b0..9e8f30ecbb 100644 --- a/includes/collection/class-interactions.php +++ b/includes/collection/class-interactions.php @@ -545,7 +545,7 @@ private static function prepare_remote_comment_data( $activity ) { * @param array $activity_object The ActivityPub object. * @return array Image attachments. */ - private static function get_image_attachments( $activity_object ) { + public static function get_image_attachments( $activity_object ) { // WordPress does not show images in comments, so this is opt-in. if ( ! \get_option( 'activitypub_allow_comment_images' ) ) { return array(); diff --git a/includes/handler/class-create.php b/includes/handler/class-create.php index bebb0d55ae..a214ce1733 100644 --- a/includes/handler/class-create.php +++ b/includes/handler/class-create.php @@ -155,17 +155,9 @@ public static function validate_object( $valid, $param, $request ) { } if ( ! isset( $activity['object']['content'] ) ) { // Image-only replies have something to show only when comment images are enabled; other posts still need text. - if ( ! \get_option( 'activitypub_allow_comment_images' ) || ! is_activity_reply( $activity ) ) { + if ( ! is_activity_reply( $activity ) || ! Interactions::get_image_attachments( $activity['object'] ) ) { return false; } - - foreach ( Remote_Posts::extract_attachments( $activity['object'], 20, 'image' ) as $attachment ) { - // Comments show images to every visitor, so the host must be public. - if ( \wp_http_validate_url( $attachment['url'] ) ) { - return $valid; - } - } - return false; } return $valid; diff --git a/tests/phpunit/tests/includes/class-test-blocks.php b/tests/phpunit/tests/includes/class-test-blocks.php index 6111bdf925..f14766ceb9 100644 --- a/tests/phpunit/tests/includes/class-test-blocks.php +++ b/tests/phpunit/tests/includes/class-test-blocks.php @@ -71,7 +71,6 @@ public function test_render_image_block_preserves_generated_urls_through_kses() } } - /** * User ID for Extra Fields block tests. * diff --git a/tests/phpunit/tests/includes/class-test-functions-media.php b/tests/phpunit/tests/includes/class-test-functions-media.php index f3a6d2c20f..f180862672 100644 --- a/tests/phpunit/tests/includes/class-test-functions-media.php +++ b/tests/phpunit/tests/includes/class-test-functions-media.php @@ -27,7 +27,6 @@ public function test_process_remote_images_decodes_html_urls_once() { } } - /** * Test process_remote_images wraps remote images. * diff --git a/tests/phpunit/tests/includes/handler/class-test-create.php b/tests/phpunit/tests/includes/handler/class-test-create.php index 13490d7fb9..aa651762a1 100644 --- a/tests/phpunit/tests/includes/handler/class-test-create.php +++ b/tests/phpunit/tests/includes/handler/class-test-create.php @@ -21,13 +21,13 @@ */ class Test_Create extends \WP_UnitTestCase { /** - * Image-only validation stops at the first usable image and bounds rejected candidates. + * Image-only validation stops at the comment image limit and bounds rejected candidates. * * @covers ::validate_object */ public function test_image_only_validation_is_bounded() { foreach ( array( - 443 => 1, + 443 => 3, 22 => 20, ) as $port => $expected ) { $object = array( From 8c5737a0b883ef94a724b83d62799e7b6cf18e4f Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 21 Oct 2026 14:54:47 +0200 Subject: [PATCH 17/33] drop hidden images and skip spam --- includes/class-blocks.php | 3 +- includes/class-comment.php | 5 +-- .../tests/includes/class-test-blocks.php | 19 +++++++++++ .../tests/includes/class-test-comment.php | 32 ++++++++++++++++++- 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/includes/class-blocks.php b/includes/class-blocks.php index e42e8dc4f3..8bba65d875 100644 --- a/includes/class-blocks.php +++ b/includes/class-blocks.php @@ -654,7 +654,8 @@ public static function render_emoji_block( $attrs, $content ) { * @return string The rendered content with cached URL. */ public static function render_image_block( $attrs, $content, $block = null ) { - if ( empty( $attrs['url'] ) ) { + // Only blocks that carry their alt text can rebuild a missing img. + if ( empty( $attrs['url'] ) || ( empty( $content ) && ! isset( $attrs['alt'] ) ) ) { return $content; } diff --git a/includes/class-comment.php b/includes/class-comment.php index 54dcc12bab..215d311ed6 100644 --- a/includes/class-comment.php +++ b/includes/class-comment.php @@ -64,7 +64,7 @@ public static function render_blocks( $content, $comment = null ) { $output = ''; $allowed_blocks = array( 'activitypub/emoji' ); $context_filter = null; - if ( \get_option( 'activitypub_allow_comment_images' ) && $comment instanceof \WP_Comment && self::was_received( $comment ) ) { + if ( \get_option( 'activitypub_allow_comment_images' ) && $comment instanceof \WP_Comment && 'spam' !== \wp_get_comment_status( $comment ) && self::was_received( $comment ) ) { $allowed_blocks[] = 'activitypub/image'; // Images are cached with the comment's post. @@ -79,7 +79,8 @@ public static function render_blocks( $content, $comment = null ) { // Rendering a parent also invokes its children's callbacks, so reject nested blocks. if ( \in_array( $block['blockName'], $allowed_blocks, true ) && empty( $block['innerBlocks'] ) ) { $output .= \render_block( $block ); - } else { + } elseif ( 'activitypub/image' !== $block['blockName'] ) { + // A hidden image block is dropped, so a stored img cannot show the remote image. $output .= \serialize_block( $block ); } } diff --git a/tests/phpunit/tests/includes/class-test-blocks.php b/tests/phpunit/tests/includes/class-test-blocks.php index f14766ceb9..3402de9f1f 100644 --- a/tests/phpunit/tests/includes/class-test-blocks.php +++ b/tests/phpunit/tests/includes/class-test-blocks.php @@ -41,6 +41,25 @@ public function test_render_image_block_preserves_literal_url_entities() { $this->assertSame( $url, $seen ); } + /** + * Image blocks without content or alt text render nothing and fetch nothing. + * + * @covers ::render_image_block + */ + public function test_render_image_block_without_content_skips_cache() { + $calls = 0; + $filter = static function ( $value ) use ( &$calls ) { + ++$calls; + return $value; + }; + \add_filter( 'activitypub_remote_media_url', $filter, 1 ); + $rendered = Blocks::render_image_block( array( 'url' => 'https://example.com/photo.jpg' ), '' ); + \remove_filter( 'activitypub_remote_media_url', $filter, 1 ); + + $this->assertSame( '', $rendered ); + $this->assertSame( 0, $calls ); + } + /** * Generated image URLs survive comment KSES without changing the resource. * diff --git a/tests/phpunit/tests/includes/class-test-comment.php b/tests/phpunit/tests/includes/class-test-comment.php index f63078b2d0..7753f8d1f7 100644 --- a/tests/phpunit/tests/includes/class-test-comment.php +++ b/tests/phpunit/tests/includes/class-test-comment.php @@ -226,7 +226,7 @@ public function test_ordinary_comment_cannot_render_image_blocks() { \remove_filter( 'activitypub_pre_download_url', $download ); $this->assertStringNotContainsString( 'assertSame( $comment->comment_content, Comment::render_blocks( $comment->comment_content ) ); + $this->assertStringNotContainsString( 'activitypub/image', Comment::render_blocks( $comment->comment_content ) ); } /** @@ -266,6 +266,36 @@ public function test_stored_images_hidden_when_comment_images_disabled() { $this->assertStringNotContainsString( 'assertSame( 0, $calls ); + + // Comments saved without kses keep the inner img; it is not shown either. + $unfiltered = generate_image_block( 'https://example.com/photo.jpg', 'Cat' ); + $this->assertStringNotContainsString( 'comment->create_and_get( + array( + 'comment_approved' => 'spam', + 'comment_meta' => array( 'protocol' => 'activitypub' ), + ) + ); + $content = generate_image_block( 'https://example.com/photo.jpg', 'Cat' ); + $calls = 0; + $filter = static function ( $url ) use ( &$calls ) { + ++$calls; + return $url; + }; + \add_filter( 'activitypub_remote_media_url', $filter, 1 ); + $rendered = Comment::render_blocks( $content, $comment ); + \remove_filter( 'activitypub_remote_media_url', $filter, 1 ); + + $this->assertStringNotContainsString( 'assertSame( 0, $calls ); } /** From 9c2fdee33995f29f6baf8fca0bd08a83db6b2b38 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 21 Oct 2026 16:42:33 +0200 Subject: [PATCH 18/33] lazy load comment images --- includes/class-blocks.php | 2 +- tests/phpunit/tests/includes/class-test-comment.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/includes/class-blocks.php b/includes/class-blocks.php index 8bba65d875..78139b4488 100644 --- a/includes/class-blocks.php +++ b/includes/class-blocks.php @@ -663,7 +663,7 @@ public static function render_image_block( $attrs, $content, $block = null ) { $processor = new \WP_HTML_Tag_Processor( $content ); if ( isset( $attrs['alt'] ) && ( ! $processor->next_tag( array( 'tag_name' => 'IMG' ) ) || ! $processor->get_attribute( 'src' ) ) ) { // Comment sanitization removes img attributes; retain them in the block instead. - $content = \sprintf( '%s', \esc_url( \htmlspecialchars( $url, ENT_QUOTES, 'UTF-8', true ) ), \esc_attr( $attrs['alt'] ) ); + $content = \sprintf( '%s', \esc_url( \htmlspecialchars( $url, ENT_QUOTES, 'UTF-8', true ) ), \esc_attr( $attrs['alt'] ) ); } // Get entity ID from context. diff --git a/tests/phpunit/tests/includes/class-test-comment.php b/tests/phpunit/tests/includes/class-test-comment.php index 7753f8d1f7..7bffcc9802 100644 --- a/tests/phpunit/tests/includes/class-test-comment.php +++ b/tests/phpunit/tests/includes/class-test-comment.php @@ -97,6 +97,7 @@ public function test_image_descriptions_survive_comment_save_and_update() { $image = new \WP_HTML_Tag_Processor( $rendered ); $this->assertTrue( $image->next_tag( 'IMG' ) ); $this->assertSame( $description, $image->get_attribute( 'alt' ) ); + $this->assertSame( 'lazy', $image->get_attribute( 'loading' ) ); } \remove_filter( 'activitypub_pre_download_url', $download ); } From a3304cdbee70ddf7335b9d2b75f5811b417c6954 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 23 Oct 2026 14:50:04 +0200 Subject: [PATCH 19/33] cache comment images per comment and remove them with the comment --- includes/cache/class-comment-media.php | 72 +++++++ includes/cache/class-media.php | 19 +- includes/class-blocks.php | 25 ++- includes/class-cache.php | 2 + includes/class-comment.php | 4 +- .../cache/class-test-comment-media.php | 185 ++++++++++++++++++ .../tests/includes/cache/class-test-media.php | 11 +- .../tests/includes/class-test-comment.php | 13 +- .../collection/class-test-interactions.php | 17 +- 9 files changed, 312 insertions(+), 36 deletions(-) create mode 100644 includes/cache/class-comment-media.php create mode 100644 tests/phpunit/tests/includes/cache/class-test-comment-media.php diff --git a/includes/cache/class-comment-media.php b/includes/cache/class-comment-media.php new file mode 100644 index 0000000000..6a933e0191 --- /dev/null +++ b/includes/cache/class-comment-media.php @@ -0,0 +1,72 @@ +comment_ID ); + } + } +} diff --git a/includes/cache/class-media.php b/includes/cache/class-media.php index 05e9d284bc..8b10a8327a 100644 --- a/includes/cache/class-media.php +++ b/includes/cache/class-media.php @@ -7,13 +7,17 @@ namespace Activitypub\Cache; +use Activitypub\Collection\Remote_Posts; + /** * Media cache class. * * Handles lazy caching of remote post and comment media locally. * Media is cached on-demand when URLs pass through the `activitypub_remote_media_url` filter. * - * Post and comment images share /wp-content/uploads/activitypub/posts/{post_id}/. + * Storage locations: + * - Posts: /wp-content/uploads/activitypub/posts/{post_id}/ + * - Comments: /wp-content/uploads/activitypub/comments/{comment_id}/ * * Files are cleaned up automatically when the parent post is deleted. * @@ -37,8 +41,7 @@ class Media extends File { /** * Context identifier for comment media. * - * Reserved for future use when comment media caching is implemented. - * Currently, only post media caching is active via maybe_cache(). + * Used by the Comment_Media cache. * * @var string */ @@ -54,7 +57,7 @@ class Media extends File { /** * Base directory for comment media. * - * Reserved for future use when comment media caching is implemented. + * Used by the Comment_Media cache. * * @var string */ @@ -152,11 +155,11 @@ public static function init() { * @return string The local URL if cached successfully, otherwise the original URL. */ public static function maybe_cache( $url, $context, $entity_id = null, $options = array() ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Required for filter signature. - if ( self::CONTEXT !== $context || empty( $url ) || empty( $entity_id ) ) { + if ( static::get_context() !== $context || empty( $url ) || empty( $entity_id ) ) { return $url; } - $cached_url = self::get_or_cache( $url, $entity_id ); + $cached_url = static::get_or_cache( $url, $entity_id ); return $cached_url ?: $url; } @@ -169,6 +172,10 @@ public static function maybe_cache( $url, $context, $entity_id = null, $options * @return void */ public static function maybe_cleanup( $post_id ) { + if ( Remote_Posts::POST_TYPE !== \get_post_type( $post_id ) ) { + return; + } + self::invalidate_entity( $post_id ); } diff --git a/includes/class-blocks.php b/includes/class-blocks.php index ec463eeb6e..06c483216a 100644 --- a/includes/class-blocks.php +++ b/includes/class-blocks.php @@ -7,6 +7,7 @@ namespace Activitypub; +use Activitypub\Cache\Media; use Activitypub\Cache\Stats_Image; use Activitypub\Collection\Actors; use Activitypub\Collection\Followers; @@ -239,7 +240,7 @@ public static function register_blocks() { 'urlEncoded' => array( 'type' => 'boolean' ), 'alt' => array( 'type' => 'string' ), ), - 'uses_context' => array( 'postId' ), + 'uses_context' => array( 'postId', 'commentId' ), 'render_callback' => array( self::class, 'render_image_block' ), ) ); @@ -673,22 +674,28 @@ public static function render_image_block( $attrs, $content, $block = null ) { $content = \sprintf( '%s', \esc_url( \htmlspecialchars( $url, ENT_QUOTES, 'UTF-8', true ) ), \esc_attr( $attrs['alt'] ) ); } - // Get entity ID from context. - $entity_id = $block->context['postId'] ?? null; - $post = \get_post(); - if ( ! $entity_id && $post ) { - $entity_id = $post->ID; + // Get entity ID from context, comment images are cached per comment. + $cache_context = Media::CONTEXT; + $entity_id = $block->context['commentId'] ?? null; + if ( $entity_id ) { + $cache_context = Media::CONTEXT_COMMENT; + } else { + $entity_id = $block->context['postId'] ?? null; + $post = \get_post(); + if ( ! $entity_id && $post ) { + $entity_id = $post->ID; + } } /** * Filters a remote image URL for caching. * * @param string $url The remote image URL. - * @param string $context The context ('media'). - * @param int|null $entity_id The entity ID. + * @param string $context The context ('media' or 'comment_media'). + * @param int|null $entity_id The entity ID, a comment ID for 'comment_media'. * @param array $options Additional options. */ - $cached_url = \apply_filters( 'activitypub_remote_media_url', $url, 'media', $entity_id, array() ); + $cached_url = \apply_filters( 'activitypub_remote_media_url', $url, $cache_context, $entity_id, array() ); if ( $cached_url && $cached_url !== $url ) { $processor = new \WP_HTML_Tag_Processor( $content ); diff --git a/includes/class-cache.php b/includes/class-cache.php index e588649523..2df73f1e93 100644 --- a/includes/class-cache.php +++ b/includes/class-cache.php @@ -8,6 +8,7 @@ namespace Activitypub; use Activitypub\Cache\Avatar; +use Activitypub\Cache\Comment_Media; use Activitypub\Cache\Emoji; use Activitypub\Cache\Media; @@ -81,6 +82,7 @@ public static function is_enabled() { public static function register_caches() { Avatar::init(); Media::init(); + Comment_Media::init(); Emoji::init(); /** diff --git a/includes/class-comment.php b/includes/class-comment.php index 1aec7c3a30..be8fa3245a 100644 --- a/includes/class-comment.php +++ b/includes/class-comment.php @@ -67,9 +67,9 @@ public static function render_blocks( $content, $comment = null ) { if ( \get_option( 'activitypub_allow_comment_images' ) && $comment instanceof \WP_Comment && 'spam' !== \wp_get_comment_status( $comment ) && self::was_received( $comment ) ) { $allowed_blocks[] = 'activitypub/image'; - // Images are cached with the comment's post. + // Images are cached per comment, so removing the comment removes them. $context_filter = static function ( $context ) use ( $comment ) { - $context['postId'] = (int) $comment->comment_post_ID; + $context['commentId'] = (int) $comment->comment_ID; return $context; }; \add_filter( 'render_block_context', $context_filter ); diff --git a/tests/phpunit/tests/includes/cache/class-test-comment-media.php b/tests/phpunit/tests/includes/cache/class-test-comment-media.php new file mode 100644 index 0000000000..d478c5f977 --- /dev/null +++ b/tests/phpunit/tests/includes/cache/class-test-comment-media.php @@ -0,0 +1,185 @@ +comment->create( + array( + 'comment_post_ID' => self::factory()->post->create(), + 'comment_approved' => $approved, + ) + ); + $paths = Comment_Media::get_storage_paths( $comment_id ); + \wp_mkdir_p( $paths['basedir'] ); + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + file_put_contents( $paths['basedir'] . '/test.txt', 'test' ); + + return array( $comment_id, $paths['basedir'] . '/test.txt' ); + } + + /** + * Comment images are stored per comment, in the directory Media reserved for them. + * + * @covers ::get_base_dir + * @covers ::get_context + */ + public function test_storage_paths() { + $this->assertSame( Media::get_storage_paths_for_context( 123, Media::CONTEXT_COMMENT ), Comment_Media::get_storage_paths( 123 ) ); + $this->assertSame( 'comment_media', Comment_Media::get_context() ); + } + + /** + * Only comment image URLs are cached, post media stays with Media. + * + * @covers ::maybe_cache + */ + public function test_maybe_cache_ignores_post_media() { + $downloads = 0; + $download = static function () use ( &$downloads ) { + ++$downloads; + return new \WP_Error( 'cache_skipped', 'Test only counts downloads.' ); + }; + \add_filter( 'activitypub_pre_download_url', $download ); + $url = Comment_Media::maybe_cache( 'https://example.com/photo.jpg', 'media', 123 ); + \remove_filter( 'activitypub_pre_download_url', $download ); + + $this->assertSame( 'https://example.com/photo.jpg', $url ); + $this->assertSame( 0, $downloads ); + } + + /** + * Deleting a comment removes its images. + * + * @covers ::init + */ + public function test_delete_comment_removes_images() { + list( $comment_id, $file ) = $this->create_cached_comment(); + + \wp_delete_comment( $comment_id, true ); + + $this->assertFileDoesNotExist( $file ); + } + + /** + * Deleting the post removes the images of its comments. + * + * @covers ::init + */ + public function test_delete_post_removes_comment_images() { + list( $comment_id, $file ) = $this->create_cached_comment(); + + \wp_delete_post( \get_comment( $comment_id )->comment_post_ID, true ); + + $this->assertFileDoesNotExist( $file ); + } + + /** + * Editing a comment removes its images, they are cached again on the next render. + * + * @covers ::init + */ + public function test_edit_comment_removes_images() { + list( $comment_id, $file ) = $this->create_cached_comment(); + + \wp_update_comment( + array( + 'comment_ID' => $comment_id, + 'comment_content' => 'Edited', + ) + ); + + $this->assertFileDoesNotExist( $file ); + } + + /** + * A comment that is no longer approved loses its images. + * + * @dataProvider data_unapproved_status + * @covers ::maybe_cleanup_comment + * + * @param string $status The new comment status. + */ + public function test_unapproved_comment_removes_images( $status ) { + list( $comment_id, $file ) = $this->create_cached_comment(); + + \wp_set_comment_status( $comment_id, $status ); + + $this->assertFileDoesNotExist( $file ); + } + + /** + * Statuses that hide a comment. + * + * @return array Test data. + */ + public function data_unapproved_status() { + return array( + 'trash' => array( 'trash' ), + 'spam' => array( 'spam' ), + 'hold' => array( 'hold' ), + ); + } + + /** + * Approving a comment keeps its images. + * + * @covers ::maybe_cleanup_comment + */ + public function test_approved_comment_keeps_images() { + list( $comment_id, $file ) = $this->create_cached_comment( 0 ); + + \wp_set_comment_status( $comment_id, 'approve' ); + + $this->assertFileExists( $file ); + Comment_Media::invalidate_entity( $comment_id ); + } + + /** + * Cleanup also runs when caching is disabled, files cached before must still go. + * + * @covers ::init + */ + public function test_cleanup_without_caching() { + \remove_all_actions( 'delete_comment' ); + \add_filter( 'activitypub_remote_cache_enabled', '__return_false' ); + Comment_Media::init(); + \remove_filter( 'activitypub_remote_cache_enabled', '__return_false' ); + list( $comment_id, $file ) = $this->create_cached_comment(); + + \wp_delete_comment( $comment_id, true ); + + $this->assertFileDoesNotExist( $file ); + } +} diff --git a/tests/phpunit/tests/includes/cache/class-test-media.php b/tests/phpunit/tests/includes/cache/class-test-media.php index 707f77c877..b7ecfeb2be 100644 --- a/tests/phpunit/tests/includes/cache/class-test-media.php +++ b/tests/phpunit/tests/includes/cache/class-test-media.php @@ -84,9 +84,9 @@ public function test_get_storage_paths_for_context_comment() { } /** - * Test cleanup also removes comment images attached to a regular post. + * Test maybe_cleanup only runs for ap_post post type. */ - public function test_maybe_cleanup_regular_post() { + public function test_maybe_cleanup_wrong_post_type() { // Create a regular post. $post_id = self::factory()->post->create(); @@ -96,10 +96,11 @@ public function test_maybe_cleanup_regular_post() { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents file_put_contents( $paths['basedir'] . '/test.txt', 'test' ); - Media::init(); - \wp_delete_post( $post_id, true ); + // Call cleanup. + Media::maybe_cleanup( $post_id ); - $this->assertFalse( is_dir( $paths['basedir'] ) ); + // File should still exist (not cleaned up because wrong post type). + $this->assertTrue( file_exists( $paths['basedir'] . '/test.txt' ) ); // Clean up. Media::invalidate_entity( $post_id ); diff --git a/tests/phpunit/tests/includes/class-test-comment.php b/tests/phpunit/tests/includes/class-test-comment.php index 7bffcc9802..0190c6a8b3 100644 --- a/tests/phpunit/tests/includes/class-test-comment.php +++ b/tests/phpunit/tests/includes/class-test-comment.php @@ -126,12 +126,12 @@ public function test_render_blocks_rejects_other_and_nested_blocks() { } /** - * Images use the comment's parent post, not the current global post. + * Images are cached per comment, not with the current global post. * * @group activitypub * @covers ::render_blocks */ - public function test_render_image_with_parent_post_context() { + public function test_render_image_with_comment_context() { $parent = self::factory()->post->create(); $comment = self::factory()->comment->create_and_get( array( @@ -142,8 +142,10 @@ public function test_render_image_with_parent_post_context() { $this->go_to( \get_permalink( self::factory()->post->create() ) ); $content = generate_image_block( 'https://example.com/photo.jpg', 'Cat' ); $seen_id = null; - $filter = static function ( $url, $context, $entity_id ) use ( &$seen_id ) { - $seen_id = $entity_id; + $seen_ctx = null; + $filter = static function ( $url, $context, $entity_id ) use ( &$seen_id, &$seen_ctx ) { + $seen_id = $entity_id; + $seen_ctx = $context; return 'https://cdn.example.com/photo.jpg'; }; $download = static function () { @@ -154,7 +156,8 @@ public function test_render_image_with_parent_post_context() { $rendered = Comment::render_blocks( $content, $comment ); \remove_filter( 'activitypub_remote_media_url', $filter, 1 ); \remove_filter( 'activitypub_pre_download_url', $download ); - $this->assertSame( (int) $comment->comment_post_ID, $seen_id ); + $this->assertSame( (int) $comment->comment_ID, $seen_id ); + $this->assertSame( 'comment_media', $seen_ctx ); $this->assertStringContainsString( 'https://cdn.example.com/photo.jpg', $rendered ); } diff --git a/tests/phpunit/tests/includes/collection/class-test-interactions.php b/tests/phpunit/tests/includes/collection/class-test-interactions.php index f9bdd8abdf..444e03d713 100644 --- a/tests/phpunit/tests/includes/collection/class-test-interactions.php +++ b/tests/phpunit/tests/includes/collection/class-test-interactions.php @@ -7,7 +7,7 @@ namespace Activitypub\Tests\Collection; -use Activitypub\Cache\Media; +use Activitypub\Cache\Comment_Media; use Activitypub\Collection\Interactions; use Activitypub\Collection\Remote_Actors; use Activitypub\Tests\Equal_Html; @@ -197,12 +197,12 @@ public function test_comment_images_disabled_by_default() { } /** - * Image-only comments reuse the post's lazy image cache and retain alt text. + * Image-only comments use the lazy comment image cache and retain alt text. * * @group activitypub * @covers ::add_comment */ - public function test_image_only_comment_uses_post_cache() { + public function test_image_only_comment_uses_comment_cache() { $url = 'https://example.com/photo.jpg?a=1&b=2'; $activity = $this->create_test_object( 'https://example.com/image-reply' ); $activity['object']['attachment'] = array( @@ -224,7 +224,7 @@ public function test_image_only_comment_uses_post_cache() { 'mime_type' => 'image/jpeg', ); }; - Media::init(); + Comment_Media::init(); \add_filter( 'activitypub_pre_download_url', $download ); $comment_id = Interactions::add_comment( $activity ); $comment = \get_comment( $comment_id ); @@ -232,20 +232,19 @@ public function test_image_only_comment_uses_post_cache() { $rendered = \apply_filters( 'comment_text', $comment->comment_content, $comment ); $again = \apply_filters( 'comment_text', $comment->comment_content, $comment ); \remove_filter( 'activitypub_pre_download_url', $download ); - $cached = Media::get( $url, self::$post_id ); + $cached = Comment_Media::get( $url, $comment_id ); \wp_delete_comment( $comment_id, true ); - $after_delete = Media::get( $url, self::$post_id ); - Media::invalidate_entity( self::$post_id ); + $after_delete = Comment_Media::get( $url, $comment_id ); $this->assertIsInt( $comment_id ); $this->assertStringContainsString( '