Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 65 additions & 6 deletions src/wp-admin/includes/image.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +795 to +799
* @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;
}

/**
Expand Down Expand Up @@ -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();
}
Comment on lines 956 to +964
}

if ( ! empty( $iptc['2#116'][0] ) ) { // Copyright.
Expand Down Expand Up @@ -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();
}
Comment on lines 1073 to +1086
}
if ( ! empty( $exif['FocalLength'] ) ) {
$meta['focal_length'] = (string) $exif['FocalLength'];
Expand Down
104 changes: 104 additions & 0 deletions tests/phpunit/tests/admin/includes/wpExifDate2TS.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

/**
* Class Tests_Admin_wpExifDate2ts
*
* Contains unit tests for wp_exif_date2ts(), which converts an Exif date
* string to a unix timestamp. Coverage for the underlying parsing lives in
* Tests_Admin_wpExifDatetime.
*
* @group admin
* @group image
*
* @covers ::wp_exif_date2ts
*/
class Tests_Admin_wpExifDate2ts extends WP_UnitTestCase {

public function set_up() {
parent::set_up();

// Pin the site timezone so the expected timestamps are deterministic.
update_option( 'timezone_string', 'UTC' );
}

/**
* @ticket 56887
*
* Test that valid date strings are converted to the expected timestamp.
*
* @dataProvider data_valid_dates
*
* @param string $input_date The date string to convert.
* @param int $expected The expected unix timestamp.
*
* @return void
*/
public function test_valid_dates( $input_date, $expected ) {
$this->assertSame( $expected, wp_exif_date2ts( $input_date ) );
}
Comment thread
t-hamano marked this conversation as resolved.

/**
* @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' ),
);
}
}
180 changes: 180 additions & 0 deletions tests/phpunit/tests/admin/includes/wpExifDatetime.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
<?php

/**
* Class Tests_Admin_wpExifDatetime
*
* Contains unit tests for validating the functionality of the wp_exif_datetime function,
* which is responsible for formatting datetime strings to the EXIF-compliant format.
*
* @group datetime
* @group image
*
* @covers ::wp_exif_datetime
*/
class Tests_Admin_wpExifDatetime extends WP_UnitTestCase {

/**
* @ticket 56887
*
* Test valid date inputs and their expected formatted outputs.
*
* @dataProvider provideValidDates
*
* @param string $input_date The input date string to be formatted.
* @param string $expected The expected formatted date string.
*
* @return void
*/
public function test_valid_dates( $input_date, $expected ) {
$datetime = wp_exif_datetime( $input_date );
$this->assertEquals( $expected, $datetime->format( 'Y:m:d H:i:s' ) );
}
Comment on lines +28 to +31

/**
* @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
*/
Comment on lines +72 to +74
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' ) );
}
}
Loading
Loading