@@ -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 ' ,
@@ -113,10 +142,50 @@ public function register_routes() {
113142 'callback ' => array ( $ this , 'finalize_item ' ),
114143 'permission_callback ' => array ( $ this , 'edit_media_item_permissions_check ' ),
115144 'args ' => array (
116- 'id ' => array (
145+ 'id ' => array (
117146 'description ' => __ ( 'Unique identifier for the attachment. ' ),
118147 'type ' => 'integer ' ,
119148 ),
149+ 'sub_sizes ' => array (
150+ 'description ' => __ ( 'Array of sub-size metadata collected from sideload responses. ' ),
151+ 'type ' => 'array ' ,
152+ 'default ' => array (),
153+ 'items ' => array (
154+ 'type ' => 'object ' ,
155+ 'properties ' => array (
156+ 'image_size ' => array (
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 ,
163+ ),
164+ 'width ' => array (
165+ 'type ' => 'integer ' ,
166+ 'minimum ' => 1 ,
167+ ),
168+ 'height ' => array (
169+ 'type ' => 'integer ' ,
170+ 'minimum ' => 1 ,
171+ ),
172+ 'file ' => array (
173+ 'type ' => 'string ' ,
174+ ),
175+ 'mime_type ' => array (
176+ 'type ' => 'string ' ,
177+ 'pattern ' => '^image/.* ' ,
178+ ),
179+ 'filesize ' => array (
180+ 'type ' => 'integer ' ,
181+ 'minimum ' => 1 ,
182+ ),
183+ 'original_image ' => array (
184+ 'type ' => 'string ' ,
185+ ),
186+ ),
187+ ),
188+ ),
120189 ),
121190 ),
122191 'allow_batch ' => $ this ->allow_batch ,
@@ -2082,16 +2151,30 @@ public function sideload_item( WP_REST_Request $request ) {
20822151
20832152 $ image_size = $ request ['image_size ' ];
20842153
2085- $ metadata = wp_get_attachment_metadata ( $ attachment_id , true );
2154+ // Build sub-size data to return to the client.
2155+ // The client accumulates these and sends them all to the finalize
2156+ // endpoint, which writes the metadata in a single operation. This
2157+ // avoids the read-modify-write race that concurrent sideloads for the
2158+ // same attachment would otherwise hit.
2159+ $ sub_size_data = array (
2160+ 'image_size ' => $ image_size ,
2161+ );
20862162
2087- if ( ! $ metadata ) {
2088- $ metadata = array ();
2089- }
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 );
20902168
2091- if ( 'original ' === $ image_size ) {
2092- $ metadata ['original_image ' ] = wp_basename ( $ path );
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 ) {
2175+ $ sub_size_data ['file ' ] = wp_basename ( $ path );
20932176 } elseif ( 'scaled ' === $ image_size ) {
2094- // The current attached file is the original; record it as original_image .
2177+ // Record the current attached file as the original.
20952178 $ current_file = get_attached_file ( $ attachment_id , true );
20962179
20972180 if ( ! $ current_file ) {
@@ -2102,7 +2185,7 @@ public function sideload_item( WP_REST_Request $request ) {
21022185 );
21032186 }
21042187
2105- $ metadata ['original_image ' ] = wp_basename ( $ current_file );
2188+ $ sub_size_data ['original_image ' ] = wp_basename ( $ current_file );
21062189
21072190 // Validate the scaled image before updating the attached file.
21082191 $ size = wp_getimagesize ( $ path );
@@ -2117,6 +2200,7 @@ public function sideload_item( WP_REST_Request $request ) {
21172200 }
21182201
21192202 // Update the attached file to point to the scaled version.
2203+ // This writes to _wp_attached_file meta, not _wp_attachment_metadata.
21202204 if (
21212205 get_attached_file ( $ attachment_id , true ) !== $ path &&
21222206 ! update_attached_file ( $ attachment_id , $ path )
@@ -2128,42 +2212,21 @@ public function sideload_item( WP_REST_Request $request ) {
21282212 );
21292213 }
21302214
2131- $ metadata ['width ' ] = $ size [0 ];
2132- $ metadata ['height ' ] = $ size [1 ];
2133- $ metadata ['filesize ' ] = $ filesize ;
2134- $ metadata ['file ' ] = _wp_relative_upload_path ( $ path );
2215+ $ sub_size_data ['width ' ] = $ size [0 ];
2216+ $ sub_size_data ['height ' ] = $ size [1 ];
2217+ $ sub_size_data ['filesize ' ] = $ filesize ;
2218+ $ sub_size_data ['file ' ] = _wp_relative_upload_path ( $ path );
21352219 } else {
2136- $ metadata ['sizes ' ] = $ metadata ['sizes ' ] ?? array ();
2137-
21382220 $ size = wp_getimagesize ( $ path );
21392221
2140- $ metadata ['sizes ' ][ $ image_size ] = array (
2141- 'width ' => $ size ? $ size [0 ] : 0 ,
2142- 'height ' => $ size ? $ size [1 ] : 0 ,
2143- 'file ' => wp_basename ( $ path ),
2144- 'mime-type ' => $ type ,
2145- 'filesize ' => wp_filesize ( $ path ),
2146- );
2147- }
2148-
2149- wp_update_attachment_metadata ( $ attachment_id , $ metadata );
2150-
2151- $ response_request = new WP_REST_Request (
2152- WP_REST_Server::READABLE ,
2153- rest_get_route_for_post ( $ attachment_id )
2154- );
2155-
2156- $ response_request ['context ' ] = 'edit ' ;
2157-
2158- if ( isset ( $ request ['_fields ' ] ) ) {
2159- $ response_request ['_fields ' ] = $ request ['_fields ' ];
2222+ $ sub_size_data ['width ' ] = $ size ? $ size [0 ] : 0 ;
2223+ $ sub_size_data ['height ' ] = $ size ? $ size [1 ] : 0 ;
2224+ $ sub_size_data ['file ' ] = wp_basename ( $ path );
2225+ $ sub_size_data ['mime_type ' ] = $ type ;
2226+ $ sub_size_data ['filesize ' ] = wp_filesize ( $ path );
21602227 }
21612228
2162- $ response = $ this ->prepare_item_for_response ( get_post ( $ attachment_id ), $ response_request );
2163-
2164- $ response ->header ( 'Location ' , rest_url ( rest_get_route_for_post ( $ attachment_id ) ) );
2165-
2166- return $ response ;
2229+ return rest_ensure_response ( $ sub_size_data );
21672230 }
21682231
21692232 /**
@@ -2215,9 +2278,11 @@ private static function filter_wp_unique_filename( $filename, $dir, $number, $at
22152278 /**
22162279 * Finalizes an attachment after client-side media processing.
22172280 *
2218- * Triggers the 'wp_generate_attachment_metadata' filter so that
2219- * server-side plugins can process the attachment after all client-side
2220- * operations (upload, thumbnail generation, sideloads) are complete.
2281+ * Applies the sub-size metadata collected from sideload responses in a
2282+ * single metadata update, then triggers the 'wp_generate_attachment_metadata'
2283+ * filter so that server-side plugins can process the attachment after all
2284+ * client-side operations (upload, thumbnail generation, sideloads) are
2285+ * complete.
22212286 *
22222287 * @since 7.1.0
22232288 *
@@ -2237,6 +2302,53 @@ public function finalize_item( WP_REST_Request $request ) {
22372302 $ metadata = array ();
22382303 }
22392304
2305+ // Apply all sub-size metadata collected from sideload responses.
2306+ $ sub_sizes = $ request ['sub_sizes ' ] ?? array ();
2307+
2308+ foreach ( $ sub_sizes as $ sub_size ) {
2309+ $ image_size = $ sub_size ['image_size ' ];
2310+
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+
2329+ if ( 'original ' === $ image_size ) {
2330+ $ metadata ['original_image ' ] = $ sub_size ['file ' ];
2331+ } elseif ( 'scaled ' === $ image_size ) {
2332+ if ( ! empty ( $ sub_size ['original_image ' ] ) ) {
2333+ $ metadata ['original_image ' ] = $ sub_size ['original_image ' ];
2334+ }
2335+ $ metadata ['width ' ] = $ sub_size ['width ' ] ?? 0 ;
2336+ $ metadata ['height ' ] = $ sub_size ['height ' ] ?? 0 ;
2337+ $ metadata ['filesize ' ] = $ sub_size ['filesize ' ] ?? 0 ;
2338+ $ metadata ['file ' ] = $ sub_size ['file ' ] ?? '' ;
2339+ } else {
2340+ $ metadata ['sizes ' ] = $ metadata ['sizes ' ] ?? array ();
2341+
2342+ $ metadata ['sizes ' ][ $ image_size ] = array (
2343+ 'width ' => $ sub_size ['width ' ] ?? 0 ,
2344+ 'height ' => $ sub_size ['height ' ] ?? 0 ,
2345+ 'file ' => $ sub_size ['file ' ] ?? '' ,
2346+ 'mime-type ' => $ sub_size ['mime_type ' ] ?? '' ,
2347+ 'filesize ' => $ sub_size ['filesize ' ] ?? 0 ,
2348+ );
2349+ }
2350+ }
2351+
22402352 /** This filter is documented in wp-admin/includes/image.php */
22412353 $ metadata = apply_filters ( 'wp_generate_attachment_metadata ' , $ metadata , $ attachment_id , 'update ' );
22422354
0 commit comments