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..2a14083968635 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 @@ -1603,24 +1603,12 @@ public function prepare_item_for_response( $item, $request ) { $mime_type = (string) get_post_mime_type( $post ); /* - * Per-file output format for images, evaluated with the real filename - * and MIME type so plugins filtering image_editor_output_format can - * make per-attachment decisions (e.g. JPEG -> WebP). Resolved the same - * way WP_Image_Editor::set_quality() resolves the output format. + * Per-file output format for images, so plugins filtering + * image_editor_output_format can make per-attachment decisions + * (e.g. JPEG -> WebP). */ if ( in_array( 'image_output_format', $fields, true ) ) { - $filename = get_attached_file( $post->ID ); - - /** This filter is documented in wp-includes/media.php */ - $output_formats = apply_filters( - 'image_editor_output_format', - array( $mime_type => $mime_type ), - $filename ? $filename : '', - $mime_type - ); - - $output_mime = $output_formats[ $mime_type ] ?? $mime_type; - $data['image_output_format'] = ( $output_mime !== $mime_type ) ? $output_mime : null; + $data['image_output_format'] = $this->get_image_output_format( $post ); } /* @@ -2783,6 +2771,34 @@ private function validate_image_dimensions( int $width, int $height, string $ima return true; } + /** + * Gets the MIME type an image attachment's sub-sizes are generated in. + * + * Resolved the same way WP_Image_Editor::set_quality() resolves the output + * format, with the attachment's real file name and MIME type. + * + * @since 7.2.0 + * + * @param WP_Post $post Image attachment post object. + * @return string|null The output MIME type, or null if it matches the attachment's own. + */ + private function get_image_output_format( WP_Post $post ): ?string { + $mime_type = (string) get_post_mime_type( $post ); + $filename = get_attached_file( $post->ID ); + + /** This filter is documented in wp-includes/media.php */ + $output_formats = apply_filters( + 'image_editor_output_format', + array( $mime_type => $mime_type ), + $filename ? $filename : '', + $mime_type + ); + + $output_mime = is_array( $output_formats ) ? ( $output_formats[ $mime_type ] ?? $mime_type ) : $mime_type; + + return ( is_string( $output_mime ) && $output_mime !== $mime_type ) ? $output_mime : null; + } + /** * Checks whether a dimension exceeds the maximum allowed value. * @@ -2847,6 +2863,25 @@ public function sideload_item( WP_REST_Request $request ) { ); } + /* + * The server writes sub-sizes in the attachment's output format without + * checking it against the network's allowed upload file types, so a + * sideload in that format is exempt from them too. Resolved before the + * output format filter below is added. + */ + $output_format = wp_attachment_is_image( $post ) ? $this->get_image_output_format( $post ) : null; + $output_extensions = null !== $output_format ? array_search( $output_format, wp_get_mime_types(), true ) : false; + $filter_filetypes = null; + + if ( is_string( $output_extensions ) ) { + $filter_filetypes = static function ( $filetypes ) use ( $output_extensions ) { + return is_string( $filetypes ) ? $filetypes . ' ' . str_replace( '|', ' ', $output_extensions ) : $filetypes; + }; + + add_filter( 'site_option_upload_filetypes', $filter_filetypes ); + add_filter( 'default_site_option_upload_filetypes', $filter_filetypes ); + } + if ( false === $request['convert_format'] ) { // Prevent image conversion as that is done client-side. add_filter( 'image_editor_output_format', '__return_empty_array', 100 ); @@ -2898,6 +2933,11 @@ public function sideload_item( WP_REST_Request $request ) { remove_filter( 'image_editor_output_format', '__return_empty_array', 100 ); remove_filter( 'upload_dir', $filter_upload_dir, 100 ); + if ( null !== $filter_filetypes ) { + remove_filter( 'site_option_upload_filetypes', $filter_filetypes ); + remove_filter( 'default_site_option_upload_filetypes', $filter_filetypes ); + } + 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 a8499a34ed274..56a62748a1c80 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -2854,6 +2854,149 @@ 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 to the + * network's allowed upload file types. + * + * The image editor writes sub-sizes straight to disk, so a network that + * does not allow WebP uploads still gets WebP sub-sizes when an + * `image_editor_output_format` filter asks for them. This is the + * baseline for test_sideload_sub_size_in_output_format_not_allowed_by_network(). + * + * @ticket 66681 + * + * @group multisite + * @group ms-required + * @covers ::wp_generate_attachment_metadata + */ + public function test_server_sub_sizes_in_output_format_not_allowed_by_network(): void { + if ( ! wp_image_editor_supports( array( 'mime_type' => 'image/webp' ) ) ) { + $this->markTestSkipped( 'This test requires an image editor that can save WebP.' ); + } + + /* + * Networks created before WordPress 5.8 have no `webp` in this list, + * and upgrades never add it. This is also the list + * check_upload_mimes() falls back to. + */ + update_site_option( 'upload_filetypes', 'jpg jpeg png gif' ); + $this->assertNotContains( 'image/webp', get_allowed_mime_types(), 'The network should not allow WebP uploads.' ); + + add_filter( 'image_editor_output_format', array( $this, 'filter_output_format_jpeg_to_webp' ) ); + + $attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); + $this->assertIsInt( $attachment_id ); + + $metadata = wp_get_attachment_metadata( $attachment_id ); + $this->assertIsArray( $metadata ); + $this->assertArrayHasKey( 'sizes', $metadata ); + + $this->assertArrayHasKey( 'thumbnail', $metadata['sizes'], 'The server should generate a thumbnail.' ); + $this->assertSame( 'image/webp', $metadata['sizes']['thumbnail']['mime-type'], 'The thumbnail should be a WebP.' ); + } + + /** + * Tests that a client-generated sub-size can be sideloaded in an output + * format the network does not allow for upload. + * + * With client-side media processing, the browser transcodes each sub-size + * to the `image_output_format` returned by the upload, then sideloads it. + * The server generates the same sub-size itself on the classic path (see + * test_server_sub_sizes_in_output_format_not_allowed_by_network()), so the + * sideload should not be rejected for its file type. + * + * @ticket 66681 + * + * @group multisite + * @group ms-required + * @covers WP_REST_Attachments_Controller::sideload_item + */ + public function test_sideload_sub_size_in_output_format_not_allowed_by_network(): void { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + // See test_server_sub_sizes_in_output_format_not_allowed_by_network(). + update_site_option( 'upload_filetypes', 'jpg jpeg png gif' ); + $this->assertNotContains( 'image/webp', get_allowed_mime_types(), 'The network should not allow WebP uploads.' ); + + add_filter( 'image_editor_output_format', array( $this, 'filter_output_format_jpeg_to_webp' ) ); + + $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 ); + $this->assertIsInt( $data['id'] ); + + $attachment_id = $data['id']; + + // A WebP 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/webp' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=canola-50x50.webp' ); + $request->set_param( 'image_size', 'thumbnail' ); + $request->set_param( 'convert_format', false ); + $request->set_body( (string) file_get_contents( DIR_TESTDATA . '/images/test-image.webp' ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( + 200, + $response->get_status(), + 'Sideloading a WebP sub-size should succeed. Response: ' . wp_json_encode( $response->get_data() ) + ); + } + + /** + * Tests that a sideload is still limited to the network's allowed upload + * file types when it is not in the attachment's output format. + * + * @ticket 66681 + * + * @group multisite + * @group ms-required + * @covers WP_REST_Attachments_Controller::sideload_item + */ + public function test_sideload_sub_size_not_in_output_format_not_allowed_by_network(): void { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + update_site_option( 'upload_filetypes', 'jpg jpeg png gif' ); + + $attachment_id = self::factory()->attachment->create_upload_object( self::$test_file ); + $this->assertIsInt( $attachment_id ); + + // No output format filter, so the sub-sizes of a JPEG are JPEGs. + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" ); + $request->set_header( 'Content-Type', 'image/webp' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=canola-50x50.webp' ); + $request->set_param( 'image_size', 'thumbnail' ); + $request->set_param( 'convert_format', false ); + $request->set_body( (string) file_get_contents( DIR_TESTDATA . '/images/test-image.webp' ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_upload_sideload_error', $response, 500 ); + $this->assertNotContains( 'image/webp', get_allowed_mime_types(), 'The network should still not allow WebP uploads.' ); + } + + /** + * Maps JPEG output to WebP, as image format plugins do. + * + * @param array $formats Output formats keyed by source mime type. + * @return array The filtered output formats. + */ + public function filter_output_format_jpeg_to_webp( $formats ) { + $formats['image/jpeg'] = 'image/webp'; + return $formats; + } + /** * Ensure the `rest_after_insert_attachment` and `rest_insert_attachment` hooks only fire * once when attachments are created.