diff --git a/.github/changelog/fix-comment-images b/.github/changelog/fix-comment-images
new file mode 100644
index 0000000000..7dbae3f081
--- /dev/null
+++ b/.github/changelog/fix-comment-images
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Add an option to show images attached to Fediverse replies in comments, with local caching and image descriptions.
diff --git a/.github/changelog/fix-photon-snippet-args b/.github/changelog/fix-photon-snippet-args
new file mode 100644
index 0000000000..4e0cfbff1b
--- /dev/null
+++ b/.github/changelog/fix-photon-snippet-args
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Fixed a fatal error on sites using the Photon snippet, which now also serves images in Fediverse replies through the CDN.
diff --git a/includes/cache/class-comment-media.php b/includes/cache/class-comment-media.php
new file mode 100644
index 0000000000..248fe7d9c1
--- /dev/null
+++ b/includes/cache/class-comment-media.php
@@ -0,0 +1,195 @@
+comment_content ) as $block ) {
+ if ( 'activitypub/image' === $block['blockName'] && Blocks::get_image_url( $block['attrs'] ) === $url ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Maybe clean up cached images when a comment is no longer approved.
+ *
+ * @param string $new_status The new comment status.
+ * @param string $old_status The old comment status.
+ * @param \WP_Comment $comment The comment.
+ *
+ * @return void
+ */
+ public static function maybe_cleanup_comment( $new_status, $old_status, $comment ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Required for action signature.
+ if ( 'approved' !== $new_status ) {
+ self::clear_cached_images( (int) $comment->comment_ID );
+ }
+ }
+
+ /**
+ * Remove the cached images of a comment.
+ *
+ * Files are cached lazily, so the next render downloads them again.
+ *
+ * @param int $comment_id The comment ID.
+ *
+ * @return void
+ */
+ public static function clear_cached_images( $comment_id ) {
+ self::invalidate_entity( $comment_id );
+ }
+}
diff --git a/includes/cache/class-file.php b/includes/cache/class-file.php
index 71e8f7758a..aabb078eae 100644
--- a/includes/cache/class-file.php
+++ b/includes/cache/class-file.php
@@ -199,8 +199,22 @@ public static function get_or_cache( $url, $entity_id, $options = array() ) {
}
}
+ /*
+ * Files are downloaded while a page renders, so a URL that failed is not tried again for
+ * a while. Otherwise every page view would wait for the same slow or broken host.
+ */
+ $failed_key = 'activitypub_failed_download_' . static::get_type() . '_' . static::generate_hash( $url );
+ if ( \get_transient( $failed_key ) ) {
+ return false;
+ }
+
// Download and cache the file.
- return static::cache( $url, $entity_id, $options );
+ $local_url = static::cache( $url, $entity_id, $options );
+ if ( ! $local_url ) {
+ \set_transient( $failed_key, 1, HOUR_IN_SECONDS );
+ }
+
+ return $local_url;
}
/**
diff --git a/includes/cache/class-media.php b/includes/cache/class-media.php
index e2bc0df811..037a800835 100644
--- a/includes/cache/class-media.php
+++ b/includes/cache/class-media.php
@@ -12,14 +12,11 @@
/**
* Media cache class.
*
- * Handles lazy caching of remote post and comment media locally.
+ * Handles lazy caching of remote post 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}/
- *
- * Files are cleaned up automatically when the parent post is deleted.
+ * Media is stored in /wp-content/uploads/activitypub/posts/{post_id}/
+ * and cleaned up automatically when the post is deleted.
*
* @since 5.6.0
*/
@@ -39,30 +36,29 @@ class Media extends File {
const CONTEXT = 'media';
/**
- * 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().
+ * Base directory for post media.
*
* @var string
*/
- const CONTEXT_COMMENT = 'comment_media';
+ const BASE_DIR_POSTS = '/activitypub/posts/';
/**
- * Base directory for post media.
+ * Context identifier for comment media.
+ *
+ * @deprecated unreleased Use {@see Comment_Media::CONTEXT}.
*
* @var string
*/
- const BASE_DIR_POSTS = '/activitypub/posts/';
+ const CONTEXT_COMMENT = Comment_Media::CONTEXT;
/**
* Base directory for comment media.
*
- * Reserved for future use when comment media caching is implemented.
+ * @deprecated unreleased Use {@see Comment_Media::BASE_DIR}.
*
* @var string
*/
- const BASE_DIR_COMMENTS = '/activitypub/comments/';
+ const BASE_DIR_COMMENTS = Comment_Media::BASE_DIR;
/**
* Get the cache type identifier.
@@ -76,9 +72,6 @@ public static function get_type() {
/**
* Get the base directory path relative to uploads.
*
- * Default to post media directory. Use get_storage_paths_for_context()
- * for context-aware path resolution.
- *
* @return string Base directory path.
*/
public static function get_base_dir() {
@@ -103,30 +96,6 @@ public static function get_max_dimension() {
return self::MAX_DIMENSION;
}
- /**
- * Get storage paths based on context.
- *
- * @param string|int $entity_id The entity identifier.
- * @param string $context The context ('media' or 'comment_media').
- *
- * @return array {
- * Storage paths for the entity.
- *
- * @type string $basedir Base directory path.
- * @type string $baseurl Base URL.
- * }
- */
- public static function get_storage_paths_for_context( $entity_id, $context = self::CONTEXT ) {
- $upload_dir = \wp_upload_dir();
- $entity_id = \sanitize_file_name( (string) $entity_id );
- $base_dir = self::CONTEXT_COMMENT === $context ? self::BASE_DIR_COMMENTS : self::BASE_DIR_POSTS;
-
- return array(
- 'basedir' => $upload_dir['basedir'] . $base_dir . $entity_id,
- 'baseurl' => $upload_dir['baseurl'] . $base_dir . $entity_id,
- );
- }
-
/**
* Initialize the cache handler.
*
@@ -156,11 +125,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;
}
@@ -180,16 +149,39 @@ public static function maybe_cleanup( $post_id ) {
self::invalidate_entity( $post_id );
}
+ /**
+ * Get storage paths based on context.
+ *
+ * @deprecated unreleased Use {@see Media::get_storage_paths()} or {@see Comment_Media::get_storage_paths()}.
+ *
+ * @param string|int $entity_id The entity identifier.
+ * @param string $context The context ('media' or 'comment_media').
+ *
+ * @return array {
+ * Storage paths for the entity.
+ *
+ * @type string $basedir Base directory path.
+ * @type string $baseurl Base URL.
+ * }
+ */
+ public static function get_storage_paths_for_context( $entity_id, $context = self::CONTEXT ) {
+ \_deprecated_function( __METHOD__, 'unreleased', 'Activitypub\Cache\Media::get_storage_paths or Activitypub\Cache\Comment_Media::get_storage_paths' );
+
+ return Comment_Media::CONTEXT === $context ? Comment_Media::get_storage_paths( $entity_id ) : self::get_storage_paths( $entity_id );
+ }
+
/**
* Invalidate cached media for a comment.
*
+ * @deprecated unreleased Use {@see Comment_Media::clear_cached_images()}.
+ *
* @param int $comment_id The comment ID.
*
* @return bool True on success, false on failure.
*/
public static function invalidate_comment( $comment_id ) {
- $paths = self::get_storage_paths_for_context( $comment_id, self::CONTEXT_COMMENT );
+ \_deprecated_function( __METHOD__, 'unreleased', 'Activitypub\Cache\Comment_Media::clear_cached_images' );
- return static::delete_directory( $paths['basedir'] );
+ return Comment_Media::invalidate_entity( $comment_id );
}
}
diff --git a/includes/class-blocks.php b/includes/class-blocks.php
index 0b7216afbc..ee47d54578 100644
--- a/includes/class-blocks.php
+++ b/includes/class-blocks.php
@@ -7,6 +7,8 @@
namespace Activitypub;
+use Activitypub\Cache\Comment_Media;
+use Activitypub\Cache\Media;
use Activitypub\Cache\Stats_Image;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Followers;
@@ -235,8 +237,11 @@ public static function register_blocks() {
'activitypub/image',
array(
'attributes' => array(
- 'url' => array( 'type' => 'string' ),
+ 'url' => array( 'type' => 'string' ),
+ 'urlEncoded' => array( 'type' => 'boolean' ),
+ 'alt' => array( 'type' => 'string' ),
),
+ 'uses_context' => array( 'postId', 'commentId' ),
'render_callback' => array( self::class, 'render_image_block' ),
)
);
@@ -651,22 +656,32 @@ 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.
+ * @return string The rendered content with cached URL, or the image description if a comment image has no URL.
*/
- public static function render_image_block( $attrs, $content ) {
- if ( empty( $attrs['url'] ) || empty( $content ) ) {
- return $content;
+ public static function render_image_block( $attrs, $content, $block = null ) {
+ $comment_id = $block->context['commentId'] ?? null;
+
+ // Only blocks that carry their alt text can rebuild a missing img.
+ if ( empty( $attrs['url'] ) || ( empty( $content ) && ! isset( $attrs['alt'] ) ) ) {
+ return $comment_id ? self::render_image_description( $attrs ) : $content;
}
- $url = $attrs['url'];
+ $url = self::get_image_url( $attrs );
+ $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( '', \esc_url( \htmlspecialchars( $url, ENT_QUOTES, 'UTF-8', true ) ), \esc_attr( $attrs['alt'] ) );
+ }
- // Get entity ID from context.
- $entity_id = null;
- $post = \get_post();
- if ( $post ) {
+ // Get entity ID from context, comment images are cached per comment.
+ $cache_context = $comment_id ? Comment_Media::CONTEXT : Media::CONTEXT;
+ $entity_id = $comment_id ?: ( $block->context['postId'] ?? null );
+ $post = \get_post();
+ if ( ! $entity_id && $post ) {
$entity_id = $post->ID;
}
@@ -674,19 +689,61 @@ public static function render_image_block( $attrs, $content ) {
* 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 ) {
- 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 );
+ // Browsers prefer srcset candidates, which still point to the remote host.
+ $processor->remove_attribute( 'srcset' );
+ return $processor->get_updated_html();
+ }
}
return $content;
}
+ /**
+ * Get the remote URL of an image block.
+ *
+ * @since unreleased
+ *
+ * @param array $attrs The image block attributes.
+ *
+ * @return string The remote URL, empty if the block has none.
+ */
+ public static function get_image_url( $attrs ) {
+ if ( empty( $attrs['url'] ) || ! \is_string( $attrs['url'] ) ) {
+ return '';
+ }
+
+ // Comment sanitization would mangle a raw URL, so generated blocks store it encoded.
+ return ! empty( $attrs['urlEncoded'] ) ? \wp_specialchars_decode( $attrs['url'], ENT_QUOTES ) : $attrs['url'];
+ }
+
+ /**
+ * Render the description of an image that is not shown.
+ *
+ * @since unreleased
+ *
+ * @param array $attrs The image block attributes.
+ *
+ * @return string The description as a paragraph.
+ */
+ public static function render_image_description( $attrs ) {
+ $alt = $attrs['alt'] ?? '';
+
+ return '
' . \esc_html( + /* translators: %s: Image description. */ + $alt ? \sprintf( \__( 'Image: %s', 'activitypub' ), $alt ) : \__( 'Image', 'activitypub' ) + ) . '
'; + } + /** * Render the audio block. * diff --git a/includes/class-cache.php b/includes/class-cache.php index e588649523..00b5ce3ca3 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; @@ -30,6 +31,9 @@ class Cache { * @return void */ public static function init() { + // Cached comment images go with their comment, also after caching was turned off. + Comment_Media::register_cleanup(); + if ( ! self::is_enabled() ) { return; } @@ -81,6 +85,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 57100bcd41..0584644b0d 100644 --- a/includes/class-comment.php +++ b/includes/class-comment.php @@ -41,35 +41,54 @@ 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. } /** * Render blocks in comment content. * * Comments don't automatically parse blocks like posts do. - * This filter applies do_blocks() to render activitypub/emoji - * and activitypub/image blocks in comment content. + * This filter renders only leaf activitypub/emoji and activitypub/image blocks. * - * @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( + // Core runs block attribute strings through KSES when saving comments. + 'url' => \htmlspecialchars( $url, ENT_QUOTES, 'UTF-8', true ), + 'urlEncoded' => true, + 'alt' => \htmlspecialchars( $processor->get_attribute( 'alt' ) ?? '', ENT_QUOTES, 'UTF-8', true ), + ), $img_html ); } @@ -134,9 +142,12 @@ function ( $matches ) { // Reconstruct img tag without the marker attribute. $img_html = '
';
+ \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 );
+ }
+
+ /**
+ * 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 );
+ }
+
+ /**
+ * The cached URL replaces srcset too, so browsers do not pick a remote candidate.
+ *
+ * @covers ::render_image_block
+ */
+ public function test_render_image_block_drops_remote_srcset() {
+ $cached = static function () {
+ return 'https://local.example/cached.jpg';
+ };
+
+ \add_filter( 'activitypub_remote_media_url', $cached, 20 );
+ $rendered = Blocks::render_image_block(
+ array( 'url' => 'https://example.com/photo.jpg' ),
+ '
'
+ );
+ \remove_filter( 'activitypub_remote_media_url', $cached, 20 );
+
+ $this->assertStringContainsString( 'src="https://local.example/cached.jpg"', $rendered );
+ $this->assertStringNotContainsString( 'example.com', $rendered );
+ }
+
+ /**
+ * Comment images are shown from the local cache, else from the remote host. Without a URL their description is shown.
+ *
+ * @covers ::render_image_block
+ * @covers ::render_image_description
+ */
+ public function test_render_image_block_comment_context() {
+ $parsed = \parse_blocks( generate_image_block( 'https://example.com/photo.jpg', '
' ) )[0];
+ $context = array();
+ $cached = static function ( $url, $cache_context, $entity_id ) use ( &$context ) {
+ $context = array( $cache_context, $entity_id );
+ return 'https://local.example/cached.jpg';
+ };
+
+ \add_filter( 'activitypub_remote_media_url', $cached, 20, 3 );
+ $with_cache = ( new \WP_Block( $parsed, array( 'commentId' => 7 ) ) )->render();
+ \remove_filter( 'activitypub_remote_media_url', $cached, 20 );
+ $uncached = ( new \WP_Block( $parsed, array( 'commentId' => 7 ) ) )->render();
+ $no_url = Blocks::render_image_block( array( 'alt' => 'Cat' ), '
', new \WP_Block( $parsed, array( 'commentId' => 7 ) ) );
+
+ $this->assertSame( array( 'comment_media', 7 ), $context );
+ $this->assertStringContainsString( 'src="https://local.example/cached.jpg"', $with_cache );
+ $this->assertStringContainsString( 'src="https://example.com/photo.jpg"', $uncached );
+ $this->assertStringContainsString( 'alt="Cat"', $uncached );
+ $this->assertSame( 'Image: Cat
', $no_url ); + $this->assertSame( 'Image
', Blocks::render_image_description( array() ) ); + } + + /** + * 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-cache.php b/tests/phpunit/tests/includes/class-test-cache.php index c9fd278d14..cd660fc1bb 100644 --- a/tests/phpunit/tests/includes/class-test-cache.php +++ b/tests/phpunit/tests/includes/class-test-cache.php @@ -9,6 +9,7 @@ use Activitypub\Cache; use Activitypub\Cache\Avatar; +use Activitypub\Cache\Comment_Media; use Activitypub\Cache\Emoji; use Activitypub\Cache\Media; use WP_UnitTestCase; @@ -48,11 +49,12 @@ public function test_is_enabled_deprecated_sideloading_filter() { } /** - * Test init does not run when disabled. + * Test init does not run when disabled, except for the comment image cleanup. */ public function test_init_disabled() { // Remove existing filters first. remove_all_filters( 'activitypub_remote_media_url' ); + remove_action( 'transition_comment_status', array( Comment_Media::class, 'maybe_cleanup_comment' ) ); add_filter( 'activitypub_remote_cache_enabled', '__return_false' ); @@ -62,6 +64,14 @@ public function test_init_disabled() { $this->assertFalse( has_filter( 'activitypub_remote_media_url', array( Avatar::class, 'maybe_cache' ) ) ); + $this->assertFalse( + has_filter( 'activitypub_remote_media_url', array( Comment_Media::class, 'maybe_cache' ) ) + ); + + // Comment images are still removed with their comment. + $this->assertNotFalse( + has_action( 'transition_comment_status', array( Comment_Media::class, 'maybe_cleanup_comment' ) ) + ); remove_filter( 'activitypub_remote_cache_enabled', '__return_false' ); } diff --git a/tests/phpunit/tests/includes/class-test-comment.php b/tests/phpunit/tests/includes/class-test-comment.php index 95f698c7b6..dd5c63f882 100644 --- a/tests/phpunit/tests/includes/class-test-comment.php +++ b/tests/phpunit/tests/includes/class-test-comment.php @@ -10,12 +10,404 @@ use Activitypub\Collection\Remote_Posts; use Activitypub\Comment; +use function Activitypub\generate_image_block; +use function Activitypub\process_remote_images; + /** * Test class for Activitypub Comment. * * @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. + * + * @covers ::render_blocks + */ + public function test_image_descriptions_survive_comment_save_and_update() { + \wp_set_current_user( 0 ); + \kses_init(); + $post_id = self::factory()->post->create(); + $comment_id = 0; + $download = static function () { + return new \WP_Error( 'cache_skipped', 'The image cache is unavailable.' ); + }; + $cached = static function ( $url, $context ) { + // Stand in for the local cache, comment images are only shown from it. + return 'comment_media' === $context ? $url . '#cached' : $url; + }; + \add_filter( 'activitypub_pre_download_url', $download ); + \add_filter( 'activitypub_remote_media_url', $cached, 20, 2 ); + foreach ( array( 'width < height', 'Updated: width < height, "quotes", & and literal <' ) as $description ) { + $content = process_remote_images( + '', + Remote_Posts::extract_attachments( + array( + 'attachment' => array( + 'type' => 'Image', + 'url' => 'https://example.com/photo.jpg', + 'name' => $description, + ), + ) + ) + ); + if ( $comment_id ) { + $this->assertSame( + 1, + \wp_update_comment( + \wp_slash( + array( + 'comment_ID' => $comment_id, + 'comment_content' => $content, + ) + ) + ) + ); + } else { + $comment_id = \wp_new_comment( + \wp_slash( + array( + 'comment_post_ID' => $post_id, + 'comment_author' => 'Remote author', + 'comment_author_email' => 'remote@example.com', + 'comment_author_url' => '', + 'comment_content' => $content, + 'comment_meta' => array( 'protocol' => 'activitypub' ), + ) + ), + true + ); + $this->assertIsInt( $comment_id ); + \wp_set_comment_status( $comment_id, 'approve' ); + } + $comment = \get_comment( $comment_id ); + $rendered = Comment::render_blocks( $comment->comment_content, $comment ); + $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_remote_media_url', $cached, 20 ); + \remove_filter( 'activitypub_pre_download_url', $download ); + } + + /** + * 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() { + $comment = self::factory()->comment->create_and_get( array( 'comment_meta' => array( 'protocol' => 'activitypub' ) ) ); + $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 ); + Comment::render_blocks( '', $comment ); + Comment::render_blocks( '', $comment ); + \unregister_block_type( 'activitypub/test-probe' ); + \unregister_block_type( 'core/test-probe' ); + + $this->assertSame( 0, $calls ); + } + + /** + * Images are cached per comment, not with the current global post. + * + * @group activitypub + * @covers ::render_blocks + */ + public function test_render_image_with_comment_context() { + $parent = self::factory()->post->create(); + $comment = self::factory()->comment->create_and_get( + array( + 'comment_post_ID' => $parent, + 'comment_meta' => array( 'protocol' => 'activitypub' ), + ) + ); + $this->go_to( \get_permalink( self::factory()->post->create() ) ); + $content = generate_image_block( 'https://example.com/photo.jpg', '
' );
+ $seen_id = null;
+ $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 () {
+ 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_ID, $seen_id );
+ $this->assertSame( 'comment_media', $seen_ctx );
+ $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() {
+ $comment = self::factory()->comment->create_and_get( array( 'comment_meta' => array( 'protocol' => 'activitypub' ) ) );
+ $content = generate_image_block( 'https://example.com/photo.jpg', '
' );
+ $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( '', $comment );
+ \add_filter( 'pre_render_block', $pre_render, 20 );
+ $rendered = Comment::render_blocks( $content, $comment );
+ \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 );
+ }
+ }
+
+ /**
+ * 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( '
' );
+ $blocks = \parse_blocks( $result );
+ $this->assertCount( 1, $blocks );
+ $this->assertSame( '--> "cat" & kittens', $blocks[0]['attrs']['alt'] );
}
/**
diff --git a/tests/phpunit/tests/includes/collection/class-test-interactions.php b/tests/phpunit/tests/includes/collection/class-test-interactions.php
index c26750fa54..81adc0ef01 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\Comment_Media;
use Activitypub\Collection\Interactions;
use Activitypub\Collection\Remote_Actors;
use Activitypub\Tests\Equal_Html;
@@ -21,6 +22,464 @@
class Test_Interactions extends \WP_UnitTestCase {
use Equal_Html;
+ /**
+ * Unsafe attachments never become blocks, including when caching is unavailable.
+ *
+ * @covers ::add_comment
+ * @covers ::update_comment
+ */
+ public function test_comment_images_reject_unsafe_urls_before_creating_blocks() {
+ $activity = $this->create_test_object( 'https://example.com/unsafe-images' );
+ foreach ( array(
+ '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',
+ ) as $url ) {
+ $activity['object']['attachment'][] = array(
+ 'type' => 'Image',
+ 'url' => $url,
+ );
+ }
+ $comment_id = 0;
+ foreach ( array( 'https://example.com/写真.jpg', 'https://example.com/updated.jpg' ) as $url ) {
+ $object = $activity;
+ $object['object']['attachment'][] = array(
+ 'type' => 'Image',
+ 'url' => $url,
+ );
+ if ( $comment_id ) {
+ $result = Interactions::update_comment( $object );
+ $this->assertNotWPError( $result );
+ $this->assertNotFalse( $result );
+ } else {
+ $comment_id = Interactions::add_comment( $object );
+ $this->assertIsInt( $comment_id );
+ // New authors are held for moderation, images only render for approved comments.
+ \wp_set_comment_status( $comment_id, 'approve' );
+ }
+ $comment = \get_comment( $comment_id );
+ $this->assertSame( 1, \substr_count( $comment->comment_content, '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( '
Safe text.
'; + $comment_id = Interactions::add_comment( $activity ); + $this->assertIsInt( $comment_id ); + $content = \get_comment( $comment_id )->comment_content; + $this->assertStringNotContainsString( '