ProShell v2.0
Dashboard
Server Info
Server: 158.106.128.192
PHP: 8.2.33
home
planet5
public_html
bansibaba.com
2026-09-12 21:11:14
Editing: class-wp-rest-attachments-controller.php
Cancel
Save Changes
<?php /** * REST API: WP_REST_Attachments_Controller class * * @package WordPress * @subpackage REST_API * @since 4.7.0 */ /** * Core controller used to access attachments via the REST API. * * @since 4.7.0 * * @see WP_REST_Posts_Controller * * @phpstan-type Image_Sub_Size array{ * image_size: non-empty-string|non-empty-list<non-empty-string>, * width?: positive-int, * height?: positive-int, * file?: non-empty-string, * mime_type?: non-empty-string, * filesize?: positive-int, * original_image?: non-empty-string, * } */ class WP_REST_Attachments_Controller extends WP_REST_Posts_Controller { /** * Whether the controller supports batching. * * @since 5.9.0 * @var false */ protected $allow_batch = false; /** * Image size token for the source-format original preserved alongside a * client-generated derivative (e.g. the HEIC file kept next to its JPEG). * * Used both in the `/sideload` route schema and when dispatching the * sideloaded file to its metadata key, so the two never drift apart. * * @since 7.1.0 * @var string */ const IMAGE_SIZE_SOURCE_ORIGINAL = 'source_original'; /** * Metadata key holding the basename of the source-format original. * * Deliberately specific so it never collides with the generic `original` * or `original_image` keys other flows write to. * * @since 7.1.0 * @var string */ const META_KEY_SOURCE_IMAGE = 'source_image'; /** * Post meta key recording the file names produced by the sideload endpoint. * * Each successful sideload appends the file name(s) it created for an * attachment under this key. The finalize endpoint reads them back to * confirm every stored sub-size was actually produced here, rather than * trusting a client-supplied name that could point at another attachment's * files. Stored as one row per value (via {@see add_post_meta()}) so concurrent * sideloads never read-modify-write a shared value. * * @since 7.1.0 * @var string */ const META_KEY_SIDELOAD_FILE_NAME = '_wp_sideloaded_file'; /** * Registers the routes for attachments. * * @since 5.3.0 * * @see register_rest_route() */ public function register_routes() { parent::register_routes(); register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[\d]+)/post-process', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'post_process_item' ), 'permission_callback' => array( $this, 'post_process_item_permissions_check' ), 'args' => array( 'id' => array( 'description' => __( 'Unique identifier for the attachment.' ), 'type' => 'integer', ), 'action' => array( 'type' => 'string', 'enum' => array( 'create-image-subsizes' ), 'required' => true, ), ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[\d]+)/edit', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'edit_media_item' ), 'permission_callback' => array( $this, 'edit_media_item_permissions_check' ), 'args' => $this->get_edit_media_item_args(), ) ); if ( wp_is_client_side_media_processing_enabled() ) { register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[\d]+)/sideload', array( array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'sideload_item' ), 'permission_callback' => array( $this, 'sideload_item_permissions_check' ), 'args' => array( 'id' => array( 'description' => __( 'Unique identifier for the attachment.' ), 'type' => 'integer', ), 'image_size' => array( 'description' => __( 'Image size. Can be a single size name or an array of size names to register the same file under multiple sizes.' ), 'type' => array( 'string', 'array' ), 'items' => array( 'type' => 'string', 'minLength' => 1, ), 'minItems' => 1, 'minLength' => 1, 'required' => true, /* * A custom callback is used instead of the default enum validation * because rest_is_array() treats scalar strings as single-element * lists (via wp_parse_list()), so a [ 'string', 'array' ] type alone * cannot enforce the enum. The callback validates each item against * the current list of registered sizes, which reflects sizes added * after route registration (e.g. via add_image_size()). */ 'validate_callback' => static function ( $value, WP_REST_Request $request, string $param ) { /* * Providing a custom callback replaces the default schema * validation, so apply the declared schema (type, minLength, * minItems) before the enum check below. */ $schema_validity = rest_validate_request_arg( $value, $request, $param ); if ( is_wp_error( $schema_validity ) ) { return $schema_validity; } return self::validate_image_size_names( $value, $param ); }, ), 'convert_format' => array( 'type' => 'boolean', 'default' => true, 'description' => __( 'Whether to convert image formats.' ), ), ), ), 'allow_batch' => $this->allow_batch, 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[\d]+)/finalize', array( array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'finalize_item' ), 'permission_callback' => array( $this, 'edit_media_item_permissions_check' ), 'args' => array( 'id' => array( 'description' => __( 'Unique identifier for the attachment.' ), 'type' => 'integer', ), 'sub_sizes' => array( 'description' => __( 'Array of sub-size metadata collected from sideload responses.' ), 'type' => 'array', 'default' => array(), /* * A finalize request sends one entry per sideloaded sub-size, so * the ceiling only needs to clear the number of sizes a site can * register. Bounding it keeps a request from repeating a name * across an arbitrary number of entries. */ 'maxItems' => 100, /* * As on the sideload endpoint, the size names are checked in a * callback rather than an enum, so the set reflects the sizes * registered when the request runs. The callback sits on * sub_sizes because a nested property cannot carry one. */ 'validate_callback' => static function ( $value, WP_REST_Request $request, string $param ) { /* * Providing a custom callback replaces the default schema * validation, so apply the declared schema first. That is what * guarantees each entry is an object carrying an image_size of * the declared type. */ $schema_validity = rest_validate_request_arg( $value, $request, $param ); if ( is_wp_error( $schema_validity ) ) { return $schema_validity; } foreach ( (array) $value as $index => $sub_size ) { $sub_size = (array) $sub_size; $validity = self::validate_image_size_names( $sub_size['image_size'] ?? null, sprintf( '%s[%s][image_size]', $param, $index ) ); if ( is_wp_error( $validity ) ) { return $validity; } } return true; }, 'items' => array( 'type' => 'object', 'properties' => array( 'image_size' => array( 'description' => __( 'Size name, or an array of size names when a single file is registered under multiple sizes with matching dimensions.' ), 'type' => array( 'string', 'array' ), 'items' => array( 'type' => 'string', 'minLength' => 1, ), 'minItems' => 1, 'minLength' => 1, 'required' => true, ), 'width' => array( 'type' => 'integer', 'minimum' => 1, ), 'height' => array( 'type' => 'integer', 'minimum' => 1, ), 'file' => array( 'type' => 'string', 'minLength' => 1, ), 'mime_type' => array( 'type' => 'string', 'pattern' => '^image/.*', ), 'filesize' => array( 'type' => 'integer', 'minimum' => 1, ), 'original_image' => array( 'type' => 'string', 'minLength' => 1, ), ), ), ), ), ), 'allow_batch' => $this->allow_batch, 'schema' => array( $this, 'get_public_item_schema' ), ) ); } } /** * Retrieves the query params for the attachments collection. * * @since 7.1.0 * * @param string $method Optional. HTTP method of the request. * The arguments for `CREATABLE` requests are * checked for required values and may fall-back to a given default. * Default WP_REST_Server::CREATABLE. * @return array<string, array<string, mixed>> Endpoint arguments. */ public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CREATABLE ) { $args = parent::get_endpoint_args_for_item_schema( $method ); if ( WP_REST_Server::CREATABLE !== $method ) { return $args; } $args['generate_sub_sizes'] = array( 'type' => 'boolean', 'default' => true, 'description' => __( 'Whether to generate image sub sizes.' ), ); $args['convert_format'] = array( 'type' => 'boolean', 'default' => true, 'description' => __( 'Whether to convert image formats.' ), ); $args['url'] = array( 'type' => 'string', 'format' => 'uri', 'description' => __( 'URL of an external image to sideload into the media library, instead of uploading a file.' ), 'sanitize_callback' => 'sanitize_url', 'validate_callback' => static function ( $url, $request, $param ) { /* * A custom validate_callback replaces the default * rest_validate_request_arg(), so re-apply it first to keep * the schema checks (string type, uri format) enforced. */ $valid = rest_validate_request_arg( $url, $request, $param ); if ( is_wp_error( $valid ) ) { return $valid; } /* * Reject URLs that are not safe to request server-side. wp_http_validate_url() * enforces an HTTP(S) scheme and blocks private, local, and otherwise * disallowed hosts, guarding the sideload against SSRF. */ if ( false === wp_http_validate_url( $url ) ) { return new WP_Error( 'rest_invalid_url', __( 'Invalid URL. Provide a valid, publicly reachable HTTP or HTTPS image URL.' ), array( 'status' => 400 ) ); } return true; }, ); return $args; } /** * Determines the allowed query_vars for a get_items() response and * prepares for WP_Query. * * @since 4.7.0 * @since 6.9.0 Extends the `media_type` and `mime_type` request arguments to support array values. * * @param array $prepared_args Optional. Array of prepared arguments. Default empty array. * @param WP_REST_Request $request Optional. Request to prepare items for. * @return array Array of query arguments. */ protected function prepare_items_query( $prepared_args = array(), $request = null ) { $query_args = parent::prepare_items_query( $prepared_args, $request ); if ( empty( $query_args['post_status'] ) ) { $query_args['post_status'] = 'inherit'; } $all_mime_types = array(); $media_types = $this->get_media_types(); if ( ! empty( $request['media_type'] ) && is_array( $request['media_type'] ) ) { foreach ( $request['media_type'] as $type ) { if ( isset( $media_types[ $type ] ) ) { $all_mime_types = array_merge( $all_mime_types, $media_types[ $type ] ); } } } if ( ! empty( $request['mime_type'] ) && is_array( $request['mime_type'] ) ) { foreach ( $request['mime_type'] as $mime_type ) { $parts = explode( '/', $mime_type ); if ( isset( $media_types[ $parts[0] ] ) && in_array( $mime_type, $media_types[ $parts[0] ], true ) ) { $all_mime_types[] = $mime_type; } } } if ( ! empty( $all_mime_types ) ) { $query_args['post_mime_type'] = array_values( array_unique( $all_mime_types ) ); } // Filter query clauses to include filenames. if ( isset( $query_args['s'] ) ) { add_filter( 'wp_allow_query_attachment_by_filename', '__return_true' ); } return $query_args; } /** * Checks if a given request has access to create an attachment. * * @since 4.7.0 * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error Boolean true if the attachment may be created, or a WP_Error if not. */ public function create_item_permissions_check( $request ) { $ret = parent::create_item_permissions_check( $request ); if ( ! $ret || is_wp_error( $ret ) ) { return $ret; } if ( ! current_user_can( 'upload_files' ) ) { return new WP_Error( 'rest_cannot_create', __( 'Sorry, you are not allowed to upload media on this site.' ), array( 'status' => 400 ) ); } // Attaching media to a post requires ability to edit said post. if ( ! empty( $request['post'] ) && ! current_user_can( 'edit_post', (int) $request['post'] ) ) { return new WP_Error( 'rest_cannot_edit', __( 'Sorry, you are not allowed to upload media to this post.' ), array( 'status' => rest_authorization_required_code() ) ); } $files = $request->get_file_params(); /** * Filter whether the server should prevent uploads for image types it doesn't support. Default true. * * Developers can use this filter to enable uploads of certain image types. By default image types that are not * supported by the server are prevented from being uploaded. * * @since 6.8.0 * * @param bool $check_mime Whether to prevent uploads of unsupported image types. * @param string|null $mime_type The mime type of the file being uploaded (if available). */ $prevent_unsupported_uploads = apply_filters( 'wp_prevent_unsupported_mime_type_uploads', true, $files['file']['type'] ?? null ); /* * When the client handles image processing (generate_sub_sizes is false), * skip the server-side image editor support check. This check exists * because the server cannot process the image, so it is only relaxed when * client side media processing is enabled and something else can. Asking * to skip sub sizes on a site without it does not make an unsupported * image type any more usable. */ if ( wp_is_client_side_media_processing_enabled() && false === $request['generate_sub_sizes'] ) { $prevent_unsupported_uploads = false; } /* * Always allow still HEIC/HEIF uploads through even if the server's * image editor doesn't support them. The client-side canvas fallback * handles processing using the browser's native HEVC decoder. * * The '-sequence' variants (multi-frame Live Photos) are deliberately * excluded: neither the server nor the browser fallback can process * them yet, so they should fall through to the standard unsupported * mime-type error rather than be stored unprocessable. */ $still_heic_mime_types = array( 'image/heic', 'image/heif' ); if ( $prevent_unsupported_uploads && ! empty( $files['file']['type'] ) && in_array( $files['file']['type'], $still_heic_mime_types, true ) ) { $prevent_unsupported_uploads = false; } // If the upload is an image, check if the server can handle the mime type. if ( $prevent_unsupported_uploads && isset( $files['file']['type'] ) && str_starts_with( $files['file']['type'], 'image/' ) ) { // List of non-resizable image formats. $editor_non_resizable_formats = array( 'image/svg+xml', ); // Check if the image editor supports the type or ignore if it isn't a format resizable by an editor. if ( ! in_array( $files['file']['type'], $editor_non_resizable_formats, true ) && ! wp_image_editor_supports( array( 'mime_type' => $files['file']['type'] ) ) ) { return new WP_Error( 'rest_upload_image_type_not_supported', __( 'The web server cannot generate responsive image sizes for this image. Convert it to JPEG or PNG before uploading.' ), array( 'status' => 400 ) ); } } return true; } /** * Creates a single attachment. * * @since 4.7.0 * @since 7.1.0 Added the `generate_sub_sizes`, `convert_format`, and `url` parameters. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure. */ public function create_item( $request ) { if ( ! empty( $request['post'] ) && in_array( get_post_type( $request['post'] ), array( 'revision', 'attachment' ), true ) ) { return new WP_Error( 'rest_invalid_param', __( 'Invalid parent type.' ), array( 'status' => 400 ) ); } // Handle generate_sub_sizes parameter. if ( false === $request['generate_sub_sizes'] ) { add_filter( 'intermediate_image_sizes_advanced', '__return_empty_array', 100 ); add_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 ); // Disable server-side EXIF rotation so the client can handle it. // This preserves the original orientation value in the metadata. add_filter( 'wp_image_maybe_exif_rotate', '__return_false', 100 ); // Disable server-side "big image" downscaling; the client supplies its // own scaled version via the sideload endpoint. Scaling here would // create a conflicting "-scaled" file and orphan the full-size upload. add_filter( 'big_image_size_threshold', '__return_false', 100 ); } // Handle convert_format parameter. if ( false === $request['convert_format'] ) { add_filter( 'image_editor_output_format', '__return_empty_array', 100 ); } /* * When a URL is supplied instead of an uploaded file, sideload the * remote image on the server. This avoids a cross-origin browser fetch, * which fails under cross-origin isolation. The sub-size and scaling * filters applied above still govern whether derivatives are generated. */ if ( ! empty( $request['url'] ) ) { $response = $this->create_item_from_url( $request ); $this->remove_client_side_media_processing_filters(); return $response; } $insert = $this->insert_attachment( $request ); if ( is_wp_error( $insert ) ) { $this->remove_client_side_media_processing_filters(); return $insert; } $schema = $this->get_item_schema(); // Extract by name. $attachment_id = $insert['attachment_id']; $file = $insert['file']; if ( isset( $request['alt_text'] ) ) { update_post_meta( $attachment_id, '_wp_attachment_image_alt', sanitize_text_field( $request['alt_text'] ) ); } if ( ! empty( $schema['properties']['featured_media'] ) && isset( $request['featured_media'] ) ) { $thumbnail_update = $this->handle_featured_media( $request['featured_media'], $attachment_id ); if ( is_wp_error( $thumbnail_update ) ) { $this->remove_client_side_media_processing_filters(); return $thumbnail_update; } } if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) { $meta_update = $this->meta->update_value( $request['meta'], $attachment_id ); if ( is_wp_error( $meta_update ) ) { $this->remove_client_side_media_processing_filters(); return $meta_update; } } $attachment = get_post( $attachment_id ); $fields_update = $this->update_additional_fields_for_object( $attachment, $request ); if ( is_wp_error( $fields_update ) ) { $this->remove_client_side_media_processing_filters(); return $fields_update; } $terms_update = $this->handle_terms( $attachment_id, $request ); if ( is_wp_error( $terms_update ) ) { $this->remove_client_side_media_processing_filters(); return $terms_update; } $request->set_param( 'context', 'edit' ); /** * Fires after a single attachment is completely created or updated via the REST API. * * @since 5.0.0 * * @param WP_Post $attachment Inserted or updated attachment object. * @param WP_REST_Request $request Request object. * @param bool $creating True when creating an attachment, false when updating. */ do_action( 'rest_after_insert_attachment', $attachment, $request, true ); wp_after_insert_post( $attachment, false, null ); if ( wp_is_serving_rest_request() ) { /* * Set a custom header with the attachment_id. * Used by the browser/client to resume creating image sub-sizes after a PHP fatal error. */ header( 'X-WP-Upload-Attachment-ID: ' . $attachment_id ); } // Include media and image functions to get access to wp_generate_attachment_metadata(). require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; /* * Post-process the upload (create image sub-sizes, make PDF thumbnails, etc.) and insert attachment meta. * At this point the server may run out of resources and post-processing of uploaded images may fail. */ wp_update_attachment_metadata( $attachment_id, wp_generate_attachment_metadata( $attachment_id, $file ) ); $this->remove_client_side_media_processing_filters(); $response = $this->prepare_item_for_response( $attachment, $request ); $response = rest_ensure_response( $response ); $response->set_status( 201 ); $response->header( 'Location', rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $attachment_id ) ) ); return $response; } /** * Sideloads an external image from a URL into the media library. * * Downloads the remote file on the server, avoiding a cross-origin browser * fetch that fails under cross-origin isolation. Whether sub-sizes are * generated is governed by the filters applied in create_item(). * * @since 7.1.0 * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure. */ protected function create_item_from_url( WP_REST_Request $request ) { // Sideloading downloads and stores a file, so require the upload capability. if ( ! current_user_can( 'upload_files' ) ) { return new WP_Error( 'rest_cannot_create', __( 'Sorry, you are not allowed to upload media on this site.' ), array( 'status' => rest_authorization_required_code() ) ); } require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; $url = $request['url']; $post_id = ! empty( $request['post'] ) ? (int) $request['post'] : 0; // Derive the filename from the URL path before downloading anything. $url_path = wp_parse_url( $url, PHP_URL_PATH ); $filename = $url_path ? wp_basename( $url_path ) : ''; if ( '' === $filename ) { return new WP_Error( 'rest_invalid_url', __( 'Could not determine a filename from the provided URL.' ), array( 'status' => 400 ) ); } /* * Only download URLs whose extension maps to an allowed image MIME type. * The sideload handler would reject other types anyway (via * wp_check_filetype_and_ext()), but checking first avoids downloading * files that can never be accepted, such as PHP scripts. */ $filetype = wp_check_filetype( $filename ); if ( ! $filetype['type'] || ! str_starts_with( $filetype['type'], 'image/' ) ) { return new WP_Error( 'rest_invalid_url', __( 'The provided URL does not point to a supported image file.' ), array( 'status' => 400 ) ); } /* * Cap the download at the same size the site would accept as a direct * upload. check_upload_size() only applies on multisite, so without a * ceiling here a single site has no limit at all on this path: the * `upload_max_filesize` and `post_max_size` directives bound a request * body, not a fetch the server makes itself. * * When `wp_max_upload_size` returns 0, no ceiling is applied. */ $max_size = (int) wp_max_upload_size(); /* * Download the remote file with WordPress's HTTP API, which validates * the host and blocks requests to private or local addresses. This is * the same primitive core's media_sideload_image() relies on. * * `limit_response_size` stops the transfer once the limit is passed, * so an oversized remote file is never written to disk in full. One * byte over the ceiling is enough to fail the size check below. */ $limit_response_size = static function ( $args ) use ( $max_size ) { $args['limit_response_size'] = $max_size + 1; return $args; }; if ( $max_size > 0 ) { add_filter( 'http_request_args', $limit_response_size ); } $tmp_file = download_url( $url ); if ( $max_size > 0 ) { remove_filter( 'http_request_args', $limit_response_size ); } if ( is_wp_error( $tmp_file ) ) { return $tmp_file; } $file_array = array( 'name' => $filename, 'tmp_name' => $tmp_file, ); $size_check = self::check_upload_size( $file_array ); if ( is_wp_error( $size_check ) ) { if ( file_exists( $tmp_file ) ) { wp_delete_file( $tmp_file ); } return $size_check; } if ( $max_size > 0 && wp_filesize( $tmp_file ) > $max_size ) { if ( file_exists( $tmp_file ) ) { wp_delete_file( $tmp_file ); } return new WP_Error( 'rest_upload_file_too_big', /* translators: %s: Maximum allowed file size in kilobytes. */ sprintf( __( 'This file is too big. Files must be less than %s KB in size.' ), number_format( $max_size / KB_IN_BYTES ) ), array( 'status' => 400 ) ); } $attachment_id = media_handle_sideload( $file_array, $post_id ); if ( is_wp_error( $attachment_id ) ) { /* * media_handle_sideload() deletes the temp file on success; remove * it explicitly when the sideload fails. */ if ( file_exists( $tmp_file ) ) { wp_delete_file( $tmp_file ); } return $attachment_id; } $attachment = get_post( $attachment_id ); $request->set_param( 'context', 'edit' ); /* * media_handle_sideload() fires the standard insert hooks (including * wp_after_insert_post), but not the REST-specific action, so fire it * here for parity with the uploaded-file path in create_item(). */ /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php */ do_action( 'rest_after_insert_attachment', $attachment, $request, true ); $response = $this->prepare_item_for_response( $attachment, $request ); $response->set_status( 201 ); $response->header( 'Location', rest_url( rest_get_route_for_post( $attachment_id ) ) ); return $response; } /** * Removes filters added for client-side media processing. * * @since 7.1.0 */ private function remove_client_side_media_processing_filters(): void { remove_filter( 'intermediate_image_sizes_advanced', '__return_empty_array', 100 ); remove_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 ); remove_filter( 'wp_image_maybe_exif_rotate', '__return_false', 100 ); remove_filter( 'image_editor_output_format', '__return_empty_array', 100 ); remove_filter( 'big_image_size_threshold', '__return_false', 100 ); } /** * Inserts the attachment post in the database. Does not update the attachment meta. * * @since 5.3.0 * * @param WP_REST_Request $request * @return array|WP_Error */ protected function insert_attachment( $request ) { // Get the file via $_FILES or raw data. $files = $request->get_file_params(); $headers = $request->get_headers(); $time = null; // Matches logic in media_handle_upload(). if ( ! empty( $request['post'] ) ) { $post = get_post( $request['post'] ); // The post date doesn't usually matter for pages, so don't backdate this upload. if ( $post && 'page' !== $post->post_type && substr( $post->post_date, 0, 4 ) > 0 ) { $time = $post->post_date; } } if ( ! empty( $files ) ) { $file = $this->upload_from_file( $files, $headers, $time ); } else { $file = $this->upload_from_data( $request->get_body(), $headers, $time ); } if ( is_wp_error( $file ) ) { return $file; } $name = wp_basename( $file['file'] ); $name_parts = pathinfo( $name ); $name = trim( substr( $name, 0, -( 1 + strlen( $name_parts['extension'] ) ) ) ); $url = $file['url']; $type = $file['type']; $file = $file['file']; $alt = ''; // Include image functions to get access to wp_read_image_metadata(). require_once ABSPATH . 'wp-admin/includes/image.php'; // Use image exif/iptc data for title and caption defaults if possible. $image_meta = wp_read_image_metadata( $file ); if ( ! empty( $image_meta ) ) { if ( empty( $request['title'] ) && trim( $image_meta['title'] ) && ! is_numeric( sanitize_title( $image_meta['title'] ) ) ) { $request['title'] = $image_meta['title']; } if ( empty( $request['caption'] ) && trim( $image_meta['caption'] ) ) { $request['caption'] = $image_meta['caption']; } if ( empty( $request['alt'] ) && trim( $image_meta['alt'] ) ) { $alt = $image_meta['alt']; } } $attachment = $this->prepare_item_for_database( $request ); $attachment->post_mime_type = $type; $attachment->guid = $url; // If the title was not set, use the original filename. if ( empty( $attachment->post_title ) && ! empty( $files['file']['name'] ) ) { // Remove the file extension (after the last `.`) $tmp_title = substr( $files['file']['name'], 0, strrpos( $files['file']['name'], '.' ) ); if ( ! empty( $tmp_title ) ) { $attachment->post_title = $tmp_title; } } // Fall back to the original approach. if ( empty( $attachment->post_title ) ) { $attachment->post_title = preg_replace( '/\.[^.]+$/', '', wp_basename( $file ) ); } // $post_parent is inherited from $attachment['post_parent']. $id = wp_insert_attachment( wp_slash( (array) $attachment ), $file, 0, true, false ); if ( trim( $alt ) ) { update_post_meta( $id, '_wp_attachment_image_alt', sanitize_text_field( $alt ) ); } if ( is_wp_error( $id ) ) { if ( 'db_update_error' === $id->get_error_code() ) { $id->add_data( array( 'status' => 500 ) ); } else { $id->add_data( array( 'status' => 400 ) ); } return $id; } $attachment = get_post( $id ); /** * Fires after a single attachment is created or updated via the REST API. * * @since 4.7.0 * * @param WP_Post $attachment Inserted or updated attachment object. * @param WP_REST_Request $request The request sent to the API. * @param bool $creating True when creating an attachment, false when updating. */ do_action( 'rest_insert_attachment', $attachment, $request, true ); return array( 'attachment_id' => $id, 'file' => $file, ); } /** * Determines the featured media based on a request param. * * @since 6.5.0 * * @param int $featured_media Featured Media ID. * @param int $post_id Post ID. * @return bool|WP_Error Whether the post thumbnail was successfully deleted, otherwise WP_Error. */ protected function handle_featured_media( $featured_media, $post_id ) { $post_type = get_post_type( $post_id ); $thumbnail_support = current_theme_supports( 'post-thumbnails', $post_type ) && post_type_supports( $post_type, 'thumbnail' ); // Similar check as in wp_insert_post(). if ( ! $thumbnail_support && get_post_mime_type( $post_id ) ) { if ( wp_attachment_is( 'audio', $post_id ) ) { $thumbnail_support = post_type_supports( 'attachment:audio', 'thumbnail' ) || current_theme_supports( 'post-thumbnails', 'attachment:audio' ); } elseif ( wp_attachment_is( 'video', $post_id ) ) { $thumbnail_support = post_type_supports( 'attachment:video', 'thumbnail' ) || current_theme_supports( 'post-thumbnails', 'attachment:video' ); } } if ( $thumbnail_support ) { return parent::handle_featured_media( $featured_media, $post_id ); } return new WP_Error( 'rest_no_featured_media', sprintf( /* translators: %s: attachment mime type */ __( 'This site does not support post thumbnails on attachments with MIME type %s.' ), get_post_mime_type( $post_id ) ), array( 'status' => 400 ) ); } /** * Updates a single attachment. * * @since 4.7.0 * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure. */ public function update_item( $request ) { if ( ! empty( $request['post'] ) && in_array( get_post_type( $request['post'] ), array( 'revision', 'attachment' ), true ) ) { return new WP_Error( 'rest_invalid_param', __( 'Invalid parent type.' ), array( 'status' => 400 ) ); } $attachment_before = get_post( $request['id'] ); $response = parent::update_item( $request ); if ( is_wp_error( $response ) ) { return $response; } $response = rest_ensure_response( $response ); $data = $response->get_data(); if ( isset( $request['alt_text'] ) ) { update_post_meta( $data['id'], '_wp_attachment_image_alt', $request['alt_text'] ); } $attachment = get_post( $request['id'] ); if ( ! empty( $schema['properties']['featured_media'] ) && isset( $request['featured_media'] ) ) { $thumbnail_update = $this->handle_featured_media( $request['featured_media'], $attachment->ID ); if ( is_wp_error( $thumbnail_update ) ) { return $thumbnail_update; } } $fields_update = $this->update_additional_fields_for_object( $attachment, $request ); if ( is_wp_error( $fields_update ) ) { return $fields_update; } $request->set_param( 'context', 'edit' ); /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php */ do_action( 'rest_after_insert_attachment', $attachment, $request, false ); wp_after_insert_post( $attachment, true, $attachment_before ); $response = $this->prepare_item_for_response( $attachment, $request ); $response = rest_ensure_response( $response ); return $response; } /** * Performs post-processing on an attachment. * * @since 5.3.0 * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure. */ public function post_process_item( $request ) { switch ( $request['action'] ) { case 'create-image-subsizes': require_once ABSPATH . 'wp-admin/includes/image.php'; wp_update_image_subsizes( $request['id'] ); break; } $request['context'] = 'edit'; return $this->prepare_item_for_response( get_post( $request['id'] ), $request ); } /** * Checks if a given request can perform post-processing on an attachment. * * @since 5.3.0 * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has access to update the item, WP_Error object otherwise. */ public function post_process_item_permissions_check( $request ) { return $this->update_item_permissions_check( $request ); } /** * Checks if a given request has access to editing media. * * @since 5.5.0 * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has read access, WP_Error object otherwise. */ public function edit_media_item_permissions_check( $request ) { if ( ! current_user_can( 'upload_files' ) ) { return new WP_Error( 'rest_cannot_edit_image', __( 'Sorry, you are not allowed to upload media on this site.' ), array( 'status' => rest_authorization_required_code() ) ); } return $this->update_item_permissions_check( $request ); } /** * Applies edits to a media item and creates a new attachment record. * * @since 5.5.0 * @since 6.9.0 Adds flips capability and editable fields for the newly-created attachment post. * @since 7.1.0 Applies EXIF orientation correction before image modifications. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure. */ public function edit_media_item( $request ) { require_once ABSPATH . 'wp-admin/includes/image.php'; $attachment_id = $request['id']; // This also confirms the attachment is an image. $image_file = wp_get_original_image_path( $attachment_id ); $image_meta = wp_get_attachment_metadata( $attachment_id ); if ( ! $image_meta || ! $image_file || ! wp_image_file_matches_image_meta( $request['src'], $image_meta, $attachment_id ) ) { return new WP_Error( 'rest_unknown_attachment', __( 'Unable to get meta information for file.' ), array( 'status' => 404 ) ); } $supported_types = array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/heic' ); $mime_type = get_post_mime_type( $attachment_id ); if ( ! in_array( $mime_type, $supported_types, true ) ) { return new WP_Error( 'rest_cannot_edit_file_type', __( 'This type of file cannot be edited.' ), array( 'status' => 400 ) ); } // The `modifiers` param takes precedence over the older format. if ( isset( $request['modifiers'] ) ) { $modifiers = $request['modifiers']; } else { $modifiers = array(); if ( isset( $request['flip']['horizontal'] ) || isset( $request['flip']['vertical'] ) ) { $flip_args = array( 'vertical' => isset( $request['flip']['vertical'] ) ? (bool) $request['flip']['vertical'] : false, 'horizontal' => isset( $request['flip']['horizontal'] ) ? (bool) $request['flip']['horizontal'] : false, ); $modifiers[] = array( 'type' => 'flip', 'args' => array( 'flip' => $flip_args, ), ); } if ( ! empty( $request['rotation'] ) ) { $modifiers[] = array( 'type' => 'rotate', 'args' => array( 'angle' => $request['rotation'], ), ); } if ( isset( $request['x'], $request['y'], $request['width'], $request['height'] ) ) { $modifiers[] = array( 'type' => 'crop', 'args' => array( 'left' => $request['x'], 'top' => $request['y'], 'width' => $request['width'], 'height' => $request['height'], ), ); } if ( 0 === count( $modifiers ) ) { return new WP_Error( 'rest_image_not_edited', __( 'The image was not edited. Edit the image before applying the changes.' ), array( 'status' => 400 ) ); } } /* * If the file doesn't exist, attempt a URL fopen on the src link. * This can occur with certain file replication plugins. * Keep the original file path to get a modified name later. */ $image_file_to_edit = $image_file; if ( ! file_exists( $image_file_to_edit ) ) { $image_file_to_edit = _load_image_to_edit_path( $attachment_id ); } $image_editor = wp_get_image_editor( $image_file_to_edit ); if ( is_wp_error( $image_editor ) ) { return new WP_Error( 'rest_unknown_image_file_type', __( 'Unable to edit this image.' ), array( 'status' => 500 ) ); } // Apply any unapplied EXIF orientation so edits run in the upright frame the client previewed. $image_editor->maybe_exif_rotate(); foreach ( $modifiers as $modifier ) { $args = $modifier['args']; switch ( $modifier['type'] ) { case 'flip': /* * Flips the current image. * The vertical flip is the first argument (flip along horizontal axis), the horizontal flip is the second argument (flip along vertical axis). * See: WP_Image_Editor::flip() */ $result = $image_editor->flip( $args['flip']['vertical'], $args['flip']['horizontal'] ); if ( is_wp_error( $result ) ) { return new WP_Error( 'rest_image_flip_failed', __( 'Unable to flip this image.' ), array( 'status' => 500 ) ); } break; case 'rotate': // Rotation direction: clockwise vs. counterclockwise. $rotate = 0 - $args['angle']; if ( 0 !== $rotate ) { $result = $image_editor->rotate( $rotate ); if ( is_wp_error( $result ) ) { return new WP_Error( 'rest_image_rotation_failed', __( 'Unable to rotate this image.' ), array( 'status' => 500 ) ); } } break; case 'crop': $size = $image_editor->get_size(); $crop_x = (int) round( ( $size['width'] * $args['left'] ) / 100.0 ); $crop_y = (int) round( ( $size['height'] * $args['top'] ) / 100.0 ); $width = (int) round( ( $size['width'] * $args['width'] ) / 100.0 ); $height = (int) round( ( $size['height'] * $args['height'] ) / 100.0 ); if ( $size['width'] !== $width || $size['height'] !== $height ) { $result = $image_editor->crop( $crop_x, $crop_y, $width, $height ); if ( is_wp_error( $result ) ) { return new WP_Error( 'rest_image_crop_failed', __( 'Unable to crop this image.' ), array( 'status' => 500 ) ); } } break; } } // Calculate the file name. $image_ext = pathinfo( $image_file, PATHINFO_EXTENSION ); $image_name = wp_basename( $image_file, ".{$image_ext}" ); /* * Do not append multiple `-edited` to the file name. * The user may be editing a previously edited image. */ if ( preg_match( '/-edited(-\d+)?$/', $image_name ) ) { // Remove any `-1`, `-2`, etc. `wp_unique_filename()` will add the proper number. $image_name = preg_replace( '/-edited(-\d+)?$/', '-edited', $image_name ); } else { // Append `-edited` before the extension. $image_name .= '-edited'; } $filename = "{$image_name}.{$image_ext}"; // Create the uploads subdirectory if needed. $uploads = wp_upload_dir(); // Make the file name unique in the (new) upload directory. $filename = wp_unique_filename( $uploads['path'], $filename ); // Save to disk. $saved = $image_editor->save( $uploads['path'] . "/$filename" ); if ( is_wp_error( $saved ) ) { return $saved; } // Grab original attachment post so we can use it to set defaults. $original_attachment_post = get_post( $attachment_id ); // Check request fields and assign default values. $new_attachment_post = $this->prepare_item_for_database( $request ); $new_attachment_post->post_mime_type = $saved['mime-type']; $new_attachment_post->guid = $uploads['url'] . "/$filename"; // Unset ID so wp_insert_attachment generates a new ID. unset( $new_attachment_post->ID ); // Set new attachment post title with fallbacks. $new_attachment_post->post_title = $new_attachment_post->post_title ?? $original_attachment_post->post_title ?? $image_name; // Set new attachment post caption (post_excerpt). $new_attachment_post->post_excerpt = $new_attachment_post->post_excerpt ?? $original_attachment_post->post_excerpt ?? ''; // Set new attachment post description (post_content) with fallbacks. $new_attachment_post->post_content = $new_attachment_post->post_content ?? $original_attachment_post->post_content ?? ''; // Set post parent if set in request, else the default of `0` (no parent). $new_attachment_post->post_parent = $new_attachment_post->post_parent ?? 0; // Insert the new attachment post. $new_attachment_id = wp_insert_attachment( wp_slash( (array) $new_attachment_post ), $saved['path'], 0, true ); if ( is_wp_error( $new_attachment_id ) ) { if ( 'db_update_error' === $new_attachment_id->get_error_code() ) { $new_attachment_id->add_data( array( 'status' => 500 ) ); } else { $new_attachment_id->add_data( array( 'status' => 400 ) ); } return $new_attachment_id; } // First, try to use the alt text from the request. If not set, copy the image alt text from the original attachment. $image_alt = isset( $request['alt_text'] ) ? sanitize_text_field( $request['alt_text'] ) : get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ); if ( ! empty( $image_alt ) ) { // update_post_meta() expects slashed. update_post_meta( $new_attachment_id, '_wp_attachment_image_alt', wp_slash( $image_alt ) ); } if ( wp_is_serving_rest_request() ) { /* * Set a custom header with the attachment_id. * Used by the browser/client to resume creating image sub-sizes after a PHP fatal error. */ header( 'X-WP-Upload-Attachment-ID: ' . $new_attachment_id ); } // Generate image sub-sizes and meta. $new_image_meta = wp_generate_attachment_metadata( $new_attachment_id, $saved['path'] ); // Copy the EXIF metadata from the original attachment if not generated for the edited image. if ( isset( $image_meta['image_meta'] ) && isset( $new_image_meta['image_meta'] ) && is_array( $new_image_meta['image_meta'] ) ) { // Merge but skip empty values. foreach ( (array) $image_meta['image_meta'] as $key => $value ) { if ( empty( $new_image_meta['image_meta'][ $key ] ) && ! empty( $value ) ) { $new_image_meta['image_meta'][ $key ] = $value; } } } // Reset orientation. At this point the image is edited and orientation is correct. if ( ! empty( $new_image_meta['image_meta']['orientation'] ) ) { $new_image_meta['image_meta']['orientation'] = 1; } // The attachment_id may change if the site is exported and imported. $new_image_meta['parent_image'] = array( 'attachment_id' => $attachment_id, // Path to the originally uploaded image file relative to the uploads directory. 'file' => _wp_relative_upload_path( $image_file ), ); /** * Filters the meta data for the new image created by editing an existing image. * * @since 5.5.0 * * @param array $new_image_meta Meta data for the new image. * @param int $new_attachment_id Attachment post ID for the new image. * @param int $attachment_id Attachment post ID for the edited (parent) image. */ $new_image_meta = apply_filters( 'wp_edited_image_metadata', $new_image_meta, $new_attachment_id, $attachment_id ); wp_update_attachment_metadata( $new_attachment_id, $new_image_meta ); $response = $this->prepare_item_for_response( get_post( $new_attachment_id ), $request ); $response->set_status( 201 ); $response->header( 'Location', rest_url( sprintf( '%s/%s/%s', $this->namespace, $this->rest_base, $new_attachment_id ) ) ); return $response; } /** * Prepares a single attachment for create or update. * * @since 4.7.0 * * @param WP_REST_Request $request Request object. * @return stdClass|WP_Error Post object. */ protected function prepare_item_for_database( $request ) { $prepared_attachment = parent::prepare_item_for_database( $request ); // Attachment caption (post_excerpt internally). if ( isset( $request['caption'] ) ) { if ( is_string( $request['caption'] ) ) { $prepared_attachment->post_excerpt = $request['caption']; } elseif ( isset( $request['caption']['raw'] ) ) { $prepared_attachment->post_excerpt = $request['caption']['raw']; } } // Attachment description (post_content internally). if ( isset( $request['description'] ) ) { if ( is_string( $request['description'] ) ) { $prepared_attachment->post_content = $request['description']; } elseif ( isset( $request['description']['raw'] ) ) { $prepared_attachment->post_content = $request['description']['raw']; } } if ( isset( $request['post'] ) ) { $prepared_attachment->post_parent = (int) $request['post']; } return $prepared_attachment; } /** * Prepares a single attachment output for response. * * @since 4.7.0 * @since 5.9.0 Renamed `$post` to `$item` to match parent class for PHP 8 named parameter support. * * @param WP_Post $item Attachment object. * @param WP_REST_Request $request Request object. * @return WP_REST_Response Response object. */ public function prepare_item_for_response( $item, $request ) { // Restores the more descriptive, specific name for use within this method. $post = $item; $response = parent::prepare_item_for_response( $post, $request ); $fields = $this->get_fields_for_response( $request ); /** @var array<string, mixed> $data */ $data = $response->get_data(); if ( in_array( 'description', $fields, true ) ) { $data['description'] = array( 'raw' => $post->post_content, /** This filter is documented in wp-includes/post-template.php */ 'rendered' => apply_filters( 'the_content', $post->post_content ), ); } if ( in_array( 'caption', $fields, true ) ) { /** This filter is documented in wp-includes/post-template.php */ $caption = apply_filters( 'get_the_excerpt', $post->post_excerpt, $post ); /** This filter is documented in wp-includes/post-template.php */ $caption = apply_filters( 'the_excerpt', $caption ); $data['caption'] = array( 'raw' => $post->post_excerpt, 'rendered' => $caption, ); } if ( in_array( 'alt_text', $fields, true ) ) { $data['alt_text'] = get_post_meta( $post->ID, '_wp_attachment_image_alt', true ); } if ( in_array( 'media_type', $fields, true ) ) { $data['media_type'] = wp_attachment_is_image( $post->ID ) ? 'image' : 'file'; } if ( in_array( 'mime_type', $fields, true ) ) { $data['mime_type'] = $post->post_mime_type; } if ( in_array( 'media_details', $fields, true ) ) { $data['media_details'] = wp_get_attachment_metadata( $post->ID ); // Ensure empty details is an empty object. if ( empty( $data['media_details'] ) ) { $data['media_details'] = new stdClass(); } elseif ( ! empty( $data['media_details']['sizes'] ) ) { foreach ( $data['media_details']['sizes'] as $size => &$size_data ) { if ( isset( $size_data['mime-type'] ) ) { $size_data['mime_type'] = $size_data['mime-type']; unset( $size_data['mime-type'] ); } // Use the same method image_downsize() does. $image_src = wp_get_attachment_image_src( $post->ID, $size ); if ( ! $image_src ) { continue; } $size_data['source_url'] = $image_src[0]; } unset( $size_data ); $full_src = wp_get_attachment_image_src( $post->ID, 'full' ); if ( ! empty( $full_src ) ) { $data['media_details']['sizes']['full'] = array( 'file' => wp_basename( $full_src[0] ), 'width' => $full_src[1], 'height' => $full_src[2], 'mime_type' => $post->post_mime_type, 'source_url' => $full_src[0], ); } } else { $data['media_details']['sizes'] = new stdClass(); } } if ( in_array( 'post', $fields, true ) ) { $data['post'] = ! empty( $post->post_parent ) ? (int) $post->post_parent : null; } if ( in_array( 'source_url', $fields, true ) ) { $data['source_url'] = wp_get_attachment_url( $post->ID ); } if ( in_array( 'missing_image_sizes', $fields, true ) ) { require_once ABSPATH . 'wp-admin/includes/image.php'; $data['missing_image_sizes'] = array_keys( wp_get_missing_image_subsizes( $post->ID ) ); // Handle PDFs which don't use wp_get_missing_image_subsizes(). if ( empty( $data['missing_image_sizes'] ) && 'application/pdf' === get_post_mime_type( $post ) ) { $metadata = wp_get_attachment_metadata( $post->ID, true ); if ( ! is_array( $metadata ) ) { $metadata = array(); } $metadata['sizes'] = $metadata['sizes'] ?? array(); $fallback_sizes = array( 'thumbnail', 'medium', 'large', ); // The filter might have been added by ::create_item(). remove_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 ); /** This filter is documented in wp-admin/includes/image.php */ $fallback_sizes = apply_filters( 'fallback_intermediate_image_sizes', $fallback_sizes, $metadata ); $registered_sizes = wp_get_registered_image_subsizes(); $merged_sizes = array_keys( array_intersect_key( $registered_sizes, array_flip( $fallback_sizes ) ) ); $data['missing_image_sizes'] = array_values( array_diff( $merged_sizes, array_keys( $metadata['sizes'] ) ) ); } } if ( in_array( 'filename', $fields, true ) ) { $data['filename'] = $this->get_attachment_filename( $post->ID ); } if ( in_array( 'filesize', $fields, true ) ) { $data['filesize'] = $this->get_attachment_filesize( $post->ID ); } if ( in_array( 'exif_orientation', $fields, true ) && wp_attachment_is_image( $post ) ) { $metadata = wp_get_attachment_metadata( $post->ID, true ); // Default to 1 (no rotation needed) if orientation not set. $orientation = 1; if ( is_array( $metadata ) && isset( $metadata['image_meta']['orientation'] ) && (int) $metadata['image_meta']['orientation'] > 0 ) { $orientation = (int) $metadata['image_meta']['orientation']; } $data['exif_orientation'] = $orientation; } if ( wp_attachment_is_image( $post ) ) { $mime_type = (string) get_post_mime_type( $post ); /* * Per-file output format for images, evaluated with the real filename * and MIME type so plugins filtering image_editor_output_format can * make per-attachment decisions (e.g. JPEG -> WebP). Resolved the same * way WP_Image_Editor::set_quality() resolves the output format. */ if ( in_array( 'image_output_format', $fields, true ) ) { $filename = get_attached_file( $post->ID ); /** This filter is documented in wp-includes/media.php */ $output_formats = apply_filters( 'image_editor_output_format', array( $mime_type => $mime_type ), $filename ? $filename : '', $mime_type ); $output_mime = $output_formats[ $mime_type ] ?? $mime_type; $data['image_output_format'] = ( $output_mime !== $mime_type ) ? $output_mime : null; } /* * Per-file progressive/interlaced encoding flag for images, evaluated * against the attachment's MIME type. */ if ( in_array( 'image_save_progressive', $fields, true ) ) { /** This filter is documented in wp-includes/class-wp-image-editor-gd.php */ $data['image_save_progressive'] = (bool) apply_filters( 'image_save_progressive', false, $mime_type ); } if ( in_array( 'image_quality', $fields, true ) ) { $filename = get_attached_file( $post->ID ); /** This filter is documented in wp-includes/media.php */ $output_formats = apply_filters( 'image_editor_output_format', array( $mime_type => $mime_type ), $filename ? $filename : '', $mime_type ); $output_mime = $output_formats[ $mime_type ] ?? $mime_type; $metadata = wp_get_attachment_metadata( $post->ID, true ); $full_width = max( 0, ( is_array( $metadata ) && isset( $metadata['width'] ) ) ? (int) $metadata['width'] : 0 ); $full_height = max( 0, ( is_array( $metadata ) && isset( $metadata['height'] ) ) ? (int) $metadata['height'] : 0 ); $full_quality = wp_get_image_encode_quality( $output_mime, array( 'width' => $full_width, 'height' => $full_height, ) ); $size_quality = array(); foreach ( wp_get_registered_image_subsizes() as $size_name => $size_data ) { $quality = wp_get_image_encode_quality( $output_mime, array( 'width' => (int) $size_data['width'], 'height' => (int) $size_data['height'], ) ); // Only report sizes whose quality diverges from the full-size value. if ( $quality !== $full_quality ) { $size_quality[ $size_name ] = $quality; } } $data['image_quality'] = array( 'default' => $full_quality, 'sizes' => $size_quality, ); } } $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->filter_response_by_context( $data, $context ); $links = $response->get_links(); // Wrap the data in a response object. $response = rest_ensure_response( $data ); foreach ( $links as $rel => $rel_links ) { foreach ( $rel_links as $link ) { $response->add_link( $rel, $link['href'], $link['attributes'] ); } } /** * Filters an attachment returned from the REST API. * * Allows modification of the attachment right before it is returned. * * @since 4.7.0 * * @param WP_REST_Response $response The response object. * @param WP_Post $post The original attachment post. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'rest_prepare_attachment', $response, $post, $request ); } /** * Prepares attachment links for the request. * * @since 6.9.0 * * @param WP_Post $post Post object. * @return array Links for the given attachment. */ protected function prepare_links( $post ) { $links = parent::prepare_links( $post ); if ( ! empty( $post->post_parent ) ) { $post = get_post( $post->post_parent ); if ( ! empty( $post ) ) { $links['https://api.w.org/attached-to'] = array( 'href' => rest_url( rest_get_route_for_post( $post ) ), 'embeddable' => true, 'post_type' => $post->post_type, 'id' => $post->ID, ); } } return $links; } /** * Retrieves the attachment's schema, conforming to JSON Schema. * * @since 4.7.0 * * @return array Item schema as an array. */ public function get_item_schema() { if ( $this->schema ) { return $this->add_additional_fields_schema( $this->schema ); } $schema = parent::get_item_schema(); $schema['properties']['alt_text'] = array( 'description' => __( 'Alternative text to display when attachment is not displayed.' ), 'type' => 'string', 'context' => array( 'view', 'edit', 'embed' ), 'arg_options' => array( 'sanitize_callback' => 'sanitize_text_field', ), ); $schema['properties']['caption'] = array( 'description' => __( 'The attachment caption.' ), 'type' => 'object', 'context' => array( 'view', 'edit', 'embed' ), 'arg_options' => array( 'sanitize_callback' => null, // Note: sanitization implemented in self::prepare_item_for_database(). 'validate_callback' => null, // Note: validation implemented in self::prepare_item_for_database(). ), 'properties' => array( 'raw' => array( 'description' => __( 'Caption for the attachment, as it exists in the database.' ), 'type' => 'string', 'context' => array( 'edit' ), ), 'rendered' => array( 'description' => __( 'HTML caption for the attachment, transformed for display.' ), 'type' => 'string', 'context' => array( 'view', 'edit', 'embed' ), 'readonly' => true, ), ), ); $schema['properties']['description'] = array( 'description' => __( 'The attachment description.' ), 'type' => 'object', 'context' => array( 'view', 'edit' ), 'arg_options' => array( 'sanitize_callback' => null, // Note: sanitization implemented in self::prepare_item_for_database(). 'validate_callback' => null, // Note: validation implemented in self::prepare_item_for_database(). ), 'properties' => array( 'raw' => array( 'description' => __( 'Description for the attachment, as it exists in the database.' ), 'type' => 'string', 'context' => array( 'edit' ), ), 'rendered' => array( 'description' => __( 'HTML description for the attachment, transformed for display.' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ), ); $schema['properties']['media_type'] = array( 'description' => __( 'Attachment type.' ), 'type' => 'string', 'enum' => array( 'image', 'file' ), 'context' => array( 'view', 'edit', 'embed' ), 'readonly' => true, ); $schema['properties']['mime_type'] = array( 'description' => __( 'The attachment MIME type.' ), 'type' => 'string', 'context' => array( 'view', 'edit', 'embed' ), 'readonly' => true, ); $schema['properties']['media_details'] = array( 'description' => __( 'Details about the media file, specific to its type.' ), 'type' => 'object', 'context' => array( 'view', 'edit', 'embed' ), 'readonly' => true, ); $schema['properties']['post'] = array( 'description' => __( 'The ID for the associated post of the attachment.' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), ); $schema['properties']['source_url'] = array( 'description' => __( 'URL to the original attachment file.' ), 'type' => 'string', 'format' => 'uri', 'context' => array( 'view', 'edit', 'embed' ), 'readonly' => true, ); $schema['properties']['missing_image_sizes'] = array( 'description' => __( 'List of the missing image sizes of the attachment.' ), 'type' => 'array', 'items' => array( 'type' => 'string' ), 'context' => array( 'edit' ), 'readonly' => true, ); $schema['properties']['filename'] = array( 'description' => __( 'Original attachment file name.' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ); $schema['properties']['filesize'] = array( 'description' => __( 'Attachment file size in bytes.' ), 'type' => array( 'integer', 'null' ), 'context' => array( 'view', 'edit' ), 'readonly' => true, ); $schema['properties']['exif_orientation'] = array( 'description' => __( 'EXIF orientation value. Values 1-8 follow the EXIF specification, where 1 means no rotation needed.' ), 'type' => 'integer', 'context' => array( 'edit' ), 'readonly' => true, ); // Enumerate the registered sub-sizes so the schema documents exactly which // keys may appear under "sizes". $size_quality_properties = array(); foreach ( array_keys( wp_get_registered_image_subsizes() ) as $size_name ) { $size_quality_properties[ $size_name ] = array( 'type' => 'integer', 'minimum' => 1, 'maximum' => 100, ); } $schema['properties']['image_quality'] = array( '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".' ), 'type' => 'object', 'context' => array( 'edit' ), 'readonly' => true, 'properties' => array( 'default' => array( 'type' => 'integer', 'minimum' => 1, 'maximum' => 100, ), 'sizes' => array( 'type' => 'object', 'properties' => $size_quality_properties, ), ), ); $schema['properties']['image_output_format'] = array( '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.' ), 'type' => array( 'string', 'null' ), 'context' => array( 'edit' ), 'readonly' => true, ); $schema['properties']['image_save_progressive'] = array( 'description' => __( 'Whether to use progressive/interlaced encoding when saving this image.' ), 'type' => 'boolean', 'context' => array( 'edit' ), 'readonly' => true, ); unset( $schema['properties']['password'] ); $this->schema = $schema; return $this->add_additional_fields_schema( $this->schema ); } /** * Handles an upload via raw POST data. * * @since 4.7.0 * @since 6.6.0 Added the `$time` parameter. * * @param string $data Supplied file data. * @param array $headers HTTP headers from the request. * @param string|null $time Optional. Time formatted in 'yyyy/mm'. Default null. * @return array{ file: non-empty-string, url: non-empty-string, type: non-empty-string }|WP_Error Data from wp_handle_sideload(). */ protected function upload_from_data( $data, $headers, $time = null ) { if ( empty( $data ) ) { return new WP_Error( 'rest_upload_no_data', __( 'No data supplied.' ), array( 'status' => 400 ) ); } if ( empty( $headers['content_type'] ) ) { return new WP_Error( 'rest_upload_no_content_type', __( 'No Content-Type supplied.' ), array( 'status' => 400 ) ); } if ( empty( $headers['content_disposition'] ) ) { return new WP_Error( 'rest_upload_no_content_disposition', __( 'No Content-Disposition supplied.' ), array( 'status' => 400 ) ); } $filename = self::get_filename_from_disposition( $headers['content_disposition'] ); if ( empty( $filename ) ) { return new WP_Error( 'rest_upload_invalid_disposition', __( 'Invalid Content-Disposition supplied. Content-Disposition needs to be formatted as `attachment; filename="image.png"` or similar.' ), array( 'status' => 400 ) ); } if ( ! empty( $headers['content_md5'] ) ) { $content_md5 = array_shift( $headers['content_md5'] ); $expected = trim( $content_md5 ); $actual = md5( $data ); if ( $expected !== $actual ) { return new WP_Error( 'rest_upload_hash_mismatch', __( 'Content hash did not match expected.' ), array( 'status' => 412 ) ); } } // Get the content-type. $type = array_shift( $headers['content_type'] ); // Include filesystem functions to get access to wp_tempnam() and wp_handle_sideload(). require_once ABSPATH . 'wp-admin/includes/file.php'; // Save the file. $tmpfname = wp_tempnam( $filename ); $fp = fopen( $tmpfname, 'w+' ); if ( ! $fp ) { return new WP_Error( 'rest_upload_file_error', __( 'Could not open file handle.' ), array( 'status' => 500 ) ); } fwrite( $fp, $data ); fclose( $fp ); // Now, sideload it in. $file_data = array( 'error' => null, 'tmp_name' => $tmpfname, 'name' => $filename, 'type' => $type, ); $size_check = self::check_upload_size( $file_data ); if ( is_wp_error( $size_check ) ) { return $size_check; } $overrides = array( 'test_form' => false, ); $sideloaded = wp_handle_sideload( $file_data, $overrides, $time ); if ( isset( $sideloaded['error'] ) ) { @unlink( $tmpfname ); return new WP_Error( 'rest_upload_sideload_error', $sideloaded['error'], array( 'status' => 500 ) ); } return $sideloaded; } /** * Parses filename from a Content-Disposition header value. * * As per RFC6266: * * content-disposition = "Content-Disposition" ":" * disposition-type *( ";" disposition-parm ) * * disposition-type = "inline" | "attachment" | disp-ext-type * ; case-insensitive * disp-ext-type = token * * disposition-parm = filename-parm | disp-ext-parm * * filename-parm = "filename" "=" value * | "filename*" "=" ext-value * * disp-ext-parm = token "=" value * | ext-token "=" ext-value * ext-token = <the characters in token, followed by "*"> * * @since 4.7.0 * * @link https://tools.ietf.org/html/rfc2388 * @link https://tools.ietf.org/html/rfc6266 * * @param string[] $disposition_header List of Content-Disposition header values. * @return string|null Filename if available, or null if not found. */ public static function get_filename_from_disposition( $disposition_header ) { // Get the filename. $filename = null; foreach ( $disposition_header as $value ) { $value = trim( $value ); if ( ! str_contains( $value, ';' ) ) { continue; } list( , $attr_parts ) = explode( ';', $value, 2 ); $attr_parts = explode( ';', $attr_parts ); $attributes = array(); foreach ( $attr_parts as $part ) { if ( ! str_contains( $part, '=' ) ) { continue; } list( $key, $value ) = explode( '=', $part, 2 ); $attributes[ trim( $key ) ] = trim( $value ); } if ( empty( $attributes['filename'] ) ) { continue; } $filename = trim( $attributes['filename'] ); // Unquote quoted filename, but after trimming. if ( str_starts_with( $filename, '"' ) && str_ends_with( $filename, '"' ) ) { $filename = substr( $filename, 1, -1 ); } } return $filename; } /** * Retrieves the query params for collections of attachments. * * @since 4.7.0 * @since 6.9.0 Extends the `media_type` and `mime_type` request arguments to support array values. * * @return array Query parameters for the attachment collection as an array. */ public function get_collection_params() { $params = parent::get_collection_params(); $params['status']['default'] = 'inherit'; $params['status']['items']['enum'] = array( 'inherit', 'private', 'trash' ); $media_types = array_keys( $this->get_media_types() ); $params['media_type'] = array( 'default' => null, 'description' => __( 'Limit result set to attachments of a particular media type or media types.' ), 'type' => 'array', 'items' => array( 'type' => 'string', 'enum' => $media_types, ), ); $params['mime_type'] = array( 'default' => null, 'description' => __( 'Limit result set to attachments of a particular MIME type or MIME types.' ), 'type' => 'array', 'items' => array( 'type' => 'string', ), ); return $params; } /** * Handles an upload via multipart/form-data ($_FILES). * * @since 4.7.0 * @since 6.6.0 Added the `$time` parameter. * * @param array $files Data from the `$_FILES` superglobal. * @param array $headers HTTP headers from the request. * @param string|null $time Optional. Time formatted in 'yyyy/mm'. Default null. * @return array{ file: non-empty-string, url: non-empty-string, type: non-empty-string }|WP_Error Data from wp_handle_upload(). */ protected function upload_from_file( $files, $headers, $time = null ) { if ( empty( $files ) ) { return new WP_Error( 'rest_upload_no_data', __( 'No data supplied.' ), array( 'status' => 400 ) ); } // Verify hash, if given. if ( ! empty( $headers['content_md5'] ) ) { $content_md5 = array_shift( $headers['content_md5'] ); $expected = trim( $content_md5 ); $actual = md5_file( $files['file']['tmp_name'] ); if ( $expected !== $actual ) { return new WP_Error( 'rest_upload_hash_mismatch', __( 'Content hash did not match expected.' ), array( 'status' => 412 ) ); } } // Pass off to WP to handle the actual upload. $overrides = array( 'test_form' => false, ); // Bypasses is_uploaded_file() when running unit tests. if ( defined( 'DIR_TESTDATA' ) && DIR_TESTDATA ) { $overrides['action'] = 'wp_handle_mock_upload'; } $size_check = self::check_upload_size( $files['file'] ); if ( is_wp_error( $size_check ) ) { return $size_check; } // Include filesystem functions to get access to wp_handle_upload(). require_once ABSPATH . 'wp-admin/includes/file.php'; $file = wp_handle_upload( $files['file'], $overrides, $time ); if ( isset( $file['error'] ) ) { return new WP_Error( 'rest_upload_unknown_error', $file['error'], array( 'status' => 500 ) ); } return $file; } /** * Retrieves the supported media types. * * Media types are considered the MIME type category. * * @since 4.7.0 * * @return array Array of supported media types. */ protected function get_media_types() { $media_types = array(); foreach ( get_allowed_mime_types() as $mime_type ) { $parts = explode( '/', $mime_type ); if ( ! isset( $media_types[ $parts[0] ] ) ) { $media_types[ $parts[0] ] = array(); } $media_types[ $parts[0] ][] = $mime_type; } return $media_types; } /** * Determine if uploaded file exceeds space quota on multisite. * * Replicates check_upload_size(). * * @since 4.9.8 * * @param array $file $_FILES array for a given file. * @return true|WP_Error True if can upload, error for errors. */ protected function check_upload_size( $file ) { if ( ! is_multisite() ) { return true; } if ( get_site_option( 'upload_space_check_disabled' ) ) { return true; } $space_left = get_upload_space_available(); $file_size = filesize( $file['tmp_name'] ); if ( $space_left < $file_size ) { return new WP_Error( 'rest_upload_limited_space', /* translators: %s: Required disk space in kilobytes. */ sprintf( __( 'Not enough space to upload. %s KB needed.' ), number_format( ( $file_size - $space_left ) / KB_IN_BYTES ) ), array( 'status' => 400 ) ); } if ( $file_size > ( KB_IN_BYTES * get_site_option( 'fileupload_maxk', 1500 ) ) ) { return new WP_Error( 'rest_upload_file_too_big', /* translators: %s: Maximum allowed file size in kilobytes. */ sprintf( __( 'This file is too big. Files must be less than %s KB in size.' ), get_site_option( 'fileupload_maxk', 1500 ) ), array( 'status' => 400 ) ); } // Include multisite admin functions to get access to upload_is_user_over_quota(). require_once ABSPATH . 'wp-admin/includes/ms.php'; if ( upload_is_user_over_quota( false ) ) { return new WP_Error( 'rest_upload_user_quota_exceeded', __( 'You have used your space quota. Please delete files before uploading.' ), array( 'status' => 400 ) ); } return true; } /** * Gets the request args for the edit item route. * * @since 5.5.0 * @since 6.9.0 Adds flips capability and editable fields for the newly-created attachment post. * * @return array */ protected function get_edit_media_item_args() { $args = array( 'src' => array( 'description' => __( 'URL to the edited image file.' ), 'type' => 'string', 'format' => 'uri', 'required' => true, ), // The `modifiers` param takes precedence over the older format. 'modifiers' => array( 'description' => __( 'Array of image edits.' ), 'type' => 'array', 'minItems' => 1, 'items' => array( 'description' => __( 'Image edit.' ), 'type' => 'object', 'required' => array( 'type', 'args', ), 'oneOf' => array( array( 'title' => __( 'Flip' ), 'properties' => array( 'type' => array( 'description' => __( 'Flip type.' ), 'type' => 'string', 'enum' => array( 'flip' ), ), 'args' => array( 'description' => __( 'Flip arguments.' ), 'type' => 'object', 'required' => array( 'flip', ), 'properties' => array( 'flip' => array( 'description' => __( 'Flip direction.' ), 'type' => 'object', 'required' => array( 'horizontal', 'vertical', ), 'properties' => array( 'horizontal' => array( 'description' => __( 'Whether to flip in the horizontal direction.' ), 'type' => 'boolean', ), 'vertical' => array( 'description' => __( 'Whether to flip in the vertical direction.' ), 'type' => 'boolean', ), ), ), ), ), ), ), array( 'title' => __( 'Rotation' ), 'properties' => array( 'type' => array( 'description' => __( 'Rotation type.' ), 'type' => 'string', 'enum' => array( 'rotate' ), ), 'args' => array( 'description' => __( 'Rotation arguments.' ), 'type' => 'object', 'required' => array( 'angle', ), 'properties' => array( 'angle' => array( 'description' => __( 'Angle to rotate clockwise in degrees.' ), 'type' => 'number', ), ), ), ), ), array( 'title' => __( 'Crop' ), 'properties' => array( 'type' => array( 'description' => __( 'Crop type.' ), 'type' => 'string', 'enum' => array( 'crop' ), ), 'args' => array( 'description' => __( 'Crop arguments.' ), 'type' => 'object', 'required' => array( 'left', 'top', 'width', 'height', ), 'properties' => array( 'left' => array( 'description' => __( 'Horizontal position from the left to begin the crop as a percentage of the image width.' ), 'type' => 'number', ), 'top' => array( 'description' => __( 'Vertical position from the top to begin the crop as a percentage of the image height.' ), 'type' => 'number', ), 'width' => array( 'description' => __( 'Width of the crop as a percentage of the image width.' ), 'type' => 'number', ), 'height' => array( 'description' => __( 'Height of the crop as a percentage of the image height.' ), 'type' => 'number', ), ), ), ), ), ), ), ), 'rotation' => array( 'description' => __( 'The amount to rotate the image clockwise in degrees. DEPRECATED: Use `modifiers` instead.' ), 'type' => 'integer', 'minimum' => 0, 'exclusiveMinimum' => true, 'maximum' => 360, 'exclusiveMaximum' => true, ), 'x' => array( 'description' => __( 'As a percentage of the image, the x position to start the crop from. DEPRECATED: Use `modifiers` instead.' ), 'type' => 'number', 'minimum' => 0, 'maximum' => 100, ), 'y' => array( 'description' => __( 'As a percentage of the image, the y position to start the crop from. DEPRECATED: Use `modifiers` instead.' ), 'type' => 'number', 'minimum' => 0, 'maximum' => 100, ), 'width' => array( 'description' => __( 'As a percentage of the image, the width to crop the image to. DEPRECATED: Use `modifiers` instead.' ), 'type' => 'number', 'minimum' => 0, 'maximum' => 100, ), 'height' => array( 'description' => __( 'As a percentage of the image, the height to crop the image to. DEPRECATED: Use `modifiers` instead.' ), 'type' => 'number', 'minimum' => 0, 'maximum' => 100, ), ); /* * Get the args based on the post schema. This calls `rest_get_endpoint_args_for_schema()`, * which also takes care of sanitization and validation. */ $update_item_args = $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ); if ( isset( $update_item_args['caption'] ) ) { $args['caption'] = $update_item_args['caption']; } if ( isset( $update_item_args['description'] ) ) { $args['description'] = $update_item_args['description']; } if ( isset( $update_item_args['title'] ) ) { $args['title'] = $update_item_args['title']; } if ( isset( $update_item_args['post'] ) ) { $args['post'] = $update_item_args['post']; } if ( isset( $update_item_args['alt_text'] ) ) { $args['alt_text'] = $update_item_args['alt_text']; } return $args; } /** * Gets the attachment's original file name. * * @since 7.0.0 * * @param int $attachment_id Attachment ID. * @return string|null Attachment file name, or null if not found. */ protected function get_attachment_filename( int $attachment_id ): ?string { $path = wp_get_original_image_path( $attachment_id ); if ( $path ) { return wp_basename( $path ); } $path = get_attached_file( $attachment_id ); if ( $path ) { return wp_basename( $path ); } return null; } /** * Gets the attachment's file size in bytes. * * @since 7.0.0 * * @param int $attachment_id Attachment ID. * @return int|null Attachment file size in bytes, or null if not available. * @phpstan-return non-negative-int|null */ protected function get_attachment_filesize( int $attachment_id ): ?int { $meta = wp_get_attachment_metadata( $attachment_id ); if ( isset( $meta['filesize'] ) && is_numeric( $meta['filesize'] ) && $meta['filesize'] > 0 ) { return (int) $meta['filesize']; } $original_path = wp_get_original_image_path( $attachment_id ); $attached_file = $original_path ? $original_path : get_attached_file( $attachment_id ); if ( is_string( $attached_file ) && is_readable( $attached_file ) ) { return wp_filesize( $attached_file ); } return null; } /** * Checks if a given request has access to sideload a file. * * Sideloading a file for an existing attachment * requires both update and create permissions. * * @since 7.1.0 * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has access to update the item, WP_Error object otherwise. */ public function sideload_item_permissions_check( $request ) { return $this->edit_media_item_permissions_check( $request ); } /** * Validates an image size name, or an array of names sharing a single file. * * Shared by the sideload endpoint, which names the size a file is produced * for, and the finalize endpoint, which names the size each submitted entry * is stored under. Both need the same set, and finalize accepts a payload of * its own rather than one this class produced, so leaving it unconstrained * there would let a submission write an arbitrary key into the metadata * 'sizes' array or route a file into a branch it was never produced for. * * @since 7.1.0 * * @param mixed $value The image size name, or an array of names. * @param string $param Parameter name, used in the error messages. * @return true|WP_Error True when every name is valid, WP_Error otherwise. */ private static function validate_image_size_names( $value, string $param ) { $special_sizes = self::get_special_image_sizes(); $regular_sizes = array_values( array_diff( array_merge( array_keys( wp_get_registered_image_subsizes() ), // Not a registered sub-size, but stored as an ordinary // entry in the metadata 'sizes' array (PDF thumbnails). array( 'full' ) ), $special_sizes ) ); if ( is_string( $value ) ) { $items = array( $value ); $valid_sizes = array_merge( $regular_sizes, $special_sizes ); } elseif ( is_array( $value ) ) { /** * An array registers one sideloaded file under several size names, * which only makes sense for regular sub-sizes: each special size * names a single file with its own handling in * {@see self::sideload_item()} and its own metadata key in * {@see self::finalize_item()}. Rejecting them here is what lets the * array branches in both methods treat an array as regular sizes. */ $items = $value; $valid_sizes = $regular_sizes; } else { return new WP_Error( 'rest_invalid_type', /* translators: %s: Parameter name. */ sprintf( __( '%s must be a string or an array of strings.' ), $param ) ); } foreach ( $items as $item ) { if ( ! in_array( $item, $valid_sizes, true ) ) { return new WP_Error( 'rest_not_in_enum', /* translators: %s: Parameter name. */ sprintf( __( '%s contains an invalid image size.' ), $param ) ); } } return true; } /** * Returns the image size names which name a single file rather than a sub-size. * * Each of these is handled on its own in {@see self::sideload_item()} and stored * under its own key by {@see self::finalize_item()}, so unlike a regular * sub-size none of them may appear in an array of names sharing one file. * * @since 7.1.0 * * @return string[] Special image size names. * * @phpstan-return non-empty-list<non-empty-string> */ private static function get_special_image_sizes(): array { return array( 'original', 'scaled', // Source-format original (e.g. the HEIC kept alongside its JPEG derivative). self::IMAGE_SIZE_SOURCE_ORIGINAL, // Converted-video companions for an animated GIF (the MP4/WebM and its poster). 'animated_video', 'animated_video_poster', ); } /** * Validates that uploaded image dimensions are appropriate for the specified image size. * * @since 7.1.0 * * @param int $width Uploaded image width. * @param int $height Uploaded image height. * @param string $image_size The target image size name. * @param int $attachment_id The attachment ID. * @return true|WP_Error True if valid, WP_Error if invalid. */ private function validate_image_dimensions( int $width, int $height, string $image_size, int $attachment_id ) { // All image sizes require positive dimensions. if ( $width <= 0 || $height <= 0 ) { return new WP_Error( 'rest_upload_invalid_dimensions', __( 'Uploaded image must have positive dimensions.' ), array( 'status' => 400 ) ); } /* * 'original' size: the full-size image that replaces the main file (see * sideload_item()/finalize_item()). The endpoint expects any EXIF * orientation to be applied to the image already, which can swap width * and height, so the dimensions must match the stored dimensions or be * their transpose. */ if ( 'original' === $image_size ) { $metadata = wp_get_attachment_metadata( $attachment_id, true ); if ( is_array( $metadata ) && isset( $metadata['width'], $metadata['height'] ) ) { $expected_width = (int) $metadata['width']; $expected_height = (int) $metadata['height']; $matches_dimensions = $width === $expected_width && $height === $expected_height; $transposes_dimensions = $width === $expected_height && $height === $expected_width; if ( ! $matches_dimensions && ! $transposes_dimensions ) { return new WP_Error( 'rest_upload_dimension_mismatch', sprintf( /* translators: 1: Actual width, 2: actual height, 3: expected width, 4: expected height. */ __( 'Uploaded image dimensions (%1$dx%2$d) do not match original image dimensions (%3$dx%4$d).' ), $width, $height, $expected_width, $expected_height ), array( 'status' => 400 ) ); } } return true; } // 'full' size (PDF thumbnails) and 'scaled': no further constraints. if ( in_array( $image_size, array( 'full', 'scaled' ), true ) ) { return true; } /* * 'animated_video_poster' companion: a static poster image for the * converted video. It is a real image (so it has positive dimensions) * but is not a registered sub-size, so it has no dimension constraint. */ if ( 'animated_video_poster' === $image_size ) { return true; } // Regular image sizes: validate against registered size constraints. $registered_sizes = wp_get_registered_image_subsizes(); if ( ! isset( $registered_sizes[ $image_size ] ) ) { return new WP_Error( 'rest_upload_unknown_size', __( 'Unknown image size.' ), array( 'status' => 400 ) ); } $size_data = $registered_sizes[ $image_size ]; $max_width = (int) $size_data['width']; $max_height = (int) $size_data['height']; // Validate dimensions don't exceed the registered size maximums. // Allow 1px tolerance for rounding differences. $tolerance = 1; if ( $this->dimension_exceeds_max( $width, $max_width, $tolerance ) ) { return new WP_Error( 'rest_upload_dimension_mismatch', sprintf( /* translators: 1: Image size name, 2: maximum width, 3: actual width. */ __( 'Uploaded image width (%3$d) exceeds maximum for "%1$s" size (%2$d).' ), $image_size, $max_width, $width ), array( 'status' => 400 ) ); } if ( $this->dimension_exceeds_max( $height, $max_height, $tolerance ) ) { return new WP_Error( 'rest_upload_dimension_mismatch', sprintf( /* translators: 1: Image size name, 2: maximum height, 3: actual height. */ __( 'Uploaded image height (%3$d) exceeds maximum for "%1$s" size (%2$d).' ), $image_size, $max_height, $height ), array( 'status' => 400 ) ); } return true; } /** * Checks whether a dimension exceeds the maximum allowed value. * * A maximum of zero means the dimension is unconstrained. * * @since 7.1.0 * * @param int $value The actual dimension in pixels. * @param int $max The maximum allowed dimension in pixels. Zero means no constraint. * @param int $tolerance Pixel tolerance allowed for rounding differences. * @return bool True if the value exceeds the maximum plus tolerance. */ private function dimension_exceeds_max( int $value, int $max, int $tolerance ): bool { return $max > 0 && $value > $max + $tolerance; } /** * Side-loads a media file without creating a new attachment. * * @since 7.1.0 * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure. */ public function sideload_item( WP_REST_Request $request ) { $attachment_id = (int) $request['id']; $post = $this->get_post( $attachment_id ); if ( is_wp_error( $post ) ) { return $post; } if ( ! wp_attachment_is_image( $post ) && ! wp_attachment_is( 'pdf', $post ) ) { return new WP_Error( 'rest_post_invalid_id', __( 'Invalid post ID. Only images and PDFs can be sideloaded.' ), array( 'status' => 400 ) ); } /* * Sideloaded files are placed in the same directory as the attachment * they extend, because the file names produced here are later resolved * against that directory. An attachment stored outside the uploads * directory has no such directory to use, so there is nowhere the names * this would produce could resolve. */ $attached_file = get_attached_file( $attachment_id, true ); $subdir = is_string( $attached_file ) && '' !== $attached_file ? $this->get_attachment_upload_subdir( $attached_file ) : null; if ( ! is_string( $attached_file ) || '' === $attached_file || null === $subdir ) { return new WP_Error( 'rest_sideload_attachment_not_in_uploads', __( 'The attachment is not stored in the uploads directory, so a file cannot be sideloaded for it.' ), array( 'status' => 403 ) ); } if ( false === $request['convert_format'] ) { // Prevent image conversion as that is done client-side. add_filter( 'image_editor_output_format', '__return_empty_array', 100 ); } // Get the file via $_FILES or raw data. $files = $request->get_file_params(); $headers = $request->get_headers(); /* * wp_unique_filename() will always add numeric suffix if the name looks like a sub-size to avoid conflicts. * See /wp-includes/functions.php. * With the following filter we can work around this safeguard. */ $attachment_filename = wp_basename( $attached_file ); $filter_filename = static function ( $filename, $ext, $dir, $unique_filename_callback, $alt_filenames, $number ) use ( $attachment_filename ) { return self::filter_wp_unique_filename( $filename, $dir, $number, $attachment_filename ); }; add_filter( 'wp_unique_filename', $filter_filename, 10, 6 ); // Pin the upload to the attachment's own directory, rather than deriving // it from the parent post's date as media_handle_upload() does for a // brand new upload. See the note above where $subdir is resolved. $filter_upload_dir = static function ( $uploads ) use ( $subdir ) { if ( is_array( $uploads ) && isset( $uploads['basedir'], $uploads['baseurl'] ) && is_string( $uploads['basedir'] ) && is_string( $uploads['baseurl'] ) ) { $uploads['subdir'] = $subdir; $uploads['path'] = $uploads['basedir'] . $subdir; $uploads['url'] = $uploads['baseurl'] . $subdir; } return $uploads; }; add_filter( 'upload_dir', $filter_upload_dir, 100 ); if ( ! empty( $files ) ) { $file = $this->upload_from_file( $files, $headers ); } else { $file = $this->upload_from_data( $request->get_body(), $headers ); } remove_filter( 'wp_unique_filename', $filter_filename ); remove_filter( 'image_editor_output_format', '__return_empty_array', 100 ); remove_filter( 'upload_dir', $filter_upload_dir, 100 ); if ( is_wp_error( $file ) ) { return $file; } $type = $file['type']; $path = $file['file']; /** @var non-empty-string|non-empty-list<non-empty-string> $image_size */ $image_size = $request['image_size']; /* * Validate raster sub-sizes before storing them. Two companion sizes * are exempt because wp_getimagesize() may not be able to read the * file at all: the 'animated_video' companion of an animated GIF is a * video (MP4/WebM), and a source-format original (e.g. a HEIC or JXL * kept next to its JPEG derivative) may be an unreadable format. Their * dimensions are neither validated nor recorded. The * 'animated_video_poster' companion is a real image, so it is still * read and rejected if unreadable; validate_image_dimensions() skips * only the registered-size constraint for it. */ $skip_dimension_read = self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size || 'animated_video' === $image_size; $size = false; if ( ! $skip_dimension_read ) { /* * Read the dimensions up front. A file whose dimensions cannot be * read is corrupted or an unsupported format and must be rejected * rather than silently stored with zero dimensions. */ $size = wp_getimagesize( $path ); if ( ! $size ) { // Clean up the uploaded file. wp_delete_file( $path ); return new WP_Error( 'rest_upload_invalid_image', __( 'Could not read image dimensions. The file may be corrupted or an unsupported format.' ), array( 'status' => 400 ) ); } /* * Validate the dimensions against every size the file is being * registered under. An array $image_size shares one file among * several registered sizes, so the file has to satisfy each of * them; validating only the scalar case would let a name wrapped * in a one-element array skip the constraint entirely. */ foreach ( (array) $image_size as $size_name ) { $validation = $this->validate_image_dimensions( $size[0], $size[1], $size_name, $attachment_id ); if ( is_wp_error( $validation ) ) { // Clean up the uploaded file. wp_delete_file( $path ); return $validation; } } } // Build sub-size data to return to the client. // The client accumulates these and sends them all to the finalize // endpoint, which writes the metadata in a single operation. This // avoids the read-modify-write race that concurrent sideloads for the // same attachment would otherwise hit. $sub_size_data = array( 'image_size' => $image_size, ); if ( is_array( $image_size ) ) { /** * Multiple registered sizes share these dimensions, so a single * sideloaded file is reused for all of them. Arrays only carry * regular sub-sizes; the special keys below are always scalar * (ref. {@see self::get_special_image_sizes()}). Those never skip * the read above, so $size already holds the dimensions. */ $sub_size_data['width'] = $size ? $size[0] : 0; $sub_size_data['height'] = $size ? $size[1] : 0; $sub_size_data['file'] = wp_basename( $path ); $sub_size_data['mime_type'] = $type; $sub_size_data['filesize'] = wp_filesize( $path ); } elseif ( self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size ) { /* * Source-format original (e.g. the HEIC kept next to its JPEG * derivative). Record the filename so finalize_item can store it * under the dedicated source-image meta key. */ $sub_size_data['file'] = wp_basename( $path ); } elseif ( 'animated_video' === $image_size || 'animated_video_poster' === $image_size ) { /* * Converted-video companion of an animated GIF (the MP4/WebM or * its static first-frame poster). Record the filename so * finalize_item can store it under its dedicated meta key. */ $sub_size_data['file'] = wp_basename( $path ); } elseif ( 'scaled' === $image_size || 'original' === $image_size ) { /* * 'scaled' and 'original' both replace the attachment's main file * with the supplied image and keep the file being replaced as * `original_image`, which is the untouched upload. A 'scaled' * image is downsized and an 'original' image has any EXIF * orientation already applied. This is the same swap WordPress * makes when it scales or rotates an image on upload; see * _wp_image_meta_replace_original(). */ $sub_size_data['original_image'] = $attachment_filename; // Validate the supplied image before updating the attached file. // $size was read above: neither of these sizes skips that read. $filesize = wp_filesize( $path ); if ( ! $size || ! $filesize ) { // Clean up the uploaded file, which nothing references yet. wp_delete_file( $path ); return new WP_Error( 'rest_sideload_invalid_image', __( 'Unable to read the sideloaded image file.' ), array( 'status' => 500 ) ); } // Update the attached file to point to the supplied image. // This writes to _wp_attached_file meta, not _wp_attachment_metadata. if ( $attached_file !== $path && ! update_attached_file( $attachment_id, $path ) ) { // Clean up the uploaded file, which nothing references yet. wp_delete_file( $path ); return new WP_Error( 'rest_sideload_update_attached_file_failed', __( 'Unable to update the attached file for this attachment.' ), array( 'status' => 500 ) ); } $sub_size_data['width'] = $size[0]; $sub_size_data['height'] = $size[1]; $sub_size_data['filesize'] = $filesize; $sub_size_data['file'] = _wp_relative_upload_path( $path ); } else { // As above, $size was already read for every size reaching here. $sub_size_data['width'] = $size ? $size[0] : 0; $sub_size_data['height'] = $size ? $size[1] : 0; $sub_size_data['file'] = wp_basename( $path ); $sub_size_data['mime_type'] = $type; $sub_size_data['filesize'] = wp_filesize( $path ); } /* * Record the file names produced for this attachment so finalize can * confirm every stored sub-size was actually sideloaded here. The * values recorded are exactly the ones handed back to the client, so * finalize accepts a submission only when it echoes what was produced. */ foreach ( array( 'file', 'original_image' ) as $provenance_key ) { if ( isset( $sub_size_data[ $provenance_key ] ) && is_string( $sub_size_data[ $provenance_key ] ) && '' !== $sub_size_data[ $provenance_key ] ) { add_post_meta( $attachment_id, self::META_KEY_SIDELOAD_FILE_NAME, wp_slash( $sub_size_data[ $provenance_key ] ) ); } } return rest_ensure_response( $sub_size_data ); } /** * Filters wp_unique_filename during sideloads. * * wp_unique_filename() will always add numeric suffix if the name looks like a sub-size to avoid conflicts. * Adding this closure to the filter helps work around this safeguard. * * Example: when uploading myphoto.jpeg, WordPress normally creates myphoto-150x150.jpeg, * and when uploading myphoto-150x150.jpeg, it will be renamed to myphoto-150x150-1.jpeg * However, here it is desired not to add the suffix in order to maintain the same * naming convention as if the file was uploaded regularly. * * The suffix is only dropped when no file of that name already exists in $dir, * so this never returns a name that would overwrite one. The unsuffixed name * must also derive from the attachment's own file name, and * {@see self::sideload_item()} pins the upload to the attachment's own * directory, so any name returned here belongs to the attachment being * extended. * * @since 7.1.0 * * @link https://github.com/WordPress/wordpress-develop/blob/30954f7ac0840cfdad464928021d7f380940c347/src/wp-includes/functions.php#L2576-L2582 * * @param string $filename Unique file name. * @param string $dir Directory path. * @param int|string $number The highest number that was used to make the file name unique * or an empty string if unused. * @param string|null $attachment_filename Original attachment file name. * @return string Filtered file name. */ private static function filter_wp_unique_filename( $filename, $dir, $number, $attachment_filename ) { if ( ! is_int( $number ) || ! $attachment_filename ) { return $filename; } $ext = pathinfo( $filename, PATHINFO_EXTENSION ); $name = pathinfo( $filename, PATHINFO_FILENAME ); $orig_name = pathinfo( $attachment_filename, PATHINFO_FILENAME ); if ( ! $ext || ! $name ) { return $filename; } $matches = array(); if ( preg_match( '/(.*)-(\d+x\d+|scaled)-' . $number . '$/', $name, $matches ) ) { $filename_without_suffix = $matches[1] . '-' . $matches[2] . ".$ext"; if ( $matches[1] === $orig_name && ! file_exists( "$dir/$filename_without_suffix" ) ) { return $filename_without_suffix; } } return $filename; } /** * Validates the `sub_sizes` file names against what this attachment produced. * * The {@see self::finalize_item()} method stores the client-supplied `file` * and `original_image` values in the attachment metadata, where they are * later resolved within the attachment's upload directory and read or deleted * (for example by {@see wp_get_original_image_path()}, {@see wp_getimagesize()}, * and {@see wp_delete_attachment_files()}). * * Every file the sideload endpoint creates is recorded under * {@see self::META_KEY_SIDELOAD_FILE_NAME} as it is produced, using * server-generated names. finalize accepts a `file` or `original_image` * value only when it matches one of those recorded names (or the * attachment's own attached file, which it definitionally owns). * * @since 7.1.0 * * @param int $attachment_id The attachment being finalized. * @param array $sub_sizes Sub-size metadata collected from sideloads. * @return true|WP_Error True if every file name was produced here, WP_Error otherwise. * * @phpstan-param list<Image_Sub_Size> $sub_sizes */ protected function validate_sub_size_provenance( int $attachment_id, array $sub_sizes ) { $allowed = $this->get_sideloaded_file_names( $attachment_id ); foreach ( $sub_sizes as $sub_size ) { foreach ( array( 'file', 'original_image' ) as $key ) { /* * Every value that was sent is checked, no matter how unlikely * a name it looks. A loose emptiness test would wave through * '0', which is a valid one-character name as far as the schema * is concerned and is stored like any other. A value the schema * types as a string but which arrives as something else is * rejected rather than skipped, so a subclass which widens the * schema cannot pass an unchecked value on to the metadata. */ if ( ! isset( $sub_size[ $key ] ) ) { continue; } if ( ! is_string( $sub_size[ $key ] ) || ! in_array( $sub_size[ $key ], $allowed, true ) ) { return new WP_Error( 'rest_invalid_sub_size_file', __( 'Invalid sub-size file name. File names must have been produced by a prior sideload for this attachment.' ), array( 'status' => 400 ) ); } } } return true; } /** * Returns the file names which a finalize request may store for an attachment. * * The set is the file names the sideload endpoint recorded as it produced * them (ref. {@see self::META_KEY_SIDELOAD_FILE_NAME}), plus the attachment's own * attached file - accepted in both its uploads-relative and basename form so * a scaled main-file pointer validates regardless of which the client * echoes - plus the names already stored in the attachment's own metadata. * * @since 7.1.0 * * @param int $attachment_id The attachment being finalized. * @param bool $include_provenance Whether to include the sideload provenance rows. * Pass false to get only the names recoverable from * the attached file and stored metadata, e.g. to decide * whether a provenance row is still needed. Default true. * @return string[] File names that may appear in the finalize submission. * * @phpstan-return list<string> */ protected function get_sideloaded_file_names( int $attachment_id, bool $include_provenance = true ): array { $allowed = array(); if ( $include_provenance ) { foreach ( (array) get_post_meta( $attachment_id, self::META_KEY_SIDELOAD_FILE_NAME ) as $name ) { if ( is_string( $name ) && '' !== $name ) { $allowed[] = $name; } } } $attached_file = get_post_meta( $attachment_id, '_wp_attached_file', true ); if ( is_string( $attached_file ) && strlen( $attached_file ) > 0 ) { $allowed[] = $attached_file; $allowed[] = wp_basename( $attached_file ); } /* * Names already stored in this attachment's metadata passed this same * check when they were written, so accepting them again introduces * nothing new. */ $metadata = wp_get_attachment_metadata( $attachment_id, true ); if ( is_array( $metadata ) ) { $stored = array( $metadata['file'] ?? null, $metadata['original_image'] ?? null, $metadata[ self::META_KEY_SOURCE_IMAGE ] ?? null, $metadata['animated_video'] ?? null, $metadata['animated_video_poster'] ?? null, ); if ( ! empty( $metadata['sizes'] ) && is_array( $metadata['sizes'] ) ) { foreach ( $metadata['sizes'] as $size ) { $stored[] = is_array( $size ) ? ( $size['file'] ?? null ) : null; } } foreach ( $stored as $name ) { if ( is_string( $name ) && '' !== $name ) { $allowed[] = $name; $allowed[] = wp_basename( $name ); } } } return array_values( array_unique( $allowed ) ); } /** * Returns the uploads subdirectory an attachment is stored in. * * Used to place a sideloaded file alongside the attachment it extends. The * result is concatenated into a filesystem path by the caller, so it is * returned only when the attachment resolves inside the uploads directory * and the stored path is well formed. * * @since 7.1.0 * * @param string $attached_file Absolute path to the attached file. * @return string|null Subdirectory beginning with a slash, an empty string when the * attachment sits in the base directory, or null when the * attachment is not inside the uploads directory. * * @phpstan-param non-empty-string $attached_file */ protected function get_attachment_upload_subdir( string $attached_file ): ?string { $uploads = wp_get_upload_dir(); if ( empty( $uploads['basedir'] ) ) { return null; } $basedir = untrailingslashit( wp_normalize_path( $uploads['basedir'] ) ); $file_dir = wp_normalize_path( dirname( $attached_file ) ); /* * The attachment's directory must be the uploads base directory itself * or a directory inside it. The trailing slash in the prefix comparison * keeps a sibling directory that merely shares the prefix (for example * 'uploads-elsewhere' next to 'uploads') from matching. */ if ( $file_dir !== $basedir && ! str_starts_with( $file_dir, trailingslashit( $basedir ) ) ) { return null; } $subdir = (string) substr( $file_dir, strlen( $basedir ) ); // A prefix match alone does not rule out a path that climbs back out. if ( in_array( '..', explode( '/', $subdir ), true ) ) { return null; } return $subdir; } /** * Finalizes an attachment after client-side media processing. * * Applies the sub-size metadata collected from sideload responses in a * single metadata update, then triggers the 'wp_generate_attachment_metadata' * filter so that server-side plugins can process the attachment after all * client-side operations (upload, thumbnail generation, sideloads) are * complete. * * @since 7.1.0 * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure. */ public function finalize_item( WP_REST_Request $request ) { $attachment_id = (int) $request['id']; $post = $this->get_post( $attachment_id ); if ( is_wp_error( $post ) ) { return $post; } /** * Sub-size metadata collected from sideload responses. Confirm every * file name was produced by a prior sideload for this attachment before * storing it, so a client cannot make finalize record (and later read or * delete) another attachment's files. * * @var list<Image_Sub_Size> $sub_sizes */ $sub_sizes = $request['sub_sizes'] ?? array(); $provenance = $this->validate_sub_size_provenance( $attachment_id, $sub_sizes ); if ( is_wp_error( $provenance ) ) { return $provenance; } $metadata = wp_get_attachment_metadata( $attachment_id ); if ( ! is_array( $metadata ) ) { $metadata = array(); } // Apply all sub-size metadata collected from sideload responses. foreach ( $sub_sizes as $sub_size ) { $image_size = $sub_size['image_size']; // When multiple size names share identical dimensions the client // sends a single sub-size entry with an array of names. Register the // same file under each name. if ( is_array( $image_size ) ) { /* * Arrays carry regular sizes only, as the sideload endpoint * enforces. Each special size names a single file handled by one * of the branches below, so grouping one under a shared file * would write it to the wrong place; reject rather than guess. */ if ( array_intersect( $image_size, self::get_special_image_sizes() ) ) { return new WP_Error( 'rest_invalid_sub_size_name', __( 'A grouped sub-size entry may only name regular image sizes.' ), array( 'status' => 400 ) ); } // As below: `file` is not required by the schema, and a size // entry that names no file is not worth recording. if ( empty( $sub_size['file'] ) ) { continue; } $metadata['sizes'] = $metadata['sizes'] ?? array(); foreach ( $image_size as $name ) { $metadata['sizes'][ $name ] = array( 'width' => $sub_size['width'] ?? 0, 'height' => $sub_size['height'] ?? 0, 'file' => $sub_size['file'], 'mime-type' => $sub_size['mime_type'] ?? '', 'filesize' => $sub_size['filesize'] ?? 0, ); } continue; } if ( 'original' === $image_size || 'scaled' === $image_size ) { // Skip malformed entries so a bad payload cannot blank out the // main file metadata. if ( empty( $sub_size['file'] ) ) { continue; } /* * Record the supplied full-size image (from sideload_item()) as * the main file, keeping the current attached file as * `original_image`. A 'scaled' image is downsized and an * 'original' image is rotated; both have any EXIF orientation * already applied by the client. */ if ( ! empty( $sub_size['original_image'] ) ) { $metadata['original_image'] = $sub_size['original_image']; } $metadata['width'] = $sub_size['width'] ?? 0; $metadata['height'] = $sub_size['height'] ?? 0; $metadata['filesize'] = $sub_size['filesize'] ?? 0; $metadata['file'] = $sub_size['file']; /* * The supplied image has its orientation applied already, so * reset the stored value (from the upload) to 1, as * wp_create_image_subsizes() does for both its scale and rotate * paths. Otherwise exif_orientation would still report the * pre-rotation value and the client would rotate the image * again on a re-fetch. */ if ( ! empty( $metadata['image_meta']['orientation'] ) ) { $metadata['image_meta']['orientation'] = 1; } } elseif ( self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size ) { // As above: `file` is not required by the schema, and each of // these sizes is nothing but the file it names. if ( empty( $sub_size['file'] ) ) { continue; } /* * Source-format original: stored under its own meta key so the * scaled-sideload flow (which writes 'original_image') cannot * clobber it. 'original_image' keeps pointing at the * web-viewable JPEG derivative. Cleanup on attachment delete * is handled by wp_delete_attachment_files(). */ $metadata[ self::META_KEY_SOURCE_IMAGE ] = $sub_size['file']; } elseif ( 'animated_video' === $image_size ) { if ( empty( $sub_size['file'] ) ) { continue; } /* * Converted-video companion of an animated GIF. Stored under its * own meta key; 'original_image' keeps pointing at the GIF. Cleanup * on attachment delete is handled by wp_delete_attachment_files(). */ $metadata['animated_video'] = $sub_size['file']; } elseif ( 'animated_video_poster' === $image_size ) { if ( empty( $sub_size['file'] ) ) { continue; } // Static first-frame poster for the converted video. $metadata['animated_video_poster'] = $sub_size['file']; } else { if ( empty( $sub_size['file'] ) ) { continue; } $metadata['sizes'] = $metadata['sizes'] ?? array(); $metadata['sizes'][ $image_size ] = array( 'width' => $sub_size['width'] ?? 0, 'height' => $sub_size['height'] ?? 0, 'file' => $sub_size['file'], 'mime-type' => $sub_size['mime_type'] ?? '', 'filesize' => $sub_size['filesize'] ?? 0, ); } } /** This filter is documented in wp-admin/includes/image.php */ $metadata = apply_filters( 'wp_generate_attachment_metadata', $metadata, $attachment_id, 'update' ); wp_update_attachment_metadata( $attachment_id, $metadata ); /* * Drop only the provenance rows this request consumed, now that the * names are recorded in the metadata itself. A row is dropped only once * its name is recoverable from the stored metadata, so a name the * 'wp_generate_attachment_metadata' filter removed - or that a failed * update never persisted - keeps its row and the retried request the * endpoint documents as idempotent still validates. Rows for sideloads * that have not been finalized yet survive for a later call, and passing * the value makes the delete a no-op when the row is already gone, so a * retried request cleans up without error. Any rows left behind by an * abandoned upload are removed with the attachment itself. * * Retrying is idempotent for the request as it was sent. A name is only * unavailable to a retry once a later finalize has overwritten the same * size with a newly sideloaded file, which drops the earlier name from * the metadata the retry recovers it from. * * The names are collected before deleting so a request which repeats * the same name across many sub-sizes still issues one query per * distinct name. */ $recoverable = $this->get_sideloaded_file_names( $attachment_id, false ); $consumed = array(); foreach ( $sub_sizes as $sub_size ) { foreach ( array( 'file', 'original_image' ) as $key ) { // Matches the set validate_sub_size_provenance() checked, so // every name a request was allowed to store is also cleaned up. if ( isset( $sub_size[ $key ] ) && is_string( $sub_size[ $key ] ) && in_array( $sub_size[ $key ], $recoverable, true ) ) { $consumed[] = $sub_size[ $key ]; } } } foreach ( array_unique( $consumed ) as $file_name ) { delete_post_meta( $attachment_id, self::META_KEY_SIDELOAD_FILE_NAME, wp_slash( $file_name ) ); } $response_request = new WP_REST_Request( WP_REST_Server::READABLE, rest_get_route_for_post( $attachment_id ) ); $response_request['context'] = 'edit'; if ( isset( $request['_fields'] ) ) { $response_request['_fields'] = $request['_fields']; } return $this->prepare_item_for_response( $post, $response_request ); } }
Upload Files
Cancel
Upload
Create New
Cancel
Create
Change Permissions
Cancel
Save
Change Date
Cancel
Save
Rename Item
Cancel
Save
Confirm Delete
Are you sure you want to delete the selected items?
Cancel
Delete