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

1,351 lines 50.0 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 = 'source_original';
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 * Image size token for the video transcoded from an animated GIF, sideloaded
38 * as a companion of the GIF attachment.
39 *
40 * Paired with META_KEY_ANIMATED_VIDEO: used both in the `/sideload` route
41 * and when writing the sideloaded file to its metadata key. Both use the
42 * underscore convention so the size token and meta key stay consistent.
43 *
44 * @var string
45 */
46 const IMAGE_SIZE_ANIMATED_VIDEO = 'animated_video';
47
48 /**
49 * Image size token for the static first-frame poster of a converted GIF.
50 *
51 * @var string
52 */
53 const IMAGE_SIZE_ANIMATED_VIDEO_POSTER = 'animated_video_poster';
54
55 /**
56 * Metadata key holding the basename of the converted animated-GIF video.
57 *
58 * @var string
59 */
60 const META_KEY_ANIMATED_VIDEO = 'animated_video';
61
62 /**
63 * Metadata key holding the basename of the converted GIF's poster image.
64 *
65 * @var string
66 */
67 const META_KEY_ANIMATED_VIDEO_POSTER = 'animated_video_poster';
68
69 /**
70 * Registers the routes for attachments.
71 *
72 * @see register_rest_route()
73 */
74 public function register_routes(): void {
75 parent::register_routes();
76
77 register_rest_route(
78 $this->namespace,
79 '/' . $this->rest_base . '/(?P<id>[\d]+)/sideload',
80 array(
81 array(
82 'methods' => WP_REST_Server::CREATABLE,
83 'callback' => array( $this, 'sideload_item' ),
84 'permission_callback' => array( $this, 'sideload_item_permissions_check' ),
85 'args' => array(
86 'id' => array(
87 'description' => __( 'Unique identifier for the attachment.', 'gutenberg' ),
88 'type' => 'integer',
89 ),
90 'image_size' => array(
91 '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 'type' => array( 'string', 'array' ),
93 'items' => array(
94 'type' => 'string',
95 ),
96 'required' => true,
97 // A custom callback is used instead of the default `rest_validate_request_arg`
98 // because WordPress's `rest_is_array()` treats scalar strings as single-element
99 // lists (via wp_parse_list), so a oneOf with both a string and array schema
100 // matches a plain string twice and validation fails with "matches more than one
101 // of the expected formats". The callback validates the enum per-item using the
102 // current list of registered sizes, which reflects any sizes added after the
103 // 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 );
120 }
121
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;
133 },
134 ),
135 'convert_format' => array(
136 'description' => __( 'Whether to convert image formats.', 'gutenberg' ),
137 'type' => 'boolean',
138 'default' => true,
139 ),
140 ),
141 ),
142 'allow_batch' => $this->allow_batch,
143 'schema' => array( $this, 'get_public_item_schema' ),
144 ),
145 true // Override core's route so 'scaled' is included in the enum.
146 );
147
148 register_rest_route(
149 $this->namespace,
150 '/' . $this->rest_base . '/(?P<id>[\d]+)/finalize',
151 array(
152 array(
153 'methods' => WP_REST_Server::CREATABLE,
154 'callback' => array( $this, 'finalize_item' ),
155 'permission_callback' => array( $this, 'edit_media_item_permissions_check' ),
156 'args' => array(
157 'id' => array(
158 'description' => __( 'Unique identifier for the attachment.', 'gutenberg' ),
159 'type' => 'integer',
160 ),
161 'sub_sizes' => array(
162 'description' => __( 'Array of sub-size metadata collected from sideload responses.', 'gutenberg' ),
163 'type' => 'array',
164 'default' => array(),
165 'items' => array(
166 'type' => 'object',
167 'properties' => array(
168 'image_size' => array(
169 // Uses a multi-type schema instead of `oneOf` because WordPress's
170 // `rest_is_array()` treats scalar strings as single-element lists,
171 // so both a `{type: string}` and `{type: array}` oneOf schema would
172 // match a plain string and trigger a "matches more than one"
173 // validation error.
174 'description' => __( 'Size name, or an array of size names when a single file is registered under multiple sizes with matching dimensions.', 'gutenberg' ),
175 'type' => array( 'string', 'array' ),
176 'items' => array(
177 'type' => 'string',
178 ),
179 'required' => true,
180 ),
181 'width' => array(
182 'type' => 'integer',
183 'minimum' => 1,
184 ),
185 'height' => array(
186 'type' => 'integer',
187 'minimum' => 1,
188 ),
189 'file' => array(
190 'type' => 'string',
191 'minLength' => 1,
192 ),
193 'mime_type' => array(
194 'type' => 'string',
195 'pattern' => '^image/.*',
196 ),
197 'filesize' => array(
198 'type' => 'integer',
199 'minimum' => 1,
200 ),
201 'original_image' => array(
202 'type' => 'string',
203 'minLength' => 1,
204 ),
205 ),
206 ),
207 ),
208 ),
209 ),
210 'allow_batch' => $this->allow_batch,
211 'schema' => array( $this, 'get_public_item_schema' ),
212 )
213 );
214 }
215
216 /**
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 * Retrieves an array of endpoint arguments from the item schema for the controller.
267 *
268 * @param string $method Optional. HTTP method of the request. The arguments for `CREATABLE` requests are
269 * checked for required values and may fall-back to a given default, this is not done
270 * on `EDITABLE` requests. Default WP_REST_Server::CREATABLE.
271 * @return array<string, array<string, mixed>> Endpoint arguments keyed by argument name.
272 */
273 public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CREATABLE ) {
274 $args = rest_get_endpoint_args_for_schema( $this->get_item_schema(), $method );
275
276 if ( WP_REST_Server::CREATABLE === $method ) {
277 $args['generate_sub_sizes'] = array(
278 'type' => 'boolean',
279 'default' => true,
280 'description' => __( 'Whether to generate image sub sizes.', 'gutenberg' ),
281 );
282 $args['convert_format'] = array(
283 'type' => 'boolean',
284 'default' => true,
285 'description' => __( 'Whether to convert image formats.', 'gutenberg' ),
286 );
287 $args['url'] = array(
288 'type' => 'string',
289 'format' => 'uri',
290 'description' => __( 'URL of an external image to sideload into the media library, instead of uploading a file.', 'gutenberg' ),
291 'sanitize_callback' => 'sanitize_url',
292 'validate_callback' => static function ( $url, WP_REST_Request $request, string $param ) {
293 /*
294 * A custom validate_callback replaces the default
295 * rest_validate_request_arg(), so re-apply it first to keep
296 * the schema checks (string type, uri format) enforced.
297 */
298 $valid = rest_validate_request_arg( $url, $request, $param );
299 if ( is_wp_error( $valid ) ) {
300 return $valid;
301 }
302 /** @var non-empty-string $url */
303
304 /*
305 * Reject URLs that are not safe to request server-side. wp_http_validate_url()
306 * enforces an HTTP(S) scheme and blocks private, local, and otherwise
307 * disallowed hosts, guarding the sideload against SSRF.
308 */
309 if ( false === wp_http_validate_url( $url ) ) {
310 return new WP_Error(
311 'rest_invalid_url',
312 __( 'Invalid URL. Provide a valid, publicly reachable HTTP or HTTPS image URL.', 'gutenberg' ),
313 array( 'status' => 400 )
314 );
315 }
316
317 return true;
318 },
319 );
320 }
321
322 return $args;
323 }
324
325 /**
326 * Retrieves the attachment's schema, conforming to JSON Schema.
327 *
328 * Adds exif_orientation field to the schema.
329 *
330 * @return array Item schema data.
331 */
332 public function get_item_schema() {
333 $schema = parent::get_item_schema();
334
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 );
341
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;
426 }
427 }
428
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 }
446 }
447
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 );
452
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 }
460 }
461
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;
566 }
567
568 /**
569 * Creates a single attachment.
570 *
571 * @param WP_REST_Request $request Full details about the request.
572 * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
573 */
574 public function create_item( $request ) {
575 if ( ! $request['generate_sub_sizes'] ) {
576 add_filter( 'intermediate_image_sizes_advanced', '__return_empty_array', 100 );
577 add_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 );
578 // Disable server-side EXIF rotation so the client can handle it.
579 // This preserves the original orientation value in the metadata.
580 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 );
583 }
584
585 if ( ! $request['convert_format'] ) {
586 add_filter( 'image_editor_output_format', '__return_empty_array', 100 );
587 }
588
589 /*
590 * When a URL is supplied instead of an uploaded file, sideload the
591 * remote image on the server. This avoids a cross-origin browser fetch,
592 * which fails under cross-origin isolation. The sub-size and scaling
593 * filters applied above still govern whether derivatives are generated.
594 */
595 if ( ! empty( $request['url'] ) ) {
596 $response = $this->create_item_from_url( $request );
597 } else {
598 $response = parent::create_item( $request );
599 }
600
601 remove_filter( 'intermediate_image_sizes_advanced', '__return_empty_array', 100 );
602 remove_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 );
603 remove_filter( 'wp_image_maybe_exif_rotate', '__return_false', 100 );
604 remove_filter( 'big_image_size_threshold', '__return_zero', 100 );
605 remove_filter( 'image_editor_output_format', '__return_empty_array', 100 );
606
607 // Recompute image_output_format now that __return_empty_array is removed.
608 if ( ! is_wp_error( $response ) ) {
609 $data = $response->get_data();
610 if ( ! empty( $data['id'] ) && wp_attachment_is_image( $data['id'] ) ) {
611 $mime_type = get_post_mime_type( $data['id'] );
612 $filename = get_attached_file( $data['id'] );
613
614 /** This filter is documented in wp-includes/class-wp-image-editor.php */
615 $output_formats = apply_filters(
616 'image_editor_output_format', // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
617 array( $mime_type => $mime_type ),
618 $filename ? $filename : '',
619 $mime_type
620 );
621
622 $output_mime = $output_formats[ $mime_type ] ?? $mime_type;
623 $data['image_output_format'] = ( $output_mime !== $mime_type ) ? $output_mime : null;
624
625 /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */
626 $data['image_save_progressive'] = (bool) apply_filters(
627 'image_save_progressive', // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
628 false,
629 $mime_type
630 );
631
632 $response->set_data( $data );
633 }
634 }
635
636 return $response;
637 }
638
639 /**
640 * Sideloads an external image from a URL into the media library.
641 *
642 * Downloads the remote file on the server, avoiding a cross-origin browser
643 * fetch that fails under cross-origin isolation. Whether sub-sizes are
644 * generated is governed by the filters applied in create_item().
645 *
646 * @param WP_REST_Request $request Full details about the request.
647 * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
648 */
649 protected function create_item_from_url( $request ) {
650 // Sideloading downloads and stores a file, so require the upload capability.
651 if ( ! current_user_can( 'upload_files' ) ) {
652 return new WP_Error(
653 'rest_cannot_create',
654 __( 'Sorry, you are not allowed to upload media on this site.', 'gutenberg' ),
655 array( 'status' => rest_authorization_required_code() )
656 );
657 }
658
659 require_once ABSPATH . 'wp-admin/includes/file.php';
660 require_once ABSPATH . 'wp-admin/includes/media.php';
661 require_once ABSPATH . 'wp-admin/includes/image.php';
662
663 $url = $request['url'];
664 $post_id = ! empty( $request['post'] ) ? (int) $request['post'] : 0;
665
666 // Derive the filename from the URL path before downloading anything.
667 $url_path = wp_parse_url( $url, PHP_URL_PATH );
668 $filename = $url_path ? wp_basename( $url_path ) : '';
669 if ( '' === $filename ) {
670 return new WP_Error(
671 'rest_invalid_url',
672 __( 'Could not determine a filename from the provided URL.', 'gutenberg' ),
673 array( 'status' => 400 )
674 );
675 }
676
677 /*
678 * Only download URLs whose extension maps to an allowed image MIME type.
679 * The sideload handler would reject other types anyway (via
680 * wp_check_filetype_and_ext()), but checking first avoids downloading
681 * files that can never be accepted, such as PHP scripts.
682 */
683 $filetype = wp_check_filetype( $filename );
684 if ( ! $filetype['type'] || ! str_starts_with( $filetype['type'], 'image/' ) ) {
685 return new WP_Error(
686 'rest_invalid_url',
687 __( 'The provided URL does not point to a supported image file.', 'gutenberg' ),
688 array( 'status' => 400 )
689 );
690 }
691
692 /*
693 * Download the remote file with WordPress's HTTP API, which validates
694 * the host and blocks requests to private or local addresses. This is
695 * the same primitive core's media_sideload_image() relies on.
696 */
697 $tmp_file = download_url( $url );
698 if ( is_wp_error( $tmp_file ) ) {
699 return $tmp_file;
700 }
701
702 $file_array = array(
703 'name' => $filename,
704 'tmp_name' => $tmp_file,
705 );
706
707 $size_check = self::check_upload_size( $file_array );
708 if ( is_wp_error( $size_check ) ) {
709 if ( file_exists( $tmp_file ) ) {
710 wp_delete_file( $tmp_file );
711 }
712 return $size_check;
713 }
714
715 $attachment_id = media_handle_sideload( $file_array, $post_id );
716
717 if ( is_wp_error( $attachment_id ) ) {
718 /*
719 * media_handle_sideload() deletes the temp file on success; remove
720 * it explicitly when the sideload fails.
721 */
722 if ( file_exists( $tmp_file ) ) {
723 wp_delete_file( $tmp_file );
724 }
725 return $attachment_id;
726 }
727
728 $attachment = get_post( $attachment_id );
729
730 $request->set_param( 'context', 'edit' );
731
732 /*
733 * media_handle_sideload() fires the standard insert hooks (including
734 * wp_after_insert_post), but not the REST-specific action, so fire it
735 * here for parity with the uploaded-file path in create_item().
736 */
737 /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php */
738 do_action( 'rest_after_insert_attachment', $attachment, $request, true );
739
740 $response = $this->prepare_item_for_response( $attachment, $request );
741 $response->set_status( 201 );
742 $response->header( 'Location', rest_url( rest_get_route_for_post( $attachment_id ) ) );
743
744 return $response;
745 }
746
747 /**
748 * Finalizes an attachment after client-side media processing.
749 *
750 * Triggers the {@see 'wp_generate_attachment_metadata'} filter so that
751 * server-side plugins can process the attachment after all client-side
752 * operations (upload, thumbnail generation, sideloads) are complete.
753 *
754 * @param WP_REST_Request $request Full details about the request.
755 * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
756 */
757 public function finalize_item( WP_REST_Request $request ) {
758 $attachment_id = $request['id'];
759
760 $post = $this->get_post( $attachment_id );
761
762 if ( is_wp_error( $post ) ) {
763 return $post;
764 }
765
766 $metadata = wp_get_attachment_metadata( $attachment_id );
767
768 if ( ! is_array( $metadata ) ) {
769 $metadata = array();
770 }
771
772 // Apply all sub-size metadata collected from sideload responses.
773 $sub_sizes = $request['sub_sizes'] ?? array();
774
775 foreach ( $sub_sizes as $sub_size ) {
776 $image_size = $sub_size['image_size'];
777
778 // When multiple size names share identical dimensions the client
779 // sends a single sub-size entry with an array of names. Register the
780 // same file under each name. Arrays only contain regular sizes.
781 if ( is_array( $image_size ) ) {
782 $metadata['sizes'] = $metadata['sizes'] ?? array();
783
784 foreach ( $image_size as $name ) {
785 $metadata['sizes'][ $name ] = array(
786 'width' => $sub_size['width'] ?? 0,
787 'height' => $sub_size['height'] ?? 0,
788 'file' => $sub_size['file'] ?? '',
789 'mime-type' => $sub_size['mime_type'] ?? '',
790 'filesize' => $sub_size['filesize'] ?? 0,
791 );
792 }
793 continue;
794 }
795
796 if ( 'original' === $image_size || 'scaled' === $image_size ) {
797 // Skip malformed entries so a bad payload cannot blank out the
798 // main file metadata.
799 if ( empty( $sub_size['file'] ) ) {
800 continue;
801 }
802
803 // Record the supplied full-size image (from sideload_item()) as
804 // the main file, keeping the current attached file as
805 // `original_image`. A 'scaled' image is downsized and an
806 // 'original' image is rotated; both have any EXIF orientation
807 // already applied by the client.
808 if ( ! empty( $sub_size['original_image'] ) ) {
809 $metadata['original_image'] = $sub_size['original_image'];
810 }
811 $metadata['width'] = $sub_size['width'] ?? 0;
812 $metadata['height'] = $sub_size['height'] ?? 0;
813 $metadata['filesize'] = $sub_size['filesize'] ?? 0;
814 $metadata['file'] = $sub_size['file'];
815
816 // The supplied image has its orientation applied already, so
817 // reset the stored value (from the upload) to 1, as
818 // wp_create_image_subsizes() does for both its scale and rotate
819 // paths. Otherwise exif_orientation would still report the
820 // pre-rotation value and the client would rotate the image
821 // again on a re-fetch.
822 if ( ! empty( $metadata['image_meta']['orientation'] ) ) {
823 $metadata['image_meta']['orientation'] = 1;
824 }
825 } elseif ( self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size ) {
826 // Source-format original: stored under its own meta key so the
827 // scaled-sideload flow (which writes 'original_image') cannot
828 // clobber it. 'original_image' keeps pointing at the
829 // web-viewable JPEG derivative. Cleanup on attachment delete
830 // is handled by a delete_attachment hook that reads this key.
831 $metadata[ self::META_KEY_SOURCE_IMAGE ] = $sub_size['file'];
832 } elseif ( self::IMAGE_SIZE_ANIMATED_VIDEO === $image_size ) {
833 // Converted video companion of an animated GIF. Stored
834 // under its own key; the GIF stays the attachment. The
835 // editor reads this key to switch the block to a video;
836 // companion cleanup lives in lib/media/animated-gif-to-video.php.
837 $metadata[ self::META_KEY_ANIMATED_VIDEO ] = $sub_size['file'];
838 } elseif ( self::IMAGE_SIZE_ANIMATED_VIDEO_POSTER === $image_size ) {
839 // Static first-frame poster for the converted video. Used as
840 // the video block's poster and deleted alongside the video.
841 // See lib/media/animated-gif-to-video.php.
842 $metadata[ self::META_KEY_ANIMATED_VIDEO_POSTER ] = $sub_size['file'];
843 } else {
844 $metadata['sizes'] = $metadata['sizes'] ?? array();
845
846 $metadata['sizes'][ $image_size ] = array(
847 'width' => $sub_size['width'] ?? 0,
848 'height' => $sub_size['height'] ?? 0,
849 'file' => $sub_size['file'] ?? '',
850 'mime-type' => $sub_size['mime_type'] ?? '',
851 'filesize' => $sub_size['filesize'] ?? 0,
852 );
853 }
854 }
855
856 /**
857 * Filters the attachment metadata after client-side processing.
858 *
859 * This re-applies the wp_generate_attachment_metadata filter so that
860 * server-side plugins (e.g. those adding custom image sizes or
861 * processing metadata) can run after client-side uploads are complete.
862 *
863 * @param array $metadata Attachment metadata.
864 * @param int $attachment_id Attachment ID.
865 * @param string $context Context: 'create' or 'update'.
866 */
867 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
868 $metadata = apply_filters( 'wp_generate_attachment_metadata', $metadata, $attachment_id, 'update' );
869
870 wp_update_attachment_metadata( $attachment_id, $metadata );
871
872 $response_request = new WP_REST_Request(
873 WP_REST_Server::READABLE,
874 rest_get_route_for_post( $attachment_id )
875 );
876
877 $response_request['context'] = 'edit';
878
879 if ( isset( $request['_fields'] ) ) {
880 $response_request['_fields'] = $request['_fields'];
881 }
882
883 return $this->prepare_item_for_response( get_post( $attachment_id ), $response_request );
884 }
885
886 /**
887 * Checks if a given request has access to sideload a file.
888 *
889 * Sideloading a file for an existing attachment
890 * requires both update and create permissions.
891 *
892 * @param WP_REST_Request $request Full details about the request.
893 * @return true|WP_Error True if the request has access to update the item, WP_Error object otherwise.
894 */
895 public function sideload_item_permissions_check( $request ) {
896 return $this->edit_media_item_permissions_check( $request );
897 }
898
899 /**
900 * Filters {@see 'wp_unique_filename'} during sideloads.
901 *
902 * {@see wp_unique_filename()} will always add numeric suffix if the name looks like a sub-size to avoid conflicts.
903 *
904 * Adding this closure to the filter helps work around this safeguard.
905 *
906 * Example: when uploading myphoto.jpeg, WordPress normally creates myphoto-150x150.jpeg,
907 * and when uploading myphoto-150x150.jpeg, it will be renamed to myphoto-150x150-1.jpeg
908 * However, here it is desired not to add the suffix in order to maintain the same
909 * naming convention as if the file was uploaded regularly.
910 *
911 * @link https://github.com/WordPress/wordpress-develop/blob/30954f7ac0840cfdad464928021d7f380940c347/src/wp-includes/functions.php#L2576-L2582
912 *
913 * @param string $filename Unique file name.
914 * @param string $dir Directory path.
915 * @param int|string $number The highest number that was used to make the file name unique
916 * or an empty string if unused.
917 * @param string $attachment_filename Original attachment file name.
918 * @return string Filtered file name.
919 */
920 private static function filter_wp_unique_filename( $filename, $dir, $number, $attachment_filename ) {
921 if ( empty( $number ) || ! $attachment_filename ) {
922 return $filename;
923 }
924
925 $ext = pathinfo( $filename, PATHINFO_EXTENSION );
926 $name = pathinfo( $filename, PATHINFO_FILENAME );
927 $orig_name = pathinfo( $attachment_filename, PATHINFO_FILENAME );
928
929 if ( ! $ext || ! $name ) {
930 return $filename;
931 }
932
933 $matches = array();
934 if ( preg_match( '/(.*)(-\d+x\d+|-scaled)-' . $number . '$/', $name, $matches ) ) {
935 $filename_without_suffix = $matches[1] . $matches[2] . ".$ext";
936 if ( $matches[1] === $orig_name ) {
937 return $filename_without_suffix;
938 }
939 }
940
941 return $filename;
942 }
943
944 /**
945 * Validates that uploaded image dimensions are appropriate for the specified image size.
946 *
947 * @param int $width Uploaded image width.
948 * @param int $height Uploaded image height.
949 * @param string|array $image_size The target image size name, or an array
950 * of names that share the same dimensions.
951 * @param int $attachment_id The attachment ID.
952 * @return true|WP_Error True if valid, WP_Error if invalid.
953 */
954 private function validate_image_dimensions( int $width, int $height, $image_size, int $attachment_id ) {
955 // 'animated_video' companion file: video, not an image. Skip *all*
956 // dimension checks (the caller passes (0, 0) for this case so the
957 // positive-dimension assertion below would otherwise fire).
958 if ( self::IMAGE_SIZE_ANIMATED_VIDEO === $image_size ) {
959 return true;
960 }
961
962 // Source-format original companion file: no dimension constraint, and
963 // the caller passes (0, 0) because the source format (e.g. HEIC) may
964 // not be readable by wp_getimagesize() at all.
965 if ( self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size ) {
966 return true;
967 }
968
969 // Dimensions must be positive for all sizes.
970 if ( $width <= 0 || $height <= 0 ) {
971 return new WP_Error(
972 'rest_upload_invalid_dimensions',
973 __( 'Uploaded image must have positive dimensions.', 'gutenberg' ),
974 array( 'status' => 400 )
975 );
976 }
977
978 // Arrays only contain regular sub-size names that share dimensions.
979 // Validate each one against its registered constraints.
980 if ( is_array( $image_size ) ) {
981 foreach ( $image_size as $name ) {
982 $result = $this->validate_image_dimensions( $width, $height, $name, $attachment_id );
983 if ( is_wp_error( $result ) ) {
984 return $result;
985 }
986 }
987 return true;
988 }
989
990 // 'animated_video_poster' companion: a static poster image for the
991 // converted video. It is a real image (so it has positive dimensions)
992 // but is not a registered sub-size, so it has no dimension constraint.
993 if ( self::IMAGE_SIZE_ANIMATED_VIDEO_POSTER === $image_size ) {
994 return true;
995 }
996
997 // 'original' size: the full-size image that replaces the main file (see
998 // sideload_item()/finalize_item()). The endpoint expects any EXIF
999 // orientation to be applied to the image already, which can swap width
1000 // and height, so the dimensions must match the stored dimensions or be
1001 // their transpose.
1002 if ( 'original' === $image_size ) {
1003 $metadata = wp_get_attachment_metadata( $attachment_id, true );
1004 if ( is_array( $metadata ) && isset( $metadata['width'], $metadata['height'] ) ) {
1005 $expected_width = (int) $metadata['width'];
1006 $expected_height = (int) $metadata['height'];
1007
1008 $matches_dimensions = $width === $expected_width && $height === $expected_height;
1009 $transposes_dimensions = $width === $expected_height && $height === $expected_width;
1010
1011 if ( ! $matches_dimensions && ! $transposes_dimensions ) {
1012 return new WP_Error(
1013 'rest_upload_dimension_mismatch',
1014 sprintf(
1015 /* translators: 1: actual width, 2: actual height, 3: expected width, 4: expected height */
1016 __( 'Uploaded image dimensions (%1$dx%2$d) do not match original image dimensions (%3$dx%4$d).', 'gutenberg' ),
1017 $width,
1018 $height,
1019 $expected_width,
1020 $expected_height
1021 ),
1022 array( 'status' => 400 )
1023 );
1024 }
1025 }
1026 return true;
1027 }
1028
1029 // 'full' size (PDF thumbnails) and 'scaled': no further constraints.
1030 if ( 'full' === $image_size || 'scaled' === $image_size ) {
1031 return true;
1032 }
1033
1034 // Regular image sizes: validate against registered size constraints.
1035 $registered_sizes = wp_get_registered_image_subsizes();
1036
1037 if ( ! isset( $registered_sizes[ $image_size ] ) ) {
1038 return new WP_Error(
1039 'rest_upload_unknown_size',
1040 __( 'Unknown image size.', 'gutenberg' ),
1041 array( 'status' => 400 )
1042 );
1043 }
1044
1045 $size_data = $registered_sizes[ $image_size ];
1046 $max_width = (int) $size_data['width'];
1047 $max_height = (int) $size_data['height'];
1048
1049 // Validate dimensions don't exceed the registered size maximums.
1050 // Allow 1px tolerance for rounding differences.
1051 $tolerance = 1;
1052
1053 if ( $max_width > 0 && $width > $max_width + $tolerance ) {
1054 return new WP_Error(
1055 'rest_upload_dimension_mismatch',
1056 sprintf(
1057 /* translators: 1: image size name, 2: max width, 3: actual width */
1058 __( 'Uploaded image width (%3$d) exceeds maximum for "%1$s" size (%2$d).', 'gutenberg' ),
1059 $image_size,
1060 $max_width,
1061 $width
1062 ),
1063 array( 'status' => 400 )
1064 );
1065 }
1066
1067 if ( $max_height > 0 && $height > $max_height + $tolerance ) {
1068 return new WP_Error(
1069 'rest_upload_dimension_mismatch',
1070 sprintf(
1071 /* translators: 1: image size name, 2: max height, 3: actual height */
1072 __( 'Uploaded image height (%3$d) exceeds maximum for "%1$s" size (%2$d).', 'gutenberg' ),
1073 $image_size,
1074 $max_height,
1075 $height
1076 ),
1077 array( 'status' => 400 )
1078 );
1079 }
1080
1081 return true;
1082 }
1083
1084 /**
1085 * Side-loads a media file without creating an attachment.
1086 *
1087 * @param WP_REST_Request $request Full details about the request.
1088 * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
1089 */
1090 public function sideload_item( WP_REST_Request $request ) {
1091 $attachment_id = $request['id'];
1092
1093 $post = $this->get_post( $attachment_id );
1094
1095 if ( is_wp_error( $post ) ) {
1096 return $post;
1097 }
1098
1099 if (
1100 ! wp_attachment_is_image( $post ) &&
1101 ! wp_attachment_is( 'pdf', $post )
1102 ) {
1103 return new WP_Error(
1104 'rest_post_invalid_id',
1105 __( 'Invalid post ID, only images and PDFs can be sideloaded.', 'gutenberg' ),
1106 array( 'status' => 400 )
1107 );
1108 }
1109
1110 if ( ! $request['convert_format'] ) {
1111 // Prevent image conversion as that is done client-side.
1112 add_filter( 'image_editor_output_format', '__return_empty_array', 100 );
1113 }
1114
1115 // Get the file via $_FILES or raw data.
1116 $files = $request->get_file_params();
1117 $headers = $request->get_headers();
1118
1119 /*
1120 * wp_unique_filename() will always add numeric suffix if the name looks like a sub-size to avoid conflicts.
1121 * See https://github.com/WordPress/wordpress-develop/blob/30954f7ac0840cfdad464928021d7f380940c347/src/wp-includes/functions.php#L2576-L2582
1122 * With the following filter we can work around this safeguard.
1123 */
1124
1125 $attachment_filename = get_attached_file( $attachment_id, true );
1126 $attachment_filename = $attachment_filename ? wp_basename( $attachment_filename ) : null;
1127
1128 /**
1129 * @param string $filename Unique file name.
1130 * @param string $ext File extension. Example: ".png".
1131 * @param string $dir Directory path.
1132 * @param callable|null $unique_filename_callback Callback function that generates the unique file name.
1133 * @param string[] $alt_filenames Array of alternate file names that were checked for collisions.
1134 * @param int|string $number The highest number that was used to make the file name unique
1135 * or an empty string if unused.
1136 * @return string Filtered file name.
1137 */
1138 $filter_filename = static function ( $filename, $ext, $dir, $unique_filename_callback, $alt_filenames, $number ) use ( $attachment_filename ) {
1139 return self::filter_wp_unique_filename( $filename, $dir, $number, $attachment_filename );
1140 };
1141
1142 add_filter( 'wp_unique_filename', $filter_filename, 10, 6 );
1143
1144 $parent_post = get_post_parent( $attachment_id );
1145
1146 $time = null;
1147
1148 // Matches logic in media_handle_upload().
1149 // The post date doesn't usually matter for pages, so don't backdate this upload.
1150 if ( $parent_post && 'page' !== $parent_post->post_type && substr( $parent_post->post_date, 0, 4 ) > 0 ) {
1151 $time = $parent_post->post_date;
1152 }
1153
1154 if ( ! empty( $files ) ) {
1155 $file = $this->upload_from_file( $files, $headers, $time );
1156 } else {
1157 $file = $this->upload_from_data( $request->get_body(), $headers, $time );
1158 }
1159
1160 remove_filter( 'wp_unique_filename', $filter_filename );
1161 remove_filter( 'image_editor_output_format', '__return_empty_array', 100 );
1162
1163 if ( is_wp_error( $file ) ) {
1164 return $file;
1165 }
1166
1167 $type = $file['type'];
1168 $path = $file['file'];
1169
1170 $image_size = $request['image_size'];
1171
1172 // Read dimensions once up-front. Needed both for early-error handling
1173 // (corrupted/unsupported files) and for populating the sub-size payload
1174 // below. 'original' and 'scaled' both replace the main file, so their
1175 // dimensions are written to metadata; 'original' is additionally
1176 // validated against the stored attachment size (it must match it or be
1177 // its transpose).
1178 //
1179 // 'animated_video' companions are video files (MP4/WebM); the image
1180 // helpers can't read their dimensions and would falsely report the
1181 // upload as "corrupted or unsupported". Source-format originals
1182 // ('source_original', e.g. the HEIC kept next to its JPEG derivative)
1183 // are exempt for the same reason: their dimensions are neither
1184 // validated nor recorded, and wp_getimagesize() may not be able to
1185 // read the source format at all on servers without HEIC/HEIF support.
1186 // Skip the read for both cases; validate_image_dimensions() also
1187 // short-circuits them below.
1188 $skip_dimension_read =
1189 self::IMAGE_SIZE_ANIMATED_VIDEO === $image_size ||
1190 self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size;
1191
1192 $size = $skip_dimension_read ? array( 0, 0 ) : wp_getimagesize( $path );
1193
1194 if ( ! $size ) {
1195 // Could not determine dimensions (corrupted file, unsupported format).
1196 wp_delete_file( $path );
1197 return new WP_Error(
1198 'rest_upload_invalid_image',
1199 __( 'Could not read image dimensions. The file may be corrupted or an unsupported format.', 'gutenberg' ),
1200 array( 'status' => 400 )
1201 );
1202 }
1203
1204 $validation = $this->validate_image_dimensions( $size[0], $size[1], $image_size, $attachment_id );
1205 if ( is_wp_error( $validation ) ) {
1206 // Clean up the uploaded file.
1207 wp_delete_file( $path );
1208 return $validation;
1209 }
1210
1211 // Build sub-size data to return to the client.
1212 // The client accumulates these and sends them all to the finalize endpoint.
1213 // `image_size` may be a single string or an array of names that share the
1214 // same dimensions and therefore reuse a single sideloaded file. Arrays
1215 // only carry regular sub-sizes; the special keys below ('original',
1216 // 'scaled', and the source-format original) are always scalar strings.
1217 $sub_size_data = array(
1218 'image_size' => $image_size,
1219 );
1220
1221 if ( is_array( $image_size ) ) {
1222 $sub_size_data['width'] = $size[0];
1223 $sub_size_data['height'] = $size[1];
1224 $sub_size_data['file'] = wp_basename( $path );
1225 $sub_size_data['mime_type'] = $type;
1226 $sub_size_data['filesize'] = wp_filesize( $path );
1227 } elseif ( self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size ) {
1228 // Source-format original. finalize_item() writes the filename to
1229 // $metadata[ self::META_KEY_SOURCE_IMAGE ] (separate from
1230 // 'original_image', which the scaled-sideload flow owns). Cleanup on
1231 // attachment delete is handled by a delete_attachment hook that reads
1232 // this key.
1233 $sub_size_data['file'] = wp_basename( $path );
1234 } elseif ( self::IMAGE_SIZE_ANIMATED_VIDEO === $image_size ) {
1235 // Converted animated-GIF video companion. finalize_item()
1236 // writes the filename to $metadata['animated_video']; the editor
1237 // reads it to switch the block to a video, and a delete_attachment
1238 // hook removes it. See lib/media/animated-gif-to-video.php.
1239 $sub_size_data['file'] = wp_basename( $path );
1240 } elseif ( self::IMAGE_SIZE_ANIMATED_VIDEO_POSTER === $image_size ) {
1241 // Static poster for the converted video. finalize_item() writes
1242 // the filename to $metadata['animated_video_poster']; used as the
1243 // video block's poster and deleted with the video.
1244 $sub_size_data['file'] = wp_basename( $path );
1245 } elseif ( 'scaled' === $image_size || 'original' === $image_size ) {
1246 // 'scaled' and 'original' both replace the attachment's main file
1247 // with the supplied image and keep the file being replaced as
1248 // `original_image`, which is the untouched upload. A 'scaled' image is
1249 // downsized and an 'original' image has any EXIF orientation already
1250 // applied. This is the same swap WordPress makes when it scales or
1251 // rotates an image on upload. See core's _wp_image_meta_replace_original().
1252 $current_file = get_attached_file( $attachment_id, true );
1253
1254 if ( ! $current_file ) {
1255 return new WP_Error(
1256 'rest_sideload_no_attached_file',
1257 __( 'Unable to retrieve the attached file for this attachment.', 'gutenberg' ),
1258 array( 'status' => 404 )
1259 );
1260 }
1261
1262 $sub_size_data['original_image'] = wp_basename( $current_file );
1263
1264 // Update the attached file to point to the supplied image.
1265 // This writes to _wp_attached_file meta, not _wp_attachment_metadata.
1266 // Guard against a failed update so a stale original is not recorded.
1267 if (
1268 get_attached_file( $attachment_id, true ) !== $path &&
1269 ! update_attached_file( $attachment_id, $path )
1270 ) {
1271 return new WP_Error(
1272 'rest_sideload_update_attached_file_failed',
1273 __( 'Unable to update the attached file for this attachment.', 'gutenberg' ),
1274 array( 'status' => 500 )
1275 );
1276 }
1277
1278 $sub_size_data['width'] = $size[0];
1279 $sub_size_data['height'] = $size[1];
1280 $sub_size_data['filesize'] = wp_filesize( $path );
1281 $sub_size_data['file'] = _wp_relative_upload_path( $path );
1282 } else {
1283 $sub_size_data['width'] = $size[0];
1284 $sub_size_data['height'] = $size[1];
1285 $sub_size_data['file'] = wp_basename( $path );
1286 $sub_size_data['mime_type'] = $type;
1287 $sub_size_data['filesize'] = wp_filesize( $path );
1288 }
1289
1290 return rest_ensure_response( $sub_size_data );
1291 }
1292
1293 /**
1294 * Resolves the encode quality WordPress would use for an image.
1295 *
1296 * Prefers the core wp_get_image_encode_quality() helper when available, and
1297 * otherwise mirrors WP_Image_Editor::set_quality() inline for WordPress
1298 * versions that predate it: per-format default, the wp_editor_set_quality
1299 * filter, the jpeg_quality filter for JPEG output, then resets non-numeric
1300 * or out-of-range values to the default and squashes 0 to 1.
1301 *
1302 * wp_get_image_encode_quality() is proposed for WordPress core in
1303 * https://github.com/WordPress/wordpress-develop/pull/11856; until it lands
1304 * the function_exists() guard falls back to the inline implementation below.
1305 *
1306 * @param non-empty-string $mime_type The output image MIME type, e.g. 'image/jpeg'.
1307 * @param array{ width?: non-negative-int, height?: non-negative-int } $size Dimensions ('width', 'height') for the wp_editor_set_quality filter.
1308 * @return int<1, 100> Encode quality between 1 and 100.
1309 */
1310 private function get_image_encode_quality( string $mime_type, array $size = array() ): int {
1311 if ( function_exists( 'wp_get_image_encode_quality' ) ) {
1312 return wp_get_image_encode_quality( $mime_type, $size );
1313 }
1314
1315 // Mirror WP_Image_Editor::get_default_quality(): WebP defaults to 86,
1316 // everything else to 82.
1317 $default_quality = ( 'image/webp' === $mime_type ) ? 86 : 82;
1318
1319 /** This filter is documented in wp-includes/class-wp-image-editor.php */
1320 $quality = apply_filters(
1321 'wp_editor_set_quality', // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
1322 $default_quality,
1323 $mime_type,
1324 $size
1325 );
1326
1327 if ( 'image/jpeg' === $mime_type ) {
1328 /** This filter is documented in wp-includes/class-wp-image-editor.php */
1329 $quality = apply_filters( 'jpeg_quality', $quality, 'image_resize' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
1330 }
1331
1332 if ( ! is_numeric( $quality ) ) {
1333 $quality = $default_quality;
1334 } else {
1335 $quality = (int) $quality;
1336 }
1337
1338 // Reset out-of-range values to the default, matching WP_Image_Editor::set_quality().
1339 if ( $quality < 0 || $quality > 100 ) {
1340 $quality = $default_quality;
1341 }
1342
1343 // Allow 0, but squash to 1, matching WP_Image_Editor::set_quality().
1344 if ( 0 === $quality ) {
1345 $quality = 1;
1346 }
1347
1348 return $quality;
1349 }
1350 }
1351