Commit 7824f9a7b3 for wordpress.org

commit 7824f9a7b349268c35b81211df22046385fa4c55
Author: adamsilverstein <adamsilverstein@git.wordpress.org>
Date:   Sun Oct 18 22:57:45 2026 +0000

    REST API: Send URL sideloads through the shared attachment insert path.

    Since [62659], a `POST /wp/v2/media` request with a `url` built its own response in `create_item_from_url()` and returned before `create_item()` did any of its work. Fields the schema accepts (`title`, `caption`, `description`, `alt_text`, `featured_media`, `meta`, terms) were silently dropped. The `rest_pre_insert_attachment`, `rest_insert_attachment` and `wp_after_insert_post` hooks never fired. A request with both an uploaded file and a `url` discarded the file without an error.

    This treats a URL as a third source of the file, next to a multipart upload and a raw request body. `create_item_from_url()` becomes `upload_from_url()`, a sibling of `upload_from_file()` and `upload_from_data()`. It downloads the remote file, passes it to `wp_handle_sideload()`, and returns the same data. `insert_attachment()` picks the handler, so every path now shares the same insert, hooks and field handling. A request with both a file and a `url` now returns a 400. An error returned from `rest_pre_insert_attachment` is now respected on every path instead of being used as if it were an attachment.

    Follow-up to [62659], [62841].

    Props jeremyfelt, andrewserong, khokansardar.
    Fixes #65810.


    Built from https://develop.svn.wordpress.org/trunk@64492


    git-svn-id: http://core.svn.wordpress.org/trunk@63642 1a063a9b-81f0-0310-95a4-ce76da25c4cd

diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php
index f0f1d0e49d..7b3ae9d160 100644
--- a/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php
@@ -516,6 +516,29 @@ class WP_REST_Attachments_Controller extends WP_REST_Posts_Controller {
 			);
 		}

