| @@ -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,107 @@ | ||
| 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 | + /** | |
| 93 | 216 | * Best-effort path to the web server's access log, used to count |
| 94 | 217 | * static-rewrite HITs that bypass PHP on Apache/LiteSpeed (those |
| 95 | 218 | * requests are served straight from disk and never reach our |
| 96 | 219 | * Hit_Counter inline — see Hit_Counter::collect_server_log_hits()). |
| @@ -102,19 +225,22 @@ | ||
| 102 | 225 | * 3. Auto-detection: a short list of the standard Apache/LiteSpeed |
| 103 | 226 | * access-log locations, returning the first that exists AND is |
| 104 | 227 | * readable by the PHP user. |
| 105 | 228 | * |
| 106 | - * Returns '' when nothing readable is found — a very common case on | |
| 107 | - * managed/cPanel hosts where the PHP user can't read the server log. | |
| 108 | - * Callers MUST treat '' as "can't count static hits here" and fall | |
| 109 | - * back gracefully (the drop-in path still counts its own HITs). | |
| 229 | + * Every route is funnelled through usable_access_log(), so an | |
| 230 | + * override can no more hand us a pipe than auto-detection can. | |
| 110 | 231 | * |
| 111 | - * @return string Absolute path, or '' if none is readable. | |
| 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. | |
| 112 | 239 | */ |
| 113 | 240 | public static function access_log_path(): string { |
| 114 | 241 | if ( defined( 'XSPEED_ACCESS_LOG' ) && is_string( XSPEED_ACCESS_LOG ) && '' !== XSPEED_ACCESS_LOG ) { |
| 115 | - $override = XSPEED_ACCESS_LOG; | |
| 116 | - return is_readable( $override ) ? $override : ''; | |
| 242 | + return self::usable_access_log( XSPEED_ACCESS_LOG ); | |
| 117 | 243 | } |
| 118 | 244 | |
| 119 | 245 | /** |
| 120 | 246 | * Filter: xspeed_access_log_path |
| @@ -125,9 +251,9 @@ | ||
| 125 | 251 | * @param string|null $path Null = use auto-detection below. |
| 126 | 252 | */ |
| 127 | 253 | $filtered = apply_filters( 'xspeed_access_log_path', null ); |
| 128 | 254 | if ( is_string( $filtered ) ) { |
| 129 | - return ( '' !== $filtered && is_readable( $filtered ) ) ? $filtered : ''; | |
| 255 | + return self::usable_access_log( $filtered ); | |
| 130 | 256 | } |
| 131 | 257 | |
| 132 | 258 | // Auto-detect: the standard Apache + OpenLiteSpeed/LiteSpeed |
| 133 | 259 | // Enterprise access-log locations. First readable, NON-EMPTY file |
| @@ -141,9 +267,12 @@ | ||
| 141 | 267 | '/usr/local/lsws/logs/access.log', // OpenLiteSpeed global |
| 142 | 268 | '/var/log/lshttpd/access.log', // LiteSpeed Enterprise |
| 143 | 269 | ); |
| 144 | 270 | foreach ( $candidates as $path ) { |
| 145 | - if ( is_readable( $path ) && (int) @filesize( $path ) > 0 ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- racey stat, treated as "skip". | |
| 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". | |
| 146 | 275 | return $path; |
| 147 | 276 | } |
| 148 | 277 | } |
| 149 | 278 | |
| @@ -163,9 +292,11 @@ | ||
| 163 | 292 | if ( ! is_array( $globbed ) ) { |
| 164 | 293 | continue; |
| 165 | 294 | } |
| 166 | 295 | foreach ( $globbed as $path ) { |
| 167 | - if ( ! is_readable( $path ) || (int) @filesize( $path ) === 0 ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged | |
| 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 | |
| 168 | 299 | continue; |
| 169 | 300 | } |
| 170 | 301 | $mtime = (int) @filemtime( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 171 | 302 | if ( $mtime > $best_mtime ) { |
| @@ -215,8 +346,19 @@ | ||
| 215 | 346 | } elseif ( function_exists( 'apache_get_modules' ) && in_array( 'mod_brotli', apache_get_modules(), true ) ) { |
| 216 | 347 | $available = true; |
| 217 | 348 | } elseif ( function_exists( 'brotli_compress' ) ) { |
| 218 | 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(); | |
| 219 | 361 | } |
| 220 | 362 | |
| 221 | 363 | /** |
| 222 | 364 | * Filter detected Brotli availability. |
| @@ -226,8 +368,205 @@ | ||
| 226 | 368 | return (bool) apply_filters( 'xspeed_brotli_available', $available ); |
| 227 | 369 | } |
| 228 | 370 | |
| 229 | 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 | + /** | |
| 230 | 569 | * Is WordPress running inside a container (Docker / Podman / k8s)? |
| 231 | 570 | * |
| 232 | 571 | * Three signals checked in cheapness order, OR'd together: |
| 233 | 572 | * 1. /.dockerenv exists — Docker's traditional marker; rare absence. |
| @@ -333,18 +672,34 @@ | ||
| 333 | 672 | : ''; |
| 334 | 673 | } |
| 335 | 674 | |
| 336 | 675 | /** |
| 337 | - * Detect active caching plugins that would conflict with xSpeed. Returns | |
| 338 | - * a list of human-readable labels for any conflicting plugin currently | |
| 339 | - * active; empty array means the field is clear. Used by the onboarding | |
| 340 | - * wizard's Step 1 health check and (Phase 2.1) the main dashboard's | |
| 341 | - * 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". | |
| 342 | 681 | * |
| 343 | - * The detection key is the plugin's main file path relative to the | |
| 344 | - * plugins directory — the same value WordPress uses internally in | |
| 345 | - * `active_plugins`. Folder-only checks (`is_plugin_active('foo/')`) | |
| 346 | - * 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. | |
| 347 | 702 | */ |
| 348 | 703 | public static function conflicts() { |
| 349 | 704 | if ( ! function_exists( 'is_plugin_active' ) ) { |
| 350 | 705 | require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| @@ -349,29 +704,53 @@ | ||
| 349 | 704 | if ( ! function_exists( 'is_plugin_active' ) ) { |
| 350 | 705 | require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 351 | 706 | } |
| 352 | 707 | |
| 353 | - $known = array( | |
| 354 | - 'wp-rocket/wp-rocket.php' => 'WP Rocket', | |
| 355 | - 'w3-total-cache/w3-total-cache.php' => 'W3 Total Cache', | |
| 356 | - 'wp-super-cache/wp-cache.php' => 'WP Super Cache', | |
| 357 | - 'wp-fastest-cache/wpFastestCache.php' => 'WP Fastest Cache', | |
| 358 | - 'litespeed-cache/litespeed-cache.php' => 'LiteSpeed Cache', | |
| 359 | - 'cache-enabler/cache-enabler.php' => 'Cache Enabler', | |
| 360 | - 'comet-cache/comet-cache.php' => 'Comet Cache', | |
| 361 | - 'hummingbird-performance/wp-hummingbird.php' => 'Hummingbird', | |
| 362 | - 'sg-cachepress/sg-cachepress.php' => 'SG Optimizer', | |
| 363 | - 'breeze/breeze.php' => 'Breeze', | |
| 364 | - 'autoptimize/autoptimize.php' => 'Autoptimize', | |
| 365 | - 'flying-press/flying-press.php' => 'FlyingPress', | |
| 366 | - 'nitropack/main.php' => 'NitroPack', | |
| 367 | - ); | |
| 368 | - | |
| 369 | - $active = array(); | |
| 370 | - 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 | + } | |
| 371 | 717 | if ( is_plugin_active( $file ) ) { |
| 372 | - $active[] = $label; | |
| 718 | + $found[] = $entry['label']; | |
| 373 | 719 | } |
| 374 | 720 | } |
| 375 | - 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' ); | |
| 376 | 755 | } |
| 377 | 756 | } |