PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / trunk
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN vtrunk
1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 1.2.0 1.2.1 1.2.2 1.2.3
xspeed / includes / class-server.php

class-server.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN trunk, at includes/class-server.php

757 lines 30.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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