diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index 21805778ba659..4b492d3b9af40 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -151,6 +151,12 @@ public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CRE 'default' => true, 'description' => __( 'Whether to convert image formats.' ), ); + $args['url'] = array( + 'type' => 'string', + 'format' => 'uri', + 'description' => __( 'URL of an external image to sideload into the media library, instead of uploading a file.' ), + 'sanitize_callback' => 'sanitize_url', + ); } return $args; @@ -289,7 +295,7 @@ public function create_item_permissions_check( $request ) { * Creates a single attachment. * * @since 4.7.0 - * @since 7.1.0 Added `generate_sub_sizes` and `convert_format` parameters. + * @since 7.1.0 Added the `generate_sub_sizes`, `convert_format`, and `url` parameters. * * @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. @@ -317,6 +323,18 @@ public function create_item( $request ) { 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 ) ) { @@ -410,6 +428,95 @@ public function create_item( $request ) { return $response; } + /** + * Sideloads an external image from a URL into the media library. + * + * 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(). + * + * @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. + */ + protected function create_item_from_url( $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() ) + ); + } + + 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 ); + $filename = $url_path ? wp_basename( $url_path ) : ''; + if ( '' === $filename ) { + return new WP_Error( + 'rest_invalid_url', + __( 'Could not determine a filename from the provided URL.' ), + array( 'status' => 400 ) + ); + } + + /* + * Download the remote file with WordPress's HTTP API, which validates + * the host and blocks requests to private or local addresses. This is + * the same primitive core's media_sideload_image() relies on. + */ + $tmp_file = download_url( $url ); + if ( is_wp_error( $tmp_file ) ) { + return $tmp_file; + } + + $file_array = array( + 'name' => $filename, + 'tmp_name' => $tmp_file, + ); + + $attachment_id = media_handle_sideload( $file_array, $post_id ); + + if ( is_wp_error( $attachment_id ) ) { + /* + * media_handle_sideload() deletes the temp file on success; remove + * it explicitly when the sideload 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 ); + + $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 $response; + } + /** * Removes filters added for client-side media processing. * diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 79e9d23cf9dd3..4a75a65d42fe5 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3541,4 +3541,317 @@ public function test_finalize_item_invalid_id(): void { $this->assertErrorResponse( 'rest_post_invalid_id', $response, 404 ); } + + /** + * The URL requested by the most recent mocked HTTP download. + * + * @var string|null + */ + protected $last_download_url = null; + + /** + * Short-circuits download_url()'s HTTP request, writing a local fixture into + * the streamed temp file so media_handle_sideload() has a real image to process. + * + * Mirrors the approach core's media_sideload_image() tests use: returning a + * non-false value from `pre_http_request` skips the network, so the mock must + * copy the fixture into the `filename` the request would have streamed to. + * + * @param false|array|WP_Error $response A preempted response, or false to continue. + * @param array $args HTTP request arguments. + * @param string $url The request URL. + * @return array A faked 200 response. + */ + public function mock_image_download( $response, $args, $url ) { + $this->last_download_url = $url; + + if ( ! empty( $args['filename'] ) ) { + copy( DIR_TESTDATA . '/images/canola.jpg', $args['filename'] ); + } + + return array( + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + 'headers' => array(), + 'cookies' => array(), + 'body' => '', + ); + } + + /** + * Verifies that supplying a `url` to the create endpoint sideloads the remote + * image on the server and, with generate_sub_sizes=false, creates no sub-sizes. + * + * This is the cross-origin-isolation fallback path: the server fetches the + * remote image so the browser does not have to, and only the original is kept. + * + * @ticket 65517 + * + * @covers WP_REST_Attachments_Controller::create_item + * @covers WP_REST_Attachments_Controller::create_item_from_url + */ + public function test_create_item_from_url_sideloads_without_subsizes() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$superadmin_id ); + + add_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10, 3 ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_param( 'url', 'https://example.com/photo.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10 ); + + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status() ); + $this->assertSame( 'image', $data['media_type'] ); + $this->assertSame( 'https://example.com/photo.jpg', $this->last_download_url ); + + // No sub-sizes should have been generated; only the original is stored. + $metadata = wp_get_attachment_metadata( $data['id'], true ); + $this->assertEmpty( $metadata['sizes'] ?? array(), 'Sideloaded external image should have no sub-sizes.' ); + } + + /** + * Verifies that, with the default generate_sub_sizes (true), sideloading an + * external image generates sub-sizes, so the filters applied in create_item() + * still govern derivative generation on the URL path. + * + * @ticket 65517 + * + * @covers WP_REST_Attachments_Controller::create_item + * @covers WP_REST_Attachments_Controller::create_item_from_url + */ + public function test_create_item_from_url_generates_subsizes_by_default() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$superadmin_id ); + + add_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10, 3 ); + + // Note: generate_sub_sizes is intentionally not set, so it defaults to true. + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_param( 'url', 'https://example.com/full.jpg' ); + + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10 ); + + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status() ); + + $metadata = wp_get_attachment_metadata( $data['id'], true ); + $this->assertNotEmpty( $metadata['sizes'] ?? array(), 'Sub-sizes should be generated when generate_sub_sizes is true.' ); + } + + /** + * Verifies that the REST-specific rest_after_insert_attachment action fires on + * the URL sideload path, for parity with the uploaded-file path. + * + * @ticket 65517 + * + * @covers WP_REST_Attachments_Controller::create_item_from_url + */ + public function test_create_item_from_url_fires_rest_after_insert_attachment() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$superadmin_id ); + + $fired = array(); + $spy = static function ( $attachment, $request, $creating ) use ( &$fired ) { + $fired = array( + 'id' => $attachment->ID, + 'creating' => $creating, + ); + }; + + add_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10, 3 ); + add_action( 'rest_after_insert_attachment', $spy, 10, 3 ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_param( 'url', 'https://example.com/hooked.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + + $response = rest_get_server()->dispatch( $request ); + + remove_action( 'rest_after_insert_attachment', $spy, 10 ); + remove_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10 ); + + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status() ); + $this->assertSame( $data['id'], $fired['id'] ?? null, 'rest_after_insert_attachment should fire with the new attachment.' ); + $this->assertTrue( $fired['creating'] ?? null, 'rest_after_insert_attachment should report creating=true.' ); + } + + /** + * Verifies that a sideloaded external image is attached to the post passed in + * the `post` parameter. + * + * @ticket 65517 + * + * @covers WP_REST_Attachments_Controller::create_item + * @covers WP_REST_Attachments_Controller::create_item_from_url + */ + public function test_create_item_from_url_attaches_to_post() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$superadmin_id ); + + $parent_post = self::factory()->post->create(); + + add_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10, 3 ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_param( 'url', 'https://example.com/attached.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + $request->set_param( 'post', $parent_post ); + + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10 ); + + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status() ); + $this->assertSame( $parent_post, get_post( $data['id'] )->post_parent ); + } + + /** + * Verifies that a failed download propagates the WP_Error from download_url() + * rather than creating an attachment. + * + * @ticket 65517 + * + * @covers WP_REST_Attachments_Controller::create_item + * @covers WP_REST_Attachments_Controller::create_item_from_url + */ + public function test_create_item_from_url_returns_error_on_download_failure() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$superadmin_id ); + + $fail_download = static function () { + return new WP_Error( 'http_request_failed', 'Could not resolve host.' ); + }; + add_filter( 'pre_http_request', $fail_download ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_param( 'url', 'https://example.com/missing.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'pre_http_request', $fail_download ); + + $this->assertSame( 'http_request_failed', $response->get_data()['code'] ); + $this->assertSame( 500, $response->get_status() ); + } + + /** + * Verifies that a URL with no usable path bails with a 400 before any + * download is attempted, rather than handing an empty filename to the + * sideload handler. + * + * @ticket 65517 + * + * @covers WP_REST_Attachments_Controller::create_item_from_url + */ + public function test_create_item_from_url_rejects_url_without_filename() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$superadmin_id ); + + // Fail loudly if the guard does not bail and a download is attempted. + $downloaded = false; + $track = static function () use ( &$downloaded ) { + $downloaded = true; + return new WP_Error( 'http_request_failed', 'Should not be reached.' ); + }; + add_filter( 'pre_http_request', $track ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_param( 'url', 'https://example.com/?img=123' ); + + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'pre_http_request', $track ); + + $this->assertSame( 'rest_invalid_url', $response->get_data()['code'] ); + $this->assertSame( 400, $response->get_status() ); + $this->assertFalse( $downloaded, 'No download should be attempted for a URL without a filename.' ); + } + + /** + * Verifies that a user without the `upload_files` capability cannot sideload + * an external image and that the request bails before any download happens. + * + * @ticket 65517 + * + * @covers WP_REST_Attachments_Controller::create_item_from_url + */ + public function test_create_item_from_url_requires_upload_capability() { + $subscriber_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $subscriber_id ); + + // Fail loudly if the guard does not bail and a download is attempted. + $downloaded = false; + $track = static function () use ( &$downloaded ) { + $downloaded = true; + return new WP_Error( 'http_request_failed', 'Should not be reached.' ); + }; + add_filter( 'pre_http_request', $track ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_param( 'url', 'https://example.com/denied.jpg' ); + + $controller = new WP_REST_Attachments_Controller( 'attachment' ); + $method = new ReflectionMethod( $controller, 'create_item_from_url' ); + if ( PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + $result = $method->invoke( $controller, $request ); + + remove_filter( 'pre_http_request', $track ); + + $this->assertWPError( $result ); + $this->assertSame( 'rest_cannot_create', $result->get_error_code() ); + $this->assertSame( 403, $result->get_error_data()['status'] ); + $this->assertFalse( $downloaded, 'No download should be attempted without upload_files.' ); + } + + /** + * Verifies that the `url` argument is registered on the creatable media route + * so requests can supply an external image URL to sideload. + * + * @ticket 65517 + * + * @covers WP_REST_Attachments_Controller::get_endpoint_args_for_item_schema + */ + public function test_url_registered_as_creatable_arg() { + $this->enable_client_side_media_processing(); + + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( '/wp/v2/media', $routes ); + + $creatable = null; + foreach ( $routes['/wp/v2/media'] as $route ) { + if ( ! empty( $route['methods'][ WP_REST_Server::CREATABLE ] ) ) { + $creatable = $route; + break; + } + } + + $this->assertNotNull( $creatable, 'The media route should register a CREATABLE handler.' ); + $this->assertArrayHasKey( 'url', $creatable['args'] ); + $this->assertSame( 'string', $creatable['args']['url']['type'] ); + $this->assertSame( 'uri', $creatable['args']['url']['format'] ); + } } diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index fa03d9751fe99..7dcb7f7f54734 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -3160,6 +3160,12 @@ mockedApiResponse.Schema = { "default": true, "description": "Whether to convert image formats.", "required": false + }, + "url": { + "type": "string", + "format": "uri", + "description": "URL of an external image to sideload into the media library, instead of uploading a file.", + "required": false } } }