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

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

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