Skip to content

Commit 9249e4f

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 8245308 commit 9249e4f

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.
@@ -2262,6 +2306,24 @@ public function finalize_item( WP_REST_Request $request ) {
22622306
foreach ( $sub_sizes as $sub_size ) {
22632307
$image_size = $sub_size['image_size'];
22642308

2309+
// When multiple size names share identical dimensions the client
2310+
// sends a single sub-size entry with an array of names. Register the
2311+
// same file under each name. Arrays only contain regular sizes.
2312+
if ( is_array( $image_size ) ) {
2313+
$metadata['sizes'] = $metadata['sizes'] ?? array();
2314+
2315+
foreach ( $image_size as $name ) {
2316+
$metadata['sizes'][ $name ] = array(
2317+
'width' => $sub_size['width'] ?? 0,
2318+
'height' => $sub_size['height'] ?? 0,
2319+
'file' => $sub_size['file'] ?? '',
2320+
'mime-type' => $sub_size['mime_type'] ?? '',
2321+
'filesize' => $sub_size['filesize'] ?? 0,
2322+
);
2323+
}
2324+
continue;
2325+
}
2326+
22652327
if ( 'original' === $image_size ) {
22662328
$metadata['original_image'] = $sub_size['file'];
22672329
} 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
@@ -3608,4 +3608,87 @@ public function test_finalize_writes_regular_sub_sizes(): void {
36083608
$this->assertSame( 'image/jpeg', $metadata['sizes']['thumbnail']['mime-type'], 'Thumbnail mime-type should be recorded.' );
36093609
$this->assertGreaterThan( 0, $metadata['sizes']['thumbnail']['filesize'], 'Thumbnail filesize should be positive.' );
36103610
}
3611+
3612+
/**
3613+
* Tests that sideloading with an array of image sizes registers the single
3614+
* file under each size name when finalized.
3615+
*
3616+
* @ticket 64737
3617+
* @covers WP_REST_Attachments_Controller::sideload_item
3618+
* @covers WP_REST_Attachments_Controller::finalize_item
3619+
* @requires function imagejpeg
3620+
*/
3621+
public function test_sideload_image_size_array() {
3622+
$this->enable_client_side_media_processing();
3623+
3624+
wp_set_current_user( self::$author_id );
3625+
3626+
// Create an attachment without generating sub-sizes server-side.
3627+
$request = new WP_REST_Request( 'POST', '/wp/v2/media' );
3628+
$request->set_header( 'Content-Type', 'image/jpeg' );
3629+
$request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' );
3630+
$request->set_param( 'generate_sub_sizes', false );
3631+
$request->set_body( (string) file_get_contents( self::$test_file ) );
3632+
$response = rest_get_server()->dispatch( $request );
3633+
$attachment_id = $response->get_data()['id'];
3634+
3635+
$this->assertSame( 201, $response->get_status() );
3636+
3637+
// Sideload a single file registered under multiple sizes.
3638+
$request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" );
3639+
$request->set_header( 'Content-Type', 'image/jpeg' );
3640+
$request->set_header( 'Content-Disposition', 'attachment; filename=canola-dup.jpg' );
3641+
$request->set_param( 'image_size', array( 'thumbnail', 'medium' ) );
3642+
$request->set_body( (string) file_get_contents( self::$test_file ) );
3643+
$response = rest_get_server()->dispatch( $request );
3644+
3645+
$this->assertSame( 200, $response->get_status(), 'Sideloading with an array of sizes should succeed.' );
3646+
3647+
$sub_size = $response->get_data();
3648+
$this->assertSame( array( 'thumbnail', 'medium' ), $sub_size['image_size'], 'Response should echo the array of sizes.' );
3649+
3650+
// Finalize with the collected sub-size.
3651+
$request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/finalize" );
3652+
$request->set_param( 'sub_sizes', array( $sub_size ) );
3653+
$response = rest_get_server()->dispatch( $request );
3654+
3655+
$this->assertSame( 200, $response->get_status(), 'Finalize should succeed.' );
3656+
3657+
$metadata = wp_get_attachment_metadata( $attachment_id );
3658+
$this->assertArrayHasKey( 'thumbnail', $metadata['sizes'], 'Metadata should register the thumbnail size.' );
3659+
$this->assertArrayHasKey( 'medium', $metadata['sizes'], 'Metadata should register the medium size.' );
3660+
$this->assertSame(
3661+
$metadata['sizes']['thumbnail']['file'],
3662+
$metadata['sizes']['medium']['file'],
3663+
'Both sizes should reference the same physical file.'
3664+
);
3665+
}
3666+
3667+
/**
3668+
* Tests that the sideload endpoint rejects an invalid image size name.
3669+
*
3670+
* @ticket 64737
3671+
* @requires function imagejpeg
3672+
*/
3673+
public function test_sideload_image_size_invalid() {
3674+
$this->enable_client_side_media_processing();
3675+
3676+
wp_set_current_user( self::$author_id );
3677+
3678+
$request = new WP_REST_Request( 'POST', '/wp/v2/media' );
3679+
$request->set_header( 'Content-Type', 'image/jpeg' );
3680+
$request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' );
3681+
$request->set_body( (string) file_get_contents( self::$test_file ) );
3682+
$response = rest_get_server()->dispatch( $request );
3683+
$attachment_id = $response->get_data()['id'];
3684+
3685+
$request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" );
3686+
$request->set_header( 'Content-Type', 'image/jpeg' );
3687+
$request->set_header( 'Content-Disposition', 'attachment; filename=canola-x.jpg' );
3688+
$request->set_param( 'image_size', array( 'thumbnail', 'not-a-real-size' ) );
3689+
$request->set_body( (string) file_get_contents( self::$test_file ) );
3690+
$response = rest_get_server()->dispatch( $request );
3691+
3692+
$this->assertSame( 400, $response->get_status(), 'An unknown size name should be rejected.' );
3693+
}
36113694
}

0 commit comments

Comments
 (0)