Skip to content

Commit 80c5bd2

Browse files
Media: Allow HEIC/HEIF uploads when the server lacks editor support.
Enable HEIC/HEIF image uploads to succeed even when the server's image editor cannot process them, so client-side media processing can decode the file in the browser and generate the required sub-sizes. Previously, `wp_prevent_unsupported_mime_type_uploads` rejected these uploads outright with a `rest_upload_image_type_not_supported` error. See related Gutenberg work: WordPress/gutenberg#76731. Props westonruter, swissspidy, ramonopoly. Fixes #64915. git-svn-id: https://develop.svn.wordpress.org/trunk@62616 602fd350-edb4-49c9-b593-d223f7449a82
1 parent 711fa9c commit 80c5bd2

5 files changed

Lines changed: 393 additions & 31 deletions

File tree

src/wp-includes/post.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6846,6 +6846,29 @@ function wp_delete_attachment_files( $post_id, $meta, $backup_sizes, $file ) {
68466846
}
68476847
}
68486848

6849+
/*
6850+
* Delete the source-format companion file. The client-side media flow can
6851+
* sideload a source-format original (such as a HEIC file) alongside a
6852+
* web-viewable derivative, recording its filename under the 'source_image'
6853+
* key. This is kept separate from 'original_image', which continues to
6854+
* point at the derivative.
6855+
*/
6856+
if ( ! empty( $meta['source_image'] ) && is_string( $meta['source_image'] ) ) {
6857+
if ( empty( $intermediate_dir ) ) {
6858+
$intermediate_dir = path_join( $uploadpath['basedir'], dirname( $file ) );
6859+
}
6860+
6861+
$source_image = str_replace( wp_basename( $file ), $meta['source_image'], $file );
6862+
6863+
if ( ! empty( $source_image ) ) {
6864+
$source_image = path_join( $uploadpath['basedir'], $source_image );
6865+
6866+
if ( ! wp_delete_file_from_directory( $source_image, $intermediate_dir ) ) {
6867+
$deleted = false;
6868+
}
6869+
}
6870+
}
6871+
68496872
if ( is_array( $backup_sizes ) ) {
68506873
$del_dir = path_join( $uploadpath['basedir'], dirname( $meta['file'] ) );
68516874

src/wp-includes/rest-api/class-wp-rest-server.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1380,7 +1380,7 @@ public function get_index( $request ) {
13801380
$available['image_size_threshold'] = (int) apply_filters( 'big_image_size_threshold', 2560, array( 0, 0 ), '', 0 );
13811381

13821382
// Image output formats.
1383-
$input_formats = array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/heic' );
1383+
$input_formats = array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/heic', 'image/heif' );
13841384
$output_formats = array();
13851385
foreach ( $input_formats as $mime_type ) {
13861386
/** This filter is documented in wp-includes/media.php */

src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,29 @@ class WP_REST_Attachments_Controller extends WP_REST_Posts_Controller {
2424
*/
2525
protected $allow_batch = false;
2626

27+
/**
28+
* Image size token for the source-format original preserved alongside a
29+
* client-generated derivative (e.g. the HEIC file kept next to its JPEG).
30+
*
31+
* Used both in the `/sideload` route schema and when dispatching the
32+
* sideloaded file to its metadata key, so the two never drift apart.
33+
*
34+
* @since 7.1.0
35+
* @var string
36+
*/
37+
const IMAGE_SIZE_SOURCE_ORIGINAL = 'source_original';
38+
39+
/**
40+
* Metadata key holding the basename of the source-format original.
41+
*
42+
* Deliberately specific so it never collides with the generic `original`
43+
* or `original_image` keys other flows write to.
44+
*
45+
* @since 7.1.0
46+
* @var string
47+
*/
48+
const META_KEY_SOURCE_IMAGE = 'source_image';
49+
2750
/**
2851
* Registers the routes for attachments.
2952
*
@@ -98,6 +121,8 @@ public function register_routes() {
98121
$valid_sizes[] = 'original';
99122
$valid_sizes[] = 'scaled';
100123
$valid_sizes[] = 'full';
124+
// Source-format original (e.g. the HEIC kept alongside its JPEG derivative).
125+
$valid_sizes[] = self::IMAGE_SIZE_SOURCE_ORIGINAL;
101126

102127
$items = is_string( $value ) ? array( $value ) : ( is_array( $value ) ? $value : null );
103128
if ( null === $items ) {
@@ -327,6 +352,26 @@ public function create_item_permissions_check( $request ) {
327352
$prevent_unsupported_uploads = false;
328353
}
329354

355+
/*
356+
* Always allow still HEIC/HEIF uploads through even if the server's
357+
* image editor doesn't support them. The client-side canvas fallback
358+
* handles processing using the browser's native HEVC decoder.
359+
*
360+
* The '-sequence' variants (multi-frame Live Photos) are deliberately
361+
* excluded: neither the server nor the browser fallback can process
362+
* them yet, so they should fall through to the standard unsupported
363+
* mime-type error rather than be stored unprocessable.
364+
*/
365+
$still_heic_mime_types = array( 'image/heic', 'image/heif' );
366+
367+
if (
368+
$prevent_unsupported_uploads &&
369+
! empty( $files['file']['type'] ) &&
370+
in_array( $files['file']['type'], $still_heic_mime_types, true )
371+
) {
372+
$prevent_unsupported_uploads = false;
373+
}
374+
330375
// If the upload is an image, check if the server can handle the mime type.
331376
if (
332377
$prevent_unsupported_uploads &&
@@ -1458,7 +1503,7 @@ public function get_item_schema() {
14581503
* @param string $data Supplied file data.
14591504
* @param array $headers HTTP headers from the request.
14601505
* @param string|null $time Optional. Time formatted in 'yyyy/mm'. Default null.
1461-
* @return array|WP_Error Data from wp_handle_sideload().
1506+
* @return array{ file: non-empty-string, url: non-empty-string, type: non-empty-string }|WP_Error Data from wp_handle_sideload().
14621507
*/
14631508
protected function upload_from_data( $data, $headers, $time = null ) {
14641509
if ( empty( $data ) ) {
@@ -1678,7 +1723,7 @@ public function get_collection_params() {
16781723
* @param array $files Data from the `$_FILES` superglobal.
16791724
* @param array $headers HTTP headers from the request.
16801725
* @param string|null $time Optional. Time formatted in 'yyyy/mm'. Default null.
1681-
* @return array|WP_Error Data from wp_handle_upload().
1726+
* @return array{ file: non-empty-string, url: non-empty-string, type: non-empty-string }|WP_Error Data from wp_handle_upload().
16821727
*/
16831728
protected function upload_from_file( $files, $headers, $time = null ) {
16841729
if ( empty( $files ) ) {
@@ -2173,6 +2218,13 @@ public function sideload_item( WP_REST_Request $request ) {
21732218
$sub_size_data['filesize'] = wp_filesize( $path );
21742219
} elseif ( 'original' === $image_size ) {
21752220
$sub_size_data['file'] = wp_basename( $path );
2221+
} elseif ( self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size ) {
2222+
/*
2223+
* Source-format original (e.g. the HEIC kept next to its JPEG
2224+
* derivative). Record the filename so finalize_item can store it
2225+
* under the dedicated source-image meta key.
2226+
*/
2227+
$sub_size_data['file'] = wp_basename( $path );
21762228
} elseif ( 'scaled' === $image_size ) {
21772229
// Record the current attached file as the original.
21782230
$current_file = get_attached_file( $attachment_id, true );
@@ -2328,6 +2380,15 @@ public function finalize_item( WP_REST_Request $request ) {
23282380

23292381
if ( 'original' === $image_size ) {
23302382
$metadata['original_image'] = $sub_size['file'];
2383+
} elseif ( self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size ) {
2384+
/*
2385+
* Source-format original: stored under its own meta key so the
2386+
* scaled-sideload flow (which writes 'original_image') cannot
2387+
* clobber it. 'original_image' keeps pointing at the
2388+
* web-viewable JPEG derivative. Cleanup on attachment delete
2389+
* is handled by wp_delete_attachment_files().
2390+
*/
2391+
$metadata[ self::META_KEY_SOURCE_IMAGE ] = $sub_size['file'];
23312392
} elseif ( 'scaled' === $image_size ) {
23322393
if ( ! empty( $sub_size['original_image'] ) ) {
23332394
$metadata['original_image'] = $sub_size['original_image'];
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
<?php
2+
3+
/**
4+
* Tests that wp_delete_attachment_files() removes the 'source_image' companion file.
5+
*
6+
* @group media
7+
* @covers ::wp_delete_attachment_files
8+
*/
9+
class Tests_Media_wpDeleteAttachmentSourceImage extends WP_UnitTestCase {
10+
11+
public function tear_down(): void {
12+
$this->remove_added_uploads();
13+
14+
parent::tear_down();
15+
}
16+
17+
/**
18+
* @ticket 64915
19+
*/
20+
public function test_deletes_companion_file_recorded_in_metadata_source_image(): void {
21+
$attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' );
22+
$this->assertIsInt( $attachment_id );
23+
24+
$attached_file = get_attached_file( $attachment_id, true );
25+
$this->assertIsString( $attached_file );
26+
$dir = dirname( $attached_file );
27+
$heic_name = 'companion-' . wp_generate_password( 6, false ) . '.heic';
28+
$heic_path = $dir . '/' . $heic_name;
29+
30+
// Create a dummy companion file on disk.
31+
file_put_contents( $heic_path, 'test' );
32+
$this->assertFileExists( $heic_path, 'Test fixture should be on disk.' );
33+
34+
// Record the companion under metadata['source_image'] as the sideload route does.
35+
$metadata = wp_get_attachment_metadata( $attachment_id, true );
36+
$this->assertIsArray( $metadata );
37+
$metadata['source_image'] = $heic_name;
38+
wp_update_attachment_metadata( $attachment_id, $metadata );
39+
40+
wp_delete_attachment( $attachment_id, true );
41+
42+
$this->assertNull( get_post( $attachment_id ) );
43+
$this->assertFileDoesNotExist( $heic_path, 'Companion file should be deleted alongside the attachment.' );
44+
}
45+
46+
/**
47+
* @ticket 64915
48+
*/
49+
public function test_noop_when_metadata_source_image_is_missing(): void {
50+
$attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' );
51+
$this->assertIsInt( $attachment_id );
52+
53+
// Sanity: no 'source_image' key on freshly-created metadata.
54+
$metadata = wp_get_attachment_metadata( $attachment_id, true );
55+
$this->assertIsArray( $metadata );
56+
$this->assertArrayNotHasKey( 'source_image', $metadata );
57+
58+
// Deletion should complete cleanly even though no companion file is recorded.
59+
wp_delete_attachment( $attachment_id, true );
60+
61+
$this->assertNull( get_post( $attachment_id ) );
62+
}
63+
64+
/**
65+
* Guards against $metadata['source_image'] holding a non-string value (e.g.
66+
* the array form some flows write). Regression coverage for GB #78128.
67+
*
68+
* @ticket 64915
69+
*/
70+
public function test_noop_when_metadata_source_image_is_not_a_string(): void {
71+
$attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' );
72+
$this->assertIsInt( $attachment_id );
73+
$attached_file = get_attached_file( $attachment_id, true );
74+
$this->assertIsString( $attached_file );
75+
76+
/*
77+
* Place a real file that a buggy, guard-less implementation could try to
78+
* delete after running wp_basename() over the array value below.
79+
*/
80+
$bystander_path = dirname( $attached_file ) . '/should-not-delete.heic';
81+
file_put_contents( $bystander_path, 'test' );
82+
$this->assertFileExists( $bystander_path, 'Test fixture should be on disk.' );
83+
84+
$metadata = wp_get_attachment_metadata( $attachment_id, true );
85+
$this->assertIsArray( $metadata );
86+
$metadata['source_image'] = array( 'file' => 'should-not-delete.heic' );
87+
wp_update_attachment_metadata( $attachment_id, $metadata );
88+
89+
// Deletion should not raise (no str_replace() / file deletion on an array).
90+
wp_delete_attachment( $attachment_id, true );
91+
92+
$this->assertNull( get_post( $attachment_id ) );
93+
$this->assertFileExists( $bystander_path, 'The non-string guard must prevent any file deletion.' );
94+
}
95+
}

0 commit comments

Comments
 (0)