From bf9f644f76d27f7bb1c71643491a7efaf266822e Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 20 Mar 2026 09:59:22 -0700 Subject: [PATCH 1/7] Media: Allow HEIC/HEIF uploads when server lacks support Bypass the `wp_prevent_unsupported_mime_type_uploads` check for HEIC/HEIF images so they can be stored even when the server's image editor doesn't support them. The client-side canvas fallback handles processing using the browser's native HEVC decoder via createImageBitmap(). --- .../class-wp-rest-attachments-controller.php | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 cb714d5a5de71..50467f788ada4 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 @@ -258,6 +258,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 && From b261f30d1d30e43eef56ddd3b9896e6e632bc595 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 28 May 2026 11:54:02 -0400 Subject: [PATCH 2/7] REST API: Add HEIC client-side support to the sideload route. Extends the /wp/v2/media//sideload route so the client-side media flow can upload a HEIC/HEIF companion original alongside the JPEG derivative: - Adds 'original-heic' to the allowed image_size enum. The companion filename is recorded under $metadata['original'] so it never collides with 'original_image', which the scaled-sideload flow owns. - Adds a 'generate_sub_sizes' boolean arg (default false) so callers that handle processing client-side can suppress server-side sub-size generation per request. - Adds 'image/heif' to the image_output_formats input list returned by the REST API root index. Backport of GB #76731. --- .../rest-api/class-wp-rest-server.php | 2 +- .../class-wp-rest-attachments-controller.php | 22 ++++++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/rest-api/class-wp-rest-server.php b/src/wp-includes/rest-api/class-wp-rest-server.php index 704a990298826..192f76e2d4c5a 100644 --- a/src/wp-includes/rest-api/class-wp-rest-server.php +++ b/src/wp-includes/rest-api/class-wp-rest-server.php @@ -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 */ 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 3de551148d99d..d979872f96158 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,10 @@ 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'; // Used for PDF thumbnails. $valid_image_sizes[] = 'full'; // Client-side big image threshold: sideload the scaled version. @@ -82,21 +86,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, @@ -2101,6 +2110,13 @@ 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 ( 'scaled' === $image_size ) { // The current attached file is the original; record it as original_image. $current_file = get_attached_file( $attachment_id, true ); From c711280a0c2b811099c6ea57dcd848e4750eafa2 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 28 May 2026 11:54:09 -0400 Subject: [PATCH 3/7] Media: Delete 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 stored in $metadata['original']. wp_delete_attachment_files() only tracks 'original_image', so without this hook the HEIC file would linger on disk after the attachment is removed. wp_delete_attachment_heic_companion_file() reads the meta key, guards against non-string values (e.g. arrays written by other flows), and deletes the file when present. Hooked into the delete_attachment action via default-filters.php. Backport of GB #76731, with the is_string() guard from GB #78128. --- src/wp-includes/default-filters.php | 1 + src/wp-includes/media.php | 39 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index cf895eb748dbe..de8e9fb3364d6 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_heic_companion_file' ); 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..13fb4f9c6a7d9 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -5760,6 +5760,45 @@ 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'] ); + } +} + /** * Allows PHP's getimagesize() to be debuggable when necessary. * From b20bbcaf447c2478680897595abdd8bf23dfa0aa Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 28 May 2026 11:57:14 -0400 Subject: [PATCH 4/7] Tests: Cover the HEIC client-side sideload and companion-delete flow. Adds REST API controller tests: - The sideload route exposes 'original-heic' in the image_size enum. - The sideload route exposes a 'generate_sub_sizes' boolean arg defaulting to false. - Sideloading an 'original-heic' image writes the filename to $metadata['original'] and leaves 'original_image' untouched. Adds wp_delete_attachment_heic_companion_file() unit tests: - The companion HEIC is removed when the attachment is deleted. - The hook is a no-op when $metadata['original'] is missing. - The hook bails when $metadata['original'] is not a string (regression coverage for the guard added in GB #78128). --- .../wpDeleteAttachmentHeicCompanionFile.php | 77 +++++++++++++++++++ .../rest-api/rest-attachments-controller.php | 74 ++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php diff --git a/tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php b/tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php new file mode 100644 index 0000000000000..d3e2dc0256321 --- /dev/null +++ b/tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php @@ -0,0 +1,77 @@ +remove_added_uploads(); + + parent::tear_down(); + } + + /** + * @ticket 64915 + */ + public function test_deletes_heic_file_recorded_in_metadata_original() { + $attachment_id = $this->factory->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); + + $attached_file = get_attached_file( $attachment_id, true ); + $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['original'] as the sideload route does. + $metadata = wp_get_attachment_metadata( $attachment_id, true ); + $metadata['original'] = $heic_name; + wp_update_attachment_metadata( $attachment_id, $metadata ); + + wp_delete_attachment( $attachment_id, true ); + + $this->assertFileDoesNotExist( $heic_path, 'Companion HEIC file should be deleted alongside the attachment.' ); + } + + /** + * @ticket 64915 + */ + public function test_noop_when_metadata_original_is_missing() { + $attachment_id = $this->factory->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); + + // Sanity: no 'original' key on freshly-created metadata. + $metadata = wp_get_attachment_metadata( $attachment_id, true ); + $this->assertArrayNotHasKey( 'original', $metadata ); + + // Should not raise even though the hook fires. + wp_delete_attachment( $attachment_id, true ); + + $this->assertNull( get_post( $attachment_id ) ); + } + + /** + * Guards against $metadata['original'] 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_original_is_not_a_string() { + $attachment_id = $this->factory->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); + $attached_file = get_attached_file( $attachment_id, true ); + + $metadata = wp_get_attachment_metadata( $attachment_id, true ); + $metadata['original'] = array( 'file' => 'should-not-delete.heic' ); + wp_update_attachment_metadata( $attachment_id, $metadata ); + + // Should not raise (no path_join() / file_exists() on an array). + wp_delete_attachment_heic_companion_file( $attachment_id ); + + $this->assertFileExists( $attached_file, 'Attached file should still be on disk; the hook must bail on non-string original.' ); + } +} diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 79e9d23cf9dd3..def3559b3d602 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3351,6 +3351,80 @@ 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 includes 'original-heic' in the image_size enum. + * + * @ticket 64915 + */ + public function test_sideload_route_includes_original_heic_enum() { + $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( 'image_size', $args, 'Route should have image_size arg.' ); + $this->assertContains( 'original-heic', $args['image_size']['enum'], 'image_size enum should include original-heic.' ); + } + + /** + * Tests that the sideload endpoint exposes the generate_sub_sizes arg. + * + * @ticket 64915 + */ + 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 sideloading an 'original-heic' companion file alongside its JPEG + * derivative. The HEIC filename is recorded under $metadata['original'] + * so it does not collide with 'original_image', which the scaled-sideload + * flow owns. + * + * @ticket 64915 + * @requires function imagejpeg + */ + public function test_sideload_original_heic_writes_metadata_original() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + // Create the JPEG attachment that the HEIC will be a companion to. + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' ); + $request->set_body( file_get_contents( self::$test_file ) ); + $response = rest_get_server()->dispatch( $request ); + $attachment_id = $response->get_data()['id']; + + $this->assertSame( 201, $response->get_status() ); + + // Sideload the HEIC companion. Uses a JPEG body since the size enum, + // not the file format, is what we're exercising here. + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=canola.heic' ); + $request->set_param( 'image_size', 'original-heic' ); + $request->set_body( file_get_contents( self::$test_file ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Sideloading original-heic should succeed.' ); + + $metadata = wp_get_attachment_metadata( $attachment_id ); + $this->assertArrayHasKey( 'original', $metadata, "Metadata should contain 'original' for the HEIC companion." ); + $this->assertMatchesRegularExpression( '/canola.*\.heic$/', $metadata['original'], "Metadata 'original' should reference the HEIC filename." ); + $this->assertArrayNotHasKey( 'original_image', $metadata, "Metadata 'original_image' should be untouched by the HEIC sideload." ); + } + /** * Tests the filter_wp_unique_filename method handles the -scaled suffix. * From eea07d222055212a5660255fb078aa6860778bee Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 28 May 2026 12:39:17 -0400 Subject: [PATCH 5/7] Tests: Use HEIC fixture and convert_format=false for original-heic sideload. The test was sending JPEG bytes with a .heic filename, which wp_check_filetype_and_ext() corrected to canola-1.jpg before the metadata assertion ran. Switch to the real test-image.heic fixture, set Content-Type accordingly, and pass convert_format=false to disable the default HEIC -> JPEG output mapping so the .heic extension is preserved. --- .../tests/rest-api/rest-attachments-controller.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index def3559b3d602..acadc3556247a 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3408,13 +3408,15 @@ public function test_sideload_original_heic_writes_metadata_original() { $this->assertSame( 201, $response->get_status() ); - // Sideload the HEIC companion. Uses a JPEG body since the size enum, - // not the file format, is what we're exercising here. + // Sideload the HEIC companion using the real HEIC fixture. `convert_format` + // is disabled so the default HEIC -> JPEG output mapping does not rename + // the file or append an alt-extension suffix. $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" ); - $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Type', 'image/heic' ); $request->set_header( 'Content-Disposition', 'attachment; filename=canola.heic' ); $request->set_param( 'image_size', 'original-heic' ); - $request->set_body( file_get_contents( self::$test_file ) ); + $request->set_param( 'convert_format', false ); + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/test-image.heic' ) ); $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status(), 'Sideloading original-heic should succeed.' ); From d976d2e4a675d868621144acb429c0d06cdc1d2b Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 28 May 2026 13:28:42 -0400 Subject: [PATCH 6/7] Tests: Refresh wp-api-generated.js fixture for the sideload route. Add 'original-heic' to the image_size enum and the missing generate_sub_sizes arg so the schema fixture matches what the live REST index now reports. Without this the test-fixtures step fails the git diff --exit-code check. --- tests/qunit/fixtures/wp-api-generated.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index fa03d9751fe99..c3bf2bf452928 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -3703,6 +3703,7 @@ mockedApiResponse.Schema = { "1536x1536", "2048x2048", "original", + "original-heic", "full", "scaled" ], @@ -3713,6 +3714,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 } } } From 615bc14e37c9f60455222de87e493810889c11ae Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 28 May 2026 14:00:23 -0400 Subject: [PATCH 7/7] Media: Add animated GIF to video sideload sizes and companion-file cleanup. 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 the attachment metadata under 'animated_video' and 'animated_video_poster'. The GIF itself remains the attachment, and the editor switches the inserted block to the core/video block's 'GIF' variation by reading those keys. Accept the two new sideload sizes (`animated-video` and `animated-video-poster`) on the `/sideload` route by adding them to the `image_size` enum and writing each one to its own metadata key. Add `wp_delete_attachment_animated_gif_video()`, hooked on `delete_attachment`, plus a `wp_get_attachment_animated_gif_companion_path()` helper. Both apply the same path hardening already used for the HEIC companion: only the basename of the recorded metadata is trusted, the path is rebuilt from the attachment's own directory, and deletion goes through `wp_delete_file_from_directory()` against the uploads basedir so the cleanup can only ever remove a sibling file of the attachment. Backport of WordPress/gutenberg#78410. Builds on the HEIC companion infrastructure added in #11323. --- src/wp-includes/default-filters.php | 1 + src/wp-includes/media.php | 69 ++++++++ .../class-wp-rest-attachments-controller.php | 15 ++ .../wpDeleteAttachmentAnimatedGifVideo.php | 166 ++++++++++++++++++ 4 files changed, 251 insertions(+) create mode 100644 tests/phpunit/tests/media/wpDeleteAttachmentAnimatedGifVideo.php diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index de8e9fb3364d6..c18bd913608cd 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -563,6 +563,7 @@ 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. diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index 13fb4f9c6a7d9..f8126dc7fc6b7 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -5799,6 +5799,75 @@ function wp_delete_attachment_heic_companion_file( $post_id ) { } } +/** + * 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 d979872f96158..dc7e57ff22ebe 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 @@ -72,6 +72,11 @@ public function register_routes() { // 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. @@ -2117,6 +2122,16 @@ public function sideload_item( WP_REST_Request $request ) { // 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 ); diff --git a/tests/phpunit/tests/media/wpDeleteAttachmentAnimatedGifVideo.php b/tests/phpunit/tests/media/wpDeleteAttachmentAnimatedGifVideo.php new file mode 100644 index 0000000000000..f70f965bf3fab --- /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 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 ) ); + } +}