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
2 changes: 2 additions & 0 deletions src/wp-includes/default-filters.php
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,8 @@
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( 'delete_attachment', 'wp_delete_attachment_animated_gif_video' );
add_action( 'transition_post_status', '_wp_keep_alive_customize_changeset_dependent_auto_drafts', 20, 3 );

// Block Theme Previews.
Expand Down
108 changes: 108 additions & 0 deletions src/wp-includes/media.php
Original file line number Diff line number Diff line change
Expand Up @@ -5760,6 +5760,114 @@ function wp_show_heic_upload_error( $plupload_settings ) {
return $plupload_settings;
}

/**
* Deletes the HEIC companion file when its attachment is deleted.
*
* When the client-side media flow sideloads a HEIC original alongside a
* JPEG derivative, the HEIC filename is recorded in $metadata['original'].
* WordPress only tracks 'original_image' in wp_delete_attachment_files(),
* so without this hook the HEIC file would linger on disk after the
* attachment is deleted.
*
* @since 7.1.0
*
* @param int $post_id Attachment ID being deleted.
*/
function wp_delete_attachment_heic_companion_file( $post_id ) {
$metadata = wp_get_attachment_metadata( $post_id, true );

if ( empty( $metadata['original'] ) || ! is_string( $metadata['original'] ) ) {
return;
}

$attached_file = get_attached_file( $post_id, true );

if ( ! $attached_file ) {
return;
}

$uploads = wp_get_upload_dir();

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

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

if ( file_exists( $heic_path ) ) {
wp_delete_file_from_directory( $heic_path, $uploads['basedir'] );
}
}

/**
* Returns the absolute path to one of an attachment's animated-GIF companion files
* (the converted video or its poster), if recorded.
*
* The path is rebuilt from the attachment's own (trusted) directory plus the
* recorded basename, so the stored metadata cannot point anywhere else.
*
* @since 7.1.0
*
* @param int $attachment_id Attachment ID.
* @param string $meta_key Metadata key holding the companion basename
* ('animated_video' or 'animated_video_poster').
* @return string|null Absolute file path, or null when there is no companion.
*/
function wp_get_attachment_animated_gif_companion_path( $attachment_id, $meta_key ) {
$metadata = wp_get_attachment_metadata( $attachment_id, true );

if ( empty( $metadata[ $meta_key ] ) || ! is_string( $metadata[ $meta_key ] ) ) {
return null;
}

// Only ever trust the basename of the recorded value; strip any path
// components so the metadata can't reference another directory.
$name = wp_basename( $metadata[ $meta_key ] );

if ( '' === $name ) {
return null;
}

$attached_file = get_attached_file( $attachment_id, true );

if ( ! $attached_file ) {
return null;
}

return path_join( dirname( $attached_file ), $name );
}

