Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/wp-includes/default-filters.php
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,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.
Expand Down
88 changes: 88 additions & 0 deletions src/wp-includes/media.php
Original file line number Diff line number Diff line change
Expand Up @@ -5760,6 +5760,94 @@ function wp_show_heic_upload_error( $plupload_settings ) {
return $plupload_settings;
}

/**
* 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.
*
Expand Down
2 changes: 1 addition & 1 deletion src/wp-includes/rest-api/class-wp-rest-server.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ 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';
// JPEG XL (JXL) companion original preserved alongside the JPEG
// derivative. Stored under the dedicated 'source_image' meta key so it
// never collides with 'original_image' (which the scaled-sideload flow
// writes). The HEIC companion shares this key and lands in the HEIC
// upload backport.
$valid_image_sizes[] = 'original-jxl';
// Used for PDF thumbnails.
$valid_image_sizes[] = 'full';
// Client-side big image threshold: sideload the scaled version.
Expand All @@ -82,21 +88,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,
Expand Down Expand Up @@ -258,6 +269,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 &&
Expand Down Expand Up @@ -2090,6 +2112,14 @@ public function sideload_item( WP_REST_Request $request ) {

if ( 'original' === $image_size ) {
$metadata['original_image'] = wp_basename( $path );
} elseif ( 'original-jxl' === $image_size ) {
// JXL companion original: stored under the dedicated 'source_image'
// 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 the delete_attachment hook in the HEIC upload backport,
// which reads this same 'source_image' key.
$metadata['source_image'] = 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 );
Expand Down
169 changes: 169 additions & 0 deletions tests/phpunit/tests/media/wpJxlUpload.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
<?php

/**
* Tests for the JPEG XL (JXL) upload helpers.
*
* @group media
* @covers ::wp_is_jxl_file
* @covers ::wp_add_jxl_upload_mimes
* @covers ::wp_filter_jxl_filetype_and_ext
*/
class Tests_Media_wpJxlUpload extends WP_UnitTestCase {

/**
* @var string[] Absolute paths of temp files created during a test.
*/
private $temp_files = array();

public function tear_down() {
foreach ( $this->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'] );
}
}
Loading
Loading