$_ ) {
if ( ! self::query_key_is_ignored( (string) $key, $ignored ) ) {
return false;
}
}
}
$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
$path = (string) strtok( $request_uri, '?' );
$excluded_urls = is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array();
if ( Glob_Matcher::any_match( $excluded_urls, $path ) ) {
return false;
}
// Cookie-based exclusion. We only check cookie NAMES (matching
// values would leak content-sensitive logic into the cache key
// rules); presence of any matching cookie name skips cache.
$excluded_cookies = is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array();
if ( ! empty( $excluded_cookies ) && ! empty( $_COOKIE ) ) {
foreach ( array_keys( $_COOKIE ) as $cookie_name ) {
if ( Glob_Matcher::any_match( $excluded_cookies, (string) $cookie_name ) ) {
return false;
}
}
}
// User-agent bypass list. Substring match (not glob) since UA
// strings have so much variation that glob anchoring rarely
// helps and confuses users.
$bypass_uas = is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array();
if ( ! empty( $bypass_uas ) ) {
$ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
foreach ( $bypass_uas as $needle ) {
if ( '' !== $needle && false !== stripos( $ua, (string) $needle ) ) {
return false;
}
}
}
// Per-post override (Phase 3.4). Honored only on singular
// post-context requests — archives / 404s / taxonomies use the
// global policy above.
if ( Cache_Rules::should_skip_for_post( Cache_Rules::current_post_id() ) ) {
return false;
}
return true;
}
/**
* Is this query-string key on the ignored-params allow-list? Supports
* trailing-star globs (`utm_*` matches `utm_source`, `utm_medium`,
* etc.) so users don't have to enumerate every UTM variant.
*/
private static function query_key_is_ignored( string $key, array $ignored ): bool {
return Glob_Matcher::any_match( $ignored, $key );
}
public static function cache_key() {
$host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : 'default';
$uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/';
// Strip the query string from the key so /post and /post?utm_*=…
// share the same cache entry. should_cache() above already
// rejected requests with non-ignored params, so by the time we
// build the key the only params left are safe to drop.
$uri = (string) strtok( $uri, '?' );
// Optional device bucket: when mobile_separate is on, mobile and
// desktop responses live in different cache files so themes that
// serve different HTML by device (AMP, WPtouch, Jetpack mobile)
// can't poison each other.
$device = '';
$opts = Settings_Manager::get( 'cache' );
if ( ! empty( $opts['mobile_separate'] ) ) {
$device = self::is_mobile_request() ? '|m' : '|d';
}
return md5( $host . $uri . $device );
}
/**
* Server-side mobile detection. Prefers WordPress's `wp_is_mobile()`
* which uses the same UA tokens as core (so our bucket aligns with
* whatever theme-side branching uses). Falls back to a tiny inline
* detector if wp_is_mobile() isn't loaded (e.g. the drop-in path).
*/
private static function is_mobile_request(): bool {
if ( function_exists( 'wp_is_mobile' ) ) {
return (bool) wp_is_mobile();
}
$ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
if ( '' === $ua ) {
return false;
}
// Mirrors the token list wp_is_mobile() uses internally.
return (bool) preg_match( '/(Mobile|Android|Silk\/|Kindle|BlackBerry|Opera Mini|Opera Mobi)/i', $ua );
}
public static function cache_file_for( $key ) {
return XSPEED_CACHE_DIR . '/' . $key . '.html';
}
public static function is_expired( $file ) {
// cache_expiry now owned by CacheModule; per-post override
// (Phase 3.4) shrinks the TTL further when the editor set one.
$opts = Settings_Manager::get( 'cache' );
$max_age = (int) $opts['cache_expiry'] * HOUR_IN_SECONDS;
$post_override = Cache_Rules::expiry_override_seconds_for_post( Cache_Rules::current_post_id() );
if ( null !== $post_override ) {
$max_age = $post_override;
}
return ( time() - filemtime( $file ) ) > $max_age;
}
/**
* Accumulator for the full response body across all output-handler phases.
*
* PHP invokes an ob_start() callback once per flush, and each invocation
* only receives the chunk produced *since the previous flush*. If anything
* during the render calls `ob_flush()` or `flush()` (some themes, lazy-
* load plugins, AMP, etc. do), the final-phase call would otherwise only
* see the tail of the page — and we'd cache a truncated response that
* gets served repeatedly until purge. We accumulate every chunk here so
* the cache file always reflects the complete page.
*
* @var string
*/
private static $accumulated = '';
public static function finalize_buffer( $buffer, $phase = PHP_OUTPUT_HANDLER_FINAL ) {
self::$accumulated .= $buffer;
// On non-final phases (mid-request flushes), pass the current chunk
// through to the client unmodified and keep collecting. The WP 6.9
// filter path always passes the full body in one shot with the
// default $phase, so it falls straight through to the final block.
$is_final = ( $phase & ( PHP_OUTPUT_HANDLER_FINAL | PHP_OUTPUT_HANDLER_END ) ) !== 0;
if ( ! $is_final ) {
return $buffer;
}
$full = self::$accumulated;
self::$accumulated = '';
if ( strlen( $full ) < 255 ) {
return $buffer;
}
if ( function_exists( 'http_response_code' ) && 200 !== http_response_code() ) {
return $buffer;
}
// If no mid-request flush happened, $buffer === $full and we can
// safely minify the on-wire bytes too. Otherwise earlier chunks have
// already been sent unminified, so we minify only what goes to disk —
// the first visitor sees unminified HTML, every cache hit after that
// is minified.
$single_chunk = ( $buffer === $full );
// minify_html now owned by the Minify module; read through the
// module's storage so this stays consistent with the engine that
// applies CSS/JS minification.
$minify_opts = Settings_Manager::get( 'minify' );
if ( ! empty( $minify_opts['minify_html'] ) ) {
$full = Minifier::minify_html( $full );
if ( $single_chunk ) {
$buffer = $full;
}
}
if ( ! file_exists( XSPEED_CACHE_DIR ) ) {
wp_mkdir_p( XSPEED_CACHE_DIR );
self::write_silence( XSPEED_CACHE_DIR );
}
// Path safety: cache_file_for() builds `XSPEED_CACHE_DIR . '/' . $key . '.html'`
// where $key comes from md5() — guaranteed to be exactly 32 lowercase
// hex chars, so no traversal sequence ('..', '/', null byte, etc.)
// can appear. The write is therefore always inside XSPEED_CACHE_DIR.
$file = self::cache_file_for( self::cache_key() );
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context for credentials; cache writes happen on frontend requests where it's unavailable.
file_put_contents( $file, $full, LOCK_EX );
// Static-cache tree (xspeed-static/{host}{path}/index.html). The
// .htaccess rewrite block serves this file directly via the web
// server, bypassing PHP for ~3-5× lower TTFB vs the drop-in path.
// store_static() returns silently on any path/permission issue —
// the drop-in remains the safety net.
self::store_static( $full );
return $buffer;
}
/**
* Write the current response to the static-cache tree at
* `xspeed-static/{host}{request_uri}/index.html`. The web-server
* rewrite block points at this path so cache hits skip PHP
* entirely. Caller already minified/finalized $html.
*
* Path safety: $host is restricted to a `[a-zA-Z0-9.\-]` allowlist;
* $uri has its query string stripped, null bytes removed, '..'
* sequences collapsed, and after concatenation we verify the
* resolved real path stays inside XSPEED_CACHE_STATIC_DIR before
* any write. Anything off the happy path returns silently.
*/
private static function store_static( string $html ): void {
$host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
$uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
$host = preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host );
$uri = str_replace( "\0", '', $uri );
$uri = (string) strtok( $uri, '?' );
if ( '' === $host || '' === $uri ) {
return;
}
// Collapse any traversal sequences before path resolution.
$uri = preg_replace( '#/+#', '/', $uri );
if ( false !== strpos( $uri, '..' ) ) {
return;
}
$base = rtrim( XSPEED_CACHE_STATIC_DIR, '/' );
$dir = $base . '/' . $host . rtrim( $uri, '/' );
$file = $dir . '/index.html';
// Resolve the parent against the cache root to be sure the
// final path is inside our tree even if the OS does anything
// funny with multi-byte sequences.
$base_real = realpath( WP_CONTENT_DIR );
if ( false === $base_real || 0 !== strpos( $base, $base_real ) ) {
return;
}
if ( ! file_exists( $dir ) ) {
wp_mkdir_p( $dir );
}
if ( ! is_dir( $dir ) ) {
return;
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- Same rationale as the flat-hash cache write above: WP_Filesystem isn't available on frontend requests, and the cache write must happen during shutdown.
file_put_contents( $file, $html, LOCK_EX );
}
/**
* @param string $cause Free-form human reason. Recorded in the
* Activity log to give users context (e.g.
* 'post saved', 'settings change', 'manual',
* 'theme switch').
*/
public static function purge_all( string $cause = 'manual' ) {
$count = 0;
if ( is_dir( XSPEED_CACHE_DIR ) ) {
$files = glob( XSPEED_CACHE_DIR . '/*.html' );
if ( $files ) {
$count = count( $files );
foreach ( $files as $f ) {
wp_delete_file( $f );
}
}
}
// Static-cache tree purge — recursive because the layout is
// xspeed-static/{host}/{path}/index.html, so a flat glob can't
// reach everything.
if ( is_dir( XSPEED_CACHE_STATIC_DIR ) ) {
$count += self::rmtree_html( XSPEED_CACHE_STATIC_DIR );
}
self::update_stats( array( 'last_purge' => time() ) );
// Trigger of WP_CLI / hook / admin-bar purges all hit the same
// path. Record once with the supplied cause so the dashboard
// activity feed reads naturally.
Activity_Log::record(
'cache_purged',
sprintf( 'Cache purged (%s) — %d file%s removed', $cause, $count, 1 === $count ? '' : 's' ),
Activity_Log::INFO
);
}
/**
* Recursively delete every `index.html` and empty directory inside
* the static-cache tree. Used by purge_all(). Returns the number of
* .html files removed so purge stats stay accurate across the flat
* + static caches.
*/
private static function rmtree_html( string $dir ): int {
if ( ! is_dir( $dir ) ) {
return 0;
}
$removed = 0;
// SCANDIR_SORT_NONE skips alphabetic sort — we're going to walk
// the whole tree regardless of order.
$entries = @scandir( $dir, SCANDIR_SORT_NONE ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
if ( false === $entries ) {
return 0;
}
foreach ( $entries as $entry ) {
if ( '.' === $entry || '..' === $entry ) {
continue;
}
$path = $dir . '/' . $entry;
if ( is_dir( $path ) ) {
$removed += self::rmtree_html( $path );
// Best-effort empty-dir cleanup; ignore failures (a
// foreign file inside would block rmdir, which is fine).
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Best-effort empty-dir cleanup; WP_Filesystem needs admin credentials we don't have during a normal purge.
@rmdir( $path );
continue;
}
if ( substr( $entry, -5 ) === '.html' ) {
wp_delete_file( $path );
++$removed;
}
}
return $removed;
}
/**
* Drop a "silence is golden" index.php into a directory so apaches/nginx
* with directory listing enabled don't expose cache contents.
*/
public static function write_silence( $dir ) {
$file = trailingslashit( $dir ) . 'index.php';
if ( ! file_exists( $file ) ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context for credentials; cache dir setup may run during a frontend page render.
file_put_contents( $file, " $count,
'cache_size' => $size,
'last_purge' => isset( $stats['last_purge'] ) ? (int) $stats['last_purge'] : 0,
// Rolling 24h cache performance — sourced from Hit_Counter's
// hourly buckets. The frontend uses hit_ratio to drive the
// CacheHero stat grid + the Health module's panel.
'hits_24h' => $totals['hits'],
'misses_24h' => $totals['misses'],
'hit_ratio' => $totals['ratio'],
);
}
/**
* Apply the user's enable/disable choice. Called only from the REST
* toggle endpoint, which is gated by current_user_can( 'manage_options' )
* and a verified REST nonce. This is the only place the drop-in and
* the WP_CACHE constant are written — they MUST NOT happen on
* register_activation_hook (WordPress.org review requirement).
*
* @param bool $enable User's choice.
* @return array{
* enabled: bool,
* dropin_installed: bool,
* wp_cache_constant: bool,
* wp_config_writable: bool,
* manual_snippet: ?string
* }
*/
public static function toggle( $enable ) {
$enable = (bool) $enable;
if ( $enable ) {
$dropin_ok = self::install_dropin();
$wp_config_ok = self::set_wp_cache_constant( true );
$rewrite_ok = self::install_rewrite();
$snippet = $wp_config_ok ? null : "define( 'WP_CACHE', true );";
Activity_Log::record(
'cache_enabled_event',
$wp_config_ok
? 'Cache enabled. Drop-in installed, WP_CACHE constant set.'
: 'Cache enabled. Drop-in installed; wp-config.php not writable — add the WP_CACHE snippet manually.',
$wp_config_ok ? Activity_Log::SUCCESS : Activity_Log::WARN
);
return array(
'enabled' => true,
'dropin_installed' => (bool) $dropin_ok,
'wp_cache_constant' => (bool) $wp_config_ok,
'rewrite_installed' => (bool) $rewrite_ok,
'wp_config_writable' => self::wp_config_writable(),
'manual_snippet' => $snippet,
'nginx_snippet' => self::nginx_snippet(),
);
}
self::remove_dropin();
self::set_wp_cache_constant( false );
self::remove_rewrite();
Activity_Log::record(
'cache_disabled_event',
'Cache disabled. Drop-in removed.',
Activity_Log::INFO
);
return array(
'enabled' => false,
'dropin_installed' => false,
'wp_cache_constant' => false,
'rewrite_installed' => false,
'wp_config_writable' => self::wp_config_writable(),
'manual_snippet' => null,
'nginx_snippet' => self::nginx_snippet(),
);
}
/**
* Check wp-config.php writability via WP_Filesystem. Plugin Check flags
* direct is_writable() under WordPress.WP.AlternativeFunctions.
*/
private static function wp_config_writable() {
global $wp_filesystem;
if ( ! function_exists( 'WP_Filesystem' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
WP_Filesystem();
return $wp_filesystem ? (bool) $wp_filesystem->is_writable( ABSPATH . 'wp-config.php' ) : false;
}
/**
* Nginx server-block snippet mirroring the Apache rewrite block.
* We never auto-write nginx config — it sits outside the WordPress
* root and is owned by the server admin — but the dashboard
* surfaces this snippet when nginx is detected so the admin can
* paste it once and unlock the same PHP-bypass speedup we get on
* Apache / LiteSpeed via .htaccess.
*
* Returns null when the server isn't nginx (no point showing it).
*/
public static function nginx_snippet(): ?string {
if ( Server::NGINX !== Server::type() ) {
return null;
}
$rel = '/' . ltrim( str_replace( ABSPATH, '/', XSPEED_CACHE_STATIC_DIR ), '/' );
$rel = rtrim( $rel, '/' );
// WP-Rocket-canonical pattern: every condition lives at
// SERVER level (outside any location block). Each one appends
// a tag to $xspeed_no_cache; the final check is a single
// string-equality against the unmodified default "no-cache".
// Only when ALL conditions pass does the rewrite fire,
// jumping the request to the static file's URL. nginx then
// restarts location matching against the new path, where
// regular static-file serving takes over.
//
// Why server-level + a single rewrite (instead of try_files
// inside `location /`): nginx's well-documented "if is evil"
// quirk silently disables `try_files`'s last fallback when
// any `if` in the same location is true. Moving the `if`s
// outside any location dodges the trap completely, because
// server-level rewrite is the documented stable path.
//
// `last` (not `break`) restarts location matching — required
// so the rewritten static-file URI gets served via the normal
// static-file location, not re-matched against `location /`
// where our own rewrite would loop.
//
// The cache existence check is the LAST condition in the
// chain so when the file isn't cached, $xspeed_no_cache
// gets a "-nofile" tag and the rewrite is skipped — the
// request falls through to whatever `location /` the user
// already had (typically `try_files $uri $uri/ /index.php?$args;`).
$lines = array();
$lines[] = '# xSpeed static cache — paste at SERVER level (inside `server { }`,';
$lines[] = '# above your existing `location / { … }`; do NOT put it inside any';
$lines[] = '# location block).';
$lines[] = 'set $xspeed_no_cache "no-cache";';
$lines[] = 'if ($request_method != GET) { set $xspeed_no_cache "$xspeed_no_cache-method"; }';
$lines[] = 'if ($args) { set $xspeed_no_cache "$xspeed_no_cache-args"; }';
$lines[] = 'if ($http_cookie ~* "(wordpress_logged_in|comment_author|wp-postpass_)") { set $xspeed_no_cache "$xspeed_no_cache-cookie"; }';
$lines[] = 'if (!-f "$document_root' . $rel . '/$host$uri/index.html") { set $xspeed_no_cache "$xspeed_no_cache-nofile"; }';
$lines[] = 'if ($xspeed_no_cache = "no-cache") {';
$lines[] = ' rewrite ^ ' . $rel . '/$host$uri/index.html last;';
$lines[] = '}';
return implode( "\n", $lines );
}
/**
* Emit LiteSpeed Cache module headers on the cache-miss render
* path so the server caches the response and serves subsequent
* requests at edge speed without booting PHP again.
*
* LSCache reads two response headers:
* - X-LiteSpeed-Cache-Control: public,max-age=N → "cache for N s"
* - X-LiteSpeed-Tag: tag1,tag2 → tag the entry for selective
* purge later via X-LiteSpeed-Purge in any later response.
*
* Server detection runs through Server::type() so a non-LiteSpeed
* host (Apache / nginx / IIS) sees a no-op — the headers are
* harmless if emitted there, but we skip them to keep response
* headers tidy. The conflict check defers to the LiteSpeed Cache
* plugin when present so we don't double-cache.
*/
public static function maybe_emit_lscache_headers(): void {
if ( headers_sent() ) {
return;
}
if ( Server::LITESPEED !== Server::type() ) {
return;
}
// is_plugin_active() lives in wp-admin/includes/plugin.php which
// isn't auto-loaded on front-end requests. Use the option layer
// directly to avoid pulling in admin code from a render path.
$active = (array) get_option( 'active_plugins', array() );
if ( in_array( 'litespeed-cache/litespeed-cache.php', $active, true ) ) {
return;
}
$opts = Settings_Manager::get( 'cache' );
$expiry = isset( $opts['cache_expiry'] ) ? (int) $opts['cache_expiry'] : DAY_IN_SECONDS;
$expiry = max( 60, min( $expiry, 30 * DAY_IN_SECONDS ) );
// Tags scope the entry so a single post change can purge just
// that page (or its archive) instead of the whole cache. We
// always send the global `xspeed` tag plus a path-derived one.
$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/';
$path_tag = 'xspeed_' . md5( (string) strtok( $request_uri, '?' ) );
header( 'X-LiteSpeed-Cache-Control: public,max-age=' . $expiry );
header( 'X-LiteSpeed-Tag: xspeed,' . $path_tag );
}
/**
* Reconcile drop-in + WP_CACHE + rewrite block with the user's
* saved choice. Runs on admin_init. Cheap when nothing's wrong
* (one option read + a handful of file_exists / defined checks);
* writes only when state has drifted (typical cause: plugin
* upgrade wiped the drop-in, foreign plugin removed our WP_CACHE
* define, or someone hand-edited .htaccess).
*
* Skipped during the WP plugin updater run so we don't race
* the upgrader's own filesystem operations.
*/
public static function auto_heal(): void {
if ( defined( 'WP_INSTALLING' ) && WP_INSTALLING ) {
return;
}
if ( wp_doing_ajax() || wp_doing_cron() ) {
return;
}
$opts = get_option( 'xspeed_options', array() );
if ( empty( $opts['cache_enabled'] ) ) {
return;
}
$dropin_target = WP_CONTENT_DIR . '/advanced-cache.php';
$dropin_ours = false;
if ( file_exists( $dropin_target ) ) {
$contents = @file_get_contents( $dropin_target ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
$dropin_ours = is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' );
}
if ( ! $dropin_ours ) {
self::install_dropin();
}
if ( ! defined( 'WP_CACHE' ) || ! WP_CACHE ) {
self::set_wp_cache_constant( true );
}
// Rewrite block goes last. It's what turns the static-cache
// tree into a PHP-bypass — every cache hit served by the web
// server directly. Without it we still cache, just at drop-in
// speed (~85ms TTFB) instead of static-file speed (~25-40ms).
if ( ! self::rewrite_installed() ) {
self::install_rewrite();
}
}
/**
* Build the .htaccess rules that map cacheable requests to the
* static-cache tree. Conditions are deliberately strict: GET only,
* empty query string, no session/comment-author/post-password
* cookie, and the static file must exist on disk. Anything that
* fails one of these falls through to PHP and the drop-in / full
* WordPress path.
*
* @return string[] Lines for insert_with_markers().
*/
public static function rewrite_block_lines(): array {
// Path relative to ABSPATH so the rule lives in the site-root
// .htaccess regardless of where wp-content sits. WP_CONTENT_DIR
// can be moved, so we compute the document-root-relative form
// at install time and bake it into the rule.
$rel = str_replace( ABSPATH, '/', XSPEED_CACHE_STATIC_DIR );
$rel = '/' . ltrim( $rel, '/' );
$rel = rtrim( $rel, '/' );
return array(
'',
' RewriteEngine On',
' RewriteCond %{REQUEST_METHOD} ^GET$',
' RewriteCond %{QUERY_STRING} ^$',
' RewriteCond %{HTTP_COOKIE} !(wordpress_logged_in|comment_author|wp-postpass_) [NC]',
// Capture REQUEST_URI without its trailing slash into %1.
// store_static() writes `{host}{uri-without-trailing-slash}/index.html`,
// so this normalization lets `/blog/` and `/blog` both hit
// the same cache file without producing the double-slash
// path that would skip the -f check below.
' RewriteCond %{REQUEST_URI} ^(.*?)/?$',
' RewriteCond %{DOCUMENT_ROOT}' . $rel . '/%{HTTP_HOST}%1/index.html -f',
' RewriteRule . ' . $rel . '/%{HTTP_HOST}%1/index.html [L]',
'',
);
}
/**
* Active probe that confirms the web-server static-rewrite path is
* actually serving cached files. Writes a probe file with a random
* nonce, fetches it over HTTP at its public URL, and checks whether
* the response was served directly by the web server (Last-Modified
* + ETag headers + no X-Powered-By: PHP).
*
* Server-agnostic: same probe works for nginx (snippet pasted) and
* Apache / LiteSpeed (.htaccess block installed). If the rewrite
* isn't engaged, the request falls through to WordPress and PHP
* adds its own headers, which the probe detects and reports.
*
* Throttled via a 5-minute transient — we never want this running
* on every Health card paint.
*
* @return array{active:bool, reason:string, code?:int, php?:bool, expires?:int}
*/
public static function probe_static_rewrite(): array {
$cached = get_transient( 'xspeed_rewrite_probe' );
if ( is_array( $cached ) ) {
return $cached;
}
$home = home_url( '/' );
$host = (string) wp_parse_url( $home, PHP_URL_HOST );
if ( '' === $host ) {
$result = array( 'active' => false, 'reason' => 'home_url has no host' );
set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
return $result;
}
// Use a randomised path AND nonce so a stale CDN cache entry
// from a prior probe can never make a broken install look
// healthy. Path is namespaced under __xspeed_probe__ so the
// directory listing stays obvious if cleanup misfires.
$slug = wp_generate_password( 12, false, false );
$nonce = wp_generate_password( 24, false, false );
$probe_dir = XSPEED_CACHE_STATIC_DIR . '/' . $host . '/__xspeed_probe__/' . $slug;
$probe_file = $probe_dir . '/index.html';
$probe_url = trailingslashit( $home ) . '__xspeed_probe__/' . $slug . '/';
if ( ! file_exists( $probe_dir ) ) {
wp_mkdir_p( $probe_dir );
}
if ( ! is_dir( $probe_dir ) ) {
$result = array( 'active' => false, 'reason' => 'cannot create probe dir' );
set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
return $result;
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin credentials we may not have here; the file is in our own cache dir.
file_put_contents( $probe_file, $nonce, LOCK_EX );
$resp = wp_remote_get(
$probe_url,
array(
'timeout' => 4,
'sslverify' => false,
'redirection' => 0,
'headers' => array( 'Cache-Control' => 'no-cache' ),
)
);
// Best-effort cleanup so we don't accumulate probe dirs even
// if subsequent calls all hit the transient.
if ( file_exists( $probe_file ) ) {
wp_delete_file( $probe_file );
}
if ( is_dir( $probe_dir ) ) {
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Best-effort probe-dir cleanup; WP_Filesystem needs admin credentials we don't have here.
@rmdir( $probe_dir );
}
if ( is_wp_error( $resp ) ) {
$result = array(
'active' => false,
'reason' => 'http error: ' . $resp->get_error_message(),
);
set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
return $result;
}
$code = (int) wp_remote_retrieve_response_code( $resp );
$body = (string) wp_remote_retrieve_body( $resp );
$ua_php = '' !== (string) wp_remote_retrieve_header( $resp, 'x-powered-by' );
$has_etag = '' !== (string) wp_remote_retrieve_header( $resp, 'etag' )
|| '' !== (string) wp_remote_retrieve_header( $resp, 'last-modified' );
$match = trim( $body ) === $nonce;
// "Active" = the web server served our raw nonce bytes back
// AND emitted the static-serve markers (ETag / Last-Modified)
// AND didn't add an X-Powered-By: PHP header. All three are
// individually noisy; together they're conclusive.
$active = $match && $has_etag && ! $ua_php && 200 === $code;
if ( $active ) {
$reason = 'static-served';
} elseif ( 200 === $code && $match && $ua_php ) {
$reason = 'php served the file instead of nginx/Apache (rewrite block missing)';
} elseif ( 200 === $code && ! $match ) {
$reason = 'unexpected body (CDN cached an older response?)';
} elseif ( 404 === $code ) {
$reason = 'probe URL returned 404 (rewrite block missing or wrong path)';
} else {
$reason = sprintf( 'unexpected response (HTTP %d, body %d B, php=%s)', $code, strlen( $body ), $ua_php ? 'yes' : 'no' );
}
$result = array(
'active' => $active,
'reason' => $reason,
'code' => $code,
'php' => $ua_php,
);
set_transient( 'xspeed_rewrite_probe', $result, 5 * MINUTE_IN_SECONDS );
return $result;
}
public static function rewrite_installed(): bool {
$htaccess = ABSPATH . '.htaccess';
if ( ! file_exists( $htaccess ) ) {
return false;
}
$existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
if ( ! is_string( $existing ) ) {
return false;
}
return false !== strpos( $existing, '# BEGIN xSpeed Static Cache' );
}
/**
* Install the static-cache rewrite block at the TOP of .htaccess.
*
* Position matters: WordPress's own block ends with
* `RewriteRule . /index.php [L]` which routes every non-file
* request to PHP. The [L] flag stops the current rewrite pass,
* but Apache restarts the cycle; on the second pass REQUEST_URI
* is /index.php and no static-file check can match. The only
* reliable position for a "serve static if it exists" rule is
* before WordPress's block.
*
* WP's insert_with_markers() always appends, so we manage the
* block manually: strip any prior xSpeed Static Cache markers,
* then write our block followed by the rest of the file.
*/
public static function install_rewrite(): bool {
$htaccess = ABSPATH . '.htaccess';
$existing = file_exists( $htaccess ) ? @file_get_contents( $htaccess ) : ''; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
if ( false === $existing ) {
$existing = '';
}
// Apache/LiteSpeed only. nginx hosts: rule won't fire, drop-in
// covers; we skip the write so we don't litter their root.
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Pre-flight check before file_put_contents; WP_Filesystem requires admin credentials we don't have inside a manage_options REST request.
if ( file_exists( $htaccess ) && ! is_writable( $htaccess ) ) {
return false;
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- See above.
if ( ! file_exists( $htaccess ) && ! is_writable( ABSPATH ) ) {
return false;
}
$cleaned = self::strip_marker_block( $existing, 'xSpeed Static Cache' );
$block = self::marker_block( 'xSpeed Static Cache', self::rewrite_block_lines() );
$next = $block . ( '' === $cleaned ? '' : "\n" . $cleaned );
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents, PluginCheck.CodeAnalysis.WriteFile.ABSPATHDetected -- WP_Filesystem requires admin credentials we don't have here; toggle() runs in a REST request authorized by manage_options nonce. The target is the site's .htaccess (configuration file managed by WP core itself), not user data — wp_upload_dir() doesn't apply.
return false !== file_put_contents( $htaccess, $next, LOCK_EX );
}
public static function remove_rewrite(): bool {
$htaccess = ABSPATH . '.htaccess';
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- See install_rewrite() rationale.
if ( ! file_exists( $htaccess ) || ! is_writable( $htaccess ) ) {
return false;
}
$existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
if ( false === $existing ) {
return false;
}
$cleaned = self::strip_marker_block( $existing, 'xSpeed Static Cache' );
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents, PluginCheck.CodeAnalysis.WriteFile.ABSPATHDetected -- See install_rewrite() rationale.
return false !== file_put_contents( $htaccess, $cleaned, LOCK_EX );
}
/**
* Strip a `# BEGIN ` ... `# END ` block from a
* .htaccess-style file, including any blank line that immediately
* follows it. Idempotent — returns the input unchanged if the
* marker isn't present.
*/
private static function strip_marker_block( string $contents, string $marker ): string {
$pattern = '/# BEGIN ' . preg_quote( $marker, '/' ) . '\b.*?# END ' . preg_quote( $marker, '/' ) . "\b[^\n]*\n?\n?/s";
$out = preg_replace( $pattern, '', $contents );
return is_string( $out ) ? $out : $contents;
}
private static function marker_block( string $marker, array $lines ): string {
$header = "# BEGIN $marker\n";
$header .= "# The directives (lines) between \"BEGIN $marker\" and \"END $marker\" are\n";
$header .= "# dynamically generated, and should only be modified via WordPress filters.\n";
$header .= "# Any changes to the directives between these markers will be overwritten.\n";
$footer = "# END $marker\n";
return $header . implode( "\n", $lines ) . "\n" . $footer;
}
public static function install_dropin() {
$source = XSPEED_DIR . 'includes/advanced-cache.php';
$target = WP_CONTENT_DIR . '/advanced-cache.php';
if ( ! file_exists( $source ) ) {
return false;
}
global $wp_filesystem;
if ( ! function_exists( 'WP_Filesystem' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
WP_Filesystem();
if ( ! $wp_filesystem ) {
return false;
}
$source_contents = $wp_filesystem->get_contents( $source );
if ( ! is_string( $source_contents ) ) {
return false;
}
if ( file_exists( $target ) ) {
$existing = $wp_filesystem->get_contents( $target );
$is_xspeed = is_string( $existing ) && false !== strpos( $existing, 'XSPEED_DROPIN' );
if ( $is_xspeed ) {
if ( $existing === $source_contents ) {
return true;
}
return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE );
}
// Foreign drop-in (e.g. left over from another cache plugin) — back it up
// before overwriting so the user can recover if needed. Uploads dir
// (not wp-content root) keeps the backup out of WordPress's reserved
// drop-in location.
$upload = wp_upload_dir( null, false );
$basedir = isset( $upload['basedir'] ) ? trailingslashit( $upload['basedir'] ) . 'xspeed-backups' : false;
if ( $basedir ) {
if ( ! file_exists( $basedir ) ) {
wp_mkdir_p( $basedir );
self::write_silence( $basedir );
}
$backup = $basedir . '/advanced-cache.foreign-' . gmdate( 'Ymd-His' ) . '.php.bak';
$wp_filesystem->move( $target, $backup, true );
} else {
$wp_filesystem->delete( $target );
}
}
return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE );
}
public static function remove_dropin() {
$target = WP_CONTENT_DIR . '/advanced-cache.php';
if ( ! file_exists( $target ) ) {
return;
}
global $wp_filesystem;
if ( ! function_exists( 'WP_Filesystem' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
WP_Filesystem();
if ( ! $wp_filesystem ) {
return;
}
$contents = $wp_filesystem->get_contents( $target );
if ( is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' ) ) {
wp_delete_file( $target );
}
}
public static function set_wp_cache_constant( $enable ) {
$wp_config = ABSPATH . 'wp-config.php';
if ( ! file_exists( $wp_config ) ) {
return false;
}
global $wp_filesystem;
if ( ! function_exists( 'WP_Filesystem' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
WP_Filesystem();
if ( ! $wp_filesystem || ! $wp_filesystem->is_writable( $wp_config ) ) {
return false;
}
$config = $wp_filesystem->get_contents( $wp_config );
if ( $enable ) {
if ( strpos( $config, "define( 'WP_CACHE'" ) !== false || strpos( $config, "define('WP_CACHE'" ) !== false ) {
return true;
}
$config = preg_replace( '/(<\?php)/', "$1\ndefine( 'WP_CACHE', true );", $config, 1 );
} else {
$config = preg_replace( "/define\\(\\s*['\"]WP_CACHE['\"]\\s*,\\s*true\\s*\\);\\s*\\n?/", '', $config );
}
return (bool) $wp_filesystem->put_contents( $wp_config, $config, FS_CHMOD_FILE );
}
public function admin_bar_purge( $wp_admin_bar ) {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$wp_admin_bar->add_node(
array(
'id' => 'xspeed-purge',
'title' => __( 'Purge xSpeed Cache', 'xspeed' ),
'href' => wp_nonce_url( admin_url( 'admin-post.php?action=xspeed_purge' ), 'xspeed_purge' ),
)
);
}
public function handle_admin_bar_purge() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Unauthorized.', 'xspeed' ), 403 );
}
check_admin_referer( 'xspeed_purge' );
self::purge_all();
wp_safe_redirect( wp_get_referer() ?: admin_url() );
exit;
}
}