PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.0.5
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.0.5
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.0.5, at includes/class-server.php

330 lines 13.1 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 * Is WordPress running inside a container (Docker / Podman / k8s)?
191 *
192 * Three signals checked in cheapness order, OR'd together:
193 * 1. /.dockerenv exists — Docker's traditional marker; rare absence.
194 * 2. /proc/self/mountinfo references /var/lib/docker/overlay2 or
195 * containerd/podman storage drivers — works under cgroup v2.
196 * 3. /proc/1/cgroup names docker / kubepods / containerd / podman / lxc
197 * — the cgroup v1 signal, still present on older Docker installs.
198 *
199 * Any single positive returns true. On non-Linux hosts (Windows /
200 * macOS / WSL host process), all three quietly return false and we
201 * fall back to "not containerized."
202 */
203 public static function is_containerized(): bool {
204 // 1. Docker marker file — cheap to stat, almost always present.
205 if ( file_exists( '/.dockerenv' ) ) {
206 return true;
207 }
208 // 2. mountinfo overlay2 / containerd footprint — works under cgroup v2.
209 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- /proc/self/mountinfo is a virtual file; WP_Filesystem doesn't model /proc.
210 $mounts = @file_get_contents( '/proc/self/mountinfo' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- missing /proc on non-Linux hosts is the negative answer.
211 if ( is_string( $mounts ) && '' !== $mounts && preg_match( '#(docker/overlay2|/var/lib/containerd|/var/lib/podman)#i', $mounts ) ) {
212 return true;
213 }
214 // 3. cgroup v1 fallback.
215 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- See above.
216 $cgroup = @file_get_contents( '/proc/1/cgroup' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- See above.
217 if ( is_string( $cgroup ) && '' !== $cgroup && preg_match( '#(docker|kubepods|containerd|podman|lxc)#i', $cgroup ) ) {
218 return true;
219 }
220 return false;
221 }
222
223 /**
224 * Is WordPress likely behind a reverse proxy (host nginx → container
225 * php-fpm, host nginx → docker nginx, etc.)? Detection is a heuristic
226 * built from the headers WordPress hands to PHP: when a proxy forwards
227 * the request it almost always sets X-Forwarded-* or X-Real-IP.
228 *
229 * False positives (CDN-only forwarding without a local reverse proxy)
230 * are acceptable — the caller uses this signal only to soften messaging
231 * that would otherwise mislead container-host customers. False negatives
232 * (proxy that strips headers) just mean we keep showing the snippet
233 * paste UX, which is the safe default.
234 */
235 public static function is_behind_proxy(): bool {
236 $proxy_headers = array( 'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED_HOST', 'HTTP_X_FORWARDED_PROTO', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_SERVER' );
237 foreach ( $proxy_headers as $h ) {
238 if ( ! empty( $_SERVER[ $h ] ) ) {
239 return true;
240 }
241 }
242 return false;
243 }
244
245 /**
246 * High-level topology classifier driving the rewrite-alert UX.
247 *
248 * Decides WHERE the user's nginx snippet needs to be installed
249 * (or whether automatic install via .htaccess covers it). The
250 * dashboard banner uses the return value to render the right
251 * "paste this here" message — there is no topology that can't
252 * benefit from xSpeed's static-rewrite; the question is only
253 * which nginx is in the cache file's filesystem.
254 *
255 * Returns one of:
256 * 'htaccess' — Apache / LiteSpeed; .htaccess block is
257 * installed automatically, no user action.
258 * 'nginx-host' — self-managed nginx on the host (no
259 * container in the request path). User
260 * pastes the snippet into their vhost
261 * (typically /etc/nginx/sites-enabled/<site>).
262 * 'nginx-container' — nginx running inside the same container
263 * as PHP. User pastes the snippet into the
264 * container's nginx config (typically
265 * docker/nginx.conf in the site's
266 * docker-compose dir). Host nginx (if any)
267 * is a reverse-proxy that just forwards
268 * bytes — snippet does NOT go there.
269 * 'unknown' — IIS or undetected; treat as manual.
270 */
271 public static function rewrite_topology(): string {
272 $type = self::type();
273 if ( self::APACHE === $type || self::LITESPEED === $type ) {
274 return 'htaccess';
275 }
276 if ( self::NGINX === $type ) {
277 return self::is_containerized() ? 'nginx-container' : 'nginx-host';
278 }
279 return 'unknown';
280 }
281
282 private static function server_signature() {
283 return isset( $_SERVER['SERVER_SOFTWARE'] )
284 ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) )
285 : '';
286 }
287
288 /**
289 * Detect active caching plugins that would conflict with xSpeed. Returns
290 * a list of human-readable labels for any conflicting plugin currently
291 * active; empty array means the field is clear. Used by the onboarding
292 * wizard's Step 1 health check and (Phase 2.1) the main dashboard's
293 * Health card.
294 *
295 * The detection key is the plugin's main file path relative to the
296 * plugins directory — the same value WordPress uses internally in
297 * `active_plugins`. Folder-only checks (`is_plugin_active('foo/')`)
298 * would false-positive on disabled plugins still on disk.
299 */
300 public static function conflicts() {
301 if ( ! function_exists( 'is_plugin_active' ) ) {
302 require_once ABSPATH . 'wp-admin/includes/plugin.php';
303 }
304
305 $known = array(
306 'wp-rocket/wp-rocket.php' => 'WP Rocket',
307 'w3-total-cache/w3-total-cache.php' => 'W3 Total Cache',
308 'wp-super-cache/wp-cache.php' => 'WP Super Cache',
309 'wp-fastest-cache/wpFastestCache.php' => 'WP Fastest Cache',
310 'litespeed-cache/litespeed-cache.php' => 'LiteSpeed Cache',
311 'cache-enabler/cache-enabler.php' => 'Cache Enabler',
312 'comet-cache/comet-cache.php' => 'Comet Cache',
313 'hummingbird-performance/wp-hummingbird.php' => 'Hummingbird',
314 'sg-cachepress/sg-cachepress.php' => 'SG Optimizer',
315 'breeze/breeze.php' => 'Breeze',
316 'autoptimize/autoptimize.php' => 'Autoptimize',
317 'flying-press/flying-press.php' => 'FlyingPress',
318 'nitropack/main.php' => 'NitroPack',
319 );
320
321 $active = array();
322 foreach ( $known as $file => $label ) {
323 if ( is_plugin_active( $file ) ) {
324 $active[] = $label;
325 }
326 }
327 return $active;
328 }
329 }
330