PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.2
1.3.2 1.3.1 1.3.0 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 All 28 releases
xspeed / includes / class-cache.php

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

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