PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.0
1.3.1 1.3.0 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 All 27 releases
xspeed / includes / class-server.php

class-server.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.0, 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