PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.7
1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 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 All 33 releases
xspeed / includes / class-server.php

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

759 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' => Self_Traffic::headers(
449 array(
450 // `br` ONLY. Offering gzip as well would let a server that
451 // prefers gzip answer with it and look like a brotli
452 // failure, which is exactly the false negative this method
453 // exists to remove.
454 'Accept-Encoding' => 'br',
455 'Cache-Control' => 'no-cache',
456 )
457 ),
458 )
459 );
460
461 delete_transient( $inflight );
462
463 if ( is_wp_error( $resp ) ) {
464 // Can't reach ourselves. This is NOT evidence the server lacks
465 // Brotli — reporting it as "no" would repeat the original bug in a
466 // new place. Cache briefly so a hanging host doesn't cost 3s on
467 // every call, but re-check soon.
468 set_transient( $key, 'unknown', 5 * MINUTE_IN_SECONDS );
469 return 'unknown';
470 }
471
472 // A non-2xx answer tells us nothing about compression: basic auth
473 // (401), maintenance mode (503) and WAF challenge pages are all
474 // "couldn't check", not "no module". Caching 'no' for an hour on the
475 // strength of one is the same category error this method fixes.
476 $code = (int) wp_remote_retrieve_response_code( $resp );
477 if ( $code < 200 || $code >= 300 ) {
478 set_transient( $key, 'unknown', 5 * MINUTE_IN_SECONDS );
479 return 'unknown';
480 }
481
482 // A CDN or reverse proxy in front of the origin compresses on its own
483 // behalf, so `content-encoding: br` would describe the EDGE, not this
484 // server. On Apache/LiteSpeed that is harmless (brotli_available()
485 // short-circuits before consulting the probe), but on nginx the probe
486 // IS the answer — and a large share of nginx sites sit behind
487 // Cloudflare, Fastly or a load balancer. Asserting 'yes' there is the
488 // mirror image of the false negative this method exists to remove, so
489 // we answer 'unknown': we genuinely could not observe the origin.
490 if ( self::response_came_through_proxy( $resp ) ) {
491 set_transient( $key, 'unknown', HOUR_IN_SECONDS );
492 return 'unknown';
493 }
494
495 $encoding = wp_remote_retrieve_header( $resp, 'content-encoding' );
496 if ( is_array( $encoding ) ) {
497 $encoding = implode( ',', $encoding );
498 }
499 $serves_brotli = false !== stripos( (string) $encoding, 'br' );
500
501 // A positive is durable (server modules don't come and go); a negative
502 // expires sooner so someone who has just installed ngx_brotli isn't
503 // told "no" until tomorrow.
504 $state = $serves_brotli ? 'yes' : 'no';
505 set_transient( $key, $state, $serves_brotli ? DAY_IN_SECONDS : HOUR_IN_SECONDS );
506
507 return $state;
508 }
509
510 /**
511 * Did this response come back through a CDN / reverse proxy rather than
512 * straight from our own web server?
513 *
514 * home_url() resolves through public DNS, so the request can leave the
515 * box entirely and be answered at an edge. These headers are the evidence
516 * the edge leaves behind; none of them are set by a plain origin.
517 *
518 * Deliberately conservative — a false "there's a proxy" costs a user the
519 * capability assertion and shows the 'unknown' copy, while a false "no
520 * proxy" tells an nginx user Brotli is on when their origin cannot serve
521 * it. Cache::probe_static_rewrite() shares this blind spot, which is why
522 * this is a public helper rather than inline.
523 *
524 * @param array|\WP_Error $resp Response from wp_remote_get().
525 */
526 public static function response_came_through_proxy( $resp ): bool {
527 if ( is_wp_error( $resp ) ) {
528 return false;
529 }
530
531 // Headers whose mere presence means an intermediary handled this.
532 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 ) {
533 $value = wp_remote_retrieve_header( $resp, $header );
534 if ( is_array( $value ) ) {
535 $value = implode( ',', $value );
536 }
537 if ( '' !== (string) $value ) {
538 return true;
539 }
540 }
541
542 // `server:` naming a known edge. Checked by substring because these
543 // arrive as `cloudflare`, `Sucuri/Cloudproxy`, `AkamaiGHost`, etc.
544 $server = wp_remote_retrieve_header( $resp, 'server' );
545 if ( is_array( $server ) ) {
546 $server = implode( ',', $server );
547 }
548 $server = strtolower( (string) $server );
549 foreach ( array( 'cloudflare', 'cloudfront', 'akamai', 'fastly', 'sucuri', 'incapsula', 'stackpath', 'bunnycdn', 'keycdn' ) as $needle ) {
550 if ( false !== strpos( $server, $needle ) ) {
551 return true;
552 }
553 }
554
555 return (bool) apply_filters( 'xspeed_response_came_through_proxy', false, $resp );
556 }
557
558 /**
559 * Drop the cached Brotli probe result so the next call re-checks.
560 *
561 * Without this a user who installs ngx_brotli has no way to make the
562 * dashboard notice before the transient expires — the same gap
563 * Cache::recheck_static_rewrite() exists to close.
564 */
565 public static function recheck_brotli(): bool {
566 delete_transient( 'xspeed_brotli_probe' );
567 return self::brotli_probe( true );
568 }
569
570 /**
571 * Is WordPress running inside a container (Docker / Podman / k8s)?
572 *
573 * Three signals checked in cheapness order, OR'd together:
574 * 1. /.dockerenv exists — Docker's traditional marker; rare absence.
575 * 2. /proc/self/mountinfo references /var/lib/docker/overlay2 or
576 * containerd/podman storage drivers — works under cgroup v2.
577 * 3. /proc/1/cgroup names docker / kubepods / containerd / podman / lxc
578 * — the cgroup v1 signal, still present on older Docker installs.
579 *
580 * Any single positive returns true. On non-Linux hosts (Windows /
581 * macOS / WSL host process), all three quietly return false and we
582 * fall back to "not containerized."
583 */
584 public static function is_containerized(): bool {
585 // 1. Docker marker file — cheap to stat, almost always present.
586 if ( file_exists( '/.dockerenv' ) ) {
587 return true;
588 }
589 // 2. mountinfo overlay2 / containerd footprint — works under cgroup v2.
590 // Gate on is_readable() first: on non-Linux hosts (macOS/Windows) or
591 // hosts that hide /proc (open_basedir, hardened Apache), the file is
592 // absent and reading it would emit a warning. Query Monitor surfaces
593 // even @-suppressed warnings, so guard rather than silence. (FBS-83114)
594 if ( is_readable( '/proc/self/mountinfo' ) ) {
595 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- /proc/self/mountinfo is a virtual file; WP_Filesystem doesn't model /proc.
596 $mounts = file_get_contents( '/proc/self/mountinfo' );
597 if ( is_string( $mounts ) && '' !== $mounts && preg_match( '#(docker/overlay2|/var/lib/containerd|/var/lib/podman)#i', $mounts ) ) {
598 return true;
599 }
600 }
601 // 3. cgroup v1 fallback.
602 if ( is_readable( '/proc/1/cgroup' ) ) {
603 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- See above.
604 $cgroup = file_get_contents( '/proc/1/cgroup' );
605 if ( is_string( $cgroup ) && '' !== $cgroup && preg_match( '#(docker|kubepods|containerd|podman|lxc)#i', $cgroup ) ) {
606 return true;
607 }
608 }
609 return false;
610 }
611
612 /**
613 * Is WordPress likely behind a reverse proxy (host nginx → container
614 * php-fpm, host nginx → docker nginx, etc.)? Detection is a heuristic
615 * built from the headers WordPress hands to PHP: when a proxy forwards
616 * the request it almost always sets X-Forwarded-* or X-Real-IP.
617 *
618 * False positives (CDN-only forwarding without a local reverse proxy)
619 * are acceptable — the caller uses this signal only to soften messaging
620 * that would otherwise mislead container-host customers. False negatives
621 * (proxy that strips headers) just mean we keep showing the snippet
622 * paste UX, which is the safe default.
623 */
624 public static function is_behind_proxy(): bool {
625 $proxy_headers = array( 'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED_HOST', 'HTTP_X_FORWARDED_PROTO', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_SERVER' );
626 foreach ( $proxy_headers as $h ) {
627 if ( ! empty( $_SERVER[ $h ] ) ) {
628 return true;
629 }
630 }
631 return false;
632 }
633
634 /**
635 * High-level topology classifier driving the rewrite-alert UX.
636 *
637 * Decides WHERE the user's nginx snippet needs to be installed
638 * (or whether automatic install via .htaccess covers it). The
639 * dashboard banner uses the return value to render the right
640 * "paste this here" message — there is no topology that can't
641 * benefit from xSpeed's static-rewrite; the question is only
642 * which nginx is in the cache file's filesystem.
643 *
644 * Returns one of:
645 * 'htaccess' — Apache / LiteSpeed; .htaccess block is
646 * installed automatically, no user action.
647 * 'nginx-host' — self-managed nginx on the host (no
648 * container in the request path). User
649 * pastes the snippet into their vhost
650 * (typically /etc/nginx/sites-enabled/<site>).
651 * 'nginx-container' — nginx running inside the same container
652 * as PHP. User pastes the snippet into the
653 * container's nginx config (typically
654 * docker/nginx.conf in the site's
655 * docker-compose dir). Host nginx (if any)
656 * is a reverse-proxy that just forwards
657 * bytes — snippet does NOT go there.
658 * 'unknown' — IIS or undetected; treat as manual.
659 */
660 public static function rewrite_topology(): string {
661 $type = self::type();
662 if ( self::APACHE === $type || self::LITESPEED === $type ) {
663 return 'htaccess';
664 }
665 if ( self::NGINX === $type ) {
666 return self::is_containerized() ? 'nginx-container' : 'nginx-host';
667 }
668 return 'unknown';
669 }
670
671 private static function server_signature() {
672 return isset( $_SERVER['SERVER_SOFTWARE'] )
673 ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) )
674 : '';
675 }
676
677 /**
678 * Active PAGE-CACHING plugins that would fight xSpeed over the cache
679 * drop-in. Returns human-readable labels; an empty array means the field
680 * is clear. Used by the onboarding wizard's Step 1 health check and the
681 * dashboard's Health card, both of which tell the user to deactivate what
682 * is listed "to avoid double-caching".
683 *
684 * Which is why the list is filtered on the page-cache capability rather
685 * than "is it a performance plugin": Autoptimize only minifies, so naming
686 * it here made the health row give advice that was flatly wrong.
687 * Minification overlap is still caught — by Conflict_Registry, per feature.
688 *
689 * The detection key is the plugin's main file path relative to the plugins
690 * directory — the same value WordPress uses internally in `active_plugins`.
691 * Folder-only checks (`is_plugin_active('foo/')`) would false-positive on
692 * disabled plugins still on disk.
693 *
694 * Membership comes from Cache_Plugin_Catalog, so a plugin is added in one
695 * place and shows up in both this list and the conflict matrix.
696 *
697 * Activation is not the whole test, though. What actually stops xSpeed
698 * enabling its cache is who holds advanced-cache.php, and a drop-in left
699 * behind by an uninstalled plugin holds it just as firmly as a running
700 * one. Checking only active_plugins let the wizard say "No other caching
701 * plugins detected" on the environment step and then refuse the enable on
702 * the very next step, for a file it had just looked past. So a foreign
703 * drop-in is listed too, named where we can name it.
704 */
705 public static function conflicts() {
706 if ( ! function_exists( 'is_plugin_active' ) ) {
707 require_once ABSPATH . 'wp-admin/includes/plugin.php';
708 }
709
710 $found = array();
711 foreach ( Cache_Plugin_Catalog::with_capability( Cache_Plugin_Catalog::CAP_PAGE_CACHE ) as $file => $entry ) {
712 // xSpeed is in the catalog — it is a page cache, and the detector
713 // needs to be able to name our own drop-in. It is not a conflict
714 // with itself, and listing it told every site running us to
715 // deactivate us to avoid double-caching.
716 if ( 'xspeed/xspeed.php' === $file ) {
717 continue;
718 }
719 if ( is_plugin_active( $file ) ) {
720 $found[] = $entry['label'];
721 }
722 }
723
724 $dropin = self::foreign_dropin_label();
725 if ( null !== $dropin && ! in_array( $dropin, $found, true ) ) {
726 $found[] = $dropin;
727 }
728
729 return array_values( array_unique( $found ) );
730 }
731
732 /**
733 * The name of whoever owns advanced-cache.php, when it is not xSpeed.
734 *
735 * Null when the file is absent or ours. An owner we cannot identify still
736 * blocks the enable, so it is reported under a generic name rather than
737 * being silently dropped — "we could not tell" and "there is nothing
738 * there" are different answers.
739 */
740 private static function foreign_dropin_label(): ?string {
741 $owner = Cache::dropin_owner();
742 if ( Cache::DROPIN_XSPEED === $owner || Cache::DROPIN_NONE === $owner ) {
743 return null;
744 }
745 if ( Cache::DROPIN_UNREADABLE === $owner ) {
746 return __( 'an unreadable advanced-cache.php', 'xspeed' );
747 }
748
749 $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.
750 $named = is_string( $contents ) ? Cache_Plugin_Catalog::identify_dropin( $contents ) : null;
751 if ( null !== $named ) {
752 $entry = Cache_Plugin_Catalog::get( $named );
753 return (string) ( $entry['label'] ?? $named );
754 }
755
756 return __( 'an unidentified advanced-cache.php', 'xspeed' );
757 }
758 }
759