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( '%s', \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 = ''; - // 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]; @@ -162,10 +173,10 @@ function ( $matches ) { continue; } - $alt = ! empty( $attachment['alt'] ) ? \esc_attr( $attachment['alt'] ) : ''; + $alt = ! empty( $attachment['alt'] ) ? \esc_attr( \htmlspecialchars( $attachment['alt'], ENT_QUOTES, 'UTF-8', true ) ) : ''; $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/includes/handler/class-create.php b/includes/handler/class-create.php index 7d8aeef785..b1f6865081 100644 --- a/includes/handler/class-create.php +++ b/includes/handler/class-create.php @@ -150,9 +150,19 @@ public static function validate_object( $valid, $param, $request ) { return false; } - if ( ! isset( $activity['object']['id'], $activity['object']['content'] ) ) { + if ( ! isset( $activity['object']['id'] ) ) { return false; } + if ( ! isset( $activity['object']['content'] ) ) { + /* + * Image-only replies and quotes have something to show only when comment images are enabled; + * other posts still need text. This runs before the signature check, so it leaves the host + * checks to Interactions::add_comment(). + */ + if ( ( ! is_activity_reply( $activity ) && ! is_quote_activity( $activity ) ) || ! Interactions::get_image_attachments( $activity['object'], false ) ) { + return false; + } + } return $valid; } diff --git a/includes/wp-admin/class-settings-fields.php b/includes/wp-admin/class-settings-fields.php index 5d6dff20a4..98d06a96b8 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() {

+

+ +

+

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 have their own cache type, so hooks can tell them from post media. + * + * @covers ::get_type + */ + public function test_get_type() { + $this->assertSame( 'comment_media', Comment_Media::get_type() ); + } + + /** + * Download an image for a comment, with the download stubbed. + * + * @param int $comment_id The comment ID. + * + * @return string The URL the cache returns. + */ + private function cache_image( $comment_id ) { + $download = static function () { + $file = \wp_tempnam( 'comment-image.jpg' ); + \copy( AP_TESTS_DIR . '/data/assets/test.jpg', $file ); + return array( + 'file' => $file, + 'mime_type' => 'image/jpeg', + ); + }; + + // Comment IDs repeat across tests, start without a cached file. + Comment_Media::invalidate_entity( $comment_id ); + \add_filter( 'activitypub_pre_download_url', $download ); + $url = Comment_Media::maybe_cache( 'https://example.com/photo.jpg', 'comment_media', $comment_id ); + \remove_filter( 'activitypub_pre_download_url', $download ); + + return $url; + } + + /** + * An image downloaded while its comment was removed is not kept on the server. + * + * @covers ::cache + */ + public function test_maybe_cache_removes_images_of_removed_comment() { + $comment_id = self::factory()->comment->create( + array( + 'comment_approved' => 'trash', + 'comment_content' => generate_image_block( 'https://example.com/photo.jpg', '' ), + ) + ); + + $this->assertSame( 'https://example.com/photo.jpg', $this->cache_image( $comment_id ) ); + $this->assertDirectoryDoesNotExist( Comment_Media::get_storage_paths( $comment_id )['basedir'] ); + } + + /** + * An approved comment keeps the image it shows. + * + * @covers ::cache + */ + public function test_maybe_cache_keeps_image_of_approved_comment() { + \update_option( 'activitypub_allow_comment_images', '1' ); + $comment_id = self::factory()->comment->create( + array( 'comment_content' => generate_image_block( 'https://example.com/photo.jpg', '' ) ) + ); + + $this->assertNotSame( 'https://example.com/photo.jpg', $this->cache_image( $comment_id ) ); + $this->assertDirectoryExists( Comment_Media::get_storage_paths( $comment_id )['basedir'] ); + Comment_Media::invalidate_entity( $comment_id ); + \delete_option( 'activitypub_allow_comment_images' ); + } + + /** + * An image downloaded while comment images were turned off is not kept on the server. + * + * @covers ::cache + */ + public function test_maybe_cache_removes_image_when_option_is_off() { + \update_option( 'activitypub_allow_comment_images', '0' ); + $comment_id = self::factory()->comment->create( + array( 'comment_content' => generate_image_block( 'https://example.com/photo.jpg', '' ) ) + ); + + $this->assertSame( 'https://example.com/photo.jpg', $this->cache_image( $comment_id ) ); + $this->assertDirectoryDoesNotExist( Comment_Media::get_storage_paths( $comment_id )['basedir'] ); + \delete_option( 'activitypub_allow_comment_images' ); + } + + /** + * An image an edit removed while it downloaded is not kept, also when the request holds the old comment. + * + * @covers ::cache + */ + public function test_maybe_cache_removes_image_dropped_by_edit() { + global $wpdb; + + $comment_id = self::factory()->comment->create( + array( 'comment_content' => generate_image_block( 'https://example.com/photo.jpg', '' ) ) + ); + \get_comment( $comment_id ); + // Another request edits the comment, this request still has the old one cached. + $wpdb->update( $wpdb->comments, array( 'comment_content' => 'Edited' ), array( 'comment_ID' => $comment_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery + + $this->assertSame( 'https://example.com/photo.jpg', $this->cache_image( $comment_id ) ); + $this->assertDirectoryDoesNotExist( Comment_Media::get_storage_paths( $comment_id )['basedir'] ); + } + + /** + * 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 ::register_cleanup + */ + 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 ::register_cleanup + */ + 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 ::register_cleanup + */ + 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' ), + ); + } + + /** + * Turning comment images off removes all cached comment images, turning them on keeps them. + * + * @covers ::maybe_clear_all_images + */ + public function test_turning_option_off_removes_all_images() { + \update_option( 'activitypub_allow_comment_images', '1' ); + list( , $file ) = $this->create_cached_comment(); + + // A change that keeps the option on keeps the images. + \update_option( 'activitypub_allow_comment_images', 'on' ); + $this->assertFileExists( $file ); + + \update_option( 'activitypub_allow_comment_images', '0' ); + $this->assertDirectoryDoesNotExist( \wp_upload_dir()['basedir'] . Comment_Media::BASE_DIR ); + } + + /** + * 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 ); + } +} diff --git a/tests/phpunit/tests/includes/cache/class-test-file.php b/tests/phpunit/tests/includes/cache/class-test-file.php index 82ba9ac564..eaa19d135b 100644 --- a/tests/phpunit/tests/includes/cache/class-test-file.php +++ b/tests/phpunit/tests/includes/cache/class-test-file.php @@ -9,6 +9,7 @@ use Activitypub\Cache\Avatar; use Activitypub\Tests\Cache_Directory_Stream; +use Activitypub\Tests\Remote_Request_Stub; use WP_UnitTestCase; /** @@ -18,6 +19,33 @@ * since the File class is abstract. */ class Test_File extends WP_UnitTestCase { + use Remote_Request_Stub; + + /** + * A failed download is not tried again for a while, so page views do not wait for it again. + * Caching a file directly still downloads it. + * + * @covers \Activitypub\Cache\File::get_or_cache + */ + public function test_failed_download_is_remembered() { + $url = 'https://slow.example.com/photo.jpg'; + + \add_filter( 'activitypub_cache_is_safe_url', '__return_true' ); + $this->stub_remote_requests(); + $this->responses[ $url ] = new \WP_Error( 'http_request_failed', 'The remote host timed out.' ); + + Avatar::get_or_cache( $url, 1 ); + $remembered = Avatar::get_or_cache( $url, 1 ); + $requests = $this->requests; + Avatar::cache( $url, 1 ); + + $this->unstub_remote_requests(); + \remove_filter( 'activitypub_cache_is_safe_url', '__return_true' ); + + $this->assertFalse( $remembered ); + $this->assertSame( 1, $requests ); + $this->assertSame( 2, $this->requests ); + } /** * Test that is_enabled returns true by default. diff --git a/tests/phpunit/tests/includes/cache/class-test-media.php b/tests/phpunit/tests/includes/cache/class-test-media.php index b7ecfeb2be..0ef5dc97d4 100644 --- a/tests/phpunit/tests/includes/cache/class-test-media.php +++ b/tests/phpunit/tests/includes/cache/class-test-media.php @@ -61,28 +61,6 @@ public function test_is_enabled_filter() { $this->assertTrue( Media::is_enabled() ); } - /** - * Test get_storage_paths_for_context returns correct paths for media. - */ - public function test_get_storage_paths_for_context_media() { - $paths = Media::get_storage_paths_for_context( 123, 'media' ); - - $this->assertIsArray( $paths ); - $this->assertStringContainsString( '/activitypub/posts/', $paths['basedir'] ); - $this->assertStringContainsString( '/activitypub/posts/', $paths['baseurl'] ); - } - - /** - * Test get_storage_paths_for_context returns correct paths for comment_media. - */ - public function test_get_storage_paths_for_context_comment() { - $paths = Media::get_storage_paths_for_context( 123, 'comment_media' ); - - $this->assertIsArray( $paths ); - $this->assertStringContainsString( '/activitypub/comments/', $paths['basedir'] ); - $this->assertStringContainsString( '/activitypub/comments/', $paths['baseurl'] ); - } - /** * Test maybe_cleanup only runs for ap_post post type. */ @@ -106,27 +84,6 @@ public function test_maybe_cleanup_wrong_post_type() { Media::invalidate_entity( $post_id ); } - /** - * Test invalidate_comment removes comment directory. - */ - public function test_invalidate_comment() { - $paths = Media::get_storage_paths_for_context( 'test-comment', 'comment_media' ); - - // Create directory with a file. - wp_mkdir_p( $paths['basedir'] ); - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents - file_put_contents( $paths['basedir'] . '/test.txt', 'test content' ); - - $this->assertTrue( is_dir( $paths['basedir'] ) ); - - // Invalidate. - $result = Media::invalidate_comment( 'test-comment' ); - - $this->assertTrue( $result ); - $this->assertFalse( is_dir( $paths['basedir'] ) ); - } - - /** * Test maybe_cache filter is registered when caching is enabled. */ @@ -220,4 +177,53 @@ public function test_cdn_filter_works_at_render_time() { // Clean up. remove_filter( 'activitypub_remote_media_url', $cdn_filter ); } + + /** + * Test get_storage_paths_for_context returns correct paths for media. + * + * @expectedDeprecated Activitypub\Cache\Media::get_storage_paths_for_context + */ + public function test_get_storage_paths_for_context_media() { + $paths = Media::get_storage_paths_for_context( 123, 'media' ); + + $this->assertIsArray( $paths ); + $this->assertStringContainsString( '/activitypub/posts/', $paths['basedir'] ); + $this->assertStringContainsString( '/activitypub/posts/', $paths['baseurl'] ); + } + + /** + * Test get_storage_paths_for_context returns correct paths for comment_media. + * + * @expectedDeprecated Activitypub\Cache\Media::get_storage_paths_for_context + */ + public function test_get_storage_paths_for_context_comment() { + $paths = Media::get_storage_paths_for_context( 123, 'comment_media' ); + + $this->assertIsArray( $paths ); + $this->assertStringContainsString( '/activitypub/comments/', $paths['basedir'] ); + $this->assertStringContainsString( '/activitypub/comments/', $paths['baseurl'] ); + } + + /** + * Test invalidate_comment removes comment directory. + * + * @expectedDeprecated Activitypub\Cache\Media::get_storage_paths_for_context + * @expectedDeprecated Activitypub\Cache\Media::invalidate_comment + */ + public function test_invalidate_comment() { + $paths = Media::get_storage_paths_for_context( 'test-comment', 'comment_media' ); + + // Create directory with a file. + wp_mkdir_p( $paths['basedir'] ); + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + file_put_contents( $paths['basedir'] . '/test.txt', 'test content' ); + + $this->assertTrue( is_dir( $paths['basedir'] ) ); + + // Invalidate. + $result = Media::invalidate_comment( 'test-comment' ); + + $this->assertTrue( $result ); + $this->assertFalse( is_dir( $paths['basedir'] ) ); + } } diff --git a/tests/phpunit/tests/includes/class-test-blocks.php b/tests/phpunit/tests/includes/class-test-blocks.php index 118191d23b..94c5c37e5e 100644 --- a/tests/phpunit/tests/includes/class-test-blocks.php +++ b/tests/phpunit/tests/includes/class-test-blocks.php @@ -12,7 +12,9 @@ use Activitypub\Collection\Interactions; use Activitypub\Transformer\Post; +use function Activitypub\generate_image_block; use function Activitypub\object_to_uri; +use function Activitypub\process_remote_images; use function Activitypub\register_comment_type; /** @@ -21,6 +23,123 @@ * @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 ); + } + + /** + * 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', 'Cat' ) )[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' ), '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', 'Cat' ); + $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', '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( '', $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( 'assertStringContainsString( 'class="emoji"', $rendered ); + $this->assertSame( $rendered, Comment::render_blocks( $content ) ); + } /** * 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 20ea09541d..f180862672 100644 --- a/tests/phpunit/tests/includes/class-test-functions-media.php +++ b/tests/phpunit/tests/includes/class-test-functions-media.php @@ -13,6 +13,19 @@ * @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. @@ -152,7 +165,21 @@ 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'] ); } /** 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, '' ), + ); + } + + /** + * Create and update strip remote blocks before adding our own image blocks. + * + * @covers ::add_comment + * @covers ::update_comment + */ + public function test_comment_images_reject_remote_blocks_and_non_image_objects() { + $activity = $this->create_test_object( 'https://example.com/remote-block-reply' ); + $activity['object']['attachment'] = array( + array( + 'type' => 'Image', + 'url' => 'https://example.com/photo.jpg', + ), + array( + 'type' => 'Audio', + 'url' => 'https://example.com/audio.mp3', + ), + array( + 'type' => 'Video', + 'url' => 'https://example.com/video.mp4', + ), + ); + $activity['object']['content'] = '<!-- 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( '

Safe text.

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