.br). On this // PHP serve path the web server never sees the .br, so without // this a br-capable client got the plain .html — precompression // did nothing here. Falls through to plain readfile otherwise. $br = self::maybe_serve_brotli( $file ); if ( null !== $br ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- streaming a static cache file directly; WP_Filesystem would buffer through PHP memory and is not appropriate for response streaming. readfile( $br ); exit; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- readfile is optimal for streaming a static cache file directly to the visitor; WP_Filesystem would buffer through PHP memory and is not appropriate for response streaming. readfile( $file ); exit; } // Cache miss → render fresh + write cache. On LiteSpeed we send an // explicit "stand down" header so the server's LSCache module does // NOT cache + shadow our response — xSpeed's own .htaccess static // rewrite owns hit serving (and hit accounting) here, exactly as on // Apache. See maybe_emit_lscache_headers() for the full rationale. self::maybe_emit_lscache_headers(); // We're about to render fresh + cache → miss for this request. Hit_Counter::record_miss(); // WP < 6.9 fallback: ob_start() with a callback, paired with an // explicit shutdown close so the buffer lifecycle is visible to // reviewers and Plugin Check, instead of relying on PHP's implicit // request-end flush. We record our nesting level so close_buffer() // flushes ONLY the buffer we opened. ob_start( array( __CLASS__, 'finalize_buffer' ) ); self::$buffer_level = ob_get_level(); add_action( 'shutdown', array( __CLASS__, 'close_buffer' ), 0 ); } /** * Close the cache buffer opened by maybe_start_cache(). * * Guarded by the recorded buffer level so we never flush a buffer that * another plugin pushed on top of (or under) ours. If something else is * currently on top, we leave the stack alone — PHP's shutdown sequence * will unwind buffers in order and our finalize_buffer() callback will * still run when our level becomes the topmost one. */ public static function close_buffer() { if ( null === self::$buffer_level ) { return; } if ( ob_get_level() === self::$buffer_level ) { ob_end_flush(); } self::$buffer_level = null; } public static function should_cache() { $opts = Settings::get(); if ( empty( $opts['cache_enabled'] ) ) { return false; } if ( is_user_logged_in() || is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { return false; } if ( defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE ) { return false; } // All exclusion knobs now owned by CacheModule. $cache_opts = Settings_Manager::get( 'cache' ); $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : ''; if ( 'GET' !== $method ) { return false; } // Search-results requests carry a `s` query param, which the // query-string gate below would normally reject as "dynamic". An // add-on (xspeed-pro search cache) can opt them in: when this is a // genuine is_search() and the filter returns true, the `s` param is // treated as cacheable (the search term goes into the cache key so // different searches stay distinct — see cache_key()). $cache_search = self::should_cache_search(); // Feed opt-in is resolved BEFORE the query-string gate so query-form // feeds (/?feed=rss2, used on plain-permalink sites) aren't rejected // as "dynamic" by that gate — the `feed` param is then allowed through // just like the search `s` param. Feeds are excluded by default (the // `/feed/` pattern in excluded_urls); an add-on (xspeed-pro feed cache) // opts them back in via the filter. (FBS-82407 #4) $is_feed_request = function_exists( 'is_feed' ) && is_feed(); /** * Whether to cache the current feed request. * * Default false → feeds fall through to the normal URL-exclusion * rules (so `/feed/` keeps them out). A listener returning true * opts this feed request into caching. * * @param bool $cache_feed Whether to cache this feed request. */ $cache_feed = $is_feed_request && (bool) apply_filters( 'xspeed_should_cache_feed', false ); // Query string handling: anything OUTSIDE the ignored-params // allow-list (utm_*, fbclid, gclid by default) means a unique // request that we don't want to share with the canonical cache // entry. Skip cache rather than poison the key. $query_raw = isset( $_SERVER['QUERY_STRING'] ) ? sanitize_text_field( wp_unslash( $_SERVER['QUERY_STRING'] ) ) : ''; if ( '' !== $query_raw ) { $ignored = is_array( $cache_opts['ignored_query_params'] ?? null ) ? $cache_opts['ignored_query_params'] : array(); parse_str( $query_raw, $params ); foreach ( $params as $key => $_ ) { // Allow the search param through when search caching is on. if ( $cache_search && 's' === $key ) { continue; } // Allow query-form feed params through when feed caching opted // this request in (?feed=rss2 / &withcomments=1 on feeds). if ( $cache_feed && in_array( $key, array( 'feed', 'withcomments', 'withoutcomments' ), true ) ) { continue; } 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 ( ! $cache_feed && 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; } /** * Final say on whether the current request is cacheable. * * Runs at template_redirect (full WP context), so listeners may use * conditional tags (is_search(), is_feed(), is_404(), * wp_is_maintenance_mode(), …). The core engine has already applied * its own exclusion rules and reached `true`; a listener returning * false vetoes caching for this request. This is the documented * extension point add-ons (xspeed-pro) hook to add their own * request-level cache policy without forking the engine. * * Note: this gates the WRITE side. The pre-WP drop-in * (advanced-cache.php) cannot run PHP filters, so request types that * must never be *served* from a stale file are handled by not * writing them here and/or by purging — see the conflict notes in * advanced-cache.php. * * @param bool $should_cache Whether to cache the current request. */ return (bool) apply_filters( 'xspeed_should_cache', true ); } /** * Whether the current request is a 404 we may cache. * * True only when: it's a genuine main-query is_404(), an add-on opted * in via `xspeed_should_cache_404` (default false), and the request * isn't a transient 404 we must never freeze — maintenance mode or a * 404 emitted while the DB/site is in an error state. The xspeed-pro * 404 cache flips the filter; Free never caches 404s on its own. */ public static function should_cache_404(): bool { if ( ! function_exists( 'is_404' ) || ! is_404() ) { return false; } // Never cache a 404 served because the site is down for // maintenance — that screen disappears the moment maintenance // ends, and a cached copy would outlive it. if ( function_exists( 'wp_is_maintenance_mode' ) && wp_is_maintenance_mode() ) { return false; } /** * Whether to cache the current 404 response. * * Default false. A listener returning true opts the (genuine) * 404 into the page cache, served back for any unknown URL under * one generic key. The 404 status is preserved on the HIT. * * @param bool $cache_404 Whether to cache this 404. */ return (bool) apply_filters( 'xspeed_should_cache_404', false ); } /** * Whether the current request is an internal search-results page we * may cache. * * True only when: it's a genuine main-query is_search() with a * non-empty term, and an add-on opted in via `xspeed_should_cache_search` * (default false). The search term is folded into the cache key (see * search_term() / cache_key()) so different searches stay distinct. * The xspeed-pro search cache flips the filter; Free never caches * search results on its own. */ public static function should_cache_search(): bool { if ( ! function_exists( 'is_search' ) || ! is_search() ) { return false; } // Empty search (`?s=`) renders the same as a normal archive and // carries no term to key on — let it fall through to the usual // rules rather than caching an ambiguous entry. if ( '' === self::search_term() ) { return false; } /** * Whether to cache the current search-results request. * * Default false. A listener returning true opts the search page * into the cache, keyed by the normalized search term. * * @param bool $cache_search Whether to cache this search request. */ return (bool) apply_filters( 'xspeed_should_cache_search', false ); } /** * The current request's normalized search term, or '' if none. Reads * the raw `s` query param (works on the pre-WP drop-in path too, where * get_search_query() isn't available), trims + lowercases so * "WordPress" and "wordpress" share one entry, and collapses internal * whitespace. */ public static function search_term(): string { $raw = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only cache-key derivation from a public search param; no state change. $raw = trim( $raw ); if ( '' === $raw ) { return ''; } $raw = preg_replace( '/\s+/', ' ', $raw ); return function_exists( 'mb_strtolower' ) ? mb_strtolower( $raw ) : strtolower( $raw ); } /** * 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'; // Cacheable 404s share ONE generic per-host entry — keying them by // URL would let a scanner flood (millions of random paths) bloat // the cache with identical 404 bodies. Both the write and the HIT // lookup run through here, so they agree on the key automatically. if ( self::should_cache_404() ) { return md5( $host . '|404' ); } $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'; } // Search-results requests fold the normalized term into the key so // /?s=foo and /?s=bar get distinct entries (the query string is // otherwise stripped above). Only added when search caching opted // in, so non-search URLs are unaffected. $search = self::should_cache_search() ? '|s=' . self::search_term() : ''; // Query-form feeds (/?feed=rss2 vs /?feed=atom) share the same path // once the query is stripped, so fold the feed type into the key to // keep the flavors distinct. Pretty-permalink feeds (/feed/rss/) carry // the type in $uri already and are unaffected. (FBS-82407 #4) $feed = ''; if ( function_exists( 'is_feed' ) && is_feed() && function_exists( 'get_query_var' ) ) { $feed_type = (string) get_query_var( 'feed' ); if ( '' !== $feed_type ) { $feed = '|feed=' . preg_replace( '/[^a-z0-9]/i', '', $feed_type ); } } return md5( $host . $uri . $device . $search . $feed ); } /** * 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(); } // Fallback for the rare context where wp_is_mobile() isn't loaded. // Mirrors core's wp_is_mobile() EXACTLY — including the // Sec-CH-UA-Mobile client hint it checks *before* UA tokens — so the // bucket this picks matches whatever the engine's primary path (and // the drop-in's own copy of this logic) would pick for the same // request. Drift here re-introduces the cross-path key mismatch. if ( isset( $_SERVER['HTTP_SEC_CH_UA_MOBILE'] ) ) { return '?1' === $_SERVER['HTTP_SEC_CH_UA_MOBILE']; } $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; if ( '' === $ua ) { return false; } 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'; } /** * If a precompressed Brotli sibling (`.br`) exists and the client * advertises `Accept-Encoding: br`, emit the Brotli response headers and * return the `.br` path to stream. Returns null to fall through to the * plain file. Keeps the PHP serve path in parity with the web server's * static .br serving (mod_brotli / ngx_brotli rewrite). * * Free has no Brotli logic of its own — this only fires when an add-on * (the Pro Brotli module) actually wrote the .br, so it's a safe no-op * on Free-only installs. * * @param string $file Absolute path to the cached .html file. * @return string|null The .br path to stream, or null to serve $file. */ public static function maybe_serve_brotli( string $file ): ?string { if ( headers_sent() ) { return null; } $accept = isset( $_SERVER['HTTP_ACCEPT_ENCODING'] ) ? strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT_ENCODING'] ) ) ) : ''; // Match `br` as a token (comma/space delimited), not a substring, so // a hypothetical "xbr" encoding can't false-positive. if ( ! preg_match( '/(^|[\s,])br([\s,;]|$)/', $accept ) ) { return null; } $br = $file . '.br'; if ( ! is_string( $br ) || ! file_exists( $br ) || ! is_readable( $br ) ) { return null; } header( 'Content-Encoding: br' ); header( 'Vary: Accept-Encoding', false ); // The byte length changes for the compressed body — drop any // Content-Length the caller may have set so the stream isn't // truncated/padded. readfile() lets the SAPI set the right length. header_remove( 'Content-Length' ); return $br; } /** * Sidecar metadata file for a cache entry. Holds response bits the HIT * path must replay — Content-Type (cached feeds → application/rss+xml, * sitemaps → text/xml) and status (a cached 404 must serve 404, not * 200). JSON, one tiny file per entry, written only when there's * something non-default to replay. */ public static function cache_meta_for( $key ) { return XSPEED_CACHE_DIR . '/' . $key . '.meta'; } /** * Read the .meta sidecar for a cache entry as an array, or [] if none. * Keys: 'content_type' (string), 'status' (int). Used on the HIT path * to replay them before streaming the file. */ private static function read_meta( $key ): array { $meta_file = self::cache_meta_for( $key ); if ( ! file_exists( $meta_file ) ) { return array(); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- our own cache dir; WP_Filesystem needs admin creds unavailable on a frontend HIT. $raw = file_get_contents( $meta_file ); $data = json_decode( (string) $raw, true ); return is_array( $data ) ? $data : array(); } /** * Conditional-GET support for a cache HIT. Emits Last-Modified + ETag * derived from the cache file's mtime, and — when the request's * If-Modified-Since / If-None-Match still match — sends 304 Not Modified * and returns true (caller should exit without a body). Returns false to * proceed with a normal 200 body. Lets aggregators/browsers skip * re-downloading an unchanged cached response. (FBS-82407 #5) * * @param string $file Absolute path to the cache .html file. * @return bool True when a 304 was sent. */ public static function serve_not_modified( string $file ): bool { $mtime = (int) filemtime( $file ); if ( $mtime <= 0 ) { return false; } $last_modified = gmdate( 'D, d M Y H:i:s', $mtime ) . ' GMT'; $etag = '"' . md5( $file . '|' . $mtime ) . '"'; header( 'Last-Modified: ' . $last_modified ); header( 'ETag: ' . $etag ); $ims = isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ) ) : ''; $inm = isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_NONE_MATCH'] ) ) ) : ''; $etag_match = '' !== $inm && false !== strpos( $inm, $etag ); $time_match = '' !== $ims && ( strtotime( $ims ) >= $mtime ); if ( $etag_match || $time_match ) { if ( function_exists( 'http_response_code' ) ) { http_response_code( 304 ); } return true; } return false; } 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; } /** * Filter the max-age (seconds) for the current cache entry. * * Lets an add-on apply a request-type-specific TTL — e.g. the * xspeed-pro feed cache gives feeds a longer expiry than pages, * since aggregators tolerate more staleness. Return seconds. * * @param int $max_age Computed max-age in seconds. */ $max_age = (int) apply_filters( 'xspeed_cache_max_age', $max_age ); 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; } // Status gate. We cache 200 by default. A 404 may be cached too, // but only when an add-on (xspeed-pro 404 cache) opts in for a // genuine is_404() — never a transient 404 (maintenance screen, // DB error, or a 404 emitted outside the main query), which would // otherwise be frozen until purge. Any other status is skipped. $status = function_exists( 'http_response_code' ) ? (int) http_response_code() : 200; if ( 200 !== $status ) { if ( 404 !== $status || ! self::should_cache_404() ) { 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 ); /** * Filter: xspeed_cache_final_html * * Last chance to transform the fully-rendered page HTML before it is * minified and written to the cache file. Runs on cache MISS only, so * whatever a listener injects here is baked into the cached HTML and * replayed on every subsequent HIT (the drop-in short-circuits before * PHP on a HIT — a wp_head hook would never fire there). * * The Preload module uses this to inject the LCP-image * + preconnect hints and add fetchpriority="high" to the hero . * Keep listeners fast and idempotent; this is the on-wire body. * * @param string $full Complete page HTML. */ $full = (string) apply_filters( 'xspeed_cache_final_html', $full ); if ( $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. $key = self::cache_key(); $file = self::cache_file_for( $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 ); /** * Fires after the flat hash cache file ({md5}.html) is written. * * Mirror of `xspeed_static_file_written` for the flat cache. The PHP * serve path (Cache::maybe_serve_brotli / the drop-in) serves THIS * file and looks for a `{md5}.html.br` sibling — which only the Pro * Brotli listener on this hook writes. Without it the .br sibling was * never created and the PHP path could never serve Brotli (FBS-83039, * Blocker 2): the static-tree .br (written on xspeed_static_file_written) * lives in a different cache layout the PHP path never reads. * * @param string $file Absolute path to the flat cache file just written. * @param string $full The HTML written to it. */ do_action( 'xspeed_flat_file_written', $file, $full ); // Persist a non-default Content-Type so the HIT path can replay it // (cached feeds must serve application/rss+xml, not text/html). // Only written when the response set a content-type other than // the HTML default — pages don't pay for an extra file. self::write_meta( $key ); // 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. // // Skip it entirely when mobile_separate is on: the rewrite is // disabled in that mode (static_rewrite_allowed()), so a static file // would only be dead weight — and a device-blind one at that. // Skip the static-tree write for responses the web server can't replay // correctly: a non-200 status (a cached 404 would be served as a soft // 200, FBS-82406) or a non-HTML content-type (a cached feed would go // out as text/html, FBS-82407). The web server serves these .html files // directly with no PHP, so there's no .meta replay — keep them on the // drop-in / PHP path instead, which DOES replay status + content-type. if ( self::static_rewrite_allowed() && self::response_is_plain_html() ) { 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. $written = file_put_contents( $file, $html, LOCK_EX ); if ( false !== $written ) { /** * Fires after a static cache file (index.html) is written. * * The extension point for serving pre-compressed siblings: * the xspeed-pro Brotli module writes `index.html.br` next to * the file here so the web server's static rewrite can serve a * Brotli copy to clients that advertise `Accept-Encoding: br`, * falling back to GZIP / the plain file otherwise. No core * behavior depends on a listener being present. * * @param string $file Absolute path to the static cache file just written. * @param string $html The HTML written to it. */ do_action( 'xspeed_static_file_written', $file, $html ); } } /** * Write the .meta sidecar for a cache entry when the response carries * anything the HIT path must replay beyond a plain 200 text/html: * - a non-HTML Content-Type (cached feeds → application/rss+xml, * sitemaps → text/xml, …), and/or * - a non-200 status (a cached 404 must serve 404, not 200). * * Ordinary 200 text/html pages get NO .meta file, so the common path * stays a single write. * * @param string $key Cache key for the current request. */ /** * True only for a plain 200 text/html response — the only kind the * web-server static tree can serve correctly (it streams the .html with * no PHP, so it can't replay a 404 status or a feed Content-Type). Used * to gate store_static() so cached 404s / feeds stay on the replay-capable * drop-in / PHP path. (FBS-82406, FBS-82407) */ private static function response_is_plain_html(): bool { $status = function_exists( 'http_response_code' ) ? (int) http_response_code() : 200; if ( 200 !== $status && $status > 0 ) { return false; } foreach ( headers_list() as $header ) { if ( 0 === stripos( $header, 'content-type:' ) ) { $ct = trim( substr( $header, strlen( 'content-type:' ) ) ); if ( '' !== $ct && false === stripos( $ct, 'text/html' ) ) { return false; } } } return true; } private static function write_meta( string $key ): void { $content_type = ''; foreach ( headers_list() as $header ) { if ( 0 === stripos( $header, 'content-type:' ) ) { $content_type = trim( substr( $header, strlen( 'content-type:' ) ) ); } } $status = function_exists( 'http_response_code' ) ? (int) http_response_code() : 200; $meta = array(); $is_default_type = ( '' === $content_type || false !== stripos( $content_type, 'text/html' ) ); if ( ! $is_default_type ) { $meta['content_type'] = $content_type; } if ( 200 !== $status && $status > 0 ) { $meta['status'] = $status; } // Per-content TTL (seconds). The drop-in and static fast paths can't // call is_expired() / the xspeed_cache_max_age filter (they run before // WP), so persist the resolved max-age here whenever it differs from // the plain page TTL — e.g. the Pro feed cache's 12h vs the 24h page // default. The fast paths read this to expire correctly. (FBS-82407) $opts = Settings_Manager::get( 'cache' ); $default_ttl = (int) $opts['cache_expiry'] * HOUR_IN_SECONDS; $ttl = (int) apply_filters( 'xspeed_cache_max_age', $default_ttl ); if ( $ttl > 0 && $ttl !== $default_ttl ) { $meta['ttl'] = $ttl; } // Nothing to replay → no sidecar. if ( empty( $meta ) ) { return; } $payload = wp_json_encode( $meta ); if ( false === $payload ) { return; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- our own cache dir; WP_Filesystem needs admin creds unavailable on a frontend shutdown write. file_put_contents( self::cache_meta_for( $key ), $payload, 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 ); } } // Remove the .meta sidecars (content-type for feeds/sitemaps) // alongside their .html entries. Not counted — they're not // cache "pages", just per-entry metadata. $meta = glob( XSPEED_CACHE_DIR . '/*.meta' ); if ( $meta ) { foreach ( $meta as $m ) { wp_delete_file( $m ); } } // Remove precompressed siblings (e.g. .html.br from the Pro // Brotli module). Not counted — same as .meta. Without this a // purge leaves stale .br bodies behind: disk bloat, and a // staleness window if precompression is later disabled. $br = glob( XSPEED_CACHE_DIR . '/*.br' ); if ( $br ) { foreach ( $br as $b ) { wp_delete_file( $b ); } } } // 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 ); } // REST response cache (cache/xspeed/rest/*.json) — same purge // triggers (publish, settings change) invalidate it too. $count += Rest_Cache::purge(); // Minified + combined CSS/JS (cache/xspeed/min/ and min/combined/). // purge_all is a full filesystem sweep and must clear these too, even // when the Minify module is currently disabled — orphaned min/ files // from a feature the user later turned off must still be removed, and // a stale combined-.css that the regenerated page no longer // references otherwise 404s and breaks the frontend. (FBS-83114/83116) if ( class_exists( '\\XSpeed\\Minifier' ) ) { Minifier::purge_minified(); } // Persistent object cache (Redis / Memcached). Flush regardless of // whether the Object Cache module is currently enabled — a drop-in // installed earlier keeps serving until flushed. if ( function_exists( 'wp_cache_flush' ) ) { wp_cache_flush(); } self::update_stats( array( 'last_purge' => time() ) ); // Fire AFTER the local sweep so module listeners (Critical CSS, // Unused CSS, Cloudflare edge purge) run — this action had three // registered listeners but was never emitted. Treat it as additive // (CDN / edge invalidation), not the mechanism for clearing local // files. (FBS-83114) do_action( 'xspeed_after_purge_all', $cause ); // 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 ); return $count; } /** * The per-type purge menu, LiteSpeed-style. Each entry is a cache type * the user can purge individually from the admin-bar dropdown. `visible` * controls whether the item shows (active + licensed module only) — it * NEVER limits Purge All, which always sweeps everything on disk. * * Pro registers its own types (Critical CSS, Unused CSS, …) by filtering * `xspeed_purge_types`, so Free degrades gracefully when Pro is absent. * * @return array */ public static function purge_types(): array { $minify_on = false; if ( class_exists( '\\XSpeed\\Settings_Manager' ) ) { $min = Settings_Manager::get( 'minify' ); $minify_on = ! empty( $min['minify_css'] ) || ! empty( $min['minify_js'] ) || ! empty( $min['combine_css'] ) || ! empty( $min['combine_js'] ); } // Object cache is "active" when an external object-cache drop-in is in // use — the canonical WP signal, independent of our settings option. $oc_on = function_exists( 'wp_using_ext_object_cache' ) && wp_using_ext_object_cache(); $types = array( 'all' => array( 'label' => __( 'Purge All', 'xspeed' ), 'visible' => true, ), 'page' => array( 'label' => __( 'Purge Page / Static Cache', 'xspeed' ), 'visible' => true, ), 'assets' => array( 'label' => __( 'Purge CSS / JS Cache', 'xspeed' ), 'visible' => $minify_on, ), 'object' => array( 'label' => __( 'Purge Object Cache', 'xspeed' ), 'visible' => $oc_on, ), 'rest' => array( 'label' => __( 'Purge REST Cache', 'xspeed' ), 'visible' => true, ), ); /** * Filter the admin-bar purge-type menu. Pro modules add their own * (Critical CSS, Unused CSS, CDN). Adding a type here only adds a * MENU item — purge_type() must know how to handle the same slug. * * @param array $types Map of slug => [label, visible]. */ return (array) apply_filters( 'xspeed_purge_types', $types ); } /** * Purge a single cache type by slug. 'all' delegates to purge_all(); * every other slug clears just its own artifacts. Unknown slugs (e.g. a * Pro type) fan out via the `xspeed_purge_type_{slug}` action so the * owning module can handle it. Returns the number of items removed where * countable. */ public static function purge_type( string $type ): int { switch ( $type ) { case 'all': return self::purge_all( 'manual' ); case 'page': $count = 0; if ( is_dir( XSPEED_CACHE_DIR ) ) { foreach ( (array) glob( XSPEED_CACHE_DIR . '/*.html' ) as $f ) { wp_delete_file( $f ); ++$count; } foreach ( (array) glob( XSPEED_CACHE_DIR . '/*.meta' ) as $m ) { wp_delete_file( $m ); } foreach ( (array) glob( XSPEED_CACHE_DIR . '/*.br' ) as $b ) { wp_delete_file( $b ); } } if ( is_dir( XSPEED_CACHE_STATIC_DIR ) ) { $count += self::rmtree_html( XSPEED_CACHE_STATIC_DIR ); } self::update_stats( array( 'last_purge' => time() ) ); return $count; case 'assets': if ( class_exists( '\\XSpeed\\Minifier' ) ) { Minifier::purge_minified(); } return 0; case 'object': if ( function_exists( 'wp_cache_flush' ) ) { wp_cache_flush(); } return 0; case 'rest': return Rest_Cache::purge(); default: // Pro / third-party type — let the owning module handle it. do_action( 'xspeed_purge_type_' . $type ); return 0; } } /** * Recursively delete every `index.html` (and its precompressed * `index.html.br` sibling, if the Pro Brotli module wrote one) plus * empty directories 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 — .br siblings are not counted * (they're encodings of a page, not pages). */ 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; } elseif ( substr( $entry, -3 ) === '.br' ) { // Precompressed sibling (index.html.br). Remove it too so a // purge doesn't orphan stale Brotli bodies. Not counted. wp_delete_file( $path ); } } 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(); self::sync_mobile_flag(); $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(), // Unified server-block snippet aggregating every enabled // module's directives — the same value the dashboard and // Health insight render. The wizard shows this so all three // surfaces stay in lockstep. Null on non-nginx hosts. 'nginx_server_block' => self::full_nginx_server_block(), ); } self::remove_dropin(); self::set_wp_cache_constant( false ); self::remove_rewrite(); // Drop the device-bucket marker too — with the drop-in gone there's // nothing left to read it, and leaving it behind would dirty a fresh // re-enable (and leaks across test runs). self::sync_mobile_flag( false ); 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(), 'nginx_server_block' => self::full_nginx_server_block(), ); } /** * 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.) */ /** * Directory holding the nginx hit log. Lives under uploads/, NOT the * cache dir — uninstall.php and a cache purge both delete the cache * dir, which would orphan the pasted nginx `access_log` directive's * parent directory and make `nginx -t` fail [emerg], taking down every * vhost on the host (FBS-82478). uploads/ always exists, isn't a * plugin-managed cache dir, and is never deleted on uninstall — so the * directive's target dir survives both, and nginx (which creates a * missing log FILE but not a missing DIR) can always open it. * * Falls back to the cache dir only if uploads is somehow unavailable. */ public static function hits_log_dir(): string { if ( function_exists( 'wp_upload_dir' ) ) { $uploads = wp_upload_dir( null, false ); if ( is_array( $uploads ) && empty( $uploads['error'] ) && ! empty( $uploads['basedir'] ) ) { return rtrim( (string) $uploads['basedir'], '/' ) . '/xspeed'; } } return XSPEED_CACHE_DIR; } /** Absolute path to the nginx hit log file. */ public static function hits_log_path(): string { return self::hits_log_dir() . '/hits.log'; } /** * Sync the drop-in's mobile-bucket flag file with the `mobile_separate` * setting. The drop-in (advanced-cache.php) runs before WordPress loads, * so it can't read the option — instead it checks for a zero-byte * `.mobile-separate` marker next to the cache files. When the setting is * on we touch the marker; when off we remove it. The drop-in's cache_key * computation keys off the marker's presence so its '|m'/'|d' device * bucket stays in lockstep with Cache::cache_key(). * * Without this, turning on mobile_separate made Cache::store() write keys * with a '|d'/'|m' suffix the drop-in never reproduced — so the drop-in's * file_exists() always missed, every HIT fell through to a full WP boot, * and the fast pre-WP path was silently dead. * * @param bool|null $enabled Force a state; null reads the current setting. */ public static function sync_mobile_flag( $enabled = null ): void { if ( null === $enabled ) { $opts = Settings_Manager::get( 'cache' ); $enabled = ! empty( $opts['mobile_separate'] ); } $dir = XSPEED_CACHE_DIR; $flag = $dir . '/.mobile-separate'; if ( $enabled ) { if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) { return; } if ( ! file_exists( $flag ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch, WordPress.PHP.NoSilencedErrors.Discouraged -- read by the pre-WP drop-in via file_exists(); must be a plain marker, not WP_Filesystem. @touch( $flag ); } return; } if ( file_exists( $flag ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal. @unlink( $flag ); } } /** * Write / remove the `.maintenance-active` sentinel next to the cache * files. The pre-WP drop-in checks for this marker and bails when present, * so a page cached while the site was live is NOT served during * maintenance / coming-soon mode — WordPress loads and renders the * maintenance screen instead. The Pro Maintenance-Cache module drives this * on the maintenance on/off transition. (FBS-82409 B1) * * @param bool $active True to arm the sentinel (entering maintenance), * false to clear it (site recovered). */ public static function sync_maintenance_flag( bool $active ): void { $dir = XSPEED_CACHE_DIR; $flag = $dir . '/.maintenance-active'; if ( $active ) { if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) { return; } if ( ! file_exists( $flag ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch, WordPress.PHP.NoSilencedErrors.Discouraged -- read by the pre-WP drop-in via file_exists(); must be a plain marker, not WP_Filesystem. @touch( $flag ); } return; } if ( file_exists( $flag ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal. @unlink( $flag ); } } /** * Reconcile every mobile_separate-dependent artifact to the current * setting. Called on boot and whenever the cache settings are saved, so * flipping mobile_separate at runtime can't leave the install in a * half-converted state. * * Three things must agree with the setting: * 1. the drop-in's `.mobile-separate` flag (sync_mobile_flag()), * 2. the device-blind server rewrite — present only when OFF * (static_rewrite_allowed()), * 3. the now-stale static-cache tree + page cache, which were keyed * under the old scheme and would serve wrong-device HTML. * * No-ops when the cache is disabled — there's nothing installed to * reconcile, and toggle() handles install/teardown itself. */ public static function reconcile_mobile_separate(): void { self::sync_mobile_flag(); // The rewrite/static reconciliation below needs the plugin's path // constants. They're absent in early-boot / unit-test contexts where // only the drop-in flag matters — bail to the flag-only behavior then. if ( ! defined( 'XSPEED_CACHE_STATIC_DIR' ) ) { return; } // Only touch the rewrite + caches when caching is actually on. $opts = get_option( 'xspeed_options', array() ); if ( empty( $opts['cache_enabled'] ) ) { return; } $rewrite_present = self::rewrite_installed(); $rewrite_wanted = self::static_rewrite_allowed(); if ( $rewrite_present === $rewrite_wanted ) { // Already consistent — nothing flipped, leave caches intact so a // plain settings save (e.g. expiry change) doesn't blow the cache. return; } // The setting flipped. Bring the rewrite into line and purge the // now-misbucketed cache so the next request re-primes under the new // device scheme. if ( $rewrite_wanted ) { self::install_rewrite(); } else { self::remove_rewrite(); } self::purge_all( 'mobile_separate changed' ); } /** * Whether the server-level static-rewrite fast path may be used. * * The rewrite serves `{host}{path}/index.html` straight from the web * server, keyed only by host + path — it has no way to run our PHP * device detection, so it can't tell mobile from desktop. When * `mobile_separate` is on, a single static file would be shared across * devices and whoever primed it wins (mobile visitors could get desktop * HTML, or vice-versa). Rather than duplicate a wp_is_mobile()-equivalent * UA matcher into .htaccess AND the nginx snippet (three copies that * would inevitably drift), we simply DON'T engage the static rewrite when * mobile_separate is on. Requests then fall through to the PHP drop-in, * which buckets correctly — a small TTFB cost (~85ms vs ~30ms) paid only * on mobile-separate sites, in exchange for guaranteed correctness. * * LiteSpeed exclusion (2026-06-16): on LiteSpeed — OpenLiteSpeed in * particular — `.htaccess` CAN run our RewriteRule to serve the static * file, but its `.htaccess` engine ignores `mod_headers`, so we cannot * stamp the served response with `X-XSpeed-Cache: HIT`, AND there is no * `.htaccess` equivalent of nginx's per-location `access_log` to record * the hit. The result was a cache that worked but was invisible: no HIT * header and a hit-ratio frozen near 0%. Every OTHER server gives the * user a visible HIT header + a counted hit (nginx via add_header + * access_log in its snippet; Apache via .htaccess mod_headers, which it * honors). To keep LiteSpeed CONSISTENT with the rest, we route its hits * through the PHP drop-in instead — the drop-in emits * `X-XSpeed-Cache: HIT (php)` and calls Hit_Counter inline, exactly the * observable behavior the other servers get. The cost is the drop-in's * ~30ms TTFB vs the static path's ~10ms, paid only on LiteSpeed; in * exchange the dashboard hit-ratio and the response header finally tell * the truth there. (Apache keeps the static fast path — it honors the * header.) See maybe_emit_lscache_headers() for the paired LSCache * stand-down that stops LiteSpeed's own module from shadowing the * drop-in. */ public static function static_rewrite_allowed(): bool { // LiteSpeed: drop-in serves hits (visible + counted) — see docblock. if ( Server::LITESPEED === Server::type() ) { return false; } $opts = Settings_Manager::get( 'cache' ); return empty( $opts['mobile_separate'] ); } /** * Why the device-blind static rewrite is NOT installed, when it isn't. * Returns 'mobile_separate' when Separate Mobile Cache is the blocker * (the static file is one-per-URL, so it can't coexist with per-device * buckets), '' otherwise. Lets the dashboard explain the slow path * instead of silently falling back to PHP serving. (FBS-83145) */ public static function static_rewrite_block_reason(): string { if ( Server::LITESPEED === Server::type() ) { return ''; // Intended on LiteSpeed — not a "block". } $opts = Settings_Manager::get( 'cache' ); return ! empty( $opts['mobile_separate'] ) ? 'mobile_separate' : ''; } /** * Whether migration flagged Separate Mobile Cache for user review. Set by * Migration::map_mobile_separate() when a source plugin (WP Rocket / WP * Super Cache / LiteSpeed) had its "separate mobile cache" option on: we * import it as OFF (to keep the device-blind static fast path) but record * this flag so the dashboard can invite the user to turn it back on only * if their site genuinely serves different HTML per device. (FBS-83145) */ public static function mobile_separate_needs_review(): bool { $opts = Settings_Manager::get( 'cache' ); return ! empty( $opts['mobile_separate_review'] ); } /** * Clear the review flag — called when the user has acted on the prompt * (dismissed it, or turned Separate Mobile Cache on/off deliberately) so * the dashboard callout doesn't nag forever. Writes the option directly * (bypassing Settings_Manager) so it never touches schema fields. */ public static function clear_mobile_separate_review(): void { $stored = get_option( 'xspeed_module_cache', array() ); if ( ! is_array( $stored ) || empty( $stored['mobile_separate_review'] ) ) { return; } unset( $stored['mobile_separate_review'] ); update_option( 'xspeed_module_cache', $stored ); } /** * On-demand probe: does the homepage serve materially the same HTML to a * desktop and a mobile browser? Fetches home_url() twice over loopback — * once with a desktop User-Agent, once with a mobile one — strips * per-request noise (nonces, CSRF tokens, session ids, inline timestamps), * and compares. When identical, Separate Mobile Cache is almost certainly * unnecessary and the user can turn it off to regain the static fast path. * * NEVER run automatically (no page-load cost) — only from the dashboard * "Check now" button. Result is cached for 10 minutes so a double-click or * a re-render doesn't fire two more self-requests. (FBS-83145) * * @return array{ identical:bool, checked:bool, reason?:string, desktop_bytes?:int, mobile_bytes?:int } */ public static function probe_mobile_equality(): array { $cached = get_transient( 'xspeed_mobile_equality_probe' ); if ( is_array( $cached ) ) { return $cached; } $home = home_url( '/' ); $host = (string) wp_parse_url( $home, PHP_URL_HOST ); if ( '' === $host ) { $result = array( 'identical' => false, 'checked' => false, 'reason' => 'home_url has no host' ); set_transient( 'xspeed_mobile_equality_probe', $result, MINUTE_IN_SECONDS ); return $result; } // Match WP core's own mobile detection (wp_is_mobile) so the probe // reflects what the site would actually branch on. iPhone Safari for // mobile; a current desktop Chrome UA for desktop. $desktop_ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; $mobile_ua = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'; $is_local = function_exists( 'wp_get_environment_type' ) && in_array( wp_get_environment_type(), array( 'local', 'development' ), true ); $fetch = static function ( string $ua ) use ( $home, $is_local ) { $resp = wp_remote_get( $home, array( 'timeout' => 5, 'sslverify' => ! $is_local, 'redirection' => 2, // Bust any per-device cache so we compare freshly-rendered // HTML, and pass the device UA the site would branch on. 'user-agent' => $ua, 'headers' => array( 'Cache-Control' => 'no-cache' ), ) ); if ( is_wp_error( $resp ) || 200 !== (int) wp_remote_retrieve_response_code( $resp ) ) { return null; } return (string) wp_remote_retrieve_body( $resp ); }; $desktop = $fetch( $desktop_ua ); $mobile = $fetch( $mobile_ua ); if ( null === $desktop || null === $mobile ) { $result = array( 'identical' => false, 'checked' => false, 'reason' => 'could not fetch homepage twice' ); set_transient( 'xspeed_mobile_equality_probe', $result, MINUTE_IN_SECONDS ); return $result; } $identical = self::normalize_html_for_diff( $desktop ) === self::normalize_html_for_diff( $mobile ); $result = array( 'identical' => $identical, 'checked' => true, 'desktop_bytes' => strlen( $desktop ), 'mobile_bytes' => strlen( $mobile ), ); set_transient( 'xspeed_mobile_equality_probe', $result, 10 * MINUTE_IN_SECONDS ); return $result; } /** * Strip per-request noise from HTML so a desktop-vs-mobile diff reflects * real structural differences, not nonces / session ids / timestamps that * change on every render. Deliberately conservative: it normalizes the * handful of well-known noise sources and collapses whitespace, so a site * that truly serves different markup per device still compares as different. */ private static function normalize_html_for_diff( string $html ): string { $patterns = array( // WP nonces (data-nonce="...", _wpnonce=..., "nonce":"..."). '/(_wpnonce|nonce|_ajax_nonce)["\']?\s*[:=]\s*["\']?[a-f0-9]{10}/i', // Generic 10+ hex tokens (CSRF, cache-buster hashes, session ids). '/\b[a-f0-9]{16,}\b/i', // wp-generated unique ids (e.g. wp-block ids, aria ids). '/(id|for|aria-[a-z]+)="[^"]*-[0-9]{3,}"/i', // ISO-ish timestamps + epoch-looking numbers in query strings. '/\?ver=[0-9.]+/', '/[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.+Z-]+/', ); $html = (string) preg_replace( $patterns, 'X', $html ); // Collapse all whitespace so trivial formatting differences don't count. return trim( (string) preg_replace( '/\s+/', ' ', $html ) ); } public static function ensure_hits_log_file(): bool { // The HITs log exists ONLY so a server-level nginx `access_log` // directive has a world-writable file to append to (see nginx_snippet() // + Hit_Counter::collect_nginx_log_hits()). On Apache/LiteSpeed/managed // hosts nothing writes it, so creating it — and, worse, chmod()-ing it // world-writable — is pointless AND fails with "Operation not permitted" // when PHP can't chmod files it doesn't own (a warning that surfaces in // logs that capture @-suppressed errors). Skip the whole thing off nginx. if ( Server::NGINX !== Server::type() ) { return false; } $dir = self::hits_log_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 = self::hits_log_path(); 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 PHP computes it. Lives under uploads/ (NOT the // cache dir): a cache purge or uninstall deletes the cache dir, // which would orphan this directive's parent directory and make // `nginx -t` fail [emerg] for EVERY vhost on the host // (FBS-82478). uploads/ survives both, so the directive can // never take nginx down. Works on every topology where the nginx // process shares a filesystem with PHP (container or host). $hits_abs = self::hits_log_path(); $lines = array(); $lines[] = '# xSpeed static cache — paste at server level, above location / { }.'; // Cache host must match the on-disk dir PHP writes: store_static() / // static_host() take HTTP_HOST and strip every char outside // [a-zA-Z0-9.\-] — i.e. it removes the colon but KEEPS the port digits // (localhost:8192 → localhost8192). nginx's own $host can't reproduce // that: $host has the port already stripped ENTIRELY (→ localhost), so // the -f check looks for localhost/... while PHP wrote localhost8192/... // and the rewrite never fires on a non-standard port. Derive // $xspeed_host from $http_host (which keeps the port) and drop just the // colon, so it equals the PHP dir on every port. On standard ports // $http_host has no colon, so $xspeed_host == $host == the bare domain. $lines[] = 'set $xspeed_host $http_host;'; // default: no port → unchanged (e.g. example.com) $lines[] = 'if ($http_host ~ "^([^:]+):(\\d+)$") { set $xspeed_host $1$2; }'; // host:port → hostport (matches PHP static_host()) $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 . '/$xspeed_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 . '/$xspeed_host$uri/index.html last;'; $lines[] = '}'; $lines[] = ''; $lines[] = '# Serve + log the cached HIT — `^~` is required so this beats any regex location.'; $lines[] = 'location ^~ ' . $rel . '/ {'; $lines[] = ' internal;'; // LITERAL log path (not `set $var; access_log $var`). The variable form // makes nginx open the log lazily per-request and SILENTLY drop the // line if the open fails — so on a working host hits were served // (X-XSpeed-Cache fires regardless) but nothing was ever written and // the hit ratio sat at 0%. A literal path makes nginx open the file at // config load and actually log every hit. // // Deleting the log FILE is still safe with a literal path: nginx // recreates it on the next write/reload and `nginx -t` stays green // (verified). The only thing that [emerg]s `nginx -t` is a missing // parent DIRECTORY — and the log lives under uploads/xspeed/, which // survives cache purge + uninstall, and which ensure_hits_log_file() // (run on every admin_init via auto_heal) recreates if it ever goes // missing. So: hits are logged, and a user deleting the log can't take // nginx down. $lines[] = ' access_log ' . $hits_abs . ' combined buffer=16k flush=5s;'; $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 into `server { }`, above `location / { }`; re-paste after toggling features.\n"; return $header . "\n" . implode( "\n\n", $blocks ) . "\n"; } /** * Tell LiteSpeed's LSCache module to stand down on the cache-miss * render path. * * History: this method used to emit X-LiteSpeed-Cache-Control: * public,max-age=N + X-LiteSpeed-Tag, handing caching to the server's * LSCache store. That delegation backfired — once LSCache cached a * page it served every subsequent request from its OWN store and * intercepted the request before our site-root .htaccess static * rewrite could run. Net effect on LiteSpeed hosts: no X-XSpeed-Cache * header, our static-cache tree never served, the HIT log never * written (hit ratio frozen at 0%), and the Health probe reporting a * false "cache running on PHP fallback" because it never saw an * xSpeed-served response. * * xSpeed now owns the cache on LiteSpeed exactly as it does on Apache: * our `.htaccess` mod_rewrite block serves hits straight from the * static-cache tree (with the X-XSpeed-Cache header + access-log HIT * accounting), and PHP/the drop-in is the fallback. To guarantee * LSCache doesn't shadow that with its own copy — some LiteSpeed * configs cache by default — we send an explicit `no-cache` control so * the server defers to our rewrite. Skipped when the LiteSpeed Cache * plugin is active (it owns its own header policy; our Conflict * registry handles that coexistence separately). */ 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; } // Explicitly opt this response OUT of LSCache so the server can't // shadow our static-rewrite cache with its own internal copy. header( 'X-LiteSpeed-Cache-Control: no-cache' ); } /** * 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; $dropin_stale = 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' ); // Reinstall when OUR drop-in is an older version than the source — // the marker alone can't distinguish an old copy from a new one, so // a serve-logic change (e.g. the .meta read for 404s/feeds) would // otherwise never reach existing cache-enabled sites until a manual // cache toggle. (FBS-82406/82407) if ( $dropin_ours ) { $dropin_stale = self::dropin_version( (string) $contents ) < self::dropin_version( @file_get_contents( XSPEED_DIR . 'includes/advanced-cache.php' ) ?: '' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged } } if ( ! $dropin_ours || $dropin_stale ) { 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). // // Reconcile against mobile_separate: the rewrite is device-blind, so // it must be ABSENT when mobile_separate is on and PRESENT otherwise. // auto_heal() runs periodically, so it also repairs a rewrite that // was left installed before mobile_separate was switched on. if ( self::static_rewrite_allowed() ) { if ( ! self::rewrite_installed() ) { self::install_rewrite(); } } elseif ( self::rewrite_installed() ) { self::remove_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', // Pattern is `^`, NOT `.`. The per-directory rewrite engine // strips the leading slash before matching, so the HOMEPAGE // request `/` arrives here as an EMPTY path. `.` requires at // least one character and therefore never matches the homepage // — on LiteSpeed (which honors this strictly) the front page // fell through to PHP while every inner page rewrote fine. // `^` matches the empty string AND any non-empty path, so it // covers `/` and `/blog` alike. (Confirmed on OpenLiteSpeed // 1.8: `.` → homepage served by PHP drop-in; `^` → served // directly from the static file.) ' 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} */ /** * @param bool $allow_probe When false (the default), return ONLY a cached * result and never make an HTTP request — so admin page loads are never * blocked by the loopback probe. The actual HTTP probe only runs when a * caller explicitly opts in (the Health tab / cron). Previously this ran * synchronously on every dashboard bootstrap, so a slow/timing-out * loopback request added up to `timeout` seconds to admin page loads on * hosts that block self-requests. (FBS-82142) */ public static function probe_static_rewrite( bool $allow_probe = false ): array { $cached = get_transient( 'xspeed_rewrite_probe' ); if ( is_array( $cached ) ) { return $cached; } // No cached result yet and the caller doesn't want to pay for a live // HTTP probe (e.g. the admin bootstrap): report "pending" without // blocking. The Health tab will run the real probe on demand. if ( ! $allow_probe ) { return array( 'active' => false, 'reason' => 'probe pending', 'pending' => true ); } $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 ); // Verify TLS by default — disabling it site-wide is a needless MITM // exposure (FBS-82142). Only relax verification in local/dev // environments, where self-signed certs are common and there's no // real attacker in the loop. $is_local = function_exists( 'wp_get_environment_type' ) && in_array( wp_get_environment_type(), array( 'local', 'development' ), true ); $resp = wp_remote_get( $probe_url, array( // 3s cap so a host that hangs on loopback self-requests can't // stall the caller for long; the result/error is cached so we // don't repeat the wait every minute. 'timeout' => 3, 'sslverify' => ! $is_local, '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(), ); // Cache the failure for the full 5 minutes (not 1) so a host that // times out on the loopback probe isn't re-probed — and re-stalled // — on every page load within the window. (FBS-82142) set_transient( 'xspeed_rewrite_probe', $result, 5 * 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 { // The static rewrite is device-blind; never install it when // mobile_separate is on (see static_rewrite_allowed()). if ( ! self::static_rewrite_allowed() ) { return false; } $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; } /** * Parse the `XSPEED_DROPIN_VERSION: N` stamp out of a drop-in's source. * Returns 0 when absent (an un-stamped older copy reinstalls). Used to * detect a stale installed drop-in vs the bundled source. */ private static function dropin_version( string $contents ): int { if ( preg_match( '/XSPEED_DROPIN_VERSION:\s*(\d+)/', $contents, $m ) ) { return (int) $m[1]; } return 0; } 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; } // Bake the absolute hit-log path into the drop-in. It runs before // WordPress loads, so it can't resolve wp_upload_dir() itself — we // substitute the @@XSPEED_HITS_LOG@@ token with the real uploads path // (never the cache dir; see hits_log_dir() / FBS-82478). Use a single // quoted PHP string literal so the installed file stays valid PHP. $source_contents = str_replace( '@@XSPEED_HITS_LOG@@', str_replace( "'", "\\'", self::hits_log_path() ), $source_contents ); 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 ) { // Own the constant. A previous caching plugin (e.g. WP Rocket sets // it false on deactivate) can leave `define( 'WP_CACHE', false );` // behind — presence alone is not enough, the VALUE must be true or // WordPress never loads advanced-cache.php and our drop-in is dead. if ( preg_match( "/define\\(\\s*['\"]WP_CACHE['\"]\\s*,/", $config ) ) { $rewritten = preg_replace( "/define\\(\\s*['\"]WP_CACHE['\"]\\s*,\\s*[^)]*\\)\\s*;/", "define( 'WP_CACHE', true );", $config, 1 ); // If an existing define was already `true`, the rewrite is a // no-op string-wise; either way we end on WP_CACHE === true. if ( null !== $rewritten ) { $config = $rewritten; } } else { $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 ); } /** * Admin-bar purge menu — a parent node plus one child per visible cache * type (LiteSpeed-style), instead of a single "Purge All" link. Each * child posts to the same admin-post handler with its type slug. The * per-type items only appear for active/licensed modules; "Purge All" * always shows and always sweeps everything. (FBS-83114) */ 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' => __( 'xSpeed Cache', 'xspeed' ), 'href' => self::purge_type_url( 'all' ), ) ); foreach ( self::purge_types() as $slug => $type ) { if ( empty( $type['visible'] ) ) { continue; } $wp_admin_bar->add_node( array( 'id' => 'xspeed-purge-' . $slug, 'parent' => 'xspeed-purge', 'title' => esc_html( $type['label'] ), 'href' => self::purge_type_url( $slug ), ) ); } } /** * Nonce-protected admin-post URL for purging a single type. The nonce * action is per-type so a leaked URL can't be replayed for a different * scope. */ private static function purge_type_url( string $type ): string { return wp_nonce_url( admin_url( 'admin-post.php?action=xspeed_purge&type=' . rawurlencode( $type ) ), 'xspeed_purge_' . $type ); } public function handle_admin_bar_purge() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Unauthorized.', 'xspeed' ), 403 ); } $type = isset( $_GET['type'] ) ? sanitize_key( wp_unslash( $_GET['type'] ) ) : 'all'; check_admin_referer( 'xspeed_purge_' . $type ); // Only honour known types; anything else falls back to a full purge. if ( ! array_key_exists( $type, self::purge_types() ) ) { $type = 'all'; } self::purge_type( $type ); wp_safe_redirect( wp_get_referer() ?: admin_url() ); exit; } }