diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 005ccadd62e34..67b14b8e35052 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -6846,6 +6846,29 @@ function wp_delete_attachment_files( $post_id, $meta, $backup_sizes, $file ) { } } + /* + * Delete the source-format companion file. The client-side media flow can + * sideload a source-format original (such as a HEIC file) alongside a + * web-viewable derivative, recording its filename under the 'source_image' + * key. This is kept separate from 'original_image', which continues to + * point at the derivative. + */ + if ( ! empty( $meta['source_image'] ) && is_string( $meta['source_image'] ) ) { + if ( empty( $intermediate_dir ) ) { + $intermediate_dir = path_join( $uploadpath['basedir'], dirname( $file ) ); + } + + $source_image = str_replace( wp_basename( $file ), $meta['source_image'], $file ); + + if ( ! empty( $source_image ) ) { + $source_image = path_join( $uploadpath['basedir'], $source_image ); + + if ( ! wp_delete_file_from_directory( $source_image, $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/class-wp-rest-server.php b/src/wp-includes/rest-api/class-wp-rest-server.php index 9655b59af3b95..4beec177b4187 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 8f707acaba378..e0ee88052a9c2 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 @@ -24,6 +24,29 @@ class WP_REST_Attachments_Controller extends WP_REST_Posts_Controller { */ protected $allow_batch = false; + /** + * Image size token for the source-format original preserved alongside a + * client-generated derivative (e.g. the HEIC file kept next to its JPEG). + * + * Used both in the `/sideload` route schema and when dispatching the + * sideloaded file to its metadata key, so the two never drift apart. + * + * @since 7.1.0 + * @var string + */ + const IMAGE_SIZE_SOURCE_ORIGINAL = 'source_original'; + + /** + * Metadata key holding the basename of the source-format original. + * + * Deliberately specific so it never collides with the generic `original` + * or `original_image` keys other flows write to. + * + * @since 7.1.0 + * @var string + */ + const META_KEY_SOURCE_IMAGE = 'source_image'; + /** * Registers the routes for attachments. * @@ -98,6 +121,8 @@ public function register_routes() { $valid_sizes[] = 'original'; $valid_sizes[] = 'scaled'; $valid_sizes[] = 'full'; + // Source-format original (e.g. the HEIC kept alongside its JPEG derivative). + $valid_sizes[] = self::IMAGE_SIZE_SOURCE_ORIGINAL; $items = is_string( $value ) ? array( $value ) : ( is_array( $value ) ? $value : null ); if ( null === $items ) { @@ -327,6 +352,26 @@ public function create_item_permissions_check( $request ) { $prevent_unsupported_uploads = false; } + /* + * Always allow still HEIC/HEIF uploads through even if the server's + * image editor doesn't support them. The client-side canvas fallback + * handles processing using the browser's native HEVC decoder. + * + * The '-sequence' variants (multi-frame Live Photos) are deliberately + * excluded: neither the server nor the browser fallback can process + * them yet, so they should fall through to the standard unsupported + * mime-type error rather than be stored unprocessable. + */ + $still_heic_mime_types = array( 'image/heic', 'image/heif' ); + + if ( + $prevent_unsupported_uploads && + ! empty( $files['file']['type'] ) && + in_array( $files['file']['type'], $still_heic_mime_types, true ) + ) { + $prevent_unsupported_uploads = false; + } + // If the upload is an image, check if the server can handle the mime type. if ( $prevent_unsupported_uploads && @@ -1458,7 +1503,7 @@ public function get_item_schema() { * @param string $data Supplied file data. * @param array $headers HTTP headers from the request. * @param string|null $time Optional. Time formatted in 'yyyy/mm'. Default null. - * @return array|WP_Error Data from wp_handle_sideload(). + * @return array{ file: non-empty-string, url: non-empty-string, type: non-empty-string }|WP_Error Data from wp_handle_sideload(). */ protected function upload_from_data( $data, $headers, $time = null ) { if ( empty( $data ) ) { @@ -1678,7 +1723,7 @@ public function get_collection_params() { * @param array $files Data from the `$_FILES` superglobal. * @param array $headers HTTP headers from the request. * @param string|null $time Optional. Time formatted in 'yyyy/mm'. Default null. - * @return array|WP_Error Data from wp_handle_upload(). + * @return array{ file: non-empty-string, url: non-empty-string, type: non-empty-string }|WP_Error Data from wp_handle_upload(). */ protected function upload_from_file( $files, $headers, $time = null ) { if ( empty( $files ) ) { @@ -2173,6 +2218,13 @@ public function sideload_item( WP_REST_Request $request ) { $sub_size_data['filesize'] = wp_filesize( $path ); } elseif ( 'original' === $image_size ) { $sub_size_data['file'] = wp_basename( $path ); + } elseif ( self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size ) { + /* + * Source-format original (e.g. the HEIC kept next to its JPEG + * derivative). Record the filename so finalize_item can store it + * under the dedicated source-image 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 ); @@ -2328,6 +2380,15 @@ public function finalize_item( WP_REST_Request $request ) { if ( 'original' === $image_size ) { $metadata['original_image'] = $sub_size['file']; + } elseif ( self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size ) { + /* + * Source-format 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_files(). + */ + $metadata[ self::META_KEY_SOURCE_IMAGE ] = $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/wpDeleteAttachmentSourceImage.php b/tests/phpunit/tests/media/wpDeleteAttachmentSourceImage.php new file mode 100644 index 0000000000000..987ba5d44385c --- /dev/null +++ b/tests/phpunit/tests/media/wpDeleteAttachmentSourceImage.php @@ -0,0 +1,95 @@ +remove_added_uploads(); + + parent::tear_down(); + } + + /** + * @ticket 64915 + */ + public function test_deletes_companion_file_recorded_in_metadata_source_image(): 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 ); + $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['source_image'] as the sideload route does. + $metadata = wp_get_attachment_metadata( $attachment_id, true ); + $this->assertIsArray( $metadata ); + $metadata['source_image'] = $heic_name; + wp_update_attachment_metadata( $attachment_id, $metadata ); + + wp_delete_attachment( $attachment_id, true ); + + $this->assertNull( get_post( $attachment_id ) ); + $this->assertFileDoesNotExist( $heic_path, 'Companion file should be deleted alongside the attachment.' ); + } + + /** + * @ticket 64915 + */ + public function test_noop_when_metadata_source_image_is_missing(): void { + $attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); + $this->assertIsInt( $attachment_id ); + + // Sanity: no 'source_image' key on freshly-created metadata. + $metadata = wp_get_attachment_metadata( $attachment_id, true ); + $this->assertIsArray( $metadata ); + $this->assertArrayNotHasKey( 'source_image', $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 $metadata['source_image'] 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_source_image_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.heic'; + 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['source_image'] = array( 'file' => 'should-not-delete.heic' ); + 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 ff9854994abf7..43ad46881ca09 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -9,53 +9,54 @@ */ class WP_Test_REST_Attachments_Controller extends WP_Test_REST_Post_Type_Controller_Testcase { - protected static $superadmin_id; - protected static $editor_id; - protected static $author_id; - protected static $contributor_id; - protected static $uploader_id; - protected static $rest_after_insert_attachment_count; - protected static $rest_insert_attachment_count; + protected static int $superadmin_id; + protected static int $editor_id; + protected static int $author_id; + protected static int $contributor_id; + protected static int $uploader_id; + protected static int $rest_after_insert_attachment_count; + protected static int $rest_insert_attachment_count; /** * @var string The path to a test file. */ - private static $test_file; + private static string $test_file; /** * @var string The path to a second test file. */ - private static $test_file2; + private static string $test_file2; /** * @var string The path to the AVIF test image. */ - private static $test_avif_file; + private static string $test_avif_file; /** * @var string The path to the SVG test image. */ - private static $test_svg_file; + private static string $test_svg_file; /** * @var string The path to the test video. */ - private static $test_video_file; + private static string $test_video_file; /** * @var string The path to the test audio. */ - private static $test_audio_file; + private static string $test_audio_file; /** * @var string The path to the test RTF file. */ - private static $test_rtf_file; + private static string $test_rtf_file; /** - * @var array The recorded posts query clauses. + * @var array[] The recorded posts query clauses. Each entry is the array of + * SQL clause fragments passed to the `posts_clauses` filter. */ - protected $posts_clauses; + protected array $posts_clauses; public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { self::$superadmin_id = $factory->user->create( @@ -3015,6 +3016,106 @@ public function test_upload_unsupported_image_type_enforced_when_generating_sub_ $this->assertSame( 'rest_upload_image_type_not_supported', $result->get_error_code() ); } + /** + * Test that still HEIC/HEIF uploads bypass the image editor support check. + * + * The browser's canvas fallback can always decode still HEIC/HEIF, so the + * upload is allowed even when the server has no editor that supports it. + * + * @ticket 64915 + * + * @dataProvider data_still_heic_mime_types + * + * @param string $mime_type Still HEIC/HEIF mime type. + */ + public function test_upload_still_heic_bypasses_unsupported_image_type_check( $mime_type ) { + wp_set_current_user( self::$author_id ); + + add_filter( 'wp_image_editors', '__return_empty_array' ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_file_params( + array( + 'file' => array( + 'name' => 'canola.heic', + 'type' => $mime_type, + 'tmp_name' => DIR_TESTDATA . '/images/test-image.heic', + 'error' => 0, + 'size' => filesize( DIR_TESTDATA . '/images/test-image.heic' ), + ), + ) + ); + + $controller = new WP_REST_Attachments_Controller( 'attachment' ); + $result = $controller->create_item_permissions_check( $request ); + + // Should pass because the browser can decode still HEIC/HEIF client-side. + $this->assertTrue( $result ); + } + + /** + * Data provider for still HEIC/HEIF mime types. + * + * @return array[] + */ + public function data_still_heic_mime_types() { + return array( + 'heic' => array( 'image/heic' ), + 'heif' => array( 'image/heif' ), + ); + } + + /** + * Test that HEIC/HEIF sequence uploads do not bypass the editor support check. + * + * The multi-frame '-sequence' variants (Live Photos) cannot be processed by + * the server or decoded by the browser fallback, so they should fall through + * to the standard unsupported mime-type error rather than be stored. + * + * @ticket 64915 + * + * @dataProvider data_heic_sequence_mime_types + * + * @param string $mime_type HEIC/HEIF sequence mime type. + */ + public function test_upload_heic_sequence_is_not_bypassed( $mime_type ) { + wp_set_current_user( self::$author_id ); + + add_filter( 'wp_image_editors', '__return_empty_array' ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_file_params( + array( + 'file' => array( + 'name' => 'live-photo.heic', + 'type' => $mime_type, + 'tmp_name' => DIR_TESTDATA . '/images/test-image.heic', + 'error' => 0, + 'size' => filesize( DIR_TESTDATA . '/images/test-image.heic' ), + ), + ) + ); + + $controller = new WP_REST_Attachments_Controller( 'attachment' ); + $result = $controller->create_item_permissions_check( $request ); + + // Should fail: sequences are unsupported by both the server and the fallback. + $this->assertWPError( $result ); + $this->assertSame( 'rest_upload_image_type_not_supported', $result->get_error_code() ); + } + + /** + * Data provider for HEIC/HEIF sequence mime types. + * + * @return array[] + */ + public function data_heic_sequence_mime_types() { + return array( + 'heic-sequence' => array( 'image/heic-sequence' ), + 'heif-sequence' => array( 'image/heif-sequence' ), + ); + } + /** * Test that uploading an SVG image doesn't throw a `rest_upload_image_type_not_supported` error. * @@ -3346,28 +3447,37 @@ public function test_sideload_scaled_image_requires_auth() { } /** - * Tests that the sideload endpoint accepts 'scaled' as an image size. + * Tests that the sideload endpoint accepts the expected image sizes and does + * not expose a generate_sub_sizes arg. * * The image_size argument accepts either a single size name or an array of - * size names, so it validates via a custom callback rather than an enum. + * size names, so it validates via a custom callback rather than an enum. The + * callback must accept 'scaled' and the 'source_original' source-format size, + * and reject unknown sizes. + * + * sideload_item() never reads generate_sub_sizes, so advertising it on the + * route would silently mislead clients into expecting server-side sub-size + * generation. That arg only does real work on create_item() (POST /wp/v2/media). * * @ticket 64737 + * @ticket 64915 */ - public function test_sideload_route_accepts_scaled_image_size() { + public function test_sideload_route_accepts_expected_image_sizes() { $this->enable_client_side_media_processing(); - $server = rest_get_server(); - $routes = $server->get_routes(); - - $endpoint = '/wp/v2/media/(?P[\d]+)/sideload'; - $this->assertArrayHasKey( $endpoint, $routes, 'Sideload route should exist.' ); - - $route = $routes[ $endpoint ]; - $endpoint = $route[0]; - $args = $endpoint['args']; + $routes = rest_get_server()->get_routes(); + $path = '/wp/v2/media/(?P[\d]+)/sideload'; + $this->assertArrayHasKey( $path, $routes, 'Sideload route should exist.' ); + $this->assertIsArray( $routes[ $path ] ); + $endpoint = array_first( $routes[ $path ] ); + $this->assertIsArray( $endpoint ); + $this->assertArrayHasKey( 'args', $endpoint, 'Route endpoint should declare args.' ); + $args = $endpoint['args']; + $this->assertIsArray( $args ); $param_name = 'image_size'; $this->assertArrayHasKey( $param_name, $args, 'Route should have image_size arg.' ); + $this->assertIsArray( $args[ $param_name ] ); $this->assertArrayHasKey( 'validate_callback', $args[ $param_name ], @@ -3381,6 +3491,10 @@ public function test_sideload_route_accepts_scaled_image_size() { $validate( 'scaled', $request, $param_name ), 'image_size validation should accept the scaled size.' ); + $this->assertTrue( + $validate( WP_REST_Attachments_Controller::IMAGE_SIZE_SOURCE_ORIGINAL, $request, $param_name ), + 'image_size validation should accept the source_original source-format size.' + ); $this->assertTrue( $validate( array( 'scaled' ), $request, $param_name ), 'image_size validation should accept an array of size names.' @@ -3389,6 +3503,75 @@ public function test_sideload_route_accepts_scaled_image_size() { $validate( 'not-a-real-size', $request, $param_name ), 'image_size validation should reject an unknown size.' ); + + $this->assertArrayNotHasKey( 'generate_sub_sizes', $args, 'Sideload route should not advertise the unused generate_sub_sizes arg.' ); + } + + /** + * Tests sideloading a 'source_original' companion file alongside its JPEG + * derivative. The HEIC filename is recorded under $metadata['source_image'] + * so it does not collide with 'original_image', which the scaled-sideload + * flow owns. Metadata is written by the finalize endpoint, not the sideload. + * + * @ticket 64915 + * @requires function imagejpeg + */ + public function test_sideload_source_original_writes_metadata_source_image(): void { + $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( (string) file_get_contents( self::$test_file ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertIsArray( $data ); + $this->assertArrayHasKey( 'id', $data ); + $attachment_id = $data['id']; + $this->assertIsInt( $attachment_id ); + + $this->assertSame( 201, $response->get_status() ); + + /* + * 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. The sideload returns + * lightweight sub-size data; metadata is written by finalize. + */ + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" ); + $request->set_header( 'Content-Type', 'image/heic' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=canola.heic' ); + $request->set_param( 'image_size', WP_REST_Attachments_Controller::IMAGE_SIZE_SOURCE_ORIGINAL ); + $request->set_param( 'convert_format', false ); + $request->set_body( (string) file_get_contents( DIR_TESTDATA . '/images/test-image.heic' ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Sideloading source_original should succeed.' ); + + $sub_size = $response->get_data(); + $this->assertIsArray( $sub_size ); + $this->assertSame( WP_REST_Attachments_Controller::IMAGE_SIZE_SOURCE_ORIGINAL, $sub_size['image_size'], 'Response should echo the image_size.' ); + $this->assertMatchesRegularExpression( '/canola.*\.heic$/', $sub_size['file'], 'Response file should reference the HEIC filename.' ); + + // Sideload must not write metadata; that happens in finalize. + $metadata = wp_get_attachment_metadata( $attachment_id, true ); + $this->assertArrayNotHasKey( 'source_image', $metadata, 'Sideload should not write source_image metadata.' ); + + // Finalize with the collected sub-size, which writes the metadata. + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/finalize" ); + $request->set_param( 'sub_sizes', array( $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( 'source_image', $metadata, "Metadata should contain 'source_image' for the HEIC companion." ); + $this->assertMatchesRegularExpression( '/canola.*\.heic$/', $metadata['source_image'], "Metadata 'source_image' should reference the HEIC filename." ); + $this->assertArrayNotHasKey( 'original_image', $metadata, "Metadata 'original_image' should be untouched by the HEIC sideload." ); } /**