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 f336f321a9ea2..f42ae0edf8e84 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 @@ -462,6 +462,29 @@ public function create_item( $request ) { ); } + /* + * 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 ); @@ -480,18 +503,6 @@ 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 ) ) { @@ -586,33 +597,23 @@ public function create_item( $request ) { } /** - * 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( $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 ); @@ -705,36 +706,25 @@ protected function create_item_from_url( $request ) { ); } - $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 ); - - $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; } /** @@ -774,7 +764,16 @@ protected function insert_attachment( $request ) { } } - 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 ); @@ -815,6 +814,11 @@ protected function insert_attachment( $request ) { $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/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 7f4dcd06c2f71..20084f3006d82 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -5353,7 +5353,8 @@ public function mock_image_download( $response, $args, $url ) { * @ticket 65517 * * @covers WP_REST_Attachments_Controller::create_item - * @covers WP_REST_Attachments_Controller::create_item_from_url + * @covers WP_REST_Attachments_Controller::insert_attachment + * @covers WP_REST_Attachments_Controller::upload_from_url */ public function test_create_item_from_url_sideloads_without_subsizes() { $this->enable_client_side_media_processing(); @@ -5389,7 +5390,8 @@ public function test_create_item_from_url_sideloads_without_subsizes() { * @ticket 65517 * * @covers WP_REST_Attachments_Controller::create_item - * @covers WP_REST_Attachments_Controller::create_item_from_url + * @covers WP_REST_Attachments_Controller::insert_attachment + * @covers WP_REST_Attachments_Controller::upload_from_url */ public function test_create_item_from_url_generates_subsizes_by_default() { $this->enable_client_side_media_processing(); @@ -5420,7 +5422,8 @@ public function test_create_item_from_url_generates_subsizes_by_default() { * * @ticket 65517 * - * @covers WP_REST_Attachments_Controller::create_item_from_url + * @covers WP_REST_Attachments_Controller::insert_attachment + * @covers WP_REST_Attachments_Controller::upload_from_url */ public function test_create_item_from_url_fires_rest_after_insert_attachment() { $this->enable_client_side_media_processing(); @@ -5461,7 +5464,8 @@ public function test_create_item_from_url_fires_rest_after_insert_attachment() { * @ticket 65517 * * @covers WP_REST_Attachments_Controller::create_item - * @covers WP_REST_Attachments_Controller::create_item_from_url + * @covers WP_REST_Attachments_Controller::insert_attachment + * @covers WP_REST_Attachments_Controller::upload_from_url */ public function test_create_item_from_url_attaches_to_post() { $this->enable_client_side_media_processing(); @@ -5494,7 +5498,8 @@ public function test_create_item_from_url_attaches_to_post() { * @ticket 65517 * * @covers WP_REST_Attachments_Controller::create_item - * @covers WP_REST_Attachments_Controller::create_item_from_url + * @covers WP_REST_Attachments_Controller::insert_attachment + * @covers WP_REST_Attachments_Controller::upload_from_url */ public function test_create_item_from_url_returns_error_on_download_failure() { $this->enable_client_side_media_processing(); @@ -5526,7 +5531,8 @@ public function test_create_item_from_url_returns_error_on_download_failure() { * @group multisite * @group ms-required * - * @covers WP_REST_Attachments_Controller::create_item_from_url + * @covers WP_REST_Attachments_Controller::insert_attachment + * @covers WP_REST_Attachments_Controller::upload_from_url * @covers WP_REST_Attachments_Controller::check_upload_size */ public function test_create_item_from_url_exceeds_multisite_max_filesize() { @@ -5559,7 +5565,8 @@ public function test_create_item_from_url_exceeds_multisite_max_filesize() { * @group multisite * @group ms-required * - * @covers WP_REST_Attachments_Controller::create_item_from_url + * @covers WP_REST_Attachments_Controller::insert_attachment + * @covers WP_REST_Attachments_Controller::upload_from_url * @covers WP_REST_Attachments_Controller::check_upload_size */ public function test_create_item_from_url_exceeds_multisite_site_upload_space() { @@ -5591,7 +5598,8 @@ public function test_create_item_from_url_exceeds_multisite_site_upload_space() * * @ticket 65517 * - * @covers WP_REST_Attachments_Controller::create_item_from_url + * @covers WP_REST_Attachments_Controller::insert_attachment + * @covers WP_REST_Attachments_Controller::upload_from_url */ public function test_create_item_from_url_exceeds_max_upload_size() { $this->enable_client_side_media_processing(); @@ -5619,7 +5627,8 @@ public function test_create_item_from_url_exceeds_max_upload_size() { * * @ticket 65517 * - * @covers WP_REST_Attachments_Controller::create_item_from_url + * @covers WP_REST_Attachments_Controller::insert_attachment + * @covers WP_REST_Attachments_Controller::upload_from_url */ public function test_create_item_from_url_limits_the_download_size() { $this->enable_client_side_media_processing(); @@ -5681,7 +5690,8 @@ public function filter_small_upload_size_limit() { * * @ticket 65517 * - * @covers WP_REST_Attachments_Controller::create_item_from_url + * @covers WP_REST_Attachments_Controller::insert_attachment + * @covers WP_REST_Attachments_Controller::upload_from_url */ public function test_create_item_from_url_rejects_url_without_filename() { $this->enable_client_side_media_processing(); @@ -5716,7 +5726,8 @@ public function test_create_item_from_url_rejects_url_without_filename() { * * @dataProvider data_create_item_from_url_rejects_non_image_extension * - * @covers WP_REST_Attachments_Controller::create_item_from_url + * @covers WP_REST_Attachments_Controller::insert_attachment + * @covers WP_REST_Attachments_Controller::upload_from_url * * @param string $url URL with a disallowed file extension. */ @@ -5766,7 +5777,8 @@ public function data_create_item_from_url_rejects_non_image_extension() { * * @ticket 65517 * - * @covers WP_REST_Attachments_Controller::create_item_from_url + * @covers WP_REST_Attachments_Controller::insert_attachment + * @covers WP_REST_Attachments_Controller::upload_from_url */ public function test_create_item_from_url_requires_upload_capability() { $subscriber_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); @@ -5783,18 +5795,11 @@ public function test_create_item_from_url_requires_upload_capability() { $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 ); + $response = rest_get_server()->dispatch( $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->assertErrorResponse( 'rest_cannot_create', $response ); $this->assertFalse( $downloaded, 'No download should be attempted without upload_files.' ); } @@ -5886,7 +5891,8 @@ public function test_creatable_args_registered_without_client_side_media_process * @ticket 65517 * * @covers WP_REST_Attachments_Controller::create_item - * @covers WP_REST_Attachments_Controller::create_item_from_url + * @covers WP_REST_Attachments_Controller::insert_attachment + * @covers WP_REST_Attachments_Controller::upload_from_url */ public function test_create_item_from_url_without_client_side_media_processing() { $this->disable_client_side_media_processing(); @@ -6052,4 +6058,189 @@ public function test_url_arg_rejects_unsafe_urls() { $this->assertSame( 400, $result->get_error_data()['status'] ); } } + + /** + * Verifies that a request cannot supply both an uploaded file and a `url`. + * + * The two are alternative sources for the same attachment, so honoring one + * would silently discard the other. + * + * @ticket 65810 + * + * @covers WP_REST_Attachments_Controller::create_item + */ + public function test_create_item_rejects_a_file_and_url_together() { + wp_set_current_user( self::$superadmin_id ); + + // Fail loudly if the request is not rejected 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/photo.jpg' ); + $request->set_file_params( + array( + 'file' => array( + 'file' => file_get_contents( self::$test_file ), + 'name' => 'canola.jpg', + 'size' => filesize( self::$test_file ), + 'tmp_name' => self::$test_file, + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'pre_http_request', $track ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + $this->assertFalse( $downloaded, 'No download should be attempted when a file is also supplied.' ); + } + + /** + * Verifies that a request cannot supply both a raw body upload and a `url`. + * + * The raw body path is identified by its Content-Disposition filename, so + * the conflict has to be detected from the headers rather than from the + * file parameters. + * + * @ticket 65810 + * + * @covers WP_REST_Attachments_Controller::create_item + */ + public function test_create_item_rejects_a_body_upload_and_url_together() { + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' ); + $request->set_body( file_get_contents( self::$test_file ) ); + $request->set_param( 'url', 'https://example.com/photo.jpg' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + /** + * Verifies that the fields carried on the request are applied to an + * attachment created from a `url`, as they are for an uploaded file. + * + * The URL path previously returned before create_item() applied them, so + * they were accepted by the schema and then silently dropped. + * + * @ticket 65810 + * + * @covers WP_REST_Attachments_Controller::create_item + * @covers WP_REST_Attachments_Controller::insert_attachment + */ + public function test_create_item_from_url_applies_request_fields() { + 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/fields.jpg' ); + $request->set_param( 'title', 'My sideloaded title' ); + $request->set_param( 'caption', 'My sideloaded caption' ); + $request->set_param( 'description', 'My sideloaded description' ); + $request->set_param( 'alt_text', 'My sideloaded alt text' ); + + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10 ); + + $this->assertSame( 201, $response->get_status() ); + + $data = $response->get_data(); + $attachment = get_post( $data['id'] ); + + $this->assertSame( 'My sideloaded title', $attachment->post_title ); + $this->assertSame( 'My sideloaded caption', $attachment->post_excerpt ); + $this->assertSame( 'My sideloaded description', $attachment->post_content ); + $this->assertSame( 'My sideloaded alt text', get_post_meta( $attachment->ID, '_wp_attachment_image_alt', true ) ); + } + + /** + * Verifies that the insert hooks fire when an attachment is created from a + * `url`, as they do for an uploaded file. + * + * rest_pre_insert_attachment and rest_insert_attachment fire from + * prepare_item_for_database() and insert_attachment(), which the URL path + * did not reach, and wp_after_insert_post never fires for attachments from + * wp_insert_post() because it returns before the after-insert hooks. + * + * @ticket 65810 + * + * @covers WP_REST_Attachments_Controller::create_item + * @covers WP_REST_Attachments_Controller::insert_attachment + */ + public function test_create_item_from_url_fires_the_insert_hooks() { + wp_set_current_user( self::$superadmin_id ); + + $fired = array(); + $spy = static function ( $hook ) use ( &$fired ) { + return static function ( $value ) use ( $hook, &$fired ) { + $fired[] = $hook; + return $value; + }; + }; + + $pre = $spy( 'rest_pre_insert_attachment' ); + $insert = $spy( 'rest_insert_attachment' ); + $after = $spy( 'wp_after_insert_post' ); + + add_filter( 'rest_pre_insert_attachment', $pre ); + add_action( 'rest_insert_attachment', $insert ); + add_action( 'wp_after_insert_post', $after ); + 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/hooks.jpg' ); + + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10 ); + remove_action( 'wp_after_insert_post', $after ); + remove_action( 'rest_insert_attachment', $insert ); + remove_filter( 'rest_pre_insert_attachment', $pre ); + + $this->assertSame( 201, $response->get_status() ); + $this->assertContains( 'rest_pre_insert_attachment', $fired ); + $this->assertContains( 'rest_insert_attachment', $fired ); + $this->assertContains( 'wp_after_insert_post', $fired ); + } + + /** + * Verifies that an error returned from the rest_pre_insert_attachment + * filter is honored rather than used as if it were an attachment. + * + * @ticket 65810 + * + * @covers WP_REST_Attachments_Controller::insert_attachment + */ + public function test_create_item_from_url_honors_a_pre_insert_error() { + wp_set_current_user( self::$superadmin_id ); + + $deny = static function () { + return new WP_Error( 'rest_pre_insert_denied', 'Denied.', array( 'status' => 400 ) ); + }; + + add_filter( 'rest_pre_insert_attachment', $deny ); + 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/denied-by-filter.jpg' ); + + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10 ); + remove_filter( 'rest_pre_insert_attachment', $deny ); + + $this->assertErrorResponse( 'rest_pre_insert_denied', $response, 400 ); + } }