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