$_ ) { 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(); self::ensure_hits_log_file(); $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). */ /** * Create wp-content/cache/xspeed/hits.log as an empty file so the * server-level rewrite's `access_log` directive has somewhere to * write on first request. Idempotent — touches an existing file * without disturbing accumulated lines. Called from Cache::toggle() * on enable and from auto_heal() when the file is missing. * * Permissions matter here. The file is created by PHP-FPM (often uid * www-data), but the nginx process that appends HIT lines may run as a * DIFFERENT uid — on multi-container hosts (e.g. xclude/Kinsta: nginx in * its own container as uid `nginx`, PHP-FPM in another as `www-data`) * they don't share a user at all. A default-umask 0644 file is then * unwritable by nginx, the access_log write silently fails, and the * dashboard shows a 0% hit ratio even though static HITs are serving. * So we widen the dir to 0777 and the file to 0666 — group/other write — * so whatever uid nginx runs as can append. (The file holds only HIT * request lines, no secrets.) */ public static function ensure_hits_log_file(): bool { $dir = XSPEED_CACHE_DIR; if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) { return false; } // Ensure the dir is traversable + writable by a different-uid nginx. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- nginx (a separate uid in multi-container setups) must be able to create/append the log; WP_Filesystem layers ownership overrides that defeat that intent. @chmod( $dir, 0777 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort; the access_log just stays empty if it fails. $path = $dir . '/hits.log'; if ( ! file_exists( $path ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch -- See docblock: must be a plain touch, not WP_Filesystem. @touch( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- non-fatal helper; failures already covered by the dir check. } // World-writable so a different-uid nginx can append HIT lines. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- See docblock. @chmod( $path, 0666 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort. return file_exists( $path ); } 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;`). // Absolute path to the hit-log file from the nginx process's // filesystem view. Nginx's `access_log buffer=N flush=Ns` form // requires a literal path — `$document_root` variables are // rejected — so we emit `WP_CONTENT_DIR/cache/xspeed/hits.log` // computed by PHP. Works on every topology where the nginx // process shares a filesystem with PHP (container or host). $hits_abs = WP_CONTENT_DIR . '/cache/xspeed/hits.log'; $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"; }'; // Neither `add_header` nor `access_log` is allowed inside an `if{}` // at server level (nginx rejects with "directive is not allowed // here"). The logging therefore lives in a `location` block that // matches the rewritten URI after `rewrite … last;` restarts // location matching. Every HIT lands there exactly once, every // MISS / PHP-served request never matches it. $lines[] = 'if ($xspeed_no_cache = "no-cache") {'; $lines[] = ' rewrite ^ ' . $rel . '/$host$uri/index.html last;'; $lines[] = '}'; $lines[] = ''; $lines[] = '# Serve + log the cached HIT. The `^~` modifier is REQUIRED:'; $lines[] = '# the rewrite above lands on a `…/index.html` URI, and nginx'; $lines[] = '# matches regex locations (e.g. a `~* \.html$` block a vhost'; $lines[] = '# commonly has) BEFORE plain prefix locations. Without `^~`,'; $lines[] = '# that regex block wins, this location never matches, the HIT'; $lines[] = '# is never logged, and on some vhosts the request falls through'; $lines[] = '# to PHP — i.e. the static rewrite silently does nothing.'; $lines[] = '# `^~` makes this prefix match beat any regex location, so the'; $lines[] = '# rewritten request always serves here from disk and logs once.'; $lines[] = 'location ^~ ' . $rel . '/ {'; $lines[] = ' internal;'; $lines[] = ' access_log ' . $hits_abs . ' combined buffer=16k flush=10s;'; $lines[] = ' # Visible HIT indicator for the fast path: this file was'; $lines[] = ' # served directly by nginx from xSpeed\'s static cache,'; $lines[] = ' # bypassing PHP entirely. The PHP drop-in sends the same'; $lines[] = ' # header with value "HIT (php)" on its slower fallback path.'; $lines[] = ' add_header X-XSpeed-Cache "HIT (nginx)" always;'; $lines[] = '}'; return implode( "\n", $lines ); } /** * Aggregate every enabled module's nginx_directives() into one * pasteable server-block snippet. Replaces the per-module "paste * this snippet" notices with a single consolidated paste — every * future feature toggle just regenerates this output. * * Returns null on non-nginx hosts (nothing to paste). * * Sections render in module-registration order so the layout stays * predictable; each module gets a comment header `# `. */ public static function full_nginx_server_block(): ?string { if ( Server::NGINX !== Server::type() ) { return null; } $blocks = array(); foreach ( Module_Registry::all() as $module ) { $directives = $module->nginx_directives(); if ( ! is_string( $directives ) || '' === trim( $directives ) ) { continue; } $blocks[] = "# === " . $module->slug() . " ===\n" . rtrim( $directives ); } if ( empty( $blocks ) ) { return null; } $header = "# xSpeed unified nginx config — paste once into your\n" . "# nginx vhost's `server { }` block (or container nginx\n" . "# config for containerized hosts), above `location / { }`.\n" . "# Regenerated on every dashboard load — re-paste after\n" . "# toggling features so the directives reflect current state.\n"; return $header . "\n" . implode( "\n\n", $blocks ) . "\n"; } /** * 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(); } // HITs log file — nginx writes one line per HIT served directly // (see nginx_snippet()), Cache::get_stats() drains the file via // Hit_Counter::collect_nginx_log_hits(). If the file vanishes // (plugin upgrade wiped wp-content/cache/), nginx errors silently // on the access_log directive and the counter stays at 0. self::ensure_hits_log_file(); } /** * 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; } }