PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.2
1.4.2 1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 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 All 36 releases
xspeed / includes / class-cache.php

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

11,513 lines 487.4 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 * Response header the generated server rules stamp themselves with, so a
16 * cache hit says which version of the rules served it. See
17 * rules_marker_expected() for what the value means.
18 */
19 public const RULES_HEADER = 'X-XSpeed-Rules';
20
21 /**
22 * Response header carrying the unix time the served HTML was generated.
23 *
24 * Free owns it on every path where PHP runs, and emits it from the same
25 * `filemtime()` of the file it is about to send: the drop-in stamps it in
26 * advanced-cache.php, and the template_redirect serve path stamps it in
27 * serve_not_modified(). The nginx and Apache static paths never emit it —
28 * no PHP runs there, so a consumer reads their `Last-Modified` instead.
29 *
30 * An add-on cannot supply this value, and edge_headers_for() strips it
31 * from the `xspeed_edge_cache_headers` result in every context rather
32 * than asking add-ons not to try. Nothing on the filter's side of the
33 * seam knows which file is being served: mark() resolves the filter with
34 * no file and no mtime, so an add-on has only time(), which is when the
35 * page was SERVED. A downstream purge verifier compares this stamp with
36 * the moment it asked for the purge, and a stamp that is always "now"
37 * makes every purge look like it worked — worse than sending nothing.
38 */
39 public const BUILT_HEADER = 'X-XSpeed-Built';
40
41 /**
42 * The headers that can grant an edge a lifetime: `Cache-Control`,
43 * `CDN-Cache-Control`, `Cloudflare-CDN-Cache-Control`,
44 * `Surrogate-Control`, `Edge-Control`. Matched on the header NAME.
45 *
46 * advanced-cache.php carries a copy of this and of the pattern below,
47 * because the class is not loaded when the drop-in runs. Change all four
48 * together; EdgeLifetimeEntryCapTest compares them.
49 */
50 public const EDGE_LIFETIME_HEADER = '/(?:^|-)control$/i';
51
52 /**
53 * A lifetime directive inside one of those headers, and its seconds.
54 * See cap_edge_lifetime().
55 */
56 public const EDGE_LIFETIME_DIRECTIVE = '/(?<![\w-])(max-age|s-maxage)\s*=\s*"?(\d+)"?/i';
57
58 /** Map of rules hash => the settings fingerprint that produced it. */
59 public const RULES_INPUTS_OPTION = 'xspeed_rules_inputs';
60
61 /**
62 * User meta holding {hash, at}: the rules version this admin says they
63 * pasted into the server config. See rules_copied().
64 */
65 public const RULES_COPIED_META = 'xspeed_nginx_rules_copied';
66
67 /** How many rules versions the map above remembers. */
68 private const RULES_INPUTS_KEPT = 10;
69
70 /**
71 * Output-buffer nesting level at which we opened our cache buffer, so
72 * `close_buffer()` can flush ONLY our buffer and never disturb a buffer
73 * another plugin pushed on top of (or below) ours.
74 *
75 * @var int|null
76 */
77 private static $buffer_level = null;
78
79 /**
80 * Bytes freed by the current sweep, accumulated by sweep_delete().
81 *
82 * A counter rather than a return value because the two sweeps that free
83 * the bytes — the flat glob loop and the recursive static walk — already
84 * report a FILE count, and `wp xspeed purge` needs both numbers from a
85 * single pass. Re-walking the tree to size it would double the I/O on
86 * exactly the caches large enough for the number to matter.
87 *
88 * @var int
89 */
90 private static $sweep_bytes = 0;
91
92 /**
93 * The `X-XSpeed-Cache` value decided for this request, and — when the
94 * decision was BYPASS — the slug of the gate that made it.
95 *
96 * Recorded as well as sent so unit tests (CLI SAPI, where header() is a
97 * no-op and headers_sent() is meaningless) can assert on the decision.
98 *
99 * @var string
100 */
101 private static $status_header = '';
102 private static $bypass_reason = '';
103
104 /**
105 * Edge/CDN headers decided for this request, after sanitising.
106 *
107 * Same reason as $status_header: header() cannot be observed from the CLI
108 * SAPI, so the pairs we sent are recorded here too.
109 *
110 * @var array<string,string>
111 */
112 private static $edge_headers = array();
113
114 /**
115 * This entry's edge headers when they differ from the site-wide bake,
116 * resolved once per store. Null until asked.
117 *
118 * @var array<string,string>|null
119 */
120 private static $per_entry_edge = null;
121
122 /**
123 * Cache key whose write was deferred to shutdown because a render-time
124 * translation plugin's buffer wraps ours. Null on every ordinary request.
125 *
126 * @var string|null
127 */
128 private static $deferred_key = null;
129
130 /**
131 * Translated page HTML captured by the outer buffer, for the deferred
132 * write. Only populated when a translation plugin is active.
133 *
134 * @var string
135 */
136 private static $translated_output = '';
137
138 /**
139 * Did finalize_buffer() run to completion on this request?
140 *
141 * The deferred translated write runs as a PHP shutdown function, which
142 * fires after a `wp_die()` or a bare `exit()` exactly as it does after a
143 * clean render. Only finalize_buffer() sets this, and only at the point
144 * where it has the full buffer in hand — so an aborted render leaves it
145 * false and the writer declines rather than caching a truncated page
146 * under the real key.
147 *
148 * @var bool
149 */
150 private static $render_completed = false;
151
152 /**
153 * Minifier::purge_stamp() when this request's cache buffer opened.
154 *
155 * A render names its minified and combined files while it runs and is
156 * stored when it ends. A purge that deletes those files in between would
157 * otherwise leave a stored page linking files that are gone, for the
158 * whole TTL. The stamp changes whenever such a purge deletes anything, so
159 * a different value at store time means "do not store this one".
160 *
161 * Null when maybe_start_cache() opened no buffer. A direct call (tests,
162 * add-ons) has nothing to compare against and stores as before.
163 *
164 * @var string|null
165 */
166 private static $asset_stamp_at_open = null;
167
168 /**
169 * The snapshot above, carried to the deferred (translated) writer.
170 *
171 * @var string|null
172 */
173 private static $deferred_asset_stamp = null;
174
175 /**
176 * Hooks that get an argument-aware handler instead of a blanket purge.
177 *
178 * Each fires on an ordinary visitor action — an order, a review, a
179 * registration — where purge_all() cannot see WHAT changed and so wiped
180 * the whole cache on every one. They are re-bound further down to
181 * handlers that inspect the payload first.
182 *
183 * Listed here so the generic invalidation loop skips them. It binds a
184 * closure (to name the cause), and a closure cannot be unbound by the
185 * remove_action() pairs below — binding one would leave the coarse purge
186 * running alongside its replacement and silently undo #243.
187 */
188 private const TARGETED_INVALIDATION_HOOKS = array(
189 'save_post',
190 'before_delete_post',
191 'trashed_post',
192 'comment_post',
193 'wp_set_comment_status',
194 'user_register',
195 'profile_update',
196 );
197
198 public function __construct() {
199 /**
200 * When the page-cache output buffer opens.
201 *
202 * Filterable because buffer ORDER decides what gets cached. PHP's
203 * output buffers are LIFO: the last one opened is innermost, and its
204 * callback runs first. A render-time translation plugin that opens
205 * an outer buffer therefore translates AFTER we have already captured
206 * and cached the raw HTML — see translation_buffer_compat().
207 *
208 * @param string $hook Hook to open the buffer on.
209 * @param int $priority Priority for that hook.
210 */
211 $hook = (string) apply_filters( 'xspeed_cache_buffer_hook', 'template_redirect' );
212 $priority = (int) apply_filters( 'xspeed_cache_buffer_priority', 0 );
213 add_action( $hook, array( $this, 'maybe_start_cache' ), $priority );
214
215 // When a render-time translation plugin is present, open one extra
216 // buffer OUTSIDE its own so we can capture post-translation HTML.
217 // TranslatePress opens on `init` priority 0, so we take a negative
218 // priority to land outside it. This buffer only collects bytes for
219 // the deferred cache write — it never modifies the response.
220 add_action(
221 'init',
222 static function () {
223 if ( ! self::translation_plugin_active() ) {
224 return;
225 }
226 // `init` fires on EVERY request type, and
227 // translation_plugin_active() is a class_exists() check that
228 // is true site-wide — so without this guard the buffer opened
229 // on REST, admin-ajax, cron and WP-CLI too. None of those
230 // reach template_redirect, so $deferred_key stays null and
231 // the collected bytes are never released: a long-running
232 // WP-CLI command copied every byte of its output into a
233 // string that grew for the life of the process.
234 if ( is_admin()
235 || wp_doing_ajax()
236 || wp_doing_cron()
237 || ( defined( 'REST_REQUEST' ) && REST_REQUEST )
238 || ( defined( 'WP_CLI' ) && WP_CLI )
239 || ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) ) {
240 return;
241 }
242 ob_start(
243 static function ( $chunk ) {
244 self::$translated_output .= $chunk;
245 return $chunk;
246 }
247 );
248 },
249 (int) apply_filters( 'xspeed_translation_outer_buffer_priority', -100 )
250 );
251
252 // Events that should invalidate cached output. Beyond posts/comments,
253 // this covers user and term changes — the REST cache can serve
254 // /wp/v2/users, /wp/v2/categories, /wp/v2/tags, and these also affect
255 // rendered author bylines / term-archive pages. Without them, an edit
256 // left the matching endpoint (and archives) stale for the full TTL.
257 // (FBS-82408)
258 $invalidate_hooks = array(
259 'save_post', 'before_delete_post', 'trashed_post',
260 'comment_post', 'wp_set_comment_status',
261 'switch_theme', 'activated_plugin', 'deactivated_plugin',
262 // Users → /wp/v2/users + author archives.
263 'profile_update', 'user_register', 'deleted_user',
264 // Terms → /wp/v2/{taxonomy} + term archives.
265 'created_term', 'edited_term', 'delete_term',
266 // Menu structure changes (reorder, rename, assign to a location)
267 // fire only here — the per-item `nav_menu_item` save_post does
268 // not cover them. (#270 regression)
269 'wp_update_nav_menu',
270 );
271 foreach ( $invalidate_hooks as $hook ) {
272 // Name the hook in the cause rather than binding purge_all bare.
273 // Bound bare, WordPress passes the action's own first argument
274 // into $cause — a term id, a user id, a menu id — so the activity
275 // feed read "Cache purged (12)" and told the user nothing about
276 // what happened. (#270 QA round 2)
277 //
278 // The four hooks that get an argument-aware handler below
279 // (save_post, comment_post, user_register, profile_update) are
280 // deliberately NOT wired here: a closure cannot be unbound by
281 // remove_action(), so binding one would leave the coarse purge in
282 // place alongside its replacement and silently undo #243. Skipping
283 // them is equivalent — each is re-added with its own handler, and
284 // each of those names its own cause.
285 if ( in_array( $hook, self::TARGETED_INVALIDATION_HOOKS, true ) ) {
286 continue;
287 }
288 add_action(
289 $hook,
290 static function () use ( $hook ): void {
291 self::purge_all(
292 'hook:' . $hook,
293 null,
294 self::invalidation_for_hook( $hook )
295 );
296 }
297 );
298 // No purge_minified() here. Minified and combined files are named
299 // by content, so a render after this purge links the same names
300 // when nothing changed and new names when something did. Deleting
301 // them only opened a window where cached and in-flight pages link
302 // files that are gone, and on a network it took every subsite's
303 // files with it.
304 }
305
306 // Updating a plugin, theme or core changes the markup and the assets
307 // a page is built from, but fires NONE of the hooks above: WordPress
308 // does not deactivate and reactivate a plugin to update it, so
309 // `activated_plugin` never runs and the cached HTML survives the
310 // update untouched for the whole TTL — up to 7 days on the Aggressive
311 // preset, 30 at the maximum.
312 //
313 // The stale copy is not merely old, it is wrong in a way the user
314 // cannot see the cause of: they update a plugin to get a fix, the
315 // cache keeps serving the pre-fix HTML, and the update looks like it
316 // did nothing. Minified assets do regenerate on their own (they are
317 // named by content), which makes it worse rather than better — the
318 // cached pages still link the PREVIOUS names.
319 //
320 // Purge unconditionally on any completed update. Scoping it to
321 // "plugins that enqueue front-end assets" is not knowable here, and a
322 // cold cache after an update is the cheaper mistake. (#269)
323 add_action( 'upgrader_process_complete', array( __CLASS__, 'purge_after_upgrade' ), 10, 2 );
324 // The replacement signal has to outlive OUR listener: add-ons read it
325 // through upgrade_replaced_code() from their own priority-10 callbacks,
326 // and consuming it inside purge_after_upgrade() meant whoever
327 // registered second saw false. Cleared at the END of the dispatch
328 // instead, once every listener has had its turn.
329 //
330 // Depth-counted, because this action NESTS. Core hangs
331 // Language_Pack_Upgrader::async_upgrade() on it at priority 20
332 // (wp-admin/includes/admin-filters.php), and that runs a whole
333 // upgrader of its own, which fires this same action again. A flat
334 // reset therefore fired while the OUTER dispatch was still running —
335 // on any site with pending translations — and every listener after
336 // priority 20 read the cleared signal as false. Which is the bug this
337 // pair exists to fix, back again and harder to see. (#303)
338 add_action( 'upgrader_process_complete', array( __CLASS__, 'note_upgrade_dispatch' ), PHP_INT_MIN );
339 add_action( 'upgrader_process_complete', array( __CLASS__, 'forget_cleared_destination' ), PHP_INT_MAX );
340 // WordPress labels an upload-and-replace as an INSTALL, so the action
341 // alone cannot tell "added beside nothing" from "replaced live code".
342 // This filter fires only when the upgrader removed an existing copy,
343 // which is exactly the difference. Registered as a filter listener
344 // that returns its input untouched. (#303)
345 add_filter( 'upgrader_clear_destination', array( __CLASS__, 'note_cleared_destination' ), 10, 4 );
346 // …but `upgrader_clear_destination` fires whenever the upgrader was
347 // ASKED to clear, not only when it removed something:
348 // WP_Upgrader::clear_destination() returns true early when the
349 // destination does not exist. Looked at before the delete, while the
350 // old copy is still on disk. (#303)
351 //
352 // PHP_INT_MAX, because the folder name is only final once every other
353 // listener has had its turn. Update libraries that normalise
354 // `plugin-1.2.3/` to `plugin/` (Plugin Update Checker, EDD Software
355 // Licensing, GitHub-sourced zips) rename the extracted directory on
356 // this same filter at priority 10 or later, and core derives the real
357 // destination from the FILTERED source. Measured at 10, a genuine
358 // replacement read as "nothing was there" and the stale cache stayed.
359 // note_cleared_destination() cross-checks the folder core actually
360 // cleared against the one measured here, for a renamer that runs
361 // later still. (#407 QA)
362 add_filter( 'upgrader_source_selection', array( __CLASS__, 'note_destination_state' ), PHP_INT_MAX, 4 );
363 // Unattended auto-updates are the case that matters most here: they
364 // land overnight with nobody around to purge by hand, which is the
365 // exact scenario the stale cache goes undiagnosed in. WordPress fires
366 // this INSTEAD of a per-item upgrader_process_complete for some
367 // background runs. Its payload is a results array keyed by type
368 // rather than a hook_extra, so it needs its own handler — passing it
369 // to purge_after_upgrade() landed it in the unused $upgrader slot and
370 // left $type empty, which read as "invalidating" and purged the whole
371 // cache for a language-pack-only run. Matches what LiteSpeed binds.
372 // (#298)
373 add_action( 'automatic_updates_complete', array( __CLASS__, 'purge_after_auto_updates' ), 10, 1 );
374 // A Customizer publish changes theme mods, Additional CSS, the site
375 // identity and widget areas, so markup and inline CSS change on every
376 // page. It fires none of the hooks above. theme_mods_* is an ordinary
377 // option, and Additional CSS is a non-viewable `custom_css` post that
378 // the save_post gate rightly ignores. Without this, cached pages kept
379 // the old header, colours and custom CSS for the whole TTL.
380 add_action( 'customize_save_after', array( __CLASS__, 'on_customize_save' ), 10, 0 );
381 // Adding, removing or editing a widget on the Widgets screen (classic
382 // or block) only writes options: `sidebars_widgets` for which widget
383 // sits where, `widget_<type>` for its settings. No hook above fires,
384 // so the sidebar on every cached page stayed as it was until expiry.
385 add_action( 'update_option_sidebars_widgets', array( __CLASS__, 'on_widgets_change' ), 10, 0 );
386 add_action( 'add_option_sidebars_widgets', array( __CLASS__, 'on_widgets_change' ), 10, 0 );
387 add_action( 'updated_option', array( __CLASS__, 'on_widget_option_change' ), 10, 1 );
388 // …except the four hooks above that fire on ordinary visitor actions.
389 // Attached bare, purge_all() can't see WHAT changed, so on a store
390 // every order, every product review and every checkout
391 // account-creation wiped 100% of the cache — all anonymous happy-path
392 // actions, so the cache never reached steady state (#243). Measured:
393 // 3 orders across 36 pageviews took the hit rate from 83% to 50% and
394 // the average response from 23ms to 57ms.
395 //
396 // HPOS does NOT help: WooCommerce still writes a
397 // `shop_order_placehold` row into wp_posts to reserve the order ID,
398 // so save_post fires either way. The gate therefore keys on POST-TYPE
399 // VIEWABILITY, not on storage mode — which fixes both modes at once,
400 // and generalises to Flamingo (#229) and Tutor LMS (#231) too.
401 remove_action( 'save_post', array( __CLASS__, 'purge_all' ) );
402 add_action( 'save_post', array( __CLASS__, 'on_save_post' ), 10, 2 );
403 // Bracket every post save, so a WooCommerce product save that runs
404 // inside one (wp-admin's Update) can tell on_save_post() is about to
405 // clear the whole site for the same product. See purge_product().
406 add_action( 'save_post', array( __CLASS__, 'on_post_save_start' ), 0, 2 );
407 add_action( 'save_post', array( __CLASS__, 'on_post_save_end' ), PHP_INT_MAX, 1 );
408 // The narrow purge runs here, after the REST API has set the terms
409 // (it sets them after `save_post`), with the post as it was before.
410 add_action( 'wp_after_insert_post', array( __CLASS__, 'on_after_insert_post' ), 10, 4 );
411 add_action( 'shutdown', array( __CLASS__, 'flush_pending_saves' ), 1, 0 );
412 add_action( 'update_option_sticky_posts', array( __CLASS__, 'on_sticky_posts_change' ), 10, 2 );
413 // The first sticky post on a site creates the option instead.
414 add_action( 'add_option_sticky_posts', array( __CLASS__, 'on_sticky_posts_added' ), 10, 2 );
415 add_action( 'pre_post_update', array( __CLASS__, 'on_pre_post_update' ), 10, 2 );
416 add_action( 'before_delete_post', array( __CLASS__, 'on_post_removed' ), 10, 2 );
417 add_action( 'trashed_post', array( __CLASS__, 'on_post_removed' ), 10, 2 );
418 // wp_delete_post() hands an attachment to wp_delete_attachment() and
419 // returns BEFORE before_delete_post fires, so deleting media reached
420 // neither hook above. Attachment pages are public and media appears in
421 // galleries, so that left cached pages showing a file that is gone.
422 // (dev caught this via `deleted_post`, which this branch replaced.)
423 add_action( 'delete_attachment', array( __CLASS__, 'on_post_removed' ), 10, 2 );
424
425 remove_action( 'comment_post', array( __CLASS__, 'purge_all' ) );
426 add_action( 'comment_post', array( __CLASS__, 'on_comment_post' ), 10, 3 );
427 add_action( 'wp_set_comment_status', array( __CLASS__, 'on_comment_status' ), 10, 2 );
428
429 remove_action( 'user_register', array( __CLASS__, 'purge_all' ) );
430 add_action( 'user_register', array( __CLASS__, 'on_user_change' ) );
431
432 remove_action( 'profile_update', array( __CLASS__, 'purge_all' ) );
433 add_action( 'profile_update', array( __CLASS__, 'on_user_change' ) );
434
435 // Product data lives in post meta and lookup tables, NOT in wp_posts,
436 // so WC_Product_Data_Store_CPT::update() takes a direct $wpdb->update()
437 // branch and save_post never fires. Anchoring invalidation on
438 // save_post therefore missed 100% of commerce-relevant mutations: a
439 // REST price change, wc_update_product_stock(), a CLI ->save(), and
440 // every scheduled sale start/end left the product page, the shop and
441 // the category archives serving the old price and stock for the full
442 // lifetime — the store quoting one price and charging another (#242).
443 //
444 // This MUST ship with the gate above: once orders stop purging
445 // everything, the accidental invalidation that was masking this
446 // disappears, and an order that reduces stock would leave the product
447 // page stale.
448 if ( class_exists( 'WooCommerce' ) ) {
449 foreach ( array( 'woocommerce_update_product', 'woocommerce_new_product' ) as $wc_hook ) {
450 add_action( $wc_hook, array( __CLASS__, 'on_product_saved' ) );
451 }
452 // wc_update_product_stock() writes the stock with SQL, saves the
453 // product (which fires woocommerce_update_product above), then
454 // fires *_set_stock. The *_before_set_stock actions open that
455 // write, so the *_set_stock that closes it can tell the save
456 // inside it already purged. With `$updating` set it skips the
457 // save, and *_set_stock is then the only purge.
458 add_action( 'woocommerce_product_before_set_stock', array( __CLASS__, 'on_product_stock_write' ) );
459 add_action( 'woocommerce_variation_before_set_stock', array( __CLASS__, 'on_product_stock_write' ) );
460 add_action( 'woocommerce_product_set_stock', array( __CLASS__, 'on_product_stock_set' ) );
461 add_action( 'woocommerce_variation_set_stock', array( __CLASS__, 'on_product_stock_set' ) );
462 add_action( 'woocommerce_product_set_stock_status', array( __CLASS__, 'purge_product' ) );
463 add_action( 'woocommerce_variation_set_stock_status', array( __CLASS__, 'purge_product' ) );
464 }
465
466 add_action( 'update_option_xspeed_options', array( __CLASS__, 'on_settings_change' ), 10, 2 );
467
468 // …and the same for every PER-MODULE option. The handler above only
469 // ever watched the legacy `xspeed_options` blob, but every module has
470 // since migrated to its own `xspeed_module_<slug>` option and no hook
471 // followed — so changing Minify HTML, Lazy Load, Remove Query Strings
472 // etc. left the cached HTML untouched until the TTL expired (24h by
473 // default) and the feature read as broken. (#205)
474 //
475 // One central listener rather than a hook per module: it covers Pro
476 // modules with no cross-repo change, and a new module can't forget to
477 // wire it up.
478 add_action( 'updated_option', array( __CLASS__, 'on_module_settings_change' ), 10, 1 );
479 // `added_option` matters as much as `updated_option`: on a fresh install
480 // a module's option doesn't exist yet, so the FIRST save of every panel
481 // goes through add_option() and would otherwise skip the purge — the
482 // original bug surviving one save per module. `deleted_option` covers a
483 // reset-to-defaults, which changes rendered HTML just as much. (#205)
484 add_action( 'added_option', array( __CLASS__, 'on_module_settings_change' ), 10, 1 );
485 add_action( 'deleted_option', array( __CLASS__, 'on_module_settings_change' ), 10, 1 );
486
487 add_action( 'admin_bar_menu', array( $this, 'admin_bar_purge' ), 100 );
488 add_action( 'admin_post_xspeed_purge', array( $this, 'handle_admin_bar_purge' ) );
489 }
490
491 public static function on_settings_change( $old, $new ) {
492 // gzip_enabled moved to xspeed_module_gzip — GzipModule owns the
493 // .htaccess flip via its own update_option_xspeed_module_gzip hook.
494 // Same migration is planned for cache_expiry + excluded_urls
495 // (Cache module). Keep this handler around for whatever still
496 // lives in the legacy blob (cache_enabled is special and goes
497 // through Cache::toggle anyway).
498
499 // Any settings change — purge caches so changes take effect. Minified
500 // files are named by content, so there is nothing to delete for them.
501 self::purge_all( 'settings change' );
502 }
503
504 /**
505 * Modules whose settings cannot change rendered HTML, so a write to them
506 * doesn't warrant throwing away the page cache.
507 *
508 * The safe default is to purge: a module is listed here only when it is
509 * clearly incapable of altering front-end output (diagnostics, the MCP
510 * server, licensing/telemetry surfaces). When in doubt, leave it off the
511 * list — a needless purge costs a re-render, a missed one makes the
512 * feature look broken. (#205)
513 *
514 * @return string[] Module slugs.
515 */
516 public static function non_rendering_modules(): array {
517 return (array) apply_filters(
518 'xspeed_non_rendering_modules',
519 array(
520 'mcp', // AI endpoint — no front-end output.
521 'health', // diagnostics only.
522 'support', // support snapshot.
523 'score', // PageSpeed/GTmetrix runner.
524 'migration', // one-shot importer.
525 'settings', // import/export surface.
526 'cache-coverage', // read-only reporting.
527 'ai-privacy', // consent flags for AI surfaces.
528 'database', // DB cleanup schedule — no HTML impact.
529 // Pro slugs — listed by name rather than by asking Pro, so
530 // Free stays unaware of it. A Pro module absent here simply
531 // purges, which is the safe default.
532 'license',
533 'pro_status',
534 'analytics',
535 'performance-health',
536 'recommendations',
537 'ai-provider',
538 'migration-pro',
539 )
540 );
541 }
542
543 /**
544 * Purge when ANY module's settings option is written. (#205)
545 *
546 * Bound to `updated_option`, `added_option` and `deleted_option` — all three
547 * fire for every option on the site, so the prefix test comes first and is
548 * the cheap path for the ~99% of writes that aren't ours. All three pass the
549 * option name first, which is why this can't hook purge_all() directly:
550 * that takes $cause first, so every purge would be filed under a cause
551 * literally named "xspeed_module_minify".
552 *
553 * @param string $option Option name that was just written or removed.
554 */
555 public static function on_module_settings_change( $option ): void {
556 $option = (string) $option;
557 $prefix = Settings_Manager::OPTION_PREFIX;
558 if ( 0 !== strpos( $option, $prefix ) ) {
559 return;
560 }
561
562 $slug = substr( $option, strlen( $prefix ) );
563 if ( '' === $slug || in_array( $slug, self::non_rendering_modules(), true ) ) {
564 return;
565 }
566
567 // Guard against re-entry: purge_all() can write options of its own (stats, timestamps), and a nested purge would
568 // both waste work and risk recursing through this same hook.
569 static $purging = false;
570 if ( $purging ) {
571 return;
572 }
573 $purging = true;
574
575 self::purge_all( 'settings change' );
576
577 $purging = false;
578 }
579
580 /**
581 * Stamp the request's cache decision on the response.
582 *
583 * `X-XSpeed-Cache` was only ever written on the serve-from-cache paths,
584 * so a miss and a deliberate bypass both came back with no header at all
585 * — indistinguishable from a `curl -I`, the first thing anyone reaches
586 * for when a site "isn't caching" (issue #10). The reason slug rides
587 * along on `X-XSpeed-Reason`, but only under WP_DEBUG so production
588 * responses stay clean. Slugs are fixed per gate — never the matched
589 * pattern, cookie or user-agent, which would echo request input back.
590 *
591 * @param string $value HIT (php) | MISS | BYPASS.
592 * @param string $reason Fixed slug naming the gate, for BYPASS only.
593 * @param int|null $lifetime_left On a HIT, the seconds the served entry has
594 * left when it has a lifetime of its own;
595 * null when it follows the site's. See
596 * entry_lifetime_left().
597 */
598 private static function mark( string $value, string $reason = '', ?int $lifetime_left = null ): void {
599 self::$status_header = $value;
600 self::$bypass_reason = $reason;
601 self::$edge_headers = array();
602
603 // A served-from-cache response may carry edge/CDN headers an add-on
604 // contributes — `CDN-Cache-Control`, `Cache-Tag` and friends. Only a
605 // HIT resolves them here. A BYPASS never does: the response was
606 // deliberately excluded from our cache, so telling a CDN to hold it
607 // for a month would cache at the edge exactly what we refused to
608 // cache here. A MISS never does either, stored or not: it is the
609 // first render, and the one most likely to be replaced once critical
610 // CSS and unused CSS have been generated. Pinning it at the edge pins
611 // the version xSpeed is about to improve on. The edge caches from the
612 // first HIT instead — one render later, and the right one.
613 //
614 // Resolved before the headers_sent() guard so the decision is
615 // recorded (and observable in tests) even on a request that can no
616 // longer send headers; only the emission below is conditional.
617 if ( 'HIT' === self::edge_status( $value ) ) {
618 self::$edge_headers = self::edge_headers_for( 'HIT' );
619 }
620
621 // Every status, not just a HIT. A page we declined to cache is the
622 // one an edge most needs telling about: it goes out naked today, and
623 // a CDN that stores HTML by default keeps somebody's cart.
624 //
625 // Resolved before the headers_sent() guard so the decision is
626 // recorded (and observable in tests) even on a request that can no
627 // longer send headers; only the emission below is conditional.
628 self::$edge_headers = self::edge_headers_for( self::edge_status( $value ), 'request', $reason );
629
630 // The same countdown the drop-in applies to this entry, so the two
631 // PHP serve paths tell the edge the same thing about one page.
632 if ( null !== $lifetime_left && 'HIT' === self::edge_status( $value ) ) {
633 self::$edge_headers = self::cap_edge_lifetime( self::$edge_headers, $lifetime_left );
634 }
635
636 if ( headers_sent() ) {
637 return;
638 }
639 header( 'X-XSpeed-Cache: ' . $value );
640 if ( '' !== $reason && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
641 header( 'X-XSpeed-Reason: ' . $reason );
642 }
643 foreach ( self::$edge_headers as $name => $val ) {
644 header( $name . ': ' . $val );
645 }
646 }
647
648 /**
649 * Normalize an `X-XSpeed-Cache` value to the vocabulary the edge seam
650 * speaks.
651 *
652 * The header value carries which layer served the page (`HIT (php)`,
653 * `HIT (nginx)`, `HIT (static)`); nothing deciding what to tell a CDN
654 * cares, and making a caller match on three spellings of one outcome is
655 * how a rule ends up applied on two paths out of three.
656 */
657 private static function edge_status( string $value ): string {
658 return 0 === strpos( $value, 'HIT' ) ? 'HIT' : $value;
659 }
660
661 /**
662 * Hash a generated rule set so the rules can identify themselves.
663 *
664 * @param string[] $lines Every line of the rule set EXCEPT the marker.
665 */
666 private static function rules_hash( array $lines ): string {
667 return substr( sha1( implode( "\n", $lines ) ), 0, 8 );
668 }
669
670 /**
671 * Insert the self-describing marker directive into a generated rule set.
672 *
673 * The hash is taken over the rule set WITHOUT this line, and that is the
674 * whole trick: hashing the finished text instead would mean the act of
675 * adding the marker changed the value the marker advertises, the probe
676 * would never see a match, and every correctly installed block would
677 * report itself out of date forever. The one property worth a test of its
678 * own — RewriteProbeRulesStateTest covers it.
679 *
680 * More than one template is allowed because Apache needs the same marker
681 * twice, under two environment-variable names (see rewrite_block_lines).
682 * All of them carry the SAME hash, taken over the marker-less rule set, so
683 * adding the second directive cannot change the value either advertises.
684 *
685 * @param string[] $lines The rule set, with no marker line in it.
686 * @param string[] $templates sprintf templates for the directives; `%s` is the hash.
687 * @param int $at Index to insert the marker at.
688 * @return string[]
689 */
690 private static function with_rules_marker( array $lines, array $templates, int $at ): array {
691 $hash = self::rules_hash( $lines );
692 $directives = array();
693 foreach ( $templates as $template ) {
694 $directives[] = sprintf( $template, $hash );
695 }
696 array_splice( $lines, $at, 0, $directives );
697 return $lines;
698 }
699
700 /**
701 * The rules hash the CURRENT settings generate, i.e. what a correctly
702 * installed rule set would be sending back.
703 *
704 * Read out of the generated artifact rather than recomputed, so there is
705 * exactly one definition of the hash and no way for the generator and the
706 * expectation to drift apart. Empty on a server whose fast path we do not
707 * generate rules for.
708 */
709 public static function rules_marker_expected(): string {
710 $type = Server::type();
711 if ( Server::NGINX === $type ) {
712 return self::extract_rules_marker( (string) self::nginx_snippet() );
713 }
714 if ( Server::APACHE === $type ) {
715 return self::extract_rules_marker( implode( "\n", self::rewrite_block_lines() ) );
716 }
717 return '';
718 }
719
720 /** Pull the marker value out of a generated rule set ('' when absent). */
721 public static function extract_rules_marker( string $source ): string {
722 if ( preg_match( '/' . preg_quote( self::RULES_HEADER, '/' ) . ' "([0-9a-f]{8})"/', $source, $m ) ) {
723 return $m[1];
724 }
725 return '';
726 }
727
728 /**
729 * Fingerprint of every setting that feeds the generated server rules.
730 *
731 * Values are hashed rather than stored: this map is written from a read
732 * path and has no business becoming a second copy of the user's exclusion
733 * lists. A per-key hash is enough — the point is to name WHICH setting
734 * moved between two rule versions, not to reconstruct the old value.
735 *
736 * @return array<string,string>
737 */
738 public static function rules_inputs(): array {
739 $cache_opts = Settings_Manager::get( 'cache' );
740 $fingerprint = static function ( $value ): string {
741 $flat = array();
742 foreach ( (array) $value as $key => $item ) {
743 $flat[] = $key . '=' . ( is_scalar( $item ) ? (string) $item : '' );
744 }
745 return substr( sha1( implode( "\x1f", $flat ) ), 0, 8 );
746 };
747
748 return array(
749 'excluded_cookies' => $fingerprint( $cache_opts['excluded_cookies'] ?? array() ),
750 'bypass_user_agents' => $fingerprint( $cache_opts['bypass_user_agents'] ?? array() ),
751 'excluded_urls' => $fingerprint( $cache_opts['excluded_urls'] ?? array() ),
752 'edge_headers' => $fingerprint( self::edge_headers_for( 'HIT', 'rules' ) ),
753 'static_dir' => $fingerprint( array( XSPEED_CACHE_STATIC_DIR ) ),
754 );
755 }
756
757 /**
758 * Remember which settings produced a rules version.
759 *
760 * Without this a stale verdict can only say "stale". With it, the hash the
761 * server sent back is a key into what the site's settings looked like when
762 * those rules were generated, so the dashboard can name what changed since
763 * the user last pasted. Bounded to the last few versions — the map exists
764 * to explain a recent drift, not to keep a history.
765 *
766 * @param array<string,string> $inputs Fingerprint from rules_inputs().
767 */
768 private static function remember_rules_inputs( string $hash, array $inputs ): void {
769 if ( '' === $hash ) {
770 return;
771 }
772 $map = get_option( self::RULES_INPUTS_OPTION, array() );
773 if ( ! is_array( $map ) ) {
774 $map = array();
775 }
776 if ( isset( $map[ $hash ] ) && $map[ $hash ] === $inputs ) {
777 return;
778 }
779 $map[ $hash ] = $inputs;
780 if ( count( $map ) > self::RULES_INPUTS_KEPT ) {
781 $map = array_slice( $map, -self::RULES_INPUTS_KEPT, null, true );
782 }
783 update_option( self::RULES_INPUTS_OPTION, $map, false );
784 }
785
786 /**
787 * Which settings changed between the installed rules and the current ones.
788 *
789 * Empty when the installed version predates the map (nothing to compare
790 * against) — the caller then says "out of date" without naming a cause,
791 * which is honest.
792 *
793 * @param array<string,string> $current Fingerprint from rules_inputs().
794 * @return string[] Setting keys.
795 */
796 private static function changed_rules_inputs( string $observed, array $current ): array {
797 $map = get_option( self::RULES_INPUTS_OPTION, array() );
798 if ( ! is_array( $map ) || ! isset( $map[ $observed ] ) || ! is_array( $map[ $observed ] ) ) {
799 return array();
800 }
801
802 $was = $map[ $observed ];
803 $changed = array();
804 foreach ( $current as $key => $value ) {
805 if ( ! array_key_exists( $key, $was ) || $was[ $key ] !== $value ) {
806 $changed[] = $key;
807 }
808 }
809 return $changed;
810 }
811
812 /**
813 * Whether the rules the server is running are the rules these settings
814 * generate.
815 *
816 * Nothing in WordPress can read a hand-pasted nginx server block, so
817 * before this the dashboard could only guess — and guessed by telling
818 * everyone to re-paste after every settings change. The generated rules
819 * now carry their own hash and the probe reads it back:
820 *
821 * current — the marker matches what these settings generate.
822 * stale — a marker came back, from a different version of the rules.
823 * absent — the probe reached a verdict and saw no marker: either no
824 * rules are installed (the request fell through to PHP) or
825 * they predate the marker. Either way the fix is the same.
826 * unknown — the probe could not tell, or this server has no rule set we
827 * generate.
828 *
829 * A marker outranks the probe's own `active` verdict: the marker is direct
830 * evidence of which rules answered, while `active` is inferred from
831 * response shape.
832 *
833 * Scope: the static-cache rules only — nginx_snippet() on nginx, the
834 * .htaccess block on Apache. full_nginx_server_block() pastes those
835 * alongside other modules' directives, and a change to one of those does
836 * NOT move this hash. Hashing the aggregate would mean the marker inside
837 * the location block had to know the text it is embedded in, and the
838 * snippet would hash differently depending on which caller asked for it.
839 * Callers wording this for a human should say "cache rules", not "your
840 * nginx config".
841 *
842 * `copied` is the mirror of what this admin last pasted — see
843 * rules_copied(). It is always present, null included, because a client
844 * keys on the key existing to decide whether the server remembers at all.
845 *
846 * @param array $probe Raw result from probe_static_rewrite().
847 * @return array{expected:string,observed:string,state:string,changed:string[],copied:array{hash:string,at:int}|null}
848 */
849 public static function rules_state( array $probe ): array {
850 $expected = self::rules_marker_expected();
851 $observed = (string) ( $probe['rules'] ?? '' );
852 $copied = self::rules_copied();
853
854 if ( '' === $expected ) {
855 return array(
856 'expected' => '',
857 'observed' => $observed,
858 'state' => 'unknown',
859 'changed' => array(),
860 'copied' => $copied,
861 );
862 }
863
864 $inputs = self::rules_inputs();
865 self::remember_rules_inputs( $expected, $inputs );
866
867 if ( '' !== $observed ) {
868 return array(
869 'expected' => $expected,
870 'observed' => $observed,
871 'state' => $observed === $expected ? 'current' : 'stale',
872 'changed' => $observed === $expected ? array() : self::changed_rules_inputs( $observed, $inputs ),
873 'copied' => $copied,
874 );
875 }
876
877 // `absent` is a claim about what is installed, so it takes a probe that
878 // completed a round trip and read the response headers. A result that
879 // is still pending, one whose loopback failed, one a CDN answered, and
880 // one that never got off the ground (no host in home_url, no writable
881 // probe dir — neither of which sets `inconclusive`, and both of which
882 // arrive here with no status code) all leave us knowing nothing.
883 if ( empty( $probe['code'] ) || ! empty( $probe['pending'] ) || ! empty( $probe['inconclusive'] ) ) {
884 return array(
885 'expected' => $expected,
886 'observed' => '',
887 'state' => 'unknown',
888 'changed' => array(),
889 'copied' => $copied,
890 );
891 }
892
893 return array(
894 'expected' => $expected,
895 'observed' => '',
896 'state' => 'absent',
897 'changed' => array(),
898 'copied' => $copied,
899 );
900 }
901
902 /**
903 * Which version of the rules this admin says they pasted into the server.
904 *
905 * Their claim, not evidence — the probe is the evidence, and where the
906 * probe can answer this is ignored. It exists for the `unknown` state: a
907 * host whose loopback is blocked, or one behind a CDN that answers the
908 * probe itself, where nothing can read back what is installed. There the
909 * only thing left to go on is that someone said they had done it, and
910 * without a record of that the panel asks every admin to paste the block
911 * again forever.
912 *
913 * Per user rather than per site, because it is a claim a person made. A
914 * second admin on the same site has not pasted anything and should not be
915 * told the work is done. Null when nobody has claimed this version, when
916 * the stored value is not a shape we wrote, or when there is no current
917 * user at all (WP-CLI, cron).
918 *
919 * @return array{hash:string,at:int}|null
920 */
921 public static function rules_copied(): ?array {
922 if ( ! function_exists( 'get_current_user_id' ) || ! function_exists( 'get_user_meta' ) ) {
923 return null;
924 }
925 $user_id = (int) get_current_user_id();
926 if ( $user_id <= 0 ) {
927 return null;
928 }
929
930 $stored = get_user_meta( $user_id, self::RULES_COPIED_META, true );
931 if ( ! is_array( $stored ) ) {
932 return null;
933 }
934
935 $hash = (string) ( $stored['hash'] ?? '' );
936 $at = (int) ( $stored['at'] ?? 0 );
937 if ( ! self::is_rules_hash( $hash ) || $at <= 0 ) {
938 return null;
939 }
940
941 return array(
942 'hash' => $hash,
943 'at' => $at,
944 );
945 }
946
947 /**
948 * Record that this admin pasted the rules whose marker is $hash.
949 *
950 * Rejects anything that is not one of our markers rather than storing it,
951 * so the mirror can only ever hold a value rules_marker_expected() could
952 * also produce — a stored string that matches nothing would read as "a
953 * different version is installed" forever.
954 *
955 * @param string $hash The 8-hex rules marker the admin copied.
956 * @return array{hash:string,at:int}|null The stored record, or null if refused.
957 */
958 public static function remember_rules_copied( string $hash ): ?array {
959 // Trimmed but not case-folded: extract_rules_marker() reads a marker
960 // back in lower case only, so an upper-case claim would never match
961 // anything the probe could observe. Refuse it rather than store a
962 // value that can only ever read as a different version.
963 $hash = trim( $hash );
964 if ( ! self::is_rules_hash( $hash ) ) {
965 return null;
966 }
967 if ( ! function_exists( 'get_current_user_id' ) || ! function_exists( 'update_user_meta' ) ) {
968 return null;
969 }
970 $user_id = (int) get_current_user_id();
971 if ( $user_id <= 0 ) {
972 return null;
973 }
974
975 $record = array(
976 'hash' => $hash,
977 'at' => time(),
978 );
979 update_user_meta( $user_id, self::RULES_COPIED_META, $record );
980
981 return $record;
982 }
983
984 /** Whether a string is shaped like one of our rules markers. */
985 public static function is_rules_hash( string $hash ): bool {
986 return 1 === preg_match( '/^[0-9a-f]{8}$/', $hash );
987 }
988
989 /** Record a bypass gate and answer "don't cache" in one statement. */
990 private static function bypass( string $reason ): bool {
991 self::mark( 'BYPASS', $reason );
992 return false;
993 }
994
995 /** The X-XSpeed-Cache value decided for this request ('' if none yet). */
996 public static function status_header(): string {
997 return self::$status_header;
998 }
999
1000 /** The bypass gate slug for this request ('' unless BYPASS). */
1001 public static function bypass_reason(): string {
1002 return self::$bypass_reason;
1003 }
1004
1005 /**
1006 * The edge/CDN pairs sent on this request ('' if none were).
1007 *
1008 * @return array<string,string>
1009 */
1010 public static function edge_headers(): array {
1011 return self::$edge_headers;
1012 }
1013
1014 /**
1015 * Bypass gates that do NOT ask a cache in front of us to stand down.
1016 *
1017 * Every other slug does. The split is the reason this reads the gate
1018 * rather than the status: a bypass usually means "this response is
1019 * personal, or someone decided this page is never stored", and an edge
1020 * holding one of those does precisely what we refused to do. These two
1021 * mean something else.
1022 *
1023 * `cache-disabled` is the user switching OUR page cache off. Nothing
1024 * about the page became personal. Sending `no-store` on every page of a
1025 * site whose owner chose a different cache would make a local toggle a
1026 * site-wide side effect on infrastructure we do not own.
1027 *
1028 * `non-frontend` is admin, REST, cron and AJAX. Not ours to describe:
1029 * WordPress already nocaches admin, and a REST caller sets its own
1030 * policy.
1031 */
1032 private const HOLD_EXEMPT_BYPASS = array( 'cache-disabled', 'non-frontend' );
1033
1034 /**
1035 * Bypass gates that describe the SHAPE of the request rather than the
1036 * visitor or the page.
1037 *
1038 * These still hold, but only once we have evidence of an edge — the same
1039 * bar a MISS has to clear. The difference matters because the default
1040 * excluded-URL list contains `/feed/`, the sitemap and `/wp-json/`, and
1041 * `query-param` catches `?lang=fr`, `?paged=2`, and every page of a
1042 * plain-permalink site.
1043 *
1044 * xSpeed refuses those because IT cannot key on a query string, not
1045 * because the response is private. A CDN keys on the full URL and caches
1046 * them correctly. Holding them unconditionally would have meant every
1047 * default install stopped its feed and sitemap being edge-cached — a
1048 * performance regression shipped to sites that never had a CDN in the
1049 * first place, in the name of protecting them from one.
1050 *
1051 * The gates left out of this list are about the visitor (`logged-in`,
1052 * `excluded-cookie`) or are somebody stating outright that this page is
1053 * never to be stored (`donotcachepage`, `post-excluded`, `filtered`).
1054 * Those hold whether or not we can see an edge.
1055 */
1056 private const REQUEST_SHAPE_BYPASS = array( 'query-param', 'non-get', 'user-agent' );
1057
1058 /**
1059 * Default exclusions that are about the site's plumbing, not its content.
1060 *
1061 * `excluded-url` covers two unlike things. The default list carries
1062 * `/cart`, `/checkout`, `/my-account` and `/wp-login` — personal pages,
1063 * and the reason this feature exists. It also carries the entries below:
1064 * feeds, sitemaps, the REST root, the front controller. Those are public,
1065 * cacheable, and hammered by pollers; a CDN keys on the full URL and
1066 * serves them correctly, so telling it to stop is a cost with no benefit.
1067 *
1068 * Matched as exact strings against the stored list, never as patterns
1069 * against the path. Three bugs came out of doing it the other way round:
1070 * `strpos( $uri, '/feed' )` matched `/my-account/feedback/`, reading the
1071 * whole URI let `/cart/?utm_source=/feed/` disguise a cart as a feed, and
1072 * a bare `index.php` — which is in this list, and which every URL contains
1073 * on an "almost pretty" permalink site — made every page on such a site
1074 * look personal. Comparing the LIST ENTRY rather than the path cannot make
1075 * any of those mistakes, and it keeps a pattern the site owner added
1076 * themselves on the personal side where it belongs.
1077 */
1078 private const STRUCTURAL_EXCLUSIONS = array(
1079 '/wp-json/',
1080 '/xmlrpc.php',
1081 '~wp-.*\.php',
1082 '/feed/',
1083 'index.php',
1084 '/robots.txt',
1085 // Both spellings, and no entry here is ever retired. This is a
1086 // RECOGNITION list, not a source of truth: it is matched against
1087 // whatever the site has STORED, and a site that saved its settings
1088 // before `~sitemap(_index)?\.xml` was widened to `sitemaps?` (for
1089 // SEOPress, which ships sitemaps.xml) still has the old string in
1090 // its option row. Dropping the old spelling when the default moved
1091 // would read every upgraded site's sitemap exclusion as somebody's
1092 // personal data and hold sitemaps off the CDN — the bug this whole
1093 // predicate exists to prevent, reintroduced by a rename.
1094 '~sitemaps?(_index)?\.xml',
1095 '~sitemap(_index)?\.xml',
1096 );
1097 /**
1098 * Header names no edge instruction may ever carry.
1099 *
1100 * These describe the transfer, not the caching policy, and one wrong
1101 * value from a settings field is a white screen rather than a missing
1102 * optimization.
1103 */
1104 private const NEVER_AN_EDGE_HEADER = array(
1105 'content-length',
1106 'content-encoding',
1107 'content-type',
1108 'transfer-encoding',
1109 'set-cookie',
1110 'location',
1111 'x-xspeed-cache',
1112 'x-xspeed-edge-hold',
1113 // `X-XSpeed-Built` says when THIS PAGE's HTML was generated, which is
1114 // what a downstream verifier compares against the moment it asked for
1115 // a purge. Free owns it and never takes it from a filter, in any
1116 // context — see the BUILT_HEADER docblock.
1117 //
1118 // Under `bake` a supplied value would be frozen into an artifact and
1119 // report when the artifact was written: identical on every page and
1120 // never moving. Under `request` it is no better, because the filter
1121 // runs from mark() with no file and no mtime in reach, so the only
1122 // value anything can produce there is time() — the moment of the
1123 // SERVE, not of the build. Both make every purge check pass.
1124 //
1125 // Free stamps the real value where it has the file: the drop-in and
1126 // serve_not_modified(), both from filemtime(). The static serve paths
1127 // run no PHP and carry no stamp at all; their `Last-Modified` is read
1128 // instead.
1129 'x-xspeed-built',
1130 );
1131
1132 /**
1133 * Reasons that hold the edge off even when we detected nothing in front.
1134 *
1135 * `none` confidence means no evidence of a proxy, which is not proof
1136 * there is none — a transparent proxy and a host page cache both leave
1137 * the request untouched. So the question is what a wasted header costs
1138 * against what a missed one does, and the answer differs by reason.
1139 *
1140 * These two are correctness failures. A cart page stored by something we
1141 * could not see is the defect this exists to fix, and a mobile-split page
1142 * served to the wrong device is a wrong page rather than a slow one.
1143 * Ninety bytes on a response that was never cacheable is a cheap premium.
1144 *
1145 * `miss` and `pending` are performance hedges, and a hedge against a
1146 * cache that does not exist is noise on every first render. Skipping them
1147 * has a second benefit: because per_entry_edge_headers() compares `store`
1148 * against `bake`, a `pending` hold that never fires leaves the two
1149 * agreeing, which keeps the page on the static tree.
1150 *
1151 * `query-variant` waits for evidence as well. It is about purges. An edge
1152 * keeps a separate copy for every query string, and a purge of the plain
1153 * URL never reaches them. With no edge in front there are no copies.
1154 */
1155 private const HOLD_WITHOUT_EVIDENCE = array( 'bypass', 'mobile-split' );
1156
1157 /**
1158 * True only while query_variant_edge_headers() bakes the drop-in's
1159 * answer for a URL no purge names.
1160 *
1161 * A bake has no request to read, so this is how it asks "what if this
1162 * HIT carried a param no purge names?". The drop-in answers that for
1163 * itself, per request. Never true outside that one call.
1164 *
1165 * @var bool
1166 */
1167 private static $baking_query_variant = false;
1168
1169 /**
1170 * Is a module still going to change this page after this response?
1171 *
1172 * Free itself never says yes — nothing in Free defers work past the
1173 * request. Minification and combining write their file and return its URL
1174 * inside the same render; the LCP preload is chosen by parsing the HTML
1175 * being sent. It is the question that matters to anything caching in
1176 * front of us, so Free asks it on their behalf and lets whoever owns the
1177 * deferred work answer.
1178 *
1179 * Answer TRUE while the work is outstanding for the page being served.
1180 * The cost of a false yes is one extra origin hit; the cost of a false no
1181 * is an un-optimized page pinned at the edge for the full lifetime, which
1182 * is the failure this exists to prevent — so when in doubt, say yes.
1183 *
1184 * Asked on a `request` only, and that boundary is the whole safety of it.
1185 *
1186 * A `bake` is generated once, in an admin or CLI request, and serves every
1187 * static HIT on the site; a per-page answer frozen into it would be wrong
1188 * for every other page.
1189 *
1190 * A `store` is worse, and cost a live site an afternoon. The pairs written
1191 * at store time go into the `.meta` sidecar, which the drop-in replays on
1192 * every later HIT — before plugins load, so nothing can re-ask this
1193 * question. A hold written there therefore outlives the state that caused
1194 * it, and the only thing that clears it is the page being stored again. On
1195 * a site where the deferred work never completes, every re-store re-pins
1196 * it, and the page is never edge-cacheable again. The symptom is a cache
1197 * HIT carrying `no-store` and `X-XSpeed-Edge-Hold: pending` on a page
1198 * whose deferred work finished long ago — the sidecar answering with
1199 * state nothing can re-ask.
1200 *
1201 * Holding the MISS is what this is for, and it is enough: that response is
1202 * the un-optimized one. The copy we then store is what an edge should
1203 * mirror, and when the work does land the module purges the page, which
1204 * reaches the edge. The purge is the correctness mechanism; this is only
1205 * meant to cover the single render before it.
1206 *
1207 * @param string $context `request`, `store` or `bake`.
1208 */
1209 public static function edge_optimization_pending( string $context = 'request' ): bool {
1210 if ( 'request' !== $context ) {
1211 return false;
1212 }
1213
1214 /**
1215 * Filter: xspeed_edge_optimization_pending
1216 *
1217 * @param bool $pending Whether deferred work will still change this page.
1218 */
1219 return (bool) apply_filters( 'xspeed_edge_optimization_pending', false );
1220 }
1221
1222 /**
1223 * Does mobile cache split this URL into two renders?
1224 *
1225 * With `mobile_separate` on, Free keys its cache on device and serves a
1226 * different page to a phone than to a desktop at the SAME url. No CDN
1227 * varies on User-Agent, so an edge holding one of those renders serves it
1228 * to everyone: whichever device asked first decides what the other sees,
1229 * for the whole lifetime. A wrong page, not a slow one.
1230 *
1231 * Read from the stored option rather than through Settings_Manager: this
1232 * is consulted from the serve path, where the module registry may not
1233 * have run.
1234 */
1235 private static function mobile_cache_splits_html(): bool {
1236 $stored = self::stored_cache_opts();
1237 return ! empty( $stored['mobile_separate'] );
1238 }
1239
1240 /**
1241 * Is this cached page being served for a URL that no purge names?
1242 *
1243 * Free serves `/post?utm_source=x` from the entry stored for `/post`,
1244 * which is what the ignored-params list is for. An edge does not. It
1245 * keys on the full URL, so every query string becomes its own copy. A
1246 * purge that names `/post` clears one copy and leaves the others for the
1247 * whole edge lifetime. A newsletter link can then serve last month's
1248 * page. So such a HIT is held, and the edge keeps only URLs a purge can
1249 * name. See query_carries_unpurged_param() for which query strings count.
1250 *
1251 * A HIT only. A MISS is already held as `miss`, and a BYPASS never
1252 * reached the cache. And `request` only, because the query string belongs
1253 * to the request, not to the entry. One stored file answers `/post` and
1254 * every variant of it, so a hold written into its sidecar under `store`
1255 * would be replayed by the drop-in for the plain URL as well. A `bake`
1256 * says no unless query_variant_edge_headers() is asking for the drop-in.
1257 *
1258 * @param string $status `HIT`, `MISS` or `BYPASS`.
1259 * @param string $context `request`, `store` or `bake`.
1260 */
1261 private static function serves_query_variant( string $status, string $context ): bool {
1262 if ( 'HIT' !== $status ) {
1263 return false;
1264 }
1265 if ( 'bake' === $context ) {
1266 return self::$baking_query_variant;
1267 }
1268
1269 return 'request' === $context && self::query_carries_unpurged_param();
1270 }
1271
1272 /**
1273 * Does this request's query string carry a param that keeps its URL out
1274 * of every purge?
1275 *
1276 * Two kinds do. A param the cache key leaves out, which is anything on
1277 * the ignored-params list, judged by query_key_is_ignored() against the
1278 * same setting should_cache() reads. And the search and query-form feed
1279 * params (`s`, FEED_QUERY_PARAMS). Those have entries keyed their own
1280 * way, but they belong to opt-in caches whose URLs no purge names either.
1281 *
1282 * Any other param that reaches a HIT is part of the page's own address,
1283 * which a purge names, so it is not held. A plain-permalink route such
1284 * as `/?page_id=2` is the case this leaves alone.
1285 */
1286 private static function query_carries_unpurged_param(): bool {
1287 $query_raw = isset( $_SERVER['QUERY_STRING'] ) ? (string) wp_unslash( $_SERVER['QUERY_STRING'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- parsed for its keys only, as should_cache() does; never echoed or stored.
1288 if ( '' === trim( $query_raw ) ) {
1289 return false;
1290 }
1291
1292 parse_str( $query_raw, $params );
1293 $cache_opts = Settings_Manager::get( 'cache' );
1294 $ignored = is_array( $cache_opts['ignored_query_params'] ?? null ) ? $cache_opts['ignored_query_params'] : array();
1295 foreach ( array_keys( $params ) as $key ) {
1296 $key = (string) $key;
1297 if ( 's' === $key || in_array( $key, self::FEED_QUERY_PARAMS, true ) ) {
1298 return true;
1299 }
1300 if ( self::query_key_is_ignored( $key, $ignored ) ) {
1301 return true;
1302 }
1303 }
1304
1305 return false;
1306 }
1307
1308 /**
1309 * The edge answer the drop-in sends for a cached page served for a URL
1310 * no purge names, or an empty array when that answer is the same as the
1311 * plain URL's.
1312 *
1313 * The drop-in runs before plugins load and cannot ask edge_headers_for(),
1314 * so this is baked in next to the plain answer. The drop-in applies it
1315 * when a param in the query string matches the ignored-params list it
1316 * already reads (`.ignored-query-params`, written from the same setting
1317 * by sync_query_allowlist()). Search and feed params never reach it,
1318 * because it hands those requests to PHP. The nginx and Apache
1319 * rules need no copy. Both refuse any request with a query string
1320 * (`if ($args)`, `RewriteCond %{QUERY_STRING} ^$`), so a variant always
1321 * reaches PHP.
1322 *
1323 * Empty when the two answers agree. That covers no edge being known,
1324 * a filter vetoing the hold, and a site where every page is held
1325 * already (mobile-split). The drop-in then sends what it sends for the
1326 * plain URL, including any sidecar the page has.
1327 *
1328 * @return array<string,string>
1329 */
1330 public static function query_variant_edge_headers(): array {
1331 $plain = self::edge_headers_for( 'HIT', 'bake' );
1332
1333 self::$baking_query_variant = true;
1334 try {
1335 $variant = self::edge_headers_for( 'HIT', 'bake' );
1336 } finally {
1337 self::$baking_query_variant = false;
1338 }
1339
1340 return $variant === $plain ? array() : $variant;
1341 }
1342
1343 /**
1344 * Why, if at all, a cache in front of us should refuse to store this.
1345 *
1346 * @param string $status `HIT`, `MISS` or `BYPASS`.
1347 * @param string $context `request`, `store` or `bake`.
1348 * @param string $bypass_reason The gate slug, for BYPASS only.
1349 * @return string '' or one of bypass|bypass-shape|miss|mobile-split|pending|query-variant.
1350 */
1351 private static function edge_hold_reason( string $status, string $context, string $bypass_reason ): string {
1352 $reason = '';
1353
1354 // The two exempt gates are answered before anything else, or a site
1355 // with Separate Mobile Cache on would keep holding after the page
1356 // cache was switched off — which is exactly the "a local toggle must
1357 // not become a site-wide side effect on infrastructure we do not own"
1358 // rule below, defeated by the ordering rather than by the logic.
1359 if ( 'BYPASS' === $status && in_array( $bypass_reason, self::HOLD_EXEMPT_BYPASS, true ) ) {
1360 /** This filter is documented below. */
1361 return (string) apply_filters( 'xspeed_edge_hold_reason', '', $status, $context, $bypass_reason );
1362 }
1363
1364 // First, because it is the only reason true in every context: the
1365 // setting is a property of the site, not of one request, so it is the
1366 // one thing a baked artifact can honestly assert.
1367 //
1368 // It is also the only reason that holds a HIT — a response we DID
1369 // cache — and that is deliberate rather than an artefact of the
1370 // ordering. With mobile_separate on we key the cache by device and
1371 // serve different HTML to a phone than to a desktop at the same URL.
1372 // No CDN varies on User-Agent, so an edge holding one of those
1373 // renders serves it to everyone and whichever device asked first
1374 // decides what the other sees. Our copy is fine; theirs would be a
1375 // wrong page. The static path is switched off in this mode anyway
1376 // (static_rewrite_allowed()), so these hits come from the drop-in,
1377 // which carries the same baked answer.
1378 if ( self::mobile_cache_splits_html() ) {
1379 $reason = 'mobile-split';
1380 } elseif ( 'BYPASS' === $status ) {
1381 $shaped = in_array( $bypass_reason, array( 'excluded-url', 'query-param' ), true )
1382 ? ! self::path_is_a_personal_exclusion( $bypass_reason )
1383 : in_array( $bypass_reason, self::REQUEST_SHAPE_BYPASS, true );
1384 $reason = $shaped ? 'bypass-shape' : 'bypass';
1385 } elseif ( self::edge_optimization_pending( $context ) ) {
1386 $reason = 'pending';
1387 } elseif ( 'MISS' === $status ) {
1388 $reason = 'miss';
1389 } elseif ( self::serves_query_variant( $status, $context ) ) {
1390 // Last, so it only adds a hold where there was none. A variant
1391 // still waiting for its CSS says `pending`, which is also true,
1392 // and the CSS modules are still asked on every HIT as before.
1393 $reason = 'query-variant';
1394 }
1395
1396 /**
1397 * Filter: xspeed_edge_hold_reason
1398 *
1399 * Return '' to veto a hold, or a reason string to force one.
1400 *
1401 * @param string $reason '' or bypass|bypass-shape|miss|mobile-split|pending|query-variant.
1402 * @param string $status `HIT`, `MISS` or `BYPASS`.
1403 * @param string $context `request`, `store` or `bake`.
1404 * @param string $bypass_reason The gate slug, for BYPASS only.
1405 */
1406 return (string) apply_filters( 'xspeed_edge_hold_reason', $reason, $status, $context, $bypass_reason );
1407 }
1408
1409 /**
1410 * Was this page excluded because it is personal, or because it is
1411 * plumbing we cannot key a cache entry on?
1412 *
1413 * Answers by removing the structural defaults from the site's own
1414 * exclusion list and asking whether anything is left that matches. So a
1415 * feed matches only `/feed/` and comes back false; `/my-account/feedback/`
1416 * matches `/my-account` and comes back true; and on an "almost pretty"
1417 * permalink site, where every path contains `index.php`, an ordinary page
1418 * matches nothing else and is correctly treated as public.
1419 *
1420 * The path only, never the query string — a visitor writes that, and
1421 * `/cart/?utm_source=/feed/` must not be able to talk a cart out of its
1422 * hold. It is also what `should_cache()` matches the list against.
1423 *
1424 * Asked for a `query-param` bypass too, because the query gate runs
1425 * BEFORE the URL gate, so `/cart/?add-to-cart=12` reports `query-param`
1426 * and never reaches `excluded-url` at all. Which gate fired first says
1427 * nothing about whose data is on the page.
1428 */
1429 private static function path_is_a_personal_exclusion( string $bypass_reason ): bool {
1430 $path = self::request_path();
1431 if ( '' === $path ) {
1432 return false;
1433 }
1434
1435 // Through Settings_Manager, not the raw option, because the schema's
1436 // default IS the structural list and a fresh install has never
1437 // written the option. Read raw, every site that has not visited the
1438 // settings screen looks like a site with no exclusions at all, takes
1439 // the contradiction branch below, and reports its feeds as personal.
1440 //
1441 // Safe here where `mobile_cache_splits_html()` is not: we are only
1442 // ever called with a bypass reason, and those come from
1443 // `should_cache()`, which resolved the same settings through
1444 // `Settings_Manager::get()` to produce them.
1445 $opts = Settings_Manager::get( 'cache' );
1446 $excluded = is_array( $opts['excluded_urls'] ?? null ) ? $opts['excluded_urls'] : array();
1447 if ( array() === $excluded ) {
1448 // An `excluded-url` bypass with no exclusion list is a
1449 // contradiction — something excluded the request and the list
1450 // cannot say what — so assume personal, because a wasted header
1451 // costs a little origin traffic while a missing one serves
1452 // somebody's basket to a stranger. A `query-param` bypass with an
1453 // empty list is just an ordinary page carrying a parameter, and
1454 // says nothing about the path at all.
1455 return 'excluded-url' === $bypass_reason;
1456 }
1457
1458 $personal = array_values(
1459 array_filter(
1460 $excluded,
1461 static fn ( $pattern ) => ! in_array( (string) $pattern, self::STRUCTURAL_EXCLUSIONS, true )
1462 )
1463 );
1464
1465 return array() !== $personal && self::path_matches_exclusions( $personal, $path );
1466 }
1467
1468 /**
1469 * The edge/CDN headers to send on a response with this cache status.
1470 *
1471 * @param string $status `HIT`, `MISS` or `BYPASS`.
1472 * @param string $context `request` when resolved per request on the
1473 * PHP serve path, `store` when resolved for
1474 * one entry's sidecar, `bake` when resolved
1475 * once and frozen into the drop-in, `rules`
1476 * when resolved once for the nginx or Apache
1477 * static block.
1478 * @param string $bypass_reason The gate slug, for BYPASS only.
1479 * @return array<string,string>
1480 */
1481 public static function edge_headers_for( string $status, string $context = 'request', string $bypass_reason = '' ): array {
1482 // `rules` is a bake in every respect but one: the cache-headers
1483 // filter is told it is answering for a pasted server rule. Such a
1484 // rule serves every static HIT for as long as it stays pasted, and
1485 // nothing regenerates it when the site's expiry or a page's own
1486 // lifetime (a nonce cap) changes, so a lifetime given there cannot
1487 // follow the page. The drop-in is rewritten by auto_heal(); a paste
1488 // is not.
1489 $filter_context = $context;
1490 if ( 'rules' === $context ) {
1491 $context = 'bake';
1492 }
1493
1494 $base = array();
1495 if ( 'HIT' === $status ) {
1496 /**
1497 * Filter: xspeed_edge_cache_headers
1498 *
1499 * Response headers to add to a cached HTML response. The lifetime
1500 * is HIT-only: it is a promise that this copy is worth keeping,
1501 * and neither a first render nor a page we refused to cache is
1502 * one. The filter is also asked with `MISS` when a first render
1503 * is held, and only the `Cache-Tag` from that answer is sent, so
1504 * a purge can name a copy an edge stored despite the hold.
1505 *
1506 * The same filter feeds four regimes and `$context` says which.
1507 * On the PHP serve path it runs per request (`request`); at store
1508 * time it runs for one entry (`store`); when the drop-in is
1509 * generated it runs once (`bake`) and the result answers for
1510 * every drop-in HIT on the site; when the nginx or Apache static
1511 * block is generated it runs once (`rules`). Anything per-page — a
1512 * post id in a cache tag, say — must be skipped under `bake` and
1513 * `rules`. A server rule serves every static HIT for as long as it
1514 * stays pasted, so a lifetime here can't follow the page: send
1515 * none under `rules`.
1516 *
1517 * @param array<string,string> $headers Header name => value.
1518 * @param string $status `HIT`, or `MISS` when only the tag of a held first render is wanted.
1519 * @param string $context `request`, `store`, `bake` or `rules`.
1520 */
1521 $base = self::sanitize_edge_headers( (array) apply_filters( 'xspeed_edge_cache_headers', array(), 'HIT', $filter_context ) );
1522 }
1523
1524 $reason = self::edge_hold_reason( $status, $context, $bypass_reason );
1525 if ( '' === $reason ) {
1526 return $base;
1527 }
1528 $detected = Edge_Provider::detect( $context );
1529 if ( Edge_Provider::is_off( $detected ) ) {
1530 return $base;
1531 }
1532 if ( Edge_Provider::NONE === $detected['confidence']
1533 && ! in_array( $reason, self::HOLD_WITHOUT_EVIDENCE, true ) ) {
1534 return $base;
1535 }
1536
1537 $hold = Edge_Provider::hold_headers( $detected['provider'] );
1538
1539 /**
1540 * Filter: xspeed_edge_hold_headers
1541 *
1542 * The last word on what a hold INSTRUCTS. Runs before sanitising, so
1543 * a value that cannot be sent as a header is still dropped, and
1544 * before `X-XSpeed-Edge-Hold` is added, so it cannot rewrite the
1545 * reason xSpeed held the page for — that is a diagnosis, not an
1546 * instruction, and a forged one sends a reader after the wrong
1547 * module.
1548 *
1549 * @param array<string,string> $hold Header name => value.
1550 * @param array<string,string> $detected Provider, confidence, source.
1551 * @param string $reason Why the hold fired.
1552 * @param string $context `request`, `store` or `bake`.
1553 */
1554 $hold = (array) apply_filters( 'xspeed_edge_hold_headers', $hold, $detected, $reason, $context );
1555
1556 // A hold replaces the lifetime rather than sitting beside it: the two
1557 // describe the same response and would contradict each other. The
1558 // cache tag survives, because a later purge still has to be able to
1559 // name whatever the edge picked up on its own terms.
1560 //
1561 // On a HIT the tag came with `$base`. A MISS has no `$base`, and that
1562 // is the case that mattered most: the first render after any purge.
1563 // An edge whose rule overrides the origin's lifetime stores it
1564 // despite the hold, and with no tag on it the next tag purge cannot
1565 // reach it, so an edit stayed stale for the edge's whole lifetime
1566 // (QA, 2026-09-23). So a held MISS asks the same filter for its tag
1567 // and keeps the tag alone; the lifetime in the answer is dropped,
1568 // because the hold is the instruction for this response.
1569 $tag = $base['Cache-Tag'] ?? '';
1570 if ( '' === $tag && 'MISS' === $status ) {
1571 $tagged = self::sanitize_edge_headers( (array) apply_filters( 'xspeed_edge_cache_headers', array(), 'MISS', $filter_context ) );
1572 $tag = $tagged['Cache-Tag'] ?? '';
1573 }
1574 if ( '' !== $tag ) {
1575 $hold['Cache-Tag'] = $tag;
1576 }
1577
1578 // Never argue with a stronger answer WordPress already gave. It sends
1579 // `no-store, private` of its own accord on a logged-in, 404 or
1580 // password-protected response, from WP::send_headers() — which runs
1581 // before template_redirect, so it is already on the wire by the time
1582 // we get here. Ours is the weaker statement of the two; replacing it
1583 // would be a downgrade dressed as a fix. Only meaningful per request:
1584 // a bake has no response to inspect.
1585 if ( 'request' === $context && isset( $hold['Cache-Control'] ) && self::cache_control_already_stronger() ) {
1586 unset( $hold['Cache-Control'] );
1587 }
1588
1589 // A page we refused to cache must not carry a validator either. A
1590 // `Last-Modified` left on it invites a conditional request, and a
1591 // shared cache that gets a 304 back serves the copy it should not
1592 // have stored. Only on a bypass, and only per request: a MISS is
1593 // about to be stored by us, so its validator is ours to keep.
1594 if ( 'request' === $context && 'bypass' === $reason && ! headers_sent() ) {
1595 header_remove( 'Last-Modified' );
1596 }
1597
1598 $hold = self::sanitize_edge_headers( $hold );
1599
1600 // Name the reason in the hold set itself, rather than sending it
1601 // separately from mark().
1602 //
1603 // "Why is my page not being cached at the edge?" is the question this
1604 // answers, and mark() could only answer it on the PHP serve path. The
1605 // other emitters send whatever this function returns and never ran
1606 // mark() at all — so the responses hardest to explain went out
1607 // carrying `no-store` with nothing beside it to say why. Chiefly the
1608 // drop-in, which serves from the `.meta` sidecar written under
1609 // `store` and from the literal baked under `bake`, before plugins
1610 // load and with no way to re-ask (the symptom
1611 // edge_optimization_pending() describes above).
1612 //
1613 // The nginx and Apache blocks are a third path in principle and
1614 // almost never in practice: they are only installed when
1615 // static_rewrite_allowed() is true, and the one reason a stock site
1616 // can hold in their bake is `mobile-split`, which is exactly what
1617 // makes that false. `query-variant` is baked for the drop-in alone,
1618 // since both blocks refuse a query string. They will carry a hold
1619 // where a site forces one through `xspeed_edge_hold_reason`, and
1620 // otherwise have none to carry.
1621 //
1622 // Added AFTER sanitising and banned in NEVER_AN_EDGE_HEADER, so
1623 // neither of the two filters above can forge a reason or suppress the
1624 // real one.
1625 //
1626 // Reduced to the slug CHARACTER CLASS, not checked against the six
1627 // slugs: `xspeed_edge_hold_reason` is documented as able to force a
1628 // reason, and a site that forces its own deserves to see it. What is
1629 // not negotiable is the shape, because this value reaches an
1630 // .htaccess and an nginx conf as well as a response header — so no
1631 // CR/LF, no `$`, no `%`, no `\`, and a length a config file can hold.
1632 $slug = preg_replace( '/[^a-z0-9-]/', '', strtolower( $reason ) );
1633 if ( is_string( $slug ) && '' !== $slug ) {
1634 $hold['X-XSpeed-Edge-Hold'] = substr( $slug, 0, 32 );
1635 }
1636
1637 return $hold;
1638 }
1639
1640 /** Has something already sent a Cache-Control at least as strict as ours? */
1641 private static function cache_control_already_stronger(): bool {
1642 foreach ( headers_list() as $line ) {
1643 if ( 0 !== stripos( $line, 'cache-control:' ) ) {
1644 continue;
1645 }
1646 if ( preg_match( '/\b(?:no-store|private)\b/i', $line ) ) {
1647 return true;
1648 }
1649 }
1650
1651 return false;
1652 }
1653
1654 /**
1655 * Edge headers that belong to THIS page rather than to every page.
1656 *
1657 * `edge_headers_for('HIT','bake')` is the answer frozen into the drop-in
1658 * and the server rules: one set, serving the whole site. But the answer
1659 * for one URL can legitimately differ — a page whose deferred work is
1660 * still outstanding, say — and that answer has nowhere to live, because
1661 * the baked set is all the fast paths know about.
1662 *
1663 * So ask again in a `store` context, with the request still in scope, and
1664 * return the pairs only when they differ from the baked ones. Identical is
1665 * the overwhelmingly common case and writes nothing: pages do not pay a
1666 * sidecar for an answer the drop-in already has.
1667 *
1668 * Memoised because two callers ask within one store — the sidecar writer
1669 * and the static-tree guard — and the filters behind it are not required
1670 * to be cheap.
1671 *
1672 * A page with a lifetime of its own also gets its own pairs, whenever
1673 * they grant an edge any lifetime at all. The lifetime is cut to the
1674 * page's (see cap_edge_lifetime()), and the pairs are returned even when
1675 * the cut leaves them equal to the baked ones. Returning them keeps the
1676 * page off the static tree, and it has to: only the two PHP paths can
1677 * count a lifetime down as the copy ages. The web server sends the baked
1678 * value from whatever age the file has reached, so a page capped to its
1679 * nonce would reach the edge with hours of dead nonce still to serve.
1680 *
1681 * write_meta() resolves that lifetime and passes it on the first call.
1682 * The static-tree guards ask afterwards and read the memo. Both store
1683 * paths call write_meta() before their guard.
1684 *
1685 * @param int|null $own_ttl The entry's own lifetime in seconds, when it
1686 * differs from the site's (the sidecar `ttl`).
1687 * @return array<string,string> Empty when this page needs no override.
1688 */
1689 private static function per_entry_edge_headers( ?int $own_ttl = null ): array {
1690 if ( is_array( self::$per_entry_edge ) ) {
1691 return self::$per_entry_edge;
1692 }
1693 $baked = self::edge_headers_for( 'HIT', 'bake' );
1694 $request = self::edge_headers_for( 'HIT', 'store' );
1695 if ( null !== $own_ttl && self::grants_edge_lifetime( $request ) ) {
1696 self::$per_entry_edge = self::cap_edge_lifetime( $request, $own_ttl );
1697 return self::$per_entry_edge;
1698 }
1699 self::$per_entry_edge = ( $request === $baked ) ? array() : $request;
1700
1701 return self::$per_entry_edge;
1702 }
1703
1704 /**
1705 * Cut every lifetime an edge header grants down to `$seconds`.
1706 *
1707 * The lifetime in a HIT's pairs comes from `xspeed_edge_cache_headers`
1708 * and describes the site, so it says nothing of a page whose own lifetime
1709 * is shorter: one carrying a nonce (#236), one with a per-post expiry,
1710 * one a `xspeed_cache_max_age` filter shortened. xSpeed rebuilds that page
1711 * on time, and an edge holding it for the site's lifetime goes on serving
1712 * the old copy anyway. Nothing tells the edge when our copy expires,
1713 * because an expiry is not a purge. For a nonce page that is a broken
1714 * form for every visitor until the next purge.
1715 *
1716 * Only shortens. A `max-age` or `s-maxage` already under `$seconds` is
1717 * kept, so a hold's `s-maxage=0` stays 0. Only headers named `*-Control`
1718 * are touched; a `Cache-Tag` that happens to contain the text is not a
1719 * directive.
1720 *
1721 * @param array<string,string> $headers Header name => value.
1722 * @param int $seconds The most any of them may grant.
1723 * @return array<string,string>
1724 */
1725 public static function cap_edge_lifetime( array $headers, int $seconds ): array {
1726 $seconds = max( 0, $seconds );
1727 foreach ( $headers as $name => $value ) {
1728 if ( ! preg_match( self::EDGE_LIFETIME_HEADER, (string) $name ) ) {
1729 continue;
1730 }
1731 $capped = preg_replace_callback(
1732 self::EDGE_LIFETIME_DIRECTIVE,
1733 static function ( array $m ) use ( $seconds ): string {
1734 return $m[1] . '=' . min( (int) $m[2], $seconds );
1735 },
1736 (string) $value
1737 );
1738 if ( is_string( $capped ) ) {
1739 $headers[ $name ] = $capped;
1740 }
1741 }
1742
1743 return $headers;
1744 }
1745
1746 /**
1747 * Does any `*-Control` header in the set let an edge keep the response?
1748 *
1749 * @param array<string,string> $headers Header name => value.
1750 */
1751 private static function grants_edge_lifetime( array $headers ): bool {
1752 foreach ( $headers as $name => $value ) {
1753 if ( ! preg_match( self::EDGE_LIFETIME_HEADER, (string) $name ) ) {
1754 continue;
1755 }
1756 if ( preg_match_all( self::EDGE_LIFETIME_DIRECTIVE, (string) $value, $m ) ) {
1757 foreach ( $m[2] as $seconds ) {
1758 if ( (int) $seconds > 0 ) {
1759 return true;
1760 }
1761 }
1762 }
1763 }
1764
1765 return false;
1766 }
1767
1768 /**
1769 * Seconds a cached entry has left, when it has a lifetime of its own.
1770 *
1771 * Read from the sidecar `ttl`, which write_meta() records only when the
1772 * entry's lifetime differs from the site's. That is the drop-in's rule
1773 * too, so both PHP serve paths count down the same entries. Null for an
1774 * entry that follows the site's lifetime: what the edge is told about
1775 * those is the site's lifetime, unchanged.
1776 *
1777 * @param array<string,mixed> $meta The entry's sidecar, from read_meta().
1778 * @param string $file The cached file being served.
1779 */
1780 private static function entry_lifetime_left( array $meta, string $file ): ?int {
1781 $ttl = isset( $meta['ttl'] ) ? (int) $meta['ttl'] : 0;
1782 if ( $ttl < 1 ) {
1783 return null;
1784 }
1785 $mtime = file_exists( $file ) ? filemtime( $file ) : false;
1786 if ( false === $mtime ) {
1787 return null;
1788 }
1789
1790 return max( 0, $ttl - ( time() - (int) $mtime ) );
1791 }
1792
1793 /**
1794 * Render baked pairs as a PHP array literal for the drop-in.
1795 *
1796 * Single-quoted literals with quotes escaped, because the result is
1797 * written into a PHP file that must still parse. Values reaching here
1798 * have already been through sanitize_edge_headers(), so neither name nor
1799 * value can carry a newline.
1800 *
1801 * @param array<string,string> $headers Name => value.
1802 */
1803 private static function edge_headers_literal( array $headers ): string {
1804 if ( array() === $headers ) {
1805 return 'array()';
1806 }
1807 // var_export(), not hand-rolled quoting. A single-quoted PHP string
1808 // escapes BOTH `'` and `\\`, and escaping only the first is how a
1809 // value ending in a backslash — `X-Foo: C:\path\` from the custom
1810 // headers box — leaves the literal unterminated. That file is
1811 // included on every request once WP_CACHE is on, so the result is a
1812 // parse error on the front end AND in wp-admin, with no way back
1813 // except deleting the file over SSH.
1814 $parts = array();
1815 foreach ( $headers as $name => $value ) {
1816 $parts[] = var_export( (string) $name, true ) . ' => ' . var_export( (string) $value, true );
1817 }
1818
1819 return 'array( ' . implode( ', ', $parts ) . ' )';
1820 }
1821
1822 /**
1823 * Quote a header value for an nginx / Apache directive.
1824 *
1825 * Both accept a double-quoted string with backslash escapes, and both
1826 * refuse to load a config where the quoting is wrong — a mis-escaped
1827 * value takes the whole vhost down, not just this header.
1828 */
1829 private static function quote_directive_value( string $value ): string {
1830 return str_replace( array( '\\', '"' ), array( '\\\\', '\\"' ), $value );
1831 }
1832
1833 /**
1834 * The same directive twice — once per name Apache can expose the
1835 * rewrite's environment variable under.
1836 *
1837 * `RewriteRule ... [E=XSPEED_STATIC_HIT:1]` in a per-directory context is
1838 * an INTERNAL REDIRECT: Apache re-enters the request with the substituted
1839 * path, and every variable set on the first pass is renamed with a
1840 * `REDIRECT_` prefix for the second. `env=XSPEED_STATIC_HIT` is evaluated
1841 * on that second pass, where nothing answers to that name any more, so
1842 * the directive never fires — dropping the headers from precisely the
1843 * responses they exist for. The rules marker rides on the same gate, so
1844 * the probe also read its own marker as missing and called correctly
1845 * installed rules stale.
1846 *
1847 * It cannot be written once: `env=` takes a single name with no
1848 * alternation, and `expr=` — which could express both — is not dependable
1849 * on LiteSpeed, which reads this same block. So both are emitted; the one
1850 * whose variable is unset on a given pass does nothing.
1851 *
1852 * @param string $directive The directive, without its `env=` clause.
1853 * @return string[]
1854 */
1855 private static function static_hit_directives( string $directive ): array {
1856 return array(
1857 $directive . ' env=XSPEED_STATIC_HIT',
1858 $directive . ' env=REDIRECT_XSPEED_STATIC_HIT',
1859 );
1860 }
1861
1862 /**
1863 * Keep only pairs that can be sent as a header verbatim.
1864 *
1865 * These values reach three different emitters — PHP's header(), an nginx
1866 * `add_header` and an Apache `Header always set` — so a name with a space
1867 * or a value carrying CR/LF is not merely malformed, it is a
1868 * response-splitting vector in the first and a broken server config in
1869 * the other two. Names must be token-shaped; values lose CR/LF and are
1870 * dropped if nothing survives.
1871 *
1872 * @param array<mixed,mixed> $headers Raw pairs.
1873 * @return array<string,string>
1874 */
1875 public static function sanitize_edge_headers( array $headers ): array {
1876 $clean = array();
1877 foreach ( $headers as $name => $value ) {
1878 // Never let one of these through, whoever asked. They describe the
1879 // transfer rather than the caching policy, and getting one wrong
1880 // from a settings field is a white screen: `Content-Encoding: gzip`
1881 // on an uncompressed body, a `Content-Length` that disagrees with
1882 // the bytes. `X-XSpeed-Cache` is ours and a second copy would lie
1883 // to whoever reads it.
1884 if ( is_string( $name ) && in_array( strtolower( $name ), self::NEVER_AN_EDGE_HEADER, true ) ) {
1885 continue;
1886 }
1887 // `\z`, not `$`: PCRE's `$` also matches immediately BEFORE a
1888 // trailing newline, so "Cache-Tag\n" passes a `$` check and gets
1889 // concatenated raw into the generated .htaccess — splitting one
1890 // Header directive across two lines, which is a syntax error
1891 // Apache reports as a 500 on every request while `httpd -t` stays
1892 // green (.htaccess is parsed per request, not at load).
1893 if ( ! is_string( $name ) || ! preg_match( '/^[A-Za-z0-9-]+\z/', $name ) ) {
1894 continue;
1895 }
1896 if ( ! is_string( $value ) && ! is_numeric( $value ) ) {
1897 continue;
1898 }
1899 $value = trim( str_replace( array( "\r", "\n" ), '', (string) $value ) );
1900 if ( '' === $value ) {
1901 continue;
1902 }
1903 // `$` is a variable reference in an nginx string and `%` is a
1904 // format tag to Apache's mod_headers, which rejects an
1905 // unrecognised one — in .htaccess that is a 500 on every request
1906 // while `httpd -t` still reports OK, because .htaccess is parsed
1907 // per request. `\` escapes the quote in the PHP literal baked into
1908 // the drop-in. None of them can be escaped reliably in all three
1909 // places at once, and nothing a cache reads needs any of them, so
1910 // the value is dropped rather than mangled.
1911 if ( preg_match( '/[$%\\\\]/', $value ) ) {
1912 continue;
1913 }
1914 $clean[ $name ] = $value;
1915 }
1916
1917 return $clean;
1918 }
1919
1920 /**
1921 * Bypass gates that describe THE VISITOR rather than THIS REQUEST.
1922 *
1923 * Only these may be recorded in the bypass cookie. A visitor-scoped
1924 * verdict stays true for the visitor's next request — they are still
1925 * logged in, still hold a cart cookie — so the web server can act on
1926 * it without booting PHP.
1927 *
1928 * Every other gate describes the request in front of us: its method,
1929 * its URL, its query string, the client's user agent. Persisting one
1930 * of those pins a visitor to the uncached path over a property that
1931 * was never theirs to begin with. (#218)
1932 */
1933 private const VISITOR_SCOPED_BYPASS = array( 'logged-in', 'excluded-cookie' );
1934
1935 /**
1936 * Whether $reason describes the visitor (persist it) or merely this
1937 * request (don't).
1938 *
1939 * Split out as a pure function because it is the whole decision behind
1940 * the bypass cookie, and the cookie write itself (setcookie()) can't be
1941 * asserted in a unit test.
1942 */
1943 public static function bypass_is_visitor_scoped( string $reason ): bool {
1944 return in_array( $reason, self::VISITOR_SCOPED_BYPASS, true );
1945 }
1946
1947 public function maybe_start_cache() {
1948 if ( ! self::should_cache() ) {
1949 // PHP has just evaluated the FULL exclusion rule list — including
1950 // the `~regex` patterns the server config can't express — and
1951 // decided this response must not be served from cache. Record that
1952 // verdict in the conventional bypass cookie so the web server can
1953 // enforce it on subsequent requests without starting PHP.
1954 //
1955 // This is what stops most settings changes from needing an nginx
1956 // reload: the config tests one fixed cookie name forever, and the
1957 // rule list behind it can change freely.
1958 //
1959 // But ONLY when the verdict is about the visitor. A request-shape
1960 // gate — `non-get` above all — says nothing about who is asking,
1961 // and persisting it pinned that visitor to the uncached path for
1962 // the rest of their session: one search-form POST, one comment,
1963 // one `curl -I` from an uptime monitor, and every later GET
1964 // bypassed. It could not self-heal either, because the bypass
1965 // cookie is itself in excluded_cookies, so the next GET bypassed
1966 // with `excluded-cookie` and landed right back here, where
1967 // sync_bypass_cookie()'s no-change short-circuit left the cookie
1968 // exactly where it was. (#218)
1969 if ( self::bypass_is_visitor_scoped( self::bypass_reason() ) ) {
1970 self::sync_bypass_cookie( true );
1971 }
1972 return;
1973 }
1974
1975 // Cacheable: clear any stale bypass cookie, or a visitor who once
1976 // had a cart would keep skipping the fast path long after checkout.
1977 self::sync_bypass_cookie( false );
1978
1979 $key = self::cache_key();
1980 $file = self::cache_file_for( $key );
1981
1982 if ( file_exists( $file ) && ! self::is_expired( $file ) ) {
1983 // Symmetric with the miss branch below: a bot, scanner or one of
1984 // xSpeed's own warm/benchmark requests that lands a HIT must not
1985 // inflate the ratio either — excluding only their misses would
1986 // shrink the denominator while their hits kept feeding the
1987 // numerator, making the displayed ratio MORE optimistic than
1988 // before the exclusion existed.
1989 if ( self::miss_is_excluded() ) {
1990 Hit_Counter::record_excluded();
1991 } else {
1992 Hit_Counter::record_hit();
1993 }
1994 // Emit the HIT marker on THIS path too. The drop-in
1995 // (advanced-cache.php) sends "HIT (php)" and the nginx static
1996 // rewrite sends "HIT (nginx)", but this template_redirect
1997 // serve path — the one that runs when the drop-in isn't loaded
1998 // (e.g. WP_CACHE not true) — previously streamed the cached
1999 // file with NO marker, so a genuine HIT looked like a MISS in
2000 // the response headers. Same header + value as the drop-in.
2001 //
2002 // The sidecar is read first because the HIT's edge lifetime
2003 // depends on it: an entry with a lifetime of its own may not be
2004 // kept at the edge past it. See entry_lifetime_left().
2005 $meta = self::read_meta( $key );
2006 self::mark( 'HIT (php)', '', self::entry_lifetime_left( $meta, $file ) );
2007 // Replay stored response bits so the HIT matches the original:
2008 // a non-HTML Content-Type (cached feeds, sitemaps) and a non-200
2009 // status (a cached 404 must serve 404, not 200). No-op for
2010 // ordinary pages, which write no .meta.
2011 if ( ! headers_sent() ) {
2012 if ( ! empty( $meta['status'] ) && function_exists( 'http_response_code' ) ) {
2013 http_response_code( (int) $meta['status'] );
2014 }
2015 if ( ! empty( $meta['content_type'] ) && is_string( $meta['content_type'] ) ) {
2016 header( 'Content-Type: ' . $meta['content_type'] );
2017 }
2018 // Conditional GET: emit Last-Modified + ETag and answer a
2019 // matching If-Modified-Since / If-None-Match with 304 so
2020 // aggregators (and browsers) skip re-downloading an unchanged
2021 // cached response — the bandwidth win feeds are about.
2022 // (FBS-82407 #5)
2023 if ( self::serve_not_modified( $file ) ) {
2024 exit; // 304 sent, no body.
2025 }
2026 }
2027 // Serve the precompressed Brotli sibling when the client accepts
2028 // it (an add-on, the Pro Brotli module, wrote <file>.br). On this
2029 // PHP serve path the web server never sees the .br, so without
2030 // this a br-capable client got the plain .html — precompression
2031 // did nothing here. Falls through to plain readfile otherwise.
2032 $br = self::maybe_serve_brotli( $file );
2033 if ( null !== $br ) {
2034 // 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.
2035 readfile( $br );
2036 exit;
2037 }
2038 // 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.
2039 readfile( $file );
2040 exit;
2041 }
2042
2043 // Cache miss → render fresh + write cache. On LiteSpeed we send an
2044 // explicit "stand down" header so the server's LSCache module does
2045 // NOT cache + shadow our response — xSpeed's own .htaccess static
2046 // rewrite owns hit serving (and hit accounting) here, exactly as on
2047 // Apache. See maybe_emit_lscache_headers() for the full rationale.
2048 self::maybe_emit_lscache_headers();
2049
2050 // We're about to render fresh + cache → miss for this request.
2051 // …UNLESS this request is a 404 or a known bot/scanner. Those reach the
2052 // render path too, but counting them as cache misses makes the ratio
2053 // meaningless — a wave of `/wp-x7.php` scanner 404s reads as a collapsing
2054 // cache when nothing is wrong. Runs at template_redirect (priority 0), so
2055 // is_404() is already resolved. Excluded requests are tallied separately
2056 // for the "you absorbed N scanner hits" line, not dropped. (#118)
2057 if ( self::miss_is_excluded() ) {
2058 Hit_Counter::record_excluded();
2059 } else {
2060 Hit_Counter::record_miss();
2061 }
2062
2063 // Stamp it, so "eligible but not cached yet" is visibly different
2064 // from "deliberately bypassed" (issue #10). Headers can't be sent
2065 // after the body starts, so this has to happen here, not in
2066 // finalize_buffer() — nothing has been output at template_redirect.
2067 self::mark( 'MISS' );
2068
2069
2070 // WP < 6.9 fallback: ob_start() with a callback, paired with an
2071 // explicit shutdown close so the buffer lifecycle is visible to
2072 // reviewers and Plugin Check, instead of relying on PHP's implicit
2073 // request-end flush. We record our nesting level so close_buffer()
2074 // flushes ONLY the buffer we opened.
2075 self::$asset_stamp_at_open = class_exists( '\\XSpeed\\Minifier' ) ? Minifier::purge_stamp() : null;
2076 ob_start( array( __CLASS__, 'finalize_buffer' ) );
2077 self::$buffer_level = ob_get_level();
2078
2079 add_action( 'shutdown', array( __CLASS__, 'close_buffer' ), 0 );
2080 }
2081
2082 /**
2083 * Close the cache buffer opened by maybe_start_cache().
2084 *
2085 * Guarded by the recorded buffer level so we never flush a buffer that
2086 * another plugin pushed on top of (or under) ours. If something else is
2087 * currently on top, we leave the stack alone — PHP's shutdown sequence
2088 * will unwind buffers in order and our finalize_buffer() callback will
2089 * still run when our level becomes the topmost one.
2090 */
2091 public static function close_buffer() {
2092 if ( null === self::$buffer_level ) {
2093 return;
2094 }
2095 if ( ob_get_level() === self::$buffer_level ) {
2096 ob_end_flush();
2097 }
2098 self::$buffer_level = null;
2099 }
2100
2101 /**
2102 * Are we buffering this request?
2103 *
2104 * Asked by Css_Combine_Buffer, which needs the finished HTML but must not
2105 * open a second buffer when this one is already going to hand it the page
2106 * through `xspeed_cache_final_html`. False here means the request is not
2107 * cacheable — cache off, excluded URL, logged in — and the combiner has to
2108 * provide its own buffer or it silently stops working. (#195)
2109 */
2110 public static function is_buffering(): bool {
2111 return null !== self::$buffer_level;
2112 }
2113
2114 /**
2115 * Is a render-time translation plugin going to wrap our output buffer?
2116 *
2117 * TranslatePress opens its translation buffer on `init` priority 0. We
2118 * open ours on `template_redirect`, which runs much later, so ours nests
2119 * INSIDE theirs. PHP unwinds output buffers LIFO — innermost callback
2120 * first — so `finalize_buffer()` saw the raw, pre-translation HTML and
2121 * cached that, while the live visitor still got the translated bytes from
2122 * TRP's outer buffer.
2123 *
2124 * Result: the first (MISS) visitor to /fr/some-page/ got correct French;
2125 * every visitor after got English body text under a `lang="fr-FR"`
2126 * document, plus TRP's internal `#TRPLINKPROCESSED` link markers, which
2127 * TRP strips at the very end of its own buffer and which therefore leak
2128 * into anything captured from inside it.
2129 *
2130 * Note the ordering cannot be fixed from TRP's side: its
2131 * `trp_start_output_buffer_priority` filter only moves the PRIORITY on
2132 * `init`, and `init` always fires before `template_redirect` whatever the
2133 * priority. The buffer that has to move is ours.
2134 *
2135 * Detected by main class rather than plugin path, so a renamed directory
2136 * or a bundled copy still matches.
2137 */
2138 public static function translation_plugin_active(): bool {
2139 $active = class_exists( 'TRP_Translate_Press' );
2140
2141 /**
2142 * Whether to treat this request as wrapped by a translation buffer.
2143 *
2144 * Lets a site add another render-time translation plugin (or opt out)
2145 * without patching the engine.
2146 *
2147 * @param bool $active
2148 */
2149 return (bool) apply_filters( 'xspeed_translation_plugin_active', $active );
2150 }
2151
2152 /**
2153 * Write the cache file for a request whose output was wrapped by a
2154 * render-time translation plugin.
2155 *
2156 * Registered as a PHP shutdown function (not a WP `shutdown` action) so
2157 * it runs after PHP has unwound the output-buffer stack — by which point
2158 * the translation plugin's callback has transformed the bytes and its
2159 * internal markers are gone.
2160 *
2161 * finalize_buffer() has already applied the status gate, the
2162 * xspeed_cache_final_html filter and HTML minification to the
2163 * untranslated copy and then declined to write it. Here we re-run only
2164 * what's needed on the translated bytes: minify, write, and fire the
2165 * same downstream hooks so Brotli / static-tree listeners behave
2166 * identically to the ordinary path.
2167 */
2168 public static function write_deferred_translated_cache(): void {
2169 $key = self::$deferred_key;
2170 self::$deferred_key = null;
2171
2172 // Release the collected bytes BEFORE the early return, so the static
2173 // is cleared on every path rather than only when a key survived.
2174 $full = self::$translated_output;
2175 self::$translated_output = '';
2176
2177 $completed = self::$render_completed;
2178 self::$render_completed = false;
2179
2180 $asset_stamp = self::$deferred_asset_stamp;
2181 self::$deferred_asset_stamp = null;
2182
2183 if ( null === $key ) {
2184 return;
2185 }
2186
2187 // Did the render actually finish?
2188 //
2189 // This runs as a PHP shutdown function, which fires after a wp_die()
2190 // or a bare exit() just as readily as after a clean render — but in
2191 // those cases finalize_buffer() never returned, so the bytes we hold
2192 // are a page that was cut off partway through. The length and
2193 // TRPLINKPROCESSED checks below don't catch that: a fatal after the
2194 // footer's translated markup is both over 255 bytes and free of TRP
2195 // markers, i.e. truncated but entirely plausible. Caching it would
2196 // freeze a half-rendered page under the real key for the full TTL.
2197 //
2198 // Serving this one URL uncached is the cheap failure; the corrupt
2199 // cache entry is the expensive one.
2200 if ( ! $completed ) {
2201 return;
2202 }
2203
2204 if ( strlen( $full ) < 255 ) {
2205 return;
2206 }
2207
2208 // Refuse to cache a copy still carrying the translation plugin's
2209 // internal link markers. TRP strips these at the very end of its own
2210 // buffer, so their presence means we captured too early — and a
2211 // cached page containing them is SEO-visible damage. Better to serve
2212 // this URL uncached than to freeze broken markup for the full TTL.
2213 if ( false !== strpos( $full, 'TRPLINKPROCESSED' ) ) {
2214 return;
2215 }
2216
2217 $minify_opts = Settings_Manager::get( 'minify' );
2218 if ( ! empty( $minify_opts['minify_html'] ) ) {
2219 $full = Minifier::minify_html( $full );
2220 }
2221 $full = self::signed( $full );
2222
2223 // Per-site directory: on multisite every blog shares this tree, so
2224 // entries are bucketed by host to keep one site's purge from
2225 // sweeping the whole network. (#6)
2226 self::ensure_host_dir();
2227
2228 // Never author a cache entry from a request that carried a query
2229 // string: cache_key() files it under the BARE url, so the params'
2230 // render would be served to every clean-URL visitor (#241).
2231 if ( self::query_string_blocks_write() ) {
2232 return;
2233 }
2234
2235 // Same guard as finalize_buffer(): the translation plugin's buffer
2236 // adds time between render and store, not less.
2237 if ( self::assets_purged_since( $asset_stamp ) ) {
2238 return;
2239 }
2240
2241 $file = self::cache_file_for( $key );
2242 // 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.
2243 file_put_contents( $file, $full, LOCK_EX );
2244
2245 /** This action is documented in includes/class-cache.php */
2246 do_action( 'xspeed_flat_file_written', $file, $full );
2247
2248 self::write_meta( $key, $full );
2249
2250 // Static tree too, under the same gates finalize_buffer() applies —
2251 // otherwise deferring the write would silently cost translated pages
2252 // the web-server fast path and leave them on the slower drop-in.
2253 // The static tree cannot replay a sidecar. A file served straight by
2254 // the web server carries the headers baked into the rule that serves
2255 // the whole site — the very answer this entry exists because it
2256 // disagreed with. Same reasoning as the status and content-type
2257 // cases: what the fast path cannot replay belongs on the drop-in path.
2258 if ( self::static_rewrite_allowed()
2259 && self::response_is_plain_html()
2260 && array() === self::per_entry_edge_headers() ) {
2261 self::store_static( $full );
2262 }
2263 }
2264
2265 /**
2266 * Did a purge delete minified or combined files after $stamp was taken?
2267 *
2268 * @param string|null $stamp Minifier::purge_stamp() when the render began,
2269 * or null when no snapshot was taken.
2270 */
2271 private static function assets_purged_since( ?string $stamp ): bool {
2272 if ( null === $stamp || ! class_exists( '\\XSpeed\\Minifier' ) ) {
2273 return false;
2274 }
2275 return Minifier::purge_stamp() !== $stamp;
2276 }
2277
2278 public static function should_cache() {
2279 // Reset first: a single request only reaches this once (the sole
2280 // caller is maybe_start_cache()), but tests and any future caller
2281 // must never inherit the previous request's verdict.
2282 self::$status_header = '';
2283 self::$bypass_reason = '';
2284 self::$edge_headers = array();
2285 self::$per_entry_edge = null;
2286 // Under PHP-FPM a process serves one request and this is moot. Under
2287 // a persistent worker runtime it is not: without it, an answer
2288 // resolved from one visitor's forgeable headers would be reused for
2289 // every later request the worker handles.
2290 Edge_Provider::forget();
2291
2292 $opts = Settings::get();
2293 if ( empty( $opts['cache_enabled'] ) ) {
2294 return self::bypass( 'cache-disabled' );
2295 }
2296
2297 if ( is_user_logged_in() ) {
2298 return self::bypass( 'logged-in' );
2299 }
2300
2301 if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
2302 return self::bypass( 'non-frontend' );
2303 }
2304
2305 if ( defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE ) {
2306 return self::bypass( 'donotcachepage' );
2307 }
2308
2309 // All exclusion knobs now owned by CacheModule.
2310 $cache_opts = Settings_Manager::get( 'cache' );
2311
2312 $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : '';
2313 if ( 'GET' !== $method ) {
2314 return self::bypass( 'non-get' );
2315 }
2316
2317 // Search-results requests carry a `s` query param, which the
2318 // query-string gate below would normally reject as "dynamic". An
2319 // add-on (xspeed-pro search cache) can opt them in: when this is a
2320 // genuine is_search() and the filter returns true, the `s` param is
2321 // treated as cacheable (the search term goes into the cache key so
2322 // different searches stay distinct — see cache_key()).
2323 $cache_search = self::should_cache_search();
2324
2325 // Feed opt-in is resolved BEFORE the query-string gate so query-form
2326 // feeds (/?feed=rss2, used on plain-permalink sites) aren't rejected
2327 // as "dynamic" by that gate — the `feed` param is then allowed through
2328 // just like the search `s` param. Feeds are excluded by default (the
2329 // `/feed/` pattern in excluded_urls); an add-on (xspeed-pro feed cache)
2330 // opts them back in via the filter. (FBS-82407 #4)
2331 $is_feed_request = function_exists( 'is_feed' ) && is_feed();
2332 /**
2333 * Whether to cache the current feed request.
2334 *
2335 * Default false → feeds fall through to the normal URL-exclusion
2336 * rules (so `/feed/` keeps them out). A listener returning true
2337 * opts this feed request into caching.
2338 *
2339 * @param bool $cache_feed Whether to cache this feed request.
2340 */
2341 $cache_feed = $is_feed_request && (bool) apply_filters( 'xspeed_should_cache_feed', false );
2342
2343 // WordPress's virtual robots.txt (and virtual favicon) are not HTML:
2344 // caching one runs it through the whole HTML pipeline, which stamped
2345 // the footer comment onto text/plain and let HTML minification
2346 // collapse robots.txt to a single line — a line-based format, so
2347 // every directive after the first was lost and crawlers read an
2348 // invalid file. No opt-in filter here: there is no correct way to
2349 // treat these as pages. (Reported live on a customer site.)
2350 if ( ( function_exists( 'is_robots' ) && is_robots() )
2351 || ( function_exists( 'is_favicon' ) && is_favicon() ) ) {
2352 return self::bypass( 'non-html' );
2353 }
2354
2355 // Query string handling: anything OUTSIDE the ignored-params
2356 // allow-list (utm_*, fbclid, gclid by default) means a unique
2357 // request that we don't want to share with the canonical cache
2358 // entry. Skip cache rather than poison the key.
2359 //
2360 // Parse the RAW query string, NOT a sanitize_text_field() copy:
2361 // that filter strips percent-encoded octets (%XX), so `?%73=…`
2362 // would lose its `s` key here while WordPress still decodes it to
2363 // a search request — the gate would wave the request through and
2364 // cache_key() would file the search page under the bare URL,
2365 // letting an attacker poison the homepage cache with `/?%73=<spam>`.
2366 // parse_str() does its own urldecoding, matching WP's own parse, and
2367 // only the KEYS are used below (fed to Glob_Matcher → preg_match,
2368 // never echoed or executed), so no sanitization is needed here.
2369 $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.
2370 if ( '' !== $query_raw ) {
2371 $ignored = is_array( $cache_opts['ignored_query_params'] ?? null ) ? $cache_opts['ignored_query_params'] : array();
2372 parse_str( $query_raw, $params );
2373 foreach ( $params as $key => $_ ) {
2374 // Allow the search param through when search caching is on.
2375 if ( $cache_search && 's' === $key ) {
2376 continue;
2377 }
2378 // Allow query-form feed params through when feed caching opted
2379 // this request in (?feed=rss2 / &withcomments=1 on feeds).
2380 if ( $cache_feed && in_array( $key, self::FEED_QUERY_PARAMS, true ) ) {
2381 continue;
2382 }
2383 if ( ! self::query_key_is_ignored( (string) $key, $ignored ) ) {
2384 // Slug only — never the param name, which is attacker-
2385 // controlled and would be reflected into a header.
2386 return self::bypass( 'query-param' );
2387 }
2388 }
2389 }
2390
2391 $path = self::request_path();
2392
2393 $excluded_urls = is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array();
2394 if ( ! $cache_feed && self::path_matches_exclusions( $excluded_urls, $path ) ) {
2395 return self::bypass( 'excluded-url' );
2396 }
2397
2398 // Cookie-based exclusion. We only check cookie NAMES (matching
2399 // values would leak content-sensitive logic into the cache key
2400 // rules); presence of any matching cookie name skips cache.
2401 $excluded_cookies = is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array();
2402 if ( ! empty( $excluded_cookies ) && ! empty( $_COOKIE ) ) {
2403 foreach ( array_keys( $_COOKIE ) as $cookie_name ) {
2404 // Our own bypass cookie is a RECORD of a previous verdict, not
2405 // evidence about this visitor, so it never gets a vote here.
2406 // Letting it match made the verdict self-confirming: once set,
2407 // it produced `excluded-cookie` forever, which re-set it, and
2408 // no later request could ever re-evaluate the visitor on the
2409 // rules that actually describe them. The web server still acts
2410 // on the cookie without booting PHP; when PHP does boot it is
2411 // authoritative and re-decides from scratch. (#218)
2412 if ( Server_Rules::BYPASS_COOKIE === $cookie_name ) {
2413 continue;
2414 }
2415 if ( Glob_Matcher::any_match( $excluded_cookies, (string) $cookie_name ) ) {
2416 return self::bypass( 'excluded-cookie' );
2417 }
2418 }
2419 }
2420
2421 // User-agent bypass list. Substring match (not glob) since UA
2422 // strings have so much variation that glob anchoring rarely
2423 // helps and confuses users.
2424 $bypass_uas = is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array();
2425 if ( ! empty( $bypass_uas ) ) {
2426 $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
2427 foreach ( $bypass_uas as $needle ) {
2428 if ( '' !== $needle && false !== stripos( $ua, (string) $needle ) ) {
2429 return self::bypass( 'user-agent' );
2430 }
2431 }
2432 }
2433
2434 // Per-post override (Phase 3.4). Honored only on singular
2435 // post-context requests — archives / 404s / taxonomies use the
2436 // global policy above.
2437 if ( Cache_Rules::should_skip_for_post( Cache_Rules::current_post_id() ) ) {
2438 return self::bypass( 'post-excluded' );
2439 }
2440
2441 /**
2442 * Final say on whether the current request is cacheable.
2443 *
2444 * Runs at template_redirect (full WP context), so listeners may use
2445 * conditional tags (is_search(), is_feed(), is_404(),
2446 * wp_is_maintenance_mode(), …). The core engine has already applied
2447 * its own exclusion rules and reached `true`; a listener returning
2448 * false vetoes caching for this request. This is the documented
2449 * extension point add-ons (xspeed-pro) hook to add their own
2450 * request-level cache policy without forking the engine.
2451 *
2452 * Note: this gates the WRITE side. The pre-WP drop-in
2453 * (advanced-cache.php) cannot run PHP filters, so request types that
2454 * must never be *served* from a stale file are handled by not
2455 * writing them here and/or by purging — see the conflict notes in
2456 * advanced-cache.php.
2457 *
2458 * @param bool $should_cache Whether to cache the current request.
2459 */
2460 if ( ! apply_filters( 'xspeed_should_cache', true ) ) {
2461 // One slug for every listener — a third-party callback name is
2462 // not ours to put in a response header. Which listener vetoed is
2463 // a WP_DEBUG-level question the filter itself can answer.
2464 return self::bypass( 'filtered' );
2465 }
2466
2467 return true;
2468 }
2469
2470 /**
2471 * Whether the current request is a 404 we may cache.
2472 *
2473 * True only when: it's a genuine main-query is_404(), an add-on opted
2474 * in via `xspeed_should_cache_404` (default false), and the request
2475 * isn't a transient 404 we must never freeze — maintenance mode or a
2476 * 404 emitted while the DB/site is in an error state. The xspeed-pro
2477 * 404 cache flips the filter; Free never caches 404s on its own.
2478 */
2479 public static function should_cache_404(): bool {
2480 if ( ! function_exists( 'is_404' ) || ! is_404() ) {
2481 return false;
2482 }
2483 // Never cache a 404 served because the site is down for
2484 // maintenance — that screen disappears the moment maintenance
2485 // ends, and a cached copy would outlive it.
2486 if ( function_exists( 'wp_is_maintenance_mode' ) && wp_is_maintenance_mode() ) {
2487 return false;
2488 }
2489
2490 /**
2491 * Whether to cache the current 404 response.
2492 *
2493 * Default false. A listener returning true opts the (genuine)
2494 * 404 into the page cache, served back for any unknown URL under
2495 * one generic key. The 404 status is preserved on the HIT.
2496 *
2497 * @param bool $cache_404 Whether to cache this 404.
2498 */
2499 return (bool) apply_filters( 'xspeed_should_cache_404', false );
2500 }
2501
2502 /**
2503 * Whether the current request is an internal search-results page we
2504 * may cache.
2505 *
2506 * True only when: it's a genuine main-query is_search() with a
2507 * non-empty term, and an add-on opted in via `xspeed_should_cache_search`
2508 * (default false). The search term is folded into the cache key (see
2509 * search_term() / cache_key()) so different searches stay distinct.
2510 * The xspeed-pro search cache flips the filter; Free never caches
2511 * search results on its own.
2512 */
2513 /**
2514 * Whether this response was rendered for a query string and therefore
2515 * must not be STORED under the bare-URL key.
2516 *
2517 * should_cache() lets a request through when every key is on the
2518 * `ignored_query_params` allow-list, and cache_key() then drops the
2519 * query string so `/post` and `/post?utm_source=x` share one entry.
2520 * Sharing on READ is the point of the allow-list and stays. Sharing on
2521 * WRITE is a cache-poisoning vector: the response was rendered *with*
2522 * those params, and WordPress reflects REQUEST_URI into form actions,
2523 * share links, canonical helpers and plugin smart tags. One anonymous
2524 * GET to a cold URL therefore freezes an attacker-chosen variant under
2525 * the clean URL's key, served for the whole TTL by the drop-in and by
2526 * the web server — neither of which runs these checks (issue #241).
2527 *
2528 * The allow-list keeps its benefit: a visitor arriving on
2529 * `?utm_source=…` is still SERVED the canonical cached entry. Only the
2530 * write is skipped, so the entry is authored by a clean request.
2531 *
2532 * This is the same reasoning as the `should_cache_search()` guard in
2533 * store_static() (#191), generalised to the allow-listed params.
2534 */
2535 public static function request_has_query_string(): bool {
2536 $query = isset( $_SERVER['QUERY_STRING'] )
2537 ? (string) wp_unslash( $_SERVER['QUERY_STRING'] ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- only tested for emptiness; never echoed, stored or used as a path.
2538 : '';
2539
2540 return '' !== trim( $query );
2541 }
2542
2543 /**
2544 * Whether this request's query string is empty or holds only params the
2545 * cache ignores (`ignored_query_params`, utm_* and the like), so the
2546 * page is the one the bare URL serves.
2547 *
2548 * The same test should_cache() applies, without its search and feed
2549 * exceptions: those are keyed apart from the bare URL.
2550 */
2551 public static function query_has_only_ignored_params(): bool {
2552 $query_raw = isset( $_SERVER['QUERY_STRING'] ) ? wp_unslash( $_SERVER['QUERY_STRING'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- as in should_cache(): only keys are read, via preg_match.
2553 if ( '' === trim( (string) $query_raw ) ) {
2554 return true;
2555 }
2556 $cache_opts = Settings_Manager::get( 'cache' );
2557 $ignored = is_array( $cache_opts['ignored_query_params'] ?? null ) ? $cache_opts['ignored_query_params'] : array();
2558 parse_str( (string) $query_raw, $params );
2559 foreach ( $params as $key => $_ ) {
2560 if ( ! self::query_key_is_ignored( (string) $key, $ignored ) ) {
2561 return false;
2562 }
2563 }
2564 return true;
2565 }
2566
2567 /**
2568 * Would authoring a cache entry from THIS request file a query-string
2569 * render under the bare URL?
2570 *
2571 * The one predicate both write sites ask, so they cannot drift.
2572 *
2573 * Two shapes are exempt because cache_key() does NOT drop their query —
2574 * it folds the distinguishing part into the key, so each variant gets
2575 * its own entry and none is filed under the bare URL:
2576 *
2577 * - searches, keyed by `|s=<term>` (#191)
2578 * - feeds, keyed by `|feed=<type>` — `/?feed=rss2` is the ONLY feed URL
2579 * core generates on plain permalinks, so treating it as poisonable
2580 * made feed caching a no-op on exactly the sites that need it
2581 *
2582 * @return bool True when the write must be skipped.
2583 */
2584 public static function query_string_blocks_write(): bool {
2585 if ( ! self::request_has_query_string() ) {
2586 return false;
2587 }
2588
2589 if ( self::should_cache_search() ) {
2590 return false;
2591 }
2592
2593 // Feed caching is opt-in, via the same filter should_cache() reads
2594 // to admit the feed params in the first place.
2595 if ( function_exists( 'is_feed' ) && is_feed()
2596 && (bool) apply_filters( 'xspeed_should_cache_feed', false )
2597 ) {
2598 return false;
2599 }
2600
2601 return true;
2602 }
2603
2604 public static function should_cache_search(): bool {
2605 if ( ! function_exists( 'is_search' ) || ! is_search() ) {
2606 return false;
2607 }
2608 // Empty search (`?s=`) renders the same as a normal archive and
2609 // carries no term to key on — let it fall through to the usual
2610 // rules rather than caching an ambiguous entry.
2611 if ( '' === self::search_term() ) {
2612 return false;
2613 }
2614
2615 /**
2616 * Whether to cache the current search-results request.
2617 *
2618 * Default false. A listener returning true opts the search page
2619 * into the cache, keyed by the normalized search term.
2620 *
2621 * @param bool $cache_search Whether to cache this search request.
2622 */
2623 return (bool) apply_filters( 'xspeed_should_cache_search', false );
2624 }
2625
2626 /**
2627 * The current request's normalized search term, or '' if none. Reads
2628 * the raw `s` query param (works on the pre-WP drop-in path too, where
2629 * get_search_query() isn't available), trims + lowercases so
2630 * "WordPress" and "wordpress" share one entry, and collapses internal
2631 * whitespace.
2632 */
2633 public static function search_term(): string {
2634 $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.
2635 $raw = trim( $raw );
2636 if ( '' === $raw ) {
2637 return '';
2638 }
2639 $raw = preg_replace( '/\s+/', ' ', $raw );
2640 return function_exists( 'mb_strtolower' ) ? mb_strtolower( $raw ) : strtolower( $raw );
2641 }
2642
2643 /**
2644 * Is this query-string key on the ignored-params allow-list? Supports
2645 * globs (`utm_*` matches `utm_source`, `utm_medium`, etc.) so users
2646 * don't have to enumerate every UTM variant, and `~regex`.
2647 *
2648 * Matching is whole-name, not "contains" — a param name is an
2649 * identifier, not a path. Under the old contains match the shipped
2650 * default `ref` also swallowed `preference`, `product_ref` and
2651 * `referrer`: those params were dropped from the cache key, so
2652 * `/shop?preference=1` was served — and, on a cold entry, WRITTEN as —
2653 * `/shop`. Same for `_ga` vs `_gallery`, and for the unanchored
2654 * `~utm_…` default vs `my_utm_source`. A param name that is genuinely
2655 * unknown now bypasses the cache, which is the safe direction.
2656 */
2657 private static function query_key_is_ignored( string $key, array $ignored ): bool {
2658 if ( in_array( $key, self::NEVER_IGNORED_QUERY_PARAMS, true ) ) {
2659 return false;
2660 }
2661 return Glob_Matcher::any_match_name( $ignored, $key );
2662 }
2663
2664 /**
2665 * Query params no ignored-params entry can match, glob or regex.
2666 *
2667 * A measuring request asks for the page as it is before optimisation
2668 * (`xspeed_css=off`), with a one-time value (`xspeed_nc`) so no cache
2669 * has a copy of it. The answer must be rendered for that request. A
2670 * list entry such as `xspeed_*` or `*` would make both params
2671 * decoration: the drop-in would serve the canonical, already-optimised
2672 * entry before any plugin loads, and the measurement would describe
2673 * the optimised page instead of the source.
2674 */
2675 public const NEVER_IGNORED_QUERY_PARAMS = array( 'xspeed_css', 'xspeed_nc' );
2676
2677 /**
2678 * The params a query-form feed may carry (`/?feed=rss2`) once feed
2679 * caching has opted the request in. should_cache() lets them through,
2680 * and serves_query_variant() holds a HIT that carries one.
2681 */
2682 private const FEED_QUERY_PARAMS = array( 'feed', 'withcomments', 'withoutcomments' );
2683
2684 /**
2685 * The one spelling of a URL path that every cache key is built from.
2686 *
2687 * WordPress stores a non-ASCII slug percent-encoded, so a page called
2688 * `關於我們` is requested as `/%e9%97%9c%e6%96%bc%e6%88%91%e5%80%91/`. The
2689 * same page also arrives as `/%E9%97%9C…/` (what a browser sends for an
2690 * address typed or pasted into it) and as raw UTF-8 bytes. WordPress
2691 * renders the same page for all three, so they get one key: escapes are
2692 * lowercased (WordPress's own spelling, so a permalink comes back
2693 * unchanged) and any byte outside printable ASCII is escaped the same way.
2694 * Null bytes are dropped, as the drop-in does.
2695 *
2696 * Nothing is decoded. `/ab%6Fut/` stays a separate entry from `/about/`.
2697 * Only spellings of the same bytes are merged, because merging spellings
2698 * WordPress may route differently would let one page be served for
2699 * another. A printable-ASCII path with no escapes comes back unchanged,
2700 * so ordinary pages keep the keys they had.
2701 *
2702 * The path used to go through sanitize_text_field(), which deletes every
2703 * `%XX` octet: `/第九屆-當代藝術-tagboat-award-特展/` was keyed as
2704 * `/--tagboat-award-/`, every page whose slug differed only in non-ASCII
2705 * characters shared one entry, and an all-non-ASCII slug became `//`,
2706 * which store_static() wrote over the home page's static file.
2707 *
2708 * The drop-in carries a copy of this transform, because it runs before
2709 * WordPress and this class load. Change both together.
2710 *
2711 * @param string $path A URL path, without the query string.
2712 */
2713 public static function normalize_path( string $path ): string {
2714 return self::path_with_escape_case( $path, false );
2715 }
2716
2717 /**
2718 * The spellings of one path that a cache in front of the site may hold.
2719 *
2720 * xSpeed keys every escape spelling of a path as one entry
2721 * (normalize_path()). A CDN does not: Cloudflare keeps `/%E9%97%9C…/`,
2722 * the spelling a browser sends, apart from `/%e9%97%9c…/`, the spelling
2723 * of WordPress's own links. A purge that named one left the other
2724 * serving the old page until its edge lifetime ran out.
2725 *
2726 * Returns the path as given, then normalize_path()'s spelling (lower-case
2727 * escapes), then the same with upper-case escapes. Both are built from
2728 * the path the local sweep cleared, so a cache in front is never asked
2729 * about a page xSpeed did not purge. Duplicates drop out, so a path with
2730 * no escapes has one spelling. Trailing-slash forms are the caller's job.
2731 *
2732 * @param string $path A URL path, without the query string.
2733 * @return string[]
2734 */
2735 public static function escape_spellings( string $path ): array {
2736 return array_values(
2737 array_unique(
2738 array( $path, self::path_with_escape_case( $path, false ), self::path_with_escape_case( $path, true ) )
2739 )
2740 );
2741 }
2742
2743 /**
2744 * normalize_path() with the escapes in the case asked for.
2745 *
2746 * @param string $path A URL path, without the query string.
2747 * @param bool $upper Upper-case escapes, or lower (the key's spelling).
2748 */
2749 private static function path_with_escape_case( string $path, bool $upper ): string {
2750 return (string) preg_replace_callback(
2751 '/%[0-9a-fA-F]{2}|[^\x21-\x7E]/',
2752 static function ( array $m ) use ( $upper ): string {
2753 $escape = '%' === $m[0][0] ? $m[0] : sprintf( '%%%02x', ord( $m[0] ) );
2754 return $upper ? strtoupper( $escape ) : strtolower( $escape );
2755 },
2756 str_replace( "\0", '', $path )
2757 );
2758 }
2759
2760 /**
2761 * Percent-encode every byte 0x80 and up, lowercase hex.
2762 *
2763 * @param string $value A path or a whole URL.
2764 */
2765 private static function encode_non_ascii( string $value ): string {
2766 return (string) preg_replace_callback(
2767 '/[\x80-\xff]/',
2768 static function ( array $m ): string {
2769 return '%' . bin2hex( $m[0] );
2770 },
2771 $value
2772 );
2773 }
2774
2775 /**
2776 * Where a URL path lives in the static tree, relative to the host
2777 * directory, with a leading slash and no trailing one ('' for the home
2778 * page). Null when the path must not be written there.
2779 *
2780 * Decoded, because that is what the web server looks up: nginx builds the
2781 * file name from `$uri` and Apache from `%{REQUEST_URI}`, and both hold
2782 * the decoded path. A file stored under the encoded name is never found.
2783 *
2784 * Decoding is only safe for the escapes WordPress itself writes, which
2785 * encode the bytes of non-ASCII letters (0x80 and up). An escaped ASCII
2786 * byte is refused, because the server decodes it before the lookup:
2787 * `%2F` would become a directory, `%2E%2E` a parent, and `%6F` would
2788 * make `/ab%6Fut/` and `/about/` share one file although they are
2789 * separate cache entries. The decoded path must also be valid UTF-8, with
2790 * no control byte, backslash or `%`, no `..` anywhere and no `.`
2791 * segment. A refused page is still cached by the drop-in; it only loses
2792 * the no-PHP path.
2793 *
2794 * @param string $path A URL path, without the query string.
2795 */
2796 public static function static_path( string $path ): ?string {
2797 if ( '' === $path || false !== strpos( $path, "\0" ) ) {
2798 return null;
2799 }
2800 if ( preg_match_all( '/%([0-9a-fA-F]{2})/', $path, $escapes ) ) {
2801 foreach ( $escapes[1] as $hex ) {
2802 if ( hexdec( $hex ) < 0x80 ) {
2803 return null;
2804 }
2805 }
2806 }
2807 $decoded = rawurldecode( $path );
2808 if ( 1 !== preg_match( '//u', $decoded ) || preg_match( '/[\x00-\x1f\x7f\\\\%]/', $decoded ) ) {
2809 return null;
2810 }
2811 $decoded = (string) preg_replace( '#/+#', '/', $decoded );
2812 if ( false !== strpos( $decoded, '..' ) || preg_match( '#(^|/)\.(/|$)#', $decoded ) ) {
2813 return null;
2814 }
2815 return rtrim( $decoded, '/' );
2816 }
2817
2818 /**
2819 * The current request's path, normalized, without the query string.
2820 *
2821 * @param string $fallback Returned when the server sent no REQUEST_URI.
2822 */
2823 private static function request_path( string $fallback = '' ): string {
2824 if ( ! isset( $_SERVER['REQUEST_URI'] ) ) {
2825 return $fallback;
2826 }
2827 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- normalize_path() is the sanitizer. sanitize_text_field() deletes percent-encoded octets and broke every non-ASCII slug (see normalize_path()). The value is hashed, matched, or checked by static_path() before it touches the disk.
2828 $uri = (string) wp_unslash( $_SERVER['REQUEST_URI'] );
2829 return self::normalize_path( (string) strtok( $uri, '?' ) );
2830 }
2831
2832 /**
2833 * An Excluded URLs entry with its escapes in lower case.
2834 *
2835 * The request path is matched in normalize_path()'s spelling, so an entry
2836 * copied from Chrome's address bar (`/%E8%81%AF…/`) never matched it.
2837 * Only `%XX` sequences change; the rest of the entry, including a `~`
2838 * regex, is left as typed.
2839 *
2840 * @param string $pattern One Excluded URLs entry.
2841 */
2842 public static function normalize_exclusion( string $pattern ): string {
2843 return (string) preg_replace_callback(
2844 '/%[0-9a-fA-F]{2}/',
2845 static function ( array $m ): string {
2846 return strtolower( $m[0] );
2847 },
2848 $pattern
2849 );
2850 }
2851
2852 /**
2853 * Does an exclusion list match this path, in either spelling?
2854 *
2855 * An admin may paste an exclusion as `/購物車`, as WordPress spells it, or
2856 * as Chrome shows it in upper case. The entry's escapes are lowercased to
2857 * match the path, and the decoded path is tried too, which is also what
2858 * the nginx rule matches (`$uri` is decoded).
2859 *
2860 * @param string[] $patterns Exclusion entries.
2861 * @param string $path Output of normalize_path().
2862 */
2863 private static function path_matches_exclusions( array $patterns, string $path ): bool {
2864 $patterns = array_map(
2865 static function ( $pattern ): string {
2866 return self::normalize_exclusion( (string) $pattern );
2867 },
2868 $patterns
2869 );
2870 if ( Glob_Matcher::any_match( $patterns, $path ) ) {
2871 return true;
2872 }
2873 $decoded = rawurldecode( $path );
2874 return $decoded !== $path && Glob_Matcher::any_match( $patterns, $decoded );
2875 }
2876
2877 /**
2878 * Whether this request's path matches an Excluded URLs list, by the
2879 * page cache's own rule. Turbo Render asks it when it buffers the page
2880 * itself (page cache off), so both read an entry the same way.
2881 *
2882 * @param string[] $patterns Exclusion entries.
2883 */
2884 public static function request_matches_exclusions( array $patterns ): bool {
2885 return self::path_matches_exclusions( $patterns, self::request_path() );
2886 }
2887
2888 public static function cache_key() {
2889 $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : 'default';
2890
2891 // Cacheable 404s share ONE generic per-host entry — keying them by
2892 // URL would let a scanner flood (millions of random paths) bloat
2893 // the cache with identical 404 bodies. Both the write and the HIT
2894 // lookup run through here, so they agree on the key automatically.
2895 if ( self::should_cache_404() ) {
2896 return md5( $host . '|404' );
2897 }
2898
2899 // Strip the query string from the key so /post and /post?utm_*=…
2900 // share the same cache entry. should_cache() above already
2901 // rejected requests with non-ignored params, so by the time we
2902 // build the key the only params left are safe to drop.
2903 $uri = self::request_path( '/' );
2904
2905 // Optional device bucket: when mobile_separate is on, mobile and
2906 // desktop responses live in different cache files so themes that
2907 // serve different HTML by device (AMP, WPtouch, Jetpack mobile)
2908 // can't poison each other.
2909 $device = '';
2910 $opts = Settings_Manager::get( 'cache' );
2911 if ( ! empty( $opts['mobile_separate'] ) ) {
2912 $device = self::is_mobile_request() ? '|m' : '|d';
2913 }
2914
2915 // Search-results requests fold the normalized term into the key so
2916 // /?s=foo and /?s=bar get distinct entries (the query string is
2917 // otherwise stripped above). Only added when search caching opted
2918 // in, so non-search URLs are unaffected.
2919 $search = self::should_cache_search() ? '|s=' . self::search_term() : '';
2920
2921 // Query-form feeds (/?feed=rss2 vs /?feed=atom) share the same path
2922 // once the query is stripped, so fold the feed type into the key to
2923 // keep the flavors distinct. Pretty-permalink feeds (/feed/rss/) carry
2924 // the type in $uri already and are unaffected. (FBS-82407 #4)
2925 $feed = '';
2926 if ( function_exists( 'is_feed' ) && is_feed() && function_exists( 'get_query_var' ) ) {
2927 $feed_type = (string) get_query_var( 'feed' );
2928 if ( '' !== $feed_type ) {
2929 $feed = '|feed=' . preg_replace( '/[^a-z0-9]/i', '', $feed_type );
2930 }
2931 }
2932
2933 return md5( $host . $uri . $device . $search . $feed );
2934 }
2935
2936 /**
2937 * Server-side mobile detection. Prefers WordPress's `wp_is_mobile()`
2938 * which uses the same UA tokens as core (so our bucket aligns with
2939 * whatever theme-side branching uses). Falls back to a tiny inline
2940 * detector if wp_is_mobile() isn't loaded (e.g. the drop-in path).
2941 */
2942 private static function is_mobile_request(): bool {
2943 if ( function_exists( 'wp_is_mobile' ) ) {
2944 return (bool) wp_is_mobile();
2945 }
2946 // Fallback for the rare context where wp_is_mobile() isn't loaded.
2947 // Mirrors core's wp_is_mobile() EXACTLY — including the
2948 // Sec-CH-UA-Mobile client hint it checks *before* UA tokens — so the
2949 // bucket this picks matches whatever the engine's primary path (and
2950 // the drop-in's own copy of this logic) would pick for the same
2951 // request. Drift here re-introduces the cross-path key mismatch.
2952 if ( isset( $_SERVER['HTTP_SEC_CH_UA_MOBILE'] ) ) {
2953 return '?1' === $_SERVER['HTTP_SEC_CH_UA_MOBILE'];
2954 }
2955 $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
2956 if ( '' === $ua ) {
2957 return false;
2958 }
2959 return (bool) preg_match( '/(Mobile|Android|Silk\/|Kindle|BlackBerry|Opera Mini|Opera Mobi)/i', $ua );
2960 }
2961
2962 /**
2963 * Filesystem-safe directory name for a host, or '' when unusable.
2964 *
2965 * The charset MUST match the static tree (store_static()) and the
2966 * drop-in's own copy, or the paths disagree about where an entry lives.
2967 * The colon of `host:port` is stripped: it is legal in a Host header but
2968 * not portable in a path.
2969 *
2970 * @param string $host Raw host, e.g. from HTTP_HOST.
2971 * @return string Safe directory segment, or '' if nothing usable remains.
2972 */
2973 /**
2974 * The host segment of the STATIC tree — `xspeed-static/<host>/…`, which
2975 * the web server resolves without PHP.
2976 *
2977 * Different from host_dir(): here the port is folded INTO the segment
2978 * (`localhost:8080` → `localhost8080`) rather than dropped, because the
2979 * generated server rules have to reproduce this from their own variables
2980 * and nginx's `$host` has no port to drop — see the `$xspeed_host`
2981 * derivation in nginx_snippet(). Shared by the write and the purge so the
2982 * two can't drift; when they did, purging a page on a ported host deleted
2983 * nothing and the stale copy kept being served by the rewrite.
2984 */
2985 public static function static_host_dir( string $host ): string {
2986 return (string) preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host );
2987 }
2988
2989 public static function host_dir( string $host ): string {
2990 $host = str_replace( "\0", '', $host );
2991 // Drop the port BEFORE filtering, or `example.com:8080` collapses to
2992 // `example.com8080` — which both loses the boundary and could collide
2993 // with a real host of that name.
2994 $colon = strpos( $host, ':' );
2995 if ( false !== $colon ) {
2996 $host = substr( $host, 0, $colon );
2997 }
2998 $host = preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host );
2999 // Collapse any run of dots so no traversal sequence can survive the
3000 // charset filter (`a/../b` would otherwise reduce to `a..b`).
3001 $host = preg_replace( '/\.{2,}/', '.', (string) $host );
3002 $host = trim( (string) $host, '.-' );
3003 return '' === $host ? '' : $host;
3004 }
3005
3006 /**
3007 * The per-site bucket a cache entry belongs to: `<host>` on a single
3008 * site, `<host>/<path-prefix>` for a subdirectory multisite blog.
3009 *
3010 * On multisite every blog shares one cache directory, and a flat md5
3011 * filename carries no clue which site wrote it — so purging one subsite
3012 * swept the whole network cold. (#6)
3013 *
3014 * Host alone is NOT enough: a subdirectory network (the common layout)
3015 * puts every blog on the same host, so `example.com/` and
3016 * `example.com/siteb/` would share a bucket and keep purging each other.
3017 * The path prefix is what separates them, and it is derivable from the
3018 * REQUEST_URI alone — which matters because the drop-in must compute
3019 * this identical value before WordPress (and get_blog_details()) exist.
3020 *
3021 * Subdomain and domain-mapped networks differ by host already, so they
3022 * get a bare host bucket and are unaffected.
3023 *
3024 * @param string $host Raw host.
3025 * @param string $uri Raw REQUEST_URI (query string is ignored).
3026 * @return string Bucket path, always non-empty.
3027 */
3028 public static function site_bucket( string $host, string $uri ): string {
3029 $dir = self::host_dir( $host );
3030 if ( '' === $dir ) {
3031 $dir = 'default';
3032 }
3033
3034 $prefix = self::site_path_prefix();
3035 return '' === $prefix ? $dir : $dir . '/' . $prefix;
3036 }
3037
3038 /**
3039 * The current blog's path prefix as a single safe segment ('' for the
3040 * root blog or a non-multisite install). `/siteb/` becomes `siteb`;
3041 * a nested `/a/b/` becomes `a-b` so the bucket stays one level deep.
3042 *
3043 * Written to a sidecar for the drop-in by sync_site_paths().
3044 */
3045 public static function site_path_prefix(): string {
3046 if ( ! function_exists( 'is_multisite' ) || ! is_multisite() ) {
3047 return '';
3048 }
3049 if ( function_exists( 'is_subdomain_install' ) && is_subdomain_install() ) {
3050 return ''; // Hosts already differ; no prefix needed.
3051 }
3052 $path = function_exists( 'get_blog_details' ) ? (string) get_blog_details()->path : '/';
3053 return self::path_prefix_segment( $path );
3054 }
3055
3056 /**
3057 * The bucket an arbitrary URL's cache entry lives in.
3058 *
3059 * `site_bucket()` answers for the CURRENT request; this answers for a URL
3060 * that may belong to another blog entirely — which is what a per-URL purge
3061 * is usually doing (WP-CLI, cron, the MCP tool, a network-admin action).
3062 *
3063 * The blog is resolved from the URL itself: on a subdirectory network
3064 * `get_blog_details()` is asked which blog owns `<host><path>`, and its
3065 * registered path becomes the prefix. Deriving the prefix from the URL's
3066 * first path segment directly would be wrong — `/shop/` on the main blog
3067 * is a page, not a subsite, and would send the purge into a bucket that
3068 * does not exist. (QA B2 on #166)
3069 *
3070 * @param string $host Host of the URL being purged.
3071 * @param string $path Path of the URL being purged.
3072 * @return string Bucket path, always non-empty.
3073 */
3074 public static function bucket_for_url( string $host, string $path ): string {
3075 $dir = self::host_dir( $host );
3076 if ( '' === $dir ) {
3077 $dir = 'default';
3078 }
3079
3080 if ( ! function_exists( 'is_multisite' ) || ! is_multisite() ) {
3081 return $dir;
3082 }
3083 if ( function_exists( 'is_subdomain_install' ) && is_subdomain_install() ) {
3084 return $dir; // Hosts already differ; no prefix.
3085 }
3086 if ( ! function_exists( 'get_blog_details' ) ) {
3087 return $dir;
3088 }
3089
3090 // Longest registered blog path that prefixes this URL wins, so
3091 // `/one/2026/post/` resolves to blog `/one/` and not to the root blog.
3092 $blog = self::blog_for_path( $host, $path );
3093 if ( null === $blog ) {
3094 return $dir;
3095 }
3096 $prefix = self::path_prefix_segment( (string) $blog );
3097 return '' === $prefix ? $dir : $dir . '/' . $prefix;
3098 }
3099
3100 /**
3101 * The registered path of the blog that owns `<host><path>`, or null.
3102 *
3103 * Uses get_blog_details() with a domain/path pair rather than scanning
3104 * every blog, so a large network costs one lookup per candidate segment
3105 * instead of a full table read.
3106 */
3107 private static function blog_for_path( string $host, string $path ): ?string {
3108 $segments = array_values( array_filter( explode( '/', trim( $path, '/' ) ) ) );
3109
3110 // Try the longest candidate first: /a/b/ before /a/ before /.
3111 for ( $take = min( count( $segments ), 2 ); $take >= 1; $take-- ) {
3112 $candidate = '/' . implode( '/', array_slice( $segments, 0, $take ) ) . '/';
3113 $details = get_blog_details(
3114 array(
3115 'domain' => $host,
3116 'path' => $candidate,
3117 ),
3118 false
3119 );
3120 if ( $details && ! empty( $details->path ) ) {
3121 return (string) $details->path;
3122 }
3123 }
3124 return null;
3125 }
3126
3127 /**
3128 * Normalise a blog path ('/', '/siteb/', '/a/b/') into a single
3129 * filesystem-safe segment. Shared with the drop-in's copy.
3130 */
3131 public static function path_prefix_segment( string $path ): string {
3132 $path = trim( str_replace( "\0", '', $path ), '/' );
3133 if ( '' === $path ) {
3134 return '';
3135 }
3136 $path = preg_replace( '/[^a-zA-Z0-9._\-\/]/', '', $path );
3137 $path = str_replace( '/', '-', (string) $path );
3138 return trim( (string) $path, '.-' );
3139 }
3140
3141 /**
3142 * The current blog's path as the static tree stores it — real slashes
3143 * preserved, because that tree mirrors the URL
3144 * (`xspeed-static/{host}{request_uri}/index.html`) rather than using a
3145 * single flattened segment. '' for a root blog / single site.
3146 */
3147 public static function site_path_raw(): string {
3148 if ( ! function_exists( 'is_multisite' ) || ! is_multisite() ) {
3149 return '';
3150 }
3151 if ( function_exists( 'is_subdomain_install' ) && is_subdomain_install() ) {
3152 return '';
3153 }
3154 $path = function_exists( 'get_blog_details' ) ? (string) get_blog_details()->path : '/';
3155 $path = trim( str_replace( "\0", '', $path ), '/' );
3156 if ( '' === $path ) {
3157 return '';
3158 }
3159 $path = preg_replace( '#[^a-zA-Z0-9._\-/]#', '', $path );
3160 return trim( (string) $path, '/' );
3161 }
3162
3163 /**
3164 * Static-tree root for the current site: `<host>` plus the blog's real
3165 * path. Mirrors store_static()'s layout so a scoped purge deletes
3166 * exactly this blog's pages.
3167 */
3168 public static function current_static_scope(): string {
3169 // Same switch_to_blog() caveat as current_host_dir() — see current_host().
3170 // Keep the port folded into the segment exactly as store_static() does.
3171 $dir = self::static_host_dir( self::current_host() );
3172 if ( '' === $dir ) {
3173 $dir = 'default';
3174 }
3175 $path = self::site_path_raw();
3176 return '' === $path ? $dir : $dir . '/' . $path;
3177 }
3178
3179 /**
3180 * The bucket for the CURRENT request. Never empty, so an entry is never
3181 * written to the tree root (which is what the unscoped sweeps used to
3182 * delete indiscriminately).
3183 */
3184 public static function current_host_dir(): string {
3185 $host = self::current_host();
3186 return self::site_bucket( $host, self::request_path( '/' ) );
3187 }
3188
3189 /**
3190 * The host the CURRENT blog is served from.
3191 *
3192 * Deliberately NOT just $_SERVER['HTTP_HOST']: inside a
3193 * switch_to_blog() the request header still names whichever site is
3194 * serving the admin screen, while the cache entries we want belong to
3195 * the switched-to blog. On a subdomain network the host IS the bucket,
3196 * so reading the header there would make Pro's per-site "purge this
3197 * site" button clear the network admin's own cache instead — the very
3198 * bug this scoping exists to fix, surviving in one topology.
3199 *
3200 * get_blog_details() follows the switch, so prefer it whenever we are
3201 * on multisite, and fall back to the request header otherwise.
3202 */
3203 public static function current_host(): string {
3204 if ( function_exists( 'is_multisite' ) && is_multisite() && function_exists( 'get_blog_details' ) ) {
3205 $details = get_blog_details();
3206 if ( $details && ! empty( $details->domain ) ) {
3207 return (string) $details->domain;
3208 }
3209 }
3210
3211 if ( isset( $_SERVER['HTTP_HOST'] ) ) {
3212 return sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) );
3213 }
3214
3215 /*
3216 * No request header — WP-CLI, or WP-Cron driven by system cron.
3217 *
3218 * Returning '' here made the bucket resolve to the literal `default`
3219 * while HTTP requests were writing to `<host>/`, so a scheduled purge
3220 * swept an empty directory and reported success, and get_stats()
3221 * reported 0 cached pages on a site with a full cache. That is the
3222 * normal setup on any host running DISABLE_WP_CRON, which is most of
3223 * them. Fall back to the site's own registered host. (QA D4 on #166)
3224 */
3225 if ( function_exists( 'home_url' ) ) {
3226 $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.
3227 if ( is_array( $parts ) && ! empty( $parts['host'] ) ) {
3228 return (string) $parts['host'];
3229 }
3230 }
3231
3232 return '';
3233 }
3234
3235 /**
3236 * Ensure the current site's cache directory exists, with the silence
3237 * index in both it and the shared root. Returns the directory.
3238 */
3239 public static function ensure_host_dir(): string {
3240 $dir = XSPEED_CACHE_DIR . '/' . self::current_host_dir();
3241 if ( ! file_exists( XSPEED_CACHE_DIR ) ) {
3242 wp_mkdir_p( XSPEED_CACHE_DIR );
3243 self::write_silence( XSPEED_CACHE_DIR );
3244 }
3245 if ( ! file_exists( $dir ) ) {
3246 wp_mkdir_p( $dir );
3247 self::write_silence( $dir );
3248 }
3249 return $dir;
3250 }
3251
3252 public static function cache_file_for( $key ) {
3253 return XSPEED_CACHE_DIR . '/' . self::current_host_dir() . '/' . $key . '.html';
3254 }
3255
3256 /**
3257 * If a precompressed Brotli sibling (`<file>.br`) exists and the client
3258 * advertises `Accept-Encoding: br`, emit the Brotli response headers and
3259 * return the `.br` path to stream. Returns null to fall through to the
3260 * plain file. Keeps the PHP serve path in parity with the web server's
3261 * static .br serving (mod_brotli / ngx_brotli rewrite).
3262 *
3263 * Free has no Brotli logic of its own — this only fires when an add-on
3264 * (the Pro Brotli module) actually wrote the .br, so it's a safe no-op
3265 * on Free-only installs.
3266 *
3267 * @param string $file Absolute path to the cached .html file.
3268 * @return string|null The .br path to stream, or null to serve $file.
3269 */
3270 public static function maybe_serve_brotli( string $file ): ?string {
3271 if ( headers_sent() ) {
3272 return null;
3273 }
3274 $accept = isset( $_SERVER['HTTP_ACCEPT_ENCODING'] )
3275 ? strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT_ENCODING'] ) ) )
3276 : '';
3277 // Match `br` as a token (comma/space delimited), not a substring, so
3278 // a hypothetical "xbr" encoding can't false-positive.
3279 if ( ! preg_match( '/(^|[\s,])br([\s,;]|$)/', $accept ) ) {
3280 return null;
3281 }
3282 $br = $file . '.br';
3283 if ( ! is_string( $br ) || ! file_exists( $br ) || ! is_readable( $br ) ) {
3284 return null;
3285 }
3286 if ( ! self::brotli_sibling_is_usable( $file, $br ) ) {
3287 return null; // fall through to the plain .html
3288 }
3289 header( 'Content-Encoding: br' );
3290 header( 'Vary: Accept-Encoding', false );
3291 // The byte length changes for the compressed body — drop any
3292 // Content-Length the caller may have set so the stream isn't
3293 // truncated/padded. readfile() lets the SAPI set the right length.
3294 header_remove( 'Content-Length' );
3295 return $br;
3296 }
3297
3298 /**
3299 * Is a precompressed `.br` sibling safe to serve?
3300 *
3301 * Existence is not enough. The sibling is written with a plain
3302 * file_put_contents() — no atomic rename — so a crash, a full disk, or a
3303 * read that races the write leaves a TRUNCATED file behind. Serving that
3304 * with `Content-Encoding: br` hands the browser a stream it cannot
3305 * inflate: it renders nothing at all (document.body is null) and the
3306 * navigation can hang. A 16-byte .br for a 172KB page reproduces it
3307 * exactly. (#286)
3308 *
3309 * Brotli has no magic number, and no byte-level marker distinguishes a
3310 * truncated stream from a short valid one (the ISLAST bit is bit-packed,
3311 * not byte-aligned). So this checks only what CAN be known by stat:
3312 *
3313 * - Not empty. A zero-byte sibling is unambiguously broken.
3314 * - Not older than the HTML. A stale sibling would serve the PREVIOUS
3315 * revision of the page under the current entry's ETag.
3316 *
3317 * A size-RATIO floor was tried here and removed. Brotli's ratio is
3318 * unbounded on repetitive input: a ~1 MB page of table rows or a product
3319 * grid — the ordinary shape of a big generated page — compresses to
3320 * about 0.04%, so a 2% floor rejected a perfectly good sibling and sent
3321 * visitors the uncompressed page instead, silently. Measured: 963 KB of
3322 * repeated markup → 89 bytes at q5 (0.009%). No floor can separate
3323 * "impossibly small" from "extremely compressible" for arbitrary HTML.
3324 *
3325 * Truncation is prevented at the WRITE side instead — see
3326 * write_atomic(), which the Brotli writer uses so a partial file is
3327 * never visible under the final name. Detection at read time cannot be
3328 * made correct; not creating the bad file can.
3329 *
3330 * Anything suspicious returns false and the caller streams the plain
3331 * .html — slower, always correct. Serving an uninflatable body is worse
3332 * than serving no compression at all.
3333 *
3334 * @param string $file Absolute path to the .html cache file.
3335 * @param string $br Absolute path to its .br sibling.
3336 * @return bool True when the sibling may be served.
3337 */
3338 /**
3339 * Write a cache sidecar so a partial file is never visible.
3340 *
3341 * `file_put_contents()` truncates the target and then fills it, so any
3342 * reader arriving mid-write — or any crash, full disk, or killed worker
3343 * — leaves a SHORT file under the real name. For HTML that degrades to a
3344 * clipped page; for a `.br` sibling it is worse, because a truncated
3345 * brotli stream is not a short page but an UNINFLATABLE one: the browser
3346 * renders nothing at all and the navigation can hang.
3347 *
3348 * Writing to a unique temp file in the same directory and renaming is
3349 * atomic on POSIX, so readers see either the previous complete file or
3350 * the new complete file, never a partial one. This is the half of #286
3351 * that is actually fixable — a read-time heuristic cannot tell a
3352 * truncated brotli stream from a very small valid one, but a truncated
3353 * file that never becomes visible needs no detection.
3354 *
3355 * @param string $path Absolute destination path.
3356 * @param string $contents Bytes to write.
3357 * @return bool True when the destination now holds exactly $contents.
3358 */
3359 public static function write_atomic( string $path, string $contents ): bool {
3360 $dir = dirname( $path );
3361 // 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.
3362 if ( ! is_dir( $dir ) || ! is_writable( $dir ) ) {
3363 return false;
3364 }
3365
3366 // Same directory, so the rename stays on one filesystem — a rename
3367 // across devices is a copy and loses atomicity.
3368 $tmp = @tempnam( $dir, '.xspeed-tmp-' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a failure returns false and the caller skips the write.
3369 if ( ! is_string( $tmp ) || '' === $tmp ) {
3370 return false;
3371 }
3372
3373 // 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.
3374 $written = @file_put_contents( $tmp, $contents ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- handled by the length check below.
3375
3376 // A short write is exactly the failure this function exists to
3377 // prevent, so verify the byte count before publishing the file.
3378 if ( false === $written || $written !== strlen( $contents ) ) {
3379 @unlink( $tmp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort cleanup of our own temp file; non-fatal.
3380 return false;
3381 }
3382
3383 // tempnam() creates the file 0600; cache files must stay readable by
3384 // the web server, which may run as a different user.
3385 @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.
3386
3387 // 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.
3388 if ( ! @rename( $tmp, $path ) ) {
3389 @unlink( $tmp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort cleanup; non-fatal.
3390 return false;
3391 }
3392
3393 return true;
3394 }
3395
3396 public static function brotli_sibling_is_usable( string $file, string $br ): bool {
3397 $br_size = (int) @filesize( $br ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a stat failure means "don't serve it", handled by the <= 0 check.
3398 if ( $br_size <= 0 ) {
3399 return false;
3400 }
3401
3402 $html_size = (int) @filesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- as above.
3403 if ( $html_size <= 0 ) {
3404 return false;
3405 }
3406
3407 // A sibling older than the page it compresses is stale.
3408 $br_mtime = (int) @filemtime( $br ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- as above.
3409 $html_mtime = (int) @filemtime( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- as above.
3410 if ( $br_mtime > 0 && $html_mtime > 0 && $br_mtime < $html_mtime ) {
3411 return false;
3412 }
3413
3414 // The writer recorded how many bytes it produced. Where that record
3415 // exists, truncation is a certainty rather than an inference: a
3416 // stream shorter than its own declared length cannot inflate, and
3417 // one that matches was published whole. This is what a size ratio
3418 // could never be — brotli's ratio is unbounded on repetitive input,
3419 // so a 0.01% sibling of a generated page is genuinely valid.
3420 //
3421 // Absent for a sibling written before this version, or by an add-on
3422 // that writes the file directly. That case keeps the checks above
3423 // and no more, which is where a pre-existing truncated file on a
3424 // live site still slips through — write_atomic() stops NEW ones,
3425 // but it cannot retroactively vouch for what is already on disk.
3426 $expected = self::brotli_expected_size( $br );
3427 if ( $expected > 0 && $br_size !== $expected ) {
3428 return false;
3429 }
3430
3431 return true;
3432 }
3433
3434 /**
3435 * Path of the sidecar recording a `.br` sibling's complete byte count.
3436 *
3437 * Kept beside the sibling as `<file>.html.br.size` rather than folded
3438 * into the entry's `.meta`: the static tree the web server serves has no
3439 * `.meta` at all, and the two trees must answer this question the same
3440 * way. Every path that deletes a `.br` deletes this with it.
3441 *
3442 * @param string $br Absolute path to the `.br` sibling.
3443 * @return string Absolute path to its size sidecar.
3444 */
3445 public static function brotli_size_sidecar( string $br ): string {
3446 return $br . '.size';
3447 }
3448
3449 /**
3450 * The byte count the writer recorded for a `.br` sibling, or 0 when no
3451 * record exists (a sibling predating this version, or written by an
3452 * add-on that bypassed write_brotli_sibling()).
3453 *
3454 * @param string $br Absolute path to the `.br` sibling.
3455 * @return int Expected size in bytes, or 0 when unknown.
3456 */
3457 public static function brotli_expected_size( string $br ): int {
3458 $sidecar = self::brotli_size_sidecar( $br );
3459 if ( ! is_file( $sidecar ) ) {
3460 return 0;
3461 }
3462 // 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.
3463 $raw = @file_get_contents( $sidecar ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- an unreadable sidecar means "unknown", handled by the cast below.
3464 return max( 0, (int) trim( (string) $raw ) );
3465 }
3466
3467 /**
3468 * Publish a `.br` sibling together with the record of its own length.
3469 *
3470 * The single writer every producer of a `.br` should route through — the
3471 * Pro Brotli module included. Publishing the body atomically stops a
3472 * truncated file from ever becoming visible; recording the byte count
3473 * lets the serve path prove wholeness for the files that already exist
3474 * on disk when this ships.
3475 *
3476 * Order matters: the size sidecar is removed first and written last, so
3477 * a reader arriving mid-update sees "no record" (checks above still
3478 * apply) rather than the previous body's length against the new body.
3479 *
3480 * @param string $br Absolute path to the `.br` sibling to write.
3481 * @param string $contents Compressed bytes.
3482 * @return bool True when both the sibling and its size record are in place.
3483 */
3484 public static function write_brotli_sibling( string $br, string $contents ): bool {
3485 $sidecar = self::brotli_size_sidecar( $br );
3486 if ( is_file( $sidecar ) ) {
3487 wp_delete_file( $sidecar );
3488 }
3489
3490 if ( ! self::write_atomic( $br, $contents ) ) {
3491 return false;
3492 }
3493
3494 if ( self::write_atomic( $sidecar, (string) strlen( $contents ) ) ) {
3495 return true;
3496 }
3497
3498 // The body landed but its length did not. That sibling is servable
3499 // and unguarded — exactly the file this function exists to prevent —
3500 // and the caller has no way to know. Withdraw it: a MISS costs one
3501 // uncompressed response, where an unguarded sibling can cost a blank
3502 // page for as long as the entry lives.
3503 wp_delete_file( $br );
3504 return false;
3505 }
3506
3507 /**
3508 * Sidecar metadata file for a cache entry. Holds response bits the HIT
3509 * path must replay — Content-Type (cached feeds → application/rss+xml,
3510 * sitemaps → text/xml) and status (a cached 404 must serve 404, not
3511 * 200). JSON, one tiny file per entry, written only when there's
3512 * something non-default to replay.
3513 */
3514 public static function cache_meta_for( $key ) {
3515 return XSPEED_CACHE_DIR . '/' . self::current_host_dir() . '/' . $key . '.meta';
3516 }
3517
3518 /**
3519 * Read the .meta sidecar for a cache entry as an array, or [] if none.
3520 * Keys: 'content_type' (string), 'status' (int), 'ttl' (int seconds).
3521 * Used on the HIT path to replay content-type/status before streaming
3522 * the file, and by Cache_GC to age an entry by its own TTL rather than
3523 * the global one — hence public.
3524 */
3525 public static function read_meta( $key ): array {
3526 $meta_file = self::cache_meta_for( $key );
3527 if ( ! file_exists( $meta_file ) ) {
3528 return array();
3529 }
3530 // 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.
3531 $raw = file_get_contents( $meta_file );
3532 $data = json_decode( (string) $raw, true );
3533 return is_array( $data ) ? $data : array();
3534 }
3535
3536 /**
3537 * Conditional-GET support for a cache HIT. Emits Last-Modified + ETag
3538 * derived from the cache file's mtime, and — when the request's
3539 * If-Modified-Since / If-None-Match still match — sends 304 Not Modified
3540 * and returns true (caller should exit without a body). Returns false to
3541 * proceed with a normal 200 body. Lets aggregators/browsers skip
3542 * re-downloading an unchanged cached response. (FBS-82407 #5)
3543 *
3544 * Also stamps `X-XSpeed-Built` from the same mtime. This is the PHP serve
3545 * path's build time, and the only place on it that holds the file: mark()
3546 * has already run by the time we get here and resolved the edge-header
3547 * filter without one. Both are emitted, and a purge verifier prefers the
3548 * stamp: `Last-Modified` carries the same integer, but it is a generic
3549 * header that the origin-side cache layer such a verifier exists to
3550 * detect — or any proxy in between — may rewrite to its own store time,
3551 * which would turn a stale origin into a pass. Nothing but xSpeed's own
3552 * serve code writes `X-XSpeed-Built`, so a value older than the purge is
3553 * proof the origin answered with old HTML.
3554 *
3555 * @param string $file Absolute path to the cache .html file.
3556 * @return bool True when a 304 was sent.
3557 */
3558 public static function serve_not_modified( string $file ): bool {
3559 $mtime = (int) filemtime( $file );
3560 if ( $mtime <= 0 ) {
3561 return false;
3562 }
3563 $last_modified = gmdate( 'D, d M Y H:i:s', $mtime ) . ' GMT';
3564 $etag = '"' . md5( $file . '|' . $mtime ) . '"';
3565 header( 'Last-Modified: ' . $last_modified );
3566 header( 'ETag: ' . $etag );
3567 // Before the 304 branch below, so a conditional request that gets a
3568 // bodyless 304 still carries the stamp. A verifier's fetch may well
3569 // be conditional, and a 304 with no build time reads as unverifiable.
3570 header( self::BUILT_HEADER . ': ' . $mtime );
3571
3572 $ims = isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ) ) : '';
3573 $inm = isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_NONE_MATCH'] ) ) ) : '';
3574
3575 $etag_match = '' !== $inm && false !== strpos( $inm, $etag );
3576 $time_match = '' !== $ims && ( strtotime( $ims ) >= $mtime );
3577
3578 if ( $etag_match || $time_match ) {
3579 if ( function_exists( 'http_response_code' ) ) {
3580 http_response_code( 304 );
3581 }
3582 return true;
3583 }
3584 return false;
3585 }
3586
3587 public static function is_expired( $file ) {
3588 // cache_expiry now owned by CacheModule; per-post override
3589 // (Phase 3.4) shrinks the TTL further when the editor set one.
3590 $opts = Settings_Manager::get( 'cache' );
3591 $max_age = (int) $opts['cache_expiry'] * HOUR_IN_SECONDS;
3592 $post_override = Cache_Rules::expiry_override_seconds_for_post( Cache_Rules::current_post_id() );
3593 if ( null !== $post_override ) {
3594 $max_age = $post_override;
3595 }
3596
3597 /**
3598 * Filter the max-age (seconds) for the current cache entry.
3599 *
3600 * Lets an add-on apply a request-type-specific TTL — e.g. the
3601 * xspeed-pro feed cache gives feeds a longer expiry than pages,
3602 * since aggregators tolerate more staleness. Return seconds.
3603 *
3604 * @param int $max_age Computed max-age in seconds.
3605 */
3606 $max_age = (int) apply_filters( 'xspeed_cache_max_age', $max_age );
3607
3608 // Honour the per-entry TTL the .meta sidecar carries, when it is
3609 // SHORTER than what we just resolved. The sidecar records the TTL
3610 // this specific entry was written under — a nonce cap (#236), a Pro
3611 // feed/404 expiry — and the drop-in already reads it. is_expired()
3612 // did not, so on the engine path a capped entry was still served for
3613 // the full configured lifetime: exactly the stale nonce the cap
3614 // exists to prevent. Only ever shortens, so an entry can never be
3615 // kept alive past the configured maximum by a stale sidecar.
3616 // Derive the sidecar from the FILE we were handed rather than
3617 // recomputing cache_key(): callers legitimately ask about an entry
3618 // that isn't the current request's (Cache_GC sweeps, Pro's warmer),
3619 // and cache_key() would answer for the wrong one — besides needing a
3620 // request context this function has no business requiring.
3621 $meta_file = preg_replace( '/\.html$/', '.meta', (string) $file );
3622 if ( is_string( $meta_file ) && $meta_file !== $file && is_readable( $meta_file ) ) {
3623 // 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.
3624 $raw = file_get_contents( $meta_file );
3625 $decoded = is_string( $raw ) ? json_decode( $raw, true ) : null;
3626 if ( is_array( $decoded ) && isset( $decoded['ttl'] ) ) {
3627 $entry_ttl = (int) $decoded['ttl'];
3628 if ( $entry_ttl > 0 && ( $max_age < 1 || $entry_ttl < $max_age ) ) {
3629 $max_age = $entry_ttl;
3630 }
3631 }
3632 }
3633
3634 // A missing file is "expired" — the caller should re-render. Guard
3635 // filemtime() rather than letting it warn: callers legitimately ask
3636 // about a file that isn't there (Pro's predictive warmer probes for
3637 // freshness, and Cache_GC can collect an entry between the check and
3638 // the read), and on a site with WP_DEBUG the warning is noise.
3639 $mtime = file_exists( $file ) ? filemtime( $file ) : false;
3640 if ( false === $mtime ) {
3641 return true;
3642 }
3643
3644 return ( time() - (int) $mtime ) > $max_age;
3645 }
3646
3647 /**
3648 * Accumulator for the full response body across all output-handler phases.
3649 *
3650 * PHP invokes an ob_start() callback once per flush, and each invocation
3651 * only receives the chunk produced *since the previous flush*. If anything
3652 * during the render calls `ob_flush()` or `flush()` (some themes, lazy-
3653 * load plugins, AMP, etc. do), the final-phase call would otherwise only
3654 * see the tail of the page — and we'd cache a truncated response that
3655 * gets served repeatedly until purge. We accumulate every chunk here so
3656 * the cache file always reflects the complete page.
3657 *
3658 * @var string
3659 */
3660 private static $accumulated = '';
3661
3662 public static function finalize_buffer( $buffer, $phase = PHP_OUTPUT_HANDLER_FINAL ) {
3663 self::$accumulated .= $buffer;
3664
3665 // On non-final phases (mid-request flushes), pass the current chunk
3666 // through to the client unmodified and keep collecting. The WP 6.9
3667 // filter path always passes the full body in one shot with the
3668 // default $phase, so it falls straight through to the final block.
3669 $is_final = ( $phase & ( PHP_OUTPUT_HANDLER_FINAL | PHP_OUTPUT_HANDLER_END ) ) !== 0;
3670 if ( ! $is_final ) {
3671 return $buffer;
3672 }
3673
3674 $full = self::$accumulated;
3675 self::$accumulated = '';
3676
3677 // Consumed here, on every final path, so a later call can never
3678 // compare against this request's snapshot.
3679 $asset_stamp = self::$asset_stamp_at_open;
3680 self::$asset_stamp_at_open = null;
3681
3682 if ( strlen( $full ) < 255 ) {
3683 return $buffer;
3684 }
3685
3686 // Status gate. We cache 200 by default. A 404 may be cached too,
3687 // but only when an add-on (xspeed-pro 404 cache) opts in for a
3688 // genuine is_404() — never a transient 404 (maintenance screen,
3689 // DB error, or a 404 emitted outside the main query), which would
3690 // otherwise be frozen until purge. Any other status is skipped.
3691 $status = function_exists( 'http_response_code' ) ? (int) http_response_code() : 200;
3692 if ( 200 !== $status ) {
3693 if ( 404 !== $status || ! self::should_cache_404() ) {
3694 return $buffer;
3695 }
3696 }
3697
3698 // If no mid-request flush happened, $buffer === $full and we can
3699 // safely minify the on-wire bytes too. Otherwise earlier chunks have
3700 // already been sent unminified, so we minify only what goes to disk —
3701 // the first visitor sees unminified HTML, every cache hit after that
3702 // is minified.
3703 $single_chunk = ( $buffer === $full );
3704
3705 /**
3706 * Filter: xspeed_cache_final_html
3707 *
3708 * Last chance to transform the fully-rendered page HTML before it is
3709 * minified and written to the cache file. Runs on cache MISS only, so
3710 * whatever a listener injects here is baked into the cached HTML and
3711 * replayed on every subsequent HIT (the drop-in short-circuits before
3712 * PHP on a HIT — a wp_head hook would never fire there).
3713 *
3714 * The Preload module uses this to inject the LCP-image <link rel=preload>
3715 * + preconnect hints and add fetchpriority="high" to the hero <img>.
3716 * Keep listeners fast and idempotent; this is the on-wire body.
3717 *
3718 * A result with fewer than 255 non-whitespace bytes is treated as a
3719 * failed listener and replaced by the rendered page.
3720 *
3721 * @param string $full Complete page HTML.
3722 */
3723 $rendered = $full;
3724 $full = apply_filters( 'xspeed_cache_final_html', $full );
3725
3726 // A listener whose preg_* call fails returns null, which a cast turns
3727 // into ''. That empty string used to be signed, sent and cached, and
3728 // nginx then served the signature comment as a HIT to every visitor
3729 // until purge. Same floor as the rendered page above. (#723)
3730 if ( ! is_string( $full ) || strlen( trim( $full ) ) < 255 ) {
3731 $full = $rendered;
3732 }
3733 if ( $single_chunk ) {
3734 $buffer = $full;
3735 }
3736
3737 // minify_html now owned by the Minify module; read through the
3738 // module's storage so this stays consistent with the engine that
3739 // applies CSS/JS minification.
3740 $minify_opts = Settings_Manager::get( 'minify' );
3741 if ( ! empty( $minify_opts['minify_html'] ) ) {
3742 $full = Minifier::minify_html( $full );
3743 if ( $single_chunk ) {
3744 $buffer = $full;
3745 }
3746 }
3747
3748 // AFTER minification on purpose — the HTML minifier strips comments,
3749 // so signing earlier would erase the signature from every minified
3750 // page. Baked into the cached bytes so all three serve paths (nginx
3751 // static rewrite, .htaccess, the PHP drop-in) carry it identically.
3752 $full = self::signed( $full );
3753 if ( $single_chunk ) {
3754 $buffer = $full;
3755 }
3756
3757 // Per-site directory — see ensure_host_dir(). (#6)
3758 self::ensure_host_dir();
3759
3760 // Path safety: cache_file_for() builds
3761 // `XSPEED_CACHE_DIR . '/' . <host> . '/' . $key . '.html'` where $key
3762 // comes from md5() — guaranteed to be exactly 32 lowercase hex chars —
3763 // and <host> is filtered by host_dir() to [A-Za-z0-9.-] with leading
3764 // dots trimmed, so no traversal sequence ('..', '/', null byte, etc.)
3765 // can appear in either segment. The write is therefore always inside
3766 // XSPEED_CACHE_DIR.
3767 $key = self::cache_key();
3768
3769 // Query-string gate. should_cache() waved this request through
3770 // because every param is on the ignored_query_params allow-list, and
3771 // cache_key() drops the query so reads share the canonical entry.
3772 // That sharing is safe on READ but not on WRITE: this response was
3773 // rendered WITH the params, and WordPress reflects REQUEST_URI into
3774 // form actions, share links and plugin smart tags — so storing it
3775 // would serve an attacker-chosen variant under the clean URL for the
3776 // whole TTL (#241).
3777 //
3778 // This sits BELOW the transforms deliberately. Returning above them
3779 // also skipped xspeed_cache_final_html, and every listener disables
3780 // its own fallback ob_start() when the page cache is on precisely
3781 // because that filter is the shared transport — so a visitor
3782 // arriving on ?utm_source=… was served HTML with no LCP preload, no
3783 // preconnect, no CDN rewrite, no CSS combine and no HTML minify.
3784 // That is the ad-click and newsletter cohort getting the least
3785 // optimised page on the site. Only the WRITE is skipped, which is
3786 // what this fix was always meant to do — and it is where the
3787 // deferred writer has always placed its own copy of the guard.
3788 if ( self::query_string_blocks_write() ) {
3789 return $buffer;
3790 }
3791
3792 // A purge deleted minified or combined files while this page was
3793 // rendering, so it may link names that are gone. Serve it, but do
3794 // not store it; the next request renders against the files as they
3795 // are now.
3796 if ( self::assets_purged_since( $asset_stamp ) ) {
3797 return $buffer;
3798 }
3799 $file = self::cache_file_for( $key );
3800
3801 // A render-time translation plugin (TranslatePress) wraps our buffer,
3802 // so the bytes we hold here are still UNTRANSLATED — its callback has
3803 // not run yet, and writing now would cache English under a French URL
3804 // and bake in its internal #TRPLINKPROCESSED markers. Hand off to
3805 // shutdown, where the outer buffer has already translated, and let
3806 // the pass-through below deliver this request untouched.
3807 if ( self::translation_plugin_active() ) {
3808 self::$deferred_key = $key;
3809 self::$deferred_asset_stamp = $asset_stamp;
3810 // Reaching here means finalize_buffer() ran to completion: the
3811 // status gate passed, should_cache() said yes, and PHP handed us
3812 // the whole buffer. A wp_die() or exit() mid-render unwinds the
3813 // buffer stack WITHOUT calling this callback, so the flag stays
3814 // false and the shutdown writer declines — see the guard there.
3815 self::$render_completed = true;
3816 // A PHP shutdown function, not a WP `shutdown` action: this must
3817 // run after the output-buffer stack has unwound, and WP's
3818 // shutdown action fires while our outer buffer is still open.
3819 register_shutdown_function( array( __CLASS__, 'write_deferred_translated_cache' ) );
3820 return $buffer;
3821 }
3822
3823 // 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.
3824 $stored = file_put_contents( $file, $full, LOCK_EX );
3825
3826 /*
3827 * No edge headers on a MISS. Not even a stored one.
3828 *
3829 * A MISS is the FIRST render, and it is the render most likely to be
3830 * replaced: critical CSS and unused CSS are generated after the fact
3831 * and applied to later requests, so the copy written here is the
3832 * pre-optimization one. Telling a CDN to hold it pins exactly the
3833 * version xSpeed is about to improve on — reported from a live site,
3834 * where Cloudflare had cached an unoptimized first render.
3835 *
3836 * `CDN-Cache-Control` is why it reaches the edge at all: Cloudflare
3837 * honours it as the CDN-targeted directive whatever its own cache
3838 * rules say, so an origin header is enough to pin HTML for the whole
3839 * TTL even where edge page caching is switched off.
3840 *
3841 * A HIT is the safe moment and the honest one: it means xSpeed is
3842 * serving its stored copy, that copy is what the edge would mirror,
3843 * and a later regeneration purges it — which reaches the edge through
3844 * the purge actions. So the edge caches from the second visitor on,
3845 * one render later than before and the right one.
3846 */
3847
3848 /**
3849 * Fires after the flat hash cache file ({md5}.html) is written.
3850 *
3851 * Mirror of `xspeed_static_file_written` for the flat cache. The PHP
3852 * serve path (Cache::maybe_serve_brotli / the drop-in) serves THIS
3853 * file and looks for a `{md5}.html.br` sibling — which only the Pro
3854 * Brotli listener on this hook writes. Without it the .br sibling was
3855 * never created and the PHP path could never serve Brotli (FBS-83039,
3856 * Blocker 2): the static-tree .br (written on xspeed_static_file_written)
3857 * lives in a different cache layout the PHP path never reads.
3858 *
3859 * @param string $file Absolute path to the flat cache file just written.
3860 * @param string $full The HTML written to it.
3861 */
3862 do_action( 'xspeed_flat_file_written', $file, $full );
3863
3864 // Persist a non-default Content-Type so the HIT path can replay it
3865 // (cached feeds must serve application/rss+xml, not text/html).
3866 // Only written when the response set a content-type other than
3867 // the HTML default — pages don't pay for an extra file.
3868 self::write_meta( $key, $full );
3869
3870 // Static-cache tree (xspeed-static/{host}{path}/index.html). The
3871 // .htaccess rewrite block serves this file directly via the web
3872 // server, bypassing PHP for ~3-5× lower TTFB vs the drop-in path.
3873 // store_static() returns silently on any path/permission issue —
3874 // the drop-in remains the safety net.
3875 //
3876 // Skip it entirely when mobile_separate is on: the rewrite is
3877 // disabled in that mode (static_rewrite_allowed()), so a static file
3878 // would only be dead weight — and a device-blind one at that.
3879 // Skip the static-tree write for responses the web server can't replay
3880 // correctly: a non-200 status (a cached 404 would be served as a soft
3881 // 200, FBS-82406) or a non-HTML content-type (a cached feed would go
3882 // out as text/html, FBS-82407). The web server serves these .html files
3883 // directly with no PHP, so there's no .meta replay — keep them on the
3884 // drop-in / PHP path instead, which DOES replay status + content-type.
3885 // The static tree cannot replay a sidecar. A file served straight by
3886 // the web server carries the headers baked into the rule that serves
3887 // the whole site — the very answer this entry exists because it
3888 // disagreed with. Same reasoning as the status and content-type
3889 // cases: what the fast path cannot replay belongs on the drop-in path.
3890 if ( self::static_rewrite_allowed()
3891 && self::response_is_plain_html()
3892 && array() === self::per_entry_edge_headers() ) {
3893 self::store_static( $full );
3894 }
3895
3896 return $buffer;
3897 }
3898
3899 /**
3900 * Write the current response to the static-cache tree at
3901 * `xspeed-static/{host}{request_uri}/index.html`. The web-server
3902 * rewrite block points at this path so cache hits skip PHP
3903 * entirely. Caller already minified/finalized $html.
3904 *
3905 * Path safety: $host is restricted to a `[a-zA-Z0-9.\-]` allowlist;
3906 * the path goes through static_path(), which decodes it the way the
3907 * web server will and refuses anything that could leave the host
3908 * directory or share a file with another page. After the directory is
3909 * created we verify its real path is inside XSPEED_CACHE_STATIC_DIR
3910 * before any write. Anything off the happy path returns silently.
3911 *
3912 * INVARIANT — the static tree is keyed by `{host}{path}` and NOTHING
3913 * else, and both generated rewrites refuse any request that carries a
3914 * query string at all (`RewriteCond %{QUERY_STRING} ^$` on Apache,
3915 * `if ($args)` in nginx_snippet()). So a response may only be stored
3916 * here when cache_key() adds no discriminator beyond `{host}{path}`:
3917 * a query-keyed entry can never be *served* from here, only mis-served
3918 * as the bare path. Any future opt-in that folds a query param into the
3919 * key needs a guard below, exactly like the search one.
3920 */
3921 /**
3922 * Transient holding the most recent static-tree refusal.
3923 *
3924 * Short-lived on purpose: it describes what the last cacheable render
3925 * actually did, so a stale entry would keep warning about a page whose
3926 * nonces have since been removed. A site that still refuses simply
3927 * rewrites it on the next render. (#372)
3928 */
3929 private const STATIC_SKIP_TRANSIENT = 'xspeed_static_skip';
3930
3931 /**
3932 * File beside a static index.html that holds its nonce deadline. GC reads
3933 * it; every path that deletes the page must delete it too, or the folder
3934 * can never be removed.
3935 */
3936 public const EXPIRES_MARKER = '.xspeed-expires';
3937
3938 /**
3939 * Remember why a page was kept out of the static tree, for Health.
3940 *
3941 * Records the URL, the reason, and — for the nonce case — the distinct
3942 * nonce KEYS found, which is what makes the finding actionable: the names
3943 * (`eael_login_nonce`, `post_grid_pagination_nonce`, …) trace straight back
3944 * to the plugin emitting them, and it is usually a widget the site does not
3945 * use on that page. Only key names are kept, never the nonce values.
3946 *
3947 * @param string $reason Machine-readable refusal reason.
3948 * @param string $html The response, for extracting the nonce keys.
3949 */
3950 private static function note_static_skip( string $reason, string $html = '' ): void {
3951 if ( ! function_exists( 'set_transient' ) ) {
3952 return;
3953 }
3954
3955 $keys = array();
3956 if ( 'nonce' === $reason && '' !== $html ) {
3957 // Must recognise the SAME shapes response_has_nonce() refuses on,
3958 // or a page is skipped and reported with no keys at all — which is
3959 // most of them, since the plain `name="_wpnonce"` form field is the
3960 // commonest shape by far and only the JSON one was handled here.
3961 // The keys are the actionable half of the message, so a mismatch
3962 // leaves the admin with bad news and nothing to act on.
3963 //
3964 // Both alternations capture the KEY only: each value pattern sits
3965 // outside the capture group, so a nonce secret can never be stored.
3966 $found = array();
3967 if ( preg_match_all( '/name=["\']([a-z0-9_\-\[\]]*nonce[a-z0-9_\-\[\]]*)["\']/i', $html, $m ) ) {
3968 $found = array_merge( $found, $m[1] );
3969 }
3970 if ( preg_match_all( '/["\']([a-z0-9_\-]*nonce[a-z0-9_\-]*)["\']\s*:\s*["\'][a-f0-9]{8,}["\']/i', $html, $m ) ) {
3971 $found = array_merge( $found, $m[1] );
3972 }
3973 // The query-arg shape (`?_wpnonce=…`) has no key name to report
3974 // beyond the literal, so name it explicitly rather than reporting
3975 // nothing for a page that was genuinely refused.
3976 if ( preg_match( '/[?&]_wpnonce=/i', $html ) ) {
3977 $found[] = '_wpnonce';
3978 }
3979 $keys = array_slice( array_values( array_unique( $found ) ), 0, 10 );
3980 }
3981
3982 set_transient(
3983 self::STATIC_SKIP_TRANSIENT,
3984 array(
3985 'reason' => $reason,
3986 'url' => esc_url_raw( self::request_path() ),
3987 'keys' => $keys,
3988 'at' => time(),
3989 ),
3990 HOUR_IN_SECONDS
3991 );
3992 }
3993
3994 /**
3995 * The most recent static-tree refusal, or an empty array when there is none.
3996 *
3997 * @return array{reason:string,url:string,keys:string[],at:int}|array{}
3998 */
3999 public static function last_static_skip(): array {
4000 $stored = function_exists( 'get_transient' ) ? get_transient( self::STATIC_SKIP_TRANSIENT ) : false;
4001 return is_array( $stored ) && ! empty( $stored['reason'] ) ? $stored : array();
4002 }
4003
4004 private static function store_static( string $html ): void {
4005 // Search results are keyed by term in cache_key() (`|s=<term>`) but
4006 // carry the *path* of whatever URL was searched from — for the usual
4007 // `/?s=<term>` that path is `/`. Writing them here would file the
4008 // results page as `{host}/index.html` and the web server would serve
4009 // it to every visitor as the homepage: an unauthenticated visitor
4010 // poisons the front page with one request. Searches stay on the
4011 // drop-in, which replays the term-keyed entry correctly. (#191)
4012 //
4013 // This is a superset of the query-string check the exclusion gate
4014 // does: it also covers `/?%73=<term>`, which decodes to the same
4015 // search (the shape #109 fixed on the gate side).
4016 if ( self::should_cache_search() ) {
4017 return;
4018 }
4019
4020 // Same hazard for the allow-listed query params: store_static()
4021 // strips the query and files the response under the bare path, which
4022 // the web server then serves to every visitor of the clean URL with
4023 // no PHP involved at all — so none of the engine's checks can catch
4024 // it later (#241). The callers already gate on this, but the guard
4025 // is repeated here because this tree is the most dangerous of the
4026 // three write sites and must not depend on its callers.
4027 if ( self::request_has_query_string() ) {
4028 return;
4029 }
4030
4031 // A nonce-bearing page is served here with NO PHP: no TTL check and
4032 // no .meta replay, so the per-entry cap that keeps the drop-in honest
4033 // (#236) cannot reach a file once it is written. Only Cache_GC removes
4034 // it, and until it does the page hands every visitor the same nonce —
4035 // which, once that nonce dies, breaks every anonymous form on it.
4036 //
4037 // Refusing outright was the safe answer, and it cost every
4038 // nonce-bearing page the static tree entirely: a site whose homepage
4039 // carries one unused login nonce ran PHP on every request forever.
4040 // The nonce's own remaining life is the better gate — the page is
4041 // written and its deadline recorded below for GC to enforce.
4042 //
4043 // A nonce we cannot put a clock on is still refused, and that refusal
4044 // is still recorded: it stays completely silent otherwise, because the
4045 // drop-in answers HIT while Health reports the fast path active from a
4046 // probe that writes its OWN file and never proves real pages reach the
4047 // tree. (#372)
4048 $nonce_ttl = self::response_has_nonce( $html ) ? self::nonce_capped_ttl( $html, 0 ) : 0;
4049 if ( self::response_has_nonce( $html ) && $nonce_ttl < 1 ) {
4050 self::note_static_skip( 'nonce', $html );
4051 return;
4052 }
4053
4054 $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
4055 $host = self::static_host_dir( $host );
4056 $uri = self::request_path();
4057 if ( '' === $host || '' === $uri ) {
4058 return;
4059 }
4060 // Decoded the way the web server will decode it, or refused.
4061 $rel = self::static_path( $uri );
4062 if ( null === $rel ) {
4063 return;
4064 }
4065
4066 $base = rtrim( XSPEED_CACHE_STATIC_DIR, '/' );
4067 $dir = $base . '/' . $host . $rel;
4068 $file = $dir . '/index.html';
4069
4070 // Resolve the parent against the cache root to be sure the
4071 // final path is inside our tree even if the OS does anything
4072 // funny with multi-byte sequences.
4073 $base_real = realpath( WP_CONTENT_DIR );
4074 if ( false === $base_real || 0 !== strpos( $base, $base_real ) ) {
4075 return;
4076 }
4077
4078 if ( ! file_exists( $dir ) ) {
4079 wp_mkdir_p( $dir );
4080 }
4081 if ( ! is_dir( $dir ) ) {
4082 return;
4083 }
4084 // static_path() already refuses every traversal spelling. This
4085 // catches a directory in the tree that resolves somewhere else.
4086 $dir_real = realpath( $dir );
4087 $static_real = realpath( $base );
4088 if ( false === $dir_real || false === $static_real || 0 !== strpos( $dir_real . '/', $static_real . '/' ) ) {
4089 return;
4090 }
4091 // 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.
4092 $written = file_put_contents( $file, $html, LOCK_EX );
4093
4094 // A nonce-bearing page expires on the nonce's schedule, not the site's.
4095 // Nothing reads this file at serve time — the web server hands over
4096 // index.html without PHP — so the deadline is recorded beside it for
4097 // GC, which is the only thing that can enforce it. Written before the
4098 // action below so a listener that shells out cannot race the sweep.
4099 if ( false !== $written && $nonce_ttl > 0 ) {
4100 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- same rationale as the write above.
4101 file_put_contents( $dir . '/' . self::EXPIRES_MARKER, (string) ( time() + $nonce_ttl ), LOCK_EX );
4102 }
4103
4104 if ( false !== $written ) {
4105 /**
4106 * Fires after a static cache file (index.html) is written.
4107 *
4108 * The extension point for serving pre-compressed siblings:
4109 * the xspeed-pro Brotli module writes `index.html.br` next to
4110 * the file here so the web server's static rewrite can serve a
4111 * Brotli copy to clients that advertise `Accept-Encoding: br`,
4112 * falling back to GZIP / the plain file otherwise. No core
4113 * behavior depends on a listener being present.
4114 *
4115 * @param string $file Absolute path to the static cache file just written.
4116 * @param string $html The HTML written to it.
4117 */
4118 do_action( 'xspeed_static_file_written', $file, $html );
4119 }
4120 }
4121
4122 /**
4123 * Write the .meta sidecar for a cache entry when the response carries
4124 * anything the HIT path must replay beyond a plain 200 text/html:
4125 * - a non-HTML Content-Type (cached feeds → application/rss+xml,
4126 * sitemaps → text/xml, …), and/or
4127 * - a non-200 status (a cached 404 must serve 404, not 200).
4128 *
4129 * Ordinary 200 text/html pages get NO .meta file, so the common path
4130 * stays a single write.
4131 *
4132 * @param string $key Cache key for the current request.
4133 */
4134 /**
4135 * True only for a plain 200 text/html response — the only kind the
4136 * web-server static tree can serve correctly (it streams the .html with
4137 * no PHP, so it can't replay a 404 status or a feed Content-Type). Used
4138 * to gate store_static() so cached 404s / feeds stay on the replay-capable
4139 * drop-in / PHP path. (FBS-82406, FBS-82407)
4140 */
4141 private static function response_is_plain_html(): bool {
4142 $status = function_exists( 'http_response_code' ) ? (int) http_response_code() : 200;
4143 if ( 200 !== $status && $status > 0 ) {
4144 return false;
4145 }
4146 foreach ( headers_list() as $header ) {
4147 if ( 0 === stripos( $header, 'content-type:' ) ) {
4148 $ct = trim( substr( $header, strlen( 'content-type:' ) ) );
4149 if ( '' !== $ct && false === stripos( $ct, 'text/html' ) ) {
4150 return false;
4151 }
4152 }
4153 }
4154 return true;
4155 }
4156
4157 /**
4158 * Append the cache signature comment to a finished page.
4159 *
4160 * The plugin's one outward version signal: external scanners (the
4161 * xspeedcache.com speed test among them) read it to detect xSpeed and
4162 * its version on a cached page, the way other cache plugins sign their
4163 * output. Callers apply it AFTER HTML minification — the minifier strips
4164 * comments — and before every cache write, so all serve paths carry the
4165 * same bytes.
4166 *
4167 * The generation time is baked in here, at write time, in UTC. It is the
4168 * moment the cached bytes were produced — NOT the moment they were served
4169 * — because all three serve paths replay the same stored file, and two of
4170 * them (the nginx/`.htaccess` static rewrite) run no PHP at all and so
4171 * could never stamp a serve-time value. Reading the age of a page is the
4172 * point: `generated` plus the current clock tells you how stale it is.
4173 * `gmdate()` (not `current_time()`) keeps the value comparable across
4174 * sites regardless of the configured timezone.
4175 *
4176 * @param string $html Finished page HTML.
4177 * @return string HTML with the signature appended (or unchanged when a
4178 * filter removed it).
4179 */
4180 private static function signed( string $html ): string {
4181 $version = defined( 'XSPEED_VERSION' ) ? XSPEED_VERSION : '';
4182 $generated = gmdate( 'Y-m-d H:i:s' ) . ' UTC';
4183 // The literal ' | xspeedcache.com' must survive intact, and what
4184 // precedes it is where an edition suffix lands: Pro appends itself by
4185 // str_replace()-ing on that exact token
4186 // (Pro_Plugin::sign_cache_signature). So the stamp goes AFTER it —
4187 // placed before, it sits between the version and the anchor and
4188 // composes as "generated <date> + Pro v1.1.3".
4189 $signature = sprintf(
4190 '<!-- Page cached by xSpeed Cache v%s | xspeedcache.com | generated %s -->',
4191 $version,
4192 $generated
4193 );
4194
4195 /**
4196 * Filter: xspeed_cache_signature
4197 *
4198 * The HTML comment appended to every cached page. Add-ons append
4199 * their own edition/version here; white-label setups return '' to
4200 * remove the comment entirely. Must remain a valid HTML comment (or
4201 * an empty string) — it ships inside the cached body.
4202 *
4203 * @param string $signature The signature comment.
4204 * @param string $version The plugin version baked into it.
4205 * @param string $generated The write-time timestamp baked into it,
4206 * formatted `Y-m-d H:i:s UTC`.
4207 */
4208 $signature = (string) apply_filters( 'xspeed_cache_signature', $signature, $version, $generated );
4209 if ( '' === trim( $signature ) ) {
4210 return $html;
4211 }
4212 return $html . "\n" . $signature;
4213 }
4214
4215 /**
4216 * Does this response carry a WordPress nonce?
4217 *
4218 * Anonymous nonces depend only on the tick (user 0, empty session
4219 * token), so they are identical for every visitor — which is exactly why
4220 * they cache "successfully" and then fail silently once the tick moves.
4221 *
4222 * Matches any form field whose NAME contains "nonce" — `_wpnonce`,
4223 * `_wpnonce_<action>`, Tutor's `_tutor_nonce`, CF7's `_wpcf7_nonce` and
4224 * WooCommerce's `woocommerce-add-to-cart-nonce` (which does NOT start
4225 * with an underscore, so a `_`-anchored pattern misses it) — plus the
4226 * `_wpnonce=` form used in nonce-bearing URLs. Deliberately keyed on
4227 * `name=` so prose, CSS classes and data attributes don't false-positive.
4228 *
4229 * @param string $html Rendered response body.
4230 */
4231 public static function response_has_nonce( string $html ): bool {
4232 if ( '' === $html ) {
4233 return false;
4234 }
4235
4236 /*
4237 * Three shapes, because a nonce reaches the page in three ways:
4238 *
4239 * 1. A form field name — `_wpnonce`, `woocommerce-login-nonce`, and
4240 * the GROUPED names form builders emit (`data[_wpnonce]`,
4241 * `frm[nonce]`). The character class deliberately allows `[` and
4242 * `]` so grouping does not hide the field: form builders are
4243 * exactly the kind of plugin #236 is about, and a missed page
4244 * keeps the old broken behaviour silently.
4245 * 2. A query argument (`?_wpnonce=`) on a link.
4246 * 3. A nonce handed to the page's own scripts rather than placed in
4247 * a visible form — `wp_localize_script()` output and inline JSON
4248 * both land as a `"nonce":"…"`-shaped pair.
4249 */
4250 return 1 === preg_match(
4251 '/(name=["\'][a-z0-9_\-\[\]]*nonce[a-z0-9_\-\[\]]*["\']'
4252 . '|[?&]_wpnonce='
4253 . '|["\'][a-z0-9_\-]*nonce[a-z0-9_\-]*["\']\s*:\s*["\'][a-f0-9]{8,}["\'])/i',
4254 $html
4255 );
4256 }
4257
4258 /**
4259 * The TTL (seconds) a response may be cached for, capped to the nonce
4260 * lifetime when it carries one.
4261 *
4262 * WordPress nonces are valid for at most `nonce_life` — 24h by default —
4263 * because wp_verify_nonce() accepts the current tick and the previous
4264 * one. Our own lifetime maximum is 720h and the shipped Aggressive
4265 * preset is 168h, so on any site configured above 24h every anonymous
4266 * front-end form carried a DEAD nonce for the majority of the cache's
4267 * life and every submission was rejected — with the other plugin's error
4268 * string ("Nonce not matched"), so the report never reached us (#236).
4269 *
4270 * `nonce_life` is the MAXIMUM a nonce can live, not the minimum, so it
4271 * is the wrong number to cap with. wp_nonce_tick() buckets time into
4272 * `nonce_life / 2` slices; a nonce minted x seconds into its bucket is
4273 * valid for `nonce_life - x`, where x can be as large as a full bucket.
4274 * Capping the entry at `nonce_life` therefore still served a dead nonce
4275 * for up to half of every entry's life — 0-12h of each 24h entry,
4276 * averaging 6h, re-rolled by every purge so it reads as intermittent.
4277 * Capping at the guaranteed-valid remainder closes the window at every
4278 * tick phase, at the cost of caching nonce-bearing pages for 12h rather
4279 * than 24h.
4280 *
4281 * Capping is per-entry, so only nonce-bearing pages pay for it; the rest
4282 * of the site keeps the configured lifetime.
4283 *
4284 * @param string $html Rendered response body.
4285 * @param int $ttl Otherwise-resolved TTL in seconds.
4286 * @return int TTL to actually use.
4287 */
4288 /**
4289 * The nonce lifetime to cap against, in seconds.
4290 *
4291 * `nonce_life` is a TWO-argument filter in core:
4292 *
4293 * $nonce_life = apply_filters( 'nonce_life', DAY_IN_SECONDS, $action );
4294 *
4295 * Applying it with one argument is not merely incomplete — a callback
4296 * that declares both parameters as required (the documented shape, and
4297 * what a site branching per action must write) raises ArgumentCountError
4298 * the moment we call it. That fatal lands in the shutdown cache write,
4299 * so the visitor still sees a perfectly normal page while the sidecar is
4300 * never written: the entry then keeps the FULL configured lifetime
4301 * carrying a dead nonce, which is precisely the bug #236 set out to fix.
4302 * Worse, the entry stays that way until a purge, even after the site
4303 * removes whatever customised the lifetime.
4304 *
4305 * We are inspecting rendered markup, so we cannot know which action
4306 * minted the nonce we found. Two consequences:
4307 *
4308 * 1. We pass `''` as the action. A per-action callback therefore sees
4309 * the same "unknown action" value core itself passes when a nonce is
4310 * created with no action, and can branch on it deliberately.
4311 * 2. A page may carry nonces from SEVERAL actions with different
4312 * lifetimes. The entry can only have one TTL, so the safe choice is
4313 * the SHORTEST lifetime any action on the site resolves to — capping
4314 * to a longer one would serve a dead nonce for the shorter action.
4315 * Sites can narrow this with `xspeed_cache_nonce_life_actions`.
4316 *
4317 * @param string $html Response body being cached.
4318 * @return int Nonce lifetime in seconds (0 = do not cap).
4319 */
4320 private static function nonce_life_seconds( string $html ): int {
4321 /**
4322 * Filter the nonce actions whose lifetimes are consulted when
4323 * capping a cache entry.
4324 *
4325 * The default `''` is the "action unknown" case — we are reading
4326 * rendered HTML, not minting a nonce. A site whose `nonce_life`
4327 * callback shortens specific actions can list them here so the cap
4328 * accounts for the shortest one that could appear on the page.
4329 *
4330 * @since 1.1.8
4331 * @param string[] $actions Nonce actions to resolve.
4332 * @param string $html The response body being cached.
4333 */
4334 $actions = (array) apply_filters( 'xspeed_cache_nonce_life_actions', array( '' ), $html );
4335 if ( empty( $actions ) ) {
4336 $actions = array( '' );
4337 }
4338
4339 $shortest = 0;
4340 foreach ( $actions as $action ) {
4341 // Both arguments, exactly as core passes them.
4342 $life = (int) apply_filters( 'nonce_life', DAY_IN_SECONDS, (string) $action );
4343 if ( $life < 1 ) {
4344 continue;
4345 }
4346 if ( 0 === $shortest || $life < $shortest ) {
4347 $shortest = $life;
4348 }
4349 }
4350
4351 return $shortest;
4352 }
4353
4354 public static function nonce_capped_ttl( string $html, int $ttl ): int {
4355 if ( ! self::response_has_nonce( $html ) ) {
4356 return $ttl;
4357 }
4358
4359 $nonce_life = self::nonce_life_seconds( $html );
4360 if ( $nonce_life < 1 ) {
4361 return $ttl;
4362 }
4363
4364 // Half of nonce_life is the GUARANTEED-valid remainder — see above.
4365 $guaranteed = max( 1, intdiv( $nonce_life, 2 ) );
4366 $capped = ( $ttl > 0 ) ? min( $ttl, $guaranteed ) : $guaranteed;
4367
4368 /**
4369 * Filter the nonce-capped TTL for a cache entry.
4370 *
4371 * Escape hatch for a site whose nonce-shaped markup is decorative —
4372 * return the uncapped $ttl to keep the configured lifetime. Most
4373 * sites should leave this alone: serving a dead nonce breaks every
4374 * anonymous form on the page.
4375 *
4376 * @param int $capped TTL after the nonce cap (seconds).
4377 * @param int $ttl TTL before the cap (seconds).
4378 * @param int $nonce_life Current nonce lifetime (seconds).
4379 * @param string $html The response body being cached.
4380 */
4381 return (int) apply_filters( 'xspeed_cache_nonce_ttl_cap', $capped, $ttl, $nonce_life, $html );
4382 }
4383
4384 private static function write_meta( string $key, string $html = '' ): void {
4385 $content_type = '';
4386 foreach ( headers_list() as $header ) {
4387 if ( 0 === stripos( $header, 'content-type:' ) ) {
4388 $content_type = trim( substr( $header, strlen( 'content-type:' ) ) );
4389 }
4390 }
4391 $status = function_exists( 'http_response_code' ) ? (int) http_response_code() : 200;
4392
4393 $meta = array();
4394 $is_default_type = ( '' === $content_type || false !== stripos( $content_type, 'text/html' ) );
4395 if ( ! $is_default_type ) {
4396 $meta['content_type'] = $content_type;
4397 }
4398 if ( 200 !== $status && $status > 0 ) {
4399 $meta['status'] = $status;
4400 }
4401
4402 // Per-content TTL (seconds). The drop-in and static fast paths can't
4403 // call is_expired() / the xspeed_cache_max_age filter (they run before
4404 // WP), so persist the resolved max-age here whenever it differs from
4405 // the plain page TTL — e.g. the Pro feed cache's 12h vs the 24h page
4406 // default. The fast paths read this to expire correctly. (FBS-82407)
4407 // This MUST resolve the TTL the same way is_expired() does, including
4408 // the per-post override — the sidecar is the only channel that can
4409 // carry a per-entry TTL into the pre-boot fast paths. Omitting the
4410 // override here left an editor's "expire this post after 1h" visible
4411 // to the engine but invisible to the drop-in, which kept serving the
4412 // entry until the global lifetime elapsed (#240 AC#3). Handing the
4413 // filter the same base as is_expired() also keeps a filter that
4414 // SCALES its input (e.g. $max_age * 2) consistent between the two.
4415 $opts = Settings_Manager::get( 'cache' );
4416 $default_ttl = (int) $opts['cache_expiry'] * HOUR_IN_SECONDS;
4417 $max_age = $default_ttl;
4418 $post_override = Cache_Rules::expiry_override_seconds_for_post( Cache_Rules::current_post_id() );
4419 if ( null !== $post_override ) {
4420 $max_age = $post_override;
4421 }
4422 /** This filter is documented in includes/class-cache.php */
4423 $ttl = (int) apply_filters( 'xspeed_cache_max_age', $max_age );
4424
4425 // A response carrying a nonce may not outlive that nonce, however
4426 // long the site's configured lifetime is (#236). This runs AFTER the
4427 // max-age filter so it caps whatever the filter resolved rather than
4428 // being overridden by it — a Pro module lengthening the TTL must not
4429 // be able to reintroduce a dead nonce.
4430 $ttl = self::nonce_capped_ttl( $html, $ttl );
4431
4432 if ( $ttl > 0 && $ttl !== $default_ttl ) {
4433 $meta['ttl'] = $ttl;
4434 }
4435
4436 // This entry's edge headers, when they differ from the site-wide set
4437 // baked into the drop-in. The sidecar is the only channel that can
4438 // carry a per-page answer into the pre-boot fast path, and the drop-in
4439 // REPLACES the baked set with it rather than merging: the two describe
4440 // the same response, so merging would leave the baked lifetime in
4441 // place beside the hold meant to overrule it.
4442 //
4443 // The lifetime resolved above goes with it, under the same condition
4444 // as the `ttl` key: a page that does not follow the site's lifetime
4445 // may not be kept at the edge past its own either.
4446 $edge = self::per_entry_edge_headers( isset( $meta['ttl'] ) ? $ttl : null );
4447 if ( array() !== $edge ) {
4448 $meta['edge_headers'] = $edge;
4449 }
4450
4451 // Nothing to replay → no sidecar.
4452 if ( empty( $meta ) ) {
4453 return;
4454 }
4455
4456 $payload = wp_json_encode( $meta );
4457 if ( false === $payload ) {
4458 return;
4459 }
4460 // 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.
4461 file_put_contents( self::cache_meta_for( $key ), $payload, LOCK_EX );
4462 }
4463
4464 /**
4465 * @param string $cause Free-form human reason. Recorded in the
4466 * Activity log to give users context (e.g.
4467 * 'post saved', 'settings change', 'manual',
4468 * 'theme switch').
4469 */
4470 /**
4471 * Purge the cache entries for ONE URL — every variant of it: the
4472 * flat-hash entry (+ .meta / .html.br siblings), both device buckets
4473 * (mobile_separate keys them separately), both trailing-slash forms,
4474 * and the static-tree index.html (+ .br) the server rewrite serves.
4475 * The rest of the cache is untouched — this is the surgical
4476 * alternative to purge_all for "I just edited this one page".
4477 *
4478 * @param string $url Absolute URL, or site-relative path ("/about/").
4479 * @param string $cause Who asked, for the purge log. See purge_all().
4480 * @return int Number of cache files removed.
4481 */
4482 /**
4483 * Post types that are not "viewable" but ARE the presentation layer.
4484 *
4485 * `is_post_type_viewable()` answers "does this type have a front end of
4486 * its own?" — which is the right question for `shop_order`, but the
4487 * wrong one for the types core uses to render every OTHER page. A
4488 * template part, a global-styles record, a navigation or a synced
4489 * pattern has no permalink, yet editing one changes how the whole site
4490 * looks. Gating purges on viewability alone meant a Site Editor save
4491 * invalidated nothing and visitors kept the old design for the full
4492 * TTL — up to 30 days at the maximum lifetime. (#270 regression)
4493 *
4494 * @return string[]
4495 */
4496 /**
4497 * Could this post change alter anything an anonymous visitor had cached?
4498 *
4499 * Deleting one post fired a full purge for the post AND for every stored
4500 * revision, because wp_delete_post() removes each revision through
4501 * wp_delete_post() again and every one of those fires before_delete_post
4502 * with post_type 'revision'. A post with six revisions cost seven whole-
4503 * site sweeps, each one also announcing to LiteSpeed, purging the object
4504 * cache network-wide on Redis, rewriting the stats option and running
4505 * every xspeed_after_purge_all listener -- including Pro's Cloudflare
4506 * purge, so seven API calls. Trashing cost two, via save_post and then
4507 * trashed_post. (QA #348)
4508 *
4509 * The check lives here, ahead of purge_all(), so one early return covers
4510 * the local sweep, the server-cache announcement and both action hooks.
4511 * It deliberately does NOT live inside purge_all(): a manual, CLI or
4512 * explicit caller asked for a purge and must get one.
4513 *
4514 * @param int $post_id Post being saved or removed.
4515 * @param mixed $post Post object when the hook passed one.
4516 * @param string $event 'save' or 'remove'.
4517 */
4518 private static function post_change_is_cacheable_content( $post_id, $post, string $event ): bool {
4519 $post_id = (int) $post_id;
4520
4521 // Only `save_post` and `before_delete_post` hand over a post object.
4522 // `trashed_post` passes ( $post_id, $previous_status ) -- a STRING --
4523 // so reaching for ->post_status on the second argument finds nothing
4524 // and the status rule below would never fire. Read the row instead.
4525 if ( ! is_object( $post ) && function_exists( 'get_post' ) ) {
4526 $post = get_post( $post_id );
4527 }
4528
4529 $type = is_object( $post ) && isset( $post->post_type )
4530 ? (string) $post->post_type
4531 : (string) ( function_exists( 'get_post_type' ) ? get_post_type( $post_id ) : '' );
4532 if ( '' === $type ) {
4533 return false;
4534 }
4535
4536 // A revision is a copy of content nobody can browse to.
4537 if ( 'revision' === $type ) {
4538 return false;
4539 }
4540 if ( function_exists( 'wp_is_post_revision' ) && wp_is_post_revision( $post_id ) ) {
4541 return false;
4542 }
4543 if ( function_exists( 'wp_is_post_autosave' ) && wp_is_post_autosave( $post_id ) ) {
4544 return false;
4545 }
4546
4547 $status = is_object( $post ) && isset( $post->post_status ) ? (string) $post->post_status : '';
4548
4549 // Clicking "Add New" inserts an auto-draft and fires save_post. There
4550 // is nothing cached of a post that has never existed publicly.
4551 if ( 'auto-draft' === $status ) {
4552 return false;
4553 }
4554
4555 // Unknown/!viewable → nothing anonymous can see changed, UNLESS the
4556 // type is itself part of how pages render (#270 regression).
4557 if ( function_exists( 'is_post_type_viewable' )
4558 && ! is_post_type_viewable( $type )
4559 && ! in_array( $type, self::presentation_post_types(), true )
4560 ) {
4561 return false;
4562 }
4563
4564 // Deleting something that was already invisible changes no cached
4565 // page: the transition that hid it purged at the time. This is what
4566 // makes emptying a trash of a hundred posts cost nothing rather than
4567 // a hundred full sweeps.
4568 //
4569 // It also collapses trashing to a single purge: wp_trash_post() fires
4570 // save_post first, where the post is genuinely disappearing from
4571 // listings and SHOULD purge, then trashed_post, by which point the
4572 // row reads 'trash' and is skipped. A status we cannot read, on a row
4573 // that still reports a type, means assume viewable -- erring toward
4574 // an extra purge, never toward serving a stale page. A row that is
4575 // gone entirely reports no type either and was refused above.
4576 // 'inherit' is an INTERNAL status in core, so is_post_status_viewable()
4577 // says no -- but an attachment carrying it is genuinely public. Judge
4578 // those on the post type alone, which is already checked above.
4579 if ( 'remove' === $event && '' !== $status && 'inherit' !== $status
4580 && function_exists( 'is_post_status_viewable' )
4581 && ! is_post_status_viewable( $status )
4582 ) {
4583 return false;
4584 }
4585
4586 return true;
4587 }
4588
4589 public static function presentation_post_types(): array {
4590 $types = array(
4591 'wp_template', // Site Editor templates.
4592 'wp_template_part', // Header / footer / reusable parts.
4593 'wp_global_styles', // Colours, typography, spacing.
4594 'wp_navigation', // Navigation block menus.
4595 'nav_menu_item', // Classic menus.
4596 'wp_block', // Synced patterns / reusable blocks.
4597 );
4598
4599 /**
4600 * Filter the non-viewable post types that still invalidate the cache.
4601 *
4602 * Add a type here when it has no front end of its own but changes
4603 * how other pages render (a theme's own layout CPT, for example).
4604 *
4605 * @param string[] $types Post type slugs.
4606 */
4607 return (array) apply_filters( 'xspeed_presentation_post_types', $types );
4608 }
4609
4610 /**
4611 * Describe a broad hook invalidation for response-cache adapters.
4612 *
4613 * Term, menu, theme and plugin changes can alter navigation, archives or
4614 * markup across the site, so they require a site response-cache purge.
4615 * Content saves also require this scope while their local operation is a
4616 * complete bucket sweep.
4617 *
4618 * A new term is `content`, not `presentation`. It has no posts yet, so no
4619 * page renders it until a post is saved with it, and that save is its own
4620 * content purge. Classed as presentation, it cleared the host's whole
4621 * nginx cache every time a post was published with a tag that did not
4622 * exist yet, which is most publishing. Renaming or deleting a term stays
4623 * presentation: the new name shows on every post in the term, and Nginx
4624 * Helper purges only the homepage for either. (QA #448)
4625 *
4626 * @return array{scope:string,intent:string,urls:array<int,string>}
4627 */
4628 private static function invalidation_for_hook( string $hook ): array {
4629 $presentation = array(
4630 'switch_theme',
4631 'activated_plugin',
4632 'deactivated_plugin',
4633 'edited_term',
4634 'delete_term',
4635 'wp_update_nav_menu',
4636 );
4637
4638 return array(
4639 'scope' => 'site',
4640 'intent' => in_array( $hook, $presentation, true ) ? 'presentation' : 'content',
4641 'urls' => array(),
4642 );
4643 }
4644
4645
4646 /**
4647 * save_post → purge only when the saved thing can appear on a cached page.
4648 *
4649 * Revisions and autosaves are never rendered. Non-viewable post types —
4650 * WooCommerce's `shop_order` / `shop_order_placehold` / `shop_order_refund`
4651 * / `shop_coupon`, Flamingo's `flamingo_inbound` (#229), Tutor's
4652 * `tutor_enrolled` (#231) — are invisible to anonymous visitors, so
4653 * writing one changes nothing that is cached. (#243)
4654 *
4655 * The exception is the presentation types above, which are non-viewable
4656 * yet render every page — they are allow-listed BEFORE the viewability
4657 * test. (#270 regression)
4658 *
4659 * @param int $post_id Saved post ID.
4660 * @param \WP_Post $post Saved post object.
4661 */
4662 public static function on_save_post( $post_id, $post = null ): void {
4663 if ( ! self::post_change_is_cacheable_content( $post_id, $post, 'save' ) ) {
4664 return;
4665 }
4666
4667 $post_type = is_object( $post ) && isset( $post->post_type )
4668 ? (string) $post->post_type
4669 : (string) get_post_type( $post_id );
4670
4671 // Name the trigger rather than logging a bare numeric id — the old
4672 // wiring passed the post ID into $cause, so the log read
4673 // "Cache purged (46)" with no indication of what caused it. (#243)
4674 $presentation = in_array( $post_type, self::presentation_post_types(), true );
4675
4676 // Clearing only the affected pages needs the post's terms, and the
4677 // REST API sets those after `save_post`. Note the post and purge on
4678 // `wp_after_insert_post`; flush_pending_saves() covers a save that
4679 // never gets there.
4680 if ( ! $presentation && self::narrow_purge_applies( $post_type ) ) {
4681 self::$pending_saves[ (int) $post_id ] = true;
4682 return;
4683 }
4684
4685 self::purge_all(
4686 'post:' . $post_type,
4687 null,
4688 array(
4689 // purge_all() sweeps every local response in this site's bucket.
4690 // Without dependency tracking, the server cache must match that
4691 // same boundary or unrelated pages can remain stale there.
4692 'scope' => 'site',
4693 'intent' => $presentation ? 'presentation' : 'content',
4694 'urls' => array(),
4695 )
4696 );
4697 }
4698
4699 /**
4700 * Reasons a change that would have cleared only its own pages cleared
4701 * the whole site, published as `fallback` in the purge context.
4702 *
4703 * - THEME_LIST: the change alters a post or comment list the theme draws
4704 * on pages the rules cannot name.
4705 * - LIMIT: the affected pages are more than Affected_Pages::LIMIT.
4706 * - PENDING: the save never reached `wp_after_insert_post`, so there was
4707 * no before-copy to work the old address out from.
4708 * - FILTER: `xspeed_purge_affected_pages` returned false.
4709 * - LISTING: the record of pages that run a post list of their own
4710 * (Listing_Pages) cannot answer: a page of the type went unrecorded at
4711 * its cap, more than Affected_Pages::LIMIT pages of the type would
4712 * have to be named, or the index does not read back. Or the pages it
4713 * named took the list past Affected_Pages::LIMIT.
4714 *
4715 * Every other purge publishes ''. A post type that always purges the
4716 * whole site (a WooCommerce product) is a choice, not a fallback, and
4717 * publishes '' too.
4718 */
4719 public const FALLBACK_THEME_LIST = 'theme_list';
4720 public const FALLBACK_LIMIT = 'limit';
4721 public const FALLBACK_PENDING = 'pending';
4722 public const FALLBACK_FILTER = 'filter';
4723 public const FALLBACK_LISTING = 'listing';
4724
4725 /**
4726 * Posts noted by on_save_post() for a narrow purge, keyed by ID.
4727 *
4728 * @var array<int,bool>
4729 */
4730 private static $pending_saves = array();
4731
4732 /**
4733 * Posts whose save purged their pages in this request, keyed by ID. That
4734 * purge named page 1 of the posts page, so a sticky change made after
4735 * it (the classic editor and Quick Edit stick after saving) has nothing
4736 * left to clear there.
4737 *
4738 * @var array<int,bool>
4739 */
4740 private static $saves_purged = array();
4741
4742 /**
4743 * Purge the pages a save affected, once WordPress has finished saving.
4744 *
4745 * @param int $post_id Post ID.
4746 * @param \WP_Post $post Post as saved.
4747 * @param bool $update Whether an existing post was updated.
4748 * @param \WP_Post|null $post_before Post before the save, null when new.
4749 */
4750 public static function on_after_insert_post( $post_id, $post = null, $update = false, $post_before = null ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- hook signature.
4751 $post_id = (int) $post_id;
4752 if ( ! isset( self::$pending_saves[ $post_id ] ) ) {
4753 return;
4754 }
4755 unset( self::$pending_saves[ $post_id ] );
4756 if ( ! $post instanceof \WP_Post ) {
4757 $post = get_post( $post_id );
4758 }
4759 if ( ! $post instanceof \WP_Post ) {
4760 return;
4761 }
4762 $post_before = $post_before instanceof \WP_Post ? $post_before : null;
4763 if ( self::repeats_block_editor_save( $post, $post_before ) ) {
4764 Affected_Pages::forget( $post_id );
4765 return;
4766 }
4767 self::$saves_purged[ $post_id ] = true;
4768 self::purge_post_change( $post, $post_before, 'post:' . $post->post_type );
4769 }
4770
4771 /** Meta WordPress rewrites on every save, which says nothing about the page. */
4772 private const SAVE_NOISE_META = array( '_edit_lock', '_edit_last', '_encloseme', '_pingme' );
4773
4774 /**
4775 * Whether this save repeats the block editor save that just purged.
4776 *
4777 * The block editor saves a post through REST, then, when the screen has
4778 * meta boxes (xSpeed's own Cache Rules box is one), posts them to
4779 * post.php?meta-box-loader=1, which saves the post a second time. Both
4780 * requests purged, so every save sent the same pages to every cache in
4781 * front twice. The REST save notes what it purged for; the meta box
4782 * request skips its purge when nothing a visitor sees has changed since:
4783 * the post's fields, its terms and its meta. A meta box that saved meta
4784 * the page prints (an SEO title, a custom field) still purges.
4785 *
4786 * @param \WP_Post $post Post as saved.
4787 * @param \WP_Post|null $before Post before the save.
4788 */
4789 private static function repeats_block_editor_save( \WP_Post $post, ?\WP_Post $before ): bool {
4790 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- only names the request; WordPress verified the save's own nonce.
4791 $meta_boxes = isset( $_GET['meta-box-loader'] );
4792 $rest = defined( 'REST_REQUEST' ) && REST_REQUEST;
4793 if ( ! $meta_boxes && ! $rest ) {
4794 return false;
4795 }
4796 if ( ! Affected_Pages::is_public( $post ) && ! ( $before instanceof \WP_Post && Affected_Pages::is_public( $before ) ) ) {
4797 return false;
4798 }
4799 $key = 'xspeed_saved_' . (int) $post->ID;
4800 $fingerprint = self::save_fingerprint( $post );
4801 if ( $meta_boxes ) {
4802 if ( get_transient( $key ) === $fingerprint ) {
4803 delete_transient( $key );
4804 return true;
4805 }
4806 return false;
4807 }
4808 set_transient( $key, $fingerprint, 2 * MINUTE_IN_SECONDS );
4809 return false;
4810 }
4811
4812 /**
4813 * A hash of what a save can change that a visitor sees: the post's own
4814 * fields, its terms and its meta.
4815 *
4816 * @param \WP_Post $post Post as saved.
4817 */
4818 private static function save_fingerprint( \WP_Post $post ): string {
4819 $fields = array();
4820 foreach ( array( 'post_type', 'post_status', 'post_date', 'post_title', 'post_name', 'post_content', 'post_excerpt', 'post_parent', 'menu_order', 'post_author', 'post_password', 'comment_status' ) as $field ) {
4821 $fields[ $field ] = (string) ( $post->$field ?? '' );
4822 }
4823 $terms = array();
4824 $taxonomies = get_object_taxonomies( (string) $post->post_type );
4825 if ( is_array( $taxonomies ) && array() !== $taxonomies ) {
4826 $ids = wp_get_object_terms( (int) $post->ID, $taxonomies, array( 'fields' => 'tt_ids' ) );
4827 $terms = is_array( $ids ) ? array_map( 'intval', $ids ) : array();
4828 sort( $terms );
4829 }
4830 $meta = get_post_meta( (int) $post->ID );
4831 $meta = is_array( $meta ) ? array_diff_key( $meta, array_flip( self::SAVE_NOISE_META ) ) : array();
4832 ksort( $meta );
4833 return md5( (string) wp_json_encode( array( $fields, $terms, $meta ) ) );
4834 }
4835
4836 /**
4837 * A save that never reached `wp_after_insert_post` still purges.
4838 *
4839 * `wp_insert_post()` called with `$fire_after_hooks = false` leaves that
4840 * hook to its caller, and a caller can fail to fire it. Without this, the
4841 * save would purge nothing at all. Site-wide, because there is no
4842 * before-copy to work out the old address from.
4843 */
4844 public static function flush_pending_saves(): void {
4845 if ( array() === self::$pending_saves ) {
4846 return;
4847 }
4848 $ids = array_keys( self::$pending_saves );
4849 self::$pending_saves = array();
4850 self::purge_all(
4851 'post:pending',
4852 null,
4853 array(
4854 'scope' => 'site',
4855 'intent' => 'content',
4856 'urls' => array(),
4857 'fallback' => self::FALLBACK_PENDING,
4858 )
4859 );
4860 foreach ( $ids as $id ) {
4861 Affected_Pages::forget( (int) $id );
4862 }
4863 }
4864
4865 /**
4866 * Before an update is written: note the post's neighbours, which a new
4867 * date or category takes it away from. Only for a post visitors can see
4868 * now, and only when the save will purge narrowly; that costs four
4869 * queries per update.
4870 *
4871 * @param int $post_id Post ID.
4872 * @param array $data Unused: the new values.
4873 */
4874 public static function on_pre_post_update( $post_id, $data = array() ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- hook signature.
4875 $post = get_post( (int) $post_id );
4876 if ( ! $post instanceof \WP_Post || ! Affected_Pages::is_public( $post ) ) {
4877 return;
4878 }
4879 if ( in_array( (string) $post->post_type, self::presentation_post_types(), true ) || ! self::narrow_purge_applies( (string) $post->post_type ) ) {
4880 return;
4881 }
4882 Affected_Pages::remember_old_neighbours( $post );
4883 }
4884
4885 /**
4886 * A post was stuck or unstuck.
4887 *
4888 * A theme list that puts sticky posts first (Twenty Twenty-Five's "More
4889 * posts" under every post) changes on every page that draws it, so the
4890 * whole site goes. The block editor changes stickiness inside the save,
4891 * and the save's own purge sees it then. The classic editor and Quick
4892 * Edit change it after the save has purged, which is why this hook
4893 * purges the posts no save is waiting on.
4894 *
4895 * @param mixed $old_value Sticky post IDs before.
4896 * @param mixed $value Sticky post IDs after.
4897 */
4898 public static function on_sticky_posts_change( $old_value, $value ): void {
4899 $blog_page = false;
4900 foreach ( Affected_Pages::remember_sticky_change( $old_value, $value ) as $post_id ) {
4901 if ( isset( self::$pending_saves[ $post_id ] ) ) {
4902 continue;
4903 }
4904 $post = get_post( $post_id );
4905 if ( ! $post instanceof \WP_Post || ! Affected_Pages::is_public( $post ) || ! self::narrow_purge_applies( (string) $post->post_type ) ) {
4906 continue;
4907 }
4908 if ( Affected_Pages::lists_show_sticky( (string) $post->post_type ) ) {
4909 self::purge_all(
4910 'sticky:' . $post->post_type,
4911 null,
4912 array(
4913 'scope' => 'site',
4914 'intent' => 'content',
4915 'urls' => array(),
4916 'fallback' => self::FALLBACK_THEME_LIST,
4917 )
4918 );
4919 return;
4920 }
4921 // WordPress's own blog list puts sticky posts first on its first
4922 // page, whatever the theme draws, so a post stuck or unstuck by
4923 // code, WP-CLI or a plugin moves on that page. A save in this
4924 // request already cleared it.
4925 if ( 'post' === $post->post_type && ! isset( self::$saves_purged[ $post_id ] ) ) {
4926 $blog_page = true;
4927 }
4928 }
4929 if ( $blog_page ) {
4930 $url = Affected_Pages::posts_page_url();
4931 if ( '' !== $url ) {
4932 self::purge_urls( array( $url ), 'sticky:post' );
4933 }
4934 }
4935 }
4936
4937 /**
4938 * The first post stuck on a site: WordPress creates `sticky_posts`
4939 * instead of updating it, so update_option_sticky_posts never fires.
4940 *
4941 * @param string $option Option name.
4942 * @param mixed $value Sticky post IDs.
4943 */
4944 public static function on_sticky_posts_added( $option, $value ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed -- hook signature.
4945 self::on_sticky_posts_change( array(), $value );
4946 }
4947
4948 /**
4949 * Whether a content change to this post type may clear only the pages
4950 * it affects, before looking at the post itself.
4951 *
4952 * Off when the setting is off, during an import (one full purge runs at
4953 * the end), and for post types whose lists the rules do not know:
4954 * WooCommerce products, whose shop and category pages purge_product()
4955 * already handles.
4956 *
4957 * @param string $post_type Post type.
4958 */
4959 private static function narrow_purge_applies( string $post_type ): bool {
4960 if ( defined( 'WP_IMPORTING' ) && WP_IMPORTING ) {
4961 return false;
4962 }
4963 $opts = Settings_Manager::get( 'cache' );
4964 if ( empty( $opts['purge_affected_only'] ) ) {
4965 return false;
4966 }
4967 /**
4968 * Filter the post types whose saves always purge the whole site.
4969 *
4970 * @param string[] $types Post type slugs.
4971 */
4972 $site_wide = (array) apply_filters( 'xspeed_site_wide_purge_post_types', array( 'product', 'product_variation' ) );
4973 return ! in_array( $post_type, $site_wide, true );
4974 }
4975
4976 /**
4977 * Purge what a change to one post affects, or the whole site when the
4978 * affected pages cannot be listed safely.
4979 *
4980 * The pages are the ones the rules name (Affected_Pages::for_post())
4981 * and the recorded pages whose own post lists the change may alter
4982 * (Listing_Pages::pages_for()).
4983 *
4984 * Site-wide when this change alters a post list the theme draws on
4985 * pages the rules cannot name (Affected_Pages::lists_changed_by()), when
4986 * the record of listing pages cannot answer, when the list is
4987 * longer than Affected_Pages::LIMIT, or when the filter says so. Nothing
4988 * when the post was not public before or after: no page anyone can see
4989 * changed.
4990 *
4991 * @param \WP_Post $post Post as it is now.
4992 * @param \WP_Post|null $before Post before the change.
4993 * @param string $cause Purge log cause.
4994 */
4995 private static function purge_post_change( \WP_Post $post, ?\WP_Post $before, string $cause ): void {
4996 // Checked before any list is consulted. A draft save moves the
4997 // draft's date and can match a list of any kind, which would send a
4998 // change nobody can see to a site-wide purge.
4999 if ( ! Affected_Pages::is_public( $post ) && ! ( $before instanceof \WP_Post && Affected_Pages::is_public( $before ) ) ) {
5000 Affected_Pages::forget( (int) $post->ID );
5001 return;
5002 }
5003 $fallback = '';
5004 $urls = null;
5005 if ( Affected_Pages::lists_changed_by( $post, $before ) ) {
5006 $fallback = self::FALLBACK_THEME_LIST;
5007 } else {
5008 $urls = Affected_Pages::for_post( $post, $before );
5009 // Read before forget(): a plain edit is judged on the terms the
5010 // save replaced.
5011 $listing = Listing_Pages::enabled() ? Listing_Pages::pages_for( $post, $before ) : array();
5012 if ( null === $listing ) {
5013 $fallback = self::FALLBACK_LISTING;
5014 $urls = null;
5015 } else {
5016 // Pages the rules did not name. Nginx Helper's own purge does
5017 // not reach them either, so a fallback they cause has to say so.
5018 $extra = array_diff( $listing, $urls );
5019 $urls = array_values( array_unique( array_merge( $urls, $listing ) ) );
5020 if ( count( $urls ) > Affected_Pages::LIMIT ) {
5021 $fallback = array() === $extra ? self::FALLBACK_LIMIT : self::FALLBACK_LISTING;
5022 $urls = null;
5023 }
5024 }
5025 }
5026 Affected_Pages::forget( (int) $post->ID );
5027
5028 if ( is_array( $urls ) ) {
5029 /**
5030 * Filter the pages a post change clears.
5031 *
5032 * Return false to purge the whole site instead.
5033 *
5034 * @param string[]|false $urls Absolute URLs.
5035 * @param \WP_Post $post The post that changed.
5036 */
5037 $urls = apply_filters( 'xspeed_purge_affected_pages', $urls, $post );
5038 if ( ! is_array( $urls ) ) {
5039 $fallback = self::FALLBACK_FILTER;
5040 }
5041 }
5042
5043 if ( ! is_array( $urls ) ) {
5044 self::purge_all(
5045 $cause,
5046 null,
5047 array(
5048 'scope' => 'site',
5049 'intent' => 'content',
5050 'urls' => array(),
5051 'fallback' => $fallback,
5052 )
5053 );
5054 return;
5055 }
5056 if ( array() !== $urls ) {
5057 self::purge_urls( $urls, $cause );
5058 }
5059 }
5060
5061 /**
5062 * Delete/trash invalidation while the post type is still available.
5063 * The local and server response-cache sweeps share the same site boundary.
5064 *
5065 * @param int $post_id Removed post ID.
5066 * @param object|null $post Post object supplied by core when available.
5067 */
5068 public static function on_post_removed( $post_id, $post = null ): void {
5069 if ( ! self::post_change_is_cacheable_content( $post_id, $post, 'remove' ) ) {
5070 return;
5071 }
5072
5073 $post_type = is_object( $post ) && isset( $post->post_type )
5074 ? (string) $post->post_type
5075 : (string) get_post_type( $post_id );
5076
5077 if ( ! in_array( $post_type, self::presentation_post_types(), true ) && self::narrow_purge_applies( $post_type ) ) {
5078 $object = $post instanceof \WP_Post ? $post : get_post( (int) $post_id );
5079 if ( $object instanceof \WP_Post ) {
5080 if ( 'attachment' === $post_type ) {
5081 // Its own page and the post it is attached to. Pages that
5082 // show the file keep an <img> to a file that is gone either
5083 // way: a fresh render prints the same tag.
5084 $urls = array();
5085 $link = get_permalink( $object );
5086 if ( is_string( $link ) && '' !== $link ) {
5087 $urls[] = $link;
5088 }
5089 $parent = $object->post_parent > 0 ? get_post( (int) $object->post_parent ) : null;
5090 if ( $parent instanceof \WP_Post && 'publish' === $parent->post_status ) {
5091 $parent_link = get_permalink( $parent );
5092 if ( is_string( $parent_link ) && '' !== $parent_link ) {
5093 $urls[] = $parent_link;
5094 }
5095 }
5096 if ( array() !== $urls ) {
5097 self::purge_urls( $urls, 'post-removed:attachment' );
5098 }
5099 return;
5100 }
5101 // Still published here: `before_delete_post` runs before the
5102 // row goes. Trashing reaches the save path instead.
5103 self::purge_post_change( $object, null, 'post-removed:' . $post_type );
5104 return;
5105 }
5106 }
5107
5108 self::purge_all(
5109 'post-removed:' . $post_type,
5110 null,
5111 array(
5112 'scope' => 'site',
5113 // Match on_save_post: a presentation type changes how pages
5114 // render rather than what they say.
5115 'intent' => in_array( $post_type, self::presentation_post_types(), true )
5116 ? 'presentation'
5117 : 'content',
5118 'urls' => array(),
5119 )
5120 );
5121 }
5122
5123 /** Purge site responses when moderation changes visible comments. */
5124 public static function on_comment_status( $comment_id, $status = '' ): void {
5125 $comment = function_exists( 'get_comment' ) ? get_comment( (int) $comment_id ) : null;
5126 $post_id = is_object( $comment ) && isset( $comment->comment_post_ID ) ? (int) $comment->comment_post_ID : 0;
5127 if ( $post_id < 1 || ! function_exists( 'get_permalink' ) ) {
5128 return;
5129 }
5130 $url = get_permalink( $post_id );
5131 if ( ! is_string( $url ) || '' === $url ) {
5132 return;
5133 }
5134 $post = function_exists( 'get_post' ) ? get_post( $post_id ) : null;
5135 $narrow = $post instanceof \WP_Post && self::narrow_purge_applies( (string) $post->post_type );
5136 if ( $narrow && ! Affected_Pages::site_lists_comments() ) {
5137 $urls = Affected_Pages::for_comment( $post );
5138 if ( array() !== $urls ) {
5139 self::purge_urls( $urls, 'comment-status:' . (string) $status );
5140 }
5141 return;
5142 }
5143 self::purge_all(
5144 'comment-status:' . (string) $status,
5145 null,
5146 array(
5147 'scope' => 'site',
5148 'intent' => 'content',
5149 'urls' => array(),
5150 // Narrow purges apply to this post, so the site-wide purge is
5151 // for a recent-comments list on pages the rules cannot name.
5152 'fallback' => $narrow ? self::FALLBACK_THEME_LIST : '',
5153 )
5154 );
5155 }
5156
5157 /**
5158 * comment_post → purge just the commented-on URL, and only once the
5159 * comment is actually visible.
5160 *
5161 * A comment held for moderation changes nothing on the front end, and an
5162 * approved one changes exactly one page — not the whole site. Product
5163 * reviews are comments and guest reviews are on by default, so under the
5164 * old wiring any visitor could flush a store's entire cache, repeatedly,
5165 * with no account. (#243)
5166 *
5167 * @param int $comment_id New comment ID.
5168 * @param int|string $approved 1 when approved, 0 when held, 'spam'.
5169 * @param array $data Comment data.
5170 */
5171 public static function on_comment_post( $comment_id, $approved = 0, $data = array() ): void {
5172 if ( 1 !== (int) $approved ) {
5173 return;
5174 }
5175 $post_id = is_array( $data ) && isset( $data['comment_post_ID'] ) ? (int) $data['comment_post_ID'] : 0;
5176 if ( $post_id < 1 ) {
5177 return;
5178 }
5179 // With narrow purges on, the comment pages and comment feeds go too.
5180 // No site-wide fallback here: a visitor's comment must never be able
5181 // to clear the whole site (#243).
5182 $post = function_exists( 'get_post' ) ? get_post( $post_id ) : null;
5183 if ( $post instanceof \WP_Post && self::narrow_purge_applies( (string) $post->post_type ) ) {
5184 $urls = Affected_Pages::for_comment( $post );
5185 if ( array() !== $urls ) {
5186 self::purge_urls( $urls, 'comment' );
5187 }
5188 return;
5189 }
5190 $url = get_permalink( $post_id );
5191 if ( is_string( $url ) && '' !== $url ) {
5192 self::purge_url( $url, 'comment' );
5193 }
5194 }
5195
5196 /**
5197 * user_register / profile_update → purge only when the user can author
5198 * content that appears on the front end.
5199 *
5200 * A customer registering at checkout changes no rendered page, and cannot
5201 * change an enqueued asset — so it must not purge the cache, and must not
5202 * rebuild the minified bundles. Checkout account-creation fired FOUR
5203 * full-site purges plus four purge_minified() runs in a single request
5204 * before this gate. (#243)
5205 *
5206 * @param int $user_id Affected user.
5207 */
5208 public static function on_user_change( $user_id ): void {
5209 $user = function_exists( 'get_userdata' ) ? get_userdata( (int) $user_id ) : null;
5210 if ( ! $user ) {
5211 return;
5212 }
5213
5214 // Only roles that can publish can change a rendered page. WooCommerce
5215 // customers and WordPress subscribers cannot.
5216 if ( ! user_can( $user, 'edit_posts' ) ) {
5217 return;
5218 }
5219
5220 $url = get_author_posts_url( (int) $user_id );
5221 if ( is_string( $url ) && '' !== $url ) {
5222 self::purge_url( $url, 'user' );
5223 }
5224 }
5225
5226 /**
5227 * Purge everything a product's price / stock / sale state is rendered on.
5228 *
5229 * The product permalink is not enough: the shop archive and the product's
5230 * category and tag archives render the same price and Sale! badge, and
5231 * #242 reproduces all three going stale together.
5232 *
5233 * Accepts a product ID or a WC_Product. A variation resolves to its
5234 * parent, which is the page that actually renders.
5235 *
5236 * @param int|object $product Product ID or WC_Product.
5237 */
5238 public static function purge_product( $product ): void {
5239 $product_id = is_object( $product ) && method_exists( $product, 'get_id' )
5240 ? (int) $product->get_id()
5241 : (int) $product;
5242 if ( $product_id < 1 ) {
5243 return;
5244 }
5245
5246 // Variations are never rendered on their own URL.
5247 $parent = (int) wp_get_post_parent_id( $product_id );
5248 if ( $parent > 0 ) {
5249 $product_id = $parent;
5250 }
5251
5252 // wp-admin's Update saves the product through WooCommerce inside the
5253 // post's own save_post, and Cache::on_save_post() then clears the
5254 // whole site for it, since products are in
5255 // xspeed_site_wide_purge_post_types. Sending these pages first only
5256 // purged them twice. A price or stock change made without a post
5257 // save (an order, the REST API, `$product->save()`) still purges here.
5258 if ( self::site_wide_save_under_way( $product_id ) ) {
5259 return;
5260 }
5261
5262 $urls = array();
5263
5264 $permalink = get_permalink( $product_id );
5265 if ( is_string( $permalink ) && '' !== $permalink ) {
5266 $urls[] = $permalink;
5267 }
5268
5269 // The shop archive.
5270 if ( function_exists( 'wc_get_page_id' ) ) {
5271 $shop_id = (int) wc_get_page_id( 'shop' );
5272 if ( $shop_id > 0 ) {
5273 $shop_url = get_permalink( $shop_id );
5274 if ( is_string( $shop_url ) && '' !== $shop_url ) {
5275 $urls[] = $shop_url;
5276 }
5277 }
5278 }
5279
5280 // Every category / tag archive this product appears on.
5281 foreach ( array( 'product_cat', 'product_tag' ) as $taxonomy ) {
5282 $terms = get_the_terms( $product_id, $taxonomy );
5283 if ( ! is_array( $terms ) ) {
5284 continue;
5285 }
5286 foreach ( $terms as $term ) {
5287 $term_url = get_term_link( $term );
5288 if ( is_string( $term_url ) && '' !== $term_url ) {
5289 $urls[] = $term_url;
5290 }
5291 }
5292 }
5293
5294 // The front page, when it is not the shop page but still lists
5295 // products (a block/shortcode storefront).
5296 $front_id = (int) get_option( 'page_on_front' );
5297 if ( $front_id > 0 ) {
5298 $front_url = get_permalink( $front_id );
5299 if ( is_string( $front_url ) && '' !== $front_url ) {
5300 $urls[] = $front_url;
5301 }
5302 }
5303
5304 /**
5305 * Filter the URLs purged when a product changes.
5306 *
5307 * A storefront that renders products somewhere else — a landing page,
5308 * a custom archive — can add its URLs here rather than falling back
5309 * to purging the whole site.
5310 *
5311 * @param string[] $urls URLs about to be purged.
5312 * @param int $product_id The product that changed.
5313 */
5314 $urls = (array) apply_filters( 'xspeed_purge_product_urls', $urls, $product_id );
5315
5316 $targets = array();
5317 foreach ( $urls as $url ) {
5318 if ( is_scalar( $url ) && '' !== (string) $url ) {
5319 $targets[] = (string) $url;
5320 }
5321 }
5322 if ( array() === $targets ) {
5323 return;
5324 }
5325
5326 // One batch, as a post save sends: one purge-log row and one event
5327 // naming every page, each in the spelling WordPress links to. A
5328 // purge_url() per page published every page in both spellings,
5329 // which doubled the calls to Nginx Helper, and wrote no purge-log
5330 // row when xSpeed's own page cache was off and no file went.
5331 // purge_urls() leaves the object cache alone, which matters here:
5332 // this runs on every stock change at checkout.
5333 self::purge_urls( array_values( array_unique( $targets ) ), 'product', 'content' );
5334 }
5335
5336 /**
5337 * Posts whose save_post is running in this request, keyed by ID, with
5338 * the post as saved. Set before any other save_post listener and cleared
5339 * after the last one.
5340 *
5341 * @var array<int,\WP_Post|null>
5342 */
5343 private static $saves_under_way = array();
5344
5345 /**
5346 * A post save has started.
5347 *
5348 * @param int $post_id Post ID.
5349 * @param \WP_Post|null $post Post as saved.
5350 */
5351 public static function on_post_save_start( $post_id, $post = null ): void {
5352 self::$saves_under_way[ (int) $post_id ] = is_object( $post ) ? $post : null;
5353 }
5354
5355 /**
5356 * A post save has finished.
5357 *
5358 * @param int $post_id Post ID.
5359 */
5360 public static function on_post_save_end( $post_id ): void {
5361 unset( self::$saves_under_way[ (int) $post_id ] );
5362 }
5363
5364 /**
5365 * Whether a save_post for this post is running now and will end in
5366 * on_save_post() clearing the whole site: the same checks it makes.
5367 *
5368 * @param int $post_id Post ID.
5369 */
5370 private static function site_wide_save_under_way( int $post_id ): bool {
5371 if ( ! array_key_exists( $post_id, self::$saves_under_way ) ) {
5372 return false;
5373 }
5374 $post = self::$saves_under_way[ $post_id ];
5375 if ( ! self::post_change_is_cacheable_content( $post_id, $post, 'save' ) ) {
5376 return false;
5377 }
5378 $post_type = is_object( $post ) && isset( $post->post_type )
5379 ? (string) $post->post_type
5380 : (string) get_post_type( $post_id );
5381 if ( in_array( $post_type, self::presentation_post_types(), true ) ) {
5382 return true;
5383 }
5384 return ! self::narrow_purge_applies( $post_type );
5385 }
5386
5387 /** Test seam: forget the post saves in progress. */
5388 public static function reset_saves_under_way(): void {
5389 self::$saves_under_way = array();
5390 }
5391
5392 /**
5393 * Adapter for the WooCommerce stock actions that pass a product OBJECT
5394 * where the status actions pass an ID.
5395 *
5396 * No longer wired to a hook (on_product_stock_set() is); kept because
5397 * it is public.
5398 *
5399 * @param object $product WC_Product (or variation).
5400 */
5401 public static function purge_product_object( $product ): void {
5402 self::purge_product( $product );
5403 }
5404
5405 /**
5406 * Stock writes wc_update_product_stock() has opened in this request,
5407 * keyed by product ID: true once the save inside the write has purged.
5408 *
5409 * Entries are removed when the write's *_set_stock arrives, so this
5410 * holds only writes still in progress.
5411 *
5412 * @var array<int,bool>
5413 */
5414 private static $stock_writes = array();
5415
5416 /**
5417 * A CRUD save of a product: purge its pages.
5418 *
5419 * Inside a wc_update_product_stock() write, this is the save that write
5420 * makes, so note that the write's pages are already purged.
5421 *
5422 * @param int|object $product Product ID (what WooCommerce passes) or WC_Product.
5423 */
5424 public static function on_product_saved( $product ): void {
5425 $product_id = self::product_id_of( $product );
5426 if ( isset( self::$stock_writes[ $product_id ] ) ) {
5427 self::$stock_writes[ $product_id ] = true;
5428 }
5429 self::purge_product( $product );
5430 }
5431
5432 /**
5433 * wc_update_product_stock() is about to write a product's stock.
5434 *
5435 * @param object $product WC_Product (or variation) whose stock changes.
5436 */
5437 public static function on_product_stock_write( $product ): void {
5438 $product_id = self::product_id_of( $product );
5439 if ( $product_id > 0 ) {
5440 self::$stock_writes[ $product_id ] = false;
5441 }
5442 }
5443
5444 /**
5445 * A product's stock changed: purge its pages, unless the save inside
5446 * the same wc_update_product_stock() call already did.
5447 *
5448 * That save fires woocommerce_update_product for the same product a
5449 * moment earlier, with the stock already written, so purging again
5450 * here cleared the same pages twice in one call. Only that pairing is
5451 * skipped: the flag is set by the save inside this write and cleared
5452 * here, so the next write, a status change or a later save in the same
5453 * request purges as usual, and so does a write made with `$updating`
5454 * set, which skips the save. A *_set_stock that arrives without
5455 * *_before_set_stock (WooCommerce's data store fires one mid-save when
5456 * a CRUD save changes the quantity) is not part of a write and purges.
5457 *
5458 * @param object $product WC_Product (or variation).
5459 */
5460 public static function on_product_stock_set( $product ): void {
5461 $product_id = self::product_id_of( $product );
5462 $purged = ! empty( self::$stock_writes[ $product_id ] );
5463 unset( self::$stock_writes[ $product_id ] );
5464 if ( $purged ) {
5465 return;
5466 }
5467 self::purge_product( $product );
5468 }
5469
5470 /** Test seam: forget the stock writes in progress. */
5471 public static function reset_stock_writes(): void {
5472 self::$stock_writes = array();
5473 }
5474
5475 /**
5476 * The ID of a product passed as an ID or as a WC_Product.
5477 *
5478 * @param int|object $product Product ID or WC_Product.
5479 */
5480 private static function product_id_of( $product ): int {
5481 if ( is_object( $product ) ) {
5482 return method_exists( $product, 'get_id' ) ? (int) $product->get_id() : 0;
5483 }
5484 return is_numeric( $product ) ? (int) $product : 0;
5485 }
5486
5487 /**
5488 * Re-entry guard for the purge-event contract.
5489 *
5490 * A listener on `xspeed_after_purge_url` legitimately purges its own
5491 * layer, and a server-cache or CDN adapter that calls back into xSpeed
5492 * while doing so re-enters this method — unbounded, because each pass
5493 * looks like a fresh purge.
5494 *
5495 * A single global flag stops too much: a nested purge of a DIFFERENT URL is
5496 * a real purge whose listeners must hear about it. But a per-request
5497 * "already published" set stops too much in the other direction — a
5498 * network purge loops every blog in one request, and on a subdirectory
5499 * network they share a host, so blogs 2..N would be silently skipped. It
5500 * also grows for the life of the process.
5501 *
5502 * So the guard tracks what is IN FLIGHT, not what has been published: a
5503 * target is marked while its own dispatch is on the stack and unmarked
5504 * when it returns. Re-entering the same target recurses, so it is refused;
5505 * purging the same URL again later is a new event and publishes. The set
5506 * is bounded by call depth rather than by how many URLs a request touches.
5507 *
5508 * @var array<string,bool>
5509 */
5510 private static $purge_events_in_flight = array();
5511
5512 /** Monotonic count used to detect whether a delegated purge published. */
5513 private static $purge_event_sequence = 0;
5514
5515 /**
5516 * Publish a purge event exactly once, with bounded arguments.
5517 *
5518 * Deliberately carries only what an integration needs to invalidate its
5519 * own copy: the canonical URL (or null for a full purge), the site host,
5520 * the cause label, and how many files went. No filesystem paths, no cache
5521 * contents, no request headers, no user data. The URL query and caller-
5522 * supplied cause may nevertheless contain sensitive text, so listeners
5523 * must redact them in logs or unrelated destinations that do not need the
5524 * exact cache key.
5525 *
5526 * A listener that throws must not take the purge down with it: the files
5527 * are already gone by the time we get here, and an integration's bad day
5528 * is not a reason to report a failed purge to the caller.
5529 *
5530 * @param string $hook Hook name to emit.
5531 * @param array<string,mixed> $context Bounded context, see above.
5532 */
5533 private static function dispatch_purge_event( string $hook, array $context ): void {
5534 if ( ! function_exists( 'do_action' ) ) {
5535 return;
5536 }
5537 // Every event carries `fallback`, so one handler can read both the
5538 // per-URL and the full-purge shape. '' unless a narrow purge fell
5539 // back to the whole site.
5540 $context['fallback'] = isset( $context['fallback'] ) && is_string( $context['fallback'] ) ? $context['fallback'] : '';
5541 $target = $hook . '|' . ( isset( $context['url'] ) ? (string) $context['url'] : '' )
5542 . '|' . ( isset( $context['host'] ) ? (string) $context['host'] : '' );
5543 if ( isset( self::$purge_events_in_flight[ $target ] ) ) {
5544 return;
5545 }
5546 self::$purge_events_in_flight[ $target ] = true;
5547 ++self::$purge_event_sequence;
5548
5549 // Our own integrations get their own try. Sharing one with the public
5550 // action below meant a listener on the extension seam could throw and
5551 // take the contract event down with it — the mirror of the failure
5552 // this separation exists to prevent.
5553 try {
5554 // Built-in server-cache integrations run FIRST, and by a direct
5555 // call rather than as listeners on the action below.
5556 //
5557 // WordPress stops dispatching an action's remaining callbacks when
5558 // one of them throws. As a listener, our LiteSpeed forwarding
5559 // would then be skipped by any unrelated third-party callback that
5560 // happened to be registered earlier and blew up — and the visible
5561 // result is the worst kind: xSpeed reports a successful purge while
5562 // the server keeps serving stale HTML. Shipped behaviour must not
5563 // be hostage to a listener's bug.
5564 self::forward_to_server_caches( $context );
5565 } catch ( \Throwable $e ) {
5566 self::log_purge_listener_error( $hook, $e );
5567 }
5568
5569 // The edge xCloud provides, through its purge plugin. Direct for the
5570 // same reason, and in its own try so a failure here cannot cost the
5571 // host page caches above or the public action below.
5572 try {
5573 if ( class_exists( __NAMESPACE__ . '\\Managed_Edge_Purge' ) ) {
5574 Managed_Edge_Purge::forward( $context );
5575 }
5576 } catch ( \Throwable $e ) {
5577 self::log_purge_listener_error( $hook, $e );
5578 }
5579
5580 try {
5581 self::do_action_isolated( $hook, $context );
5582 } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
5583 // Swallow: see docblock. The purge succeeded regardless.
5584 self::log_purge_listener_error( $hook, $e );
5585 } finally {
5586 unset( self::$purge_events_in_flight[ $target ] );
5587 }
5588 }
5589
5590 /**
5591 * Run every listener on a purge hook, isolating each from the others.
5592 *
5593 * `do_action()` dispatches callbacks in one loop, so the first one to
5594 * throw takes every LATER listener down with it. On a purge that meant a
5595 * failing CDN integration silently cancelled the ones queued behind it —
5596 * and because the throw was swallowed to keep the purge itself succeeding,
5597 * the user was told the clear worked while two edges were never touched.
5598 * Invisible unless WP_DEBUG happened to be on. (QA #348)
5599 *
5600 * Each callback gets its own try/catch here, so one integration's bad day
5601 * costs only that integration. Priority order is preserved. Falls back to
5602 * a plain `do_action()` when the filter registry is not the shape we
5603 * expect, so an unusual environment degrades to the old behaviour rather
5604 * than skipping listeners entirely.
5605 *
5606 * @param string $hook Hook name to emit.
5607 * @param mixed $arg Single argument passed to each listener.
5608 */
5609 public static function do_action_isolated( string $hook, $arg ): void {
5610 global $wp_filter;
5611
5612 // Walking $wp_filter by hand and calling each callback directly was the
5613 // obvious way to do this, and it was wrong: it bypasses WordPress, so
5614 // `current_filter()` came back empty, `did_action()` stayed at 0, the
5615 // `all` hook never fired, and Query Monitor and Debug Bar could not see
5616 // the very contract this class publishes. A shared handler branching on
5617 // current_filter() picked the wrong branch. (QA #348 round 2, issue 3)
5618 //
5619 // So let do_action() dispatch — WordPress keeps its bookkeeping — and
5620 // isolate one level down instead: each registered callback is swapped
5621 // for a wrapper that runs it inside a try/catch. One listener throwing
5622 // then costs only that listener, which is the whole point, without
5623 // costing the hook its identity.
5624 if ( ! isset( $wp_filter[ $hook ] ) || ! ( $wp_filter[ $hook ] instanceof \WP_Hook ) ) {
5625 do_action( $hook, $arg );
5626 return;
5627 }
5628
5629 $hook_object = $wp_filter[ $hook ];
5630 $original = $hook_object->callbacks;
5631 if ( ! is_array( $original ) || array() === $original ) {
5632 do_action( $hook, $arg );
5633 return;
5634 }
5635
5636 $wrapped = array();
5637 $restorations = array();
5638 foreach ( $original as $priority => $group ) {
5639 if ( ! is_array( $group ) ) {
5640 $wrapped[ $priority ] = $group;
5641 continue;
5642 }
5643 foreach ( $group as $id => $registered ) {
5644 if ( ! isset( $registered['function'] ) || ! is_callable( $registered['function'] ) ) {
5645 $wrapped[ $priority ][ $id ] = $registered;
5646 continue;
5647 }
5648 $callback = $registered['function'];
5649 $wrapper = static function ( ...$args ) use ( $callback, $hook ) {
5650 try {
5651 return $callback( ...$args );
5652 } catch ( \Throwable $e ) {
5653 self::log_purge_listener_error( $hook, $e );
5654 return null;
5655 }
5656 };
5657 $wrapped[ $priority ][ $id ] = array(
5658 // Keep accepted_args: a listener registered for 0 or 1
5659 // arguments must still be called the way it asked.
5660 'accepted_args' => $registered['accepted_args'] ?? 1,
5661 'function' => $wrapper,
5662 );
5663 $restorations[ $priority ][ $id ] = array(
5664 'original' => $registered,
5665 'wrapper' => $wrapper,
5666 );
5667 }
5668 }
5669
5670 $hook_object->callbacks = $wrapped;
5671 try {
5672 do_action( $hook, $arg );
5673 } finally {
5674 // Restore only wrappers still present. Native add/remove operations
5675 // performed by listeners must survive this temporary substitution.
5676 foreach ( $restorations as $priority => $group ) {
5677 foreach ( $group as $id => $restore ) {
5678 $current = $hook_object->callbacks[ $priority ][ $id ]['function'] ?? null;
5679 if ( $current === $restore['wrapper'] ) {
5680 $hook_object->callbacks[ $priority ][ $id ] = $restore['original'];
5681 }
5682 }
5683 }
5684 }
5685 }
5686
5687 /**
5688 * Name a listener that threw, under WP_DEBUG only.
5689 *
5690 * Gated like the rest of Free's diagnostics: a third-party listener
5691 * throwing on every purge must not fill a production log.
5692 */
5693 private static function log_purge_listener_error( string $hook, \Throwable $e ): void {
5694 // An \Error — a TypeError from one of OUR listeners, say — is a bug
5695 // rather than a runtime condition a third party imposed on us, and
5696 // swallowing it silently in production turns it into a purge that
5697 // quietly stops working. Those are logged whatever WP_DEBUG says;
5698 // third-party \Exceptions stay gated so a noisy integration cannot
5699 // fill a production log.
5700 $always = $e instanceof \Error;
5701 if ( ( $always || ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ) && function_exists( 'error_log' ) ) {
5702 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- names a third-party listener that threw during a purge.
5703 error_log( '[xspeed] a ' . $hook . ' listener threw: ' . $e->getMessage() );
5704 }
5705 }
5706
5707 /**
5708 * Test seam: clear the in-flight set left behind by an aborted dispatch,
5709 * and the record of which saves purged in this request.
5710 */
5711 public static function reset_purge_events(): void {
5712 self::$purge_events_in_flight = array();
5713 self::$purge_event_sequence = 0;
5714 self::$saves_purged = array();
5715 }
5716
5717 /**
5718 * Hand the purge to the caches we ship integrations for.
5719 *
5720 * Isolated from the public action on purpose — see dispatch_purge_event().
5721 * Guarded so a missing class (a partial upgrade, a stripped build) cannot
5722 * turn a working purge into a fatal.
5723 *
5724 * @param array<string,mixed> $context Bounded purge context.
5725 */
5726 private static function forward_to_server_caches( array $context ): void {
5727 if ( class_exists( __NAMESPACE__ . '\\Server_Caches' ) ) {
5728 Server_Caches::forward( $context );
5729 }
5730 }
5731
5732 /**
5733 * `host[:port]` for a cache key, from a parsed URL.
5734 *
5735 * The port is kept, because `cache_key()` hashes the raw `HTTP_HOST` and
5736 * that carries `:8080` on any install not served from 80/443 — dropping it
5737 * computed a different md5, found no file, and reported "already cold"
5738 * while the page kept serving HIT.
5739 *
5740 * A port that is the DEFAULT for the scheme is dropped, though, because
5741 * `HTTP_HOST` does not carry one: a browser sends `Host: site.com` for
5742 * `https://site.com:443/`. Keeping it hashed `site.com:443` against a file
5743 * stored under `site.com` — the same silent no-op in the other direction,
5744 * and the one QA hit passing a canonical URL with the port spelled out.
5745 * (QA #348)
5746 *
5747 * @param array<string,mixed> $parts Output of wp_parse_url().
5748 */
5749 private static function host_port_of( array $parts ): string {
5750 if ( ! isset( $parts['host'] ) ) {
5751 return '';
5752 }
5753 $host = strtolower( (string) $parts['host'] );
5754 if ( '' === $host || ! isset( $parts['port'] ) ) {
5755 return $host;
5756 }
5757 $port = (int) $parts['port'];
5758 $scheme = isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : '';
5759 if ( ( 'https' === $scheme && 443 === $port ) || ( 'http' === $scheme && 80 === $port ) ) {
5760 return $host;
5761 }
5762 return $host . ':' . $port;
5763 }
5764
5765 public static function purge_url( string $url, string $cause = 'manual' ): int {
5766 // A URL that names nothing is not a purge of everything. An empty or
5767 // blank string used to fall through to the home_url() default below
5768 // and clear the HOMEPAGE — so a third party calling
5769 // `purge_url( get_permalink( $id ) )` on a post whose permalink came
5770 // back empty silently purged the front page instead of nothing. The
5771 // CLI and the MCP tool reject empties before reaching this, so only
5772 // direct API callers were exposed, but they are exactly the audience
5773 // this public contract is for. (QA #348)
5774 if ( '' === trim( $url ) ) {
5775 return 0;
5776 }
5777 // parse_url() can turn raw UTF-8 into underscores (it depends on the
5778 // host's C library and locale), so a pasted `/關於我們/` is encoded
5779 // first. normalize_path() below gives the same result either way.
5780 $url = self::encode_non_ascii( $url );
5781 $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.
5782 if ( ! is_array( $parts ) ) {
5783 return 0;
5784 }
5785 // Absolute URLs are accepted only for HTTP response caches. Schemes such
5786 // as ftp:, file: and javascript: can parse cleanly but do not name a page
5787 // xSpeed or a server response cache can invalidate. A leading-slash path
5788 // remains a supported site-relative target.
5789 if ( isset( $parts['scheme'] ) && ! in_array( strtolower( (string) $parts['scheme'] ), array( 'http', 'https' ), true ) ) {
5790 return 0;
5791 }
5792 if ( isset( $parts['scheme'] ) && empty( $parts['host'] ) ) {
5793 return 0;
5794 }
5795 // Reject a string that parsed but is not a URL we can act on: no
5796 // scheme AND no host AND no leading-slash path means something like
5797 // `ht!tp://[[[` or a bare word, which parse_url() hands back as a
5798 // relative "path". Forwarding that produced `purge_url(/ht!tp://[[[)`
5799 // — a nonsense tag sent to LiteSpeed for every malformed call.
5800 if ( ! isset( $parts['scheme'] ) && ! isset( $parts['host'] ) ) {
5801 $raw = isset( $parts['path'] ) ? (string) $parts['path'] : '';
5802 if ( '' === $raw || '/' !== $raw[0] ) {
5803 return 0;
5804 }
5805 }
5806 // Keep the port. `cache_key()` hashes the raw `HTTP_HOST`, which
5807 // carries `:8080` on any install not served from 80/443 — while
5808 // parse_url() splits the port into its own component, so a purge that
5809 // used the bare host computed a different md5, found no file, and
5810 // reported "already cold". A silent no-op: the page kept serving HIT
5811 // until its TTL ran out. Intranet installs, panel hosts on :8443 and
5812 // proxies that forward `Host: site.com:8080` all hit this.
5813 // A scheme-less `site.test:443/page/` is a supported explicit-host
5814 // target. Infer a scheme only when it names THIS site's hostname: then
5815 // its explicit default port is the same origin and the same local cache
5816 // key. Never apply this to another host or to a non-default port.
5817 if ( ! isset( $parts['scheme'] ) && isset( $parts['host'], $parts['port'] ) && function_exists( 'home_url' ) ) {
5818 $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.
5819 if ( is_array( $home ) && ! empty( $home['host'] ) && ! empty( $home['scheme'] )
5820 && strtolower( (string) $home['host'] ) === strtolower( (string) $parts['host'] )
5821 ) {
5822 $home_scheme = strtolower( (string) $home['scheme'] );
5823 $port = (int) $parts['port'];
5824 $home_port = isset( $home['port'] )
5825 ? (int) $home['port']
5826 : ( 'https' === $home_scheme ? 443 : ( 'http' === $home_scheme ? 80 : 0 ) );
5827 if ( $home_port === $port
5828 && ( ( 'https' === $home_scheme && 443 === $port ) || ( 'http' === $home_scheme && 80 === $port ) )
5829 ) {
5830 $parts['scheme'] = $home_scheme;
5831 }
5832 }
5833 }
5834 $host = self::host_port_of( $parts );
5835 if ( '' === $host && function_exists( 'home_url' ) ) {
5836 $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.
5837 if ( is_array( $home ) ) {
5838 $host = self::host_port_of( $home );
5839 }
5840 }
5841 if ( '' === $host ) {
5842 return 0;
5843 }
5844 $path = isset( $parts['path'] ) ? (string) $parts['path'] : '/';
5845 $path = '/' . ltrim( $path, '/' );
5846 if ( false !== strpos( $path, '..' ) ) {
5847 return 0;
5848 }
5849 // Same spelling cache_key() hashes, so a permalink, the same URL with
5850 // `%E7` capitals, and a pasted `/關於我們/` all find the entry. The
5851 // purge event below keeps the caller's spelling and adds the others
5852 // (see escape_spellings()).
5853 $key_path = self::normalize_path( $path );
5854
5855 // The cache key preserves REQUEST_URI's trailing-slash form, so
5856 // purge both. Root stays a single '/'.
5857 $forms = array( $key_path );
5858 if ( '/' !== $key_path ) {
5859 $forms[] = rtrim( $key_path, '/' );
5860 $forms[] = rtrim( $key_path, '/' ) . '/';
5861 }
5862 $forms = array_unique( $forms );
5863
5864 /*
5865 * Entries live under the bucket they were written for, and this URL's
5866 * site may not be the one serving THIS request (a cross-site purge on
5867 * multisite, WP-CLI, or cron). Build the directory from the URL's own
5868 * host AND path. (#6)
5869 *
5870 * Host alone is wrong on a subdirectory network: `store()` wrote to
5871 * `<host>/<prefix>/`, so looking in `<host>/` found nothing and the
5872 * call reported "already cold" while the page kept serving HIT — a
5873 * false success, which is worse than an error. The prefix has to come
5874 * from the URL being purged rather than from the current blog, because
5875 * the caller is usually purging some OTHER site. (QA B2 on #166)
5876 */
5877 $base = XSPEED_CACHE_DIR . '/' . self::bucket_for_url( $host, $path );
5878
5879 $count = 0;
5880 foreach ( $forms as $uri ) {
5881 // '' = mobile_separate off; '|m' / '|d' = the device buckets.
5882 foreach ( array( '', '|m', '|d' ) as $device ) {
5883 $key = md5( $host . $uri . $device );
5884 $file = $base . '/' . $key . '.html';
5885 if ( is_file( $file ) ) {
5886 wp_delete_file( $file );
5887 ++$count;
5888 }
5889 foreach ( array( $base . '/' . $key . '.meta', $file . '.br', self::brotli_size_sidecar( $file . '.br' ) ) as $sidecar ) {
5890 if ( is_file( $sidecar ) ) {
5891 wp_delete_file( $sidecar );
5892 }
5893 }
5894 }
5895 }
5896
5897 // Static tree (served directly by the nginx/.htaccess rewrite).
5898 // The static tree stores decoded names (see static_path()). A path
5899 // static_path() refuses was never written there.
5900 $rel = self::static_path( $key_path );
5901 if ( defined( 'XSPEED_CACHE_STATIC_DIR' ) && null !== $rel ) {
5902 // Same transform the write used — `localhost:8080` files under
5903 // `localhost8080`, so the bare host found nothing here either.
5904 $dir = rtrim( XSPEED_CACHE_STATIC_DIR, '/' ) . '/' . self::static_host_dir( $host ) . $rel;
5905 $file = $dir . '/index.html';
5906 if ( is_file( $file ) ) {
5907 wp_delete_file( $file );
5908 ++$count;
5909 }
5910 foreach ( array( $file . '.br', self::brotli_size_sidecar( $file . '.br' ) ) as $sidecar ) {
5911 if ( is_file( $sidecar ) ) {
5912 wp_delete_file( $sidecar );
5913 }
5914 }
5915 }
5916
5917 /*
5918 * The per-URL purge event is published by `dispatch_purge_event()`
5919 * below, NOT here.
5920 *
5921 * This branch used to publish it itself, with a raw `do_action` and a
5922 * `{scope,url,urls,cause,count}` payload. #348 then landed the purge
5923 * contract on dev: a canonical URL, `removed` rather than `count`,
5924 * `intent`, per-listener isolation, and an in-flight guard so a
5925 * listener that purges its own layer cannot re-enter the event.
5926 *
5927 * Keeping both meant every per-URL purge fired TWICE, in two payload
5928 * shapes, and the raw call bypassed the guard — so a listener that
5929 * called back into `purge_url()` recursed until the process ran out
5930 * of memory. `CachePurgeEventContractTest::
5931 * test_a_listener_re_purging_the_same_url_does_not_recurse` is what
5932 * caught it, and it arrived from dev with the contract it defends.
5933 *
5934 * The contract wins: it is a superset of what this published, and it
5935 * is what the LiteSpeed and nginx adapters are written against.
5936 */
5937
5938 // purge_url_reported() writes this row itself once the event has
5939 // gone out, so it can name the caches the purge was sent to.
5940 if ( $count > 0 && null === self::$url_batch && null === self::$reported_url_purge ) {
5941 Cache_Inventory::invalidate();
5942 Activity_Log::record(
5943 'cache_purge_url',
5944 sprintf(
5945 /* translators: 1: cause of the purge, 2: URL or path, 3: number of files removed. */
5946 __( 'Purged one URL (%1$s) — %2$s, %3$d file(s) removed', 'xspeed' ),
5947 $cause,
5948 $host . $path,
5949 $count
5950 ),
5951 Activity_Log::INFO
5952 );
5953 }
5954
5955 /**
5956 * Fires after one URL's cached copy has been purged.
5957 *
5958 * The single-URL counterpart to `xspeed_after_purge_all`. Subscribe
5959 * here to invalidate a cache xSpeed does not own — a server-level
5960 * cache such as LiteSpeed's LSCache, a reverse proxy, or a CDN — for
5961 * the same URL.
5962 *
5963 * Only fires when the purge actually ran. A malformed URL, a URL with
5964 * no resolvable host, or a traversal attempt returns earlier and
5965 * publishes nothing, so a listener can treat this as "xSpeed purged
5966 * this URL" rather than "xSpeed was asked to". `removed` may legitimately
5967 * be 0: the URL was not in xSpeed's cache, which says nothing about
5968 * whether it is in yours.
5969 *
5970 * Fires at most once per purge. A listener that calls back into
5971 * xSpeed's purge API will not re-enter this event.
5972 *
5973 * @since 1.2.3
5974 *
5975 * @param array $context {
5976 * Bounded description of the purge. URL queries and caller-supplied
5977 * causes can contain sensitive values and are not logging fields.
5978 *
5979 * @type string $url Canonical scheme://host/path[?query] of the purged URL.
5980 * The query is preserved because caches in front
5981 * commonly key on it; xSpeed's own sweep is
5982 * path-based, so `removed` describes that.
5983 * @type string $host Host (with port when non-standard).
5984 * @type string $path Path component, leading slash.
5985 * @type string $cause Short label for who asked. See purge_all().
5986 * @type int $removed Number of cache files removed.
5987 * @type string $scope Actionable adapter scope: `urls`.
5988 * @type string $intent Why responses changed: `content`.
5989 * @type string[] $urls Exact response URLs to invalidate: the
5990 * canonical URL first, then the same URL
5991 * with the other trailing-slash spelling
5992 * (none for the root). A path with
5993 * percent-escapes adds the same pair with
5994 * the escapes in lower case and in upper
5995 * case, where those differ from it.
5996 * @type string $fallback Always '' on this event. Present so both
5997 * purge events share one shape; see
5998 * `xspeed_after_purge`.
5999 * }
6000 */
6001 $query = isset( $parts['query'] ) ? (string) $parts['query'] : '';
6002 $url_scheme = isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : '';
6003 $canonical_url = self::canonical_purge_url( $host, $path, $query, $url_scheme );
6004 // Every spelling the local sweep above cleared, because a cache in
6005 // front keys each one on its own. Visitors reach a page as `/about/`
6006 // while a caller often passes `/about` (or the reverse), and a
6007 // non-ASCII page as `/%E9%97%9C…/` while get_permalink() passes
6008 // `/%e9%97%9c…/`. Publishing one spelling left the edge serving the
6009 // other, usually the one visitors actually use.
6010 $event_urls = array();
6011 foreach ( self::escape_spellings( $path ) as $spelling ) {
6012 $event_urls[] = self::canonical_purge_url( $host, $spelling, $query, $url_scheme );
6013 if ( '/' === $spelling ) {
6014 continue;
6015 }
6016 $other = '/' === substr( $spelling, -1 ) ? rtrim( $spelling, '/' ) : $spelling . '/';
6017 if ( '' !== $other ) {
6018 $event_urls[] = self::canonical_purge_url( $host, $other, $query, $url_scheme );
6019 }
6020 }
6021 // Inside purge_urls(): the batch publishes one event for every URL,
6022 // in the trailing-slash form it was given. The local sweep above still
6023 // took both forms. Escape spellings are still added, because the other
6024 // escape case does not redirect: a cache in front keeps the browser's
6025 // `%E9…` copy apart from the permalink's `%e9…` one.
6026 if ( null !== self::$url_batch ) {
6027 if ( '' === self::$url_batch['url'] ) {
6028 self::$url_batch['url'] = $canonical_url;
6029 self::$url_batch['host'] = $host;
6030 self::$url_batch['path'] = $path;
6031 }
6032 self::$url_batch['removed'] += $count;
6033 foreach ( self::escape_spellings( $path ) as $spelling ) {
6034 self::$url_batch['urls'][ self::canonical_purge_url( $host, $spelling, $query, $url_scheme ) ] = true;
6035 }
6036 return $count;
6037 }
6038
6039 self::dispatch_purge_event(
6040 'xspeed_after_purge_url',
6041 array(
6042 'url' => $canonical_url,
6043 'host' => $host,
6044 'path' => $path,
6045 'cause' => $cause,
6046 'removed' => $count,
6047 'scope' => 'urls',
6048 'intent' => 'content',
6049 'urls' => array_values( array_unique( $event_urls ) ),
6050 'fallback' => '',
6051 )
6052 );
6053
6054 return $count;
6055 }
6056
6057 /**
6058 * The batch purge_urls() is collecting, or null outside one.
6059 *
6060 * @var array{url:string,host:string,path:string,removed:int,urls:array<string,bool>}|null
6061 */
6062 private static $url_batch = null;
6063
6064 /**
6065 * The caches in front of PHP that took a purge while a report is open,
6066 * keyed by label, or null when no report is open.
6067 *
6068 * @var array<string,bool>|null
6069 */
6070 private static $forward_report = null;
6071
6072 /**
6073 * Note that a purge was handed to a cache in front of PHP: a server
6074 * cache, a host cache, a CDN or an edge.
6075 *
6076 * The built-in adapters call this when they accept a purge, whether
6077 * they send it now or queue it for the end of the request. A third-party
6078 * adapter listening on `xspeed_after_purge_url` may call it too, so an
6079 * operator's purge names it. Does nothing unless a caller opened a
6080 * report with purge_url_reported().
6081 *
6082 * @param string $layer Name to show, such as "Nginx Helper".
6083 */
6084 public static function note_purge_forwarded( string $layer ): void {
6085 if ( null === self::$forward_report || '' === trim( $layer ) ) {
6086 return;
6087 }
6088 self::$forward_report[ $layer ] = true;
6089 }
6090
6091 /**
6092 * Run a purge and collect the caches in front of PHP it was sent to.
6093 *
6094 * @param callable $purge The purge to run.
6095 * @return array{result:mixed,forwarded:string[]}
6096 */
6097 public static function report_forwarding( callable $purge ): array {
6098 $outer = self::$forward_report;
6099 self::$forward_report = array();
6100 try {
6101 $result = $purge();
6102 } finally {
6103 $forwarded = array_keys( (array) self::$forward_report );
6104 self::$forward_report = null === $outer ? null : $outer + (array) self::$forward_report;
6105 }
6106 return array(
6107 'result' => $result,
6108 'forwarded' => $forwarded,
6109 );
6110 }
6111
6112 /**
6113 * Set while purge_url_reported() runs, so purge_url() leaves the
6114 * purge-log row to it. Null otherwise.
6115 *
6116 * @var bool|null
6117 */
6118 private static $reported_url_purge = null;
6119
6120 /**
6121 * Purge one URL for an operator, and say where the purge went.
6122 *
6123 * purge_url() writes a purge-log row only when it removed a local file,
6124 * because Pro's beacons call it on visitor requests. On a site whose
6125 * pages are held only by a server cache (xSpeed's page cache off, nginx
6126 * in front) an operator's purge then removed nothing locally, was sent
6127 * to the server cache, and left no row and an "already cold" answer.
6128 * This adds the row in that case. The CLI, the MCP tool (which runs the
6129 * CLI) and the admin "Purge this URL" link use it.
6130 *
6131 * @param string $url Absolute URL or site-relative path.
6132 * @param string $cause Who asked, for the purge log.
6133 * @return array{removed:int,forwarded:string[]} Files removed here, and
6134 * the caches the purge was sent to.
6135 */
6136 public static function purge_url_reported( string $url, string $cause ): array {
6137 $outer = self::$reported_url_purge;
6138 self::$reported_url_purge = true;
6139 try {
6140 $run = self::report_forwarding(
6141 static function () use ( $url, $cause ): int {
6142 return self::purge_url( $url, $cause );
6143 }
6144 );
6145 } finally {
6146 self::$reported_url_purge = $outer;
6147 }
6148 $removed = (int) $run['result'];
6149 $forwarded = $run['forwarded'];
6150
6151 if ( $removed > 0 ) {
6152 Cache_Inventory::invalidate();
6153 $message = array() === $forwarded
6154 ? sprintf(
6155 /* translators: 1: cause of the purge, 2: URL or path, 3: number of files removed. */
6156 __( 'Purged one URL (%1$s) — %2$s, %3$d file(s) removed', 'xspeed' ),
6157 $cause,
6158 $url,
6159 $removed
6160 )
6161 : sprintf(
6162 /* translators: 1: cause of the purge, 2: URL or path, 3: number of files removed, 4: caches the purge was sent to. */
6163 __( 'Purged one URL (%1$s) — %2$s, %3$d file(s) removed, sent to %4$s', 'xspeed' ),
6164 $cause,
6165 $url,
6166 $removed,
6167 implode( ', ', $forwarded )
6168 );
6169 Activity_Log::record( 'cache_purge_url', $message, Activity_Log::INFO );
6170 } elseif ( array() !== $forwarded ) {
6171 Activity_Log::record(
6172 'cache_purge_url',
6173 sprintf(
6174 /* translators: 1: cause of the purge, 2: URL or path, 3: caches the purge was sent to. */
6175 __( 'Purged one URL (%1$s): %2$s, 0 file(s) removed here, sent to %3$s', 'xspeed' ),
6176 $cause,
6177 $url,
6178 implode( ', ', $forwarded )
6179 ),
6180 Activity_Log::INFO
6181 );
6182 }
6183
6184 return array(
6185 'removed' => $removed,
6186 'forwarded' => $forwarded,
6187 );
6188 }
6189
6190 /**
6191 * Purge several URLs as one purge: each URL's local copy goes as in
6192 * purge_url(), then ONE `xspeed_after_purge_url` event carries every URL.
6193 *
6194 * One event rather than one per URL, so the purge log gets one line, an
6195 * edge gets one batch, and a listener that counts purges counts one. The
6196 * event is the same contract purge_url() publishes: `scope` is `urls`,
6197 * `url` is the first URL, `urls` is all of them.
6198 *
6199 * Unlike purge_url(), `urls` holds each URL in the spelling it was given
6200 * and not the other trailing-slash spelling too. Callers pass WordPress's
6201 * own links (get_permalink(), get_term_link() and the like), which are
6202 * the spelling visitors reach; the other spelling redirects to it, so a
6203 * cache in front holds nothing stale under it. Both spellings doubled the
6204 * list: a typical save named 34 pages as 67 URLs, past xCloud's 50-URL
6205 * batch and the Hub's 30 per call. xSpeed's own sweep still removes both
6206 * spellings of each. The escape spellings (escape_spellings()) are still
6207 * listed, because `%E9…` does not redirect to `%e9…`; they add URLs only
6208 * for a path with percent-escapes.
6209 *
6210 * The object cache is left alone, as purge_url() leaves it. WordPress
6211 * already drops a post's own entries when the post, its terms or its
6212 * comments change (clean_post_cache() and the like), so a flush here
6213 * cleared nothing stale. What it did do: an approved visitor comment
6214 * emptied Redis or Memcached for the whole site, as often as a visitor
6215 * cared to comment. With a persistent object cache, transients live
6216 * there too, so a flush could also drop work a purge listener had just
6217 * queued in one. purge_all() still flushes.
6218 *
6219 * @param string[] $urls Absolute URLs or site-relative paths.
6220 * @param string $cause Who asked, for the purge log.
6221 * @param string $intent Why responses changed: `content` by default.
6222 * @return int Cache files removed.
6223 */
6224 public static function purge_urls( array $urls, string $cause = 'manual', string $intent = 'content' ): int {
6225 $outer = self::$url_batch;
6226 self::$url_batch = array(
6227 'url' => '',
6228 'host' => '',
6229 'path' => '',
6230 'removed' => 0,
6231 'urls' => array(),
6232 );
6233 try {
6234 foreach ( array_unique( array_filter( $urls, 'is_string' ) ) as $url ) {
6235 self::purge_url( $url, $cause );
6236 }
6237 } finally {
6238 $batch = self::$url_batch;
6239 self::$url_batch = $outer;
6240 }
6241 if ( '' === $batch['url'] ) {
6242 return 0;
6243 }
6244
6245 Cache_Inventory::invalidate();
6246 self::update_stats( array( 'last_purge' => time() ) );
6247 // Same type as one URL's purge, so the purge log and the dashboard's
6248 // drill-down list it with the others.
6249 Activity_Log::record(
6250 'cache_purge_url',
6251 sprintf(
6252 /* translators: 1: number of pages, 2: cause of the purge, 3: number of files removed. */
6253 _n( 'Purged %1$d page (%2$s), %3$d file(s) removed', 'Purged %1$d pages (%2$s), %3$d file(s) removed', count( $urls ), 'xspeed' ),
6254 count( $urls ),
6255 $cause,
6256 $batch['removed']
6257 ),
6258 Activity_Log::INFO
6259 );
6260
6261 self::dispatch_purge_event(
6262 'xspeed_after_purge_url',
6263 array(
6264 'url' => $batch['url'],
6265 'host' => $batch['host'],
6266 'path' => $batch['path'],
6267 'cause' => $cause,
6268 'removed' => $batch['removed'],
6269 'scope' => 'urls',
6270 'intent' => $intent,
6271 'urls' => array_keys( $batch['urls'] ),
6272 'fallback' => '',
6273 )
6274 );
6275
6276 return $batch['removed'];
6277 }
6278
6279 /** Host this site's purge is scoped to, for the purge-event context. */
6280 private static function current_purge_host(): string {
6281 if ( ! function_exists( 'home_url' ) ) {
6282 return '';
6283 }
6284 $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.
6285 if ( ! is_array( $home ) || empty( $home['host'] ) ) {
6286 return '';
6287 }
6288 // Same default-port normalisation as purge_url(): a site whose
6289 // home_url() carries `:443` (normal behind a proxy) otherwise stamps
6290 // every full-purge event with a host that matches none of its own
6291 // URLs, so the LiteSpeed forward stood down site-wide. (QA #348)
6292 return self::host_port_of( $home );
6293 }
6294
6295 /**
6296 * Rebuild the canonical URL a purge applied to.
6297 *
6298 * Built from the parts the purge itself used, so a listener is told the
6299 * URL we acted on rather than the string the caller happened to pass —
6300 * those differ whenever the caller supplied a site-relative path, a
6301 * different scheme, or a query string the cache key ignores.
6302 */
6303 private static function canonical_purge_url( string $host, string $path, string $query = '', string $url_scheme = '' ): string {
6304 // The purged URL's own scheme wins. purge_url() explicitly supports
6305 // cross-site purges (multisite, WP-CLI, cron), where composing the
6306 // current site's scheme onto another site's host builds a URL that was
6307 // never served — and a CDN listener then purges the wrong key and
6308 // reports success.
6309 if ( '' !== $url_scheme ) {
6310 return $url_scheme . '://' . $host . $path . ( '' !== $query ? '?' . $query : '' );
6311 }
6312 $scheme = function_exists( 'is_ssl' ) && is_ssl() ? 'https' : 'http';
6313 if ( function_exists( 'home_url' ) ) {
6314 $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.
6315 if ( is_array( $home ) && ! empty( $home['scheme'] ) ) {
6316 $scheme = (string) $home['scheme'];
6317 }
6318 }
6319 // The query is carried even though OUR sweep above is path-based.
6320 // Caches in front commonly key on the full request line — LiteSpeed
6321 // tags `/shop/?page=2` separately from `/shop/` — so publishing the
6322 // bare path would have a listener confidently purge the wrong entry
6323 // and report success. Telling it exactly what was asked for lets it
6324 // act correctly; `removed` still describes only what WE removed.
6325 return $scheme . '://' . $host . $path . ( '' !== $query ? '?' . $query : '' );
6326 }
6327
6328 /**
6329 * Sweep this site's cache files.
6330 *
6331 * On multisite every blog shares one cache directory, so an unscoped
6332 * sweep here took the whole network cold — one subsite's settings save
6333 * or post publish rebuilt every other site from PHP. Entries are stored
6334 * per host (see host_dir()), and the sweep is scoped to match, so a
6335 * purge originating on site-a leaves site-b's cache warm. (#6)
6336 *
6337 * Clears the files only: the flat tree, the static tree, the REST
6338 * responses and the minified assets. The object-cache flush, the stats
6339 * update, `xspeed_after_purge_all`, the `xspeed_after_purge` contract
6340 * event and the log entry live in purge_all(), which is still the entry
6341 * point for every existing caller. Split out so `wp xspeed purge` can
6342 * report the local sweep as one line item and the object cache as
6343 * another, each with its own status — see Purge_Runner.
6344 *
6345 * @param string|null $host Host to purge. Defaults to the current site.
6346 * Pass '*' to sweep the ENTIRE tree — network
6347 * admin's "purge all sites", and the migration
6348 * of pre-#6 entries that sit in the tree root.
6349 * @return array{pages:int,rest:int,assets:int,bytes:int} Entries removed
6350 * per store, and the bytes freed by the two file sweeps
6351 * that measure themselves.
6352 */
6353 public static function purge_local( ?string $host = null ): array {
6354 $network_wide = ( '*' === $host );
6355 self::$sweep_bytes = 0;
6356 // The flat tree buckets by a flattened segment (host/a-b) while the
6357 // static tree mirrors the URL (host/a/b), so they need separate
6358 // scopes — see current_host_dir() vs current_static_scope().
6359 $static_scope = '';
6360 if ( null === $host || $network_wide ) {
6361 $scope = $network_wide ? '' : self::current_host_dir();
6362 $static_scope = $network_wide ? '' : self::current_static_scope();
6363 } else {
6364 $dir = self::host_dir( $host );
6365 $scope = '' === $dir ? 'default' : $dir;
6366 $static_dir = self::static_host_dir( $host );
6367 $static_scope = '' === $static_dir ? 'default' : $static_dir;
6368 }
6369
6370 $count = 0;
6371 if ( is_dir( XSPEED_CACHE_DIR ) ) {
6372 // Scoped to one host directory, or the whole tree (including the
6373 // legacy top-level entries written before #6) when network-wide.
6374 /*
6375 * Network-wide sweeps go TWO levels deep, not one. A subdirectory
6376 * subsite's bucket is `<host>/<prefix>/`, so globbing only
6377 * `<cache>/*` reached the main site and left every subsite's
6378 * entries in place. (QA D5 on #166)
6379 *
6380 * A scoped purge also has to cover its own nested buckets: when
6381 * the main blog of a subdirectory network purges, `<host>/` is its
6382 * bucket and `<host>/one/` belongs to another blog — so the scoped
6383 * branch deliberately does NOT descend, which is what keeps
6384 * site-level purges isolated.
6385 */
6386 $roots = $network_wide
6387 ? array_merge(
6388 array( XSPEED_CACHE_DIR ),
6389 array_filter( (array) glob( XSPEED_CACHE_DIR . '/*', GLOB_ONLYDIR ) ),
6390 array_filter( (array) glob( XSPEED_CACHE_DIR . '/*/*', GLOB_ONLYDIR ) )
6391 )
6392 : array( XSPEED_CACHE_DIR . '/' . $scope );
6393
6394 foreach ( $roots as $root ) {
6395 /*
6396 * min/ and rest/ are swept by their own purgers below; never
6397 * treat them as host buckets.
6398 *
6399 * Checked on every path SEGMENT, not just the basename: now
6400 * that the network-wide glob descends two levels it can reach
6401 * `min/combined`, whose basename is `combined` and would sail
6402 * past a basename-only test — deleting the combined
6403 * stylesheets out from under the pages that link them.
6404 */
6405 if ( ! $network_wide || XSPEED_CACHE_DIR !== $root ) {
6406 $relative = trim( str_replace( XSPEED_CACHE_DIR, '', (string) $root ), '/' );
6407 $segments = '' === $relative ? array() : explode( '/', $relative );
6408 if ( array_intersect( $segments, array( 'min', 'rest' ) ) ) {
6409 continue;
6410 }
6411 }
6412 if ( ! is_dir( $root ) ) {
6413 continue;
6414 }
6415 $files = glob( $root . '/*.html' );
6416 if ( $files ) {
6417 $count += count( $files );
6418 foreach ( $files as $f ) {
6419 self::sweep_delete( $f );
6420 }
6421 }
6422 // Remove the .meta sidecars (content-type for feeds/sitemaps)
6423 // alongside their .html entries. Not counted — they're not
6424 // cache "pages", just per-entry metadata.
6425 $meta = glob( $root . '/*.meta' );
6426 if ( $meta ) {
6427 foreach ( $meta as $m ) {
6428 self::sweep_delete( $m );
6429 }
6430 }
6431 // Remove precompressed siblings (e.g. <key>.html.br from the Pro
6432 // Brotli module). Not counted — same as .meta. Without this a
6433 // purge leaves stale .br bodies behind: disk bloat, and a
6434 // staleness window if precompression is later disabled.
6435 $br = glob( $root . '/*.br' );
6436 if ( $br ) {
6437 foreach ( $br as $b ) {
6438 self::sweep_delete( $b );
6439 }
6440 }
6441 // `*.br` does not match `*.br.size` — same reason as the flat-root
6442 // sweep above: a size record outliving its body would later be
6443 // read against a different sibling's bytes.
6444 $br_size = glob( $root . '/*.br.size' );
6445 if ( $br_size ) {
6446 foreach ( $br_size as $b ) {
6447 self::sweep_delete( $b );
6448 }
6449 }
6450 }
6451 }
6452 // Static-cache tree purge — recursive because the layout is
6453 // xspeed-static/{host}/{path}/index.html, so a flat glob can't
6454 // reach everything. Already host-segmented, so scoping is just a
6455 // matter of starting one level down.
6456 if ( is_dir( XSPEED_CACHE_STATIC_DIR ) ) {
6457 $static_root = $network_wide
6458 ? XSPEED_CACHE_STATIC_DIR
6459 : XSPEED_CACHE_STATIC_DIR . '/' . $static_scope;
6460 if ( is_dir( $static_root ) ) {
6461 $count += self::rmtree_html( $static_root );
6462 }
6463 }
6464 // REST response cache (cache/xspeed/rest/*.json) — same purge
6465 // triggers (publish, settings change) invalidate it too.
6466 $rest = Rest_Cache::purge();
6467 $count += $rest;
6468
6469 // Minified + combined CSS/JS (cache/xspeed/min/ and min/combined/)
6470 // go only on a NETWORK-wide sweep.
6471 //
6472 // They are named by content, so a site purge gains nothing by deleting
6473 // them: the next render links the same names for unchanged sources
6474 // and new names for changed ones. Deleting them did cost something.
6475 // A page rendered just before the purge and stored just after linked
6476 // files that were gone (unstyled until the TTL), and min/ is shared
6477 // by every blog, so one subsite's purge broke every other subsite's
6478 // cached pages. Orphans are Cache_GC's job; a network purge, an
6479 // update, the explicit assets purge and deactivation still clear the
6480 // tree, and the purge stamp stops a render in flight from being
6481 // stored against deleted files.
6482 $assets = ( $network_wide && class_exists( '\\XSpeed\\Minifier' ) ) ? Minifier::purge_minified() : 0;
6483
6484 return array(
6485 'pages' => $count - $rest,
6486 'rest' => $rest,
6487 'assets' => $assets,
6488 'bytes' => self::$sweep_bytes,
6489 );
6490 }
6491
6492 /**
6493 * Flush the persistent object cache (Redis / Memcached).
6494 *
6495 * Runs regardless of whether the Object Cache module is currently
6496 * enabled — a drop-in installed earlier keeps serving until flushed.
6497 *
6498 * @param bool $network_wide Flush every blog's entries. wp_cache_flush()
6499 * is NETWORK-global, so on multisite the
6500 * default prefers the blog-scoped group flush
6501 * (WP 6.1+) — otherwise one site's purge drops
6502 * every other site's object cache, the same bug
6503 * #6 fixed for the page cache.
6504 * @return bool Whether a flush was actually performed.
6505 */
6506 public static function flush_object_cache( bool $network_wide = false ): bool {
6507 if ( ! $network_wide && is_multisite() && function_exists( 'wp_cache_flush_group' ) && function_exists( 'wp_cache_supports' ) && wp_cache_supports( 'flush_group' ) ) {
6508 // Blog-scoped groups only; a shared/global group (site options,
6509 // user meta) is intentionally left alone.
6510 foreach ( array( 'options', 'posts', 'terms', 'post_meta', 'comment' ) as $group ) {
6511 wp_cache_flush_group( $group );
6512 }
6513 return true;
6514 }
6515 if ( function_exists( 'wp_cache_flush' ) ) {
6516 return (bool) wp_cache_flush();
6517 }
6518 return false;
6519 }
6520
6521 /**
6522 * Purge this site's cache: the local sweep, then the object cache, then
6523 * the bookkeeping every caller expects (stats, `xspeed_after_purge_all`,
6524 * inventory invalidation, purge log).
6525 *
6526 * @param string $cause Who asked, for the purge log.
6527 * @param string|null $host See purge_local().
6528 * @param array<string,mixed> $invalidation Public adapter policy. `scope`
6529 * is urls/site/network/none,
6530 * `intent` explains why, and
6531 * `urls` supplies exact targets.
6532 * @return int Page + REST entries removed.
6533 */
6534 public static function purge_all( string $cause = 'manual', ?string $host = null, array $invalidation = array() ) {
6535 $network_wide = ( '*' === $host );
6536 $adapter_scope = isset( $invalidation['scope'] ) && is_string( $invalidation['scope'] )
6537 ? $invalidation['scope']
6538 : ( $network_wide ? 'network' : 'site' );
6539 if ( ! in_array( $adapter_scope, array( 'urls', 'site', 'network', 'none' ), true ) ) {
6540 $adapter_scope = $network_wide ? 'network' : 'site';
6541 }
6542 if ( $network_wide ) {
6543 $adapter_scope = 'network';
6544 }
6545 $intent = isset( $invalidation['intent'] ) && is_string( $invalidation['intent'] ) && '' !== $invalidation['intent']
6546 ? $invalidation['intent']
6547 : 'complete';
6548 $urls = isset( $invalidation['urls'] ) && is_array( $invalidation['urls'] )
6549 ? array_values( array_unique( array_filter( $invalidation['urls'], 'is_string' ) ) )
6550 : array();
6551 // This method always sweeps a complete local bucket. A narrower adapter
6552 // announcement would claim unrelated local pages stayed warm when they
6553 // did not, leaving their server copies stale. Until purge_all() gains
6554 // dependency-aware local deletion, its response scope cannot be `urls`.
6555 if ( 'urls' === $adapter_scope ) {
6556 $adapter_scope = $network_wide ? 'network' : 'site';
6557 }
6558 if ( 'site' === $adapter_scope || 'network' === $adapter_scope || 'none' === $adapter_scope ) {
6559 $urls = array();
6560 }
6561
6562 $fallback = isset( $invalidation['fallback'] ) && is_string( $invalidation['fallback'] ) ? $invalidation['fallback'] : '';
6563
6564 $removed = self::purge_local( $host );
6565 $count = $removed['pages'] + $removed['rest'];
6566
6567 self::flush_object_cache( $network_wide );
6568
6569 self::update_stats( array( 'last_purge' => time() ) );
6570
6571 // Fire AFTER the local sweep so module listeners (Critical CSS,
6572 // Unused CSS, Cloudflare edge purge) run — this action had three
6573 // registered listeners but was never emitted. Treat it as additive
6574 // (CDN / edge invalidation), not the mechanism for clearing local
6575 // files. (FBS-83114)
6576 // Wrapped: this action predates the purge-event contract and has its
6577 // own third-party listeners. One of them throwing used to abort
6578 // purge_all() here, which now also means the contract event below
6579 // never fires and a server cache keeps serving stale HTML. The local
6580 // sweep is already done by this point, so swallowing is strictly safer
6581 // than letting a listener decide the rest of the method runs.
6582 try {
6583 // Isolated per listener: one throwing used to cancel every
6584 // listener queued behind it — Critical CSS, Unused CSS and the
6585 // Cloudflare edge purge all hang off this hook. (QA #348)
6586 self::do_action_isolated( 'xspeed_after_purge_all', $cause );
6587 } catch ( \Throwable $e ) {
6588 self::log_purge_listener_error( 'xspeed_after_purge_all', $e );
6589 }
6590
6591 /**
6592 * Fires after a full purge, with the same bounded context shape as
6593 * `xspeed_after_purge_url`.
6594 *
6595 * Distinct from `xspeed_after_purge_all` on purpose. That action is
6596 * the long-standing internal signal — it passes a bare `$cause` string
6597 * and Free's own modules use it for local bookkeeping. This one is the
6598 * documented contract for OUTSIDE integrations: same argument shape as
6599 * the per-URL event, so a server-cache or CDN adapter can subscribe to
6600 * both with one handler and branch on a null `url`.
6601 *
6602 * Fires at most once per purge, and not at all when a listener's own
6603 * purge re-enters xSpeed.
6604 *
6605 * @since 1.2.3
6606 *
6607 * @param array $context {
6608 * @type null $url Always null — a full purge has no single URL.
6609 * @type string $host Host swept, or '*' for the entire tree.
6610 * @type null $path Always null.
6611 * @type string $cause Short label for who asked.
6612 * @type int $removed Number of cache files removed.
6613 * @type string $scope Adapter action: urls/site/network/none.
6614 * @type string $intent content/presentation/complete or a caller-defined intent.
6615 * @type string[] $urls Exact targets when scope is urls.
6616 * @type string $fallback Why a change that would have cleared
6617 * only its own pages cleared the whole site
6618 * instead: `theme_list`, `limit`, `pending`
6619 * or `filter`. Empty for every other purge.
6620 * See the purge-event contract in
6621 * docs/guides/hooks-and-filters.md.
6622 * }
6623 */
6624 self::dispatch_purge_event(
6625 'xspeed_after_purge',
6626 array(
6627 'url' => null,
6628 'host' => null === $host ? self::current_purge_host() : (string) $host,
6629 'path' => null,
6630 'cause' => $cause,
6631 'removed' => $count,
6632 'scope' => $adapter_scope,
6633 'intent' => $intent,
6634 'urls' => $urls,
6635 'fallback' => $fallback,
6636 )
6637 );
6638
6639 /*
6640 * The generic event is published by the `dispatch_purge_event()` call
6641 * directly above, NOT here. Same supersession as in `purge_url()`:
6642 * this branch's raw `do_action` with a `count` payload predates #348's
6643 * contract, and keeping both made a full purge publish twice.
6644 */
6645
6646 // The list behind the "Cached pages" card is memoized for a minute;
6647 // a purge has to drop it or the drill-down shows pages that no
6648 // longer exist.
6649 Cache_Inventory::invalidate();
6650
6651 // Trigger of WP_CLI / hook / admin-bar purges all hit the same
6652 // path. Record once with the supplied cause so the dashboard
6653 // activity feed reads naturally.
6654 Activity_Log::record(
6655 'cache_purged',
6656 sprintf( 'Cache purged (%s) — %d file%s removed', $cause, $count, 1 === $count ? '' : 's' ),
6657 Activity_Log::INFO
6658 );
6659
6660 return $count;
6661 }
6662
6663 /**
6664 * Purge everything after a plugin / theme / core update completes.
6665 *
6666 * Bound to `upgrader_process_complete`, which is the only hook an update
6667 * fires — no activation hook runs, so without this the cached HTML (and
6668 * the asset URLs baked into it) outlives the code that produced it.
6669 *
6670 * Runs for plugin, theme and core updates alike, including bulk runs and
6671 * auto-updates, and purges the WHOLE network rather than the current
6672 * site — see the call below. Translation updates are skipped: they
6673 * change no markup a cached page depends on, and language packs update
6674 * often enough that purging on them would keep a multilingual site
6675 * permanently cold.
6676 *
6677 * Note this cannot be folded into the `$invalidate_hooks` loop above:
6678 * that binds `purge_all` directly, and `purge_all( string $cause )` would
6679 * then receive the WP_Upgrader instance as its cause.
6680 *
6681 * @param mixed $upgrader WP_Upgrader instance (unused).
6682 * @param array $hook_extra Context for the completed operation.
6683 * @return void
6684 */
6685 public static function purge_after_upgrade( $upgrader = null, $hook_extra = array() ) {
6686 $cleared = self::$upgrade_cleared_destination;
6687
6688 if ( ! self::upgrade_produced_something( $upgrader ) ) {
6689 return;
6690 }
6691
6692 if ( ! self::upgrade_should_purge( is_array( $hook_extra ) ? $hook_extra : array(), $cleared ) ) {
6693 return;
6694 }
6695
6696 self::purge_for_upgrade();
6697 }
6698
6699 /**
6700 * Whether this request's upgrader removed an existing copy.
6701 *
6702 * @var bool
6703 */
6704 private static $upgrade_cleared_destination = false;
6705
6706 /**
6707 * How many `upgrader_process_complete` dispatches are on the stack.
6708 *
6709 * @var int
6710 */
6711 private static $upgrade_dispatch_depth = 0;
6712
6713 /**
6714 * Enter an `upgrader_process_complete` dispatch.
6715 *
6716 * Bound at PHP_INT_MIN, so it runs before any listener that might read
6717 * the replacement signal. Public because it is a hook target.
6718 *
6719 * @return void
6720 */
6721 public static function note_upgrade_dispatch(): void {
6722 ++self::$upgrade_dispatch_depth;
6723 }
6724
6725 /**
6726 * Drop the replacement signal once every listener has read it.
6727 *
6728 * Bound at PHP_INT_MAX so a second upgrade in the same request starts
6729 * clean, without taking the answer away from the add-on callbacks that
6730 * run at the same priority as ours.
6731 *
6732 * Only the OUTERMOST dispatch clears it. A nested run — core's language
6733 * pack upgrader, or any add-on that installs something from this hook —
6734 * fires the action again, and clearing there would answer for a run that
6735 * has not finished. Called directly (no dispatch on the stack) it still
6736 * clears, which is what a test wants.
6737 *
6738 * Known limit: a nested run INHERITS the outer run's signal, because the
6739 * only evidence we get is a filter that fires before the nested dispatch
6740 * begins and carries no upgrader identity. So a fresh install performed
6741 * from inside a replacement run reads as a replacement and purges once
6742 * more than it needs to. A cold cache is the cheap direction, and the
6743 * alternative — scoping the signal per upgrader — is not knowable from
6744 * `upgrader_clear_destination`.
6745 *
6746 * The observed-destination signal is dropped here too. The two are read
6747 * together and have to expire together: leaving "the directory was not
6748 * there" behind would let a first install answer for whatever ran next
6749 * in the same request, and that one's mistake is a cache left stale.
6750 *
6751 * @return void
6752 */
6753 public static function forget_cleared_destination(): void {
6754 if ( self::$upgrade_dispatch_depth > 0 ) {
6755 --self::$upgrade_dispatch_depth;
6756 }
6757
6758 if ( 0 === self::$upgrade_dispatch_depth ) {
6759 self::$upgrade_cleared_destination = false;
6760 self::$upgrade_destination_existed = null;
6761 self::$upgrade_destination_folder = '';
6762 }
6763 }
6764
6765 /**
6766 * Whether the destination this run installs into was already there.
6767 *
6768 * Null while unknown — an upgrader whose target we cannot work out keeps
6769 * the old behaviour rather than being guessed at.
6770 *
6771 * @var bool|null
6772 */
6773 private static $upgrade_destination_existed = null;
6774
6775 /**
6776 * The folder name that answer was measured against, so the destination
6777 * WordPress reports on `upgrader_clear_destination` can be checked
6778 * against it. Empty when nothing was measured.
6779 *
6780 * @var string
6781 */
6782 private static $upgrade_destination_folder = '';
6783
6784 /**
6785 * Note whether the package's destination exists, before it is cleared.
6786 *
6787 * `upgrader_source_selection` is the last hook that fires while the old
6788 * copy is still on disk, and the extracted source folder name is the
6789 * directory the package will install into. A pass-through listener: the
6790 * source is returned untouched.
6791 *
6792 * @param mixed $source Extracted package directory.
6793 * @param mixed $remote_src Unused; the package's remote source.
6794 * @param mixed $upgrader The upgrader instance, if one was supplied.
6795 * @param mixed $hook_extra Context supplied by the upgrader.
6796 * @return mixed The source, unchanged.
6797 */
6798 public static function note_destination_state( $source, $remote_src = '', $upgrader = null, $hook_extra = array() ) {
6799 self::$upgrade_destination_existed = null;
6800 self::$upgrade_destination_folder = '';
6801
6802 $root = self::upgrade_destination_root( $upgrader, is_array( $hook_extra ) ? $hook_extra : array() );
6803 if ( null !== $root && is_string( $source ) && '' !== $source ) {
6804 $folder = basename( rtrim( $source, '/\\' ) );
6805 if ( '' !== $folder ) {
6806 self::$upgrade_destination_existed = is_dir( rtrim( $root, '/\\' ) . '/' . $folder );
6807 self::$upgrade_destination_folder = $folder;
6808 }
6809 }
6810
6811 return $source;
6812 }
6813
6814 /**
6815 * Where a package of this kind installs to, or null if we cannot tell.
6816 *
6817 * @param mixed $upgrader The upgrader instance, if one was supplied.
6818 * @param array $hook_extra Context supplied by the upgrader.
6819 * @return string|null
6820 */
6821 private static function upgrade_destination_root( $upgrader, array $hook_extra ): ?string {
6822 $type = isset( $hook_extra['type'] ) ? (string) $hook_extra['type'] : '';
6823
6824 if ( 'plugin' === $type || $upgrader instanceof \Plugin_Upgrader ) {
6825 return defined( 'WP_PLUGIN_DIR' ) ? WP_PLUGIN_DIR : null;
6826 }
6827
6828 if ( 'theme' === $type || $upgrader instanceof \Theme_Upgrader ) {
6829 return function_exists( 'get_theme_root' ) ? (string) get_theme_root() : null;
6830 }
6831
6832 return null;
6833 }
6834
6835 /**
6836 * Record that the upgrader cleared an existing destination.
6837 *
6838 * A pass-through listener on `upgrader_clear_destination`: WordPress only
6839 * fires it when `clear_destination` was set AND something was there to
6840 * remove, which is the one signal that separates an upload-and-replace
6841 * from a first-time install. The filtered value is returned untouched.
6842 *
6843 * @param true|\WP_Error $removed Whether the destination was cleared.
6844 * @return true|\WP_Error
6845 */
6846 public static function note_cleared_destination( $removed, $local_destination = '', $remote_destination = '', $hook_extra = array() ) {
6847 if ( ! is_wp_error( $removed ) ) {
6848 self::$upgrade_cleared_destination = true;
6849 }
6850
6851 // $remote_destination is the directory WordPress actually cleared,
6852 // derived from the source AFTER every `upgrader_source_selection`
6853 // listener ran. If its folder is not the one note_destination_state()
6854 // measured, a listener that ran after ours renamed the package, and
6855 // the "was it there?" answer is about a directory that was never going
6856 // to be written. Unknown is the answer that purges, so that is what it
6857 // becomes. Only the last segment is compared: over FTP the remote
6858 // path sits under the server's own root, not WP_PLUGIN_DIR. (#407 QA)
6859 if ( is_string( $remote_destination ) && '' !== $remote_destination ) {
6860 $cleared_folder = basename( rtrim( $remote_destination, '/\\' ) );
6861 if ( '' !== $cleared_folder && $cleared_folder !== self::$upgrade_destination_folder ) {
6862 self::$upgrade_destination_existed = null;
6863 }
6864 }
6865
6866 return $removed;
6867 }
6868
6869 /**
6870 * Did the completed run actually replace anything?
6871 *
6872 * `upgrader_process_complete` fires whether the run succeeded or failed —
6873 * the failure branch in WP_Upgrader::run() only feeds the skin before the
6874 * action fires. A run that installed nothing changed no markup, so purging
6875 * for it is a cold cache bought for nothing.
6876 *
6877 * Deliberately conservative: this returns false ONLY when every result we
6878 * can see is an error. An upgrader we cannot read, a mixed bulk run, or a
6879 * missing result all fall through to purging, which is the safe direction
6880 * everywhere else in this handler.
6881 *
6882 * @param mixed $upgrader WP_Upgrader instance, or anything else.
6883 * @return bool
6884 */
6885 public static function upgrade_produced_something( $upgrader ): bool {
6886 if ( ! is_object( $upgrader ) ) {
6887 return true;
6888 }
6889
6890 // A bulk run collects one entry per item; `result` alone would only
6891 // describe the last of them.
6892 if ( isset( $upgrader->results ) && is_array( $upgrader->results ) && ! empty( $upgrader->results ) ) {
6893 foreach ( $upgrader->results as $result ) {
6894 if ( ! is_wp_error( $result ) && ! empty( $result ) ) {
6895 return true;
6896 }
6897 }
6898 return false;
6899 }
6900
6901 if ( ! property_exists( $upgrader, 'result' ) ) {
6902 return true;
6903 }
6904
6905 return ! is_wp_error( $upgrader->result ) && ! empty( $upgrader->result );
6906 }
6907
6908 /**
6909 * Decide whether a completed operation invalidates the cache.
6910 *
6911 * Split out from the handler so the decision is testable on its own:
6912 * purge_all() reaches straight for glob() and unlink(), which a unit test
6913 * cannot observe honestly, while every rule that matters lives here.
6914 *
6915 * @param array $hook_extra Context for the completed operation.
6916 * @return bool
6917 */
6918 public static function upgrade_should_purge( array $hook_extra, bool $destination_cleared = false ): bool {
6919 if ( ! self::upgrade_replaced_code( $hook_extra, $destination_cleared ) ) {
6920 return false;
6921 }
6922
6923 // An update to xSpeed ITSELF always purges, whatever the setting says.
6924 // This plugin's own code is what rendered every cached page — the
6925 // minifier, lazy-loader, resource hints and CDN rewriter all changed
6926 // underneath it — so serving that HTML after an update means serving
6927 // output from a version that no longer exists. Minified assets make it
6928 // concrete rather than theoretical: their filenames are keyed on the
6929 // source filemtime, so they regenerate under NEW hashes while the
6930 // cached pages still link the old ones, and the page requests files
6931 // that are no longer on disk. Offering an opt-out for that would be
6932 // offering a broken site.
6933 return self::upgrade_touches_xspeed( $hook_extra ) || self::purge_on_upgrade_enabled();
6934 }
6935
6936 /**
6937 * Did this completed run replace code that renders pages?
6938 *
6939 * The half of the decision that has nothing to do with our settings: it
6940 * asks only whether live code changed underneath the output we cached.
6941 * Add-ons that keep their own derived artifacts — generated CSS, captured
6942 * selectors, fingerprints — need the same answer and must not have to
6943 * rebuild these rules, or they drift apart. Call it with the hook's own
6944 * `$hook_extra`; the upload-and-replace signal is read from this request.
6945 *
6946 * Deliberately independent of the "Purge After Updates" setting. That
6947 * setting governs the page cache, not whether an add-on's derived data is
6948 * still valid.
6949 *
6950 * @param array $hook_extra Context for the completed operation.
6951 * @param bool|null $destination_cleared Override the recorded signal; null reads this request's.
6952 * @return bool
6953 */
6954 public static function upgrade_replaced_code( array $hook_extra, ?bool $destination_cleared = null ): bool {
6955 $cleared = null === $destination_cleared ? self::$upgrade_cleared_destination : $destination_cleared;
6956
6957 $type = isset( $hook_extra['type'] ) ? (string) $hook_extra['type'] : '';
6958 $action = isset( $hook_extra['action'] ) ? (string) $hook_extra['action'] : '';
6959
6960 // `upgrader_process_complete` fires for INSTALLS as well as updates.
6961 // A freshly installed plugin is inactive and a freshly installed theme
6962 // is not the active one, so neither can change a single rendered page
6963 // — but the first cut of this handler purged the whole tree anyway, so
6964 // evaluating three plugins in a row emptied the cache three times.
6965 //
6966 // 'install' alone is NOT enough to skip on, because WordPress reports
6967 // an upload-and-replace as an install: `Plugin_Upgrader::install()`
6968 // hardcodes `action => install` and `overwrite_package` does not change
6969 // it, so "Replace current with uploaded" and `wp plugin install <zip>
6970 // --force` both arrive here labelled install while genuinely replacing
6971 // live code. That is how a plugin distributed as a zip is updated, and
6972 // skipping it put back the stale markup this handler exists to clear.
6973 //
6974 // The distinguishing signal is whether the destination was cleared:
6975 // WP_Upgrader only fires `upgrader_clear_destination` when it removed
6976 // something that was already there. Installing beside nothing does not.
6977 if ( 'install' === $action && ! $cleared ) {
6978 return false;
6979 }
6980
6981 // A cleared destination is only evidence of a replacement if there was
6982 // something in it. Core returns success from clear_destination() for a
6983 // destination that never existed, so a first-time install arrived here
6984 // looking exactly like an upload-and-replace and bought a cold cache
6985 // for a plugin that is not even active yet. Only acted on when we
6986 // positively know the directory was absent.
6987 if ( 'install' === $action && false === self::$upgrade_destination_existed ) {
6988 return false;
6989 }
6990
6991 // 'translation' is the one update type that cannot change rendered
6992 // markup. Anything else — including an empty type from a custom
6993 // updater — is treated as cache-invalidating, because guessing wrong
6994 // in that direction only costs a cold cache.
6995 if ( 'translation' === $type ) {
6996 return false;
6997 }
6998
6999 return true;
7000 }
7001
7002 /**
7003 * Purge everything an update can invalidate.
7004 *
7005 * Network-wide ('*'), not the calling site's bucket. A plugin, theme or
7006 * core update replaces code shared by EVERY site on the network, so a
7007 * scoped purge would clear the site that happened to run the updater and
7008 * leave every other subsite serving pre-update HTML for the whole TTL —
7009 * the very bug this handler exists to fix, one level down. On single-site
7010 * this is identical to the scoped call, since there is only ever one
7011 * bucket.
7012 *
7013 * @return void
7014 */
7015 private static function purge_for_upgrade(): void {
7016 // Network-wide, so purge_local() also clears min/.
7017 self::purge_all( 'upgrade', '*' );
7018 }
7019
7020 /**
7021 * Purge after an unattended background update run.
7022 *
7023 * `automatic_updates_complete` passes ONE argument, and it is not a
7024 * hook_extra: it is WordPress's results array, keyed by what was updated
7025 * ('core', 'plugin', 'theme', 'translation'). Handing it to
7026 * purge_after_upgrade() put it in the unused $upgrader slot and left the
7027 * type empty, so a night on which only a language pack updated purged
7028 * every cached page — the exact case the translation exemption exists to
7029 * prevent, and WordPress auto-updates language packs by default.
7030 *
7031 * @param array $results Update results, keyed by type.
7032 * @return void
7033 */
7034 public static function purge_after_auto_updates( $results = array() ): void {
7035 if ( ! self::auto_updates_should_purge( is_array( $results ) ? $results : array() ) ) {
7036 return;
7037 }
7038
7039 self::purge_for_upgrade();
7040 }
7041
7042 /**
7043 * Decide whether a background update run invalidates the cache.
7044 *
7045 * @param array $results Update results, keyed by type.
7046 * @return bool
7047 */
7048 public static function auto_updates_should_purge( array $results ): bool {
7049 // An unrecognisable payload is treated as invalidating, the same
7050 // direction every other unknown takes here.
7051 if ( empty( $results ) ) {
7052 return true;
7053 }
7054
7055 // Failed items are listed alongside successful ones — WP_Automatic_Updater
7056 // appends an entry whatever the outcome — and a night on which every
7057 // update failed replaced no code, so it invalidates nothing.
7058 $updated = array();
7059 foreach ( $results as $type => $items ) {
7060 if ( ! is_array( $items ) ) {
7061 continue;
7062 }
7063 foreach ( $items as $item ) {
7064 $result = is_object( $item ) && isset( $item->result ) ? $item->result : true;
7065 if ( ! is_wp_error( $result ) && ! empty( $result ) ) {
7066 $updated[] = (string) $type;
7067 break;
7068 }
7069 }
7070 }
7071
7072 if ( empty( $updated ) ) {
7073 return false;
7074 }
7075
7076 // Nothing but language packs: a language pack changes no markup a
7077 // cached page depends on, and purging on one would keep a multilingual
7078 // site permanently cold.
7079 if ( array( 'translation' ) === array_values( array_unique( $updated ) ) ) {
7080 return false;
7081 }
7082
7083 return self::auto_updates_touch_xspeed( $results ) || self::purge_on_upgrade_enabled();
7084 }
7085
7086 /**
7087 * Does a background run include one of our own plugins?
7088 *
7089 * Same rule as a foreground self-update, read out of the results array's
7090 * shape instead of a hook_extra: each plugin entry carries the update
7091 * object on `->item->plugin`.
7092 *
7093 * @param array $results Update results, keyed by type.
7094 * @return bool
7095 */
7096 private static function auto_updates_touch_xspeed( array $results ): bool {
7097 if ( empty( $results['plugin'] ) || ! is_array( $results['plugin'] ) ) {
7098 return false;
7099 }
7100
7101 $ours = self::self_update_plugins();
7102 foreach ( $results['plugin'] as $entry ) {
7103 $item = is_object( $entry ) && isset( $entry->item ) ? $entry->item : null;
7104 $file = is_object( $item ) && isset( $item->plugin ) ? (string) $item->plugin : '';
7105 if ( '' !== $file && in_array( $file, $ours, true ) ) {
7106 return true;
7107 }
7108 }
7109
7110 return false;
7111 }
7112
7113 /**
7114 * Is the "Purge After Updates" setting on?
7115 *
7116 * Gates THIRD-PARTY updates only — an xSpeed self-update ignores it, see
7117 * purge_after_upgrade(). Defaults to true when the option has never been
7118 * written, matching the schema default in CacheModule: an unset value on
7119 * an existing install must not read as "the user turned this off".
7120 *
7121 * Unlike LiteSpeed, which ships the equivalent toggle OFF, this defaults
7122 * ON — a cold cache costs one slow request, whereas stale HTML is a wrong
7123 * page for up to the full TTL and the site owner has no way to tell why.
7124 *
7125 * @return bool
7126 */
7127 private static function purge_on_upgrade_enabled(): bool {
7128 $opts = Settings_Manager::get( 'cache' );
7129 return ! array_key_exists( 'purge_on_upgrade', $opts ) || ! empty( $opts['purge_on_upgrade'] );
7130 }
7131
7132 /**
7133 * Does this completed update include xSpeed itself?
7134 *
7135 * Mirrors the payload shapes Plugin::maybe_restore_after_update() reads:
7136 * a single update carries 'plugin', a bulk run carries 'plugins'.
7137 *
7138 * @param array $hook_extra Context for the completed operation.
7139 * @return bool
7140 */
7141 private static function upgrade_touches_xspeed( array $hook_extra ): bool {
7142 if ( ! isset( $hook_extra['type'] ) || 'plugin' !== $hook_extra['type'] ) {
7143 return false;
7144 }
7145
7146 $updated = array();
7147 if ( isset( $hook_extra['plugins'] ) && is_array( $hook_extra['plugins'] ) ) {
7148 // Strings only: array_intersect() stringifies what it is given, so
7149 // an object without __toString in a custom updater's payload would
7150 // be a fatal rather than a miss.
7151 $updated = array_filter( $hook_extra['plugins'], 'is_string' );
7152 } elseif ( isset( $hook_extra['plugin'] ) && is_string( $hook_extra['plugin'] ) ) {
7153 $updated = array( $hook_extra['plugin'] );
7154 }
7155
7156 return (bool) array_intersect( self::self_update_plugins(), $updated );
7157 }
7158
7159 /**
7160 * Plugin files whose update counts as an update to us.
7161 *
7162 * The self-update rule is "our own code rendered this cached HTML, so it
7163 * must not survive the code being replaced". That is true of any add-on
7164 * that writes into the same page: an add-on inlines critical CSS, rewrites
7165 * stylesheet links and image URLs, and produces the compressed and static
7166 * copies, so its update leaves exactly the stale markup this rule exists
7167 * to clear. Free cannot name an add-on, so it asks instead.
7168 *
7169 * Filter: xspeed_self_update_plugins
7170 *
7171 * Add-ons add their own `plugin_basename( __FILE__ )`. Entries are matched
7172 * against the plugin files WordPress reports for the completed update, so
7173 * a value that is not a `dir/file.php` basename simply never matches.
7174 *
7175 * @param string[] $plugins Plugin basenames treated as our own.
7176 * @return string[]
7177 */
7178 private static function self_update_plugins(): array {
7179 $ours = array( plugin_basename( XSPEED_FILE ) );
7180
7181 /** This filter is documented above. */
7182 $filtered = apply_filters( 'xspeed_self_update_plugins', $ours );
7183
7184 // Our own file is merged back afterwards rather than trusted to survive
7185 // the round trip. A listener that returns null, a bare string, or a
7186 // list it built from scratch would otherwise drop it, and the plugin
7187 // would quietly stop exempting its OWN update from the setting — a
7188 // failure no add-on author would think to test for.
7189 $claimed = array_filter( is_array( $filtered ) ? $filtered : array(), 'is_string' );
7190
7191 return array_values( array_unique( array_merge( $ours, array_filter( $claimed ) ) ) );
7192 }
7193
7194 /**
7195 * Invalidate caches of RENDERED output owned by other plugins.
7196 *
7197 * purge_all() sweeps only what xSpeed wrote. A page builder that stores
7198 * rendered HTML or generated CSS of its own — Elementor's element cache
7199 * and `uploads/elementor/css/`, and the equivalents in Beaver / Divi /
7200 * Bricks / Oxygen — keeps whatever asset URLs were current when it was
7201 * written, and no xSpeed purge has ever reached it.
7202 *
7203 * That only matters for rewrites that happen DURING render rather than on
7204 * the finished page. Minify, combine, lazy-load and resource hints all run
7205 * on `xspeed_cache_final_html` or a `template_redirect` buffer — after the
7206 * builder has already stored its copy — so nothing they emit can leak.
7207 * The CDN module's `wp_get_attachment_url` filter is the one that can.
7208 *
7209 * Called ONLY from purges where asset URLs themselves can have changed
7210 * (a CDN settings write, an explicit Purge All). NOT from purge_all(),
7211 * which also runs on every post publish — regenerating every builder CSS
7212 * file that often would cost more than it saves, and the builder already
7213 * invalidates its own copy for the post being saved.
7214 *
7215 * @param string $cause Who asked. Threaded through to the listeners and
7216 * the activity log.
7217 * @return string[] Labels of the caches that were actually cleared.
7218 */
7219 public static function purge_render_caches( string $cause = 'manual' ): array {
7220 /**
7221 * Clear render caches belonging to other plugins.
7222 *
7223 * A listener does its own work and appends a human-readable label for
7224 * what it cleared, so the activity log can name it. Returning
7225 * `$cleared` unchanged means "nothing of mine is installed" and is the
7226 * correct no-op — never a failure.
7227 *
7228 * Detect the owning plugin by class or constant, not by an
7229 * `is_plugin_active()` path check: a renamed plugin folder must not
7230 * silently disable the integration.
7231 *
7232 * @param string[] $cleared Labels of caches cleared so far.
7233 * @param string $cause Why the purge is happening.
7234 */
7235 $cleared = (array) apply_filters( 'xspeed_purge_third_party_render_caches', array(), $cause );
7236
7237 // Labels are strings destined for the activity feed. Anything else a
7238 // third-party listener returns is dropped rather than coerced — a
7239 // stray `0` or `null` in the log reads as a cache we cleared.
7240 $cleared = array_values(
7241 array_filter(
7242 $cleared,
7243 static function ( $label ) {
7244 return is_string( $label ) && '' !== trim( $label );
7245 }
7246 )
7247 );
7248
7249 if ( ! $cleared ) {
7250 return $cleared;
7251 }
7252
7253 // Logged separately from the page-cache purge above it. "I turned the
7254 // CDN off and the images are still wrong" is only diagnosable if the
7255 // feed says which OTHER plugin's cache was regenerated and when.
7256 Activity_Log::record(
7257 'cache_purged',
7258 sprintf( 'Render caches cleared (%s) — %s', $cause, implode( ', ', $cleared ) ),
7259 Activity_Log::INFO
7260 );
7261
7262 return $cleared;
7263 }
7264
7265 /**
7266 * The per-type purge menu, LiteSpeed-style. Each entry is a cache type
7267 * the user can purge individually from the admin-bar dropdown. `visible`
7268 * controls whether the item shows (active + licensed module only) — it
7269 * NEVER limits Purge All, which always sweeps everything on disk.
7270 *
7271 * Pro registers its own types (Critical CSS, Unused CSS, …) by filtering
7272 * `xspeed_purge_types`, so Free degrades gracefully when Pro is absent.
7273 *
7274 * @return array<string,array{label:string,visible:bool}>
7275 */
7276 public static function purge_types(): array {
7277 $minify_on = false;
7278 if ( class_exists( '\\XSpeed\\Settings_Manager' ) ) {
7279 $min = Settings_Manager::get( 'minify' );
7280 $minify_on = ! empty( $min['minify_css'] ) || ! empty( $min['minify_js'] ) || ! empty( $min['combine_css'] ) || ! empty( $min['combine_js'] );
7281 }
7282 // Object cache is "active" when an external object-cache drop-in is in
7283 // use — the canonical WP signal, independent of our settings option.
7284 $oc_on = function_exists( 'wp_using_ext_object_cache' ) && wp_using_ext_object_cache();
7285
7286 $types = array(
7287 'all' => array(
7288 'label' => __( 'Purge All', 'xspeed' ),
7289 'visible' => true,
7290 ),
7291 'page' => array(
7292 'label' => __( 'Purge Page / Static Cache', 'xspeed' ),
7293 'visible' => true,
7294 ),
7295 'assets' => array(
7296 'label' => __( 'Purge CSS / JS Cache', 'xspeed' ),
7297 'visible' => $minify_on,
7298 ),
7299 'object' => array(
7300 'label' => __( 'Purge Object Cache', 'xspeed' ),
7301 'visible' => $oc_on,
7302 ),
7303 'rest' => array(
7304 'label' => __( 'Purge REST Cache', 'xspeed' ),
7305 'visible' => true,
7306 ),
7307 );
7308
7309 /**
7310 * Filter the admin-bar purge-type menu. Pro modules add their own
7311 * (Critical CSS, Unused CSS, CDN). Adding a type here only adds a
7312 * MENU item — purge_type() must know how to handle the same slug.
7313 *
7314 * @param array $types Map of slug => [label, visible].
7315 */
7316 return (array) apply_filters( 'xspeed_purge_types', $types );
7317 }
7318
7319 /**
7320 * Purge a single cache type by slug. 'all' delegates to purge_all();
7321 * every other slug clears just its own artifacts. Unknown slugs (e.g. a
7322 * Pro type) fan out via the `xspeed_purge_type_{slug}` action so the
7323 * owning module can handle it. Returns the number of items removed where
7324 * countable.
7325 *
7326 * @param string $type Cache type slug.
7327 * @param string $cause Who asked. Threaded through so the purge log can
7328 * tell an AI assistant's purge apart from a click —
7329 * "the cache cleared four times today" is only
7330 * actionable once you know what kept clearing it.
7331 */
7332 public static function purge_type( string $type, string $cause = 'manual' ): int {
7333 switch ( $type ) {
7334 case 'all':
7335 $count = self::purge_all( $cause );
7336 // "Purge All" is the user saying they don't trust anything
7337 // stored anywhere — the one purge that should also reach
7338 // caches of rendered output we don't own. purge_all() itself
7339 // deliberately does NOT, because it also runs on every post
7340 // publish. (See Render_Caches.)
7341 self::purge_render_caches( $cause );
7342 return $count;
7343
7344 case 'page':
7345 $count = self::purge_pages();
7346 self::update_stats( array( 'last_purge' => time() ) );
7347 Cache_Inventory::invalidate();
7348 self::record_partial_purge( 'page', $cause, $count );
7349 self::announce_purge( $cause, $count );
7350 return $count;
7351
7352 case 'assets':
7353 return self::purge_assets( $cause );
7354
7355 case 'object':
7356 if ( function_exists( 'wp_cache_flush' ) ) {
7357 wp_cache_flush();
7358 }
7359 self::record_partial_purge( 'object cache', $cause, null );
7360 return 0;
7361
7362 case 'rest':
7363 $count = Rest_Cache::purge();
7364 self::record_partial_purge( 'REST responses', $cause, $count );
7365 self::announce_purge( $cause, $count );
7366 return $count;
7367
7368 default:
7369 return self::purge_type_unhandled( $type, $cause );
7370 }
7371 }
7372
7373 /**
7374 * Whether a widget change already purged in this request.
7375 *
7376 * @var bool
7377 */
7378 private static $widgets_purged = false;
7379
7380 /**
7381 * A widget option changed: purge when it is a widget's settings.
7382 *
7383 * @param string $option Option name.
7384 */
7385 public static function on_widget_option_change( $option ): void {
7386 if ( is_string( $option ) && 0 === strpos( $option, 'widget_' ) ) {
7387 self::on_widgets_change();
7388 }
7389 }
7390
7391 /**
7392 * A widget was added, removed, moved or edited. Sidebars render on every
7393 * page, so the whole site goes, once per request: one save writes both
7394 * `sidebars_widgets` and the widget's own option.
7395 *
7396 * A Customizer publish writes the same options and purges on its own
7397 * (on_customize_save()), so it is left to that.
7398 */
7399 public static function on_widgets_change(): void {
7400 if ( self::$widgets_purged || did_action( 'customize_save' ) ) {
7401 return;
7402 }
7403 self::$widgets_purged = true;
7404 self::purge_all(
7405 'widgets',
7406 null,
7407 array(
7408 'scope' => 'site',
7409 'intent' => 'presentation',
7410 'urls' => array(),
7411 )
7412 );
7413 }
7414
7415 /**
7416 * Purge this site's pages after a Customizer publish.
7417 *
7418 * A presentation change: every page's markup or inline CSS may differ, so
7419 * the whole site bucket goes, as for a template or global-styles edit.
7420 * min/ stays, because its files are named by content; this blog's
7421 * manifests are dropped so the next render re-reads every source rather
7422 * than trusting a signature. Bound with no arguments, because the hook
7423 * passes the WP_Customize_Manager, which purge_all() would take as its
7424 * cause.
7425 */
7426 public static function on_customize_save(): void {
7427 self::purge_all(
7428 'customizer',
7429 null,
7430 array(
7431 'scope' => 'site',
7432 'intent' => 'presentation',
7433 'urls' => array(),
7434 )
7435 );
7436 if ( class_exists( '\\XSpeed\\Minifier' ) ) {
7437 Minifier::purge_manifests( Asset_Manifest::blog_id() );
7438 }
7439 }
7440
7441 /**
7442 * Purge minified and combined CSS/JS, and the pages that link them.
7443 *
7444 * Scope follows who shares what:
7445 *
7446 * - Single site: min/ is deleted outright.
7447 * - One site of a network: min/ is shared by every blog, so only this
7448 * blog's manifests go. Its next render re-reads each source and
7449 * rebuilds only what changed; no other blog loses a file it links.
7450 * Outputs nothing links any more are left to Cache_GC.
7451 * - $network: min/ and every blog's pages, network-wide.
7452 *
7453 * Deleting min/ without clearing the pages that link it left every
7454 * cached page pointing at files that no longer exist. WordPress answers
7455 * the missing asset by 301-ing to its pretty-permalink form and serving
7456 * the 404 TEMPLATE as `HTTP 200 text/html`, which the browser accepts as
7457 * a stylesheet and parses to zero rules — no console error, no network
7458 * failure, no 4xx anywhere in devtools. The pages stayed broken for the
7459 * rest of the TTL, and the admin who clicked could not see it: they are
7460 * logged in, so their own requests bypass the page cache. (#244) So the
7461 * pages go too, and when files were actually deleted the edge is told
7462 * through `xspeed_after_purge_all`, or it keeps serving HTML that links
7463 * them.
7464 *
7465 * The public `xspeed_after_purge` event carries intent `assets`: rendered
7466 * markup did not change, only asset files did. A listener that keeps
7467 * measurements of rendered pages (selectors, layout) can keep them.
7468 *
7469 * @param string $cause Who asked.
7470 * @param bool $network Purge every blog's pages and all of min/.
7471 * @return int Page entries removed.
7472 */
7473 public static function purge_assets( string $cause, bool $network = false ): int {
7474 $deleted = 0;
7475 $rest = 0;
7476 if ( $network ) {
7477 // purge_local('*') clears min/ itself on a network-wide sweep.
7478 $removed = self::purge_local( '*' );
7479 $count = (int) $removed['pages'];
7480 $rest = (int) $removed['rest'];
7481 $deleted = (int) $removed['assets'];
7482 } else {
7483 if ( class_exists( '\\XSpeed\\Minifier' ) && is_multisite() ) {
7484 Minifier::purge_manifests( Asset_Manifest::blog_id() );
7485 } elseif ( class_exists( '\\XSpeed\\Minifier' ) ) {
7486 $deleted = Minifier::purge_minified();
7487 }
7488 $count = self::purge_pages();
7489 }
7490
7491 self::update_stats( array( 'last_purge' => time() ) );
7492 Cache_Inventory::invalidate();
7493 self::record_partial_purge( 'assets', $cause, $count + $rest );
7494
7495 if ( $deleted > 0 || $network ) {
7496 try {
7497 self::do_action_isolated( 'xspeed_after_purge_all', $cause );
7498 } catch ( \Throwable $e ) {
7499 self::log_purge_listener_error( 'xspeed_after_purge_all', $e );
7500 }
7501 }
7502
7503 if ( ! $network ) {
7504 self::announce_purge( $cause, $count, 'site', 'assets' );
7505 } elseif ( function_exists( 'do_action' ) ) {
7506 try {
7507 self::dispatch_purge_event(
7508 'xspeed_after_purge',
7509 array(
7510 'url' => null,
7511 'host' => '*',
7512 'path' => null,
7513 'cause' => $cause,
7514 'removed' => $count + $rest,
7515 'scope' => 'network',
7516 'intent' => 'assets',
7517 'urls' => array(),
7518 )
7519 );
7520 } catch ( \Throwable $e ) {
7521 self::log_purge_listener_error( 'xspeed_after_purge', $e );
7522 }
7523 }
7524
7525 return $count + $rest;
7526 }
7527
7528 /**
7529 * Delete this site's cached pages from both the flat and static trees.
7530 *
7531 * Extracted so the `assets` purge can reuse it: minified assets are a
7532 * dependency of the cached HTML, so clearing them must clear the pages
7533 * too or the pages are left referencing deleted files (#244).
7534 *
7535 * @return int Number of page entries removed.
7536 */
7537 private static function purge_pages(): int {
7538 $count = 0;
7539 // Scoped to this site — see purge_all(). (#6)
7540 $scope = self::current_host_dir();
7541 $flat_root = XSPEED_CACHE_DIR . '/' . $scope;
7542 if ( is_dir( $flat_root ) ) {
7543 foreach ( (array) glob( $flat_root . '/*.html' ) as $f ) {
7544 wp_delete_file( $f );
7545 ++$count;
7546 }
7547 foreach ( (array) glob( $flat_root . '/*.meta' ) as $m ) {
7548 wp_delete_file( $m );
7549 }
7550 foreach ( (array) glob( $flat_root . '/*.br' ) as $b ) {
7551 wp_delete_file( $b );
7552 }
7553 // `*.br` does not match `*.br.size`; a size record outliving its
7554 // body would later be read against a DIFFERENT sibling's bytes.
7555 foreach ( (array) glob( $flat_root . '/*.br.size' ) as $b ) {
7556 wp_delete_file( $b );
7557 }
7558 }
7559 $static_root = XSPEED_CACHE_STATIC_DIR . '/' . self::current_static_scope();
7560 if ( is_dir( $static_root ) ) {
7561 $count += self::rmtree_html( $static_root );
7562 }
7563
7564 return $count;
7565 }
7566
7567 /**
7568 * A purge type this class does not own — a Pro or third-party module
7569 * registered it via the `xspeed_purge_types` filter, so hand it off.
7570 *
7571 * @param string $type Purge-type slug.
7572 * @param string $cause Who asked.
7573 */
7574 private static function purge_type_unhandled( string $type, string $cause ): int {
7575 $event_sequence = self::$purge_event_sequence;
7576 $hook = 'xspeed_purge_type_' . $type;
7577 $has_handler = false !== has_action( $hook );
7578 do_action( $hook );
7579 self::record_partial_purge( $type, $cause, null );
7580
7581 // Announce, same as the types this class owns. Pro's "Purge Critical
7582 // CSS" and "Purge Unused CSS" arrive here, and they change what a
7583 // cached page CONTAINS — critical CSS is inlined into the HTML, so a
7584 // server cache goes on serving pages with the old styles baked in.
7585 // Fixing the three Free buttons and leaving these two silent left the
7586 // same hole for the tier most likely to be using both plugins.
7587 // (QA #348 round 2, issue 2)
7588 //
7589 // Unknown slugs must not turn into a site-wide purge merely because no
7590 // handler exists. These are the response-changing Pro types Free knows;
7591 // third parties can declare another through the filter. A registered
7592 // handler plus this explicit response scope is the handled signal.
7593 $scope = in_array( $type, array( 'critical-css', 'unused-css' ), true ) ? 'site' : 'none';
7594 /**
7595 * Declare whether a handled custom purge type changes cached responses.
7596 *
7597 * @since 1.2.3
7598 * @param string $scope site/network/none.
7599 * @param string $type Purge-type slug.
7600 */
7601 $scope = (string) apply_filters( 'xspeed_purge_type_response_scope', $scope, $type );
7602 if ( $has_handler
7603 && $event_sequence === self::$purge_event_sequence
7604 && in_array( $scope, array( 'site', 'network' ), true )
7605 ) {
7606 self::announce_purge( $cause, 0, $scope, 'presentation' );
7607 }
7608
7609 return 0;
7610 }
7611
7612 /**
7613 * Tell the server cache that a PARTIAL purge cleared cached responses.
7614 *
7615 * "Purge Page / Static Cache", "Purge CSS / JS Cache" and "Purge REST
7616 * Cache" each delete cached RESPONSES for the whole site, so a cache in
7617 * front of PHP is now serving copies xSpeed has just thrown away. Only
7618 * "Purge All" announced itself, which left three of the four toolbar
7619 * buttons doing exactly what this contract exists to prevent: clearing
7620 * our copy while the server kept serving the stale one. The `assets` case
7621 * was the sharpest — it deletes the minified bundles too, so LiteSpeed
7622 * went on serving pages whose CSS and JS no longer exist. (QA #348)
7623 *
7624 * Sent as the full-purge shape (`url` null) because that is what happened:
7625 * every cached page for this site went, not one address. `object` is not
7626 * announced — flushing the object cache changes no rendered response a
7627 * server cache could be holding.
7628 *
7629 * Public because Purge_Runner sweeps the local files itself, through
7630 * purge_local(), rather than through purge_all() — so it has to announce
7631 * on its own behalf or `wp xspeed purge` and the dashboard button clear
7632 * our copy while LiteSpeed keeps serving the stale one.
7633 *
7634 * @param string $cause Who asked.
7635 * @param int $removed Entries removed locally.
7636 * @param string $scope Actionable adapter scope.
7637 * @param string $intent Reason rendered responses changed.
7638 */
7639 public static function announce_purge( string $cause, int $removed, string $scope = 'site', string $intent = 'complete' ): void {
7640 // Announcing is additive: the local sweep has already happened and
7641 // succeeded. Notification must never be able to turn a working purge
7642 // into a fatal, so anything the URL helpers do in an unusual context
7643 // (early boot, a drop-in, a bare test harness) is contained here
7644 // rather than propagating to the caller.
7645 if ( ! function_exists( 'home_url' ) || ! function_exists( 'do_action' ) ) {
7646 return;
7647 }
7648 try {
7649 self::dispatch_purge_event(
7650 'xspeed_after_purge',
7651 array(
7652 'url' => null,
7653 'host' => self::current_purge_host(),
7654 'path' => null,
7655 'cause' => $cause,
7656 'removed' => $removed,
7657 'scope' => $scope,
7658 'intent' => $intent,
7659 'urls' => array(),
7660 )
7661 );
7662 } catch ( \Throwable $e ) {
7663 self::log_purge_listener_error( 'xspeed_after_purge', $e );
7664 }
7665 }
7666
7667 /**
7668 * Log a partial purge so the drill-down behind "Last purge" shows every
7669 * clear, not only the full ones. Without this a site whose object cache
7670 * is flushed on a schedule looks, from the log, like nothing happens.
7671 *
7672 * @param string $what Human label for the slice purged.
7673 * @param string $cause Who asked.
7674 * @param int|null $count Items removed, when countable.
7675 */
7676 private static function record_partial_purge( string $what, string $cause, ?int $count ): void {
7677 $message = null === $count
7678 ? sprintf(
7679 /* translators: 1: what was purged, 2: cause of the purge. */
7680 __( 'Purged %1$s (%2$s)', 'xspeed' ),
7681 $what,
7682 $cause
7683 )
7684 : sprintf(
7685 /* translators: 1: what was purged, 2: cause of the purge, 3: number of files removed. */
7686 __( 'Purged %1$s (%2$s) — %3$d file(s) removed', 'xspeed' ),
7687 $what,
7688 $cause,
7689 $count
7690 );
7691
7692 Activity_Log::record( 'cache_purged', $message, Activity_Log::INFO );
7693
7694 /*
7695 * A partial purge is still a purge, and an edge in front of this site
7696 * has to hear about it.
7697 *
7698 * "Purge Page / Static Cache" is always visible in the admin bar and
7699 * clears every cached page for this site — the flat tree AND the
7700 * static tree the generated nginx/Apache rules serve directly. It
7701 * announced none of that: not `xspeed_after_purge_all`, which only
7702 * purge_all() fires, and not the scoped actions. A CDN told to hold
7703 * those pages went on serving the ones xSpeed had just deleted, for
7704 * whatever lifetime it was given.
7705 *
7706 * Scope is `site` only for the two types that clear rendered HTML.
7707 * An object-cache flush or a REST purge removes nothing an edge is
7708 * holding, and announcing those as a site purge would have a CDN
7709 * drop its whole cache every time a scheduled flush ran — worse than
7710 * the silence this replaces. A type registered by another plugin
7711 * through `xspeed_purge_types` is unknown here, so it says nothing
7712 * rather than guessing.
7713 *
7714 * Not gated on `$count`, for the reason spelled out in purge_url():
7715 * an empty local tree is not evidence that the edge is empty, and
7716 * "Purge Page / Static Cache" with nothing left locally is precisely
7717 * what someone clicks when the page they are looking at is stale.
7718 * Answering that with silence made the button appear broken.
7719 *
7720 * That announcement is now `announce_purge()`'s, called beside this
7721 * by the same callers (`page`, `assets`, `REST responses`). This
7722 * function went back to being what its name says: a log entry. It
7723 * used to publish as well, which double-fired every partial purge and
7724 * — worse — announced `scope: none` for types #348's contract
7725 * requires to stay silent about.
7726 */
7727 }
7728
7729 /**
7730 * Clear the static tree only, leaving the flat cache in place.
7731 *
7732 * A narrower purge_all() for the case where only the web-server tree can
7733 * be wrong: its files are keyed by `{host}{path}` and nothing else, so a
7734 * response filed under the wrong path poisons it while the flat cache —
7735 * keyed by cache_key(), discriminators included — stays correct. Avoids
7736 * throwing away Critical CSS, minified bundles and the object cache to
7737 * fix a static-only problem.
7738 *
7739 * @return int Number of index.html files removed.
7740 */
7741 public static function purge_static_tree(): int {
7742 return self::rmtree_html( XSPEED_CACHE_STATIC_DIR );
7743 }
7744
7745 /**
7746 * Recursively delete every `index.html` (and its precompressed
7747 * `index.html.br` sibling, if the Pro Brotli module wrote one) plus
7748 * empty directories inside the static-cache tree. Used by purge_all().
7749 * Returns the number of .html files removed so purge stats stay accurate
7750 * across the flat + static caches — .br siblings are not counted
7751 * (they're encodings of a page, not pages).
7752 */
7753 /**
7754 * Delete a cache file, adding its size to the current sweep's byte
7755 * total. filesize() is silenced and re-checked because the file can
7756 * vanish between the glob and the unlink — a concurrent purge, or the
7757 * cache GC — and a warning there would be noise, not news.
7758 *
7759 * @param string $file Absolute path inside the cache tree.
7760 */
7761 private static function sweep_delete( string $file ): void {
7762 $size = @filesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- the file may be gone already; see docblock.
7763 if ( is_int( $size ) ) {
7764 self::$sweep_bytes += $size;
7765 }
7766 wp_delete_file( $file );
7767 }
7768
7769 private static function rmtree_html( string $dir ): int {
7770 if ( ! is_dir( $dir ) ) {
7771 return 0;
7772 }
7773 $removed = 0;
7774 // SCANDIR_SORT_NONE skips alphabetic sort — we're going to walk
7775 // the whole tree regardless of order.
7776 $entries = @scandir( $dir, SCANDIR_SORT_NONE ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
7777 if ( false === $entries ) {
7778 return 0;
7779 }
7780 foreach ( $entries as $entry ) {
7781 if ( '.' === $entry || '..' === $entry ) {
7782 continue;
7783 }
7784 $path = $dir . '/' . $entry;
7785 if ( is_dir( $path ) ) {
7786 $removed += self::rmtree_html( $path );
7787 // Best-effort empty-dir cleanup; ignore failures (a
7788 // foreign file inside would block rmdir, which is fine).
7789 // 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.
7790 @rmdir( $path );
7791 continue;
7792 }
7793 if ( substr( $entry, -5 ) === '.html' ) {
7794 self::sweep_delete( $path );
7795 ++$removed;
7796 } elseif ( self::EXPIRES_MARKER === $entry ) {
7797 // store_static()'s deadline for a nonce page. Left behind, it
7798 // kept every such folder from being removed, and the failed
7799 // rmdir warned once per folder: Query Monitor turned those
7800 // into enough response headers to 502 a post save (#636).
7801 self::sweep_delete( $path );
7802 } elseif ( substr( $entry, -3 ) === '.br' || substr( $entry, -8 ) === '.br.size' ) {
7803 // Precompressed sibling (index.html.br) and the record of its
7804 // length. Remove both so a purge doesn't orphan stale Brotli
7805 // bodies, or a size record that would later be read against a
7806 // different sibling's bytes. Not counted.
7807 self::sweep_delete( $path );
7808 }
7809 }
7810 return $removed;
7811 }
7812
7813 /**
7814 * Drop a "silence is golden" index.php into a directory so apaches/nginx
7815 * with directory listing enabled don't expose cache contents.
7816 */
7817 public static function write_silence( $dir ) {
7818 $file = trailingslashit( $dir ) . 'index.php';
7819 if ( ! file_exists( $file ) ) {
7820 // 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.
7821 file_put_contents( $file, "<?php\n// Silence is golden.\n" );
7822 }
7823 }
7824
7825 /**
7826 * The raw xspeed_stats option as an array. Keys currently in use:
7827 * 'last_purge', 'last_gc', 'gc_removed', 'gc_removed_total'.
7828 */
7829 public static function get_stats_option(): array {
7830 $stats = get_option( 'xspeed_stats', array() );
7831 return is_array( $stats ) ? $stats : array();
7832 }
7833
7834 /**
7835 * Persist stats with autoload disabled — stats are only read in admin
7836 * contexts, so there is no reason to inflate every frontend request's
7837 * `wp_load_alloptions()` payload.
7838 *
7839 * MERGES into whatever is already stored. It used to overwrite, which
7840 * was harmless while `last_purge` was the only key — with the GC keys
7841 * alongside it, a purge would have wiped the GC history and vice versa.
7842 */
7843 public static function update_stats( array $stats ) {
7844 if ( false === get_option( 'xspeed_stats', false ) ) {
7845 add_option( 'xspeed_stats', $stats, '', 'no' );
7846 return;
7847 }
7848 update_option( 'xspeed_stats', array_merge( self::get_stats_option(), $stats ) );
7849 }
7850
7851 public static function get_stats() {
7852 $count = 0;
7853 $size = 0;
7854 // This site's entries only — on multisite the tree is shared, so an
7855 // unscoped count reported the whole network's pages on every
7856 // subsite's dashboard. (#6)
7857 $flat_root = XSPEED_CACHE_DIR . '/' . self::current_host_dir();
7858 if ( is_dir( $flat_root ) ) {
7859 $files = glob( $flat_root . '/*.html' );
7860 if ( $files ) {
7861 $count = count( $files );
7862 foreach ( $files as $f ) {
7863 $size += filesize( $f );
7864 }
7865 }
7866 }
7867 // Drain the HIT-log file BEFORE reading totals. Two serve paths that
7868 // bypass the normal in-PHP record_hit() append one line per HIT here:
7869 // the nginx server-level rewrite (see nginx_snippet(), never reaches
7870 // PHP) and the advanced-cache.php drop-in (runs pre-WordPress, can't
7871 // reach Hit_Counter). Without this drain both look like a 0% hit-ratio
7872 // on a perfectly working cache.
7873 Hit_Counter::collect_nginx_log_hits();
7874
7875 // Apache/LiteSpeed static-rewrite HITs are served straight from disk
7876 // by .htaccess and never reach PHP either — but there's no .htaccess
7877 // equivalent of nginx's access_log directive, so we count them by
7878 // scanning the web server's own access log incrementally. No-op when
7879 // the log isn't readable (managed hosts) — see the method docblock.
7880 Hit_Counter::collect_server_log_hits();
7881
7882 $stats = get_option( 'xspeed_stats', array() );
7883 $totals = Hit_Counter::totals_24h();
7884 // One read of the ground truth for both fields below: it costs a
7885 // stat of advanced-cache.php and a tokenize of wp-config.php, and
7886 // this runs on every dashboard poll.
7887 $serving = self::page_cache_operational();
7888 return array(
7889 'cached_pages' => $count,
7890 'cache_size' => $size,
7891 'last_purge' => isset( $stats['last_purge'] ) ? (int) $stats['last_purge'] : 0,
7892 // Rolling 24h cache performance — sourced from Hit_Counter's
7893 // hourly buckets. The frontend uses hit_ratio to drive the
7894 // CacheHero stat grid + the Health module's panel.
7895 'hits_24h' => $totals['hits'],
7896 'misses_24h' => $totals['misses'],
7897 'hit_ratio' => $totals['ratio'],
7898 // Requests kept OUT of the ratio (404s + bots) — surfaced as its own
7899 // "absorbed N scanner/bot requests" line rather than distorting the
7900 // cache-performance number. (#118)
7901 'excluded_24h' => $totals['excluded'],
7902 // True when an edge cache (Cloudflare) fronts the origin, so hits are
7903 // absorbed before reaching PHP. The dashboard labels the ratio
7904 // "origin-layer only" instead of implying it's the full picture. (#118)
7905 'edge_cache' => self::edge_cache_detected(),
7906 // LiteSpeed Static Fast Path (#509): the web server serves hits
7907 // with no PHP, no way to tag them, and no way to count them. The
7908 // dashboard labels the ratio as PHP-layer only so a low number
7909 // reads as the trade the user chose, not a fault.
7910 //
7911 // rewrite_installed() is part of the condition (QA on #513): when
7912 // the .htaccess write failed (read-only file), hits still take
7913 // the drop-in path and ARE counted — the disclosure would be the
7914 // opposite of the truth. Health carries the "block missing"
7915 // warning for that state; this flag only speaks when static
7916 // serving is genuinely in effect.
7917 'static_hits_uncounted' => (
7918 Server::LITESPEED === Server::type()
7919 && ! empty( Settings::get()['cache_enabled'] )
7920 && self::static_rewrite_allowed()
7921 && self::rewrite_installed()
7922 ),
7923 /*
7924 * Whether the page cache is actually SERVING, as opposed to
7925 * switched on in settings. The hero read the setting alone and
7926 * announced "Active — serving cached HTML"; a site whose
7927 * advanced-cache.php had been taken over by another cache plugin
7928 * got that line while every response carried
7929 * `X-XSpeed-Cache: BYPASS`. The setting is the user's intent;
7930 * this is the outcome, and the dashboard needs both to explain
7931 * the difference.
7932 */
7933 'page_cache_serving' => $serving,
7934 /*
7935 * Why not, when intent and outcome disagree. Only computed in
7936 * that state — the detector sweep behind it is far more work than
7937 * a stats call should do on an ordinary healthy site.
7938 */
7939 'page_cache_blocked_reason' => ( ! $serving && ! empty( Settings::get()['cache_enabled'] ) )
7940 ? ( self::acquisition_blocker() ?? self::not_serving_reason() )
7941 : null,
7942 );
7943 }
7944
7945 /**
7946 * Why the cache is not serving, when nothing REFUSES to enable it.
7947 *
7948 * acquisition_blocker() answers "may we take the field", and since a
7949 * foreign drop-in became takeable it answers null on a site where another
7950 * plugin is nonetheless holding that file. Intent and outcome still
7951 * disagree there, and the dashboard was left reporting the symptom -- not
7952 * serving -- with no reason under it, which is exactly the state a user
7953 * cannot act on.
7954 *
7955 * So this names the holder and says what to do: enabling takes it over.
7956 */
7957 private static function not_serving_reason(): ?string {
7958 $owner = self::dropin_owner();
7959 if ( self::DROPIN_FOREIGN !== $owner && self::DROPIN_UNREADABLE !== $owner ) {
7960 return null;
7961 }
7962
7963 if ( self::DROPIN_UNREADABLE === $owner ) {
7964 return __( 'advanced-cache.php cannot be read, so xSpeed cannot tell whose page cache is installed.', 'xspeed' );
7965 }
7966
7967 $label = Page_Cache_Detector::dropin_owner_label();
7968 return $label
7969 ? sprintf(
7970 /* translators: %s: the page-caching plugin that owns advanced-cache.php. */
7971 __( '%s is serving the page cache. Turn the xSpeed cache off and on again to take it over.', 'xspeed' ),
7972 $label
7973 )
7974 : __( 'Another plugin is serving the page cache. Turn the xSpeed cache off and on again to take it over.', 'xspeed' );
7975 }
7976
7977 /**
7978 * Whether the current request should be kept OUT of the cache hit/miss
7979 * ratio: a genuine 404, or a known bot / scanner. Runs at template_redirect
7980 * time, so is_404() is resolved. (#118)
7981 */
7982 private static function miss_is_excluded(): bool {
7983 if ( function_exists( 'is_404' ) && is_404() ) {
7984 return true;
7985 }
7986 // A marked request is ours whatever its UA says: a renamed warmer,
7987 // or a probe that has to send a browser's UA.
7988 if ( Self_Traffic::request_is_marked() ) {
7989 return true;
7990 }
7991 $ua = isset( $_SERVER['HTTP_USER_AGENT'] )
7992 ? sanitize_text_field( wp_unslash( (string) $_SERVER['HTTP_USER_AGENT'] ) )
7993 : '';
7994 return Hit_Counter::is_bot_ua( $ua );
7995 }
7996
7997 /**
7998 * Whether an edge cache fronts this origin, so an unknown share of hits
7999 * is served there and never counted here — which makes the origin ratio a
8000 * partial view the dashboard has to label as such. (#118)
8001 *
8002 * This used to mean "the Cloudflare module is switched on", which answered
8003 * no for every site fronted by anything else, and no for a site on
8004 * Cloudflare that had never opened our Cloudflare panel. Both of those
8005 * sites had their ratio presented as the whole story. Edge_Provider knows
8006 * better and knows it per request, so ask it.
8007 */
8008 private static function edge_cache_detected(): bool {
8009 return Edge_Provider::NONE !== Edge_Provider::detect()['confidence'];
8010 }
8011
8012 /**
8013 * Apply the user's enable/disable choice. Called from the REST toggle
8014 * endpoint, which is gated by current_user_can( 'manage_options' ) and
8015 * a verified REST nonce.
8016 *
8017 * This is the only path that ENABLES caching — a drop-in is never
8018 * created for a user who hasn't opted in, which is the guideline that
8019 * matters (a plugin must not install drop-ins or edit wp-config.php
8020 * on a fresh activation). RESTORING the drop-in for a site that
8021 * already has cache_enabled = true is a different act and is handled
8022 * by restore_dropin_if_enabled() on activation and auto_heal() at
8023 * runtime; without it every plugin update silently un-caches the site.
8024 *
8025 * Enabling is gated on acquisition_blocker(): if another plugin owns the
8026 * drop-in, or WP_CACHE is written in a form we must not rewrite, nothing
8027 * is written and the returned state carries `blocked` + a reason the
8028 * caller can show. Callers must persist `cache_enabled` from the returned
8029 * `enabled`, never from what they asked for.
8030 *
8031 * @param bool $enable User's choice.
8032 * @return array{
8033 * enabled: bool,
8034 * blocked: bool,
8035 * blocked_reason: ?string,
8036 * dropin_installed: bool,
8037 * wp_cache_constant: bool,
8038 * wp_config_writable: bool,
8039 * manual_snippet: ?string
8040 * }
8041 */
8042 public static function toggle( $enable, bool $consented = true ) {
8043 Page_Cache_Detector::invalidate();
8044 $expected = Page_Cache_Detector::inspect()['revision'];
8045 /** Diagnostic seam; changing the expected revision can only force a safe refusal. */
8046 $expected = (string) apply_filters( 'xspeed_page_cache_expected_revision', $expected );
8047 $lock = self::page_cache_lock();
8048 if ( ! is_resource( $lock ) ) {
8049 return self::blocked_toggle_state( __( 'Could not lock page-cache ownership. Try again.', 'xspeed' ) );
8050 }
8051 $changed = false;
8052 try {
8053 Page_Cache_Detector::invalidate();
8054 $fresh = Page_Cache_Detector::inspect()['revision'];
8055 if ( ! hash_equals( (string) $expected, (string) $fresh ) ) {
8056 return self::blocked_toggle_state( __( 'Page-cache ownership changed while xSpeed was checking it. Nothing was changed; try again.', 'xspeed' ) );
8057 }
8058 $before = self::page_cache_fingerprint();
8059 $state = self::toggle_unlocked( (bool) $enable, $consented );
8060 $changed = empty( $state['blocked'] ) && self::page_cache_fingerprint() !== $before;
8061 return $state;
8062 } finally {
8063 flock( $lock, LOCK_UN );
8064 fclose( $lock );
8065
8066 /*
8067 * Announce the change to anything caching in front of us.
8068 *
8069 * Turning the page cache on or off changes what every URL on this
8070 * site returns, and a CDN holding renders made under the old state
8071 * goes on serving them for their whole lifetime. Nothing told it.
8072 * OFF is the less obvious half and matters as much: the edge
8073 * otherwise keeps serving pages from a cache the site no longer
8074 * has.
8075 *
8076 * Only when something actually changed. A refused toggle wrote
8077 * nothing — that is the whole point of the refusal — and purging
8078 * after it would announce a change that did not happen. Nor does a
8079 * toggle that found the cache already in the state it asked for:
8080 * auto_heal() re-enables on every admin_init, and gating on "not
8081 * refused" alone made every wp-admin page load empty the page
8082 * cache and purge the edge (QA, 2026-09-23). The fingerprint is
8083 * compared instead, so restoring a stripped drop-in still counts.
8084 *
8085 * After the writes and OUTSIDE the lock. Before them, a purge
8086 * would repopulate from the state we are in the middle of leaving,
8087 * which is how a "purge didn't work" report is born; inside them,
8088 * it would hold single-occupancy ownership for the length of a
8089 * filesystem sweep.
8090 */
8091 if ( $changed ) {
8092 /*
8093 * On shutdown, not inline. `purge_all()` sweeps the cache
8094 * directory and fires the purge actions, and a listener on
8095 * those can make an HTTP call to an edge — so running it here
8096 * would make the toggle as slow as the sweep and couple its
8097 * response to a third party. The Cloudflare Enterprise purge
8098 * queue already defers for the same reason.
8099 *
8100 * Still after the writes: shutdown runs at the end of THIS
8101 * request, with the new state on disk.
8102 */
8103 add_action(
8104 'shutdown',
8105 static function () {
8106 self::purge_all( 'page cache toggled' );
8107 },
8108 20
8109 );
8110 }
8111 }
8112 }
8113
8114 /** Run the page-cache mutation while toggle() owns the scoped lock. */
8115 /**
8116 * @param bool $consented The user asked for this in the dashboard, so a
8117 * foreign drop-in may be taken over. False on the
8118 * unattended paths, which stand down instead.
8119 */
8120 /**
8121 * What the page cache looks like on disk and in wp-config, as one string.
8122 *
8123 * `toggle()` compares it before and after its writes to tell a real ON/OFF
8124 * flip, or a repaired drop-in, from a call that found everything already
8125 * as asked. Only the latter must not announce a purge.
8126 */
8127 private static function page_cache_fingerprint(): string {
8128 $dropin = self::read_file( WP_CONTENT_DIR . '/advanced-cache.php' );
8129 return md5(
8130 ( null === $dropin ? "\0none" : md5( $dropin ) )
8131 . '|' . self::wp_cache_define_state()
8132 . '|' . ( self::rewrite_installed() ? '1' : '0' )
8133 . '|' . ( self::page_cache_operational() ? '1' : '0' )
8134 );
8135 }
8136
8137 private static function toggle_unlocked( bool $enable, bool $consented = true ) {
8138 $enable = (bool) $enable;
8139
8140 if ( $enable ) {
8141 /*
8142 * Preflight. The drop-in and the WP_CACHE define are shared,
8143 * single-occupancy state; if we do not own them, no part of this
8144 * runs — not the drop-in, not wp-config.php, not the rewrite
8145 * block. Refusing whole is the point: a partial enable leaves the
8146 * site claiming a cache it cannot serve.
8147 *
8148 * Every caller routes through here (REST, onboarding, MCP, CLI,
8149 * the optimize runner, Pro's migration), so the gate lives here
8150 * rather than being re-implemented at each entry point.
8151 *
8152 * Except when there is nothing to acquire. A site where we
8153 * already own the drop-in and are already serving is being asked
8154 * to stay as it is, and the gate answers a different question —
8155 * "is the field free to take" — which a merely ACTIVE competitor
8156 * makes false. So "make sure caching is on", from an AI agent,
8157 * the optimize runner or Pro's migration, came back as a refusal
8158 * telling the user to deactivate a plugin on a site that was
8159 * caching perfectly. The dashboard never saw it, because nobody
8160 * presses Enable on a cache that is already enabled.
8161 *
8162 * Only the GATE is skipped. The writes below still run, and every
8163 * one of them is individually idempotent — which matters, because
8164 * this is the path CacheModule re-bakes the drop-in through when
8165 * an exclusion rule or the TTL changes (#240, #251), and the path
8166 * auto_heal() restores a stripped WP_CACHE through. Returning
8167 * early here left both of those doing nothing at all, silently,
8168 * on exactly the healthy sites this branch is about.
8169 */
8170 $reasserting = self::page_cache_operational() && self::DROPIN_XSPEED === self::dropin_owner();
8171 $blocker = $reasserting ? null : self::acquisition_blocker();
8172
8173 /*
8174 * Taking over another plugin's drop-in needs the user to have
8175 * asked for it. On the dashboard they did -- they clicked the
8176 * switch, having been told whose file it is. The UNATTENDED
8177 * callers have no such click: restore_dropin_if_enabled() runs
8178 * after a plugin update and auto_heal() on an admin page load,
8179 * both from nothing more than `cache_enabled` still being true.
8180 *
8181 * A competitor installed since that flag was set would have its
8182 * page cache seized by a background repair, which is the silent
8183 * acquisition this plugin refuses to perform. So those callers
8184 * pass $consented = false and stand down instead.
8185 */
8186 if ( null === $blocker && ! $consented && self::DROPIN_FOREIGN === self::dropin_owner() ) {
8187 // Name the owner. This string is rendered by host plugins
8188 // through Host::enable_page_cache(), and an unnamed refusal
8189 // is what made every host invent its own explanation.
8190 $owner_label = Page_Cache_Detector::dropin_owner_label();
8191 return self::blocked_toggle_state(
8192 $owner_label
8193 ? sprintf(
8194 /* translators: %s: the page-caching plugin that owns advanced-cache.php. */
8195 __( '%s owns advanced-cache.php, so xSpeed left it alone. Enable the cache from the xSpeed dashboard to take it over.', 'xspeed' ),
8196 $owner_label
8197 )
8198 : __( 'Another plugin owns advanced-cache.php, so xSpeed left it alone. Enable the cache from the xSpeed dashboard to take it over.', 'xspeed' )
8199 );
8200 }
8201 if ( null !== $blocker ) {
8202 Activity_Log::record(
8203 'cache_enable_blocked',
8204 'Cache not enabled — ' . $blocker,
8205 Activity_Log::WARN
8206 );
8207
8208 return self::blocked_toggle_state( $blocker );
8209 }
8210
8211 $dropin_path = WP_CONTENT_DIR . '/advanced-cache.php';
8212 $config_path = self::wp_config_path();
8213 $dropin_before = file_exists( $dropin_path ) ? self::read_file( $dropin_path ) : null;
8214 $config_before = '' !== $config_path ? self::read_file( $config_path ) : null;
8215 $dropin_ok = self::install_dropin();
8216 if ( ! $dropin_ok ) {
8217 $partial = self::read_file( $dropin_path );
8218 if ( is_string( $partial ) && xspeed_has_canonical_dropin_signature( $partial ) ) {
8219 self::rollback_page_cache_artifacts( $dropin_path, $dropin_before, $partial, $config_path, $config_before, null );
8220 }
8221 /*
8222 * Preflight said the field was clear, so this is a filesystem
8223 * failure (or a drop-in that appeared in between). Without the
8224 * drop-in there is no cache to enable, and persisting
8225 * cache_enabled anyway is what produced sites reporting a
8226 * healthy cache while serving every request uncached.
8227 */
8228 $reason = __( 'Could not write wp-content/advanced-cache.php. Check filesystem permissions.', 'xspeed' );
8229 Activity_Log::record(
8230 'cache_enable_blocked',
8231 'Cache not enabled — ' . $reason,
8232 Activity_Log::WARN
8233 );
8234
8235 return array(
8236 'enabled' => false,
8237 'blocked' => true,
8238 'blocked_reason' => $reason,
8239 'dropin_installed' => false,
8240 'wp_cache_constant' => false,
8241 'rewrite_installed' => false,
8242 'wp_config_writable' => self::wp_config_writable(),
8243 'manual_snippet' => null,
8244 'nginx_snippet' => self::nginx_snippet(),
8245 'nginx_server_block' => self::full_nginx_server_block(),
8246 );
8247 }
8248
8249 $dropin_written = self::read_file( $dropin_path );
8250 self::set_wp_cache_constant( true );
8251 $config_written = '' !== $config_path ? self::read_file( $config_path ) : null;
8252 Page_Cache_Detector::invalidate();
8253 $dropin_ours = self::DROPIN_XSPEED === self::dropin_owner();
8254 $constant_state = self::wp_cache_define_state();
8255 $constant_ok = 'true' === $constant_state;
8256
8257 /*
8258 * A wp-config.php we cannot write at all is a supported state, not
8259 * a failed transaction. Plenty of managed hosts ship the file
8260 * read-only; there the drop-in is ours and installed, the cache
8261 * works the moment WP_CACHE exists, and the one line to paste
8262 * comes back as `manual_snippet`. Rolling back instead left those
8263 * hosts unable to turn the page cache on by any route — including
8264 * when the user had already pasted the define, since the write
8265 * fails on an unwritable file whatever value is already there.
8266 *
8267 * `undefined` ONLY. `false` looks eligible — this method would
8268 * have rewritten it — but the snippet we hand back cannot work
8269 * there: the file already says `define( 'WP_CACHE', false )`, the
8270 * first define() call wins, and a user who pastes our line via
8271 * FTP ends up with a cache that never serves AND a `duplicate`
8272 * wp-config that blocks every future toggle in both directions.
8273 * They have to edit the existing line, which means refusing here
8274 * and saying so. `duplicate` and `dynamic` are refused by
8275 * acquisition_blocker() before we get here, and if one appears in
8276 * the race window it must still fail closed.
8277 */
8278 $manual_mode = ! $constant_ok
8279 && 'undefined' === $constant_state
8280 && ! self::can_write_wp_config();
8281
8282 if ( ! $dropin_ours || ( ! $constant_ok && ! $manual_mode ) ) {
8283 if ( ! self::can_write_wp_config() ) {
8284 $reason = 'false' === $constant_state
8285 ? __( "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' )
8286 : __( 'xSpeed could not verify the complete page-cache write, and wp-config.php is not writable. Its changes were rolled back.', 'xspeed' );
8287 } else {
8288 $reason = __( 'xSpeed could not verify the complete page-cache write. Its changes were rolled back.', 'xspeed' );
8289 }
8290 self::rollback_page_cache_artifacts( $dropin_path, $dropin_before, $dropin_written, $config_path, $config_before, $config_written );
8291 return self::blocked_toggle_state( $reason );
8292 }
8293 $wp_config_ok = $constant_ok;
8294 $rewrite_ok = self::install_rewrite();
8295 self::ensure_hits_log_file();
8296 self::sync_mobile_flag();
8297 $snippet = $wp_config_ok ? null : "define( 'WP_CACHE', true );";
8298 Settings::update( array( 'cache_enabled' => true ) );
8299 if ( empty( Settings::get()['cache_enabled'] ) ) {
8300 self::remove_rewrite();
8301 self::rollback_page_cache_artifacts( $dropin_path, $dropin_before, $dropin_written, $config_path, $config_before, $config_written );
8302 delete_option( 'xspeed_page_cache_ownership_receipt' );
8303 return self::blocked_toggle_state( __( 'xSpeed could not save the page-cache setting. Its file changes were rolled back.', 'xspeed' ) );
8304 }
8305
8306 /*
8307 * Only when this call actually changed something. auto_heal() runs
8308 * the enable transaction on every admin_init, and an unconditional
8309 * entry filled the 50-slot log with identical "Cache enabled" lines
8310 * within 50 wp-admin page loads, evicting every real event — plus
8311 * an option write per admin request. The sentence is also false
8312 * when nothing was installed.
8313 */
8314 if ( $dropin_written !== $dropin_before || $config_written !== $config_before ) {
8315 Activity_Log::record(
8316 'cache_enabled_event',
8317 $wp_config_ok
8318 ? 'Cache enabled. Drop-in installed, WP_CACHE constant set.'
8319 : 'Cache enabled. Drop-in installed; wp-config.php not writable — add the WP_CACHE snippet manually.',
8320 $wp_config_ok ? Activity_Log::SUCCESS : Activity_Log::WARN
8321 );
8322 }
8323
8324 return array(
8325 'enabled' => true,
8326 'blocked' => false,
8327 'blocked_reason' => null,
8328 'dropin_installed' => (bool) $dropin_ok,
8329 'wp_cache_constant' => (bool) $wp_config_ok,
8330 'rewrite_installed' => (bool) $rewrite_ok,
8331 'wp_config_writable' => self::wp_config_writable(),
8332 'manual_snippet' => $snippet,
8333 'nginx_snippet' => self::nginx_snippet(),
8334 // Unified server-block snippet aggregating every enabled
8335 // module's directives — the same value the dashboard and
8336 // Health insight render. The wizard shows this so all three
8337 // surfaces stay in lockstep. Null on non-nginx hosts.
8338 'nginx_server_block' => self::full_nginx_server_block(),
8339 );
8340 }
8341
8342 /*
8343 * Whose advanced-cache.php is on disk decides how much of the disable
8344 * below may run. Read it once, before anything is touched.
8345 */
8346 $owner = self::dropin_owner();
8347 $not_ours = self::DROPIN_FOREIGN === $owner || self::DROPIN_UNREADABLE === $owner;
8348 if ( ! self::set_wp_cache_constant( false ) ) {
8349 /*
8350 * The mirror of the enable path. A wp-config.php nobody can write
8351 * does not trap the user in a cache they turned off: WP_CACHE on
8352 * its own does nothing once advanced-cache.php is gone, and core
8353 * simply skips the missing drop-in. Refusing here left the
8354 * read-only managed hosts able to enable the page cache and never
8355 * able to disable it again.
8356 *
8357 * A drop-in that is not ours reaches the same conclusion by a
8358 * different road. WP_CACHE is then the switch for THEIR cache, so
8359 * set_wp_cache_constant() refuses it — correctly, and permanently,
8360 * because nothing the user does to xSpeed will make that file ours
8361 * again. Treating that refusal as a failed disable was a trap with
8362 * no exit: install any competing cache plugin while xSpeed's cache
8363 * was on, and xSpeed's toggle could never be turned off again,
8364 * while the dashboard went on claiming a cache that was serving
8365 * nothing. Turning xSpeed off is entirely within our own state —
8366 * our setting, our rewrite block — so it proceeds, and their
8367 * constant and their file are left exactly as they are.
8368 */
8369 /*
8370 * Every reason set_wp_cache_constant() refuses is structural
8371 * except one, and the exception is the only one worth blocking
8372 * on. It will not touch a constant it cannot prove is ours; it
8373 * will not rewrite a define it cannot read as a literal —
8374 * duplicate, dynamic, or inside a conditional; and it cannot
8375 * write a file the filesystem will not let it write. None of
8376 * those improve on a retry, and all of them leave a WP_CACHE
8377 * that does nothing once our drop-in is gone. What is left — our
8378 * own constant, in a shape we can rewrite, in a file we can
8379 * write, and the write still failed — is a real I/O failure, and
8380 * that one still refuses so the user is not told a cache was
8381 * turned off while it goes on serving.
8382 *
8383 * The proof, not the drop-in, is the test. A user who pasted our
8384 * manual snippet on a locked-down host has a WP_CACHE line with
8385 * no receipt on it; if their drop-in later goes missing, we can
8386 * never prove that line is ours, so refusing left the toggle
8387 * stuck on with no way out but enabling first and disabling
8388 * again. Nothing loads a drop-in that is not there, so the line
8389 * is inert either way and the disable proceeds without it.
8390 */
8391 $leave_it = ! self::wp_cache_define_is_ours_to_remove( $owner )
8392 || ! in_array( self::wp_cache_define_state(), array( 'true', 'false', 'undefined' ), true )
8393 || ! self::can_write_wp_config();
8394 if ( ! $leave_it ) {
8395 return self::blocked_toggle_state( __( 'xSpeed could not safely remove its WP_CACHE setting. The cache remains enabled.', 'xspeed' ) );
8396 }
8397 }
8398 self::remove_dropin();
8399 if ( self::DROPIN_XSPEED === self::dropin_owner() ) {
8400 // Put WP_CACHE back, and say so if we could not. Reporting a
8401 // hardcoded `enabled: true` here claimed a working cache on a
8402 // site whose constant we had just failed to restore.
8403 // Put WP_CACHE back, then read the outcome off disk rather than
8404 // trusting the write's return value — a write can report failure
8405 // for a value that was already correct, and the question the
8406 // caller needs answered is whether the cache serves.
8407 self::set_wp_cache_constant( true );
8408 return self::blocked_toggle_state(
8409 self::page_cache_operational()
8410 ? __( 'xSpeed could not remove its page-cache drop-in. The cache remains enabled.', 'xspeed' )
8411 : __( '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' )
8412 );
8413 }
8414 self::remove_rewrite();
8415 /*
8416 * The .htaccess block serves cached HTML straight off disk without
8417 * ever reaching PHP, so a block we failed to remove keeps answering
8418 * requests from a cache the user just turned off — and nothing else
8419 * in this method can stop it. remove_rewrite() also returns false
8420 * when there is no .htaccess to clean, which is the ordinary case,
8421 * so ask the file rather than trust the return value.
8422 */
8423 if ( self::rewrite_installed() ) {
8424 if ( $not_ours ) {
8425 // Nothing to roll back — under a foreign drop-in this method
8426 // removed no drop-in and wrote no constant, and it could not
8427 // put either back if it wanted to. Say what is actually left.
8428 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' ) );
8429 }
8430 /*
8431 * Roll the disable back. Both calls can fail — a filesystem that
8432 * would not let us remove the block may not let us write the
8433 * drop-in either — and discarding their results reported an
8434 * enabled cache over a site left with no drop-in and no
8435 * constant. Fall through to the default state so the artifact
8436 * fields are read from disk rather than asserted.
8437 */
8438 self::install_dropin();
8439 self::set_wp_cache_constant( true );
8440 // Both of those can fail — a filesystem that would not let us
8441 // remove the block may not let us write the drop-in either — so
8442 // the message follows what is on disk afterwards, not what the
8443 // calls returned.
8444 return self::blocked_toggle_state(
8445 self::page_cache_operational()
8446 ? __( 'xSpeed could not remove its rewrite rules from .htaccess, which would keep serving cached pages. The cache remains enabled.', 'xspeed' )
8447 : __( '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' )
8448 );
8449 }
8450 // Drop the device-bucket marker too — with the drop-in gone there's
8451 // nothing left to read it, and leaving it behind would dirty a fresh
8452 // re-enable (and leaks across test runs).
8453 self::sync_mobile_flag( false );
8454 Settings::update( array( 'cache_enabled' => false ) );
8455 if ( ! empty( Settings::get()['cache_enabled'] ) ) {
8456 if ( $not_ours ) {
8457 // Same as above: there is nothing of ours on disk to restore.
8458 return self::blocked_toggle_state( __( 'xSpeed could not save the disabled state.', 'xspeed' ) );
8459 }
8460 self::install_dropin();
8461 self::set_wp_cache_constant( true );
8462 return self::blocked_toggle_state( __( 'xSpeed could not save the disabled state. The page cache was restored.', 'xspeed' ) );
8463 }
8464
8465 // A WP_CACHE we could not remove because wp-config.php is read-only
8466 // is left behind deliberately (see above) — say so rather than
8467 // reporting a constant that is still in the file as gone.
8468 $constant_left = 'true' === self::wp_cache_define_state();
8469 /*
8470 * Say why the constant is still there, because there are now three
8471 * different reasons and they call for different advice. Keyed off the
8472 * same facts $leave_it was, so the log cannot drift from the decision
8473 * it is describing — it did, briefly, and reported a wp-config.php as
8474 * unwritable when the real reason was that we could not prove the
8475 * line was ours.
8476 */
8477 if ( self::DROPIN_UNREADABLE === $owner ) {
8478 $log_message = 'Cache disabled. advanced-cache.php could not be read, so it and the WP_CACHE setting were left untouched.';
8479 } elseif ( $not_ours ) {
8480 $log_message = 'Cache disabled. Another plugin owns advanced-cache.php, so its drop-in and its WP_CACHE setting were left untouched.';
8481 } elseif ( ! $constant_left ) {
8482 $log_message = 'Cache disabled. Drop-in removed.';
8483 } elseif ( ! self::wp_cache_define_is_ours_to_remove( $owner ) ) {
8484 $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.';
8485 } elseif ( ! self::can_write_wp_config() ) {
8486 $log_message = 'Cache disabled. Drop-in removed; wp-config.php not writable, so WP_CACHE was left in place (harmless without the drop-in).';
8487 } else {
8488 $log_message = 'Cache disabled. Drop-in removed; WP_CACHE was left in place (harmless without the drop-in).';
8489 }
8490 Activity_Log::record(
8491 'cache_disabled_event',
8492 $log_message,
8493 $constant_left ? Activity_Log::WARN : Activity_Log::INFO
8494 );
8495
8496 return array(
8497 'enabled' => false,
8498 'blocked' => false,
8499 'blocked_reason' => null,
8500 'dropin_installed' => false,
8501 'wp_cache_constant' => $constant_left,
8502 'rewrite_installed' => false,
8503 'wp_config_writable' => self::wp_config_writable(),
8504 'manual_snippet' => null,
8505 'nginx_snippet' => self::nginx_snippet(),
8506 'nginx_server_block' => self::full_nginx_server_block(),
8507 );
8508 }
8509
8510 /** Acquire the local lock that serializes page-cache ownership changes. */
8511 private static function page_cache_lock() {
8512 $path = WP_CONTENT_DIR . '/.xspeed-page-cache.lock';
8513 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen,WordPress.PHP.NoSilencedErrors.Discouraged -- flock requires a local handle; failure is a safe blocked result.
8514 $lock = @fopen( $path, 'c+' );
8515 if ( ! is_resource( $lock ) || ! flock( $lock, LOCK_EX ) ) {
8516 return false;
8517 }
8518 return $lock;
8519 }
8520
8521 /**
8522 * Build the stable response shape for a refused transaction.
8523 *
8524 * The artifact fields report what is ON DISK, not zeros. A refusal means
8525 * xSpeed changed nothing — on a site already running our cache that is
8526 * exactly the state where the drop-in and WP_CACHE are both still in
8527 * place and still serving hits. Hardcoding false told the dashboard the
8528 * cache had been dismantled every time a refusal was returned.
8529 */
8530 private static function blocked_toggle_state( string $reason ): array {
8531 /*
8532 * `enabled` answers ONE question: is the page cache operational right
8533 * now. Not what was asked for, and not what the option says.
8534 *
8535 * WordPress loads advanced-cache.php only when WP_CACHE is truthy, so
8536 * those two files together are the whole answer, and reading them is
8537 * the only source that cannot go stale. Both of the alternatives were
8538 * tried here and both produced wrong answers on real paths: a
8539 * hardcoded false told a caller the cache had gone away on a site
8540 * still serving hits, and the persisted setting told a caller the
8541 * cache was healthy after a rollback had just removed the artifacts
8542 * — the option is not written until the end of the transaction, so
8543 * mid-transaction it is stale by construction.
8544 *
8545 * Deliberately not a parameter. Every branch that got to choose its
8546 * own answer eventually chose wrong.
8547 */
8548 return array(
8549 'enabled' => self::page_cache_operational(),
8550 'blocked' => true,
8551 'blocked_reason' => $reason,
8552 'dropin_installed' => self::DROPIN_XSPEED === self::dropin_owner(),
8553 'wp_cache_constant' => 'true' === self::wp_cache_define_state(),
8554 'rewrite_installed' => self::rewrite_installed(),
8555 'wp_config_writable' => self::wp_config_writable(),
8556 'manual_snippet' => null,
8557 'nginx_snippet' => self::nginx_snippet(),
8558 'nginx_server_block' => self::full_nginx_server_block(),
8559 );
8560 }
8561
8562 /**
8563 * The wp-config.php line a user must paste, or null when none is needed.
8564 *
8565 * Non-null only where the drop-in is ours and WP_CACHE is not set to true
8566 * in a file we can write — the read-only managed host. Everywhere else the
8567 * constant is ours to manage and there is nothing to ask for.
8568 */
8569 public static function manual_wp_cache_snippet(): ?string {
8570 if ( self::DROPIN_XSPEED !== self::dropin_owner() ) {
8571 return null;
8572 }
8573 if ( 'true' === self::wp_cache_define_state() ) {
8574 return null;
8575 }
8576 return self::wp_config_writable() ? null : "define( 'WP_CACHE', true );";
8577 }
8578
8579 /**
8580 * Is the page cache serving right now?
8581 *
8582 * Two things decide it, and `WP_CACHE` is not one of them.
8583 *
8584 * xSpeed serves a cached page from `template_redirect` whenever the
8585 * setting is on — see the `HIT (php)` mark on that path, which exists
8586 * precisely for "the drop-in isn't loaded". `advanced-cache.php` and the
8587 * `WP_CACHE` constant that loads it are the FAST path: they answer before
8588 * WordPress boots, which is worth a lot of milliseconds and nothing at
8589 * all to the question of whether pages are being served from cache.
8590 *
8591 * Conflating the two reported a dead cache over a live one. On a managed
8592 * host with an unwritable wp-config.php — the exact case the manual
8593 * snippet exists for — one card said "Your cache works on every request",
8594 * "On, but not serving", "nothing will be cached until you add this line"
8595 * and "hit ratio 67%", all at once, and told the user to edit a file they
8596 * have no permission to write. The released 1.2.1 reported that site as
8597 * active, correctly.
8598 *
8599 * So: the setting, and whether anyone else holds the drop-in. A foreign
8600 * drop-in answers before WordPress loads us, so ours never runs and we
8601 * genuinely are not serving. An unreadable one we must assume the same of.
8602 * Everything else — our drop-in, or none at all — serves.
8603 *
8604 * Public because it is part of the host-plugin contract — see Host. A
8605 * plugin that installed xSpeed needs to be able to say whether the cache
8606 * it asked for is actually serving, and no combination of settings reads
8607 * answers that.
8608 */
8609 public static function page_cache_operational(): bool {
8610 $settings = Settings::get();
8611 if ( empty( $settings['cache_enabled'] ) ) {
8612 return false;
8613 }
8614 $owner = self::dropin_owner();
8615 return self::DROPIN_FOREIGN !== $owner && self::DROPIN_UNREADABLE !== $owner;
8616 }
8617
8618 /** Restore exact snapshots only while disk still matches our own write. */
8619 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 {
8620 // Roll back only files that still carry xSpeed's just-written state.
8621 if ( null !== $dropin_written && hash_equals( $dropin_written, (string) self::read_file( $dropin_path ) ) ) {
8622 if ( null === $dropin_before ) {
8623 wp_delete_file( $dropin_path );
8624 } else {
8625 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- Exact compare-and-swap rollback under the scoped lock.
8626 file_put_contents( $dropin_path, $dropin_before );
8627 }
8628 }
8629 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 ) ) {
8630 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- Exact compare-and-swap rollback under the scoped lock.
8631 file_put_contents( $config_path, $config_before );
8632 }
8633 }
8634
8635 /**
8636 * Check wp-config.php writability via WP_Filesystem. Plugin Check flags
8637 * direct is_writable() under WordPress.WP.AlternativeFunctions.
8638 */
8639 private static function wp_config_writable() {
8640 global $wp_filesystem;
8641 if ( ! function_exists( 'WP_Filesystem' ) ) {
8642 require_once ABSPATH . 'wp-admin/includes/file.php';
8643 }
8644 WP_Filesystem();
8645
8646 return $wp_filesystem ? (bool) $wp_filesystem->is_writable( ABSPATH . 'wp-config.php' ) : false;
8647 }
8648
8649 /**
8650 * Nginx server-block snippet mirroring the Apache rewrite block.
8651 * We never auto-write nginx config — it sits outside the WordPress
8652 * root and is owned by the server admin — but the dashboard
8653 * surfaces this snippet when nginx is detected so the admin can
8654 * paste it once and unlock the same PHP-bypass speedup we get on
8655 * Apache / LiteSpeed via .htaccess.
8656 *
8657 * Returns null when the server isn't nginx (no point showing it).
8658 */
8659 /**
8660 * Create wp-content/cache/xspeed/hits.log as an empty file so the
8661 * server-level rewrite's `access_log` directive has somewhere to
8662 * write on first request. Idempotent — touches an existing file
8663 * without disturbing accumulated lines. Called from Cache::toggle()
8664 * on enable and from auto_heal() when the file is missing.
8665 *
8666 * Permissions matter here. The file is created by PHP-FPM (often uid
8667 * www-data), but the nginx process that appends HIT lines may run as a
8668 * DIFFERENT uid — on multi-container hosts (e.g. xclude/Kinsta: nginx in
8669 * its own container as uid `nginx`, PHP-FPM in another as `www-data`)
8670 * they don't share a user at all. A default-umask 0644 file is then
8671 * unwritable by nginx, the access_log write silently fails, and the
8672 * dashboard shows a 0% hit ratio even though static HITs are serving.
8673 * So we widen the dir to 0777 and the file to 0666 — group/other write —
8674 * so whatever uid nginx runs as can append. The file holds HIT request
8675 * lines and must be protected like an access log: paths and queries can
8676 * contain sensitive values.
8677 */
8678 /**
8679 * Directory holding the nginx hit log. Lives under uploads/, NOT the
8680 * cache dir — uninstall.php and a cache purge both delete the cache
8681 * dir, which would orphan the pasted nginx `access_log` directive's
8682 * parent directory and make `nginx -t` fail [emerg], taking down every
8683 * vhost on the host (FBS-82478). uploads/ always exists, isn't a
8684 * plugin-managed cache dir, and is never deleted on uninstall — so the
8685 * directive's target dir survives both, and nginx (which creates a
8686 * missing log FILE but not a missing DIR) can always open it.
8687 *
8688 * Falls back to the cache dir only if uploads is somehow unavailable.
8689 */
8690 public static function hits_log_dir(): string {
8691 if ( function_exists( 'wp_upload_dir' ) ) {
8692 // One drop-in serves the whole network, so its hit log has one
8693 // home: the main site's uploads. Resolved per blog, the path
8694 // baked into the drop-in changed with whichever blog's admin
8695 // last ran auto_heal(), and each rewrite read as a page-cache
8696 // change and purged the site and the edge (QA, 2026-09-23).
8697 // A subsite's uploads are `<main uploads>/sites/<id>`, so the
8698 // suffix comes off rather than switching blogs to ask.
8699 $uploads = wp_upload_dir( null, false );
8700 if ( is_array( $uploads ) && empty( $uploads['error'] ) && ! empty( $uploads['basedir'] ) ) {
8701 $base = rtrim( (string) $uploads['basedir'], '/' );
8702 if ( function_exists( 'is_multisite' ) && is_multisite() ) {
8703 $base = (string) preg_replace( '#/sites/\d+$#', '', $base );
8704 }
8705 return $base . '/xspeed';
8706 }
8707 }
8708 return XSPEED_CACHE_DIR;
8709 }
8710
8711 /** Absolute path to the nginx hit log file. */
8712 public static function hits_log_path(): string {
8713 return self::hits_log_dir() . '/hits.log';
8714 }
8715
8716 /**
8717 * Sync the drop-in's mobile-bucket flag file with the `mobile_separate`
8718 * setting. The drop-in (advanced-cache.php) runs before WordPress loads,
8719 * so it can't read the option — instead it checks for a zero-byte
8720 * `.mobile-separate` marker next to the cache files. When the setting is
8721 * on we touch the marker; when off we remove it. The drop-in's cache_key
8722 * computation keys off the marker's presence so its '|m'/'|d' device
8723 * bucket stays in lockstep with Cache::cache_key().
8724 *
8725 * Without this, turning on mobile_separate made Cache::store() write keys
8726 * with a '|d'/'|m' suffix the drop-in never reproduced — so the drop-in's
8727 * file_exists() always missed, every HIT fell through to a full WP boot,
8728 * and the fast pre-WP path was silently dead.
8729 *
8730 * @param bool|null $enabled Force a state; null reads the current setting.
8731 */
8732 /**
8733 * Write the subdirectory-multisite path list the drop-in needs to work
8734 * out which blog a request belongs to.
8735 *
8736 * The drop-in runs before WordPress, so it cannot call is_multisite()
8737 * or get_blog_details(). It can only see REQUEST_URI — so we persist the
8738 * network's blog paths (one per line, longest first) next to the cache
8739 * files, exactly as sync_mobile_flag() persists the device flag. The
8740 * drop-in prefix-matches the URI against that list to pick the same
8741 * bucket Cache::current_host_dir() picks. (#6)
8742 *
8743 * No file is written for a single site or a subdomain network — there
8744 * the host alone identifies the blog and the bucket carries no prefix.
8745 */
8746 public static function sync_site_paths(): void {
8747 $file = XSPEED_CACHE_DIR . '/.site-paths';
8748
8749 $needed = function_exists( 'is_multisite' ) && is_multisite()
8750 && ( ! function_exists( 'is_subdomain_install' ) || ! is_subdomain_install() );
8751
8752 if ( ! $needed ) {
8753 if ( file_exists( $file ) ) {
8754 // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal.
8755 @unlink( $file );
8756 }
8757 return;
8758 }
8759
8760 if ( ! function_exists( 'get_sites' ) ) {
8761 return;
8762 }
8763
8764 $paths = array();
8765 foreach ( get_sites( array( 'number' => 0 ) ) as $site ) {
8766 $prefix = self::path_prefix_segment( (string) $site->path );
8767 if ( '' !== $prefix ) {
8768 // Store the raw path so the drop-in can prefix-match a URI,
8769 // alongside the segment it maps to.
8770 $paths[ trim( (string) $site->path, '/' ) ] = $prefix;
8771 }
8772 }
8773
8774 if ( empty( $paths ) ) {
8775 if ( file_exists( $file ) ) {
8776 // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- see above.
8777 @unlink( $file );
8778 }
8779 return;
8780 }
8781
8782 // Longest path first so /a/b wins over /a.
8783 uksort(
8784 $paths,
8785 static function ( $x, $y ) {
8786 return strlen( (string) $y ) <=> strlen( (string) $x );
8787 }
8788 );
8789
8790 $lines = array();
8791 foreach ( $paths as $raw => $segment ) {
8792 $lines[] = $raw . '|' . $segment;
8793 }
8794
8795 if ( ! is_dir( XSPEED_CACHE_DIR ) && ! wp_mkdir_p( XSPEED_CACHE_DIR ) ) {
8796 return;
8797 }
8798 // 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.
8799 file_put_contents( $file, implode( "\n", $lines ), LOCK_EX );
8800 }
8801
8802 /**
8803 * Compile `ignored_query_params` into a regex the DROP-IN can use.
8804 *
8805 * Tracking traffic was cached but never served fast. should_cache()
8806 * learned to allow `?utm_source=…` through and cache_key() strips the
8807 * query, so `/post` and `/post?utm_source=x` share one entry — but the
8808 * drop-in still bailed on ANY query string, so every visitor from an
8809 * email or ad campaign paid a full WordPress boot to be handed a file
8810 * that was already on disk. On a marketing site that is most of the
8811 * paid traffic taking the slowest path. (#13)
8812 *
8813 * The drop-in runs before WordPress, so it cannot read the option or
8814 * call Glob_Matcher. It gets a precompiled alternation instead, written
8815 * next to the cache files exactly as sync_mobile_flag() writes the
8816 * device flag. Regenerated whenever cache settings are saved.
8817 *
8818 * Only the KEYS matter: a param whose name is on the list contributes
8819 * nothing to the response, so the entry keyed without it is correct.
8820 * Anything not on the list means the drop-in must stand down and let
8821 * PHP decide — the file is deleted rather than left stale when the
8822 * list is empty, so a missing sidecar always fails safe.
8823 */
8824 public static function sync_query_allowlist(): void {
8825 $file = XSPEED_CACHE_DIR . '/.ignored-query-params';
8826
8827 /*
8828 * Stored read, not Settings_Manager::get() — this runs from boot(),
8829 * before translation is legal (see stored_cache_opts()).
8830 *
8831 * A raw read applies no schema defaults, and this field's default is a
8832 * long tracking-parameter list, NOT empty. Falling back to array()
8833 * would strip that whole allow-list from the drop-in on any install
8834 * that has never saved the Cache panel. So fall back to the schema's
8835 * own default, read from the module without building its labels.
8836 */
8837 $opts = self::stored_cache_opts();
8838 $ignored = is_array( $opts['ignored_query_params'] ?? null )
8839 ? $opts['ignored_query_params']
8840 : \XSpeed\Modules\Cache\CacheModule::default_ignored_query_params();
8841
8842 $parts = array();
8843 foreach ( $ignored as $pattern ) {
8844 $pattern = trim( (string) $pattern );
8845 if ( '' === $pattern ) {
8846 continue;
8847 }
8848 if ( '~' === $pattern[0] ) {
8849 // Raw regex, PHP-side dialect. Keep it — unlike a server
8850 // config, the drop-in runs the same PCRE engine, so the
8851 // pattern behaves identically. Anchored below with the rest.
8852 $body = substr( $pattern, 1 );
8853 if ( '' !== $body && false !== @preg_match( '#^(?:' . $body . ')$#', '' ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a malformed user pattern must be dropped, not fatal.
8854 $parts[] = $body;
8855 }
8856 continue;
8857 }
8858 // Glob semantics, same as Glob_Matcher: * is any run, ? is one.
8859 $esc = preg_quote( $pattern, '#' );
8860 $esc = str_replace( array( '\*', '\?' ), array( '.*', '.' ), $esc );
8861 $parts[] = $esc;
8862 }
8863
8864 if ( empty( $parts ) ) {
8865 if ( file_exists( $file ) ) {
8866 // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal.
8867 @unlink( $file );
8868 }
8869 return;
8870 }
8871
8872 if ( ! is_dir( XSPEED_CACHE_DIR ) && ! wp_mkdir_p( XSPEED_CACHE_DIR ) ) {
8873 return;
8874 }
8875
8876 // The drop-in anchors this as `^…$`, so the lookahead refuses the
8877 // never-ignored names whole, whatever entry would have matched them.
8878 $never = implode( '|', array_map( static fn ( $p ) => preg_quote( $p, '#' ), self::NEVER_IGNORED_QUERY_PARAMS ) );
8879 $payload = '(?!(?:' . $never . ')$)(?:' . implode( '|', array_unique( $parts ) ) . ')';
8880
8881 // Only write when the value actually changed. This runs from
8882 // reconcile_mobile_separate() on CacheModule::boot(), so an
8883 // unconditional write cost a file write and an exclusive lock on every
8884 // request that boots WordPress — every MISS, every BYPASS, every admin
8885 // screen, every REST call. sync_mobile_flag() below is the model: it
8886 // touches the marker only when the setting flips.
8887 // 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.
8888 if ( is_readable( $file ) && (string) @file_get_contents( $file ) === $payload ) {
8889 return;
8890 }
8891
8892 // 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.
8893 file_put_contents( $file, $payload, LOCK_EX );
8894 }
8895
8896 /**
8897 * CacheModule's STORED settings, read straight from the option.
8898 *
8899 * `Settings_Manager::get( 'cache' )` builds CacheModule's settings schema,
8900 * whose labels are declared through `__()`. The reconcile chain below runs
8901 * from `CacheModule::boot()` on `plugins_loaded` — before
8902 * `after_setup_theme`, the point WordPress 6.7+ treats as safe to
8903 * translate — so going through the schema there fires
8904 * `_load_textdomain_just_in_time` on every request AND resolves the labels
8905 * against a domain that is not loaded yet.
8906 *
8907 * The callers here need stored values, not schema metadata, so a raw read
8908 * is equivalent. It applies NO defaults or coercion: read each key with a
8909 * fallback matching the schema's own default.
8910 *
8911 * @return array<string,mixed>
8912 */
8913 private static function stored_cache_opts(): array {
8914 $stored = get_option( Settings_Manager::OPTION_PREFIX . 'cache', array() );
8915 return is_array( $stored ) ? $stored : array();
8916 }
8917
8918 /**
8919 * Strict truthiness for the LiteSpeed Static Fast Path opt-in.
8920 *
8921 * On non-LiteSpeed servers the key is out of the schema and carried by
8922 * preserved_keys(), so a REST/MCP write lands VERBATIM — QA on #513
8923 * stored the string "false" on Apache and the fast path installed
8924 * itself the moment the site moved to LiteSpeed, because
8925 * empty("false") is false. Only an explicit, unambiguous "yes" may
8926 * enable a path that trades away hit tagging; any other value —
8927 * "false", "no", arbitrary junk — stays OFF, which is the default the
8928 * user never left.
8929 */
8930 private static function litespeed_optin_enabled( $value ): bool {
8931 if ( true === $value || 1 === $value ) {
8932 return true;
8933 }
8934 return is_string( $value )
8935 && in_array( strtolower( trim( $value ) ), array( '1', 'true', 'on', 'yes' ), true );
8936 }
8937
8938 public static function sync_mobile_flag( $enabled = null ): void {
8939 if ( null === $enabled ) {
8940 $stored = self::stored_cache_opts();
8941 $enabled = ! empty( $stored['mobile_separate'] );
8942 }
8943 $dir = XSPEED_CACHE_DIR;
8944 $flag = $dir . '/.mobile-separate';
8945 if ( $enabled ) {
8946 if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
8947 return;
8948 }
8949 if ( ! file_exists( $flag ) ) {
8950 // 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.
8951 @touch( $flag );
8952 }
8953 return;
8954 }
8955 if ( file_exists( $flag ) ) {
8956 // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal.
8957 @unlink( $flag );
8958 }
8959 }
8960
8961 /**
8962 * Write / remove the `.maintenance-active` sentinel next to the cache
8963 * files. The pre-WP drop-in checks for this marker and bails when present,
8964 * so a page cached while the site was live is NOT served during
8965 * maintenance / coming-soon mode — WordPress loads and renders the
8966 * maintenance screen instead. The Pro Maintenance-Cache module drives this
8967 * on the maintenance on/off transition. (FBS-82409 B1)
8968 *
8969 * @param bool $active True to arm the sentinel (entering maintenance),
8970 * false to clear it (site recovered).
8971 */
8972 public static function sync_maintenance_flag( bool $active ): void {
8973 $dir = XSPEED_CACHE_DIR;
8974 $flag = $dir . '/.maintenance-active';
8975 if ( $active ) {
8976 if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
8977 return;
8978 }
8979 if ( ! file_exists( $flag ) ) {
8980 // 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.
8981 @touch( $flag );
8982 }
8983 return;
8984 }
8985 if ( file_exists( $flag ) ) {
8986 // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal.
8987 @unlink( $flag );
8988 }
8989 }
8990
8991 /**
8992 * Reconcile every mobile_separate-dependent artifact to the current
8993 * setting. Called on boot and whenever the cache settings are saved, so
8994 * flipping mobile_separate at runtime can't leave the install in a
8995 * half-converted state.
8996 *
8997 * Three things must agree with the setting:
8998 * 1. the drop-in's `.mobile-separate` flag (sync_mobile_flag()),
8999 * 2. the device-blind server rewrite — present only when OFF
9000 * (static_rewrite_allowed()),
9001 * 3. the now-stale static-cache tree + page cache, which were keyed
9002 * under the old scheme and would serve wrong-device HTML.
9003 *
9004 * No-ops when the cache is disabled — there's nothing installed to
9005 * reconcile, and toggle() handles install/teardown itself.
9006 */
9007 public static function reconcile_mobile_separate(): void {
9008 self::sync_mobile_flag();
9009 if ( defined( 'XSPEED_CACHE_DIR' ) ) {
9010 // Keep the drop-in's view of the network's blog paths current — a
9011 // site added or removed changes which bucket its URLs belong to. (#6)
9012 self::sync_site_paths();
9013 // Keep the drop-in's copy of the query allow-list current — a param
9014 // added in settings must reach the fast path too. (#13)
9015 self::sync_query_allowlist();
9016 }
9017
9018 // The rewrite/static reconciliation below needs the plugin's path
9019 // constants. They're absent in early-boot / unit-test contexts where
9020 // only the drop-in flag matters — bail to the flag-only behavior then.
9021 if ( ! defined( 'XSPEED_CACHE_STATIC_DIR' ) ) {
9022 return;
9023 }
9024
9025 // Only touch the rewrite + caches when caching is actually on.
9026 $opts = get_option( 'xspeed_options', array() );
9027 if ( empty( $opts['cache_enabled'] ) ) {
9028 return;
9029 }
9030
9031 $rewrite_present = self::rewrite_installed();
9032 $rewrite_wanted = self::static_rewrite_allowed();
9033
9034 // Did the thing that actually invalidates cache KEYS change?
9035 // mobile_separate buckets entries as |d / |m, so flipping it makes
9036 // stored entries mis-bucketed and they must go. A rewrite-state
9037 // mismatch from anything else (e.g. mod_headers detection, a hand-
9038 // edited .htaccess) changes no key at all — the same files are still
9039 // valid, they're just served by PHP instead of by the web server.
9040 // Purging there is what let one WP-CLI call wipe the whole cache on
9041 // every bootstrap. (#138)
9042 //
9043 // Read the setting from the SAME place static_rewrite_allowed() and
9044 // sync_mobile_flag() do — the cache module's settings, not the
9045 // top-level xspeed_options — or this marker would track a key that
9046 // never changes and a real flip would go unnoticed.
9047 // Stored read — this runs from boot(); see stored_cache_opts().
9048 $cache_opts = self::stored_cache_opts();
9049 $mobile_now = ! empty( $cache_opts['mobile_separate'] );
9050 $mobile_last = get_option( 'xspeed_last_mobile_separate', null );
9051 $mobile_flipped = ( null !== $mobile_last && (bool) (int) $mobile_last !== $mobile_now );
9052
9053 if ( (string) (int) $mobile_now !== (string) $mobile_last ) {
9054 update_option( 'xspeed_last_mobile_separate', $mobile_now ? '1' : '0', false );
9055 }
9056
9057 if ( $rewrite_present === $rewrite_wanted ) {
9058 // Already consistent — nothing flipped, leave caches intact so a
9059 // plain settings save (e.g. expiry change) doesn't blow the cache.
9060 return;
9061 }
9062
9063 // Bring the rewrite into line with what this server actually supports.
9064 if ( $rewrite_wanted ) {
9065 self::install_rewrite();
9066 } else {
9067 self::remove_rewrite();
9068 }
9069
9070 // Only discard cache contents when the device bucketing changed.
9071 if ( $mobile_flipped ) {
9072 self::purge_all( 'mobile_separate changed' );
9073 }
9074 }
9075
9076 /**
9077 * Whether the server-level static-rewrite fast path may be used.
9078 *
9079 * The rewrite serves `{host}{path}/index.html` straight from the web
9080 * server, keyed only by host + path — it has no way to run our PHP
9081 * device detection, so it can't tell mobile from desktop. When
9082 * `mobile_separate` is on, a single static file would be shared across
9083 * devices and whoever primed it wins (mobile visitors could get desktop
9084 * HTML, or vice-versa). Rather than duplicate a wp_is_mobile()-equivalent
9085 * UA matcher into .htaccess AND the nginx snippet (three copies that
9086 * would inevitably drift), we simply DON'T engage the static rewrite when
9087 * mobile_separate is on. Requests then fall through to the PHP drop-in,
9088 * which buckets correctly — a small TTFB cost (~85ms vs ~30ms) paid only
9089 * on mobile-separate sites, in exchange for guaranteed correctness.
9090 *
9091 * LiteSpeed exclusion (2026-06-16): on LiteSpeed — OpenLiteSpeed in
9092 * particular — `.htaccess` CAN run our RewriteRule to serve the static
9093 * file, but its `.htaccess` engine ignores `mod_headers`, so we cannot
9094 * stamp the served response with `X-XSpeed-Cache: HIT`, AND there is no
9095 * `.htaccess` equivalent of nginx's per-location `access_log` to record
9096 * the hit. The result was a cache that worked but was invisible: no HIT
9097 * header and a hit-ratio frozen near 0%. Every OTHER server gives the
9098 * user a visible HIT header + a counted hit (nginx via add_header +
9099 * access_log in its snippet; Apache via the `<IfModule mod_headers.c>`
9100 * block in rewrite_block_lines(), WHEN that module is loaded — when it is
9101 * not, Apache takes this same drop-in fallback). To keep LiteSpeed
9102 * CONSISTENT with the rest, we route its hits
9103 * through the PHP drop-in instead — the drop-in emits
9104 * `X-XSpeed-Cache: HIT (php)` and calls Hit_Counter inline, exactly the
9105 * observable behavior the other servers get. The cost is the drop-in's
9106 * ~30ms TTFB vs the static path's ~10ms, paid only on LiteSpeed; in
9107 * exchange the dashboard hit-ratio and the response header finally tell
9108 * the truth there. (Apache keeps the static fast path — it honors the
9109 * header.) See maybe_emit_lscache_headers() for the paired LSCache
9110 * stand-down that stops LiteSpeed's own module from shadowing the
9111 * drop-in.
9112 *
9113 * Opt-in (#509): `litespeed_static_rewrite` re-enables the fast path on
9114 * LiteSpeed for users who value raw TTFB over hit accounting. The trade
9115 * is stated in the setting's copy: statically served hits carry no
9116 * X-XSpeed-Cache header and are not counted (LiteSpeed logs the
9117 * original request line, so even the access-log scan cannot see
9118 * them — see Hit_Counter::collect_server_log_hits()). The drop-in
9119 * default above stays — nobody is surprised into an unverifiable cache.
9120 */
9121 public static function static_rewrite_allowed(): bool {
9122 // Stored read — reached from boot(); see stored_cache_opts().
9123 $opts = self::stored_cache_opts();
9124 // LiteSpeed: drop-in serves hits (visible + counted) unless the user
9125 // explicitly opted into the static fast path — see docblock.
9126 if ( Server::LITESPEED === Server::type()
9127 && ! self::litespeed_optin_enabled( $opts['litespeed_static_rewrite'] ?? false ) ) {
9128 return false;
9129 }
9130 // Apache without mod_headers is in EXACTLY the position LiteSpeed
9131 // is in above: it can run the RewriteRule and serve the static
9132 // file, but it cannot stamp `X-XSpeed-Cache` on the response, so
9133 // the hit is invisible to the user and uncountable by
9134 // Hit_Counter. The docblock above used to assert Apache "honors
9135 // mod_headers" and left it on the fast path unconditionally —
9136 // true only when the module is actually loaded. Fall back to the
9137 // drop-in when it isn't, trading ~10ms of TTFB for a hit that
9138 // shows up in the header and the ratio. (Field report: hit ratio
9139 // pinned at 0% on a working Apache cache.)
9140 if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) {
9141 return false;
9142 }
9143 return empty( $opts['mobile_separate'] );
9144 }
9145
9146 /**
9147 * Why the device-blind static rewrite is NOT installed, when it isn't.
9148 * Returns 'mobile_separate' when Separate Mobile Cache is the blocker
9149 * (the static file is one-per-URL, so it can't coexist with per-device
9150 * buckets), 'no_mod_headers' when Apache can't stamp the HIT header,
9151 * '' otherwise. Lets the dashboard explain the slow path instead of
9152 * silently falling back to PHP serving. (FBS-83145)
9153 *
9154 * Every refusal in static_rewrite_allowed() that is NOT self-explanatory
9155 * must have a branch here. Otherwise the Health card falls through to
9156 * "Block missing — toggle Enable Cache off and on to reinstall it",
9157 * advice that cannot work: the same condition that suppressed the write
9158 * suppresses the reinstall, and auto_heal() strips the block again on
9159 * the next admin page load. (Field report: Apache host with mod_headers
9160 * unloaded sat on the slow path with no way to find out why.)
9161 */
9162 /**
9163 * Qualify a raw probe result with what we already KNOW about config.
9164 *
9165 * probe_static_rewrite() writes its own file under the static-cache tree
9166 * and fetches that, which succeeds whenever the web server can serve a
9167 * static file at all — including when static_rewrite_allowed() is false
9168 * and no real page is on the static path. So `active: true` on its own is
9169 * not evidence that pages are being served statically.
9170 *
9171 * The reachable case is nginx with Separate Mobile Cache on: the snippet
9172 * lives in the server block and we cannot remove it, pages are
9173 * deliberately routed to the PHP drop-in, but the probe file is still
9174 * served directly.
9175 *
9176 * The Health panel learned this in 88b4b50; the CLI, REST and MCP paths
9177 * did not, so they kept reporting "active" in exactly that configuration.
9178 * Rather than repeat the reasoning at each call site, they now all come
9179 * through here.
9180 *
9181 * Deliberately does NOT consult rewrite_installed(): on nginx the fast
9182 * path is the pasted snippet and there is no .htaccess marker to find, so
9183 * requiring one would report every correctly-configured nginx site as
9184 * broken.
9185 *
9186 * Carries `rules` through as well. The dashboard bootstrap builds its own
9187 * payload and so had it; every other caller — POST /cache/recheck-rewrite,
9188 * `wp xspeed cache recheck-rewrite`, the recheck_rewrite_rules MCP tool —
9189 * came through here and got four keys, so the one surface a user reaches
9190 * AFTER pasting the block could not tell them whether the block took. An
9191 * agent driving the same fix could not read it at all.
9192 *
9193 * @param array $probe Raw result from probe_static_rewrite().
9194 * @return array{active:bool,inconclusive:bool,reason:string,block_reason:string,rules:array}
9195 */
9196 public static function qualify_rewrite_probe( array $probe ): array {
9197 $active = (bool) ( $probe['active'] ?? false );
9198 $inconclusive = (bool) ( $probe['inconclusive'] ?? false );
9199 $reason = (string) ( $probe['reason'] ?? '' );
9200 $block_reason = self::static_rewrite_block_reason();
9201 $rules = self::rules_state( $probe );
9202
9203 // Same observed-refusal check Health makes. This is the shared path for
9204 // `wp xspeed cache recheck-rewrite` and POST /cache/recheck-rewrite —
9205 // and, because a CLI command is automatically an MCP tool, for the
9206 // AI-facing surface too. Leaving it out would have fixed the dashboard
9207 // while the CLI kept answering that the fast path was active. (#372)
9208 if ( '' === $block_reason ) {
9209 $skip = self::last_static_skip();
9210 if ( ! empty( $skip['reason'] ) ) {
9211 $block_reason = 'skipped_' . (string) $skip['reason'];
9212 }
9213 }
9214
9215 // With page caching off there is nothing to serve, so `active` can
9216 // never be true here whatever the raw probe says. probe_static_rewrite()
9217 // writes its OWN file under the static tree and fetches that, which
9218 // succeeds whenever the server can serve a static file at all — and on
9219 // nginx the snippet is server-level, so it keeps succeeding after the
9220 // cache is switched off.
9221 //
9222 // block_reason() used to carry this meaning by accident: it returned
9223 // 'mobile_separate' with caching off, and the refusal branch below
9224 // forced active=false. Now that it correctly reports '' (nothing can
9225 // block a fast path that isn't in use), this consumer has to state the
9226 // condition itself — otherwise `wp xspeed cache recheck-rewrite` and
9227 // POST /cache/recheck-rewrite claim "the web server is serving cache
9228 // hits directly" on a site with no cache. That is a positive false
9229 // claim rather than a nag, i.e. worse than the bug being fixed.
9230 $cache_opts = Settings::get();
9231 if ( empty( $cache_opts['cache_enabled'] ) ) {
9232 return array(
9233 'active' => false,
9234 'inconclusive' => false,
9235 'reason' => 'Page caching is off, so there is no cache for the web server to serve.',
9236 'block_reason' => '',
9237 'rules' => $rules,
9238 );
9239 }
9240
9241 // A known refusal outranks the probe, and also outranks
9242 // "inconclusive" — a blocked rewrite whose probe merely failed to
9243 // complete is still definitely blocked.
9244 if ( '' !== $block_reason ) {
9245 $active = false;
9246 $inconclusive = false;
9247 $reason = self::block_reason_text( $block_reason );
9248 }
9249
9250 return array(
9251 'active' => $active,
9252 'inconclusive' => $inconclusive,
9253 'reason' => $reason,
9254 'block_reason' => $block_reason,
9255 'rules' => $rules,
9256 );
9257 }
9258
9259 /**
9260 * Human-readable explanation for a static_rewrite_block_reason() code.
9261 *
9262 * Each one has to say what to DO about it: "mobile_separate" alone tells
9263 * a user nothing, and the whole point of surfacing a refusal instead of
9264 * the probe verdict is that it is actionable.
9265 */
9266 public static function block_reason_text( string $code ): string {
9267 switch ( $code ) {
9268 case 'mobile_separate':
9269 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.';
9270 case 'no_mod_headers':
9271 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.";
9272 case 'litespeed_dropin':
9273 return 'On LiteSpeed, cache hits are served by the PHP drop-in so every hit is tagged X-XSpeed-Cache and counted in the hit ratio — LiteSpeed\'s .htaccess engine cannot do either for statically served files. If raw TTFB matters more to you than hit accounting, turn on LiteSpeed Static Fast Path in Cache settings to serve hits straight from the web server.';
9274 case 'skipped_nonce':
9275 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.';
9276 default:
9277 return sprintf( 'The static rewrite is disabled (%s).', $code );
9278 }
9279 }
9280
9281 public static function static_rewrite_block_reason(): string {
9282 // Nothing can be blocking the fast path when there is no cache to
9283 // serve from it. Without this the dashboard told users with page
9284 // caching switched OFF that Separate Mobile Cache "is disabling
9285 // faster static serving" — a fast path they were not using, about a
9286 // cache that did not exist. Every caller of this is a user-facing
9287 // explanation of why the rewrite is off, so "the cache is off" is
9288 // the honest answer, and it is silence. (#108)
9289 $opts = Settings::get();
9290 if ( empty( $opts['cache_enabled'] ) ) {
9291 return '';
9292 }
9293 if ( Server::LITESPEED === Server::type() ) {
9294 // The opt-in is read RAW (stored_cache_opts), not through
9295 // Settings_Manager::get(): the schema's bool coercion is a PHP
9296 // cast, and (bool) "false" is true — so a junk string stored on
9297 // another server (where the key bypasses the schema) would come
9298 // back from the coercion layer as an ENABLE. Raw + the strict
9299 // parse below is the same read static_rewrite_allowed() makes,
9300 // so the two can't disagree either. (QA on #513)
9301 $stored = self::stored_cache_opts();
9302 // The intended default — but no longer silent: with the opt-in
9303 // off, Health must be able to explain the PHP path and point at
9304 // the toggle instead of falling through to "reinstall the block"
9305 // advice that cannot work here. (#509)
9306 if ( ! self::litespeed_optin_enabled( $stored['litespeed_static_rewrite'] ?? false ) ) {
9307 return 'litespeed_dropin';
9308 }
9309 $cache_opts = Settings_Manager::get( 'cache' );
9310 return ! empty( $cache_opts['mobile_separate'] ) ? 'mobile_separate' : '';
9311 }
9312 if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) {
9313 return 'no_mod_headers';
9314 }
9315 $cache_opts = Settings_Manager::get( 'cache' );
9316 return ! empty( $cache_opts['mobile_separate'] ) ? 'mobile_separate' : '';
9317 }
9318
9319 /**
9320 * Whether migration flagged Separate Mobile Cache for user review. Set by
9321 * Migration::map_mobile_separate() when a source plugin (WP Rocket / WP
9322 * Super Cache / LiteSpeed) had its "separate mobile cache" option on: we
9323 * import it as OFF (to keep the device-blind static fast path) but record
9324 * this flag so the dashboard can invite the user to turn it back on only
9325 * if their site genuinely serves different HTML per device. (FBS-83145)
9326 */
9327 public static function mobile_separate_needs_review(): bool {
9328 // Same reasoning as static_rewrite_block_reason(): the invitation is
9329 // "turn this back on if your site needs it, to regain the fast path",
9330 // which is meaningless with page caching off — there is no fast path
9331 // to regain, and the equality probe behind the prompt would fetch
9332 // pages that aren't being cached. Gated here rather than at the two
9333 // payload call sites (Admin + Rest_Api) so `enabled`, `blocking` and
9334 // `needs_review` are consistently gated on the same condition. (#108)
9335 $opts = Settings::get();
9336 if ( empty( $opts['cache_enabled'] ) ) {
9337 return false;
9338 }
9339 $cache_opts = Settings_Manager::get( 'cache' );
9340 return ! empty( $cache_opts['mobile_separate_review'] );
9341 }
9342
9343 /**
9344 * Clear the review flag — called when the user has acted on the prompt
9345 * (dismissed it, or turned Separate Mobile Cache on/off deliberately) so
9346 * the dashboard callout doesn't nag forever. Writes the option directly
9347 * (bypassing Settings_Manager) so it never touches schema fields.
9348 */
9349 public static function clear_mobile_separate_review(): void {
9350 $stored = get_option( 'xspeed_module_cache', array() );
9351 if ( ! is_array( $stored ) || empty( $stored['mobile_separate_review'] ) ) {
9352 return;
9353 }
9354 unset( $stored['mobile_separate_review'] );
9355 update_option( 'xspeed_module_cache', $stored );
9356 }
9357
9358 /**
9359 * On-demand probe: does the homepage serve materially the same HTML to a
9360 * desktop and a mobile browser? Fetches home_url() twice over loopback —
9361 * once with a desktop User-Agent, once with a mobile one — strips
9362 * per-request noise (nonces, CSRF tokens, session ids, inline timestamps),
9363 * and compares. When identical, Separate Mobile Cache is almost certainly
9364 * unnecessary and the user can turn it off to regain the static fast path.
9365 *
9366 * NEVER run automatically (no page-load cost) — only from the dashboard
9367 * "Check now" button. Result is cached for 10 minutes so a double-click or
9368 * a re-render doesn't fire two more self-requests. (FBS-83145)
9369 *
9370 * @return array{ identical:bool, checked:bool, reason?:string, desktop_bytes?:int, mobile_bytes?:int }
9371 */
9372 public static function probe_mobile_equality(): array {
9373 $cached = get_transient( 'xspeed_mobile_equality_probe' );
9374 if ( is_array( $cached ) ) {
9375 return $cached;
9376 }
9377
9378 $home = home_url( '/' );
9379 $host = (string) wp_parse_url( $home, PHP_URL_HOST );
9380 if ( '' === $host ) {
9381 $result = array( 'identical' => false, 'checked' => false, 'reason' => 'home_url has no host' );
9382 set_transient( 'xspeed_mobile_equality_probe', $result, MINUTE_IN_SECONDS );
9383 return $result;
9384 }
9385
9386 // Match WP core's own mobile detection (wp_is_mobile) so the probe
9387 // reflects what the site would actually branch on. iPhone Safari for
9388 // mobile; a current desktop Chrome UA for desktop.
9389 $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';
9390 $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';
9391
9392 $is_local = function_exists( 'wp_get_environment_type' )
9393 && in_array( wp_get_environment_type(), array( 'local', 'development' ), true );
9394
9395 $fetch = static function ( string $ua ) use ( $home, $is_local ) {
9396 $resp = wp_remote_get(
9397 $home,
9398 array(
9399 'timeout' => 5,
9400 'sslverify' => ! $is_local,
9401 'redirection' => 2,
9402 // Bust any per-device cache so we compare freshly-rendered
9403 // HTML, and pass the device UA the site would branch on.
9404 'user-agent' => $ua,
9405 // A real device UA by design, so only the header marks
9406 // this as ours to analytics and the hit ratio.
9407 'headers' => Self_Traffic::headers( array( 'Cache-Control' => 'no-cache' ) ),
9408 )
9409 );
9410 if ( is_wp_error( $resp ) || 200 !== (int) wp_remote_retrieve_response_code( $resp ) ) {
9411 return null;
9412 }
9413 return (string) wp_remote_retrieve_body( $resp );
9414 };
9415
9416 $desktop = $fetch( $desktop_ua );
9417 $mobile = $fetch( $mobile_ua );
9418
9419 if ( null === $desktop || null === $mobile ) {
9420 $result = array( 'identical' => false, 'checked' => false, 'reason' => 'could not fetch homepage twice' );
9421 set_transient( 'xspeed_mobile_equality_probe', $result, MINUTE_IN_SECONDS );
9422 return $result;
9423 }
9424
9425 $identical = self::normalize_html_for_diff( $desktop ) === self::normalize_html_for_diff( $mobile );
9426
9427 $result = array(
9428 'identical' => $identical,
9429 'checked' => true,
9430 'desktop_bytes' => strlen( $desktop ),
9431 'mobile_bytes' => strlen( $mobile ),
9432 );
9433 set_transient( 'xspeed_mobile_equality_probe', $result, 10 * MINUTE_IN_SECONDS );
9434 return $result;
9435 }
9436
9437 /**
9438 * Strip per-request noise from HTML so a desktop-vs-mobile diff reflects
9439 * real structural differences, not nonces / session ids / timestamps that
9440 * change on every render. Deliberately conservative: it normalizes the
9441 * handful of well-known noise sources and collapses whitespace, so a site
9442 * that truly serves different markup per device still compares as different.
9443 */
9444 private static function normalize_html_for_diff( string $html ): string {
9445 // Every rule here errs toward "they differ" being WRONG rather than
9446 // "they match" being wrong: this check only ever tells a user it is
9447 // SAFE to turn Separate Mobile Cache off, so a false "identical"
9448 // would cost them device-specific output. The risk of being too
9449 // conservative is milder but real — the useful answer never appears,
9450 // and the feature's whole pitch ("we'll prove it's safe to turn
9451 // off") silently never pays out. These close the gaps that made a
9452 // mismatch effectively guaranteed on an ordinary WordPress site. (#108)
9453 $patterns = array(
9454 // WP nonces in attribute or JSON form: data-nonce="…",
9455 // _wpnonce=…, "nonce":"…". The `[:=]` adjacency below misses
9456 // wp_nonce_field()'s own markup — `name="_wpnonce" value="ab…"`
9457 // puts `value=` between the key and the token — which is the
9458 // single most common nonce shape in WordPress, so that form is
9459 // matched explicitly first.
9460 '/name=["\']?(_wpnonce|_ajax_nonce)["\']?\s+value=["\']?[a-z0-9]{8,}/i',
9461 // CSP nonces on script/style tags. Base64, so uppercase and
9462 // +/= appear — the hex-only rules below can never match one,
9463 // and a CSP-enabled site therefore differed on every fetch.
9464 // MUST precede the generic nonce rule: that one stops at the
9465 // first non-alphanumeric, leaving the rest of the token behind
9466 // and the two responses still unequal.
9467 // The quotes are optional so HTML5's legal unquoted attribute
9468 // form (`<script nonce=AbCd+q/r=>`) is covered too — without
9469 // that it fell through to the generic rule, which is the exact
9470 // failure this rule exists to remove.
9471 '/\bnonce=(["\'])?[A-Za-z0-9+\/=_-]{8,}(?(1)\1)/',
9472 '/(_wpnonce|nonce|_ajax_nonce)["\']?\s*[:=]\s*["\']?[a-z0-9]{8,}/i',
9473 // Generic hex tokens: cache busters, session ids, md5/sha
9474 // digests. Was 16+, which left an 11-15 char gap above the
9475 // 10-char nonce rule.
9476 //
9477 // The token MUST contain at least one a-f letter. `[a-f0-9]`
9478 // also matches every decimal digit, so a bare `{10,}` erased
9479 // every 10+ digit INTEGER anywhere in the document — including
9480 // visible body text. A page whose desktop and mobile HTML
9481 // differed only by a per-device numeric id (an AdSense slot, an
9482 // A/B bucket, an analytics property) then compared as identical,
9483 // and the check told the user it was safe to switch off the very
9484 // setting keeping that output correct — the one direction this
9485 // function must never fail in. Decimal-only runs are left to the
9486 // bounded epoch rule below, which is deliberately narrower.
9487 //
9488 // Known, accepted (QA R2): a token whose letters all fall in a-f
9489 // reads as a digest, so a per-device `ABC1234567890` strips even
9490 // though it is an id, not a hash. Deliberately left open — the
9491 // alternatives all cost more than the bug:
9492 //
9493 // Token shape (lowercase-only, case-uniformity, a trailing
9494 // letter) cannot separate it. `ABC1234567890` and
9495 // `ABCDEF012345` — an uppercase digest this rule SHOULD strip —
9496 // are both all-hex, uniformly cased, letters-then-digits.
9497 // Each variant fixed the id only by sparing the digest.
9498 //
9499 // Letter density does separate them (23% letters vs 50%), but
9500 // measured over 2000 md5/sha1/sha256 samples, requiring letters
9501 // spread through the token leaves 21-67% of REAL digests
9502 // unmatched depending on the window. Digest noise is most of
9503 // what this function exists to remove, so that trade guts it.
9504 //
9505 // Context (protecting data-* attribute values from this rule)
9506 // works for ids and still strips digests in URLs, classes and
9507 // query strings — but regresses a CHANGING digest inside a
9508 // non-nonce data-* attribute, and needs a two-pass
9509 // hold/restore. Viable if R2 is ever worth pressing; its
9510 // failure at least errs toward "differ".
9511 //
9512 // An A-F-only prefix on a per-device id is rare, and the earlier
9513 // nonce rules already claim the data-nonce/_wpnonce shapes.
9514 '/\b(?=[a-f0-9]{10,}\b)[0-9]*[a-f][a-f0-9]*\b/i',
9515 // wp-generated unique ids (e.g. wp-block ids, aria ids).
9516 '/(id|for|aria-[a-z]+)="[^"]*-[0-9]{3,}"/i',
9517 // ISO-ish timestamps + epoch-looking numbers in query strings.
9518 '/\?ver=[0-9.]+/',
9519 '/[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.+Z-]+/',
9520 // Our own signature's generation stamp. The two fetches are
9521 // sequential and each writes its own entry, so this differs on
9522 // essentially every comparison — and it is space-separated, so
9523 // the ISO rule above (which requires a literal `T`) never
9524 // touches it. Without this the probe reports "differ" for every
9525 // site and the "safe to turn Separate Mobile Cache off" verdict
9526 // can never appear.
9527 '/generated [0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2} UTC/',
9528 // Raw epoch seconds. The two fetches are sequential, so any
9529 // template printing time() guaranteed a mismatch.
9530 //
9531 // This is the ONLY rule that may strip a decimal-only run, so
9532 // its bound is load-bearing rather than decorative — every digit
9533 // it gives away is a class of per-device id it silently erases.
9534 // `1[0-9]{9}` was too loose: it claimed the whole
9535 // 1000000000-1999999999 range (2001-2033) to cover timestamps
9536 // nobody serves, and took every 10-digit AdSense slot, order id
9537 // and SKU beginning with 1 along with it — reproducing the exact
9538 // false-"identical" verdict the hex rule above was tightened to
9539 // stop. `1[6-9]` covers 2020-2033, which is the only span a live
9540 // site can actually print, and collides with roughly a tenth as
9541 // many ids.
9542 //
9543 // Not airtight — an id beginning 16-19 still collides. Closing
9544 // that properly means scoping this to places a timestamp really
9545 // appears (an attribute value, a query parameter, a JSON value)
9546 // rather than bare body text; the bound is the cheap 90% of it.
9547 '/\b1[6-9][0-9]{8}\b/',
9548 );
9549 $html = (string) preg_replace( $patterns, 'X', $html );
9550 // Collapse all whitespace so trivial formatting differences don't count.
9551 return trim( (string) preg_replace( '/\s+/', ' ', $html ) );
9552 }
9553
9554 public static function ensure_hits_log_file(): bool {
9555 // TWO writers append to this log, and an earlier fix conflated them:
9556 //
9557 // 1. nginx, via the server-level `access_log` directive in
9558 // nginx_snippet() — a DIFFERENT uid, which is why the file needs
9559 // to be world-writable there.
9560 // 2. the PHP drop-in (advanced-cache.php), on EVERY server. A hit it
9561 // serves bypasses WordPress entirely, so it can't call
9562 // Hit_Counter::record_hit() — appending here is the only way that
9563 // hit is ever counted.
9564 //
9565 // The nginx-only early return that used to sit at the top of this
9566 // method was fixing something real: chmod() on a file PHP doesn't own
9567 // raises "Operation not permitted", and off nginx that chmod buys
9568 // nothing. But it took directory creation with it, so on LiteSpeed
9569 // (which always serves via the drop-in), on Apache without mod_headers,
9570 // and anywhere mobile_separate forces the drop-in path, writer 2 was
9571 // appending to a file whose parent directory did not exist. The append
9572 // is @-suppressed and documented as non-fatal, so every one of those
9573 // hits vanished and the dashboard ratio sat at 0% forever.
9574 //
9575 // So: create the dir + file everywhere, and keep only the chmod gated
9576 // to nginx.
9577 $dir = self::hits_log_dir();
9578 if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
9579 return false;
9580 }
9581
9582 $is_nginx = ( Server::NGINX === Server::type() );
9583
9584 if ( $is_nginx ) {
9585 // Ensure the dir is traversable + writable by a different-uid nginx.
9586 // 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.
9587 @chmod( $dir, 0777 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort; the access_log just stays empty if it fails.
9588 }
9589
9590 $path = self::hits_log_path();
9591 if ( ! file_exists( $path ) ) {
9592 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch -- See docblock: must be a plain touch, not WP_Filesystem.
9593 @touch( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- non-fatal helper; failures already covered by the dir check.
9594 }
9595
9596 if ( $is_nginx ) {
9597 // World-writable so a different-uid nginx can append HIT lines.
9598 // Off nginx the drop-in appends as the same uid that owns the file,
9599 // so this is unnecessary — and would emit the "Operation not
9600 // permitted" warnings the old early return was added to silence.
9601 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- See docblock.
9602 @chmod( $path, 0666 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort.
9603 }
9604
9605 return file_exists( $path );
9606 }
9607
9608 public static function nginx_snippet(): ?string {
9609 if ( Server::NGINX !== Server::type() ) {
9610 return null;
9611 }
9612 $rel = '/' . ltrim( str_replace( ABSPATH, '/', XSPEED_CACHE_STATIC_DIR ), '/' );
9613 $rel = rtrim( $rel, '/' );
9614
9615 // WP-Rocket-canonical pattern: every condition lives at
9616 // SERVER level (outside any location block). Each one appends
9617 // a tag to $xspeed_no_cache; the final check is a single
9618 // string-equality against the unmodified default "no-cache".
9619 // Only when ALL conditions pass does the rewrite fire,
9620 // jumping the request to the static file's URL. nginx then
9621 // restarts location matching against the new path, where
9622 // regular static-file serving takes over.
9623 //
9624 // Why server-level + a single rewrite (instead of try_files
9625 // inside `location /`): nginx's well-documented "if is evil"
9626 // quirk silently disables `try_files`'s last fallback when
9627 // any `if` in the same location is true. Moving the `if`s
9628 // outside any location dodges the trap completely, because
9629 // server-level rewrite is the documented stable path.
9630 //
9631 // `last` (not `break`) restarts location matching — required
9632 // so the rewritten static-file URI gets served via the normal
9633 // static-file location, not re-matched against `location /`
9634 // where our own rewrite would loop.
9635 //
9636 // The cache existence check is the LAST condition in the
9637 // chain so when the file isn't cached, $xspeed_no_cache
9638 // gets a "-nofile" tag and the rewrite is skipped — the
9639 // request falls through to whatever `location /` the user
9640 // already had (typically `try_files $uri $uri/ /index.php?$args;`).
9641 // Absolute path to the hit-log file from the nginx process's
9642 // filesystem view. Nginx's `access_log buffer=N flush=Ns` form
9643 // requires a literal path — `$document_root` variables are
9644 // rejected — so PHP computes it. Lives under uploads/ (NOT the
9645 // cache dir): a cache purge or uninstall deletes the cache dir,
9646 // which would orphan this directive's parent directory and make
9647 // `nginx -t` fail [emerg] for EVERY vhost on the host
9648 // (FBS-82478). uploads/ survives both, so the directive can
9649 // never take nginx down. Works on every topology where the nginx
9650 // process shares a filesystem with PHP (container or host).
9651 $hits_abs = self::hits_log_path();
9652
9653 $lines = array();
9654 $lines[] = '# xSpeed static cache — paste at server level, above location / { }.';
9655 // Cache host must match the on-disk dir PHP writes: store_static() /
9656 // static_host() take HTTP_HOST and strip every char outside
9657 // [a-zA-Z0-9.\-] — i.e. it removes the colon but KEEPS the port digits
9658 // (localhost:8192 → localhost8192). nginx's own $host can't reproduce
9659 // that: $host has the port already stripped ENTIRELY (→ localhost), so
9660 // the -f check looks for localhost/... while PHP wrote localhost8192/...
9661 // and the rewrite never fires on a non-standard port. Derive
9662 // $xspeed_host from $http_host (which keeps the port) and drop just the
9663 // colon, so it equals the PHP dir on every port. On standard ports
9664 // $http_host has no colon, so $xspeed_host == $host == the bare domain.
9665 $lines[] = 'set $xspeed_host $http_host;'; // default: no port → unchanged (e.g. example.com)
9666 $lines[] = 'if ($http_host ~ "^([^:]+):(\\d+)$") { set $xspeed_host $1$2; }'; // host:port → hostport (matches PHP static_host())
9667 $lines[] = 'set $xspeed_no_cache "no-cache";';
9668 $lines[] = 'if ($request_method != GET) { set $xspeed_no_cache "$xspeed_no_cache-method"; }';
9669 $lines[] = 'if ($args) { set $xspeed_no_cache "$xspeed_no_cache-args"; }';
9670 // Cookie + user-agent exclusions, generated from the user's actual
9671 // settings rather than a hardcoded list. Before this, the rule
9672 // tested three fixed cookie names and no user agent at all, so
9673 // every excluded_cookies / bypass_user_agents entry applied only
9674 // while a page was cold — on a warm page nginx served the shared
9675 // anonymous copy to carts, members and bypassed bots alike. The
9676 // three historical names survive as a floor inside cookie_rule().
9677 // `~*` is case-insensitive, matching PHP's stripos()/glob checks.
9678 // Stored read — reached from boot(); see stored_cache_opts(). The
9679 // fallbacks below mirror the schema's own defaults, which a raw read
9680 // does not apply.
9681 $cache_opts = self::stored_cache_opts();
9682 $cookie_rule = Server_Rules::cookie_rule(
9683 is_array( $cache_opts['excluded_cookies'] ?? null )
9684 ? $cache_opts['excluded_cookies']
9685 : \XSpeed\Modules\Cache\CacheModule::DEFAULT_EXCLUDED_COOKIES
9686 );
9687 $lines[] = 'if ($http_cookie ~* "(' . $cookie_rule['regex'] . ')") { set $xspeed_no_cache "$xspeed_no_cache-cookie"; }';
9688
9689 $ua_rule = Server_Rules::user_agent_rule(
9690 is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array()
9691 );
9692 // Emitted only when the list is non-empty — an empty alternation
9693 // would compile to `(...)` matching every request and disable the
9694 // fast path entirely.
9695 if ( '' !== $ua_rule['regex'] ) {
9696 $lines[] = 'if ($http_user_agent ~* "(' . $ua_rule['regex'] . ')") { set $xspeed_no_cache "$xspeed_no_cache-ua"; }';
9697 }
9698
9699 // URL exclusions. Without this an excluded URL was only excluded
9700 // while its page was cold: PHP won't write a static file for one, so
9701 // there is usually nothing to serve — but a page cached BEFORE the
9702 // rule was added still has its file on disk, and nginx serves it
9703 // without ever asking PHP. The exclusion then does nothing until the
9704 // next purge. (#169)
9705 //
9706 // Matched against $uri, not $request_uri: $uri is the decoded path
9707 // without the query string, which is what Cache::should_cache()
9708 // tests. Using $request_uri would make `/cart` fail to match
9709 // `/cart?x=1` inconsistently with PHP. Same empty-regex guard as the
9710 // UA rule above — an empty alternation matches everything.
9711 $url_rule = Server_Rules::url_rule(
9712 is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array()
9713 );
9714 if ( '' !== $url_rule['regex'] ) {
9715 $lines[] = 'if ($uri ~* "(' . $url_rule['regex'] . ')") { set $xspeed_no_cache "$xspeed_no_cache-url"; }';
9716 }
9717 $lines[] = 'if (!-f "$document_root' . $rel . '/$xspeed_host$uri/index.html") { set $xspeed_no_cache "$xspeed_no_cache-nofile"; }';
9718 // Neither `add_header` nor `access_log` is allowed inside an `if{}`
9719 // at server level (nginx rejects with "directive is not allowed
9720 // here"). The logging therefore lives in a `location` block that
9721 // matches the rewritten URI after `rewrite … last;` restarts
9722 // location matching. Every HIT lands there exactly once, every
9723 // MISS / PHP-served request never matches it.
9724 $lines[] = 'if ($xspeed_no_cache = "no-cache") {';
9725 $lines[] = ' rewrite ^ ' . $rel . '/$xspeed_host$uri/index.html last;';
9726 $lines[] = '}';
9727 $lines[] = '';
9728 $lines[] = '# Serve + log the cached HIT — `^~` is required so this beats any regex location.';
9729 $lines[] = 'location ^~ ' . $rel . '/ {';
9730 $lines[] = ' internal;';
9731 // LITERAL log path (not `set $var; access_log $var`). The variable form
9732 // makes nginx open the log lazily per-request and SILENTLY drop the
9733 // line if the open fails — so on a working host hits were served
9734 // (X-XSpeed-Cache fires regardless) but nothing was ever written and
9735 // the hit ratio sat at 0%. A literal path makes nginx open the file at
9736 // config load and actually log every hit.
9737 //
9738 // Deleting the log FILE is still safe with a literal path: nginx
9739 // recreates it on the next write/reload and `nginx -t` stays green
9740 // (verified). The only thing that [emerg]s `nginx -t` is a missing
9741 // parent DIRECTORY — and the log lives under uploads/xspeed/, which
9742 // survives cache purge + uninstall, and which ensure_hits_log_file()
9743 // (run on every admin_init via auto_heal) recreates if it ever goes
9744 // missing. So: hits are logged, and a user deleting the log can't take
9745 // nginx down.
9746 $lines[] = ' access_log ' . $hits_abs . ' combined buffer=16k flush=5s;';
9747 $lines[] = ' add_header X-XSpeed-Cache "HIT (nginx)" always;';
9748 // Edge/CDN headers from the same seam the drop-in bakes. nginx serves
9749 // this path without ever starting PHP, so the answer cannot be
9750 // resolved per request — the pairs are resolved HERE, when the
9751 // snippet is generated, and a change of answer needs the snippet
9752 // regenerated and re-pasted to take effect.
9753 //
9754 // Skipped entirely when the static path is switched off. The only
9755 // reason that can fire under `bake` is mobile-split, and mobile-split
9756 // is also what switches the static path off — so the block would be
9757 // baked with a hold it can never serve, and would start serving it
9758 // the moment the setting is turned off and static files reappear,
9759 // until somebody regenerates and re-pastes. A rule that can only be
9760 // served once its premise is false is guaranteed to be stale.
9761 foreach ( self::static_rewrite_allowed() ? self::edge_headers_for( 'HIT', 'rules' ) : array() as $name => $value ) {
9762 $lines[] = ' add_header ' . $name . ' "' . self::quote_directive_value( $value ) . '" always;';
9763 }
9764 $lines[] = '}';
9765
9766 // Stamp the block with a hash of itself. This is the only way to find
9767 // out what a user actually pasted: the block lives in a server config
9768 // WordPress cannot read, so until now the dashboard could not tell an
9769 // up-to-date paste from one made three settings changes ago, and
9770 // covered for that by telling everyone to re-paste after every save.
9771 // A hit through this location now carries the version that served it
9772 // and probe_static_rewrite() reads it back. See rules_state().
9773 return implode(
9774 "\n",
9775 self::with_rules_marker(
9776 $lines,
9777 array( ' add_header ' . self::RULES_HEADER . ' "%s" always;' ),
9778 count( $lines ) - 1
9779 )
9780 );
9781 }
9782
9783 /**
9784 * Aggregate every enabled module's nginx_directives() into one
9785 * pasteable server-block snippet. Replaces the per-module "paste
9786 * this snippet" notices with a single consolidated paste — every
9787 * future feature toggle just regenerates this output.
9788 *
9789 * Returns null on non-nginx hosts (nothing to paste).
9790 *
9791 * Sections render in module-registration order so the layout stays
9792 * predictable; each module gets a comment header `# <slug>`.
9793 */
9794 public static function full_nginx_server_block(): ?string {
9795 if ( Server::NGINX !== Server::type() ) {
9796 return null;
9797 }
9798
9799 $blocks = array();
9800 foreach ( Module_Registry::all() as $module ) {
9801 $directives = $module->nginx_directives();
9802 if ( ! is_string( $directives ) || '' === trim( $directives ) ) {
9803 continue;
9804 }
9805 $blocks[] = "# === " . $module->slug() . " ===\n" . rtrim( $directives );
9806 }
9807
9808 if ( empty( $blocks ) ) {
9809 return null;
9810 }
9811
9812 $header = "# xSpeed unified nginx config — paste into `server { }`, above `location / { }`; re-paste after toggling features.\n";
9813
9814 return $header . "\n" . implode( "\n\n", $blocks ) . "\n";
9815 }
9816
9817 /**
9818 * Tell LiteSpeed's LSCache module to stand down on the cache-miss
9819 * render path.
9820 *
9821 * History: this method used to emit X-LiteSpeed-Cache-Control:
9822 * public,max-age=N + X-LiteSpeed-Tag, handing caching to the server's
9823 * LSCache store. That delegation backfired — once LSCache cached a
9824 * page it served every subsequent request from its OWN store and
9825 * intercepted the request before our site-root .htaccess static
9826 * rewrite could run. Net effect on LiteSpeed hosts: no X-XSpeed-Cache
9827 * header, our static-cache tree never served, the HIT log never
9828 * written (hit ratio frozen at 0%), and the Health probe reporting a
9829 * false "cache running on PHP fallback" because it never saw an
9830 * xSpeed-served response.
9831 *
9832 * xSpeed now owns the cache on LiteSpeed exactly as it does on Apache:
9833 * our `.htaccess` mod_rewrite block serves hits straight from the
9834 * static-cache tree (with the X-XSpeed-Cache header + access-log HIT
9835 * accounting), and PHP/the drop-in is the fallback. To guarantee
9836 * LSCache doesn't shadow that with its own copy — some LiteSpeed
9837 * configs cache by default — we send an explicit `no-cache` control so
9838 * the server defers to our rewrite. Skipped while the LiteSpeed Cache
9839 * plugin's page cache may be on: it owns its own header policy then,
9840 * and enabling ours is refused while its page cache is on (#653). With
9841 * its page cache provably off, xSpeed owns the cache exactly as when
9842 * the plugin is absent, so the stand-down is sent.
9843 */
9844 public static function maybe_emit_lscache_headers(): void {
9845 if ( headers_sent() ) {
9846 return;
9847 }
9848 if ( Server::LITESPEED !== Server::type() ) {
9849 return;
9850 }
9851 // is_plugin_active() lives in wp-admin/includes/plugin.php which
9852 // isn't auto-loaded on front-end requests. Use the option layer
9853 // directly to avoid pulling in admin code from a render path.
9854 $active = (array) get_option( 'active_plugins', array() );
9855 if ( in_array( 'litespeed-cache/litespeed-cache.php', $active, true )
9856 && false !== Cache_Plugin_Catalog::litespeed_page_cache_state() ) {
9857 return;
9858 }
9859
9860 // Explicitly opt this response OUT of LSCache so the server can't
9861 // shadow our static-rewrite cache with its own internal copy.
9862 header( 'X-LiteSpeed-Cache-Control: no-cache' );
9863 }
9864
9865 /**
9866 * Restore the drop-in + WP_CACHE constant for a site that had caching
9867 * ON before this activation — and ONLY for such a site.
9868 *
9869 * WordPress runs an upgrade as deactivate → wipe plugin files →
9870 * install → activate. The wipe takes advanced-cache.php with it, so
9871 * without this the site serves 100% uncached from the moment the
9872 * update finishes until the next authenticated wp-admin page load
9873 * (auto_heal() is on admin_init). On a site whose admin logs in
9874 * rarely that window is hours or days of silent cache loss, while
9875 * the dashboard still reports cache_enabled = true. (FBS field
9876 * report against 1.1.2 / Pro 1.0.5.)
9877 *
9878 * The `cache_enabled` guard is the whole contract: a FRESH install
9879 * has the option unset, so activation writes nothing and the user
9880 * still opts in explicitly through Cache::toggle() via the
9881 * /cache/toggle REST endpoint. We only ever put back state the user
9882 * already chose — repair, never a new install path. This is what
9883 * keeps us on the right side of the "don't create drop-ins the user
9884 * didn't ask for" guideline while matching what WP Rocket, W3 Total
9885 * Cache and WP Super Cache all do on activation.
9886 *
9887 * @return bool True when a restore was performed.
9888 */
9889 public static function restore_dropin_if_enabled(): bool {
9890 if ( defined( 'WP_INSTALLING' ) && WP_INSTALLING ) {
9891 return false;
9892 }
9893
9894 // The user's saved choice. Absent/false on a fresh install => no
9895 // drop-in is written and nothing touches wp-config.php.
9896 $opts = get_option( 'xspeed_options', array() );
9897 if ( empty( $opts['cache_enabled'] ) ) {
9898 return false;
9899 }
9900
9901 $state = self::toggle( true, false );
9902 // A refusal reports whether the cache SERVES, which on this path can
9903 // be true for reasons that have nothing to do with this call — so a
9904 // refusal would otherwise log "drop-in restored" for a restore that
9905 // was declined. Restored means the transaction went through.
9906 $restored = empty( $state['blocked'] ) && ! empty( $state['enabled'] );
9907
9908 if ( $restored ) {
9909 Activity_Log::record(
9910 'cache_dropin_restored',
9911 'Cache drop-in restored after a plugin update — caching was already enabled.',
9912 Activity_Log::SUCCESS
9913 );
9914 }
9915
9916 return $restored;
9917 }
9918
9919 /**
9920 * Reconcile drop-in + WP_CACHE + rewrite block with the user's
9921 * saved choice. Runs on admin_init. Cheap when nothing's wrong
9922 * (one option read + a handful of file_exists / defined checks);
9923 * writes only when state has drifted (typical cause: plugin
9924 * upgrade wiped the drop-in, foreign plugin removed our WP_CACHE
9925 * define, or someone hand-edited .htaccess).
9926 *
9927 * Skipped during the WP plugin updater run so we don't race
9928 * the upgrader's own filesystem operations.
9929 */
9930 public static function auto_heal(): void {
9931 if ( defined( 'WP_INSTALLING' ) && WP_INSTALLING ) {
9932 return;
9933 }
9934 if ( wp_doing_ajax() || wp_doing_cron() ) {
9935 return;
9936 }
9937
9938 $opts = get_option( 'xspeed_options', array() );
9939 if ( empty( $opts['cache_enabled'] ) ) {
9940 return;
9941 }
9942
9943 $state = self::toggle( true, false );
9944 // A refusal means something else now owns the page-cache field, or
9945 // the write could not be verified. Either way this is not the moment
9946 // to go on maintaining our rewrite block and log file.
9947 if ( ! empty( $state['blocked'] ) || empty( $state['enabled'] ) ) {
9948 return;
9949 }
9950
9951 // Rewrite block goes last. It's what turns the static-cache
9952 // tree into a PHP-bypass — every cache hit served by the web
9953 // server directly. Without it we still cache, just at drop-in
9954 // speed (~85ms TTFB) instead of static-file speed (~25-40ms).
9955 //
9956 // Reconcile against mobile_separate: the rewrite is device-blind, so
9957 // it must be ABSENT when mobile_separate is on and PRESENT otherwise.
9958 // auto_heal() runs periodically, so it also repairs a rewrite that
9959 // was left installed before mobile_separate was switched on.
9960 if ( self::static_rewrite_allowed() ) {
9961 if ( ! self::rewrite_installed() ) {
9962 self::install_rewrite();
9963 }
9964 } elseif ( self::rewrite_installed() ) {
9965 self::remove_rewrite();
9966 }
9967
9968 // HITs log file — nginx writes one line per HIT served directly
9969 // (see nginx_snippet()), Cache::get_stats() drains the file via
9970 // Hit_Counter::collect_nginx_log_hits(). If the file vanishes
9971 // (plugin upgrade wiped wp-content/cache/), nginx errors silently
9972 // on the access_log directive and the counter stays at 0.
9973 self::ensure_hits_log_file();
9974 }
9975
9976 /**
9977 * Keep the generic bypass cookie in sync with PHP's caching verdict.
9978 *
9979 * The server config tests exactly one cookie name (Server_Rules::
9980 * BYPASS_COOKIE) forever, and PHP decides what that name means. Adding
9981 * a new excluded cookie therefore needs no config change and no nginx
9982 * reload — the reason this exists.
9983 *
9984 * Session cookie (expiry 0) so it dies with the browser session, and
9985 * deliberately NOT HttpOnly-sensitive: it carries no identity, only the
9986 * boolean "don't serve this visitor a shared cached page".
9987 *
9988 * Honest limit: this can only ever help a visitor PHP has already seen
9989 * once. A bot's first request to a warm page never reaches PHP, which
9990 * is why user-agent rules are still written into the server config
9991 * rather than relying on this.
9992 *
9993 * @param bool $bypass Whether this visitor must skip the cache.
9994 */
9995 private static function sync_bypass_cookie( bool $bypass ): void {
9996 if ( headers_sent() ) {
9997 return;
9998 }
9999
10000 $name = Server_Rules::BYPASS_COOKIE;
10001 $has = isset( $_COOKIE[ $name ] );
10002
10003 // Only touch the header when the state actually changes — a
10004 // Set-Cookie on every request would make the response uncacheable
10005 // for intermediary caches and add noise to every hit.
10006 if ( $bypass === $has ) {
10007 return;
10008 }
10009
10010 $path = defined( 'COOKIEPATH' ) && COOKIEPATH ? COOKIEPATH : '/';
10011 $domain = defined( 'COOKIE_DOMAIN' ) ? COOKIE_DOMAIN : '';
10012
10013 if ( $bypass ) {
10014 setcookie( $name, '1', 0, $path, (string) $domain, is_ssl(), false );
10015 $_COOKIE[ $name ] = '1';
10016 } else {
10017 setcookie( $name, '', time() - 3600, $path, (string) $domain, is_ssl(), false );
10018 unset( $_COOKIE[ $name ] );
10019 }
10020 }
10021
10022 /**
10023 * Build the .htaccess rules that map cacheable requests to the
10024 * static-cache tree. Conditions are deliberately strict: GET only,
10025 * empty query string, no session/comment-author/post-password
10026 * cookie, and the static file must exist on disk. Anything that
10027 * fails one of these falls through to PHP and the drop-in / full
10028 * WordPress path.
10029 *
10030 * @return string[] Lines for insert_with_markers().
10031 */
10032 public static function rewrite_block_lines(): array {
10033 // Path relative to ABSPATH so the rule lives in the site-root
10034 // .htaccess regardless of where wp-content sits. WP_CONTENT_DIR
10035 // can be moved, so we compute the document-root-relative form
10036 // at install time and bake it into the rule.
10037 $rel = str_replace( ABSPATH, '/', XSPEED_CACHE_STATIC_DIR );
10038 $rel = '/' . ltrim( $rel, '/' );
10039 $rel = rtrim( $rel, '/' );
10040
10041 // Cookie + user-agent exclusions generated from the live settings.
10042 // See the matching block in nginx_snippet() — same generator, same
10043 // floor, so both servers enforce an identical policy. Apache reads
10044 // .htaccess on every request and we already self-heal this file, so
10045 // Apache/LiteSpeed users get the fix on upgrade with no action.
10046 $cache_opts = Settings_Manager::get( 'cache' );
10047 $cookie_rule = Server_Rules::cookie_rule(
10048 is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array()
10049 );
10050 $ua_rule = Server_Rules::user_agent_rule(
10051 is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array()
10052 );
10053
10054 $lines = array(
10055 '<IfModule mod_rewrite.c>',
10056 ' RewriteEngine On',
10057 ' RewriteCond %{REQUEST_METHOD} ^GET$',
10058 ' RewriteCond %{QUERY_STRING} ^$',
10059 ' RewriteCond %{HTTP_COOKIE} !(' . $cookie_rule['regex'] . ') [NC]',
10060 );
10061
10062 // Only emit the UA condition when there's something to match —
10063 // `!()` would negate an always-true empty match and refuse every
10064 // request, silently disabling the static path.
10065 if ( '' !== $ua_rule['regex'] ) {
10066 // Quoted, because RewriteCond is whitespace-delimited and real
10067 // user-agent fragments contain spaces ("Mozilla/5.0 (compatible").
10068 // Unquoted, a space adds an argument and Apache answers every
10069 // request with a 500 — and because .htaccess is parsed per
10070 // request, `httpd -t` still reports Syntax OK. Server_Rules has
10071 // already excluded quotes and backslashes from the alternation,
10072 // so the closing quote here cannot be escaped away.
10073 $lines[] = ' RewriteCond %{HTTP_USER_AGENT} "!(' . $ua_rule['regex'] . ')" [NC]';
10074 }
10075
10076 $block = array_merge(
10077 $lines,
10078 array(
10079 // Capture REQUEST_URI without its trailing slash into %1.
10080 // store_static() writes `{host}{uri-without-trailing-slash}/index.html`,
10081 // so this normalization lets `/blog/` and `/blog` both hit
10082 // the same cache file without producing the double-slash
10083 // path that would skip the -f check below.
10084 ' RewriteCond %{REQUEST_URI} ^(.*?)/?$',
10085 ' RewriteCond %{DOCUMENT_ROOT}' . $rel . '/%{HTTP_HOST}%1/index.html -f',
10086 // Pattern is `^`, NOT `.`. The per-directory rewrite engine
10087 // strips the leading slash before matching, so the HOMEPAGE
10088 // request `/` arrives here as an EMPTY path. `.` requires at
10089 // least one character and therefore never matches the homepage
10090 // — on LiteSpeed (which honors this strictly) the front page
10091 // fell through to PHP while every inner page rewrote fine.
10092 // `^` matches the empty string AND any non-empty path, so it
10093 // covers `/` and `/blog` alike. (Confirmed on OpenLiteSpeed
10094 // 1.8: `.` → homepage served by PHP drop-in; `^` → served
10095 // directly from the static file.)
10096 // `E=` tags the request the rewrite just served from the cache
10097 // tree. The edge headers below key off it instead of the file
10098 // name: <FilesMatch "\.html$"> in a document-root .htaccess
10099 // matches EVERY .html on the site — a hand-uploaded /promo.html,
10100 // a static export — and telling a CDN to hold those for the page
10101 // TTL would pin files xSpeed never wrote and cannot purge.
10102 ' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [E=XSPEED_STATIC_HIT:1,L]',
10103 '</IfModule>',
10104 // Mark the statically-served response as a cache HIT.
10105 //
10106 // A file served by the rewrite above bypasses PHP entirely, so
10107 // this directive is the ONLY thing that can identify it as
10108 // cached — both for the user reading response headers and for
10109 // Hit_Counter, which reconciles static hits from the access
10110 // log. Without it the cache works perfectly and reports a 0%
10111 // hit ratio, which reads as "the plugin is broken". (Field
10112 // report against 1.1.2: homepage served byte-identical from
10113 // the static tree, no X-XSpeed-Cache header on any response.)
10114 //
10115 // `always` so the header is set on the 200 from the rewritten
10116 // file, not only on the successful-response table. The
10117 // <IfModule> guard keeps a server without mod_headers from
10118 // 500ing on an unknown directive — on such a host the header
10119 // is silently dropped, which is exactly why
10120 // static_rewrite_allowed() refuses the static path there and
10121 // routes hits through the drop-in instead.
10122 '<IfModule mod_headers.c>',
10123 ' <FilesMatch "\\.html$">',
10124 ' Header always set X-XSpeed-Cache "HIT (static)"',
10125 ' </FilesMatch>',
10126 )
10127 );
10128
10129 // Edge/CDN headers from the same seam the drop-in bakes. Like the
10130 // nginx snippet, the static rewrite answers without PHP, so the pairs
10131 // are resolved when the block is GENERATED rather than per request.
10132 //
10133 // `env=` rather than the `<FilesMatch>` scoping above, because these
10134 // must ride only on responses the rewrite produced. The X-XSpeed-Cache
10135 // marker stays filename-scoped: it is inert, and narrowing it would
10136 // change a header QA reads.
10137 //
10138 // Same reasoning as the nginx snippet: a bake hold can only come from
10139 // mobile-split, and mobile-split is what turns this path off, so a
10140 // hold baked here could only ever be served once its own premise had
10141 // stopped being true.
10142 $edge_lines = array();
10143 foreach ( self::static_rewrite_allowed() ? self::edge_headers_for( 'HIT', 'rules' ) : array() as $edge_name => $edge_value ) {
10144 $edge_lines = array_merge(
10145 $edge_lines,
10146 self::static_hit_directives(
10147 ' Header always set ' . $edge_name . ' "' . self::quote_directive_value( $edge_value ) . '"'
10148 )
10149 );
10150 }
10151
10152 $block = array_merge( $block, $edge_lines, array( '</IfModule>' ) );
10153
10154 // Same self-describing marker as the nginx snippet. Apache's block is
10155 // written by us rather than pasted by hand, so it should never be out
10156 // of date — but "should" is what refresh_rewrite_if_installed() not
10157 // running looks like from the outside, and the probe can now say so
10158 // instead of assuming. `env=` keeps it on responses the rewrite
10159 // produced, so a hand-uploaded .html never claims to be our cache.
10160 //
10161 // Spliced in after the edge headers so the hash covers them: a
10162 // changed edge answer has to make an installed block report itself
10163 // stale, and a marker computed before they were appended would not
10164 // move. The splice point steps back over them and `</IfModule>` to
10165 // land inside `<FilesMatch>`, beside the X-XSpeed-Cache marker.
10166 return self::with_rules_marker(
10167 $block,
10168 self::static_hit_directives( ' Header always set ' . self::RULES_HEADER . ' "%s"' ),
10169 count( $block ) - count( $edge_lines ) - 2
10170 );
10171 }
10172
10173 /**
10174 * Active probe that confirms the web-server static-rewrite path is
10175 * actually serving cached files. Writes a probe file with a random
10176 * nonce, fetches it over HTTP at its public URL, and checks whether
10177 * the response was served directly by the web server (Last-Modified
10178 * + ETag headers + no X-Powered-By: PHP).
10179 *
10180 * Server-agnostic: same probe works for nginx (snippet pasted) and
10181 * Apache / LiteSpeed (.htaccess block installed). If the rewrite
10182 * isn't engaged, the request falls through to WordPress and PHP
10183 * adds its own headers, which the probe detects and reports.
10184 *
10185 * Throttled via a 5-minute transient — we never want this running
10186 * on every Health card paint.
10187 *
10188 * @return array{active:bool, reason:string, code?:int, php?:bool, expires?:int}
10189 */
10190 /**
10191 * @param bool $allow_probe When false (the default), return ONLY a cached
10192 * result and never make an HTTP request — so admin page loads are never
10193 * blocked by the loopback probe. The actual HTTP probe only runs when a
10194 * caller explicitly opts in (the Health tab / cron). Previously this ran
10195 * synchronously on every dashboard bootstrap, so a slow/timing-out
10196 * loopback request added up to `timeout` seconds to admin page loads on
10197 * hosts that block self-requests. (FBS-82142)
10198 */
10199 /**
10200 * Discard the cached probe result and run a fresh one.
10201 *
10202 * Without this there was no way to re-check: the result sat in a transient
10203 * for five minutes and nothing ever deleted it, so a user who fixed their
10204 * nginx config kept seeing "nginx detected — configure for max cache speed"
10205 * with no means of confirming the fix worked. (FBS-84012)
10206 */
10207 public static function recheck_static_rewrite(): array {
10208 delete_transient( 'xspeed_rewrite_probe' );
10209 return self::probe_static_rewrite( true );
10210 }
10211
10212 public static function probe_static_rewrite( bool $allow_probe = false ): array {
10213 $cached = get_transient( 'xspeed_rewrite_probe' );
10214 if ( is_array( $cached ) ) {
10215 return $cached;
10216 }
10217 // No cached result yet and the caller doesn't want to pay for a live
10218 // HTTP probe (e.g. the admin bootstrap): report "pending" without
10219 // blocking. The Health tab will run the real probe on demand.
10220 if ( ! $allow_probe ) {
10221 return array( 'active' => false, 'reason' => 'probe pending', 'pending' => true );
10222 }
10223
10224 $home = home_url( '/' );
10225 $host = (string) wp_parse_url( $home, PHP_URL_HOST );
10226 if ( '' === $host ) {
10227 // Environmental failure, not evidence the server config is wrong —
10228 // mark it inconclusive so Health surfaces say "could not verify"
10229 // instead of demanding a snippet paste. (#480)
10230 $result = array( 'active' => false, 'inconclusive' => true, 'reason' => 'home_url has no host' );
10231 set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
10232 return $result;
10233 }
10234
10235 // Use a randomised path AND nonce so a stale CDN cache entry
10236 // from a prior probe can never make a broken install look
10237 // healthy. Path is namespaced under __xspeed_probe__ so the
10238 // directory listing stays obvious if cleanup misfires.
10239 $slug = wp_generate_password( 12, false, false );
10240 $nonce = wp_generate_password( 24, false, false );
10241 $probe_dir = XSPEED_CACHE_STATIC_DIR . '/' . $host . '/__xspeed_probe__/' . $slug;
10242 $probe_file = $probe_dir . '/index.html';
10243 $probe_url = trailingslashit( $home ) . '__xspeed_probe__/' . $slug . '/';
10244
10245 if ( ! file_exists( $probe_dir ) ) {
10246 wp_mkdir_p( $probe_dir );
10247 }
10248 if ( ! is_dir( $probe_dir ) ) {
10249 // A cache-dir permissions problem — the probe never ran, so this
10250 // says nothing about the nginx config. Inconclusive, not
10251 // "required". (#480)
10252 $result = array( 'active' => false, 'inconclusive' => true, 'reason' => 'cannot create probe dir' );
10253 set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
10254 return $result;
10255 }
10256 // 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.
10257 file_put_contents( $probe_file, $nonce, LOCK_EX );
10258
10259 // Verify TLS by default — disabling it site-wide is a needless MITM
10260 // exposure (FBS-82142). Only relax verification in local/dev
10261 // environments, where self-signed certs are common and there's no
10262 // real attacker in the loop.
10263 $is_local = function_exists( 'wp_get_environment_type' )
10264 && in_array( wp_get_environment_type(), array( 'local', 'development' ), true );
10265 $resp = wp_remote_get(
10266 $probe_url,
10267 array(
10268 // 3s cap so a host that hangs on loopback self-requests can't
10269 // stall the caller for long; the result/error is cached so we
10270 // don't repeat the wait every minute.
10271 'timeout' => 3,
10272 'sslverify' => ! $is_local,
10273 'redirection' => 0,
10274 'headers' => Self_Traffic::headers( array( 'Cache-Control' => 'no-cache' ) ),
10275 )
10276 );
10277
10278 // Best-effort cleanup so we don't accumulate probe dirs even
10279 // if subsequent calls all hit the transient.
10280 if ( file_exists( $probe_file ) ) {
10281 wp_delete_file( $probe_file );
10282 }
10283 if ( is_dir( $probe_dir ) ) {
10284 // 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.
10285 @rmdir( $probe_dir );
10286 }
10287
10288 if ( is_wp_error( $resp ) ) {
10289 $result = array(
10290 'active' => false,
10291 // The request never completed, so we learned NOTHING about the
10292 // rewrite. Flagged inconclusive so the UI doesn't tell the user
10293 // to configure a server that may already be configured — a
10294 // blocked loopback, a self-signed cert, or a timeout is a probe
10295 // failure, not a missing rewrite. (FBS-84012)
10296 'inconclusive' => true,
10297 'reason' => 'http error: ' . $resp->get_error_message(),
10298 );
10299 // Cache the failure for the full 5 minutes (not 1) so a host that
10300 // times out on the loopback probe isn't re-probed — and re-stalled
10301 // — on every page load within the window. (FBS-82142)
10302 set_transient( 'xspeed_rewrite_probe', $result, 5 * MINUTE_IN_SECONDS );
10303 return $result;
10304 }
10305
10306 $code = (int) wp_remote_retrieve_response_code( $resp );
10307 $body = (string) wp_remote_retrieve_body( $resp );
10308 $ua_php = '' !== (string) wp_remote_retrieve_header( $resp, 'x-powered-by' );
10309 $has_etag = '' !== (string) wp_remote_retrieve_header( $resp, 'etag' )
10310 || '' !== (string) wp_remote_retrieve_header( $resp, 'last-modified' );
10311 $match = trim( $body ) === $nonce;
10312 // Which version of our generated rules answered, if any. Only the
10313 // static path can set this — it is baked into the rules themselves —
10314 // so its presence is direct evidence about what is installed, and its
10315 // absence on a conclusive probe is evidence too. See rules_state().
10316 // Validated to the shape we generate, so a proxy or another plugin
10317 // sending something else under this name cannot be mistaken for a
10318 // rules version and reported as "out of date".
10319 $rules_raw = trim( (string) wp_remote_retrieve_header( $resp, strtolower( self::RULES_HEADER ) ) );
10320 $rules = preg_match( '/^[0-9a-f]{8}\z/', $rules_raw ) ? $rules_raw : '';
10321
10322 // "Active" = the web server served our raw nonce bytes back
10323 // AND emitted the static-serve markers (ETag / Last-Modified)
10324 // AND didn't add an X-Powered-By: PHP header. All three are
10325 // individually noisy; together they're conclusive.
10326 $active = $match && $has_etag && ! $ua_php && 200 === $code;
10327
10328 /*
10329 * `inconclusive` separates "we proved the rewrite isn't serving" from
10330 * "the probe couldn't tell". Only the former should drive a
10331 * configure-your-server banner; the latter previously rendered the
10332 * same alarming copy at a user who had already configured nginx
10333 * correctly, and there was no way to clear it. (FBS-84012)
10334 */
10335 $inconclusive = false;
10336 if ( $active ) {
10337 $reason = 'static-served';
10338 } elseif ( 200 === $code && $match && $ua_php ) {
10339 $reason = 'php served the file instead of nginx/Apache (rewrite block missing)';
10340 } elseif ( 200 === $code && ! $match ) {
10341 // Something answered 200 with content that isn't our nonce — a CDN,
10342 // a proxy, a security plugin. That tells us nothing about the
10343 // origin's rewrite.
10344 $reason = 'unexpected body (CDN cached an older response?)';
10345 $inconclusive = true;
10346 } elseif ( 404 === $code ) {
10347 $reason = 'probe URL returned 404 (rewrite block missing or wrong path)';
10348 } else {
10349 // Redirects, 403s from a WAF, 5xx — the probe never reached a
10350 // verdict about the rewrite itself.
10351 $reason = sprintf( 'unexpected response (HTTP %d, body %d B, php=%s)', $code, strlen( $body ), $ua_php ? 'yes' : 'no' );
10352 $inconclusive = true;
10353 }
10354
10355 $result = array(
10356 'active' => $active,
10357 'inconclusive' => $inconclusive,
10358 'reason' => $reason,
10359 'code' => $code,
10360 'php' => $ua_php,
10361 'rules' => $rules,
10362 );
10363 set_transient( 'xspeed_rewrite_probe', $result, 5 * MINUTE_IN_SECONDS );
10364 return $result;
10365 }
10366
10367 public static function rewrite_installed(): bool {
10368 $htaccess = ABSPATH . '.htaccess';
10369 if ( ! file_exists( $htaccess ) ) {
10370 return false;
10371 }
10372 $existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
10373 if ( ! is_string( $existing ) ) {
10374 return false;
10375 }
10376 return false !== strpos( $existing, '# BEGIN xSpeed Static Cache' );
10377 }
10378
10379 /**
10380 * Install the static-cache rewrite block at the TOP of .htaccess.
10381 *
10382 * Position matters: WordPress's own block ends with
10383 * `RewriteRule . /index.php [L]` which routes every non-file
10384 * request to PHP. The [L] flag stops the current rewrite pass,
10385 * but Apache restarts the cycle; on the second pass REQUEST_URI
10386 * is /index.php and no static-file check can match. The only
10387 * reliable position for a "serve static if it exists" rule is
10388 * before WordPress's block.
10389 *
10390 * WP's insert_with_markers() always appends, so we manage the
10391 * block manually: strip any prior xSpeed Static Cache markers,
10392 * then write our block followed by the rest of the file.
10393 */
10394 public static function install_rewrite(): bool {
10395 // The static rewrite is device-blind; never install it when
10396 // mobile_separate is on (see static_rewrite_allowed()).
10397 if ( ! self::static_rewrite_allowed() ) {
10398 return false;
10399 }
10400 $htaccess = ABSPATH . '.htaccess';
10401 $existing = file_exists( $htaccess ) ? @file_get_contents( $htaccess ) : ''; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
10402 if ( false === $existing ) {
10403 $existing = '';
10404 }
10405 // Apache/LiteSpeed only. nginx hosts: rule won't fire, drop-in
10406 // covers; we skip the write so we don't litter their root.
10407 // 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.
10408 if ( file_exists( $htaccess ) && ! is_writable( $htaccess ) ) {
10409 return false;
10410 }
10411 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- See above.
10412 if ( ! file_exists( $htaccess ) && ! is_writable( ABSPATH ) ) {
10413 return false;
10414 }
10415
10416 $cleaned = self::strip_marker_block( $existing, 'xSpeed Static Cache' );
10417 $block = self::marker_block( 'xSpeed Static Cache', self::rewrite_block_lines() );
10418 $next = $block . ( '' === $cleaned ? '' : "\n" . $cleaned );
10419
10420 /*
10421 * Nothing to change. auto_heal() runs the whole enable transaction on
10422 * every admin_init and this is called unconditionally from it, so
10423 * without this every wp-admin request truncated and rewrote .htaccess
10424 * with byte-identical content. Apache reads that file without a lock,
10425 * so the truncate window is a real 500 on a busy admin, and the churn
10426 * trips host file-integrity monitors.
10427 */
10428 if ( $next === $existing ) {
10429 return true;
10430 }
10431
10432 // 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.
10433 return false !== file_put_contents( $htaccess, $next, LOCK_EX );
10434 }
10435
10436 /**
10437 * Rewrite the .htaccess block in place when — and only when — one is
10438 * already installed.
10439 *
10440 * The block embeds the generated cookie / user-agent exclusion rules,
10441 * so it goes stale the moment those settings change. install_rewrite()
10442 * regenerates it from the live settings, but calling that unconditionally
10443 * on every save would CREATE a block on sites that never enabled the
10444 * static path — silently turning on server-level serving nobody asked
10445 * for. So we refresh only what's already there.
10446 *
10447 * @return bool True when a block was present and rewritten.
10448 */
10449 public static function refresh_rewrite_if_installed(): bool {
10450 $htaccess = ABSPATH . '.htaccess';
10451 if ( ! file_exists( $htaccess ) ) {
10452 return false;
10453 }
10454 $existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Best-effort read; an unreadable file simply means nothing to refresh.
10455 if ( ! is_string( $existing ) || false === strpos( $existing, '# BEGIN xSpeed Static Cache' ) ) {
10456 return false;
10457 }
10458 return self::install_rewrite();
10459 }
10460
10461 public static function remove_rewrite(): bool {
10462 $htaccess = ABSPATH . '.htaccess';
10463 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- See install_rewrite() rationale.
10464 if ( ! file_exists( $htaccess ) || ! is_writable( $htaccess ) ) {
10465 return false;
10466 }
10467 $existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
10468 if ( false === $existing ) {
10469 return false;
10470 }
10471 $cleaned = self::strip_marker_block( $existing, 'xSpeed Static Cache' );
10472 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents, PluginCheck.CodeAnalysis.WriteFile.ABSPATHDetected -- See install_rewrite() rationale.
10473 return false !== file_put_contents( $htaccess, $cleaned, LOCK_EX );
10474 }
10475
10476 /**
10477 * Strip a `# BEGIN <marker>` ... `# END <marker>` block from a
10478 * .htaccess-style file, including any blank line that immediately
10479 * follows it. Idempotent — returns the input unchanged if the
10480 * marker isn't present.
10481 */
10482 private static function strip_marker_block( string $contents, string $marker ): string {
10483 /*
10484 * The body may not contain another BEGIN for this marker.
10485 *
10486 * `.*?` is non-greedy but still spans anything, so an ORPHANED
10487 * `# BEGIN xSpeed Static Cache` — an END line lost to a hand edit or
10488 * a partial write — paired with the END of the NEXT block and deleted
10489 * everything between them. On a site where the orphan sits above
10490 * `# BEGIN WordPress`, that takes WordPress's own rewrite rules with
10491 * it and every permalink 404s. Refusing to cross a second BEGIN makes
10492 * the orphan a no-op instead of a site-wide outage.
10493 */
10494 $begin = '# BEGIN ' . preg_quote( $marker, '/' ) . '\b';
10495 $pattern = '/' . $begin . '(?:(?!' . $begin . ').)*?# END ' . preg_quote( $marker, '/' ) . "\b[^\n]*\n?\n?/s";
10496 $out = preg_replace( $pattern, '', $contents );
10497 return is_string( $out ) ? $out : $contents;
10498 }
10499
10500 private static function marker_block( string $marker, array $lines ): string {
10501 $header = "# BEGIN $marker\n";
10502 $header .= "# The directives (lines) between \"BEGIN $marker\" and \"END $marker\" are\n";
10503 $header .= "# dynamically generated, and should only be modified via WordPress filters.\n";
10504 $header .= "# Any changes to the directives between these markers will be overwritten.\n";
10505 $footer = "# END $marker\n";
10506 return $header . implode( "\n", $lines ) . "\n" . $footer;
10507 }
10508
10509 /**
10510 * Parse the `XSPEED_DROPIN_VERSION: N` stamp out of a drop-in's source.
10511 * Returns 0 when absent (an un-stamped older copy reinstalls). Used to
10512 * detect a stale installed drop-in vs the bundled source.
10513 */
10514 private static function dropin_version( string $contents ): int {
10515 if ( preg_match( '/XSPEED_DROPIN_VERSION:\s*(\d+)/', $contents, $m ) ) {
10516 return (int) $m[1];
10517 }
10518 return 0;
10519 }
10520
10521 /** The advanced-cache.php drop-in is ours. */
10522 public const DROPIN_XSPEED = 'xspeed';
10523 /** Someone else's drop-in is installed. */
10524 public const DROPIN_FOREIGN = 'foreign';
10525 /** No drop-in installed. */
10526 public const DROPIN_NONE = 'none';
10527 /** A drop-in is installed and we could not read it. */
10528 public const DROPIN_UNREADABLE = 'unreadable';
10529 /**
10530 * Present but holding nothing -- empty, or whitespace only. WP Rocket
10531 * truncates advanced-cache.php to 0 bytes on deactivate, and calling that
10532 * FOREIGN made it a permanent blocker with no owner to ask. (#391)
10533 */
10534 public const DROPIN_ABANDONED = 'abandoned';
10535
10536 /**
10537 * Who owns wp-content/advanced-cache.php right now.
10538 *
10539 * WordPress gives every caching plugin the same single file to live in,
10540 * so "is there a drop-in" and "is it ours" are completely different
10541 * questions, and only the second one licenses a write. An unreadable
10542 * drop-in is deliberately its own answer rather than folding into
10543 * "foreign": we cannot even name what we would be destroying.
10544 *
10545 * @return string One of the DROPIN_* constants.
10546 */
10547 public static function dropin_owner(): string {
10548 require_once XSPEED_DIR . 'includes/wp-cache-constant.php';
10549 $target = WP_CONTENT_DIR . '/advanced-cache.php';
10550 if ( ! file_exists( $target ) ) {
10551 return self::DROPIN_NONE;
10552 }
10553
10554 $contents = self::read_file( $target );
10555 if ( null === $contents ) {
10556 return self::DROPIN_UNREADABLE;
10557 }
10558
10559 if ( xspeed_has_canonical_dropin_signature( $contents ) ) {
10560 return self::DROPIN_XSPEED;
10561 }
10562
10563 // Nothing in the file means nothing owns it. Kept distinct from
10564 // FOREIGN so the acquisition gate can tell "someone else's cache" from
10565 // "a husk the last plugin left behind". (#391)
10566 if ( '' === trim( $contents ) ) {
10567 return self::DROPIN_ABANDONED;
10568 }
10569
10570 /*
10571 * The other half of the same question, and it cannot be answered from
10572 * the bytes: a file we cannot attribute is a COMPETITOR only while
10573 * some page cache is actually running. With every candidate switched
10574 * off it is abandoned -- a hosting company's own cache, a hand-rolled
10575 * one, or a plugin that was deleted without cleaning up.
10576 *
10577 * Asking the detector rather than re-deriving it here is the point:
10578 * these two answers disagreeing is a split brain with a bad ending --
10579 * acquisition_blocker() opens the gate, install_dropin() then refuses
10580 * on FOREIGN, and toggle() blames the filesystem for a write it never
10581 * attempted. One question, one answer. (#391, #393)
10582 */
10583 if ( class_exists( __NAMESPACE__ . '\\Page_Cache_Detector' ) ) {
10584 $owner = (string) ( Page_Cache_Detector::inspect()['dropin']['owner'] ?? '' );
10585
10586 // Attributable to a named plugin -> somebody's cache, whatever its
10587 // activation state. Only a file NOBODY can be shown to own, with
10588 // nothing running, is abandoned.
10589 if ( Page_Cache_Detector::OWNER_UNKNOWN === $owner
10590 && ! Page_Cache_Detector::another_page_cache_is_active() ) {
10591 return self::DROPIN_ABANDONED;
10592 }
10593 }
10594
10595 return self::DROPIN_FOREIGN;
10596 }
10597
10598 /**
10599 * Why xSpeed must not install its page-cache artifacts right now, or null
10600 * when it may.
10601 *
10602 * This is the single gate in front of every write that touches shared
10603 * state — the drop-in and the WP_CACHE define. Both are single-occupancy:
10604 * whatever is there belongs to exactly one plugin, and taking it silently
10605 * breaks that plugin's caching with no way back.
10606 *
10607 * Returns a user-facing string, so a REST caller can hand it straight to
10608 * the dashboard instead of reporting a bare failure.
10609 */
10610 public static function acquisition_blocker(): ?string {
10611 Page_Cache_Detector::invalidate();
10612 $verdict = Page_Cache_Detector::classify();
10613 $owner = self::dropin_owner();
10614 // The reason we refuse, whether that reason already names a plugin,
10615 // and every other page cache the detector counted anywhere in the
10616 // verdict. See the tail of this method for why all three are needed.
10617 $primary = null;
10618 $primary_names = false;
10619 $named = array();
10620 foreach ( $verdict['blockers'] as $blocker ) {
10621 $code = (string) ( $blocker['code'] ?? '' );
10622 // The shared detector quite correctly reports xSpeed itself as a
10623 // page-cache owner. That is not a competitor to this transaction.
10624 //
10625 // Except when the two disagree about the DROP-IN. The detector
10626 // accepts our marker anywhere in a file's header; this plugin's
10627 // own check requires it to open the header, because only this
10628 // side authorizes overwriting and deleting. A foreign drop-in
10629 // that merely carries our marker further down its header is
10630 // attributed to us by the detector, and skipping it here dropped
10631 // the refusal entirely — the write then failed on the stricter
10632 // check and the user was told to go and fix file permissions.
10633 // Where they disagree, believe the stricter one.
10634 if ( self::PLUGIN_FILE === ( $blocker['plugin'] ?? null ) ) {
10635 $about_dropin = in_array(
10636 $code,
10637 array(
10638 Page_Cache_Detector::BLOCKER_FOREIGN_DROPIN,
10639 Page_Cache_Detector::BLOCKER_UNKNOWN_DROPIN,
10640 ),
10641 true
10642 );
10643 if ( ! $about_dropin || self::DROPIN_XSPEED === $owner ) {
10644 continue;
10645 }
10646 }
10647 if ( Page_Cache_Detector::BLOCKER_WP_CACHE_ORPHANED === $code && self::DROPIN_XSPEED === $owner ) {
10648 continue;
10649 }
10650 /*
10651 * Another plugin's drop-in is no longer a refusal.
10652 *
10653 * It used to be: whoever held advanced-cache.php kept it, and
10654 * enabling was blocked with "deactivate its page cache first".
10655 * That left a user who had asked for our cache with no way to get
10656 * it — on a live site the only exit was deleting a file over SSH,
10657 * and the message could not even say which of its two causes
10658 * applied ("is active OR owns advanced-cache.php").
10659 *
10660 * Turning the page cache on is the instruction to serve pages
10661 * from cache, and that is not possible without this file. So we
10662 * take it, and the dashboard says whose file it is first —
10663 * dropin_disclosure() names the owner, the user confirms, and
10664 * install_dropin() writes ours over the top.
10665 *
10666 * A still-active competitor is deliberately NOT re-added as a
10667 * blocker below: it is caught by `active_page_cache`, which the
10668 * capability rule already downgrades to a note. Two page caches
10669 * installed at once is the user's call to make, not ours to
10670 * refuse — they just told us which one they want serving.
10671 *
10672 * UNREADABLE is the exception and stays a refusal: we cannot name
10673 * what we would destroy, and install_dropin() refuses it too, so
10674 * opening the gate here would only produce a failed write.
10675 */
10676 $about_dropin_owner = in_array(
10677 $code,
10678 array(
10679 Page_Cache_Detector::BLOCKER_FOREIGN_DROPIN,
10680 Page_Cache_Detector::BLOCKER_UNKNOWN_DROPIN,
10681 ),
10682 true
10683 );
10684 if ( $about_dropin_owner && self::DROPIN_UNREADABLE !== $owner ) {
10685 continue;
10686 }
10687 /*
10688 * Capability is not possession. `active_page_cache` and
10689 * `multiple_page_caches` both fire on a plugin that merely CAN
10690 * cache pages — the detector cannot prove a competitor's page
10691 * cache is off, so it counts it. As a warning that is right. As
10692 * a gate it refuses a write that takes nothing from anyone.
10693 *
10694 * This gate guards exactly two files: advanced-cache.php and the
10695 * WP_CACHE define that loads it. A plugin that does not hold the
10696 * drop-in has nothing here for us to overwrite, and one that does
10697 * is already refused by `foreign_dropin` / `unknown_dropin` a few
10698 * lines up. So when the field is ours or empty, an active
10699 * competitor is a note, not a refusal.
10700 *
10701 * QA found this on a live OpenLiteSpeed site keeping LiteSpeed
10702 * Cache for images and CDN with its page cache off, while xSpeed
10703 * served the pages. One click of the off switch and it could not
10704 * be turned back on: the only way out was deactivating LiteSpeed
10705 * entirely, and the message told them to "deactivate its page
10706 * cache" — which they already had.
10707 */
10708 $about_capability = in_array(
10709 $code,
10710 array(
10711 Page_Cache_Detector::BLOCKER_ACTIVE_PAGE_CACHE,
10712 Page_Cache_Detector::BLOCKER_MULTIPLE_PAGE_CACHES,
10713 ),
10714 true
10715 );
10716 /*
10717 * FOREIGN belongs in this list now, and it is the whole point.
10718 *
10719 * The rule is still "capability is not possession": these two
10720 * blockers fire on any plugin that CAN cache pages, which the
10721 * detector cannot prove is switched off. What changed is that a
10722 * competitor holding the drop-in no longer stops us either — we
10723 * take the file, having said whose it is. So there is nothing
10724 * left for a merely-installed competitor to protect, and keeping
10725 * the refusal here would put back the dead end by another route:
10726 * "another page cache is active" on a site where the user has
10727 * just told us, by name, which cache they want serving.
10728 *
10729 * UNREADABLE is deliberately still absent — that one refuses.
10730 */
10731 if ( $about_capability
10732 && in_array( $owner, array( self::DROPIN_XSPEED, self::DROPIN_NONE, self::DROPIN_FOREIGN, self::DROPIN_ABANDONED ), true ) ) {
10733 continue;
10734 }
10735 if ( Page_Cache_Detector::BLOCKER_MULTIPLE_PAGE_CACHES === $code ) {
10736 $others = self::other_page_cache_names( $blocker );
10737 if ( array() === $others ) {
10738 // We were the only owner counted — nothing to refuse —
10739 // unless the list is missing entirely, which is an older
10740 // detector copy we still must not talk past.
10741 if ( null === $primary && array() === (array) ( $blocker['plugins'] ?? array() ) ) {
10742 $primary = self::ownership_blocker_message( '', '' );
10743 }
10744 continue;
10745 }
10746 $named = array_values( array_unique( array_merge( $named, $others ) ) );
10747 if ( null === $primary ) {
10748 $primary = self::multiple_page_caches_message( $others );
10749 $primary_names = true;
10750 }
10751 continue;
10752 }
10753 if ( null === $primary ) {
10754 $label = (string) ( $blocker['label'] ?? '' );
10755 $primary = self::ownership_blocker_message( $code, $label );
10756 $primary_names = '' !== $label;
10757 }
10758 }
10759
10760 if ( null === $primary ) {
10761 return null;
10762 }
10763 /*
10764 * The first blocker decides WHY we refuse; it does not always know
10765 * WHO. The detector can only attribute a drop-in it recognises, and
10766 * an unrecognised one produces "its owner cannot be proved" — the
10767 * sentence a W3 Total Cache site used to get while a later blocker in
10768 * the same verdict was holding the name "W3 Total Cache".
10769 *
10770 * So keep the reason and add the names, rather than swapping one for
10771 * the other: the plugin the user must deal with is not necessarily
10772 * the owner of the file we could not identify, and promoting the
10773 * named blocker would have told them to deactivate a plugin that is
10774 * not what is in their way.
10775 */
10776 if ( $primary_names || array() === $named ) {
10777 return $primary;
10778 }
10779 if ( 1 === count( $named ) ) {
10780 return sprintf(
10781 /* translators: 1: the refusal reason, 2: a page-caching plugin's name. */
10782 __( '%1$s %2$s is also active on this site — deactivate its page cache before enabling xSpeed.', 'xspeed' ),
10783 $primary,
10784 $named[0]
10785 );
10786 }
10787 return sprintf(
10788 /* translators: 1: the refusal reason, 2: comma-separated page-caching plugin names. */
10789 __( '%1$s These page caches are also active on this site: %2$s. Deactivate them before enabling xSpeed.', 'xspeed' ),
10790 $primary,
10791 implode( ', ', $named )
10792 );
10793 }
10794
10795 /** How xSpeed's own plugin file appears in the detector's catalog. */
10796 private const PLUGIN_FILE = 'xspeed/xspeed.php';
10797
10798 /**
10799 * Name the OTHER page caches behind a `multiple_page_caches` refusal.
10800 *
10801 * This blocker has no single owner, so the detector leaves `plugin` and
10802 * `label` null and hands over the full list instead. Left unhandled it
10803 * fell through to the anonymous fallback sentence — and it is the blocker
10804 * an ordinary site hits most: xSpeed counts toward "multiple", so the
10805 * count reaches two the moment one other page-cache plugin is activated,
10806 * even one that has not written a drop-in. A site running our cache that
10807 * activated LiteSpeed could not re-enable it and was told only that "the
10808 * page-cache field is occupied".
10809 *
10810 * Returns an empty list when xSpeed was the only owner counted, or when
10811 * an older detector copy sent no list at all — the caller distinguishes
10812 * the two by looking at `plugins`.
10813 *
10814 * @param array<string,mixed> $blocker One entry from Detector::classify().
10815 * @return string[]
10816 */
10817 private static function other_page_cache_names( array $blocker ): array {
10818 $plugins = array_values( (array) ( $blocker['plugins'] ?? array() ) );
10819 $labels = array_values( (array) ( $blocker['labels'] ?? array() ) );
10820
10821 $others = array();
10822 foreach ( $plugins as $i => $plugin ) {
10823 if ( self::PLUGIN_FILE === $plugin ) {
10824 continue;
10825 }
10826 $others[] = isset( $labels[ $i ] ) && '' !== (string) $labels[ $i ]
10827 ? (string) $labels[ $i ]
10828 : (string) $plugin;
10829 }
10830 return array_values( array_unique( $others ) );
10831 }
10832
10833 /**
10834 * The refusal sentence for a `multiple_page_caches` blocker.
10835 *
10836 * @param string[] $others Page caches other than xSpeed. Never empty.
10837 */
10838 private static function multiple_page_caches_message( array $others ): string {
10839 if ( 1 === count( $others ) ) {
10840 return self::ownership_blocker_message( Page_Cache_Detector::BLOCKER_ACTIVE_PAGE_CACHE, $others[0] );
10841 }
10842 return sprintf(
10843 /* translators: %s: comma-separated list of page-caching plugin names. */
10844 __( 'More than one page cache is active on this site (%s). Turn off the other page caches before enabling xSpeed.', 'xspeed' ),
10845 implode( ', ', $others )
10846 );
10847 }
10848
10849 private static function ownership_blocker_message( string $code, string $label ): string {
10850 // The plugin can stay active. Only its page cache is in the way, so
10851 // say that rather than "deactivate it". (#653)
10852 if ( Page_Cache_Detector::BLOCKER_PAGE_CACHE_SWITCHED_ON === $code && '' !== $label ) {
10853 return sprintf(
10854 /* translators: %s: a page-caching plugin's name, e.g. LiteSpeed Cache. */
10855 __( "%s's page cache is on. Turn it off in that plugin's settings before enabling xSpeed's page cache. The plugin can stay active.", 'xspeed' ),
10856 $label
10857 );
10858 }
10859 if ( '' !== $label ) {
10860 return sprintf( __( '%s is active or owns advanced-cache.php. Deactivate its page cache before enabling xSpeed.', 'xspeed' ), $label );
10861 }
10862 $messages = array(
10863 'wp_cache_orphaned' => __( 'WP_CACHE is true but no page-cache drop-in owner can be proved. xSpeed will not claim it.', 'xspeed' ),
10864 'wp_cache_duplicate' => __( 'wp-config.php defines WP_CACHE more than once. Remove the duplicate before enabling the cache.', 'xspeed' ),
10865 'wp_cache_dynamic' => __( 'WP_CACHE is set from an expression in wp-config.php. xSpeed will not rewrite it.', 'xspeed' ),
10866 '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' ),
10867 'wp_config_unreadable' => __( 'wp-config.php cannot be read, so xSpeed cannot safely change page-cache ownership.', 'xspeed' ),
10868 'unknown_dropin' => __( 'advanced-cache.php is occupied but its owner cannot be proved. xSpeed will not replace it.', 'xspeed' ),
10869 'unreadable_dropin' => __( 'advanced-cache.php cannot be read, so xSpeed cannot prove its owner.', 'xspeed' ),
10870 );
10871 return $messages[ $code ] ?? __( 'The page-cache field is occupied or cannot be verified. xSpeed will not change it.', 'xspeed' );
10872 }
10873
10874 /**
10875 * How WP_CACHE is written in wp-config.php, as opposed to what it
10876 * evaluates to at runtime.
10877 *
10878 * The literal is what matters to a writer: a value behind an expression,
10879 * or two competing defines, cannot be rewritten by a regex without
10880 * guessing — and a wrong guess silently disables page caching (ours or
10881 * someone else's) with no error anywhere.
10882 *
10883 * @return string undefined | true | false | duplicate | dynamic | conditional | unreadable
10884 */
10885 public static function wp_cache_define_state(): string {
10886 $path = self::wp_config_path();
10887 if ( '' === $path ) {
10888 return 'unreadable';
10889 }
10890
10891 $config = self::read_file( $path );
10892 if ( null === $config ) {
10893 return 'unreadable';
10894 }
10895
10896 require_once XSPEED_DIR . 'includes/wp-cache-constant.php';
10897 $parsed = \xspeed_parse_wp_cache_defines( $config );
10898 return $parsed['state'];
10899 }
10900
10901 /**
10902 * Classify the captured right-hand side of a WP_CACHE define.
10903 *
10904 * Hosts and older tutorials write the value several ways —
10905 * `1`, `'1'`, `TRUE` — and all of them are literals a rewrite can safely
10906 * replace. Only a value we cannot evaluate by looking at it (a variable, a
10907 * function call, a ternary) counts as dynamic, because that is the case
10908 * where rewriting means guessing.
10909 *
10910 * @return string true | false | dynamic
10911 */
10912 private static function classify_wp_cache_literal( string $raw ): string {
10913 $literal = strtolower( trim( $raw ) );
10914 $literal = trim( $literal, "'\"" );
10915
10916 if ( in_array( $literal, array( 'true', '1' ), true ) ) {
10917 return 'true';
10918 }
10919 if ( in_array( $literal, array( 'false', '0', '', 'null' ), true ) ) {
10920 return 'false';
10921 }
10922 return 'dynamic';
10923 }
10924
10925 /**
10926 * Read a file for an ownership decision. Null on any failure — callers
10927 * treat null as "unknown", never as "empty", because an empty string
10928 * would read as "no marker found" and license an overwrite.
10929 */
10930 private static function read_file( string $path ): ?string {
10931 if ( ! is_readable( $path ) ) {
10932 return null;
10933 }
10934 // 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.
10935 $contents = @file_get_contents( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- A failed read is a valid answer ("unknown"), not an error to surface.
10936 return is_string( $contents ) ? $contents : null;
10937 }
10938
10939 public static function install_dropin() {
10940 $source = XSPEED_DIR . 'includes/advanced-cache.php';
10941 $target = WP_CONTENT_DIR . '/advanced-cache.php';
10942 if ( ! file_exists( $source ) ) {
10943 return false;
10944 }
10945
10946 /*
10947 * A drop-in we cannot READ is the one thing still refused here. Not
10948 * because of who owns it — we no longer refuse on ownership — but
10949 * because an unreadable file is usually a permissions problem, and
10950 * writing over it would fail anyway or destroy something we were
10951 * never able to look at.
10952 *
10953 * Everything else is ours to take. Enabling the page cache IS the
10954 * user's instruction to serve the cache, and serving it means holding
10955 * advanced-cache.php; the dashboard says whose file it is replacing
10956 * before the click (Page_Cache_Detector::dropin_disclosure()), so the
10957 * takeover is consented rather than silent.
10958 */
10959 $owner = self::dropin_owner();
10960 if ( self::DROPIN_UNREADABLE === $owner ) {
10961 return false;
10962 }
10963
10964 global $wp_filesystem;
10965 if ( ! function_exists( 'WP_Filesystem' ) ) {
10966 require_once ABSPATH . 'wp-admin/includes/file.php';
10967 }
10968 WP_Filesystem();
10969 if ( ! $wp_filesystem ) {
10970 return false;
10971 }
10972
10973 $source_contents = $wp_filesystem->get_contents( $source );
10974 if ( ! is_string( $source_contents ) ) {
10975 return false;
10976 }
10977
10978 // Bake the absolute hit-log path into the drop-in. It runs before
10979 // WordPress loads, so it can't resolve wp_upload_dir() itself — we
10980 // substitute the @@XSPEED_HITS_LOG@@ token with the real uploads path
10981 // (never the cache dir; see hits_log_dir() / FBS-82478). Use a single
10982 // quoted PHP string literal so the installed file stays valid PHP.
10983 $source_contents = str_replace(
10984 '@@XSPEED_HITS_LOG@@',
10985 str_replace( "'", "\\'", self::hits_log_path() ),
10986 $source_contents
10987 );
10988
10989 // Bake the cookie + user-agent exclusion rules in too. The drop-in
10990 // runs before WordPress loads, so it cannot read the settings — and
10991 // without them it served the shared anonymous page to any visitor
10992 // PHP had not yet seen (a first-time cart visitor, a bypassed bot).
10993 // The generic bypass cookie only covers repeat visitors; these two
10994 // regexes are what make the FIRST request correct.
10995 //
10996 // Both are already fully escaped by Server_Rules, and each is
10997 // embedded as a single-quoted PHP literal, so a settings value can
10998 // neither break the drop-in's syntax nor execute.
10999 $cache_opts = Settings_Manager::get( 'cache' );
11000 $cookie_rule = Server_Rules::cookie_rule(
11001 is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array()
11002 );
11003 $ua_rule = Server_Rules::user_agent_rule(
11004 is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array()
11005 );
11006
11007 $source_contents = str_replace(
11008 '@@XSPEED_COOKIE_RE@@',
11009 str_replace( "'", "\\'", $cookie_rule['regex'] ),
11010 $source_contents
11011 );
11012 $source_contents = str_replace(
11013 '@@XSPEED_UA_RE@@',
11014 str_replace( "'", "\\'", $ua_rule['regex'] ),
11015 $source_contents
11016 );
11017 // Which user agents must not count toward the hit ratio. Built here
11018 // because `xspeed_self_user_agents` is a filter the drop-in cannot
11019 // call. A renamed warmer is caught by Self_Traffic::HEADER instead.
11020 $source_contents = str_replace(
11021 '@@XSPEED_HIT_EXCLUDE_RE@@',
11022 str_replace( "'", "\\'", Hit_Counter::excluded_ua_regex() ),
11023 $source_contents
11024 );
11025
11026 /*
11027 * Ours or absent — the ownership gate at the top of this method ruled
11028 * out everything else. The old code path that moved a foreign drop-in
11029 * into uploads/xspeed-backups and wrote ours on top is gone: it
11030 * disabled the other plugin's page cache the moment an xSpeed install
11031 * ran, with nothing in its own UI to explain why.
11032 */
11033
11034 // Bake the configured cache lifetime in. The drop-in runs before
11035 // WordPress loads, so it cannot read the option — it previously fell
11036 // back to a hardcoded 86400 for every ordinary page, because
11037 // write_meta() only emits a `ttl` sidecar when the value DIFFERS from
11038 // the page default. That made the admin's "1 to 720 hours" control a
11039 // no-op at the layer that actually answers the request: 12h served
11040 // stale for up to 2x the configured lifetime, and 168h lost the fast
11041 // path for 6 of every 7 days (issue #240).
11042 //
11043 // This is re-baked on every cache settings save (see CacheModule::boot),
11044 // exactly like the cookie / user-agent rules above.
11045 $expiry_hours = isset( $cache_opts['cache_expiry'] ) ? (int) $cache_opts['cache_expiry'] : 24;
11046 if ( $expiry_hours < 1 || $expiry_hours > 720 ) {
11047 $expiry_hours = 24;
11048 }
11049 $source_contents = str_replace(
11050 '@@XSPEED_DEFAULT_TTL@@',
11051 (string) ( $expiry_hours * HOUR_IN_SECONDS ),
11052 $source_contents
11053 );
11054
11055 // Bake the site-wide edge answer in. Resolved in a `bake` context, so
11056 // nothing per-page and nothing a request header vouched for can reach
11057 // it: a bake runs once, in an admin or CLI request, and answers for
11058 // every page on the site. A page that disagrees gets a sidecar
11059 // instead — see per_entry_edge_headers().
11060 //
11061 // Re-baked on every cache settings save (see CacheModule::boot),
11062 // exactly like the cookie, user-agent and lifetime rules above.
11063 $source_contents = str_replace(
11064 "'@@XSPEED_EDGE_HEADERS@@'",
11065 self::edge_headers_literal( self::edge_headers_for( 'HIT', 'bake' ) ),
11066 $source_contents
11067 );
11068
11069 // And the answer for the same HIT served for a URL with an ignored
11070 // param in it, which the drop-in sends instead of the one above. An
11071 // empty array means the two agree. See query_variant_edge_headers().
11072 $source_contents = str_replace(
11073 "'@@XSPEED_EDGE_QUERY_HOLD@@'",
11074 self::edge_headers_literal( self::query_variant_edge_headers() ),
11075 $source_contents
11076 );
11077
11078 if ( file_exists( $target ) ) {
11079 $existing = $wp_filesystem->get_contents( $target );
11080 if ( is_string( $existing ) && $existing === $source_contents ) {
11081 return true;
11082 }
11083 }
11084
11085 $written = (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE );
11086 if ( $written ) {
11087 self::forget_compiled_dropin( $target );
11088 }
11089 return $written;
11090 }
11091
11092 /**
11093 * Callable that drops a script's compiled copy. Replaced by tests only;
11094 * opcache_invalidate() is a PHP internal that cannot be stubbed.
11095 *
11096 * @var callable|null
11097 */
11098 private static $opcache_invalidate = null;
11099
11100 /**
11101 * Make PHP compile the new drop-in on the next request.
11102 *
11103 * opcache keeps the compiled drop-in and checks the file's timestamp at
11104 * most every `opcache.revalidate_freq` seconds (60 on lenzora.site), or
11105 * never with `validate_timestamps` off. So a re-bake, such as the one
11106 * that follows a change to "CDN or proxy in front", kept serving the old
11107 * edge headers for up to a minute, or until PHP restarted.
11108 *
11109 * This reaches the opcache of the PHP process doing the write, which is
11110 * the web server's own when the bake runs in a page or REST request. A
11111 * WP-CLI bake has its own opcache, so the web server still waits for its
11112 * next timestamp check there.
11113 *
11114 * @param string $target Absolute path of the drop-in just written.
11115 */
11116 private static function forget_compiled_dropin( string $target ): void {
11117 $invalidate = self::$opcache_invalidate;
11118 if ( null === $invalidate ) {
11119 if ( ! function_exists( 'opcache_invalidate' ) ) {
11120 return;
11121 }
11122 $invalidate = 'opcache_invalidate';
11123 }
11124 @call_user_func( $invalidate, $target, true ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- opcache may be disabled or restricted (opcache.restrict_api); nothing to do either way.
11125 }
11126
11127 public static function remove_dropin() {
11128 $target = WP_CONTENT_DIR . '/advanced-cache.php';
11129 if ( ! file_exists( $target ) ) {
11130 return;
11131 }
11132
11133 global $wp_filesystem;
11134 if ( ! function_exists( 'WP_Filesystem' ) ) {
11135 require_once ABSPATH . 'wp-admin/includes/file.php';
11136 }
11137 WP_Filesystem();
11138 if ( ! $wp_filesystem ) {
11139 return;
11140 }
11141
11142 $contents = $wp_filesystem->get_contents( $target );
11143 if ( is_string( $contents ) && xspeed_has_canonical_dropin_signature( $contents ) ) {
11144 wp_delete_file( $target );
11145 }
11146 }
11147
11148 /**
11149 * Where wp-config.php actually is.
11150 *
11151 * WordPress core supports the file one directory ABOVE ABSPATH, and
11152 * plenty of installs use that layout. This used to look only in ABSPATH
11153 * and bail, so on those sites the constant could never be written — while
11154 * Health, which did fall back to the parent, reported the file writable
11155 * and told the user to toggle the cache off and on. The advice could
11156 * never work, and its fallback hint ("another plugin left WP_CACHE false
11157 * behind") was wrong too: there was no define at all. (#19, QA on #174)
11158 *
11159 * Returns '' when no wp-config.php can be found in either location.
11160 */
11161 public static function wp_config_path(): string {
11162 $candidates = array( ABSPATH . 'wp-config.php', dirname( ABSPATH ) . '/wp-config.php' );
11163 foreach ( $candidates as $path ) {
11164 if ( file_exists( $path ) ) {
11165 return $path;
11166 }
11167 }
11168 return '';
11169 }
11170
11171 /**
11172 * Can we actually write the constant right now?
11173 *
11174 * This is the single oracle for that question — Health asks THIS rather
11175 * than running its own `wp_is_writable()` test, so the message a user
11176 * reads can never disagree with what the plugin will do. The two differed
11177 * in both directions: on the path (above) and on the test itself, since
11178 * an FTP/SSH WP_Filesystem transport can refuse a file that
11179 * `wp_is_writable()` reports as writable. (#19, QA on #174)
11180 */
11181 public static function can_write_wp_config(): bool {
11182 $wp_config = self::wp_config_path();
11183 if ( '' === $wp_config ) {
11184 return false;
11185 }
11186
11187 global $wp_filesystem;
11188 if ( ! function_exists( 'WP_Filesystem' ) ) {
11189 require_once ABSPATH . 'wp-admin/includes/file.php';
11190 }
11191 WP_Filesystem();
11192 return (bool) ( $wp_filesystem && $wp_filesystem->is_writable( $wp_config ) );
11193 }
11194
11195 public static function set_wp_cache_constant( $enable ) {
11196 $wp_config = self::wp_config_path();
11197 if ( '' === $wp_config ) {
11198 return false;
11199 }
11200
11201 /*
11202 * WP_CACHE belongs to whoever owns the drop-in — it is the switch that
11203 * makes core load that one file. Editing it while someone else's
11204 * drop-in is installed either turns THEIR cache on or off; either way
11205 * it is a write to another plugin's state. So: no ownership, no edit.
11206 */
11207 $owner = self::dropin_owner();
11208 if ( self::DROPIN_FOREIGN === $owner || self::DROPIN_UNREADABLE === $owner ) {
11209 return false;
11210 }
11211
11212 $state = self::wp_cache_define_state();
11213 if ( 'duplicate' === $state || 'dynamic' === $state ) {
11214 // Two competing defines, or a value behind an expression. A regex
11215 // rewrite here is a guess, and a wrong guess silently kills page
11216 // caching with no error anywhere.
11217 return false;
11218 }
11219 global $wp_filesystem;
11220 if ( ! function_exists( 'WP_Filesystem' ) ) {
11221 require_once ABSPATH . 'wp-admin/includes/file.php';
11222 }
11223 WP_Filesystem();
11224 if ( ! $wp_filesystem ) {
11225 return false;
11226 }
11227
11228 $config = $wp_filesystem->get_contents( $wp_config );
11229 if ( ! is_string( $config ) ) {
11230 return false;
11231 }
11232 require_once XSPEED_DIR . 'includes/wp-cache-constant.php';
11233 $marker = $enable ? self::wp_cache_receipt() : '';
11234 $updated = xspeed_rewrite_wp_cache_define( $config, (bool) $enable, $marker );
11235 if ( ! is_string( $updated ) ) {
11236 return false;
11237 }
11238
11239 /*
11240 * Removing a WP_CACHE line we cannot prove we wrote is somebody else's
11241 * configuration, so a disable needs either our drop-in or our receipt.
11242 *
11243 * The test is on the REWRITE, not on the request: it used to run
11244 * before the rewrite and refuse a disable that had nothing to remove.
11245 * An ordinary site with no drop-in and no define — every fresh
11246 * install — therefore failed to turn page caching off, so the
11247 * onboarding wizard reported "setup needs attention" to every user who
11248 * declined it and Migration reported the cache import as failed.
11249 */
11250 if ( ! $enable && $updated !== $config
11251 && self::DROPIN_XSPEED !== $owner
11252 && ! self::wp_cache_receipt_matches_source( $config ) ) {
11253 return false;
11254 }
11255
11256 /*
11257 * Nothing to write. auto_heal() runs the whole enable transaction on
11258 * every admin_init, so without this every wp-admin request rewrote
11259 * wp-config.php with byte-identical content: pointless disk churn
11260 * that trips host file-integrity monitors and widens the window for
11261 * a concurrent write on a busy admin.
11262 *
11263 * It is also what makes a correct WP_CACHE on a read-only
11264 * wp-config.php succeed. A managed host that ships the file
11265 * unwritable, on a site where the user already pasted the define,
11266 * is in the state we wanted — the writability test below is about
11267 * whether we can CHANGE the file, and there is nothing to change.
11268 */
11269 if ( $updated === $config ) {
11270 if ( ! $enable ) {
11271 // Our line is not in the file, so the receipt that proved we
11272 // wrote it is stale — drop it on the same terms as a real
11273 // removal, or uninstall keeps a claim on nothing.
11274 delete_option( 'xspeed_page_cache_ownership_receipt' );
11275 }
11276 return true;
11277 }
11278
11279 if ( ! $wp_filesystem->is_writable( $wp_config ) ) {
11280 return false;
11281 }
11282 $written = (bool) $wp_filesystem->put_contents( $wp_config, $updated, FS_CHMOD_FILE );
11283 if ( $written && ! $enable ) {
11284 delete_option( 'xspeed_page_cache_ownership_receipt' );
11285 }
11286 return $written;
11287 }
11288
11289 private static function wp_cache_receipt(): string {
11290 $receipt = get_option( 'xspeed_page_cache_ownership_receipt', '' );
11291 if ( is_string( $receipt ) && preg_match( '/^[a-f0-9]{32}$/', $receipt ) ) {
11292 return $receipt;
11293 }
11294 $receipt = substr( hash( 'sha256', XSPEED_DIR . microtime( true ) . mt_rand() ), 0, 32 );
11295 update_option( 'xspeed_page_cache_ownership_receipt', $receipt, false );
11296 return $receipt;
11297 }
11298
11299 /**
11300 * Is the WP_CACHE line in wp-config.php ours to REMOVE?
11301 *
11302 * Two different questions live here and only one of them matters. "Did we
11303 * write it" is answered by our drop-in on disk or by our receipt comment
11304 * beside the define. "Is it ours to remove" also asks what the line does
11305 * NOW — and once a competitor owns advanced-cache.php, a line we wrote
11306 * ourselves is the switch that loads THEIR drop-in. They had no reason to
11307 * touch an already-true define, so our receipt is still sitting on it.
11308 * Removing it there would stop their live page cache.
11309 *
11310 * So a foreign or unreadable owner is never ours to remove, whatever the
11311 * receipt says, and the caller treats that as a reason to leave the line
11312 * and get on with disabling our own cache — not as a reason to refuse.
11313 */
11314 private static function wp_cache_define_is_ours_to_remove( string $owner ): bool {
11315 if ( self::DROPIN_FOREIGN === $owner || self::DROPIN_UNREADABLE === $owner ) {
11316 return false;
11317 }
11318 if ( self::DROPIN_XSPEED === $owner ) {
11319 return true;
11320 }
11321 $path = self::wp_config_path();
11322 if ( '' === $path ) {
11323 return false;
11324 }
11325 $config = self::read_file( $path );
11326 return is_string( $config ) && self::wp_cache_receipt_matches_source( $config );
11327 }
11328
11329 private static function wp_cache_receipt_matches_source( string $source ): bool {
11330 $receipt = get_option( 'xspeed_page_cache_ownership_receipt', '' );
11331 require_once XSPEED_DIR . 'includes/wp-cache-constant.php';
11332 return xspeed_wp_cache_receipt_matches( $source, $receipt );
11333 }
11334
11335 /**
11336 * Admin-bar purge menu — a parent node plus one child per visible cache
11337 * type (LiteSpeed-style), instead of a single "Purge All" link. Each
11338 * child posts to the same admin-post handler with its type slug. The
11339 * per-type items only appear for active/licensed modules; "Purge All"
11340 * always shows and always sweeps everything. (FBS-83114)
11341 *
11342 * The parent node links to the settings page rather than a purge URL —
11343 * clicking the top-level item used to wipe the whole cache instantly with
11344 * no confirmation, which is far too destructive for a stray click. Purging
11345 * stays available (and explicit) through the child items. (FBS-84068)
11346 */
11347 public function admin_bar_purge( $wp_admin_bar ) {
11348 if ( ! current_user_can( 'manage_options' ) ) {
11349 return;
11350 }
11351
11352 $wp_admin_bar->add_node(
11353 array(
11354 'id' => 'xspeed-purge',
11355 'title' => __( 'xSpeed Cache', 'xspeed' ),
11356 'href' => admin_url( 'admin.php?page=' . Admin::PAGE_SLUG ),
11357 )
11358 );
11359
11360 // Settings first, then the two whole-errand actions (Purge All,
11361 // Purge this URL), then the per-type items. The order is the one WP
11362 // Rocket uses, and it front-loads what people open this menu for:
11363 // nobody reaches for "Purge Object Cache" as often as they reach for
11364 // the page they are looking at.
11365 $wp_admin_bar->add_node(
11366 array(
11367 'id' => 'xspeed-purge-settings',
11368 'parent' => 'xspeed-purge',
11369 'title' => esc_html__( 'Settings', 'xspeed' ),
11370 'href' => admin_url( 'admin.php?page=' . Admin::PAGE_SLUG ),
11371 )
11372 );
11373
11374 $types = self::purge_types();
11375
11376 // 'all' is rendered out of band so the single-URL item can sit
11377 // directly under it. A filter that reorders or drops it is honoured:
11378 // the loop below skips whatever was emitted here.
11379 $emitted = array();
11380 if ( ! empty( $types['all']['visible'] ) ) {
11381 $wp_admin_bar->add_node(
11382 array(
11383 'id' => 'xspeed-purge-all',
11384 'parent' => 'xspeed-purge',
11385 'title' => esc_html( $types['all']['label'] ),
11386 'href' => self::purge_type_url( 'all' ),
11387 )
11388 );
11389 $emitted['all'] = true;
11390 }
11391
11392 // Only when the current screen is about one thing — a front-end view,
11393 // or a published post's edit screen. On a list table or a settings
11394 // page there is nothing for "this" to mean, so the item stays hidden
11395 // rather than silently targeting the dashboard. Purge_Ui decides both
11396 // the label and the scope, which differ between the two contexts.
11397 $context = Purge_Ui::context_node();
11398 if ( null !== $context ) {
11399 $wp_admin_bar->add_node(
11400 array(
11401 'id' => 'xspeed-purge-this-url',
11402 'parent' => 'xspeed-purge',
11403 'title' => esc_html( $context['title'] ),
11404 'href' => $context['href'],
11405 )
11406 );
11407 }
11408
11409 foreach ( $types as $slug => $type ) {
11410 if ( empty( $type['visible'] ) || isset( $emitted[ $slug ] ) ) {
11411 continue;
11412 }
11413 $wp_admin_bar->add_node(
11414 array(
11415 'id' => 'xspeed-purge-' . $slug,
11416 'parent' => 'xspeed-purge',
11417 'title' => esc_html( $type['label'] ),
11418 'href' => self::purge_type_url( $slug ),
11419 )
11420 );
11421 }
11422 }
11423
11424 /**
11425 * Nonce-protected admin-post URL for purging a single type. The nonce
11426 * action is per-type so a leaked URL can't be replayed for a different
11427 * scope.
11428 */
11429 private static function purge_type_url( string $type ): string {
11430 return wp_nonce_url(
11431 admin_url( 'admin-post.php?action=xspeed_purge&type=' . rawurlencode( $type ) ),
11432 'xspeed_purge_' . $type
11433 );
11434 }
11435
11436 public function handle_admin_bar_purge() {
11437 if ( ! current_user_can( 'manage_options' ) ) {
11438 wp_die( esc_html__( 'Unauthorized.', 'xspeed' ), 403 );
11439 }
11440 $type = isset( $_GET['type'] ) ? sanitize_key( wp_unslash( $_GET['type'] ) ) : 'all';
11441 check_admin_referer( 'xspeed_purge_' . $type );
11442
11443 // Only honour known types; anything else falls back to a full purge.
11444 if ( ! array_key_exists( $type, self::purge_types() ) ) {
11445 $type = 'all';
11446 }
11447
11448 // Answer the browser BEFORE purging. "Purge All" fans out to the local
11449 // sweep, the object cache, CSS/edge listeners (outbound HTTP) and
11450 // third-party render caches, all in this one request — on a large site
11451 // that can outlive PHP-FPM's request_terminate_timeout, FPM kills the
11452 // worker mid-purge, and nginx answers the admin's click with a 502.
11453 // fastcgi_finish_request() exists on exactly those FPM setups: send
11454 // the redirect, close the connection, then keep purging in the same
11455 // process. Elsewhere (mod_php, CLI tests) fall back to purge-then-
11456 // redirect as before.
11457 $redirect = self::safe_purge_redirect( wp_get_referer() );
11458 if ( function_exists( 'ignore_user_abort' ) ) {
11459 ignore_user_abort( true );
11460 }
11461 if ( function_exists( 'set_time_limit' ) ) {
11462 @set_time_limit( 300 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort under safe-mode-like restrictions.
11463 }
11464 if ( function_exists( 'fastcgi_finish_request' ) ) {
11465 wp_safe_redirect( $redirect );
11466 fastcgi_finish_request();
11467 self::purge_type( $type );
11468 exit;
11469 }
11470
11471 self::purge_type( $type );
11472 wp_safe_redirect( $redirect );
11473 exit;
11474 }
11475
11476 /**
11477 * Resolve a safe redirect target for an admin-bar purge.
11478 *
11479 * The purge sends the admin back where they came from — but the referer
11480 * can be a ONE-SHOT action URL (e.g. update.php?action=upload-plugin from
11481 * installing a plugin zip, or any *.php?action=… that consumed a POST /
11482 * temp upload). Redirecting there re-runs the action with nothing to act
11483 * on, so WordPress dies — the classic "Please select a file" from
11484 * File_Upload_Upgrader. Strip the transient action args so we return to a
11485 * safe, re-GET-able view of the same page; fall back to the dashboard when
11486 * there is no usable referer.
11487 *
11488 * @param string|false $referer Raw wp_get_referer() value.
11489 * @return string Safe URL to redirect to.
11490 */
11491 public static function safe_purge_redirect( $referer ): string {
11492 $referer = is_string( $referer ) ? $referer : '';
11493 if ( '' === $referer ) {
11494 return admin_url();
11495 }
11496
11497 // A referer that lands on an action-processing endpoint (update.php,
11498 // update-core.php, plugin/theme install/upload flows) can't be safely
11499 // re-requested — send them to the dashboard instead of replaying it.
11500 $path = (string) wp_parse_url( $referer, PHP_URL_PATH );
11501 if ( preg_match( '#/wp-admin/(update|update-core)\.php$#', $path ) ) {
11502 return admin_url();
11503 }
11504
11505 // Otherwise keep them on the same page but drop the query args that
11506 // would re-trigger a form action or upload on load.
11507 return remove_query_arg(
11508 array( 'action', 'action2', 'package', 'overwrite', 'plugin', 'theme', 'file', '_wpnonce', '_ajax_nonce' ),
11509 $referer
11510 );
11511 }
11512 }
11513