diff --git a/src/wp-admin/includes/image.php b/src/wp-admin/includes/image.php index 935c613d561e9..78ab415b5f1bc 100644 --- a/src/wp-admin/includes/image.php +++ b/src/wp-admin/includes/image.php @@ -792,18 +792,58 @@ function wp_exif_frac2dec( $str ) { } /** - * Converts the exif date format to a unix timestamp. + * Converts the exif date format to DateTime object + * + * @since 7.2.0 + * + * @param string $str + * @param string $timezone Optional. Timezone or offset string. Anything that is not a + * non-empty string falls back to the site timezone. Default null. + * @return DateTimeImmutable|false Return false if not valid date. + */ +function wp_exif_datetime( $str, $timezone = null ) { + if ( ! is_string( $str ) || empty( $str ) ) { + + return false; + } + try { + $timezone = ( is_string( $timezone ) && '' !== $timezone ) ? new DateTimeZone( $timezone ) : wp_timezone(); + $datetime = new DateTimeImmutable( $str, $timezone ); + } catch ( Exception $e ) { + + return false; + } + + /* + * Out of range components are rolled over rather than rejected, so the '0000:00:00 00:00:00' + * cameras write for an unset field parses as a year -1 date. Every correction the parser + * had to make is reported as a warning. + */ + $parse_errors = DateTimeImmutable::getLastErrors(); + + if ( $parse_errors && ( $parse_errors['error_count'] || $parse_errors['warning_count'] ) ) { + return false; + } + + return $datetime; +} + +/** + * Converts the exif date format to an unix timestamp. * * @since 2.5.0 + * @since 7.2.0 Uses wp_exif_datetime() to generate the timestamp. * * @param string $str A date string expected to be in Exif format (Y:m:d H:i:s). * @return int|false The unix timestamp, or false on failure. */ function wp_exif_date2ts( $str ) { - list( $date, $time ) = explode( ' ', trim( $str ) ); - list( $y, $m, $d ) = explode( ':', $date ); + $datetime = wp_exif_datetime( $str ); - return strtotime( "{$y}-{$m}-{$d} {$time}" ); + if ( $datetime instanceof DateTimeImmutable ) { + return $datetime->getTimestamp(); + } + return false; } /** @@ -914,7 +954,14 @@ function wp_read_image_metadata( $file ) { } if ( ! empty( $iptc['2#055'][0] ) && ! empty( $iptc['2#060'][0] ) ) { // Created date and time. - $meta['created_timestamp'] = strtotime( $iptc['2#055'][0] . ' ' . $iptc['2#060'][0] ); + $datetime = wp_exif_datetime( $iptc['2#055'][0] . ' ' . $iptc['2#060'][0] ); + + if ( $datetime instanceof DateTimeImmutable ) { + // Store as a RFC3339 formatted timestring as this includes both date, time, and timezone. + $meta['created'] = $datetime->format( DATE_RFC3339 ); + // Retain the original created timestamp for backcompat. + $meta['created_timestamp'] = $datetime->getTimestamp(); + } } if ( ! empty( $iptc['2#116'][0] ) ) { // Copyright. @@ -1024,7 +1071,19 @@ function wp_read_image_metadata( $file ) { $meta['camera'] = trim( $exif['Model'] ); } if ( empty( $meta['created_timestamp'] ) && ! empty( $exif['DateTimeDigitized'] ) ) { - $meta['created_timestamp'] = wp_exif_date2ts( $exif['DateTimeDigitized'] ); + $timezone = null; + if ( ! empty( $exif['UndefinedTag:0x9012'] ) ) { + $timezone = $exif['UndefinedTag:0x9012']; + } + + $datetime = wp_exif_datetime( $exif['DateTimeDigitized'], $timezone ); + + if ( $datetime instanceof DateTimeImmutable ) { + // Store as a RFC3339 formatted timestring as this includes both date, time, and timezone. + $meta['created'] = $datetime->format( DATE_RFC3339 ); + // Retain the original created timestamp for backcompat. + $meta['created_timestamp'] = $datetime->getTimestamp(); + } } if ( ! empty( $exif['FocalLength'] ) ) { $meta['focal_length'] = (string) $exif['FocalLength']; diff --git a/tests/phpunit/tests/admin/includes/wpExifDate2TS.php b/tests/phpunit/tests/admin/includes/wpExifDate2TS.php new file mode 100644 index 0000000000000..5bbbb53596548 --- /dev/null +++ b/tests/phpunit/tests/admin/includes/wpExifDate2TS.php @@ -0,0 +1,104 @@ +assertSame( $expected, wp_exif_date2ts( $input_date ) ); + } + + /** + * @ticket 56887 + * + * Test that invalid input returns false rather than throwing. + * + * @dataProvider data_invalid_dates + * + * @param mixed $input_date The value to convert. + * + * @return void + */ + public function test_returns_false_for_invalid_input( $input_date ) { + $this->assertFalse( wp_exif_date2ts( $input_date ) ); + } + + /** + * Data provider for valid dates. + * + * @return array[] + */ + public function data_valid_dates() { + return array( + 'exif format' => array( + '2024:03:15 14:30:00', + 1710513000, + ), + 'mysql format' => array( + '2024-03-15 14:30:00', + 1710513000, + ), + 'mysql format with seconds' => array( + '2024-03-15 14:30:45', + 1710513045, + ), + 'date only' => array( + '2024-03-15', + 1710460800, + ), + 'incomplete date' => array( + '2024-03', + 1709251200, + ), + ); + } + + /** + * Data provider for invalid dates. + * + * @return array[] + */ + public function data_invalid_dates() { + return array( + 'empty string' => array( '' ), + 'null' => array( null ), + 'boolean true' => array( true ), + 'boolean false' => array( false ), + 'array' => array( array() ), + 'object' => array( new stdClass() ), + 'invalid date string' => array( 'not a date' ), + 'invalid month' => array( '2024-13-15' ), + 'invalid day' => array( '2024-03-32' ), + 'invalid hour' => array( '2024-03-15 25:00:00' ), + ); + } +} diff --git a/tests/phpunit/tests/admin/includes/wpExifDatetime.php b/tests/phpunit/tests/admin/includes/wpExifDatetime.php new file mode 100644 index 0000000000000..0e22b53b20a27 --- /dev/null +++ b/tests/phpunit/tests/admin/includes/wpExifDatetime.php @@ -0,0 +1,180 @@ +assertEquals( $expected, $datetime->format( 'Y:m:d H:i:s' ) ); + } + + /** + * @ticket 56887 + * + * Test handling of invalid date inputs. + * + * @dataProvider provideInvalidDates + * + * @param string $input_date The date string to be tested for validation. + * + * @return void + */ + public function test_invalid_dates( $input_date ) { + $this->assertFalse( wp_exif_datetime( $input_date ) ); + } + + /** + * Data provider for valid dates + */ + public function provideValidDates() { + return array( + 'mysql datetime' => array( + '2024-03-15 14:30:00', + '2024:03:15 14:30:00', + ), + 'exif format' => array( + '2024:03:15 14:30:00', + '2024:03:15 14:30:00', + ), + 'slash separated' => array( + '2024/03/15 14:30:00', + '2024:03:15 14:30:00', + ), + 'no separators' => array( + '20240315 143000', + '2024:03:15 14:30:00', + ), + 'mysql date only' => array( + '2024-03-15', + '2024:03:15 00:00:00', + ), + 'incomplete date' => array( + '2024-03', + '2024:03:01 00:00:00', + ), + ); + } + + /** + * Data provider for invalid dates that should trigger exceptions + */ + public function provideInvalidDates() { + return array( + 'empty string' => array( '' ), + 'null' => array( null ), + 'boolean false' => array( false ), + 'boolean true' => array( true ), + 'invalid format' => array( 'not a date' ), + 'invalid month' => array( '2024-13-15' ), + 'invalid day' => array( '2024-03-32' ), + 'invalid time' => array( '2024-03-15 25:00:00' ), + 'day rolled into a month' => array( '2024:02:30 00:00:00' ), + 'unset exif field' => array( '0000:00:00 00:00:00' ), + 'garbage with numbers' => array( '2024abc15' ), + 'array input' => array( array() ), + 'object input' => array( new stdClass() ), + 'out of bounds timestamp' => array( 253402300800 ), // Year 9999 + 'negative timestamp' => array( - 62167219200 ), // Year 0 + ); + } + + /** + * @ticket 56887 + * + * Test handling of edge case date and time formats. + * + * @return void + */ + public function test_edge_cases() { + + // Test with a very old date + $datetime = wp_exif_datetime( '1900-01-01' ); + $this->assertEquals( '1900:01:01 00:00:00', $datetime->format( 'Y:m:d H:i:s' ) ); + + // Test with milliseconds + $datetime = wp_exif_datetime( '2024-03-15 14:30:00.123' ); + $this->assertEquals( '2024:03:15 14:30:00', $datetime->format( 'Y:m:d H:i:s' ) ); + } + + /** + * @ticket 56887 + * + * Test that an offset from the Exif OffsetTime tags is applied. + * + * @return void + */ + public function test_timezone_argument_is_applied() { + $datetime = wp_exif_datetime( '2024:03:15 14:30:00', '+09:00' ); + + $this->assertSame( '+09:00', $datetime->format( 'P' ) ); + $this->assertSame( '2024:03:15 14:30:00', $datetime->format( 'Y:m:d H:i:s' ) ); + } + + /** + * @ticket 56887 + * + * Test that an unusable timezone argument falls back to the site timezone instead of + * erroring out. Exif data is untrusted, so the tags can hold anything. + * + * @dataProvider data_unusable_timezones + * + * @param mixed $timezone The timezone argument to pass. + * + * @return void + */ + public function test_unusable_timezone_falls_back_to_site_timezone( $timezone ) { + $datetime = wp_exif_datetime( '2024:03:15 14:30:00', $timezone ); + + $this->assertInstanceOf( 'DateTimeImmutable', $datetime ); + $this->assertSame( wp_timezone()->getName(), $datetime->getTimezone()->getName() ); + } + + /** + * Data provider for timezone arguments that cannot be used. + * + * @return array[] + */ + public function data_unusable_timezones() { + return array( + 'null' => array( null ), + 'empty string' => array( '' ), + 'array' => array( array() ), + 'object' => array( new stdClass() ), + 'boolean true' => array( true ), + 'boolean false' => array( false ), + ); + } + + /** + * @ticket 56887 + * + * Test that an unrecognized timezone string returns false rather than throwing. + * + * @return void + */ + public function test_invalid_timezone_string_returns_false() { + $this->assertFalse( wp_exif_datetime( '2024:03:15 14:30:00', 'Not/AZone' ) ); + } +} diff --git a/tests/phpunit/tests/image/meta.php b/tests/phpunit/tests/image/meta.php index babd3a94bb3b8..d90cec1b9855a 100644 --- a/tests/phpunit/tests/image/meta.php +++ b/tests/phpunit/tests/image/meta.php @@ -229,6 +229,7 @@ public function data_stream() { 'orientation' => '0', 'keywords' => array(), 'alt' => '', + 'created' => '2004-07-22T17:14:35+00:00', ), ), 'Exif from a DMC-LX2 camera with keywords' => array( @@ -247,6 +248,7 @@ public function data_stream() { 'orientation' => '1', 'keywords' => array( 'beach', 'baywatch', 'LA', 'sunset' ), 'alt' => '', + 'created' => '2011-05-25T09:22:07+00:00', ), ), );