Changeset 63200
Legend:
- Unmodified
- Added
- Removed
-
trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php
r63018 r63200 14 14 * 15 15 * @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 * } 16 26 */ 17 27 class WP_REST_Attachments_Controller extends WP_REST_Posts_Controller { … … 47 57 */ 48 58 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'; 49 74 50 75 /** … … 106 131 'type' => array( 'string', 'array' ), 107 132 'items' => array( 108 'type' => 'string', 133 'type' => 'string', 134 'minLength' => 1, 109 135 ), 136 'minItems' => 1, 137 'minLength' => 1, 110 138 'required' => true, 111 139 /* … … 117 145 * after route registration (e.g. via add_image_size()). 118 146 */ 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; 137 156 } 138 157 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 ); 150 159 }, 151 160 ), … … 176 185 ), 177 186 '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( 182 231 'type' => 'object', 183 232 'properties' => array( … … 187 236 'items' => array( 188 237 'type' => 'string', 238 'minLength' => 1, 189 239 ), 240 'minItems' => 1, 241 'minLength' => 1, 190 242 'required' => true, 191 243 ), … … 199 251 ), 200 252 'file' => array( 201 'type' => 'string', 253 'type' => 'string', 254 'minLength' => 1, 202 255 ), 203 256 'mime_type' => array( … … 210 263 ), 211 264 'original_image' => array( 212 'type' => 'string', 265 'type' => 'string', 266 'minLength' => 1, 213 267 ), 214 268 ), … … 598 652 * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure. 599 653 */ 600 protected function create_item_from_url( $request ) {654 protected function create_item_from_url( WP_REST_Request $request ) { 601 655 // Sideloading downloads and stores a file, so require the upload capability. 602 656 if ( ! current_user_can( 'upload_files' ) ) { … … 2464 2518 2465 2519 /** 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 /** 2466 2610 * Validates that uploaded image dimensions are appropriate for the specified image size. 2467 2611 * … … 2626 2770 } 2627 2771 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 2628 2792 if ( false === $request['convert_format'] ) { 2629 2793 // Prevent image conversion as that is done client-side. … … 2640 2804 * With the following filter we can work around this safeguard. 2641 2805 */ 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 ); 2644 2807 2645 2808 $filter_filename = static function ( $filename, $ext, $dir, $unique_filename_callback, $alt_filenames, $number ) use ( $attachment_filename ) { … … 2649 2812 add_filter( 'wp_unique_filename', $filter_filename, 10, 6 ); 2650 2813 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 ); 2660 2832 2661 2833 if ( ! empty( $files ) ) { 2662 $file = $this->upload_from_file( $files, $headers , $time);2834 $file = $this->upload_from_file( $files, $headers ); 2663 2835 } else { 2664 $file = $this->upload_from_data( $request->get_body(), $headers , $time);2836 $file = $this->upload_from_data( $request->get_body(), $headers ); 2665 2837 } 2666 2838 2667 2839 remove_filter( 'wp_unique_filename', $filter_filename ); 2668 2840 remove_filter( 'image_editor_output_format', '__return_empty_array', 100 ); 2841 remove_filter( 'upload_dir', $filter_upload_dir, 100 ); 2669 2842 2670 2843 if ( is_wp_error( $file ) ) { … … 2675 2848 $path = $file['file']; 2676 2849 2677 /** @var non-empty-string $image_size */2850 /** @var non-empty-string|non-empty-list<non-empty-string> $image_size */ 2678 2851 $image_size = $request['image_size']; 2679 2852 … … 2689 2862 * only the registered-size constraint for it. 2690 2863 */ 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; 2692 2866 2693 2867 if ( ! $skip_dimension_read ) { … … 2710 2884 2711 2885 /* 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. 2716 2891 */ 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 ); 2719 2894 if ( is_wp_error( $validation ) ) { 2720 2895 // Clean up the uploaded file. … … 2735 2910 2736 2911 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 */ 2742 2919 $sub_size_data['width'] = $size ? $size[0] : 0; 2743 2920 $sub_size_data['height'] = $size ? $size[1] : 0; … … 2769 2946 * _wp_image_meta_replace_original(). 2770 2947 */ 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; 2782 2949 2783 2950 // 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. 2785 2952 $filesize = wp_filesize( $path ); 2786 2953 2787 2954 if ( ! $size || ! $filesize ) { 2955 // Clean up the uploaded file, which nothing references yet. 2956 wp_delete_file( $path ); 2788 2957 return new WP_Error( 2789 2958 'rest_sideload_invalid_image', … … 2796 2965 // This writes to _wp_attached_file meta, not _wp_attachment_metadata. 2797 2966 if ( 2798 get_attached_file( $attachment_id, true )!== $path &&2967 $attached_file !== $path && 2799 2968 ! update_attached_file( $attachment_id, $path ) 2800 2969 ) { 2970 // Clean up the uploaded file, which nothing references yet. 2971 wp_delete_file( $path ); 2801 2972 return new WP_Error( 2802 2973 'rest_sideload_update_attached_file_failed', … … 2811 2982 $sub_size_data['file'] = _wp_relative_upload_path( $path ); 2812 2983 } else { 2813 $size = wp_getimagesize( $path ); 2814 2984 // As above, $size was already read for every size reaching here. 2815 2985 $sub_size_data['width'] = $size ? $size[0] : 0; 2816 2986 $sub_size_data['height'] = $size ? $size[1] : 0; … … 2820 2990 } 2821 2991 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 2822 3008 return rest_ensure_response( $sub_size_data ); 2823 3009 } … … 2833 3019 * However, here it is desired not to add the suffix in order to maintain the same 2834 3020 * 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. 2835 3028 * 2836 3029 * @since 7.1.0 … … 2870 3063 2871 3064 /** 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 /** 2872 3234 * Finalizes an attachment after client-side media processing. 2873 3235 * … … 2891 3253 } 2892 3254 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 2893 3269 $metadata = wp_get_attachment_metadata( $attachment_id ); 2894 3270 if ( ! is_array( $metadata ) ) { … … 2897 3273 2898 3274 // Apply all sub-size metadata collected from sideload responses. 2899 $sub_sizes = $request['sub_sizes'] ?? array();2900 2901 3275 foreach ( $sub_sizes as $sub_size ) { 2902 3276 $image_size = $sub_size['image_size']; … … 2904 3278 // When multiple size names share identical dimensions the client 2905 3279 // 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. 2907 3281 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 2908 3302 $metadata['sizes'] = $metadata['sizes'] ?? array(); 2909 3303 … … 2912 3306 'width' => $sub_size['width'] ?? 0, 2913 3307 'height' => $sub_size['height'] ?? 0, 2914 'file' => $sub_size['file'] ?? '',3308 'file' => $sub_size['file'], 2915 3309 'mime-type' => $sub_size['mime_type'] ?? '', 2916 3310 'filesize' => $sub_size['filesize'] ?? 0, … … 2954 3348 } 2955 3349 } 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 2956 3356 /* 2957 3357 * Source-format original: stored under its own meta key so the … … 2963 3363 $metadata[ self::META_KEY_SOURCE_IMAGE ] = $sub_size['file']; 2964 3364 } elseif ( 'animated_video' === $image_size ) { 3365 if ( empty( $sub_size['file'] ) ) { 3366 continue; 3367 } 3368 2965 3369 /* 2966 3370 * Converted-video companion of an animated GIF. Stored under its … … 2970 3374 $metadata['animated_video'] = $sub_size['file']; 2971 3375 } elseif ( 'animated_video_poster' === $image_size ) { 3376 if ( empty( $sub_size['file'] ) ) { 3377 continue; 3378 } 3379 2972 3380 // Static first-frame poster for the converted video. 2973 3381 $metadata['animated_video_poster'] = $sub_size['file']; 2974 3382 } else { 3383 if ( empty( $sub_size['file'] ) ) { 3384 continue; 3385 } 3386 2975 3387 $metadata['sizes'] = $metadata['sizes'] ?? array(); 2976 3388 … … 2978 3390 'width' => $sub_size['width'] ?? 0, 2979 3391 'height' => $sub_size['height'] ?? 0, 2980 'file' => $sub_size['file'] ?? '',3392 'file' => $sub_size['file'], 2981 3393 'mime-type' => $sub_size['mime_type'] ?? '', 2982 3394 'filesize' => $sub_size['filesize'] ?? 0, … … 2989 3401 2990 3402 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 } 2991 3444 2992 3445 $response_request = new WP_REST_Request( -
trunk/tests/phpunit/tests/rest-api/rest-attachments-controller.php
r63018 r63200 4103 4103 * size names, so it validates via a custom callback rather than an enum. The 4104 4104 * 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. 4106 4107 * 4107 4108 * sideload_item() never reads generate_sub_sizes, so advertising it on the … … 4146 4147 ); 4147 4148 $this->assertTrue( 4148 $validate( array( ' scaled' ), $request, $param_name ),4149 $validate( array( 'thumbnail', 'medium' ), $request, $param_name ), 4149 4150 '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.' 4150 4155 ); 4151 4156 $this->assertWPError( 4152 4157 $validate( 'not-a-real-size', $request, $param_name ), 4153 4158 '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.' 4154 4163 ); 4155 4164 … … 4992 5001 wp_update_attachment_metadata( $attachment_id, $metadata ); 4993 5002 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 4994 5014 $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 ) ); 5008 5016 5009 5017 $response = rest_get_server()->dispatch( $request ); … … 5091 5099 $original_image_meta = wp_get_attachment_metadata( $attachment_id, true )['image_meta']; 5092 5100 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. 5094 5113 $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 ) ); 5108 5115 $response = rest_get_server()->dispatch( $request ); 5109 5116 … … 5248 5255 $this->assertSame( 201, $response->get_status() ); 5249 5256 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 */ 5251 5262 $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" ); 5252 5263 $request->set_header( 'Content-Type', 'image/jpeg' ); 5253 5264 $request->set_header( 'Content-Disposition', 'attachment; filename=canola-dup.jpg' ); 5254 5265 $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' ) ); 5256 5267 $response = rest_get_server()->dispatch( $request ); 5257 5268 -
trunk/tests/qunit/fixtures/wp-api-generated.js
r62806 r63200 3706 3706 ], 3707 3707 "items": { 3708 "type": "string" 3709 }, 3708 "type": "string", 3709 "minLength": 1 3710 }, 3711 "minItems": 1, 3712 "minLength": 1, 3710 3713 "required": true 3711 3714 }, … … 3740 3743 "type": "array", 3741 3744 "default": [], 3745 "maxItems": 100, 3742 3746 "items": { 3743 3747 "type": "object", … … 3750 3754 ], 3751 3755 "items": { 3752 "type": "string" 3756 "type": "string", 3757 "minLength": 1 3753 3758 }, 3759 "minItems": 1, 3760 "minLength": 1, 3754 3761 "required": true 3755 3762 }, … … 3763 3770 }, 3764 3771 "file": { 3765 "type": "string" 3772 "type": "string", 3773 "minLength": 1 3766 3774 }, 3767 3775 "mime_type": { … … 3774 3782 }, 3775 3783 "original_image": { 3776 "type": "string" 3784 "type": "string", 3785 "minLength": 1 3777 3786 } 3778 3787 }
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)