` guard, and the site serves fast but * completely invisible cache hits — no `X-XSpeed-Cache` header for * the user, nothing for the hit counter. That is exactly the * "cache works, dashboard says 0%" report this check exists to * prevent. (Cache::static_rewrite_allowed() consumes it.) * * Detection is best-effort by necessity: * - mod_php exposes apache_get_modules() — authoritative. * - Under PHP-FPM that function doesn't exist. Assume the module * IS present, matching Apache's own default build (mod_headers * ships enabled in every mainstream distro package). Guessing * "absent" there would push every FPM site onto the slower * drop-in path over a detection limitation rather than a real * capability gap; the loopback probe in * Cache::probe_static_rewrite() is what catches a genuinely * header-less FPM host. * * @return bool */ public static function apache_has_mod_headers(): bool { if ( function_exists( 'apache_get_modules' ) ) { $has = in_array( 'mod_headers', apache_get_modules(), true ); } else { $has = true; // FPM: undetectable, assume the distro default. } /** * Filter: xspeed_apache_has_mod_headers * * Override mod_headers detection. Return false on a host where * `.htaccess` Header directives are stripped (some managed * stacks do this) to force cache hits through the PHP drop-in, * where they are stamped and counted. * * @param bool $has Whether mod_headers appears to be available. */ return (bool) apply_filters( 'xspeed_apache_has_mod_headers', $has ); } /** * Accept a candidate access-log path only if it can actually be * tail-scanned, else ''. * * `is_readable()` alone is not enough. The official WordPress and * Apache Docker images symlink `access.log -> /dev/stdout`, i.e. a * PIPE: `is_file()` is false, `filesize()` is 0, and `is_readable()` * is false for the PHP user. Hit_Counter::collect_server_log_hits() * fseek()s to a stored byte offset and reads forward, which a pipe * or character device cannot support at all — it would either fail * or block. Requiring a REGULAR file makes that contract explicit * instead of relying on the filesize>0 test to reject pipes as a * side effect. Containerised Apache is the standard layout, not an * edge case, so this path is common. (Field report: hit ratio stuck * at 0% on Dockerised Apache while the cache served correctly.) * * @param string $path Candidate path. * @return string The path when usable, '' otherwise. */ private static function usable_access_log( string $path ): string { if ( '' === $path ) { return ''; } // is_file() resolves symlinks, so access.log -> /var/log/real.log // is still accepted; only the pipe/device targets are rejected. if ( ! @is_file( $path ) || ! is_readable( $path ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- racey stat on an external log; treated as "unusable". return ''; } return $path; } /** * Best-effort path to the web server's access log, used to count * static-rewrite HITs that bypass PHP on Apache/LiteSpeed (those * requests are served straight from disk and never reach our * Hit_Counter inline — see Hit_Counter::collect_server_log_hits()). * * Resolution order: * 1. The `XSPEED_ACCESS_LOG` constant, if defined (explicit override * for hosts where the log lives somewhere non-standard). * 2. The `xspeed_access_log_path` filter (programmatic override). * 3. Auto-detection: a short list of the standard Apache/LiteSpeed * access-log locations, returning the first that exists AND is * readable by the PHP user. * * Every route is funnelled through usable_access_log(), so an * override can no more hand us a pipe than auto-detection can. * * Returns '' when nothing usable is found — a very common case on * managed/cPanel hosts where the PHP user can't read the server log, * and on containers where it's a symlink to stdout. Callers MUST * treat '' as "can't count static hits here" and fall back * gracefully (the drop-in path still counts its own HITs). * * @return string Absolute path, or '' if none is usable. */ public static function access_log_path(): string { if ( defined( 'XSPEED_ACCESS_LOG' ) && is_string( XSPEED_ACCESS_LOG ) && '' !== XSPEED_ACCESS_LOG ) { return self::usable_access_log( XSPEED_ACCESS_LOG ); } /** * Filter: xspeed_access_log_path * * Override the auto-detected access-log path. Return '' to disable * server-log hit counting entirely. * * @param string|null $path Null = use auto-detection below. */ $filtered = apply_filters( 'xspeed_access_log_path', null ); if ( is_string( $filtered ) ) { return self::usable_access_log( $filtered ); } // Auto-detect: the standard Apache + OpenLiteSpeed/LiteSpeed // Enterprise access-log locations. First readable, NON-EMPTY file // wins — an empty global access.log (common on LiteSpeed, which // logs per-vhost instead) must not shadow the real per-vhost log we // discover below. $candidates = array( '/var/log/apache2/access.log', // Debian/Ubuntu Apache '/var/log/httpd/access_log', // RHEL/CentOS Apache '/var/log/apache2/other_vhosts_access.log', // Debian multi-vhost '/usr/local/lsws/logs/access.log', // OpenLiteSpeed global '/var/log/lshttpd/access.log', // LiteSpeed Enterprise ); foreach ( $candidates as $path ) { // usable_access_log() enforces "regular file + readable"; the // non-empty test stays here so an empty global log doesn't // shadow the real per-vhost one found further below. if ( '' !== self::usable_access_log( $path ) && (int) @filesize( $path ) > 0 ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- racey stat, treated as "skip". return $path; } } // LiteSpeed (and some Apache vhost setups) write a per-vhost // `.access.log` rather than a single global file. Scan the // known log dirs for the most-recently-written, readable, non-empty // *.access.log and use that. Auto-tracks whichever vhost is serving // this site without the admin having to set a path. $dirs = array( '/usr/local/lsws/logs', '/var/log/lshttpd', '/var/log/apache2', '/var/log/httpd' ); $best = ''; $best_mtime = 0; foreach ( $dirs as $dir ) { if ( ! is_dir( $dir ) ) { continue; } $globbed = glob( $dir . '/*access*log*' ); if ( ! is_array( $globbed ) ) { continue; } foreach ( $globbed as $path ) { // Same regular-file contract as the fixed candidates: a // glob can just as easily turn up a symlink to stdout. if ( '' === self::usable_access_log( $path ) || (int) @filesize( $path ) === 0 ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged continue; } $mtime = (int) @filemtime( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( $mtime > $best_mtime ) { $best_mtime = $mtime; $best = $path; } } } return $best; } /** * GZIP support category for the UI: * 'auto' — toggling writes server config (Apache / LiteSpeed) * 'manual' — must be configured outside the plugin (nginx, IIS, unknown) */ public static function gzip_mode() { return self::supports_htaccess() ? 'auto' : 'manual'; } /** * Whether the server can serve Brotli-compressed responses. * * Brotli is an optional server module (mod_brotli on Apache, * ngx_brotli on nginx, built in on LiteSpeed/OpenLiteSpeed) — unlike * GZIP it is NOT guaranteed present. We report availability so the UI * and any add-on (xspeed-pro Brotli module) can decide whether to * emit Brotli rules or fall back to GZIP only. * * Detection, cheapest signal first: * 1. LiteSpeed — Brotli is part of the core server, always available. * 2. Apache mod_php — apache_get_modules() lists 'mod_brotli'. * 3. PHP `brotli` extension (kjdev/php-ext-brotli) — lets us at least * pre-compress static files even when the web server can't. * Anything else (nginx/FPM, IIS, unknown) is reported as not detected; * the user can still wire ngx_brotli manually and the UI surfaces a * snippet, mirroring how GZIP behaves on nginx. * * Result is filterable so a host with a known-good but undetectable * setup (e.g. nginx + ngx_brotli) can force-enable. */ public static function brotli_available(): bool { $available = false; if ( self::LITESPEED === self::type() ) { $available = true; } elseif ( function_exists( 'apache_get_modules' ) && in_array( 'mod_brotli', apache_get_modules(), true ) ) { $available = true; } elseif ( function_exists( 'brotli_compress' ) ) { $available = true; } /** * Filter detected Brotli availability. * * @param bool $available Whether Brotli serving was detected. */ return (bool) apply_filters( 'xspeed_brotli_available', $available ); } /** * Is WordPress running inside a container (Docker / Podman / k8s)? * * Three signals checked in cheapness order, OR'd together: * 1. /.dockerenv exists — Docker's traditional marker; rare absence. * 2. /proc/self/mountinfo references /var/lib/docker/overlay2 or * containerd/podman storage drivers — works under cgroup v2. * 3. /proc/1/cgroup names docker / kubepods / containerd / podman / lxc * — the cgroup v1 signal, still present on older Docker installs. * * Any single positive returns true. On non-Linux hosts (Windows / * macOS / WSL host process), all three quietly return false and we * fall back to "not containerized." */ public static function is_containerized(): bool { // 1. Docker marker file — cheap to stat, almost always present. if ( file_exists( '/.dockerenv' ) ) { return true; } // 2. mountinfo overlay2 / containerd footprint — works under cgroup v2. // Gate on is_readable() first: on non-Linux hosts (macOS/Windows) or // hosts that hide /proc (open_basedir, hardened Apache), the file is // absent and reading it would emit a warning. Query Monitor surfaces // even @-suppressed warnings, so guard rather than silence. (FBS-83114) if ( is_readable( '/proc/self/mountinfo' ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- /proc/self/mountinfo is a virtual file; WP_Filesystem doesn't model /proc. $mounts = file_get_contents( '/proc/self/mountinfo' ); if ( is_string( $mounts ) && '' !== $mounts && preg_match( '#(docker/overlay2|/var/lib/containerd|/var/lib/podman)#i', $mounts ) ) { return true; } } // 3. cgroup v1 fallback. if ( is_readable( '/proc/1/cgroup' ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- See above. $cgroup = file_get_contents( '/proc/1/cgroup' ); if ( is_string( $cgroup ) && '' !== $cgroup && preg_match( '#(docker|kubepods|containerd|podman|lxc)#i', $cgroup ) ) { return true; } } return false; } /** * Is WordPress likely behind a reverse proxy (host nginx → container * php-fpm, host nginx → docker nginx, etc.)? Detection is a heuristic * built from the headers WordPress hands to PHP: when a proxy forwards * the request it almost always sets X-Forwarded-* or X-Real-IP. * * False positives (CDN-only forwarding without a local reverse proxy) * are acceptable — the caller uses this signal only to soften messaging * that would otherwise mislead container-host customers. False negatives * (proxy that strips headers) just mean we keep showing the snippet * paste UX, which is the safe default. */ public static function is_behind_proxy(): bool { $proxy_headers = array( 'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED_HOST', 'HTTP_X_FORWARDED_PROTO', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_SERVER' ); foreach ( $proxy_headers as $h ) { if ( ! empty( $_SERVER[ $h ] ) ) { return true; } } return false; } /** * High-level topology classifier driving the rewrite-alert UX. * * Decides WHERE the user's nginx snippet needs to be installed * (or whether automatic install via .htaccess covers it). The * dashboard banner uses the return value to render the right * "paste this here" message — there is no topology that can't * benefit from xSpeed's static-rewrite; the question is only * which nginx is in the cache file's filesystem. * * Returns one of: * 'htaccess' — Apache / LiteSpeed; .htaccess block is * installed automatically, no user action. * 'nginx-host' — self-managed nginx on the host (no * container in the request path). User * pastes the snippet into their vhost * (typically /etc/nginx/sites-enabled/). * 'nginx-container' — nginx running inside the same container * as PHP. User pastes the snippet into the * container's nginx config (typically * docker/nginx.conf in the site's * docker-compose dir). Host nginx (if any) * is a reverse-proxy that just forwards * bytes — snippet does NOT go there. * 'unknown' — IIS or undetected; treat as manual. */ public static function rewrite_topology(): string { $type = self::type(); if ( self::APACHE === $type || self::LITESPEED === $type ) { return 'htaccess'; } if ( self::NGINX === $type ) { return self::is_containerized() ? 'nginx-container' : 'nginx-host'; } return 'unknown'; } private static function server_signature() { return isset( $_SERVER['SERVER_SOFTWARE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) ) : ''; } /** * Detect active caching plugins that would conflict with xSpeed. Returns * a list of human-readable labels for any conflicting plugin currently * active; empty array means the field is clear. Used by the onboarding * wizard's Step 1 health check and (Phase 2.1) the main dashboard's * Health card. * * The detection key is the plugin's main file path relative to the * plugins directory — the same value WordPress uses internally in * `active_plugins`. Folder-only checks (`is_plugin_active('foo/')`) * would false-positive on disabled plugins still on disk. */ public static function conflicts() { if ( ! function_exists( 'is_plugin_active' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $known = array( 'wp-rocket/wp-rocket.php' => 'WP Rocket', 'w3-total-cache/w3-total-cache.php' => 'W3 Total Cache', 'wp-super-cache/wp-cache.php' => 'WP Super Cache', 'wp-fastest-cache/wpFastestCache.php' => 'WP Fastest Cache', 'litespeed-cache/litespeed-cache.php' => 'LiteSpeed Cache', 'cache-enabler/cache-enabler.php' => 'Cache Enabler', 'comet-cache/comet-cache.php' => 'Comet Cache', 'hummingbird-performance/wp-hummingbird.php' => 'Hummingbird', 'sg-cachepress/sg-cachepress.php' => 'SG Optimizer', 'breeze/breeze.php' => 'Breeze', 'autoptimize/autoptimize.php' => 'Autoptimize', 'flying-press/flying-press.php' => 'FlyingPress', 'nitropack/main.php' => 'NitroPack', ); $active = array(); foreach ( $known as $file => $label ) { if ( is_plugin_active( $file ) ) { $active[] = $label; } } return $active; } }