*/ protected static $editor_saves = array(); /** * Registers the WordPress hooks that drive metadata embedding. * * @since 1.0.0 * * @return void */ public function hooks() { add_filter( 'wp_generate_attachment_metadata', array( $this, 'on_generate_metadata' ), 20, 3 ); add_filter( 'wp_edited_image_metadata', array( $this, 'on_edited_image_metadata' ), 20, 3 ); add_filter( 'image_editor_save_pre', array( $this, 'note_image_editor_save' ), 10, 2 ); add_filter( 'wp_update_attachment_metadata', array( $this, 'on_update_metadata' ), 20, 2 ); } /** * Embeds the mark when attachment metadata is generated for a flagged image. * * WordPress 7.1 can hand image processing to the browser, in which case every sub-size is * generated client-side and uploaded on its own, and this filter runs twice: once as 'create' * and again as 'update'. On the first pass the sub-size files may not have landed yet, so a * mark written then would cover the original alone. Running unconditionally on every pass is * what gets the resized copies marked too, and repeating costs nothing because write() returns * early on a file that already declares the same source type. * * The $context parameter is accepted for readability only. It is deliberately discarded: the * correct behaviour is to run on every pass, so nothing here should branch on which pass it is. * Note that accepting it changes no behaviour either way, because accepted_args only trims the * argument list a callback receives and never decides whether the callback runs. * * @since 1.0.0 * * @param array $metadata The generated attachment metadata. * @param int $attachment_id The attachment post ID. * @param string $context 'create' or 'update'. Passed by core since 5.3.0; unused here. * @return array The unmodified attachment metadata. */ public function on_generate_metadata( $metadata, $attachment_id, $context = '' ) { unset( $context ); if ( AICL_Plugin::is_flagged( $attachment_id ) ) { self::embed_attachment( $attachment_id, $metadata ); } return $metadata; } /** * Notes that the built-in image editor is about to rewrite this attachment's files. * * Cropping or rotating from the Media screen writes new files over the old ones, and the mark * does not survive being re-encoded. Core offers no action after that save, only * wp_update_attachment_metadata(), which fires for all sorts of unrelated reasons. Recording the * attachment here lets the re-embed run for the one case that needs it instead of on every * metadata write. * * @since 2.1.4 * * @param resource|GdImage $image The image resource, passed through untouched. * @param int $attachment_id The attachment being saved. * @return resource|GdImage The unmodified image. */ public function note_image_editor_save( $image, $attachment_id ) { self::$editor_saves[ (int) $attachment_id ] = true; return $image; } /** * Rewrites the mark after the built-in image editor has replaced an attachment's files. * * Without this, a crop or rotate left the disclosure half true: the source type stayed on the * attachment so the badge and the JSON-LD still rendered, while every file behind them had been * re-encoded and carried nothing. Worse, the recorded embed status was never revised, so the * Media screen went on reporting the mark as written into files that no longer held it. * * @since 2.1.4 * * @param array $metadata The attachment metadata being saved. * @param int $attachment_id The attachment post ID. * @return array The unmodified metadata. */ public function on_update_metadata( $metadata, $attachment_id ) { $attachment_id = (int) $attachment_id; if ( empty( self::$editor_saves[ $attachment_id ] ) ) { return $metadata; } unset( self::$editor_saves[ $attachment_id ] ); if ( AICL_Plugin::is_flagged( $attachment_id ) ) { self::embed_attachment( $attachment_id, $metadata ); } return $metadata; } /** * Carries the disclosure across when an image is edited in the block editor. * * Cropping, rotating or flipping an image does not change the file in place. Core saves the * result as a brand new attachment and copies only the alt text across, so a picture the owner * had marked as AI came out the other side unmarked: no badge, no JSON-LD and nothing embedded * in the file. The alt text surviving made it look as though everything had. * * The source type is the owner's statement about how the picture was made, and rotating it does * not make it any less machine-made, so it belongs on the copy. Embedding then runs on the new * attachment as it would for any other upload. * * @since 2.1.4 * * @param array $metadata Metadata for the newly created attachment. * @param int $new_attachment_id The attachment the edit produced. * @param int $attachment_id The attachment that was edited. * @return array The unmodified metadata. */ public function on_edited_image_metadata( $metadata, $new_attachment_id, $attachment_id ) { $type = AICL_Plugin::get_type( $attachment_id ); if ( '' === $type ) { return $metadata; } update_post_meta( $new_attachment_id, AICL_Plugin::META_KEY, $type ); AICL_Plugin::flush_flagged_count(); self::embed_attachment( $new_attachment_id, $metadata ); return $metadata; } /** * Embeds the mark into the original file and every generated size. * * The result is recorded in attachment meta for the admin column. * * @since 1.0.0 * * @param int $attachment_id The attachment post ID. * @param array|null $metadata Optional attachment metadata; fetched when null. * @return void */ public static function embed_attachment( $attachment_id, $metadata = null ) { if ( ! AICL_Plugin::opt( 'embed_metadata' ) ) { return; } $token = AICL_Plugin::get_type( $attachment_id ); if ( '' === $token ) { return; } $uri = AICL_Plugin::uri_for( $token ); $full = get_attached_file( $attachment_id ); if ( ! $full || ! file_exists( $full ) ) { update_post_meta( $attachment_id, AICL_Plugin::META_EMBED, 'file missing' ); return; } if ( null === $metadata ) { $metadata = wp_get_attachment_metadata( $attachment_id ); } $files = array( $full ); $dir = trailingslashit( dirname( $full ) ); if ( is_array( $metadata ) && ! empty( $metadata['sizes'] ) ) { foreach ( $metadata['sizes'] as $size ) { if ( ! empty( $size['file'] ) ) { $files[] = $dir . $size['file']; } } } $ok = 0; $skipped = 0; $failed = 0; foreach ( array_unique( $files ) as $path ) { if ( ! file_exists( $path ) ) { continue; } $res = self::write( $path, $uri ); if ( 'ok' === $res ) { ++$ok; } elseif ( 'unsupported' === $res ) { ++$skipped; } else { ++$failed; } } $status = sprintf( 'embedded %d', $ok ); if ( $skipped ) { $status .= sprintf( ', %d unsupported', $skipped ); } if ( $failed ) { $status .= sprintf( ', %d failed', $failed ); } update_post_meta( $attachment_id, AICL_Plugin::META_EMBED, $status ); } /** * Writes the XMP DigitalSourceType property into one image file. * * A file already carrying the exact value is left untouched. Rewriting one that declares the same * source type would only invalidate any provenance signature it already holds, such as a * C2PA-signed camera capture. * * @since 1.0.0 * * @param string $path Absolute path to the image file. * @param string $uri The IPTC Digital Source Type URI to embed. * @return string One of 'ok', 'unsupported', or 'failed'. */ public static function write( $path, $uri ) { if ( ! wp_is_writable( $path ) || ! self::within_uploads( $path ) ) { return 'failed'; } $head = (string) @file_get_contents( $path, false, null, 0, 524288 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading the head of a local upload, not a remote URL; unreadable head falls through to a normal write attempt. if ( '' !== $head && preg_match( '#' . preg_quote( $uri, '#' ) . '(?![A-Za-z])#', $head ) ) { return 'ok'; } $ext = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) ); if ( self::exiftool() ) { $cmd = sprintf( '%s -overwrite_original -q -XMP-iptcExt:DigitalSourceType=%s %s 2>&1; echo "__aiclrc:$?"', escapeshellarg( self::exiftool() ), escapeshellarg( $uri ), escapeshellarg( $path ) ); $out = (string) @shell_exec( $cmd ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_shell_exec, WordPress.WP.AlternativeFunctions.shell_exec_shell_exec, WordPress.PHP.NoSilencedErrors.Discouraged -- exiftool invocation; all args escapeshellarg-escaped, and a host with shell_exec disabled must fall through to the pure-PHP writer rather than warn. return preg_match( '/__aiclrc:0\s*$/', $out ) ? 'ok' : 'failed'; } $packet = self::xmp_packet( $uri ); if ( 'jpg' === $ext || 'jpeg' === $ext ) { return self::jpeg_xmp( $path, $packet ) ? 'ok' : 'failed'; } if ( 'png' === $ext ) { return self::png_xmp( $path, $packet ) ? 'ok' : 'failed'; } return 'unsupported'; } /** * Builds the XMP packet carrying the single IPTC Extension property. * * @since 1.0.0 * * @param string $uri The IPTC Digital Source Type URI to embed. * @return string The serialized XMP packet. */ public static function xmp_packet( $uri ) { return '' . '' . '' . '' . '' . ''; } /* ---------------------------------------------------------------------- */ /* exiftool detection */ /* ---------------------------------------------------------------------- */ /** * Detects the exiftool binary path, caching the result for the request. * * @since 1.0.0 * * @return string|false Path to exiftool, or false if unavailable. */ public static function exiftool() { static $cached = null; if ( null !== $cached ) { return $cached; } $cached = false; if ( ! function_exists( 'shell_exec' ) ) { return $cached; } $disabled = explode( ',', (string) ini_get( 'disable_functions' ) ); if ( in_array( 'shell_exec', array_map( 'trim', $disabled ), true ) ) { return $cached; } $which = @shell_exec( 'command -v exiftool 2>/dev/null' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_shell_exec, WordPress.WP.AlternativeFunctions.shell_exec_shell_exec, WordPress.PHP.NoSilencedErrors.Discouraged -- exiftool invocation; all args escapeshellarg-escaped, and a host with shell_exec disabled must fall through to the pure-PHP writer rather than warn. if ( is_string( $which ) && '' !== trim( $which ) ) { $cached = trim( $which ); } return $cached; } /** * Determines whether a path resolves inside the uploads directory. * * @since 1.0.0 * * @param string $path Absolute path to test. * @return bool True only when $path resolves inside the uploads directory. */ protected static function within_uploads( $path ) { $base = wp_get_upload_dir(); if ( empty( $base['basedir'] ) ) { return false; } $real = realpath( $path ); $root = realpath( $base['basedir'] ); return $real && $root && 0 === strpos( $real, $root ); } /* ---------------------------------------------------------------------- */ /* Pure-PHP JPEG (APP1) XMP */ /* ---------------------------------------------------------------------- */ /** * Injects an XMP packet into a JPEG file as an APP1 segment. * * @since 1.0.0 * * @param string $path Absolute path to the JPEG file. * @param string $packet The serialized XMP packet to inject. * @return bool True on success, false on failure or malformed input. */ protected static function jpeg_xmp( $path, $packet ) { $data = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Binary read for in-place XMP injection; WP_Filesystem returns no raw bytes. if ( false === $data || "\xFF\xD8" !== substr( $data, 0, 2 ) ) { return false; } $ns = "http://ns.adobe.com/xap/1.0/\x00"; $body = $ns . $packet; $seglen = strlen( $body ) + 2; if ( $seglen > 0xFFFF ) { return false; } $xmp_seg = "\xFF\xE1" . pack( 'n', $seglen ) . $body; $out = "\xFF\xD8"; $pos = 2; $len = strlen( $data ); $inserted = false; while ( $pos + 1 < $len ) { if ( "\xFF" !== $data[ $pos ] ) { return false; } $marker = ord( $data[ $pos + 1 ] ); if ( 0xFF === $marker ) { ++$pos; continue; } if ( 0xDA === $marker ) { if ( ! $inserted ) { $out .= $xmp_seg; $inserted = true; } $out .= substr( $data, $pos ); return self::put( $path, $out ); } if ( 0x01 === $marker || ( $marker >= 0xD0 && $marker <= 0xD9 ) ) { $out .= substr( $data, $pos, 2 ); $pos += 2; continue; } if ( $pos + 4 > $len ) { return false; } $unpacked = unpack( 'n', substr( $data, $pos + 2, 2 ) ); if ( false === $unpacked ) { return false; } $slen = $unpacked[1]; $segment = substr( $data, $pos, 2 + $slen ); $is_xmp = ( 0xE1 === $marker && 0 === strpos( substr( $segment, 4 ), 'http://ns.adobe.com/xap/1.0/' ) ); $is_appn = ( $marker >= 0xE0 && $marker <= 0xEF ); if ( ! $inserted && ! $is_appn ) { $out .= $xmp_seg; $inserted = true; } if ( ! $is_xmp ) { $out .= $segment; } $pos += 2 + $slen; } return false; } /* ---------------------------------------------------------------------- */ /* Pure-PHP PNG (iTXt) XMP */ /* ---------------------------------------------------------------------- */ /** * Injects an XMP packet into a PNG file as an iTXt chunk. * * @since 1.0.0 * * @param string $path Absolute path to the PNG file. * @param string $packet The serialized XMP packet to inject. * @return bool True on success, false on failure or malformed input. */ protected static function png_xmp( $path, $packet ) { $data = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Binary read for in-place XMP injection; WP_Filesystem returns no raw bytes. $sig = "\x89PNG\r\n\x1a\n"; if ( false === $data || substr( $data, 0, 8 ) !== $sig ) { return false; } $itxt_data = "XML:com.adobe.xmp\x00" // keyword . "\x00" // compression flag (uncompressed) . "\x00" // compression method . "\x00" // language tag (empty) . "\x00" // translated keyword (empty) . $packet; // UTF-8 XMP $chunk = pack( 'N', strlen( $itxt_data ) ) . 'iTXt' . $itxt_data . pack( 'N', crc32( 'iTXt' . $itxt_data ) ); $out = $sig; $pos = 8; $len = strlen( $data ); while ( $pos + 8 <= $len ) { $unpacked = unpack( 'N', substr( $data, $pos, 4 ) ); if ( false === $unpacked ) { return false; } $clen = (int) $unpacked[1]; if ( $pos + 12 + $clen > $len ) { return false; } $type = substr( $data, $pos + 4, 4 ); $full = substr( $data, $pos, 12 + $clen ); if ( 'IEND' === $type ) { $out .= $chunk . $full; $out .= substr( $data, $pos + 12 + $clen ); return self::put( $path, $out ); } $is_xmp = ( 'iTXt' === $type && 0 === strpos( substr( $data, $pos + 8, 18 ), 'XML:com.adobe.xmp' ) ); if ( ! $is_xmp ) { $out .= $full; } $pos += 12 + $clen; } return false; } /** * Atomically writes new file contents via a temp file and rename. * * @since 1.0.0 * * @param string $path Absolute path to the destination file. * @param string $contents The full binary file contents to write. * @return bool True on success, false on failure. */ protected static function put( $path, $contents ) { $tmp = $path . '.aicl-tmp'; if ( false === file_put_contents( $tmp, $contents ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- binary write for in-place XMP injection; WP_Filesystem has no raw-bytes API. return false; } if ( ! @rename( $tmp, $path ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.rename_rename -- Atomic in-place replace of a binary image file within uploads; @ suppresses the failure warning, handled just below. wp_delete_file( $tmp ); return false; } return true; } }