PluginProbe
Gutenberg / 23.2.1
Gutenberg v23.2.1
23.9.1 23.9.0 23.8.0 23.7.2 23.7.1 23.7.0 23.6.1 23.6.2 23.6.0 23.5.3 23.5.2 23.5.1 23.5.0 23.4.0 23.3.2 23.3.1 23.3.0 23.2.0 23.2.1 23.2.2 23.1.1 23.1.0 23.0.1 12.6.0 7.4.0 All 402 releases
gutenberg / lib / media / class-gutenberg-rest-attachments-controller.php

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

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