From ad5accfc9e3ea39a527d86bf02b6793647f498c0 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 20 Mar 2026 10:37:18 -0700 Subject: [PATCH] Media: Re-introduce client-side media processing feature. Reverts the removal in [62081] now that WordPress 7.1 has forked. Restores all PHP functions, REST API endpoints, cross-origin isolation infrastructure, VIPS script module handling, build configuration, and associated tests. Props adamsilverstein, jorbin. See #64906. Co-Authored-By: Claude Opus 4.6 (1M context) --- Gruntfile.js | 4 +- src/wp-includes/default-filters.php | 7 + src/wp-includes/media-template.php | 44 ++ src/wp-includes/media.php | 231 ++++++++++ .../rest-api/class-wp-rest-server.php | 28 ++ .../class-wp-rest-attachments-controller.php | 399 ++++++++++++++++++ src/wp-includes/script-modules.php | 6 +- .../tests/media/wpCrossOriginIsolation.php | 365 ++++++++++++++++ .../tests/media/wpGetChromiumMajorVersion.php | 69 +++ .../rest-api/rest-attachments-controller.php | 350 +++++++++++++++ .../tests/rest-api/rest-schema-setup.php | 5 + .../tests/script-modules/wpScriptModules.php | 14 +- tests/qunit/fixtures/wp-api-generated.js | 111 +++++ tools/gutenberg/copy.js | 15 - 14 files changed, 1625 insertions(+), 23 deletions(-) create mode 100644 tests/phpunit/tests/media/wpCrossOriginIsolation.php create mode 100644 tests/phpunit/tests/media/wpGetChromiumMajorVersion.php diff --git a/Gruntfile.js b/Gruntfile.js index c925a4f567227..9a35f61e79496 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -659,7 +659,9 @@ module.exports = function(grunt) { src: [ '**/*', '!**/*.map', - '!vips/**', + // Skip non-minified VIPS files — they are ~16MB of inlined WASM + // with no debugging value over the minified versions. + '!vips/!(*.min).js', ], dest: WORKING_DIR + 'wp-includes/js/dist/script-modules/', } ], diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 4b6d9de25fa11..a1c2e4d93df87 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -678,6 +678,13 @@ add_action( 'plugins_loaded', '_wp_add_additional_image_sizes', 0 ); add_filter( 'plupload_default_settings', 'wp_show_heic_upload_error' ); +// Client-side media processing. +add_action( 'admin_init', 'wp_set_client_side_media_processing_flag' ); +// Cross-origin isolation for client-side media processing. +add_action( 'load-post.php', 'wp_set_up_cross_origin_isolation' ); +add_action( 'load-post-new.php', 'wp_set_up_cross_origin_isolation' ); +add_action( 'load-site-editor.php', 'wp_set_up_cross_origin_isolation' ); +add_action( 'load-widgets.php', 'wp_set_up_cross_origin_isolation' ); // Nav menu. add_filter( 'nav_menu_item_id', '_nav_menu_item_id_use_once', 10, 2 ); add_filter( 'nav_menu_css_class', 'wp_nav_menu_remove_menu_item_has_children_class', 10, 4 ); diff --git a/src/wp-includes/media-template.php b/src/wp-includes/media-template.php index 5fb6b5d894d9b..bc887bafd1197 100644 --- a/src/wp-includes/media-template.php +++ b/src/wp-includes/media-template.php @@ -156,6 +156,12 @@ class="wp-video-shortcode {{ classes.join( ' ' ) }}" function wp_print_media_templates() { $class = 'media-modal wp-core-ui'; + $is_cross_origin_isolation_enabled = wp_is_client_side_media_processing_enabled(); + + if ( $is_cross_origin_isolation_enabled ) { + ob_start(); + } + $alt_text_description = sprintf( /* translators: 1: Link to tutorial, 2: Additional link attributes, 3: Accessibility text. */ __( 'Learn how to describe the purpose of the image%3$s. Leave empty if the image is purely decorative.' ), @@ -1582,4 +1588,42 @@ function wp_print_media_templates() { * @since 3.5.0 */ do_action( 'print_media_templates' ); + + if ( $is_cross_origin_isolation_enabled ) { + $html = (string) ob_get_clean(); + + /* + * The media templates are inside ', + ), + 'cross-origin audio' => array( + '', + ), + 'cross-origin video' => array( + '', + ), + 'cross-origin link stylesheet' => array( + '', + ), + 'cross-origin source inside video' => array( + '', + ), + ); + } + + /** + * Verifies that certain elements do not get crossorigin="anonymous" added. + * + * Images are excluded because under Document-Isolation-Policy: + * isolate-and-credentialless, the browser handles cross-origin images + * in credentialless mode without needing explicit CORS headers. + * + * @ticket 64766 + * + * @runInSeparateProcess + * @preserveGlobalState disabled + * + * @dataProvider data_elements_that_should_not_get_crossorigin + * + * @param string $html HTML input to process. + */ + public function test_output_buffer_does_not_add_crossorigin( $html ) { + $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36'; + + ob_start(); + + wp_start_cross_origin_isolation_output_buffer(); + echo $html; + + ob_end_flush(); + $output = ob_get_clean(); + + $this->assertStringNotContainsString( 'crossorigin="anonymous"', $output ); + } + + /** + * Data provider for elements that should not receive crossorigin="anonymous". + * + * @return array[] + */ + public function data_elements_that_should_not_get_crossorigin() { + return array( + 'cross-origin img' => array( + '', + ), + 'cross-origin img with srcset' => array( + '', + ), + 'link with cross-origin imagesrcset only' => array( + '', + ), + 'relative URL script' => array( + '', + ), + ); + } + + /** + * Same-origin URLs should not get crossorigin="anonymous". + * + * Uses site_url() at runtime since the test domain varies by CI config. + * + * @ticket 64766 + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_output_buffer_does_not_add_crossorigin_to_same_origin() { + $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36'; + + ob_start(); + + wp_start_cross_origin_isolation_output_buffer(); + echo ''; + + ob_end_flush(); + $output = ob_get_clean(); + + $this->assertStringNotContainsString( 'crossorigin="anonymous"', $output ); + } + + /** + * Elements that already have a crossorigin attribute should not be modified. + * + * @ticket 64766 + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_output_buffer_does_not_override_existing_crossorigin() { + $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36'; + + ob_start(); + + wp_start_cross_origin_isolation_output_buffer(); + echo ''; + + ob_end_flush(); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'crossorigin="use-credentials"', $output, 'Existing crossorigin attribute should not be overridden.' ); + $this->assertStringNotContainsString( 'crossorigin="anonymous"', $output ); + } + + /** + * Multiple tags in the same output should each be handled correctly. + * + * @ticket 64766 + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_output_buffer_handles_mixed_tags() { + $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36'; + + ob_start(); + + wp_start_cross_origin_isolation_output_buffer(); + echo ''; + echo ''; + echo ''; + + ob_end_flush(); + $output = ob_get_clean(); + + // IMG should NOT have crossorigin. + $this->assertStringContainsString( '', $output, 'IMG should not be modified.' ); + + // Script and audio should have crossorigin. + $this->assertSame( 2, substr_count( $output, 'crossorigin="anonymous"' ), 'Script and audio should both get crossorigin, but not img.' ); + } +} diff --git a/tests/phpunit/tests/media/wpGetChromiumMajorVersion.php b/tests/phpunit/tests/media/wpGetChromiumMajorVersion.php new file mode 100644 index 0000000000000..7249d9b91b665 --- /dev/null +++ b/tests/phpunit/tests/media/wpGetChromiumMajorVersion.php @@ -0,0 +1,69 @@ +original_user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : null; + } + + public function tear_down() { + if ( null === $this->original_user_agent ) { + unset( $_SERVER['HTTP_USER_AGENT'] ); + } else { + $_SERVER['HTTP_USER_AGENT'] = $this->original_user_agent; + } + parent::tear_down(); + } + + /** + * @ticket 64766 + */ + public function test_returns_null_when_no_user_agent() { + unset( $_SERVER['HTTP_USER_AGENT'] ); + $this->assertNull( wp_get_chromium_major_version() ); + } + + /** + * @ticket 64766 + * + * @dataProvider data_user_agents + * + * @param string $user_agent The user agent string. + * @param int|null $expected The expected Chromium major version, or null. + */ + public function test_returns_expected_version( $user_agent, $expected ) { + $_SERVER['HTTP_USER_AGENT'] = $user_agent; + $this->assertSame( $expected, wp_get_chromium_major_version() ); + } + + /** + * Data provider for test_returns_expected_version. + * + * @return array[] + */ + public function data_user_agents() { + return array( + 'empty user agent' => array( '', null ), + 'Firefox' => array( 'Mozilla/5.0 (Windows NT 10.0; rv:128.0) Gecko/20100101 Firefox/128.0', null ), + 'Safari' => array( 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15', null ), + 'Chrome 137' => array( 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36', 137 ), + 'Edge 137' => array( 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Edg/137.0.0.0', 137 ), + 'Opera (Chrome 136)' => array( 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36 OPR/122.0.0.0', 136 ), + 'Chrome 100' => array( 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.75 Safari/537.36', 100 ), + ); + } +} diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index c8746931ed30a..79e9d23cf9dd3 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -194,6 +194,18 @@ public function tear_down() { parent::tear_down(); } + /** + * Enables client-side media processing and reinitializes the REST server + * so that the sideload and finalize routes are registered. + */ + private function enable_client_side_media_processing(): void { + add_filter( 'wp_client_side_media_processing_enabled', '__return_true' ); + + global $wp_rest_server; + $wp_rest_server = new Spy_REST_Server(); + do_action( 'rest_api_init', $wp_rest_server ); + } + public function test_register_routes() { $routes = rest_get_server()->get_routes(); $this->assertArrayHasKey( '/wp/v2/media', $routes ); @@ -2929,6 +2941,43 @@ public function test_upload_unsupported_image_type_with_filter() { $this->assertSame( 201, $response->get_status() ); } + /** + * Test that unsupported image type check is skipped when not generating sub-sizes. + * + * When the client handles image processing (generate_sub_sizes is false), + * the server should not check image editor support. + * + * Tests the permissions check directly with file params set, since the core + * check uses get_file_params() which is only populated for multipart uploads. + * + * @ticket 64836 + */ + public function test_upload_unsupported_image_type_skipped_when_not_generating_sub_sizes() { + wp_set_current_user( self::$author_id ); + + add_filter( 'wp_image_editors', '__return_empty_array' ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_file_params( + array( + 'file' => array( + 'name' => 'avif-lossy.avif', + 'type' => 'image/avif', + 'tmp_name' => self::$test_avif_file, + 'error' => 0, + 'size' => filesize( self::$test_avif_file ), + ), + ) + ); + $request->set_param( 'generate_sub_sizes', false ); + + $controller = new WP_REST_Attachments_Controller( 'attachment' ); + $result = $controller->create_item_permissions_check( $request ); + + // Should pass because generate_sub_sizes is false (client handles processing). + $this->assertTrue( $result ); + } + /** * Test that unsupported image type check is enforced when generating sub-sizes. * @@ -3191,4 +3240,305 @@ static function ( $data ) use ( &$captured_data ) { // Verify that the data is an array (not an object). $this->assertIsArray( $captured_data, 'Data passed to wp_insert_attachment should be an array' ); } + + /** + * Tests sideloading a scaled image for an existing attachment. + * + * @ticket 64737 + * @requires function imagejpeg + */ + public function test_sideload_scaled_image() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + // First, create an attachment. + $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 ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $attachment_id = $data['id']; + + $this->assertSame( 201, $response->get_status() ); + + $original_file = get_attached_file( $attachment_id, true ); + + // Sideload a "scaled" version of the image. + $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-scaled.jpg' ); + $request->set_param( 'image_size', 'scaled' ); + $request->set_body( file_get_contents( self::$test_file ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Sideloading scaled image should succeed.' ); + + $metadata = wp_get_attachment_metadata( $attachment_id ); + + // The original file should now be recorded as original_image. + $this->assertArrayHasKey( 'original_image', $metadata, 'Metadata should contain original_image.' ); + $this->assertSame( wp_basename( $original_file ), $metadata['original_image'], 'original_image should be the basename of the original attached file.' ); + + // The attached file should now point to the scaled version. + $new_file = get_attached_file( $attachment_id, true ); + $this->assertStringContainsString( 'scaled', wp_basename( $new_file ), 'Attached file should now be the scaled version.' ); + + // Metadata should have width, height, filesize, and file updated. + $this->assertArrayHasKey( 'width', $metadata, 'Metadata should contain width.' ); + $this->assertArrayHasKey( 'height', $metadata, 'Metadata should contain height.' ); + $this->assertArrayHasKey( 'filesize', $metadata, 'Metadata should contain filesize.' ); + $this->assertArrayHasKey( 'file', $metadata, 'Metadata should contain file.' ); + $this->assertStringContainsString( 'scaled', $metadata['file'], 'Metadata file should reference the scaled version.' ); + $this->assertGreaterThan( 0, $metadata['width'], 'Width should be positive.' ); + $this->assertGreaterThan( 0, $metadata['height'], 'Height should be positive.' ); + $this->assertGreaterThan( 0, $metadata['filesize'], 'Filesize should be positive.' ); + } + + /** + * Tests that sideloading scaled image requires authentication. + * + * @ticket 64737 + * @requires function imagejpeg + */ + public function test_sideload_scaled_image_requires_auth() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + // Create an attachment. + $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 ) ); + $response = rest_get_server()->dispatch( $request ); + $attachment_id = $response->get_data()['id']; + + // Try sideloading without authentication. + wp_set_current_user( 0 ); + + $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-scaled.jpg' ); + $request->set_param( 'image_size', 'scaled' ); + $request->set_body( file_get_contents( self::$test_file ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_cannot_edit_image', $response, 401 ); + } + + /** + * Tests that the sideload endpoint includes 'scaled' in the image_size enum. + * + * @ticket 64737 + */ + public function test_sideload_route_includes_scaled_enum() { + $this->enable_client_side_media_processing(); + + $server = rest_get_server(); + $routes = $server->get_routes(); + + $endpoint = '/wp/v2/media/(?P[\d]+)/sideload'; + $this->assertArrayHasKey( $endpoint, $routes, 'Sideload route should exist.' ); + + $route = $routes[ $endpoint ]; + $endpoint = $route[0]; + $args = $endpoint['args']; + + $param_name = 'image_size'; + $this->assertArrayHasKey( $param_name, $args, 'Route should have image_size arg.' ); + $this->assertContains( 'scaled', $args[ $param_name ]['enum'], 'image_size enum should include scaled.' ); + } + + /** + * Tests the filter_wp_unique_filename method handles the -scaled suffix. + * + * @ticket 64737 + * @requires function imagejpeg + */ + public function test_sideload_scaled_unique_filename() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + // Create an attachment. + $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 ) ); + $response = rest_get_server()->dispatch( $request ); + $attachment_id = $response->get_data()['id']; + + // Sideload with the -scaled suffix. + $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-scaled.jpg' ); + $request->set_param( 'image_size', 'scaled' ); + $request->set_body( file_get_contents( self::$test_file ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Sideloading scaled image should succeed.' ); + + // The filename should retain the -scaled suffix without numeric disambiguation. + $new_file = get_attached_file( $attachment_id, true ); + $basename = wp_basename( $new_file ); + $this->assertMatchesRegularExpression( '/canola-scaled\.jpg$/', $basename, 'Scaled filename should not have numeric suffix appended.' ); + } + + /** + * Tests that sideloading a scaled image for a different attachment retains the numeric suffix + * when a file with the same name already exists on disk. + * + * @ticket 64737 + * @requires function imagejpeg + */ + public function test_sideload_scaled_unique_filename_conflict() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + // Create the first attachment. + $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 ) ); + $response = rest_get_server()->dispatch( $request ); + $attachment_id_a = $response->get_data()['id']; + + // Sideload a scaled image for attachment A, creating canola-scaled.jpg on disk. + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id_a}/sideload" ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=canola-scaled.jpg' ); + $request->set_param( 'image_size', 'scaled' ); + $request->set_body( file_get_contents( self::$test_file ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'First sideload should succeed.' ); + + // Create a second, different attachment. + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=other.jpg' ); + $request->set_body( file_get_contents( self::$test_file ) ); + $response = rest_get_server()->dispatch( $request ); + $attachment_id_b = $response->get_data()['id']; + + // Sideload scaled for attachment B using the same filename that already exists on disk. + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id_b}/sideload" ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=canola-scaled.jpg' ); + $request->set_param( 'image_size', 'scaled' ); + $request->set_body( file_get_contents( self::$test_file ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Second sideload should succeed.' ); + + // The filename should have a numeric suffix since the base name does not match this attachment. + $new_file = get_attached_file( $attachment_id_b, true ); + $basename = wp_basename( $new_file ); + $this->assertMatchesRegularExpression( '/canola-scaled-\d+\.jpg$/', $basename, 'Scaled filename should have numeric suffix when file conflicts with a different attachment.' ); + } + + /** + * Tests that the finalize endpoint triggers wp_generate_attachment_metadata. + * + * @ticket 62243 + * @covers WP_REST_Attachments_Controller::finalize_item + * @requires function imagejpeg + */ + public function test_finalize_item(): void { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + // Create an attachment. + $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 ); + $attachment_id = $response->get_data()['id']; + + $this->assertSame( 201, $response->get_status() ); + + // Track whether wp_generate_attachment_metadata filter fires. + $filter_metadata = null; + $filter_id = null; + $filter_context = null; + add_filter( + 'wp_generate_attachment_metadata', + function ( array $metadata, int $id, string $context ) use ( &$filter_metadata, &$filter_id, &$filter_context ) { + $filter_metadata = $metadata; + $filter_id = $id; + $filter_context = $context; + $metadata['foo'] = 'bar'; + return $metadata; + }, + 10, + 3 + ); + + // Call the finalize endpoint. + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/finalize" ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Finalize endpoint should return 200.' ); + $this->assertIsArray( $filter_metadata ); + $this->assertStringContainsString( 'canola', $filter_metadata['file'], 'Expected the canola image to have been had its metadata updated.' ); + $this->assertSame( $attachment_id, $filter_id, 'Expected the post ID to be passed to the filter.' ); + $this->assertSame( 'update', $filter_context, 'Filter context should be "update".' ); + $resulting_metadata = wp_get_attachment_metadata( $attachment_id ); + $this->assertIsArray( $resulting_metadata ); + $this->assertArrayHasKey( 'foo', $resulting_metadata, 'Expected new metadata key to have been added.' ); + $this->assertSame( 'bar', $resulting_metadata['foo'], 'Expected filtered metadata to be updated.' ); + } + + /** + * Tests that the finalize endpoint requires authentication. + * + * @ticket 62243 + * @covers WP_REST_Attachments_Controller::finalize_item + * @requires function imagejpeg + */ + public function test_finalize_item_requires_auth(): void { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + // Create an attachment. + $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 ); + $attachment_id = $response->get_data()['id']; + + // Try finalizing without authentication. + wp_set_current_user( 0 ); + + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/finalize" ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_cannot_edit_image', $response, 401 ); + } + + /** + * Tests that the finalize endpoint returns error for invalid attachment ID. + * + * @ticket 62243 + * @covers WP_REST_Attachments_Controller::finalize_item + */ + public function test_finalize_item_invalid_id(): void { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + $invalid_id = PHP_INT_MAX; + $this->assertNull( get_post( $invalid_id ), 'Expected invalid ID to not exist for an existing post.' ); + $request = new WP_REST_Request( 'POST', "/wp/v2/media/$invalid_id/finalize" ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_post_invalid_id', $response, 404 ); + } } diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php index 9c6c431e5ef35..89bf2c481c567 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-setup.php +++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php @@ -16,6 +16,9 @@ class WP_Test_REST_Schema_Initialization extends WP_Test_REST_TestCase { public function set_up() { parent::set_up(); + // Ensure client-side media processing is enabled so the sideload route is registered. + add_filter( 'wp_client_side_media_processing_enabled', '__return_true' ); + /** @var WP_REST_Server $wp_rest_server */ global $wp_rest_server; $wp_rest_server = new Spy_REST_Server(); @@ -109,6 +112,8 @@ public function test_expected_routes_in_schema() { '/wp/v2/media/(?P[\\d]+)', '/wp/v2/media/(?P[\\d]+)/post-process', '/wp/v2/media/(?P[\\d]+)/edit', + '/wp/v2/media/(?P[\\d]+)/sideload', + '/wp/v2/media/(?P[\\d]+)/finalize', '/wp/v2/blocks', '/wp/v2/blocks/(?P[\d]+)', '/wp/v2/blocks/(?P[\d]+)/autosaves', diff --git a/tests/phpunit/tests/script-modules/wpScriptModules.php b/tests/phpunit/tests/script-modules/wpScriptModules.php index 4f647c6a3d2e0..140531101a1cd 100644 --- a/tests/phpunit/tests/script-modules/wpScriptModules.php +++ b/tests/phpunit/tests/script-modules/wpScriptModules.php @@ -1904,22 +1904,24 @@ public function test_dependent_of_default_script_modules() { } /** - * Tests that VIPS script modules are not registered in Core. + * Tests that VIPS script modules always use minified file paths. * - * The wasm-vips library is plugin-only and should not be included - * in WordPress Core builds due to its large size (~16MB per file). + * Non-minified VIPS files are not shipped because they are ~10MB of + * inlined WASM with no debugging value, so the registration should + * always point to the .min.js variants. * - * @ticket 64906 + * @ticket 64734 * * @covers ::wp_default_script_modules */ - public function test_vips_script_modules_not_registered_in_core() { + public function test_vips_script_modules_always_use_minified_paths() { wp_default_script_modules(); wp_enqueue_script_module( '@wordpress/vips/loader' ); $actual = get_echo( array( wp_script_modules(), 'print_enqueued_script_modules' ) ); - $this->assertStringNotContainsString( 'vips', $actual ); + $this->assertStringContainsString( 'vips/loader.min.js', $actual ); + $this->assertStringNotContainsString( 'vips/loader.js"', $actual ); } /** diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 003dc397ae305..b953a0303537c 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -3148,6 +3148,18 @@ mockedApiResponse.Schema = { "description": "The ID for the associated post of the attachment.", "type": "integer", "required": false + }, + "generate_sub_sizes": { + "type": "boolean", + "default": true, + "description": "Whether to generate image sub sizes.", + "required": false + }, + "convert_format": { + "type": "boolean", + "default": true, + "description": "Whether to convert image formats.", + "required": false } } } @@ -3664,6 +3676,68 @@ mockedApiResponse.Schema = { } ] }, + "/wp/v2/media/(?P[\\d]+)/sideload": { + "namespace": "wp/v2", + "methods": [ + "POST" + ], + "endpoints": [ + { + "methods": [ + "POST" + ], + "args": { + "id": { + "description": "Unique identifier for the attachment.", + "type": "integer", + "required": false + }, + "image_size": { + "description": "Image size.", + "type": "string", + "enum": [ + "thumbnail", + "medium", + "medium_large", + "large", + "1536x1536", + "2048x2048", + "original", + "full", + "scaled" + ], + "required": true + }, + "convert_format": { + "type": "boolean", + "default": true, + "description": "Whether to convert image formats.", + "required": false + } + } + } + ] + }, + "/wp/v2/media/(?P[\\d]+)/finalize": { + "namespace": "wp/v2", + "methods": [ + "POST" + ], + "endpoints": [ + { + "methods": [ + "POST" + ], + "args": { + "id": { + "description": "Unique identifier for the attachment.", + "type": "integer", + "required": false + } + } + } + ] + }, "/wp/v2/menu-items": { "namespace": "wp/v2", "methods": [ @@ -12700,6 +12774,43 @@ mockedApiResponse.Schema = { ] } }, + "image_sizes": { + "thumbnail": { + "width": 150, + "height": 150, + "crop": true + }, + "medium": { + "width": 300, + "height": 300, + "crop": false + }, + "medium_large": { + "width": 768, + "height": 0, + "crop": false + }, + "large": { + "width": 1024, + "height": 1024, + "crop": false + }, + "1536x1536": { + "width": 1536, + "height": 1536, + "crop": false + }, + "2048x2048": { + "width": 2048, + "height": 2048, + "crop": false + } + }, + "image_size_threshold": 2560, + "image_output_formats": {}, + "jpeg_interlaced": false, + "png_interlaced": false, + "gif_interlaced": false, "site_logo": 0, "site_icon": 0, "site_icon_url": "" diff --git a/tools/gutenberg/copy.js b/tools/gutenberg/copy.js index e5ca8eb71dce5..0f197169f7366 100644 --- a/tools/gutenberg/copy.js +++ b/tools/gutenberg/copy.js @@ -259,10 +259,6 @@ function generateScriptModulesPackages() { const fullPath = path.join( dir, entry.name ); if ( entry.isDirectory() ) { - // Skip plugin-only packages (e.g., vips/wasm) that should not be in Core. - if ( entry.name === 'vips' ) { - continue; - } processDirectory( fullPath, baseDir ); } else if ( entry.name.endsWith( '.min.asset.php' ) ) { const relativePath = path.relative( baseDir, fullPath ); @@ -348,17 +344,6 @@ function generateScriptLoaderPackages() { assetData.dependencies = []; } - // Strip plugin-only module dependencies (e.g., vips) that are not in Core. - if ( Array.isArray( assetData.module_dependencies ) ) { - assetData.module_dependencies = - assetData.module_dependencies.filter( - ( dep ) => - ! ( dep.id || dep ).startsWith( - '@wordpress/vips' - ) - ); - } - assets[ `${ entry.name }.js` ] = assetData; } catch ( error ) { console.error(