/**
* Deletes the sideloaded video and poster companions when their animated GIF
* attachment is deleted.
*
* When the client-side media flow converts an opaque animated GIF to a video,
* the converted MP4/WebM and a static first-frame JPEG poster are sideloaded
* alongside the GIF and recorded in $metadata['animated_video'] and
* $metadata['animated_video_poster']. WordPress core only tracks 'original_image'
* in wp_delete_attachment_files(), so without this hook the companions would
* linger on disk after the attachment is deleted.
*
* @since 7.1.0
*
* @param int $post_id Attachment ID being deleted.
*/
function wp_delete_attachment_animated_gif_video( $post_id ) {
$uploads = wp_get_upload_dir();

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

foreach ( array( 'animated_video', 'animated_video_poster' ) as $meta_key ) {
$path = wp_get_attachment_animated_gif_companion_path( $post_id, $meta_key );

if ( $path && file_exists( $path ) ) {
wp_delete_file_from_directory( $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' );
$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,15 @@ 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';
// HEIC/HEIF companion original preserved alongside the JPEG derivative.
// Stored under its own meta key so it never collides with 'original'
// (which the scaled-sideload flow also writes to).
$valid_image_sizes[] = 'original-heic';
// Animated GIF → video companions: the converted MP4/WebM and its
// first-frame poster. Stored under their own metadata keys and read
// by the editor to switch the block to the core/video GIF variation.
$valid_image_sizes[] = 'animated-video';
$valid_image_sizes[] = 'animated-video-poster';
// Used for PDF thumbnails.
$valid_image_sizes[] = 'full';
// Client-side big image threshold: sideload the scaled version.
Expand All @@ -82,21 +91,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 +272,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 +2115,23 @@ public function sideload_item( WP_REST_Request $request ) {

if ( 'original' === $image_size ) {
$metadata['original_image'] = wp_basename( $path );
} 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
// web-viewable JPEG derivative. Cleanup on attachment delete
// is handled by wp_delete_attachment_heic_companion_file().
$metadata['original'] = wp_basename( $path );
} elseif ( 'animated-video' === $image_size ) {
// Converted video companion of an animated GIF. The GIF stays
// the attachment; the editor reads this key to switch the block
// to the core/video block's "GIF" variation. Cleanup on attachment
// delete is handled by wp_delete_attachment_animated_gif_video().
$metadata['animated_video'] = wp_basename( $path );
} elseif ( 'animated-video-poster' === $image_size ) {
// Static first-frame poster for the converted video. Used as the
// video block's poster and deleted alongside the video.
$metadata['animated_video_poster'] = 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
166 changes: 166 additions & 0 deletions tests/phpunit/tests/media/wpDeleteAttachmentAnimatedGifVideo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
<?php

/**
* Tests for the `wp_delete_attachment_animated_gif_video()` function and
* its helper `wp_get_attachment_animated_gif_companion_path()`.
*
* @group media
* @covers ::wp_delete_attachment_animated_gif_video
* @covers ::wp_get_attachment_animated_gif_companion_path
*/
class Tests_Media_wpDeleteAttachmentAnimatedGifVideo extends WP_UnitTestCase {

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

parent::tear_down();
}

/**
* Creates a GIF attachment plus on-disk video + poster companions, and
* records the companion basenames under the attachment metadata as the
* sideload route does.
*
* @param bool $with_poster Whether to also create the poster companion.
* @return array{0:int,1:string,2:?string} [ attachment_id, video_path, poster_path ]
*/
private function create_gif_attachment_with_companions( $with_poster = true ) {
$attachment_id = $this->factory->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' );

$dir = dirname( get_attached_file( $attachment_id, true ) );
$video_name = 'animated-' . wp_generate_password( 6, false ) . '.mp4';
$video_path = $dir . '/' . $video_name;
file_put_contents( $video_path, 'video' );

$metadata = wp_get_attachment_metadata( $attachment_id, true );
$metadata = is_array( $metadata ) ? $metadata : array();
$metadata['animated_video'] = $video_name;

$poster_path = null;
if ( $with_poster ) {
$poster_name = 'poster-' . wp_generate_password( 6, false ) . '.jpg';
$poster_path = $dir . '/' . $poster_name;
file_put_contents( $poster_path, 'poster' );
$metadata['animated_video_poster'] = $poster_name;
}

wp_update_attachment_metadata( $attachment_id, $metadata );

return array( $attachment_id, $video_path, $poster_path );
}

/**
* The companion path is rebuilt from the attachment's own directory plus
* the recorded basename.
*
* @ticket 64915
*/
public function test_companion_path_resolves_inside_attachment_directory() {
list( $attachment_id, $video_path, $poster_path ) = $this->create_gif_attachment_with_companions();

$this->assertSame(
$video_path,
wp_get_attachment_animated_gif_companion_path( $attachment_id, 'animated_video' )
);
$this->assertSame(
$poster_path,
wp_get_attachment_animated_gif_companion_path( $attachment_id, 'animated_video_poster' )
);
}

/**
* Only the basename of the recorded value is trusted, so a path traversal
* in the metadata cannot escape the attachment's directory.
*
* @ticket 64915
*/
public function test_companion_path_ignores_directory_traversal() {
$attachment_id = $this->factory->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' );
$dir = dirname( get_attached_file( $attachment_id, true ) );

$metadata = wp_get_attachment_metadata( $attachment_id, true );
$metadata['animated_video'] = '../../evil.mp4';
wp_update_attachment_metadata( $attachment_id, $metadata );

$this->assertSame(
$dir . '/evil.mp4',
wp_get_attachment_animated_gif_companion_path( $attachment_id, 'animated_video' )
);
}

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

$this->assertNull(
wp_get_attachment_animated_gif_companion_path( $attachment_id, 'animated_video' )
);
}

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

$metadata = wp_get_attachment_metadata( $attachment_id, true );
$metadata['animated_video'] = array( 'file' => 'should-not-delete.mp4' );
wp_update_attachment_metadata( $attachment_id, $metadata );

$this->assertNull(
wp_get_attachment_animated_gif_companion_path( $attachment_id, 'animated_video' )
);
}

/**
* Both the converted video and the poster are removed when the
* attachment is deleted.
*
* @ticket 64915
*/
public function test_deletes_companion_files_on_attachment_delete() {
list( $attachment_id, $video_path, $poster_path ) = $this->create_gif_attachment_with_companions();

$this->assertFileExists( $video_path, 'Test fixture video should be on disk.' );
$this->assertFileExists( $poster_path, 'Test fixture poster should be on disk.' );

wp_delete_attachment( $attachment_id, true );

$this->assertFileDoesNotExist( $video_path, 'Converted video should be deleted alongside the attachment.' );
$this->assertFileDoesNotExist( $poster_path, 'Poster should be deleted alongside the attachment.' );
}

/**
* Only the video companion is removed when only it is recorded
* (transparent GIFs have no poster).
*
* @ticket 64915
*/
public function test_deletes_only_video_when_no_poster_recorded() {
list( $attachment_id, $video_path ) = $this->create_gif_attachment_with_companions( false );

$this->assertFileExists( $video_path, 'Test fixture video should be on disk.' );

wp_delete_attachment( $attachment_id, true );

$this->assertFileDoesNotExist( $video_path, 'Converted video should be deleted alongside the attachment.' );
}

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

$metadata = wp_get_attachment_metadata( $attachment_id, true );
$this->assertArrayNotHasKey( 'animated_video', $metadata );
$this->assertArrayNotHasKey( 'animated_video_poster', $metadata );

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

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