Skip to content

Commit 7f976bb

Browse files
REST API: Address review feedback on HEIC client-side sideload support.
Harden the companion-original handling surfaced in review: - Rename the companion metadata key from the over-generic 'original' to 'source_image' so unrelated plugin or theme data stored under 'original' can no longer drive file deletion on attachment delete. - Add IMAGE_SIZE_SOURCE_ORIGINAL and META_KEY_SOURCE_IMAGE class constants so the sideload image_size enum and its dispatch branch cannot drift. - Drop the unused generate_sub_sizes argument from the /sideload route schema; only create_item() reads it, so advertising it on sideload silently misleads clients. - Advertise the HEIC/HEIF -sequence variants in the REST index input formats so they match wp_is_heic_image_mime_type(). - Return a boolean from wp_delete_attachment_heic_companion_file() and strengthen the non-string guard test with a real on-disk bystander file so the regression it protects against can actually fail.
1 parent 9b6b768 commit 7f976bb

5 files changed

Lines changed: 95 additions & 57 deletions

File tree

src/wp-includes/media.php

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5761,42 +5761,47 @@ function wp_show_heic_upload_error( $plupload_settings ) {
57615761
}
57625762

57635763
/**
5764-
* Deletes the HEIC companion file when its attachment is deleted.
5764+
* Deletes the source-format companion file when its attachment is deleted.
57655765
*
5766-
* When the client-side media flow sideloads a HEIC original alongside a
5767-
* JPEG derivative, the HEIC filename is recorded in $metadata['original'].
5768-
* WordPress only tracks 'original_image' in wp_delete_attachment_files(),
5769-
* so without this hook the HEIC file would linger on disk after the
5770-
* attachment is deleted.
5766+
* When the client-side media flow sideloads a source-format original (such as
5767+
* a HEIC file) alongside a web-viewable derivative, the original's filename is
5768+
* recorded in the 'source_image' metadata key. WordPress only tracks
5769+
* 'original_image' in wp_delete_attachment_files(), so without this hook the
5770+
* companion file would linger on disk after the attachment is deleted.
57715771
*
57725772
* @since 7.1.0
57735773
*
57745774
* @param int $post_id Attachment ID being deleted.
5775+
* @return bool Whether a companion file was deleted.
57755776
*/
5776-
function wp_delete_attachment_heic_companion_file( $post_id ): void {
5777+
function wp_delete_attachment_heic_companion_file( $post_id ): bool {
57775778
$metadata = wp_get_attachment_metadata( $post_id, true );
57785779

5779-
if ( empty( $metadata['original'] ) || ! is_string( $metadata['original'] ) ) {
5780-
return;
5780+
if ( empty( $metadata['source_image'] ) || ! is_string( $metadata['source_image'] ) ) {
5781+
return false;
57815782
}
57825783

57835784
$attached_file = get_attached_file( $post_id, true );
57845785

57855786
if ( ! $attached_file ) {
5786-
return;
5787+
return false;
57875788
}
57885789

57895790
$uploads = wp_get_upload_dir();
57905791

57915792
if ( empty( $uploads['basedir'] ) ) {
5792-
return;
5793+
return false;
57935794
}
57945795

5795-
$heic_path = path_join( dirname( $attached_file ), wp_basename( $metadata['original'] ) );
5796+
$companion_path = path_join( dirname( $attached_file ), wp_basename( $metadata['source_image'] ) );
57965797

5797-
if ( file_exists( $heic_path ) ) {
5798-
wp_delete_file_from_directory( $heic_path, $uploads['basedir'] );
5798+
if ( ! file_exists( $companion_path ) ) {
5799+
return false;
57995800
}
5801+
5802+
wp_delete_file_from_directory( $companion_path, $uploads['basedir'] );
5803+
5804+
return true;
58005805
}
58015806

58025807
/**

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', 'image/heif' );
1383+
$input_formats = array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence' );
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: 37 additions & 17 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 = 'original-heic';
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
*
@@ -68,10 +91,12 @@ public function register_routes() {
6891
$valid_image_sizes = array_keys( wp_get_registered_image_subsizes() );
6992
// Special case to set 'original_image' in attachment metadata.
7093
$valid_image_sizes[] = 'original';
71-
// HEIC/HEIF companion original preserved alongside the JPEG derivative.
72-
// Stored under its own meta key so it never collides with 'original'
73-
// (which the scaled-sideload flow also writes to).
74-
$valid_image_sizes[] = 'original-heic';
94+
// Source-format original preserved alongside a client-generated
95+
// derivative (e.g. the HEIC kept next to its JPEG). Stored under
96+
// the dedicated self::META_KEY_SOURCE_IMAGE key so it never
97+
// collides with 'original_image' (which the scaled-sideload flow
98+
// also writes to).
99+
$valid_image_sizes[] = self::IMAGE_SIZE_SOURCE_ORIGINAL;
75100
// Used for PDF thumbnails.
76101
$valid_image_sizes[] = 'full';
77102
// Client-side big image threshold: sideload the scaled version.
@@ -86,26 +111,21 @@ public function register_routes() {
86111
'callback' => array( $this, 'sideload_item' ),
87112
'permission_callback' => array( $this, 'sideload_item_permissions_check' ),
88113
'args' => array(
89-
'id' => array(
114+
'id' => array(
90115
'description' => __( 'Unique identifier for the attachment.' ),
91116
'type' => 'integer',
92117
),
93-
'image_size' => array(
118+
'image_size' => array(
94119
'description' => __( 'Image size.' ),
95120
'type' => 'string',
96121
'enum' => $valid_image_sizes,
97122
'required' => true,
98123
),
99-
'convert_format' => array(
124+
'convert_format' => array(
100125
'type' => 'boolean',
101126
'default' => true,
102127
'description' => __( 'Whether to convert image formats.' ),
103128
),
104-
'generate_sub_sizes' => array(
105-
'description' => __( 'Whether to generate image sub sizes from the sideloaded file.' ),
106-
'type' => 'boolean',
107-
'default' => false,
108-
),
109129
),
110130
),
111131
'allow_batch' => $this->allow_batch,
@@ -2110,13 +2130,13 @@ public function sideload_item( WP_REST_Request $request ) {
21102130

21112131
if ( 'original' === $image_size ) {
21122132
$metadata['original_image'] = wp_basename( $path );
2113-
} elseif ( 'original-heic' === $image_size ) {
2114-
// HEIC companion original: stored under its own meta key so
2115-
// the scaled-sideload flow (which writes 'original_image')
2116-
// cannot clobber it. 'original_image' keeps pointing at the
2133+
} elseif ( self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size ) {
2134+
// Source-format original: stored under its own meta key so the
2135+
// scaled-sideload flow (which writes 'original_image') cannot
2136+
// clobber it. 'original_image' keeps pointing at the
21172137
// web-viewable JPEG derivative. Cleanup on attachment delete
21182138
// is handled by wp_delete_attachment_heic_companion_file().
2119-
$metadata['original'] = wp_basename( $path );
2139+
$metadata[ self::META_KEY_SOURCE_IMAGE ] = wp_basename( $path );
21202140
} elseif ( 'scaled' === $image_size ) {
21212141
// The current attached file is the original; record it as original_image.
21222142
$current_file = get_attached_file( $attachment_id, true );

tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ public function tear_down(): void {
1717
/**
1818
* @ticket 64915
1919
*/
20-
public function test_deletes_heic_file_recorded_in_metadata_original(): void {
20+
public function test_deletes_companion_file_recorded_in_metadata_source_image(): void {
2121
$attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' );
2222
$this->assertIsInt( $attachment_id );
2323

@@ -31,55 +31,66 @@ public function test_deletes_heic_file_recorded_in_metadata_original(): void {
3131
file_put_contents( $heic_path, 'test' );
3232
$this->assertFileExists( $heic_path, 'Test fixture should be on disk.' );
3333

34-
// Record the companion under metadata['original'] as the sideload route does.
34+
// Record the companion under metadata['source_image'] as the sideload route does.
3535
$metadata = wp_get_attachment_metadata( $attachment_id, true );
3636
$this->assertIsArray( $metadata );
37-
$metadata['original'] = $heic_name;
37+
$metadata['source_image'] = $heic_name;
3838
wp_update_attachment_metadata( $attachment_id, $metadata );
3939

40-
wp_delete_attachment( $attachment_id, true );
41-
42-
$this->assertFileDoesNotExist( $heic_path, 'Companion HEIC file should be deleted alongside the attachment.' );
40+
$this->assertTrue(
41+
wp_delete_attachment_heic_companion_file( $attachment_id ),
42+
'Function should report that a companion file was deleted.'
43+
);
44+
$this->assertFileDoesNotExist( $heic_path, 'Companion file should be deleted alongside the attachment.' );
4345
}
4446

4547
/**
4648
* @ticket 64915
4749
*/
48-
public function test_noop_when_metadata_original_is_missing(): void {
50+
public function test_noop_when_metadata_source_image_is_missing(): void {
4951
$attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' );
5052
$this->assertIsInt( $attachment_id );
5153

52-
// Sanity: no 'original' key on freshly-created metadata.
54+
// Sanity: no 'source_image' key on freshly-created metadata.
5355
$metadata = wp_get_attachment_metadata( $attachment_id, true );
5456
$this->assertIsArray( $metadata );
55-
$this->assertArrayNotHasKey( 'original', $metadata );
57+
$this->assertArrayNotHasKey( 'source_image', $metadata );
58+
59+
// Should report no deletion and not raise even though the hook fires.
60+
$this->assertFalse( wp_delete_attachment_heic_companion_file( $attachment_id ) );
5661

57-
// Should not raise even though the hook fires.
5862
wp_delete_attachment( $attachment_id, true );
5963

6064
$this->assertNull( get_post( $attachment_id ) );
6165
}
6266

6367
/**
64-
* Guards against $metadata['original'] holding a non-string value (e.g.
68+
* Guards against $metadata['source_image'] holding a non-string value (e.g.
6569
* the array form some flows write). Regression coverage for GB #78128.
6670
*
6771
* @ticket 64915
6872
*/
69-
public function test_noop_when_metadata_original_is_not_a_string(): void {
73+
public function test_noop_when_metadata_source_image_is_not_a_string(): void {
7074
$attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' );
7175
$this->assertIsInt( $attachment_id );
7276
$attached_file = get_attached_file( $attachment_id, true );
7377
$this->assertIsString( $attached_file );
7478

79+
// Place a real file that a buggy, guard-less implementation could try to
80+
// delete after running wp_basename() over the array value below.
81+
$bystander_path = dirname( $attached_file ) . '/should-not-delete.heic';
82+
file_put_contents( $bystander_path, 'test' );
83+
$this->assertFileExists( $bystander_path, 'Test fixture should be on disk.' );
84+
7585
$metadata = wp_get_attachment_metadata( $attachment_id, true );
7686
$this->assertIsArray( $metadata );
77-
$metadata['original'] = array( 'file' => 'should-not-delete.heic' );
87+
$metadata['source_image'] = array( 'file' => 'should-not-delete.heic' );
7888
wp_update_attachment_metadata( $attachment_id, $metadata );
7989

80-
// Should not raise (no path_join() / file_exists() on an array).
81-
wp_delete_attachment_heic_companion_file( $attachment_id );
90+
// Should report no deletion and not raise (no path_join() / file_exists() on an array).
91+
$this->assertFalse( wp_delete_attachment_heic_companion_file( $attachment_id ) );
8292

83-
$this->assertFileExists( $attached_file, 'Attached file should still be on disk; the hook must bail on non-string original.' );
93+
$this->assertFileExists( $bystander_path, 'The non-string guard must prevent any file deletion.' );
94+
$this->assertFileExists( $attached_file, 'Attached file should still be on disk.' );
8495
}
8596
}

tests/phpunit/tests/rest-api/rest-attachments-controller.php

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3368,33 +3368,35 @@ public function test_sideload_route_includes_original_heic_enum(): void {
33683368
}
33693369

33703370
/**
3371-
* Tests that the sideload endpoint exposes the generate_sub_sizes arg.
3371+
* Tests that the sideload endpoint does not expose a generate_sub_sizes arg.
3372+
*
3373+
* sideload_item() never reads the parameter, so advertising it on the route
3374+
* would silently mislead clients into expecting server-side sub-size
3375+
* generation. The arg only does real work on create_item() (POST /wp/v2/media).
33723376
*
33733377
* @ticket 64915
33743378
*/
3375-
public function test_sideload_route_includes_generate_sub_sizes_arg(): void {
3379+
public function test_sideload_route_excludes_generate_sub_sizes_arg(): void {
33763380
$this->enable_client_side_media_processing();
33773381

33783382
$routes = rest_get_server()->get_routes();
33793383
$endpoint = $routes['/wp/v2/media/(?P<id>[\d]+)/sideload'][0];
33803384
$args = $endpoint['args'];
33813385
$this->assertIsArray( $args );
33823386

3383-
$this->assertArrayHasKey( 'generate_sub_sizes', $args, 'Route should have generate_sub_sizes arg.' );
3384-
$this->assertSame( 'boolean', $args['generate_sub_sizes']['type'], 'generate_sub_sizes should be a boolean.' );
3385-
$this->assertFalse( $args['generate_sub_sizes']['default'], 'generate_sub_sizes should default to false on sideload.' );
3387+
$this->assertArrayNotHasKey( 'generate_sub_sizes', $args, 'Sideload route should not advertise the unused generate_sub_sizes arg.' );
33863388
}
33873389

33883390
/**
33893391
* Tests sideloading an 'original-heic' companion file alongside its JPEG
3390-
* derivative. The HEIC filename is recorded under $metadata['original']
3392+
* derivative. The HEIC filename is recorded under $metadata['source_image']
33913393
* so it does not collide with 'original_image', which the scaled-sideload
33923394
* flow owns.
33933395
*
33943396
* @ticket 64915
33953397
* @requires function imagejpeg
33963398
*/
3397-
public function test_sideload_original_heic_writes_metadata_original(): void {
3399+
public function test_sideload_original_heic_writes_metadata_source_image(): void {
33983400
$this->enable_client_side_media_processing();
33993401

34003402
wp_set_current_user( self::$author_id );
@@ -3425,8 +3427,8 @@ public function test_sideload_original_heic_writes_metadata_original(): void {
34253427

34263428
$metadata = wp_get_attachment_metadata( $attachment_id );
34273429
$this->assertIsArray( $metadata );
3428-
$this->assertArrayHasKey( 'original', $metadata, "Metadata should contain 'original' for the HEIC companion." );
3429-
$this->assertMatchesRegularExpression( '/canola.*\.heic$/', $metadata['original'], "Metadata 'original' should reference the HEIC filename." );
3430+
$this->assertArrayHasKey( 'source_image', $metadata, "Metadata should contain 'source_image' for the HEIC companion." );
3431+
$this->assertMatchesRegularExpression( '/canola.*\.heic$/', $metadata['source_image'], "Metadata 'source_image' should reference the HEIC filename." );
34303432
$this->assertArrayNotHasKey( 'original_image', $metadata, "Metadata 'original_image' should be untouched by the HEIC sideload." );
34313433
}
34323434

0 commit comments

Comments
 (0)