Skip to content

Commit fa0d479

Browse files
authored
Merge branch 'WordPress:trunk' into trunk
2 parents 24fc5f5 + 7e5d241 commit fa0d479

2 files changed

Lines changed: 174 additions & 0 deletions

File tree

src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,10 @@ public function create_item( $request ) {
459459
// Disable server-side EXIF rotation so the client can handle it.
460460
// This preserves the original orientation value in the metadata.
461461
add_filter( 'wp_image_maybe_exif_rotate', '__return_false', 100 );
462+
// Disable server-side "big image" downscaling; the client supplies its
463+
// own scaled version via the sideload endpoint. Scaling here would
464+
// create a conflicting "-scaled" file and orphan the full-size upload.
465+
add_filter( 'big_image_size_threshold', '__return_false', 100 );
462466
}
463467

464468
// Handle convert_format parameter.
@@ -691,6 +695,7 @@ private function remove_client_side_media_processing_filters(): void {
691695
remove_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 );
692696
remove_filter( 'wp_image_maybe_exif_rotate', '__return_false', 100 );
693697
remove_filter( 'image_editor_output_format', '__return_empty_array', 100 );
698+
remove_filter( 'big_image_size_threshold', '__return_false', 100 );
694699
}
695700

696701
/**

tests/phpunit/tests/rest-api/rest-attachments-controller.php

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3875,6 +3875,175 @@ public function test_sideload_scaled_image() {
38753875
$this->assertGreaterThan( 0, $metadata['filesize'], 'Filesize should be positive.' );
38763876
}
38773877

3878+
/**
3879+
* When the client generates sub-sizes (generate_sub_sizes is false), the
3880+
* server must not perform its own "big image" downscaling on upload.
3881+
*
3882+
* Otherwise the server creates a `-scaled` file and records the upload as
3883+
* `original_image`. The client's subsequent scaled sideload then collides
3884+
* with that `-scaled` file and is renamed `-scaled-1`, the thumbnails
3885+
* inherit the numbered name, and the server-generated full-size file is
3886+
* left orphaned on disk.
3887+
*
3888+
* @ticket 65708
3889+
* @requires function imagejpeg
3890+
*/
3891+
public function test_create_item_skips_big_image_scaling_when_client_generates_sub_sizes() {
3892+
$this->enable_client_side_media_processing();
3893+
3894+
wp_set_current_user( self::$author_id );
3895+
3896+
// Force the threshold below the image's dimensions so scaling would be
3897+
// triggered were it not suppressed for client-side processing.
3898+
add_filter(
3899+
'big_image_size_threshold',
3900+
static function () {
3901+
return 1000;
3902+
}
3903+
);
3904+
3905+
// Upload a large image with the client handling sub-size generation.
3906+
$request = new WP_REST_Request( 'POST', '/wp/v2/media' );
3907+
$request->set_header( 'Content-Type', 'image/jpeg' );
3908+
$request->set_header( 'Content-Disposition', 'attachment; filename=33772.jpg' );
3909+
$request->set_param( 'generate_sub_sizes', false );
3910+
$request->set_body( file_get_contents( DIR_TESTDATA . '/images/33772.jpg' ) );
3911+
$response = rest_get_server()->dispatch( $request );
3912+
$data = $response->get_data();
3913+
$attachment_id = $data['id'];
3914+
3915+
$this->assertSame( 201, $response->get_status(), 'Uploading the image should succeed.' );
3916+
3917+
// The uploaded full-size image should be stored untouched: no
3918+
// server-side "-scaled" file and no original_image swap.
3919+
$original_file = get_attached_file( $attachment_id, true );
3920+
$original_basename = wp_basename( $original_file );
3921+
$original_name_stem = pathinfo( $original_basename, PATHINFO_FILENAME );
3922+
$this->assertStringNotContainsString( '-scaled', $original_basename, 'The server should not create a -scaled file when the client generates sub-sizes.' );
3923+
3924+
$metadata = wp_get_attachment_metadata( $attachment_id );
3925+
$this->assertArrayNotHasKey( 'original_image', $metadata, 'The server should not record an original_image when it does not scale the upload.' );
3926+
3927+
// The client's scaled sideload should now record the untouched upload as
3928+
// original_image and keep the -scaled name without a numeric suffix.
3929+
$request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" );
3930+
$request->set_header( 'Content-Type', 'image/jpeg' );
3931+
$request->set_header( 'Content-Disposition', "attachment; filename={$original_name_stem}-scaled.jpg" );
3932+
$request->set_param( 'image_size', 'scaled' );
3933+
$request->set_body( file_get_contents( DIR_TESTDATA . '/images/33772.jpg' ) );
3934+
$response = rest_get_server()->dispatch( $request );
3935+
3936+
$this->assertSame( 200, $response->get_status(), 'Sideloading the scaled image should succeed.' );
3937+
3938+
$sub_size = $response->get_data();
3939+
$this->assertSame( $original_basename, $sub_size['original_image'], 'The untouched upload should be recorded as original_image.' );
3940+
$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.' );
3941+
}
3942+
3943+
/**
3944+
* The complete client-side flow for an image over the "big image" threshold
3945+
* should write only files that the metadata tracks, so that deleting the
3946+
* attachment removes all of them.
3947+
*
3948+
* When the server scales the upload as well, its own full-size file is
3949+
* never referenced by the metadata and survives "Delete Permanently", the
3950+
* client's scaled sideload collides with the server's "-scaled" file and is
3951+
* stored as "-scaled-1", and the sub-sizes inherit the numbered name.
3952+
*
3953+
* @ticket 65708
3954+
* @covers WP_REST_Attachments_Controller::create_item
3955+
* @covers WP_REST_Attachments_Controller::sideload_item
3956+
* @covers WP_REST_Attachments_Controller::finalize_item
3957+
* @requires function imagejpeg
3958+
*/
3959+
public function test_client_side_big_image_flow_leaves_no_orphaned_files() {
3960+
$this->enable_client_side_media_processing();
3961+
3962+
wp_set_current_user( self::$author_id );
3963+
3964+
// Force the threshold below the uploaded image's dimensions so scaling
3965+
// would be triggered were it not suppressed for client-side processing.
3966+
add_filter(
3967+
'big_image_size_threshold',
3968+
static function () {
3969+
return 1000;
3970+
}
3971+
);
3972+
3973+
$upload_dir = wp_upload_dir();
3974+
$files_before = (array) glob( $upload_dir['path'] . '/*' );
3975+
3976+
// 1. Upload the full-size image; the client owns all the derivatives.
3977+
// 33772.jpg is 1920x1080, so it exceeds the threshold above.
3978+
$request = new WP_REST_Request( 'POST', '/wp/v2/media' );
3979+
$request->set_header( 'Content-Type', 'image/jpeg' );
3980+
$request->set_header( 'Content-Disposition', 'attachment; filename=big-photo.jpg' );
3981+
$request->set_param( 'generate_sub_sizes', false );
3982+
$request->set_body( file_get_contents( DIR_TESTDATA . '/images/33772.jpg' ) );
3983+
$response = rest_get_server()->dispatch( $request );
3984+
$attachment_id = $response->get_data()['id'];
3985+
3986+
$this->assertSame( 201, $response->get_status(), 'Uploading the image should succeed.' );
3987+
3988+
/*
3989+
* 2. Sideload a thumbnail, as the client does for each sub-size. The
3990+
* client names it after the file it uploaded, so a server-side
3991+
* rename of that file is what pushes this into a collision.
3992+
* test-image.jpg is 50x50, within the registered thumbnail maximum.
3993+
*/
3994+
$request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" );
3995+
$request->set_header( 'Content-Type', 'image/jpeg' );
3996+
$request->set_header( 'Content-Disposition', 'attachment; filename=big-photo-150x150.jpg' );
3997+
$request->set_param( 'image_size', 'thumbnail' );
3998+
$request->set_body( file_get_contents( DIR_TESTDATA . '/images/test-image.jpg' ) );
3999+
$response = rest_get_server()->dispatch( $request );
4000+
$thumbnail_data = $response->get_data();
4001+
4002+
$this->assertSame( 200, $response->get_status(), 'Sideloading the thumbnail should succeed.' );
4003+
$this->assertSame( 'big-photo-150x150.jpg', wp_basename( $thumbnail_data['file'] ), 'The thumbnail should not inherit a numeric collision suffix.' );
4004+
4005+
// 3. Sideload the scaled full-size image. canola.jpg is 640x480, the
4006+
// size the client would have downscaled the upload to.
4007+
$request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" );
4008+
$request->set_header( 'Content-Type', 'image/jpeg' );
4009+
$request->set_header( 'Content-Disposition', 'attachment; filename=big-photo-scaled.jpg' );
4010+
$request->set_param( 'image_size', 'scaled' );
4011+
$request->set_body( file_get_contents( self::$test_file ) );
4012+
$response = rest_get_server()->dispatch( $request );
4013+
$scaled_data = $response->get_data();
4014+
4015+
$this->assertSame( 200, $response->get_status(), 'Sideloading the scaled image should succeed.' );
4016+
4017+
// 4. Finalize, which writes the collected sub-size metadata in one pass.
4018+
$request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/finalize" );
4019+
$request->set_param( 'sub_sizes', array( $thumbnail_data, $scaled_data ) );
4020+
$response = rest_get_server()->dispatch( $request );
4021+
4022+
$this->assertSame( 200, $response->get_status(), 'Finalize should succeed.' );
4023+
4024+
$metadata = wp_get_attachment_metadata( $attachment_id );
4025+
4026+
$this->assertSame( 'big-photo.jpg', $metadata['original_image'], 'The untouched upload should be recorded as original_image.' );
4027+
$this->assertSame( 'big-photo-scaled.jpg', wp_basename( $metadata['file'] ), 'The client-supplied scaled image should become the attached file.' );
4028+
$this->assertSame( 'big-photo-150x150.jpg', $metadata['sizes']['thumbnail']['file'], 'The thumbnail should keep its dimension-based name.' );
4029+
4030+
// Every file written for this attachment must be reachable from the
4031+
// metadata, otherwise it is orphaned on disk.
4032+
$written = array_map( 'wp_basename', array_diff( (array) glob( $upload_dir['path'] . '/*' ), $files_before ) );
4033+
sort( $written );
4034+
$this->assertSame(
4035+
array( 'big-photo-150x150.jpg', 'big-photo-scaled.jpg', 'big-photo.jpg' ),
4036+
$written,
4037+
'The flow should write only the full-size upload, its scaled copy, and the sub-sizes.'
4038+
);
4039+
4040+
// Deleting the attachment should therefore clean all of them up.
4041+
wp_delete_attachment( $attachment_id, true );
4042+
4043+
$remaining = array_diff( (array) glob( $upload_dir['path'] . '/*' ), $files_before );
4044+
$this->assertSame( array(), array_values( $remaining ), 'Deleting the attachment should leave no files behind.' );
4045+
}
4046+
38784047
/**
38794048
* Tests that sideloading scaled image requires authentication.
38804049
*

0 commit comments

Comments
 (0)