Skip to content
Open
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 src/wp-includes/default-filters.php
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,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_animated_gif_video' );
add_action( 'transition_post_status', '_wp_keep_alive_customize_changeset_dependent_auto_drafts', 20, 3 );

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

/**
* 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
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ 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';
// 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 +87,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 +268,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 +2111,16 @@ public function sideload_item( WP_REST_Request $request ) {

if ( 'original' === $image_size ) {
$metadata['original_image'] = 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 65549
*/
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 65549
*/
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 65549
*/
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 65549
*/
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 65549
*/
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 65549
*/
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 65549
*/
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 ) );
}
}
17 changes: 17 additions & 0 deletions tests/phpunit/tests/rest-api/rest-attachments-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -3351,6 +3351,23 @@ public function test_sideload_route_includes_scaled_enum() {
$this->assertContains( 'scaled', $args[ $param_name ]['enum'], 'image_size enum should include scaled.' );
}

/**
* Tests that the sideload endpoint exposes the generate_sub_sizes arg.
*
* @ticket 65549
*/
public function test_sideload_route_includes_generate_sub_sizes_arg() {
$this->enable_client_side_media_processing();

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

$this->assertArrayHasKey( 'generate_sub_sizes', $args, 'Route should have generate_sub_sizes arg.' );
$this->assertSame( 'boolean', $args['generate_sub_sizes']['type'], 'generate_sub_sizes should be a boolean.' );
$this->assertFalse( $args['generate_sub_sizes']['default'], 'generate_sub_sizes should default to false on sideload.' );
}

/**
* Tests the filter_wp_unique_filename method handles the -scaled suffix.
*
Expand Down
8 changes: 8 additions & 0 deletions tests/qunit/fixtures/wp-api-generated.js
Original file line number Diff line number Diff line change
Expand Up @@ -3703,6 +3703,8 @@ mockedApiResponse.Schema = {
"1536x1536",
"2048x2048",
"original",
"animated-video",
"animated-video-poster",
"full",
"scaled"
],
Expand All @@ -3713,6 +3715,12 @@ mockedApiResponse.Schema = {
"default": true,
"description": "Whether to convert image formats.",
"required": false
},
"generate_sub_sizes": {
"description": "Whether to generate image sub sizes from the sideloaded file.",
"type": "boolean",
"default": false,
"required": false
}
}
}
Expand Down
Loading