diff --git a/src/wp-admin/includes/image-edit.php b/src/wp-admin/includes/image-edit.php index a192ef0000c17..dce312ed63145 100644 --- a/src/wp-admin/includes/image-edit.php +++ b/src/wp-admin/includes/image-edit.php @@ -825,6 +825,15 @@ function wp_restore_image( $post_id ) { return $msg; } + if ( ! is_array( $meta ) ) { + $msg->error = __( 'Cannot load image metadata.' ); + return $msg; + } + + if ( ! isset( $meta['sizes'] ) || ! is_array( $meta['sizes'] ) ) { + $meta['sizes'] = array(); + } + $parts = pathinfo( $file ); $suffix = time() . rand( 100, 999 ); $default_sizes = get_intermediate_image_sizes(); @@ -983,6 +992,10 @@ function wp_save_image( $post_id ) { return $return; } + if ( ! isset( $meta['sizes'] ) || ! is_array( $meta['sizes'] ) ) { + $meta['sizes'] = array(); + } + if ( ! is_array( $backup_sizes ) ) { $backup_sizes = array(); } diff --git a/tests/phpunit/tests/ajax/wpAjaxImageEditor.php b/tests/phpunit/tests/ajax/wpAjaxImageEditor.php index 205f61636149c..c7af8a2f9c892 100644 --- a/tests/phpunit/tests/ajax/wpAjaxImageEditor.php +++ b/tests/phpunit/tests/ajax/wpAjaxImageEditor.php @@ -194,4 +194,132 @@ public function test_filesize_restored_after_restoring_original_image() { $this->assertSameSetsWithIndex( $pre_file_sizes, $post_restore_file_sizes, 'Filesize should have restored after restoring the original image.' ); } + + /** + * Ensure editing an image does not fatal when the attachment metadata has no usable `sizes` data. + * + * Attachment metadata is not guaranteed to contain a `sizes` array. It can be missing when + * sub-size generation never ran or failed (for example `wp_create_image_subsizes()` returns an + * empty array when the file cannot be parsed), or when it is removed by a plugin filtering + * `wp_get_attachment_metadata`. `wp_save_image()` only validates that the metadata itself is an + * array, then passes `$meta['sizes']` straight to `array_merge()`. + * + * @ticket 65748 + * + * @covers ::wp_save_image + * + * @dataProvider data_save_image_with_unusable_sizes_metadata + * + * @param array $meta Attachment metadata to store before editing, minus the file-specific keys. + */ + public function test_save_image_with_unusable_sizes_metadata( array $meta ) { + require_once ABSPATH . 'wp-admin/includes/image-edit.php'; + + $filename = DIR_TESTDATA . '/images/canola.jpg'; + $contents = file_get_contents( $filename ); + + $upload = wp_upload_bits( wp_basename( $filename ), null, $contents ); + $id = $this->_make_attachment( $upload ); + + $original_meta = wp_get_attachment_metadata( $id ); + + // Keep the real file/dimension data, only make `sizes` unusable. + $meta = array_merge( + array( + 'width' => $original_meta['width'], + 'height' => $original_meta['height'], + 'file' => $original_meta['file'], + 'filesize' => $original_meta['filesize'], + ), + $meta + ); + + wp_update_attachment_metadata( $id, $meta ); + + $_REQUEST['action'] = 'image-editor'; + $_REQUEST['context'] = 'edit-attachment'; + $_REQUEST['postid'] = $id; + $_REQUEST['target'] = 'all'; + $_REQUEST['do'] = 'save'; + $_REQUEST['history'] = '[{"c":{"x":5,"y":8,"w":289,"h":322}}]'; + + $ret = wp_save_image( $id ); + + $this->assertObjectNotHasProperty( 'error', $ret, 'Saving the image should not have returned an error.' ); + + $saved_meta = wp_get_attachment_metadata( $id ); + + $this->assertIsArray( $saved_meta, 'The saved attachment metadata should be an array.' ); + $this->assertIsArray( $saved_meta['sizes'], 'The saved attachment metadata should contain a `sizes` array.' ); + $this->assertArrayHasKey( 'thumbnail', $saved_meta['sizes'], 'The edited image should have regenerated the thumbnail size.' ); + } + + /** + * Ensure restoring an image does not fatal when the attachment metadata has no usable `sizes` data. + * + * `wp_restore_image()` writes each backed up size with `$meta['sizes'][ $default_size ] = $data` + * without ever checking that `$meta['sizes']` is an array. A scalar value raises + * "Cannot use a scalar value as an array", and `false` is deprecated as of PHP 8.1 and + * an error as of PHP 9. The same metadata that fatals `wp_save_image()` reaches this code. + * + * @ticket 65748 + * + * @covers ::wp_restore_image + * + * @dataProvider data_save_image_with_unusable_sizes_metadata + * + * @param array $meta Replacement `sizes` metadata to store before restoring. + */ + public function test_restore_image_with_unusable_sizes_metadata( array $meta ) { + require_once ABSPATH . 'wp-admin/includes/image-edit.php'; + + $filename = DIR_TESTDATA . '/images/canola.jpg'; + $contents = file_get_contents( $filename ); + + $upload = wp_upload_bits( wp_basename( $filename ), null, $contents ); + $id = $this->_make_attachment( $upload ); + + $_REQUEST['action'] = 'image-editor'; + $_REQUEST['context'] = 'edit-attachment'; + $_REQUEST['postid'] = $id; + $_REQUEST['target'] = 'all'; + $_REQUEST['do'] = 'save'; + $_REQUEST['history'] = '[{"c":{"x":5,"y":8,"w":289,"h":322}}]'; + + // Edit the image first so that `_wp_attachment_backup_sizes` holds the original sizes. + wp_save_image( $id ); + + $this->assertNotEmpty( + get_post_meta( $id, '_wp_attachment_backup_sizes', true ), + 'The image edit should have stored backup sizes to restore from.' + ); + + // Keep the metadata written by the edit, only make `sizes` unusable. + $edited_meta = wp_get_attachment_metadata( $id ); + unset( $edited_meta['sizes'] ); + + wp_update_attachment_metadata( $id, array_merge( $edited_meta, $meta ) ); + + wp_restore_image( $id ); + + $restored_meta = wp_get_attachment_metadata( $id ); + + $this->assertIsArray( $restored_meta['sizes'], 'The restored attachment metadata should contain a `sizes` array.' ); + $this->assertArrayHasKey( 'thumbnail', $restored_meta['sizes'], 'The restored image should have the thumbnail size restored from the backup sizes.' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_save_image_with_unusable_sizes_metadata(): array { + return array( + 'no sizes key' => array( array() ), + 'null sizes' => array( array( 'sizes' => null ) ), + 'empty string' => array( array( 'sizes' => '' ) ), + 'string sizes' => array( array( 'sizes' => 'not-an-array' ) ), + 'boolean sizes' => array( array( 'sizes' => false ) ), + ); + } }