| 1 |
<?php |
| 2 |
/** |
| 3 |
* Server / SAPI detection. |
| 4 |
* |
| 5 |
* Used by Gzip and the UI to decide which optimizations are server-applied |
| 6 |
* (Apache / LiteSpeed via .htaccess) vs. require manual config (nginx). |
| 7 |
* |
| 8 |
* @package XSpeed |
| 9 |
*/ |
| 10 |
|
| 11 |
namespace XSpeed; |
| 12 |
|
| 13 |
defined( 'ABSPATH' ) || exit; |
| 14 |
|
| 15 |
class Server { |
| 16 |
|
| 17 |
const APACHE = 'apache'; |
| 18 |
const LITESPEED = 'litespeed'; |
| 19 |
const NGINX = 'nginx'; |
| 20 |
const IIS = 'iis'; |
| 21 |
const UNKNOWN = 'unknown'; |
| 22 |
|
| 23 |
const OPT_CACHED_TYPE = 'xspeed_server_type'; |
| 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 |
|
| 33 |
public static function type() { |
| 34 |
$detected = self::detect(); |
| 35 |
if ( self::UNKNOWN !== $detected ) { |
| 36 |
// Persist whenever we have a real answer so future CLI / |
| 37 |
// cron / REST calls (where SERVER_SOFTWARE may be empty) |
| 38 |
// inherit it. Non-autoloaded — only read when needed. |
| 39 |
$cached = get_option( self::OPT_CACHED_TYPE, null ); |
| 40 |
if ( $cached !== $detected ) { |
| 41 |
update_option( self::OPT_CACHED_TYPE, $detected, false ); |
| 42 |
} |
| 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; |
| 50 |
} |
| 51 |
|
| 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 ); |
| 71 |
} |
| 72 |
|
| 73 |
/** |
| 74 |
* Live detection — never reads the cache. Used by type() and by |
| 75 |
* any caller that explicitly wants the current-request answer |
| 76 |
* (e.g. diagnostic UI showing "detected this request"). |
| 77 |
* |
| 78 |
* We DO NOT fall back to "if .htaccess exists assume Apache" here: |
| 79 |
* Cache::install_rewrite() writes .htaccess itself, so on nginx |
| 80 |
* hosts the file appears after first cache toggle and a presence |
| 81 |
* check then flips us to APACHE forever. Cached HTTP detection |
| 82 |
* is the cleaner backstop. |
| 83 |
*/ |
| 84 |
public static function detect(): string { |
| 85 |
global $is_apache, $is_nginx, $is_IIS, $is_iis7; |
| 86 |
|
| 87 |
$signature = self::server_signature(); |
| 88 |
|
| 89 |
if ( false !== stripos( $signature, 'litespeed' ) ) { |
| 90 |
return self::LITESPEED; |
| 91 |
} |
| 92 |
// apache_get_modules() exists only with mod_php (not FPM), so |
| 93 |
// gate it behind SERVER_SOFTWARE first. Otherwise an |
| 94 |
// "apache_get_modules exists" check would false-positive on a |
| 95 |
// few PHP-builtin-server / mod_php-on-localhost dev edge cases. |
| 96 |
if ( false !== stripos( $signature, 'apache' ) || ! empty( $is_apache ) ) { |
| 97 |
return self::APACHE; |
| 98 |
} |
| 99 |
if ( false !== stripos( $signature, 'nginx' ) || ! empty( $is_nginx ) ) { |
| 100 |
return self::NGINX; |
| 101 |
} |
| 102 |
if ( false !== stripos( $signature, 'microsoft-iis' ) || ! empty( $is_IIS ) || ! empty( $is_iis7 ) ) { |
| 103 |
return self::IIS; |
| 104 |
} |
| 105 |
return self::UNKNOWN; |
| 106 |
} |
| 107 |
|
| 108 |
/** |
| 109 |
* Whether the server respects .htaccess / web.config-style file-based config. |
| 110 |
*/ |
| 111 |
public static function supports_htaccess() { |
| 112 |
$t = self::type(); |
| 113 |
return self::APACHE === $t || self::LITESPEED === $t; |
| 114 |
} |
| 115 |
|
| 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 |
/** |
| 312 |
* GZIP support category for the UI: |
| 313 |
* 'auto' — toggling writes server config (Apache / LiteSpeed) |
| 314 |
* 'manual' — must be configured outside the plugin (nginx, IIS, unknown) |
| 315 |
*/ |
| 316 |
public static function gzip_mode() { |
| 317 |
return self::supports_htaccess() ? 'auto' : 'manual'; |
| 318 |
} |
| 319 |
|
| 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 |
/** |
| 569 |
* Is WordPress running inside a container (Docker / Podman / k8s)? |
| 570 |
* |
| 571 |
* Three signals checked in cheapness order, OR'd together: |
| 572 |
* 1. /.dockerenv exists — Docker's traditional marker; rare absence. |
| 573 |
* 2. /proc/self/mountinfo references /var/lib/docker/overlay2 or |
| 574 |
* containerd/podman storage drivers — works under cgroup v2. |
| 575 |
* 3. /proc/1/cgroup names docker / kubepods / containerd / podman / lxc |
| 576 |
* — the cgroup v1 signal, still present on older Docker installs. |
| 577 |
* |
| 578 |
* Any single positive returns true. On non-Linux hosts (Windows / |
| 579 |
* macOS / WSL host process), all three quietly return false and we |
| 580 |
* fall back to "not containerized." |
| 581 |
*/ |
| 582 |
public static function is_containerized(): bool { |
| 583 |
// 1. Docker marker file — cheap to stat, almost always present. |
| 584 |
if ( file_exists( '/.dockerenv' ) ) { |
| 585 |
return true; |
| 586 |
} |
| 587 |
// 2. mountinfo overlay2 / containerd footprint — works under cgroup v2. |
| 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 |
} |
| 598 |
} |
| 599 |
// 3. cgroup v1 fallback. |
| 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 |
} |
| 606 |
} |
| 607 |
return false; |
| 608 |
} |
| 609 |
|
| 610 |
/** |
| 611 |
* Is WordPress likely behind a reverse proxy (host nginx → container |
| 612 |
* php-fpm, host nginx → docker nginx, etc.)? Detection is a heuristic |
| 613 |
* built from the headers WordPress hands to PHP: when a proxy forwards |
| 614 |
* the request it almost always sets X-Forwarded-* or X-Real-IP. |
| 615 |
* |
| 616 |
* False positives (CDN-only forwarding without a local reverse proxy) |
| 617 |
* are acceptable — the caller uses this signal only to soften messaging |
| 618 |
* that would otherwise mislead container-host customers. False negatives |
| 619 |
* (proxy that strips headers) just mean we keep showing the snippet |
| 620 |
* paste UX, which is the safe default. |
| 621 |
*/ |
| 622 |
public static function is_behind_proxy(): bool { |
| 623 |
$proxy_headers = array( 'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED_HOST', 'HTTP_X_FORWARDED_PROTO', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_SERVER' ); |
| 624 |
foreach ( $proxy_headers as $h ) { |
| 625 |
if ( ! empty( $_SERVER[ $h ] ) ) { |
| 626 |
return true; |
| 627 |
} |
| 628 |
} |
| 629 |
return false; |
| 630 |
} |
| 631 |
|
| 632 |
/** |
| 633 |
* High-level topology classifier driving the rewrite-alert UX. |
| 634 |
* |
| 635 |
* Decides WHERE the user's nginx snippet needs to be installed |
| 636 |
* (or whether automatic install via .htaccess covers it). The |
| 637 |
* dashboard banner uses the return value to render the right |
| 638 |
* "paste this here" message — there is no topology that can't |
| 639 |
* benefit from xSpeed's static-rewrite; the question is only |
| 640 |
* which nginx is in the cache file's filesystem. |
| 641 |
* |
| 642 |
* Returns one of: |
| 643 |
* 'htaccess' — Apache / LiteSpeed; .htaccess block is |
| 644 |
* installed automatically, no user action. |
| 645 |
* 'nginx-host' — self-managed nginx on the host (no |
| 646 |
* container in the request path). User |
| 647 |
* pastes the snippet into their vhost |
| 648 |
* (typically /etc/nginx/sites-enabled/<site>). |
| 649 |
* 'nginx-container' — nginx running inside the same container |
| 650 |
* as PHP. User pastes the snippet into the |
| 651 |
* container's nginx config (typically |
| 652 |
* docker/nginx.conf in the site's |
| 653 |
* docker-compose dir). Host nginx (if any) |
| 654 |
* is a reverse-proxy that just forwards |
| 655 |
* bytes — snippet does NOT go there. |
| 656 |
* 'unknown' — IIS or undetected; treat as manual. |
| 657 |
*/ |
| 658 |
public static function rewrite_topology(): string { |
| 659 |
$type = self::type(); |
| 660 |
if ( self::APACHE === $type || self::LITESPEED === $type ) { |
| 661 |
return 'htaccess'; |
| 662 |
} |
| 663 |
if ( self::NGINX === $type ) { |
| 664 |
return self::is_containerized() ? 'nginx-container' : 'nginx-host'; |
| 665 |
} |
| 666 |
return 'unknown'; |
| 667 |
} |
| 668 |
|
| 669 |
private static function server_signature() { |
| 670 |
return isset( $_SERVER['SERVER_SOFTWARE'] ) |
| 671 |
? sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) ) |
| 672 |
: ''; |
| 673 |
} |
| 674 |
|
| 675 |
/** |
| 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". |
| 681 |
* |
| 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. |
| 702 |
*/ |
| 703 |
public static function conflicts() { |
| 704 |
if ( ! function_exists( 'is_plugin_active' ) ) { |
| 705 |
require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 706 |
} |
| 707 |
|
| 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 |
} |
| 717 |
if ( is_plugin_active( $file ) ) { |
| 718 |
$found[] = $entry['label']; |
| 719 |
} |
| 720 |
} |
| 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' ); |
| 755 |
} |
| 756 |
} |
| 757 |
|