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 c3fab17aca707..41d5976e80390 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 @@ -65,14 +65,6 @@ public function register_routes() { ); if ( wp_is_client_side_media_processing_enabled() ) { - $valid_image_sizes = array_keys( wp_get_registered_image_subsizes() ); - // Special case to set 'original_image' in attachment metadata. - $valid_image_sizes[] = 'original'; - // Used for PDF thumbnails. - $valid_image_sizes[] = 'full'; - // Client-side big image threshold: sideload the scaled version. - $valid_image_sizes[] = 'scaled'; - register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P[\d]+)/sideload', @@ -87,10 +79,47 @@ public function register_routes() { 'type' => 'integer', ), 'image_size' => array( - 'description' => __( 'Image size.' ), - 'type' => 'string', - 'enum' => $valid_image_sizes, - 'required' => true, + 'description' => __( 'Image size. Can be a single size name or an array of size names to register the same file under multiple sizes.' ), + 'type' => array( 'string', 'array' ), + 'items' => array( + 'type' => 'string', + ), + 'required' => true, + /* + * A custom callback is used instead of the default enum validation + * because rest_is_array() treats scalar strings as single-element + * lists (via wp_parse_list()), so a [ 'string', 'array' ] type alone + * cannot enforce the enum. The callback validates each item against + * the current list of registered sizes, which reflects sizes added + * after route registration (e.g. via add_image_size()). + */ + 'validate_callback' => static function ( $value, $request, $param ) { + $valid_sizes = array_keys( wp_get_registered_image_subsizes() ); + $valid_sizes[] = 'original'; + $valid_sizes[] = 'scaled'; + $valid_sizes[] = 'full'; + + $items = is_string( $value ) ? array( $value ) : ( is_array( $value ) ? $value : null ); + if ( null === $items ) { + return new WP_Error( + 'rest_invalid_type', + /* translators: %s: Parameter name. */ + sprintf( __( '%s must be a string or an array of strings.' ), $param ) + ); + } + + foreach ( $items as $item ) { + if ( ! is_string( $item ) || ! in_array( $item, $valid_sizes, true ) ) { + return new WP_Error( + 'rest_not_in_enum', + /* translators: %s: Parameter name. */ + sprintf( __( '%s contains an invalid image size.' ), $param ) + ); + } + } + + return true; + }, ), 'convert_format' => array( 'type' => 'boolean', @@ -125,8 +154,12 @@ public function register_routes() { 'type' => 'object', 'properties' => array( 'image_size' => array( - 'type' => 'string', - 'required' => true, + 'description' => __( 'Size name, or an array of size names when a single file is registered under multiple sizes with matching dimensions.' ), + 'type' => array( 'string', 'array' ), + 'items' => array( + 'type' => 'string', + ), + 'required' => true, ), 'width' => array( 'type' => 'integer', @@ -2127,7 +2160,18 @@ public function sideload_item( WP_REST_Request $request ) { 'image_size' => $image_size, ); - if ( 'original' === $image_size ) { + if ( is_array( $image_size ) ) { + // Multiple registered sizes share these dimensions, so a single + // sideloaded file is reused for all of them. Arrays only carry + // regular sub-sizes; the special keys below are always scalar. + $size = wp_getimagesize( $path ); + + $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 ); + } elseif ( 'original' === $image_size ) { $sub_size_data['file'] = wp_basename( $path ); } elseif ( 'scaled' === $image_size ) { // Record the current attached file as the original. @@ -2264,6 +2308,24 @@ public function finalize_item( WP_REST_Request $request ) { foreach ( $sub_sizes as $sub_size ) { $image_size = $sub_size['image_size']; + // When multiple size names share identical dimensions the client + // sends a single sub-size entry with an array of names. Register the + // same file under each name. Arrays only contain regular sizes. + if ( is_array( $image_size ) ) { + $metadata['sizes'] = $metadata['sizes'] ?? array(); + + foreach ( $image_size as $name ) { + $metadata['sizes'][ $name ] = 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, + ); + } + continue; + } + if ( 'original' === $image_size ) { $metadata['original_image'] = $sub_size['file']; } elseif ( 'scaled' === $image_size ) { diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 76c944c72f55e..9698c6f90950e 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3721,4 +3721,87 @@ public function test_finalize_preserves_image_meta(): void { $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.' ); } + + /** + * Tests that sideloading with an array of image sizes registers the single + * file under each size name when finalized. + * + * @ticket 64737 + * @covers WP_REST_Attachments_Controller::sideload_item + * @covers WP_REST_Attachments_Controller::finalize_item + * @requires function imagejpeg + */ + public function test_sideload_image_size_array() { + $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 single file registered under multiple sizes. + $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-dup.jpg' ); + $request->set_param( 'image_size', array( 'thumbnail', 'medium' ) ); + $request->set_body( (string) file_get_contents( self::$test_file ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Sideloading with an array of sizes should succeed.' ); + + $sub_size = $response->get_data(); + $this->assertSame( array( 'thumbnail', 'medium' ), $sub_size['image_size'], 'Response should echo the array of sizes.' ); + + // Finalize with the collected sub-size. + $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( 'thumbnail', $metadata['sizes'], 'Metadata should register the thumbnail size.' ); + $this->assertArrayHasKey( 'medium', $metadata['sizes'], 'Metadata should register the medium size.' ); + $this->assertSame( + $metadata['sizes']['thumbnail']['file'], + $metadata['sizes']['medium']['file'], + 'Both sizes should reference the same physical file.' + ); + } + + /** + * Tests that the sideload endpoint rejects an invalid image size name. + * + * @ticket 64737 + * @requires function imagejpeg + */ + public function test_sideload_image_size_invalid() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + $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_body( (string) file_get_contents( self::$test_file ) ); + $response = rest_get_server()->dispatch( $request ); + $attachment_id = $response->get_data()['id']; + + $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-x.jpg' ); + $request->set_param( 'image_size', array( 'thumbnail', 'not-a-real-size' ) ); + $request->set_body( (string) file_get_contents( self::$test_file ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 400, $response->get_status(), 'An unknown size name should be rejected.' ); + } }