PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.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 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / class-server.php

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

672 lines 26.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 public static function type() {
26 $detected = self::detect();
27 if ( self::UNKNOWN !== $detected ) {
28 // Persist whenever we have a real answer so future CLI /
29 // cron / REST calls (where SERVER_SOFTWARE may be empty)
30 // inherit it. Non-autoloaded — only read when needed.
31 $cached = get_option( self::OPT_CACHED_TYPE, null );
32 if ( $cached !== $detected ) {
33 update_option( self::OPT_CACHED_TYPE, $detected, false );
34 }
35 return $detected;
36 }
37
38 // No definitive signal this request (typically WP-CLI, where
39 // SERVER_SOFTWARE is empty). Read whatever was cached the last
40 // time we ran from a real HTTP request.
41 $cached = get_option( self::OPT_CACHED_TYPE, null );
42 if ( is_string( $cached ) && '' !== $cached ) {
43 return $cached;
44 }
45
46 return self::UNKNOWN;
47 }
48
49 /**
50 * Live detection — never reads the cache. Used by type() and by
51 * any caller that explicitly wants the current-request answer
52 * (e.g. diagnostic UI showing "detected this request").
53 *
54 * We DO NOT fall back to "if .htaccess exists assume Apache" here:
55 * Cache::install_rewrite() writes .htaccess itself, so on nginx
56 * hosts the file appears after first cache toggle and a presence
57 * check then flips us to APACHE forever. Cached HTTP detection
58 * is the cleaner backstop.
59 */
60 public static function detect(): string {
61 global $is_apache, $is_nginx, $is_IIS, $is_iis7;
62
63 $signature = self::server_signature();
64
65 if ( false !== stripos( $signature, 'litespeed' ) ) {
66 return self::LITESPEED;
67 }
68 // apache_get_modules() exists only with mod_php (not FPM), so
69 // gate it behind SERVER_SOFTWARE first. Otherwise an
70 // "apache_get_modules exists" check would false-positive on a
71 // few PHP-builtin-server / mod_php-on-localhost dev edge cases.
72 if ( false !== stripos( $signature, 'apache' ) || ! empty( $is_apache ) ) {
73 return self::APACHE;
74 }
75 if ( false !== stripos( $signature, 'nginx' ) || ! empty( $is_nginx ) ) {
76 return self::NGINX;
77 }
78 if ( false !== stripos( $signature, 'microsoft-iis' ) || ! empty( $is_IIS ) || ! empty( $is_iis7 ) ) {
79 return self::IIS;
80 }
81 return self::UNKNOWN;
82 }
83
84 /**
85 * Whether the server respects .htaccess / web.config-style file-based config.
86 */
87 public static function supports_htaccess() {
88 $t = self::type();
89 return self::APACHE === $t || self::LITESPEED === $t;
90 }
91
92 /**
93 * Whether Apache can stamp a response header from `.htaccess`
94 * (i.e. mod_headers is loaded).
95 *
96 * This decides whether the static-rewrite fast path can be used at
97 * all. A statically-served file bypasses PHP entirely, so the ONLY
98 * way to mark it as a cache HIT is a `Header` directive in the
99 * rewrite block. Without mod_headers that directive is silently
100 * swallowed by its `<IfModule>` guard, and the site serves fast but
101 * completely invisible cache hits — no `X-XSpeed-Cache` header for
102 * the user, nothing for the hit counter. That is exactly the
103 * "cache works, dashboard says 0%" report this check exists to
104 * prevent. (Cache::static_rewrite_allowed() consumes it.)
105 *
106 * Detection is best-effort by necessity:
107 * - mod_php exposes apache_get_modules() — authoritative.
108 * - Under PHP-FPM that function doesn't exist. Assume the module
109 * IS present, matching Apache's own default build (mod_headers
110 * ships enabled in every mainstream distro package). Guessing
111 * "absent" there would push every FPM site onto the slower
112 * drop-in path over a detection limitation rather than a real
113 * capability gap; the loopback probe in
114 * Cache::probe_static_rewrite() is what catches a genuinely
115 * header-less FPM host.
116 *
117 * @return bool
118 */
119 public static function apache_has_mod_headers(): bool {
120 if ( function_exists( 'apache_get_modules' ) ) {
121 $has = in_array( 'mod_headers', apache_get_modules(), true );
122 } else {
123 $has = true; // FPM: undetectable, assume the distro default.
124 }
125
126 /**
127 * Filter: xspeed_apache_has_mod_headers
128 *
129 * Override mod_headers detection. Return false on a host where
130 * `.htaccess` Header directives are stripped (some managed
131 * stacks do this) to force cache hits through the PHP drop-in,
132 * where they are stamped and counted.
133 *
134 * @param bool $has Whether mod_headers appears to be available.
135 */
136 return (bool) apply_filters( 'xspeed_apache_has_mod_headers', $has );
137 }
138
139 /**
140 * Accept a candidate access-log path only if it can actually be
141 * tail-scanned, else ''.
142 *
143 * `is_readable()` alone is not enough. The official WordPress and
144 * Apache Docker images symlink `access.log -> /dev/stdout`, i.e. a
145 * PIPE: `is_file()` is false, `filesize()` is 0, and `is_readable()`
146 * is false for the PHP user. Hit_Counter::collect_server_log_hits()
147 * fseek()s to a stored byte offset and reads forward, which a pipe
148 * or character device cannot support at all — it would either fail
149 * or block. Requiring a REGULAR file makes that contract explicit
150 * instead of relying on the filesize>0 test to reject pipes as a
151 * side effect. Containerised Apache is the standard layout, not an
152 * edge case, so this path is common. (Field report: hit ratio stuck
153 * at 0% on Dockerised Apache while the cache served correctly.)
154 *
155 * @param string $path Candidate path.
156 * @return string The path when usable, '' otherwise.
157 */
158 private static function usable_access_log( string $path ): string {
159 if ( '' === $path ) {
160 return '';
161 }
162 // is_file() resolves symlinks, so access.log -> /var/log/real.log
163 // is still accepted; only the pipe/device targets are rejected.
164 if ( ! @is_file( $path ) || ! is_readable( $path ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- racey stat on an external log; treated as "unusable".
165 return '';
166 }
167 return $path;
168 }
169
170 /**
171 * Best-effort path to the web server's access log, used to count
172 * static-rewrite HITs that bypass PHP on Apache/LiteSpeed (those
173 * requests are served straight from disk and never reach our
174 * Hit_Counter inline — see Hit_Counter::collect_server_log_hits()).
175 *
176 * Resolution order:
177 * 1. The `XSPEED_ACCESS_LOG` constant, if defined (explicit override
178 * for hosts where the log lives somewhere non-standard).
179 * 2. The `xspeed_access_log_path` filter (programmatic override).
180 * 3. Auto-detection: a short list of the standard Apache/LiteSpeed
181 * access-log locations, returning the first that exists AND is
182 * readable by the PHP user.
183 *
184 * Every route is funnelled through usable_access_log(), so an
185 * override can no more hand us a pipe than auto-detection can.
186 *
187 * Returns '' when nothing usable is found — a very common case on
188 * managed/cPanel hosts where the PHP user can't read the server log,
189 * and on containers where it's a symlink to stdout. Callers MUST
190 * treat '' as "can't count static hits here" and fall back
191 * gracefully (the drop-in path still counts its own HITs).
192 *
193 * @return string Absolute path, or '' if none is usable.
194 */
195 public static function access_log_path(): string {
196 if ( defined( 'XSPEED_ACCESS_LOG' ) && is_string( XSPEED_ACCESS_LOG ) && '' !== XSPEED_ACCESS_LOG ) {
197 return self::usable_access_log( XSPEED_ACCESS_LOG );
198 }
199
200 /**
201 * Filter: xspeed_access_log_path
202 *
203 * Override the auto-detected access-log path. Return '' to disable
204 * server-log hit counting entirely.
205 *
206 * @param string|null $path Null = use auto-detection below.
207 */
208 $filtered = apply_filters( 'xspeed_access_log_path', null );
209 if ( is_string( $filtered ) ) {
210 return self::usable_access_log( $filtered );
211 }
212
213 // Auto-detect: the standard Apache + OpenLiteSpeed/LiteSpeed
214 // Enterprise access-log locations. First readable, NON-EMPTY file
215 // wins — an empty global access.log (common on LiteSpeed, which
216 // logs per-vhost instead) must not shadow the real per-vhost log we
217 // discover below.
218 $candidates = array(
219 '/var/log/apache2/access.log', // Debian/Ubuntu Apache
220 '/var/log/httpd/access_log', // RHEL/CentOS Apache
221 '/var/log/apache2/other_vhosts_access.log', // Debian multi-vhost
222 '/usr/local/lsws/logs/access.log', // OpenLiteSpeed global
223 '/var/log/lshttpd/access.log', // LiteSpeed Enterprise
224 );
225 foreach ( $candidates as $path ) {
226 // usable_access_log() enforces "regular file + readable"; the
227 // non-empty test stays here so an empty global log doesn't
228 // shadow the real per-vhost one found further below.
229 if ( '' !== self::usable_access_log( $path ) && (int) @filesize( $path ) > 0 ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- racey stat, treated as "skip".
230 return $path;
231 }
232 }
233
234 // LiteSpeed (and some Apache vhost setups) write a per-vhost
235 // `<vhost>.access.log` rather than a single global file. Scan the
236 // known log dirs for the most-recently-written, readable, non-empty
237 // *.access.log and use that. Auto-tracks whichever vhost is serving
238 // this site without the admin having to set a path.
239 $dirs = array( '/usr/local/lsws/logs', '/var/log/lshttpd', '/var/log/apache2', '/var/log/httpd' );
240 $best = '';
241 $best_mtime = 0;
242 foreach ( $dirs as $dir ) {
243 if ( ! is_dir( $dir ) ) {
244 continue;
245 }
246 $globbed = glob( $dir . '/*access*log*' );
247 if ( ! is_array( $globbed ) ) {
248 continue;
249 }
250 foreach ( $globbed as $path ) {
251 // Same regular-file contract as the fixed candidates: a
252 // glob can just as easily turn up a symlink to stdout.
253 if ( '' === self::usable_access_log( $path ) || (int) @filesize( $path ) === 0 ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
254 continue;
255 }
256 $mtime = (int) @filemtime( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
257 if ( $mtime > $best_mtime ) {
258 $best_mtime = $mtime;
259 $best = $path;
260 }
261 }
262 }
263 return $best;
264 }
265
266 /**
267 * GZIP support category for the UI:
268 * 'auto' — toggling writes server config (Apache / LiteSpeed)
269 * 'manual' — must be configured outside the plugin (nginx, IIS, unknown)
270 */
271 public static function gzip_mode() {
272 return self::supports_htaccess() ? 'auto' : 'manual';
273 }
274
275 /**
276 * Whether the server can serve Brotli-compressed responses.
277 *
278 * Brotli is an optional server module (mod_brotli on Apache,
279 * ngx_brotli on nginx, built in on LiteSpeed/OpenLiteSpeed) — unlike
280 * GZIP it is NOT guaranteed present. We report availability so the UI
281 * and any add-on (xspeed-pro Brotli module) can decide whether to
282 * emit Brotli rules or fall back to GZIP only.
283 *
284 * Detection, cheapest signal first:
285 * 1. LiteSpeed — Brotli is part of the core server, always available.
286 * 2. Apache mod_php — apache_get_modules() lists 'mod_brotli'.
287 * 3. PHP `brotli` extension (kjdev/php-ext-brotli) — lets us at least
288 * pre-compress static files even when the web server can't.
289 * Anything else (nginx/FPM, IIS, unknown) is reported as not detected;
290 * the user can still wire ngx_brotli manually and the UI surfaces a
291 * snippet, mirroring how GZIP behaves on nginx.
292 *
293 * Result is filterable so a host with a known-good but undetectable
294 * setup (e.g. nginx + ngx_brotli) can force-enable.
295 */
296 public static function brotli_available(): bool {
297 $available = false;
298
299 if ( self::LITESPEED === self::type() ) {
300 $available = true;
301 } elseif ( function_exists( 'apache_get_modules' ) && in_array( 'mod_brotli', apache_get_modules(), true ) ) {
302 $available = true;
303 } elseif ( function_exists( 'brotli_compress' ) ) {
304 $available = true;
305 } elseif ( self::NGINX === self::type() ) {
306 // nginx modules are not introspectable from PHP, so none of the
307 // branches above can ever be true on the very common nginx +
308 // php-fpm setup — even while ngx_brotli is actively serving
309 // `Content-Encoding: br` on every request. Reporting "unavailable"
310 // there told users who had done everything right to go install a
311 // module they already had.
312 //
313 // So ask the server instead of asking PHP: one cached loopback
314 // request with `Accept-Encoding: br`, and read what comes back.
315 $available = self::brotli_probe();
316 }
317
318 /**
319 * Filter detected Brotli availability.
320 *
321 * @param bool $available Whether Brotli serving was detected.
322 */
323 return (bool) apply_filters( 'xspeed_brotli_available', $available );
324 }
325
326 /**
327 * Ask the web server whether it serves Brotli, by requesting our own home
328 * URL with `Accept-Encoding: br` and reading the response encoding.
329 *
330 * The only way to answer this on nginx: the module list isn't visible to
331 * PHP, so introspection can't work and the request itself is the evidence.
332 *
333 * Cached in a transient — a positive result for a day (server modules
334 * don't come and go), a negative for an hour so someone who has just
335 * installed ngx_brotli isn't told "no" until tomorrow. Failures cache
336 * briefly too, so a host that hangs on loopback self-requests can't turn
337 * every dashboard load into a timeout.
338 *
339 * @param bool $force Skip the cache and re-probe.
340 */
341 public static function brotli_probe( bool $force = false ): bool {
342 return 'yes' === self::brotli_probe_state( $force );
343 }
344
345 /**
346 * The probe's three-way answer: 'yes', 'no', or 'unknown'.
347 *
348 * brotli_probe() collapses this to a bool because every consumer wants
349 * one, but the distinction matters for what we TELL the user.
350 * "unknown" — a blocked or failing loopback — is not evidence that the
351 * server lacks Brotli, and reporting it as "no" would repeat the original
352 * bug in a new place: telling someone whose setup is fine that it isn't.
353 *
354 * @param bool $force Skip the cache and re-probe.
355 * @return string 'yes' | 'no' | 'unknown'
356 */
357 public static function brotli_probe_state( bool $force = false ): string {
358 $key = 'xspeed_brotli_probe';
359
360 if ( ! $force ) {
361 $cached = get_transient( $key );
362 if ( false !== $cached ) {
363 $cached = (string) $cached;
364 // Legacy '1'/'0' values from an earlier cache format.
365 if ( '1' === $cached ) {
366 return 'yes';
367 }
368 if ( '0' === $cached ) {
369 return 'no';
370 }
371 return in_array( $cached, array( 'yes', 'no', 'unknown' ), true ) ? $cached : 'unknown';
372 }
373 }
374
375 $url = home_url( '/' );
376 if ( ! function_exists( 'wp_remote_get' ) || '' === $url ) {
377 return 'unknown';
378 }
379
380 // Stampede guard. On a cold transient every concurrent dashboard load
381 // would otherwise fire its own 3s loopback request, because nothing
382 // was written until the response came back. Claim the slot BEFORE the
383 // request so the other callers answer 'unknown' (accurate — they
384 // genuinely don't know yet) rather than piling on.
385 $inflight = $key . '_inflight';
386 if ( ! $force && false !== get_transient( $inflight ) ) {
387 return 'unknown';
388 }
389 set_transient( $inflight, 1, 30 );
390
391 // Mirror Cache::probe_static_rewrite()'s posture: short timeout so a
392 // blocked loopback can't stall the caller, and relax cert verification
393 // only in local/dev where self-signed certs are normal.
394 $is_local = function_exists( 'wp_get_environment_type' )
395 && in_array( wp_get_environment_type(), array( 'local', 'development' ), true );
396
397 $resp = wp_remote_get(
398 $url,
399 array(
400 'timeout' => 3,
401 'sslverify' => ! $is_local,
402 'redirection' => 0,
403 'headers' => array(
404 // `br` ONLY. Offering gzip as well would let a server that
405 // prefers gzip answer with it and look like a brotli
406 // failure, which is exactly the false negative this method
407 // exists to remove.
408 'Accept-Encoding' => 'br',
409 'Cache-Control' => 'no-cache',
410 ),
411 )
412 );
413
414 delete_transient( $inflight );
415
416 if ( is_wp_error( $resp ) ) {
417 // Can't reach ourselves. This is NOT evidence the server lacks
418 // Brotli — reporting it as "no" would repeat the original bug in a
419 // new place. Cache briefly so a hanging host doesn't cost 3s on
420 // every call, but re-check soon.
421 set_transient( $key, 'unknown', 5 * MINUTE_IN_SECONDS );
422 return 'unknown';
423 }
424
425 // A non-2xx answer tells us nothing about compression: basic auth
426 // (401), maintenance mode (503) and WAF challenge pages are all
427 // "couldn't check", not "no module". Caching 'no' for an hour on the
428 // strength of one is the same category error this method fixes.
429 $code = (int) wp_remote_retrieve_response_code( $resp );
430 if ( $code < 200 || $code >= 300 ) {
431 set_transient( $key, 'unknown', 5 * MINUTE_IN_SECONDS );
432 return 'unknown';
433 }
434
435 // A CDN or reverse proxy in front of the origin compresses on its own
436 // behalf, so `content-encoding: br` would describe the EDGE, not this
437 // server. On Apache/LiteSpeed that is harmless (brotli_available()
438 // short-circuits before consulting the probe), but on nginx the probe
439 // IS the answer — and a large share of nginx sites sit behind
440 // Cloudflare, Fastly or a load balancer. Asserting 'yes' there is the
441 // mirror image of the false negative this method exists to remove, so
442 // we answer 'unknown': we genuinely could not observe the origin.
443 if ( self::response_came_through_proxy( $resp ) ) {
444 set_transient( $key, 'unknown', HOUR_IN_SECONDS );
445 return 'unknown';
446 }
447
448 $encoding = wp_remote_retrieve_header( $resp, 'content-encoding' );
449 if ( is_array( $encoding ) ) {
450 $encoding = implode( ',', $encoding );
451 }
452 $serves_brotli = false !== stripos( (string) $encoding, 'br' );
453
454 // A positive is durable (server modules don't come and go); a negative
455 // expires sooner so someone who has just installed ngx_brotli isn't
456 // told "no" until tomorrow.
457 $state = $serves_brotli ? 'yes' : 'no';
458 set_transient( $key, $state, $serves_brotli ? DAY_IN_SECONDS : HOUR_IN_SECONDS );
459
460 return $state;
461 }
462
463 /**
464 * Did this response come back through a CDN / reverse proxy rather than
465 * straight from our own web server?
466 *
467 * home_url() resolves through public DNS, so the request can leave the
468 * box entirely and be answered at an edge. These headers are the evidence
469 * the edge leaves behind; none of them are set by a plain origin.
470 *
471 * Deliberately conservative — a false "there's a proxy" costs a user the
472 * capability assertion and shows the 'unknown' copy, while a false "no
473 * proxy" tells an nginx user Brotli is on when their origin cannot serve
474 * it. Cache::probe_static_rewrite() shares this blind spot, which is why
475 * this is a public helper rather than inline.
476 *
477 * @param array|\WP_Error $resp Response from wp_remote_get().
478 */
479 public static function response_came_through_proxy( $resp ): bool {
480 if ( is_wp_error( $resp ) ) {
481 return false;
482 }
483
484 // Headers whose mere presence means an intermediary handled this.
485 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 ) {
486 $value = wp_remote_retrieve_header( $resp, $header );
487 if ( is_array( $value ) ) {
488 $value = implode( ',', $value );
489 }
490 if ( '' !== (string) $value ) {
491 return true;
492 }
493 }
494
495 // `server:` naming a known edge. Checked by substring because these
496 // arrive as `cloudflare`, `Sucuri/Cloudproxy`, `AkamaiGHost`, etc.
497 $server = wp_remote_retrieve_header( $resp, 'server' );
498 if ( is_array( $server ) ) {
499 $server = implode( ',', $server );
500 }
501 $server = strtolower( (string) $server );
502 foreach ( array( 'cloudflare', 'cloudfront', 'akamai', 'fastly', 'sucuri', 'incapsula', 'stackpath', 'bunnycdn', 'keycdn' ) as $needle ) {
503 if ( false !== strpos( $server, $needle ) ) {
504 return true;
505 }
506 }
507
508 return (bool) apply_filters( 'xspeed_response_came_through_proxy', false, $resp );
509 }
510
511 /**
512 * Drop the cached Brotli probe result so the next call re-checks.
513 *
514 * Without this a user who installs ngx_brotli has no way to make the
515 * dashboard notice before the transient expires — the same gap
516 * Cache::recheck_static_rewrite() exists to close.
517 */
518 public static function recheck_brotli(): bool {
519 delete_transient( 'xspeed_brotli_probe' );
520 return self::brotli_probe( true );
521 }
522
523 /**
524 * Is WordPress running inside a container (Docker / Podman / k8s)?
525 *
526 * Three signals checked in cheapness order, OR'd together:
527 * 1. /.dockerenv exists — Docker's traditional marker; rare absence.
528 * 2. /proc/self/mountinfo references /var/lib/docker/overlay2 or
529 * containerd/podman storage drivers — works under cgroup v2.
530 * 3. /proc/1/cgroup names docker / kubepods / containerd / podman / lxc
531 * — the cgroup v1 signal, still present on older Docker installs.
532 *
533 * Any single positive returns true. On non-Linux hosts (Windows /
534 * macOS / WSL host process), all three quietly return false and we
535 * fall back to "not containerized."
536 */
537 public static function is_containerized(): bool {
538 // 1. Docker marker file — cheap to stat, almost always present.
539 if ( file_exists( '/.dockerenv' ) ) {
540 return true;
541 }
542 // 2. mountinfo overlay2 / containerd footprint — works under cgroup v2.
543 // Gate on is_readable() first: on non-Linux hosts (macOS/Windows) or
544 // hosts that hide /proc (open_basedir, hardened Apache), the file is
545 // absent and reading it would emit a warning. Query Monitor surfaces
546 // even @-suppressed warnings, so guard rather than silence. (FBS-83114)
547 if ( is_readable( '/proc/self/mountinfo' ) ) {
548 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- /proc/self/mountinfo is a virtual file; WP_Filesystem doesn't model /proc.
549 $mounts = file_get_contents( '/proc/self/mountinfo' );
550 if ( is_string( $mounts ) && '' !== $mounts && preg_match( '#(docker/overlay2|/var/lib/containerd|/var/lib/podman)#i', $mounts ) ) {
551 return true;
552 }
553 }
554 // 3. cgroup v1 fallback.
555 if ( is_readable( '/proc/1/cgroup' ) ) {
556 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- See above.
557 $cgroup = file_get_contents( '/proc/1/cgroup' );
558 if ( is_string( $cgroup ) && '' !== $cgroup && preg_match( '#(docker|kubepods|containerd|podman|lxc)#i', $cgroup ) ) {
559 return true;
560 }
561 }
562 return false;
563 }
564
565 /**
566 * Is WordPress likely behind a reverse proxy (host nginx → container
567 * php-fpm, host nginx → docker nginx, etc.)? Detection is a heuristic
568 * built from the headers WordPress hands to PHP: when a proxy forwards
569 * the request it almost always sets X-Forwarded-* or X-Real-IP.
570 *
571 * False positives (CDN-only forwarding without a local reverse proxy)
572 * are acceptable — the caller uses this signal only to soften messaging
573 * that would otherwise mislead container-host customers. False negatives
574 * (proxy that strips headers) just mean we keep showing the snippet
575 * paste UX, which is the safe default.
576 */
577 public static function is_behind_proxy(): bool {
578 $proxy_headers = array( 'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED_HOST', 'HTTP_X_FORWARDED_PROTO', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_SERVER' );
579 foreach ( $proxy_headers as $h ) {
580 if ( ! empty( $_SERVER[ $h ] ) ) {
581 return true;
582 }
583 }
584 return false;
585 }
586
587 /**
588 * High-level topology classifier driving the rewrite-alert UX.
589 *
590 * Decides WHERE the user's nginx snippet needs to be installed
591 * (or whether automatic install via .htaccess covers it). The
592 * dashboard banner uses the return value to render the right
593 * "paste this here" message — there is no topology that can't
594 * benefit from xSpeed's static-rewrite; the question is only
595 * which nginx is in the cache file's filesystem.
596 *
597 * Returns one of:
598 * 'htaccess' — Apache / LiteSpeed; .htaccess block is
599 * installed automatically, no user action.
600 * 'nginx-host' — self-managed nginx on the host (no
601 * container in the request path). User
602 * pastes the snippet into their vhost
603 * (typically /etc/nginx/sites-enabled/<site>).
604 * 'nginx-container' — nginx running inside the same container
605 * as PHP. User pastes the snippet into the
606 * container's nginx config (typically
607 * docker/nginx.conf in the site's
608 * docker-compose dir). Host nginx (if any)
609 * is a reverse-proxy that just forwards
610 * bytes — snippet does NOT go there.
611 * 'unknown' — IIS or undetected; treat as manual.
612 */
613 public static function rewrite_topology(): string {
614 $type = self::type();
615 if ( self::APACHE === $type || self::LITESPEED === $type ) {
616 return 'htaccess';
617 }
618 if ( self::NGINX === $type ) {
619 return self::is_containerized() ? 'nginx-container' : 'nginx-host';
620 }
621 return 'unknown';
622 }
623
624 private static function server_signature() {
625 return isset( $_SERVER['SERVER_SOFTWARE'] )
626 ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) )
627 : '';
628 }
629
630 /**
631 * Detect active caching plugins that would conflict with xSpeed. Returns
632 * a list of human-readable labels for any conflicting plugin currently
633 * active; empty array means the field is clear. Used by the onboarding
634 * wizard's Step 1 health check and (Phase 2.1) the main dashboard's
635 * Health card.
636 *
637 * The detection key is the plugin's main file path relative to the
638 * plugins directory — the same value WordPress uses internally in
639 * `active_plugins`. Folder-only checks (`is_plugin_active('foo/')`)
640 * would false-positive on disabled plugins still on disk.
641 */
642 public static function conflicts() {
643 if ( ! function_exists( 'is_plugin_active' ) ) {
644 require_once ABSPATH . 'wp-admin/includes/plugin.php';
645 }
646
647 $known = array(
648 'wp-rocket/wp-rocket.php' => 'WP Rocket',
649 'w3-total-cache/w3-total-cache.php' => 'W3 Total Cache',
650 'wp-super-cache/wp-cache.php' => 'WP Super Cache',
651 'wp-fastest-cache/wpFastestCache.php' => 'WP Fastest Cache',
652 'litespeed-cache/litespeed-cache.php' => 'LiteSpeed Cache',
653 'cache-enabler/cache-enabler.php' => 'Cache Enabler',
654 'comet-cache/comet-cache.php' => 'Comet Cache',
655 'hummingbird-performance/wp-hummingbird.php' => 'Hummingbird',
656 'sg-cachepress/sg-cachepress.php' => 'SG Optimizer',
657 'breeze/breeze.php' => 'Breeze',
658 'autoptimize/autoptimize.php' => 'Autoptimize',
659 'flying-press/flying-press.php' => 'FlyingPress',
660 'nitropack/main.php' => 'NitroPack',
661 );
662
663 $active = array();
664 foreach ( $known as $file => $label ) {
665 if ( is_plugin_active( $file ) ) {
666 $active[] = $label;
667 }
668 }
669 return $active;
670 }
671 }
672