PluginProbe
Gutenberg / trunk
Gutenberg vtrunk
24.1.0 24.0.0 23.9.1 23.9.0 23.8.0 23.7.2 23.7.1 23.7.0 23.6.1 23.6.2 23.6.0 23.5.3 23.5.2 23.5.1 23.5.0 23.4.0 23.3.2 23.3.1 23.3.0 23.2.0 23.2.1 23.2.2 23.1.1 23.1.0 23.0.1 All 404 releases
← All changes | lib/media/class-gutenberg-rest-attachments-controller.php +959 -483 23.6.2 → trunk View file →
@@ -9,10 +9,21 @@
9 9 * REST API controller for media attachments.
10 10 *
11 11 * Extends the core attachments controller to add client-side media processing
12 12 * functionality including sideload support and sub-size generation control.
13 + *
14 + * @phpstan-type Image_Sub_Size array{
15 + * image_size: non-empty-string|non-empty-list<non-empty-string>,
16 + * width?: positive-int,
17 + * height?: positive-int,
18 + * file?: non-empty-string,
19 + * mime_type?: non-empty-string,
20 + * filesize?: positive-int,
21 + * original_image?: non-empty-string,
22 + * }
13 23 */
14 24 class Gutenberg_REST_Attachments_Controller extends WP_REST_Attachments_Controller {
25 +
15 26 /**
16 27 * Image size token for the source-format original preserved alongside a
17 28 * client-generated derivative (e.g. the HEIC file kept next to its JPEG).
18 29 *
@@ -66,8 +77,25 @@
66 77 */
67 78 const META_KEY_ANIMATED_VIDEO_POSTER = 'animated_video_poster';
68 79
69 80 /**
81 + * Post meta key recording the file names produced by the sideload endpoint.
82 + *
83 + * Each successful sideload appends the file name(s) it created for an
84 + * attachment under this key. The finalize endpoint reads them back to
85 + * confirm every stored sub-size was actually produced here, rather than
86 + * trusting a client-supplied name that could point at another attachment's
87 + * files. Stored as one row per value (via {@see add_post_meta()}) so concurrent
88 + * sideloads never read-modify-write a shared value.
89 + *
90 + * Matches the key used by WordPress core's implementation so the records
91 + * stay interchangeable when core also ships these endpoints.
92 + *
93 + * @var string
94 + */
95 + const META_KEY_SIDELOAD_FILE_NAME = '_wp_sideloaded_file';
96 +
97 + /**
70 98 * Registers the routes for attachments.
71 99 *
72 100 * @see register_rest_route()
73 101 */
@@ -90,10 +118,13 @@
90 118 'image_size' => array(
91 119 'description' => __( 'Image size. Can be a single size name or an array of size names to register the same file under multiple sizes.', 'gutenberg' ),
92 120 'type' => array( 'string', 'array' ),
93 121 'items' => array(
94 - 'type' => 'string',
122 + 'type' => 'string',
123 + 'minLength' => 1,
95 124 ),
125 + 'minItems' => 1,
126 + 'minLength' => 1,
96 127 'required' => true,
97 128 // A custom callback is used instead of the default `rest_validate_request_arg`
98 129 // because WordPress's `rest_is_array()` treats scalar strings as single-element
99 130 // lists (via wp_parse_list), so a oneOf with both a string and array schema
@@ -100,37 +131,20 @@
100 131 // matches a plain string twice and validation fails with "matches more than one
101 132 // of the expected formats". The callback validates the enum per-item using the
102 133 // current list of registered sizes, which reflects any sizes added after the
103 134 // route was registered (e.g. via add_image_size() in tests).
104 - 'validate_callback' => static function ( $value, $request, $param ) {
105 - $valid_sizes = array_keys( wp_get_registered_image_subsizes() );
106 - $valid_sizes[] = 'original';
107 - $valid_sizes[] = self::IMAGE_SIZE_SOURCE_ORIGINAL;
108 - $valid_sizes[] = self::IMAGE_SIZE_ANIMATED_VIDEO;
109 - $valid_sizes[] = self::IMAGE_SIZE_ANIMATED_VIDEO_POSTER;
110 - $valid_sizes[] = 'scaled';
111 - $valid_sizes[] = 'full';
112 -
113 - $items = is_string( $value ) ? array( $value ) : ( is_array( $value ) ? $value : null );
114 - if ( null === $items ) {
115 - return new WP_Error(
116 - 'rest_invalid_type',
117 - /* translators: %s: Parameter name. */
118 - sprintf( __( '%s must be a string or an array of strings.', 'gutenberg' ), $param )
119 - );
135 + 'validate_callback' => static function ( $value, WP_REST_Request $request, string $param ) {
136 + /*
137 + * Providing a custom callback replaces the default schema
138 + * validation, so apply the declared schema (type, minLength,
139 + * minItems) before the enum check below.
140 + */
141 + $schema_validity = rest_validate_request_arg( $value, $request, $param );
142 + if ( is_wp_error( $schema_validity ) ) {
143 + return $schema_validity;
120 144 }
121 145
122 - foreach ( $items as $item ) {
123 - if ( ! is_string( $item ) || ! in_array( $item, $valid_sizes, true ) ) {
124 - return new WP_Error(
125 - 'rest_not_in_enum',
126 - /* translators: %s: Parameter name. */
127 - sprintf( __( '%s contains an invalid image size.', 'gutenberg' ), $param )
128 - );
129 - }
130 - }
131 -
132 - return true;
146 + return self::validate_image_size_names( $value, $param );
133 147 },
134 148 ),
135 149 'convert_format' => array(
136 150 'description' => __( 'Whether to convert image formats.', 'gutenberg' ),
@@ -158,12 +172,52 @@
158 172 'description' => __( 'Unique identifier for the attachment.', 'gutenberg' ),
159 173 'type' => 'integer',
160 174 ),
161 175 'sub_sizes' => array(
162 - 'description' => __( 'Array of sub-size metadata collected from sideload responses.', 'gutenberg' ),
163 - 'type' => 'array',
164 - 'default' => array(),
165 - 'items' => array(
176 + 'description' => __( 'Array of sub-size metadata collected from sideload responses.', 'gutenberg' ),
177 + 'type' => 'array',
178 + 'default' => array(),
179 + /*
180 + * A finalize request sends one entry per sideloaded sub-size, so
181 + * the ceiling only needs to clear the number of sizes a site can
182 + * register. Bounding it keeps a request from repeating a name
183 + * across an arbitrary number of entries.
184 + */
185 + 'maxItems' => 100,
186 + /*
187 + * As on the sideload endpoint, the size names are checked in a
188 + * callback rather than an enum, so the set reflects the sizes
189 + * registered when the request runs. The callback sits on
190 + * sub_sizes because a nested property cannot carry one.
191 + */
192 + 'validate_callback' => static function ( $value, WP_REST_Request $request, string $param ) {
193 + /*
194 + * Providing a custom callback replaces the default schema
195 + * validation, so apply the declared schema first. That is what
196 + * guarantees each entry is an object carrying an image_size of
197 + * the declared type.
198 + */
199 + $schema_validity = rest_validate_request_arg( $value, $request, $param );
200 + if ( is_wp_error( $schema_validity ) ) {
201 + return $schema_validity;
202 + }
203 +
204 + foreach ( (array) $value as $index => $sub_size ) {
205 + $sub_size = (array) $sub_size;
206 +
207 + $validity = self::validate_image_size_names(
208 + $sub_size['image_size'] ?? null,
209 + sprintf( '%s[%s][image_size]', $param, $index )
210 + );
211 +
212 + if ( is_wp_error( $validity ) ) {
213 + return $validity;
214 + }
215 + }
216 +
217 + return true;
218 + },
219 + 'items' => array(
166 220 'type' => 'object',
167 221 'properties' => array(
168 222 'image_size' => array(
169 223 // Uses a multi-type schema instead of `oneOf` because WordPress's
@@ -173,10 +227,13 @@
173 227 // validation error.
174 228 'description' => __( 'Size name, or an array of size names when a single file is registered under multiple sizes with matching dimensions.', 'gutenberg' ),
175 229 'type' => array( 'string', 'array' ),
176 230 'items' => array(
177 - 'type' => 'string',
231 + 'type' => 'string',
232 + 'minLength' => 1,
178 233 ),
234 + 'minItems' => 1,
235 + 'minLength' => 1,
179 236 'required' => true,
180 237 ),
181 238 'width' => array(
182 239 'type' => 'integer',
@@ -208,62 +265,18 @@
208 265 ),
209 266 ),
210 267 'allow_batch' => $this->allow_batch,
211 268 'schema' => array( $this, 'get_public_item_schema' ),
212 - )
269 + ),
270 + // Override core's route so this schema wins on WordPress versions
271 + // that register their own finalize route: without the override the
272 + // earlier core registration is dispatched and this hardened schema
273 + // (minLength/minItems) never applies.
274 + true
213 275 );
214 276 }
215 277
216 278 /**
217 - * Checks if a given request has access to create an attachment.
218 - *
219 - * Skips the server-side image type support check when the client
220 - * will handle image processing (generate_sub_sizes is false). Still
221 - * HEIC/HEIF uploads always skip the check, since the browser's canvas
222 - * fallback can decode them even when the server cannot.
223 - *
224 - * @param WP_REST_Request $request Full details about the request.
225 - * @return true|WP_Error True if the request has access to create items, WP_Error object otherwise.
226 - */
227 - public function create_item_permissions_check( $request ) {
228 - $bypass_mime_check = false === $request['generate_sub_sizes'];
229 -
230 - /*
231 - * Always allow still HEIC/HEIF uploads through even if the server's
232 - * image editor doesn't support them. The client-side canvas fallback
233 - * handles processing using the browser's native HEVC decoder.
234 - *
235 - * The '-sequence' variants (multi-frame Live Photos) are deliberately
236 - * excluded: neither the server nor the browser fallback can process
237 - * them yet, so they should fall through to the standard unsupported
238 - * mime-type error rather than be stored unprocessable.
239 - */
240 - if ( ! $bypass_mime_check ) {
241 - $still_heic_mime_types = array( 'image/heic', 'image/heif' );
242 - $files = $request->get_file_params();
243 -
244 - if (
245 - ! empty( $files['file']['type'] ) &&
246 - in_array( $files['file']['type'], $still_heic_mime_types, true )
247 - ) {
248 - $bypass_mime_check = true;
249 - }
250 - }
251 -
252 - if ( $bypass_mime_check ) {
253 - add_filter( 'wp_prevent_unsupported_mime_type_uploads', '__return_false' );
254 - }
255 -
256 - $result = parent::create_item_permissions_check( $request );
257 -
258 - if ( $bypass_mime_check ) {
259 - remove_filter( 'wp_prevent_unsupported_mime_type_uploads', '__return_false' );
260 - }
261 -
262 - return $result;
263 - }
264 -
265 - /**
266 279 * Retrieves an array of endpoint arguments from the item schema for the controller.
267 280 *
268 281 * @param string $method Optional. HTTP method of the request. The arguments for `CREATABLE` requests are
269 282 * checked for required values and may fall-back to a given default, this is not done
@@ -288,9 +301,9 @@
288 301 'type' => 'string',
289 302 'format' => 'uri',
290 303 'description' => __( 'URL of an external image to sideload into the media library, instead of uploading a file.', 'gutenberg' ),
291 304 'sanitize_callback' => 'sanitize_url',
292 - 'validate_callback' => static function ( $url, WP_REST_Request $request, string $param ) {
305 + 'validate_callback' => static function ( $url, $request, $param ) {
293 306 /*
294 307 * A custom validate_callback replaces the default
295 308 * rest_validate_request_arg(), so re-apply it first to keep
296 309 * the schema checks (string type, uri format) enforced.
@@ -322,248 +335,54 @@
322 335 return $args;
323 336 }
324 337
325 338 /**
326 - * Retrieves the attachment's schema, conforming to JSON Schema.
339 + * Checks if a given request has access to create an attachment.
327 340 *
328 - * Adds exif_orientation field to the schema.
341 + * Skips the server-side image type support check when the client
342 + * will handle image processing (generate_sub_sizes is false). Still
343 + * HEIC/HEIF uploads always skip the check, since the browser's canvas
344 + * fallback can decode them even when the server cannot.
329 345 *
330 - * @return array Item schema data.
346 + * @param WP_REST_Request $request Full details about the request.
347 + * @return true|WP_Error True if the request has access to create items, WP_Error object otherwise.
331 348 */
332 - public function get_item_schema() {
333 - $schema = parent::get_item_schema();
349 + public function create_item_permissions_check( $request ) {
350 + $bypass_mime_check = false === $request['generate_sub_sizes'];
334 351
335 - $schema['properties']['exif_orientation'] = array(
336 - 'description' => __( 'EXIF orientation value from the original image. Values 1-8 follow the EXIF specification. A value other than 1 indicates the image needs rotation.', 'gutenberg' ),
337 - 'type' => 'integer',
338 - 'context' => array( 'edit' ),
339 - 'readonly' => true,
340 - );
352 + /*
353 + * Always allow still HEIC/HEIF uploads through even if the server's
354 + * image editor doesn't support them. The client-side canvas fallback
355 + * handles processing using the browser's native HEVC decoder.
356 + *
357 + * The '-sequence' variants (multi-frame Live Photos) are deliberately
358 + * excluded: neither the server nor the browser fallback can process
359 + * them yet, so they should fall through to the standard unsupported
360 + * mime-type error rather than be stored unprocessable.
361 + */
362 + if ( ! $bypass_mime_check ) {
363 + $still_heic_mime_types = array( 'image/heic', 'image/heif' );
364 + $files = $request->get_file_params();
341 365
342 - $schema['properties']['image_output_format'] = array(
343 - 'description' => __( 'The output MIME type this image should be converted to, based on the image_editor_output_format filter. Null if no conversion is needed.', 'gutenberg' ),
344 - 'type' => array( 'string', 'null' ),
345 - 'context' => array( 'edit' ),
346 - 'readonly' => true,
347 - );
348 -
349 - $schema['properties']['image_save_progressive'] = array(
350 - 'description' => __( 'Whether to use progressive/interlaced encoding when saving this image.', 'gutenberg' ),
351 - 'type' => 'boolean',
352 - 'context' => array( 'edit' ),
353 - 'readonly' => true,
354 - );
355 -
356 - // Enumerate the registered sub-sizes so the schema documents exactly which
357 - // keys may appear under "sizes".
358 - $size_quality_properties = array();
359 - foreach ( array_keys( wp_get_registered_image_subsizes() ) as $size_name ) {
360 - $size_quality_properties[ $size_name ] = array(
361 - 'type' => 'integer',
362 - 'minimum' => 1,
363 - 'maximum' => 100,
364 - );
365 - }
366 -
367 - $schema['properties']['image_quality'] = array(
368 - 'description' => __( 'Encode quality (1-100) from the wp_editor_set_quality filter, resolved against the output MIME type. "default" applies to the full-size image; "sizes" lists per-registered-size overrides where the filtered value differs from "default".', 'gutenberg' ),
369 - 'type' => 'object',
370 - 'context' => array( 'edit' ),
371 - 'readonly' => true,
372 - 'properties' => array(
373 - 'default' => array(
374 - 'type' => 'integer',
375 - 'minimum' => 1,
376 - 'maximum' => 100,
377 - ),
378 - 'sizes' => array(
379 - 'type' => 'object',
380 - 'properties' => $size_quality_properties,
381 - ),
382 - ),
383 - );
384 -
385 - return $schema;
386 - }
387 -
388 - /**
389 - * Prepares a single attachment output for response.
390 - *
391 - * Ensures 'missing_image_sizes' is set for PDFs and not just images.
392 - * Adds 'exif_orientation' for images that need client-side rotation.
393 - *
394 - * @param WP_Post $item Attachment object.
395 - * @param WP_REST_Request $request Request object.
396 - * @return WP_REST_Response Response object.
397 - */
398 - public function prepare_item_for_response( $item, $request ): WP_REST_Response {
399 - $response = parent::prepare_item_for_response( $item, $request );
400 -
401 - $data = $response->get_data();
402 -
403 - $fields = $this->get_fields_for_response( $request );
404 -
405 - // Add EXIF orientation for images.
406 - if ( rest_is_field_included( 'exif_orientation', $fields ) ) {
407 - if ( wp_attachment_is_image( $item ) ) {
408 - $metadata = wp_get_attachment_metadata( $item->ID, true );
409 -
410 - // Get the EXIF orientation from the image metadata.
411 - // This is stored by wp_read_image_metadata() during upload.
412 - // Values:
413 - // 0 = undefined (no EXIF data), treat as no rotation needed
414 - // 1 = normal (no rotation needed)
415 - // 2-8 = various rotations/flips needed
416 - $orientation = 1; // Default: no rotation needed.
417 - if (
418 - is_array( $metadata ) &&
419 - isset( $metadata['image_meta']['orientation'] ) &&
420 - (int) $metadata['image_meta']['orientation'] > 0
421 - ) {
422 - $orientation = (int) $metadata['image_meta']['orientation'];
423 - }
424 -
425 - $data['exif_orientation'] = $orientation;
366 + if (
367 + ! empty( $files['file']['type'] ) &&
368 + in_array( $files['file']['type'], $still_heic_mime_types, true )
369 + ) {
370 + $bypass_mime_check = true;
426 371 }
427 372 }
428 373
429 - // Add per-file output format for images.
430 - if ( rest_is_field_included( 'image_output_format', $fields ) ) {
431 - if ( wp_attachment_is_image( $item ) ) {
432 - $mime_type = get_post_mime_type( $item );
433 - $filename = get_attached_file( $item->ID );
434 -
435 - /** This filter is documented in wp-includes/class-wp-image-editor.php */
436 - $output_formats = apply_filters(
437 - 'image_editor_output_format', // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
438 - array( $mime_type => $mime_type ),
439 - $filename ? $filename : '',
440 - $mime_type
441 - );
442 -
443 - $output_mime = $output_formats[ $mime_type ] ?? $mime_type;
444 - $data['image_output_format'] = ( $output_mime !== $mime_type ) ? $output_mime : null;
445 - }
374 + if ( $bypass_mime_check ) {
375 + add_filter( 'wp_prevent_unsupported_mime_type_uploads', '__return_false' );
446 376 }
447 377
448 - // Add progressive/interlaced encoding setting for images.
449 - if ( rest_is_field_included( 'image_save_progressive', $fields ) ) {
450 - if ( wp_attachment_is_image( $item ) ) {
451 - $mime_type = get_post_mime_type( $item );
378 + $result = parent::create_item_permissions_check( $request );
452 379
453 - /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */
454 - $data['image_save_progressive'] = (bool) apply_filters(
455 - 'image_save_progressive', // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
456 - false,
457 - $mime_type
458 - );
459 - }
380 + if ( $bypass_mime_check ) {
381 + remove_filter( 'wp_prevent_unsupported_mime_type_uploads', '__return_false' );
460 382 }
461 383
462 - // Add per-file, size-aware encode quality for images.
463 - if ( rest_is_field_included( 'image_quality', $fields ) ) {
464 - if ( wp_attachment_is_image( $item ) ) {
465 - $mime_type = (string) get_post_mime_type( $item );
466 - $filename = get_attached_file( $item->ID );
467 -
468 - // Resolve the output MIME type the same way core's
469 - // WP_Image_Editor::set_quality() does: quality is filtered
470 - // against the format the file will actually be saved as.
471 - /** This filter is documented in wp-includes/class-wp-image-editor.php */
472 - $output_formats = apply_filters(
473 - 'image_editor_output_format', // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
474 - array( $mime_type => $mime_type ),
475 - $filename ? $filename : '',
476 - $mime_type
477 - );
478 - $output_mime = $output_formats[ $mime_type ] ?? $mime_type;
479 -
480 - $metadata = wp_get_attachment_metadata( $item->ID, true );
481 - $full_width = max( 0, ( is_array( $metadata ) && isset( $metadata['width'] ) ) ? (int) $metadata['width'] : 0 );
482 - $full_height = max( 0, ( is_array( $metadata ) && isset( $metadata['height'] ) ) ? (int) $metadata['height'] : 0 );
483 -
484 - $full_quality = $this->get_image_encode_quality(
485 - $output_mime,
486 - array(
487 - 'width' => $full_width,
488 - 'height' => $full_height,
489 - )
490 - );
491 -
492 - $size_quality = array();
493 - foreach ( wp_get_registered_image_subsizes() as $size_name => $size_data ) {
494 - $quality = $this->get_image_encode_quality(
495 - $output_mime,
496 - array(
497 - 'width' => (int) $size_data['width'],
498 - 'height' => (int) $size_data['height'],
499 - )
500 - );
501 -
502 - // Only report sizes that diverge from the full-size value
503 - // to keep the response payload small.
504 - if ( $quality !== $full_quality ) {
505 - $size_quality[ $size_name ] = $quality;
506 - }
507 - }
508 -
509 - $data['image_quality'] = array(
510 - 'default' => $full_quality,
511 - 'sizes' => $size_quality,
512 - );
513 - }
514 - }
515 -
516 - if (
517 - rest_is_field_included( 'missing_image_sizes', $fields ) &&
518 - empty( $data['missing_image_sizes'] )
519 - ) {
520 - $mime_type = get_post_mime_type( $item );
521 -
522 - if ( 'application/pdf' === $mime_type ) {
523 - $metadata = wp_get_attachment_metadata( $item->ID, true );
524 -
525 - if ( ! is_array( $metadata ) ) {
526 - $metadata = array();
527 - }
528 -
529 - $metadata['sizes'] = $metadata['sizes'] ?? array();
530 -
531 - $fallback_sizes = array(
532 - 'thumbnail',
533 - 'medium',
534 - 'large',
535 - );
536 -
537 - // The filter might have been added by ::create_item().
538 - remove_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 );
539 -
540 - /** This filter is documented in wp-admin/includes/image.php */
541 - $fallback_sizes = apply_filters( 'fallback_intermediate_image_sizes', $fallback_sizes, $metadata ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
542 -
543 - $registered_sizes = wp_get_registered_image_subsizes();
544 - $merged_sizes = array_keys( array_intersect_key( $registered_sizes, array_flip( $fallback_sizes ) ) );
545 -
546 - $missing_image_sizes = array_diff( $merged_sizes, array_keys( $metadata['sizes'] ) );
547 - $data['missing_image_sizes'] = $missing_image_sizes;
548 - }
549 - }
550 -
551 - $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
552 - $data = $this->add_additional_fields_to_object( $data, $request );
553 - $data = $this->filter_response_by_context( $data, $context );
554 -
555 - $links = $response->get_links();
556 -
557 - $response = rest_ensure_response( $data );
558 -
559 - foreach ( $links as $rel => $rel_links ) {
560 - foreach ( $rel_links as $link ) {
561 - $response->add_link( $rel, $link['href'], $link['attributes'] );
562 - }
563 - }
564 -
565 - return $response;
384 + return $result;
566 385 }
567 386
568 387 /**
569 388 * Creates a single attachment.
@@ -577,13 +396,15 @@
577 396 add_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 );
578 397 // Disable server-side EXIF rotation so the client can handle it.
579 398 // This preserves the original orientation value in the metadata.
580 399 add_filter( 'wp_image_maybe_exif_rotate', '__return_false', 100 );
581 - // Disable server-side big image scaling since the client handles it.
582 - add_filter( 'big_image_size_threshold', '__return_zero', 100 );
400 + // Disable server-side "big image" downscaling; the client supplies its
401 + // own scaled version via the sideload endpoint. Scaling here would
402 + // create a conflicting "-scaled" file and orphan the full-size upload.
403 + add_filter( 'big_image_size_threshold', '__return_false', 100 );
583 404 }
584 405
585 - if ( ! $request['convert_format'] ) {
406 + if ( false === $request['convert_format'] ) {
586 407 add_filter( 'image_editor_output_format', '__return_empty_array', 100 );
587 408 }
588 409
589 410 /*
@@ -600,9 +421,9 @@
600 421
601 422 remove_filter( 'intermediate_image_sizes_advanced', '__return_empty_array', 100 );
602 423 remove_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 );
603 424 remove_filter( 'wp_image_maybe_exif_rotate', '__return_false', 100 );
604 - remove_filter( 'big_image_size_threshold', '__return_zero', 100 );
425 + remove_filter( 'big_image_size_threshold', '__return_false', 100 );
605 426 remove_filter( 'image_editor_output_format', '__return_empty_array', 100 );
606 427
607 428 // Recompute image_output_format now that __return_empty_array is removed.
608 429 if ( ! is_wp_error( $response ) ) {
@@ -703,8 +524,16 @@
703 524 'name' => $filename,
704 525 'tmp_name' => $tmp_file,
705 526 );
706 527
528 + $size_check = self::check_upload_size( $file_array );
529 + if ( is_wp_error( $size_check ) ) {
530 + if ( file_exists( $tmp_file ) ) {
531 + wp_delete_file( $tmp_file );
532 + }
533 + return $size_check;
534 + }
535 +
707 536 $attachment_id = media_handle_sideload( $file_array, $post_id );
708 537
709 538 if ( is_wp_error( $attachment_id ) ) {
710 539 /*
@@ -736,144 +565,248 @@
736 565 return $response;
737 566 }
738 567
739 568 /**
740 - * Finalizes an attachment after client-side media processing.
569 + * Prepares a single attachment output for response.
741 570 *
742 - * Triggers the {@see 'wp_generate_attachment_metadata'} filter so that
743 - * server-side plugins can process the attachment after all client-side
744 - * operations (upload, thumbnail generation, sideloads) are complete.
571 + * Ensures 'missing_image_sizes' is set for PDFs and not just images.
572 + * Adds 'exif_orientation' for images that need client-side rotation.
745 573 *
746 - * @param WP_REST_Request $request Full details about the request.
747 - * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
574 + * @param WP_Post $item Attachment object.
575 + * @param WP_REST_Request $request Request object.
576 + * @return WP_REST_Response Response object.
748 577 */
749 - public function finalize_item( WP_REST_Request $request ) {
750 - $attachment_id = $request['id'];
578 + public function prepare_item_for_response( $item, $request ): WP_REST_Response {
579 + $response = parent::prepare_item_for_response( $item, $request );
751 580
752 - $post = $this->get_post( $attachment_id );
581 + $data = $response->get_data();
753 582
754 - if ( is_wp_error( $post ) ) {
755 - return $post;
583 + $fields = $this->get_fields_for_response( $request );
584 +
585 + // Add EXIF orientation for images.
586 + if ( rest_is_field_included( 'exif_orientation', $fields ) ) {
587 + if ( wp_attachment_is_image( $item ) ) {
588 + $metadata = wp_get_attachment_metadata( $item->ID, true );
589 +
590 + // Get the EXIF orientation from the image metadata.
591 + // This is stored by wp_read_image_metadata() during upload.
592 + // Values:
593 + // 0 = undefined (no EXIF data), treat as no rotation needed
594 + // 1 = normal (no rotation needed)
595 + // 2-8 = various rotations/flips needed
596 + $orientation = 1; // Default: no rotation needed.
597 + if (
598 + is_array( $metadata ) &&
599 + isset( $metadata['image_meta']['orientation'] ) &&
600 + (int) $metadata['image_meta']['orientation'] > 0
601 + ) {
602 + $orientation = (int) $metadata['image_meta']['orientation'];
603 + }
604 +
605 + $data['exif_orientation'] = $orientation;
606 + }
756 607 }
757 608
758 - $metadata = wp_get_attachment_metadata( $attachment_id );
609 + // Add per-file output format for images.
610 + if ( rest_is_field_included( 'image_output_format', $fields ) ) {
611 + if ( wp_attachment_is_image( $item ) ) {
612 + $mime_type = get_post_mime_type( $item );
613 + $filename = get_attached_file( $item->ID );
759 614
760 - if ( ! is_array( $metadata ) ) {
761 - $metadata = array();
615 + /** This filter is documented in wp-includes/class-wp-image-editor.php */
616 + $output_formats = apply_filters(
617 + 'image_editor_output_format', // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
618 + array( $mime_type => $mime_type ),
619 + $filename ? $filename : '',
620 + $mime_type
621 + );
622 +
623 + $output_mime = $output_formats[ $mime_type ] ?? $mime_type;
624 + $data['image_output_format'] = ( $output_mime !== $mime_type ) ? $output_mime : null;
625 + }
762 626 }
763 627
764 - // Apply all sub-size metadata collected from sideload responses.
765 - $sub_sizes = $request['sub_sizes'] ?? array();
628 + // Add progressive/interlaced encoding setting for images.
629 + if ( rest_is_field_included( 'image_save_progressive', $fields ) ) {
630 + if ( wp_attachment_is_image( $item ) ) {
631 + $mime_type = get_post_mime_type( $item );
766 632
767 - foreach ( $sub_sizes as $sub_size ) {
768 - $image_size = $sub_size['image_size'];
633 + /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */
634 + $data['image_save_progressive'] = (bool) apply_filters(
635 + 'image_save_progressive', // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
636 + false,
637 + $mime_type
638 + );
639 + }
640 + }
769 641
770 - // When multiple size names share identical dimensions the client
771 - // sends a single sub-size entry with an array of names. Register the
772 - // same file under each name. Arrays only contain regular sizes.
773 - if ( is_array( $image_size ) ) {
774 - $metadata['sizes'] = $metadata['sizes'] ?? array();
642 + // Add per-file, size-aware encode quality for images.
643 + if ( rest_is_field_included( 'image_quality', $fields ) ) {
644 + if ( wp_attachment_is_image( $item ) ) {
645 + $mime_type = (string) get_post_mime_type( $item );
646 + $filename = get_attached_file( $item->ID );
775 647
776 - foreach ( $image_size as $name ) {
777 - $metadata['sizes'][ $name ] = array(
778 - 'width' => $sub_size['width'] ?? 0,
779 - 'height' => $sub_size['height'] ?? 0,
780 - 'file' => $sub_size['file'] ?? '',
781 - 'mime-type' => $sub_size['mime_type'] ?? '',
782 - 'filesize' => $sub_size['filesize'] ?? 0,
648 + // Resolve the output MIME type the same way core's
649 + // WP_Image_Editor::set_quality() does: quality is filtered
650 + // against the format the file will actually be saved as.
651 + /** This filter is documented in wp-includes/class-wp-image-editor.php */
652 + $output_formats = apply_filters(
653 + 'image_editor_output_format', // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
654 + array( $mime_type => $mime_type ),
655 + $filename ? $filename : '',
656 + $mime_type
657 + );
658 + $output_mime = $output_formats[ $mime_type ] ?? $mime_type;
659 +
660 + $metadata = wp_get_attachment_metadata( $item->ID, true );
661 + $full_width = max( 0, ( is_array( $metadata ) && isset( $metadata['width'] ) ) ? (int) $metadata['width'] : 0 );
662 + $full_height = max( 0, ( is_array( $metadata ) && isset( $metadata['height'] ) ) ? (int) $metadata['height'] : 0 );
663 +
664 + $full_quality = $this->get_image_encode_quality(
665 + $output_mime,
666 + array(
667 + 'width' => $full_width,
668 + 'height' => $full_height,
669 + )
670 + );
671 +
672 + $size_quality = array();
673 + foreach ( wp_get_registered_image_subsizes() as $size_name => $size_data ) {
674 + $quality = $this->get_image_encode_quality(
675 + $output_mime,
676 + array(
677 + 'width' => (int) $size_data['width'],
678 + 'height' => (int) $size_data['height'],
679 + )
783 680 );
681 +
682 + // Only report sizes that diverge from the full-size value
683 + // to keep the response payload small.
684 + if ( $quality !== $full_quality ) {
685 + $size_quality[ $size_name ] = $quality;
686 + }
784 687 }
785 - continue;
688 +
689 + $data['image_quality'] = array(
690 + 'default' => $full_quality,
691 + 'sizes' => $size_quality,
692 + );
786 693 }
694 + }
787 695
788 - if ( 'original' === $image_size || 'scaled' === $image_size ) {
789 - // Skip malformed entries so a bad payload cannot blank out the
790 - // main file metadata.
791 - if ( empty( $sub_size['file'] ) ) {
792 - continue;
793 - }
696 + if (
697 + rest_is_field_included( 'missing_image_sizes', $fields ) &&
698 + empty( $data['missing_image_sizes'] )
699 + ) {
700 + $mime_type = get_post_mime_type( $item );
794 701
795 - // Record the supplied full-size image (from sideload_item()) as
796 - // the main file, keeping the current attached file as
797 - // `original_image`. A 'scaled' image is downsized and an
798 - // 'original' image is rotated; both have any EXIF orientation
799 - // already applied by the client.
800 - if ( ! empty( $sub_size['original_image'] ) ) {
801 - $metadata['original_image'] = $sub_size['original_image'];
702 + if ( 'application/pdf' === $mime_type ) {
703 + $metadata = wp_get_attachment_metadata( $item->ID, true );
704 +
705 + if ( ! is_array( $metadata ) ) {
706 + $metadata = array();
802 707 }
803 - $metadata['width'] = $sub_size['width'] ?? 0;
804 - $metadata['height'] = $sub_size['height'] ?? 0;
805 - $metadata['filesize'] = $sub_size['filesize'] ?? 0;
806 - $metadata['file'] = $sub_size['file'];
807 708
808 - // The supplied image has its orientation applied already, so
809 - // reset the stored value (from the upload) to 1, as
810 - // wp_create_image_subsizes() does for both its scale and rotate
811 - // paths. Otherwise exif_orientation would still report the
812 - // pre-rotation value and the client would rotate the image
813 - // again on a re-fetch.
814 - if ( ! empty( $metadata['image_meta']['orientation'] ) ) {
815 - $metadata['image_meta']['orientation'] = 1;
816 - }
817 - } elseif ( self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size ) {
818 - // Source-format original: stored under its own meta key so the
819 - // scaled-sideload flow (which writes 'original_image') cannot
820 - // clobber it. 'original_image' keeps pointing at the
821 - // web-viewable JPEG derivative. Cleanup on attachment delete
822 - // is handled by a delete_attachment hook that reads this key.
823 - $metadata[ self::META_KEY_SOURCE_IMAGE ] = $sub_size['file'];
824 - } elseif ( self::IMAGE_SIZE_ANIMATED_VIDEO === $image_size ) {
825 - // Converted video companion of an animated GIF. Stored
826 - // under its own key; the GIF stays the attachment. The
827 - // editor reads this key to switch the block to a video;
828 - // companion cleanup lives in lib/media/animated-gif-to-video.php.
829 - $metadata[ self::META_KEY_ANIMATED_VIDEO ] = $sub_size['file'];
830 - } elseif ( self::IMAGE_SIZE_ANIMATED_VIDEO_POSTER === $image_size ) {
831 - // Static first-frame poster for the converted video. Used as
832 - // the video block's poster and deleted alongside the video.
833 - // See lib/media/animated-gif-to-video.php.
834 - $metadata[ self::META_KEY_ANIMATED_VIDEO_POSTER ] = $sub_size['file'];
835 - } else {
836 709 $metadata['sizes'] = $metadata['sizes'] ?? array();
837 710
838 - $metadata['sizes'][ $image_size ] = array(
839 - 'width' => $sub_size['width'] ?? 0,
840 - 'height' => $sub_size['height'] ?? 0,
841 - 'file' => $sub_size['file'] ?? '',
842 - 'mime-type' => $sub_size['mime_type'] ?? '',
843 - 'filesize' => $sub_size['filesize'] ?? 0,
711 + $fallback_sizes = array(
712 + 'thumbnail',
713 + 'medium',
714 + 'large',
844 715 );
716 +
717 + // The filter might have been added by ::create_item().
718 + remove_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 );
719 +
720 + /** This filter is documented in wp-admin/includes/image.php */
721 + $fallback_sizes = apply_filters( 'fallback_intermediate_image_sizes', $fallback_sizes, $metadata ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
722 +
723 + $registered_sizes = wp_get_registered_image_subsizes();
724 + $merged_sizes = array_keys( array_intersect_key( $registered_sizes, array_flip( $fallback_sizes ) ) );
725 +
726 + $missing_image_sizes = array_diff( $merged_sizes, array_keys( $metadata['sizes'] ) );
727 + $data['missing_image_sizes'] = $missing_image_sizes;
845 728 }
846 729 }
847 730
848 - /**
849 - * Filters the attachment metadata after client-side processing.
850 - *
851 - * This re-applies the wp_generate_attachment_metadata filter so that
852 - * server-side plugins (e.g. those adding custom image sizes or
853 - * processing metadata) can run after client-side uploads are complete.
854 - *
855 - * @param array $metadata Attachment metadata.
856 - * @param int $attachment_id Attachment ID.
857 - * @param string $context Context: 'create' or 'update'.
858 - */
859 - // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
860 - $metadata = apply_filters( 'wp_generate_attachment_metadata', $metadata, $attachment_id, 'update' );
731 + $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
732 + $data = $this->add_additional_fields_to_object( $data, $request );
733 + $data = $this->filter_response_by_context( $data, $context );
861 734
862 - wp_update_attachment_metadata( $attachment_id, $metadata );
735 + $links = $response->get_links();
863 736
864 - $response_request = new WP_REST_Request(
865 - WP_REST_Server::READABLE,
866 - rest_get_route_for_post( $attachment_id )
737 + $response = rest_ensure_response( $data );
738 +
739 + foreach ( $links as $rel => $rel_links ) {
740 + foreach ( $rel_links as $link ) {
741 + $response->add_link( $rel, $link['href'], $link['attributes'] );
742 + }
743 + }
744 +
745 + return $response;
746 + }
747 +
748 + /**
749 + * Retrieves the attachment's schema, conforming to JSON Schema.
750 + *
751 + * Adds exif_orientation field to the schema.
752 + *
753 + * @return array Item schema data.
754 + */
755 + public function get_item_schema() {
756 + $schema = parent::get_item_schema();
757 +
758 + $schema['properties']['exif_orientation'] = array(
759 + 'description' => __( 'EXIF orientation value from the original image. Values 1-8 follow the EXIF specification. A value other than 1 indicates the image needs rotation.', 'gutenberg' ),
760 + 'type' => 'integer',
761 + 'context' => array( 'edit' ),
762 + 'readonly' => true,
867 763 );
868 764
869 - $response_request['context'] = 'edit';
765 + $schema['properties']['image_output_format'] = array(
766 + 'description' => __( 'The output MIME type this image should be converted to, based on the image_editor_output_format filter. Null if no conversion is needed.', 'gutenberg' ),
767 + 'type' => array( 'string', 'null' ),
768 + 'context' => array( 'edit' ),
769 + 'readonly' => true,
770 + );
870 771
871 - if ( isset( $request['_fields'] ) ) {
872 - $response_request['_fields'] = $request['_fields'];
772 + $schema['properties']['image_save_progressive'] = array(
773 + 'description' => __( 'Whether to use progressive/interlaced encoding when saving this image.', 'gutenberg' ),
774 + 'type' => 'boolean',
775 + 'context' => array( 'edit' ),
776 + 'readonly' => true,
777 + );
778 +
779 + // Enumerate the registered sub-sizes so the schema documents exactly which
780 + // keys may appear under "sizes".
781 + $size_quality_properties = array();
782 + foreach ( array_keys( wp_get_registered_image_subsizes() ) as $size_name ) {
783 + $size_quality_properties[ $size_name ] = array(
784 + 'type' => 'integer',
785 + 'minimum' => 1,
786 + 'maximum' => 100,
787 + );
873 788 }
874 789
875 - return $this->prepare_item_for_response( get_post( $attachment_id ), $response_request );
790 + $schema['properties']['image_quality'] = array(
791 + 'description' => __( 'Encode quality (1-100) from the wp_editor_set_quality filter, resolved against the output MIME type. "default" applies to the full-size image; "sizes" lists per-registered-size overrides where the filtered value differs from "default".', 'gutenberg' ),
792 + 'type' => 'object',
793 + 'context' => array( 'edit' ),
794 + 'readonly' => true,
795 + 'properties' => array(
796 + 'default' => array(
797 + 'type' => 'integer',
798 + 'minimum' => 1,
799 + 'maximum' => 100,
800 + ),
801 + 'sizes' => array(
802 + 'type' => 'object',
803 + 'properties' => $size_quality_properties,
804 + ),
805 + ),
806 + );
807 +
808 + return $schema;
876 809 }
877 810
878 811 /**
879 812 * Checks if a given request has access to sideload a file.
@@ -888,53 +821,94 @@
888 821 return $this->edit_media_item_permissions_check( $request );
889 822 }
890 823
891 824 /**
892 - * Filters {@see 'wp_unique_filename'} during sideloads.
825 + * Validates an image size name, or an array of names sharing a single file.
893 826 *
894 - * {@see wp_unique_filename()} will always add numeric suffix if the name looks like a sub-size to avoid conflicts.
827 + * Shared by the sideload endpoint, which names the size a file is produced
828 + * for, and the finalize endpoint, which names the size each submitted entry
829 + * is stored under. Both need the same set, and finalize accepts a payload of
830 + * its own rather than one this class produced, so leaving it unconstrained
831 + * there would let a submission write an arbitrary key into the metadata
832 + * 'sizes' array or route a file into a branch it was never produced for.
895 833 *
896 - * Adding this closure to the filter helps work around this safeguard.
897 - *
898 - * Example: when uploading myphoto.jpeg, WordPress normally creates myphoto-150x150.jpeg,
899 - * and when uploading myphoto-150x150.jpeg, it will be renamed to myphoto-150x150-1.jpeg
900 - * However, here it is desired not to add the suffix in order to maintain the same
901 - * naming convention as if the file was uploaded regularly.
902 - *
903 - * @link https://github.com/WordPress/wordpress-develop/blob/30954f7ac0840cfdad464928021d7f380940c347/src/wp-includes/functions.php#L2576-L2582
904 - *
905 - * @param string $filename Unique file name.
906 - * @param string $dir Directory path.
907 - * @param int|string $number The highest number that was used to make the file name unique
908 - * or an empty string if unused.
909 - * @param string $attachment_filename Original attachment file name.
910 - * @return string Filtered file name.
834 + * @param mixed $value The image size name, or an array of names.
835 + * @param string $param Parameter name, used in the error messages.
836 + * @return true|WP_Error True when every name is valid, WP_Error otherwise.
911 837 */
912 - private static function filter_wp_unique_filename( $filename, $dir, $number, $attachment_filename ) {
913 - if ( empty( $number ) || ! $attachment_filename ) {
914 - return $filename;
915 - }
838 + private static function validate_image_size_names( $value, string $param ) {
839 + $special_sizes = self::get_special_image_sizes();
840 + $regular_sizes = array_values(
841 + array_diff(
842 + array_merge(
843 + array_keys( wp_get_registered_image_subsizes() ),
844 + // Not a registered sub-size, but stored as an ordinary
845 + // entry in the metadata 'sizes' array (PDF thumbnails).
846 + array( 'full' )
847 + ),
848 + $special_sizes
849 + )
850 + );
916 851
917 - $ext = pathinfo( $filename, PATHINFO_EXTENSION );
918 - $name = pathinfo( $filename, PATHINFO_FILENAME );
919 - $orig_name = pathinfo( $attachment_filename, PATHINFO_FILENAME );
920 -
921 - if ( ! $ext || ! $name ) {
922 - return $filename;
852 + if ( is_string( $value ) ) {
853 + $items = array( $value );
854 + $valid_sizes = array_merge( $regular_sizes, $special_sizes );
855 + } elseif ( is_array( $value ) ) {
856 + /**
857 + * An array registers one sideloaded file under several size names,
858 + * which only makes sense for regular sub-sizes: each special size
859 + * names a single file with its own handling in
860 + * {@see self::sideload_item()} and its own metadata key in
861 + * {@see self::finalize_item()}. Rejecting them here is what lets the
862 + * array branches in both methods treat an array as regular sizes.
863 + */
864 + $items = $value;
865 + $valid_sizes = $regular_sizes;
866 + } else {
867 + return new WP_Error(
868 + 'rest_invalid_type',
869 + /* translators: %s: Parameter name. */
870 + sprintf( __( '%s must be a string or an array of strings.', 'gutenberg' ), $param )
871 + );
923 872 }
924 873
925 - $matches = array();
926 - if ( preg_match( '/(.*)(-\d+x\d+|-scaled)-' . $number . '$/', $name, $matches ) ) {
927 - $filename_without_suffix = $matches[1] . $matches[2] . ".$ext";
928 - if ( $matches[1] === $orig_name ) {
929 - return $filename_without_suffix;
874 + foreach ( $items as $item ) {
875 + if ( ! in_array( $item, $valid_sizes, true ) ) {
876 + return new WP_Error(
877 + 'rest_not_in_enum',
878 + /* translators: %s: Parameter name. */
879 + sprintf( __( '%s contains an invalid image size.', 'gutenberg' ), $param )
880 + );
930 881 }
931 882 }
932 883
933 - return $filename;
884 + return true;
934 885 }
935 886
936 887 /**
888 + * Returns the image size names which name a single file rather than a sub-size.
889 + *
890 + * Each of these is handled on its own in {@see self::sideload_item()} and stored
891 + * under its own key by {@see self::finalize_item()}, so unlike a regular
892 + * sub-size none of them may appear in an array of names sharing one file.
893 + *
894 + * @return string[] Special image size names.
895 + *
896 + * @phpstan-return non-empty-list<non-empty-string>
897 + */
898 + private static function get_special_image_sizes(): array {
899 + return array(
900 + 'original',
901 + 'scaled',
902 + // Source-format original (e.g. the HEIC kept alongside its JPEG derivative).
903 + self::IMAGE_SIZE_SOURCE_ORIGINAL,
904 + // Converted-video companions for an animated GIF (the MP4/WebM and its poster).
905 + self::IMAGE_SIZE_ANIMATED_VIDEO,
906 + self::IMAGE_SIZE_ANIMATED_VIDEO_POSTER,
907 + );
908 + }
909 +
910 + /**
937 911 * Validates that uploaded image dimensions are appropriate for the specified image size.
938 912 *
939 913 * @param int $width Uploaded image width.
940 914 * @param int $height Uploaded image height.
@@ -966,9 +940,10 @@
966 940 array( 'status' => 400 )
967 941 );
968 942 }
969 943
970 - // Arrays only contain regular sub-size names that share dimensions.
944 + // Arrays only contain regular sub-size names that share dimensions, which
945 + // the image_size validation enforces (ref. get_special_image_sizes()).
971 946 // Validate each one against its registered constraints.
972 947 if ( is_array( $image_size ) ) {
973 948 foreach ( $image_size as $name ) {
974 949 $result = $this->validate_image_dimensions( $width, $height, $name, $attachment_id );
@@ -1073,15 +1048,15 @@
1073 1048 return true;
1074 1049 }
1075 1050
1076 1051 /**
1077 - * Side-loads a media file without creating an attachment.
1052 + * Side-loads a media file without creating a new attachment.
1078 1053 *
1079 1054 * @param WP_REST_Request $request Full details about the request.
1080 1055 * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
1081 1056 */
1082 1057 public function sideload_item( WP_REST_Request $request ) {
1083 - $attachment_id = $request['id'];
1058 + $attachment_id = (int) $request['id'];
1084 1059
1085 1060 $post = $this->get_post( $attachment_id );
1086 1061
1087 1062 if ( is_wp_error( $post ) ) {
@@ -1093,14 +1068,34 @@
1093 1068 ! wp_attachment_is( 'pdf', $post )
1094 1069 ) {
1095 1070 return new WP_Error(
1096 1071 'rest_post_invalid_id',
1097 - __( 'Invalid post ID, only images and PDFs can be sideloaded.', 'gutenberg' ),
1072 + __( 'Invalid post ID. Only images and PDFs can be sideloaded.', 'gutenberg' ),
1098 1073 array( 'status' => 400 )
1099 1074 );
1100 1075 }
1101 1076
1102 - if ( ! $request['convert_format'] ) {
1077 + /*
1078 + * Sideloaded files are placed in the same directory as the attachment
1079 + * they extend, because the file names produced here are later resolved
1080 + * against that directory. An attachment stored outside the uploads
1081 + * directory has no such directory to use, so there is nowhere the names
1082 + * this would produce could resolve.
1083 + */
1084 + $attached_file = get_attached_file( $attachment_id, true );
1085 + $subdir = is_string( $attached_file ) && '' !== $attached_file
1086 + ? $this->get_attachment_upload_subdir( $attached_file )
1087 + : null;
1088 +
1089 + if ( ! is_string( $attached_file ) || '' === $attached_file || null === $subdir ) {
1090 + return new WP_Error(
1091 + 'rest_sideload_attachment_not_in_uploads',
1092 + __( 'The attachment is not stored in the uploads directory, so a file cannot be sideloaded for it.', 'gutenberg' ),
1093 + array( 'status' => 403 )
1094 + );
1095 + }
1096 +
1097 + if ( false === $request['convert_format'] ) {
1103 1098 // Prevent image conversion as that is done client-side.
1104 1099 add_filter( 'image_editor_output_format', '__return_empty_array', 100 );
1105 1100 }
1106 1101
@@ -1113,10 +1108,9 @@
1113 1108 * See https://github.com/WordPress/wordpress-develop/blob/30954f7ac0840cfdad464928021d7f380940c347/src/wp-includes/functions.php#L2576-L2582
1114 1109 * With the following filter we can work around this safeguard.
1115 1110 */
1116 1111
1117 - $attachment_filename = get_attached_file( $attachment_id, true );
1118 - $attachment_filename = $attachment_filename ? wp_basename( $attachment_filename ) : null;
1112 + $attachment_filename = wp_basename( $attached_file );
1119 1113
1120 1114 /**
1121 1115 * @param string $filename Unique file name.
1122 1116 * @param string $ext File extension. Example: ".png".
@@ -1132,26 +1126,36 @@
1132 1126 };
1133 1127
1134 1128 add_filter( 'wp_unique_filename', $filter_filename, 10, 6 );
1135 1129
1136 - $parent_post = get_post_parent( $attachment_id );
1130 + // Pin the upload to the attachment's own directory, rather than deriving
1131 + // it from the parent post's date as media_handle_upload() does for a
1132 + // brand new upload. See the note above where $subdir is resolved.
1133 + $filter_upload_dir = static function ( $uploads ) use ( $subdir ) {
1134 + if (
1135 + is_array( $uploads ) &&
1136 + isset( $uploads['basedir'], $uploads['baseurl'] ) &&
1137 + is_string( $uploads['basedir'] ) &&
1138 + is_string( $uploads['baseurl'] )
1139 + ) {
1140 + $uploads['subdir'] = $subdir;
1141 + $uploads['path'] = $uploads['basedir'] . $subdir;
1142 + $uploads['url'] = $uploads['baseurl'] . $subdir;
1143 + }
1144 + return $uploads;
1145 + };
1137 1146
1138 - $time = null;
1147 + add_filter( 'upload_dir', $filter_upload_dir, 100 );
1139 1148
1140 - // Matches logic in media_handle_upload().
1141 - // The post date doesn't usually matter for pages, so don't backdate this upload.
1142 - if ( $parent_post && 'page' !== $parent_post->post_type && substr( $parent_post->post_date, 0, 4 ) > 0 ) {
1143 - $time = $parent_post->post_date;
1144 - }
1145 -
1146 1149 if ( ! empty( $files ) ) {
1147 - $file = $this->upload_from_file( $files, $headers, $time );
1150 + $file = $this->upload_from_file( $files, $headers );
1148 1151 } else {
1149 - $file = $this->upload_from_data( $request->get_body(), $headers, $time );
1152 + $file = $this->upload_from_data( $request->get_body(), $headers );
1150 1153 }
1151 1154
1152 1155 remove_filter( 'wp_unique_filename', $filter_filename );
1153 1156 remove_filter( 'image_editor_output_format', '__return_empty_array', 100 );
1157 + remove_filter( 'upload_dir', $filter_upload_dir, 100 );
1154 1158
1155 1159 if ( is_wp_error( $file ) ) {
1156 1160 return $file;
1157 1161 }
@@ -1204,9 +1208,10 @@
1204 1208 // The client accumulates these and sends them all to the finalize endpoint.
1205 1209 // `image_size` may be a single string or an array of names that share the
1206 1210 // same dimensions and therefore reuse a single sideloaded file. Arrays
1207 1211 // only carry regular sub-sizes; the special keys below ('original',
1208 - // 'scaled', and the source-format original) are always scalar strings.
1212 + // 'scaled', and the source-format original) are always scalar strings,
1213 + // which the image_size validation enforces (ref. get_special_image_sizes()).
1209 1214 $sub_size_data = array(
1210 1215 'image_size' => $image_size,
1211 1216 );
1212 1217
@@ -1240,27 +1245,19 @@
1240 1245 // `original_image`, which is the untouched upload. A 'scaled' image is
1241 1246 // downsized and an 'original' image has any EXIF orientation already
1242 1247 // applied. This is the same swap WordPress makes when it scales or
1243 1248 // rotates an image on upload. See core's _wp_image_meta_replace_original().
1244 - $current_file = get_attached_file( $attachment_id, true );
1249 + $sub_size_data['original_image'] = $attachment_filename;
1245 1250
1246 - if ( ! $current_file ) {
1247 - return new WP_Error(
1248 - 'rest_sideload_no_attached_file',
1249 - __( 'Unable to retrieve the attached file for this attachment.', 'gutenberg' ),
1250 - array( 'status' => 404 )
1251 - );
1252 - }
1253 -
1254 - $sub_size_data['original_image'] = wp_basename( $current_file );
1255 -
1256 1251 // Update the attached file to point to the supplied image.
1257 1252 // This writes to _wp_attached_file meta, not _wp_attachment_metadata.
1258 1253 // Guard against a failed update so a stale original is not recorded.
1259 1254 if (
1260 - get_attached_file( $attachment_id, true ) !== $path &&
1255 + $attached_file !== $path &&
1261 1256 ! update_attached_file( $attachment_id, $path )
1262 1257 ) {
1258 + // Clean up the uploaded file, which nothing references yet.
1259 + wp_delete_file( $path );
1263 1260 return new WP_Error(
1264 1261 'rest_sideload_update_attached_file_failed',
1265 1262 __( 'Unable to update the attached file for this attachment.', 'gutenberg' ),
1266 1263 array( 'status' => 500 )
@@ -1278,12 +1275,491 @@
1278 1275 $sub_size_data['mime_type'] = $type;
1279 1276 $sub_size_data['filesize'] = wp_filesize( $path );
1280 1277 }
1281 1278
1279 + /*
1280 + * Record the file names produced for this attachment so finalize can
1281 + * confirm every stored sub-size was actually sideloaded here. The
1282 + * values recorded are exactly the ones handed back to the client, so
1283 + * finalize accepts a submission only when it echoes what was produced.
1284 + * add_post_meta() appends one row per value, which avoids the
1285 + * read-modify-write race that a single serialized value would create
1286 + * for concurrent sideloads (the same reason metadata is deferred to
1287 + * finalize).
1288 + */
1289 + foreach ( array( 'file', 'original_image' ) as $provenance_key ) {
1290 + if (
1291 + isset( $sub_size_data[ $provenance_key ] ) &&
1292 + is_string( $sub_size_data[ $provenance_key ] ) &&
1293 + '' !== $sub_size_data[ $provenance_key ]
1294 + ) {
1295 + add_post_meta( $attachment_id, self::META_KEY_SIDELOAD_FILE_NAME, wp_slash( $sub_size_data[ $provenance_key ] ) );
1296 + }
1297 + }
1298 +
1282 1299 return rest_ensure_response( $sub_size_data );
1283 1300 }
1284 1301
1285 1302 /**
1303 + * Filters wp_unique_filename during sideloads.
1304 + *
1305 + * wp_unique_filename() will always add numeric suffix if the name looks like a sub-size to avoid conflicts.
1306 + * Adding this closure to the filter helps work around this safeguard.
1307 + *
1308 + * Example: when uploading myphoto.jpeg, WordPress normally creates myphoto-150x150.jpeg,
1309 + * and when uploading myphoto-150x150.jpeg, it will be renamed to myphoto-150x150-1.jpeg
1310 + * However, here it is desired not to add the suffix in order to maintain the same
1311 + * naming convention as if the file was uploaded regularly.
1312 + *
1313 + * The suffix is only dropped when no file of that name already exists in $dir,
1314 + * so this never returns a name that would overwrite one. The unsuffixed name
1315 + * must also derive from the attachment's own file name, and
1316 + * {@see self::sideload_item()} pins the upload to the attachment's own
1317 + * directory, so any name returned here belongs to the attachment being
1318 + * extended.
1319 + *
1320 + * @link https://github.com/WordPress/wordpress-develop/blob/30954f7ac0840cfdad464928021d7f380940c347/src/wp-includes/functions.php#L2576-L2582
1321 + *
1322 + * @param string $filename Unique file name.
1323 + * @param string $dir Directory path.
1324 + * @param int|string $number The highest number that was used to make the file name unique
1325 + * or an empty string if unused.
1326 + * @param string|null $attachment_filename Original attachment file name.
1327 + * @return string Filtered file name.
1328 + */
1329 + private static function filter_wp_unique_filename( $filename, $dir, $number, $attachment_filename ) {
1330 + if ( ! is_int( $number ) || ! $attachment_filename ) {
1331 + return $filename;
1332 + }
1333 +
1334 + $ext = pathinfo( $filename, PATHINFO_EXTENSION );
1335 + $name = pathinfo( $filename, PATHINFO_FILENAME );
1336 + $orig_name = pathinfo( $attachment_filename, PATHINFO_FILENAME );
1337 +
1338 + if ( ! $ext || ! $name ) {
1339 + return $filename;
1340 + }
1341 +
1342 + $matches = array();
1343 + if ( preg_match( '/(.*)-(\d+x\d+|scaled)-' . $number . '$/', $name, $matches ) ) {
1344 + $filename_without_suffix = $matches[1] . '-' . $matches[2] . ".$ext";
1345 + if ( $matches[1] === $orig_name && ! file_exists( "$dir/$filename_without_suffix" ) ) {
1346 + return $filename_without_suffix;
1347 + }
1348 + }
1349 +
1350 + return $filename;
1351 + }
1352 +
1353 + /**
1354 + * Validates the `sub_sizes` file names against what this attachment produced.
1355 + *
1356 + * The {@see self::finalize_item()} method stores the client-supplied `file`
1357 + * and `original_image` values in the attachment metadata, where they are
1358 + * later resolved within the attachment's upload directory and read or deleted
1359 + * (for example by {@see wp_get_original_image_path()}, {@see wp_getimagesize()},
1360 + * and {@see wp_delete_attachment_files()}).
1361 + *
1362 + * Every file the sideload endpoint creates is recorded under
1363 + * {@see self::META_KEY_SIDELOAD_FILE_NAME} as it is produced, using
1364 + * server-generated names. finalize accepts a `file` or `original_image`
1365 + * value only when it matches one of those recorded names (or the
1366 + * attachment's own attached file, which it definitionally owns).
1367 + *
1368 + * @param int $attachment_id The attachment being finalized.
1369 + * @param array $sub_sizes Sub-size metadata collected from sideloads.
1370 + * @return true|WP_Error True if every file name was produced here, WP_Error otherwise.
1371 + *
1372 + * @phpstan-param list<Image_Sub_Size> $sub_sizes
1373 + */
1374 + protected function validate_sub_size_provenance( int $attachment_id, array $sub_sizes ) {
1375 + $allowed = $this->get_sideloaded_file_names( $attachment_id );
1376 +
1377 + foreach ( $sub_sizes as $sub_size ) {
1378 + foreach ( array( 'file', 'original_image' ) as $key ) {
1379 + /*
1380 + * Every value that was sent is checked, no matter how unlikely
1381 + * a name it looks. A loose emptiness test would wave through
1382 + * '0', which is a valid one-character name as far as the schema
1383 + * is concerned and is stored like any other. A value the schema
1384 + * types as a string but which arrives as something else is
1385 + * rejected rather than skipped, so a subclass which widens the
1386 + * schema cannot pass an unchecked value on to the metadata.
1387 + */
1388 + if ( ! isset( $sub_size[ $key ] ) ) {
1389 + continue;
1390 + }
1391 +
1392 + if ( ! is_string( $sub_size[ $key ] ) || ! in_array( $sub_size[ $key ], $allowed, true ) ) {
1393 + return new WP_Error(
1394 + 'rest_invalid_sub_size_file',
1395 + __( 'Invalid sub-size file name. File names must have been produced by a prior sideload for this attachment.', 'gutenberg' ),
1396 + array( 'status' => 400 )
1397 + );
1398 + }
1399 + }
1400 + }
1401 +
1402 + return true;
1403 + }
1404 +
1405 + /**
1406 + * Returns the file names which a finalize request may store for an attachment.
1407 + *
1408 + * The set is the file names the sideload endpoint recorded as it produced
1409 + * them (ref. {@see self::META_KEY_SIDELOAD_FILE_NAME}), plus the attachment's own
1410 + * attached file - accepted in both its uploads-relative and basename form so
1411 + * a scaled main-file pointer validates regardless of which the client
1412 + * echoes - plus the names already stored in the attachment's own metadata.
1413 + *
1414 + * @param int $attachment_id The attachment being finalized.
1415 + * @param bool $include_provenance Whether to include the sideload provenance rows.
1416 + * Pass false to get only the names recoverable from
1417 + * the attached file and stored metadata, e.g. to decide
1418 + * whether a provenance row is still needed. Default true.
1419 + * @return string[] File names that may appear in the finalize submission.
1420 + *
1421 + * @phpstan-return list<string>
1422 + */
1423 + protected function get_sideloaded_file_names( int $attachment_id, bool $include_provenance = true ): array {
1424 + $allowed = array();
1425 +
1426 + if ( $include_provenance ) {
1427 + foreach ( (array) get_post_meta( $attachment_id, self::META_KEY_SIDELOAD_FILE_NAME ) as $name ) {
1428 + if ( is_string( $name ) && '' !== $name ) {
1429 + $allowed[] = $name;
1430 + }
1431 + }
1432 + }
1433 +
1434 + $attached_file = get_post_meta( $attachment_id, '_wp_attached_file', true );
1435 + if ( is_string( $attached_file ) && strlen( $attached_file ) > 0 ) {
1436 + $allowed[] = $attached_file;
1437 + $allowed[] = wp_basename( $attached_file );
1438 + }
1439 +
1440 + /*
1441 + * Names already stored in this attachment's metadata passed this same
1442 + * check when they were written, so accepting them again introduces
1443 + * nothing new.
1444 + */
1445 + $metadata = wp_get_attachment_metadata( $attachment_id, true );
1446 + if ( is_array( $metadata ) ) {
1447 + $stored = array(
1448 + $metadata['file'] ?? null,
1449 + $metadata['original_image'] ?? null,
1450 + $metadata[ self::META_KEY_SOURCE_IMAGE ] ?? null,
1451 + $metadata['animated_video'] ?? null,
1452 + $metadata['animated_video_poster'] ?? null,
1453 + );
1454 +
1455 + if ( ! empty( $metadata['sizes'] ) && is_array( $metadata['sizes'] ) ) {
1456 + foreach ( $metadata['sizes'] as $size ) {
1457 + $stored[] = is_array( $size ) ? ( $size['file'] ?? null ) : null;
1458 + }
1459 + }
1460 +
1461 + foreach ( $stored as $name ) {
1462 + if ( is_string( $name ) && '' !== $name ) {
1463 + $allowed[] = $name;
1464 + $allowed[] = wp_basename( $name );
1465 + }
1466 + }
1467 + }
1468 +
1469 + return array_values( array_unique( $allowed ) );
1470 + }
1471 +
1472 + /**
1473 + * Returns the uploads subdirectory an attachment is stored in.
1474 + *
1475 + * Used to place a sideloaded file alongside the attachment it extends. The
1476 + * result is concatenated into a filesystem path by the caller, so it is
1477 + * returned only when the attachment resolves inside the uploads directory
1478 + * and the stored path is well formed.
1479 + *
1480 + * @param string $attached_file Absolute path to the attached file.
1481 + * @return string|null Subdirectory beginning with a slash, an empty string when the
1482 + * attachment sits in the base directory, or null when the
1483 + * attachment is not inside the uploads directory.
1484 + *
1485 + * @phpstan-param non-empty-string $attached_file
1486 + */
1487 + protected function get_attachment_upload_subdir( string $attached_file ): ?string {
1488 + $uploads = wp_get_upload_dir();
1489 + if ( empty( $uploads['basedir'] ) ) {
1490 + return null;
1491 + }
1492 +
1493 + $basedir = untrailingslashit( wp_normalize_path( $uploads['basedir'] ) );
1494 + $file_dir = wp_normalize_path( dirname( $attached_file ) );
1495 +
1496 + /*
1497 + * The attachment's directory must be the uploads base directory itself
1498 + * or a directory inside it. The trailing slash in the prefix comparison
1499 + * keeps a sibling directory that merely shares the prefix (for example
1500 + * 'uploads-elsewhere' next to 'uploads') from matching.
1501 + */
1502 + if ( $file_dir !== $basedir && ! str_starts_with( $file_dir, trailingslashit( $basedir ) ) ) {
1503 + return null;
1504 + }
1505 +
1506 + $subdir = (string) substr( $file_dir, strlen( $basedir ) );
1507 +
1508 + // A prefix match alone does not rule out a path that climbs back out.
1509 + if ( in_array( '..', explode( '/', $subdir ), true ) ) {
1510 + return null;
1511 + }
1512 +
1513 + return $subdir;
1514 + }
1515 +
1516 + /**
1517 + * Finalizes an attachment after client-side media processing.
1518 + *
1519 + * Applies the sub-size metadata collected from sideload responses in a
1520 + * single metadata update, then triggers the 'wp_generate_attachment_metadata'
1521 + * filter so that server-side plugins can process the attachment after all
1522 + * client-side operations (upload, thumbnail generation, sideloads) are
1523 + * complete.
1524 + *
1525 + * @param WP_REST_Request $request Full details about the request.
1526 + * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
1527 + */
1528 + public function finalize_item( WP_REST_Request $request ) {
1529 + $attachment_id = (int) $request['id'];
1530 +
1531 + $post = $this->get_post( $attachment_id );
1532 + if ( is_wp_error( $post ) ) {
1533 + return $post;
1534 + }
1535 +
1536 + /**
1537 + * Sub-size metadata collected from sideload responses. Confirm every
1538 + * file name was produced by a prior sideload for this attachment before
1539 + * storing it, so a client cannot make finalize record (and later read or
1540 + * delete) another attachment's files.
1541 + *
1542 + * @var list<Image_Sub_Size> $sub_sizes
1543 + */
1544 + $sub_sizes = $request['sub_sizes'] ?? array();
1545 + $provenance = $this->validate_sub_size_provenance( $attachment_id, $sub_sizes );
1546 + if ( is_wp_error( $provenance ) ) {
1547 + return $provenance;
1548 + }
1549 +
1550 + $metadata = wp_get_attachment_metadata( $attachment_id );
1551 + if ( ! is_array( $metadata ) ) {
1552 + $metadata = array();
1553 + }
1554 +
1555 + // Apply all sub-size metadata collected from sideload responses.
1556 + foreach ( $sub_sizes as $sub_size ) {
1557 + $image_size = $sub_size['image_size'];
1558 +
1559 + // When multiple size names share identical dimensions the client
1560 + // sends a single sub-size entry with an array of names. Register the
1561 + // same file under each name.
1562 + if ( is_array( $image_size ) ) {
1563 + /*
1564 + * Arrays carry regular sizes only, as the sideload endpoint
1565 + * enforces. Each special size names a single file handled by one
1566 + * of the branches below, so grouping one under a shared file
1567 + * would write it to the wrong place; reject rather than guess.
1568 + */
1569 + if ( array_intersect( $image_size, self::get_special_image_sizes() ) ) {
1570 + return new WP_Error(
1571 + 'rest_invalid_sub_size_name',
1572 + __( 'A grouped sub-size entry may only name regular image sizes.', 'gutenberg' ),
1573 + array( 'status' => 400 )
1574 + );
1575 + }
1576 +
1577 + // As below: `file` is not required by the schema, and a size
1578 + // entry that names no file is not worth recording.
1579 + if ( empty( $sub_size['file'] ) ) {
1580 + continue;
1581 + }
1582 +
1583 + $metadata['sizes'] = $metadata['sizes'] ?? array();
1584 +
1585 + foreach ( $image_size as $name ) {
1586 + $metadata['sizes'][ $name ] = array(
1587 + 'width' => $sub_size['width'] ?? 0,
1588 + 'height' => $sub_size['height'] ?? 0,
1589 + 'file' => $sub_size['file'],
1590 + 'mime-type' => $sub_size['mime_type'] ?? '',
1591 + 'filesize' => $sub_size['filesize'] ?? 0,
1592 + );
1593 + }
1594 + continue;
1595 + }
1596 +
1597 + if ( 'original' === $image_size || 'scaled' === $image_size ) {
1598 + // Skip malformed entries so a bad payload cannot blank out the
1599 + // main file metadata.
1600 + if ( empty( $sub_size['file'] ) ) {
1601 + continue;
1602 + }
1603 +
1604 + /*
1605 + * Record the supplied full-size image (from sideload_item()) as
1606 + * the main file, keeping the current attached file as
1607 + * `original_image`. A 'scaled' image is downsized and an
1608 + * 'original' image is rotated; both have any EXIF orientation
1609 + * already applied by the client.
1610 + */
1611 + if ( ! empty( $sub_size['original_image'] ) ) {
1612 + $metadata['original_image'] = $sub_size['original_image'];
1613 + }
1614 + $metadata['width'] = $sub_size['width'] ?? 0;
1615 + $metadata['height'] = $sub_size['height'] ?? 0;
1616 + $metadata['filesize'] = $sub_size['filesize'] ?? 0;
1617 + $metadata['file'] = $sub_size['file'];
1618 +
1619 + /*
1620 + * The supplied image has its orientation applied already, so
1621 + * reset the stored value (from the upload) to 1, as
1622 + * wp_create_image_subsizes() does for both its scale and rotate
1623 + * paths. Otherwise exif_orientation would still report the
1624 + * pre-rotation value and the client would rotate the image
1625 + * again on a re-fetch.
1626 + */
1627 + if ( ! empty( $metadata['image_meta']['orientation'] ) ) {
1628 + $metadata['image_meta']['orientation'] = 1;
1629 + }
1630 + } elseif ( self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size ) {
1631 + // As above: `file` is not required by the schema, and each of
1632 + // these sizes is nothing but the file it names.
1633 + if ( empty( $sub_size['file'] ) ) {
1634 + continue;
1635 + }
1636 +
1637 + /*
1638 + * Source-format original: stored under its own meta key so the
1639 + * scaled-sideload flow (which writes 'original_image') cannot
1640 + * clobber it. 'original_image' keeps pointing at the
1641 + * web-viewable JPEG derivative. Cleanup on attachment delete
1642 + * is handled by a delete_attachment hook that reads this key.
1643 + */
1644 + $metadata[ self::META_KEY_SOURCE_IMAGE ] = $sub_size['file'];
1645 + } elseif ( self::IMAGE_SIZE_ANIMATED_VIDEO === $image_size ) {
1646 + if ( empty( $sub_size['file'] ) ) {
1647 + continue;
1648 + }
1649 +
1650 + /*
1651 + * Converted-video companion of an animated GIF. Stored under its
1652 + * own meta key; 'original_image' keeps pointing at the GIF. The
1653 + * editor reads this key to switch the block to a video; companion
1654 + * cleanup lives in lib/media/animated-gif-to-video.php.
1655 + */
1656 + $metadata[ self::META_KEY_ANIMATED_VIDEO ] = $sub_size['file'];
1657 + } elseif ( self::IMAGE_SIZE_ANIMATED_VIDEO_POSTER === $image_size ) {
1658 + if ( empty( $sub_size['file'] ) ) {
1659 + continue;
1660 + }
1661 +
1662 + /*
1663 + * Static first-frame poster for the converted video. Used as the
1664 + * video block's poster and deleted alongside the video. See
1665 + * lib/media/animated-gif-to-video.php.
1666 + */
1667 + $metadata[ self::META_KEY_ANIMATED_VIDEO_POSTER ] = $sub_size['file'];
1668 + } else {
1669 + if ( empty( $sub_size['file'] ) ) {
1670 + continue;
1671 + }
1672 +
1673 + $metadata['sizes'] = $metadata['sizes'] ?? array();
1674 +
1675 + $metadata['sizes'][ $image_size ] = array(
1676 + 'width' => $sub_size['width'] ?? 0,
1677 + 'height' => $sub_size['height'] ?? 0,
1678 + 'file' => $sub_size['file'],
1679 + 'mime-type' => $sub_size['mime_type'] ?? '',
1680 + 'filesize' => $sub_size['filesize'] ?? 0,
1681 + );
1682 + }
1683 + }
1684 +
1685 + /** This filter is documented in wp-admin/includes/image.php */
1686 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
1687 + $metadata = apply_filters( 'wp_generate_attachment_metadata', $metadata, $attachment_id, 'update' );
1688 +
1689 + wp_update_attachment_metadata( $attachment_id, $metadata );
1690 +
1691 + /*
1692 + * Drop only the provenance rows this request consumed, now that the
1693 + * names are recorded in the metadata itself. A row is dropped only once
1694 + * its name is recoverable from the stored metadata, so a name the
1695 + * 'wp_generate_attachment_metadata' filter removed - or that a failed
1696 + * update never persisted - keeps its row and the retried request the
1697 + * endpoint documents as idempotent still validates. Rows for sideloads
1698 + * that have not been finalized yet survive for a later call, and passing
1699 + * the value makes the delete a no-op when the row is already gone, so a
1700 + * retried request cleans up without error. Any rows left behind by an
1701 + * abandoned upload are removed with the attachment itself.
1702 + *
1703 + * Retrying is idempotent for the request as it was sent. A name is only
1704 + * unavailable to a retry once a later finalize has overwritten the same
1705 + * size with a newly sideloaded file, which drops the earlier name from
1706 + * the metadata the retry recovers it from.
1707 + *
1708 + * The names are collected before deleting so a request which repeats
1709 + * the same name across many sub-sizes still issues one query per
1710 + * distinct name.
1711 + */
1712 + $recoverable = $this->get_sideloaded_file_names( $attachment_id, false );
1713 + $consumed = array();
1714 + foreach ( $sub_sizes as $sub_size ) {
1715 + foreach ( array( 'file', 'original_image' ) as $key ) {
1716 + // Matches the set validate_sub_size_provenance() checked, so
1717 + // every name a request was allowed to store is also cleaned up.
1718 + if (
1719 + isset( $sub_size[ $key ] ) &&
1720 + is_string( $sub_size[ $key ] ) &&
1721 + in_array( $sub_size[ $key ], $recoverable, true )
1722 + ) {
1723 + $consumed[] = $sub_size[ $key ];
1724 + }
1725 + }
1726 + }
1727 +
1728 + foreach ( array_unique( $consumed ) as $file_name ) {
1729 + delete_post_meta( $attachment_id, self::META_KEY_SIDELOAD_FILE_NAME, wp_slash( $file_name ) );
1730 + }
1731 +
1732 + $response_request = new WP_REST_Request(
1733 + WP_REST_Server::READABLE,
1734 + rest_get_route_for_post( $attachment_id )
1735 + );
1736 +
1737 + $response_request['context'] = 'edit';
1738 +
1739 + if ( isset( $request['_fields'] ) ) {
1740 + $response_request['_fields'] = $request['_fields'];
1741 + }
1742 +
1743 + /*
1744 + * Re-read the post. The 'wp_generate_attachment_metadata' filter above
1745 + * runs long after $post was fetched, and a callback that rewrites the
1746 + * post row - an optimizer changing post_mime_type once it has
1747 + * converted the file, say - would otherwise be missing from this
1748 + * response. The editor stores the response as its copy of the record
1749 + * rather than reading the attachment again, so a stale row here is
1750 + * what it keeps. A callback that deleted the attachment instead leaves
1751 + * nothing to respond with, so that is reported as the error it is.
1752 + */
1753 + $post = $this->get_post( $attachment_id );
1754 + if ( is_wp_error( $post ) ) {
1755 + return $post;
1756 + }
1757 +
1758 + return $this->prepare_item_for_response( $post, $response_request );
1759 + }
1760 +
1761 + /**
1286 1762 * Resolves the encode quality WordPress would use for an image.
1287 1763 *
1288 1764 * Prefers the core wp_get_image_encode_quality() helper when available, and
1289 1765 * otherwise mirrors WP_Image_Editor::set_quality() inline for WordPress
@@ -1294,10 +1770,10 @@
1294 1770 * wp_get_image_encode_quality() is proposed for WordPress core in
1295 1771 * https://github.com/WordPress/wordpress-develop/pull/11856; until it lands
1296 1772 * the function_exists() guard falls back to the inline implementation below.
1297 1773 *
1298 - * @param non-empty-string $mime_type The output image MIME type, e.g. 'image/jpeg'.
1299 - * @param array{ width?: non-negative-int, height?: non-negative-int } $size Dimensions ('width', 'height') for the wp_editor_set_quality filter.
1774 + * @param non-empty-string $mime_type The output image MIME type, e.g. 'image/jpeg'.
1775 + * @param array{ width?: non-negative-int, height?: non-negative-int } $size Dimensions ('width', 'height') for the wp_editor_set_quality filter.
1300 1776 * @return int<1, 100> Encode quality between 1 and 100.
1301 1777 */
1302 1778 private function get_image_encode_quality( string $mime_type, array $size = array() ): int {
1303 1779 if ( function_exists( 'wp_get_image_encode_quality' ) ) {