PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.1
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.1, at includes/class-cache.php

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