PluginProbe
Gutenberg / 23.3.1
Gutenberg v23.3.1
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 12.6.0 7.4.0 All 402 releases
gutenberg / lib / media / class-gutenberg-rest-attachments-controller.php

class-gutenberg-rest-attachments-controller.php in Gutenberg 23.3.1, at lib/media/class-gutenberg-rest-attachments-controller.php

879 lines 31.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Class Gutenberg_REST_Attachments_Controller.
4 *
5 * @package gutenberg
6 */
7
8 /**
9 * REST API controller for media attachments.
10 *
11 * Extends the core attachments controller to add client-side media processing
12 * functionality including sideload support and sub-size generation control.
13 */
14 class Gutenberg_REST_Attachments_Controller extends WP_REST_Attachments_Controller {
15 /**
16 * Registers the routes for attachments.
17 *
18 * @see register_rest_route()
19 */
20 public function register_routes(): void {
21 parent::register_routes();
22
23 register_rest_route(
24 $this->namespace,
25 '/' . $this->rest_base . '/(?P<id>[\d]+)/sideload',
26 array(
27 array(
28 'methods' => WP_REST_Server::CREATABLE,
29 'callback' => array( $this, 'sideload_item' ),
30 'permission_callback' => array( $this, 'sideload_item_permissions_check' ),
31 'args' => array(
32 'id' => array(
33 'description' => __( 'Unique identifier for the attachment.', 'gutenberg' ),
34 'type' => 'integer',
35 ),
36 'image_size' => array(
37 'description' => __( 'Image size. Can be a single size name or an array of size names to register the same file under multiple sizes.', 'gutenberg' ),
38 'type' => array( 'string', 'array' ),
39 'items' => array(
40 'type' => 'string',
41 ),
42 'required' => true,
43 // A custom callback is used instead of the default `rest_validate_request_arg`
44 // because WordPress's `rest_is_array()` treats scalar strings as single-element
45 // lists (via wp_parse_list), so a oneOf with both a string and array schema
46 // matches a plain string twice and validation fails with "matches more than one
47 // of the expected formats". The callback validates the enum per-item using the
48 // current list of registered sizes, which reflects any sizes added after the
49 // route was registered (e.g. via add_image_size() in tests).
50 'validate_callback' => static function ( $value, $request, $param ) {
51 $valid_sizes = array_keys( wp_get_registered_image_subsizes() );
52 $valid_sizes[] = 'original';
53 $valid_sizes[] = 'original-heic';
54 $valid_sizes[] = 'scaled';
55 $valid_sizes[] = 'full';
56
57 $items = is_string( $value ) ? array( $value ) : ( is_array( $value ) ? $value : null );
58 if ( null === $items ) {
59 return new WP_Error(
60 'rest_invalid_type',
61 /* translators: %s: Parameter name. */
62 sprintf( __( '%s must be a string or an array of strings.', 'gutenberg' ), $param )
63 );
64 }
65
66 foreach ( $items as $item ) {
67 if ( ! is_string( $item ) || ! in_array( $item, $valid_sizes, true ) ) {
68 return new WP_Error(
69 'rest_not_in_enum',
70 /* translators: %s: Parameter name. */
71 sprintf( __( '%s contains an invalid image size.', 'gutenberg' ), $param )
72 );
73 }
74 }
75
76 return true;
77 },
78 ),
79 'generate_sub_sizes' => array(
80 'description' => __( 'Whether to generate image sub sizes from the sideloaded file.', 'gutenberg' ),
81 'type' => 'boolean',
82 'default' => false,
83 ),
84 'convert_format' => array(
85 'description' => __( 'Whether to convert image formats.', 'gutenberg' ),
86 'type' => 'boolean',
87 'default' => true,
88 ),
89 ),
90 ),
91 'allow_batch' => $this->allow_batch,
92 'schema' => array( $this, 'get_public_item_schema' ),
93 ),
94 true // Override core's route so 'scaled' is included in the enum.
95 );
96
97 register_rest_route(
98 $this->namespace,
99 '/' . $this->rest_base . '/(?P<id>[\d]+)/finalize',
100 array(
101 array(
102 'methods' => WP_REST_Server::CREATABLE,
103 'callback' => array( $this, 'finalize_item' ),
104 'permission_callback' => array( $this, 'edit_media_item_permissions_check' ),
105 'args' => array(
106 'id' => array(
107 'description' => __( 'Unique identifier for the attachment.', 'gutenberg' ),
108 'type' => 'integer',
109 ),
110 'sub_sizes' => array(
111 'description' => __( 'Array of sub-size metadata collected from sideload responses.', 'gutenberg' ),
112 'type' => 'array',
113 'default' => array(),
114 'items' => array(
115 'type' => 'object',
116 'properties' => array(
117 'image_size' => array(
118 // Uses a multi-type schema instead of `oneOf` because WordPress's
119 // `rest_is_array()` treats scalar strings as single-element lists,
120 // so both a `{type: string}` and `{type: array}` oneOf schema would
121 // match a plain string and trigger a "matches more than one"
122 // validation error.
123 'description' => __( 'Size name, or an array of size names when a single file is registered under multiple sizes with matching dimensions.', 'gutenberg' ),
124 'type' => array( 'string', 'array' ),
125 'items' => array(
126 'type' => 'string',
127 ),
128 'required' => true,
129 ),
130 'width' => array(
131 'type' => 'integer',
132 'minimum' => 1,
133 ),
134 'height' => array(
135 'type' => 'integer',
136 'minimum' => 1,
137 ),
138 'file' => array(
139 'type' => 'string',
140 ),
141 'mime_type' => array(
142 'type' => 'string',
143 'pattern' => '^image/.*',
144 ),
145 'filesize' => array(
146 'type' => 'integer',
147 'minimum' => 1,
148 ),
149 'original_image' => array(
150 'type' => 'string',
151 ),
152 ),
153 ),
154 ),
155 ),
156 ),
157 'allow_batch' => $this->allow_batch,
158 'schema' => array( $this, 'get_public_item_schema' ),
159 )
160 );
161 }
162
163 /**
164 * Checks if a given request has access to create an attachment.
165 *
166 * Skips the server-side image type support check when the client
167 * will handle image processing (generate_sub_sizes is false).
168 *
169 * @param WP_REST_Request $request Full details about the request.
170 * @return true|WP_Error True if the request has access to create items, WP_Error object otherwise.
171 */
172 public function create_item_permissions_check( $request ) {
173 $bypass_mime_check = false === $request['generate_sub_sizes'];
174
175 if ( $bypass_mime_check ) {
176 add_filter( 'wp_prevent_unsupported_mime_type_uploads', '__return_false' );
177 }
178
179 $result = parent::create_item_permissions_check( $request );
180
181 if ( $bypass_mime_check ) {
182 remove_filter( 'wp_prevent_unsupported_mime_type_uploads', '__return_false' );
183 }
184
185 return $result;
186 }
187
188 /**
189 * Retrieves an array of endpoint arguments from the item schema for the controller.
190 *
191 * @param string $method Optional. HTTP method of the request. The arguments for `CREATABLE` requests are
192 * checked for required values and may fall-back to a given default, this is not done
193 * on `EDITABLE` requests. Default WP_REST_Server::CREATABLE.
194 * @return array Endpoint arguments.
195 */
196 public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CREATABLE ) {
197 $args = rest_get_endpoint_args_for_schema( $this->get_item_schema(), $method );
198
199 if ( WP_REST_Server::CREATABLE === $method ) {
200 $args['generate_sub_sizes'] = array(
201 'type' => 'boolean',
202 'default' => true,
203 'description' => __( 'Whether to generate image sub sizes.', 'gutenberg' ),
204 );
205 $args['convert_format'] = array(
206 'type' => 'boolean',
207 'default' => true,
208 'description' => __( 'Whether to convert image formats.', 'gutenberg' ),
209 );
210 }
211
212 return $args;
213 }
214
215 /**
216 * Retrieves the attachment's schema, conforming to JSON Schema.
217 *
218 * Adds exif_orientation field to the schema.
219 *
220 * @return array Item schema data.
221 */
222 public function get_item_schema() {
223 $schema = parent::get_item_schema();
224
225 $schema['properties']['exif_orientation'] = array(
226 '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' ),
227 'type' => 'integer',
228 'context' => array( 'edit' ),
229 'readonly' => true,
230 );
231
232 $schema['properties']['image_output_format'] = array(
233 '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' ),
234 'type' => array( 'string', 'null' ),
235 'context' => array( 'edit' ),
236 'readonly' => true,
237 );
238
239 $schema['properties']['image_save_progressive'] = array(
240 'description' => __( 'Whether to use progressive/interlaced encoding when saving this image.', 'gutenberg' ),
241 'type' => 'boolean',
242 'context' => array( 'edit' ),
243 'readonly' => true,
244 );
245
246 return $schema;
247 }
248
249 /**
250 * Prepares a single attachment output for response.
251 *
252 * Ensures 'missing_image_sizes' is set for PDFs and not just images.
253 * Adds 'exif_orientation' for images that need client-side rotation.
254 *
255 * @param WP_Post $item Attachment object.
256 * @param WP_REST_Request $request Request object.
257 * @return WP_REST_Response Response object.
258 */
259 public function prepare_item_for_response( $item, $request ): WP_REST_Response {
260 $response = parent::prepare_item_for_response( $item, $request );
261
262 $data = $response->get_data();
263
264 $fields = $this->get_fields_for_response( $request );
265
266 // Add EXIF orientation for images.
267 if ( rest_is_field_included( 'exif_orientation', $fields ) ) {
268 if ( wp_attachment_is_image( $item ) ) {
269 $metadata = wp_get_attachment_metadata( $item->ID, true );
270
271 // Get the EXIF orientation from the image metadata.
272 // This is stored by wp_read_image_metadata() during upload.
273 // Values:
274 // 0 = undefined (no EXIF data), treat as no rotation needed
275 // 1 = normal (no rotation needed)
276 // 2-8 = various rotations/flips needed
277 $orientation = 1; // Default: no rotation needed.
278 if (
279 is_array( $metadata ) &&
280 isset( $metadata['image_meta']['orientation'] ) &&
281 (int) $metadata['image_meta']['orientation'] > 0
282 ) {
283 $orientation = (int) $metadata['image_meta']['orientation'];
284 }
285
286 $data['exif_orientation'] = $orientation;
287 }
288 }
289
290 // Add per-file output format for images.
291 if ( rest_is_field_included( 'image_output_format', $fields ) ) {
292 if ( wp_attachment_is_image( $item ) ) {
293 $mime_type = get_post_mime_type( $item );
294 $filename = get_attached_file( $item->ID );
295
296 /** This filter is documented in wp-includes/class-wp-image-editor.php */
297 $output_formats = apply_filters(
298 'image_editor_output_format', // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
299 array( $mime_type => $mime_type ),
300 $filename ? $filename : '',
301 $mime_type
302 );
303
304 $output_mime = $output_formats[ $mime_type ] ?? $mime_type;
305 $data['image_output_format'] = ( $output_mime !== $mime_type ) ? $output_mime : null;
306 }
307 }
308
309 // Add progressive/interlaced encoding setting for images.
310 if ( rest_is_field_included( 'image_save_progressive', $fields ) ) {
311 if ( wp_attachment_is_image( $item ) ) {
312 $mime_type = get_post_mime_type( $item );
313
314 /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */
315 $data['image_save_progressive'] = (bool) apply_filters(
316 'image_save_progressive', // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
317 false,
318 $mime_type
319 );
320 }
321 }
322
323 if (
324 rest_is_field_included( 'missing_image_sizes', $fields ) &&
325 empty( $data['missing_image_sizes'] )
326 ) {
327 $mime_type = get_post_mime_type( $item );
328
329 if ( 'application/pdf' === $mime_type ) {
330 $metadata = wp_get_attachment_metadata( $item->ID, true );
331
332 if ( ! is_array( $metadata ) ) {
333 $metadata = array();
334 }
335
336 $metadata['sizes'] = $metadata['sizes'] ?? array();
337
338 $fallback_sizes = array(
339 'thumbnail',
340 'medium',
341 'large',
342 );
343
344 // The filter might have been added by ::create_item().
345 remove_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 );
346
347 /** This filter is documented in wp-admin/includes/image.php */
348 $fallback_sizes = apply_filters( 'fallback_intermediate_image_sizes', $fallback_sizes, $metadata ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
349
350 $registered_sizes = wp_get_registered_image_subsizes();
351 $merged_sizes = array_keys( array_intersect_key( $registered_sizes, array_flip( $fallback_sizes ) ) );
352
353 $missing_image_sizes = array_diff( $merged_sizes, array_keys( $metadata['sizes'] ) );
354 $data['missing_image_sizes'] = $missing_image_sizes;
355 }
356 }
357
358 $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
359 $data = $this->add_additional_fields_to_object( $data, $request );
360 $data = $this->filter_response_by_context( $data, $context );
361
362 $links = $response->get_links();
363
364 $response = rest_ensure_response( $data );
365
366 foreach ( $links as $rel => $rel_links ) {
367 foreach ( $rel_links as $link ) {
368 $response->add_link( $rel, $link['href'], $link['attributes'] );
369 }
370 }
371
372 return $response;
373 }
374
375 /**
376 * Creates a single attachment.
377 *
378 * @param WP_REST_Request $request Full details about the request.
379 * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
380 */
381 public function create_item( $request ) {
382 if ( ! $request['generate_sub_sizes'] ) {
383 add_filter( 'intermediate_image_sizes_advanced', '__return_empty_array', 100 );
384 add_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 );
385 // Disable server-side EXIF rotation so the client can handle it.
386 // This preserves the original orientation value in the metadata.
387 add_filter( 'wp_image_maybe_exif_rotate', '__return_false', 100 );
388 // Disable server-side big image scaling since the client handles it.
389 add_filter( 'big_image_size_threshold', '__return_zero', 100 );
390 }
391
392 if ( ! $request['convert_format'] ) {
393 add_filter( 'image_editor_output_format', '__return_empty_array', 100 );
394 }
395
396 $response = parent::create_item( $request );
397
398 remove_filter( 'intermediate_image_sizes_advanced', '__return_empty_array', 100 );
399 remove_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 );
400 remove_filter( 'wp_image_maybe_exif_rotate', '__return_false', 100 );
401 remove_filter( 'big_image_size_threshold', '__return_zero', 100 );
402 remove_filter( 'image_editor_output_format', '__return_empty_array', 100 );
403
404 // Recompute image_output_format now that __return_empty_array is removed.
405 if ( ! is_wp_error( $response ) ) {
406 $data = $response->get_data();
407 if ( ! empty( $data['id'] ) && wp_attachment_is_image( $data['id'] ) ) {
408 $mime_type = get_post_mime_type( $data['id'] );
409 $filename = get_attached_file( $data['id'] );
410
411 /** This filter is documented in wp-includes/class-wp-image-editor.php */
412 $output_formats = apply_filters(
413 'image_editor_output_format', // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
414 array( $mime_type => $mime_type ),
415 $filename ? $filename : '',
416 $mime_type
417 );
418
419 $output_mime = $output_formats[ $mime_type ] ?? $mime_type;
420 $data['image_output_format'] = ( $output_mime !== $mime_type ) ? $output_mime : null;
421
422 /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */
423 $data['image_save_progressive'] = (bool) apply_filters(
424 'image_save_progressive', // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
425 false,
426 $mime_type
427 );
428
429 $response->set_data( $data );
430 }
431 }
432
433 return $response;
434 }
435
436 /**
437 * Finalizes an attachment after client-side media processing.
438 *
439 * Triggers the {@see 'wp_generate_attachment_metadata'} filter so that
440 * server-side plugins can process the attachment after all client-side
441 * operations (upload, thumbnail generation, sideloads) are complete.
442 *
443 * @param WP_REST_Request $request Full details about the request.
444 * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
445 */
446 public function finalize_item( WP_REST_Request $request ) {
447 $attachment_id = $request['id'];
448
449 $post = $this->get_post( $attachment_id );
450
451 if ( is_wp_error( $post ) ) {
452 return $post;
453 }
454
455 $metadata = wp_get_attachment_metadata( $attachment_id );
456
457 if ( ! is_array( $metadata ) ) {
458 $metadata = array();
459 }
460
461 // Apply all sub-size metadata collected from sideload responses.
462 $sub_sizes = $request['sub_sizes'] ?? array();
463
464 foreach ( $sub_sizes as $sub_size ) {
465 $image_size = $sub_size['image_size'];
466
467 // When multiple size names share identical dimensions the client
468 // sends a single sub-size entry with an array of names. Register the
469 // same file under each name. Arrays only contain regular sizes.
470 if ( is_array( $image_size ) ) {
471 $metadata['sizes'] = $metadata['sizes'] ?? array();
472
473 foreach ( $image_size as $name ) {
474 $metadata['sizes'][ $name ] = array(
475 'width' => $sub_size['width'] ?? 0,
476 'height' => $sub_size['height'] ?? 0,
477 'file' => $sub_size['file'] ?? '',
478 'mime-type' => $sub_size['mime_type'] ?? '',
479 'filesize' => $sub_size['filesize'] ?? 0,
480 );
481 }
482 continue;
483 }
484
485 if ( 'original' === $image_size ) {
486 $metadata['original_image'] = $sub_size['file'];
487 } elseif ( 'original-heic' === $image_size ) {
488 // HEIC companion original: stored under its own meta key so
489 // the scaled-sideload flow (which writes 'original_image')
490 // cannot clobber it. 'original_image' keeps pointing at the
491 // web-viewable JPEG derivative. Cleanup on attachment delete
492 // is handled by a delete_attachment hook that reads this key.
493 $metadata['original'] = $sub_size['file'];
494 } elseif ( 'scaled' === $image_size ) {
495 if ( ! empty( $sub_size['original_image'] ) ) {
496 $metadata['original_image'] = $sub_size['original_image'];
497 }
498 $metadata['width'] = $sub_size['width'] ?? 0;
499 $metadata['height'] = $sub_size['height'] ?? 0;
500 $metadata['filesize'] = $sub_size['filesize'] ?? 0;
501 $metadata['file'] = $sub_size['file'] ?? '';
502 } else {
503 $metadata['sizes'] = $metadata['sizes'] ?? array();
504
505 $metadata['sizes'][ $image_size ] = array(
506 'width' => $sub_size['width'] ?? 0,
507 'height' => $sub_size['height'] ?? 0,
508 'file' => $sub_size['file'] ?? '',
509 'mime-type' => $sub_size['mime_type'] ?? '',
510 'filesize' => $sub_size['filesize'] ?? 0,
511 );
512 }
513 }
514
515 /**
516 * Filters the attachment metadata after client-side processing.
517 *
518 * This re-applies the wp_generate_attachment_metadata filter so that
519 * server-side plugins (e.g. those adding custom image sizes or
520 * processing metadata) can run after client-side uploads are complete.
521 *
522 * @param array $metadata Attachment metadata.
523 * @param int $attachment_id Attachment ID.
524 * @param string $context Context: 'create' or 'update'.
525 */
526 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
527 $metadata = apply_filters( 'wp_generate_attachment_metadata', $metadata, $attachment_id, 'update' );
528
529 wp_update_attachment_metadata( $attachment_id, $metadata );
530
531 $response_request = new WP_REST_Request(
532 WP_REST_Server::READABLE,
533 rest_get_route_for_post( $attachment_id )
534 );
535
536 $response_request['context'] = 'edit';
537
538 if ( isset( $request['_fields'] ) ) {
539 $response_request['_fields'] = $request['_fields'];
540 }
541
542 return $this->prepare_item_for_response( get_post( $attachment_id ), $response_request );
543 }
544
545 /**
546 * Checks if a given request has access to sideload a file.
547 *
548 * Sideloading a file for an existing attachment
549 * requires both update and create permissions.
550 *
551 * @param WP_REST_Request $request Full details about the request.
552 * @return true|WP_Error True if the request has access to update the item, WP_Error object otherwise.
553 */
554 public function sideload_item_permissions_check( $request ) {
555 return $this->edit_media_item_permissions_check( $request );
556 }
557
558 /**
559 * Filters {@see 'wp_unique_filename'} during sideloads.
560 *
561 * {@see wp_unique_filename()} will always add numeric suffix if the name looks like a sub-size to avoid conflicts.
562 *
563 * Adding this closure to the filter helps work around this safeguard.
564 *
565 * Example: when uploading myphoto.jpeg, WordPress normally creates myphoto-150x150.jpeg,
566 * and when uploading myphoto-150x150.jpeg, it will be renamed to myphoto-150x150-1.jpeg
567 * However, here it is desired not to add the suffix in order to maintain the same
568 * naming convention as if the file was uploaded regularly.
569 *
570 * @link https://github.com/WordPress/wordpress-develop/blob/30954f7ac0840cfdad464928021d7f380940c347/src/wp-includes/functions.php#L2576-L2582
571 *
572 * @param string $filename Unique file name.
573 * @param string $dir Directory path.
574 * @param int|string $number The highest number that was used to make the file name unique
575 * or an empty string if unused.
576 * @param string $attachment_filename Original attachment file name.
577 * @return string Filtered file name.
578 */
579 private static function filter_wp_unique_filename( $filename, $dir, $number, $attachment_filename ) {
580 if ( empty( $number ) || ! $attachment_filename ) {
581 return $filename;
582 }
583
584 $ext = pathinfo( $filename, PATHINFO_EXTENSION );
585 $name = pathinfo( $filename, PATHINFO_FILENAME );
586 $orig_name = pathinfo( $attachment_filename, PATHINFO_FILENAME );
587
588 if ( ! $ext || ! $name ) {
589 return $filename;
590 }
591
592 $matches = array();
593 if ( preg_match( '/(.*)(-\d+x\d+|-scaled)-' . $number . '$/', $name, $matches ) ) {
594 $filename_without_suffix = $matches[1] . $matches[2] . ".$ext";
595 if ( $matches[1] === $orig_name ) {
596 return $filename_without_suffix;
597 }
598 }
599
600 return $filename;
601 }
602
603 /**
604 * Validates that uploaded image dimensions are appropriate for the specified image size.
605 *
606 * @param int $width Uploaded image width.
607 * @param int $height Uploaded image height.
608 * @param string|array $image_size The target image size name, or an array
609 * of names that share the same dimensions.
610 * @param int $attachment_id The attachment ID.
611 * @return true|WP_Error True if valid, WP_Error if invalid.
612 */
613 private function validate_image_dimensions( int $width, int $height, $image_size, int $attachment_id ) {
614 // Dimensions must be positive for all sizes.
615 if ( $width <= 0 || $height <= 0 ) {
616 return new WP_Error(
617 'rest_upload_invalid_dimensions',
618 __( 'Uploaded image must have positive dimensions.', 'gutenberg' ),
619 array( 'status' => 400 )
620 );
621 }
622
623 // Arrays only contain regular sub-size names that share dimensions.
624 // Validate each one against its registered constraints.
625 if ( is_array( $image_size ) ) {
626 foreach ( $image_size as $name ) {
627 $result = $this->validate_image_dimensions( $width, $height, $name, $attachment_id );
628 if ( is_wp_error( $result ) ) {
629 return $result;
630 }
631 }
632 return true;
633 }
634
635 // 'original-heic' companion file: no dimension constraint.
636 if ( 'original-heic' === $image_size ) {
637 return true;
638 }
639
640 // 'original' size: should match original attachment dimensions.
641 if ( 'original' === $image_size ) {
642 $metadata = wp_get_attachment_metadata( $attachment_id, true );
643 if ( is_array( $metadata ) && isset( $metadata['width'], $metadata['height'] ) ) {
644 $expected_width = (int) $metadata['width'];
645 $expected_height = (int) $metadata['height'];
646
647 if ( $width !== $expected_width || $height !== $expected_height ) {
648 return new WP_Error(
649 'rest_upload_dimension_mismatch',
650 sprintf(
651 /* translators: 1: actual width, 2: actual height, 3: expected width, 4: expected height */
652 __( 'Uploaded image dimensions (%1$dx%2$d) do not match original image dimensions (%3$dx%4$d).', 'gutenberg' ),
653 $width,
654 $height,
655 $expected_width,
656 $expected_height
657 ),
658 array( 'status' => 400 )
659 );
660 }
661 }
662 return true;
663 }
664
665 // 'full' size (PDF thumbnails) and 'scaled': no further constraints.
666 if ( 'full' === $image_size || 'scaled' === $image_size ) {
667 return true;
668 }
669
670 // Regular image sizes: validate against registered size constraints.
671 $registered_sizes = wp_get_registered_image_subsizes();
672
673 if ( ! isset( $registered_sizes[ $image_size ] ) ) {
674 return new WP_Error(
675 'rest_upload_unknown_size',
676 __( 'Unknown image size.', 'gutenberg' ),
677 array( 'status' => 400 )
678 );
679 }
680
681 $size_data = $registered_sizes[ $image_size ];
682 $max_width = (int) $size_data['width'];
683 $max_height = (int) $size_data['height'];
684
685 // Validate dimensions don't exceed the registered size maximums.
686 // Allow 1px tolerance for rounding differences.
687 $tolerance = 1;
688
689 if ( $max_width > 0 && $width > $max_width + $tolerance ) {
690 return new WP_Error(
691 'rest_upload_dimension_mismatch',
692 sprintf(
693 /* translators: 1: image size name, 2: max width, 3: actual width */
694 __( 'Uploaded image width (%3$d) exceeds maximum for "%1$s" size (%2$d).', 'gutenberg' ),
695 $image_size,
696 $max_width,
697 $width
698 ),
699 array( 'status' => 400 )
700 );
701 }
702
703 if ( $max_height > 0 && $height > $max_height + $tolerance ) {
704 return new WP_Error(
705 'rest_upload_dimension_mismatch',
706 sprintf(
707 /* translators: 1: image size name, 2: max height, 3: actual height */
708 __( 'Uploaded image height (%3$d) exceeds maximum for "%1$s" size (%2$d).', 'gutenberg' ),
709 $image_size,
710 $max_height,
711 $height
712 ),
713 array( 'status' => 400 )
714 );
715 }
716
717 return true;
718 }
719
720 /**
721 * Side-loads a media file without creating an attachment.
722 *
723 * @param WP_REST_Request $request Full details about the request.
724 * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
725 */
726 public function sideload_item( WP_REST_Request $request ) {
727 $attachment_id = $request['id'];
728
729 $post = $this->get_post( $attachment_id );
730
731 if ( is_wp_error( $post ) ) {
732 return $post;
733 }
734
735 if (
736 ! wp_attachment_is_image( $post ) &&
737 ! wp_attachment_is( 'pdf', $post )
738 ) {
739 return new WP_Error(
740 'rest_post_invalid_id',
741 __( 'Invalid post ID, only images and PDFs can be sideloaded.', 'gutenberg' ),
742 array( 'status' => 400 )
743 );
744 }
745
746 if ( ! $request['convert_format'] ) {
747 // Prevent image conversion as that is done client-side.
748 add_filter( 'image_editor_output_format', '__return_empty_array', 100 );
749 }
750
751 // Get the file via $_FILES or raw data.
752 $files = $request->get_file_params();
753 $headers = $request->get_headers();
754
755 /*
756 * wp_unique_filename() will always add numeric suffix if the name looks like a sub-size to avoid conflicts.
757 * See https://github.com/WordPress/wordpress-develop/blob/30954f7ac0840cfdad464928021d7f380940c347/src/wp-includes/functions.php#L2576-L2582
758 * With the following filter we can work around this safeguard.
759 */
760
761 $attachment_filename = get_attached_file( $attachment_id, true );
762 $attachment_filename = $attachment_filename ? wp_basename( $attachment_filename ) : null;
763
764 /**
765 * @param string $filename Unique file name.
766 * @param string $ext File extension. Example: ".png".
767 * @param string $dir Directory path.
768 * @param callable|null $unique_filename_callback Callback function that generates the unique file name.
769 * @param string[] $alt_filenames Array of alternate file names that were checked for collisions.
770 * @param int|string $number The highest number that was used to make the file name unique
771 * or an empty string if unused.
772 * @return string Filtered file name.
773 */
774 $filter_filename = static function ( $filename, $ext, $dir, $unique_filename_callback, $alt_filenames, $number ) use ( $attachment_filename ) {
775 return self::filter_wp_unique_filename( $filename, $dir, $number, $attachment_filename );
776 };
777
778 add_filter( 'wp_unique_filename', $filter_filename, 10, 6 );
779
780 $parent_post = get_post_parent( $attachment_id );
781
782 $time = null;
783
784 // Matches logic in media_handle_upload().
785 // The post date doesn't usually matter for pages, so don't backdate this upload.
786 if ( $parent_post && 'page' !== $parent_post->post_type && substr( $parent_post->post_date, 0, 4 ) > 0 ) {
787 $time = $parent_post->post_date;
788 }
789
790 if ( ! empty( $files ) ) {
791 $file = $this->upload_from_file( $files, $headers, $time );
792 } else {
793 $file = $this->upload_from_data( $request->get_body(), $headers, $time );
794 }
795
796 remove_filter( 'wp_unique_filename', $filter_filename );
797 remove_filter( 'image_editor_output_format', '__return_empty_array', 100 );
798
799 if ( is_wp_error( $file ) ) {
800 return $file;
801 }
802
803 $type = $file['type'];
804 $path = $file['file'];
805
806 $image_size = $request['image_size'];
807
808 // Read dimensions once up-front. Needed both for early-error handling
809 // (corrupted/unsupported files) and for populating the sub-size payload
810 // below. Scalar 'original' is a byte-only passthrough and does not need
811 // dimensions, but reading them here is harmless.
812 $size = wp_getimagesize( $path );
813
814 if ( ! $size ) {
815 // Could not determine dimensions (corrupted file, unsupported format).
816 wp_delete_file( $path );
817 return new WP_Error(
818 'rest_upload_invalid_image',
819 __( 'Could not read image dimensions. The file may be corrupted or an unsupported format.', 'gutenberg' ),
820 array( 'status' => 400 )
821 );
822 }
823
824 $validation = $this->validate_image_dimensions( $size[0], $size[1], $image_size, $attachment_id );
825 if ( is_wp_error( $validation ) ) {
826 // Clean up the uploaded file.
827 wp_delete_file( $path );
828 return $validation;
829 }
830
831 // Build sub-size data to return to the client.
832 // The client accumulates these and sends them all to the finalize endpoint.
833 // `image_size` may be a single string or an array of names that share the
834 // same dimensions and therefore reuse a single sideloaded file. Arrays
835 // only carry regular sub-sizes; the special keys below ('original',
836 // 'scaled', 'original-heic') are always scalar strings.
837 $sub_size_data = array(
838 'image_size' => $image_size,
839 );
840
841 if ( is_array( $image_size ) ) {
842 $sub_size_data['width'] = $size[0];
843 $sub_size_data['height'] = $size[1];
844 $sub_size_data['file'] = wp_basename( $path );
845 $sub_size_data['mime_type'] = $type;
846 $sub_size_data['filesize'] = wp_filesize( $path );
847 } elseif ( 'original' === $image_size ) {
848 $sub_size_data['file'] = wp_basename( $path );
849 } elseif ( 'original-heic' === $image_size ) {
850 // HEIC companion original. finalize_item() writes the filename to
851 // $metadata['original'] (separate from 'original_image', which the
852 // scaled-sideload flow owns). Cleanup on attachment delete is
853 // handled by a delete_attachment hook that reads this key.
854 $sub_size_data['file'] = wp_basename( $path );
855 } elseif ( 'scaled' === $image_size ) {
856 // Record the current attached file as the original.
857 $current_file = get_attached_file( $attachment_id, true );
858 $sub_size_data['original_image'] = wp_basename( $current_file );
859
860 // Update the attached file to point to the scaled version.
861 // This writes to _wp_attached_file meta, not _wp_attachment_metadata.
862 update_attached_file( $attachment_id, $path );
863
864 $sub_size_data['width'] = $size[0];
865 $sub_size_data['height'] = $size[1];
866 $sub_size_data['filesize'] = wp_filesize( $path );
867 $sub_size_data['file'] = _wp_relative_upload_path( $path );
868 } else {
869 $sub_size_data['width'] = $size[0];
870 $sub_size_data['height'] = $size[1];
871 $sub_size_data['file'] = wp_basename( $path );
872 $sub_size_data['mime_type'] = $type;
873 $sub_size_data['filesize'] = wp_filesize( $path );
874 }
875
876 return rest_ensure_response( $sub_size_data );
877 }
878 }
879