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-admin/edit-form-blocks.php
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ static function ( $classes ) {
'site_logo',
'timezone_string',
'url',
'video_keep_original',
'page_for_posts',
'page_on_front',
'show_on_front',
Expand Down
1 change: 1 addition & 0 deletions src/wp-admin/site-editor.php
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ static function ( $classes ) {
'site_logo',
'timezone_string',
'url',
'video_keep_original',
'page_for_posts',
'page_on_front',
'show_on_front',
Expand Down
15 changes: 9 additions & 6 deletions src/wp-includes/post.php
Original file line number Diff line number Diff line change
Expand Up @@ -6998,13 +6998,16 @@ 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.
* Delete the 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.
* When it transcodes a video upload to a web-safe format, the transcode is
* sideloaded alongside the original video and recorded under the
* 'optimized_video' key. These are kept separate from 'original_image',
* which continues to point at the upload itself.
*/
foreach ( array( 'animated_video', 'animated_video_poster' ) as $companion_key ) {
foreach ( array( 'animated_video', 'animated_video_poster', 'optimized_video' ) as $companion_key ) {
if ( empty( $meta[ $companion_key ] ) || ! is_string( $meta[ $companion_key ] ) ) {
continue;
}
Expand Down
14 changes: 14 additions & 0 deletions src/wp-includes/rest-api/class-wp-rest-server.php
Original file line number Diff line number Diff line change
Expand Up @@ -1397,6 +1397,20 @@ public function get_index( $request ) {
*/
/** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */
$available['image_max_bit_depth'] = (int) apply_filters( 'image_max_bit_depth', 16, 16 );

/**
* Filters whether the original video upload is kept when a video is transcoded.
*
* When true (default), the original video is stored as the attachment and
* the transcoded web-safe version is sideloaded as a companion file. When
* false, the video is transcoded before upload so only the optimized file
* is stored.
*
* @since 7.2.0
*
* @param bool $keep_original Whether to keep the original video upload. Default true.
*/
$available['video_keep_original'] = (bool) apply_filters( 'wp_video_transcoding_keep_original', true );
}

$response = new WP_REST_Response( $available );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2603,6 +2603,8 @@ private static function get_special_image_sizes(): array {
// Converted-video companions for an animated GIF (the MP4/WebM and its poster).
'animated_video',
'animated_video_poster',
// Web-safe transcode kept alongside the original video upload.
'optimized_video',
);
}

Expand Down Expand Up @@ -2758,13 +2760,28 @@ public function sideload_item( WP_REST_Request $request ) {
return $post;
}

/** @var non-empty-string|non-empty-list<non-empty-string> $image_size */
$image_size = $request['image_size'];

/*
* Sideloads extend an existing attachment with a file derived from it,
* which is an image or PDF sub-size in every case but one: the web-safe
* transcode of a video is sideloaded as a companion of the video
* attachment it was produced from, so a video parent is valid for that
* size and no other.
*/
$is_optimized_video_companion =
'optimized_video' === $image_size &&
wp_attachment_is( 'video', $post );

if (
! wp_attachment_is_image( $post ) &&
! wp_attachment_is( 'pdf', $post )
! wp_attachment_is( 'pdf', $post ) &&
! $is_optimized_video_companion
) {
return new WP_Error(
'rest_post_invalid_id',
__( 'Invalid post ID. Only images and PDFs can be sideloaded.' ),
__( 'Invalid post ID. Only images, PDFs and videos can be sideloaded.' ),
array( 'status' => 400 )
);
}
Expand Down Expand Up @@ -2847,21 +2864,23 @@ public function sideload_item( WP_REST_Request $request ) {
$type = $file['type'];
$path = $file['file'];

/** @var non-empty-string|non-empty-list<non-empty-string> $image_size */
$image_size = $request['image_size'];

/*
* Validate raster sub-sizes before storing them. Two companion sizes
* Validate raster sub-sizes before storing them. Three companion sizes
* are exempt because wp_getimagesize() may not be able to read the
* file at all: the 'animated_video' companion of an animated GIF is a
* video (MP4/WebM), and a source-format original (e.g. a HEIC or JXL
* kept next to its JPEG derivative) may be an unreadable format. Their
* file at all: the 'animated_video' companion of an animated GIF and
* the 'optimized_video' companion of a video upload are videos
* (MP4/WebM), and a source-format original (e.g. a HEIC or JXL kept
* next to its JPEG derivative) may be an unreadable format. Their
* dimensions are neither validated nor recorded. The
* 'animated_video_poster' companion is a real image, so it is still
* read and rejected if unreadable; validate_image_dimensions() skips
* only the registered-size constraint for it.
*/
$skip_dimension_read = self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size || 'animated_video' === $image_size;
$skip_dimension_read = in_array(
$image_size,
array( self::IMAGE_SIZE_SOURCE_ORIGINAL, 'animated_video', 'optimized_video' ),
true
);
$size = false;

if ( ! $skip_dimension_read ) {
Expand Down Expand Up @@ -2935,6 +2954,13 @@ public function sideload_item( WP_REST_Request $request ) {
* finalize_item can store it under its dedicated meta key.
*/
$sub_size_data['file'] = wp_basename( $path );
} elseif ( 'optimized_video' === $image_size ) {
/*
* Web-safe transcode of a video upload. The original video stays
* the attachment; record the companion's filename so finalize_item
* can store it under its dedicated meta key.
*/
$sub_size_data['file'] = wp_basename( $path );
} elseif ( 'scaled' === $image_size || 'original' === $image_size ) {
/*
* 'scaled' and 'original' both replace the attachment's main file
Expand Down Expand Up @@ -3165,6 +3191,7 @@ protected function get_sideloaded_file_names( int $attachment_id, bool $include_
$metadata[ self::META_KEY_SOURCE_IMAGE ] ?? null,
$metadata['animated_video'] ?? null,
$metadata['animated_video_poster'] ?? null,
$metadata['optimized_video'] ?? null,
);

if ( ! empty( $metadata['sizes'] ) && is_array( $metadata['sizes'] ) ) {
Expand Down Expand Up @@ -3379,6 +3406,17 @@ public function finalize_item( WP_REST_Request $request ) {

// Static first-frame poster for the converted video.
$metadata['animated_video_poster'] = $sub_size['file'];
} elseif ( 'optimized_video' === $image_size ) {
if ( empty( $sub_size['file'] ) ) {
continue;
}

/*
* Web-safe transcode of a video upload. Stored under its own
* meta key; the original video stays the attachment. Cleanup on
* attachment delete is handled by wp_delete_attachment_files().
*/
$metadata['optimized_video'] = $sub_size['file'];
} else {
if ( empty( $sub_size['file'] ) ) {
continue;
Expand Down
118 changes: 118 additions & 0 deletions tests/phpunit/tests/media/wpDeleteAttachmentOptimizedVideo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<?php

/**
* Tests that wp_delete_attachment_files() removes the transcoded video companion.
*
* @group media
* @covers ::wp_delete_attachment_files
*/
class Tests_Media_wpDeleteAttachmentOptimizedVideo extends WP_UnitTestCase {

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

parent::tear_down();
}

/**
* @ticket 65998
*/
public function test_deletes_companion_recorded_in_metadata(): void {
$attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/uploads/small-video.mov' );
$this->assertIsInt( $attachment_id );

$attached_file = get_attached_file( $attachment_id, true );
$this->assertIsString( $attached_file );
$dir = dirname( $attached_file );
$video_name = 'optimized-' . wp_generate_password( 6, false ) . '.mp4';
$video_path = $dir . '/' . $video_name;

// Create a dummy companion file on disk.
file_put_contents( $video_path, 'test' );
$this->assertFileExists( $video_path, 'Video fixture should be on disk.' );

// Record the companion as the finalize route does.
$metadata = (array) wp_get_attachment_metadata( $attachment_id, true );
$metadata['optimized_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.' );
$this->assertFileDoesNotExist( $attached_file, 'The original video should be deleted as before.' );
}

/**
* @ticket 65998
*/
public function test_noop_when_no_companion_metadata(): void {
$attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/uploads/small-video.mov' );
$this->assertIsInt( $attachment_id );

$metadata = (array) wp_get_attachment_metadata( $attachment_id, true );
$this->assertArrayNotHasKey( 'optimized_video', $metadata );

// Deletion should complete cleanly even though no companion file is recorded.
wp_delete_attachment( $attachment_id, true );

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

/**
* A path-traversal value in the metadata only ever resolves inside the
* attachment's own directory, so a file outside it is never touched.
*
* @ticket 65998
*/
public function test_traversal_value_does_not_delete_outside_attachment_dir(): void {
$attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/uploads/small-video.mov' );
$this->assertIsInt( $attachment_id );

$attached_file = get_attached_file( $attachment_id, true );
$this->assertIsString( $attached_file );

// A file one level above the attachment's directory.
$outside_path = dirname( $attached_file, 2 ) . '/outside-' . wp_generate_password( 6, false ) . '.mp4';
file_put_contents( $outside_path, 'test' );
$this->assertFileExists( $outside_path, 'Test fixture should be on disk.' );

$metadata = (array) wp_get_attachment_metadata( $attachment_id, true );
$metadata['optimized_video'] = '../' . wp_basename( $outside_path );
wp_update_attachment_metadata( $attachment_id, $metadata );

wp_delete_attachment( $attachment_id, true );

$this->assertNull( get_post( $attachment_id ) );
$this->assertFileExists( $outside_path, 'A file outside the attachment directory must not be deleted.' );

wp_delete_file( $outside_path );
}

/**
* Guards against the companion key holding a non-string value.
*
* @ticket 65998
*/
public function test_noop_when_companion_metadata_is_not_a_string(): void {
$attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/uploads/small-video.mov' );
$this->assertIsInt( $attachment_id );
$attached_file = get_attached_file( $attachment_id, true );
$this->assertIsString( $attached_file );

$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 = (array) wp_get_attachment_metadata( $attachment_id, true );
$metadata['optimized_video'] = array( 'file' => 'should-not-delete.mp4' );
wp_update_attachment_metadata( $attachment_id, $metadata );

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.' );

wp_delete_file( $bystander_path );
}
}
Loading
Loading