diff --git a/src/wp-admin/includes/image.php b/src/wp-admin/includes/image.php index a05531c6d9557..8aa4d66a28938 100644 --- a/src/wp-admin/includes/image.php +++ b/src/wp-admin/includes/image.php @@ -857,13 +857,37 @@ function wp_read_image_metadata( $file ) { 'alt' => '', ); + /** + * Filters the list of allowed metadata keys to extract from an image. + * + * Allows customizing or disabling extraction of specific metadata fields + * (such as 'alt', 'caption', 'title', 'copyright', etc.), or disabling metadata extraction entirely. + * + * @since 7.2.0 + * + * @param string[] $allowed_keys Array of metadata keys to extract. Defaults to all keys: + * 'aperture', 'credit', 'camera', 'caption', 'created_timestamp', + * 'copyright', 'focal_length', 'iso', 'shutter_speed', 'title', + * 'orientation', 'keywords', 'alt'. + * @param string $file Path to the image file. + * @param int $image_type Type of image, one of the `IMAGETYPE_*` constants. + */ + $allowed_keys = apply_filters( 'wp_read_image_metadata_allowed_keys', array_keys( $meta ), $file, $image_type ); + + if ( ! is_array( $allowed_keys ) ) { + $allowed_keys = array(); + } + + $iptc_keys = array( 'title', 'caption', 'credit', 'created_timestamp', 'copyright', 'keywords' ); + $extract_iptc = ! empty( array_intersect( $iptc_keys, $allowed_keys ) ); + $iptc = array(); $info = array(); /* * Read IPTC first, since it might contain data not available in exif such * as caption, description etc. */ - if ( is_callable( 'iptcparse' ) ) { + if ( $extract_iptc && is_callable( 'iptcparse' ) ) { wp_getimagesize( $file, $info ); if ( ! empty( $info['APP13'] ) ) { @@ -882,52 +906,63 @@ function wp_read_image_metadata( $file ) { } // Headline, "A brief synopsis of the caption". - if ( ! empty( $iptc['2#105'][0] ) ) { - $meta['title'] = trim( $iptc['2#105'][0] ); - /* - * Title, "Many use the Title field to store the filename of the image, - * though the field may be used in many ways". - */ - } elseif ( ! empty( $iptc['2#005'][0] ) ) { - $meta['title'] = trim( $iptc['2#005'][0] ); + if ( in_array( 'title', $allowed_keys, true ) ) { + if ( ! empty( $iptc['2#105'][0] ) ) { + $meta['title'] = trim( $iptc['2#105'][0] ); + /* + * Title, "Many use the Title field to store the filename of the image, + * though the field may be used in many ways". + */ + } elseif ( ! empty( $iptc['2#005'][0] ) ) { + $meta['title'] = trim( $iptc['2#005'][0] ); + } } - if ( ! empty( $iptc['2#120'][0] ) ) { // Description / legacy caption. + if ( ( in_array( 'title', $allowed_keys, true ) || in_array( 'caption', $allowed_keys, true ) ) && ! empty( $iptc['2#120'][0] ) ) { // Description / legacy caption. $caption = trim( $iptc['2#120'][0] ); mbstring_binary_safe_encoding(); $caption_length = strlen( $caption ); reset_mbstring_encoding(); - if ( empty( $meta['title'] ) && $caption_length < 80 ) { + if ( in_array( 'title', $allowed_keys, true ) && empty( $meta['title'] ) && $caption_length < 80 ) { // Assume the title is stored in 2:120 if it's short. $meta['title'] = $caption; } - $meta['caption'] = $caption; + if ( in_array( 'caption', $allowed_keys, true ) ) { + $meta['caption'] = $caption; + } } - if ( ! empty( $iptc['2#110'][0] ) ) { // Credit. - $meta['credit'] = trim( $iptc['2#110'][0] ); - } elseif ( ! empty( $iptc['2#080'][0] ) ) { // Creator / legacy byline. - $meta['credit'] = trim( $iptc['2#080'][0] ); + if ( in_array( 'credit', $allowed_keys, true ) ) { + if ( ! empty( $iptc['2#110'][0] ) ) { // Credit. + $meta['credit'] = trim( $iptc['2#110'][0] ); + } elseif ( ! empty( $iptc['2#080'][0] ) ) { // Creator / legacy byline. + $meta['credit'] = trim( $iptc['2#080'][0] ); + } } - if ( ! empty( $iptc['2#055'][0] ) && ! empty( $iptc['2#060'][0] ) ) { // Created date and time. + if ( in_array( 'created_timestamp', $allowed_keys, true ) && ! 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] ); } - if ( ! empty( $iptc['2#116'][0] ) ) { // Copyright. + if ( in_array( 'copyright', $allowed_keys, true ) && ! empty( $iptc['2#116'][0] ) ) { // Copyright. $meta['copyright'] = trim( $iptc['2#116'][0] ); } - if ( ! empty( $iptc['2#025'][0] ) ) { // Keywords array. + if ( in_array( 'keywords', $allowed_keys, true ) && ! empty( $iptc['2#025'][0] ) ) { // Keywords array. $meta['keywords'] = $iptc['2#025']; } } } - $meta['alt'] = wp_get_image_alttext( $file ); + if ( in_array( 'alt', $allowed_keys, true ) ) { + $meta['alt'] = wp_get_image_alttext( $file ); + } + + $exif_keys = array( 'aperture', 'credit', 'camera', 'caption', 'created_timestamp', 'copyright', 'focal_length', 'iso', 'shutter_speed', 'title', 'orientation' ); + $extract_exif = ! empty( array_intersect( $exif_keys, $allowed_keys ) ); $exif = array(); @@ -941,7 +976,7 @@ function wp_read_image_metadata( $file ) { */ $exif_image_types = apply_filters( 'wp_read_image_metadata_types', array( IMAGETYPE_JPEG, IMAGETYPE_TIFF_II, IMAGETYPE_TIFF_MM ) ); - if ( is_callable( 'exif_read_data' ) && in_array( $image_type, $exif_image_types, true ) ) { + if ( $extract_exif && is_callable( 'exif_read_data' ) && in_array( $image_type, $exif_image_types, true ) ) { // Don't silence errors when in debug mode, unless running unit tests. if ( defined( 'WP_DEBUG' ) && WP_DEBUG && ! defined( 'WP_RUN_CORE_TESTS' ) @@ -970,43 +1005,47 @@ function wp_read_image_metadata( $file ) { mbstring_binary_safe_encoding(); $description_length = strlen( $exif_description ); reset_mbstring_encoding(); - if ( empty( $meta['title'] ) && $description_length < 80 ) { + if ( in_array( 'title', $allowed_keys, true ) && empty( $meta['title'] ) && $description_length < 80 ) { // Assume the title is stored in ImageDescription. $meta['title'] = $exif_description; } - // If both user comments and description are present. - if ( empty( $meta['caption'] ) && $exif_usercomment ) { - if ( ! empty( $meta['title'] ) && $exif_description === $meta['title'] ) { - $caption = $exif_usercomment; - } else { - if ( $exif_description === $exif_usercomment ) { - $caption = $exif_description; + if ( in_array( 'caption', $allowed_keys, true ) ) { + // If both user comments and description are present. + if ( empty( $meta['caption'] ) && $exif_usercomment ) { + if ( ! empty( $meta['title'] ) && $exif_description === $meta['title'] ) { + $caption = $exif_usercomment; } else { - $caption = trim( $exif_description . ' ' . $exif_usercomment ); + if ( $exif_description === $exif_usercomment ) { + $caption = $exif_description; + } else { + $caption = trim( $exif_description . ' ' . $exif_usercomment ); + } } + $meta['caption'] = $caption; } - $meta['caption'] = $caption; - } - if ( empty( $meta['caption'] ) && $exif_usercomment ) { - $meta['caption'] = $exif_usercomment; - } + if ( empty( $meta['caption'] ) && $exif_usercomment ) { + $meta['caption'] = $exif_usercomment; + } - if ( empty( $meta['caption'] ) ) { - $meta['caption'] = $exif_description; + if ( empty( $meta['caption'] ) ) { + $meta['caption'] = $exif_description; + } + } + } elseif ( $exif_usercomment && ( ( in_array( 'caption', $allowed_keys, true ) && empty( $meta['caption'] ) ) || ( in_array( 'title', $allowed_keys, true ) && empty( $meta['title'] ) ) ) ) { + if ( in_array( 'caption', $allowed_keys, true ) && empty( $meta['caption'] ) ) { + $meta['caption'] = $exif_usercomment; } - } elseif ( empty( $meta['caption'] ) && $exif_usercomment ) { - $meta['caption'] = $exif_usercomment; $description_length = strlen( $exif_usercomment ); - if ( empty( $meta['title'] ) && $description_length < 80 ) { + if ( in_array( 'title', $allowed_keys, true ) && empty( $meta['title'] ) && $description_length < 80 ) { $meta['title'] = trim( $exif_usercomment ); } - } elseif ( empty( $meta['caption'] ) && ! empty( $exif['Comments'] ) ) { + } elseif ( in_array( 'caption', $allowed_keys, true ) && empty( $meta['caption'] ) && ! empty( $exif['Comments'] ) ) { $meta['caption'] = trim( $exif['Comments'] ); } - if ( empty( $meta['credit'] ) ) { + if ( in_array( 'credit', $allowed_keys, true ) && empty( $meta['credit'] ) ) { if ( ! empty( $exif['Artist'] ) ) { $meta['credit'] = trim( $exif['Artist'] ); } elseif ( ! empty( $exif['Author'] ) ) { @@ -1014,35 +1053,35 @@ function wp_read_image_metadata( $file ) { } } - if ( empty( $meta['copyright'] ) && ! empty( $exif['Copyright'] ) ) { + if ( in_array( 'copyright', $allowed_keys, true ) && empty( $meta['copyright'] ) && ! empty( $exif['Copyright'] ) ) { $meta['copyright'] = trim( $exif['Copyright'] ); } - if ( ! empty( $exif['FNumber'] ) && is_scalar( $exif['FNumber'] ) ) { + if ( in_array( 'aperture', $allowed_keys, true ) && ! empty( $exif['FNumber'] ) && is_scalar( $exif['FNumber'] ) ) { $meta['aperture'] = round( wp_exif_frac2dec( $exif['FNumber'] ), 2 ); } - if ( ! empty( $exif['Model'] ) ) { + if ( in_array( 'camera', $allowed_keys, true ) && ! empty( $exif['Model'] ) ) { $meta['camera'] = trim( $exif['Model'] ); } - if ( empty( $meta['created_timestamp'] ) && ! empty( $exif['DateTimeDigitized'] ) ) { + if ( in_array( 'created_timestamp', $allowed_keys, true ) && empty( $meta['created_timestamp'] ) && ! empty( $exif['DateTimeDigitized'] ) ) { $meta['created_timestamp'] = wp_exif_date2ts( $exif['DateTimeDigitized'] ); } - if ( ! empty( $exif['FocalLength'] ) ) { + if ( in_array( 'focal_length', $allowed_keys, true ) && ! empty( $exif['FocalLength'] ) ) { $meta['focal_length'] = (string) $exif['FocalLength']; if ( is_scalar( $exif['FocalLength'] ) ) { $meta['focal_length'] = (string) wp_exif_frac2dec( $exif['FocalLength'] ); } } - if ( ! empty( $exif['ISOSpeedRatings'] ) ) { + if ( in_array( 'iso', $allowed_keys, true ) && ! empty( $exif['ISOSpeedRatings'] ) ) { $meta['iso'] = is_array( $exif['ISOSpeedRatings'] ) ? reset( $exif['ISOSpeedRatings'] ) : $exif['ISOSpeedRatings']; $meta['iso'] = trim( $meta['iso'] ); } - if ( ! empty( $exif['ExposureTime'] ) ) { + if ( in_array( 'shutter_speed', $allowed_keys, true ) && ! empty( $exif['ExposureTime'] ) ) { $meta['shutter_speed'] = (string) $exif['ExposureTime']; if ( is_scalar( $exif['ExposureTime'] ) ) { $meta['shutter_speed'] = (string) wp_exif_frac2dec( $exif['ExposureTime'] ); } } - if ( ! empty( $exif['Orientation'] ) ) { + if ( in_array( 'orientation', $allowed_keys, true ) && ! empty( $exif['Orientation'] ) ) { $meta['orientation'] = $exif['Orientation']; } } diff --git a/tests/phpunit/tests/image/readImageMetadataAllowedKeys.php b/tests/phpunit/tests/image/readImageMetadataAllowedKeys.php new file mode 100644 index 0000000000000..0c45574377e55 --- /dev/null +++ b/tests/phpunit/tests/image/readImageMetadataAllowedKeys.php @@ -0,0 +1,296 @@ +assertIsArray( $meta ); + $this->assertSame( 'NIKON D70', $meta['camera'] ); + $this->assertSame( '6.3', $meta['aperture'] ); + $this->assertSame( '27', $meta['focal_length'] ); + $this->assertSame( '400', $meta['iso'] ); + } + + /** + * Tests that disallowing the 'alt' key prevents alt text extraction while allowing other keys. + * + * @ticket 66248 + */ + public function test_disallowing_alt_key_skips_alt_extraction() { + add_filter( + 'wp_read_image_metadata_allowed_keys', + static function ( $keys ) { + return array_diff( $keys, array( 'alt' ) ); + } + ); + + $meta = wp_read_image_metadata( self::$iptc_xmp_image ); + + $this->assertIsArray( $meta ); + $this->assertSame( '', $meta['alt'], "Alt text should remain empty when 'alt' is not in allowed keys." ); + $this->assertNotEmpty( $meta['caption'], 'Caption should still be extracted from IPTC.' ); + } + + /** + * Tests that passing an empty array of allowed keys skips all metadata extraction + * while preserving the complete default array structure. + * + * @ticket 66248 + */ + public function test_empty_allowed_keys_skips_all_extraction_and_preserves_structure() { + add_filter( 'wp_read_image_metadata_allowed_keys', '__return_empty_array' ); + + $meta = wp_read_image_metadata( self::$exif_image ); + + $expected = array( + 'aperture' => '0', + 'credit' => '', + 'camera' => '', + 'caption' => '', + 'created_timestamp' => '0', + 'copyright' => '', + 'focal_length' => '0', + 'iso' => '0', + 'shutter_speed' => '0', + 'title' => '', + 'orientation' => '0', + 'keywords' => array(), + 'alt' => '', + ); + + $this->assertSame( $expected, $meta ); + } + + /** + * Tests that non-array return values from the filter are safely treated as an empty array. + * + * @ticket 66248 + * + * @dataProvider data_non_array_allowed_keys + * + * @param mixed $non_array_value Non-array filter return value. + */ + public function test_non_array_allowed_keys_treated_as_empty_array( $non_array_value ) { + add_filter( + 'wp_read_image_metadata_allowed_keys', + static function () use ( $non_array_value ) { + return $non_array_value; + } + ); + + $meta = wp_read_image_metadata( self::$exif_image ); + + $this->assertSame( '0', $meta['aperture'] ); + $this->assertSame( '', $meta['camera'] ); + $this->assertSame( '', $meta['alt'] ); + } + + /** + * Data provider for non-array allowed keys filter return values. + * + * @return array[] + */ + public static function data_non_array_allowed_keys() { + return array( + 'false' => array( false ), + 'null' => array( null ), + 'string' => array( 'invalid' ), + 'int' => array( 123 ), + ); + } + + /** + * Tests whitelisting specific metadata keys only extracts those fields. + * + * @ticket 66248 + */ + public function test_whitelist_specific_keys_only_extracts_those_fields() { + add_filter( + 'wp_read_image_metadata_allowed_keys', + static function () { + return array( 'camera', 'iso' ); + } + ); + + $meta = wp_read_image_metadata( self::$exif_image ); + + // Whitelisted fields should be extracted. + $this->assertSame( 'NIKON D70', $meta['camera'] ); + $this->assertSame( '400', $meta['iso'] ); + + // Non-whitelisted fields should remain defaults. + $this->assertSame( '0', $meta['aperture'] ); + $this->assertSame( '0', $meta['focal_length'] ); + $this->assertSame( '0', $meta['shutter_speed'] ); + $this->assertSame( '', $meta['alt'] ); + } + + /** + * Tests that the filter receives all expected parameters. + * + * @ticket 66248 + */ + public function test_allowed_keys_filter_receives_expected_arguments() { + $captured_args = array(); + + add_filter( + 'wp_read_image_metadata_allowed_keys', + static function ( $allowed_keys, $file, $image_type ) use ( &$captured_args ) { + $captured_args = array( + 'allowed_keys' => $allowed_keys, + 'file' => $file, + 'image_type' => $image_type, + ); + return $allowed_keys; + }, + 10, + 3 + ); + + wp_read_image_metadata( self::$exif_image ); + + $expected_keys = array( + 'aperture', + 'credit', + 'camera', + 'caption', + 'created_timestamp', + 'copyright', + 'focal_length', + 'iso', + 'shutter_speed', + 'title', + 'orientation', + 'keywords', + 'alt', + ); + + $this->assertSame( $expected_keys, $captured_args['allowed_keys'] ); + $this->assertSame( self::$exif_image, $captured_args['file'] ); + $this->assertSame( IMAGETYPE_JPEG, $captured_args['image_type'] ); + } + + /** + * Tests that allowing only 'title' extracts the title from IPTC 2#120 + * without extracting 'caption'. + * + * @ticket 66248 + */ + public function test_allowed_keys_with_only_title_extracts_title_from_iptc_without_caption() { + if ( ! function_exists( 'iptcembed' ) ) { + $this->markTestSkipped( 'iptcembed() is required for this test.' ); + } + + $iptc_tag = chr( 0x1c ) . chr( 0x02 ) . chr( 120 ) . chr( 0x00 ) . chr( strlen( 'IPTC Fallback Title' ) ) . 'IPTC Fallback Title'; + $data = iptcembed( $iptc_tag, DIR_TESTDATA . '/images/2004-07-22-DSC_0008.jpg' ); + $temp_file = wp_tempnam( 'iptc-title' ); + file_put_contents( $temp_file, $data ); + + add_filter( + 'wp_read_image_metadata_allowed_keys', + static function () { + return array( 'title' ); + } + ); + + $meta = wp_read_image_metadata( $temp_file ); + unlink( $temp_file ); + + $this->assertSame( 'IPTC Fallback Title', $meta['title'], 'Title should be extracted from IPTC 2#120 when only title is allowed.' ); + $this->assertSame( '', $meta['caption'], 'Caption should remain empty when caption is not in allowed keys.' ); + } + + /** + * Tests that allowing only 'title' extracts the title from EXIF metadata + * without extracting 'caption'. + * + * @ticket 66248 + */ + public function test_allowed_keys_with_only_title_extracts_title_from_exif_without_caption() { + add_filter( + 'wp_read_image_metadata_allowed_keys', + static function () { + return array( 'title' ); + } + ); + + $meta = wp_read_image_metadata( DIR_TESTDATA . '/images/2007-06-17DSC_4173.JPG' ); + + $this->assertSame( 'Copyright Alex Shiels', $meta['title'], 'Title should be extracted from EXIF when only title is allowed.' ); + $this->assertSame( '', $meta['caption'], 'Caption should remain empty when caption is not in allowed keys.' ); + } + + /** + * Tests that allowing only 'caption' extracts the caption from IPTC 2#120 + * without extracting 'title'. + * + * @ticket 66248 + */ + public function test_allowed_keys_with_only_caption_extracts_caption_without_title() { + add_filter( + 'wp_read_image_metadata_allowed_keys', + static function () { + return array( 'caption' ); + } + ); + + $meta = wp_read_image_metadata( DIR_TESTDATA . '/images/2004-07-22-DSC_0007.jpg' ); + + $this->assertSame( 'IPTC Caption', $meta['caption'], 'Caption should be extracted when caption is allowed.' ); + $this->assertSame( '', $meta['title'], 'Title should remain empty when title is not in allowed keys.' ); + } +}