diff --git a/src/wp-admin/includes/image.php b/src/wp-admin/includes/image.php index 9fe2471669f99..dc9ace28fc23f 100644 --- a/src/wp-admin/includes/image.php +++ b/src/wp-admin/includes/image.php @@ -792,18 +792,48 @@ function wp_exif_frac2dec( $str ) { } /** - * Converts the exif date format to a unix timestamp. + * @ticket 56887 + * + * Convert the exif date format to DateTime object + * + * @since 6.9.0 + * + * @param string $str + * @param string $timezone Timezone or offset string. + * @return DateTimeImmutable|false Return false if not valid date + */ +function wp_exif_datetime( $str, $timezone = null ) { + if ( ! is_string( $str ) || empty( $str ) ) { + + return false; + } + $timezone = ( $timezone ) ? new DateTimeZone( $timezone ) : wp_timezone(); + try { + $datetime = new DateTimeImmutable( $str, $timezone ); + } catch ( Exception $e ) { + + return false; + } + + return $datetime; +} + +/** + * Converts the exif date format to an unix timestamp. * * @since 2.5.0 + * @since 6.9.0 Function uses wp_exif_datetime to generate 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; } /** @@ -907,7 +937,11 @@ 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 = new DateTimeImmutable( $iptc['2#055'][0] . ' ' . $iptc['2#060'][0] ); + // 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. @@ -1015,7 +1049,17 @@ 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 ); + + // 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/wpExicDatetime.php b/tests/phpunit/tests/admin/includes/wpExicDatetime.php new file mode 100644 index 0000000000000..ab63a0d15106c --- /dev/null +++ b/tests/phpunit/tests/admin/includes/wpExicDatetime.php @@ -0,0 +1,130 @@ +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( + 'unix timestamp' => array( + '1710500000', // March 15, 2024 14:30:00 + '0000:06:03 17:10:50', + ), + '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', + ), + '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' ), + '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 + * + * Tests the functionality of parsing dates with different separators and ensures the output format is consistent. + * + * @return void + */ + public function test_different_separators() { + + $datetime = wp_exif_datetime( '2024/03/15 14:30:00' ); + $this->assertEquals( '2024:03:15 14:30:00', $datetime->format( 'Y:m:d H:i:s' ) ); + + $datetime = wp_exif_datetime( '2024/03/15 14:30:00' ); + $this->assertEquals( '2024:03:15 14:30:00', $datetime->format( 'Y:m:d H:i:s' ) ); + } +} diff --git a/tests/phpunit/tests/admin/includes/wpExifDate2TS.php b/tests/phpunit/tests/admin/includes/wpExifDate2TS.php new file mode 100644 index 0000000000000..4cf07f9f4f226 --- /dev/null +++ b/tests/phpunit/tests/admin/includes/wpExifDate2TS.php @@ -0,0 +1,71 @@ +assertSame( $expected, $result->format( 'Y:m:d H:i:s' ) ); + } + + /** + * Test that invalid inputs return false + * + * @dataProvider provideInvalidDates + */ + public function test_returns_false_for_invalid_input( $input ) { + $result = wp_exif_datetime( $input ); + $this->assertFalse( $result ); + } + + /** + * Data provider for valid dates + */ + public function provideValidDates() { + return array( + 'mysql format' => array( + '2024-03-15 14:30:00', + '2024:03:15 14:30:00', + ), + 'mysql format with seconds' => array( + '2024-03-15 14:30:45', + '2024:03:15 14:30:45', + ), + '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 + */ + public function provideInvalidDates() { + return array( + 'empty string' => array( '' ), + 'null' => array( null ), + 'invalid date string' => array( 'not a date' ), + 'malformed date' => array( '2024-13-45' ), + 'boolean true' => array( true ), + 'boolean false' => array( false ), + 'array' => array( array() ), + 'object' => array( new stdClass() ), + '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/image/meta.php b/tests/phpunit/tests/image/meta.php index 88b2cbcef1e40..aabf5e9b968c5 100644 --- a/tests/phpunit/tests/image/meta.php +++ b/tests/phpunit/tests/image/meta.php @@ -217,6 +217,7 @@ public function data_stream() { 'title' => 'IPTC Headline', 'orientation' => '0', 'keywords' => array(), + 'created' => '2004-07-22T17:14:35+00:00', ), ), 'Exif from a DMC-LX2 camera with keywords' => array( @@ -234,6 +235,7 @@ public function data_stream() { 'title' => 'Photoshop Document Ttitle', 'orientation' => '1', 'keywords' => array( 'beach', 'baywatch', 'LA', 'sunset' ), + 'created' => '2011-05-25T09:22:07+00:00', ), ), );