update() // branch and save_post never fires. Anchoring invalidation on // save_post therefore missed 100% of commerce-relevant mutations: a // REST price change, wc_update_product_stock(), a CLI ->save(), and // every scheduled sale start/end left the product page, the shop and // the category archives serving the old price and stock for the full // lifetime — the store quoting one price and charging another (#242). // // This MUST ship with the gate above: once orders stop purging // everything, the accidental invalidation that was masking this // disappears, and an order that reduces stock would leave the product // page stale. if ( class_exists( 'WooCommerce' ) ) { foreach ( array( 'woocommerce_update_product', 'woocommerce_new_product' ) as $wc_hook ) { add_action( $wc_hook, array( __CLASS__, 'purge_product' ) ); } // Direct stock writes bypass the CRUD entirely. add_action( 'woocommerce_product_set_stock', array( __CLASS__, 'purge_product_object' ) ); add_action( 'woocommerce_variation_set_stock', array( __CLASS__, 'purge_product_object' ) ); add_action( 'woocommerce_product_set_stock_status', array( __CLASS__, 'purge_product' ) ); add_action( 'woocommerce_variation_set_stock_status', array( __CLASS__, 'purge_product' ) ); } add_action( 'update_option_xspeed_options', array( __CLASS__, 'on_settings_change' ), 10, 2 ); // …and the same for every PER-MODULE option. The handler above only // ever watched the legacy `xspeed_options` blob, but every module has // since migrated to its own `xspeed_module_` option and no hook // followed — so changing Minify HTML, Lazy Load, Remove Query Strings // etc. left the cached HTML untouched until the TTL expired (24h by // default) and the feature read as broken. (#205) // // One central listener rather than a hook per module: it covers Pro // modules with no cross-repo change, and a new module can't forget to // wire it up. add_action( 'updated_option', array( __CLASS__, 'on_module_settings_change' ), 10, 1 ); // `added_option` matters as much as `updated_option`: on a fresh install // a module's option doesn't exist yet, so the FIRST save of every panel // goes through add_option() and would otherwise skip the purge — the // original bug surviving one save per module. `deleted_option` covers a // reset-to-defaults, which changes rendered HTML just as much. (#205) add_action( 'added_option', array( __CLASS__, 'on_module_settings_change' ), 10, 1 ); add_action( 'deleted_option', array( __CLASS__, 'on_module_settings_change' ), 10, 1 ); add_action( 'admin_bar_menu', array( $this, 'admin_bar_purge' ), 100 ); add_action( 'admin_post_xspeed_purge', array( $this, 'handle_admin_bar_purge' ) ); } public static function on_settings_change( $old, $new ) { // gzip_enabled moved to xspeed_module_gzip — GzipModule owns the // .htaccess flip via its own update_option_xspeed_module_gzip hook. // Same migration is planned for cache_expiry + excluded_urls // (Cache module). Keep this handler around for whatever still // lives in the legacy blob (cache_enabled is special and goes // through Cache::toggle anyway). // Any settings change — purge caches so changes take effect. self::purge_all( 'settings change' ); Minifier::purge_minified(); } /** * Modules whose settings cannot change rendered HTML, so a write to them * doesn't warrant throwing away the page cache. * * The safe default is to purge: a module is listed here only when it is * clearly incapable of altering front-end output (diagnostics, the MCP * server, licensing/telemetry surfaces). When in doubt, leave it off the * list — a needless purge costs a re-render, a missed one makes the * feature look broken. (#205) * * @return string[] Module slugs. */ public static function non_rendering_modules(): array { return (array) apply_filters( 'xspeed_non_rendering_modules', array( 'mcp', // AI endpoint — no front-end output. 'health', // diagnostics only. 'support', // support snapshot. 'score', // PageSpeed/GTmetrix runner. 'migration', // one-shot importer. 'settings', // import/export surface. 'cache-coverage', // read-only reporting. 'ai-privacy', // consent flags for AI surfaces. 'database', // DB cleanup schedule — no HTML impact. // Pro slugs — listed by name rather than by asking Pro, so // Free stays unaware of it. A Pro module absent here simply // purges, which is the safe default. 'license', 'pro_status', 'analytics', 'performance-health', 'recommendations', 'ai-provider', 'migration-pro', ) ); } /** * Purge when ANY module's settings option is written. (#205) * * Bound to `updated_option`, `added_option` and `deleted_option` — all three * fire for every option on the site, so the prefix test comes first and is * the cheap path for the ~99% of writes that aren't ours. All three pass the * option name first, which is why this can't hook purge_all() directly: * that takes $cause first, so every purge would be filed under a cause * literally named "xspeed_module_minify". * * @param string $option Option name that was just written or removed. */ public static function on_module_settings_change( $option ): void { $option = (string) $option; $prefix = Settings_Manager::OPTION_PREFIX; if ( 0 !== strpos( $option, $prefix ) ) { return; } $slug = substr( $option, strlen( $prefix ) ); if ( '' === $slug || in_array( $slug, self::non_rendering_modules(), true ) ) { return; } // Guard against re-entry: purge_all() and purge_minified() can write // options of their own (stats, timestamps), and a nested purge would // both waste work and risk recursing through this same hook. static $purging = false; if ( $purging ) { return; } $purging = true; self::purge_all( 'settings change' ); Minifier::purge_minified(); $purging = false; } /** * Stamp the request's cache decision on the response. * * `X-XSpeed-Cache` was only ever written on the serve-from-cache paths, * so a miss and a deliberate bypass both came back with no header at all * — indistinguishable from a `curl -I`, the first thing anyone reaches * for when a site "isn't caching" (issue #10). The reason slug rides * along on `X-XSpeed-Reason`, but only under WP_DEBUG so production * responses stay clean. Slugs are fixed per gate — never the matched * pattern, cookie or user-agent, which would echo request input back. * * @param string $value HIT (php) | MISS | BYPASS. * @param string $reason Fixed slug naming the gate, for BYPASS only. */ private static function mark( string $value, string $reason = '' ): void { self::$status_header = $value; self::$bypass_reason = $reason; if ( headers_sent() ) { return; } header( 'X-XSpeed-Cache: ' . $value ); if ( '' !== $reason && defined( 'WP_DEBUG' ) && WP_DEBUG ) { header( 'X-XSpeed-Reason: ' . $reason ); } } /** Record a bypass gate and answer "don't cache" in one statement. */ private static function bypass( string $reason ): bool { self::mark( 'BYPASS', $reason ); return false; } /** The X-XSpeed-Cache value decided for this request ('' if none yet). */ public static function status_header(): string { return self::$status_header; } /** The bypass gate slug for this request ('' unless BYPASS). */ public static function bypass_reason(): string { return self::$bypass_reason; } /** * Bypass gates that describe THE VISITOR rather than THIS REQUEST. * * Only these may be recorded in the bypass cookie. A visitor-scoped * verdict stays true for the visitor's next request — they are still * logged in, still hold a cart cookie — so the web server can act on * it without booting PHP. * * Every other gate describes the request in front of us: its method, * its URL, its query string, the client's user agent. Persisting one * of those pins a visitor to the uncached path over a property that * was never theirs to begin with. (#218) */ private const VISITOR_SCOPED_BYPASS = array( 'logged-in', 'excluded-cookie' ); /** * Whether $reason describes the visitor (persist it) or merely this * request (don't). * * Split out as a pure function because it is the whole decision behind * the bypass cookie, and the cookie write itself (setcookie()) can't be * asserted in a unit test. */ public static function bypass_is_visitor_scoped( string $reason ): bool { return in_array( $reason, self::VISITOR_SCOPED_BYPASS, true ); } public function maybe_start_cache() { if ( ! self::should_cache() ) { // PHP has just evaluated the FULL exclusion rule list — including // the `~regex` patterns the server config can't express — and // decided this response must not be served from cache. Record that // verdict in the conventional bypass cookie so the web server can // enforce it on subsequent requests without starting PHP. // // This is what stops most settings changes from needing an nginx // reload: the config tests one fixed cookie name forever, and the // rule list behind it can change freely. // // But ONLY when the verdict is about the visitor. A request-shape // gate — `non-get` above all — says nothing about who is asking, // and persisting it pinned that visitor to the uncached path for // the rest of their session: one search-form POST, one comment, // one `curl -I` from an uptime monitor, and every later GET // bypassed. It could not self-heal either, because the bypass // cookie is itself in excluded_cookies, so the next GET bypassed // with `excluded-cookie` and landed right back here, where // sync_bypass_cookie()'s no-change short-circuit left the cookie // exactly where it was. (#218) if ( self::bypass_is_visitor_scoped( self::bypass_reason() ) ) { self::sync_bypass_cookie( true ); } return; } // Cacheable: clear any stale bypass cookie, or a visitor who once // had a cart would keep skipping the fast path long after checkout. self::sync_bypass_cookie( false ); $key = self::cache_key(); $file = self::cache_file_for( $key ); if ( file_exists( $file ) && ! self::is_expired( $file ) ) { Hit_Counter::record_hit(); // Emit the HIT marker on THIS path too. The drop-in // (advanced-cache.php) sends "HIT (php)" and the nginx static // rewrite sends "HIT (nginx)", but this template_redirect // serve path — the one that runs when the drop-in isn't loaded // (e.g. WP_CACHE not true) — previously streamed the cached // file with NO marker, so a genuine HIT looked like a MISS in // the response headers. Same header + value as the drop-in. self::mark( 'HIT (php)' ); // Replay stored response bits so the HIT matches the original: // a non-HTML Content-Type (cached feeds, sitemaps) and a non-200 // status (a cached 404 must serve 404, not 200). No-op for // ordinary pages, which write no .meta. $meta = self::read_meta( $key ); if ( ! headers_sent() ) { if ( ! empty( $meta['status'] ) && function_exists( 'http_response_code' ) ) { http_response_code( (int) $meta['status'] ); } if ( ! empty( $meta['content_type'] ) && is_string( $meta['content_type'] ) ) { header( 'Content-Type: ' . $meta['content_type'] ); } // Conditional GET: emit Last-Modified + ETag and answer a // matching If-Modified-Since / If-None-Match with 304 so // aggregators (and browsers) skip re-downloading an unchanged // cached response — the bandwidth win feeds are about. // (FBS-82407 #5) if ( self::serve_not_modified( $file ) ) { exit; // 304 sent, no body. } } // Serve the precompressed Brotli sibling when the client accepts // it (an add-on, the Pro Brotli module, wrote .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 ); } $full = self::signed( $full ); // Per-site directory: on multisite every blog shares this tree, so // entries are bucketed by host to keep one site's purge from // sweeping the whole network. (#6) self::ensure_host_dir(); // Never author a cache entry from a request that carried a query // string: cache_key() files it under the BARE url, so the params' // render would be served to every clean-URL visitor (#241). if ( self::query_string_blocks_write() ) { return; } $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, $full ); // 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 ) { // Our own bypass cookie is a RECORD of a previous verdict, not // evidence about this visitor, so it never gets a vote here. // Letting it match made the verdict self-confirming: once set, // it produced `excluded-cookie` forever, which re-set it, and // no later request could ever re-evaluate the visitor on the // rules that actually describe them. The web server still acts // on the cookie without booting PHP; when PHP does boot it is // authoritative and re-decides from scratch. (#218) if ( Server_Rules::BYPASS_COOKIE === $cookie_name ) { continue; } 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. */ /** * Whether this response was rendered for a query string and therefore * must not be STORED under the bare-URL key. * * should_cache() lets a request through when every key is on the * `ignored_query_params` allow-list, and cache_key() then drops the * query string so `/post` and `/post?utm_source=x` share one entry. * Sharing on READ is the point of the allow-list and stays. Sharing on * WRITE is a cache-poisoning vector: the response was rendered *with* * those params, and WordPress reflects REQUEST_URI into form actions, * share links, canonical helpers and plugin smart tags. One anonymous * GET to a cold URL therefore freezes an attacker-chosen variant under * the clean URL's key, served for the whole TTL by the drop-in and by * the web server — neither of which runs these checks (issue #241). * * The allow-list keeps its benefit: a visitor arriving on * `?utm_source=…` is still SERVED the canonical cached entry. Only the * write is skipped, so the entry is authored by a clean request. * * This is the same reasoning as the `should_cache_search()` guard in * store_static() (#191), generalised to the allow-listed params. */ public static function request_has_query_string(): bool { $query = isset( $_SERVER['QUERY_STRING'] ) ? (string) wp_unslash( $_SERVER['QUERY_STRING'] ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- only tested for emptiness; never echoed, stored or used as a path. : ''; return '' !== trim( $query ); } /** * Would authoring a cache entry from THIS request file a query-string * render under the bare URL? * * The one predicate both write sites ask, so they cannot drift. * * Two shapes are exempt because cache_key() does NOT drop their query — * it folds the distinguishing part into the key, so each variant gets * its own entry and none is filed under the bare URL: * * - searches, keyed by `|s=` (#191) * - feeds, keyed by `|feed=` — `/?feed=rss2` is the ONLY feed URL * core generates on plain permalinks, so treating it as poisonable * made feed caching a no-op on exactly the sites that need it * * @return bool True when the write must be skipped. */ public static function query_string_blocks_write(): bool { if ( ! self::request_has_query_string() ) { return false; } if ( self::should_cache_search() ) { return false; } // Feed caching is opt-in, via the same filter should_cache() reads // to admit the feed params in the first place. if ( function_exists( 'is_feed' ) && is_feed() && (bool) apply_filters( 'xspeed_should_cache_feed', false ) ) { return false; } return true; } 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 * globs (`utm_*` matches `utm_source`, `utm_medium`, etc.) so users * don't have to enumerate every UTM variant, and `~regex`. * * Matching is whole-name, not "contains" — a param name is an * identifier, not a path. Under the old contains match the shipped * default `ref` also swallowed `preference`, `product_ref` and * `referrer`: those params were dropped from the cache key, so * `/shop?preference=1` was served — and, on a cold entry, WRITTEN as — * `/shop`. Same for `_ga` vs `_gallery`, and for the unanchored * `~utm_…` default vs `my_utm_source`. A param name that is genuinely * unknown now bypasses the cache, which is the safe direction. */ private static function query_key_is_ignored( string $key, array $ignored ): bool { return Glob_Matcher::any_match_name( $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 ); } /** * Filesystem-safe directory name for a host, or '' when unusable. * * The charset MUST match the static tree (store_static()) and the * drop-in's own copy, or the paths disagree about where an entry lives. * The colon of `host:port` is stripped: it is legal in a Host header but * not portable in a path. * * @param string $host Raw host, e.g. from HTTP_HOST. * @return string Safe directory segment, or '' if nothing usable remains. */ /** * The host segment of the STATIC tree — `xspeed-static//…`, which * the web server resolves without PHP. * * Different from host_dir(): here the port is folded INTO the segment * (`localhost:8080` → `localhost8080`) rather than dropped, because the * generated server rules have to reproduce this from their own variables * and nginx's `$host` has no port to drop — see the `$xspeed_host` * derivation in nginx_snippet(). Shared by the write and the purge so the * two can't drift; when they did, purging a page on a ported host deleted * nothing and the stale copy kept being served by the rewrite. */ public static function static_host_dir( string $host ): string { return (string) preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host ); } public static function host_dir( string $host ): string { $host = str_replace( "\0", '', $host ); // Drop the port BEFORE filtering, or `example.com:8080` collapses to // `example.com8080` — which both loses the boundary and could collide // with a real host of that name. $colon = strpos( $host, ':' ); if ( false !== $colon ) { $host = substr( $host, 0, $colon ); } $host = preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host ); // Collapse any run of dots so no traversal sequence can survive the // charset filter (`a/../b` would otherwise reduce to `a..b`). $host = preg_replace( '/\.{2,}/', '.', (string) $host ); $host = trim( (string) $host, '.-' ); return '' === $host ? '' : $host; } /** * The per-site bucket a cache entry belongs to: `` on a single * site, `/` for a subdirectory multisite blog. * * On multisite every blog shares one cache directory, and a flat md5 * filename carries no clue which site wrote it — so purging one subsite * swept the whole network cold. (#6) * * Host alone is NOT enough: a subdirectory network (the common layout) * puts every blog on the same host, so `example.com/` and * `example.com/siteb/` would share a bucket and keep purging each other. * The path prefix is what separates them, and it is derivable from the * REQUEST_URI alone — which matters because the drop-in must compute * this identical value before WordPress (and get_blog_details()) exist. * * Subdomain and domain-mapped networks differ by host already, so they * get a bare host bucket and are unaffected. * * @param string $host Raw host. * @param string $uri Raw REQUEST_URI (query string is ignored). * @return string Bucket path, always non-empty. */ public static function site_bucket( string $host, string $uri ): string { $dir = self::host_dir( $host ); if ( '' === $dir ) { $dir = 'default'; } $prefix = self::site_path_prefix(); return '' === $prefix ? $dir : $dir . '/' . $prefix; } /** * The current blog's path prefix as a single safe segment ('' for the * root blog or a non-multisite install). `/siteb/` becomes `siteb`; * a nested `/a/b/` becomes `a-b` so the bucket stays one level deep. * * Written to a sidecar for the drop-in by sync_site_paths(). */ public static function site_path_prefix(): string { if ( ! function_exists( 'is_multisite' ) || ! is_multisite() ) { return ''; } if ( function_exists( 'is_subdomain_install' ) && is_subdomain_install() ) { return ''; // Hosts already differ; no prefix needed. } $path = function_exists( 'get_blog_details' ) ? (string) get_blog_details()->path : '/'; return self::path_prefix_segment( $path ); } /** * The bucket an arbitrary URL's cache entry lives in. * * `site_bucket()` answers for the CURRENT request; this answers for a URL * that may belong to another blog entirely — which is what a per-URL purge * is usually doing (WP-CLI, cron, the MCP tool, a network-admin action). * * The blog is resolved from the URL itself: on a subdirectory network * `get_blog_details()` is asked which blog owns ``, and its * registered path becomes the prefix. Deriving the prefix from the URL's * first path segment directly would be wrong — `/shop/` on the main blog * is a page, not a subsite, and would send the purge into a bucket that * does not exist. (QA B2 on #166) * * @param string $host Host of the URL being purged. * @param string $path Path of the URL being purged. * @return string Bucket path, always non-empty. */ public static function bucket_for_url( string $host, string $path ): string { $dir = self::host_dir( $host ); if ( '' === $dir ) { $dir = 'default'; } if ( ! function_exists( 'is_multisite' ) || ! is_multisite() ) { return $dir; } if ( function_exists( 'is_subdomain_install' ) && is_subdomain_install() ) { return $dir; // Hosts already differ; no prefix. } if ( ! function_exists( 'get_blog_details' ) ) { return $dir; } // Longest registered blog path that prefixes this URL wins, so // `/one/2026/post/` resolves to blog `/one/` and not to the root blog. $blog = self::blog_for_path( $host, $path ); if ( null === $blog ) { return $dir; } $prefix = self::path_prefix_segment( (string) $blog ); return '' === $prefix ? $dir : $dir . '/' . $prefix; } /** * The registered path of the blog that owns ``, or null. * * Uses get_blog_details() with a domain/path pair rather than scanning * every blog, so a large network costs one lookup per candidate segment * instead of a full table read. */ private static function blog_for_path( string $host, string $path ): ?string { $segments = array_values( array_filter( explode( '/', trim( $path, '/' ) ) ) ); // Try the longest candidate first: /a/b/ before /a/ before /. for ( $take = min( count( $segments ), 2 ); $take >= 1; $take-- ) { $candidate = '/' . implode( '/', array_slice( $segments, 0, $take ) ) . '/'; $details = get_blog_details( array( 'domain' => $host, 'path' => $candidate, ), false ); if ( $details && ! empty( $details->path ) ) { return (string) $details->path; } } return null; } /** * Normalise a blog path ('/', '/siteb/', '/a/b/') into a single * filesystem-safe segment. Shared with the drop-in's copy. */ public static function path_prefix_segment( string $path ): string { $path = trim( str_replace( "\0", '', $path ), '/' ); if ( '' === $path ) { return ''; } $path = preg_replace( '/[^a-zA-Z0-9._\-\/]/', '', $path ); $path = str_replace( '/', '-', (string) $path ); return trim( (string) $path, '.-' ); } /** * The current blog's path as the static tree stores it — real slashes * preserved, because that tree mirrors the URL * (`xspeed-static/{host}{request_uri}/index.html`) rather than using a * single flattened segment. '' for a root blog / single site. */ public static function site_path_raw(): string { if ( ! function_exists( 'is_multisite' ) || ! is_multisite() ) { return ''; } if ( function_exists( 'is_subdomain_install' ) && is_subdomain_install() ) { return ''; } $path = function_exists( 'get_blog_details' ) ? (string) get_blog_details()->path : '/'; $path = trim( str_replace( "\0", '', $path ), '/' ); if ( '' === $path ) { return ''; } $path = preg_replace( '#[^a-zA-Z0-9._\-/]#', '', $path ); return trim( (string) $path, '/' ); } /** * Static-tree root for the current site: `` plus the blog's real * path. Mirrors store_static()'s layout so a scoped purge deletes * exactly this blog's pages. */ public static function current_static_scope(): string { // Same switch_to_blog() caveat as current_host_dir() — see current_host(). // Keep the port folded into the segment exactly as store_static() does. $dir = self::static_host_dir( self::current_host() ); if ( '' === $dir ) { $dir = 'default'; } $path = self::site_path_raw(); return '' === $path ? $dir : $dir . '/' . $path; } /** * The bucket for the CURRENT request. Never empty, so an entry is never * written to the tree root (which is what the unscoped sweeps used to * delete indiscriminately). */ public static function current_host_dir(): string { $host = self::current_host(); $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/'; return self::site_bucket( $host, $uri ); } /** * The host the CURRENT blog is served from. * * Deliberately NOT just $_SERVER['HTTP_HOST']: inside a * switch_to_blog() the request header still names whichever site is * serving the admin screen, while the cache entries we want belong to * the switched-to blog. On a subdomain network the host IS the bucket, * so reading the header there would make Pro's per-site "purge this * site" button clear the network admin's own cache instead — the very * bug this scoping exists to fix, surviving in one topology. * * get_blog_details() follows the switch, so prefer it whenever we are * on multisite, and fall back to the request header otherwise. */ public static function current_host(): string { if ( function_exists( 'is_multisite' ) && is_multisite() && function_exists( 'get_blog_details' ) ) { $details = get_blog_details(); if ( $details && ! empty( $details->domain ) ) { return (string) $details->domain; } } if ( isset( $_SERVER['HTTP_HOST'] ) ) { return sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ); } /* * No request header — WP-CLI, or WP-Cron driven by system cron. * * Returning '' here made the bucket resolve to the literal `default` * while HTTP requests were writing to `/`, so a scheduled purge * swept an empty directory and reported success, and get_stats() * reported 0 cached pages on a site with a full cache. That is the * normal setup on any host running DISABLE_WP_CRON, which is most of * them. Fall back to the site's own registered host. (QA D4 on #166) */ if ( function_exists( 'home_url' ) ) { $parts = function_exists( 'wp_parse_url' ) ? wp_parse_url( home_url( '/' ) ) : parse_url( home_url( '/' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- early-boot fallback only. if ( is_array( $parts ) && ! empty( $parts['host'] ) ) { return (string) $parts['host']; } } return ''; } /** * Ensure the current site's cache directory exists, with the silence * index in both it and the shared root. Returns the directory. */ public static function ensure_host_dir(): string { $dir = XSPEED_CACHE_DIR . '/' . self::current_host_dir(); if ( ! file_exists( XSPEED_CACHE_DIR ) ) { wp_mkdir_p( XSPEED_CACHE_DIR ); self::write_silence( XSPEED_CACHE_DIR ); } if ( ! file_exists( $dir ) ) { wp_mkdir_p( $dir ); self::write_silence( $dir ); } return $dir; } public static function cache_file_for( $key ) { return XSPEED_CACHE_DIR . '/' . self::current_host_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; } if ( ! self::brotli_sibling_is_usable( $file, $br ) ) { return null; // fall through to the plain .html } 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; } /** * Is a precompressed `.br` sibling safe to serve? * * Existence is not enough. The sibling is written with a plain * file_put_contents() — no atomic rename — so a crash, a full disk, or a * read that races the write leaves a TRUNCATED file behind. Serving that * with `Content-Encoding: br` hands the browser a stream it cannot * inflate: it renders nothing at all (document.body is null) and the * navigation can hang. A 16-byte .br for a 172KB page reproduces it * exactly. (#286) * * Brotli has no magic number, and no byte-level marker distinguishes a * truncated stream from a short valid one (the ISLAST bit is bit-packed, * not byte-aligned). So this checks only what CAN be known by stat: * * - Not empty. A zero-byte sibling is unambiguously broken. * - Not older than the HTML. A stale sibling would serve the PREVIOUS * revision of the page under the current entry's ETag. * * A size-RATIO floor was tried here and removed. Brotli's ratio is * unbounded on repetitive input: a ~1 MB page of table rows or a product * grid — the ordinary shape of a big generated page — compresses to * about 0.04%, so a 2% floor rejected a perfectly good sibling and sent * visitors the uncompressed page instead, silently. Measured: 963 KB of * repeated markup → 89 bytes at q5 (0.009%). No floor can separate * "impossibly small" from "extremely compressible" for arbitrary HTML. * * Truncation is prevented at the WRITE side instead — see * write_atomic(), which the Brotli writer uses so a partial file is * never visible under the final name. Detection at read time cannot be * made correct; not creating the bad file can. * * Anything suspicious returns false and the caller streams the plain * .html — slower, always correct. Serving an uninflatable body is worse * than serving no compression at all. * * @param string $file Absolute path to the .html cache file. * @param string $br Absolute path to its .br sibling. * @return bool True when the sibling may be served. */ /** * Write a cache sidecar so a partial file is never visible. * * `file_put_contents()` truncates the target and then fills it, so any * reader arriving mid-write — or any crash, full disk, or killed worker * — leaves a SHORT file under the real name. For HTML that degrades to a * clipped page; for a `.br` sibling it is worse, because a truncated * brotli stream is not a short page but an UNINFLATABLE one: the browser * renders nothing at all and the navigation can hang. * * Writing to a unique temp file in the same directory and renaming is * atomic on POSIX, so readers see either the previous complete file or * the new complete file, never a partial one. This is the half of #286 * that is actually fixable — a read-time heuristic cannot tell a * truncated brotli stream from a very small valid one, but a truncated * file that never becomes visible needs no detection. * * @param string $path Absolute destination path. * @param string $contents Bytes to write. * @return bool True when the destination now holds exactly $contents. */ public static function write_atomic( string $path, string $contents ): bool { $dir = dirname( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- WP_Filesystem needs admin creds unavailable on a frontend cache write; this is our own cache dir. if ( ! is_dir( $dir ) || ! is_writable( $dir ) ) { return false; } // Same directory, so the rename stays on one filesystem — a rename // across devices is a copy and loses atomicity. $tmp = @tempnam( $dir, '.xspeed-tmp-' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a failure returns false and the caller skips the write. if ( ! is_string( $tmp ) || '' === $tmp ) { return false; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem needs admin creds unavailable on a frontend cache write; target is our own cache dir. $written = @file_put_contents( $tmp, $contents ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- handled by the length check below. // A short write is exactly the failure this function exists to // prevent, so verify the byte count before publishing the file. if ( false === $written || $written !== strlen( $contents ) ) { @unlink( $tmp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort cleanup of our own temp file; non-fatal. return false; } // tempnam() creates the file 0600; cache files must stay readable by // the web server, which may run as a different user. @chmod( $tmp, defined( 'FS_CHMOD_FILE' ) ? FS_CHMOD_FILE : 0644 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod, WordPress.PHP.NoSilencedErrors.Discouraged -- the web server may run as another uid and must be able to read the published file; a chmod failure is not fatal. // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename, WordPress.PHP.NoSilencedErrors.Discouraged -- the atomic publish this function exists for; WP_Filesystem offers no atomic rename and needs admin creds. if ( ! @rename( $tmp, $path ) ) { @unlink( $tmp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort cleanup; non-fatal. return false; } return true; } public static function brotli_sibling_is_usable( string $file, string $br ): bool { $br_size = (int) @filesize( $br ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a stat failure means "don't serve it", handled by the <= 0 check. if ( $br_size <= 0 ) { return false; } $html_size = (int) @filesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- as above. if ( $html_size <= 0 ) { return false; } // A sibling older than the page it compresses is stale. $br_mtime = (int) @filemtime( $br ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- as above. $html_mtime = (int) @filemtime( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- as above. if ( $br_mtime > 0 && $html_mtime > 0 && $br_mtime < $html_mtime ) { return false; } // The writer recorded how many bytes it produced. Where that record // exists, truncation is a certainty rather than an inference: a // stream shorter than its own declared length cannot inflate, and // one that matches was published whole. This is what a size ratio // could never be — brotli's ratio is unbounded on repetitive input, // so a 0.01% sibling of a generated page is genuinely valid. // // Absent for a sibling written before this version, or by an add-on // that writes the file directly. That case keeps the checks above // and no more, which is where a pre-existing truncated file on a // live site still slips through — write_atomic() stops NEW ones, // but it cannot retroactively vouch for what is already on disk. $expected = self::brotli_expected_size( $br ); if ( $expected > 0 && $br_size !== $expected ) { return false; } return true; } /** * Path of the sidecar recording a `.br` sibling's complete byte count. * * Kept beside the sibling as `.html.br.size` rather than folded * into the entry's `.meta`: the static tree the web server serves has no * `.meta` at all, and the two trees must answer this question the same * way. Every path that deletes a `.br` deletes this with it. * * @param string $br Absolute path to the `.br` sibling. * @return string Absolute path to its size sidecar. */ public static function brotli_size_sidecar( string $br ): string { return $br . '.size'; } /** * The byte count the writer recorded for a `.br` sibling, or 0 when no * record exists (a sibling predating this version, or written by an * add-on that bypassed write_brotli_sibling()). * * @param string $br Absolute path to the `.br` sibling. * @return int Expected size in bytes, or 0 when unknown. */ public static function brotli_expected_size( string $br ): int { $sidecar = self::brotli_size_sidecar( $br ); if ( ! is_file( $sidecar ) ) { return 0; } // 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( $sidecar ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- an unreadable sidecar means "unknown", handled by the cast below. return max( 0, (int) trim( (string) $raw ) ); } /** * Publish a `.br` sibling together with the record of its own length. * * The single writer every producer of a `.br` should route through — the * Pro Brotli module included. Publishing the body atomically stops a * truncated file from ever becoming visible; recording the byte count * lets the serve path prove wholeness for the files that already exist * on disk when this ships. * * Order matters: the size sidecar is removed first and written last, so * a reader arriving mid-update sees "no record" (checks above still * apply) rather than the previous body's length against the new body. * * @param string $br Absolute path to the `.br` sibling to write. * @param string $contents Compressed bytes. * @return bool True when both the sibling and its size record are in place. */ public static function write_brotli_sibling( string $br, string $contents ): bool { $sidecar = self::brotli_size_sidecar( $br ); if ( is_file( $sidecar ) ) { wp_delete_file( $sidecar ); } if ( ! self::write_atomic( $br, $contents ) ) { return false; } if ( self::write_atomic( $sidecar, (string) strlen( $contents ) ) ) { return true; } // The body landed but its length did not. That sibling is servable // and unguarded — exactly the file this function exists to prevent — // and the caller has no way to know. Withdraw it: a MISS costs one // uncompressed response, where an unguarded sibling can cost a blank // page for as long as the entry lives. wp_delete_file( $br ); return false; } /** * 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 . '/' . self::current_host_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 ); // Honour the per-entry TTL the .meta sidecar carries, when it is // SHORTER than what we just resolved. The sidecar records the TTL // this specific entry was written under — a nonce cap (#236), a Pro // feed/404 expiry — and the drop-in already reads it. is_expired() // did not, so on the engine path a capped entry was still served for // the full configured lifetime: exactly the stale nonce the cap // exists to prevent. Only ever shortens, so an entry can never be // kept alive past the configured maximum by a stale sidecar. // Derive the sidecar from the FILE we were handed rather than // recomputing cache_key(): callers legitimately ask about an entry // that isn't the current request's (Cache_GC sweeps, Pro's warmer), // and cache_key() would answer for the wrong one — besides needing a // request context this function has no business requiring. $meta_file = preg_replace( '/\.html$/', '.meta', (string) $file ); if ( is_string( $meta_file ) && $meta_file !== $file && is_readable( $meta_file ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- our own cache dir; WP_Filesystem needs admin creds unavailable on a frontend read. $raw = file_get_contents( $meta_file ); $decoded = is_string( $raw ) ? json_decode( $raw, true ) : null; if ( is_array( $decoded ) && isset( $decoded['ttl'] ) ) { $entry_ttl = (int) $decoded['ttl']; if ( $entry_ttl > 0 && ( $max_age < 1 || $entry_ttl < $max_age ) ) { $max_age = $entry_ttl; } } } // 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; } } // AFTER minification on purpose — the HTML minifier strips comments, // so signing earlier would erase the signature from every minified // page. Baked into the cached bytes so all three serve paths (nginx // static rewrite, .htaccess, the PHP drop-in) carry it identically. $full = self::signed( $full ); if ( $single_chunk ) { $buffer = $full; } // Per-site directory — see ensure_host_dir(). (#6) self::ensure_host_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 — // and is filtered by host_dir() to [A-Za-z0-9.-] with leading // dots trimmed, so no traversal sequence ('..', '/', null byte, etc.) // can appear in either segment. The write is therefore always inside // XSPEED_CACHE_DIR. $key = self::cache_key(); // Query-string gate. should_cache() waved this request through // because every param is on the ignored_query_params allow-list, and // cache_key() drops the query so reads share the canonical entry. // That sharing is safe on READ but not on WRITE: this response was // rendered WITH the params, and WordPress reflects REQUEST_URI into // form actions, share links and plugin smart tags — so storing it // would serve an attacker-chosen variant under the clean URL for the // whole TTL (#241). // // This sits BELOW the transforms deliberately. Returning above them // also skipped xspeed_cache_final_html, and every listener disables // its own fallback ob_start() when the page cache is on precisely // because that filter is the shared transport — so a visitor // arriving on ?utm_source=… was served HTML with no LCP preload, no // preconnect, no CDN rewrite, no CSS combine and no HTML minify. // That is the ad-click and newsletter cohort getting the least // optimised page on the site. Only the WRITE is skipped, which is // what this fix was always meant to do — and it is where the // deferred writer has always placed its own copy of the guard. if ( self::query_string_blocks_write() ) { return $buffer; } $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, $full ); // 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. */ /** * Transient holding the most recent static-tree refusal. * * Short-lived on purpose: it describes what the last cacheable render * actually did, so a stale entry would keep warning about a page whose * nonces have since been removed. A site that still refuses simply * rewrites it on the next render. (#372) */ private const STATIC_SKIP_TRANSIENT = 'xspeed_static_skip'; /** * Remember why a page was kept out of the static tree, for Health. * * Records the URL, the reason, and — for the nonce case — the distinct * nonce KEYS found, which is what makes the finding actionable: the names * (`eael_login_nonce`, `post_grid_pagination_nonce`, …) trace straight back * to the plugin emitting them, and it is usually a widget the site does not * use on that page. Only key names are kept, never the nonce values. * * @param string $reason Machine-readable refusal reason. * @param string $html The response, for extracting the nonce keys. */ private static function note_static_skip( string $reason, string $html = '' ): void { if ( ! function_exists( 'set_transient' ) ) { return; } $keys = array(); if ( 'nonce' === $reason && '' !== $html ) { // Must recognise the SAME shapes response_has_nonce() refuses on, // or a page is skipped and reported with no keys at all — which is // most of them, since the plain `name="_wpnonce"` form field is the // commonest shape by far and only the JSON one was handled here. // The keys are the actionable half of the message, so a mismatch // leaves the admin with bad news and nothing to act on. // // Both alternations capture the KEY only: each value pattern sits // outside the capture group, so a nonce secret can never be stored. $found = array(); if ( preg_match_all( '/name=["\']([a-z0-9_\-\[\]]*nonce[a-z0-9_\-\[\]]*)["\']/i', $html, $m ) ) { $found = array_merge( $found, $m[1] ); } if ( preg_match_all( '/["\']([a-z0-9_\-]*nonce[a-z0-9_\-]*)["\']\s*:\s*["\'][a-f0-9]{8,}["\']/i', $html, $m ) ) { $found = array_merge( $found, $m[1] ); } // The query-arg shape (`?_wpnonce=…`) has no key name to report // beyond the literal, so name it explicitly rather than reporting // nothing for a page that was genuinely refused. if ( preg_match( '/[?&]_wpnonce=/i', $html ) ) { $found[] = '_wpnonce'; } $keys = array_slice( array_values( array_unique( $found ) ), 0, 10 ); } $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; set_transient( self::STATIC_SKIP_TRANSIENT, array( 'reason' => $reason, 'url' => (string) strtok( $uri, '?' ), 'keys' => $keys, 'at' => time(), ), HOUR_IN_SECONDS ); } /** * The most recent static-tree refusal, or an empty array when there is none. * * @return array{reason:string,url:string,keys:string[],at:int}|array{} */ public static function last_static_skip(): array { $stored = function_exists( 'get_transient' ) ? get_transient( self::STATIC_SKIP_TRANSIENT ) : false; return is_array( $stored ) && ! empty( $stored['reason'] ) ? $stored : array(); } 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; } // Same hazard for the allow-listed query params: store_static() // strips the query and files the response under the bare path, which // the web server then serves to every visitor of the clean URL with // no PHP involved at all — so none of the engine's checks can catch // it later (#241). The callers already gate on this, but the guard // is repeated here because this tree is the most dangerous of the // three write sites and must not depend on its callers. if ( self::request_has_query_string() ) { return; } // A nonce-bearing page is served here with NO PHP: no TTL check and // no .meta replay, so the per-entry cap that keeps the drop-in honest // (#236) cannot reach a file once it is written. Only Cache_GC removes // it, and until it does the page hands every visitor the same nonce — // which, once that nonce dies, breaks every anonymous form on it. // // Refusing outright was the safe answer, and it cost every // nonce-bearing page the static tree entirely: a site whose homepage // carries one unused login nonce ran PHP on every request forever. // The nonce's own remaining life is the better gate — the page is // written and its deadline recorded below for GC to enforce. // // A nonce we cannot put a clock on is still refused, and that refusal // is still recorded: it stays completely silent otherwise, because the // drop-in answers HIT while Health reports the fast path active from a // probe that writes its OWN file and never proves real pages reach the // tree. (#372) $nonce_ttl = self::response_has_nonce( $html ) ? self::nonce_capped_ttl( $html, 0 ) : 0; if ( self::response_has_nonce( $html ) && $nonce_ttl < 1 ) { self::note_static_skip( 'nonce', $html ); 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 = self::static_host_dir( $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 ); // A nonce-bearing page expires on the nonce's schedule, not the site's. // Nothing reads this file at serve time — the web server hands over // index.html without PHP — so the deadline is recorded beside it for // GC, which is the only thing that can enforce it. Written before the // action below so a listener that shells out cannot race the sweep. if ( false !== $written && $nonce_ttl > 0 ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- same rationale as the write above. file_put_contents( $dir . '/.xspeed-expires', (string) ( time() + $nonce_ttl ), 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; } /** * Append the cache signature comment to a finished page. * * The plugin's one outward version signal: external scanners (the * xspeedcache.com speed test among them) read it to detect xSpeed and * its version on a cached page, the way other cache plugins sign their * output. Callers apply it AFTER HTML minification — the minifier strips * comments — and before every cache write, so all serve paths carry the * same bytes. * * The generation time is baked in here, at write time, in UTC. It is the * moment the cached bytes were produced — NOT the moment they were served * — because all three serve paths replay the same stored file, and two of * them (the nginx/`.htaccess` static rewrite) run no PHP at all and so * could never stamp a serve-time value. Reading the age of a page is the * point: `generated` plus the current clock tells you how stale it is. * `gmdate()` (not `current_time()`) keeps the value comparable across * sites regardless of the configured timezone. * * @param string $html Finished page HTML. * @return string HTML with the signature appended (or unchanged when a * filter removed it). */ private static function signed( string $html ): string { $version = defined( 'XSPEED_VERSION' ) ? XSPEED_VERSION : ''; $generated = gmdate( 'Y-m-d H:i:s' ) . ' UTC'; // The literal ' | xspeedcache.com' must survive intact, and what // precedes it is where an edition suffix lands: Pro appends itself by // str_replace()-ing on that exact token // (Pro_Plugin::sign_cache_signature). So the stamp goes AFTER it — // placed before, it sits between the version and the anchor and // composes as "generated + Pro v1.1.3". $signature = sprintf( '', $version, $generated ); /** * Filter: xspeed_cache_signature * * The HTML comment appended to every cached page. Add-ons append * their own edition/version here; white-label setups return '' to * remove the comment entirely. Must remain a valid HTML comment (or * an empty string) — it ships inside the cached body. * * @param string $signature The signature comment. * @param string $version The plugin version baked into it. * @param string $generated The write-time timestamp baked into it, * formatted `Y-m-d H:i:s UTC`. */ $signature = (string) apply_filters( 'xspeed_cache_signature', $signature, $version, $generated ); if ( '' === trim( $signature ) ) { return $html; } return $html . "\n" . $signature; } /** * Does this response carry a WordPress nonce? * * Anonymous nonces depend only on the tick (user 0, empty session * token), so they are identical for every visitor — which is exactly why * they cache "successfully" and then fail silently once the tick moves. * * Matches any form field whose NAME contains "nonce" — `_wpnonce`, * `_wpnonce_`, Tutor's `_tutor_nonce`, CF7's `_wpcf7_nonce` and * WooCommerce's `woocommerce-add-to-cart-nonce` (which does NOT start * with an underscore, so a `_`-anchored pattern misses it) — plus the * `_wpnonce=` form used in nonce-bearing URLs. Deliberately keyed on * `name=` so prose, CSS classes and data attributes don't false-positive. * * @param string $html Rendered response body. */ public static function response_has_nonce( string $html ): bool { if ( '' === $html ) { return false; } /* * Three shapes, because a nonce reaches the page in three ways: * * 1. A form field name — `_wpnonce`, `woocommerce-login-nonce`, and * the GROUPED names form builders emit (`data[_wpnonce]`, * `frm[nonce]`). The character class deliberately allows `[` and * `]` so grouping does not hide the field: form builders are * exactly the kind of plugin #236 is about, and a missed page * keeps the old broken behaviour silently. * 2. A query argument (`?_wpnonce=`) on a link. * 3. A nonce handed to the page's own scripts rather than placed in * a visible form — `wp_localize_script()` output and inline JSON * both land as a `"nonce":"…"`-shaped pair. */ return 1 === preg_match( '/(name=["\'][a-z0-9_\-\[\]]*nonce[a-z0-9_\-\[\]]*["\']' . '|[?&]_wpnonce=' . '|["\'][a-z0-9_\-]*nonce[a-z0-9_\-]*["\']\s*:\s*["\'][a-f0-9]{8,}["\'])/i', $html ); } /** * The TTL (seconds) a response may be cached for, capped to the nonce * lifetime when it carries one. * * WordPress nonces are valid for at most `nonce_life` — 24h by default — * because wp_verify_nonce() accepts the current tick and the previous * one. Our own lifetime maximum is 720h and the shipped Aggressive * preset is 168h, so on any site configured above 24h every anonymous * front-end form carried a DEAD nonce for the majority of the cache's * life and every submission was rejected — with the other plugin's error * string ("Nonce not matched"), so the report never reached us (#236). * * `nonce_life` is the MAXIMUM a nonce can live, not the minimum, so it * is the wrong number to cap with. wp_nonce_tick() buckets time into * `nonce_life / 2` slices; a nonce minted x seconds into its bucket is * valid for `nonce_life - x`, where x can be as large as a full bucket. * Capping the entry at `nonce_life` therefore still served a dead nonce * for up to half of every entry's life — 0-12h of each 24h entry, * averaging 6h, re-rolled by every purge so it reads as intermittent. * Capping at the guaranteed-valid remainder closes the window at every * tick phase, at the cost of caching nonce-bearing pages for 12h rather * than 24h. * * Capping is per-entry, so only nonce-bearing pages pay for it; the rest * of the site keeps the configured lifetime. * * @param string $html Rendered response body. * @param int $ttl Otherwise-resolved TTL in seconds. * @return int TTL to actually use. */ /** * The nonce lifetime to cap against, in seconds. * * `nonce_life` is a TWO-argument filter in core: * * $nonce_life = apply_filters( 'nonce_life', DAY_IN_SECONDS, $action ); * * Applying it with one argument is not merely incomplete — a callback * that declares both parameters as required (the documented shape, and * what a site branching per action must write) raises ArgumentCountError * the moment we call it. That fatal lands in the shutdown cache write, * so the visitor still sees a perfectly normal page while the sidecar is * never written: the entry then keeps the FULL configured lifetime * carrying a dead nonce, which is precisely the bug #236 set out to fix. * Worse, the entry stays that way until a purge, even after the site * removes whatever customised the lifetime. * * We are inspecting rendered markup, so we cannot know which action * minted the nonce we found. Two consequences: * * 1. We pass `''` as the action. A per-action callback therefore sees * the same "unknown action" value core itself passes when a nonce is * created with no action, and can branch on it deliberately. * 2. A page may carry nonces from SEVERAL actions with different * lifetimes. The entry can only have one TTL, so the safe choice is * the SHORTEST lifetime any action on the site resolves to — capping * to a longer one would serve a dead nonce for the shorter action. * Sites can narrow this with `xspeed_cache_nonce_life_actions`. * * @param string $html Response body being cached. * @return int Nonce lifetime in seconds (0 = do not cap). */ private static function nonce_life_seconds( string $html ): int { /** * Filter the nonce actions whose lifetimes are consulted when * capping a cache entry. * * The default `''` is the "action unknown" case — we are reading * rendered HTML, not minting a nonce. A site whose `nonce_life` * callback shortens specific actions can list them here so the cap * accounts for the shortest one that could appear on the page. * * @since 1.1.8 * @param string[] $actions Nonce actions to resolve. * @param string $html The response body being cached. */ $actions = (array) apply_filters( 'xspeed_cache_nonce_life_actions', array( '' ), $html ); if ( empty( $actions ) ) { $actions = array( '' ); } $shortest = 0; foreach ( $actions as $action ) { // Both arguments, exactly as core passes them. $life = (int) apply_filters( 'nonce_life', DAY_IN_SECONDS, (string) $action ); if ( $life < 1 ) { continue; } if ( 0 === $shortest || $life < $shortest ) { $shortest = $life; } } return $shortest; } public static function nonce_capped_ttl( string $html, int $ttl ): int { if ( ! self::response_has_nonce( $html ) ) { return $ttl; } $nonce_life = self::nonce_life_seconds( $html ); if ( $nonce_life < 1 ) { return $ttl; } // Half of nonce_life is the GUARANTEED-valid remainder — see above. $guaranteed = max( 1, intdiv( $nonce_life, 2 ) ); $capped = ( $ttl > 0 ) ? min( $ttl, $guaranteed ) : $guaranteed; /** * Filter the nonce-capped TTL for a cache entry. * * Escape hatch for a site whose nonce-shaped markup is decorative — * return the uncapped $ttl to keep the configured lifetime. Most * sites should leave this alone: serving a dead nonce breaks every * anonymous form on the page. * * @param int $capped TTL after the nonce cap (seconds). * @param int $ttl TTL before the cap (seconds). * @param int $nonce_life Current nonce lifetime (seconds). * @param string $html The response body being cached. */ return (int) apply_filters( 'xspeed_cache_nonce_ttl_cap', $capped, $ttl, $nonce_life, $html ); } private static function write_meta( string $key, string $html = '' ): 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) // This MUST resolve the TTL the same way is_expired() does, including // the per-post override — the sidecar is the only channel that can // carry a per-entry TTL into the pre-boot fast paths. Omitting the // override here left an editor's "expire this post after 1h" visible // to the engine but invisible to the drop-in, which kept serving the // entry until the global lifetime elapsed (#240 AC#3). Handing the // filter the same base as is_expired() also keeps a filter that // SCALES its input (e.g. $max_age * 2) consistent between the two. $opts = Settings_Manager::get( 'cache' ); $default_ttl = (int) $opts['cache_expiry'] * HOUR_IN_SECONDS; $max_age = $default_ttl; $post_override = Cache_Rules::expiry_override_seconds_for_post( Cache_Rules::current_post_id() ); if ( null !== $post_override ) { $max_age = $post_override; } /** This filter is documented in includes/class-cache.php */ $ttl = (int) apply_filters( 'xspeed_cache_max_age', $max_age ); // A response carrying a nonce may not outlive that nonce, however // long the site's configured lifetime is (#236). This runs AFTER the // max-age filter so it caps whatever the filter resolved rather than // being overridden by it — a Pro module lengthening the TTL must not // be able to reintroduce a dead nonce. $ttl = self::nonce_capped_ttl( $html, $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. */ /** * Post types that are not "viewable" but ARE the presentation layer. * * `is_post_type_viewable()` answers "does this type have a front end of * its own?" — which is the right question for `shop_order`, but the * wrong one for the types core uses to render every OTHER page. A * template part, a global-styles record, a navigation or a synced * pattern has no permalink, yet editing one changes how the whole site * looks. Gating purges on viewability alone meant a Site Editor save * invalidated nothing and visitors kept the old design for the full * TTL — up to 30 days at the maximum lifetime. (#270 regression) * * @return string[] */ /** * Could this post change alter anything an anonymous visitor had cached? * * Deleting one post fired a full purge for the post AND for every stored * revision, because wp_delete_post() removes each revision through * wp_delete_post() again and every one of those fires before_delete_post * with post_type 'revision'. A post with six revisions cost seven whole- * site sweeps, each one also announcing to LiteSpeed, purging the object * cache network-wide on Redis, rewriting the stats option and running * every xspeed_after_purge_all listener -- including Pro's Cloudflare * purge, so seven API calls. Trashing cost two, via save_post and then * trashed_post. (QA #348) * * The check lives here, ahead of purge_all(), so one early return covers * the local sweep, the server-cache announcement and both action hooks. * It deliberately does NOT live inside purge_all(): a manual, CLI or * explicit caller asked for a purge and must get one. * * @param int $post_id Post being saved or removed. * @param mixed $post Post object when the hook passed one. * @param string $event 'save' or 'remove'. */ private static function post_change_is_cacheable_content( $post_id, $post, string $event ): bool { $post_id = (int) $post_id; // Only `save_post` and `before_delete_post` hand over a post object. // `trashed_post` passes ( $post_id, $previous_status ) -- a STRING -- // so reaching for ->post_status on the second argument finds nothing // and the status rule below would never fire. Read the row instead. if ( ! is_object( $post ) && function_exists( 'get_post' ) ) { $post = get_post( $post_id ); } $type = is_object( $post ) && isset( $post->post_type ) ? (string) $post->post_type : (string) ( function_exists( 'get_post_type' ) ? get_post_type( $post_id ) : '' ); if ( '' === $type ) { return false; } // A revision is a copy of content nobody can browse to. if ( 'revision' === $type ) { return false; } if ( function_exists( 'wp_is_post_revision' ) && wp_is_post_revision( $post_id ) ) { return false; } if ( function_exists( 'wp_is_post_autosave' ) && wp_is_post_autosave( $post_id ) ) { return false; } $status = is_object( $post ) && isset( $post->post_status ) ? (string) $post->post_status : ''; // Clicking "Add New" inserts an auto-draft and fires save_post. There // is nothing cached of a post that has never existed publicly. if ( 'auto-draft' === $status ) { return false; } // Unknown/!viewable → nothing anonymous can see changed, UNLESS the // type is itself part of how pages render (#270 regression). if ( function_exists( 'is_post_type_viewable' ) && ! is_post_type_viewable( $type ) && ! in_array( $type, self::presentation_post_types(), true ) ) { return false; } // Deleting something that was already invisible changes no cached // page: the transition that hid it purged at the time. This is what // makes emptying a trash of a hundred posts cost nothing rather than // a hundred full sweeps. // // It also collapses trashing to a single purge: wp_trash_post() fires // save_post first, where the post is genuinely disappearing from // listings and SHOULD purge, then trashed_post, by which point the // row reads 'trash' and is skipped. A status we cannot read, on a row // that still reports a type, means assume viewable -- erring toward // an extra purge, never toward serving a stale page. A row that is // gone entirely reports no type either and was refused above. // 'inherit' is an INTERNAL status in core, so is_post_status_viewable() // says no -- but an attachment carrying it is genuinely public. Judge // those on the post type alone, which is already checked above. if ( 'remove' === $event && '' !== $status && 'inherit' !== $status && function_exists( 'is_post_status_viewable' ) && ! is_post_status_viewable( $status ) ) { return false; } return true; } public static function presentation_post_types(): array { $types = array( 'wp_template', // Site Editor templates. 'wp_template_part', // Header / footer / reusable parts. 'wp_global_styles', // Colours, typography, spacing. 'wp_navigation', // Navigation block menus. 'nav_menu_item', // Classic menus. 'wp_block', // Synced patterns / reusable blocks. ); /** * Filter the non-viewable post types that still invalidate the cache. * * Add a type here when it has no front end of its own but changes * how other pages render (a theme's own layout CPT, for example). * * @param string[] $types Post type slugs. */ return (array) apply_filters( 'xspeed_presentation_post_types', $types ); } /** * Describe a broad hook invalidation for response-cache adapters. * * Term, menu, theme and plugin changes can alter navigation, archives or * markup across the site, so they require a site response-cache purge. * Content saves also require this scope while their local operation is a * complete bucket sweep. * * @return array{scope:string,intent:string,urls:array} */ private static function invalidation_for_hook( string $hook ): array { $presentation = array( 'switch_theme', 'activated_plugin', 'deactivated_plugin', 'created_term', 'edited_term', 'delete_term', 'wp_update_nav_menu', ); return array( 'scope' => 'site', 'intent' => in_array( $hook, $presentation, true ) ? 'presentation' : 'content', 'urls' => array(), ); } /** * save_post → purge only when the saved thing can appear on a cached page. * * Revisions and autosaves are never rendered. Non-viewable post types — * WooCommerce's `shop_order` / `shop_order_placehold` / `shop_order_refund` * / `shop_coupon`, Flamingo's `flamingo_inbound` (#229), Tutor's * `tutor_enrolled` (#231) — are invisible to anonymous visitors, so * writing one changes nothing that is cached. (#243) * * The exception is the presentation types above, which are non-viewable * yet render every page — they are allow-listed BEFORE the viewability * test. (#270 regression) * * @param int $post_id Saved post ID. * @param \WP_Post $post Saved post object. */ public static function on_save_post( $post_id, $post = null ): void { if ( ! self::post_change_is_cacheable_content( $post_id, $post, 'save' ) ) { return; } $post_type = is_object( $post ) && isset( $post->post_type ) ? (string) $post->post_type : (string) get_post_type( $post_id ); // Name the trigger rather than logging a bare numeric id — the old // wiring passed the post ID into $cause, so the log read // "Cache purged (46)" with no indication of what caused it. (#243) $presentation = in_array( $post_type, self::presentation_post_types(), true ); self::purge_all( 'post:' . $post_type, null, array( // purge_all() sweeps every local response in this site's bucket. // Without dependency tracking, the server cache must match that // same boundary or unrelated pages can remain stale there. 'scope' => 'site', 'intent' => $presentation ? 'presentation' : 'content', 'urls' => array(), ) ); if ( class_exists( '\XSpeed\Minifier' ) ) { Minifier::purge_minified(); } } /** * Delete/trash invalidation while the post type is still available. * The local and server response-cache sweeps share the same site boundary. * * @param int $post_id Removed post ID. * @param object|null $post Post object supplied by core when available. */ public static function on_post_removed( $post_id, $post = null ): void { if ( ! self::post_change_is_cacheable_content( $post_id, $post, 'remove' ) ) { return; } $post_type = is_object( $post ) && isset( $post->post_type ) ? (string) $post->post_type : (string) get_post_type( $post_id ); self::purge_all( 'post-removed:' . $post_type, null, array( 'scope' => 'site', // Match on_save_post: a presentation type changes how pages // render rather than what they say. 'intent' => in_array( $post_type, self::presentation_post_types(), true ) ? 'presentation' : 'content', 'urls' => array(), ) ); } /** Purge site responses when moderation changes visible comments. */ public static function on_comment_status( $comment_id, $status = '' ): void { $comment = function_exists( 'get_comment' ) ? get_comment( (int) $comment_id ) : null; $post_id = is_object( $comment ) && isset( $comment->comment_post_ID ) ? (int) $comment->comment_post_ID : 0; if ( $post_id < 1 || ! function_exists( 'get_permalink' ) ) { return; } $url = get_permalink( $post_id ); if ( ! is_string( $url ) || '' === $url ) { return; } self::purge_all( 'comment-status:' . (string) $status, null, array( 'scope' => 'site', 'intent' => 'content', 'urls' => array(), ) ); } /** * comment_post → purge just the commented-on URL, and only once the * comment is actually visible. * * A comment held for moderation changes nothing on the front end, and an * approved one changes exactly one page — not the whole site. Product * reviews are comments and guest reviews are on by default, so under the * old wiring any visitor could flush a store's entire cache, repeatedly, * with no account. (#243) * * @param int $comment_id New comment ID. * @param int|string $approved 1 when approved, 0 when held, 'spam'. * @param array $data Comment data. */ public static function on_comment_post( $comment_id, $approved = 0, $data = array() ): void { if ( 1 !== (int) $approved ) { return; } $post_id = is_array( $data ) && isset( $data['comment_post_ID'] ) ? (int) $data['comment_post_ID'] : 0; if ( $post_id < 1 ) { return; } $url = get_permalink( $post_id ); if ( is_string( $url ) && '' !== $url ) { self::purge_url( $url, 'comment' ); } } /** * user_register / profile_update → purge only when the user can author * content that appears on the front end. * * A customer registering at checkout changes no rendered page, and cannot * change an enqueued asset — so it must not purge the cache, and must not * rebuild the minified bundles. Checkout account-creation fired FOUR * full-site purges plus four purge_minified() runs in a single request * before this gate. (#243) * * @param int $user_id Affected user. */ public static function on_user_change( $user_id ): void { $user = function_exists( 'get_userdata' ) ? get_userdata( (int) $user_id ) : null; if ( ! $user ) { return; } // Only roles that can publish can change a rendered page. WooCommerce // customers and WordPress subscribers cannot. if ( ! user_can( $user, 'edit_posts' ) ) { return; } $url = get_author_posts_url( (int) $user_id ); if ( is_string( $url ) && '' !== $url ) { self::purge_url( $url, 'user' ); } } /** * Purge everything a product's price / stock / sale state is rendered on. * * The product permalink is not enough: the shop archive and the product's * category and tag archives render the same price and Sale! badge, and * #242 reproduces all three going stale together. * * Accepts a product ID or a WC_Product. A variation resolves to its * parent, which is the page that actually renders. * * @param int|object $product Product ID or WC_Product. */ public static function purge_product( $product ): void { $product_id = is_object( $product ) && method_exists( $product, 'get_id' ) ? (int) $product->get_id() : (int) $product; if ( $product_id < 1 ) { return; } // Variations are never rendered on their own URL. $parent = (int) wp_get_post_parent_id( $product_id ); if ( $parent > 0 ) { $product_id = $parent; } $urls = array(); $permalink = get_permalink( $product_id ); if ( is_string( $permalink ) && '' !== $permalink ) { $urls[] = $permalink; } // The shop archive. if ( function_exists( 'wc_get_page_id' ) ) { $shop_id = (int) wc_get_page_id( 'shop' ); if ( $shop_id > 0 ) { $shop_url = get_permalink( $shop_id ); if ( is_string( $shop_url ) && '' !== $shop_url ) { $urls[] = $shop_url; } } } // Every category / tag archive this product appears on. foreach ( array( 'product_cat', 'product_tag' ) as $taxonomy ) { $terms = get_the_terms( $product_id, $taxonomy ); if ( ! is_array( $terms ) ) { continue; } foreach ( $terms as $term ) { $term_url = get_term_link( $term ); if ( is_string( $term_url ) && '' !== $term_url ) { $urls[] = $term_url; } } } // The front page, when it is not the shop page but still lists // products (a block/shortcode storefront). $front_id = (int) get_option( 'page_on_front' ); if ( $front_id > 0 ) { $front_url = get_permalink( $front_id ); if ( is_string( $front_url ) && '' !== $front_url ) { $urls[] = $front_url; } } /** * Filter the URLs purged when a product changes. * * A storefront that renders products somewhere else — a landing page, * a custom archive — can add its URLs here rather than falling back * to purging the whole site. * * @param string[] $urls URLs about to be purged. * @param int $product_id The product that changed. */ $urls = (array) apply_filters( 'xspeed_purge_product_urls', $urls, $product_id ); foreach ( array_unique( array_filter( $urls ) ) as $url ) { self::purge_url( (string) $url, 'product' ); } } /** * Adapter for the WooCommerce stock actions that pass a product OBJECT * where the status actions pass an ID. * * @param object $product WC_Product (or variation). */ public static function purge_product_object( $product ): void { self::purge_product( $product ); } /** * Re-entry guard for the purge-event contract. * * A listener on `xspeed_after_purge_url` legitimately purges its own * layer, and a server-cache or CDN adapter that calls back into xSpeed * while doing so re-enters this method — unbounded, because each pass * looks like a fresh purge. * * A single global flag stops too much: a nested purge of a DIFFERENT URL is * a real purge whose listeners must hear about it. But a per-request * "already published" set stops too much in the other direction — a * network purge loops every blog in one request, and on a subdirectory * network they share a host, so blogs 2..N would be silently skipped. It * also grows for the life of the process. * * So the guard tracks what is IN FLIGHT, not what has been published: a * target is marked while its own dispatch is on the stack and unmarked * when it returns. Re-entering the same target recurses, so it is refused; * purging the same URL again later is a new event and publishes. The set * is bounded by call depth rather than by how many URLs a request touches. * * @var array */ private static $purge_events_in_flight = array(); /** Monotonic count used to detect whether a delegated purge published. */ private static $purge_event_sequence = 0; /** * Publish a purge event exactly once, with bounded arguments. * * Deliberately carries only what an integration needs to invalidate its * own copy: the canonical URL (or null for a full purge), the site host, * the cause label, and how many files went. No filesystem paths, no cache * contents, no request headers, no user data. The URL query and caller- * supplied cause may nevertheless contain sensitive text, so listeners * must redact them in logs or unrelated destinations that do not need the * exact cache key. * * A listener that throws must not take the purge down with it: the files * are already gone by the time we get here, and an integration's bad day * is not a reason to report a failed purge to the caller. * * @param string $hook Hook name to emit. * @param array $context Bounded context, see above. */ private static function dispatch_purge_event( string $hook, array $context ): void { if ( ! function_exists( 'do_action' ) ) { return; } $target = $hook . '|' . ( isset( $context['url'] ) ? (string) $context['url'] : '' ) . '|' . ( isset( $context['host'] ) ? (string) $context['host'] : '' ); if ( isset( self::$purge_events_in_flight[ $target ] ) ) { return; } self::$purge_events_in_flight[ $target ] = true; ++self::$purge_event_sequence; // Our own integrations get their own try. Sharing one with the public // action below meant a listener on the extension seam could throw and // take the contract event down with it — the mirror of the failure // this separation exists to prevent. try { // Built-in server-cache integrations run FIRST, and by a direct // call rather than as listeners on the action below. // // WordPress stops dispatching an action's remaining callbacks when // one of them throws. As a listener, our LiteSpeed forwarding // would then be skipped by any unrelated third-party callback that // happened to be registered earlier and blew up — and the visible // result is the worst kind: xSpeed reports a successful purge while // the server keeps serving stale HTML. Shipped behaviour must not // be hostage to a listener's bug. self::forward_to_server_caches( $context ); } catch ( \Throwable $e ) { self::log_purge_listener_error( $hook, $e ); } try { self::do_action_isolated( $hook, $context ); } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch // Swallow: see docblock. The purge succeeded regardless. self::log_purge_listener_error( $hook, $e ); } finally { unset( self::$purge_events_in_flight[ $target ] ); } } /** * Run every listener on a purge hook, isolating each from the others. * * `do_action()` dispatches callbacks in one loop, so the first one to * throw takes every LATER listener down with it. On a purge that meant a * failing CDN integration silently cancelled the ones queued behind it — * and because the throw was swallowed to keep the purge itself succeeding, * the user was told the clear worked while two edges were never touched. * Invisible unless WP_DEBUG happened to be on. (QA #348) * * Each callback gets its own try/catch here, so one integration's bad day * costs only that integration. Priority order is preserved. Falls back to * a plain `do_action()` when the filter registry is not the shape we * expect, so an unusual environment degrades to the old behaviour rather * than skipping listeners entirely. * * @param string $hook Hook name to emit. * @param mixed $arg Single argument passed to each listener. */ public static function do_action_isolated( string $hook, $arg ): void { global $wp_filter; // Walking $wp_filter by hand and calling each callback directly was the // obvious way to do this, and it was wrong: it bypasses WordPress, so // `current_filter()` came back empty, `did_action()` stayed at 0, the // `all` hook never fired, and Query Monitor and Debug Bar could not see // the very contract this class publishes. A shared handler branching on // current_filter() picked the wrong branch. (QA #348 round 2, issue 3) // // So let do_action() dispatch — WordPress keeps its bookkeeping — and // isolate one level down instead: each registered callback is swapped // for a wrapper that runs it inside a try/catch. One listener throwing // then costs only that listener, which is the whole point, without // costing the hook its identity. if ( ! isset( $wp_filter[ $hook ] ) || ! ( $wp_filter[ $hook ] instanceof \WP_Hook ) ) { do_action( $hook, $arg ); return; } $hook_object = $wp_filter[ $hook ]; $original = $hook_object->callbacks; if ( ! is_array( $original ) || array() === $original ) { do_action( $hook, $arg ); return; } $wrapped = array(); $restorations = array(); foreach ( $original as $priority => $group ) { if ( ! is_array( $group ) ) { $wrapped[ $priority ] = $group; continue; } foreach ( $group as $id => $registered ) { if ( ! isset( $registered['function'] ) || ! is_callable( $registered['function'] ) ) { $wrapped[ $priority ][ $id ] = $registered; continue; } $callback = $registered['function']; $wrapper = static function ( ...$args ) use ( $callback, $hook ) { try { return $callback( ...$args ); } catch ( \Throwable $e ) { self::log_purge_listener_error( $hook, $e ); return null; } }; $wrapped[ $priority ][ $id ] = array( // Keep accepted_args: a listener registered for 0 or 1 // arguments must still be called the way it asked. 'accepted_args' => $registered['accepted_args'] ?? 1, 'function' => $wrapper, ); $restorations[ $priority ][ $id ] = array( 'original' => $registered, 'wrapper' => $wrapper, ); } } $hook_object->callbacks = $wrapped; try { do_action( $hook, $arg ); } finally { // Restore only wrappers still present. Native add/remove operations // performed by listeners must survive this temporary substitution. foreach ( $restorations as $priority => $group ) { foreach ( $group as $id => $restore ) { $current = $hook_object->callbacks[ $priority ][ $id ]['function'] ?? null; if ( $current === $restore['wrapper'] ) { $hook_object->callbacks[ $priority ][ $id ] = $restore['original']; } } } } } /** * Name a listener that threw, under WP_DEBUG only. * * Gated like the rest of Free's diagnostics: a third-party listener * throwing on every purge must not fill a production log. */ private static function log_purge_listener_error( string $hook, \Throwable $e ): void { // An \Error — a TypeError from one of OUR listeners, say — is a bug // rather than a runtime condition a third party imposed on us, and // swallowing it silently in production turns it into a purge that // quietly stops working. Those are logged whatever WP_DEBUG says; // third-party \Exceptions stay gated so a noisy integration cannot // fill a production log. $always = $e instanceof \Error; if ( ( $always || ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ) && function_exists( 'error_log' ) ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- names a third-party listener that threw during a purge. error_log( '[xspeed] a ' . $hook . ' listener threw: ' . $e->getMessage() ); } } /** Test seam: clear the in-flight set left behind by an aborted dispatch. */ public static function reset_purge_events(): void { self::$purge_events_in_flight = array(); self::$purge_event_sequence = 0; } /** * Hand the purge to the caches we ship integrations for. * * Isolated from the public action on purpose — see dispatch_purge_event(). * Guarded so a missing class (a partial upgrade, a stripped build) cannot * turn a working purge into a fatal. * * @param array $context Bounded purge context. */ private static function forward_to_server_caches( array $context ): void { if ( class_exists( __NAMESPACE__ . '\\Server_Caches' ) ) { Server_Caches::forward( $context ); } } /** * `host[:port]` for a cache key, from a parsed URL. * * The port is kept, because `cache_key()` hashes the raw `HTTP_HOST` and * that carries `:8080` on any install not served from 80/443 — dropping it * computed a different md5, found no file, and reported "already cold" * while the page kept serving HIT. * * A port that is the DEFAULT for the scheme is dropped, though, because * `HTTP_HOST` does not carry one: a browser sends `Host: site.com` for * `https://site.com:443/`. Keeping it hashed `site.com:443` against a file * stored under `site.com` — the same silent no-op in the other direction, * and the one QA hit passing a canonical URL with the port spelled out. * (QA #348) * * @param array $parts Output of wp_parse_url(). */ private static function host_port_of( array $parts ): string { if ( ! isset( $parts['host'] ) ) { return ''; } $host = strtolower( (string) $parts['host'] ); if ( '' === $host || ! isset( $parts['port'] ) ) { return $host; } $port = (int) $parts['port']; $scheme = isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : ''; if ( ( 'https' === $scheme && 443 === $port ) || ( 'http' === $scheme && 80 === $port ) ) { return $host; } return $host . ':' . $port; } public static function purge_url( string $url, string $cause = 'manual' ): int { // A URL that names nothing is not a purge of everything. An empty or // blank string used to fall through to the home_url() default below // and clear the HOMEPAGE — so a third party calling // `purge_url( get_permalink( $id ) )` on a post whose permalink came // back empty silently purged the front page instead of nothing. The // CLI and the MCP tool reject empties before reaching this, so only // direct API callers were exposed, but they are exactly the audience // this public contract is for. (QA #348) if ( '' === trim( $url ) ) { return 0; } $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; } // Absolute URLs are accepted only for HTTP response caches. Schemes such // as ftp:, file: and javascript: can parse cleanly but do not name a page // xSpeed or a server response cache can invalidate. A leading-slash path // remains a supported site-relative target. if ( isset( $parts['scheme'] ) && ! in_array( strtolower( (string) $parts['scheme'] ), array( 'http', 'https' ), true ) ) { return 0; } if ( isset( $parts['scheme'] ) && empty( $parts['host'] ) ) { return 0; } // Reject a string that parsed but is not a URL we can act on: no // scheme AND no host AND no leading-slash path means something like // `ht!tp://[[[` or a bare word, which parse_url() hands back as a // relative "path". Forwarding that produced `purge_url(/ht!tp://[[[)` // — a nonsense tag sent to LiteSpeed for every malformed call. if ( ! isset( $parts['scheme'] ) && ! isset( $parts['host'] ) ) { $raw = isset( $parts['path'] ) ? (string) $parts['path'] : ''; if ( '' === $raw || '/' !== $raw[0] ) { return 0; } } // Keep the port. `cache_key()` hashes the raw `HTTP_HOST`, which // carries `:8080` on any install not served from 80/443 — while // parse_url() splits the port into its own component, so a purge that // used the bare host computed a different md5, found no file, and // reported "already cold". A silent no-op: the page kept serving HIT // until its TTL ran out. Intranet installs, panel hosts on :8443 and // proxies that forward `Host: site.com:8080` all hit this. // A scheme-less `site.test:443/page/` is a supported explicit-host // target. Infer a scheme only when it names THIS site's hostname: then // its explicit default port is the same origin and the same local cache // key. Never apply this to another host or to a non-default port. if ( ! isset( $parts['scheme'] ) && isset( $parts['host'], $parts['port'] ) && 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. if ( is_array( $home ) && ! empty( $home['host'] ) && ! empty( $home['scheme'] ) && strtolower( (string) $home['host'] ) === strtolower( (string) $parts['host'] ) ) { $home_scheme = strtolower( (string) $home['scheme'] ); $port = (int) $parts['port']; $home_port = isset( $home['port'] ) ? (int) $home['port'] : ( 'https' === $home_scheme ? 443 : ( 'http' === $home_scheme ? 80 : 0 ) ); if ( $home_port === $port && ( ( 'https' === $home_scheme && 443 === $port ) || ( 'http' === $home_scheme && 80 === $port ) ) ) { $parts['scheme'] = $home_scheme; } } } $host = self::host_port_of( $parts ); 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. if ( is_array( $home ) ) { $host = self::host_port_of( $home ); } } 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 ); /* * Entries live under the bucket they were written for, and this URL's * site may not be the one serving THIS request (a cross-site purge on * multisite, WP-CLI, or cron). Build the directory from the URL's own * host AND path. (#6) * * Host alone is wrong on a subdirectory network: `store()` wrote to * `//`, so looking in `/` found nothing and the * call reported "already cold" while the page kept serving HIT — a * false success, which is worse than an error. The prefix has to come * from the URL being purged rather than from the current blog, because * the caller is usually purging some OTHER site. (QA B2 on #166) */ $base = XSPEED_CACHE_DIR . '/' . self::bucket_for_url( $host, $path ); $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 = $base . '/' . $key . '.html'; if ( is_file( $file ) ) { wp_delete_file( $file ); ++$count; } foreach ( array( $base . '/' . $key . '.meta', $file . '.br', self::brotli_size_sidecar( $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' ) ) { // Same transform the write used — `localhost:8080` files under // `localhost8080`, so the bare host found nothing here either. $dir = rtrim( XSPEED_CACHE_STATIC_DIR, '/' ) . '/' . self::static_host_dir( $host ) . ( '/' === $path ? '' : rtrim( $path, '/' ) ); $file = $dir . '/index.html'; if ( is_file( $file ) ) { wp_delete_file( $file ); ++$count; } foreach ( array( $file . '.br', self::brotli_size_sidecar( $file . '.br' ) ) as $sidecar ) { if ( is_file( $sidecar ) ) { wp_delete_file( $sidecar ); } } } 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 ); } /** * Fires after one URL's cached copy has been purged. * * The single-URL counterpart to `xspeed_after_purge_all`. Subscribe * here to invalidate a cache xSpeed does not own — a server-level * cache such as LiteSpeed's LSCache, a reverse proxy, or a CDN — for * the same URL. * * Only fires when the purge actually ran. A malformed URL, a URL with * no resolvable host, or a traversal attempt returns earlier and * publishes nothing, so a listener can treat this as "xSpeed purged * this URL" rather than "xSpeed was asked to". `removed` may legitimately * be 0: the URL was not in xSpeed's cache, which says nothing about * whether it is in yours. * * Fires at most once per purge. A listener that calls back into * xSpeed's purge API will not re-enter this event. * * @since 1.2.3 * * @param array $context { * Bounded description of the purge. URL queries and caller-supplied * causes can contain sensitive values and are not logging fields. * * @type string $url Canonical scheme://host/path[?query] of the purged URL. * The query is preserved because caches in front * commonly key on it; xSpeed's own sweep is * path-based, so `removed` describes that. * @type string $host Host (with port when non-standard). * @type string $path Path component, leading slash. * @type string $cause Short label for who asked. See purge_all(). * @type int $removed Number of cache files removed. * @type string $scope Actionable adapter scope: `urls`. * @type string $intent Why responses changed: `content`. * @type string[] $urls Exact response URLs to invalidate. * } */ $canonical_url = self::canonical_purge_url( $host, $path, isset( $parts['query'] ) ? (string) $parts['query'] : '', isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : '' ); self::dispatch_purge_event( 'xspeed_after_purge_url', array( 'url' => $canonical_url, 'host' => $host, 'path' => $path, 'cause' => $cause, 'removed' => $count, 'scope' => 'urls', 'intent' => 'content', 'urls' => array( $canonical_url ), ) ); return $count; } /** Host this site's purge is scoped to, for the purge-event context. */ private static function current_purge_host(): string { if ( ! function_exists( 'home_url' ) ) { return ''; } $home = function_exists( 'wp_parse_url' ) ? wp_parse_url( home_url( '/' ) ) : parse_url( home_url( '/' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- host only. if ( ! is_array( $home ) || empty( $home['host'] ) ) { return ''; } // Same default-port normalisation as purge_url(): a site whose // home_url() carries `:443` (normal behind a proxy) otherwise stamps // every full-purge event with a host that matches none of its own // URLs, so the LiteSpeed forward stood down site-wide. (QA #348) return self::host_port_of( $home ); } /** * Rebuild the canonical URL a purge applied to. * * Built from the parts the purge itself used, so a listener is told the * URL we acted on rather than the string the caller happened to pass — * those differ whenever the caller supplied a site-relative path, a * different scheme, or a query string the cache key ignores. */ private static function canonical_purge_url( string $host, string $path, string $query = '', string $url_scheme = '' ): string { // The purged URL's own scheme wins. purge_url() explicitly supports // cross-site purges (multisite, WP-CLI, cron), where composing the // current site's scheme onto another site's host builds a URL that was // never served — and a CDN listener then purges the wrong key and // reports success. if ( '' !== $url_scheme ) { return $url_scheme . '://' . $host . $path . ( '' !== $query ? '?' . $query : '' ); } $scheme = function_exists( 'is_ssl' ) && is_ssl() ? 'https' : 'http'; if ( 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 -- scheme only. if ( is_array( $home ) && ! empty( $home['scheme'] ) ) { $scheme = (string) $home['scheme']; } } // The query is carried even though OUR sweep above is path-based. // Caches in front commonly key on the full request line — LiteSpeed // tags `/shop/?page=2` separately from `/shop/` — so publishing the // bare path would have a listener confidently purge the wrong entry // and report success. Telling it exactly what was asked for lets it // act correctly; `removed` still describes only what WE removed. return $scheme . '://' . $host . $path . ( '' !== $query ? '?' . $query : '' ); } /** * Sweep this site's cache files. * * On multisite every blog shares one cache directory, so an unscoped * sweep here took the whole network cold — one subsite's settings save * or post publish rebuilt every other site from PHP. Entries are stored * per host (see host_dir()), and the sweep is scoped to match, so a * purge originating on site-a leaves site-b's cache warm. (#6) * * Clears the files only: the flat tree, the static tree, the REST * responses and the minified assets. The object-cache flush, the stats * update, `xspeed_after_purge_all`, the `xspeed_after_purge` contract * event and the log entry live in purge_all(), which is still the entry * point for every existing caller. Split out so `wp xspeed purge` can * report the local sweep as one line item and the object cache as * another, each with its own status — see Purge_Runner. * * @param string|null $host Host to purge. Defaults to the current site. * Pass '*' to sweep the ENTIRE tree — network * admin's "purge all sites", and the migration * of pre-#6 entries that sit in the tree root. * @return array{pages:int,rest:int,assets:int,bytes:int} Entries removed * per store, and the bytes freed by the two file sweeps * that measure themselves. */ public static function purge_local( ?string $host = null ): array { $network_wide = ( '*' === $host ); self::$sweep_bytes = 0; // The flat tree buckets by a flattened segment (host/a-b) while the // static tree mirrors the URL (host/a/b), so they need separate // scopes — see current_host_dir() vs current_static_scope(). $static_scope = ''; if ( null === $host || $network_wide ) { $scope = $network_wide ? '' : self::current_host_dir(); $static_scope = $network_wide ? '' : self::current_static_scope(); } else { $dir = self::host_dir( $host ); $scope = '' === $dir ? 'default' : $dir; $static_dir = self::static_host_dir( $host ); $static_scope = '' === $static_dir ? 'default' : $static_dir; } $count = 0; if ( is_dir( XSPEED_CACHE_DIR ) ) { // Scoped to one host directory, or the whole tree (including the // legacy top-level entries written before #6) when network-wide. /* * Network-wide sweeps go TWO levels deep, not one. A subdirectory * subsite's bucket is `//`, so globbing only * `/*` reached the main site and left every subsite's * entries in place. (QA D5 on #166) * * A scoped purge also has to cover its own nested buckets: when * the main blog of a subdirectory network purges, `/` is its * bucket and `/one/` belongs to another blog — so the scoped * branch deliberately does NOT descend, which is what keeps * site-level purges isolated. */ $roots = $network_wide ? array_merge( array( XSPEED_CACHE_DIR ), array_filter( (array) glob( XSPEED_CACHE_DIR . '/*', GLOB_ONLYDIR ) ), array_filter( (array) glob( XSPEED_CACHE_DIR . '/*/*', GLOB_ONLYDIR ) ) ) : array( XSPEED_CACHE_DIR . '/' . $scope ); foreach ( $roots as $root ) { /* * min/ and rest/ are swept by their own purgers below; never * treat them as host buckets. * * Checked on every path SEGMENT, not just the basename: now * that the network-wide glob descends two levels it can reach * `min/combined`, whose basename is `combined` and would sail * past a basename-only test — deleting the combined * stylesheets out from under the pages that link them. */ if ( ! $network_wide || XSPEED_CACHE_DIR !== $root ) { $relative = trim( str_replace( XSPEED_CACHE_DIR, '', (string) $root ), '/' ); $segments = '' === $relative ? array() : explode( '/', $relative ); if ( array_intersect( $segments, array( 'min', 'rest' ) ) ) { continue; } } if ( ! is_dir( $root ) ) { continue; } $files = glob( $root . '/*.html' ); if ( $files ) { $count += count( $files ); foreach ( $files as $f ) { self::sweep_delete( $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( $root . '/*.meta' ); if ( $meta ) { foreach ( $meta as $m ) { self::sweep_delete( $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( $root . '/*.br' ); if ( $br ) { foreach ( $br as $b ) { self::sweep_delete( $b ); } } // `*.br` does not match `*.br.size` — same reason as the flat-root // sweep above: a size record outliving its body would later be // read against a different sibling's bytes. $br_size = glob( $root . '/*.br.size' ); if ( $br_size ) { foreach ( $br_size as $b ) { self::sweep_delete( $b ); } } } } // Static-cache tree purge — recursive because the layout is // xspeed-static/{host}/{path}/index.html, so a flat glob can't // reach everything. Already host-segmented, so scoping is just a // matter of starting one level down. if ( is_dir( XSPEED_CACHE_STATIC_DIR ) ) { $static_root = $network_wide ? XSPEED_CACHE_STATIC_DIR : XSPEED_CACHE_STATIC_DIR . '/' . $static_scope; if ( is_dir( $static_root ) ) { $count += self::rmtree_html( $static_root ); } } // REST response cache (cache/xspeed/rest/*.json) — same purge // triggers (publish, settings change) invalidate it too. $rest = Rest_Cache::purge(); $count += $rest; // 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) $assets = class_exists( '\\XSpeed\\Minifier' ) ? Minifier::purge_minified() : 0; return array( 'pages' => $count - $rest, 'rest' => $rest, 'assets' => $assets, 'bytes' => self::$sweep_bytes, ); } /** * Flush the persistent object cache (Redis / Memcached). * * Runs regardless of whether the Object Cache module is currently * enabled — a drop-in installed earlier keeps serving until flushed. * * @param bool $network_wide Flush every blog's entries. wp_cache_flush() * is NETWORK-global, so on multisite the * default prefers the blog-scoped group flush * (WP 6.1+) — otherwise one site's purge drops * every other site's object cache, the same bug * #6 fixed for the page cache. * @return bool Whether a flush was actually performed. */ public static function flush_object_cache( bool $network_wide = false ): bool { if ( ! $network_wide && is_multisite() && function_exists( 'wp_cache_flush_group' ) && function_exists( 'wp_cache_supports' ) && wp_cache_supports( 'flush_group' ) ) { // Blog-scoped groups only; a shared/global group (site options, // user meta) is intentionally left alone. foreach ( array( 'options', 'posts', 'terms', 'post_meta', 'comment' ) as $group ) { wp_cache_flush_group( $group ); } return true; } if ( function_exists( 'wp_cache_flush' ) ) { return (bool) wp_cache_flush(); } return false; } /** * Purge this site's cache: the local sweep, then the object cache, then * the bookkeeping every caller expects (stats, `xspeed_after_purge_all`, * inventory invalidation, purge log). * * @param string $cause Who asked, for the purge log. * @param string|null $host See purge_local(). * @param array $invalidation Public adapter policy. `scope` * is urls/site/network/none, * `intent` explains why, and * `urls` supplies exact targets. * @return int Page + REST entries removed. */ public static function purge_all( string $cause = 'manual', ?string $host = null, array $invalidation = array() ) { $network_wide = ( '*' === $host ); $adapter_scope = isset( $invalidation['scope'] ) && is_string( $invalidation['scope'] ) ? $invalidation['scope'] : ( $network_wide ? 'network' : 'site' ); if ( ! in_array( $adapter_scope, array( 'urls', 'site', 'network', 'none' ), true ) ) { $adapter_scope = $network_wide ? 'network' : 'site'; } if ( $network_wide ) { $adapter_scope = 'network'; } $intent = isset( $invalidation['intent'] ) && is_string( $invalidation['intent'] ) && '' !== $invalidation['intent'] ? $invalidation['intent'] : 'complete'; $urls = isset( $invalidation['urls'] ) && is_array( $invalidation['urls'] ) ? array_values( array_unique( array_filter( $invalidation['urls'], 'is_string' ) ) ) : array(); // This method always sweeps a complete local bucket. A narrower adapter // announcement would claim unrelated local pages stayed warm when they // did not, leaving their server copies stale. Until purge_all() gains // dependency-aware local deletion, its response scope cannot be `urls`. if ( 'urls' === $adapter_scope ) { $adapter_scope = $network_wide ? 'network' : 'site'; } if ( 'site' === $adapter_scope || 'network' === $adapter_scope || 'none' === $adapter_scope ) { $urls = array(); } $removed = self::purge_local( $host ); $count = $removed['pages'] + $removed['rest']; self::flush_object_cache( $network_wide ); 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) // Wrapped: this action predates the purge-event contract and has its // own third-party listeners. One of them throwing used to abort // purge_all() here, which now also means the contract event below // never fires and a server cache keeps serving stale HTML. The local // sweep is already done by this point, so swallowing is strictly safer // than letting a listener decide the rest of the method runs. try { // Isolated per listener: one throwing used to cancel every // listener queued behind it — Critical CSS, Unused CSS and the // Cloudflare edge purge all hang off this hook. (QA #348) self::do_action_isolated( 'xspeed_after_purge_all', $cause ); } catch ( \Throwable $e ) { self::log_purge_listener_error( 'xspeed_after_purge_all', $e ); } /** * Fires after a full purge, with the same bounded context shape as * `xspeed_after_purge_url`. * * Distinct from `xspeed_after_purge_all` on purpose. That action is * the long-standing internal signal — it passes a bare `$cause` string * and Free's own modules use it for local bookkeeping. This one is the * documented contract for OUTSIDE integrations: same argument shape as * the per-URL event, so a server-cache or CDN adapter can subscribe to * both with one handler and branch on a null `url`. * * Fires at most once per purge, and not at all when a listener's own * purge re-enters xSpeed. * * @since 1.2.3 * * @param array $context { * @type null $url Always null — a full purge has no single URL. * @type string $host Host swept, or '*' for the entire tree. * @type null $path Always null. * @type string $cause Short label for who asked. * @type int $removed Number of cache files removed. * @type string $scope Adapter action: urls/site/network/none. * @type string $intent content/presentation/complete or a caller-defined intent. * @type string[] $urls Exact targets when scope is urls. * } */ self::dispatch_purge_event( 'xspeed_after_purge', array( 'url' => null, 'host' => null === $host ? self::current_purge_host() : (string) $host, 'path' => null, 'cause' => $cause, 'removed' => $count, 'scope' => $adapter_scope, 'intent' => $intent, 'urls' => $urls, ) ); // 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; } /** * Purge everything after a plugin / theme / core update completes. * * Bound to `upgrader_process_complete`, which is the only hook an update * fires — no activation hook runs, so without this the cached HTML (and * the asset URLs baked into it) outlives the code that produced it. * * Runs for plugin, theme and core updates alike, including bulk runs and * auto-updates, and purges the WHOLE network rather than the current * site — see the call below. Translation updates are skipped: they * change no markup a cached page depends on, and language packs update * often enough that purging on them would keep a multilingual site * permanently cold. * * Note this cannot be folded into the `$invalidate_hooks` loop above: * that binds `purge_all` directly, and `purge_all( string $cause )` would * then receive the WP_Upgrader instance as its cause. * * @param mixed $upgrader WP_Upgrader instance (unused). * @param array $hook_extra Context for the completed operation. * @return void */ public static function purge_after_upgrade( $upgrader = null, $hook_extra = array() ) { $cleared = self::$upgrade_cleared_destination; if ( ! self::upgrade_produced_something( $upgrader ) ) { return; } if ( ! self::upgrade_should_purge( is_array( $hook_extra ) ? $hook_extra : array(), $cleared ) ) { return; } self::purge_for_upgrade(); } /** * Whether this request's upgrader removed an existing copy. * * @var bool */ private static $upgrade_cleared_destination = false; /** * How many `upgrader_process_complete` dispatches are on the stack. * * @var int */ private static $upgrade_dispatch_depth = 0; /** * Enter an `upgrader_process_complete` dispatch. * * Bound at PHP_INT_MIN, so it runs before any listener that might read * the replacement signal. Public because it is a hook target. * * @return void */ public static function note_upgrade_dispatch(): void { ++self::$upgrade_dispatch_depth; } /** * Drop the replacement signal once every listener has read it. * * Bound at PHP_INT_MAX so a second upgrade in the same request starts * clean, without taking the answer away from the add-on callbacks that * run at the same priority as ours. * * Only the OUTERMOST dispatch clears it. A nested run — core's language * pack upgrader, or any add-on that installs something from this hook — * fires the action again, and clearing there would answer for a run that * has not finished. Called directly (no dispatch on the stack) it still * clears, which is what a test wants. * * Known limit: a nested run INHERITS the outer run's signal, because the * only evidence we get is a filter that fires before the nested dispatch * begins and carries no upgrader identity. So a fresh install performed * from inside a replacement run reads as a replacement and purges once * more than it needs to. A cold cache is the cheap direction, and the * alternative — scoping the signal per upgrader — is not knowable from * `upgrader_clear_destination`. * * @return void */ public static function forget_cleared_destination(): void { if ( self::$upgrade_dispatch_depth > 0 ) { --self::$upgrade_dispatch_depth; } if ( 0 === self::$upgrade_dispatch_depth ) { self::$upgrade_cleared_destination = false; } } /** * Record that the upgrader cleared an existing destination. * * A pass-through listener on `upgrader_clear_destination`: WordPress only * fires it when `clear_destination` was set AND something was there to * remove, which is the one signal that separates an upload-and-replace * from a first-time install. The filtered value is returned untouched. * * @param true|\WP_Error $removed Whether the destination was cleared. * @return true|\WP_Error */ public static function note_cleared_destination( $removed ) { if ( ! is_wp_error( $removed ) ) { self::$upgrade_cleared_destination = true; } return $removed; } /** * Did the completed run actually replace anything? * * `upgrader_process_complete` fires whether the run succeeded or failed — * the failure branch in WP_Upgrader::run() only feeds the skin before the * action fires. A run that installed nothing changed no markup, so purging * for it is a cold cache bought for nothing. * * Deliberately conservative: this returns false ONLY when every result we * can see is an error. An upgrader we cannot read, a mixed bulk run, or a * missing result all fall through to purging, which is the safe direction * everywhere else in this handler. * * @param mixed $upgrader WP_Upgrader instance, or anything else. * @return bool */ public static function upgrade_produced_something( $upgrader ): bool { if ( ! is_object( $upgrader ) ) { return true; } // A bulk run collects one entry per item; `result` alone would only // describe the last of them. if ( isset( $upgrader->results ) && is_array( $upgrader->results ) && ! empty( $upgrader->results ) ) { foreach ( $upgrader->results as $result ) { if ( ! is_wp_error( $result ) && ! empty( $result ) ) { return true; } } return false; } if ( ! property_exists( $upgrader, 'result' ) ) { return true; } return ! is_wp_error( $upgrader->result ) && ! empty( $upgrader->result ); } /** * Decide whether a completed operation invalidates the cache. * * Split out from the handler so the decision is testable on its own: * purge_all() reaches straight for glob() and unlink(), which a unit test * cannot observe honestly, while every rule that matters lives here. * * @param array $hook_extra Context for the completed operation. * @return bool */ public static function upgrade_should_purge( array $hook_extra, bool $destination_cleared = false ): bool { if ( ! self::upgrade_replaced_code( $hook_extra, $destination_cleared ) ) { return false; } // An update to xSpeed ITSELF always purges, whatever the setting says. // This plugin's own code is what rendered every cached page — the // minifier, lazy-loader, resource hints and CDN rewriter all changed // underneath it — so serving that HTML after an update means serving // output from a version that no longer exists. Minified assets make it // concrete rather than theoretical: their filenames are keyed on the // source filemtime, so they regenerate under NEW hashes while the // cached pages still link the old ones, and the page requests files // that are no longer on disk. Offering an opt-out for that would be // offering a broken site. return self::upgrade_touches_xspeed( $hook_extra ) || self::purge_on_upgrade_enabled(); } /** * Did this completed run replace code that renders pages? * * The half of the decision that has nothing to do with our settings: it * asks only whether live code changed underneath the output we cached. * Add-ons that keep their own derived artifacts — generated CSS, captured * selectors, fingerprints — need the same answer and must not have to * rebuild these rules, or they drift apart. Call it with the hook's own * `$hook_extra`; the upload-and-replace signal is read from this request. * * Deliberately independent of the "Purge After Updates" setting. That * setting governs the page cache, not whether an add-on's derived data is * still valid. * * @param array $hook_extra Context for the completed operation. * @param bool|null $destination_cleared Override the recorded signal; null reads this request's. * @return bool */ public static function upgrade_replaced_code( array $hook_extra, ?bool $destination_cleared = null ): bool { $cleared = null === $destination_cleared ? self::$upgrade_cleared_destination : $destination_cleared; $type = isset( $hook_extra['type'] ) ? (string) $hook_extra['type'] : ''; $action = isset( $hook_extra['action'] ) ? (string) $hook_extra['action'] : ''; // `upgrader_process_complete` fires for INSTALLS as well as updates. // A freshly installed plugin is inactive and a freshly installed theme // is not the active one, so neither can change a single rendered page // — but the first cut of this handler purged the whole tree anyway, so // evaluating three plugins in a row emptied the cache three times. // // 'install' alone is NOT enough to skip on, because WordPress reports // an upload-and-replace as an install: `Plugin_Upgrader::install()` // hardcodes `action => install` and `overwrite_package` does not change // it, so "Replace current with uploaded" and `wp plugin install // --force` both arrive here labelled install while genuinely replacing // live code. That is how a plugin distributed as a zip is updated, and // skipping it put back the stale markup this handler exists to clear. // // The distinguishing signal is whether the destination was cleared: // WP_Upgrader only fires `upgrader_clear_destination` when it removed // something that was already there. Installing beside nothing does not. if ( 'install' === $action && ! $cleared ) { return false; } // 'translation' is the one update type that cannot change rendered // markup. Anything else — including an empty type from a custom // updater — is treated as cache-invalidating, because guessing wrong // in that direction only costs a cold cache. if ( 'translation' === $type ) { return false; } return true; } /** * Purge everything an update can invalidate. * * Network-wide ('*'), not the calling site's bucket. A plugin, theme or * core update replaces code shared by EVERY site on the network, so a * scoped purge would clear the site that happened to run the updater and * leave every other subsite serving pre-update HTML for the whole TTL — * the very bug this handler exists to fix, one level down. On single-site * this is identical to the scoped call, since there is only ever one * bucket. * * @return void */ private static function purge_for_upgrade(): void { self::purge_all( 'upgrade', '*' ); Minifier::purge_minified(); } /** * Purge after an unattended background update run. * * `automatic_updates_complete` passes ONE argument, and it is not a * hook_extra: it is WordPress's results array, keyed by what was updated * ('core', 'plugin', 'theme', 'translation'). Handing it to * purge_after_upgrade() put it in the unused $upgrader slot and left the * type empty, so a night on which only a language pack updated purged * every cached page — the exact case the translation exemption exists to * prevent, and WordPress auto-updates language packs by default. * * @param array $results Update results, keyed by type. * @return void */ public static function purge_after_auto_updates( $results = array() ): void { if ( ! self::auto_updates_should_purge( is_array( $results ) ? $results : array() ) ) { return; } self::purge_for_upgrade(); } /** * Decide whether a background update run invalidates the cache. * * @param array $results Update results, keyed by type. * @return bool */ public static function auto_updates_should_purge( array $results ): bool { // An unrecognisable payload is treated as invalidating, the same // direction every other unknown takes here. if ( empty( $results ) ) { return true; } // Failed items are listed alongside successful ones — WP_Automatic_Updater // appends an entry whatever the outcome — and a night on which every // update failed replaced no code, so it invalidates nothing. $updated = array(); foreach ( $results as $type => $items ) { if ( ! is_array( $items ) ) { continue; } foreach ( $items as $item ) { $result = is_object( $item ) && isset( $item->result ) ? $item->result : true; if ( ! is_wp_error( $result ) && ! empty( $result ) ) { $updated[] = (string) $type; break; } } } if ( empty( $updated ) ) { return false; } // Nothing but language packs: a language pack changes no markup a // cached page depends on, and purging on one would keep a multilingual // site permanently cold. if ( array( 'translation' ) === array_values( array_unique( $updated ) ) ) { return false; } return self::auto_updates_touch_xspeed( $results ) || self::purge_on_upgrade_enabled(); } /** * Does a background run include one of our own plugins? * * Same rule as a foreground self-update, read out of the results array's * shape instead of a hook_extra: each plugin entry carries the update * object on `->item->plugin`. * * @param array $results Update results, keyed by type. * @return bool */ private static function auto_updates_touch_xspeed( array $results ): bool { if ( empty( $results['plugin'] ) || ! is_array( $results['plugin'] ) ) { return false; } $ours = self::self_update_plugins(); foreach ( $results['plugin'] as $entry ) { $item = is_object( $entry ) && isset( $entry->item ) ? $entry->item : null; $file = is_object( $item ) && isset( $item->plugin ) ? (string) $item->plugin : ''; if ( '' !== $file && in_array( $file, $ours, true ) ) { return true; } } return false; } /** * Is the "Purge After Updates" setting on? * * Gates THIRD-PARTY updates only — an xSpeed self-update ignores it, see * purge_after_upgrade(). Defaults to true when the option has never been * written, matching the schema default in CacheModule: an unset value on * an existing install must not read as "the user turned this off". * * Unlike LiteSpeed, which ships the equivalent toggle OFF, this defaults * ON — a cold cache costs one slow request, whereas stale HTML is a wrong * page for up to the full TTL and the site owner has no way to tell why. * * @return bool */ private static function purge_on_upgrade_enabled(): bool { $opts = Settings_Manager::get( 'cache' ); return ! array_key_exists( 'purge_on_upgrade', $opts ) || ! empty( $opts['purge_on_upgrade'] ); } /** * Does this completed update include xSpeed itself? * * Mirrors the payload shapes Plugin::maybe_restore_after_update() reads: * a single update carries 'plugin', a bulk run carries 'plugins'. * * @param array $hook_extra Context for the completed operation. * @return bool */ private static function upgrade_touches_xspeed( array $hook_extra ): bool { if ( ! isset( $hook_extra['type'] ) || 'plugin' !== $hook_extra['type'] ) { return false; } $updated = array(); if ( isset( $hook_extra['plugins'] ) && is_array( $hook_extra['plugins'] ) ) { // Strings only: array_intersect() stringifies what it is given, so // an object without __toString in a custom updater's payload would // be a fatal rather than a miss. $updated = array_filter( $hook_extra['plugins'], 'is_string' ); } elseif ( isset( $hook_extra['plugin'] ) && is_string( $hook_extra['plugin'] ) ) { $updated = array( $hook_extra['plugin'] ); } return (bool) array_intersect( self::self_update_plugins(), $updated ); } /** * Plugin files whose update counts as an update to us. * * The self-update rule is "our own code rendered this cached HTML, so it * must not survive the code being replaced". That is true of any add-on * that writes into the same page: an add-on inlines critical CSS, rewrites * stylesheet links and image URLs, and produces the compressed and static * copies, so its update leaves exactly the stale markup this rule exists * to clear. Free cannot name an add-on, so it asks instead. * * Filter: xspeed_self_update_plugins * * Add-ons add their own `plugin_basename( __FILE__ )`. Entries are matched * against the plugin files WordPress reports for the completed update, so * a value that is not a `dir/file.php` basename simply never matches. * * @param string[] $plugins Plugin basenames treated as our own. * @return string[] */ private static function self_update_plugins(): array { $ours = array( plugin_basename( XSPEED_FILE ) ); /** This filter is documented above. */ $filtered = apply_filters( 'xspeed_self_update_plugins', $ours ); // Our own file is merged back afterwards rather than trusted to survive // the round trip. A listener that returns null, a bare string, or a // list it built from scratch would otherwise drop it, and the plugin // would quietly stop exempting its OWN update from the setting — a // failure no add-on author would think to test for. $claimed = array_filter( is_array( $filtered ) ? $filtered : array(), 'is_string' ); return array_values( array_unique( array_merge( $ours, array_filter( $claimed ) ) ) ); } /** * Invalidate caches of RENDERED output owned by other plugins. * * purge_all() sweeps only what xSpeed wrote. A page builder that stores * rendered HTML or generated CSS of its own — Elementor's element cache * and `uploads/elementor/css/`, and the equivalents in Beaver / Divi / * Bricks / Oxygen — keeps whatever asset URLs were current when it was * written, and no xSpeed purge has ever reached it. * * That only matters for rewrites that happen DURING render rather than on * the finished page. Minify, combine, lazy-load and resource hints all run * on `xspeed_cache_final_html` or a `template_redirect` buffer — after the * builder has already stored its copy — so nothing they emit can leak. * The CDN module's `wp_get_attachment_url` filter is the one that can. * * Called ONLY from purges where asset URLs themselves can have changed * (a CDN settings write, an explicit Purge All). NOT from purge_all(), * which also runs on every post publish — regenerating every builder CSS * file that often would cost more than it saves, and the builder already * invalidates its own copy for the post being saved. * * @param string $cause Who asked. Threaded through to the listeners and * the activity log. * @return string[] Labels of the caches that were actually cleared. */ public static function purge_render_caches( string $cause = 'manual' ): array { /** * Clear render caches belonging to other plugins. * * A listener does its own work and appends a human-readable label for * what it cleared, so the activity log can name it. Returning * `$cleared` unchanged means "nothing of mine is installed" and is the * correct no-op — never a failure. * * Detect the owning plugin by class or constant, not by an * `is_plugin_active()` path check: a renamed plugin folder must not * silently disable the integration. * * @param string[] $cleared Labels of caches cleared so far. * @param string $cause Why the purge is happening. */ $cleared = (array) apply_filters( 'xspeed_purge_third_party_render_caches', array(), $cause ); // Labels are strings destined for the activity feed. Anything else a // third-party listener returns is dropped rather than coerced — a // stray `0` or `null` in the log reads as a cache we cleared. $cleared = array_values( array_filter( $cleared, static function ( $label ) { return is_string( $label ) && '' !== trim( $label ); } ) ); if ( ! $cleared ) { return $cleared; } // Logged separately from the page-cache purge above it. "I turned the // CDN off and the images are still wrong" is only diagnosable if the // feed says which OTHER plugin's cache was regenerated and when. Activity_Log::record( 'cache_purged', sprintf( 'Render caches cleared (%s) — %s', $cause, implode( ', ', $cleared ) ), Activity_Log::INFO ); return $cleared; } /** * 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': $count = self::purge_all( $cause ); // "Purge All" is the user saying they don't trust anything // stored anywhere — the one purge that should also reach // caches of rendered output we don't own. purge_all() itself // deliberately does NOT, because it also runs on every post // publish. (See Render_Caches.) self::purge_render_caches( $cause ); return $count; case 'page': $count = self::purge_pages(); self::update_stats( array( 'last_purge' => time() ) ); Cache_Inventory::invalidate(); self::record_partial_purge( 'page', $cause, $count ); self::announce_purge( $cause, $count ); return $count; case 'assets': if ( class_exists( '\\XSpeed\\Minifier' ) ) { Minifier::purge_minified(); } // Deleting min/ without clearing the pages that link it left // every cached page pointing at files that no longer exist. // WordPress answers the missing asset by 301-ing to its // pretty-permalink form and serving the 404 TEMPLATE as // `HTTP 200 text/html`, which the browser accepts as a // stylesheet and parses to zero rules — no console error, no // network failure, no 4xx anywhere in devtools. The pages // stayed broken for the rest of the TTL (7 days on // Aggressive, up to 30), and the admin who clicked could not // see it: they are logged in, so their own requests bypass // the page cache and re-render, regenerating the assets as a // side effect. Only anonymous visitors were served the stale // HTML. (#244) // // The assets are the pages' dependency, so invalidating them // invalidates the pages. Same invariant Cache_GC enforces // with is_referenced(): never leave a cached page pointing at // an asset that is gone. $count = self::purge_pages(); self::update_stats( array( 'last_purge' => time() ) ); Cache_Inventory::invalidate(); self::record_partial_purge( 'assets', $cause, $count ); self::announce_purge( $cause, $count ); return $count; 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 ); self::announce_purge( $cause, $count ); return $count; default: return self::purge_type_unhandled( $type, $cause ); } } /** * Delete this site's cached pages from both the flat and static trees. * * Extracted so the `assets` purge can reuse it: minified assets are a * dependency of the cached HTML, so clearing them must clear the pages * too or the pages are left referencing deleted files (#244). * * @return int Number of page entries removed. */ private static function purge_pages(): int { $count = 0; // Scoped to this site — see purge_all(). (#6) $scope = self::current_host_dir(); $flat_root = XSPEED_CACHE_DIR . '/' . $scope; if ( is_dir( $flat_root ) ) { foreach ( (array) glob( $flat_root . '/*.html' ) as $f ) { wp_delete_file( $f ); ++$count; } foreach ( (array) glob( $flat_root . '/*.meta' ) as $m ) { wp_delete_file( $m ); } foreach ( (array) glob( $flat_root . '/*.br' ) as $b ) { wp_delete_file( $b ); } // `*.br` does not match `*.br.size`; a size record outliving its // body would later be read against a DIFFERENT sibling's bytes. foreach ( (array) glob( $flat_root . '/*.br.size' ) as $b ) { wp_delete_file( $b ); } } $static_root = XSPEED_CACHE_STATIC_DIR . '/' . self::current_static_scope(); if ( is_dir( $static_root ) ) { $count += self::rmtree_html( $static_root ); } return $count; } /** * A purge type this class does not own — a Pro or third-party module * registered it via the `xspeed_purge_types` filter, so hand it off. * * @param string $type Purge-type slug. * @param string $cause Who asked. */ private static function purge_type_unhandled( string $type, string $cause ): int { $event_sequence = self::$purge_event_sequence; $hook = 'xspeed_purge_type_' . $type; $has_handler = false !== has_action( $hook ); do_action( $hook ); self::record_partial_purge( $type, $cause, null ); // Announce, same as the types this class owns. Pro's "Purge Critical // CSS" and "Purge Unused CSS" arrive here, and they change what a // cached page CONTAINS — critical CSS is inlined into the HTML, so a // server cache goes on serving pages with the old styles baked in. // Fixing the three Free buttons and leaving these two silent left the // same hole for the tier most likely to be using both plugins. // (QA #348 round 2, issue 2) // // Unknown slugs must not turn into a site-wide purge merely because no // handler exists. These are the response-changing Pro types Free knows; // third parties can declare another through the filter. A registered // handler plus this explicit response scope is the handled signal. $scope = in_array( $type, array( 'critical-css', 'unused-css' ), true ) ? 'site' : 'none'; /** * Declare whether a handled custom purge type changes cached responses. * * @since 1.2.3 * @param string $scope site/network/none. * @param string $type Purge-type slug. */ $scope = (string) apply_filters( 'xspeed_purge_type_response_scope', $scope, $type ); if ( $has_handler && $event_sequence === self::$purge_event_sequence && in_array( $scope, array( 'site', 'network' ), true ) ) { self::announce_purge( $cause, 0, $scope, 'presentation' ); } return 0; } /** * Tell the server cache that a PARTIAL purge cleared cached responses. * * "Purge Page / Static Cache", "Purge CSS / JS Cache" and "Purge REST * Cache" each delete cached RESPONSES for the whole site, so a cache in * front of PHP is now serving copies xSpeed has just thrown away. Only * "Purge All" announced itself, which left three of the four toolbar * buttons doing exactly what this contract exists to prevent: clearing * our copy while the server kept serving the stale one. The `assets` case * was the sharpest — it deletes the minified bundles too, so LiteSpeed * went on serving pages whose CSS and JS no longer exist. (QA #348) * * Sent as the full-purge shape (`url` null) because that is what happened: * every cached page for this site went, not one address. `object` is not * announced — flushing the object cache changes no rendered response a * server cache could be holding. * * Public because Purge_Runner sweeps the local files itself, through * purge_local(), rather than through purge_all() — so it has to announce * on its own behalf or `wp xspeed purge` and the dashboard button clear * our copy while LiteSpeed keeps serving the stale one. * * @param string $cause Who asked. * @param int $removed Entries removed locally. * @param string $scope Actionable adapter scope. * @param string $intent Reason rendered responses changed. */ public static function announce_purge( string $cause, int $removed, string $scope = 'site', string $intent = 'complete' ): void { // Announcing is additive: the local sweep has already happened and // succeeded. Notification must never be able to turn a working purge // into a fatal, so anything the URL helpers do in an unusual context // (early boot, a drop-in, a bare test harness) is contained here // rather than propagating to the caller. if ( ! function_exists( 'home_url' ) || ! function_exists( 'do_action' ) ) { return; } try { self::dispatch_purge_event( 'xspeed_after_purge', array( 'url' => null, 'host' => self::current_purge_host(), 'path' => null, 'cause' => $cause, 'removed' => $removed, 'scope' => $scope, 'intent' => $intent, 'urls' => array(), ) ); } catch ( \Throwable $e ) { self::log_purge_listener_error( 'xspeed_after_purge', $e ); } } /** * 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). */ /** * Delete a cache file, adding its size to the current sweep's byte * total. filesize() is silenced and re-checked because the file can * vanish between the glob and the unlink — a concurrent purge, or the * cache GC — and a warning there would be noise, not news. * * @param string $file Absolute path inside the cache tree. */ private static function sweep_delete( string $file ): void { $size = @filesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- the file may be gone already; see docblock. if ( is_int( $size ) ) { self::$sweep_bytes += $size; } wp_delete_file( $file ); } 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' ) { self::sweep_delete( $path ); ++$removed; } elseif ( substr( $entry, -3 ) === '.br' || substr( $entry, -8 ) === '.br.size' ) { // Precompressed sibling (index.html.br) and the record of its // length. Remove both so a purge doesn't orphan stale Brotli // bodies, or a size record that would later be read against a // different sibling's bytes. Not counted. self::sweep_delete( $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 page cache is actually SERVING, as opposed to * switched on in settings. The hero read the setting alone and * announced "Active — serving cached HTML"; a site whose * advanced-cache.php had been taken over by another cache plugin * got that line while every response carried * `X-XSpeed-Cache: BYPASS`. The setting is the user's intent; * this is the outcome, and the dashboard needs both to explain * the difference. */ 'page_cache_serving' => $serving, /* * Why not, when intent and outcome disagree. Only computed in * that state — the detector sweep behind it is far more work than * a stats call should do on an ordinary healthy site. */ 'page_cache_blocked_reason' => ( ! $serving && ! empty( Settings::get()['cache_enabled'] ) ) ? ( self::acquisition_blocker() ?? self::not_serving_reason() ) : null, ); } /** * Why the cache is not serving, when nothing REFUSES to enable it. * * acquisition_blocker() answers "may we take the field", and since a * foreign drop-in became takeable it answers null on a site where another * plugin is nonetheless holding that file. Intent and outcome still * disagree there, and the dashboard was left reporting the symptom -- not * serving -- with no reason under it, which is exactly the state a user * cannot act on. * * So this names the holder and says what to do: enabling takes it over. */ private static function not_serving_reason(): ?string { $owner = self::dropin_owner(); if ( self::DROPIN_FOREIGN !== $owner && self::DROPIN_UNREADABLE !== $owner ) { return null; } if ( self::DROPIN_UNREADABLE === $owner ) { return __( 'advanced-cache.php cannot be read, so xSpeed cannot tell whose page cache is installed.', 'xspeed' ); } $label = Page_Cache_Detector::dropin_owner_label(); return $label ? sprintf( /* translators: %s: the page-caching plugin that owns advanced-cache.php. */ __( '%s is serving the page cache. Turn the xSpeed cache off and on again to take it over.', 'xspeed' ), $label ) : __( 'Another plugin is serving the page cache. Turn the xSpeed cache off and on again to take it over.', 'xspeed' ); } /** * 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. * * Enabling is gated on acquisition_blocker(): if another plugin owns the * drop-in, or WP_CACHE is written in a form we must not rewrite, nothing * is written and the returned state carries `blocked` + a reason the * caller can show. Callers must persist `cache_enabled` from the returned * `enabled`, never from what they asked for. * * @param bool $enable User's choice. * @return array{ * enabled: bool, * blocked: bool, * blocked_reason: ?string, * dropin_installed: bool, * wp_cache_constant: bool, * wp_config_writable: bool, * manual_snippet: ?string * } */ public static function toggle( $enable, bool $consented = true ) { Page_Cache_Detector::invalidate(); $expected = Page_Cache_Detector::inspect()['revision']; /** Diagnostic seam; changing the expected revision can only force a safe refusal. */ $expected = (string) apply_filters( 'xspeed_page_cache_expected_revision', $expected ); $lock = self::page_cache_lock(); if ( ! is_resource( $lock ) ) { return self::blocked_toggle_state( __( 'Could not lock page-cache ownership. Try again.', 'xspeed' ) ); } try { Page_Cache_Detector::invalidate(); $fresh = Page_Cache_Detector::inspect()['revision']; if ( ! hash_equals( (string) $expected, (string) $fresh ) ) { return self::blocked_toggle_state( __( 'Page-cache ownership changed while xSpeed was checking it. Nothing was changed; try again.', 'xspeed' ) ); } $state = self::toggle_unlocked( (bool) $enable, $consented ); return $state; } finally { flock( $lock, LOCK_UN ); fclose( $lock ); } } /** Run the page-cache mutation while toggle() owns the scoped lock. */ /** * @param bool $consented The user asked for this in the dashboard, so a * foreign drop-in may be taken over. False on the * unattended paths, which stand down instead. */ private static function toggle_unlocked( bool $enable, bool $consented = true ) { $enable = (bool) $enable; if ( $enable ) { /* * Preflight. The drop-in and the WP_CACHE define are shared, * single-occupancy state; if we do not own them, no part of this * runs — not the drop-in, not wp-config.php, not the rewrite * block. Refusing whole is the point: a partial enable leaves the * site claiming a cache it cannot serve. * * Every caller routes through here (REST, onboarding, MCP, CLI, * the optimize runner, Pro's migration), so the gate lives here * rather than being re-implemented at each entry point. * * Except when there is nothing to acquire. A site where we * already own the drop-in and are already serving is being asked * to stay as it is, and the gate answers a different question — * "is the field free to take" — which a merely ACTIVE competitor * makes false. So "make sure caching is on", from an AI agent, * the optimize runner or Pro's migration, came back as a refusal * telling the user to deactivate a plugin on a site that was * caching perfectly. The dashboard never saw it, because nobody * presses Enable on a cache that is already enabled. * * Only the GATE is skipped. The writes below still run, and every * one of them is individually idempotent — which matters, because * this is the path CacheModule re-bakes the drop-in through when * an exclusion rule or the TTL changes (#240, #251), and the path * auto_heal() restores a stripped WP_CACHE through. Returning * early here left both of those doing nothing at all, silently, * on exactly the healthy sites this branch is about. */ $reasserting = self::page_cache_operational() && self::DROPIN_XSPEED === self::dropin_owner(); $blocker = $reasserting ? null : self::acquisition_blocker(); /* * Taking over another plugin's drop-in needs the user to have * asked for it. On the dashboard they did -- they clicked the * switch, having been told whose file it is. The UNATTENDED * callers have no such click: restore_dropin_if_enabled() runs * after a plugin update and auto_heal() on an admin page load, * both from nothing more than `cache_enabled` still being true. * * A competitor installed since that flag was set would have its * page cache seized by a background repair, which is the silent * acquisition this plugin refuses to perform. So those callers * pass $consented = false and stand down instead. */ if ( null === $blocker && ! $consented && self::DROPIN_FOREIGN === self::dropin_owner() ) { // Name the owner. This string is rendered by host plugins // through Host::enable_page_cache(), and an unnamed refusal // is what made every host invent its own explanation. $owner_label = Page_Cache_Detector::dropin_owner_label(); return self::blocked_toggle_state( $owner_label ? sprintf( /* translators: %s: the page-caching plugin that owns advanced-cache.php. */ __( '%s owns advanced-cache.php, so xSpeed left it alone. Enable the cache from the xSpeed dashboard to take it over.', 'xspeed' ), $owner_label ) : __( 'Another plugin owns advanced-cache.php, so xSpeed left it alone. Enable the cache from the xSpeed dashboard to take it over.', 'xspeed' ) ); } if ( null !== $blocker ) { Activity_Log::record( 'cache_enable_blocked', 'Cache not enabled — ' . $blocker, Activity_Log::WARN ); return self::blocked_toggle_state( $blocker ); } $dropin_path = WP_CONTENT_DIR . '/advanced-cache.php'; $config_path = self::wp_config_path(); $dropin_before = file_exists( $dropin_path ) ? self::read_file( $dropin_path ) : null; $config_before = '' !== $config_path ? self::read_file( $config_path ) : null; $dropin_ok = self::install_dropin(); if ( ! $dropin_ok ) { $partial = self::read_file( $dropin_path ); if ( is_string( $partial ) && xspeed_has_canonical_dropin_signature( $partial ) ) { self::rollback_page_cache_artifacts( $dropin_path, $dropin_before, $partial, $config_path, $config_before, null ); } /* * Preflight said the field was clear, so this is a filesystem * failure (or a drop-in that appeared in between). Without the * drop-in there is no cache to enable, and persisting * cache_enabled anyway is what produced sites reporting a * healthy cache while serving every request uncached. */ $reason = __( 'Could not write wp-content/advanced-cache.php. Check filesystem permissions.', 'xspeed' ); Activity_Log::record( 'cache_enable_blocked', 'Cache not enabled — ' . $reason, Activity_Log::WARN ); return array( 'enabled' => false, 'blocked' => true, 'blocked_reason' => $reason, '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(), ); } $dropin_written = self::read_file( $dropin_path ); self::set_wp_cache_constant( true ); $config_written = '' !== $config_path ? self::read_file( $config_path ) : null; Page_Cache_Detector::invalidate(); $dropin_ours = self::DROPIN_XSPEED === self::dropin_owner(); $constant_state = self::wp_cache_define_state(); $constant_ok = 'true' === $constant_state; /* * A wp-config.php we cannot write at all is a supported state, not * a failed transaction. Plenty of managed hosts ship the file * read-only; there the drop-in is ours and installed, the cache * works the moment WP_CACHE exists, and the one line to paste * comes back as `manual_snippet`. Rolling back instead left those * hosts unable to turn the page cache on by any route — including * when the user had already pasted the define, since the write * fails on an unwritable file whatever value is already there. * * `undefined` ONLY. `false` looks eligible — this method would * have rewritten it — but the snippet we hand back cannot work * there: the file already says `define( 'WP_CACHE', false )`, the * first define() call wins, and a user who pastes our line via * FTP ends up with a cache that never serves AND a `duplicate` * wp-config that blocks every future toggle in both directions. * They have to edit the existing line, which means refusing here * and saying so. `duplicate` and `dynamic` are refused by * acquisition_blocker() before we get here, and if one appears in * the race window it must still fail closed. */ $manual_mode = ! $constant_ok && 'undefined' === $constant_state && ! self::can_write_wp_config(); if ( ! $dropin_ours || ( ! $constant_ok && ! $manual_mode ) ) { if ( ! self::can_write_wp_config() ) { $reason = 'false' === $constant_state ? __( "wp-config.php is not writable and already contains define( 'WP_CACHE', false ). Change that line to true — adding a second one would leave the cache off and block xSpeed from changing it again.", 'xspeed' ) : __( 'xSpeed could not verify the complete page-cache write, and wp-config.php is not writable. Its changes were rolled back.', 'xspeed' ); } else { $reason = __( 'xSpeed could not verify the complete page-cache write. Its changes were rolled back.', 'xspeed' ); } self::rollback_page_cache_artifacts( $dropin_path, $dropin_before, $dropin_written, $config_path, $config_before, $config_written ); return self::blocked_toggle_state( $reason ); } $wp_config_ok = $constant_ok; $rewrite_ok = self::install_rewrite(); self::ensure_hits_log_file(); self::sync_mobile_flag(); $snippet = $wp_config_ok ? null : "define( 'WP_CACHE', true );"; Settings::update( array( 'cache_enabled' => true ) ); if ( empty( Settings::get()['cache_enabled'] ) ) { self::remove_rewrite(); self::rollback_page_cache_artifacts( $dropin_path, $dropin_before, $dropin_written, $config_path, $config_before, $config_written ); delete_option( 'xspeed_page_cache_ownership_receipt' ); return self::blocked_toggle_state( __( 'xSpeed could not save the page-cache setting. Its file changes were rolled back.', 'xspeed' ) ); } /* * Only when this call actually changed something. auto_heal() runs * the enable transaction on every admin_init, and an unconditional * entry filled the 50-slot log with identical "Cache enabled" lines * within 50 wp-admin page loads, evicting every real event — plus * an option write per admin request. The sentence is also false * when nothing was installed. */ if ( $dropin_written !== $dropin_before || $config_written !== $config_before ) { 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, 'blocked' => false, 'blocked_reason' => null, '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(), ); } /* * Whose advanced-cache.php is on disk decides how much of the disable * below may run. Read it once, before anything is touched. */ $owner = self::dropin_owner(); $not_ours = self::DROPIN_FOREIGN === $owner || self::DROPIN_UNREADABLE === $owner; if ( ! self::set_wp_cache_constant( false ) ) { /* * The mirror of the enable path. A wp-config.php nobody can write * does not trap the user in a cache they turned off: WP_CACHE on * its own does nothing once advanced-cache.php is gone, and core * simply skips the missing drop-in. Refusing here left the * read-only managed hosts able to enable the page cache and never * able to disable it again. * * A drop-in that is not ours reaches the same conclusion by a * different road. WP_CACHE is then the switch for THEIR cache, so * set_wp_cache_constant() refuses it — correctly, and permanently, * because nothing the user does to xSpeed will make that file ours * again. Treating that refusal as a failed disable was a trap with * no exit: install any competing cache plugin while xSpeed's cache * was on, and xSpeed's toggle could never be turned off again, * while the dashboard went on claiming a cache that was serving * nothing. Turning xSpeed off is entirely within our own state — * our setting, our rewrite block — so it proceeds, and their * constant and their file are left exactly as they are. */ /* * Every reason set_wp_cache_constant() refuses is structural * except one, and the exception is the only one worth blocking * on. It will not touch a constant it cannot prove is ours; it * will not rewrite a define it cannot read as a literal — * duplicate, dynamic, or inside a conditional; and it cannot * write a file the filesystem will not let it write. None of * those improve on a retry, and all of them leave a WP_CACHE * that does nothing once our drop-in is gone. What is left — our * own constant, in a shape we can rewrite, in a file we can * write, and the write still failed — is a real I/O failure, and * that one still refuses so the user is not told a cache was * turned off while it goes on serving. * * The proof, not the drop-in, is the test. A user who pasted our * manual snippet on a locked-down host has a WP_CACHE line with * no receipt on it; if their drop-in later goes missing, we can * never prove that line is ours, so refusing left the toggle * stuck on with no way out but enabling first and disabling * again. Nothing loads a drop-in that is not there, so the line * is inert either way and the disable proceeds without it. */ $leave_it = ! self::wp_cache_define_is_ours_to_remove( $owner ) || ! in_array( self::wp_cache_define_state(), array( 'true', 'false', 'undefined' ), true ) || ! self::can_write_wp_config(); if ( ! $leave_it ) { return self::blocked_toggle_state( __( 'xSpeed could not safely remove its WP_CACHE setting. The cache remains enabled.', 'xspeed' ) ); } } self::remove_dropin(); if ( self::DROPIN_XSPEED === self::dropin_owner() ) { // Put WP_CACHE back, and say so if we could not. Reporting a // hardcoded `enabled: true` here claimed a working cache on a // site whose constant we had just failed to restore. // Put WP_CACHE back, then read the outcome off disk rather than // trusting the write's return value — a write can report failure // for a value that was already correct, and the question the // caller needs answered is whether the cache serves. self::set_wp_cache_constant( true ); return self::blocked_toggle_state( self::page_cache_operational() ? __( 'xSpeed could not remove its page-cache drop-in. The cache remains enabled.', 'xspeed' ) : __( 'xSpeed could not remove its page-cache drop-in, and could not put WP_CACHE back. The cache is not serving; check wp-config.php before changing the page cache again.', 'xspeed' ) ); } self::remove_rewrite(); /* * The .htaccess block serves cached HTML straight off disk without * ever reaching PHP, so a block we failed to remove keeps answering * requests from a cache the user just turned off — and nothing else * in this method can stop it. remove_rewrite() also returns false * when there is no .htaccess to clean, which is the ordinary case, * so ask the file rather than trust the return value. */ if ( self::rewrite_installed() ) { if ( $not_ours ) { // Nothing to roll back — under a foreign drop-in this method // removed no drop-in and wrote no constant, and it could not // put either back if it wanted to. Say what is actually left. return self::blocked_toggle_state( __( 'xSpeed could not remove its rewrite rules from .htaccess, which would keep serving cached pages. Remove the xSpeed block from .htaccess by hand before turning the page cache off.', 'xspeed' ) ); } /* * Roll the disable back. Both calls can fail — a filesystem that * would not let us remove the block may not let us write the * drop-in either — and discarding their results reported an * enabled cache over a site left with no drop-in and no * constant. Fall through to the default state so the artifact * fields are read from disk rather than asserted. */ self::install_dropin(); self::set_wp_cache_constant( true ); // Both of those can fail — a filesystem that would not let us // remove the block may not let us write the drop-in either — so // the message follows what is on disk afterwards, not what the // calls returned. return self::blocked_toggle_state( self::page_cache_operational() ? __( 'xSpeed could not remove its rewrite rules from .htaccess, which would keep serving cached pages. The cache remains enabled.', 'xspeed' ) : __( 'xSpeed could not remove its rewrite rules from .htaccess, and could not restore the drop-in it had just removed. The cache is not serving, and the site may still return stale cached pages until the xSpeed block is removed from .htaccess by hand.', 'xspeed' ) ); } // 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 ); Settings::update( array( 'cache_enabled' => false ) ); if ( ! empty( Settings::get()['cache_enabled'] ) ) { if ( $not_ours ) { // Same as above: there is nothing of ours on disk to restore. return self::blocked_toggle_state( __( 'xSpeed could not save the disabled state.', 'xspeed' ) ); } self::install_dropin(); self::set_wp_cache_constant( true ); return self::blocked_toggle_state( __( 'xSpeed could not save the disabled state. The page cache was restored.', 'xspeed' ) ); } // A WP_CACHE we could not remove because wp-config.php is read-only // is left behind deliberately (see above) — say so rather than // reporting a constant that is still in the file as gone. $constant_left = 'true' === self::wp_cache_define_state(); /* * Say why the constant is still there, because there are now three * different reasons and they call for different advice. Keyed off the * same facts $leave_it was, so the log cannot drift from the decision * it is describing — it did, briefly, and reported a wp-config.php as * unwritable when the real reason was that we could not prove the * line was ours. */ if ( self::DROPIN_UNREADABLE === $owner ) { $log_message = 'Cache disabled. advanced-cache.php could not be read, so it and the WP_CACHE setting were left untouched.'; } elseif ( $not_ours ) { $log_message = 'Cache disabled. Another plugin owns advanced-cache.php, so its drop-in and its WP_CACHE setting were left untouched.'; } elseif ( ! $constant_left ) { $log_message = 'Cache disabled. Drop-in removed.'; } elseif ( ! self::wp_cache_define_is_ours_to_remove( $owner ) ) { $log_message = 'Cache disabled. WP_CACHE was left in place — it carries no proof xSpeed wrote it, and it does nothing without a drop-in.'; } elseif ( ! self::can_write_wp_config() ) { $log_message = 'Cache disabled. Drop-in removed; wp-config.php not writable, so WP_CACHE was left in place (harmless without the drop-in).'; } else { $log_message = 'Cache disabled. Drop-in removed; WP_CACHE was left in place (harmless without the drop-in).'; } Activity_Log::record( 'cache_disabled_event', $log_message, $constant_left ? Activity_Log::WARN : Activity_Log::INFO ); return array( 'enabled' => false, 'blocked' => false, 'blocked_reason' => null, 'dropin_installed' => false, 'wp_cache_constant' => $constant_left, '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(), ); } /** Acquire the local lock that serializes page-cache ownership changes. */ private static function page_cache_lock() { $path = WP_CONTENT_DIR . '/.xspeed-page-cache.lock'; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen,WordPress.PHP.NoSilencedErrors.Discouraged -- flock requires a local handle; failure is a safe blocked result. $lock = @fopen( $path, 'c+' ); if ( ! is_resource( $lock ) || ! flock( $lock, LOCK_EX ) ) { return false; } return $lock; } /** * Build the stable response shape for a refused transaction. * * The artifact fields report what is ON DISK, not zeros. A refusal means * xSpeed changed nothing — on a site already running our cache that is * exactly the state where the drop-in and WP_CACHE are both still in * place and still serving hits. Hardcoding false told the dashboard the * cache had been dismantled every time a refusal was returned. */ private static function blocked_toggle_state( string $reason ): array { /* * `enabled` answers ONE question: is the page cache operational right * now. Not what was asked for, and not what the option says. * * WordPress loads advanced-cache.php only when WP_CACHE is truthy, so * those two files together are the whole answer, and reading them is * the only source that cannot go stale. Both of the alternatives were * tried here and both produced wrong answers on real paths: a * hardcoded false told a caller the cache had gone away on a site * still serving hits, and the persisted setting told a caller the * cache was healthy after a rollback had just removed the artifacts * — the option is not written until the end of the transaction, so * mid-transaction it is stale by construction. * * Deliberately not a parameter. Every branch that got to choose its * own answer eventually chose wrong. */ return array( 'enabled' => self::page_cache_operational(), 'blocked' => true, 'blocked_reason' => $reason, 'dropin_installed' => self::DROPIN_XSPEED === self::dropin_owner(), 'wp_cache_constant' => 'true' === self::wp_cache_define_state(), 'rewrite_installed' => self::rewrite_installed(), 'wp_config_writable' => self::wp_config_writable(), 'manual_snippet' => null, 'nginx_snippet' => self::nginx_snippet(), 'nginx_server_block' => self::full_nginx_server_block(), ); } /** * The wp-config.php line a user must paste, or null when none is needed. * * Non-null only where the drop-in is ours and WP_CACHE is not set to true * in a file we can write — the read-only managed host. Everywhere else the * constant is ours to manage and there is nothing to ask for. */ public static function manual_wp_cache_snippet(): ?string { if ( self::DROPIN_XSPEED !== self::dropin_owner() ) { return null; } if ( 'true' === self::wp_cache_define_state() ) { return null; } return self::wp_config_writable() ? null : "define( 'WP_CACHE', true );"; } /** * Is the page cache serving right now? * * Two things decide it, and `WP_CACHE` is not one of them. * * xSpeed serves a cached page from `template_redirect` whenever the * setting is on — see the `HIT (php)` mark on that path, which exists * precisely for "the drop-in isn't loaded". `advanced-cache.php` and the * `WP_CACHE` constant that loads it are the FAST path: they answer before * WordPress boots, which is worth a lot of milliseconds and nothing at * all to the question of whether pages are being served from cache. * * Conflating the two reported a dead cache over a live one. On a managed * host with an unwritable wp-config.php — the exact case the manual * snippet exists for — one card said "Your cache works on every request", * "On, but not serving", "nothing will be cached until you add this line" * and "hit ratio 67%", all at once, and told the user to edit a file they * have no permission to write. The released 1.2.1 reported that site as * active, correctly. * * So: the setting, and whether anyone else holds the drop-in. A foreign * drop-in answers before WordPress loads us, so ours never runs and we * genuinely are not serving. An unreadable one we must assume the same of. * Everything else — our drop-in, or none at all — serves. * * Public because it is part of the host-plugin contract — see Host. A * plugin that installed xSpeed needs to be able to say whether the cache * it asked for is actually serving, and no combination of settings reads * answers that. */ public static function page_cache_operational(): bool { $settings = Settings::get(); if ( empty( $settings['cache_enabled'] ) ) { return false; } $owner = self::dropin_owner(); return self::DROPIN_FOREIGN !== $owner && self::DROPIN_UNREADABLE !== $owner; } /** Restore exact snapshots only while disk still matches our own write. */ private static function rollback_page_cache_artifacts( string $dropin_path, ?string $dropin_before, ?string $dropin_written, string $config_path, ?string $config_before, ?string $config_written ): void { // Roll back only files that still carry xSpeed's just-written state. if ( null !== $dropin_written && hash_equals( $dropin_written, (string) self::read_file( $dropin_path ) ) ) { if ( null === $dropin_before ) { wp_delete_file( $dropin_path ); } else { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- Exact compare-and-swap rollback under the scoped lock. file_put_contents( $dropin_path, $dropin_before ); } } if ( '' !== $config_path && null !== $config_before && null !== $config_written && hash_equals( $config_written, (string) self::read_file( $config_path ) ) && self::wp_cache_receipt_matches_source( $config_written ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- Exact compare-and-swap rollback under the scoped lock. file_put_contents( $config_path, $config_before ); } } /** * 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 HIT request * lines and must be protected like an access log: paths and queries can * contain sensitive values. */ /** * 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. */ /** * Write the subdirectory-multisite path list the drop-in needs to work * out which blog a request belongs to. * * The drop-in runs before WordPress, so it cannot call is_multisite() * or get_blog_details(). It can only see REQUEST_URI — so we persist the * network's blog paths (one per line, longest first) next to the cache * files, exactly as sync_mobile_flag() persists the device flag. The * drop-in prefix-matches the URI against that list to pick the same * bucket Cache::current_host_dir() picks. (#6) * * No file is written for a single site or a subdomain network — there * the host alone identifies the blog and the bucket carries no prefix. */ public static function sync_site_paths(): void { $file = XSPEED_CACHE_DIR . '/.site-paths'; $needed = function_exists( 'is_multisite' ) && is_multisite() && ( ! function_exists( 'is_subdomain_install' ) || ! is_subdomain_install() ); if ( ! $needed ) { if ( file_exists( $file ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal. @unlink( $file ); } return; } if ( ! function_exists( 'get_sites' ) ) { return; } $paths = array(); foreach ( get_sites( array( 'number' => 0 ) ) as $site ) { $prefix = self::path_prefix_segment( (string) $site->path ); if ( '' !== $prefix ) { // Store the raw path so the drop-in can prefix-match a URI, // alongside the segment it maps to. $paths[ trim( (string) $site->path, '/' ) ] = $prefix; } } if ( empty( $paths ) ) { if ( file_exists( $file ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- see above. @unlink( $file ); } return; } // Longest path first so /a/b wins over /a. uksort( $paths, static function ( $x, $y ) { return strlen( (string) $y ) <=> strlen( (string) $x ); } ); $lines = array(); foreach ( $paths as $raw => $segment ) { $lines[] = $raw . '|' . $segment; } if ( ! is_dir( XSPEED_CACHE_DIR ) && ! wp_mkdir_p( XSPEED_CACHE_DIR ) ) { return; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- read by the pre-WP drop-in; WP_Filesystem needs admin credentials unavailable here. file_put_contents( $file, implode( "\n", $lines ), LOCK_EX ); } /** * Compile `ignored_query_params` into a regex the DROP-IN can use. * * Tracking traffic was cached but never served fast. should_cache() * learned to allow `?utm_source=…` through and cache_key() strips the * query, so `/post` and `/post?utm_source=x` share one entry — but the * drop-in still bailed on ANY query string, so every visitor from an * email or ad campaign paid a full WordPress boot to be handed a file * that was already on disk. On a marketing site that is most of the * paid traffic taking the slowest path. (#13) * * The drop-in runs before WordPress, so it cannot read the option or * call Glob_Matcher. It gets a precompiled alternation instead, written * next to the cache files exactly as sync_mobile_flag() writes the * device flag. Regenerated whenever cache settings are saved. * * Only the KEYS matter: a param whose name is on the list contributes * nothing to the response, so the entry keyed without it is correct. * Anything not on the list means the drop-in must stand down and let * PHP decide — the file is deleted rather than left stale when the * list is empty, so a missing sidecar always fails safe. */ public static function sync_query_allowlist(): void { $file = XSPEED_CACHE_DIR . '/.ignored-query-params'; /* * Stored read, not Settings_Manager::get() — this runs from boot(), * before translation is legal (see stored_cache_opts()). * * A raw read applies no schema defaults, and this field's default is a * long tracking-parameter list, NOT empty. Falling back to array() * would strip that whole allow-list from the drop-in on any install * that has never saved the Cache panel. So fall back to the schema's * own default, read from the module without building its labels. */ $opts = self::stored_cache_opts(); $ignored = is_array( $opts['ignored_query_params'] ?? null ) ? $opts['ignored_query_params'] : \XSpeed\Modules\Cache\CacheModule::DEFAULT_IGNORED_QUERY_PARAMS; $parts = array(); foreach ( $ignored as $pattern ) { $pattern = trim( (string) $pattern ); if ( '' === $pattern ) { continue; } if ( '~' === $pattern[0] ) { // Raw regex, PHP-side dialect. Keep it — unlike a server // config, the drop-in runs the same PCRE engine, so the // pattern behaves identically. Anchored below with the rest. $body = substr( $pattern, 1 ); if ( '' !== $body && false !== @preg_match( '#^(?:' . $body . ')$#', '' ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a malformed user pattern must be dropped, not fatal. $parts[] = $body; } continue; } // Glob semantics, same as Glob_Matcher: * is any run, ? is one. $esc = preg_quote( $pattern, '#' ); $esc = str_replace( array( '\*', '\?' ), array( '.*', '.' ), $esc ); $parts[] = $esc; } if ( empty( $parts ) ) { if ( file_exists( $file ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal. @unlink( $file ); } return; } if ( ! is_dir( XSPEED_CACHE_DIR ) && ! wp_mkdir_p( XSPEED_CACHE_DIR ) ) { return; } $payload = '(?:' . implode( '|', array_unique( $parts ) ) . ')'; // Only write when the value actually changed. This runs from // reconcile_mobile_separate() on CacheModule::boot(), so an // unconditional write cost a file write and an exclusive lock on every // request that boots WordPress — every MISS, every BYPASS, every admin // screen, every REST call. sync_mobile_flag() below is the model: it // touches the marker only when the setting flips. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.PHP.NoSilencedErrors.Discouraged -- our own sidecar; an unreadable file falls through to the write below. if ( is_readable( $file ) && (string) @file_get_contents( $file ) === $payload ) { return; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- read by the pre-WP drop-in; WP_Filesystem needs admin credentials unavailable here. file_put_contents( $file, $payload, LOCK_EX ); } /** * CacheModule's STORED settings, read straight from the option. * * `Settings_Manager::get( 'cache' )` builds CacheModule's settings schema, * whose labels are declared through `__()`. The reconcile chain below runs * from `CacheModule::boot()` on `plugins_loaded` — before * `after_setup_theme`, the point WordPress 6.7+ treats as safe to * translate — so going through the schema there fires * `_load_textdomain_just_in_time` on every request AND resolves the labels * against a domain that is not loaded yet. * * The callers here need stored values, not schema metadata, so a raw read * is equivalent. It applies NO defaults or coercion: read each key with a * fallback matching the schema's own default. * * @return array */ private static function stored_cache_opts(): array { $stored = get_option( Settings_Manager::OPTION_PREFIX . 'cache', array() ); return is_array( $stored ) ? $stored : array(); } public static function sync_mobile_flag( $enabled = null ): void { if ( null === $enabled ) { $stored = self::stored_cache_opts(); $enabled = ! empty( $stored['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(); if ( defined( 'XSPEED_CACHE_DIR' ) ) { // Keep the drop-in's view of the network's blog paths current — a // site added or removed changes which bucket its URLs belong to. (#6) self::sync_site_paths(); // Keep the drop-in's copy of the query allow-list current — a param // added in settings must reach the fast path too. (#13) self::sync_query_allowlist(); } // 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. // Stored read — this runs from boot(); see stored_cache_opts(). $cache_opts = self::stored_cache_opts(); $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; } // Stored read — reached from boot(); see stored_cache_opts(). $opts = self::stored_cache_opts(); 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(); // Same observed-refusal check Health makes. This is the shared path for // `wp xspeed cache recheck-rewrite` and POST /cache/recheck-rewrite — // and, because a CLI command is automatically an MCP tool, for the // AI-facing surface too. Leaving it out would have fixed the dashboard // while the CLI kept answering that the fast path was active. (#372) if ( '' === $block_reason ) { $skip = self::last_static_skip(); if ( ! empty( $skip['reason'] ) ) { $block_reason = 'skipped_' . (string) $skip['reason']; } } // With page caching off there is nothing to serve, so `active` can // never be true here whatever the raw probe says. probe_static_rewrite() // writes its OWN file under the static tree and fetches that, which // succeeds whenever the server can serve a static file at all — and on // nginx the snippet is server-level, so it keeps succeeding after the // cache is switched off. // // block_reason() used to carry this meaning by accident: it returned // 'mobile_separate' with caching off, and the refusal branch below // forced active=false. Now that it correctly reports '' (nothing can // block a fast path that isn't in use), this consumer has to state the // condition itself — otherwise `wp xspeed cache recheck-rewrite` and // POST /cache/recheck-rewrite claim "the web server is serving cache // hits directly" on a site with no cache. That is a positive false // claim rather than a nag, i.e. worse than the bug being fixed. $cache_opts = Settings::get(); if ( empty( $cache_opts['cache_enabled'] ) ) { return array( 'active' => false, 'inconclusive' => false, 'reason' => 'Page caching is off, so there is no cache for the web server to serve.', '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."; case 'skipped_nonce': return 'The server config is correct, but pages are not reaching the static cache because they contain nonces, so hits are served by PHP instead. A static file is served with no PHP, so a nonce baked into one could never be refreshed and every anonymous form on the page would break once it expired — keeping these pages on PHP is deliberate. Nonces usually come from plugin widgets; disabling the ones the site does not use lets its pages be served statically again.'; default: return sprintf( 'The static rewrite is disabled (%s).', $code ); } } public static function static_rewrite_block_reason(): string { // Nothing can be blocking the fast path when there is no cache to // serve from it. Without this the dashboard told users with page // caching switched OFF that Separate Mobile Cache "is disabling // faster static serving" — a fast path they were not using, about a // cache that did not exist. Every caller of this is a user-facing // explanation of why the rewrite is off, so "the cache is off" is // the honest answer, and it is silence. (#108) $opts = Settings::get(); if ( empty( $opts['cache_enabled'] ) ) { return ''; } 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'; } $cache_opts = Settings_Manager::get( 'cache' ); return ! empty( $cache_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 { // Same reasoning as static_rewrite_block_reason(): the invitation is // "turn this back on if your site needs it, to regain the fast path", // which is meaningless with page caching off — there is no fast path // to regain, and the equality probe behind the prompt would fetch // pages that aren't being cached. Gated here rather than at the two // payload call sites (Admin + Rest_Api) so `enabled`, `blocking` and // `needs_review` are consistently gated on the same condition. (#108) $opts = Settings::get(); if ( empty( $opts['cache_enabled'] ) ) { return false; } $cache_opts = Settings_Manager::get( 'cache' ); return ! empty( $cache_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 { // Every rule here errs toward "they differ" being WRONG rather than // "they match" being wrong: this check only ever tells a user it is // SAFE to turn Separate Mobile Cache off, so a false "identical" // would cost them device-specific output. The risk of being too // conservative is milder but real — the useful answer never appears, // and the feature's whole pitch ("we'll prove it's safe to turn // off") silently never pays out. These close the gaps that made a // mismatch effectively guaranteed on an ordinary WordPress site. (#108) $patterns = array( // WP nonces in attribute or JSON form: data-nonce="…", // _wpnonce=…, "nonce":"…". The `[:=]` adjacency below misses // wp_nonce_field()'s own markup — `name="_wpnonce" value="ab…"` // puts `value=` between the key and the token — which is the // single most common nonce shape in WordPress, so that form is // matched explicitly first. '/name=["\']?(_wpnonce|_ajax_nonce)["\']?\s+value=["\']?[a-z0-9]{8,}/i', // CSP nonces on script/style tags. Base64, so uppercase and // +/= appear — the hex-only rules below can never match one, // and a CSP-enabled site therefore differed on every fetch. // MUST precede the generic nonce rule: that one stops at the // first non-alphanumeric, leaving the rest of the token behind // and the two responses still unequal. // The quotes are optional so HTML5's legal unquoted attribute // form (`