*/ 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. } } }