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

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

378 lines 14.9 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 * Best-effort path to the web server's access log, used to count
94 * static-rewrite HITs that bypass PHP on Apache/LiteSpeed (those
95 * requests are served straight from disk and never reach our
96 * Hit_Counter inline — see Hit_Counter::collect_server_log_hits()).
97 *
98 * Resolution order:
99 * 1. The `XSPEED_ACCESS_LOG` constant, if defined (explicit override
100 * for hosts where the log lives somewhere non-standard).
101 * 2. The `xspeed_access_log_path` filter (programmatic override).
102 * 3. Auto-detection: a short list of the standard Apache/LiteSpeed
103 * access-log locations, returning the first that exists AND is
104 * readable by the PHP user.
105 *
106 * Returns '' when nothing readable is found — a very common case on
107 * managed/cPanel hosts where the PHP user can't read the server log.
108 * Callers MUST treat '' as "can't count static hits here" and fall
109 * back gracefully (the drop-in path still counts its own HITs).
110 *
111 * @return string Absolute path, or '' if none is readable.
112 */
113 public static function access_log_path(): string {
114 if ( defined( 'XSPEED_ACCESS_LOG' ) && is_string( XSPEED_ACCESS_LOG ) && '' !== XSPEED_ACCESS_LOG ) {
115 $override = XSPEED_ACCESS_LOG;
116 return is_readable( $override ) ? $override : '';
117 }
118
119 /**
120 * Filter: xspeed_access_log_path
121 *
122 * Override the auto-detected access-log path. Return '' to disable
123 * server-log hit counting entirely.
124 *
125 * @param string|null $path Null = use auto-detection below.
126 */
127 $filtered = apply_filters( 'xspeed_access_log_path', null );
128 if ( is_string( $filtered ) ) {
129 return ( '' !== $filtered && is_readable( $filtered ) ) ? $filtered : '';
130 }
131
132 // Auto-detect: the standard Apache + OpenLiteSpeed/LiteSpeed
133 // Enterprise access-log locations. First readable, NON-EMPTY file
134 // wins — an empty global access.log (common on LiteSpeed, which
135 // logs per-vhost instead) must not shadow the real per-vhost log we
136 // discover below.
137 $candidates = array(
138 '/var/log/apache2/access.log', // Debian/Ubuntu Apache
139 '/var/log/httpd/access_log', // RHEL/CentOS Apache
140 '/var/log/apache2/other_vhosts_access.log', // Debian multi-vhost
141 '/usr/local/lsws/logs/access.log', // OpenLiteSpeed global
142 '/var/log/lshttpd/access.log', // LiteSpeed Enterprise
143 );
144 foreach ( $candidates as $path ) {
145 if ( is_readable( $path ) && (int) @filesize( $path ) > 0 ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- racey stat, treated as "skip".
146 return $path;
147 }
148 }
149
150 // LiteSpeed (and some Apache vhost setups) write a per-vhost
151 // `<vhost>.access.log` rather than a single global file. Scan the
152 // known log dirs for the most-recently-written, readable, non-empty
153 // *.access.log and use that. Auto-tracks whichever vhost is serving
154 // this site without the admin having to set a path.
155 $dirs = array( '/usr/local/lsws/logs', '/var/log/lshttpd', '/var/log/apache2', '/var/log/httpd' );
156 $best = '';
157 $best_mtime = 0;
158 foreach ( $dirs as $dir ) {
159 if ( ! is_dir( $dir ) ) {
160 continue;
161 }
162 $globbed = glob( $dir . '/*access*log*' );
163 if ( ! is_array( $globbed ) ) {
164 continue;
165 }
166 foreach ( $globbed as $path ) {
167 if ( ! is_readable( $path ) || (int) @filesize( $path ) === 0 ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
168 continue;
169 }
170 $mtime = (int) @filemtime( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
171 if ( $mtime > $best_mtime ) {
172 $best_mtime = $mtime;
173 $best = $path;
174 }
175 }
176 }
177 return $best;
178 }
179
180 /**
181 * GZIP support category for the UI:
182 * 'auto' — toggling writes server config (Apache / LiteSpeed)
183 * 'manual' — must be configured outside the plugin (nginx, IIS, unknown)
184 */
185 public static function gzip_mode() {
186 return self::supports_htaccess() ? 'auto' : 'manual';
187 }
188
189 /**
190 * Whether the server can serve Brotli-compressed responses.
191 *
192 * Brotli is an optional server module (mod_brotli on Apache,
193 * ngx_brotli on nginx, built in on LiteSpeed/OpenLiteSpeed) — unlike
194 * GZIP it is NOT guaranteed present. We report availability so the UI
195 * and any add-on (xspeed-pro Brotli module) can decide whether to
196 * emit Brotli rules or fall back to GZIP only.
197 *
198 * Detection, cheapest signal first:
199 * 1. LiteSpeed — Brotli is part of the core server, always available.
200 * 2. Apache mod_php — apache_get_modules() lists 'mod_brotli'.
201 * 3. PHP `brotli` extension (kjdev/php-ext-brotli) — lets us at least
202 * pre-compress static files even when the web server can't.
203 * Anything else (nginx/FPM, IIS, unknown) is reported as not detected;
204 * the user can still wire ngx_brotli manually and the UI surfaces a
205 * snippet, mirroring how GZIP behaves on nginx.
206 *
207 * Result is filterable so a host with a known-good but undetectable
208 * setup (e.g. nginx + ngx_brotli) can force-enable.
209 */
210 public static function brotli_available(): bool {
211 $available = false;
212
213 if ( self::LITESPEED === self::type() ) {
214 $available = true;
215 } elseif ( function_exists( 'apache_get_modules' ) && in_array( 'mod_brotli', apache_get_modules(), true ) ) {
216 $available = true;
217 } elseif ( function_exists( 'brotli_compress' ) ) {
218 $available = true;
219 }
220
221 /**
222 * Filter detected Brotli availability.
223 *
224 * @param bool $available Whether Brotli serving was detected.
225 */
226 return (bool) apply_filters( 'xspeed_brotli_available', $available );
227 }
228
229 /**
230 * Is WordPress running inside a container (Docker / Podman / k8s)?
231 *
232 * Three signals checked in cheapness order, OR'd together:
233 * 1. /.dockerenv exists — Docker's traditional marker; rare absence.
234 * 2. /proc/self/mountinfo references /var/lib/docker/overlay2 or
235 * containerd/podman storage drivers — works under cgroup v2.
236 * 3. /proc/1/cgroup names docker / kubepods / containerd / podman / lxc
237 * — the cgroup v1 signal, still present on older Docker installs.
238 *
239 * Any single positive returns true. On non-Linux hosts (Windows /
240 * macOS / WSL host process), all three quietly return false and we
241 * fall back to "not containerized."
242 */
243 public static function is_containerized(): bool {
244 // 1. Docker marker file — cheap to stat, almost always present.
245 if ( file_exists( '/.dockerenv' ) ) {
246 return true;
247 }
248 // 2. mountinfo overlay2 / containerd footprint — works under cgroup v2.
249 // Gate on is_readable() first: on non-Linux hosts (macOS/Windows) or
250 // hosts that hide /proc (open_basedir, hardened Apache), the file is
251 // absent and reading it would emit a warning. Query Monitor surfaces
252 // even @-suppressed warnings, so guard rather than silence. (FBS-83114)
253 if ( is_readable( '/proc/self/mountinfo' ) ) {
254 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- /proc/self/mountinfo is a virtual file; WP_Filesystem doesn't model /proc.
255 $mounts = file_get_contents( '/proc/self/mountinfo' );
256 if ( is_string( $mounts ) && '' !== $mounts && preg_match( '#(docker/overlay2|/var/lib/containerd|/var/lib/podman)#i', $mounts ) ) {
257 return true;
258 }
259 }
260 // 3. cgroup v1 fallback.
261 if ( is_readable( '/proc/1/cgroup' ) ) {
262 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- See above.
263 $cgroup = file_get_contents( '/proc/1/cgroup' );
264 if ( is_string( $cgroup ) && '' !== $cgroup && preg_match( '#(docker|kubepods|containerd|podman|lxc)#i', $cgroup ) ) {
265 return true;
266 }
267 }
268 return false;
269 }
270
271 /**
272 * Is WordPress likely behind a reverse proxy (host nginx → container
273 * php-fpm, host nginx → docker nginx, etc.)? Detection is a heuristic
274 * built from the headers WordPress hands to PHP: when a proxy forwards
275 * the request it almost always sets X-Forwarded-* or X-Real-IP.
276 *
277 * False positives (CDN-only forwarding without a local reverse proxy)
278 * are acceptable — the caller uses this signal only to soften messaging
279 * that would otherwise mislead container-host customers. False negatives
280 * (proxy that strips headers) just mean we keep showing the snippet
281 * paste UX, which is the safe default.
282 */
283 public static function is_behind_proxy(): bool {
284 $proxy_headers = array( 'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED_HOST', 'HTTP_X_FORWARDED_PROTO', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_SERVER' );
285 foreach ( $proxy_headers as $h ) {
286 if ( ! empty( $_SERVER[ $h ] ) ) {
287 return true;
288 }
289 }
290 return false;
291 }
292
293 /**
294 * High-level topology classifier driving the rewrite-alert UX.
295 *
296 * Decides WHERE the user's nginx snippet needs to be installed
297 * (or whether automatic install via .htaccess covers it). The
298 * dashboard banner uses the return value to render the right
299 * "paste this here" message — there is no topology that can't
300 * benefit from xSpeed's static-rewrite; the question is only
301 * which nginx is in the cache file's filesystem.
302 *
303 * Returns one of:
304 * 'htaccess' — Apache / LiteSpeed; .htaccess block is
305 * installed automatically, no user action.
306 * 'nginx-host' — self-managed nginx on the host (no
307 * container in the request path). User
308 * pastes the snippet into their vhost
309 * (typically /etc/nginx/sites-enabled/<site>).
310 * 'nginx-container' — nginx running inside the same container
311 * as PHP. User pastes the snippet into the
312 * container's nginx config (typically
313 * docker/nginx.conf in the site's
314 * docker-compose dir). Host nginx (if any)
315 * is a reverse-proxy that just forwards
316 * bytes — snippet does NOT go there.
317 * 'unknown' — IIS or undetected; treat as manual.
318 */
319 public static function rewrite_topology(): string {
320 $type = self::type();
321 if ( self::APACHE === $type || self::LITESPEED === $type ) {
322 return 'htaccess';
323 }
324 if ( self::NGINX === $type ) {
325 return self::is_containerized() ? 'nginx-container' : 'nginx-host';
326 }
327 return 'unknown';
328 }
329
330 private static function server_signature() {
331 return isset( $_SERVER['SERVER_SOFTWARE'] )
332 ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) )
333 : '';
334 }
335
336 /**
337 * Detect active caching plugins that would conflict with xSpeed. Returns
338 * a list of human-readable labels for any conflicting plugin currently
339 * active; empty array means the field is clear. Used by the onboarding
340 * wizard's Step 1 health check and (Phase 2.1) the main dashboard's
341 * Health card.
342 *
343 * The detection key is the plugin's main file path relative to the
344 * plugins directory — the same value WordPress uses internally in
345 * `active_plugins`. Folder-only checks (`is_plugin_active('foo/')`)
346 * would false-positive on disabled plugins still on disk.
347 */
348 public static function conflicts() {
349 if ( ! function_exists( 'is_plugin_active' ) ) {
350 require_once ABSPATH . 'wp-admin/includes/plugin.php';
351 }
352
353 $known = array(
354 'wp-rocket/wp-rocket.php' => 'WP Rocket',
355 'w3-total-cache/w3-total-cache.php' => 'W3 Total Cache',
356 'wp-super-cache/wp-cache.php' => 'WP Super Cache',
357 'wp-fastest-cache/wpFastestCache.php' => 'WP Fastest Cache',
358 'litespeed-cache/litespeed-cache.php' => 'LiteSpeed Cache',
359 'cache-enabler/cache-enabler.php' => 'Cache Enabler',
360 'comet-cache/comet-cache.php' => 'Comet Cache',
361 'hummingbird-performance/wp-hummingbird.php' => 'Hummingbird',
362 'sg-cachepress/sg-cachepress.php' => 'SG Optimizer',
363 'breeze/breeze.php' => 'Breeze',
364 'autoptimize/autoptimize.php' => 'Autoptimize',
365 'flying-press/flying-press.php' => 'FlyingPress',
366 'nitropack/main.php' => 'NitroPack',
367 );
368
369 $active = array();
370 foreach ( $known as $file => $label ) {
371 if ( is_plugin_active( $file ) ) {
372 $active[] = $label;
373 }
374 }
375 return $active;
376 }
377 }
378