Make WordPress Core

Changeset 63200


Ignore:
Timestamp:
08/12/2026 05:28:45 AM (12 days ago)
Author:
adamsilverstein
Message:

REST API: Bind finalize sub-size file names to their sideload.

Fix an issue where the finalize endpoint stored the sub-size file names a client sent without confirming a sideloaded image had produced them for that attachment. Those names are later resolved against the attachment's upload directory and read or deleted, so finalize could record a file the upload never created. To address this, the sideload endpoint now records each name it generates under a _wp_sideloaded_file post meta key, and the finalize endpoint accepts a name only when:

  1. a prior sideload recorded it in post meta,
  2. it is the attachment's own attached file, or
  3. it is already stored in the attachment's metadata.

Anything else returns a rest_invalid_sub_size_file error (HTTP 400) before any metadata is written. Sideloads are also pinned to the attachment's own subdirectory via the upload_dir filter, so names are in the directory they later resolve against. Finalize stays safe to repeat: a name already in the attachment's metadata is accepted again.

Follow-up to r61982, r62609.

Reviewed by westonruter.

Props westonruter.
See #64804.

Location:
trunk
Files:
3 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php

    r63018 r63200  
    1414 *
    1515 * @see WP_REST_Posts_Controller
     16 *
     17 * @phpstan-type Image_Sub_Size array{
     18 *     image_size: non-empty-string|non-empty-list<non-empty-string>,
     19 *     width?: positive-int,
     20 *     height?: positive-int,
     21 *     file?: non-empty-string,
     22 *     mime_type?: non-empty-string,
     23 *     filesize?: positive-int,
     24 *     original_image?: non-empty-string,
     25 * }
    1626 */
    1727class WP_REST_Attachments_Controller extends WP_REST_Posts_Controller {
     
    4757         */
    4858        const META_KEY_SOURCE_IMAGE = 'source_image';
     59
     60        /**
     61         * Post meta key recording the file names produced by the sideload endpoint.
     62         *
     63         * Each successful sideload appends the file name(s) it created for an
     64         * attachment under this key. The finalize endpoint reads them back to
     65         * confirm every stored sub-size was actually produced here, rather than
     66         * trusting a client-supplied name that could point at another attachment's
     67         * files. Stored as one row per value (via {@see add_post_meta()}) so concurrent
     68         * sideloads never read-modify-write a shared value.
     69         *
     70         * @since 7.1.0
     71         * @var string
     72         */
     73        const META_KEY_SIDELOAD_FILE_NAME = '_wp_sideloaded_file';
    4974
    5075        /**
     
    106131                                                                'type'              => array( 'string', 'array' ),
    107132                                                                'items'             => array(
    108                                                                         'type' => 'string',
     133                                                                        'type'      => 'string',
     134                                                                        'minLength' => 1,
    109135                                                                ),
     136                                                                'minItems'          => 1,
     137                                                                'minLength'         => 1,
    110138                                                                'required'          => true,
    111139                                                                /*
     
    117145                                                                 * after route registration (e.g. via add_image_size()).
    118146                                                                 */
    119                                                                 'validate_callback' => static function ( $value, $request, $param ) {
    120                                                                         $valid_sizes   = array_keys( wp_get_registered_image_subsizes() );
    121                                                                         $valid_sizes[] = 'original';
    122                                                                         $valid_sizes[] = 'scaled';
    123                                                                         $valid_sizes[] = 'full';
    124                                                                         // Source-format original (e.g. the HEIC kept alongside its JPEG derivative).
    125                                                                         $valid_sizes[] = self::IMAGE_SIZE_SOURCE_ORIGINAL;
    126                                                                         // Converted-video companions for an animated GIF (the MP4/WebM and its poster).
    127                                                                         $valid_sizes[] = 'animated_video';
    128                                                                         $valid_sizes[] = 'animated_video_poster';
    129 
    130                                                                         $items = is_string( $value ) ? array( $value ) : ( is_array( $value ) ? $value : null );
    131                                                                         if ( null === $items ) {
    132                                                                                 return new WP_Error(
    133                                                                                         'rest_invalid_type',
    134                                                                                         /* translators: %s: Parameter name. */
    135                                                                                         sprintf( __( '%s must be a string or an array of strings.' ), $param )
    136                                                                                 );
     147                                                                'validate_callback' => static function ( $value, WP_REST_Request $request, string $param ) {
     148                                                                        /*
     149                                                                         * Providing a custom callback replaces the default schema
     150                                                                         * validation, so apply the declared schema (type, minLength,
     151                                                                         * minItems) before the enum check below.
     152                                                                         */
     153                                                                        $schema_validity = rest_validate_request_arg( $value, $request, $param );
     154                                                                        if ( is_wp_error( $schema_validity ) ) {
     155                                                                                return $schema_validity;
    137156                                                                        }
    138157
    139                                                                         foreach ( $items as $item ) {
    140                                                                                 if ( ! is_string( $item ) || ! in_array( $item, $valid_sizes, true ) ) {
    141                                                                                         return new WP_Error(
    142                                                                                                 'rest_not_in_enum',
    143                                                                                                 /* translators: %s: Parameter name. */
    144                                                                                                 sprintf( __( '%s contains an invalid image size.' ), $param )
    145                                                                                         );
    146                                                                                 }
    147                                                                         }
    148 
    149                                                                         return true;
     158                                                                        return self::validate_image_size_names( $value, $param );
    150159                                                                },
    151160                                                        ),
     
    176185                                                        ),
    177186                                                        'sub_sizes' => array(
    178                                                                 'description' => __( 'Array of sub-size metadata collected from sideload responses.' ),
    179                                                                 'type'        => 'array',
    180                                                                 'default'     => array(),
    181                                                                 'items'       => array(
     187                                                                'description'       => __( 'Array of sub-size metadata collected from sideload responses.' ),
     188                                                                'type'              => 'array',
     189                                                                'default'           => array(),
     190                                                                /*
     191                                                                 * A finalize request sends one entry per sideloaded sub-size, so
     192                                                                 * the ceiling only needs to clear the number of sizes a site can
     193                                                                 * register. Bounding it keeps a request from repeating a name
     194                                                                 * across an arbitrary number of entries.
     195                                                                 */
     196                                                                'maxItems'          => 100,
     197                                                                /*
     198                                                                 * As on the sideload endpoint, the size names are checked in a
     199                                                                 * callback rather than an enum, so the set reflects the sizes
     200                                                                 * registered when the request runs. The callback sits on
     201                                                                 * sub_sizes because a nested property cannot carry one.
     202                                                                 */
     203                                                                'validate_callback' => static function ( $value, WP_REST_Request $request, string $param ) {
     204                                                                        /*
     205                                                                         * Providing a custom callback replaces the default schema
     206                                                                         * validation, so apply the declared schema first. That is what
     207                                                                         * guarantees each entry is an object carrying an image_size of
     208                                                                         * the declared type.
     209                                                                         */
     210                                                                        $schema_validity = rest_validate_request_arg( $value, $request, $param );
     211                                                                        if ( is_wp_error( $schema_validity ) ) {
     212                                                                                return $schema_validity;
     213                                                                        }
     214
     215                                                                        foreach ( (array) $value as $index => $sub_size ) {
     216                                                                                $sub_size = (array) $sub_size;
     217
     218                                                                                $validity = self::validate_image_size_names(
     219                                                                                        $sub_size['image_size'] ?? null,
     220                                                                                        sprintf( '%s[%s][image_size]', $param, $index )
     221                                                                                );
     222
     223                                                                                if ( is_wp_error( $validity ) ) {
     224                                                                                        return $validity;
     225                                                                                }
     226                                                                        }
     227
     228                                                                        return true;
     229                                                                },
     230                                                                'items'             => array(
    182231                                                                        'type'       => 'object',
    183232                                                                        'properties' => array(
     
    187236                                                                                        'items'       => array(
    188237                                                                                                'type' => 'string',
     238                                                                                                'minLength' => 1,
    189239                                                                                        ),
     240                                                                                        'minItems'    => 1,
     241                                                                                        'minLength'   => 1,
    190242                                                                                        'required'    => true,
    191243                                                                                ),
     
    199251                                                                                ),
    200252                                                                                'file'           => array(
    201                                                                                         'type' => 'string',
     253                                                                                        'type'      => 'string',
     254                                                                                        'minLength' => 1,
    202255                                                                                ),
    203256                                                                                'mime_type'      => array(
     
    210263                                                                                ),
    211264                                                                                'original_image' => array(
    212                                                                                         'type' => 'string',
     265                                                                                        'type'      => 'string',
     266                                                                                        'minLength' => 1,
    213267                                                                                ),
    214268                                                                        ),
     
    598652         * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
    599653         */
    600         protected function create_item_from_url( $request ) {
     654        protected function create_item_from_url( WP_REST_Request $request ) {
    601655                // Sideloading downloads and stores a file, so require the upload capability.
    602656                if ( ! current_user_can( 'upload_files' ) ) {
     
    24642518
    24652519        /**
     2520         * Validates an image size name, or an array of names sharing a single file.
     2521         *
     2522         * Shared by the sideload endpoint, which names the size a file is produced
     2523         * for, and the finalize endpoint, which names the size each submitted entry
     2524         * is stored under. Both need the same set, and finalize accepts a payload of
     2525         * its own rather than one this class produced, so leaving it unconstrained
     2526         * there would let a submission write an arbitrary key into the metadata
     2527         * 'sizes' array or route a file into a branch it was never produced for.
     2528         *
     2529         * @since 7.1.0
     2530         *
     2531         * @param mixed  $value The image size name, or an array of names.
     2532         * @param string $param Parameter name, used in the error messages.
     2533         * @return true|WP_Error True when every name is valid, WP_Error otherwise.
     2534         */
     2535        private static function validate_image_size_names( $value, string $param ) {
     2536                $special_sizes = self::get_special_image_sizes();
     2537                $regular_sizes = array_values(
     2538                        array_diff(
     2539                                array_merge(
     2540                                        array_keys( wp_get_registered_image_subsizes() ),
     2541                                        // Not a registered sub-size, but stored as an ordinary
     2542                                        // entry in the metadata 'sizes' array (PDF thumbnails).
     2543                                        array( 'full' )
     2544                                ),
     2545                                $special_sizes
     2546                        )
     2547                );
     2548
     2549                if ( is_string( $value ) ) {
     2550                        $items       = array( $value );
     2551                        $valid_sizes = array_merge( $regular_sizes, $special_sizes );
     2552                } elseif ( is_array( $value ) ) {
     2553                        /**
     2554                         * An array registers one sideloaded file under several size names,
     2555                         * which only makes sense for regular sub-sizes: each special size
     2556                         * names a single file with its own handling in
     2557                         * {@see self::sideload_item()} and its own metadata key in
     2558                         * {@see self::finalize_item()}. Rejecting them here is what lets the
     2559                         * array branches in both methods treat an array as regular sizes.
     2560                         */
     2561                        $items       = $value;
     2562                        $valid_sizes = $regular_sizes;
     2563                } else {
     2564                        return new WP_Error(
     2565                                'rest_invalid_type',
     2566                                /* translators: %s: Parameter name. */
     2567                                sprintf( __( '%s must be a string or an array of strings.' ), $param )
     2568                        );
     2569                }
     2570
     2571                foreach ( $items as $item ) {
     2572                        if ( ! in_array( $item, $valid_sizes, true ) ) {
     2573                                return new WP_Error(
     2574                                        'rest_not_in_enum',
     2575                                        /* translators: %s: Parameter name. */
     2576                                        sprintf( __( '%s contains an invalid image size.' ), $param )
     2577                                );
     2578                        }
     2579                }
     2580
     2581                return true;
     2582        }
     2583
     2584        /**
     2585         * Returns the image size names which name a single file rather than a sub-size.
     2586         *
     2587         * Each of these is handled on its own in {@see self::sideload_item()} and stored
     2588         * under its own key by {@see self::finalize_item()}, so unlike a regular
     2589         * sub-size none of them may appear in an array of names sharing one file.
     2590         *
     2591         * @since 7.1.0
     2592         *
     2593         * @return string[] Special image size names.
     2594         *
     2595         * @phpstan-return non-empty-list<non-empty-string>
     2596         */
     2597        private static function get_special_image_sizes(): array {
     2598                return array(
     2599                        'original',
     2600                        'scaled',
     2601                        // Source-format original (e.g. the HEIC kept alongside its JPEG derivative).
     2602                        self::IMAGE_SIZE_SOURCE_ORIGINAL,
     2603                        // Converted-video companions for an animated GIF (the MP4/WebM and its poster).
     2604                        'animated_video',
     2605                        'animated_video_poster',
     2606                );
     2607        }
     2608
     2609        /**
    24662610         * Validates that uploaded image dimensions are appropriate for the specified image size.
    24672611         *
     
    26262770                }
    26272771
     2772                /*
     2773                 * Sideloaded files are placed in the same directory as the attachment
     2774                 * they extend, because the file names produced here are later resolved
     2775                 * against that directory. An attachment stored outside the uploads
     2776                 * directory has no such directory to use, so there is nowhere the names
     2777                 * this would produce could resolve.
     2778                 */
     2779                $attached_file = get_attached_file( $attachment_id, true );
     2780                $subdir        = is_string( $attached_file ) && '' !== $attached_file
     2781                        ? $this->get_attachment_upload_subdir( $attached_file )
     2782                        : null;
     2783
     2784                if ( ! is_string( $attached_file ) || '' === $attached_file || null === $subdir ) {
     2785                        return new WP_Error(
     2786                                'rest_sideload_attachment_not_in_uploads',
     2787                                __( 'The attachment is not stored in the uploads directory, so a file cannot be sideloaded for it.' ),
     2788                                array( 'status' => 403 )
     2789                        );
     2790                }
     2791
    26282792                if ( false === $request['convert_format'] ) {
    26292793                        // Prevent image conversion as that is done client-side.
     
    26402804                 * With the following filter we can work around this safeguard.
    26412805                 */
    2642                 $attachment_filename = get_attached_file( $attachment_id, true );
    2643                 $attachment_filename = $attachment_filename ? wp_basename( $attachment_filename ) : null;
     2806                $attachment_filename = wp_basename( $attached_file );
    26442807
    26452808                $filter_filename = static function ( $filename, $ext, $dir, $unique_filename_callback, $alt_filenames, $number ) use ( $attachment_filename ) {
     
    26492812                add_filter( 'wp_unique_filename', $filter_filename, 10, 6 );
    26502813
    2651                 $parent_post = get_post_parent( $attachment_id );
    2652 
    2653                 $time = null;
    2654 
    2655                 // Matches logic in media_handle_upload().
    2656                 // The post date doesn't usually matter for pages, so don't backdate this upload.
    2657                 if ( $parent_post && 'page' !== $parent_post->post_type && ! str_starts_with( $parent_post->post_date, '0000-00-00' ) ) {
    2658                         $time = $parent_post->post_date;
    2659                 }
     2814                // Pin the upload to the attachment's own directory, rather than deriving
     2815                // it from the parent post's date as media_handle_upload() does for a
     2816                // brand new upload. See the note above where $subdir is resolved.
     2817                $filter_upload_dir = static function ( $uploads ) use ( $subdir ) {
     2818                        if (
     2819                                is_array( $uploads ) &&
     2820                                isset( $uploads['basedir'], $uploads['baseurl'] ) &&
     2821                                is_string( $uploads['basedir'] ) &&
     2822                                is_string( $uploads['baseurl'] )
     2823                        ) {
     2824                                $uploads['subdir'] = $subdir;
     2825                                $uploads['path']   = $uploads['basedir'] . $subdir;
     2826                                $uploads['url']    = $uploads['baseurl'] . $subdir;
     2827                        }
     2828                        return $uploads;
     2829                };
     2830
     2831                add_filter( 'upload_dir', $filter_upload_dir, 100 );
    26602832
    26612833                if ( ! empty( $files ) ) {
    2662                         $file = $this->upload_from_file( $files, $headers, $time );
     2834                        $file = $this->upload_from_file( $files, $headers );
    26632835                } else {
    2664                         $file = $this->upload_from_data( $request->get_body(), $headers, $time );
     2836                        $file = $this->upload_from_data( $request->get_body(), $headers );
    26652837                }
    26662838
    26672839                remove_filter( 'wp_unique_filename', $filter_filename );
    26682840                remove_filter( 'image_editor_output_format', '__return_empty_array', 100 );
     2841                remove_filter( 'upload_dir', $filter_upload_dir, 100 );
    26692842
    26702843                if ( is_wp_error( $file ) ) {
     
    26752848                $path = $file['file'];
    26762849
    2677                 /** @var non-empty-string $image_size */
     2850                /** @var non-empty-string|non-empty-list<non-empty-string> $image_size */
    26782851                $image_size = $request['image_size'];
    26792852
     
    26892862                 * only the registered-size constraint for it.
    26902863                 */
    2691                 $skip_dimension_read = in_array( $image_size, array( self::IMAGE_SIZE_SOURCE_ORIGINAL, 'animated_video' ), true );
     2864                $skip_dimension_read = self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size || 'animated_video' === $image_size;
     2865                $size                = false;
    26922866
    26932867                if ( ! $skip_dimension_read ) {
     
    27102884
    27112885                        /*
    2712                          * Validate the dimensions match the expected size. An array
    2713                          * $image_size represents multiple registered sizes sharing a single
    2714                          * file; those are handled by the per-size branch below, so only
    2715                          * scalar sizes are validated here.
     2886                         * Validate the dimensions against every size the file is being
     2887                         * registered under. An array $image_size shares one file among
     2888                         * several registered sizes, so the file has to satisfy each of
     2889                         * them; validating only the scalar case would let a name wrapped
     2890                         * in a one-element array skip the constraint entirely.
    27162891                         */
    2717                         if ( ! is_array( $image_size ) ) {
    2718                                 $validation = $this->validate_image_dimensions( $size[0], $size[1], $image_size, $attachment_id );
     2892                        foreach ( (array) $image_size as $size_name ) {
     2893                                $validation = $this->validate_image_dimensions( $size[0], $size[1], $size_name, $attachment_id );
    27192894                                if ( is_wp_error( $validation ) ) {
    27202895                                        // Clean up the uploaded file.
     
    27352910
    27362911                if ( is_array( $image_size ) ) {
    2737                         // Multiple registered sizes share these dimensions, so a single
    2738                         // sideloaded file is reused for all of them. Arrays only carry
    2739                         // regular sub-sizes; the special keys below are always scalar.
    2740                         $size = wp_getimagesize( $path );
    2741 
     2912                        /**
     2913                         * Multiple registered sizes share these dimensions, so a single
     2914                         * sideloaded file is reused for all of them. Arrays only carry
     2915                         * regular sub-sizes; the special keys below are always scalar
     2916                         * (ref. {@see self::get_special_image_sizes()}). Those never skip
     2917                         * the read above, so $size already holds the dimensions.
     2918                         */
    27422919                        $sub_size_data['width']     = $size ? $size[0] : 0;
    27432920                        $sub_size_data['height']    = $size ? $size[1] : 0;
     
    27692946                         * _wp_image_meta_replace_original().
    27702947                         */
    2771                         $current_file = get_attached_file( $attachment_id, true );
    2772 
    2773                         if ( ! $current_file ) {
    2774                                 return new WP_Error(
    2775                                         'rest_sideload_no_attached_file',
    2776                                         __( 'Unable to retrieve the attached file for this attachment.' ),
    2777                                         array( 'status' => 404 )
    2778                                 );
    2779                         }
    2780 
    2781                         $sub_size_data['original_image'] = wp_basename( $current_file );
     2948                        $sub_size_data['original_image'] = $attachment_filename;
    27822949
    27832950                        // Validate the supplied image before updating the attached file.
    2784                         $size     = wp_getimagesize( $path );
     2951                        // $size was read above: neither of these sizes skips that read.
    27852952                        $filesize = wp_filesize( $path );
    27862953
    27872954                        if ( ! $size || ! $filesize ) {
     2955                                // Clean up the uploaded file, which nothing references yet.
     2956                                wp_delete_file( $path );
    27882957                                return new WP_Error(
    27892958                                        'rest_sideload_invalid_image',
     
    27962965                        // This writes to _wp_attached_file meta, not _wp_attachment_metadata.
    27972966                        if (
    2798                                 get_attached_file( $attachment_id, true ) !== $path &&
     2967                                $attached_file !== $path &&
    27992968                                ! update_attached_file( $attachment_id, $path )
    28002969                        ) {
     2970                                // Clean up the uploaded file, which nothing references yet.
     2971                                wp_delete_file( $path );
    28012972                                return new WP_Error(
    28022973                                        'rest_sideload_update_attached_file_failed',
     
    28112982                        $sub_size_data['file']     = _wp_relative_upload_path( $path );
    28122983                } else {
    2813                         $size = wp_getimagesize( $path );
    2814 
     2984                        // As above, $size was already read for every size reaching here.
    28152985                        $sub_size_data['width']     = $size ? $size[0] : 0;
    28162986                        $sub_size_data['height']    = $size ? $size[1] : 0;
     
    28202990                }
    28212991
     2992                /*
     2993                 * Record the file names produced for this attachment so finalize can
     2994                 * confirm every stored sub-size was actually sideloaded here. The
     2995                 * values recorded are exactly the ones handed back to the client, so
     2996                 * finalize accepts a submission only when it echoes what was produced.
     2997                 */
     2998                foreach ( array( 'file', 'original_image' ) as $provenance_key ) {
     2999                        if (
     3000                                isset( $sub_size_data[ $provenance_key ] ) &&
     3001                                is_string( $sub_size_data[ $provenance_key ] ) &&
     3002                                '' !== $sub_size_data[ $provenance_key ]
     3003                        ) {
     3004                                add_post_meta( $attachment_id, self::META_KEY_SIDELOAD_FILE_NAME, wp_slash( $sub_size_data[ $provenance_key ] ) );
     3005                        }
     3006                }
     3007
    28223008                return rest_ensure_response( $sub_size_data );
    28233009        }
     
    28333019         * However, here it is desired not to add the suffix in order to maintain the same
    28343020         * naming convention as if the file was uploaded regularly.
     3021         *
     3022         * The suffix is only dropped when no file of that name already exists in $dir,
     3023         * so this never returns a name that would overwrite one. The unsuffixed name
     3024         * must also derive from the attachment's own file name, and
     3025         * {@see self::sideload_item()} pins the upload to the attachment's own
     3026         * directory, so any name returned here belongs to the attachment being
     3027         * extended.
    28353028         *
    28363029         * @since 7.1.0
     
    28703063
    28713064        /**
     3065         * Validates the `sub_sizes` file names against what this attachment produced.
     3066         *
     3067         * The {@see self::finalize_item()} method stores the client-supplied `file`
     3068         * and `original_image` values in the attachment metadata, where they are
     3069         * later resolved within the attachment's upload directory and read or deleted
     3070         * (for example by {@see wp_get_original_image_path()}, {@see wp_getimagesize()},
     3071         * and {@see wp_delete_attachment_files()}).
     3072         *
     3073         * Every file the sideload endpoint creates is recorded under
     3074         * {@see self::META_KEY_SIDELOAD_FILE_NAME} as it is produced, using
     3075         * server-generated names. finalize accepts a `file` or `original_image`
     3076         * value only when it matches one of those recorded names (or the
     3077         * attachment's own attached file, which it definitionally owns).
     3078         *
     3079         * @since 7.1.0
     3080         *
     3081         * @param int   $attachment_id The attachment being finalized.
     3082         * @param array $sub_sizes     Sub-size metadata collected from sideloads.
     3083         * @return true|WP_Error True if every file name was produced here, WP_Error otherwise.
     3084         *
     3085         * @phpstan-param list<Image_Sub_Size> $sub_sizes
     3086         */
     3087        protected function validate_sub_size_provenance( int $attachment_id, array $sub_sizes ) {
     3088                $allowed = $this->get_sideloaded_file_names( $attachment_id );
     3089
     3090                foreach ( $sub_sizes as $sub_size ) {
     3091                        foreach ( array( 'file', 'original_image' ) as $key ) {
     3092                                /*
     3093                                 * Every value that was sent is checked, no matter how unlikely
     3094                                 * a name it looks. A loose emptiness test would wave through
     3095                                 * '0', which is a valid one-character name as far as the schema
     3096                                 * is concerned and is stored like any other. A value the schema
     3097                                 * types as a string but which arrives as something else is
     3098                                 * rejected rather than skipped, so a subclass which widens the
     3099                                 * schema cannot pass an unchecked value on to the metadata.
     3100                                 */
     3101                                if ( ! isset( $sub_size[ $key ] ) ) {
     3102                                        continue;
     3103                                }
     3104
     3105                                if ( ! is_string( $sub_size[ $key ] ) || ! in_array( $sub_size[ $key ], $allowed, true ) ) {
     3106                                        return new WP_Error(
     3107                                                'rest_invalid_sub_size_file',
     3108                                                __( 'Invalid sub-size file name. File names must have been produced by a prior sideload for this attachment.' ),
     3109                                                array( 'status' => 400 )
     3110                                        );
     3111                                }
     3112                        }
     3113                }
     3114
     3115                return true;
     3116        }
     3117
     3118        /**
     3119         * Returns the file names which a finalize request may store for an attachment.
     3120         *
     3121         * The set is the file names the sideload endpoint recorded as it produced
     3122         * them (ref. {@see self::META_KEY_SIDELOAD_FILE_NAME}), plus the attachment's own
     3123         * attached file - accepted in both its uploads-relative and basename form so
     3124         * a scaled main-file pointer validates regardless of which the client
     3125         * echoes - plus the names already stored in the attachment's own metadata.
     3126         *
     3127         * @since 7.1.0
     3128         *
     3129         * @param int  $attachment_id      The attachment being finalized.
     3130         * @param bool $include_provenance Whether to include the sideload provenance rows.
     3131         *                                 Pass false to get only the names recoverable from
     3132         *                                 the attached file and stored metadata, e.g. to decide
     3133         *                                 whether a provenance row is still needed. Default true.
     3134         * @return string[] File names that may appear in the finalize submission.
     3135         *
     3136         * @phpstan-return list<string>
     3137         */
     3138        protected function get_sideloaded_file_names( int $attachment_id, bool $include_provenance = true ): array {
     3139                $allowed = array();
     3140
     3141                if ( $include_provenance ) {
     3142                        foreach ( (array) get_post_meta( $attachment_id, self::META_KEY_SIDELOAD_FILE_NAME ) as $name ) {
     3143                                if ( is_string( $name ) && '' !== $name ) {
     3144                                        $allowed[] = $name;
     3145                                }
     3146                        }
     3147                }
     3148
     3149                $attached_file = get_post_meta( $attachment_id, '_wp_attached_file', true );
     3150                if ( is_string( $attached_file ) && strlen( $attached_file ) > 0 ) {
     3151                        $allowed[] = $attached_file;
     3152                        $allowed[] = wp_basename( $attached_file );
     3153                }
     3154
     3155                /*
     3156                 * Names already stored in this attachment's metadata passed this same
     3157                 * check when they were written, so accepting them again introduces
     3158                 * nothing new.
     3159                 */
     3160                $metadata = wp_get_attachment_metadata( $attachment_id, true );
     3161                if ( is_array( $metadata ) ) {
     3162                        $stored = array(
     3163                                $metadata['file'] ?? null,
     3164                                $metadata['original_image'] ?? null,
     3165                                $metadata[ self::META_KEY_SOURCE_IMAGE ] ?? null,
     3166                                $metadata['animated_video'] ?? null,
     3167                                $metadata['animated_video_poster'] ?? null,
     3168                        );
     3169
     3170                        if ( ! empty( $metadata['sizes'] ) && is_array( $metadata['sizes'] ) ) {
     3171                                foreach ( $metadata['sizes'] as $size ) {
     3172                                        $stored[] = is_array( $size ) ? ( $size['file'] ?? null ) : null;
     3173                                }
     3174                        }
     3175
     3176                        foreach ( $stored as $name ) {
     3177                                if ( is_string( $name ) && '' !== $name ) {
     3178                                        $allowed[] = $name;
     3179                                        $allowed[] = wp_basename( $name );
     3180                                }
     3181                        }
     3182                }
     3183
     3184                return array_values( array_unique( $allowed ) );
     3185        }
     3186
     3187        /**
     3188         * Returns the uploads subdirectory an attachment is stored in.
     3189         *
     3190         * Used to place a sideloaded file alongside the attachment it extends. The
     3191         * result is concatenated into a filesystem path by the caller, so it is
     3192         * returned only when the attachment resolves inside the uploads directory
     3193         * and the stored path is well formed.
     3194         *
     3195         * @since 7.1.0
     3196         *
     3197         * @param string $attached_file Absolute path to the attached file.
     3198         * @return string|null Subdirectory beginning with a slash, an empty string when the
     3199         *                     attachment sits in the base directory, or null when the
     3200         *                     attachment is not inside the uploads directory.
     3201         *
     3202         * @phpstan-param non-empty-string $attached_file
     3203         */
     3204        protected function get_attachment_upload_subdir( string $attached_file ): ?string {
     3205                $uploads = wp_get_upload_dir();
     3206                if ( empty( $uploads['basedir'] ) ) {
     3207                        return null;
     3208                }
     3209
     3210                $basedir  = untrailingslashit( wp_normalize_path( $uploads['basedir'] ) );
     3211                $file_dir = wp_normalize_path( dirname( $attached_file ) );
     3212
     3213                /*
     3214                 * The attachment's directory must be the uploads base directory itself
     3215                 * or a directory inside it. The trailing slash in the prefix comparison
     3216                 * keeps a sibling directory that merely shares the prefix (for example
     3217                 * 'uploads-elsewhere' next to 'uploads') from matching.
     3218                 */
     3219                if ( $file_dir !== $basedir && ! str_starts_with( $file_dir, trailingslashit( $basedir ) ) ) {
     3220                        return null;
     3221                }
     3222
     3223                $subdir = (string) substr( $file_dir, strlen( $basedir ) );
     3224
     3225                // A prefix match alone does not rule out a path that climbs back out.
     3226                if ( in_array( '..', explode( '/', $subdir ), true ) ) {
     3227                        return null;
     3228                }
     3229
     3230                return $subdir;
     3231        }
     3232
     3233        /**
    28723234         * Finalizes an attachment after client-side media processing.
    28733235         *
     
    28913253                }
    28923254
     3255                /**
     3256                 * Sub-size metadata collected from sideload responses. Confirm every
     3257                 * file name was produced by a prior sideload for this attachment before
     3258                 * storing it, so a client cannot make finalize record (and later read or
     3259                 * delete) another attachment's files.
     3260                 *
     3261                 * @var list<Image_Sub_Size> $sub_sizes
     3262                 */
     3263                $sub_sizes  = $request['sub_sizes'] ?? array();
     3264                $provenance = $this->validate_sub_size_provenance( $attachment_id, $sub_sizes );
     3265                if ( is_wp_error( $provenance ) ) {
     3266                        return $provenance;
     3267                }
     3268
    28933269                $metadata = wp_get_attachment_metadata( $attachment_id );
    28943270                if ( ! is_array( $metadata ) ) {
     
    28973273
    28983274                // Apply all sub-size metadata collected from sideload responses.
    2899                 $sub_sizes = $request['sub_sizes'] ?? array();
    2900 
    29013275                foreach ( $sub_sizes as $sub_size ) {
    29023276                        $image_size = $sub_size['image_size'];
     
    29043278                        // When multiple size names share identical dimensions the client
    29053279                        // sends a single sub-size entry with an array of names. Register the
    2906                         // same file under each name. Arrays only contain regular sizes.
     3280                        // same file under each name.
    29073281                        if ( is_array( $image_size ) ) {
     3282                                /*
     3283                                 * Arrays carry regular sizes only, as the sideload endpoint
     3284                                 * enforces. Each special size names a single file handled by one
     3285                                 * of the branches below, so grouping one under a shared file
     3286                                 * would write it to the wrong place; reject rather than guess.
     3287                                 */
     3288                                if ( array_intersect( $image_size, self::get_special_image_sizes() ) ) {
     3289                                        return new WP_Error(
     3290                                                'rest_invalid_sub_size_name',
     3291                                                __( 'A grouped sub-size entry may only name regular image sizes.' ),
     3292                                                array( 'status' => 400 )
     3293                                        );
     3294                                }
     3295
     3296                                // As below: `file` is not required by the schema, and a size
     3297                                // entry that names no file is not worth recording.
     3298                                if ( empty( $sub_size['file'] ) ) {
     3299                                        continue;
     3300                                }
     3301
    29083302                                $metadata['sizes'] = $metadata['sizes'] ?? array();
    29093303
     
    29123306                                                'width'     => $sub_size['width'] ?? 0,
    29133307                                                'height'    => $sub_size['height'] ?? 0,
    2914                                                 'file'      => $sub_size['file'] ?? '',
     3308                                                'file'      => $sub_size['file'],
    29153309                                                'mime-type' => $sub_size['mime_type'] ?? '',
    29163310                                                'filesize'  => $sub_size['filesize'] ?? 0,
     
    29543348                                }
    29553349                        } elseif ( self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size ) {
     3350                                // As above: `file` is not required by the schema, and each of
     3351                                // these sizes is nothing but the file it names.
     3352                                if ( empty( $sub_size['file'] ) ) {
     3353                                        continue;
     3354                                }
     3355
    29563356                                /*
    29573357                                 * Source-format original: stored under its own meta key so the
     
    29633363                                $metadata[ self::META_KEY_SOURCE_IMAGE ] = $sub_size['file'];
    29643364                        } elseif ( 'animated_video' === $image_size ) {
     3365                                if ( empty( $sub_size['file'] ) ) {
     3366                                        continue;
     3367                                }
     3368
    29653369                                /*
    29663370                                 * Converted-video companion of an animated GIF. Stored under its
     
    29703374                                $metadata['animated_video'] = $sub_size['file'];
    29713375                        } elseif ( 'animated_video_poster' === $image_size ) {
     3376                                if ( empty( $sub_size['file'] ) ) {
     3377                                        continue;
     3378                                }
     3379
    29723380                                // Static first-frame poster for the converted video.
    29733381                                $metadata['animated_video_poster'] = $sub_size['file'];
    29743382                        } else {
     3383                                if ( empty( $sub_size['file'] ) ) {
     3384                                        continue;
     3385                                }
     3386
    29753387                                $metadata['sizes'] = $metadata['sizes'] ?? array();
    29763388
     
    29783390                                        'width'     => $sub_size['width'] ?? 0,
    29793391                                        'height'    => $sub_size['height'] ?? 0,
    2980                                         'file'      => $sub_size['file'] ?? '',
     3392                                        'file'      => $sub_size['file'],
    29813393                                        'mime-type' => $sub_size['mime_type'] ?? '',
    29823394                                        'filesize'  => $sub_size['filesize'] ?? 0,
     
    29893401
    29903402                wp_update_attachment_metadata( $attachment_id, $metadata );
     3403
     3404                /*
     3405                 * Drop only the provenance rows this request consumed, now that the
     3406                 * names are recorded in the metadata itself. A row is dropped only once
     3407                 * its name is recoverable from the stored metadata, so a name the
     3408                 * 'wp_generate_attachment_metadata' filter removed - or that a failed
     3409                 * update never persisted - keeps its row and the retried request the
     3410                 * endpoint documents as idempotent still validates. Rows for sideloads
     3411                 * that have not been finalized yet survive for a later call, and passing
     3412                 * the value makes the delete a no-op when the row is already gone, so a
     3413                 * retried request cleans up without error. Any rows left behind by an
     3414                 * abandoned upload are removed with the attachment itself.
     3415                 *
     3416                 * Retrying is idempotent for the request as it was sent. A name is only
     3417                 * unavailable to a retry once a later finalize has overwritten the same
     3418                 * size with a newly sideloaded file, which drops the earlier name from
     3419                 * the metadata the retry recovers it from.
     3420                 *
     3421                 * The names are collected before deleting so a request which repeats
     3422                 * the same name across many sub-sizes still issues one query per
     3423                 * distinct name.
     3424                 */
     3425                $recoverable = $this->get_sideloaded_file_names( $attachment_id, false );
     3426                $consumed    = array();
     3427                foreach ( $sub_sizes as $sub_size ) {
     3428                        foreach ( array( 'file', 'original_image' ) as $key ) {
     3429                                // Matches the set validate_sub_size_provenance() checked, so
     3430                                // every name a request was allowed to store is also cleaned up.
     3431                                if (
     3432                                        isset( $sub_size[ $key ] ) &&
     3433                                        is_string( $sub_size[ $key ] ) &&
     3434                                        in_array( $sub_size[ $key ], $recoverable, true )
     3435                                ) {
     3436                                        $consumed[] = $sub_size[ $key ];
     3437                                }
     3438                        }
     3439                }
     3440
     3441                foreach ( array_unique( $consumed ) as $file_name ) {
     3442                        delete_post_meta( $attachment_id, self::META_KEY_SIDELOAD_FILE_NAME, wp_slash( $file_name ) );
     3443                }
    29913444
    29923445                $response_request = new WP_REST_Request(
  • trunk/tests/phpunit/tests/rest-api/rest-attachments-controller.php

    r63018 r63200  
    41034103         * size names, so it validates via a custom callback rather than an enum. The
    41044104         * callback must accept 'scaled' and the 'source_original' source-format size,
    4105          * and reject unknown sizes.
     4105         * reject unknown sizes, and reject a special size sent as an array, since an
     4106         * array registers one file under several regular sub-sizes.
    41064107         *
    41074108         * sideload_item() never reads generate_sub_sizes, so advertising it on the
     
    41464147                );
    41474148                $this->assertTrue(
    4148                         $validate( array( 'scaled' ), $request, $param_name ),
     4149                        $validate( array( 'thumbnail', 'medium' ), $request, $param_name ),
    41494150                        'image_size validation should accept an array of size names.'
     4151                );
     4152                $this->assertTrue(
     4153                        $validate( array( 'full', 'large' ), $request, $param_name ),
     4154                        'image_size validation should accept the full size grouped with a registered size.'
    41504155                );
    41514156                $this->assertWPError(
    41524157                        $validate( 'not-a-real-size', $request, $param_name ),
    41534158                        'image_size validation should reject an unknown size.'
     4159                );
     4160                $this->assertWPError(
     4161                        $validate( array( 'scaled' ), $request, $param_name ),
     4162                        'image_size validation should reject a special size sent as an array.'
    41544163                );
    41554164
     
    49925001                wp_update_attachment_metadata( $attachment_id, $metadata );
    49935002
     5003                // Sideload the client-scaled image so finalize has a provenance-backed
     5004                // 'scaled' entry to store.
     5005                $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" );
     5006                $request->set_header( 'Content-Type', 'image/jpeg' );
     5007                $request->set_header( 'Content-Disposition', 'attachment; filename=big-rotated-photo-scaled.jpg' );
     5008                $request->set_param( 'image_size', 'scaled' );
     5009                $request->set_body( (string) file_get_contents( self::$test_file ) );
     5010                $response = rest_get_server()->dispatch( $request );
     5011                $this->assertSame( 200, $response->get_status(), 'Sideloading the scaled image should succeed.' );
     5012                $sub_size = $response->get_data();
     5013
    49945014                $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/finalize" );
    4995                 $request->set_param(
    4996                         'sub_sizes',
    4997                         array(
    4998                                 array(
    4999                                         'image_size'     => 'scaled',
    5000                                         'width'          => 1920,
    5001                                         'height'         => 2560,
    5002                                         'file'           => '2026/07/big-rotated-photo-scaled.jpg',
    5003                                         'filesize'       => 500000,
    5004                                         'original_image' => 'big-rotated-photo.jpg',
    5005                                 ),
    5006                         )
    5007                 );
     5015                $request->set_param( 'sub_sizes', array( $sub_size ) );
    50085016
    50095017                $response = rest_get_server()->dispatch( $request );
     
    50915099                $original_image_meta = wp_get_attachment_metadata( $attachment_id, true )['image_meta'];
    50925100
    5093                 // Finalize with a thumbnail sub-size.
     5101                // Sideload a thumbnail sub-size so finalize has a provenance-backed file
     5102                // to store. test-image.jpg is 50x50, within the thumbnail maximum.
     5103                $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" );
     5104                $request->set_header( 'Content-Type', 'image/jpeg' );
     5105                $request->set_header( 'Content-Disposition', 'attachment; filename=2004-07-22-DSC_0008-thumb.jpg' );
     5106                $request->set_param( 'image_size', 'thumbnail' );
     5107                $request->set_body( (string) file_get_contents( DIR_TESTDATA . '/images/test-image.jpg' ) );
     5108                $response = rest_get_server()->dispatch( $request );
     5109                $this->assertSame( 200, $response->get_status(), 'Sideloading a thumbnail should succeed.' );
     5110                $sub_size = $response->get_data();
     5111
     5112                // Finalize with the sideloaded thumbnail sub-size.
    50945113                $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/finalize" );
    5095                 $request->set_param(
    5096                         'sub_sizes',
    5097                         array(
    5098                                 array(
    5099                                         'image_size' => 'thumbnail',
    5100                                         'width'      => 150,
    5101                                         'height'     => 150,
    5102                                         'file'       => '2004-07-22-DSC_0008-150x150.jpg',
    5103                                         'mime_type'  => 'image/jpeg',
    5104                                         'filesize'   => 5000,
    5105                                 ),
    5106                         )
    5107                 );
     5114                $request->set_param( 'sub_sizes', array( $sub_size ) );
    51085115                $response = rest_get_server()->dispatch( $request );
    51095116
     
    52485255                $this->assertSame( 201, $response->get_status() );
    52495256
    5250                 // Sideload a single file registered under multiple sizes.
     5257                /*
     5258                 * Sideload a single file registered under multiple sizes. The file is
     5259                 * 50x50 so that it satisfies the registered maximum for every size in
     5260                 * the group, which is what sharing one file among them requires.
     5261                 */
    52515262                $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" );
    52525263                $request->set_header( 'Content-Type', 'image/jpeg' );
    52535264                $request->set_header( 'Content-Disposition', 'attachment; filename=canola-dup.jpg' );
    52545265                $request->set_param( 'image_size', array( 'thumbnail', 'medium' ) );
    5255                 $request->set_body( (string) file_get_contents( self::$test_file ) );
     5266                $request->set_body( (string) file_get_contents( DIR_TESTDATA . '/images/test-image.jpg' ) );
    52565267                $response = rest_get_server()->dispatch( $request );
    52575268
  • trunk/tests/qunit/fixtures/wp-api-generated.js

    r62806 r63200  
    37063706                            ],
    37073707                            "items": {
    3708                                 "type": "string"
    3709                             },
     3708                                "type": "string",
     3709                                "minLength": 1
     3710                            },
     3711                            "minItems": 1,
     3712                            "minLength": 1,
    37103713                            "required": true
    37113714                        },
     
    37403743                            "type": "array",
    37413744                            "default": [],
     3745                            "maxItems": 100,
    37423746                            "items": {
    37433747                                "type": "object",
     
    37503754                                        ],
    37513755                                        "items": {
    3752                                             "type": "string"
     3756                                            "type": "string",
     3757                                            "minLength": 1
    37533758                                        },
     3759                                        "minItems": 1,
     3760                                        "minLength": 1,
    37543761                                        "required": true
    37553762                                    },
     
    37633770                                    },
    37643771                                    "file": {
    3765                                         "type": "string"
     3772                                        "type": "string",
     3773                                        "minLength": 1
    37663774                                    },
    37673775                                    "mime_type": {
     
    37743782                                    },
    37753783                                    "original_image": {
    3776                                         "type": "string"
     3784                                        "type": "string",
     3785                                        "minLength": 1
    37773786                                    }
    37783787                                }
Note: See TracChangeset for help on using the changeset viewer.