Skip to content
Merged
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
1 change: 1 addition & 0 deletions backport-changelog/7.1/11323.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ https://github.com/WordPress/wordpress-develop/pull/11323

* https://github.com/WordPress/gutenberg/pull/76731
* https://github.com/WordPress/gutenberg/pull/78128
* https://github.com/WordPress/gutenberg/pull/79307
50 changes: 36 additions & 14 deletions lib/media/class-gutenberg-rest-attachments-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,27 @@
* functionality including sideload support and sub-size generation control.
*/
class Gutenberg_REST_Attachments_Controller extends WP_REST_Attachments_Controller {
/**
* 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.
*
* @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.
*
* @var string
*/
const META_KEY_SOURCE_IMAGE = 'source_image';

/**
* Registers the routes for attachments.
*
Expand Down Expand Up @@ -50,7 +71,7 @@ public function register_routes(): void {
'validate_callback' => static function ( $value, $request, $param ) {
$valid_sizes = array_keys( wp_get_registered_image_subsizes() );
$valid_sizes[] = 'original';
$valid_sizes[] = 'original-heic';
$valid_sizes[] = self::IMAGE_SIZE_SOURCE_ORIGINAL;
$valid_sizes[] = 'scaled';
$valid_sizes[] = 'full';

Expand Down Expand Up @@ -484,13 +505,13 @@ public function finalize_item( WP_REST_Request $request ) {

if ( 'original' === $image_size ) {
$metadata['original_image'] = $sub_size['file'];
} elseif ( 'original-heic' === $image_size ) {
// HEIC 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
} 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 a delete_attachment hook that reads this key.
$metadata['original'] = $sub_size['file'];
$metadata[ self::META_KEY_SOURCE_IMAGE ] = $sub_size['file'];
} elseif ( 'scaled' === $image_size ) {
if ( ! empty( $sub_size['original_image'] ) ) {
$metadata['original_image'] = $sub_size['original_image'];
Expand Down Expand Up @@ -632,8 +653,8 @@ private function validate_image_dimensions( int $width, int $height, $image_size
return true;
}

// 'original-heic' companion file: no dimension constraint.
if ( 'original-heic' === $image_size ) {
// Source-format original companion file: no dimension constraint.
if ( self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size ) {
return true;
}

Expand Down Expand Up @@ -833,7 +854,7 @@ public function sideload_item( WP_REST_Request $request ) {
// `image_size` may be a single string or an array of names that share the
// same dimensions and therefore reuse a single sideloaded file. Arrays
// only carry regular sub-sizes; the special keys below ('original',
// 'scaled', 'original-heic') are always scalar strings.
// 'scaled', and the source-format original) are always scalar strings.
$sub_size_data = array(
'image_size' => $image_size,
);
Expand All @@ -846,11 +867,12 @@ public function sideload_item( WP_REST_Request $request ) {
$sub_size_data['filesize'] = wp_filesize( $path );
} elseif ( 'original' === $image_size ) {
$sub_size_data['file'] = wp_basename( $path );
} elseif ( 'original-heic' === $image_size ) {
// HEIC companion original. finalize_item() writes the filename to
// $metadata['original'] (separate from 'original_image', which the
// scaled-sideload flow owns). Cleanup on attachment delete is
// handled by a delete_attachment hook that reads this key.
} elseif ( self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size ) {
// Source-format original. finalize_item() writes the filename to
// $metadata[ self::META_KEY_SOURCE_IMAGE ] (separate from
// 'original_image', which the scaled-sideload flow owns). Cleanup on
// attachment delete is handled by a delete_attachment hook that reads
// this key.
$sub_size_data['file'] = wp_basename( $path );
} elseif ( 'scaled' === $image_size ) {
// Record the current attached file as the original.
Expand Down
35 changes: 23 additions & 12 deletions lib/media/load.php
Original file line number Diff line number Diff line change
Expand Up @@ -190,33 +190,44 @@ function gutenberg_set_heic_upload_support_flag() {
add_action( 'admin_init', 'gutenberg_set_heic_upload_support_flag' );

/**
* Deletes the HEIC companion file when its attachment is deleted.
* Deletes the source-format companion file when its attachment is deleted.
*
* The HEIC is sideloaded alongside a JPEG derivative and recorded in
* $metadata['original']. WordPress core's wp_delete_attachment_files()
* only knows about 'original_image', so without this hook the HEIC
* would linger on disk after the 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.
*
* @param int $post_id Attachment ID being deleted.
* @return bool Whether a companion file was deleted.
*/
function gutenberg_delete_heic_companion_file( int $post_id ): void {
function gutenberg_delete_heic_companion_file( int $post_id ): bool {
$metadata = wp_get_attachment_metadata( $post_id, true );

if ( empty( $metadata['original'] ) || ! is_string( $metadata['original'] ) ) {
return;
$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;
return false;
}

$uploads = wp_get_upload_dir();

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

$heic_path = path_join( dirname( $attached_file ), $metadata['original'] );
$companion_path = path_join( dirname( $attached_file ), wp_basename( $source_image ) );

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

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

add_action( 'delete_attachment', 'gutenberg_delete_heic_companion_file' );
Expand Down
70 changes: 70 additions & 0 deletions phpunit/media/gutenberg-delete-heic-companion-file-test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php
/**
* Tests for gutenberg_delete_heic_companion_file().
*
* Covers cleanup of the source-format companion file recorded under the
* 'source_image' metadata key when its attachment is deleted.
*
* @group media
* @covers ::gutenberg_delete_heic_companion_file
*/
class Gutenberg_Delete_HEIC_Companion_File_Test extends WP_UnitTestCase {

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

parent::tear_down();
}

/**
* The companion file recorded in metadata['source_image'] is removed from
* disk when the attachment is deleted.
*/
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(
gutenberg_delete_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.' );
}

/**
* No companion file is recorded, so the hook is a no-op and attachment
* deletion still proceeds normally.
*/
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( gutenberg_delete_heic_companion_file( $attachment_id ) );

wp_delete_attachment( $attachment_id, true );

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