PluginProbe
Gutenberg / 24.1.0
Gutenberg v24.1.0
24.1.0 24.0.0 23.9.1 23.9.0 23.8.0 23.7.2 23.7.1 23.7.0 23.6.1 23.6.2 23.6.0 23.5.3 23.5.2 23.5.1 23.5.0 23.4.0 23.3.2 23.3.1 23.3.0 23.2.0 23.2.1 23.2.2 23.1.1 23.1.0 23.0.1 All 404 releases
← All changes | lib/media/class-gutenberg-rest-attachments-controller.php +1226 -308 23.5.2 → 24.1.0 View file →
@@ -9,10 +9,21 @@
9 9 * REST API controller for media attachments.
10 10 *
11 11 * Extends the core attachments controller to add client-side media processing
12 12 * functionality including sideload support and sub-size generation control.
13 + *
14 + * @phpstan-type Image_Sub_Size array{
15 + * image_size: non-empty-string|non-empty-list<non-empty-string>,
16 + * width?: positive-int,
17 + * height?: positive-int,
18 + * file?: non-empty-string,
19 + * mime_type?: non-empty-string,
20 + * filesize?: positive-int,
21 + * original_image?: non-empty-string,
22 + * }
13 23 */
14 24 class Gutenberg_REST_Attachments_Controller extends WP_REST_Attachments_Controller {
25 +
15 26 /**
16 27 * Image size token for the source-format original preserved alongside a
17 28 * client-generated derivative (e.g. the HEIC file kept next to its JPEG).
18 29 *
@@ -20,9 +31,9 @@
20 31 * sideloaded file to its metadata key, so the two never drift apart.
21 32 *
22 33 * @var string
23 34 */
24 - const IMAGE_SIZE_SOURCE_ORIGINAL = 'original-heic';
35 + const IMAGE_SIZE_SOURCE_ORIGINAL = 'source_original';
25 36
26 37 /**
27 38 * Metadata key holding the basename of the source-format original.
28 39 *
@@ -33,8 +44,58 @@
33 44 */
34 45 const META_KEY_SOURCE_IMAGE = 'source_image';
35 46
36 47 /**
48 + * Image size token for the video transcoded from an animated GIF, sideloaded
49 + * as a companion of the GIF attachment.
50 + *
51 + * Paired with META_KEY_ANIMATED_VIDEO: used both in the `/sideload` route
52 + * and when writing the sideloaded file to its metadata key. Both use the
53 + * underscore convention so the size token and meta key stay consistent.
54 + *
55 + * @var string
56 + */
57 + const IMAGE_SIZE_ANIMATED_VIDEO = 'animated_video';
58 +
59 + /**
60 + * Image size token for the static first-frame poster of a converted GIF.
61 + *
62 + * @var string
63 + */
64 + const IMAGE_SIZE_ANIMATED_VIDEO_POSTER = 'animated_video_poster';
65 +
66 + /**
67 + * Metadata key holding the basename of the converted animated-GIF video.
68 + *
69 + * @var string
70 + */
71 + const META_KEY_ANIMATED_VIDEO = 'animated_video';
72 +
73 + /**
74 + * Metadata key holding the basename of the converted GIF's poster image.
75 + *
76 + * @var string
77 + */
78 + const META_KEY_ANIMATED_VIDEO_POSTER = 'animated_video_poster';
79 +
80 + /**
81 + * Post meta key recording the file names produced by the sideload endpoint.
82 + *
83 + * Each successful sideload appends the file name(s) it created for an
84 + * attachment under this key. The finalize endpoint reads them back to
85 + * confirm every stored sub-size was actually produced here, rather than
86 + * trusting a client-supplied name that could point at another attachment's
87 + * files. Stored as one row per value (via {@see add_post_meta()}) so concurrent
88 + * sideloads never read-modify-write a shared value.
89 + *
90 + * Matches the key used by WordPress core's implementation so the records
91 + * stay interchangeable when core also ships these endpoints.
92 + *
93 + * @var string
94 + */
95 + const META_KEY_SIDELOAD_FILE_NAME = '_wp_sideloaded_file';
96 +
97 + /**
37 98 * Registers the routes for attachments.
38 99 *
39 100 * @see register_rest_route()
40 101 */
@@ -49,18 +110,21 @@
49 110 'methods' => WP_REST_Server::CREATABLE,
50 111 'callback' => array( $this, 'sideload_item' ),
51 112 'permission_callback' => array( $this, 'sideload_item_permissions_check' ),
52 113 'args' => array(
53 - 'id' => array(
114 + 'id' => array(
54 115 'description' => __( 'Unique identifier for the attachment.', 'gutenberg' ),
55 116 'type' => 'integer',
56 117 ),
57 - 'image_size' => array(
118 + 'image_size' => array(
58 119 'description' => __( 'Image size. Can be a single size name or an array of size names to register the same file under multiple sizes.', 'gutenberg' ),
59 120 'type' => array( 'string', 'array' ),
60 121 'items' => array(
61 - 'type' => 'string',
122 + 'type' => 'string',
123 + 'minLength' => 1,
62 124 ),
125 + 'minItems' => 1,
126 + 'minLength' => 1,
63 127 'required' => true,
64 128 // A custom callback is used instead of the default `rest_validate_request_arg`
65 129 // because WordPress's `rest_is_array()` treats scalar strings as single-element
66 130 // lists (via wp_parse_list), so a oneOf with both a string and array schema
@@ -67,43 +131,23 @@
67 131 // matches a plain string twice and validation fails with "matches more than one
68 132 // of the expected formats". The callback validates the enum per-item using the
69 133 // current list of registered sizes, which reflects any sizes added after the
70 134 // route was registered (e.g. via add_image_size() in tests).
71 - 'validate_callback' => static function ( $value, $request, $param ) {
72 - $valid_sizes = array_keys( wp_get_registered_image_subsizes() );
73 - $valid_sizes[] = 'original';
74 - $valid_sizes[] = self::IMAGE_SIZE_SOURCE_ORIGINAL;
75 - $valid_sizes[] = 'scaled';
76 - $valid_sizes[] = 'full';
77 -
78 - $items = is_string( $value ) ? array( $value ) : ( is_array( $value ) ? $value : null );
79 - if ( null === $items ) {
80 - return new WP_Error(
81 - 'rest_invalid_type',
82 - /* translators: %s: Parameter name. */
83 - sprintf( __( '%s must be a string or an array of strings.', 'gutenberg' ), $param )
84 - );
135 + 'validate_callback' => static function ( $value, WP_REST_Request $request, string $param ) {
136 + /*
137 + * Providing a custom callback replaces the default schema
138 + * validation, so apply the declared schema (type, minLength,
139 + * minItems) before the enum check below.
140 + */
141 + $schema_validity = rest_validate_request_arg( $value, $request, $param );
142 + if ( is_wp_error( $schema_validity ) ) {
143 + return $schema_validity;
85 144 }
86 145
87 - foreach ( $items as $item ) {
88 - if ( ! is_string( $item ) || ! in_array( $item, $valid_sizes, true ) ) {
89 - return new WP_Error(
90 - 'rest_not_in_enum',
91 - /* translators: %s: Parameter name. */
92 - sprintf( __( '%s contains an invalid image size.', 'gutenberg' ), $param )
93 - );
94 - }
95 - }
96 -
97 - return true;
146 + return self::validate_image_size_names( $value, $param );
98 147 },
99 148 ),
100 - 'generate_sub_sizes' => array(
101 - 'description' => __( 'Whether to generate image sub sizes from the sideloaded file.', 'gutenberg' ),
102 - 'type' => 'boolean',
103 - 'default' => false,
104 - ),
105 - 'convert_format' => array(
149 + 'convert_format' => array(
106 150 'description' => __( 'Whether to convert image formats.', 'gutenberg' ),
107 151 'type' => 'boolean',
108 152 'default' => true,
109 153 ),
@@ -128,12 +172,52 @@
128 172 'description' => __( 'Unique identifier for the attachment.', 'gutenberg' ),
129 173 'type' => 'integer',
130 174 ),
131 175 'sub_sizes' => array(
132 - 'description' => __( 'Array of sub-size metadata collected from sideload responses.', 'gutenberg' ),
133 - 'type' => 'array',
134 - 'default' => array(),
135 - 'items' => array(
176 + 'description' => __( 'Array of sub-size metadata collected from sideload responses.', 'gutenberg' ),
177 + 'type' => 'array',
178 + 'default' => array(),
179 + /*
180 + * A finalize request sends one entry per sideloaded sub-size, so
181 + * the ceiling only needs to clear the number of sizes a site can
182 + * register. Bounding it keeps a request from repeating a name
183 + * across an arbitrary number of entries.
184 + */
185 + 'maxItems' => 100,
186 + /*
187 + * As on the sideload endpoint, the size names are checked in a
188 + * callback rather than an enum, so the set reflects the sizes
189 + * registered when the request runs. The callback sits on
190 + * sub_sizes because a nested property cannot carry one.
191 + */
192 + 'validate_callback' => static function ( $value, WP_REST_Request $request, string $param ) {
193 + /*
194 + * Providing a custom callback replaces the default schema
195 + * validation, so apply the declared schema first. That is what
196 + * guarantees each entry is an object carrying an image_size of
197 + * the declared type.
198 + */
199 + $schema_validity = rest_validate_request_arg( $value, $request, $param );
200 + if ( is_wp_error( $schema_validity ) ) {
201 + return $schema_validity;
202 + }
203 +
204 + foreach ( (array) $value as $index => $sub_size ) {
205 + $sub_size = (array) $sub_size;
206 +
207 + $validity = self::validate_image_size_names(
208 + $sub_size['image_size'] ?? null,
209 + sprintf( '%s[%s][image_size]', $param, $index )
210 + );
211 +
212 + if ( is_wp_error( $validity ) ) {
213 + return $validity;
214 + }
215 + }
216 +
217 + return true;
218 + },
219 + 'items' => array(
136 220 'type' => 'object',
137 221 'properties' => array(
138 222 'image_size' => array(
139 223 // Uses a multi-type schema instead of `oneOf` because WordPress's
@@ -143,10 +227,13 @@
143 227 // validation error.
144 228 'description' => __( 'Size name, or an array of size names when a single file is registered under multiple sizes with matching dimensions.', 'gutenberg' ),
145 229 'type' => array( 'string', 'array' ),
146 230 'items' => array(
147 - 'type' => 'string',
231 + 'type' => 'string',
232 + 'minLength' => 1,
148 233 ),
234 + 'minItems' => 1,
235 + 'minLength' => 1,
149 236 'required' => true,
150 237 ),
151 238 'width' => array(
152 239 'type' => 'integer',
@@ -156,9 +243,10 @@
156 243 'type' => 'integer',
157 244 'minimum' => 1,
158 245 ),
159 246 'file' => array(
160 - 'type' => 'string',
247 + 'type' => 'string',
248 + 'minLength' => 1,
161 249 ),
162 250 'mime_type' => array(
163 251 'type' => 'string',
164 252 'pattern' => '^image/.*',
@@ -167,9 +255,10 @@
167 255 'type' => 'integer',
168 256 'minimum' => 1,
169 257 ),
170 258 'original_image' => array(
171 - 'type' => 'string',
259 + 'type' => 'string',
260 + 'minLength' => 1,
172 261 ),
173 262 ),
174 263 ),
175 264 ),
@@ -176,17 +265,84 @@
176 265 ),
177 266 ),
178 267 'allow_batch' => $this->allow_batch,
179 268 'schema' => array( $this, 'get_public_item_schema' ),
180 - )
269 + ),
270 + // Override core's route so this schema wins on WordPress versions
271 + // that register their own finalize route: without the override the
272 + // earlier core registration is dispatched and this hardened schema
273 + // (minLength/minItems) never applies.
274 + true
181 275 );
182 276 }
183 277
184 278 /**
279 + * Retrieves an array of endpoint arguments from the item schema for the controller.
280 + *
281 + * @param string $method Optional. HTTP method of the request. The arguments for `CREATABLE` requests are
282 + * checked for required values and may fall-back to a given default, this is not done
283 + * on `EDITABLE` requests. Default WP_REST_Server::CREATABLE.
284 + * @return array<string, array<string, mixed>> Endpoint arguments keyed by argument name.
285 + */
286 + public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CREATABLE ) {
287 + $args = rest_get_endpoint_args_for_schema( $this->get_item_schema(), $method );
288 +
289 + if ( WP_REST_Server::CREATABLE === $method ) {
290 + $args['generate_sub_sizes'] = array(
291 + 'type' => 'boolean',
292 + 'default' => true,
293 + 'description' => __( 'Whether to generate image sub sizes.', 'gutenberg' ),
294 + );
295 + $args['convert_format'] = array(
296 + 'type' => 'boolean',
297 + 'default' => true,
298 + 'description' => __( 'Whether to convert image formats.', 'gutenberg' ),
299 + );
300 + $args['url'] = array(
301 + 'type' => 'string',
302 + 'format' => 'uri',
303 + 'description' => __( 'URL of an external image to sideload into the media library, instead of uploading a file.', 'gutenberg' ),
304 + 'sanitize_callback' => 'sanitize_url',
305 + 'validate_callback' => static function ( $url, $request, $param ) {
306 + /*
307 + * A custom validate_callback replaces the default
308 + * rest_validate_request_arg(), so re-apply it first to keep
309 + * the schema checks (string type, uri format) enforced.
310 + */
311 + $valid = rest_validate_request_arg( $url, $request, $param );
312 + if ( is_wp_error( $valid ) ) {
313 + return $valid;
314 + }
315 + /** @var non-empty-string $url */
316 +
317 + /*
318 + * Reject URLs that are not safe to request server-side. wp_http_validate_url()
319 + * enforces an HTTP(S) scheme and blocks private, local, and otherwise
320 + * disallowed hosts, guarding the sideload against SSRF.
321 + */
322 + if ( false === wp_http_validate_url( $url ) ) {
323 + return new WP_Error(
324 + 'rest_invalid_url',
325 + __( 'Invalid URL. Provide a valid, publicly reachable HTTP or HTTPS image URL.', 'gutenberg' ),
326 + array( 'status' => 400 )
327 + );
328 + }
329 +
330 + return true;
331 + },
332 + );
333 + }
334 +
335 + return $args;
336 + }
337 +
338 + /**
185 339 * Checks if a given request has access to create an attachment.
186 340 *
187 341 * Skips the server-side image type support check when the client
188 - * will handle image processing (generate_sub_sizes is false).
342 + * will handle image processing (generate_sub_sizes is false). Still
343 + * HEIC/HEIF uploads always skip the check, since the browser's canvas
344 + * fallback can decode them even when the server cannot.
189 345 *
190 346 * @param WP_REST_Request $request Full details about the request.
191 347 * @return true|WP_Error True if the request has access to create items, WP_Error object otherwise.
192 348 */
@@ -192,8 +348,30 @@
192 348 */
193 349 public function create_item_permissions_check( $request ) {
194 350 $bypass_mime_check = false === $request['generate_sub_sizes'];
195 351
352 + /*
353 + * Always allow still HEIC/HEIF uploads through even if the server's
354 + * image editor doesn't support them. The client-side canvas fallback
355 + * handles processing using the browser's native HEVC decoder.
356 + *
357 + * The '-sequence' variants (multi-frame Live Photos) are deliberately
358 + * excluded: neither the server nor the browser fallback can process
359 + * them yet, so they should fall through to the standard unsupported
360 + * mime-type error rather than be stored unprocessable.
361 + */
362 + if ( ! $bypass_mime_check ) {
363 + $still_heic_mime_types = array( 'image/heic', 'image/heif' );
364 + $files = $request->get_file_params();
365 +
366 + if (
367 + ! empty( $files['file']['type'] ) &&
368 + in_array( $files['file']['type'], $still_heic_mime_types, true )
369 + ) {
370 + $bypass_mime_check = true;
371 + }
372 + }
373 +
196 374 if ( $bypass_mime_check ) {
197 375 add_filter( 'wp_prevent_unsupported_mime_type_uploads', '__return_false' );
198 376 }
199 377
@@ -206,66 +384,186 @@
206 384 return $result;
207 385 }
208 386
209 387 /**
210 - * Retrieves an array of endpoint arguments from the item schema for the controller.
388 + * Creates a single attachment.
211 389 *
212 - * @param string $method Optional. HTTP method of the request. The arguments for `CREATABLE` requests are
213 - * checked for required values and may fall-back to a given default, this is not done
214 - * on `EDITABLE` requests. Default WP_REST_Server::CREATABLE.
215 - * @return array Endpoint arguments.
390 + * @param WP_REST_Request $request Full details about the request.
391 + * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
216 392 */
217 - public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CREATABLE ) {
218 - $args = rest_get_endpoint_args_for_schema( $this->get_item_schema(), $method );
393 + public function create_item( $request ) {
394 + if ( ! $request['generate_sub_sizes'] ) {
395 + add_filter( 'intermediate_image_sizes_advanced', '__return_empty_array', 100 );
396 + add_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 );
397 + // Disable server-side EXIF rotation so the client can handle it.
398 + // This preserves the original orientation value in the metadata.
399 + add_filter( 'wp_image_maybe_exif_rotate', '__return_false', 100 );
400 + // Disable server-side "big image" downscaling; the client supplies its
401 + // own scaled version via the sideload endpoint. Scaling here would
402 + // create a conflicting "-scaled" file and orphan the full-size upload.
403 + add_filter( 'big_image_size_threshold', '__return_false', 100 );
404 + }
219 405
220 - if ( WP_REST_Server::CREATABLE === $method ) {
221 - $args['generate_sub_sizes'] = array(
222 - 'type' => 'boolean',
223 - 'default' => true,
224 - 'description' => __( 'Whether to generate image sub sizes.', 'gutenberg' ),
225 - );
226 - $args['convert_format'] = array(
227 - 'type' => 'boolean',
228 - 'default' => true,
229 - 'description' => __( 'Whether to convert image formats.', 'gutenberg' ),
230 - );
406 + if ( false === $request['convert_format'] ) {
407 + add_filter( 'image_editor_output_format', '__return_empty_array', 100 );
231 408 }
232 409
233 - return $args;
410 + /*
411 + * When a URL is supplied instead of an uploaded file, sideload the
412 + * remote image on the server. This avoids a cross-origin browser fetch,
413 + * which fails under cross-origin isolation. The sub-size and scaling
414 + * filters applied above still govern whether derivatives are generated.
415 + */
416 + if ( ! empty( $request['url'] ) ) {
417 + $response = $this->create_item_from_url( $request );
418 + } else {
419 + $response = parent::create_item( $request );
420 + }
421 +
422 + remove_filter( 'intermediate_image_sizes_advanced', '__return_empty_array', 100 );
423 + remove_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 );
424 + remove_filter( 'wp_image_maybe_exif_rotate', '__return_false', 100 );
425 + remove_filter( 'big_image_size_threshold', '__return_false', 100 );
426 + remove_filter( 'image_editor_output_format', '__return_empty_array', 100 );
427 +
428 + // Recompute image_output_format now that __return_empty_array is removed.
429 + if ( ! is_wp_error( $response ) ) {
430 + $data = $response->get_data();
431 + if ( ! empty( $data['id'] ) && wp_attachment_is_image( $data['id'] ) ) {
432 + $mime_type = get_post_mime_type( $data['id'] );
433 + $filename = get_attached_file( $data['id'] );
434 +
435 + /** This filter is documented in wp-includes/class-wp-image-editor.php */
436 + $output_formats = apply_filters(
437 + 'image_editor_output_format', // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
438 + array( $mime_type => $mime_type ),
439 + $filename ? $filename : '',
440 + $mime_type
441 + );
442 +
443 + $output_mime = $output_formats[ $mime_type ] ?? $mime_type;
444 + $data['image_output_format'] = ( $output_mime !== $mime_type ) ? $output_mime : null;
445 +
446 + /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */
447 + $data['image_save_progressive'] = (bool) apply_filters(
448 + 'image_save_progressive', // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
449 + false,
450 + $mime_type
451 + );
452 +
453 + $response->set_data( $data );
454 + }
455 + }
456 +
457 + return $response;
234 458 }
235 459
236 460 /**
237 - * Retrieves the attachment's schema, conforming to JSON Schema.
461 + * Sideloads an external image from a URL into the media library.
238 462 *
239 - * Adds exif_orientation field to the schema.
463 + * Downloads the remote file on the server, avoiding a cross-origin browser
464 + * fetch that fails under cross-origin isolation. Whether sub-sizes are
465 + * generated is governed by the filters applied in create_item().
240 466 *
241 - * @return array Item schema data.
467 + * @param WP_REST_Request $request Full details about the request.
468 + * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
242 469 */
243 - public function get_item_schema() {
244 - $schema = parent::get_item_schema();
470 + protected function create_item_from_url( $request ) {
471 + // Sideloading downloads and stores a file, so require the upload capability.
472 + if ( ! current_user_can( 'upload_files' ) ) {
473 + return new WP_Error(
474 + 'rest_cannot_create',
475 + __( 'Sorry, you are not allowed to upload media on this site.', 'gutenberg' ),
476 + array( 'status' => rest_authorization_required_code() )
477 + );
478 + }
245 479
246 - $schema['properties']['exif_orientation'] = array(
247 - 'description' => __( 'EXIF orientation value from the original image. Values 1-8 follow the EXIF specification. A value other than 1 indicates the image needs rotation.', 'gutenberg' ),
248 - 'type' => 'integer',
249 - 'context' => array( 'edit' ),
250 - 'readonly' => true,
480 + require_once ABSPATH . 'wp-admin/includes/file.php';
481 + require_once ABSPATH . 'wp-admin/includes/media.php';
482 + require_once ABSPATH . 'wp-admin/includes/image.php';
483 +
484 + $url = $request['url'];
485 + $post_id = ! empty( $request['post'] ) ? (int) $request['post'] : 0;
486 +
487 + // Derive the filename from the URL path before downloading anything.
488 + $url_path = wp_parse_url( $url, PHP_URL_PATH );
489 + $filename = $url_path ? wp_basename( $url_path ) : '';
490 + if ( '' === $filename ) {
491 + return new WP_Error(
492 + 'rest_invalid_url',
493 + __( 'Could not determine a filename from the provided URL.', 'gutenberg' ),
494 + array( 'status' => 400 )
495 + );
496 + }
497 +
498 + /*
499 + * Only download URLs whose extension maps to an allowed image MIME type.
500 + * The sideload handler would reject other types anyway (via
501 + * wp_check_filetype_and_ext()), but checking first avoids downloading
502 + * files that can never be accepted, such as PHP scripts.
503 + */
504 + $filetype = wp_check_filetype( $filename );
505 + if ( ! $filetype['type'] || ! str_starts_with( $filetype['type'], 'image/' ) ) {
506 + return new WP_Error(
507 + 'rest_invalid_url',
508 + __( 'The provided URL does not point to a supported image file.', 'gutenberg' ),
509 + array( 'status' => 400 )
510 + );
511 + }
512 +
513 + /*
514 + * Download the remote file with WordPress's HTTP API, which validates
515 + * the host and blocks requests to private or local addresses. This is
516 + * the same primitive core's media_sideload_image() relies on.
517 + */
518 + $tmp_file = download_url( $url );
519 + if ( is_wp_error( $tmp_file ) ) {
520 + return $tmp_file;
521 + }
522 +
523 + $file_array = array(
524 + 'name' => $filename,
525 + 'tmp_name' => $tmp_file,
251 526 );
252 527
253 - $schema['properties']['image_output_format'] = array(
254 - 'description' => __( 'The output MIME type this image should be converted to, based on the image_editor_output_format filter. Null if no conversion is needed.', 'gutenberg' ),
255 - 'type' => array( 'string', 'null' ),
256 - 'context' => array( 'edit' ),
257 - 'readonly' => true,
258 - );
528 + $size_check = self::check_upload_size( $file_array );
529 + if ( is_wp_error( $size_check ) ) {
530 + if ( file_exists( $tmp_file ) ) {
531 + wp_delete_file( $tmp_file );
532 + }
533 + return $size_check;
534 + }
259 535
260 - $schema['properties']['image_save_progressive'] = array(
261 - 'description' => __( 'Whether to use progressive/interlaced encoding when saving this image.', 'gutenberg' ),
262 - 'type' => 'boolean',
263 - 'context' => array( 'edit' ),
264 - 'readonly' => true,
265 - );
536 + $attachment_id = media_handle_sideload( $file_array, $post_id );
266 537
267 - return $schema;
538 + if ( is_wp_error( $attachment_id ) ) {
539 + /*
540 + * media_handle_sideload() deletes the temp file on success; remove
541 + * it explicitly when the sideload fails.
542 + */
543 + if ( file_exists( $tmp_file ) ) {
544 + wp_delete_file( $tmp_file );
545 + }
546 + return $attachment_id;
547 + }
548 +
549 + $attachment = get_post( $attachment_id );
550 +
551 + $request->set_param( 'context', 'edit' );
552 +
553 + /*
554 + * media_handle_sideload() fires the standard insert hooks (including
555 + * wp_after_insert_post), but not the REST-specific action, so fire it
556 + * here for parity with the uploaded-file path in create_item().
557 + */
558 + /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php */
559 + do_action( 'rest_after_insert_attachment', $attachment, $request, true );
560 +
561 + $response = $this->prepare_item_for_response( $attachment, $request );
562 + $response->set_status( 201 );
563 + $response->header( 'Location', rest_url( rest_get_route_for_post( $attachment_id ) ) );
564 +
565 + return $response;
268 566 }
269 567
270 568 /**
271 569 * Prepares a single attachment output for response.
@@ -340,8 +638,62 @@
340 638 );
341 639 }
342 640 }
343 641
642 + // Add per-file, size-aware encode quality for images.
643 + if ( rest_is_field_included( 'image_quality', $fields ) ) {
644 + if ( wp_attachment_is_image( $item ) ) {
645 + $mime_type = (string) get_post_mime_type( $item );
646 + $filename = get_attached_file( $item->ID );
647 +
648 + // Resolve the output MIME type the same way core's
649 + // WP_Image_Editor::set_quality() does: quality is filtered
650 + // against the format the file will actually be saved as.
651 + /** This filter is documented in wp-includes/class-wp-image-editor.php */
652 + $output_formats = apply_filters(
653 + 'image_editor_output_format', // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
654 + array( $mime_type => $mime_type ),
655 + $filename ? $filename : '',
656 + $mime_type
657 + );
658 + $output_mime = $output_formats[ $mime_type ] ?? $mime_type;
659 +
660 + $metadata = wp_get_attachment_metadata( $item->ID, true );
661 + $full_width = max( 0, ( is_array( $metadata ) && isset( $metadata['width'] ) ) ? (int) $metadata['width'] : 0 );
662 + $full_height = max( 0, ( is_array( $metadata ) && isset( $metadata['height'] ) ) ? (int) $metadata['height'] : 0 );
663 +
664 + $full_quality = $this->get_image_encode_quality(
665 + $output_mime,
666 + array(
667 + 'width' => $full_width,
668 + 'height' => $full_height,
669 + )
670 + );
671 +
672 + $size_quality = array();
673 + foreach ( wp_get_registered_image_subsizes() as $size_name => $size_data ) {
674 + $quality = $this->get_image_encode_quality(
675 + $output_mime,
676 + array(
677 + 'width' => (int) $size_data['width'],
678 + 'height' => (int) $size_data['height'],
679 + )
680 + );
681 +
682 + // Only report sizes that diverge from the full-size value
683 + // to keep the response payload small.
684 + if ( $quality !== $full_quality ) {
685 + $size_quality[ $size_name ] = $quality;
686 + }
687 + }
688 +
689 + $data['image_quality'] = array(
690 + 'default' => $full_quality,
691 + 'sizes' => $size_quality,
692 + );
693 + }
694 + }
695 +
344 696 if (
345 697 rest_is_field_included( 'missing_image_sizes', $fields ) &&
346 698 empty( $data['missing_image_sizes'] )
347 699 ) {
@@ -393,175 +745,68 @@
393 745 return $response;
394 746 }
395 747
396 748 /**
397 - * Creates a single attachment.
749 + * Retrieves the attachment's schema, conforming to JSON Schema.
398 750 *
399 - * @param WP_REST_Request $request Full details about the request.
400 - * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
401 - */
402 - public function create_item( $request ) {
403 - if ( ! $request['generate_sub_sizes'] ) {
404 - add_filter( 'intermediate_image_sizes_advanced', '__return_empty_array', 100 );
405 - add_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 );
406 - // Disable server-side EXIF rotation so the client can handle it.
407 - // This preserves the original orientation value in the metadata.
408 - add_filter( 'wp_image_maybe_exif_rotate', '__return_false', 100 );
409 - // Disable server-side big image scaling since the client handles it.
410 - add_filter( 'big_image_size_threshold', '__return_zero', 100 );
411 - }
412 -
413 - if ( ! $request['convert_format'] ) {
414 - add_filter( 'image_editor_output_format', '__return_empty_array', 100 );
415 - }
416 -
417 - $response = parent::create_item( $request );
418 -
419 - remove_filter( 'intermediate_image_sizes_advanced', '__return_empty_array', 100 );
420 - remove_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 );
421 - remove_filter( 'wp_image_maybe_exif_rotate', '__return_false', 100 );
422 - remove_filter( 'big_image_size_threshold', '__return_zero', 100 );
423 - remove_filter( 'image_editor_output_format', '__return_empty_array', 100 );
424 -
425 - // Recompute image_output_format now that __return_empty_array is removed.
426 - if ( ! is_wp_error( $response ) ) {
427 - $data = $response->get_data();
428 - if ( ! empty( $data['id'] ) && wp_attachment_is_image( $data['id'] ) ) {
429 - $mime_type = get_post_mime_type( $data['id'] );
430 - $filename = get_attached_file( $data['id'] );
431 -
432 - /** This filter is documented in wp-includes/class-wp-image-editor.php */
433 - $output_formats = apply_filters(
434 - 'image_editor_output_format', // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
435 - array( $mime_type => $mime_type ),
436 - $filename ? $filename : '',
437 - $mime_type
438 - );
439 -
440 - $output_mime = $output_formats[ $mime_type ] ?? $mime_type;
441 - $data['image_output_format'] = ( $output_mime !== $mime_type ) ? $output_mime : null;
442 -
443 - /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */
444 - $data['image_save_progressive'] = (bool) apply_filters(
445 - 'image_save_progressive', // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
446 - false,
447 - $mime_type
448 - );
449 -
450 - $response->set_data( $data );
451 - }
452 - }
453 -
454 - return $response;
455 - }
456 -
457 - /**
458 - * Finalizes an attachment after client-side media processing.
751 + * Adds exif_orientation field to the schema.
459 752 *
460 - * Triggers the {@see 'wp_generate_attachment_metadata'} filter so that
461 - * server-side plugins can process the attachment after all client-side
462 - * operations (upload, thumbnail generation, sideloads) are complete.
463 - *
464 - * @param WP_REST_Request $request Full details about the request.
465 - * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
753 + * @return array Item schema data.
466 754 */
467 - public function finalize_item( WP_REST_Request $request ) {
468 - $attachment_id = $request['id'];
755 + public function get_item_schema() {
756 + $schema = parent::get_item_schema();
469 757
470 - $post = $this->get_post( $attachment_id );
758 + $schema['properties']['exif_orientation'] = array(
759 + 'description' => __( 'EXIF orientation value from the original image. Values 1-8 follow the EXIF specification. A value other than 1 indicates the image needs rotation.', 'gutenberg' ),
760 + 'type' => 'integer',
761 + 'context' => array( 'edit' ),
762 + 'readonly' => true,
763 + );
471 764
472 - if ( is_wp_error( $post ) ) {
473 - return $post;
474 - }
765 + $schema['properties']['image_output_format'] = array(
766 + 'description' => __( 'The output MIME type this image should be converted to, based on the image_editor_output_format filter. Null if no conversion is needed.', 'gutenberg' ),
767 + 'type' => array( 'string', 'null' ),
768 + 'context' => array( 'edit' ),
769 + 'readonly' => true,
770 + );
475 771
476 - $metadata = wp_get_attachment_metadata( $attachment_id );
772 + $schema['properties']['image_save_progressive'] = array(
773 + 'description' => __( 'Whether to use progressive/interlaced encoding when saving this image.', 'gutenberg' ),
774 + 'type' => 'boolean',
775 + 'context' => array( 'edit' ),
776 + 'readonly' => true,
777 + );
477 778
478 - if ( ! is_array( $metadata ) ) {
479 - $metadata = array();
779 + // Enumerate the registered sub-sizes so the schema documents exactly which
780 + // keys may appear under "sizes".
781 + $size_quality_properties = array();
782 + foreach ( array_keys( wp_get_registered_image_subsizes() ) as $size_name ) {
783 + $size_quality_properties[ $size_name ] = array(
784 + 'type' => 'integer',
785 + 'minimum' => 1,
786 + 'maximum' => 100,
787 + );
480 788 }
481 789
482 - // Apply all sub-size metadata collected from sideload responses.
483 - $sub_sizes = $request['sub_sizes'] ?? array();
484 -
485 - foreach ( $sub_sizes as $sub_size ) {
486 - $image_size = $sub_size['image_size'];
487 -
488 - // When multiple size names share identical dimensions the client
489 - // sends a single sub-size entry with an array of names. Register the
490 - // same file under each name. Arrays only contain regular sizes.
491 - if ( is_array( $image_size ) ) {
492 - $metadata['sizes'] = $metadata['sizes'] ?? array();
493 -
494 - foreach ( $image_size as $name ) {
495 - $metadata['sizes'][ $name ] = array(
496 - 'width' => $sub_size['width'] ?? 0,
497 - 'height' => $sub_size['height'] ?? 0,
498 - 'file' => $sub_size['file'] ?? '',
499 - 'mime-type' => $sub_size['mime_type'] ?? '',
500 - 'filesize' => $sub_size['filesize'] ?? 0,
501 - );
502 - }
503 - continue;
504 - }
505 -
506 - if ( 'original' === $image_size ) {
507 - $metadata['original_image'] = $sub_size['file'];
508 - } elseif ( self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size ) {
509 - // Source-format original: stored under its own meta key so the
510 - // scaled-sideload flow (which writes 'original_image') cannot
511 - // clobber it. 'original_image' keeps pointing at the
512 - // web-viewable JPEG derivative. Cleanup on attachment delete
513 - // is handled by a delete_attachment hook that reads this key.
514 - $metadata[ self::META_KEY_SOURCE_IMAGE ] = $sub_size['file'];
515 - } elseif ( 'scaled' === $image_size ) {
516 - if ( ! empty( $sub_size['original_image'] ) ) {
517 - $metadata['original_image'] = $sub_size['original_image'];
518 - }
519 - $metadata['width'] = $sub_size['width'] ?? 0;
520 - $metadata['height'] = $sub_size['height'] ?? 0;
521 - $metadata['filesize'] = $sub_size['filesize'] ?? 0;
522 - $metadata['file'] = $sub_size['file'] ?? '';
523 - } else {
524 - $metadata['sizes'] = $metadata['sizes'] ?? array();
525 -
526 - $metadata['sizes'][ $image_size ] = array(
527 - 'width' => $sub_size['width'] ?? 0,
528 - 'height' => $sub_size['height'] ?? 0,
529 - 'file' => $sub_size['file'] ?? '',
530 - 'mime-type' => $sub_size['mime_type'] ?? '',
531 - 'filesize' => $sub_size['filesize'] ?? 0,
532 - );
533 - }
534 - }
535 -
536 - /**
537 - * Filters the attachment metadata after client-side processing.
538 - *
539 - * This re-applies the wp_generate_attachment_metadata filter so that
540 - * server-side plugins (e.g. those adding custom image sizes or
541 - * processing metadata) can run after client-side uploads are complete.
542 - *
543 - * @param array $metadata Attachment metadata.
544 - * @param int $attachment_id Attachment ID.
545 - * @param string $context Context: 'create' or 'update'.
546 - */
547 - // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
548 - $metadata = apply_filters( 'wp_generate_attachment_metadata', $metadata, $attachment_id, 'update' );
549 -
550 - wp_update_attachment_metadata( $attachment_id, $metadata );
551 -
552 - $response_request = new WP_REST_Request(
553 - WP_REST_Server::READABLE,
554 - rest_get_route_for_post( $attachment_id )
790 + $schema['properties']['image_quality'] = array(
791 + 'description' => __( 'Encode quality (1-100) from the wp_editor_set_quality filter, resolved against the output MIME type. "default" applies to the full-size image; "sizes" lists per-registered-size overrides where the filtered value differs from "default".', 'gutenberg' ),
792 + 'type' => 'object',
793 + 'context' => array( 'edit' ),
794 + 'readonly' => true,
795 + 'properties' => array(
796 + 'default' => array(
797 + 'type' => 'integer',
798 + 'minimum' => 1,
799 + 'maximum' => 100,
800 + ),
801 + 'sizes' => array(
802 + 'type' => 'object',
803 + 'properties' => $size_quality_properties,
804 + ),
805 + ),
555 806 );
556 807
557 - $response_request['context'] = 'edit';
558 -
559 - if ( isset( $request['_fields'] ) ) {
560 - $response_request['_fields'] = $request['_fields'];
561 - }
562 -
563 - return $this->prepare_item_for_response( get_post( $attachment_id ), $response_request );
808 + return $schema;
564 809 }
565 810
566 811 /**
567 812 * Checks if a given request has access to sideload a file.
@@ -576,53 +821,94 @@
576 821 return $this->edit_media_item_permissions_check( $request );
577 822 }
578 823
579 824 /**
580 - * Filters {@see 'wp_unique_filename'} during sideloads.
825 + * Validates an image size name, or an array of names sharing a single file.
581 826 *
582 - * {@see wp_unique_filename()} will always add numeric suffix if the name looks like a sub-size to avoid conflicts.
827 + * Shared by the sideload endpoint, which names the size a file is produced
828 + * for, and the finalize endpoint, which names the size each submitted entry
829 + * is stored under. Both need the same set, and finalize accepts a payload of
830 + * its own rather than one this class produced, so leaving it unconstrained
831 + * there would let a submission write an arbitrary key into the metadata
832 + * 'sizes' array or route a file into a branch it was never produced for.
583 833 *
584 - * Adding this closure to the filter helps work around this safeguard.
585 - *
586 - * Example: when uploading myphoto.jpeg, WordPress normally creates myphoto-150x150.jpeg,
587 - * and when uploading myphoto-150x150.jpeg, it will be renamed to myphoto-150x150-1.jpeg
588 - * However, here it is desired not to add the suffix in order to maintain the same
589 - * naming convention as if the file was uploaded regularly.
590 - *
591 - * @link https://github.com/WordPress/wordpress-develop/blob/30954f7ac0840cfdad464928021d7f380940c347/src/wp-includes/functions.php#L2576-L2582
592 - *
593 - * @param string $filename Unique file name.
594 - * @param string $dir Directory path.
595 - * @param int|string $number The highest number that was used to make the file name unique
596 - * or an empty string if unused.
597 - * @param string $attachment_filename Original attachment file name.
598 - * @return string Filtered file name.
834 + * @param mixed $value The image size name, or an array of names.
835 + * @param string $param Parameter name, used in the error messages.
836 + * @return true|WP_Error True when every name is valid, WP_Error otherwise.
599 837 */
600 - private static function filter_wp_unique_filename( $filename, $dir, $number, $attachment_filename ) {
601 - if ( empty( $number ) || ! $attachment_filename ) {
602 - return $filename;
603 - }
838 + private static function validate_image_size_names( $value, string $param ) {
839 + $special_sizes = self::get_special_image_sizes();
840 + $regular_sizes = array_values(
841 + array_diff(
842 + array_merge(
843 + array_keys( wp_get_registered_image_subsizes() ),
844 + // Not a registered sub-size, but stored as an ordinary
845 + // entry in the metadata 'sizes' array (PDF thumbnails).
846 + array( 'full' )
847 + ),
848 + $special_sizes
849 + )
850 + );
604 851
605 - $ext = pathinfo( $filename, PATHINFO_EXTENSION );
606 - $name = pathinfo( $filename, PATHINFO_FILENAME );
607 - $orig_name = pathinfo( $attachment_filename, PATHINFO_FILENAME );
608 -
609 - if ( ! $ext || ! $name ) {
610 - return $filename;
852 + if ( is_string( $value ) ) {
853 + $items = array( $value );
854 + $valid_sizes = array_merge( $regular_sizes, $special_sizes );
855 + } elseif ( is_array( $value ) ) {
856 + /**
857 + * An array registers one sideloaded file under several size names,
858 + * which only makes sense for regular sub-sizes: each special size
859 + * names a single file with its own handling in
860 + * {@see self::sideload_item()} and its own metadata key in
861 + * {@see self::finalize_item()}. Rejecting them here is what lets the
862 + * array branches in both methods treat an array as regular sizes.
863 + */
864 + $items = $value;
865 + $valid_sizes = $regular_sizes;
866 + } else {
867 + return new WP_Error(
868 + 'rest_invalid_type',
869 + /* translators: %s: Parameter name. */
870 + sprintf( __( '%s must be a string or an array of strings.', 'gutenberg' ), $param )
871 + );
611 872 }
612 873
613 - $matches = array();
614 - if ( preg_match( '/(.*)(-\d+x\d+|-scaled)-' . $number . '$/', $name, $matches ) ) {
615 - $filename_without_suffix = $matches[1] . $matches[2] . ".$ext";
616 - if ( $matches[1] === $orig_name ) {
617 - return $filename_without_suffix;
874 + foreach ( $items as $item ) {
875 + if ( ! in_array( $item, $valid_sizes, true ) ) {
876 + return new WP_Error(
877 + 'rest_not_in_enum',
878 + /* translators: %s: Parameter name. */
879 + sprintf( __( '%s contains an invalid image size.', 'gutenberg' ), $param )
880 + );
618 881 }
619 882 }
620 883
621 - return $filename;
884 + return true;
622 885 }
623 886
624 887 /**
888 + * Returns the image size names which name a single file rather than a sub-size.
889 + *
890 + * Each of these is handled on its own in {@see self::sideload_item()} and stored
891 + * under its own key by {@see self::finalize_item()}, so unlike a regular
892 + * sub-size none of them may appear in an array of names sharing one file.
893 + *
894 + * @return string[] Special image size names.
895 + *
896 + * @phpstan-return non-empty-list<non-empty-string>
897 + */
898 + private static function get_special_image_sizes(): array {
899 + return array(
900 + 'original',
901 + 'scaled',
902 + // Source-format original (e.g. the HEIC kept alongside its JPEG derivative).
903 + self::IMAGE_SIZE_SOURCE_ORIGINAL,
904 + // Converted-video companions for an animated GIF (the MP4/WebM and its poster).
905 + self::IMAGE_SIZE_ANIMATED_VIDEO,
906 + self::IMAGE_SIZE_ANIMATED_VIDEO_POSTER,
907 + );
908 + }
909 +
910 + /**
625 911 * Validates that uploaded image dimensions are appropriate for the specified image size.
626 912 *
627 913 * @param int $width Uploaded image width.
628 914 * @param int $height Uploaded image height.
@@ -631,8 +917,22 @@
631 917 * @param int $attachment_id The attachment ID.
632 918 * @return true|WP_Error True if valid, WP_Error if invalid.
633 919 */
634 920 private function validate_image_dimensions( int $width, int $height, $image_size, int $attachment_id ) {
921 + // 'animated_video' companion file: video, not an image. Skip *all*
922 + // dimension checks (the caller passes (0, 0) for this case so the
923 + // positive-dimension assertion below would otherwise fire).
924 + if ( self::IMAGE_SIZE_ANIMATED_VIDEO === $image_size ) {
925 + return true;
926 + }
927 +
928 + // Source-format original companion file: no dimension constraint, and
929 + // the caller passes (0, 0) because the source format (e.g. HEIC) may
930 + // not be readable by wp_getimagesize() at all.
931 + if ( self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size ) {
932 + return true;
933 + }
934 +
635 935 // Dimensions must be positive for all sizes.
636 936 if ( $width <= 0 || $height <= 0 ) {
637 937 return new WP_Error(
638 938 'rest_upload_invalid_dimensions',
@@ -640,9 +940,10 @@
640 940 array( 'status' => 400 )
641 941 );
642 942 }
643 943
644 - // Arrays only contain regular sub-size names that share dimensions.
944 + // Arrays only contain regular sub-size names that share dimensions, which
945 + // the image_size validation enforces (ref. get_special_image_sizes()).
645 946 // Validate each one against its registered constraints.
646 947 if ( is_array( $image_size ) ) {
647 948 foreach ( $image_size as $name ) {
648 949 $result = $this->validate_image_dimensions( $width, $height, $name, $attachment_id );
@@ -652,14 +953,20 @@
652 953 }
653 954 return true;
654 955 }
655 956
656 - // Source-format original companion file: no dimension constraint.
657 - if ( self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size ) {
957 + // 'animated_video_poster' companion: a static poster image for the
958 + // converted video. It is a real image (so it has positive dimensions)
959 + // but is not a registered sub-size, so it has no dimension constraint.
960 + if ( self::IMAGE_SIZE_ANIMATED_VIDEO_POSTER === $image_size ) {
658 961 return true;
659 962 }
660 963
661 - // 'original' size: should match original attachment dimensions.
964 + // 'original' size: the full-size image that replaces the main file (see
965 + // sideload_item()/finalize_item()). The endpoint expects any EXIF
966 + // orientation to be applied to the image already, which can swap width
967 + // and height, so the dimensions must match the stored dimensions or be
968 + // their transpose.
662 969 if ( 'original' === $image_size ) {
663 970 $metadata = wp_get_attachment_metadata( $attachment_id, true );
664 971 if ( is_array( $metadata ) && isset( $metadata['width'], $metadata['height'] ) ) {
665 972 $expected_width = (int) $metadata['width'];
@@ -664,9 +971,12 @@
664 971 if ( is_array( $metadata ) && isset( $metadata['width'], $metadata['height'] ) ) {
665 972 $expected_width = (int) $metadata['width'];
666 973 $expected_height = (int) $metadata['height'];
667 974
668 - if ( $width !== $expected_width || $height !== $expected_height ) {
975 + $matches_dimensions = $width === $expected_width && $height === $expected_height;
976 + $transposes_dimensions = $width === $expected_height && $height === $expected_width;
977 +
978 + if ( ! $matches_dimensions && ! $transposes_dimensions ) {
669 979 return new WP_Error(
670 980 'rest_upload_dimension_mismatch',
671 981 sprintf(
672 982 /* translators: 1: actual width, 2: actual height, 3: expected width, 4: expected height */
@@ -738,15 +1048,15 @@
738 1048 return true;
739 1049 }
740 1050
741 1051 /**
742 - * Side-loads a media file without creating an attachment.
1052 + * Side-loads a media file without creating a new attachment.
743 1053 *
744 1054 * @param WP_REST_Request $request Full details about the request.
745 1055 * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
746 1056 */
747 1057 public function sideload_item( WP_REST_Request $request ) {
748 - $attachment_id = $request['id'];
1058 + $attachment_id = (int) $request['id'];
749 1059
750 1060 $post = $this->get_post( $attachment_id );
751 1061
752 1062 if ( is_wp_error( $post ) ) {
@@ -758,14 +1068,34 @@
758 1068 ! wp_attachment_is( 'pdf', $post )
759 1069 ) {
760 1070 return new WP_Error(
761 1071 'rest_post_invalid_id',
762 - __( 'Invalid post ID, only images and PDFs can be sideloaded.', 'gutenberg' ),
1072 + __( 'Invalid post ID. Only images and PDFs can be sideloaded.', 'gutenberg' ),
763 1073 array( 'status' => 400 )
764 1074 );
765 1075 }
766 1076
767 - if ( ! $request['convert_format'] ) {
1077 + /*
1078 + * Sideloaded files are placed in the same directory as the attachment
1079 + * they extend, because the file names produced here are later resolved
1080 + * against that directory. An attachment stored outside the uploads
1081 + * directory has no such directory to use, so there is nowhere the names
1082 + * this would produce could resolve.
1083 + */
1084 + $attached_file = get_attached_file( $attachment_id, true );
1085 + $subdir = is_string( $attached_file ) && '' !== $attached_file
1086 + ? $this->get_attachment_upload_subdir( $attached_file )
1087 + : null;
1088 +
1089 + if ( ! is_string( $attached_file ) || '' === $attached_file || null === $subdir ) {
1090 + return new WP_Error(
1091 + 'rest_sideload_attachment_not_in_uploads',
1092 + __( 'The attachment is not stored in the uploads directory, so a file cannot be sideloaded for it.', 'gutenberg' ),
1093 + array( 'status' => 403 )
1094 + );
1095 + }
1096 +
1097 + if ( false === $request['convert_format'] ) {
768 1098 // Prevent image conversion as that is done client-side.
769 1099 add_filter( 'image_editor_output_format', '__return_empty_array', 100 );
770 1100 }
771 1101
@@ -778,10 +1108,9 @@
778 1108 * See https://github.com/WordPress/wordpress-develop/blob/30954f7ac0840cfdad464928021d7f380940c347/src/wp-includes/functions.php#L2576-L2582
779 1109 * With the following filter we can work around this safeguard.
780 1110 */
781 1111
782 - $attachment_filename = get_attached_file( $attachment_id, true );
783 - $attachment_filename = $attachment_filename ? wp_basename( $attachment_filename ) : null;
1112 + $attachment_filename = wp_basename( $attached_file );
784 1113
785 1114 /**
786 1115 * @param string $filename Unique file name.
787 1116 * @param string $ext File extension. Example: ".png".
@@ -797,26 +1126,36 @@
797 1126 };
798 1127
799 1128 add_filter( 'wp_unique_filename', $filter_filename, 10, 6 );
800 1129
801 - $parent_post = get_post_parent( $attachment_id );
1130 + // Pin the upload to the attachment's own directory, rather than deriving
1131 + // it from the parent post's date as media_handle_upload() does for a
1132 + // brand new upload. See the note above where $subdir is resolved.
1133 + $filter_upload_dir = static function ( $uploads ) use ( $subdir ) {
1134 + if (
1135 + is_array( $uploads ) &&
1136 + isset( $uploads['basedir'], $uploads['baseurl'] ) &&
1137 + is_string( $uploads['basedir'] ) &&
1138 + is_string( $uploads['baseurl'] )
1139 + ) {
1140 + $uploads['subdir'] = $subdir;
1141 + $uploads['path'] = $uploads['basedir'] . $subdir;
1142 + $uploads['url'] = $uploads['baseurl'] . $subdir;
1143 + }
1144 + return $uploads;
1145 + };
802 1146
803 - $time = null;
1147 + add_filter( 'upload_dir', $filter_upload_dir, 100 );
804 1148
805 - // Matches logic in media_handle_upload().
806 - // The post date doesn't usually matter for pages, so don't backdate this upload.
807 - if ( $parent_post && 'page' !== $parent_post->post_type && substr( $parent_post->post_date, 0, 4 ) > 0 ) {
808 - $time = $parent_post->post_date;
809 - }
810 -
811 1149 if ( ! empty( $files ) ) {
812 - $file = $this->upload_from_file( $files, $headers, $time );
1150 + $file = $this->upload_from_file( $files, $headers );
813 1151 } else {
814 - $file = $this->upload_from_data( $request->get_body(), $headers, $time );
1152 + $file = $this->upload_from_data( $request->get_body(), $headers );
815 1153 }
816 1154
817 1155 remove_filter( 'wp_unique_filename', $filter_filename );
818 1156 remove_filter( 'image_editor_output_format', '__return_empty_array', 100 );
1157 + remove_filter( 'upload_dir', $filter_upload_dir, 100 );
819 1158
820 1159 if ( is_wp_error( $file ) ) {
821 1160 return $file;
822 1161 }
@@ -827,12 +1166,28 @@
827 1166 $image_size = $request['image_size'];
828 1167
829 1168 // Read dimensions once up-front. Needed both for early-error handling
830 1169 // (corrupted/unsupported files) and for populating the sub-size payload
831 - // below. Scalar 'original' is a byte-only passthrough and does not need
832 - // dimensions, but reading them here is harmless.
833 - $size = wp_getimagesize( $path );
1170 + // below. 'original' and 'scaled' both replace the main file, so their
1171 + // dimensions are written to metadata; 'original' is additionally
1172 + // validated against the stored attachment size (it must match it or be
1173 + // its transpose).
1174 + //
1175 + // 'animated_video' companions are video files (MP4/WebM); the image
1176 + // helpers can't read their dimensions and would falsely report the
1177 + // upload as "corrupted or unsupported". Source-format originals
1178 + // ('source_original', e.g. the HEIC kept next to its JPEG derivative)
1179 + // are exempt for the same reason: their dimensions are neither
1180 + // validated nor recorded, and wp_getimagesize() may not be able to
1181 + // read the source format at all on servers without HEIC/HEIF support.
1182 + // Skip the read for both cases; validate_image_dimensions() also
1183 + // short-circuits them below.
1184 + $skip_dimension_read =
1185 + self::IMAGE_SIZE_ANIMATED_VIDEO === $image_size ||
1186 + self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size;
834 1187
1188 + $size = $skip_dimension_read ? array( 0, 0 ) : wp_getimagesize( $path );
1189 +
835 1190 if ( ! $size ) {
836 1191 // Could not determine dimensions (corrupted file, unsupported format).
837 1192 wp_delete_file( $path );
838 1193 return new WP_Error(
@@ -853,9 +1208,10 @@
853 1208 // The client accumulates these and sends them all to the finalize endpoint.
854 1209 // `image_size` may be a single string or an array of names that share the
855 1210 // same dimensions and therefore reuse a single sideloaded file. Arrays
856 1211 // only carry regular sub-sizes; the special keys below ('original',
857 - // 'scaled', and the source-format original) are always scalar strings.
1212 + // 'scaled', and the source-format original) are always scalar strings,
1213 + // which the image_size validation enforces (ref. get_special_image_sizes()).
858 1214 $sub_size_data = array(
859 1215 'image_size' => $image_size,
860 1216 );
861 1217
@@ -864,10 +1220,8 @@
864 1220 $sub_size_data['height'] = $size[1];
865 1221 $sub_size_data['file'] = wp_basename( $path );
866 1222 $sub_size_data['mime_type'] = $type;
867 1223 $sub_size_data['filesize'] = wp_filesize( $path );
868 - } elseif ( 'original' === $image_size ) {
869 - $sub_size_data['file'] = wp_basename( $path );
870 1224 } elseif ( self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size ) {
871 1225 // Source-format original. finalize_item() writes the filename to
872 1226 // $metadata[ self::META_KEY_SOURCE_IMAGE ] (separate from
873 1227 // 'original_image', which the scaled-sideload flow owns). Cleanup on
@@ -873,16 +1227,43 @@
873 1227 // 'original_image', which the scaled-sideload flow owns). Cleanup on
874 1228 // attachment delete is handled by a delete_attachment hook that reads
875 1229 // this key.
876 1230 $sub_size_data['file'] = wp_basename( $path );
877 - } elseif ( 'scaled' === $image_size ) {
878 - // Record the current attached file as the original.
879 - $current_file = get_attached_file( $attachment_id, true );
880 - $sub_size_data['original_image'] = wp_basename( $current_file );
1231 + } elseif ( self::IMAGE_SIZE_ANIMATED_VIDEO === $image_size ) {
1232 + // Converted animated-GIF video companion. finalize_item()
1233 + // writes the filename to $metadata['animated_video']; the editor
1234 + // reads it to switch the block to a video, and a delete_attachment
1235 + // hook removes it. See lib/media/animated-gif-to-video.php.
1236 + $sub_size_data['file'] = wp_basename( $path );
1237 + } elseif ( self::IMAGE_SIZE_ANIMATED_VIDEO_POSTER === $image_size ) {
1238 + // Static poster for the converted video. finalize_item() writes
1239 + // the filename to $metadata['animated_video_poster']; used as the
1240 + // video block's poster and deleted with the video.
1241 + $sub_size_data['file'] = wp_basename( $path );
1242 + } elseif ( 'scaled' === $image_size || 'original' === $image_size ) {
1243 + // 'scaled' and 'original' both replace the attachment's main file
1244 + // with the supplied image and keep the file being replaced as
1245 + // `original_image`, which is the untouched upload. A 'scaled' image is
1246 + // downsized and an 'original' image has any EXIF orientation already
1247 + // applied. This is the same swap WordPress makes when it scales or
1248 + // rotates an image on upload. See core's _wp_image_meta_replace_original().
1249 + $sub_size_data['original_image'] = $attachment_filename;
881 1250
882 - // Update the attached file to point to the scaled version.
1251 + // Update the attached file to point to the supplied image.
883 1252 // This writes to _wp_attached_file meta, not _wp_attachment_metadata.
884 - update_attached_file( $attachment_id, $path );
1253 + // Guard against a failed update so a stale original is not recorded.
1254 + if (
1255 + $attached_file !== $path &&
1256 + ! update_attached_file( $attachment_id, $path )
1257 + ) {
1258 + // Clean up the uploaded file, which nothing references yet.
1259 + wp_delete_file( $path );
1260 + return new WP_Error(
1261 + 'rest_sideload_update_attached_file_failed',
1262 + __( 'Unable to update the attached file for this attachment.', 'gutenberg' ),
1263 + array( 'status' => 500 )
1264 + );
1265 + }
885 1266
886 1267 $sub_size_data['width'] = $size[0];
887 1268 $sub_size_data['height'] = $size[1];
888 1269 $sub_size_data['filesize'] = wp_filesize( $path );
@@ -894,7 +1275,544 @@
894 1275 $sub_size_data['mime_type'] = $type;
895 1276 $sub_size_data['filesize'] = wp_filesize( $path );
896 1277 }
897 1278
1279 + /*
1280 + * Record the file names produced for this attachment so finalize can
1281 + * confirm every stored sub-size was actually sideloaded here. The
1282 + * values recorded are exactly the ones handed back to the client, so
1283 + * finalize accepts a submission only when it echoes what was produced.
1284 + * add_post_meta() appends one row per value, which avoids the
1285 + * read-modify-write race that a single serialized value would create
1286 + * for concurrent sideloads (the same reason metadata is deferred to
1287 + * finalize).
1288 + */
1289 + foreach ( array( 'file', 'original_image' ) as $provenance_key ) {
1290 + if (
1291 + isset( $sub_size_data[ $provenance_key ] ) &&
1292 + is_string( $sub_size_data[ $provenance_key ] ) &&
1293 + '' !== $sub_size_data[ $provenance_key ]
1294 + ) {
1295 + add_post_meta( $attachment_id, self::META_KEY_SIDELOAD_FILE_NAME, wp_slash( $sub_size_data[ $provenance_key ] ) );
1296 + }
1297 + }
1298 +
898 1299 return rest_ensure_response( $sub_size_data );
1300 + }
1301 +
1302 + /**
1303 + * Filters wp_unique_filename during sideloads.
1304 + *
1305 + * wp_unique_filename() will always add numeric suffix if the name looks like a sub-size to avoid conflicts.
1306 + * Adding this closure to the filter helps work around this safeguard.
1307 + *
1308 + * Example: when uploading myphoto.jpeg, WordPress normally creates myphoto-150x150.jpeg,
1309 + * and when uploading myphoto-150x150.jpeg, it will be renamed to myphoto-150x150-1.jpeg
1310 + * However, here it is desired not to add the suffix in order to maintain the same
1311 + * naming convention as if the file was uploaded regularly.
1312 + *
1313 + * The suffix is only dropped when no file of that name already exists in $dir,
1314 + * so this never returns a name that would overwrite one. The unsuffixed name
1315 + * must also derive from the attachment's own file name, and
1316 + * {@see self::sideload_item()} pins the upload to the attachment's own
1317 + * directory, so any name returned here belongs to the attachment being
1318 + * extended.
1319 + *
1320 + * @link https://github.com/WordPress/wordpress-develop/blob/30954f7ac0840cfdad464928021d7f380940c347/src/wp-includes/functions.php#L2576-L2582
1321 + *
1322 + * @param string $filename Unique file name.
1323 + * @param string $dir Directory path.
1324 + * @param int|string $number The highest number that was used to make the file name unique
1325 + * or an empty string if unused.
1326 + * @param string|null $attachment_filename Original attachment file name.
1327 + * @return string Filtered file name.
1328 + */
1329 + private static function filter_wp_unique_filename( $filename, $dir, $number, $attachment_filename ) {
1330 + if ( ! is_int( $number ) || ! $attachment_filename ) {
1331 + return $filename;
1332 + }
1333 +
1334 + $ext = pathinfo( $filename, PATHINFO_EXTENSION );
1335 + $name = pathinfo( $filename, PATHINFO_FILENAME );
1336 + $orig_name = pathinfo( $attachment_filename, PATHINFO_FILENAME );
1337 +
1338 + if ( ! $ext || ! $name ) {
1339 + return $filename;
1340 + }
1341 +
1342 + $matches = array();
1343 + if ( preg_match( '/(.*)-(\d+x\d+|scaled)-' . $number . '$/', $name, $matches ) ) {
1344 + $filename_without_suffix = $matches[1] . '-' . $matches[2] . ".$ext";
1345 + if ( $matches[1] === $orig_name && ! file_exists( "$dir/$filename_without_suffix" ) ) {
1346 + return $filename_without_suffix;
1347 + }
1348 + }
1349 +
1350 + return $filename;
1351 + }
1352 +
1353 + /**
1354 + * Validates the `sub_sizes` file names against what this attachment produced.
1355 + *
1356 + * The {@see self::finalize_item()} method stores the client-supplied `file`
1357 + * and `original_image` values in the attachment metadata, where they are
1358 + * later resolved within the attachment's upload directory and read or deleted
1359 + * (for example by {@see wp_get_original_image_path()}, {@see wp_getimagesize()},
1360 + * and {@see wp_delete_attachment_files()}).
1361 + *
1362 + * Every file the sideload endpoint creates is recorded under
1363 + * {@see self::META_KEY_SIDELOAD_FILE_NAME} as it is produced, using
1364 + * server-generated names. finalize accepts a `file` or `original_image`
1365 + * value only when it matches one of those recorded names (or the
1366 + * attachment's own attached file, which it definitionally owns).
1367 + *
1368 + * @param int $attachment_id The attachment being finalized.
1369 + * @param array $sub_sizes Sub-size metadata collected from sideloads.
1370 + * @return true|WP_Error True if every file name was produced here, WP_Error otherwise.
1371 + *
1372 + * @phpstan-param list<Image_Sub_Size> $sub_sizes
1373 + */
1374 + protected function validate_sub_size_provenance( int $attachment_id, array $sub_sizes ) {
1375 + $allowed = $this->get_sideloaded_file_names( $attachment_id );
1376 +
1377 + foreach ( $sub_sizes as $sub_size ) {
1378 + foreach ( array( 'file', 'original_image' ) as $key ) {
1379 + /*
1380 + * Every value that was sent is checked, no matter how unlikely
1381 + * a name it looks. A loose emptiness test would wave through
1382 + * '0', which is a valid one-character name as far as the schema
1383 + * is concerned and is stored like any other. A value the schema
1384 + * types as a string but which arrives as something else is
1385 + * rejected rather than skipped, so a subclass which widens the
1386 + * schema cannot pass an unchecked value on to the metadata.
1387 + */
1388 + if ( ! isset( $sub_size[ $key ] ) ) {
1389 + continue;
1390 + }
1391 +
1392 + if ( ! is_string( $sub_size[ $key ] ) || ! in_array( $sub_size[ $key ], $allowed, true ) ) {
1393 + return new WP_Error(
1394 + 'rest_invalid_sub_size_file',
1395 + __( 'Invalid sub-size file name. File names must have been produced by a prior sideload for this attachment.', 'gutenberg' ),
1396 + array( 'status' => 400 )
1397 + );
1398 + }
1399 + }
1400 + }
1401 +
1402 + return true;
1403 + }
1404 +
1405 + /**
1406 + * Returns the file names which a finalize request may store for an attachment.
1407 + *
1408 + * The set is the file names the sideload endpoint recorded as it produced
1409 + * them (ref. {@see self::META_KEY_SIDELOAD_FILE_NAME}), plus the attachment's own
1410 + * attached file - accepted in both its uploads-relative and basename form so
1411 + * a scaled main-file pointer validates regardless of which the client
1412 + * echoes - plus the names already stored in the attachment's own metadata.
1413 + *
1414 + * @param int $attachment_id The attachment being finalized.
1415 + * @param bool $include_provenance Whether to include the sideload provenance rows.
1416 + * Pass false to get only the names recoverable from
1417 + * the attached file and stored metadata, e.g. to decide
1418 + * whether a provenance row is still needed. Default true.
1419 + * @return string[] File names that may appear in the finalize submission.
1420 + *
1421 + * @phpstan-return list<string>
1422 + */
1423 + protected function get_sideloaded_file_names( int $attachment_id, bool $include_provenance = true ): array {
1424 + $allowed = array();
1425 +
1426 + if ( $include_provenance ) {
1427 + foreach ( (array) get_post_meta( $attachment_id, self::META_KEY_SIDELOAD_FILE_NAME ) as $name ) {
1428 + if ( is_string( $name ) && '' !== $name ) {
1429 + $allowed[] = $name;
1430 + }
1431 + }
1432 + }
1433 +
1434 + $attached_file = get_post_meta( $attachment_id, '_wp_attached_file', true );
1435 + if ( is_string( $attached_file ) && strlen( $attached_file ) > 0 ) {
1436 + $allowed[] = $attached_file;
1437 + $allowed[] = wp_basename( $attached_file );
1438 + }
1439 +
1440 + /*
1441 + * Names already stored in this attachment's metadata passed this same
1442 + * check when they were written, so accepting them again introduces
1443 + * nothing new.
1444 + */
1445 + $metadata = wp_get_attachment_metadata( $attachment_id, true );
1446 + if ( is_array( $metadata ) ) {
1447 + $stored = array(
1448 + $metadata['file'] ?? null,
1449 + $metadata['original_image'] ?? null,
1450 + $metadata[ self::META_KEY_SOURCE_IMAGE ] ?? null,
1451 + $metadata['animated_video'] ?? null,
1452 + $metadata['animated_video_poster'] ?? null,
1453 + );
1454 +
1455 + if ( ! empty( $metadata['sizes'] ) && is_array( $metadata['sizes'] ) ) {
1456 + foreach ( $metadata['sizes'] as $size ) {
1457 + $stored[] = is_array( $size ) ? ( $size['file'] ?? null ) : null;
1458 + }
1459 + }
1460 +
1461 + foreach ( $stored as $name ) {
1462 + if ( is_string( $name ) && '' !== $name ) {
1463 + $allowed[] = $name;
1464 + $allowed[] = wp_basename( $name );
1465 + }
1466 + }
1467 + }
1468 +
1469 + return array_values( array_unique( $allowed ) );
1470 + }
1471 +
1472 + /**
1473 + * Returns the uploads subdirectory an attachment is stored in.
1474 + *
1475 + * Used to place a sideloaded file alongside the attachment it extends. The
1476 + * result is concatenated into a filesystem path by the caller, so it is
1477 + * returned only when the attachment resolves inside the uploads directory
1478 + * and the stored path is well formed.
1479 + *
1480 + * @param string $attached_file Absolute path to the attached file.
1481 + * @return string|null Subdirectory beginning with a slash, an empty string when the
1482 + * attachment sits in the base directory, or null when the
1483 + * attachment is not inside the uploads directory.
1484 + *
1485 + * @phpstan-param non-empty-string $attached_file
1486 + */
1487 + protected function get_attachment_upload_subdir( string $attached_file ): ?string {
1488 + $uploads = wp_get_upload_dir();
1489 + if ( empty( $uploads['basedir'] ) ) {
1490 + return null;
1491 + }
1492 +
1493 + $basedir = untrailingslashit( wp_normalize_path( $uploads['basedir'] ) );
1494 + $file_dir = wp_normalize_path( dirname( $attached_file ) );
1495 +
1496 + /*
1497 + * The attachment's directory must be the uploads base directory itself
1498 + * or a directory inside it. The trailing slash in the prefix comparison
1499 + * keeps a sibling directory that merely shares the prefix (for example
1500 + * 'uploads-elsewhere' next to 'uploads') from matching.
1501 + */
1502 + if ( $file_dir !== $basedir && ! str_starts_with( $file_dir, trailingslashit( $basedir ) ) ) {
1503 + return null;
1504 + }
1505 +
1506 + $subdir = (string) substr( $file_dir, strlen( $basedir ) );
1507 +
1508 + // A prefix match alone does not rule out a path that climbs back out.
1509 + if ( in_array( '..', explode( '/', $subdir ), true ) ) {
1510 + return null;
1511 + }
1512 +
1513 + return $subdir;
1514 + }
1515 +
1516 + /**
1517 + * Finalizes an attachment after client-side media processing.
1518 + *
1519 + * Applies the sub-size metadata collected from sideload responses in a
1520 + * single metadata update, then triggers the 'wp_generate_attachment_metadata'
1521 + * filter so that server-side plugins can process the attachment after all
1522 + * client-side operations (upload, thumbnail generation, sideloads) are
1523 + * complete.
1524 + *
1525 + * @param WP_REST_Request $request Full details about the request.
1526 + * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
1527 + */
1528 + public function finalize_item( WP_REST_Request $request ) {
1529 + $attachment_id = (int) $request['id'];
1530 +
1531 + $post = $this->get_post( $attachment_id );
1532 + if ( is_wp_error( $post ) ) {
1533 + return $post;
1534 + }
1535 +
1536 + /**
1537 + * Sub-size metadata collected from sideload responses. Confirm every
1538 + * file name was produced by a prior sideload for this attachment before
1539 + * storing it, so a client cannot make finalize record (and later read or
1540 + * delete) another attachment's files.
1541 + *
1542 + * @var list<Image_Sub_Size> $sub_sizes
1543 + */
1544 + $sub_sizes = $request['sub_sizes'] ?? array();
1545 + $provenance = $this->validate_sub_size_provenance( $attachment_id, $sub_sizes );
1546 + if ( is_wp_error( $provenance ) ) {
1547 + return $provenance;
1548 + }
1549 +
1550 + $metadata = wp_get_attachment_metadata( $attachment_id );
1551 + if ( ! is_array( $metadata ) ) {
1552 + $metadata = array();
1553 + }
1554 +
1555 + // Apply all sub-size metadata collected from sideload responses.
1556 + foreach ( $sub_sizes as $sub_size ) {
1557 + $image_size = $sub_size['image_size'];
1558 +
1559 + // When multiple size names share identical dimensions the client
1560 + // sends a single sub-size entry with an array of names. Register the
1561 + // same file under each name.
1562 + if ( is_array( $image_size ) ) {
1563 + /*
1564 + * Arrays carry regular sizes only, as the sideload endpoint
1565 + * enforces. Each special size names a single file handled by one
1566 + * of the branches below, so grouping one under a shared file
1567 + * would write it to the wrong place; reject rather than guess.
1568 + */
1569 + if ( array_intersect( $image_size, self::get_special_image_sizes() ) ) {
1570 + return new WP_Error(
1571 + 'rest_invalid_sub_size_name',
1572 + __( 'A grouped sub-size entry may only name regular image sizes.', 'gutenberg' ),
1573 + array( 'status' => 400 )
1574 + );
1575 + }
1576 +
1577 + // As below: `file` is not required by the schema, and a size
1578 + // entry that names no file is not worth recording.
1579 + if ( empty( $sub_size['file'] ) ) {
1580 + continue;
1581 + }
1582 +
1583 + $metadata['sizes'] = $metadata['sizes'] ?? array();
1584 +
1585 + foreach ( $image_size as $name ) {
1586 + $metadata['sizes'][ $name ] = array(
1587 + 'width' => $sub_size['width'] ?? 0,
1588 + 'height' => $sub_size['height'] ?? 0,
1589 + 'file' => $sub_size['file'],
1590 + 'mime-type' => $sub_size['mime_type'] ?? '',
1591 + 'filesize' => $sub_size['filesize'] ?? 0,
1592 + );
1593 + }
1594 + continue;
1595 + }
1596 +
1597 + if ( 'original' === $image_size || 'scaled' === $image_size ) {
1598 + // Skip malformed entries so a bad payload cannot blank out the
1599 + // main file metadata.
1600 + if ( empty( $sub_size['file'] ) ) {
1601 + continue;
1602 + }
1603 +
1604 + /*
1605 + * Record the supplied full-size image (from sideload_item()) as
1606 + * the main file, keeping the current attached file as
1607 + * `original_image`. A 'scaled' image is downsized and an
1608 + * 'original' image is rotated; both have any EXIF orientation
1609 + * already applied by the client.
1610 + */
1611 + if ( ! empty( $sub_size['original_image'] ) ) {
1612 + $metadata['original_image'] = $sub_size['original_image'];
1613 + }
1614 + $metadata['width'] = $sub_size['width'] ?? 0;
1615 + $metadata['height'] = $sub_size['height'] ?? 0;
1616 + $metadata['filesize'] = $sub_size['filesize'] ?? 0;
1617 + $metadata['file'] = $sub_size['file'];
1618 +
1619 + /*
1620 + * The supplied image has its orientation applied already, so
1621 + * reset the stored value (from the upload) to 1, as
1622 + * wp_create_image_subsizes() does for both its scale and rotate
1623 + * paths. Otherwise exif_orientation would still report the
1624 + * pre-rotation value and the client would rotate the image
1625 + * again on a re-fetch.
1626 + */
1627 + if ( ! empty( $metadata['image_meta']['orientation'] ) ) {
1628 + $metadata['image_meta']['orientation'] = 1;
1629 + }
1630 + } elseif ( self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size ) {
1631 + // As above: `file` is not required by the schema, and each of
1632 + // these sizes is nothing but the file it names.
1633 + if ( empty( $sub_size['file'] ) ) {
1634 + continue;
1635 + }
1636 +
1637 + /*
1638 + * Source-format original: stored under its own meta key so the
1639 + * scaled-sideload flow (which writes 'original_image') cannot
1640 + * clobber it. 'original_image' keeps pointing at the
1641 + * web-viewable JPEG derivative. Cleanup on attachment delete
1642 + * is handled by a delete_attachment hook that reads this key.
1643 + */
1644 + $metadata[ self::META_KEY_SOURCE_IMAGE ] = $sub_size['file'];
1645 + } elseif ( self::IMAGE_SIZE_ANIMATED_VIDEO === $image_size ) {
1646 + if ( empty( $sub_size['file'] ) ) {
1647 + continue;
1648 + }
1649 +
1650 + /*
1651 + * Converted-video companion of an animated GIF. Stored under its
1652 + * own meta key; 'original_image' keeps pointing at the GIF. The
1653 + * editor reads this key to switch the block to a video; companion
1654 + * cleanup lives in lib/media/animated-gif-to-video.php.
1655 + */
1656 + $metadata[ self::META_KEY_ANIMATED_VIDEO ] = $sub_size['file'];
1657 + } elseif ( self::IMAGE_SIZE_ANIMATED_VIDEO_POSTER === $image_size ) {
1658 + if ( empty( $sub_size['file'] ) ) {
1659 + continue;
1660 + }
1661 +
1662 + /*
1663 + * Static first-frame poster for the converted video. Used as the
1664 + * video block's poster and deleted alongside the video. See
1665 + * lib/media/animated-gif-to-video.php.
1666 + */
1667 + $metadata[ self::META_KEY_ANIMATED_VIDEO_POSTER ] = $sub_size['file'];
1668 + } else {
1669 + if ( empty( $sub_size['file'] ) ) {
1670 + continue;
1671 + }
1672 +
1673 + $metadata['sizes'] = $metadata['sizes'] ?? array();
1674 +
1675 + $metadata['sizes'][ $image_size ] = array(
1676 + 'width' => $sub_size['width'] ?? 0,
1677 + 'height' => $sub_size['height'] ?? 0,
1678 + 'file' => $sub_size['file'],
1679 + 'mime-type' => $sub_size['mime_type'] ?? '',
1680 + 'filesize' => $sub_size['filesize'] ?? 0,
1681 + );
1682 + }
1683 + }
1684 +
1685 + /** This filter is documented in wp-admin/includes/image.php */
1686 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
1687 + $metadata = apply_filters( 'wp_generate_attachment_metadata', $metadata, $attachment_id, 'update' );
1688 +
1689 + wp_update_attachment_metadata( $attachment_id, $metadata );
1690 +
1691 + /*
1692 + * Drop only the provenance rows this request consumed, now that the
1693 + * names are recorded in the metadata itself. A row is dropped only once
1694 + * its name is recoverable from the stored metadata, so a name the
1695 + * 'wp_generate_attachment_metadata' filter removed - or that a failed
1696 + * update never persisted - keeps its row and the retried request the
1697 + * endpoint documents as idempotent still validates. Rows for sideloads
1698 + * that have not been finalized yet survive for a later call, and passing
1699 + * the value makes the delete a no-op when the row is already gone, so a
1700 + * retried request cleans up without error. Any rows left behind by an
1701 + * abandoned upload are removed with the attachment itself.
1702 + *
1703 + * Retrying is idempotent for the request as it was sent. A name is only
1704 + * unavailable to a retry once a later finalize has overwritten the same
1705 + * size with a newly sideloaded file, which drops the earlier name from
1706 + * the metadata the retry recovers it from.
1707 + *
1708 + * The names are collected before deleting so a request which repeats
1709 + * the same name across many sub-sizes still issues one query per
1710 + * distinct name.
1711 + */
1712 + $recoverable = $this->get_sideloaded_file_names( $attachment_id, false );
1713 + $consumed = array();
1714 + foreach ( $sub_sizes as $sub_size ) {
1715 + foreach ( array( 'file', 'original_image' ) as $key ) {
1716 + // Matches the set validate_sub_size_provenance() checked, so
1717 + // every name a request was allowed to store is also cleaned up.
1718 + if (
1719 + isset( $sub_size[ $key ] ) &&
1720 + is_string( $sub_size[ $key ] ) &&
1721 + in_array( $sub_size[ $key ], $recoverable, true )
1722 + ) {
1723 + $consumed[] = $sub_size[ $key ];
1724 + }
1725 + }
1726 + }
1727 +
1728 + foreach ( array_unique( $consumed ) as $file_name ) {
1729 + delete_post_meta( $attachment_id, self::META_KEY_SIDELOAD_FILE_NAME, wp_slash( $file_name ) );
1730 + }
1731 +
1732 + $response_request = new WP_REST_Request(
1733 + WP_REST_Server::READABLE,
1734 + rest_get_route_for_post( $attachment_id )
1735 + );
1736 +
1737 + $response_request['context'] = 'edit';
1738 +
1739 + if ( isset( $request['_fields'] ) ) {
1740 + $response_request['_fields'] = $request['_fields'];
1741 + }
1742 +
1743 + /*
1744 + * Re-read the post. The 'wp_generate_attachment_metadata' filter above
1745 + * runs long after $post was fetched, and a callback that rewrites the
1746 + * post row - an optimizer changing post_mime_type once it has
1747 + * converted the file, say - would otherwise be missing from this
1748 + * response. The editor stores the response as its copy of the record
1749 + * rather than reading the attachment again, so a stale row here is
1750 + * what it keeps. A callback that deleted the attachment instead leaves
1751 + * nothing to respond with, so that is reported as the error it is.
1752 + */
1753 + $post = $this->get_post( $attachment_id );
1754 + if ( is_wp_error( $post ) ) {
1755 + return $post;
1756 + }
1757 +
1758 + return $this->prepare_item_for_response( $post, $response_request );
1759 + }
1760 +
1761 + /**
1762 + * Resolves the encode quality WordPress would use for an image.
1763 + *
1764 + * Prefers the core wp_get_image_encode_quality() helper when available, and
1765 + * otherwise mirrors WP_Image_Editor::set_quality() inline for WordPress
1766 + * versions that predate it: per-format default, the wp_editor_set_quality
1767 + * filter, the jpeg_quality filter for JPEG output, then resets non-numeric
1768 + * or out-of-range values to the default and squashes 0 to 1.
1769 + *
1770 + * wp_get_image_encode_quality() is proposed for WordPress core in
1771 + * https://github.com/WordPress/wordpress-develop/pull/11856; until it lands
1772 + * the function_exists() guard falls back to the inline implementation below.
1773 + *
1774 + * @param non-empty-string $mime_type The output image MIME type, e.g. 'image/jpeg'.
1775 + * @param array{ width?: non-negative-int, height?: non-negative-int } $size Dimensions ('width', 'height') for the wp_editor_set_quality filter.
1776 + * @return int<1, 100> Encode quality between 1 and 100.
1777 + */
1778 + private function get_image_encode_quality( string $mime_type, array $size = array() ): int {
1779 + if ( function_exists( 'wp_get_image_encode_quality' ) ) {
1780 + return wp_get_image_encode_quality( $mime_type, $size );
1781 + }
1782 +
1783 + // Mirror WP_Image_Editor::get_default_quality(): WebP defaults to 86,
1784 + // everything else to 82.
1785 + $default_quality = ( 'image/webp' === $mime_type ) ? 86 : 82;
1786 +
1787 + /** This filter is documented in wp-includes/class-wp-image-editor.php */
1788 + $quality = apply_filters(
1789 + 'wp_editor_set_quality', // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
1790 + $default_quality,
1791 + $mime_type,
1792 + $size
1793 + );
1794 +
1795 + if ( 'image/jpeg' === $mime_type ) {
1796 + /** This filter is documented in wp-includes/class-wp-image-editor.php */
1797 + $quality = apply_filters( 'jpeg_quality', $quality, 'image_resize' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
1798 + }
1799 +
1800 + if ( ! is_numeric( $quality ) ) {
1801 + $quality = $default_quality;
1802 + } else {
1803 + $quality = (int) $quality;
1804 + }
1805 +
1806 + // Reset out-of-range values to the default, matching WP_Image_Editor::set_quality().
1807 + if ( $quality < 0 || $quality > 100 ) {
1808 + $quality = $default_quality;
1809 + }
1810 +
1811 + // Allow 0, but squash to 1, matching WP_Image_Editor::set_quality().
1812 + if ( 0 === $quality ) {
1813 + $quality = 1;
1814 + }
1815 +
1816 + return $quality;
899 1817 }
900 1818 }