Skip to content

Commit 9fd5b2e

Browse files
REST API: Support registering one sideloaded file under multiple sizes
Backport of Gutenberg PR #77036. When several registered image sizes share the same dimensions, the client generates a single physical file and sends its size names as an array, so the file is registered once and referenced under every matching size. The sideload endpoint's `image_size` parameter (and the finalize endpoint's `sub_sizes[].image_size`) now accept a string or an array of strings. Because rest_is_array() treats scalar strings as single-element lists, the enum is enforced via a validate_callback rather than a oneOf schema. sideload_item() and finalize_item() register the file under each name when an array is given.
1 parent d1b3a76 commit 9fd5b2e

2 files changed

Lines changed: 160 additions & 15 deletions

File tree

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

Lines changed: 77 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,6 @@ public function register_routes() {
6565
);
6666

6767
if ( wp_is_client_side_media_processing_enabled() ) {
68-
$valid_image_sizes = array_keys( wp_get_registered_image_subsizes() );
69-
// Special case to set 'original_image' in attachment metadata.
70-
$valid_image_sizes[] = 'original';
71-
// Used for PDF thumbnails.
72-
$valid_image_sizes[] = 'full';
73-
// Client-side big image threshold: sideload the scaled version.
74-
$valid_image_sizes[] = 'scaled';
75-
7668
register_rest_route(
7769
$this->namespace,
7870
'/' . $this->rest_base . '/(?P<id>[\d]+)/sideload',
@@ -87,10 +79,47 @@ public function register_routes() {
8779
'type' => 'integer',
8880
),
8981
'image_size' => array(
90-
'description' => __( 'Image size.' ),
91-
'type' => 'string',
92-
'enum' => $valid_image_sizes,
93-
'required' => true,
82+
'description' => __( 'Image size. Can be a single size name or an array of size names to register the same file under multiple sizes.' ),
83+
'type' => array( 'string', 'array' ),
84+
'items' => array(
85+
'type' => 'string',
86+
),
87+
'required' => true,
88+
/*
89+
* A custom callback is used instead of the default enum validation
90+
* because rest_is_array() treats scalar strings as single-element
91+
* lists (via wp_parse_list()), so a [ 'string', 'array' ] type alone
92+
* cannot enforce the enum. The callback validates each item against
93+
* the current list of registered sizes, which reflects sizes added
94+
* after route registration (e.g. via add_image_size()).
95+
*/
96+
'validate_callback' => static function ( $value, $request, $param ) {
97+
$valid_sizes = array_keys( wp_get_registered_image_subsizes() );
98+
$valid_sizes[] = 'original';
99+
$valid_sizes[] = 'scaled';
100+
$valid_sizes[] = 'full';
101+
102+
$items = is_string( $value ) ? array( $value ) : ( is_array( $value ) ? $value : null );
103+
if ( null === $items ) {
104+
return new WP_Error(
105+
'rest_invalid_type',
106+
/* translators: %s: Parameter name. */
107+
sprintf( __( '%s must be a string or an array of strings.' ), $param )
108+
);
109+
}
110+
111+
foreach ( $items as $item ) {
112+
if ( ! is_string( $item ) || ! in_array( $item, $valid_sizes, true ) ) {
113+
return new WP_Error(
114+
'rest_not_in_enum',
115+
/* translators: %s: Parameter name. */
116+
sprintf( __( '%s contains an invalid image size.' ), $param )
117+
);
118+
}
119+
}
120+
121+
return true;
122+
},
94123
),
95124
'convert_format' => array(
96125
'type' => 'boolean',
@@ -125,8 +154,12 @@ public function register_routes() {
125154
'type' => 'object',
126155
'properties' => array(
127156
'image_size' => array(
128-
'type' => 'string',
129-
'required' => true,
157+
'description' => __( 'Size name, or an array of size names when a single file is registered under multiple sizes with matching dimensions.' ),
158+
'type' => array( 'string', 'array' ),
159+
'items' => array(
160+
'type' => 'string',
161+
),
162+
'required' => true,
130163
),
131164
'width' => array(
132165
'type' => 'integer',
@@ -2127,7 +2160,18 @@ public function sideload_item( WP_REST_Request $request ) {
21272160
'image_size' => $image_size,
21282161
);
21292162

2130-
if ( 'original' === $image_size ) {
2163+
if ( is_array( $image_size ) ) {
2164+
// Multiple registered sizes share these dimensions, so a single
2165+
// sideloaded file is reused for all of them. Arrays only carry
2166+
// regular sub-sizes; the special keys below are always scalar.
2167+
$size = wp_getimagesize( $path );
2168+
2169+
$sub_size_data['width'] = $size ? $size[0] : 0;
2170+
$sub_size_data['height'] = $size ? $size[1] : 0;
2171+
$sub_size_data['file'] = wp_basename( $path );
2172+
$sub_size_data['mime_type'] = $type;
2173+
$sub_size_data['filesize'] = wp_filesize( $path );
2174+
} elseif ( 'original' === $image_size ) {
21312175
$sub_size_data['file'] = wp_basename( $path );
21322176
} elseif ( 'scaled' === $image_size ) {
21332177
// Record the current attached file as the original.
@@ -2264,6 +2308,24 @@ public function finalize_item( WP_REST_Request $request ) {
22642308
foreach ( $sub_sizes as $sub_size ) {
22652309
$image_size = $sub_size['image_size'];
22662310

2311+
// When multiple size names share identical dimensions the client
2312+
// sends a single sub-size entry with an array of names. Register the
2313+
// same file under each name. Arrays only contain regular sizes.
2314+
if ( is_array( $image_size ) ) {
2315+
$metadata['sizes'] = $metadata['sizes'] ?? array();
2316+
2317+
foreach ( $image_size as $name ) {
2318+
$metadata['sizes'][ $name ] = array(
2319+
'width' => $sub_size['width'] ?? 0,
2320+
'height' => $sub_size['height'] ?? 0,
2321+
'file' => $sub_size['file'] ?? '',
2322+
'mime-type' => $sub_size['mime_type'] ?? '',
2323+
'filesize' => $sub_size['filesize'] ?? 0,
2324+
);
2325+
}
2326+
continue;
2327+
}
2328+
22672329
if ( 'original' === $image_size ) {
22682330
$metadata['original_image'] = $sub_size['file'];
22692331
} elseif ( 'scaled' === $image_size ) {

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

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3722,4 +3722,87 @@ public function test_finalize_preserves_image_meta(): void {
37223722
$this->assertSame( $original_image_meta['focal_length'], $metadata['image_meta']['focal_length'], 'Focal length should be preserved.' );
37233723
$this->assertSame( $original_image_meta['iso'], $metadata['image_meta']['iso'], 'ISO should be preserved.' );
37243724
}
3725+
3726+
/**
3727+
* Tests that sideloading with an array of image sizes registers the single
3728+
* file under each size name when finalized.
3729+
*
3730+
* @ticket 64737
3731+
* @covers WP_REST_Attachments_Controller::sideload_item
3732+
* @covers WP_REST_Attachments_Controller::finalize_item
3733+
* @requires function imagejpeg
3734+
*/
3735+
public function test_sideload_image_size_array() {
3736+
$this->enable_client_side_media_processing();
3737+
3738+
wp_set_current_user( self::$author_id );
3739+
3740+
// Create an attachment without generating sub-sizes server-side.
3741+
$request = new WP_REST_Request( 'POST', '/wp/v2/media' );
3742+
$request->set_header( 'Content-Type', 'image/jpeg' );
3743+
$request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' );
3744+
$request->set_param( 'generate_sub_sizes', false );
3745+
$request->set_body( (string) file_get_contents( self::$test_file ) );
3746+
$response = rest_get_server()->dispatch( $request );
3747+
$attachment_id = $response->get_data()['id'];
3748+
3749+
$this->assertSame( 201, $response->get_status() );
3750+
3751+
// Sideload a single file registered under multiple sizes.
3752+
$request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" );
3753+
$request->set_header( 'Content-Type', 'image/jpeg' );
3754+
$request->set_header( 'Content-Disposition', 'attachment; filename=canola-dup.jpg' );
3755+
$request->set_param( 'image_size', array( 'thumbnail', 'medium' ) );
3756+
$request->set_body( (string) file_get_contents( self::$test_file ) );
3757+
$response = rest_get_server()->dispatch( $request );
3758+
3759+
$this->assertSame( 200, $response->get_status(), 'Sideloading with an array of sizes should succeed.' );
3760+
3761+
$sub_size = $response->get_data();
3762+
$this->assertSame( array( 'thumbnail', 'medium' ), $sub_size['image_size'], 'Response should echo the array of sizes.' );
3763+
3764+
// Finalize with the collected sub-size.
3765+
$request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/finalize" );
3766+
$request->set_param( 'sub_sizes', array( $sub_size ) );
3767+
$response = rest_get_server()->dispatch( $request );
3768+
3769+
$this->assertSame( 200, $response->get_status(), 'Finalize should succeed.' );
3770+
3771+
$metadata = wp_get_attachment_metadata( $attachment_id );
3772+
$this->assertArrayHasKey( 'thumbnail', $metadata['sizes'], 'Metadata should register the thumbnail size.' );
3773+
$this->assertArrayHasKey( 'medium', $metadata['sizes'], 'Metadata should register the medium size.' );
3774+
$this->assertSame(
3775+
$metadata['sizes']['thumbnail']['file'],
3776+
$metadata['sizes']['medium']['file'],
3777+
'Both sizes should reference the same physical file.'
3778+
);
3779+
}
3780+
3781+
/**
3782+
* Tests that the sideload endpoint rejects an invalid image size name.
3783+
*
3784+
* @ticket 64737
3785+
* @requires function imagejpeg
3786+
*/
3787+
public function test_sideload_image_size_invalid() {
3788+
$this->enable_client_side_media_processing();
3789+
3790+
wp_set_current_user( self::$author_id );
3791+
3792+
$request = new WP_REST_Request( 'POST', '/wp/v2/media' );
3793+
$request->set_header( 'Content-Type', 'image/jpeg' );
3794+
$request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' );
3795+
$request->set_body( (string) file_get_contents( self::$test_file ) );
3796+
$response = rest_get_server()->dispatch( $request );
3797+
$attachment_id = $response->get_data()['id'];
3798+
3799+
$request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" );
3800+
$request->set_header( 'Content-Type', 'image/jpeg' );
3801+
$request->set_header( 'Content-Disposition', 'attachment; filename=canola-x.jpg' );
3802+
$request->set_param( 'image_size', array( 'thumbnail', 'not-a-real-size' ) );
3803+
$request->set_body( (string) file_get_contents( self::$test_file ) );
3804+
$response = rest_get_server()->dispatch( $request );
3805+
3806+
$this->assertSame( 400, $response->get_status(), 'An unknown size name should be rejected.' );
3807+
}
37253808
}

0 commit comments

Comments
 (0)