class-wp-rest-attachments-controller.php (43823B)
1 <?php 2 /** 3 * REST API: WP_REST_Attachments_Controller class 4 * 5 * @package WordPress 6 * @subpackage REST_API 7 * @since 4.7.0 8 */ 9 10 /** 11 * Core controller used to access attachments via the REST API. 12 * 13 * @since 4.7.0 14 * 15 * @see WP_REST_Posts_Controller 16 */ 17 class WP_REST_Attachments_Controller extends WP_REST_Posts_Controller { 18 19 /** 20 * Registers the routes for attachments. 21 * 22 * @since 5.3.0 23 * 24 * @see register_rest_route() 25 */ 26 public function register_routes() { 27 parent::register_routes(); 28 register_rest_route( 29 $this->namespace, 30 '/' . $this->rest_base . '/(?P<id>[\d]+)/post-process', 31 array( 32 'methods' => WP_REST_Server::CREATABLE, 33 'callback' => array( $this, 'post_process_item' ), 34 'permission_callback' => array( $this, 'post_process_item_permissions_check' ), 35 'args' => array( 36 'id' => array( 37 'description' => __( 'Unique identifier for the attachment.' ), 38 'type' => 'integer', 39 ), 40 'action' => array( 41 'type' => 'string', 42 'enum' => array( 'create-image-subsizes' ), 43 'required' => true, 44 ), 45 ), 46 ) 47 ); 48 register_rest_route( 49 $this->namespace, 50 '/' . $this->rest_base . '/(?P<id>[\d]+)/edit', 51 array( 52 'methods' => WP_REST_Server::CREATABLE, 53 'callback' => array( $this, 'edit_media_item' ), 54 'permission_callback' => array( $this, 'edit_media_item_permissions_check' ), 55 'args' => $this->get_edit_media_item_args(), 56 ) 57 ); 58 } 59 60 /** 61 * Determines the allowed query_vars for a get_items() response and 62 * prepares for WP_Query. 63 * 64 * @since 4.7.0 65 * 66 * @param array $prepared_args Optional. Array of prepared arguments. Default empty array. 67 * @param WP_REST_Request $request Optional. Request to prepare items for. 68 * @return array Array of query arguments. 69 */ 70 protected function prepare_items_query( $prepared_args = array(), $request = null ) { 71 $query_args = parent::prepare_items_query( $prepared_args, $request ); 72 73 if ( empty( $query_args['post_status'] ) ) { 74 $query_args['post_status'] = 'inherit'; 75 } 76 77 $media_types = $this->get_media_types(); 78 79 if ( ! empty( $request['media_type'] ) && isset( $media_types[ $request['media_type'] ] ) ) { 80 $query_args['post_mime_type'] = $media_types[ $request['media_type'] ]; 81 } 82 83 if ( ! empty( $request['mime_type'] ) ) { 84 $parts = explode( '/', $request['mime_type'] ); 85 if ( isset( $media_types[ $parts[0] ] ) && in_array( $request['mime_type'], $media_types[ $parts[0] ], true ) ) { 86 $query_args['post_mime_type'] = $request['mime_type']; 87 } 88 } 89 90 // Filter query clauses to include filenames. 91 if ( isset( $query_args['s'] ) ) { 92 add_filter( 'posts_clauses', '_filter_query_attachment_filenames' ); 93 } 94 95 return $query_args; 96 } 97 98 /** 99 * Checks if a given request has access to create an attachment. 100 * 101 * @since 4.7.0 102 * 103 * @param WP_REST_Request $request Full details about the request. 104 * @return true|WP_Error Boolean true if the attachment may be created, or a WP_Error if not. 105 */ 106 public function create_item_permissions_check( $request ) { 107 $ret = parent::create_item_permissions_check( $request ); 108 109 if ( ! $ret || is_wp_error( $ret ) ) { 110 return $ret; 111 } 112 113 if ( ! current_user_can( 'upload_files' ) ) { 114 return new WP_Error( 115 'rest_cannot_create', 116 __( 'Sorry, you are not allowed to upload media on this site.' ), 117 array( 'status' => 400 ) 118 ); 119 } 120 121 // Attaching media to a post requires ability to edit said post. 122 if ( ! empty( $request['post'] ) && ! current_user_can( 'edit_post', (int) $request['post'] ) ) { 123 return new WP_Error( 124 'rest_cannot_edit', 125 __( 'Sorry, you are not allowed to upload media to this post.' ), 126 array( 'status' => rest_authorization_required_code() ) 127 ); 128 } 129 130 return true; 131 } 132 133 /** 134 * Creates a single attachment. 135 * 136 * @since 4.7.0 137 * 138 * @param WP_REST_Request $request Full details about the request. 139 * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure. 140 */ 141 public function create_item( $request ) { 142 if ( ! empty( $request['post'] ) && in_array( get_post_type( $request['post'] ), array( 'revision', 'attachment' ), true ) ) { 143 return new WP_Error( 144 'rest_invalid_param', 145 __( 'Invalid parent type.' ), 146 array( 'status' => 400 ) 147 ); 148 } 149 150 $insert = $this->insert_attachment( $request ); 151 152 if ( is_wp_error( $insert ) ) { 153 return $insert; 154 } 155 156 $schema = $this->get_item_schema(); 157 158 // Extract by name. 159 $attachment_id = $insert['attachment_id']; 160 $file = $insert['file']; 161 162 if ( isset( $request['alt_text'] ) ) { 163 update_post_meta( $attachment_id, '_wp_attachment_image_alt', sanitize_text_field( $request['alt_text'] ) ); 164 } 165 166 if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) { 167 $meta_update = $this->meta->update_value( $request['meta'], $attachment_id ); 168 169 if ( is_wp_error( $meta_update ) ) { 170 return $meta_update; 171 } 172 } 173 174 $attachment = get_post( $attachment_id ); 175 $fields_update = $this->update_additional_fields_for_object( $attachment, $request ); 176 177 if ( is_wp_error( $fields_update ) ) { 178 return $fields_update; 179 } 180 181 $request->set_param( 'context', 'edit' ); 182 183 /** 184 * Fires after a single attachment is completely created or updated via the REST API. 185 * 186 * @since 5.0.0 187 * 188 * @param WP_Post $attachment Inserted or updated attachment object. 189 * @param WP_REST_Request $request Request object. 190 * @param bool $creating True when creating an attachment, false when updating. 191 */ 192 do_action( 'rest_after_insert_attachment', $attachment, $request, true ); 193 194 wp_after_insert_post( $attachment, false, null ); 195 196 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { 197 // Set a custom header with the attachment_id. 198 // Used by the browser/client to resume creating image sub-sizes after a PHP fatal error. 199 header( 'X-WP-Upload-Attachment-ID: ' . $attachment_id ); 200 } 201 202 // Include media and image functions to get access to wp_generate_attachment_metadata(). 203 require_once ABSPATH . 'wp-admin/includes/media.php'; 204 require_once ABSPATH . 'wp-admin/includes/image.php'; 205 206 // Post-process the upload (create image sub-sizes, make PDF thumbnails, etc.) and insert attachment meta. 207 // At this point the server may run out of resources and post-processing of uploaded images may fail. 208 wp_update_attachment_metadata( $attachment_id, wp_generate_attachment_metadata( $attachment_id, $file ) ); 209 210 $response = $this->prepare_item_for_response( $attachment, $request ); 211 $response = rest_ensure_response( $response ); 212 $response->set_status( 201 ); 213 $response->header( 'Location', rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $attachment_id ) ) ); 214 215 return $response; 216 } 217 218 /** 219 * Inserts the attachment post in the database. Does not update the attachment meta. 220 * 221 * @since 5.3.0 222 * 223 * @param WP_REST_Request $request 224 * @return array|WP_Error 225 */ 226 protected function insert_attachment( $request ) { 227 // Get the file via $_FILES or raw data. 228 $files = $request->get_file_params(); 229 $headers = $request->get_headers(); 230 231 if ( ! empty( $files ) ) { 232 $file = $this->upload_from_file( $files, $headers ); 233 } else { 234 $file = $this->upload_from_data( $request->get_body(), $headers ); 235 } 236 237 if ( is_wp_error( $file ) ) { 238 return $file; 239 } 240 241 $name = wp_basename( $file['file'] ); 242 $name_parts = pathinfo( $name ); 243 $name = trim( substr( $name, 0, -( 1 + strlen( $name_parts['extension'] ) ) ) ); 244 245 $url = $file['url']; 246 $type = $file['type']; 247 $file = $file['file']; 248 249 // Include image functions to get access to wp_read_image_metadata(). 250 require_once ABSPATH . 'wp-admin/includes/image.php'; 251 252 // Use image exif/iptc data for title and caption defaults if possible. 253 $image_meta = wp_read_image_metadata( $file ); 254 255 if ( ! empty( $image_meta ) ) { 256 if ( empty( $request['title'] ) && trim( $image_meta['title'] ) && ! is_numeric( sanitize_title( $image_meta['title'] ) ) ) { 257 $request['title'] = $image_meta['title']; 258 } 259 260 if ( empty( $request['caption'] ) && trim( $image_meta['caption'] ) ) { 261 $request['caption'] = $image_meta['caption']; 262 } 263 } 264 265 $attachment = $this->prepare_item_for_database( $request ); 266 267 $attachment->post_mime_type = $type; 268 $attachment->guid = $url; 269 270 if ( empty( $attachment->post_title ) ) { 271 $attachment->post_title = preg_replace( '/\.[^.]+$/', '', wp_basename( $file ) ); 272 } 273 274 // $post_parent is inherited from $attachment['post_parent']. 275 $id = wp_insert_attachment( wp_slash( (array) $attachment ), $file, 0, true, false ); 276 277 if ( is_wp_error( $id ) ) { 278 if ( 'db_update_error' === $id->get_error_code() ) { 279 $id->add_data( array( 'status' => 500 ) ); 280 } else { 281 $id->add_data( array( 'status' => 400 ) ); 282 } 283 284 return $id; 285 } 286 287 $attachment = get_post( $id ); 288 289 /** 290 * Fires after a single attachment is created or updated via the REST API. 291 * 292 * @since 4.7.0 293 * 294 * @param WP_Post $attachment Inserted or updated attachment 295 * object. 296 * @param WP_REST_Request $request The request sent to the API. 297 * @param bool $creating True when creating an attachment, false when updating. 298 */ 299 do_action( 'rest_insert_attachment', $attachment, $request, true ); 300 301 return array( 302 'attachment_id' => $id, 303 'file' => $file, 304 ); 305 } 306 307 /** 308 * Updates a single attachment. 309 * 310 * @since 4.7.0 311 * 312 * @param WP_REST_Request $request Full details about the request. 313 * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure. 314 */ 315 public function update_item( $request ) { 316 if ( ! empty( $request['post'] ) && in_array( get_post_type( $request['post'] ), array( 'revision', 'attachment' ), true ) ) { 317 return new WP_Error( 318 'rest_invalid_param', 319 __( 'Invalid parent type.' ), 320 array( 'status' => 400 ) 321 ); 322 } 323 324 $attachment_before = get_post( $request['id'] ); 325 $response = parent::update_item( $request ); 326 327 if ( is_wp_error( $response ) ) { 328 return $response; 329 } 330 331 $response = rest_ensure_response( $response ); 332 $data = $response->get_data(); 333 334 if ( isset( $request['alt_text'] ) ) { 335 update_post_meta( $data['id'], '_wp_attachment_image_alt', $request['alt_text'] ); 336 } 337 338 $attachment = get_post( $request['id'] ); 339 340 $fields_update = $this->update_additional_fields_for_object( $attachment, $request ); 341 342 if ( is_wp_error( $fields_update ) ) { 343 return $fields_update; 344 } 345 346 $request->set_param( 'context', 'edit' ); 347 348 /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php */ 349 do_action( 'rest_after_insert_attachment', $attachment, $request, false ); 350 351 wp_after_insert_post( $attachment, true, $attachment_before ); 352 353 $response = $this->prepare_item_for_response( $attachment, $request ); 354 $response = rest_ensure_response( $response ); 355 356 return $response; 357 } 358 359 /** 360 * Performs post processing on an attachment. 361 * 362 * @since 5.3.0 363 * 364 * @param WP_REST_Request $request Full details about the request. 365 * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure. 366 */ 367 public function post_process_item( $request ) { 368 switch ( $request['action'] ) { 369 case 'create-image-subsizes': 370 require_once ABSPATH . 'wp-admin/includes/image.php'; 371 wp_update_image_subsizes( $request['id'] ); 372 break; 373 } 374 375 $request['context'] = 'edit'; 376 377 return $this->prepare_item_for_response( get_post( $request['id'] ), $request ); 378 } 379 380 /** 381 * Checks if a given request can perform post processing on an attachment. 382 * 383 * @since 5.3.0 384 * 385 * @param WP_REST_Request $request Full details about the request. 386 * @return true|WP_Error True if the request has access to update the item, WP_Error object otherwise. 387 */ 388 public function post_process_item_permissions_check( $request ) { 389 return $this->update_item_permissions_check( $request ); 390 } 391 392 /** 393 * Checks if a given request has access to editing media. 394 * 395 * @since 5.5.0 396 * 397 * @param WP_REST_Request $request Full details about the request. 398 * @return true|WP_Error True if the request has read access, WP_Error object otherwise. 399 */ 400 public function edit_media_item_permissions_check( $request ) { 401 if ( ! current_user_can( 'upload_files' ) ) { 402 return new WP_Error( 403 'rest_cannot_edit_image', 404 __( 'Sorry, you are not allowed to upload media on this site.' ), 405 array( 'status' => rest_authorization_required_code() ) 406 ); 407 } 408 409 return $this->update_item_permissions_check( $request ); 410 } 411 412 /** 413 * Applies edits to a media item and creates a new attachment record. 414 * 415 * @since 5.5.0 416 * 417 * @param WP_REST_Request $request Full details about the request. 418 * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure. 419 */ 420 public function edit_media_item( $request ) { 421 require_once ABSPATH . 'wp-admin/includes/image.php'; 422 423 $attachment_id = $request['id']; 424 425 // This also confirms the attachment is an image. 426 $image_file = wp_get_original_image_path( $attachment_id ); 427 $image_meta = wp_get_attachment_metadata( $attachment_id ); 428 429 if ( 430 ! $image_meta || 431 ! $image_file || 432 ! wp_image_file_matches_image_meta( $request['src'], $image_meta, $attachment_id ) 433 ) { 434 return new WP_Error( 435 'rest_unknown_attachment', 436 __( 'Unable to get meta information for file.' ), 437 array( 'status' => 404 ) 438 ); 439 } 440 441 $supported_types = array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp' ); 442 $mime_type = get_post_mime_type( $attachment_id ); 443 if ( ! in_array( $mime_type, $supported_types, true ) ) { 444 return new WP_Error( 445 'rest_cannot_edit_file_type', 446 __( 'This type of file cannot be edited.' ), 447 array( 'status' => 400 ) 448 ); 449 } 450 451 // The `modifiers` param takes precedence over the older format. 452 if ( isset( $request['modifiers'] ) ) { 453 $modifiers = $request['modifiers']; 454 } else { 455 $modifiers = array(); 456 457 if ( ! empty( $request['rotation'] ) ) { 458 $modifiers[] = array( 459 'type' => 'rotate', 460 'args' => array( 461 'angle' => $request['rotation'], 462 ), 463 ); 464 } 465 466 if ( isset( $request['x'], $request['y'], $request['width'], $request['height'] ) ) { 467 $modifiers[] = array( 468 'type' => 'crop', 469 'args' => array( 470 'left' => $request['x'], 471 'top' => $request['y'], 472 'width' => $request['width'], 473 'height' => $request['height'], 474 ), 475 ); 476 } 477 478 if ( 0 === count( $modifiers ) ) { 479 return new WP_Error( 480 'rest_image_not_edited', 481 __( 'The image was not edited. Edit the image before applying the changes.' ), 482 array( 'status' => 400 ) 483 ); 484 } 485 } 486 487 /* 488 * If the file doesn't exist, attempt a URL fopen on the src link. 489 * This can occur with certain file replication plugins. 490 * Keep the original file path to get a modified name later. 491 */ 492 $image_file_to_edit = $image_file; 493 if ( ! file_exists( $image_file_to_edit ) ) { 494 $image_file_to_edit = _load_image_to_edit_path( $attachment_id ); 495 } 496 497 $image_editor = wp_get_image_editor( $image_file_to_edit ); 498 499 if ( is_wp_error( $image_editor ) ) { 500 return new WP_Error( 501 'rest_unknown_image_file_type', 502 __( 'Unable to edit this image.' ), 503 array( 'status' => 500 ) 504 ); 505 } 506 507 foreach ( $modifiers as $modifier ) { 508 $args = $modifier['args']; 509 switch ( $modifier['type'] ) { 510 case 'rotate': 511 // Rotation direction: clockwise vs. counter clockwise. 512 $rotate = 0 - $args['angle']; 513 514 if ( 0 !== $rotate ) { 515 $result = $image_editor->rotate( $rotate ); 516 517 if ( is_wp_error( $result ) ) { 518 return new WP_Error( 519 'rest_image_rotation_failed', 520 __( 'Unable to rotate this image.' ), 521 array( 'status' => 500 ) 522 ); 523 } 524 } 525 526 break; 527 528 case 'crop': 529 $size = $image_editor->get_size(); 530 531 $crop_x = round( ( $size['width'] * $args['left'] ) / 100.0 ); 532 $crop_y = round( ( $size['height'] * $args['top'] ) / 100.0 ); 533 $width = round( ( $size['width'] * $args['width'] ) / 100.0 ); 534 $height = round( ( $size['height'] * $args['height'] ) / 100.0 ); 535 536 if ( $size['width'] !== $width && $size['height'] !== $height ) { 537 $result = $image_editor->crop( $crop_x, $crop_y, $width, $height ); 538 539 if ( is_wp_error( $result ) ) { 540 return new WP_Error( 541 'rest_image_crop_failed', 542 __( 'Unable to crop this image.' ), 543 array( 'status' => 500 ) 544 ); 545 } 546 } 547 548 break; 549 550 } 551 } 552 553 // Calculate the file name. 554 $image_ext = pathinfo( $image_file, PATHINFO_EXTENSION ); 555 $image_name = wp_basename( $image_file, ".{$image_ext}" ); 556 557 // Do not append multiple `-edited` to the file name. 558 // The user may be editing a previously edited image. 559 if ( preg_match( '/-edited(-\d+)?$/', $image_name ) ) { 560 // Remove any `-1`, `-2`, etc. `wp_unique_filename()` will add the proper number. 561 $image_name = preg_replace( '/-edited(-\d+)?$/', '-edited', $image_name ); 562 } else { 563 // Append `-edited` before the extension. 564 $image_name .= '-edited'; 565 } 566 567 $filename = "{$image_name}.{$image_ext}"; 568 569 // Create the uploads sub-directory if needed. 570 $uploads = wp_upload_dir(); 571 572 // Make the file name unique in the (new) upload directory. 573 $filename = wp_unique_filename( $uploads['path'], $filename ); 574 575 // Save to disk. 576 $saved = $image_editor->save( $uploads['path'] . "/$filename" ); 577 578 if ( is_wp_error( $saved ) ) { 579 return $saved; 580 } 581 582 // Create new attachment post. 583 $new_attachment_post = array( 584 'post_mime_type' => $saved['mime-type'], 585 'guid' => $uploads['url'] . "/$filename", 586 'post_title' => $image_name, 587 'post_content' => '', 588 ); 589 590 // Copy post_content, post_excerpt, and post_title from the edited image's attachment post. 591 $attachment_post = get_post( $attachment_id ); 592 593 if ( $attachment_post ) { 594 $new_attachment_post['post_content'] = $attachment_post->post_content; 595 $new_attachment_post['post_excerpt'] = $attachment_post->post_excerpt; 596 $new_attachment_post['post_title'] = $attachment_post->post_title; 597 } 598 599 $new_attachment_id = wp_insert_attachment( wp_slash( $new_attachment_post ), $saved['path'], 0, true ); 600 601 if ( is_wp_error( $new_attachment_id ) ) { 602 if ( 'db_update_error' === $new_attachment_id->get_error_code() ) { 603 $new_attachment_id->add_data( array( 'status' => 500 ) ); 604 } else { 605 $new_attachment_id->add_data( array( 'status' => 400 ) ); 606 } 607 608 return $new_attachment_id; 609 } 610 611 // Copy the image alt text from the edited image. 612 $image_alt = get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ); 613 614 if ( ! empty( $image_alt ) ) { 615 // update_post_meta() expects slashed. 616 update_post_meta( $new_attachment_id, '_wp_attachment_image_alt', wp_slash( $image_alt ) ); 617 } 618 619 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { 620 // Set a custom header with the attachment_id. 621 // Used by the browser/client to resume creating image sub-sizes after a PHP fatal error. 622 header( 'X-WP-Upload-Attachment-ID: ' . $new_attachment_id ); 623 } 624 625 // Generate image sub-sizes and meta. 626 $new_image_meta = wp_generate_attachment_metadata( $new_attachment_id, $saved['path'] ); 627 628 // Copy the EXIF metadata from the original attachment if not generated for the edited image. 629 if ( isset( $image_meta['image_meta'] ) && isset( $new_image_meta['image_meta'] ) && is_array( $new_image_meta['image_meta'] ) ) { 630 // Merge but skip empty values. 631 foreach ( (array) $image_meta['image_meta'] as $key => $value ) { 632 if ( empty( $new_image_meta['image_meta'][ $key ] ) && ! empty( $value ) ) { 633 $new_image_meta['image_meta'][ $key ] = $value; 634 } 635 } 636 } 637 638 // Reset orientation. At this point the image is edited and orientation is correct. 639 if ( ! empty( $new_image_meta['image_meta']['orientation'] ) ) { 640 $new_image_meta['image_meta']['orientation'] = 1; 641 } 642 643 // The attachment_id may change if the site is exported and imported. 644 $new_image_meta['parent_image'] = array( 645 'attachment_id' => $attachment_id, 646 // Path to the originally uploaded image file relative to the uploads directory. 647 'file' => _wp_relative_upload_path( $image_file ), 648 ); 649 650 /** 651 * Filters the meta data for the new image created by editing an existing image. 652 * 653 * @since 5.5.0 654 * 655 * @param array $new_image_meta Meta data for the new image. 656 * @param int $new_attachment_id Attachment post ID for the new image. 657 * @param int $attachment_id Attachment post ID for the edited (parent) image. 658 */ 659 $new_image_meta = apply_filters( 'wp_edited_image_metadata', $new_image_meta, $new_attachment_id, $attachment_id ); 660 661 wp_update_attachment_metadata( $new_attachment_id, $new_image_meta ); 662 663 $response = $this->prepare_item_for_response( get_post( $new_attachment_id ), $request ); 664 $response->set_status( 201 ); 665 $response->header( 'Location', rest_url( sprintf( '%s/%s/%s', $this->namespace, $this->rest_base, $new_attachment_id ) ) ); 666 667 return $response; 668 } 669 670 /** 671 * Prepares a single attachment for create or update. 672 * 673 * @since 4.7.0 674 * 675 * @param WP_REST_Request $request Request object. 676 * @return stdClass|WP_Error Post object. 677 */ 678 protected function prepare_item_for_database( $request ) { 679 $prepared_attachment = parent::prepare_item_for_database( $request ); 680 681 // Attachment caption (post_excerpt internally). 682 if ( isset( $request['caption'] ) ) { 683 if ( is_string( $request['caption'] ) ) { 684 $prepared_attachment->post_excerpt = $request['caption']; 685 } elseif ( isset( $request['caption']['raw'] ) ) { 686 $prepared_attachment->post_excerpt = $request['caption']['raw']; 687 } 688 } 689 690 // Attachment description (post_content internally). 691 if ( isset( $request['description'] ) ) { 692 if ( is_string( $request['description'] ) ) { 693 $prepared_attachment->post_content = $request['description']; 694 } elseif ( isset( $request['description']['raw'] ) ) { 695 $prepared_attachment->post_content = $request['description']['raw']; 696 } 697 } 698 699 if ( isset( $request['post'] ) ) { 700 $prepared_attachment->post_parent = (int) $request['post']; 701 } 702 703 return $prepared_attachment; 704 } 705 706 /** 707 * Prepares a single attachment output for response. 708 * 709 * @since 4.7.0 710 * 711 * @param WP_Post $post Attachment object. 712 * @param WP_REST_Request $request Request object. 713 * @return WP_REST_Response Response object. 714 */ 715 public function prepare_item_for_response( $post, $request ) { 716 $response = parent::prepare_item_for_response( $post, $request ); 717 $fields = $this->get_fields_for_response( $request ); 718 $data = $response->get_data(); 719 720 if ( in_array( 'description', $fields, true ) ) { 721 $data['description'] = array( 722 'raw' => $post->post_content, 723 /** This filter is documented in wp-includes/post-template.php */ 724 'rendered' => apply_filters( 'the_content', $post->post_content ), 725 ); 726 } 727 728 if ( in_array( 'caption', $fields, true ) ) { 729 /** This filter is documented in wp-includes/post-template.php */ 730 $caption = apply_filters( 'get_the_excerpt', $post->post_excerpt, $post ); 731 732 /** This filter is documented in wp-includes/post-template.php */ 733 $caption = apply_filters( 'the_excerpt', $caption ); 734 735 $data['caption'] = array( 736 'raw' => $post->post_excerpt, 737 'rendered' => $caption, 738 ); 739 } 740 741 if ( in_array( 'alt_text', $fields, true ) ) { 742 $data['alt_text'] = get_post_meta( $post->ID, '_wp_attachment_image_alt', true ); 743 } 744 745 if ( in_array( 'media_type', $fields, true ) ) { 746 $data['media_type'] = wp_attachment_is_image( $post->ID ) ? 'image' : 'file'; 747 } 748 749 if ( in_array( 'mime_type', $fields, true ) ) { 750 $data['mime_type'] = $post->post_mime_type; 751 } 752 753 if ( in_array( 'media_details', $fields, true ) ) { 754 $data['media_details'] = wp_get_attachment_metadata( $post->ID ); 755 756 // Ensure empty details is an empty object. 757 if ( empty( $data['media_details'] ) ) { 758 $data['media_details'] = new stdClass; 759 } elseif ( ! empty( $data['media_details']['sizes'] ) ) { 760 761 foreach ( $data['media_details']['sizes'] as $size => &$size_data ) { 762 763 if ( isset( $size_data['mime-type'] ) ) { 764 $size_data['mime_type'] = $size_data['mime-type']; 765 unset( $size_data['mime-type'] ); 766 } 767 768 // Use the same method image_downsize() does. 769 $image_src = wp_get_attachment_image_src( $post->ID, $size ); 770 if ( ! $image_src ) { 771 continue; 772 } 773 774 $size_data['source_url'] = $image_src[0]; 775 } 776 777 $full_src = wp_get_attachment_image_src( $post->ID, 'full' ); 778 779 if ( ! empty( $full_src ) ) { 780 $data['media_details']['sizes']['full'] = array( 781 'file' => wp_basename( $full_src[0] ), 782 'width' => $full_src[1], 783 'height' => $full_src[2], 784 'mime_type' => $post->post_mime_type, 785 'source_url' => $full_src[0], 786 ); 787 } 788 } else { 789 $data['media_details']['sizes'] = new stdClass; 790 } 791 } 792 793 if ( in_array( 'post', $fields, true ) ) { 794 $data['post'] = ! empty( $post->post_parent ) ? (int) $post->post_parent : null; 795 } 796 797 if ( in_array( 'source_url', $fields, true ) ) { 798 $data['source_url'] = wp_get_attachment_url( $post->ID ); 799 } 800 801 if ( in_array( 'missing_image_sizes', $fields, true ) ) { 802 require_once ABSPATH . 'wp-admin/includes/image.php'; 803 $data['missing_image_sizes'] = array_keys( wp_get_missing_image_subsizes( $post->ID ) ); 804 } 805 806 $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; 807 808 $data = $this->filter_response_by_context( $data, $context ); 809 810 $links = $response->get_links(); 811 812 // Wrap the data in a response object. 813 $response = rest_ensure_response( $data ); 814 815 foreach ( $links as $rel => $rel_links ) { 816 foreach ( $rel_links as $link ) { 817 $response->add_link( $rel, $link['href'], $link['attributes'] ); 818 } 819 } 820 821 /** 822 * Filters an attachment returned from the REST API. 823 * 824 * Allows modification of the attachment right before it is returned. 825 * 826 * @since 4.7.0 827 * 828 * @param WP_REST_Response $response The response object. 829 * @param WP_Post $post The original attachment post. 830 * @param WP_REST_Request $request Request used to generate the response. 831 */ 832 return apply_filters( 'rest_prepare_attachment', $response, $post, $request ); 833 } 834 835 /** 836 * Retrieves the attachment's schema, conforming to JSON Schema. 837 * 838 * @since 4.7.0 839 * 840 * @return array Item schema as an array. 841 */ 842 public function get_item_schema() { 843 if ( $this->schema ) { 844 return $this->add_additional_fields_schema( $this->schema ); 845 } 846 847 $schema = parent::get_item_schema(); 848 849 $schema['properties']['alt_text'] = array( 850 'description' => __( 'Alternative text to display when attachment is not displayed.' ), 851 'type' => 'string', 852 'context' => array( 'view', 'edit', 'embed' ), 853 'arg_options' => array( 854 'sanitize_callback' => 'sanitize_text_field', 855 ), 856 ); 857 858 $schema['properties']['caption'] = array( 859 'description' => __( 'The attachment caption.' ), 860 'type' => 'object', 861 'context' => array( 'view', 'edit', 'embed' ), 862 'arg_options' => array( 863 'sanitize_callback' => null, // Note: sanitization implemented in self::prepare_item_for_database(). 864 'validate_callback' => null, // Note: validation implemented in self::prepare_item_for_database(). 865 ), 866 'properties' => array( 867 'raw' => array( 868 'description' => __( 'Caption for the attachment, as it exists in the database.' ), 869 'type' => 'string', 870 'context' => array( 'edit' ), 871 ), 872 'rendered' => array( 873 'description' => __( 'HTML caption for the attachment, transformed for display.' ), 874 'type' => 'string', 875 'context' => array( 'view', 'edit', 'embed' ), 876 'readonly' => true, 877 ), 878 ), 879 ); 880 881 $schema['properties']['description'] = array( 882 'description' => __( 'The attachment description.' ), 883 'type' => 'object', 884 'context' => array( 'view', 'edit' ), 885 'arg_options' => array( 886 'sanitize_callback' => null, // Note: sanitization implemented in self::prepare_item_for_database(). 887 'validate_callback' => null, // Note: validation implemented in self::prepare_item_for_database(). 888 ), 889 'properties' => array( 890 'raw' => array( 891 'description' => __( 'Description for the attachment, as it exists in the database.' ), 892 'type' => 'string', 893 'context' => array( 'edit' ), 894 ), 895 'rendered' => array( 896 'description' => __( 'HTML description for the attachment, transformed for display.' ), 897 'type' => 'string', 898 'context' => array( 'view', 'edit' ), 899 'readonly' => true, 900 ), 901 ), 902 ); 903 904 $schema['properties']['media_type'] = array( 905 'description' => __( 'Attachment type.' ), 906 'type' => 'string', 907 'enum' => array( 'image', 'file' ), 908 'context' => array( 'view', 'edit', 'embed' ), 909 'readonly' => true, 910 ); 911 912 $schema['properties']['mime_type'] = array( 913 'description' => __( 'The attachment MIME type.' ), 914 'type' => 'string', 915 'context' => array( 'view', 'edit', 'embed' ), 916 'readonly' => true, 917 ); 918 919 $schema['properties']['media_details'] = array( 920 'description' => __( 'Details about the media file, specific to its type.' ), 921 'type' => 'object', 922 'context' => array( 'view', 'edit', 'embed' ), 923 'readonly' => true, 924 ); 925 926 $schema['properties']['post'] = array( 927 'description' => __( 'The ID for the associated post of the attachment.' ), 928 'type' => 'integer', 929 'context' => array( 'view', 'edit' ), 930 ); 931 932 $schema['properties']['source_url'] = array( 933 'description' => __( 'URL to the original attachment file.' ), 934 'type' => 'string', 935 'format' => 'uri', 936 'context' => array( 'view', 'edit', 'embed' ), 937 'readonly' => true, 938 ); 939 940 $schema['properties']['missing_image_sizes'] = array( 941 'description' => __( 'List of the missing image sizes of the attachment.' ), 942 'type' => 'array', 943 'items' => array( 'type' => 'string' ), 944 'context' => array( 'edit' ), 945 'readonly' => true, 946 ); 947 948 unset( $schema['properties']['password'] ); 949 950 $this->schema = $schema; 951 952 return $this->add_additional_fields_schema( $this->schema ); 953 } 954 955 /** 956 * Handles an upload via raw POST data. 957 * 958 * @since 4.7.0 959 * 960 * @param array $data Supplied file data. 961 * @param array $headers HTTP headers from the request. 962 * @return array|WP_Error Data from wp_handle_sideload(). 963 */ 964 protected function upload_from_data( $data, $headers ) { 965 if ( empty( $data ) ) { 966 return new WP_Error( 967 'rest_upload_no_data', 968 __( 'No data supplied.' ), 969 array( 'status' => 400 ) 970 ); 971 } 972 973 if ( empty( $headers['content_type'] ) ) { 974 return new WP_Error( 975 'rest_upload_no_content_type', 976 __( 'No Content-Type supplied.' ), 977 array( 'status' => 400 ) 978 ); 979 } 980 981 if ( empty( $headers['content_disposition'] ) ) { 982 return new WP_Error( 983 'rest_upload_no_content_disposition', 984 __( 'No Content-Disposition supplied.' ), 985 array( 'status' => 400 ) 986 ); 987 } 988 989 $filename = self::get_filename_from_disposition( $headers['content_disposition'] ); 990 991 if ( empty( $filename ) ) { 992 return new WP_Error( 993 'rest_upload_invalid_disposition', 994 __( 'Invalid Content-Disposition supplied. Content-Disposition needs to be formatted as `attachment; filename="image.png"` or similar.' ), 995 array( 'status' => 400 ) 996 ); 997 } 998 999 if ( ! empty( $headers['content_md5'] ) ) { 1000 $content_md5 = array_shift( $headers['content_md5'] ); 1001 $expected = trim( $content_md5 ); 1002 $actual = md5( $data ); 1003 1004 if ( $expected !== $actual ) { 1005 return new WP_Error( 1006 'rest_upload_hash_mismatch', 1007 __( 'Content hash did not match expected.' ), 1008 array( 'status' => 412 ) 1009 ); 1010 } 1011 } 1012 1013 // Get the content-type. 1014 $type = array_shift( $headers['content_type'] ); 1015 1016 // Include filesystem functions to get access to wp_tempnam() and wp_handle_sideload(). 1017 require_once ABSPATH . 'wp-admin/includes/file.php'; 1018 1019 // Save the file. 1020 $tmpfname = wp_tempnam( $filename ); 1021 1022 $fp = fopen( $tmpfname, 'w+' ); 1023 1024 if ( ! $fp ) { 1025 return new WP_Error( 1026 'rest_upload_file_error', 1027 __( 'Could not open file handle.' ), 1028 array( 'status' => 500 ) 1029 ); 1030 } 1031 1032 fwrite( $fp, $data ); 1033 fclose( $fp ); 1034 1035 // Now, sideload it in. 1036 $file_data = array( 1037 'error' => null, 1038 'tmp_name' => $tmpfname, 1039 'name' => $filename, 1040 'type' => $type, 1041 ); 1042 1043 $size_check = self::check_upload_size( $file_data ); 1044 if ( is_wp_error( $size_check ) ) { 1045 return $size_check; 1046 } 1047 1048 $overrides = array( 1049 'test_form' => false, 1050 ); 1051 1052 $sideloaded = wp_handle_sideload( $file_data, $overrides ); 1053 1054 if ( isset( $sideloaded['error'] ) ) { 1055 @unlink( $tmpfname ); 1056 1057 return new WP_Error( 1058 'rest_upload_sideload_error', 1059 $sideloaded['error'], 1060 array( 'status' => 500 ) 1061 ); 1062 } 1063 1064 return $sideloaded; 1065 } 1066 1067 /** 1068 * Parses filename from a Content-Disposition header value. 1069 * 1070 * As per RFC6266: 1071 * 1072 * content-disposition = "Content-Disposition" ":" 1073 * disposition-type *( ";" disposition-parm ) 1074 * 1075 * disposition-type = "inline" | "attachment" | disp-ext-type 1076 * ; case-insensitive 1077 * disp-ext-type = token 1078 * 1079 * disposition-parm = filename-parm | disp-ext-parm 1080 * 1081 * filename-parm = "filename" "=" value 1082 * | "filename*" "=" ext-value 1083 * 1084 * disp-ext-parm = token "=" value 1085 * | ext-token "=" ext-value 1086 * ext-token = <the characters in token, followed by "*"> 1087 * 1088 * @since 4.7.0 1089 * 1090 * @link https://tools.ietf.org/html/rfc2388 1091 * @link https://tools.ietf.org/html/rfc6266 1092 * 1093 * @param string[] $disposition_header List of Content-Disposition header values. 1094 * @return string|null Filename if available, or null if not found. 1095 */ 1096 public static function get_filename_from_disposition( $disposition_header ) { 1097 // Get the filename. 1098 $filename = null; 1099 1100 foreach ( $disposition_header as $value ) { 1101 $value = trim( $value ); 1102 1103 if ( strpos( $value, ';' ) === false ) { 1104 continue; 1105 } 1106 1107 list( $type, $attr_parts ) = explode( ';', $value, 2 ); 1108 1109 $attr_parts = explode( ';', $attr_parts ); 1110 $attributes = array(); 1111 1112 foreach ( $attr_parts as $part ) { 1113 if ( strpos( $part, '=' ) === false ) { 1114 continue; 1115 } 1116 1117 list( $key, $value ) = explode( '=', $part, 2 ); 1118 1119 $attributes[ trim( $key ) ] = trim( $value ); 1120 } 1121 1122 if ( empty( $attributes['filename'] ) ) { 1123 continue; 1124 } 1125 1126 $filename = trim( $attributes['filename'] ); 1127 1128 // Unquote quoted filename, but after trimming. 1129 if ( substr( $filename, 0, 1 ) === '"' && substr( $filename, -1, 1 ) === '"' ) { 1130 $filename = substr( $filename, 1, -1 ); 1131 } 1132 } 1133 1134 return $filename; 1135 } 1136 1137 /** 1138 * Retrieves the query params for collections of attachments. 1139 * 1140 * @since 4.7.0 1141 * 1142 * @return array Query parameters for the attachment collection as an array. 1143 */ 1144 public function get_collection_params() { 1145 $params = parent::get_collection_params(); 1146 $params['status']['default'] = 'inherit'; 1147 $params['status']['items']['enum'] = array( 'inherit', 'private', 'trash' ); 1148 $media_types = $this->get_media_types(); 1149 1150 $params['media_type'] = array( 1151 'default' => null, 1152 'description' => __( 'Limit result set to attachments of a particular media type.' ), 1153 'type' => 'string', 1154 'enum' => array_keys( $media_types ), 1155 ); 1156 1157 $params['mime_type'] = array( 1158 'default' => null, 1159 'description' => __( 'Limit result set to attachments of a particular MIME type.' ), 1160 'type' => 'string', 1161 ); 1162 1163 return $params; 1164 } 1165 1166 /** 1167 * Handles an upload via multipart/form-data ($_FILES). 1168 * 1169 * @since 4.7.0 1170 * 1171 * @param array $files Data from the `$_FILES` superglobal. 1172 * @param array $headers HTTP headers from the request. 1173 * @return array|WP_Error Data from wp_handle_upload(). 1174 */ 1175 protected function upload_from_file( $files, $headers ) { 1176 if ( empty( $files ) ) { 1177 return new WP_Error( 1178 'rest_upload_no_data', 1179 __( 'No data supplied.' ), 1180 array( 'status' => 400 ) 1181 ); 1182 } 1183 1184 // Verify hash, if given. 1185 if ( ! empty( $headers['content_md5'] ) ) { 1186 $content_md5 = array_shift( $headers['content_md5'] ); 1187 $expected = trim( $content_md5 ); 1188 $actual = md5_file( $files['file']['tmp_name'] ); 1189 1190 if ( $expected !== $actual ) { 1191 return new WP_Error( 1192 'rest_upload_hash_mismatch', 1193 __( 'Content hash did not match expected.' ), 1194 array( 'status' => 412 ) 1195 ); 1196 } 1197 } 1198 1199 // Pass off to WP to handle the actual upload. 1200 $overrides = array( 1201 'test_form' => false, 1202 ); 1203 1204 // Bypasses is_uploaded_file() when running unit tests. 1205 if ( defined( 'DIR_TESTDATA' ) && DIR_TESTDATA ) { 1206 $overrides['action'] = 'wp_handle_mock_upload'; 1207 } 1208 1209 $size_check = self::check_upload_size( $files['file'] ); 1210 if ( is_wp_error( $size_check ) ) { 1211 return $size_check; 1212 } 1213 1214 // Include filesystem functions to get access to wp_handle_upload(). 1215 require_once ABSPATH . 'wp-admin/includes/file.php'; 1216 1217 $file = wp_handle_upload( $files['file'], $overrides ); 1218 1219 if ( isset( $file['error'] ) ) { 1220 return new WP_Error( 1221 'rest_upload_unknown_error', 1222 $file['error'], 1223 array( 'status' => 500 ) 1224 ); 1225 } 1226 1227 return $file; 1228 } 1229 1230 /** 1231 * Retrieves the supported media types. 1232 * 1233 * Media types are considered the MIME type category. 1234 * 1235 * @since 4.7.0 1236 * 1237 * @return array Array of supported media types. 1238 */ 1239 protected function get_media_types() { 1240 $media_types = array(); 1241 1242 foreach ( get_allowed_mime_types() as $mime_type ) { 1243 $parts = explode( '/', $mime_type ); 1244 1245 if ( ! isset( $media_types[ $parts[0] ] ) ) { 1246 $media_types[ $parts[0] ] = array(); 1247 } 1248 1249 $media_types[ $parts[0] ][] = $mime_type; 1250 } 1251 1252 return $media_types; 1253 } 1254 1255 /** 1256 * Determine if uploaded file exceeds space quota on multisite. 1257 * 1258 * Replicates check_upload_size(). 1259 * 1260 * @since 4.9.8 1261 * 1262 * @param array $file $_FILES array for a given file. 1263 * @return true|WP_Error True if can upload, error for errors. 1264 */ 1265 protected function check_upload_size( $file ) { 1266 if ( ! is_multisite() ) { 1267 return true; 1268 } 1269 1270 if ( get_site_option( 'upload_space_check_disabled' ) ) { 1271 return true; 1272 } 1273 1274 $space_left = get_upload_space_available(); 1275 1276 $file_size = filesize( $file['tmp_name'] ); 1277 1278 if ( $space_left < $file_size ) { 1279 return new WP_Error( 1280 'rest_upload_limited_space', 1281 /* translators: %s: Required disk space in kilobytes. */ 1282 sprintf( __( 'Not enough space to upload. %s KB needed.' ), number_format( ( $file_size - $space_left ) / KB_IN_BYTES ) ), 1283 array( 'status' => 400 ) 1284 ); 1285 } 1286 1287 if ( $file_size > ( KB_IN_BYTES * get_site_option( 'fileupload_maxk', 1500 ) ) ) { 1288 return new WP_Error( 1289 'rest_upload_file_too_big', 1290 /* translators: %s: Maximum allowed file size in kilobytes. */ 1291 sprintf( __( 'This file is too big. Files must be less than %s KB in size.' ), get_site_option( 'fileupload_maxk', 1500 ) ), 1292 array( 'status' => 400 ) 1293 ); 1294 } 1295 1296 // Include multisite admin functions to get access to upload_is_user_over_quota(). 1297 require_once ABSPATH . 'wp-admin/includes/ms.php'; 1298 1299 if ( upload_is_user_over_quota( false ) ) { 1300 return new WP_Error( 1301 'rest_upload_user_quota_exceeded', 1302 __( 'You have used your space quota. Please delete files before uploading.' ), 1303 array( 'status' => 400 ) 1304 ); 1305 } 1306 1307 return true; 1308 } 1309 1310 /** 1311 * Gets the request args for the edit item route. 1312 * 1313 * @since 5.5.0 1314 * 1315 * @return array 1316 */ 1317 protected function get_edit_media_item_args() { 1318 return array( 1319 'src' => array( 1320 'description' => __( 'URL to the edited image file.' ), 1321 'type' => 'string', 1322 'format' => 'uri', 1323 'required' => true, 1324 ), 1325 'modifiers' => array( 1326 'description' => __( 'Array of image edits.' ), 1327 'type' => 'array', 1328 'minItems' => 1, 1329 'items' => array( 1330 'description' => __( 'Image edit.' ), 1331 'type' => 'object', 1332 'required' => array( 1333 'type', 1334 'args', 1335 ), 1336 'oneOf' => array( 1337 array( 1338 'title' => __( 'Rotation' ), 1339 'properties' => array( 1340 'type' => array( 1341 'description' => __( 'Rotation type.' ), 1342 'type' => 'string', 1343 'enum' => array( 'rotate' ), 1344 ), 1345 'args' => array( 1346 'description' => __( 'Rotation arguments.' ), 1347 'type' => 'object', 1348 'required' => array( 1349 'angle', 1350 ), 1351 'properties' => array( 1352 'angle' => array( 1353 'description' => __( 'Angle to rotate clockwise in degrees.' ), 1354 'type' => 'number', 1355 ), 1356 ), 1357 ), 1358 ), 1359 ), 1360 array( 1361 'title' => __( 'Crop' ), 1362 'properties' => array( 1363 'type' => array( 1364 'description' => __( 'Crop type.' ), 1365 'type' => 'string', 1366 'enum' => array( 'crop' ), 1367 ), 1368 'args' => array( 1369 'description' => __( 'Crop arguments.' ), 1370 'type' => 'object', 1371 'required' => array( 1372 'left', 1373 'top', 1374 'width', 1375 'height', 1376 ), 1377 'properties' => array( 1378 'left' => array( 1379 'description' => __( 'Horizontal position from the left to begin the crop as a percentage of the image width.' ), 1380 'type' => 'number', 1381 ), 1382 'top' => array( 1383 'description' => __( 'Vertical position from the top to begin the crop as a percentage of the image height.' ), 1384 'type' => 'number', 1385 ), 1386 'width' => array( 1387 'description' => __( 'Width of the crop as a percentage of the image width.' ), 1388 'type' => 'number', 1389 ), 1390 'height' => array( 1391 'description' => __( 'Height of the crop as a percentage of the image height.' ), 1392 'type' => 'number', 1393 ), 1394 ), 1395 ), 1396 ), 1397 ), 1398 ), 1399 ), 1400 ), 1401 'rotation' => array( 1402 'description' => __( 'The amount to rotate the image clockwise in degrees. DEPRECATED: Use `modifiers` instead.' ), 1403 'type' => 'integer', 1404 'minimum' => 0, 1405 'exclusiveMinimum' => true, 1406 'maximum' => 360, 1407 'exclusiveMaximum' => true, 1408 ), 1409 'x' => array( 1410 'description' => __( 'As a percentage of the image, the x position to start the crop from. DEPRECATED: Use `modifiers` instead.' ), 1411 'type' => 'number', 1412 'minimum' => 0, 1413 'maximum' => 100, 1414 ), 1415 'y' => array( 1416 'description' => __( 'As a percentage of the image, the y position to start the crop from. DEPRECATED: Use `modifiers` instead.' ), 1417 'type' => 'number', 1418 'minimum' => 0, 1419 'maximum' => 100, 1420 ), 1421 'width' => array( 1422 'description' => __( 'As a percentage of the image, the width to crop the image to. DEPRECATED: Use `modifiers` instead.' ), 1423 'type' => 'number', 1424 'minimum' => 0, 1425 'maximum' => 100, 1426 ), 1427 'height' => array( 1428 'description' => __( 'As a percentage of the image, the height to crop the image to. DEPRECATED: Use `modifiers` instead.' ), 1429 'type' => 'number', 1430 'minimum' => 0, 1431 'maximum' => 100, 1432 ), 1433 ); 1434 } 1435 1436 }