WP_REST_Attachments_Controller::finalize_item( WP_REST_Request $request ): WP_REST_Response|WP_Error

Finalizes an attachment after client-side media processing.

Description

Applies the sub-size metadata collected from sideload responses in a single metadata update, then triggers the ‘wp_generate_attachment_metadata’ filter so that server-side plugins can process the attachment after all client-side operations (upload, thumbnail generation, sideloads) are complete.

Parameters

$requestWP_REST_Requestrequired
Full details about the request.

Return

WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.

Source

public function finalize_item( WP_REST_Request $request ) {
	$attachment_id = (int) $request['id'];

	$post = $this->get_post( $attachment_id );
	if ( is_wp_error( $post ) ) {
		return $post;
	}

	/**
	 * Sub-size metadata collected from sideload responses. Confirm every
	 * file name was produced by a prior sideload for this attachment before
	 * storing it, so a client cannot make finalize record (and later read or
	 * delete) another attachment's files.
	 *
	 * @var list<Image_Sub_Size> $sub_sizes
	 */
	$sub_sizes  = $request['sub_sizes'] ?? array();
	$provenance = $this->validate_sub_size_provenance( $attachment_id, $sub_sizes );
	if ( is_wp_error( $provenance ) ) {
		return $provenance;
	}

	$metadata = wp_get_attachment_metadata( $attachment_id );
	if ( ! is_array( $metadata ) ) {
		$metadata = array();
	}

	// Apply all sub-size metadata collected from sideload responses.
	foreach ( $sub_sizes as $sub_size ) {
		$image_size = $sub_size['image_size'];

		// When multiple size names share identical dimensions the client
		// sends a single sub-size entry with an array of names. Register the
		// same file under each name.
		if ( is_array( $image_size ) ) {
			/*
			 * Arrays carry regular sizes only, as the sideload endpoint
			 * enforces. Each special size names a single file handled by one
			 * of the branches below, so grouping one under a shared file
			 * would write it to the wrong place; reject rather than guess.
			 */
			if ( array_intersect( $image_size, self::get_special_image_sizes() ) ) {
				return new WP_Error(
					'rest_invalid_sub_size_name',
					__( 'A grouped sub-size entry may only name regular image sizes.' ),
					array( 'status' => 400 )
				);
			}

			// As below: `file` is not required by the schema, and a size
			// entry that names no file is not worth recording.
			if ( empty( $sub_size['file'] ) ) {
				continue;
			}

			$metadata['sizes'] = $metadata['sizes'] ?? array();

			foreach ( $image_size as $name ) {
				$metadata['sizes'][ $name ] = array(
					'width'     => $sub_size['width'] ?? 0,
					'height'    => $sub_size['height'] ?? 0,
					'file'      => $sub_size['file'],
					'mime-type' => $sub_size['mime_type'] ?? '',
					'filesize'  => $sub_size['filesize'] ?? 0,
				);
			}
			continue;
		}

		if ( 'original' === $image_size || 'scaled' === $image_size ) {
			// Skip malformed entries so a bad payload cannot blank out the
			// main file metadata.
			if ( empty( $sub_size['file'] ) ) {
				continue;
			}

			/*
			 * Record the supplied full-size image (from sideload_item()) as
			 * the main file, keeping the current attached file as
			 * `original_image`. A 'scaled' image is downsized and an
			 * 'original' image is rotated; both have any EXIF orientation
			 * already applied by the client.
			 */
			if ( ! empty( $sub_size['original_image'] ) ) {
				$metadata['original_image'] = $sub_size['original_image'];
			}
			$metadata['width']    = $sub_size['width'] ?? 0;
			$metadata['height']   = $sub_size['height'] ?? 0;
			$metadata['filesize'] = $sub_size['filesize'] ?? 0;
			$metadata['file']     = $sub_size['file'];

			/*
			 * The supplied image has its orientation applied already, so
			 * reset the stored value (from the upload) to 1, as
			 * wp_create_image_subsizes() does for both its scale and rotate
			 * paths. Otherwise exif_orientation would still report the
			 * pre-rotation value and the client would rotate the image
			 * again on a re-fetch.
			 */
			if ( ! empty( $metadata['image_meta']['orientation'] ) ) {
				$metadata['image_meta']['orientation'] = 1;
			}
		} elseif ( self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size ) {
			// As above: `file` is not required by the schema, and each of
			// these sizes is nothing but the file it names.
			if ( empty( $sub_size['file'] ) ) {
				continue;
			}

			/*
			 * Source-format original: stored under its own meta key so the
			 * scaled-sideload flow (which writes 'original_image') cannot
			 * clobber it. 'original_image' keeps pointing at the
			 * web-viewable JPEG derivative. Cleanup on attachment delete
			 * is handled by wp_delete_attachment_files().
			 */
			$metadata[ self::META_KEY_SOURCE_IMAGE ] = $sub_size['file'];
		} elseif ( 'animated_video' === $image_size ) {
			if ( empty( $sub_size['file'] ) ) {
				continue;
			}

			/*
			 * Converted-video companion of an animated GIF. Stored under its
			 * own meta key; 'original_image' keeps pointing at the GIF. Cleanup
			 * on attachment delete is handled by wp_delete_attachment_files().
			 */
			$metadata['animated_video'] = $sub_size['file'];
		} elseif ( 'animated_video_poster' === $image_size ) {
			if ( empty( $sub_size['file'] ) ) {
				continue;
			}

			// Static first-frame poster for the converted video.
			$metadata['animated_video_poster'] = $sub_size['file'];
		} else {
			if ( empty( $sub_size['file'] ) ) {
				continue;
			}

			$metadata['sizes'] = $metadata['sizes'] ?? array();

			$metadata['sizes'][ $image_size ] = array(
				'width'     => $sub_size['width'] ?? 0,
				'height'    => $sub_size['height'] ?? 0,
				'file'      => $sub_size['file'],
				'mime-type' => $sub_size['mime_type'] ?? '',
				'filesize'  => $sub_size['filesize'] ?? 0,
			);
		}
	}

	/** This filter is documented in wp-admin/includes/image.php */
	$metadata = apply_filters( 'wp_generate_attachment_metadata', $metadata, $attachment_id, 'update' );

	wp_update_attachment_metadata( $attachment_id, $metadata );

	/*
	 * Drop only the provenance rows this request consumed, now that the
	 * names are recorded in the metadata itself. A row is dropped only once
	 * its name is recoverable from the stored metadata, so a name the
	 * 'wp_generate_attachment_metadata' filter removed - or that a failed
	 * update never persisted - keeps its row and the retried request the
	 * endpoint documents as idempotent still validates. Rows for sideloads
	 * that have not been finalized yet survive for a later call, and passing
	 * the value makes the delete a no-op when the row is already gone, so a
	 * retried request cleans up without error. Any rows left behind by an
	 * abandoned upload are removed with the attachment itself.
	 *
	 * Retrying is idempotent for the request as it was sent. A name is only
	 * unavailable to a retry once a later finalize has overwritten the same
	 * size with a newly sideloaded file, which drops the earlier name from
	 * the metadata the retry recovers it from.
	 *
	 * The names are collected before deleting so a request which repeats
	 * the same name across many sub-sizes still issues one query per
	 * distinct name.
	 */
	$recoverable = $this->get_sideloaded_file_names( $attachment_id, false );
	$consumed    = array();
	foreach ( $sub_sizes as $sub_size ) {
		foreach ( array( 'file', 'original_image' ) as $key ) {
			// Matches the set validate_sub_size_provenance() checked, so
			// every name a request was allowed to store is also cleaned up.
			if (
				isset( $sub_size[ $key ] ) &&
				is_string( $sub_size[ $key ] ) &&
				in_array( $sub_size[ $key ], $recoverable, true )
			) {
				$consumed[] = $sub_size[ $key ];
			}
		}
	}

	foreach ( array_unique( $consumed ) as $file_name ) {
		delete_post_meta( $attachment_id, self::META_KEY_SIDELOAD_FILE_NAME, wp_slash( $file_name ) );
	}

	$response_request = new WP_REST_Request(
		WP_REST_Server::READABLE,
		rest_get_route_for_post( $attachment_id )
	);

	$response_request['context'] = 'edit';

	if ( isset( $request['_fields'] ) ) {
		$response_request['_fields'] = $request['_fields'];
	}

	return $this->prepare_item_for_response( $post, $response_request );
}

Hooks

apply_filters( ‘wp_generate_attachment_metadata’, array $metadata, int $attachment_id, string $context )

Filters the generated attachment meta data.

Changelog

VersionDescription
7.1.0Introduced.

User Contributed Notes

You must log in before being able to contribute a note or feedback.