diff --git a/php/class-media.php b/php/class-media.php index ea14b2ab6..cf4a3370b 100644 --- a/php/class-media.php +++ b/php/class-media.php @@ -1187,9 +1187,7 @@ public function attachment_url( $url, $attachment_id ) { } if ( - false === $this->in_downsize - && ! doing_filter( 'content_save_pre' ) - && ! Utils::is_saving_metadata() + $this->is_replacement_paused( $attachment_id, false ) // false: videos still need their own URL corrected here. /** * Filter doing upload. * If so, return the default attachment URL. @@ -1198,11 +1196,13 @@ public function attachment_url( $url, $attachment_id ) { * * @return bool */ - && ! apply_filters( 'cloudinary_doing_upload', false ) + || apply_filters( 'cloudinary_doing_upload', false ) ) { - if ( ! $this->is_cloudinary_url( $url ) && $this->cloudinary_id( $attachment_id ) ) { - $url = $this->cloudinary_url( $attachment_id ); - } + return $url; + } + + if ( ! $this->is_cloudinary_url( $url ) && $this->cloudinary_id( $attachment_id ) ) { + $url = $this->cloudinary_url( $attachment_id ); } return $url; @@ -1802,7 +1802,7 @@ public function filter_downsize( $image, $attachment_id, $size ) { } // Don't do this while saving. - if ( true === $this->in_downsize || doing_filter( 'content_save_pre' ) || wp_attachment_is( 'video', $attachment_id ) || Utils::is_saving_metadata() ) { + if ( $this->is_replacement_paused( $attachment_id ) ) { return $image; } @@ -1836,6 +1836,81 @@ public function filter_downsize( $image, $attachment_id, $size ) { return $image; } + /** + * Whether URL replacement should be paused for this attachment right now: while a + * downsize is already in progress (re-entrancy guard), while content or metadata is being + * saved, or -- unless explicitly included -- for a video attachment, since there is no + * "image size" to downsize a video to. + * + * Shared by filter_downsize(), filter_attachment_image_src(), and attachment_url(), so the + * save-state/re-entrancy logic lives in one place. + * + * @param int $attachment_id The attachment ID. + * @param bool $exclude_videos Whether a video attachment should also count as paused. + * + * @return bool + */ + private function is_replacement_paused( $attachment_id, $exclude_videos = true ) { + if ( true === $this->in_downsize || doing_filter( 'content_save_pre' ) || Utils::is_saving_metadata() ) { + return true; + } + + return $exclude_videos && wp_attachment_is( 'video', $attachment_id ); + } + + /** + * Correct wp_get_attachment_image_src() results that image_downsize() missed, or that a + * later-priority plugin overwrote, so Cloudinary keeps the last word on this specific, + * commonly-targeted core hook. + * + * @param array|false $image The image src array, or false. + * @param int $attachment_id The ID of the attachment. + * @param string|array $size The requested size of the image. + * @param bool $icon Whether the image should be treated as an icon. + * + * @return array|false The image array of size and url. + * @uses filter:wp_get_attachment_image_src + */ + public function filter_attachment_image_src( $image, $attachment_id, $size, $icon ) { + if ( empty( $image ) ) { + return $image; + } + + // Fast bow-out: already a Cloudinary URL, nothing to do. + if ( $this->is_cloudinary_url( $image[0] ) ) { + return $image; + } + + // Only images and preview-capable formats (PDF, PSD) have a real image representation to + // correct to. is_deliverable() treats every other attachment type as deliverable too, so + // without this, a synced non-image (audio, zip, docx, ...) requested with icon=true would + // have its generic mime icon replaced with the raw asset's Cloudinary URL, breaking the + // resulting tag. + if ( ! wp_attachment_is_image( $attachment_id ) && ! $this->is_preview_only( $attachment_id ) ) { + return $image; + } + + if ( $this->is_replacement_paused( $attachment_id ) ) { + return $image; + } + + if ( ! $this->plugin->get_component( 'delivery' )->is_deliverable( $attachment_id ) ) { + return $image; + } + + $cloudinary_id = $this->cloudinary_id( $attachment_id ); + if ( ! $cloudinary_id ) { + return $image; + } + + $url = $this->cloudinary_url( $attachment_id, $size, array(), $cloudinary_id ); + if ( $url ) { + $image[0] = $url; + } + + return $image; + } + /** * At the point of running wp_get_attachment_image_srcset, the $image_src Should be a Cloudinary URL, unless not synced. * This will fix the $image_meta so that the there's a match $src_matched on wp_calculate_image_srcset. @@ -3149,7 +3224,6 @@ public function add_live_url_filters() { add_filter( 'wp_calculate_image_srcset', array( $this, 'image_srcset' ), 10, 5 ); add_filter( 'wp_get_attachment_url', array( $this, 'attachment_url' ), 10, 2 ); add_filter( 'wp_get_original_image_url', array( $this, 'original_attachment_url' ), 10, 2 ); - add_filter( 'image_downsize', array( $this, 'filter_downsize' ), 10, 3 ); add_filter( 'wp_calculate_image_srcset_meta', array( $this, 'calculate_image_srcset_meta' ), 10, 3 ); // Hook into Featured Image cycle. @@ -3191,6 +3265,14 @@ public function setup() { if ( Utils::is_admin() ) { $this->add_live_url_filters(); } + + // image_downsize() and wp_get_attachment_image_src() are exercised by ordinary + // front-end theme code (e.g. the_post_thumbnail()), not just wp-admin screens, so + // these run everywhere rather than being scoped to add_live_url_filters()'s + // admin-only set. + add_filter( 'image_downsize', array( $this, 'filter_downsize' ), 10, 3 ); + add_filter( 'wp_get_attachment_image_src', array( $this, 'filter_attachment_image_src' ), PHP_INT_MAX, 4 ); + // Filter default image Quality and Format transformations. add_filter( 'cloudinary_default_qf_transformations_image', array( $this, 'default_image_transformations' ), 10 ); add_filter( 'cloudinary_default_freeform_transformations_image', array( $this, 'default_image_freeform_transformations' ), 10 ); diff --git a/php/class-utils.php b/php/class-utils.php index 8674a584a..2f3af7459 100644 --- a/php/class-utils.php +++ b/php/class-utils.php @@ -720,7 +720,11 @@ public static function strip_inline_svg_data_uris( $content ) { /** - * Is saving metadata. + * Is metadata currently being saved, right now. + * + * Uses doing_action(), not did_action(): the latter is cumulative for the whole request, so + * it would stay true for the rest of the page after any earlier, unrelated post/term/user + * meta write (a view counter, a session plugin, etc.), long after that write finished. * * @return bool */ @@ -731,7 +735,7 @@ public static function is_saving_metadata() { foreach ( $metadata['actions'] as $action ) { foreach ( $metadata['objects'] as $object ) { $inline_action = str_replace( array( '{object}', 'metadata' ), array( $object, 'meta' ), $action ); - if ( did_action( $inline_action ) ) { + if ( doing_action( $inline_action ) ) { $saving = true; break; } diff --git a/tests/phpunit/tests/test-attachment-image-src.php b/tests/phpunit/tests/test-attachment-image-src.php new file mode 100644 index 000000000..b5ab3efbf --- /dev/null +++ b/tests/phpunit/tests/test-attachment-image-src.php @@ -0,0 +1,345 @@ +attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); + } + + /** + * Build a Test_Attachment_Image_Src_Media instance wired to the real, already-booted plugin. + * + * @return Test_Attachment_Image_Src_Media + */ + protected function get_media() { + $media = new Test_Attachment_Image_Src_Media( \Cloudinary\get_plugin_instance() ); + $media->base_url = 'https://res.cloudinary.com/test-cloud'; + + return $media; + } + + /** + * A URL that already lives on the configured Cloudinary domain is returned untouched -- the + * cheap bow-out path the ticket calls for, taken before any sync/delivery check runs. + * + * @return void + */ + public function test_already_cloudinary_url_is_returned_unchanged() { + $media = $this->get_media(); + $media->stub_cloudinary_id = 'sample.jpg'; + $media->stub_cloudinary_url = 'https://res.cloudinary.com/test-cloud/image/upload/should-not-be-used.jpg'; + $image = array( 'https://res.cloudinary.com/test-cloud/image/upload/v1/sample.jpg', 100, 100, true ); + + $result = $media->filter_attachment_image_src( $image, self::$attachment_id, 'thumbnail', false ); + + $this->assertSame( $image, $result ); + } + + /** + * $icon is the caller's permission to fall back to a mime-type icon if image_downsize() + * found nothing -- WordPress passes the original argument through to the filter unchanged, + * regardless of whether $image is actually a real image or a fallback icon (see + * wp_get_attachment_image_src() in wp-includes/media.php). WP_Media_List_Table's list-mode + * thumbnail column calls wp_get_attachment_image() with icon=true for every attachment, real + * synced images included, so icon=true must not by itself block correction. + * + * @return void + */ + public function test_icon_true_does_not_block_correcting_a_real_synced_image() { + $media = $this->get_media(); + $media->stub_cloudinary_id = 'sample.jpg'; + $media->stub_cloudinary_url = 'https://res.cloudinary.com/test-cloud/image/upload/sample.jpg'; + $image = array( 'http://example.org/wp-content/uploads/canola.jpg', 100, 100, true ); + + $result = $media->filter_attachment_image_src( $image, self::$attachment_id, 'thumbnail', true ); + + $this->assertSame( $media->stub_cloudinary_url, $result[0] ); + } + + /** + * A genuine mime-icon fallback (no cloudinary_id to correct it to, e.g. an unsynced or + * non-deliverable attachment) is still left alone -- not because of the $icon flag, but + * because there is nothing to swap it for. + * + * @return void + */ + public function test_icon_fallback_is_unchanged_when_nothing_to_correct_it_to() { + $bare_id = self::factory()->post->create( + array( + 'post_type' => 'attachment', + 'post_mime_type' => 'image/jpeg', + ) + ); + // wp_attachment_is()/wp_attachment_is_image() return false unconditionally without a + // real _wp_attached_file, regardless of post_mime_type -- see wp-includes/post.php. + update_post_meta( $bare_id, '_wp_attached_file', 'bare.jpg' ); + + $media = $this->get_media(); + $image = array( 'http://example.org/wp-includes/images/media/default.png', 48, 64, false ); + + $result = $media->filter_attachment_image_src( $image, $bare_id, 'thumbnail', true ); + + $this->assertSame( $image, $result ); + } + + /** + * A synced non-image, non-preview attachment (audio, zip, docx, ...) requested with icon=true + * must keep its generic mime icon. Delivery::is_deliverable() treats every non-image, + * non-video attachment as deliverable, so without a type check here, this would otherwise get + * "corrected" to the raw asset's Cloudinary URL -- breaking the resulting tag (caught in + * PR review: WP_Media_List_Table's list view requests icon=true for every attachment type). + * + * @return void + */ + public function test_synced_non_image_attachment_with_icon_is_returned_unchanged() { + $zip_id = self::factory()->post->create( + array( + 'post_type' => 'attachment', + 'post_mime_type' => 'application/zip', + ) + ); + update_post_meta( $zip_id, '_wp_attached_file', 'archive.zip' ); + + $media = $this->get_media(); + $media->stub_cloudinary_id = 'archive.zip'; + $media->stub_cloudinary_url = 'https://res.cloudinary.com/test-cloud/raw/upload/archive.zip'; + $image = array( 'http://example.org/wp-includes/images/media/archive.png', 48, 64, false ); + + $result = $media->filter_attachment_image_src( $image, $zip_id, 'thumbnail', true ); + + $this->assertSame( $image, $result ); + } + + /** + * A synced preview-capable format (PDF/PSD) is the deliberate carve-out: it has a real image + * preview to correct to, so icon=true must not block it either, mirroring the image case. + * + * @return void + */ + public function test_synced_preview_only_attachment_with_icon_is_corrected() { + $pdf_id = self::factory()->post->create( + array( + 'post_type' => 'attachment', + 'post_mime_type' => 'application/pdf', + ) + ); + update_post_meta( $pdf_id, '_wp_attached_file', 'document.pdf' ); + + $media = $this->get_media(); + $media->stub_cloudinary_id = 'document.pdf'; + $media->stub_cloudinary_url = 'https://res.cloudinary.com/test-cloud/image/upload/document.jpg'; + $image = array( 'http://example.org/wp-includes/images/media/document.png', 48, 64, false ); + + $result = $media->filter_attachment_image_src( $image, $pdf_id, 'thumbnail', true ); + + $this->assertSame( $media->stub_cloudinary_url, $result[0] ); + } + + /** + * A false (no image) result has nothing to correct and must pass through unchanged. + * + * @return void + */ + public function test_false_image_is_returned_unchanged() { + $media = $this->get_media(); + + $this->assertFalse( $media->filter_attachment_image_src( false, self::$attachment_id, 'thumbnail', false ) ); + } + + /** + * A video attachment is left untouched -- caught by the image/preview-only type check before + * ever reaching the deliverable/sync checks (a video has no image representation to correct + * to here; its own URL is handled by attachment_url() instead). + * + * @return void + */ + public function test_video_attachment_is_returned_unchanged() { + $video_id = self::factory()->post->create( + array( + 'post_type' => 'attachment', + 'post_mime_type' => 'video/mp4', + ) + ); + update_post_meta( $video_id, '_wp_attached_file', 'video.mp4' ); + + $media = $this->get_media(); + $image = array( 'http://example.org/wp-content/uploads/video.mp4', 640, 360, true ); + + $result = $media->filter_attachment_image_src( $image, $video_id, 'thumbnail', false ); + + $this->assertSame( $image, $result ); + } + + /** + * An image attachment with no generated metadata is not deliverable (no width/height to + * hand Cloudinary), so the local URL passes through unchanged. + * + * @return void + */ + public function test_non_deliverable_attachment_is_returned_unchanged() { + $bare_id = self::factory()->post->create( + array( + 'post_type' => 'attachment', + 'post_mime_type' => 'image/jpeg', + ) + ); + update_post_meta( $bare_id, '_wp_attached_file', 'bare.jpg' ); + + $media = $this->get_media(); + $media->stub_cloudinary_id = 'sample.jpg'; + $media->stub_cloudinary_url = 'https://res.cloudinary.com/test-cloud/image/upload/should-not-be-used.jpg'; + $image = array( 'http://example.org/wp-content/uploads/bare.jpg', 100, 100, true ); + + $result = $media->filter_attachment_image_src( $image, $bare_id, 'thumbnail', false ); + + $this->assertSame( $image, $result ); + } + + /** + * A deliverable attachment that has no Cloudinary ID (not synced) is left untouched -- there + * is nothing to correct it to. + * + * @return void + */ + public function test_no_cloudinary_id_returns_image_unchanged() { + $media = $this->get_media(); + $media->stub_cloudinary_id = false; + $media->stub_cloudinary_url = 'https://res.cloudinary.com/test-cloud/image/upload/should-not-be-used.jpg'; + $image = array( 'http://example.org/wp-content/uploads/canola.jpg', 100, 100, true ); + + $result = $media->filter_attachment_image_src( $image, self::$attachment_id, 'thumbnail', false ); + + $this->assertSame( $image, $result ); + } + + /** + * The actual fix: a local URL on a deliverable, synced attachment is corrected to the + * Cloudinary URL, with width/height/is-intermediate preserved from the original array. + * + * @return void + */ + public function test_local_url_is_corrected_to_cloudinary_url() { + $media = $this->get_media(); + $media->stub_cloudinary_id = 'sample.jpg'; + $media->stub_cloudinary_url = 'https://res.cloudinary.com/test-cloud/image/upload/sample.jpg'; + $image = array( 'http://example.org/wp-content/uploads/canola.jpg', 100, 100, true ); + + $result = $media->filter_attachment_image_src( $image, self::$attachment_id, 'thumbnail', false ); + + $this->assertSame( $media->stub_cloudinary_url, $result[0] ); + $this->assertSame( 100, $result[1] ); + $this->assertSame( 100, $result[2] ); + $this->assertTrue( $result[3] ); + } + + /** + * Regression test for a PR review finding: Utils::is_saving_metadata() must reflect a meta + * write happening right now, not one that happened earlier in the request. It used to read + * did_action(), which is cumulative for the whole request -- so an unrelated post/term/user + * meta write anywhere earlier (a view counter, a session plugin, anything) would permanently + * block correction for the rest of the page once these filters started running on the front + * end. Writing unrelated meta here, before calling the filter, catches a regression back to + * that behaviour. + * + * @return void + */ + public function test_earlier_unrelated_meta_write_does_not_block_correction() { + $other_post_id = self::factory()->post->create(); + update_post_meta( $other_post_id, 'unrelated_counter', 1 ); + + $media = $this->get_media(); + $media->stub_cloudinary_id = 'sample.jpg'; + $media->stub_cloudinary_url = 'https://res.cloudinary.com/test-cloud/image/upload/sample.jpg'; + $image = array( 'http://example.org/wp-content/uploads/canola.jpg', 100, 100, true ); + + $result = $media->filter_attachment_image_src( $image, self::$attachment_id, 'thumbnail', false ); + + $this->assertSame( $media->stub_cloudinary_url, $result[0] ); + } +} + +/** + * A Media subclass with cloudinary_id()/cloudinary_url() stubbed out, so + * filter_attachment_image_src() can be exercised without driving the real sync/signature + * pipeline (which needs a live Cloudinary account -- see tests/e2e for that coverage). + */ +class Test_Attachment_Image_Src_Media extends \Cloudinary\Media { + + /** + * Canned cloudinary_id() return value. + * + * @var string|false + */ + public $stub_cloudinary_id = false; + + /** + * Canned cloudinary_url() return value. + * + * @var string|false + */ + public $stub_cloudinary_url = false; + + /** + * Stubbed to avoid the real sync/signature pipeline. + * + * @param int $attachment_id The attachment ID. + * + * @return string|false + */ + public function cloudinary_id( $attachment_id ) { + return $this->stub_cloudinary_id; + } + + /** + * Stubbed to avoid the real sync/signature pipeline. + * + * @param int $attachment_id The attachment ID. + * @param array|string $size The requested size. + * @param array $transformations Transformations to apply. + * @param string|null $cloudinary_id A forced Cloudinary ID. + * @param bool $overwrite_transformations Whether to overwrite transformations. + * + * @return string|false + */ + public function cloudinary_url( $attachment_id, $size = array(), $transformations = array(), $cloudinary_id = null, $overwrite_transformations = false ) { + return $this->stub_cloudinary_url; + } +}