diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index cf895eb748dbe..2d358a5cef4d2 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_preserved_original_companion_file' ); add_action( 'transition_post_status', '_wp_keep_alive_customize_changeset_dependent_auto_drafts', 20, 3 ); // Block Theme Previews. @@ -684,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 d318a275a9607..fd1b432d834b0 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -5760,6 +5760,133 @@ function wp_show_heic_upload_error( $plupload_settings ) { return $plupload_settings; } +/** + * Deletes a preserved original companion file when its 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_preserved_original_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; + } + + $original_path = path_join( dirname( $attached_file ), wp_basename( (string) $metadata['original'] ) ); + + 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/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 21805778ba659..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 @@ -68,6 +68,14 @@ 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'; + // 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. @@ -82,21 +90,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, @@ -258,6 +271,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 && @@ -2090,6 +2114,13 @@ public function sideload_item( WP_REST_Request $request ) { if ( 'original' === $image_size ) { $metadata['original_image'] = wp_basename( $path ); + } 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_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. $current_file = get_attached_file( $attachment_id, true ); diff --git a/tests/phpunit/tests/media/wpDeleteAttachmentPreservedOriginalCompanionFile.php b/tests/phpunit/tests/media/wpDeleteAttachmentPreservedOriginalCompanionFile.php new file mode 100644 index 0000000000000..72c2ecffb4872 --- /dev/null +++ b/tests/phpunit/tests/media/wpDeleteAttachmentPreservedOriginalCompanionFile.php @@ -0,0 +1,105 @@ +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_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'] ); + } +} diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 79e9d23cf9dd3..acadc3556247a 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3351,6 +3351,82 @@ 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 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/heic' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=canola.heic' ); + $request->set_param( 'image_size', 'original-heic' ); + $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.' ); + + $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. * 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 } } }