diff --git a/php/class-delivery.php b/php/class-delivery.php index a4eaa7452..f2ec5d184 100644 --- a/php/class-delivery.php +++ b/php/class-delivery.php @@ -1583,9 +1583,10 @@ 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 ] ) ) { - - $url = $this->known[ $public_id ]['sized_url']; + // 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; } } @@ -1837,25 +1838,54 @@ public function validate_url( $url ) { } /** - * Set url usability. + * 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 array $item The item result. - * @param null|bool $auto_sync If auto_sync is on. + * @param string $url The Cloudinary URL. + * @param int $attachment_id The attachment ID. + * + * @return bool */ - protected function set_usability( $item, $auto_sync = null ) { + 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; + } + } - /** - * 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 ); + 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. + * + * @param array $item The item result. + * + * @return string + */ + protected function get_searchable_url( $item ) { /** * The URL to be searched for and prepared to be delivered by Cloudinary. * @@ -1887,7 +1917,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..492f6e2a1 --- /dev/null +++ b/tests/phpunit/tests/test-delivery-parse-element.php @@ -0,0 +1,220 @@ +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 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 ); + + 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 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. + * + * @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'] ); + } +} + +/** + * 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. + */ +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; + } +}