*/
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'];
}
}
}
/**
* Filters whether the untouched original is marked alongside the served copy.
*
* When an upload is larger than the big-image threshold WordPress serves a scaled copy and
* keeps the full-size original beside it. That original is reachable at a predictable URL,
* it is the largest and most re-shareable version of the picture, and until now it was the
* one file that carried no mark. Pro already reads it looking for provenance, so leaving it
* bare had the two halves of the product disagreeing about whether it counted.
*
* Return false to leave it exactly as uploaded.
*
* @since 2.1.5
*
* @param bool $mark Whether to mark the kept original.
* @param int $attachment_id The attachment being embedded.
*/
if ( apply_filters( 'aicl_mark_original_image', true, $attachment_id ) ) {
$original = wp_get_original_image_path( $attachment_id );
if ( $original && $original !== $full ) {
$files[] = $original;
}
}
$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';
}
/*
* Resolve once, then work on the resolved path. within_uploads() already calls realpath(),
* which follows symlinks, so the containment check was validating the target while every
* write landed on the link: rename() replaces a path entry, so a symlinked file was turned
* into a regular file with a new inode and the real image was left unmarked. Containment
* itself was never in question, because a link pointing outside uploads still resolves
* outside and fails the check above.
*/
$real = realpath( $path );
if ( $real ) {
$path = $real;
}
$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 ) {
$res = self::jpeg_xmp( $path, $packet, $uri );
return is_string( $res ) ? $res : ( $res ? 'ok' : 'failed' );
}
if ( 'png' === $ext ) {
return self::png_xmp( $path, $packet, $uri ) ? '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.
*/
/**
* Reports whether a JPEG carries a Multi-Picture Format index.
*
* MPF stores byte offsets to the extra images packed after the main one, and those offsets are
* absolute. Inserting a segment ahead of them shifts every one, so the secondary images stop
* being findable: a two-image file came out of the pure-PHP writer with its second image no
* longer a valid JPEG. That is mainstream phone output, not an edge case, since iPhone gain
* maps, Samsung, Fujifilm and GoPro all use it.
*
* Rewriting the offsets is the correct fix and exiftool already does it, so a host with exiftool
* never reaches this. Without it, declining is the honest answer: the caller records the file as
* unsupported rather than quietly breaking it.
*
* @since 2.1.5
*
* @param string $data The whole JPEG.
* @return bool True when an APP2 MPF index is present.
*/
protected static function jpeg_has_mpf( $data ) {
$pos = 2;
$len = strlen( $data );
while ( $pos + 3 < $len ) {
if ( "\xFF" !== $data[ $pos ] ) {
return false;
}
$marker = ord( $data[ $pos + 1 ] );
if ( 0xFF === $marker ) {
++$pos;
continue;
}
if ( 0xDA === $marker || 0xD9 === $marker ) {
return false;
}
if ( 0x01 === $marker || ( $marker >= 0xD0 && $marker <= 0xD7 ) ) {
$pos += 2;
continue;
}
$unpacked = unpack( 'n', substr( $data, $pos + 2, 2 ) );
if ( false === $unpacked ) {
return false;
}
if ( 0xE2 === $marker && "MPF\x00" === substr( $data, $pos + 4, 4 ) ) {
return true;
}
$pos += 2 + (int) $unpacked[1];
}
return false;
}
/**
* Returns the XMP payload a JPEG already carries, if any.
*
* Every APP1 payload is concatenated rather than only the first, because the writer drops them
* all: a file whose first packet holds only our own property and whose second holds a rights
* block would otherwise be waved through and the second one lost.
*
* @since 2.1.5
*
* @param string $data The whole JPEG.
* @param string $ns The Adobe XMP namespace marker, including its trailing NUL.
* @return string The payload found, or '' when the file carries none.
*/
protected static function jpeg_existing_xmp( $data, $ns ) {
$found = '';
$pos = 2;
$len = strlen( $data );
while ( $pos + 3 < $len ) {
if ( "\xFF" !== $data[ $pos ] ) {
return $found;
}
$marker = ord( $data[ $pos + 1 ] );
if ( 0xFF === $marker ) {
++$pos;
continue;
}
if ( 0xDA === $marker || 0xD9 === $marker ) {
return $found;
}
if ( 0x01 === $marker || ( $marker >= 0xD0 && $marker <= 0xD7 ) ) {
$pos += 2;
continue;
}
$unpacked = unpack( 'n', substr( $data, $pos + 2, 2 ) );
if ( false === $unpacked ) {
return $found;
}
$slen = (int) $unpacked[1];
if ( 0xE1 === $marker && 0 === strpos( substr( $data, $pos + 4, strlen( $ns ) ), $ns ) ) {
$found .= substr( $data, $pos + 4 + strlen( $ns ), $slen - 2 - strlen( $ns ) );
}
$pos += 2 + $slen;
}
return $found;
}
/**
* Returns the XMP payload a PNG already carries, if any.
*
* A compressed iTXt is inflated, because the packet is stored deflated often enough that a
* search for the raw text finds nothing while the writer still drops the chunk. One that cannot
* be inflated returns a marker that will not merge, so the write is declined rather than
* silently replacing something unreadable.
*
* @since 2.1.5
*
* @param string $data The whole PNG.
* @return string The payload found, or '' when the file carries none.
*/
protected static function png_existing_xmp( $data ) {
$pos = 8;
$len = strlen( $data );
while ( $pos + 8 <= $len ) {
$unpacked = unpack( 'N', substr( $data, $pos, 4 ) );
if ( false === $unpacked ) {
return '';
}
$clen = (int) $unpacked[1];
if ( $pos + 12 + $clen > $len ) {
return '';
}
$type = substr( $data, $pos + 4, 4 );
if ( 'IEND' === $type ) {
return '';
}
if ( 'iTXt' === $type && 0 === strpos( substr( $data, $pos + 8, 18 ), 'XML:com.adobe.xmp' ) ) {
$body = substr( $data, $pos + 8, $clen );
$key_end = strpos( $body, "\x00" );
$compress = ord( substr( $body, $key_end + 1, 1 ) );
$rest = substr( $body, $key_end + 3 );
$lang_end = strpos( $rest, "\x00" );
$rest = substr( $rest, $lang_end + 1 );
$tr_end = strpos( $rest, "\x00" );
$text = substr( $rest, $tr_end + 1 );
if ( 1 === $compress ) {
$text = @gzuncompress( $text ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- an undecompressable packet is reported below, not thrown.
if ( false === $text || '' === $text ) {
return 'aicl-unreadable';
}
}
return (string) $text;
}
$pos += 12 + $clen;
}
return '';
}
/**
* Writes our property into an XMP packet the file already carries, keeping everything else.
*
* The writers used to drop an existing packet and put a fresh one in its place, which quietly
* destroyed whatever else it held: dc:creator, dc:rights, photoshop:Credit, xmpRights:UsageTerms,
* and the xmp:CreatorTool and xmpMM identifiers every image editor leaves behind. EXIF and IPTC
* survived because they live in other segments, which is exactly why a spot check missed it.
*
* Both serialisations have to be handled, because both are in the wild: Adobe's compact form
* puts properties as attributes on rdf:Description, exiftool writes them as child elements. Our
* own property is removed in both forms first, so a re-mark replaces the value rather than
* leaving two, and then written back as an attribute alongside its namespace declaration.
*
* A packet this cannot find an rdf:Description in is left alone and reported as unmergeable, so
* the caller can decline rather than fall back to overwriting it.
*
* @since 2.1.5
*
* @param string $packet The existing XMP packet.
* @param string $uri The Digital Source Type URI to write.
* @return string The merged packet, or an empty string when it could not be merged safely.
*/
protected static function merge_packet( $packet, $uri ) {
if ( '' === $packet || false === strpos( $packet, 'rdf:Description' ) ) {
return '';
}
$clean = preg_replace(
'#]*)?>.*?#s',
'',
$packet
);
$clean = preg_replace( '#]*)?/>#', '', (string) $clean );
$clean = preg_replace( '#\sIptc4xmpExt:DigitalSourceType\s*=\s*(["\']).*?\1#s', '', (string) $clean );
if ( null === $clean ) {
return '';
}
$attr = ' Iptc4xmpExt:DigitalSourceType="' . esc_attr( $uri ) . '"';
if ( false === strpos( $clean, 'xmlns:Iptc4xmpExt' ) ) {
$attr = ' xmlns:Iptc4xmpExt="http://iptc.org/std/Iptc4xmpExt/2008-02-29/"' . $attr;
}
$merged = preg_replace( '#('
. ''
. ''
. ''
. ''
. '';
}
/* ---------------------------------------------------------------------- */
/* 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.
* @param string $uri The source-type URI, needed to merge into an existing packet.
* @return bool|string True on success, false on failure, or 'unsupported' when the file's
* structure cannot take a segment safely.
*/
protected static function jpeg_xmp( $path, $packet, $uri = '' ) {
/*
* Only the header is parsed. Every segment is at most 64KB and a file carries a handful, so
* a megabyte covers essentially everything; the loop grows the window if a file proves
* unusual, and gives up once it has the whole file and still no SOS. The image data behind
* the header is never read, it is streamed straight across.
*/
$size = (int) @filesize( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- an unreadable file falls through to the checks below.
$want = 1048576;
$data = '';
while ( true ) {
$data = (string) @file_get_contents( $path, false, null, 0, $want ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- bounded binary read of a local upload.
if ( '' === $data || false !== strpos( $data, "\xFF\xDA" ) || $want >= $size ) {
break;
}
$want *= 4;
}
if ( '' === $data || "\xFF\xD8" !== substr( $data, 0, 2 ) ) {
return false;
}
if ( self::jpeg_has_mpf( $data ) ) {
return 'unsupported';
}
$ns = "http://ns.adobe.com/xap/1.0/\x00";
$existing = self::jpeg_existing_xmp( $data, $ns );
if ( '' !== $existing ) {
$merged = self::merge_packet( $existing, $uri );
if ( '' === $merged ) {
return false;
}
$packet = $merged;
}
$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;
}
return self::put_streamed(
$path,
array(
array( 'lit', $out ),
array( 'copy', $pos, null ),
)
);
}
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 */
/* ---------------------------------------------------------------------- */
// phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.WP.AlternativeFunctions.file_system_operations_fread, WordPress.WP.AlternativeFunctions.file_system_operations_fwrite, WordPress.WP.AlternativeFunctions.file_system_operations_fclose, WordPress.PHP.NoSilencedErrors.Discouraged
// Chunk headers are read one at a time and the data is seeked past, so IDAT never enters memory.
// WP_Filesystem cannot express that: it returns whole files.
/**
* 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.
* @param string $uri The source-type URI, needed to merge into an existing packet.
* @return bool True on success, false on failure or malformed input.
*/
protected static function png_xmp( $path, $packet, $uri = '' ) {
$size = (int) @filesize( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- an unreadable file fails the signature check below.
$fh = @fopen( $path, 'rb' );
if ( ! $fh ) {
return false;
}
$sig = "\x89PNG\r\n\x1a\n";
$read = fread( $fh, 8 );
if ( $sig !== $read ) {
fclose( $fh );
return false;
}
/*
* Only chunk headers are read; the data is seeked past and later streamed. IDAT is the one
* chunk that can be large, and holding it in memory purely to copy it back unchanged is
* what made the writer's peak scale with the file.
*/
$keep = array();
$existing = '';
$iend = -1;
$pos = 8;
while ( $pos + 8 <= $size ) {
fseek( $fh, $pos );
$header = fread( $fh, 8 );
if ( 8 !== strlen( $header ) ) {
break;
}
$unpacked = unpack( 'N', substr( $header, 0, 4 ) );
if ( false === $unpacked ) {
fclose( $fh );
return false;
}
$clen = (int) $unpacked[1];
$type = substr( $header, 4, 4 );
if ( $pos + 12 + $clen > $size ) {
fclose( $fh );
return false;
}
if ( 'IEND' === $type ) {
$iend = $pos;
break;
}
$is_xmp = false;
if ( 'iTXt' === $type ) {
fseek( $fh, $pos + 8 );
$body = (string) fread( $fh, min( $clen, 2097152 ) );
$is_xmp = ( 0 === strpos( $body, 'XML:com.adobe.xmp' ) );
if ( $is_xmp ) {
$existing = self::itxt_text( $body );
}
}
if ( ! $is_xmp ) {
$keep[] = array( 'copy', $pos, 12 + $clen );
}
$pos += 12 + $clen;
}
fclose( $fh );
if ( $iend < 0 ) {
return false;
}
if ( '' !== $existing ) {
$merged = self::merge_packet( $existing, $uri );
if ( '' === $merged ) {
return false;
}
$packet = $merged;
}
$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 ) );
$ops = array( array( 'lit', $sig ) );
foreach ( $keep as $range ) {
$ops[] = $range;
}
$ops[] = array( 'lit', $chunk );
$ops[] = array( 'copy', $iend, null );
return self::put_streamed( $path, $ops );
}
// phpcs:enable WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.WP.AlternativeFunctions.file_system_operations_fread, WordPress.WP.AlternativeFunctions.file_system_operations_fwrite, WordPress.WP.AlternativeFunctions.file_system_operations_fclose, WordPress.PHP.NoSilencedErrors.Discouraged
/**
* Pulls the text out of an iTXt chunk body, inflating it when it is stored compressed.
*
* A deflated packet contains no readable XML at all, so a search of the raw bytes finds nothing
* while the writer still replaces the chunk. One that will not inflate returns a marker that
* cannot merge, so the write is declined rather than silently dropping something unreadable.
*
* @since 2.1.5
*
* @param string $body The iTXt chunk data, starting at the keyword.
* @return string The XMP text, or a non-mergeable marker.
*/
protected static function itxt_text( $body ) {
$key_end = strpos( $body, "\x00" );
if ( false === $key_end ) {
return '';
}
$compress = ord( substr( $body, $key_end + 1, 1 ) );
$rest = substr( $body, $key_end + 3 );
$lang_end = strpos( $rest, "\x00" );
$rest = false === $lang_end ? $rest : substr( $rest, $lang_end + 1 );
$tr_end = strpos( $rest, "\x00" );
$text = false === $tr_end ? $rest : substr( $rest, $tr_end + 1 );
if ( 1 === $compress ) {
$text = @gzuncompress( $text ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- an undecompressable packet is reported, not thrown.
if ( false === $text || '' === $text ) {
return 'aicl-unreadable';
}
}
return (string) $text;
}
// phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.WP.AlternativeFunctions.file_system_operations_fread, WordPress.WP.AlternativeFunctions.file_system_operations_fwrite, WordPress.WP.AlternativeFunctions.file_system_operations_fclose, WordPress.PHP.NoSilencedErrors.Discouraged
// WP_Filesystem has no streaming API: it reads and returns whole files, which is the very thing
// this code exists to stop doing. Raw handles are the only way to copy a tail without holding it.
/**
* Writes a new version of a file as a sequence of literals and ranges copied from the original.
*
* The writers only ever change a file's header: a segment is inserted near the front and the
* image data behind it is reproduced byte for byte. Building that in memory meant holding the
* whole file two or three times over to copy a tail nothing had touched, which is survivable on
* a served copy capped at 2560px and is not survivable on an untouched original, whose size has
* no ceiling. Hosts that pin memory_limit get no reprieve from wp_raise_memory_limit() either.
*
* So the header is assembled in memory, where it is small, and every range is streamed. Peak
* memory becomes the header plus one copy buffer, whatever the file weighs.
*
* Each op is array( 'lit', $string ) or array( 'copy', $offset, $length ), where a null length
* means "to the end". Every write is length-checked, because a short write here would leave a
* truncated temp with no sign anything went wrong, and the temp is removed on any failure.
*
* @since 2.1.5
*
* @param string $path Absolute path to the file being rewritten.
* @param array $ops Ordered list of literal and copy operations.
* @return bool True when the replacement is complete and in place.
*/
protected static function put_streamed( $path, $ops ) {
$src = @fopen( $path, 'rb' );
if ( ! $src ) {
return false;
}
$tmp = self::temp_name( $path );
$dst = @fopen( $tmp, 'wb' );
if ( ! $dst ) {
fclose( $src );
return false;
}
$ok = true;
foreach ( $ops as $op ) {
if ( 'lit' === $op[0] ) {
$want = strlen( $op[1] );
$wrote = fwrite( $dst, $op[1] );
$ok = ( $want === $wrote );
} else {
$want = $op[2];
if ( 0 === fseek( $src, $op[1] ) ) {
$copied = stream_copy_to_stream( $src, $dst, null === $want ? -1 : $want );
$ok = ( false !== $copied ) && ( null === $want || $copied === $want );
} else {
$ok = false;
}
}
if ( ! $ok ) {
break;
}
}
fclose( $src );
if ( ! fflush( $dst ) ) {
$ok = false;
}
fclose( $dst );
if ( ! $ok ) {
wp_delete_file( $tmp );
return false;
}
self::match_mode( $path, $tmp );
if ( ! @rename( $tmp, $path ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.rename_rename -- atomic in-place replace within uploads; failure handled here.
wp_delete_file( $tmp );
return false;
}
return true;
}
// phpcs:enable WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.WP.AlternativeFunctions.file_system_operations_fread, WordPress.WP.AlternativeFunctions.file_system_operations_fwrite, WordPress.WP.AlternativeFunctions.file_system_operations_fclose, WordPress.PHP.NoSilencedErrors.Discouraged
/**
* Atomically writes new file contents via a temp file and rename.
*
* Both failure paths remove the temp. A failed write is nearly always a full disk, and the
* partial file left behind sat beside the user's image holding that space hostage, so the one
* moment free space mattered most was the one moment none was returned.
*
* @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 = self::temp_name( $path );
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.
wp_delete_file( $tmp );
return false;
}
self::match_mode( $path, $tmp );
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;
}
/**
* Builds a temp filename that two requests cannot collide on.
*
* A fixed '.aicl-tmp' suffix meant two requests marking the same attachment wrote the same file.
* The rename is atomic so the image always survived, but one write was silently lost, and the
* loser's cleanup could delete the winner's temp out from under it between write and rename.
*
* @since 2.1.5
*
* @param string $path Absolute path to the destination file.
* @return string Absolute path to a temp file beside it.
*/
protected static function temp_name( $path ) {
return $path . '.' . getmypid() . '.' . wp_generate_password( 6, false, false ) . '.aicl-tmp';
}
/**
* Copies the destination's permissions onto the temp file before the rename.
*
* A rename carries the temp's own mode across, and a freshly created temp takes whatever the
* process umask gives it, so marking an image silently reset its permissions to 0666 & ~umask.
* On the common 0644 upload that is invisible; on a group-writable uploads directory it dropped
* the group's write bit, and under a permissive umask it widened files the owner had restricted.
*
* Ownership cannot be repaired the same way. rename() also hands the file to the PHP user, and
* changing that back needs root, which is not available here.
*
* @since 2.1.5
*
* @param string $path Absolute path to the destination file.
* @param string $tmp Absolute path to the temp file about to replace it.
* @return void
*/
protected static function match_mode( $path, $tmp ) {
$mode = @fileperms( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a missing destination simply leaves the temp at its default mode.
if ( false !== $mode ) {
@chmod( $tmp, $mode & 0777 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- restoring the destination's own mode; failure leaves the umask default, which is the pre-existing behaviour.
}
}
}