PluginProbe
Gutenberg / 23.5.1
Gutenberg v23.5.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.5.1, at lib/media/class-gutenberg-rest-attachments-controller.php

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