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'] ) );
+ }
+}