From bf9f644f76d27f7bb1c71643491a7efaf266822e Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 20 Mar 2026 09:59:22 -0700 Subject: [PATCH 1/7] Media: Allow HEIC/HEIF uploads when server lacks support Bypass the `wp_prevent_unsupported_mime_type_uploads` check for HEIC/HEIF images so they can be stored even when the server's image editor doesn't support them. The client-side canvas fallback handles processing using the browser's native HEVC decoder via createImageBitmap(). --- .../class-wp-rest-attachments-controller.php | 11 +++++++++++ 1 file changed, 11 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 cb714d5a5de71..50467f788ada4 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 @@ -258,6 +258,17 @@ public function create_item_permissions_check( $request ) { $prevent_unsupported_uploads = false; } + // Always allow HEIC/HEIF uploads through even if the server's image + // editor doesn't support them. The client-side canvas fallback will + // handle processing using the browser's native HEVC decoder. + if ( + $prevent_unsupported_uploads && + ! empty( $files['file']['type'] ) && + wp_is_heic_image_mime_type( $files['file']['type'] ) + ) { + $prevent_unsupported_uploads = false; + } + // If the upload is an image, check if the server can handle the mime type. if ( $prevent_unsupported_uploads && From b261f30d1d30e43eef56ddd3b9896e6e632bc595 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 28 May 2026 11:54:02 -0400 Subject: [PATCH 2/7] REST API: Add HEIC client-side support to the sideload route. Extends the /wp/v2/media//sideload route so the client-side media flow can upload a HEIC/HEIF companion original alongside the JPEG derivative: - Adds 'original-heic' to the allowed image_size enum. The companion filename is recorded under $metadata['original'] so it never collides with 'original_image', which the scaled-sideload flow owns. - Adds a 'generate_sub_sizes' boolean arg (default false) so callers that handle processing client-side can suppress server-side sub-size generation per request. - Adds 'image/heif' to the image_output_formats input list returned by the REST API root index. Backport of GB #76731. --- .../rest-api/class-wp-rest-server.php | 2 +- .../class-wp-rest-attachments-controller.php | 22 ++++++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/rest-api/class-wp-rest-server.php b/src/wp-includes/rest-api/class-wp-rest-server.php index 704a990298826..192f76e2d4c5a 100644 --- a/src/wp-includes/rest-api/class-wp-rest-server.php +++ b/src/wp-includes/rest-api/class-wp-rest-server.php @@ -1380,7 +1380,7 @@ public function get_index( $request ) { $available['image_size_threshold'] = (int) apply_filters( 'big_image_size_threshold', 2560, array( 0, 0 ), '', 0 ); // Image output formats. - $input_formats = array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/heic' ); + $input_formats = array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/heic', 'image/heif' ); $output_formats = array(); foreach ( $input_formats as $mime_type ) { /** This filter is documented in wp-includes/media.php */ 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 3de551148d99d..d979872f96158 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 @@ -68,6 +68,10 @@ public function register_routes() { $valid_image_sizes = array_keys( wp_get_registered_image_subsizes() ); // Special case to set 'original_image' in attachment metadata. $valid_image_sizes[] = 'original'; + // HEIC/HEIF companion original preserved alongside the JPEG derivative. + // Stored under its own meta key so it never collides with 'original' + // (which the scaled-sideload flow also writes to). + $valid_image_sizes[] = 'original-heic'; // Used for PDF thumbnails. $valid_image_sizes[] = 'full'; // Client-side big image threshold: sideload the scaled version. @@ -82,21 +86,26 @@ public function register_routes() { 'callback' => array( $this, 'sideload_item' ), 'permission_callback' => array( $this, 'sideload_item_permissions_check' ), 'args' => array( - 'id' => array( + 'id' => array( 'description' => __( 'Unique identifier for the attachment.' ), 'type' => 'integer', ), - 'image_size' => array( + 'image_size' => array( 'description' => __( 'Image size.' ), 'type' => 'string', 'enum' => $valid_image_sizes, 'required' => true, ), - 'convert_format' => array( + 'convert_format' => array( 'type' => 'boolean', 'default' => true, 'description' => __( 'Whether to convert image formats.' ), ), + 'generate_sub_sizes' => array( + 'description' => __( 'Whether to generate image sub sizes from the sideloaded file.' ), + 'type' => 'boolean', + 'default' => false, + ), ), ), 'allow_batch' => $this->allow_batch, @@ -2101,6 +2110,13 @@ public function sideload_item( WP_REST_Request $request ) { if ( 'original' === $image_size ) { $metadata['original_image'] = wp_basename( $path ); + } elseif ( 'original-heic' === $image_size ) { + // HEIC companion original: stored under its own meta key so + // the scaled-sideload flow (which writes 'original_image') + // cannot clobber it. 'original_image' keeps pointing at the + // web-viewable JPEG derivative. Cleanup on attachment delete + // is handled by wp_delete_attachment_heic_companion_file(). + $metadata['original'] = wp_basename( $path ); } elseif ( 'scaled' === $image_size ) { // The current attached file is the original; record it as original_image. $current_file = get_attached_file( $attachment_id, true ); From c711280a0c2b811099c6ea57dcd848e4750eafa2 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 28 May 2026 11:54:09 -0400 Subject: [PATCH 3/7] Media: Delete HEIC companion file when its attachment is deleted. When the client-side media flow sideloads a HEIC original alongside a JPEG derivative, the HEIC filename is stored in $metadata['original']. wp_delete_attachment_files() only tracks 'original_image', so without this hook the HEIC file would linger on disk after the attachment is removed. wp_delete_attachment_heic_companion_file() reads the meta key, guards against non-string values (e.g. arrays written by other flows), and deletes the file when present. Hooked into the delete_attachment action via default-filters.php. Backport of GB #76731, with the is_string() guard from GB #78128. --- src/wp-includes/default-filters.php | 1 + src/wp-includes/media.php | 39 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index cf895eb748dbe..de8e9fb3364d6 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -562,6 +562,7 @@ add_action( 'transition_post_status', '_wp_customize_publish_changeset', 10, 3 ); add_action( 'admin_enqueue_scripts', '_wp_customize_loader_settings' ); add_action( 'delete_attachment', '_delete_attachment_theme_mod' ); +add_action( 'delete_attachment', 'wp_delete_attachment_heic_companion_file' ); add_action( 'transition_post_status', '_wp_keep_alive_customize_changeset_dependent_auto_drafts', 20, 3 ); // Block Theme Previews. diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index d318a275a9607..13fb4f9c6a7d9 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -5760,6 +5760,45 @@ function wp_show_heic_upload_error( $plupload_settings ) { return $plupload_settings; } +/** + * Deletes the HEIC companion file when its attachment is deleted. + * + * When the client-side media flow sideloads a HEIC original alongside a + * JPEG derivative, the HEIC filename is recorded in $metadata['original']. + * WordPress only tracks 'original_image' in wp_delete_attachment_files(), + * so without this hook the HEIC file would linger on disk after the + * attachment is deleted. + * + * @since 7.1.0 + * + * @param int $post_id Attachment ID being deleted. + */ +function wp_delete_attachment_heic_companion_file( $post_id ) { + $metadata = wp_get_attachment_metadata( $post_id, true ); + + if ( empty( $metadata['original'] ) || ! is_string( $metadata['original'] ) ) { + return; + } + + $attached_file = get_attached_file( $post_id, true ); + + if ( ! $attached_file ) { + return; + } + + $uploads = wp_get_upload_dir(); + + if ( empty( $uploads['basedir'] ) ) { + return; + } + + $heic_path = path_join( dirname( $attached_file ), wp_basename( (string) $metadata['original'] ) ); + + if ( file_exists( $heic_path ) ) { + wp_delete_file_from_directory( $heic_path, $uploads['basedir'] ); + } +} + /** * Allows PHP's getimagesize() to be debuggable when necessary. * From b20bbcaf447c2478680897595abdd8bf23dfa0aa Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 28 May 2026 11:57:14 -0400 Subject: [PATCH 4/7] Tests: Cover the HEIC client-side sideload and companion-delete flow. Adds REST API controller tests: - The sideload route exposes 'original-heic' in the image_size enum. - The sideload route exposes a 'generate_sub_sizes' boolean arg defaulting to false. - Sideloading an 'original-heic' image writes the filename to $metadata['original'] and leaves 'original_image' untouched. Adds wp_delete_attachment_heic_companion_file() unit tests: - The companion HEIC is removed when the attachment is deleted. - The hook is a no-op when $metadata['original'] is missing. - The hook bails when $metadata['original'] is not a string (regression coverage for the guard added in GB #78128). --- .../wpDeleteAttachmentHeicCompanionFile.php | 77 +++++++++++++++++++ .../rest-api/rest-attachments-controller.php | 74 ++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php diff --git a/tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php b/tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php new file mode 100644 index 0000000000000..d3e2dc0256321 --- /dev/null +++ b/tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php @@ -0,0 +1,77 @@ +remove_added_uploads(); + + parent::tear_down(); + } + + /** + * @ticket 64915 + */ + public function test_deletes_heic_file_recorded_in_metadata_original() { + $attachment_id = $this->factory->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); + + $attached_file = get_attached_file( $attachment_id, true ); + $dir = dirname( $attached_file ); + $heic_name = 'companion-' . wp_generate_password( 6, false ) . '.heic'; + $heic_path = $dir . '/' . $heic_name; + + // Create a dummy companion file on disk. + file_put_contents( $heic_path, 'test' ); + $this->assertFileExists( $heic_path, 'Test fixture should be on disk.' ); + + // Record the companion under metadata['original'] as the sideload route does. + $metadata = wp_get_attachment_metadata( $attachment_id, true ); + $metadata['original'] = $heic_name; + wp_update_attachment_metadata( $attachment_id, $metadata ); + + wp_delete_attachment( $attachment_id, true ); + + $this->assertFileDoesNotExist( $heic_path, 'Companion HEIC file should be deleted alongside the attachment.' ); + } + + /** + * @ticket 64915 + */ + public function test_noop_when_metadata_original_is_missing() { + $attachment_id = $this->factory->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); + + // Sanity: no 'original' key on freshly-created metadata. + $metadata = wp_get_attachment_metadata( $attachment_id, true ); + $this->assertArrayNotHasKey( 'original', $metadata ); + + // Should not raise even though the hook fires. + wp_delete_attachment( $attachment_id, true ); + + $this->assertNull( get_post( $attachment_id ) ); + } + + /** + * Guards against $metadata['original'] holding a non-string value (e.g. + * the array form some flows write). Regression coverage for GB #78128. + * + * @ticket 64915 + */ + public function test_noop_when_metadata_original_is_not_a_string() { + $attachment_id = $this->factory->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); + $attached_file = get_attached_file( $attachment_id, true ); + + $metadata = wp_get_attachment_metadata( $attachment_id, true ); + $metadata['original'] = array( 'file' => 'should-not-delete.heic' ); + wp_update_attachment_metadata( $attachment_id, $metadata ); + + // Should not raise (no path_join() / file_exists() on an array). + wp_delete_attachment_heic_companion_file( $attachment_id ); + + $this->assertFileExists( $attached_file, 'Attached file should still be on disk; the hook must bail on non-string original.' ); + } +} diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 79e9d23cf9dd3..def3559b3d602 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3351,6 +3351,80 @@ public function test_sideload_route_includes_scaled_enum() { $this->assertContains( 'scaled', $args[ $param_name ]['enum'], 'image_size enum should include scaled.' ); } + /** + * Tests that the sideload endpoint includes 'original-heic' in the image_size enum. + * + * @ticket 64915 + */ + public function test_sideload_route_includes_original_heic_enum() { + $this->enable_client_side_media_processing(); + + $routes = rest_get_server()->get_routes(); + $endpoint = $routes['/wp/v2/media/(?P[\d]+)/sideload'][0]; + $args = $endpoint['args']; + + $this->assertArrayHasKey( 'image_size', $args, 'Route should have image_size arg.' ); + $this->assertContains( 'original-heic', $args['image_size']['enum'], 'image_size enum should include original-heic.' ); + } + + /** + * Tests that the sideload endpoint exposes the generate_sub_sizes arg. + * + * @ticket 64915 + */ + public function test_sideload_route_includes_generate_sub_sizes_arg() { + $this->enable_client_side_media_processing(); + + $routes = rest_get_server()->get_routes(); + $endpoint = $routes['/wp/v2/media/(?P[\d]+)/sideload'][0]; + $args = $endpoint['args']; + + $this->assertArrayHasKey( 'generate_sub_sizes', $args, 'Route should have generate_sub_sizes arg.' ); + $this->assertSame( 'boolean', $args['generate_sub_sizes']['type'], 'generate_sub_sizes should be a boolean.' ); + $this->assertFalse( $args['generate_sub_sizes']['default'], 'generate_sub_sizes should default to false on sideload.' ); + } + + /** + * Tests sideloading an 'original-heic' companion file alongside its JPEG + * derivative. The HEIC filename is recorded under $metadata['original'] + * so it does not collide with 'original_image', which the scaled-sideload + * flow owns. + * + * @ticket 64915 + * @requires function imagejpeg + */ + public function test_sideload_original_heic_writes_metadata_original() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + // Create the JPEG attachment that the HEIC will be a companion to. + $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']; + + $this->assertSame( 201, $response->get_status() ); + + // Sideload the HEIC companion. Uses a JPEG body since the size enum, + // not the file format, is what we're exercising here. + $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.heic' ); + $request->set_param( 'image_size', 'original-heic' ); + $request->set_body( file_get_contents( self::$test_file ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Sideloading original-heic should succeed.' ); + + $metadata = wp_get_attachment_metadata( $attachment_id ); + $this->assertArrayHasKey( 'original', $metadata, "Metadata should contain 'original' for the HEIC companion." ); + $this->assertMatchesRegularExpression( '/canola.*\.heic$/', $metadata['original'], "Metadata 'original' should reference the HEIC filename." ); + $this->assertArrayNotHasKey( 'original_image', $metadata, "Metadata 'original_image' should be untouched by the HEIC sideload." ); + } + /** * Tests the filter_wp_unique_filename method handles the -scaled suffix. * From eea07d222055212a5660255fb078aa6860778bee Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 28 May 2026 12:39:17 -0400 Subject: [PATCH 5/7] Tests: Use HEIC fixture and convert_format=false for original-heic sideload. The test was sending JPEG bytes with a .heic filename, which wp_check_filetype_and_ext() corrected to canola-1.jpg before the metadata assertion ran. Switch to the real test-image.heic fixture, set Content-Type accordingly, and pass convert_format=false to disable the default HEIC -> JPEG output mapping so the .heic extension is preserved. --- .../tests/rest-api/rest-attachments-controller.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index def3559b3d602..acadc3556247a 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3408,13 +3408,15 @@ public function test_sideload_original_heic_writes_metadata_original() { $this->assertSame( 201, $response->get_status() ); - // Sideload the HEIC companion. Uses a JPEG body since the size enum, - // not the file format, is what we're exercising here. + // Sideload the HEIC companion using the real HEIC fixture. `convert_format` + // is disabled so the default HEIC -> JPEG output mapping does not rename + // the file or append an alt-extension suffix. $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" ); - $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Type', 'image/heic' ); $request->set_header( 'Content-Disposition', 'attachment; filename=canola.heic' ); $request->set_param( 'image_size', 'original-heic' ); - $request->set_body( file_get_contents( self::$test_file ) ); + $request->set_param( 'convert_format', false ); + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/test-image.heic' ) ); $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status(), 'Sideloading original-heic should succeed.' ); From d976d2e4a675d868621144acb429c0d06cdc1d2b Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 28 May 2026 13:28:42 -0400 Subject: [PATCH 6/7] Tests: Refresh wp-api-generated.js fixture for the sideload route. Add 'original-heic' to the image_size enum and the missing generate_sub_sizes arg so the schema fixture matches what the live REST index now reports. Without this the test-fixtures step fails the git diff --exit-code check. --- tests/qunit/fixtures/wp-api-generated.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index fa03d9751fe99..c3bf2bf452928 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -3703,6 +3703,7 @@ mockedApiResponse.Schema = { "1536x1536", "2048x2048", "original", + "original-heic", "full", "scaled" ], @@ -3713,6 +3714,12 @@ mockedApiResponse.Schema = { "default": true, "description": "Whether to convert image formats.", "required": false + }, + "generate_sub_sizes": { + "description": "Whether to generate image sub sizes from the sideloaded file.", + "type": "boolean", + "default": false, + "required": false } } } From 53628e7923691e4528855ad65ad8d6f1c27f1921 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 28 May 2026 14:04:10 -0400 Subject: [PATCH 7/7] Media: Add JPEG XL (JXL) upload support with companion-original preservation. Add server-side support for the client-side media flow that decodes JPEG XL uploads to JPEG in the browser via lazy-loaded VIPS/WASM and preserves the original .jxl as a companion file (under $metadata['original'], the same slot the HEIC companion uses). * Register `image/jxl` as an allowed upload MIME type. WordPress core does not include JXL in its default MIME list, so without this filter the editor's allowed-types check rejects the original .jxl before it can be converted. * Add `wp_is_jxl_file()`, a magic-bytes check that recognizes both JXL flavors (the naked codestream beginning 0xFF 0x0A and the 12-byte ISOBMFF container signature). * Add `wp_filter_jxl_filetype_and_ext` on `wp_check_filetype_and_ext`. PHP's fileinfo reports JXL as `image/x-jxl` (and getimagesize() can't identify it at all), so core's MIME-mismatch check otherwise rejects the upload. When the file is genuinely JXL, restore the expected ext/type so the upload is allowed. * Accept `original-jxl` as a sideload `image_size`. The branch is collapsed with `original-heic` since both write to $metadata['original'] and are mutually exclusive. * Rename `wp_delete_attachment_heic_companion_file()` to `wp_delete_attachment_preserved_original_companion_file()` since the same hook now covers both HEIC and JXL companions. The implementation is unchanged; only the name and docblock are generalized. The test file is renamed alongside. Backport of WordPress/gutenberg#77584. Stacked on #11323 (HEIC canvas fallback). --- src/wp-includes/default-filters.php | 7 +- src/wp-includes/media.php | 108 +++++++++-- .../class-wp-rest-attachments-controller.php | 10 +- ...achmentPreservedOriginalCompanionFile.php} | 36 +++- tests/phpunit/tests/media/wpJxlUpload.php | 169 ++++++++++++++++++ 5 files changed, 312 insertions(+), 18 deletions(-) rename tests/phpunit/tests/media/{wpDeleteAttachmentHeicCompanionFile.php => wpDeleteAttachmentPreservedOriginalCompanionFile.php} (63%) create mode 100644 tests/phpunit/tests/media/wpJxlUpload.php diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index de8e9fb3364d6..2d358a5cef4d2 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -562,7 +562,7 @@ add_action( 'transition_post_status', '_wp_customize_publish_changeset', 10, 3 ); add_action( 'admin_enqueue_scripts', '_wp_customize_loader_settings' ); add_action( 'delete_attachment', '_delete_attachment_theme_mod' ); -add_action( 'delete_attachment', 'wp_delete_attachment_heic_companion_file' ); +add_action( 'delete_attachment', 'wp_delete_attachment_preserved_original_companion_file' ); add_action( 'transition_post_status', '_wp_keep_alive_customize_changeset_dependent_auto_drafts', 20, 3 ); // Block Theme Previews. @@ -685,6 +685,11 @@ add_action( 'plugins_loaded', '_wp_add_additional_image_sizes', 0 ); add_filter( 'plupload_default_settings', 'wp_show_heic_upload_error' ); +// JPEG XL (JXL) upload support: register the MIME type and restore it during +// upload validation when fileinfo reports the non-canonical image/x-jxl form. +add_filter( 'upload_mimes', 'wp_add_jxl_upload_mimes' ); +add_filter( 'wp_check_filetype_and_ext', 'wp_filter_jxl_filetype_and_ext', 10, 3 ); + // Client-side media processing. add_action( 'admin_init', 'wp_set_client_side_media_processing_flag' ); // Cross-origin isolation for client-side media processing. diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index 13fb4f9c6a7d9..fd1b432d834b0 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -5761,19 +5761,19 @@ function wp_show_heic_upload_error( $plupload_settings ) { } /** - * Deletes the HEIC companion file when its attachment is deleted. + * Deletes a preserved original companion file when its attachment is deleted. * - * When the client-side media flow sideloads a HEIC original alongside a - * JPEG derivative, the HEIC filename is recorded in $metadata['original']. - * WordPress only tracks 'original_image' in wp_delete_attachment_files(), - * so without this hook the HEIC file would linger on disk after the - * attachment is deleted. + * HEIC and JPEG XL uploads are converted to a web-viewable JPEG, and the + * original file is sideloaded alongside it and recorded in + * $metadata['original']. WordPress only tracks 'original_image' in + * wp_delete_attachment_files(), so without this hook the companion original + * would linger on disk after the attachment is deleted. * * @since 7.1.0 * * @param int $post_id Attachment ID being deleted. */ -function wp_delete_attachment_heic_companion_file( $post_id ) { +function wp_delete_attachment_preserved_original_companion_file( $post_id ) { $metadata = wp_get_attachment_metadata( $post_id, true ); if ( empty( $metadata['original'] ) || ! is_string( $metadata['original'] ) ) { @@ -5792,13 +5792,101 @@ function wp_delete_attachment_heic_companion_file( $post_id ) { return; } - $heic_path = path_join( dirname( $attached_file ), wp_basename( (string) $metadata['original'] ) ); + $original_path = path_join( dirname( $attached_file ), wp_basename( (string) $metadata['original'] ) ); - if ( file_exists( $heic_path ) ) { - wp_delete_file_from_directory( $heic_path, $uploads['basedir'] ); + if ( file_exists( $original_path ) ) { + wp_delete_file_from_directory( $original_path, $uploads['basedir'] ); } } +/** + * Determines whether a file is a JPEG XL image by inspecting its magic bytes. + * + * JXL files come in two flavors: a naked codestream (starts with 0xFF 0x0A) + * and an ISOBMFF container (starts with the 12-byte JXL box signature). + * + * @since 7.1.0 + * + * @param string $file Full path to the file. + * @return bool Whether the file is a JPEG XL image. + */ +function wp_is_jxl_file( $file ) { + $handle = @fopen( $file, 'rb' ); + if ( ! $handle ) { + return false; + } + + $bytes = fread( $handle, 12 ); + fclose( $handle ); + + if ( ! is_string( $bytes ) || strlen( $bytes ) < 2 ) { + return false; + } + + // Naked JXL codestream. + if ( "\xFF\x0A" === substr( $bytes, 0, 2 ) ) { + return true; + } + + // JXL ISOBMFF container ("....JXL \r\n\x87\n"). + if ( "\x00\x00\x00\x0C\x4A\x58\x4C\x20\x0D\x0A\x87\x0A" === $bytes ) { + return true; + } + + return false; +} + +/** + * Registers JPEG XL (JXL) as an allowed upload MIME type. + * + * JXL images are decoded to JPEG client-side via VIPS/WASM and the JPEG is + * uploaded; the original .jxl is preserved as a companion file. WordPress core + * does not include image/jxl in its default MIME list, so without this filter + * the editor's allowed-types check rejects the original .jxl before it can be + * converted, and the companion sideload is rejected by the server. + * + * @since 7.1.0 + * + * @param array $mimes Allowed MIME types (extension => type). + * @return array Modified MIME types. + */ +function wp_add_jxl_upload_mimes( $mimes ) { + $mimes['jxl'] = 'image/jxl'; + return $mimes; +} + +/** + * Restores the JPEG XL MIME type during upload validation. + * + * fileinfo reports JXL as image/x-jxl (and getimagesize() cannot identify it + * at all), so WordPress core's wp_check_filetype_and_ext() rejects the upload + * because the detected MIME type does not match the registered image/jxl type. + * When the file is genuinely a JPEG XL (verified via its magic bytes), restore + * the expected extension and MIME type so the upload is allowed. + * + * @since 7.1.0 + * + * @param array $data Values for the extension, MIME type, and corrected filename. + * @param string $file Full path to the file. + * @param string $filename The name of the file. + * @return array Filtered values for the extension, MIME type, and corrected filename. + */ +function wp_filter_jxl_filetype_and_ext( $data, $file, $filename ) { + // Leave already-recognized files untouched. + if ( ! empty( $data['type'] ) ) { + return $data; + } + + $ext = strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) ); + + if ( 'jxl' === $ext && wp_is_jxl_file( $file ) ) { + $data['ext'] = 'jxl'; + $data['type'] = 'image/jxl'; + } + + return $data; +} + /** * Allows PHP's getimagesize() to be debuggable when necessary. * 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 d979872f96158..f4af127f5c9cd 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 @@ -72,6 +72,10 @@ public function register_routes() { // Stored under its own meta key so it never collides with 'original' // (which the scaled-sideload flow also writes to). $valid_image_sizes[] = 'original-heic'; + // JPEG XL (JXL) companion original. Same metadata slot as HEIC; the + // two are mutually exclusive (an attachment is converted from either + // HEIC or JXL, never both). + $valid_image_sizes[] = 'original-jxl'; // Used for PDF thumbnails. $valid_image_sizes[] = 'full'; // Client-side big image threshold: sideload the scaled version. @@ -2110,12 +2114,12 @@ public function sideload_item( WP_REST_Request $request ) { if ( 'original' === $image_size ) { $metadata['original_image'] = wp_basename( $path ); - } elseif ( 'original-heic' === $image_size ) { - // HEIC companion original: stored under its own meta key so + } elseif ( 'original-heic' === $image_size || 'original-jxl' === $image_size ) { + // HEIC/JXL companion original: stored under its own meta key so // the scaled-sideload flow (which writes 'original_image') // cannot clobber it. 'original_image' keeps pointing at the // web-viewable JPEG derivative. Cleanup on attachment delete - // is handled by wp_delete_attachment_heic_companion_file(). + // is handled by wp_delete_attachment_preserved_original_companion_file(). $metadata['original'] = wp_basename( $path ); } elseif ( 'scaled' === $image_size ) { // The current attached file is the original; record it as original_image. diff --git a/tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php b/tests/phpunit/tests/media/wpDeleteAttachmentPreservedOriginalCompanionFile.php similarity index 63% rename from tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php rename to tests/phpunit/tests/media/wpDeleteAttachmentPreservedOriginalCompanionFile.php index d3e2dc0256321..72c2ecffb4872 100644 --- a/tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php +++ b/tests/phpunit/tests/media/wpDeleteAttachmentPreservedOriginalCompanionFile.php @@ -1,12 +1,14 @@ remove_added_uploads(); @@ -70,8 +72,34 @@ public function test_noop_when_metadata_original_is_not_a_string() { wp_update_attachment_metadata( $attachment_id, $metadata ); // Should not raise (no path_join() / file_exists() on an array). - wp_delete_attachment_heic_companion_file( $attachment_id ); + wp_delete_attachment_preserved_original_companion_file( $attachment_id ); $this->assertFileExists( $attached_file, 'Attached file should still be on disk; the hook must bail on non-string original.' ); } + + /** + * The same hook also deletes a JXL companion since both HEIC and JXL + * write the preserved original under $metadata['original']. + * + * @ticket 64915 + */ + public function test_deletes_jxl_file_recorded_in_metadata_original() { + $attachment_id = $this->factory->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); + + $attached_file = get_attached_file( $attachment_id, true ); + $dir = dirname( $attached_file ); + $jxl_name = 'companion-' . wp_generate_password( 6, false ) . '.jxl'; + $jxl_path = $dir . '/' . $jxl_name; + + file_put_contents( $jxl_path, 'test' ); + $this->assertFileExists( $jxl_path, 'Test fixture should be on disk.' ); + + $metadata = wp_get_attachment_metadata( $attachment_id, true ); + $metadata['original'] = $jxl_name; + wp_update_attachment_metadata( $attachment_id, $metadata ); + + wp_delete_attachment( $attachment_id, true ); + + $this->assertFileDoesNotExist( $jxl_path, 'Companion JXL file should be deleted alongside the attachment.' ); + } } diff --git a/tests/phpunit/tests/media/wpJxlUpload.php b/tests/phpunit/tests/media/wpJxlUpload.php new file mode 100644 index 0000000000000..932042381a415 --- /dev/null +++ b/tests/phpunit/tests/media/wpJxlUpload.php @@ -0,0 +1,169 @@ +temp_files as $file ) { + if ( file_exists( $file ) ) { + wp_delete_file( $file ); + } + } + $this->temp_files = array(); + + parent::tear_down(); + } + + /** + * Writes `$bytes` to a temp file and returns its absolute path. + * + * @param string $bytes Raw file contents. + * @param string $ext File extension (without the dot). + * @return string Absolute path to the temp file. + */ + private function create_temp_file( $bytes, $ext = 'jxl' ) { + $path = get_temp_dir() . 'jxl-test-' . wp_generate_uuid4() . '.' . $ext; + file_put_contents( $path, $bytes ); + $this->temp_files[] = $path; + return $path; + } + + /** + * @ticket 64915 + */ + public function test_is_jxl_file_recognizes_naked_codestream() { + $path = $this->create_temp_file( "\xFF\x0A" . str_repeat( "\x00", 32 ) ); + + $this->assertTrue( wp_is_jxl_file( $path ) ); + } + + /** + * @ticket 64915 + */ + public function test_is_jxl_file_recognizes_isobmff_container() { + $signature = "\x00\x00\x00\x0C\x4A\x58\x4C\x20\x0D\x0A\x87\x0A"; + $path = $this->create_temp_file( $signature . str_repeat( "\x00", 32 ) ); + + $this->assertTrue( wp_is_jxl_file( $path ) ); + } + + /** + * @ticket 64915 + */ + public function test_is_jxl_file_rejects_jpeg() { + // JPEG SOI marker. + $path = $this->create_temp_file( "\xFF\xD8\xFF\xE0" . str_repeat( "\x00", 16 ) ); + + $this->assertFalse( wp_is_jxl_file( $path ) ); + } + + /** + * @ticket 64915 + */ + public function test_is_jxl_file_rejects_png() { + $path = $this->create_temp_file( "\x89PNG\r\n\x1a\n" . str_repeat( "\x00", 16 ) ); + + $this->assertFalse( wp_is_jxl_file( $path ) ); + } + + /** + * @ticket 64915 + */ + public function test_is_jxl_file_returns_false_for_short_file() { + $path = $this->create_temp_file( "\xFF" ); + + $this->assertFalse( wp_is_jxl_file( $path ) ); + } + + /** + * @ticket 64915 + */ + public function test_is_jxl_file_returns_false_for_missing_file() { + $this->assertFalse( wp_is_jxl_file( '/nonexistent/path/to/file.jxl' ) ); + } + + /** + * @ticket 64915 + */ + public function test_upload_mimes_includes_jxl() { + $mimes = apply_filters( 'upload_mimes', get_allowed_mime_types() ); + + $this->assertArrayHasKey( 'jxl', $mimes ); + $this->assertSame( 'image/jxl', $mimes['jxl'] ); + } + + /** + * @ticket 64915 + */ + public function test_filetype_and_ext_restores_jxl_mime_when_empty() { + $path = $this->create_temp_file( "\xFF\x0A" . str_repeat( "\x00", 32 ) ); + + $data = wp_filter_jxl_filetype_and_ext( + array( + 'ext' => false, + 'type' => false, + 'proper_filename' => false, + ), + $path, + wp_basename( $path ) + ); + + $this->assertSame( 'jxl', $data['ext'] ); + $this->assertSame( 'image/jxl', $data['type'] ); + } + + /** + * @ticket 64915 + */ + public function test_filetype_and_ext_leaves_already_recognized_file_alone() { + $path = $this->create_temp_file( "\xFF\x0A" . str_repeat( "\x00", 32 ) ); + + $data = wp_filter_jxl_filetype_and_ext( + array( + 'ext' => 'jpg', + 'type' => 'image/jpeg', + 'proper_filename' => 'photo.jpg', + ), + $path, + 'photo.jpg' + ); + + $this->assertSame( 'jpg', $data['ext'], 'A recognized image must not be overwritten by the JXL filter.' ); + $this->assertSame( 'image/jpeg', $data['type'] ); + } + + /** + * The filter must not rescue a file that has a `.jxl` extension but is + * actually a different format (e.g. a JPEG renamed to `.jxl`). + * + * @ticket 64915 + */ + public function test_filetype_and_ext_rejects_jxl_extension_with_wrong_magic() { + $path = $this->create_temp_file( "\xFF\xD8\xFF\xE0" . str_repeat( "\x00", 16 ), 'jxl' ); + + $data = wp_filter_jxl_filetype_and_ext( + array( + 'ext' => false, + 'type' => false, + 'proper_filename' => false, + ), + $path, + 'fake.jxl' + ); + + $this->assertFalse( $data['ext'], 'JXL extension on a non-JXL file must not be restored.' ); + $this->assertFalse( $data['type'] ); + } +}