Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
bf9f644
Media: Allow HEIC/HEIF uploads when server lacks support
adamsilverstein Mar 20, 2026
71a9e8b
Merge remote-tracking branch 'origin/trunk' into add/heic-canvas-fall…
adamsilverstein Apr 22, 2026
630266f
Merge remote-tracking branch 'origin/trunk' into add/heic-canvas-fall…
adamsilverstein May 28, 2026
b261f30
REST API: Add HEIC client-side support to the sideload route.
adamsilverstein May 28, 2026
c711280
Media: Delete HEIC companion file when its attachment is deleted.
adamsilverstein May 28, 2026
b20bbca
Tests: Cover the HEIC client-side sideload and companion-delete flow.
adamsilverstein May 28, 2026
eea07d2
Tests: Use HEIC fixture and convert_format=false for original-heic si…
adamsilverstein May 28, 2026
d976d2e
Tests: Refresh wp-api-generated.js fixture for the sideload route.
adamsilverstein May 28, 2026
d01bab6
Add void return type hints
westonruter May 28, 2026
f728cfb
Use non-deprecated factory
westonruter May 28, 2026
d6f69d6
Add assertions for successful attachment creation
westonruter May 28, 2026
beb9ffe
Add types and type assertions to tests
westonruter May 28, 2026
9b6b768
Update src/wp-includes/media.php
adamsilverstein May 28, 2026
7f976bb
REST API: Address review feedback on HEIC client-side sideload support.
adamsilverstein May 28, 2026
df33a07
Tests: Sync wp-api-generated.js fixture after dropping sideload gener…
adamsilverstein May 28, 2026
c3c02f9
Apply suggestions from code review
adamsilverstein Jun 18, 2026
ef1767d
Merge branch 'trunk' of https://github.com/WordPress/wordpress-develo…
westonruter Jun 18, 2026
a07580e
Fix Squiz.WhiteSpace.SuperfluousWhitespace.EndLine
westonruter Jun 18, 2026
9f40ed4
Incorporate file.php phpstan-return types from https://github.com/Wor…
westonruter Jun 18, 2026
b11a774
Add return types for wp_get_upload_dir() and wp_upload_dir()
westonruter Jun 18, 2026
18cc9e3
Add types to get_file_params()/set_file_params() in WP_REST_Request
westonruter Jun 18, 2026
509309f
Add return type for WP_REST_Attachments_Controller::upload_from_data(…
westonruter Jun 18, 2026
546d470
Add return type definition for wp_get_attachment_metadata()
westonruter Jun 19, 2026
08b4fa5
Add source_image to possible keys returned by wp_get_attachment_metad…
westonruter Jun 19, 2026
46bccdb
Add phpstan/phpstan-phpunit for PHPUnit assertion type narrowing
westonruter Jun 19, 2026
6dfdba3
Combine sideload route tests to avoid having to re-assert shape
westonruter Jun 19, 2026
5b2b81b
Add assertions that response is array with id key
westonruter Jun 19, 2026
1e42037
Restore return description
westonruter Jun 19, 2026
707326c
Add missing nullable original_image to metadata array shape
westonruter Jun 19, 2026
1b7c752
Indicate attachment metadata shape is unsealed (since filterable)
westonruter Jun 19, 2026
be9dd94
Fix unsealed array syntax
westonruter Jun 19, 2026
24676d1
Make wp_get_attachment_metadata() return shape reflect all attachment…
westonruter Jun 19, 2026
e26b99d
Tests: Read sideload route args from the endpoint, not the endpoint i…
adamsilverstein Jun 19, 2026
5f0c396
REST API: Extract PHPStan type-coverage changes to a separate PR.
adamsilverstein Jun 25, 2026
4ff3b5b
Merge branch 'trunk' into add/heic-canvas-fallback
adamsilverstein Jun 25, 2026
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
1 change: 1 addition & 0 deletions src/wp-includes/default-filters.php
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,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.
Expand Down
43 changes: 43 additions & 0 deletions src/wp-includes/media.php
Original file line number Diff line number Diff line change
Expand Up @@ -5771,6 +5771,49 @@ function wp_show_heic_upload_error( $plupload_settings ) {
return $plupload_settings;
}

/**
* Deletes the source-format companion file when its attachment is deleted.
*
* When the client-side media flow sideloads a source-format original (such as
* a HEIC file) alongside a web-viewable derivative, the original's filename is
* recorded in the 'source_image' metadata key. WordPress only tracks
* 'original_image' in wp_delete_attachment_files(), so without this hook the
* companion file would linger on disk after the attachment is deleted.
*
* @since 7.1.0
*
* @param int $post_id Attachment ID being deleted.
* @return bool Whether a companion file was deleted.
*/
function wp_delete_attachment_heic_companion_file( $post_id ): bool {
$metadata = wp_get_attachment_metadata( $post_id, true );
Comment thread
adamsilverstein marked this conversation as resolved.

$source_image = $metadata['source_image'] ?? null;
if ( ! is_string( $source_image ) || '' === $source_image ) {
return false;
}

$attached_file = get_attached_file( $post_id, true );

if ( ! $attached_file ) {
return false;
}

$uploads = wp_get_upload_dir();

if ( empty( $uploads['basedir'] ) ) {
return false;
}

$companion_path = path_join( dirname( $attached_file ), wp_basename( $source_image ) );

if ( ! file_exists( $companion_path ) ) {
return false;
}

return wp_delete_file_from_directory( $companion_path, $uploads['basedir'] );
}

/**
* 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', 'image/heic-sequence', 'image/heif-sequence' );
$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 @@ -24,6 +24,29 @@ class WP_REST_Attachments_Controller extends WP_REST_Posts_Controller {
*/
protected $allow_batch = false;

/**
* Image size token for the source-format original preserved alongside a
* client-generated derivative (e.g. the HEIC file kept next to its JPEG).
*
* Used both in the `/sideload` route schema and when dispatching the
* sideloaded file to its metadata key, so the two never drift apart.
*
* @since 7.1.0
* @var string
*/
const IMAGE_SIZE_SOURCE_ORIGINAL = 'original-heic';

/**
* Metadata key holding the basename of the source-format original.
*
* Deliberately specific so it never collides with the generic `original`
* or `original_image` keys other flows write to.
*
* @since 7.1.0
* @var string
*/
const META_KEY_SOURCE_IMAGE = 'source_image';

/**
* Registers the routes for attachments.
*
Expand Down Expand Up @@ -68,6 +91,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';
// Source-format original preserved alongside a client-generated
// derivative (e.g. the HEIC kept next to its JPEG). Stored under
// the dedicated self::META_KEY_SOURCE_IMAGE key so it never
// collides with 'original_image' (which the scaled-sideload flow
// also writes to).
$valid_image_sizes[] = self::IMAGE_SIZE_SOURCE_ORIGINAL;
// Used for PDF thumbnails.
$valid_image_sizes[] = 'full';
// Client-side big image threshold: sideload the scaled version.
Expand Down Expand Up @@ -258,6 +287,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 @@ -1389,7 +1429,7 @@ public function get_item_schema() {
* @param string $data Supplied file data.
* @param array $headers HTTP headers from the request.
* @param string|null $time Optional. Time formatted in 'yyyy/mm'. Default null.
* @return array|WP_Error Data from wp_handle_sideload().
* @return array{ file: non-empty-string, url: non-empty-string, type: non-empty-string }|WP_Error Data from wp_handle_sideload().
*/
protected function upload_from_data( $data, $headers, $time = null ) {
if ( empty( $data ) ) {
Expand Down Expand Up @@ -1609,7 +1649,7 @@ public function get_collection_params() {
* @param array $files Data from the `$_FILES` superglobal.
* @param array $headers HTTP headers from the request.
* @param string|null $time Optional. Time formatted in 'yyyy/mm'. Default null.
* @return array|WP_Error Data from wp_handle_upload().
* @return array{ file: non-empty-string, url: non-empty-string, type: non-empty-string }|WP_Error Data from wp_handle_upload().
*/
protected function upload_from_file( $files, $headers, $time = null ) {
if ( empty( $files ) ) {
Expand Down Expand Up @@ -2090,6 +2130,13 @@ public function sideload_item( WP_REST_Request $request ) {

if ( 'original' === $image_size ) {
$metadata['original_image'] = wp_basename( $path );
} elseif ( self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size ) {
// Source-format 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[ self::META_KEY_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
96 changes: 96 additions & 0 deletions tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

/**
* Tests for the `wp_delete_attachment_heic_companion_file()` function.
*
* @group media
* @covers ::wp_delete_attachment_heic_companion_file
*/
class Tests_Media_wpDeleteAttachmentHeicCompanionFile extends WP_UnitTestCase {

public function tear_down(): void {
$this->remove_added_uploads();

parent::tear_down();
}

/**
* @ticket 64915
*/
public function test_deletes_companion_file_recorded_in_metadata_source_image(): void {
$attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' );
$this->assertIsInt( $attachment_id );

$attached_file = get_attached_file( $attachment_id, true );
$this->assertIsString( $attached_file );
$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['source_image'] as the sideload route does.
$metadata = wp_get_attachment_metadata( $attachment_id, true );
$this->assertIsArray( $metadata );
$metadata['source_image'] = $heic_name;
wp_update_attachment_metadata( $attachment_id, $metadata );

$this->assertTrue(
wp_delete_attachment_heic_companion_file( $attachment_id ),
'Function should report that a companion file was deleted.'
);
$this->assertFileDoesNotExist( $heic_path, 'Companion file should be deleted alongside the attachment.' );
}

/**
* @ticket 64915
*/
public function test_noop_when_metadata_source_image_is_missing(): void {
$attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' );
$this->assertIsInt( $attachment_id );

// Sanity: no 'source_image' key on freshly-created metadata.
$metadata = wp_get_attachment_metadata( $attachment_id, true );
$this->assertIsArray( $metadata );
$this->assertArrayNotHasKey( 'source_image', $metadata );

// Should report no deletion and not raise even though the hook fires.
$this->assertFalse( wp_delete_attachment_heic_companion_file( $attachment_id ) );

wp_delete_attachment( $attachment_id, true );

$this->assertNull( get_post( $attachment_id ) );
}

/**
* Guards against $metadata['source_image'] 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_source_image_is_not_a_string(): void {
$attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' );
$this->assertIsInt( $attachment_id );
$attached_file = get_attached_file( $attachment_id, true );
$this->assertIsString( $attached_file );

// Place a real file that a buggy, guard-less implementation could try to
// delete after running wp_basename() over the array value below.
$bystander_path = dirname( $attached_file ) . '/should-not-delete.heic';
file_put_contents( $bystander_path, 'test' );
$this->assertFileExists( $bystander_path, 'Test fixture should be on disk.' );

$metadata = wp_get_attachment_metadata( $attachment_id, true );
$this->assertIsArray( $metadata );
$metadata['source_image'] = array( 'file' => 'should-not-delete.heic' );
wp_update_attachment_metadata( $attachment_id, $metadata );

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

$this->assertFileExists( $bystander_path, 'The non-string guard must prevent any file deletion.' );
$this->assertFileExists( $attached_file, 'Attached file should still be on disk.' );
}
}
Loading
Loading