diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 67b14b8e35052..f8ac023cdfdf0 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -6869,6 +6869,33 @@ function wp_delete_attachment_files( $post_id, $meta, $backup_sizes, $file ) { } } + /* + * Delete the animated-GIF video companions. When the client-side media flow + * converts an opaque animated GIF to a web-safe video, the converted MP4/WebM + * and a static first-frame JPEG poster are sideloaded alongside the GIF and + * recorded under the 'animated_video' and 'animated_video_poster' keys. These + * are kept separate from 'original_image', which continues to point at the GIF. + */ + foreach ( array( 'animated_video', 'animated_video_poster' ) as $companion_key ) { + if ( empty( $meta[ $companion_key ] ) || ! is_string( $meta[ $companion_key ] ) ) { + continue; + } + + if ( empty( $intermediate_dir ) ) { + $intermediate_dir = path_join( $uploadpath['basedir'], dirname( $file ) ); + } + + $companion_file = str_replace( wp_basename( $file ), $meta[ $companion_key ], $file ); + + if ( ! empty( $companion_file ) ) { + $companion_file = path_join( $uploadpath['basedir'], $companion_file ); + + if ( ! wp_delete_file_from_directory( $companion_file, $intermediate_dir ) ) { + $deleted = false; + } + } + } + if ( is_array( $backup_sizes ) ) { $del_dir = path_join( $uploadpath['basedir'], dirname( $meta['file'] ) ); 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 e0ee88052a9c2..41743d1717509 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 @@ -123,6 +123,9 @@ public function register_routes() { $valid_sizes[] = 'full'; // Source-format original (e.g. the HEIC kept alongside its JPEG derivative). $valid_sizes[] = self::IMAGE_SIZE_SOURCE_ORIGINAL; + // Converted-video companions for an animated GIF (the MP4/WebM and its poster). + $valid_sizes[] = 'animated_video'; + $valid_sizes[] = 'animated_video_poster'; $items = is_string( $value ) ? array( $value ) : ( is_array( $value ) ? $value : null ); if ( null === $items ) { @@ -2225,6 +2228,13 @@ public function sideload_item( WP_REST_Request $request ) { * under the dedicated source-image meta key. */ $sub_size_data['file'] = wp_basename( $path ); + } elseif ( 'animated_video' === $image_size || 'animated_video_poster' === $image_size ) { + /* + * Converted-video companion of an animated GIF (the MP4/WebM or + * its static first-frame poster). Record the filename so + * finalize_item can store it under its dedicated meta key. + */ + $sub_size_data['file'] = wp_basename( $path ); } elseif ( 'scaled' === $image_size ) { // Record the current attached file as the original. $current_file = get_attached_file( $attachment_id, true ); @@ -2389,6 +2399,16 @@ public function finalize_item( WP_REST_Request $request ) { * is handled by wp_delete_attachment_files(). */ $metadata[ self::META_KEY_SOURCE_IMAGE ] = $sub_size['file']; + } elseif ( 'animated_video' === $image_size ) { + /* + * Converted-video companion of an animated GIF. Stored under its + * own meta key; 'original_image' keeps pointing at the GIF. Cleanup + * on attachment delete is handled by wp_delete_attachment_files(). + */ + $metadata['animated_video'] = $sub_size['file']; + } elseif ( 'animated_video_poster' === $image_size ) { + // Static first-frame poster for the converted video. + $metadata['animated_video_poster'] = $sub_size['file']; } elseif ( 'scaled' === $image_size ) { if ( ! empty( $sub_size['original_image'] ) ) { $metadata['original_image'] = $sub_size['original_image']; diff --git a/tests/phpunit/tests/media/wpDeleteAttachmentAnimatedGifVideo.php b/tests/phpunit/tests/media/wpDeleteAttachmentAnimatedGifVideo.php new file mode 100644 index 0000000000000..9d0f8eddb446b --- /dev/null +++ b/tests/phpunit/tests/media/wpDeleteAttachmentAnimatedGifVideo.php @@ -0,0 +1,129 @@ +remove_added_uploads(); + + parent::tear_down(); + } + + /** + * @ticket 65549 + */ + public function test_deletes_video_and_poster_companions(): 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 ); + $video_name = 'companion-' . wp_generate_password( 6, false ) . '.mp4'; + $poster_name = 'companion-' . wp_generate_password( 6, false ) . '.jpg'; + $video_path = $dir . '/' . $video_name; + $poster_path = $dir . '/' . $poster_name; + + // Create dummy companion files on disk. + file_put_contents( $video_path, 'test' ); + file_put_contents( $poster_path, 'test' ); + $this->assertFileExists( $video_path, 'Video fixture should be on disk.' ); + $this->assertFileExists( $poster_path, 'Poster fixture should be on disk.' ); + + // Record the companions as the sideload route does. + $metadata = wp_get_attachment_metadata( $attachment_id, true ); + $this->assertIsArray( $metadata ); + $metadata['animated_video'] = $video_name; + $metadata['animated_video_poster'] = $poster_name; + wp_update_attachment_metadata( $attachment_id, $metadata ); + + wp_delete_attachment( $attachment_id, true ); + + $this->assertNull( get_post( $attachment_id ) ); + $this->assertFileDoesNotExist( $video_path, 'Video companion should be deleted alongside the attachment.' ); + $this->assertFileDoesNotExist( $poster_path, 'Poster companion should be deleted alongside the attachment.' ); + } + + /** + * @ticket 65549 + */ + public function test_deletes_only_video_when_no_poster_recorded(): 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 ); + $video_name = 'companion-' . wp_generate_password( 6, false ) . '.mp4'; + $video_path = $dir . '/' . $video_name; + + file_put_contents( $video_path, 'test' ); + $this->assertFileExists( $video_path, 'Video fixture should be on disk.' ); + + $metadata = wp_get_attachment_metadata( $attachment_id, true ); + $this->assertIsArray( $metadata ); + $metadata['animated_video'] = $video_name; + wp_update_attachment_metadata( $attachment_id, $metadata ); + + wp_delete_attachment( $attachment_id, true ); + + $this->assertNull( get_post( $attachment_id ) ); + $this->assertFileDoesNotExist( $video_path, 'Video companion should be deleted alongside the attachment.' ); + } + + /** + * @ticket 65549 + */ + public function test_noop_when_no_companion_metadata(): void { + $attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); + $this->assertIsInt( $attachment_id ); + + // Sanity: no companion keys on freshly-created metadata. + $metadata = wp_get_attachment_metadata( $attachment_id, true ); + $this->assertIsArray( $metadata ); + $this->assertArrayNotHasKey( 'animated_video', $metadata ); + $this->assertArrayNotHasKey( 'animated_video_poster', $metadata ); + + // Deletion should complete cleanly even though no companion file is recorded. + wp_delete_attachment( $attachment_id, true ); + + $this->assertNull( get_post( $attachment_id ) ); + } + + /** + * Guards against a companion key holding a non-string value (e.g. the array + * form some metadata flows write). + * + * @ticket 65549 + */ + public function test_noop_when_companion_metadata_is_not_a_string(): 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 ); + + /* + * Place a real file that a buggy, guard-less implementation could try to + * delete after running wp_basename() over the array value below. + */ + $bystander_path = dirname( $attached_file ) . '/should-not-delete.mp4'; + file_put_contents( $bystander_path, 'test' ); + $this->assertFileExists( $bystander_path, 'Test fixture should be on disk.' ); + + $metadata = wp_get_attachment_metadata( $attachment_id, true ); + $this->assertIsArray( $metadata ); + $metadata['animated_video'] = array( 'file' => 'should-not-delete.mp4' ); + wp_update_attachment_metadata( $attachment_id, $metadata ); + + // Deletion should not raise (no str_replace() / file deletion on an array). + wp_delete_attachment( $attachment_id, true ); + + $this->assertNull( get_post( $attachment_id ) ); + $this->assertFileExists( $bystander_path, 'The non-string guard must prevent any file deletion.' ); + } +} diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 43ad46881ca09..3c697be4cedb0 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3574,6 +3574,77 @@ public function test_sideload_source_original_writes_metadata_source_image(): vo $this->assertArrayNotHasKey( 'original_image', $metadata, "Metadata 'original_image' should be untouched by the HEIC sideload." ); } + /** + * Tests sideloading the animated-GIF video companions ('animated_video' and + * 'animated_video_poster'). Each filename is recorded under its own metadata + * key by the finalize endpoint and does not collide with 'original_image', + * which keeps pointing at the GIF. + * + * The uploaded bytes are a JPEG stand-in: the sideload branch only records + * wp_basename() of the stored file, so the metadata plumbing can be exercised + * without depending on video upload support in the test environment. + * + * @ticket 65549 + * @requires function imagejpeg + */ + public function test_sideload_animated_video_companions_write_metadata(): void { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + // Create the (GIF) attachment the companions belong 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( (string) 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 converted-video companion. + $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-video.jpg' ); + $request->set_param( 'image_size', 'animated_video' ); + $request->set_body( (string) file_get_contents( self::$test_file ) ); + $video_response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $video_response->get_status(), 'Sideloading animated_video should succeed.' ); + $video_sub_size = $video_response->get_data(); + $this->assertIsArray( $video_sub_size ); + $this->assertSame( 'animated_video', $video_sub_size['image_size'], 'Response should echo the image_size.' ); + + // Sideload the poster companion. + $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-poster.jpg' ); + $request->set_param( 'image_size', 'animated_video_poster' ); + $request->set_body( (string) file_get_contents( self::$test_file ) ); + $poster_response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $poster_response->get_status(), 'Sideloading animated_video_poster should succeed.' ); + $poster_sub_size = $poster_response->get_data(); + $this->assertIsArray( $poster_sub_size ); + $this->assertSame( 'animated_video_poster', $poster_sub_size['image_size'], 'Response should echo the image_size.' ); + + // Sideload must not write metadata; that happens in finalize. + $metadata = wp_get_attachment_metadata( $attachment_id, true ); + $this->assertArrayNotHasKey( 'animated_video', $metadata, 'Sideload should not write animated_video metadata.' ); + $this->assertArrayNotHasKey( 'animated_video_poster', $metadata, 'Sideload should not write animated_video_poster metadata.' ); + + // Finalize with both collected sub-sizes, which writes the metadata. + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/finalize" ); + $request->set_param( 'sub_sizes', array( $video_sub_size, $poster_sub_size ) ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status(), 'Finalize should succeed.' ); + + $metadata = wp_get_attachment_metadata( $attachment_id ); + $this->assertIsArray( $metadata ); + $this->assertArrayHasKey( 'animated_video', $metadata, "Metadata should contain 'animated_video'." ); + $this->assertMatchesRegularExpression( '/canola-video.*\.jpg$/', $metadata['animated_video'], "Metadata 'animated_video' should reference the video companion filename." ); + $this->assertArrayHasKey( 'animated_video_poster', $metadata, "Metadata should contain 'animated_video_poster'." ); + $this->assertMatchesRegularExpression( '/canola-poster.*\.jpg$/', $metadata['animated_video_poster'], "Metadata 'animated_video_poster' should reference the poster filename." ); + $this->assertArrayNotHasKey( 'original_image', $metadata, "Metadata 'original_image' should be untouched by the companion sideloads." ); + } + /** * Tests the filter_wp_unique_filename method handles the -scaled suffix. *