| @@ -21,8 +21,16 @@ | ||
| 21 | 21 | const UNKNOWN = 'unknown'; |
| 22 | 22 | |
| 23 | 23 | const OPT_CACHED_TYPE = 'xspeed_server_type'; |
| 24 | 24 | |
| 25 | + /** | |
| 26 | + * Last authoritative mod_headers answer, captured under mod_php where | |
| 27 | + * apache_get_modules() actually exists. Read by SAPIs that cannot | |
| 28 | + * detect (WP-CLI, FPM) so one host gives one answer. See | |
| 29 | + * apache_has_mod_headers(). | |
| 30 | + */ | |
| 31 | + const OPT_CACHED_MOD_HEADERS = 'xspeed_apache_mod_headers'; | |
| 32 | + | |
| 25 | 33 | public static function type() { |
| 26 | 34 | $detected = self::detect(); |
| 27 | 35 | if ( self::UNKNOWN !== $detected ) { |
| 28 | 36 | // Persist whenever we have a real answer so future CLI / |
| @@ -31,20 +39,36 @@ | ||
| 31 | 39 | $cached = get_option( self::OPT_CACHED_TYPE, null ); |
| 32 | 40 | if ( $cached !== $detected ) { |
| 33 | 41 | update_option( self::OPT_CACHED_TYPE, $detected, false ); |
| 34 | 42 | } |
| 35 | - return $detected; | |
| 43 | + $type = $detected; | |
| 44 | + } else { | |
| 45 | + // No definitive signal this request (typically WP-CLI, where | |
| 46 | + // SERVER_SOFTWARE is empty). Read whatever was cached the last | |
| 47 | + // time we ran from a real HTTP request. | |
| 48 | + $cached = get_option( self::OPT_CACHED_TYPE, null ); | |
| 49 | + $type = ( is_string( $cached ) && '' !== $cached ) ? $cached : self::UNKNOWN; | |
| 36 | 50 | } |
| 37 | 51 | |
| 38 | - // No definitive signal this request (typically WP-CLI, where | |
| 39 | - // SERVER_SOFTWARE is empty). Read whatever was cached the last | |
| 40 | - // time we ran from a real HTTP request. | |
| 41 | - $cached = get_option( self::OPT_CACHED_TYPE, null ); | |
| 42 | - if ( is_string( $cached ) && '' !== $cached ) { | |
| 43 | - return $cached; | |
| 44 | - } | |
| 45 | - | |
| 46 | - return self::UNKNOWN; | |
| 52 | + /** | |
| 53 | + * Filters the resolved server type. | |
| 54 | + * | |
| 55 | + * The override point for contexts that cannot detect. Detection | |
| 56 | + * reads SERVER_SOFTWARE, which the web server supplies and WP-CLI | |
| 57 | + * therefore never has; the cached option covers CLI runs on a site | |
| 58 | + * some request has already reached, but a site provisioned entirely | |
| 59 | + * over WP-CLI has nothing cached and resolves to `unknown` even on | |
| 60 | + * nginx. `wp xspeed cache nginx-config --server=` hooks this so | |
| 61 | + * every gate downstream — Cache::nginx_snippet(), each module's own | |
| 62 | + * nginx_directives() — agrees on one answer, rather than each | |
| 63 | + * re-deciding and emitting a half-built config. | |
| 64 | + * | |
| 65 | + * Filtering does NOT write the cached option: an assumption stated | |
| 66 | + * for one command must not become this site's persisted answer. | |
| 67 | + * | |
| 68 | + * @param string $type One of apache|litespeed|nginx|iis|unknown. | |
| 69 | + */ | |
| 70 | + return apply_filters( 'xspeed_server_type', $type ); | |
| 47 | 71 | } |
| 48 | 72 | |
| 49 | 73 | /** |
| 50 | 74 | * Live detection — never reads the cache. Used by type() and by |
| @@ -89,8 +113,203 @@ | ||
| 89 | 113 | return self::APACHE === $t || self::LITESPEED === $t; |
| 90 | 114 | } |
| 91 | 115 | |
| 92 | 116 | /** |
| 117 | + * Whether Apache can stamp a response header from `.htaccess` | |
| 118 | + * (i.e. mod_headers is loaded). | |
| 119 | + * | |
| 120 | + * This decides whether the static-rewrite fast path can be used at | |
| 121 | + * all. A statically-served file bypasses PHP entirely, so the ONLY | |
| 122 | + * way to mark it as a cache HIT is a `Header` directive in the | |
| 123 | + * rewrite block. Without mod_headers that directive is silently | |
| 124 | + * swallowed by its `<IfModule>` guard, and the site serves fast but | |
| 125 | + * completely invisible cache hits — no `X-XSpeed-Cache` header for | |
| 126 | + * the user, nothing for the hit counter. That is exactly the | |
| 127 | + * "cache works, dashboard says 0%" report this check exists to | |
| 128 | + * prevent. (Cache::static_rewrite_allowed() consumes it.) | |
| 129 | + * | |
| 130 | + * Detection is best-effort by necessity, and MUST NOT vary by SAPI: | |
| 131 | + * - mod_php exposes apache_get_modules() — authoritative. Persist | |
| 132 | + * that answer so other SAPIs can inherit it. | |
| 133 | + * - Under PHP-FPM / WP-CLI the function doesn't exist. Read the | |
| 134 | + * stored mod_php answer; only when nothing was ever stored do we | |
| 135 | + * assume the module IS present, matching Apache's own default | |
| 136 | + * build (mod_headers ships enabled in every mainstream distro | |
| 137 | + * package). Guessing "absent" there would push every FPM site | |
| 138 | + * onto the slower drop-in path over a detection limitation | |
| 139 | + * rather than a real capability gap; the loopback probe in | |
| 140 | + * Cache::probe_static_rewrite() is what catches a genuinely | |
| 141 | + * header-less FPM host. | |
| 142 | + * | |
| 143 | + * Returning a different answer per SAPI is not merely inaccurate: it | |
| 144 | + * makes static_rewrite_allowed() disagree with the on-disk .htaccess, | |
| 145 | + * so every WP-CLI bootstrap "corrects" what the last web request | |
| 146 | + * wrote and vice versa — an endless rewrite/purge ping-pong that | |
| 147 | + * keeps the hit ratio pinned near zero. (#138) | |
| 148 | + * | |
| 149 | + * @return bool | |
| 150 | + */ | |
| 151 | + public static function apache_has_mod_headers(): bool { | |
| 152 | + if ( function_exists( 'apache_get_modules' ) ) { | |
| 153 | + $has = in_array( 'mod_headers', apache_get_modules(), true ); | |
| 154 | + | |
| 155 | + // Authoritative — persist so CLI/FPM inherit it instead of | |
| 156 | + // guessing. Non-autoloaded; only read when needed. | |
| 157 | + $cached = get_option( self::OPT_CACHED_MOD_HEADERS, null ); | |
| 158 | + $want = $has ? '1' : '0'; | |
| 159 | + if ( (string) $cached !== $want ) { | |
| 160 | + update_option( self::OPT_CACHED_MOD_HEADERS, $want, false ); | |
| 161 | + } | |
| 162 | + } else { | |
| 163 | + // Cannot detect here. Prefer the last known real answer over | |
| 164 | + // an optimistic guess that would flip static_rewrite_allowed(). | |
| 165 | + $cached = get_option( self::OPT_CACHED_MOD_HEADERS, null ); | |
| 166 | + $has = ( null === $cached || '' === $cached ) | |
| 167 | + ? true // never detected: assume the distro default. | |
| 168 | + : (bool) (int) $cached; | |
| 169 | + } | |
| 170 | + | |
| 171 | + /** | |
| 172 | + * Filter: xspeed_apache_has_mod_headers | |
| 173 | + * | |
| 174 | + * Override mod_headers detection. Return false on a host where | |
| 175 | + * `.htaccess` Header directives are stripped (some managed | |
| 176 | + * stacks do this) to force cache hits through the PHP drop-in, | |
| 177 | + * where they are stamped and counted. | |
| 178 | + * | |
| 179 | + * @param bool $has Whether mod_headers appears to be available. | |
| 180 | + */ | |
| 181 | + return (bool) apply_filters( 'xspeed_apache_has_mod_headers', $has ); | |
| 182 | + } | |
| 183 | + | |
| 184 | + /** | |
| 185 | + * Accept a candidate access-log path only if it can actually be | |
| 186 | + * tail-scanned, else ''. | |
| 187 | + * | |
| 188 | + * `is_readable()` alone is not enough. The official WordPress and | |
| 189 | + * Apache Docker images symlink `access.log -> /dev/stdout`, i.e. a | |
| 190 | + * PIPE: `is_file()` is false, `filesize()` is 0, and `is_readable()` | |
| 191 | + * is false for the PHP user. Hit_Counter::collect_server_log_hits() | |
| 192 | + * fseek()s to a stored byte offset and reads forward, which a pipe | |
| 193 | + * or character device cannot support at all — it would either fail | |
| 194 | + * or block. Requiring a REGULAR file makes that contract explicit | |
| 195 | + * instead of relying on the filesize>0 test to reject pipes as a | |
| 196 | + * side effect. Containerised Apache is the standard layout, not an | |
| 197 | + * edge case, so this path is common. (Field report: hit ratio stuck | |
| 198 | + * at 0% on Dockerised Apache while the cache served correctly.) | |
| 199 | + * | |
| 200 | + * @param string $path Candidate path. | |
| 201 | + * @return string The path when usable, '' otherwise. | |
| 202 | + */ | |
| 203 | + private static function usable_access_log( string $path ): string { | |
| 204 | + if ( '' === $path ) { | |
| 205 | + return ''; | |
| 206 | + } | |
| 207 | + // is_file() resolves symlinks, so access.log -> /var/log/real.log | |
| 208 | + // is still accepted; only the pipe/device targets are rejected. | |
| 209 | + if ( ! @is_file( $path ) || ! is_readable( $path ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- racey stat on an external log; treated as "unusable". | |
| 210 | + return ''; | |
| 211 | + } | |
| 212 | + return $path; | |
| 213 | + } | |
| 214 | + | |
| 215 | + /** | |
| 216 | + * Best-effort path to the web server's access log, used to count | |
| 217 | + * static-rewrite HITs that bypass PHP on Apache/LiteSpeed (those | |
| 218 | + * requests are served straight from disk and never reach our | |
| 219 | + * Hit_Counter inline — see Hit_Counter::collect_server_log_hits()). | |
| 220 | + * | |
| 221 | + * Resolution order: | |
| 222 | + * 1. The `XSPEED_ACCESS_LOG` constant, if defined (explicit override | |
| 223 | + * for hosts where the log lives somewhere non-standard). | |
| 224 | + * 2. The `xspeed_access_log_path` filter (programmatic override). | |
| 225 | + * 3. Auto-detection: a short list of the standard Apache/LiteSpeed | |
| 226 | + * access-log locations, returning the first that exists AND is | |
| 227 | + * readable by the PHP user. | |
| 228 | + * | |
| 229 | + * Every route is funnelled through usable_access_log(), so an | |
| 230 | + * override can no more hand us a pipe than auto-detection can. | |
| 231 | + * | |
| 232 | + * Returns '' when nothing usable is found — a very common case on | |
| 233 | + * managed/cPanel hosts where the PHP user can't read the server log, | |
| 234 | + * and on containers where it's a symlink to stdout. Callers MUST | |
| 235 | + * treat '' as "can't count static hits here" and fall back | |
| 236 | + * gracefully (the drop-in path still counts its own HITs). | |
| 237 | + * | |
| 238 | + * @return string Absolute path, or '' if none is usable. | |
| 239 | + */ | |
| 240 | + public static function access_log_path(): string { | |
| 241 | + if ( defined( 'XSPEED_ACCESS_LOG' ) && is_string( XSPEED_ACCESS_LOG ) && '' !== XSPEED_ACCESS_LOG ) { | |
| 242 | + return self::usable_access_log( XSPEED_ACCESS_LOG ); | |
| 243 | + } | |
| 244 | + | |
| 245 | + /** | |
| 246 | + * Filter: xspeed_access_log_path | |
| 247 | + * | |
| 248 | + * Override the auto-detected access-log path. Return '' to disable | |
| 249 | + * server-log hit counting entirely. | |
| 250 | + * | |
| 251 | + * @param string|null $path Null = use auto-detection below. | |
| 252 | + */ | |
| 253 | + $filtered = apply_filters( 'xspeed_access_log_path', null ); | |
| 254 | + if ( is_string( $filtered ) ) { | |
| 255 | + return self::usable_access_log( $filtered ); | |
| 256 | + } | |
| 257 | + | |
| 258 | + // Auto-detect: the standard Apache + OpenLiteSpeed/LiteSpeed | |
| 259 | + // Enterprise access-log locations. First readable, NON-EMPTY file | |
| 260 | + // wins — an empty global access.log (common on LiteSpeed, which | |
| 261 | + // logs per-vhost instead) must not shadow the real per-vhost log we | |
| 262 | + // discover below. | |
| 263 | + $candidates = array( | |
| 264 | + '/var/log/apache2/access.log', // Debian/Ubuntu Apache | |
| 265 | + '/var/log/httpd/access_log', // RHEL/CentOS Apache | |
| 266 | + '/var/log/apache2/other_vhosts_access.log', // Debian multi-vhost | |
| 267 | + '/usr/local/lsws/logs/access.log', // OpenLiteSpeed global | |
| 268 | + '/var/log/lshttpd/access.log', // LiteSpeed Enterprise | |
| 269 | + ); | |
| 270 | + foreach ( $candidates as $path ) { | |
| 271 | + // usable_access_log() enforces "regular file + readable"; the | |
| 272 | + // non-empty test stays here so an empty global log doesn't | |
| 273 | + // shadow the real per-vhost one found further below. | |
| 274 | + if ( '' !== self::usable_access_log( $path ) && (int) @filesize( $path ) > 0 ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- racey stat, treated as "skip". | |
| 275 | + return $path; | |
| 276 | + } | |
| 277 | + } | |
| 278 | + | |
| 279 | + // LiteSpeed (and some Apache vhost setups) write a per-vhost | |
| 280 | + // `<vhost>.access.log` rather than a single global file. Scan the | |
| 281 | + // known log dirs for the most-recently-written, readable, non-empty | |
| 282 | + // *.access.log and use that. Auto-tracks whichever vhost is serving | |
| 283 | + // this site without the admin having to set a path. | |
| 284 | + $dirs = array( '/usr/local/lsws/logs', '/var/log/lshttpd', '/var/log/apache2', '/var/log/httpd' ); | |
| 285 | + $best = ''; | |
| 286 | + $best_mtime = 0; | |
| 287 | + foreach ( $dirs as $dir ) { | |
| 288 | + if ( ! is_dir( $dir ) ) { | |
| 289 | + continue; | |
| 290 | + } | |
| 291 | + $globbed = glob( $dir . '/*access*log*' ); | |
| 292 | + if ( ! is_array( $globbed ) ) { | |
| 293 | + continue; | |
| 294 | + } | |
| 295 | + foreach ( $globbed as $path ) { | |
| 296 | + // Same regular-file contract as the fixed candidates: a | |
| 297 | + // glob can just as easily turn up a symlink to stdout. | |
| 298 | + if ( '' === self::usable_access_log( $path ) || (int) @filesize( $path ) === 0 ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged | |
| 299 | + continue; | |
| 300 | + } | |
| 301 | + $mtime = (int) @filemtime( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged | |
| 302 | + if ( $mtime > $best_mtime ) { | |
| 303 | + $best_mtime = $mtime; | |
| 304 | + $best = $path; | |
| 305 | + } | |
| 306 | + } | |
| 307 | + } | |
| 308 | + return $best; | |
| 309 | + } | |
| 310 | + | |
| 311 | + /** | |
| 93 | 312 | * GZIP support category for the UI: |
| 94 | 313 | * 'auto' — toggling writes server config (Apache / LiteSpeed) |
| 95 | 314 | * 'manual' — must be configured outside the plugin (nginx, IIS, unknown) |
| 96 | 315 | */ |
| @@ -98,8 +317,256 @@ | ||
| 98 | 317 | return self::supports_htaccess() ? 'auto' : 'manual'; |
| 99 | 318 | } |
| 100 | 319 | |
| 101 | 320 | /** |
| 321 | + * Whether the server can serve Brotli-compressed responses. | |
| 322 | + * | |
| 323 | + * Brotli is an optional server module (mod_brotli on Apache, | |
| 324 | + * ngx_brotli on nginx, built in on LiteSpeed/OpenLiteSpeed) — unlike | |
| 325 | + * GZIP it is NOT guaranteed present. We report availability so the UI | |
| 326 | + * and any add-on (xspeed-pro Brotli module) can decide whether to | |
| 327 | + * emit Brotli rules or fall back to GZIP only. | |
| 328 | + * | |
| 329 | + * Detection, cheapest signal first: | |
| 330 | + * 1. LiteSpeed — Brotli is part of the core server, always available. | |
| 331 | + * 2. Apache mod_php — apache_get_modules() lists 'mod_brotli'. | |
| 332 | + * 3. PHP `brotli` extension (kjdev/php-ext-brotli) — lets us at least | |
| 333 | + * pre-compress static files even when the web server can't. | |
| 334 | + * Anything else (nginx/FPM, IIS, unknown) is reported as not detected; | |
| 335 | + * the user can still wire ngx_brotli manually and the UI surfaces a | |
| 336 | + * snippet, mirroring how GZIP behaves on nginx. | |
| 337 | + * | |
| 338 | + * Result is filterable so a host with a known-good but undetectable | |
| 339 | + * setup (e.g. nginx + ngx_brotli) can force-enable. | |
| 340 | + */ | |
| 341 | + public static function brotli_available(): bool { | |
| 342 | + $available = false; | |
| 343 | + | |
| 344 | + if ( self::LITESPEED === self::type() ) { | |
| 345 | + $available = true; | |
| 346 | + } elseif ( function_exists( 'apache_get_modules' ) && in_array( 'mod_brotli', apache_get_modules(), true ) ) { | |
| 347 | + $available = true; | |
| 348 | + } elseif ( function_exists( 'brotli_compress' ) ) { | |
| 349 | + $available = true; | |
| 350 | + } elseif ( self::NGINX === self::type() ) { | |
| 351 | + // nginx modules are not introspectable from PHP, so none of the | |
| 352 | + // branches above can ever be true on the very common nginx + | |
| 353 | + // php-fpm setup — even while ngx_brotli is actively serving | |
| 354 | + // `Content-Encoding: br` on every request. Reporting "unavailable" | |
| 355 | + // there told users who had done everything right to go install a | |
| 356 | + // module they already had. | |
| 357 | + // | |
| 358 | + // So ask the server instead of asking PHP: one cached loopback | |
| 359 | + // request with `Accept-Encoding: br`, and read what comes back. | |
| 360 | + $available = self::brotli_probe(); | |
| 361 | + } | |
| 362 | + | |
| 363 | + /** | |
| 364 | + * Filter detected Brotli availability. | |
| 365 | + * | |
| 366 | + * @param bool $available Whether Brotli serving was detected. | |
| 367 | + */ | |
| 368 | + return (bool) apply_filters( 'xspeed_brotli_available', $available ); | |
| 369 | + } | |
| 370 | + | |
| 371 | + /** | |
| 372 | + * Ask the web server whether it serves Brotli, by requesting our own home | |
| 373 | + * URL with `Accept-Encoding: br` and reading the response encoding. | |
| 374 | + * | |
| 375 | + * The only way to answer this on nginx: the module list isn't visible to | |
| 376 | + * PHP, so introspection can't work and the request itself is the evidence. | |
| 377 | + * | |
| 378 | + * Cached in a transient — a positive result for a day (server modules | |
| 379 | + * don't come and go), a negative for an hour so someone who has just | |
| 380 | + * installed ngx_brotli isn't told "no" until tomorrow. Failures cache | |
| 381 | + * briefly too, so a host that hangs on loopback self-requests can't turn | |
| 382 | + * every dashboard load into a timeout. | |
| 383 | + * | |
| 384 | + * @param bool $force Skip the cache and re-probe. | |
| 385 | + */ | |
| 386 | + public static function brotli_probe( bool $force = false ): bool { | |
| 387 | + return 'yes' === self::brotli_probe_state( $force ); | |
| 388 | + } | |
| 389 | + | |
| 390 | + /** | |
| 391 | + * The probe's three-way answer: 'yes', 'no', or 'unknown'. | |
| 392 | + * | |
| 393 | + * brotli_probe() collapses this to a bool because every consumer wants | |
| 394 | + * one, but the distinction matters for what we TELL the user. | |
| 395 | + * "unknown" — a blocked or failing loopback — is not evidence that the | |
| 396 | + * server lacks Brotli, and reporting it as "no" would repeat the original | |
| 397 | + * bug in a new place: telling someone whose setup is fine that it isn't. | |
| 398 | + * | |
| 399 | + * @param bool $force Skip the cache and re-probe. | |
| 400 | + * @return string 'yes' | 'no' | 'unknown' | |
| 401 | + */ | |
| 402 | + public static function brotli_probe_state( bool $force = false ): string { | |
| 403 | + $key = 'xspeed_brotli_probe'; | |
| 404 | + | |
| 405 | + if ( ! $force ) { | |
| 406 | + $cached = get_transient( $key ); | |
| 407 | + if ( false !== $cached ) { | |
| 408 | + $cached = (string) $cached; | |
| 409 | + // Legacy '1'/'0' values from an earlier cache format. | |
| 410 | + if ( '1' === $cached ) { | |
| 411 | + return 'yes'; | |
| 412 | + } | |
| 413 | + if ( '0' === $cached ) { | |
| 414 | + return 'no'; | |
| 415 | + } | |
| 416 | + return in_array( $cached, array( 'yes', 'no', 'unknown' ), true ) ? $cached : 'unknown'; | |
| 417 | + } | |
| 418 | + } | |
| 419 | + | |
| 420 | + $url = home_url( '/' ); | |
| 421 | + if ( ! function_exists( 'wp_remote_get' ) || '' === $url ) { | |
| 422 | + return 'unknown'; | |
| 423 | + } | |
| 424 | + | |
| 425 | + // Stampede guard. On a cold transient every concurrent dashboard load | |
| 426 | + // would otherwise fire its own 3s loopback request, because nothing | |
| 427 | + // was written until the response came back. Claim the slot BEFORE the | |
| 428 | + // request so the other callers answer 'unknown' (accurate — they | |
| 429 | + // genuinely don't know yet) rather than piling on. | |
| 430 | + $inflight = $key . '_inflight'; | |
| 431 | + if ( ! $force && false !== get_transient( $inflight ) ) { | |
| 432 | + return 'unknown'; | |
| 433 | + } | |
| 434 | + set_transient( $inflight, 1, 30 ); | |
| 435 | + | |
| 436 | + // Mirror Cache::probe_static_rewrite()'s posture: short timeout so a | |
| 437 | + // blocked loopback can't stall the caller, and relax cert verification | |
| 438 | + // only in local/dev where self-signed certs are normal. | |
| 439 | + $is_local = function_exists( 'wp_get_environment_type' ) | |
| 440 | + && in_array( wp_get_environment_type(), array( 'local', 'development' ), true ); | |
| 441 | + | |
| 442 | + $resp = wp_remote_get( | |
| 443 | + $url, | |
| 444 | + array( | |
| 445 | + 'timeout' => 3, | |
| 446 | + 'sslverify' => ! $is_local, | |
| 447 | + 'redirection' => 0, | |
| 448 | + 'headers' => array( | |
| 449 | + // `br` ONLY. Offering gzip as well would let a server that | |
| 450 | + // prefers gzip answer with it and look like a brotli | |
| 451 | + // failure, which is exactly the false negative this method | |
| 452 | + // exists to remove. | |
| 453 | + 'Accept-Encoding' => 'br', | |
| 454 | + 'Cache-Control' => 'no-cache', | |
| 455 | + ), | |
| 456 | + ) | |
| 457 | + ); | |
| 458 | + | |
| 459 | + delete_transient( $inflight ); | |
| 460 | + | |
| 461 | + if ( is_wp_error( $resp ) ) { | |
| 462 | + // Can't reach ourselves. This is NOT evidence the server lacks | |
| 463 | + // Brotli — reporting it as "no" would repeat the original bug in a | |
| 464 | + // new place. Cache briefly so a hanging host doesn't cost 3s on | |
| 465 | + // every call, but re-check soon. | |
| 466 | + set_transient( $key, 'unknown', 5 * MINUTE_IN_SECONDS ); | |
| 467 | + return 'unknown'; | |
| 468 | + } | |
| 469 | + | |
| 470 | + // A non-2xx answer tells us nothing about compression: basic auth | |
| 471 | + // (401), maintenance mode (503) and WAF challenge pages are all | |
| 472 | + // "couldn't check", not "no module". Caching 'no' for an hour on the | |
| 473 | + // strength of one is the same category error this method fixes. | |
| 474 | + $code = (int) wp_remote_retrieve_response_code( $resp ); | |
| 475 | + if ( $code < 200 || $code >= 300 ) { | |
| 476 | + set_transient( $key, 'unknown', 5 * MINUTE_IN_SECONDS ); | |
| 477 | + return 'unknown'; | |
| 478 | + } | |
| 479 | + | |
| 480 | + // A CDN or reverse proxy in front of the origin compresses on its own | |
| 481 | + // behalf, so `content-encoding: br` would describe the EDGE, not this | |
| 482 | + // server. On Apache/LiteSpeed that is harmless (brotli_available() | |
| 483 | + // short-circuits before consulting the probe), but on nginx the probe | |
| 484 | + // IS the answer — and a large share of nginx sites sit behind | |
| 485 | + // Cloudflare, Fastly or a load balancer. Asserting 'yes' there is the | |
| 486 | + // mirror image of the false negative this method exists to remove, so | |
| 487 | + // we answer 'unknown': we genuinely could not observe the origin. | |
| 488 | + if ( self::response_came_through_proxy( $resp ) ) { | |
| 489 | + set_transient( $key, 'unknown', HOUR_IN_SECONDS ); | |
| 490 | + return 'unknown'; | |
| 491 | + } | |
| 492 | + | |
| 493 | + $encoding = wp_remote_retrieve_header( $resp, 'content-encoding' ); | |
| 494 | + if ( is_array( $encoding ) ) { | |
| 495 | + $encoding = implode( ',', $encoding ); | |
| 496 | + } | |
| 497 | + $serves_brotli = false !== stripos( (string) $encoding, 'br' ); | |
| 498 | + | |
| 499 | + // A positive is durable (server modules don't come and go); a negative | |
| 500 | + // expires sooner so someone who has just installed ngx_brotli isn't | |
| 501 | + // told "no" until tomorrow. | |
| 502 | + $state = $serves_brotli ? 'yes' : 'no'; | |
| 503 | + set_transient( $key, $state, $serves_brotli ? DAY_IN_SECONDS : HOUR_IN_SECONDS ); | |
| 504 | + | |
| 505 | + return $state; | |
| 506 | + } | |
| 507 | + | |
| 508 | + /** | |
| 509 | + * Did this response come back through a CDN / reverse proxy rather than | |
| 510 | + * straight from our own web server? | |
| 511 | + * | |
| 512 | + * home_url() resolves through public DNS, so the request can leave the | |
| 513 | + * box entirely and be answered at an edge. These headers are the evidence | |
| 514 | + * the edge leaves behind; none of them are set by a plain origin. | |
| 515 | + * | |
| 516 | + * Deliberately conservative — a false "there's a proxy" costs a user the | |
| 517 | + * capability assertion and shows the 'unknown' copy, while a false "no | |
| 518 | + * proxy" tells an nginx user Brotli is on when their origin cannot serve | |
| 519 | + * it. Cache::probe_static_rewrite() shares this blind spot, which is why | |
| 520 | + * this is a public helper rather than inline. | |
| 521 | + * | |
| 522 | + * @param array|\WP_Error $resp Response from wp_remote_get(). | |
| 523 | + */ | |
| 524 | + public static function response_came_through_proxy( $resp ): bool { | |
| 525 | + if ( is_wp_error( $resp ) ) { | |
| 526 | + return false; | |
| 527 | + } | |
| 528 | + | |
| 529 | + // Headers whose mere presence means an intermediary handled this. | |
| 530 | + foreach ( array( 'cf-ray', 'x-served-by', 'x-cache', 'via', 'x-varnish', 'fastly-io-info', 'x-amz-cf-id', 'x-akamai-transformed', 'x-sucuri-id' ) as $header ) { | |
| 531 | + $value = wp_remote_retrieve_header( $resp, $header ); | |
| 532 | + if ( is_array( $value ) ) { | |
| 533 | + $value = implode( ',', $value ); | |
| 534 | + } | |
| 535 | + if ( '' !== (string) $value ) { | |
| 536 | + return true; | |
| 537 | + } | |
| 538 | + } | |
| 539 | + | |
| 540 | + // `server:` naming a known edge. Checked by substring because these | |
| 541 | + // arrive as `cloudflare`, `Sucuri/Cloudproxy`, `AkamaiGHost`, etc. | |
| 542 | + $server = wp_remote_retrieve_header( $resp, 'server' ); | |
| 543 | + if ( is_array( $server ) ) { | |
| 544 | + $server = implode( ',', $server ); | |
| 545 | + } | |
| 546 | + $server = strtolower( (string) $server ); | |
| 547 | + foreach ( array( 'cloudflare', 'cloudfront', 'akamai', 'fastly', 'sucuri', 'incapsula', 'stackpath', 'bunnycdn', 'keycdn' ) as $needle ) { | |
| 548 | + if ( false !== strpos( $server, $needle ) ) { | |
| 549 | + return true; | |
| 550 | + } | |
| 551 | + } | |
| 552 | + | |
| 553 | + return (bool) apply_filters( 'xspeed_response_came_through_proxy', false, $resp ); | |
| 554 | + } | |
| 555 | + | |
| 556 | + /** | |
| 557 | + * Drop the cached Brotli probe result so the next call re-checks. | |
| 558 | + * | |
| 559 | + * Without this a user who installs ngx_brotli has no way to make the | |
| 560 | + * dashboard notice before the transient expires — the same gap | |
| 561 | + * Cache::recheck_static_rewrite() exists to close. | |
| 562 | + */ | |
| 563 | + public static function recheck_brotli(): bool { | |
| 564 | + delete_transient( 'xspeed_brotli_probe' ); | |
| 565 | + return self::brotli_probe( true ); | |
| 566 | + } | |
| 567 | + | |
| 568 | + /** | |
| 102 | 569 | * Is WordPress running inside a container (Docker / Podman / k8s)? |
| 103 | 570 | * |
| 104 | 571 | * Three signals checked in cheapness order, OR'd together: |
| 105 | 572 | * 1. /.dockerenv exists — Docker's traditional marker; rare absence. |
| @@ -117,18 +584,26 @@ | ||
| 117 | 584 | if ( file_exists( '/.dockerenv' ) ) { |
| 118 | 585 | return true; |
| 119 | 586 | } |
| 120 | 587 | // 2. mountinfo overlay2 / containerd footprint — works under cgroup v2. |
| 121 | - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- /proc/self/mountinfo is a virtual file; WP_Filesystem doesn't model /proc. | |
| 122 | - $mounts = @file_get_contents( '/proc/self/mountinfo' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- missing /proc on non-Linux hosts is the negative answer. | |
| 123 | - if ( is_string( $mounts ) && '' !== $mounts && preg_match( '#(docker/overlay2|/var/lib/containerd|/var/lib/podman)#i', $mounts ) ) { | |
| 124 | - return true; | |
| 588 | + // Gate on is_readable() first: on non-Linux hosts (macOS/Windows) or | |
| 589 | + // hosts that hide /proc (open_basedir, hardened Apache), the file is | |
| 590 | + // absent and reading it would emit a warning. Query Monitor surfaces | |
| 591 | + // even @-suppressed warnings, so guard rather than silence. (FBS-83114) | |
| 592 | + if ( is_readable( '/proc/self/mountinfo' ) ) { | |
| 593 | + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- /proc/self/mountinfo is a virtual file; WP_Filesystem doesn't model /proc. | |
| 594 | + $mounts = file_get_contents( '/proc/self/mountinfo' ); | |
| 595 | + if ( is_string( $mounts ) && '' !== $mounts && preg_match( '#(docker/overlay2|/var/lib/containerd|/var/lib/podman)#i', $mounts ) ) { | |
| 596 | + return true; | |
| 597 | + } | |
| 125 | 598 | } |
| 126 | 599 | // 3. cgroup v1 fallback. |
| 127 | - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- See above. | |
| 128 | - $cgroup = @file_get_contents( '/proc/1/cgroup' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- See above. | |
| 129 | - if ( is_string( $cgroup ) && '' !== $cgroup && preg_match( '#(docker|kubepods|containerd|podman|lxc)#i', $cgroup ) ) { | |
| 130 | - return true; | |
| 600 | + if ( is_readable( '/proc/1/cgroup' ) ) { | |
| 601 | + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- See above. | |
| 602 | + $cgroup = file_get_contents( '/proc/1/cgroup' ); | |
| 603 | + if ( is_string( $cgroup ) && '' !== $cgroup && preg_match( '#(docker|kubepods|containerd|podman|lxc)#i', $cgroup ) ) { | |
| 604 | + return true; | |
| 605 | + } | |
| 131 | 606 | } |
| 132 | 607 | return false; |
| 133 | 608 | } |
| 134 | 609 | |
| @@ -197,18 +672,34 @@ | ||
| 197 | 672 | : ''; |
| 198 | 673 | } |
| 199 | 674 | |
| 200 | 675 | /** |
| 201 | - * Detect active caching plugins that would conflict with xSpeed. Returns | |
| 202 | - * a list of human-readable labels for any conflicting plugin currently | |
| 203 | - * active; empty array means the field is clear. Used by the onboarding | |
| 204 | - * wizard's Step 1 health check and (Phase 2.1) the main dashboard's | |
| 205 | - * Health card. | |
| 676 | + * Active PAGE-CACHING plugins that would fight xSpeed over the cache | |
| 677 | + * drop-in. Returns human-readable labels; an empty array means the field | |
| 678 | + * is clear. Used by the onboarding wizard's Step 1 health check and the | |
| 679 | + * dashboard's Health card, both of which tell the user to deactivate what | |
| 680 | + * is listed "to avoid double-caching". | |
| 206 | 681 | * |
| 207 | - * The detection key is the plugin's main file path relative to the | |
| 208 | - * plugins directory — the same value WordPress uses internally in | |
| 209 | - * `active_plugins`. Folder-only checks (`is_plugin_active('foo/')`) | |
| 210 | - * would false-positive on disabled plugins still on disk. | |
| 682 | + * Which is why the list is filtered on the page-cache capability rather | |
| 683 | + * than "is it a performance plugin": Autoptimize only minifies, so naming | |
| 684 | + * it here made the health row give advice that was flatly wrong. | |
| 685 | + * Minification overlap is still caught — by Conflict_Registry, per feature. | |
| 686 | + * | |
| 687 | + * The detection key is the plugin's main file path relative to the plugins | |
| 688 | + * directory — the same value WordPress uses internally in `active_plugins`. | |
| 689 | + * Folder-only checks (`is_plugin_active('foo/')`) would false-positive on | |
| 690 | + * disabled plugins still on disk. | |
| 691 | + * | |
| 692 | + * Membership comes from Cache_Plugin_Catalog, so a plugin is added in one | |
| 693 | + * place and shows up in both this list and the conflict matrix. | |
| 694 | + * | |
| 695 | + * Activation is not the whole test, though. What actually stops xSpeed | |
| 696 | + * enabling its cache is who holds advanced-cache.php, and a drop-in left | |
| 697 | + * behind by an uninstalled plugin holds it just as firmly as a running | |
| 698 | + * one. Checking only active_plugins let the wizard say "No other caching | |
| 699 | + * plugins detected" on the environment step and then refuse the enable on | |
| 700 | + * the very next step, for a file it had just looked past. So a foreign | |
| 701 | + * drop-in is listed too, named where we can name it. | |
| 211 | 702 | */ |
| 212 | 703 | public static function conflicts() { |
| 213 | 704 | if ( ! function_exists( 'is_plugin_active' ) ) { |
| 214 | 705 | require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| @@ -213,29 +704,53 @@ | ||
| 213 | 704 | if ( ! function_exists( 'is_plugin_active' ) ) { |
| 214 | 705 | require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 215 | 706 | } |
| 216 | 707 | |
| 217 | - $known = array( | |
| 218 | - 'wp-rocket/wp-rocket.php' => 'WP Rocket', | |
| 219 | - 'w3-total-cache/w3-total-cache.php' => 'W3 Total Cache', | |
| 220 | - 'wp-super-cache/wp-cache.php' => 'WP Super Cache', | |
| 221 | - 'wp-fastest-cache/wpFastestCache.php' => 'WP Fastest Cache', | |
| 222 | - 'litespeed-cache/litespeed-cache.php' => 'LiteSpeed Cache', | |
| 223 | - 'cache-enabler/cache-enabler.php' => 'Cache Enabler', | |
| 224 | - 'comet-cache/comet-cache.php' => 'Comet Cache', | |
| 225 | - 'hummingbird-performance/wp-hummingbird.php' => 'Hummingbird', | |
| 226 | - 'sg-cachepress/sg-cachepress.php' => 'SG Optimizer', | |
| 227 | - 'breeze/breeze.php' => 'Breeze', | |
| 228 | - 'autoptimize/autoptimize.php' => 'Autoptimize', | |
| 229 | - 'flying-press/flying-press.php' => 'FlyingPress', | |
| 230 | - 'nitropack/main.php' => 'NitroPack', | |
| 231 | - ); | |
| 232 | - | |
| 233 | - $active = array(); | |
| 234 | - foreach ( $known as $file => $label ) { | |
| 708 | + $found = array(); | |
| 709 | + foreach ( Cache_Plugin_Catalog::with_capability( Cache_Plugin_Catalog::CAP_PAGE_CACHE ) as $file => $entry ) { | |
| 710 | + // xSpeed is in the catalog — it is a page cache, and the detector | |
| 711 | + // needs to be able to name our own drop-in. It is not a conflict | |
| 712 | + // with itself, and listing it told every site running us to | |
| 713 | + // deactivate us to avoid double-caching. | |
| 714 | + if ( 'xspeed/xspeed.php' === $file ) { | |
| 715 | + continue; | |
| 716 | + } | |
| 235 | 717 | if ( is_plugin_active( $file ) ) { |
| 236 | - $active[] = $label; | |
| 718 | + $found[] = $entry['label']; | |
| 237 | 719 | } |
| 238 | 720 | } |
| 239 | - return $active; | |
| 721 | + | |
| 722 | + $dropin = self::foreign_dropin_label(); | |
| 723 | + if ( null !== $dropin && ! in_array( $dropin, $found, true ) ) { | |
| 724 | + $found[] = $dropin; | |
| 725 | + } | |
| 726 | + | |
| 727 | + return array_values( array_unique( $found ) ); | |
| 728 | + } | |
| 729 | + | |
| 730 | + /** | |
| 731 | + * The name of whoever owns advanced-cache.php, when it is not xSpeed. | |
| 732 | + * | |
| 733 | + * Null when the file is absent or ours. An owner we cannot identify still | |
| 734 | + * blocks the enable, so it is reported under a generic name rather than | |
| 735 | + * being silently dropped — "we could not tell" and "there is nothing | |
| 736 | + * there" are different answers. | |
| 737 | + */ | |
| 738 | + private static function foreign_dropin_label(): ?string { | |
| 739 | + $owner = Cache::dropin_owner(); | |
| 740 | + if ( Cache::DROPIN_XSPEED === $owner || Cache::DROPIN_NONE === $owner ) { | |
| 741 | + return null; | |
| 742 | + } | |
| 743 | + if ( Cache::DROPIN_UNREADABLE === $owner ) { | |
| 744 | + return __( 'an unreadable advanced-cache.php', 'xspeed' ); | |
| 745 | + } | |
| 746 | + | |
| 747 | + $contents = @file_get_contents( WP_CONTENT_DIR . '/advanced-cache.php' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.PHP.NoSilencedErrors.Discouraged -- read-only inspection of a drop-in we may not own; failure is reported as an unidentified owner. | |
| 748 | + $named = is_string( $contents ) ? Cache_Plugin_Catalog::identify_dropin( $contents ) : null; | |
| 749 | + if ( null !== $named ) { | |
| 750 | + $entry = Cache_Plugin_Catalog::get( $named ); | |
| 751 | + return (string) ( $entry['label'] ?? $named ); | |
| 752 | + } | |
| 753 | + | |
| 754 | + return __( 'an unidentified advanced-cache.php', 'xspeed' ); | |
| 240 | 755 | } |
| 241 | 756 | } |