[ Index ]

PHP Cross Reference of WordPress Trunk (Updated Daily)

Search

title

Body

[close]

/wp-includes/rest-api/endpoints/ -> class-wp-rest-attachments-controller.php (source)

   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       * Whether the controller supports batching.
  21       *
  22       * @since 5.9.0
  23       * @var false
  24       */
  25      protected $allow_batch = false;
  26  
  27      /**
  28       * Image size token for the source-format original preserved alongside a
  29       * client-generated derivative (e.g. the HEIC file kept next to its JPEG).
  30       *
  31       * Used both in the `/sideload` route schema and when dispatching the
  32       * sideloaded file to its metadata key, so the two never drift apart.
  33       *
  34       * @since 7.1.0
  35       * @var string
  36       */
  37      const IMAGE_SIZE_SOURCE_ORIGINAL = 'source_original';
  38  
  39      /**
  40       * Metadata key holding the basename of the source-format original.
  41       *
  42       * Deliberately specific so it never collides with the generic `original`
  43       * or `original_image` keys other flows write to.
  44       *
  45       * @since 7.1.0
  46       * @var string
  47       */
  48      const META_KEY_SOURCE_IMAGE = 'source_image';
  49  
  50      /**
  51       * Registers the routes for attachments.
  52       *
  53       * @since 5.3.0
  54       *
  55       * @see register_rest_route()
  56       */
  57  	public function register_routes() {
  58          parent::register_routes();
  59          register_rest_route(
  60              $this->namespace,
  61              '/' . $this->rest_base . '/(?P<id>[\d]+)/post-process',
  62              array(
  63                  'methods'             => WP_REST_Server::CREATABLE,
  64                  'callback'            => array( $this, 'post_process_item' ),
  65                  'permission_callback' => array( $this, 'post_process_item_permissions_check' ),
  66                  'args'                => array(
  67                      'id'     => array(
  68                          'description' => __( 'Unique identifier for the attachment.' ),
  69                          'type'        => 'integer',
  70                      ),
  71                      'action' => array(
  72                          'type'     => 'string',
  73                          'enum'     => array( 'create-image-subsizes' ),
  74                          'required' => true,
  75                      ),
  76                  ),
  77              )
  78          );
  79          register_rest_route(
  80              $this->namespace,
  81              '/' . $this->rest_base . '/(?P<id>[\d]+)/edit',
  82              array(
  83                  'methods'             => WP_REST_Server::CREATABLE,
  84                  'callback'            => array( $this, 'edit_media_item' ),
  85                  'permission_callback' => array( $this, 'edit_media_item_permissions_check' ),
  86                  'args'                => $this->get_edit_media_item_args(),
  87              )
  88          );
  89  
  90          if ( wp_is_client_side_media_processing_enabled() ) {
  91              register_rest_route(
  92                  $this->namespace,
  93                  '/' . $this->rest_base . '/(?P<id>[\d]+)/sideload',
  94                  array(
  95                      array(
  96                          'methods'             => WP_REST_Server::CREATABLE,
  97                          'callback'            => array( $this, 'sideload_item' ),
  98                          'permission_callback' => array( $this, 'sideload_item_permissions_check' ),
  99                          'args'                => array(
 100                              'id'             => array(
 101                                  'description' => __( 'Unique identifier for the attachment.' ),
 102                                  'type'        => 'integer',
 103                              ),
 104                              'image_size'     => array(
 105                                  'description'       => __( 'Image size. Can be a single size name or an array of size names to register the same file under multiple sizes.' ),
 106                                  'type'              => array( 'string', 'array' ),
 107                                  'items'             => array(
 108                                      'type' => 'string',
 109                                  ),
 110                                  'required'          => true,
 111                                  /*
 112                                   * A custom callback is used instead of the default enum validation
 113                                   * because rest_is_array() treats scalar strings as single-element
 114                                   * lists (via wp_parse_list()), so a [ 'string', 'array' ] type alone
 115                                   * cannot enforce the enum. The callback validates each item against
 116                                   * the current list of registered sizes, which reflects sizes added
 117                                   * after route registration (e.g. via add_image_size()).
 118                                   */
 119                                  'validate_callback' => static function ( $value, $request, $param ) {
 120                                      $valid_sizes   = array_keys( wp_get_registered_image_subsizes() );
 121                                      $valid_sizes[] = 'original';
 122                                      $valid_sizes[] = 'scaled';
 123                                      $valid_sizes[] = 'full';
 124                                      // Source-format original (e.g. the HEIC kept alongside its JPEG derivative).
 125                                      $valid_sizes[] = self::IMAGE_SIZE_SOURCE_ORIGINAL;
 126                                      // Converted-video companions for an animated GIF (the MP4/WebM and its poster).
 127                                      $valid_sizes[] = 'animated_video';
 128                                      $valid_sizes[] = 'animated_video_poster';
 129  
 130                                      $items = is_string( $value ) ? array( $value ) : ( is_array( $value ) ? $value : null );
 131                                      if ( null === $items ) {
 132                                          return new WP_Error(
 133                                              'rest_invalid_type',
 134                                              /* translators: %s: Parameter name. */
 135                                              sprintf( __( '%s must be a string or an array of strings.' ), $param )
 136                                          );
 137                                      }
 138  
 139                                      foreach ( $items as $item ) {
 140                                          if ( ! is_string( $item ) || ! in_array( $item, $valid_sizes, true ) ) {
 141                                              return new WP_Error(
 142                                                  'rest_not_in_enum',
 143                                                  /* translators: %s: Parameter name. */
 144                                                  sprintf( __( '%s contains an invalid image size.' ), $param )
 145                                              );
 146                                          }
 147                                      }
 148  
 149                                      return true;
 150                                  },
 151                              ),
 152                              'convert_format' => array(
 153                                  'type'        => 'boolean',
 154                                  'default'     => true,
 155                                  'description' => __( 'Whether to convert image formats.' ),
 156                              ),
 157                          ),
 158                      ),
 159                      'allow_batch' => $this->allow_batch,
 160                      'schema'      => array( $this, 'get_public_item_schema' ),
 161                  )
 162              );
 163  
 164              register_rest_route(
 165                  $this->namespace,
 166                  '/' . $this->rest_base . '/(?P<id>[\d]+)/finalize',
 167                  array(
 168                      array(
 169                          'methods'             => WP_REST_Server::CREATABLE,
 170                          'callback'            => array( $this, 'finalize_item' ),
 171                          'permission_callback' => array( $this, 'edit_media_item_permissions_check' ),
 172                          'args'                => array(
 173                              'id'        => array(
 174                                  'description' => __( 'Unique identifier for the attachment.' ),
 175                                  'type'        => 'integer',
 176                              ),
 177                              'sub_sizes' => array(
 178                                  'description' => __( 'Array of sub-size metadata collected from sideload responses.' ),
 179                                  'type'        => 'array',
 180                                  'default'     => array(),
 181                                  'items'       => array(
 182                                      'type'       => 'object',
 183                                      'properties' => array(
 184                                          'image_size'     => array(
 185                                              'description' => __( 'Size name, or an array of size names when a single file is registered under multiple sizes with matching dimensions.' ),
 186                                              'type'        => array( 'string', 'array' ),
 187                                              'items'       => array(
 188                                                  'type' => 'string',
 189                                              ),
 190                                              'required'    => true,
 191                                          ),
 192                                          'width'          => array(
 193                                              'type'    => 'integer',
 194                                              'minimum' => 1,
 195                                          ),
 196                                          'height'         => array(
 197                                              'type'    => 'integer',
 198                                              'minimum' => 1,
 199                                          ),
 200                                          'file'           => array(
 201                                              'type' => 'string',
 202                                          ),
 203                                          'mime_type'      => array(
 204                                              'type'    => 'string',
 205                                              'pattern' => '^image/.*',
 206                                          ),
 207                                          'filesize'       => array(
 208                                              'type'    => 'integer',
 209                                              'minimum' => 1,
 210                                          ),
 211                                          'original_image' => array(
 212                                              'type' => 'string',
 213                                          ),
 214                                      ),
 215                                  ),
 216                              ),
 217                          ),
 218                      ),
 219                      'allow_batch' => $this->allow_batch,
 220                      'schema'      => array( $this, 'get_public_item_schema' ),
 221                  )
 222              );
 223          }
 224      }
 225  
 226      /**
 227       * Retrieves the query params for the attachments collection.
 228       *
 229       * @since 7.1.0
 230       *
 231       * @param string $method Optional. HTTP method of the request.
 232       *                       The arguments for `CREATABLE` requests are
 233       *                       checked for required values and may fall-back to a given default.
 234       *                       Default WP_REST_Server::CREATABLE.
 235       * @return array<string, array<string, mixed>> Endpoint arguments.
 236       */
 237  	public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CREATABLE ) {
 238          $args = parent::get_endpoint_args_for_item_schema( $method );
 239  
 240          if ( WP_REST_Server::CREATABLE !== $method ) {
 241              return $args;
 242          }
 243  
 244          $args['generate_sub_sizes'] = array(
 245              'type'        => 'boolean',
 246              'default'     => true,
 247              'description' => __( 'Whether to generate image sub sizes.' ),
 248          );
 249  
 250          $args['convert_format'] = array(
 251              'type'        => 'boolean',
 252              'default'     => true,
 253              'description' => __( 'Whether to convert image formats.' ),
 254          );
 255  
 256          $args['url'] = array(
 257              'type'              => 'string',
 258              'format'            => 'uri',
 259              'description'       => __( 'URL of an external image to sideload into the media library, instead of uploading a file.' ),
 260              'sanitize_callback' => 'sanitize_url',
 261              'validate_callback' => static function ( $url, $request, $param ) {
 262                  /*
 263                   * A custom validate_callback replaces the default
 264                   * rest_validate_request_arg(), so re-apply it first to keep
 265                   * the schema checks (string type, uri format) enforced.
 266                   */
 267                  $valid = rest_validate_request_arg( $url, $request, $param );
 268                  if ( is_wp_error( $valid ) ) {
 269                      return $valid;
 270                  }
 271  
 272                  /*
 273                   * Reject URLs that are not safe to request server-side. wp_http_validate_url()
 274                   * enforces an HTTP(S) scheme and blocks private, local, and otherwise
 275                   * disallowed hosts, guarding the sideload against SSRF.
 276                   */
 277                  if ( false === wp_http_validate_url( $url ) ) {
 278                      return new WP_Error(
 279                          'rest_invalid_url',
 280                          __( 'Invalid URL. Provide a valid, publicly reachable HTTP or HTTPS image URL.' ),
 281                          array( 'status' => 400 )
 282                      );
 283                  }
 284  
 285                  return true;
 286              },
 287          );
 288  
 289          return $args;
 290      }
 291  
 292      /**
 293       * Determines the allowed query_vars for a get_items() response and
 294       * prepares for WP_Query.
 295       *
 296       * @since 4.7.0
 297       * @since 6.9.0 Extends the `media_type` and `mime_type` request arguments to support array values.
 298       *
 299       * @param array           $prepared_args Optional. Array of prepared arguments. Default empty array.
 300       * @param WP_REST_Request $request       Optional. Request to prepare items for.
 301       * @return array Array of query arguments.
 302       */
 303  	protected function prepare_items_query( $prepared_args = array(), $request = null ) {
 304          $query_args = parent::prepare_items_query( $prepared_args, $request );
 305  
 306          if ( empty( $query_args['post_status'] ) ) {
 307              $query_args['post_status'] = 'inherit';
 308          }
 309  
 310          $all_mime_types = array();
 311          $media_types    = $this->get_media_types();
 312  
 313          if ( ! empty( $request['media_type'] ) && is_array( $request['media_type'] ) ) {
 314              foreach ( $request['media_type'] as $type ) {
 315                  if ( isset( $media_types[ $type ] ) ) {
 316                      $all_mime_types = array_merge( $all_mime_types, $media_types[ $type ] );
 317                  }
 318              }
 319          }
 320  
 321          if ( ! empty( $request['mime_type'] ) && is_array( $request['mime_type'] ) ) {
 322              foreach ( $request['mime_type'] as $mime_type ) {
 323                  $parts = explode( '/', $mime_type );
 324                  if ( isset( $media_types[ $parts[0] ] ) && in_array( $mime_type, $media_types[ $parts[0] ], true ) ) {
 325                      $all_mime_types[] = $mime_type;
 326                  }
 327              }
 328          }
 329  
 330          if ( ! empty( $all_mime_types ) ) {
 331              $query_args['post_mime_type'] = array_values( array_unique( $all_mime_types ) );
 332          }
 333  
 334          // Filter query clauses to include filenames.
 335          if ( isset( $query_args['s'] ) ) {
 336              add_filter( 'wp_allow_query_attachment_by_filename', '__return_true' );
 337          }
 338  
 339          return $query_args;
 340      }
 341  
 342      /**
 343       * Checks if a given request has access to create an attachment.
 344       *
 345       * @since 4.7.0
 346       *
 347       * @param WP_REST_Request $request Full details about the request.
 348       * @return true|WP_Error Boolean true if the attachment may be created, or a WP_Error if not.
 349       */
 350  	public function create_item_permissions_check( $request ) {
 351          $ret = parent::create_item_permissions_check( $request );
 352  
 353          if ( ! $ret || is_wp_error( $ret ) ) {
 354              return $ret;
 355          }
 356  
 357          if ( ! current_user_can( 'upload_files' ) ) {
 358              return new WP_Error(
 359                  'rest_cannot_create',
 360                  __( 'Sorry, you are not allowed to upload media on this site.' ),
 361                  array( 'status' => 400 )
 362              );
 363          }
 364  
 365          // Attaching media to a post requires ability to edit said post.
 366          if ( ! empty( $request['post'] ) && ! current_user_can( 'edit_post', (int) $request['post'] ) ) {
 367              return new WP_Error(
 368                  'rest_cannot_edit',
 369                  __( 'Sorry, you are not allowed to upload media to this post.' ),
 370                  array( 'status' => rest_authorization_required_code() )
 371              );
 372          }
 373          $files = $request->get_file_params();
 374  
 375          /**
 376           * Filter whether the server should prevent uploads for image types it doesn't support. Default true.
 377           *
 378           * Developers can use this filter to enable uploads of certain image types. By default image types that are not
 379           * supported by the server are prevented from being uploaded.
 380           *
 381           * @since 6.8.0
 382           *
 383           * @param bool        $check_mime Whether to prevent uploads of unsupported image types.
 384           * @param string|null $mime_type  The mime type of the file being uploaded (if available).
 385           */
 386          $prevent_unsupported_uploads = apply_filters( 'wp_prevent_unsupported_mime_type_uploads', true, $files['file']['type'] ?? null );
 387  
 388          /*
 389           * When the client handles image processing (generate_sub_sizes is false),
 390           * skip the server-side image editor support check. This check exists
 391           * because the server cannot process the image, so it is only relaxed when
 392           * client side media processing is enabled and something else can. Asking
 393           * to skip sub sizes on a site without it does not make an unsupported
 394           * image type any more usable.
 395           */
 396          if ( wp_is_client_side_media_processing_enabled() && false === $request['generate_sub_sizes'] ) {
 397              $prevent_unsupported_uploads = false;
 398          }
 399  
 400          /*
 401           * Always allow still HEIC/HEIF uploads through even if the server's
 402           * image editor doesn't support them. The client-side canvas fallback
 403           * handles processing using the browser's native HEVC decoder.
 404           *
 405           * The '-sequence' variants (multi-frame Live Photos) are deliberately
 406           * excluded: neither the server nor the browser fallback can process
 407           * them yet, so they should fall through to the standard unsupported
 408           * mime-type error rather than be stored unprocessable.
 409           */
 410          $still_heic_mime_types = array( 'image/heic', 'image/heif' );
 411  
 412          if (
 413              $prevent_unsupported_uploads &&
 414              ! empty( $files['file']['type'] ) &&
 415              in_array( $files['file']['type'], $still_heic_mime_types, true )
 416          ) {
 417              $prevent_unsupported_uploads = false;
 418          }
 419  
 420          // If the upload is an image, check if the server can handle the mime type.
 421          if (
 422              $prevent_unsupported_uploads &&
 423              isset( $files['file']['type'] ) &&
 424              str_starts_with( $files['file']['type'], 'image/' )
 425          ) {
 426              // List of non-resizable image formats.
 427              $editor_non_resizable_formats = array(
 428                  'image/svg+xml',
 429              );
 430  
 431              // Check if the image editor supports the type or ignore if it isn't a format resizable by an editor.
 432              if (
 433                  ! in_array( $files['file']['type'], $editor_non_resizable_formats, true ) &&
 434                  ! wp_image_editor_supports( array( 'mime_type' => $files['file']['type'] ) )
 435              ) {
 436                  return new WP_Error(
 437                      'rest_upload_image_type_not_supported',
 438                      __( 'The web server cannot generate responsive image sizes for this image. Convert it to JPEG or PNG before uploading.' ),
 439                      array( 'status' => 400 )
 440                  );
 441              }
 442          }
 443  
 444          return true;
 445      }
 446  
 447      /**
 448       * Creates a single attachment.
 449       *
 450       * @since 4.7.0
 451       * @since 7.1.0 Added the `generate_sub_sizes`, `convert_format`, and `url` parameters.
 452       *
 453       * @param WP_REST_Request $request Full details about the request.
 454       * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
 455       */
 456  	public function create_item( $request ) {
 457          if ( ! empty( $request['post'] ) && in_array( get_post_type( $request['post'] ), array( 'revision', 'attachment' ), true ) ) {
 458              return new WP_Error(
 459                  'rest_invalid_param',
 460                  __( 'Invalid parent type.' ),
 461                  array( 'status' => 400 )
 462              );
 463          }
 464  
 465          // Handle generate_sub_sizes parameter.
 466          if ( false === $request['generate_sub_sizes'] ) {
 467              add_filter( 'intermediate_image_sizes_advanced', '__return_empty_array', 100 );
 468              add_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 );
 469              // Disable server-side EXIF rotation so the client can handle it.
 470              // This preserves the original orientation value in the metadata.
 471              add_filter( 'wp_image_maybe_exif_rotate', '__return_false', 100 );
 472              // Disable server-side "big image" downscaling; the client supplies its
 473              // own scaled version via the sideload endpoint. Scaling here would
 474              // create a conflicting "-scaled" file and orphan the full-size upload.
 475              add_filter( 'big_image_size_threshold', '__return_false', 100 );
 476          }
 477  
 478          // Handle convert_format parameter.
 479          if ( false === $request['convert_format'] ) {
 480              add_filter( 'image_editor_output_format', '__return_empty_array', 100 );
 481          }
 482  
 483          /*
 484           * When a URL is supplied instead of an uploaded file, sideload the
 485           * remote image on the server. This avoids a cross-origin browser fetch,
 486           * which fails under cross-origin isolation. The sub-size and scaling
 487           * filters applied above still govern whether derivatives are generated.
 488           */
 489          if ( ! empty( $request['url'] ) ) {
 490              $response = $this->create_item_from_url( $request );
 491              $this->remove_client_side_media_processing_filters();
 492              return $response;
 493          }
 494  
 495          $insert = $this->insert_attachment( $request );
 496  
 497          if ( is_wp_error( $insert ) ) {
 498              $this->remove_client_side_media_processing_filters();
 499              return $insert;
 500          }
 501  
 502          $schema = $this->get_item_schema();
 503  
 504          // Extract by name.
 505          $attachment_id = $insert['attachment_id'];
 506          $file          = $insert['file'];
 507  
 508          if ( isset( $request['alt_text'] ) ) {
 509              update_post_meta( $attachment_id, '_wp_attachment_image_alt', sanitize_text_field( $request['alt_text'] ) );
 510          }
 511  
 512          if ( ! empty( $schema['properties']['featured_media'] ) && isset( $request['featured_media'] ) ) {
 513              $thumbnail_update = $this->handle_featured_media( $request['featured_media'], $attachment_id );
 514  
 515              if ( is_wp_error( $thumbnail_update ) ) {
 516                  $this->remove_client_side_media_processing_filters();
 517                  return $thumbnail_update;
 518              }
 519          }
 520  
 521          if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) {
 522              $meta_update = $this->meta->update_value( $request['meta'], $attachment_id );
 523  
 524              if ( is_wp_error( $meta_update ) ) {
 525                  $this->remove_client_side_media_processing_filters();
 526                  return $meta_update;
 527              }
 528          }
 529  
 530          $attachment    = get_post( $attachment_id );
 531          $fields_update = $this->update_additional_fields_for_object( $attachment, $request );
 532  
 533          if ( is_wp_error( $fields_update ) ) {
 534              $this->remove_client_side_media_processing_filters();
 535              return $fields_update;
 536          }
 537  
 538          $terms_update = $this->handle_terms( $attachment_id, $request );
 539  
 540          if ( is_wp_error( $terms_update ) ) {
 541              $this->remove_client_side_media_processing_filters();
 542              return $terms_update;
 543          }
 544  
 545          $request->set_param( 'context', 'edit' );
 546  
 547          /**
 548           * Fires after a single attachment is completely created or updated via the REST API.
 549           *
 550           * @since 5.0.0
 551           *
 552           * @param WP_Post         $attachment Inserted or updated attachment object.
 553           * @param WP_REST_Request $request    Request object.
 554           * @param bool            $creating   True when creating an attachment, false when updating.
 555           */
 556          do_action( 'rest_after_insert_attachment', $attachment, $request, true );
 557  
 558          wp_after_insert_post( $attachment, false, null );
 559  
 560          if ( wp_is_serving_rest_request() ) {
 561              /*
 562               * Set a custom header with the attachment_id.
 563               * Used by the browser/client to resume creating image sub-sizes after a PHP fatal error.
 564               */
 565              header( 'X-WP-Upload-Attachment-ID: ' . $attachment_id );
 566          }
 567  
 568          // Include media and image functions to get access to wp_generate_attachment_metadata().
 569          require_once  ABSPATH . 'wp-admin/includes/media.php';
 570          require_once  ABSPATH . 'wp-admin/includes/image.php';
 571  
 572          /*
 573           * Post-process the upload (create image sub-sizes, make PDF thumbnails, etc.) and insert attachment meta.
 574           * At this point the server may run out of resources and post-processing of uploaded images may fail.
 575           */
 576          wp_update_attachment_metadata( $attachment_id, wp_generate_attachment_metadata( $attachment_id, $file ) );
 577  
 578          $this->remove_client_side_media_processing_filters();
 579  
 580          $response = $this->prepare_item_for_response( $attachment, $request );
 581          $response = rest_ensure_response( $response );
 582          $response->set_status( 201 );
 583          $response->header( 'Location', rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $attachment_id ) ) );
 584  
 585          return $response;
 586      }
 587  
 588      /**
 589       * Sideloads an external image from a URL into the media library.
 590       *
 591       * Downloads the remote file on the server, avoiding a cross-origin browser
 592       * fetch that fails under cross-origin isolation. Whether sub-sizes are
 593       * generated is governed by the filters applied in create_item().
 594       *
 595       * @since 7.1.0
 596       *
 597       * @param WP_REST_Request $request Full details about the request.
 598       * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
 599       */
 600  	protected function create_item_from_url( $request ) {
 601          // Sideloading downloads and stores a file, so require the upload capability.
 602          if ( ! current_user_can( 'upload_files' ) ) {
 603              return new WP_Error(
 604                  'rest_cannot_create',
 605                  __( 'Sorry, you are not allowed to upload media on this site.' ),
 606                  array( 'status' => rest_authorization_required_code() )
 607              );
 608          }
 609  
 610          require_once  ABSPATH . 'wp-admin/includes/file.php';
 611          require_once  ABSPATH . 'wp-admin/includes/media.php';
 612          require_once  ABSPATH . 'wp-admin/includes/image.php';
 613  
 614          $url     = $request['url'];
 615          $post_id = ! empty( $request['post'] ) ? (int) $request['post'] : 0;
 616  
 617          // Derive the filename from the URL path before downloading anything.
 618          $url_path = wp_parse_url( $url, PHP_URL_PATH );
 619          $filename = $url_path ? wp_basename( $url_path ) : '';
 620          if ( '' === $filename ) {
 621              return new WP_Error(
 622                  'rest_invalid_url',
 623                  __( 'Could not determine a filename from the provided URL.' ),
 624                  array( 'status' => 400 )
 625              );
 626          }
 627  
 628          /*
 629           * Only download URLs whose extension maps to an allowed image MIME type.
 630           * The sideload handler would reject other types anyway (via
 631           * wp_check_filetype_and_ext()), but checking first avoids downloading
 632           * files that can never be accepted, such as PHP scripts.
 633           */
 634          $filetype = wp_check_filetype( $filename );
 635          if ( ! $filetype['type'] || ! str_starts_with( $filetype['type'], 'image/' ) ) {
 636              return new WP_Error(
 637                  'rest_invalid_url',
 638                  __( 'The provided URL does not point to a supported image file.' ),
 639                  array( 'status' => 400 )
 640              );
 641          }
 642  
 643          /*
 644           * Cap the download at the same size the site would accept as a direct
 645           * upload. check_upload_size() only applies on multisite, so without a
 646           * ceiling here a single site has no limit at all on this path: the
 647           * `upload_max_filesize` and `post_max_size` directives bound a request
 648           * body, not a fetch the server makes itself.
 649           *
 650           * When `wp_max_upload_size` returns 0, no ceiling is applied.
 651           */
 652          $max_size = (int) wp_max_upload_size();
 653  
 654          /*
 655           * Download the remote file with WordPress's HTTP API, which validates
 656           * the host and blocks requests to private or local addresses. This is
 657           * the same primitive core's media_sideload_image() relies on.
 658           *
 659           * `limit_response_size` stops the transfer once the limit is passed,
 660           * so an oversized remote file is never written to disk in full. One
 661           * byte over the ceiling is enough to fail the size check below.
 662           */
 663          $limit_response_size = static function ( $args ) use ( $max_size ) {
 664              $args['limit_response_size'] = $max_size + 1;
 665              return $args;
 666          };
 667  
 668          if ( $max_size > 0 ) {
 669              add_filter( 'http_request_args', $limit_response_size );
 670          }
 671  
 672          $tmp_file = download_url( $url );
 673  
 674          if ( $max_size > 0 ) {
 675              remove_filter( 'http_request_args', $limit_response_size );
 676          }
 677  
 678          if ( is_wp_error( $tmp_file ) ) {
 679              return $tmp_file;
 680          }
 681  
 682          $file_array = array(
 683              'name'     => $filename,
 684              'tmp_name' => $tmp_file,
 685          );
 686  
 687          $size_check = self::check_upload_size( $file_array );
 688          if ( is_wp_error( $size_check ) ) {
 689              if ( file_exists( $tmp_file ) ) {
 690                  wp_delete_file( $tmp_file );
 691              }
 692              return $size_check;
 693          }
 694  
 695          if ( $max_size > 0 && wp_filesize( $tmp_file ) > $max_size ) {
 696              if ( file_exists( $tmp_file ) ) {
 697                  wp_delete_file( $tmp_file );
 698              }
 699  
 700              return new WP_Error(
 701                  'rest_upload_file_too_big',
 702                  /* translators: %s: Maximum allowed file size in kilobytes. */
 703                  sprintf( __( 'This file is too big. Files must be less than %s KB in size.' ), number_format( $max_size / KB_IN_BYTES ) ),
 704                  array( 'status' => 400 )
 705              );
 706          }
 707  
 708          $attachment_id = media_handle_sideload( $file_array, $post_id );
 709  
 710          if ( is_wp_error( $attachment_id ) ) {
 711              /*
 712               * media_handle_sideload() deletes the temp file on success; remove
 713               * it explicitly when the sideload fails.
 714               */
 715              if ( file_exists( $tmp_file ) ) {
 716                  wp_delete_file( $tmp_file );
 717              }
 718              return $attachment_id;
 719          }
 720  
 721          $attachment = get_post( $attachment_id );
 722  
 723          $request->set_param( 'context', 'edit' );
 724  
 725          /*
 726           * media_handle_sideload() fires the standard insert hooks (including
 727           * wp_after_insert_post), but not the REST-specific action, so fire it
 728           * here for parity with the uploaded-file path in create_item().
 729           */
 730          /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php */
 731          do_action( 'rest_after_insert_attachment', $attachment, $request, true );
 732  
 733          $response = $this->prepare_item_for_response( $attachment, $request );
 734          $response->set_status( 201 );
 735          $response->header( 'Location', rest_url( rest_get_route_for_post( $attachment_id ) ) );
 736  
 737          return $response;
 738      }
 739  
 740      /**
 741       * Removes filters added for client-side media processing.
 742       *
 743       * @since 7.1.0
 744       */
 745  	private function remove_client_side_media_processing_filters(): void {
 746          remove_filter( 'intermediate_image_sizes_advanced', '__return_empty_array', 100 );
 747          remove_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 );
 748          remove_filter( 'wp_image_maybe_exif_rotate', '__return_false', 100 );
 749          remove_filter( 'image_editor_output_format', '__return_empty_array', 100 );
 750          remove_filter( 'big_image_size_threshold', '__return_false', 100 );
 751      }
 752  
 753      /**
 754       * Inserts the attachment post in the database. Does not update the attachment meta.
 755       *
 756       * @since 5.3.0
 757       *
 758       * @param WP_REST_Request $request
 759       * @return array|WP_Error
 760       */
 761  	protected function insert_attachment( $request ) {
 762          // Get the file via $_FILES or raw data.
 763          $files   = $request->get_file_params();
 764          $headers = $request->get_headers();
 765  
 766          $time = null;
 767  
 768          // Matches logic in media_handle_upload().
 769          if ( ! empty( $request['post'] ) ) {
 770              $post = get_post( $request['post'] );
 771              // The post date doesn't usually matter for pages, so don't backdate this upload.
 772              if ( $post && 'page' !== $post->post_type && substr( $post->post_date, 0, 4 ) > 0 ) {
 773                  $time = $post->post_date;
 774              }
 775          }
 776  
 777          if ( ! empty( $files ) ) {
 778              $file = $this->upload_from_file( $files, $headers, $time );
 779          } else {
 780              $file = $this->upload_from_data( $request->get_body(), $headers, $time );
 781          }
 782  
 783          if ( is_wp_error( $file ) ) {
 784              return $file;
 785          }
 786  
 787          $name       = wp_basename( $file['file'] );
 788          $name_parts = pathinfo( $name );
 789          $name       = trim( substr( $name, 0, -( 1 + strlen( $name_parts['extension'] ) ) ) );
 790  
 791          $url  = $file['url'];
 792          $type = $file['type'];
 793          $file = $file['file'];
 794          $alt  = '';
 795  
 796          // Include image functions to get access to wp_read_image_metadata().
 797          require_once  ABSPATH . 'wp-admin/includes/image.php';
 798  
 799          // Use image exif/iptc data for title and caption defaults if possible.
 800          $image_meta = wp_read_image_metadata( $file );
 801  
 802          if ( ! empty( $image_meta ) ) {
 803              if ( empty( $request['title'] ) && trim( $image_meta['title'] ) && ! is_numeric( sanitize_title( $image_meta['title'] ) ) ) {
 804                  $request['title'] = $image_meta['title'];
 805              }
 806  
 807              if ( empty( $request['caption'] ) && trim( $image_meta['caption'] ) ) {
 808                  $request['caption'] = $image_meta['caption'];
 809              }
 810  
 811              if ( empty( $request['alt'] ) && trim( $image_meta['alt'] ) ) {
 812                  $alt = $image_meta['alt'];
 813              }
 814          }
 815  
 816          $attachment = $this->prepare_item_for_database( $request );
 817  
 818          $attachment->post_mime_type = $type;
 819          $attachment->guid           = $url;
 820  
 821          // If the title was not set, use the original filename.
 822          if ( empty( $attachment->post_title ) && ! empty( $files['file']['name'] ) ) {
 823              // Remove the file extension (after the last `.`)
 824              $tmp_title = substr( $files['file']['name'], 0, strrpos( $files['file']['name'], '.' ) );
 825  
 826              if ( ! empty( $tmp_title ) ) {
 827                  $attachment->post_title = $tmp_title;
 828              }
 829          }
 830  
 831          // Fall back to the original approach.
 832          if ( empty( $attachment->post_title ) ) {
 833              $attachment->post_title = preg_replace( '/\.[^.]+$/', '', wp_basename( $file ) );
 834          }
 835  
 836          // $post_parent is inherited from $attachment['post_parent'].
 837          $id = wp_insert_attachment( wp_slash( (array) $attachment ), $file, 0, true, false );
 838  
 839          if ( trim( $alt ) ) {
 840              update_post_meta( $id, '_wp_attachment_image_alt', sanitize_text_field( $alt ) );
 841          }
 842  
 843          if ( is_wp_error( $id ) ) {
 844              if ( 'db_update_error' === $id->get_error_code() ) {
 845                  $id->add_data( array( 'status' => 500 ) );
 846              } else {
 847                  $id->add_data( array( 'status' => 400 ) );
 848              }
 849  
 850              return $id;
 851          }
 852  
 853          $attachment = get_post( $id );
 854  
 855          /**
 856           * Fires after a single attachment is created or updated via the REST API.
 857           *
 858           * @since 4.7.0
 859           *
 860           * @param WP_Post         $attachment Inserted or updated attachment object.
 861           * @param WP_REST_Request $request    The request sent to the API.
 862           * @param bool            $creating   True when creating an attachment, false when updating.
 863           */
 864          do_action( 'rest_insert_attachment', $attachment, $request, true );
 865  
 866          return array(
 867              'attachment_id' => $id,
 868              'file'          => $file,
 869          );
 870      }
 871  
 872      /**
 873       * Determines the featured media based on a request param.
 874       *
 875       * @since 6.5.0
 876       *
 877       * @param int $featured_media Featured Media ID.
 878       * @param int $post_id        Post ID.
 879       * @return bool|WP_Error Whether the post thumbnail was successfully deleted, otherwise WP_Error.
 880       */
 881  	protected function handle_featured_media( $featured_media, $post_id ) {
 882          $post_type         = get_post_type( $post_id );
 883          $thumbnail_support = current_theme_supports( 'post-thumbnails', $post_type ) && post_type_supports( $post_type, 'thumbnail' );
 884  
 885          // Similar check as in wp_insert_post().
 886          if ( ! $thumbnail_support && get_post_mime_type( $post_id ) ) {
 887              if ( wp_attachment_is( 'audio', $post_id ) ) {
 888                  $thumbnail_support = post_type_supports( 'attachment:audio', 'thumbnail' ) || current_theme_supports( 'post-thumbnails', 'attachment:audio' );
 889              } elseif ( wp_attachment_is( 'video', $post_id ) ) {
 890                  $thumbnail_support = post_type_supports( 'attachment:video', 'thumbnail' ) || current_theme_supports( 'post-thumbnails', 'attachment:video' );
 891              }
 892          }
 893  
 894          if ( $thumbnail_support ) {
 895              return parent::handle_featured_media( $featured_media, $post_id );
 896          }
 897  
 898          return new WP_Error(
 899              'rest_no_featured_media',
 900              sprintf(
 901                  /* translators: %s: attachment mime type */
 902                  __( 'This site does not support post thumbnails on attachments with MIME type %s.' ),
 903                  get_post_mime_type( $post_id )
 904              ),
 905              array( 'status' => 400 )
 906          );
 907      }
 908  
 909      /**
 910       * Updates a single attachment.
 911       *
 912       * @since 4.7.0
 913       *
 914       * @param WP_REST_Request $request Full details about the request.
 915       * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
 916       */
 917  	public function update_item( $request ) {
 918          if ( ! empty( $request['post'] ) && in_array( get_post_type( $request['post'] ), array( 'revision', 'attachment' ), true ) ) {
 919              return new WP_Error(
 920                  'rest_invalid_param',
 921                  __( 'Invalid parent type.' ),
 922                  array( 'status' => 400 )
 923              );
 924          }
 925  
 926          $attachment_before = get_post( $request['id'] );
 927          $response          = parent::update_item( $request );
 928  
 929          if ( is_wp_error( $response ) ) {
 930              return $response;
 931          }
 932  
 933          $response = rest_ensure_response( $response );
 934          $data     = $response->get_data();
 935  
 936          if ( isset( $request['alt_text'] ) ) {
 937              update_post_meta( $data['id'], '_wp_attachment_image_alt', $request['alt_text'] );
 938          }
 939  
 940          $attachment = get_post( $request['id'] );
 941  
 942          if ( ! empty( $schema['properties']['featured_media'] ) && isset( $request['featured_media'] ) ) {
 943              $thumbnail_update = $this->handle_featured_media( $request['featured_media'], $attachment->ID );
 944  
 945              if ( is_wp_error( $thumbnail_update ) ) {
 946                  return $thumbnail_update;
 947              }
 948          }
 949  
 950          $fields_update = $this->update_additional_fields_for_object( $attachment, $request );
 951  
 952          if ( is_wp_error( $fields_update ) ) {
 953              return $fields_update;
 954          }
 955  
 956          $request->set_param( 'context', 'edit' );
 957  
 958          /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php */
 959          do_action( 'rest_after_insert_attachment', $attachment, $request, false );
 960  
 961          wp_after_insert_post( $attachment, true, $attachment_before );
 962  
 963          $response = $this->prepare_item_for_response( $attachment, $request );
 964          $response = rest_ensure_response( $response );
 965  
 966          return $response;
 967      }
 968  
 969      /**
 970       * Performs post-processing on an attachment.
 971       *
 972       * @since 5.3.0
 973       *
 974       * @param WP_REST_Request $request Full details about the request.
 975       * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
 976       */
 977  	public function post_process_item( $request ) {
 978          switch ( $request['action'] ) {
 979              case 'create-image-subsizes':
 980                  require_once  ABSPATH . 'wp-admin/includes/image.php';
 981                  wp_update_image_subsizes( $request['id'] );
 982                  break;
 983          }
 984  
 985          $request['context'] = 'edit';
 986  
 987          return $this->prepare_item_for_response( get_post( $request['id'] ), $request );
 988      }
 989  
 990      /**
 991       * Checks if a given request can perform post-processing on an attachment.
 992       *
 993       * @since 5.3.0
 994       *
 995       * @param WP_REST_Request $request Full details about the request.
 996       * @return true|WP_Error True if the request has access to update the item, WP_Error object otherwise.
 997       */
 998  	public function post_process_item_permissions_check( $request ) {
 999          return $this->update_item_permissions_check( $request );
1000      }
1001  
1002      /**
1003       * Checks if a given request has access to editing media.
1004       *
1005       * @since 5.5.0
1006       *
1007       * @param WP_REST_Request $request Full details about the request.
1008       * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
1009       */
1010  	public function edit_media_item_permissions_check( $request ) {
1011          if ( ! current_user_can( 'upload_files' ) ) {
1012              return new WP_Error(
1013                  'rest_cannot_edit_image',
1014                  __( 'Sorry, you are not allowed to upload media on this site.' ),
1015                  array( 'status' => rest_authorization_required_code() )
1016              );
1017          }
1018  
1019          return $this->update_item_permissions_check( $request );
1020      }
1021  
1022      /**
1023       * Applies edits to a media item and creates a new attachment record.
1024       *
1025       * @since 5.5.0
1026       * @since 6.9.0 Adds flips capability and editable fields for the newly-created attachment post.
1027       * @since 7.1.0 Applies EXIF orientation correction before image modifications.
1028       *
1029       * @param WP_REST_Request $request Full details about the request.
1030       * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
1031       */
1032  	public function edit_media_item( $request ) {
1033          require_once  ABSPATH . 'wp-admin/includes/image.php';
1034  
1035          $attachment_id = $request['id'];
1036  
1037          // This also confirms the attachment is an image.
1038          $image_file = wp_get_original_image_path( $attachment_id );
1039          $image_meta = wp_get_attachment_metadata( $attachment_id );
1040  
1041          if (
1042              ! $image_meta ||
1043              ! $image_file ||
1044              ! wp_image_file_matches_image_meta( $request['src'], $image_meta, $attachment_id )
1045          ) {
1046              return new WP_Error(
1047                  'rest_unknown_attachment',
1048                  __( 'Unable to get meta information for file.' ),
1049                  array( 'status' => 404 )
1050              );
1051          }
1052  
1053          $supported_types = array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/heic' );
1054          $mime_type       = get_post_mime_type( $attachment_id );
1055          if ( ! in_array( $mime_type, $supported_types, true ) ) {
1056              return new WP_Error(
1057                  'rest_cannot_edit_file_type',
1058                  __( 'This type of file cannot be edited.' ),
1059                  array( 'status' => 400 )
1060              );
1061          }
1062  
1063          // The `modifiers` param takes precedence over the older format.
1064          if ( isset( $request['modifiers'] ) ) {
1065              $modifiers = $request['modifiers'];
1066          } else {
1067              $modifiers = array();
1068  
1069              if ( isset( $request['flip']['horizontal'] ) || isset( $request['flip']['vertical'] ) ) {
1070                  $flip_args = array(
1071                      'vertical'   => isset( $request['flip']['vertical'] ) ? (bool) $request['flip']['vertical'] : false,
1072                      'horizontal' => isset( $request['flip']['horizontal'] ) ? (bool) $request['flip']['horizontal'] : false,
1073                  );
1074  
1075                  $modifiers[] = array(
1076                      'type' => 'flip',
1077                      'args' => array(
1078                          'flip' => $flip_args,
1079                      ),
1080                  );
1081              }
1082  
1083              if ( ! empty( $request['rotation'] ) ) {
1084                  $modifiers[] = array(
1085                      'type' => 'rotate',
1086                      'args' => array(
1087                          'angle' => $request['rotation'],
1088                      ),
1089                  );
1090              }
1091  
1092              if ( isset( $request['x'], $request['y'], $request['width'], $request['height'] ) ) {
1093                  $modifiers[] = array(
1094                      'type' => 'crop',
1095                      'args' => array(
1096                          'left'   => $request['x'],
1097                          'top'    => $request['y'],
1098                          'width'  => $request['width'],
1099                          'height' => $request['height'],
1100                      ),
1101                  );
1102              }
1103  
1104              if ( 0 === count( $modifiers ) ) {
1105                  return new WP_Error(
1106                      'rest_image_not_edited',
1107                      __( 'The image was not edited. Edit the image before applying the changes.' ),
1108                      array( 'status' => 400 )
1109                  );
1110              }
1111          }
1112  
1113          /*
1114           * If the file doesn't exist, attempt a URL fopen on the src link.
1115           * This can occur with certain file replication plugins.
1116           * Keep the original file path to get a modified name later.
1117           */
1118          $image_file_to_edit = $image_file;
1119          if ( ! file_exists( $image_file_to_edit ) ) {
1120              $image_file_to_edit = _load_image_to_edit_path( $attachment_id );
1121          }
1122  
1123          $image_editor = wp_get_image_editor( $image_file_to_edit );
1124  
1125          if ( is_wp_error( $image_editor ) ) {
1126              return new WP_Error(
1127                  'rest_unknown_image_file_type',
1128                  __( 'Unable to edit this image.' ),
1129                  array( 'status' => 500 )
1130              );
1131          }
1132  
1133          // Apply any unapplied EXIF orientation so edits run in the upright frame the client previewed.
1134          $image_editor->maybe_exif_rotate();
1135  
1136          foreach ( $modifiers as $modifier ) {
1137              $args = $modifier['args'];
1138              switch ( $modifier['type'] ) {
1139                  case 'flip':
1140                      /*
1141                       * Flips the current image.
1142                       * The vertical flip is the first argument (flip along horizontal axis), the horizontal flip is the second argument (flip along vertical axis).
1143                       * See: WP_Image_Editor::flip()
1144                       */
1145                      $result = $image_editor->flip( $args['flip']['vertical'], $args['flip']['horizontal'] );
1146                      if ( is_wp_error( $result ) ) {
1147                          return new WP_Error(
1148                              'rest_image_flip_failed',
1149                              __( 'Unable to flip this image.' ),
1150                              array( 'status' => 500 )
1151                          );
1152                      }
1153                      break;
1154                  case 'rotate':
1155                      // Rotation direction: clockwise vs. counterclockwise.
1156                      $rotate = 0 - $args['angle'];
1157  
1158                      if ( 0 !== $rotate ) {
1159                          $result = $image_editor->rotate( $rotate );
1160  
1161                          if ( is_wp_error( $result ) ) {
1162                              return new WP_Error(
1163                                  'rest_image_rotation_failed',
1164                                  __( 'Unable to rotate this image.' ),
1165                                  array( 'status' => 500 )
1166                              );
1167                          }
1168                      }
1169  
1170                      break;
1171  
1172                  case 'crop':
1173                      $size = $image_editor->get_size();
1174  
1175                      $crop_x = (int) round( ( $size['width'] * $args['left'] ) / 100.0 );
1176                      $crop_y = (int) round( ( $size['height'] * $args['top'] ) / 100.0 );
1177                      $width  = (int) round( ( $size['width'] * $args['width'] ) / 100.0 );
1178                      $height = (int) round( ( $size['height'] * $args['height'] ) / 100.0 );
1179  
1180                      if ( $size['width'] !== $width || $size['height'] !== $height ) {
1181                          $result = $image_editor->crop( $crop_x, $crop_y, $width, $height );
1182  
1183                          if ( is_wp_error( $result ) ) {
1184                              return new WP_Error(
1185                                  'rest_image_crop_failed',
1186                                  __( 'Unable to crop this image.' ),
1187                                  array( 'status' => 500 )
1188                              );
1189                          }
1190                      }
1191  
1192                      break;
1193  
1194              }
1195          }
1196  
1197          // Calculate the file name.
1198          $image_ext  = pathinfo( $image_file, PATHINFO_EXTENSION );
1199          $image_name = wp_basename( $image_file, ".{$image_ext}" );
1200  
1201          /*
1202           * Do not append multiple `-edited` to the file name.
1203           * The user may be editing a previously edited image.
1204           */
1205          if ( preg_match( '/-edited(-\d+)?$/', $image_name ) ) {
1206              // Remove any `-1`, `-2`, etc. `wp_unique_filename()` will add the proper number.
1207              $image_name = preg_replace( '/-edited(-\d+)?$/', '-edited', $image_name );
1208          } else {
1209              // Append `-edited` before the extension.
1210              $image_name .= '-edited';
1211          }
1212  
1213          $filename = "{$image_name}.{$image_ext}";
1214  
1215          // Create the uploads subdirectory if needed.
1216          $uploads = wp_upload_dir();
1217  
1218          // Make the file name unique in the (new) upload directory.
1219          $filename = wp_unique_filename( $uploads['path'], $filename );
1220  
1221          // Save to disk.
1222          $saved = $image_editor->save( $uploads['path'] . "/$filename" );
1223  
1224          if ( is_wp_error( $saved ) ) {
1225              return $saved;
1226          }
1227  
1228          // Grab original attachment post so we can use it to set defaults.
1229          $original_attachment_post = get_post( $attachment_id );
1230  
1231          // Check request fields and assign default values.
1232          $new_attachment_post                 = $this->prepare_item_for_database( $request );
1233          $new_attachment_post->post_mime_type = $saved['mime-type'];
1234          $new_attachment_post->guid           = $uploads['url'] . "/$filename";
1235  
1236          // Unset ID so wp_insert_attachment generates a new ID.
1237          unset( $new_attachment_post->ID );
1238  
1239          // Set new attachment post title with fallbacks.
1240          $new_attachment_post->post_title = $new_attachment_post->post_title ?? $original_attachment_post->post_title ?? $image_name;
1241  
1242          // Set new attachment post caption (post_excerpt).
1243          $new_attachment_post->post_excerpt = $new_attachment_post->post_excerpt ?? $original_attachment_post->post_excerpt ?? '';
1244  
1245          // Set new attachment post description (post_content) with fallbacks.
1246          $new_attachment_post->post_content = $new_attachment_post->post_content ?? $original_attachment_post->post_content ?? '';
1247  
1248          // Set post parent if set in request, else the default of `0` (no parent).
1249          $new_attachment_post->post_parent = $new_attachment_post->post_parent ?? 0;
1250  
1251          // Insert the new attachment post.
1252          $new_attachment_id = wp_insert_attachment( wp_slash( (array) $new_attachment_post ), $saved['path'], 0, true );
1253  
1254          if ( is_wp_error( $new_attachment_id ) ) {
1255              if ( 'db_update_error' === $new_attachment_id->get_error_code() ) {
1256                  $new_attachment_id->add_data( array( 'status' => 500 ) );
1257              } else {
1258                  $new_attachment_id->add_data( array( 'status' => 400 ) );
1259              }
1260  
1261              return $new_attachment_id;
1262          }
1263  
1264          // First, try to use the alt text from the request. If not set, copy the image alt text from the original attachment.
1265          $image_alt = isset( $request['alt_text'] ) ? sanitize_text_field( $request['alt_text'] ) : get_post_meta( $attachment_id, '_wp_attachment_image_alt', true );
1266  
1267          if ( ! empty( $image_alt ) ) {
1268              // update_post_meta() expects slashed.
1269              update_post_meta( $new_attachment_id, '_wp_attachment_image_alt', wp_slash( $image_alt ) );
1270          }
1271  
1272          if ( wp_is_serving_rest_request() ) {
1273              /*
1274               * Set a custom header with the attachment_id.
1275               * Used by the browser/client to resume creating image sub-sizes after a PHP fatal error.
1276               */
1277              header( 'X-WP-Upload-Attachment-ID: ' . $new_attachment_id );
1278          }
1279  
1280          // Generate image sub-sizes and meta.
1281          $new_image_meta = wp_generate_attachment_metadata( $new_attachment_id, $saved['path'] );
1282  
1283          // Copy the EXIF metadata from the original attachment if not generated for the edited image.
1284          if ( isset( $image_meta['image_meta'] ) && isset( $new_image_meta['image_meta'] ) && is_array( $new_image_meta['image_meta'] ) ) {
1285              // Merge but skip empty values.
1286              foreach ( (array) $image_meta['image_meta'] as $key => $value ) {
1287                  if ( empty( $new_image_meta['image_meta'][ $key ] ) && ! empty( $value ) ) {
1288                      $new_image_meta['image_meta'][ $key ] = $value;
1289                  }
1290              }
1291          }
1292  
1293          // Reset orientation. At this point the image is edited and orientation is correct.
1294          if ( ! empty( $new_image_meta['image_meta']['orientation'] ) ) {
1295              $new_image_meta['image_meta']['orientation'] = 1;
1296          }
1297  
1298          // The attachment_id may change if the site is exported and imported.
1299          $new_image_meta['parent_image'] = array(
1300              'attachment_id' => $attachment_id,
1301              // Path to the originally uploaded image file relative to the uploads directory.
1302              'file'          => _wp_relative_upload_path( $image_file ),
1303          );
1304  
1305          /**
1306           * Filters the meta data for the new image created by editing an existing image.
1307           *
1308           * @since 5.5.0
1309           *
1310           * @param array $new_image_meta    Meta data for the new image.
1311           * @param int   $new_attachment_id Attachment post ID for the new image.
1312           * @param int   $attachment_id     Attachment post ID for the edited (parent) image.
1313           */
1314          $new_image_meta = apply_filters( 'wp_edited_image_metadata', $new_image_meta, $new_attachment_id, $attachment_id );
1315  
1316          wp_update_attachment_metadata( $new_attachment_id, $new_image_meta );
1317  
1318          $response = $this->prepare_item_for_response( get_post( $new_attachment_id ), $request );
1319          $response->set_status( 201 );
1320          $response->header( 'Location', rest_url( sprintf( '%s/%s/%s', $this->namespace, $this->rest_base, $new_attachment_id ) ) );
1321  
1322          return $response;
1323      }
1324  
1325      /**
1326       * Prepares a single attachment for create or update.
1327       *
1328       * @since 4.7.0
1329       *
1330       * @param WP_REST_Request $request Request object.
1331       * @return stdClass|WP_Error Post object.
1332       */
1333  	protected function prepare_item_for_database( $request ) {
1334          $prepared_attachment = parent::prepare_item_for_database( $request );
1335  
1336          // Attachment caption (post_excerpt internally).
1337          if ( isset( $request['caption'] ) ) {
1338              if ( is_string( $request['caption'] ) ) {
1339                  $prepared_attachment->post_excerpt = $request['caption'];
1340              } elseif ( isset( $request['caption']['raw'] ) ) {
1341                  $prepared_attachment->post_excerpt = $request['caption']['raw'];
1342              }
1343          }
1344  
1345          // Attachment description (post_content internally).
1346          if ( isset( $request['description'] ) ) {
1347              if ( is_string( $request['description'] ) ) {
1348                  $prepared_attachment->post_content = $request['description'];
1349              } elseif ( isset( $request['description']['raw'] ) ) {
1350                  $prepared_attachment->post_content = $request['description']['raw'];
1351              }
1352          }
1353  
1354          if ( isset( $request['post'] ) ) {
1355              $prepared_attachment->post_parent = (int) $request['post'];
1356          }
1357  
1358          return $prepared_attachment;
1359      }
1360  
1361      /**
1362       * Prepares a single attachment output for response.
1363       *
1364       * @since 4.7.0
1365       * @since 5.9.0 Renamed `$post` to `$item` to match parent class for PHP 8 named parameter support.
1366       *
1367       * @param WP_Post         $item    Attachment object.
1368       * @param WP_REST_Request $request Request object.
1369       * @return WP_REST_Response Response object.
1370       */
1371  	public function prepare_item_for_response( $item, $request ) {
1372          // Restores the more descriptive, specific name for use within this method.
1373          $post = $item;
1374  
1375          $response = parent::prepare_item_for_response( $post, $request );
1376          $fields   = $this->get_fields_for_response( $request );
1377          /** @var array<string, mixed> $data */
1378          $data = $response->get_data();
1379  
1380          if ( in_array( 'description', $fields, true ) ) {
1381              $data['description'] = array(
1382                  'raw'      => $post->post_content,
1383                  /** This filter is documented in wp-includes/post-template.php */
1384                  'rendered' => apply_filters( 'the_content', $post->post_content ),
1385              );
1386          }
1387  
1388          if ( in_array( 'caption', $fields, true ) ) {
1389              /** This filter is documented in wp-includes/post-template.php */
1390              $caption = apply_filters( 'get_the_excerpt', $post->post_excerpt, $post );
1391  
1392              /** This filter is documented in wp-includes/post-template.php */
1393              $caption = apply_filters( 'the_excerpt', $caption );
1394  
1395              $data['caption'] = array(
1396                  'raw'      => $post->post_excerpt,
1397                  'rendered' => $caption,
1398              );
1399          }
1400  
1401          if ( in_array( 'alt_text', $fields, true ) ) {
1402              $data['alt_text'] = get_post_meta( $post->ID, '_wp_attachment_image_alt', true );
1403          }
1404  
1405          if ( in_array( 'media_type', $fields, true ) ) {
1406              $data['media_type'] = wp_attachment_is_image( $post->ID ) ? 'image' : 'file';
1407          }
1408  
1409          if ( in_array( 'mime_type', $fields, true ) ) {
1410              $data['mime_type'] = $post->post_mime_type;
1411          }
1412  
1413          if ( in_array( 'media_details', $fields, true ) ) {
1414              $data['media_details'] = wp_get_attachment_metadata( $post->ID );
1415  
1416              // Ensure empty details is an empty object.
1417              if ( empty( $data['media_details'] ) ) {
1418                  $data['media_details'] = new stdClass();
1419              } elseif ( ! empty( $data['media_details']['sizes'] ) ) {
1420  
1421                  foreach ( $data['media_details']['sizes'] as $size => &$size_data ) {
1422  
1423                      if ( isset( $size_data['mime-type'] ) ) {
1424                          $size_data['mime_type'] = $size_data['mime-type'];
1425                          unset( $size_data['mime-type'] );
1426                      }
1427  
1428                      // Use the same method image_downsize() does.
1429                      $image_src = wp_get_attachment_image_src( $post->ID, $size );
1430                      if ( ! $image_src ) {
1431                          continue;
1432                      }
1433  
1434                      $size_data['source_url'] = $image_src[0];
1435                  }
1436                  unset( $size_data );
1437  
1438                  $full_src = wp_get_attachment_image_src( $post->ID, 'full' );
1439  
1440                  if ( ! empty( $full_src ) ) {
1441                      $data['media_details']['sizes']['full'] = array(
1442                          'file'       => wp_basename( $full_src[0] ),
1443                          'width'      => $full_src[1],
1444                          'height'     => $full_src[2],
1445                          'mime_type'  => $post->post_mime_type,
1446                          'source_url' => $full_src[0],
1447                      );
1448                  }
1449              } else {
1450                  $data['media_details']['sizes'] = new stdClass();
1451              }
1452          }
1453  
1454          if ( in_array( 'post', $fields, true ) ) {
1455              $data['post'] = ! empty( $post->post_parent ) ? (int) $post->post_parent : null;
1456          }
1457  
1458          if ( in_array( 'source_url', $fields, true ) ) {
1459              $data['source_url'] = wp_get_attachment_url( $post->ID );
1460          }
1461  
1462          if ( in_array( 'missing_image_sizes', $fields, true ) ) {
1463              require_once  ABSPATH . 'wp-admin/includes/image.php';
1464              $data['missing_image_sizes'] = array_keys( wp_get_missing_image_subsizes( $post->ID ) );
1465  
1466              // Handle PDFs which don't use wp_get_missing_image_subsizes().
1467              if ( empty( $data['missing_image_sizes'] ) && 'application/pdf' === get_post_mime_type( $post ) ) {
1468                  $metadata = wp_get_attachment_metadata( $post->ID, true );
1469  
1470                  if ( ! is_array( $metadata ) ) {
1471                      $metadata = array();
1472                  }
1473  
1474                  $metadata['sizes'] = $metadata['sizes'] ?? array();
1475  
1476                  $fallback_sizes = array(
1477                      'thumbnail',
1478                      'medium',
1479                      'large',
1480                  );
1481  
1482                  // The filter might have been added by ::create_item().
1483                  remove_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 );
1484  
1485                  /** This filter is documented in wp-admin/includes/image.php */
1486                  $fallback_sizes = apply_filters( 'fallback_intermediate_image_sizes', $fallback_sizes, $metadata );
1487  
1488                  $registered_sizes = wp_get_registered_image_subsizes();
1489                  $merged_sizes     = array_keys( array_intersect_key( $registered_sizes, array_flip( $fallback_sizes ) ) );
1490  
1491                  $data['missing_image_sizes'] = array_values( array_diff( $merged_sizes, array_keys( $metadata['sizes'] ) ) );
1492              }
1493          }
1494  
1495          if ( in_array( 'filename', $fields, true ) ) {
1496              $data['filename'] = $this->get_attachment_filename( $post->ID );
1497          }
1498  
1499          if ( in_array( 'filesize', $fields, true ) ) {
1500              $data['filesize'] = $this->get_attachment_filesize( $post->ID );
1501          }
1502  
1503          if ( in_array( 'exif_orientation', $fields, true ) && wp_attachment_is_image( $post ) ) {
1504              $metadata = wp_get_attachment_metadata( $post->ID, true );
1505  
1506              // Default to 1 (no rotation needed) if orientation not set.
1507              $orientation = 1;
1508  
1509              if (
1510                  is_array( $metadata ) &&
1511                  isset( $metadata['image_meta']['orientation'] ) &&
1512                  (int) $metadata['image_meta']['orientation'] > 0
1513              ) {
1514                  $orientation = (int) $metadata['image_meta']['orientation'];
1515              }
1516  
1517              $data['exif_orientation'] = $orientation;
1518          }
1519  
1520          if ( wp_attachment_is_image( $post ) ) {
1521              $mime_type = (string) get_post_mime_type( $post );
1522  
1523              /*
1524               * Per-file output format for images, evaluated with the real filename
1525               * and MIME type so plugins filtering image_editor_output_format can
1526               * make per-attachment decisions (e.g. JPEG -> WebP). Resolved the same
1527               * way WP_Image_Editor::set_quality() resolves the output format.
1528               */
1529              if ( in_array( 'image_output_format', $fields, true ) ) {
1530                  $filename = get_attached_file( $post->ID );
1531  
1532                  /** This filter is documented in wp-includes/media.php */
1533                  $output_formats = apply_filters(
1534                      'image_editor_output_format',
1535                      array( $mime_type => $mime_type ),
1536                      $filename ? $filename : '',
1537                      $mime_type
1538                  );
1539  
1540                  $output_mime                 = $output_formats[ $mime_type ] ?? $mime_type;
1541                  $data['image_output_format'] = ( $output_mime !== $mime_type ) ? $output_mime : null;
1542              }
1543  
1544              /*
1545               * Per-file progressive/interlaced encoding flag for images, evaluated
1546               * against the attachment's MIME type.
1547               */
1548              if ( in_array( 'image_save_progressive', $fields, true ) ) {
1549                  /** This filter is documented in wp-includes/class-wp-image-editor-gd.php */
1550                  $data['image_save_progressive'] = (bool) apply_filters( 'image_save_progressive', false, $mime_type );
1551              }
1552  
1553              if ( in_array( 'image_quality', $fields, true ) ) {
1554                  $filename = get_attached_file( $post->ID );
1555  
1556                  /** This filter is documented in wp-includes/media.php */
1557                  $output_formats = apply_filters(
1558                      'image_editor_output_format',
1559                      array( $mime_type => $mime_type ),
1560                      $filename ? $filename : '',
1561                      $mime_type
1562                  );
1563                  $output_mime    = $output_formats[ $mime_type ] ?? $mime_type;
1564  
1565                  $metadata    = wp_get_attachment_metadata( $post->ID, true );
1566                  $full_width  = max( 0, ( is_array( $metadata ) && isset( $metadata['width'] ) ) ? (int) $metadata['width'] : 0 );
1567                  $full_height = max( 0, ( is_array( $metadata ) && isset( $metadata['height'] ) ) ? (int) $metadata['height'] : 0 );
1568  
1569                  $full_quality = wp_get_image_encode_quality(
1570                      $output_mime,
1571                      array(
1572                          'width'  => $full_width,
1573                          'height' => $full_height,
1574                      )
1575                  );
1576  
1577                  $size_quality = array();
1578  
1579                  foreach ( wp_get_registered_image_subsizes() as $size_name => $size_data ) {
1580                      $quality = wp_get_image_encode_quality(
1581                          $output_mime,
1582                          array(
1583                              'width'  => (int) $size_data['width'],
1584                              'height' => (int) $size_data['height'],
1585                          )
1586                      );
1587  
1588                      // Only report sizes whose quality diverges from the full-size value.
1589                      if ( $quality !== $full_quality ) {
1590                          $size_quality[ $size_name ] = $quality;
1591                      }
1592                  }
1593  
1594                  $data['image_quality'] = array(
1595                      'default' => $full_quality,
1596                      'sizes'   => $size_quality,
1597                  );
1598              }
1599          }
1600  
1601          $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
1602  
1603          $data = $this->filter_response_by_context( $data, $context );
1604  
1605          $links = $response->get_links();
1606  
1607          // Wrap the data in a response object.
1608          $response = rest_ensure_response( $data );
1609  
1610          foreach ( $links as $rel => $rel_links ) {
1611              foreach ( $rel_links as $link ) {
1612                  $response->add_link( $rel, $link['href'], $link['attributes'] );
1613              }
1614          }
1615  
1616          /**
1617           * Filters an attachment returned from the REST API.
1618           *
1619           * Allows modification of the attachment right before it is returned.
1620           *
1621           * @since 4.7.0
1622           *
1623           * @param WP_REST_Response $response The response object.
1624           * @param WP_Post          $post     The original attachment post.
1625           * @param WP_REST_Request  $request  Request used to generate the response.
1626           */
1627          return apply_filters( 'rest_prepare_attachment', $response, $post, $request );
1628      }
1629  
1630      /**
1631       * Prepares attachment links for the request.
1632       *
1633       * @since 6.9.0
1634       *
1635       * @param WP_Post $post Post object.
1636       * @return array Links for the given attachment.
1637       */
1638  	protected function prepare_links( $post ) {
1639          $links = parent::prepare_links( $post );
1640  
1641          if ( ! empty( $post->post_parent ) ) {
1642              $post = get_post( $post->post_parent );
1643  
1644              if ( ! empty( $post ) ) {
1645                  $links['https://api.w.org/attached-to'] = array(
1646                      'href'       => rest_url( rest_get_route_for_post( $post ) ),
1647                      'embeddable' => true,
1648                      'post_type'  => $post->post_type,
1649                      'id'         => $post->ID,
1650                  );
1651              }
1652          }
1653  
1654          return $links;
1655      }
1656  
1657      /**
1658       * Retrieves the attachment's schema, conforming to JSON Schema.
1659       *
1660       * @since 4.7.0
1661       *
1662       * @return array Item schema as an array.
1663       */
1664  	public function get_item_schema() {
1665          if ( $this->schema ) {
1666              return $this->add_additional_fields_schema( $this->schema );
1667          }
1668  
1669          $schema = parent::get_item_schema();
1670  
1671          $schema['properties']['alt_text'] = array(
1672              'description' => __( 'Alternative text to display when attachment is not displayed.' ),
1673              'type'        => 'string',
1674              'context'     => array( 'view', 'edit', 'embed' ),
1675              'arg_options' => array(
1676                  'sanitize_callback' => 'sanitize_text_field',
1677              ),
1678          );
1679  
1680          $schema['properties']['caption'] = array(
1681              'description' => __( 'The attachment caption.' ),
1682              'type'        => 'object',
1683              'context'     => array( 'view', 'edit', 'embed' ),
1684              'arg_options' => array(
1685                  'sanitize_callback' => null, // Note: sanitization implemented in self::prepare_item_for_database().
1686                  'validate_callback' => null, // Note: validation implemented in self::prepare_item_for_database().
1687              ),
1688              'properties'  => array(
1689                  'raw'      => array(
1690                      'description' => __( 'Caption for the attachment, as it exists in the database.' ),
1691                      'type'        => 'string',
1692                      'context'     => array( 'edit' ),
1693                  ),
1694                  'rendered' => array(
1695                      'description' => __( 'HTML caption for the attachment, transformed for display.' ),
1696                      'type'        => 'string',
1697                      'context'     => array( 'view', 'edit', 'embed' ),
1698                      'readonly'    => true,
1699                  ),
1700              ),
1701          );
1702  
1703          $schema['properties']['description'] = array(
1704              'description' => __( 'The attachment description.' ),
1705              'type'        => 'object',
1706              'context'     => array( 'view', 'edit' ),
1707              'arg_options' => array(
1708                  'sanitize_callback' => null, // Note: sanitization implemented in self::prepare_item_for_database().
1709                  'validate_callback' => null, // Note: validation implemented in self::prepare_item_for_database().
1710              ),
1711              'properties'  => array(
1712                  'raw'      => array(
1713                      'description' => __( 'Description for the attachment, as it exists in the database.' ),
1714                      'type'        => 'string',
1715                      'context'     => array( 'edit' ),
1716                  ),
1717                  'rendered' => array(
1718                      'description' => __( 'HTML description for the attachment, transformed for display.' ),
1719                      'type'        => 'string',
1720                      'context'     => array( 'view', 'edit' ),
1721                      'readonly'    => true,
1722                  ),
1723              ),
1724          );
1725  
1726          $schema['properties']['media_type'] = array(
1727              'description' => __( 'Attachment type.' ),
1728              'type'        => 'string',
1729              'enum'        => array( 'image', 'file' ),
1730              'context'     => array( 'view', 'edit', 'embed' ),
1731              'readonly'    => true,
1732          );
1733  
1734          $schema['properties']['mime_type'] = array(
1735              'description' => __( 'The attachment MIME type.' ),
1736              'type'        => 'string',
1737              'context'     => array( 'view', 'edit', 'embed' ),
1738              'readonly'    => true,
1739          );
1740  
1741          $schema['properties']['media_details'] = array(
1742              'description' => __( 'Details about the media file, specific to its type.' ),
1743              'type'        => 'object',
1744              'context'     => array( 'view', 'edit', 'embed' ),
1745              'readonly'    => true,
1746          );
1747  
1748          $schema['properties']['post'] = array(
1749              'description' => __( 'The ID for the associated post of the attachment.' ),
1750              'type'        => 'integer',
1751              'context'     => array( 'view', 'edit' ),
1752          );
1753  
1754          $schema['properties']['source_url'] = array(
1755              'description' => __( 'URL to the original attachment file.' ),
1756              'type'        => 'string',
1757              'format'      => 'uri',
1758              'context'     => array( 'view', 'edit', 'embed' ),
1759              'readonly'    => true,
1760          );
1761  
1762          $schema['properties']['missing_image_sizes'] = array(
1763              'description' => __( 'List of the missing image sizes of the attachment.' ),
1764              'type'        => 'array',
1765              'items'       => array( 'type' => 'string' ),
1766              'context'     => array( 'edit' ),
1767              'readonly'    => true,
1768          );
1769  
1770          $schema['properties']['filename'] = array(
1771              'description' => __( 'Original attachment file name.' ),
1772              'type'        => 'string',
1773              'context'     => array( 'view', 'edit' ),
1774              'readonly'    => true,
1775          );
1776  
1777          $schema['properties']['filesize'] = array(
1778              'description' => __( 'Attachment file size in bytes.' ),
1779              'type'        => array( 'integer', 'null' ),
1780              'context'     => array( 'view', 'edit' ),
1781              'readonly'    => true,
1782          );
1783  
1784          $schema['properties']['exif_orientation'] = array(
1785              'description' => __( 'EXIF orientation value. Values 1-8 follow the EXIF specification, where 1 means no rotation needed.' ),
1786              'type'        => 'integer',
1787              'context'     => array( 'edit' ),
1788              'readonly'    => true,
1789          );
1790  
1791          // Enumerate the registered sub-sizes so the schema documents exactly which
1792          // keys may appear under "sizes".
1793          $size_quality_properties = array();
1794          foreach ( array_keys( wp_get_registered_image_subsizes() ) as $size_name ) {
1795              $size_quality_properties[ $size_name ] = array(
1796                  'type'    => 'integer',
1797                  'minimum' => 1,
1798                  'maximum' => 100,
1799              );
1800          }
1801  
1802          $schema['properties']['image_quality'] = array(
1803              'description' => __( 'Encode quality (1-100) from the wp_editor_set_quality filter, resolved against the output MIME type. The "default" value applies to the full-size image; "sizes" lists per-registered-size overrides where the filtered value differs from "default".' ),
1804              'type'        => 'object',
1805              'context'     => array( 'edit' ),
1806              'readonly'    => true,
1807              'properties'  => array(
1808                  'default' => array(
1809                      'type'    => 'integer',
1810                      'minimum' => 1,
1811                      'maximum' => 100,
1812                  ),
1813                  'sizes'   => array(
1814                      'type'       => 'object',
1815                      'properties' => $size_quality_properties,
1816                  ),
1817              ),
1818          );
1819  
1820          $schema['properties']['image_output_format'] = array(
1821              '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.' ),
1822              'type'        => array( 'string', 'null' ),
1823              'context'     => array( 'edit' ),
1824              'readonly'    => true,
1825          );
1826  
1827          $schema['properties']['image_save_progressive'] = array(
1828              'description' => __( 'Whether to use progressive/interlaced encoding when saving this image.' ),
1829              'type'        => 'boolean',
1830              'context'     => array( 'edit' ),
1831              'readonly'    => true,
1832          );
1833  
1834          unset( $schema['properties']['password'] );
1835  
1836          $this->schema = $schema;
1837  
1838          return $this->add_additional_fields_schema( $this->schema );
1839      }
1840  
1841      /**
1842       * Handles an upload via raw POST data.
1843       *
1844       * @since 4.7.0
1845       * @since 6.6.0 Added the `$time` parameter.
1846       *
1847       * @param string      $data    Supplied file data.
1848       * @param array       $headers HTTP headers from the request.
1849       * @param string|null $time    Optional. Time formatted in 'yyyy/mm'. Default null.
1850       * @return array{ file: non-empty-string, url: non-empty-string, type: non-empty-string }|WP_Error Data from wp_handle_sideload().
1851       */
1852  	protected function upload_from_data( $data, $headers, $time = null ) {
1853          if ( empty( $data ) ) {
1854              return new WP_Error(
1855                  'rest_upload_no_data',
1856                  __( 'No data supplied.' ),
1857                  array( 'status' => 400 )
1858              );
1859          }
1860  
1861          if ( empty( $headers['content_type'] ) ) {
1862              return new WP_Error(
1863                  'rest_upload_no_content_type',
1864                  __( 'No Content-Type supplied.' ),
1865                  array( 'status' => 400 )
1866              );
1867          }
1868  
1869          if ( empty( $headers['content_disposition'] ) ) {
1870              return new WP_Error(
1871                  'rest_upload_no_content_disposition',
1872                  __( 'No Content-Disposition supplied.' ),
1873                  array( 'status' => 400 )
1874              );
1875          }
1876  
1877          $filename = self::get_filename_from_disposition( $headers['content_disposition'] );
1878  
1879          if ( empty( $filename ) ) {
1880              return new WP_Error(
1881                  'rest_upload_invalid_disposition',
1882                  __( 'Invalid Content-Disposition supplied. Content-Disposition needs to be formatted as `attachment; filename="image.png"` or similar.' ),
1883                  array( 'status' => 400 )
1884              );
1885          }
1886  
1887          if ( ! empty( $headers['content_md5'] ) ) {
1888              $content_md5 = array_shift( $headers['content_md5'] );
1889              $expected    = trim( $content_md5 );
1890              $actual      = md5( $data );
1891  
1892              if ( $expected !== $actual ) {
1893                  return new WP_Error(
1894                      'rest_upload_hash_mismatch',
1895                      __( 'Content hash did not match expected.' ),
1896                      array( 'status' => 412 )
1897                  );
1898              }
1899          }
1900  
1901          // Get the content-type.
1902          $type = array_shift( $headers['content_type'] );
1903  
1904          // Include filesystem functions to get access to wp_tempnam() and wp_handle_sideload().
1905          require_once  ABSPATH . 'wp-admin/includes/file.php';
1906  
1907          // Save the file.
1908          $tmpfname = wp_tempnam( $filename );
1909  
1910          $fp = fopen( $tmpfname, 'w+' );
1911  
1912          if ( ! $fp ) {
1913              return new WP_Error(
1914                  'rest_upload_file_error',
1915                  __( 'Could not open file handle.' ),
1916                  array( 'status' => 500 )
1917              );
1918          }
1919  
1920          fwrite( $fp, $data );
1921          fclose( $fp );
1922  
1923          // Now, sideload it in.
1924          $file_data = array(
1925              'error'    => null,
1926              'tmp_name' => $tmpfname,
1927              'name'     => $filename,
1928              'type'     => $type,
1929          );
1930  
1931          $size_check = self::check_upload_size( $file_data );
1932          if ( is_wp_error( $size_check ) ) {
1933              return $size_check;
1934          }
1935  
1936          $overrides = array(
1937              'test_form' => false,
1938          );
1939  
1940          $sideloaded = wp_handle_sideload( $file_data, $overrides, $time );
1941  
1942          if ( isset( $sideloaded['error'] ) ) {
1943              @unlink( $tmpfname );
1944  
1945              return new WP_Error(
1946                  'rest_upload_sideload_error',
1947                  $sideloaded['error'],
1948                  array( 'status' => 500 )
1949              );
1950          }
1951  
1952          return $sideloaded;
1953      }
1954  
1955      /**
1956       * Parses filename from a Content-Disposition header value.
1957       *
1958       * As per RFC6266:
1959       *
1960       *     content-disposition = "Content-Disposition" ":"
1961       *                            disposition-type *( ";" disposition-parm )
1962       *
1963       *     disposition-type    = "inline" | "attachment" | disp-ext-type
1964       *                         ; case-insensitive
1965       *     disp-ext-type       = token
1966       *
1967       *     disposition-parm    = filename-parm | disp-ext-parm
1968       *
1969       *     filename-parm       = "filename" "=" value
1970       *                         | "filename*" "=" ext-value
1971       *
1972       *     disp-ext-parm       = token "=" value
1973       *                         | ext-token "=" ext-value
1974       *     ext-token           = <the characters in token, followed by "*">
1975       *
1976       * @since 4.7.0
1977       *
1978       * @link https://tools.ietf.org/html/rfc2388
1979       * @link https://tools.ietf.org/html/rfc6266
1980       *
1981       * @param string[] $disposition_header List of Content-Disposition header values.
1982       * @return string|null Filename if available, or null if not found.
1983       */
1984  	public static function get_filename_from_disposition( $disposition_header ) {
1985          // Get the filename.
1986          $filename = null;
1987  
1988          foreach ( $disposition_header as $value ) {
1989              $value = trim( $value );
1990  
1991              if ( ! str_contains( $value, ';' ) ) {
1992                  continue;
1993              }
1994  
1995              list( , $attr_parts ) = explode( ';', $value, 2 );
1996  
1997              $attr_parts = explode( ';', $attr_parts );
1998              $attributes = array();
1999  
2000              foreach ( $attr_parts as $part ) {
2001                  if ( ! str_contains( $part, '=' ) ) {
2002                      continue;
2003                  }
2004  
2005                  list( $key, $value ) = explode( '=', $part, 2 );
2006  
2007                  $attributes[ trim( $key ) ] = trim( $value );
2008              }
2009  
2010              if ( empty( $attributes['filename'] ) ) {
2011                  continue;
2012              }
2013  
2014              $filename = trim( $attributes['filename'] );
2015  
2016              // Unquote quoted filename, but after trimming.
2017              if ( str_starts_with( $filename, '"' ) && str_ends_with( $filename, '"' ) ) {
2018                  $filename = substr( $filename, 1, -1 );
2019              }
2020          }
2021  
2022          return $filename;
2023      }
2024  
2025      /**
2026       * Retrieves the query params for collections of attachments.
2027       *
2028       * @since 4.7.0
2029       * @since 6.9.0 Extends the `media_type` and `mime_type` request arguments to support array values.
2030       *
2031       * @return array Query parameters for the attachment collection as an array.
2032       */
2033  	public function get_collection_params() {
2034          $params                            = parent::get_collection_params();
2035          $params['status']['default']       = 'inherit';
2036          $params['status']['items']['enum'] = array( 'inherit', 'private', 'trash' );
2037          $media_types                       = array_keys( $this->get_media_types() );
2038  
2039          $params['media_type'] = array(
2040              'default'     => null,
2041              'description' => __( 'Limit result set to attachments of a particular media type or media types.' ),
2042              'type'        => 'array',
2043              'items'       => array(
2044                  'type' => 'string',
2045                  'enum' => $media_types,
2046              ),
2047          );
2048  
2049          $params['mime_type'] = array(
2050              'default'     => null,
2051              'description' => __( 'Limit result set to attachments of a particular MIME type or MIME types.' ),
2052              'type'        => 'array',
2053              'items'       => array(
2054                  'type' => 'string',
2055              ),
2056          );
2057  
2058          return $params;
2059      }
2060  
2061      /**
2062       * Handles an upload via multipart/form-data ($_FILES).
2063       *
2064       * @since 4.7.0
2065       * @since 6.6.0 Added the `$time` parameter.
2066       *
2067       * @param array       $files   Data from the `$_FILES` superglobal.
2068       * @param array       $headers HTTP headers from the request.
2069       * @param string|null $time    Optional. Time formatted in 'yyyy/mm'. Default null.
2070       * @return array{ file: non-empty-string, url: non-empty-string, type: non-empty-string }|WP_Error Data from wp_handle_upload().
2071       */
2072  	protected function upload_from_file( $files, $headers, $time = null ) {
2073          if ( empty( $files ) ) {
2074              return new WP_Error(
2075                  'rest_upload_no_data',
2076                  __( 'No data supplied.' ),
2077                  array( 'status' => 400 )
2078              );
2079          }
2080  
2081          // Verify hash, if given.
2082          if ( ! empty( $headers['content_md5'] ) ) {
2083              $content_md5 = array_shift( $headers['content_md5'] );
2084              $expected    = trim( $content_md5 );
2085              $actual      = md5_file( $files['file']['tmp_name'] );
2086  
2087              if ( $expected !== $actual ) {
2088                  return new WP_Error(
2089                      'rest_upload_hash_mismatch',
2090                      __( 'Content hash did not match expected.' ),
2091                      array( 'status' => 412 )
2092                  );
2093              }
2094          }
2095  
2096          // Pass off to WP to handle the actual upload.
2097          $overrides = array(
2098              'test_form' => false,
2099          );
2100  
2101          // Bypasses is_uploaded_file() when running unit tests.
2102          if ( defined( 'DIR_TESTDATA' ) && DIR_TESTDATA ) {
2103              $overrides['action'] = 'wp_handle_mock_upload';
2104          }
2105  
2106          $size_check = self::check_upload_size( $files['file'] );
2107          if ( is_wp_error( $size_check ) ) {
2108              return $size_check;
2109          }
2110  
2111          // Include filesystem functions to get access to wp_handle_upload().
2112          require_once  ABSPATH . 'wp-admin/includes/file.php';
2113  
2114          $file = wp_handle_upload( $files['file'], $overrides, $time );
2115  
2116          if ( isset( $file['error'] ) ) {
2117              return new WP_Error(
2118                  'rest_upload_unknown_error',
2119                  $file['error'],
2120                  array( 'status' => 500 )
2121              );
2122          }
2123  
2124          return $file;
2125      }
2126  
2127      /**
2128       * Retrieves the supported media types.
2129       *
2130       * Media types are considered the MIME type category.
2131       *
2132       * @since 4.7.0
2133       *
2134       * @return array Array of supported media types.
2135       */
2136  	protected function get_media_types() {
2137          $media_types = array();
2138  
2139          foreach ( get_allowed_mime_types() as $mime_type ) {
2140              $parts = explode( '/', $mime_type );
2141  
2142              if ( ! isset( $media_types[ $parts[0] ] ) ) {
2143                  $media_types[ $parts[0] ] = array();
2144              }
2145  
2146              $media_types[ $parts[0] ][] = $mime_type;
2147          }
2148  
2149          return $media_types;
2150      }
2151  
2152      /**
2153       * Determine if uploaded file exceeds space quota on multisite.
2154       *
2155       * Replicates check_upload_size().
2156       *
2157       * @since 4.9.8
2158       *
2159       * @param array $file $_FILES array for a given file.
2160       * @return true|WP_Error True if can upload, error for errors.
2161       */
2162  	protected function check_upload_size( $file ) {
2163          if ( ! is_multisite() ) {
2164              return true;
2165          }
2166  
2167          if ( get_site_option( 'upload_space_check_disabled' ) ) {
2168              return true;
2169          }
2170  
2171          $space_left = get_upload_space_available();
2172  
2173          $file_size = filesize( $file['tmp_name'] );
2174  
2175          if ( $space_left < $file_size ) {
2176              return new WP_Error(
2177                  'rest_upload_limited_space',
2178                  /* translators: %s: Required disk space in kilobytes. */
2179                  sprintf( __( 'Not enough space to upload. %s KB needed.' ), number_format( ( $file_size - $space_left ) / KB_IN_BYTES ) ),
2180                  array( 'status' => 400 )
2181              );
2182          }
2183  
2184          if ( $file_size > ( KB_IN_BYTES * get_site_option( 'fileupload_maxk', 1500 ) ) ) {
2185              return new WP_Error(
2186                  'rest_upload_file_too_big',
2187                  /* translators: %s: Maximum allowed file size in kilobytes. */
2188                  sprintf( __( 'This file is too big. Files must be less than %s KB in size.' ), get_site_option( 'fileupload_maxk', 1500 ) ),
2189                  array( 'status' => 400 )
2190              );
2191          }
2192  
2193          // Include multisite admin functions to get access to upload_is_user_over_quota().
2194          require_once  ABSPATH . 'wp-admin/includes/ms.php';
2195  
2196          if ( upload_is_user_over_quota( false ) ) {
2197              return new WP_Error(
2198                  'rest_upload_user_quota_exceeded',
2199                  __( 'You have used your space quota. Please delete files before uploading.' ),
2200                  array( 'status' => 400 )
2201              );
2202          }
2203  
2204          return true;
2205      }
2206  
2207      /**
2208       * Gets the request args for the edit item route.
2209       *
2210       * @since 5.5.0
2211       * @since 6.9.0 Adds flips capability and editable fields for the newly-created attachment post.
2212       *
2213       * @return array
2214       */
2215  	protected function get_edit_media_item_args() {
2216          $args = array(
2217              'src'       => array(
2218                  'description' => __( 'URL to the edited image file.' ),
2219                  'type'        => 'string',
2220                  'format'      => 'uri',
2221                  'required'    => true,
2222              ),
2223              // The `modifiers` param takes precedence over the older format.
2224              'modifiers' => array(
2225                  'description' => __( 'Array of image edits.' ),
2226                  'type'        => 'array',
2227                  'minItems'    => 1,
2228                  'items'       => array(
2229                      'description' => __( 'Image edit.' ),
2230                      'type'        => 'object',
2231                      'required'    => array(
2232                          'type',
2233                          'args',
2234                      ),
2235                      'oneOf'       => array(
2236                          array(
2237                              'title'      => __( 'Flip' ),
2238                              'properties' => array(
2239                                  'type' => array(
2240                                      'description' => __( 'Flip type.' ),
2241                                      'type'        => 'string',
2242                                      'enum'        => array( 'flip' ),
2243                                  ),
2244                                  'args' => array(
2245                                      'description' => __( 'Flip arguments.' ),
2246                                      'type'        => 'object',
2247                                      'required'    => array(
2248                                          'flip',
2249                                      ),
2250                                      'properties'  => array(
2251                                          'flip' => array(
2252                                              'description' => __( 'Flip direction.' ),
2253                                              'type'        => 'object',
2254                                              'required'    => array(
2255                                                  'horizontal',
2256                                                  'vertical',
2257                                              ),
2258                                              'properties'  => array(
2259                                                  'horizontal' => array(
2260                                                      'description' => __( 'Whether to flip in the horizontal direction.' ),
2261                                                      'type' => 'boolean',
2262                                                  ),
2263                                                  'vertical' => array(
2264                                                      'description' => __( 'Whether to flip in the vertical direction.' ),
2265                                                      'type' => 'boolean',
2266                                                  ),
2267                                              ),
2268                                          ),
2269                                      ),
2270                                  ),
2271                              ),
2272                          ),
2273                          array(
2274                              'title'      => __( 'Rotation' ),
2275                              'properties' => array(
2276                                  'type' => array(
2277                                      'description' => __( 'Rotation type.' ),
2278                                      'type'        => 'string',
2279                                      'enum'        => array( 'rotate' ),
2280                                  ),
2281                                  'args' => array(
2282                                      'description' => __( 'Rotation arguments.' ),
2283                                      'type'        => 'object',
2284                                      'required'    => array(
2285                                          'angle',
2286                                      ),
2287                                      'properties'  => array(
2288                                          'angle' => array(
2289                                              'description' => __( 'Angle to rotate clockwise in degrees.' ),
2290                                              'type'        => 'number',
2291                                          ),
2292                                      ),
2293                                  ),
2294                              ),
2295                          ),
2296                          array(
2297                              'title'      => __( 'Crop' ),
2298                              'properties' => array(
2299                                  'type' => array(
2300                                      'description' => __( 'Crop type.' ),
2301                                      'type'        => 'string',
2302                                      'enum'        => array( 'crop' ),
2303                                  ),
2304                                  'args' => array(
2305                                      'description' => __( 'Crop arguments.' ),
2306                                      'type'        => 'object',
2307                                      'required'    => array(
2308                                          'left',
2309                                          'top',
2310                                          'width',
2311                                          'height',
2312                                      ),
2313                                      'properties'  => array(
2314                                          'left'   => array(
2315                                              'description' => __( 'Horizontal position from the left to begin the crop as a percentage of the image width.' ),
2316                                              'type'        => 'number',
2317                                          ),
2318                                          'top'    => array(
2319                                              'description' => __( 'Vertical position from the top to begin the crop as a percentage of the image height.' ),
2320                                              'type'        => 'number',
2321                                          ),
2322                                          'width'  => array(
2323                                              'description' => __( 'Width of the crop as a percentage of the image width.' ),
2324                                              'type'        => 'number',
2325                                          ),
2326                                          'height' => array(
2327                                              'description' => __( 'Height of the crop as a percentage of the image height.' ),
2328                                              'type'        => 'number',
2329                                          ),
2330                                      ),
2331                                  ),
2332                              ),
2333                          ),
2334                      ),
2335                  ),
2336              ),
2337              'rotation'  => array(
2338                  'description'      => __( 'The amount to rotate the image clockwise in degrees. DEPRECATED: Use `modifiers` instead.' ),
2339                  'type'             => 'integer',
2340                  'minimum'          => 0,
2341                  'exclusiveMinimum' => true,
2342                  'maximum'          => 360,
2343                  'exclusiveMaximum' => true,
2344              ),
2345              'x'         => array(
2346                  'description' => __( 'As a percentage of the image, the x position to start the crop from. DEPRECATED: Use `modifiers` instead.' ),
2347                  'type'        => 'number',
2348                  'minimum'     => 0,
2349                  'maximum'     => 100,
2350              ),
2351              'y'         => array(
2352                  'description' => __( 'As a percentage of the image, the y position to start the crop from. DEPRECATED: Use `modifiers` instead.' ),
2353                  'type'        => 'number',
2354                  'minimum'     => 0,
2355                  'maximum'     => 100,
2356              ),
2357              'width'     => array(
2358                  'description' => __( 'As a percentage of the image, the width to crop the image to. DEPRECATED: Use `modifiers` instead.' ),
2359                  'type'        => 'number',
2360                  'minimum'     => 0,
2361                  'maximum'     => 100,
2362              ),
2363              'height'    => array(
2364                  'description' => __( 'As a percentage of the image, the height to crop the image to. DEPRECATED: Use `modifiers` instead.' ),
2365                  'type'        => 'number',
2366                  'minimum'     => 0,
2367                  'maximum'     => 100,
2368              ),
2369          );
2370  
2371          /*
2372           * Get the args based on the post schema. This calls `rest_get_endpoint_args_for_schema()`,
2373           * which also takes care of sanitization and validation.
2374           */
2375          $update_item_args = $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE );
2376  
2377          if ( isset( $update_item_args['caption'] ) ) {
2378              $args['caption'] = $update_item_args['caption'];
2379          }
2380  
2381          if ( isset( $update_item_args['description'] ) ) {
2382              $args['description'] = $update_item_args['description'];
2383          }
2384  
2385          if ( isset( $update_item_args['title'] ) ) {
2386              $args['title'] = $update_item_args['title'];
2387          }
2388  
2389          if ( isset( $update_item_args['post'] ) ) {
2390              $args['post'] = $update_item_args['post'];
2391          }
2392  
2393          if ( isset( $update_item_args['alt_text'] ) ) {
2394              $args['alt_text'] = $update_item_args['alt_text'];
2395          }
2396  
2397          return $args;
2398      }
2399  
2400      /**
2401       * Gets the attachment's original file name.
2402       *
2403       * @since 7.0.0
2404       *
2405       * @param int $attachment_id Attachment ID.
2406       * @return string|null Attachment file name, or null if not found.
2407       */
2408  	protected function get_attachment_filename( int $attachment_id ): ?string {
2409          $path = wp_get_original_image_path( $attachment_id );
2410  
2411          if ( $path ) {
2412              return wp_basename( $path );
2413          }
2414  
2415          $path = get_attached_file( $attachment_id );
2416  
2417          if ( $path ) {
2418              return wp_basename( $path );
2419          }
2420  
2421          return null;
2422      }
2423  
2424      /**
2425       * Gets the attachment's file size in bytes.
2426       *
2427       * @since 7.0.0
2428       *
2429       * @param int $attachment_id Attachment ID.
2430       * @return int|null Attachment file size in bytes, or null if not available.
2431       * @phpstan-return non-negative-int|null
2432       */
2433  	protected function get_attachment_filesize( int $attachment_id ): ?int {
2434          $meta = wp_get_attachment_metadata( $attachment_id );
2435  
2436          if ( isset( $meta['filesize'] ) && is_numeric( $meta['filesize'] ) && $meta['filesize'] > 0 ) {
2437              return (int) $meta['filesize'];
2438          }
2439  
2440          $original_path = wp_get_original_image_path( $attachment_id );
2441          $attached_file = $original_path ? $original_path : get_attached_file( $attachment_id );
2442  
2443          if ( is_string( $attached_file ) && is_readable( $attached_file ) ) {
2444              return wp_filesize( $attached_file );
2445          }
2446  
2447          return null;
2448      }
2449  
2450      /**
2451       * Checks if a given request has access to sideload a file.
2452       *
2453       * Sideloading a file for an existing attachment
2454       * requires both update and create permissions.
2455       *
2456       * @since 7.1.0
2457       *
2458       * @param WP_REST_Request $request Full details about the request.
2459       * @return true|WP_Error True if the request has access to update the item, WP_Error object otherwise.
2460       */
2461  	public function sideload_item_permissions_check( $request ) {
2462          return $this->edit_media_item_permissions_check( $request );
2463      }
2464  
2465      /**
2466       * Validates that uploaded image dimensions are appropriate for the specified image size.
2467       *
2468       * @since 7.1.0
2469       *
2470       * @param int    $width         Uploaded image width.
2471       * @param int    $height        Uploaded image height.
2472       * @param string $image_size    The target image size name.
2473       * @param int    $attachment_id The attachment ID.
2474       * @return true|WP_Error True if valid, WP_Error if invalid.
2475       */
2476  	private function validate_image_dimensions( int $width, int $height, string $image_size, int $attachment_id ) {
2477          // All image sizes require positive dimensions.
2478          if ( $width <= 0 || $height <= 0 ) {
2479              return new WP_Error(
2480                  'rest_upload_invalid_dimensions',
2481                  __( 'Uploaded image must have positive dimensions.' ),
2482                  array( 'status' => 400 )
2483              );
2484          }
2485  
2486          /*
2487           * 'original' size: the full-size image that replaces the main file (see
2488           * sideload_item()/finalize_item()). The endpoint expects any EXIF
2489           * orientation to be applied to the image already, which can swap width
2490           * and height, so the dimensions must match the stored dimensions or be
2491           * their transpose.
2492           */
2493          if ( 'original' === $image_size ) {
2494              $metadata = wp_get_attachment_metadata( $attachment_id, true );
2495              if ( is_array( $metadata ) && isset( $metadata['width'], $metadata['height'] ) ) {
2496                  $expected_width  = (int) $metadata['width'];
2497                  $expected_height = (int) $metadata['height'];
2498  
2499                  $matches_dimensions    = $width === $expected_width && $height === $expected_height;
2500                  $transposes_dimensions = $width === $expected_height && $height === $expected_width;
2501  
2502                  if ( ! $matches_dimensions && ! $transposes_dimensions ) {
2503                      return new WP_Error(
2504                          'rest_upload_dimension_mismatch',
2505                          sprintf(
2506                              /* translators: 1: Actual width, 2: actual height, 3: expected width, 4: expected height. */
2507                              __( 'Uploaded image dimensions (%1$dx%2$d) do not match original image dimensions (%3$dx%4$d).' ),
2508                              $width,
2509                              $height,
2510                              $expected_width,
2511                              $expected_height
2512                          ),
2513                          array( 'status' => 400 )
2514                      );
2515                  }
2516              }
2517              return true;
2518          }
2519  
2520          // 'full' size (PDF thumbnails) and 'scaled': no further constraints.
2521          if ( in_array( $image_size, array( 'full', 'scaled' ), true ) ) {
2522              return true;
2523          }
2524  
2525          /*
2526           * 'animated_video_poster' companion: a static poster image for the
2527           * converted video. It is a real image (so it has positive dimensions)
2528           * but is not a registered sub-size, so it has no dimension constraint.
2529           */
2530          if ( 'animated_video_poster' === $image_size ) {
2531              return true;
2532          }
2533  
2534          // Regular image sizes: validate against registered size constraints.
2535          $registered_sizes = wp_get_registered_image_subsizes();
2536  
2537          if ( ! isset( $registered_sizes[ $image_size ] ) ) {
2538              return new WP_Error(
2539                  'rest_upload_unknown_size',
2540                  __( 'Unknown image size.' ),
2541                  array( 'status' => 400 )
2542              );
2543          }
2544  
2545          $size_data  = $registered_sizes[ $image_size ];
2546          $max_width  = (int) $size_data['width'];
2547          $max_height = (int) $size_data['height'];
2548  
2549          // Validate dimensions don't exceed the registered size maximums.
2550          // Allow 1px tolerance for rounding differences.
2551          $tolerance = 1;
2552  
2553          if ( $this->dimension_exceeds_max( $width, $max_width, $tolerance ) ) {
2554              return new WP_Error(
2555                  'rest_upload_dimension_mismatch',
2556                  sprintf(
2557                      /* translators: 1: Image size name, 2: maximum width, 3: actual width. */
2558                      __( 'Uploaded image width (%3$d) exceeds maximum for "%1$s" size (%2$d).' ),
2559                      $image_size,
2560                      $max_width,
2561                      $width
2562                  ),
2563                  array( 'status' => 400 )
2564              );
2565          }
2566  
2567          if ( $this->dimension_exceeds_max( $height, $max_height, $tolerance ) ) {
2568              return new WP_Error(
2569                  'rest_upload_dimension_mismatch',
2570                  sprintf(
2571                      /* translators: 1: Image size name, 2: maximum height, 3: actual height. */
2572                      __( 'Uploaded image height (%3$d) exceeds maximum for "%1$s" size (%2$d).' ),
2573                      $image_size,
2574                      $max_height,
2575                      $height
2576                  ),
2577                  array( 'status' => 400 )
2578              );
2579          }
2580  
2581          return true;
2582      }
2583  
2584      /**
2585       * Checks whether a dimension exceeds the maximum allowed value.
2586       *
2587       * A maximum of zero means the dimension is unconstrained.
2588       *
2589       * @since 7.1.0
2590       *
2591       * @param int $value     The actual dimension in pixels.
2592       * @param int $max       The maximum allowed dimension in pixels. Zero means no constraint.
2593       * @param int $tolerance Pixel tolerance allowed for rounding differences.
2594       * @return bool True if the value exceeds the maximum plus tolerance.
2595       */
2596  	private function dimension_exceeds_max( int $value, int $max, int $tolerance ): bool {
2597          return $max > 0 && $value > $max + $tolerance;
2598      }
2599  
2600      /**
2601       * Side-loads a media file without creating a new attachment.
2602       *
2603       * @since 7.1.0
2604       *
2605       * @param WP_REST_Request $request Full details about the request.
2606       * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
2607       */
2608  	public function sideload_item( WP_REST_Request $request ) {
2609          $attachment_id = (int) $request['id'];
2610  
2611          $post = $this->get_post( $attachment_id );
2612  
2613          if ( is_wp_error( $post ) ) {
2614              return $post;
2615          }
2616  
2617          if (
2618              ! wp_attachment_is_image( $post ) &&
2619              ! wp_attachment_is( 'pdf', $post )
2620          ) {
2621              return new WP_Error(
2622                  'rest_post_invalid_id',
2623                  __( 'Invalid post ID. Only images and PDFs can be sideloaded.' ),
2624                  array( 'status' => 400 )
2625              );
2626          }
2627  
2628          if ( false === $request['convert_format'] ) {
2629              // Prevent image conversion as that is done client-side.
2630              add_filter( 'image_editor_output_format', '__return_empty_array', 100 );
2631          }
2632  
2633          // Get the file via $_FILES or raw data.
2634          $files   = $request->get_file_params();
2635          $headers = $request->get_headers();
2636  
2637          /*
2638           * wp_unique_filename() will always add numeric suffix if the name looks like a sub-size to avoid conflicts.
2639           * See /wp-includes/functions.php.
2640           * With the following filter we can work around this safeguard.
2641           */
2642          $attachment_filename = get_attached_file( $attachment_id, true );
2643          $attachment_filename = $attachment_filename ? wp_basename( $attachment_filename ) : null;
2644  
2645          $filter_filename = static function ( $filename, $ext, $dir, $unique_filename_callback, $alt_filenames, $number ) use ( $attachment_filename ) {
2646              return self::filter_wp_unique_filename( $filename, $dir, $number, $attachment_filename );
2647          };
2648  
2649          add_filter( 'wp_unique_filename', $filter_filename, 10, 6 );
2650  
2651          $parent_post = get_post_parent( $attachment_id );
2652  
2653          $time = null;
2654  
2655          // Matches logic in media_handle_upload().
2656          // The post date doesn't usually matter for pages, so don't backdate this upload.
2657          if ( $parent_post && 'page' !== $parent_post->post_type && ! str_starts_with( $parent_post->post_date, '0000-00-00' ) ) {
2658              $time = $parent_post->post_date;
2659          }
2660  
2661          if ( ! empty( $files ) ) {
2662              $file = $this->upload_from_file( $files, $headers, $time );
2663          } else {
2664              $file = $this->upload_from_data( $request->get_body(), $headers, $time );
2665          }
2666  
2667          remove_filter( 'wp_unique_filename', $filter_filename );
2668          remove_filter( 'image_editor_output_format', '__return_empty_array', 100 );
2669  
2670          if ( is_wp_error( $file ) ) {
2671              return $file;
2672          }
2673  
2674          $type = $file['type'];
2675          $path = $file['file'];
2676  
2677          /** @var non-empty-string $image_size */
2678          $image_size = $request['image_size'];
2679  
2680          /*
2681           * Validate raster sub-sizes before storing them. Two companion sizes
2682           * are exempt because wp_getimagesize() may not be able to read the
2683           * file at all: the 'animated_video' companion of an animated GIF is a
2684           * video (MP4/WebM), and a source-format original (e.g. a HEIC or JXL
2685           * kept next to its JPEG derivative) may be an unreadable format. Their
2686           * dimensions are neither validated nor recorded. The
2687           * 'animated_video_poster' companion is a real image, so it is still
2688           * read and rejected if unreadable; validate_image_dimensions() skips
2689           * only the registered-size constraint for it.
2690           */
2691          $skip_dimension_read = in_array( $image_size, array( self::IMAGE_SIZE_SOURCE_ORIGINAL, 'animated_video' ), true );
2692  
2693          if ( ! $skip_dimension_read ) {
2694              /*
2695               * Read the dimensions up front. A file whose dimensions cannot be
2696               * read is corrupted or an unsupported format and must be rejected
2697               * rather than silently stored with zero dimensions.
2698               */
2699              $size = wp_getimagesize( $path );
2700  
2701              if ( ! $size ) {
2702                  // Clean up the uploaded file.
2703                  wp_delete_file( $path );
2704                  return new WP_Error(
2705                      'rest_upload_invalid_image',
2706                      __( 'Could not read image dimensions. The file may be corrupted or an unsupported format.' ),
2707                      array( 'status' => 400 )
2708                  );
2709              }
2710  
2711              /*
2712               * Validate the dimensions match the expected size. An array
2713               * $image_size represents multiple registered sizes sharing a single
2714               * file; those are handled by the per-size branch below, so only
2715               * scalar sizes are validated here.
2716               */
2717              if ( ! is_array( $image_size ) ) {
2718                  $validation = $this->validate_image_dimensions( $size[0], $size[1], $image_size, $attachment_id );
2719                  if ( is_wp_error( $validation ) ) {
2720                      // Clean up the uploaded file.
2721                      wp_delete_file( $path );
2722                      return $validation;
2723                  }
2724              }
2725          }
2726  
2727          // Build sub-size data to return to the client.
2728          // The client accumulates these and sends them all to the finalize
2729          // endpoint, which writes the metadata in a single operation. This
2730          // avoids the read-modify-write race that concurrent sideloads for the
2731          // same attachment would otherwise hit.
2732          $sub_size_data = array(
2733              'image_size' => $image_size,
2734          );
2735  
2736          if ( is_array( $image_size ) ) {
2737              // Multiple registered sizes share these dimensions, so a single
2738              // sideloaded file is reused for all of them. Arrays only carry
2739              // regular sub-sizes; the special keys below are always scalar.
2740              $size = wp_getimagesize( $path );
2741  
2742              $sub_size_data['width']     = $size ? $size[0] : 0;
2743              $sub_size_data['height']    = $size ? $size[1] : 0;
2744              $sub_size_data['file']      = wp_basename( $path );
2745              $sub_size_data['mime_type'] = $type;
2746              $sub_size_data['filesize']  = wp_filesize( $path );
2747          } elseif ( self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size ) {
2748              /*
2749               * Source-format original (e.g. the HEIC kept next to its JPEG
2750               * derivative). Record the filename so finalize_item can store it
2751               * under the dedicated source-image meta key.
2752               */
2753              $sub_size_data['file'] = wp_basename( $path );
2754          } elseif ( 'animated_video' === $image_size || 'animated_video_poster' === $image_size ) {
2755              /*
2756               * Converted-video companion of an animated GIF (the MP4/WebM or
2757               * its static first-frame poster). Record the filename so
2758               * finalize_item can store it under its dedicated meta key.
2759               */
2760              $sub_size_data['file'] = wp_basename( $path );
2761          } elseif ( 'scaled' === $image_size || 'original' === $image_size ) {
2762              /*
2763               * 'scaled' and 'original' both replace the attachment's main file
2764               * with the supplied image and keep the file being replaced as
2765               * `original_image`, which is the untouched upload. A 'scaled'
2766               * image is downsized and an 'original' image has any EXIF
2767               * orientation already applied. This is the same swap WordPress
2768               * makes when it scales or rotates an image on upload; see
2769               * _wp_image_meta_replace_original().
2770               */
2771              $current_file = get_attached_file( $attachment_id, true );
2772  
2773              if ( ! $current_file ) {
2774                  return new WP_Error(
2775                      'rest_sideload_no_attached_file',
2776                      __( 'Unable to retrieve the attached file for this attachment.' ),
2777                      array( 'status' => 404 )
2778                  );
2779              }
2780  
2781              $sub_size_data['original_image'] = wp_basename( $current_file );
2782  
2783              // Validate the supplied image before updating the attached file.
2784              $size     = wp_getimagesize( $path );
2785              $filesize = wp_filesize( $path );
2786  
2787              if ( ! $size || ! $filesize ) {
2788                  return new WP_Error(
2789                      'rest_sideload_invalid_image',
2790                      __( 'Unable to read the sideloaded image file.' ),
2791                      array( 'status' => 500 )
2792                  );
2793              }
2794  
2795              // Update the attached file to point to the supplied image.
2796              // This writes to _wp_attached_file meta, not _wp_attachment_metadata.
2797              if (
2798                  get_attached_file( $attachment_id, true ) !== $path &&
2799                  ! update_attached_file( $attachment_id, $path )
2800              ) {
2801                  return new WP_Error(
2802                      'rest_sideload_update_attached_file_failed',
2803                      __( 'Unable to update the attached file for this attachment.' ),
2804                      array( 'status' => 500 )
2805                  );
2806              }
2807  
2808              $sub_size_data['width']    = $size[0];
2809              $sub_size_data['height']   = $size[1];
2810              $sub_size_data['filesize'] = $filesize;
2811              $sub_size_data['file']     = _wp_relative_upload_path( $path );
2812          } else {
2813              $size = wp_getimagesize( $path );
2814  
2815              $sub_size_data['width']     = $size ? $size[0] : 0;
2816              $sub_size_data['height']    = $size ? $size[1] : 0;
2817              $sub_size_data['file']      = wp_basename( $path );
2818              $sub_size_data['mime_type'] = $type;
2819              $sub_size_data['filesize']  = wp_filesize( $path );
2820          }
2821  
2822          return rest_ensure_response( $sub_size_data );
2823      }
2824  
2825      /**
2826       * Filters wp_unique_filename during sideloads.
2827       *
2828       * wp_unique_filename() will always add numeric suffix if the name looks like a sub-size to avoid conflicts.
2829       * Adding this closure to the filter helps work around this safeguard.
2830       *
2831       * Example: when uploading myphoto.jpeg, WordPress normally creates myphoto-150x150.jpeg,
2832       * and when uploading myphoto-150x150.jpeg, it will be renamed to myphoto-150x150-1.jpeg
2833       * However, here it is desired not to add the suffix in order to maintain the same
2834       * naming convention as if the file was uploaded regularly.
2835       *
2836       * @since 7.1.0
2837       *
2838       * @link https://github.com/WordPress/wordpress-develop/blob/30954f7ac0840cfdad464928021d7f380940c347/src/wp-includes/functions.php#L2576-L2582
2839       *
2840       * @param string      $filename            Unique file name.
2841       * @param string      $dir                 Directory path.
2842       * @param int|string  $number              The highest number that was used to make the file name unique
2843       *                                         or an empty string if unused.
2844       * @param string|null $attachment_filename Original attachment file name.
2845       * @return string Filtered file name.
2846       */
2847  	private static function filter_wp_unique_filename( $filename, $dir, $number, $attachment_filename ) {
2848          if ( ! is_int( $number ) || ! $attachment_filename ) {
2849              return $filename;
2850          }
2851  
2852          $ext       = pathinfo( $filename, PATHINFO_EXTENSION );
2853          $name      = pathinfo( $filename, PATHINFO_FILENAME );
2854          $orig_name = pathinfo( $attachment_filename, PATHINFO_FILENAME );
2855  
2856          if ( ! $ext || ! $name ) {
2857              return $filename;
2858          }
2859  
2860          $matches = array();
2861          if ( preg_match( '/(.*)-(\d+x\d+|scaled)-' . $number . '$/', $name, $matches ) ) {
2862              $filename_without_suffix = $matches[1] . '-' . $matches[2] . ".$ext";
2863              if ( $matches[1] === $orig_name && ! file_exists( "$dir/$filename_without_suffix" ) ) {
2864                  return $filename_without_suffix;
2865              }
2866          }
2867  
2868          return $filename;
2869      }
2870  
2871      /**
2872       * Finalizes an attachment after client-side media processing.
2873       *
2874       * Applies the sub-size metadata collected from sideload responses in a
2875       * single metadata update, then triggers the 'wp_generate_attachment_metadata'
2876       * filter so that server-side plugins can process the attachment after all
2877       * client-side operations (upload, thumbnail generation, sideloads) are
2878       * complete.
2879       *
2880       * @since 7.1.0
2881       *
2882       * @param WP_REST_Request $request Full details about the request.
2883       * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
2884       */
2885  	public function finalize_item( WP_REST_Request $request ) {
2886          $attachment_id = (int) $request['id'];
2887  
2888          $post = $this->get_post( $attachment_id );
2889          if ( is_wp_error( $post ) ) {
2890              return $post;
2891          }
2892  
2893          $metadata = wp_get_attachment_metadata( $attachment_id );
2894          if ( ! is_array( $metadata ) ) {
2895              $metadata = array();
2896          }
2897  
2898          // Apply all sub-size metadata collected from sideload responses.
2899          $sub_sizes = $request['sub_sizes'] ?? array();
2900  
2901          foreach ( $sub_sizes as $sub_size ) {
2902              $image_size = $sub_size['image_size'];
2903  
2904              // When multiple size names share identical dimensions the client
2905              // sends a single sub-size entry with an array of names. Register the
2906              // same file under each name. Arrays only contain regular sizes.
2907              if ( is_array( $image_size ) ) {
2908                  $metadata['sizes'] = $metadata['sizes'] ?? array();
2909  
2910                  foreach ( $image_size as $name ) {
2911                      $metadata['sizes'][ $name ] = array(
2912                          'width'     => $sub_size['width'] ?? 0,
2913                          'height'    => $sub_size['height'] ?? 0,
2914                          'file'      => $sub_size['file'] ?? '',
2915                          'mime-type' => $sub_size['mime_type'] ?? '',
2916                          'filesize'  => $sub_size['filesize'] ?? 0,
2917                      );
2918                  }
2919                  continue;
2920              }
2921  
2922              if ( 'original' === $image_size || 'scaled' === $image_size ) {
2923                  // Skip malformed entries so a bad payload cannot blank out the
2924                  // main file metadata.
2925                  if ( empty( $sub_size['file'] ) ) {
2926                      continue;
2927                  }
2928  
2929                  /*
2930                   * Record the supplied full-size image (from sideload_item()) as
2931                   * the main file, keeping the current attached file as
2932                   * `original_image`. A 'scaled' image is downsized and an
2933                   * 'original' image is rotated; both have any EXIF orientation
2934                   * already applied by the client.
2935                   */
2936                  if ( ! empty( $sub_size['original_image'] ) ) {
2937                      $metadata['original_image'] = $sub_size['original_image'];
2938                  }
2939                  $metadata['width']    = $sub_size['width'] ?? 0;
2940                  $metadata['height']   = $sub_size['height'] ?? 0;
2941                  $metadata['filesize'] = $sub_size['filesize'] ?? 0;
2942                  $metadata['file']     = $sub_size['file'];
2943  
2944                  /*
2945                   * The supplied image has its orientation applied already, so
2946                   * reset the stored value (from the upload) to 1, as
2947                   * wp_create_image_subsizes() does for both its scale and rotate
2948                   * paths. Otherwise exif_orientation would still report the
2949                   * pre-rotation value and the client would rotate the image
2950                   * again on a re-fetch.
2951                   */
2952                  if ( ! empty( $metadata['image_meta']['orientation'] ) ) {
2953                      $metadata['image_meta']['orientation'] = 1;
2954                  }
2955              } elseif ( self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size ) {
2956                  /*
2957                   * Source-format original: stored under its own meta key so the
2958                   * scaled-sideload flow (which writes 'original_image') cannot
2959                   * clobber it. 'original_image' keeps pointing at the
2960                   * web-viewable JPEG derivative. Cleanup on attachment delete
2961                   * is handled by wp_delete_attachment_files().
2962                   */
2963                  $metadata[ self::META_KEY_SOURCE_IMAGE ] = $sub_size['file'];
2964              } elseif ( 'animated_video' === $image_size ) {
2965                  /*
2966                   * Converted-video companion of an animated GIF. Stored under its
2967                   * own meta key; 'original_image' keeps pointing at the GIF. Cleanup
2968                   * on attachment delete is handled by wp_delete_attachment_files().
2969                   */
2970                  $metadata['animated_video'] = $sub_size['file'];
2971              } elseif ( 'animated_video_poster' === $image_size ) {
2972                  // Static first-frame poster for the converted video.
2973                  $metadata['animated_video_poster'] = $sub_size['file'];
2974              } else {
2975                  $metadata['sizes'] = $metadata['sizes'] ?? array();
2976  
2977                  $metadata['sizes'][ $image_size ] = array(
2978                      'width'     => $sub_size['width'] ?? 0,
2979                      'height'    => $sub_size['height'] ?? 0,
2980                      'file'      => $sub_size['file'] ?? '',
2981                      'mime-type' => $sub_size['mime_type'] ?? '',
2982                      'filesize'  => $sub_size['filesize'] ?? 0,
2983                  );
2984              }
2985          }
2986  
2987          /** This filter is documented in wp-admin/includes/image.php */
2988          $metadata = apply_filters( 'wp_generate_attachment_metadata', $metadata, $attachment_id, 'update' );
2989  
2990          wp_update_attachment_metadata( $attachment_id, $metadata );
2991  
2992          $response_request = new WP_REST_Request(
2993              WP_REST_Server::READABLE,
2994              rest_get_route_for_post( $attachment_id )
2995          );
2996  
2997          $response_request['context'] = 'edit';
2998  
2999          if ( isset( $request['_fields'] ) ) {
3000              $response_request['_fields'] = $request['_fields'];
3001          }
3002  
3003          return $this->prepare_item_for_response( $post, $response_request );
3004      }
3005  }


Generated : Sat Aug 8 08:20:21 2026 Cross-referenced by PHPXref