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

In this article

Side-loads a media file without creating a new attachment.

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 sideload_item( WP_REST_Request $request ) {
	$attachment_id = (int) $request['id'];

	$post = $this->get_post( $attachment_id );

	if ( is_wp_error( $post ) ) {
		return $post;
	}

	if (
		! wp_attachment_is_image( $post ) &&
		! wp_attachment_is( 'pdf', $post )
	) {
		return new WP_Error(
			'rest_post_invalid_id',
			__( 'Invalid post ID. Only images and PDFs can be sideloaded.' ),
			array( 'status' => 400 )
		);
	}

	/*
	 * Sideloaded files are placed in the same directory as the attachment
	 * they extend, because the file names produced here are later resolved
	 * against that directory. An attachment stored outside the uploads
	 * directory has no such directory to use, so there is nowhere the names
	 * this would produce could resolve.
	 */
	$attached_file = get_attached_file( $attachment_id, true );
	$subdir        = is_string( $attached_file ) && '' !== $attached_file
		? $this->get_attachment_upload_subdir( $attached_file )
		: null;

	if ( ! is_string( $attached_file ) || '' === $attached_file || null === $subdir ) {
		return new WP_Error(
			'rest_sideload_attachment_not_in_uploads',
			__( 'The attachment is not stored in the uploads directory, so a file cannot be sideloaded for it.' ),
			array( 'status' => 403 )
		);
	}

	if ( false === $request['convert_format'] ) {
		// Prevent image conversion as that is done client-side.
		add_filter( 'image_editor_output_format', '__return_empty_array', 100 );
	}

	// Get the file via $_FILES or raw data.
	$files   = $request->get_file_params();
	$headers = $request->get_headers();

	/*
	 * wp_unique_filename() will always add numeric suffix if the name looks like a sub-size to avoid conflicts.
	 * See /wp-includes/functions.php.
	 * With the following filter we can work around this safeguard.
	 */
	$attachment_filename = wp_basename( $attached_file );

	$filter_filename = static function ( $filename, $ext, $dir, $unique_filename_callback, $alt_filenames, $number ) use ( $attachment_filename ) {
		return self::filter_wp_unique_filename( $filename, $dir, $number, $attachment_filename );
	};

	add_filter( 'wp_unique_filename', $filter_filename, 10, 6 );

	// Pin the upload to the attachment's own directory, rather than deriving
	// it from the parent post's date as media_handle_upload() does for a
	// brand new upload. See the note above where $subdir is resolved.
	$filter_upload_dir = static function ( $uploads ) use ( $subdir ) {
		if (
			is_array( $uploads ) &&
			isset( $uploads['basedir'], $uploads['baseurl'] ) &&
			is_string( $uploads['basedir'] ) &&
			is_string( $uploads['baseurl'] )
		) {
			$uploads['subdir'] = $subdir;
			$uploads['path']   = $uploads['basedir'] . $subdir;
			$uploads['url']    = $uploads['baseurl'] . $subdir;
		}
		return $uploads;
	};

	add_filter( 'upload_dir', $filter_upload_dir, 100 );

	if ( ! empty( $files ) ) {
		$file = $this->upload_from_file( $files, $headers );
	} else {
		$file = $this->upload_from_data( $request->get_body(), $headers );
	}

	remove_filter( 'wp_unique_filename', $filter_filename );
	remove_filter( 'image_editor_output_format', '__return_empty_array', 100 );
	remove_filter( 'upload_dir', $filter_upload_dir, 100 );

	if ( is_wp_error( $file ) ) {
		return $file;
	}

	$type = $file['type'];
	$path = $file['file'];

	/** @var non-empty-string|non-empty-list<non-empty-string> $image_size */
	$image_size = $request['image_size'];

	/*
	 * Validate raster sub-sizes before storing them. Two companion sizes
	 * are exempt because wp_getimagesize() may not be able to read the
	 * file at all: the 'animated_video' companion of an animated GIF is a
	 * video (MP4/WebM), and a source-format original (e.g. a HEIC or JXL
	 * kept next to its JPEG derivative) may be an unreadable format. Their
	 * dimensions are neither validated nor recorded. The
	 * 'animated_video_poster' companion is a real image, so it is still
	 * read and rejected if unreadable; validate_image_dimensions() skips
	 * only the registered-size constraint for it.
	 */
	$skip_dimension_read = self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size || 'animated_video' === $image_size;
	$size                = false;

	if ( ! $skip_dimension_read ) {
		/*
		 * Read the dimensions up front. A file whose dimensions cannot be
		 * read is corrupted or an unsupported format and must be rejected
		 * rather than silently stored with zero dimensions.
		 */
		$size = wp_getimagesize( $path );

		if ( ! $size ) {
			// Clean up the uploaded file.
			wp_delete_file( $path );
			return new WP_Error(
				'rest_upload_invalid_image',
				__( 'Could not read image dimensions. The file may be corrupted or an unsupported format.' ),
				array( 'status' => 400 )
			);
		}

		/*
		 * Validate the dimensions against every size the file is being
		 * registered under. An array $image_size shares one file among
		 * several registered sizes, so the file has to satisfy each of
		 * them; validating only the scalar case would let a name wrapped
		 * in a one-element array skip the constraint entirely.
		 */
		foreach ( (array) $image_size as $size_name ) {
			$validation = $this->validate_image_dimensions( $size[0], $size[1], $size_name, $attachment_id );
			if ( is_wp_error( $validation ) ) {
				// Clean up the uploaded file.
				wp_delete_file( $path );
				return $validation;
			}
		}
	}

	// Build sub-size data to return to the client.
	// The client accumulates these and sends them all to the finalize
	// endpoint, which writes the metadata in a single operation. This
	// avoids the read-modify-write race that concurrent sideloads for the
	// same attachment would otherwise hit.
	$sub_size_data = array(
		'image_size' => $image_size,
	);

	if ( is_array( $image_size ) ) {
		/**
		 * Multiple registered sizes share these dimensions, so a single
		 * sideloaded file is reused for all of them. Arrays only carry
		 * regular sub-sizes; the special keys below are always scalar
		 * (ref. WP_REST_Attachments_Controller::get_special_image_sizes()). Those never skip
		 * the read above, so $size already holds the dimensions.
		 */
		$sub_size_data['width']     = $size ? $size[0] : 0;
		$sub_size_data['height']    = $size ? $size[1] : 0;
		$sub_size_data['file']      = wp_basename( $path );
		$sub_size_data['mime_type'] = $type;
		$sub_size_data['filesize']  = wp_filesize( $path );
	} elseif ( self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size ) {
		/*
		 * Source-format original (e.g. the HEIC kept next to its JPEG
		 * derivative). Record the filename so finalize_item can store it
		 * under the dedicated source-image meta key.
		 */
		$sub_size_data['file'] = wp_basename( $path );
	} elseif ( 'animated_video' === $image_size || 'animated_video_poster' === $image_size ) {
		/*
		 * Converted-video companion of an animated GIF (the MP4/WebM or
		 * its static first-frame poster). Record the filename so
		 * finalize_item can store it under its dedicated meta key.
		 */
		$sub_size_data['file'] = wp_basename( $path );
	} elseif ( 'scaled' === $image_size || 'original' === $image_size ) {
		/*
		 * 'scaled' and 'original' both replace the attachment's main file
		 * with the supplied image and keep the file being replaced as
		 * `original_image`, which is the untouched upload. A 'scaled'
		 * image is downsized and an 'original' image has any EXIF
		 * orientation already applied. This is the same swap WordPress
		 * makes when it scales or rotates an image on upload; see
		 * _wp_image_meta_replace_original().
		 */
		$sub_size_data['original_image'] = $attachment_filename;

		// Validate the supplied image before updating the attached file.
		// $size was read above: neither of these sizes skips that read.
		$filesize = wp_filesize( $path );

		if ( ! $size || ! $filesize ) {
			// Clean up the uploaded file, which nothing references yet.
			wp_delete_file( $path );
			return new WP_Error(
				'rest_sideload_invalid_image',
				__( 'Unable to read the sideloaded image file.' ),
				array( 'status' => 500 )
			);
		}

		// Update the attached file to point to the supplied image.
		// This writes to _wp_attached_file meta, not _wp_attachment_metadata.
		if (
			$attached_file !== $path &&
			! update_attached_file( $attachment_id, $path )
		) {
			// Clean up the uploaded file, which nothing references yet.
			wp_delete_file( $path );
			return new WP_Error(
				'rest_sideload_update_attached_file_failed',
				__( 'Unable to update the attached file for this attachment.' ),
				array( 'status' => 500 )
			);
		}

		$sub_size_data['width']    = $size[0];
		$sub_size_data['height']   = $size[1];
		$sub_size_data['filesize'] = $filesize;
		$sub_size_data['file']     = _wp_relative_upload_path( $path );
	} else {
		// As above, $size was already read for every size reaching here.
		$sub_size_data['width']     = $size ? $size[0] : 0;
		$sub_size_data['height']    = $size ? $size[1] : 0;
		$sub_size_data['file']      = wp_basename( $path );
		$sub_size_data['mime_type'] = $type;
		$sub_size_data['filesize']  = wp_filesize( $path );
	}

	/*
	 * Record the file names produced for this attachment so finalize can
	 * confirm every stored sub-size was actually sideloaded here. The
	 * values recorded are exactly the ones handed back to the client, so
	 * finalize accepts a submission only when it echoes what was produced.
	 */
	foreach ( array( 'file', 'original_image' ) as $provenance_key ) {
		if (
			isset( $sub_size_data[ $provenance_key ] ) &&
			is_string( $sub_size_data[ $provenance_key ] ) &&
			'' !== $sub_size_data[ $provenance_key ]
		) {
			add_post_meta( $attachment_id, self::META_KEY_SIDELOAD_FILE_NAME, wp_slash( $sub_size_data[ $provenance_key ] ) );
		}
	}

	return rest_ensure_response( $sub_size_data );
}

Changelog

VersionDescription
7.1.0Introduced.

User Contributed Notes

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