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 0277a7dd44f76..15fde6ccff001 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 @@ -34,6 +34,17 @@ class WP_REST_Attachments_Controller extends WP_REST_Posts_Controller { */ protected $allow_batch = false; + /** + * Whether check_upload_size() skips the multisite upload space checks. + * + * Set while a file is sideloaded for an existing attachment. The maximum + * upload file size still applies. + * + * @since 7.2.0 + * @var bool + */ + private bool $skip_upload_space_check = false; + /** * Image size token for the source-format original preserved alongside a * client-generated derivative (e.g. the HEIC file kept next to its JPEG). @@ -2284,7 +2295,7 @@ protected function check_upload_size( $file ) { $file_size = filesize( $file['tmp_name'] ); - if ( $space_left < $file_size ) { + if ( ! $this->skip_upload_space_check && $space_left < $file_size ) { return new WP_Error( 'rest_upload_limited_space', /* translators: %s: Required disk space in kilobytes. */ @@ -2305,7 +2316,7 @@ protected function check_upload_size( $file ) { // Include multisite admin functions to get access to upload_is_user_over_quota(). require_once ABSPATH . 'wp-admin/includes/ms.php'; - if ( upload_is_user_over_quota( false ) ) { + if ( ! $this->skip_upload_space_check && upload_is_user_over_quota( false ) ) { return new WP_Error( 'rest_upload_user_quota_exceeded', __( 'You have used your space quota. Please delete files before uploading.' ), @@ -2803,6 +2814,8 @@ private function dimension_exceeds_max( int $value, int $max, int $tolerance ): * Side-loads a media file without creating a new attachment. * * @since 7.1.0 + * @since 7.2.0 The file is no longer checked against the multisite upload space. + * The maximum upload file size still applies. * * @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. @@ -2888,12 +2901,22 @@ public function sideload_item( WP_REST_Request $request ) { add_filter( 'upload_dir', $filter_upload_dir, 100 ); + /* + * On multisite, only the original upload counts against the site's + * upload space: the sub-sizes the server generates from it are written + * without a check. A sideloaded file is such a sub-size, so skip the + * check rather than leave the attachment with some of its sizes missing. + */ + $this->skip_upload_space_check = true; + if ( ! empty( $files ) ) { $file = $this->upload_from_file( $files, $headers ); } else { $file = $this->upload_from_data( $request->get_body(), $headers ); } + $this->skip_upload_space_check = false; + 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 ); diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index a8499a34ed274..4bea5bcca10e6 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -2854,6 +2854,192 @@ public function test_create_item_with_data_exceeds_multisite_site_upload_space() $this->assertErrorResponse( 'rest_upload_limited_space', $response, 400 ); } + /** + * Tests that sub-sizes generated on the server are not limited by the + * site's upload space. + * + * On the classic path, only the original upload is checked against the + * space quota (check_upload_size() on `wp_handle_upload_prefilter`). The + * image editor then writes sub-sizes even when that upload used the last + * of the space. This is the baseline for + * test_sideload_sub_size_after_multisite_upload_space_is_used(). + * + * @ticket 66680 + * @group multisite + * @group ms-required + * @covers ::wp_update_image_subsizes + */ + public function test_server_sub_sizes_after_multisite_upload_space_is_used(): void { + // Upload the original without sub-sizes, while there is space left. + add_filter( 'intermediate_image_sizes_advanced', '__return_empty_array' ); + $attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); + remove_filter( 'intermediate_image_sizes_advanced', '__return_empty_array' ); + + $this->assertIsInt( $attachment_id, 'The original upload should succeed.' ); + + $metadata = wp_get_attachment_metadata( $attachment_id ); + + $this->assertIsArray( $metadata, 'The original should have metadata.' ); + $this->assertEmpty( $metadata['sizes'] ?? array(), 'The original should have no sub-sizes yet.' ); + + // The original used the last of the space. + add_filter( 'get_space_allowed', '__return_zero' ); + update_site_option( 'upload_space_check_disabled', false ); + $this->assertSame( 0, get_upload_space_available(), 'The site should have no upload space left.' ); + + $metadata = wp_update_image_subsizes( $attachment_id ); + + $this->assertIsArray( $metadata, 'Generating sub-sizes should succeed.' ); + $this->assertIsArray( $metadata['sizes'], 'The metadata should list the sub-sizes.' ); + $this->assertArrayHasKey( 'thumbnail', $metadata['sizes'], 'The server should generate a thumbnail.' ); + } + + /** + * Tests that a client-generated sub-size can be sideloaded after the + * original upload used the last of the site's upload space. + * + * With client-side media processing, the browser uploads the original, + * then sideloads each sub-size. The server generates the same sub-sizes + * regardless of the quota on the classic path (see + * test_server_sub_sizes_after_multisite_upload_space_is_used()), so the + * sideload should not be rejected for lack of space. + * + * @ticket 66680 + * @group multisite + * @group ms-required + * @covers WP_REST_Attachments_Controller::sideload_item + */ + public function test_sideload_sub_size_after_multisite_upload_space_is_used(): void { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + update_site_option( 'upload_space_check_disabled', false ); + + // Upload the original while there is space left. + $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_param( 'generate_sub_sizes', false ); + $request->set_body( (string) file_get_contents( self::$test_file ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 201, $response->get_status(), 'The original upload should succeed.' ); + + $data = $response->get_data(); + + $this->assertIsArray( $data, 'The upload response should describe the attachment.' ); + $this->assertIsInt( $data['id'], 'The upload response should include the attachment ID.' ); + + $attachment_id = $data['id']; + + // The original used the last of the space. + add_filter( 'get_space_allowed', '__return_zero' ); + $this->assertSame( 0, get_upload_space_available(), 'The site should have no upload space left.' ); + + // A thumbnail, as the client would sideload it. + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=canola-50x50.jpg' ); + $request->set_param( 'image_size', 'thumbnail' ); + $request->set_body( (string) file_get_contents( DIR_TESTDATA . '/images/test-image.jpg' ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( + 200, + $response->get_status(), + 'Sideloading a sub-size should succeed. Response: ' . wp_json_encode( $response->get_data() ) + ); + } + + /** + * Tests that a sub-size sideloaded as a multipart file, the way the browser + * sends it, skips the upload space check without disabling it for the + * uploads that follow. + * + * @ticket 66680 + * @group multisite + * @group ms-required + * @covers WP_REST_Attachments_Controller::sideload_item + */ + public function test_sideload_sub_size_file_skips_multisite_upload_space_check_once(): void { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + update_site_option( 'upload_space_check_disabled', false ); + + $attachment_id = self::factory()->attachment->create_upload_object( self::$test_file ); + + $this->assertIsInt( $attachment_id, 'The original upload should succeed.' ); + + // The original used the last of the space. + add_filter( 'get_space_allowed', '__return_zero' ); + + // wp_handle_upload() removes the temporary file, so sideload a copy. + $sub_size_file = wp_tempnam( 'canola-50x50.jpg' ); + copy( DIR_TESTDATA . '/images/test-image.jpg', $sub_size_file ); + + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" ); + $request->set_file_params( + array( + 'file' => array( + 'error' => UPLOAD_ERR_OK, + 'name' => 'canola-50x50.jpg', + 'type' => 'image/jpeg', + 'size' => (int) filesize( $sub_size_file ), + 'tmp_name' => $sub_size_file, + ), + ) + ); + $request->set_param( 'image_size', 'thumbnail' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( + 200, + $response->get_status(), + 'Sideloading a sub-size should succeed. Response: ' . wp_json_encode( $response->get_data() ) + ); + + // A new upload is still checked against the upload space. + $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( (string) file_get_contents( self::$test_file ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_upload_limited_space', $response, 400 ); + } + + /** + * Tests that a sideloaded sub-size is still held to the network's maximum + * upload file size. + * + * @ticket 66680 + * @group multisite + * @group ms-required + * @covers WP_REST_Attachments_Controller::sideload_item + */ + public function test_sideload_sub_size_exceeds_multisite_max_filesize(): void { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + update_site_option( 'upload_space_check_disabled', false ); + + $attachment_id = self::factory()->attachment->create_upload_object( self::$test_file ); + + $this->assertIsInt( $attachment_id, 'The original upload should succeed.' ); + + update_site_option( 'fileupload_maxk', 1 ); + + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=canola-50x50.jpg' ); + $request->set_param( 'image_size', 'thumbnail' ); + $request->set_body( (string) file_get_contents( DIR_TESTDATA . '/images/test-image.jpg' ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_upload_file_too_big', $response, 400 ); + } + /** * Ensure the `rest_after_insert_attachment` and `rest_insert_attachment` hooks only fire * once when attachments are created.