From 658fa4720917e279005edb5bf3c3ce7548863071 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 22 May 2026 13:30:02 -0700 Subject: [PATCH 1/3] REST API: Move sideload metadata writing to the finalize endpoint Backport of Gutenberg PR #75888. Eliminate the read-modify-write race between concurrent sideloads for the same attachment by no longer writing attachment metadata in the sideload endpoint. Instead, sideload returns lightweight sub-size data (dimensions, filename, filesize) which the client accumulates and passes to the finalize endpoint, which writes all collected sub-sizes in a single metadata update. This matches how core generates sub-sizes (one metadata write after all sizes exist) and replaces the earlier per-attachment locking approach that the merged Gutenberg PR ultimately abandoned. --- .../class-wp-rest-attachments-controller.php | 136 +++++++++---- .../rest-api/rest-attachments-controller.php | 188 +++++++++++++++++- 2 files changed, 277 insertions(+), 47 deletions(-) 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 383135a76fac3..c3fab17aca707 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 @@ -113,10 +113,46 @@ public function register_routes() { 'callback' => array( $this, 'finalize_item' ), 'permission_callback' => array( $this, 'edit_media_item_permissions_check' ), 'args' => array( - 'id' => array( + 'id' => array( 'description' => __( 'Unique identifier for the attachment.' ), 'type' => 'integer', ), + 'sub_sizes' => array( + 'description' => __( 'Array of sub-size metadata collected from sideload responses.' ), + 'type' => 'array', + 'default' => array(), + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'image_size' => array( + 'type' => 'string', + 'required' => true, + ), + 'width' => array( + 'type' => 'integer', + 'minimum' => 1, + ), + 'height' => array( + 'type' => 'integer', + 'minimum' => 1, + ), + 'file' => array( + 'type' => 'string', + ), + 'mime_type' => array( + 'type' => 'string', + 'pattern' => '^image/.*', + ), + 'filesize' => array( + 'type' => 'integer', + 'minimum' => 1, + ), + 'original_image' => array( + 'type' => 'string', + ), + ), + ), + ), ), ), 'allow_batch' => $this->allow_batch, @@ -2082,16 +2118,19 @@ public function sideload_item( WP_REST_Request $request ) { $image_size = $request['image_size']; - $metadata = wp_get_attachment_metadata( $attachment_id, true ); - - if ( ! $metadata ) { - $metadata = array(); - } + // Build sub-size data to return to the client. + // The client accumulates these and sends them all to the finalize + // endpoint, which writes the metadata in a single operation. This + // avoids the read-modify-write race that concurrent sideloads for the + // same attachment would otherwise hit. + $sub_size_data = array( + 'image_size' => $image_size, + ); if ( 'original' === $image_size ) { - $metadata['original_image'] = wp_basename( $path ); + $sub_size_data['file'] = wp_basename( $path ); } elseif ( 'scaled' === $image_size ) { - // The current attached file is the original; record it as original_image. + // Record the current attached file as the original. $current_file = get_attached_file( $attachment_id, true ); if ( ! $current_file ) { @@ -2102,7 +2141,7 @@ public function sideload_item( WP_REST_Request $request ) { ); } - $metadata['original_image'] = wp_basename( $current_file ); + $sub_size_data['original_image'] = wp_basename( $current_file ); // Validate the scaled image before updating the attached file. $size = wp_getimagesize( $path ); @@ -2117,6 +2156,7 @@ public function sideload_item( WP_REST_Request $request ) { } // Update the attached file to point to the scaled version. + // This writes to _wp_attached_file meta, not _wp_attachment_metadata. if ( get_attached_file( $attachment_id, true ) !== $path && ! update_attached_file( $attachment_id, $path ) @@ -2128,42 +2168,21 @@ public function sideload_item( WP_REST_Request $request ) { ); } - $metadata['width'] = $size[0]; - $metadata['height'] = $size[1]; - $metadata['filesize'] = $filesize; - $metadata['file'] = _wp_relative_upload_path( $path ); + $sub_size_data['width'] = $size[0]; + $sub_size_data['height'] = $size[1]; + $sub_size_data['filesize'] = $filesize; + $sub_size_data['file'] = _wp_relative_upload_path( $path ); } else { - $metadata['sizes'] = $metadata['sizes'] ?? array(); - $size = wp_getimagesize( $path ); - $metadata['sizes'][ $image_size ] = array( - 'width' => $size ? $size[0] : 0, - 'height' => $size ? $size[1] : 0, - 'file' => wp_basename( $path ), - 'mime-type' => $type, - 'filesize' => wp_filesize( $path ), - ); - } - - wp_update_attachment_metadata( $attachment_id, $metadata ); - - $response_request = new WP_REST_Request( - WP_REST_Server::READABLE, - rest_get_route_for_post( $attachment_id ) - ); - - $response_request['context'] = 'edit'; - - if ( isset( $request['_fields'] ) ) { - $response_request['_fields'] = $request['_fields']; + $sub_size_data['width'] = $size ? $size[0] : 0; + $sub_size_data['height'] = $size ? $size[1] : 0; + $sub_size_data['file'] = wp_basename( $path ); + $sub_size_data['mime_type'] = $type; + $sub_size_data['filesize'] = wp_filesize( $path ); } - $response = $this->prepare_item_for_response( get_post( $attachment_id ), $response_request ); - - $response->header( 'Location', rest_url( rest_get_route_for_post( $attachment_id ) ) ); - - return $response; + return rest_ensure_response( $sub_size_data ); } /** @@ -2215,9 +2234,11 @@ private static function filter_wp_unique_filename( $filename, $dir, $number, $at /** * Finalizes an attachment after client-side media processing. * - * Triggers the 'wp_generate_attachment_metadata' filter so that - * server-side plugins can process the attachment after all client-side - * operations (upload, thumbnail generation, sideloads) are complete. + * Applies the sub-size metadata collected from sideload responses in a + * single metadata update, then triggers the 'wp_generate_attachment_metadata' + * filter so that server-side plugins can process the attachment after all + * client-side operations (upload, thumbnail generation, sideloads) are + * complete. * * @since 7.1.0 * @@ -2237,6 +2258,35 @@ public function finalize_item( WP_REST_Request $request ) { $metadata = array(); } + // Apply all sub-size metadata collected from sideload responses. + $sub_sizes = $request['sub_sizes'] ?? array(); + + foreach ( $sub_sizes as $sub_size ) { + $image_size = $sub_size['image_size']; + + if ( 'original' === $image_size ) { + $metadata['original_image'] = $sub_size['file']; + } elseif ( 'scaled' === $image_size ) { + if ( ! empty( $sub_size['original_image'] ) ) { + $metadata['original_image'] = $sub_size['original_image']; + } + $metadata['width'] = $sub_size['width'] ?? 0; + $metadata['height'] = $sub_size['height'] ?? 0; + $metadata['filesize'] = $sub_size['filesize'] ?? 0; + $metadata['file'] = $sub_size['file'] ?? ''; + } else { + $metadata['sizes'] = $metadata['sizes'] ?? array(); + + $metadata['sizes'][ $image_size ] = array( + 'width' => $sub_size['width'] ?? 0, + 'height' => $sub_size['height'] ?? 0, + 'file' => $sub_size['file'] ?? '', + 'mime-type' => $sub_size['mime_type'] ?? '', + 'filesize' => $sub_size['filesize'] ?? 0, + ); + } + } + /** This filter is documented in wp-admin/includes/image.php */ $metadata = apply_filters( 'wp_generate_attachment_metadata', $metadata, $attachment_id, 'update' ); diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 79e9d23cf9dd3..76c944c72f55e 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3275,16 +3275,32 @@ public function test_sideload_scaled_image() { $this->assertSame( 200, $response->get_status(), 'Sideloading scaled image should succeed.' ); + // The sideload endpoint returns lightweight sub-size data; the metadata + // is written later by the finalize endpoint. + $sub_size = $response->get_data(); + $this->assertSame( 'scaled', $sub_size['image_size'], 'Response should echo the image_size.' ); + $this->assertSame( wp_basename( $original_file ), $sub_size['original_image'], 'Response original_image should be the basename of the original attached file.' ); + $this->assertGreaterThan( 0, $sub_size['width'], 'Response width should be positive.' ); + $this->assertGreaterThan( 0, $sub_size['height'], 'Response height should be positive.' ); + $this->assertGreaterThan( 0, $sub_size['filesize'], 'Response filesize should be positive.' ); + $this->assertStringContainsString( 'scaled', $sub_size['file'], 'Response file should reference the scaled version.' ); + + // The attached file is still repointed to the scaled version during sideload. + $new_file = get_attached_file( $attachment_id, true ); + $this->assertStringContainsString( 'scaled', wp_basename( $new_file ), 'Attached file should now be the scaled version.' ); + + // Finalize with the collected sub-size, which writes the metadata. + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/finalize" ); + $request->set_param( 'sub_sizes', array( $sub_size ) ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status(), 'Finalize should succeed.' ); + $metadata = wp_get_attachment_metadata( $attachment_id ); // The original file should now be recorded as original_image. $this->assertArrayHasKey( 'original_image', $metadata, 'Metadata should contain original_image.' ); $this->assertSame( wp_basename( $original_file ), $metadata['original_image'], 'original_image should be the basename of the original attached file.' ); - // The attached file should now point to the scaled version. - $new_file = get_attached_file( $attachment_id, true ); - $this->assertStringContainsString( 'scaled', wp_basename( $new_file ), 'Attached file should now be the scaled version.' ); - // Metadata should have width, height, filesize, and file updated. $this->assertArrayHasKey( 'width', $metadata, 'Metadata should contain width.' ); $this->assertArrayHasKey( 'height', $metadata, 'Metadata should contain height.' ); @@ -3541,4 +3557,168 @@ public function test_finalize_item_invalid_id(): void { $this->assertErrorResponse( 'rest_post_invalid_id', $response, 404 ); } + + /** + * Tests that the finalize endpoint writes regular sub-size metadata + * collected from sideload responses. + * + * @ticket 62243 + * @covers WP_REST_Attachments_Controller::finalize_item + * @requires function imagejpeg + */ + public function test_finalize_writes_regular_sub_sizes(): void { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + // Create an attachment without generating sub-sizes server-side. + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + $request->set_body( (string) file_get_contents( self::$test_file ) ); + $response = rest_get_server()->dispatch( $request ); + $attachment_id = $response->get_data()['id']; + + $this->assertSame( 201, $response->get_status() ); + + // Sideload a thumbnail sub-size; the response carries its metadata. + $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=canola-thumb.jpg' ); + $request->set_param( 'image_size', 'thumbnail' ); + $request->set_body( (string) file_get_contents( self::$test_file ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Sideloading a thumbnail should succeed.' ); + + $sub_size = $response->get_data(); + $this->assertSame( 'thumbnail', $sub_size['image_size'], 'Response should echo the image_size.' ); + + // Finalize with the collected sub-size, which writes it into metadata. + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/finalize" ); + $request->set_param( 'sub_sizes', array( $sub_size ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Finalize should succeed.' ); + + $metadata = wp_get_attachment_metadata( $attachment_id ); + $this->assertArrayHasKey( 'sizes', $metadata, 'Metadata should contain sizes.' ); + $this->assertArrayHasKey( 'thumbnail', $metadata['sizes'], 'Metadata sizes should contain the sideloaded thumbnail.' ); + $this->assertSame( 'image/jpeg', $metadata['sizes']['thumbnail']['mime-type'], 'Thumbnail mime-type should be recorded.' ); + $this->assertGreaterThan( 0, $metadata['sizes']['thumbnail']['filesize'], 'Thumbnail filesize should be positive.' ); + } + + /** + * Tests that the finalize endpoint records original_image from an + * 'original' sub-size collected from a sideload response. + * + * @ticket 62243 + * @covers WP_REST_Attachments_Controller::finalize_item + * @requires function imagejpeg + */ + public function test_finalize_writes_original_metadata(): void { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + // Create an attachment without generating sub-sizes server-side. + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + $request->set_body( (string) file_get_contents( self::$test_file ) ); + $response = rest_get_server()->dispatch( $request ); + $attachment_id = $response->get_data()['id']; + + $this->assertSame( 201, $response->get_status() ); + + // Sideload the 'original' version (simulating a rotated image), which + // returns the basename without writing metadata. + $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=canola-original.jpg' ); + $request->set_param( 'image_size', 'original' ); + $request->set_body( (string) file_get_contents( self::$test_file ) ); + $response = rest_get_server()->dispatch( $request ); + $original_data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'Sideloading the original should succeed.' ); + $this->assertSame( 'original', $original_data['image_size'], 'Response should echo the image_size.' ); + $this->assertSame( 'canola-original.jpg', $original_data['file'], 'Response should return the file basename.' ); + + // Sideload must not write metadata; that happens in finalize. + $metadata = wp_get_attachment_metadata( $attachment_id, true ); + $this->assertArrayNotHasKey( 'original_image', $metadata, 'Sideload should not write original_image metadata.' ); + + // Finalize with the collected original sub-size. + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/finalize" ); + $request->set_param( 'sub_sizes', array( $original_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( 'canola-original.jpg', $metadata['original_image'], 'Finalize should record original_image from the sub-size.' ); + } + + /** + * Tests that the finalize endpoint preserves existing image_meta (EXIF) + * when adding sub-sizes collected from sideload responses. + * + * @ticket 62243 + * @covers WP_REST_Attachments_Controller::finalize_item + * @requires function imagejpeg + * @requires extension exif + */ + public function test_finalize_preserves_image_meta(): void { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + $exif_file = DIR_TESTDATA . '/images/2004-07-22-DSC_0008.jpg'; + + // Create an attachment without generating sub-sizes server-side. + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=2004-07-22-DSC_0008.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + $request->set_body( (string) file_get_contents( $exif_file ) ); + $response = rest_get_server()->dispatch( $request ); + $attachment_id = $response->get_data()['id']; + + $this->assertSame( 201, $response->get_status() ); + + $original_image_meta = wp_get_attachment_metadata( $attachment_id, true )['image_meta']; + + // Finalize with a thumbnail sub-size. + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/finalize" ); + $request->set_param( + 'sub_sizes', + array( + array( + 'image_size' => 'thumbnail', + 'width' => 150, + 'height' => 150, + 'file' => '2004-07-22-DSC_0008-150x150.jpg', + 'mime_type' => 'image/jpeg', + 'filesize' => 5000, + ), + ) + ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Finalize should succeed.' ); + + $metadata = wp_get_attachment_metadata( $attachment_id ); + + // The sub-size should have been added. + $this->assertArrayHasKey( 'thumbnail', $metadata['sizes'], 'Finalize should add the thumbnail sub-size.' ); + + // The EXIF image_meta should be unchanged. + $this->assertSame( $original_image_meta['aperture'], $metadata['image_meta']['aperture'], 'Aperture should be preserved.' ); + $this->assertSame( $original_image_meta['camera'], $metadata['image_meta']['camera'], 'Camera should be preserved.' ); + $this->assertSame( $original_image_meta['focal_length'], $metadata['image_meta']['focal_length'], 'Focal length should be preserved.' ); + $this->assertSame( $original_image_meta['iso'], $metadata['image_meta']['iso'], 'ISO should be preserved.' ); + } } From 65e27ef96de6bd1bc676aa5f2a9e7f432286567a Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 22 May 2026 15:28:55 -0700 Subject: [PATCH 2/3] Tests: Point new finalize tests at Trac ticket #65329 The three sub-size finalize tests added in this backport used the placeholder ticket 62243 (the original client-side media feature ticket) before a dedicated ticket existed. Trac #65329 now tracks this change, so update those @ticket annotations. Pre-existing finalize tests keep 62243. --- .../phpunit/tests/rest-api/rest-attachments-controller.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 76c944c72f55e..8ca608d184e39 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3562,7 +3562,7 @@ public function test_finalize_item_invalid_id(): void { * Tests that the finalize endpoint writes regular sub-size metadata * collected from sideload responses. * - * @ticket 62243 + * @ticket 65329 * @covers WP_REST_Attachments_Controller::finalize_item * @requires function imagejpeg */ @@ -3613,7 +3613,7 @@ public function test_finalize_writes_regular_sub_sizes(): void { * Tests that the finalize endpoint records original_image from an * 'original' sub-size collected from a sideload response. * - * @ticket 62243 + * @ticket 65329 * @covers WP_REST_Attachments_Controller::finalize_item * @requires function imagejpeg */ @@ -3666,7 +3666,7 @@ public function test_finalize_writes_original_metadata(): void { * Tests that the finalize endpoint preserves existing image_meta (EXIF) * when adding sub-sizes collected from sideload responses. * - * @ticket 62243 + * @ticket 65329 * @covers WP_REST_Attachments_Controller::finalize_item * @requires function imagejpeg * @requires extension exif From 16104c1f26d11729a5c52f8686531486478f67c1 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 22 May 2026 15:30:59 -0700 Subject: [PATCH 3/3] Tests: Cross-reference Trac #65329 in test_sideload_scaled_image This backport changes the scaled-image sideload behavior (sideload now returns sub-size data and metadata is written at finalize), so add a 65329 ticket reference alongside the existing 64737. --- tests/phpunit/tests/rest-api/rest-attachments-controller.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 8ca608d184e39..53df60e4b3b45 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3245,6 +3245,7 @@ static function ( $data ) use ( &$captured_data ) { * Tests sideloading a scaled image for an existing attachment. * * @ticket 64737 + * @ticket 65329 * @requires function imagejpeg */ public function test_sideload_scaled_image() {