PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / trunk
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN vtrunk
1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 1.2.0 1.2.1 1.2.2 1.2.3
xspeed / includes / class-cache.php

class-cache.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN trunk, at includes/class-cache.php

6,981 lines 297.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Page cache engine.
4 *
5 * @package XSpeed
6 */
7
8 namespace XSpeed;
9
10 defined( 'ABSPATH' ) || exit;
11
12 class Cache {
13
14 /**
15 * Output-buffer nesting level at which we opened our cache buffer, so
16 * `close_buffer()` can flush ONLY our buffer and never disturb a buffer
17 * another plugin pushed on top of (or below) ours.
18 *
19 * @var int|null
20 */
21 private static $buffer_level = null;
22
23 /**
24 * The `X-XSpeed-Cache` value decided for this request, and — when the
25 * decision was BYPASS — the slug of the gate that made it.
26 *
27 * Recorded as well as sent so unit tests (CLI SAPI, where header() is a
28 * no-op and headers_sent() is meaningless) can assert on the decision.
29 *
30 * @var string
31 */
32 private static $status_header = '';
33 private static $bypass_reason = '';
34
35 /**
36 * Cache key whose write was deferred to shutdown because a render-time
37 * translation plugin's buffer wraps ours. Null on every ordinary request.
38 *
39 * @var string|null
40 */
41 private static $deferred_key = null;
42
43 /**
44 * Translated page HTML captured by the outer buffer, for the deferred
45 * write. Only populated when a translation plugin is active.
46 *
47 * @var string
48 */
49 private static $translated_output = '';
50
51 /**
52 * Did finalize_buffer() run to completion on this request?
53 *
54 * The deferred translated write runs as a PHP shutdown function, which
55 * fires after a `wp_die()` or a bare `exit()` exactly as it does after a
56 * clean render. Only finalize_buffer() sets this, and only at the point
57 * where it has the full buffer in hand — so an aborted render leaves it
58 * false and the writer declines rather than caching a truncated page
59 * under the real key.
60 *
61 * @var bool
62 */
63 private static $render_completed = false;
64
65 /**
66 * Hooks that get an argument-aware handler instead of a blanket purge.
67 *
68 * Each fires on an ordinary visitor action — an order, a review, a
69 * registration — where purge_all() cannot see WHAT changed and so wiped
70 * the whole cache on every one. They are re-bound further down to
71 * handlers that inspect the payload first.
72 *
73 * Listed here so the generic invalidation loop skips them. It binds a
74 * closure (to name the cause), and a closure cannot be unbound by the
75 * remove_action() pairs below — binding one would leave the coarse purge
76 * running alongside its replacement and silently undo #243.
77 */
78 private const TARGETED_INVALIDATION_HOOKS = array(
79 'save_post',
80 'comment_post',
81 'user_register',
82 'profile_update',
83 );
84
85 public function __construct() {
86 /**
87 * When the page-cache output buffer opens.
88 *
89 * Filterable because buffer ORDER decides what gets cached. PHP's
90 * output buffers are LIFO: the last one opened is innermost, and its
91 * callback runs first. A render-time translation plugin that opens
92 * an outer buffer therefore translates AFTER we have already captured
93 * and cached the raw HTML — see translation_buffer_compat().
94 *
95 * @param string $hook Hook to open the buffer on.
96 * @param int $priority Priority for that hook.
97 */
98 $hook = (string) apply_filters( 'xspeed_cache_buffer_hook', 'template_redirect' );
99 $priority = (int) apply_filters( 'xspeed_cache_buffer_priority', 0 );
100 add_action( $hook, array( $this, 'maybe_start_cache' ), $priority );
101
102 // When a render-time translation plugin is present, open one extra
103 // buffer OUTSIDE its own so we can capture post-translation HTML.
104 // TranslatePress opens on `init` priority 0, so we take a negative
105 // priority to land outside it. This buffer only collects bytes for
106 // the deferred cache write — it never modifies the response.
107 add_action(
108 'init',
109 static function () {
110 if ( ! self::translation_plugin_active() ) {
111 return;
112 }
113 // `init` fires on EVERY request type, and
114 // translation_plugin_active() is a class_exists() check that
115 // is true site-wide — so without this guard the buffer opened
116 // on REST, admin-ajax, cron and WP-CLI too. None of those
117 // reach template_redirect, so $deferred_key stays null and
118 // the collected bytes are never released: a long-running
119 // WP-CLI command copied every byte of its output into a
120 // string that grew for the life of the process.
121 if ( is_admin()
122 || wp_doing_ajax()
123 || wp_doing_cron()
124 || ( defined( 'REST_REQUEST' ) && REST_REQUEST )
125 || ( defined( 'WP_CLI' ) && WP_CLI )
126 || ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) ) {
127 return;
128 }
129 ob_start(
130 static function ( $chunk ) {
131 self::$translated_output .= $chunk;
132 return $chunk;
133 }
134 );
135 },
136 (int) apply_filters( 'xspeed_translation_outer_buffer_priority', -100 )
137 );
138
139 // Events that should invalidate cached output. Beyond posts/comments,
140 // this covers user and term changes — the REST cache can serve
141 // /wp/v2/users, /wp/v2/categories, /wp/v2/tags, and these also affect
142 // rendered author bylines / term-archive pages. Without them, an edit
143 // left the matching endpoint (and archives) stale for the full TTL.
144 // (FBS-82408)
145 $invalidate_hooks = array(
146 'save_post', 'deleted_post', 'trashed_post',
147 'comment_post', 'wp_set_comment_status',
148 'switch_theme', 'activated_plugin', 'deactivated_plugin',
149 // Users → /wp/v2/users + author archives.
150 'profile_update', 'user_register', 'deleted_user',
151 // Terms → /wp/v2/{taxonomy} + term archives.
152 'created_term', 'edited_term', 'delete_term',
153 // Menu structure changes (reorder, rename, assign to a location)
154 // fire only here — the per-item `nav_menu_item` save_post does
155 // not cover them. (#270 regression)
156 'wp_update_nav_menu',
157 );
158 foreach ( $invalidate_hooks as $hook ) {
159 // Name the hook in the cause rather than binding purge_all bare.
160 // Bound bare, WordPress passes the action's own first argument
161 // into $cause — a term id, a user id, a menu id — so the activity
162 // feed read "Cache purged (12)" and told the user nothing about
163 // what happened. (#270 QA round 2)
164 //
165 // The four hooks that get an argument-aware handler below
166 // (save_post, comment_post, user_register, profile_update) are
167 // deliberately NOT wired here: a closure cannot be unbound by
168 // remove_action(), so binding one would leave the coarse purge in
169 // place alongside its replacement and silently undo #243. Skipping
170 // them is equivalent — each is re-added with its own handler, and
171 // each of those names its own cause.
172 if ( in_array( $hook, self::TARGETED_INVALIDATION_HOOKS, true ) ) {
173 continue;
174 }
175 add_action(
176 $hook,
177 static function () use ( $hook ): void {
178 self::purge_all( 'hook:' . $hook );
179 }
180 );
181 add_action( $hook, array( 'XSpeed\\Minifier', 'purge_minified' ) );
182 }
183
184 // Updating a plugin, theme or core changes the markup and the assets
185 // a page is built from, but fires NONE of the hooks above: WordPress
186 // does not deactivate and reactivate a plugin to update it, so
187 // `activated_plugin` never runs and the cached HTML survives the
188 // update untouched for the whole TTL — up to 7 days on the Aggressive
189 // preset, 30 at the maximum.
190 //
191 // The stale copy is not merely old, it is wrong in a way the user
192 // cannot see the cause of: they update a plugin to get a fix, the
193 // cache keeps serving the pre-fix HTML, and the update looks like it
194 // did nothing. Minified assets do regenerate on their own (their key
195 // includes the source filemtime), which makes it worse rather than
196 // better — the cached pages still link the PREVIOUS hashes.
197 //
198 // Purge unconditionally on any completed update. Scoping it to
199 // "plugins that enqueue front-end assets" is not knowable here, and a
200 // cold cache after an update is the cheaper mistake. (#269)
201 add_action( 'upgrader_process_complete', array( __CLASS__, 'purge_after_upgrade' ), 10, 2 );
202 // The replacement signal has to outlive OUR listener: add-ons read it
203 // through upgrade_replaced_code() from their own priority-10 callbacks,
204 // and consuming it inside purge_after_upgrade() meant whoever
205 // registered second saw false. Cleared at the END of the dispatch
206 // instead, once every listener has had its turn.
207 //
208 // Depth-counted, because this action NESTS. Core hangs
209 // Language_Pack_Upgrader::async_upgrade() on it at priority 20
210 // (wp-admin/includes/admin-filters.php), and that runs a whole
211 // upgrader of its own, which fires this same action again. A flat
212 // reset therefore fired while the OUTER dispatch was still running —
213 // on any site with pending translations — and every listener after
214 // priority 20 read the cleared signal as false. Which is the bug this
215 // pair exists to fix, back again and harder to see. (#303)
216 add_action( 'upgrader_process_complete', array( __CLASS__, 'note_upgrade_dispatch' ), PHP_INT_MIN );
217 add_action( 'upgrader_process_complete', array( __CLASS__, 'forget_cleared_destination' ), PHP_INT_MAX );
218 // WordPress labels an upload-and-replace as an INSTALL, so the action
219 // alone cannot tell "added beside nothing" from "replaced live code".
220 // This filter fires only when the upgrader removed an existing copy,
221 // which is exactly the difference. Registered as a filter listener
222 // that returns its input untouched. (#303)
223 add_filter( 'upgrader_clear_destination', array( __CLASS__, 'note_cleared_destination' ), 10, 1 );
224 // Unattended auto-updates are the case that matters most here: they
225 // land overnight with nobody around to purge by hand, which is the
226 // exact scenario the stale cache goes undiagnosed in. WordPress fires
227 // this INSTEAD of a per-item upgrader_process_complete for some
228 // background runs. Its payload is a results array keyed by type
229 // rather than a hook_extra, so it needs its own handler — passing it
230 // to purge_after_upgrade() landed it in the unused $upgrader slot and
231 // left $type empty, which read as "invalidating" and purged the whole
232 // cache for a language-pack-only run. Matches what LiteSpeed binds.
233 // (#298)
234 add_action( 'automatic_updates_complete', array( __CLASS__, 'purge_after_auto_updates' ), 10, 1 );
235 // …except the four hooks above that fire on ordinary visitor actions.
236 // Attached bare, purge_all() can't see WHAT changed, so on a store
237 // every order, every product review and every checkout
238 // account-creation wiped 100% of the cache — all anonymous happy-path
239 // actions, so the cache never reached steady state (#243). Measured:
240 // 3 orders across 36 pageviews took the hit rate from 83% to 50% and
241 // the average response from 23ms to 57ms.
242 //
243 // HPOS does NOT help: WooCommerce still writes a
244 // `shop_order_placehold` row into wp_posts to reserve the order ID,
245 // so save_post fires either way. The gate therefore keys on POST-TYPE
246 // VIEWABILITY, not on storage mode — which fixes both modes at once,
247 // and generalises to Flamingo (#229) and Tutor LMS (#231) too.
248 remove_action( 'save_post', array( __CLASS__, 'purge_all' ) );
249 remove_action( 'save_post', array( 'XSpeed\\Minifier', 'purge_minified' ) );
250 add_action( 'save_post', array( __CLASS__, 'on_save_post' ), 10, 2 );
251
252 remove_action( 'comment_post', array( __CLASS__, 'purge_all' ) );
253 remove_action( 'comment_post', array( 'XSpeed\\Minifier', 'purge_minified' ) );
254 add_action( 'comment_post', array( __CLASS__, 'on_comment_post' ), 10, 3 );
255
256 remove_action( 'user_register', array( __CLASS__, 'purge_all' ) );
257 remove_action( 'user_register', array( 'XSpeed\\Minifier', 'purge_minified' ) );
258 add_action( 'user_register', array( __CLASS__, 'on_user_change' ) );
259
260 remove_action( 'profile_update', array( __CLASS__, 'purge_all' ) );
261 remove_action( 'profile_update', array( 'XSpeed\\Minifier', 'purge_minified' ) );
262 add_action( 'profile_update', array( __CLASS__, 'on_user_change' ) );
263
264 // Product data lives in post meta and lookup tables, NOT in wp_posts,
265 // so WC_Product_Data_Store_CPT::update() takes a direct $wpdb->update()
266 // branch and save_post never fires. Anchoring invalidation on
267 // save_post therefore missed 100% of commerce-relevant mutations: a
268 // REST price change, wc_update_product_stock(), a CLI ->save(), and
269 // every scheduled sale start/end left the product page, the shop and
270 // the category archives serving the old price and stock for the full
271 // lifetime — the store quoting one price and charging another (#242).
272 //
273 // This MUST ship with the gate above: once orders stop purging
274 // everything, the accidental invalidation that was masking this
275 // disappears, and an order that reduces stock would leave the product
276 // page stale.
277 if ( class_exists( 'WooCommerce' ) ) {
278 foreach ( array( 'woocommerce_update_product', 'woocommerce_new_product' ) as $wc_hook ) {
279 add_action( $wc_hook, array( __CLASS__, 'purge_product' ) );
280 }
281 // Direct stock writes bypass the CRUD entirely.
282 add_action( 'woocommerce_product_set_stock', array( __CLASS__, 'purge_product_object' ) );
283 add_action( 'woocommerce_variation_set_stock', array( __CLASS__, 'purge_product_object' ) );
284 add_action( 'woocommerce_product_set_stock_status', array( __CLASS__, 'purge_product' ) );
285 add_action( 'woocommerce_variation_set_stock_status', array( __CLASS__, 'purge_product' ) );
286 }
287
288 add_action( 'update_option_xspeed_options', array( __CLASS__, 'on_settings_change' ), 10, 2 );
289
290 // …and the same for every PER-MODULE option. The handler above only
291 // ever watched the legacy `xspeed_options` blob, but every module has
292 // since migrated to its own `xspeed_module_<slug>` option and no hook
293 // followed — so changing Minify HTML, Lazy Load, Remove Query Strings
294 // etc. left the cached HTML untouched until the TTL expired (24h by
295 // default) and the feature read as broken. (#205)
296 //
297 // One central listener rather than a hook per module: it covers Pro
298 // modules with no cross-repo change, and a new module can't forget to
299 // wire it up.
300 add_action( 'updated_option', array( __CLASS__, 'on_module_settings_change' ), 10, 1 );
301 // `added_option` matters as much as `updated_option`: on a fresh install
302 // a module's option doesn't exist yet, so the FIRST save of every panel
303 // goes through add_option() and would otherwise skip the purge — the
304 // original bug surviving one save per module. `deleted_option` covers a
305 // reset-to-defaults, which changes rendered HTML just as much. (#205)
306 add_action( 'added_option', array( __CLASS__, 'on_module_settings_change' ), 10, 1 );
307 add_action( 'deleted_option', array( __CLASS__, 'on_module_settings_change' ), 10, 1 );
308
309 add_action( 'admin_bar_menu', array( $this, 'admin_bar_purge' ), 100 );
310 add_action( 'admin_post_xspeed_purge', array( $this, 'handle_admin_bar_purge' ) );
311 }
312
313 public static function on_settings_change( $old, $new ) {
314 // gzip_enabled moved to xspeed_module_gzip — GzipModule owns the
315 // .htaccess flip via its own update_option_xspeed_module_gzip hook.
316 // Same migration is planned for cache_expiry + excluded_urls
317 // (Cache module). Keep this handler around for whatever still
318 // lives in the legacy blob (cache_enabled is special and goes
319 // through Cache::toggle anyway).
320
321 // Any settings change — purge caches so changes take effect.
322 self::purge_all( 'settings change' );
323 Minifier::purge_minified();
324 }
325
326 /**
327 * Modules whose settings cannot change rendered HTML, so a write to them
328 * doesn't warrant throwing away the page cache.
329 *
330 * The safe default is to purge: a module is listed here only when it is
331 * clearly incapable of altering front-end output (diagnostics, the MCP
332 * server, licensing/telemetry surfaces). When in doubt, leave it off the
333 * list — a needless purge costs a re-render, a missed one makes the
334 * feature look broken. (#205)
335 *
336 * @return string[] Module slugs.
337 */
338 public static function non_rendering_modules(): array {
339 return (array) apply_filters(
340 'xspeed_non_rendering_modules',
341 array(
342 'mcp', // AI endpoint — no front-end output.
343 'health', // diagnostics only.
344 'support', // support snapshot.
345 'score', // PageSpeed/GTmetrix runner.
346 'migration', // one-shot importer.
347 'settings', // import/export surface.
348 'cache-coverage', // read-only reporting.
349 'ai-privacy', // consent flags for AI surfaces.
350 'database', // DB cleanup schedule — no HTML impact.
351 // Pro slugs — listed by name rather than by asking Pro, so
352 // Free stays unaware of it. A Pro module absent here simply
353 // purges, which is the safe default.
354 'license',
355 'pro_status',
356 'analytics',
357 'performance-health',
358 'recommendations',
359 'ai-provider',
360 'migration-pro',
361 )
362 );
363 }
364
365 /**
366 * Purge when ANY module's settings option is written. (#205)
367 *
368 * Bound to `updated_option`, `added_option` and `deleted_option` — all three
369 * fire for every option on the site, so the prefix test comes first and is
370 * the cheap path for the ~99% of writes that aren't ours. All three pass the
371 * option name first, which is why this can't hook purge_all() directly:
372 * that takes $cause first, so every purge would be filed under a cause
373 * literally named "xspeed_module_minify".
374 *
375 * @param string $option Option name that was just written or removed.
376 */
377 public static function on_module_settings_change( $option ): void {
378 $option = (string) $option;
379 $prefix = Settings_Manager::OPTION_PREFIX;
380 if ( 0 !== strpos( $option, $prefix ) ) {
381 return;
382 }
383
384 $slug = substr( $option, strlen( $prefix ) );
385 if ( '' === $slug || in_array( $slug, self::non_rendering_modules(), true ) ) {
386 return;
387 }
388
389 // Guard against re-entry: purge_all() and purge_minified() can write
390 // options of their own (stats, timestamps), and a nested purge would
391 // both waste work and risk recursing through this same hook.
392 static $purging = false;
393 if ( $purging ) {
394 return;
395 }
396 $purging = true;
397
398 self::purge_all( 'settings change' );
399 Minifier::purge_minified();
400
401 $purging = false;
402 }
403
404 /**
405 * Stamp the request's cache decision on the response.
406 *
407 * `X-XSpeed-Cache` was only ever written on the serve-from-cache paths,
408 * so a miss and a deliberate bypass both came back with no header at all
409 * — indistinguishable from a `curl -I`, the first thing anyone reaches
410 * for when a site "isn't caching" (issue #10). The reason slug rides
411 * along on `X-XSpeed-Reason`, but only under WP_DEBUG so production
412 * responses stay clean. Slugs are fixed per gate — never the matched
413 * pattern, cookie or user-agent, which would echo request input back.
414 *
415 * @param string $value HIT (php) | MISS | BYPASS.
416 * @param string $reason Fixed slug naming the gate, for BYPASS only.
417 */
418 private static function mark( string $value, string $reason = '' ): void {
419 self::$status_header = $value;
420 self::$bypass_reason = $reason;
421
422 if ( headers_sent() ) {
423 return;
424 }
425 header( 'X-XSpeed-Cache: ' . $value );
426 if ( '' !== $reason && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
427 header( 'X-XSpeed-Reason: ' . $reason );
428 }
429 }
430
431 /** Record a bypass gate and answer "don't cache" in one statement. */
432 private static function bypass( string $reason ): bool {
433 self::mark( 'BYPASS', $reason );
434 return false;
435 }
436
437 /** The X-XSpeed-Cache value decided for this request ('' if none yet). */
438 public static function status_header(): string {
439 return self::$status_header;
440 }
441
442 /** The bypass gate slug for this request ('' unless BYPASS). */
443 public static function bypass_reason(): string {
444 return self::$bypass_reason;
445 }
446
447 /**
448 * Bypass gates that describe THE VISITOR rather than THIS REQUEST.
449 *
450 * Only these may be recorded in the bypass cookie. A visitor-scoped
451 * verdict stays true for the visitor's next request — they are still
452 * logged in, still hold a cart cookie — so the web server can act on
453 * it without booting PHP.
454 *
455 * Every other gate describes the request in front of us: its method,
456 * its URL, its query string, the client's user agent. Persisting one
457 * of those pins a visitor to the uncached path over a property that
458 * was never theirs to begin with. (#218)
459 */
460 private const VISITOR_SCOPED_BYPASS = array( 'logged-in', 'excluded-cookie' );
461
462 /**
463 * Whether $reason describes the visitor (persist it) or merely this
464 * request (don't).
465 *
466 * Split out as a pure function because it is the whole decision behind
467 * the bypass cookie, and the cookie write itself (setcookie()) can't be
468 * asserted in a unit test.
469 */
470 public static function bypass_is_visitor_scoped( string $reason ): bool {
471 return in_array( $reason, self::VISITOR_SCOPED_BYPASS, true );
472 }
473
474 public function maybe_start_cache() {
475 if ( ! self::should_cache() ) {
476 // PHP has just evaluated the FULL exclusion rule list — including
477 // the `~regex` patterns the server config can't express — and
478 // decided this response must not be served from cache. Record that
479 // verdict in the conventional bypass cookie so the web server can
480 // enforce it on subsequent requests without starting PHP.
481 //
482 // This is what stops most settings changes from needing an nginx
483 // reload: the config tests one fixed cookie name forever, and the
484 // rule list behind it can change freely.
485 //
486 // But ONLY when the verdict is about the visitor. A request-shape
487 // gate — `non-get` above all — says nothing about who is asking,
488 // and persisting it pinned that visitor to the uncached path for
489 // the rest of their session: one search-form POST, one comment,
490 // one `curl -I` from an uptime monitor, and every later GET
491 // bypassed. It could not self-heal either, because the bypass
492 // cookie is itself in excluded_cookies, so the next GET bypassed
493 // with `excluded-cookie` and landed right back here, where
494 // sync_bypass_cookie()'s no-change short-circuit left the cookie
495 // exactly where it was. (#218)
496 if ( self::bypass_is_visitor_scoped( self::bypass_reason() ) ) {
497 self::sync_bypass_cookie( true );
498 }
499 return;
500 }
501
502 // Cacheable: clear any stale bypass cookie, or a visitor who once
503 // had a cart would keep skipping the fast path long after checkout.
504 self::sync_bypass_cookie( false );
505
506 $key = self::cache_key();
507 $file = self::cache_file_for( $key );
508
509 if ( file_exists( $file ) && ! self::is_expired( $file ) ) {
510 Hit_Counter::record_hit();
511 // Emit the HIT marker on THIS path too. The drop-in
512 // (advanced-cache.php) sends "HIT (php)" and the nginx static
513 // rewrite sends "HIT (nginx)", but this template_redirect
514 // serve path — the one that runs when the drop-in isn't loaded
515 // (e.g. WP_CACHE not true) — previously streamed the cached
516 // file with NO marker, so a genuine HIT looked like a MISS in
517 // the response headers. Same header + value as the drop-in.
518 self::mark( 'HIT (php)' );
519 // Replay stored response bits so the HIT matches the original:
520 // a non-HTML Content-Type (cached feeds, sitemaps) and a non-200
521 // status (a cached 404 must serve 404, not 200). No-op for
522 // ordinary pages, which write no .meta.
523 $meta = self::read_meta( $key );
524 if ( ! headers_sent() ) {
525 if ( ! empty( $meta['status'] ) && function_exists( 'http_response_code' ) ) {
526 http_response_code( (int) $meta['status'] );
527 }
528 if ( ! empty( $meta['content_type'] ) && is_string( $meta['content_type'] ) ) {
529 header( 'Content-Type: ' . $meta['content_type'] );
530 }
531 // Conditional GET: emit Last-Modified + ETag and answer a
532 // matching If-Modified-Since / If-None-Match with 304 so
533 // aggregators (and browsers) skip re-downloading an unchanged
534 // cached response — the bandwidth win feeds are about.
535 // (FBS-82407 #5)
536 if ( self::serve_not_modified( $file ) ) {
537 exit; // 304 sent, no body.
538 }
539 }
540 // Serve the precompressed Brotli sibling when the client accepts
541 // it (an add-on, the Pro Brotli module, wrote <file>.br). On this
542 // PHP serve path the web server never sees the .br, so without
543 // this a br-capable client got the plain .html — precompression
544 // did nothing here. Falls through to plain readfile otherwise.
545 $br = self::maybe_serve_brotli( $file );
546 if ( null !== $br ) {
547 // 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.
548 readfile( $br );
549 exit;
550 }
551 // 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.
552 readfile( $file );
553 exit;
554 }
555
556 // Cache miss → render fresh + write cache. On LiteSpeed we send an
557 // explicit "stand down" header so the server's LSCache module does
558 // NOT cache + shadow our response — xSpeed's own .htaccess static
559 // rewrite owns hit serving (and hit accounting) here, exactly as on
560 // Apache. See maybe_emit_lscache_headers() for the full rationale.
561 self::maybe_emit_lscache_headers();
562
563 // We're about to render fresh + cache → miss for this request.
564 // …UNLESS this request is a 404 or a known bot/scanner. Those reach the
565 // render path too, but counting them as cache misses makes the ratio
566 // meaningless — a wave of `/wp-x7.php` scanner 404s reads as a collapsing
567 // cache when nothing is wrong. Runs at template_redirect (priority 0), so
568 // is_404() is already resolved. Excluded requests are tallied separately
569 // for the "you absorbed N scanner hits" line, not dropped. (#118)
570 if ( self::miss_is_excluded() ) {
571 Hit_Counter::record_excluded();
572 } else {
573 Hit_Counter::record_miss();
574 }
575
576 // Stamp it, so "eligible but not cached yet" is visibly different
577 // from "deliberately bypassed" (issue #10). Headers can't be sent
578 // after the body starts, so this has to happen here, not in
579 // finalize_buffer() — nothing has been output at template_redirect.
580 self::mark( 'MISS' );
581
582
583 // WP < 6.9 fallback: ob_start() with a callback, paired with an
584 // explicit shutdown close so the buffer lifecycle is visible to
585 // reviewers and Plugin Check, instead of relying on PHP's implicit
586 // request-end flush. We record our nesting level so close_buffer()
587 // flushes ONLY the buffer we opened.
588 ob_start( array( __CLASS__, 'finalize_buffer' ) );
589 self::$buffer_level = ob_get_level();
590
591 add_action( 'shutdown', array( __CLASS__, 'close_buffer' ), 0 );
592 }
593
594 /**
595 * Close the cache buffer opened by maybe_start_cache().
596 *
597 * Guarded by the recorded buffer level so we never flush a buffer that
598 * another plugin pushed on top of (or under) ours. If something else is
599 * currently on top, we leave the stack alone — PHP's shutdown sequence
600 * will unwind buffers in order and our finalize_buffer() callback will
601 * still run when our level becomes the topmost one.
602 */
603 public static function close_buffer() {
604 if ( null === self::$buffer_level ) {
605 return;
606 }
607 if ( ob_get_level() === self::$buffer_level ) {
608 ob_end_flush();
609 }
610 self::$buffer_level = null;
611 }
612
613 /**
614 * Are we buffering this request?
615 *
616 * Asked by Css_Combine_Buffer, which needs the finished HTML but must not
617 * open a second buffer when this one is already going to hand it the page
618 * through `xspeed_cache_final_html`. False here means the request is not
619 * cacheable — cache off, excluded URL, logged in — and the combiner has to
620 * provide its own buffer or it silently stops working. (#195)
621 */
622 public static function is_buffering(): bool {
623 return null !== self::$buffer_level;
624 }
625
626 /**
627 * Is a render-time translation plugin going to wrap our output buffer?
628 *
629 * TranslatePress opens its translation buffer on `init` priority 0. We
630 * open ours on `template_redirect`, which runs much later, so ours nests
631 * INSIDE theirs. PHP unwinds output buffers LIFO — innermost callback
632 * first — so `finalize_buffer()` saw the raw, pre-translation HTML and
633 * cached that, while the live visitor still got the translated bytes from
634 * TRP's outer buffer.
635 *
636 * Result: the first (MISS) visitor to /fr/some-page/ got correct French;
637 * every visitor after got English body text under a `lang="fr-FR"`
638 * document, plus TRP's internal `#TRPLINKPROCESSED` link markers, which
639 * TRP strips at the very end of its own buffer and which therefore leak
640 * into anything captured from inside it.
641 *
642 * Note the ordering cannot be fixed from TRP's side: its
643 * `trp_start_output_buffer_priority` filter only moves the PRIORITY on
644 * `init`, and `init` always fires before `template_redirect` whatever the
645 * priority. The buffer that has to move is ours.
646 *
647 * Detected by main class rather than plugin path, so a renamed directory
648 * or a bundled copy still matches.
649 */
650 public static function translation_plugin_active(): bool {
651 $active = class_exists( 'TRP_Translate_Press' );
652
653 /**
654 * Whether to treat this request as wrapped by a translation buffer.
655 *
656 * Lets a site add another render-time translation plugin (or opt out)
657 * without patching the engine.
658 *
659 * @param bool $active
660 */
661 return (bool) apply_filters( 'xspeed_translation_plugin_active', $active );
662 }
663
664 /**
665 * Write the cache file for a request whose output was wrapped by a
666 * render-time translation plugin.
667 *
668 * Registered as a PHP shutdown function (not a WP `shutdown` action) so
669 * it runs after PHP has unwound the output-buffer stack — by which point
670 * the translation plugin's callback has transformed the bytes and its
671 * internal markers are gone.
672 *
673 * finalize_buffer() has already applied the status gate, the
674 * xspeed_cache_final_html filter and HTML minification to the
675 * untranslated copy and then declined to write it. Here we re-run only
676 * what's needed on the translated bytes: minify, write, and fire the
677 * same downstream hooks so Brotli / static-tree listeners behave
678 * identically to the ordinary path.
679 */
680 public static function write_deferred_translated_cache(): void {
681 $key = self::$deferred_key;
682 self::$deferred_key = null;
683
684 // Release the collected bytes BEFORE the early return, so the static
685 // is cleared on every path rather than only when a key survived.
686 $full = self::$translated_output;
687 self::$translated_output = '';
688
689 $completed = self::$render_completed;
690 self::$render_completed = false;
691
692 if ( null === $key ) {
693 return;
694 }
695
696 // Did the render actually finish?
697 //
698 // This runs as a PHP shutdown function, which fires after a wp_die()
699 // or a bare exit() just as readily as after a clean render — but in
700 // those cases finalize_buffer() never returned, so the bytes we hold
701 // are a page that was cut off partway through. The length and
702 // TRPLINKPROCESSED checks below don't catch that: a fatal after the
703 // footer's translated markup is both over 255 bytes and free of TRP
704 // markers, i.e. truncated but entirely plausible. Caching it would
705 // freeze a half-rendered page under the real key for the full TTL.
706 //
707 // Serving this one URL uncached is the cheap failure; the corrupt
708 // cache entry is the expensive one.
709 if ( ! $completed ) {
710 return;
711 }
712
713 if ( strlen( $full ) < 255 ) {
714 return;
715 }
716
717 // Refuse to cache a copy still carrying the translation plugin's
718 // internal link markers. TRP strips these at the very end of its own
719 // buffer, so their presence means we captured too early — and a
720 // cached page containing them is SEO-visible damage. Better to serve
721 // this URL uncached than to freeze broken markup for the full TTL.
722 if ( false !== strpos( $full, 'TRPLINKPROCESSED' ) ) {
723 return;
724 }
725
726 $minify_opts = Settings_Manager::get( 'minify' );
727 if ( ! empty( $minify_opts['minify_html'] ) ) {
728 $full = Minifier::minify_html( $full );
729 }
730 $full = self::signed( $full );
731
732 // Per-site directory: on multisite every blog shares this tree, so
733 // entries are bucketed by host to keep one site's purge from
734 // sweeping the whole network. (#6)
735 self::ensure_host_dir();
736
737 // Never author a cache entry from a request that carried a query
738 // string: cache_key() files it under the BARE url, so the params'
739 // render would be served to every clean-URL visitor (#241).
740 if ( self::query_string_blocks_write() ) {
741 return;
742 }
743
744 $file = self::cache_file_for( $key );
745 // 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.
746 file_put_contents( $file, $full, LOCK_EX );
747
748 /** This action is documented in includes/class-cache.php */
749 do_action( 'xspeed_flat_file_written', $file, $full );
750
751 self::write_meta( $key, $full );
752
753 // Static tree too, under the same gates finalize_buffer() applies —
754 // otherwise deferring the write would silently cost translated pages
755 // the web-server fast path and leave them on the slower drop-in.
756 if ( self::static_rewrite_allowed() && self::response_is_plain_html() ) {
757 self::store_static( $full );
758 }
759 }
760
761 public static function should_cache() {
762 // Reset first: a single request only reaches this once (the sole
763 // caller is maybe_start_cache()), but tests and any future caller
764 // must never inherit the previous request's verdict.
765 self::$status_header = '';
766 self::$bypass_reason = '';
767
768 $opts = Settings::get();
769 if ( empty( $opts['cache_enabled'] ) ) {
770 return self::bypass( 'cache-disabled' );
771 }
772
773 if ( is_user_logged_in() ) {
774 return self::bypass( 'logged-in' );
775 }
776
777 if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
778 return self::bypass( 'non-frontend' );
779 }
780
781 if ( defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE ) {
782 return self::bypass( 'donotcachepage' );
783 }
784
785 // All exclusion knobs now owned by CacheModule.
786 $cache_opts = Settings_Manager::get( 'cache' );
787
788 $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : '';
789 if ( 'GET' !== $method ) {
790 return self::bypass( 'non-get' );
791 }
792
793 // Search-results requests carry a `s` query param, which the
794 // query-string gate below would normally reject as "dynamic". An
795 // add-on (xspeed-pro search cache) can opt them in: when this is a
796 // genuine is_search() and the filter returns true, the `s` param is
797 // treated as cacheable (the search term goes into the cache key so
798 // different searches stay distinct — see cache_key()).
799 $cache_search = self::should_cache_search();
800
801 // Feed opt-in is resolved BEFORE the query-string gate so query-form
802 // feeds (/?feed=rss2, used on plain-permalink sites) aren't rejected
803 // as "dynamic" by that gate — the `feed` param is then allowed through
804 // just like the search `s` param. Feeds are excluded by default (the
805 // `/feed/` pattern in excluded_urls); an add-on (xspeed-pro feed cache)
806 // opts them back in via the filter. (FBS-82407 #4)
807 $is_feed_request = function_exists( 'is_feed' ) && is_feed();
808 /**
809 * Whether to cache the current feed request.
810 *
811 * Default false → feeds fall through to the normal URL-exclusion
812 * rules (so `/feed/` keeps them out). A listener returning true
813 * opts this feed request into caching.
814 *
815 * @param bool $cache_feed Whether to cache this feed request.
816 */
817 $cache_feed = $is_feed_request && (bool) apply_filters( 'xspeed_should_cache_feed', false );
818
819 // Query string handling: anything OUTSIDE the ignored-params
820 // allow-list (utm_*, fbclid, gclid by default) means a unique
821 // request that we don't want to share with the canonical cache
822 // entry. Skip cache rather than poison the key.
823 //
824 // Parse the RAW query string, NOT a sanitize_text_field() copy:
825 // that filter strips percent-encoded octets (%XX), so `?%73=…`
826 // would lose its `s` key here while WordPress still decodes it to
827 // a search request — the gate would wave the request through and
828 // cache_key() would file the search page under the bare URL,
829 // letting an attacker poison the homepage cache with `/?%73=<spam>`.
830 // parse_str() does its own urldecoding, matching WP's own parse, and
831 // only the KEYS are used below (fed to Glob_Matcher → preg_match,
832 // never echoed or executed), so no sanitization is needed here.
833 $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.
834 if ( '' !== $query_raw ) {
835 $ignored = is_array( $cache_opts['ignored_query_params'] ?? null ) ? $cache_opts['ignored_query_params'] : array();
836 parse_str( $query_raw, $params );
837 foreach ( $params as $key => $_ ) {
838 // Allow the search param through when search caching is on.
839 if ( $cache_search && 's' === $key ) {
840 continue;
841 }
842 // Allow query-form feed params through when feed caching opted
843 // this request in (?feed=rss2 / &withcomments=1 on feeds).
844 if ( $cache_feed && in_array( $key, array( 'feed', 'withcomments', 'withoutcomments' ), true ) ) {
845 continue;
846 }
847 if ( ! self::query_key_is_ignored( (string) $key, $ignored ) ) {
848 // Slug only — never the param name, which is attacker-
849 // controlled and would be reflected into a header.
850 return self::bypass( 'query-param' );
851 }
852 }
853 }
854
855 $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
856 $path = (string) strtok( $request_uri, '?' );
857
858 $excluded_urls = is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array();
859 if ( ! $cache_feed && Glob_Matcher::any_match( $excluded_urls, $path ) ) {
860 return self::bypass( 'excluded-url' );
861 }
862
863 // Cookie-based exclusion. We only check cookie NAMES (matching
864 // values would leak content-sensitive logic into the cache key
865 // rules); presence of any matching cookie name skips cache.
866 $excluded_cookies = is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array();
867 if ( ! empty( $excluded_cookies ) && ! empty( $_COOKIE ) ) {
868 foreach ( array_keys( $_COOKIE ) as $cookie_name ) {
869 // Our own bypass cookie is a RECORD of a previous verdict, not
870 // evidence about this visitor, so it never gets a vote here.
871 // Letting it match made the verdict self-confirming: once set,
872 // it produced `excluded-cookie` forever, which re-set it, and
873 // no later request could ever re-evaluate the visitor on the
874 // rules that actually describe them. The web server still acts
875 // on the cookie without booting PHP; when PHP does boot it is
876 // authoritative and re-decides from scratch. (#218)
877 if ( Server_Rules::BYPASS_COOKIE === $cookie_name ) {
878 continue;
879 }
880 if ( Glob_Matcher::any_match( $excluded_cookies, (string) $cookie_name ) ) {
881 return self::bypass( 'excluded-cookie' );
882 }
883 }
884 }
885
886 // User-agent bypass list. Substring match (not glob) since UA
887 // strings have so much variation that glob anchoring rarely
888 // helps and confuses users.
889 $bypass_uas = is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array();
890 if ( ! empty( $bypass_uas ) ) {
891 $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
892 foreach ( $bypass_uas as $needle ) {
893 if ( '' !== $needle && false !== stripos( $ua, (string) $needle ) ) {
894 return self::bypass( 'user-agent' );
895 }
896 }
897 }
898
899 // Per-post override (Phase 3.4). Honored only on singular
900 // post-context requests — archives / 404s / taxonomies use the
901 // global policy above.
902 if ( Cache_Rules::should_skip_for_post( Cache_Rules::current_post_id() ) ) {
903 return self::bypass( 'post-excluded' );
904 }
905
906 /**
907 * Final say on whether the current request is cacheable.
908 *
909 * Runs at template_redirect (full WP context), so listeners may use
910 * conditional tags (is_search(), is_feed(), is_404(),
911 * wp_is_maintenance_mode(), …). The core engine has already applied
912 * its own exclusion rules and reached `true`; a listener returning
913 * false vetoes caching for this request. This is the documented
914 * extension point add-ons (xspeed-pro) hook to add their own
915 * request-level cache policy without forking the engine.
916 *
917 * Note: this gates the WRITE side. The pre-WP drop-in
918 * (advanced-cache.php) cannot run PHP filters, so request types that
919 * must never be *served* from a stale file are handled by not
920 * writing them here and/or by purging — see the conflict notes in
921 * advanced-cache.php.
922 *
923 * @param bool $should_cache Whether to cache the current request.
924 */
925 if ( ! apply_filters( 'xspeed_should_cache', true ) ) {
926 // One slug for every listener — a third-party callback name is
927 // not ours to put in a response header. Which listener vetoed is
928 // a WP_DEBUG-level question the filter itself can answer.
929 return self::bypass( 'filtered' );
930 }
931
932 return true;
933 }
934
935 /**
936 * Whether the current request is a 404 we may cache.
937 *
938 * True only when: it's a genuine main-query is_404(), an add-on opted
939 * in via `xspeed_should_cache_404` (default false), and the request
940 * isn't a transient 404 we must never freeze — maintenance mode or a
941 * 404 emitted while the DB/site is in an error state. The xspeed-pro
942 * 404 cache flips the filter; Free never caches 404s on its own.
943 */
944 public static function should_cache_404(): bool {
945 if ( ! function_exists( 'is_404' ) || ! is_404() ) {
946 return false;
947 }
948 // Never cache a 404 served because the site is down for
949 // maintenance — that screen disappears the moment maintenance
950 // ends, and a cached copy would outlive it.
951 if ( function_exists( 'wp_is_maintenance_mode' ) && wp_is_maintenance_mode() ) {
952 return false;
953 }
954
955 /**
956 * Whether to cache the current 404 response.
957 *
958 * Default false. A listener returning true opts the (genuine)
959 * 404 into the page cache, served back for any unknown URL under
960 * one generic key. The 404 status is preserved on the HIT.
961 *
962 * @param bool $cache_404 Whether to cache this 404.
963 */
964 return (bool) apply_filters( 'xspeed_should_cache_404', false );
965 }
966
967 /**
968 * Whether the current request is an internal search-results page we
969 * may cache.
970 *
971 * True only when: it's a genuine main-query is_search() with a
972 * non-empty term, and an add-on opted in via `xspeed_should_cache_search`
973 * (default false). The search term is folded into the cache key (see
974 * search_term() / cache_key()) so different searches stay distinct.
975 * The xspeed-pro search cache flips the filter; Free never caches
976 * search results on its own.
977 */
978 /**
979 * Whether this response was rendered for a query string and therefore
980 * must not be STORED under the bare-URL key.
981 *
982 * should_cache() lets a request through when every key is on the
983 * `ignored_query_params` allow-list, and cache_key() then drops the
984 * query string so `/post` and `/post?utm_source=x` share one entry.
985 * Sharing on READ is the point of the allow-list and stays. Sharing on
986 * WRITE is a cache-poisoning vector: the response was rendered *with*
987 * those params, and WordPress reflects REQUEST_URI into form actions,
988 * share links, canonical helpers and plugin smart tags. One anonymous
989 * GET to a cold URL therefore freezes an attacker-chosen variant under
990 * the clean URL's key, served for the whole TTL by the drop-in and by
991 * the web server — neither of which runs these checks (issue #241).
992 *
993 * The allow-list keeps its benefit: a visitor arriving on
994 * `?utm_source=…` is still SERVED the canonical cached entry. Only the
995 * write is skipped, so the entry is authored by a clean request.
996 *
997 * This is the same reasoning as the `should_cache_search()` guard in
998 * store_static() (#191), generalised to the allow-listed params.
999 */
1000 public static function request_has_query_string(): bool {
1001 $query = isset( $_SERVER['QUERY_STRING'] )
1002 ? (string) wp_unslash( $_SERVER['QUERY_STRING'] ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- only tested for emptiness; never echoed, stored or used as a path.
1003 : '';
1004
1005 return '' !== trim( $query );
1006 }
1007
1008 /**
1009 * Would authoring a cache entry from THIS request file a query-string
1010 * render under the bare URL?
1011 *
1012 * The one predicate both write sites ask, so they cannot drift.
1013 *
1014 * Two shapes are exempt because cache_key() does NOT drop their query —
1015 * it folds the distinguishing part into the key, so each variant gets
1016 * its own entry and none is filed under the bare URL:
1017 *
1018 * - searches, keyed by `|s=<term>` (#191)
1019 * - feeds, keyed by `|feed=<type>` — `/?feed=rss2` is the ONLY feed URL
1020 * core generates on plain permalinks, so treating it as poisonable
1021 * made feed caching a no-op on exactly the sites that need it
1022 *
1023 * @return bool True when the write must be skipped.
1024 */
1025 public static function query_string_blocks_write(): bool {
1026 if ( ! self::request_has_query_string() ) {
1027 return false;
1028 }
1029
1030 if ( self::should_cache_search() ) {
1031 return false;
1032 }
1033
1034 // Feed caching is opt-in, via the same filter should_cache() reads
1035 // to admit the feed params in the first place.
1036 if ( function_exists( 'is_feed' ) && is_feed()
1037 && (bool) apply_filters( 'xspeed_should_cache_feed', false )
1038 ) {
1039 return false;
1040 }
1041
1042 return true;
1043 }
1044
1045 public static function should_cache_search(): bool {
1046 if ( ! function_exists( 'is_search' ) || ! is_search() ) {
1047 return false;
1048 }
1049 // Empty search (`?s=`) renders the same as a normal archive and
1050 // carries no term to key on — let it fall through to the usual
1051 // rules rather than caching an ambiguous entry.
1052 if ( '' === self::search_term() ) {
1053 return false;
1054 }
1055
1056 /**
1057 * Whether to cache the current search-results request.
1058 *
1059 * Default false. A listener returning true opts the search page
1060 * into the cache, keyed by the normalized search term.
1061 *
1062 * @param bool $cache_search Whether to cache this search request.
1063 */
1064 return (bool) apply_filters( 'xspeed_should_cache_search', false );
1065 }
1066
1067 /**
1068 * The current request's normalized search term, or '' if none. Reads
1069 * the raw `s` query param (works on the pre-WP drop-in path too, where
1070 * get_search_query() isn't available), trims + lowercases so
1071 * "WordPress" and "wordpress" share one entry, and collapses internal
1072 * whitespace.
1073 */
1074 public static function search_term(): string {
1075 $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.
1076 $raw = trim( $raw );
1077 if ( '' === $raw ) {
1078 return '';
1079 }
1080 $raw = preg_replace( '/\s+/', ' ', $raw );
1081 return function_exists( 'mb_strtolower' ) ? mb_strtolower( $raw ) : strtolower( $raw );
1082 }
1083
1084 /**
1085 * Is this query-string key on the ignored-params allow-list? Supports
1086 * globs (`utm_*` matches `utm_source`, `utm_medium`, etc.) so users
1087 * don't have to enumerate every UTM variant, and `~regex`.
1088 *
1089 * Matching is whole-name, not "contains" — a param name is an
1090 * identifier, not a path. Under the old contains match the shipped
1091 * default `ref` also swallowed `preference`, `product_ref` and
1092 * `referrer`: those params were dropped from the cache key, so
1093 * `/shop?preference=1` was served — and, on a cold entry, WRITTEN as —
1094 * `/shop`. Same for `_ga` vs `_gallery`, and for the unanchored
1095 * `~utm_…` default vs `my_utm_source`. A param name that is genuinely
1096 * unknown now bypasses the cache, which is the safe direction.
1097 */
1098 private static function query_key_is_ignored( string $key, array $ignored ): bool {
1099 return Glob_Matcher::any_match_name( $ignored, $key );
1100 }
1101
1102 public static function cache_key() {
1103 $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : 'default';
1104
1105 // Cacheable 404s share ONE generic per-host entry — keying them by
1106 // URL would let a scanner flood (millions of random paths) bloat
1107 // the cache with identical 404 bodies. Both the write and the HIT
1108 // lookup run through here, so they agree on the key automatically.
1109 if ( self::should_cache_404() ) {
1110 return md5( $host . '|404' );
1111 }
1112
1113 $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/';
1114 // Strip the query string from the key so /post and /post?utm_*=…
1115 // share the same cache entry. should_cache() above already
1116 // rejected requests with non-ignored params, so by the time we
1117 // build the key the only params left are safe to drop.
1118 $uri = (string) strtok( $uri, '?' );
1119
1120 // Optional device bucket: when mobile_separate is on, mobile and
1121 // desktop responses live in different cache files so themes that
1122 // serve different HTML by device (AMP, WPtouch, Jetpack mobile)
1123 // can't poison each other.
1124 $device = '';
1125 $opts = Settings_Manager::get( 'cache' );
1126 if ( ! empty( $opts['mobile_separate'] ) ) {
1127 $device = self::is_mobile_request() ? '|m' : '|d';
1128 }
1129
1130 // Search-results requests fold the normalized term into the key so
1131 // /?s=foo and /?s=bar get distinct entries (the query string is
1132 // otherwise stripped above). Only added when search caching opted
1133 // in, so non-search URLs are unaffected.
1134 $search = self::should_cache_search() ? '|s=' . self::search_term() : '';
1135
1136 // Query-form feeds (/?feed=rss2 vs /?feed=atom) share the same path
1137 // once the query is stripped, so fold the feed type into the key to
1138 // keep the flavors distinct. Pretty-permalink feeds (/feed/rss/) carry
1139 // the type in $uri already and are unaffected. (FBS-82407 #4)
1140 $feed = '';
1141 if ( function_exists( 'is_feed' ) && is_feed() && function_exists( 'get_query_var' ) ) {
1142 $feed_type = (string) get_query_var( 'feed' );
1143 if ( '' !== $feed_type ) {
1144 $feed = '|feed=' . preg_replace( '/[^a-z0-9]/i', '', $feed_type );
1145 }
1146 }
1147
1148 return md5( $host . $uri . $device . $search . $feed );
1149 }
1150
1151 /**
1152 * Server-side mobile detection. Prefers WordPress's `wp_is_mobile()`
1153 * which uses the same UA tokens as core (so our bucket aligns with
1154 * whatever theme-side branching uses). Falls back to a tiny inline
1155 * detector if wp_is_mobile() isn't loaded (e.g. the drop-in path).
1156 */
1157 private static function is_mobile_request(): bool {
1158 if ( function_exists( 'wp_is_mobile' ) ) {
1159 return (bool) wp_is_mobile();
1160 }
1161 // Fallback for the rare context where wp_is_mobile() isn't loaded.
1162 // Mirrors core's wp_is_mobile() EXACTLY — including the
1163 // Sec-CH-UA-Mobile client hint it checks *before* UA tokens — so the
1164 // bucket this picks matches whatever the engine's primary path (and
1165 // the drop-in's own copy of this logic) would pick for the same
1166 // request. Drift here re-introduces the cross-path key mismatch.
1167 if ( isset( $_SERVER['HTTP_SEC_CH_UA_MOBILE'] ) ) {
1168 return '?1' === $_SERVER['HTTP_SEC_CH_UA_MOBILE'];
1169 }
1170 $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
1171 if ( '' === $ua ) {
1172 return false;
1173 }
1174 return (bool) preg_match( '/(Mobile|Android|Silk\/|Kindle|BlackBerry|Opera Mini|Opera Mobi)/i', $ua );
1175 }
1176
1177 /**
1178 * Filesystem-safe directory name for a host, or '' when unusable.
1179 *
1180 * The charset MUST match the static tree (store_static()) and the
1181 * drop-in's own copy, or the paths disagree about where an entry lives.
1182 * The colon of `host:port` is stripped: it is legal in a Host header but
1183 * not portable in a path.
1184 *
1185 * @param string $host Raw host, e.g. from HTTP_HOST.
1186 * @return string Safe directory segment, or '' if nothing usable remains.
1187 */
1188 /**
1189 * The host segment of the STATIC tree — `xspeed-static/<host>/…`, which
1190 * the web server resolves without PHP.
1191 *
1192 * Different from host_dir(): here the port is folded INTO the segment
1193 * (`localhost:8080` → `localhost8080`) rather than dropped, because the
1194 * generated server rules have to reproduce this from their own variables
1195 * and nginx's `$host` has no port to drop — see the `$xspeed_host`
1196 * derivation in nginx_snippet(). Shared by the write and the purge so the
1197 * two can't drift; when they did, purging a page on a ported host deleted
1198 * nothing and the stale copy kept being served by the rewrite.
1199 */
1200 public static function static_host_dir( string $host ): string {
1201 return (string) preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host );
1202 }
1203
1204 public static function host_dir( string $host ): string {
1205 $host = str_replace( "\0", '', $host );
1206 // Drop the port BEFORE filtering, or `example.com:8080` collapses to
1207 // `example.com8080` — which both loses the boundary and could collide
1208 // with a real host of that name.
1209 $colon = strpos( $host, ':' );
1210 if ( false !== $colon ) {
1211 $host = substr( $host, 0, $colon );
1212 }
1213 $host = preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host );
1214 // Collapse any run of dots so no traversal sequence can survive the
1215 // charset filter (`a/../b` would otherwise reduce to `a..b`).
1216 $host = preg_replace( '/\.{2,}/', '.', (string) $host );
1217 $host = trim( (string) $host, '.-' );
1218 return '' === $host ? '' : $host;
1219 }
1220
1221 /**
1222 * The per-site bucket a cache entry belongs to: `<host>` on a single
1223 * site, `<host>/<path-prefix>` for a subdirectory multisite blog.
1224 *
1225 * On multisite every blog shares one cache directory, and a flat md5
1226 * filename carries no clue which site wrote it — so purging one subsite
1227 * swept the whole network cold. (#6)
1228 *
1229 * Host alone is NOT enough: a subdirectory network (the common layout)
1230 * puts every blog on the same host, so `example.com/` and
1231 * `example.com/siteb/` would share a bucket and keep purging each other.
1232 * The path prefix is what separates them, and it is derivable from the
1233 * REQUEST_URI alone — which matters because the drop-in must compute
1234 * this identical value before WordPress (and get_blog_details()) exist.
1235 *
1236 * Subdomain and domain-mapped networks differ by host already, so they
1237 * get a bare host bucket and are unaffected.
1238 *
1239 * @param string $host Raw host.
1240 * @param string $uri Raw REQUEST_URI (query string is ignored).
1241 * @return string Bucket path, always non-empty.
1242 */
1243 public static function site_bucket( string $host, string $uri ): string {
1244 $dir = self::host_dir( $host );
1245 if ( '' === $dir ) {
1246 $dir = 'default';
1247 }
1248
1249 $prefix = self::site_path_prefix();
1250 return '' === $prefix ? $dir : $dir . '/' . $prefix;
1251 }
1252
1253 /**
1254 * The current blog's path prefix as a single safe segment ('' for the
1255 * root blog or a non-multisite install). `/siteb/` becomes `siteb`;
1256 * a nested `/a/b/` becomes `a-b` so the bucket stays one level deep.
1257 *
1258 * Written to a sidecar for the drop-in by sync_site_paths().
1259 */
1260 public static function site_path_prefix(): string {
1261 if ( ! function_exists( 'is_multisite' ) || ! is_multisite() ) {
1262 return '';
1263 }
1264 if ( function_exists( 'is_subdomain_install' ) && is_subdomain_install() ) {
1265 return ''; // Hosts already differ; no prefix needed.
1266 }
1267 $path = function_exists( 'get_blog_details' ) ? (string) get_blog_details()->path : '/';
1268 return self::path_prefix_segment( $path );
1269 }
1270
1271 /**
1272 * The bucket an arbitrary URL's cache entry lives in.
1273 *
1274 * `site_bucket()` answers for the CURRENT request; this answers for a URL
1275 * that may belong to another blog entirely — which is what a per-URL purge
1276 * is usually doing (WP-CLI, cron, the MCP tool, a network-admin action).
1277 *
1278 * The blog is resolved from the URL itself: on a subdirectory network
1279 * `get_blog_details()` is asked which blog owns `<host><path>`, and its
1280 * registered path becomes the prefix. Deriving the prefix from the URL's
1281 * first path segment directly would be wrong — `/shop/` on the main blog
1282 * is a page, not a subsite, and would send the purge into a bucket that
1283 * does not exist. (QA B2 on #166)
1284 *
1285 * @param string $host Host of the URL being purged.
1286 * @param string $path Path of the URL being purged.
1287 * @return string Bucket path, always non-empty.
1288 */
1289 public static function bucket_for_url( string $host, string $path ): string {
1290 $dir = self::host_dir( $host );
1291 if ( '' === $dir ) {
1292 $dir = 'default';
1293 }
1294
1295 if ( ! function_exists( 'is_multisite' ) || ! is_multisite() ) {
1296 return $dir;
1297 }
1298 if ( function_exists( 'is_subdomain_install' ) && is_subdomain_install() ) {
1299 return $dir; // Hosts already differ; no prefix.
1300 }
1301 if ( ! function_exists( 'get_blog_details' ) ) {
1302 return $dir;
1303 }
1304
1305 // Longest registered blog path that prefixes this URL wins, so
1306 // `/one/2026/post/` resolves to blog `/one/` and not to the root blog.
1307 $blog = self::blog_for_path( $host, $path );
1308 if ( null === $blog ) {
1309 return $dir;
1310 }
1311 $prefix = self::path_prefix_segment( (string) $blog );
1312 return '' === $prefix ? $dir : $dir . '/' . $prefix;
1313 }
1314
1315 /**
1316 * The registered path of the blog that owns `<host><path>`, or null.
1317 *
1318 * Uses get_blog_details() with a domain/path pair rather than scanning
1319 * every blog, so a large network costs one lookup per candidate segment
1320 * instead of a full table read.
1321 */
1322 private static function blog_for_path( string $host, string $path ): ?string {
1323 $segments = array_values( array_filter( explode( '/', trim( $path, '/' ) ) ) );
1324
1325 // Try the longest candidate first: /a/b/ before /a/ before /.
1326 for ( $take = min( count( $segments ), 2 ); $take >= 1; $take-- ) {
1327 $candidate = '/' . implode( '/', array_slice( $segments, 0, $take ) ) . '/';
1328 $details = get_blog_details(
1329 array(
1330 'domain' => $host,
1331 'path' => $candidate,
1332 ),
1333 false
1334 );
1335 if ( $details && ! empty( $details->path ) ) {
1336 return (string) $details->path;
1337 }
1338 }
1339 return null;
1340 }
1341
1342 /**
1343 * Normalise a blog path ('/', '/siteb/', '/a/b/') into a single
1344 * filesystem-safe segment. Shared with the drop-in's copy.
1345 */
1346 public static function path_prefix_segment( string $path ): string {
1347 $path = trim( str_replace( "\0", '', $path ), '/' );
1348 if ( '' === $path ) {
1349 return '';
1350 }
1351 $path = preg_replace( '/[^a-zA-Z0-9._\-\/]/', '', $path );
1352 $path = str_replace( '/', '-', (string) $path );
1353 return trim( (string) $path, '.-' );
1354 }
1355
1356 /**
1357 * The current blog's path as the static tree stores it — real slashes
1358 * preserved, because that tree mirrors the URL
1359 * (`xspeed-static/{host}{request_uri}/index.html`) rather than using a
1360 * single flattened segment. '' for a root blog / single site.
1361 */
1362 public static function site_path_raw(): string {
1363 if ( ! function_exists( 'is_multisite' ) || ! is_multisite() ) {
1364 return '';
1365 }
1366 if ( function_exists( 'is_subdomain_install' ) && is_subdomain_install() ) {
1367 return '';
1368 }
1369 $path = function_exists( 'get_blog_details' ) ? (string) get_blog_details()->path : '/';
1370 $path = trim( str_replace( "\0", '', $path ), '/' );
1371 if ( '' === $path ) {
1372 return '';
1373 }
1374 $path = preg_replace( '#[^a-zA-Z0-9._\-/]#', '', $path );
1375 return trim( (string) $path, '/' );
1376 }
1377
1378 /**
1379 * Static-tree root for the current site: `<host>` plus the blog's real
1380 * path. Mirrors store_static()'s layout so a scoped purge deletes
1381 * exactly this blog's pages.
1382 */
1383 public static function current_static_scope(): string {
1384 // Same switch_to_blog() caveat as current_host_dir() — see current_host().
1385 $dir = self::host_dir( self::current_host() );
1386 if ( '' === $dir ) {
1387 $dir = 'default';
1388 }
1389 $path = self::site_path_raw();
1390 return '' === $path ? $dir : $dir . '/' . $path;
1391 }
1392
1393 /**
1394 * The bucket for the CURRENT request. Never empty, so an entry is never
1395 * written to the tree root (which is what the unscoped sweeps used to
1396 * delete indiscriminately).
1397 */
1398 public static function current_host_dir(): string {
1399 $host = self::current_host();
1400 $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/';
1401 return self::site_bucket( $host, $uri );
1402 }
1403
1404 /**
1405 * The host the CURRENT blog is served from.
1406 *
1407 * Deliberately NOT just $_SERVER['HTTP_HOST']: inside a
1408 * switch_to_blog() the request header still names whichever site is
1409 * serving the admin screen, while the cache entries we want belong to
1410 * the switched-to blog. On a subdomain network the host IS the bucket,
1411 * so reading the header there would make Pro's per-site "purge this
1412 * site" button clear the network admin's own cache instead — the very
1413 * bug this scoping exists to fix, surviving in one topology.
1414 *
1415 * get_blog_details() follows the switch, so prefer it whenever we are
1416 * on multisite, and fall back to the request header otherwise.
1417 */
1418 public static function current_host(): string {
1419 if ( function_exists( 'is_multisite' ) && is_multisite() && function_exists( 'get_blog_details' ) ) {
1420 $details = get_blog_details();
1421 if ( $details && ! empty( $details->domain ) ) {
1422 return (string) $details->domain;
1423 }
1424 }
1425
1426 if ( isset( $_SERVER['HTTP_HOST'] ) ) {
1427 return sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) );
1428 }
1429
1430 /*
1431 * No request header — WP-CLI, or WP-Cron driven by system cron.
1432 *
1433 * Returning '' here made the bucket resolve to the literal `default`
1434 * while HTTP requests were writing to `<host>/`, so a scheduled purge
1435 * swept an empty directory and reported success, and get_stats()
1436 * reported 0 cached pages on a site with a full cache. That is the
1437 * normal setup on any host running DISABLE_WP_CRON, which is most of
1438 * them. Fall back to the site's own registered host. (QA D4 on #166)
1439 */
1440 if ( function_exists( 'home_url' ) ) {
1441 $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.
1442 if ( is_array( $parts ) && ! empty( $parts['host'] ) ) {
1443 return (string) $parts['host'];
1444 }
1445 }
1446
1447 return '';
1448 }
1449
1450 /**
1451 * Ensure the current site's cache directory exists, with the silence
1452 * index in both it and the shared root. Returns the directory.
1453 */
1454 public static function ensure_host_dir(): string {
1455 $dir = XSPEED_CACHE_DIR . '/' . self::current_host_dir();
1456 if ( ! file_exists( XSPEED_CACHE_DIR ) ) {
1457 wp_mkdir_p( XSPEED_CACHE_DIR );
1458 self::write_silence( XSPEED_CACHE_DIR );
1459 }
1460 if ( ! file_exists( $dir ) ) {
1461 wp_mkdir_p( $dir );
1462 self::write_silence( $dir );
1463 }
1464 return $dir;
1465 }
1466
1467 public static function cache_file_for( $key ) {
1468 return XSPEED_CACHE_DIR . '/' . self::current_host_dir() . '/' . $key . '.html';
1469 }
1470
1471 /**
1472 * If a precompressed Brotli sibling (`<file>.br`) exists and the client
1473 * advertises `Accept-Encoding: br`, emit the Brotli response headers and
1474 * return the `.br` path to stream. Returns null to fall through to the
1475 * plain file. Keeps the PHP serve path in parity with the web server's
1476 * static .br serving (mod_brotli / ngx_brotli rewrite).
1477 *
1478 * Free has no Brotli logic of its own — this only fires when an add-on
1479 * (the Pro Brotli module) actually wrote the .br, so it's a safe no-op
1480 * on Free-only installs.
1481 *
1482 * @param string $file Absolute path to the cached .html file.
1483 * @return string|null The .br path to stream, or null to serve $file.
1484 */
1485 public static function maybe_serve_brotli( string $file ): ?string {
1486 if ( headers_sent() ) {
1487 return null;
1488 }
1489 $accept = isset( $_SERVER['HTTP_ACCEPT_ENCODING'] )
1490 ? strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT_ENCODING'] ) ) )
1491 : '';
1492 // Match `br` as a token (comma/space delimited), not a substring, so
1493 // a hypothetical "xbr" encoding can't false-positive.
1494 if ( ! preg_match( '/(^|[\s,])br([\s,;]|$)/', $accept ) ) {
1495 return null;
1496 }
1497 $br = $file . '.br';
1498 if ( ! is_string( $br ) || ! file_exists( $br ) || ! is_readable( $br ) ) {
1499 return null;
1500 }
1501 if ( ! self::brotli_sibling_is_usable( $file, $br ) ) {
1502 return null; // fall through to the plain .html
1503 }
1504 header( 'Content-Encoding: br' );
1505 header( 'Vary: Accept-Encoding', false );
1506 // The byte length changes for the compressed body — drop any
1507 // Content-Length the caller may have set so the stream isn't
1508 // truncated/padded. readfile() lets the SAPI set the right length.
1509 header_remove( 'Content-Length' );
1510 return $br;
1511 }
1512
1513 /**
1514 * Is a precompressed `.br` sibling safe to serve?
1515 *
1516 * Existence is not enough. The sibling is written with a plain
1517 * file_put_contents() — no atomic rename — so a crash, a full disk, or a
1518 * read that races the write leaves a TRUNCATED file behind. Serving that
1519 * with `Content-Encoding: br` hands the browser a stream it cannot
1520 * inflate: it renders nothing at all (document.body is null) and the
1521 * navigation can hang. A 16-byte .br for a 172KB page reproduces it
1522 * exactly. (#286)
1523 *
1524 * Brotli has no magic number, and no byte-level marker distinguishes a
1525 * truncated stream from a short valid one (the ISLAST bit is bit-packed,
1526 * not byte-aligned). So this checks only what CAN be known by stat:
1527 *
1528 * - Not empty. A zero-byte sibling is unambiguously broken.
1529 * - Not older than the HTML. A stale sibling would serve the PREVIOUS
1530 * revision of the page under the current entry's ETag.
1531 *
1532 * A size-RATIO floor was tried here and removed. Brotli's ratio is
1533 * unbounded on repetitive input: a ~1 MB page of table rows or a product
1534 * grid — the ordinary shape of a big generated page — compresses to
1535 * about 0.04%, so a 2% floor rejected a perfectly good sibling and sent
1536 * visitors the uncompressed page instead, silently. Measured: 963 KB of
1537 * repeated markup → 89 bytes at q5 (0.009%). No floor can separate
1538 * "impossibly small" from "extremely compressible" for arbitrary HTML.
1539 *
1540 * Truncation is prevented at the WRITE side instead — see
1541 * write_atomic(), which the Brotli writer uses so a partial file is
1542 * never visible under the final name. Detection at read time cannot be
1543 * made correct; not creating the bad file can.
1544 *
1545 * Anything suspicious returns false and the caller streams the plain
1546 * .html — slower, always correct. Serving an uninflatable body is worse
1547 * than serving no compression at all.
1548 *
1549 * @param string $file Absolute path to the .html cache file.
1550 * @param string $br Absolute path to its .br sibling.
1551 * @return bool True when the sibling may be served.
1552 */
1553 /**
1554 * Write a cache sidecar so a partial file is never visible.
1555 *
1556 * `file_put_contents()` truncates the target and then fills it, so any
1557 * reader arriving mid-write — or any crash, full disk, or killed worker
1558 * — leaves a SHORT file under the real name. For HTML that degrades to a
1559 * clipped page; for a `.br` sibling it is worse, because a truncated
1560 * brotli stream is not a short page but an UNINFLATABLE one: the browser
1561 * renders nothing at all and the navigation can hang.
1562 *
1563 * Writing to a unique temp file in the same directory and renaming is
1564 * atomic on POSIX, so readers see either the previous complete file or
1565 * the new complete file, never a partial one. This is the half of #286
1566 * that is actually fixable — a read-time heuristic cannot tell a
1567 * truncated brotli stream from a very small valid one, but a truncated
1568 * file that never becomes visible needs no detection.
1569 *
1570 * @param string $path Absolute destination path.
1571 * @param string $contents Bytes to write.
1572 * @return bool True when the destination now holds exactly $contents.
1573 */
1574 public static function write_atomic( string $path, string $contents ): bool {
1575 $dir = dirname( $path );
1576 // 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.
1577 if ( ! is_dir( $dir ) || ! is_writable( $dir ) ) {
1578 return false;
1579 }
1580
1581 // Same directory, so the rename stays on one filesystem — a rename
1582 // across devices is a copy and loses atomicity.
1583 $tmp = @tempnam( $dir, '.xspeed-tmp-' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a failure returns false and the caller skips the write.
1584 if ( ! is_string( $tmp ) || '' === $tmp ) {
1585 return false;
1586 }
1587
1588 // 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.
1589 $written = @file_put_contents( $tmp, $contents ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- handled by the length check below.
1590
1591 // A short write is exactly the failure this function exists to
1592 // prevent, so verify the byte count before publishing the file.
1593 if ( false === $written || $written !== strlen( $contents ) ) {
1594 @unlink( $tmp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort cleanup of our own temp file; non-fatal.
1595 return false;
1596 }
1597
1598 // tempnam() creates the file 0600; cache files must stay readable by
1599 // the web server, which may run as a different user.
1600 @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.
1601
1602 // 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.
1603 if ( ! @rename( $tmp, $path ) ) {
1604 @unlink( $tmp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort cleanup; non-fatal.
1605 return false;
1606 }
1607
1608 return true;
1609 }
1610
1611 public static function brotli_sibling_is_usable( string $file, string $br ): bool {
1612 $br_size = (int) @filesize( $br ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a stat failure means "don't serve it", handled by the <= 0 check.
1613 if ( $br_size <= 0 ) {
1614 return false;
1615 }
1616
1617 $html_size = (int) @filesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- as above.
1618 if ( $html_size <= 0 ) {
1619 return false;
1620 }
1621
1622 // A sibling older than the page it compresses is stale.
1623 $br_mtime = (int) @filemtime( $br ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- as above.
1624 $html_mtime = (int) @filemtime( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- as above.
1625 if ( $br_mtime > 0 && $html_mtime > 0 && $br_mtime < $html_mtime ) {
1626 return false;
1627 }
1628
1629 // The writer recorded how many bytes it produced. Where that record
1630 // exists, truncation is a certainty rather than an inference: a
1631 // stream shorter than its own declared length cannot inflate, and
1632 // one that matches was published whole. This is what a size ratio
1633 // could never be — brotli's ratio is unbounded on repetitive input,
1634 // so a 0.01% sibling of a generated page is genuinely valid.
1635 //
1636 // Absent for a sibling written before this version, or by an add-on
1637 // that writes the file directly. That case keeps the checks above
1638 // and no more, which is where a pre-existing truncated file on a
1639 // live site still slips through — write_atomic() stops NEW ones,
1640 // but it cannot retroactively vouch for what is already on disk.
1641 $expected = self::brotli_expected_size( $br );
1642 if ( $expected > 0 && $br_size !== $expected ) {
1643 return false;
1644 }
1645
1646 return true;
1647 }
1648
1649 /**
1650 * Path of the sidecar recording a `.br` sibling's complete byte count.
1651 *
1652 * Kept beside the sibling as `<file>.html.br.size` rather than folded
1653 * into the entry's `.meta`: the static tree the web server serves has no
1654 * `.meta` at all, and the two trees must answer this question the same
1655 * way. Every path that deletes a `.br` deletes this with it.
1656 *
1657 * @param string $br Absolute path to the `.br` sibling.
1658 * @return string Absolute path to its size sidecar.
1659 */
1660 public static function brotli_size_sidecar( string $br ): string {
1661 return $br . '.size';
1662 }
1663
1664 /**
1665 * The byte count the writer recorded for a `.br` sibling, or 0 when no
1666 * record exists (a sibling predating this version, or written by an
1667 * add-on that bypassed write_brotli_sibling()).
1668 *
1669 * @param string $br Absolute path to the `.br` sibling.
1670 * @return int Expected size in bytes, or 0 when unknown.
1671 */
1672 public static function brotli_expected_size( string $br ): int {
1673 $sidecar = self::brotli_size_sidecar( $br );
1674 if ( ! is_file( $sidecar ) ) {
1675 return 0;
1676 }
1677 // 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.
1678 $raw = @file_get_contents( $sidecar ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- an unreadable sidecar means "unknown", handled by the cast below.
1679 return max( 0, (int) trim( (string) $raw ) );
1680 }
1681
1682 /**
1683 * Publish a `.br` sibling together with the record of its own length.
1684 *
1685 * The single writer every producer of a `.br` should route through — the
1686 * Pro Brotli module included. Publishing the body atomically stops a
1687 * truncated file from ever becoming visible; recording the byte count
1688 * lets the serve path prove wholeness for the files that already exist
1689 * on disk when this ships.
1690 *
1691 * Order matters: the size sidecar is removed first and written last, so
1692 * a reader arriving mid-update sees "no record" (checks above still
1693 * apply) rather than the previous body's length against the new body.
1694 *
1695 * @param string $br Absolute path to the `.br` sibling to write.
1696 * @param string $contents Compressed bytes.
1697 * @return bool True when both the sibling and its size record are in place.
1698 */
1699 public static function write_brotli_sibling( string $br, string $contents ): bool {
1700 $sidecar = self::brotli_size_sidecar( $br );
1701 if ( is_file( $sidecar ) ) {
1702 wp_delete_file( $sidecar );
1703 }
1704
1705 if ( ! self::write_atomic( $br, $contents ) ) {
1706 return false;
1707 }
1708
1709 if ( self::write_atomic( $sidecar, (string) strlen( $contents ) ) ) {
1710 return true;
1711 }
1712
1713 // The body landed but its length did not. That sibling is servable
1714 // and unguarded — exactly the file this function exists to prevent —
1715 // and the caller has no way to know. Withdraw it: a MISS costs one
1716 // uncompressed response, where an unguarded sibling can cost a blank
1717 // page for as long as the entry lives.
1718 wp_delete_file( $br );
1719 return false;
1720 }
1721
1722 /**
1723 * Sidecar metadata file for a cache entry. Holds response bits the HIT
1724 * path must replay — Content-Type (cached feeds → application/rss+xml,
1725 * sitemaps → text/xml) and status (a cached 404 must serve 404, not
1726 * 200). JSON, one tiny file per entry, written only when there's
1727 * something non-default to replay.
1728 */
1729 public static function cache_meta_for( $key ) {
1730 return XSPEED_CACHE_DIR . '/' . self::current_host_dir() . '/' . $key . '.meta';
1731 }
1732
1733 /**
1734 * Read the .meta sidecar for a cache entry as an array, or [] if none.
1735 * Keys: 'content_type' (string), 'status' (int), 'ttl' (int seconds).
1736 * Used on the HIT path to replay content-type/status before streaming
1737 * the file, and by Cache_GC to age an entry by its own TTL rather than
1738 * the global one — hence public.
1739 */
1740 public static function read_meta( $key ): array {
1741 $meta_file = self::cache_meta_for( $key );
1742 if ( ! file_exists( $meta_file ) ) {
1743 return array();
1744 }
1745 // 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.
1746 $raw = file_get_contents( $meta_file );
1747 $data = json_decode( (string) $raw, true );
1748 return is_array( $data ) ? $data : array();
1749 }
1750
1751 /**
1752 * Conditional-GET support for a cache HIT. Emits Last-Modified + ETag
1753 * derived from the cache file's mtime, and — when the request's
1754 * If-Modified-Since / If-None-Match still match — sends 304 Not Modified
1755 * and returns true (caller should exit without a body). Returns false to
1756 * proceed with a normal 200 body. Lets aggregators/browsers skip
1757 * re-downloading an unchanged cached response. (FBS-82407 #5)
1758 *
1759 * @param string $file Absolute path to the cache .html file.
1760 * @return bool True when a 304 was sent.
1761 */
1762 public static function serve_not_modified( string $file ): bool {
1763 $mtime = (int) filemtime( $file );
1764 if ( $mtime <= 0 ) {
1765 return false;
1766 }
1767 $last_modified = gmdate( 'D, d M Y H:i:s', $mtime ) . ' GMT';
1768 $etag = '"' . md5( $file . '|' . $mtime ) . '"';
1769 header( 'Last-Modified: ' . $last_modified );
1770 header( 'ETag: ' . $etag );
1771
1772 $ims = isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ) ) : '';
1773 $inm = isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_NONE_MATCH'] ) ) ) : '';
1774
1775 $etag_match = '' !== $inm && false !== strpos( $inm, $etag );
1776 $time_match = '' !== $ims && ( strtotime( $ims ) >= $mtime );
1777
1778 if ( $etag_match || $time_match ) {
1779 if ( function_exists( 'http_response_code' ) ) {
1780 http_response_code( 304 );
1781 }
1782 return true;
1783 }
1784 return false;
1785 }
1786
1787 public static function is_expired( $file ) {
1788 // cache_expiry now owned by CacheModule; per-post override
1789 // (Phase 3.4) shrinks the TTL further when the editor set one.
1790 $opts = Settings_Manager::get( 'cache' );
1791 $max_age = (int) $opts['cache_expiry'] * HOUR_IN_SECONDS;
1792 $post_override = Cache_Rules::expiry_override_seconds_for_post( Cache_Rules::current_post_id() );
1793 if ( null !== $post_override ) {
1794 $max_age = $post_override;
1795 }
1796
1797 /**
1798 * Filter the max-age (seconds) for the current cache entry.
1799 *
1800 * Lets an add-on apply a request-type-specific TTL — e.g. the
1801 * xspeed-pro feed cache gives feeds a longer expiry than pages,
1802 * since aggregators tolerate more staleness. Return seconds.
1803 *
1804 * @param int $max_age Computed max-age in seconds.
1805 */
1806 $max_age = (int) apply_filters( 'xspeed_cache_max_age', $max_age );
1807
1808 // Honour the per-entry TTL the .meta sidecar carries, when it is
1809 // SHORTER than what we just resolved. The sidecar records the TTL
1810 // this specific entry was written under — a nonce cap (#236), a Pro
1811 // feed/404 expiry — and the drop-in already reads it. is_expired()
1812 // did not, so on the engine path a capped entry was still served for
1813 // the full configured lifetime: exactly the stale nonce the cap
1814 // exists to prevent. Only ever shortens, so an entry can never be
1815 // kept alive past the configured maximum by a stale sidecar.
1816 // Derive the sidecar from the FILE we were handed rather than
1817 // recomputing cache_key(): callers legitimately ask about an entry
1818 // that isn't the current request's (Cache_GC sweeps, Pro's warmer),
1819 // and cache_key() would answer for the wrong one — besides needing a
1820 // request context this function has no business requiring.
1821 $meta_file = preg_replace( '/\.html$/', '.meta', (string) $file );
1822 if ( is_string( $meta_file ) && $meta_file !== $file && is_readable( $meta_file ) ) {
1823 // 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.
1824 $raw = file_get_contents( $meta_file );
1825 $decoded = is_string( $raw ) ? json_decode( $raw, true ) : null;
1826 if ( is_array( $decoded ) && isset( $decoded['ttl'] ) ) {
1827 $entry_ttl = (int) $decoded['ttl'];
1828 if ( $entry_ttl > 0 && ( $max_age < 1 || $entry_ttl < $max_age ) ) {
1829 $max_age = $entry_ttl;
1830 }
1831 }
1832 }
1833
1834 // A missing file is "expired" — the caller should re-render. Guard
1835 // filemtime() rather than letting it warn: callers legitimately ask
1836 // about a file that isn't there (Pro's predictive warmer probes for
1837 // freshness, and Cache_GC can collect an entry between the check and
1838 // the read), and on a site with WP_DEBUG the warning is noise.
1839 $mtime = file_exists( $file ) ? filemtime( $file ) : false;
1840 if ( false === $mtime ) {
1841 return true;
1842 }
1843
1844 return ( time() - (int) $mtime ) > $max_age;
1845 }
1846
1847 /**
1848 * Accumulator for the full response body across all output-handler phases.
1849 *
1850 * PHP invokes an ob_start() callback once per flush, and each invocation
1851 * only receives the chunk produced *since the previous flush*. If anything
1852 * during the render calls `ob_flush()` or `flush()` (some themes, lazy-
1853 * load plugins, AMP, etc. do), the final-phase call would otherwise only
1854 * see the tail of the page — and we'd cache a truncated response that
1855 * gets served repeatedly until purge. We accumulate every chunk here so
1856 * the cache file always reflects the complete page.
1857 *
1858 * @var string
1859 */
1860 private static $accumulated = '';
1861
1862 public static function finalize_buffer( $buffer, $phase = PHP_OUTPUT_HANDLER_FINAL ) {
1863 self::$accumulated .= $buffer;
1864
1865 // On non-final phases (mid-request flushes), pass the current chunk
1866 // through to the client unmodified and keep collecting. The WP 6.9
1867 // filter path always passes the full body in one shot with the
1868 // default $phase, so it falls straight through to the final block.
1869 $is_final = ( $phase & ( PHP_OUTPUT_HANDLER_FINAL | PHP_OUTPUT_HANDLER_END ) ) !== 0;
1870 if ( ! $is_final ) {
1871 return $buffer;
1872 }
1873
1874 $full = self::$accumulated;
1875 self::$accumulated = '';
1876
1877 if ( strlen( $full ) < 255 ) {
1878 return $buffer;
1879 }
1880
1881 // Status gate. We cache 200 by default. A 404 may be cached too,
1882 // but only when an add-on (xspeed-pro 404 cache) opts in for a
1883 // genuine is_404() — never a transient 404 (maintenance screen,
1884 // DB error, or a 404 emitted outside the main query), which would
1885 // otherwise be frozen until purge. Any other status is skipped.
1886 $status = function_exists( 'http_response_code' ) ? (int) http_response_code() : 200;
1887 if ( 200 !== $status ) {
1888 if ( 404 !== $status || ! self::should_cache_404() ) {
1889 return $buffer;
1890 }
1891 }
1892
1893 // If no mid-request flush happened, $buffer === $full and we can
1894 // safely minify the on-wire bytes too. Otherwise earlier chunks have
1895 // already been sent unminified, so we minify only what goes to disk —
1896 // the first visitor sees unminified HTML, every cache hit after that
1897 // is minified.
1898 $single_chunk = ( $buffer === $full );
1899
1900 /**
1901 * Filter: xspeed_cache_final_html
1902 *
1903 * Last chance to transform the fully-rendered page HTML before it is
1904 * minified and written to the cache file. Runs on cache MISS only, so
1905 * whatever a listener injects here is baked into the cached HTML and
1906 * replayed on every subsequent HIT (the drop-in short-circuits before
1907 * PHP on a HIT — a wp_head hook would never fire there).
1908 *
1909 * The Preload module uses this to inject the LCP-image <link rel=preload>
1910 * + preconnect hints and add fetchpriority="high" to the hero <img>.
1911 * Keep listeners fast and idempotent; this is the on-wire body.
1912 *
1913 * @param string $full Complete page HTML.
1914 */
1915 $full = (string) apply_filters( 'xspeed_cache_final_html', $full );
1916 if ( $single_chunk ) {
1917 $buffer = $full;
1918 }
1919
1920 // minify_html now owned by the Minify module; read through the
1921 // module's storage so this stays consistent with the engine that
1922 // applies CSS/JS minification.
1923 $minify_opts = Settings_Manager::get( 'minify' );
1924 if ( ! empty( $minify_opts['minify_html'] ) ) {
1925 $full = Minifier::minify_html( $full );
1926 if ( $single_chunk ) {
1927 $buffer = $full;
1928 }
1929 }
1930
1931 // AFTER minification on purpose — the HTML minifier strips comments,
1932 // so signing earlier would erase the signature from every minified
1933 // page. Baked into the cached bytes so all three serve paths (nginx
1934 // static rewrite, .htaccess, the PHP drop-in) carry it identically.
1935 $full = self::signed( $full );
1936 if ( $single_chunk ) {
1937 $buffer = $full;
1938 }
1939
1940 // Per-site directory — see ensure_host_dir(). (#6)
1941 self::ensure_host_dir();
1942
1943 // Path safety: cache_file_for() builds
1944 // `XSPEED_CACHE_DIR . '/' . <host> . '/' . $key . '.html'` where $key
1945 // comes from md5() — guaranteed to be exactly 32 lowercase hex chars —
1946 // and <host> is filtered by host_dir() to [A-Za-z0-9.-] with leading
1947 // dots trimmed, so no traversal sequence ('..', '/', null byte, etc.)
1948 // can appear in either segment. The write is therefore always inside
1949 // XSPEED_CACHE_DIR.
1950 $key = self::cache_key();
1951
1952 // Query-string gate. should_cache() waved this request through
1953 // because every param is on the ignored_query_params allow-list, and
1954 // cache_key() drops the query so reads share the canonical entry.
1955 // That sharing is safe on READ but not on WRITE: this response was
1956 // rendered WITH the params, and WordPress reflects REQUEST_URI into
1957 // form actions, share links and plugin smart tags — so storing it
1958 // would serve an attacker-chosen variant under the clean URL for the
1959 // whole TTL (#241).
1960 //
1961 // This sits BELOW the transforms deliberately. Returning above them
1962 // also skipped xspeed_cache_final_html, and every listener disables
1963 // its own fallback ob_start() when the page cache is on precisely
1964 // because that filter is the shared transport — so a visitor
1965 // arriving on ?utm_source=… was served HTML with no LCP preload, no
1966 // preconnect, no CDN rewrite, no CSS combine and no HTML minify.
1967 // That is the ad-click and newsletter cohort getting the least
1968 // optimised page on the site. Only the WRITE is skipped, which is
1969 // what this fix was always meant to do — and it is where the
1970 // deferred writer has always placed its own copy of the guard.
1971 if ( self::query_string_blocks_write() ) {
1972 return $buffer;
1973 }
1974 $file = self::cache_file_for( $key );
1975
1976 // A render-time translation plugin (TranslatePress) wraps our buffer,
1977 // so the bytes we hold here are still UNTRANSLATED — its callback has
1978 // not run yet, and writing now would cache English under a French URL
1979 // and bake in its internal #TRPLINKPROCESSED markers. Hand off to
1980 // shutdown, where the outer buffer has already translated, and let
1981 // the pass-through below deliver this request untouched.
1982 if ( self::translation_plugin_active() ) {
1983 self::$deferred_key = $key;
1984 // Reaching here means finalize_buffer() ran to completion: the
1985 // status gate passed, should_cache() said yes, and PHP handed us
1986 // the whole buffer. A wp_die() or exit() mid-render unwinds the
1987 // buffer stack WITHOUT calling this callback, so the flag stays
1988 // false and the shutdown writer declines — see the guard there.
1989 self::$render_completed = true;
1990 // A PHP shutdown function, not a WP `shutdown` action: this must
1991 // run after the output-buffer stack has unwound, and WP's
1992 // shutdown action fires while our outer buffer is still open.
1993 register_shutdown_function( array( __CLASS__, 'write_deferred_translated_cache' ) );
1994 return $buffer;
1995 }
1996
1997 // 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.
1998 file_put_contents( $file, $full, LOCK_EX );
1999
2000 /**
2001 * Fires after the flat hash cache file ({md5}.html) is written.
2002 *
2003 * Mirror of `xspeed_static_file_written` for the flat cache. The PHP
2004 * serve path (Cache::maybe_serve_brotli / the drop-in) serves THIS
2005 * file and looks for a `{md5}.html.br` sibling — which only the Pro
2006 * Brotli listener on this hook writes. Without it the .br sibling was
2007 * never created and the PHP path could never serve Brotli (FBS-83039,
2008 * Blocker 2): the static-tree .br (written on xspeed_static_file_written)
2009 * lives in a different cache layout the PHP path never reads.
2010 *
2011 * @param string $file Absolute path to the flat cache file just written.
2012 * @param string $full The HTML written to it.
2013 */
2014 do_action( 'xspeed_flat_file_written', $file, $full );
2015
2016 // Persist a non-default Content-Type so the HIT path can replay it
2017 // (cached feeds must serve application/rss+xml, not text/html).
2018 // Only written when the response set a content-type other than
2019 // the HTML default — pages don't pay for an extra file.
2020 self::write_meta( $key, $full );
2021
2022 // Static-cache tree (xspeed-static/{host}{path}/index.html). The
2023 // .htaccess rewrite block serves this file directly via the web
2024 // server, bypassing PHP for ~3-5× lower TTFB vs the drop-in path.
2025 // store_static() returns silently on any path/permission issue —
2026 // the drop-in remains the safety net.
2027 //
2028 // Skip it entirely when mobile_separate is on: the rewrite is
2029 // disabled in that mode (static_rewrite_allowed()), so a static file
2030 // would only be dead weight — and a device-blind one at that.
2031 // Skip the static-tree write for responses the web server can't replay
2032 // correctly: a non-200 status (a cached 404 would be served as a soft
2033 // 200, FBS-82406) or a non-HTML content-type (a cached feed would go
2034 // out as text/html, FBS-82407). The web server serves these .html files
2035 // directly with no PHP, so there's no .meta replay — keep them on the
2036 // drop-in / PHP path instead, which DOES replay status + content-type.
2037 if ( self::static_rewrite_allowed() && self::response_is_plain_html() ) {
2038 self::store_static( $full );
2039 }
2040
2041 return $buffer;
2042 }
2043
2044 /**
2045 * Write the current response to the static-cache tree at
2046 * `xspeed-static/{host}{request_uri}/index.html`. The web-server
2047 * rewrite block points at this path so cache hits skip PHP
2048 * entirely. Caller already minified/finalized $html.
2049 *
2050 * Path safety: $host is restricted to a `[a-zA-Z0-9.\-]` allowlist;
2051 * $uri has its query string stripped, null bytes removed, '..'
2052 * sequences collapsed, and after concatenation we verify the
2053 * resolved real path stays inside XSPEED_CACHE_STATIC_DIR before
2054 * any write. Anything off the happy path returns silently.
2055 *
2056 * INVARIANT — the static tree is keyed by `{host}{path}` and NOTHING
2057 * else, and both generated rewrites refuse any request that carries a
2058 * query string at all (`RewriteCond %{QUERY_STRING} ^$` on Apache,
2059 * `if ($args)` in nginx_snippet()). So a response may only be stored
2060 * here when cache_key() adds no discriminator beyond `{host}{path}`:
2061 * a query-keyed entry can never be *served* from here, only mis-served
2062 * as the bare path. Any future opt-in that folds a query param into the
2063 * key needs a guard below, exactly like the search one.
2064 */
2065 /**
2066 * Transient holding the most recent static-tree refusal.
2067 *
2068 * Short-lived on purpose: it describes what the last cacheable render
2069 * actually did, so a stale entry would keep warning about a page whose
2070 * nonces have since been removed. A site that still refuses simply
2071 * rewrites it on the next render. (#372)
2072 */
2073 private const STATIC_SKIP_TRANSIENT = 'xspeed_static_skip';
2074
2075 /**
2076 * Remember why a page was kept out of the static tree, for Health.
2077 *
2078 * Records the URL, the reason, and — for the nonce case — the distinct
2079 * nonce KEYS found, which is what makes the finding actionable: the names
2080 * (`eael_login_nonce`, `post_grid_pagination_nonce`, …) trace straight back
2081 * to the plugin emitting them, and it is usually a widget the site does not
2082 * use on that page. Only key names are kept, never the nonce values.
2083 *
2084 * @param string $reason Machine-readable refusal reason.
2085 * @param string $html The response, for extracting the nonce keys.
2086 */
2087 private static function note_static_skip( string $reason, string $html = '' ): void {
2088 if ( ! function_exists( 'set_transient' ) ) {
2089 return;
2090 }
2091
2092 $keys = array();
2093 if ( 'nonce' === $reason && '' !== $html ) {
2094 // Must recognise the SAME shapes response_has_nonce() refuses on,
2095 // or a page is skipped and reported with no keys at all — which is
2096 // most of them, since the plain `name="_wpnonce"` form field is the
2097 // commonest shape by far and only the JSON one was handled here.
2098 // The keys are the actionable half of the message, so a mismatch
2099 // leaves the admin with bad news and nothing to act on.
2100 //
2101 // Both alternations capture the KEY only: each value pattern sits
2102 // outside the capture group, so a nonce secret can never be stored.
2103 $found = array();
2104 if ( preg_match_all( '/name=["\']([a-z0-9_\-\[\]]*nonce[a-z0-9_\-\[\]]*)["\']/i', $html, $m ) ) {
2105 $found = array_merge( $found, $m[1] );
2106 }
2107 if ( preg_match_all( '/["\']([a-z0-9_\-]*nonce[a-z0-9_\-]*)["\']\s*:\s*["\'][a-f0-9]{8,}["\']/i', $html, $m ) ) {
2108 $found = array_merge( $found, $m[1] );
2109 }
2110 // The query-arg shape (`?_wpnonce=…`) has no key name to report
2111 // beyond the literal, so name it explicitly rather than reporting
2112 // nothing for a page that was genuinely refused.
2113 if ( preg_match( '/[?&]_wpnonce=/i', $html ) ) {
2114 $found[] = '_wpnonce';
2115 }
2116 $keys = array_slice( array_values( array_unique( $found ) ), 0, 10 );
2117 }
2118
2119 $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
2120
2121 set_transient(
2122 self::STATIC_SKIP_TRANSIENT,
2123 array(
2124 'reason' => $reason,
2125 'url' => (string) strtok( $uri, '?' ),
2126 'keys' => $keys,
2127 'at' => time(),
2128 ),
2129 HOUR_IN_SECONDS
2130 );
2131 }
2132
2133 /**
2134 * The most recent static-tree refusal, or an empty array when there is none.
2135 *
2136 * @return array{reason:string,url:string,keys:string[],at:int}|array{}
2137 */
2138 public static function last_static_skip(): array {
2139 $stored = function_exists( 'get_transient' ) ? get_transient( self::STATIC_SKIP_TRANSIENT ) : false;
2140 return is_array( $stored ) && ! empty( $stored['reason'] ) ? $stored : array();
2141 }
2142
2143 private static function store_static( string $html ): void {
2144 // Search results are keyed by term in cache_key() (`|s=<term>`) but
2145 // carry the *path* of whatever URL was searched from — for the usual
2146 // `/?s=<term>` that path is `/`. Writing them here would file the
2147 // results page as `{host}/index.html` and the web server would serve
2148 // it to every visitor as the homepage: an unauthenticated visitor
2149 // poisons the front page with one request. Searches stay on the
2150 // drop-in, which replays the term-keyed entry correctly. (#191)
2151 //
2152 // This is a superset of the query-string check the exclusion gate
2153 // does: it also covers `/?%73=<term>`, which decodes to the same
2154 // search (the shape #109 fixed on the gate side).
2155 if ( self::should_cache_search() ) {
2156 return;
2157 }
2158
2159 // Same hazard for the allow-listed query params: store_static()
2160 // strips the query and files the response under the bare path, which
2161 // the web server then serves to every visitor of the clean URL with
2162 // no PHP involved at all — so none of the engine's checks can catch
2163 // it later (#241). The callers already gate on this, but the guard
2164 // is repeated here because this tree is the most dangerous of the
2165 // three write sites and must not depend on its callers.
2166 if ( self::request_has_query_string() ) {
2167 return;
2168 }
2169
2170 // A nonce-bearing page is served here with NO PHP: no TTL check and
2171 // no .meta replay, so the per-entry cap that keeps the drop-in honest
2172 // (#236) cannot reach a file once it is written. Only Cache_GC removes
2173 // it, and until it does the page hands every visitor the same nonce —
2174 // which, once that nonce dies, breaks every anonymous form on it.
2175 //
2176 // Refusing outright was the safe answer, and it cost every
2177 // nonce-bearing page the static tree entirely: a site whose homepage
2178 // carries one unused login nonce ran PHP on every request forever.
2179 // The nonce's own remaining life is the better gate — the page is
2180 // written and its deadline recorded below for GC to enforce.
2181 //
2182 // A nonce we cannot put a clock on is still refused, and that refusal
2183 // is still recorded: it stays completely silent otherwise, because the
2184 // drop-in answers HIT while Health reports the fast path active from a
2185 // probe that writes its OWN file and never proves real pages reach the
2186 // tree. (#372)
2187 $nonce_ttl = self::response_has_nonce( $html ) ? self::nonce_capped_ttl( $html, 0 ) : 0;
2188 if ( self::response_has_nonce( $html ) && $nonce_ttl < 1 ) {
2189 self::note_static_skip( 'nonce', $html );
2190 return;
2191 }
2192
2193 $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
2194 $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
2195 $host = self::static_host_dir( $host );
2196 $uri = str_replace( "\0", '', $uri );
2197 $uri = (string) strtok( $uri, '?' );
2198 if ( '' === $host || '' === $uri ) {
2199 return;
2200 }
2201 // Collapse any traversal sequences before path resolution.
2202 $uri = preg_replace( '#/+#', '/', $uri );
2203 if ( false !== strpos( $uri, '..' ) ) {
2204 return;
2205 }
2206
2207 $base = rtrim( XSPEED_CACHE_STATIC_DIR, '/' );
2208 $dir = $base . '/' . $host . rtrim( $uri, '/' );
2209 $file = $dir . '/index.html';
2210
2211 // Resolve the parent against the cache root to be sure the
2212 // final path is inside our tree even if the OS does anything
2213 // funny with multi-byte sequences.
2214 $base_real = realpath( WP_CONTENT_DIR );
2215 if ( false === $base_real || 0 !== strpos( $base, $base_real ) ) {
2216 return;
2217 }
2218
2219 if ( ! file_exists( $dir ) ) {
2220 wp_mkdir_p( $dir );
2221 }
2222 if ( ! is_dir( $dir ) ) {
2223 return;
2224 }
2225 // 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.
2226 $written = file_put_contents( $file, $html, LOCK_EX );
2227
2228 // A nonce-bearing page expires on the nonce's schedule, not the site's.
2229 // Nothing reads this file at serve time — the web server hands over
2230 // index.html without PHP — so the deadline is recorded beside it for
2231 // GC, which is the only thing that can enforce it. Written before the
2232 // action below so a listener that shells out cannot race the sweep.
2233 if ( false !== $written && $nonce_ttl > 0 ) {
2234 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- same rationale as the write above.
2235 file_put_contents( $dir . '/.xspeed-expires', (string) ( time() + $nonce_ttl ), LOCK_EX );
2236 }
2237
2238 if ( false !== $written ) {
2239 /**
2240 * Fires after a static cache file (index.html) is written.
2241 *
2242 * The extension point for serving pre-compressed siblings:
2243 * the xspeed-pro Brotli module writes `index.html.br` next to
2244 * the file here so the web server's static rewrite can serve a
2245 * Brotli copy to clients that advertise `Accept-Encoding: br`,
2246 * falling back to GZIP / the plain file otherwise. No core
2247 * behavior depends on a listener being present.
2248 *
2249 * @param string $file Absolute path to the static cache file just written.
2250 * @param string $html The HTML written to it.
2251 */
2252 do_action( 'xspeed_static_file_written', $file, $html );
2253 }
2254 }
2255
2256 /**
2257 * Write the .meta sidecar for a cache entry when the response carries
2258 * anything the HIT path must replay beyond a plain 200 text/html:
2259 * - a non-HTML Content-Type (cached feeds → application/rss+xml,
2260 * sitemaps → text/xml, …), and/or
2261 * - a non-200 status (a cached 404 must serve 404, not 200).
2262 *
2263 * Ordinary 200 text/html pages get NO .meta file, so the common path
2264 * stays a single write.
2265 *
2266 * @param string $key Cache key for the current request.
2267 */
2268 /**
2269 * True only for a plain 200 text/html response — the only kind the
2270 * web-server static tree can serve correctly (it streams the .html with
2271 * no PHP, so it can't replay a 404 status or a feed Content-Type). Used
2272 * to gate store_static() so cached 404s / feeds stay on the replay-capable
2273 * drop-in / PHP path. (FBS-82406, FBS-82407)
2274 */
2275 private static function response_is_plain_html(): bool {
2276 $status = function_exists( 'http_response_code' ) ? (int) http_response_code() : 200;
2277 if ( 200 !== $status && $status > 0 ) {
2278 return false;
2279 }
2280 foreach ( headers_list() as $header ) {
2281 if ( 0 === stripos( $header, 'content-type:' ) ) {
2282 $ct = trim( substr( $header, strlen( 'content-type:' ) ) );
2283 if ( '' !== $ct && false === stripos( $ct, 'text/html' ) ) {
2284 return false;
2285 }
2286 }
2287 }
2288 return true;
2289 }
2290
2291 /**
2292 * Append the cache signature comment to a finished page.
2293 *
2294 * The plugin's one outward version signal: external scanners (the
2295 * xspeedcache.com speed test among them) read it to detect xSpeed and
2296 * its version on a cached page, the way other cache plugins sign their
2297 * output. Callers apply it AFTER HTML minification — the minifier strips
2298 * comments — and before every cache write, so all serve paths carry the
2299 * same bytes.
2300 *
2301 * The generation time is baked in here, at write time, in UTC. It is the
2302 * moment the cached bytes were produced — NOT the moment they were served
2303 * — because all three serve paths replay the same stored file, and two of
2304 * them (the nginx/`.htaccess` static rewrite) run no PHP at all and so
2305 * could never stamp a serve-time value. Reading the age of a page is the
2306 * point: `generated` plus the current clock tells you how stale it is.
2307 * `gmdate()` (not `current_time()`) keeps the value comparable across
2308 * sites regardless of the configured timezone.
2309 *
2310 * @param string $html Finished page HTML.
2311 * @return string HTML with the signature appended (or unchanged when a
2312 * filter removed it).
2313 */
2314 private static function signed( string $html ): string {
2315 $version = defined( 'XSPEED_VERSION' ) ? XSPEED_VERSION : '';
2316 $generated = gmdate( 'Y-m-d H:i:s' ) . ' UTC';
2317 // The literal ' | xspeedcache.com' must survive intact, and what
2318 // precedes it is where an edition suffix lands: Pro appends itself by
2319 // str_replace()-ing on that exact token
2320 // (Pro_Plugin::sign_cache_signature). So the stamp goes AFTER it —
2321 // placed before, it sits between the version and the anchor and
2322 // composes as "generated <date> + Pro v1.1.3".
2323 $signature = sprintf(
2324 '<!-- Page cached by xSpeed Cache v%s | xspeedcache.com | generated %s -->',
2325 $version,
2326 $generated
2327 );
2328
2329 /**
2330 * Filter: xspeed_cache_signature
2331 *
2332 * The HTML comment appended to every cached page. Add-ons append
2333 * their own edition/version here; white-label setups return '' to
2334 * remove the comment entirely. Must remain a valid HTML comment (or
2335 * an empty string) — it ships inside the cached body.
2336 *
2337 * @param string $signature The signature comment.
2338 * @param string $version The plugin version baked into it.
2339 * @param string $generated The write-time timestamp baked into it,
2340 * formatted `Y-m-d H:i:s UTC`.
2341 */
2342 $signature = (string) apply_filters( 'xspeed_cache_signature', $signature, $version, $generated );
2343 if ( '' === trim( $signature ) ) {
2344 return $html;
2345 }
2346 return $html . "\n" . $signature;
2347 }
2348
2349 /**
2350 * Does this response carry a WordPress nonce?
2351 *
2352 * Anonymous nonces depend only on the tick (user 0, empty session
2353 * token), so they are identical for every visitor — which is exactly why
2354 * they cache "successfully" and then fail silently once the tick moves.
2355 *
2356 * Matches any form field whose NAME contains "nonce" — `_wpnonce`,
2357 * `_wpnonce_<action>`, Tutor's `_tutor_nonce`, CF7's `_wpcf7_nonce` and
2358 * WooCommerce's `woocommerce-add-to-cart-nonce` (which does NOT start
2359 * with an underscore, so a `_`-anchored pattern misses it) — plus the
2360 * `_wpnonce=` form used in nonce-bearing URLs. Deliberately keyed on
2361 * `name=` so prose, CSS classes and data attributes don't false-positive.
2362 *
2363 * @param string $html Rendered response body.
2364 */
2365 public static function response_has_nonce( string $html ): bool {
2366 if ( '' === $html ) {
2367 return false;
2368 }
2369
2370 /*
2371 * Three shapes, because a nonce reaches the page in three ways:
2372 *
2373 * 1. A form field name — `_wpnonce`, `woocommerce-login-nonce`, and
2374 * the GROUPED names form builders emit (`data[_wpnonce]`,
2375 * `frm[nonce]`). The character class deliberately allows `[` and
2376 * `]` so grouping does not hide the field: form builders are
2377 * exactly the kind of plugin #236 is about, and a missed page
2378 * keeps the old broken behaviour silently.
2379 * 2. A query argument (`?_wpnonce=`) on a link.
2380 * 3. A nonce handed to the page's own scripts rather than placed in
2381 * a visible form — `wp_localize_script()` output and inline JSON
2382 * both land as a `"nonce":"…"`-shaped pair.
2383 */
2384 return 1 === preg_match(
2385 '/(name=["\'][a-z0-9_\-\[\]]*nonce[a-z0-9_\-\[\]]*["\']'
2386 . '|[?&]_wpnonce='
2387 . '|["\'][a-z0-9_\-]*nonce[a-z0-9_\-]*["\']\s*:\s*["\'][a-f0-9]{8,}["\'])/i',
2388 $html
2389 );
2390 }
2391
2392 /**
2393 * The TTL (seconds) a response may be cached for, capped to the nonce
2394 * lifetime when it carries one.
2395 *
2396 * WordPress nonces are valid for at most `nonce_life` — 24h by default —
2397 * because wp_verify_nonce() accepts the current tick and the previous
2398 * one. Our own lifetime maximum is 720h and the shipped Aggressive
2399 * preset is 168h, so on any site configured above 24h every anonymous
2400 * front-end form carried a DEAD nonce for the majority of the cache's
2401 * life and every submission was rejected — with the other plugin's error
2402 * string ("Nonce not matched"), so the report never reached us (#236).
2403 *
2404 * `nonce_life` is the MAXIMUM a nonce can live, not the minimum, so it
2405 * is the wrong number to cap with. wp_nonce_tick() buckets time into
2406 * `nonce_life / 2` slices; a nonce minted x seconds into its bucket is
2407 * valid for `nonce_life - x`, where x can be as large as a full bucket.
2408 * Capping the entry at `nonce_life` therefore still served a dead nonce
2409 * for up to half of every entry's life — 0-12h of each 24h entry,
2410 * averaging 6h, re-rolled by every purge so it reads as intermittent.
2411 * Capping at the guaranteed-valid remainder closes the window at every
2412 * tick phase, at the cost of caching nonce-bearing pages for 12h rather
2413 * than 24h.
2414 *
2415 * Capping is per-entry, so only nonce-bearing pages pay for it; the rest
2416 * of the site keeps the configured lifetime.
2417 *
2418 * @param string $html Rendered response body.
2419 * @param int $ttl Otherwise-resolved TTL in seconds.
2420 * @return int TTL to actually use.
2421 */
2422 /**
2423 * The nonce lifetime to cap against, in seconds.
2424 *
2425 * `nonce_life` is a TWO-argument filter in core:
2426 *
2427 * $nonce_life = apply_filters( 'nonce_life', DAY_IN_SECONDS, $action );
2428 *
2429 * Applying it with one argument is not merely incomplete — a callback
2430 * that declares both parameters as required (the documented shape, and
2431 * what a site branching per action must write) raises ArgumentCountError
2432 * the moment we call it. That fatal lands in the shutdown cache write,
2433 * so the visitor still sees a perfectly normal page while the sidecar is
2434 * never written: the entry then keeps the FULL configured lifetime
2435 * carrying a dead nonce, which is precisely the bug #236 set out to fix.
2436 * Worse, the entry stays that way until a purge, even after the site
2437 * removes whatever customised the lifetime.
2438 *
2439 * We are inspecting rendered markup, so we cannot know which action
2440 * minted the nonce we found. Two consequences:
2441 *
2442 * 1. We pass `''` as the action. A per-action callback therefore sees
2443 * the same "unknown action" value core itself passes when a nonce is
2444 * created with no action, and can branch on it deliberately.
2445 * 2. A page may carry nonces from SEVERAL actions with different
2446 * lifetimes. The entry can only have one TTL, so the safe choice is
2447 * the SHORTEST lifetime any action on the site resolves to — capping
2448 * to a longer one would serve a dead nonce for the shorter action.
2449 * Sites can narrow this with `xspeed_cache_nonce_life_actions`.
2450 *
2451 * @param string $html Response body being cached.
2452 * @return int Nonce lifetime in seconds (0 = do not cap).
2453 */
2454 private static function nonce_life_seconds( string $html ): int {
2455 /**
2456 * Filter the nonce actions whose lifetimes are consulted when
2457 * capping a cache entry.
2458 *
2459 * The default `''` is the "action unknown" case — we are reading
2460 * rendered HTML, not minting a nonce. A site whose `nonce_life`
2461 * callback shortens specific actions can list them here so the cap
2462 * accounts for the shortest one that could appear on the page.
2463 *
2464 * @since 1.1.8
2465 * @param string[] $actions Nonce actions to resolve.
2466 * @param string $html The response body being cached.
2467 */
2468 $actions = (array) apply_filters( 'xspeed_cache_nonce_life_actions', array( '' ), $html );
2469 if ( empty( $actions ) ) {
2470 $actions = array( '' );
2471 }
2472
2473 $shortest = 0;
2474 foreach ( $actions as $action ) {
2475 // Both arguments, exactly as core passes them.
2476 $life = (int) apply_filters( 'nonce_life', DAY_IN_SECONDS, (string) $action );
2477 if ( $life < 1 ) {
2478 continue;
2479 }
2480 if ( 0 === $shortest || $life < $shortest ) {
2481 $shortest = $life;
2482 }
2483 }
2484
2485 return $shortest;
2486 }
2487
2488 public static function nonce_capped_ttl( string $html, int $ttl ): int {
2489 if ( ! self::response_has_nonce( $html ) ) {
2490 return $ttl;
2491 }
2492
2493 $nonce_life = self::nonce_life_seconds( $html );
2494 if ( $nonce_life < 1 ) {
2495 return $ttl;
2496 }
2497
2498 // Half of nonce_life is the GUARANTEED-valid remainder — see above.
2499 $guaranteed = max( 1, intdiv( $nonce_life, 2 ) );
2500 $capped = ( $ttl > 0 ) ? min( $ttl, $guaranteed ) : $guaranteed;
2501
2502 /**
2503 * Filter the nonce-capped TTL for a cache entry.
2504 *
2505 * Escape hatch for a site whose nonce-shaped markup is decorative —
2506 * return the uncapped $ttl to keep the configured lifetime. Most
2507 * sites should leave this alone: serving a dead nonce breaks every
2508 * anonymous form on the page.
2509 *
2510 * @param int $capped TTL after the nonce cap (seconds).
2511 * @param int $ttl TTL before the cap (seconds).
2512 * @param int $nonce_life Current nonce lifetime (seconds).
2513 * @param string $html The response body being cached.
2514 */
2515 return (int) apply_filters( 'xspeed_cache_nonce_ttl_cap', $capped, $ttl, $nonce_life, $html );
2516 }
2517
2518 private static function write_meta( string $key, string $html = '' ): void {
2519 $content_type = '';
2520 foreach ( headers_list() as $header ) {
2521 if ( 0 === stripos( $header, 'content-type:' ) ) {
2522 $content_type = trim( substr( $header, strlen( 'content-type:' ) ) );
2523 }
2524 }
2525 $status = function_exists( 'http_response_code' ) ? (int) http_response_code() : 200;
2526
2527 $meta = array();
2528 $is_default_type = ( '' === $content_type || false !== stripos( $content_type, 'text/html' ) );
2529 if ( ! $is_default_type ) {
2530 $meta['content_type'] = $content_type;
2531 }
2532 if ( 200 !== $status && $status > 0 ) {
2533 $meta['status'] = $status;
2534 }
2535
2536 // Per-content TTL (seconds). The drop-in and static fast paths can't
2537 // call is_expired() / the xspeed_cache_max_age filter (they run before
2538 // WP), so persist the resolved max-age here whenever it differs from
2539 // the plain page TTL — e.g. the Pro feed cache's 12h vs the 24h page
2540 // default. The fast paths read this to expire correctly. (FBS-82407)
2541 // This MUST resolve the TTL the same way is_expired() does, including
2542 // the per-post override — the sidecar is the only channel that can
2543 // carry a per-entry TTL into the pre-boot fast paths. Omitting the
2544 // override here left an editor's "expire this post after 1h" visible
2545 // to the engine but invisible to the drop-in, which kept serving the
2546 // entry until the global lifetime elapsed (#240 AC#3). Handing the
2547 // filter the same base as is_expired() also keeps a filter that
2548 // SCALES its input (e.g. $max_age * 2) consistent between the two.
2549 $opts = Settings_Manager::get( 'cache' );
2550 $default_ttl = (int) $opts['cache_expiry'] * HOUR_IN_SECONDS;
2551 $max_age = $default_ttl;
2552 $post_override = Cache_Rules::expiry_override_seconds_for_post( Cache_Rules::current_post_id() );
2553 if ( null !== $post_override ) {
2554 $max_age = $post_override;
2555 }
2556 /** This filter is documented in includes/class-cache.php */
2557 $ttl = (int) apply_filters( 'xspeed_cache_max_age', $max_age );
2558
2559 // A response carrying a nonce may not outlive that nonce, however
2560 // long the site's configured lifetime is (#236). This runs AFTER the
2561 // max-age filter so it caps whatever the filter resolved rather than
2562 // being overridden by it — a Pro module lengthening the TTL must not
2563 // be able to reintroduce a dead nonce.
2564 $ttl = self::nonce_capped_ttl( $html, $ttl );
2565
2566 if ( $ttl > 0 && $ttl !== $default_ttl ) {
2567 $meta['ttl'] = $ttl;
2568 }
2569
2570 // Nothing to replay → no sidecar.
2571 if ( empty( $meta ) ) {
2572 return;
2573 }
2574
2575 $payload = wp_json_encode( $meta );
2576 if ( false === $payload ) {
2577 return;
2578 }
2579 // 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.
2580 file_put_contents( self::cache_meta_for( $key ), $payload, LOCK_EX );
2581 }
2582
2583 /**
2584 * @param string $cause Free-form human reason. Recorded in the
2585 * Activity log to give users context (e.g.
2586 * 'post saved', 'settings change', 'manual',
2587 * 'theme switch').
2588 */
2589 /**
2590 * Purge the cache entries for ONE URL — every variant of it: the
2591 * flat-hash entry (+ .meta / .html.br siblings), both device buckets
2592 * (mobile_separate keys them separately), both trailing-slash forms,
2593 * and the static-tree index.html (+ .br) the server rewrite serves.
2594 * The rest of the cache is untouched — this is the surgical
2595 * alternative to purge_all for "I just edited this one page".
2596 *
2597 * @param string $url Absolute URL, or site-relative path ("/about/").
2598 * @param string $cause Who asked, for the purge log. See purge_all().
2599 * @return int Number of cache files removed.
2600 */
2601 /**
2602 * Post types that are not "viewable" but ARE the presentation layer.
2603 *
2604 * `is_post_type_viewable()` answers "does this type have a front end of
2605 * its own?" — which is the right question for `shop_order`, but the
2606 * wrong one for the types core uses to render every OTHER page. A
2607 * template part, a global-styles record, a navigation or a synced
2608 * pattern has no permalink, yet editing one changes how the whole site
2609 * looks. Gating purges on viewability alone meant a Site Editor save
2610 * invalidated nothing and visitors kept the old design for the full
2611 * TTL — up to 30 days at the maximum lifetime. (#270 regression)
2612 *
2613 * @return string[]
2614 */
2615 public static function presentation_post_types(): array {
2616 $types = array(
2617 'wp_template', // Site Editor templates.
2618 'wp_template_part', // Header / footer / reusable parts.
2619 'wp_global_styles', // Colours, typography, spacing.
2620 'wp_navigation', // Navigation block menus.
2621 'nav_menu_item', // Classic menus.
2622 'wp_block', // Synced patterns / reusable blocks.
2623 );
2624
2625 /**
2626 * Filter the non-viewable post types that still invalidate the cache.
2627 *
2628 * Add a type here when it has no front end of its own but changes
2629 * how other pages render (a theme's own layout CPT, for example).
2630 *
2631 * @param string[] $types Post type slugs.
2632 */
2633 return (array) apply_filters( 'xspeed_presentation_post_types', $types );
2634 }
2635
2636 /**
2637 * save_post → purge only when the saved thing can appear on a cached page.
2638 *
2639 * Revisions and autosaves are never rendered. Non-viewable post types —
2640 * WooCommerce's `shop_order` / `shop_order_placehold` / `shop_order_refund`
2641 * / `shop_coupon`, Flamingo's `flamingo_inbound` (#229), Tutor's
2642 * `tutor_enrolled` (#231) — are invisible to anonymous visitors, so
2643 * writing one changes nothing that is cached. (#243)
2644 *
2645 * The exception is the presentation types above, which are non-viewable
2646 * yet render every page — they are allow-listed BEFORE the viewability
2647 * test. (#270 regression)
2648 *
2649 * @param int $post_id Saved post ID.
2650 * @param \WP_Post $post Saved post object.
2651 */
2652 public static function on_save_post( $post_id, $post = null ): void {
2653 if ( function_exists( 'wp_is_post_revision' ) && wp_is_post_revision( $post_id ) ) {
2654 return;
2655 }
2656 if ( function_exists( 'wp_is_post_autosave' ) && wp_is_post_autosave( $post_id ) ) {
2657 return;
2658 }
2659
2660 $post_type = is_object( $post ) && isset( $post->post_type )
2661 ? (string) $post->post_type
2662 : (string) get_post_type( $post_id );
2663 if ( '' === $post_type ) {
2664 return;
2665 }
2666
2667 // Unknown/!viewable → nothing anonymous can see changed, UNLESS the
2668 // type is itself part of how pages render (#270 regression).
2669 if ( function_exists( 'is_post_type_viewable' )
2670 && ! is_post_type_viewable( $post_type )
2671 && ! in_array( $post_type, self::presentation_post_types(), true )
2672 ) {
2673 return;
2674 }
2675
2676 // Name the trigger rather than logging a bare numeric id — the old
2677 // wiring passed the post ID into $cause, so the log read
2678 // "Cache purged (46)" with no indication of what caused it. (#243)
2679 self::purge_all( 'post:' . $post_type );
2680 if ( class_exists( '\XSpeed\Minifier' ) ) {
2681 Minifier::purge_minified();
2682 }
2683 }
2684
2685 /**
2686 * comment_post → purge just the commented-on URL, and only once the
2687 * comment is actually visible.
2688 *
2689 * A comment held for moderation changes nothing on the front end, and an
2690 * approved one changes exactly one page — not the whole site. Product
2691 * reviews are comments and guest reviews are on by default, so under the
2692 * old wiring any visitor could flush a store's entire cache, repeatedly,
2693 * with no account. (#243)
2694 *
2695 * @param int $comment_id New comment ID.
2696 * @param int|string $approved 1 when approved, 0 when held, 'spam'.
2697 * @param array $data Comment data.
2698 */
2699 public static function on_comment_post( $comment_id, $approved = 0, $data = array() ): void {
2700 if ( 1 !== (int) $approved ) {
2701 return;
2702 }
2703 $post_id = is_array( $data ) && isset( $data['comment_post_ID'] ) ? (int) $data['comment_post_ID'] : 0;
2704 if ( $post_id < 1 ) {
2705 return;
2706 }
2707 $url = get_permalink( $post_id );
2708 if ( is_string( $url ) && '' !== $url ) {
2709 self::purge_url( $url, 'comment' );
2710 }
2711 }
2712
2713 /**
2714 * user_register / profile_update → purge only when the user can author
2715 * content that appears on the front end.
2716 *
2717 * A customer registering at checkout changes no rendered page, and cannot
2718 * change an enqueued asset — so it must not purge the cache, and must not
2719 * rebuild the minified bundles. Checkout account-creation fired FOUR
2720 * full-site purges plus four purge_minified() runs in a single request
2721 * before this gate. (#243)
2722 *
2723 * @param int $user_id Affected user.
2724 */
2725 public static function on_user_change( $user_id ): void {
2726 $user = function_exists( 'get_userdata' ) ? get_userdata( (int) $user_id ) : null;
2727 if ( ! $user ) {
2728 return;
2729 }
2730
2731 // Only roles that can publish can change a rendered page. WooCommerce
2732 // customers and WordPress subscribers cannot.
2733 if ( ! user_can( $user, 'edit_posts' ) ) {
2734 return;
2735 }
2736
2737 $url = get_author_posts_url( (int) $user_id );
2738 if ( is_string( $url ) && '' !== $url ) {
2739 self::purge_url( $url, 'user' );
2740 }
2741 }
2742
2743 /**
2744 * Purge everything a product's price / stock / sale state is rendered on.
2745 *
2746 * The product permalink is not enough: the shop archive and the product's
2747 * category and tag archives render the same price and Sale! badge, and
2748 * #242 reproduces all three going stale together.
2749 *
2750 * Accepts a product ID or a WC_Product. A variation resolves to its
2751 * parent, which is the page that actually renders.
2752 *
2753 * @param int|object $product Product ID or WC_Product.
2754 */
2755 public static function purge_product( $product ): void {
2756 $product_id = is_object( $product ) && method_exists( $product, 'get_id' )
2757 ? (int) $product->get_id()
2758 : (int) $product;
2759 if ( $product_id < 1 ) {
2760 return;
2761 }
2762
2763 // Variations are never rendered on their own URL.
2764 $parent = (int) wp_get_post_parent_id( $product_id );
2765 if ( $parent > 0 ) {
2766 $product_id = $parent;
2767 }
2768
2769 $urls = array();
2770
2771 $permalink = get_permalink( $product_id );
2772 if ( is_string( $permalink ) && '' !== $permalink ) {
2773 $urls[] = $permalink;
2774 }
2775
2776 // The shop archive.
2777 if ( function_exists( 'wc_get_page_id' ) ) {
2778 $shop_id = (int) wc_get_page_id( 'shop' );
2779 if ( $shop_id > 0 ) {
2780 $shop_url = get_permalink( $shop_id );
2781 if ( is_string( $shop_url ) && '' !== $shop_url ) {
2782 $urls[] = $shop_url;
2783 }
2784 }
2785 }
2786
2787 // Every category / tag archive this product appears on.
2788 foreach ( array( 'product_cat', 'product_tag' ) as $taxonomy ) {
2789 $terms = get_the_terms( $product_id, $taxonomy );
2790 if ( ! is_array( $terms ) ) {
2791 continue;
2792 }
2793 foreach ( $terms as $term ) {
2794 $term_url = get_term_link( $term );
2795 if ( is_string( $term_url ) && '' !== $term_url ) {
2796 $urls[] = $term_url;
2797 }
2798 }
2799 }
2800
2801 // The front page, when it is not the shop page but still lists
2802 // products (a block/shortcode storefront).
2803 $front_id = (int) get_option( 'page_on_front' );
2804 if ( $front_id > 0 ) {
2805 $front_url = get_permalink( $front_id );
2806 if ( is_string( $front_url ) && '' !== $front_url ) {
2807 $urls[] = $front_url;
2808 }
2809 }
2810
2811 /**
2812 * Filter the URLs purged when a product changes.
2813 *
2814 * A storefront that renders products somewhere else — a landing page,
2815 * a custom archive — can add its URLs here rather than falling back
2816 * to purging the whole site.
2817 *
2818 * @param string[] $urls URLs about to be purged.
2819 * @param int $product_id The product that changed.
2820 */
2821 $urls = (array) apply_filters( 'xspeed_purge_product_urls', $urls, $product_id );
2822
2823 foreach ( array_unique( array_filter( $urls ) ) as $url ) {
2824 self::purge_url( (string) $url, 'product' );
2825 }
2826 }
2827
2828 /**
2829 * Adapter for the WooCommerce stock actions that pass a product OBJECT
2830 * where the status actions pass an ID.
2831 *
2832 * @param object $product WC_Product (or variation).
2833 */
2834 public static function purge_product_object( $product ): void {
2835 self::purge_product( $product );
2836 }
2837
2838 public static function purge_url( string $url, string $cause = 'manual' ): int {
2839 $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.
2840 if ( ! is_array( $parts ) ) {
2841 return 0;
2842 }
2843 // Keep the port. `cache_key()` hashes the raw `HTTP_HOST`, which
2844 // carries `:8080` on any install not served from 80/443 — while
2845 // parse_url() splits the port into its own component, so a purge that
2846 // used the bare host computed a different md5, found no file, and
2847 // reported "already cold". A silent no-op: the page kept serving HIT
2848 // until its TTL ran out. Intranet installs, panel hosts on :8443 and
2849 // proxies that forward `Host: site.com:8080` all hit this.
2850 $host = isset( $parts['host'] ) ? strtolower( (string) $parts['host'] ) : '';
2851 if ( '' !== $host && isset( $parts['port'] ) ) {
2852 $host .= ':' . (int) $parts['port'];
2853 }
2854 if ( '' === $host && function_exists( 'home_url' ) ) {
2855 $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.
2856 if ( is_array( $home ) && isset( $home['host'] ) ) {
2857 $host = strtolower( (string) $home['host'] );
2858 if ( isset( $home['port'] ) ) {
2859 $host .= ':' . (int) $home['port'];
2860 }
2861 }
2862 }
2863 if ( '' === $host ) {
2864 return 0;
2865 }
2866 $path = isset( $parts['path'] ) ? (string) $parts['path'] : '/';
2867 $path = '/' . ltrim( $path, '/' );
2868 if ( false !== strpos( $path, '..' ) ) {
2869 return 0;
2870 }
2871
2872 // The cache key preserves REQUEST_URI's trailing-slash form, so
2873 // purge both. Root stays a single '/'.
2874 $forms = array( $path );
2875 if ( '/' !== $path ) {
2876 $forms[] = rtrim( $path, '/' );
2877 $forms[] = rtrim( $path, '/' ) . '/';
2878 }
2879 $forms = array_unique( $forms );
2880
2881 /*
2882 * Entries live under the bucket they were written for, and this URL's
2883 * site may not be the one serving THIS request (a cross-site purge on
2884 * multisite, WP-CLI, or cron). Build the directory from the URL's own
2885 * host AND path. (#6)
2886 *
2887 * Host alone is wrong on a subdirectory network: `store()` wrote to
2888 * `<host>/<prefix>/`, so looking in `<host>/` found nothing and the
2889 * call reported "already cold" while the page kept serving HIT — a
2890 * false success, which is worse than an error. The prefix has to come
2891 * from the URL being purged rather than from the current blog, because
2892 * the caller is usually purging some OTHER site. (QA B2 on #166)
2893 */
2894 $base = XSPEED_CACHE_DIR . '/' . self::bucket_for_url( $host, $path );
2895
2896 $count = 0;
2897 foreach ( $forms as $uri ) {
2898 // '' = mobile_separate off; '|m' / '|d' = the device buckets.
2899 foreach ( array( '', '|m', '|d' ) as $device ) {
2900 $key = md5( $host . $uri . $device );
2901 $file = $base . '/' . $key . '.html';
2902 if ( is_file( $file ) ) {
2903 wp_delete_file( $file );
2904 ++$count;
2905 }
2906 foreach ( array( $base . '/' . $key . '.meta', $file . '.br', self::brotli_size_sidecar( $file . '.br' ) ) as $sidecar ) {
2907 if ( is_file( $sidecar ) ) {
2908 wp_delete_file( $sidecar );
2909 }
2910 }
2911 }
2912 }
2913
2914 // Static tree (served directly by the nginx/.htaccess rewrite).
2915 if ( defined( 'XSPEED_CACHE_STATIC_DIR' ) ) {
2916 // Same transform the write used — `localhost:8080` files under
2917 // `localhost8080`, so the bare host found nothing here either.
2918 $dir = rtrim( XSPEED_CACHE_STATIC_DIR, '/' ) . '/' . self::static_host_dir( $host ) . ( '/' === $path ? '' : rtrim( $path, '/' ) );
2919 $file = $dir . '/index.html';
2920 if ( is_file( $file ) ) {
2921 wp_delete_file( $file );
2922 ++$count;
2923 }
2924 foreach ( array( $file . '.br', self::brotli_size_sidecar( $file . '.br' ) ) as $sidecar ) {
2925 if ( is_file( $sidecar ) ) {
2926 wp_delete_file( $sidecar );
2927 }
2928 }
2929 }
2930
2931 if ( $count > 0 ) {
2932 Cache_Inventory::invalidate();
2933 Activity_Log::record(
2934 'cache_purge_url',
2935 sprintf(
2936 /* translators: 1: cause of the purge, 2: URL or path, 3: number of files removed. */
2937 __( 'Purged one URL (%1$s) — %2$s, %3$d file(s) removed', 'xspeed' ),
2938 $cause,
2939 $host . $path,
2940 $count
2941 ),
2942 Activity_Log::INFO
2943 );
2944 }
2945
2946 return $count;
2947 }
2948
2949 /**
2950 * Purge this site's cache.
2951 *
2952 * On multisite every blog shares one cache directory, so an unscoped
2953 * sweep here took the whole network cold — one subsite's settings save
2954 * or post publish rebuilt every other site from PHP. Entries are stored
2955 * per host (see host_dir()), and the sweep is scoped to match, so a
2956 * purge originating on site-a leaves site-b's cache warm. (#6)
2957 *
2958 * @param string $cause Who asked, for the purge log.
2959 * @param string|null $host Host to purge. Defaults to the current site.
2960 * Pass '*' to sweep the ENTIRE tree — network
2961 * admin's "purge all sites", and the migration
2962 * of pre-#6 entries that sit in the tree root.
2963 */
2964 public static function purge_all( string $cause = 'manual', ?string $host = null ) {
2965 $network_wide = ( '*' === $host );
2966 // The flat tree buckets by a flattened segment (host/a-b) while the
2967 // static tree mirrors the URL (host/a/b), so they need separate
2968 // scopes — see current_host_dir() vs current_static_scope().
2969 $static_scope = '';
2970 if ( null === $host || $network_wide ) {
2971 $scope = $network_wide ? '' : self::current_host_dir();
2972 $static_scope = $network_wide ? '' : self::current_static_scope();
2973 } else {
2974 $dir = self::host_dir( $host );
2975 $scope = '' === $dir ? 'default' : $dir;
2976 $static_scope = $scope;
2977 }
2978
2979 $count = 0;
2980 if ( is_dir( XSPEED_CACHE_DIR ) ) {
2981 // Scoped to one host directory, or the whole tree (including the
2982 // legacy top-level entries written before #6) when network-wide.
2983 /*
2984 * Network-wide sweeps go TWO levels deep, not one. A subdirectory
2985 * subsite's bucket is `<host>/<prefix>/`, so globbing only
2986 * `<cache>/*` reached the main site and left every subsite's
2987 * entries in place. (QA D5 on #166)
2988 *
2989 * A scoped purge also has to cover its own nested buckets: when
2990 * the main blog of a subdirectory network purges, `<host>/` is its
2991 * bucket and `<host>/one/` belongs to another blog — so the scoped
2992 * branch deliberately does NOT descend, which is what keeps
2993 * site-level purges isolated.
2994 */
2995 $roots = $network_wide
2996 ? array_merge(
2997 array( XSPEED_CACHE_DIR ),
2998 array_filter( (array) glob( XSPEED_CACHE_DIR . '/*', GLOB_ONLYDIR ) ),
2999 array_filter( (array) glob( XSPEED_CACHE_DIR . '/*/*', GLOB_ONLYDIR ) )
3000 )
3001 : array( XSPEED_CACHE_DIR . '/' . $scope );
3002
3003 foreach ( $roots as $root ) {
3004 /*
3005 * min/ and rest/ are swept by their own purgers below; never
3006 * treat them as host buckets.
3007 *
3008 * Checked on every path SEGMENT, not just the basename: now
3009 * that the network-wide glob descends two levels it can reach
3010 * `min/combined`, whose basename is `combined` and would sail
3011 * past a basename-only test — deleting the combined
3012 * stylesheets out from under the pages that link them.
3013 */
3014 if ( ! $network_wide || XSPEED_CACHE_DIR !== $root ) {
3015 $relative = trim( str_replace( XSPEED_CACHE_DIR, '', (string) $root ), '/' );
3016 $segments = '' === $relative ? array() : explode( '/', $relative );
3017 if ( array_intersect( $segments, array( 'min', 'rest' ) ) ) {
3018 continue;
3019 }
3020 }
3021 if ( ! is_dir( $root ) ) {
3022 continue;
3023 }
3024 $files = glob( $root . '/*.html' );
3025 if ( $files ) {
3026 $count += count( $files );
3027 foreach ( $files as $f ) {
3028 wp_delete_file( $f );
3029 }
3030 }
3031 // Remove the .meta sidecars (content-type for feeds/sitemaps)
3032 // alongside their .html entries. Not counted — they're not
3033 // cache "pages", just per-entry metadata.
3034 $meta = glob( $root . '/*.meta' );
3035 if ( $meta ) {
3036 foreach ( $meta as $m ) {
3037 wp_delete_file( $m );
3038 }
3039 }
3040 // Remove precompressed siblings (e.g. <key>.html.br from the Pro
3041 // Brotli module). Not counted — same as .meta. Without this a
3042 // purge leaves stale .br bodies behind: disk bloat, and a
3043 // staleness window if precompression is later disabled.
3044 $br = glob( $root . '/*.br' );
3045 if ( $br ) {
3046 foreach ( $br as $b ) {
3047 wp_delete_file( $b );
3048 }
3049 }
3050 // `*.br` does not match `*.br.size` — same reason as the flat-root
3051 // sweep above: a size record outliving its body would later be
3052 // read against a different sibling's bytes.
3053 $br_size = glob( $root . '/*.br.size' );
3054 if ( $br_size ) {
3055 foreach ( $br_size as $b ) {
3056 wp_delete_file( $b );
3057 }
3058 }
3059 }
3060 }
3061 // Static-cache tree purge — recursive because the layout is
3062 // xspeed-static/{host}/{path}/index.html, so a flat glob can't
3063 // reach everything. Already host-segmented, so scoping is just a
3064 // matter of starting one level down.
3065 if ( is_dir( XSPEED_CACHE_STATIC_DIR ) ) {
3066 $static_root = $network_wide
3067 ? XSPEED_CACHE_STATIC_DIR
3068 : XSPEED_CACHE_STATIC_DIR . '/' . $static_scope;
3069 if ( is_dir( $static_root ) ) {
3070 $count += self::rmtree_html( $static_root );
3071 }
3072 }
3073 // REST response cache (cache/xspeed/rest/*.json) — same purge
3074 // triggers (publish, settings change) invalidate it too.
3075 $count += Rest_Cache::purge();
3076
3077 // Minified + combined CSS/JS (cache/xspeed/min/ and min/combined/).
3078 // purge_all is a full filesystem sweep and must clear these too, even
3079 // when the Minify module is currently disabled — orphaned min/ files
3080 // from a feature the user later turned off must still be removed, and
3081 // a stale combined-<hash>.css that the regenerated page no longer
3082 // references otherwise 404s and breaks the frontend. (FBS-83114/83116)
3083 if ( class_exists( '\\XSpeed\\Minifier' ) ) {
3084 Minifier::purge_minified();
3085 }
3086
3087 // Persistent object cache (Redis / Memcached). Flush regardless of
3088 // whether the Object Cache module is currently enabled — a drop-in
3089 // installed earlier keeps serving until flushed.
3090 //
3091 // wp_cache_flush() is NETWORK-global: on multisite it would drop
3092 // every other site's object cache too, which is the same bug this
3093 // change fixes for the page cache. Prefer the blog-scoped flush
3094 // (WP 6.1+) unless we were explicitly asked to go network-wide. (#6)
3095 if ( ! $network_wide && is_multisite() && function_exists( 'wp_cache_flush_group' ) && function_exists( 'wp_cache_supports' ) && wp_cache_supports( 'flush_group' ) ) {
3096 // Blog-scoped groups only; a shared/global group (site options,
3097 // user meta) is intentionally left alone.
3098 foreach ( array( 'options', 'posts', 'terms', 'post_meta', 'comment' ) as $group ) {
3099 wp_cache_flush_group( $group );
3100 }
3101 } elseif ( function_exists( 'wp_cache_flush' ) ) {
3102 wp_cache_flush();
3103 }
3104
3105 self::update_stats( array( 'last_purge' => time() ) );
3106
3107 // Fire AFTER the local sweep so module listeners (Critical CSS,
3108 // Unused CSS, Cloudflare edge purge) run — this action had three
3109 // registered listeners but was never emitted. Treat it as additive
3110 // (CDN / edge invalidation), not the mechanism for clearing local
3111 // files. (FBS-83114)
3112 do_action( 'xspeed_after_purge_all', $cause );
3113
3114 // The list behind the "Cached pages" card is memoized for a minute;
3115 // a purge has to drop it or the drill-down shows pages that no
3116 // longer exist.
3117 Cache_Inventory::invalidate();
3118
3119 // Trigger of WP_CLI / hook / admin-bar purges all hit the same
3120 // path. Record once with the supplied cause so the dashboard
3121 // activity feed reads naturally.
3122 Activity_Log::record(
3123 'cache_purged',
3124 sprintf( 'Cache purged (%s) — %d file%s removed', $cause, $count, 1 === $count ? '' : 's' ),
3125 Activity_Log::INFO
3126 );
3127
3128 return $count;
3129 }
3130
3131 /**
3132 * Purge everything after a plugin / theme / core update completes.
3133 *
3134 * Bound to `upgrader_process_complete`, which is the only hook an update
3135 * fires — no activation hook runs, so without this the cached HTML (and
3136 * the asset URLs baked into it) outlives the code that produced it.
3137 *
3138 * Runs for plugin, theme and core updates alike, including bulk runs and
3139 * auto-updates, and purges the WHOLE network rather than the current
3140 * site — see the call below. Translation updates are skipped: they
3141 * change no markup a cached page depends on, and language packs update
3142 * often enough that purging on them would keep a multilingual site
3143 * permanently cold.
3144 *
3145 * Note this cannot be folded into the `$invalidate_hooks` loop above:
3146 * that binds `purge_all` directly, and `purge_all( string $cause )` would
3147 * then receive the WP_Upgrader instance as its cause.
3148 *
3149 * @param mixed $upgrader WP_Upgrader instance (unused).
3150 * @param array $hook_extra Context for the completed operation.
3151 * @return void
3152 */
3153 public static function purge_after_upgrade( $upgrader = null, $hook_extra = array() ) {
3154 $cleared = self::$upgrade_cleared_destination;
3155
3156 if ( ! self::upgrade_produced_something( $upgrader ) ) {
3157 return;
3158 }
3159
3160 if ( ! self::upgrade_should_purge( is_array( $hook_extra ) ? $hook_extra : array(), $cleared ) ) {
3161 return;
3162 }
3163
3164 self::purge_for_upgrade();
3165 }
3166
3167 /**
3168 * Whether this request's upgrader removed an existing copy.
3169 *
3170 * @var bool
3171 */
3172 private static $upgrade_cleared_destination = false;
3173
3174 /**
3175 * How many `upgrader_process_complete` dispatches are on the stack.
3176 *
3177 * @var int
3178 */
3179 private static $upgrade_dispatch_depth = 0;
3180
3181 /**
3182 * Enter an `upgrader_process_complete` dispatch.
3183 *
3184 * Bound at PHP_INT_MIN, so it runs before any listener that might read
3185 * the replacement signal. Public because it is a hook target.
3186 *
3187 * @return void
3188 */
3189 public static function note_upgrade_dispatch(): void {
3190 ++self::$upgrade_dispatch_depth;
3191 }
3192
3193 /**
3194 * Drop the replacement signal once every listener has read it.
3195 *
3196 * Bound at PHP_INT_MAX so a second upgrade in the same request starts
3197 * clean, without taking the answer away from the add-on callbacks that
3198 * run at the same priority as ours.
3199 *
3200 * Only the OUTERMOST dispatch clears it. A nested run — core's language
3201 * pack upgrader, or any add-on that installs something from this hook —
3202 * fires the action again, and clearing there would answer for a run that
3203 * has not finished. Called directly (no dispatch on the stack) it still
3204 * clears, which is what a test wants.
3205 *
3206 * Known limit: a nested run INHERITS the outer run's signal, because the
3207 * only evidence we get is a filter that fires before the nested dispatch
3208 * begins and carries no upgrader identity. So a fresh install performed
3209 * from inside a replacement run reads as a replacement and purges once
3210 * more than it needs to. A cold cache is the cheap direction, and the
3211 * alternative — scoping the signal per upgrader — is not knowable from
3212 * `upgrader_clear_destination`.
3213 *
3214 * @return void
3215 */
3216 public static function forget_cleared_destination(): void {
3217 if ( self::$upgrade_dispatch_depth > 0 ) {
3218 --self::$upgrade_dispatch_depth;
3219 }
3220
3221 if ( 0 === self::$upgrade_dispatch_depth ) {
3222 self::$upgrade_cleared_destination = false;
3223 }
3224 }
3225
3226 /**
3227 * Record that the upgrader cleared an existing destination.
3228 *
3229 * A pass-through listener on `upgrader_clear_destination`: WordPress only
3230 * fires it when `clear_destination` was set AND something was there to
3231 * remove, which is the one signal that separates an upload-and-replace
3232 * from a first-time install. The filtered value is returned untouched.
3233 *
3234 * @param true|\WP_Error $removed Whether the destination was cleared.
3235 * @return true|\WP_Error
3236 */
3237 public static function note_cleared_destination( $removed ) {
3238 if ( ! is_wp_error( $removed ) ) {
3239 self::$upgrade_cleared_destination = true;
3240 }
3241
3242 return $removed;
3243 }
3244
3245 /**
3246 * Did the completed run actually replace anything?
3247 *
3248 * `upgrader_process_complete` fires whether the run succeeded or failed —
3249 * the failure branch in WP_Upgrader::run() only feeds the skin before the
3250 * action fires. A run that installed nothing changed no markup, so purging
3251 * for it is a cold cache bought for nothing.
3252 *
3253 * Deliberately conservative: this returns false ONLY when every result we
3254 * can see is an error. An upgrader we cannot read, a mixed bulk run, or a
3255 * missing result all fall through to purging, which is the safe direction
3256 * everywhere else in this handler.
3257 *
3258 * @param mixed $upgrader WP_Upgrader instance, or anything else.
3259 * @return bool
3260 */
3261 public static function upgrade_produced_something( $upgrader ): bool {
3262 if ( ! is_object( $upgrader ) ) {
3263 return true;
3264 }
3265
3266 // A bulk run collects one entry per item; `result` alone would only
3267 // describe the last of them.
3268 if ( isset( $upgrader->results ) && is_array( $upgrader->results ) && ! empty( $upgrader->results ) ) {
3269 foreach ( $upgrader->results as $result ) {
3270 if ( ! is_wp_error( $result ) && ! empty( $result ) ) {
3271 return true;
3272 }
3273 }
3274 return false;
3275 }
3276
3277 if ( ! property_exists( $upgrader, 'result' ) ) {
3278 return true;
3279 }
3280
3281 return ! is_wp_error( $upgrader->result ) && ! empty( $upgrader->result );
3282 }
3283
3284 /**
3285 * Decide whether a completed operation invalidates the cache.
3286 *
3287 * Split out from the handler so the decision is testable on its own:
3288 * purge_all() reaches straight for glob() and unlink(), which a unit test
3289 * cannot observe honestly, while every rule that matters lives here.
3290 *
3291 * @param array $hook_extra Context for the completed operation.
3292 * @return bool
3293 */
3294 public static function upgrade_should_purge( array $hook_extra, bool $destination_cleared = false ): bool {
3295 if ( ! self::upgrade_replaced_code( $hook_extra, $destination_cleared ) ) {
3296 return false;
3297 }
3298
3299 // An update to xSpeed ITSELF always purges, whatever the setting says.
3300 // This plugin's own code is what rendered every cached page — the
3301 // minifier, lazy-loader, resource hints and CDN rewriter all changed
3302 // underneath it — so serving that HTML after an update means serving
3303 // output from a version that no longer exists. Minified assets make it
3304 // concrete rather than theoretical: their filenames are keyed on the
3305 // source filemtime, so they regenerate under NEW hashes while the
3306 // cached pages still link the old ones, and the page requests files
3307 // that are no longer on disk. Offering an opt-out for that would be
3308 // offering a broken site.
3309 return self::upgrade_touches_xspeed( $hook_extra ) || self::purge_on_upgrade_enabled();
3310 }
3311
3312 /**
3313 * Did this completed run replace code that renders pages?
3314 *
3315 * The half of the decision that has nothing to do with our settings: it
3316 * asks only whether live code changed underneath the output we cached.
3317 * Add-ons that keep their own derived artifacts — generated CSS, captured
3318 * selectors, fingerprints — need the same answer and must not have to
3319 * rebuild these rules, or they drift apart. Call it with the hook's own
3320 * `$hook_extra`; the upload-and-replace signal is read from this request.
3321 *
3322 * Deliberately independent of the "Purge After Updates" setting. That
3323 * setting governs the page cache, not whether an add-on's derived data is
3324 * still valid.
3325 *
3326 * @param array $hook_extra Context for the completed operation.
3327 * @param bool|null $destination_cleared Override the recorded signal; null reads this request's.
3328 * @return bool
3329 */
3330 public static function upgrade_replaced_code( array $hook_extra, ?bool $destination_cleared = null ): bool {
3331 $cleared = null === $destination_cleared ? self::$upgrade_cleared_destination : $destination_cleared;
3332
3333 $type = isset( $hook_extra['type'] ) ? (string) $hook_extra['type'] : '';
3334 $action = isset( $hook_extra['action'] ) ? (string) $hook_extra['action'] : '';
3335
3336 // `upgrader_process_complete` fires for INSTALLS as well as updates.
3337 // A freshly installed plugin is inactive and a freshly installed theme
3338 // is not the active one, so neither can change a single rendered page
3339 // — but the first cut of this handler purged the whole tree anyway, so
3340 // evaluating three plugins in a row emptied the cache three times.
3341 //
3342 // 'install' alone is NOT enough to skip on, because WordPress reports
3343 // an upload-and-replace as an install: `Plugin_Upgrader::install()`
3344 // hardcodes `action => install` and `overwrite_package` does not change
3345 // it, so "Replace current with uploaded" and `wp plugin install <zip>
3346 // --force` both arrive here labelled install while genuinely replacing
3347 // live code. That is how a plugin distributed as a zip is updated, and
3348 // skipping it put back the stale markup this handler exists to clear.
3349 //
3350 // The distinguishing signal is whether the destination was cleared:
3351 // WP_Upgrader only fires `upgrader_clear_destination` when it removed
3352 // something that was already there. Installing beside nothing does not.
3353 if ( 'install' === $action && ! $cleared ) {
3354 return false;
3355 }
3356
3357 // 'translation' is the one update type that cannot change rendered
3358 // markup. Anything else — including an empty type from a custom
3359 // updater — is treated as cache-invalidating, because guessing wrong
3360 // in that direction only costs a cold cache.
3361 if ( 'translation' === $type ) {
3362 return false;
3363 }
3364
3365 return true;
3366 }
3367
3368 /**
3369 * Purge everything an update can invalidate.
3370 *
3371 * Network-wide ('*'), not the calling site's bucket. A plugin, theme or
3372 * core update replaces code shared by EVERY site on the network, so a
3373 * scoped purge would clear the site that happened to run the updater and
3374 * leave every other subsite serving pre-update HTML for the whole TTL —
3375 * the very bug this handler exists to fix, one level down. On single-site
3376 * this is identical to the scoped call, since there is only ever one
3377 * bucket.
3378 *
3379 * @return void
3380 */
3381 private static function purge_for_upgrade(): void {
3382 self::purge_all( 'upgrade', '*' );
3383 Minifier::purge_minified();
3384 }
3385
3386 /**
3387 * Purge after an unattended background update run.
3388 *
3389 * `automatic_updates_complete` passes ONE argument, and it is not a
3390 * hook_extra: it is WordPress's results array, keyed by what was updated
3391 * ('core', 'plugin', 'theme', 'translation'). Handing it to
3392 * purge_after_upgrade() put it in the unused $upgrader slot and left the
3393 * type empty, so a night on which only a language pack updated purged
3394 * every cached page — the exact case the translation exemption exists to
3395 * prevent, and WordPress auto-updates language packs by default.
3396 *
3397 * @param array $results Update results, keyed by type.
3398 * @return void
3399 */
3400 public static function purge_after_auto_updates( $results = array() ): void {
3401 if ( ! self::auto_updates_should_purge( is_array( $results ) ? $results : array() ) ) {
3402 return;
3403 }
3404
3405 self::purge_for_upgrade();
3406 }
3407
3408 /**
3409 * Decide whether a background update run invalidates the cache.
3410 *
3411 * @param array $results Update results, keyed by type.
3412 * @return bool
3413 */
3414 public static function auto_updates_should_purge( array $results ): bool {
3415 // An unrecognisable payload is treated as invalidating, the same
3416 // direction every other unknown takes here.
3417 if ( empty( $results ) ) {
3418 return true;
3419 }
3420
3421 // Failed items are listed alongside successful ones — WP_Automatic_Updater
3422 // appends an entry whatever the outcome — and a night on which every
3423 // update failed replaced no code, so it invalidates nothing.
3424 $updated = array();
3425 foreach ( $results as $type => $items ) {
3426 if ( ! is_array( $items ) ) {
3427 continue;
3428 }
3429 foreach ( $items as $item ) {
3430 $result = is_object( $item ) && isset( $item->result ) ? $item->result : true;
3431 if ( ! is_wp_error( $result ) && ! empty( $result ) ) {
3432 $updated[] = (string) $type;
3433 break;
3434 }
3435 }
3436 }
3437
3438 if ( empty( $updated ) ) {
3439 return false;
3440 }
3441
3442 // Nothing but language packs: a language pack changes no markup a
3443 // cached page depends on, and purging on one would keep a multilingual
3444 // site permanently cold.
3445 if ( array( 'translation' ) === array_values( array_unique( $updated ) ) ) {
3446 return false;
3447 }
3448
3449 return self::auto_updates_touch_xspeed( $results ) || self::purge_on_upgrade_enabled();
3450 }
3451
3452 /**
3453 * Does a background run include one of our own plugins?
3454 *
3455 * Same rule as a foreground self-update, read out of the results array's
3456 * shape instead of a hook_extra: each plugin entry carries the update
3457 * object on `->item->plugin`.
3458 *
3459 * @param array $results Update results, keyed by type.
3460 * @return bool
3461 */
3462 private static function auto_updates_touch_xspeed( array $results ): bool {
3463 if ( empty( $results['plugin'] ) || ! is_array( $results['plugin'] ) ) {
3464 return false;
3465 }
3466
3467 $ours = self::self_update_plugins();
3468 foreach ( $results['plugin'] as $entry ) {
3469 $item = is_object( $entry ) && isset( $entry->item ) ? $entry->item : null;
3470 $file = is_object( $item ) && isset( $item->plugin ) ? (string) $item->plugin : '';
3471 if ( '' !== $file && in_array( $file, $ours, true ) ) {
3472 return true;
3473 }
3474 }
3475
3476 return false;
3477 }
3478
3479 /**
3480 * Is the "Purge After Updates" setting on?
3481 *
3482 * Gates THIRD-PARTY updates only — an xSpeed self-update ignores it, see
3483 * purge_after_upgrade(). Defaults to true when the option has never been
3484 * written, matching the schema default in CacheModule: an unset value on
3485 * an existing install must not read as "the user turned this off".
3486 *
3487 * Unlike LiteSpeed, which ships the equivalent toggle OFF, this defaults
3488 * ON — a cold cache costs one slow request, whereas stale HTML is a wrong
3489 * page for up to the full TTL and the site owner has no way to tell why.
3490 *
3491 * @return bool
3492 */
3493 private static function purge_on_upgrade_enabled(): bool {
3494 $opts = Settings_Manager::get( 'cache' );
3495 return ! array_key_exists( 'purge_on_upgrade', $opts ) || ! empty( $opts['purge_on_upgrade'] );
3496 }
3497
3498 /**
3499 * Does this completed update include xSpeed itself?
3500 *
3501 * Mirrors the payload shapes Plugin::maybe_restore_after_update() reads:
3502 * a single update carries 'plugin', a bulk run carries 'plugins'.
3503 *
3504 * @param array $hook_extra Context for the completed operation.
3505 * @return bool
3506 */
3507 private static function upgrade_touches_xspeed( array $hook_extra ): bool {
3508 if ( ! isset( $hook_extra['type'] ) || 'plugin' !== $hook_extra['type'] ) {
3509 return false;
3510 }
3511
3512 $updated = array();
3513 if ( isset( $hook_extra['plugins'] ) && is_array( $hook_extra['plugins'] ) ) {
3514 // Strings only: array_intersect() stringifies what it is given, so
3515 // an object without __toString in a custom updater's payload would
3516 // be a fatal rather than a miss.
3517 $updated = array_filter( $hook_extra['plugins'], 'is_string' );
3518 } elseif ( isset( $hook_extra['plugin'] ) && is_string( $hook_extra['plugin'] ) ) {
3519 $updated = array( $hook_extra['plugin'] );
3520 }
3521
3522 return (bool) array_intersect( self::self_update_plugins(), $updated );
3523 }
3524
3525 /**
3526 * Plugin files whose update counts as an update to us.
3527 *
3528 * The self-update rule is "our own code rendered this cached HTML, so it
3529 * must not survive the code being replaced". That is true of any add-on
3530 * that writes into the same page: an add-on inlines critical CSS, rewrites
3531 * stylesheet links and image URLs, and produces the compressed and static
3532 * copies, so its update leaves exactly the stale markup this rule exists
3533 * to clear. Free cannot name an add-on, so it asks instead.
3534 *
3535 * Filter: xspeed_self_update_plugins
3536 *
3537 * Add-ons add their own `plugin_basename( __FILE__ )`. Entries are matched
3538 * against the plugin files WordPress reports for the completed update, so
3539 * a value that is not a `dir/file.php` basename simply never matches.
3540 *
3541 * @param string[] $plugins Plugin basenames treated as our own.
3542 * @return string[]
3543 */
3544 private static function self_update_plugins(): array {
3545 $ours = array( plugin_basename( XSPEED_FILE ) );
3546
3547 /** This filter is documented above. */
3548 $filtered = apply_filters( 'xspeed_self_update_plugins', $ours );
3549
3550 // Our own file is merged back afterwards rather than trusted to survive
3551 // the round trip. A listener that returns null, a bare string, or a
3552 // list it built from scratch would otherwise drop it, and the plugin
3553 // would quietly stop exempting its OWN update from the setting — a
3554 // failure no add-on author would think to test for.
3555 $claimed = array_filter( is_array( $filtered ) ? $filtered : array(), 'is_string' );
3556
3557 return array_values( array_unique( array_merge( $ours, array_filter( $claimed ) ) ) );
3558 }
3559
3560 /**
3561 * Invalidate caches of RENDERED output owned by other plugins.
3562 *
3563 * purge_all() sweeps only what xSpeed wrote. A page builder that stores
3564 * rendered HTML or generated CSS of its own — Elementor's element cache
3565 * and `uploads/elementor/css/`, and the equivalents in Beaver / Divi /
3566 * Bricks / Oxygen — keeps whatever asset URLs were current when it was
3567 * written, and no xSpeed purge has ever reached it.
3568 *
3569 * That only matters for rewrites that happen DURING render rather than on
3570 * the finished page. Minify, combine, lazy-load and resource hints all run
3571 * on `xspeed_cache_final_html` or a `template_redirect` buffer — after the
3572 * builder has already stored its copy — so nothing they emit can leak.
3573 * The CDN module's `wp_get_attachment_url` filter is the one that can.
3574 *
3575 * Called ONLY from purges where asset URLs themselves can have changed
3576 * (a CDN settings write, an explicit Purge All). NOT from purge_all(),
3577 * which also runs on every post publish — regenerating every builder CSS
3578 * file that often would cost more than it saves, and the builder already
3579 * invalidates its own copy for the post being saved.
3580 *
3581 * @param string $cause Who asked. Threaded through to the listeners and
3582 * the activity log.
3583 * @return string[] Labels of the caches that were actually cleared.
3584 */
3585 public static function purge_render_caches( string $cause = 'manual' ): array {
3586 /**
3587 * Clear render caches belonging to other plugins.
3588 *
3589 * A listener does its own work and appends a human-readable label for
3590 * what it cleared, so the activity log can name it. Returning
3591 * `$cleared` unchanged means "nothing of mine is installed" and is the
3592 * correct no-op — never a failure.
3593 *
3594 * Detect the owning plugin by class or constant, not by an
3595 * `is_plugin_active()` path check: a renamed plugin folder must not
3596 * silently disable the integration.
3597 *
3598 * @param string[] $cleared Labels of caches cleared so far.
3599 * @param string $cause Why the purge is happening.
3600 */
3601 $cleared = (array) apply_filters( 'xspeed_purge_third_party_render_caches', array(), $cause );
3602
3603 // Labels are strings destined for the activity feed. Anything else a
3604 // third-party listener returns is dropped rather than coerced — a
3605 // stray `0` or `null` in the log reads as a cache we cleared.
3606 $cleared = array_values(
3607 array_filter(
3608 $cleared,
3609 static function ( $label ) {
3610 return is_string( $label ) && '' !== trim( $label );
3611 }
3612 )
3613 );
3614
3615 if ( ! $cleared ) {
3616 return $cleared;
3617 }
3618
3619 // Logged separately from the page-cache purge above it. "I turned the
3620 // CDN off and the images are still wrong" is only diagnosable if the
3621 // feed says which OTHER plugin's cache was regenerated and when.
3622 Activity_Log::record(
3623 'cache_purged',
3624 sprintf( 'Render caches cleared (%s) — %s', $cause, implode( ', ', $cleared ) ),
3625 Activity_Log::INFO
3626 );
3627
3628 return $cleared;
3629 }
3630
3631 /**
3632 * The per-type purge menu, LiteSpeed-style. Each entry is a cache type
3633 * the user can purge individually from the admin-bar dropdown. `visible`
3634 * controls whether the item shows (active + licensed module only) — it
3635 * NEVER limits Purge All, which always sweeps everything on disk.
3636 *
3637 * Pro registers its own types (Critical CSS, Unused CSS, …) by filtering
3638 * `xspeed_purge_types`, so Free degrades gracefully when Pro is absent.
3639 *
3640 * @return array<string,array{label:string,visible:bool}>
3641 */
3642 public static function purge_types(): array {
3643 $minify_on = false;
3644 if ( class_exists( '\\XSpeed\\Settings_Manager' ) ) {
3645 $min = Settings_Manager::get( 'minify' );
3646 $minify_on = ! empty( $min['minify_css'] ) || ! empty( $min['minify_js'] ) || ! empty( $min['combine_css'] ) || ! empty( $min['combine_js'] );
3647 }
3648 // Object cache is "active" when an external object-cache drop-in is in
3649 // use — the canonical WP signal, independent of our settings option.
3650 $oc_on = function_exists( 'wp_using_ext_object_cache' ) && wp_using_ext_object_cache();
3651
3652 $types = array(
3653 'all' => array(
3654 'label' => __( 'Purge All', 'xspeed' ),
3655 'visible' => true,
3656 ),
3657 'page' => array(
3658 'label' => __( 'Purge Page / Static Cache', 'xspeed' ),
3659 'visible' => true,
3660 ),
3661 'assets' => array(
3662 'label' => __( 'Purge CSS / JS Cache', 'xspeed' ),
3663 'visible' => $minify_on,
3664 ),
3665 'object' => array(
3666 'label' => __( 'Purge Object Cache', 'xspeed' ),
3667 'visible' => $oc_on,
3668 ),
3669 'rest' => array(
3670 'label' => __( 'Purge REST Cache', 'xspeed' ),
3671 'visible' => true,
3672 ),
3673 );
3674
3675 /**
3676 * Filter the admin-bar purge-type menu. Pro modules add their own
3677 * (Critical CSS, Unused CSS, CDN). Adding a type here only adds a
3678 * MENU item — purge_type() must know how to handle the same slug.
3679 *
3680 * @param array $types Map of slug => [label, visible].
3681 */
3682 return (array) apply_filters( 'xspeed_purge_types', $types );
3683 }
3684
3685 /**
3686 * Purge a single cache type by slug. 'all' delegates to purge_all();
3687 * every other slug clears just its own artifacts. Unknown slugs (e.g. a
3688 * Pro type) fan out via the `xspeed_purge_type_{slug}` action so the
3689 * owning module can handle it. Returns the number of items removed where
3690 * countable.
3691 *
3692 * @param string $type Cache type slug.
3693 * @param string $cause Who asked. Threaded through so the purge log can
3694 * tell an AI assistant's purge apart from a click —
3695 * "the cache cleared four times today" is only
3696 * actionable once you know what kept clearing it.
3697 */
3698 public static function purge_type( string $type, string $cause = 'manual' ): int {
3699 switch ( $type ) {
3700 case 'all':
3701 $count = self::purge_all( $cause );
3702 // "Purge All" is the user saying they don't trust anything
3703 // stored anywhere — the one purge that should also reach
3704 // caches of rendered output we don't own. purge_all() itself
3705 // deliberately does NOT, because it also runs on every post
3706 // publish. (See Render_Caches.)
3707 self::purge_render_caches( $cause );
3708 return $count;
3709
3710 case 'page':
3711 $count = self::purge_pages();
3712 self::update_stats( array( 'last_purge' => time() ) );
3713 Cache_Inventory::invalidate();
3714 self::record_partial_purge( 'page', $cause, $count );
3715 return $count;
3716
3717 case 'assets':
3718 if ( class_exists( '\\XSpeed\\Minifier' ) ) {
3719 Minifier::purge_minified();
3720 }
3721 // Deleting min/ without clearing the pages that link it left
3722 // every cached page pointing at files that no longer exist.
3723 // WordPress answers the missing asset by 301-ing to its
3724 // pretty-permalink form and serving the 404 TEMPLATE as
3725 // `HTTP 200 text/html`, which the browser accepts as a
3726 // stylesheet and parses to zero rules — no console error, no
3727 // network failure, no 4xx anywhere in devtools. The pages
3728 // stayed broken for the rest of the TTL (7 days on
3729 // Aggressive, up to 30), and the admin who clicked could not
3730 // see it: they are logged in, so their own requests bypass
3731 // the page cache and re-render, regenerating the assets as a
3732 // side effect. Only anonymous visitors were served the stale
3733 // HTML. (#244)
3734 //
3735 // The assets are the pages' dependency, so invalidating them
3736 // invalidates the pages. Same invariant Cache_GC enforces
3737 // with is_referenced(): never leave a cached page pointing at
3738 // an asset that is gone.
3739 $count = self::purge_pages();
3740 self::update_stats( array( 'last_purge' => time() ) );
3741 Cache_Inventory::invalidate();
3742 self::record_partial_purge( 'assets', $cause, $count );
3743 return $count;
3744
3745 case 'object':
3746 if ( function_exists( 'wp_cache_flush' ) ) {
3747 wp_cache_flush();
3748 }
3749 self::record_partial_purge( 'object cache', $cause, null );
3750 return 0;
3751
3752 case 'rest':
3753 $count = Rest_Cache::purge();
3754 self::record_partial_purge( 'REST responses', $cause, $count );
3755 return $count;
3756
3757 default:
3758 return self::purge_type_unhandled( $type, $cause );
3759 }
3760 }
3761
3762 /**
3763 * Delete this site's cached pages from both the flat and static trees.
3764 *
3765 * Extracted so the `assets` purge can reuse it: minified assets are a
3766 * dependency of the cached HTML, so clearing them must clear the pages
3767 * too or the pages are left referencing deleted files (#244).
3768 *
3769 * @return int Number of page entries removed.
3770 */
3771 private static function purge_pages(): int {
3772 $count = 0;
3773 // Scoped to this site — see purge_all(). (#6)
3774 $scope = self::current_host_dir();
3775 $flat_root = XSPEED_CACHE_DIR . '/' . $scope;
3776 if ( is_dir( $flat_root ) ) {
3777 foreach ( (array) glob( $flat_root . '/*.html' ) as $f ) {
3778 wp_delete_file( $f );
3779 ++$count;
3780 }
3781 foreach ( (array) glob( $flat_root . '/*.meta' ) as $m ) {
3782 wp_delete_file( $m );
3783 }
3784 foreach ( (array) glob( $flat_root . '/*.br' ) as $b ) {
3785 wp_delete_file( $b );
3786 }
3787 // `*.br` does not match `*.br.size`; a size record outliving its
3788 // body would later be read against a DIFFERENT sibling's bytes.
3789 foreach ( (array) glob( $flat_root . '/*.br.size' ) as $b ) {
3790 wp_delete_file( $b );
3791 }
3792 }
3793 $static_root = XSPEED_CACHE_STATIC_DIR . '/' . self::current_static_scope();
3794 if ( is_dir( $static_root ) ) {
3795 $count += self::rmtree_html( $static_root );
3796 }
3797
3798 return $count;
3799 }
3800
3801 /**
3802 * A purge type this class does not own — a Pro or third-party module
3803 * registered it via the `xspeed_purge_types` filter, so hand it off.
3804 *
3805 * @param string $type Purge-type slug.
3806 * @param string $cause Who asked.
3807 */
3808 private static function purge_type_unhandled( string $type, string $cause ): int {
3809 do_action( 'xspeed_purge_type_' . $type );
3810 self::record_partial_purge( $type, $cause, null );
3811
3812 return 0;
3813 }
3814
3815 /**
3816 * Log a partial purge so the drill-down behind "Last purge" shows every
3817 * clear, not only the full ones. Without this a site whose object cache
3818 * is flushed on a schedule looks, from the log, like nothing happens.
3819 *
3820 * @param string $what Human label for the slice purged.
3821 * @param string $cause Who asked.
3822 * @param int|null $count Items removed, when countable.
3823 */
3824 private static function record_partial_purge( string $what, string $cause, ?int $count ): void {
3825 $message = null === $count
3826 ? sprintf(
3827 /* translators: 1: what was purged, 2: cause of the purge. */
3828 __( 'Purged %1$s (%2$s)', 'xspeed' ),
3829 $what,
3830 $cause
3831 )
3832 : sprintf(
3833 /* translators: 1: what was purged, 2: cause of the purge, 3: number of files removed. */
3834 __( 'Purged %1$s (%2$s) — %3$d file(s) removed', 'xspeed' ),
3835 $what,
3836 $cause,
3837 $count
3838 );
3839
3840 Activity_Log::record( 'cache_purged', $message, Activity_Log::INFO );
3841 }
3842
3843 /**
3844 * Clear the static tree only, leaving the flat cache in place.
3845 *
3846 * A narrower purge_all() for the case where only the web-server tree can
3847 * be wrong: its files are keyed by `{host}{path}` and nothing else, so a
3848 * response filed under the wrong path poisons it while the flat cache —
3849 * keyed by cache_key(), discriminators included — stays correct. Avoids
3850 * throwing away Critical CSS, minified bundles and the object cache to
3851 * fix a static-only problem.
3852 *
3853 * @return int Number of index.html files removed.
3854 */
3855 public static function purge_static_tree(): int {
3856 return self::rmtree_html( XSPEED_CACHE_STATIC_DIR );
3857 }
3858
3859 /**
3860 * Recursively delete every `index.html` (and its precompressed
3861 * `index.html.br` sibling, if the Pro Brotli module wrote one) plus
3862 * empty directories inside the static-cache tree. Used by purge_all().
3863 * Returns the number of .html files removed so purge stats stay accurate
3864 * across the flat + static caches — .br siblings are not counted
3865 * (they're encodings of a page, not pages).
3866 */
3867 private static function rmtree_html( string $dir ): int {
3868 if ( ! is_dir( $dir ) ) {
3869 return 0;
3870 }
3871 $removed = 0;
3872 // SCANDIR_SORT_NONE skips alphabetic sort — we're going to walk
3873 // the whole tree regardless of order.
3874 $entries = @scandir( $dir, SCANDIR_SORT_NONE ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
3875 if ( false === $entries ) {
3876 return 0;
3877 }
3878 foreach ( $entries as $entry ) {
3879 if ( '.' === $entry || '..' === $entry ) {
3880 continue;
3881 }
3882 $path = $dir . '/' . $entry;
3883 if ( is_dir( $path ) ) {
3884 $removed += self::rmtree_html( $path );
3885 // Best-effort empty-dir cleanup; ignore failures (a
3886 // foreign file inside would block rmdir, which is fine).
3887 // 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.
3888 @rmdir( $path );
3889 continue;
3890 }
3891 if ( substr( $entry, -5 ) === '.html' ) {
3892 wp_delete_file( $path );
3893 ++$removed;
3894 } elseif ( substr( $entry, -3 ) === '.br' || substr( $entry, -8 ) === '.br.size' ) {
3895 // Precompressed sibling (index.html.br) and the record of its
3896 // length. Remove both so a purge doesn't orphan stale Brotli
3897 // bodies, or a size record that would later be read against a
3898 // different sibling's bytes. Not counted.
3899 wp_delete_file( $path );
3900 }
3901 }
3902 return $removed;
3903 }
3904
3905 /**
3906 * Drop a "silence is golden" index.php into a directory so apaches/nginx
3907 * with directory listing enabled don't expose cache contents.
3908 */
3909 public static function write_silence( $dir ) {
3910 $file = trailingslashit( $dir ) . 'index.php';
3911 if ( ! file_exists( $file ) ) {
3912 // 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.
3913 file_put_contents( $file, "<?php\n// Silence is golden.\n" );
3914 }
3915 }
3916
3917 /**
3918 * The raw xspeed_stats option as an array. Keys currently in use:
3919 * 'last_purge', 'last_gc', 'gc_removed', 'gc_removed_total'.
3920 */
3921 public static function get_stats_option(): array {
3922 $stats = get_option( 'xspeed_stats', array() );
3923 return is_array( $stats ) ? $stats : array();
3924 }
3925
3926 /**
3927 * Persist stats with autoload disabled — stats are only read in admin
3928 * contexts, so there is no reason to inflate every frontend request's
3929 * `wp_load_alloptions()` payload.
3930 *
3931 * MERGES into whatever is already stored. It used to overwrite, which
3932 * was harmless while `last_purge` was the only key — with the GC keys
3933 * alongside it, a purge would have wiped the GC history and vice versa.
3934 */
3935 public static function update_stats( array $stats ) {
3936 if ( false === get_option( 'xspeed_stats', false ) ) {
3937 add_option( 'xspeed_stats', $stats, '', 'no' );
3938 return;
3939 }
3940 update_option( 'xspeed_stats', array_merge( self::get_stats_option(), $stats ) );
3941 }
3942
3943 public static function get_stats() {
3944 $count = 0;
3945 $size = 0;
3946 // This site's entries only — on multisite the tree is shared, so an
3947 // unscoped count reported the whole network's pages on every
3948 // subsite's dashboard. (#6)
3949 $flat_root = XSPEED_CACHE_DIR . '/' . self::current_host_dir();
3950 if ( is_dir( $flat_root ) ) {
3951 $files = glob( $flat_root . '/*.html' );
3952 if ( $files ) {
3953 $count = count( $files );
3954 foreach ( $files as $f ) {
3955 $size += filesize( $f );
3956 }
3957 }
3958 }
3959 // Drain the HIT-log file BEFORE reading totals. Two serve paths that
3960 // bypass the normal in-PHP record_hit() append one line per HIT here:
3961 // the nginx server-level rewrite (see nginx_snippet(), never reaches
3962 // PHP) and the advanced-cache.php drop-in (runs pre-WordPress, can't
3963 // reach Hit_Counter). Without this drain both look like a 0% hit-ratio
3964 // on a perfectly working cache.
3965 Hit_Counter::collect_nginx_log_hits();
3966
3967 // Apache/LiteSpeed static-rewrite HITs are served straight from disk
3968 // by .htaccess and never reach PHP either — but there's no .htaccess
3969 // equivalent of nginx's access_log directive, so we count them by
3970 // scanning the web server's own access log incrementally. No-op when
3971 // the log isn't readable (managed hosts) — see the method docblock.
3972 Hit_Counter::collect_server_log_hits();
3973
3974 $stats = get_option( 'xspeed_stats', array() );
3975 $totals = Hit_Counter::totals_24h();
3976 // One read of the ground truth for both fields below: it costs a
3977 // stat of advanced-cache.php and a tokenize of wp-config.php, and
3978 // this runs on every dashboard poll.
3979 $serving = self::page_cache_operational();
3980 return array(
3981 'cached_pages' => $count,
3982 'cache_size' => $size,
3983 'last_purge' => isset( $stats['last_purge'] ) ? (int) $stats['last_purge'] : 0,
3984 // Rolling 24h cache performance — sourced from Hit_Counter's
3985 // hourly buckets. The frontend uses hit_ratio to drive the
3986 // CacheHero stat grid + the Health module's panel.
3987 'hits_24h' => $totals['hits'],
3988 'misses_24h' => $totals['misses'],
3989 'hit_ratio' => $totals['ratio'],
3990 // Requests kept OUT of the ratio (404s + bots) — surfaced as its own
3991 // "absorbed N scanner/bot requests" line rather than distorting the
3992 // cache-performance number. (#118)
3993 'excluded_24h' => $totals['excluded'],
3994 // True when an edge cache (Cloudflare) fronts the origin, so hits are
3995 // absorbed before reaching PHP. The dashboard labels the ratio
3996 // "origin-layer only" instead of implying it's the full picture. (#118)
3997 'edge_cache' => self::edge_cache_detected(),
3998 /*
3999 * Whether the page cache is actually SERVING, as opposed to
4000 * switched on in settings. The hero read the setting alone and
4001 * announced "Active — serving cached HTML"; a site whose
4002 * advanced-cache.php had been taken over by another cache plugin
4003 * got that line while every response carried
4004 * `X-XSpeed-Cache: BYPASS`. The setting is the user's intent;
4005 * this is the outcome, and the dashboard needs both to explain
4006 * the difference.
4007 */
4008 'page_cache_serving' => $serving,
4009 /*
4010 * Why not, when intent and outcome disagree. Only computed in
4011 * that state — the detector sweep behind it is far more work than
4012 * a stats call should do on an ordinary healthy site.
4013 */
4014 'page_cache_blocked_reason' => ( ! $serving && ! empty( Settings::get()['cache_enabled'] ) )
4015 ? self::acquisition_blocker()
4016 : null,
4017 );
4018 }
4019
4020 /**
4021 * Whether the current request should be kept OUT of the cache hit/miss
4022 * ratio: a genuine 404, or a known bot / scanner. Runs at template_redirect
4023 * time, so is_404() is resolved. (#118)
4024 */
4025 private static function miss_is_excluded(): bool {
4026 if ( function_exists( 'is_404' ) && is_404() ) {
4027 return true;
4028 }
4029 $ua = isset( $_SERVER['HTTP_USER_AGENT'] )
4030 ? sanitize_text_field( wp_unslash( (string) $_SERVER['HTTP_USER_AGENT'] ) )
4031 : '';
4032 return Hit_Counter::is_bot_ua( $ua );
4033 }
4034
4035 /**
4036 * Whether an edge cache fronts this origin. Today: the Cloudflare
4037 * integration is connected — so an unknown share of hits is served at the
4038 * edge and never counted here, making the origin ratio a partial view the
4039 * dashboard must label as such. (#118)
4040 */
4041 private static function edge_cache_detected(): bool {
4042 $cf = get_option( 'xspeed_module_cloudflare', array() );
4043 return is_array( $cf ) && ! empty( $cf['enabled'] );
4044 }
4045
4046 /**
4047 * Apply the user's enable/disable choice. Called from the REST toggle
4048 * endpoint, which is gated by current_user_can( 'manage_options' ) and
4049 * a verified REST nonce.
4050 *
4051 * This is the only path that ENABLES caching — a drop-in is never
4052 * created for a user who hasn't opted in, which is the guideline that
4053 * matters (a plugin must not install drop-ins or edit wp-config.php
4054 * on a fresh activation). RESTORING the drop-in for a site that
4055 * already has cache_enabled = true is a different act and is handled
4056 * by restore_dropin_if_enabled() on activation and auto_heal() at
4057 * runtime; without it every plugin update silently un-caches the site.
4058 *
4059 * Enabling is gated on acquisition_blocker(): if another plugin owns the
4060 * drop-in, or WP_CACHE is written in a form we must not rewrite, nothing
4061 * is written and the returned state carries `blocked` + a reason the
4062 * caller can show. Callers must persist `cache_enabled` from the returned
4063 * `enabled`, never from what they asked for.
4064 *
4065 * @param bool $enable User's choice.
4066 * @return array{
4067 * enabled: bool,
4068 * blocked: bool,
4069 * blocked_reason: ?string,
4070 * dropin_installed: bool,
4071 * wp_cache_constant: bool,
4072 * wp_config_writable: bool,
4073 * manual_snippet: ?string
4074 * }
4075 */
4076 public static function toggle( $enable ) {
4077 Page_Cache_Detector::invalidate();
4078 $expected = Page_Cache_Detector::inspect()['revision'];
4079 /** Diagnostic seam; changing the expected revision can only force a safe refusal. */
4080 $expected = (string) apply_filters( 'xspeed_page_cache_expected_revision', $expected );
4081 $lock = self::page_cache_lock();
4082 if ( ! is_resource( $lock ) ) {
4083 return self::blocked_toggle_state( __( 'Could not lock page-cache ownership. Try again.', 'xspeed' ) );
4084 }
4085 try {
4086 Page_Cache_Detector::invalidate();
4087 $fresh = Page_Cache_Detector::inspect()['revision'];
4088 if ( ! hash_equals( (string) $expected, (string) $fresh ) ) {
4089 return self::blocked_toggle_state( __( 'Page-cache ownership changed while xSpeed was checking it. Nothing was changed; try again.', 'xspeed' ) );
4090 }
4091 $state = self::toggle_unlocked( (bool) $enable );
4092 return $state;
4093 } finally {
4094 flock( $lock, LOCK_UN );
4095 fclose( $lock );
4096 }
4097 }
4098
4099 /** Run the page-cache mutation while toggle() owns the scoped lock. */
4100 private static function toggle_unlocked( bool $enable ) {
4101 $enable = (bool) $enable;
4102
4103 if ( $enable ) {
4104 /*
4105 * Preflight. The drop-in and the WP_CACHE define are shared,
4106 * single-occupancy state; if we do not own them, no part of this
4107 * runs — not the drop-in, not wp-config.php, not the rewrite
4108 * block. Refusing whole is the point: a partial enable leaves the
4109 * site claiming a cache it cannot serve.
4110 *
4111 * Every caller routes through here (REST, onboarding, MCP, CLI,
4112 * the optimize runner, Pro's migration), so the gate lives here
4113 * rather than being re-implemented at each entry point.
4114 *
4115 * Except when there is nothing to acquire. A site where we
4116 * already own the drop-in and are already serving is being asked
4117 * to stay as it is, and the gate answers a different question —
4118 * "is the field free to take" — which a merely ACTIVE competitor
4119 * makes false. So "make sure caching is on", from an AI agent,
4120 * the optimize runner or Pro's migration, came back as a refusal
4121 * telling the user to deactivate a plugin on a site that was
4122 * caching perfectly. The dashboard never saw it, because nobody
4123 * presses Enable on a cache that is already enabled.
4124 *
4125 * Only the GATE is skipped. The writes below still run, and every
4126 * one of them is individually idempotent — which matters, because
4127 * this is the path CacheModule re-bakes the drop-in through when
4128 * an exclusion rule or the TTL changes (#240, #251), and the path
4129 * auto_heal() restores a stripped WP_CACHE through. Returning
4130 * early here left both of those doing nothing at all, silently,
4131 * on exactly the healthy sites this branch is about.
4132 */
4133 $reasserting = self::page_cache_operational() && self::DROPIN_XSPEED === self::dropin_owner();
4134 $blocker = $reasserting ? null : self::acquisition_blocker();
4135 if ( null !== $blocker ) {
4136 Activity_Log::record(
4137 'cache_enable_blocked',
4138 'Cache not enabled — ' . $blocker,
4139 Activity_Log::WARN
4140 );
4141
4142 return self::blocked_toggle_state( $blocker );
4143 }
4144
4145 $dropin_path = WP_CONTENT_DIR . '/advanced-cache.php';
4146 $config_path = self::wp_config_path();
4147 $dropin_before = file_exists( $dropin_path ) ? self::read_file( $dropin_path ) : null;
4148 $config_before = '' !== $config_path ? self::read_file( $config_path ) : null;
4149 $dropin_ok = self::install_dropin();
4150 if ( ! $dropin_ok ) {
4151 $partial = self::read_file( $dropin_path );
4152 if ( is_string( $partial ) && xspeed_has_canonical_dropin_signature( $partial ) ) {
4153 self::rollback_page_cache_artifacts( $dropin_path, $dropin_before, $partial, $config_path, $config_before, null );
4154 }
4155 /*
4156 * Preflight said the field was clear, so this is a filesystem
4157 * failure (or a drop-in that appeared in between). Without the
4158 * drop-in there is no cache to enable, and persisting
4159 * cache_enabled anyway is what produced sites reporting a
4160 * healthy cache while serving every request uncached.
4161 */
4162 $reason = __( 'Could not write wp-content/advanced-cache.php. Check filesystem permissions.', 'xspeed' );
4163 Activity_Log::record(
4164 'cache_enable_blocked',
4165 'Cache not enabled — ' . $reason,
4166 Activity_Log::WARN
4167 );
4168
4169 return array(
4170 'enabled' => false,
4171 'blocked' => true,
4172 'blocked_reason' => $reason,
4173 'dropin_installed' => false,
4174 'wp_cache_constant' => false,
4175 'rewrite_installed' => false,
4176 'wp_config_writable' => self::wp_config_writable(),
4177 'manual_snippet' => null,
4178 'nginx_snippet' => self::nginx_snippet(),
4179 'nginx_server_block' => self::full_nginx_server_block(),
4180 );
4181 }
4182
4183 $dropin_written = self::read_file( $dropin_path );
4184 self::set_wp_cache_constant( true );
4185 $config_written = '' !== $config_path ? self::read_file( $config_path ) : null;
4186 Page_Cache_Detector::invalidate();
4187 $dropin_ours = self::DROPIN_XSPEED === self::dropin_owner();
4188 $constant_state = self::wp_cache_define_state();
4189 $constant_ok = 'true' === $constant_state;
4190
4191 /*
4192 * A wp-config.php we cannot write at all is a supported state, not
4193 * a failed transaction. Plenty of managed hosts ship the file
4194 * read-only; there the drop-in is ours and installed, the cache
4195 * works the moment WP_CACHE exists, and the one line to paste
4196 * comes back as `manual_snippet`. Rolling back instead left those
4197 * hosts unable to turn the page cache on by any route — including
4198 * when the user had already pasted the define, since the write
4199 * fails on an unwritable file whatever value is already there.
4200 *
4201 * `undefined` ONLY. `false` looks eligible — this method would
4202 * have rewritten it — but the snippet we hand back cannot work
4203 * there: the file already says `define( 'WP_CACHE', false )`, the
4204 * first define() call wins, and a user who pastes our line via
4205 * FTP ends up with a cache that never serves AND a `duplicate`
4206 * wp-config that blocks every future toggle in both directions.
4207 * They have to edit the existing line, which means refusing here
4208 * and saying so. `duplicate` and `dynamic` are refused by
4209 * acquisition_blocker() before we get here, and if one appears in
4210 * the race window it must still fail closed.
4211 */
4212 $manual_mode = ! $constant_ok
4213 && 'undefined' === $constant_state
4214 && ! self::can_write_wp_config();
4215
4216 if ( ! $dropin_ours || ( ! $constant_ok && ! $manual_mode ) ) {
4217 if ( ! self::can_write_wp_config() ) {
4218 $reason = 'false' === $constant_state
4219 ? __( "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' )
4220 : __( 'xSpeed could not verify the complete page-cache write, and wp-config.php is not writable. Its changes were rolled back.', 'xspeed' );
4221 } else {
4222 $reason = __( 'xSpeed could not verify the complete page-cache write. Its changes were rolled back.', 'xspeed' );
4223 }
4224 self::rollback_page_cache_artifacts( $dropin_path, $dropin_before, $dropin_written, $config_path, $config_before, $config_written );
4225 return self::blocked_toggle_state( $reason );
4226 }
4227 $wp_config_ok = $constant_ok;
4228 $rewrite_ok = self::install_rewrite();
4229 self::ensure_hits_log_file();
4230 self::sync_mobile_flag();
4231 $snippet = $wp_config_ok ? null : "define( 'WP_CACHE', true );";
4232 Settings::update( array( 'cache_enabled' => true ) );
4233 if ( empty( Settings::get()['cache_enabled'] ) ) {
4234 self::remove_rewrite();
4235 self::rollback_page_cache_artifacts( $dropin_path, $dropin_before, $dropin_written, $config_path, $config_before, $config_written );
4236 delete_option( 'xspeed_page_cache_ownership_receipt' );
4237 return self::blocked_toggle_state( __( 'xSpeed could not save the page-cache setting. Its file changes were rolled back.', 'xspeed' ) );
4238 }
4239
4240 /*
4241 * Only when this call actually changed something. auto_heal() runs
4242 * the enable transaction on every admin_init, and an unconditional
4243 * entry filled the 50-slot log with identical "Cache enabled" lines
4244 * within 50 wp-admin page loads, evicting every real event — plus
4245 * an option write per admin request. The sentence is also false
4246 * when nothing was installed.
4247 */
4248 if ( $dropin_written !== $dropin_before || $config_written !== $config_before ) {
4249 Activity_Log::record(
4250 'cache_enabled_event',
4251 $wp_config_ok
4252 ? 'Cache enabled. Drop-in installed, WP_CACHE constant set.'
4253 : 'Cache enabled. Drop-in installed; wp-config.php not writable — add the WP_CACHE snippet manually.',
4254 $wp_config_ok ? Activity_Log::SUCCESS : Activity_Log::WARN
4255 );
4256 }
4257
4258 return array(
4259 'enabled' => true,
4260 'blocked' => false,
4261 'blocked_reason' => null,
4262 'dropin_installed' => (bool) $dropin_ok,
4263 'wp_cache_constant' => (bool) $wp_config_ok,
4264 'rewrite_installed' => (bool) $rewrite_ok,
4265 'wp_config_writable' => self::wp_config_writable(),
4266 'manual_snippet' => $snippet,
4267 'nginx_snippet' => self::nginx_snippet(),
4268 // Unified server-block snippet aggregating every enabled
4269 // module's directives — the same value the dashboard and
4270 // Health insight render. The wizard shows this so all three
4271 // surfaces stay in lockstep. Null on non-nginx hosts.
4272 'nginx_server_block' => self::full_nginx_server_block(),
4273 );
4274 }
4275
4276 /*
4277 * Whose advanced-cache.php is on disk decides how much of the disable
4278 * below may run. Read it once, before anything is touched.
4279 */
4280 $owner = self::dropin_owner();
4281 $not_ours = self::DROPIN_FOREIGN === $owner || self::DROPIN_UNREADABLE === $owner;
4282 if ( ! self::set_wp_cache_constant( false ) ) {
4283 /*
4284 * The mirror of the enable path. A wp-config.php nobody can write
4285 * does not trap the user in a cache they turned off: WP_CACHE on
4286 * its own does nothing once advanced-cache.php is gone, and core
4287 * simply skips the missing drop-in. Refusing here left the
4288 * read-only managed hosts able to enable the page cache and never
4289 * able to disable it again.
4290 *
4291 * A drop-in that is not ours reaches the same conclusion by a
4292 * different road. WP_CACHE is then the switch for THEIR cache, so
4293 * set_wp_cache_constant() refuses it — correctly, and permanently,
4294 * because nothing the user does to xSpeed will make that file ours
4295 * again. Treating that refusal as a failed disable was a trap with
4296 * no exit: install any competing cache plugin while xSpeed's cache
4297 * was on, and xSpeed's toggle could never be turned off again,
4298 * while the dashboard went on claiming a cache that was serving
4299 * nothing. Turning xSpeed off is entirely within our own state —
4300 * our setting, our rewrite block — so it proceeds, and their
4301 * constant and their file are left exactly as they are.
4302 */
4303 /*
4304 * Every reason set_wp_cache_constant() refuses is structural
4305 * except one, and the exception is the only one worth blocking
4306 * on. It will not touch a constant it cannot prove is ours; it
4307 * will not rewrite a define it cannot read as a literal —
4308 * duplicate, dynamic, or inside a conditional; and it cannot
4309 * write a file the filesystem will not let it write. None of
4310 * those improve on a retry, and all of them leave a WP_CACHE
4311 * that does nothing once our drop-in is gone. What is left — our
4312 * own constant, in a shape we can rewrite, in a file we can
4313 * write, and the write still failed — is a real I/O failure, and
4314 * that one still refuses so the user is not told a cache was
4315 * turned off while it goes on serving.
4316 *
4317 * The proof, not the drop-in, is the test. A user who pasted our
4318 * manual snippet on a locked-down host has a WP_CACHE line with
4319 * no receipt on it; if their drop-in later goes missing, we can
4320 * never prove that line is ours, so refusing left the toggle
4321 * stuck on with no way out but enabling first and disabling
4322 * again. Nothing loads a drop-in that is not there, so the line
4323 * is inert either way and the disable proceeds without it.
4324 */
4325 $leave_it = ! self::wp_cache_define_is_ours_to_remove( $owner )
4326 || ! in_array( self::wp_cache_define_state(), array( 'true', 'false', 'undefined' ), true )
4327 || ! self::can_write_wp_config();
4328 if ( ! $leave_it ) {
4329 return self::blocked_toggle_state( __( 'xSpeed could not safely remove its WP_CACHE setting. The cache remains enabled.', 'xspeed' ) );
4330 }
4331 }
4332 self::remove_dropin();
4333 if ( self::DROPIN_XSPEED === self::dropin_owner() ) {
4334 // Put WP_CACHE back, and say so if we could not. Reporting a
4335 // hardcoded `enabled: true` here claimed a working cache on a
4336 // site whose constant we had just failed to restore.
4337 // Put WP_CACHE back, then read the outcome off disk rather than
4338 // trusting the write's return value — a write can report failure
4339 // for a value that was already correct, and the question the
4340 // caller needs answered is whether the cache serves.
4341 self::set_wp_cache_constant( true );
4342 return self::blocked_toggle_state(
4343 self::page_cache_operational()
4344 ? __( 'xSpeed could not remove its page-cache drop-in. The cache remains enabled.', 'xspeed' )
4345 : __( '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' )
4346 );
4347 }
4348 self::remove_rewrite();
4349 /*
4350 * The .htaccess block serves cached HTML straight off disk without
4351 * ever reaching PHP, so a block we failed to remove keeps answering
4352 * requests from a cache the user just turned off — and nothing else
4353 * in this method can stop it. remove_rewrite() also returns false
4354 * when there is no .htaccess to clean, which is the ordinary case,
4355 * so ask the file rather than trust the return value.
4356 */
4357 if ( self::rewrite_installed() ) {
4358 if ( $not_ours ) {
4359 // Nothing to roll back — under a foreign drop-in this method
4360 // removed no drop-in and wrote no constant, and it could not
4361 // put either back if it wanted to. Say what is actually left.
4362 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' ) );
4363 }
4364 /*
4365 * Roll the disable back. Both calls can fail — a filesystem that
4366 * would not let us remove the block may not let us write the
4367 * drop-in either — and discarding their results reported an
4368 * enabled cache over a site left with no drop-in and no
4369 * constant. Fall through to the default state so the artifact
4370 * fields are read from disk rather than asserted.
4371 */
4372 self::install_dropin();
4373 self::set_wp_cache_constant( true );
4374 // Both of those can fail — a filesystem that would not let us
4375 // remove the block may not let us write the drop-in either — so
4376 // the message follows what is on disk afterwards, not what the
4377 // calls returned.
4378 return self::blocked_toggle_state(
4379 self::page_cache_operational()
4380 ? __( 'xSpeed could not remove its rewrite rules from .htaccess, which would keep serving cached pages. The cache remains enabled.', 'xspeed' )
4381 : __( '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' )
4382 );
4383 }
4384 // Drop the device-bucket marker too — with the drop-in gone there's
4385 // nothing left to read it, and leaving it behind would dirty a fresh
4386 // re-enable (and leaks across test runs).
4387 self::sync_mobile_flag( false );
4388 Settings::update( array( 'cache_enabled' => false ) );
4389 if ( ! empty( Settings::get()['cache_enabled'] ) ) {
4390 if ( $not_ours ) {
4391 // Same as above: there is nothing of ours on disk to restore.
4392 return self::blocked_toggle_state( __( 'xSpeed could not save the disabled state.', 'xspeed' ) );
4393 }
4394 self::install_dropin();
4395 self::set_wp_cache_constant( true );
4396 return self::blocked_toggle_state( __( 'xSpeed could not save the disabled state. The page cache was restored.', 'xspeed' ) );
4397 }
4398
4399 // A WP_CACHE we could not remove because wp-config.php is read-only
4400 // is left behind deliberately (see above) — say so rather than
4401 // reporting a constant that is still in the file as gone.
4402 $constant_left = 'true' === self::wp_cache_define_state();
4403 /*
4404 * Say why the constant is still there, because there are now three
4405 * different reasons and they call for different advice. Keyed off the
4406 * same facts $leave_it was, so the log cannot drift from the decision
4407 * it is describing — it did, briefly, and reported a wp-config.php as
4408 * unwritable when the real reason was that we could not prove the
4409 * line was ours.
4410 */
4411 if ( self::DROPIN_UNREADABLE === $owner ) {
4412 $log_message = 'Cache disabled. advanced-cache.php could not be read, so it and the WP_CACHE setting were left untouched.';
4413 } elseif ( $not_ours ) {
4414 $log_message = 'Cache disabled. Another plugin owns advanced-cache.php, so its drop-in and its WP_CACHE setting were left untouched.';
4415 } elseif ( ! $constant_left ) {
4416 $log_message = 'Cache disabled. Drop-in removed.';
4417 } elseif ( ! self::wp_cache_define_is_ours_to_remove( $owner ) ) {
4418 $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.';
4419 } elseif ( ! self::can_write_wp_config() ) {
4420 $log_message = 'Cache disabled. Drop-in removed; wp-config.php not writable, so WP_CACHE was left in place (harmless without the drop-in).';
4421 } else {
4422 $log_message = 'Cache disabled. Drop-in removed; WP_CACHE was left in place (harmless without the drop-in).';
4423 }
4424 Activity_Log::record(
4425 'cache_disabled_event',
4426 $log_message,
4427 $constant_left ? Activity_Log::WARN : Activity_Log::INFO
4428 );
4429
4430 return array(
4431 'enabled' => false,
4432 'blocked' => false,
4433 'blocked_reason' => null,
4434 'dropin_installed' => false,
4435 'wp_cache_constant' => $constant_left,
4436 'rewrite_installed' => false,
4437 'wp_config_writable' => self::wp_config_writable(),
4438 'manual_snippet' => null,
4439 'nginx_snippet' => self::nginx_snippet(),
4440 'nginx_server_block' => self::full_nginx_server_block(),
4441 );
4442 }
4443
4444 /** Acquire the local lock that serializes page-cache ownership changes. */
4445 private static function page_cache_lock() {
4446 $path = WP_CONTENT_DIR . '/.xspeed-page-cache.lock';
4447 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen,WordPress.PHP.NoSilencedErrors.Discouraged -- flock requires a local handle; failure is a safe blocked result.
4448 $lock = @fopen( $path, 'c+' );
4449 if ( ! is_resource( $lock ) || ! flock( $lock, LOCK_EX ) ) {
4450 return false;
4451 }
4452 return $lock;
4453 }
4454
4455 /**
4456 * Build the stable response shape for a refused transaction.
4457 *
4458 * The artifact fields report what is ON DISK, not zeros. A refusal means
4459 * xSpeed changed nothing — on a site already running our cache that is
4460 * exactly the state where the drop-in and WP_CACHE are both still in
4461 * place and still serving hits. Hardcoding false told the dashboard the
4462 * cache had been dismantled every time a refusal was returned.
4463 */
4464 private static function blocked_toggle_state( string $reason ): array {
4465 /*
4466 * `enabled` answers ONE question: is the page cache operational right
4467 * now. Not what was asked for, and not what the option says.
4468 *
4469 * WordPress loads advanced-cache.php only when WP_CACHE is truthy, so
4470 * those two files together are the whole answer, and reading them is
4471 * the only source that cannot go stale. Both of the alternatives were
4472 * tried here and both produced wrong answers on real paths: a
4473 * hardcoded false told a caller the cache had gone away on a site
4474 * still serving hits, and the persisted setting told a caller the
4475 * cache was healthy after a rollback had just removed the artifacts
4476 * — the option is not written until the end of the transaction, so
4477 * mid-transaction it is stale by construction.
4478 *
4479 * Deliberately not a parameter. Every branch that got to choose its
4480 * own answer eventually chose wrong.
4481 */
4482 return array(
4483 'enabled' => self::page_cache_operational(),
4484 'blocked' => true,
4485 'blocked_reason' => $reason,
4486 'dropin_installed' => self::DROPIN_XSPEED === self::dropin_owner(),
4487 'wp_cache_constant' => 'true' === self::wp_cache_define_state(),
4488 'rewrite_installed' => self::rewrite_installed(),
4489 'wp_config_writable' => self::wp_config_writable(),
4490 'manual_snippet' => null,
4491 'nginx_snippet' => self::nginx_snippet(),
4492 'nginx_server_block' => self::full_nginx_server_block(),
4493 );
4494 }
4495
4496 /**
4497 * The wp-config.php line a user must paste, or null when none is needed.
4498 *
4499 * Non-null only where the drop-in is ours and WP_CACHE is not set to true
4500 * in a file we can write — the read-only managed host. Everywhere else the
4501 * constant is ours to manage and there is nothing to ask for.
4502 */
4503 public static function manual_wp_cache_snippet(): ?string {
4504 if ( self::DROPIN_XSPEED !== self::dropin_owner() ) {
4505 return null;
4506 }
4507 if ( 'true' === self::wp_cache_define_state() ) {
4508 return null;
4509 }
4510 return self::wp_config_writable() ? null : "define( 'WP_CACHE', true );";
4511 }
4512
4513 /**
4514 * Is the page cache serving right now?
4515 *
4516 * Two things decide it, and `WP_CACHE` is not one of them.
4517 *
4518 * xSpeed serves a cached page from `template_redirect` whenever the
4519 * setting is on — see the `HIT (php)` mark on that path, which exists
4520 * precisely for "the drop-in isn't loaded". `advanced-cache.php` and the
4521 * `WP_CACHE` constant that loads it are the FAST path: they answer before
4522 * WordPress boots, which is worth a lot of milliseconds and nothing at
4523 * all to the question of whether pages are being served from cache.
4524 *
4525 * Conflating the two reported a dead cache over a live one. On a managed
4526 * host with an unwritable wp-config.php — the exact case the manual
4527 * snippet exists for — one card said "Your cache works on every request",
4528 * "On, but not serving", "nothing will be cached until you add this line"
4529 * and "hit ratio 67%", all at once, and told the user to edit a file they
4530 * have no permission to write. The released 1.2.1 reported that site as
4531 * active, correctly.
4532 *
4533 * So: the setting, and whether anyone else holds the drop-in. A foreign
4534 * drop-in answers before WordPress loads us, so ours never runs and we
4535 * genuinely are not serving. An unreadable one we must assume the same of.
4536 * Everything else — our drop-in, or none at all — serves.
4537 *
4538 * Public because it is part of the host-plugin contract — see Host. A
4539 * plugin that installed xSpeed needs to be able to say whether the cache
4540 * it asked for is actually serving, and no combination of settings reads
4541 * answers that.
4542 */
4543 public static function page_cache_operational(): bool {
4544 $settings = Settings::get();
4545 if ( empty( $settings['cache_enabled'] ) ) {
4546 return false;
4547 }
4548 $owner = self::dropin_owner();
4549 return self::DROPIN_FOREIGN !== $owner && self::DROPIN_UNREADABLE !== $owner;
4550 }
4551
4552 /** Restore exact snapshots only while disk still matches our own write. */
4553 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 {
4554 // Roll back only files that still carry xSpeed's just-written state.
4555 if ( null !== $dropin_written && hash_equals( $dropin_written, (string) self::read_file( $dropin_path ) ) ) {
4556 if ( null === $dropin_before ) {
4557 wp_delete_file( $dropin_path );
4558 } else {
4559 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- Exact compare-and-swap rollback under the scoped lock.
4560 file_put_contents( $dropin_path, $dropin_before );
4561 }
4562 }
4563 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 ) ) {
4564 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- Exact compare-and-swap rollback under the scoped lock.
4565 file_put_contents( $config_path, $config_before );
4566 }
4567 }
4568
4569 /**
4570 * Check wp-config.php writability via WP_Filesystem. Plugin Check flags
4571 * direct is_writable() under WordPress.WP.AlternativeFunctions.
4572 */
4573 private static function wp_config_writable() {
4574 global $wp_filesystem;
4575 if ( ! function_exists( 'WP_Filesystem' ) ) {
4576 require_once ABSPATH . 'wp-admin/includes/file.php';
4577 }
4578 WP_Filesystem();
4579
4580 return $wp_filesystem ? (bool) $wp_filesystem->is_writable( ABSPATH . 'wp-config.php' ) : false;
4581 }
4582
4583 /**
4584 * Nginx server-block snippet mirroring the Apache rewrite block.
4585 * We never auto-write nginx config — it sits outside the WordPress
4586 * root and is owned by the server admin — but the dashboard
4587 * surfaces this snippet when nginx is detected so the admin can
4588 * paste it once and unlock the same PHP-bypass speedup we get on
4589 * Apache / LiteSpeed via .htaccess.
4590 *
4591 * Returns null when the server isn't nginx (no point showing it).
4592 */
4593 /**
4594 * Create wp-content/cache/xspeed/hits.log as an empty file so the
4595 * server-level rewrite's `access_log` directive has somewhere to
4596 * write on first request. Idempotent — touches an existing file
4597 * without disturbing accumulated lines. Called from Cache::toggle()
4598 * on enable and from auto_heal() when the file is missing.
4599 *
4600 * Permissions matter here. The file is created by PHP-FPM (often uid
4601 * www-data), but the nginx process that appends HIT lines may run as a
4602 * DIFFERENT uid — on multi-container hosts (e.g. xclude/Kinsta: nginx in
4603 * its own container as uid `nginx`, PHP-FPM in another as `www-data`)
4604 * they don't share a user at all. A default-umask 0644 file is then
4605 * unwritable by nginx, the access_log write silently fails, and the
4606 * dashboard shows a 0% hit ratio even though static HITs are serving.
4607 * So we widen the dir to 0777 and the file to 0666 — group/other write —
4608 * so whatever uid nginx runs as can append. (The file holds only HIT
4609 * request lines, no secrets.)
4610 */
4611 /**
4612 * Directory holding the nginx hit log. Lives under uploads/, NOT the
4613 * cache dir — uninstall.php and a cache purge both delete the cache
4614 * dir, which would orphan the pasted nginx `access_log` directive's
4615 * parent directory and make `nginx -t` fail [emerg], taking down every
4616 * vhost on the host (FBS-82478). uploads/ always exists, isn't a
4617 * plugin-managed cache dir, and is never deleted on uninstall — so the
4618 * directive's target dir survives both, and nginx (which creates a
4619 * missing log FILE but not a missing DIR) can always open it.
4620 *
4621 * Falls back to the cache dir only if uploads is somehow unavailable.
4622 */
4623 public static function hits_log_dir(): string {
4624 if ( function_exists( 'wp_upload_dir' ) ) {
4625 $uploads = wp_upload_dir( null, false );
4626 if ( is_array( $uploads ) && empty( $uploads['error'] ) && ! empty( $uploads['basedir'] ) ) {
4627 return rtrim( (string) $uploads['basedir'], '/' ) . '/xspeed';
4628 }
4629 }
4630 return XSPEED_CACHE_DIR;
4631 }
4632
4633 /** Absolute path to the nginx hit log file. */
4634 public static function hits_log_path(): string {
4635 return self::hits_log_dir() . '/hits.log';
4636 }
4637
4638 /**
4639 * Sync the drop-in's mobile-bucket flag file with the `mobile_separate`
4640 * setting. The drop-in (advanced-cache.php) runs before WordPress loads,
4641 * so it can't read the option — instead it checks for a zero-byte
4642 * `.mobile-separate` marker next to the cache files. When the setting is
4643 * on we touch the marker; when off we remove it. The drop-in's cache_key
4644 * computation keys off the marker's presence so its '|m'/'|d' device
4645 * bucket stays in lockstep with Cache::cache_key().
4646 *
4647 * Without this, turning on mobile_separate made Cache::store() write keys
4648 * with a '|d'/'|m' suffix the drop-in never reproduced — so the drop-in's
4649 * file_exists() always missed, every HIT fell through to a full WP boot,
4650 * and the fast pre-WP path was silently dead.
4651 *
4652 * @param bool|null $enabled Force a state; null reads the current setting.
4653 */
4654 /**
4655 * Write the subdirectory-multisite path list the drop-in needs to work
4656 * out which blog a request belongs to.
4657 *
4658 * The drop-in runs before WordPress, so it cannot call is_multisite()
4659 * or get_blog_details(). It can only see REQUEST_URI — so we persist the
4660 * network's blog paths (one per line, longest first) next to the cache
4661 * files, exactly as sync_mobile_flag() persists the device flag. The
4662 * drop-in prefix-matches the URI against that list to pick the same
4663 * bucket Cache::current_host_dir() picks. (#6)
4664 *
4665 * No file is written for a single site or a subdomain network — there
4666 * the host alone identifies the blog and the bucket carries no prefix.
4667 */
4668 public static function sync_site_paths(): void {
4669 $file = XSPEED_CACHE_DIR . '/.site-paths';
4670
4671 $needed = function_exists( 'is_multisite' ) && is_multisite()
4672 && ( ! function_exists( 'is_subdomain_install' ) || ! is_subdomain_install() );
4673
4674 if ( ! $needed ) {
4675 if ( file_exists( $file ) ) {
4676 // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal.
4677 @unlink( $file );
4678 }
4679 return;
4680 }
4681
4682 if ( ! function_exists( 'get_sites' ) ) {
4683 return;
4684 }
4685
4686 $paths = array();
4687 foreach ( get_sites( array( 'number' => 0 ) ) as $site ) {
4688 $prefix = self::path_prefix_segment( (string) $site->path );
4689 if ( '' !== $prefix ) {
4690 // Store the raw path so the drop-in can prefix-match a URI,
4691 // alongside the segment it maps to.
4692 $paths[ trim( (string) $site->path, '/' ) ] = $prefix;
4693 }
4694 }
4695
4696 if ( empty( $paths ) ) {
4697 if ( file_exists( $file ) ) {
4698 // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- see above.
4699 @unlink( $file );
4700 }
4701 return;
4702 }
4703
4704 // Longest path first so /a/b wins over /a.
4705 uksort(
4706 $paths,
4707 static function ( $x, $y ) {
4708 return strlen( (string) $y ) <=> strlen( (string) $x );
4709 }
4710 );
4711
4712 $lines = array();
4713 foreach ( $paths as $raw => $segment ) {
4714 $lines[] = $raw . '|' . $segment;
4715 }
4716
4717 if ( ! is_dir( XSPEED_CACHE_DIR ) && ! wp_mkdir_p( XSPEED_CACHE_DIR ) ) {
4718 return;
4719 }
4720 // 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.
4721 file_put_contents( $file, implode( "\n", $lines ), LOCK_EX );
4722 }
4723
4724 /**
4725 * Compile `ignored_query_params` into a regex the DROP-IN can use.
4726 *
4727 * Tracking traffic was cached but never served fast. should_cache()
4728 * learned to allow `?utm_source=…` through and cache_key() strips the
4729 * query, so `/post` and `/post?utm_source=x` share one entry — but the
4730 * drop-in still bailed on ANY query string, so every visitor from an
4731 * email or ad campaign paid a full WordPress boot to be handed a file
4732 * that was already on disk. On a marketing site that is most of the
4733 * paid traffic taking the slowest path. (#13)
4734 *
4735 * The drop-in runs before WordPress, so it cannot read the option or
4736 * call Glob_Matcher. It gets a precompiled alternation instead, written
4737 * next to the cache files exactly as sync_mobile_flag() writes the
4738 * device flag. Regenerated whenever cache settings are saved.
4739 *
4740 * Only the KEYS matter: a param whose name is on the list contributes
4741 * nothing to the response, so the entry keyed without it is correct.
4742 * Anything not on the list means the drop-in must stand down and let
4743 * PHP decide — the file is deleted rather than left stale when the
4744 * list is empty, so a missing sidecar always fails safe.
4745 */
4746 public static function sync_query_allowlist(): void {
4747 $file = XSPEED_CACHE_DIR . '/.ignored-query-params';
4748
4749 $opts = Settings_Manager::get( 'cache' );
4750 $ignored = is_array( $opts['ignored_query_params'] ?? null ) ? $opts['ignored_query_params'] : array();
4751
4752 $parts = array();
4753 foreach ( $ignored as $pattern ) {
4754 $pattern = trim( (string) $pattern );
4755 if ( '' === $pattern ) {
4756 continue;
4757 }
4758 if ( '~' === $pattern[0] ) {
4759 // Raw regex, PHP-side dialect. Keep it — unlike a server
4760 // config, the drop-in runs the same PCRE engine, so the
4761 // pattern behaves identically. Anchored below with the rest.
4762 $body = substr( $pattern, 1 );
4763 if ( '' !== $body && false !== @preg_match( '#^(?:' . $body . ')$#', '' ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a malformed user pattern must be dropped, not fatal.
4764 $parts[] = $body;
4765 }
4766 continue;
4767 }
4768 // Glob semantics, same as Glob_Matcher: * is any run, ? is one.
4769 $esc = preg_quote( $pattern, '#' );
4770 $esc = str_replace( array( '\*', '\?' ), array( '.*', '.' ), $esc );
4771 $parts[] = $esc;
4772 }
4773
4774 if ( empty( $parts ) ) {
4775 if ( file_exists( $file ) ) {
4776 // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal.
4777 @unlink( $file );
4778 }
4779 return;
4780 }
4781
4782 if ( ! is_dir( XSPEED_CACHE_DIR ) && ! wp_mkdir_p( XSPEED_CACHE_DIR ) ) {
4783 return;
4784 }
4785
4786 $payload = '(?:' . implode( '|', array_unique( $parts ) ) . ')';
4787
4788 // Only write when the value actually changed. This runs from
4789 // reconcile_mobile_separate() on CacheModule::boot(), so an
4790 // unconditional write cost a file write and an exclusive lock on every
4791 // request that boots WordPress — every MISS, every BYPASS, every admin
4792 // screen, every REST call. sync_mobile_flag() below is the model: it
4793 // touches the marker only when the setting flips.
4794 // 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.
4795 if ( is_readable( $file ) && (string) @file_get_contents( $file ) === $payload ) {
4796 return;
4797 }
4798
4799 // 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.
4800 file_put_contents( $file, $payload, LOCK_EX );
4801 }
4802
4803 public static function sync_mobile_flag( $enabled = null ): void {
4804 if ( null === $enabled ) {
4805 $opts = Settings_Manager::get( 'cache' );
4806 $enabled = ! empty( $opts['mobile_separate'] );
4807 }
4808 $dir = XSPEED_CACHE_DIR;
4809 $flag = $dir . '/.mobile-separate';
4810 if ( $enabled ) {
4811 if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
4812 return;
4813 }
4814 if ( ! file_exists( $flag ) ) {
4815 // 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.
4816 @touch( $flag );
4817 }
4818 return;
4819 }
4820 if ( file_exists( $flag ) ) {
4821 // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal.
4822 @unlink( $flag );
4823 }
4824 }
4825
4826 /**
4827 * Write / remove the `.maintenance-active` sentinel next to the cache
4828 * files. The pre-WP drop-in checks for this marker and bails when present,
4829 * so a page cached while the site was live is NOT served during
4830 * maintenance / coming-soon mode — WordPress loads and renders the
4831 * maintenance screen instead. The Pro Maintenance-Cache module drives this
4832 * on the maintenance on/off transition. (FBS-82409 B1)
4833 *
4834 * @param bool $active True to arm the sentinel (entering maintenance),
4835 * false to clear it (site recovered).
4836 */
4837 public static function sync_maintenance_flag( bool $active ): void {
4838 $dir = XSPEED_CACHE_DIR;
4839 $flag = $dir . '/.maintenance-active';
4840 if ( $active ) {
4841 if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
4842 return;
4843 }
4844 if ( ! file_exists( $flag ) ) {
4845 // 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.
4846 @touch( $flag );
4847 }
4848 return;
4849 }
4850 if ( file_exists( $flag ) ) {
4851 // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal.
4852 @unlink( $flag );
4853 }
4854 }
4855
4856 /**
4857 * Reconcile every mobile_separate-dependent artifact to the current
4858 * setting. Called on boot and whenever the cache settings are saved, so
4859 * flipping mobile_separate at runtime can't leave the install in a
4860 * half-converted state.
4861 *
4862 * Three things must agree with the setting:
4863 * 1. the drop-in's `.mobile-separate` flag (sync_mobile_flag()),
4864 * 2. the device-blind server rewrite — present only when OFF
4865 * (static_rewrite_allowed()),
4866 * 3. the now-stale static-cache tree + page cache, which were keyed
4867 * under the old scheme and would serve wrong-device HTML.
4868 *
4869 * No-ops when the cache is disabled — there's nothing installed to
4870 * reconcile, and toggle() handles install/teardown itself.
4871 */
4872 public static function reconcile_mobile_separate(): void {
4873 self::sync_mobile_flag();
4874 if ( defined( 'XSPEED_CACHE_DIR' ) ) {
4875 // Keep the drop-in's view of the network's blog paths current — a
4876 // site added or removed changes which bucket its URLs belong to. (#6)
4877 self::sync_site_paths();
4878 // Keep the drop-in's copy of the query allow-list current — a param
4879 // added in settings must reach the fast path too. (#13)
4880 self::sync_query_allowlist();
4881 }
4882
4883 // The rewrite/static reconciliation below needs the plugin's path
4884 // constants. They're absent in early-boot / unit-test contexts where
4885 // only the drop-in flag matters — bail to the flag-only behavior then.
4886 if ( ! defined( 'XSPEED_CACHE_STATIC_DIR' ) ) {
4887 return;
4888 }
4889
4890 // Only touch the rewrite + caches when caching is actually on.
4891 $opts = get_option( 'xspeed_options', array() );
4892 if ( empty( $opts['cache_enabled'] ) ) {
4893 return;
4894 }
4895
4896 $rewrite_present = self::rewrite_installed();
4897 $rewrite_wanted = self::static_rewrite_allowed();
4898
4899 // Did the thing that actually invalidates cache KEYS change?
4900 // mobile_separate buckets entries as |d / |m, so flipping it makes
4901 // stored entries mis-bucketed and they must go. A rewrite-state
4902 // mismatch from anything else (e.g. mod_headers detection, a hand-
4903 // edited .htaccess) changes no key at all — the same files are still
4904 // valid, they're just served by PHP instead of by the web server.
4905 // Purging there is what let one WP-CLI call wipe the whole cache on
4906 // every bootstrap. (#138)
4907 //
4908 // Read the setting from the SAME place static_rewrite_allowed() and
4909 // sync_mobile_flag() do — the cache module's settings, not the
4910 // top-level xspeed_options — or this marker would track a key that
4911 // never changes and a real flip would go unnoticed.
4912 $cache_opts = Settings_Manager::get( 'cache' );
4913 $mobile_now = ! empty( $cache_opts['mobile_separate'] );
4914 $mobile_last = get_option( 'xspeed_last_mobile_separate', null );
4915 $mobile_flipped = ( null !== $mobile_last && (bool) (int) $mobile_last !== $mobile_now );
4916
4917 if ( (string) (int) $mobile_now !== (string) $mobile_last ) {
4918 update_option( 'xspeed_last_mobile_separate', $mobile_now ? '1' : '0', false );
4919 }
4920
4921 if ( $rewrite_present === $rewrite_wanted ) {
4922 // Already consistent — nothing flipped, leave caches intact so a
4923 // plain settings save (e.g. expiry change) doesn't blow the cache.
4924 return;
4925 }
4926
4927 // Bring the rewrite into line with what this server actually supports.
4928 if ( $rewrite_wanted ) {
4929 self::install_rewrite();
4930 } else {
4931 self::remove_rewrite();
4932 }
4933
4934 // Only discard cache contents when the device bucketing changed.
4935 if ( $mobile_flipped ) {
4936 self::purge_all( 'mobile_separate changed' );
4937 }
4938 }
4939
4940 /**
4941 * Whether the server-level static-rewrite fast path may be used.
4942 *
4943 * The rewrite serves `{host}{path}/index.html` straight from the web
4944 * server, keyed only by host + path — it has no way to run our PHP
4945 * device detection, so it can't tell mobile from desktop. When
4946 * `mobile_separate` is on, a single static file would be shared across
4947 * devices and whoever primed it wins (mobile visitors could get desktop
4948 * HTML, or vice-versa). Rather than duplicate a wp_is_mobile()-equivalent
4949 * UA matcher into .htaccess AND the nginx snippet (three copies that
4950 * would inevitably drift), we simply DON'T engage the static rewrite when
4951 * mobile_separate is on. Requests then fall through to the PHP drop-in,
4952 * which buckets correctly — a small TTFB cost (~85ms vs ~30ms) paid only
4953 * on mobile-separate sites, in exchange for guaranteed correctness.
4954 *
4955 * LiteSpeed exclusion (2026-06-16): on LiteSpeed — OpenLiteSpeed in
4956 * particular — `.htaccess` CAN run our RewriteRule to serve the static
4957 * file, but its `.htaccess` engine ignores `mod_headers`, so we cannot
4958 * stamp the served response with `X-XSpeed-Cache: HIT`, AND there is no
4959 * `.htaccess` equivalent of nginx's per-location `access_log` to record
4960 * the hit. The result was a cache that worked but was invisible: no HIT
4961 * header and a hit-ratio frozen near 0%. Every OTHER server gives the
4962 * user a visible HIT header + a counted hit (nginx via add_header +
4963 * access_log in its snippet; Apache via the `<IfModule mod_headers.c>`
4964 * block in rewrite_block_lines(), WHEN that module is loaded — when it is
4965 * not, Apache takes this same drop-in fallback). To keep LiteSpeed
4966 * CONSISTENT with the rest, we route its hits
4967 * through the PHP drop-in instead — the drop-in emits
4968 * `X-XSpeed-Cache: HIT (php)` and calls Hit_Counter inline, exactly the
4969 * observable behavior the other servers get. The cost is the drop-in's
4970 * ~30ms TTFB vs the static path's ~10ms, paid only on LiteSpeed; in
4971 * exchange the dashboard hit-ratio and the response header finally tell
4972 * the truth there. (Apache keeps the static fast path — it honors the
4973 * header.) See maybe_emit_lscache_headers() for the paired LSCache
4974 * stand-down that stops LiteSpeed's own module from shadowing the
4975 * drop-in.
4976 */
4977 public static function static_rewrite_allowed(): bool {
4978 // LiteSpeed: drop-in serves hits (visible + counted) — see docblock.
4979 if ( Server::LITESPEED === Server::type() ) {
4980 return false;
4981 }
4982 // Apache without mod_headers is in EXACTLY the position LiteSpeed
4983 // is in above: it can run the RewriteRule and serve the static
4984 // file, but it cannot stamp `X-XSpeed-Cache` on the response, so
4985 // the hit is invisible to the user and uncountable by
4986 // Hit_Counter. The docblock above used to assert Apache "honors
4987 // mod_headers" and left it on the fast path unconditionally —
4988 // true only when the module is actually loaded. Fall back to the
4989 // drop-in when it isn't, trading ~10ms of TTFB for a hit that
4990 // shows up in the header and the ratio. (Field report: hit ratio
4991 // pinned at 0% on a working Apache cache.)
4992 if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) {
4993 return false;
4994 }
4995 $opts = Settings_Manager::get( 'cache' );
4996 return empty( $opts['mobile_separate'] );
4997 }
4998
4999 /**
5000 * Why the device-blind static rewrite is NOT installed, when it isn't.
5001 * Returns 'mobile_separate' when Separate Mobile Cache is the blocker
5002 * (the static file is one-per-URL, so it can't coexist with per-device
5003 * buckets), 'no_mod_headers' when Apache can't stamp the HIT header,
5004 * '' otherwise. Lets the dashboard explain the slow path instead of
5005 * silently falling back to PHP serving. (FBS-83145)
5006 *
5007 * Every refusal in static_rewrite_allowed() that is NOT self-explanatory
5008 * must have a branch here. Otherwise the Health card falls through to
5009 * "Block missing — toggle Enable Cache off and on to reinstall it",
5010 * advice that cannot work: the same condition that suppressed the write
5011 * suppresses the reinstall, and auto_heal() strips the block again on
5012 * the next admin page load. (Field report: Apache host with mod_headers
5013 * unloaded sat on the slow path with no way to find out why.)
5014 */
5015 /**
5016 * Qualify a raw probe result with what we already KNOW about config.
5017 *
5018 * probe_static_rewrite() writes its own file under the static-cache tree
5019 * and fetches that, which succeeds whenever the web server can serve a
5020 * static file at all — including when static_rewrite_allowed() is false
5021 * and no real page is on the static path. So `active: true` on its own is
5022 * not evidence that pages are being served statically.
5023 *
5024 * The reachable case is nginx with Separate Mobile Cache on: the snippet
5025 * lives in the server block and we cannot remove it, pages are
5026 * deliberately routed to the PHP drop-in, but the probe file is still
5027 * served directly.
5028 *
5029 * The Health panel learned this in 88b4b50; the CLI, REST and MCP paths
5030 * did not, so they kept reporting "active" in exactly that configuration.
5031 * Rather than repeat the reasoning at each call site, they now all come
5032 * through here.
5033 *
5034 * Deliberately does NOT consult rewrite_installed(): on nginx the fast
5035 * path is the pasted snippet and there is no .htaccess marker to find, so
5036 * requiring one would report every correctly-configured nginx site as
5037 * broken.
5038 *
5039 * @param array $probe Raw result from probe_static_rewrite().
5040 * @return array{active:bool,inconclusive:bool,reason:string,block_reason:string}
5041 */
5042 public static function qualify_rewrite_probe( array $probe ): array {
5043 $active = (bool) ( $probe['active'] ?? false );
5044 $inconclusive = (bool) ( $probe['inconclusive'] ?? false );
5045 $reason = (string) ( $probe['reason'] ?? '' );
5046 $block_reason = self::static_rewrite_block_reason();
5047
5048 // Same observed-refusal check Health makes. This is the shared path for
5049 // `wp xspeed cache recheck-rewrite` and POST /cache/recheck-rewrite —
5050 // and, because a CLI command is automatically an MCP tool, for the
5051 // AI-facing surface too. Leaving it out would have fixed the dashboard
5052 // while the CLI kept answering that the fast path was active. (#372)
5053 if ( '' === $block_reason ) {
5054 $skip = self::last_static_skip();
5055 if ( ! empty( $skip['reason'] ) ) {
5056 $block_reason = 'skipped_' . (string) $skip['reason'];
5057 }
5058 }
5059
5060 // With page caching off there is nothing to serve, so `active` can
5061 // never be true here whatever the raw probe says. probe_static_rewrite()
5062 // writes its OWN file under the static tree and fetches that, which
5063 // succeeds whenever the server can serve a static file at all — and on
5064 // nginx the snippet is server-level, so it keeps succeeding after the
5065 // cache is switched off.
5066 //
5067 // block_reason() used to carry this meaning by accident: it returned
5068 // 'mobile_separate' with caching off, and the refusal branch below
5069 // forced active=false. Now that it correctly reports '' (nothing can
5070 // block a fast path that isn't in use), this consumer has to state the
5071 // condition itself — otherwise `wp xspeed cache recheck-rewrite` and
5072 // POST /cache/recheck-rewrite claim "the web server is serving cache
5073 // hits directly" on a site with no cache. That is a positive false
5074 // claim rather than a nag, i.e. worse than the bug being fixed.
5075 $cache_opts = Settings::get();
5076 if ( empty( $cache_opts['cache_enabled'] ) ) {
5077 return array(
5078 'active' => false,
5079 'inconclusive' => false,
5080 'reason' => 'Page caching is off, so there is no cache for the web server to serve.',
5081 'block_reason' => '',
5082 );
5083 }
5084
5085 // A known refusal outranks the probe, and also outranks
5086 // "inconclusive" — a blocked rewrite whose probe merely failed to
5087 // complete is still definitely blocked.
5088 if ( '' !== $block_reason ) {
5089 $active = false;
5090 $inconclusive = false;
5091 $reason = self::block_reason_text( $block_reason );
5092 }
5093
5094 return array(
5095 'active' => $active,
5096 'inconclusive' => $inconclusive,
5097 'reason' => $reason,
5098 'block_reason' => $block_reason,
5099 );
5100 }
5101
5102 /**
5103 * Human-readable explanation for a static_rewrite_block_reason() code.
5104 *
5105 * Each one has to say what to DO about it: "mobile_separate" alone tells
5106 * a user nothing, and the whole point of surfacing a refusal instead of
5107 * the probe verdict is that it is actionable.
5108 */
5109 public static function block_reason_text( string $code ): string {
5110 switch ( $code ) {
5111 case 'mobile_separate':
5112 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.';
5113 case 'no_mod_headers':
5114 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.";
5115 case 'skipped_nonce':
5116 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.';
5117 default:
5118 return sprintf( 'The static rewrite is disabled (%s).', $code );
5119 }
5120 }
5121
5122 public static function static_rewrite_block_reason(): string {
5123 // Nothing can be blocking the fast path when there is no cache to
5124 // serve from it. Without this the dashboard told users with page
5125 // caching switched OFF that Separate Mobile Cache "is disabling
5126 // faster static serving" — a fast path they were not using, about a
5127 // cache that did not exist. Every caller of this is a user-facing
5128 // explanation of why the rewrite is off, so "the cache is off" is
5129 // the honest answer, and it is silence. (#108)
5130 $opts = Settings::get();
5131 if ( empty( $opts['cache_enabled'] ) ) {
5132 return '';
5133 }
5134 if ( Server::LITESPEED === Server::type() ) {
5135 return ''; // Intended on LiteSpeed — not a "block".
5136 }
5137 if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) {
5138 return 'no_mod_headers';
5139 }
5140 $cache_opts = Settings_Manager::get( 'cache' );
5141 return ! empty( $cache_opts['mobile_separate'] ) ? 'mobile_separate' : '';
5142 }
5143
5144 /**
5145 * Whether migration flagged Separate Mobile Cache for user review. Set by
5146 * Migration::map_mobile_separate() when a source plugin (WP Rocket / WP
5147 * Super Cache / LiteSpeed) had its "separate mobile cache" option on: we
5148 * import it as OFF (to keep the device-blind static fast path) but record
5149 * this flag so the dashboard can invite the user to turn it back on only
5150 * if their site genuinely serves different HTML per device. (FBS-83145)
5151 */
5152 public static function mobile_separate_needs_review(): bool {
5153 // Same reasoning as static_rewrite_block_reason(): the invitation is
5154 // "turn this back on if your site needs it, to regain the fast path",
5155 // which is meaningless with page caching off — there is no fast path
5156 // to regain, and the equality probe behind the prompt would fetch
5157 // pages that aren't being cached. Gated here rather than at the two
5158 // payload call sites (Admin + Rest_Api) so `enabled`, `blocking` and
5159 // `needs_review` are consistently gated on the same condition. (#108)
5160 $opts = Settings::get();
5161 if ( empty( $opts['cache_enabled'] ) ) {
5162 return false;
5163 }
5164 $cache_opts = Settings_Manager::get( 'cache' );
5165 return ! empty( $cache_opts['mobile_separate_review'] );
5166 }
5167
5168 /**
5169 * Clear the review flag — called when the user has acted on the prompt
5170 * (dismissed it, or turned Separate Mobile Cache on/off deliberately) so
5171 * the dashboard callout doesn't nag forever. Writes the option directly
5172 * (bypassing Settings_Manager) so it never touches schema fields.
5173 */
5174 public static function clear_mobile_separate_review(): void {
5175 $stored = get_option( 'xspeed_module_cache', array() );
5176 if ( ! is_array( $stored ) || empty( $stored['mobile_separate_review'] ) ) {
5177 return;
5178 }
5179 unset( $stored['mobile_separate_review'] );
5180 update_option( 'xspeed_module_cache', $stored );
5181 }
5182
5183 /**
5184 * On-demand probe: does the homepage serve materially the same HTML to a
5185 * desktop and a mobile browser? Fetches home_url() twice over loopback —
5186 * once with a desktop User-Agent, once with a mobile one — strips
5187 * per-request noise (nonces, CSRF tokens, session ids, inline timestamps),
5188 * and compares. When identical, Separate Mobile Cache is almost certainly
5189 * unnecessary and the user can turn it off to regain the static fast path.
5190 *
5191 * NEVER run automatically (no page-load cost) — only from the dashboard
5192 * "Check now" button. Result is cached for 10 minutes so a double-click or
5193 * a re-render doesn't fire two more self-requests. (FBS-83145)
5194 *
5195 * @return array{ identical:bool, checked:bool, reason?:string, desktop_bytes?:int, mobile_bytes?:int }
5196 */
5197 public static function probe_mobile_equality(): array {
5198 $cached = get_transient( 'xspeed_mobile_equality_probe' );
5199 if ( is_array( $cached ) ) {
5200 return $cached;
5201 }
5202
5203 $home = home_url( '/' );
5204 $host = (string) wp_parse_url( $home, PHP_URL_HOST );
5205 if ( '' === $host ) {
5206 $result = array( 'identical' => false, 'checked' => false, 'reason' => 'home_url has no host' );
5207 set_transient( 'xspeed_mobile_equality_probe', $result, MINUTE_IN_SECONDS );
5208 return $result;
5209 }
5210
5211 // Match WP core's own mobile detection (wp_is_mobile) so the probe
5212 // reflects what the site would actually branch on. iPhone Safari for
5213 // mobile; a current desktop Chrome UA for desktop.
5214 $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';
5215 $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';
5216
5217 $is_local = function_exists( 'wp_get_environment_type' )
5218 && in_array( wp_get_environment_type(), array( 'local', 'development' ), true );
5219
5220 $fetch = static function ( string $ua ) use ( $home, $is_local ) {
5221 $resp = wp_remote_get(
5222 $home,
5223 array(
5224 'timeout' => 5,
5225 'sslverify' => ! $is_local,
5226 'redirection' => 2,
5227 // Bust any per-device cache so we compare freshly-rendered
5228 // HTML, and pass the device UA the site would branch on.
5229 'user-agent' => $ua,
5230 'headers' => array( 'Cache-Control' => 'no-cache' ),
5231 )
5232 );
5233 if ( is_wp_error( $resp ) || 200 !== (int) wp_remote_retrieve_response_code( $resp ) ) {
5234 return null;
5235 }
5236 return (string) wp_remote_retrieve_body( $resp );
5237 };
5238
5239 $desktop = $fetch( $desktop_ua );
5240 $mobile = $fetch( $mobile_ua );
5241
5242 if ( null === $desktop || null === $mobile ) {
5243 $result = array( 'identical' => false, 'checked' => false, 'reason' => 'could not fetch homepage twice' );
5244 set_transient( 'xspeed_mobile_equality_probe', $result, MINUTE_IN_SECONDS );
5245 return $result;
5246 }
5247
5248 $identical = self::normalize_html_for_diff( $desktop ) === self::normalize_html_for_diff( $mobile );
5249
5250 $result = array(
5251 'identical' => $identical,
5252 'checked' => true,
5253 'desktop_bytes' => strlen( $desktop ),
5254 'mobile_bytes' => strlen( $mobile ),
5255 );
5256 set_transient( 'xspeed_mobile_equality_probe', $result, 10 * MINUTE_IN_SECONDS );
5257 return $result;
5258 }
5259
5260 /**
5261 * Strip per-request noise from HTML so a desktop-vs-mobile diff reflects
5262 * real structural differences, not nonces / session ids / timestamps that
5263 * change on every render. Deliberately conservative: it normalizes the
5264 * handful of well-known noise sources and collapses whitespace, so a site
5265 * that truly serves different markup per device still compares as different.
5266 */
5267 private static function normalize_html_for_diff( string $html ): string {
5268 // Every rule here errs toward "they differ" being WRONG rather than
5269 // "they match" being wrong: this check only ever tells a user it is
5270 // SAFE to turn Separate Mobile Cache off, so a false "identical"
5271 // would cost them device-specific output. The risk of being too
5272 // conservative is milder but real — the useful answer never appears,
5273 // and the feature's whole pitch ("we'll prove it's safe to turn
5274 // off") silently never pays out. These close the gaps that made a
5275 // mismatch effectively guaranteed on an ordinary WordPress site. (#108)
5276 $patterns = array(
5277 // WP nonces in attribute or JSON form: data-nonce="…",
5278 // _wpnonce=…, "nonce":"…". The `[:=]` adjacency below misses
5279 // wp_nonce_field()'s own markup — `name="_wpnonce" value="ab…"`
5280 // puts `value=` between the key and the token — which is the
5281 // single most common nonce shape in WordPress, so that form is
5282 // matched explicitly first.
5283 '/name=["\']?(_wpnonce|_ajax_nonce)["\']?\s+value=["\']?[a-z0-9]{8,}/i',
5284 // CSP nonces on script/style tags. Base64, so uppercase and
5285 // +/= appear — the hex-only rules below can never match one,
5286 // and a CSP-enabled site therefore differed on every fetch.
5287 // MUST precede the generic nonce rule: that one stops at the
5288 // first non-alphanumeric, leaving the rest of the token behind
5289 // and the two responses still unequal.
5290 // The quotes are optional so HTML5's legal unquoted attribute
5291 // form (`<script nonce=AbCd+q/r=>`) is covered too — without
5292 // that it fell through to the generic rule, which is the exact
5293 // failure this rule exists to remove.
5294 '/\bnonce=(["\'])?[A-Za-z0-9+\/=_-]{8,}(?(1)\1)/',
5295 '/(_wpnonce|nonce|_ajax_nonce)["\']?\s*[:=]\s*["\']?[a-z0-9]{8,}/i',
5296 // Generic hex tokens: cache busters, session ids, md5/sha
5297 // digests. Was 16+, which left an 11-15 char gap above the
5298 // 10-char nonce rule.
5299 //
5300 // The token MUST contain at least one a-f letter. `[a-f0-9]`
5301 // also matches every decimal digit, so a bare `{10,}` erased
5302 // every 10+ digit INTEGER anywhere in the document — including
5303 // visible body text. A page whose desktop and mobile HTML
5304 // differed only by a per-device numeric id (an AdSense slot, an
5305 // A/B bucket, an analytics property) then compared as identical,
5306 // and the check told the user it was safe to switch off the very
5307 // setting keeping that output correct — the one direction this
5308 // function must never fail in. Decimal-only runs are left to the
5309 // bounded epoch rule below, which is deliberately narrower.
5310 //
5311 // Known, accepted (QA R2): a token whose letters all fall in a-f
5312 // reads as a digest, so a per-device `ABC1234567890` strips even
5313 // though it is an id, not a hash. Deliberately left open — the
5314 // alternatives all cost more than the bug:
5315 //
5316 // Token shape (lowercase-only, case-uniformity, a trailing
5317 // letter) cannot separate it. `ABC1234567890` and
5318 // `ABCDEF012345` — an uppercase digest this rule SHOULD strip —
5319 // are both all-hex, uniformly cased, letters-then-digits.
5320 // Each variant fixed the id only by sparing the digest.
5321 //
5322 // Letter density does separate them (23% letters vs 50%), but
5323 // measured over 2000 md5/sha1/sha256 samples, requiring letters
5324 // spread through the token leaves 21-67% of REAL digests
5325 // unmatched depending on the window. Digest noise is most of
5326 // what this function exists to remove, so that trade guts it.
5327 //
5328 // Context (protecting data-* attribute values from this rule)
5329 // works for ids and still strips digests in URLs, classes and
5330 // query strings — but regresses a CHANGING digest inside a
5331 // non-nonce data-* attribute, and needs a two-pass
5332 // hold/restore. Viable if R2 is ever worth pressing; its
5333 // failure at least errs toward "differ".
5334 //
5335 // An A-F-only prefix on a per-device id is rare, and the earlier
5336 // nonce rules already claim the data-nonce/_wpnonce shapes.
5337 '/\b(?=[a-f0-9]{10,}\b)[0-9]*[a-f][a-f0-9]*\b/i',
5338 // wp-generated unique ids (e.g. wp-block ids, aria ids).
5339 '/(id|for|aria-[a-z]+)="[^"]*-[0-9]{3,}"/i',
5340 // ISO-ish timestamps + epoch-looking numbers in query strings.
5341 '/\?ver=[0-9.]+/',
5342 '/[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.+Z-]+/',
5343 // Our own signature's generation stamp. The two fetches are
5344 // sequential and each writes its own entry, so this differs on
5345 // essentially every comparison — and it is space-separated, so
5346 // the ISO rule above (which requires a literal `T`) never
5347 // touches it. Without this the probe reports "differ" for every
5348 // site and the "safe to turn Separate Mobile Cache off" verdict
5349 // can never appear.
5350 '/generated [0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2} UTC/',
5351 // Raw epoch seconds. The two fetches are sequential, so any
5352 // template printing time() guaranteed a mismatch.
5353 //
5354 // This is the ONLY rule that may strip a decimal-only run, so
5355 // its bound is load-bearing rather than decorative — every digit
5356 // it gives away is a class of per-device id it silently erases.
5357 // `1[0-9]{9}` was too loose: it claimed the whole
5358 // 1000000000-1999999999 range (2001-2033) to cover timestamps
5359 // nobody serves, and took every 10-digit AdSense slot, order id
5360 // and SKU beginning with 1 along with it — reproducing the exact
5361 // false-"identical" verdict the hex rule above was tightened to
5362 // stop. `1[6-9]` covers 2020-2033, which is the only span a live
5363 // site can actually print, and collides with roughly a tenth as
5364 // many ids.
5365 //
5366 // Not airtight — an id beginning 16-19 still collides. Closing
5367 // that properly means scoping this to places a timestamp really
5368 // appears (an attribute value, a query parameter, a JSON value)
5369 // rather than bare body text; the bound is the cheap 90% of it.
5370 '/\b1[6-9][0-9]{8}\b/',
5371 );
5372 $html = (string) preg_replace( $patterns, 'X', $html );
5373 // Collapse all whitespace so trivial formatting differences don't count.
5374 return trim( (string) preg_replace( '/\s+/', ' ', $html ) );
5375 }
5376
5377 public static function ensure_hits_log_file(): bool {
5378 // TWO writers append to this log, and an earlier fix conflated them:
5379 //
5380 // 1. nginx, via the server-level `access_log` directive in
5381 // nginx_snippet() — a DIFFERENT uid, which is why the file needs
5382 // to be world-writable there.
5383 // 2. the PHP drop-in (advanced-cache.php), on EVERY server. A hit it
5384 // serves bypasses WordPress entirely, so it can't call
5385 // Hit_Counter::record_hit() — appending here is the only way that
5386 // hit is ever counted.
5387 //
5388 // The nginx-only early return that used to sit at the top of this
5389 // method was fixing something real: chmod() on a file PHP doesn't own
5390 // raises "Operation not permitted", and off nginx that chmod buys
5391 // nothing. But it took directory creation with it, so on LiteSpeed
5392 // (which always serves via the drop-in), on Apache without mod_headers,
5393 // and anywhere mobile_separate forces the drop-in path, writer 2 was
5394 // appending to a file whose parent directory did not exist. The append
5395 // is @-suppressed and documented as non-fatal, so every one of those
5396 // hits vanished and the dashboard ratio sat at 0% forever.
5397 //
5398 // So: create the dir + file everywhere, and keep only the chmod gated
5399 // to nginx.
5400 $dir = self::hits_log_dir();
5401 if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
5402 return false;
5403 }
5404
5405 $is_nginx = ( Server::NGINX === Server::type() );
5406
5407 if ( $is_nginx ) {
5408 // Ensure the dir is traversable + writable by a different-uid nginx.
5409 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- nginx (a separate uid in multi-container setups) must be able to create/append the log; WP_Filesystem layers ownership overrides that defeat that intent.
5410 @chmod( $dir, 0777 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort; the access_log just stays empty if it fails.
5411 }
5412
5413 $path = self::hits_log_path();
5414 if ( ! file_exists( $path ) ) {
5415 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch -- See docblock: must be a plain touch, not WP_Filesystem.
5416 @touch( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- non-fatal helper; failures already covered by the dir check.
5417 }
5418
5419 if ( $is_nginx ) {
5420 // World-writable so a different-uid nginx can append HIT lines.
5421 // Off nginx the drop-in appends as the same uid that owns the file,
5422 // so this is unnecessary — and would emit the "Operation not
5423 // permitted" warnings the old early return was added to silence.
5424 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- See docblock.
5425 @chmod( $path, 0666 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort.
5426 }
5427
5428 return file_exists( $path );
5429 }
5430
5431 public static function nginx_snippet(): ?string {
5432 if ( Server::NGINX !== Server::type() ) {
5433 return null;
5434 }
5435 $rel = '/' . ltrim( str_replace( ABSPATH, '/', XSPEED_CACHE_STATIC_DIR ), '/' );
5436 $rel = rtrim( $rel, '/' );
5437
5438 // WP-Rocket-canonical pattern: every condition lives at
5439 // SERVER level (outside any location block). Each one appends
5440 // a tag to $xspeed_no_cache; the final check is a single
5441 // string-equality against the unmodified default "no-cache".
5442 // Only when ALL conditions pass does the rewrite fire,
5443 // jumping the request to the static file's URL. nginx then
5444 // restarts location matching against the new path, where
5445 // regular static-file serving takes over.
5446 //
5447 // Why server-level + a single rewrite (instead of try_files
5448 // inside `location /`): nginx's well-documented "if is evil"
5449 // quirk silently disables `try_files`'s last fallback when
5450 // any `if` in the same location is true. Moving the `if`s
5451 // outside any location dodges the trap completely, because
5452 // server-level rewrite is the documented stable path.
5453 //
5454 // `last` (not `break`) restarts location matching — required
5455 // so the rewritten static-file URI gets served via the normal
5456 // static-file location, not re-matched against `location /`
5457 // where our own rewrite would loop.
5458 //
5459 // The cache existence check is the LAST condition in the
5460 // chain so when the file isn't cached, $xspeed_no_cache
5461 // gets a "-nofile" tag and the rewrite is skipped — the
5462 // request falls through to whatever `location /` the user
5463 // already had (typically `try_files $uri $uri/ /index.php?$args;`).
5464 // Absolute path to the hit-log file from the nginx process's
5465 // filesystem view. Nginx's `access_log buffer=N flush=Ns` form
5466 // requires a literal path — `$document_root` variables are
5467 // rejected — so PHP computes it. Lives under uploads/ (NOT the
5468 // cache dir): a cache purge or uninstall deletes the cache dir,
5469 // which would orphan this directive's parent directory and make
5470 // `nginx -t` fail [emerg] for EVERY vhost on the host
5471 // (FBS-82478). uploads/ survives both, so the directive can
5472 // never take nginx down. Works on every topology where the nginx
5473 // process shares a filesystem with PHP (container or host).
5474 $hits_abs = self::hits_log_path();
5475
5476 $lines = array();
5477 $lines[] = '# xSpeed static cache — paste at server level, above location / { }.';
5478 // Cache host must match the on-disk dir PHP writes: store_static() /
5479 // static_host() take HTTP_HOST and strip every char outside
5480 // [a-zA-Z0-9.\-] — i.e. it removes the colon but KEEPS the port digits
5481 // (localhost:8192 → localhost8192). nginx's own $host can't reproduce
5482 // that: $host has the port already stripped ENTIRELY (→ localhost), so
5483 // the -f check looks for localhost/... while PHP wrote localhost8192/...
5484 // and the rewrite never fires on a non-standard port. Derive
5485 // $xspeed_host from $http_host (which keeps the port) and drop just the
5486 // colon, so it equals the PHP dir on every port. On standard ports
5487 // $http_host has no colon, so $xspeed_host == $host == the bare domain.
5488 $lines[] = 'set $xspeed_host $http_host;'; // default: no port → unchanged (e.g. example.com)
5489 $lines[] = 'if ($http_host ~ "^([^:]+):(\\d+)$") { set $xspeed_host $1$2; }'; // host:port → hostport (matches PHP static_host())
5490 $lines[] = 'set $xspeed_no_cache "no-cache";';
5491 $lines[] = 'if ($request_method != GET) { set $xspeed_no_cache "$xspeed_no_cache-method"; }';
5492 $lines[] = 'if ($args) { set $xspeed_no_cache "$xspeed_no_cache-args"; }';
5493 // Cookie + user-agent exclusions, generated from the user's actual
5494 // settings rather than a hardcoded list. Before this, the rule
5495 // tested three fixed cookie names and no user agent at all, so
5496 // every excluded_cookies / bypass_user_agents entry applied only
5497 // while a page was cold — on a warm page nginx served the shared
5498 // anonymous copy to carts, members and bypassed bots alike. The
5499 // three historical names survive as a floor inside cookie_rule().
5500 // `~*` is case-insensitive, matching PHP's stripos()/glob checks.
5501 $cache_opts = Settings_Manager::get( 'cache' );
5502 $cookie_rule = Server_Rules::cookie_rule(
5503 is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array()
5504 );
5505 $lines[] = 'if ($http_cookie ~* "(' . $cookie_rule['regex'] . ')") { set $xspeed_no_cache "$xspeed_no_cache-cookie"; }';
5506
5507 $ua_rule = Server_Rules::user_agent_rule(
5508 is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array()
5509 );
5510 // Emitted only when the list is non-empty — an empty alternation
5511 // would compile to `(...)` matching every request and disable the
5512 // fast path entirely.
5513 if ( '' !== $ua_rule['regex'] ) {
5514 $lines[] = 'if ($http_user_agent ~* "(' . $ua_rule['regex'] . ')") { set $xspeed_no_cache "$xspeed_no_cache-ua"; }';
5515 }
5516
5517 // URL exclusions. Without this an excluded URL was only excluded
5518 // while its page was cold: PHP won't write a static file for one, so
5519 // there is usually nothing to serve — but a page cached BEFORE the
5520 // rule was added still has its file on disk, and nginx serves it
5521 // without ever asking PHP. The exclusion then does nothing until the
5522 // next purge. (#169)
5523 //
5524 // Matched against $uri, not $request_uri: $uri is the decoded path
5525 // without the query string, which is what Cache::should_cache()
5526 // tests. Using $request_uri would make `/cart` fail to match
5527 // `/cart?x=1` inconsistently with PHP. Same empty-regex guard as the
5528 // UA rule above — an empty alternation matches everything.
5529 $url_rule = Server_Rules::url_rule(
5530 is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array()
5531 );
5532 if ( '' !== $url_rule['regex'] ) {
5533 $lines[] = 'if ($uri ~* "(' . $url_rule['regex'] . ')") { set $xspeed_no_cache "$xspeed_no_cache-url"; }';
5534 }
5535 $lines[] = 'if (!-f "$document_root' . $rel . '/$xspeed_host$uri/index.html") { set $xspeed_no_cache "$xspeed_no_cache-nofile"; }';
5536 // Neither `add_header` nor `access_log` is allowed inside an `if{}`
5537 // at server level (nginx rejects with "directive is not allowed
5538 // here"). The logging therefore lives in a `location` block that
5539 // matches the rewritten URI after `rewrite … last;` restarts
5540 // location matching. Every HIT lands there exactly once, every
5541 // MISS / PHP-served request never matches it.
5542 $lines[] = 'if ($xspeed_no_cache = "no-cache") {';
5543 $lines[] = ' rewrite ^ ' . $rel . '/$xspeed_host$uri/index.html last;';
5544 $lines[] = '}';
5545 $lines[] = '';
5546 $lines[] = '# Serve + log the cached HIT — `^~` is required so this beats any regex location.';
5547 $lines[] = 'location ^~ ' . $rel . '/ {';
5548 $lines[] = ' internal;';
5549 // LITERAL log path (not `set $var; access_log $var`). The variable form
5550 // makes nginx open the log lazily per-request and SILENTLY drop the
5551 // line if the open fails — so on a working host hits were served
5552 // (X-XSpeed-Cache fires regardless) but nothing was ever written and
5553 // the hit ratio sat at 0%. A literal path makes nginx open the file at
5554 // config load and actually log every hit.
5555 //
5556 // Deleting the log FILE is still safe with a literal path: nginx
5557 // recreates it on the next write/reload and `nginx -t` stays green
5558 // (verified). The only thing that [emerg]s `nginx -t` is a missing
5559 // parent DIRECTORY — and the log lives under uploads/xspeed/, which
5560 // survives cache purge + uninstall, and which ensure_hits_log_file()
5561 // (run on every admin_init via auto_heal) recreates if it ever goes
5562 // missing. So: hits are logged, and a user deleting the log can't take
5563 // nginx down.
5564 $lines[] = ' access_log ' . $hits_abs . ' combined buffer=16k flush=5s;';
5565 $lines[] = ' add_header X-XSpeed-Cache "HIT (nginx)" always;';
5566 $lines[] = '}';
5567 return implode( "\n", $lines );
5568 }
5569
5570 /**
5571 * Aggregate every enabled module's nginx_directives() into one
5572 * pasteable server-block snippet. Replaces the per-module "paste
5573 * this snippet" notices with a single consolidated paste — every
5574 * future feature toggle just regenerates this output.
5575 *
5576 * Returns null on non-nginx hosts (nothing to paste).
5577 *
5578 * Sections render in module-registration order so the layout stays
5579 * predictable; each module gets a comment header `# <slug>`.
5580 */
5581 public static function full_nginx_server_block(): ?string {
5582 if ( Server::NGINX !== Server::type() ) {
5583 return null;
5584 }
5585
5586 $blocks = array();
5587 foreach ( Module_Registry::all() as $module ) {
5588 $directives = $module->nginx_directives();
5589 if ( ! is_string( $directives ) || '' === trim( $directives ) ) {
5590 continue;
5591 }
5592 $blocks[] = "# === " . $module->slug() . " ===\n" . rtrim( $directives );
5593 }
5594
5595 if ( empty( $blocks ) ) {
5596 return null;
5597 }
5598
5599 $header = "# xSpeed unified nginx config — paste into `server { }`, above `location / { }`; re-paste after toggling features.\n";
5600
5601 return $header . "\n" . implode( "\n\n", $blocks ) . "\n";
5602 }
5603
5604 /**
5605 * Tell LiteSpeed's LSCache module to stand down on the cache-miss
5606 * render path.
5607 *
5608 * History: this method used to emit X-LiteSpeed-Cache-Control:
5609 * public,max-age=N + X-LiteSpeed-Tag, handing caching to the server's
5610 * LSCache store. That delegation backfired — once LSCache cached a
5611 * page it served every subsequent request from its OWN store and
5612 * intercepted the request before our site-root .htaccess static
5613 * rewrite could run. Net effect on LiteSpeed hosts: no X-XSpeed-Cache
5614 * header, our static-cache tree never served, the HIT log never
5615 * written (hit ratio frozen at 0%), and the Health probe reporting a
5616 * false "cache running on PHP fallback" because it never saw an
5617 * xSpeed-served response.
5618 *
5619 * xSpeed now owns the cache on LiteSpeed exactly as it does on Apache:
5620 * our `.htaccess` mod_rewrite block serves hits straight from the
5621 * static-cache tree (with the X-XSpeed-Cache header + access-log HIT
5622 * accounting), and PHP/the drop-in is the fallback. To guarantee
5623 * LSCache doesn't shadow that with its own copy — some LiteSpeed
5624 * configs cache by default — we send an explicit `no-cache` control so
5625 * the server defers to our rewrite. Skipped when the LiteSpeed Cache
5626 * plugin is active (it owns its own header policy; our Conflict
5627 * registry handles that coexistence separately).
5628 */
5629 public static function maybe_emit_lscache_headers(): void {
5630 if ( headers_sent() ) {
5631 return;
5632 }
5633 if ( Server::LITESPEED !== Server::type() ) {
5634 return;
5635 }
5636 // is_plugin_active() lives in wp-admin/includes/plugin.php which
5637 // isn't auto-loaded on front-end requests. Use the option layer
5638 // directly to avoid pulling in admin code from a render path.
5639 $active = (array) get_option( 'active_plugins', array() );
5640 if ( in_array( 'litespeed-cache/litespeed-cache.php', $active, true ) ) {
5641 return;
5642 }
5643
5644 // Explicitly opt this response OUT of LSCache so the server can't
5645 // shadow our static-rewrite cache with its own internal copy.
5646 header( 'X-LiteSpeed-Cache-Control: no-cache' );
5647 }
5648
5649 /**
5650 * Restore the drop-in + WP_CACHE constant for a site that had caching
5651 * ON before this activation — and ONLY for such a site.
5652 *
5653 * WordPress runs an upgrade as deactivate → wipe plugin files →
5654 * install → activate. The wipe takes advanced-cache.php with it, so
5655 * without this the site serves 100% uncached from the moment the
5656 * update finishes until the next authenticated wp-admin page load
5657 * (auto_heal() is on admin_init). On a site whose admin logs in
5658 * rarely that window is hours or days of silent cache loss, while
5659 * the dashboard still reports cache_enabled = true. (FBS field
5660 * report against 1.1.2 / Pro 1.0.5.)
5661 *
5662 * The `cache_enabled` guard is the whole contract: a FRESH install
5663 * has the option unset, so activation writes nothing and the user
5664 * still opts in explicitly through Cache::toggle() via the
5665 * /cache/toggle REST endpoint. We only ever put back state the user
5666 * already chose — repair, never a new install path. This is what
5667 * keeps us on the right side of the "don't create drop-ins the user
5668 * didn't ask for" guideline while matching what WP Rocket, W3 Total
5669 * Cache and WP Super Cache all do on activation.
5670 *
5671 * @return bool True when a restore was performed.
5672 */
5673 public static function restore_dropin_if_enabled(): bool {
5674 if ( defined( 'WP_INSTALLING' ) && WP_INSTALLING ) {
5675 return false;
5676 }
5677
5678 // The user's saved choice. Absent/false on a fresh install => no
5679 // drop-in is written and nothing touches wp-config.php.
5680 $opts = get_option( 'xspeed_options', array() );
5681 if ( empty( $opts['cache_enabled'] ) ) {
5682 return false;
5683 }
5684
5685 $state = self::toggle( true );
5686 // A refusal reports whether the cache SERVES, which on this path can
5687 // be true for reasons that have nothing to do with this call — so a
5688 // refusal would otherwise log "drop-in restored" for a restore that
5689 // was declined. Restored means the transaction went through.
5690 $restored = empty( $state['blocked'] ) && ! empty( $state['enabled'] );
5691
5692 if ( $restored ) {
5693 Activity_Log::record(
5694 'cache_dropin_restored',
5695 'Cache drop-in restored after a plugin update — caching was already enabled.',
5696 Activity_Log::SUCCESS
5697 );
5698 }
5699
5700 return $restored;
5701 }
5702
5703 /**
5704 * Reconcile drop-in + WP_CACHE + rewrite block with the user's
5705 * saved choice. Runs on admin_init. Cheap when nothing's wrong
5706 * (one option read + a handful of file_exists / defined checks);
5707 * writes only when state has drifted (typical cause: plugin
5708 * upgrade wiped the drop-in, foreign plugin removed our WP_CACHE
5709 * define, or someone hand-edited .htaccess).
5710 *
5711 * Skipped during the WP plugin updater run so we don't race
5712 * the upgrader's own filesystem operations.
5713 */
5714 public static function auto_heal(): void {
5715 if ( defined( 'WP_INSTALLING' ) && WP_INSTALLING ) {
5716 return;
5717 }
5718 if ( wp_doing_ajax() || wp_doing_cron() ) {
5719 return;
5720 }
5721
5722 $opts = get_option( 'xspeed_options', array() );
5723 if ( empty( $opts['cache_enabled'] ) ) {
5724 return;
5725 }
5726
5727 $state = self::toggle( true );
5728 // A refusal means something else now owns the page-cache field, or
5729 // the write could not be verified. Either way this is not the moment
5730 // to go on maintaining our rewrite block and log file.
5731 if ( ! empty( $state['blocked'] ) || empty( $state['enabled'] ) ) {
5732 return;
5733 }
5734
5735 // Rewrite block goes last. It's what turns the static-cache
5736 // tree into a PHP-bypass — every cache hit served by the web
5737 // server directly. Without it we still cache, just at drop-in
5738 // speed (~85ms TTFB) instead of static-file speed (~25-40ms).
5739 //
5740 // Reconcile against mobile_separate: the rewrite is device-blind, so
5741 // it must be ABSENT when mobile_separate is on and PRESENT otherwise.
5742 // auto_heal() runs periodically, so it also repairs a rewrite that
5743 // was left installed before mobile_separate was switched on.
5744 if ( self::static_rewrite_allowed() ) {
5745 if ( ! self::rewrite_installed() ) {
5746 self::install_rewrite();
5747 }
5748 } elseif ( self::rewrite_installed() ) {
5749 self::remove_rewrite();
5750 }
5751
5752 // HITs log file — nginx writes one line per HIT served directly
5753 // (see nginx_snippet()), Cache::get_stats() drains the file via
5754 // Hit_Counter::collect_nginx_log_hits(). If the file vanishes
5755 // (plugin upgrade wiped wp-content/cache/), nginx errors silently
5756 // on the access_log directive and the counter stays at 0.
5757 self::ensure_hits_log_file();
5758 }
5759
5760 /**
5761 * Keep the generic bypass cookie in sync with PHP's caching verdict.
5762 *
5763 * The server config tests exactly one cookie name (Server_Rules::
5764 * BYPASS_COOKIE) forever, and PHP decides what that name means. Adding
5765 * a new excluded cookie therefore needs no config change and no nginx
5766 * reload — the reason this exists.
5767 *
5768 * Session cookie (expiry 0) so it dies with the browser session, and
5769 * deliberately NOT HttpOnly-sensitive: it carries no identity, only the
5770 * boolean "don't serve this visitor a shared cached page".
5771 *
5772 * Honest limit: this can only ever help a visitor PHP has already seen
5773 * once. A bot's first request to a warm page never reaches PHP, which
5774 * is why user-agent rules are still written into the server config
5775 * rather than relying on this.
5776 *
5777 * @param bool $bypass Whether this visitor must skip the cache.
5778 */
5779 private static function sync_bypass_cookie( bool $bypass ): void {
5780 if ( headers_sent() ) {
5781 return;
5782 }
5783
5784 $name = Server_Rules::BYPASS_COOKIE;
5785 $has = isset( $_COOKIE[ $name ] );
5786
5787 // Only touch the header when the state actually changes — a
5788 // Set-Cookie on every request would make the response uncacheable
5789 // for intermediary caches and add noise to every hit.
5790 if ( $bypass === $has ) {
5791 return;
5792 }
5793
5794 $path = defined( 'COOKIEPATH' ) && COOKIEPATH ? COOKIEPATH : '/';
5795 $domain = defined( 'COOKIE_DOMAIN' ) ? COOKIE_DOMAIN : '';
5796
5797 if ( $bypass ) {
5798 setcookie( $name, '1', 0, $path, (string) $domain, is_ssl(), false );
5799 $_COOKIE[ $name ] = '1';
5800 } else {
5801 setcookie( $name, '', time() - 3600, $path, (string) $domain, is_ssl(), false );
5802 unset( $_COOKIE[ $name ] );
5803 }
5804 }
5805
5806 /**
5807 * Build the .htaccess rules that map cacheable requests to the
5808 * static-cache tree. Conditions are deliberately strict: GET only,
5809 * empty query string, no session/comment-author/post-password
5810 * cookie, and the static file must exist on disk. Anything that
5811 * fails one of these falls through to PHP and the drop-in / full
5812 * WordPress path.
5813 *
5814 * @return string[] Lines for insert_with_markers().
5815 */
5816 public static function rewrite_block_lines(): array {
5817 // Path relative to ABSPATH so the rule lives in the site-root
5818 // .htaccess regardless of where wp-content sits. WP_CONTENT_DIR
5819 // can be moved, so we compute the document-root-relative form
5820 // at install time and bake it into the rule.
5821 $rel = str_replace( ABSPATH, '/', XSPEED_CACHE_STATIC_DIR );
5822 $rel = '/' . ltrim( $rel, '/' );
5823 $rel = rtrim( $rel, '/' );
5824
5825 // Cookie + user-agent exclusions generated from the live settings.
5826 // See the matching block in nginx_snippet() — same generator, same
5827 // floor, so both servers enforce an identical policy. Apache reads
5828 // .htaccess on every request and we already self-heal this file, so
5829 // Apache/LiteSpeed users get the fix on upgrade with no action.
5830 $cache_opts = Settings_Manager::get( 'cache' );
5831 $cookie_rule = Server_Rules::cookie_rule(
5832 is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array()
5833 );
5834 $ua_rule = Server_Rules::user_agent_rule(
5835 is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array()
5836 );
5837
5838 $lines = array(
5839 '<IfModule mod_rewrite.c>',
5840 ' RewriteEngine On',
5841 ' RewriteCond %{REQUEST_METHOD} ^GET$',
5842 ' RewriteCond %{QUERY_STRING} ^$',
5843 ' RewriteCond %{HTTP_COOKIE} !(' . $cookie_rule['regex'] . ') [NC]',
5844 );
5845
5846 // Only emit the UA condition when there's something to match —
5847 // `!()` would negate an always-true empty match and refuse every
5848 // request, silently disabling the static path.
5849 if ( '' !== $ua_rule['regex'] ) {
5850 // Quoted, because RewriteCond is whitespace-delimited and real
5851 // user-agent fragments contain spaces ("Mozilla/5.0 (compatible").
5852 // Unquoted, a space adds an argument and Apache answers every
5853 // request with a 500 — and because .htaccess is parsed per
5854 // request, `httpd -t` still reports Syntax OK. Server_Rules has
5855 // already excluded quotes and backslashes from the alternation,
5856 // so the closing quote here cannot be escaped away.
5857 $lines[] = ' RewriteCond %{HTTP_USER_AGENT} "!(' . $ua_rule['regex'] . ')" [NC]';
5858 }
5859
5860 return array_merge(
5861 $lines,
5862 array(
5863 // Capture REQUEST_URI without its trailing slash into %1.
5864 // store_static() writes `{host}{uri-without-trailing-slash}/index.html`,
5865 // so this normalization lets `/blog/` and `/blog` both hit
5866 // the same cache file without producing the double-slash
5867 // path that would skip the -f check below.
5868 ' RewriteCond %{REQUEST_URI} ^(.*?)/?$',
5869 ' RewriteCond %{DOCUMENT_ROOT}' . $rel . '/%{HTTP_HOST}%1/index.html -f',
5870 // Pattern is `^`, NOT `.`. The per-directory rewrite engine
5871 // strips the leading slash before matching, so the HOMEPAGE
5872 // request `/` arrives here as an EMPTY path. `.` requires at
5873 // least one character and therefore never matches the homepage
5874 // — on LiteSpeed (which honors this strictly) the front page
5875 // fell through to PHP while every inner page rewrote fine.
5876 // `^` matches the empty string AND any non-empty path, so it
5877 // covers `/` and `/blog` alike. (Confirmed on OpenLiteSpeed
5878 // 1.8: `.` → homepage served by PHP drop-in; `^` → served
5879 // directly from the static file.)
5880 ' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [L]',
5881 '</IfModule>',
5882 // Mark the statically-served response as a cache HIT.
5883 //
5884 // A file served by the rewrite above bypasses PHP entirely, so
5885 // this directive is the ONLY thing that can identify it as
5886 // cached — both for the user reading response headers and for
5887 // Hit_Counter, which reconciles static hits from the access
5888 // log. Without it the cache works perfectly and reports a 0%
5889 // hit ratio, which reads as "the plugin is broken". (Field
5890 // report against 1.1.2: homepage served byte-identical from
5891 // the static tree, no X-XSpeed-Cache header on any response.)
5892 //
5893 // `always` so the header is set on the 200 from the rewritten
5894 // file, not only on the successful-response table. The
5895 // <IfModule> guard keeps a server without mod_headers from
5896 // 500ing on an unknown directive — on such a host the header
5897 // is silently dropped, which is exactly why
5898 // static_rewrite_allowed() refuses the static path there and
5899 // routes hits through the drop-in instead.
5900 '<IfModule mod_headers.c>',
5901 ' <FilesMatch "\\.html$">',
5902 ' Header always set X-XSpeed-Cache "HIT (static)"',
5903 ' </FilesMatch>',
5904 '</IfModule>',
5905 )
5906 );
5907 }
5908
5909 /**
5910 * Active probe that confirms the web-server static-rewrite path is
5911 * actually serving cached files. Writes a probe file with a random
5912 * nonce, fetches it over HTTP at its public URL, and checks whether
5913 * the response was served directly by the web server (Last-Modified
5914 * + ETag headers + no X-Powered-By: PHP).
5915 *
5916 * Server-agnostic: same probe works for nginx (snippet pasted) and
5917 * Apache / LiteSpeed (.htaccess block installed). If the rewrite
5918 * isn't engaged, the request falls through to WordPress and PHP
5919 * adds its own headers, which the probe detects and reports.
5920 *
5921 * Throttled via a 5-minute transient — we never want this running
5922 * on every Health card paint.
5923 *
5924 * @return array{active:bool, reason:string, code?:int, php?:bool, expires?:int}
5925 */
5926 /**
5927 * @param bool $allow_probe When false (the default), return ONLY a cached
5928 * result and never make an HTTP request — so admin page loads are never
5929 * blocked by the loopback probe. The actual HTTP probe only runs when a
5930 * caller explicitly opts in (the Health tab / cron). Previously this ran
5931 * synchronously on every dashboard bootstrap, so a slow/timing-out
5932 * loopback request added up to `timeout` seconds to admin page loads on
5933 * hosts that block self-requests. (FBS-82142)
5934 */
5935 /**
5936 * Discard the cached probe result and run a fresh one.
5937 *
5938 * Without this there was no way to re-check: the result sat in a transient
5939 * for five minutes and nothing ever deleted it, so a user who fixed their
5940 * nginx config kept seeing "nginx detected — configure for max cache speed"
5941 * with no means of confirming the fix worked. (FBS-84012)
5942 */
5943 public static function recheck_static_rewrite(): array {
5944 delete_transient( 'xspeed_rewrite_probe' );
5945 return self::probe_static_rewrite( true );
5946 }
5947
5948 public static function probe_static_rewrite( bool $allow_probe = false ): array {
5949 $cached = get_transient( 'xspeed_rewrite_probe' );
5950 if ( is_array( $cached ) ) {
5951 return $cached;
5952 }
5953 // No cached result yet and the caller doesn't want to pay for a live
5954 // HTTP probe (e.g. the admin bootstrap): report "pending" without
5955 // blocking. The Health tab will run the real probe on demand.
5956 if ( ! $allow_probe ) {
5957 return array( 'active' => false, 'reason' => 'probe pending', 'pending' => true );
5958 }
5959
5960 $home = home_url( '/' );
5961 $host = (string) wp_parse_url( $home, PHP_URL_HOST );
5962 if ( '' === $host ) {
5963 $result = array( 'active' => false, 'reason' => 'home_url has no host' );
5964 set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
5965 return $result;
5966 }
5967
5968 // Use a randomised path AND nonce so a stale CDN cache entry
5969 // from a prior probe can never make a broken install look
5970 // healthy. Path is namespaced under __xspeed_probe__ so the
5971 // directory listing stays obvious if cleanup misfires.
5972 $slug = wp_generate_password( 12, false, false );
5973 $nonce = wp_generate_password( 24, false, false );
5974 $probe_dir = XSPEED_CACHE_STATIC_DIR . '/' . $host . '/__xspeed_probe__/' . $slug;
5975 $probe_file = $probe_dir . '/index.html';
5976 $probe_url = trailingslashit( $home ) . '__xspeed_probe__/' . $slug . '/';
5977
5978 if ( ! file_exists( $probe_dir ) ) {
5979 wp_mkdir_p( $probe_dir );
5980 }
5981 if ( ! is_dir( $probe_dir ) ) {
5982 $result = array( 'active' => false, 'reason' => 'cannot create probe dir' );
5983 set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
5984 return $result;
5985 }
5986 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin credentials we may not have here; the file is in our own cache dir.
5987 file_put_contents( $probe_file, $nonce, LOCK_EX );
5988
5989 // Verify TLS by default — disabling it site-wide is a needless MITM
5990 // exposure (FBS-82142). Only relax verification in local/dev
5991 // environments, where self-signed certs are common and there's no
5992 // real attacker in the loop.
5993 $is_local = function_exists( 'wp_get_environment_type' )
5994 && in_array( wp_get_environment_type(), array( 'local', 'development' ), true );
5995 $resp = wp_remote_get(
5996 $probe_url,
5997 array(
5998 // 3s cap so a host that hangs on loopback self-requests can't
5999 // stall the caller for long; the result/error is cached so we
6000 // don't repeat the wait every minute.
6001 'timeout' => 3,
6002 'sslverify' => ! $is_local,
6003 'redirection' => 0,
6004 'headers' => array( 'Cache-Control' => 'no-cache' ),
6005 )
6006 );
6007
6008 // Best-effort cleanup so we don't accumulate probe dirs even
6009 // if subsequent calls all hit the transient.
6010 if ( file_exists( $probe_file ) ) {
6011 wp_delete_file( $probe_file );
6012 }
6013 if ( is_dir( $probe_dir ) ) {
6014 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Best-effort probe-dir cleanup; WP_Filesystem needs admin credentials we don't have here.
6015 @rmdir( $probe_dir );
6016 }
6017
6018 if ( is_wp_error( $resp ) ) {
6019 $result = array(
6020 'active' => false,
6021 // The request never completed, so we learned NOTHING about the
6022 // rewrite. Flagged inconclusive so the UI doesn't tell the user
6023 // to configure a server that may already be configured — a
6024 // blocked loopback, a self-signed cert, or a timeout is a probe
6025 // failure, not a missing rewrite. (FBS-84012)
6026 'inconclusive' => true,
6027 'reason' => 'http error: ' . $resp->get_error_message(),
6028 );
6029 // Cache the failure for the full 5 minutes (not 1) so a host that
6030 // times out on the loopback probe isn't re-probed — and re-stalled
6031 // — on every page load within the window. (FBS-82142)
6032 set_transient( 'xspeed_rewrite_probe', $result, 5 * MINUTE_IN_SECONDS );
6033 return $result;
6034 }
6035
6036 $code = (int) wp_remote_retrieve_response_code( $resp );
6037 $body = (string) wp_remote_retrieve_body( $resp );
6038 $ua_php = '' !== (string) wp_remote_retrieve_header( $resp, 'x-powered-by' );
6039 $has_etag = '' !== (string) wp_remote_retrieve_header( $resp, 'etag' )
6040 || '' !== (string) wp_remote_retrieve_header( $resp, 'last-modified' );
6041 $match = trim( $body ) === $nonce;
6042
6043 // "Active" = the web server served our raw nonce bytes back
6044 // AND emitted the static-serve markers (ETag / Last-Modified)
6045 // AND didn't add an X-Powered-By: PHP header. All three are
6046 // individually noisy; together they're conclusive.
6047 $active = $match && $has_etag && ! $ua_php && 200 === $code;
6048
6049 /*
6050 * `inconclusive` separates "we proved the rewrite isn't serving" from
6051 * "the probe couldn't tell". Only the former should drive a
6052 * configure-your-server banner; the latter previously rendered the
6053 * same alarming copy at a user who had already configured nginx
6054 * correctly, and there was no way to clear it. (FBS-84012)
6055 */
6056 $inconclusive = false;
6057 if ( $active ) {
6058 $reason = 'static-served';
6059 } elseif ( 200 === $code && $match && $ua_php ) {
6060 $reason = 'php served the file instead of nginx/Apache (rewrite block missing)';
6061 } elseif ( 200 === $code && ! $match ) {
6062 // Something answered 200 with content that isn't our nonce — a CDN,
6063 // a proxy, a security plugin. That tells us nothing about the
6064 // origin's rewrite.
6065 $reason = 'unexpected body (CDN cached an older response?)';
6066 $inconclusive = true;
6067 } elseif ( 404 === $code ) {
6068 $reason = 'probe URL returned 404 (rewrite block missing or wrong path)';
6069 } else {
6070 // Redirects, 403s from a WAF, 5xx — the probe never reached a
6071 // verdict about the rewrite itself.
6072 $reason = sprintf( 'unexpected response (HTTP %d, body %d B, php=%s)', $code, strlen( $body ), $ua_php ? 'yes' : 'no' );
6073 $inconclusive = true;
6074 }
6075
6076 $result = array(
6077 'active' => $active,
6078 'inconclusive' => $inconclusive,
6079 'reason' => $reason,
6080 'code' => $code,
6081 'php' => $ua_php,
6082 );
6083 set_transient( 'xspeed_rewrite_probe', $result, 5 * MINUTE_IN_SECONDS );
6084 return $result;
6085 }
6086
6087 public static function rewrite_installed(): bool {
6088 $htaccess = ABSPATH . '.htaccess';
6089 if ( ! file_exists( $htaccess ) ) {
6090 return false;
6091 }
6092 $existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
6093 if ( ! is_string( $existing ) ) {
6094 return false;
6095 }
6096 return false !== strpos( $existing, '# BEGIN xSpeed Static Cache' );
6097 }
6098
6099 /**
6100 * Install the static-cache rewrite block at the TOP of .htaccess.
6101 *
6102 * Position matters: WordPress's own block ends with
6103 * `RewriteRule . /index.php [L]` which routes every non-file
6104 * request to PHP. The [L] flag stops the current rewrite pass,
6105 * but Apache restarts the cycle; on the second pass REQUEST_URI
6106 * is /index.php and no static-file check can match. The only
6107 * reliable position for a "serve static if it exists" rule is
6108 * before WordPress's block.
6109 *
6110 * WP's insert_with_markers() always appends, so we manage the
6111 * block manually: strip any prior xSpeed Static Cache markers,
6112 * then write our block followed by the rest of the file.
6113 */
6114 public static function install_rewrite(): bool {
6115 // The static rewrite is device-blind; never install it when
6116 // mobile_separate is on (see static_rewrite_allowed()).
6117 if ( ! self::static_rewrite_allowed() ) {
6118 return false;
6119 }
6120 $htaccess = ABSPATH . '.htaccess';
6121 $existing = file_exists( $htaccess ) ? @file_get_contents( $htaccess ) : ''; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
6122 if ( false === $existing ) {
6123 $existing = '';
6124 }
6125 // Apache/LiteSpeed only. nginx hosts: rule won't fire, drop-in
6126 // covers; we skip the write so we don't litter their root.
6127 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Pre-flight check before file_put_contents; WP_Filesystem requires admin credentials we don't have inside a manage_options REST request.
6128 if ( file_exists( $htaccess ) && ! is_writable( $htaccess ) ) {
6129 return false;
6130 }
6131 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- See above.
6132 if ( ! file_exists( $htaccess ) && ! is_writable( ABSPATH ) ) {
6133 return false;
6134 }
6135
6136 $cleaned = self::strip_marker_block( $existing, 'xSpeed Static Cache' );
6137 $block = self::marker_block( 'xSpeed Static Cache', self::rewrite_block_lines() );
6138 $next = $block . ( '' === $cleaned ? '' : "\n" . $cleaned );
6139
6140 /*
6141 * Nothing to change. auto_heal() runs the whole enable transaction on
6142 * every admin_init and this is called unconditionally from it, so
6143 * without this every wp-admin request truncated and rewrote .htaccess
6144 * with byte-identical content. Apache reads that file without a lock,
6145 * so the truncate window is a real 500 on a busy admin, and the churn
6146 * trips host file-integrity monitors.
6147 */
6148 if ( $next === $existing ) {
6149 return true;
6150 }
6151
6152 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents, PluginCheck.CodeAnalysis.WriteFile.ABSPATHDetected -- WP_Filesystem requires admin credentials we don't have here; toggle() runs in a REST request authorized by manage_options nonce. The target is the site's .htaccess (configuration file managed by WP core itself), not user data — wp_upload_dir() doesn't apply.
6153 return false !== file_put_contents( $htaccess, $next, LOCK_EX );
6154 }
6155
6156 /**
6157 * Rewrite the .htaccess block in place when — and only when — one is
6158 * already installed.
6159 *
6160 * The block embeds the generated cookie / user-agent exclusion rules,
6161 * so it goes stale the moment those settings change. install_rewrite()
6162 * regenerates it from the live settings, but calling that unconditionally
6163 * on every save would CREATE a block on sites that never enabled the
6164 * static path — silently turning on server-level serving nobody asked
6165 * for. So we refresh only what's already there.
6166 *
6167 * @return bool True when a block was present and rewritten.
6168 */
6169 public static function refresh_rewrite_if_installed(): bool {
6170 $htaccess = ABSPATH . '.htaccess';
6171 if ( ! file_exists( $htaccess ) ) {
6172 return false;
6173 }
6174 $existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Best-effort read; an unreadable file simply means nothing to refresh.
6175 if ( ! is_string( $existing ) || false === strpos( $existing, '# BEGIN xSpeed Static Cache' ) ) {
6176 return false;
6177 }
6178 return self::install_rewrite();
6179 }
6180
6181 public static function remove_rewrite(): bool {
6182 $htaccess = ABSPATH . '.htaccess';
6183 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- See install_rewrite() rationale.
6184 if ( ! file_exists( $htaccess ) || ! is_writable( $htaccess ) ) {
6185 return false;
6186 }
6187 $existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
6188 if ( false === $existing ) {
6189 return false;
6190 }
6191 $cleaned = self::strip_marker_block( $existing, 'xSpeed Static Cache' );
6192 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents, PluginCheck.CodeAnalysis.WriteFile.ABSPATHDetected -- See install_rewrite() rationale.
6193 return false !== file_put_contents( $htaccess, $cleaned, LOCK_EX );
6194 }
6195
6196 /**
6197 * Strip a `# BEGIN <marker>` ... `# END <marker>` block from a
6198 * .htaccess-style file, including any blank line that immediately
6199 * follows it. Idempotent — returns the input unchanged if the
6200 * marker isn't present.
6201 */
6202 private static function strip_marker_block( string $contents, string $marker ): string {
6203 /*
6204 * The body may not contain another BEGIN for this marker.
6205 *
6206 * `.*?` is non-greedy but still spans anything, so an ORPHANED
6207 * `# BEGIN xSpeed Static Cache` — an END line lost to a hand edit or
6208 * a partial write — paired with the END of the NEXT block and deleted
6209 * everything between them. On a site where the orphan sits above
6210 * `# BEGIN WordPress`, that takes WordPress's own rewrite rules with
6211 * it and every permalink 404s. Refusing to cross a second BEGIN makes
6212 * the orphan a no-op instead of a site-wide outage.
6213 */
6214 $begin = '# BEGIN ' . preg_quote( $marker, '/' ) . '\b';
6215 $pattern = '/' . $begin . '(?:(?!' . $begin . ').)*?# END ' . preg_quote( $marker, '/' ) . "\b[^\n]*\n?\n?/s";
6216 $out = preg_replace( $pattern, '', $contents );
6217 return is_string( $out ) ? $out : $contents;
6218 }
6219
6220 private static function marker_block( string $marker, array $lines ): string {
6221 $header = "# BEGIN $marker\n";
6222 $header .= "# The directives (lines) between \"BEGIN $marker\" and \"END $marker\" are\n";
6223 $header .= "# dynamically generated, and should only be modified via WordPress filters.\n";
6224 $header .= "# Any changes to the directives between these markers will be overwritten.\n";
6225 $footer = "# END $marker\n";
6226 return $header . implode( "\n", $lines ) . "\n" . $footer;
6227 }
6228
6229 /**
6230 * Parse the `XSPEED_DROPIN_VERSION: N` stamp out of a drop-in's source.
6231 * Returns 0 when absent (an un-stamped older copy reinstalls). Used to
6232 * detect a stale installed drop-in vs the bundled source.
6233 */
6234 private static function dropin_version( string $contents ): int {
6235 if ( preg_match( '/XSPEED_DROPIN_VERSION:\s*(\d+)/', $contents, $m ) ) {
6236 return (int) $m[1];
6237 }
6238 return 0;
6239 }
6240
6241 /** The advanced-cache.php drop-in is ours. */
6242 public const DROPIN_XSPEED = 'xspeed';
6243 /** Someone else's drop-in is installed. */
6244 public const DROPIN_FOREIGN = 'foreign';
6245 /** No drop-in installed. */
6246 public const DROPIN_NONE = 'none';
6247 /** A drop-in is installed and we could not read it. */
6248 public const DROPIN_UNREADABLE = 'unreadable';
6249
6250 /**
6251 * Who owns wp-content/advanced-cache.php right now.
6252 *
6253 * WordPress gives every caching plugin the same single file to live in,
6254 * so "is there a drop-in" and "is it ours" are completely different
6255 * questions, and only the second one licenses a write. An unreadable
6256 * drop-in is deliberately its own answer rather than folding into
6257 * "foreign": we cannot even name what we would be destroying.
6258 *
6259 * @return string One of the DROPIN_* constants.
6260 */
6261 public static function dropin_owner(): string {
6262 require_once XSPEED_DIR . 'includes/wp-cache-constant.php';
6263 $target = WP_CONTENT_DIR . '/advanced-cache.php';
6264 if ( ! file_exists( $target ) ) {
6265 return self::DROPIN_NONE;
6266 }
6267
6268 $contents = self::read_file( $target );
6269 if ( null === $contents ) {
6270 return self::DROPIN_UNREADABLE;
6271 }
6272
6273 return xspeed_has_canonical_dropin_signature( $contents )
6274 ? self::DROPIN_XSPEED
6275 : self::DROPIN_FOREIGN;
6276 }
6277
6278 /**
6279 * Why xSpeed must not install its page-cache artifacts right now, or null
6280 * when it may.
6281 *
6282 * This is the single gate in front of every write that touches shared
6283 * state — the drop-in and the WP_CACHE define. Both are single-occupancy:
6284 * whatever is there belongs to exactly one plugin, and taking it silently
6285 * breaks that plugin's caching with no way back.
6286 *
6287 * Returns a user-facing string, so a REST caller can hand it straight to
6288 * the dashboard instead of reporting a bare failure.
6289 */
6290 public static function acquisition_blocker(): ?string {
6291 Page_Cache_Detector::invalidate();
6292 $verdict = Page_Cache_Detector::classify();
6293 $owner = self::dropin_owner();
6294 // The reason we refuse, whether that reason already names a plugin,
6295 // and every other page cache the detector counted anywhere in the
6296 // verdict. See the tail of this method for why all three are needed.
6297 $primary = null;
6298 $primary_names = false;
6299 $named = array();
6300 foreach ( $verdict['blockers'] as $blocker ) {
6301 $code = (string) ( $blocker['code'] ?? '' );
6302 // The shared detector quite correctly reports xSpeed itself as a
6303 // page-cache owner. That is not a competitor to this transaction.
6304 //
6305 // Except when the two disagree about the DROP-IN. The detector
6306 // accepts our marker anywhere in a file's header; this plugin's
6307 // own check requires it to open the header, because only this
6308 // side authorizes overwriting and deleting. A foreign drop-in
6309 // that merely carries our marker further down its header is
6310 // attributed to us by the detector, and skipping it here dropped
6311 // the refusal entirely — the write then failed on the stricter
6312 // check and the user was told to go and fix file permissions.
6313 // Where they disagree, believe the stricter one.
6314 if ( self::PLUGIN_FILE === ( $blocker['plugin'] ?? null ) ) {
6315 $about_dropin = in_array(
6316 $code,
6317 array(
6318 Page_Cache_Detector::BLOCKER_FOREIGN_DROPIN,
6319 Page_Cache_Detector::BLOCKER_UNKNOWN_DROPIN,
6320 ),
6321 true
6322 );
6323 if ( ! $about_dropin || self::DROPIN_XSPEED === $owner ) {
6324 continue;
6325 }
6326 }
6327 if ( Page_Cache_Detector::BLOCKER_WP_CACHE_ORPHANED === $code && self::DROPIN_XSPEED === $owner ) {
6328 continue;
6329 }
6330 /*
6331 * Capability is not possession. `active_page_cache` and
6332 * `multiple_page_caches` both fire on a plugin that merely CAN
6333 * cache pages — the detector cannot prove a competitor's page
6334 * cache is off, so it counts it. As a warning that is right. As
6335 * a gate it refuses a write that takes nothing from anyone.
6336 *
6337 * This gate guards exactly two files: advanced-cache.php and the
6338 * WP_CACHE define that loads it. A plugin that does not hold the
6339 * drop-in has nothing here for us to overwrite, and one that does
6340 * is already refused by `foreign_dropin` / `unknown_dropin` a few
6341 * lines up. So when the field is ours or empty, an active
6342 * competitor is a note, not a refusal.
6343 *
6344 * QA found this on a live OpenLiteSpeed site keeping LiteSpeed
6345 * Cache for images and CDN with its page cache off, while xSpeed
6346 * served the pages. One click of the off switch and it could not
6347 * be turned back on: the only way out was deactivating LiteSpeed
6348 * entirely, and the message told them to "deactivate its page
6349 * cache" — which they already had.
6350 */
6351 $about_capability = in_array(
6352 $code,
6353 array(
6354 Page_Cache_Detector::BLOCKER_ACTIVE_PAGE_CACHE,
6355 Page_Cache_Detector::BLOCKER_MULTIPLE_PAGE_CACHES,
6356 ),
6357 true
6358 );
6359 if ( $about_capability && in_array( $owner, array( self::DROPIN_XSPEED, self::DROPIN_NONE ), true ) ) {
6360 continue;
6361 }
6362 if ( Page_Cache_Detector::BLOCKER_MULTIPLE_PAGE_CACHES === $code ) {
6363 $others = self::other_page_cache_names( $blocker );
6364 if ( array() === $others ) {
6365 // We were the only owner counted — nothing to refuse —
6366 // unless the list is missing entirely, which is an older
6367 // detector copy we still must not talk past.
6368 if ( null === $primary && array() === (array) ( $blocker['plugins'] ?? array() ) ) {
6369 $primary = self::ownership_blocker_message( '', '' );
6370 }
6371 continue;
6372 }
6373 $named = array_values( array_unique( array_merge( $named, $others ) ) );
6374 if ( null === $primary ) {
6375 $primary = self::multiple_page_caches_message( $others );
6376 $primary_names = true;
6377 }
6378 continue;
6379 }
6380 if ( null === $primary ) {
6381 $label = (string) ( $blocker['label'] ?? '' );
6382 $primary = self::ownership_blocker_message( $code, $label );
6383 $primary_names = '' !== $label;
6384 }
6385 }
6386
6387 if ( null === $primary ) {
6388 return null;
6389 }
6390 /*
6391 * The first blocker decides WHY we refuse; it does not always know
6392 * WHO. The detector can only attribute a drop-in it recognises, and
6393 * an unrecognised one produces "its owner cannot be proved" — the
6394 * sentence a W3 Total Cache site used to get while a later blocker in
6395 * the same verdict was holding the name "W3 Total Cache".
6396 *
6397 * So keep the reason and add the names, rather than swapping one for
6398 * the other: the plugin the user must deal with is not necessarily
6399 * the owner of the file we could not identify, and promoting the
6400 * named blocker would have told them to deactivate a plugin that is
6401 * not what is in their way.
6402 */
6403 if ( $primary_names || array() === $named ) {
6404 return $primary;
6405 }
6406 if ( 1 === count( $named ) ) {
6407 return sprintf(
6408 /* translators: 1: the refusal reason, 2: a page-caching plugin's name. */
6409 __( '%1$s %2$s is also active on this site — deactivate its page cache before enabling xSpeed.', 'xspeed' ),
6410 $primary,
6411 $named[0]
6412 );
6413 }
6414 return sprintf(
6415 /* translators: 1: the refusal reason, 2: comma-separated page-caching plugin names. */
6416 __( '%1$s These page caches are also active on this site: %2$s. Deactivate them before enabling xSpeed.', 'xspeed' ),
6417 $primary,
6418 implode( ', ', $named )
6419 );
6420 }
6421
6422 /** How xSpeed's own plugin file appears in the detector's catalog. */
6423 private const PLUGIN_FILE = 'xspeed/xspeed.php';
6424
6425 /**
6426 * Name the OTHER page caches behind a `multiple_page_caches` refusal.
6427 *
6428 * This blocker has no single owner, so the detector leaves `plugin` and
6429 * `label` null and hands over the full list instead. Left unhandled it
6430 * fell through to the anonymous fallback sentence — and it is the blocker
6431 * an ordinary site hits most: xSpeed counts toward "multiple", so the
6432 * count reaches two the moment one other page-cache plugin is activated,
6433 * even one that has not written a drop-in. A site running our cache that
6434 * activated LiteSpeed could not re-enable it and was told only that "the
6435 * page-cache field is occupied".
6436 *
6437 * Returns an empty list when xSpeed was the only owner counted, or when
6438 * an older detector copy sent no list at all — the caller distinguishes
6439 * the two by looking at `plugins`.
6440 *
6441 * @param array<string,mixed> $blocker One entry from Detector::classify().
6442 * @return string[]
6443 */
6444 private static function other_page_cache_names( array $blocker ): array {
6445 $plugins = array_values( (array) ( $blocker['plugins'] ?? array() ) );
6446 $labels = array_values( (array) ( $blocker['labels'] ?? array() ) );
6447
6448 $others = array();
6449 foreach ( $plugins as $i => $plugin ) {
6450 if ( self::PLUGIN_FILE === $plugin ) {
6451 continue;
6452 }
6453 $others[] = isset( $labels[ $i ] ) && '' !== (string) $labels[ $i ]
6454 ? (string) $labels[ $i ]
6455 : (string) $plugin;
6456 }
6457 return array_values( array_unique( $others ) );
6458 }
6459
6460 /**
6461 * The refusal sentence for a `multiple_page_caches` blocker.
6462 *
6463 * @param string[] $others Page caches other than xSpeed. Never empty.
6464 */
6465 private static function multiple_page_caches_message( array $others ): string {
6466 if ( 1 === count( $others ) ) {
6467 return self::ownership_blocker_message( Page_Cache_Detector::BLOCKER_ACTIVE_PAGE_CACHE, $others[0] );
6468 }
6469 return sprintf(
6470 /* translators: %s: comma-separated list of page-caching plugin names. */
6471 __( 'More than one page cache is active on this site (%s). Turn off the other page caches before enabling xSpeed.', 'xspeed' ),
6472 implode( ', ', $others )
6473 );
6474 }
6475
6476 private static function ownership_blocker_message( string $code, string $label ): string {
6477 if ( '' !== $label ) {
6478 return sprintf( __( '%s is active or owns advanced-cache.php. Deactivate its page cache before enabling xSpeed.', 'xspeed' ), $label );
6479 }
6480 $messages = array(
6481 'wp_cache_orphaned' => __( 'WP_CACHE is true but no page-cache drop-in owner can be proved. xSpeed will not claim it.', 'xspeed' ),
6482 'wp_cache_duplicate' => __( 'wp-config.php defines WP_CACHE more than once. Remove the duplicate before enabling the cache.', 'xspeed' ),
6483 'wp_cache_dynamic' => __( 'WP_CACHE is set from an expression in wp-config.php. xSpeed will not rewrite it.', 'xspeed' ),
6484 'wp_cache_conditional' => __( 'WP_CACHE is defined inside a conditional in wp-config.php, so xSpeed cannot tell what it will be. Move it to a plain define before enabling the cache.', 'xspeed' ),
6485 'wp_config_unreadable' => __( 'wp-config.php cannot be read, so xSpeed cannot safely change page-cache ownership.', 'xspeed' ),
6486 'unknown_dropin' => __( 'advanced-cache.php is occupied but its owner cannot be proved. xSpeed will not replace it.', 'xspeed' ),
6487 'unreadable_dropin' => __( 'advanced-cache.php cannot be read, so xSpeed cannot prove its owner.', 'xspeed' ),
6488 );
6489 return $messages[ $code ] ?? __( 'The page-cache field is occupied or cannot be verified. xSpeed will not change it.', 'xspeed' );
6490 }
6491
6492 /**
6493 * How WP_CACHE is written in wp-config.php, as opposed to what it
6494 * evaluates to at runtime.
6495 *
6496 * The literal is what matters to a writer: a value behind an expression,
6497 * or two competing defines, cannot be rewritten by a regex without
6498 * guessing — and a wrong guess silently disables page caching (ours or
6499 * someone else's) with no error anywhere.
6500 *
6501 * @return string undefined | true | false | duplicate | dynamic | conditional | unreadable
6502 */
6503 public static function wp_cache_define_state(): string {
6504 $path = self::wp_config_path();
6505 if ( '' === $path ) {
6506 return 'unreadable';
6507 }
6508
6509 $config = self::read_file( $path );
6510 if ( null === $config ) {
6511 return 'unreadable';
6512 }
6513
6514 require_once XSPEED_DIR . 'includes/wp-cache-constant.php';
6515 $parsed = \xspeed_parse_wp_cache_defines( $config );
6516 return $parsed['state'];
6517 }
6518
6519 /**
6520 * Classify the captured right-hand side of a WP_CACHE define.
6521 *
6522 * Hosts and older tutorials write the value several ways —
6523 * `1`, `'1'`, `TRUE` — and all of them are literals a rewrite can safely
6524 * replace. Only a value we cannot evaluate by looking at it (a variable, a
6525 * function call, a ternary) counts as dynamic, because that is the case
6526 * where rewriting means guessing.
6527 *
6528 * @return string true | false | dynamic
6529 */
6530 private static function classify_wp_cache_literal( string $raw ): string {
6531 $literal = strtolower( trim( $raw ) );
6532 $literal = trim( $literal, "'\"" );
6533
6534 if ( in_array( $literal, array( 'true', '1' ), true ) ) {
6535 return 'true';
6536 }
6537 if ( in_array( $literal, array( 'false', '0', '', 'null' ), true ) ) {
6538 return 'false';
6539 }
6540 return 'dynamic';
6541 }
6542
6543 /**
6544 * Read a file for an ownership decision. Null on any failure — callers
6545 * treat null as "unknown", never as "empty", because an empty string
6546 * would read as "no marker found" and license an overwrite.
6547 */
6548 private static function read_file( string $path ): ?string {
6549 if ( ! is_readable( $path ) ) {
6550 return null;
6551 }
6552 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Ownership check on a local file; WP_Filesystem would need credentials we must not prompt for here.
6553 $contents = @file_get_contents( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- A failed read is a valid answer ("unknown"), not an error to surface.
6554 return is_string( $contents ) ? $contents : null;
6555 }
6556
6557 public static function install_dropin() {
6558 $source = XSPEED_DIR . 'includes/advanced-cache.php';
6559 $target = WP_CONTENT_DIR . '/advanced-cache.php';
6560 if ( ! file_exists( $source ) ) {
6561 return false;
6562 }
6563
6564 /*
6565 * Ownership first, before any of the work below. WordPress gives every
6566 * caching plugin the same single file, so a drop-in that is not ours is
6567 * another plugin's live cache — refuse rather than replace it.
6568 */
6569 $owner = self::dropin_owner();
6570 if ( self::DROPIN_FOREIGN === $owner || self::DROPIN_UNREADABLE === $owner ) {
6571 return false;
6572 }
6573
6574 global $wp_filesystem;
6575 if ( ! function_exists( 'WP_Filesystem' ) ) {
6576 require_once ABSPATH . 'wp-admin/includes/file.php';
6577 }
6578 WP_Filesystem();
6579 if ( ! $wp_filesystem ) {
6580 return false;
6581 }
6582
6583 $source_contents = $wp_filesystem->get_contents( $source );
6584 if ( ! is_string( $source_contents ) ) {
6585 return false;
6586 }
6587
6588 // Bake the absolute hit-log path into the drop-in. It runs before
6589 // WordPress loads, so it can't resolve wp_upload_dir() itself — we
6590 // substitute the @@XSPEED_HITS_LOG@@ token with the real uploads path
6591 // (never the cache dir; see hits_log_dir() / FBS-82478). Use a single
6592 // quoted PHP string literal so the installed file stays valid PHP.
6593 $source_contents = str_replace(
6594 '@@XSPEED_HITS_LOG@@',
6595 str_replace( "'", "\\'", self::hits_log_path() ),
6596 $source_contents
6597 );
6598
6599 // Bake the cookie + user-agent exclusion rules in too. The drop-in
6600 // runs before WordPress loads, so it cannot read the settings — and
6601 // without them it served the shared anonymous page to any visitor
6602 // PHP had not yet seen (a first-time cart visitor, a bypassed bot).
6603 // The generic bypass cookie only covers repeat visitors; these two
6604 // regexes are what make the FIRST request correct.
6605 //
6606 // Both are already fully escaped by Server_Rules, and each is
6607 // embedded as a single-quoted PHP literal, so a settings value can
6608 // neither break the drop-in's syntax nor execute.
6609 $cache_opts = Settings_Manager::get( 'cache' );
6610 $cookie_rule = Server_Rules::cookie_rule(
6611 is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array()
6612 );
6613 $ua_rule = Server_Rules::user_agent_rule(
6614 is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array()
6615 );
6616
6617 $source_contents = str_replace(
6618 '@@XSPEED_COOKIE_RE@@',
6619 str_replace( "'", "\\'", $cookie_rule['regex'] ),
6620 $source_contents
6621 );
6622 $source_contents = str_replace(
6623 '@@XSPEED_UA_RE@@',
6624 str_replace( "'", "\\'", $ua_rule['regex'] ),
6625 $source_contents
6626 );
6627
6628 /*
6629 * Ours or absent — the ownership gate at the top of this method ruled
6630 * out everything else. The old code path that moved a foreign drop-in
6631 * into uploads/xspeed-backups and wrote ours on top is gone: it
6632 * disabled the other plugin's page cache the moment an xSpeed install
6633 * ran, with nothing in its own UI to explain why.
6634 */
6635
6636 // Bake the configured cache lifetime in. The drop-in runs before
6637 // WordPress loads, so it cannot read the option — it previously fell
6638 // back to a hardcoded 86400 for every ordinary page, because
6639 // write_meta() only emits a `ttl` sidecar when the value DIFFERS from
6640 // the page default. That made the admin's "1 to 720 hours" control a
6641 // no-op at the layer that actually answers the request: 12h served
6642 // stale for up to 2x the configured lifetime, and 168h lost the fast
6643 // path for 6 of every 7 days (issue #240).
6644 //
6645 // This is re-baked on every cache settings save (see CacheModule::boot),
6646 // exactly like the cookie / user-agent rules above.
6647 $expiry_hours = isset( $cache_opts['cache_expiry'] ) ? (int) $cache_opts['cache_expiry'] : 24;
6648 if ( $expiry_hours < 1 || $expiry_hours > 720 ) {
6649 $expiry_hours = 24;
6650 }
6651 $source_contents = str_replace(
6652 '@@XSPEED_DEFAULT_TTL@@',
6653 (string) ( $expiry_hours * HOUR_IN_SECONDS ),
6654 $source_contents
6655 );
6656
6657 if ( file_exists( $target ) ) {
6658 $existing = $wp_filesystem->get_contents( $target );
6659 if ( is_string( $existing ) && $existing === $source_contents ) {
6660 return true;
6661 }
6662 }
6663
6664 return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE );
6665 }
6666
6667 public static function remove_dropin() {
6668 $target = WP_CONTENT_DIR . '/advanced-cache.php';
6669 if ( ! file_exists( $target ) ) {
6670 return;
6671 }
6672
6673 global $wp_filesystem;
6674 if ( ! function_exists( 'WP_Filesystem' ) ) {
6675 require_once ABSPATH . 'wp-admin/includes/file.php';
6676 }
6677 WP_Filesystem();
6678 if ( ! $wp_filesystem ) {
6679 return;
6680 }
6681
6682 $contents = $wp_filesystem->get_contents( $target );
6683 if ( is_string( $contents ) && xspeed_has_canonical_dropin_signature( $contents ) ) {
6684 wp_delete_file( $target );
6685 }
6686 }
6687
6688 /**
6689 * Where wp-config.php actually is.
6690 *
6691 * WordPress core supports the file one directory ABOVE ABSPATH, and
6692 * plenty of installs use that layout. This used to look only in ABSPATH
6693 * and bail, so on those sites the constant could never be written — while
6694 * Health, which did fall back to the parent, reported the file writable
6695 * and told the user to toggle the cache off and on. The advice could
6696 * never work, and its fallback hint ("another plugin left WP_CACHE false
6697 * behind") was wrong too: there was no define at all. (#19, QA on #174)
6698 *
6699 * Returns '' when no wp-config.php can be found in either location.
6700 */
6701 public static function wp_config_path(): string {
6702 $candidates = array( ABSPATH . 'wp-config.php', dirname( ABSPATH ) . '/wp-config.php' );
6703 foreach ( $candidates as $path ) {
6704 if ( file_exists( $path ) ) {
6705 return $path;
6706 }
6707 }
6708 return '';
6709 }
6710
6711 /**
6712 * Can we actually write the constant right now?
6713 *
6714 * This is the single oracle for that question — Health asks THIS rather
6715 * than running its own `wp_is_writable()` test, so the message a user
6716 * reads can never disagree with what the plugin will do. The two differed
6717 * in both directions: on the path (above) and on the test itself, since
6718 * an FTP/SSH WP_Filesystem transport can refuse a file that
6719 * `wp_is_writable()` reports as writable. (#19, QA on #174)
6720 */
6721 public static function can_write_wp_config(): bool {
6722 $wp_config = self::wp_config_path();
6723 if ( '' === $wp_config ) {
6724 return false;
6725 }
6726
6727 global $wp_filesystem;
6728 if ( ! function_exists( 'WP_Filesystem' ) ) {
6729 require_once ABSPATH . 'wp-admin/includes/file.php';
6730 }
6731 WP_Filesystem();
6732 return (bool) ( $wp_filesystem && $wp_filesystem->is_writable( $wp_config ) );
6733 }
6734
6735 public static function set_wp_cache_constant( $enable ) {
6736 $wp_config = self::wp_config_path();
6737 if ( '' === $wp_config ) {
6738 return false;
6739 }
6740
6741 /*
6742 * WP_CACHE belongs to whoever owns the drop-in — it is the switch that
6743 * makes core load that one file. Editing it while someone else's
6744 * drop-in is installed either turns THEIR cache on or off; either way
6745 * it is a write to another plugin's state. So: no ownership, no edit.
6746 */
6747 $owner = self::dropin_owner();
6748 if ( self::DROPIN_FOREIGN === $owner || self::DROPIN_UNREADABLE === $owner ) {
6749 return false;
6750 }
6751
6752 $state = self::wp_cache_define_state();
6753 if ( 'duplicate' === $state || 'dynamic' === $state ) {
6754 // Two competing defines, or a value behind an expression. A regex
6755 // rewrite here is a guess, and a wrong guess silently kills page
6756 // caching with no error anywhere.
6757 return false;
6758 }
6759 global $wp_filesystem;
6760 if ( ! function_exists( 'WP_Filesystem' ) ) {
6761 require_once ABSPATH . 'wp-admin/includes/file.php';
6762 }
6763 WP_Filesystem();
6764 if ( ! $wp_filesystem ) {
6765 return false;
6766 }
6767
6768 $config = $wp_filesystem->get_contents( $wp_config );
6769 if ( ! is_string( $config ) ) {
6770 return false;
6771 }
6772 require_once XSPEED_DIR . 'includes/wp-cache-constant.php';
6773 $marker = $enable ? self::wp_cache_receipt() : '';
6774 $updated = xspeed_rewrite_wp_cache_define( $config, (bool) $enable, $marker );
6775 if ( ! is_string( $updated ) ) {
6776 return false;
6777 }
6778
6779 /*
6780 * Removing a WP_CACHE line we cannot prove we wrote is somebody else's
6781 * configuration, so a disable needs either our drop-in or our receipt.
6782 *
6783 * The test is on the REWRITE, not on the request: it used to run
6784 * before the rewrite and refuse a disable that had nothing to remove.
6785 * An ordinary site with no drop-in and no define — every fresh
6786 * install — therefore failed to turn page caching off, so the
6787 * onboarding wizard reported "setup needs attention" to every user who
6788 * declined it and Migration reported the cache import as failed.
6789 */
6790 if ( ! $enable && $updated !== $config
6791 && self::DROPIN_XSPEED !== $owner
6792 && ! self::wp_cache_receipt_matches_source( $config ) ) {
6793 return false;
6794 }
6795
6796 /*
6797 * Nothing to write. auto_heal() runs the whole enable transaction on
6798 * every admin_init, so without this every wp-admin request rewrote
6799 * wp-config.php with byte-identical content: pointless disk churn
6800 * that trips host file-integrity monitors and widens the window for
6801 * a concurrent write on a busy admin.
6802 *
6803 * It is also what makes a correct WP_CACHE on a read-only
6804 * wp-config.php succeed. A managed host that ships the file
6805 * unwritable, on a site where the user already pasted the define,
6806 * is in the state we wanted — the writability test below is about
6807 * whether we can CHANGE the file, and there is nothing to change.
6808 */
6809 if ( $updated === $config ) {
6810 if ( ! $enable ) {
6811 // Our line is not in the file, so the receipt that proved we
6812 // wrote it is stale — drop it on the same terms as a real
6813 // removal, or uninstall keeps a claim on nothing.
6814 delete_option( 'xspeed_page_cache_ownership_receipt' );
6815 }
6816 return true;
6817 }
6818
6819 if ( ! $wp_filesystem->is_writable( $wp_config ) ) {
6820 return false;
6821 }
6822 $written = (bool) $wp_filesystem->put_contents( $wp_config, $updated, FS_CHMOD_FILE );
6823 if ( $written && ! $enable ) {
6824 delete_option( 'xspeed_page_cache_ownership_receipt' );
6825 }
6826 return $written;
6827 }
6828
6829 private static function wp_cache_receipt(): string {
6830 $receipt = get_option( 'xspeed_page_cache_ownership_receipt', '' );
6831 if ( is_string( $receipt ) && preg_match( '/^[a-f0-9]{32}$/', $receipt ) ) {
6832 return $receipt;
6833 }
6834 $receipt = substr( hash( 'sha256', XSPEED_DIR . microtime( true ) . mt_rand() ), 0, 32 );
6835 update_option( 'xspeed_page_cache_ownership_receipt', $receipt, false );
6836 return $receipt;
6837 }
6838
6839 /**
6840 * Is the WP_CACHE line in wp-config.php ours to REMOVE?
6841 *
6842 * Two different questions live here and only one of them matters. "Did we
6843 * write it" is answered by our drop-in on disk or by our receipt comment
6844 * beside the define. "Is it ours to remove" also asks what the line does
6845 * NOW — and once a competitor owns advanced-cache.php, a line we wrote
6846 * ourselves is the switch that loads THEIR drop-in. They had no reason to
6847 * touch an already-true define, so our receipt is still sitting on it.
6848 * Removing it there would stop their live page cache.
6849 *
6850 * So a foreign or unreadable owner is never ours to remove, whatever the
6851 * receipt says, and the caller treats that as a reason to leave the line
6852 * and get on with disabling our own cache — not as a reason to refuse.
6853 */
6854 private static function wp_cache_define_is_ours_to_remove( string $owner ): bool {
6855 if ( self::DROPIN_FOREIGN === $owner || self::DROPIN_UNREADABLE === $owner ) {
6856 return false;
6857 }
6858 if ( self::DROPIN_XSPEED === $owner ) {
6859 return true;
6860 }
6861 $path = self::wp_config_path();
6862 if ( '' === $path ) {
6863 return false;
6864 }
6865 $config = self::read_file( $path );
6866 return is_string( $config ) && self::wp_cache_receipt_matches_source( $config );
6867 }
6868
6869 private static function wp_cache_receipt_matches_source( string $source ): bool {
6870 $receipt = get_option( 'xspeed_page_cache_ownership_receipt', '' );
6871 require_once XSPEED_DIR . 'includes/wp-cache-constant.php';
6872 return xspeed_wp_cache_receipt_matches( $source, $receipt );
6873 }
6874
6875 /**
6876 * Admin-bar purge menu — a parent node plus one child per visible cache
6877 * type (LiteSpeed-style), instead of a single "Purge All" link. Each
6878 * child posts to the same admin-post handler with its type slug. The
6879 * per-type items only appear for active/licensed modules; "Purge All"
6880 * always shows and always sweeps everything. (FBS-83114)
6881 *
6882 * The parent node links to the settings page rather than a purge URL —
6883 * clicking the top-level item used to wipe the whole cache instantly with
6884 * no confirmation, which is far too destructive for a stray click. Purging
6885 * stays available (and explicit) through the child items. (FBS-84068)
6886 */
6887 public function admin_bar_purge( $wp_admin_bar ) {
6888 if ( ! current_user_can( 'manage_options' ) ) {
6889 return;
6890 }
6891
6892 $wp_admin_bar->add_node(
6893 array(
6894 'id' => 'xspeed-purge',
6895 'title' => __( 'xSpeed Cache', 'xspeed' ),
6896 'href' => admin_url( 'admin.php?page=' . Admin::PAGE_SLUG ),
6897 )
6898 );
6899
6900 foreach ( self::purge_types() as $slug => $type ) {
6901 if ( empty( $type['visible'] ) ) {
6902 continue;
6903 }
6904 $wp_admin_bar->add_node(
6905 array(
6906 'id' => 'xspeed-purge-' . $slug,
6907 'parent' => 'xspeed-purge',
6908 'title' => esc_html( $type['label'] ),
6909 'href' => self::purge_type_url( $slug ),
6910 )
6911 );
6912 }
6913 }
6914
6915 /**
6916 * Nonce-protected admin-post URL for purging a single type. The nonce
6917 * action is per-type so a leaked URL can't be replayed for a different
6918 * scope.
6919 */
6920 private static function purge_type_url( string $type ): string {
6921 return wp_nonce_url(
6922 admin_url( 'admin-post.php?action=xspeed_purge&type=' . rawurlencode( $type ) ),
6923 'xspeed_purge_' . $type
6924 );
6925 }
6926
6927 public function handle_admin_bar_purge() {
6928 if ( ! current_user_can( 'manage_options' ) ) {
6929 wp_die( esc_html__( 'Unauthorized.', 'xspeed' ), 403 );
6930 }
6931 $type = isset( $_GET['type'] ) ? sanitize_key( wp_unslash( $_GET['type'] ) ) : 'all';
6932 check_admin_referer( 'xspeed_purge_' . $type );
6933
6934 // Only honour known types; anything else falls back to a full purge.
6935 if ( ! array_key_exists( $type, self::purge_types() ) ) {
6936 $type = 'all';
6937 }
6938 self::purge_type( $type );
6939
6940 wp_safe_redirect( self::safe_purge_redirect( wp_get_referer() ) );
6941 exit;
6942 }
6943
6944 /**
6945 * Resolve a safe redirect target for an admin-bar purge.
6946 *
6947 * The purge sends the admin back where they came from — but the referer
6948 * can be a ONE-SHOT action URL (e.g. update.php?action=upload-plugin from
6949 * installing a plugin zip, or any *.php?action=… that consumed a POST /
6950 * temp upload). Redirecting there re-runs the action with nothing to act
6951 * on, so WordPress dies — the classic "Please select a file" from
6952 * File_Upload_Upgrader. Strip the transient action args so we return to a
6953 * safe, re-GET-able view of the same page; fall back to the dashboard when
6954 * there is no usable referer.
6955 *
6956 * @param string|false $referer Raw wp_get_referer() value.
6957 * @return string Safe URL to redirect to.
6958 */
6959 public static function safe_purge_redirect( $referer ): string {
6960 $referer = is_string( $referer ) ? $referer : '';
6961 if ( '' === $referer ) {
6962 return admin_url();
6963 }
6964
6965 // A referer that lands on an action-processing endpoint (update.php,
6966 // update-core.php, plugin/theme install/upload flows) can't be safely
6967 // re-requested — send them to the dashboard instead of replaying it.
6968 $path = (string) wp_parse_url( $referer, PHP_URL_PATH );
6969 if ( preg_match( '#/wp-admin/(update|update-core)\.php$#', $path ) ) {
6970 return admin_url();
6971 }
6972
6973 // Otherwise keep them on the same page but drop the query args that
6974 // would re-trigger a form action or upload on load.
6975 return remove_query_arg(
6976 array( 'action', 'action2', 'package', 'overwrite', 'plugin', 'theme', 'file', '_wpnonce', '_ajax_nonce' ),
6977 $referer
6978 );
6979 }
6980 }
6981