# xspeed/1.1.3/includes/class-server.php

xSpeed Cache: AI-Powered Performance Hub with MCP, Caching &amp; CDN, version 1.1.3. 464 lines.

- Page: https://pluginprobe.com/plugins/xspeed/1.1.3/code/includes/class-server.php
- Raw: https://pluginprobe.com/plugins/xspeed/1.1.3/raw/includes/class-server.php
- Modified: 2026-08-05T12:06:42+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/xspeed/1.1.3/code/includes/class-server.php#L10-L20`.

```php
<?php
/**
 * Server / SAPI detection.
 *
 * Used by Gzip and the UI to decide which optimizations are server-applied
 * (Apache / LiteSpeed via .htaccess) vs. require manual config (nginx).
 *
 * @package XSpeed
 */

namespace XSpeed;

defined( 'ABSPATH' ) || exit;

class Server {

	const APACHE    = 'apache';
	const LITESPEED = 'litespeed';
	const NGINX     = 'nginx';
	const IIS       = 'iis';
	const UNKNOWN   = 'unknown';

	const OPT_CACHED_TYPE = 'xspeed_server_type';

	public static function type() {
		$detected = self::detect();
		if ( self::UNKNOWN !== $detected ) {
			// Persist whenever we have a real answer so future CLI /
			// cron / REST calls (where SERVER_SOFTWARE may be empty)
			// inherit it. Non-autoloaded — only read when needed.
			$cached = get_option( self::OPT_CACHED_TYPE, null );
			if ( $cached !== $detected ) {
				update_option( self::OPT_CACHED_TYPE, $detected, false );
			}
			return $detected;
		}

		// No definitive signal this request (typically WP-CLI, where
		// SERVER_SOFTWARE is empty). Read whatever was cached the last
		// time we ran from a real HTTP request.
		$cached = get_option( self::OPT_CACHED_TYPE, null );
		if ( is_string( $cached ) && '' !== $cached ) {
			return $cached;
		}

		return self::UNKNOWN;
	}

	/**
	 * Live detection — never reads the cache. Used by type() and by
	 * any caller that explicitly wants the current-request answer
	 * (e.g. diagnostic UI showing "detected this request").
	 *
	 * We DO NOT fall back to "if .htaccess exists assume Apache" here:
	 * Cache::install_rewrite() writes .htaccess itself, so on nginx
	 * hosts the file appears after first cache toggle and a presence
	 * check then flips us to APACHE forever. Cached HTTP detection
	 * is the cleaner backstop.
	 */
	public static function detect(): string {
		global $is_apache, $is_nginx, $is_IIS, $is_iis7;

		$signature = self::server_signature();

		if ( false !== stripos( $signature, 'litespeed' ) ) {
			return self::LITESPEED;
		}
		// apache_get_modules() exists only with mod_php (not FPM), so
		// gate it behind SERVER_SOFTWARE first. Otherwise an
		// "apache_get_modules exists" check would false-positive on a
		// few PHP-builtin-server / mod_php-on-localhost dev edge cases.
		if ( false !== stripos( $signature, 'apache' ) || ! empty( $is_apache ) ) {
			return self::APACHE;
		}
		if ( false !== stripos( $signature, 'nginx' ) || ! empty( $is_nginx ) ) {
			return self::NGINX;
		}
		if ( false !== stripos( $signature, 'microsoft-iis' ) || ! empty( $is_IIS ) || ! empty( $is_iis7 ) ) {
			return self::IIS;
		}
		return self::UNKNOWN;
	}

	/**
	 * Whether the server respects .htaccess / web.config-style file-based config.
	 */
	public static function supports_htaccess() {
		$t = self::type();
		return self::APACHE === $t || self::LITESPEED === $t;
	}

	/**
	 * Whether Apache can stamp a response header from `.htaccess`
	 * (i.e. mod_headers is loaded).
	 *
	 * This decides whether the static-rewrite fast path can be used at
	 * all. A statically-served file bypasses PHP entirely, so the ONLY
	 * way to mark it as a cache HIT is a `Header` directive in the
	 * rewrite block. Without mod_headers that directive is silently
	 * swallowed by its `<IfModule>` guard, and the site serves fast but
	 * completely invisible cache hits — no `X-XSpeed-Cache` header for
	 * the user, nothing for the hit counter. That is exactly the
	 * "cache works, dashboard says 0%" report this check exists to
	 * prevent. (Cache::static_rewrite_allowed() consumes it.)
	 *
	 * Detection is best-effort by necessity:
	 *   - mod_php exposes apache_get_modules() — authoritative.
	 *   - Under PHP-FPM that function doesn't exist. Assume the module
	 *     IS present, matching Apache's own default build (mod_headers
	 *     ships enabled in every mainstream distro package). Guessing
	 *     "absent" there would push every FPM site onto the slower
	 *     drop-in path over a detection limitation rather than a real
	 *     capability gap; the loopback probe in
	 *     Cache::probe_static_rewrite() is what catches a genuinely
	 *     header-less FPM host.
	 *
	 * @return bool
	 */
	public static function apache_has_mod_headers(): bool {
		if ( function_exists( 'apache_get_modules' ) ) {
			$has = in_array( 'mod_headers', apache_get_modules(), true );
		} else {
			$has = true; // FPM: undetectable, assume the distro default.
		}

		/**
		 * Filter: xspeed_apache_has_mod_headers
		 *
		 * Override mod_headers detection. Return false on a host where
		 * `.htaccess` Header directives are stripped (some managed
		 * stacks do this) to force cache hits through the PHP drop-in,
		 * where they are stamped and counted.
		 *
		 * @param bool $has Whether mod_headers appears to be available.
		 */
		return (bool) apply_filters( 'xspeed_apache_has_mod_headers', $has );
	}

	/**
	 * Accept a candidate access-log path only if it can actually be
	 * tail-scanned, else ''.
	 *
	 * `is_readable()` alone is not enough. The official WordPress and
	 * Apache Docker images symlink `access.log -> /dev/stdout`, i.e. a
	 * PIPE: `is_file()` is false, `filesize()` is 0, and `is_readable()`
	 * is false for the PHP user. Hit_Counter::collect_server_log_hits()
	 * fseek()s to a stored byte offset and reads forward, which a pipe
	 * or character device cannot support at all — it would either fail
	 * or block. Requiring a REGULAR file makes that contract explicit
	 * instead of relying on the filesize>0 test to reject pipes as a
	 * side effect. Containerised Apache is the standard layout, not an
	 * edge case, so this path is common. (Field report: hit ratio stuck
	 * at 0% on Dockerised Apache while the cache served correctly.)
	 *
	 * @param string $path Candidate path.
	 * @return string The path when usable, '' otherwise.
	 */
	private static function usable_access_log( string $path ): string {
		if ( '' === $path ) {
			return '';
		}
		// is_file() resolves symlinks, so access.log -> /var/log/real.log
		// is still accepted; only the pipe/device targets are rejected.
		if ( ! @is_file( $path ) || ! is_readable( $path ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- racey stat on an external log; treated as "unusable".
			return '';
		}
		return $path;
	}

	/**
	 * Best-effort path to the web server's access log, used to count
	 * static-rewrite HITs that bypass PHP on Apache/LiteSpeed (those
	 * requests are served straight from disk and never reach our
	 * Hit_Counter inline — see Hit_Counter::collect_server_log_hits()).
	 *
	 * Resolution order:
	 *   1. The `XSPEED_ACCESS_LOG` constant, if defined (explicit override
	 *      for hosts where the log lives somewhere non-standard).
	 *   2. The `xspeed_access_log_path` filter (programmatic override).
	 *   3. Auto-detection: a short list of the standard Apache/LiteSpeed
	 *      access-log locations, returning the first that exists AND is
	 *      readable by the PHP user.
	 *
	 * Every route is funnelled through usable_access_log(), so an
	 * override can no more hand us a pipe than auto-detection can.
	 *
	 * Returns '' when nothing usable is found — a very common case on
	 * managed/cPanel hosts where the PHP user can't read the server log,
	 * and on containers where it's a symlink to stdout. Callers MUST
	 * treat '' as "can't count static hits here" and fall back
	 * gracefully (the drop-in path still counts its own HITs).
	 *
	 * @return string Absolute path, or '' if none is usable.
	 */
	public static function access_log_path(): string {
		if ( defined( 'XSPEED_ACCESS_LOG' ) && is_string( XSPEED_ACCESS_LOG ) && '' !== XSPEED_ACCESS_LOG ) {
			return self::usable_access_log( XSPEED_ACCESS_LOG );
		}

		/**
		 * Filter: xspeed_access_log_path
		 *
		 * Override the auto-detected access-log path. Return '' to disable
		 * server-log hit counting entirely.
		 *
		 * @param string|null $path Null = use auto-detection below.
		 */
		$filtered = apply_filters( 'xspeed_access_log_path', null );
		if ( is_string( $filtered ) ) {
			return self::usable_access_log( $filtered );
		}

		// Auto-detect: the standard Apache + OpenLiteSpeed/LiteSpeed
		// Enterprise access-log locations. First readable, NON-EMPTY file
		// wins — an empty global access.log (common on LiteSpeed, which
		// logs per-vhost instead) must not shadow the real per-vhost log we
		// discover below.
		$candidates = array(
			'/var/log/apache2/access.log',              // Debian/Ubuntu Apache
			'/var/log/httpd/access_log',                // RHEL/CentOS Apache
			'/var/log/apache2/other_vhosts_access.log', // Debian multi-vhost
			'/usr/local/lsws/logs/access.log',          // OpenLiteSpeed global
			'/var/log/lshttpd/access.log',              // LiteSpeed Enterprise
		);
		foreach ( $candidates as $path ) {
			// usable_access_log() enforces "regular file + readable"; the
			// non-empty test stays here so an empty global log doesn't
			// shadow the real per-vhost one found further below.
			if ( '' !== self::usable_access_log( $path ) && (int) @filesize( $path ) > 0 ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- racey stat, treated as "skip".
				return $path;
			}
		}

		// LiteSpeed (and some Apache vhost setups) write a per-vhost
		// `<vhost>.access.log` rather than a single global file. Scan the
		// known log dirs for the most-recently-written, readable, non-empty
		// *.access.log and use that. Auto-tracks whichever vhost is serving
		// this site without the admin having to set a path.
		$dirs = array( '/usr/local/lsws/logs', '/var/log/lshttpd', '/var/log/apache2', '/var/log/httpd' );
		$best = '';
		$best_mtime = 0;
		foreach ( $dirs as $dir ) {
			if ( ! is_dir( $dir ) ) {
				continue;
			}
			$globbed = glob( $dir . '/*access*log*' );
			if ( ! is_array( $globbed ) ) {
				continue;
			}
			foreach ( $globbed as $path ) {
				// Same regular-file contract as the fixed candidates: a
				// glob can just as easily turn up a symlink to stdout.
				if ( '' === self::usable_access_log( $path ) || (int) @filesize( $path ) === 0 ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
					continue;
				}
				$mtime = (int) @filemtime( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				if ( $mtime > $best_mtime ) {
					$best_mtime = $mtime;
					$best       = $path;
				}
			}
		}
		return $best;
	}

	/**
	 * GZIP support category for the UI:
	 *   'auto'   — toggling writes server config (Apache / LiteSpeed)
	 *   'manual' — must be configured outside the plugin (nginx, IIS, unknown)
	 */
	public static function gzip_mode() {
		return self::supports_htaccess() ? 'auto' : 'manual';
	}

	/**
	 * Whether the server can serve Brotli-compressed responses.
	 *
	 * Brotli is an optional server module (mod_brotli on Apache,
	 * ngx_brotli on nginx, built in on LiteSpeed/OpenLiteSpeed) — unlike
	 * GZIP it is NOT guaranteed present. We report availability so the UI
	 * and any add-on (xspeed-pro Brotli module) can decide whether to
	 * emit Brotli rules or fall back to GZIP only.
	 *
	 * Detection, cheapest signal first:
	 *   1. LiteSpeed — Brotli is part of the core server, always available.
	 *   2. Apache mod_php — apache_get_modules() lists 'mod_brotli'.
	 *   3. PHP `brotli` extension (kjdev/php-ext-brotli) — lets us at least
	 *      pre-compress static files even when the web server can't.
	 * Anything else (nginx/FPM, IIS, unknown) is reported as not detected;
	 * the user can still wire ngx_brotli manually and the UI surfaces a
	 * snippet, mirroring how GZIP behaves on nginx.
	 *
	 * Result is filterable so a host with a known-good but undetectable
	 * setup (e.g. nginx + ngx_brotli) can force-enable.
	 */
	public static function brotli_available(): bool {
		$available = false;

		if ( self::LITESPEED === self::type() ) {
			$available = true;
		} elseif ( function_exists( 'apache_get_modules' ) && in_array( 'mod_brotli', apache_get_modules(), true ) ) {
			$available = true;
		} elseif ( function_exists( 'brotli_compress' ) ) {
			$available = true;
		}

		/**
		 * Filter detected Brotli availability.
		 *
		 * @param bool $available Whether Brotli serving was detected.
		 */
		return (bool) apply_filters( 'xspeed_brotli_available', $available );
	}

	/**
	 * Is WordPress running inside a container (Docker / Podman / k8s)?
	 *
	 * Three signals checked in cheapness order, OR'd together:
	 *   1. /.dockerenv exists — Docker's traditional marker; rare absence.
	 *   2. /proc/self/mountinfo references /var/lib/docker/overlay2 or
	 *      containerd/podman storage drivers — works under cgroup v2.
	 *   3. /proc/1/cgroup names docker / kubepods / containerd / podman / lxc
	 *      — the cgroup v1 signal, still present on older Docker installs.
	 *
	 * Any single positive returns true. On non-Linux hosts (Windows /
	 * macOS / WSL host process), all three quietly return false and we
	 * fall back to "not containerized."
	 */
	public static function is_containerized(): bool {
		// 1. Docker marker file — cheap to stat, almost always present.
		if ( file_exists( '/.dockerenv' ) ) {
			return true;
		}
		// 2. mountinfo overlay2 / containerd footprint — works under cgroup v2.
		// Gate on is_readable() first: on non-Linux hosts (macOS/Windows) or
		// hosts that hide /proc (open_basedir, hardened Apache), the file is
		// absent and reading it would emit a warning. Query Monitor surfaces
		// even @-suppressed warnings, so guard rather than silence. (FBS-83114)
		if ( is_readable( '/proc/self/mountinfo' ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- /proc/self/mountinfo is a virtual file; WP_Filesystem doesn't model /proc.
			$mounts = file_get_contents( '/proc/self/mountinfo' );
			if ( is_string( $mounts ) && '' !== $mounts && preg_match( '#(docker/overlay2|/var/lib/containerd|/var/lib/podman)#i', $mounts ) ) {
				return true;
			}
		}
		// 3. cgroup v1 fallback.
		if ( is_readable( '/proc/1/cgroup' ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- See above.
			$cgroup = file_get_contents( '/proc/1/cgroup' );
			if ( is_string( $cgroup ) && '' !== $cgroup && preg_match( '#(docker|kubepods|containerd|podman|lxc)#i', $cgroup ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Is WordPress likely behind a reverse proxy (host nginx → container
	 * php-fpm, host nginx → docker nginx, etc.)? Detection is a heuristic
	 * built from the headers WordPress hands to PHP: when a proxy forwards
	 * the request it almost always sets X-Forwarded-* or X-Real-IP.
	 *
	 * False positives (CDN-only forwarding without a local reverse proxy)
	 * are acceptable — the caller uses this signal only to soften messaging
	 * that would otherwise mislead container-host customers. False negatives
	 * (proxy that strips headers) just mean we keep showing the snippet
	 * paste UX, which is the safe default.
	 */
	public static function is_behind_proxy(): bool {
		$proxy_headers = array( 'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED_HOST', 'HTTP_X_FORWARDED_PROTO', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_SERVER' );
		foreach ( $proxy_headers as $h ) {
			if ( ! empty( $_SERVER[ $h ] ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * High-level topology classifier driving the rewrite-alert UX.
	 *
	 * Decides WHERE the user's nginx snippet needs to be installed
	 * (or whether automatic install via .htaccess covers it). The
	 * dashboard banner uses the return value to render the right
	 * "paste this here" message — there is no topology that can't
	 * benefit from xSpeed's static-rewrite; the question is only
	 * which nginx is in the cache file's filesystem.
	 *
	 * Returns one of:
	 *   'htaccess'        — Apache / LiteSpeed; .htaccess block is
	 *                       installed automatically, no user action.
	 *   'nginx-host'      — self-managed nginx on the host (no
	 *                       container in the request path). User
	 *                       pastes the snippet into their vhost
	 *                       (typically /etc/nginx/sites-enabled/<site>).
	 *   'nginx-container' — nginx running inside the same container
	 *                       as PHP. User pastes the snippet into the
	 *                       container's nginx config (typically
	 *                       docker/nginx.conf in the site's
	 *                       docker-compose dir). Host nginx (if any)
	 *                       is a reverse-proxy that just forwards
	 *                       bytes — snippet does NOT go there.
	 *   'unknown'         — IIS or undetected; treat as manual.
	 */
	public static function rewrite_topology(): string {
		$type = self::type();
		if ( self::APACHE === $type || self::LITESPEED === $type ) {
			return 'htaccess';
		}
		if ( self::NGINX === $type ) {
			return self::is_containerized() ? 'nginx-container' : 'nginx-host';
		}
		return 'unknown';
	}

	private static function server_signature() {
		return isset( $_SERVER['SERVER_SOFTWARE'] )
			? sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) )
			: '';
	}

	/**
	 * Detect active caching plugins that would conflict with xSpeed. Returns
	 * a list of human-readable labels for any conflicting plugin currently
	 * active; empty array means the field is clear. Used by the onboarding
	 * wizard's Step 1 health check and (Phase 2.1) the main dashboard's
	 * Health card.
	 *
	 * The detection key is the plugin's main file path relative to the
	 * plugins directory — the same value WordPress uses internally in
	 * `active_plugins`. Folder-only checks (`is_plugin_active('foo/')`)
	 * would false-positive on disabled plugins still on disk.
	 */
	public static function conflicts() {
		if ( ! function_exists( 'is_plugin_active' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}

		$known = array(
			'wp-rocket/wp-rocket.php'                                 => 'WP Rocket',
			'w3-total-cache/w3-total-cache.php'                       => 'W3 Total Cache',
			'wp-super-cache/wp-cache.php'                             => 'WP Super Cache',
			'wp-fastest-cache/wpFastestCache.php'                     => 'WP Fastest Cache',
			'litespeed-cache/litespeed-cache.php'                     => 'LiteSpeed Cache',
			'cache-enabler/cache-enabler.php'                         => 'Cache Enabler',
			'comet-cache/comet-cache.php'                             => 'Comet Cache',
			'hummingbird-performance/wp-hummingbird.php'              => 'Hummingbird',
			'sg-cachepress/sg-cachepress.php'                         => 'SG Optimizer',
			'breeze/breeze.php'                                       => 'Breeze',
			'autoptimize/autoptimize.php'                             => 'Autoptimize',
			'flying-press/flying-press.php'                           => 'FlyingPress',
			'nitropack/main.php'                                      => 'NitroPack',
		);

		$active = array();
		foreach ( $known as $file => $label ) {
			if ( is_plugin_active( $file ) ) {
				$active[] = $label;
			}
		}
		return $active;
	}
}

```
