From f5d38a0a710daf9109005afaeb6acd1fa29d34c0 Mon Sep 17 00:00:00 2001 From: Gabriel de Tassigny Date: Thu, 29 Oct 2026 09:13:17 +0100 Subject: [PATCH 1/2] fix(delivery): track Cloudinary src URLs back to their attachment Since WPP-1183, image_downsize() and wp_get_attachment_image_src() return Cloudinary URLs on the front end, so tags built by theme code (e.g. wp_get_attachment_image() in a template or shortcode) reach Delivery with a Cloudinary src. parse_element() tracks such a src back through the public ID, but then looked the item up by the relation's sized_url, which is relative to the content dir, while the keys of $this->known are full URLs. The lookup never matched, so these tags lost lazy loading, responsive breakpoints and the SEO attributes they had in 3.3.8. Extracts the URL set_usability() builds for the known keys into get_searchable_url(), and uses it for the track-back. --- php/class-delivery.php | 51 ++++--- .../tests/test-delivery-parse-element.php | 142 ++++++++++++++++++ 2 files changed, 173 insertions(+), 20 deletions(-) create mode 100644 tests/phpunit/tests/test-delivery-parse-element.php diff --git a/php/class-delivery.php b/php/class-delivery.php index a4eaa7452..b4374c255 100644 --- a/php/class-delivery.php +++ b/php/class-delivery.php @@ -1584,8 +1584,8 @@ public function parse_element( $element, $content = '' ) { if ( $this->media->is_cloudinary_url( $raw_url ) ) { $public_id = $this->media->get_public_id_from_url( $raw_url ); if ( isset( $this->known[ $public_id ] ) ) { - - $url = $this->known[ $public_id ]['sized_url']; + // The relation's sized_url is relative to the content dir, while the URL keys of $this->known are full URLs. + $url = $this->get_searchable_url( $this->known[ $public_id ] ); $attributes['src'] = $url; } } @@ -1837,25 +1837,13 @@ public function validate_url( $url ) { } /** - * Set url usability. + * Get the URL an item is known by, as used for the keys of $this->known. * - * @param array $item The item result. - * @param null|bool $auto_sync If auto_sync is on. + * @param array $item The item result. + * + * @return string */ - protected function set_usability( $item, $auto_sync = null ) { - - /** - * Filter the found item to allow usability to be altered. - * - * @hook cloudinary_set_usable_asset - * @since 3.0.2 - * - * @param array $item The found asset. - * - * @return array - */ - $item = apply_filters( 'cloudinary_set_usable_asset', $item ); - + protected function get_searchable_url( $item ) { /** * The URL to be searched for and prepared to be delivered by Cloudinary. * @@ -1887,7 +1875,30 @@ protected function set_usability( $item, $auto_sync = null ) { * * @return string */ - $url = apply_filters( 'cloudinary_delivery_searchable_url', $url, $item, $content_url ); + return apply_filters( 'cloudinary_delivery_searchable_url', $url, $item, $content_url ); + } + + /** + * Set url usability. + * + * @param array $item The item result. + * @param null|bool $auto_sync If auto_sync is on. + */ + protected function set_usability( $item, $auto_sync = null ) { + + /** + * Filter the found item to allow usability to be altered. + * + * @hook cloudinary_set_usable_asset + * @since 3.0.2 + * + * @param array $item The found asset. + * + * @return array + */ + $item = apply_filters( 'cloudinary_set_usable_asset', $item ); + + $url = $this->get_searchable_url( $item ); $found = array(); diff --git a/tests/phpunit/tests/test-delivery-parse-element.php b/tests/phpunit/tests/test-delivery-parse-element.php new file mode 100644 index 000000000..0c37cb4a2 --- /dev/null +++ b/tests/phpunit/tests/test-delivery-parse-element.php @@ -0,0 +1,142 @@ +attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); + } + + /** + * Build a Delivery instance using a media component pointed at the test Cloudinary domain. + * + * @return Test_Delivery_Parse_Element_Delivery + */ + protected function get_delivery() { + $plugin = \Cloudinary\get_plugin_instance(); + $media = new \Cloudinary\Media( $plugin ); + $media->base_url = self::BASE_URL; + $media->filter = new \Cloudinary\Media\Filter( $media ); + + $delivery = new Test_Delivery_Parse_Element_Delivery( $plugin ); + $delivery->set_media( $media ); + + return $delivery; + } + + /** + * Seed the known items the same way Delivery::set_usability() keys them: by public ID, and + * by the full URL built from the content URL and the relation's content-relative sized_url. + * + * @param Test_Delivery_Parse_Element_Delivery $delivery The delivery instance. + * + * @return string The full URL the item is known by. + */ + protected function seed_known( $delivery ) { + $file = get_post_meta( self::$attachment_id, '_wp_attached_file', true ); + $item = array( + 'post_id' => self::$attachment_id, + 'public_id' => 'canola', + 'sized_url' => '/uploads/' . $file, + 'sync_type' => 'media', + 'format' => 'jpg', + 'width' => 640, + 'height' => 480, + 'post_state' => 'enable', + ); + $url = \Cloudinary\Utils::clean_url( content_url() ) . $item['sized_url']; + + $delivery->known['canola'] = $item; + $delivery->known[ $url ] = $item; + + return $url; + } + + /** + * An img tag with a Cloudinary src for a known public ID is tracked back to its attachment. + * + * @return void + */ + public function test_cloudinary_src_is_tracked_back_to_its_attachment() { + $delivery = $this->get_delivery(); + $url = $this->seed_known( $delivery ); + + $element = $delivery->parse_element( '' ); + + $this->assertSame( self::$attachment_id, $element['id'] ); + $this->assertSame( $url, $element['atts']['src'] ); + $this->assertSame( 'canola.jpg', $element['atts']['data-public-id'] ); + } + + /** + * A Cloudinary src for a public ID Delivery doesn't know is left alone. + * + * @return void + */ + public function test_unknown_cloudinary_src_is_not_tracked_back() { + $delivery = $this->get_delivery(); + $this->seed_known( $delivery ); + + $src = self::BASE_URL . '/images/w_300,h_225,c_scale/v1/other/other.jpg'; + $element = $delivery->parse_element( '' ); + + $this->assertSame( 0, $element['id'] ); + $this->assertSame( $src, $element['atts']['src'] ); + } +} + +/** + * Delivery with a settable media component, since init() only wires the real one once connected. + */ +class Test_Delivery_Parse_Element_Delivery extends \Cloudinary\Delivery { + + /** + * Set the media component. + * + * @param \Cloudinary\Media $media The media component. + * + * @return void + */ + public function set_media( $media ) { + $this->media = $media; + } +} From c0d6f79ee131682e4e0e42a57774af88485dce56 Mon Sep 17 00:00:00 2001 From: Gabriel de Tassigny Date: Thu, 29 Oct 2026 13:34:15 +0100 Subject: [PATCH 2/2] fix(delivery): leave Cloudinary URLs with their own transformations as is The track-back also matched Cloudinary URLs typed into content by hand, and rebuilding them from the attachment dropped any transformations in their path (e.g. an effect or a manual crop). Before the track-back worked, these tags were left as they were. Only track a URL back when its transformations are ones the plugin applies to the attachment itself: its size, and the attachment's own and global transformations. --- php/class-delivery.php | 44 +++++++++- .../tests/test-delivery-parse-element.php | 86 ++++++++++++++++++- 2 files changed, 125 insertions(+), 5 deletions(-) diff --git a/php/class-delivery.php b/php/class-delivery.php index b4374c255..f2ec5d184 100644 --- a/php/class-delivery.php +++ b/php/class-delivery.php @@ -1583,7 +1583,8 @@ public function parse_element( $element, $content = '' ) { // Track back the found URL. if ( $this->media->is_cloudinary_url( $raw_url ) ) { $public_id = $this->media->get_public_id_from_url( $raw_url ); - if ( isset( $this->known[ $public_id ] ) ) { + // A URL with transformations of its own (e.g. typed into content by hand) is left as is, since rebuilding it would drop them. + if ( isset( $this->known[ $public_id ] ) && ! $this->has_custom_transformations( $raw_url, $this->known[ $public_id ]['post_id'] ) ) { // The relation's sized_url is relative to the content dir, while the URL keys of $this->known are full URLs. $url = $this->get_searchable_url( $this->known[ $public_id ] ); $attributes['src'] = $url; @@ -1836,6 +1837,47 @@ public function validate_url( $url ) { return $parts['host'] === $home['host'] ? $this->is_content_dir( $url ) : $this->media->can_upload_from_host( $parts['host'] ); } + /** + * Check if a Cloudinary URL has transformations other than the ones the plugin applies to the attachment itself: + * its size, and the attachment's own and global transformations. + * + * @param string $url The Cloudinary URL. + * @param int $attachment_id The attachment ID. + * + * @return bool + */ + protected function has_custom_transformations( $url, $attachment_id ) { + $type = $this->media->get_resource_type( $attachment_id ); + $expected = $this->media->get_transformations_from_string( (string) $this->media->cloudinary_url( $attachment_id ), $type ); + + foreach ( $this->media->get_transformations_from_string( $url, $type ) as $transformation ) { + // Loose comparison, so the order of the parameters doesn't matter. + if ( ! $this->is_size_transformation( $transformation ) && ! in_array( $transformation, $expected ) ) { // phpcs:ignore WordPress.PHP.StrictInArray.MissingTrueStrict + return true; + } + } + + return false; + } + + /** + * Check if a transformation is one the plugin uses to size an image, e.g. `w_300,h_200,c_scale` or `w_150,h_150,c_thumb,g_auto`. + * + * @param array $transformation The transformation, as parsed by Media::get_transformations_from_string(). + * + * @return bool + */ + protected function is_size_transformation( $transformation ) { + if ( array_diff_key( $transformation, array_flip( array( 'width', 'height', 'crop', 'gravity' ) ) ) ) { + return false; + } + if ( isset( $transformation['crop'] ) && ! in_array( $transformation['crop'], array( 'scale', 'fill', 'thumb' ), true ) ) { + return false; + } + + return ! isset( $transformation['gravity'] ) || 'auto' === $transformation['gravity']; + } + /** * Get the URL an item is known by, as used for the keys of $this->known. * diff --git a/tests/phpunit/tests/test-delivery-parse-element.php b/tests/phpunit/tests/test-delivery-parse-element.php index 0c37cb4a2..492f6e2a1 100644 --- a/tests/phpunit/tests/test-delivery-parse-element.php +++ b/tests/phpunit/tests/test-delivery-parse-element.php @@ -8,7 +8,8 @@ * tracks such a src back to its attachment through the public ID; this covers that the track-back * resolves to the same full URL the known items are keyed by, rather than the relation's * content-relative sized_url, which never matched and left the tag unprocessed (no lazy loading, - * responsive breakpoints or SEO attributes). + * responsive breakpoints or SEO attributes). A src with transformations of its own (e.g. typed + * into content by hand) is left alone, as rebuilding it from the attachment would drop them. * * The known items are seeded directly rather than driven through prepare_delivery() and the * relationship table, so no Cloudinary account is needed. @@ -53,9 +54,10 @@ public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { */ protected function get_delivery() { $plugin = \Cloudinary\get_plugin_instance(); - $media = new \Cloudinary\Media( $plugin ); - $media->base_url = self::BASE_URL; - $media->filter = new \Cloudinary\Media\Filter( $media ); + $media = new Test_Delivery_Parse_Element_Media( $plugin ); + $media->base_url = self::BASE_URL; + $media->filter = new \Cloudinary\Media\Filter( $media ); + $media->stub_cloudinary_url = self::BASE_URL . '/images/f_auto,q_auto/v1/canola/canola.jpg'; $delivery = new Test_Delivery_Parse_Element_Delivery( $plugin ); $delivery->set_media( $media ); @@ -107,6 +109,54 @@ public function test_cloudinary_src_is_tracked_back_to_its_attachment() { $this->assertSame( 'canola.jpg', $element['atts']['data-public-id'] ); } + /** + * A Cloudinary src with the plugin's own thumbnail crop is still tracked back. + * + * @return void + */ + public function test_cloudinary_src_with_thumbnail_crop_is_tracked_back() { + $delivery = $this->get_delivery(); + $this->seed_known( $delivery ); + + $element = $delivery->parse_element( '' ); + + $this->assertSame( self::$attachment_id, $element['id'] ); + } + + /** + * A Cloudinary src with transformations of its own, e.g. typed into content by hand, is left + * alone, since rebuilding it from the attachment would drop them. + * + * @dataProvider data_custom_transformations + * + * @param string $transformations The transformations in the src. + * + * @return void + */ + public function test_cloudinary_src_with_custom_transformations_is_not_tracked_back( $transformations ) { + $delivery = $this->get_delivery(); + $this->seed_known( $delivery ); + + $src = self::BASE_URL . '/images/' . $transformations . '/v1/canola/canola.jpg'; + $element = $delivery->parse_element( '' ); + + $this->assertSame( 0, $element['id'] ); + $this->assertSame( $src, $element['atts']['src'] ); + } + + /** + * Transformations the plugin doesn't apply itself. + * + * @return array + */ + public function data_custom_transformations() { + return array( + 'effect' => array( 'w_300,h_225,c_scale/e_sepia/f_auto,q_auto' ), + 'manual crop' => array( 'c_crop,w_400,h_400,x_100,y_100/e_grayscale' ), + 'gravity' => array( 'w_300,h_300,c_fill,g_face' ), + ); + } + /** * A Cloudinary src for a public ID Delivery doesn't know is left alone. * @@ -124,6 +174,34 @@ public function test_unknown_cloudinary_src_is_not_tracked_back() { } } +/** + * Media with cloudinary_url() stubbed, to avoid the real sync/signature pipeline. + */ +class Test_Delivery_Parse_Element_Media extends \Cloudinary\Media { + + /** + * 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. + * @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; + } +} + /** * Delivery with a settable media component, since init() only wires the real one once connected. */