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 +1481 -197 23.0.1 → 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 */
@@ -19,20 +101,8 @@
19 101 */
20 102 public function register_routes(): void {
21 103 parent::register_routes();
22 104
23 - // Override the parent's sideload route so that 'scaled' is included
24 - // in the image_size enum. Without the override, core's handler
25 - // validates first and rejects 'scaled' before ours is tried.
26 - $valid_image_sizes = array_keys( wp_get_registered_image_subsizes() );
27 -
28 - // Special case to set 'original_image' in attachment metadata.
29 - $valid_image_sizes[] = 'original';
30 - // Client-side big image threshold: sideload the scaled version.
31 - $valid_image_sizes[] = 'scaled';
32 - // Used for PDF thumbnails.
33 - $valid_image_sizes[] = 'full';
34 -
35 105 register_rest_route(
36 106 $this->namespace,
37 107 '/' . $this->rest_base . '/(?P<id>[\d]+)/sideload',
38 108 array(
@@ -40,18 +110,48 @@
40 110 'methods' => WP_REST_Server::CREATABLE,
41 111 'callback' => array( $this, 'sideload_item' ),
42 112 'permission_callback' => array( $this, 'sideload_item_permissions_check' ),
43 113 'args' => array(
44 - 'id' => array(
114 + 'id' => array(
45 115 'description' => __( 'Unique identifier for the attachment.', 'gutenberg' ),
46 116 'type' => 'integer',
47 117 ),
48 - 'image_size' => array(
49 - 'description' => __( 'Image size.', 'gutenberg' ),
50 - 'type' => 'string',
51 - 'enum' => $valid_image_sizes,
52 - 'required' => true,
118 + 'image_size' => array(
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' ),
120 + 'type' => array( 'string', 'array' ),
121 + 'items' => array(
122 + 'type' => 'string',
123 + 'minLength' => 1,
124 + ),
125 + 'minItems' => 1,
126 + 'minLength' => 1,
127 + 'required' => true,
128 + // A custom callback is used instead of the default `rest_validate_request_arg`
129 + // because WordPress's `rest_is_array()` treats scalar strings as single-element
130 + // lists (via wp_parse_list), so a oneOf with both a string and array schema
131 + // matches a plain string twice and validation fails with "matches more than one
132 + // of the expected formats". The callback validates the enum per-item using the
133 + // current list of registered sizes, which reflects any sizes added after the
134 + // route was registered (e.g. via add_image_size() in tests).
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;
144 + }
145 +
146 + return self::validate_image_size_names( $value, $param );
147 + },
53 148 ),
149 + 'convert_format' => array(
150 + 'description' => __( 'Whether to convert image formats.', 'gutenberg' ),
151 + 'type' => 'boolean',
152 + 'default' => true,
153 + ),
54 154 ),
55 155 ),
56 156 'allow_batch' => $this->allow_batch,
57 157 'schema' => array( $this, 'get_public_item_schema' ),
@@ -67,37 +167,218 @@
67 167 'methods' => WP_REST_Server::CREATABLE,
68 168 'callback' => array( $this, 'finalize_item' ),
69 169 'permission_callback' => array( $this, 'edit_media_item_permissions_check' ),
70 170 'args' => array(
71 - 'id' => array(
171 + 'id' => array(
72 172 'description' => __( 'Unique identifier for the attachment.', 'gutenberg' ),
73 173 'type' => 'integer',
74 174 ),
175 + 'sub_sizes' => 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(
220 + 'type' => 'object',
221 + 'properties' => array(
222 + 'image_size' => array(
223 + // Uses a multi-type schema instead of `oneOf` because WordPress's
224 + // `rest_is_array()` treats scalar strings as single-element lists,
225 + // so both a `{type: string}` and `{type: array}` oneOf schema would
226 + // match a plain string and trigger a "matches more than one"
227 + // validation error.
228 + 'description' => __( 'Size name, or an array of size names when a single file is registered under multiple sizes with matching dimensions.', 'gutenberg' ),
229 + 'type' => array( 'string', 'array' ),
230 + 'items' => array(
231 + 'type' => 'string',
232 + 'minLength' => 1,
233 + ),
234 + 'minItems' => 1,
235 + 'minLength' => 1,
236 + 'required' => true,
237 + ),
238 + 'width' => array(
239 + 'type' => 'integer',
240 + 'minimum' => 1,
241 + ),
242 + 'height' => array(
243 + 'type' => 'integer',
244 + 'minimum' => 1,
245 + ),
246 + 'file' => array(
247 + 'type' => 'string',
248 + 'minLength' => 1,
249 + ),
250 + 'mime_type' => array(
251 + 'type' => 'string',
252 + 'pattern' => '^image/.*',
253 + ),
254 + 'filesize' => array(
255 + 'type' => 'integer',
256 + 'minimum' => 1,
257 + ),
258 + 'original_image' => array(
259 + 'type' => 'string',
260 + 'minLength' => 1,
261 + ),
262 + ),
263 + ),
264 + ),
75 265 ),
76 266 ),
77 267 'allow_batch' => $this->allow_batch,
78 268 'schema' => array( $this, 'get_public_item_schema' ),
79 - )
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
80 275 );
81 276 }
82 277
83 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 + /**
84 339 * Checks if a given request has access to create an attachment.
85 340 *
86 341 * Skips the server-side image type support check when the client
87 - * 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.
88 345 *
89 346 * @param WP_REST_Request $request Full details about the request.
90 347 * @return true|WP_Error True if the request has access to create items, WP_Error object otherwise.
91 348 */
92 349 public function create_item_permissions_check( $request ) {
93 - if ( false === $request['generate_sub_sizes'] ) {
350 + $bypass_mime_check = false === $request['generate_sub_sizes'];
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 +
374 + if ( $bypass_mime_check ) {
94 375 add_filter( 'wp_prevent_unsupported_mime_type_uploads', '__return_false' );
95 376 }
96 377
97 378 $result = parent::create_item_permissions_check( $request );
98 379
99 - if ( false === $request['generate_sub_sizes'] ) {
380 + if ( $bypass_mime_check ) {
100 381 remove_filter( 'wp_prevent_unsupported_mime_type_uploads', '__return_false' );
101 382 }
102 383
103 384 return $result;
@@ -103,52 +384,186 @@
103 384 return $result;
104 385 }
105 386
106 387 /**
107 - * Retrieves an array of endpoint arguments from the item schema for the controller.
388 + * Creates a single attachment.
108 389 *
109 - * @param string $method Optional. HTTP method of the request. The arguments for `CREATABLE` requests are
110 - * checked for required values and may fall-back to a given default, this is not done
111 - * on `EDITABLE` requests. Default WP_REST_Server::CREATABLE.
112 - * @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.
113 392 */
114 - public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CREATABLE ) {
115 - $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 + }
116 405
117 - if ( WP_REST_Server::CREATABLE === $method ) {
118 - $args['generate_sub_sizes'] = array(
119 - 'type' => 'boolean',
120 - 'default' => true,
121 - 'description' => __( 'Whether to generate image sub sizes.', 'gutenberg' ),
122 - );
123 - $args['convert_format'] = array(
124 - 'type' => 'boolean',
125 - 'default' => true,
126 - 'description' => __( 'Whether to convert image formats.', 'gutenberg' ),
127 - );
406 + if ( false === $request['convert_format'] ) {
407 + add_filter( 'image_editor_output_format', '__return_empty_array', 100 );
128 408 }
129 409
130 - 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;
131 458 }
132 459
133 460 /**
134 - * Retrieves the attachment's schema, conforming to JSON Schema.
461 + * Sideloads an external image from a URL into the media library.
135 462 *
136 - * 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().
137 466 *
138 - * @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.
139 469 */
140 - public function get_item_schema() {
141 - $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 + }
142 479
143 - $schema['properties']['exif_orientation'] = array(
144 - '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' ),
145 - 'type' => 'integer',
146 - 'context' => array( 'edit' ),
147 - '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,
148 526 );
149 527
150 - return $schema;
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 + }
535 +
536 + $attachment_id = media_handle_sideload( $file_array, $post_id );
537 +
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;
151 566 }
152 567
153 568 /**
154 569 * Prepares a single attachment output for response.
@@ -190,8 +605,95 @@
190 605 $data['exif_orientation'] = $orientation;
191 606 }
192 607 }
193 608
609 + // Add per-file output format for images.
610 + if ( rest_is_field_included( 'image_output_format', $fields ) ) {
611 + if ( wp_attachment_is_image( $item ) ) {
612 + $mime_type = get_post_mime_type( $item );
613 + $filename = get_attached_file( $item->ID );
614 +
615 + /** This filter is documented in wp-includes/class-wp-image-editor.php */
616 + $output_formats = apply_filters(
617 + 'image_editor_output_format', // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
618 + array( $mime_type => $mime_type ),
619 + $filename ? $filename : '',
620 + $mime_type
621 + );
622 +
623 + $output_mime = $output_formats[ $mime_type ] ?? $mime_type;
624 + $data['image_output_format'] = ( $output_mime !== $mime_type ) ? $output_mime : null;
625 + }
626 + }
627 +
628 + // Add progressive/interlaced encoding setting for images.
629 + if ( rest_is_field_included( 'image_save_progressive', $fields ) ) {
630 + if ( wp_attachment_is_image( $item ) ) {
631 + $mime_type = get_post_mime_type( $item );
632 +
633 + /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */
634 + $data['image_save_progressive'] = (bool) apply_filters(
635 + 'image_save_progressive', // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
636 + false,
637 + $mime_type
638 + );
639 + }
640 + }
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 +
194 696 if (
195 697 rest_is_field_included( 'missing_image_sizes', $fields ) &&
196 698 empty( $data['missing_image_sizes'] )
197 699 ) {
@@ -243,92 +745,68 @@
243 745 return $response;
244 746 }
245 747
246 748 /**
247 - * Creates a single attachment.
749 + * Retrieves the attachment's schema, conforming to JSON Schema.
248 750 *
249 - * @param WP_REST_Request $request Full details about the request.
250 - * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
251 - */
252 - public function create_item( $request ) {
253 - if ( ! $request['generate_sub_sizes'] ) {
254 - add_filter( 'intermediate_image_sizes_advanced', '__return_empty_array', 100 );
255 - add_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 );
256 - // Disable server-side EXIF rotation so the client can handle it.
257 - // This preserves the original orientation value in the metadata.
258 - add_filter( 'wp_image_maybe_exif_rotate', '__return_false', 100 );
259 - // Disable server-side big image scaling since the client handles it.
260 - add_filter( 'big_image_size_threshold', '__return_zero', 100 );
261 - }
262 -
263 - if ( ! $request['convert_format'] ) {
264 - add_filter( 'image_editor_output_format', '__return_empty_array', 100 );
265 - }
266 -
267 - $response = parent::create_item( $request );
268 -
269 - remove_filter( 'intermediate_image_sizes_advanced', '__return_empty_array', 100 );
270 - remove_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 );
271 - remove_filter( 'wp_image_maybe_exif_rotate', '__return_false', 100 );
272 - remove_filter( 'big_image_size_threshold', '__return_zero', 100 );
273 - remove_filter( 'image_editor_output_format', '__return_empty_array', 100 );
274 -
275 - return $response;
276 - }
277 -
278 - /**
279 - * Finalizes an attachment after client-side media processing.
751 + * Adds exif_orientation field to the schema.
280 752 *
281 - * Triggers the {@see 'wp_generate_attachment_metadata'} filter so that
282 - * server-side plugins can process the attachment after all client-side
283 - * operations (upload, thumbnail generation, sideloads) are complete.
284 - *
285 - * @param WP_REST_Request $request Full details about the request.
286 - * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
753 + * @return array Item schema data.
287 754 */
288 - public function finalize_item( WP_REST_Request $request ) {
289 - $attachment_id = $request['id'];
755 + public function get_item_schema() {
756 + $schema = parent::get_item_schema();
290 757
291 - $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 + );
292 764
293 - if ( is_wp_error( $post ) ) {
294 - return $post;
295 - }
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 + );
296 771
297 - $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 + );
298 778
299 - if ( ! is_array( $metadata ) ) {
300 - $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 + );
301 788 }
302 789
303 - /**
304 - * Filters the attachment metadata after client-side processing.
305 - *
306 - * This re-applies the wp_generate_attachment_metadata filter so that
307 - * server-side plugins (e.g. those adding custom image sizes or
308 - * processing metadata) can run after client-side uploads are complete.
309 - *
310 - * @param array $metadata Attachment metadata.
311 - * @param int $attachment_id Attachment ID.
312 - * @param string $context Context: 'create' or 'update'.
313 - */
314 - // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
315 - $metadata = apply_filters( 'wp_generate_attachment_metadata', $metadata, $attachment_id, 'update' );
316 -
317 - wp_update_attachment_metadata( $attachment_id, $metadata );
318 -
319 - $response_request = new WP_REST_Request(
320 - WP_REST_Server::READABLE,
321 - 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 + ),
322 806 );
323 807
324 - $response_request['context'] = 'edit';
325 -
326 - if ( isset( $request['_fields'] ) ) {
327 - $response_request['_fields'] = $request['_fields'];
328 - }
329 -
330 - return $this->prepare_item_for_response( get_post( $attachment_id ), $response_request );
808 + return $schema;
331 809 }
332 810
333 811 /**
334 812 * Checks if a given request has access to sideload a file.
@@ -343,60 +821,242 @@
343 821 return $this->edit_media_item_permissions_check( $request );
344 822 }
345 823
346 824 /**
347 - * Filters {@see 'wp_unique_filename'} during sideloads.
825 + * Validates an image size name, or an array of names sharing a single file.
348 826 *
349 - * {@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.
350 833 *
351 - * Adding this closure to the filter helps work around this safeguard.
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.
837 + */
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 + );
851 +
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 + );
872 + }
873 +
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 + );
881 + }
882 + }
883 +
884 + return true;
885 + }
886 +
887 + /**
888 + * Returns the image size names which name a single file rather than a sub-size.
352 889 *
353 - * Example: when uploading myphoto.jpeg, WordPress normally creates myphoto-150x150.jpeg,
354 - * and when uploading myphoto-150x150.jpeg, it will be renamed to myphoto-150x150-1.jpeg
355 - * However, here it is desired not to add the suffix in order to maintain the same
356 - * naming convention as if the file was uploaded regularly.
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.
357 893 *
358 - * @link https://github.com/WordPress/wordpress-develop/blob/30954f7ac0840cfdad464928021d7f380940c347/src/wp-includes/functions.php#L2576-L2582
894 + * @return string[] Special image size names.
359 895 *
360 - * @param string $filename Unique file name.
361 - * @param string $dir Directory path.
362 - * @param int|string $number The highest number that was used to make the file name unique
363 - * or an empty string if unused.
364 - * @param string $attachment_filename Original attachment file name.
365 - * @return string Filtered file name.
896 + * @phpstan-return non-empty-list<non-empty-string>
366 897 */
367 - private static function filter_wp_unique_filename( $filename, $dir, $number, $attachment_filename ) {
368 - if ( empty( $number ) || ! $attachment_filename ) {
369 - return $filename;
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 + /**
911 + * Validates that uploaded image dimensions are appropriate for the specified image size.
912 + *
913 + * @param int $width Uploaded image width.
914 + * @param int $height Uploaded image height.
915 + * @param string|array $image_size The target image size name, or an array
916 + * of names that share the same dimensions.
917 + * @param int $attachment_id The attachment ID.
918 + * @return true|WP_Error True if valid, WP_Error if invalid.
919 + */
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;
370 926 }
371 927
372 - $ext = pathinfo( $filename, PATHINFO_EXTENSION );
373 - $name = pathinfo( $filename, PATHINFO_FILENAME );
374 - $orig_name = pathinfo( $attachment_filename, PATHINFO_FILENAME );
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 + }
375 934
376 - if ( ! $ext || ! $name ) {
377 - return $filename;
935 + // Dimensions must be positive for all sizes.
936 + if ( $width <= 0 || $height <= 0 ) {
937 + return new WP_Error(
938 + 'rest_upload_invalid_dimensions',
939 + __( 'Uploaded image must have positive dimensions.', 'gutenberg' ),
940 + array( 'status' => 400 )
941 + );
378 942 }
379 943
380 - $matches = array();
381 - if ( preg_match( '/(.*)(-\d+x\d+|-scaled)-' . $number . '$/', $name, $matches ) ) {
382 - $filename_without_suffix = $matches[1] . $matches[2] . ".$ext";
383 - if ( $matches[1] === $orig_name && ! file_exists( "$dir/$filename_without_suffix" ) ) {
384 - return $filename_without_suffix;
944 + // Arrays only contain regular sub-size names that share dimensions, which
945 + // the image_size validation enforces (ref. get_special_image_sizes()).
946 + // Validate each one against its registered constraints.
947 + if ( is_array( $image_size ) ) {
948 + foreach ( $image_size as $name ) {
949 + $result = $this->validate_image_dimensions( $width, $height, $name, $attachment_id );
950 + if ( is_wp_error( $result ) ) {
951 + return $result;
952 + }
385 953 }
954 + return true;
386 955 }
387 956
388 - return $filename;
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 ) {
961 + return true;
962 + }
963 +
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.
969 + if ( 'original' === $image_size ) {
970 + $metadata = wp_get_attachment_metadata( $attachment_id, true );
971 + if ( is_array( $metadata ) && isset( $metadata['width'], $metadata['height'] ) ) {
972 + $expected_width = (int) $metadata['width'];
973 + $expected_height = (int) $metadata['height'];
974 +
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 ) {
979 + return new WP_Error(
980 + 'rest_upload_dimension_mismatch',
981 + sprintf(
982 + /* translators: 1: actual width, 2: actual height, 3: expected width, 4: expected height */
983 + __( 'Uploaded image dimensions (%1$dx%2$d) do not match original image dimensions (%3$dx%4$d).', 'gutenberg' ),
984 + $width,
985 + $height,
986 + $expected_width,
987 + $expected_height
988 + ),
989 + array( 'status' => 400 )
990 + );
991 + }
992 + }
993 + return true;
994 + }
995 +
996 + // 'full' size (PDF thumbnails) and 'scaled': no further constraints.
997 + if ( 'full' === $image_size || 'scaled' === $image_size ) {
998 + return true;
999 + }
1000 +
1001 + // Regular image sizes: validate against registered size constraints.
1002 + $registered_sizes = wp_get_registered_image_subsizes();
1003 +
1004 + if ( ! isset( $registered_sizes[ $image_size ] ) ) {
1005 + return new WP_Error(
1006 + 'rest_upload_unknown_size',
1007 + __( 'Unknown image size.', 'gutenberg' ),
1008 + array( 'status' => 400 )
1009 + );
1010 + }
1011 +
1012 + $size_data = $registered_sizes[ $image_size ];
1013 + $max_width = (int) $size_data['width'];
1014 + $max_height = (int) $size_data['height'];
1015 +
1016 + // Validate dimensions don't exceed the registered size maximums.
1017 + // Allow 1px tolerance for rounding differences.
1018 + $tolerance = 1;
1019 +
1020 + if ( $max_width > 0 && $width > $max_width + $tolerance ) {
1021 + return new WP_Error(
1022 + 'rest_upload_dimension_mismatch',
1023 + sprintf(
1024 + /* translators: 1: image size name, 2: max width, 3: actual width */
1025 + __( 'Uploaded image width (%3$d) exceeds maximum for "%1$s" size (%2$d).', 'gutenberg' ),
1026 + $image_size,
1027 + $max_width,
1028 + $width
1029 + ),
1030 + array( 'status' => 400 )
1031 + );
1032 + }
1033 +
1034 + if ( $max_height > 0 && $height > $max_height + $tolerance ) {
1035 + return new WP_Error(
1036 + 'rest_upload_dimension_mismatch',
1037 + sprintf(
1038 + /* translators: 1: image size name, 2: max height, 3: actual height */
1039 + __( 'Uploaded image height (%3$d) exceeds maximum for "%1$s" size (%2$d).', 'gutenberg' ),
1040 + $image_size,
1041 + $max_height,
1042 + $height
1043 + ),
1044 + array( 'status' => 400 )
1045 + );
1046 + }
1047 +
1048 + return true;
389 1049 }
390 1050
391 1051 /**
392 - * Side-loads a media file without creating an attachment.
1052 + * Side-loads a media file without creating a new attachment.
393 1053 *
394 1054 * @param WP_REST_Request $request Full details about the request.
395 1055 * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
396 1056 */
397 1057 public function sideload_item( WP_REST_Request $request ) {
398 - $attachment_id = $request['id'];
1058 + $attachment_id = (int) $request['id'];
399 1059
400 1060 $post = $this->get_post( $attachment_id );
401 1061
402 1062 if ( is_wp_error( $post ) ) {
@@ -408,14 +1068,34 @@
408 1068 ! wp_attachment_is( 'pdf', $post )
409 1069 ) {
410 1070 return new WP_Error(
411 1071 'rest_post_invalid_id',
412 - __( 'Invalid post ID, only images and PDFs can be sideloaded.', 'gutenberg' ),
1072 + __( 'Invalid post ID. Only images and PDFs can be sideloaded.', 'gutenberg' ),
413 1073 array( 'status' => 400 )
414 1074 );
415 1075 }
416 1076
417 - 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'] ) {
418 1098 // Prevent image conversion as that is done client-side.
419 1099 add_filter( 'image_editor_output_format', '__return_empty_array', 100 );
420 1100 }
421 1101
@@ -428,10 +1108,9 @@
428 1108 * See https://github.com/WordPress/wordpress-develop/blob/30954f7ac0840cfdad464928021d7f380940c347/src/wp-includes/functions.php#L2576-L2582
429 1109 * With the following filter we can work around this safeguard.
430 1110 */
431 1111
432 - $attachment_filename = get_attached_file( $attachment_id, true );
433 - $attachment_filename = $attachment_filename ? wp_basename( $attachment_filename ) : null;
1112 + $attachment_filename = wp_basename( $attached_file );
434 1113
435 1114 /**
436 1115 * @param string $filename Unique file name.
437 1116 * @param string $ext File extension. Example: ".png".
@@ -447,26 +1126,36 @@
447 1126 };
448 1127
449 1128 add_filter( 'wp_unique_filename', $filter_filename, 10, 6 );
450 1129
451 - $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 + };
452 1146
453 - $time = null;
1147 + add_filter( 'upload_dir', $filter_upload_dir, 100 );
454 1148
455 - // Matches logic in media_handle_upload().
456 - // The post date doesn't usually matter for pages, so don't backdate this upload.
457 - if ( $parent_post && 'page' !== $parent_post->post_type && substr( $parent_post->post_date, 0, 4 ) > 0 ) {
458 - $time = $parent_post->post_date;
459 - }
460 -
461 1149 if ( ! empty( $files ) ) {
462 - $file = $this->upload_from_file( $files, $headers, $time );
1150 + $file = $this->upload_from_file( $files, $headers );
463 1151 } else {
464 - $file = $this->upload_from_data( $request->get_body(), $headers, $time );
1152 + $file = $this->upload_from_data( $request->get_body(), $headers );
465 1153 }
466 1154
467 1155 remove_filter( 'wp_unique_filename', $filter_filename );
468 1156 remove_filter( 'image_editor_output_format', '__return_empty_array', 100 );
1157 + remove_filter( 'upload_dir', $filter_upload_dir, 100 );
469 1158
470 1159 if ( is_wp_error( $file ) ) {
471 1160 return $file;
472 1161 }
@@ -475,46 +1164,572 @@
475 1164 $path = $file['file'];
476 1165
477 1166 $image_size = $request['image_size'];
478 1167
1168 + // Read dimensions once up-front. Needed both for early-error handling
1169 + // (corrupted/unsupported files) and for populating the sub-size payload
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;
1187 +
1188 + $size = $skip_dimension_read ? array( 0, 0 ) : wp_getimagesize( $path );
1189 +
1190 + if ( ! $size ) {
1191 + // Could not determine dimensions (corrupted file, unsupported format).
1192 + wp_delete_file( $path );
1193 + return new WP_Error(
1194 + 'rest_upload_invalid_image',
1195 + __( 'Could not read image dimensions. The file may be corrupted or an unsupported format.', 'gutenberg' ),
1196 + array( 'status' => 400 )
1197 + );
1198 + }
1199 +
1200 + $validation = $this->validate_image_dimensions( $size[0], $size[1], $image_size, $attachment_id );
1201 + if ( is_wp_error( $validation ) ) {
1202 + // Clean up the uploaded file.
1203 + wp_delete_file( $path );
1204 + return $validation;
1205 + }
1206 +
1207 + // Build sub-size data to return to the client.
1208 + // The client accumulates these and sends them all to the finalize endpoint.
1209 + // `image_size` may be a single string or an array of names that share the
1210 + // same dimensions and therefore reuse a single sideloaded file. Arrays
1211 + // only carry regular sub-sizes; the special keys below ('original',
1212 + // 'scaled', and the source-format original) are always scalar strings,
1213 + // which the image_size validation enforces (ref. get_special_image_sizes()).
1214 + $sub_size_data = array(
1215 + 'image_size' => $image_size,
1216 + );
1217 +
1218 + if ( is_array( $image_size ) ) {
1219 + $sub_size_data['width'] = $size[0];
1220 + $sub_size_data['height'] = $size[1];
1221 + $sub_size_data['file'] = wp_basename( $path );
1222 + $sub_size_data['mime_type'] = $type;
1223 + $sub_size_data['filesize'] = wp_filesize( $path );
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.
1230 + $sub_size_data['file'] = wp_basename( $path );
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;
1250 +
1251 + // Update the attached file to point to the supplied image.
1252 + // This writes to _wp_attached_file meta, not _wp_attachment_metadata.
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 + }
1266 +
1267 + $sub_size_data['width'] = $size[0];
1268 + $sub_size_data['height'] = $size[1];
1269 + $sub_size_data['filesize'] = wp_filesize( $path );
1270 + $sub_size_data['file'] = _wp_relative_upload_path( $path );
1271 + } else {
1272 + $sub_size_data['width'] = $size[0];
1273 + $sub_size_data['height'] = $size[1];
1274 + $sub_size_data['file'] = wp_basename( $path );
1275 + $sub_size_data['mime_type'] = $type;
1276 + $sub_size_data['filesize'] = wp_filesize( $path );
1277 + }
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 +
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 + */
479 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 + );
480 1454
481 - if ( ! $metadata ) {
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 ) ) {
482 1552 $metadata = array();
483 1553 }
484 1554
485 - if ( 'original' === $image_size ) {
486 - $metadata['original_image'] = wp_basename( $path );
487 - } elseif ( 'scaled' === $image_size ) {
488 - // The current attached file is the original; record it as original_image.
489 - $current_file = get_attached_file( $attachment_id, true );
490 - $metadata['original_image'] = wp_basename( $current_file );
1555 + // Apply all sub-size metadata collected from sideload responses.
1556 + foreach ( $sub_sizes as $sub_size ) {
1557 + $image_size = $sub_size['image_size'];
491 1558
492 - // Update the attached file to point to the scaled version.
493 - update_attached_file( $attachment_id, $path );
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 + }
494 1576
495 - $size = wp_getimagesize( $path );
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 + }
496 1582
497 - $metadata['width'] = $size ? $size[0] : 0;
498 - $metadata['height'] = $size ? $size[1] : 0;
499 - $metadata['filesize'] = wp_filesize( $path );
500 - $metadata['file'] = _wp_relative_upload_path( $path );
501 - } else {
502 - $metadata['sizes'] = $metadata['sizes'] ?? array();
1583 + $metadata['sizes'] = $metadata['sizes'] ?? array();
503 1584
504 - $size = wp_getimagesize( $path );
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 + }
505 1596
506 - $metadata['sizes'][ $image_size ] = array(
507 - 'width' => $size ? $size[0] : 0,
508 - 'height' => $size ? $size[1] : 0,
509 - 'file' => wp_basename( $path ),
510 - 'mime-type' => $type,
511 - 'filesize' => wp_filesize( $path ),
512 - );
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 + }
513 1683 }
514 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 +
515 1689 wp_update_attachment_metadata( $attachment_id, $metadata );
516 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 +
517 1732 $response_request = new WP_REST_Request(
518 1733 WP_REST_Server::READABLE,
519 1734 rest_get_route_for_post( $attachment_id )
520 1735 );
@@ -524,11 +1739,80 @@
524 1739 if ( isset( $request['_fields'] ) ) {
525 1740 $response_request['_fields'] = $request['_fields'];
526 1741 }
527 1742
528 - $response = $this->prepare_item_for_response( get_post( $attachment_id ), $response_request );
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 + }
529 1757
530 - $response->header( 'Location', rest_url( rest_get_route_for_post( $attachment_id ) ) );
1758 + return $this->prepare_item_for_response( $post, $response_request );
1759 + }
531 1760
532 - return $response;
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;
533 1817 }
534 1818 }