diff --git a/plugins/webp-uploads/rest-api.php b/plugins/webp-uploads/rest-api.php index ff0069cc40..c7efc2481d 100644 --- a/plugins/webp-uploads/rest-api.php +++ b/plugins/webp-uploads/rest-api.php @@ -79,3 +79,76 @@ function webp_uploads_update_rest_attachment( WP_REST_Response $response, WP_Pos return new WP_REST_Response( $data ); } add_filter( 'rest_prepare_attachment', 'webp_uploads_update_rest_attachment', 10, 2 ); + +/** + * Ensures the REST attachment response's `source_url` points to the original uploaded file when + * this plugin has swapped the attachment's main file for a modern format. + * + * When the fallback setting is disabled, the plugin replaces the attachment's main file with the + * generated modern-format version and backs up the true original in the `original_image` metadata. + * `source_url` is what the Image/Gallery blocks read to build the "Link to image file" href, so + * without this it would point to the modern-format file instead of the original upload. This is + * intentionally scoped to the REST response only: `wp_get_attachment_url()` itself must keep + * returning the swapped file everywhere else, since that's what the plugin's own `` tag + * rewriting relies on to show the modern format. + * + * @since n.e.x.t + * + * @param WP_REST_Response $response The response object so far. + * @param WP_Post $post The attachment post object. + * @return WP_REST_Response The response object, with `source_url` corrected where applicable. + */ +function webp_uploads_update_rest_attachment_original_source_url( WP_REST_Response $response, WP_Post $post ): WP_REST_Response { + $data = $response->get_data(); + if ( ! is_array( $data ) || ! isset( $data['source_url'] ) || ! is_string( $data['source_url'] ) || '' === $data['source_url'] ) { + return $response; + } + + $metadata = wp_get_attachment_metadata( $post->ID ); + if ( ! is_array( $metadata ) || ! isset( $metadata['original_image'] ) || ! is_string( $metadata['original_image'] ) || '' === $metadata['original_image'] ) { + return $response; + } + + $attached_file = get_attached_file( $post->ID ); + if ( false === $attached_file ) { + return $response; + } + + /* + * Compare the mime type of the currently attached file against the backed-up original, not + * `get_post_mime_type()`: WordPress intentionally keeps the post's mime type as the original + * one for compatibility even after this plugin swaps the attached file. Core's own "-scaled" + * resizing keeps the same file mime type as the original; only override when this plugin + * actually swapped the attachment to a different mime type. + */ + $current_mime = wp_check_filetype( $attached_file )['type']; + $original_mime = wp_check_filetype( $metadata['original_image'] )['type']; + if ( ! is_string( $current_mime ) || $original_mime === $current_mime ) { + return $response; + } + + /* + * The plugin swapped the attached file to a modern format. Oversized images that + * WordPress scaled to "-scaled" keep that suffix on the swapped file, and the + * "Link to image file" href should stay scaled in the output format. Only fall + * back to the backed-up original upload for non-scaled images, where the + * `original_image` metadata has no "-scaled" suffix. + */ + if ( preg_match( '/-scaled(?=\.[^.]+$)/', $attached_file ) === 1 ) { + $target_url = wp_get_attachment_url( $post->ID ); + } else { + $target_url = path_join( dirname( $data['source_url'] ), $metadata['original_image'] ); + } + + if ( ! is_string( $target_url ) || '' === $target_url ) { + return $response; + } + + $data['source_url'] = $target_url; + if ( isset( $data['media_details']['sizes']['full']['source_url'] ) ) { + $data['media_details']['sizes']['full']['source_url'] = $target_url; + } + + return new WP_REST_Response( $data ); +} +add_filter( 'rest_prepare_attachment', 'webp_uploads_update_rest_attachment_original_source_url', 10, 2 ); diff --git a/plugins/webp-uploads/tests/test-attachment-url.php b/plugins/webp-uploads/tests/test-attachment-url.php new file mode 100644 index 0000000000..16e946d04a --- /dev/null +++ b/plugins/webp-uploads/tests/test-attachment-url.php @@ -0,0 +1,175 @@ + 'image/webp' ) ) ) { + $this->markTestSkipped( 'Mime type image/webp is not supported.' ); + } + + // Default to webp output for tests. + $this->set_image_output_type( 'webp' ); + } + + /** + * Fetches the REST attachment response data for a given attachment. + * + * @param int $attachment_id The attachment ID. + * @return array The REST response data. + */ + private function get_rest_attachment_data( int $attachment_id ): array { + $request = new WP_REST_Request(); + $request->set_param( 'id', $attachment_id ); + + $controller = new WP_REST_Attachments_Controller( 'attachment' ); + $response = $controller->get_item( $request ); + + $this->assertNotWPError( $response ); + + return $response->get_data(); + } + + /** + * When the fallback setting is disabled, the attachment's main file is swapped for the + * modern format and the original is backed up. The REST `source_url` (which the + * Image/Gallery blocks use for "Link to image file") should still resolve to the original. + * + * @covers ::webp_uploads_update_rest_attachment_original_source_url + */ + public function test_source_url_points_to_original_when_fallback_disabled(): void { + update_option( 'perflab_generate_webp_and_jpeg', false ); + + $attachment_id = self::factory()->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/data/images/leaves.jpg' ); + $this->assertNotWPError( $attachment_id ); + + // Sanity check: the attached file itself was swapped to WebP (the post's mime type + // stays JPEG for compatibility, which is why this can't be detected from that alone). + $this->assertStringEndsWith( '.webp', get_attached_file( $attachment_id ) ); + + $original_path = wp_get_original_image_path( $attachment_id ); + $this->assertStringEndsWith( '.jpg', $original_path ); + $this->assertFileExists( $original_path ); + + $data = $this->get_rest_attachment_data( $attachment_id ); + + $this->assertStringEndsWith( '.jpg', $data['source_url'] ); + $this->assertSame( wp_basename( $original_path ), wp_basename( $data['source_url'] ) ); + $this->assertSame( wp_basename( $original_path ), wp_basename( $data['media_details']['sizes']['full']['source_url'] ) ); + + // The src pipeline (wp_get_attachment_url()) must be unaffected: it still needs + // to resolve to the current (modern-format) file, only the REST `source_url` changes. + $this->assertStringEndsWith( '.webp', wp_get_attachment_url( $attachment_id ) ); + } + + /** + * When the fallback setting is enabled, the main file is not swapped, so `source_url` + * should be unaffected (still the original, as before this filter was added). + * + * @covers ::webp_uploads_update_rest_attachment_original_source_url + */ + public function test_source_url_unchanged_when_fallback_enabled(): void { + update_option( 'perflab_generate_webp_and_jpeg', true ); + + $attachment_id = self::factory()->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/data/images/leaves.jpg' ); + $this->assertNotWPError( $attachment_id ); + + $this->assertSame( 'image/jpeg', get_post_mime_type( $attachment_id ) ); + + $data = $this->get_rest_attachment_data( $attachment_id ); + + $this->assertStringEndsWith( '.jpg', $data['source_url'] ); + $this->assertSame( wp_basename( get_attached_file( $attachment_id ) ), wp_basename( $data['source_url'] ) ); + } + + /** + * Core's own "-scaled" resizing for oversized images keeps the same mime type and is + * unrelated to this plugin's format swap. It must not be affected by the filter. + * + * @covers ::webp_uploads_update_rest_attachment_original_source_url + */ + public function test_source_url_unaffected_by_core_scaled_images(): void { + update_option( 'perflab_generate_webp_and_jpeg', true ); + + add_filter( + 'big_image_size_threshold', + static function () { + return 850; + } + ); + + $attachment_id = self::factory()->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/data/images/leaves.jpg' ); + $this->assertNotWPError( $attachment_id ); + + $metadata = wp_get_attachment_metadata( $attachment_id ); + $this->assertArrayHasKey( 'original_image', $metadata, 'Core should have backed up the pre-scaled original.' ); + + $data = $this->get_rest_attachment_data( $attachment_id ); + + // The URL should still be the "-scaled" file core itself produced, not the pre-scaled original. + $this->assertSame( wp_basename( get_attached_file( $attachment_id ) ), wp_basename( $data['source_url'] ) ); + $this->assertStringContainsString( '-scaled', $data['source_url'] ); + } + + /** + * The tag optimization must keep showing the modern format regardless of the + * REST `source_url` filter above. + * + * @covers ::webp_uploads_img_tag_update_mime_type + */ + public function test_img_tag_still_uses_modern_format_when_fallback_disabled(): void { + update_option( 'perflab_generate_webp_and_jpeg', false ); + $this->mock_frontend_body_hooks(); + + $attachment_id = self::factory()->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/data/images/leaves.jpg' ); + $this->assertNotWPError( $attachment_id ); + + $image = wp_get_attachment_image( $attachment_id, 'large', false, array( 'class' => 'wp-image-' . $attachment_id ) ); + $content = apply_filters( 'the_content', $image ); + + $processor = new WP_HTML_Tag_Processor( $content ); + $this->assertTrue( $processor->next_tag( array( 'tag_name' => 'IMG' ) ) ); + $this->assertStringEndsWith( '.webp', $processor->get_attribute( 'src' ) ); + } + + /** + * When the fallback setting is disabled and the image is oversized, the plugin swaps + * the attached file to the scaled modern format (e.g. `leaves-scaled.webp`) and backs + * up the unscaled original in `original_image`. The REST `source_url` should still + * resolve to the scaled output-format file, not the unscaled original upload, so the + * "Link to image file" href stays scaled and in the modern format. + * + * @covers ::webp_uploads_update_rest_attachment_original_source_url + */ + public function test_source_url_points_to_scaled_output_format_when_fallback_disabled(): void { + update_option( 'perflab_generate_webp_and_jpeg', false ); + + add_filter( + 'big_image_size_threshold', + static function () { + return 850; + } + ); + + $attachment_id = self::factory()->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/data/images/leaves.jpg' ); + $this->assertNotWPError( $attachment_id ); + + // The attached file itself was swapped to the scaled modern format. + $this->assertStringEndsWith( '-scaled.webp', get_attached_file( $attachment_id ) ); + + $data = $this->get_rest_attachment_data( $attachment_id ); + + // The link must stay scaled and in the output format, not the unscaled original upload. + $this->assertStringEndsWith( '-scaled.webp', $data['source_url'] ); + $this->assertSame( wp_basename( get_attached_file( $attachment_id ) ), wp_basename( $data['source_url'] ) ); + $this->assertSame( wp_basename( get_attached_file( $attachment_id ) ), wp_basename( $data['media_details']['sizes']['full']['source_url'] ) ); + } +}