.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. // …UNLESS this request is a 404 or a known bot/scanner. Those reach the // render path too, but counting them as cache misses makes the ratio // meaningless — a wave of `/wp-x7.php` scanner 404s reads as a collapsing // cache when nothing is wrong. Runs at template_redirect (priority 0), so // is_404() is already resolved. Excluded requests are tallied separately // for the "you absorbed N scanner hits" line, not dropped. (#118) if ( self::miss_is_excluded() ) { Hit_Counter::record_excluded(); } else { Hit_Counter::record_miss(); } // Stamp it, so "eligible but not cached yet" is visibly different // from "deliberately bypassed" (issue #10). Headers can't be sent // after the body starts, so this has to happen here, not in // finalize_buffer() — nothing has been output at template_redirect. self::mark( '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; } /** * Are we buffering this request? * * Asked by Css_Combine_Buffer, which needs the finished HTML but must not * open a second buffer when this one is already going to hand it the page * through `xspeed_cache_final_html`. False here means the request is not * cacheable — cache off, excluded URL, logged in — and the combiner has to * provide its own buffer or it silently stops working. (#195) */ public static function is_buffering(): bool { return null !== self::$buffer_level; } /** * Is a render-time translation plugin going to wrap our output buffer? * * TranslatePress opens its translation buffer on `init` priority 0. We * open ours on `template_redirect`, which runs much later, so ours nests * INSIDE theirs. PHP unwinds output buffers LIFO — innermost callback * first — so `finalize_buffer()` saw the raw, pre-translation HTML and * cached that, while the live visitor still got the translated bytes from * TRP's outer buffer. * * Result: the first (MISS) visitor to /fr/some-page/ got correct French; * every visitor after got English body text under a `lang="fr-FR"` * document, plus TRP's internal `#TRPLINKPROCESSED` link markers, which * TRP strips at the very end of its own buffer and which therefore leak * into anything captured from inside it. * * Note the ordering cannot be fixed from TRP's side: its * `trp_start_output_buffer_priority` filter only moves the PRIORITY on * `init`, and `init` always fires before `template_redirect` whatever the * priority. The buffer that has to move is ours. * * Detected by main class rather than plugin path, so a renamed directory * or a bundled copy still matches. */ public static function translation_plugin_active(): bool { $active = class_exists( 'TRP_Translate_Press' ); /** * Whether to treat this request as wrapped by a translation buffer. * * Lets a site add another render-time translation plugin (or opt out) * without patching the engine. * * @param bool $active */ return (bool) apply_filters( 'xspeed_translation_plugin_active', $active ); } /** * Write the cache file for a request whose output was wrapped by a * render-time translation plugin. * * Registered as a PHP shutdown function (not a WP `shutdown` action) so * it runs after PHP has unwound the output-buffer stack — by which point * the translation plugin's callback has transformed the bytes and its * internal markers are gone. * * finalize_buffer() has already applied the status gate, the * xspeed_cache_final_html filter and HTML minification to the * untranslated copy and then declined to write it. Here we re-run only * what's needed on the translated bytes: minify, write, and fire the * same downstream hooks so Brotli / static-tree listeners behave * identically to the ordinary path. */ public static function write_deferred_translated_cache(): void { $key = self::$deferred_key; self::$deferred_key = null; // Release the collected bytes BEFORE the early return, so the static // is cleared on every path rather than only when a key survived. $full = self::$translated_output; self::$translated_output = ''; $completed = self::$render_completed; self::$render_completed = false; if ( null === $key ) { return; } // Did the render actually finish? // // This runs as a PHP shutdown function, which fires after a wp_die() // or a bare exit() just as readily as after a clean render — but in // those cases finalize_buffer() never returned, so the bytes we hold // are a page that was cut off partway through. The length and // TRPLINKPROCESSED checks below don't catch that: a fatal after the // footer's translated markup is both over 255 bytes and free of TRP // markers, i.e. truncated but entirely plausible. Caching it would // freeze a half-rendered page under the real key for the full TTL. // // Serving this one URL uncached is the cheap failure; the corrupt // cache entry is the expensive one. if ( ! $completed ) { return; } if ( strlen( $full ) < 255 ) { return; } // Refuse to cache a copy still carrying the translation plugin's // internal link markers. TRP strips these at the very end of its own // buffer, so their presence means we captured too early — and a // cached page containing them is SEO-visible damage. Better to serve // this URL uncached than to freeze broken markup for the full TTL. if ( false !== strpos( $full, 'TRPLINKPROCESSED' ) ) { return; } $minify_opts = Settings_Manager::get( 'minify' ); if ( ! empty( $minify_opts['minify_html'] ) ) { $full = Minifier::minify_html( $full ); } if ( ! file_exists( XSPEED_CACHE_DIR ) ) { wp_mkdir_p( XSPEED_CACHE_DIR ); self::write_silence( XSPEED_CACHE_DIR ); } $file = self::cache_file_for( $key ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context for credentials; this runs on a frontend shutdown where it's unavailable. file_put_contents( $file, $full, LOCK_EX ); /** This action is documented in includes/class-cache.php */ do_action( 'xspeed_flat_file_written', $file, $full ); self::write_meta( $key ); // Static tree too, under the same gates finalize_buffer() applies — // otherwise deferring the write would silently cost translated pages // the web-server fast path and leave them on the slower drop-in. if ( self::static_rewrite_allowed() && self::response_is_plain_html() ) { self::store_static( $full ); } } public static function should_cache() { // Reset first: a single request only reaches this once (the sole // caller is maybe_start_cache()), but tests and any future caller // must never inherit the previous request's verdict. self::$status_header = ''; self::$bypass_reason = ''; $opts = Settings::get(); if ( empty( $opts['cache_enabled'] ) ) { return self::bypass( 'cache-disabled' ); } if ( is_user_logged_in() ) { return self::bypass( 'logged-in' ); } if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { return self::bypass( 'non-frontend' ); } if ( defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE ) { return self::bypass( 'donotcachepage' ); } // 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 self::bypass( 'non-get' ); } // 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. // // Parse the RAW query string, NOT a sanitize_text_field() copy: // that filter strips percent-encoded octets (%XX), so `?%73=…` // would lose its `s` key here while WordPress still decodes it to // a search request — the gate would wave the request through and // cache_key() would file the search page under the bare URL, // letting an attacker poison the homepage cache with `/?%73=`. // parse_str() does its own urldecoding, matching WP's own parse, and // only the KEYS are used below (fed to Glob_Matcher → preg_match, // never echoed or executed), so no sanitization is needed here. $query_raw = isset( $_SERVER['QUERY_STRING'] ) ? wp_unslash( $_SERVER['QUERY_STRING'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- see note above: parse_str() urldecodes to match WP; only keys are consumed, via preg_match, never output. 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 ) ) { // Slug only — never the param name, which is attacker- // controlled and would be reflected into a header. return self::bypass( 'query-param' ); } } } $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 self::bypass( 'excluded-url' ); } // 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 self::bypass( 'excluded-cookie' ); } } } // 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 self::bypass( 'user-agent' ); } } } // 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 self::bypass( 'post-excluded' ); } /** * 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. */ if ( ! apply_filters( 'xspeed_should_cache', true ) ) { // One slug for every listener — a third-party callback name is // not ours to put in a response header. Which listener vetoed is // a WP_DEBUG-level question the filter itself can answer. return self::bypass( 'filtered' ); } return 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), 'ttl' (int seconds). * Used on the HIT path to replay content-type/status before streaming * the file, and by Cache_GC to age an entry by its own TTL rather than * the global one — hence public. */ public 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 ); // A missing file is "expired" — the caller should re-render. Guard // filemtime() rather than letting it warn: callers legitimately ask // about a file that isn't there (Pro's predictive warmer probes for // freshness, and Cache_GC can collect an entry between the check and // the read), and on a site with WP_DEBUG the warning is noise. $mtime = file_exists( $file ) ? filemtime( $file ) : false; if ( false === $mtime ) { return true; } return ( time() - (int) $mtime ) > $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 ); // A render-time translation plugin (TranslatePress) wraps our buffer, // so the bytes we hold here are still UNTRANSLATED — its callback has // not run yet, and writing now would cache English under a French URL // and bake in its internal #TRPLINKPROCESSED markers. Hand off to // shutdown, where the outer buffer has already translated, and let // the pass-through below deliver this request untouched. if ( self::translation_plugin_active() ) { self::$deferred_key = $key; // Reaching here means finalize_buffer() ran to completion: the // status gate passed, should_cache() said yes, and PHP handed us // the whole buffer. A wp_die() or exit() mid-render unwinds the // buffer stack WITHOUT calling this callback, so the flag stays // false and the shutdown writer declines — see the guard there. self::$render_completed = true; // A PHP shutdown function, not a WP `shutdown` action: this must // run after the output-buffer stack has unwound, and WP's // shutdown action fires while our outer buffer is still open. register_shutdown_function( array( __CLASS__, 'write_deferred_translated_cache' ) ); return $buffer; } // 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. * * INVARIANT — the static tree is keyed by `{host}{path}` and NOTHING * else, and both generated rewrites refuse any request that carries a * query string at all (`RewriteCond %{QUERY_STRING} ^$` on Apache, * `if ($args)` in nginx_snippet()). So a response may only be stored * here when cache_key() adds no discriminator beyond `{host}{path}`: * a query-keyed entry can never be *served* from here, only mis-served * as the bare path. Any future opt-in that folds a query param into the * key needs a guard below, exactly like the search one. */ private static function store_static( string $html ): void { // Search results are keyed by term in cache_key() (`|s=`) but // carry the *path* of whatever URL was searched from — for the usual // `/?s=` that path is `/`. Writing them here would file the // results page as `{host}/index.html` and the web server would serve // it to every visitor as the homepage: an unauthenticated visitor // poisons the front page with one request. Searches stay on the // drop-in, which replays the term-keyed entry correctly. (#191) // // This is a superset of the query-string check the exclusion gate // does: it also covers `/?%73=`, which decodes to the same // search (the shape #109 fixed on the gate side). if ( self::should_cache_search() ) { return; } $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'). */ /** * Purge the cache entries for ONE URL — every variant of it: the * flat-hash entry (+ .meta / .html.br siblings), both device buckets * (mobile_separate keys them separately), both trailing-slash forms, * and the static-tree index.html (+ .br) the server rewrite serves. * The rest of the cache is untouched — this is the surgical * alternative to purge_all for "I just edited this one page". * * @param string $url Absolute URL, or site-relative path ("/about/"). * @param string $cause Who asked, for the purge log. See purge_all(). * @return int Number of cache files removed. */ public static function purge_url( string $url, string $cause = 'manual' ): int { $parts = function_exists( 'wp_parse_url' ) ? wp_parse_url( $url ) : parse_url( $url ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- fallback for early-boot contexts only. if ( ! is_array( $parts ) ) { return 0; } $host = isset( $parts['host'] ) ? strtolower( (string) $parts['host'] ) : ''; if ( '' === $host && function_exists( 'home_url' ) ) { $home = function_exists( 'wp_parse_url' ) ? wp_parse_url( home_url( '/' ) ) : parse_url( home_url( '/' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- see above. $host = is_array( $home ) && isset( $home['host'] ) ? strtolower( (string) $home['host'] ) : ''; } if ( '' === $host ) { return 0; } $path = isset( $parts['path'] ) ? (string) $parts['path'] : '/'; $path = '/' . ltrim( $path, '/' ); if ( false !== strpos( $path, '..' ) ) { return 0; } // The cache key preserves REQUEST_URI's trailing-slash form, so // purge both. Root stays a single '/'. $forms = array( $path ); if ( '/' !== $path ) { $forms[] = rtrim( $path, '/' ); $forms[] = rtrim( $path, '/' ) . '/'; } $forms = array_unique( $forms ); $count = 0; foreach ( $forms as $uri ) { // '' = mobile_separate off; '|m' / '|d' = the device buckets. foreach ( array( '', '|m', '|d' ) as $device ) { $key = md5( $host . $uri . $device ); $file = self::cache_file_for( $key ); if ( is_file( $file ) ) { wp_delete_file( $file ); ++$count; } foreach ( array( XSPEED_CACHE_DIR . '/' . $key . '.meta', $file . '.br' ) as $sidecar ) { if ( is_file( $sidecar ) ) { wp_delete_file( $sidecar ); } } } } // Static tree (served directly by the nginx/.htaccess rewrite). if ( defined( 'XSPEED_CACHE_STATIC_DIR' ) ) { $dir = rtrim( XSPEED_CACHE_STATIC_DIR, '/' ) . '/' . $host . ( '/' === $path ? '' : rtrim( $path, '/' ) ); $file = $dir . '/index.html'; if ( is_file( $file ) ) { wp_delete_file( $file ); ++$count; } if ( is_file( $file . '.br' ) ) { wp_delete_file( $file . '.br' ); } } if ( $count > 0 ) { Cache_Inventory::invalidate(); Activity_Log::record( 'cache_purge_url', sprintf( /* translators: 1: cause of the purge, 2: URL or path, 3: number of files removed. */ __( 'Purged one URL (%1$s) — %2$s, %3$d file(s) removed', 'xspeed' ), $cause, $host . $path, $count ), Activity_Log::INFO ); } return $count; } 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 ); // The list behind the "Cached pages" card is memoized for a minute; // a purge has to drop it or the drill-down shows pages that no // longer exist. Cache_Inventory::invalidate(); // 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. * * @param string $type Cache type slug. * @param string $cause Who asked. Threaded through so the purge log can * tell an AI assistant's purge apart from a click — * "the cache cleared four times today" is only * actionable once you know what kept clearing it. */ public static function purge_type( string $type, string $cause = 'manual' ): int { switch ( $type ) { case 'all': return self::purge_all( $cause ); 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() ) ); Cache_Inventory::invalidate(); self::record_partial_purge( 'page', $cause, $count ); return $count; case 'assets': if ( class_exists( '\\XSpeed\\Minifier' ) ) { Minifier::purge_minified(); } self::record_partial_purge( 'assets', $cause, null ); return 0; case 'object': if ( function_exists( 'wp_cache_flush' ) ) { wp_cache_flush(); } self::record_partial_purge( 'object cache', $cause, null ); return 0; case 'rest': $count = Rest_Cache::purge(); self::record_partial_purge( 'REST responses', $cause, $count ); return $count; default: // Pro / third-party type — let the owning module handle it. do_action( 'xspeed_purge_type_' . $type ); self::record_partial_purge( $type, $cause, null ); return 0; } } /** * Log a partial purge so the drill-down behind "Last purge" shows every * clear, not only the full ones. Without this a site whose object cache * is flushed on a schedule looks, from the log, like nothing happens. * * @param string $what Human label for the slice purged. * @param string $cause Who asked. * @param int|null $count Items removed, when countable. */ private static function record_partial_purge( string $what, string $cause, ?int $count ): void { $message = null === $count ? sprintf( /* translators: 1: what was purged, 2: cause of the purge. */ __( 'Purged %1$s (%2$s)', 'xspeed' ), $what, $cause ) : sprintf( /* translators: 1: what was purged, 2: cause of the purge, 3: number of files removed. */ __( 'Purged %1$s (%2$s) — %3$d file(s) removed', 'xspeed' ), $what, $cause, $count ); Activity_Log::record( 'cache_purged', $message, Activity_Log::INFO ); } /** * Clear the static tree only, leaving the flat cache in place. * * A narrower purge_all() for the case where only the web-server tree can * be wrong: its files are keyed by `{host}{path}` and nothing else, so a * response filed under the wrong path poisons it while the flat cache — * keyed by cache_key(), discriminators included — stays correct. Avoids * throwing away Critical CSS, minified bundles and the object cache to * fix a static-only problem. * * @return int Number of index.html files removed. */ public static function purge_static_tree(): int { return self::rmtree_html( XSPEED_CACHE_STATIC_DIR ); } /** * 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'], // Requests kept OUT of the ratio (404s + bots) — surfaced as its own // "absorbed N scanner/bot requests" line rather than distorting the // cache-performance number. (#118) 'excluded_24h' => $totals['excluded'], // True when an edge cache (Cloudflare) fronts the origin, so hits are // absorbed before reaching PHP. The dashboard labels the ratio // "origin-layer only" instead of implying it's the full picture. (#118) 'edge_cache' => self::edge_cache_detected(), ); } /** * Whether the current request should be kept OUT of the cache hit/miss * ratio: a genuine 404, or a known bot / scanner. Runs at template_redirect * time, so is_404() is resolved. (#118) */ private static function miss_is_excluded(): bool { if ( function_exists( 'is_404' ) && is_404() ) { return true; } $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( (string) $_SERVER['HTTP_USER_AGENT'] ) ) : ''; return Hit_Counter::is_bot_ua( $ua ); } /** * Whether an edge cache fronts this origin. Today: the Cloudflare * integration is connected — so an unknown share of hits is served at the * edge and never counted here, making the origin ratio a partial view the * dashboard must label as such. (#118) */ private static function edge_cache_detected(): bool { $cf = get_option( 'xspeed_module_cloudflare', array() ); return is_array( $cf ) && ! empty( $cf['enabled'] ); } /** * Apply the user's enable/disable choice. Called from the REST toggle * endpoint, which is gated by current_user_can( 'manage_options' ) and * a verified REST nonce. * * This is the only path that ENABLES caching — a drop-in is never * created for a user who hasn't opted in, which is the guideline that * matters (a plugin must not install drop-ins or edit wp-config.php * on a fresh activation). RESTORING the drop-in for a site that * already has cache_enabled = true is a different act and is handled * by restore_dropin_if_enabled() on activation and auto_heal() at * runtime; without it every plugin update silently un-caches the site. * * @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.unlink_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.unlink_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(); // Did the thing that actually invalidates cache KEYS change? // mobile_separate buckets entries as |d / |m, so flipping it makes // stored entries mis-bucketed and they must go. A rewrite-state // mismatch from anything else (e.g. mod_headers detection, a hand- // edited .htaccess) changes no key at all — the same files are still // valid, they're just served by PHP instead of by the web server. // Purging there is what let one WP-CLI call wipe the whole cache on // every bootstrap. (#138) // // Read the setting from the SAME place static_rewrite_allowed() and // sync_mobile_flag() do — the cache module's settings, not the // top-level xspeed_options — or this marker would track a key that // never changes and a real flip would go unnoticed. $cache_opts = Settings_Manager::get( 'cache' ); $mobile_now = ! empty( $cache_opts['mobile_separate'] ); $mobile_last = get_option( 'xspeed_last_mobile_separate', null ); $mobile_flipped = ( null !== $mobile_last && (bool) (int) $mobile_last !== $mobile_now ); if ( (string) (int) $mobile_now !== (string) $mobile_last ) { update_option( 'xspeed_last_mobile_separate', $mobile_now ? '1' : '0', false ); } 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; } // Bring the rewrite into line with what this server actually supports. if ( $rewrite_wanted ) { self::install_rewrite(); } else { self::remove_rewrite(); } // Only discard cache contents when the device bucketing changed. if ( $mobile_flipped ) { 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 the `` * block in rewrite_block_lines(), WHEN that module is loaded — when it is * not, Apache takes this same drop-in fallback). 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; } // Apache without mod_headers is in EXACTLY the position LiteSpeed // is in above: it can run the RewriteRule and serve the static // file, but it cannot stamp `X-XSpeed-Cache` on the response, so // the hit is invisible to the user and uncountable by // Hit_Counter. The docblock above used to assert Apache "honors // mod_headers" and left it on the fast path unconditionally — // true only when the module is actually loaded. Fall back to the // drop-in when it isn't, trading ~10ms of TTFB for a hit that // shows up in the header and the ratio. (Field report: hit ratio // pinned at 0% on a working Apache cache.) if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) { 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), 'no_mod_headers' when Apache can't stamp the HIT header, * '' otherwise. Lets the dashboard explain the slow path instead of * silently falling back to PHP serving. (FBS-83145) * * Every refusal in static_rewrite_allowed() that is NOT self-explanatory * must have a branch here. Otherwise the Health card falls through to * "Block missing — toggle Enable Cache off and on to reinstall it", * advice that cannot work: the same condition that suppressed the write * suppresses the reinstall, and auto_heal() strips the block again on * the next admin page load. (Field report: Apache host with mod_headers * unloaded sat on the slow path with no way to find out why.) */ /** * Qualify a raw probe result with what we already KNOW about config. * * probe_static_rewrite() writes its own file under the static-cache tree * and fetches that, which succeeds whenever the web server can serve a * static file at all — including when static_rewrite_allowed() is false * and no real page is on the static path. So `active: true` on its own is * not evidence that pages are being served statically. * * The reachable case is nginx with Separate Mobile Cache on: the snippet * lives in the server block and we cannot remove it, pages are * deliberately routed to the PHP drop-in, but the probe file is still * served directly. * * The Health panel learned this in 88b4b50; the CLI, REST and MCP paths * did not, so they kept reporting "active" in exactly that configuration. * Rather than repeat the reasoning at each call site, they now all come * through here. * * Deliberately does NOT consult rewrite_installed(): on nginx the fast * path is the pasted snippet and there is no .htaccess marker to find, so * requiring one would report every correctly-configured nginx site as * broken. * * @param array $probe Raw result from probe_static_rewrite(). * @return array{active:bool,inconclusive:bool,reason:string,block_reason:string} */ public static function qualify_rewrite_probe( array $probe ): array { $active = (bool) ( $probe['active'] ?? false ); $inconclusive = (bool) ( $probe['inconclusive'] ?? false ); $reason = (string) ( $probe['reason'] ?? '' ); $block_reason = self::static_rewrite_block_reason(); // A known refusal outranks the probe, and also outranks // "inconclusive" — a blocked rewrite whose probe merely failed to // complete is still definitely blocked. if ( '' !== $block_reason ) { $active = false; $inconclusive = false; $reason = self::block_reason_text( $block_reason ); } return array( 'active' => $active, 'inconclusive' => $inconclusive, 'reason' => $reason, 'block_reason' => $block_reason, ); } /** * Human-readable explanation for a static_rewrite_block_reason() code. * * Each one has to say what to DO about it: "mobile_separate" alone tells * a user nothing, and the whole point of surfacing a refusal instead of * the probe verdict is that it is actionable. */ public static function block_reason_text( string $code ): string { switch ( $code ) { case 'mobile_separate': return 'Separate Mobile Cache is on, which disables the device-blind static rewrite. Cache hits are served by PHP instead. If your site serves the same HTML to every device, turn it off in Cache settings for much faster hits.'; case 'no_mod_headers': return "Apache's mod_headers is not loaded, so the static rewrite cannot mark its responses as cache hits. Enable mod_headers, or leave hits on the PHP path."; default: return sprintf( 'The static rewrite is disabled (%s).', $code ); } } public static function static_rewrite_block_reason(): string { if ( Server::LITESPEED === Server::type() ) { return ''; // Intended on LiteSpeed — not a "block". } if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) { return 'no_mod_headers'; } $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 { // TWO writers append to this log, and an earlier fix conflated them: // // 1. nginx, via the server-level `access_log` directive in // nginx_snippet() — a DIFFERENT uid, which is why the file needs // to be world-writable there. // 2. the PHP drop-in (advanced-cache.php), on EVERY server. A hit it // serves bypasses WordPress entirely, so it can't call // Hit_Counter::record_hit() — appending here is the only way that // hit is ever counted. // // The nginx-only early return that used to sit at the top of this // method was fixing something real: chmod() on a file PHP doesn't own // raises "Operation not permitted", and off nginx that chmod buys // nothing. But it took directory creation with it, so on LiteSpeed // (which always serves via the drop-in), on Apache without mod_headers, // and anywhere mobile_separate forces the drop-in path, writer 2 was // appending to a file whose parent directory did not exist. The append // is @-suppressed and documented as non-fatal, so every one of those // hits vanished and the dashboard ratio sat at 0% forever. // // So: create the dir + file everywhere, and keep only the chmod gated // to nginx. $dir = self::hits_log_dir(); if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) { return false; } $is_nginx = ( Server::NGINX === Server::type() ); if ( $is_nginx ) { // 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. } if ( $is_nginx ) { // World-writable so a different-uid nginx can append HIT lines. // Off nginx the drop-in appends as the same uid that owns the file, // so this is unnecessary — and would emit the "Operation not // permitted" warnings the old early return was added to silence. // 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"; }'; // Cookie + user-agent exclusions, generated from the user's actual // settings rather than a hardcoded list. Before this, the rule // tested three fixed cookie names and no user agent at all, so // every excluded_cookies / bypass_user_agents entry applied only // while a page was cold — on a warm page nginx served the shared // anonymous copy to carts, members and bypassed bots alike. The // three historical names survive as a floor inside cookie_rule(). // `~*` is case-insensitive, matching PHP's stripos()/glob checks. $cache_opts = Settings_Manager::get( 'cache' ); $cookie_rule = Server_Rules::cookie_rule( is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array() ); $lines[] = 'if ($http_cookie ~* "(' . $cookie_rule['regex'] . ')") { set $xspeed_no_cache "$xspeed_no_cache-cookie"; }'; $ua_rule = Server_Rules::user_agent_rule( is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array() ); // Emitted only when the list is non-empty — an empty alternation // would compile to `(...)` matching every request and disable the // fast path entirely. if ( '' !== $ua_rule['regex'] ) { $lines[] = 'if ($http_user_agent ~* "(' . $ua_rule['regex'] . ')") { set $xspeed_no_cache "$xspeed_no_cache-ua"; }'; } $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' ); } /** * Restore the drop-in + WP_CACHE constant for a site that had caching * ON before this activation — and ONLY for such a site. * * WordPress runs an upgrade as deactivate → wipe plugin files → * install → activate. The wipe takes advanced-cache.php with it, so * without this the site serves 100% uncached from the moment the * update finishes until the next authenticated wp-admin page load * (auto_heal() is on admin_init). On a site whose admin logs in * rarely that window is hours or days of silent cache loss, while * the dashboard still reports cache_enabled = true. (FBS field * report against 1.1.2 / Pro 1.0.5.) * * The `cache_enabled` guard is the whole contract: a FRESH install * has the option unset, so activation writes nothing and the user * still opts in explicitly through Cache::toggle() via the * /cache/toggle REST endpoint. We only ever put back state the user * already chose — repair, never a new install path. This is what * keeps us on the right side of the "don't create drop-ins the user * didn't ask for" guideline while matching what WP Rocket, W3 Total * Cache and WP Super Cache all do on activation. * * @return bool True when a restore was performed. */ public static function restore_dropin_if_enabled(): bool { if ( defined( 'WP_INSTALLING' ) && WP_INSTALLING ) { return false; } // The user's saved choice. Absent/false on a fresh install => no // drop-in is written and nothing touches wp-config.php. $opts = get_option( 'xspeed_options', array() ); if ( empty( $opts['cache_enabled'] ) ) { return false; } $restored = false; // Only (re)install when the drop-in is missing, foreign, or an // older version of ours — never rewrite a current, healthy file. $target = WP_CONTENT_DIR . '/advanced-cache.php'; $needs = true; if ( file_exists( $target ) ) { $contents = @file_get_contents( $target ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Best-effort read; a failure just means we reinstall. if ( is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' ) ) { $source = @file_get_contents( XSPEED_DIR . 'includes/advanced-cache.php' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Same. $needs = self::dropin_version( $contents ) < self::dropin_version( is_string( $source ) ? $source : '' ); } } if ( $needs && self::install_dropin() ) { $restored = true; } // WP_CACHE lives in wp-config.php, which the upgrade doesn't touch — // but a foreign cache plugin or a hand-edit can drop it, and without // it core never loads the drop-in at all. if ( ! defined( 'WP_CACHE' ) || ! WP_CACHE ) { if ( self::set_wp_cache_constant( true ) ) { $restored = true; } } if ( $restored ) { Activity_Log::record( 'cache_dropin_restored', 'Cache drop-in restored after a plugin update — caching was already enabled.', Activity_Log::SUCCESS ); } return $restored; } /** * 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(); } /** * Keep the generic bypass cookie in sync with PHP's caching verdict. * * The server config tests exactly one cookie name (Server_Rules:: * BYPASS_COOKIE) forever, and PHP decides what that name means. Adding * a new excluded cookie therefore needs no config change and no nginx * reload — the reason this exists. * * Session cookie (expiry 0) so it dies with the browser session, and * deliberately NOT HttpOnly-sensitive: it carries no identity, only the * boolean "don't serve this visitor a shared cached page". * * Honest limit: this can only ever help a visitor PHP has already seen * once. A bot's first request to a warm page never reaches PHP, which * is why user-agent rules are still written into the server config * rather than relying on this. * * @param bool $bypass Whether this visitor must skip the cache. */ private static function sync_bypass_cookie( bool $bypass ): void { if ( headers_sent() ) { return; } $name = Server_Rules::BYPASS_COOKIE; $has = isset( $_COOKIE[ $name ] ); // Only touch the header when the state actually changes — a // Set-Cookie on every request would make the response uncacheable // for intermediary caches and add noise to every hit. if ( $bypass === $has ) { return; } $path = defined( 'COOKIEPATH' ) && COOKIEPATH ? COOKIEPATH : '/'; $domain = defined( 'COOKIE_DOMAIN' ) ? COOKIE_DOMAIN : ''; if ( $bypass ) { setcookie( $name, '1', 0, $path, (string) $domain, is_ssl(), false ); $_COOKIE[ $name ] = '1'; } else { setcookie( $name, '', time() - 3600, $path, (string) $domain, is_ssl(), false ); unset( $_COOKIE[ $name ] ); } } /** * 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, '/' ); // Cookie + user-agent exclusions generated from the live settings. // See the matching block in nginx_snippet() — same generator, same // floor, so both servers enforce an identical policy. Apache reads // .htaccess on every request and we already self-heal this file, so // Apache/LiteSpeed users get the fix on upgrade with no action. $cache_opts = Settings_Manager::get( 'cache' ); $cookie_rule = Server_Rules::cookie_rule( is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array() ); $ua_rule = Server_Rules::user_agent_rule( is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array() ); $lines = array( '', ' RewriteEngine On', ' RewriteCond %{REQUEST_METHOD} ^GET$', ' RewriteCond %{QUERY_STRING} ^$', ' RewriteCond %{HTTP_COOKIE} !(' . $cookie_rule['regex'] . ') [NC]', ); // Only emit the UA condition when there's something to match — // `!()` would negate an always-true empty match and refuse every // request, silently disabling the static path. if ( '' !== $ua_rule['regex'] ) { // Quoted, because RewriteCond is whitespace-delimited and real // user-agent fragments contain spaces ("Mozilla/5.0 (compatible"). // Unquoted, a space adds an argument and Apache answers every // request with a 500 — and because .htaccess is parsed per // request, `httpd -t` still reports Syntax OK. Server_Rules has // already excluded quotes and backslashes from the alternation, // so the closing quote here cannot be escaped away. $lines[] = ' RewriteCond %{HTTP_USER_AGENT} "!(' . $ua_rule['regex'] . ')" [NC]'; } return array_merge( $lines, array( // 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]', '', // Mark the statically-served response as a cache HIT. // // A file served by the rewrite above bypasses PHP entirely, so // this directive is the ONLY thing that can identify it as // cached — both for the user reading response headers and for // Hit_Counter, which reconciles static hits from the access // log. Without it the cache works perfectly and reports a 0% // hit ratio, which reads as "the plugin is broken". (Field // report against 1.1.2: homepage served byte-identical from // the static tree, no X-XSpeed-Cache header on any response.) // // `always` so the header is set on the 200 from the rewritten // file, not only on the successful-response table. The // guard keeps a server without mod_headers from // 500ing on an unknown directive — on such a host the header // is silently dropped, which is exactly why // static_rewrite_allowed() refuses the static path there and // routes hits through the drop-in instead. '', ' ', ' Header always set X-XSpeed-Cache "HIT (static)"', ' ', '', ) ); } /** * 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) */ /** * Discard the cached probe result and run a fresh one. * * Without this there was no way to re-check: the result sat in a transient * for five minutes and nothing ever deleted it, so a user who fixed their * nginx config kept seeing "nginx detected — configure for max cache speed" * with no means of confirming the fix worked. (FBS-84012) */ public static function recheck_static_rewrite(): array { delete_transient( 'xspeed_rewrite_probe' ); return self::probe_static_rewrite( true ); } 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, // The request never completed, so we learned NOTHING about the // rewrite. Flagged inconclusive so the UI doesn't tell the user // to configure a server that may already be configured — a // blocked loopback, a self-signed cert, or a timeout is a probe // failure, not a missing rewrite. (FBS-84012) 'inconclusive' => true, '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; /* * `inconclusive` separates "we proved the rewrite isn't serving" from * "the probe couldn't tell". Only the former should drive a * configure-your-server banner; the latter previously rendered the * same alarming copy at a user who had already configured nginx * correctly, and there was no way to clear it. (FBS-84012) */ $inconclusive = false; 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 ) { // Something answered 200 with content that isn't our nonce — a CDN, // a proxy, a security plugin. That tells us nothing about the // origin's rewrite. $reason = 'unexpected body (CDN cached an older response?)'; $inconclusive = true; } elseif ( 404 === $code ) { $reason = 'probe URL returned 404 (rewrite block missing or wrong path)'; } else { // Redirects, 403s from a WAF, 5xx — the probe never reached a // verdict about the rewrite itself. $reason = sprintf( 'unexpected response (HTTP %d, body %d B, php=%s)', $code, strlen( $body ), $ua_php ? 'yes' : 'no' ); $inconclusive = true; } $result = array( 'active' => $active, 'inconclusive' => $inconclusive, '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 ); } /** * Rewrite the .htaccess block in place when — and only when — one is * already installed. * * The block embeds the generated cookie / user-agent exclusion rules, * so it goes stale the moment those settings change. install_rewrite() * regenerates it from the live settings, but calling that unconditionally * on every save would CREATE a block on sites that never enabled the * static path — silently turning on server-level serving nobody asked * for. So we refresh only what's already there. * * @return bool True when a block was present and rewritten. */ public static function refresh_rewrite_if_installed(): bool { $htaccess = ABSPATH . '.htaccess'; if ( ! file_exists( $htaccess ) ) { return false; } $existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Best-effort read; an unreadable file simply means nothing to refresh. if ( ! is_string( $existing ) || false === strpos( $existing, '# BEGIN xSpeed Static Cache' ) ) { return false; } return self::install_rewrite(); } 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 ); // Bake the cookie + user-agent exclusion rules in too. The drop-in // runs before WordPress loads, so it cannot read the settings — and // without them it served the shared anonymous page to any visitor // PHP had not yet seen (a first-time cart visitor, a bypassed bot). // The generic bypass cookie only covers repeat visitors; these two // regexes are what make the FIRST request correct. // // Both are already fully escaped by Server_Rules, and each is // embedded as a single-quoted PHP literal, so a settings value can // neither break the drop-in's syntax nor execute. $cache_opts = Settings_Manager::get( 'cache' ); $cookie_rule = Server_Rules::cookie_rule( is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array() ); $ua_rule = Server_Rules::user_agent_rule( is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array() ); $source_contents = str_replace( '@@XSPEED_COOKIE_RE@@', str_replace( "'", "\\'", $cookie_rule['regex'] ), $source_contents ); $source_contents = str_replace( '@@XSPEED_UA_RE@@', str_replace( "'", "\\'", $ua_rule['regex'] ), $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 { // Shared with uninstall.php so the two removal paths can't drift // — they already had, which is why every non-lowercase spelling // of the value survived a disable. (#9) require_once XSPEED_DIR . 'includes/wp-cache-constant.php'; $config = xspeed_strip_wp_cache_define( $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) * * The parent node links to the settings page rather than a purge URL — * clicking the top-level item used to wipe the whole cache instantly with * no confirmation, which is far too destructive for a stray click. Purging * stays available (and explicit) through the child items. (FBS-84068) */ 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' => admin_url( 'admin.php?page=' . Admin::PAGE_SLUG ), ) ); 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( self::safe_purge_redirect( wp_get_referer() ) ); exit; } /** * Resolve a safe redirect target for an admin-bar purge. * * The purge sends the admin back where they came from — but the referer * can be a ONE-SHOT action URL (e.g. update.php?action=upload-plugin from * installing a plugin zip, or any *.php?action=… that consumed a POST / * temp upload). Redirecting there re-runs the action with nothing to act * on, so WordPress dies — the classic "Please select a file" from * File_Upload_Upgrader. Strip the transient action args so we return to a * safe, re-GET-able view of the same page; fall back to the dashboard when * there is no usable referer. * * @param string|false $referer Raw wp_get_referer() value. * @return string Safe URL to redirect to. */ public static function safe_purge_redirect( $referer ): string { $referer = is_string( $referer ) ? $referer : ''; if ( '' === $referer ) { return admin_url(); } // A referer that lands on an action-processing endpoint (update.php, // update-core.php, plugin/theme install/upload flows) can't be safely // re-requested — send them to the dashboard instead of replaying it. $path = (string) wp_parse_url( $referer, PHP_URL_PATH ); if ( preg_match( '#/wp-admin/(update|update-core)\.php$#', $path ) ) { return admin_url(); } // Otherwise keep them on the same page but drop the query args that // would re-trigger a form action or upload on load. return remove_query_arg( array( 'action', 'action2', 'package', 'overwrite', 'plugin', 'theme', 'file', '_wpnonce', '_ajax_nonce' ), $referer ); } }