From cf6dc25d35ea2e1f7a1c6ce16a6e9a1fd303d8d1 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Mon, 9 Nov 2026 14:58:55 -0500 Subject: [PATCH 1/5] Tests: Demonstrate multisite quota checks on sideloaded sub-sizes. On the classic path, only the original upload is checked against the site's upload space. The image editor then writes sub-sizes even when the original used the last of it. With client-side media processing, every sub-size is sideloaded and checked against the quota on its own, so it is rejected with "Not enough space to upload." and the attachment is left with missing sub-sizes, or deleted when they all fail. The first test is the classic baseline and passes. The second fails until the sideload endpoint stops checking the quota for an attachment's own sub-sizes. Co-Authored-By: Claude Opus 5.5 --- .../rest-api/rest-attachments-controller.php | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index a8499a34ed274..591c442d044ab 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -2854,6 +2854,91 @@ 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(). + * + * @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->assertEmpty( wp_get_attachment_metadata( $attachment_id )['sizes'], '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->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, and every sideload is checked against the + * space quota. 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. + * + * @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( file_get_contents( self::$test_file ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 201, $response->get_status(), 'The original upload should succeed.' ); + + $attachment_id = $response->get_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( 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() ) + ); + } + /** * Ensure the `rest_after_insert_attachment` and `rest_insert_attachment` hooks only fire * once when attachments are created. From edd6d80b1a430dd71c00c8c87b3b24ed07240aa1 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 12 Nov 2026 10:33:23 -0800 Subject: [PATCH 2/5] Media: Skip the multisite upload space check for sideloaded sub-sizes. On multisite, the classic upload path checks only the original file against the site's upload space. The sub-sizes the server generates from it are written without a check. With client-side media processing each sub-size is sideloaded and was checked on its own, so a site close to its quota rejected some of them and left the attachment with missing sizes. Disable the space check while a sideloaded file is stored, so both paths treat an attachment's sub-sizes the same way. New uploads are still checked. See #66680. Claude-Session: https://claude.ai/code/session_018vwYjSXLpsi7LrGqx7UsrL --- .../endpoints/class-wp-rest-attachments-controller.php | 10 ++++++++++ 1 file changed, 10 insertions(+) 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..922318b0201b4 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 @@ -2803,6 +2803,7 @@ 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. * * @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,6 +2889,14 @@ 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. + */ + add_filter( 'pre_site_option_upload_space_check_disabled', '__return_true', 100 ); + if ( ! empty( $files ) ) { $file = $this->upload_from_file( $files, $headers ); } else { @@ -2897,6 +2906,7 @@ public function sideload_item( WP_REST_Request $request ) { 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 ); + remove_filter( 'pre_site_option_upload_space_check_disabled', '__return_true', 100 ); if ( is_wp_error( $file ) ) { return $file; From a2f1f54b77a93fdae2158d1e7c8f5f208dad2f47 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 12 Nov 2026 10:33:23 -0800 Subject: [PATCH 3/5] Tests: Reference the ticket and tighten types in the upload space tests. Add the `@ticket` annotation, assert the types PHPStan cannot infer, and drop the sentence describing the sideload check, which no longer runs. See #66680. Claude-Session: https://claude.ai/code/session_018vwYjSXLpsi7LrGqx7UsrL --- .../rest-api/rest-attachments-controller.php | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 591c442d044ab..ac0a6fdacccb9 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -2864,6 +2864,7 @@ public function test_create_item_with_data_exceeds_multisite_site_upload_space() * 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 @@ -2874,7 +2875,12 @@ public function test_server_sub_sizes_after_multisite_upload_space_is_used(): vo $attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); remove_filter( 'intermediate_image_sizes_advanced', '__return_empty_array' ); - $this->assertEmpty( wp_get_attachment_metadata( $attachment_id )['sizes'], 'The original should have no sub-sizes yet.' ); + $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' ); @@ -2884,6 +2890,7 @@ public function test_server_sub_sizes_after_multisite_upload_space_is_used(): vo $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.' ); } @@ -2892,12 +2899,12 @@ public function test_server_sub_sizes_after_multisite_upload_space_is_used(): vo * 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, and every sideload is checked against the - * space quota. The server generates the same sub-sizes regardless of the - * quota on the classic path (see + * 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 @@ -2913,12 +2920,17 @@ public function test_sideload_sub_size_after_multisite_upload_space_is_used(): v $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( file_get_contents( self::$test_file ) ); + $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.' ); - $attachment_id = $response->get_data()['id']; + $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' ); @@ -2929,7 +2941,7 @@ public function test_sideload_sub_size_after_multisite_upload_space_is_used(): v $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( file_get_contents( DIR_TESTDATA . '/images/test-image.jpg' ) ); + $request->set_body( (string) file_get_contents( DIR_TESTDATA . '/images/test-image.jpg' ) ); $response = rest_get_server()->dispatch( $request ); $this->assertSame( From 29e3d98edcc0bd015f35f744d1c0d8a654452aa2 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 12 Nov 2026 10:33:23 -0800 Subject: [PATCH 4/5] Tests: Cover a sub-size sideloaded as a multipart file. The browser sends each sub-size as multipart form data, which takes a different upload handler than the raw request body the existing test uses. Also confirm the upload space check still applies to the next new upload. See #66680. Claude-Session: https://claude.ai/code/session_018vwYjSXLpsi7LrGqx7UsrL --- .../rest-api/rest-attachments-controller.php | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index ac0a6fdacccb9..0e488889a57a8 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -2951,6 +2951,64 @@ public function test_sideload_sub_size_after_multisite_upload_space_is_used(): v ); } + /** + * 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 ); + } + /** * Ensure the `rest_after_insert_attachment` and `rest_insert_attachment` hooks only fire * once when attachments are created. From 573179ecc7366e00823f78b1b711f25d4efbc303 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 12 Nov 2026 10:37:40 -0800 Subject: [PATCH 5/5] Media: Keep the maximum upload file size for sideloaded sub-sizes. Disabling the whole upload space check for a sideload also dropped the network's per-file size limit, which is unrelated to the space a site has left. Skip only the two space checks with a flag set while the file is stored, so a sideloaded file is still held to `fileupload_maxk`. See #66680. Claude-Session: https://claude.ai/code/session_018vwYjSXLpsi7LrGqx7UsrL --- .../class-wp-rest-attachments-controller.php | 21 ++++++++++--- .../rest-api/rest-attachments-controller.php | 31 +++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) 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 922318b0201b4..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.' ), @@ -2804,6 +2815,7 @@ private function dimension_exceeds_max( int $value, int $max, int $tolerance ): * * @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. @@ -2895,7 +2907,7 @@ public function sideload_item( WP_REST_Request $request ) { * 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. */ - add_filter( 'pre_site_option_upload_space_check_disabled', '__return_true', 100 ); + $this->skip_upload_space_check = true; if ( ! empty( $files ) ) { $file = $this->upload_from_file( $files, $headers ); @@ -2903,10 +2915,11 @@ public function sideload_item( WP_REST_Request $request ) { $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 ); - remove_filter( 'pre_site_option_upload_space_check_disabled', '__return_true', 100 ); if ( is_wp_error( $file ) ) { return $file; diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 0e488889a57a8..4bea5bcca10e6 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3009,6 +3009,37 @@ public function test_sideload_sub_size_file_skips_multisite_upload_space_check_o $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.