PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.1
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 1.1.2 All 35 releases
← All changes | includes/class-cache.php +9612 -219 1.0.6 → 1.4.1 View file →
@@ -11,8 +11,64 @@
11 11
12 12 class Cache {
13 13
14 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 + /**
15 71 * Output-buffer nesting level at which we opened our cache buffer, so
16 72 * `close_buffer()` can flush ONLY our buffer and never disturb a buffer
17 73 * another plugin pushed on top of (or below) ours.
18 74 *
@@ -19,11 +75,181 @@
19 75 * @var int|null
20 76 */
21 77 private static $buffer_level = null;
22 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 +
23 198 public function __construct() {
24 - add_action( 'template_redirect', array( $this, 'maybe_start_cache' ), 0 );
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 );
25 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 +
26 252 // Events that should invalidate cached output. Beyond posts/comments,
27 253 // this covers user and term changes — the REST cache can serve
28 254 // /wp/v2/users, /wp/v2/categories, /wp/v2/tags, and these also affect
29 255 // rendered author bylines / term-archive pages. Without them, an edit
@@ -29,9 +255,9 @@
29 255 // rendered author bylines / term-archive pages. Without them, an edit
30 256 // left the matching endpoint (and archives) stale for the full TTL.
31 257 // (FBS-82408)
32 258 $invalidate_hooks = array(
33 - 'save_post', 'deleted_post', 'trashed_post',
259 + 'save_post', 'before_delete_post', 'trashed_post',
34 260 'comment_post', 'wp_set_comment_status',
35 261 'switch_theme', 'activated_plugin', 'deactivated_plugin',
36 262 // Users → /wp/v2/users + author archives.
37 263 'profile_update', 'user_register', 'deleted_user',
@@ -36,16 +262,222 @@
36 262 // Users → /wp/v2/users + author archives.
37 263 'profile_update', 'user_register', 'deleted_user',
38 264 // Terms → /wp/v2/{taxonomy} + term archives.
39 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',
40 270 );
41 271 foreach ( $invalidate_hooks as $hook ) {
42 - add_action( $hook, array( __CLASS__, 'purge_all' ) );
43 - add_action( $hook, array( 'XSpeed\\Minifier', 'purge_minified' ) );
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.
44 304 }
45 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 + // …except the four hooks above that fire on ordinary visitor actions.
382 + // Attached bare, purge_all() can't see WHAT changed, so on a store
383 + // every order, every product review and every checkout
384 + // account-creation wiped 100% of the cache — all anonymous happy-path
385 + // actions, so the cache never reached steady state (#243). Measured:
386 + // 3 orders across 36 pageviews took the hit rate from 83% to 50% and
387 + // the average response from 23ms to 57ms.
388 + //
389 + // HPOS does NOT help: WooCommerce still writes a
390 + // `shop_order_placehold` row into wp_posts to reserve the order ID,
391 + // so save_post fires either way. The gate therefore keys on POST-TYPE
392 + // VIEWABILITY, not on storage mode — which fixes both modes at once,
393 + // and generalises to Flamingo (#229) and Tutor LMS (#231) too.
394 + remove_action( 'save_post', array( __CLASS__, 'purge_all' ) );
395 + add_action( 'save_post', array( __CLASS__, 'on_save_post' ), 10, 2 );
396 + // Bracket every post save, so a WooCommerce product save that runs
397 + // inside one (wp-admin's Update) can tell on_save_post() is about to
398 + // clear the whole site for the same product. See purge_product().
399 + add_action( 'save_post', array( __CLASS__, 'on_post_save_start' ), 0, 2 );
400 + add_action( 'save_post', array( __CLASS__, 'on_post_save_end' ), PHP_INT_MAX, 1 );
401 + // The narrow purge runs here, after the REST API has set the terms
402 + // (it sets them after `save_post`), with the post as it was before.
403 + add_action( 'wp_after_insert_post', array( __CLASS__, 'on_after_insert_post' ), 10, 4 );
404 + add_action( 'shutdown', array( __CLASS__, 'flush_pending_saves' ), 1, 0 );
405 + add_action( 'update_option_sticky_posts', array( __CLASS__, 'on_sticky_posts_change' ), 10, 2 );
406 + // The first sticky post on a site creates the option instead.
407 + add_action( 'add_option_sticky_posts', array( __CLASS__, 'on_sticky_posts_added' ), 10, 2 );
408 + add_action( 'pre_post_update', array( __CLASS__, 'on_pre_post_update' ), 10, 2 );
409 + add_action( 'before_delete_post', array( __CLASS__, 'on_post_removed' ), 10, 2 );
410 + add_action( 'trashed_post', array( __CLASS__, 'on_post_removed' ), 10, 2 );
411 + // wp_delete_post() hands an attachment to wp_delete_attachment() and
412 + // returns BEFORE before_delete_post fires, so deleting media reached
413 + // neither hook above. Attachment pages are public and media appears in
414 + // galleries, so that left cached pages showing a file that is gone.
415 + // (dev caught this via `deleted_post`, which this branch replaced.)
416 + add_action( 'delete_attachment', array( __CLASS__, 'on_post_removed' ), 10, 2 );
417 +
418 + remove_action( 'comment_post', array( __CLASS__, 'purge_all' ) );
419 + add_action( 'comment_post', array( __CLASS__, 'on_comment_post' ), 10, 3 );
420 + add_action( 'wp_set_comment_status', array( __CLASS__, 'on_comment_status' ), 10, 2 );
421 +
422 + remove_action( 'user_register', array( __CLASS__, 'purge_all' ) );
423 + add_action( 'user_register', array( __CLASS__, 'on_user_change' ) );
424 +
425 + remove_action( 'profile_update', array( __CLASS__, 'purge_all' ) );
426 + add_action( 'profile_update', array( __CLASS__, 'on_user_change' ) );
427 +
428 + // Product data lives in post meta and lookup tables, NOT in wp_posts,
429 + // so WC_Product_Data_Store_CPT::update() takes a direct $wpdb->update()
430 + // branch and save_post never fires. Anchoring invalidation on
431 + // save_post therefore missed 100% of commerce-relevant mutations: a
432 + // REST price change, wc_update_product_stock(), a CLI ->save(), and
433 + // every scheduled sale start/end left the product page, the shop and
434 + // the category archives serving the old price and stock for the full
435 + // lifetime — the store quoting one price and charging another (#242).
436 + //
437 + // This MUST ship with the gate above: once orders stop purging
438 + // everything, the accidental invalidation that was masking this
439 + // disappears, and an order that reduces stock would leave the product
440 + // page stale.
441 + if ( class_exists( 'WooCommerce' ) ) {
442 + foreach ( array( 'woocommerce_update_product', 'woocommerce_new_product' ) as $wc_hook ) {
443 + add_action( $wc_hook, array( __CLASS__, 'on_product_saved' ) );
444 + }
445 + // wc_update_product_stock() writes the stock with SQL, saves the
446 + // product (which fires woocommerce_update_product above), then
447 + // fires *_set_stock. The *_before_set_stock actions open that
448 + // write, so the *_set_stock that closes it can tell the save
449 + // inside it already purged. With `$updating` set it skips the
450 + // save, and *_set_stock is then the only purge.
451 + add_action( 'woocommerce_product_before_set_stock', array( __CLASS__, 'on_product_stock_write' ) );
452 + add_action( 'woocommerce_variation_before_set_stock', array( __CLASS__, 'on_product_stock_write' ) );
453 + add_action( 'woocommerce_product_set_stock', array( __CLASS__, 'on_product_stock_set' ) );
454 + add_action( 'woocommerce_variation_set_stock', array( __CLASS__, 'on_product_stock_set' ) );
455 + add_action( 'woocommerce_product_set_stock_status', array( __CLASS__, 'purge_product' ) );
456 + add_action( 'woocommerce_variation_set_stock_status', array( __CLASS__, 'purge_product' ) );
457 + }
458 +
46 459 add_action( 'update_option_xspeed_options', array( __CLASS__, 'on_settings_change' ), 10, 2 );
47 460
461 + // …and the same for every PER-MODULE option. The handler above only
462 + // ever watched the legacy `xspeed_options` blob, but every module has
463 + // since migrated to its own `xspeed_module_<slug>` option and no hook
464 + // followed — so changing Minify HTML, Lazy Load, Remove Query Strings
465 + // etc. left the cached HTML untouched until the TTL expired (24h by
466 + // default) and the feature read as broken. (#205)
467 + //
468 + // One central listener rather than a hook per module: it covers Pro
469 + // modules with no cross-repo change, and a new module can't forget to
470 + // wire it up.
471 + add_action( 'updated_option', array( __CLASS__, 'on_module_settings_change' ), 10, 1 );
472 + // `added_option` matters as much as `updated_option`: on a fresh install
473 + // a module's option doesn't exist yet, so the FIRST save of every panel
474 + // goes through add_option() and would otherwise skip the purge — the
475 + // original bug surviving one save per module. `deleted_option` covers a
476 + // reset-to-defaults, which changes rendered HTML just as much. (#205)
477 + add_action( 'added_option', array( __CLASS__, 'on_module_settings_change' ), 10, 1 );
478 + add_action( 'deleted_option', array( __CLASS__, 'on_module_settings_change' ), 10, 1 );
479 +
48 480 add_action( 'admin_bar_menu', array( $this, 'admin_bar_purge' ), 100 );
49 481 add_action( 'admin_post_xspeed_purge', array( $this, 'handle_admin_bar_purge' ) );
50 482 }
51 483
@@ -56,23 +488,1503 @@
56 488 // (Cache module). Keep this handler around for whatever still
57 489 // lives in the legacy blob (cache_enabled is special and goes
58 490 // through Cache::toggle anyway).
59 491
60 - // Any settings change — purge caches so changes take effect.
492 + // Any settings change — purge caches so changes take effect. Minified
493 + // files are named by content, so there is nothing to delete for them.
61 494 self::purge_all( 'settings change' );
62 - Minifier::purge_minified();
63 495 }
64 496
497 + /**
498 + * Modules whose settings cannot change rendered HTML, so a write to them
499 + * doesn't warrant throwing away the page cache.
500 + *
501 + * The safe default is to purge: a module is listed here only when it is
502 + * clearly incapable of altering front-end output (diagnostics, the MCP
503 + * server, licensing/telemetry surfaces). When in doubt, leave it off the
504 + * list — a needless purge costs a re-render, a missed one makes the
505 + * feature look broken. (#205)
506 + *
507 + * @return string[] Module slugs.
508 + */
509 + public static function non_rendering_modules(): array {
510 + return (array) apply_filters(
511 + 'xspeed_non_rendering_modules',
512 + array(
513 + 'mcp', // AI endpoint — no front-end output.
514 + 'health', // diagnostics only.
515 + 'support', // support snapshot.
516 + 'score', // PageSpeed/GTmetrix runner.
517 + 'migration', // one-shot importer.
518 + 'settings', // import/export surface.
519 + 'cache-coverage', // read-only reporting.
520 + 'ai-privacy', // consent flags for AI surfaces.
521 + 'database', // DB cleanup schedule — no HTML impact.
522 + // Pro slugs — listed by name rather than by asking Pro, so
523 + // Free stays unaware of it. A Pro module absent here simply
524 + // purges, which is the safe default.
525 + 'license',
526 + 'pro_status',
527 + 'analytics',
528 + 'performance-health',
529 + 'recommendations',
530 + 'ai-provider',
531 + 'migration-pro',
532 + )
533 + );
534 + }
535 +
536 + /**
537 + * Purge when ANY module's settings option is written. (#205)
538 + *
539 + * Bound to `updated_option`, `added_option` and `deleted_option` — all three
540 + * fire for every option on the site, so the prefix test comes first and is
541 + * the cheap path for the ~99% of writes that aren't ours. All three pass the
542 + * option name first, which is why this can't hook purge_all() directly:
543 + * that takes $cause first, so every purge would be filed under a cause
544 + * literally named "xspeed_module_minify".
545 + *
546 + * @param string $option Option name that was just written or removed.
547 + */
548 + public static function on_module_settings_change( $option ): void {
549 + $option = (string) $option;
550 + $prefix = Settings_Manager::OPTION_PREFIX;
551 + if ( 0 !== strpos( $option, $prefix ) ) {
552 + return;
553 + }
554 +
555 + $slug = substr( $option, strlen( $prefix ) );
556 + if ( '' === $slug || in_array( $slug, self::non_rendering_modules(), true ) ) {
557 + return;
558 + }
559 +
560 + // Guard against re-entry: purge_all() can write options of its own (stats, timestamps), and a nested purge would
561 + // both waste work and risk recursing through this same hook.
562 + static $purging = false;
563 + if ( $purging ) {
564 + return;
565 + }
566 + $purging = true;
567 +
568 + self::purge_all( 'settings change' );
569 +
570 + $purging = false;
571 + }
572 +
573 + /**
574 + * Stamp the request's cache decision on the response.
575 + *
576 + * `X-XSpeed-Cache` was only ever written on the serve-from-cache paths,
577 + * so a miss and a deliberate bypass both came back with no header at all
578 + * — indistinguishable from a `curl -I`, the first thing anyone reaches
579 + * for when a site "isn't caching" (issue #10). The reason slug rides
580 + * along on `X-XSpeed-Reason`, but only under WP_DEBUG so production
581 + * responses stay clean. Slugs are fixed per gate — never the matched
582 + * pattern, cookie or user-agent, which would echo request input back.
583 + *
584 + * @param string $value HIT (php) | MISS | BYPASS.
585 + * @param string $reason Fixed slug naming the gate, for BYPASS only.
586 + * @param int|null $lifetime_left On a HIT, the seconds the served entry has
587 + * left when it has a lifetime of its own;
588 + * null when it follows the site's. See
589 + * entry_lifetime_left().
590 + */
591 + private static function mark( string $value, string $reason = '', ?int $lifetime_left = null ): void {
592 + self::$status_header = $value;
593 + self::$bypass_reason = $reason;
594 + self::$edge_headers = array();
595 +
596 + // A served-from-cache response may carry edge/CDN headers an add-on
597 + // contributes — `CDN-Cache-Control`, `Cache-Tag` and friends. Only a
598 + // HIT resolves them here. A BYPASS never does: the response was
599 + // deliberately excluded from our cache, so telling a CDN to hold it
600 + // for a month would cache at the edge exactly what we refused to
601 + // cache here. A MISS never does either, stored or not: it is the
602 + // first render, and the one most likely to be replaced once critical
603 + // CSS and unused CSS have been generated. Pinning it at the edge pins
604 + // the version xSpeed is about to improve on. The edge caches from the
605 + // first HIT instead — one render later, and the right one.
606 + //
607 + // Resolved before the headers_sent() guard so the decision is
608 + // recorded (and observable in tests) even on a request that can no
609 + // longer send headers; only the emission below is conditional.
610 + if ( 'HIT' === self::edge_status( $value ) ) {
611 + self::$edge_headers = self::edge_headers_for( 'HIT' );
612 + }
613 +
614 + // Every status, not just a HIT. A page we declined to cache is the
615 + // one an edge most needs telling about: it goes out naked today, and
616 + // a CDN that stores HTML by default keeps somebody's cart.
617 + //
618 + // Resolved before the headers_sent() guard so the decision is
619 + // recorded (and observable in tests) even on a request that can no
620 + // longer send headers; only the emission below is conditional.
621 + self::$edge_headers = self::edge_headers_for( self::edge_status( $value ), 'request', $reason );
622 +
623 + // The same countdown the drop-in applies to this entry, so the two
624 + // PHP serve paths tell the edge the same thing about one page.
625 + if ( null !== $lifetime_left && 'HIT' === self::edge_status( $value ) ) {
626 + self::$edge_headers = self::cap_edge_lifetime( self::$edge_headers, $lifetime_left );
627 + }
628 +
629 + if ( headers_sent() ) {
630 + return;
631 + }
632 + header( 'X-XSpeed-Cache: ' . $value );
633 + if ( '' !== $reason && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
634 + header( 'X-XSpeed-Reason: ' . $reason );
635 + }
636 + foreach ( self::$edge_headers as $name => $val ) {
637 + header( $name . ': ' . $val );
638 + }
639 + }
640 +
641 + /**
642 + * Normalize an `X-XSpeed-Cache` value to the vocabulary the edge seam
643 + * speaks.
644 + *
645 + * The header value carries which layer served the page (`HIT (php)`,
646 + * `HIT (nginx)`, `HIT (static)`); nothing deciding what to tell a CDN
647 + * cares, and making a caller match on three spellings of one outcome is
648 + * how a rule ends up applied on two paths out of three.
649 + */
650 + private static function edge_status( string $value ): string {
651 + return 0 === strpos( $value, 'HIT' ) ? 'HIT' : $value;
652 + }
653 +
654 + /**
655 + * Hash a generated rule set so the rules can identify themselves.
656 + *
657 + * @param string[] $lines Every line of the rule set EXCEPT the marker.
658 + */
659 + private static function rules_hash( array $lines ): string {
660 + return substr( sha1( implode( "\n", $lines ) ), 0, 8 );
661 + }
662 +
663 + /**
664 + * Insert the self-describing marker directive into a generated rule set.
665 + *
666 + * The hash is taken over the rule set WITHOUT this line, and that is the
667 + * whole trick: hashing the finished text instead would mean the act of
668 + * adding the marker changed the value the marker advertises, the probe
669 + * would never see a match, and every correctly installed block would
670 + * report itself out of date forever. The one property worth a test of its
671 + * own — RewriteProbeRulesStateTest covers it.
672 + *
673 + * More than one template is allowed because Apache needs the same marker
674 + * twice, under two environment-variable names (see rewrite_block_lines).
675 + * All of them carry the SAME hash, taken over the marker-less rule set, so
676 + * adding the second directive cannot change the value either advertises.
677 + *
678 + * @param string[] $lines The rule set, with no marker line in it.
679 + * @param string[] $templates sprintf templates for the directives; `%s` is the hash.
680 + * @param int $at Index to insert the marker at.
681 + * @return string[]
682 + */
683 + private static function with_rules_marker( array $lines, array $templates, int $at ): array {
684 + $hash = self::rules_hash( $lines );
685 + $directives = array();
686 + foreach ( $templates as $template ) {
687 + $directives[] = sprintf( $template, $hash );
688 + }
689 + array_splice( $lines, $at, 0, $directives );
690 + return $lines;
691 + }
692 +
693 + /**
694 + * The rules hash the CURRENT settings generate, i.e. what a correctly
695 + * installed rule set would be sending back.
696 + *
697 + * Read out of the generated artifact rather than recomputed, so there is
698 + * exactly one definition of the hash and no way for the generator and the
699 + * expectation to drift apart. Empty on a server whose fast path we do not
700 + * generate rules for.
701 + */
702 + public static function rules_marker_expected(): string {
703 + $type = Server::type();
704 + if ( Server::NGINX === $type ) {
705 + return self::extract_rules_marker( (string) self::nginx_snippet() );
706 + }
707 + if ( Server::APACHE === $type ) {
708 + return self::extract_rules_marker( implode( "\n", self::rewrite_block_lines() ) );
709 + }
710 + return '';
711 + }
712 +
713 + /** Pull the marker value out of a generated rule set ('' when absent). */
714 + public static function extract_rules_marker( string $source ): string {
715 + if ( preg_match( '/' . preg_quote( self::RULES_HEADER, '/' ) . ' "([0-9a-f]{8})"/', $source, $m ) ) {
716 + return $m[1];
717 + }
718 + return '';
719 + }
720 +
721 + /**
722 + * Fingerprint of every setting that feeds the generated server rules.
723 + *
724 + * Values are hashed rather than stored: this map is written from a read
725 + * path and has no business becoming a second copy of the user's exclusion
726 + * lists. A per-key hash is enough — the point is to name WHICH setting
727 + * moved between two rule versions, not to reconstruct the old value.
728 + *
729 + * @return array<string,string>
730 + */
731 + public static function rules_inputs(): array {
732 + $cache_opts = Settings_Manager::get( 'cache' );
733 + $fingerprint = static function ( $value ): string {
734 + $flat = array();
735 + foreach ( (array) $value as $key => $item ) {
736 + $flat[] = $key . '=' . ( is_scalar( $item ) ? (string) $item : '' );
737 + }
738 + return substr( sha1( implode( "\x1f", $flat ) ), 0, 8 );
739 + };
740 +
741 + return array(
742 + 'excluded_cookies' => $fingerprint( $cache_opts['excluded_cookies'] ?? array() ),
743 + 'bypass_user_agents' => $fingerprint( $cache_opts['bypass_user_agents'] ?? array() ),
744 + 'excluded_urls' => $fingerprint( $cache_opts['excluded_urls'] ?? array() ),
745 + 'edge_headers' => $fingerprint( self::edge_headers_for( 'HIT', 'rules' ) ),
746 + 'static_dir' => $fingerprint( array( XSPEED_CACHE_STATIC_DIR ) ),
747 + );
748 + }
749 +
750 + /**
751 + * Remember which settings produced a rules version.
752 + *
753 + * Without this a stale verdict can only say "stale". With it, the hash the
754 + * server sent back is a key into what the site's settings looked like when
755 + * those rules were generated, so the dashboard can name what changed since
756 + * the user last pasted. Bounded to the last few versions — the map exists
757 + * to explain a recent drift, not to keep a history.
758 + *
759 + * @param array<string,string> $inputs Fingerprint from rules_inputs().
760 + */
761 + private static function remember_rules_inputs( string $hash, array $inputs ): void {
762 + if ( '' === $hash ) {
763 + return;
764 + }
765 + $map = get_option( self::RULES_INPUTS_OPTION, array() );
766 + if ( ! is_array( $map ) ) {
767 + $map = array();
768 + }
769 + if ( isset( $map[ $hash ] ) && $map[ $hash ] === $inputs ) {
770 + return;
771 + }
772 + $map[ $hash ] = $inputs;
773 + if ( count( $map ) > self::RULES_INPUTS_KEPT ) {
774 + $map = array_slice( $map, -self::RULES_INPUTS_KEPT, null, true );
775 + }
776 + update_option( self::RULES_INPUTS_OPTION, $map, false );
777 + }
778 +
779 + /**
780 + * Which settings changed between the installed rules and the current ones.
781 + *
782 + * Empty when the installed version predates the map (nothing to compare
783 + * against) — the caller then says "out of date" without naming a cause,
784 + * which is honest.
785 + *
786 + * @param array<string,string> $current Fingerprint from rules_inputs().
787 + * @return string[] Setting keys.
788 + */
789 + private static function changed_rules_inputs( string $observed, array $current ): array {
790 + $map = get_option( self::RULES_INPUTS_OPTION, array() );
791 + if ( ! is_array( $map ) || ! isset( $map[ $observed ] ) || ! is_array( $map[ $observed ] ) ) {
792 + return array();
793 + }
794 +
795 + $was = $map[ $observed ];
796 + $changed = array();
797 + foreach ( $current as $key => $value ) {
798 + if ( ! array_key_exists( $key, $was ) || $was[ $key ] !== $value ) {
799 + $changed[] = $key;
800 + }
801 + }
802 + return $changed;
803 + }
804 +
805 + /**
806 + * Whether the rules the server is running are the rules these settings
807 + * generate.
808 + *
809 + * Nothing in WordPress can read a hand-pasted nginx server block, so
810 + * before this the dashboard could only guess — and guessed by telling
811 + * everyone to re-paste after every settings change. The generated rules
812 + * now carry their own hash and the probe reads it back:
813 + *
814 + * current — the marker matches what these settings generate.
815 + * stale — a marker came back, from a different version of the rules.
816 + * absent — the probe reached a verdict and saw no marker: either no
817 + * rules are installed (the request fell through to PHP) or
818 + * they predate the marker. Either way the fix is the same.
819 + * unknown — the probe could not tell, or this server has no rule set we
820 + * generate.
821 + *
822 + * A marker outranks the probe's own `active` verdict: the marker is direct
823 + * evidence of which rules answered, while `active` is inferred from
824 + * response shape.
825 + *
826 + * Scope: the static-cache rules only — nginx_snippet() on nginx, the
827 + * .htaccess block on Apache. full_nginx_server_block() pastes those
828 + * alongside other modules' directives, and a change to one of those does
829 + * NOT move this hash. Hashing the aggregate would mean the marker inside
830 + * the location block had to know the text it is embedded in, and the
831 + * snippet would hash differently depending on which caller asked for it.
832 + * Callers wording this for a human should say "cache rules", not "your
833 + * nginx config".
834 + *
835 + * `copied` is the mirror of what this admin last pasted — see
836 + * rules_copied(). It is always present, null included, because a client
837 + * keys on the key existing to decide whether the server remembers at all.
838 + *
839 + * @param array $probe Raw result from probe_static_rewrite().
840 + * @return array{expected:string,observed:string,state:string,changed:string[],copied:array{hash:string,at:int}|null}
841 + */
842 + public static function rules_state( array $probe ): array {
843 + $expected = self::rules_marker_expected();
844 + $observed = (string) ( $probe['rules'] ?? '' );
845 + $copied = self::rules_copied();
846 +
847 + if ( '' === $expected ) {
848 + return array(
849 + 'expected' => '',
850 + 'observed' => $observed,
851 + 'state' => 'unknown',
852 + 'changed' => array(),
853 + 'copied' => $copied,
854 + );
855 + }
856 +
857 + $inputs = self::rules_inputs();
858 + self::remember_rules_inputs( $expected, $inputs );
859 +
860 + if ( '' !== $observed ) {
861 + return array(
862 + 'expected' => $expected,
863 + 'observed' => $observed,
864 + 'state' => $observed === $expected ? 'current' : 'stale',
865 + 'changed' => $observed === $expected ? array() : self::changed_rules_inputs( $observed, $inputs ),
866 + 'copied' => $copied,
867 + );
868 + }
869 +
870 + // `absent` is a claim about what is installed, so it takes a probe that
871 + // completed a round trip and read the response headers. A result that
872 + // is still pending, one whose loopback failed, one a CDN answered, and
873 + // one that never got off the ground (no host in home_url, no writable
874 + // probe dir — neither of which sets `inconclusive`, and both of which
875 + // arrive here with no status code) all leave us knowing nothing.
876 + if ( empty( $probe['code'] ) || ! empty( $probe['pending'] ) || ! empty( $probe['inconclusive'] ) ) {
877 + return array(
878 + 'expected' => $expected,
879 + 'observed' => '',
880 + 'state' => 'unknown',
881 + 'changed' => array(),
882 + 'copied' => $copied,
883 + );
884 + }
885 +
886 + return array(
887 + 'expected' => $expected,
888 + 'observed' => '',
889 + 'state' => 'absent',
890 + 'changed' => array(),
891 + 'copied' => $copied,
892 + );
893 + }
894 +
895 + /**
896 + * Which version of the rules this admin says they pasted into the server.
897 + *
898 + * Their claim, not evidence — the probe is the evidence, and where the
899 + * probe can answer this is ignored. It exists for the `unknown` state: a
900 + * host whose loopback is blocked, or one behind a CDN that answers the
901 + * probe itself, where nothing can read back what is installed. There the
902 + * only thing left to go on is that someone said they had done it, and
903 + * without a record of that the panel asks every admin to paste the block
904 + * again forever.
905 + *
906 + * Per user rather than per site, because it is a claim a person made. A
907 + * second admin on the same site has not pasted anything and should not be
908 + * told the work is done. Null when nobody has claimed this version, when
909 + * the stored value is not a shape we wrote, or when there is no current
910 + * user at all (WP-CLI, cron).
911 + *
912 + * @return array{hash:string,at:int}|null
913 + */
914 + public static function rules_copied(): ?array {
915 + if ( ! function_exists( 'get_current_user_id' ) || ! function_exists( 'get_user_meta' ) ) {
916 + return null;
917 + }
918 + $user_id = (int) get_current_user_id();
919 + if ( $user_id <= 0 ) {
920 + return null;
921 + }
922 +
923 + $stored = get_user_meta( $user_id, self::RULES_COPIED_META, true );
924 + if ( ! is_array( $stored ) ) {
925 + return null;
926 + }
927 +
928 + $hash = (string) ( $stored['hash'] ?? '' );
929 + $at = (int) ( $stored['at'] ?? 0 );
930 + if ( ! self::is_rules_hash( $hash ) || $at <= 0 ) {
931 + return null;
932 + }
933 +
934 + return array(
935 + 'hash' => $hash,
936 + 'at' => $at,
937 + );
938 + }
939 +
940 + /**
941 + * Record that this admin pasted the rules whose marker is $hash.
942 + *
943 + * Rejects anything that is not one of our markers rather than storing it,
944 + * so the mirror can only ever hold a value rules_marker_expected() could
945 + * also produce — a stored string that matches nothing would read as "a
946 + * different version is installed" forever.
947 + *
948 + * @param string $hash The 8-hex rules marker the admin copied.
949 + * @return array{hash:string,at:int}|null The stored record, or null if refused.
950 + */
951 + public static function remember_rules_copied( string $hash ): ?array {
952 + // Trimmed but not case-folded: extract_rules_marker() reads a marker
953 + // back in lower case only, so an upper-case claim would never match
954 + // anything the probe could observe. Refuse it rather than store a
955 + // value that can only ever read as a different version.
956 + $hash = trim( $hash );
957 + if ( ! self::is_rules_hash( $hash ) ) {
958 + return null;
959 + }
960 + if ( ! function_exists( 'get_current_user_id' ) || ! function_exists( 'update_user_meta' ) ) {
961 + return null;
962 + }
963 + $user_id = (int) get_current_user_id();
964 + if ( $user_id <= 0 ) {
965 + return null;
966 + }
967 +
968 + $record = array(
969 + 'hash' => $hash,
970 + 'at' => time(),
971 + );
972 + update_user_meta( $user_id, self::RULES_COPIED_META, $record );
973 +
974 + return $record;
975 + }
976 +
977 + /** Whether a string is shaped like one of our rules markers. */
978 + public static function is_rules_hash( string $hash ): bool {
979 + return 1 === preg_match( '/^[0-9a-f]{8}$/', $hash );
980 + }
981 +
982 + /** Record a bypass gate and answer "don't cache" in one statement. */
983 + private static function bypass( string $reason ): bool {
984 + self::mark( 'BYPASS', $reason );
985 + return false;
986 + }
987 +
988 + /** The X-XSpeed-Cache value decided for this request ('' if none yet). */
989 + public static function status_header(): string {
990 + return self::$status_header;
991 + }
992 +
993 + /** The bypass gate slug for this request ('' unless BYPASS). */
994 + public static function bypass_reason(): string {
995 + return self::$bypass_reason;
996 + }
997 +
998 + /**
999 + * The edge/CDN pairs sent on this request ('' if none were).
1000 + *
1001 + * @return array<string,string>
1002 + */
1003 + public static function edge_headers(): array {
1004 + return self::$edge_headers;
1005 + }
1006 +
1007 + /**
1008 + * Bypass gates that do NOT ask a cache in front of us to stand down.
1009 + *
1010 + * Every other slug does. The split is the reason this reads the gate
1011 + * rather than the status: a bypass usually means "this response is
1012 + * personal, or someone decided this page is never stored", and an edge
1013 + * holding one of those does precisely what we refused to do. These two
1014 + * mean something else.
1015 + *
1016 + * `cache-disabled` is the user switching OUR page cache off. Nothing
1017 + * about the page became personal. Sending `no-store` on every page of a
1018 + * site whose owner chose a different cache would make a local toggle a
1019 + * site-wide side effect on infrastructure we do not own.
1020 + *
1021 + * `non-frontend` is admin, REST, cron and AJAX. Not ours to describe:
1022 + * WordPress already nocaches admin, and a REST caller sets its own
1023 + * policy.
1024 + */
1025 + private const HOLD_EXEMPT_BYPASS = array( 'cache-disabled', 'non-frontend' );
1026 +
1027 + /**
1028 + * Bypass gates that describe the SHAPE of the request rather than the
1029 + * visitor or the page.
1030 + *
1031 + * These still hold, but only once we have evidence of an edge — the same
1032 + * bar a MISS has to clear. The difference matters because the default
1033 + * excluded-URL list contains `/feed/`, the sitemap and `/wp-json/`, and
1034 + * `query-param` catches `?lang=fr`, `?paged=2`, and every page of a
1035 + * plain-permalink site.
1036 + *
1037 + * xSpeed refuses those because IT cannot key on a query string, not
1038 + * because the response is private. A CDN keys on the full URL and caches
1039 + * them correctly. Holding them unconditionally would have meant every
1040 + * default install stopped its feed and sitemap being edge-cached — a
1041 + * performance regression shipped to sites that never had a CDN in the
1042 + * first place, in the name of protecting them from one.
1043 + *
1044 + * The gates left out of this list are about the visitor (`logged-in`,
1045 + * `excluded-cookie`) or are somebody stating outright that this page is
1046 + * never to be stored (`donotcachepage`, `post-excluded`, `filtered`).
1047 + * Those hold whether or not we can see an edge.
1048 + */
1049 + private const REQUEST_SHAPE_BYPASS = array( 'query-param', 'non-get', 'user-agent' );
1050 +
1051 + /**
1052 + * Default exclusions that are about the site's plumbing, not its content.
1053 + *
1054 + * `excluded-url` covers two unlike things. The default list carries
1055 + * `/cart`, `/checkout`, `/my-account` and `/wp-login` — personal pages,
1056 + * and the reason this feature exists. It also carries the entries below:
1057 + * feeds, sitemaps, the REST root, the front controller. Those are public,
1058 + * cacheable, and hammered by pollers; a CDN keys on the full URL and
1059 + * serves them correctly, so telling it to stop is a cost with no benefit.
1060 + *
1061 + * Matched as exact strings against the stored list, never as patterns
1062 + * against the path. Three bugs came out of doing it the other way round:
1063 + * `strpos( $uri, '/feed' )` matched `/my-account/feedback/`, reading the
1064 + * whole URI let `/cart/?utm_source=/feed/` disguise a cart as a feed, and
1065 + * a bare `index.php` — which is in this list, and which every URL contains
1066 + * on an "almost pretty" permalink site — made every page on such a site
1067 + * look personal. Comparing the LIST ENTRY rather than the path cannot make
1068 + * any of those mistakes, and it keeps a pattern the site owner added
1069 + * themselves on the personal side where it belongs.
1070 + */
1071 + private const STRUCTURAL_EXCLUSIONS = array(
1072 + '/wp-json/',
1073 + '/xmlrpc.php',
1074 + '~wp-.*\.php',
1075 + '/feed/',
1076 + 'index.php',
1077 + '/robots.txt',
1078 + // Both spellings, and no entry here is ever retired. This is a
1079 + // RECOGNITION list, not a source of truth: it is matched against
1080 + // whatever the site has STORED, and a site that saved its settings
1081 + // before `~sitemap(_index)?\.xml` was widened to `sitemaps?` (for
1082 + // SEOPress, which ships sitemaps.xml) still has the old string in
1083 + // its option row. Dropping the old spelling when the default moved
1084 + // would read every upgraded site's sitemap exclusion as somebody's
1085 + // personal data and hold sitemaps off the CDN — the bug this whole
1086 + // predicate exists to prevent, reintroduced by a rename.
1087 + '~sitemaps?(_index)?\.xml',
1088 + '~sitemap(_index)?\.xml',
1089 + );
1090 + /**
1091 + * Header names no edge instruction may ever carry.
1092 + *
1093 + * These describe the transfer, not the caching policy, and one wrong
1094 + * value from a settings field is a white screen rather than a missing
1095 + * optimization.
1096 + */
1097 + private const NEVER_AN_EDGE_HEADER = array(
1098 + 'content-length',
1099 + 'content-encoding',
1100 + 'content-type',
1101 + 'transfer-encoding',
1102 + 'set-cookie',
1103 + 'location',
1104 + 'x-xspeed-cache',
1105 + 'x-xspeed-edge-hold',
1106 + // `X-XSpeed-Built` says when THIS PAGE's HTML was generated, which is
1107 + // what a downstream verifier compares against the moment it asked for
1108 + // a purge. Free owns it and never takes it from a filter, in any
1109 + // context — see the BUILT_HEADER docblock.
1110 + //
1111 + // Under `bake` a supplied value would be frozen into an artifact and
1112 + // report when the artifact was written: identical on every page and
1113 + // never moving. Under `request` it is no better, because the filter
1114 + // runs from mark() with no file and no mtime in reach, so the only
1115 + // value anything can produce there is time() — the moment of the
1116 + // SERVE, not of the build. Both make every purge check pass.
1117 + //
1118 + // Free stamps the real value where it has the file: the drop-in and
1119 + // serve_not_modified(), both from filemtime(). The static serve paths
1120 + // run no PHP and carry no stamp at all; their `Last-Modified` is read
1121 + // instead.
1122 + 'x-xspeed-built',
1123 + );
1124 +
1125 + /**
1126 + * Reasons that hold the edge off even when we detected nothing in front.
1127 + *
1128 + * `none` confidence means no evidence of a proxy, which is not proof
1129 + * there is none — a transparent proxy and a host page cache both leave
1130 + * the request untouched. So the question is what a wasted header costs
1131 + * against what a missed one does, and the answer differs by reason.
1132 + *
1133 + * These two are correctness failures. A cart page stored by something we
1134 + * could not see is the defect this exists to fix, and a mobile-split page
1135 + * served to the wrong device is a wrong page rather than a slow one.
1136 + * Ninety bytes on a response that was never cacheable is a cheap premium.
1137 + *
1138 + * `miss` and `pending` are performance hedges, and a hedge against a
1139 + * cache that does not exist is noise on every first render. Skipping them
1140 + * has a second benefit: because per_entry_edge_headers() compares `store`
1141 + * against `bake`, a `pending` hold that never fires leaves the two
1142 + * agreeing, which keeps the page on the static tree.
1143 + *
1144 + * `query-variant` waits for evidence as well. It is about purges. An edge
1145 + * keeps a separate copy for every query string, and a purge of the plain
1146 + * URL never reaches them. With no edge in front there are no copies.
1147 + */
1148 + private const HOLD_WITHOUT_EVIDENCE = array( 'bypass', 'mobile-split' );
1149 +
1150 + /**
1151 + * True only while query_variant_edge_headers() bakes the drop-in's
1152 + * answer for a URL no purge names.
1153 + *
1154 + * A bake has no request to read, so this is how it asks "what if this
1155 + * HIT carried a param no purge names?". The drop-in answers that for
1156 + * itself, per request. Never true outside that one call.
1157 + *
1158 + * @var bool
1159 + */
1160 + private static $baking_query_variant = false;
1161 +
1162 + /**
1163 + * Is a module still going to change this page after this response?
1164 + *
1165 + * Free itself never says yes — nothing in Free defers work past the
1166 + * request. Minification and combining write their file and return its URL
1167 + * inside the same render; the LCP preload is chosen by parsing the HTML
1168 + * being sent. It is the question that matters to anything caching in
1169 + * front of us, so Free asks it on their behalf and lets whoever owns the
1170 + * deferred work answer.
1171 + *
1172 + * Answer TRUE while the work is outstanding for the page being served.
1173 + * The cost of a false yes is one extra origin hit; the cost of a false no
1174 + * is an un-optimized page pinned at the edge for the full lifetime, which
1175 + * is the failure this exists to prevent — so when in doubt, say yes.
1176 + *
1177 + * Asked on a `request` only, and that boundary is the whole safety of it.
1178 + *
1179 + * A `bake` is generated once, in an admin or CLI request, and serves every
1180 + * static HIT on the site; a per-page answer frozen into it would be wrong
1181 + * for every other page.
1182 + *
1183 + * A `store` is worse, and cost a live site an afternoon. The pairs written
1184 + * at store time go into the `.meta` sidecar, which the drop-in replays on
1185 + * every later HIT — before plugins load, so nothing can re-ask this
1186 + * question. A hold written there therefore outlives the state that caused
1187 + * it, and the only thing that clears it is the page being stored again. On
1188 + * a site where the deferred work never completes, every re-store re-pins
1189 + * it, and the page is never edge-cacheable again. The symptom is a cache
1190 + * HIT carrying `no-store` and `X-XSpeed-Edge-Hold: pending` on a page
1191 + * whose deferred work finished long ago — the sidecar answering with
1192 + * state nothing can re-ask.
1193 + *
1194 + * Holding the MISS is what this is for, and it is enough: that response is
1195 + * the un-optimized one. The copy we then store is what an edge should
1196 + * mirror, and when the work does land the module purges the page, which
1197 + * reaches the edge. The purge is the correctness mechanism; this is only
1198 + * meant to cover the single render before it.
1199 + *
1200 + * @param string $context `request`, `store` or `bake`.
1201 + */
1202 + public static function edge_optimization_pending( string $context = 'request' ): bool {
1203 + if ( 'request' !== $context ) {
1204 + return false;
1205 + }
1206 +
1207 + /**
1208 + * Filter: xspeed_edge_optimization_pending
1209 + *
1210 + * @param bool $pending Whether deferred work will still change this page.
1211 + */
1212 + return (bool) apply_filters( 'xspeed_edge_optimization_pending', false );
1213 + }
1214 +
1215 + /**
1216 + * Does mobile cache split this URL into two renders?
1217 + *
1218 + * With `mobile_separate` on, Free keys its cache on device and serves a
1219 + * different page to a phone than to a desktop at the SAME url. No CDN
1220 + * varies on User-Agent, so an edge holding one of those renders serves it
1221 + * to everyone: whichever device asked first decides what the other sees,
1222 + * for the whole lifetime. A wrong page, not a slow one.
1223 + *
1224 + * Read from the stored option rather than through Settings_Manager: this
1225 + * is consulted from the serve path, where the module registry may not
1226 + * have run.
1227 + */
1228 + private static function mobile_cache_splits_html(): bool {
1229 + $stored = self::stored_cache_opts();
1230 + return ! empty( $stored['mobile_separate'] );
1231 + }
1232 +
1233 + /**
1234 + * Is this cached page being served for a URL that no purge names?
1235 + *
1236 + * Free serves `/post?utm_source=x` from the entry stored for `/post`,
1237 + * which is what the ignored-params list is for. An edge does not. It
1238 + * keys on the full URL, so every query string becomes its own copy. A
1239 + * purge that names `/post` clears one copy and leaves the others for the
1240 + * whole edge lifetime. A newsletter link can then serve last month's
1241 + * page. So such a HIT is held, and the edge keeps only URLs a purge can
1242 + * name. See query_carries_unpurged_param() for which query strings count.
1243 + *
1244 + * A HIT only. A MISS is already held as `miss`, and a BYPASS never
1245 + * reached the cache. And `request` only, because the query string belongs
1246 + * to the request, not to the entry. One stored file answers `/post` and
1247 + * every variant of it, so a hold written into its sidecar under `store`
1248 + * would be replayed by the drop-in for the plain URL as well. A `bake`
1249 + * says no unless query_variant_edge_headers() is asking for the drop-in.
1250 + *
1251 + * @param string $status `HIT`, `MISS` or `BYPASS`.
1252 + * @param string $context `request`, `store` or `bake`.
1253 + */
1254 + private static function serves_query_variant( string $status, string $context ): bool {
1255 + if ( 'HIT' !== $status ) {
1256 + return false;
1257 + }
1258 + if ( 'bake' === $context ) {
1259 + return self::$baking_query_variant;
1260 + }
1261 +
1262 + return 'request' === $context && self::query_carries_unpurged_param();
1263 + }
1264 +
1265 + /**
1266 + * Does this request's query string carry a param that keeps its URL out
1267 + * of every purge?
1268 + *
1269 + * Two kinds do. A param the cache key leaves out, which is anything on
1270 + * the ignored-params list, judged by query_key_is_ignored() against the
1271 + * same setting should_cache() reads. And the search and query-form feed
1272 + * params (`s`, FEED_QUERY_PARAMS). Those have entries keyed their own
1273 + * way, but they belong to opt-in caches whose URLs no purge names either.
1274 + *
1275 + * Any other param that reaches a HIT is part of the page's own address,
1276 + * which a purge names, so it is not held. A plain-permalink route such
1277 + * as `/?page_id=2` is the case this leaves alone.
1278 + */
1279 + private static function query_carries_unpurged_param(): bool {
1280 + $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.
1281 + if ( '' === trim( $query_raw ) ) {
1282 + return false;
1283 + }
1284 +
1285 + parse_str( $query_raw, $params );
1286 + $cache_opts = Settings_Manager::get( 'cache' );
1287 + $ignored = is_array( $cache_opts['ignored_query_params'] ?? null ) ? $cache_opts['ignored_query_params'] : array();
1288 + foreach ( array_keys( $params ) as $key ) {
1289 + $key = (string) $key;
1290 + if ( 's' === $key || in_array( $key, self::FEED_QUERY_PARAMS, true ) ) {
1291 + return true;
1292 + }
1293 + if ( self::query_key_is_ignored( $key, $ignored ) ) {
1294 + return true;
1295 + }
1296 + }
1297 +
1298 + return false;
1299 + }
1300 +
1301 + /**
1302 + * The edge answer the drop-in sends for a cached page served for a URL
1303 + * no purge names, or an empty array when that answer is the same as the
1304 + * plain URL's.
1305 + *
1306 + * The drop-in runs before plugins load and cannot ask edge_headers_for(),
1307 + * so this is baked in next to the plain answer. The drop-in applies it
1308 + * when a param in the query string matches the ignored-params list it
1309 + * already reads (`.ignored-query-params`, written from the same setting
1310 + * by sync_query_allowlist()). Search and feed params never reach it,
1311 + * because it hands those requests to PHP. The nginx and Apache
1312 + * rules need no copy. Both refuse any request with a query string
1313 + * (`if ($args)`, `RewriteCond %{QUERY_STRING} ^$`), so a variant always
1314 + * reaches PHP.
1315 + *
1316 + * Empty when the two answers agree. That covers no edge being known,
1317 + * a filter vetoing the hold, and a site where every page is held
1318 + * already (mobile-split). The drop-in then sends what it sends for the
1319 + * plain URL, including any sidecar the page has.
1320 + *
1321 + * @return array<string,string>
1322 + */
1323 + public static function query_variant_edge_headers(): array {
1324 + $plain = self::edge_headers_for( 'HIT', 'bake' );
1325 +
1326 + self::$baking_query_variant = true;
1327 + try {
1328 + $variant = self::edge_headers_for( 'HIT', 'bake' );
1329 + } finally {
1330 + self::$baking_query_variant = false;
1331 + }
1332 +
1333 + return $variant === $plain ? array() : $variant;
1334 + }
1335 +
1336 + /**
1337 + * Why, if at all, a cache in front of us should refuse to store this.
1338 + *
1339 + * @param string $status `HIT`, `MISS` or `BYPASS`.
1340 + * @param string $context `request`, `store` or `bake`.
1341 + * @param string $bypass_reason The gate slug, for BYPASS only.
1342 + * @return string '' or one of bypass|bypass-shape|miss|mobile-split|pending|query-variant.
1343 + */
1344 + private static function edge_hold_reason( string $status, string $context, string $bypass_reason ): string {
1345 + $reason = '';
1346 +
1347 + // The two exempt gates are answered before anything else, or a site
1348 + // with Separate Mobile Cache on would keep holding after the page
1349 + // cache was switched off — which is exactly the "a local toggle must
1350 + // not become a site-wide side effect on infrastructure we do not own"
1351 + // rule below, defeated by the ordering rather than by the logic.
1352 + if ( 'BYPASS' === $status && in_array( $bypass_reason, self::HOLD_EXEMPT_BYPASS, true ) ) {
1353 + /** This filter is documented below. */
1354 + return (string) apply_filters( 'xspeed_edge_hold_reason', '', $status, $context, $bypass_reason );
1355 + }
1356 +
1357 + // First, because it is the only reason true in every context: the
1358 + // setting is a property of the site, not of one request, so it is the
1359 + // one thing a baked artifact can honestly assert.
1360 + //
1361 + // It is also the only reason that holds a HIT — a response we DID
1362 + // cache — and that is deliberate rather than an artefact of the
1363 + // ordering. With mobile_separate on we key the cache by device and
1364 + // serve different HTML to a phone than to a desktop at the same URL.
1365 + // No CDN varies on User-Agent, so an edge holding one of those
1366 + // renders serves it to everyone and whichever device asked first
1367 + // decides what the other sees. Our copy is fine; theirs would be a
1368 + // wrong page. The static path is switched off in this mode anyway
1369 + // (static_rewrite_allowed()), so these hits come from the drop-in,
1370 + // which carries the same baked answer.
1371 + if ( self::mobile_cache_splits_html() ) {
1372 + $reason = 'mobile-split';
1373 + } elseif ( 'BYPASS' === $status ) {
1374 + $shaped = in_array( $bypass_reason, array( 'excluded-url', 'query-param' ), true )
1375 + ? ! self::path_is_a_personal_exclusion( $bypass_reason )
1376 + : in_array( $bypass_reason, self::REQUEST_SHAPE_BYPASS, true );
1377 + $reason = $shaped ? 'bypass-shape' : 'bypass';
1378 + } elseif ( self::edge_optimization_pending( $context ) ) {
1379 + $reason = 'pending';
1380 + } elseif ( 'MISS' === $status ) {
1381 + $reason = 'miss';
1382 + } elseif ( self::serves_query_variant( $status, $context ) ) {
1383 + // Last, so it only adds a hold where there was none. A variant
1384 + // still waiting for its CSS says `pending`, which is also true,
1385 + // and the CSS modules are still asked on every HIT as before.
1386 + $reason = 'query-variant';
1387 + }
1388 +
1389 + /**
1390 + * Filter: xspeed_edge_hold_reason
1391 + *
1392 + * Return '' to veto a hold, or a reason string to force one.
1393 + *
1394 + * @param string $reason '' or bypass|bypass-shape|miss|mobile-split|pending|query-variant.
1395 + * @param string $status `HIT`, `MISS` or `BYPASS`.
1396 + * @param string $context `request`, `store` or `bake`.
1397 + * @param string $bypass_reason The gate slug, for BYPASS only.
1398 + */
1399 + return (string) apply_filters( 'xspeed_edge_hold_reason', $reason, $status, $context, $bypass_reason );
1400 + }
1401 +
1402 + /**
1403 + * Was this page excluded because it is personal, or because it is
1404 + * plumbing we cannot key a cache entry on?
1405 + *
1406 + * Answers by removing the structural defaults from the site's own
1407 + * exclusion list and asking whether anything is left that matches. So a
1408 + * feed matches only `/feed/` and comes back false; `/my-account/feedback/`
1409 + * matches `/my-account` and comes back true; and on an "almost pretty"
1410 + * permalink site, where every path contains `index.php`, an ordinary page
1411 + * matches nothing else and is correctly treated as public.
1412 + *
1413 + * The path only, never the query string — a visitor writes that, and
1414 + * `/cart/?utm_source=/feed/` must not be able to talk a cart out of its
1415 + * hold. It is also what `should_cache()` matches the list against.
1416 + *
1417 + * Asked for a `query-param` bypass too, because the query gate runs
1418 + * BEFORE the URL gate, so `/cart/?add-to-cart=12` reports `query-param`
1419 + * and never reaches `excluded-url` at all. Which gate fired first says
1420 + * nothing about whose data is on the page.
1421 + */
1422 + private static function path_is_a_personal_exclusion( string $bypass_reason ): bool {
1423 + $path = self::request_path();
1424 + if ( '' === $path ) {
1425 + return false;
1426 + }
1427 +
1428 + // Through Settings_Manager, not the raw option, because the schema's
1429 + // default IS the structural list and a fresh install has never
1430 + // written the option. Read raw, every site that has not visited the
1431 + // settings screen looks like a site with no exclusions at all, takes
1432 + // the contradiction branch below, and reports its feeds as personal.
1433 + //
1434 + // Safe here where `mobile_cache_splits_html()` is not: we are only
1435 + // ever called with a bypass reason, and those come from
1436 + // `should_cache()`, which resolved the same settings through
1437 + // `Settings_Manager::get()` to produce them.
1438 + $opts = Settings_Manager::get( 'cache' );
1439 + $excluded = is_array( $opts['excluded_urls'] ?? null ) ? $opts['excluded_urls'] : array();
1440 + if ( array() === $excluded ) {
1441 + // An `excluded-url` bypass with no exclusion list is a
1442 + // contradiction — something excluded the request and the list
1443 + // cannot say what — so assume personal, because a wasted header
1444 + // costs a little origin traffic while a missing one serves
1445 + // somebody's basket to a stranger. A `query-param` bypass with an
1446 + // empty list is just an ordinary page carrying a parameter, and
1447 + // says nothing about the path at all.
1448 + return 'excluded-url' === $bypass_reason;
1449 + }
1450 +
1451 + $personal = array_values(
1452 + array_filter(
1453 + $excluded,
1454 + static fn ( $pattern ) => ! in_array( (string) $pattern, self::STRUCTURAL_EXCLUSIONS, true )
1455 + )
1456 + );
1457 +
1458 + return array() !== $personal && self::path_matches_exclusions( $personal, $path );
1459 + }
1460 +
1461 + /**
1462 + * The edge/CDN headers to send on a response with this cache status.
1463 + *
1464 + * @param string $status `HIT`, `MISS` or `BYPASS`.
1465 + * @param string $context `request` when resolved per request on the
1466 + * PHP serve path, `store` when resolved for
1467 + * one entry's sidecar, `bake` when resolved
1468 + * once and frozen into the drop-in, `rules`
1469 + * when resolved once for the nginx or Apache
1470 + * static block.
1471 + * @param string $bypass_reason The gate slug, for BYPASS only.
1472 + * @return array<string,string>
1473 + */
1474 + public static function edge_headers_for( string $status, string $context = 'request', string $bypass_reason = '' ): array {
1475 + // `rules` is a bake in every respect but one: the cache-headers
1476 + // filter is told it is answering for a pasted server rule. Such a
1477 + // rule serves every static HIT for as long as it stays pasted, and
1478 + // nothing regenerates it when the site's expiry or a page's own
1479 + // lifetime (a nonce cap) changes, so a lifetime given there cannot
1480 + // follow the page. The drop-in is rewritten by auto_heal(); a paste
1481 + // is not.
1482 + $filter_context = $context;
1483 + if ( 'rules' === $context ) {
1484 + $context = 'bake';
1485 + }
1486 +
1487 + $base = array();
1488 + if ( 'HIT' === $status ) {
1489 + /**
1490 + * Filter: xspeed_edge_cache_headers
1491 + *
1492 + * Response headers to add to a cached HTML response. The lifetime
1493 + * is HIT-only: it is a promise that this copy is worth keeping,
1494 + * and neither a first render nor a page we refused to cache is
1495 + * one. The filter is also asked with `MISS` when a first render
1496 + * is held, and only the `Cache-Tag` from that answer is sent, so
1497 + * a purge can name a copy an edge stored despite the hold.
1498 + *
1499 + * The same filter feeds four regimes and `$context` says which.
1500 + * On the PHP serve path it runs per request (`request`); at store
1501 + * time it runs for one entry (`store`); when the drop-in is
1502 + * generated it runs once (`bake`) and the result answers for
1503 + * every drop-in HIT on the site; when the nginx or Apache static
1504 + * block is generated it runs once (`rules`). Anything per-page — a
1505 + * post id in a cache tag, say — must be skipped under `bake` and
1506 + * `rules`. A server rule serves every static HIT for as long as it
1507 + * stays pasted, so a lifetime here can't follow the page: send
1508 + * none under `rules`.
1509 + *
1510 + * @param array<string,string> $headers Header name => value.
1511 + * @param string $status `HIT`, or `MISS` when only the tag of a held first render is wanted.
1512 + * @param string $context `request`, `store`, `bake` or `rules`.
1513 + */
1514 + $base = self::sanitize_edge_headers( (array) apply_filters( 'xspeed_edge_cache_headers', array(), 'HIT', $filter_context ) );
1515 + }
1516 +
1517 + $reason = self::edge_hold_reason( $status, $context, $bypass_reason );
1518 + if ( '' === $reason ) {
1519 + return $base;
1520 + }
1521 + $detected = Edge_Provider::detect( $context );
1522 + if ( Edge_Provider::is_off( $detected ) ) {
1523 + return $base;
1524 + }
1525 + if ( Edge_Provider::NONE === $detected['confidence']
1526 + && ! in_array( $reason, self::HOLD_WITHOUT_EVIDENCE, true ) ) {
1527 + return $base;
1528 + }
1529 +
1530 + $hold = Edge_Provider::hold_headers( $detected['provider'] );
1531 +
1532 + /**
1533 + * Filter: xspeed_edge_hold_headers
1534 + *
1535 + * The last word on what a hold INSTRUCTS. Runs before sanitising, so
1536 + * a value that cannot be sent as a header is still dropped, and
1537 + * before `X-XSpeed-Edge-Hold` is added, so it cannot rewrite the
1538 + * reason xSpeed held the page for — that is a diagnosis, not an
1539 + * instruction, and a forged one sends a reader after the wrong
1540 + * module.
1541 + *
1542 + * @param array<string,string> $hold Header name => value.
1543 + * @param array<string,string> $detected Provider, confidence, source.
1544 + * @param string $reason Why the hold fired.
1545 + * @param string $context `request`, `store` or `bake`.
1546 + */
1547 + $hold = (array) apply_filters( 'xspeed_edge_hold_headers', $hold, $detected, $reason, $context );
1548 +
1549 + // A hold replaces the lifetime rather than sitting beside it: the two
1550 + // describe the same response and would contradict each other. The
1551 + // cache tag survives, because a later purge still has to be able to
1552 + // name whatever the edge picked up on its own terms.
1553 + //
1554 + // On a HIT the tag came with `$base`. A MISS has no `$base`, and that
1555 + // is the case that mattered most: the first render after any purge.
1556 + // An edge whose rule overrides the origin's lifetime stores it
1557 + // despite the hold, and with no tag on it the next tag purge cannot
1558 + // reach it, so an edit stayed stale for the edge's whole lifetime
1559 + // (QA, 2026-09-23). So a held MISS asks the same filter for its tag
1560 + // and keeps the tag alone; the lifetime in the answer is dropped,
1561 + // because the hold is the instruction for this response.
1562 + $tag = $base['Cache-Tag'] ?? '';
1563 + if ( '' === $tag && 'MISS' === $status ) {
1564 + $tagged = self::sanitize_edge_headers( (array) apply_filters( 'xspeed_edge_cache_headers', array(), 'MISS', $filter_context ) );
1565 + $tag = $tagged['Cache-Tag'] ?? '';
1566 + }
1567 + if ( '' !== $tag ) {
1568 + $hold['Cache-Tag'] = $tag;
1569 + }
1570 +
1571 + // Never argue with a stronger answer WordPress already gave. It sends
1572 + // `no-store, private` of its own accord on a logged-in, 404 or
1573 + // password-protected response, from WP::send_headers() — which runs
1574 + // before template_redirect, so it is already on the wire by the time
1575 + // we get here. Ours is the weaker statement of the two; replacing it
1576 + // would be a downgrade dressed as a fix. Only meaningful per request:
1577 + // a bake has no response to inspect.
1578 + if ( 'request' === $context && isset( $hold['Cache-Control'] ) && self::cache_control_already_stronger() ) {
1579 + unset( $hold['Cache-Control'] );
1580 + }
1581 +
1582 + // A page we refused to cache must not carry a validator either. A
1583 + // `Last-Modified` left on it invites a conditional request, and a
1584 + // shared cache that gets a 304 back serves the copy it should not
1585 + // have stored. Only on a bypass, and only per request: a MISS is
1586 + // about to be stored by us, so its validator is ours to keep.
1587 + if ( 'request' === $context && 'bypass' === $reason && ! headers_sent() ) {
1588 + header_remove( 'Last-Modified' );
1589 + }
1590 +
1591 + $hold = self::sanitize_edge_headers( $hold );
1592 +
1593 + // Name the reason in the hold set itself, rather than sending it
1594 + // separately from mark().
1595 + //
1596 + // "Why is my page not being cached at the edge?" is the question this
1597 + // answers, and mark() could only answer it on the PHP serve path. The
1598 + // other emitters send whatever this function returns and never ran
1599 + // mark() at all — so the responses hardest to explain went out
1600 + // carrying `no-store` with nothing beside it to say why. Chiefly the
1601 + // drop-in, which serves from the `.meta` sidecar written under
1602 + // `store` and from the literal baked under `bake`, before plugins
1603 + // load and with no way to re-ask (the symptom
1604 + // edge_optimization_pending() describes above).
1605 + //
1606 + // The nginx and Apache blocks are a third path in principle and
1607 + // almost never in practice: they are only installed when
1608 + // static_rewrite_allowed() is true, and the one reason a stock site
1609 + // can hold in their bake is `mobile-split`, which is exactly what
1610 + // makes that false. `query-variant` is baked for the drop-in alone,
1611 + // since both blocks refuse a query string. They will carry a hold
1612 + // where a site forces one through `xspeed_edge_hold_reason`, and
1613 + // otherwise have none to carry.
1614 + //
1615 + // Added AFTER sanitising and banned in NEVER_AN_EDGE_HEADER, so
1616 + // neither of the two filters above can forge a reason or suppress the
1617 + // real one.
1618 + //
1619 + // Reduced to the slug CHARACTER CLASS, not checked against the six
1620 + // slugs: `xspeed_edge_hold_reason` is documented as able to force a
1621 + // reason, and a site that forces its own deserves to see it. What is
1622 + // not negotiable is the shape, because this value reaches an
1623 + // .htaccess and an nginx conf as well as a response header — so no
1624 + // CR/LF, no `$`, no `%`, no `\`, and a length a config file can hold.
1625 + $slug = preg_replace( '/[^a-z0-9-]/', '', strtolower( $reason ) );
1626 + if ( is_string( $slug ) && '' !== $slug ) {
1627 + $hold['X-XSpeed-Edge-Hold'] = substr( $slug, 0, 32 );
1628 + }
1629 +
1630 + return $hold;
1631 + }
1632 +
1633 + /** Has something already sent a Cache-Control at least as strict as ours? */
1634 + private static function cache_control_already_stronger(): bool {
1635 + foreach ( headers_list() as $line ) {
1636 + if ( 0 !== stripos( $line, 'cache-control:' ) ) {
1637 + continue;
1638 + }
1639 + if ( preg_match( '/\b(?:no-store|private)\b/i', $line ) ) {
1640 + return true;
1641 + }
1642 + }
1643 +
1644 + return false;
1645 + }
1646 +
1647 + /**
1648 + * Edge headers that belong to THIS page rather than to every page.
1649 + *
1650 + * `edge_headers_for('HIT','bake')` is the answer frozen into the drop-in
1651 + * and the server rules: one set, serving the whole site. But the answer
1652 + * for one URL can legitimately differ — a page whose deferred work is
1653 + * still outstanding, say — and that answer has nowhere to live, because
1654 + * the baked set is all the fast paths know about.
1655 + *
1656 + * So ask again in a `store` context, with the request still in scope, and
1657 + * return the pairs only when they differ from the baked ones. Identical is
1658 + * the overwhelmingly common case and writes nothing: pages do not pay a
1659 + * sidecar for an answer the drop-in already has.
1660 + *
1661 + * Memoised because two callers ask within one store — the sidecar writer
1662 + * and the static-tree guard — and the filters behind it are not required
1663 + * to be cheap.
1664 + *
1665 + * A page with a lifetime of its own also gets its own pairs, whenever
1666 + * they grant an edge any lifetime at all. The lifetime is cut to the
1667 + * page's (see cap_edge_lifetime()), and the pairs are returned even when
1668 + * the cut leaves them equal to the baked ones. Returning them keeps the
1669 + * page off the static tree, and it has to: only the two PHP paths can
1670 + * count a lifetime down as the copy ages. The web server sends the baked
1671 + * value from whatever age the file has reached, so a page capped to its
1672 + * nonce would reach the edge with hours of dead nonce still to serve.
1673 + *
1674 + * write_meta() resolves that lifetime and passes it on the first call.
1675 + * The static-tree guards ask afterwards and read the memo. Both store
1676 + * paths call write_meta() before their guard.
1677 + *
1678 + * @param int|null $own_ttl The entry's own lifetime in seconds, when it
1679 + * differs from the site's (the sidecar `ttl`).
1680 + * @return array<string,string> Empty when this page needs no override.
1681 + */
1682 + private static function per_entry_edge_headers( ?int $own_ttl = null ): array {
1683 + if ( is_array( self::$per_entry_edge ) ) {
1684 + return self::$per_entry_edge;
1685 + }
1686 + $baked = self::edge_headers_for( 'HIT', 'bake' );
1687 + $request = self::edge_headers_for( 'HIT', 'store' );
1688 + if ( null !== $own_ttl && self::grants_edge_lifetime( $request ) ) {
1689 + self::$per_entry_edge = self::cap_edge_lifetime( $request, $own_ttl );
1690 + return self::$per_entry_edge;
1691 + }
1692 + self::$per_entry_edge = ( $request === $baked ) ? array() : $request;
1693 +
1694 + return self::$per_entry_edge;
1695 + }
1696 +
1697 + /**
1698 + * Cut every lifetime an edge header grants down to `$seconds`.
1699 + *
1700 + * The lifetime in a HIT's pairs comes from `xspeed_edge_cache_headers`
1701 + * and describes the site, so it says nothing of a page whose own lifetime
1702 + * is shorter: one carrying a nonce (#236), one with a per-post expiry,
1703 + * one a `xspeed_cache_max_age` filter shortened. xSpeed rebuilds that page
1704 + * on time, and an edge holding it for the site's lifetime goes on serving
1705 + * the old copy anyway. Nothing tells the edge when our copy expires,
1706 + * because an expiry is not a purge. For a nonce page that is a broken
1707 + * form for every visitor until the next purge.
1708 + *
1709 + * Only shortens. A `max-age` or `s-maxage` already under `$seconds` is
1710 + * kept, so a hold's `s-maxage=0` stays 0. Only headers named `*-Control`
1711 + * are touched; a `Cache-Tag` that happens to contain the text is not a
1712 + * directive.
1713 + *
1714 + * @param array<string,string> $headers Header name => value.
1715 + * @param int $seconds The most any of them may grant.
1716 + * @return array<string,string>
1717 + */
1718 + public static function cap_edge_lifetime( array $headers, int $seconds ): array {
1719 + $seconds = max( 0, $seconds );
1720 + foreach ( $headers as $name => $value ) {
1721 + if ( ! preg_match( self::EDGE_LIFETIME_HEADER, (string) $name ) ) {
1722 + continue;
1723 + }
1724 + $capped = preg_replace_callback(
1725 + self::EDGE_LIFETIME_DIRECTIVE,
1726 + static function ( array $m ) use ( $seconds ): string {
1727 + return $m[1] . '=' . min( (int) $m[2], $seconds );
1728 + },
1729 + (string) $value
1730 + );
1731 + if ( is_string( $capped ) ) {
1732 + $headers[ $name ] = $capped;
1733 + }
1734 + }
1735 +
1736 + return $headers;
1737 + }
1738 +
1739 + /**
1740 + * Does any `*-Control` header in the set let an edge keep the response?
1741 + *
1742 + * @param array<string,string> $headers Header name => value.
1743 + */
1744 + private static function grants_edge_lifetime( array $headers ): bool {
1745 + foreach ( $headers as $name => $value ) {
1746 + if ( ! preg_match( self::EDGE_LIFETIME_HEADER, (string) $name ) ) {
1747 + continue;
1748 + }
1749 + if ( preg_match_all( self::EDGE_LIFETIME_DIRECTIVE, (string) $value, $m ) ) {
1750 + foreach ( $m[2] as $seconds ) {
1751 + if ( (int) $seconds > 0 ) {
1752 + return true;
1753 + }
1754 + }
1755 + }
1756 + }
1757 +
1758 + return false;
1759 + }
1760 +
1761 + /**
1762 + * Seconds a cached entry has left, when it has a lifetime of its own.
1763 + *
1764 + * Read from the sidecar `ttl`, which write_meta() records only when the
1765 + * entry's lifetime differs from the site's. That is the drop-in's rule
1766 + * too, so both PHP serve paths count down the same entries. Null for an
1767 + * entry that follows the site's lifetime: what the edge is told about
1768 + * those is the site's lifetime, unchanged.
1769 + *
1770 + * @param array<string,mixed> $meta The entry's sidecar, from read_meta().
1771 + * @param string $file The cached file being served.
1772 + */
1773 + private static function entry_lifetime_left( array $meta, string $file ): ?int {
1774 + $ttl = isset( $meta['ttl'] ) ? (int) $meta['ttl'] : 0;
1775 + if ( $ttl < 1 ) {
1776 + return null;
1777 + }
1778 + $mtime = file_exists( $file ) ? filemtime( $file ) : false;
1779 + if ( false === $mtime ) {
1780 + return null;
1781 + }
1782 +
1783 + return max( 0, $ttl - ( time() - (int) $mtime ) );
1784 + }
1785 +
1786 + /**
1787 + * Render baked pairs as a PHP array literal for the drop-in.
1788 + *
1789 + * Single-quoted literals with quotes escaped, because the result is
1790 + * written into a PHP file that must still parse. Values reaching here
1791 + * have already been through sanitize_edge_headers(), so neither name nor
1792 + * value can carry a newline.
1793 + *
1794 + * @param array<string,string> $headers Name => value.
1795 + */
1796 + private static function edge_headers_literal( array $headers ): string {
1797 + if ( array() === $headers ) {
1798 + return 'array()';
1799 + }
1800 + // var_export(), not hand-rolled quoting. A single-quoted PHP string
1801 + // escapes BOTH `'` and `\\`, and escaping only the first is how a
1802 + // value ending in a backslash — `X-Foo: C:\path\` from the custom
1803 + // headers box — leaves the literal unterminated. That file is
1804 + // included on every request once WP_CACHE is on, so the result is a
1805 + // parse error on the front end AND in wp-admin, with no way back
1806 + // except deleting the file over SSH.
1807 + $parts = array();
1808 + foreach ( $headers as $name => $value ) {
1809 + $parts[] = var_export( (string) $name, true ) . ' => ' . var_export( (string) $value, true );
1810 + }
1811 +
1812 + return 'array( ' . implode( ', ', $parts ) . ' )';
1813 + }
1814 +
1815 + /**
1816 + * Quote a header value for an nginx / Apache directive.
1817 + *
1818 + * Both accept a double-quoted string with backslash escapes, and both
1819 + * refuse to load a config where the quoting is wrong — a mis-escaped
1820 + * value takes the whole vhost down, not just this header.
1821 + */
1822 + private static function quote_directive_value( string $value ): string {
1823 + return str_replace( array( '\\', '"' ), array( '\\\\', '\\"' ), $value );
1824 + }
1825 +
1826 + /**
1827 + * The same directive twice — once per name Apache can expose the
1828 + * rewrite's environment variable under.
1829 + *
1830 + * `RewriteRule ... [E=XSPEED_STATIC_HIT:1]` in a per-directory context is
1831 + * an INTERNAL REDIRECT: Apache re-enters the request with the substituted
1832 + * path, and every variable set on the first pass is renamed with a
1833 + * `REDIRECT_` prefix for the second. `env=XSPEED_STATIC_HIT` is evaluated
1834 + * on that second pass, where nothing answers to that name any more, so
1835 + * the directive never fires — dropping the headers from precisely the
1836 + * responses they exist for. The rules marker rides on the same gate, so
1837 + * the probe also read its own marker as missing and called correctly
1838 + * installed rules stale.
1839 + *
1840 + * It cannot be written once: `env=` takes a single name with no
1841 + * alternation, and `expr=` — which could express both — is not dependable
1842 + * on LiteSpeed, which reads this same block. So both are emitted; the one
1843 + * whose variable is unset on a given pass does nothing.
1844 + *
1845 + * @param string $directive The directive, without its `env=` clause.
1846 + * @return string[]
1847 + */
1848 + private static function static_hit_directives( string $directive ): array {
1849 + return array(
1850 + $directive . ' env=XSPEED_STATIC_HIT',
1851 + $directive . ' env=REDIRECT_XSPEED_STATIC_HIT',
1852 + );
1853 + }
1854 +
1855 + /**
1856 + * Keep only pairs that can be sent as a header verbatim.
1857 + *
1858 + * These values reach three different emitters — PHP's header(), an nginx
1859 + * `add_header` and an Apache `Header always set` — so a name with a space
1860 + * or a value carrying CR/LF is not merely malformed, it is a
1861 + * response-splitting vector in the first and a broken server config in
1862 + * the other two. Names must be token-shaped; values lose CR/LF and are
1863 + * dropped if nothing survives.
1864 + *
1865 + * @param array<mixed,mixed> $headers Raw pairs.
1866 + * @return array<string,string>
1867 + */
1868 + public static function sanitize_edge_headers( array $headers ): array {
1869 + $clean = array();
1870 + foreach ( $headers as $name => $value ) {
1871 + // Never let one of these through, whoever asked. They describe the
1872 + // transfer rather than the caching policy, and getting one wrong
1873 + // from a settings field is a white screen: `Content-Encoding: gzip`
1874 + // on an uncompressed body, a `Content-Length` that disagrees with
1875 + // the bytes. `X-XSpeed-Cache` is ours and a second copy would lie
1876 + // to whoever reads it.
1877 + if ( is_string( $name ) && in_array( strtolower( $name ), self::NEVER_AN_EDGE_HEADER, true ) ) {
1878 + continue;
1879 + }
1880 + // `\z`, not `$`: PCRE's `$` also matches immediately BEFORE a
1881 + // trailing newline, so "Cache-Tag\n" passes a `$` check and gets
1882 + // concatenated raw into the generated .htaccess — splitting one
1883 + // Header directive across two lines, which is a syntax error
1884 + // Apache reports as a 500 on every request while `httpd -t` stays
1885 + // green (.htaccess is parsed per request, not at load).
1886 + if ( ! is_string( $name ) || ! preg_match( '/^[A-Za-z0-9-]+\z/', $name ) ) {
1887 + continue;
1888 + }
1889 + if ( ! is_string( $value ) && ! is_numeric( $value ) ) {
1890 + continue;
1891 + }
1892 + $value = trim( str_replace( array( "\r", "\n" ), '', (string) $value ) );
1893 + if ( '' === $value ) {
1894 + continue;
1895 + }
1896 + // `$` is a variable reference in an nginx string and `%` is a
1897 + // format tag to Apache's mod_headers, which rejects an
1898 + // unrecognised one — in .htaccess that is a 500 on every request
1899 + // while `httpd -t` still reports OK, because .htaccess is parsed
1900 + // per request. `\` escapes the quote in the PHP literal baked into
1901 + // the drop-in. None of them can be escaped reliably in all three
1902 + // places at once, and nothing a cache reads needs any of them, so
1903 + // the value is dropped rather than mangled.
1904 + if ( preg_match( '/[$%\\\\]/', $value ) ) {
1905 + continue;
1906 + }
1907 + $clean[ $name ] = $value;
1908 + }
1909 +
1910 + return $clean;
1911 + }
1912 +
1913 + /**
1914 + * Bypass gates that describe THE VISITOR rather than THIS REQUEST.
1915 + *
1916 + * Only these may be recorded in the bypass cookie. A visitor-scoped
1917 + * verdict stays true for the visitor's next request — they are still
1918 + * logged in, still hold a cart cookie — so the web server can act on
1919 + * it without booting PHP.
1920 + *
1921 + * Every other gate describes the request in front of us: its method,
1922 + * its URL, its query string, the client's user agent. Persisting one
1923 + * of those pins a visitor to the uncached path over a property that
1924 + * was never theirs to begin with. (#218)
1925 + */
1926 + private const VISITOR_SCOPED_BYPASS = array( 'logged-in', 'excluded-cookie' );
1927 +
1928 + /**
1929 + * Whether $reason describes the visitor (persist it) or merely this
1930 + * request (don't).
1931 + *
1932 + * Split out as a pure function because it is the whole decision behind
1933 + * the bypass cookie, and the cookie write itself (setcookie()) can't be
1934 + * asserted in a unit test.
1935 + */
1936 + public static function bypass_is_visitor_scoped( string $reason ): bool {
1937 + return in_array( $reason, self::VISITOR_SCOPED_BYPASS, true );
1938 + }
1939 +
65 1940 public function maybe_start_cache() {
66 1941 if ( ! self::should_cache() ) {
1942 + // PHP has just evaluated the FULL exclusion rule list — including
1943 + // the `~regex` patterns the server config can't express — and
1944 + // decided this response must not be served from cache. Record that
1945 + // verdict in the conventional bypass cookie so the web server can
1946 + // enforce it on subsequent requests without starting PHP.
1947 + //
1948 + // This is what stops most settings changes from needing an nginx
1949 + // reload: the config tests one fixed cookie name forever, and the
1950 + // rule list behind it can change freely.
1951 + //
1952 + // But ONLY when the verdict is about the visitor. A request-shape
1953 + // gate — `non-get` above all — says nothing about who is asking,
1954 + // and persisting it pinned that visitor to the uncached path for
1955 + // the rest of their session: one search-form POST, one comment,
1956 + // one `curl -I` from an uptime monitor, and every later GET
1957 + // bypassed. It could not self-heal either, because the bypass
1958 + // cookie is itself in excluded_cookies, so the next GET bypassed
1959 + // with `excluded-cookie` and landed right back here, where
1960 + // sync_bypass_cookie()'s no-change short-circuit left the cookie
1961 + // exactly where it was. (#218)
1962 + if ( self::bypass_is_visitor_scoped( self::bypass_reason() ) ) {
1963 + self::sync_bypass_cookie( true );
1964 + }
67 1965 return;
68 1966 }
69 1967
1968 + // Cacheable: clear any stale bypass cookie, or a visitor who once
1969 + // had a cart would keep skipping the fast path long after checkout.
1970 + self::sync_bypass_cookie( false );
1971 +
70 1972 $key = self::cache_key();
71 1973 $file = self::cache_file_for( $key );
72 1974
73 1975 if ( file_exists( $file ) && ! self::is_expired( $file ) ) {
74 - Hit_Counter::record_hit();
1976 + // Symmetric with the miss branch below: a bot, scanner or one of
1977 + // xSpeed's own warm/benchmark requests that lands a HIT must not
1978 + // inflate the ratio either — excluding only their misses would
1979 + // shrink the denominator while their hits kept feeding the
1980 + // numerator, making the displayed ratio MORE optimistic than
1981 + // before the exclusion existed.
1982 + if ( self::miss_is_excluded() ) {
1983 + Hit_Counter::record_excluded();
1984 + } else {
1985 + Hit_Counter::record_hit();
1986 + }
75 1987 // Emit the HIT marker on THIS path too. The drop-in
76 1988 // (advanced-cache.php) sends "HIT (php)" and the nginx static
77 1989 // rewrite sends "HIT (nginx)", but this template_redirect
78 1990 // serve path — the one that runs when the drop-in isn't loaded
@@ -78,16 +1990,18 @@
78 1990 // serve path — the one that runs when the drop-in isn't loaded
79 1991 // (e.g. WP_CACHE not true) — previously streamed the cached
80 1992 // file with NO marker, so a genuine HIT looked like a MISS in
81 1993 // the response headers. Same header + value as the drop-in.
82 - if ( ! headers_sent() ) {
83 - header( 'X-XSpeed-Cache: HIT (php)' );
84 - }
1994 + //
1995 + // The sidecar is read first because the HIT's edge lifetime
1996 + // depends on it: an entry with a lifetime of its own may not be
1997 + // kept at the edge past it. See entry_lifetime_left().
1998 + $meta = self::read_meta( $key );
1999 + self::mark( 'HIT (php)', '', self::entry_lifetime_left( $meta, $file ) );
85 2000 // Replay stored response bits so the HIT matches the original:
86 2001 // a non-HTML Content-Type (cached feeds, sitemaps) and a non-200
87 2002 // status (a cached 404 must serve 404, not 200). No-op for
88 2003 // ordinary pages, which write no .meta.
89 - $meta = self::read_meta( $key );
90 2004 if ( ! headers_sent() ) {
91 2005 if ( ! empty( $meta['status'] ) && function_exists( 'http_response_code' ) ) {
92 2006 http_response_code( (int) $meta['status'] );
93 2007 }
@@ -126,16 +2040,33 @@
126 2040 // Apache. See maybe_emit_lscache_headers() for the full rationale.
127 2041 self::maybe_emit_lscache_headers();
128 2042
129 2043 // We're about to render fresh + cache → miss for this request.
130 - Hit_Counter::record_miss();
2044 + // …UNLESS this request is a 404 or a known bot/scanner. Those reach the
2045 + // render path too, but counting them as cache misses makes the ratio
2046 + // meaningless — a wave of `/wp-x7.php` scanner 404s reads as a collapsing
2047 + // cache when nothing is wrong. Runs at template_redirect (priority 0), so
2048 + // is_404() is already resolved. Excluded requests are tallied separately
2049 + // for the "you absorbed N scanner hits" line, not dropped. (#118)
2050 + if ( self::miss_is_excluded() ) {
2051 + Hit_Counter::record_excluded();
2052 + } else {
2053 + Hit_Counter::record_miss();
2054 + }
131 2055
2056 + // Stamp it, so "eligible but not cached yet" is visibly different
2057 + // from "deliberately bypassed" (issue #10). Headers can't be sent
2058 + // after the body starts, so this has to happen here, not in
2059 + // finalize_buffer() — nothing has been output at template_redirect.
2060 + self::mark( 'MISS' );
132 2061
2062 +
133 2063 // WP < 6.9 fallback: ob_start() with a callback, paired with an
134 2064 // explicit shutdown close so the buffer lifecycle is visible to
135 2065 // reviewers and Plugin Check, instead of relying on PHP's implicit
136 2066 // request-end flush. We record our nesting level so close_buffer()
137 2067 // flushes ONLY the buffer we opened.
2068 + self::$asset_stamp_at_open = class_exists( '\\XSpeed\\Minifier' ) ? Minifier::purge_stamp() : null;
138 2069 ob_start( array( __CLASS__, 'finalize_buffer' ) );
139 2070 self::$buffer_level = ob_get_level();
140 2071
141 2072 add_action( 'shutdown', array( __CLASS__, 'close_buffer' ), 0 );
@@ -159,20 +2090,214 @@
159 2090 }
160 2091 self::$buffer_level = null;
161 2092 }
162 2093
2094 + /**
2095 + * Are we buffering this request?
2096 + *
2097 + * Asked by Css_Combine_Buffer, which needs the finished HTML but must not
2098 + * open a second buffer when this one is already going to hand it the page
2099 + * through `xspeed_cache_final_html`. False here means the request is not
2100 + * cacheable — cache off, excluded URL, logged in — and the combiner has to
2101 + * provide its own buffer or it silently stops working. (#195)
2102 + */
2103 + public static function is_buffering(): bool {
2104 + return null !== self::$buffer_level;
2105 + }
2106 +
2107 + /**
2108 + * Is a render-time translation plugin going to wrap our output buffer?
2109 + *
2110 + * TranslatePress opens its translation buffer on `init` priority 0. We
2111 + * open ours on `template_redirect`, which runs much later, so ours nests
2112 + * INSIDE theirs. PHP unwinds output buffers LIFO — innermost callback
2113 + * first — so `finalize_buffer()` saw the raw, pre-translation HTML and
2114 + * cached that, while the live visitor still got the translated bytes from
2115 + * TRP's outer buffer.
2116 + *
2117 + * Result: the first (MISS) visitor to /fr/some-page/ got correct French;
2118 + * every visitor after got English body text under a `lang="fr-FR"`
2119 + * document, plus TRP's internal `#TRPLINKPROCESSED` link markers, which
2120 + * TRP strips at the very end of its own buffer and which therefore leak
2121 + * into anything captured from inside it.
2122 + *
2123 + * Note the ordering cannot be fixed from TRP's side: its
2124 + * `trp_start_output_buffer_priority` filter only moves the PRIORITY on
2125 + * `init`, and `init` always fires before `template_redirect` whatever the
2126 + * priority. The buffer that has to move is ours.
2127 + *
2128 + * Detected by main class rather than plugin path, so a renamed directory
2129 + * or a bundled copy still matches.
2130 + */
2131 + public static function translation_plugin_active(): bool {
2132 + $active = class_exists( 'TRP_Translate_Press' );
2133 +
2134 + /**
2135 + * Whether to treat this request as wrapped by a translation buffer.
2136 + *
2137 + * Lets a site add another render-time translation plugin (or opt out)
2138 + * without patching the engine.
2139 + *
2140 + * @param bool $active
2141 + */
2142 + return (bool) apply_filters( 'xspeed_translation_plugin_active', $active );
2143 + }
2144 +
2145 + /**
2146 + * Write the cache file for a request whose output was wrapped by a
2147 + * render-time translation plugin.
2148 + *
2149 + * Registered as a PHP shutdown function (not a WP `shutdown` action) so
2150 + * it runs after PHP has unwound the output-buffer stack — by which point
2151 + * the translation plugin's callback has transformed the bytes and its
2152 + * internal markers are gone.
2153 + *
2154 + * finalize_buffer() has already applied the status gate, the
2155 + * xspeed_cache_final_html filter and HTML minification to the
2156 + * untranslated copy and then declined to write it. Here we re-run only
2157 + * what's needed on the translated bytes: minify, write, and fire the
2158 + * same downstream hooks so Brotli / static-tree listeners behave
2159 + * identically to the ordinary path.
2160 + */
2161 + public static function write_deferred_translated_cache(): void {
2162 + $key = self::$deferred_key;
2163 + self::$deferred_key = null;
2164 +
2165 + // Release the collected bytes BEFORE the early return, so the static
2166 + // is cleared on every path rather than only when a key survived.
2167 + $full = self::$translated_output;
2168 + self::$translated_output = '';
2169 +
2170 + $completed = self::$render_completed;
2171 + self::$render_completed = false;
2172 +
2173 + $asset_stamp = self::$deferred_asset_stamp;
2174 + self::$deferred_asset_stamp = null;
2175 +
2176 + if ( null === $key ) {
2177 + return;
2178 + }
2179 +
2180 + // Did the render actually finish?
2181 + //
2182 + // This runs as a PHP shutdown function, which fires after a wp_die()
2183 + // or a bare exit() just as readily as after a clean render — but in
2184 + // those cases finalize_buffer() never returned, so the bytes we hold
2185 + // are a page that was cut off partway through. The length and
2186 + // TRPLINKPROCESSED checks below don't catch that: a fatal after the
2187 + // footer's translated markup is both over 255 bytes and free of TRP
2188 + // markers, i.e. truncated but entirely plausible. Caching it would
2189 + // freeze a half-rendered page under the real key for the full TTL.
2190 + //
2191 + // Serving this one URL uncached is the cheap failure; the corrupt
2192 + // cache entry is the expensive one.
2193 + if ( ! $completed ) {
2194 + return;
2195 + }
2196 +
2197 + if ( strlen( $full ) < 255 ) {
2198 + return;
2199 + }
2200 +
2201 + // Refuse to cache a copy still carrying the translation plugin's
2202 + // internal link markers. TRP strips these at the very end of its own
2203 + // buffer, so their presence means we captured too early — and a
2204 + // cached page containing them is SEO-visible damage. Better to serve
2205 + // this URL uncached than to freeze broken markup for the full TTL.
2206 + if ( false !== strpos( $full, 'TRPLINKPROCESSED' ) ) {
2207 + return;
2208 + }
2209 +
2210 + $minify_opts = Settings_Manager::get( 'minify' );
2211 + if ( ! empty( $minify_opts['minify_html'] ) ) {
2212 + $full = Minifier::minify_html( $full );
2213 + }
2214 + $full = self::signed( $full );
2215 +
2216 + // Per-site directory: on multisite every blog shares this tree, so
2217 + // entries are bucketed by host to keep one site's purge from
2218 + // sweeping the whole network. (#6)
2219 + self::ensure_host_dir();
2220 +
2221 + // Never author a cache entry from a request that carried a query
2222 + // string: cache_key() files it under the BARE url, so the params'
2223 + // render would be served to every clean-URL visitor (#241).
2224 + if ( self::query_string_blocks_write() ) {
2225 + return;
2226 + }
2227 +
2228 + // Same guard as finalize_buffer(): the translation plugin's buffer
2229 + // adds time between render and store, not less.
2230 + if ( self::assets_purged_since( $asset_stamp ) ) {
2231 + return;
2232 + }
2233 +
2234 + $file = self::cache_file_for( $key );
2235 + // 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.
2236 + file_put_contents( $file, $full, LOCK_EX );
2237 +
2238 + /** This action is documented in includes/class-cache.php */
2239 + do_action( 'xspeed_flat_file_written', $file, $full );
2240 +
2241 + self::write_meta( $key, $full );
2242 +
2243 + // Static tree too, under the same gates finalize_buffer() applies —
2244 + // otherwise deferring the write would silently cost translated pages
2245 + // the web-server fast path and leave them on the slower drop-in.
2246 + // The static tree cannot replay a sidecar. A file served straight by
2247 + // the web server carries the headers baked into the rule that serves
2248 + // the whole site — the very answer this entry exists because it
2249 + // disagreed with. Same reasoning as the status and content-type
2250 + // cases: what the fast path cannot replay belongs on the drop-in path.
2251 + if ( self::static_rewrite_allowed()
2252 + && self::response_is_plain_html()
2253 + && array() === self::per_entry_edge_headers() ) {
2254 + self::store_static( $full );
2255 + }
2256 + }
2257 +
2258 + /**
2259 + * Did a purge delete minified or combined files after $stamp was taken?
2260 + *
2261 + * @param string|null $stamp Minifier::purge_stamp() when the render began,
2262 + * or null when no snapshot was taken.
2263 + */
2264 + private static function assets_purged_since( ?string $stamp ): bool {
2265 + if ( null === $stamp || ! class_exists( '\\XSpeed\\Minifier' ) ) {
2266 + return false;
2267 + }
2268 + return Minifier::purge_stamp() !== $stamp;
2269 + }
2270 +
163 2271 public static function should_cache() {
2272 + // Reset first: a single request only reaches this once (the sole
2273 + // caller is maybe_start_cache()), but tests and any future caller
2274 + // must never inherit the previous request's verdict.
2275 + self::$status_header = '';
2276 + self::$bypass_reason = '';
2277 + self::$edge_headers = array();
2278 + self::$per_entry_edge = null;
2279 + // Under PHP-FPM a process serves one request and this is moot. Under
2280 + // a persistent worker runtime it is not: without it, an answer
2281 + // resolved from one visitor's forgeable headers would be reused for
2282 + // every later request the worker handles.
2283 + Edge_Provider::forget();
2284 +
164 2285 $opts = Settings::get();
165 2286 if ( empty( $opts['cache_enabled'] ) ) {
166 - return false;
2287 + return self::bypass( 'cache-disabled' );
167 2288 }
168 2289
169 - if ( is_user_logged_in() || is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
170 - return false;
2290 + if ( is_user_logged_in() ) {
2291 + return self::bypass( 'logged-in' );
171 2292 }
172 2293
2294 + if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
2295 + return self::bypass( 'non-frontend' );
2296 + }
2297 +
173 2298 if ( defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE ) {
174 - return false;
2299 + return self::bypass( 'donotcachepage' );
175 2300 }
176 2301
177 2302 // All exclusion knobs now owned by CacheModule.
178 2303 $cache_opts = Settings_Manager::get( 'cache' );
@@ -178,9 +2303,9 @@
178 2303 $cache_opts = Settings_Manager::get( 'cache' );
179 2304
180 2305 $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : '';
181 2306 if ( 'GET' !== $method ) {
182 - return false;
2307 + return self::bypass( 'non-get' );
183 2308 }
184 2309
185 2310 // Search-results requests carry a `s` query param, which the
186 2311 // query-string gate below would normally reject as "dynamic". An
@@ -207,13 +2332,35 @@
207 2332 * @param bool $cache_feed Whether to cache this feed request.
208 2333 */
209 2334 $cache_feed = $is_feed_request && (bool) apply_filters( 'xspeed_should_cache_feed', false );
210 2335
2336 + // WordPress's virtual robots.txt (and virtual favicon) are not HTML:
2337 + // caching one runs it through the whole HTML pipeline, which stamped
2338 + // the footer comment onto text/plain and let HTML minification
2339 + // collapse robots.txt to a single line — a line-based format, so
2340 + // every directive after the first was lost and crawlers read an
2341 + // invalid file. No opt-in filter here: there is no correct way to
2342 + // treat these as pages. (Reported live on a customer site.)
2343 + if ( ( function_exists( 'is_robots' ) && is_robots() )
2344 + || ( function_exists( 'is_favicon' ) && is_favicon() ) ) {
2345 + return self::bypass( 'non-html' );
2346 + }
2347 +
211 2348 // Query string handling: anything OUTSIDE the ignored-params
212 2349 // allow-list (utm_*, fbclid, gclid by default) means a unique
213 2350 // request that we don't want to share with the canonical cache
214 2351 // entry. Skip cache rather than poison the key.
215 - $query_raw = isset( $_SERVER['QUERY_STRING'] ) ? sanitize_text_field( wp_unslash( $_SERVER['QUERY_STRING'] ) ) : '';
2352 + //
2353 + // Parse the RAW query string, NOT a sanitize_text_field() copy:
2354 + // that filter strips percent-encoded octets (%XX), so `?%73=…`
2355 + // would lose its `s` key here while WordPress still decodes it to
2356 + // a search request — the gate would wave the request through and
2357 + // cache_key() would file the search page under the bare URL,
2358 + // letting an attacker poison the homepage cache with `/?%73=<spam>`.
2359 + // parse_str() does its own urldecoding, matching WP's own parse, and
2360 + // only the KEYS are used below (fed to Glob_Matcher → preg_match,
2361 + // never echoed or executed), so no sanitization is needed here.
2362 + $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.
216 2363 if ( '' !== $query_raw ) {
217 2364 $ignored = is_array( $cache_opts['ignored_query_params'] ?? null ) ? $cache_opts['ignored_query_params'] : array();
218 2365 parse_str( $query_raw, $params );
219 2366 foreach ( $params as $key => $_ ) {
@@ -222,23 +2369,24 @@
222 2369 continue;
223 2370 }
224 2371 // Allow query-form feed params through when feed caching opted
225 2372 // this request in (?feed=rss2 / &withcomments=1 on feeds).
226 - if ( $cache_feed && in_array( $key, array( 'feed', 'withcomments', 'withoutcomments' ), true ) ) {
2373 + if ( $cache_feed && in_array( $key, self::FEED_QUERY_PARAMS, true ) ) {
227 2374 continue;
228 2375 }
229 2376 if ( ! self::query_key_is_ignored( (string) $key, $ignored ) ) {
230 - return false;
2377 + // Slug only — never the param name, which is attacker-
2378 + // controlled and would be reflected into a header.
2379 + return self::bypass( 'query-param' );
231 2380 }
232 2381 }
233 2382 }
234 2383
235 - $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
236 - $path = (string) strtok( $request_uri, '?' );
2384 + $path = self::request_path();
237 2385
238 2386 $excluded_urls = is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array();
239 - if ( ! $cache_feed && Glob_Matcher::any_match( $excluded_urls, $path ) ) {
240 - return false;
2387 + if ( ! $cache_feed && self::path_matches_exclusions( $excluded_urls, $path ) ) {
2388 + return self::bypass( 'excluded-url' );
241 2389 }
242 2390
243 2391 // Cookie-based exclusion. We only check cookie NAMES (matching
244 2392 // values would leak content-sensitive logic into the cache key
@@ -245,10 +2393,21 @@
245 2393 // rules); presence of any matching cookie name skips cache.
246 2394 $excluded_cookies = is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array();
247 2395 if ( ! empty( $excluded_cookies ) && ! empty( $_COOKIE ) ) {
248 2396 foreach ( array_keys( $_COOKIE ) as $cookie_name ) {
2397 + // Our own bypass cookie is a RECORD of a previous verdict, not
2398 + // evidence about this visitor, so it never gets a vote here.
2399 + // Letting it match made the verdict self-confirming: once set,
2400 + // it produced `excluded-cookie` forever, which re-set it, and
2401 + // no later request could ever re-evaluate the visitor on the
2402 + // rules that actually describe them. The web server still acts
2403 + // on the cookie without booting PHP; when PHP does boot it is
2404 + // authoritative and re-decides from scratch. (#218)
2405 + if ( Server_Rules::BYPASS_COOKIE === $cookie_name ) {
2406 + continue;
2407 + }
249 2408 if ( Glob_Matcher::any_match( $excluded_cookies, (string) $cookie_name ) ) {
250 - return false;
2409 + return self::bypass( 'excluded-cookie' );
251 2410 }
252 2411 }
253 2412 }
254 2413
@@ -259,9 +2418,9 @@
259 2418 if ( ! empty( $bypass_uas ) ) {
260 2419 $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
261 2420 foreach ( $bypass_uas as $needle ) {
262 2421 if ( '' !== $needle && false !== stripos( $ua, (string) $needle ) ) {
263 - return false;
2422 + return self::bypass( 'user-agent' );
264 2423 }
265 2424 }
266 2425 }
267 2426
@@ -268,9 +2427,9 @@
268 2427 // Per-post override (Phase 3.4). Honored only on singular
269 2428 // post-context requests — archives / 404s / taxonomies use the
270 2429 // global policy above.
271 2430 if ( Cache_Rules::should_skip_for_post( Cache_Rules::current_post_id() ) ) {
272 - return false;
2431 + return self::bypass( 'post-excluded' );
273 2432 }
274 2433
275 2434 /**
276 2435 * Final say on whether the current request is cacheable.
@@ -290,9 +2449,16 @@
290 2449 * advanced-cache.php.
291 2450 *
292 2451 * @param bool $should_cache Whether to cache the current request.
293 2452 */
294 - return (bool) apply_filters( 'xspeed_should_cache', true );
2453 + if ( ! apply_filters( 'xspeed_should_cache', true ) ) {
2454 + // One slug for every listener — a third-party callback name is
2455 + // not ours to put in a response header. Which listener vetoed is
2456 + // a WP_DEBUG-level question the filter itself can answer.
2457 + return self::bypass( 'filtered' );
2458 + }
2459 +
2460 + return true;
295 2461 }
296 2462
297 2463 /**
298 2464 * Whether the current request is a 404 we may cache.
@@ -336,8 +2502,99 @@
336 2502 * search_term() / cache_key()) so different searches stay distinct.
337 2503 * The xspeed-pro search cache flips the filter; Free never caches
338 2504 * search results on its own.
339 2505 */
2506 + /**
2507 + * Whether this response was rendered for a query string and therefore
2508 + * must not be STORED under the bare-URL key.
2509 + *
2510 + * should_cache() lets a request through when every key is on the
2511 + * `ignored_query_params` allow-list, and cache_key() then drops the
2512 + * query string so `/post` and `/post?utm_source=x` share one entry.
2513 + * Sharing on READ is the point of the allow-list and stays. Sharing on
2514 + * WRITE is a cache-poisoning vector: the response was rendered *with*
2515 + * those params, and WordPress reflects REQUEST_URI into form actions,
2516 + * share links, canonical helpers and plugin smart tags. One anonymous
2517 + * GET to a cold URL therefore freezes an attacker-chosen variant under
2518 + * the clean URL's key, served for the whole TTL by the drop-in and by
2519 + * the web server — neither of which runs these checks (issue #241).
2520 + *
2521 + * The allow-list keeps its benefit: a visitor arriving on
2522 + * `?utm_source=…` is still SERVED the canonical cached entry. Only the
2523 + * write is skipped, so the entry is authored by a clean request.
2524 + *
2525 + * This is the same reasoning as the `should_cache_search()` guard in
2526 + * store_static() (#191), generalised to the allow-listed params.
2527 + */
2528 + public static function request_has_query_string(): bool {
2529 + $query = isset( $_SERVER['QUERY_STRING'] )
2530 + ? (string) wp_unslash( $_SERVER['QUERY_STRING'] ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- only tested for emptiness; never echoed, stored or used as a path.
2531 + : '';
2532 +
2533 + return '' !== trim( $query );
2534 + }
2535 +
2536 + /**
2537 + * Whether this request's query string is empty or holds only params the
2538 + * cache ignores (`ignored_query_params`, utm_* and the like), so the
2539 + * page is the one the bare URL serves.
2540 + *
2541 + * The same test should_cache() applies, without its search and feed
2542 + * exceptions: those are keyed apart from the bare URL.
2543 + */
2544 + public static function query_has_only_ignored_params(): bool {
2545 + $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.
2546 + if ( '' === trim( (string) $query_raw ) ) {
2547 + return true;
2548 + }
2549 + $cache_opts = Settings_Manager::get( 'cache' );
2550 + $ignored = is_array( $cache_opts['ignored_query_params'] ?? null ) ? $cache_opts['ignored_query_params'] : array();
2551 + parse_str( (string) $query_raw, $params );
2552 + foreach ( $params as $key => $_ ) {
2553 + if ( ! self::query_key_is_ignored( (string) $key, $ignored ) ) {
2554 + return false;
2555 + }
2556 + }
2557 + return true;
2558 + }
2559 +
2560 + /**
2561 + * Would authoring a cache entry from THIS request file a query-string
2562 + * render under the bare URL?
2563 + *
2564 + * The one predicate both write sites ask, so they cannot drift.
2565 + *
2566 + * Two shapes are exempt because cache_key() does NOT drop their query —
2567 + * it folds the distinguishing part into the key, so each variant gets
2568 + * its own entry and none is filed under the bare URL:
2569 + *
2570 + * - searches, keyed by `|s=<term>` (#191)
2571 + * - feeds, keyed by `|feed=<type>` — `/?feed=rss2` is the ONLY feed URL
2572 + * core generates on plain permalinks, so treating it as poisonable
2573 + * made feed caching a no-op on exactly the sites that need it
2574 + *
2575 + * @return bool True when the write must be skipped.
2576 + */
2577 + public static function query_string_blocks_write(): bool {
2578 + if ( ! self::request_has_query_string() ) {
2579 + return false;
2580 + }
2581 +
2582 + if ( self::should_cache_search() ) {
2583 + return false;
2584 + }
2585 +
2586 + // Feed caching is opt-in, via the same filter should_cache() reads
2587 + // to admit the feed params in the first place.
2588 + if ( function_exists( 'is_feed' ) && is_feed()
2589 + && (bool) apply_filters( 'xspeed_should_cache_feed', false )
2590 + ) {
2591 + return false;
2592 + }
2593 +
2594 + return true;
2595 + }
2596 +
340 2597 public static function should_cache_search(): bool {
341 2598 if ( ! function_exists( 'is_search' ) || ! is_search() ) {
342 2599 return false;
343 2600 }
@@ -377,15 +2634,240 @@
377 2634 }
378 2635
379 2636 /**
380 2637 * Is this query-string key on the ignored-params allow-list? Supports
381 - * trailing-star globs (`utm_*` matches `utm_source`, `utm_medium`,
382 - * etc.) so users don't have to enumerate every UTM variant.
2638 + * globs (`utm_*` matches `utm_source`, `utm_medium`, etc.) so users
2639 + * don't have to enumerate every UTM variant, and `~regex`.
2640 + *
2641 + * Matching is whole-name, not "contains" — a param name is an
2642 + * identifier, not a path. Under the old contains match the shipped
2643 + * default `ref` also swallowed `preference`, `product_ref` and
2644 + * `referrer`: those params were dropped from the cache key, so
2645 + * `/shop?preference=1` was served — and, on a cold entry, WRITTEN as —
2646 + * `/shop`. Same for `_ga` vs `_gallery`, and for the unanchored
2647 + * `~utm_…` default vs `my_utm_source`. A param name that is genuinely
2648 + * unknown now bypasses the cache, which is the safe direction.
383 2649 */
384 2650 private static function query_key_is_ignored( string $key, array $ignored ): bool {
385 - return Glob_Matcher::any_match( $ignored, $key );
2651 + if ( in_array( $key, self::NEVER_IGNORED_QUERY_PARAMS, true ) ) {
2652 + return false;
2653 + }
2654 + return Glob_Matcher::any_match_name( $ignored, $key );
386 2655 }
387 2656
2657 + /**
2658 + * Query params no ignored-params entry can match, glob or regex.
2659 + *
2660 + * A measuring request asks for the page as it is before optimisation
2661 + * (`xspeed_css=off`), with a one-time value (`xspeed_nc`) so no cache
2662 + * has a copy of it. The answer must be rendered for that request. A
2663 + * list entry such as `xspeed_*` or `*` would make both params
2664 + * decoration: the drop-in would serve the canonical, already-optimised
2665 + * entry before any plugin loads, and the measurement would describe
2666 + * the optimised page instead of the source.
2667 + */
2668 + public const NEVER_IGNORED_QUERY_PARAMS = array( 'xspeed_css', 'xspeed_nc' );
2669 +
2670 + /**
2671 + * The params a query-form feed may carry (`/?feed=rss2`) once feed
2672 + * caching has opted the request in. should_cache() lets them through,
2673 + * and serves_query_variant() holds a HIT that carries one.
2674 + */
2675 + private const FEED_QUERY_PARAMS = array( 'feed', 'withcomments', 'withoutcomments' );
2676 +
2677 + /**
2678 + * The one spelling of a URL path that every cache key is built from.
2679 + *
2680 + * WordPress stores a non-ASCII slug percent-encoded, so a page called
2681 + * `關於我們` is requested as `/%e9%97%9c%e6%96%bc%e6%88%91%e5%80%91/`. The
2682 + * same page also arrives as `/%E9%97%9C…/` (what a browser sends for an
2683 + * address typed or pasted into it) and as raw UTF-8 bytes. WordPress
2684 + * renders the same page for all three, so they get one key: escapes are
2685 + * lowercased (WordPress's own spelling, so a permalink comes back
2686 + * unchanged) and any byte outside printable ASCII is escaped the same way.
2687 + * Null bytes are dropped, as the drop-in does.
2688 + *
2689 + * Nothing is decoded. `/ab%6Fut/` stays a separate entry from `/about/`.
2690 + * Only spellings of the same bytes are merged, because merging spellings
2691 + * WordPress may route differently would let one page be served for
2692 + * another. A printable-ASCII path with no escapes comes back unchanged,
2693 + * so ordinary pages keep the keys they had.
2694 + *
2695 + * The path used to go through sanitize_text_field(), which deletes every
2696 + * `%XX` octet: `/第九屆-當代藝術-tagboat-award-特展/` was keyed as
2697 + * `/--tagboat-award-/`, every page whose slug differed only in non-ASCII
2698 + * characters shared one entry, and an all-non-ASCII slug became `//`,
2699 + * which store_static() wrote over the home page's static file.
2700 + *
2701 + * The drop-in carries a copy of this transform, because it runs before
2702 + * WordPress and this class load. Change both together.
2703 + *
2704 + * @param string $path A URL path, without the query string.
2705 + */
2706 + public static function normalize_path( string $path ): string {
2707 + return self::path_with_escape_case( $path, false );
2708 + }
2709 +
2710 + /**
2711 + * The spellings of one path that a cache in front of the site may hold.
2712 + *
2713 + * xSpeed keys every escape spelling of a path as one entry
2714 + * (normalize_path()). A CDN does not: Cloudflare keeps `/%E9%97%9C…/`,
2715 + * the spelling a browser sends, apart from `/%e9%97%9c…/`, the spelling
2716 + * of WordPress's own links. A purge that named one left the other
2717 + * serving the old page until its edge lifetime ran out.
2718 + *
2719 + * Returns the path as given, then normalize_path()'s spelling (lower-case
2720 + * escapes), then the same with upper-case escapes. Both are built from
2721 + * the path the local sweep cleared, so a cache in front is never asked
2722 + * about a page xSpeed did not purge. Duplicates drop out, so a path with
2723 + * no escapes has one spelling. Trailing-slash forms are the caller's job.
2724 + *
2725 + * @param string $path A URL path, without the query string.
2726 + * @return string[]
2727 + */
2728 + public static function escape_spellings( string $path ): array {
2729 + return array_values(
2730 + array_unique(
2731 + array( $path, self::path_with_escape_case( $path, false ), self::path_with_escape_case( $path, true ) )
2732 + )
2733 + );
2734 + }
2735 +
2736 + /**
2737 + * normalize_path() with the escapes in the case asked for.
2738 + *
2739 + * @param string $path A URL path, without the query string.
2740 + * @param bool $upper Upper-case escapes, or lower (the key's spelling).
2741 + */
2742 + private static function path_with_escape_case( string $path, bool $upper ): string {
2743 + return (string) preg_replace_callback(
2744 + '/%[0-9a-fA-F]{2}|[^\x21-\x7E]/',
2745 + static function ( array $m ) use ( $upper ): string {
2746 + $escape = '%' === $m[0][0] ? $m[0] : sprintf( '%%%02x', ord( $m[0] ) );
2747 + return $upper ? strtoupper( $escape ) : strtolower( $escape );
2748 + },
2749 + str_replace( "\0", '', $path )
2750 + );
2751 + }
2752 +
2753 + /**
2754 + * Percent-encode every byte 0x80 and up, lowercase hex.
2755 + *
2756 + * @param string $value A path or a whole URL.
2757 + */
2758 + private static function encode_non_ascii( string $value ): string {
2759 + return (string) preg_replace_callback(
2760 + '/[\x80-\xff]/',
2761 + static function ( array $m ): string {
2762 + return '%' . bin2hex( $m[0] );
2763 + },
2764 + $value
2765 + );
2766 + }
2767 +
2768 + /**
2769 + * Where a URL path lives in the static tree, relative to the host
2770 + * directory, with a leading slash and no trailing one ('' for the home
2771 + * page). Null when the path must not be written there.
2772 + *
2773 + * Decoded, because that is what the web server looks up: nginx builds the
2774 + * file name from `$uri` and Apache from `%{REQUEST_URI}`, and both hold
2775 + * the decoded path. A file stored under the encoded name is never found.
2776 + *
2777 + * Decoding is only safe for the escapes WordPress itself writes, which
2778 + * encode the bytes of non-ASCII letters (0x80 and up). An escaped ASCII
2779 + * byte is refused, because the server decodes it before the lookup:
2780 + * `%2F` would become a directory, `%2E%2E` a parent, and `%6F` would
2781 + * make `/ab%6Fut/` and `/about/` share one file although they are
2782 + * separate cache entries. The decoded path must also be valid UTF-8, with
2783 + * no control byte, backslash or `%`, no `..` anywhere and no `.`
2784 + * segment. A refused page is still cached by the drop-in; it only loses
2785 + * the no-PHP path.
2786 + *
2787 + * @param string $path A URL path, without the query string.
2788 + */
2789 + public static function static_path( string $path ): ?string {
2790 + if ( '' === $path || false !== strpos( $path, "\0" ) ) {
2791 + return null;
2792 + }
2793 + if ( preg_match_all( '/%([0-9a-fA-F]{2})/', $path, $escapes ) ) {
2794 + foreach ( $escapes[1] as $hex ) {
2795 + if ( hexdec( $hex ) < 0x80 ) {
2796 + return null;
2797 + }
2798 + }
2799 + }
2800 + $decoded = rawurldecode( $path );
2801 + if ( 1 !== preg_match( '//u', $decoded ) || preg_match( '/[\x00-\x1f\x7f\\\\%]/', $decoded ) ) {
2802 + return null;
2803 + }
2804 + $decoded = (string) preg_replace( '#/+#', '/', $decoded );
2805 + if ( false !== strpos( $decoded, '..' ) || preg_match( '#(^|/)\.(/|$)#', $decoded ) ) {
2806 + return null;
2807 + }
2808 + return rtrim( $decoded, '/' );
2809 + }
2810 +
2811 + /**
2812 + * The current request's path, normalized, without the query string.
2813 + *
2814 + * @param string $fallback Returned when the server sent no REQUEST_URI.
2815 + */
2816 + private static function request_path( string $fallback = '' ): string {
2817 + if ( ! isset( $_SERVER['REQUEST_URI'] ) ) {
2818 + return $fallback;
2819 + }
2820 + // 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.
2821 + $uri = (string) wp_unslash( $_SERVER['REQUEST_URI'] );
2822 + return self::normalize_path( (string) strtok( $uri, '?' ) );
2823 + }
2824 +
2825 + /**
2826 + * An Excluded URLs entry with its escapes in lower case.
2827 + *
2828 + * The request path is matched in normalize_path()'s spelling, so an entry
2829 + * copied from Chrome's address bar (`/%E8%81%AF…/`) never matched it.
2830 + * Only `%XX` sequences change; the rest of the entry, including a `~`
2831 + * regex, is left as typed.
2832 + *
2833 + * @param string $pattern One Excluded URLs entry.
2834 + */
2835 + public static function normalize_exclusion( string $pattern ): string {
2836 + return (string) preg_replace_callback(
2837 + '/%[0-9a-fA-F]{2}/',
2838 + static function ( array $m ): string {
2839 + return strtolower( $m[0] );
2840 + },
2841 + $pattern
2842 + );
2843 + }
2844 +
2845 + /**
2846 + * Does an exclusion list match this path, in either spelling?
2847 + *
2848 + * An admin may paste an exclusion as `/購物車`, as WordPress spells it, or
2849 + * as Chrome shows it in upper case. The entry's escapes are lowercased to
2850 + * match the path, and the decoded path is tried too, which is also what
2851 + * the nginx rule matches (`$uri` is decoded).
2852 + *
2853 + * @param string[] $patterns Exclusion entries.
2854 + * @param string $path Output of normalize_path().
2855 + */
2856 + private static function path_matches_exclusions( array $patterns, string $path ): bool {
2857 + $patterns = array_map(
2858 + static function ( $pattern ): string {
2859 + return self::normalize_exclusion( (string) $pattern );
2860 + },
2861 + $patterns
2862 + );
2863 + if ( Glob_Matcher::any_match( $patterns, $path ) ) {
2864 + return true;
2865 + }
2866 + $decoded = rawurldecode( $path );
2867 + return $decoded !== $path && Glob_Matcher::any_match( $patterns, $decoded );
2868 + }
2869 +
388 2870 public static function cache_key() {
389 2871 $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : 'default';
390 2872
391 2873 // Cacheable 404s share ONE generic per-host entry — keying them by
@@ -395,14 +2877,13 @@
395 2877 if ( self::should_cache_404() ) {
396 2878 return md5( $host . '|404' );
397 2879 }
398 2880
399 - $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/';
400 2881 // Strip the query string from the key so /post and /post?utm_*=…
401 2882 // share the same cache entry. should_cache() above already
402 2883 // rejected requests with non-ignored params, so by the time we
403 2884 // build the key the only params left are safe to drop.
404 - $uri = (string) strtok( $uri, '?' );
2885 + $uri = self::request_path( '/' );
405 2886
406 2887 // Optional device bucket: when mobile_separate is on, mobile and
407 2888 // desktop responses live in different cache files so themes that
408 2889 // serve different HTML by device (AMP, WPtouch, Jetpack mobile)
@@ -459,10 +2940,300 @@
459 2940 }
460 2941 return (bool) preg_match( '/(Mobile|Android|Silk\/|Kindle|BlackBerry|Opera Mini|Opera Mobi)/i', $ua );
461 2942 }
462 2943
2944 + /**
2945 + * Filesystem-safe directory name for a host, or '' when unusable.
2946 + *
2947 + * The charset MUST match the static tree (store_static()) and the
2948 + * drop-in's own copy, or the paths disagree about where an entry lives.
2949 + * The colon of `host:port` is stripped: it is legal in a Host header but
2950 + * not portable in a path.
2951 + *
2952 + * @param string $host Raw host, e.g. from HTTP_HOST.
2953 + * @return string Safe directory segment, or '' if nothing usable remains.
2954 + */
2955 + /**
2956 + * The host segment of the STATIC tree — `xspeed-static/<host>/…`, which
2957 + * the web server resolves without PHP.
2958 + *
2959 + * Different from host_dir(): here the port is folded INTO the segment
2960 + * (`localhost:8080` → `localhost8080`) rather than dropped, because the
2961 + * generated server rules have to reproduce this from their own variables
2962 + * and nginx's `$host` has no port to drop — see the `$xspeed_host`
2963 + * derivation in nginx_snippet(). Shared by the write and the purge so the
2964 + * two can't drift; when they did, purging a page on a ported host deleted
2965 + * nothing and the stale copy kept being served by the rewrite.
2966 + */
2967 + public static function static_host_dir( string $host ): string {
2968 + return (string) preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host );
2969 + }
2970 +
2971 + public static function host_dir( string $host ): string {
2972 + $host = str_replace( "\0", '', $host );
2973 + // Drop the port BEFORE filtering, or `example.com:8080` collapses to
2974 + // `example.com8080` — which both loses the boundary and could collide
2975 + // with a real host of that name.
2976 + $colon = strpos( $host, ':' );
2977 + if ( false !== $colon ) {
2978 + $host = substr( $host, 0, $colon );
2979 + }
2980 + $host = preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host );
2981 + // Collapse any run of dots so no traversal sequence can survive the
2982 + // charset filter (`a/../b` would otherwise reduce to `a..b`).
2983 + $host = preg_replace( '/\.{2,}/', '.', (string) $host );
2984 + $host = trim( (string) $host, '.-' );
2985 + return '' === $host ? '' : $host;
2986 + }
2987 +
2988 + /**
2989 + * The per-site bucket a cache entry belongs to: `<host>` on a single
2990 + * site, `<host>/<path-prefix>` for a subdirectory multisite blog.
2991 + *
2992 + * On multisite every blog shares one cache directory, and a flat md5
2993 + * filename carries no clue which site wrote it — so purging one subsite
2994 + * swept the whole network cold. (#6)
2995 + *
2996 + * Host alone is NOT enough: a subdirectory network (the common layout)
2997 + * puts every blog on the same host, so `example.com/` and
2998 + * `example.com/siteb/` would share a bucket and keep purging each other.
2999 + * The path prefix is what separates them, and it is derivable from the
3000 + * REQUEST_URI alone — which matters because the drop-in must compute
3001 + * this identical value before WordPress (and get_blog_details()) exist.
3002 + *
3003 + * Subdomain and domain-mapped networks differ by host already, so they
3004 + * get a bare host bucket and are unaffected.
3005 + *
3006 + * @param string $host Raw host.
3007 + * @param string $uri Raw REQUEST_URI (query string is ignored).
3008 + * @return string Bucket path, always non-empty.
3009 + */
3010 + public static function site_bucket( string $host, string $uri ): string {
3011 + $dir = self::host_dir( $host );
3012 + if ( '' === $dir ) {
3013 + $dir = 'default';
3014 + }
3015 +
3016 + $prefix = self::site_path_prefix();
3017 + return '' === $prefix ? $dir : $dir . '/' . $prefix;
3018 + }
3019 +
3020 + /**
3021 + * The current blog's path prefix as a single safe segment ('' for the
3022 + * root blog or a non-multisite install). `/siteb/` becomes `siteb`;
3023 + * a nested `/a/b/` becomes `a-b` so the bucket stays one level deep.
3024 + *
3025 + * Written to a sidecar for the drop-in by sync_site_paths().
3026 + */
3027 + public static function site_path_prefix(): string {
3028 + if ( ! function_exists( 'is_multisite' ) || ! is_multisite() ) {
3029 + return '';
3030 + }
3031 + if ( function_exists( 'is_subdomain_install' ) && is_subdomain_install() ) {
3032 + return ''; // Hosts already differ; no prefix needed.
3033 + }
3034 + $path = function_exists( 'get_blog_details' ) ? (string) get_blog_details()->path : '/';
3035 + return self::path_prefix_segment( $path );
3036 + }
3037 +
3038 + /**
3039 + * The bucket an arbitrary URL's cache entry lives in.
3040 + *
3041 + * `site_bucket()` answers for the CURRENT request; this answers for a URL
3042 + * that may belong to another blog entirely — which is what a per-URL purge
3043 + * is usually doing (WP-CLI, cron, the MCP tool, a network-admin action).
3044 + *
3045 + * The blog is resolved from the URL itself: on a subdirectory network
3046 + * `get_blog_details()` is asked which blog owns `<host><path>`, and its
3047 + * registered path becomes the prefix. Deriving the prefix from the URL's
3048 + * first path segment directly would be wrong — `/shop/` on the main blog
3049 + * is a page, not a subsite, and would send the purge into a bucket that
3050 + * does not exist. (QA B2 on #166)
3051 + *
3052 + * @param string $host Host of the URL being purged.
3053 + * @param string $path Path of the URL being purged.
3054 + * @return string Bucket path, always non-empty.
3055 + */
3056 + public static function bucket_for_url( string $host, string $path ): string {
3057 + $dir = self::host_dir( $host );
3058 + if ( '' === $dir ) {
3059 + $dir = 'default';
3060 + }
3061 +
3062 + if ( ! function_exists( 'is_multisite' ) || ! is_multisite() ) {
3063 + return $dir;
3064 + }
3065 + if ( function_exists( 'is_subdomain_install' ) && is_subdomain_install() ) {
3066 + return $dir; // Hosts already differ; no prefix.
3067 + }
3068 + if ( ! function_exists( 'get_blog_details' ) ) {
3069 + return $dir;
3070 + }
3071 +
3072 + // Longest registered blog path that prefixes this URL wins, so
3073 + // `/one/2026/post/` resolves to blog `/one/` and not to the root blog.
3074 + $blog = self::blog_for_path( $host, $path );
3075 + if ( null === $blog ) {
3076 + return $dir;
3077 + }
3078 + $prefix = self::path_prefix_segment( (string) $blog );
3079 + return '' === $prefix ? $dir : $dir . '/' . $prefix;
3080 + }
3081 +
3082 + /**
3083 + * The registered path of the blog that owns `<host><path>`, or null.
3084 + *
3085 + * Uses get_blog_details() with a domain/path pair rather than scanning
3086 + * every blog, so a large network costs one lookup per candidate segment
3087 + * instead of a full table read.
3088 + */
3089 + private static function blog_for_path( string $host, string $path ): ?string {
3090 + $segments = array_values( array_filter( explode( '/', trim( $path, '/' ) ) ) );
3091 +
3092 + // Try the longest candidate first: /a/b/ before /a/ before /.
3093 + for ( $take = min( count( $segments ), 2 ); $take >= 1; $take-- ) {
3094 + $candidate = '/' . implode( '/', array_slice( $segments, 0, $take ) ) . '/';
3095 + $details = get_blog_details(
3096 + array(
3097 + 'domain' => $host,
3098 + 'path' => $candidate,
3099 + ),
3100 + false
3101 + );
3102 + if ( $details && ! empty( $details->path ) ) {
3103 + return (string) $details->path;
3104 + }
3105 + }
3106 + return null;
3107 + }
3108 +
3109 + /**
3110 + * Normalise a blog path ('/', '/siteb/', '/a/b/') into a single
3111 + * filesystem-safe segment. Shared with the drop-in's copy.
3112 + */
3113 + public static function path_prefix_segment( string $path ): string {
3114 + $path = trim( str_replace( "\0", '', $path ), '/' );
3115 + if ( '' === $path ) {
3116 + return '';
3117 + }
3118 + $path = preg_replace( '/[^a-zA-Z0-9._\-\/]/', '', $path );
3119 + $path = str_replace( '/', '-', (string) $path );
3120 + return trim( (string) $path, '.-' );
3121 + }
3122 +
3123 + /**
3124 + * The current blog's path as the static tree stores it — real slashes
3125 + * preserved, because that tree mirrors the URL
3126 + * (`xspeed-static/{host}{request_uri}/index.html`) rather than using a
3127 + * single flattened segment. '' for a root blog / single site.
3128 + */
3129 + public static function site_path_raw(): string {
3130 + if ( ! function_exists( 'is_multisite' ) || ! is_multisite() ) {
3131 + return '';
3132 + }
3133 + if ( function_exists( 'is_subdomain_install' ) && is_subdomain_install() ) {
3134 + return '';
3135 + }
3136 + $path = function_exists( 'get_blog_details' ) ? (string) get_blog_details()->path : '/';
3137 + $path = trim( str_replace( "\0", '', $path ), '/' );
3138 + if ( '' === $path ) {
3139 + return '';
3140 + }
3141 + $path = preg_replace( '#[^a-zA-Z0-9._\-/]#', '', $path );
3142 + return trim( (string) $path, '/' );
3143 + }
3144 +
3145 + /**
3146 + * Static-tree root for the current site: `<host>` plus the blog's real
3147 + * path. Mirrors store_static()'s layout so a scoped purge deletes
3148 + * exactly this blog's pages.
3149 + */
3150 + public static function current_static_scope(): string {
3151 + // Same switch_to_blog() caveat as current_host_dir() — see current_host().
3152 + // Keep the port folded into the segment exactly as store_static() does.
3153 + $dir = self::static_host_dir( self::current_host() );
3154 + if ( '' === $dir ) {
3155 + $dir = 'default';
3156 + }
3157 + $path = self::site_path_raw();
3158 + return '' === $path ? $dir : $dir . '/' . $path;
3159 + }
3160 +
3161 + /**
3162 + * The bucket for the CURRENT request. Never empty, so an entry is never
3163 + * written to the tree root (which is what the unscoped sweeps used to
3164 + * delete indiscriminately).
3165 + */
3166 + public static function current_host_dir(): string {
3167 + $host = self::current_host();
3168 + return self::site_bucket( $host, self::request_path( '/' ) );
3169 + }
3170 +
3171 + /**
3172 + * The host the CURRENT blog is served from.
3173 + *
3174 + * Deliberately NOT just $_SERVER['HTTP_HOST']: inside a
3175 + * switch_to_blog() the request header still names whichever site is
3176 + * serving the admin screen, while the cache entries we want belong to
3177 + * the switched-to blog. On a subdomain network the host IS the bucket,
3178 + * so reading the header there would make Pro's per-site "purge this
3179 + * site" button clear the network admin's own cache instead — the very
3180 + * bug this scoping exists to fix, surviving in one topology.
3181 + *
3182 + * get_blog_details() follows the switch, so prefer it whenever we are
3183 + * on multisite, and fall back to the request header otherwise.
3184 + */
3185 + public static function current_host(): string {
3186 + if ( function_exists( 'is_multisite' ) && is_multisite() && function_exists( 'get_blog_details' ) ) {
3187 + $details = get_blog_details();
3188 + if ( $details && ! empty( $details->domain ) ) {
3189 + return (string) $details->domain;
3190 + }
3191 + }
3192 +
3193 + if ( isset( $_SERVER['HTTP_HOST'] ) ) {
3194 + return sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) );
3195 + }
3196 +
3197 + /*
3198 + * No request header — WP-CLI, or WP-Cron driven by system cron.
3199 + *
3200 + * Returning '' here made the bucket resolve to the literal `default`
3201 + * while HTTP requests were writing to `<host>/`, so a scheduled purge
3202 + * swept an empty directory and reported success, and get_stats()
3203 + * reported 0 cached pages on a site with a full cache. That is the
3204 + * normal setup on any host running DISABLE_WP_CRON, which is most of
3205 + * them. Fall back to the site's own registered host. (QA D4 on #166)
3206 + */
3207 + if ( function_exists( 'home_url' ) ) {
3208 + $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.
3209 + if ( is_array( $parts ) && ! empty( $parts['host'] ) ) {
3210 + return (string) $parts['host'];
3211 + }
3212 + }
3213 +
3214 + return '';
3215 + }
3216 +
3217 + /**
3218 + * Ensure the current site's cache directory exists, with the silence
3219 + * index in both it and the shared root. Returns the directory.
3220 + */
3221 + public static function ensure_host_dir(): string {
3222 + $dir = XSPEED_CACHE_DIR . '/' . self::current_host_dir();
3223 + if ( ! file_exists( XSPEED_CACHE_DIR ) ) {
3224 + wp_mkdir_p( XSPEED_CACHE_DIR );
3225 + self::write_silence( XSPEED_CACHE_DIR );
3226 + }
3227 + if ( ! file_exists( $dir ) ) {
3228 + wp_mkdir_p( $dir );
3229 + self::write_silence( $dir );
3230 + }
3231 + return $dir;
3232 + }
3233 +
463 3234 public static function cache_file_for( $key ) {
464 - return XSPEED_CACHE_DIR . '/' . $key . '.html';
3235 + return XSPEED_CACHE_DIR . '/' . self::current_host_dir() . '/' . $key . '.html';
465 3236 }
466 3237
467 3238 /**
468 3239 * If a precompressed Brotli sibling (`<file>.br`) exists and the client
@@ -493,8 +3264,11 @@
493 3264 $br = $file . '.br';
494 3265 if ( ! is_string( $br ) || ! file_exists( $br ) || ! is_readable( $br ) ) {
495 3266 return null;
496 3267 }
3268 + if ( ! self::brotli_sibling_is_usable( $file, $br ) ) {
3269 + return null; // fall through to the plain .html
3270 + }
497 3271 header( 'Content-Encoding: br' );
498 3272 header( 'Vary: Accept-Encoding', false );
499 3273 // The byte length changes for the compressed body — drop any
500 3274 // Content-Length the caller may have set so the stream isn't
@@ -503,8 +3277,217 @@
503 3277 return $br;
504 3278 }
505 3279
506 3280 /**
3281 + * Is a precompressed `.br` sibling safe to serve?
3282 + *
3283 + * Existence is not enough. The sibling is written with a plain
3284 + * file_put_contents() — no atomic rename — so a crash, a full disk, or a
3285 + * read that races the write leaves a TRUNCATED file behind. Serving that
3286 + * with `Content-Encoding: br` hands the browser a stream it cannot
3287 + * inflate: it renders nothing at all (document.body is null) and the
3288 + * navigation can hang. A 16-byte .br for a 172KB page reproduces it
3289 + * exactly. (#286)
3290 + *
3291 + * Brotli has no magic number, and no byte-level marker distinguishes a
3292 + * truncated stream from a short valid one (the ISLAST bit is bit-packed,
3293 + * not byte-aligned). So this checks only what CAN be known by stat:
3294 + *
3295 + * - Not empty. A zero-byte sibling is unambiguously broken.
3296 + * - Not older than the HTML. A stale sibling would serve the PREVIOUS
3297 + * revision of the page under the current entry's ETag.
3298 + *
3299 + * A size-RATIO floor was tried here and removed. Brotli's ratio is
3300 + * unbounded on repetitive input: a ~1 MB page of table rows or a product
3301 + * grid — the ordinary shape of a big generated page — compresses to
3302 + * about 0.04%, so a 2% floor rejected a perfectly good sibling and sent
3303 + * visitors the uncompressed page instead, silently. Measured: 963 KB of
3304 + * repeated markup → 89 bytes at q5 (0.009%). No floor can separate
3305 + * "impossibly small" from "extremely compressible" for arbitrary HTML.
3306 + *
3307 + * Truncation is prevented at the WRITE side instead — see
3308 + * write_atomic(), which the Brotli writer uses so a partial file is
3309 + * never visible under the final name. Detection at read time cannot be
3310 + * made correct; not creating the bad file can.
3311 + *
3312 + * Anything suspicious returns false and the caller streams the plain
3313 + * .html — slower, always correct. Serving an uninflatable body is worse
3314 + * than serving no compression at all.
3315 + *
3316 + * @param string $file Absolute path to the .html cache file.
3317 + * @param string $br Absolute path to its .br sibling.
3318 + * @return bool True when the sibling may be served.
3319 + */
3320 + /**
3321 + * Write a cache sidecar so a partial file is never visible.
3322 + *
3323 + * `file_put_contents()` truncates the target and then fills it, so any
3324 + * reader arriving mid-write — or any crash, full disk, or killed worker
3325 + * — leaves a SHORT file under the real name. For HTML that degrades to a
3326 + * clipped page; for a `.br` sibling it is worse, because a truncated
3327 + * brotli stream is not a short page but an UNINFLATABLE one: the browser
3328 + * renders nothing at all and the navigation can hang.
3329 + *
3330 + * Writing to a unique temp file in the same directory and renaming is
3331 + * atomic on POSIX, so readers see either the previous complete file or
3332 + * the new complete file, never a partial one. This is the half of #286
3333 + * that is actually fixable — a read-time heuristic cannot tell a
3334 + * truncated brotli stream from a very small valid one, but a truncated
3335 + * file that never becomes visible needs no detection.
3336 + *
3337 + * @param string $path Absolute destination path.
3338 + * @param string $contents Bytes to write.
3339 + * @return bool True when the destination now holds exactly $contents.
3340 + */
3341 + public static function write_atomic( string $path, string $contents ): bool {
3342 + $dir = dirname( $path );
3343 + // 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.
3344 + if ( ! is_dir( $dir ) || ! is_writable( $dir ) ) {
3345 + return false;
3346 + }
3347 +
3348 + // Same directory, so the rename stays on one filesystem — a rename
3349 + // across devices is a copy and loses atomicity.
3350 + $tmp = @tempnam( $dir, '.xspeed-tmp-' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a failure returns false and the caller skips the write.
3351 + if ( ! is_string( $tmp ) || '' === $tmp ) {
3352 + return false;
3353 + }
3354 +
3355 + // 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.
3356 + $written = @file_put_contents( $tmp, $contents ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- handled by the length check below.
3357 +
3358 + // A short write is exactly the failure this function exists to
3359 + // prevent, so verify the byte count before publishing the file.
3360 + if ( false === $written || $written !== strlen( $contents ) ) {
3361 + @unlink( $tmp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort cleanup of our own temp file; non-fatal.
3362 + return false;
3363 + }
3364 +
3365 + // tempnam() creates the file 0600; cache files must stay readable by
3366 + // the web server, which may run as a different user.
3367 + @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.
3368 +
3369 + // 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.
3370 + if ( ! @rename( $tmp, $path ) ) {
3371 + @unlink( $tmp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort cleanup; non-fatal.
3372 + return false;
3373 + }
3374 +
3375 + return true;
3376 + }
3377 +
3378 + public static function brotli_sibling_is_usable( string $file, string $br ): bool {
3379 + $br_size = (int) @filesize( $br ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a stat failure means "don't serve it", handled by the <= 0 check.
3380 + if ( $br_size <= 0 ) {
3381 + return false;
3382 + }
3383 +
3384 + $html_size = (int) @filesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- as above.
3385 + if ( $html_size <= 0 ) {
3386 + return false;
3387 + }
3388 +
3389 + // A sibling older than the page it compresses is stale.
3390 + $br_mtime = (int) @filemtime( $br ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- as above.
3391 + $html_mtime = (int) @filemtime( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- as above.
3392 + if ( $br_mtime > 0 && $html_mtime > 0 && $br_mtime < $html_mtime ) {
3393 + return false;
3394 + }
3395 +
3396 + // The writer recorded how many bytes it produced. Where that record
3397 + // exists, truncation is a certainty rather than an inference: a
3398 + // stream shorter than its own declared length cannot inflate, and
3399 + // one that matches was published whole. This is what a size ratio
3400 + // could never be — brotli's ratio is unbounded on repetitive input,
3401 + // so a 0.01% sibling of a generated page is genuinely valid.
3402 + //
3403 + // Absent for a sibling written before this version, or by an add-on
3404 + // that writes the file directly. That case keeps the checks above
3405 + // and no more, which is where a pre-existing truncated file on a
3406 + // live site still slips through — write_atomic() stops NEW ones,
3407 + // but it cannot retroactively vouch for what is already on disk.
3408 + $expected = self::brotli_expected_size( $br );
3409 + if ( $expected > 0 && $br_size !== $expected ) {
3410 + return false;
3411 + }
3412 +
3413 + return true;
3414 + }
3415 +
3416 + /**
3417 + * Path of the sidecar recording a `.br` sibling's complete byte count.
3418 + *
3419 + * Kept beside the sibling as `<file>.html.br.size` rather than folded
3420 + * into the entry's `.meta`: the static tree the web server serves has no
3421 + * `.meta` at all, and the two trees must answer this question the same
3422 + * way. Every path that deletes a `.br` deletes this with it.
3423 + *
3424 + * @param string $br Absolute path to the `.br` sibling.
3425 + * @return string Absolute path to its size sidecar.
3426 + */
3427 + public static function brotli_size_sidecar( string $br ): string {
3428 + return $br . '.size';
3429 + }
3430 +
3431 + /**
3432 + * The byte count the writer recorded for a `.br` sibling, or 0 when no
3433 + * record exists (a sibling predating this version, or written by an
3434 + * add-on that bypassed write_brotli_sibling()).
3435 + *
3436 + * @param string $br Absolute path to the `.br` sibling.
3437 + * @return int Expected size in bytes, or 0 when unknown.
3438 + */
3439 + public static function brotli_expected_size( string $br ): int {
3440 + $sidecar = self::brotli_size_sidecar( $br );
3441 + if ( ! is_file( $sidecar ) ) {
3442 + return 0;
3443 + }
3444 + // 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.
3445 + $raw = @file_get_contents( $sidecar ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- an unreadable sidecar means "unknown", handled by the cast below.
3446 + return max( 0, (int) trim( (string) $raw ) );
3447 + }
3448 +
3449 + /**
3450 + * Publish a `.br` sibling together with the record of its own length.
3451 + *
3452 + * The single writer every producer of a `.br` should route through — the
3453 + * Pro Brotli module included. Publishing the body atomically stops a
3454 + * truncated file from ever becoming visible; recording the byte count
3455 + * lets the serve path prove wholeness for the files that already exist
3456 + * on disk when this ships.
3457 + *
3458 + * Order matters: the size sidecar is removed first and written last, so
3459 + * a reader arriving mid-update sees "no record" (checks above still
3460 + * apply) rather than the previous body's length against the new body.
3461 + *
3462 + * @param string $br Absolute path to the `.br` sibling to write.
3463 + * @param string $contents Compressed bytes.
3464 + * @return bool True when both the sibling and its size record are in place.
3465 + */
3466 + public static function write_brotli_sibling( string $br, string $contents ): bool {
3467 + $sidecar = self::brotli_size_sidecar( $br );
3468 + if ( is_file( $sidecar ) ) {
3469 + wp_delete_file( $sidecar );
3470 + }
3471 +
3472 + if ( ! self::write_atomic( $br, $contents ) ) {
3473 + return false;
3474 + }
3475 +
3476 + if ( self::write_atomic( $sidecar, (string) strlen( $contents ) ) ) {
3477 + return true;
3478 + }
3479 +
3480 + // The body landed but its length did not. That sibling is servable
3481 + // and unguarded — exactly the file this function exists to prevent —
3482 + // and the caller has no way to know. Withdraw it: a MISS costs one
3483 + // uncompressed response, where an unguarded sibling can cost a blank
3484 + // page for as long as the entry lives.
3485 + wp_delete_file( $br );
3486 + return false;
3487 + }
3488 +
3489 + /**
507 3490 * Sidecar metadata file for a cache entry. Holds response bits the HIT
508 3491 * path must replay — Content-Type (cached feeds → application/rss+xml,
509 3492 * sitemaps → text/xml) and status (a cached 404 must serve 404, not
510 3493 * 200). JSON, one tiny file per entry, written only when there's
@@ -510,17 +3493,19 @@
510 3493 * 200). JSON, one tiny file per entry, written only when there's
511 3494 * something non-default to replay.
512 3495 */
513 3496 public static function cache_meta_for( $key ) {
514 - return XSPEED_CACHE_DIR . '/' . $key . '.meta';
3497 + return XSPEED_CACHE_DIR . '/' . self::current_host_dir() . '/' . $key . '.meta';
515 3498 }
516 3499
517 3500 /**
518 3501 * Read the .meta sidecar for a cache entry as an array, or [] if none.
519 - * Keys: 'content_type' (string), 'status' (int). Used on the HIT path
520 - * to replay them before streaming the file.
3502 + * Keys: 'content_type' (string), 'status' (int), 'ttl' (int seconds).
3503 + * Used on the HIT path to replay content-type/status before streaming
3504 + * the file, and by Cache_GC to age an entry by its own TTL rather than
3505 + * the global one — hence public.
521 3506 */
522 - private static function read_meta( $key ): array {
3507 + public static function read_meta( $key ): array {
523 3508 $meta_file = self::cache_meta_for( $key );
524 3509 if ( ! file_exists( $meta_file ) ) {
525 3510 return array();
526 3511 }
@@ -537,8 +3522,19 @@
537 3522 * and returns true (caller should exit without a body). Returns false to
538 3523 * proceed with a normal 200 body. Lets aggregators/browsers skip
539 3524 * re-downloading an unchanged cached response. (FBS-82407 #5)
540 3525 *
3526 + * Also stamps `X-XSpeed-Built` from the same mtime. This is the PHP serve
3527 + * path's build time, and the only place on it that holds the file: mark()
3528 + * has already run by the time we get here and resolved the edge-header
3529 + * filter without one. Both are emitted, and a purge verifier prefers the
3530 + * stamp: `Last-Modified` carries the same integer, but it is a generic
3531 + * header that the origin-side cache layer such a verifier exists to
3532 + * detect — or any proxy in between — may rewrite to its own store time,
3533 + * which would turn a stale origin into a pass. Nothing but xSpeed's own
3534 + * serve code writes `X-XSpeed-Built`, so a value older than the purge is
3535 + * proof the origin answered with old HTML.
3536 + *
541 3537 * @param string $file Absolute path to the cache .html file.
542 3538 * @return bool True when a 304 was sent.
543 3539 */
544 3540 public static function serve_not_modified( string $file ): bool {
@@ -549,8 +3545,12 @@
549 3545 $last_modified = gmdate( 'D, d M Y H:i:s', $mtime ) . ' GMT';
550 3546 $etag = '"' . md5( $file . '|' . $mtime ) . '"';
551 3547 header( 'Last-Modified: ' . $last_modified );
552 3548 header( 'ETag: ' . $etag );
3549 + // Before the 304 branch below, so a conditional request that gets a
3550 + // bodyless 304 still carries the stamp. A verifier's fetch may well
3551 + // be conditional, and a 304 with no build time reads as unverifiable.
3552 + header( self::BUILT_HEADER . ': ' . $mtime );
553 3553
554 3554 $ims = isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ) ) : '';
555 3555 $inm = isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_NONE_MATCH'] ) ) ) : '';
556 3556
@@ -586,9 +3586,45 @@
586 3586 * @param int $max_age Computed max-age in seconds.
587 3587 */
588 3588 $max_age = (int) apply_filters( 'xspeed_cache_max_age', $max_age );
589 3589
590 - return ( time() - filemtime( $file ) ) > $max_age;
3590 + // Honour the per-entry TTL the .meta sidecar carries, when it is
3591 + // SHORTER than what we just resolved. The sidecar records the TTL
3592 + // this specific entry was written under — a nonce cap (#236), a Pro
3593 + // feed/404 expiry — and the drop-in already reads it. is_expired()
3594 + // did not, so on the engine path a capped entry was still served for
3595 + // the full configured lifetime: exactly the stale nonce the cap
3596 + // exists to prevent. Only ever shortens, so an entry can never be
3597 + // kept alive past the configured maximum by a stale sidecar.
3598 + // Derive the sidecar from the FILE we were handed rather than
3599 + // recomputing cache_key(): callers legitimately ask about an entry
3600 + // that isn't the current request's (Cache_GC sweeps, Pro's warmer),
3601 + // and cache_key() would answer for the wrong one — besides needing a
3602 + // request context this function has no business requiring.
3603 + $meta_file = preg_replace( '/\.html$/', '.meta', (string) $file );
3604 + if ( is_string( $meta_file ) && $meta_file !== $file && is_readable( $meta_file ) ) {
3605 + // 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.
3606 + $raw = file_get_contents( $meta_file );
3607 + $decoded = is_string( $raw ) ? json_decode( $raw, true ) : null;
3608 + if ( is_array( $decoded ) && isset( $decoded['ttl'] ) ) {
3609 + $entry_ttl = (int) $decoded['ttl'];
3610 + if ( $entry_ttl > 0 && ( $max_age < 1 || $entry_ttl < $max_age ) ) {
3611 + $max_age = $entry_ttl;
3612 + }
3613 + }
3614 + }
3615 +
3616 + // A missing file is "expired" — the caller should re-render. Guard
3617 + // filemtime() rather than letting it warn: callers legitimately ask
3618 + // about a file that isn't there (Pro's predictive warmer probes for
3619 + // freshness, and Cache_GC can collect an entry between the check and
3620 + // the read), and on a site with WP_DEBUG the warning is noise.
3621 + $mtime = file_exists( $file ) ? filemtime( $file ) : false;
3622 + if ( false === $mtime ) {
3623 + return true;
3624 + }
3625 +
3626 + return ( time() - (int) $mtime ) > $max_age;
591 3627 }
592 3628
593 3629 /**
594 3630 * Accumulator for the full response body across all output-handler phases.
@@ -619,8 +3655,13 @@
619 3655
620 3656 $full = self::$accumulated;
621 3657 self::$accumulated = '';
622 3658
3659 + // Consumed here, on every final path, so a later call can never
3660 + // compare against this request's snapshot.
3661 + $asset_stamp = self::$asset_stamp_at_open;
3662 + self::$asset_stamp_at_open = null;
3663 +
623 3664 if ( strlen( $full ) < 255 ) {
624 3665 return $buffer;
625 3666 }
626 3667
@@ -642,8 +3683,28 @@
642 3683 // the first visitor sees unminified HTML, every cache hit after that
643 3684 // is minified.
644 3685 $single_chunk = ( $buffer === $full );
645 3686
3687 + /**
3688 + * Filter: xspeed_cache_final_html
3689 + *
3690 + * Last chance to transform the fully-rendered page HTML before it is
3691 + * minified and written to the cache file. Runs on cache MISS only, so
3692 + * whatever a listener injects here is baked into the cached HTML and
3693 + * replayed on every subsequent HIT (the drop-in short-circuits before
3694 + * PHP on a HIT — a wp_head hook would never fire there).
3695 + *
3696 + * The Preload module uses this to inject the LCP-image <link rel=preload>
3697 + * + preconnect hints and add fetchpriority="high" to the hero <img>.
3698 + * Keep listeners fast and idempotent; this is the on-wire body.
3699 + *
3700 + * @param string $full Complete page HTML.
3701 + */
3702 + $full = (string) apply_filters( 'xspeed_cache_final_html', $full );
3703 + if ( $single_chunk ) {
3704 + $buffer = $full;
3705 + }
3706 +
646 3707 // minify_html now owned by the Minify module; read through the
647 3708 // module's storage so this stays consistent with the engine that
648 3709 // applies CSS/JS minification.
649 3710 $minify_opts = Settings_Manager::get( 'minify' );
@@ -653,27 +3714,129 @@
653 3714 $buffer = $full;
654 3715 }
655 3716 }
656 3717
657 - if ( ! file_exists( XSPEED_CACHE_DIR ) ) {
658 - wp_mkdir_p( XSPEED_CACHE_DIR );
659 - self::write_silence( XSPEED_CACHE_DIR );
3718 + // AFTER minification on purpose — the HTML minifier strips comments,
3719 + // so signing earlier would erase the signature from every minified
3720 + // page. Baked into the cached bytes so all three serve paths (nginx
3721 + // static rewrite, .htaccess, the PHP drop-in) carry it identically.
3722 + $full = self::signed( $full );
3723 + if ( $single_chunk ) {
3724 + $buffer = $full;
660 3725 }
661 3726
662 - // Path safety: cache_file_for() builds `XSPEED_CACHE_DIR . '/' . $key . '.html'`
663 - // where $key comes from md5() — guaranteed to be exactly 32 lowercase
664 - // hex chars, so no traversal sequence ('..', '/', null byte, etc.)
665 - // can appear. The write is therefore always inside XSPEED_CACHE_DIR.
666 - $key = self::cache_key();
3727 + // Per-site directory — see ensure_host_dir(). (#6)
3728 + self::ensure_host_dir();
3729 +
3730 + // Path safety: cache_file_for() builds
3731 + // `XSPEED_CACHE_DIR . '/' . <host> . '/' . $key . '.html'` where $key
3732 + // comes from md5() — guaranteed to be exactly 32 lowercase hex chars —
3733 + // and <host> is filtered by host_dir() to [A-Za-z0-9.-] with leading
3734 + // dots trimmed, so no traversal sequence ('..', '/', null byte, etc.)
3735 + // can appear in either segment. The write is therefore always inside
3736 + // XSPEED_CACHE_DIR.
3737 + $key = self::cache_key();
3738 +
3739 + // Query-string gate. should_cache() waved this request through
3740 + // because every param is on the ignored_query_params allow-list, and
3741 + // cache_key() drops the query so reads share the canonical entry.
3742 + // That sharing is safe on READ but not on WRITE: this response was
3743 + // rendered WITH the params, and WordPress reflects REQUEST_URI into
3744 + // form actions, share links and plugin smart tags — so storing it
3745 + // would serve an attacker-chosen variant under the clean URL for the
3746 + // whole TTL (#241).
3747 + //
3748 + // This sits BELOW the transforms deliberately. Returning above them
3749 + // also skipped xspeed_cache_final_html, and every listener disables
3750 + // its own fallback ob_start() when the page cache is on precisely
3751 + // because that filter is the shared transport — so a visitor
3752 + // arriving on ?utm_source=… was served HTML with no LCP preload, no
3753 + // preconnect, no CDN rewrite, no CSS combine and no HTML minify.
3754 + // That is the ad-click and newsletter cohort getting the least
3755 + // optimised page on the site. Only the WRITE is skipped, which is
3756 + // what this fix was always meant to do — and it is where the
3757 + // deferred writer has always placed its own copy of the guard.
3758 + if ( self::query_string_blocks_write() ) {
3759 + return $buffer;
3760 + }
3761 +
3762 + // A purge deleted minified or combined files while this page was
3763 + // rendering, so it may link names that are gone. Serve it, but do
3764 + // not store it; the next request renders against the files as they
3765 + // are now.
3766 + if ( self::assets_purged_since( $asset_stamp ) ) {
3767 + return $buffer;
3768 + }
667 3769 $file = self::cache_file_for( $key );
3770 +
3771 + // A render-time translation plugin (TranslatePress) wraps our buffer,
3772 + // so the bytes we hold here are still UNTRANSLATED — its callback has
3773 + // not run yet, and writing now would cache English under a French URL
3774 + // and bake in its internal #TRPLINKPROCESSED markers. Hand off to
3775 + // shutdown, where the outer buffer has already translated, and let
3776 + // the pass-through below deliver this request untouched.
3777 + if ( self::translation_plugin_active() ) {
3778 + self::$deferred_key = $key;
3779 + self::$deferred_asset_stamp = $asset_stamp;
3780 + // Reaching here means finalize_buffer() ran to completion: the
3781 + // status gate passed, should_cache() said yes, and PHP handed us
3782 + // the whole buffer. A wp_die() or exit() mid-render unwinds the
3783 + // buffer stack WITHOUT calling this callback, so the flag stays
3784 + // false and the shutdown writer declines — see the guard there.
3785 + self::$render_completed = true;
3786 + // A PHP shutdown function, not a WP `shutdown` action: this must
3787 + // run after the output-buffer stack has unwound, and WP's
3788 + // shutdown action fires while our outer buffer is still open.
3789 + register_shutdown_function( array( __CLASS__, 'write_deferred_translated_cache' ) );
3790 + return $buffer;
3791 + }
3792 +
668 3793 // 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.
669 - file_put_contents( $file, $full, LOCK_EX );
3794 + $stored = file_put_contents( $file, $full, LOCK_EX );
670 3795
3796 + /*
3797 + * No edge headers on a MISS. Not even a stored one.
3798 + *
3799 + * A MISS is the FIRST render, and it is the render most likely to be
3800 + * replaced: critical CSS and unused CSS are generated after the fact
3801 + * and applied to later requests, so the copy written here is the
3802 + * pre-optimization one. Telling a CDN to hold it pins exactly the
3803 + * version xSpeed is about to improve on — reported from a live site,
3804 + * where Cloudflare had cached an unoptimized first render.
3805 + *
3806 + * `CDN-Cache-Control` is why it reaches the edge at all: Cloudflare
3807 + * honours it as the CDN-targeted directive whatever its own cache
3808 + * rules say, so an origin header is enough to pin HTML for the whole
3809 + * TTL even where edge page caching is switched off.
3810 + *
3811 + * A HIT is the safe moment and the honest one: it means xSpeed is
3812 + * serving its stored copy, that copy is what the edge would mirror,
3813 + * and a later regeneration purges it — which reaches the edge through
3814 + * the purge actions. So the edge caches from the second visitor on,
3815 + * one render later than before and the right one.
3816 + */
3817 +
3818 + /**
3819 + * Fires after the flat hash cache file ({md5}.html) is written.
3820 + *
3821 + * Mirror of `xspeed_static_file_written` for the flat cache. The PHP
3822 + * serve path (Cache::maybe_serve_brotli / the drop-in) serves THIS
3823 + * file and looks for a `{md5}.html.br` sibling — which only the Pro
3824 + * Brotli listener on this hook writes. Without it the .br sibling was
3825 + * never created and the PHP path could never serve Brotli (FBS-83039,
3826 + * Blocker 2): the static-tree .br (written on xspeed_static_file_written)
3827 + * lives in a different cache layout the PHP path never reads.
3828 + *
3829 + * @param string $file Absolute path to the flat cache file just written.
3830 + * @param string $full The HTML written to it.
3831 + */
3832 + do_action( 'xspeed_flat_file_written', $file, $full );
3833 +
671 3834 // Persist a non-default Content-Type so the HIT path can replay it
672 3835 // (cached feeds must serve application/rss+xml, not text/html).
673 3836 // Only written when the response set a content-type other than
674 3837 // the HTML default — pages don't pay for an extra file.
675 - self::write_meta( $key );
3838 + self::write_meta( $key, $full );
676 3839
677 3840 // Static-cache tree (xspeed-static/{host}{path}/index.html). The
678 3841 // .htaccess rewrite block serves this file directly via the web
679 3842 // server, bypassing PHP for ~3-5× lower TTFB vs the drop-in path.
@@ -688,9 +3851,16 @@
688 3851 // 200, FBS-82406) or a non-HTML content-type (a cached feed would go
689 3852 // out as text/html, FBS-82407). The web server serves these .html files
690 3853 // directly with no PHP, so there's no .meta replay — keep them on the
691 3854 // drop-in / PHP path instead, which DOES replay status + content-type.
692 - if ( self::static_rewrite_allowed() && self::response_is_plain_html() ) {
3855 + // The static tree cannot replay a sidecar. A file served straight by
3856 + // the web server carries the headers baked into the rule that serves
3857 + // the whole site — the very answer this entry exists because it
3858 + // disagreed with. Same reasoning as the status and content-type
3859 + // cases: what the fast path cannot replay belongs on the drop-in path.
3860 + if ( self::static_rewrite_allowed()
3861 + && self::response_is_plain_html()
3862 + && array() === self::per_entry_edge_headers() ) {
693 3863 self::store_static( $full );
694 3864 }
695 3865
696 3866 return $buffer;
@@ -702,30 +3872,163 @@
702 3872 * rewrite block points at this path so cache hits skip PHP
703 3873 * entirely. Caller already minified/finalized $html.
704 3874 *
705 3875 * Path safety: $host is restricted to a `[a-zA-Z0-9.\-]` allowlist;
706 - * $uri has its query string stripped, null bytes removed, '..'
707 - * sequences collapsed, and after concatenation we verify the
708 - * resolved real path stays inside XSPEED_CACHE_STATIC_DIR before
709 - * any write. Anything off the happy path returns silently.
3876 + * the path goes through static_path(), which decodes it the way the
3877 + * web server will and refuses anything that could leave the host
3878 + * directory or share a file with another page. After the directory is
3879 + * created we verify its real path is inside XSPEED_CACHE_STATIC_DIR
3880 + * before any write. Anything off the happy path returns silently.
3881 + *
3882 + * INVARIANT — the static tree is keyed by `{host}{path}` and NOTHING
3883 + * else, and both generated rewrites refuse any request that carries a
3884 + * query string at all (`RewriteCond %{QUERY_STRING} ^$` on Apache,
3885 + * `if ($args)` in nginx_snippet()). So a response may only be stored
3886 + * here when cache_key() adds no discriminator beyond `{host}{path}`:
3887 + * a query-keyed entry can never be *served* from here, only mis-served
3888 + * as the bare path. Any future opt-in that folds a query param into the
3889 + * key needs a guard below, exactly like the search one.
710 3890 */
3891 + /**
3892 + * Transient holding the most recent static-tree refusal.
3893 + *
3894 + * Short-lived on purpose: it describes what the last cacheable render
3895 + * actually did, so a stale entry would keep warning about a page whose
3896 + * nonces have since been removed. A site that still refuses simply
3897 + * rewrites it on the next render. (#372)
3898 + */
3899 + private const STATIC_SKIP_TRANSIENT = 'xspeed_static_skip';
3900 +
3901 + /**
3902 + * Remember why a page was kept out of the static tree, for Health.
3903 + *
3904 + * Records the URL, the reason, and — for the nonce case — the distinct
3905 + * nonce KEYS found, which is what makes the finding actionable: the names
3906 + * (`eael_login_nonce`, `post_grid_pagination_nonce`, …) trace straight back
3907 + * to the plugin emitting them, and it is usually a widget the site does not
3908 + * use on that page. Only key names are kept, never the nonce values.
3909 + *
3910 + * @param string $reason Machine-readable refusal reason.
3911 + * @param string $html The response, for extracting the nonce keys.
3912 + */
3913 + private static function note_static_skip( string $reason, string $html = '' ): void {
3914 + if ( ! function_exists( 'set_transient' ) ) {
3915 + return;
3916 + }
3917 +
3918 + $keys = array();
3919 + if ( 'nonce' === $reason && '' !== $html ) {
3920 + // Must recognise the SAME shapes response_has_nonce() refuses on,
3921 + // or a page is skipped and reported with no keys at all — which is
3922 + // most of them, since the plain `name="_wpnonce"` form field is the
3923 + // commonest shape by far and only the JSON one was handled here.
3924 + // The keys are the actionable half of the message, so a mismatch
3925 + // leaves the admin with bad news and nothing to act on.
3926 + //
3927 + // Both alternations capture the KEY only: each value pattern sits
3928 + // outside the capture group, so a nonce secret can never be stored.
3929 + $found = array();
3930 + if ( preg_match_all( '/name=["\']([a-z0-9_\-\[\]]*nonce[a-z0-9_\-\[\]]*)["\']/i', $html, $m ) ) {
3931 + $found = array_merge( $found, $m[1] );
3932 + }
3933 + if ( preg_match_all( '/["\']([a-z0-9_\-]*nonce[a-z0-9_\-]*)["\']\s*:\s*["\'][a-f0-9]{8,}["\']/i', $html, $m ) ) {
3934 + $found = array_merge( $found, $m[1] );
3935 + }
3936 + // The query-arg shape (`?_wpnonce=…`) has no key name to report
3937 + // beyond the literal, so name it explicitly rather than reporting
3938 + // nothing for a page that was genuinely refused.
3939 + if ( preg_match( '/[?&]_wpnonce=/i', $html ) ) {
3940 + $found[] = '_wpnonce';
3941 + }
3942 + $keys = array_slice( array_values( array_unique( $found ) ), 0, 10 );
3943 + }
3944 +
3945 + set_transient(
3946 + self::STATIC_SKIP_TRANSIENT,
3947 + array(
3948 + 'reason' => $reason,
3949 + 'url' => esc_url_raw( self::request_path() ),
3950 + 'keys' => $keys,
3951 + 'at' => time(),
3952 + ),
3953 + HOUR_IN_SECONDS
3954 + );
3955 + }
3956 +
3957 + /**
3958 + * The most recent static-tree refusal, or an empty array when there is none.
3959 + *
3960 + * @return array{reason:string,url:string,keys:string[],at:int}|array{}
3961 + */
3962 + public static function last_static_skip(): array {
3963 + $stored = function_exists( 'get_transient' ) ? get_transient( self::STATIC_SKIP_TRANSIENT ) : false;
3964 + return is_array( $stored ) && ! empty( $stored['reason'] ) ? $stored : array();
3965 + }
3966 +
711 3967 private static function store_static( string $html ): void {
3968 + // Search results are keyed by term in cache_key() (`|s=<term>`) but
3969 + // carry the *path* of whatever URL was searched from — for the usual
3970 + // `/?s=<term>` that path is `/`. Writing them here would file the
3971 + // results page as `{host}/index.html` and the web server would serve
3972 + // it to every visitor as the homepage: an unauthenticated visitor
3973 + // poisons the front page with one request. Searches stay on the
3974 + // drop-in, which replays the term-keyed entry correctly. (#191)
3975 + //
3976 + // This is a superset of the query-string check the exclusion gate
3977 + // does: it also covers `/?%73=<term>`, which decodes to the same
3978 + // search (the shape #109 fixed on the gate side).
3979 + if ( self::should_cache_search() ) {
3980 + return;
3981 + }
3982 +
3983 + // Same hazard for the allow-listed query params: store_static()
3984 + // strips the query and files the response under the bare path, which
3985 + // the web server then serves to every visitor of the clean URL with
3986 + // no PHP involved at all — so none of the engine's checks can catch
3987 + // it later (#241). The callers already gate on this, but the guard
3988 + // is repeated here because this tree is the most dangerous of the
3989 + // three write sites and must not depend on its callers.
3990 + if ( self::request_has_query_string() ) {
3991 + return;
3992 + }
3993 +
3994 + // A nonce-bearing page is served here with NO PHP: no TTL check and
3995 + // no .meta replay, so the per-entry cap that keeps the drop-in honest
3996 + // (#236) cannot reach a file once it is written. Only Cache_GC removes
3997 + // it, and until it does the page hands every visitor the same nonce —
3998 + // which, once that nonce dies, breaks every anonymous form on it.
3999 + //
4000 + // Refusing outright was the safe answer, and it cost every
4001 + // nonce-bearing page the static tree entirely: a site whose homepage
4002 + // carries one unused login nonce ran PHP on every request forever.
4003 + // The nonce's own remaining life is the better gate — the page is
4004 + // written and its deadline recorded below for GC to enforce.
4005 + //
4006 + // A nonce we cannot put a clock on is still refused, and that refusal
4007 + // is still recorded: it stays completely silent otherwise, because the
4008 + // drop-in answers HIT while Health reports the fast path active from a
4009 + // probe that writes its OWN file and never proves real pages reach the
4010 + // tree. (#372)
4011 + $nonce_ttl = self::response_has_nonce( $html ) ? self::nonce_capped_ttl( $html, 0 ) : 0;
4012 + if ( self::response_has_nonce( $html ) && $nonce_ttl < 1 ) {
4013 + self::note_static_skip( 'nonce', $html );
4014 + return;
4015 + }
4016 +
712 4017 $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
713 - $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
714 - $host = preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host );
715 - $uri = str_replace( "\0", '', $uri );
716 - $uri = (string) strtok( $uri, '?' );
4018 + $host = self::static_host_dir( $host );
4019 + $uri = self::request_path();
717 4020 if ( '' === $host || '' === $uri ) {
718 4021 return;
719 4022 }
720 - // Collapse any traversal sequences before path resolution.
721 - $uri = preg_replace( '#/+#', '/', $uri );
722 - if ( false !== strpos( $uri, '..' ) ) {
4023 + // Decoded the way the web server will decode it, or refused.
4024 + $rel = self::static_path( $uri );
4025 + if ( null === $rel ) {
723 4026 return;
724 4027 }
725 4028
726 4029 $base = rtrim( XSPEED_CACHE_STATIC_DIR, '/' );
727 - $dir = $base . '/' . $host . rtrim( $uri, '/' );
4030 + $dir = $base . '/' . $host . $rel;
728 4031 $file = $dir . '/index.html';
729 4032
730 4033 // Resolve the parent against the cache root to be sure the
731 4034 // final path is inside our tree even if the OS does anything
@@ -740,11 +4043,28 @@
740 4043 }
741 4044 if ( ! is_dir( $dir ) ) {
742 4045 return;
743 4046 }
4047 + // static_path() already refuses every traversal spelling. This
4048 + // catches a directory in the tree that resolves somewhere else.
4049 + $dir_real = realpath( $dir );
4050 + $static_real = realpath( $base );
4051 + if ( false === $dir_real || false === $static_real || 0 !== strpos( $dir_real . '/', $static_real . '/' ) ) {
4052 + return;
4053 + }
744 4054 // 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.
745 4055 $written = file_put_contents( $file, $html, LOCK_EX );
746 4056
4057 + // A nonce-bearing page expires on the nonce's schedule, not the site's.
4058 + // Nothing reads this file at serve time — the web server hands over
4059 + // index.html without PHP — so the deadline is recorded beside it for
4060 + // GC, which is the only thing that can enforce it. Written before the
4061 + // action below so a listener that shells out cannot race the sweep.
4062 + if ( false !== $written && $nonce_ttl > 0 ) {
4063 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- same rationale as the write above.
4064 + file_put_contents( $dir . '/.xspeed-expires', (string) ( time() + $nonce_ttl ), LOCK_EX );
4065 + }
4066 +
747 4067 if ( false !== $written ) {
748 4068 /**
749 4069 * Fires after a static cache file (index.html) is written.
750 4070 *
@@ -796,9 +4116,236 @@
796 4116 }
797 4117 return true;
798 4118 }
799 4119
800 - private static function write_meta( string $key ): void {
4120 + /**
4121 + * Append the cache signature comment to a finished page.
4122 + *
4123 + * The plugin's one outward version signal: external scanners (the
4124 + * xspeedcache.com speed test among them) read it to detect xSpeed and
4125 + * its version on a cached page, the way other cache plugins sign their
4126 + * output. Callers apply it AFTER HTML minification — the minifier strips
4127 + * comments — and before every cache write, so all serve paths carry the
4128 + * same bytes.
4129 + *
4130 + * The generation time is baked in here, at write time, in UTC. It is the
4131 + * moment the cached bytes were produced — NOT the moment they were served
4132 + * — because all three serve paths replay the same stored file, and two of
4133 + * them (the nginx/`.htaccess` static rewrite) run no PHP at all and so
4134 + * could never stamp a serve-time value. Reading the age of a page is the
4135 + * point: `generated` plus the current clock tells you how stale it is.
4136 + * `gmdate()` (not `current_time()`) keeps the value comparable across
4137 + * sites regardless of the configured timezone.
4138 + *
4139 + * @param string $html Finished page HTML.
4140 + * @return string HTML with the signature appended (or unchanged when a
4141 + * filter removed it).
4142 + */
4143 + private static function signed( string $html ): string {
4144 + $version = defined( 'XSPEED_VERSION' ) ? XSPEED_VERSION : '';
4145 + $generated = gmdate( 'Y-m-d H:i:s' ) . ' UTC';
4146 + // The literal ' | xspeedcache.com' must survive intact, and what
4147 + // precedes it is where an edition suffix lands: Pro appends itself by
4148 + // str_replace()-ing on that exact token
4149 + // (Pro_Plugin::sign_cache_signature). So the stamp goes AFTER it —
4150 + // placed before, it sits between the version and the anchor and
4151 + // composes as "generated <date> + Pro v1.1.3".
4152 + $signature = sprintf(
4153 + '<!-- Page cached by xSpeed Cache v%s | xspeedcache.com | generated %s -->',
4154 + $version,
4155 + $generated
4156 + );
4157 +
4158 + /**
4159 + * Filter: xspeed_cache_signature
4160 + *
4161 + * The HTML comment appended to every cached page. Add-ons append
4162 + * their own edition/version here; white-label setups return '' to
4163 + * remove the comment entirely. Must remain a valid HTML comment (or
4164 + * an empty string) — it ships inside the cached body.
4165 + *
4166 + * @param string $signature The signature comment.
4167 + * @param string $version The plugin version baked into it.
4168 + * @param string $generated The write-time timestamp baked into it,
4169 + * formatted `Y-m-d H:i:s UTC`.
4170 + */
4171 + $signature = (string) apply_filters( 'xspeed_cache_signature', $signature, $version, $generated );
4172 + if ( '' === trim( $signature ) ) {
4173 + return $html;
4174 + }
4175 + return $html . "\n" . $signature;
4176 + }
4177 +
4178 + /**
4179 + * Does this response carry a WordPress nonce?
4180 + *
4181 + * Anonymous nonces depend only on the tick (user 0, empty session
4182 + * token), so they are identical for every visitor — which is exactly why
4183 + * they cache "successfully" and then fail silently once the tick moves.
4184 + *
4185 + * Matches any form field whose NAME contains "nonce" — `_wpnonce`,
4186 + * `_wpnonce_<action>`, Tutor's `_tutor_nonce`, CF7's `_wpcf7_nonce` and
4187 + * WooCommerce's `woocommerce-add-to-cart-nonce` (which does NOT start
4188 + * with an underscore, so a `_`-anchored pattern misses it) — plus the
4189 + * `_wpnonce=` form used in nonce-bearing URLs. Deliberately keyed on
4190 + * `name=` so prose, CSS classes and data attributes don't false-positive.
4191 + *
4192 + * @param string $html Rendered response body.
4193 + */
4194 + public static function response_has_nonce( string $html ): bool {
4195 + if ( '' === $html ) {
4196 + return false;
4197 + }
4198 +
4199 + /*
4200 + * Three shapes, because a nonce reaches the page in three ways:
4201 + *
4202 + * 1. A form field name — `_wpnonce`, `woocommerce-login-nonce`, and
4203 + * the GROUPED names form builders emit (`data[_wpnonce]`,
4204 + * `frm[nonce]`). The character class deliberately allows `[` and
4205 + * `]` so grouping does not hide the field: form builders are
4206 + * exactly the kind of plugin #236 is about, and a missed page
4207 + * keeps the old broken behaviour silently.
4208 + * 2. A query argument (`?_wpnonce=`) on a link.
4209 + * 3. A nonce handed to the page's own scripts rather than placed in
4210 + * a visible form — `wp_localize_script()` output and inline JSON
4211 + * both land as a `"nonce":"…"`-shaped pair.
4212 + */
4213 + return 1 === preg_match(
4214 + '/(name=["\'][a-z0-9_\-\[\]]*nonce[a-z0-9_\-\[\]]*["\']'
4215 + . '|[?&]_wpnonce='
4216 + . '|["\'][a-z0-9_\-]*nonce[a-z0-9_\-]*["\']\s*:\s*["\'][a-f0-9]{8,}["\'])/i',
4217 + $html
4218 + );
4219 + }
4220 +
4221 + /**
4222 + * The TTL (seconds) a response may be cached for, capped to the nonce
4223 + * lifetime when it carries one.
4224 + *
4225 + * WordPress nonces are valid for at most `nonce_life` — 24h by default —
4226 + * because wp_verify_nonce() accepts the current tick and the previous
4227 + * one. Our own lifetime maximum is 720h and the shipped Aggressive
4228 + * preset is 168h, so on any site configured above 24h every anonymous
4229 + * front-end form carried a DEAD nonce for the majority of the cache's
4230 + * life and every submission was rejected — with the other plugin's error
4231 + * string ("Nonce not matched"), so the report never reached us (#236).
4232 + *
4233 + * `nonce_life` is the MAXIMUM a nonce can live, not the minimum, so it
4234 + * is the wrong number to cap with. wp_nonce_tick() buckets time into
4235 + * `nonce_life / 2` slices; a nonce minted x seconds into its bucket is
4236 + * valid for `nonce_life - x`, where x can be as large as a full bucket.
4237 + * Capping the entry at `nonce_life` therefore still served a dead nonce
4238 + * for up to half of every entry's life — 0-12h of each 24h entry,
4239 + * averaging 6h, re-rolled by every purge so it reads as intermittent.
4240 + * Capping at the guaranteed-valid remainder closes the window at every
4241 + * tick phase, at the cost of caching nonce-bearing pages for 12h rather
4242 + * than 24h.
4243 + *
4244 + * Capping is per-entry, so only nonce-bearing pages pay for it; the rest
4245 + * of the site keeps the configured lifetime.
4246 + *
4247 + * @param string $html Rendered response body.
4248 + * @param int $ttl Otherwise-resolved TTL in seconds.
4249 + * @return int TTL to actually use.
4250 + */
4251 + /**
4252 + * The nonce lifetime to cap against, in seconds.
4253 + *
4254 + * `nonce_life` is a TWO-argument filter in core:
4255 + *
4256 + * $nonce_life = apply_filters( 'nonce_life', DAY_IN_SECONDS, $action );
4257 + *
4258 + * Applying it with one argument is not merely incomplete — a callback
4259 + * that declares both parameters as required (the documented shape, and
4260 + * what a site branching per action must write) raises ArgumentCountError
4261 + * the moment we call it. That fatal lands in the shutdown cache write,
4262 + * so the visitor still sees a perfectly normal page while the sidecar is
4263 + * never written: the entry then keeps the FULL configured lifetime
4264 + * carrying a dead nonce, which is precisely the bug #236 set out to fix.
4265 + * Worse, the entry stays that way until a purge, even after the site
4266 + * removes whatever customised the lifetime.
4267 + *
4268 + * We are inspecting rendered markup, so we cannot know which action
4269 + * minted the nonce we found. Two consequences:
4270 + *
4271 + * 1. We pass `''` as the action. A per-action callback therefore sees
4272 + * the same "unknown action" value core itself passes when a nonce is
4273 + * created with no action, and can branch on it deliberately.
4274 + * 2. A page may carry nonces from SEVERAL actions with different
4275 + * lifetimes. The entry can only have one TTL, so the safe choice is
4276 + * the SHORTEST lifetime any action on the site resolves to — capping
4277 + * to a longer one would serve a dead nonce for the shorter action.
4278 + * Sites can narrow this with `xspeed_cache_nonce_life_actions`.
4279 + *
4280 + * @param string $html Response body being cached.
4281 + * @return int Nonce lifetime in seconds (0 = do not cap).
4282 + */
4283 + private static function nonce_life_seconds( string $html ): int {
4284 + /**
4285 + * Filter the nonce actions whose lifetimes are consulted when
4286 + * capping a cache entry.
4287 + *
4288 + * The default `''` is the "action unknown" case — we are reading
4289 + * rendered HTML, not minting a nonce. A site whose `nonce_life`
4290 + * callback shortens specific actions can list them here so the cap
4291 + * accounts for the shortest one that could appear on the page.
4292 + *
4293 + * @since 1.1.8
4294 + * @param string[] $actions Nonce actions to resolve.
4295 + * @param string $html The response body being cached.
4296 + */
4297 + $actions = (array) apply_filters( 'xspeed_cache_nonce_life_actions', array( '' ), $html );
4298 + if ( empty( $actions ) ) {
4299 + $actions = array( '' );
4300 + }
4301 +
4302 + $shortest = 0;
4303 + foreach ( $actions as $action ) {
4304 + // Both arguments, exactly as core passes them.
4305 + $life = (int) apply_filters( 'nonce_life', DAY_IN_SECONDS, (string) $action );
4306 + if ( $life < 1 ) {
4307 + continue;
4308 + }
4309 + if ( 0 === $shortest || $life < $shortest ) {
4310 + $shortest = $life;
4311 + }
4312 + }
4313 +
4314 + return $shortest;
4315 + }
4316 +
4317 + public static function nonce_capped_ttl( string $html, int $ttl ): int {
4318 + if ( ! self::response_has_nonce( $html ) ) {
4319 + return $ttl;
4320 + }
4321 +
4322 + $nonce_life = self::nonce_life_seconds( $html );
4323 + if ( $nonce_life < 1 ) {
4324 + return $ttl;
4325 + }
4326 +
4327 + // Half of nonce_life is the GUARANTEED-valid remainder — see above.
4328 + $guaranteed = max( 1, intdiv( $nonce_life, 2 ) );
4329 + $capped = ( $ttl > 0 ) ? min( $ttl, $guaranteed ) : $guaranteed;
4330 +
4331 + /**
4332 + * Filter the nonce-capped TTL for a cache entry.
4333 + *
4334 + * Escape hatch for a site whose nonce-shaped markup is decorative —
4335 + * return the uncapped $ttl to keep the configured lifetime. Most
4336 + * sites should leave this alone: serving a dead nonce breaks every
4337 + * anonymous form on the page.
4338 + *
4339 + * @param int $capped TTL after the nonce cap (seconds).
4340 + * @param int $ttl TTL before the cap (seconds).
4341 + * @param int $nonce_life Current nonce lifetime (seconds).
4342 + * @param string $html The response body being cached.
4343 + */
4344 + return (int) apply_filters( 'xspeed_cache_nonce_ttl_cap', $capped, $ttl, $nonce_life, $html );
4345 + }
4346 +
4347 + private static function write_meta( string $key, string $html = '' ): void {
801 4348 $content_type = '';
802 4349 foreach ( headers_list() as $header ) {
803 4350 if ( 0 === stripos( $header, 'content-type:' ) ) {
804 4351 $content_type = trim( substr( $header, strlen( 'content-type:' ) ) );
@@ -819,15 +4366,52 @@
819 4366 // call is_expired() / the xspeed_cache_max_age filter (they run before
820 4367 // WP), so persist the resolved max-age here whenever it differs from
821 4368 // the plain page TTL — e.g. the Pro feed cache's 12h vs the 24h page
822 4369 // default. The fast paths read this to expire correctly. (FBS-82407)
823 - $opts = Settings_Manager::get( 'cache' );
824 - $default_ttl = (int) $opts['cache_expiry'] * HOUR_IN_SECONDS;
825 - $ttl = (int) apply_filters( 'xspeed_cache_max_age', $default_ttl );
4370 + // This MUST resolve the TTL the same way is_expired() does, including
4371 + // the per-post override — the sidecar is the only channel that can
4372 + // carry a per-entry TTL into the pre-boot fast paths. Omitting the
4373 + // override here left an editor's "expire this post after 1h" visible
4374 + // to the engine but invisible to the drop-in, which kept serving the
4375 + // entry until the global lifetime elapsed (#240 AC#3). Handing the
4376 + // filter the same base as is_expired() also keeps a filter that
4377 + // SCALES its input (e.g. $max_age * 2) consistent between the two.
4378 + $opts = Settings_Manager::get( 'cache' );
4379 + $default_ttl = (int) $opts['cache_expiry'] * HOUR_IN_SECONDS;
4380 + $max_age = $default_ttl;
4381 + $post_override = Cache_Rules::expiry_override_seconds_for_post( Cache_Rules::current_post_id() );
4382 + if ( null !== $post_override ) {
4383 + $max_age = $post_override;
4384 + }
4385 + /** This filter is documented in includes/class-cache.php */
4386 + $ttl = (int) apply_filters( 'xspeed_cache_max_age', $max_age );
4387 +
4388 + // A response carrying a nonce may not outlive that nonce, however
4389 + // long the site's configured lifetime is (#236). This runs AFTER the
4390 + // max-age filter so it caps whatever the filter resolved rather than
4391 + // being overridden by it — a Pro module lengthening the TTL must not
4392 + // be able to reintroduce a dead nonce.
4393 + $ttl = self::nonce_capped_ttl( $html, $ttl );
4394 +
826 4395 if ( $ttl > 0 && $ttl !== $default_ttl ) {
827 4396 $meta['ttl'] = $ttl;
828 4397 }
829 4398
4399 + // This entry's edge headers, when they differ from the site-wide set
4400 + // baked into the drop-in. The sidecar is the only channel that can
4401 + // carry a per-page answer into the pre-boot fast path, and the drop-in
4402 + // REPLACES the baked set with it rather than merging: the two describe
4403 + // the same response, so merging would leave the baked lifetime in
4404 + // place beside the hold meant to overrule it.
4405 + //
4406 + // The lifetime resolved above goes with it, under the same condition
4407 + // as the `ttl` key: a page that does not follow the site's lifetime
4408 + // may not be kept at the edge past its own either.
4409 + $edge = self::per_entry_edge_headers( isset( $meta['ttl'] ) ? $ttl : null );
4410 + if ( array() !== $edge ) {
4411 + $meta['edge_headers'] = $edge;
4412 + }
4413 +
830 4414 // Nothing to replay → no sidecar.
831 4415 if ( empty( $meta ) ) {
832 4416 return;
833 4417 }
@@ -845,49 +4429,2189 @@
845 4429 * Activity log to give users context (e.g.
846 4430 * 'post saved', 'settings change', 'manual',
847 4431 * 'theme switch').
848 4432 */
849 - public static function purge_all( string $cause = 'manual' ) {
4433 + /**
4434 + * Purge the cache entries for ONE URL — every variant of it: the
4435 + * flat-hash entry (+ .meta / .html.br siblings), both device buckets
4436 + * (mobile_separate keys them separately), both trailing-slash forms,
4437 + * and the static-tree index.html (+ .br) the server rewrite serves.
4438 + * The rest of the cache is untouched — this is the surgical
4439 + * alternative to purge_all for "I just edited this one page".
4440 + *
4441 + * @param string $url Absolute URL, or site-relative path ("/about/").
4442 + * @param string $cause Who asked, for the purge log. See purge_all().
4443 + * @return int Number of cache files removed.
4444 + */
4445 + /**
4446 + * Post types that are not "viewable" but ARE the presentation layer.
4447 + *
4448 + * `is_post_type_viewable()` answers "does this type have a front end of
4449 + * its own?" — which is the right question for `shop_order`, but the
4450 + * wrong one for the types core uses to render every OTHER page. A
4451 + * template part, a global-styles record, a navigation or a synced
4452 + * pattern has no permalink, yet editing one changes how the whole site
4453 + * looks. Gating purges on viewability alone meant a Site Editor save
4454 + * invalidated nothing and visitors kept the old design for the full
4455 + * TTL — up to 30 days at the maximum lifetime. (#270 regression)
4456 + *
4457 + * @return string[]
4458 + */
4459 + /**
4460 + * Could this post change alter anything an anonymous visitor had cached?
4461 + *
4462 + * Deleting one post fired a full purge for the post AND for every stored
4463 + * revision, because wp_delete_post() removes each revision through
4464 + * wp_delete_post() again and every one of those fires before_delete_post
4465 + * with post_type 'revision'. A post with six revisions cost seven whole-
4466 + * site sweeps, each one also announcing to LiteSpeed, purging the object
4467 + * cache network-wide on Redis, rewriting the stats option and running
4468 + * every xspeed_after_purge_all listener -- including Pro's Cloudflare
4469 + * purge, so seven API calls. Trashing cost two, via save_post and then
4470 + * trashed_post. (QA #348)
4471 + *
4472 + * The check lives here, ahead of purge_all(), so one early return covers
4473 + * the local sweep, the server-cache announcement and both action hooks.
4474 + * It deliberately does NOT live inside purge_all(): a manual, CLI or
4475 + * explicit caller asked for a purge and must get one.
4476 + *
4477 + * @param int $post_id Post being saved or removed.
4478 + * @param mixed $post Post object when the hook passed one.
4479 + * @param string $event 'save' or 'remove'.
4480 + */
4481 + private static function post_change_is_cacheable_content( $post_id, $post, string $event ): bool {
4482 + $post_id = (int) $post_id;
4483 +
4484 + // Only `save_post` and `before_delete_post` hand over a post object.
4485 + // `trashed_post` passes ( $post_id, $previous_status ) -- a STRING --
4486 + // so reaching for ->post_status on the second argument finds nothing
4487 + // and the status rule below would never fire. Read the row instead.
4488 + if ( ! is_object( $post ) && function_exists( 'get_post' ) ) {
4489 + $post = get_post( $post_id );
4490 + }
4491 +
4492 + $type = is_object( $post ) && isset( $post->post_type )
4493 + ? (string) $post->post_type
4494 + : (string) ( function_exists( 'get_post_type' ) ? get_post_type( $post_id ) : '' );
4495 + if ( '' === $type ) {
4496 + return false;
4497 + }
4498 +
4499 + // A revision is a copy of content nobody can browse to.
4500 + if ( 'revision' === $type ) {
4501 + return false;
4502 + }
4503 + if ( function_exists( 'wp_is_post_revision' ) && wp_is_post_revision( $post_id ) ) {
4504 + return false;
4505 + }
4506 + if ( function_exists( 'wp_is_post_autosave' ) && wp_is_post_autosave( $post_id ) ) {
4507 + return false;
4508 + }
4509 +
4510 + $status = is_object( $post ) && isset( $post->post_status ) ? (string) $post->post_status : '';
4511 +
4512 + // Clicking "Add New" inserts an auto-draft and fires save_post. There
4513 + // is nothing cached of a post that has never existed publicly.
4514 + if ( 'auto-draft' === $status ) {
4515 + return false;
4516 + }
4517 +
4518 + // Unknown/!viewable → nothing anonymous can see changed, UNLESS the
4519 + // type is itself part of how pages render (#270 regression).
4520 + if ( function_exists( 'is_post_type_viewable' )
4521 + && ! is_post_type_viewable( $type )
4522 + && ! in_array( $type, self::presentation_post_types(), true )
4523 + ) {
4524 + return false;
4525 + }
4526 +
4527 + // Deleting something that was already invisible changes no cached
4528 + // page: the transition that hid it purged at the time. This is what
4529 + // makes emptying a trash of a hundred posts cost nothing rather than
4530 + // a hundred full sweeps.
4531 + //
4532 + // It also collapses trashing to a single purge: wp_trash_post() fires
4533 + // save_post first, where the post is genuinely disappearing from
4534 + // listings and SHOULD purge, then trashed_post, by which point the
4535 + // row reads 'trash' and is skipped. A status we cannot read, on a row
4536 + // that still reports a type, means assume viewable -- erring toward
4537 + // an extra purge, never toward serving a stale page. A row that is
4538 + // gone entirely reports no type either and was refused above.
4539 + // 'inherit' is an INTERNAL status in core, so is_post_status_viewable()
4540 + // says no -- but an attachment carrying it is genuinely public. Judge
4541 + // those on the post type alone, which is already checked above.
4542 + if ( 'remove' === $event && '' !== $status && 'inherit' !== $status
4543 + && function_exists( 'is_post_status_viewable' )
4544 + && ! is_post_status_viewable( $status )
4545 + ) {
4546 + return false;
4547 + }
4548 +
4549 + return true;
4550 + }
4551 +
4552 + public static function presentation_post_types(): array {
4553 + $types = array(
4554 + 'wp_template', // Site Editor templates.
4555 + 'wp_template_part', // Header / footer / reusable parts.
4556 + 'wp_global_styles', // Colours, typography, spacing.
4557 + 'wp_navigation', // Navigation block menus.
4558 + 'nav_menu_item', // Classic menus.
4559 + 'wp_block', // Synced patterns / reusable blocks.
4560 + );
4561 +
4562 + /**
4563 + * Filter the non-viewable post types that still invalidate the cache.
4564 + *
4565 + * Add a type here when it has no front end of its own but changes
4566 + * how other pages render (a theme's own layout CPT, for example).
4567 + *
4568 + * @param string[] $types Post type slugs.
4569 + */
4570 + return (array) apply_filters( 'xspeed_presentation_post_types', $types );
4571 + }
4572 +
4573 + /**
4574 + * Describe a broad hook invalidation for response-cache adapters.
4575 + *
4576 + * Term, menu, theme and plugin changes can alter navigation, archives or
4577 + * markup across the site, so they require a site response-cache purge.
4578 + * Content saves also require this scope while their local operation is a
4579 + * complete bucket sweep.
4580 + *
4581 + * A new term is `content`, not `presentation`. It has no posts yet, so no
4582 + * page renders it until a post is saved with it, and that save is its own
4583 + * content purge. Classed as presentation, it cleared the host's whole
4584 + * nginx cache every time a post was published with a tag that did not
4585 + * exist yet, which is most publishing. Renaming or deleting a term stays
4586 + * presentation: the new name shows on every post in the term, and Nginx
4587 + * Helper purges only the homepage for either. (QA #448)
4588 + *
4589 + * @return array{scope:string,intent:string,urls:array<int,string>}
4590 + */
4591 + private static function invalidation_for_hook( string $hook ): array {
4592 + $presentation = array(
4593 + 'switch_theme',
4594 + 'activated_plugin',
4595 + 'deactivated_plugin',
4596 + 'edited_term',
4597 + 'delete_term',
4598 + 'wp_update_nav_menu',
4599 + );
4600 +
4601 + return array(
4602 + 'scope' => 'site',
4603 + 'intent' => in_array( $hook, $presentation, true ) ? 'presentation' : 'content',
4604 + 'urls' => array(),
4605 + );
4606 + }
4607 +
4608 +
4609 + /**
4610 + * save_post → purge only when the saved thing can appear on a cached page.
4611 + *
4612 + * Revisions and autosaves are never rendered. Non-viewable post types —
4613 + * WooCommerce's `shop_order` / `shop_order_placehold` / `shop_order_refund`
4614 + * / `shop_coupon`, Flamingo's `flamingo_inbound` (#229), Tutor's
4615 + * `tutor_enrolled` (#231) — are invisible to anonymous visitors, so
4616 + * writing one changes nothing that is cached. (#243)
4617 + *
4618 + * The exception is the presentation types above, which are non-viewable
4619 + * yet render every page — they are allow-listed BEFORE the viewability
4620 + * test. (#270 regression)
4621 + *
4622 + * @param int $post_id Saved post ID.
4623 + * @param \WP_Post $post Saved post object.
4624 + */
4625 + public static function on_save_post( $post_id, $post = null ): void {
4626 + if ( ! self::post_change_is_cacheable_content( $post_id, $post, 'save' ) ) {
4627 + return;
4628 + }
4629 +
4630 + $post_type = is_object( $post ) && isset( $post->post_type )
4631 + ? (string) $post->post_type
4632 + : (string) get_post_type( $post_id );
4633 +
4634 + // Name the trigger rather than logging a bare numeric id — the old
4635 + // wiring passed the post ID into $cause, so the log read
4636 + // "Cache purged (46)" with no indication of what caused it. (#243)
4637 + $presentation = in_array( $post_type, self::presentation_post_types(), true );
4638 +
4639 + // Clearing only the affected pages needs the post's terms, and the
4640 + // REST API sets those after `save_post`. Note the post and purge on
4641 + // `wp_after_insert_post`; flush_pending_saves() covers a save that
4642 + // never gets there.
4643 + if ( ! $presentation && self::narrow_purge_applies( $post_type ) ) {
4644 + self::$pending_saves[ (int) $post_id ] = true;
4645 + return;
4646 + }
4647 +
4648 + self::purge_all(
4649 + 'post:' . $post_type,
4650 + null,
4651 + array(
4652 + // purge_all() sweeps every local response in this site's bucket.
4653 + // Without dependency tracking, the server cache must match that
4654 + // same boundary or unrelated pages can remain stale there.
4655 + 'scope' => 'site',
4656 + 'intent' => $presentation ? 'presentation' : 'content',
4657 + 'urls' => array(),
4658 + )
4659 + );
4660 + }
4661 +
4662 + /**
4663 + * Reasons a change that would have cleared only its own pages cleared
4664 + * the whole site, published as `fallback` in the purge context.
4665 + *
4666 + * - THEME_LIST: the change alters a post or comment list the theme draws
4667 + * on pages the rules cannot name.
4668 + * - LIMIT: the affected pages are more than Affected_Pages::LIMIT.
4669 + * - PENDING: the save never reached `wp_after_insert_post`, so there was
4670 + * no before-copy to work the old address out from.
4671 + * - FILTER: `xspeed_purge_affected_pages` returned false.
4672 + * - LISTING: the record of pages that run a post list of their own
4673 + * (Listing_Pages) cannot answer: a page of the type went unrecorded at
4674 + * its cap, more than Affected_Pages::LIMIT pages of the type would
4675 + * have to be named, or the index does not read back. Or the pages it
4676 + * named took the list past Affected_Pages::LIMIT.
4677 + *
4678 + * Every other purge publishes ''. A post type that always purges the
4679 + * whole site (a WooCommerce product) is a choice, not a fallback, and
4680 + * publishes '' too.
4681 + */
4682 + public const FALLBACK_THEME_LIST = 'theme_list';
4683 + public const FALLBACK_LIMIT = 'limit';
4684 + public const FALLBACK_PENDING = 'pending';
4685 + public const FALLBACK_FILTER = 'filter';
4686 + public const FALLBACK_LISTING = 'listing';
4687 +
4688 + /**
4689 + * Posts noted by on_save_post() for a narrow purge, keyed by ID.
4690 + *
4691 + * @var array<int,bool>
4692 + */
4693 + private static $pending_saves = array();
4694 +
4695 + /**
4696 + * Posts whose save purged their pages in this request, keyed by ID. That
4697 + * purge named page 1 of the posts page, so a sticky change made after
4698 + * it (the classic editor and Quick Edit stick after saving) has nothing
4699 + * left to clear there.
4700 + *
4701 + * @var array<int,bool>
4702 + */
4703 + private static $saves_purged = array();
4704 +
4705 + /**
4706 + * Purge the pages a save affected, once WordPress has finished saving.
4707 + *
4708 + * @param int $post_id Post ID.
4709 + * @param \WP_Post $post Post as saved.
4710 + * @param bool $update Whether an existing post was updated.
4711 + * @param \WP_Post|null $post_before Post before the save, null when new.
4712 + */
4713 + 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.
4714 + $post_id = (int) $post_id;
4715 + if ( ! isset( self::$pending_saves[ $post_id ] ) ) {
4716 + return;
4717 + }
4718 + unset( self::$pending_saves[ $post_id ] );
4719 + if ( ! $post instanceof \WP_Post ) {
4720 + $post = get_post( $post_id );
4721 + }
4722 + if ( ! $post instanceof \WP_Post ) {
4723 + return;
4724 + }
4725 + $post_before = $post_before instanceof \WP_Post ? $post_before : null;
4726 + if ( self::repeats_block_editor_save( $post, $post_before ) ) {
4727 + Affected_Pages::forget( $post_id );
4728 + return;
4729 + }
4730 + self::$saves_purged[ $post_id ] = true;
4731 + self::purge_post_change( $post, $post_before, 'post:' . $post->post_type );
4732 + }
4733 +
4734 + /** Meta WordPress rewrites on every save, which says nothing about the page. */
4735 + private const SAVE_NOISE_META = array( '_edit_lock', '_edit_last', '_encloseme', '_pingme' );
4736 +
4737 + /**
4738 + * Whether this save repeats the block editor save that just purged.
4739 + *
4740 + * The block editor saves a post through REST, then, when the screen has
4741 + * meta boxes (xSpeed's own Cache Rules box is one), posts them to
4742 + * post.php?meta-box-loader=1, which saves the post a second time. Both
4743 + * requests purged, so every save sent the same pages to every cache in
4744 + * front twice. The REST save notes what it purged for; the meta box
4745 + * request skips its purge when nothing a visitor sees has changed since:
4746 + * the post's fields, its terms and its meta. A meta box that saved meta
4747 + * the page prints (an SEO title, a custom field) still purges.
4748 + *
4749 + * @param \WP_Post $post Post as saved.
4750 + * @param \WP_Post|null $before Post before the save.
4751 + */
4752 + private static function repeats_block_editor_save( \WP_Post $post, ?\WP_Post $before ): bool {
4753 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- only names the request; WordPress verified the save's own nonce.
4754 + $meta_boxes = isset( $_GET['meta-box-loader'] );
4755 + $rest = defined( 'REST_REQUEST' ) && REST_REQUEST;
4756 + if ( ! $meta_boxes && ! $rest ) {
4757 + return false;
4758 + }
4759 + if ( ! Affected_Pages::is_public( $post ) && ! ( $before instanceof \WP_Post && Affected_Pages::is_public( $before ) ) ) {
4760 + return false;
4761 + }
4762 + $key = 'xspeed_saved_' . (int) $post->ID;
4763 + $fingerprint = self::save_fingerprint( $post );
4764 + if ( $meta_boxes ) {
4765 + if ( get_transient( $key ) === $fingerprint ) {
4766 + delete_transient( $key );
4767 + return true;
4768 + }
4769 + return false;
4770 + }
4771 + set_transient( $key, $fingerprint, 2 * MINUTE_IN_SECONDS );
4772 + return false;
4773 + }
4774 +
4775 + /**
4776 + * A hash of what a save can change that a visitor sees: the post's own
4777 + * fields, its terms and its meta.
4778 + *
4779 + * @param \WP_Post $post Post as saved.
4780 + */
4781 + private static function save_fingerprint( \WP_Post $post ): string {
4782 + $fields = array();
4783 + 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 ) {
4784 + $fields[ $field ] = (string) ( $post->$field ?? '' );
4785 + }
4786 + $terms = array();
4787 + $taxonomies = get_object_taxonomies( (string) $post->post_type );
4788 + if ( is_array( $taxonomies ) && array() !== $taxonomies ) {
4789 + $ids = wp_get_object_terms( (int) $post->ID, $taxonomies, array( 'fields' => 'tt_ids' ) );
4790 + $terms = is_array( $ids ) ? array_map( 'intval', $ids ) : array();
4791 + sort( $terms );
4792 + }
4793 + $meta = get_post_meta( (int) $post->ID );
4794 + $meta = is_array( $meta ) ? array_diff_key( $meta, array_flip( self::SAVE_NOISE_META ) ) : array();
4795 + ksort( $meta );
4796 + return md5( (string) wp_json_encode( array( $fields, $terms, $meta ) ) );
4797 + }
4798 +
4799 + /**
4800 + * A save that never reached `wp_after_insert_post` still purges.
4801 + *
4802 + * `wp_insert_post()` called with `$fire_after_hooks = false` leaves that
4803 + * hook to its caller, and a caller can fail to fire it. Without this, the
4804 + * save would purge nothing at all. Site-wide, because there is no
4805 + * before-copy to work out the old address from.
4806 + */
4807 + public static function flush_pending_saves(): void {
4808 + if ( array() === self::$pending_saves ) {
4809 + return;
4810 + }
4811 + $ids = array_keys( self::$pending_saves );
4812 + self::$pending_saves = array();
4813 + self::purge_all(
4814 + 'post:pending',
4815 + null,
4816 + array(
4817 + 'scope' => 'site',
4818 + 'intent' => 'content',
4819 + 'urls' => array(),
4820 + 'fallback' => self::FALLBACK_PENDING,
4821 + )
4822 + );
4823 + foreach ( $ids as $id ) {
4824 + Affected_Pages::forget( (int) $id );
4825 + }
4826 + }
4827 +
4828 + /**
4829 + * Before an update is written: note the post's neighbours, which a new
4830 + * date or category takes it away from. Only for a post visitors can see
4831 + * now, and only when the save will purge narrowly; that costs four
4832 + * queries per update.
4833 + *
4834 + * @param int $post_id Post ID.
4835 + * @param array $data Unused: the new values.
4836 + */
4837 + public static function on_pre_post_update( $post_id, $data = array() ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- hook signature.
4838 + $post = get_post( (int) $post_id );
4839 + if ( ! $post instanceof \WP_Post || ! Affected_Pages::is_public( $post ) ) {
4840 + return;
4841 + }
4842 + if ( in_array( (string) $post->post_type, self::presentation_post_types(), true ) || ! self::narrow_purge_applies( (string) $post->post_type ) ) {
4843 + return;
4844 + }
4845 + Affected_Pages::remember_old_neighbours( $post );
4846 + }
4847 +
4848 + /**
4849 + * A post was stuck or unstuck.
4850 + *
4851 + * A theme list that puts sticky posts first (Twenty Twenty-Five's "More
4852 + * posts" under every post) changes on every page that draws it, so the
4853 + * whole site goes. The block editor changes stickiness inside the save,
4854 + * and the save's own purge sees it then. The classic editor and Quick
4855 + * Edit change it after the save has purged, which is why this hook
4856 + * purges the posts no save is waiting on.
4857 + *
4858 + * @param mixed $old_value Sticky post IDs before.
4859 + * @param mixed $value Sticky post IDs after.
4860 + */
4861 + public static function on_sticky_posts_change( $old_value, $value ): void {
4862 + $blog_page = false;
4863 + foreach ( Affected_Pages::remember_sticky_change( $old_value, $value ) as $post_id ) {
4864 + if ( isset( self::$pending_saves[ $post_id ] ) ) {
4865 + continue;
4866 + }
4867 + $post = get_post( $post_id );
4868 + if ( ! $post instanceof \WP_Post || ! Affected_Pages::is_public( $post ) || ! self::narrow_purge_applies( (string) $post->post_type ) ) {
4869 + continue;
4870 + }
4871 + if ( Affected_Pages::lists_show_sticky( (string) $post->post_type ) ) {
4872 + self::purge_all(
4873 + 'sticky:' . $post->post_type,
4874 + null,
4875 + array(
4876 + 'scope' => 'site',
4877 + 'intent' => 'content',
4878 + 'urls' => array(),
4879 + 'fallback' => self::FALLBACK_THEME_LIST,
4880 + )
4881 + );
4882 + return;
4883 + }
4884 + // WordPress's own blog list puts sticky posts first on its first
4885 + // page, whatever the theme draws, so a post stuck or unstuck by
4886 + // code, WP-CLI or a plugin moves on that page. A save in this
4887 + // request already cleared it.
4888 + if ( 'post' === $post->post_type && ! isset( self::$saves_purged[ $post_id ] ) ) {
4889 + $blog_page = true;
4890 + }
4891 + }
4892 + if ( $blog_page ) {
4893 + $url = Affected_Pages::posts_page_url();
4894 + if ( '' !== $url ) {
4895 + self::purge_urls( array( $url ), 'sticky:post' );
4896 + }
4897 + }
4898 + }
4899 +
4900 + /**
4901 + * The first post stuck on a site: WordPress creates `sticky_posts`
4902 + * instead of updating it, so update_option_sticky_posts never fires.
4903 + *
4904 + * @param string $option Option name.
4905 + * @param mixed $value Sticky post IDs.
4906 + */
4907 + public static function on_sticky_posts_added( $option, $value ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed -- hook signature.
4908 + self::on_sticky_posts_change( array(), $value );
4909 + }
4910 +
4911 + /**
4912 + * Whether a content change to this post type may clear only the pages
4913 + * it affects, before looking at the post itself.
4914 + *
4915 + * Off when the setting is off, during an import (one full purge runs at
4916 + * the end), and for post types whose lists the rules do not know:
4917 + * WooCommerce products, whose shop and category pages purge_product()
4918 + * already handles.
4919 + *
4920 + * @param string $post_type Post type.
4921 + */
4922 + private static function narrow_purge_applies( string $post_type ): bool {
4923 + if ( defined( 'WP_IMPORTING' ) && WP_IMPORTING ) {
4924 + return false;
4925 + }
4926 + $opts = Settings_Manager::get( 'cache' );
4927 + if ( empty( $opts['purge_affected_only'] ) ) {
4928 + return false;
4929 + }
4930 + /**
4931 + * Filter the post types whose saves always purge the whole site.
4932 + *
4933 + * @param string[] $types Post type slugs.
4934 + */
4935 + $site_wide = (array) apply_filters( 'xspeed_site_wide_purge_post_types', array( 'product', 'product_variation' ) );
4936 + return ! in_array( $post_type, $site_wide, true );
4937 + }
4938 +
4939 + /**
4940 + * Purge what a change to one post affects, or the whole site when the
4941 + * affected pages cannot be listed safely.
4942 + *
4943 + * The pages are the ones the rules name (Affected_Pages::for_post())
4944 + * and the recorded pages whose own post lists the change may alter
4945 + * (Listing_Pages::pages_for()).
4946 + *
4947 + * Site-wide when this change alters a post list the theme draws on
4948 + * pages the rules cannot name (Affected_Pages::lists_changed_by()), when
4949 + * the record of listing pages cannot answer, when the list is
4950 + * longer than Affected_Pages::LIMIT, or when the filter says so. Nothing
4951 + * when the post was not public before or after: no page anyone can see
4952 + * changed.
4953 + *
4954 + * @param \WP_Post $post Post as it is now.
4955 + * @param \WP_Post|null $before Post before the change.
4956 + * @param string $cause Purge log cause.
4957 + */
4958 + private static function purge_post_change( \WP_Post $post, ?\WP_Post $before, string $cause ): void {
4959 + // Checked before any list is consulted. A draft save moves the
4960 + // draft's date and can match a list of any kind, which would send a
4961 + // change nobody can see to a site-wide purge.
4962 + if ( ! Affected_Pages::is_public( $post ) && ! ( $before instanceof \WP_Post && Affected_Pages::is_public( $before ) ) ) {
4963 + Affected_Pages::forget( (int) $post->ID );
4964 + return;
4965 + }
4966 + $fallback = '';
4967 + $urls = null;
4968 + if ( Affected_Pages::lists_changed_by( $post, $before ) ) {
4969 + $fallback = self::FALLBACK_THEME_LIST;
4970 + } else {
4971 + $urls = Affected_Pages::for_post( $post, $before );
4972 + // Read before forget(): a plain edit is judged on the terms the
4973 + // save replaced.
4974 + $listing = Listing_Pages::enabled() ? Listing_Pages::pages_for( $post, $before ) : array();
4975 + if ( null === $listing ) {
4976 + $fallback = self::FALLBACK_LISTING;
4977 + $urls = null;
4978 + } else {
4979 + // Pages the rules did not name. Nginx Helper's own purge does
4980 + // not reach them either, so a fallback they cause has to say so.
4981 + $extra = array_diff( $listing, $urls );
4982 + $urls = array_values( array_unique( array_merge( $urls, $listing ) ) );
4983 + if ( count( $urls ) > Affected_Pages::LIMIT ) {
4984 + $fallback = array() === $extra ? self::FALLBACK_LIMIT : self::FALLBACK_LISTING;
4985 + $urls = null;
4986 + }
4987 + }
4988 + }
4989 + Affected_Pages::forget( (int) $post->ID );
4990 +
4991 + if ( is_array( $urls ) ) {
4992 + /**
4993 + * Filter the pages a post change clears.
4994 + *
4995 + * Return false to purge the whole site instead.
4996 + *
4997 + * @param string[]|false $urls Absolute URLs.
4998 + * @param \WP_Post $post The post that changed.
4999 + */
5000 + $urls = apply_filters( 'xspeed_purge_affected_pages', $urls, $post );
5001 + if ( ! is_array( $urls ) ) {
5002 + $fallback = self::FALLBACK_FILTER;
5003 + }
5004 + }
5005 +
5006 + if ( ! is_array( $urls ) ) {
5007 + self::purge_all(
5008 + $cause,
5009 + null,
5010 + array(
5011 + 'scope' => 'site',
5012 + 'intent' => 'content',
5013 + 'urls' => array(),
5014 + 'fallback' => $fallback,
5015 + )
5016 + );
5017 + return;
5018 + }
5019 + if ( array() !== $urls ) {
5020 + self::purge_urls( $urls, $cause );
5021 + }
5022 + }
5023 +
5024 + /**
5025 + * Delete/trash invalidation while the post type is still available.
5026 + * The local and server response-cache sweeps share the same site boundary.
5027 + *
5028 + * @param int $post_id Removed post ID.
5029 + * @param object|null $post Post object supplied by core when available.
5030 + */
5031 + public static function on_post_removed( $post_id, $post = null ): void {
5032 + if ( ! self::post_change_is_cacheable_content( $post_id, $post, 'remove' ) ) {
5033 + return;
5034 + }
5035 +
5036 + $post_type = is_object( $post ) && isset( $post->post_type )
5037 + ? (string) $post->post_type
5038 + : (string) get_post_type( $post_id );
5039 +
5040 + if ( ! in_array( $post_type, self::presentation_post_types(), true ) && self::narrow_purge_applies( $post_type ) ) {
5041 + $object = $post instanceof \WP_Post ? $post : get_post( (int) $post_id );
5042 + if ( $object instanceof \WP_Post ) {
5043 + if ( 'attachment' === $post_type ) {
5044 + // Its own page and the post it is attached to. Pages that
5045 + // show the file keep an <img> to a file that is gone either
5046 + // way: a fresh render prints the same tag.
5047 + $urls = array();
5048 + $link = get_permalink( $object );
5049 + if ( is_string( $link ) && '' !== $link ) {
5050 + $urls[] = $link;
5051 + }
5052 + $parent = $object->post_parent > 0 ? get_post( (int) $object->post_parent ) : null;
5053 + if ( $parent instanceof \WP_Post && 'publish' === $parent->post_status ) {
5054 + $parent_link = get_permalink( $parent );
5055 + if ( is_string( $parent_link ) && '' !== $parent_link ) {
5056 + $urls[] = $parent_link;
5057 + }
5058 + }
5059 + if ( array() !== $urls ) {
5060 + self::purge_urls( $urls, 'post-removed:attachment' );
5061 + }
5062 + return;
5063 + }
5064 + // Still published here: `before_delete_post` runs before the
5065 + // row goes. Trashing reaches the save path instead.
5066 + self::purge_post_change( $object, null, 'post-removed:' . $post_type );
5067 + return;
5068 + }
5069 + }
5070 +
5071 + self::purge_all(
5072 + 'post-removed:' . $post_type,
5073 + null,
5074 + array(
5075 + 'scope' => 'site',
5076 + // Match on_save_post: a presentation type changes how pages
5077 + // render rather than what they say.
5078 + 'intent' => in_array( $post_type, self::presentation_post_types(), true )
5079 + ? 'presentation'
5080 + : 'content',
5081 + 'urls' => array(),
5082 + )
5083 + );
5084 + }
5085 +
5086 + /** Purge site responses when moderation changes visible comments. */
5087 + public static function on_comment_status( $comment_id, $status = '' ): void {
5088 + $comment = function_exists( 'get_comment' ) ? get_comment( (int) $comment_id ) : null;
5089 + $post_id = is_object( $comment ) && isset( $comment->comment_post_ID ) ? (int) $comment->comment_post_ID : 0;
5090 + if ( $post_id < 1 || ! function_exists( 'get_permalink' ) ) {
5091 + return;
5092 + }
5093 + $url = get_permalink( $post_id );
5094 + if ( ! is_string( $url ) || '' === $url ) {
5095 + return;
5096 + }
5097 + $post = function_exists( 'get_post' ) ? get_post( $post_id ) : null;
5098 + $narrow = $post instanceof \WP_Post && self::narrow_purge_applies( (string) $post->post_type );
5099 + if ( $narrow && ! Affected_Pages::site_lists_comments() ) {
5100 + $urls = Affected_Pages::for_comment( $post );
5101 + if ( array() !== $urls ) {
5102 + self::purge_urls( $urls, 'comment-status:' . (string) $status );
5103 + }
5104 + return;
5105 + }
5106 + self::purge_all(
5107 + 'comment-status:' . (string) $status,
5108 + null,
5109 + array(
5110 + 'scope' => 'site',
5111 + 'intent' => 'content',
5112 + 'urls' => array(),
5113 + // Narrow purges apply to this post, so the site-wide purge is
5114 + // for a recent-comments list on pages the rules cannot name.
5115 + 'fallback' => $narrow ? self::FALLBACK_THEME_LIST : '',
5116 + )
5117 + );
5118 + }
5119 +
5120 + /**
5121 + * comment_post → purge just the commented-on URL, and only once the
5122 + * comment is actually visible.
5123 + *
5124 + * A comment held for moderation changes nothing on the front end, and an
5125 + * approved one changes exactly one page — not the whole site. Product
5126 + * reviews are comments and guest reviews are on by default, so under the
5127 + * old wiring any visitor could flush a store's entire cache, repeatedly,
5128 + * with no account. (#243)
5129 + *
5130 + * @param int $comment_id New comment ID.
5131 + * @param int|string $approved 1 when approved, 0 when held, 'spam'.
5132 + * @param array $data Comment data.
5133 + */
5134 + public static function on_comment_post( $comment_id, $approved = 0, $data = array() ): void {
5135 + if ( 1 !== (int) $approved ) {
5136 + return;
5137 + }
5138 + $post_id = is_array( $data ) && isset( $data['comment_post_ID'] ) ? (int) $data['comment_post_ID'] : 0;
5139 + if ( $post_id < 1 ) {
5140 + return;
5141 + }
5142 + // With narrow purges on, the comment pages and comment feeds go too.
5143 + // No site-wide fallback here: a visitor's comment must never be able
5144 + // to clear the whole site (#243).
5145 + $post = function_exists( 'get_post' ) ? get_post( $post_id ) : null;
5146 + if ( $post instanceof \WP_Post && self::narrow_purge_applies( (string) $post->post_type ) ) {
5147 + $urls = Affected_Pages::for_comment( $post );
5148 + if ( array() !== $urls ) {
5149 + self::purge_urls( $urls, 'comment' );
5150 + }
5151 + return;
5152 + }
5153 + $url = get_permalink( $post_id );
5154 + if ( is_string( $url ) && '' !== $url ) {
5155 + self::purge_url( $url, 'comment' );
5156 + }
5157 + }
5158 +
5159 + /**
5160 + * user_register / profile_update → purge only when the user can author
5161 + * content that appears on the front end.
5162 + *
5163 + * A customer registering at checkout changes no rendered page, and cannot
5164 + * change an enqueued asset — so it must not purge the cache, and must not
5165 + * rebuild the minified bundles. Checkout account-creation fired FOUR
5166 + * full-site purges plus four purge_minified() runs in a single request
5167 + * before this gate. (#243)
5168 + *
5169 + * @param int $user_id Affected user.
5170 + */
5171 + public static function on_user_change( $user_id ): void {
5172 + $user = function_exists( 'get_userdata' ) ? get_userdata( (int) $user_id ) : null;
5173 + if ( ! $user ) {
5174 + return;
5175 + }
5176 +
5177 + // Only roles that can publish can change a rendered page. WooCommerce
5178 + // customers and WordPress subscribers cannot.
5179 + if ( ! user_can( $user, 'edit_posts' ) ) {
5180 + return;
5181 + }
5182 +
5183 + $url = get_author_posts_url( (int) $user_id );
5184 + if ( is_string( $url ) && '' !== $url ) {
5185 + self::purge_url( $url, 'user' );
5186 + }
5187 + }
5188 +
5189 + /**
5190 + * Purge everything a product's price / stock / sale state is rendered on.
5191 + *
5192 + * The product permalink is not enough: the shop archive and the product's
5193 + * category and tag archives render the same price and Sale! badge, and
5194 + * #242 reproduces all three going stale together.
5195 + *
5196 + * Accepts a product ID or a WC_Product. A variation resolves to its
5197 + * parent, which is the page that actually renders.
5198 + *
5199 + * @param int|object $product Product ID or WC_Product.
5200 + */
5201 + public static function purge_product( $product ): void {
5202 + $product_id = is_object( $product ) && method_exists( $product, 'get_id' )
5203 + ? (int) $product->get_id()
5204 + : (int) $product;
5205 + if ( $product_id < 1 ) {
5206 + return;
5207 + }
5208 +
5209 + // Variations are never rendered on their own URL.
5210 + $parent = (int) wp_get_post_parent_id( $product_id );
5211 + if ( $parent > 0 ) {
5212 + $product_id = $parent;
5213 + }
5214 +
5215 + // wp-admin's Update saves the product through WooCommerce inside the
5216 + // post's own save_post, and Cache::on_save_post() then clears the
5217 + // whole site for it, since products are in
5218 + // xspeed_site_wide_purge_post_types. Sending these pages first only
5219 + // purged them twice. A price or stock change made without a post
5220 + // save (an order, the REST API, `$product->save()`) still purges here.
5221 + if ( self::site_wide_save_under_way( $product_id ) ) {
5222 + return;
5223 + }
5224 +
5225 + $urls = array();
5226 +
5227 + $permalink = get_permalink( $product_id );
5228 + if ( is_string( $permalink ) && '' !== $permalink ) {
5229 + $urls[] = $permalink;
5230 + }
5231 +
5232 + // The shop archive.
5233 + if ( function_exists( 'wc_get_page_id' ) ) {
5234 + $shop_id = (int) wc_get_page_id( 'shop' );
5235 + if ( $shop_id > 0 ) {
5236 + $shop_url = get_permalink( $shop_id );
5237 + if ( is_string( $shop_url ) && '' !== $shop_url ) {
5238 + $urls[] = $shop_url;
5239 + }
5240 + }
5241 + }
5242 +
5243 + // Every category / tag archive this product appears on.
5244 + foreach ( array( 'product_cat', 'product_tag' ) as $taxonomy ) {
5245 + $terms = get_the_terms( $product_id, $taxonomy );
5246 + if ( ! is_array( $terms ) ) {
5247 + continue;
5248 + }
5249 + foreach ( $terms as $term ) {
5250 + $term_url = get_term_link( $term );
5251 + if ( is_string( $term_url ) && '' !== $term_url ) {
5252 + $urls[] = $term_url;
5253 + }
5254 + }
5255 + }
5256 +
5257 + // The front page, when it is not the shop page but still lists
5258 + // products (a block/shortcode storefront).
5259 + $front_id = (int) get_option( 'page_on_front' );
5260 + if ( $front_id > 0 ) {
5261 + $front_url = get_permalink( $front_id );
5262 + if ( is_string( $front_url ) && '' !== $front_url ) {
5263 + $urls[] = $front_url;
5264 + }
5265 + }
5266 +
5267 + /**
5268 + * Filter the URLs purged when a product changes.
5269 + *
5270 + * A storefront that renders products somewhere else — a landing page,
5271 + * a custom archive — can add its URLs here rather than falling back
5272 + * to purging the whole site.
5273 + *
5274 + * @param string[] $urls URLs about to be purged.
5275 + * @param int $product_id The product that changed.
5276 + */
5277 + $urls = (array) apply_filters( 'xspeed_purge_product_urls', $urls, $product_id );
5278 +
5279 + $targets = array();
5280 + foreach ( $urls as $url ) {
5281 + if ( is_scalar( $url ) && '' !== (string) $url ) {
5282 + $targets[] = (string) $url;
5283 + }
5284 + }
5285 + if ( array() === $targets ) {
5286 + return;
5287 + }
5288 +
5289 + // One batch, as a post save sends: one purge-log row and one event
5290 + // naming every page, each in the spelling WordPress links to. A
5291 + // purge_url() per page published every page in both spellings,
5292 + // which doubled the calls to Nginx Helper, and wrote no purge-log
5293 + // row when xSpeed's own page cache was off and no file went.
5294 + // purge_urls() leaves the object cache alone, which matters here:
5295 + // this runs on every stock change at checkout.
5296 + self::purge_urls( array_values( array_unique( $targets ) ), 'product', 'content' );
5297 + }
5298 +
5299 + /**
5300 + * Posts whose save_post is running in this request, keyed by ID, with
5301 + * the post as saved. Set before any other save_post listener and cleared
5302 + * after the last one.
5303 + *
5304 + * @var array<int,\WP_Post|null>
5305 + */
5306 + private static $saves_under_way = array();
5307 +
5308 + /**
5309 + * A post save has started.
5310 + *
5311 + * @param int $post_id Post ID.
5312 + * @param \WP_Post|null $post Post as saved.
5313 + */
5314 + public static function on_post_save_start( $post_id, $post = null ): void {
5315 + self::$saves_under_way[ (int) $post_id ] = is_object( $post ) ? $post : null;
5316 + }
5317 +
5318 + /**
5319 + * A post save has finished.
5320 + *
5321 + * @param int $post_id Post ID.
5322 + */
5323 + public static function on_post_save_end( $post_id ): void {
5324 + unset( self::$saves_under_way[ (int) $post_id ] );
5325 + }
5326 +
5327 + /**
5328 + * Whether a save_post for this post is running now and will end in
5329 + * on_save_post() clearing the whole site: the same checks it makes.
5330 + *
5331 + * @param int $post_id Post ID.
5332 + */
5333 + private static function site_wide_save_under_way( int $post_id ): bool {
5334 + if ( ! array_key_exists( $post_id, self::$saves_under_way ) ) {
5335 + return false;
5336 + }
5337 + $post = self::$saves_under_way[ $post_id ];
5338 + if ( ! self::post_change_is_cacheable_content( $post_id, $post, 'save' ) ) {
5339 + return false;
5340 + }
5341 + $post_type = is_object( $post ) && isset( $post->post_type )
5342 + ? (string) $post->post_type
5343 + : (string) get_post_type( $post_id );
5344 + if ( in_array( $post_type, self::presentation_post_types(), true ) ) {
5345 + return true;
5346 + }
5347 + return ! self::narrow_purge_applies( $post_type );
5348 + }
5349 +
5350 + /** Test seam: forget the post saves in progress. */
5351 + public static function reset_saves_under_way(): void {
5352 + self::$saves_under_way = array();
5353 + }
5354 +
5355 + /**
5356 + * Adapter for the WooCommerce stock actions that pass a product OBJECT
5357 + * where the status actions pass an ID.
5358 + *
5359 + * No longer wired to a hook (on_product_stock_set() is); kept because
5360 + * it is public.
5361 + *
5362 + * @param object $product WC_Product (or variation).
5363 + */
5364 + public static function purge_product_object( $product ): void {
5365 + self::purge_product( $product );
5366 + }
5367 +
5368 + /**
5369 + * Stock writes wc_update_product_stock() has opened in this request,
5370 + * keyed by product ID: true once the save inside the write has purged.
5371 + *
5372 + * Entries are removed when the write's *_set_stock arrives, so this
5373 + * holds only writes still in progress.
5374 + *
5375 + * @var array<int,bool>
5376 + */
5377 + private static $stock_writes = array();
5378 +
5379 + /**
5380 + * A CRUD save of a product: purge its pages.
5381 + *
5382 + * Inside a wc_update_product_stock() write, this is the save that write
5383 + * makes, so note that the write's pages are already purged.
5384 + *
5385 + * @param int|object $product Product ID (what WooCommerce passes) or WC_Product.
5386 + */
5387 + public static function on_product_saved( $product ): void {
5388 + $product_id = self::product_id_of( $product );
5389 + if ( isset( self::$stock_writes[ $product_id ] ) ) {
5390 + self::$stock_writes[ $product_id ] = true;
5391 + }
5392 + self::purge_product( $product );
5393 + }
5394 +
5395 + /**
5396 + * wc_update_product_stock() is about to write a product's stock.
5397 + *
5398 + * @param object $product WC_Product (or variation) whose stock changes.
5399 + */
5400 + public static function on_product_stock_write( $product ): void {
5401 + $product_id = self::product_id_of( $product );
5402 + if ( $product_id > 0 ) {
5403 + self::$stock_writes[ $product_id ] = false;
5404 + }
5405 + }
5406 +
5407 + /**
5408 + * A product's stock changed: purge its pages, unless the save inside
5409 + * the same wc_update_product_stock() call already did.
5410 + *
5411 + * That save fires woocommerce_update_product for the same product a
5412 + * moment earlier, with the stock already written, so purging again
5413 + * here cleared the same pages twice in one call. Only that pairing is
5414 + * skipped: the flag is set by the save inside this write and cleared
5415 + * here, so the next write, a status change or a later save in the same
5416 + * request purges as usual, and so does a write made with `$updating`
5417 + * set, which skips the save. A *_set_stock that arrives without
5418 + * *_before_set_stock (WooCommerce's data store fires one mid-save when
5419 + * a CRUD save changes the quantity) is not part of a write and purges.
5420 + *
5421 + * @param object $product WC_Product (or variation).
5422 + */
5423 + public static function on_product_stock_set( $product ): void {
5424 + $product_id = self::product_id_of( $product );
5425 + $purged = ! empty( self::$stock_writes[ $product_id ] );
5426 + unset( self::$stock_writes[ $product_id ] );
5427 + if ( $purged ) {
5428 + return;
5429 + }
5430 + self::purge_product( $product );
5431 + }
5432 +
5433 + /** Test seam: forget the stock writes in progress. */
5434 + public static function reset_stock_writes(): void {
5435 + self::$stock_writes = array();
5436 + }
5437 +
5438 + /**
5439 + * The ID of a product passed as an ID or as a WC_Product.
5440 + *
5441 + * @param int|object $product Product ID or WC_Product.
5442 + */
5443 + private static function product_id_of( $product ): int {
5444 + if ( is_object( $product ) ) {
5445 + return method_exists( $product, 'get_id' ) ? (int) $product->get_id() : 0;
5446 + }
5447 + return is_numeric( $product ) ? (int) $product : 0;
5448 + }
5449 +
5450 + /**
5451 + * Re-entry guard for the purge-event contract.
5452 + *
5453 + * A listener on `xspeed_after_purge_url` legitimately purges its own
5454 + * layer, and a server-cache or CDN adapter that calls back into xSpeed
5455 + * while doing so re-enters this method — unbounded, because each pass
5456 + * looks like a fresh purge.
5457 + *
5458 + * A single global flag stops too much: a nested purge of a DIFFERENT URL is
5459 + * a real purge whose listeners must hear about it. But a per-request
5460 + * "already published" set stops too much in the other direction — a
5461 + * network purge loops every blog in one request, and on a subdirectory
5462 + * network they share a host, so blogs 2..N would be silently skipped. It
5463 + * also grows for the life of the process.
5464 + *
5465 + * So the guard tracks what is IN FLIGHT, not what has been published: a
5466 + * target is marked while its own dispatch is on the stack and unmarked
5467 + * when it returns. Re-entering the same target recurses, so it is refused;
5468 + * purging the same URL again later is a new event and publishes. The set
5469 + * is bounded by call depth rather than by how many URLs a request touches.
5470 + *
5471 + * @var array<string,bool>
5472 + */
5473 + private static $purge_events_in_flight = array();
5474 +
5475 + /** Monotonic count used to detect whether a delegated purge published. */
5476 + private static $purge_event_sequence = 0;
5477 +
5478 + /**
5479 + * Publish a purge event exactly once, with bounded arguments.
5480 + *
5481 + * Deliberately carries only what an integration needs to invalidate its
5482 + * own copy: the canonical URL (or null for a full purge), the site host,
5483 + * the cause label, and how many files went. No filesystem paths, no cache
5484 + * contents, no request headers, no user data. The URL query and caller-
5485 + * supplied cause may nevertheless contain sensitive text, so listeners
5486 + * must redact them in logs or unrelated destinations that do not need the
5487 + * exact cache key.
5488 + *
5489 + * A listener that throws must not take the purge down with it: the files
5490 + * are already gone by the time we get here, and an integration's bad day
5491 + * is not a reason to report a failed purge to the caller.
5492 + *
5493 + * @param string $hook Hook name to emit.
5494 + * @param array<string,mixed> $context Bounded context, see above.
5495 + */
5496 + private static function dispatch_purge_event( string $hook, array $context ): void {
5497 + if ( ! function_exists( 'do_action' ) ) {
5498 + return;
5499 + }
5500 + // Every event carries `fallback`, so one handler can read both the
5501 + // per-URL and the full-purge shape. '' unless a narrow purge fell
5502 + // back to the whole site.
5503 + $context['fallback'] = isset( $context['fallback'] ) && is_string( $context['fallback'] ) ? $context['fallback'] : '';
5504 + $target = $hook . '|' . ( isset( $context['url'] ) ? (string) $context['url'] : '' )
5505 + . '|' . ( isset( $context['host'] ) ? (string) $context['host'] : '' );
5506 + if ( isset( self::$purge_events_in_flight[ $target ] ) ) {
5507 + return;
5508 + }
5509 + self::$purge_events_in_flight[ $target ] = true;
5510 + ++self::$purge_event_sequence;
5511 +
5512 + // Our own integrations get their own try. Sharing one with the public
5513 + // action below meant a listener on the extension seam could throw and
5514 + // take the contract event down with it — the mirror of the failure
5515 + // this separation exists to prevent.
5516 + try {
5517 + // Built-in server-cache integrations run FIRST, and by a direct
5518 + // call rather than as listeners on the action below.
5519 + //
5520 + // WordPress stops dispatching an action's remaining callbacks when
5521 + // one of them throws. As a listener, our LiteSpeed forwarding
5522 + // would then be skipped by any unrelated third-party callback that
5523 + // happened to be registered earlier and blew up — and the visible
5524 + // result is the worst kind: xSpeed reports a successful purge while
5525 + // the server keeps serving stale HTML. Shipped behaviour must not
5526 + // be hostage to a listener's bug.
5527 + self::forward_to_server_caches( $context );
5528 + } catch ( \Throwable $e ) {
5529 + self::log_purge_listener_error( $hook, $e );
5530 + }
5531 +
5532 + // The edge xCloud provides, through its purge plugin. Direct for the
5533 + // same reason, and in its own try so a failure here cannot cost the
5534 + // host page caches above or the public action below.
5535 + try {
5536 + if ( class_exists( __NAMESPACE__ . '\\Managed_Edge_Purge' ) ) {
5537 + Managed_Edge_Purge::forward( $context );
5538 + }
5539 + } catch ( \Throwable $e ) {
5540 + self::log_purge_listener_error( $hook, $e );
5541 + }
5542 +
5543 + try {
5544 + self::do_action_isolated( $hook, $context );
5545 + } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
5546 + // Swallow: see docblock. The purge succeeded regardless.
5547 + self::log_purge_listener_error( $hook, $e );
5548 + } finally {
5549 + unset( self::$purge_events_in_flight[ $target ] );
5550 + }
5551 + }
5552 +
5553 + /**
5554 + * Run every listener on a purge hook, isolating each from the others.
5555 + *
5556 + * `do_action()` dispatches callbacks in one loop, so the first one to
5557 + * throw takes every LATER listener down with it. On a purge that meant a
5558 + * failing CDN integration silently cancelled the ones queued behind it —
5559 + * and because the throw was swallowed to keep the purge itself succeeding,
5560 + * the user was told the clear worked while two edges were never touched.
5561 + * Invisible unless WP_DEBUG happened to be on. (QA #348)
5562 + *
5563 + * Each callback gets its own try/catch here, so one integration's bad day
5564 + * costs only that integration. Priority order is preserved. Falls back to
5565 + * a plain `do_action()` when the filter registry is not the shape we
5566 + * expect, so an unusual environment degrades to the old behaviour rather
5567 + * than skipping listeners entirely.
5568 + *
5569 + * @param string $hook Hook name to emit.
5570 + * @param mixed $arg Single argument passed to each listener.
5571 + */
5572 + public static function do_action_isolated( string $hook, $arg ): void {
5573 + global $wp_filter;
5574 +
5575 + // Walking $wp_filter by hand and calling each callback directly was the
5576 + // obvious way to do this, and it was wrong: it bypasses WordPress, so
5577 + // `current_filter()` came back empty, `did_action()` stayed at 0, the
5578 + // `all` hook never fired, and Query Monitor and Debug Bar could not see
5579 + // the very contract this class publishes. A shared handler branching on
5580 + // current_filter() picked the wrong branch. (QA #348 round 2, issue 3)
5581 + //
5582 + // So let do_action() dispatch — WordPress keeps its bookkeeping — and
5583 + // isolate one level down instead: each registered callback is swapped
5584 + // for a wrapper that runs it inside a try/catch. One listener throwing
5585 + // then costs only that listener, which is the whole point, without
5586 + // costing the hook its identity.
5587 + if ( ! isset( $wp_filter[ $hook ] ) || ! ( $wp_filter[ $hook ] instanceof \WP_Hook ) ) {
5588 + do_action( $hook, $arg );
5589 + return;
5590 + }
5591 +
5592 + $hook_object = $wp_filter[ $hook ];
5593 + $original = $hook_object->callbacks;
5594 + if ( ! is_array( $original ) || array() === $original ) {
5595 + do_action( $hook, $arg );
5596 + return;
5597 + }
5598 +
5599 + $wrapped = array();
5600 + $restorations = array();
5601 + foreach ( $original as $priority => $group ) {
5602 + if ( ! is_array( $group ) ) {
5603 + $wrapped[ $priority ] = $group;
5604 + continue;
5605 + }
5606 + foreach ( $group as $id => $registered ) {
5607 + if ( ! isset( $registered['function'] ) || ! is_callable( $registered['function'] ) ) {
5608 + $wrapped[ $priority ][ $id ] = $registered;
5609 + continue;
5610 + }
5611 + $callback = $registered['function'];
5612 + $wrapper = static function ( ...$args ) use ( $callback, $hook ) {
5613 + try {
5614 + return $callback( ...$args );
5615 + } catch ( \Throwable $e ) {
5616 + self::log_purge_listener_error( $hook, $e );
5617 + return null;
5618 + }
5619 + };
5620 + $wrapped[ $priority ][ $id ] = array(
5621 + // Keep accepted_args: a listener registered for 0 or 1
5622 + // arguments must still be called the way it asked.
5623 + 'accepted_args' => $registered['accepted_args'] ?? 1,
5624 + 'function' => $wrapper,
5625 + );
5626 + $restorations[ $priority ][ $id ] = array(
5627 + 'original' => $registered,
5628 + 'wrapper' => $wrapper,
5629 + );
5630 + }
5631 + }
5632 +
5633 + $hook_object->callbacks = $wrapped;
5634 + try {
5635 + do_action( $hook, $arg );
5636 + } finally {
5637 + // Restore only wrappers still present. Native add/remove operations
5638 + // performed by listeners must survive this temporary substitution.
5639 + foreach ( $restorations as $priority => $group ) {
5640 + foreach ( $group as $id => $restore ) {
5641 + $current = $hook_object->callbacks[ $priority ][ $id ]['function'] ?? null;
5642 + if ( $current === $restore['wrapper'] ) {
5643 + $hook_object->callbacks[ $priority ][ $id ] = $restore['original'];
5644 + }
5645 + }
5646 + }
5647 + }
5648 + }
5649 +
5650 + /**
5651 + * Name a listener that threw, under WP_DEBUG only.
5652 + *
5653 + * Gated like the rest of Free's diagnostics: a third-party listener
5654 + * throwing on every purge must not fill a production log.
5655 + */
5656 + private static function log_purge_listener_error( string $hook, \Throwable $e ): void {
5657 + // An \Error — a TypeError from one of OUR listeners, say — is a bug
5658 + // rather than a runtime condition a third party imposed on us, and
5659 + // swallowing it silently in production turns it into a purge that
5660 + // quietly stops working. Those are logged whatever WP_DEBUG says;
5661 + // third-party \Exceptions stay gated so a noisy integration cannot
5662 + // fill a production log.
5663 + $always = $e instanceof \Error;
5664 + if ( ( $always || ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ) && function_exists( 'error_log' ) ) {
5665 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- names a third-party listener that threw during a purge.
5666 + error_log( '[xspeed] a ' . $hook . ' listener threw: ' . $e->getMessage() );
5667 + }
5668 + }
5669 +
5670 + /**
5671 + * Test seam: clear the in-flight set left behind by an aborted dispatch,
5672 + * and the record of which saves purged in this request.
5673 + */
5674 + public static function reset_purge_events(): void {
5675 + self::$purge_events_in_flight = array();
5676 + self::$purge_event_sequence = 0;
5677 + self::$saves_purged = array();
5678 + }
5679 +
5680 + /**
5681 + * Hand the purge to the caches we ship integrations for.
5682 + *
5683 + * Isolated from the public action on purpose — see dispatch_purge_event().
5684 + * Guarded so a missing class (a partial upgrade, a stripped build) cannot
5685 + * turn a working purge into a fatal.
5686 + *
5687 + * @param array<string,mixed> $context Bounded purge context.
5688 + */
5689 + private static function forward_to_server_caches( array $context ): void {
5690 + if ( class_exists( __NAMESPACE__ . '\\Server_Caches' ) ) {
5691 + Server_Caches::forward( $context );
5692 + }
5693 + }
5694 +
5695 + /**
5696 + * `host[:port]` for a cache key, from a parsed URL.
5697 + *
5698 + * The port is kept, because `cache_key()` hashes the raw `HTTP_HOST` and
5699 + * that carries `:8080` on any install not served from 80/443 — dropping it
5700 + * computed a different md5, found no file, and reported "already cold"
5701 + * while the page kept serving HIT.
5702 + *
5703 + * A port that is the DEFAULT for the scheme is dropped, though, because
5704 + * `HTTP_HOST` does not carry one: a browser sends `Host: site.com` for
5705 + * `https://site.com:443/`. Keeping it hashed `site.com:443` against a file
5706 + * stored under `site.com` — the same silent no-op in the other direction,
5707 + * and the one QA hit passing a canonical URL with the port spelled out.
5708 + * (QA #348)
5709 + *
5710 + * @param array<string,mixed> $parts Output of wp_parse_url().
5711 + */
5712 + private static function host_port_of( array $parts ): string {
5713 + if ( ! isset( $parts['host'] ) ) {
5714 + return '';
5715 + }
5716 + $host = strtolower( (string) $parts['host'] );
5717 + if ( '' === $host || ! isset( $parts['port'] ) ) {
5718 + return $host;
5719 + }
5720 + $port = (int) $parts['port'];
5721 + $scheme = isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : '';
5722 + if ( ( 'https' === $scheme && 443 === $port ) || ( 'http' === $scheme && 80 === $port ) ) {
5723 + return $host;
5724 + }
5725 + return $host . ':' . $port;
5726 + }
5727 +
5728 + public static function purge_url( string $url, string $cause = 'manual' ): int {
5729 + // A URL that names nothing is not a purge of everything. An empty or
5730 + // blank string used to fall through to the home_url() default below
5731 + // and clear the HOMEPAGE — so a third party calling
5732 + // `purge_url( get_permalink( $id ) )` on a post whose permalink came
5733 + // back empty silently purged the front page instead of nothing. The
5734 + // CLI and the MCP tool reject empties before reaching this, so only
5735 + // direct API callers were exposed, but they are exactly the audience
5736 + // this public contract is for. (QA #348)
5737 + if ( '' === trim( $url ) ) {
5738 + return 0;
5739 + }
5740 + // parse_url() can turn raw UTF-8 into underscores (it depends on the
5741 + // host's C library and locale), so a pasted `/關於我們/` is encoded
5742 + // first. normalize_path() below gives the same result either way.
5743 + $url = self::encode_non_ascii( $url );
5744 + $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.
5745 + if ( ! is_array( $parts ) ) {
5746 + return 0;
5747 + }
5748 + // Absolute URLs are accepted only for HTTP response caches. Schemes such
5749 + // as ftp:, file: and javascript: can parse cleanly but do not name a page
5750 + // xSpeed or a server response cache can invalidate. A leading-slash path
5751 + // remains a supported site-relative target.
5752 + if ( isset( $parts['scheme'] ) && ! in_array( strtolower( (string) $parts['scheme'] ), array( 'http', 'https' ), true ) ) {
5753 + return 0;
5754 + }
5755 + if ( isset( $parts['scheme'] ) && empty( $parts['host'] ) ) {
5756 + return 0;
5757 + }
5758 + // Reject a string that parsed but is not a URL we can act on: no
5759 + // scheme AND no host AND no leading-slash path means something like
5760 + // `ht!tp://[[[` or a bare word, which parse_url() hands back as a
5761 + // relative "path". Forwarding that produced `purge_url(/ht!tp://[[[)`
5762 + // — a nonsense tag sent to LiteSpeed for every malformed call.
5763 + if ( ! isset( $parts['scheme'] ) && ! isset( $parts['host'] ) ) {
5764 + $raw = isset( $parts['path'] ) ? (string) $parts['path'] : '';
5765 + if ( '' === $raw || '/' !== $raw[0] ) {
5766 + return 0;
5767 + }
5768 + }
5769 + // Keep the port. `cache_key()` hashes the raw `HTTP_HOST`, which
5770 + // carries `:8080` on any install not served from 80/443 — while
5771 + // parse_url() splits the port into its own component, so a purge that
5772 + // used the bare host computed a different md5, found no file, and
5773 + // reported "already cold". A silent no-op: the page kept serving HIT
5774 + // until its TTL ran out. Intranet installs, panel hosts on :8443 and
5775 + // proxies that forward `Host: site.com:8080` all hit this.
5776 + // A scheme-less `site.test:443/page/` is a supported explicit-host
5777 + // target. Infer a scheme only when it names THIS site's hostname: then
5778 + // its explicit default port is the same origin and the same local cache
5779 + // key. Never apply this to another host or to a non-default port.
5780 + if ( ! isset( $parts['scheme'] ) && isset( $parts['host'], $parts['port'] ) && function_exists( 'home_url' ) ) {
5781 + $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.
5782 + if ( is_array( $home ) && ! empty( $home['host'] ) && ! empty( $home['scheme'] )
5783 + && strtolower( (string) $home['host'] ) === strtolower( (string) $parts['host'] )
5784 + ) {
5785 + $home_scheme = strtolower( (string) $home['scheme'] );
5786 + $port = (int) $parts['port'];
5787 + $home_port = isset( $home['port'] )
5788 + ? (int) $home['port']
5789 + : ( 'https' === $home_scheme ? 443 : ( 'http' === $home_scheme ? 80 : 0 ) );
5790 + if ( $home_port === $port
5791 + && ( ( 'https' === $home_scheme && 443 === $port ) || ( 'http' === $home_scheme && 80 === $port ) )
5792 + ) {
5793 + $parts['scheme'] = $home_scheme;
5794 + }
5795 + }
5796 + }
5797 + $host = self::host_port_of( $parts );
5798 + if ( '' === $host && function_exists( 'home_url' ) ) {
5799 + $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.
5800 + if ( is_array( $home ) ) {
5801 + $host = self::host_port_of( $home );
5802 + }
5803 + }
5804 + if ( '' === $host ) {
5805 + return 0;
5806 + }
5807 + $path = isset( $parts['path'] ) ? (string) $parts['path'] : '/';
5808 + $path = '/' . ltrim( $path, '/' );
5809 + if ( false !== strpos( $path, '..' ) ) {
5810 + return 0;
5811 + }
5812 + // Same spelling cache_key() hashes, so a permalink, the same URL with
5813 + // `%E7` capitals, and a pasted `/關於我們/` all find the entry. The
5814 + // purge event below keeps the caller's spelling and adds the others
5815 + // (see escape_spellings()).
5816 + $key_path = self::normalize_path( $path );
5817 +
5818 + // The cache key preserves REQUEST_URI's trailing-slash form, so
5819 + // purge both. Root stays a single '/'.
5820 + $forms = array( $key_path );
5821 + if ( '/' !== $key_path ) {
5822 + $forms[] = rtrim( $key_path, '/' );
5823 + $forms[] = rtrim( $key_path, '/' ) . '/';
5824 + }
5825 + $forms = array_unique( $forms );
5826 +
5827 + /*
5828 + * Entries live under the bucket they were written for, and this URL's
5829 + * site may not be the one serving THIS request (a cross-site purge on
5830 + * multisite, WP-CLI, or cron). Build the directory from the URL's own
5831 + * host AND path. (#6)
5832 + *
5833 + * Host alone is wrong on a subdirectory network: `store()` wrote to
5834 + * `<host>/<prefix>/`, so looking in `<host>/` found nothing and the
5835 + * call reported "already cold" while the page kept serving HIT — a
5836 + * false success, which is worse than an error. The prefix has to come
5837 + * from the URL being purged rather than from the current blog, because
5838 + * the caller is usually purging some OTHER site. (QA B2 on #166)
5839 + */
5840 + $base = XSPEED_CACHE_DIR . '/' . self::bucket_for_url( $host, $path );
5841 +
850 5842 $count = 0;
851 - if ( is_dir( XSPEED_CACHE_DIR ) ) {
852 - $files = glob( XSPEED_CACHE_DIR . '/*.html' );
853 - if ( $files ) {
854 - $count = count( $files );
855 - foreach ( $files as $f ) {
856 - wp_delete_file( $f );
5843 + foreach ( $forms as $uri ) {
5844 + // '' = mobile_separate off; '|m' / '|d' = the device buckets.
5845 + foreach ( array( '', '|m', '|d' ) as $device ) {
5846 + $key = md5( $host . $uri . $device );
5847 + $file = $base . '/' . $key . '.html';
5848 + if ( is_file( $file ) ) {
5849 + wp_delete_file( $file );
5850 + ++$count;
857 5851 }
5852 + foreach ( array( $base . '/' . $key . '.meta', $file . '.br', self::brotli_size_sidecar( $file . '.br' ) ) as $sidecar ) {
5853 + if ( is_file( $sidecar ) ) {
5854 + wp_delete_file( $sidecar );
5855 + }
5856 + }
858 5857 }
859 - // Remove the .meta sidecars (content-type for feeds/sitemaps)
860 - // alongside their .html entries. Not counted — they're not
861 - // cache "pages", just per-entry metadata.
862 - $meta = glob( XSPEED_CACHE_DIR . '/*.meta' );
863 - if ( $meta ) {
864 - foreach ( $meta as $m ) {
865 - wp_delete_file( $m );
5858 + }
5859 +
5860 + // Static tree (served directly by the nginx/.htaccess rewrite).
5861 + // The static tree stores decoded names (see static_path()). A path
5862 + // static_path() refuses was never written there.
5863 + $rel = self::static_path( $key_path );
5864 + if ( defined( 'XSPEED_CACHE_STATIC_DIR' ) && null !== $rel ) {
5865 + // Same transform the write used — `localhost:8080` files under
5866 + // `localhost8080`, so the bare host found nothing here either.
5867 + $dir = rtrim( XSPEED_CACHE_STATIC_DIR, '/' ) . '/' . self::static_host_dir( $host ) . $rel;
5868 + $file = $dir . '/index.html';
5869 + if ( is_file( $file ) ) {
5870 + wp_delete_file( $file );
5871 + ++$count;
5872 + }
5873 + foreach ( array( $file . '.br', self::brotli_size_sidecar( $file . '.br' ) ) as $sidecar ) {
5874 + if ( is_file( $sidecar ) ) {
5875 + wp_delete_file( $sidecar );
866 5876 }
867 5877 }
868 - // Remove precompressed siblings (e.g. <key>.html.br from the Pro
869 - // Brotli module). Not counted — same as .meta. Without this a
870 - // purge leaves stale .br bodies behind: disk bloat, and a
871 - // staleness window if precompression is later disabled.
872 - $br = glob( XSPEED_CACHE_DIR . '/*.br' );
873 - if ( $br ) {
874 - foreach ( $br as $b ) {
875 - wp_delete_file( $b );
5878 + }
5879 +
5880 + /*
5881 + * The per-URL purge event is published by `dispatch_purge_event()`
5882 + * below, NOT here.
5883 + *
5884 + * This branch used to publish it itself, with a raw `do_action` and a
5885 + * `{scope,url,urls,cause,count}` payload. #348 then landed the purge
5886 + * contract on dev: a canonical URL, `removed` rather than `count`,
5887 + * `intent`, per-listener isolation, and an in-flight guard so a
5888 + * listener that purges its own layer cannot re-enter the event.
5889 + *
5890 + * Keeping both meant every per-URL purge fired TWICE, in two payload
5891 + * shapes, and the raw call bypassed the guard — so a listener that
5892 + * called back into `purge_url()` recursed until the process ran out
5893 + * of memory. `CachePurgeEventContractTest::
5894 + * test_a_listener_re_purging_the_same_url_does_not_recurse` is what
5895 + * caught it, and it arrived from dev with the contract it defends.
5896 + *
5897 + * The contract wins: it is a superset of what this published, and it
5898 + * is what the LiteSpeed and nginx adapters are written against.
5899 + */
5900 +
5901 + // purge_url_reported() writes this row itself once the event has
5902 + // gone out, so it can name the caches the purge was sent to.
5903 + if ( $count > 0 && null === self::$url_batch && null === self::$reported_url_purge ) {
5904 + Cache_Inventory::invalidate();
5905 + Activity_Log::record(
5906 + 'cache_purge_url',
5907 + sprintf(
5908 + /* translators: 1: cause of the purge, 2: URL or path, 3: number of files removed. */
5909 + __( 'Purged one URL (%1$s) — %2$s, %3$d file(s) removed', 'xspeed' ),
5910 + $cause,
5911 + $host . $path,
5912 + $count
5913 + ),
5914 + Activity_Log::INFO
5915 + );
5916 + }
5917 +
5918 + /**
5919 + * Fires after one URL's cached copy has been purged.
5920 + *
5921 + * The single-URL counterpart to `xspeed_after_purge_all`. Subscribe
5922 + * here to invalidate a cache xSpeed does not own — a server-level
5923 + * cache such as LiteSpeed's LSCache, a reverse proxy, or a CDN — for
5924 + * the same URL.
5925 + *
5926 + * Only fires when the purge actually ran. A malformed URL, a URL with
5927 + * no resolvable host, or a traversal attempt returns earlier and
5928 + * publishes nothing, so a listener can treat this as "xSpeed purged
5929 + * this URL" rather than "xSpeed was asked to". `removed` may legitimately
5930 + * be 0: the URL was not in xSpeed's cache, which says nothing about
5931 + * whether it is in yours.
5932 + *
5933 + * Fires at most once per purge. A listener that calls back into
5934 + * xSpeed's purge API will not re-enter this event.
5935 + *
5936 + * @since 1.2.3
5937 + *
5938 + * @param array $context {
5939 + * Bounded description of the purge. URL queries and caller-supplied
5940 + * causes can contain sensitive values and are not logging fields.
5941 + *
5942 + * @type string $url Canonical scheme://host/path[?query] of the purged URL.
5943 + * The query is preserved because caches in front
5944 + * commonly key on it; xSpeed's own sweep is
5945 + * path-based, so `removed` describes that.
5946 + * @type string $host Host (with port when non-standard).
5947 + * @type string $path Path component, leading slash.
5948 + * @type string $cause Short label for who asked. See purge_all().
5949 + * @type int $removed Number of cache files removed.
5950 + * @type string $scope Actionable adapter scope: `urls`.
5951 + * @type string $intent Why responses changed: `content`.
5952 + * @type string[] $urls Exact response URLs to invalidate: the
5953 + * canonical URL first, then the same URL
5954 + * with the other trailing-slash spelling
5955 + * (none for the root). A path with
5956 + * percent-escapes adds the same pair with
5957 + * the escapes in lower case and in upper
5958 + * case, where those differ from it.
5959 + * @type string $fallback Always '' on this event. Present so both
5960 + * purge events share one shape; see
5961 + * `xspeed_after_purge`.
5962 + * }
5963 + */
5964 + $query = isset( $parts['query'] ) ? (string) $parts['query'] : '';
5965 + $url_scheme = isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : '';
5966 + $canonical_url = self::canonical_purge_url( $host, $path, $query, $url_scheme );
5967 + // Every spelling the local sweep above cleared, because a cache in
5968 + // front keys each one on its own. Visitors reach a page as `/about/`
5969 + // while a caller often passes `/about` (or the reverse), and a
5970 + // non-ASCII page as `/%E9%97%9C…/` while get_permalink() passes
5971 + // `/%e9%97%9c…/`. Publishing one spelling left the edge serving the
5972 + // other, usually the one visitors actually use.
5973 + $event_urls = array();
5974 + foreach ( self::escape_spellings( $path ) as $spelling ) {
5975 + $event_urls[] = self::canonical_purge_url( $host, $spelling, $query, $url_scheme );
5976 + if ( '/' === $spelling ) {
5977 + continue;
5978 + }
5979 + $other = '/' === substr( $spelling, -1 ) ? rtrim( $spelling, '/' ) : $spelling . '/';
5980 + if ( '' !== $other ) {
5981 + $event_urls[] = self::canonical_purge_url( $host, $other, $query, $url_scheme );
5982 + }
5983 + }
5984 + // Inside purge_urls(): the batch publishes one event for every URL,
5985 + // in the trailing-slash form it was given. The local sweep above still
5986 + // took both forms. Escape spellings are still added, because the other
5987 + // escape case does not redirect: a cache in front keeps the browser's
5988 + // `%E9…` copy apart from the permalink's `%e9…` one.
5989 + if ( null !== self::$url_batch ) {
5990 + if ( '' === self::$url_batch['url'] ) {
5991 + self::$url_batch['url'] = $canonical_url;
5992 + self::$url_batch['host'] = $host;
5993 + self::$url_batch['path'] = $path;
5994 + }
5995 + self::$url_batch['removed'] += $count;
5996 + foreach ( self::escape_spellings( $path ) as $spelling ) {
5997 + self::$url_batch['urls'][ self::canonical_purge_url( $host, $spelling, $query, $url_scheme ) ] = true;
5998 + }
5999 + return $count;
6000 + }
6001 +
6002 + self::dispatch_purge_event(
6003 + 'xspeed_after_purge_url',
6004 + array(
6005 + 'url' => $canonical_url,
6006 + 'host' => $host,
6007 + 'path' => $path,
6008 + 'cause' => $cause,
6009 + 'removed' => $count,
6010 + 'scope' => 'urls',
6011 + 'intent' => 'content',
6012 + 'urls' => array_values( array_unique( $event_urls ) ),
6013 + 'fallback' => '',
6014 + )
6015 + );
6016 +
6017 + return $count;
6018 + }
6019 +
6020 + /**
6021 + * The batch purge_urls() is collecting, or null outside one.
6022 + *
6023 + * @var array{url:string,host:string,path:string,removed:int,urls:array<string,bool>}|null
6024 + */
6025 + private static $url_batch = null;
6026 +
6027 + /**
6028 + * The caches in front of PHP that took a purge while a report is open,
6029 + * keyed by label, or null when no report is open.
6030 + *
6031 + * @var array<string,bool>|null
6032 + */
6033 + private static $forward_report = null;
6034 +
6035 + /**
6036 + * Note that a purge was handed to a cache in front of PHP: a server
6037 + * cache, a host cache, a CDN or an edge.
6038 + *
6039 + * The built-in adapters call this when they accept a purge, whether
6040 + * they send it now or queue it for the end of the request. A third-party
6041 + * adapter listening on `xspeed_after_purge_url` may call it too, so an
6042 + * operator's purge names it. Does nothing unless a caller opened a
6043 + * report with purge_url_reported().
6044 + *
6045 + * @param string $layer Name to show, such as "Nginx Helper".
6046 + */
6047 + public static function note_purge_forwarded( string $layer ): void {
6048 + if ( null === self::$forward_report || '' === trim( $layer ) ) {
6049 + return;
6050 + }
6051 + self::$forward_report[ $layer ] = true;
6052 + }
6053 +
6054 + /**
6055 + * Run a purge and collect the caches in front of PHP it was sent to.
6056 + *
6057 + * @param callable $purge The purge to run.
6058 + * @return array{result:mixed,forwarded:string[]}
6059 + */
6060 + public static function report_forwarding( callable $purge ): array {
6061 + $outer = self::$forward_report;
6062 + self::$forward_report = array();
6063 + try {
6064 + $result = $purge();
6065 + } finally {
6066 + $forwarded = array_keys( (array) self::$forward_report );
6067 + self::$forward_report = null === $outer ? null : $outer + (array) self::$forward_report;
6068 + }
6069 + return array(
6070 + 'result' => $result,
6071 + 'forwarded' => $forwarded,
6072 + );
6073 + }
6074 +
6075 + /**
6076 + * Set while purge_url_reported() runs, so purge_url() leaves the
6077 + * purge-log row to it. Null otherwise.
6078 + *
6079 + * @var bool|null
6080 + */
6081 + private static $reported_url_purge = null;
6082 +
6083 + /**
6084 + * Purge one URL for an operator, and say where the purge went.
6085 + *
6086 + * purge_url() writes a purge-log row only when it removed a local file,
6087 + * because Pro's beacons call it on visitor requests. On a site whose
6088 + * pages are held only by a server cache (xSpeed's page cache off, nginx
6089 + * in front) an operator's purge then removed nothing locally, was sent
6090 + * to the server cache, and left no row and an "already cold" answer.
6091 + * This adds the row in that case. The CLI, the MCP tool (which runs the
6092 + * CLI) and the admin "Purge this URL" link use it.
6093 + *
6094 + * @param string $url Absolute URL or site-relative path.
6095 + * @param string $cause Who asked, for the purge log.
6096 + * @return array{removed:int,forwarded:string[]} Files removed here, and
6097 + * the caches the purge was sent to.
6098 + */
6099 + public static function purge_url_reported( string $url, string $cause ): array {
6100 + $outer = self::$reported_url_purge;
6101 + self::$reported_url_purge = true;
6102 + try {
6103 + $run = self::report_forwarding(
6104 + static function () use ( $url, $cause ): int {
6105 + return self::purge_url( $url, $cause );
876 6106 }
6107 + );
6108 + } finally {
6109 + self::$reported_url_purge = $outer;
6110 + }
6111 + $removed = (int) $run['result'];
6112 + $forwarded = $run['forwarded'];
6113 +
6114 + if ( $removed > 0 ) {
6115 + Cache_Inventory::invalidate();
6116 + $message = array() === $forwarded
6117 + ? sprintf(
6118 + /* translators: 1: cause of the purge, 2: URL or path, 3: number of files removed. */
6119 + __( 'Purged one URL (%1$s) — %2$s, %3$d file(s) removed', 'xspeed' ),
6120 + $cause,
6121 + $url,
6122 + $removed
6123 + )
6124 + : sprintf(
6125 + /* translators: 1: cause of the purge, 2: URL or path, 3: number of files removed, 4: caches the purge was sent to. */
6126 + __( 'Purged one URL (%1$s) — %2$s, %3$d file(s) removed, sent to %4$s', 'xspeed' ),
6127 + $cause,
6128 + $url,
6129 + $removed,
6130 + implode( ', ', $forwarded )
6131 + );
6132 + Activity_Log::record( 'cache_purge_url', $message, Activity_Log::INFO );
6133 + } elseif ( array() !== $forwarded ) {
6134 + Activity_Log::record(
6135 + 'cache_purge_url',
6136 + sprintf(
6137 + /* translators: 1: cause of the purge, 2: URL or path, 3: caches the purge was sent to. */
6138 + __( 'Purged one URL (%1$s): %2$s, 0 file(s) removed here, sent to %3$s', 'xspeed' ),
6139 + $cause,
6140 + $url,
6141 + implode( ', ', $forwarded )
6142 + ),
6143 + Activity_Log::INFO
6144 + );
6145 + }
6146 +
6147 + return array(
6148 + 'removed' => $removed,
6149 + 'forwarded' => $forwarded,
6150 + );
6151 + }
6152 +
6153 + /**
6154 + * Purge several URLs as one purge: each URL's local copy goes as in
6155 + * purge_url(), then ONE `xspeed_after_purge_url` event carries every URL.
6156 + *
6157 + * One event rather than one per URL, so the purge log gets one line, an
6158 + * edge gets one batch, and a listener that counts purges counts one. The
6159 + * event is the same contract purge_url() publishes: `scope` is `urls`,
6160 + * `url` is the first URL, `urls` is all of them.
6161 + *
6162 + * Unlike purge_url(), `urls` holds each URL in the spelling it was given
6163 + * and not the other trailing-slash spelling too. Callers pass WordPress's
6164 + * own links (get_permalink(), get_term_link() and the like), which are
6165 + * the spelling visitors reach; the other spelling redirects to it, so a
6166 + * cache in front holds nothing stale under it. Both spellings doubled the
6167 + * list: a typical save named 34 pages as 67 URLs, past xCloud's 50-URL
6168 + * batch and the Hub's 30 per call. xSpeed's own sweep still removes both
6169 + * spellings of each. The escape spellings (escape_spellings()) are still
6170 + * listed, because `%E9…` does not redirect to `%e9…`; they add URLs only
6171 + * for a path with percent-escapes.
6172 + *
6173 + * The object cache is left alone, as purge_url() leaves it. WordPress
6174 + * already drops a post's own entries when the post, its terms or its
6175 + * comments change (clean_post_cache() and the like), so a flush here
6176 + * cleared nothing stale. What it did do: an approved visitor comment
6177 + * emptied Redis or Memcached for the whole site, as often as a visitor
6178 + * cared to comment. With a persistent object cache, transients live
6179 + * there too, so a flush could also drop work a purge listener had just
6180 + * queued in one. purge_all() still flushes.
6181 + *
6182 + * @param string[] $urls Absolute URLs or site-relative paths.
6183 + * @param string $cause Who asked, for the purge log.
6184 + * @param string $intent Why responses changed: `content` by default.
6185 + * @return int Cache files removed.
6186 + */
6187 + public static function purge_urls( array $urls, string $cause = 'manual', string $intent = 'content' ): int {
6188 + $outer = self::$url_batch;
6189 + self::$url_batch = array(
6190 + 'url' => '',
6191 + 'host' => '',
6192 + 'path' => '',
6193 + 'removed' => 0,
6194 + 'urls' => array(),
6195 + );
6196 + try {
6197 + foreach ( array_unique( array_filter( $urls, 'is_string' ) ) as $url ) {
6198 + self::purge_url( $url, $cause );
877 6199 }
6200 + } finally {
6201 + $batch = self::$url_batch;
6202 + self::$url_batch = $outer;
878 6203 }
6204 + if ( '' === $batch['url'] ) {
6205 + return 0;
6206 + }
6207 +
6208 + Cache_Inventory::invalidate();
6209 + self::update_stats( array( 'last_purge' => time() ) );
6210 + // Same type as one URL's purge, so the purge log and the dashboard's
6211 + // drill-down list it with the others.
6212 + Activity_Log::record(
6213 + 'cache_purge_url',
6214 + sprintf(
6215 + /* translators: 1: number of pages, 2: cause of the purge, 3: number of files removed. */
6216 + _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' ),
6217 + count( $urls ),
6218 + $cause,
6219 + $batch['removed']
6220 + ),
6221 + Activity_Log::INFO
6222 + );
6223 +
6224 + self::dispatch_purge_event(
6225 + 'xspeed_after_purge_url',
6226 + array(
6227 + 'url' => $batch['url'],
6228 + 'host' => $batch['host'],
6229 + 'path' => $batch['path'],
6230 + 'cause' => $cause,
6231 + 'removed' => $batch['removed'],
6232 + 'scope' => 'urls',
6233 + 'intent' => $intent,
6234 + 'urls' => array_keys( $batch['urls'] ),
6235 + 'fallback' => '',
6236 + )
6237 + );
6238 +
6239 + return $batch['removed'];
6240 + }
6241 +
6242 + /** Host this site's purge is scoped to, for the purge-event context. */
6243 + private static function current_purge_host(): string {
6244 + if ( ! function_exists( 'home_url' ) ) {
6245 + return '';
6246 + }
6247 + $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.
6248 + if ( ! is_array( $home ) || empty( $home['host'] ) ) {
6249 + return '';
6250 + }
6251 + // Same default-port normalisation as purge_url(): a site whose
6252 + // home_url() carries `:443` (normal behind a proxy) otherwise stamps
6253 + // every full-purge event with a host that matches none of its own
6254 + // URLs, so the LiteSpeed forward stood down site-wide. (QA #348)
6255 + return self::host_port_of( $home );
6256 + }
6257 +
6258 + /**
6259 + * Rebuild the canonical URL a purge applied to.
6260 + *
6261 + * Built from the parts the purge itself used, so a listener is told the
6262 + * URL we acted on rather than the string the caller happened to pass —
6263 + * those differ whenever the caller supplied a site-relative path, a
6264 + * different scheme, or a query string the cache key ignores.
6265 + */
6266 + private static function canonical_purge_url( string $host, string $path, string $query = '', string $url_scheme = '' ): string {
6267 + // The purged URL's own scheme wins. purge_url() explicitly supports
6268 + // cross-site purges (multisite, WP-CLI, cron), where composing the
6269 + // current site's scheme onto another site's host builds a URL that was
6270 + // never served — and a CDN listener then purges the wrong key and
6271 + // reports success.
6272 + if ( '' !== $url_scheme ) {
6273 + return $url_scheme . '://' . $host . $path . ( '' !== $query ? '?' . $query : '' );
6274 + }
6275 + $scheme = function_exists( 'is_ssl' ) && is_ssl() ? 'https' : 'http';
6276 + if ( function_exists( 'home_url' ) ) {
6277 + $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.
6278 + if ( is_array( $home ) && ! empty( $home['scheme'] ) ) {
6279 + $scheme = (string) $home['scheme'];
6280 + }
6281 + }
6282 + // The query is carried even though OUR sweep above is path-based.
6283 + // Caches in front commonly key on the full request line — LiteSpeed
6284 + // tags `/shop/?page=2` separately from `/shop/` — so publishing the
6285 + // bare path would have a listener confidently purge the wrong entry
6286 + // and report success. Telling it exactly what was asked for lets it
6287 + // act correctly; `removed` still describes only what WE removed.
6288 + return $scheme . '://' . $host . $path . ( '' !== $query ? '?' . $query : '' );
6289 + }
6290 +
6291 + /**
6292 + * Sweep this site's cache files.
6293 + *
6294 + * On multisite every blog shares one cache directory, so an unscoped
6295 + * sweep here took the whole network cold — one subsite's settings save
6296 + * or post publish rebuilt every other site from PHP. Entries are stored
6297 + * per host (see host_dir()), and the sweep is scoped to match, so a
6298 + * purge originating on site-a leaves site-b's cache warm. (#6)
6299 + *
6300 + * Clears the files only: the flat tree, the static tree, the REST
6301 + * responses and the minified assets. The object-cache flush, the stats
6302 + * update, `xspeed_after_purge_all`, the `xspeed_after_purge` contract
6303 + * event and the log entry live in purge_all(), which is still the entry
6304 + * point for every existing caller. Split out so `wp xspeed purge` can
6305 + * report the local sweep as one line item and the object cache as
6306 + * another, each with its own status — see Purge_Runner.
6307 + *
6308 + * @param string|null $host Host to purge. Defaults to the current site.
6309 + * Pass '*' to sweep the ENTIRE tree — network
6310 + * admin's "purge all sites", and the migration
6311 + * of pre-#6 entries that sit in the tree root.
6312 + * @return array{pages:int,rest:int,assets:int,bytes:int} Entries removed
6313 + * per store, and the bytes freed by the two file sweeps
6314 + * that measure themselves.
6315 + */
6316 + public static function purge_local( ?string $host = null ): array {
6317 + $network_wide = ( '*' === $host );
6318 + self::$sweep_bytes = 0;
6319 + // The flat tree buckets by a flattened segment (host/a-b) while the
6320 + // static tree mirrors the URL (host/a/b), so they need separate
6321 + // scopes — see current_host_dir() vs current_static_scope().
6322 + $static_scope = '';
6323 + if ( null === $host || $network_wide ) {
6324 + $scope = $network_wide ? '' : self::current_host_dir();
6325 + $static_scope = $network_wide ? '' : self::current_static_scope();
6326 + } else {
6327 + $dir = self::host_dir( $host );
6328 + $scope = '' === $dir ? 'default' : $dir;
6329 + $static_dir = self::static_host_dir( $host );
6330 + $static_scope = '' === $static_dir ? 'default' : $static_dir;
6331 + }
6332 +
6333 + $count = 0;
6334 + if ( is_dir( XSPEED_CACHE_DIR ) ) {
6335 + // Scoped to one host directory, or the whole tree (including the
6336 + // legacy top-level entries written before #6) when network-wide.
6337 + /*
6338 + * Network-wide sweeps go TWO levels deep, not one. A subdirectory
6339 + * subsite's bucket is `<host>/<prefix>/`, so globbing only
6340 + * `<cache>/*` reached the main site and left every subsite's
6341 + * entries in place. (QA D5 on #166)
6342 + *
6343 + * A scoped purge also has to cover its own nested buckets: when
6344 + * the main blog of a subdirectory network purges, `<host>/` is its
6345 + * bucket and `<host>/one/` belongs to another blog — so the scoped
6346 + * branch deliberately does NOT descend, which is what keeps
6347 + * site-level purges isolated.
6348 + */
6349 + $roots = $network_wide
6350 + ? array_merge(
6351 + array( XSPEED_CACHE_DIR ),
6352 + array_filter( (array) glob( XSPEED_CACHE_DIR . '/*', GLOB_ONLYDIR ) ),
6353 + array_filter( (array) glob( XSPEED_CACHE_DIR . '/*/*', GLOB_ONLYDIR ) )
6354 + )
6355 + : array( XSPEED_CACHE_DIR . '/' . $scope );
6356 +
6357 + foreach ( $roots as $root ) {
6358 + /*
6359 + * min/ and rest/ are swept by their own purgers below; never
6360 + * treat them as host buckets.
6361 + *
6362 + * Checked on every path SEGMENT, not just the basename: now
6363 + * that the network-wide glob descends two levels it can reach
6364 + * `min/combined`, whose basename is `combined` and would sail
6365 + * past a basename-only test — deleting the combined
6366 + * stylesheets out from under the pages that link them.
6367 + */
6368 + if ( ! $network_wide || XSPEED_CACHE_DIR !== $root ) {
6369 + $relative = trim( str_replace( XSPEED_CACHE_DIR, '', (string) $root ), '/' );
6370 + $segments = '' === $relative ? array() : explode( '/', $relative );
6371 + if ( array_intersect( $segments, array( 'min', 'rest' ) ) ) {
6372 + continue;
6373 + }
6374 + }
6375 + if ( ! is_dir( $root ) ) {
6376 + continue;
6377 + }
6378 + $files = glob( $root . '/*.html' );
6379 + if ( $files ) {
6380 + $count += count( $files );
6381 + foreach ( $files as $f ) {
6382 + self::sweep_delete( $f );
6383 + }
6384 + }
6385 + // Remove the .meta sidecars (content-type for feeds/sitemaps)
6386 + // alongside their .html entries. Not counted — they're not
6387 + // cache "pages", just per-entry metadata.
6388 + $meta = glob( $root . '/*.meta' );
6389 + if ( $meta ) {
6390 + foreach ( $meta as $m ) {
6391 + self::sweep_delete( $m );
6392 + }
6393 + }
6394 + // Remove precompressed siblings (e.g. <key>.html.br from the Pro
6395 + // Brotli module). Not counted — same as .meta. Without this a
6396 + // purge leaves stale .br bodies behind: disk bloat, and a
6397 + // staleness window if precompression is later disabled.
6398 + $br = glob( $root . '/*.br' );
6399 + if ( $br ) {
6400 + foreach ( $br as $b ) {
6401 + self::sweep_delete( $b );
6402 + }
6403 + }
6404 + // `*.br` does not match `*.br.size` — same reason as the flat-root
6405 + // sweep above: a size record outliving its body would later be
6406 + // read against a different sibling's bytes.
6407 + $br_size = glob( $root . '/*.br.size' );
6408 + if ( $br_size ) {
6409 + foreach ( $br_size as $b ) {
6410 + self::sweep_delete( $b );
6411 + }
6412 + }
6413 + }
6414 + }
879 6415 // Static-cache tree purge — recursive because the layout is
880 6416 // xspeed-static/{host}/{path}/index.html, so a flat glob can't
881 - // reach everything.
6417 + // reach everything. Already host-segmented, so scoping is just a
6418 + // matter of starting one level down.
882 6419 if ( is_dir( XSPEED_CACHE_STATIC_DIR ) ) {
883 - $count += self::rmtree_html( XSPEED_CACHE_STATIC_DIR );
6420 + $static_root = $network_wide
6421 + ? XSPEED_CACHE_STATIC_DIR
6422 + : XSPEED_CACHE_STATIC_DIR . '/' . $static_scope;
6423 + if ( is_dir( $static_root ) ) {
6424 + $count += self::rmtree_html( $static_root );
6425 + }
884 6426 }
885 6427 // REST response cache (cache/xspeed/rest/*.json) — same purge
886 6428 // triggers (publish, settings change) invalidate it too.
887 - $count += Rest_Cache::purge();
6429 + $rest = Rest_Cache::purge();
6430 + $count += $rest;
6431 +
6432 + // Minified + combined CSS/JS (cache/xspeed/min/ and min/combined/)
6433 + // go only on a NETWORK-wide sweep.
6434 + //
6435 + // They are named by content, so a site purge gains nothing by deleting
6436 + // them: the next render links the same names for unchanged sources
6437 + // and new names for changed ones. Deleting them did cost something.
6438 + // A page rendered just before the purge and stored just after linked
6439 + // files that were gone (unstyled until the TTL), and min/ is shared
6440 + // by every blog, so one subsite's purge broke every other subsite's
6441 + // cached pages. Orphans are Cache_GC's job; a network purge, an
6442 + // update, the explicit assets purge and deactivation still clear the
6443 + // tree, and the purge stamp stops a render in flight from being
6444 + // stored against deleted files.
6445 + $assets = ( $network_wide && class_exists( '\\XSpeed\\Minifier' ) ) ? Minifier::purge_minified() : 0;
6446 +
6447 + return array(
6448 + 'pages' => $count - $rest,
6449 + 'rest' => $rest,
6450 + 'assets' => $assets,
6451 + 'bytes' => self::$sweep_bytes,
6452 + );
6453 + }
6454 +
6455 + /**
6456 + * Flush the persistent object cache (Redis / Memcached).
6457 + *
6458 + * Runs regardless of whether the Object Cache module is currently
6459 + * enabled — a drop-in installed earlier keeps serving until flushed.
6460 + *
6461 + * @param bool $network_wide Flush every blog's entries. wp_cache_flush()
6462 + * is NETWORK-global, so on multisite the
6463 + * default prefers the blog-scoped group flush
6464 + * (WP 6.1+) — otherwise one site's purge drops
6465 + * every other site's object cache, the same bug
6466 + * #6 fixed for the page cache.
6467 + * @return bool Whether a flush was actually performed.
6468 + */
6469 + public static function flush_object_cache( bool $network_wide = false ): bool {
6470 + if ( ! $network_wide && is_multisite() && function_exists( 'wp_cache_flush_group' ) && function_exists( 'wp_cache_supports' ) && wp_cache_supports( 'flush_group' ) ) {
6471 + // Blog-scoped groups only; a shared/global group (site options,
6472 + // user meta) is intentionally left alone.
6473 + foreach ( array( 'options', 'posts', 'terms', 'post_meta', 'comment' ) as $group ) {
6474 + wp_cache_flush_group( $group );
6475 + }
6476 + return true;
6477 + }
6478 + if ( function_exists( 'wp_cache_flush' ) ) {
6479 + return (bool) wp_cache_flush();
6480 + }
6481 + return false;
6482 + }
6483 +
6484 + /**
6485 + * Purge this site's cache: the local sweep, then the object cache, then
6486 + * the bookkeeping every caller expects (stats, `xspeed_after_purge_all`,
6487 + * inventory invalidation, purge log).
6488 + *
6489 + * @param string $cause Who asked, for the purge log.
6490 + * @param string|null $host See purge_local().
6491 + * @param array<string,mixed> $invalidation Public adapter policy. `scope`
6492 + * is urls/site/network/none,
6493 + * `intent` explains why, and
6494 + * `urls` supplies exact targets.
6495 + * @return int Page + REST entries removed.
6496 + */
6497 + public static function purge_all( string $cause = 'manual', ?string $host = null, array $invalidation = array() ) {
6498 + $network_wide = ( '*' === $host );
6499 + $adapter_scope = isset( $invalidation['scope'] ) && is_string( $invalidation['scope'] )
6500 + ? $invalidation['scope']
6501 + : ( $network_wide ? 'network' : 'site' );
6502 + if ( ! in_array( $adapter_scope, array( 'urls', 'site', 'network', 'none' ), true ) ) {
6503 + $adapter_scope = $network_wide ? 'network' : 'site';
6504 + }
6505 + if ( $network_wide ) {
6506 + $adapter_scope = 'network';
6507 + }
6508 + $intent = isset( $invalidation['intent'] ) && is_string( $invalidation['intent'] ) && '' !== $invalidation['intent']
6509 + ? $invalidation['intent']
6510 + : 'complete';
6511 + $urls = isset( $invalidation['urls'] ) && is_array( $invalidation['urls'] )
6512 + ? array_values( array_unique( array_filter( $invalidation['urls'], 'is_string' ) ) )
6513 + : array();
6514 + // This method always sweeps a complete local bucket. A narrower adapter
6515 + // announcement would claim unrelated local pages stayed warm when they
6516 + // did not, leaving their server copies stale. Until purge_all() gains
6517 + // dependency-aware local deletion, its response scope cannot be `urls`.
6518 + if ( 'urls' === $adapter_scope ) {
6519 + $adapter_scope = $network_wide ? 'network' : 'site';
6520 + }
6521 + if ( 'site' === $adapter_scope || 'network' === $adapter_scope || 'none' === $adapter_scope ) {
6522 + $urls = array();
6523 + }
6524 +
6525 + $fallback = isset( $invalidation['fallback'] ) && is_string( $invalidation['fallback'] ) ? $invalidation['fallback'] : '';
6526 +
6527 + $removed = self::purge_local( $host );
6528 + $count = $removed['pages'] + $removed['rest'];
6529 +
6530 + self::flush_object_cache( $network_wide );
6531 +
888 6532 self::update_stats( array( 'last_purge' => time() ) );
889 6533
6534 + // Fire AFTER the local sweep so module listeners (Critical CSS,
6535 + // Unused CSS, Cloudflare edge purge) run — this action had three
6536 + // registered listeners but was never emitted. Treat it as additive
6537 + // (CDN / edge invalidation), not the mechanism for clearing local
6538 + // files. (FBS-83114)
6539 + // Wrapped: this action predates the purge-event contract and has its
6540 + // own third-party listeners. One of them throwing used to abort
6541 + // purge_all() here, which now also means the contract event below
6542 + // never fires and a server cache keeps serving stale HTML. The local
6543 + // sweep is already done by this point, so swallowing is strictly safer
6544 + // than letting a listener decide the rest of the method runs.
6545 + try {
6546 + // Isolated per listener: one throwing used to cancel every
6547 + // listener queued behind it — Critical CSS, Unused CSS and the
6548 + // Cloudflare edge purge all hang off this hook. (QA #348)
6549 + self::do_action_isolated( 'xspeed_after_purge_all', $cause );
6550 + } catch ( \Throwable $e ) {
6551 + self::log_purge_listener_error( 'xspeed_after_purge_all', $e );
6552 + }
6553 +
6554 + /**
6555 + * Fires after a full purge, with the same bounded context shape as
6556 + * `xspeed_after_purge_url`.
6557 + *
6558 + * Distinct from `xspeed_after_purge_all` on purpose. That action is
6559 + * the long-standing internal signal — it passes a bare `$cause` string
6560 + * and Free's own modules use it for local bookkeeping. This one is the
6561 + * documented contract for OUTSIDE integrations: same argument shape as
6562 + * the per-URL event, so a server-cache or CDN adapter can subscribe to
6563 + * both with one handler and branch on a null `url`.
6564 + *
6565 + * Fires at most once per purge, and not at all when a listener's own
6566 + * purge re-enters xSpeed.
6567 + *
6568 + * @since 1.2.3
6569 + *
6570 + * @param array $context {
6571 + * @type null $url Always null — a full purge has no single URL.
6572 + * @type string $host Host swept, or '*' for the entire tree.
6573 + * @type null $path Always null.
6574 + * @type string $cause Short label for who asked.
6575 + * @type int $removed Number of cache files removed.
6576 + * @type string $scope Adapter action: urls/site/network/none.
6577 + * @type string $intent content/presentation/complete or a caller-defined intent.
6578 + * @type string[] $urls Exact targets when scope is urls.
6579 + * @type string $fallback Why a change that would have cleared
6580 + * only its own pages cleared the whole site
6581 + * instead: `theme_list`, `limit`, `pending`
6582 + * or `filter`. Empty for every other purge.
6583 + * See the purge-event contract in
6584 + * docs/guides/hooks-and-filters.md.
6585 + * }
6586 + */
6587 + self::dispatch_purge_event(
6588 + 'xspeed_after_purge',
6589 + array(
6590 + 'url' => null,
6591 + 'host' => null === $host ? self::current_purge_host() : (string) $host,
6592 + 'path' => null,
6593 + 'cause' => $cause,
6594 + 'removed' => $count,
6595 + 'scope' => $adapter_scope,
6596 + 'intent' => $intent,
6597 + 'urls' => $urls,
6598 + 'fallback' => $fallback,
6599 + )
6600 + );
6601 +
6602 + /*
6603 + * The generic event is published by the `dispatch_purge_event()` call
6604 + * directly above, NOT here. Same supersession as in `purge_url()`:
6605 + * this branch's raw `do_action` with a `count` payload predates #348's
6606 + * contract, and keeping both made a full purge publish twice.
6607 + */
6608 +
6609 + // The list behind the "Cached pages" card is memoized for a minute;
6610 + // a purge has to drop it or the drill-down shows pages that no
6611 + // longer exist.
6612 + Cache_Inventory::invalidate();
6613 +
890 6614 // Trigger of WP_CLI / hook / admin-bar purges all hit the same
891 6615 // path. Record once with the supplied cause so the dashboard
892 6616 // activity feed reads naturally.
893 6617 Activity_Log::record(
@@ -894,11 +6618,1053 @@
894 6618 'cache_purged',
895 6619 sprintf( 'Cache purged (%s) — %d file%s removed', $cause, $count, 1 === $count ? '' : 's' ),
896 6620 Activity_Log::INFO
897 6621 );
6622 +
6623 + return $count;
898 6624 }
899 6625
900 6626 /**
6627 + * Purge everything after a plugin / theme / core update completes.
6628 + *
6629 + * Bound to `upgrader_process_complete`, which is the only hook an update
6630 + * fires — no activation hook runs, so without this the cached HTML (and
6631 + * the asset URLs baked into it) outlives the code that produced it.
6632 + *
6633 + * Runs for plugin, theme and core updates alike, including bulk runs and
6634 + * auto-updates, and purges the WHOLE network rather than the current
6635 + * site — see the call below. Translation updates are skipped: they
6636 + * change no markup a cached page depends on, and language packs update
6637 + * often enough that purging on them would keep a multilingual site
6638 + * permanently cold.
6639 + *
6640 + * Note this cannot be folded into the `$invalidate_hooks` loop above:
6641 + * that binds `purge_all` directly, and `purge_all( string $cause )` would
6642 + * then receive the WP_Upgrader instance as its cause.
6643 + *
6644 + * @param mixed $upgrader WP_Upgrader instance (unused).
6645 + * @param array $hook_extra Context for the completed operation.
6646 + * @return void
6647 + */
6648 + public static function purge_after_upgrade( $upgrader = null, $hook_extra = array() ) {
6649 + $cleared = self::$upgrade_cleared_destination;
6650 +
6651 + if ( ! self::upgrade_produced_something( $upgrader ) ) {
6652 + return;
6653 + }
6654 +
6655 + if ( ! self::upgrade_should_purge( is_array( $hook_extra ) ? $hook_extra : array(), $cleared ) ) {
6656 + return;
6657 + }
6658 +
6659 + self::purge_for_upgrade();
6660 + }
6661 +
6662 + /**
6663 + * Whether this request's upgrader removed an existing copy.
6664 + *
6665 + * @var bool
6666 + */
6667 + private static $upgrade_cleared_destination = false;
6668 +
6669 + /**
6670 + * How many `upgrader_process_complete` dispatches are on the stack.
6671 + *
6672 + * @var int
6673 + */
6674 + private static $upgrade_dispatch_depth = 0;
6675 +
6676 + /**
6677 + * Enter an `upgrader_process_complete` dispatch.
6678 + *
6679 + * Bound at PHP_INT_MIN, so it runs before any listener that might read
6680 + * the replacement signal. Public because it is a hook target.
6681 + *
6682 + * @return void
6683 + */
6684 + public static function note_upgrade_dispatch(): void {
6685 + ++self::$upgrade_dispatch_depth;
6686 + }
6687 +
6688 + /**
6689 + * Drop the replacement signal once every listener has read it.
6690 + *
6691 + * Bound at PHP_INT_MAX so a second upgrade in the same request starts
6692 + * clean, without taking the answer away from the add-on callbacks that
6693 + * run at the same priority as ours.
6694 + *
6695 + * Only the OUTERMOST dispatch clears it. A nested run — core's language
6696 + * pack upgrader, or any add-on that installs something from this hook —
6697 + * fires the action again, and clearing there would answer for a run that
6698 + * has not finished. Called directly (no dispatch on the stack) it still
6699 + * clears, which is what a test wants.
6700 + *
6701 + * Known limit: a nested run INHERITS the outer run's signal, because the
6702 + * only evidence we get is a filter that fires before the nested dispatch
6703 + * begins and carries no upgrader identity. So a fresh install performed
6704 + * from inside a replacement run reads as a replacement and purges once
6705 + * more than it needs to. A cold cache is the cheap direction, and the
6706 + * alternative — scoping the signal per upgrader — is not knowable from
6707 + * `upgrader_clear_destination`.
6708 + *
6709 + * The observed-destination signal is dropped here too. The two are read
6710 + * together and have to expire together: leaving "the directory was not
6711 + * there" behind would let a first install answer for whatever ran next
6712 + * in the same request, and that one's mistake is a cache left stale.
6713 + *
6714 + * @return void
6715 + */
6716 + public static function forget_cleared_destination(): void {
6717 + if ( self::$upgrade_dispatch_depth > 0 ) {
6718 + --self::$upgrade_dispatch_depth;
6719 + }
6720 +
6721 + if ( 0 === self::$upgrade_dispatch_depth ) {
6722 + self::$upgrade_cleared_destination = false;
6723 + self::$upgrade_destination_existed = null;
6724 + self::$upgrade_destination_folder = '';
6725 + }
6726 + }
6727 +
6728 + /**
6729 + * Whether the destination this run installs into was already there.
6730 + *
6731 + * Null while unknown — an upgrader whose target we cannot work out keeps
6732 + * the old behaviour rather than being guessed at.
6733 + *
6734 + * @var bool|null
6735 + */
6736 + private static $upgrade_destination_existed = null;
6737 +
6738 + /**
6739 + * The folder name that answer was measured against, so the destination
6740 + * WordPress reports on `upgrader_clear_destination` can be checked
6741 + * against it. Empty when nothing was measured.
6742 + *
6743 + * @var string
6744 + */
6745 + private static $upgrade_destination_folder = '';
6746 +
6747 + /**
6748 + * Note whether the package's destination exists, before it is cleared.
6749 + *
6750 + * `upgrader_source_selection` is the last hook that fires while the old
6751 + * copy is still on disk, and the extracted source folder name is the
6752 + * directory the package will install into. A pass-through listener: the
6753 + * source is returned untouched.
6754 + *
6755 + * @param mixed $source Extracted package directory.
6756 + * @param mixed $remote_src Unused; the package's remote source.
6757 + * @param mixed $upgrader The upgrader instance, if one was supplied.
6758 + * @param mixed $hook_extra Context supplied by the upgrader.
6759 + * @return mixed The source, unchanged.
6760 + */
6761 + public static function note_destination_state( $source, $remote_src = '', $upgrader = null, $hook_extra = array() ) {
6762 + self::$upgrade_destination_existed = null;
6763 + self::$upgrade_destination_folder = '';
6764 +
6765 + $root = self::upgrade_destination_root( $upgrader, is_array( $hook_extra ) ? $hook_extra : array() );
6766 + if ( null !== $root && is_string( $source ) && '' !== $source ) {
6767 + $folder = basename( rtrim( $source, '/\\' ) );
6768 + if ( '' !== $folder ) {
6769 + self::$upgrade_destination_existed = is_dir( rtrim( $root, '/\\' ) . '/' . $folder );
6770 + self::$upgrade_destination_folder = $folder;
6771 + }
6772 + }
6773 +
6774 + return $source;
6775 + }
6776 +
6777 + /**
6778 + * Where a package of this kind installs to, or null if we cannot tell.
6779 + *
6780 + * @param mixed $upgrader The upgrader instance, if one was supplied.
6781 + * @param array $hook_extra Context supplied by the upgrader.
6782 + * @return string|null
6783 + */
6784 + private static function upgrade_destination_root( $upgrader, array $hook_extra ): ?string {
6785 + $type = isset( $hook_extra['type'] ) ? (string) $hook_extra['type'] : '';
6786 +
6787 + if ( 'plugin' === $type || $upgrader instanceof \Plugin_Upgrader ) {
6788 + return defined( 'WP_PLUGIN_DIR' ) ? WP_PLUGIN_DIR : null;
6789 + }
6790 +
6791 + if ( 'theme' === $type || $upgrader instanceof \Theme_Upgrader ) {
6792 + return function_exists( 'get_theme_root' ) ? (string) get_theme_root() : null;
6793 + }
6794 +
6795 + return null;
6796 + }
6797 +
6798 + /**
6799 + * Record that the upgrader cleared an existing destination.
6800 + *
6801 + * A pass-through listener on `upgrader_clear_destination`: WordPress only
6802 + * fires it when `clear_destination` was set AND something was there to
6803 + * remove, which is the one signal that separates an upload-and-replace
6804 + * from a first-time install. The filtered value is returned untouched.
6805 + *
6806 + * @param true|\WP_Error $removed Whether the destination was cleared.
6807 + * @return true|\WP_Error
6808 + */
6809 + public static function note_cleared_destination( $removed, $local_destination = '', $remote_destination = '', $hook_extra = array() ) {
6810 + if ( ! is_wp_error( $removed ) ) {
6811 + self::$upgrade_cleared_destination = true;
6812 + }
6813 +
6814 + // $remote_destination is the directory WordPress actually cleared,
6815 + // derived from the source AFTER every `upgrader_source_selection`
6816 + // listener ran. If its folder is not the one note_destination_state()
6817 + // measured, a listener that ran after ours renamed the package, and
6818 + // the "was it there?" answer is about a directory that was never going
6819 + // to be written. Unknown is the answer that purges, so that is what it
6820 + // becomes. Only the last segment is compared: over FTP the remote
6821 + // path sits under the server's own root, not WP_PLUGIN_DIR. (#407 QA)
6822 + if ( is_string( $remote_destination ) && '' !== $remote_destination ) {
6823 + $cleared_folder = basename( rtrim( $remote_destination, '/\\' ) );
6824 + if ( '' !== $cleared_folder && $cleared_folder !== self::$upgrade_destination_folder ) {
6825 + self::$upgrade_destination_existed = null;
6826 + }
6827 + }
6828 +
6829 + return $removed;
6830 + }
6831 +
6832 + /**
6833 + * Did the completed run actually replace anything?
6834 + *
6835 + * `upgrader_process_complete` fires whether the run succeeded or failed —
6836 + * the failure branch in WP_Upgrader::run() only feeds the skin before the
6837 + * action fires. A run that installed nothing changed no markup, so purging
6838 + * for it is a cold cache bought for nothing.
6839 + *
6840 + * Deliberately conservative: this returns false ONLY when every result we
6841 + * can see is an error. An upgrader we cannot read, a mixed bulk run, or a
6842 + * missing result all fall through to purging, which is the safe direction
6843 + * everywhere else in this handler.
6844 + *
6845 + * @param mixed $upgrader WP_Upgrader instance, or anything else.
6846 + * @return bool
6847 + */
6848 + public static function upgrade_produced_something( $upgrader ): bool {
6849 + if ( ! is_object( $upgrader ) ) {
6850 + return true;
6851 + }
6852 +
6853 + // A bulk run collects one entry per item; `result` alone would only
6854 + // describe the last of them.
6855 + if ( isset( $upgrader->results ) && is_array( $upgrader->results ) && ! empty( $upgrader->results ) ) {
6856 + foreach ( $upgrader->results as $result ) {
6857 + if ( ! is_wp_error( $result ) && ! empty( $result ) ) {
6858 + return true;
6859 + }
6860 + }
6861 + return false;
6862 + }
6863 +
6864 + if ( ! property_exists( $upgrader, 'result' ) ) {
6865 + return true;
6866 + }
6867 +
6868 + return ! is_wp_error( $upgrader->result ) && ! empty( $upgrader->result );
6869 + }
6870 +
6871 + /**
6872 + * Decide whether a completed operation invalidates the cache.
6873 + *
6874 + * Split out from the handler so the decision is testable on its own:
6875 + * purge_all() reaches straight for glob() and unlink(), which a unit test
6876 + * cannot observe honestly, while every rule that matters lives here.
6877 + *
6878 + * @param array $hook_extra Context for the completed operation.
6879 + * @return bool
6880 + */
6881 + public static function upgrade_should_purge( array $hook_extra, bool $destination_cleared = false ): bool {
6882 + if ( ! self::upgrade_replaced_code( $hook_extra, $destination_cleared ) ) {
6883 + return false;
6884 + }
6885 +
6886 + // An update to xSpeed ITSELF always purges, whatever the setting says.
6887 + // This plugin's own code is what rendered every cached page — the
6888 + // minifier, lazy-loader, resource hints and CDN rewriter all changed
6889 + // underneath it — so serving that HTML after an update means serving
6890 + // output from a version that no longer exists. Minified assets make it
6891 + // concrete rather than theoretical: their filenames are keyed on the
6892 + // source filemtime, so they regenerate under NEW hashes while the
6893 + // cached pages still link the old ones, and the page requests files
6894 + // that are no longer on disk. Offering an opt-out for that would be
6895 + // offering a broken site.
6896 + return self::upgrade_touches_xspeed( $hook_extra ) || self::purge_on_upgrade_enabled();
6897 + }
6898 +
6899 + /**
6900 + * Did this completed run replace code that renders pages?
6901 + *
6902 + * The half of the decision that has nothing to do with our settings: it
6903 + * asks only whether live code changed underneath the output we cached.
6904 + * Add-ons that keep their own derived artifacts — generated CSS, captured
6905 + * selectors, fingerprints — need the same answer and must not have to
6906 + * rebuild these rules, or they drift apart. Call it with the hook's own
6907 + * `$hook_extra`; the upload-and-replace signal is read from this request.
6908 + *
6909 + * Deliberately independent of the "Purge After Updates" setting. That
6910 + * setting governs the page cache, not whether an add-on's derived data is
6911 + * still valid.
6912 + *
6913 + * @param array $hook_extra Context for the completed operation.
6914 + * @param bool|null $destination_cleared Override the recorded signal; null reads this request's.
6915 + * @return bool
6916 + */
6917 + public static function upgrade_replaced_code( array $hook_extra, ?bool $destination_cleared = null ): bool {
6918 + $cleared = null === $destination_cleared ? self::$upgrade_cleared_destination : $destination_cleared;
6919 +
6920 + $type = isset( $hook_extra['type'] ) ? (string) $hook_extra['type'] : '';
6921 + $action = isset( $hook_extra['action'] ) ? (string) $hook_extra['action'] : '';
6922 +
6923 + // `upgrader_process_complete` fires for INSTALLS as well as updates.
6924 + // A freshly installed plugin is inactive and a freshly installed theme
6925 + // is not the active one, so neither can change a single rendered page
6926 + // — but the first cut of this handler purged the whole tree anyway, so
6927 + // evaluating three plugins in a row emptied the cache three times.
6928 + //
6929 + // 'install' alone is NOT enough to skip on, because WordPress reports
6930 + // an upload-and-replace as an install: `Plugin_Upgrader::install()`
6931 + // hardcodes `action => install` and `overwrite_package` does not change
6932 + // it, so "Replace current with uploaded" and `wp plugin install <zip>
6933 + // --force` both arrive here labelled install while genuinely replacing
6934 + // live code. That is how a plugin distributed as a zip is updated, and
6935 + // skipping it put back the stale markup this handler exists to clear.
6936 + //
6937 + // The distinguishing signal is whether the destination was cleared:
6938 + // WP_Upgrader only fires `upgrader_clear_destination` when it removed
6939 + // something that was already there. Installing beside nothing does not.
6940 + if ( 'install' === $action && ! $cleared ) {
6941 + return false;
6942 + }
6943 +
6944 + // A cleared destination is only evidence of a replacement if there was
6945 + // something in it. Core returns success from clear_destination() for a
6946 + // destination that never existed, so a first-time install arrived here
6947 + // looking exactly like an upload-and-replace and bought a cold cache
6948 + // for a plugin that is not even active yet. Only acted on when we
6949 + // positively know the directory was absent.
6950 + if ( 'install' === $action && false === self::$upgrade_destination_existed ) {
6951 + return false;
6952 + }
6953 +
6954 + // 'translation' is the one update type that cannot change rendered
6955 + // markup. Anything else — including an empty type from a custom
6956 + // updater — is treated as cache-invalidating, because guessing wrong
6957 + // in that direction only costs a cold cache.
6958 + if ( 'translation' === $type ) {
6959 + return false;
6960 + }
6961 +
6962 + return true;
6963 + }
6964 +
6965 + /**
6966 + * Purge everything an update can invalidate.
6967 + *
6968 + * Network-wide ('*'), not the calling site's bucket. A plugin, theme or
6969 + * core update replaces code shared by EVERY site on the network, so a
6970 + * scoped purge would clear the site that happened to run the updater and
6971 + * leave every other subsite serving pre-update HTML for the whole TTL —
6972 + * the very bug this handler exists to fix, one level down. On single-site
6973 + * this is identical to the scoped call, since there is only ever one
6974 + * bucket.
6975 + *
6976 + * @return void
6977 + */
6978 + private static function purge_for_upgrade(): void {
6979 + // Network-wide, so purge_local() also clears min/.
6980 + self::purge_all( 'upgrade', '*' );
6981 + }
6982 +
6983 + /**
6984 + * Purge after an unattended background update run.
6985 + *
6986 + * `automatic_updates_complete` passes ONE argument, and it is not a
6987 + * hook_extra: it is WordPress's results array, keyed by what was updated
6988 + * ('core', 'plugin', 'theme', 'translation'). Handing it to
6989 + * purge_after_upgrade() put it in the unused $upgrader slot and left the
6990 + * type empty, so a night on which only a language pack updated purged
6991 + * every cached page — the exact case the translation exemption exists to
6992 + * prevent, and WordPress auto-updates language packs by default.
6993 + *
6994 + * @param array $results Update results, keyed by type.
6995 + * @return void
6996 + */
6997 + public static function purge_after_auto_updates( $results = array() ): void {
6998 + if ( ! self::auto_updates_should_purge( is_array( $results ) ? $results : array() ) ) {
6999 + return;
7000 + }
7001 +
7002 + self::purge_for_upgrade();
7003 + }
7004 +
7005 + /**
7006 + * Decide whether a background update run invalidates the cache.
7007 + *
7008 + * @param array $results Update results, keyed by type.
7009 + * @return bool
7010 + */
7011 + public static function auto_updates_should_purge( array $results ): bool {
7012 + // An unrecognisable payload is treated as invalidating, the same
7013 + // direction every other unknown takes here.
7014 + if ( empty( $results ) ) {
7015 + return true;
7016 + }
7017 +
7018 + // Failed items are listed alongside successful ones — WP_Automatic_Updater
7019 + // appends an entry whatever the outcome — and a night on which every
7020 + // update failed replaced no code, so it invalidates nothing.
7021 + $updated = array();
7022 + foreach ( $results as $type => $items ) {
7023 + if ( ! is_array( $items ) ) {
7024 + continue;
7025 + }
7026 + foreach ( $items as $item ) {
7027 + $result = is_object( $item ) && isset( $item->result ) ? $item->result : true;
7028 + if ( ! is_wp_error( $result ) && ! empty( $result ) ) {
7029 + $updated[] = (string) $type;
7030 + break;
7031 + }
7032 + }
7033 + }
7034 +
7035 + if ( empty( $updated ) ) {
7036 + return false;
7037 + }
7038 +
7039 + // Nothing but language packs: a language pack changes no markup a
7040 + // cached page depends on, and purging on one would keep a multilingual
7041 + // site permanently cold.
7042 + if ( array( 'translation' ) === array_values( array_unique( $updated ) ) ) {
7043 + return false;
7044 + }
7045 +
7046 + return self::auto_updates_touch_xspeed( $results ) || self::purge_on_upgrade_enabled();
7047 + }
7048 +
7049 + /**
7050 + * Does a background run include one of our own plugins?
7051 + *
7052 + * Same rule as a foreground self-update, read out of the results array's
7053 + * shape instead of a hook_extra: each plugin entry carries the update
7054 + * object on `->item->plugin`.
7055 + *
7056 + * @param array $results Update results, keyed by type.
7057 + * @return bool
7058 + */
7059 + private static function auto_updates_touch_xspeed( array $results ): bool {
7060 + if ( empty( $results['plugin'] ) || ! is_array( $results['plugin'] ) ) {
7061 + return false;
7062 + }
7063 +
7064 + $ours = self::self_update_plugins();
7065 + foreach ( $results['plugin'] as $entry ) {
7066 + $item = is_object( $entry ) && isset( $entry->item ) ? $entry->item : null;
7067 + $file = is_object( $item ) && isset( $item->plugin ) ? (string) $item->plugin : '';
7068 + if ( '' !== $file && in_array( $file, $ours, true ) ) {
7069 + return true;
7070 + }
7071 + }
7072 +
7073 + return false;
7074 + }
7075 +
7076 + /**
7077 + * Is the "Purge After Updates" setting on?
7078 + *
7079 + * Gates THIRD-PARTY updates only — an xSpeed self-update ignores it, see
7080 + * purge_after_upgrade(). Defaults to true when the option has never been
7081 + * written, matching the schema default in CacheModule: an unset value on
7082 + * an existing install must not read as "the user turned this off".
7083 + *
7084 + * Unlike LiteSpeed, which ships the equivalent toggle OFF, this defaults
7085 + * ON — a cold cache costs one slow request, whereas stale HTML is a wrong
7086 + * page for up to the full TTL and the site owner has no way to tell why.
7087 + *
7088 + * @return bool
7089 + */
7090 + private static function purge_on_upgrade_enabled(): bool {
7091 + $opts = Settings_Manager::get( 'cache' );
7092 + return ! array_key_exists( 'purge_on_upgrade', $opts ) || ! empty( $opts['purge_on_upgrade'] );
7093 + }
7094 +
7095 + /**
7096 + * Does this completed update include xSpeed itself?
7097 + *
7098 + * Mirrors the payload shapes Plugin::maybe_restore_after_update() reads:
7099 + * a single update carries 'plugin', a bulk run carries 'plugins'.
7100 + *
7101 + * @param array $hook_extra Context for the completed operation.
7102 + * @return bool
7103 + */
7104 + private static function upgrade_touches_xspeed( array $hook_extra ): bool {
7105 + if ( ! isset( $hook_extra['type'] ) || 'plugin' !== $hook_extra['type'] ) {
7106 + return false;
7107 + }
7108 +
7109 + $updated = array();
7110 + if ( isset( $hook_extra['plugins'] ) && is_array( $hook_extra['plugins'] ) ) {
7111 + // Strings only: array_intersect() stringifies what it is given, so
7112 + // an object without __toString in a custom updater's payload would
7113 + // be a fatal rather than a miss.
7114 + $updated = array_filter( $hook_extra['plugins'], 'is_string' );
7115 + } elseif ( isset( $hook_extra['plugin'] ) && is_string( $hook_extra['plugin'] ) ) {
7116 + $updated = array( $hook_extra['plugin'] );
7117 + }
7118 +
7119 + return (bool) array_intersect( self::self_update_plugins(), $updated );
7120 + }
7121 +
7122 + /**
7123 + * Plugin files whose update counts as an update to us.
7124 + *
7125 + * The self-update rule is "our own code rendered this cached HTML, so it
7126 + * must not survive the code being replaced". That is true of any add-on
7127 + * that writes into the same page: an add-on inlines critical CSS, rewrites
7128 + * stylesheet links and image URLs, and produces the compressed and static
7129 + * copies, so its update leaves exactly the stale markup this rule exists
7130 + * to clear. Free cannot name an add-on, so it asks instead.
7131 + *
7132 + * Filter: xspeed_self_update_plugins
7133 + *
7134 + * Add-ons add their own `plugin_basename( __FILE__ )`. Entries are matched
7135 + * against the plugin files WordPress reports for the completed update, so
7136 + * a value that is not a `dir/file.php` basename simply never matches.
7137 + *
7138 + * @param string[] $plugins Plugin basenames treated as our own.
7139 + * @return string[]
7140 + */
7141 + private static function self_update_plugins(): array {
7142 + $ours = array( plugin_basename( XSPEED_FILE ) );
7143 +
7144 + /** This filter is documented above. */
7145 + $filtered = apply_filters( 'xspeed_self_update_plugins', $ours );
7146 +
7147 + // Our own file is merged back afterwards rather than trusted to survive
7148 + // the round trip. A listener that returns null, a bare string, or a
7149 + // list it built from scratch would otherwise drop it, and the plugin
7150 + // would quietly stop exempting its OWN update from the setting — a
7151 + // failure no add-on author would think to test for.
7152 + $claimed = array_filter( is_array( $filtered ) ? $filtered : array(), 'is_string' );
7153 +
7154 + return array_values( array_unique( array_merge( $ours, array_filter( $claimed ) ) ) );
7155 + }
7156 +
7157 + /**
7158 + * Invalidate caches of RENDERED output owned by other plugins.
7159 + *
7160 + * purge_all() sweeps only what xSpeed wrote. A page builder that stores
7161 + * rendered HTML or generated CSS of its own — Elementor's element cache
7162 + * and `uploads/elementor/css/`, and the equivalents in Beaver / Divi /
7163 + * Bricks / Oxygen — keeps whatever asset URLs were current when it was
7164 + * written, and no xSpeed purge has ever reached it.
7165 + *
7166 + * That only matters for rewrites that happen DURING render rather than on
7167 + * the finished page. Minify, combine, lazy-load and resource hints all run
7168 + * on `xspeed_cache_final_html` or a `template_redirect` buffer — after the
7169 + * builder has already stored its copy — so nothing they emit can leak.
7170 + * The CDN module's `wp_get_attachment_url` filter is the one that can.
7171 + *
7172 + * Called ONLY from purges where asset URLs themselves can have changed
7173 + * (a CDN settings write, an explicit Purge All). NOT from purge_all(),
7174 + * which also runs on every post publish — regenerating every builder CSS
7175 + * file that often would cost more than it saves, and the builder already
7176 + * invalidates its own copy for the post being saved.
7177 + *
7178 + * @param string $cause Who asked. Threaded through to the listeners and
7179 + * the activity log.
7180 + * @return string[] Labels of the caches that were actually cleared.
7181 + */
7182 + public static function purge_render_caches( string $cause = 'manual' ): array {
7183 + /**
7184 + * Clear render caches belonging to other plugins.
7185 + *
7186 + * A listener does its own work and appends a human-readable label for
7187 + * what it cleared, so the activity log can name it. Returning
7188 + * `$cleared` unchanged means "nothing of mine is installed" and is the
7189 + * correct no-op — never a failure.
7190 + *
7191 + * Detect the owning plugin by class or constant, not by an
7192 + * `is_plugin_active()` path check: a renamed plugin folder must not
7193 + * silently disable the integration.
7194 + *
7195 + * @param string[] $cleared Labels of caches cleared so far.
7196 + * @param string $cause Why the purge is happening.
7197 + */
7198 + $cleared = (array) apply_filters( 'xspeed_purge_third_party_render_caches', array(), $cause );
7199 +
7200 + // Labels are strings destined for the activity feed. Anything else a
7201 + // third-party listener returns is dropped rather than coerced — a
7202 + // stray `0` or `null` in the log reads as a cache we cleared.
7203 + $cleared = array_values(
7204 + array_filter(
7205 + $cleared,
7206 + static function ( $label ) {
7207 + return is_string( $label ) && '' !== trim( $label );
7208 + }
7209 + )
7210 + );
7211 +
7212 + if ( ! $cleared ) {
7213 + return $cleared;
7214 + }
7215 +
7216 + // Logged separately from the page-cache purge above it. "I turned the
7217 + // CDN off and the images are still wrong" is only diagnosable if the
7218 + // feed says which OTHER plugin's cache was regenerated and when.
7219 + Activity_Log::record(
7220 + 'cache_purged',
7221 + sprintf( 'Render caches cleared (%s) — %s', $cause, implode( ', ', $cleared ) ),
7222 + Activity_Log::INFO
7223 + );
7224 +
7225 + return $cleared;
7226 + }
7227 +
7228 + /**
7229 + * The per-type purge menu, LiteSpeed-style. Each entry is a cache type
7230 + * the user can purge individually from the admin-bar dropdown. `visible`
7231 + * controls whether the item shows (active + licensed module only) — it
7232 + * NEVER limits Purge All, which always sweeps everything on disk.
7233 + *
7234 + * Pro registers its own types (Critical CSS, Unused CSS, …) by filtering
7235 + * `xspeed_purge_types`, so Free degrades gracefully when Pro is absent.
7236 + *
7237 + * @return array<string,array{label:string,visible:bool}>
7238 + */
7239 + public static function purge_types(): array {
7240 + $minify_on = false;
7241 + if ( class_exists( '\\XSpeed\\Settings_Manager' ) ) {
7242 + $min = Settings_Manager::get( 'minify' );
7243 + $minify_on = ! empty( $min['minify_css'] ) || ! empty( $min['minify_js'] ) || ! empty( $min['combine_css'] ) || ! empty( $min['combine_js'] );
7244 + }
7245 + // Object cache is "active" when an external object-cache drop-in is in
7246 + // use — the canonical WP signal, independent of our settings option.
7247 + $oc_on = function_exists( 'wp_using_ext_object_cache' ) && wp_using_ext_object_cache();
7248 +
7249 + $types = array(
7250 + 'all' => array(
7251 + 'label' => __( 'Purge All', 'xspeed' ),
7252 + 'visible' => true,
7253 + ),
7254 + 'page' => array(
7255 + 'label' => __( 'Purge Page / Static Cache', 'xspeed' ),
7256 + 'visible' => true,
7257 + ),
7258 + 'assets' => array(
7259 + 'label' => __( 'Purge CSS / JS Cache', 'xspeed' ),
7260 + 'visible' => $minify_on,
7261 + ),
7262 + 'object' => array(
7263 + 'label' => __( 'Purge Object Cache', 'xspeed' ),
7264 + 'visible' => $oc_on,
7265 + ),
7266 + 'rest' => array(
7267 + 'label' => __( 'Purge REST Cache', 'xspeed' ),
7268 + 'visible' => true,
7269 + ),
7270 + );
7271 +
7272 + /**
7273 + * Filter the admin-bar purge-type menu. Pro modules add their own
7274 + * (Critical CSS, Unused CSS, CDN). Adding a type here only adds a
7275 + * MENU item — purge_type() must know how to handle the same slug.
7276 + *
7277 + * @param array $types Map of slug => [label, visible].
7278 + */
7279 + return (array) apply_filters( 'xspeed_purge_types', $types );
7280 + }
7281 +
7282 + /**
7283 + * Purge a single cache type by slug. 'all' delegates to purge_all();
7284 + * every other slug clears just its own artifacts. Unknown slugs (e.g. a
7285 + * Pro type) fan out via the `xspeed_purge_type_{slug}` action so the
7286 + * owning module can handle it. Returns the number of items removed where
7287 + * countable.
7288 + *
7289 + * @param string $type Cache type slug.
7290 + * @param string $cause Who asked. Threaded through so the purge log can
7291 + * tell an AI assistant's purge apart from a click —
7292 + * "the cache cleared four times today" is only
7293 + * actionable once you know what kept clearing it.
7294 + */
7295 + public static function purge_type( string $type, string $cause = 'manual' ): int {
7296 + switch ( $type ) {
7297 + case 'all':
7298 + $count = self::purge_all( $cause );
7299 + // "Purge All" is the user saying they don't trust anything
7300 + // stored anywhere — the one purge that should also reach
7301 + // caches of rendered output we don't own. purge_all() itself
7302 + // deliberately does NOT, because it also runs on every post
7303 + // publish. (See Render_Caches.)
7304 + self::purge_render_caches( $cause );
7305 + return $count;
7306 +
7307 + case 'page':
7308 + $count = self::purge_pages();
7309 + self::update_stats( array( 'last_purge' => time() ) );
7310 + Cache_Inventory::invalidate();
7311 + self::record_partial_purge( 'page', $cause, $count );
7312 + self::announce_purge( $cause, $count );
7313 + return $count;
7314 +
7315 + case 'assets':
7316 + return self::purge_assets( $cause );
7317 +
7318 + case 'object':
7319 + if ( function_exists( 'wp_cache_flush' ) ) {
7320 + wp_cache_flush();
7321 + }
7322 + self::record_partial_purge( 'object cache', $cause, null );
7323 + return 0;
7324 +
7325 + case 'rest':
7326 + $count = Rest_Cache::purge();
7327 + self::record_partial_purge( 'REST responses', $cause, $count );
7328 + self::announce_purge( $cause, $count );
7329 + return $count;
7330 +
7331 + default:
7332 + return self::purge_type_unhandled( $type, $cause );
7333 + }
7334 + }
7335 +
7336 + /**
7337 + * Purge this site's pages after a Customizer publish.
7338 + *
7339 + * A presentation change: every page's markup or inline CSS may differ, so
7340 + * the whole site bucket goes, as for a template or global-styles edit.
7341 + * min/ stays, because its files are named by content; this blog's
7342 + * manifests are dropped so the next render re-reads every source rather
7343 + * than trusting a signature. Bound with no arguments, because the hook
7344 + * passes the WP_Customize_Manager, which purge_all() would take as its
7345 + * cause.
7346 + */
7347 + public static function on_customize_save(): void {
7348 + self::purge_all(
7349 + 'customizer',
7350 + null,
7351 + array(
7352 + 'scope' => 'site',
7353 + 'intent' => 'presentation',
7354 + 'urls' => array(),
7355 + )
7356 + );
7357 + if ( class_exists( '\\XSpeed\\Minifier' ) ) {
7358 + Minifier::purge_manifests( Asset_Manifest::blog_id() );
7359 + }
7360 + }
7361 +
7362 + /**
7363 + * Purge minified and combined CSS/JS, and the pages that link them.
7364 + *
7365 + * Scope follows who shares what:
7366 + *
7367 + * - Single site: min/ is deleted outright.
7368 + * - One site of a network: min/ is shared by every blog, so only this
7369 + * blog's manifests go. Its next render re-reads each source and
7370 + * rebuilds only what changed; no other blog loses a file it links.
7371 + * Outputs nothing links any more are left to Cache_GC.
7372 + * - $network: min/ and every blog's pages, network-wide.
7373 + *
7374 + * Deleting min/ without clearing the pages that link it left every
7375 + * cached page pointing at files that no longer exist. WordPress answers
7376 + * the missing asset by 301-ing to its pretty-permalink form and serving
7377 + * the 404 TEMPLATE as `HTTP 200 text/html`, which the browser accepts as
7378 + * a stylesheet and parses to zero rules — no console error, no network
7379 + * failure, no 4xx anywhere in devtools. The pages stayed broken for the
7380 + * rest of the TTL, and the admin who clicked could not see it: they are
7381 + * logged in, so their own requests bypass the page cache. (#244) So the
7382 + * pages go too, and when files were actually deleted the edge is told
7383 + * through `xspeed_after_purge_all`, or it keeps serving HTML that links
7384 + * them.
7385 + *
7386 + * The public `xspeed_after_purge` event carries intent `assets`: rendered
7387 + * markup did not change, only asset files did. A listener that keeps
7388 + * measurements of rendered pages (selectors, layout) can keep them.
7389 + *
7390 + * @param string $cause Who asked.
7391 + * @param bool $network Purge every blog's pages and all of min/.
7392 + * @return int Page entries removed.
7393 + */
7394 + public static function purge_assets( string $cause, bool $network = false ): int {
7395 + $deleted = 0;
7396 + $rest = 0;
7397 + if ( $network ) {
7398 + // purge_local('*') clears min/ itself on a network-wide sweep.
7399 + $removed = self::purge_local( '*' );
7400 + $count = (int) $removed['pages'];
7401 + $rest = (int) $removed['rest'];
7402 + $deleted = (int) $removed['assets'];
7403 + } else {
7404 + if ( class_exists( '\\XSpeed\\Minifier' ) && is_multisite() ) {
7405 + Minifier::purge_manifests( Asset_Manifest::blog_id() );
7406 + } elseif ( class_exists( '\\XSpeed\\Minifier' ) ) {
7407 + $deleted = Minifier::purge_minified();
7408 + }
7409 + $count = self::purge_pages();
7410 + }
7411 +
7412 + self::update_stats( array( 'last_purge' => time() ) );
7413 + Cache_Inventory::invalidate();
7414 + self::record_partial_purge( 'assets', $cause, $count + $rest );
7415 +
7416 + if ( $deleted > 0 || $network ) {
7417 + try {
7418 + self::do_action_isolated( 'xspeed_after_purge_all', $cause );
7419 + } catch ( \Throwable $e ) {
7420 + self::log_purge_listener_error( 'xspeed_after_purge_all', $e );
7421 + }
7422 + }
7423 +
7424 + if ( ! $network ) {
7425 + self::announce_purge( $cause, $count, 'site', 'assets' );
7426 + } elseif ( function_exists( 'do_action' ) ) {
7427 + try {
7428 + self::dispatch_purge_event(
7429 + 'xspeed_after_purge',
7430 + array(
7431 + 'url' => null,
7432 + 'host' => '*',
7433 + 'path' => null,
7434 + 'cause' => $cause,
7435 + 'removed' => $count + $rest,
7436 + 'scope' => 'network',
7437 + 'intent' => 'assets',
7438 + 'urls' => array(),
7439 + )
7440 + );
7441 + } catch ( \Throwable $e ) {
7442 + self::log_purge_listener_error( 'xspeed_after_purge', $e );
7443 + }
7444 + }
7445 +
7446 + return $count + $rest;
7447 + }
7448 +
7449 + /**
7450 + * Delete this site's cached pages from both the flat and static trees.
7451 + *
7452 + * Extracted so the `assets` purge can reuse it: minified assets are a
7453 + * dependency of the cached HTML, so clearing them must clear the pages
7454 + * too or the pages are left referencing deleted files (#244).
7455 + *
7456 + * @return int Number of page entries removed.
7457 + */
7458 + private static function purge_pages(): int {
7459 + $count = 0;
7460 + // Scoped to this site — see purge_all(). (#6)
7461 + $scope = self::current_host_dir();
7462 + $flat_root = XSPEED_CACHE_DIR . '/' . $scope;
7463 + if ( is_dir( $flat_root ) ) {
7464 + foreach ( (array) glob( $flat_root . '/*.html' ) as $f ) {
7465 + wp_delete_file( $f );
7466 + ++$count;
7467 + }
7468 + foreach ( (array) glob( $flat_root . '/*.meta' ) as $m ) {
7469 + wp_delete_file( $m );
7470 + }
7471 + foreach ( (array) glob( $flat_root . '/*.br' ) as $b ) {
7472 + wp_delete_file( $b );
7473 + }
7474 + // `*.br` does not match `*.br.size`; a size record outliving its
7475 + // body would later be read against a DIFFERENT sibling's bytes.
7476 + foreach ( (array) glob( $flat_root . '/*.br.size' ) as $b ) {
7477 + wp_delete_file( $b );
7478 + }
7479 + }
7480 + $static_root = XSPEED_CACHE_STATIC_DIR . '/' . self::current_static_scope();
7481 + if ( is_dir( $static_root ) ) {
7482 + $count += self::rmtree_html( $static_root );
7483 + }
7484 +
7485 + return $count;
7486 + }
7487 +
7488 + /**
7489 + * A purge type this class does not own — a Pro or third-party module
7490 + * registered it via the `xspeed_purge_types` filter, so hand it off.
7491 + *
7492 + * @param string $type Purge-type slug.
7493 + * @param string $cause Who asked.
7494 + */
7495 + private static function purge_type_unhandled( string $type, string $cause ): int {
7496 + $event_sequence = self::$purge_event_sequence;
7497 + $hook = 'xspeed_purge_type_' . $type;
7498 + $has_handler = false !== has_action( $hook );
7499 + do_action( $hook );
7500 + self::record_partial_purge( $type, $cause, null );
7501 +
7502 + // Announce, same as the types this class owns. Pro's "Purge Critical
7503 + // CSS" and "Purge Unused CSS" arrive here, and they change what a
7504 + // cached page CONTAINS — critical CSS is inlined into the HTML, so a
7505 + // server cache goes on serving pages with the old styles baked in.
7506 + // Fixing the three Free buttons and leaving these two silent left the
7507 + // same hole for the tier most likely to be using both plugins.
7508 + // (QA #348 round 2, issue 2)
7509 + //
7510 + // Unknown slugs must not turn into a site-wide purge merely because no
7511 + // handler exists. These are the response-changing Pro types Free knows;
7512 + // third parties can declare another through the filter. A registered
7513 + // handler plus this explicit response scope is the handled signal.
7514 + $scope = in_array( $type, array( 'critical-css', 'unused-css' ), true ) ? 'site' : 'none';
7515 + /**
7516 + * Declare whether a handled custom purge type changes cached responses.
7517 + *
7518 + * @since 1.2.3
7519 + * @param string $scope site/network/none.
7520 + * @param string $type Purge-type slug.
7521 + */
7522 + $scope = (string) apply_filters( 'xspeed_purge_type_response_scope', $scope, $type );
7523 + if ( $has_handler
7524 + && $event_sequence === self::$purge_event_sequence
7525 + && in_array( $scope, array( 'site', 'network' ), true )
7526 + ) {
7527 + self::announce_purge( $cause, 0, $scope, 'presentation' );
7528 + }
7529 +
7530 + return 0;
7531 + }
7532 +
7533 + /**
7534 + * Tell the server cache that a PARTIAL purge cleared cached responses.
7535 + *
7536 + * "Purge Page / Static Cache", "Purge CSS / JS Cache" and "Purge REST
7537 + * Cache" each delete cached RESPONSES for the whole site, so a cache in
7538 + * front of PHP is now serving copies xSpeed has just thrown away. Only
7539 + * "Purge All" announced itself, which left three of the four toolbar
7540 + * buttons doing exactly what this contract exists to prevent: clearing
7541 + * our copy while the server kept serving the stale one. The `assets` case
7542 + * was the sharpest — it deletes the minified bundles too, so LiteSpeed
7543 + * went on serving pages whose CSS and JS no longer exist. (QA #348)
7544 + *
7545 + * Sent as the full-purge shape (`url` null) because that is what happened:
7546 + * every cached page for this site went, not one address. `object` is not
7547 + * announced — flushing the object cache changes no rendered response a
7548 + * server cache could be holding.
7549 + *
7550 + * Public because Purge_Runner sweeps the local files itself, through
7551 + * purge_local(), rather than through purge_all() — so it has to announce
7552 + * on its own behalf or `wp xspeed purge` and the dashboard button clear
7553 + * our copy while LiteSpeed keeps serving the stale one.
7554 + *
7555 + * @param string $cause Who asked.
7556 + * @param int $removed Entries removed locally.
7557 + * @param string $scope Actionable adapter scope.
7558 + * @param string $intent Reason rendered responses changed.
7559 + */
7560 + public static function announce_purge( string $cause, int $removed, string $scope = 'site', string $intent = 'complete' ): void {
7561 + // Announcing is additive: the local sweep has already happened and
7562 + // succeeded. Notification must never be able to turn a working purge
7563 + // into a fatal, so anything the URL helpers do in an unusual context
7564 + // (early boot, a drop-in, a bare test harness) is contained here
7565 + // rather than propagating to the caller.
7566 + if ( ! function_exists( 'home_url' ) || ! function_exists( 'do_action' ) ) {
7567 + return;
7568 + }
7569 + try {
7570 + self::dispatch_purge_event(
7571 + 'xspeed_after_purge',
7572 + array(
7573 + 'url' => null,
7574 + 'host' => self::current_purge_host(),
7575 + 'path' => null,
7576 + 'cause' => $cause,
7577 + 'removed' => $removed,
7578 + 'scope' => $scope,
7579 + 'intent' => $intent,
7580 + 'urls' => array(),
7581 + )
7582 + );
7583 + } catch ( \Throwable $e ) {
7584 + self::log_purge_listener_error( 'xspeed_after_purge', $e );
7585 + }
7586 + }
7587 +
7588 + /**
7589 + * Log a partial purge so the drill-down behind "Last purge" shows every
7590 + * clear, not only the full ones. Without this a site whose object cache
7591 + * is flushed on a schedule looks, from the log, like nothing happens.
7592 + *
7593 + * @param string $what Human label for the slice purged.
7594 + * @param string $cause Who asked.
7595 + * @param int|null $count Items removed, when countable.
7596 + */
7597 + private static function record_partial_purge( string $what, string $cause, ?int $count ): void {
7598 + $message = null === $count
7599 + ? sprintf(
7600 + /* translators: 1: what was purged, 2: cause of the purge. */
7601 + __( 'Purged %1$s (%2$s)', 'xspeed' ),
7602 + $what,
7603 + $cause
7604 + )
7605 + : sprintf(
7606 + /* translators: 1: what was purged, 2: cause of the purge, 3: number of files removed. */
7607 + __( 'Purged %1$s (%2$s) — %3$d file(s) removed', 'xspeed' ),
7608 + $what,
7609 + $cause,
7610 + $count
7611 + );
7612 +
7613 + Activity_Log::record( 'cache_purged', $message, Activity_Log::INFO );
7614 +
7615 + /*
7616 + * A partial purge is still a purge, and an edge in front of this site
7617 + * has to hear about it.
7618 + *
7619 + * "Purge Page / Static Cache" is always visible in the admin bar and
7620 + * clears every cached page for this site — the flat tree AND the
7621 + * static tree the generated nginx/Apache rules serve directly. It
7622 + * announced none of that: not `xspeed_after_purge_all`, which only
7623 + * purge_all() fires, and not the scoped actions. A CDN told to hold
7624 + * those pages went on serving the ones xSpeed had just deleted, for
7625 + * whatever lifetime it was given.
7626 + *
7627 + * Scope is `site` only for the two types that clear rendered HTML.
7628 + * An object-cache flush or a REST purge removes nothing an edge is
7629 + * holding, and announcing those as a site purge would have a CDN
7630 + * drop its whole cache every time a scheduled flush ran — worse than
7631 + * the silence this replaces. A type registered by another plugin
7632 + * through `xspeed_purge_types` is unknown here, so it says nothing
7633 + * rather than guessing.
7634 + *
7635 + * Not gated on `$count`, for the reason spelled out in purge_url():
7636 + * an empty local tree is not evidence that the edge is empty, and
7637 + * "Purge Page / Static Cache" with nothing left locally is precisely
7638 + * what someone clicks when the page they are looking at is stale.
7639 + * Answering that with silence made the button appear broken.
7640 + *
7641 + * That announcement is now `announce_purge()`'s, called beside this
7642 + * by the same callers (`page`, `assets`, `REST responses`). This
7643 + * function went back to being what its name says: a log entry. It
7644 + * used to publish as well, which double-fired every partial purge and
7645 + * — worse — announced `scope: none` for types #348's contract
7646 + * requires to stay silent about.
7647 + */
7648 + }
7649 +
7650 + /**
7651 + * Clear the static tree only, leaving the flat cache in place.
7652 + *
7653 + * A narrower purge_all() for the case where only the web-server tree can
7654 + * be wrong: its files are keyed by `{host}{path}` and nothing else, so a
7655 + * response filed under the wrong path poisons it while the flat cache —
7656 + * keyed by cache_key(), discriminators included — stays correct. Avoids
7657 + * throwing away Critical CSS, minified bundles and the object cache to
7658 + * fix a static-only problem.
7659 + *
7660 + * @return int Number of index.html files removed.
7661 + */
7662 + public static function purge_static_tree(): int {
7663 + return self::rmtree_html( XSPEED_CACHE_STATIC_DIR );
7664 + }
7665 +
7666 + /**
901 7667 * Recursively delete every `index.html` (and its precompressed
902 7668 * `index.html.br` sibling, if the Pro Brotli module wrote one) plus
903 7669 * empty directories inside the static-cache tree. Used by purge_all().
904 7670 * Returns the number of .html files removed so purge stats stay accurate
@@ -904,8 +7670,24 @@
904 7670 * Returns the number of .html files removed so purge stats stay accurate
905 7671 * across the flat + static caches — .br siblings are not counted
906 7672 * (they're encodings of a page, not pages).
907 7673 */
7674 + /**
7675 + * Delete a cache file, adding its size to the current sweep's byte
7676 + * total. filesize() is silenced and re-checked because the file can
7677 + * vanish between the glob and the unlink — a concurrent purge, or the
7678 + * cache GC — and a warning there would be noise, not news.
7679 + *
7680 + * @param string $file Absolute path inside the cache tree.
7681 + */
7682 + private static function sweep_delete( string $file ): void {
7683 + $size = @filesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- the file may be gone already; see docblock.
7684 + if ( is_int( $size ) ) {
7685 + self::$sweep_bytes += $size;
7686 + }
7687 + wp_delete_file( $file );
7688 + }
7689 +
908 7690 private static function rmtree_html( string $dir ): int {
909 7691 if ( ! is_dir( $dir ) ) {
910 7692 return 0;
911 7693 }
@@ -929,14 +7711,16 @@
929 7711 @rmdir( $path );
930 7712 continue;
931 7713 }
932 7714 if ( substr( $entry, -5 ) === '.html' ) {
933 - wp_delete_file( $path );
7715 + self::sweep_delete( $path );
934 7716 ++$removed;
935 - } elseif ( substr( $entry, -3 ) === '.br' ) {
936 - // Precompressed sibling (index.html.br). Remove it too so a
937 - // purge doesn't orphan stale Brotli bodies. Not counted.
938 - wp_delete_file( $path );
7717 + } elseif ( substr( $entry, -3 ) === '.br' || substr( $entry, -8 ) === '.br.size' ) {
7718 + // Precompressed sibling (index.html.br) and the record of its
7719 + // length. Remove both so a purge doesn't orphan stale Brotli
7720 + // bodies, or a size record that would later be read against a
7721 + // different sibling's bytes. Not counted.
7722 + self::sweep_delete( $path );
939 7723 }
940 7724 }
941 7725 return $removed;
942 7726 }
@@ -953,25 +7737,42 @@
953 7737 }
954 7738 }
955 7739
956 7740 /**
7741 + * The raw xspeed_stats option as an array. Keys currently in use:
7742 + * 'last_purge', 'last_gc', 'gc_removed', 'gc_removed_total'.
7743 + */
7744 + public static function get_stats_option(): array {
7745 + $stats = get_option( 'xspeed_stats', array() );
7746 + return is_array( $stats ) ? $stats : array();
7747 + }
7748 +
7749 + /**
957 7750 * Persist stats with autoload disabled — stats are only read in admin
958 7751 * contexts, so there is no reason to inflate every frontend request's
959 7752 * `wp_load_alloptions()` payload.
7753 + *
7754 + * MERGES into whatever is already stored. It used to overwrite, which
7755 + * was harmless while `last_purge` was the only key — with the GC keys
7756 + * alongside it, a purge would have wiped the GC history and vice versa.
960 7757 */
961 - private static function update_stats( array $stats ) {
962 - if ( false === get_option( 'xspeed_stats' ) ) {
7758 + public static function update_stats( array $stats ) {
7759 + if ( false === get_option( 'xspeed_stats', false ) ) {
963 7760 add_option( 'xspeed_stats', $stats, '', 'no' );
964 7761 return;
965 7762 }
966 - update_option( 'xspeed_stats', $stats );
7763 + update_option( 'xspeed_stats', array_merge( self::get_stats_option(), $stats ) );
967 7764 }
968 7765
969 7766 public static function get_stats() {
970 7767 $count = 0;
971 7768 $size = 0;
972 - if ( is_dir( XSPEED_CACHE_DIR ) ) {
973 - $files = glob( XSPEED_CACHE_DIR . '/*.html' );
7769 + // This site's entries only — on multisite the tree is shared, so an
7770 + // unscoped count reported the whole network's pages on every
7771 + // subsite's dashboard. (#6)
7772 + $flat_root = XSPEED_CACHE_DIR . '/' . self::current_host_dir();
7773 + if ( is_dir( $flat_root ) ) {
7774 + $files = glob( $flat_root . '/*.html' );
974 7775 if ( $files ) {
975 7776 $count = count( $files );
976 7777 foreach ( $files as $f ) {
977 7778 $size += filesize( $f );
@@ -994,8 +7795,12 @@
994 7795 Hit_Counter::collect_server_log_hits();
995 7796
996 7797 $stats = get_option( 'xspeed_stats', array() );
997 7798 $totals = Hit_Counter::totals_24h();
7799 + // One read of the ground truth for both fields below: it costs a
7800 + // stat of advanced-cache.php and a tokenize of wp-config.php, and
7801 + // this runs on every dashboard poll.
7802 + $serving = self::page_cache_operational();
998 7803 return array(
999 7804 'cached_pages' => $count,
1000 7805 'cache_size' => $size,
1001 7806 'last_purge' => isset( $stats['last_purge'] ) ? (int) $stats['last_purge'] : 0,
@@ -1004,21 +7809,146 @@
1004 7809 // CacheHero stat grid + the Health module's panel.
1005 7810 'hits_24h' => $totals['hits'],
1006 7811 'misses_24h' => $totals['misses'],
1007 7812 'hit_ratio' => $totals['ratio'],
7813 + // Requests kept OUT of the ratio (404s + bots) — surfaced as its own
7814 + // "absorbed N scanner/bot requests" line rather than distorting the
7815 + // cache-performance number. (#118)
7816 + 'excluded_24h' => $totals['excluded'],
7817 + // True when an edge cache (Cloudflare) fronts the origin, so hits are
7818 + // absorbed before reaching PHP. The dashboard labels the ratio
7819 + // "origin-layer only" instead of implying it's the full picture. (#118)
7820 + 'edge_cache' => self::edge_cache_detected(),
7821 + // LiteSpeed Static Fast Path (#509): the web server serves hits
7822 + // with no PHP, no way to tag them, and no way to count them. The
7823 + // dashboard labels the ratio as PHP-layer only so a low number
7824 + // reads as the trade the user chose, not a fault.
7825 + //
7826 + // rewrite_installed() is part of the condition (QA on #513): when
7827 + // the .htaccess write failed (read-only file), hits still take
7828 + // the drop-in path and ARE counted — the disclosure would be the
7829 + // opposite of the truth. Health carries the "block missing"
7830 + // warning for that state; this flag only speaks when static
7831 + // serving is genuinely in effect.
7832 + 'static_hits_uncounted' => (
7833 + Server::LITESPEED === Server::type()
7834 + && ! empty( Settings::get()['cache_enabled'] )
7835 + && self::static_rewrite_allowed()
7836 + && self::rewrite_installed()
7837 + ),
7838 + /*
7839 + * Whether the page cache is actually SERVING, as opposed to
7840 + * switched on in settings. The hero read the setting alone and
7841 + * announced "Active — serving cached HTML"; a site whose
7842 + * advanced-cache.php had been taken over by another cache plugin
7843 + * got that line while every response carried
7844 + * `X-XSpeed-Cache: BYPASS`. The setting is the user's intent;
7845 + * this is the outcome, and the dashboard needs both to explain
7846 + * the difference.
7847 + */
7848 + 'page_cache_serving' => $serving,
7849 + /*
7850 + * Why not, when intent and outcome disagree. Only computed in
7851 + * that state — the detector sweep behind it is far more work than
7852 + * a stats call should do on an ordinary healthy site.
7853 + */
7854 + 'page_cache_blocked_reason' => ( ! $serving && ! empty( Settings::get()['cache_enabled'] ) )
7855 + ? ( self::acquisition_blocker() ?? self::not_serving_reason() )
7856 + : null,
1008 7857 );
1009 7858 }
1010 7859
1011 7860 /**
1012 - * Apply the user's enable/disable choice. Called only from the REST
1013 - * toggle endpoint, which is gated by current_user_can( 'manage_options' )
1014 - * and a verified REST nonce. This is the only place the drop-in and
1015 - * the WP_CACHE constant are written — they MUST NOT happen on
1016 - * register_activation_hook (WordPress.org review requirement).
7861 + * Why the cache is not serving, when nothing REFUSES to enable it.
1017 7862 *
7863 + * acquisition_blocker() answers "may we take the field", and since a
7864 + * foreign drop-in became takeable it answers null on a site where another
7865 + * plugin is nonetheless holding that file. Intent and outcome still
7866 + * disagree there, and the dashboard was left reporting the symptom -- not
7867 + * serving -- with no reason under it, which is exactly the state a user
7868 + * cannot act on.
7869 + *
7870 + * So this names the holder and says what to do: enabling takes it over.
7871 + */
7872 + private static function not_serving_reason(): ?string {
7873 + $owner = self::dropin_owner();
7874 + if ( self::DROPIN_FOREIGN !== $owner && self::DROPIN_UNREADABLE !== $owner ) {
7875 + return null;
7876 + }
7877 +
7878 + if ( self::DROPIN_UNREADABLE === $owner ) {
7879 + return __( 'advanced-cache.php cannot be read, so xSpeed cannot tell whose page cache is installed.', 'xspeed' );
7880 + }
7881 +
7882 + $label = Page_Cache_Detector::dropin_owner_label();
7883 + return $label
7884 + ? sprintf(
7885 + /* translators: %s: the page-caching plugin that owns advanced-cache.php. */
7886 + __( '%s is serving the page cache. Turn the xSpeed cache off and on again to take it over.', 'xspeed' ),
7887 + $label
7888 + )
7889 + : __( 'Another plugin is serving the page cache. Turn the xSpeed cache off and on again to take it over.', 'xspeed' );
7890 + }
7891 +
7892 + /**
7893 + * Whether the current request should be kept OUT of the cache hit/miss
7894 + * ratio: a genuine 404, or a known bot / scanner. Runs at template_redirect
7895 + * time, so is_404() is resolved. (#118)
7896 + */
7897 + private static function miss_is_excluded(): bool {
7898 + if ( function_exists( 'is_404' ) && is_404() ) {
7899 + return true;
7900 + }
7901 + // A marked request is ours whatever its UA says: a renamed warmer,
7902 + // or a probe that has to send a browser's UA.
7903 + if ( Self_Traffic::request_is_marked() ) {
7904 + return true;
7905 + }
7906 + $ua = isset( $_SERVER['HTTP_USER_AGENT'] )
7907 + ? sanitize_text_field( wp_unslash( (string) $_SERVER['HTTP_USER_AGENT'] ) )
7908 + : '';
7909 + return Hit_Counter::is_bot_ua( $ua );
7910 + }
7911 +
7912 + /**
7913 + * Whether an edge cache fronts this origin, so an unknown share of hits
7914 + * is served there and never counted here — which makes the origin ratio a
7915 + * partial view the dashboard has to label as such. (#118)
7916 + *
7917 + * This used to mean "the Cloudflare module is switched on", which answered
7918 + * no for every site fronted by anything else, and no for a site on
7919 + * Cloudflare that had never opened our Cloudflare panel. Both of those
7920 + * sites had their ratio presented as the whole story. Edge_Provider knows
7921 + * better and knows it per request, so ask it.
7922 + */
7923 + private static function edge_cache_detected(): bool {
7924 + return Edge_Provider::NONE !== Edge_Provider::detect()['confidence'];
7925 + }
7926 +
7927 + /**
7928 + * Apply the user's enable/disable choice. Called from the REST toggle
7929 + * endpoint, which is gated by current_user_can( 'manage_options' ) and
7930 + * a verified REST nonce.
7931 + *
7932 + * This is the only path that ENABLES caching — a drop-in is never
7933 + * created for a user who hasn't opted in, which is the guideline that
7934 + * matters (a plugin must not install drop-ins or edit wp-config.php
7935 + * on a fresh activation). RESTORING the drop-in for a site that
7936 + * already has cache_enabled = true is a different act and is handled
7937 + * by restore_dropin_if_enabled() on activation and auto_heal() at
7938 + * runtime; without it every plugin update silently un-caches the site.
7939 + *
7940 + * Enabling is gated on acquisition_blocker(): if another plugin owns the
7941 + * drop-in, or WP_CACHE is written in a form we must not rewrite, nothing
7942 + * is written and the returned state carries `blocked` + a reason the
7943 + * caller can show. Callers must persist `cache_enabled` from the returned
7944 + * `enabled`, never from what they asked for.
7945 + *
1018 7946 * @param bool $enable User's choice.
1019 7947 * @return array{
1020 7948 * enabled: bool,
7949 + * blocked: bool,
7950 + * blocked_reason: ?string,
1021 7951 * dropin_installed: bool,
1022 7952 * wp_cache_constant: bool,
1023 7953 * wp_config_writable: bool,
1024 7954 * manual_snippet: ?string
@@ -1023,29 +7953,294 @@
1023 7953 * wp_config_writable: bool,
1024 7954 * manual_snippet: ?string
1025 7955 * }
1026 7956 */
1027 - public static function toggle( $enable ) {
7957 + public static function toggle( $enable, bool $consented = true ) {
7958 + Page_Cache_Detector::invalidate();
7959 + $expected = Page_Cache_Detector::inspect()['revision'];
7960 + /** Diagnostic seam; changing the expected revision can only force a safe refusal. */
7961 + $expected = (string) apply_filters( 'xspeed_page_cache_expected_revision', $expected );
7962 + $lock = self::page_cache_lock();
7963 + if ( ! is_resource( $lock ) ) {
7964 + return self::blocked_toggle_state( __( 'Could not lock page-cache ownership. Try again.', 'xspeed' ) );
7965 + }
7966 + $changed = false;
7967 + try {
7968 + Page_Cache_Detector::invalidate();
7969 + $fresh = Page_Cache_Detector::inspect()['revision'];
7970 + if ( ! hash_equals( (string) $expected, (string) $fresh ) ) {
7971 + return self::blocked_toggle_state( __( 'Page-cache ownership changed while xSpeed was checking it. Nothing was changed; try again.', 'xspeed' ) );
7972 + }
7973 + $before = self::page_cache_fingerprint();
7974 + $state = self::toggle_unlocked( (bool) $enable, $consented );
7975 + $changed = empty( $state['blocked'] ) && self::page_cache_fingerprint() !== $before;
7976 + return $state;
7977 + } finally {
7978 + flock( $lock, LOCK_UN );
7979 + fclose( $lock );
7980 +
7981 + /*
7982 + * Announce the change to anything caching in front of us.
7983 + *
7984 + * Turning the page cache on or off changes what every URL on this
7985 + * site returns, and a CDN holding renders made under the old state
7986 + * goes on serving them for their whole lifetime. Nothing told it.
7987 + * OFF is the less obvious half and matters as much: the edge
7988 + * otherwise keeps serving pages from a cache the site no longer
7989 + * has.
7990 + *
7991 + * Only when something actually changed. A refused toggle wrote
7992 + * nothing — that is the whole point of the refusal — and purging
7993 + * after it would announce a change that did not happen. Nor does a
7994 + * toggle that found the cache already in the state it asked for:
7995 + * auto_heal() re-enables on every admin_init, and gating on "not
7996 + * refused" alone made every wp-admin page load empty the page
7997 + * cache and purge the edge (QA, 2026-09-23). The fingerprint is
7998 + * compared instead, so restoring a stripped drop-in still counts.
7999 + *
8000 + * After the writes and OUTSIDE the lock. Before them, a purge
8001 + * would repopulate from the state we are in the middle of leaving,
8002 + * which is how a "purge didn't work" report is born; inside them,
8003 + * it would hold single-occupancy ownership for the length of a
8004 + * filesystem sweep.
8005 + */
8006 + if ( $changed ) {
8007 + /*
8008 + * On shutdown, not inline. `purge_all()` sweeps the cache
8009 + * directory and fires the purge actions, and a listener on
8010 + * those can make an HTTP call to an edge — so running it here
8011 + * would make the toggle as slow as the sweep and couple its
8012 + * response to a third party. The Cloudflare Enterprise purge
8013 + * queue already defers for the same reason.
8014 + *
8015 + * Still after the writes: shutdown runs at the end of THIS
8016 + * request, with the new state on disk.
8017 + */
8018 + add_action(
8019 + 'shutdown',
8020 + static function () {
8021 + self::purge_all( 'page cache toggled' );
8022 + },
8023 + 20
8024 + );
8025 + }
8026 + }
8027 + }
8028 +
8029 + /** Run the page-cache mutation while toggle() owns the scoped lock. */
8030 + /**
8031 + * @param bool $consented The user asked for this in the dashboard, so a
8032 + * foreign drop-in may be taken over. False on the
8033 + * unattended paths, which stand down instead.
8034 + */
8035 + /**
8036 + * What the page cache looks like on disk and in wp-config, as one string.
8037 + *
8038 + * `toggle()` compares it before and after its writes to tell a real ON/OFF
8039 + * flip, or a repaired drop-in, from a call that found everything already
8040 + * as asked. Only the latter must not announce a purge.
8041 + */
8042 + private static function page_cache_fingerprint(): string {
8043 + $dropin = self::read_file( WP_CONTENT_DIR . '/advanced-cache.php' );
8044 + return md5(
8045 + ( null === $dropin ? "\0none" : md5( $dropin ) )
8046 + . '|' . self::wp_cache_define_state()
8047 + . '|' . ( self::rewrite_installed() ? '1' : '0' )
8048 + . '|' . ( self::page_cache_operational() ? '1' : '0' )
8049 + );
8050 + }
8051 +
8052 + private static function toggle_unlocked( bool $enable, bool $consented = true ) {
1028 8053 $enable = (bool) $enable;
1029 8054
1030 8055 if ( $enable ) {
1031 - $dropin_ok = self::install_dropin();
1032 - $wp_config_ok = self::set_wp_cache_constant( true );
8056 + /*
8057 + * Preflight. The drop-in and the WP_CACHE define are shared,
8058 + * single-occupancy state; if we do not own them, no part of this
8059 + * runs — not the drop-in, not wp-config.php, not the rewrite
8060 + * block. Refusing whole is the point: a partial enable leaves the
8061 + * site claiming a cache it cannot serve.
8062 + *
8063 + * Every caller routes through here (REST, onboarding, MCP, CLI,
8064 + * the optimize runner, Pro's migration), so the gate lives here
8065 + * rather than being re-implemented at each entry point.
8066 + *
8067 + * Except when there is nothing to acquire. A site where we
8068 + * already own the drop-in and are already serving is being asked
8069 + * to stay as it is, and the gate answers a different question —
8070 + * "is the field free to take" — which a merely ACTIVE competitor
8071 + * makes false. So "make sure caching is on", from an AI agent,
8072 + * the optimize runner or Pro's migration, came back as a refusal
8073 + * telling the user to deactivate a plugin on a site that was
8074 + * caching perfectly. The dashboard never saw it, because nobody
8075 + * presses Enable on a cache that is already enabled.
8076 + *
8077 + * Only the GATE is skipped. The writes below still run, and every
8078 + * one of them is individually idempotent — which matters, because
8079 + * this is the path CacheModule re-bakes the drop-in through when
8080 + * an exclusion rule or the TTL changes (#240, #251), and the path
8081 + * auto_heal() restores a stripped WP_CACHE through. Returning
8082 + * early here left both of those doing nothing at all, silently,
8083 + * on exactly the healthy sites this branch is about.
8084 + */
8085 + $reasserting = self::page_cache_operational() && self::DROPIN_XSPEED === self::dropin_owner();
8086 + $blocker = $reasserting ? null : self::acquisition_blocker();
8087 +
8088 + /*
8089 + * Taking over another plugin's drop-in needs the user to have
8090 + * asked for it. On the dashboard they did -- they clicked the
8091 + * switch, having been told whose file it is. The UNATTENDED
8092 + * callers have no such click: restore_dropin_if_enabled() runs
8093 + * after a plugin update and auto_heal() on an admin page load,
8094 + * both from nothing more than `cache_enabled` still being true.
8095 + *
8096 + * A competitor installed since that flag was set would have its
8097 + * page cache seized by a background repair, which is the silent
8098 + * acquisition this plugin refuses to perform. So those callers
8099 + * pass $consented = false and stand down instead.
8100 + */
8101 + if ( null === $blocker && ! $consented && self::DROPIN_FOREIGN === self::dropin_owner() ) {
8102 + // Name the owner. This string is rendered by host plugins
8103 + // through Host::enable_page_cache(), and an unnamed refusal
8104 + // is what made every host invent its own explanation.
8105 + $owner_label = Page_Cache_Detector::dropin_owner_label();
8106 + return self::blocked_toggle_state(
8107 + $owner_label
8108 + ? sprintf(
8109 + /* translators: %s: the page-caching plugin that owns advanced-cache.php. */
8110 + __( '%s owns advanced-cache.php, so xSpeed left it alone. Enable the cache from the xSpeed dashboard to take it over.', 'xspeed' ),
8111 + $owner_label
8112 + )
8113 + : __( 'Another plugin owns advanced-cache.php, so xSpeed left it alone. Enable the cache from the xSpeed dashboard to take it over.', 'xspeed' )
8114 + );
8115 + }
8116 + if ( null !== $blocker ) {
8117 + Activity_Log::record(
8118 + 'cache_enable_blocked',
8119 + 'Cache not enabled — ' . $blocker,
8120 + Activity_Log::WARN
8121 + );
8122 +
8123 + return self::blocked_toggle_state( $blocker );
8124 + }
8125 +
8126 + $dropin_path = WP_CONTENT_DIR . '/advanced-cache.php';
8127 + $config_path = self::wp_config_path();
8128 + $dropin_before = file_exists( $dropin_path ) ? self::read_file( $dropin_path ) : null;
8129 + $config_before = '' !== $config_path ? self::read_file( $config_path ) : null;
8130 + $dropin_ok = self::install_dropin();
8131 + if ( ! $dropin_ok ) {
8132 + $partial = self::read_file( $dropin_path );
8133 + if ( is_string( $partial ) && xspeed_has_canonical_dropin_signature( $partial ) ) {
8134 + self::rollback_page_cache_artifacts( $dropin_path, $dropin_before, $partial, $config_path, $config_before, null );
8135 + }
8136 + /*
8137 + * Preflight said the field was clear, so this is a filesystem
8138 + * failure (or a drop-in that appeared in between). Without the
8139 + * drop-in there is no cache to enable, and persisting
8140 + * cache_enabled anyway is what produced sites reporting a
8141 + * healthy cache while serving every request uncached.
8142 + */
8143 + $reason = __( 'Could not write wp-content/advanced-cache.php. Check filesystem permissions.', 'xspeed' );
8144 + Activity_Log::record(
8145 + 'cache_enable_blocked',
8146 + 'Cache not enabled — ' . $reason,
8147 + Activity_Log::WARN
8148 + );
8149 +
8150 + return array(
8151 + 'enabled' => false,
8152 + 'blocked' => true,
8153 + 'blocked_reason' => $reason,
8154 + 'dropin_installed' => false,
8155 + 'wp_cache_constant' => false,
8156 + 'rewrite_installed' => false,
8157 + 'wp_config_writable' => self::wp_config_writable(),
8158 + 'manual_snippet' => null,
8159 + 'nginx_snippet' => self::nginx_snippet(),
8160 + 'nginx_server_block' => self::full_nginx_server_block(),
8161 + );
8162 + }
8163 +
8164 + $dropin_written = self::read_file( $dropin_path );
8165 + self::set_wp_cache_constant( true );
8166 + $config_written = '' !== $config_path ? self::read_file( $config_path ) : null;
8167 + Page_Cache_Detector::invalidate();
8168 + $dropin_ours = self::DROPIN_XSPEED === self::dropin_owner();
8169 + $constant_state = self::wp_cache_define_state();
8170 + $constant_ok = 'true' === $constant_state;
8171 +
8172 + /*
8173 + * A wp-config.php we cannot write at all is a supported state, not
8174 + * a failed transaction. Plenty of managed hosts ship the file
8175 + * read-only; there the drop-in is ours and installed, the cache
8176 + * works the moment WP_CACHE exists, and the one line to paste
8177 + * comes back as `manual_snippet`. Rolling back instead left those
8178 + * hosts unable to turn the page cache on by any route — including
8179 + * when the user had already pasted the define, since the write
8180 + * fails on an unwritable file whatever value is already there.
8181 + *
8182 + * `undefined` ONLY. `false` looks eligible — this method would
8183 + * have rewritten it — but the snippet we hand back cannot work
8184 + * there: the file already says `define( 'WP_CACHE', false )`, the
8185 + * first define() call wins, and a user who pastes our line via
8186 + * FTP ends up with a cache that never serves AND a `duplicate`
8187 + * wp-config that blocks every future toggle in both directions.
8188 + * They have to edit the existing line, which means refusing here
8189 + * and saying so. `duplicate` and `dynamic` are refused by
8190 + * acquisition_blocker() before we get here, and if one appears in
8191 + * the race window it must still fail closed.
8192 + */
8193 + $manual_mode = ! $constant_ok
8194 + && 'undefined' === $constant_state
8195 + && ! self::can_write_wp_config();
8196 +
8197 + if ( ! $dropin_ours || ( ! $constant_ok && ! $manual_mode ) ) {
8198 + if ( ! self::can_write_wp_config() ) {
8199 + $reason = 'false' === $constant_state
8200 + ? __( "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' )
8201 + : __( 'xSpeed could not verify the complete page-cache write, and wp-config.php is not writable. Its changes were rolled back.', 'xspeed' );
8202 + } else {
8203 + $reason = __( 'xSpeed could not verify the complete page-cache write. Its changes were rolled back.', 'xspeed' );
8204 + }
8205 + self::rollback_page_cache_artifacts( $dropin_path, $dropin_before, $dropin_written, $config_path, $config_before, $config_written );
8206 + return self::blocked_toggle_state( $reason );
8207 + }
8208 + $wp_config_ok = $constant_ok;
1033 8209 $rewrite_ok = self::install_rewrite();
1034 8210 self::ensure_hits_log_file();
1035 8211 self::sync_mobile_flag();
1036 8212 $snippet = $wp_config_ok ? null : "define( 'WP_CACHE', true );";
8213 + Settings::update( array( 'cache_enabled' => true ) );
8214 + if ( empty( Settings::get()['cache_enabled'] ) ) {
8215 + self::remove_rewrite();
8216 + self::rollback_page_cache_artifacts( $dropin_path, $dropin_before, $dropin_written, $config_path, $config_before, $config_written );
8217 + delete_option( 'xspeed_page_cache_ownership_receipt' );
8218 + return self::blocked_toggle_state( __( 'xSpeed could not save the page-cache setting. Its file changes were rolled back.', 'xspeed' ) );
8219 + }
1037 8220
1038 - Activity_Log::record(
1039 - 'cache_enabled_event',
1040 - $wp_config_ok
1041 - ? 'Cache enabled. Drop-in installed, WP_CACHE constant set.'
1042 - : 'Cache enabled. Drop-in installed; wp-config.php not writable — add the WP_CACHE snippet manually.',
1043 - $wp_config_ok ? Activity_Log::SUCCESS : Activity_Log::WARN
1044 - );
8221 + /*
8222 + * Only when this call actually changed something. auto_heal() runs
8223 + * the enable transaction on every admin_init, and an unconditional
8224 + * entry filled the 50-slot log with identical "Cache enabled" lines
8225 + * within 50 wp-admin page loads, evicting every real event — plus
8226 + * an option write per admin request. The sentence is also false
8227 + * when nothing was installed.
8228 + */
8229 + if ( $dropin_written !== $dropin_before || $config_written !== $config_before ) {
8230 + Activity_Log::record(
8231 + 'cache_enabled_event',
8232 + $wp_config_ok
8233 + ? 'Cache enabled. Drop-in installed, WP_CACHE constant set.'
8234 + : 'Cache enabled. Drop-in installed; wp-config.php not writable — add the WP_CACHE snippet manually.',
8235 + $wp_config_ok ? Activity_Log::SUCCESS : Activity_Log::WARN
8236 + );
8237 + }
1045 8238
1046 8239 return array(
1047 8240 'enabled' => true,
8241 + 'blocked' => false,
8242 + 'blocked_reason' => null,
1048 8243 'dropin_installed' => (bool) $dropin_ok,
1049 8244 'wp_cache_constant' => (bool) $wp_config_ok,
1050 8245 'rewrite_installed' => (bool) $rewrite_ok,
1051 8246 'wp_config_writable' => self::wp_config_writable(),
@@ -1058,26 +8253,168 @@
1058 8253 'nginx_server_block' => self::full_nginx_server_block(),
1059 8254 );
1060 8255 }
1061 8256
8257 + /*
8258 + * Whose advanced-cache.php is on disk decides how much of the disable
8259 + * below may run. Read it once, before anything is touched.
8260 + */
8261 + $owner = self::dropin_owner();
8262 + $not_ours = self::DROPIN_FOREIGN === $owner || self::DROPIN_UNREADABLE === $owner;
8263 + if ( ! self::set_wp_cache_constant( false ) ) {
8264 + /*
8265 + * The mirror of the enable path. A wp-config.php nobody can write
8266 + * does not trap the user in a cache they turned off: WP_CACHE on
8267 + * its own does nothing once advanced-cache.php is gone, and core
8268 + * simply skips the missing drop-in. Refusing here left the
8269 + * read-only managed hosts able to enable the page cache and never
8270 + * able to disable it again.
8271 + *
8272 + * A drop-in that is not ours reaches the same conclusion by a
8273 + * different road. WP_CACHE is then the switch for THEIR cache, so
8274 + * set_wp_cache_constant() refuses it — correctly, and permanently,
8275 + * because nothing the user does to xSpeed will make that file ours
8276 + * again. Treating that refusal as a failed disable was a trap with
8277 + * no exit: install any competing cache plugin while xSpeed's cache
8278 + * was on, and xSpeed's toggle could never be turned off again,
8279 + * while the dashboard went on claiming a cache that was serving
8280 + * nothing. Turning xSpeed off is entirely within our own state —
8281 + * our setting, our rewrite block — so it proceeds, and their
8282 + * constant and their file are left exactly as they are.
8283 + */
8284 + /*
8285 + * Every reason set_wp_cache_constant() refuses is structural
8286 + * except one, and the exception is the only one worth blocking
8287 + * on. It will not touch a constant it cannot prove is ours; it
8288 + * will not rewrite a define it cannot read as a literal —
8289 + * duplicate, dynamic, or inside a conditional; and it cannot
8290 + * write a file the filesystem will not let it write. None of
8291 + * those improve on a retry, and all of them leave a WP_CACHE
8292 + * that does nothing once our drop-in is gone. What is left — our
8293 + * own constant, in a shape we can rewrite, in a file we can
8294 + * write, and the write still failed — is a real I/O failure, and
8295 + * that one still refuses so the user is not told a cache was
8296 + * turned off while it goes on serving.
8297 + *
8298 + * The proof, not the drop-in, is the test. A user who pasted our
8299 + * manual snippet on a locked-down host has a WP_CACHE line with
8300 + * no receipt on it; if their drop-in later goes missing, we can
8301 + * never prove that line is ours, so refusing left the toggle
8302 + * stuck on with no way out but enabling first and disabling
8303 + * again. Nothing loads a drop-in that is not there, so the line
8304 + * is inert either way and the disable proceeds without it.
8305 + */
8306 + $leave_it = ! self::wp_cache_define_is_ours_to_remove( $owner )
8307 + || ! in_array( self::wp_cache_define_state(), array( 'true', 'false', 'undefined' ), true )
8308 + || ! self::can_write_wp_config();
8309 + if ( ! $leave_it ) {
8310 + return self::blocked_toggle_state( __( 'xSpeed could not safely remove its WP_CACHE setting. The cache remains enabled.', 'xspeed' ) );
8311 + }
8312 + }
1062 8313 self::remove_dropin();
1063 - self::set_wp_cache_constant( false );
8314 + if ( self::DROPIN_XSPEED === self::dropin_owner() ) {
8315 + // Put WP_CACHE back, and say so if we could not. Reporting a
8316 + // hardcoded `enabled: true` here claimed a working cache on a
8317 + // site whose constant we had just failed to restore.
8318 + // Put WP_CACHE back, then read the outcome off disk rather than
8319 + // trusting the write's return value — a write can report failure
8320 + // for a value that was already correct, and the question the
8321 + // caller needs answered is whether the cache serves.
8322 + self::set_wp_cache_constant( true );
8323 + return self::blocked_toggle_state(
8324 + self::page_cache_operational()
8325 + ? __( 'xSpeed could not remove its page-cache drop-in. The cache remains enabled.', 'xspeed' )
8326 + : __( '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' )
8327 + );
8328 + }
1064 8329 self::remove_rewrite();
8330 + /*
8331 + * The .htaccess block serves cached HTML straight off disk without
8332 + * ever reaching PHP, so a block we failed to remove keeps answering
8333 + * requests from a cache the user just turned off — and nothing else
8334 + * in this method can stop it. remove_rewrite() also returns false
8335 + * when there is no .htaccess to clean, which is the ordinary case,
8336 + * so ask the file rather than trust the return value.
8337 + */
8338 + if ( self::rewrite_installed() ) {
8339 + if ( $not_ours ) {
8340 + // Nothing to roll back — under a foreign drop-in this method
8341 + // removed no drop-in and wrote no constant, and it could not
8342 + // put either back if it wanted to. Say what is actually left.
8343 + 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' ) );
8344 + }
8345 + /*
8346 + * Roll the disable back. Both calls can fail — a filesystem that
8347 + * would not let us remove the block may not let us write the
8348 + * drop-in either — and discarding their results reported an
8349 + * enabled cache over a site left with no drop-in and no
8350 + * constant. Fall through to the default state so the artifact
8351 + * fields are read from disk rather than asserted.
8352 + */
8353 + self::install_dropin();
8354 + self::set_wp_cache_constant( true );
8355 + // Both of those can fail — a filesystem that would not let us
8356 + // remove the block may not let us write the drop-in either — so
8357 + // the message follows what is on disk afterwards, not what the
8358 + // calls returned.
8359 + return self::blocked_toggle_state(
8360 + self::page_cache_operational()
8361 + ? __( 'xSpeed could not remove its rewrite rules from .htaccess, which would keep serving cached pages. The cache remains enabled.', 'xspeed' )
8362 + : __( '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' )
8363 + );
8364 + }
1065 8365 // Drop the device-bucket marker too — with the drop-in gone there's
1066 8366 // nothing left to read it, and leaving it behind would dirty a fresh
1067 8367 // re-enable (and leaks across test runs).
1068 8368 self::sync_mobile_flag( false );
8369 + Settings::update( array( 'cache_enabled' => false ) );
8370 + if ( ! empty( Settings::get()['cache_enabled'] ) ) {
8371 + if ( $not_ours ) {
8372 + // Same as above: there is nothing of ours on disk to restore.
8373 + return self::blocked_toggle_state( __( 'xSpeed could not save the disabled state.', 'xspeed' ) );
8374 + }
8375 + self::install_dropin();
8376 + self::set_wp_cache_constant( true );
8377 + return self::blocked_toggle_state( __( 'xSpeed could not save the disabled state. The page cache was restored.', 'xspeed' ) );
8378 + }
1069 8379
8380 + // A WP_CACHE we could not remove because wp-config.php is read-only
8381 + // is left behind deliberately (see above) — say so rather than
8382 + // reporting a constant that is still in the file as gone.
8383 + $constant_left = 'true' === self::wp_cache_define_state();
8384 + /*
8385 + * Say why the constant is still there, because there are now three
8386 + * different reasons and they call for different advice. Keyed off the
8387 + * same facts $leave_it was, so the log cannot drift from the decision
8388 + * it is describing — it did, briefly, and reported a wp-config.php as
8389 + * unwritable when the real reason was that we could not prove the
8390 + * line was ours.
8391 + */
8392 + if ( self::DROPIN_UNREADABLE === $owner ) {
8393 + $log_message = 'Cache disabled. advanced-cache.php could not be read, so it and the WP_CACHE setting were left untouched.';
8394 + } elseif ( $not_ours ) {
8395 + $log_message = 'Cache disabled. Another plugin owns advanced-cache.php, so its drop-in and its WP_CACHE setting were left untouched.';
8396 + } elseif ( ! $constant_left ) {
8397 + $log_message = 'Cache disabled. Drop-in removed.';
8398 + } elseif ( ! self::wp_cache_define_is_ours_to_remove( $owner ) ) {
8399 + $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.';
8400 + } elseif ( ! self::can_write_wp_config() ) {
8401 + $log_message = 'Cache disabled. Drop-in removed; wp-config.php not writable, so WP_CACHE was left in place (harmless without the drop-in).';
8402 + } else {
8403 + $log_message = 'Cache disabled. Drop-in removed; WP_CACHE was left in place (harmless without the drop-in).';
8404 + }
1070 8405 Activity_Log::record(
1071 8406 'cache_disabled_event',
1072 - 'Cache disabled. Drop-in removed.',
1073 - Activity_Log::INFO
8407 + $log_message,
8408 + $constant_left ? Activity_Log::WARN : Activity_Log::INFO
1074 8409 );
1075 8410
1076 8411 return array(
1077 8412 'enabled' => false,
8413 + 'blocked' => false,
8414 + 'blocked_reason' => null,
1078 8415 'dropin_installed' => false,
1079 - 'wp_cache_constant' => false,
8416 + 'wp_cache_constant' => $constant_left,
1080 8417 'rewrite_installed' => false,
1081 8418 'wp_config_writable' => self::wp_config_writable(),
1082 8419 'manual_snippet' => null,
1083 8420 'nginx_snippet' => self::nginx_snippet(),
@@ -1084,9 +8421,134 @@
1084 8421 'nginx_server_block' => self::full_nginx_server_block(),
1085 8422 );
1086 8423 }
1087 8424
8425 + /** Acquire the local lock that serializes page-cache ownership changes. */
8426 + private static function page_cache_lock() {
8427 + $path = WP_CONTENT_DIR . '/.xspeed-page-cache.lock';
8428 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen,WordPress.PHP.NoSilencedErrors.Discouraged -- flock requires a local handle; failure is a safe blocked result.
8429 + $lock = @fopen( $path, 'c+' );
8430 + if ( ! is_resource( $lock ) || ! flock( $lock, LOCK_EX ) ) {
8431 + return false;
8432 + }
8433 + return $lock;
8434 + }
8435 +
1088 8436 /**
8437 + * Build the stable response shape for a refused transaction.
8438 + *
8439 + * The artifact fields report what is ON DISK, not zeros. A refusal means
8440 + * xSpeed changed nothing — on a site already running our cache that is
8441 + * exactly the state where the drop-in and WP_CACHE are both still in
8442 + * place and still serving hits. Hardcoding false told the dashboard the
8443 + * cache had been dismantled every time a refusal was returned.
8444 + */
8445 + private static function blocked_toggle_state( string $reason ): array {
8446 + /*
8447 + * `enabled` answers ONE question: is the page cache operational right
8448 + * now. Not what was asked for, and not what the option says.
8449 + *
8450 + * WordPress loads advanced-cache.php only when WP_CACHE is truthy, so
8451 + * those two files together are the whole answer, and reading them is
8452 + * the only source that cannot go stale. Both of the alternatives were
8453 + * tried here and both produced wrong answers on real paths: a
8454 + * hardcoded false told a caller the cache had gone away on a site
8455 + * still serving hits, and the persisted setting told a caller the
8456 + * cache was healthy after a rollback had just removed the artifacts
8457 + * — the option is not written until the end of the transaction, so
8458 + * mid-transaction it is stale by construction.
8459 + *
8460 + * Deliberately not a parameter. Every branch that got to choose its
8461 + * own answer eventually chose wrong.
8462 + */
8463 + return array(
8464 + 'enabled' => self::page_cache_operational(),
8465 + 'blocked' => true,
8466 + 'blocked_reason' => $reason,
8467 + 'dropin_installed' => self::DROPIN_XSPEED === self::dropin_owner(),
8468 + 'wp_cache_constant' => 'true' === self::wp_cache_define_state(),
8469 + 'rewrite_installed' => self::rewrite_installed(),
8470 + 'wp_config_writable' => self::wp_config_writable(),
8471 + 'manual_snippet' => null,
8472 + 'nginx_snippet' => self::nginx_snippet(),
8473 + 'nginx_server_block' => self::full_nginx_server_block(),
8474 + );
8475 + }
8476 +
8477 + /**
8478 + * The wp-config.php line a user must paste, or null when none is needed.
8479 + *
8480 + * Non-null only where the drop-in is ours and WP_CACHE is not set to true
8481 + * in a file we can write — the read-only managed host. Everywhere else the
8482 + * constant is ours to manage and there is nothing to ask for.
8483 + */
8484 + public static function manual_wp_cache_snippet(): ?string {
8485 + if ( self::DROPIN_XSPEED !== self::dropin_owner() ) {
8486 + return null;
8487 + }
8488 + if ( 'true' === self::wp_cache_define_state() ) {
8489 + return null;
8490 + }
8491 + return self::wp_config_writable() ? null : "define( 'WP_CACHE', true );";
8492 + }
8493 +
8494 + /**
8495 + * Is the page cache serving right now?
8496 + *
8497 + * Two things decide it, and `WP_CACHE` is not one of them.
8498 + *
8499 + * xSpeed serves a cached page from `template_redirect` whenever the
8500 + * setting is on — see the `HIT (php)` mark on that path, which exists
8501 + * precisely for "the drop-in isn't loaded". `advanced-cache.php` and the
8502 + * `WP_CACHE` constant that loads it are the FAST path: they answer before
8503 + * WordPress boots, which is worth a lot of milliseconds and nothing at
8504 + * all to the question of whether pages are being served from cache.
8505 + *
8506 + * Conflating the two reported a dead cache over a live one. On a managed
8507 + * host with an unwritable wp-config.php — the exact case the manual
8508 + * snippet exists for — one card said "Your cache works on every request",
8509 + * "On, but not serving", "nothing will be cached until you add this line"
8510 + * and "hit ratio 67%", all at once, and told the user to edit a file they
8511 + * have no permission to write. The released 1.2.1 reported that site as
8512 + * active, correctly.
8513 + *
8514 + * So: the setting, and whether anyone else holds the drop-in. A foreign
8515 + * drop-in answers before WordPress loads us, so ours never runs and we
8516 + * genuinely are not serving. An unreadable one we must assume the same of.
8517 + * Everything else — our drop-in, or none at all — serves.
8518 + *
8519 + * Public because it is part of the host-plugin contract — see Host. A
8520 + * plugin that installed xSpeed needs to be able to say whether the cache
8521 + * it asked for is actually serving, and no combination of settings reads
8522 + * answers that.
8523 + */
8524 + public static function page_cache_operational(): bool {
8525 + $settings = Settings::get();
8526 + if ( empty( $settings['cache_enabled'] ) ) {
8527 + return false;
8528 + }
8529 + $owner = self::dropin_owner();
8530 + return self::DROPIN_FOREIGN !== $owner && self::DROPIN_UNREADABLE !== $owner;
8531 + }
8532 +
8533 + /** Restore exact snapshots only while disk still matches our own write. */
8534 + 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 {
8535 + // Roll back only files that still carry xSpeed's just-written state.
8536 + if ( null !== $dropin_written && hash_equals( $dropin_written, (string) self::read_file( $dropin_path ) ) ) {
8537 + if ( null === $dropin_before ) {
8538 + wp_delete_file( $dropin_path );
8539 + } else {
8540 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- Exact compare-and-swap rollback under the scoped lock.
8541 + file_put_contents( $dropin_path, $dropin_before );
8542 + }
8543 + }
8544 + 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 ) ) {
8545 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- Exact compare-and-swap rollback under the scoped lock.
8546 + file_put_contents( $config_path, $config_before );
8547 + }
8548 + }
8549 +
8550 + /**
1089 8551 * Check wp-config.php writability via WP_Filesystem. Plugin Check flags
1090 8552 * direct is_writable() under WordPress.WP.AlternativeFunctions.
1091 8553 */
1092 8554 private static function wp_config_writable() {
@@ -1123,10 +8585,11 @@
1123 8585 * they don't share a user at all. A default-umask 0644 file is then
1124 8586 * unwritable by nginx, the access_log write silently fails, and the
1125 8587 * dashboard shows a 0% hit ratio even though static HITs are serving.
1126 8588 * So we widen the dir to 0777 and the file to 0666 — group/other write —
1127 - * so whatever uid nginx runs as can append. (The file holds only HIT
1128 - * request lines, no secrets.)
8589 + * so whatever uid nginx runs as can append. The file holds HIT request
8590 + * lines and must be protected like an access log: paths and queries can
8591 + * contain sensitive values.
1129 8592 */
1130 8593 /**
1131 8594 * Directory holding the nginx hit log. Lives under uploads/, NOT the
1132 8595 * cache dir — uninstall.php and a cache purge both delete the cache
@@ -1140,11 +8603,22 @@
1140 8603 * Falls back to the cache dir only if uploads is somehow unavailable.
1141 8604 */
1142 8605 public static function hits_log_dir(): string {
1143 8606 if ( function_exists( 'wp_upload_dir' ) ) {
8607 + // One drop-in serves the whole network, so its hit log has one
8608 + // home: the main site's uploads. Resolved per blog, the path
8609 + // baked into the drop-in changed with whichever blog's admin
8610 + // last ran auto_heal(), and each rewrite read as a page-cache
8611 + // change and purged the site and the edge (QA, 2026-09-23).
8612 + // A subsite's uploads are `<main uploads>/sites/<id>`, so the
8613 + // suffix comes off rather than switching blogs to ask.
1144 8614 $uploads = wp_upload_dir( null, false );
1145 8615 if ( is_array( $uploads ) && empty( $uploads['error'] ) && ! empty( $uploads['basedir'] ) ) {
1146 - return rtrim( (string) $uploads['basedir'], '/' ) . '/xspeed';
8616 + $base = rtrim( (string) $uploads['basedir'], '/' );
8617 + if ( function_exists( 'is_multisite' ) && is_multisite() ) {
8618 + $base = (string) preg_replace( '#/sites/\d+$#', '', $base );
8619 + }
8620 + return $base . '/xspeed';
1147 8621 }
1148 8622 }
1149 8623 return XSPEED_CACHE_DIR;
1150 8624 }
@@ -1169,12 +8643,218 @@
1169 8643 * and the fast pre-WP path was silently dead.
1170 8644 *
1171 8645 * @param bool|null $enabled Force a state; null reads the current setting.
1172 8646 */
8647 + /**
8648 + * Write the subdirectory-multisite path list the drop-in needs to work
8649 + * out which blog a request belongs to.
8650 + *
8651 + * The drop-in runs before WordPress, so it cannot call is_multisite()
8652 + * or get_blog_details(). It can only see REQUEST_URI — so we persist the
8653 + * network's blog paths (one per line, longest first) next to the cache
8654 + * files, exactly as sync_mobile_flag() persists the device flag. The
8655 + * drop-in prefix-matches the URI against that list to pick the same
8656 + * bucket Cache::current_host_dir() picks. (#6)
8657 + *
8658 + * No file is written for a single site or a subdomain network — there
8659 + * the host alone identifies the blog and the bucket carries no prefix.
8660 + */
8661 + public static function sync_site_paths(): void {
8662 + $file = XSPEED_CACHE_DIR . '/.site-paths';
8663 +
8664 + $needed = function_exists( 'is_multisite' ) && is_multisite()
8665 + && ( ! function_exists( 'is_subdomain_install' ) || ! is_subdomain_install() );
8666 +
8667 + if ( ! $needed ) {
8668 + if ( file_exists( $file ) ) {
8669 + // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal.
8670 + @unlink( $file );
8671 + }
8672 + return;
8673 + }
8674 +
8675 + if ( ! function_exists( 'get_sites' ) ) {
8676 + return;
8677 + }
8678 +
8679 + $paths = array();
8680 + foreach ( get_sites( array( 'number' => 0 ) ) as $site ) {
8681 + $prefix = self::path_prefix_segment( (string) $site->path );
8682 + if ( '' !== $prefix ) {
8683 + // Store the raw path so the drop-in can prefix-match a URI,
8684 + // alongside the segment it maps to.
8685 + $paths[ trim( (string) $site->path, '/' ) ] = $prefix;
8686 + }
8687 + }
8688 +
8689 + if ( empty( $paths ) ) {
8690 + if ( file_exists( $file ) ) {
8691 + // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- see above.
8692 + @unlink( $file );
8693 + }
8694 + return;
8695 + }
8696 +
8697 + // Longest path first so /a/b wins over /a.
8698 + uksort(
8699 + $paths,
8700 + static function ( $x, $y ) {
8701 + return strlen( (string) $y ) <=> strlen( (string) $x );
8702 + }
8703 + );
8704 +
8705 + $lines = array();
8706 + foreach ( $paths as $raw => $segment ) {
8707 + $lines[] = $raw . '|' . $segment;
8708 + }
8709 +
8710 + if ( ! is_dir( XSPEED_CACHE_DIR ) && ! wp_mkdir_p( XSPEED_CACHE_DIR ) ) {
8711 + return;
8712 + }
8713 + // 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.
8714 + file_put_contents( $file, implode( "\n", $lines ), LOCK_EX );
8715 + }
8716 +
8717 + /**
8718 + * Compile `ignored_query_params` into a regex the DROP-IN can use.
8719 + *
8720 + * Tracking traffic was cached but never served fast. should_cache()
8721 + * learned to allow `?utm_source=…` through and cache_key() strips the
8722 + * query, so `/post` and `/post?utm_source=x` share one entry — but the
8723 + * drop-in still bailed on ANY query string, so every visitor from an
8724 + * email or ad campaign paid a full WordPress boot to be handed a file
8725 + * that was already on disk. On a marketing site that is most of the
8726 + * paid traffic taking the slowest path. (#13)
8727 + *
8728 + * The drop-in runs before WordPress, so it cannot read the option or
8729 + * call Glob_Matcher. It gets a precompiled alternation instead, written
8730 + * next to the cache files exactly as sync_mobile_flag() writes the
8731 + * device flag. Regenerated whenever cache settings are saved.
8732 + *
8733 + * Only the KEYS matter: a param whose name is on the list contributes
8734 + * nothing to the response, so the entry keyed without it is correct.
8735 + * Anything not on the list means the drop-in must stand down and let
8736 + * PHP decide — the file is deleted rather than left stale when the
8737 + * list is empty, so a missing sidecar always fails safe.
8738 + */
8739 + public static function sync_query_allowlist(): void {
8740 + $file = XSPEED_CACHE_DIR . '/.ignored-query-params';
8741 +
8742 + /*
8743 + * Stored read, not Settings_Manager::get() — this runs from boot(),
8744 + * before translation is legal (see stored_cache_opts()).
8745 + *
8746 + * A raw read applies no schema defaults, and this field's default is a
8747 + * long tracking-parameter list, NOT empty. Falling back to array()
8748 + * would strip that whole allow-list from the drop-in on any install
8749 + * that has never saved the Cache panel. So fall back to the schema's
8750 + * own default, read from the module without building its labels.
8751 + */
8752 + $opts = self::stored_cache_opts();
8753 + $ignored = is_array( $opts['ignored_query_params'] ?? null )
8754 + ? $opts['ignored_query_params']
8755 + : \XSpeed\Modules\Cache\CacheModule::default_ignored_query_params();
8756 +
8757 + $parts = array();
8758 + foreach ( $ignored as $pattern ) {
8759 + $pattern = trim( (string) $pattern );
8760 + if ( '' === $pattern ) {
8761 + continue;
8762 + }
8763 + if ( '~' === $pattern[0] ) {
8764 + // Raw regex, PHP-side dialect. Keep it — unlike a server
8765 + // config, the drop-in runs the same PCRE engine, so the
8766 + // pattern behaves identically. Anchored below with the rest.
8767 + $body = substr( $pattern, 1 );
8768 + if ( '' !== $body && false !== @preg_match( '#^(?:' . $body . ')$#', '' ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a malformed user pattern must be dropped, not fatal.
8769 + $parts[] = $body;
8770 + }
8771 + continue;
8772 + }
8773 + // Glob semantics, same as Glob_Matcher: * is any run, ? is one.
8774 + $esc = preg_quote( $pattern, '#' );
8775 + $esc = str_replace( array( '\*', '\?' ), array( '.*', '.' ), $esc );
8776 + $parts[] = $esc;
8777 + }
8778 +
8779 + if ( empty( $parts ) ) {
8780 + if ( file_exists( $file ) ) {
8781 + // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal.
8782 + @unlink( $file );
8783 + }
8784 + return;
8785 + }
8786 +
8787 + if ( ! is_dir( XSPEED_CACHE_DIR ) && ! wp_mkdir_p( XSPEED_CACHE_DIR ) ) {
8788 + return;
8789 + }
8790 +
8791 + // The drop-in anchors this as `^…$`, so the lookahead refuses the
8792 + // never-ignored names whole, whatever entry would have matched them.
8793 + $never = implode( '|', array_map( static fn ( $p ) => preg_quote( $p, '#' ), self::NEVER_IGNORED_QUERY_PARAMS ) );
8794 + $payload = '(?!(?:' . $never . ')$)(?:' . implode( '|', array_unique( $parts ) ) . ')';
8795 +
8796 + // Only write when the value actually changed. This runs from
8797 + // reconcile_mobile_separate() on CacheModule::boot(), so an
8798 + // unconditional write cost a file write and an exclusive lock on every
8799 + // request that boots WordPress — every MISS, every BYPASS, every admin
8800 + // screen, every REST call. sync_mobile_flag() below is the model: it
8801 + // touches the marker only when the setting flips.
8802 + // 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.
8803 + if ( is_readable( $file ) && (string) @file_get_contents( $file ) === $payload ) {
8804 + return;
8805 + }
8806 +
8807 + // 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.
8808 + file_put_contents( $file, $payload, LOCK_EX );
8809 + }
8810 +
8811 + /**
8812 + * CacheModule's STORED settings, read straight from the option.
8813 + *
8814 + * `Settings_Manager::get( 'cache' )` builds CacheModule's settings schema,
8815 + * whose labels are declared through `__()`. The reconcile chain below runs
8816 + * from `CacheModule::boot()` on `plugins_loaded` — before
8817 + * `after_setup_theme`, the point WordPress 6.7+ treats as safe to
8818 + * translate — so going through the schema there fires
8819 + * `_load_textdomain_just_in_time` on every request AND resolves the labels
8820 + * against a domain that is not loaded yet.
8821 + *
8822 + * The callers here need stored values, not schema metadata, so a raw read
8823 + * is equivalent. It applies NO defaults or coercion: read each key with a
8824 + * fallback matching the schema's own default.
8825 + *
8826 + * @return array<string,mixed>
8827 + */
8828 + private static function stored_cache_opts(): array {
8829 + $stored = get_option( Settings_Manager::OPTION_PREFIX . 'cache', array() );
8830 + return is_array( $stored ) ? $stored : array();
8831 + }
8832 +
8833 + /**
8834 + * Strict truthiness for the LiteSpeed Static Fast Path opt-in.
8835 + *
8836 + * On non-LiteSpeed servers the key is out of the schema and carried by
8837 + * preserved_keys(), so a REST/MCP write lands VERBATIM — QA on #513
8838 + * stored the string "false" on Apache and the fast path installed
8839 + * itself the moment the site moved to LiteSpeed, because
8840 + * empty("false") is false. Only an explicit, unambiguous "yes" may
8841 + * enable a path that trades away hit tagging; any other value —
8842 + * "false", "no", arbitrary junk — stays OFF, which is the default the
8843 + * user never left.
8844 + */
8845 + private static function litespeed_optin_enabled( $value ): bool {
8846 + if ( true === $value || 1 === $value ) {
8847 + return true;
8848 + }
8849 + return is_string( $value )
8850 + && in_array( strtolower( trim( $value ) ), array( '1', 'true', 'on', 'yes' ), true );
8851 + }
8852 +
1173 8853 public static function sync_mobile_flag( $enabled = null ): void {
1174 8854 if ( null === $enabled ) {
1175 - $opts = Settings_Manager::get( 'cache' );
1176 - $enabled = ! empty( $opts['mobile_separate'] );
8855 + $stored = self::stored_cache_opts();
8856 + $enabled = ! empty( $stored['mobile_separate'] );
1177 8857 }
1178 8858 $dir = XSPEED_CACHE_DIR;
1179 8859 $flag = $dir . '/.mobile-separate';
1180 8860 if ( $enabled ) {
@@ -1187,9 +8867,9 @@
1187 8867 }
1188 8868 return;
1189 8869 }
1190 8870 if ( file_exists( $flag ) ) {
1191 - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal.
8871 + // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal.
1192 8872 @unlink( $flag );
1193 8873 }
1194 8874 }
1195 8875
@@ -1217,9 +8897,9 @@
1217 8897 }
1218 8898 return;
1219 8899 }
1220 8900 if ( file_exists( $flag ) ) {
1221 - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal.
8901 + // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal.
1222 8902 @unlink( $flag );
1223 8903 }
1224 8904 }
1225 8905
@@ -1240,8 +8920,16 @@
1240 8920 * reconcile, and toggle() handles install/teardown itself.
1241 8921 */
1242 8922 public static function reconcile_mobile_separate(): void {
1243 8923 self::sync_mobile_flag();
8924 + if ( defined( 'XSPEED_CACHE_DIR' ) ) {
8925 + // Keep the drop-in's view of the network's blog paths current — a
8926 + // site added or removed changes which bucket its URLs belong to. (#6)
8927 + self::sync_site_paths();
8928 + // Keep the drop-in's copy of the query allow-list current — a param
8929 + // added in settings must reach the fast path too. (#13)
8930 + self::sync_query_allowlist();
8931 + }
1244 8932
1245 8933 // The rewrite/static reconciliation below needs the plugin's path
1246 8934 // constants. They're absent in early-boot / unit-test contexts where
1247 8935 // only the drop-in flag matters — bail to the flag-only behavior then.
@@ -1257,8 +8945,31 @@
1257 8945
1258 8946 $rewrite_present = self::rewrite_installed();
1259 8947 $rewrite_wanted = self::static_rewrite_allowed();
1260 8948
8949 + // Did the thing that actually invalidates cache KEYS change?
8950 + // mobile_separate buckets entries as |d / |m, so flipping it makes
8951 + // stored entries mis-bucketed and they must go. A rewrite-state
8952 + // mismatch from anything else (e.g. mod_headers detection, a hand-
8953 + // edited .htaccess) changes no key at all — the same files are still
8954 + // valid, they're just served by PHP instead of by the web server.
8955 + // Purging there is what let one WP-CLI call wipe the whole cache on
8956 + // every bootstrap. (#138)
8957 + //
8958 + // Read the setting from the SAME place static_rewrite_allowed() and
8959 + // sync_mobile_flag() do — the cache module's settings, not the
8960 + // top-level xspeed_options — or this marker would track a key that
8961 + // never changes and a real flip would go unnoticed.
8962 + // Stored read — this runs from boot(); see stored_cache_opts().
8963 + $cache_opts = self::stored_cache_opts();
8964 + $mobile_now = ! empty( $cache_opts['mobile_separate'] );
8965 + $mobile_last = get_option( 'xspeed_last_mobile_separate', null );
8966 + $mobile_flipped = ( null !== $mobile_last && (bool) (int) $mobile_last !== $mobile_now );
8967 +
8968 + if ( (string) (int) $mobile_now !== (string) $mobile_last ) {
8969 + update_option( 'xspeed_last_mobile_separate', $mobile_now ? '1' : '0', false );
8970 + }
8971 +
1261 8972 if ( $rewrite_present === $rewrite_wanted ) {
1262 8973 // Already consistent — nothing flipped, leave caches intact so a
1263 8974 // plain settings save (e.g. expiry change) doesn't blow the cache.
1264 8975 return;
@@ -1263,17 +8974,19 @@
1263 8974 // plain settings save (e.g. expiry change) doesn't blow the cache.
1264 8975 return;
1265 8976 }
1266 8977
1267 - // The setting flipped. Bring the rewrite into line and purge the
1268 - // now-misbucketed cache so the next request re-primes under the new
1269 - // device scheme.
8978 + // Bring the rewrite into line with what this server actually supports.
1270 8979 if ( $rewrite_wanted ) {
1271 8980 self::install_rewrite();
1272 8981 } else {
1273 8982 self::remove_rewrite();
1274 8983 }
1275 - self::purge_all( 'mobile_separate changed' );
8984 +
8985 + // Only discard cache contents when the device bucketing changed.
8986 + if ( $mobile_flipped ) {
8987 + self::purge_all( 'mobile_separate changed' );
8988 + }
1276 8989 }
1277 8990
1278 8991 /**
1279 8992 * Whether the server-level static-rewrite fast path may be used.
@@ -1297,10 +9010,12 @@
1297 9010 * `.htaccess` equivalent of nginx's per-location `access_log` to record
1298 9011 * the hit. The result was a cache that worked but was invisible: no HIT
1299 9012 * header and a hit-ratio frozen near 0%. Every OTHER server gives the
1300 9013 * user a visible HIT header + a counted hit (nginx via add_header +
1301 - * access_log in its snippet; Apache via .htaccess mod_headers, which it
1302 - * honors). To keep LiteSpeed CONSISTENT with the rest, we route its hits
9014 + * access_log in its snippet; Apache via the `<IfModule mod_headers.c>`
9015 + * block in rewrite_block_lines(), WHEN that module is loaded — when it is
9016 + * not, Apache takes this same drop-in fallback). To keep LiteSpeed
9017 + * CONSISTENT with the rest, we route its hits
1303 9018 * through the PHP drop-in instead — the drop-in emits
1304 9019 * `X-XSpeed-Cache: HIT (php)` and calls Hit_Counter inline, exactly the
1305 9020 * observable behavior the other servers get. The cost is the drop-in's
1306 9021 * ~30ms TTFB vs the static path's ~10ms, paid only on LiteSpeed; in
@@ -1308,34 +9023,501 @@
1308 9023 * the truth there. (Apache keeps the static fast path — it honors the
1309 9024 * header.) See maybe_emit_lscache_headers() for the paired LSCache
1310 9025 * stand-down that stops LiteSpeed's own module from shadowing the
1311 9026 * drop-in.
9027 + *
9028 + * Opt-in (#509): `litespeed_static_rewrite` re-enables the fast path on
9029 + * LiteSpeed for users who value raw TTFB over hit accounting. The trade
9030 + * is stated in the setting's copy: statically served hits carry no
9031 + * X-XSpeed-Cache header and are not counted (LiteSpeed logs the
9032 + * original request line, so even the access-log scan cannot see
9033 + * them — see Hit_Counter::collect_server_log_hits()). The drop-in
9034 + * default above stays — nobody is surprised into an unverifiable cache.
1312 9035 */
1313 9036 public static function static_rewrite_allowed(): bool {
1314 - // LiteSpeed: drop-in serves hits (visible + counted) — see docblock.
9037 + // Stored read — reached from boot(); see stored_cache_opts().
9038 + $opts = self::stored_cache_opts();
9039 + // LiteSpeed: drop-in serves hits (visible + counted) unless the user
9040 + // explicitly opted into the static fast path — see docblock.
9041 + if ( Server::LITESPEED === Server::type()
9042 + && ! self::litespeed_optin_enabled( $opts['litespeed_static_rewrite'] ?? false ) ) {
9043 + return false;
9044 + }
9045 + // Apache without mod_headers is in EXACTLY the position LiteSpeed
9046 + // is in above: it can run the RewriteRule and serve the static
9047 + // file, but it cannot stamp `X-XSpeed-Cache` on the response, so
9048 + // the hit is invisible to the user and uncountable by
9049 + // Hit_Counter. The docblock above used to assert Apache "honors
9050 + // mod_headers" and left it on the fast path unconditionally —
9051 + // true only when the module is actually loaded. Fall back to the
9052 + // drop-in when it isn't, trading ~10ms of TTFB for a hit that
9053 + // shows up in the header and the ratio. (Field report: hit ratio
9054 + // pinned at 0% on a working Apache cache.)
9055 + if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) {
9056 + return false;
9057 + }
9058 + return empty( $opts['mobile_separate'] );
9059 + }
9060 +
9061 + /**
9062 + * Why the device-blind static rewrite is NOT installed, when it isn't.
9063 + * Returns 'mobile_separate' when Separate Mobile Cache is the blocker
9064 + * (the static file is one-per-URL, so it can't coexist with per-device
9065 + * buckets), 'no_mod_headers' when Apache can't stamp the HIT header,
9066 + * '' otherwise. Lets the dashboard explain the slow path instead of
9067 + * silently falling back to PHP serving. (FBS-83145)
9068 + *
9069 + * Every refusal in static_rewrite_allowed() that is NOT self-explanatory
9070 + * must have a branch here. Otherwise the Health card falls through to
9071 + * "Block missing — toggle Enable Cache off and on to reinstall it",
9072 + * advice that cannot work: the same condition that suppressed the write
9073 + * suppresses the reinstall, and auto_heal() strips the block again on
9074 + * the next admin page load. (Field report: Apache host with mod_headers
9075 + * unloaded sat on the slow path with no way to find out why.)
9076 + */
9077 + /**
9078 + * Qualify a raw probe result with what we already KNOW about config.
9079 + *
9080 + * probe_static_rewrite() writes its own file under the static-cache tree
9081 + * and fetches that, which succeeds whenever the web server can serve a
9082 + * static file at all — including when static_rewrite_allowed() is false
9083 + * and no real page is on the static path. So `active: true` on its own is
9084 + * not evidence that pages are being served statically.
9085 + *
9086 + * The reachable case is nginx with Separate Mobile Cache on: the snippet
9087 + * lives in the server block and we cannot remove it, pages are
9088 + * deliberately routed to the PHP drop-in, but the probe file is still
9089 + * served directly.
9090 + *
9091 + * The Health panel learned this in 88b4b50; the CLI, REST and MCP paths
9092 + * did not, so they kept reporting "active" in exactly that configuration.
9093 + * Rather than repeat the reasoning at each call site, they now all come
9094 + * through here.
9095 + *
9096 + * Deliberately does NOT consult rewrite_installed(): on nginx the fast
9097 + * path is the pasted snippet and there is no .htaccess marker to find, so
9098 + * requiring one would report every correctly-configured nginx site as
9099 + * broken.
9100 + *
9101 + * Carries `rules` through as well. The dashboard bootstrap builds its own
9102 + * payload and so had it; every other caller — POST /cache/recheck-rewrite,
9103 + * `wp xspeed cache recheck-rewrite`, the recheck_rewrite_rules MCP tool —
9104 + * came through here and got four keys, so the one surface a user reaches
9105 + * AFTER pasting the block could not tell them whether the block took. An
9106 + * agent driving the same fix could not read it at all.
9107 + *
9108 + * @param array $probe Raw result from probe_static_rewrite().
9109 + * @return array{active:bool,inconclusive:bool,reason:string,block_reason:string,rules:array}
9110 + */
9111 + public static function qualify_rewrite_probe( array $probe ): array {
9112 + $active = (bool) ( $probe['active'] ?? false );
9113 + $inconclusive = (bool) ( $probe['inconclusive'] ?? false );
9114 + $reason = (string) ( $probe['reason'] ?? '' );
9115 + $block_reason = self::static_rewrite_block_reason();
9116 + $rules = self::rules_state( $probe );
9117 +
9118 + // Same observed-refusal check Health makes. This is the shared path for
9119 + // `wp xspeed cache recheck-rewrite` and POST /cache/recheck-rewrite —
9120 + // and, because a CLI command is automatically an MCP tool, for the
9121 + // AI-facing surface too. Leaving it out would have fixed the dashboard
9122 + // while the CLI kept answering that the fast path was active. (#372)
9123 + if ( '' === $block_reason ) {
9124 + $skip = self::last_static_skip();
9125 + if ( ! empty( $skip['reason'] ) ) {
9126 + $block_reason = 'skipped_' . (string) $skip['reason'];
9127 + }
9128 + }
9129 +
9130 + // With page caching off there is nothing to serve, so `active` can
9131 + // never be true here whatever the raw probe says. probe_static_rewrite()
9132 + // writes its OWN file under the static tree and fetches that, which
9133 + // succeeds whenever the server can serve a static file at all — and on
9134 + // nginx the snippet is server-level, so it keeps succeeding after the
9135 + // cache is switched off.
9136 + //
9137 + // block_reason() used to carry this meaning by accident: it returned
9138 + // 'mobile_separate' with caching off, and the refusal branch below
9139 + // forced active=false. Now that it correctly reports '' (nothing can
9140 + // block a fast path that isn't in use), this consumer has to state the
9141 + // condition itself — otherwise `wp xspeed cache recheck-rewrite` and
9142 + // POST /cache/recheck-rewrite claim "the web server is serving cache
9143 + // hits directly" on a site with no cache. That is a positive false
9144 + // claim rather than a nag, i.e. worse than the bug being fixed.
9145 + $cache_opts = Settings::get();
9146 + if ( empty( $cache_opts['cache_enabled'] ) ) {
9147 + return array(
9148 + 'active' => false,
9149 + 'inconclusive' => false,
9150 + 'reason' => 'Page caching is off, so there is no cache for the web server to serve.',
9151 + 'block_reason' => '',
9152 + 'rules' => $rules,
9153 + );
9154 + }
9155 +
9156 + // A known refusal outranks the probe, and also outranks
9157 + // "inconclusive" — a blocked rewrite whose probe merely failed to
9158 + // complete is still definitely blocked.
9159 + if ( '' !== $block_reason ) {
9160 + $active = false;
9161 + $inconclusive = false;
9162 + $reason = self::block_reason_text( $block_reason );
9163 + }
9164 +
9165 + return array(
9166 + 'active' => $active,
9167 + 'inconclusive' => $inconclusive,
9168 + 'reason' => $reason,
9169 + 'block_reason' => $block_reason,
9170 + 'rules' => $rules,
9171 + );
9172 + }
9173 +
9174 + /**
9175 + * Human-readable explanation for a static_rewrite_block_reason() code.
9176 + *
9177 + * Each one has to say what to DO about it: "mobile_separate" alone tells
9178 + * a user nothing, and the whole point of surfacing a refusal instead of
9179 + * the probe verdict is that it is actionable.
9180 + */
9181 + public static function block_reason_text( string $code ): string {
9182 + switch ( $code ) {
9183 + case 'mobile_separate':
9184 + 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.';
9185 + case 'no_mod_headers':
9186 + 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.";
9187 + case 'litespeed_dropin':
9188 + 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.';
9189 + case 'skipped_nonce':
9190 + 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.';
9191 + default:
9192 + return sprintf( 'The static rewrite is disabled (%s).', $code );
9193 + }
9194 + }
9195 +
9196 + public static function static_rewrite_block_reason(): string {
9197 + // Nothing can be blocking the fast path when there is no cache to
9198 + // serve from it. Without this the dashboard told users with page
9199 + // caching switched OFF that Separate Mobile Cache "is disabling
9200 + // faster static serving" — a fast path they were not using, about a
9201 + // cache that did not exist. Every caller of this is a user-facing
9202 + // explanation of why the rewrite is off, so "the cache is off" is
9203 + // the honest answer, and it is silence. (#108)
9204 + $opts = Settings::get();
9205 + if ( empty( $opts['cache_enabled'] ) ) {
9206 + return '';
9207 + }
1315 9208 if ( Server::LITESPEED === Server::type() ) {
9209 + // The opt-in is read RAW (stored_cache_opts), not through
9210 + // Settings_Manager::get(): the schema's bool coercion is a PHP
9211 + // cast, and (bool) "false" is true — so a junk string stored on
9212 + // another server (where the key bypasses the schema) would come
9213 + // back from the coercion layer as an ENABLE. Raw + the strict
9214 + // parse below is the same read static_rewrite_allowed() makes,
9215 + // so the two can't disagree either. (QA on #513)
9216 + $stored = self::stored_cache_opts();
9217 + // The intended default — but no longer silent: with the opt-in
9218 + // off, Health must be able to explain the PHP path and point at
9219 + // the toggle instead of falling through to "reinstall the block"
9220 + // advice that cannot work here. (#509)
9221 + if ( ! self::litespeed_optin_enabled( $stored['litespeed_static_rewrite'] ?? false ) ) {
9222 + return 'litespeed_dropin';
9223 + }
9224 + $cache_opts = Settings_Manager::get( 'cache' );
9225 + return ! empty( $cache_opts['mobile_separate'] ) ? 'mobile_separate' : '';
9226 + }
9227 + if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) {
9228 + return 'no_mod_headers';
9229 + }
9230 + $cache_opts = Settings_Manager::get( 'cache' );
9231 + return ! empty( $cache_opts['mobile_separate'] ) ? 'mobile_separate' : '';
9232 + }
9233 +
9234 + /**
9235 + * Whether migration flagged Separate Mobile Cache for user review. Set by
9236 + * Migration::map_mobile_separate() when a source plugin (WP Rocket / WP
9237 + * Super Cache / LiteSpeed) had its "separate mobile cache" option on: we
9238 + * import it as OFF (to keep the device-blind static fast path) but record
9239 + * this flag so the dashboard can invite the user to turn it back on only
9240 + * if their site genuinely serves different HTML per device. (FBS-83145)
9241 + */
9242 + public static function mobile_separate_needs_review(): bool {
9243 + // Same reasoning as static_rewrite_block_reason(): the invitation is
9244 + // "turn this back on if your site needs it, to regain the fast path",
9245 + // which is meaningless with page caching off — there is no fast path
9246 + // to regain, and the equality probe behind the prompt would fetch
9247 + // pages that aren't being cached. Gated here rather than at the two
9248 + // payload call sites (Admin + Rest_Api) so `enabled`, `blocking` and
9249 + // `needs_review` are consistently gated on the same condition. (#108)
9250 + $opts = Settings::get();
9251 + if ( empty( $opts['cache_enabled'] ) ) {
1316 9252 return false;
1317 9253 }
1318 - $opts = Settings_Manager::get( 'cache' );
1319 - return empty( $opts['mobile_separate'] );
9254 + $cache_opts = Settings_Manager::get( 'cache' );
9255 + return ! empty( $cache_opts['mobile_separate_review'] );
1320 9256 }
1321 9257
9258 + /**
9259 + * Clear the review flag — called when the user has acted on the prompt
9260 + * (dismissed it, or turned Separate Mobile Cache on/off deliberately) so
9261 + * the dashboard callout doesn't nag forever. Writes the option directly
9262 + * (bypassing Settings_Manager) so it never touches schema fields.
9263 + */
9264 + public static function clear_mobile_separate_review(): void {
9265 + $stored = get_option( 'xspeed_module_cache', array() );
9266 + if ( ! is_array( $stored ) || empty( $stored['mobile_separate_review'] ) ) {
9267 + return;
9268 + }
9269 + unset( $stored['mobile_separate_review'] );
9270 + update_option( 'xspeed_module_cache', $stored );
9271 + }
9272 +
9273 + /**
9274 + * On-demand probe: does the homepage serve materially the same HTML to a
9275 + * desktop and a mobile browser? Fetches home_url() twice over loopback —
9276 + * once with a desktop User-Agent, once with a mobile one — strips
9277 + * per-request noise (nonces, CSRF tokens, session ids, inline timestamps),
9278 + * and compares. When identical, Separate Mobile Cache is almost certainly
9279 + * unnecessary and the user can turn it off to regain the static fast path.
9280 + *
9281 + * NEVER run automatically (no page-load cost) — only from the dashboard
9282 + * "Check now" button. Result is cached for 10 minutes so a double-click or
9283 + * a re-render doesn't fire two more self-requests. (FBS-83145)
9284 + *
9285 + * @return array{ identical:bool, checked:bool, reason?:string, desktop_bytes?:int, mobile_bytes?:int }
9286 + */
9287 + public static function probe_mobile_equality(): array {
9288 + $cached = get_transient( 'xspeed_mobile_equality_probe' );
9289 + if ( is_array( $cached ) ) {
9290 + return $cached;
9291 + }
9292 +
9293 + $home = home_url( '/' );
9294 + $host = (string) wp_parse_url( $home, PHP_URL_HOST );
9295 + if ( '' === $host ) {
9296 + $result = array( 'identical' => false, 'checked' => false, 'reason' => 'home_url has no host' );
9297 + set_transient( 'xspeed_mobile_equality_probe', $result, MINUTE_IN_SECONDS );
9298 + return $result;
9299 + }
9300 +
9301 + // Match WP core's own mobile detection (wp_is_mobile) so the probe
9302 + // reflects what the site would actually branch on. iPhone Safari for
9303 + // mobile; a current desktop Chrome UA for desktop.
9304 + $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';
9305 + $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';
9306 +
9307 + $is_local = function_exists( 'wp_get_environment_type' )
9308 + && in_array( wp_get_environment_type(), array( 'local', 'development' ), true );
9309 +
9310 + $fetch = static function ( string $ua ) use ( $home, $is_local ) {
9311 + $resp = wp_remote_get(
9312 + $home,
9313 + array(
9314 + 'timeout' => 5,
9315 + 'sslverify' => ! $is_local,
9316 + 'redirection' => 2,
9317 + // Bust any per-device cache so we compare freshly-rendered
9318 + // HTML, and pass the device UA the site would branch on.
9319 + 'user-agent' => $ua,
9320 + // A real device UA by design, so only the header marks
9321 + // this as ours to analytics and the hit ratio.
9322 + 'headers' => Self_Traffic::headers( array( 'Cache-Control' => 'no-cache' ) ),
9323 + )
9324 + );
9325 + if ( is_wp_error( $resp ) || 200 !== (int) wp_remote_retrieve_response_code( $resp ) ) {
9326 + return null;
9327 + }
9328 + return (string) wp_remote_retrieve_body( $resp );
9329 + };
9330 +
9331 + $desktop = $fetch( $desktop_ua );
9332 + $mobile = $fetch( $mobile_ua );
9333 +
9334 + if ( null === $desktop || null === $mobile ) {
9335 + $result = array( 'identical' => false, 'checked' => false, 'reason' => 'could not fetch homepage twice' );
9336 + set_transient( 'xspeed_mobile_equality_probe', $result, MINUTE_IN_SECONDS );
9337 + return $result;
9338 + }
9339 +
9340 + $identical = self::normalize_html_for_diff( $desktop ) === self::normalize_html_for_diff( $mobile );
9341 +
9342 + $result = array(
9343 + 'identical' => $identical,
9344 + 'checked' => true,
9345 + 'desktop_bytes' => strlen( $desktop ),
9346 + 'mobile_bytes' => strlen( $mobile ),
9347 + );
9348 + set_transient( 'xspeed_mobile_equality_probe', $result, 10 * MINUTE_IN_SECONDS );
9349 + return $result;
9350 + }
9351 +
9352 + /**
9353 + * Strip per-request noise from HTML so a desktop-vs-mobile diff reflects
9354 + * real structural differences, not nonces / session ids / timestamps that
9355 + * change on every render. Deliberately conservative: it normalizes the
9356 + * handful of well-known noise sources and collapses whitespace, so a site
9357 + * that truly serves different markup per device still compares as different.
9358 + */
9359 + private static function normalize_html_for_diff( string $html ): string {
9360 + // Every rule here errs toward "they differ" being WRONG rather than
9361 + // "they match" being wrong: this check only ever tells a user it is
9362 + // SAFE to turn Separate Mobile Cache off, so a false "identical"
9363 + // would cost them device-specific output. The risk of being too
9364 + // conservative is milder but real — the useful answer never appears,
9365 + // and the feature's whole pitch ("we'll prove it's safe to turn
9366 + // off") silently never pays out. These close the gaps that made a
9367 + // mismatch effectively guaranteed on an ordinary WordPress site. (#108)
9368 + $patterns = array(
9369 + // WP nonces in attribute or JSON form: data-nonce="…",
9370 + // _wpnonce=…, "nonce":"…". The `[:=]` adjacency below misses
9371 + // wp_nonce_field()'s own markup — `name="_wpnonce" value="ab…"`
9372 + // puts `value=` between the key and the token — which is the
9373 + // single most common nonce shape in WordPress, so that form is
9374 + // matched explicitly first.
9375 + '/name=["\']?(_wpnonce|_ajax_nonce)["\']?\s+value=["\']?[a-z0-9]{8,}/i',
9376 + // CSP nonces on script/style tags. Base64, so uppercase and
9377 + // +/= appear — the hex-only rules below can never match one,
9378 + // and a CSP-enabled site therefore differed on every fetch.
9379 + // MUST precede the generic nonce rule: that one stops at the
9380 + // first non-alphanumeric, leaving the rest of the token behind
9381 + // and the two responses still unequal.
9382 + // The quotes are optional so HTML5's legal unquoted attribute
9383 + // form (`<script nonce=AbCd+q/r=>`) is covered too — without
9384 + // that it fell through to the generic rule, which is the exact
9385 + // failure this rule exists to remove.
9386 + '/\bnonce=(["\'])?[A-Za-z0-9+\/=_-]{8,}(?(1)\1)/',
9387 + '/(_wpnonce|nonce|_ajax_nonce)["\']?\s*[:=]\s*["\']?[a-z0-9]{8,}/i',
9388 + // Generic hex tokens: cache busters, session ids, md5/sha
9389 + // digests. Was 16+, which left an 11-15 char gap above the
9390 + // 10-char nonce rule.
9391 + //
9392 + // The token MUST contain at least one a-f letter. `[a-f0-9]`
9393 + // also matches every decimal digit, so a bare `{10,}` erased
9394 + // every 10+ digit INTEGER anywhere in the document — including
9395 + // visible body text. A page whose desktop and mobile HTML
9396 + // differed only by a per-device numeric id (an AdSense slot, an
9397 + // A/B bucket, an analytics property) then compared as identical,
9398 + // and the check told the user it was safe to switch off the very
9399 + // setting keeping that output correct — the one direction this
9400 + // function must never fail in. Decimal-only runs are left to the
9401 + // bounded epoch rule below, which is deliberately narrower.
9402 + //
9403 + // Known, accepted (QA R2): a token whose letters all fall in a-f
9404 + // reads as a digest, so a per-device `ABC1234567890` strips even
9405 + // though it is an id, not a hash. Deliberately left open — the
9406 + // alternatives all cost more than the bug:
9407 + //
9408 + // Token shape (lowercase-only, case-uniformity, a trailing
9409 + // letter) cannot separate it. `ABC1234567890` and
9410 + // `ABCDEF012345` — an uppercase digest this rule SHOULD strip —
9411 + // are both all-hex, uniformly cased, letters-then-digits.
9412 + // Each variant fixed the id only by sparing the digest.
9413 + //
9414 + // Letter density does separate them (23% letters vs 50%), but
9415 + // measured over 2000 md5/sha1/sha256 samples, requiring letters
9416 + // spread through the token leaves 21-67% of REAL digests
9417 + // unmatched depending on the window. Digest noise is most of
9418 + // what this function exists to remove, so that trade guts it.
9419 + //
9420 + // Context (protecting data-* attribute values from this rule)
9421 + // works for ids and still strips digests in URLs, classes and
9422 + // query strings — but regresses a CHANGING digest inside a
9423 + // non-nonce data-* attribute, and needs a two-pass
9424 + // hold/restore. Viable if R2 is ever worth pressing; its
9425 + // failure at least errs toward "differ".
9426 + //
9427 + // An A-F-only prefix on a per-device id is rare, and the earlier
9428 + // nonce rules already claim the data-nonce/_wpnonce shapes.
9429 + '/\b(?=[a-f0-9]{10,}\b)[0-9]*[a-f][a-f0-9]*\b/i',
9430 + // wp-generated unique ids (e.g. wp-block ids, aria ids).
9431 + '/(id|for|aria-[a-z]+)="[^"]*-[0-9]{3,}"/i',
9432 + // ISO-ish timestamps + epoch-looking numbers in query strings.
9433 + '/\?ver=[0-9.]+/',
9434 + '/[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.+Z-]+/',
9435 + // Our own signature's generation stamp. The two fetches are
9436 + // sequential and each writes its own entry, so this differs on
9437 + // essentially every comparison — and it is space-separated, so
9438 + // the ISO rule above (which requires a literal `T`) never
9439 + // touches it. Without this the probe reports "differ" for every
9440 + // site and the "safe to turn Separate Mobile Cache off" verdict
9441 + // can never appear.
9442 + '/generated [0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2} UTC/',
9443 + // Raw epoch seconds. The two fetches are sequential, so any
9444 + // template printing time() guaranteed a mismatch.
9445 + //
9446 + // This is the ONLY rule that may strip a decimal-only run, so
9447 + // its bound is load-bearing rather than decorative — every digit
9448 + // it gives away is a class of per-device id it silently erases.
9449 + // `1[0-9]{9}` was too loose: it claimed the whole
9450 + // 1000000000-1999999999 range (2001-2033) to cover timestamps
9451 + // nobody serves, and took every 10-digit AdSense slot, order id
9452 + // and SKU beginning with 1 along with it — reproducing the exact
9453 + // false-"identical" verdict the hex rule above was tightened to
9454 + // stop. `1[6-9]` covers 2020-2033, which is the only span a live
9455 + // site can actually print, and collides with roughly a tenth as
9456 + // many ids.
9457 + //
9458 + // Not airtight — an id beginning 16-19 still collides. Closing
9459 + // that properly means scoping this to places a timestamp really
9460 + // appears (an attribute value, a query parameter, a JSON value)
9461 + // rather than bare body text; the bound is the cheap 90% of it.
9462 + '/\b1[6-9][0-9]{8}\b/',
9463 + );
9464 + $html = (string) preg_replace( $patterns, 'X', $html );
9465 + // Collapse all whitespace so trivial formatting differences don't count.
9466 + return trim( (string) preg_replace( '/\s+/', ' ', $html ) );
9467 + }
9468 +
1322 9469 public static function ensure_hits_log_file(): bool {
9470 + // TWO writers append to this log, and an earlier fix conflated them:
9471 + //
9472 + // 1. nginx, via the server-level `access_log` directive in
9473 + // nginx_snippet() — a DIFFERENT uid, which is why the file needs
9474 + // to be world-writable there.
9475 + // 2. the PHP drop-in (advanced-cache.php), on EVERY server. A hit it
9476 + // serves bypasses WordPress entirely, so it can't call
9477 + // Hit_Counter::record_hit() — appending here is the only way that
9478 + // hit is ever counted.
9479 + //
9480 + // The nginx-only early return that used to sit at the top of this
9481 + // method was fixing something real: chmod() on a file PHP doesn't own
9482 + // raises "Operation not permitted", and off nginx that chmod buys
9483 + // nothing. But it took directory creation with it, so on LiteSpeed
9484 + // (which always serves via the drop-in), on Apache without mod_headers,
9485 + // and anywhere mobile_separate forces the drop-in path, writer 2 was
9486 + // appending to a file whose parent directory did not exist. The append
9487 + // is @-suppressed and documented as non-fatal, so every one of those
9488 + // hits vanished and the dashboard ratio sat at 0% forever.
9489 + //
9490 + // So: create the dir + file everywhere, and keep only the chmod gated
9491 + // to nginx.
1323 9492 $dir = self::hits_log_dir();
1324 9493 if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
1325 9494 return false;
1326 9495 }
1327 - // Ensure the dir is traversable + writable by a different-uid nginx.
1328 - // 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.
1329 - @chmod( $dir, 0777 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort; the access_log just stays empty if it fails.
9496 +
9497 + $is_nginx = ( Server::NGINX === Server::type() );
9498 +
9499 + if ( $is_nginx ) {
9500 + // Ensure the dir is traversable + writable by a different-uid nginx.
9501 + // 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.
9502 + @chmod( $dir, 0777 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort; the access_log just stays empty if it fails.
9503 + }
9504 +
1330 9505 $path = self::hits_log_path();
1331 9506 if ( ! file_exists( $path ) ) {
1332 9507 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch -- See docblock: must be a plain touch, not WP_Filesystem.
1333 9508 @touch( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- non-fatal helper; failures already covered by the dir check.
1334 9509 }
1335 - // World-writable so a different-uid nginx can append HIT lines.
1336 - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- See docblock.
1337 - @chmod( $path, 0666 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort.
9510 +
9511 + if ( $is_nginx ) {
9512 + // World-writable so a different-uid nginx can append HIT lines.
9513 + // Off nginx the drop-in appends as the same uid that owns the file,
9514 + // so this is unnecessary — and would emit the "Operation not
9515 + // permitted" warnings the old early return was added to silence.
9516 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- See docblock.
9517 + @chmod( $path, 0666 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort.
9518 + }
9519 +
1338 9520 return file_exists( $path );
1339 9521 }
1340 9522
1341 9523 public static function nginx_snippet(): ?string {
@@ -1399,9 +9581,55 @@
1399 9581 $lines[] = 'if ($http_host ~ "^([^:]+):(\\d+)$") { set $xspeed_host $1$2; }'; // host:port → hostport (matches PHP static_host())
1400 9582 $lines[] = 'set $xspeed_no_cache "no-cache";';
1401 9583 $lines[] = 'if ($request_method != GET) { set $xspeed_no_cache "$xspeed_no_cache-method"; }';
1402 9584 $lines[] = 'if ($args) { set $xspeed_no_cache "$xspeed_no_cache-args"; }';
1403 - $lines[] = 'if ($http_cookie ~* "(wordpress_logged_in|comment_author|wp-postpass_)") { set $xspeed_no_cache "$xspeed_no_cache-cookie"; }';
9585 + // Cookie + user-agent exclusions, generated from the user's actual
9586 + // settings rather than a hardcoded list. Before this, the rule
9587 + // tested three fixed cookie names and no user agent at all, so
9588 + // every excluded_cookies / bypass_user_agents entry applied only
9589 + // while a page was cold — on a warm page nginx served the shared
9590 + // anonymous copy to carts, members and bypassed bots alike. The
9591 + // three historical names survive as a floor inside cookie_rule().
9592 + // `~*` is case-insensitive, matching PHP's stripos()/glob checks.
9593 + // Stored read — reached from boot(); see stored_cache_opts(). The
9594 + // fallbacks below mirror the schema's own defaults, which a raw read
9595 + // does not apply.
9596 + $cache_opts = self::stored_cache_opts();
9597 + $cookie_rule = Server_Rules::cookie_rule(
9598 + is_array( $cache_opts['excluded_cookies'] ?? null )
9599 + ? $cache_opts['excluded_cookies']
9600 + : \XSpeed\Modules\Cache\CacheModule::DEFAULT_EXCLUDED_COOKIES
9601 + );
9602 + $lines[] = 'if ($http_cookie ~* "(' . $cookie_rule['regex'] . ')") { set $xspeed_no_cache "$xspeed_no_cache-cookie"; }';
9603 +
9604 + $ua_rule = Server_Rules::user_agent_rule(
9605 + is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array()
9606 + );
9607 + // Emitted only when the list is non-empty — an empty alternation
9608 + // would compile to `(...)` matching every request and disable the
9609 + // fast path entirely.
9610 + if ( '' !== $ua_rule['regex'] ) {
9611 + $lines[] = 'if ($http_user_agent ~* "(' . $ua_rule['regex'] . ')") { set $xspeed_no_cache "$xspeed_no_cache-ua"; }';
9612 + }
9613 +
9614 + // URL exclusions. Without this an excluded URL was only excluded
9615 + // while its page was cold: PHP won't write a static file for one, so
9616 + // there is usually nothing to serve — but a page cached BEFORE the
9617 + // rule was added still has its file on disk, and nginx serves it
9618 + // without ever asking PHP. The exclusion then does nothing until the
9619 + // next purge. (#169)
9620 + //
9621 + // Matched against $uri, not $request_uri: $uri is the decoded path
9622 + // without the query string, which is what Cache::should_cache()
9623 + // tests. Using $request_uri would make `/cart` fail to match
9624 + // `/cart?x=1` inconsistently with PHP. Same empty-regex guard as the
9625 + // UA rule above — an empty alternation matches everything.
9626 + $url_rule = Server_Rules::url_rule(
9627 + is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array()
9628 + );
9629 + if ( '' !== $url_rule['regex'] ) {
9630 + $lines[] = 'if ($uri ~* "(' . $url_rule['regex'] . ')") { set $xspeed_no_cache "$xspeed_no_cache-url"; }';
9631 + }
1404 9632 $lines[] = 'if (!-f "$document_root' . $rel . '/$xspeed_host$uri/index.html") { set $xspeed_no_cache "$xspeed_no_cache-nofile"; }';
1405 9633 // Neither `add_header` nor `access_log` is allowed inside an `if{}`
1406 9634 // at server level (nginx rejects with "directive is not allowed
1407 9635 // here"). The logging therefore lives in a `location` block that
@@ -1431,10 +9659,41 @@
1431 9659 // missing. So: hits are logged, and a user deleting the log can't take
1432 9660 // nginx down.
1433 9661 $lines[] = ' access_log ' . $hits_abs . ' combined buffer=16k flush=5s;';
1434 9662 $lines[] = ' add_header X-XSpeed-Cache "HIT (nginx)" always;';
9663 + // Edge/CDN headers from the same seam the drop-in bakes. nginx serves
9664 + // this path without ever starting PHP, so the answer cannot be
9665 + // resolved per request — the pairs are resolved HERE, when the
9666 + // snippet is generated, and a change of answer needs the snippet
9667 + // regenerated and re-pasted to take effect.
9668 + //
9669 + // Skipped entirely when the static path is switched off. The only
9670 + // reason that can fire under `bake` is mobile-split, and mobile-split
9671 + // is also what switches the static path off — so the block would be
9672 + // baked with a hold it can never serve, and would start serving it
9673 + // the moment the setting is turned off and static files reappear,
9674 + // until somebody regenerates and re-pastes. A rule that can only be
9675 + // served once its premise is false is guaranteed to be stale.
9676 + foreach ( self::static_rewrite_allowed() ? self::edge_headers_for( 'HIT', 'rules' ) : array() as $name => $value ) {
9677 + $lines[] = ' add_header ' . $name . ' "' . self::quote_directive_value( $value ) . '" always;';
9678 + }
1435 9679 $lines[] = '}';
1436 - return implode( "\n", $lines );
9680 +
9681 + // Stamp the block with a hash of itself. This is the only way to find
9682 + // out what a user actually pasted: the block lives in a server config
9683 + // WordPress cannot read, so until now the dashboard could not tell an
9684 + // up-to-date paste from one made three settings changes ago, and
9685 + // covered for that by telling everyone to re-paste after every save.
9686 + // A hit through this location now carries the version that served it
9687 + // and probe_static_rewrite() reads it back. See rules_state().
9688 + return implode(
9689 + "\n",
9690 + self::with_rules_marker(
9691 + $lines,
9692 + array( ' add_header ' . self::RULES_HEADER . ' "%s" always;' ),
9693 + count( $lines ) - 1
9694 + )
9695 + );
1437 9696 }
1438 9697
1439 9698 /**
1440 9699 * Aggregate every enabled module's nginx_directives() into one
@@ -1515,8 +9774,62 @@
1515 9774 header( 'X-LiteSpeed-Cache-Control: no-cache' );
1516 9775 }
1517 9776
1518 9777 /**
9778 + * Restore the drop-in + WP_CACHE constant for a site that had caching
9779 + * ON before this activation — and ONLY for such a site.
9780 + *
9781 + * WordPress runs an upgrade as deactivate → wipe plugin files →
9782 + * install → activate. The wipe takes advanced-cache.php with it, so
9783 + * without this the site serves 100% uncached from the moment the
9784 + * update finishes until the next authenticated wp-admin page load
9785 + * (auto_heal() is on admin_init). On a site whose admin logs in
9786 + * rarely that window is hours or days of silent cache loss, while
9787 + * the dashboard still reports cache_enabled = true. (FBS field
9788 + * report against 1.1.2 / Pro 1.0.5.)
9789 + *
9790 + * The `cache_enabled` guard is the whole contract: a FRESH install
9791 + * has the option unset, so activation writes nothing and the user
9792 + * still opts in explicitly through Cache::toggle() via the
9793 + * /cache/toggle REST endpoint. We only ever put back state the user
9794 + * already chose — repair, never a new install path. This is what
9795 + * keeps us on the right side of the "don't create drop-ins the user
9796 + * didn't ask for" guideline while matching what WP Rocket, W3 Total
9797 + * Cache and WP Super Cache all do on activation.
9798 + *
9799 + * @return bool True when a restore was performed.
9800 + */
9801 + public static function restore_dropin_if_enabled(): bool {
9802 + if ( defined( 'WP_INSTALLING' ) && WP_INSTALLING ) {
9803 + return false;
9804 + }
9805 +
9806 + // The user's saved choice. Absent/false on a fresh install => no
9807 + // drop-in is written and nothing touches wp-config.php.
9808 + $opts = get_option( 'xspeed_options', array() );
9809 + if ( empty( $opts['cache_enabled'] ) ) {
9810 + return false;
9811 + }
9812 +
9813 + $state = self::toggle( true, false );
9814 + // A refusal reports whether the cache SERVES, which on this path can
9815 + // be true for reasons that have nothing to do with this call — so a
9816 + // refusal would otherwise log "drop-in restored" for a restore that
9817 + // was declined. Restored means the transaction went through.
9818 + $restored = empty( $state['blocked'] ) && ! empty( $state['enabled'] );
9819 +
9820 + if ( $restored ) {
9821 + Activity_Log::record(
9822 + 'cache_dropin_restored',
9823 + 'Cache drop-in restored after a plugin update — caching was already enabled.',
9824 + Activity_Log::SUCCESS
9825 + );
9826 + }
9827 +
9828 + return $restored;
9829 + }
9830 +
9831 + /**
1519 9832 * Reconcile drop-in + WP_CACHE + rewrite block with the user's
1520 9833 * saved choice. Runs on admin_init. Cheap when nothing's wrong
1521 9834 * (one option read + a handful of file_exists / defined checks);
1522 9835 * writes only when state has drifted (typical cause: plugin
@@ -1538,32 +9851,16 @@
1538 9851 if ( empty( $opts['cache_enabled'] ) ) {
1539 9852 return;
1540 9853 }
1541 9854
1542 - $dropin_target = WP_CONTENT_DIR . '/advanced-cache.php';
1543 - $dropin_ours = false;
1544 - $dropin_stale = false;
1545 - if ( file_exists( $dropin_target ) ) {
1546 - $contents = @file_get_contents( $dropin_target ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
1547 - $dropin_ours = is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' );
1548 - // Reinstall when OUR drop-in is an older version than the source —
1549 - // the marker alone can't distinguish an old copy from a new one, so
1550 - // a serve-logic change (e.g. the .meta read for 404s/feeds) would
1551 - // otherwise never reach existing cache-enabled sites until a manual
1552 - // cache toggle. (FBS-82406/82407)
1553 - if ( $dropin_ours ) {
1554 - $dropin_stale = self::dropin_version( (string) $contents ) < self::dropin_version( @file_get_contents( XSPEED_DIR . 'includes/advanced-cache.php' ) ?: '' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
1555 - }
9855 + $state = self::toggle( true, false );
9856 + // A refusal means something else now owns the page-cache field, or
9857 + // the write could not be verified. Either way this is not the moment
9858 + // to go on maintaining our rewrite block and log file.
9859 + if ( ! empty( $state['blocked'] ) || empty( $state['enabled'] ) ) {
9860 + return;
1556 9861 }
1557 9862
1558 - if ( ! $dropin_ours || $dropin_stale ) {
1559 - self::install_dropin();
1560 - }
1561 -
1562 - if ( ! defined( 'WP_CACHE' ) || ! WP_CACHE ) {
1563 - self::set_wp_cache_constant( true );
1564 - }
1565 -
1566 9863 // Rewrite block goes last. It's what turns the static-cache
1567 9864 // tree into a PHP-bypass — every cache hit served by the web
1568 9865 // server directly. Without it we still cache, just at drop-in
1569 9866 // speed (~85ms TTFB) instead of static-file speed (~25-40ms).
@@ -1588,8 +9885,54 @@
1588 9885 self::ensure_hits_log_file();
1589 9886 }
1590 9887
1591 9888 /**
9889 + * Keep the generic bypass cookie in sync with PHP's caching verdict.
9890 + *
9891 + * The server config tests exactly one cookie name (Server_Rules::
9892 + * BYPASS_COOKIE) forever, and PHP decides what that name means. Adding
9893 + * a new excluded cookie therefore needs no config change and no nginx
9894 + * reload — the reason this exists.
9895 + *
9896 + * Session cookie (expiry 0) so it dies with the browser session, and
9897 + * deliberately NOT HttpOnly-sensitive: it carries no identity, only the
9898 + * boolean "don't serve this visitor a shared cached page".
9899 + *
9900 + * Honest limit: this can only ever help a visitor PHP has already seen
9901 + * once. A bot's first request to a warm page never reaches PHP, which
9902 + * is why user-agent rules are still written into the server config
9903 + * rather than relying on this.
9904 + *
9905 + * @param bool $bypass Whether this visitor must skip the cache.
9906 + */
9907 + private static function sync_bypass_cookie( bool $bypass ): void {
9908 + if ( headers_sent() ) {
9909 + return;
9910 + }
9911 +
9912 + $name = Server_Rules::BYPASS_COOKIE;
9913 + $has = isset( $_COOKIE[ $name ] );
9914 +
9915 + // Only touch the header when the state actually changes — a
9916 + // Set-Cookie on every request would make the response uncacheable
9917 + // for intermediary caches and add noise to every hit.
9918 + if ( $bypass === $has ) {
9919 + return;
9920 + }
9921 +
9922 + $path = defined( 'COOKIEPATH' ) && COOKIEPATH ? COOKIEPATH : '/';
9923 + $domain = defined( 'COOKIE_DOMAIN' ) ? COOKIE_DOMAIN : '';
9924 +
9925 + if ( $bypass ) {
9926 + setcookie( $name, '1', 0, $path, (string) $domain, is_ssl(), false );
9927 + $_COOKIE[ $name ] = '1';
9928 + } else {
9929 + setcookie( $name, '', time() - 3600, $path, (string) $domain, is_ssl(), false );
9930 + unset( $_COOKIE[ $name ] );
9931 + }
9932 + }
9933 +
9934 + /**
1592 9935 * Build the .htaccess rules that map cacheable requests to the
1593 9936 * static-cache tree. Conditions are deliberately strict: GET only,
1594 9937 * empty query string, no session/comment-author/post-password
1595 9938 * cookie, and the static file must exist on disk. Anything that
@@ -1606,14 +9949,46 @@
1606 9949 $rel = str_replace( ABSPATH, '/', XSPEED_CACHE_STATIC_DIR );
1607 9950 $rel = '/' . ltrim( $rel, '/' );
1608 9951 $rel = rtrim( $rel, '/' );
1609 9952
1610 - return array(
9953 + // Cookie + user-agent exclusions generated from the live settings.
9954 + // See the matching block in nginx_snippet() — same generator, same
9955 + // floor, so both servers enforce an identical policy. Apache reads
9956 + // .htaccess on every request and we already self-heal this file, so
9957 + // Apache/LiteSpeed users get the fix on upgrade with no action.
9958 + $cache_opts = Settings_Manager::get( 'cache' );
9959 + $cookie_rule = Server_Rules::cookie_rule(
9960 + is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array()
9961 + );
9962 + $ua_rule = Server_Rules::user_agent_rule(
9963 + is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array()
9964 + );
9965 +
9966 + $lines = array(
1611 9967 '<IfModule mod_rewrite.c>',
1612 9968 ' RewriteEngine On',
1613 9969 ' RewriteCond %{REQUEST_METHOD} ^GET$',
1614 9970 ' RewriteCond %{QUERY_STRING} ^$',
1615 - ' RewriteCond %{HTTP_COOKIE} !(wordpress_logged_in|comment_author|wp-postpass_) [NC]',
9971 + ' RewriteCond %{HTTP_COOKIE} !(' . $cookie_rule['regex'] . ') [NC]',
9972 + );
9973 +
9974 + // Only emit the UA condition when there's something to match —
9975 + // `!()` would negate an always-true empty match and refuse every
9976 + // request, silently disabling the static path.
9977 + if ( '' !== $ua_rule['regex'] ) {
9978 + // Quoted, because RewriteCond is whitespace-delimited and real
9979 + // user-agent fragments contain spaces ("Mozilla/5.0 (compatible").
9980 + // Unquoted, a space adds an argument and Apache answers every
9981 + // request with a 500 — and because .htaccess is parsed per
9982 + // request, `httpd -t` still reports Syntax OK. Server_Rules has
9983 + // already excluded quotes and backslashes from the alternation,
9984 + // so the closing quote here cannot be escaped away.
9985 + $lines[] = ' RewriteCond %{HTTP_USER_AGENT} "!(' . $ua_rule['regex'] . ')" [NC]';
9986 + }
9987 +
9988 + $block = array_merge(
9989 + $lines,
9990 + array(
1616 9991 // Capture REQUEST_URI without its trailing slash into %1.
1617 9992 // store_static() writes `{host}{uri-without-trailing-slash}/index.html`,
1618 9993 // so this normalization lets `/blog/` and `/blog` both hit
1619 9994 // the same cache file without producing the double-slash
@@ -1629,11 +10004,83 @@
1629 10004 // `^` matches the empty string AND any non-empty path, so it
1630 10005 // covers `/` and `/blog` alike. (Confirmed on OpenLiteSpeed
1631 10006 // 1.8: `.` → homepage served by PHP drop-in; `^` → served
1632 10007 // directly from the static file.)
1633 - ' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [L]',
10008 + // `E=` tags the request the rewrite just served from the cache
10009 + // tree. The edge headers below key off it instead of the file
10010 + // name: <FilesMatch "\.html$"> in a document-root .htaccess
10011 + // matches EVERY .html on the site — a hand-uploaded /promo.html,
10012 + // a static export — and telling a CDN to hold those for the page
10013 + // TTL would pin files xSpeed never wrote and cannot purge.
10014 + ' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [E=XSPEED_STATIC_HIT:1,L]',
1634 10015 '</IfModule>',
10016 + // Mark the statically-served response as a cache HIT.
10017 + //
10018 + // A file served by the rewrite above bypasses PHP entirely, so
10019 + // this directive is the ONLY thing that can identify it as
10020 + // cached — both for the user reading response headers and for
10021 + // Hit_Counter, which reconciles static hits from the access
10022 + // log. Without it the cache works perfectly and reports a 0%
10023 + // hit ratio, which reads as "the plugin is broken". (Field
10024 + // report against 1.1.2: homepage served byte-identical from
10025 + // the static tree, no X-XSpeed-Cache header on any response.)
10026 + //
10027 + // `always` so the header is set on the 200 from the rewritten
10028 + // file, not only on the successful-response table. The
10029 + // <IfModule> guard keeps a server without mod_headers from
10030 + // 500ing on an unknown directive — on such a host the header
10031 + // is silently dropped, which is exactly why
10032 + // static_rewrite_allowed() refuses the static path there and
10033 + // routes hits through the drop-in instead.
10034 + '<IfModule mod_headers.c>',
10035 + ' <FilesMatch "\\.html$">',
10036 + ' Header always set X-XSpeed-Cache "HIT (static)"',
10037 + ' </FilesMatch>',
10038 + )
1635 10039 );
10040 +
10041 + // Edge/CDN headers from the same seam the drop-in bakes. Like the
10042 + // nginx snippet, the static rewrite answers without PHP, so the pairs
10043 + // are resolved when the block is GENERATED rather than per request.
10044 + //
10045 + // `env=` rather than the `<FilesMatch>` scoping above, because these
10046 + // must ride only on responses the rewrite produced. The X-XSpeed-Cache
10047 + // marker stays filename-scoped: it is inert, and narrowing it would
10048 + // change a header QA reads.
10049 + //
10050 + // Same reasoning as the nginx snippet: a bake hold can only come from
10051 + // mobile-split, and mobile-split is what turns this path off, so a
10052 + // hold baked here could only ever be served once its own premise had
10053 + // stopped being true.
10054 + $edge_lines = array();
10055 + foreach ( self::static_rewrite_allowed() ? self::edge_headers_for( 'HIT', 'rules' ) : array() as $edge_name => $edge_value ) {
10056 + $edge_lines = array_merge(
10057 + $edge_lines,
10058 + self::static_hit_directives(
10059 + ' Header always set ' . $edge_name . ' "' . self::quote_directive_value( $edge_value ) . '"'
10060 + )
10061 + );
10062 + }
10063 +
10064 + $block = array_merge( $block, $edge_lines, array( '</IfModule>' ) );
10065 +
10066 + // Same self-describing marker as the nginx snippet. Apache's block is
10067 + // written by us rather than pasted by hand, so it should never be out
10068 + // of date — but "should" is what refresh_rewrite_if_installed() not
10069 + // running looks like from the outside, and the probe can now say so
10070 + // instead of assuming. `env=` keeps it on responses the rewrite
10071 + // produced, so a hand-uploaded .html never claims to be our cache.
10072 + //
10073 + // Spliced in after the edge headers so the hash covers them: a
10074 + // changed edge answer has to make an installed block report itself
10075 + // stale, and a marker computed before they were appended would not
10076 + // move. The splice point steps back over them and `</IfModule>` to
10077 + // land inside `<FilesMatch>`, beside the X-XSpeed-Cache marker.
10078 + return self::with_rules_marker(
10079 + $block,
10080 + self::static_hit_directives( ' Header always set ' . self::RULES_HEADER . ' "%s"' ),
10081 + count( $block ) - count( $edge_lines ) - 2
10082 + );
1636 10083 }
1637 10084
1638 10085 /**
1639 10086 * Active probe that confirms the web-server static-rewrite path is
@@ -1660,8 +10107,21 @@
1660 10107 * synchronously on every dashboard bootstrap, so a slow/timing-out
1661 10108 * loopback request added up to `timeout` seconds to admin page loads on
1662 10109 * hosts that block self-requests. (FBS-82142)
1663 10110 */
10111 + /**
10112 + * Discard the cached probe result and run a fresh one.
10113 + *
10114 + * Without this there was no way to re-check: the result sat in a transient
10115 + * for five minutes and nothing ever deleted it, so a user who fixed their
10116 + * nginx config kept seeing "nginx detected — configure for max cache speed"
10117 + * with no means of confirming the fix worked. (FBS-84012)
10118 + */
10119 + public static function recheck_static_rewrite(): array {
10120 + delete_transient( 'xspeed_rewrite_probe' );
10121 + return self::probe_static_rewrite( true );
10122 + }
10123 +
1664 10124 public static function probe_static_rewrite( bool $allow_probe = false ): array {
1665 10125 $cached = get_transient( 'xspeed_rewrite_probe' );
1666 10126 if ( is_array( $cached ) ) {
1667 10127 return $cached;
@@ -1675,9 +10135,12 @@
1675 10135
1676 10136 $home = home_url( '/' );
1677 10137 $host = (string) wp_parse_url( $home, PHP_URL_HOST );
1678 10138 if ( '' === $host ) {
1679 - $result = array( 'active' => false, 'reason' => 'home_url has no host' );
10139 + // Environmental failure, not evidence the server config is wrong —
10140 + // mark it inconclusive so Health surfaces say "could not verify"
10141 + // instead of demanding a snippet paste. (#480)
10142 + $result = array( 'active' => false, 'inconclusive' => true, 'reason' => 'home_url has no host' );
1680 10143 set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
1681 10144 return $result;
1682 10145 }
1683 10146
@@ -1694,9 +10157,12 @@
1694 10157 if ( ! file_exists( $probe_dir ) ) {
1695 10158 wp_mkdir_p( $probe_dir );
1696 10159 }
1697 10160 if ( ! is_dir( $probe_dir ) ) {
1698 - $result = array( 'active' => false, 'reason' => 'cannot create probe dir' );
10161 + // A cache-dir permissions problem — the probe never ran, so this
10162 + // says nothing about the nginx config. Inconclusive, not
10163 + // "required". (#480)
10164 + $result = array( 'active' => false, 'inconclusive' => true, 'reason' => 'cannot create probe dir' );
1699 10165 set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
1700 10166 return $result;
1701 10167 }
1702 10168 // 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.
@@ -1716,9 +10182,9 @@
1716 10182 // don't repeat the wait every minute.
1717 10183 'timeout' => 3,
1718 10184 'sslverify' => ! $is_local,
1719 10185 'redirection' => 0,
1720 - 'headers' => array( 'Cache-Control' => 'no-cache' ),
10186 + 'headers' => Self_Traffic::headers( array( 'Cache-Control' => 'no-cache' ) ),
1721 10187 )
1722 10188 );
1723 10189
1724 10190 // Best-effort cleanup so we don't accumulate probe dirs even
@@ -1733,8 +10199,14 @@
1733 10199
1734 10200 if ( is_wp_error( $resp ) ) {
1735 10201 $result = array(
1736 10202 'active' => false,
10203 + // The request never completed, so we learned NOTHING about the
10204 + // rewrite. Flagged inconclusive so the UI doesn't tell the user
10205 + // to configure a server that may already be configured — a
10206 + // blocked loopback, a self-signed cert, or a timeout is a probe
10207 + // failure, not a missing rewrite. (FBS-84012)
10208 + 'inconclusive' => true,
1737 10209 'reason' => 'http error: ' . $resp->get_error_message(),
1738 10210 );
1739 10211 // Cache the failure for the full 5 minutes (not 1) so a host that
1740 10212 // times out on the loopback probe isn't re-probed — and re-stalled
@@ -1748,8 +10220,17 @@
1748 10220 $ua_php = '' !== (string) wp_remote_retrieve_header( $resp, 'x-powered-by' );
1749 10221 $has_etag = '' !== (string) wp_remote_retrieve_header( $resp, 'etag' )
1750 10222 || '' !== (string) wp_remote_retrieve_header( $resp, 'last-modified' );
1751 10223 $match = trim( $body ) === $nonce;
10224 + // Which version of our generated rules answered, if any. Only the
10225 + // static path can set this — it is baked into the rules themselves —
10226 + // so its presence is direct evidence about what is installed, and its
10227 + // absence on a conclusive probe is evidence too. See rules_state().
10228 + // Validated to the shape we generate, so a proxy or another plugin
10229 + // sending something else under this name cannot be mistaken for a
10230 + // rules version and reported as "out of date".
10231 + $rules_raw = trim( (string) wp_remote_retrieve_header( $resp, strtolower( self::RULES_HEADER ) ) );
10232 + $rules = preg_match( '/^[0-9a-f]{8}\z/', $rules_raw ) ? $rules_raw : '';
1752 10233
1753 10234 // "Active" = the web server served our raw nonce bytes back
1754 10235 // AND emitted the static-serve markers (ETag / Last-Modified)
1755 10236 // AND didn't add an X-Powered-By: PHP header. All three are
@@ -1755,25 +10236,42 @@
1755 10236 // AND didn't add an X-Powered-By: PHP header. All three are
1756 10237 // individually noisy; together they're conclusive.
1757 10238 $active = $match && $has_etag && ! $ua_php && 200 === $code;
1758 10239
10240 + /*
10241 + * `inconclusive` separates "we proved the rewrite isn't serving" from
10242 + * "the probe couldn't tell". Only the former should drive a
10243 + * configure-your-server banner; the latter previously rendered the
10244 + * same alarming copy at a user who had already configured nginx
10245 + * correctly, and there was no way to clear it. (FBS-84012)
10246 + */
10247 + $inconclusive = false;
1759 10248 if ( $active ) {
1760 10249 $reason = 'static-served';
1761 10250 } elseif ( 200 === $code && $match && $ua_php ) {
1762 10251 $reason = 'php served the file instead of nginx/Apache (rewrite block missing)';
1763 10252 } elseif ( 200 === $code && ! $match ) {
1764 - $reason = 'unexpected body (CDN cached an older response?)';
10253 + // Something answered 200 with content that isn't our nonce — a CDN,
10254 + // a proxy, a security plugin. That tells us nothing about the
10255 + // origin's rewrite.
10256 + $reason = 'unexpected body (CDN cached an older response?)';
10257 + $inconclusive = true;
1765 10258 } elseif ( 404 === $code ) {
1766 10259 $reason = 'probe URL returned 404 (rewrite block missing or wrong path)';
1767 10260 } else {
1768 - $reason = sprintf( 'unexpected response (HTTP %d, body %d B, php=%s)', $code, strlen( $body ), $ua_php ? 'yes' : 'no' );
10261 + // Redirects, 403s from a WAF, 5xx — the probe never reached a
10262 + // verdict about the rewrite itself.
10263 + $reason = sprintf( 'unexpected response (HTTP %d, body %d B, php=%s)', $code, strlen( $body ), $ua_php ? 'yes' : 'no' );
10264 + $inconclusive = true;
1769 10265 }
1770 10266
1771 10267 $result = array(
1772 - 'active' => $active,
1773 - 'reason' => $reason,
1774 - 'code' => $code,
1775 - 'php' => $ua_php,
10268 + 'active' => $active,
10269 + 'inconclusive' => $inconclusive,
10270 + 'reason' => $reason,
10271 + 'code' => $code,
10272 + 'php' => $ua_php,
10273 + 'rules' => $rules,
1776 10274 );
1777 10275 set_transient( 'xspeed_rewrite_probe', $result, 5 * MINUTE_IN_SECONDS );
1778 10276 return $result;
1779 10277 }
@@ -1830,12 +10328,49 @@
1830 10328 $cleaned = self::strip_marker_block( $existing, 'xSpeed Static Cache' );
1831 10329 $block = self::marker_block( 'xSpeed Static Cache', self::rewrite_block_lines() );
1832 10330 $next = $block . ( '' === $cleaned ? '' : "\n" . $cleaned );
1833 10331
10332 + /*
10333 + * Nothing to change. auto_heal() runs the whole enable transaction on
10334 + * every admin_init and this is called unconditionally from it, so
10335 + * without this every wp-admin request truncated and rewrote .htaccess
10336 + * with byte-identical content. Apache reads that file without a lock,
10337 + * so the truncate window is a real 500 on a busy admin, and the churn
10338 + * trips host file-integrity monitors.
10339 + */
10340 + if ( $next === $existing ) {
10341 + return true;
10342 + }
10343 +
1834 10344 // 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.
1835 10345 return false !== file_put_contents( $htaccess, $next, LOCK_EX );
1836 10346 }
1837 10347
10348 + /**
10349 + * Rewrite the .htaccess block in place when — and only when — one is
10350 + * already installed.
10351 + *
10352 + * The block embeds the generated cookie / user-agent exclusion rules,
10353 + * so it goes stale the moment those settings change. install_rewrite()
10354 + * regenerates it from the live settings, but calling that unconditionally
10355 + * on every save would CREATE a block on sites that never enabled the
10356 + * static path — silently turning on server-level serving nobody asked
10357 + * for. So we refresh only what's already there.
10358 + *
10359 + * @return bool True when a block was present and rewritten.
10360 + */
10361 + public static function refresh_rewrite_if_installed(): bool {
10362 + $htaccess = ABSPATH . '.htaccess';
10363 + if ( ! file_exists( $htaccess ) ) {
10364 + return false;
10365 + }
10366 + $existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Best-effort read; an unreadable file simply means nothing to refresh.
10367 + if ( ! is_string( $existing ) || false === strpos( $existing, '# BEGIN xSpeed Static Cache' ) ) {
10368 + return false;
10369 + }
10370 + return self::install_rewrite();
10371 + }
10372 +
1838 10373 public static function remove_rewrite(): bool {
1839 10374 $htaccess = ABSPATH . '.htaccess';
1840 10375 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- See install_rewrite() rationale.
1841 10376 if ( ! file_exists( $htaccess ) || ! is_writable( $htaccess ) ) {
@@ -1856,9 +10391,21 @@
1856 10391 * follows it. Idempotent — returns the input unchanged if the
1857 10392 * marker isn't present.
1858 10393 */
1859 10394 private static function strip_marker_block( string $contents, string $marker ): string {
1860 - $pattern = '/# BEGIN ' . preg_quote( $marker, '/' ) . '\b.*?# END ' . preg_quote( $marker, '/' ) . "\b[^\n]*\n?\n?/s";
10395 + /*
10396 + * The body may not contain another BEGIN for this marker.
10397 + *
10398 + * `.*?` is non-greedy but still spans anything, so an ORPHANED
10399 + * `# BEGIN xSpeed Static Cache` — an END line lost to a hand edit or
10400 + * a partial write — paired with the END of the NEXT block and deleted
10401 + * everything between them. On a site where the orphan sits above
10402 + * `# BEGIN WordPress`, that takes WordPress's own rewrite rules with
10403 + * it and every permalink 404s. Refusing to cross a second BEGIN makes
10404 + * the orphan a no-op instead of a site-wide outage.
10405 + */
10406 + $begin = '# BEGIN ' . preg_quote( $marker, '/' ) . '\b';
10407 + $pattern = '/' . $begin . '(?:(?!' . $begin . ').)*?# END ' . preg_quote( $marker, '/' ) . "\b[^\n]*\n?\n?/s";
1861 10408 $out = preg_replace( $pattern, '', $contents );
1862 10409 return is_string( $out ) ? $out : $contents;
1863 10410 }
1864 10411
@@ -1882,8 +10429,417 @@
1882 10429 }
1883 10430 return 0;
1884 10431 }
1885 10432
10433 + /** The advanced-cache.php drop-in is ours. */
10434 + public const DROPIN_XSPEED = 'xspeed';
10435 + /** Someone else's drop-in is installed. */
10436 + public const DROPIN_FOREIGN = 'foreign';
10437 + /** No drop-in installed. */
10438 + public const DROPIN_NONE = 'none';
10439 + /** A drop-in is installed and we could not read it. */
10440 + public const DROPIN_UNREADABLE = 'unreadable';
10441 + /**
10442 + * Present but holding nothing -- empty, or whitespace only. WP Rocket
10443 + * truncates advanced-cache.php to 0 bytes on deactivate, and calling that
10444 + * FOREIGN made it a permanent blocker with no owner to ask. (#391)
10445 + */
10446 + public const DROPIN_ABANDONED = 'abandoned';
10447 +
10448 + /**
10449 + * Who owns wp-content/advanced-cache.php right now.
10450 + *
10451 + * WordPress gives every caching plugin the same single file to live in,
10452 + * so "is there a drop-in" and "is it ours" are completely different
10453 + * questions, and only the second one licenses a write. An unreadable
10454 + * drop-in is deliberately its own answer rather than folding into
10455 + * "foreign": we cannot even name what we would be destroying.
10456 + *
10457 + * @return string One of the DROPIN_* constants.
10458 + */
10459 + public static function dropin_owner(): string {
10460 + require_once XSPEED_DIR . 'includes/wp-cache-constant.php';
10461 + $target = WP_CONTENT_DIR . '/advanced-cache.php';
10462 + if ( ! file_exists( $target ) ) {
10463 + return self::DROPIN_NONE;
10464 + }
10465 +
10466 + $contents = self::read_file( $target );
10467 + if ( null === $contents ) {
10468 + return self::DROPIN_UNREADABLE;
10469 + }
10470 +
10471 + if ( xspeed_has_canonical_dropin_signature( $contents ) ) {
10472 + return self::DROPIN_XSPEED;
10473 + }
10474 +
10475 + // Nothing in the file means nothing owns it. Kept distinct from
10476 + // FOREIGN so the acquisition gate can tell "someone else's cache" from
10477 + // "a husk the last plugin left behind". (#391)
10478 + if ( '' === trim( $contents ) ) {
10479 + return self::DROPIN_ABANDONED;
10480 + }
10481 +
10482 + /*
10483 + * The other half of the same question, and it cannot be answered from
10484 + * the bytes: a file we cannot attribute is a COMPETITOR only while
10485 + * some page cache is actually running. With every candidate switched
10486 + * off it is abandoned -- a hosting company's own cache, a hand-rolled
10487 + * one, or a plugin that was deleted without cleaning up.
10488 + *
10489 + * Asking the detector rather than re-deriving it here is the point:
10490 + * these two answers disagreeing is a split brain with a bad ending --
10491 + * acquisition_blocker() opens the gate, install_dropin() then refuses
10492 + * on FOREIGN, and toggle() blames the filesystem for a write it never
10493 + * attempted. One question, one answer. (#391, #393)
10494 + */
10495 + if ( class_exists( __NAMESPACE__ . '\\Page_Cache_Detector' ) ) {
10496 + $owner = (string) ( Page_Cache_Detector::inspect()['dropin']['owner'] ?? '' );
10497 +
10498 + // Attributable to a named plugin -> somebody's cache, whatever its
10499 + // activation state. Only a file NOBODY can be shown to own, with
10500 + // nothing running, is abandoned.
10501 + if ( Page_Cache_Detector::OWNER_UNKNOWN === $owner
10502 + && ! Page_Cache_Detector::another_page_cache_is_active() ) {
10503 + return self::DROPIN_ABANDONED;
10504 + }
10505 + }
10506 +
10507 + return self::DROPIN_FOREIGN;
10508 + }
10509 +
10510 + /**
10511 + * Why xSpeed must not install its page-cache artifacts right now, or null
10512 + * when it may.
10513 + *
10514 + * This is the single gate in front of every write that touches shared
10515 + * state — the drop-in and the WP_CACHE define. Both are single-occupancy:
10516 + * whatever is there belongs to exactly one plugin, and taking it silently
10517 + * breaks that plugin's caching with no way back.
10518 + *
10519 + * Returns a user-facing string, so a REST caller can hand it straight to
10520 + * the dashboard instead of reporting a bare failure.
10521 + */
10522 + public static function acquisition_blocker(): ?string {
10523 + Page_Cache_Detector::invalidate();
10524 + $verdict = Page_Cache_Detector::classify();
10525 + $owner = self::dropin_owner();
10526 + // The reason we refuse, whether that reason already names a plugin,
10527 + // and every other page cache the detector counted anywhere in the
10528 + // verdict. See the tail of this method for why all three are needed.
10529 + $primary = null;
10530 + $primary_names = false;
10531 + $named = array();
10532 + foreach ( $verdict['blockers'] as $blocker ) {
10533 + $code = (string) ( $blocker['code'] ?? '' );
10534 + // The shared detector quite correctly reports xSpeed itself as a
10535 + // page-cache owner. That is not a competitor to this transaction.
10536 + //
10537 + // Except when the two disagree about the DROP-IN. The detector
10538 + // accepts our marker anywhere in a file's header; this plugin's
10539 + // own check requires it to open the header, because only this
10540 + // side authorizes overwriting and deleting. A foreign drop-in
10541 + // that merely carries our marker further down its header is
10542 + // attributed to us by the detector, and skipping it here dropped
10543 + // the refusal entirely — the write then failed on the stricter
10544 + // check and the user was told to go and fix file permissions.
10545 + // Where they disagree, believe the stricter one.
10546 + if ( self::PLUGIN_FILE === ( $blocker['plugin'] ?? null ) ) {
10547 + $about_dropin = in_array(
10548 + $code,
10549 + array(
10550 + Page_Cache_Detector::BLOCKER_FOREIGN_DROPIN,
10551 + Page_Cache_Detector::BLOCKER_UNKNOWN_DROPIN,
10552 + ),
10553 + true
10554 + );
10555 + if ( ! $about_dropin || self::DROPIN_XSPEED === $owner ) {
10556 + continue;
10557 + }
10558 + }
10559 + if ( Page_Cache_Detector::BLOCKER_WP_CACHE_ORPHANED === $code && self::DROPIN_XSPEED === $owner ) {
10560 + continue;
10561 + }
10562 + /*
10563 + * Another plugin's drop-in is no longer a refusal.
10564 + *
10565 + * It used to be: whoever held advanced-cache.php kept it, and
10566 + * enabling was blocked with "deactivate its page cache first".
10567 + * That left a user who had asked for our cache with no way to get
10568 + * it — on a live site the only exit was deleting a file over SSH,
10569 + * and the message could not even say which of its two causes
10570 + * applied ("is active OR owns advanced-cache.php").
10571 + *
10572 + * Turning the page cache on is the instruction to serve pages
10573 + * from cache, and that is not possible without this file. So we
10574 + * take it, and the dashboard says whose file it is first —
10575 + * dropin_disclosure() names the owner, the user confirms, and
10576 + * install_dropin() writes ours over the top.
10577 + *
10578 + * A still-active competitor is deliberately NOT re-added as a
10579 + * blocker below: it is caught by `active_page_cache`, which the
10580 + * capability rule already downgrades to a note. Two page caches
10581 + * installed at once is the user's call to make, not ours to
10582 + * refuse — they just told us which one they want serving.
10583 + *
10584 + * UNREADABLE is the exception and stays a refusal: we cannot name
10585 + * what we would destroy, and install_dropin() refuses it too, so
10586 + * opening the gate here would only produce a failed write.
10587 + */
10588 + $about_dropin_owner = in_array(
10589 + $code,
10590 + array(
10591 + Page_Cache_Detector::BLOCKER_FOREIGN_DROPIN,
10592 + Page_Cache_Detector::BLOCKER_UNKNOWN_DROPIN,
10593 + ),
10594 + true
10595 + );
10596 + if ( $about_dropin_owner && self::DROPIN_UNREADABLE !== $owner ) {
10597 + continue;
10598 + }
10599 + /*
10600 + * Capability is not possession. `active_page_cache` and
10601 + * `multiple_page_caches` both fire on a plugin that merely CAN
10602 + * cache pages — the detector cannot prove a competitor's page
10603 + * cache is off, so it counts it. As a warning that is right. As
10604 + * a gate it refuses a write that takes nothing from anyone.
10605 + *
10606 + * This gate guards exactly two files: advanced-cache.php and the
10607 + * WP_CACHE define that loads it. A plugin that does not hold the
10608 + * drop-in has nothing here for us to overwrite, and one that does
10609 + * is already refused by `foreign_dropin` / `unknown_dropin` a few
10610 + * lines up. So when the field is ours or empty, an active
10611 + * competitor is a note, not a refusal.
10612 + *
10613 + * QA found this on a live OpenLiteSpeed site keeping LiteSpeed
10614 + * Cache for images and CDN with its page cache off, while xSpeed
10615 + * served the pages. One click of the off switch and it could not
10616 + * be turned back on: the only way out was deactivating LiteSpeed
10617 + * entirely, and the message told them to "deactivate its page
10618 + * cache" — which they already had.
10619 + */
10620 + $about_capability = in_array(
10621 + $code,
10622 + array(
10623 + Page_Cache_Detector::BLOCKER_ACTIVE_PAGE_CACHE,
10624 + Page_Cache_Detector::BLOCKER_MULTIPLE_PAGE_CACHES,
10625 + ),
10626 + true
10627 + );
10628 + /*
10629 + * FOREIGN belongs in this list now, and it is the whole point.
10630 + *
10631 + * The rule is still "capability is not possession": these two
10632 + * blockers fire on any plugin that CAN cache pages, which the
10633 + * detector cannot prove is switched off. What changed is that a
10634 + * competitor holding the drop-in no longer stops us either — we
10635 + * take the file, having said whose it is. So there is nothing
10636 + * left for a merely-installed competitor to protect, and keeping
10637 + * the refusal here would put back the dead end by another route:
10638 + * "another page cache is active" on a site where the user has
10639 + * just told us, by name, which cache they want serving.
10640 + *
10641 + * UNREADABLE is deliberately still absent — that one refuses.
10642 + */
10643 + if ( $about_capability
10644 + && in_array( $owner, array( self::DROPIN_XSPEED, self::DROPIN_NONE, self::DROPIN_FOREIGN, self::DROPIN_ABANDONED ), true ) ) {
10645 + continue;
10646 + }
10647 + if ( Page_Cache_Detector::BLOCKER_MULTIPLE_PAGE_CACHES === $code ) {
10648 + $others = self::other_page_cache_names( $blocker );
10649 + if ( array() === $others ) {
10650 + // We were the only owner counted — nothing to refuse —
10651 + // unless the list is missing entirely, which is an older
10652 + // detector copy we still must not talk past.
10653 + if ( null === $primary && array() === (array) ( $blocker['plugins'] ?? array() ) ) {
10654 + $primary = self::ownership_blocker_message( '', '' );
10655 + }
10656 + continue;
10657 + }
10658 + $named = array_values( array_unique( array_merge( $named, $others ) ) );
10659 + if ( null === $primary ) {
10660 + $primary = self::multiple_page_caches_message( $others );
10661 + $primary_names = true;
10662 + }
10663 + continue;
10664 + }
10665 + if ( null === $primary ) {
10666 + $label = (string) ( $blocker['label'] ?? '' );
10667 + $primary = self::ownership_blocker_message( $code, $label );
10668 + $primary_names = '' !== $label;
10669 + }
10670 + }
10671 +
10672 + if ( null === $primary ) {
10673 + return null;
10674 + }
10675 + /*
10676 + * The first blocker decides WHY we refuse; it does not always know
10677 + * WHO. The detector can only attribute a drop-in it recognises, and
10678 + * an unrecognised one produces "its owner cannot be proved" — the
10679 + * sentence a W3 Total Cache site used to get while a later blocker in
10680 + * the same verdict was holding the name "W3 Total Cache".
10681 + *
10682 + * So keep the reason and add the names, rather than swapping one for
10683 + * the other: the plugin the user must deal with is not necessarily
10684 + * the owner of the file we could not identify, and promoting the
10685 + * named blocker would have told them to deactivate a plugin that is
10686 + * not what is in their way.
10687 + */
10688 + if ( $primary_names || array() === $named ) {
10689 + return $primary;
10690 + }
10691 + if ( 1 === count( $named ) ) {
10692 + return sprintf(
10693 + /* translators: 1: the refusal reason, 2: a page-caching plugin's name. */
10694 + __( '%1$s %2$s is also active on this site — deactivate its page cache before enabling xSpeed.', 'xspeed' ),
10695 + $primary,
10696 + $named[0]
10697 + );
10698 + }
10699 + return sprintf(
10700 + /* translators: 1: the refusal reason, 2: comma-separated page-caching plugin names. */
10701 + __( '%1$s These page caches are also active on this site: %2$s. Deactivate them before enabling xSpeed.', 'xspeed' ),
10702 + $primary,
10703 + implode( ', ', $named )
10704 + );
10705 + }
10706 +
10707 + /** How xSpeed's own plugin file appears in the detector's catalog. */
10708 + private const PLUGIN_FILE = 'xspeed/xspeed.php';
10709 +
10710 + /**
10711 + * Name the OTHER page caches behind a `multiple_page_caches` refusal.
10712 + *
10713 + * This blocker has no single owner, so the detector leaves `plugin` and
10714 + * `label` null and hands over the full list instead. Left unhandled it
10715 + * fell through to the anonymous fallback sentence — and it is the blocker
10716 + * an ordinary site hits most: xSpeed counts toward "multiple", so the
10717 + * count reaches two the moment one other page-cache plugin is activated,
10718 + * even one that has not written a drop-in. A site running our cache that
10719 + * activated LiteSpeed could not re-enable it and was told only that "the
10720 + * page-cache field is occupied".
10721 + *
10722 + * Returns an empty list when xSpeed was the only owner counted, or when
10723 + * an older detector copy sent no list at all — the caller distinguishes
10724 + * the two by looking at `plugins`.
10725 + *
10726 + * @param array<string,mixed> $blocker One entry from Detector::classify().
10727 + * @return string[]
10728 + */
10729 + private static function other_page_cache_names( array $blocker ): array {
10730 + $plugins = array_values( (array) ( $blocker['plugins'] ?? array() ) );
10731 + $labels = array_values( (array) ( $blocker['labels'] ?? array() ) );
10732 +
10733 + $others = array();
10734 + foreach ( $plugins as $i => $plugin ) {
10735 + if ( self::PLUGIN_FILE === $plugin ) {
10736 + continue;
10737 + }
10738 + $others[] = isset( $labels[ $i ] ) && '' !== (string) $labels[ $i ]
10739 + ? (string) $labels[ $i ]
10740 + : (string) $plugin;
10741 + }
10742 + return array_values( array_unique( $others ) );
10743 + }
10744 +
10745 + /**
10746 + * The refusal sentence for a `multiple_page_caches` blocker.
10747 + *
10748 + * @param string[] $others Page caches other than xSpeed. Never empty.
10749 + */
10750 + private static function multiple_page_caches_message( array $others ): string {
10751 + if ( 1 === count( $others ) ) {
10752 + return self::ownership_blocker_message( Page_Cache_Detector::BLOCKER_ACTIVE_PAGE_CACHE, $others[0] );
10753 + }
10754 + return sprintf(
10755 + /* translators: %s: comma-separated list of page-caching plugin names. */
10756 + __( 'More than one page cache is active on this site (%s). Turn off the other page caches before enabling xSpeed.', 'xspeed' ),
10757 + implode( ', ', $others )
10758 + );
10759 + }
10760 +
10761 + private static function ownership_blocker_message( string $code, string $label ): string {
10762 + if ( '' !== $label ) {
10763 + return sprintf( __( '%s is active or owns advanced-cache.php. Deactivate its page cache before enabling xSpeed.', 'xspeed' ), $label );
10764 + }
10765 + $messages = array(
10766 + 'wp_cache_orphaned' => __( 'WP_CACHE is true but no page-cache drop-in owner can be proved. xSpeed will not claim it.', 'xspeed' ),
10767 + 'wp_cache_duplicate' => __( 'wp-config.php defines WP_CACHE more than once. Remove the duplicate before enabling the cache.', 'xspeed' ),
10768 + 'wp_cache_dynamic' => __( 'WP_CACHE is set from an expression in wp-config.php. xSpeed will not rewrite it.', 'xspeed' ),
10769 + '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' ),
10770 + 'wp_config_unreadable' => __( 'wp-config.php cannot be read, so xSpeed cannot safely change page-cache ownership.', 'xspeed' ),
10771 + 'unknown_dropin' => __( 'advanced-cache.php is occupied but its owner cannot be proved. xSpeed will not replace it.', 'xspeed' ),
10772 + 'unreadable_dropin' => __( 'advanced-cache.php cannot be read, so xSpeed cannot prove its owner.', 'xspeed' ),
10773 + );
10774 + return $messages[ $code ] ?? __( 'The page-cache field is occupied or cannot be verified. xSpeed will not change it.', 'xspeed' );
10775 + }
10776 +
10777 + /**
10778 + * How WP_CACHE is written in wp-config.php, as opposed to what it
10779 + * evaluates to at runtime.
10780 + *
10781 + * The literal is what matters to a writer: a value behind an expression,
10782 + * or two competing defines, cannot be rewritten by a regex without
10783 + * guessing — and a wrong guess silently disables page caching (ours or
10784 + * someone else's) with no error anywhere.
10785 + *
10786 + * @return string undefined | true | false | duplicate | dynamic | conditional | unreadable
10787 + */
10788 + public static function wp_cache_define_state(): string {
10789 + $path = self::wp_config_path();
10790 + if ( '' === $path ) {
10791 + return 'unreadable';
10792 + }
10793 +
10794 + $config = self::read_file( $path );
10795 + if ( null === $config ) {
10796 + return 'unreadable';
10797 + }
10798 +
10799 + require_once XSPEED_DIR . 'includes/wp-cache-constant.php';
10800 + $parsed = \xspeed_parse_wp_cache_defines( $config );
10801 + return $parsed['state'];
10802 + }
10803 +
10804 + /**
10805 + * Classify the captured right-hand side of a WP_CACHE define.
10806 + *
10807 + * Hosts and older tutorials write the value several ways —
10808 + * `1`, `'1'`, `TRUE` — and all of them are literals a rewrite can safely
10809 + * replace. Only a value we cannot evaluate by looking at it (a variable, a
10810 + * function call, a ternary) counts as dynamic, because that is the case
10811 + * where rewriting means guessing.
10812 + *
10813 + * @return string true | false | dynamic
10814 + */
10815 + private static function classify_wp_cache_literal( string $raw ): string {
10816 + $literal = strtolower( trim( $raw ) );
10817 + $literal = trim( $literal, "'\"" );
10818 +
10819 + if ( in_array( $literal, array( 'true', '1' ), true ) ) {
10820 + return 'true';
10821 + }
10822 + if ( in_array( $literal, array( 'false', '0', '', 'null' ), true ) ) {
10823 + return 'false';
10824 + }
10825 + return 'dynamic';
10826 + }
10827 +
10828 + /**
10829 + * Read a file for an ownership decision. Null on any failure — callers
10830 + * treat null as "unknown", never as "empty", because an empty string
10831 + * would read as "no marker found" and license an overwrite.
10832 + */
10833 + private static function read_file( string $path ): ?string {
10834 + if ( ! is_readable( $path ) ) {
10835 + return null;
10836 + }
10837 + // 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.
10838 + $contents = @file_get_contents( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- A failed read is a valid answer ("unknown"), not an error to surface.
10839 + return is_string( $contents ) ? $contents : null;
10840 + }
10841 +
1886 10842 public static function install_dropin() {
1887 10843 $source = XSPEED_DIR . 'includes/advanced-cache.php';
1888 10844 $target = WP_CONTENT_DIR . '/advanced-cache.php';
1889 10845 if ( ! file_exists( $source ) ) {
@@ -1889,8 +10845,26 @@
1889 10845 if ( ! file_exists( $source ) ) {
1890 10846 return false;
1891 10847 }
1892 10848
10849 + /*
10850 + * A drop-in we cannot READ is the one thing still refused here. Not
10851 + * because of who owns it — we no longer refuse on ownership — but
10852 + * because an unreadable file is usually a permissions problem, and
10853 + * writing over it would fail anyway or destroy something we were
10854 + * never able to look at.
10855 + *
10856 + * Everything else is ours to take. Enabling the page cache IS the
10857 + * user's instruction to serve the cache, and serving it means holding
10858 + * advanced-cache.php; the dashboard says whose file it is replacing
10859 + * before the click (Page_Cache_Detector::dropin_disclosure()), so the
10860 + * takeover is consented rather than silent.
10861 + */
10862 + $owner = self::dropin_owner();
10863 + if ( self::DROPIN_UNREADABLE === $owner ) {
10864 + return false;
10865 + }
10866 +
1893 10867 global $wp_filesystem;
1894 10868 if ( ! function_exists( 'WP_Filesystem' ) ) {
1895 10869 require_once ABSPATH . 'wp-admin/includes/file.php';
1896 10870 }
@@ -1914,38 +10888,144 @@
1914 10888 str_replace( "'", "\\'", self::hits_log_path() ),
1915 10889 $source_contents
1916 10890 );
1917 10891
10892 + // Bake the cookie + user-agent exclusion rules in too. The drop-in
10893 + // runs before WordPress loads, so it cannot read the settings — and
10894 + // without them it served the shared anonymous page to any visitor
10895 + // PHP had not yet seen (a first-time cart visitor, a bypassed bot).
10896 + // The generic bypass cookie only covers repeat visitors; these two
10897 + // regexes are what make the FIRST request correct.
10898 + //
10899 + // Both are already fully escaped by Server_Rules, and each is
10900 + // embedded as a single-quoted PHP literal, so a settings value can
10901 + // neither break the drop-in's syntax nor execute.
10902 + $cache_opts = Settings_Manager::get( 'cache' );
10903 + $cookie_rule = Server_Rules::cookie_rule(
10904 + is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array()
10905 + );
10906 + $ua_rule = Server_Rules::user_agent_rule(
10907 + is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array()
10908 + );
10909 +
10910 + $source_contents = str_replace(
10911 + '@@XSPEED_COOKIE_RE@@',
10912 + str_replace( "'", "\\'", $cookie_rule['regex'] ),
10913 + $source_contents
10914 + );
10915 + $source_contents = str_replace(
10916 + '@@XSPEED_UA_RE@@',
10917 + str_replace( "'", "\\'", $ua_rule['regex'] ),
10918 + $source_contents
10919 + );
10920 + // Which user agents must not count toward the hit ratio. Built here
10921 + // because `xspeed_self_user_agents` is a filter the drop-in cannot
10922 + // call. A renamed warmer is caught by Self_Traffic::HEADER instead.
10923 + $source_contents = str_replace(
10924 + '@@XSPEED_HIT_EXCLUDE_RE@@',
10925 + str_replace( "'", "\\'", Hit_Counter::excluded_ua_regex() ),
10926 + $source_contents
10927 + );
10928 +
10929 + /*
10930 + * Ours or absent — the ownership gate at the top of this method ruled
10931 + * out everything else. The old code path that moved a foreign drop-in
10932 + * into uploads/xspeed-backups and wrote ours on top is gone: it
10933 + * disabled the other plugin's page cache the moment an xSpeed install
10934 + * ran, with nothing in its own UI to explain why.
10935 + */
10936 +
10937 + // Bake the configured cache lifetime in. The drop-in runs before
10938 + // WordPress loads, so it cannot read the option — it previously fell
10939 + // back to a hardcoded 86400 for every ordinary page, because
10940 + // write_meta() only emits a `ttl` sidecar when the value DIFFERS from
10941 + // the page default. That made the admin's "1 to 720 hours" control a
10942 + // no-op at the layer that actually answers the request: 12h served
10943 + // stale for up to 2x the configured lifetime, and 168h lost the fast
10944 + // path for 6 of every 7 days (issue #240).
10945 + //
10946 + // This is re-baked on every cache settings save (see CacheModule::boot),
10947 + // exactly like the cookie / user-agent rules above.
10948 + $expiry_hours = isset( $cache_opts['cache_expiry'] ) ? (int) $cache_opts['cache_expiry'] : 24;
10949 + if ( $expiry_hours < 1 || $expiry_hours > 720 ) {
10950 + $expiry_hours = 24;
10951 + }
10952 + $source_contents = str_replace(
10953 + '@@XSPEED_DEFAULT_TTL@@',
10954 + (string) ( $expiry_hours * HOUR_IN_SECONDS ),
10955 + $source_contents
10956 + );
10957 +
10958 + // Bake the site-wide edge answer in. Resolved in a `bake` context, so
10959 + // nothing per-page and nothing a request header vouched for can reach
10960 + // it: a bake runs once, in an admin or CLI request, and answers for
10961 + // every page on the site. A page that disagrees gets a sidecar
10962 + // instead — see per_entry_edge_headers().
10963 + //
10964 + // Re-baked on every cache settings save (see CacheModule::boot),
10965 + // exactly like the cookie, user-agent and lifetime rules above.
10966 + $source_contents = str_replace(
10967 + "'@@XSPEED_EDGE_HEADERS@@'",
10968 + self::edge_headers_literal( self::edge_headers_for( 'HIT', 'bake' ) ),
10969 + $source_contents
10970 + );
10971 +
10972 + // And the answer for the same HIT served for a URL with an ignored
10973 + // param in it, which the drop-in sends instead of the one above. An
10974 + // empty array means the two agree. See query_variant_edge_headers().
10975 + $source_contents = str_replace(
10976 + "'@@XSPEED_EDGE_QUERY_HOLD@@'",
10977 + self::edge_headers_literal( self::query_variant_edge_headers() ),
10978 + $source_contents
10979 + );
10980 +
1918 10981 if ( file_exists( $target ) ) {
1919 10982 $existing = $wp_filesystem->get_contents( $target );
1920 - $is_xspeed = is_string( $existing ) && false !== strpos( $existing, 'XSPEED_DROPIN' );
10983 + if ( is_string( $existing ) && $existing === $source_contents ) {
10984 + return true;
10985 + }
10986 + }
1921 10987
1922 - if ( $is_xspeed ) {
1923 - if ( $existing === $source_contents ) {
1924 - return true;
1925 - }
1926 - return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE );
1927 - }
10988 + $written = (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE );
10989 + if ( $written ) {
10990 + self::forget_compiled_dropin( $target );
10991 + }
10992 + return $written;
10993 + }
1928 10994
1929 - // Foreign drop-in (e.g. left over from another cache plugin) — back it up
1930 - // before overwriting so the user can recover if needed. Uploads dir
1931 - // (not wp-content root) keeps the backup out of WordPress's reserved
1932 - // drop-in location.
1933 - $upload = wp_upload_dir( null, false );
1934 - $basedir = isset( $upload['basedir'] ) ? trailingslashit( $upload['basedir'] ) . 'xspeed-backups' : false;
1935 - if ( $basedir ) {
1936 - if ( ! file_exists( $basedir ) ) {
1937 - wp_mkdir_p( $basedir );
1938 - self::write_silence( $basedir );
1939 - }
1940 - $backup = $basedir . '/advanced-cache.foreign-' . gmdate( 'Ymd-His' ) . '.php.bak';
1941 - $wp_filesystem->move( $target, $backup, true );
1942 - } else {
1943 - $wp_filesystem->delete( $target );
10995 + /**
10996 + * Callable that drops a script's compiled copy. Replaced by tests only;
10997 + * opcache_invalidate() is a PHP internal that cannot be stubbed.
10998 + *
10999 + * @var callable|null
11000 + */
11001 + private static $opcache_invalidate = null;
11002 +
11003 + /**
11004 + * Make PHP compile the new drop-in on the next request.
11005 + *
11006 + * opcache keeps the compiled drop-in and checks the file's timestamp at
11007 + * most every `opcache.revalidate_freq` seconds (60 on lenzora.site), or
11008 + * never with `validate_timestamps` off. So a re-bake, such as the one
11009 + * that follows a change to "CDN or proxy in front", kept serving the old
11010 + * edge headers for up to a minute, or until PHP restarted.
11011 + *
11012 + * This reaches the opcache of the PHP process doing the write, which is
11013 + * the web server's own when the bake runs in a page or REST request. A
11014 + * WP-CLI bake has its own opcache, so the web server still waits for its
11015 + * next timestamp check there.
11016 + *
11017 + * @param string $target Absolute path of the drop-in just written.
11018 + */
11019 + private static function forget_compiled_dropin( string $target ): void {
11020 + $invalidate = self::$opcache_invalidate;
11021 + if ( null === $invalidate ) {
11022 + if ( ! function_exists( 'opcache_invalidate' ) ) {
11023 + return;
1944 11024 }
11025 + $invalidate = 'opcache_invalidate';
1945 11026 }
1946 -
1947 - return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE );
11027 + @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.
1948 11028 }
1949 11029
1950 11030 public static function remove_dropin() {
1951 11031 $target = WP_CONTENT_DIR . '/advanced-cache.php';
@@ -1962,61 +11042,374 @@
1962 11042 return;
1963 11043 }
1964 11044
1965 11045 $contents = $wp_filesystem->get_contents( $target );
1966 - if ( is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' ) ) {
11046 + if ( is_string( $contents ) && xspeed_has_canonical_dropin_signature( $contents ) ) {
1967 11047 wp_delete_file( $target );
1968 11048 }
1969 11049 }
1970 11050
11051 + /**
11052 + * Where wp-config.php actually is.
11053 + *
11054 + * WordPress core supports the file one directory ABOVE ABSPATH, and
11055 + * plenty of installs use that layout. This used to look only in ABSPATH
11056 + * and bail, so on those sites the constant could never be written — while
11057 + * Health, which did fall back to the parent, reported the file writable
11058 + * and told the user to toggle the cache off and on. The advice could
11059 + * never work, and its fallback hint ("another plugin left WP_CACHE false
11060 + * behind") was wrong too: there was no define at all. (#19, QA on #174)
11061 + *
11062 + * Returns '' when no wp-config.php can be found in either location.
11063 + */
11064 + public static function wp_config_path(): string {
11065 + $candidates = array( ABSPATH . 'wp-config.php', dirname( ABSPATH ) . '/wp-config.php' );
11066 + foreach ( $candidates as $path ) {
11067 + if ( file_exists( $path ) ) {
11068 + return $path;
11069 + }
11070 + }
11071 + return '';
11072 + }
11073 +
11074 + /**
11075 + * Can we actually write the constant right now?
11076 + *
11077 + * This is the single oracle for that question — Health asks THIS rather
11078 + * than running its own `wp_is_writable()` test, so the message a user
11079 + * reads can never disagree with what the plugin will do. The two differed
11080 + * in both directions: on the path (above) and on the test itself, since
11081 + * an FTP/SSH WP_Filesystem transport can refuse a file that
11082 + * `wp_is_writable()` reports as writable. (#19, QA on #174)
11083 + */
11084 + public static function can_write_wp_config(): bool {
11085 + $wp_config = self::wp_config_path();
11086 + if ( '' === $wp_config ) {
11087 + return false;
11088 + }
11089 +
11090 + global $wp_filesystem;
11091 + if ( ! function_exists( 'WP_Filesystem' ) ) {
11092 + require_once ABSPATH . 'wp-admin/includes/file.php';
11093 + }
11094 + WP_Filesystem();
11095 + return (bool) ( $wp_filesystem && $wp_filesystem->is_writable( $wp_config ) );
11096 + }
11097 +
1971 11098 public static function set_wp_cache_constant( $enable ) {
1972 - $wp_config = ABSPATH . 'wp-config.php';
1973 - if ( ! file_exists( $wp_config ) ) {
11099 + $wp_config = self::wp_config_path();
11100 + if ( '' === $wp_config ) {
1974 11101 return false;
1975 11102 }
1976 11103
11104 + /*
11105 + * WP_CACHE belongs to whoever owns the drop-in — it is the switch that
11106 + * makes core load that one file. Editing it while someone else's
11107 + * drop-in is installed either turns THEIR cache on or off; either way
11108 + * it is a write to another plugin's state. So: no ownership, no edit.
11109 + */
11110 + $owner = self::dropin_owner();
11111 + if ( self::DROPIN_FOREIGN === $owner || self::DROPIN_UNREADABLE === $owner ) {
11112 + return false;
11113 + }
11114 +
11115 + $state = self::wp_cache_define_state();
11116 + if ( 'duplicate' === $state || 'dynamic' === $state ) {
11117 + // Two competing defines, or a value behind an expression. A regex
11118 + // rewrite here is a guess, and a wrong guess silently kills page
11119 + // caching with no error anywhere.
11120 + return false;
11121 + }
1977 11122 global $wp_filesystem;
1978 11123 if ( ! function_exists( 'WP_Filesystem' ) ) {
1979 11124 require_once ABSPATH . 'wp-admin/includes/file.php';
1980 11125 }
1981 11126 WP_Filesystem();
1982 - if ( ! $wp_filesystem || ! $wp_filesystem->is_writable( $wp_config ) ) {
11127 + if ( ! $wp_filesystem ) {
1983 11128 return false;
1984 11129 }
1985 11130
1986 11131 $config = $wp_filesystem->get_contents( $wp_config );
11132 + if ( ! is_string( $config ) ) {
11133 + return false;
11134 + }
11135 + require_once XSPEED_DIR . 'includes/wp-cache-constant.php';
11136 + $marker = $enable ? self::wp_cache_receipt() : '';
11137 + $updated = xspeed_rewrite_wp_cache_define( $config, (bool) $enable, $marker );
11138 + if ( ! is_string( $updated ) ) {
11139 + return false;
11140 + }
1987 11141
1988 - if ( $enable ) {
1989 - if ( strpos( $config, "define( 'WP_CACHE'" ) !== false || strpos( $config, "define('WP_CACHE'" ) !== false ) {
1990 - return true;
11142 + /*
11143 + * Removing a WP_CACHE line we cannot prove we wrote is somebody else's
11144 + * configuration, so a disable needs either our drop-in or our receipt.
11145 + *
11146 + * The test is on the REWRITE, not on the request: it used to run
11147 + * before the rewrite and refuse a disable that had nothing to remove.
11148 + * An ordinary site with no drop-in and no define — every fresh
11149 + * install — therefore failed to turn page caching off, so the
11150 + * onboarding wizard reported "setup needs attention" to every user who
11151 + * declined it and Migration reported the cache import as failed.
11152 + */
11153 + if ( ! $enable && $updated !== $config
11154 + && self::DROPIN_XSPEED !== $owner
11155 + && ! self::wp_cache_receipt_matches_source( $config ) ) {
11156 + return false;
11157 + }
11158 +
11159 + /*
11160 + * Nothing to write. auto_heal() runs the whole enable transaction on
11161 + * every admin_init, so without this every wp-admin request rewrote
11162 + * wp-config.php with byte-identical content: pointless disk churn
11163 + * that trips host file-integrity monitors and widens the window for
11164 + * a concurrent write on a busy admin.
11165 + *
11166 + * It is also what makes a correct WP_CACHE on a read-only
11167 + * wp-config.php succeed. A managed host that ships the file
11168 + * unwritable, on a site where the user already pasted the define,
11169 + * is in the state we wanted — the writability test below is about
11170 + * whether we can CHANGE the file, and there is nothing to change.
11171 + */
11172 + if ( $updated === $config ) {
11173 + if ( ! $enable ) {
11174 + // Our line is not in the file, so the receipt that proved we
11175 + // wrote it is stale — drop it on the same terms as a real
11176 + // removal, or uninstall keeps a claim on nothing.
11177 + delete_option( 'xspeed_page_cache_ownership_receipt' );
1991 11178 }
1992 - $config = preg_replace( '/(<\?php)/', "$1\ndefine( 'WP_CACHE', true );", $config, 1 );
1993 - } else {
1994 - $config = preg_replace( "/define\\(\\s*['\"]WP_CACHE['\"]\\s*,\\s*true\\s*\\);\\s*\\n?/", '', $config );
11179 + return true;
1995 11180 }
1996 11181
1997 - return (bool) $wp_filesystem->put_contents( $wp_config, $config, FS_CHMOD_FILE );
11182 + if ( ! $wp_filesystem->is_writable( $wp_config ) ) {
11183 + return false;
11184 + }
11185 + $written = (bool) $wp_filesystem->put_contents( $wp_config, $updated, FS_CHMOD_FILE );
11186 + if ( $written && ! $enable ) {
11187 + delete_option( 'xspeed_page_cache_ownership_receipt' );
11188 + }
11189 + return $written;
1998 11190 }
1999 11191
11192 + private static function wp_cache_receipt(): string {
11193 + $receipt = get_option( 'xspeed_page_cache_ownership_receipt', '' );
11194 + if ( is_string( $receipt ) && preg_match( '/^[a-f0-9]{32}$/', $receipt ) ) {
11195 + return $receipt;
11196 + }
11197 + $receipt = substr( hash( 'sha256', XSPEED_DIR . microtime( true ) . mt_rand() ), 0, 32 );
11198 + update_option( 'xspeed_page_cache_ownership_receipt', $receipt, false );
11199 + return $receipt;
11200 + }
11201 +
11202 + /**
11203 + * Is the WP_CACHE line in wp-config.php ours to REMOVE?
11204 + *
11205 + * Two different questions live here and only one of them matters. "Did we
11206 + * write it" is answered by our drop-in on disk or by our receipt comment
11207 + * beside the define. "Is it ours to remove" also asks what the line does
11208 + * NOW — and once a competitor owns advanced-cache.php, a line we wrote
11209 + * ourselves is the switch that loads THEIR drop-in. They had no reason to
11210 + * touch an already-true define, so our receipt is still sitting on it.
11211 + * Removing it there would stop their live page cache.
11212 + *
11213 + * So a foreign or unreadable owner is never ours to remove, whatever the
11214 + * receipt says, and the caller treats that as a reason to leave the line
11215 + * and get on with disabling our own cache — not as a reason to refuse.
11216 + */
11217 + private static function wp_cache_define_is_ours_to_remove( string $owner ): bool {
11218 + if ( self::DROPIN_FOREIGN === $owner || self::DROPIN_UNREADABLE === $owner ) {
11219 + return false;
11220 + }
11221 + if ( self::DROPIN_XSPEED === $owner ) {
11222 + return true;
11223 + }
11224 + $path = self::wp_config_path();
11225 + if ( '' === $path ) {
11226 + return false;
11227 + }
11228 + $config = self::read_file( $path );
11229 + return is_string( $config ) && self::wp_cache_receipt_matches_source( $config );
11230 + }
11231 +
11232 + private static function wp_cache_receipt_matches_source( string $source ): bool {
11233 + $receipt = get_option( 'xspeed_page_cache_ownership_receipt', '' );
11234 + require_once XSPEED_DIR . 'includes/wp-cache-constant.php';
11235 + return xspeed_wp_cache_receipt_matches( $source, $receipt );
11236 + }
11237 +
11238 + /**
11239 + * Admin-bar purge menu — a parent node plus one child per visible cache
11240 + * type (LiteSpeed-style), instead of a single "Purge All" link. Each
11241 + * child posts to the same admin-post handler with its type slug. The
11242 + * per-type items only appear for active/licensed modules; "Purge All"
11243 + * always shows and always sweeps everything. (FBS-83114)
11244 + *
11245 + * The parent node links to the settings page rather than a purge URL —
11246 + * clicking the top-level item used to wipe the whole cache instantly with
11247 + * no confirmation, which is far too destructive for a stray click. Purging
11248 + * stays available (and explicit) through the child items. (FBS-84068)
11249 + */
2000 11250 public function admin_bar_purge( $wp_admin_bar ) {
2001 11251 if ( ! current_user_can( 'manage_options' ) ) {
2002 11252 return;
2003 11253 }
11254 +
2004 11255 $wp_admin_bar->add_node(
2005 11256 array(
2006 11257 'id' => 'xspeed-purge',
2007 - 'title' => __( 'Purge xSpeed Cache', 'xspeed' ),
2008 - 'href' => wp_nonce_url( admin_url( 'admin-post.php?action=xspeed_purge' ), 'xspeed_purge' ),
11258 + 'title' => __( 'xSpeed Cache', 'xspeed' ),
11259 + 'href' => admin_url( 'admin.php?page=' . Admin::PAGE_SLUG ),
2009 11260 )
2010 11261 );
11262 +
11263 + // Settings first, then the two whole-errand actions (Purge All,
11264 + // Purge this URL), then the per-type items. The order is the one WP
11265 + // Rocket uses, and it front-loads what people open this menu for:
11266 + // nobody reaches for "Purge Object Cache" as often as they reach for
11267 + // the page they are looking at.
11268 + $wp_admin_bar->add_node(
11269 + array(
11270 + 'id' => 'xspeed-purge-settings',
11271 + 'parent' => 'xspeed-purge',
11272 + 'title' => esc_html__( 'Settings', 'xspeed' ),
11273 + 'href' => admin_url( 'admin.php?page=' . Admin::PAGE_SLUG ),
11274 + )
11275 + );
11276 +
11277 + $types = self::purge_types();
11278 +
11279 + // 'all' is rendered out of band so the single-URL item can sit
11280 + // directly under it. A filter that reorders or drops it is honoured:
11281 + // the loop below skips whatever was emitted here.
11282 + $emitted = array();
11283 + if ( ! empty( $types['all']['visible'] ) ) {
11284 + $wp_admin_bar->add_node(
11285 + array(
11286 + 'id' => 'xspeed-purge-all',
11287 + 'parent' => 'xspeed-purge',
11288 + 'title' => esc_html( $types['all']['label'] ),
11289 + 'href' => self::purge_type_url( 'all' ),
11290 + )
11291 + );
11292 + $emitted['all'] = true;
11293 + }
11294 +
11295 + // Only when the current screen is about one thing — a front-end view,
11296 + // or a published post's edit screen. On a list table or a settings
11297 + // page there is nothing for "this" to mean, so the item stays hidden
11298 + // rather than silently targeting the dashboard. Purge_Ui decides both
11299 + // the label and the scope, which differ between the two contexts.
11300 + $context = Purge_Ui::context_node();
11301 + if ( null !== $context ) {
11302 + $wp_admin_bar->add_node(
11303 + array(
11304 + 'id' => 'xspeed-purge-this-url',
11305 + 'parent' => 'xspeed-purge',
11306 + 'title' => esc_html( $context['title'] ),
11307 + 'href' => $context['href'],
11308 + )
11309 + );
11310 + }
11311 +
11312 + foreach ( $types as $slug => $type ) {
11313 + if ( empty( $type['visible'] ) || isset( $emitted[ $slug ] ) ) {
11314 + continue;
11315 + }
11316 + $wp_admin_bar->add_node(
11317 + array(
11318 + 'id' => 'xspeed-purge-' . $slug,
11319 + 'parent' => 'xspeed-purge',
11320 + 'title' => esc_html( $type['label'] ),
11321 + 'href' => self::purge_type_url( $slug ),
11322 + )
11323 + );
11324 + }
2011 11325 }
2012 11326
11327 + /**
11328 + * Nonce-protected admin-post URL for purging a single type. The nonce
11329 + * action is per-type so a leaked URL can't be replayed for a different
11330 + * scope.
11331 + */
11332 + private static function purge_type_url( string $type ): string {
11333 + return wp_nonce_url(
11334 + admin_url( 'admin-post.php?action=xspeed_purge&type=' . rawurlencode( $type ) ),
11335 + 'xspeed_purge_' . $type
11336 + );
11337 + }
11338 +
2013 11339 public function handle_admin_bar_purge() {
2014 11340 if ( ! current_user_can( 'manage_options' ) ) {
2015 11341 wp_die( esc_html__( 'Unauthorized.', 'xspeed' ), 403 );
2016 11342 }
2017 - check_admin_referer( 'xspeed_purge' );
2018 - self::purge_all();
2019 - wp_safe_redirect( wp_get_referer() ?: admin_url() );
11343 + $type = isset( $_GET['type'] ) ? sanitize_key( wp_unslash( $_GET['type'] ) ) : 'all';
11344 + check_admin_referer( 'xspeed_purge_' . $type );
11345 +
11346 + // Only honour known types; anything else falls back to a full purge.
11347 + if ( ! array_key_exists( $type, self::purge_types() ) ) {
11348 + $type = 'all';
11349 + }
11350 +
11351 + // Answer the browser BEFORE purging. "Purge All" fans out to the local
11352 + // sweep, the object cache, CSS/edge listeners (outbound HTTP) and
11353 + // third-party render caches, all in this one request — on a large site
11354 + // that can outlive PHP-FPM's request_terminate_timeout, FPM kills the
11355 + // worker mid-purge, and nginx answers the admin's click with a 502.
11356 + // fastcgi_finish_request() exists on exactly those FPM setups: send
11357 + // the redirect, close the connection, then keep purging in the same
11358 + // process. Elsewhere (mod_php, CLI tests) fall back to purge-then-
11359 + // redirect as before.
11360 + $redirect = self::safe_purge_redirect( wp_get_referer() );
11361 + if ( function_exists( 'ignore_user_abort' ) ) {
11362 + ignore_user_abort( true );
11363 + }
11364 + if ( function_exists( 'set_time_limit' ) ) {
11365 + @set_time_limit( 300 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort under safe-mode-like restrictions.
11366 + }
11367 + if ( function_exists( 'fastcgi_finish_request' ) ) {
11368 + wp_safe_redirect( $redirect );
11369 + fastcgi_finish_request();
11370 + self::purge_type( $type );
11371 + exit;
11372 + }
11373 +
11374 + self::purge_type( $type );
11375 + wp_safe_redirect( $redirect );
2020 11376 exit;
11377 + }
11378 +
11379 + /**
11380 + * Resolve a safe redirect target for an admin-bar purge.
11381 + *
11382 + * The purge sends the admin back where they came from — but the referer
11383 + * can be a ONE-SHOT action URL (e.g. update.php?action=upload-plugin from
11384 + * installing a plugin zip, or any *.php?action=… that consumed a POST /
11385 + * temp upload). Redirecting there re-runs the action with nothing to act
11386 + * on, so WordPress dies — the classic "Please select a file" from
11387 + * File_Upload_Upgrader. Strip the transient action args so we return to a
11388 + * safe, re-GET-able view of the same page; fall back to the dashboard when
11389 + * there is no usable referer.
11390 + *
11391 + * @param string|false $referer Raw wp_get_referer() value.
11392 + * @return string Safe URL to redirect to.
11393 + */
11394 + public static function safe_purge_redirect( $referer ): string {
11395 + $referer = is_string( $referer ) ? $referer : '';
11396 + if ( '' === $referer ) {
11397 + return admin_url();
11398 + }
11399 +
11400 + // A referer that lands on an action-processing endpoint (update.php,
11401 + // update-core.php, plugin/theme install/upload flows) can't be safely
11402 + // re-requested — send them to the dashboard instead of replaying it.
11403 + $path = (string) wp_parse_url( $referer, PHP_URL_PATH );
11404 + if ( preg_match( '#/wp-admin/(update|update-core)\.php$#', $path ) ) {
11405 + return admin_url();
11406 + }
11407 +
11408 + // Otherwise keep them on the same page but drop the query args that
11409 + // would re-trigger a form action or upload on load.
11410 + return remove_query_arg(
11411 + array( 'action', 'action2', 'package', 'overwrite', 'plugin', 'theme', 'file', '_wpnonce', '_ajax_nonce' ),
11412 + $referer
11413 + );
2021 11414 }
2022 11415 }