From 2c996a07c46b926694f23a6c93dad710226fe3f8 Mon Sep 17 00:00:00 2001 From: Khokan Sardar Date: Sat, 25 Jul 2026 09:07:40 +0530 Subject: [PATCH 1/2] Media: Skip server-side scaling during client-side media processing. When an attachment is uploaded with `generate_sub_sizes` set to false the client generates all derivatives itself, including the scaled full-size image it later supplies through the sideload endpoint. The upload handler already suppressed thumbnail generation and EXIF rotation in this case, but not the "big image" downscaling gated by the `big_image_size_threshold` filter. As a result a large client-converted image (for example a HEIC converted to JPEG in the browser) was still scaled server-side to a `-scaled` file, which became the attached file while the untouched upload was recorded as `original_image`. The client's later scaled sideload then collided with that `-scaled` file and was renamed `-scaled-1`, the thumbnails inherited the numbered name, and the server-generated full-size file was left orphaned on disk. Disable `big_image_size_threshold` alongside the existing client-side processing filters so the uploaded full-size image is stored untouched and the client's scaled sideload records it as `original_image` as intended. Fixes #65708. --- .../class-wp-rest-attachments-controller.php | 5 ++ .../rest-api/rest-attachments-controller.php | 65 +++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index 6e06f1563c50c..78225e87e23da 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -459,6 +459,10 @@ public function create_item( $request ) { // Disable server-side EXIF rotation so the client can handle it. // This preserves the original orientation value in the metadata. add_filter( 'wp_image_maybe_exif_rotate', '__return_false', 100 ); + // Disable server-side "big image" downscaling; the client supplies its + // own scaled version via the sideload endpoint. Scaling here would + // create a conflicting "-scaled" file and orphan the full-size upload. + add_filter( 'big_image_size_threshold', '__return_false', 100 ); } // Handle convert_format parameter. @@ -691,6 +695,7 @@ private function remove_client_side_media_processing_filters(): void { remove_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 ); remove_filter( 'wp_image_maybe_exif_rotate', '__return_false', 100 ); remove_filter( 'image_editor_output_format', '__return_empty_array', 100 ); + remove_filter( 'big_image_size_threshold', '__return_false', 100 ); } /** diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 90899df850d47..b79ee71e5ee71 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3875,6 +3875,71 @@ public function test_sideload_scaled_image() { $this->assertGreaterThan( 0, $metadata['filesize'], 'Filesize should be positive.' ); } + /** + * When the client generates sub-sizes (generate_sub_sizes is false), the + * server must not perform its own "big image" downscaling on upload. + * + * Otherwise the server creates a `-scaled` file and records the upload as + * `original_image`. The client's subsequent scaled sideload then collides + * with that `-scaled` file and is renamed `-scaled-1`, the thumbnails + * inherit the numbered name, and the server-generated full-size file is + * left orphaned on disk. + * + * @ticket 65708 + * @requires function imagejpeg + */ + public function test_create_item_skips_big_image_scaling_when_client_generates_sub_sizes() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + // Force the threshold below the image's dimensions so scaling would be + // triggered were it not suppressed for client-side processing. + add_filter( + 'big_image_size_threshold', + static function () { + return 1000; + } + ); + + // Upload a large image with the client handling sub-size generation. + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=33772.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/33772.jpg' ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $attachment_id = $data['id']; + + $this->assertSame( 201, $response->get_status(), 'Uploading the image should succeed.' ); + + // The uploaded full-size image should be stored untouched: no + // server-side "-scaled" file and no original_image swap. + $original_file = get_attached_file( $attachment_id, true ); + $original_basename = wp_basename( $original_file ); + $original_name_stem = pathinfo( $original_basename, PATHINFO_FILENAME ); + $this->assertStringNotContainsString( '-scaled', $original_basename, 'The server should not create a -scaled file when the client generates sub-sizes.' ); + + $metadata = wp_get_attachment_metadata( $attachment_id ); + $this->assertArrayNotHasKey( 'original_image', $metadata, 'The server should not record an original_image when it does not scale the upload.' ); + + // The client's scaled sideload should now record the untouched upload as + // original_image and keep the -scaled name without a numeric suffix. + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', "attachment; filename={$original_name_stem}-scaled.jpg" ); + $request->set_param( 'image_size', 'scaled' ); + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/33772.jpg' ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Sideloading the scaled image should succeed.' ); + + $sub_size = $response->get_data(); + $this->assertSame( $original_basename, $sub_size['original_image'], 'The untouched upload should be recorded as original_image.' ); + $this->assertSame( "{$original_name_stem}-scaled.jpg", wp_basename( $sub_size['file'] ), 'The scaled sideload should keep the -scaled name without a numeric collision suffix.' ); + } + /** * Tests that sideloading scaled image requires authentication. * From 292c57879dfcad8a85b0bffca37512aca2faf079 Mon Sep 17 00:00:00 2001 From: Khokan Sardar Date: Mon, 3 Aug 2026 18:47:58 +0530 Subject: [PATCH 2/2] Media: Cover the sub-size naming and orphaned file symptoms in client-side big image scaling. Walks the full client-side flow for an image over the "big image" threshold: upload with generate_sub_sizes false, sideload the thumbnail and the scaled full-size image, then finalize. Asserts that only the files the metadata tracks are written and that deleting the attachment removes all of them. On trunk the server's own scaling renames the client's sideloads to "-150x150-1" and "-scaled-1" and leaves its full-size file orphaned after "Delete Permanently". See #65708. --- .../rest-api/rest-attachments-controller.php | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index b79ee71e5ee71..72bb483d087be 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3940,6 +3940,110 @@ static function () { $this->assertSame( "{$original_name_stem}-scaled.jpg", wp_basename( $sub_size['file'] ), 'The scaled sideload should keep the -scaled name without a numeric collision suffix.' ); } + /** + * The complete client-side flow for an image over the "big image" threshold + * should write only files that the metadata tracks, so that deleting the + * attachment removes all of them. + * + * When the server scales the upload as well, its own full-size file is + * never referenced by the metadata and survives "Delete Permanently", the + * client's scaled sideload collides with the server's "-scaled" file and is + * stored as "-scaled-1", and the sub-sizes inherit the numbered name. + * + * @ticket 65708 + * @covers WP_REST_Attachments_Controller::create_item + * @covers WP_REST_Attachments_Controller::sideload_item + * @covers WP_REST_Attachments_Controller::finalize_item + * @requires function imagejpeg + */ + public function test_client_side_big_image_flow_leaves_no_orphaned_files() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + // Force the threshold below the uploaded image's dimensions so scaling + // would be triggered were it not suppressed for client-side processing. + add_filter( + 'big_image_size_threshold', + static function () { + return 1000; + } + ); + + $upload_dir = wp_upload_dir(); + $files_before = (array) glob( $upload_dir['path'] . '/*' ); + + // 1. Upload the full-size image; the client owns all the derivatives. + // 33772.jpg is 1920x1080, so it exceeds the threshold above. + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=big-photo.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/33772.jpg' ) ); + $response = rest_get_server()->dispatch( $request ); + $attachment_id = $response->get_data()['id']; + + $this->assertSame( 201, $response->get_status(), 'Uploading the image should succeed.' ); + + /* + * 2. Sideload a thumbnail, as the client does for each sub-size. The + * client names it after the file it uploaded, so a server-side + * rename of that file is what pushes this into a collision. + * test-image.jpg is 50x50, within the registered thumbnail maximum. + */ + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=big-photo-150x150.jpg' ); + $request->set_param( 'image_size', 'thumbnail' ); + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/test-image.jpg' ) ); + $response = rest_get_server()->dispatch( $request ); + $thumbnail_data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'Sideloading the thumbnail should succeed.' ); + $this->assertSame( 'big-photo-150x150.jpg', wp_basename( $thumbnail_data['file'] ), 'The thumbnail should not inherit a numeric collision suffix.' ); + + // 3. Sideload the scaled full-size image. canola.jpg is 640x480, the + // size the client would have downscaled the upload to. + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=big-photo-scaled.jpg' ); + $request->set_param( 'image_size', 'scaled' ); + $request->set_body( file_get_contents( self::$test_file ) ); + $response = rest_get_server()->dispatch( $request ); + $scaled_data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'Sideloading the scaled image should succeed.' ); + + // 4. Finalize, which writes the collected sub-size metadata in one pass. + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/finalize" ); + $request->set_param( 'sub_sizes', array( $thumbnail_data, $scaled_data ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Finalize should succeed.' ); + + $metadata = wp_get_attachment_metadata( $attachment_id ); + + $this->assertSame( 'big-photo.jpg', $metadata['original_image'], 'The untouched upload should be recorded as original_image.' ); + $this->assertSame( 'big-photo-scaled.jpg', wp_basename( $metadata['file'] ), 'The client-supplied scaled image should become the attached file.' ); + $this->assertSame( 'big-photo-150x150.jpg', $metadata['sizes']['thumbnail']['file'], 'The thumbnail should keep its dimension-based name.' ); + + // Every file written for this attachment must be reachable from the + // metadata, otherwise it is orphaned on disk. + $written = array_map( 'wp_basename', array_diff( (array) glob( $upload_dir['path'] . '/*' ), $files_before ) ); + sort( $written ); + $this->assertSame( + array( 'big-photo-150x150.jpg', 'big-photo-scaled.jpg', 'big-photo.jpg' ), + $written, + 'The flow should write only the full-size upload, its scaled copy, and the sub-sizes.' + ); + + // Deleting the attachment should therefore clean all of them up. + wp_delete_attachment( $attachment_id, true ); + + $remaining = array_diff( (array) glob( $upload_dir['path'] . '/*' ), $files_before ); + $this->assertSame( array(), array_values( $remaining ), 'Deleting the attachment should leave no files behind.' ); + } + /** * Tests that sideloading scaled image requires authentication. *