diff --git a/src/wp-includes/class-wp-image-editor.php b/src/wp-includes/class-wp-image-editor.php index 11210180b2..0bc7ee6aee 100644 --- a/src/wp-includes/class-wp-image-editor.php +++ b/src/wp-includes/class-wp-image-editor.php @@ -591,13 +591,11 @@ abstract class WP_Image_Editor { * @return string|false */ protected static function get_extension( $mime_type = null ) { - $extensions = explode( '|', array_search( $mime_type, wp_get_mime_types(), true ) ); - - if ( empty( $extensions[0] ) ) { + if ( empty( $mime_type ) ) { return false; } - return $extensions[0]; + return wp_get_default_extension_for_mime_type( $mime_type ); } } diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index 7b01568ad4..02d74e1b70 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -2488,6 +2488,10 @@ function wp_unique_filename( $dir, $filename, $unique_filename_callback = null ) $filename = sanitize_file_name( $filename ); $ext2 = null; + // Initialize vars used in the wp_unique_filename filter. + $number = ''; + $alt_filenames = array(); + // Separate the filename into a name and extension. $ext = pathinfo( $filename, PATHINFO_EXTENSION ); $name = pathinfo( $filename, PATHINFO_BASENAME ); @@ -2508,8 +2512,7 @@ function wp_unique_filename( $dir, $filename, $unique_filename_callback = null ) if ( $unique_filename_callback && is_callable( $unique_filename_callback ) ) { $filename = call_user_func( $unique_filename_callback, $dir, $name, $ext ); } else { - $number = ''; - $fname = pathinfo( $filename, PATHINFO_FILENAME ); + $fname = pathinfo( $filename, PATHINFO_FILENAME ); // Always append a number to file names that can potentially match image sub-size file names. if ( $fname && preg_match( '/-(?:\d+x\d+|scaled|rotated)$/', $fname ) ) { @@ -2519,37 +2522,54 @@ function wp_unique_filename( $dir, $filename, $unique_filename_callback = null ) $filename = str_replace( "{$fname}{$ext}", "{$fname}-{$number}{$ext}", $filename ); } - // Change '.ext' to lower case. - if ( $ext && strtolower( $ext ) != $ext ) { - $ext2 = strtolower( $ext ); - $filename2 = preg_replace( '|' . preg_quote( $ext ) . '$|', $ext2, $filename ); + // Get the mime type. Uploaded files were already checked with wp_check_filetype_and_ext() + // in _wp_handle_upload(). Using wp_check_filetype() would be sufficient here. + $file_type = wp_check_filetype( $filename ); + $mime_type = $file_type['type']; - // Check for both lower and upper case extension or image sub-sizes may be overwritten. - while ( file_exists( $dir . "/{$filename}" ) || file_exists( $dir . "/{$filename2}" ) ) { - $new_number = (int) $number + 1; - $filename = str_replace( array( "-{$number}{$ext}", "{$number}{$ext}" ), "-{$new_number}{$ext}", $filename ); - $filename2 = str_replace( array( "-{$number}{$ext2}", "{$number}{$ext2}" ), "-{$new_number}{$ext2}", $filename2 ); - $number = $new_number; + $is_image = ( ! empty( $mime_type ) && 0 === strpos( $mime_type, 'image/' ) ); + $upload_dir = wp_get_upload_dir(); + $lc_filename = null; + + $lc_ext = strtolower( $ext ); + $_dir = trailingslashit( $dir ); + + // If the extension is uppercase add an alternate file name with lowercase extension. Both need to be tested + // for uniqueness as the extension will be changed to lowercase for better compatibility with different filesystems. + // Fixes an inconsistency in WP < 2.9 where uppercase extensions were allowed but image sub-sizes were created with + // lowercase extensions. + if ( $ext && $lc_ext !== $ext ) { + $lc_filename = preg_replace( '|' . preg_quote( $ext ) . '$|', $lc_ext, $filename ); + } + + // Increment the number added to the file name if there are any files in $dir whose names match one of the + // possible name variations. + while ( file_exists( $_dir . $filename ) || ( $lc_filename && file_exists( $_dir . $lc_filename ) ) ) { + $new_number = (int) $number + 1; + + if ( $lc_filename ) { + $lc_filename = str_replace( array( "-{$number}{$lc_ext}", "{$number}{$lc_ext}" ), "-{$new_number}{$lc_ext}", $lc_filename ); } - $filename = $filename2; - } else { - while ( file_exists( $dir . "/{$filename}" ) ) { - $new_number = (int) $number + 1; - - if ( '' === "{$number}{$ext}" ) { - $filename = "{$filename}-{$new_number}"; - } else { - $filename = str_replace( array( "-{$number}{$ext}", "{$number}{$ext}" ), "-{$new_number}{$ext}", $filename ); - } - - $number = $new_number; + if ( '' === "{$number}{$ext}" ) { + $filename = "{$filename}-{$new_number}"; + } else { + $filename = str_replace( array( "-{$number}{$ext}", "{$number}{$ext}" ), "-{$new_number}{$ext}", $filename ); } + + $number = $new_number; + } + + // Change the extension to lowercase if needed. + if ( $lc_filename ) { + $filename = $lc_filename; } // Prevent collisions with existing file names that contain dimension-like strings // (whether they are subsizes or originals uploaded prior to #42437). - $upload_dir = wp_get_upload_dir(); + + $files = array(); + $count = 10000; // The (resized) image files would have name and extension, and will be in the uploads dir. if ( $name && $ext && @is_dir( $dir ) && false !== strpos( $dir, $upload_dir['basedir'] ) ) { @@ -2579,18 +2599,77 @@ function wp_unique_filename( $dir, $filename, $unique_filename_callback = null ) } if ( ! empty( $files ) ) { - // The extension case may have changed above. - $new_ext = ! empty( $ext2 ) ? $ext2 : $ext; + $count = count( $files ); // Ensure this never goes into infinite loop // as it uses pathinfo() and regex in the check, but string replacement for the changes. - $count = count( $files ); - $i = 0; + $i = 0; while ( $i <= $count && _wp_check_existing_file_names( $filename, $files ) ) { $new_number = (int) $number + 1; - $filename = str_replace( array( "-{$number}{$new_ext}", "{$number}{$new_ext}" ), "-{$new_number}{$new_ext}", $filename ); - $number = $new_number; + + // If $ext is uppercase it was replaced with the lowercase version after the previous loop. + $filename = str_replace( array( "-{$number}{$lc_ext}", "{$number}{$lc_ext}" ), "-{$new_number}{$lc_ext}", $filename ); + + $number = $new_number; + $i++; + } + } + } + + // Check if an image will be converted after uploading or some existing images sub-sizes file names may conflict + // when regenerated. If yes, ensure the new file name will be unique and will produce unique sub-sizes. + if ( $is_image ) { + $output_formats = apply_filters( 'image_editor_output_format', array(), $_dir . $filename, $mime_type ); + $alt_types = array(); + + if ( ! empty( $output_formats[ $mime_type ] ) ) { + // The image will be converted to this format/mime type. + $alt_mime_type = $output_formats[ $mime_type ]; + + // Other types of images whose names may conflict if their sub-sizes are regenerated. + $alt_types = array_keys( array_intersect( $output_formats, array( $mime_type, $alt_mime_type ) ) ); + $alt_types[] = $alt_mime_type; + } elseif ( ! empty( $output_formats ) ) { + $alt_types = array_keys( array_intersect( $output_formats, array( $mime_type ) ) ); + } + + // Remove duplicates and the original mime type. It will be added later if needed. + $alt_types = array_unique( array_diff( $alt_types, array( $mime_type ) ) ); + + foreach ( $alt_types as $alt_type ) { + $alt_ext = wp_get_default_extension_for_mime_type( $alt_type ); + + if ( ! $alt_ext ) { + continue; + } + + $alt_ext = ".{$alt_ext}"; + $alt_filename = preg_replace( '|' . preg_quote( $lc_ext ) . '$|', $alt_ext, $filename ); + + $alt_filenames[ $alt_ext ] = $alt_filename; + } + + if ( ! empty( $alt_filenames ) ) { + // Add the original filename. It needs to be checked again together with the alternate filenames + // when $number is incremented. + $alt_filenames[ $lc_ext ] = $filename; + + // Ensure no infinite loop. + $i = 0; + + while ( $i <= $count && _wp_check_alternate_file_names( $alt_filenames, $_dir, $files ) ) { + $new_number = (int) $number + 1; + + foreach ( $alt_filenames as $alt_ext => $alt_filename ) { + $alt_filenames[ $alt_ext ] = str_replace( array( "-{$number}{$alt_ext}", "{$number}{$alt_ext}" ), "-{$new_number}{$alt_ext}", $alt_filename ); + } + + // Also update the $number in (the output) $filename. + // If the extension was uppercase it was already replaced with the lowercase version. + $filename = str_replace( array( "-{$number}{$lc_ext}", "{$number}{$lc_ext}" ), "-{$new_number}{$lc_ext}", $filename ); + + $number = $new_number; $i++; } } @@ -2601,13 +2680,42 @@ function wp_unique_filename( $dir, $filename, $unique_filename_callback = null ) * Filters the result when generating a unique file name. * * @since 4.5.0 + * @since 5.8.1 The `$alt_filenames` and `$number` parameters were added. * * @param string $filename Unique file name. * @param string $ext File extension, eg. ".png". * @param string $dir Directory path. * @param callable|null $unique_filename_callback Callback function that generates the unique file name. + * @param string[] $alt_filenames Array of alternate file names that were checked for collisions. + * @param int|string $number The highest number that was used to make the file name unique + * or an empty string if unused. */ - return apply_filters( 'wp_unique_filename', $filename, $ext, $dir, $unique_filename_callback ); + return apply_filters( 'wp_unique_filename', $filename, $ext, $dir, $unique_filename_callback, $alt_filenames, $number ); +} + +/** + * Helper function to test if each of an array of file names could conflict with existing files. + * + * @since 5.8.1 + * @access private + * + * @param string[] $filenames Array of file names to check. + * @param string $dir The directory containing the files. + * @param array $files An array of existing files in the directory. May be empty. + * @return bool True if the tested file name could match an existing file, false otherwise. + */ +function _wp_check_alternate_file_names( $filenames, $dir, $files ) { + foreach ( $filenames as $filename ) { + if ( file_exists( $dir . $filename ) ) { + return true; + } + + if ( ! empty( $files ) && _wp_check_existing_file_names( $filename, $files ) ) { + return true; + } + } + + return false; } /** @@ -2793,6 +2901,26 @@ function wp_ext2type( $ext ) { } } +/** + * Returns first matched extension for the mime-type, + * as mapped from wp_get_mime_types(). + * + * @since 5.8.1 + * + * @param string $mime_type + * + * @return string|false + */ +function wp_get_default_extension_for_mime_type( $mime_type ) { + $extensions = explode( '|', array_search( $mime_type, wp_get_mime_types(), true ) ); + + if ( empty( $extensions[0] ) ) { + return false; + } + + return $extensions[0]; +} + /** * Retrieve the file type from the file name. * diff --git a/tests/phpunit/data/images/test-image-1-100x100.jpg b/tests/phpunit/data/images/test-image-1-100x100.jpg new file mode 100644 index 0000000000..534aac1d6b Binary files /dev/null and b/tests/phpunit/data/images/test-image-1-100x100.jpg differ diff --git a/tests/phpunit/data/images/test-image-2.gif b/tests/phpunit/data/images/test-image-2.gif new file mode 100644 index 0000000000..8fad364479 Binary files /dev/null and b/tests/phpunit/data/images/test-image-2.gif differ diff --git a/tests/phpunit/data/images/test-image-3.jpg b/tests/phpunit/data/images/test-image-3.jpg new file mode 100644 index 0000000000..534aac1d6b Binary files /dev/null and b/tests/phpunit/data/images/test-image-3.jpg differ diff --git a/tests/phpunit/data/images/test-image-4.png b/tests/phpunit/data/images/test-image-4.png new file mode 100644 index 0000000000..642ce92975 Binary files /dev/null and b/tests/phpunit/data/images/test-image-4.png differ diff --git a/tests/phpunit/tests/functions.php b/tests/phpunit/tests/functions.php index 4ef24d22a1..cdeb2361a8 100644 --- a/tests/phpunit/tests/functions.php +++ b/tests/phpunit/tests/functions.php @@ -168,13 +168,21 @@ class Tests_Functions extends WP_UnitTestCase { $testdir = DIR_TESTDATA . '/images/'; // Sanity check. - $this->assertSame( 'abcdefg.png', wp_unique_filename( $testdir, 'abcdefg.png' ), 'Sanitiy check failed' ); + $this->assertSame( 'abcdefg.png', wp_unique_filename( $testdir, 'abcdefg.png' ), 'Test non-existing file, file name should be unchanged.' ); - // Check number is appended for file already exists. + // Ensure correct images exist. $this->assertFileExists( $testdir . 'test-image.png', 'Test image does not exist' ); - $this->assertSame( 'test-image-1.png', wp_unique_filename( $testdir, 'test-image.png' ), 'Number not appended correctly' ); $this->assertFileDoesNotExist( $testdir . 'test-image-1.png' ); + // Check number is appended if file already exists. + $this->assertSame( 'test-image-1.png', wp_unique_filename( $testdir, 'test-image.png' ), 'File name not unique, number not appended.' ); + + // Check file with uppercase extension. + $this->assertSame( 'test-image-1.png', wp_unique_filename( $testdir, 'test-image.PNG' ), 'File name with uppercase extension not unique, number not appended.' ); + + // Check file name with already added number. + $this->assertSame( 'test-image-2-1.gif', wp_unique_filename( $testdir, 'test-image-2.gif' ), 'File name not unique, number not appended correctly.' ); + // Check special chars. $this->assertSame( 'testtest-image.png', wp_unique_filename( $testdir, 'testtést-imagé.png' ), 'Filename with special chars failed' ); @@ -221,6 +229,88 @@ class Tests_Functions extends WP_UnitTestCase { return $upload_dir; } + /** + * @ticket 53668 + */ + function test_wp_unique_filename_with_additional_image_extension() { + $testdir = DIR_TESTDATA . '/images/'; + + add_filter( 'upload_dir', array( $this, 'upload_dir_patch_basedir' ) ); + + // Set conversions for uploaded images. + add_filter( 'image_editor_output_format', array( $this, 'image_editor_output_format_handler' ) ); + + // Ensure the test images exist. + $this->assertFileExists( $testdir . 'test-image-1-100x100.jpg', 'test-image-1-100x100.jpg does not exist' ); + $this->assertFileExists( $testdir . 'test-image-2.gif', 'test-image-2.gif does not exist' ); + $this->assertFileExists( $testdir . 'test-image-3.jpg', 'test-image-3.jpg does not exist' ); + $this->assertFileExists( $testdir . 'test-image-4.png', 'test-image-4.png does not exist' ); + + // Standard test: file does not exist and there are no possible intersections with other files. + $this->assertSame( + 'abcdef.png', + wp_unique_filename( $testdir, 'abcdef.png' ), + 'The abcdef.png, abcdef.gif, and abcdef.jpg images do not exist. The file name should not be changed.' + ); + + // Actual clash recognized. + $this->assertSame( + 'canola-1.jpg', + wp_unique_filename( $testdir, 'canola.jpg' ), + 'The canola.jpg image exists. The file name should be unique.' + ); + + // Same name with different extension and the image will be converted. + $this->assertSame( + 'canola-1.png', + wp_unique_filename( $testdir, 'canola.png' ), + 'The canola.jpg image exists. Uploading canola.png that will be converted to canola.jpg should produce unique file name.' + ); + + // Same name with different uppercase extension and the image will be converted. + $this->assertSame( + 'canola-1.png', + wp_unique_filename( $testdir, 'canola.PNG' ), + 'The canola.jpg image exists. Uploading canola.PNG that will be converted to canola.jpg should produce unique file name.' + ); + + // Actual clash with several images with different extensions. + $this->assertSame( + 'test-image-5.png', + wp_unique_filename( $testdir, 'test-image.png' ), + 'The test-image.png, test-image-1-100x100.jpg, test-image-2.gif, test-image-3.jpg, and test-image-4.png images exist.' . + 'All of them may clash when creating sub-sizes or regenerating thumbnails in the future. The filename should be unique.' + ); + + // Possible clash with regenerated thumbnails in the future. + $this->assertSame( + 'codeispoetry-1.jpg', + wp_unique_filename( $testdir, 'codeispoetry.jpg' ), + 'The codeispoetry.png image exists. When regenerating thumbnails for it they will be converted to JPG.' . + 'The name of the newly uploaded codeispoetry.jpg should be made unique.' + ); + + remove_filter( 'image_editor_output_format', array( $this, 'image_editor_output_format_handler' ) ); + remove_filter( 'upload_dir', array( $this, 'upload_dir_patch_basedir' ) ); + } + + /** + * Changes the output format when editing images. When uploading a PNG file + * it will be converted to JPEG, GIF to JPEG, and PICT to BMP + * (if the image editor in PHP supports it). + * + * @param array $formats + * + * @return array + */ + public function image_editor_output_format_handler( $formats ) { + $formats['image/png'] = 'image/jpeg'; + $formats['image/gif'] = 'image/jpeg'; + $formats['image/pct'] = 'image/bmp'; + + return $formats; + } + /** * @dataProvider data_is_not_serialized */ @@ -1946,4 +2036,18 @@ class Tests_Functions extends WP_UnitTestCase { array( 'application/activity+json, application/nojson', true ), ); } + + /** + * @ticket 53668 + */ + public function test_wp_get_default_extension_for_mime_type() { + $this->assertEquals( 'jpg', wp_get_default_extension_for_mime_type( 'image/jpeg' ), 'jpg not returned as default extension for "image/jpeg"' ); + $this->assertNotEquals( 'jpeg', wp_get_default_extension_for_mime_type( 'image/jpeg' ), 'jpeg should not be returned as default extension for "image/jpeg"' ); + $this->assertEquals( 'png', wp_get_default_extension_for_mime_type( 'image/png' ), 'png not returned as default extension for "image/png"' ); + $this->assertFalse( wp_get_default_extension_for_mime_type( 'wibble/wobble' ), 'false not returned for unrecognized mime type' ); + $this->assertFalse( wp_get_default_extension_for_mime_type( '' ), 'false not returned when empty string as mime type supplied' ); + $this->assertFalse( wp_get_default_extension_for_mime_type( ' ' ), 'false not returned when empty string as mime type supplied' ); + $this->assertFalse( wp_get_default_extension_for_mime_type( 123 ), 'false not returned when int as mime type supplied' ); + $this->assertFalse( wp_get_default_extension_for_mime_type( null ), 'false not returned when null as mime type supplied' ); + } }