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;
+ }
+}