0, 'skipped' => 0, 'files' => 0, 'before' => 0, 'after' => 0, 'log' => array(), 'started' => 0, 'probe_ok' => 0, ) ); } private static function save_stats( $s ) { $s['log'] = array_slice( (array) $s['log'], -self::MAX_LOG ); update_option( self::STATS, $s, false ); } /** JPEG/PNG images still to process (not done, not skipped). */ public static function remaining() { global $wpdb; return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(p.ID) FROM {$wpdb->posts} p LEFT JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = %s WHERE p.post_type = 'attachment' AND p.post_mime_type IN ('image/jpeg','image/png') AND m.meta_id IS NULL", self::SKIP ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- live progress } public static function supported() { return CTPS_Webp::supported() && function_exists( 'imagecreatefromwebp' ); } /* --------------------------------------------------------------------- * Background work (WP-Cron, every minute, until the library is done) * ------------------------------------------------------------------ */ public static function init() { add_filter( 'cron_schedules', array( __CLASS__, 'cron_schedule' ) ); // phpcs:ignore WordPress.WP.CronInterval.CronSchedulesInterval -- one-minute steps, only while the replacement runs add_action( self::HOOK, array( __CLASS__, 'tick' ) ); } public static function cron_schedule( $s ) { $s['ctps_wr_minute'] = array( 'interval' => 60, 'display' => __( 'Every minute (WebP replacement)', 'celero-adaptive-speed' ), ); return $s; } public static function bg_on() { return (bool) get_option( self::BG ); } public static function start_bg() { update_option( self::BG, 1, false ); if ( ! wp_next_scheduled( self::HOOK ) ) { wp_schedule_event( time() + 5, 'ctps_wr_minute', self::HOOK ); } } public static function stop_bg() { delete_option( self::BG ); wp_clear_scheduled_hook( self::HOOK ); } /** One background step; stops by itself when everything is done or on error. */ public static function tick() { if ( ! self::bg_on() || ! self::supported() ) { self::stop_bg(); return; } $r = self::step( 0, self::budget() ); if ( is_wp_error( $r ) ) { $s = self::stats(); $s['error'] = $r->get_error_message(); self::save_stats( $s ); self::stop_bg(); return; } if ( empty( $r['busy'] ) && empty( $r['locked'] ) && 0 === (int) $r['remaining'] ) { self::stop_bg(); } } /** Seconds of work per step, kept well inside the PHP time limit. */ public static function budget( $cap = 40 ) { $max = (int) ini_get( 'max_execution_time' ); if ( $max > 0 ) { $cap = min( $cap, max( 8, $max - 15 ) ); } return (int) apply_filters( 'ctps_wr_budget', $cap ); } /** Only one step at a time (browser and cron may both ask). Atomic, no object cache. */ private static function lock() { global $wpdb; $now = time(); $got = $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", self::LOCK, (string) $now ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- atomic lock if ( $got ) { return true; } // A step that died (time limit) leaves its lock: taken over after 5 minutes. return (bool) $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND CAST(option_value AS UNSIGNED) < %d", (string) $now, self::LOCK, $now - 300 ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- atomic lock } private static function unlock() { global $wpdb; $wpdb->delete( $wpdb->options, array( 'option_name' => self::LOCK ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- atomic lock } /** True when a proxy (e.g. Cloudflare bot protection) answered instead of the server. */ private static function challenged( $res ) { if ( is_wp_error( $res ) ) { return false; } if ( '' !== (string) wp_remote_retrieve_header( $res, 'cf-mitigated' ) ) { return true; } $code = (int) wp_remote_retrieve_response_code( $res ); return in_array( $code, array( 403, 503 ), true ) && false !== stripos( (string) wp_remote_retrieve_header( $res, 'server' ), 'cloudflare' ); } /* --------------------------------------------------------------------- * Redirect of the old addresses (.jpg/.png -> .webp) * ------------------------------------------------------------------ */ public static function rules() { $base = trailingslashit( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ) ); return array( '# BEGIN ' . self::MARKER, '# Images replaced by their WebP: old .jpg/.png addresses lead to the WebP.', '', 'RewriteEngine On', 'RewriteCond %{REQUEST_FILENAME} !-f', 'RewriteCond %{REQUEST_FILENAME} ^(.+)\.(?:jpe?g|png)$ [NC]', 'RewriteCond %1.webp -f', 'RewriteRule ^(.+)\.(?:jpe?g|png)$ ' . $base . '$1.webp [NC,R=301,L]', '', '# END ' . self::MARKER, ); } /** Writes the block at the top of .htaccess (before the WordPress rules). */ public static function write_rules() { if ( is_multisite() || ! CTPS_Htaccess::is_apache() ) { return false; } $file = CTPS_Htaccess::file(); $current = file_exists( $file ) ? (string) file_get_contents( $file ) : ''; if ( ( file_exists( $file ) && ! wp_is_writable( $file ) ) || ( ! file_exists( $file ) && ! wp_is_writable( dirname( $file ) ) ) ) { return false; } $clean = self::strip_rules( $current ); $block = implode( "\n", self::rules() ) . "\n\n"; return false !== file_put_contents( $file, $block . ltrim( $clean ), LOCK_EX ); } /** Removes the block (only when asked: replaced images need it). */ public static function remove_rules() { $file = CTPS_Htaccess::file(); if ( ! file_exists( $file ) || ! wp_is_writable( $file ) ) { return false; } $current = (string) file_get_contents( $file ); $clean = self::strip_rules( $current ); return $clean === $current || false !== file_put_contents( $file, ltrim( $clean ), LOCK_EX ); } public static function has_rules() { $file = CTPS_Htaccess::file(); return file_exists( $file ) && false !== strpos( (string) file_get_contents( $file ), '# BEGIN ' . self::MARKER ); } private static function strip_rules( $content ) { return (string) preg_replace( '/# BEGIN ' . preg_quote( self::MARKER, '/' ) . '.*?# END ' . preg_quote( self::MARKER, '/' ) . '\s*/s', '', $content ); } /** * Proves that the server sends an old address to its WebP: a small WebP is * written and its ".jpg" address is requested without following redirects. * * @return true|string True, or the reason it does not work. */ public static function probe() { if ( apply_filters( 'ctps_wr_skip_probe', false ) ) { return true; } $up = wp_get_upload_dir(); if ( ! empty( $up['error'] ) ) { return __( 'The uploads folder is not available.', 'celero-adaptive-speed' ); } $name = 'ctps-wr-probe-' . wp_generate_password( 8, false, false ); $file = trailingslashit( $up['basedir'] ) . $name . '.webp'; $im = imagecreatetruecolor( 4, 4 ); $ok = $im && imagewebp( $im, $file, 80 ); if ( $im ) { imagedestroy( $im ); } if ( ! $ok || ! file_exists( $file ) ) { return __( 'A test file could not be written in the uploads folder.', 'celero-adaptive-speed' ); } $res = self::self_head( trailingslashit( $up['baseurl'] ) . $name . '.jpg', array( 'redirection' => 0, 'timeout' => 15, 'sslverify' => false, ) ); wp_delete_file( $file ); if ( is_wp_error( $res ) ) { return __( 'The site cannot call itself to check the redirect of the old addresses (loopback blocked).', 'celero-adaptive-speed' ); } $code = (int) wp_remote_retrieve_response_code( $res ); $loc = (string) wp_remote_retrieve_header( $res, 'location' ); if ( in_array( $code, array( 301, 302, 307, 308 ), true ) && false !== strpos( $loc, $name . '.webp' ) ) { return true; } // nginx "try_files" variant: the WebP is served directly at the old address. if ( 200 === $code && false !== stripos( (string) wp_remote_retrieve_header( $res, 'content-type' ), 'image/webp' ) ) { return true; } // A proxy (Cloudflare bot protection) stopped the site's own request before // it reached the server: the rules are in place and Apache/LiteSpeed reads them. if ( self::challenged( $res ) && self::has_rules() && CTPS_Htaccess::is_apache() ) { return true; } $server = (string) wp_remote_retrieve_header( $res, 'server' ); return __( 'The server does not send the old .jpg addresses to the WebP: with nginx, add the rule shown below to the server configuration.', 'celero-adaptive-speed' ) . ' ' . sprintf( /* translators: 1: HTTP status code, 2: name of the web server or proxy that answered, 3: yes/no */ __( 'Answer received: HTTP %1$d from "%2$s"; redirect rules in .htaccess: %3$s.', 'celero-adaptive-speed' ), $code, '' !== $server ? $server : '?', self::has_rules() ? __( 'yes', 'celero-adaptive-speed' ) : __( 'no', 'celero-adaptive-speed' ) ); } /** * HEAD request from the site to itself, sent straight to this server * (the address of the site is pinned to the server's own IP), so that a * proxy in front of the site (Cloudflare bot protection) cannot stop it. * Falls back to the normal request when that is not possible. */ private static function self_head( $url, $args ) { $ip = isset( $_SERVER['SERVER_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_ADDR'] ) ) : ''; $host = (string) wp_parse_url( $url, PHP_URL_HOST ); $scheme = (string) wp_parse_url( $url, PHP_URL_SCHEME ); $port = (int) wp_parse_url( $url, PHP_URL_PORT ); $port = $port ? $port : ( 'https' === $scheme ? 443 : 80 ); if ( filter_var( $ip, FILTER_VALIDATE_IP ) && '' !== $host && ! filter_var( $host, FILTER_VALIDATE_IP ) && defined( 'CURLOPT_RESOLVE' ) ) { $target = false !== strpos( $ip, ':' ) ? '[' . $ip . ']' : $ip; $pin = function ( $handle ) use ( $host, $port, $target ) { curl_setopt( $handle, CURLOPT_RESOLVE, array( $host . ':' . $port . ':' . $target ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt -- pins the request to this server, only for this call }; add_action( 'http_api_curl', $pin ); $res = wp_remote_head( $url, $args ); remove_action( 'http_api_curl', $pin ); if ( ! is_wp_error( $res ) && ! self::challenged( $res ) ) { return $res; } } return wp_remote_head( $url, $args ); } /* --------------------------------------------------------------------- * One step: a few images, within a time budget * ------------------------------------------------------------------ */ public static function step( $limit = 0, $budget = 20 ) { global $wpdb; $t0 = microtime( true ); $s = self::stats(); if ( ! $s['probe_ok'] ) { self::write_rules(); $p = self::probe(); if ( true !== $p ) { return new WP_Error( 'ctps_wr_probe', $p ); } $s['probe_ok'] = 1; $s['started'] = $s['started'] ? $s['started'] : time(); self::save_stats( $s ); } $load = function_exists( 'sys_getloadavg' ) ? sys_getloadavg() : false; $max = (float) apply_filters( 'ctps_wr_max_load', self::cores() * 1.5 ); if ( is_array( $load ) && $load[0] > $max ) { return array( 'processed' => 0, 'busy' => 1, 'load' => round( $load[0], 1 ), 'max_load' => $max, 'remaining' => self::remaining(), 'stats' => self::public_stats() ); } if ( ! self::lock() ) { return array( 'processed' => 0, 'busy' => 0, 'locked' => 1, 'remaining' => self::remaining(), 'stats' => self::public_stats() ); } $limit = $limit > 0 ? (int) $limit : (int) apply_filters( 'ctps_wr_batch', 40 ); $ids = $wpdb->get_col( $wpdb->prepare( "SELECT p.ID FROM {$wpdb->posts} p LEFT JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = %s WHERE p.post_type = 'attachment' AND p.post_mime_type IN ('image/jpeg','image/png') AND m.meta_id IS NULL ORDER BY p.ID DESC LIMIT %d", self::SKIP, max( 1, $limit ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- batch scan // 1. Files: convert and check, image by image. Time is kept for the // database pass, measured on the previous steps (large sites: long scans). $s = self::stats(); // A step that died during the database pass (server time limit) left its mark. if ( ! empty( $s['db_start'] ) && time() - (int) $s['db_start'] > 120 ) { $s['db_died'] = (int) ( $s['db_died'] ?? 0 ) + 1; $s['db_start'] = 0; self::save_stats( $s ); if ( $s['db_died'] >= 2 ) { self::unlock(); self::stop_bg(); return new WP_Error( 'ctps_wr_slow_db', __( 'The database pass takes longer than the server allows for one step: the replacement was stopped, nothing was lost. Images already done stay WebP.', 'celero-adaptive-speed' ) ); } } $db_time = isset( $s['db_time'] ) ? (float) $s['db_time'] : 0.0; $reserve = $db_time > 0 ? $db_time * 1.5 + 2 : $budget * 0.5; $items = array(); $fails = array(); foreach ( $ids as $id ) { $id = (int) $id; $r = self::prepare( $id ); if ( true === $r['ok'] ) { $items[ $id ] = $r; } else { $fails[ $id ] = $r['reason']; } if ( microtime( true ) - $t0 + $reserve > $budget ) { break; } } // 2. Database: one pass over the tables for the whole group. $t1 = microtime( true ); if ( $items ) { $s['db_start'] = time(); self::save_stats( $s ); } $db = $items ? self::replace_batch( $items ) : array(); if ( $items ) { $took = microtime( true ) - $t1; $db_time = $db_time > 0 ? $db_time * 0.7 + $took * 0.3 : $took; } // 3. Each image: record, HTTP check, old files deleted. $done = array(); foreach ( $items as $id => $it ) { if ( true !== $db[ $id ] ) { if ( empty( $db[ $id ]['touched'] ) ) { self::discard( $it ); } $fails[ $id ] = $db[ $id ]['reason']; continue; } $r = self::finalize( $id, $it ); if ( true === $r['ok'] ) { $done[ $id ] = $r; } else { $fails[ $id ] = $r['reason']; } } $s = self::stats(); foreach ( $done as $r ) { $s['done']++; $s['files'] += $r['files']; $s['before'] += $r['before']; $s['after'] += $r['after']; } foreach ( $fails as $id => $reason ) { $s['skipped']++; update_post_meta( $id, self::SKIP, $reason ); $s['log'][] = array( 'id' => $id, 'title' => get_the_title( $id ), 'reason' => $reason ); } $s['db_start'] = 0; $s['db_died'] = $items ? 0 : (int) ( $s['db_died'] ?? 0 ); $s['last'] = time(); $s['db_time'] = round( $db_time, 2 ); $s['step_time'] = round( microtime( true ) - $t0, 1 ); self::save_stats( $s ); self::unlock(); $n = count( $done ) + count( $fails ); $remaining = self::remaining(); if ( $n > 0 ) { // Pages keep the old addresses in their cache until it is renewed: the // redirect covers them meanwhile, the purge at the end refreshes them. if ( 0 === $remaining ) { CTPS_Page_Cache::purge_all(); do_action( 'litespeed_purge_all' ); } } return array( 'processed' => $n, 'busy' => 0, 'remaining' => $remaining, 'stats' => self::public_stats() ); } /** * Number of CPU cores of the server. The load average covers the whole * machine, so a fixed limit would stop forever on a busy shared server. */ public static function cores() { static $n = null; if ( null !== $n ) { return $n; } $n = 0; if ( @is_readable( '/proc/cpuinfo' ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- open_basedir may forbid it $info = (string) @file_get_contents( '/proc/cpuinfo' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local system file $n = (int) preg_match_all( '/^processor\s*:/m', $info ); } if ( $n < 1 ) { $n = 6; } return $n; } public static function public_stats() { $s = self::stats(); unset( $s['log'] ); $s['saved'] = max( 0, $s['before'] - $s['after'] ); return $s; } /** Lets skipped images be tried again (after fixing the cause). */ public static function retry_skipped() { delete_post_meta_by_key( self::SKIP ); $s = self::stats(); $s['skipped'] = 0; $s['log'] = array(); self::save_stats( $s ); } /* --------------------------------------------------------------------- * One image * ------------------------------------------------------------------ */ /** * One image, from start to end (used by tests and single runs). * * @return array ok (true|false), reason, files, before, after. */ public static function process( $id ) { $id = (int) $id; $it = self::prepare( $id ); if ( true !== $it['ok'] ) { return $it; } $db = self::replace_batch( array( $id => $it ) ); self::$touched = is_array( $db[ $id ] ) && ! empty( $db[ $id ]['touched'] ); if ( true !== $db[ $id ] ) { if ( ! self::$touched ) { self::discard( $it ); } return array( 'ok' => false, 'reason' => $db[ $id ]['reason'] ); } return self::finalize( $id, $it ); } /** * Converts and checks every file of an image. Nothing is changed in the * database and nothing is deleted. * * @return array ok, reason | dir, rel_dir, main, meta, map, made, pairs, before, after, resumed. */ public static function prepare( $id ) { $id = (int) $id; $work = get_post_meta( $id, self::WORK, true ); $resumed = is_array( $work ) && $work; $work = $resumed ? $work : array(); $fail = function ( $reason, $cleanup = array() ) use ( $resumed ) { // A resumed image may already be referenced as WebP: never delete then. if ( ! $resumed ) { foreach ( $cleanup as $f ) { wp_delete_file( $f ); } } return array( 'ok' => false, 'reason' => $reason ); }; if ( ! self::supported() ) { return $fail( __( 'The server cannot create or read WebP images.', 'celero-adaptive-speed' ) ); } if ( CTPS_Webp::is_site_icon( $id ) ) { return $fail( __( 'Site icon: kept as it is, Google Search does not accept WebP favicons.', 'celero-adaptive-speed' ) ); } $main = get_attached_file( $id ); $meta = wp_get_attachment_metadata( $id ); if ( ! $main || ! file_exists( $main ) || ! is_array( $meta ) || empty( $meta['file'] ) ) { return $fail( __( 'The image file is missing from the server.', 'celero-adaptive-speed' ) ); } $dir = trailingslashit( dirname( $main ) ); $up = wp_get_upload_dir(); $base = trailingslashit( $up['basedir'] ); // Files to convert: main file, pre-scaling original, every size. $list = array( basename( $main ) => 'main' ); if ( ! empty( $meta['original_image'] ) ) { $list[ $meta['original_image'] ] = 'original'; } foreach ( (array) ( $meta['sizes'] ?? array() ) as $size => $info ) { if ( ! empty( $info['file'] ) ) { $list[ $info['file'] ] = $list[ $info['file'] ] ?? 'size'; } } // Files of this image already in WebP (main file or sizes, e.g. made by // WordPress at upload): a JPEG left with the same name points to them. $own = array(); foreach ( array_keys( $list ) as $name ) { if ( preg_match( '/\.webp$/i', $name ) && file_exists( $dir . $name ) ) { $own[] = $name; } } // Names first: a WebP already there is never overwritten, unless this // image made it in a step that did not finish. $plan = array(); $reuse = array(); foreach ( array_keys( $list ) as $name ) { if ( ! preg_match( '/\.(jpe?g|png)$/i', $name ) ) { continue; // A size already in WebP stays as it is. } if ( ! file_exists( $dir . $name ) ) { if ( 'main' === $list[ $name ] ) { return $fail( __( 'The image file is missing from the server.', 'celero-adaptive-speed' ) ); } continue; // A size missing already: nothing to convert, its entry stays. } $new = preg_replace( '/\.(jpe?g|png)$/i', '.webp', $name ); if ( in_array( $new, $own, true ) && ! in_array( $new, $work, true ) ) { $info = @getimagesize( $dir . $new ); // phpcs:ignore WordPress.PHP.NoSilencedErrors if ( ! $info || 'image/webp' !== ( $info['mime'] ?? '' ) ) { return $fail( /* translators: %s: file name */ sprintf( __( 'A file named %s already exists: not overwritten.', 'celero-adaptive-speed' ), $new ) ); } $reuse[ $name ] = $new; // Already the WebP of this image: nothing to convert. continue; } if ( file_exists( $dir . $new ) && ! in_array( $new, $work, true ) && ! in_array( $new, $plan, true ) ) { return $fail( /* translators: %s: file name */ sprintf( __( 'A file named %s already exists: not overwritten.', 'celero-adaptive-speed' ), $new ) ); } $plan[ $name ] = $new; } if ( ! $plan && ! $reuse && ! preg_match( '/\.webp$/i', $main ) ) { return $fail( __( 'Nothing to convert.', 'celero-adaptive-speed' ) ); } update_post_meta( $id, self::WORK, array_values( array_unique( array_merge( $work, array_values( $plan ) ) ) ) ); $made = array(); $map = array(); // old basename => new basename $before = 0; $after = 0; $quality = (int) apply_filters( 'ctps_webp_quality', 82 ); foreach ( $plan as $name => $new ) { $src = $dir . $name; if ( isset( $map[ $name ] ) ) { continue; } // LiteSpeed keeps the uncompressed original as .bk: better quality source. $bk = preg_replace( '/\.(jpe?g|png)$/i', '.bk.$1', $src ); $from = ( file_exists( $bk ) && self::same_size( $bk, $src ) ) ? $bk : $src; $check = self::convert( $from, $dir . $new, $quality ); if ( true !== $check ) { $r = $fail( /* translators: 1: file name, 2: reason */ sprintf( __( '%1$s: %2$s', 'celero-adaptive-speed' ), $name, $check ), array_merge( $made, array( $dir . $new ) ) ); if ( ! $resumed ) { delete_post_meta( $id, self::WORK ); } return $r; } $made[] = $dir . $new; $map[ $name ] = $new; $before += (int) filesize( $src ) + ( file_exists( $bk ) ? (int) filesize( $bk ) : 0 ) + ( file_exists( $src . '.webp' ) ? (int) filesize( $src . '.webp' ) : 0 ); $after += (int) filesize( $dir . $new ); } foreach ( $reuse as $name => $new ) { $src = $dir . $name; $map[ $name ] = $new; $before += (int) filesize( $src ) + ( file_exists( $src . '.webp' ) ? (int) filesize( $src . '.webp' ) : 0 ); } $rel_dir = ltrim( str_replace( wp_normalize_path( $base ), '', wp_normalize_path( $dir ) ), '/' ); $pairs = array(); foreach ( $map as $old => $new ) { $pairs[ $rel_dir . $old ] = $rel_dir . $new; } return array( 'ok' => true, 'id' => $id, 'dir' => $dir, 'rel_dir' => $rel_dir, 'main' => $main, 'meta' => $meta, 'map' => $map, 'made' => $made, 'pairs' => $pairs, 'before' => $before, 'after' => $after, 'resumed' => $resumed, ); } /** The database refused the image before any change: its new WebP go. */ private static function discard( $it ) { if ( empty( $it['resumed'] ) ) { foreach ( (array) $it['made'] as $f ) { wp_delete_file( $f ); } delete_post_meta( (int) $it['id'], self::WORK ); } } /** * After the database: attachment record, HTTP check, old files deleted. * * @return array ok, reason, files, before, after. */ public static function finalize( $id, $it ) { $fail = function ( $reason ) { return array( 'ok' => false, 'reason' => $reason ); }; $dir = $it['dir']; $main = $it['main']; $meta = $it['meta']; $map = $it['map']; // Attachment record: files, sizes and type now point to the WebP. if ( isset( $map[ basename( $main ) ] ) ) { $meta['file'] = $it['rel_dir'] . $map[ basename( $main ) ]; update_attached_file( $id, $dir . $map[ basename( $main ) ] ); } if ( ! empty( $meta['original_image'] ) && isset( $map[ $meta['original_image'] ] ) ) { $meta['original_image'] = $map[ $meta['original_image'] ]; } foreach ( (array) ( $meta['sizes'] ?? array() ) as $size => $info ) { if ( ! empty( $info['file'] ) && isset( $map[ $info['file'] ] ) ) { $meta['sizes'][ $size ]['file'] = $map[ $info['file'] ]; $meta['sizes'][ $size ]['mime-type'] = 'image/webp'; $meta['sizes'][ $size ]['filesize'] = (int) filesize( $dir . $map[ $info['file'] ] ); } } if ( isset( $meta['filesize'] ) && isset( $map[ basename( $main ) ] ) ) { $meta['filesize'] = (int) filesize( $dir . $map[ basename( $main ) ] ); } wp_update_attachment_metadata( $id, $meta ); wp_update_post( array( 'ID' => $id, 'post_mime_type' => 'image/webp' ) ); clean_post_cache( $id ); // Last check: the attachment really points to existing WebP files. $check = wp_get_attachment_metadata( $id ); if ( ! file_exists( (string) get_attached_file( $id ) ) || 'image/webp' !== get_post_mime_type( $id ) || empty( $check['file'] ) || ! preg_match( '/\.webp$/i', $check['file'] ) ) { return $fail( __( 'The image record could not be updated.', 'celero-adaptive-speed' ) ); } // Over HTTP, when the site can call itself: the new file must answer. // A proxy challenge (Cloudflare bot protection) proves nothing either way. $url = wp_get_attachment_url( $id ); $res = self::self_head( $url, array( 'timeout' => 10, 'sslverify' => false ) ); if ( ! is_wp_error( $res ) && ! self::challenged( $res ) && 200 !== (int) wp_remote_retrieve_response_code( $res ) ) { /* translators: %d: HTTP status code */ return $fail( sprintf( __( 'The new WebP does not answer (HTTP %d): old files kept.', 'celero-adaptive-speed' ), (int) wp_remote_retrieve_response_code( $res ) ) ); } // Everything checked: the old files go, for good. foreach ( array_keys( $map ) as $old ) { $src = $dir . $old; foreach ( array( $src, preg_replace( '/\.(jpe?g|png)$/i', '.bk.$1', $src ), $src . '.webp', $src . '.webp.skip', $src . '.avif', $src . '.avif.skip' ) as $f ) { if ( file_exists( $f ) ) { wp_delete_file( $f ); } } } foreach ( array( '_ctps_webp', CTPS_Webp::MADE, 'litespeed-optimize-size', 'litespeed-optimize-set', self::WORK ) as $k ) { delete_post_meta( $id, $k ); } return array( 'ok' => true, 'reason' => '', 'files' => count( $map ), 'before' => $it['before'], 'after' => $it['after'] ); } private static function same_size( $a, $b ) { $x = @getimagesize( $a ); // phpcs:ignore WordPress.PHP.NoSilencedErrors $y = @getimagesize( $b ); // phpcs:ignore WordPress.PHP.NoSilencedErrors return $x && $y && $x[0] === $y[0] && $x[1] === $y[1]; } /** * Converts and checks: opens, same size, same look. * * @return true|string True or the reason of the failure. */ public static function convert( $src, $dest, $quality = 82 ) { $editor = wp_get_image_editor( $src ); if ( is_wp_error( $editor ) ) { return __( 'cannot be opened', 'celero-adaptive-speed' ); } $editor->set_quality( $quality ); $saved = $editor->save( $dest, 'image/webp' ); if ( is_wp_error( $saved ) || ! file_exists( $dest ) || filesize( $dest ) <= 0 ) { return __( 'WebP not created', 'celero-adaptive-speed' ); } $a = @getimagesize( $src ); // phpcs:ignore WordPress.PHP.NoSilencedErrors $b = @getimagesize( $dest ); // phpcs:ignore WordPress.PHP.NoSilencedErrors if ( ! $a || ! $b || 'image/webp' !== ( $b['mime'] ?? '' ) || $a[0] !== $b[0] || $a[1] !== $b[1] ) { return __( 'the WebP has different dimensions', 'celero-adaptive-speed' ); } $diff = self::difference( $src, $dest ); if ( null === $diff ) { return __( 'the WebP could not be compared with the original', 'celero-adaptive-speed' ); } if ( $diff > (float) apply_filters( 'ctps_wr_max_difference', 12 ) ) { /* translators: %s: average difference per pixel (0-255) */ return sprintf( __( 'the WebP looks different from the original (difference %s)', 'celero-adaptive-speed' ), number_format_i18n( $diff, 1 ) ); } return true; } /** * Average difference per channel (0-255) between two images, compared on * 48x48 copies: catches empty, broken, shifted or recolored results. */ public static function difference( $a, $b ) { $ia = self::small( $a ); $ib = self::small( $b ); if ( ! $ia || ! $ib ) { return null; } $sum = 0; $n = 0; for ( $x = 0; $x < 48; $x++ ) { for ( $y = 0; $y < 48; $y++ ) { $ca = imagecolorsforindex( $ia, imagecolorat( $ia, $x, $y ) ); $cb = imagecolorsforindex( $ib, imagecolorat( $ib, $x, $y ) ); // Transparent areas: compare against white, like the page shows them. foreach ( array( 'red', 'green', 'blue' ) as $ch ) { $va = $ca[ $ch ] + ( 255 - $ca[ $ch ] ) * $ca['alpha'] / 127; $vb = $cb[ $ch ] + ( 255 - $cb[ $ch ] ) * $cb['alpha'] / 127; $sum += abs( $va - $vb ); $n++; } } } imagedestroy( $ia ); imagedestroy( $ib ); return $n ? $sum / $n : null; } private static function small( $file ) { $data = file_get_contents( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local file $im = $data ? @imagecreatefromstring( $data ) : false; // phpcs:ignore WordPress.PHP.NoSilencedErrors if ( ! $im ) { return false; } $out = imagecreatetruecolor( 48, 48 ); imagealphablending( $out, false ); imagesavealpha( $out, true ); imagecopyresampled( $out, $im, 0, 0, 0, 0, 48, 48, imagesx( $im ), imagesy( $im ) ); imagedestroy( $im ); return $out; } /* --------------------------------------------------------------------- * Database * ------------------------------------------------------------------ */ /** * Replaces "2026/06/photo-300x200.jpg" with "2026/06/photo-300x200.webp" * (also in the "2026\/06\/..." form of JSON data) in posts, post meta, * term meta, user meta, comments and options, for a whole group of images * with a single pass over each table. Serialized values are rewritten * safely; a value holding objects is never rewritten: the images it names * are then skipped. Afterwards every updated row is read again and none of * the old names may remain. * * @param array $items Image ID => array with 'pairs' (old relative path => new relative path). * @return array Image ID => true, or array( 'reason' => string, 'touched' => bool ). */ public static function replace_batch( $items ) { global $wpdb; $full_by = array(); $likes = array(); foreach ( $items as $id => $it ) { $f = array(); foreach ( $it['pairs'] as $old => $new ) { $f[ $old ] = $new; $f[ str_replace( '/', '\\/', $old ) ] = str_replace( '/', '\\/', $new ); } $full_by[ $id ] = $f; if ( ! $f ) { continue; } // One term per upload folder ("2026/10/"), not per image: the scan of // a large table costs the same for one term, and the rows found are // matched image by image below. Without folders: the image's own prefix. $key = self::common_key( array_keys( $it['pairs'] ) ); $slash = strrpos( $key, '/' ); $key = false !== $slash ? substr( $key, 0, $slash + 1 ) : $key; $likes[] = '%' . $wpdb->esc_like( $key ) . '%'; $likes[] = '%' . $wpdb->esc_like( str_replace( '/', '\\/', $key ) ) . '%'; } $likes = array_values( array_unique( $likes ) ); if ( ! $likes ) { $likes = array( '%ctps-never-matches%' ); } $own_ids = implode( ',', array_map( 'intval', array_keys( $items ) ) ); // table, primary key, columns, extra filter, column naming the cache entry, cache group. $targets = array( array( $wpdb->posts, 'ID', array( 'post_content', 'post_excerpt' ), 'ID NOT IN (' . $own_ids . ')', 'ID', 'posts' ), array( $wpdb->postmeta, 'meta_id', array( 'meta_value' ), "meta_key NOT IN ('_wp_attached_file','_wp_attachment_metadata','_wp_attachment_backup_sizes')", 'post_id', 'post_meta' ), array( $wpdb->termmeta, 'meta_id', array( 'meta_value' ), '1=1', 'term_id', 'term_meta' ), array( $wpdb->usermeta, 'umeta_id', array( 'meta_value' ), '1=1', 'user_id', 'user_meta' ), array( $wpdb->commentmeta, 'meta_id', array( 'meta_value' ), '1=1', 'comment_id', 'comment_meta' ), array( $wpdb->comments, 'comment_ID', array( 'comment_content' ), '1=1', 'comment_ID', 'comment' ), array( $wpdb->options, 'option_id', array( 'option_value' ), "option_name NOT LIKE '\\_transient\\_%' AND option_name NOT LIKE '\\_site\\_transient\\_%' AND option_name <> '" . esc_sql( self::LOCK ) . "'", 'option_name', 'options' ), ); // 1. Read: every row naming one of the images, and which images it names. $plan = array(); foreach ( $targets as $t ) { $table = esc_sql( $t[0] ); $pk = esc_sql( $t[1] ); $where = $t[3]; $owner = esc_sql( $t[4] ); foreach ( $t[2] as $col_name ) { $col = esc_sql( $col_name ); $cond = implode( ' OR ', array_fill( 0, count( $likes ), "{$col} LIKE %s" ) ); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT {$pk} AS pk, {$owner} AS owner, {$col} AS val FROM {$table} WHERE ( {$cond} ) AND ( {$where} )", $likes ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- table and column names are fixed above; the condition holds one %s placeholder per LIKE value foreach ( (array) $rows as $row ) { $val = (string) $row->val; $ids = array(); foreach ( $full_by as $id => $f ) { foreach ( array_keys( $f ) as $old ) { if ( false !== strpos( $val, $old ) ) { $ids[] = $id; break; } } } if ( $ids ) { $plan[] = array( 'table' => $t[0], 'pk' => $t[1], 'col' => $col_name, 'group' => $t[5], 'key' => $row->pk, 'owner' => $row->owner, 'val' => $val, 'ids' => $ids, ); } } } } // 2. Plan: new values; a row that cannot be rewritten safely keeps all // the images it names out of this run (and, in turn, their other rows). $bad = array(); foreach ( $plan as $k => $p ) { $map = array(); foreach ( $p['ids'] as $id ) { $map += $full_by[ $id ]; } uksort( $map, function ( $a, $b ) { return strlen( $b ) - strlen( $a ); } ); $new = self::replace_value( $p['val'], $map ); if ( null === $new ) { foreach ( $p['ids'] as $id ) { /* translators: %s: database table name */ $bad[ $id ] = sprintf( __( 'A reference in %s could not be updated safely (data with objects): nothing deleted.', 'celero-adaptive-speed' ), $p['table'] ); } unset( $plan[ $k ] ); continue; } $plan[ $k ]['new'] = $new; } do { $again = false; foreach ( $plan as $k => $p ) { if ( array_intersect( $p['ids'], array_keys( $bad ) ) ) { foreach ( $p['ids'] as $id ) { if ( ! isset( $bad[ $id ] ) ) { $bad[ $id ] = __( 'It shares a database entry with an image that could not be updated: nothing deleted.', 'celero-adaptive-speed' ); $again = true; } } unset( $plan[ $k ] ); } } } while ( $again ); // 3. Write. $touched = array(); foreach ( $plan as $k => $p ) { if ( $p['new'] === $p['val'] ) { continue; } $ok = $wpdb->update( $p['table'], array( $p['col'] => $p['new'] ), array( $p['pk'] => $p['key'] ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the update itself if ( false === $ok ) { foreach ( $p['ids'] as $id ) { /* translators: %s: database table name */ $bad[ $id ] = sprintf( __( 'The database refused an update in %s: nothing deleted.', 'celero-adaptive-speed' ), $p['table'] ); } continue; } foreach ( $p['ids'] as $id ) { $touched[ $id ] = true; } self::forget_cache( $p['group'], $p['owner'] ); } // 4. Check: the rows read again hold none of the old names. foreach ( $plan as $p ) { $table = esc_sql( $p['table'] ); $pk = esc_sql( $p['pk'] ); $col = esc_sql( $p['col'] ); $val = (string) $wpdb->get_var( $wpdb->prepare( "SELECT {$col} FROM {$table} WHERE {$pk} = %s", (string) $p['key'] ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- check after update foreach ( $p['ids'] as $id ) { foreach ( array_keys( $full_by[ $id ] ) as $old ) { if ( false !== strpos( $val, $old ) && ! isset( $bad[ $id ] ) ) { /* translators: %s: database table name */ $bad[ $id ] = sprintf( __( 'An old address is still present in %s after the update: nothing deleted.', 'celero-adaptive-speed' ), $p['table'] ); } } } } $out = array(); foreach ( array_keys( $items ) as $id ) { $out[ $id ] = isset( $bad[ $id ] ) ? array( 'reason' => $bad[ $id ], 'touched' => ! empty( $touched[ $id ] ) ) : true; } return $out; } /** * One image (kept for callers of earlier versions). * * @return true|string */ public static function replace_everywhere( $pairs, $attachment_id ) { $r = self::replace_batch( array( (int) $attachment_id => array( 'pairs' => $pairs ) ) ); $r = $r[ (int) $attachment_id ]; self::$touched = is_array( $r ) && ! empty( $r['touched'] ); return true === $r ? true : $r['reason']; } /** Drops the object-cache entry of an updated row (Redis, Memcached...). */ private static function forget_cache( $group, $owner ) { switch ( $group ) { case 'posts': clean_post_cache( (int) $owner ); break; case 'options': wp_cache_delete( (string) $owner, 'options' ); wp_cache_delete( 'alloptions', 'options' ); wp_cache_delete( 'notoptions', 'options' ); break; case 'comment': clean_comment_cache( (int) $owner ); break; default: wp_cache_delete( (int) $owner, $group ); } } /** Longest common start of the old paths (e.g. "2026/06/photo"). */ private static function common_key( $paths ) { $key = (string) array_shift( $paths ); foreach ( $paths as $p ) { while ( '' !== $key && 0 !== strpos( $p, $key ) ) { $key = substr( $key, 0, -1 ); } } return '' === $key ? 'ctps-never-matches' : $key; } /** * @return string|null New value, or null when it cannot be rewritten safely. */ public static function replace_value( $val, $full ) { if ( is_serialized( $val ) ) { $data = @unserialize( $val, array( 'allowed_classes' => false ) ); // phpcs:ignore WordPress.PHP.NoSilencedErrors, WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- objects are refused if ( false === $data && 'b:0;' !== $val ) { return null; } $bad = false; $out = self::walk( $data, $full, $bad ); return $bad ? null : serialize( $out ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- same format as stored } return strtr( $val, $full ); } private static function walk( $data, $full, &$bad ) { if ( is_object( $data ) ) { $bad = true; return $data; } if ( is_array( $data ) ) { $out = array(); foreach ( $data as $k => $v ) { $out[ is_string( $k ) ? strtr( $k, $full ) : $k ] = self::walk( $v, $full, $bad ); } return $out; } if ( is_string( $data ) ) { if ( ! is_serialized( $data ) ) { return strtr( $data, $full ); } $inner = self::replace_value( $data, $full ); if ( null === $inner ) { $bad = true; return $data; } return $inner; } return $data; } }