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

1,343 lines 49.8 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 $attachment_id = media_handle_sideload( $file_array, $post_id );
708
709 if ( is_wp_error( $attachment_id ) ) {
710 /*
711 * media_handle_sideload() deletes the temp file on success; remove
712 * it explicitly when the sideload fails.
713 */
714 if ( file_exists( $tmp_file ) ) {
715 wp_delete_file( $tmp_file );
716 }
717 return $attachment_id;
718 }
719
720 $attachment = get_post( $attachment_id );
721
722 $request->set_param( 'context', 'edit' );
723
724 /*
725 * media_handle_sideload() fires the standard insert hooks (including
726 * wp_after_insert_post), but not the REST-specific action, so fire it
727 * here for parity with the uploaded-file path in create_item().
728 */
729 /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php */
730 do_action( 'rest_after_insert_attachment', $attachment, $request, true );
731
732 $response = $this->prepare_item_for_response( $attachment, $request );
733 $response->set_status( 201 );
734 $response->header( 'Location', rest_url( rest_get_route_for_post( $attachment_id ) ) );
735
736 return $response;
737 }
738
739 /**
740 * Finalizes an attachment after client-side media processing.
741 *
742 * Triggers the {@see 'wp_generate_attachment_metadata'} filter so that
743 * server-side plugins can process the attachment after all client-side
744 * operations (upload, thumbnail generation, sideloads) are complete.
745 *
746 * @param WP_REST_Request $request Full details about the request.
747 * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
748 */
749 public function finalize_item( WP_REST_Request $request ) {
750 $attachment_id = $request['id'];
751
752 $post = $this->get_post( $attachment_id );
753
754 if ( is_wp_error( $post ) ) {
755 return $post;
756 }
757
758 $metadata = wp_get_attachment_metadata( $attachment_id );
759
760 if ( ! is_array( $metadata ) ) {
761 $metadata = array();
762 }
763
764 // Apply all sub-size metadata collected from sideload responses.
765 $sub_sizes = $request['sub_sizes'] ?? array();
766
767 foreach ( $sub_sizes as $sub_size ) {
768 $image_size = $sub_size['image_size'];
769
770 // When multiple size names share identical dimensions the client
771 // sends a single sub-size entry with an array of names. Register the
772 // same file under each name. Arrays only contain regular sizes.
773 if ( is_array( $image_size ) ) {
774 $metadata['sizes'] = $metadata['sizes'] ?? array();
775
776 foreach ( $image_size as $name ) {
777 $metadata['sizes'][ $name ] = array(
778 'width' => $sub_size['width'] ?? 0,
779 'height' => $sub_size['height'] ?? 0,
780 'file' => $sub_size['file'] ?? '',
781 'mime-type' => $sub_size['mime_type'] ?? '',
782 'filesize' => $sub_size['filesize'] ?? 0,
783 );
784 }
785 continue;
786 }
787
788 if ( 'original' === $image_size || 'scaled' === $image_size ) {
789 // Skip malformed entries so a bad payload cannot blank out the
790 // main file metadata.
791 if ( empty( $sub_size['file'] ) ) {
792 continue;
793 }
794
795 // Record the supplied full-size image (from sideload_item()) as
796 // the main file, keeping the current attached file as
797 // `original_image`. A 'scaled' image is downsized and an
798 // 'original' image is rotated; both have any EXIF orientation
799 // already applied by the client.
800 if ( ! empty( $sub_size['original_image'] ) ) {
801 $metadata['original_image'] = $sub_size['original_image'];
802 }
803 $metadata['width'] = $sub_size['width'] ?? 0;
804 $metadata['height'] = $sub_size['height'] ?? 0;
805 $metadata['filesize'] = $sub_size['filesize'] ?? 0;
806 $metadata['file'] = $sub_size['file'];
807
808 // The supplied image has its orientation applied already, so
809 // reset the stored value (from the upload) to 1, as
810 // wp_create_image_subsizes() does for both its scale and rotate
811 // paths. Otherwise exif_orientation would still report the
812 // pre-rotation value and the client would rotate the image
813 // again on a re-fetch.
814 if ( ! empty( $metadata['image_meta']['orientation'] ) ) {
815 $metadata['image_meta']['orientation'] = 1;
816 }
817 } elseif ( self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size ) {
818 // Source-format original: stored under its own meta key so the
819 // scaled-sideload flow (which writes 'original_image') cannot
820 // clobber it. 'original_image' keeps pointing at the
821 // web-viewable JPEG derivative. Cleanup on attachment delete
822 // is handled by a delete_attachment hook that reads this key.
823 $metadata[ self::META_KEY_SOURCE_IMAGE ] = $sub_size['file'];
824 } elseif ( self::IMAGE_SIZE_ANIMATED_VIDEO === $image_size ) {
825 // Converted video companion of an animated GIF. Stored
826 // under its own key; the GIF stays the attachment. The
827 // editor reads this key to switch the block to a video;
828 // companion cleanup lives in lib/media/animated-gif-to-video.php.
829 $metadata[ self::META_KEY_ANIMATED_VIDEO ] = $sub_size['file'];
830 } elseif ( self::IMAGE_SIZE_ANIMATED_VIDEO_POSTER === $image_size ) {
831 // Static first-frame poster for the converted video. Used as
832 // the video block's poster and deleted alongside the video.
833 // See lib/media/animated-gif-to-video.php.
834 $metadata[ self::META_KEY_ANIMATED_VIDEO_POSTER ] = $sub_size['file'];
835 } else {
836 $metadata['sizes'] = $metadata['sizes'] ?? array();
837
838 $metadata['sizes'][ $image_size ] = array(
839 'width' => $sub_size['width'] ?? 0,
840 'height' => $sub_size['height'] ?? 0,
841 'file' => $sub_size['file'] ?? '',
842 'mime-type' => $sub_size['mime_type'] ?? '',
843 'filesize' => $sub_size['filesize'] ?? 0,
844 );
845 }
846 }
847
848 /**
849 * Filters the attachment metadata after client-side processing.
850 *
851 * This re-applies the wp_generate_attachment_metadata filter so that
852 * server-side plugins (e.g. those adding custom image sizes or
853 * processing metadata) can run after client-side uploads are complete.
854 *
855 * @param array $metadata Attachment metadata.
856 * @param int $attachment_id Attachment ID.
857 * @param string $context Context: 'create' or 'update'.
858 */
859 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
860 $metadata = apply_filters( 'wp_generate_attachment_metadata', $metadata, $attachment_id, 'update' );
861
862 wp_update_attachment_metadata( $attachment_id, $metadata );
863
864 $response_request = new WP_REST_Request(
865 WP_REST_Server::READABLE,
866 rest_get_route_for_post( $attachment_id )
867 );
868
869 $response_request['context'] = 'edit';
870
871 if ( isset( $request['_fields'] ) ) {
872 $response_request['_fields'] = $request['_fields'];
873 }
874
875 return $this->prepare_item_for_response( get_post( $attachment_id ), $response_request );
876 }
877
878 /**
879 * Checks if a given request has access to sideload a file.
880 *
881 * Sideloading a file for an existing attachment
882 * requires both update and create permissions.
883 *
884 * @param WP_REST_Request $request Full details about the request.
885 * @return true|WP_Error True if the request has access to update the item, WP_Error object otherwise.
886 */
887 public function sideload_item_permissions_check( $request ) {
888 return $this->edit_media_item_permissions_check( $request );
889 }
890
891 /**
892 * Filters {@see 'wp_unique_filename'} during sideloads.
893 *
894 * {@see wp_unique_filename()} will always add numeric suffix if the name looks like a sub-size to avoid conflicts.
895 *
896 * Adding this closure to the filter helps work around this safeguard.
897 *
898 * Example: when uploading myphoto.jpeg, WordPress normally creates myphoto-150x150.jpeg,
899 * and when uploading myphoto-150x150.jpeg, it will be renamed to myphoto-150x150-1.jpeg
900 * However, here it is desired not to add the suffix in order to maintain the same
901 * naming convention as if the file was uploaded regularly.
902 *
903 * @link https://github.com/WordPress/wordpress-develop/blob/30954f7ac0840cfdad464928021d7f380940c347/src/wp-includes/functions.php#L2576-L2582
904 *
905 * @param string $filename Unique file name.
906 * @param string $dir Directory path.
907 * @param int|string $number The highest number that was used to make the file name unique
908 * or an empty string if unused.
909 * @param string $attachment_filename Original attachment file name.
910 * @return string Filtered file name.
911 */
912 private static function filter_wp_unique_filename( $filename, $dir, $number, $attachment_filename ) {
913 if ( empty( $number ) || ! $attachment_filename ) {
914 return $filename;
915 }
916
917 $ext = pathinfo( $filename, PATHINFO_EXTENSION );
918 $name = pathinfo( $filename, PATHINFO_FILENAME );
919 $orig_name = pathinfo( $attachment_filename, PATHINFO_FILENAME );
920
921 if ( ! $ext || ! $name ) {
922 return $filename;
923 }
924
925 $matches = array();
926 if ( preg_match( '/(.*)(-\d+x\d+|-scaled)-' . $number . '$/', $name, $matches ) ) {
927 $filename_without_suffix = $matches[1] . $matches[2] . ".$ext";
928 if ( $matches[1] === $orig_name ) {
929 return $filename_without_suffix;
930 }
931 }
932
933 return $filename;
934 }
935
936 /**
937 * Validates that uploaded image dimensions are appropriate for the specified image size.
938 *
939 * @param int $width Uploaded image width.
940 * @param int $height Uploaded image height.
941 * @param string|array $image_size The target image size name, or an array
942 * of names that share the same dimensions.
943 * @param int $attachment_id The attachment ID.
944 * @return true|WP_Error True if valid, WP_Error if invalid.
945 */
946 private function validate_image_dimensions( int $width, int $height, $image_size, int $attachment_id ) {
947 // 'animated_video' companion file: video, not an image. Skip *all*
948 // dimension checks (the caller passes (0, 0) for this case so the
949 // positive-dimension assertion below would otherwise fire).
950 if ( self::IMAGE_SIZE_ANIMATED_VIDEO === $image_size ) {
951 return true;
952 }
953
954 // Source-format original companion file: no dimension constraint, and
955 // the caller passes (0, 0) because the source format (e.g. HEIC) may
956 // not be readable by wp_getimagesize() at all.
957 if ( self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size ) {
958 return true;
959 }
960
961 // Dimensions must be positive for all sizes.
962 if ( $width <= 0 || $height <= 0 ) {
963 return new WP_Error(
964 'rest_upload_invalid_dimensions',
965 __( 'Uploaded image must have positive dimensions.', 'gutenberg' ),
966 array( 'status' => 400 )
967 );
968 }
969
970 // Arrays only contain regular sub-size names that share dimensions.
971 // Validate each one against its registered constraints.
972 if ( is_array( $image_size ) ) {
973 foreach ( $image_size as $name ) {
974 $result = $this->validate_image_dimensions( $width, $height, $name, $attachment_id );
975 if ( is_wp_error( $result ) ) {
976 return $result;
977 }
978 }
979 return true;
980 }
981
982 // 'animated_video_poster' companion: a static poster image for the
983 // converted video. It is a real image (so it has positive dimensions)
984 // but is not a registered sub-size, so it has no dimension constraint.
985 if ( self::IMAGE_SIZE_ANIMATED_VIDEO_POSTER === $image_size ) {
986 return true;
987 }
988
989 // 'original' size: the full-size image that replaces the main file (see
990 // sideload_item()/finalize_item()). The endpoint expects any EXIF
991 // orientation to be applied to the image already, which can swap width
992 // and height, so the dimensions must match the stored dimensions or be
993 // their transpose.
994 if ( 'original' === $image_size ) {
995 $metadata = wp_get_attachment_metadata( $attachment_id, true );
996 if ( is_array( $metadata ) && isset( $metadata['width'], $metadata['height'] ) ) {
997 $expected_width = (int) $metadata['width'];
998 $expected_height = (int) $metadata['height'];
999
1000 $matches_dimensions = $width === $expected_width && $height === $expected_height;
1001 $transposes_dimensions = $width === $expected_height && $height === $expected_width;
1002
1003 if ( ! $matches_dimensions && ! $transposes_dimensions ) {
1004 return new WP_Error(
1005 'rest_upload_dimension_mismatch',
1006 sprintf(
1007 /* translators: 1: actual width, 2: actual height, 3: expected width, 4: expected height */
1008 __( 'Uploaded image dimensions (%1$dx%2$d) do not match original image dimensions (%3$dx%4$d).', 'gutenberg' ),
1009 $width,
1010 $height,
1011 $expected_width,
1012 $expected_height
1013 ),
1014 array( 'status' => 400 )
1015 );
1016 }
1017 }
1018 return true;
1019 }
1020
1021 // 'full' size (PDF thumbnails) and 'scaled': no further constraints.
1022 if ( 'full' === $image_size || 'scaled' === $image_size ) {
1023 return true;
1024 }
1025
1026 // Regular image sizes: validate against registered size constraints.
1027 $registered_sizes = wp_get_registered_image_subsizes();
1028
1029 if ( ! isset( $registered_sizes[ $image_size ] ) ) {
1030 return new WP_Error(
1031 'rest_upload_unknown_size',
1032 __( 'Unknown image size.', 'gutenberg' ),
1033 array( 'status' => 400 )
1034 );
1035 }
1036
1037 $size_data = $registered_sizes[ $image_size ];
1038 $max_width = (int) $size_data['width'];
1039 $max_height = (int) $size_data['height'];
1040
1041 // Validate dimensions don't exceed the registered size maximums.
1042 // Allow 1px tolerance for rounding differences.
1043 $tolerance = 1;
1044
1045 if ( $max_width > 0 && $width > $max_width + $tolerance ) {
1046 return new WP_Error(
1047 'rest_upload_dimension_mismatch',
1048 sprintf(
1049 /* translators: 1: image size name, 2: max width, 3: actual width */
1050 __( 'Uploaded image width (%3$d) exceeds maximum for "%1$s" size (%2$d).', 'gutenberg' ),
1051 $image_size,
1052 $max_width,
1053 $width
1054 ),
1055 array( 'status' => 400 )
1056 );
1057 }
1058
1059 if ( $max_height > 0 && $height > $max_height + $tolerance ) {
1060 return new WP_Error(
1061 'rest_upload_dimension_mismatch',
1062 sprintf(
1063 /* translators: 1: image size name, 2: max height, 3: actual height */
1064 __( 'Uploaded image height (%3$d) exceeds maximum for "%1$s" size (%2$d).', 'gutenberg' ),
1065 $image_size,
1066 $max_height,
1067 $height
1068 ),
1069 array( 'status' => 400 )
1070 );
1071 }
1072
1073 return true;
1074 }
1075
1076 /**
1077 * Side-loads a media file without creating an attachment.
1078 *
1079 * @param WP_REST_Request $request Full details about the request.
1080 * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
1081 */
1082 public function sideload_item( WP_REST_Request $request ) {
1083 $attachment_id = $request['id'];
1084
1085 $post = $this->get_post( $attachment_id );
1086
1087 if ( is_wp_error( $post ) ) {
1088 return $post;
1089 }
1090
1091 if (
1092 ! wp_attachment_is_image( $post ) &&
1093 ! wp_attachment_is( 'pdf', $post )
1094 ) {
1095 return new WP_Error(
1096 'rest_post_invalid_id',
1097 __( 'Invalid post ID, only images and PDFs can be sideloaded.', 'gutenberg' ),
1098 array( 'status' => 400 )
1099 );
1100 }
1101
1102 if ( ! $request['convert_format'] ) {
1103 // Prevent image conversion as that is done client-side.
1104 add_filter( 'image_editor_output_format', '__return_empty_array', 100 );
1105 }
1106
1107 // Get the file via $_FILES or raw data.
1108 $files = $request->get_file_params();
1109 $headers = $request->get_headers();
1110
1111 /*
1112 * wp_unique_filename() will always add numeric suffix if the name looks like a sub-size to avoid conflicts.
1113 * See https://github.com/WordPress/wordpress-develop/blob/30954f7ac0840cfdad464928021d7f380940c347/src/wp-includes/functions.php#L2576-L2582
1114 * With the following filter we can work around this safeguard.
1115 */
1116
1117 $attachment_filename = get_attached_file( $attachment_id, true );
1118 $attachment_filename = $attachment_filename ? wp_basename( $attachment_filename ) : null;
1119
1120 /**
1121 * @param string $filename Unique file name.
1122 * @param string $ext File extension. Example: ".png".
1123 * @param string $dir Directory path.
1124 * @param callable|null $unique_filename_callback Callback function that generates the unique file name.
1125 * @param string[] $alt_filenames Array of alternate file names that were checked for collisions.
1126 * @param int|string $number The highest number that was used to make the file name unique
1127 * or an empty string if unused.
1128 * @return string Filtered file name.
1129 */
1130 $filter_filename = static function ( $filename, $ext, $dir, $unique_filename_callback, $alt_filenames, $number ) use ( $attachment_filename ) {
1131 return self::filter_wp_unique_filename( $filename, $dir, $number, $attachment_filename );
1132 };
1133
1134 add_filter( 'wp_unique_filename', $filter_filename, 10, 6 );
1135
1136 $parent_post = get_post_parent( $attachment_id );
1137
1138 $time = null;
1139
1140 // Matches logic in media_handle_upload().
1141 // The post date doesn't usually matter for pages, so don't backdate this upload.
1142 if ( $parent_post && 'page' !== $parent_post->post_type && substr( $parent_post->post_date, 0, 4 ) > 0 ) {
1143 $time = $parent_post->post_date;
1144 }
1145
1146 if ( ! empty( $files ) ) {
1147 $file = $this->upload_from_file( $files, $headers, $time );
1148 } else {
1149 $file = $this->upload_from_data( $request->get_body(), $headers, $time );
1150 }
1151
1152 remove_filter( 'wp_unique_filename', $filter_filename );
1153 remove_filter( 'image_editor_output_format', '__return_empty_array', 100 );
1154
1155 if ( is_wp_error( $file ) ) {
1156 return $file;
1157 }
1158
1159 $type = $file['type'];
1160 $path = $file['file'];
1161
1162 $image_size = $request['image_size'];
1163
1164 // Read dimensions once up-front. Needed both for early-error handling
1165 // (corrupted/unsupported files) and for populating the sub-size payload
1166 // below. 'original' and 'scaled' both replace the main file, so their
1167 // dimensions are written to metadata; 'original' is additionally
1168 // validated against the stored attachment size (it must match it or be
1169 // its transpose).
1170 //
1171 // 'animated_video' companions are video files (MP4/WebM); the image
1172 // helpers can't read their dimensions and would falsely report the
1173 // upload as "corrupted or unsupported". Source-format originals
1174 // ('source_original', e.g. the HEIC kept next to its JPEG derivative)
1175 // are exempt for the same reason: their dimensions are neither
1176 // validated nor recorded, and wp_getimagesize() may not be able to
1177 // read the source format at all on servers without HEIC/HEIF support.
1178 // Skip the read for both cases; validate_image_dimensions() also
1179 // short-circuits them below.
1180 $skip_dimension_read =
1181 self::IMAGE_SIZE_ANIMATED_VIDEO === $image_size ||
1182 self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size;
1183
1184 $size = $skip_dimension_read ? array( 0, 0 ) : wp_getimagesize( $path );
1185
1186 if ( ! $size ) {
1187 // Could not determine dimensions (corrupted file, unsupported format).
1188 wp_delete_file( $path );
1189 return new WP_Error(
1190 'rest_upload_invalid_image',
1191 __( 'Could not read image dimensions. The file may be corrupted or an unsupported format.', 'gutenberg' ),
1192 array( 'status' => 400 )
1193 );
1194 }
1195
1196 $validation = $this->validate_image_dimensions( $size[0], $size[1], $image_size, $attachment_id );
1197 if ( is_wp_error( $validation ) ) {
1198 // Clean up the uploaded file.
1199 wp_delete_file( $path );
1200 return $validation;
1201 }
1202
1203 // Build sub-size data to return to the client.
1204 // The client accumulates these and sends them all to the finalize endpoint.
1205 // `image_size` may be a single string or an array of names that share the
1206 // same dimensions and therefore reuse a single sideloaded file. Arrays
1207 // only carry regular sub-sizes; the special keys below ('original',
1208 // 'scaled', and the source-format original) are always scalar strings.
1209 $sub_size_data = array(
1210 'image_size' => $image_size,
1211 );
1212
1213 if ( is_array( $image_size ) ) {
1214 $sub_size_data['width'] = $size[0];
1215 $sub_size_data['height'] = $size[1];
1216 $sub_size_data['file'] = wp_basename( $path );
1217 $sub_size_data['mime_type'] = $type;
1218 $sub_size_data['filesize'] = wp_filesize( $path );
1219 } elseif ( self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size ) {
1220 // Source-format original. finalize_item() writes the filename to
1221 // $metadata[ self::META_KEY_SOURCE_IMAGE ] (separate from
1222 // 'original_image', which the scaled-sideload flow owns). Cleanup on
1223 // attachment delete is handled by a delete_attachment hook that reads
1224 // this key.
1225 $sub_size_data['file'] = wp_basename( $path );
1226 } elseif ( self::IMAGE_SIZE_ANIMATED_VIDEO === $image_size ) {
1227 // Converted animated-GIF video companion. finalize_item()
1228 // writes the filename to $metadata['animated_video']; the editor
1229 // reads it to switch the block to a video, and a delete_attachment
1230 // hook removes it. See lib/media/animated-gif-to-video.php.
1231 $sub_size_data['file'] = wp_basename( $path );
1232 } elseif ( self::IMAGE_SIZE_ANIMATED_VIDEO_POSTER === $image_size ) {
1233 // Static poster for the converted video. finalize_item() writes
1234 // the filename to $metadata['animated_video_poster']; used as the
1235 // video block's poster and deleted with the video.
1236 $sub_size_data['file'] = wp_basename( $path );
1237 } elseif ( 'scaled' === $image_size || 'original' === $image_size ) {
1238 // 'scaled' and 'original' both replace the attachment's main file
1239 // with the supplied image and keep the file being replaced as
1240 // `original_image`, which is the untouched upload. A 'scaled' image is
1241 // downsized and an 'original' image has any EXIF orientation already
1242 // applied. This is the same swap WordPress makes when it scales or
1243 // rotates an image on upload. See core's _wp_image_meta_replace_original().
1244 $current_file = get_attached_file( $attachment_id, true );
1245
1246 if ( ! $current_file ) {
1247 return new WP_Error(
1248 'rest_sideload_no_attached_file',
1249 __( 'Unable to retrieve the attached file for this attachment.', 'gutenberg' ),
1250 array( 'status' => 404 )
1251 );
1252 }
1253
1254 $sub_size_data['original_image'] = wp_basename( $current_file );
1255
1256 // Update the attached file to point to the supplied image.
1257 // This writes to _wp_attached_file meta, not _wp_attachment_metadata.
1258 // Guard against a failed update so a stale original is not recorded.
1259 if (
1260 get_attached_file( $attachment_id, true ) !== $path &&
1261 ! update_attached_file( $attachment_id, $path )
1262 ) {
1263 return new WP_Error(
1264 'rest_sideload_update_attached_file_failed',
1265 __( 'Unable to update the attached file for this attachment.', 'gutenberg' ),
1266 array( 'status' => 500 )
1267 );
1268 }
1269
1270 $sub_size_data['width'] = $size[0];
1271 $sub_size_data['height'] = $size[1];
1272 $sub_size_data['filesize'] = wp_filesize( $path );
1273 $sub_size_data['file'] = _wp_relative_upload_path( $path );
1274 } else {
1275 $sub_size_data['width'] = $size[0];
1276 $sub_size_data['height'] = $size[1];
1277 $sub_size_data['file'] = wp_basename( $path );
1278 $sub_size_data['mime_type'] = $type;
1279 $sub_size_data['filesize'] = wp_filesize( $path );
1280 }
1281
1282 return rest_ensure_response( $sub_size_data );
1283 }
1284
1285 /**
1286 * Resolves the encode quality WordPress would use for an image.
1287 *
1288 * Prefers the core wp_get_image_encode_quality() helper when available, and
1289 * otherwise mirrors WP_Image_Editor::set_quality() inline for WordPress
1290 * versions that predate it: per-format default, the wp_editor_set_quality
1291 * filter, the jpeg_quality filter for JPEG output, then resets non-numeric
1292 * or out-of-range values to the default and squashes 0 to 1.
1293 *
1294 * wp_get_image_encode_quality() is proposed for WordPress core in
1295 * https://github.com/WordPress/wordpress-develop/pull/11856; until it lands
1296 * the function_exists() guard falls back to the inline implementation below.
1297 *
1298 * @param non-empty-string $mime_type The output image MIME type, e.g. 'image/jpeg'.
1299 * @param array{ width?: non-negative-int, height?: non-negative-int } $size Dimensions ('width', 'height') for the wp_editor_set_quality filter.
1300 * @return int<1, 100> Encode quality between 1 and 100.
1301 */
1302 private function get_image_encode_quality( string $mime_type, array $size = array() ): int {
1303 if ( function_exists( 'wp_get_image_encode_quality' ) ) {
1304 return wp_get_image_encode_quality( $mime_type, $size );
1305 }
1306
1307 // Mirror WP_Image_Editor::get_default_quality(): WebP defaults to 86,
1308 // everything else to 82.
1309 $default_quality = ( 'image/webp' === $mime_type ) ? 86 : 82;
1310
1311 /** This filter is documented in wp-includes/class-wp-image-editor.php */
1312 $quality = apply_filters(
1313 'wp_editor_set_quality', // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
1314 $default_quality,
1315 $mime_type,
1316 $size
1317 );
1318
1319 if ( 'image/jpeg' === $mime_type ) {
1320 /** This filter is documented in wp-includes/class-wp-image-editor.php */
1321 $quality = apply_filters( 'jpeg_quality', $quality, 'image_resize' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
1322 }
1323
1324 if ( ! is_numeric( $quality ) ) {
1325 $quality = $default_quality;
1326 } else {
1327 $quality = (int) $quality;
1328 }
1329
1330 // Reset out-of-range values to the default, matching WP_Image_Editor::set_quality().
1331 if ( $quality < 0 || $quality > 100 ) {
1332 $quality = $default_quality;
1333 }
1334
1335 // Allow 0, but squash to 1, matching WP_Image_Editor::set_quality().
1336 if ( 0 === $quality ) {
1337 $quality = 1;
1338 }
1339
1340 return $quality;
1341 }
1342 }
1343