PluginProbe
Gutenberg / trunk
Gutenberg vtrunk
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 +1251 -311 23.3.2 → trunk View file →
@@ -9,11 +9,93 @@
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 /**
27 + * Image size token for the source-format original preserved alongside a
28 + * client-generated derivative (e.g. the HEIC file kept next to its JPEG).
29 + *
30 + * Used both in the `/sideload` route schema and when dispatching the
31 + * sideloaded file to its metadata key, so the two never drift apart.
32 + *
33 + * @var string
34 + */
35 + const IMAGE_SIZE_SOURCE_ORIGINAL = 'source_original';
36 +
37 + /**
38 + * Metadata key holding the basename of the source-format original.
39 + *
40 + * Deliberately specific so it never collides with the generic `original`
41 + * or `original_image` keys other flows write to.
42 + *
43 + * @var string
44 + */
45 + const META_KEY_SOURCE_IMAGE = 'source_image';
46 +
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 + /**
16 98 * Registers the routes for attachments.
17 99 *
18 100 * @see register_rest_route()
19 101 */
@@ -28,18 +110,21 @@
28 110 'methods' => WP_REST_Server::CREATABLE,
29 111 'callback' => array( $this, 'sideload_item' ),
30 112 'permission_callback' => array( $this, 'sideload_item_permissions_check' ),
31 113 'args' => array(
32 - 'id' => array(
114 + 'id' => array(
33 115 'description' => __( 'Unique identifier for the attachment.', 'gutenberg' ),
34 116 'type' => 'integer',
35 117 ),
36 - 'image_size' => array(
118 + 'image_size' => array(
37 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' ),
38 120 'type' => array( 'string', 'array' ),
39 121 'items' => array(
40 - 'type' => 'string',
122 + 'type' => 'string',
123 + 'minLength' => 1,
41 124 ),
125 + 'minItems' => 1,
126 + 'minLength' => 1,
42 127 'required' => true,
43 128 // A custom callback is used instead of the default `rest_validate_request_arg`
44 129 // because WordPress's `rest_is_array()` treats scalar strings as single-element
45 130 // lists (via wp_parse_list), so a oneOf with both a string and array schema
@@ -46,43 +131,23 @@
46 131 // matches a plain string twice and validation fails with "matches more than one
47 132 // of the expected formats". The callback validates the enum per-item using the
48 133 // current list of registered sizes, which reflects any sizes added after the
49 134 // route was registered (e.g. via add_image_size() in tests).
50 - 'validate_callback' => static function ( $value, $request, $param ) {
51 - $valid_sizes = array_keys( wp_get_registered_image_subsizes() );
52 - $valid_sizes[] = 'original';
53 - $valid_sizes[] = 'original-heic';
54 - $valid_sizes[] = 'scaled';
55 - $valid_sizes[] = 'full';
56 -
57 - $items = is_string( $value ) ? array( $value ) : ( is_array( $value ) ? $value : null );
58 - if ( null === $items ) {
59 - return new WP_Error(
60 - 'rest_invalid_type',
61 - /* translators: %s: Parameter name. */
62 - sprintf( __( '%s must be a string or an array of strings.', 'gutenberg' ), $param )
63 - );
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;
64 144 }
65 145
66 - foreach ( $items as $item ) {
67 - if ( ! is_string( $item ) || ! in_array( $item, $valid_sizes, true ) ) {
68 - return new WP_Error(
69 - 'rest_not_in_enum',
70 - /* translators: %s: Parameter name. */
71 - sprintf( __( '%s contains an invalid image size.', 'gutenberg' ), $param )
72 - );
73 - }
74 - }
75 -
76 - return true;
146 + return self::validate_image_size_names( $value, $param );
77 147 },
78 148 ),
79 - 'generate_sub_sizes' => array(
80 - 'description' => __( 'Whether to generate image sub sizes from the sideloaded file.', 'gutenberg' ),
81 - 'type' => 'boolean',
82 - 'default' => false,
83 - ),
84 - 'convert_format' => array(
149 + 'convert_format' => array(
85 150 'description' => __( 'Whether to convert image formats.', 'gutenberg' ),
86 151 'type' => 'boolean',
87 152 'default' => true,
88 153 ),
@@ -107,12 +172,52 @@
107 172 'description' => __( 'Unique identifier for the attachment.', 'gutenberg' ),
108 173 'type' => 'integer',
109 174 ),
110 175 'sub_sizes' => array(
111 - 'description' => __( 'Array of sub-size metadata collected from sideload responses.', 'gutenberg' ),
112 - 'type' => 'array',
113 - 'default' => array(),
114 - '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(
115 220 'type' => 'object',
116 221 'properties' => array(
117 222 'image_size' => array(
118 223 // Uses a multi-type schema instead of `oneOf` because WordPress's
@@ -122,10 +227,13 @@
122 227 // validation error.
123 228 'description' => __( 'Size name, or an array of size names when a single file is registered under multiple sizes with matching dimensions.', 'gutenberg' ),
124 229 'type' => array( 'string', 'array' ),
125 230 'items' => array(
126 - 'type' => 'string',
231 + 'type' => 'string',
232 + 'minLength' => 1,
127 233 ),
234 + 'minItems' => 1,
235 + 'minLength' => 1,
128 236 'required' => true,
129 237 ),
130 238 'width' => array(
131 239 'type' => 'integer',
@@ -135,9 +243,10 @@
135 243 'type' => 'integer',
136 244 'minimum' => 1,
137 245 ),
138 246 'file' => array(
139 - 'type' => 'string',
247 + 'type' => 'string',
248 + 'minLength' => 1,
140 249 ),
141 250 'mime_type' => array(
142 251 'type' => 'string',
143 252 'pattern' => '^image/.*',
@@ -146,9 +255,10 @@
146 255 'type' => 'integer',
147 256 'minimum' => 1,
148 257 ),
149 258 'original_image' => array(
150 - 'type' => 'string',
259 + 'type' => 'string',
260 + 'minLength' => 1,
151 261 ),
152 262 ),
153 263 ),
154 264 ),
@@ -155,17 +265,84 @@
155 265 ),
156 266 ),
157 267 'allow_batch' => $this->allow_batch,
158 268 'schema' => array( $this, 'get_public_item_schema' ),
159 - )
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
160 275 );
161 276 }
162 277
163 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 + /**
164 339 * Checks if a given request has access to create an attachment.
165 340 *
166 341 * Skips the server-side image type support check when the client
167 - * 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.
168 345 *
169 346 * @param WP_REST_Request $request Full details about the request.
170 347 * @return true|WP_Error True if the request has access to create items, WP_Error object otherwise.
171 348 */
@@ -171,8 +348,30 @@
171 348 */
172 349 public function create_item_permissions_check( $request ) {
173 350 $bypass_mime_check = false === $request['generate_sub_sizes'];
174 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 +
175 374 if ( $bypass_mime_check ) {
176 375 add_filter( 'wp_prevent_unsupported_mime_type_uploads', '__return_false' );
177 376 }
178 377
@@ -185,66 +384,186 @@
185 384 return $result;
186 385 }
187 386
188 387 /**
189 - * Retrieves an array of endpoint arguments from the item schema for the controller.
388 + * Creates a single attachment.
190 389 *
191 - * @param string $method Optional. HTTP method of the request. The arguments for `CREATABLE` requests are
192 - * checked for required values and may fall-back to a given default, this is not done
193 - * on `EDITABLE` requests. Default WP_REST_Server::CREATABLE.
194 - * @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.
195 392 */
196 - public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CREATABLE ) {
197 - $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 + }
198 405
199 - if ( WP_REST_Server::CREATABLE === $method ) {
200 - $args['generate_sub_sizes'] = array(
201 - 'type' => 'boolean',
202 - 'default' => true,
203 - 'description' => __( 'Whether to generate image sub sizes.', 'gutenberg' ),
204 - );
205 - $args['convert_format'] = array(
206 - 'type' => 'boolean',
207 - 'default' => true,
208 - 'description' => __( 'Whether to convert image formats.', 'gutenberg' ),
209 - );
406 + if ( false === $request['convert_format'] ) {
407 + add_filter( 'image_editor_output_format', '__return_empty_array', 100 );
210 408 }
211 409
212 - 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;
213 458 }
214 459
215 460 /**
216 - * Retrieves the attachment's schema, conforming to JSON Schema.
461 + * Sideloads an external image from a URL into the media library.
217 462 *
218 - * 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().
219 466 *
220 - * @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.
221 469 */
222 - public function get_item_schema() {
223 - $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 + }
224 479
225 - $schema['properties']['exif_orientation'] = array(
226 - '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' ),
227 - 'type' => 'integer',
228 - 'context' => array( 'edit' ),
229 - '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,
230 526 );
231 527
232 - $schema['properties']['image_output_format'] = array(
233 - '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' ),
234 - 'type' => array( 'string', 'null' ),
235 - 'context' => array( 'edit' ),
236 - 'readonly' => true,
237 - );
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 + }
238 535
239 - $schema['properties']['image_save_progressive'] = array(
240 - 'description' => __( 'Whether to use progressive/interlaced encoding when saving this image.', 'gutenberg' ),
241 - 'type' => 'boolean',
242 - 'context' => array( 'edit' ),
243 - 'readonly' => true,
244 - );
536 + $attachment_id = media_handle_sideload( $file_array, $post_id );
245 537
246 - 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;
247 566 }
248 567
249 568 /**
250 569 * Prepares a single attachment output for response.
@@ -319,8 +638,62 @@
319 638 );
320 639 }
321 640 }
322 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 +
323 696 if (
324 697 rest_is_field_included( 'missing_image_sizes', $fields ) &&
325 698 empty( $data['missing_image_sizes'] )
326 699 ) {
@@ -372,175 +745,68 @@
372 745 return $response;
373 746 }
374 747
375 748 /**
376 - * Creates a single attachment.
749 + * Retrieves the attachment's schema, conforming to JSON Schema.
377 750 *
378 - * @param WP_REST_Request $request Full details about the request.
379 - * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
380 - */
381 - public function create_item( $request ) {
382 - if ( ! $request['generate_sub_sizes'] ) {
383 - add_filter( 'intermediate_image_sizes_advanced', '__return_empty_array', 100 );
384 - add_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 );
385 - // Disable server-side EXIF rotation so the client can handle it.
386 - // This preserves the original orientation value in the metadata.
387 - add_filter( 'wp_image_maybe_exif_rotate', '__return_false', 100 );
388 - // Disable server-side big image scaling since the client handles it.
389 - add_filter( 'big_image_size_threshold', '__return_zero', 100 );
390 - }
391 -
392 - if ( ! $request['convert_format'] ) {
393 - add_filter( 'image_editor_output_format', '__return_empty_array', 100 );
394 - }
395 -
396 - $response = parent::create_item( $request );
397 -
398 - remove_filter( 'intermediate_image_sizes_advanced', '__return_empty_array', 100 );
399 - remove_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 );
400 - remove_filter( 'wp_image_maybe_exif_rotate', '__return_false', 100 );
401 - remove_filter( 'big_image_size_threshold', '__return_zero', 100 );
402 - remove_filter( 'image_editor_output_format', '__return_empty_array', 100 );
403 -
404 - // Recompute image_output_format now that __return_empty_array is removed.
405 - if ( ! is_wp_error( $response ) ) {
406 - $data = $response->get_data();
407 - if ( ! empty( $data['id'] ) && wp_attachment_is_image( $data['id'] ) ) {
408 - $mime_type = get_post_mime_type( $data['id'] );
409 - $filename = get_attached_file( $data['id'] );
410 -
411 - /** This filter is documented in wp-includes/class-wp-image-editor.php */
412 - $output_formats = apply_filters(
413 - 'image_editor_output_format', // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
414 - array( $mime_type => $mime_type ),
415 - $filename ? $filename : '',
416 - $mime_type
417 - );
418 -
419 - $output_mime = $output_formats[ $mime_type ] ?? $mime_type;
420 - $data['image_output_format'] = ( $output_mime !== $mime_type ) ? $output_mime : null;
421 -
422 - /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */
423 - $data['image_save_progressive'] = (bool) apply_filters(
424 - 'image_save_progressive', // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
425 - false,
426 - $mime_type
427 - );
428 -
429 - $response->set_data( $data );
430 - }
431 - }
432 -
433 - return $response;
434 - }
435 -
436 - /**
437 - * Finalizes an attachment after client-side media processing.
751 + * Adds exif_orientation field to the schema.
438 752 *
439 - * Triggers the {@see 'wp_generate_attachment_metadata'} filter so that
440 - * server-side plugins can process the attachment after all client-side
441 - * operations (upload, thumbnail generation, sideloads) are complete.
442 - *
443 - * @param WP_REST_Request $request Full details about the request.
444 - * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
753 + * @return array Item schema data.
445 754 */
446 - public function finalize_item( WP_REST_Request $request ) {
447 - $attachment_id = $request['id'];
755 + public function get_item_schema() {
756 + $schema = parent::get_item_schema();
448 757
449 - $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 + );
450 764
451 - if ( is_wp_error( $post ) ) {
452 - return $post;
453 - }
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 + );
454 771
455 - $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 + );
456 778
457 - if ( ! is_array( $metadata ) ) {
458 - $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 + );
459 788 }
460 789
461 - // Apply all sub-size metadata collected from sideload responses.
462 - $sub_sizes = $request['sub_sizes'] ?? array();
463 -
464 - foreach ( $sub_sizes as $sub_size ) {
465 - $image_size = $sub_size['image_size'];
466 -
467 - // When multiple size names share identical dimensions the client
468 - // sends a single sub-size entry with an array of names. Register the
469 - // same file under each name. Arrays only contain regular sizes.
470 - if ( is_array( $image_size ) ) {
471 - $metadata['sizes'] = $metadata['sizes'] ?? array();
472 -
473 - foreach ( $image_size as $name ) {
474 - $metadata['sizes'][ $name ] = array(
475 - 'width' => $sub_size['width'] ?? 0,
476 - 'height' => $sub_size['height'] ?? 0,
477 - 'file' => $sub_size['file'] ?? '',
478 - 'mime-type' => $sub_size['mime_type'] ?? '',
479 - 'filesize' => $sub_size['filesize'] ?? 0,
480 - );
481 - }
482 - continue;
483 - }
484 -
485 - if ( 'original' === $image_size ) {
486 - $metadata['original_image'] = $sub_size['file'];
487 - } elseif ( 'original-heic' === $image_size ) {
488 - // HEIC companion original: stored under its own meta key so
489 - // the scaled-sideload flow (which writes 'original_image')
490 - // cannot clobber it. 'original_image' keeps pointing at the
491 - // web-viewable JPEG derivative. Cleanup on attachment delete
492 - // is handled by a delete_attachment hook that reads this key.
493 - $metadata['original'] = $sub_size['file'];
494 - } elseif ( 'scaled' === $image_size ) {
495 - if ( ! empty( $sub_size['original_image'] ) ) {
496 - $metadata['original_image'] = $sub_size['original_image'];
497 - }
498 - $metadata['width'] = $sub_size['width'] ?? 0;
499 - $metadata['height'] = $sub_size['height'] ?? 0;
500 - $metadata['filesize'] = $sub_size['filesize'] ?? 0;
501 - $metadata['file'] = $sub_size['file'] ?? '';
502 - } else {
503 - $metadata['sizes'] = $metadata['sizes'] ?? array();
504 -
505 - $metadata['sizes'][ $image_size ] = array(
506 - 'width' => $sub_size['width'] ?? 0,
507 - 'height' => $sub_size['height'] ?? 0,
508 - 'file' => $sub_size['file'] ?? '',
509 - 'mime-type' => $sub_size['mime_type'] ?? '',
510 - 'filesize' => $sub_size['filesize'] ?? 0,
511 - );
512 - }
513 - }
514 -
515 - /**
516 - * Filters the attachment metadata after client-side processing.
517 - *
518 - * This re-applies the wp_generate_attachment_metadata filter so that
519 - * server-side plugins (e.g. those adding custom image sizes or
520 - * processing metadata) can run after client-side uploads are complete.
521 - *
522 - * @param array $metadata Attachment metadata.
523 - * @param int $attachment_id Attachment ID.
524 - * @param string $context Context: 'create' or 'update'.
525 - */
526 - // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
527 - $metadata = apply_filters( 'wp_generate_attachment_metadata', $metadata, $attachment_id, 'update' );
528 -
529 - wp_update_attachment_metadata( $attachment_id, $metadata );
530 -
531 - $response_request = new WP_REST_Request(
532 - WP_REST_Server::READABLE,
533 - 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 + ),
534 806 );
535 807
536 - $response_request['context'] = 'edit';
537 -
538 - if ( isset( $request['_fields'] ) ) {
539 - $response_request['_fields'] = $request['_fields'];
540 - }
541 -
542 - return $this->prepare_item_for_response( get_post( $attachment_id ), $response_request );
808 + return $schema;
543 809 }
544 810
545 811 /**
546 812 * Checks if a given request has access to sideload a file.
@@ -555,53 +821,94 @@
555 821 return $this->edit_media_item_permissions_check( $request );
556 822 }
557 823
558 824 /**
559 - * Filters {@see 'wp_unique_filename'} during sideloads.
825 + * Validates an image size name, or an array of names sharing a single file.
560 826 *
561 - * {@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.
562 833 *
563 - * Adding this closure to the filter helps work around this safeguard.
564 - *
565 - * Example: when uploading myphoto.jpeg, WordPress normally creates myphoto-150x150.jpeg,
566 - * and when uploading myphoto-150x150.jpeg, it will be renamed to myphoto-150x150-1.jpeg
567 - * However, here it is desired not to add the suffix in order to maintain the same
568 - * naming convention as if the file was uploaded regularly.
569 - *
570 - * @link https://github.com/WordPress/wordpress-develop/blob/30954f7ac0840cfdad464928021d7f380940c347/src/wp-includes/functions.php#L2576-L2582
571 - *
572 - * @param string $filename Unique file name.
573 - * @param string $dir Directory path.
574 - * @param int|string $number The highest number that was used to make the file name unique
575 - * or an empty string if unused.
576 - * @param string $attachment_filename Original attachment file name.
577 - * @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.
578 837 */
579 - private static function filter_wp_unique_filename( $filename, $dir, $number, $attachment_filename ) {
580 - if ( empty( $number ) || ! $attachment_filename ) {
581 - return $filename;
582 - }
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 + );
583 851
584 - $ext = pathinfo( $filename, PATHINFO_EXTENSION );
585 - $name = pathinfo( $filename, PATHINFO_FILENAME );
586 - $orig_name = pathinfo( $attachment_filename, PATHINFO_FILENAME );
587 -
588 - if ( ! $ext || ! $name ) {
589 - 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 + );
590 872 }
591 873
592 - $matches = array();
593 - if ( preg_match( '/(.*)(-\d+x\d+|-scaled)-' . $number . '$/', $name, $matches ) ) {
594 - $filename_without_suffix = $matches[1] . $matches[2] . ".$ext";
595 - if ( $matches[1] === $orig_name ) {
596 - 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 + );
597 881 }
598 882 }
599 883
600 - return $filename;
884 + return true;
601 885 }
602 886
603 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 + /**
604 911 * Validates that uploaded image dimensions are appropriate for the specified image size.
605 912 *
606 913 * @param int $width Uploaded image width.
607 914 * @param int $height Uploaded image height.
@@ -610,8 +917,22 @@
610 917 * @param int $attachment_id The attachment ID.
611 918 * @return true|WP_Error True if valid, WP_Error if invalid.
612 919 */
613 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 +
614 935 // Dimensions must be positive for all sizes.
615 936 if ( $width <= 0 || $height <= 0 ) {
616 937 return new WP_Error(
617 938 'rest_upload_invalid_dimensions',
@@ -619,9 +940,10 @@
619 940 array( 'status' => 400 )
620 941 );
621 942 }
622 943
623 - // 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()).
624 946 // Validate each one against its registered constraints.
625 947 if ( is_array( $image_size ) ) {
626 948 foreach ( $image_size as $name ) {
627 949 $result = $this->validate_image_dimensions( $width, $height, $name, $attachment_id );
@@ -631,14 +953,20 @@
631 953 }
632 954 return true;
633 955 }
634 956
635 - // 'original-heic' companion file: no dimension constraint.
636 - if ( 'original-heic' === $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 ) {
637 961 return true;
638 962 }
639 963
640 - // '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.
641 969 if ( 'original' === $image_size ) {
642 970 $metadata = wp_get_attachment_metadata( $attachment_id, true );
643 971 if ( is_array( $metadata ) && isset( $metadata['width'], $metadata['height'] ) ) {
644 972 $expected_width = (int) $metadata['width'];
@@ -643,9 +971,12 @@
643 971 if ( is_array( $metadata ) && isset( $metadata['width'], $metadata['height'] ) ) {
644 972 $expected_width = (int) $metadata['width'];
645 973 $expected_height = (int) $metadata['height'];
646 974
647 - 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 ) {
648 979 return new WP_Error(
649 980 'rest_upload_dimension_mismatch',
650 981 sprintf(
651 982 /* translators: 1: actual width, 2: actual height, 3: expected width, 4: expected height */
@@ -717,15 +1048,15 @@
717 1048 return true;
718 1049 }
719 1050
720 1051 /**
721 - * Side-loads a media file without creating an attachment.
1052 + * Side-loads a media file without creating a new attachment.
722 1053 *
723 1054 * @param WP_REST_Request $request Full details about the request.
724 1055 * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
725 1056 */
726 1057 public function sideload_item( WP_REST_Request $request ) {
727 - $attachment_id = $request['id'];
1058 + $attachment_id = (int) $request['id'];
728 1059
729 1060 $post = $this->get_post( $attachment_id );
730 1061
731 1062 if ( is_wp_error( $post ) ) {
@@ -737,14 +1068,34 @@
737 1068 ! wp_attachment_is( 'pdf', $post )
738 1069 ) {
739 1070 return new WP_Error(
740 1071 'rest_post_invalid_id',
741 - __( 'Invalid post ID, only images and PDFs can be sideloaded.', 'gutenberg' ),
1072 + __( 'Invalid post ID. Only images and PDFs can be sideloaded.', 'gutenberg' ),
742 1073 array( 'status' => 400 )
743 1074 );
744 1075 }
745 1076
746 - 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'] ) {
747 1098 // Prevent image conversion as that is done client-side.
748 1099 add_filter( 'image_editor_output_format', '__return_empty_array', 100 );
749 1100 }
750 1101
@@ -757,10 +1108,9 @@
757 1108 * See https://github.com/WordPress/wordpress-develop/blob/30954f7ac0840cfdad464928021d7f380940c347/src/wp-includes/functions.php#L2576-L2582
758 1109 * With the following filter we can work around this safeguard.
759 1110 */
760 1111
761 - $attachment_filename = get_attached_file( $attachment_id, true );
762 - $attachment_filename = $attachment_filename ? wp_basename( $attachment_filename ) : null;
1112 + $attachment_filename = wp_basename( $attached_file );
763 1113
764 1114 /**
765 1115 * @param string $filename Unique file name.
766 1116 * @param string $ext File extension. Example: ".png".
@@ -776,26 +1126,36 @@
776 1126 };
777 1127
778 1128 add_filter( 'wp_unique_filename', $filter_filename, 10, 6 );
779 1129
780 - $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 + };
781 1146
782 - $time = null;
1147 + add_filter( 'upload_dir', $filter_upload_dir, 100 );
783 1148
784 - // Matches logic in media_handle_upload().
785 - // The post date doesn't usually matter for pages, so don't backdate this upload.
786 - if ( $parent_post && 'page' !== $parent_post->post_type && substr( $parent_post->post_date, 0, 4 ) > 0 ) {
787 - $time = $parent_post->post_date;
788 - }
789 -
790 1149 if ( ! empty( $files ) ) {
791 - $file = $this->upload_from_file( $files, $headers, $time );
1150 + $file = $this->upload_from_file( $files, $headers );
792 1151 } else {
793 - $file = $this->upload_from_data( $request->get_body(), $headers, $time );
1152 + $file = $this->upload_from_data( $request->get_body(), $headers );
794 1153 }
795 1154
796 1155 remove_filter( 'wp_unique_filename', $filter_filename );
797 1156 remove_filter( 'image_editor_output_format', '__return_empty_array', 100 );
1157 + remove_filter( 'upload_dir', $filter_upload_dir, 100 );
798 1158
799 1159 if ( is_wp_error( $file ) ) {
800 1160 return $file;
801 1161 }
@@ -806,12 +1166,28 @@
806 1166 $image_size = $request['image_size'];
807 1167
808 1168 // Read dimensions once up-front. Needed both for early-error handling
809 1169 // (corrupted/unsupported files) and for populating the sub-size payload
810 - // below. Scalar 'original' is a byte-only passthrough and does not need
811 - // dimensions, but reading them here is harmless.
812 - $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;
813 1187
1188 + $size = $skip_dimension_read ? array( 0, 0 ) : wp_getimagesize( $path );
1189 +
814 1190 if ( ! $size ) {
815 1191 // Could not determine dimensions (corrupted file, unsupported format).
816 1192 wp_delete_file( $path );
817 1193 return new WP_Error(
@@ -832,9 +1208,10 @@
832 1208 // The client accumulates these and sends them all to the finalize endpoint.
833 1209 // `image_size` may be a single string or an array of names that share the
834 1210 // same dimensions and therefore reuse a single sideloaded file. Arrays
835 1211 // only carry regular sub-sizes; the special keys below ('original',
836 - // 'scaled', 'original-heic') 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()).
837 1214 $sub_size_data = array(
838 1215 'image_size' => $image_size,
839 1216 );
840 1217
@@ -843,24 +1220,50 @@
843 1220 $sub_size_data['height'] = $size[1];
844 1221 $sub_size_data['file'] = wp_basename( $path );
845 1222 $sub_size_data['mime_type'] = $type;
846 1223 $sub_size_data['filesize'] = wp_filesize( $path );
847 - } elseif ( 'original' === $image_size ) {
1224 + } elseif ( self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size ) {
1225 + // Source-format original. finalize_item() writes the filename to
1226 + // $metadata[ self::META_KEY_SOURCE_IMAGE ] (separate from
1227 + // 'original_image', which the scaled-sideload flow owns). Cleanup on
1228 + // attachment delete is handled by a delete_attachment hook that reads
1229 + // this key.
848 1230 $sub_size_data['file'] = wp_basename( $path );
849 - } elseif ( 'original-heic' === $image_size ) {
850 - // HEIC companion original. finalize_item() writes the filename to
851 - // $metadata['original'] (separate from 'original_image', which the
852 - // scaled-sideload flow owns). Cleanup on attachment delete is
853 - // handled by a delete_attachment hook that reads this key.
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.
854 1236 $sub_size_data['file'] = wp_basename( $path );
855 - } elseif ( 'scaled' === $image_size ) {
856 - // Record the current attached file as the original.
857 - $current_file = get_attached_file( $attachment_id, true );
858 - $sub_size_data['original_image'] = wp_basename( $current_file );
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;
859 1250
860 - // Update the attached file to point to the scaled version.
1251 + // Update the attached file to point to the supplied image.
861 1252 // This writes to _wp_attached_file meta, not _wp_attachment_metadata.
862 - 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 + }
863 1266
864 1267 $sub_size_data['width'] = $size[0];
865 1268 $sub_size_data['height'] = $size[1];
866 1269 $sub_size_data['filesize'] = wp_filesize( $path );
@@ -872,7 +1275,544 @@
872 1275 $sub_size_data['mime_type'] = $type;
873 1276 $sub_size_data['filesize'] = wp_filesize( $path );
874 1277 }
875 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 +
876 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;
877 1817 }
878 1818 }