$path, 'hash' => hash_file( 'sha256', $path ), 'width' => (int) $info[0], 'height' => (int) $info[1], ); } catch ( \Throwable $e ) { $seen[ $stamp ] = null; } } } return $seen[ $stamp ]; } /** Whether pages offer watermarked copies now. */ public static function active(): bool { return '' !== self::key(); } /** * A picture gets watermarked copies when its largest file is at least the chosen width. * * @param array $files The picture's files. * @param array $settings The settings. * @return bool */ public static function wanted( array $files, array $settings ): bool { $widest = 0; foreach ( $files as $f ) { $widest = max( $widest, (int) $f['width'] ); } return $widest >= (int) $settings['watermark_min']; } /** * The watermarked copy set of one file, delivered only while it matches the current watermark. * * @param array $row The picture's index row. * @param array $file The file. * @return array|null */ public static function copy( array $row, array $file ): ?array { $m = $row['manifest'] ?? array(); $v = $m['watermarked'][ $file['relative'] ] ?? null; if ( ! $v || ( $m['watermark_key'] ?? '' ) !== self::key() || ( $v['watermark_key'] ?? '' ) !== $m['watermark_key'] ) { return null; } return $v; } /** * One step of the watermark pass (one file), after the picture's ordinary copies and display sizes. * * @param array $row The source row. * @param array $m Its manifest (or draft), changed in place. * @param array $files The picture's files. * @param array $recipe The compression recipe. * @param string $draft_id The draft being prepared, or an empty string. * @param string $token The worker's lease. * @param array $settings The settings of this run. * @return bool True when a step was taken (the worker comes back for the next). * @throws \RuntimeException Only inside this function, where it is caught; callers get no exception. */ public static function process( array $row, array &$m, array $files, array $recipe, string $draft_id, string $token, array $settings ): bool { $key = self::key( $settings ); if ( '' === $key || empty( $row['attachment_id'] ) || ! self::wanted( $files, $settings ) ) { return false; } if ( ( $m['watermark_key'] ?? '' ) !== $key ) { // Copies made for an earlier watermark are retired, as replaced copies are: pages still cached may name them. foreach ( $m['watermarked'] ?? array() as $old ) { foreach ( Variants::choices( $old ) as $c ) { $m['retired'][] = $c; } } $m['watermarked'] = array(); $m['watermark_results'] = array(); $m['watermark_key'] = $key; } $next = null; foreach ( $files as $rel => $f ) { if ( ! isset( $m['watermark_results'][ $rel ] ) ) { $next = $f; break; } } if ( ! $next ) { return false; } $rel = $next['relative']; $tmp = Settings::root( true ) . '/tmp-' . bin2hex( random_bytes( 16 ) ) . '.webp'; $ref = $tmp . '.resize.png'; $created = array(); $guard = null; $committed = false; try { $receipt = (array) get_option( 'afsd_im_inflight', array() ); $receipt['temp'] = basename( $tmp ); Reviewed_Store::update_option( 'afsd_im_inflight', $receipt, false ); $input = ImageInput::read( $next['path'] ); if ( $input['width'] !== $next['width'] || $input['height'] !== $next['height'] ) { throw new \RuntimeException( 'source_metadata_dimensions_mismatch' ); } // What GD cannot draw as browsers show it is left without a watermark: a turned photo or a colour profile. self::drawable( $input ); if ( $input['width'] * $input['height'] * 16 + $input['bytes'] * 6 > Settings::memory_budget() ) { throw new \RuntimeException( 'memory_limit' ); } $picture = self::picture( (int) $settings['watermark_image'] ); if ( ! $picture ) { throw new \RuntimeException( 'watermark_picture_unavailable' ); } $hash = hash( 'sha256', $input['data'] ); $content = Policy::content( $input, $recipe, $next['path'] ); self::reference( $input, $picture, $settings, $ref ); // The copy is meant to differ from the original, so it is compared with the watermarked reference; a photo is // never stored as a lossless copy of it (that would be far larger than the photo). $work = $recipe; $work['content'] = $content; if ( 'lossless' === $work['mode'] && 'graphics' !== $content && ! $input['alpha'] ) { $work['mode'] = 'high'; } $r = Compression::encode( $ref, $tmp, $work, $input['mime'], true ); $choices = array_merge( array( $r ), $r['alternatives'] ); $native = null; $modern = null; foreach ( $choices as $c ) { if ( $c['output_mime'] === $input['mime'] && ( ! $native || $c['output_bytes'] < $native['output_bytes'] ) ) { $native = $c; } elseif ( in_array( $c['output_mime'], array( 'image/webp', 'image/avif' ), true ) && $c['output_mime'] !== $input['mime'] && ( ! $modern || $c['output_bytes'] < $modern['output_bytes'] ) ) { $modern = $c; } } // The page's own img gets the watermark too, so a copy in the picture's own format is required. if ( ! $native ) { throw new \RuntimeException( 'watermark_native_unavailable' ); } $journal = array(); $temporary = array(); foreach ( array_filter( array( $native, $modern ) ) as $c ) { $c['watermarked'] = true; $c['source_mime'] = $input['mime']; $c['watermark_key'] = $key; $c['parent_relative'] = $rel; $c['reference_bytes'] = $c['bytes']; $c['reference_hash'] = $c['source_hash']; $c['bytes'] = $input['bytes']; $c['source_hash'] = $hash; $c['source_mtime'] = (int) filemtime( $next['path'] ); $c['saving_bytes'] = $input['bytes'] - $c['output_bytes']; $c['verification'] = 'Watermark drawn on a copy of the original; compression validated against that picture.'; $c['name'] = 'Watermarked'; $c['created'] = time(); $path = $c['_temporary']; unset( $c['_temporary'], $c['alternatives'] ); $identity = hash( 'sha256', self::VERSION . '|' . $key . '|' . $hash . '|' . $c['output_hash'] ); $c['file'] = 'w' . Sources::prefix( $row ) . '-' . $identity . '.' . Variants::extension( $c['output_mime'] ); $journal[] = $c; $temporary[ $c['file'] ] = $path; } $guard = Engine::publication_lock(); $fresh = Repository::row( (int) $row['id'] ); clearstatcache( true, $next['path'] ); if ( ! $fresh || $fresh['excluded'] || ! Repository::owns( $token ) || (int) $fresh['revision'] !== (int) $row['revision'] || Settings::fresh()['generation'] !== $settings['generation'] || ! Sources::eligible( $fresh ) || ! is_file( $next['path'] ) || ! hash_equals( $hash, hash_file( 'sha256', $next['path'] ) ) ) { throw new \RuntimeException( 'stale_job' ); } $m['pending'] = $journal; if ( ! Engine::work_update( $row, array( 'manifest' => $m ), $draft_id ) ) { throw new \RuntimeException( 'stale_job' ); } foreach ( $journal as $c ) { $dest = Settings::root() . '/' . $c['file']; if ( is_link( $dest ) ) { throw new \RuntimeException( 'unsafe_storage' ); } if ( is_file( $dest ) ) { if ( ! hash_equals( $c['output_hash'], hash_file( 'sha256', $dest ) ) ) { throw new \RuntimeException( 'output_name_collision' ); } } else { // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- Validated local image bytes are processed by the host codec and atomically published only after verification. if ( ! Reviewed_Store::rename( $temporary[ $c['file'] ], $dest ) ) { throw new \RuntimeException( 'atomic_publish_failed' ); } $created[] = $dest; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Validated local image bytes are processed by the host codec and atomically published only after verification. chmod( $dest, 0644 ); } $c = array_shift( $journal ); $c['alternatives'] = $journal; $m['pending'] = array(); $m['watermarked'][ $rel ] = $c; $m['watermark_results'][ $rel ] = array( 'state' => 'ready', 'reason' => 'Watermarked copy ready. Originals unchanged.', ); $committed = Engine::work_update( $row, array( 'manifest' => $m, 'available_at' => time(), 'attempts' => 0, ), $draft_id ); } catch ( \Throwable $e ) { if ( 'stale_job' !== $e->getMessage() ) { $m['watermark_results'][ $rel ] = array( 'state' => 'unsupported', 'reason' => sanitize_key( $e->getMessage() ), ); Engine::work_update( $row, array( 'manifest' => $m, 'available_at' => time(), ), $draft_id ); } } finally { Compression::cleanup_temporary( $tmp ); if ( ! $committed ) { foreach ( $created as $path ) { if ( is_file( $path ) && ! is_link( $path ) ) { Reviewed_Store::wp_delete_file( $path ); } } } Engine::publication_unlock( $guard ); } return true; } /** * GD draws the reference: a photo whose orientation or colour profile it would lose is left as it is. * * @param array $input The read original. * @throws \RuntimeException When the original cannot be drawn as browsers show it. */ private static function drawable( array $input ): void { if ( ! function_exists( 'imagecreatefromstring' ) ) { throw new \RuntimeException( 'resize_backend_unavailable' ); } if ( 'image/jpeg' === $input['mime'] ) { foreach ( ImageInput::jpeg_segments( $input['data'] ) as $s ) { if ( 0xe2 === $s['marker'] && str_starts_with( $s['data'], "ICC_PROFILE\0" ) ) { throw new \RuntimeException( 'profile_or_color_metadata_held' ); } if ( 0xe1 === $s['marker'] && str_starts_with( $s['data'], "Exif\0\0" ) && ! in_array( ImageInput::exif_orientation( substr( $s['data'], 6 ) ), array( 0, 1 ), true ) ) { throw new \RuntimeException( 'orientation_held' ); } } } elseif ( 'image/png' === $input['mime'] ) { foreach ( ImageInput::png_chunks( $input['data'] ) as $c ) { if ( in_array( $c['type'], array( 'iCCP', 'gAMA', 'cHRM', 'cICP' ), true ) ) { throw new \RuntimeException( 'profile_or_color_metadata_held' ); } } } elseif ( 'image/webp' === $input['mime'] ) { foreach ( ImageInput::webp_chunks( $input['data'] ) as $c ) { if ( 'ICCP' === $c['type'] ) { throw new \RuntimeException( 'profile_or_color_metadata_held' ); } } } elseif ( 'image/gif' !== $input['mime'] ) { throw new \RuntimeException( 'unsupported_format' ); } } /** * The reference: the original with the watermark scaled to its share of the width, made as transparent as chosen, * drawn at its place with a margin, saved as lossless PNG without GD's own density chunk. * * @param array $input The read original. * @param array $picture The watermark picture. * @param array $settings The settings. * @param string $ref Where the reference is written. * @throws \RuntimeException When it cannot be made. */ private static function reference( array $input, array $picture, array $settings, string $ref ): void { $a = imagecreatefromstring( $input['data'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Validated local image bytes are processed by the host codec and atomically published only after verification. $w = imagecreatefromstring( (string) file_get_contents( $picture['path'] ) ); $s = null; try { if ( ! $a || ! $w ) { throw new \RuntimeException( 'resize_failed' ); } if ( ! imageistruecolor( $a ) ) { imagepalettetotruecolor( $a ); } if ( ! imageistruecolor( $w ) ) { imagepalettetotruecolor( $w ); } $pw = $input['width']; $ph = $input['height']; $tw = max( 1, (int) round( $pw * (int) $settings['watermark_size'] / 100 ) ); $th = max( 1, (int) round( $picture['height'] * $tw / $picture['width'] ) ); if ( $th > $ph / 2 ) { $th = max( 1, (int) floor( $ph / 2 ) ); $tw = max( 1, (int) round( $picture['width'] * $th / $picture['height'] ) ); } $s = imagecreatetruecolor( $tw, $th ); imagealphablending( $s, false ); imagesavealpha( $s, true ); imagefill( $s, 0, 0, imagecolorallocatealpha( $s, 0, 0, 0, 127 ) ); imagecopyresampled( $s, $w, 0, 0, 0, 0, $tw, $th, $picture['width'], $picture['height'] ); $opacity = max( 10, min( 100, (int) $settings['watermark_opacity'] ) ); for ( $y = 0; $y < $th; ++$y ) { for ( $x = 0; $x < $tw; ++$x ) { $c = imagecolorat( $s, $x, $y ); $alpha = 127 - (int) round( ( 127 - ( ( $c >> 24 ) & 0x7f ) ) * $opacity / 100 ); imagesetpixel( $s, $x, $y, ( $c & 0xffffff ) | ( $alpha << 24 ) ); } } $margin = max( 4, (int) round( min( $pw, $ph ) * 0.02 ) ); $place = in_array( $settings['watermark_place'], self::PLACES, true ) ? $settings['watermark_place'] : 'bottom-right'; $x = str_ends_with( $place, 'left' ) ? $margin : ( str_ends_with( $place, 'right' ) ? $pw - $tw - $margin : (int) floor( ( $pw - $tw ) / 2 ) ); $y = str_starts_with( $place, 'top' ) ? $margin : ( str_starts_with( $place, 'bottom' ) ? $ph - $th - $margin : (int) floor( ( $ph - $th ) / 2 ) ); imagealphablending( $a, true ); // Only a transparent original keeps an alpha channel: an opaque photo's reference stays opaque, so its copies may be lossy. imagesavealpha( $a, (bool) $input['alpha'] ); if ( ! imagecopy( $a, $s, max( 0, $x ), max( 0, $y ), 0, 0, $tw, $th ) || ! imagepng( $a, $ref, 1 ) ) { throw new \RuntimeException( 'resize_failed' ); } } finally { foreach ( array( $a, $w, $s ) as $im ) { if ( $im ) { imagedestroy( $im ); } } } // GD adds its own nominal pixel-density chunk to a newly written PNG: a private intermediate, not source metadata. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Validated local image bytes are processed by the host codec and atomically published only after verification. $data = (string) file_get_contents( $ref ); $clean = substr( $data, 0, 8 ); foreach ( ImageInput::png_chunks( $data ) as $chunk ) { if ( 'pHYs' !== $chunk['type'] ) { $clean .= ImageInput::png_chunk( $chunk['type'], $chunk['data'] ); } } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Validated local image bytes are processed by the host codec and atomically published only after verification. if ( Reviewed_Store::file_put_contents( $ref, $clean, LOCK_EX ) !== strlen( $clean ) ) { throw new \RuntimeException( 'resize_failed' ); } clearstatcache( true, $ref ); } }