@@ -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