PluginProbe
Gutenberg / 22.9.0
Gutenberg v22.9.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 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 22.9.0, at lib/media/class-gutenberg-rest-attachments-controller.php

535 lines 18.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 // Override the parent's sideload route so that 'scaled' is included
24 // in the image_size enum. Without the override, core's handler
25 // validates first and rejects 'scaled' before ours is tried.
26 $valid_image_sizes = array_keys( wp_get_registered_image_subsizes() );
27
28 // Special case to set 'original_image' in attachment metadata.
29 $valid_image_sizes[] = 'original';
30 // Client-side big image threshold: sideload the scaled version.
31 $valid_image_sizes[] = 'scaled';
32 // Used for PDF thumbnails.
33 $valid_image_sizes[] = 'full';
34
35 register_rest_route(
36 $this->namespace,
37 '/' . $this->rest_base . '/(?P<id>[\d]+)/sideload',
38 array(
39 array(
40 'methods' => WP_REST_Server::CREATABLE,
41 'callback' => array( $this, 'sideload_item' ),
42 'permission_callback' => array( $this, 'sideload_item_permissions_check' ),
43 'args' => array(
44 'id' => array(
45 'description' => __( 'Unique identifier for the attachment.', 'gutenberg' ),
46 'type' => 'integer',
47 ),
48 'image_size' => array(
49 'description' => __( 'Image size.', 'gutenberg' ),
50 'type' => 'string',
51 'enum' => $valid_image_sizes,
52 'required' => true,
53 ),
54 ),
55 ),
56 'allow_batch' => $this->allow_batch,
57 'schema' => array( $this, 'get_public_item_schema' ),
58 ),
59 true // Override core's route so 'scaled' is included in the enum.
60 );
61
62 register_rest_route(
63 $this->namespace,
64 '/' . $this->rest_base . '/(?P<id>[\d]+)/finalize',
65 array(
66 array(
67 'methods' => WP_REST_Server::CREATABLE,
68 'callback' => array( $this, 'finalize_item' ),
69 'permission_callback' => array( $this, 'edit_media_item_permissions_check' ),
70 'args' => array(
71 'id' => array(
72 'description' => __( 'Unique identifier for the attachment.', 'gutenberg' ),
73 'type' => 'integer',
74 ),
75 ),
76 ),
77 'allow_batch' => $this->allow_batch,
78 'schema' => array( $this, 'get_public_item_schema' ),
79 )
80 );
81 }
82
83 /**
84 * Checks if a given request has access to create an attachment.
85 *
86 * Skips the server-side image type support check when the client
87 * will handle image processing (generate_sub_sizes is false).
88 *
89 * @param WP_REST_Request $request Full details about the request.
90 * @return true|WP_Error True if the request has access to create items, WP_Error object otherwise.
91 */
92 public function create_item_permissions_check( $request ) {
93 if ( false === $request['generate_sub_sizes'] ) {
94 add_filter( 'wp_prevent_unsupported_mime_type_uploads', '__return_false' );
95 }
96
97 $result = parent::create_item_permissions_check( $request );
98
99 if ( false === $request['generate_sub_sizes'] ) {
100 remove_filter( 'wp_prevent_unsupported_mime_type_uploads', '__return_false' );
101 }
102
103 return $result;
104 }
105
106 /**
107 * Retrieves an array of endpoint arguments from the item schema for the controller.
108 *
109 * @param string $method Optional. HTTP method of the request. The arguments for `CREATABLE` requests are
110 * checked for required values and may fall-back to a given default, this is not done
111 * on `EDITABLE` requests. Default WP_REST_Server::CREATABLE.
112 * @return array Endpoint arguments.
113 */
114 public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CREATABLE ) {
115 $args = rest_get_endpoint_args_for_schema( $this->get_item_schema(), $method );
116
117 if ( WP_REST_Server::CREATABLE === $method ) {
118 $args['generate_sub_sizes'] = array(
119 'type' => 'boolean',
120 'default' => true,
121 'description' => __( 'Whether to generate image sub sizes.', 'gutenberg' ),
122 );
123 $args['convert_format'] = array(
124 'type' => 'boolean',
125 'default' => true,
126 'description' => __( 'Whether to convert image formats.', 'gutenberg' ),
127 );
128 }
129
130 return $args;
131 }
132
133 /**
134 * Retrieves the attachment's schema, conforming to JSON Schema.
135 *
136 * Adds exif_orientation field to the schema.
137 *
138 * @return array Item schema data.
139 */
140 public function get_item_schema() {
141 $schema = parent::get_item_schema();
142
143 $schema['properties']['exif_orientation'] = array(
144 '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' ),
145 'type' => 'integer',
146 'context' => array( 'edit' ),
147 'readonly' => true,
148 );
149
150 return $schema;
151 }
152
153 /**
154 * Prepares a single attachment output for response.
155 *
156 * Ensures 'missing_image_sizes' is set for PDFs and not just images.
157 * Adds 'exif_orientation' for images that need client-side rotation.
158 *
159 * @param WP_Post $item Attachment object.
160 * @param WP_REST_Request $request Request object.
161 * @return WP_REST_Response Response object.
162 */
163 public function prepare_item_for_response( $item, $request ): WP_REST_Response {
164 $response = parent::prepare_item_for_response( $item, $request );
165
166 $data = $response->get_data();
167
168 $fields = $this->get_fields_for_response( $request );
169
170 // Add EXIF orientation for images.
171 if ( rest_is_field_included( 'exif_orientation', $fields ) ) {
172 if ( wp_attachment_is_image( $item ) ) {
173 $metadata = wp_get_attachment_metadata( $item->ID, true );
174
175 // Get the EXIF orientation from the image metadata.
176 // This is stored by wp_read_image_metadata() during upload.
177 // Values:
178 // 0 = undefined (no EXIF data), treat as no rotation needed
179 // 1 = normal (no rotation needed)
180 // 2-8 = various rotations/flips needed
181 $orientation = 1; // Default: no rotation needed.
182 if (
183 is_array( $metadata ) &&
184 isset( $metadata['image_meta']['orientation'] ) &&
185 (int) $metadata['image_meta']['orientation'] > 0
186 ) {
187 $orientation = (int) $metadata['image_meta']['orientation'];
188 }
189
190 $data['exif_orientation'] = $orientation;
191 }
192 }
193
194 if (
195 rest_is_field_included( 'missing_image_sizes', $fields ) &&
196 empty( $data['missing_image_sizes'] )
197 ) {
198 $mime_type = get_post_mime_type( $item );
199
200 if ( 'application/pdf' === $mime_type ) {
201 $metadata = wp_get_attachment_metadata( $item->ID, true );
202
203 if ( ! is_array( $metadata ) ) {
204 $metadata = array();
205 }
206
207 $metadata['sizes'] = $metadata['sizes'] ?? array();
208
209 $fallback_sizes = array(
210 'thumbnail',
211 'medium',
212 'large',
213 );
214
215 // The filter might have been added by ::create_item().
216 remove_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 );
217
218 /** This filter is documented in wp-admin/includes/image.php */
219 $fallback_sizes = apply_filters( 'fallback_intermediate_image_sizes', $fallback_sizes, $metadata ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
220
221 $registered_sizes = wp_get_registered_image_subsizes();
222 $merged_sizes = array_keys( array_intersect_key( $registered_sizes, array_flip( $fallback_sizes ) ) );
223
224 $missing_image_sizes = array_diff( $merged_sizes, array_keys( $metadata['sizes'] ) );
225 $data['missing_image_sizes'] = $missing_image_sizes;
226 }
227 }
228
229 $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
230 $data = $this->add_additional_fields_to_object( $data, $request );
231 $data = $this->filter_response_by_context( $data, $context );
232
233 $links = $response->get_links();
234
235 $response = rest_ensure_response( $data );
236
237 foreach ( $links as $rel => $rel_links ) {
238 foreach ( $rel_links as $link ) {
239 $response->add_link( $rel, $link['href'], $link['attributes'] );
240 }
241 }
242
243 return $response;
244 }
245
246 /**
247 * Creates a single attachment.
248 *
249 * @param WP_REST_Request $request Full details about the request.
250 * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
251 */
252 public function create_item( $request ) {
253 if ( ! $request['generate_sub_sizes'] ) {
254 add_filter( 'intermediate_image_sizes_advanced', '__return_empty_array', 100 );
255 add_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 );
256 // Disable server-side EXIF rotation so the client can handle it.
257 // This preserves the original orientation value in the metadata.
258 add_filter( 'wp_image_maybe_exif_rotate', '__return_false', 100 );
259 // Disable server-side big image scaling since the client handles it.
260 add_filter( 'big_image_size_threshold', '__return_zero', 100 );
261 }
262
263 if ( ! $request['convert_format'] ) {
264 add_filter( 'image_editor_output_format', '__return_empty_array', 100 );
265 }
266
267 $response = parent::create_item( $request );
268
269 remove_filter( 'intermediate_image_sizes_advanced', '__return_empty_array', 100 );
270 remove_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 );
271 remove_filter( 'wp_image_maybe_exif_rotate', '__return_false', 100 );
272 remove_filter( 'big_image_size_threshold', '__return_zero', 100 );
273 remove_filter( 'image_editor_output_format', '__return_empty_array', 100 );
274
275 return $response;
276 }
277
278 /**
279 * Finalizes an attachment after client-side media processing.
280 *
281 * Triggers the {@see 'wp_generate_attachment_metadata'} filter so that
282 * server-side plugins can process the attachment after all client-side
283 * operations (upload, thumbnail generation, sideloads) are complete.
284 *
285 * @param WP_REST_Request $request Full details about the request.
286 * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
287 */
288 public function finalize_item( WP_REST_Request $request ) {
289 $attachment_id = $request['id'];
290
291 $post = $this->get_post( $attachment_id );
292
293 if ( is_wp_error( $post ) ) {
294 return $post;
295 }
296
297 $metadata = wp_get_attachment_metadata( $attachment_id );
298
299 if ( ! is_array( $metadata ) ) {
300 $metadata = array();
301 }
302
303 /**
304 * Filters the attachment metadata after client-side processing.
305 *
306 * This re-applies the wp_generate_attachment_metadata filter so that
307 * server-side plugins (e.g. those adding custom image sizes or
308 * processing metadata) can run after client-side uploads are complete.
309 *
310 * @param array $metadata Attachment metadata.
311 * @param int $attachment_id Attachment ID.
312 * @param string $context Context: 'create' or 'update'.
313 */
314 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
315 $metadata = apply_filters( 'wp_generate_attachment_metadata', $metadata, $attachment_id, 'update' );
316
317 wp_update_attachment_metadata( $attachment_id, $metadata );
318
319 $response_request = new WP_REST_Request(
320 WP_REST_Server::READABLE,
321 rest_get_route_for_post( $attachment_id )
322 );
323
324 $response_request['context'] = 'edit';
325
326 if ( isset( $request['_fields'] ) ) {
327 $response_request['_fields'] = $request['_fields'];
328 }
329
330 return $this->prepare_item_for_response( get_post( $attachment_id ), $response_request );
331 }
332
333 /**
334 * Checks if a given request has access to sideload a file.
335 *
336 * Sideloading a file for an existing attachment
337 * requires both update and create permissions.
338 *
339 * @param WP_REST_Request $request Full details about the request.
340 * @return true|WP_Error True if the request has access to update the item, WP_Error object otherwise.
341 */
342 public function sideload_item_permissions_check( $request ) {
343 return $this->edit_media_item_permissions_check( $request );
344 }
345
346 /**
347 * Filters {@see 'wp_unique_filename'} during sideloads.
348 *
349 * {@see wp_unique_filename()} will always add numeric suffix if the name looks like a sub-size to avoid conflicts.
350 *
351 * Adding this closure to the filter helps work around this safeguard.
352 *
353 * Example: when uploading myphoto.jpeg, WordPress normally creates myphoto-150x150.jpeg,
354 * and when uploading myphoto-150x150.jpeg, it will be renamed to myphoto-150x150-1.jpeg
355 * However, here it is desired not to add the suffix in order to maintain the same
356 * naming convention as if the file was uploaded regularly.
357 *
358 * @link https://github.com/WordPress/wordpress-develop/blob/30954f7ac0840cfdad464928021d7f380940c347/src/wp-includes/functions.php#L2576-L2582
359 *
360 * @param string $filename Unique file name.
361 * @param string $dir Directory path.
362 * @param int|string $number The highest number that was used to make the file name unique
363 * or an empty string if unused.
364 * @param string $attachment_filename Original attachment file name.
365 * @return string Filtered file name.
366 */
367 private static function filter_wp_unique_filename( $filename, $dir, $number, $attachment_filename ) {
368 if ( empty( $number ) || ! $attachment_filename ) {
369 return $filename;
370 }
371
372 $ext = pathinfo( $filename, PATHINFO_EXTENSION );
373 $name = pathinfo( $filename, PATHINFO_FILENAME );
374 $orig_name = pathinfo( $attachment_filename, PATHINFO_FILENAME );
375
376 if ( ! $ext || ! $name ) {
377 return $filename;
378 }
379
380 $matches = array();
381 if ( preg_match( '/(.*)(-\d+x\d+|-scaled)-' . $number . '$/', $name, $matches ) ) {
382 $filename_without_suffix = $matches[1] . $matches[2] . ".$ext";
383 if ( $matches[1] === $orig_name && ! file_exists( "$dir/$filename_without_suffix" ) ) {
384 return $filename_without_suffix;
385 }
386 }
387
388 return $filename;
389 }
390
391 /**
392 * Side-loads a media file without creating an attachment.
393 *
394 * @param WP_REST_Request $request Full details about the request.
395 * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
396 */
397 public function sideload_item( WP_REST_Request $request ) {
398 $attachment_id = $request['id'];
399
400 $post = $this->get_post( $attachment_id );
401
402 if ( is_wp_error( $post ) ) {
403 return $post;
404 }
405
406 if (
407 ! wp_attachment_is_image( $post ) &&
408 ! wp_attachment_is( 'pdf', $post )
409 ) {
410 return new WP_Error(
411 'rest_post_invalid_id',
412 __( 'Invalid post ID, only images and PDFs can be sideloaded.', 'gutenberg' ),
413 array( 'status' => 400 )
414 );
415 }
416
417 if ( ! $request['convert_format'] ) {
418 // Prevent image conversion as that is done client-side.
419 add_filter( 'image_editor_output_format', '__return_empty_array', 100 );
420 }
421
422 // Get the file via $_FILES or raw data.
423 $files = $request->get_file_params();
424 $headers = $request->get_headers();
425
426 /*
427 * wp_unique_filename() will always add numeric suffix if the name looks like a sub-size to avoid conflicts.
428 * See https://github.com/WordPress/wordpress-develop/blob/30954f7ac0840cfdad464928021d7f380940c347/src/wp-includes/functions.php#L2576-L2582
429 * With the following filter we can work around this safeguard.
430 */
431
432 $attachment_filename = get_attached_file( $attachment_id, true );
433 $attachment_filename = $attachment_filename ? wp_basename( $attachment_filename ) : null;
434
435 /**
436 * @param string $filename Unique file name.
437 * @param string $ext File extension. Example: ".png".
438 * @param string $dir Directory path.
439 * @param callable|null $unique_filename_callback Callback function that generates the unique file name.
440 * @param string[] $alt_filenames Array of alternate file names that were checked for collisions.
441 * @param int|string $number The highest number that was used to make the file name unique
442 * or an empty string if unused.
443 * @return string Filtered file name.
444 */
445 $filter_filename = static function ( $filename, $ext, $dir, $unique_filename_callback, $alt_filenames, $number ) use ( $attachment_filename ) {
446 return self::filter_wp_unique_filename( $filename, $dir, $number, $attachment_filename );
447 };
448
449 add_filter( 'wp_unique_filename', $filter_filename, 10, 6 );
450
451 $parent_post = get_post_parent( $attachment_id );
452
453 $time = null;
454
455 // Matches logic in media_handle_upload().
456 // The post date doesn't usually matter for pages, so don't backdate this upload.
457 if ( $parent_post && 'page' !== $parent_post->post_type && substr( $parent_post->post_date, 0, 4 ) > 0 ) {
458 $time = $parent_post->post_date;
459 }
460
461 if ( ! empty( $files ) ) {
462 $file = $this->upload_from_file( $files, $headers, $time );
463 } else {
464 $file = $this->upload_from_data( $request->get_body(), $headers, $time );
465 }
466
467 remove_filter( 'wp_unique_filename', $filter_filename );
468 remove_filter( 'image_editor_output_format', '__return_empty_array', 100 );
469
470 if ( is_wp_error( $file ) ) {
471 return $file;
472 }
473
474 $type = $file['type'];
475 $path = $file['file'];
476
477 $image_size = $request['image_size'];
478
479 $metadata = wp_get_attachment_metadata( $attachment_id, true );
480
481 if ( ! $metadata ) {
482 $metadata = array();
483 }
484
485 if ( 'original' === $image_size ) {
486 $metadata['original_image'] = wp_basename( $path );
487 } elseif ( 'scaled' === $image_size ) {
488 // The current attached file is the original; record it as original_image.
489 $current_file = get_attached_file( $attachment_id, true );
490 $metadata['original_image'] = wp_basename( $current_file );
491
492 // Update the attached file to point to the scaled version.
493 update_attached_file( $attachment_id, $path );
494
495 $size = wp_getimagesize( $path );
496
497 $metadata['width'] = $size ? $size[0] : 0;
498 $metadata['height'] = $size ? $size[1] : 0;
499 $metadata['filesize'] = wp_filesize( $path );
500 $metadata['file'] = _wp_relative_upload_path( $path );
501 } else {
502 $metadata['sizes'] = $metadata['sizes'] ?? array();
503
504 $size = wp_getimagesize( $path );
505
506 $metadata['sizes'][ $image_size ] = array(
507 'width' => $size ? $size[0] : 0,
508 'height' => $size ? $size[1] : 0,
509 'file' => wp_basename( $path ),
510 'mime-type' => $type,
511 'filesize' => wp_filesize( $path ),
512 );
513 }
514
515 wp_update_attachment_metadata( $attachment_id, $metadata );
516
517 $response_request = new WP_REST_Request(
518 WP_REST_Server::READABLE,
519 rest_get_route_for_post( $attachment_id )
520 );
521
522 $response_request['context'] = 'edit';
523
524 if ( isset( $request['_fields'] ) ) {
525 $response_request['_fields'] = $request['_fields'];
526 }
527
528 $response = $this->prepare_item_for_response( get_post( $attachment_id ), $response_request );
529
530 $response->header( 'Location', rest_url( rest_get_route_for_post( $attachment_id ) ) );
531
532 return $response;
533 }
534 }
535