PluginProbe
Gutenberg / 24.1.0
Gutenberg v24.1.0
24.1.0 24.0.0 23.9.1 23.9.0 23.8.0 23.7.2 23.7.1 23.7.0 23.6.1 23.6.2 23.6.0 23.5.3 23.5.2 23.5.1 23.5.0 23.4.0 23.3.2 23.3.1 23.3.0 23.2.0 23.2.1 23.2.2 23.1.1 23.1.0 23.0.1 All 404 releases
gutenberg / lib / media / class-gutenberg-rest-attachments-controller.php

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

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