+		/*
+		 * A request names the file to attach either as an upload or as a URL to
+		 * sideload, never as both: the two are alternative sources for the same
+		 * attachment, so honoring one would silently discard the other. An
+		 * upload arrives as a multipart file parameter or as the raw request
+		 * body, which upload_from_data() identifies by its Content-Disposition
+		 * filename.
+		 */
+		if ( ! empty( $request['url'] ) ) {
+			$headers     = $request->get_headers();
+			$disposition = empty( $headers['content_disposition'] )
+				? null
+				: self::get_filename_from_disposition( $headers['content_disposition'] );
+
+			if ( ! empty( $request->get_file_params() ) || ! empty( $disposition ) ) {
+				return new WP_Error(
+					'rest_invalid_param',
+					__( 'The url parameter cannot be combined with an uploaded file.' ),
+					array( 'status' => 400 )
+				);
+			}
+		}
+
 		// Handle generate_sub_sizes parameter.
 		if ( false === $request['generate_sub_sizes'] ) {
 			add_filter( 'intermediate_image_sizes_advanced', '__return_empty_array', 100 );
@@ -534,18 +557,6 @@ class WP_REST_Attachments_Controller extends WP_REST_Posts_Controller {
 			add_filter( 'image_editor_output_format', '__return_empty_array', 100 );
 		}

-		/*
-		 * When a URL is supplied instead of an uploaded file, sideload the
-		 * remote image on the server. This avoids a cross-origin browser fetch,
-		 * which fails under cross-origin isolation. The sub-size and scaling
-		 * filters applied above still govern whether derivatives are generated.
-		 */
-		if ( ! empty( $request['url'] ) ) {
-			$response = $this->create_item_from_url( $request );
-			$this->remove_client_side_media_processing_filters();
-			return $response;
-		}
-
 		$insert = $this->insert_attachment( $request );

 		if ( is_wp_error( $insert ) ) {
@@ -640,33 +651,23 @@ class WP_REST_Attachments_Controller extends WP_REST_Posts_Controller {
 	}

 	/**
-	 * Sideloads an external image from a URL into the media library.
+	 * Handles an image sideloaded from a URL.
 	 *
 	 * Downloads the remote file on the server, avoiding a cross-origin browser
-	 * fetch that fails under cross-origin isolation. Whether sub-sizes are
-	 * generated is governed by the filters applied in create_item().
+	 * fetch that fails under cross-origin isolation. The downloaded file is
+	 * handed to wp_handle_sideload(), so this returns the same data as the
+	 * uploaded-file handlers and the attachment is inserted by the shared code
+	 * in insert_attachment().
 	 *
 	 * @since 7.1.0
 	 *
-	 * @param WP_REST_Request $request Full details about the request.
-	 * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
+	 * @param string      $url  URL of the image to sideload.
+	 * @param string|null $time Optional. Time formatted in 'yyyy/mm'. Default null.
+	 * @return array|WP_Error Data from wp_handle_sideload().
 	 */
-	protected function create_item_from_url( WP_REST_Request $request ) {
-		// Sideloading downloads and stores a file, so require the upload capability.
-		if ( ! current_user_can( 'upload_files' ) ) {
-			return new WP_Error(
-				'rest_cannot_create',
-				__( 'Sorry, you are not allowed to upload media on this site.' ),
-				array( 'status' => rest_authorization_required_code() )
-			);
-		}
-
+	protected function upload_from_url( $url, $time = null ) {
+		// Include filesystem functions to get access to download_url() and wp_handle_sideload().
 		require_once ABSPATH . 'wp-admin/includes/file.php';
-		require_once ABSPATH . 'wp-admin/includes/media.php';
-		require_once ABSPATH . 'wp-admin/includes/image.php';
-
-		$url     = $request['url'];
-		$post_id = ! empty( $request['post'] ) ? (int) $request['post'] : 0;

 		// Derive the filename from the URL path before downloading anything.
 		$url_path = wp_parse_url( $url, PHP_URL_PATH );
@@ -759,36 +760,34 @@ class WP_REST_Attachments_Controller extends WP_REST_Posts_Controller {
 			);
 		}

-		$attachment_id = media_handle_sideload( $file_array, $post_id );
+		$sideloaded = wp_handle_sideload( $file_array, array( 'test_form' => false ), $time );

-		if ( is_wp_error( $attachment_id ) ) {
+		if ( isset( $sideloaded['error'] ) ) {
 			/*
-			 * media_handle_sideload() deletes the temp file on success; remove
-			 * it explicitly when the sideload fails.
+			 * wp_handle_sideload() moves the temp file on success; remove it
+			 * explicitly when it fails.
 			 */
 			if ( file_exists( $tmp_file ) ) {
 				wp_delete_file( $tmp_file );
 			}
-			return $attachment_id;
-		}
-
-		$attachment = get_post( $attachment_id );
-
-		$request->set_param( 'context', 'edit' );

-		/*
-		 * media_handle_sideload() fires the standard insert hooks (including
-		 * wp_after_insert_post), but not the REST-specific action, so fire it
-		 * here for parity with the uploaded-file path in create_item().
-		 */
-		/** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php */
-		do_action( 'rest_after_insert_attachment', $attachment, $request, true );
+			// Content that does not match an allowed type is a client error, not a server one.
+			if ( __( 'Sorry, you are not allowed to upload this file type.' ) === $sideloaded['error'] ) {
+				return new WP_Error(
+					'rest_upload_invalid_file_type',
+					$sideloaded['error'],
+					array( 'status' => 400 )
+				);
+			}

-		$response = $this->prepare_item_for_response( $attachment, $request );
-		$response->set_status( 201 );
-		$response->header( 'Location', rest_url( rest_get_route_for_post( $attachment_id ) ) );
+			return new WP_Error(
+				'rest_upload_sideload_error',
+				$sideloaded['error'],
+				array( 'status' => 500 )
+			);
+		}

-		return $response;
+		return $sideloaded;
 	}

 	/**
@@ -828,7 +827,16 @@ class WP_REST_Attachments_Controller extends WP_REST_Posts_Controller {
 			}
 		}

-		if ( ! empty( $files ) ) {
+		/*
+		 * A URL is the third way to name the file, alongside a multipart upload
+		 * and a raw request body. All three produce the same data, so the rest
+		 * of this method is shared: the URL path gets the same title and caption
+		 * defaults, the same hooks, and the same terms and meta handling as an
+		 * uploaded file.
+		 */
+		if ( ! empty( $request['url'] ) ) {
+			$file = $this->upload_from_url( $request['url'], $time );
+		} elseif ( ! empty( $files ) ) {
 			$file = $this->upload_from_file( $files, $headers, $time );
 		} else {
 			$file = $this->upload_from_data( $request->get_body(), $headers, $time );
@@ -869,6 +877,11 @@ class WP_REST_Attachments_Controller extends WP_REST_Posts_Controller {

 		$attachment = $this->prepare_item_for_database( $request );

+		// The rest_pre_insert_attachment filter can return an error.
+		if ( is_wp_error( $attachment ) ) {
+			return $attachment;
+		}
+
 		$attachment->post_mime_type = $type;
 		$attachment->guid           = $url;

diff --git a/wp-includes/version.php b/wp-includes/version.php
index 99c1adcdfb..1519a07a27 100644
--- a/wp-includes/version.php
+++ b/wp-includes/version.php
@@ -16,7 +16,7 @@
  *
  * @global string $wp_version
  */
-$wp_version = '7.2-alpha-64491';
+$wp_version = '7.2-alpha-64492';

 /**
  * Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.