diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index cf895eb748dbe..77ecbf9856376 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -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. diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index d318a275a9607..16fa5bc49fdac 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -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. * diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index 21805778ba659..d6ad86c3fcf70 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -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. @@ -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, @@ -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 && @@ -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 ); diff --git a/tests/phpunit/tests/media/wpDeleteAttachmentAnimatedGifVideo.php b/tests/phpunit/tests/media/wpDeleteAttachmentAnimatedGifVideo.php new file mode 100644 index 0000000000000..0bb8091cb972d --- /dev/null +++ b/tests/phpunit/tests/media/wpDeleteAttachmentAnimatedGifVideo.php @@ -0,0 +1,166 @@ +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 ) ); + } +} diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 79e9d23cf9dd3..20d5ea8c0bece 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -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[\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. * diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index fa03d9751fe99..8fe3064651546 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -3703,6 +3703,8 @@ mockedApiResponse.Schema = { "1536x1536", "2048x2048", "original", + "animated-video", + "animated-video-poster", "full", "scaled" ], @@ -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 } } }