PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.7
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 1.1.3 1.1.4 All 33 releases
← All changes | includes/class-cache.php +5985 -254 1.1.4 → 1.3.7 View file →
@@ -20,8 +20,21 @@
20 20 */
21 21 private static $buffer_level = null;
22 22
23 23 /**
24 + * Bytes freed by the current sweep, accumulated by sweep_delete().
25 + *
26 + * A counter rather than a return value because the two sweeps that free
27 + * the bytes — the flat glob loop and the recursive static walk — already
28 + * report a FILE count, and `wp xspeed purge` needs both numbers from a
29 + * single pass. Re-walking the tree to size it would double the I/O on
30 + * exactly the caches large enough for the number to matter.
31 + *
32 + * @var int
33 + */
34 + private static $sweep_bytes = 0;
35 +
36 + /**
24 37 * The `X-XSpeed-Cache` value decided for this request, and — when the
25 38 * decision was BYPASS — the slug of the gate that made it.
26 39 *
27 40 * Recorded as well as sent so unit tests (CLI SAPI, where header() is a
@@ -32,8 +45,26 @@
32 45 private static $status_header = '';
33 46 private static $bypass_reason = '';
34 47
35 48 /**
49 + * Edge/CDN headers decided for this request, after sanitising.
50 + *
51 + * Same reason as $status_header: header() cannot be observed from the CLI
52 + * SAPI, so the pairs we sent are recorded here too.
53 + *
54 + * @var array<string,string>
55 + */
56 + private static $edge_headers = array();
57 +
58 + /**
59 + * This entry's edge headers when they differ from the site-wide bake,
60 + * resolved once per store. Null until asked.
61 + *
62 + * @var array<string,string>|null
63 + */
64 + private static $per_entry_edge = null;
65 +
66 + /**
36 67 * Cache key whose write was deferred to shutdown because a render-time
37 68 * translation plugin's buffer wraps ours. Null on every ordinary request.
38 69 *
39 70 * @var string|null
@@ -61,8 +92,31 @@
61 92 * @var bool
62 93 */
63 94 private static $render_completed = false;
64 95
96 + /**
97 + * Hooks that get an argument-aware handler instead of a blanket purge.
98 + *
99 + * Each fires on an ordinary visitor action — an order, a review, a
100 + * registration — where purge_all() cannot see WHAT changed and so wiped
101 + * the whole cache on every one. They are re-bound further down to
102 + * handlers that inspect the payload first.
103 + *
104 + * Listed here so the generic invalidation loop skips them. It binds a
105 + * closure (to name the cause), and a closure cannot be unbound by the
106 + * remove_action() pairs below — binding one would leave the coarse purge
107 + * running alongside its replacement and silently undo #243.
108 + */
109 + private const TARGETED_INVALIDATION_HOOKS = array(
110 + 'save_post',
111 + 'before_delete_post',
112 + 'trashed_post',
113 + 'comment_post',
114 + 'wp_set_comment_status',
115 + 'user_register',
116 + 'profile_update',
117 + );
118 +
65 119 public function __construct() {
66 120 /**
67 121 * When the page-cache output buffer opens.
68 122 *
@@ -122,9 +176,9 @@
122 176 // rendered author bylines / term-archive pages. Without them, an edit
123 177 // left the matching endpoint (and archives) stale for the full TTL.
124 178 // (FBS-82408)
125 179 $invalidate_hooks = array(
126 - 'save_post', 'deleted_post', 'trashed_post',
180 + 'save_post', 'before_delete_post', 'trashed_post',
127 181 'comment_post', 'wp_set_comment_status',
128 182 'switch_theme', 'activated_plugin', 'deactivated_plugin',
129 183 // Users → /wp/v2/users + author archives.
130 184 'profile_update', 'user_register', 'deleted_user',
@@ -129,16 +183,194 @@
129 183 // Users → /wp/v2/users + author archives.
130 184 'profile_update', 'user_register', 'deleted_user',
131 185 // Terms → /wp/v2/{taxonomy} + term archives.
132 186 'created_term', 'edited_term', 'delete_term',
187 + // Menu structure changes (reorder, rename, assign to a location)
188 + // fire only here — the per-item `nav_menu_item` save_post does
189 + // not cover them. (#270 regression)
190 + 'wp_update_nav_menu',
133 191 );
134 192 foreach ( $invalidate_hooks as $hook ) {
135 - add_action( $hook, array( __CLASS__, 'purge_all' ) );
193 + // Name the hook in the cause rather than binding purge_all bare.
194 + // Bound bare, WordPress passes the action's own first argument
195 + // into $cause — a term id, a user id, a menu id — so the activity
196 + // feed read "Cache purged (12)" and told the user nothing about
197 + // what happened. (#270 QA round 2)
198 + //
199 + // The four hooks that get an argument-aware handler below
200 + // (save_post, comment_post, user_register, profile_update) are
201 + // deliberately NOT wired here: a closure cannot be unbound by
202 + // remove_action(), so binding one would leave the coarse purge in
203 + // place alongside its replacement and silently undo #243. Skipping
204 + // them is equivalent — each is re-added with its own handler, and
205 + // each of those names its own cause.
206 + if ( in_array( $hook, self::TARGETED_INVALIDATION_HOOKS, true ) ) {
207 + continue;
208 + }
209 + add_action(
210 + $hook,
211 + static function () use ( $hook ): void {
212 + self::purge_all(
213 + 'hook:' . $hook,
214 + null,
215 + self::invalidation_for_hook( $hook )
216 + );
217 + }
218 + );
136 219 add_action( $hook, array( 'XSpeed\\Minifier', 'purge_minified' ) );
137 220 }
138 221
222 + // Updating a plugin, theme or core changes the markup and the assets
223 + // a page is built from, but fires NONE of the hooks above: WordPress
224 + // does not deactivate and reactivate a plugin to update it, so
225 + // `activated_plugin` never runs and the cached HTML survives the
226 + // update untouched for the whole TTL — up to 7 days on the Aggressive
227 + // preset, 30 at the maximum.
228 + //
229 + // The stale copy is not merely old, it is wrong in a way the user
230 + // cannot see the cause of: they update a plugin to get a fix, the
231 + // cache keeps serving the pre-fix HTML, and the update looks like it
232 + // did nothing. Minified assets do regenerate on their own (their key
233 + // includes the source filemtime), which makes it worse rather than
234 + // better — the cached pages still link the PREVIOUS hashes.
235 + //
236 + // Purge unconditionally on any completed update. Scoping it to
237 + // "plugins that enqueue front-end assets" is not knowable here, and a
238 + // cold cache after an update is the cheaper mistake. (#269)
239 + add_action( 'upgrader_process_complete', array( __CLASS__, 'purge_after_upgrade' ), 10, 2 );
240 + // The replacement signal has to outlive OUR listener: add-ons read it
241 + // through upgrade_replaced_code() from their own priority-10 callbacks,
242 + // and consuming it inside purge_after_upgrade() meant whoever
243 + // registered second saw false. Cleared at the END of the dispatch
244 + // instead, once every listener has had its turn.
245 + //
246 + // Depth-counted, because this action NESTS. Core hangs
247 + // Language_Pack_Upgrader::async_upgrade() on it at priority 20
248 + // (wp-admin/includes/admin-filters.php), and that runs a whole
249 + // upgrader of its own, which fires this same action again. A flat
250 + // reset therefore fired while the OUTER dispatch was still running —
251 + // on any site with pending translations — and every listener after
252 + // priority 20 read the cleared signal as false. Which is the bug this
253 + // pair exists to fix, back again and harder to see. (#303)
254 + add_action( 'upgrader_process_complete', array( __CLASS__, 'note_upgrade_dispatch' ), PHP_INT_MIN );
255 + add_action( 'upgrader_process_complete', array( __CLASS__, 'forget_cleared_destination' ), PHP_INT_MAX );
256 + // WordPress labels an upload-and-replace as an INSTALL, so the action
257 + // alone cannot tell "added beside nothing" from "replaced live code".
258 + // This filter fires only when the upgrader removed an existing copy,
259 + // which is exactly the difference. Registered as a filter listener
260 + // that returns its input untouched. (#303)
261 + add_filter( 'upgrader_clear_destination', array( __CLASS__, 'note_cleared_destination' ), 10, 4 );
262 + // …but `upgrader_clear_destination` fires whenever the upgrader was
263 + // ASKED to clear, not only when it removed something:
264 + // WP_Upgrader::clear_destination() returns true early when the
265 + // destination does not exist. Looked at before the delete, while the
266 + // old copy is still on disk. (#303)
267 + //
268 + // PHP_INT_MAX, because the folder name is only final once every other
269 + // listener has had its turn. Update libraries that normalise
270 + // `plugin-1.2.3/` to `plugin/` (Plugin Update Checker, EDD Software
271 + // Licensing, GitHub-sourced zips) rename the extracted directory on
272 + // this same filter at priority 10 or later, and core derives the real
273 + // destination from the FILTERED source. Measured at 10, a genuine
274 + // replacement read as "nothing was there" and the stale cache stayed.
275 + // note_cleared_destination() cross-checks the folder core actually
276 + // cleared against the one measured here, for a renamer that runs
277 + // later still. (#407 QA)
278 + add_filter( 'upgrader_source_selection', array( __CLASS__, 'note_destination_state' ), PHP_INT_MAX, 4 );
279 + // Unattended auto-updates are the case that matters most here: they
280 + // land overnight with nobody around to purge by hand, which is the
281 + // exact scenario the stale cache goes undiagnosed in. WordPress fires
282 + // this INSTEAD of a per-item upgrader_process_complete for some
283 + // background runs. Its payload is a results array keyed by type
284 + // rather than a hook_extra, so it needs its own handler — passing it
285 + // to purge_after_upgrade() landed it in the unused $upgrader slot and
286 + // left $type empty, which read as "invalidating" and purged the whole
287 + // cache for a language-pack-only run. Matches what LiteSpeed binds.
288 + // (#298)
289 + add_action( 'automatic_updates_complete', array( __CLASS__, 'purge_after_auto_updates' ), 10, 1 );
290 + // …except the four hooks above that fire on ordinary visitor actions.
291 + // Attached bare, purge_all() can't see WHAT changed, so on a store
292 + // every order, every product review and every checkout
293 + // account-creation wiped 100% of the cache — all anonymous happy-path
294 + // actions, so the cache never reached steady state (#243). Measured:
295 + // 3 orders across 36 pageviews took the hit rate from 83% to 50% and
296 + // the average response from 23ms to 57ms.
297 + //
298 + // HPOS does NOT help: WooCommerce still writes a
299 + // `shop_order_placehold` row into wp_posts to reserve the order ID,
300 + // so save_post fires either way. The gate therefore keys on POST-TYPE
301 + // VIEWABILITY, not on storage mode — which fixes both modes at once,
302 + // and generalises to Flamingo (#229) and Tutor LMS (#231) too.
303 + remove_action( 'save_post', array( __CLASS__, 'purge_all' ) );
304 + remove_action( 'save_post', array( 'XSpeed\\Minifier', 'purge_minified' ) );
305 + add_action( 'save_post', array( __CLASS__, 'on_save_post' ), 10, 2 );
306 + add_action( 'before_delete_post', array( __CLASS__, 'on_post_removed' ), 10, 2 );
307 + add_action( 'trashed_post', array( __CLASS__, 'on_post_removed' ), 10, 2 );
308 + // wp_delete_post() hands an attachment to wp_delete_attachment() and
309 + // returns BEFORE before_delete_post fires, so deleting media reached
310 + // neither hook above. Attachment pages are public and media appears in
311 + // galleries, so that left cached pages showing a file that is gone.
312 + // (dev caught this via `deleted_post`, which this branch replaced.)
313 + add_action( 'delete_attachment', array( __CLASS__, 'on_post_removed' ), 10, 2 );
314 +
315 + remove_action( 'comment_post', array( __CLASS__, 'purge_all' ) );
316 + remove_action( 'comment_post', array( 'XSpeed\\Minifier', 'purge_minified' ) );
317 + add_action( 'comment_post', array( __CLASS__, 'on_comment_post' ), 10, 3 );
318 + add_action( 'wp_set_comment_status', array( __CLASS__, 'on_comment_status' ), 10, 2 );
319 +
320 + remove_action( 'user_register', array( __CLASS__, 'purge_all' ) );
321 + remove_action( 'user_register', array( 'XSpeed\\Minifier', 'purge_minified' ) );
322 + add_action( 'user_register', array( __CLASS__, 'on_user_change' ) );
323 +
324 + remove_action( 'profile_update', array( __CLASS__, 'purge_all' ) );
325 + remove_action( 'profile_update', array( 'XSpeed\\Minifier', 'purge_minified' ) );
326 + add_action( 'profile_update', array( __CLASS__, 'on_user_change' ) );
327 +
328 + // Product data lives in post meta and lookup tables, NOT in wp_posts,
329 + // so WC_Product_Data_Store_CPT::update() takes a direct $wpdb->update()
330 + // branch and save_post never fires. Anchoring invalidation on
331 + // save_post therefore missed 100% of commerce-relevant mutations: a
332 + // REST price change, wc_update_product_stock(), a CLI ->save(), and
333 + // every scheduled sale start/end left the product page, the shop and
334 + // the category archives serving the old price and stock for the full
335 + // lifetime — the store quoting one price and charging another (#242).
336 + //
337 + // This MUST ship with the gate above: once orders stop purging
338 + // everything, the accidental invalidation that was masking this
339 + // disappears, and an order that reduces stock would leave the product
340 + // page stale.
341 + if ( class_exists( 'WooCommerce' ) ) {
342 + foreach ( array( 'woocommerce_update_product', 'woocommerce_new_product' ) as $wc_hook ) {
343 + add_action( $wc_hook, array( __CLASS__, 'purge_product' ) );
344 + }
345 + // Direct stock writes bypass the CRUD entirely.
346 + add_action( 'woocommerce_product_set_stock', array( __CLASS__, 'purge_product_object' ) );
347 + add_action( 'woocommerce_variation_set_stock', array( __CLASS__, 'purge_product_object' ) );
348 + add_action( 'woocommerce_product_set_stock_status', array( __CLASS__, 'purge_product' ) );
349 + add_action( 'woocommerce_variation_set_stock_status', array( __CLASS__, 'purge_product' ) );
350 + }
351 +
139 352 add_action( 'update_option_xspeed_options', array( __CLASS__, 'on_settings_change' ), 10, 2 );
140 353
354 + // …and the same for every PER-MODULE option. The handler above only
355 + // ever watched the legacy `xspeed_options` blob, but every module has
356 + // since migrated to its own `xspeed_module_<slug>` option and no hook
357 + // followed — so changing Minify HTML, Lazy Load, Remove Query Strings
358 + // etc. left the cached HTML untouched until the TTL expired (24h by
359 + // default) and the feature read as broken. (#205)
360 + //
361 + // One central listener rather than a hook per module: it covers Pro
362 + // modules with no cross-repo change, and a new module can't forget to
363 + // wire it up.
364 + add_action( 'updated_option', array( __CLASS__, 'on_module_settings_change' ), 10, 1 );
365 + // `added_option` matters as much as `updated_option`: on a fresh install
366 + // a module's option doesn't exist yet, so the FIRST save of every panel
367 + // goes through add_option() and would otherwise skip the purge — the
368 + // original bug surviving one save per module. `deleted_option` covers a
369 + // reset-to-defaults, which changes rendered HTML just as much. (#205)
370 + add_action( 'added_option', array( __CLASS__, 'on_module_settings_change' ), 10, 1 );
371 + add_action( 'deleted_option', array( __CLASS__, 'on_module_settings_change' ), 10, 1 );
372 +
141 373 add_action( 'admin_bar_menu', array( $this, 'admin_bar_purge' ), 100 );
142 374 add_action( 'admin_post_xspeed_purge', array( $this, 'handle_admin_bar_purge' ) );
143 375 }
144 376
@@ -155,8 +387,86 @@
155 387 Minifier::purge_minified();
156 388 }
157 389
158 390 /**
391 + * Modules whose settings cannot change rendered HTML, so a write to them
392 + * doesn't warrant throwing away the page cache.
393 + *
394 + * The safe default is to purge: a module is listed here only when it is
395 + * clearly incapable of altering front-end output (diagnostics, the MCP
396 + * server, licensing/telemetry surfaces). When in doubt, leave it off the
397 + * list — a needless purge costs a re-render, a missed one makes the
398 + * feature look broken. (#205)
399 + *
400 + * @return string[] Module slugs.
401 + */
402 + public static function non_rendering_modules(): array {
403 + return (array) apply_filters(
404 + 'xspeed_non_rendering_modules',
405 + array(
406 + 'mcp', // AI endpoint — no front-end output.
407 + 'health', // diagnostics only.
408 + 'support', // support snapshot.
409 + 'score', // PageSpeed/GTmetrix runner.
410 + 'migration', // one-shot importer.
411 + 'settings', // import/export surface.
412 + 'cache-coverage', // read-only reporting.
413 + 'ai-privacy', // consent flags for AI surfaces.
414 + 'database', // DB cleanup schedule — no HTML impact.
415 + // Pro slugs — listed by name rather than by asking Pro, so
416 + // Free stays unaware of it. A Pro module absent here simply
417 + // purges, which is the safe default.
418 + 'license',
419 + 'pro_status',
420 + 'analytics',
421 + 'performance-health',
422 + 'recommendations',
423 + 'ai-provider',
424 + 'migration-pro',
425 + )
426 + );
427 + }
428 +
429 + /**
430 + * Purge when ANY module's settings option is written. (#205)
431 + *
432 + * Bound to `updated_option`, `added_option` and `deleted_option` — all three
433 + * fire for every option on the site, so the prefix test comes first and is
434 + * the cheap path for the ~99% of writes that aren't ours. All three pass the
435 + * option name first, which is why this can't hook purge_all() directly:
436 + * that takes $cause first, so every purge would be filed under a cause
437 + * literally named "xspeed_module_minify".
438 + *
439 + * @param string $option Option name that was just written or removed.
440 + */
441 + public static function on_module_settings_change( $option ): void {
442 + $option = (string) $option;
443 + $prefix = Settings_Manager::OPTION_PREFIX;
444 + if ( 0 !== strpos( $option, $prefix ) ) {
445 + return;
446 + }
447 +
448 + $slug = substr( $option, strlen( $prefix ) );
449 + if ( '' === $slug || in_array( $slug, self::non_rendering_modules(), true ) ) {
450 + return;
451 + }
452 +
453 + // Guard against re-entry: purge_all() and purge_minified() can write
454 + // options of their own (stats, timestamps), and a nested purge would
455 + // both waste work and risk recursing through this same hook.
456 + static $purging = false;
457 + if ( $purging ) {
458 + return;
459 + }
460 + $purging = true;
461 +
462 + self::purge_all( 'settings change' );
463 + Minifier::purge_minified();
464 +
465 + $purging = false;
466 + }
467 +
468 + /**
159 469 * Stamp the request's cache decision on the response.
160 470 *
161 471 * `X-XSpeed-Cache` was only ever written on the serve-from-cache paths,
162 472 * so a miss and a deliberate bypass both came back with no header at all
@@ -172,8 +482,17 @@
172 482 private static function mark( string $value, string $reason = '' ): void {
173 483 self::$status_header = $value;
174 484 self::$bypass_reason = $reason;
175 485
486 + // Every status, not just a HIT. A page we declined to cache is the
487 + // one an edge most needs telling about: it goes out naked today, and
488 + // a CDN that stores HTML by default keeps somebody's cart.
489 + //
490 + // Resolved before the headers_sent() guard so the decision is
491 + // recorded (and observable in tests) even on a request that can no
492 + // longer send headers; only the emission below is conditional.
493 + self::$edge_headers = self::edge_headers_for( self::edge_status( $value ), 'request', $reason );
494 +
176 495 if ( headers_sent() ) {
177 496 return;
178 497 }
179 498 header( 'X-XSpeed-Cache: ' . $value );
@@ -179,10 +498,26 @@
179 498 header( 'X-XSpeed-Cache: ' . $value );
180 499 if ( '' !== $reason && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
181 500 header( 'X-XSpeed-Reason: ' . $reason );
182 501 }
502 + foreach ( self::$edge_headers as $name => $val ) {
503 + header( $name . ': ' . $val );
504 + }
183 505 }
184 506
507 + /**
508 + * Normalize an `X-XSpeed-Cache` value to the vocabulary the edge seam
509 + * speaks.
510 + *
511 + * The header value carries which layer served the page (`HIT (php)`,
512 + * `HIT (nginx)`, `HIT (static)`); nothing deciding what to tell a CDN
513 + * cares, and making a caller match on three spellings of one outcome is
514 + * how a rule ends up applied on two paths out of three.
515 + */
516 + private static function edge_status( string $value ): string {
517 + return 0 === strpos( $value, 'HIT' ) ? 'HIT' : $value;
518 + }
519 +
185 520 /** Record a bypass gate and answer "don't cache" in one statement. */
186 521 private static function bypass( string $reason ): bool {
187 522 self::mark( 'BYPASS', $reason );
188 523 return false;
@@ -197,13 +532,671 @@
197 532 public static function bypass_reason(): string {
198 533 return self::$bypass_reason;
199 534 }
200 535
536 + /**
537 + * The edge/CDN pairs sent on this request ('' if none were).
538 + *
539 + * @return array<string,string>
540 + */
541 + public static function edge_headers(): array {
542 + return self::$edge_headers;
543 + }
544 +
545 + /**
546 + * Bypass gates that do NOT ask a cache in front of us to stand down.
547 + *
548 + * Every other slug does. The split is the reason this reads the gate
549 + * rather than the status: a bypass usually means "this response is
550 + * personal, or someone decided this page is never stored", and an edge
551 + * holding one of those does precisely what we refused to do. These two
552 + * mean something else.
553 + *
554 + * `cache-disabled` is the user switching OUR page cache off. Nothing
555 + * about the page became personal. Sending `no-store` on every page of a
556 + * site whose owner chose a different cache would make a local toggle a
557 + * site-wide side effect on infrastructure we do not own.
558 + *
559 + * `non-frontend` is admin, REST, cron and AJAX. Not ours to describe:
560 + * WordPress already nocaches admin, and a REST caller sets its own
561 + * policy.
562 + */
563 + private const HOLD_EXEMPT_BYPASS = array( 'cache-disabled', 'non-frontend' );
564 +
565 + /**
566 + * Bypass gates that describe the SHAPE of the request rather than the
567 + * visitor or the page.
568 + *
569 + * These still hold, but only once we have evidence of an edge — the same
570 + * bar a MISS has to clear. The difference matters because the default
571 + * excluded-URL list contains `/feed/`, the sitemap and `/wp-json/`, and
572 + * `query-param` catches `?lang=fr`, `?paged=2`, and every page of a
573 + * plain-permalink site.
574 + *
575 + * xSpeed refuses those because IT cannot key on a query string, not
576 + * because the response is private. A CDN keys on the full URL and caches
577 + * them correctly. Holding them unconditionally would have meant every
578 + * default install stopped its feed and sitemap being edge-cached — a
579 + * performance regression shipped to sites that never had a CDN in the
580 + * first place, in the name of protecting them from one.
581 + *
582 + * The gates left out of this list are about the visitor (`logged-in`,
583 + * `excluded-cookie`) or are somebody stating outright that this page is
584 + * never to be stored (`donotcachepage`, `post-excluded`, `filtered`).
585 + * Those hold whether or not we can see an edge.
586 + */
587 + private const REQUEST_SHAPE_BYPASS = array( 'query-param', 'non-get', 'user-agent' );
588 +
589 + /**
590 + * Default exclusions that are about the site's plumbing, not its content.
591 + *
592 + * `excluded-url` covers two unlike things. The default list carries
593 + * `/cart`, `/checkout`, `/my-account` and `/wp-login` — personal pages,
594 + * and the reason this feature exists. It also carries the entries below:
595 + * feeds, sitemaps, the REST root, the front controller. Those are public,
596 + * cacheable, and hammered by pollers; a CDN keys on the full URL and
597 + * serves them correctly, so telling it to stop is a cost with no benefit.
598 + *
599 + * Matched as exact strings against the stored list, never as patterns
600 + * against the path. Three bugs came out of doing it the other way round:
601 + * `strpos( $uri, '/feed' )` matched `/my-account/feedback/`, reading the
602 + * whole URI let `/cart/?utm_source=/feed/` disguise a cart as a feed, and
603 + * a bare `index.php` — which is in this list, and which every URL contains
604 + * on an "almost pretty" permalink site — made every page on such a site
605 + * look personal. Comparing the LIST ENTRY rather than the path cannot make
606 + * any of those mistakes, and it keeps a pattern the site owner added
607 + * themselves on the personal side where it belongs.
608 + */
609 + private const STRUCTURAL_EXCLUSIONS = array(
610 + '/wp-json/',
611 + '/xmlrpc.php',
612 + '~wp-.*\.php',
613 + '/feed/',
614 + 'index.php',
615 + '/robots.txt',
616 + // Both spellings, and no entry here is ever retired. This is a
617 + // RECOGNITION list, not a source of truth: it is matched against
618 + // whatever the site has STORED, and a site that saved its settings
619 + // before `~sitemap(_index)?\.xml` was widened to `sitemaps?` (for
620 + // SEOPress, which ships sitemaps.xml) still has the old string in
621 + // its option row. Dropping the old spelling when the default moved
622 + // would read every upgraded site's sitemap exclusion as somebody's
623 + // personal data and hold sitemaps off the CDN — the bug this whole
624 + // predicate exists to prevent, reintroduced by a rename.
625 + '~sitemaps?(_index)?\.xml',
626 + '~sitemap(_index)?\.xml',
627 + );
628 + /**
629 + * Header names no edge instruction may ever carry.
630 + *
631 + * These describe the transfer, not the caching policy, and one wrong
632 + * value from a settings field is a white screen rather than a missing
633 + * optimization.
634 + */
635 + private const NEVER_AN_EDGE_HEADER = array(
636 + 'content-length',
637 + 'content-encoding',
638 + 'content-type',
639 + 'transfer-encoding',
640 + 'set-cookie',
641 + 'location',
642 + 'x-xspeed-cache',
643 + 'x-xspeed-edge-hold',
644 + );
645 +
646 + /**
647 + * Reasons that hold the edge off even when we detected nothing in front.
648 + *
649 + * `none` confidence means no evidence of a proxy, which is not proof
650 + * there is none — a transparent proxy and a host page cache both leave
651 + * the request untouched. So the question is what a wasted header costs
652 + * against what a missed one does, and the answer differs by reason.
653 + *
654 + * These two are correctness failures. A cart page stored by something we
655 + * could not see is the defect this exists to fix, and a mobile-split page
656 + * served to the wrong device is a wrong page rather than a slow one.
657 + * Ninety bytes on a response that was never cacheable is a cheap premium.
658 + *
659 + * `miss` and `pending` are performance hedges, and a hedge against a
660 + * cache that does not exist is noise on every first render. Skipping them
661 + * has a second benefit: because per_entry_edge_headers() compares `store`
662 + * against `bake`, a `pending` hold that never fires leaves the two
663 + * agreeing, which keeps the page on the static tree.
664 + */
665 + private const HOLD_WITHOUT_EVIDENCE = array( 'bypass', 'mobile-split' );
666 +
667 + /**
668 + * Is a module still going to change this page after this response?
669 + *
670 + * Free itself never says yes — nothing in Free defers work past the
671 + * request. Minification and combining write their file and return its URL
672 + * inside the same render; the LCP preload is chosen by parsing the HTML
673 + * being sent. It is the question that matters to anything caching in
674 + * front of us, so Free asks it on their behalf and lets whoever owns the
675 + * deferred work answer.
676 + *
677 + * Answer TRUE while the work is outstanding for the page being served.
678 + * The cost of a false yes is one extra origin hit; the cost of a false no
679 + * is an un-optimized page pinned at the edge for the full lifetime, which
680 + * is the failure this exists to prevent — so when in doubt, say yes.
681 + *
682 + * Asked on a `request` only, and that boundary is the whole safety of it.
683 + *
684 + * A `bake` is generated once, in an admin or CLI request, and serves every
685 + * static HIT on the site; a per-page answer frozen into it would be wrong
686 + * for every other page.
687 + *
688 + * A `store` is worse, and cost a live site an afternoon. The pairs written
689 + * at store time go into the `.meta` sidecar, which the drop-in replays on
690 + * every later HIT — before plugins load, so nothing can re-ask this
691 + * question. A hold written there therefore outlives the state that caused
692 + * it, and the only thing that clears it is the page being stored again. On
693 + * a site where the deferred work never completes, every re-store re-pins
694 + * it, and the page is never edge-cacheable again. The symptom is a cache
695 + * HIT carrying `no-store` and `X-XSpeed-Edge-Hold: pending` on a page
696 + * whose deferred work finished long ago — the sidecar answering with
697 + * state nothing can re-ask.
698 + *
699 + * Holding the MISS is what this is for, and it is enough: that response is
700 + * the un-optimized one. The copy we then store is what an edge should
701 + * mirror, and when the work does land the module purges the page, which
702 + * reaches the edge. The purge is the correctness mechanism; this is only
703 + * meant to cover the single render before it.
704 + *
705 + * @param string $context `request`, `store` or `bake`.
706 + */
707 + public static function edge_optimization_pending( string $context = 'request' ): bool {
708 + if ( 'request' !== $context ) {
709 + return false;
710 + }
711 +
712 + /**
713 + * Filter: xspeed_edge_optimization_pending
714 + *
715 + * @param bool $pending Whether deferred work will still change this page.
716 + */
717 + return (bool) apply_filters( 'xspeed_edge_optimization_pending', false );
718 + }
719 +
720 + /**
721 + * Does mobile cache split this URL into two renders?
722 + *
723 + * With `mobile_separate` on, Free keys its cache on device and serves a
724 + * different page to a phone than to a desktop at the SAME url. No CDN
725 + * varies on User-Agent, so an edge holding one of those renders serves it
726 + * to everyone: whichever device asked first decides what the other sees,
727 + * for the whole lifetime. A wrong page, not a slow one.
728 + *
729 + * Read from the stored option rather than through Settings_Manager: this
730 + * is consulted from the serve path, where the module registry may not
731 + * have run.
732 + */
733 + private static function mobile_cache_splits_html(): bool {
734 + $stored = self::stored_cache_opts();
735 + return ! empty( $stored['mobile_separate'] );
736 + }
737 +
738 + /**
739 + * Why, if at all, a cache in front of us should refuse to store this.
740 + *
741 + * @param string $status `HIT`, `MISS` or `BYPASS`.
742 + * @param string $context `request`, `store` or `bake`.
743 + * @param string $bypass_reason The gate slug, for BYPASS only.
744 + * @return string '' or one of bypass|bypass-shape|miss|mobile-split|pending.
745 + */
746 + private static function edge_hold_reason( string $status, string $context, string $bypass_reason ): string {
747 + $reason = '';
748 +
749 + // The two exempt gates are answered before anything else, or a site
750 + // with Separate Mobile Cache on would keep holding after the page
751 + // cache was switched off — which is exactly the "a local toggle must
752 + // not become a site-wide side effect on infrastructure we do not own"
753 + // rule below, defeated by the ordering rather than by the logic.
754 + if ( 'BYPASS' === $status && in_array( $bypass_reason, self::HOLD_EXEMPT_BYPASS, true ) ) {
755 + /** This filter is documented below. */
756 + return (string) apply_filters( 'xspeed_edge_hold_reason', '', $status, $context, $bypass_reason );
757 + }
758 +
759 + // First, because it is the only reason true in every context: the
760 + // setting is a property of the site, not of one request, so it is the
761 + // one thing a baked artifact can honestly assert.
762 + //
763 + // It is also the only reason that holds a HIT — a response we DID
764 + // cache — and that is deliberate rather than an artefact of the
765 + // ordering. With mobile_separate on we key the cache by device and
766 + // serve different HTML to a phone than to a desktop at the same URL.
767 + // No CDN varies on User-Agent, so an edge holding one of those
768 + // renders serves it to everyone and whichever device asked first
769 + // decides what the other sees. Our copy is fine; theirs would be a
770 + // wrong page. The static path is switched off in this mode anyway
771 + // (static_rewrite_allowed()), so these hits come from the drop-in,
772 + // which carries the same baked answer.
773 + if ( self::mobile_cache_splits_html() ) {
774 + $reason = 'mobile-split';
775 + } elseif ( 'BYPASS' === $status ) {
776 + $shaped = in_array( $bypass_reason, array( 'excluded-url', 'query-param' ), true )
777 + ? ! self::path_is_a_personal_exclusion( $bypass_reason )
778 + : in_array( $bypass_reason, self::REQUEST_SHAPE_BYPASS, true );
779 + $reason = $shaped ? 'bypass-shape' : 'bypass';
780 + } elseif ( self::edge_optimization_pending( $context ) ) {
781 + $reason = 'pending';
782 + } elseif ( 'MISS' === $status ) {
783 + $reason = 'miss';
784 + }
785 +
786 + /**
787 + * Filter: xspeed_edge_hold_reason
788 + *
789 + * Return '' to veto a hold, or a reason string to force one.
790 + *
791 + * @param string $reason '' or bypass|bypass-shape|miss|mobile-split|pending.
792 + * @param string $status `HIT`, `MISS` or `BYPASS`.
793 + * @param string $context `request`, `store` or `bake`.
794 + * @param string $bypass_reason The gate slug, for BYPASS only.
795 + */
796 + return (string) apply_filters( 'xspeed_edge_hold_reason', $reason, $status, $context, $bypass_reason );
797 + }
798 +
799 + /**
800 + * Was this page excluded because it is personal, or because it is
801 + * plumbing we cannot key a cache entry on?
802 + *
803 + * Answers by removing the structural defaults from the site's own
804 + * exclusion list and asking whether anything is left that matches. So a
805 + * feed matches only `/feed/` and comes back false; `/my-account/feedback/`
806 + * matches `/my-account` and comes back true; and on an "almost pretty"
807 + * permalink site, where every path contains `index.php`, an ordinary page
808 + * matches nothing else and is correctly treated as public.
809 + *
810 + * The path only, never the query string — a visitor writes that, and
811 + * `/cart/?utm_source=/feed/` must not be able to talk a cart out of its
812 + * hold. It is also what `should_cache()` matches the list against.
813 + *
814 + * Asked for a `query-param` bypass too, because the query gate runs
815 + * BEFORE the URL gate, so `/cart/?add-to-cart=12` reports `query-param`
816 + * and never reaches `excluded-url` at all. Which gate fired first says
817 + * nothing about whose data is on the page.
818 + */
819 + private static function path_is_a_personal_exclusion( string $bypass_reason ): bool {
820 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- reading the path of the request being served; there is no form here to nonce.
821 + $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
822 + $path = (string) strtok( $uri, '?' );
823 + if ( '' === $path ) {
824 + return false;
825 + }
826 +
827 + // Through Settings_Manager, not the raw option, because the schema's
828 + // default IS the structural list and a fresh install has never
829 + // written the option. Read raw, every site that has not visited the
830 + // settings screen looks like a site with no exclusions at all, takes
831 + // the contradiction branch below, and reports its feeds as personal.
832 + //
833 + // Safe here where `mobile_cache_splits_html()` is not: we are only
834 + // ever called with a bypass reason, and those come from
835 + // `should_cache()`, which resolved the same settings through
836 + // `Settings_Manager::get()` to produce them.
837 + $opts = Settings_Manager::get( 'cache' );
838 + $excluded = is_array( $opts['excluded_urls'] ?? null ) ? $opts['excluded_urls'] : array();
839 + if ( array() === $excluded ) {
840 + // An `excluded-url` bypass with no exclusion list is a
841 + // contradiction — something excluded the request and the list
842 + // cannot say what — so assume personal, because a wasted header
843 + // costs a little origin traffic while a missing one serves
844 + // somebody's basket to a stranger. A `query-param` bypass with an
845 + // empty list is just an ordinary page carrying a parameter, and
846 + // says nothing about the path at all.
847 + return 'excluded-url' === $bypass_reason;
848 + }
849 +
850 + $personal = array_values(
851 + array_filter(
852 + $excluded,
853 + static fn ( $pattern ) => ! in_array( (string) $pattern, self::STRUCTURAL_EXCLUSIONS, true )
854 + )
855 + );
856 +
857 + return array() !== $personal && Glob_Matcher::any_match( $personal, $path );
858 + }
859 +
860 + /**
861 + * The edge/CDN headers to send on a response with this cache status.
862 + *
863 + * @param string $status `HIT`, `MISS` or `BYPASS`.
864 + * @param string $context `request` when resolved per request on the
865 + * PHP serve path, `store` when resolved for
866 + * one entry's sidecar, `bake` when resolved
867 + * once and frozen into an artifact.
868 + * @param string $bypass_reason The gate slug, for BYPASS only.
869 + * @return array<string,string>
870 + */
871 + public static function edge_headers_for( string $status, string $context = 'request', string $bypass_reason = '' ): array {
872 + $base = array();
873 + if ( 'HIT' === $status ) {
874 + /**
875 + * Filter: xspeed_edge_cache_headers
876 + *
877 + * Response headers to add to a cached HTML response. A HIT-only
878 + * contract: a lifetime is a promise that this copy is worth
879 + * keeping, and neither a first render nor a page we refused to
880 + * cache is one.
881 + *
882 + * The same filter feeds three regimes and `$context` says which.
883 + * On the PHP serve path it runs per request (`request`); at store
884 + * time it runs for one entry (`store`); when the drop-in or a
885 + * server rule is generated it runs once (`bake`) and the result
886 + * answers for every static HIT on the site. Anything per-page — a
887 + * post id in a cache tag, say — must be skipped under `bake`.
888 + *
889 + * @param array<string,string> $headers Header name => value.
890 + * @param string $status Always `HIT` here.
891 + * @param string $context `request`, `store` or `bake`.
892 + */
893 + $base = self::sanitize_edge_headers( (array) apply_filters( 'xspeed_edge_cache_headers', array(), 'HIT', $context ) );
894 + }
895 +
896 + $reason = self::edge_hold_reason( $status, $context, $bypass_reason );
897 + if ( '' === $reason ) {
898 + return $base;
899 + }
900 + $detected = Edge_Provider::detect( $context );
901 + if ( Edge_Provider::is_off( $detected ) ) {
902 + return $base;
903 + }
904 + if ( Edge_Provider::NONE === $detected['confidence']
905 + && ! in_array( $reason, self::HOLD_WITHOUT_EVIDENCE, true ) ) {
906 + return $base;
907 + }
908 +
909 + $hold = Edge_Provider::hold_headers( $detected['provider'] );
910 +
911 + /**
912 + * Filter: xspeed_edge_hold_headers
913 + *
914 + * The last word on what a hold INSTRUCTS. Runs before sanitising, so
915 + * a value that cannot be sent as a header is still dropped, and
916 + * before `X-XSpeed-Edge-Hold` is added, so it cannot rewrite the
917 + * reason xSpeed held the page for — that is a diagnosis, not an
918 + * instruction, and a forged one sends a reader after the wrong
919 + * module.
920 + *
921 + * @param array<string,string> $hold Header name => value.
922 + * @param array<string,string> $detected Provider, confidence, source.
923 + * @param string $reason Why the hold fired.
924 + * @param string $context `request`, `store` or `bake`.
925 + */
926 + $hold = (array) apply_filters( 'xspeed_edge_hold_headers', $hold, $detected, $reason, $context );
927 +
928 + // A hold replaces the lifetime rather than sitting beside it: the two
929 + // describe the same response and would contradict each other. The
930 + // cache tag survives, because a later purge still has to be able to
931 + // name whatever the edge picked up on its own terms.
932 + if ( isset( $base['Cache-Tag'] ) ) {
933 + $hold['Cache-Tag'] = $base['Cache-Tag'];
934 + }
935 +
936 + // Never argue with a stronger answer WordPress already gave. It sends
937 + // `no-store, private` of its own accord on a logged-in, 404 or
938 + // password-protected response, from WP::send_headers() — which runs
939 + // before template_redirect, so it is already on the wire by the time
940 + // we get here. Ours is the weaker statement of the two; replacing it
941 + // would be a downgrade dressed as a fix. Only meaningful per request:
942 + // a bake has no response to inspect.
943 + if ( 'request' === $context && isset( $hold['Cache-Control'] ) && self::cache_control_already_stronger() ) {
944 + unset( $hold['Cache-Control'] );
945 + }
946 +
947 + // A page we refused to cache must not carry a validator either. A
948 + // `Last-Modified` left on it invites a conditional request, and a
949 + // shared cache that gets a 304 back serves the copy it should not
950 + // have stored. Only on a bypass, and only per request: a MISS is
951 + // about to be stored by us, so its validator is ours to keep.
952 + if ( 'request' === $context && 'bypass' === $reason && ! headers_sent() ) {
953 + header_remove( 'Last-Modified' );
954 + }
955 +
956 + $hold = self::sanitize_edge_headers( $hold );
957 +
958 + // Name the reason in the hold set itself, rather than sending it
959 + // separately from mark().
960 + //
961 + // "Why is my page not being cached at the edge?" is the question this
962 + // answers, and mark() could only answer it on the PHP serve path. The
963 + // other emitters send whatever this function returns and never ran
964 + // mark() at all — so the responses hardest to explain went out
965 + // carrying `no-store` with nothing beside it to say why. Chiefly the
966 + // drop-in, which serves from the `.meta` sidecar written under
967 + // `store` and from the literal baked under `bake`, before plugins
968 + // load and with no way to re-ask (the symptom
969 + // edge_optimization_pending() describes above).
970 + //
971 + // The nginx and Apache blocks are a third path in principle and
972 + // almost never in practice: they are only installed when
973 + // static_rewrite_allowed() is true, and the one reason a stock site
974 + // can hold under `bake` is `mobile-split`, which is exactly what
975 + // makes that false. They will carry it where a site forces a hold
976 + // through `xspeed_edge_hold_reason`, and otherwise have no hold to
977 + // carry.
978 + //
979 + // Added AFTER sanitising and banned in NEVER_AN_EDGE_HEADER, so
980 + // neither of the two filters above can forge a reason or suppress the
981 + // real one.
982 + //
983 + // Reduced to the slug CHARACTER CLASS, not checked against the five
984 + // slugs: `xspeed_edge_hold_reason` is documented as able to force a
985 + // reason, and a site that forces its own deserves to see it. What is
986 + // not negotiable is the shape, because this value reaches an
987 + // .htaccess and an nginx conf as well as a response header — so no
988 + // CR/LF, no `$`, no `%`, no `\`, and a length a config file can hold.
989 + $slug = preg_replace( '/[^a-z0-9-]/', '', strtolower( $reason ) );
990 + if ( is_string( $slug ) && '' !== $slug ) {
991 + $hold['X-XSpeed-Edge-Hold'] = substr( $slug, 0, 32 );
992 + }
993 +
994 + return $hold;
995 + }
996 +
997 + /** Has something already sent a Cache-Control at least as strict as ours? */
998 + private static function cache_control_already_stronger(): bool {
999 + foreach ( headers_list() as $line ) {
1000 + if ( 0 !== stripos( $line, 'cache-control:' ) ) {
1001 + continue;
1002 + }
1003 + if ( preg_match( '/\b(?:no-store|private)\b/i', $line ) ) {
1004 + return true;
1005 + }
1006 + }
1007 +
1008 + return false;
1009 + }
1010 +
1011 + /**
1012 + * Edge headers that belong to THIS page rather than to every page.
1013 + *
1014 + * `edge_headers_for('HIT','bake')` is the answer frozen into the drop-in
1015 + * and the server rules: one set, serving the whole site. But the answer
1016 + * for one URL can legitimately differ — a page whose deferred work is
1017 + * still outstanding, say — and that answer has nowhere to live, because
1018 + * the baked set is all the fast paths know about.
1019 + *
1020 + * So ask again in a `store` context, with the request still in scope, and
1021 + * return the pairs only when they differ from the baked ones. Identical is
1022 + * the overwhelmingly common case and writes nothing: pages do not pay a
1023 + * sidecar for an answer the drop-in already has.
1024 + *
1025 + * Memoised because two callers ask within one store — the sidecar writer
1026 + * and the static-tree guard — and the filters behind it are not required
1027 + * to be cheap.
1028 + *
1029 + * @return array<string,string> Empty when this page needs no override.
1030 + */
1031 + private static function per_entry_edge_headers(): array {
1032 + if ( is_array( self::$per_entry_edge ) ) {
1033 + return self::$per_entry_edge;
1034 + }
1035 + $baked = self::edge_headers_for( 'HIT', 'bake' );
1036 + $request = self::edge_headers_for( 'HIT', 'store' );
1037 + self::$per_entry_edge = ( $request === $baked ) ? array() : $request;
1038 +
1039 + return self::$per_entry_edge;
1040 + }
1041 +
1042 + /**
1043 + * Render baked pairs as a PHP array literal for the drop-in.
1044 + *
1045 + * Single-quoted literals with quotes escaped, because the result is
1046 + * written into a PHP file that must still parse. Values reaching here
1047 + * have already been through sanitize_edge_headers(), so neither name nor
1048 + * value can carry a newline.
1049 + *
1050 + * @param array<string,string> $headers Name => value.
1051 + */
1052 + private static function edge_headers_literal( array $headers ): string {
1053 + if ( array() === $headers ) {
1054 + return 'array()';
1055 + }
1056 + // var_export(), not hand-rolled quoting. A single-quoted PHP string
1057 + // escapes BOTH `'` and `\\`, and escaping only the first is how a
1058 + // value ending in a backslash — `X-Foo: C:\path\` from the custom
1059 + // headers box — leaves the literal unterminated. That file is
1060 + // included on every request once WP_CACHE is on, so the result is a
1061 + // parse error on the front end AND in wp-admin, with no way back
1062 + // except deleting the file over SSH.
1063 + $parts = array();
1064 + foreach ( $headers as $name => $value ) {
1065 + $parts[] = var_export( (string) $name, true ) . ' => ' . var_export( (string) $value, true );
1066 + }
1067 +
1068 + return 'array( ' . implode( ', ', $parts ) . ' )';
1069 + }
1070 +
1071 + /**
1072 + * Quote a header value for an nginx / Apache directive.
1073 + *
1074 + * Both accept a double-quoted string with backslash escapes, and both
1075 + * refuse to load a config where the quoting is wrong — a mis-escaped
1076 + * value takes the whole vhost down, not just this header.
1077 + */
1078 + private static function quote_directive_value( string $value ): string {
1079 + return str_replace( array( '\\', '"' ), array( '\\\\', '\\"' ), $value );
1080 + }
1081 +
1082 + /**
1083 + * The same directive twice — once per name Apache can expose the
1084 + * rewrite's environment variable under.
1085 + *
1086 + * `RewriteRule ... [E=XSPEED_STATIC_HIT:1]` in a per-directory context is
1087 + * an INTERNAL REDIRECT: Apache re-enters the request with the substituted
1088 + * path, and every variable set on the first pass is renamed with a
1089 + * `REDIRECT_` prefix for the second. `env=XSPEED_STATIC_HIT` is evaluated
1090 + * on that second pass, where nothing answers to that name any more, so
1091 + * the directive never fires — dropping the headers from precisely the
1092 + * responses they exist for.
1093 + *
1094 + * It cannot be written once: `env=` takes a single name with no
1095 + * alternation, and `expr=` — which could express both — is not dependable
1096 + * on LiteSpeed, which reads this same block. So both are emitted; the one
1097 + * whose variable is unset on a given pass does nothing.
1098 + *
1099 + * @param string $directive The directive, without its `env=` clause.
1100 + * @return string[]
1101 + */
1102 + private static function static_hit_directives( string $directive ): array {
1103 + return array(
1104 + $directive . ' env=XSPEED_STATIC_HIT',
1105 + $directive . ' env=REDIRECT_XSPEED_STATIC_HIT',
1106 + );
1107 + }
1108 +
1109 + /**
1110 + * Keep only pairs that can be sent as a header verbatim.
1111 + *
1112 + * These values reach three different emitters — PHP's header(), an nginx
1113 + * `add_header` and an Apache `Header always set` — so a name with a space
1114 + * or a value carrying CR/LF is not merely malformed, it is a
1115 + * response-splitting vector in the first and a broken server config in
1116 + * the other two. Names must be token-shaped; values lose CR/LF and are
1117 + * dropped if nothing survives.
1118 + *
1119 + * @param array<mixed,mixed> $headers Raw pairs.
1120 + * @return array<string,string>
1121 + */
1122 + public static function sanitize_edge_headers( array $headers ): array {
1123 + $clean = array();
1124 + foreach ( $headers as $name => $value ) {
1125 + // Never let one of these through, whoever asked. They describe the
1126 + // transfer rather than the caching policy, and getting one wrong
1127 + // from a settings field is a white screen: `Content-Encoding: gzip`
1128 + // on an uncompressed body, a `Content-Length` that disagrees with
1129 + // the bytes. `X-XSpeed-Cache` is ours and a second copy would lie
1130 + // to whoever reads it.
1131 + if ( is_string( $name ) && in_array( strtolower( $name ), self::NEVER_AN_EDGE_HEADER, true ) ) {
1132 + continue;
1133 + }
1134 + // `\z`, not `$`: PCRE's `$` also matches immediately BEFORE a
1135 + // trailing newline, so "Cache-Tag\n" passes a `$` check and gets
1136 + // concatenated raw into the generated .htaccess — splitting one
1137 + // Header directive across two lines, which is a syntax error
1138 + // Apache reports as a 500 on every request while `httpd -t` stays
1139 + // green (.htaccess is parsed per request, not at load).
1140 + if ( ! is_string( $name ) || ! preg_match( '/^[A-Za-z0-9-]+\z/', $name ) ) {
1141 + continue;
1142 + }
1143 + if ( ! is_string( $value ) && ! is_numeric( $value ) ) {
1144 + continue;
1145 + }
1146 + $value = trim( str_replace( array( "\r", "\n" ), '', (string) $value ) );
1147 + if ( '' === $value ) {
1148 + continue;
1149 + }
1150 + // `$` is a variable reference in an nginx string and `%` is a
1151 + // format tag to Apache's mod_headers, which rejects an
1152 + // unrecognised one — in .htaccess that is a 500 on every request
1153 + // while `httpd -t` still reports OK, because .htaccess is parsed
1154 + // per request. `\` escapes the quote in the PHP literal baked into
1155 + // the drop-in. None of them can be escaped reliably in all three
1156 + // places at once, and nothing a cache reads needs any of them, so
1157 + // the value is dropped rather than mangled.
1158 + if ( preg_match( '/[$%\\\\]/', $value ) ) {
1159 + continue;
1160 + }
1161 + $clean[ $name ] = $value;
1162 + }
1163 +
1164 + return $clean;
1165 + }
1166 +
1167 + /**
1168 + * Bypass gates that describe THE VISITOR rather than THIS REQUEST.
1169 + *
1170 + * Only these may be recorded in the bypass cookie. A visitor-scoped
1171 + * verdict stays true for the visitor's next request — they are still
1172 + * logged in, still hold a cart cookie — so the web server can act on
1173 + * it without booting PHP.
1174 + *
1175 + * Every other gate describes the request in front of us: its method,
1176 + * its URL, its query string, the client's user agent. Persisting one
1177 + * of those pins a visitor to the uncached path over a property that
1178 + * was never theirs to begin with. (#218)
1179 + */
1180 + private const VISITOR_SCOPED_BYPASS = array( 'logged-in', 'excluded-cookie' );
1181 +
1182 + /**
1183 + * Whether $reason describes the visitor (persist it) or merely this
1184 + * request (don't).
1185 + *
1186 + * Split out as a pure function because it is the whole decision behind
1187 + * the bypass cookie, and the cookie write itself (setcookie()) can't be
1188 + * asserted in a unit test.
1189 + */
1190 + public static function bypass_is_visitor_scoped( string $reason ): bool {
1191 + return in_array( $reason, self::VISITOR_SCOPED_BYPASS, true );
1192 + }
1193 +
201 1194 public function maybe_start_cache() {
202 1195 if ( ! self::should_cache() ) {
203 1196 // PHP has just evaluated the FULL exclusion rule list — including
204 1197 // the `~regex` patterns the server config can't express — and
205 - // decided this visitor must not be served from cache. Record that
1198 + // decided this response must not be served from cache. Record that
206 1199 // verdict in the conventional bypass cookie so the web server can
207 1200 // enforce it on subsequent requests without starting PHP.
208 1201 //
209 1202 // This is what stops most settings changes from needing an nginx
@@ -208,9 +1201,22 @@
208 1201 //
209 1202 // This is what stops most settings changes from needing an nginx
210 1203 // reload: the config tests one fixed cookie name forever, and the
211 1204 // rule list behind it can change freely.
212 - self::sync_bypass_cookie( true );
1205 + //
1206 + // But ONLY when the verdict is about the visitor. A request-shape
1207 + // gate — `non-get` above all — says nothing about who is asking,
1208 + // and persisting it pinned that visitor to the uncached path for
1209 + // the rest of their session: one search-form POST, one comment,
1210 + // one `curl -I` from an uptime monitor, and every later GET
1211 + // bypassed. It could not self-heal either, because the bypass
1212 + // cookie is itself in excluded_cookies, so the next GET bypassed
1213 + // with `excluded-cookie` and landed right back here, where
1214 + // sync_bypass_cookie()'s no-change short-circuit left the cookie
1215 + // exactly where it was. (#218)
1216 + if ( self::bypass_is_visitor_scoped( self::bypass_reason() ) ) {
1217 + self::sync_bypass_cookie( true );
1218 + }
213 1219 return;
214 1220 }
215 1221
216 1222 // Cacheable: clear any stale bypass cookie, or a visitor who once
@@ -220,9 +1226,19 @@
220 1226 $key = self::cache_key();
221 1227 $file = self::cache_file_for( $key );
222 1228
223 1229 if ( file_exists( $file ) && ! self::is_expired( $file ) ) {
224 - Hit_Counter::record_hit();
1230 + // Symmetric with the miss branch below: a bot, scanner or one of
1231 + // xSpeed's own warm/benchmark requests that lands a HIT must not
1232 + // inflate the ratio either — excluding only their misses would
1233 + // shrink the denominator while their hits kept feeding the
1234 + // numerator, making the displayed ratio MORE optimistic than
1235 + // before the exclusion existed.
1236 + if ( self::miss_is_excluded() ) {
1237 + Hit_Counter::record_excluded();
1238 + } else {
1239 + Hit_Counter::record_hit();
1240 + }
225 1241 // Emit the HIT marker on THIS path too. The drop-in
226 1242 // (advanced-cache.php) sends "HIT (php)" and the nginx static
227 1243 // rewrite sends "HIT (nginx)", but this template_redirect
228 1244 // serve path — the one that runs when the drop-in isn't loaded
@@ -324,8 +1340,21 @@
324 1340 self::$buffer_level = null;
325 1341 }
326 1342
327 1343 /**
1344 + * Are we buffering this request?
1345 + *
1346 + * Asked by Css_Combine_Buffer, which needs the finished HTML but must not
1347 + * open a second buffer when this one is already going to hand it the page
1348 + * through `xspeed_cache_final_html`. False here means the request is not
1349 + * cacheable — cache off, excluded URL, logged in — and the combiner has to
1350 + * provide its own buffer or it silently stops working. (#195)
1351 + */
1352 + public static function is_buffering(): bool {
1353 + return null !== self::$buffer_level;
1354 + }
1355 +
1356 + /**
328 1357 * Is a render-time translation plugin going to wrap our output buffer?
329 1358 *
330 1359 * TranslatePress opens its translation buffer on `init` priority 0. We
331 1360 * open ours on `template_redirect`, which runs much later, so ours nests
@@ -427,12 +1456,20 @@
427 1456 $minify_opts = Settings_Manager::get( 'minify' );
428 1457 if ( ! empty( $minify_opts['minify_html'] ) ) {
429 1458 $full = Minifier::minify_html( $full );
430 1459 }
1460 + $full = self::signed( $full );
431 1461
432 - if ( ! file_exists( XSPEED_CACHE_DIR ) ) {
433 - wp_mkdir_p( XSPEED_CACHE_DIR );
434 - self::write_silence( XSPEED_CACHE_DIR );
1462 + // Per-site directory: on multisite every blog shares this tree, so
1463 + // entries are bucketed by host to keep one site's purge from
1464 + // sweeping the whole network. (#6)
1465 + self::ensure_host_dir();
1466 +
1467 + // Never author a cache entry from a request that carried a query
1468 + // string: cache_key() files it under the BARE url, so the params'
1469 + // render would be served to every clean-URL visitor (#241).
1470 + if ( self::query_string_blocks_write() ) {
1471 + return;
435 1472 }
436 1473
437 1474 $file = self::cache_file_for( $key );
438 1475 // 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.
@@ -440,14 +1477,21 @@
440 1477
441 1478 /** This action is documented in includes/class-cache.php */
442 1479 do_action( 'xspeed_flat_file_written', $file, $full );
443 1480
444 - self::write_meta( $key );
1481 + self::write_meta( $key, $full );
445 1482
446 1483 // Static tree too, under the same gates finalize_buffer() applies —
447 1484 // otherwise deferring the write would silently cost translated pages
448 1485 // the web-server fast path and leave them on the slower drop-in.
449 - if ( self::static_rewrite_allowed() && self::response_is_plain_html() ) {
1486 + // The static tree cannot replay a sidecar. A file served straight by
1487 + // the web server carries the headers baked into the rule that serves
1488 + // the whole site — the very answer this entry exists because it
1489 + // disagreed with. Same reasoning as the status and content-type
1490 + // cases: what the fast path cannot replay belongs on the drop-in path.
1491 + if ( self::static_rewrite_allowed()
1492 + && self::response_is_plain_html()
1493 + && array() === self::per_entry_edge_headers() ) {
450 1494 self::store_static( $full );
451 1495 }
452 1496 }
453 1497
@@ -454,10 +1498,17 @@
454 1498 public static function should_cache() {
455 1499 // Reset first: a single request only reaches this once (the sole
456 1500 // caller is maybe_start_cache()), but tests and any future caller
457 1501 // must never inherit the previous request's verdict.
458 - self::$status_header = '';
459 - self::$bypass_reason = '';
1502 + self::$status_header = '';
1503 + self::$bypass_reason = '';
1504 + self::$edge_headers = array();
1505 + self::$per_entry_edge = null;
1506 + // Under PHP-FPM a process serves one request and this is moot. Under
1507 + // a persistent worker runtime it is not: without it, an answer
1508 + // resolved from one visitor's forgeable headers would be reused for
1509 + // every later request the worker handles.
1510 + Edge_Provider::forget();
460 1511
461 1512 $opts = Settings::get();
462 1513 if ( empty( $opts['cache_enabled'] ) ) {
463 1514 return self::bypass( 'cache-disabled' );
@@ -508,8 +1559,20 @@
508 1559 * @param bool $cache_feed Whether to cache this feed request.
509 1560 */
510 1561 $cache_feed = $is_feed_request && (bool) apply_filters( 'xspeed_should_cache_feed', false );
511 1562
1563 + // WordPress's virtual robots.txt (and virtual favicon) are not HTML:
1564 + // caching one runs it through the whole HTML pipeline, which stamped
1565 + // the footer comment onto text/plain and let HTML minification
1566 + // collapse robots.txt to a single line — a line-based format, so
1567 + // every directive after the first was lost and crawlers read an
1568 + // invalid file. No opt-in filter here: there is no correct way to
1569 + // treat these as pages. (Reported live on a customer site.)
1570 + if ( ( function_exists( 'is_robots' ) && is_robots() )
1571 + || ( function_exists( 'is_favicon' ) && is_favicon() ) ) {
1572 + return self::bypass( 'non-html' );
1573 + }
1574 +
512 1575 // Query string handling: anything OUTSIDE the ignored-params
513 1576 // allow-list (utm_*, fbclid, gclid by default) means a unique
514 1577 // request that we don't want to share with the canonical cache
515 1578 // entry. Skip cache rather than poison the key.
@@ -558,8 +1621,19 @@
558 1621 // rules); presence of any matching cookie name skips cache.
559 1622 $excluded_cookies = is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array();
560 1623 if ( ! empty( $excluded_cookies ) && ! empty( $_COOKIE ) ) {
561 1624 foreach ( array_keys( $_COOKIE ) as $cookie_name ) {
1625 + // Our own bypass cookie is a RECORD of a previous verdict, not
1626 + // evidence about this visitor, so it never gets a vote here.
1627 + // Letting it match made the verdict self-confirming: once set,
1628 + // it produced `excluded-cookie` forever, which re-set it, and
1629 + // no later request could ever re-evaluate the visitor on the
1630 + // rules that actually describe them. The web server still acts
1631 + // on the cookie without booting PHP; when PHP does boot it is
1632 + // authoritative and re-decides from scratch. (#218)
1633 + if ( Server_Rules::BYPASS_COOKIE === $cookie_name ) {
1634 + continue;
1635 + }
562 1636 if ( Glob_Matcher::any_match( $excluded_cookies, (string) $cookie_name ) ) {
563 1637 return self::bypass( 'excluded-cookie' );
564 1638 }
565 1639 }
@@ -656,8 +1730,75 @@
656 1730 * search_term() / cache_key()) so different searches stay distinct.
657 1731 * The xspeed-pro search cache flips the filter; Free never caches
658 1732 * search results on its own.
659 1733 */
1734 + /**
1735 + * Whether this response was rendered for a query string and therefore
1736 + * must not be STORED under the bare-URL key.
1737 + *
1738 + * should_cache() lets a request through when every key is on the
1739 + * `ignored_query_params` allow-list, and cache_key() then drops the
1740 + * query string so `/post` and `/post?utm_source=x` share one entry.
1741 + * Sharing on READ is the point of the allow-list and stays. Sharing on
1742 + * WRITE is a cache-poisoning vector: the response was rendered *with*
1743 + * those params, and WordPress reflects REQUEST_URI into form actions,
1744 + * share links, canonical helpers and plugin smart tags. One anonymous
1745 + * GET to a cold URL therefore freezes an attacker-chosen variant under
1746 + * the clean URL's key, served for the whole TTL by the drop-in and by
1747 + * the web server — neither of which runs these checks (issue #241).
1748 + *
1749 + * The allow-list keeps its benefit: a visitor arriving on
1750 + * `?utm_source=…` is still SERVED the canonical cached entry. Only the
1751 + * write is skipped, so the entry is authored by a clean request.
1752 + *
1753 + * This is the same reasoning as the `should_cache_search()` guard in
1754 + * store_static() (#191), generalised to the allow-listed params.
1755 + */
1756 + public static function request_has_query_string(): bool {
1757 + $query = isset( $_SERVER['QUERY_STRING'] )
1758 + ? (string) wp_unslash( $_SERVER['QUERY_STRING'] ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- only tested for emptiness; never echoed, stored or used as a path.
1759 + : '';
1760 +
1761 + return '' !== trim( $query );
1762 + }
1763 +
1764 + /**
1765 + * Would authoring a cache entry from THIS request file a query-string
1766 + * render under the bare URL?
1767 + *
1768 + * The one predicate both write sites ask, so they cannot drift.
1769 + *
1770 + * Two shapes are exempt because cache_key() does NOT drop their query —
1771 + * it folds the distinguishing part into the key, so each variant gets
1772 + * its own entry and none is filed under the bare URL:
1773 + *
1774 + * - searches, keyed by `|s=<term>` (#191)
1775 + * - feeds, keyed by `|feed=<type>` — `/?feed=rss2` is the ONLY feed URL
1776 + * core generates on plain permalinks, so treating it as poisonable
1777 + * made feed caching a no-op on exactly the sites that need it
1778 + *
1779 + * @return bool True when the write must be skipped.
1780 + */
1781 + public static function query_string_blocks_write(): bool {
1782 + if ( ! self::request_has_query_string() ) {
1783 + return false;
1784 + }
1785 +
1786 + if ( self::should_cache_search() ) {
1787 + return false;
1788 + }
1789 +
1790 + // Feed caching is opt-in, via the same filter should_cache() reads
1791 + // to admit the feed params in the first place.
1792 + if ( function_exists( 'is_feed' ) && is_feed()
1793 + && (bool) apply_filters( 'xspeed_should_cache_feed', false )
1794 + ) {
1795 + return false;
1796 + }
1797 +
1798 + return true;
1799 + }
1800 +
660 1801 public static function should_cache_search(): bool {
661 1802 if ( ! function_exists( 'is_search' ) || ! is_search() ) {
662 1803 return false;
663 1804 }
@@ -697,15 +1838,40 @@
697 1838 }
698 1839
699 1840 /**
700 1841 * Is this query-string key on the ignored-params allow-list? Supports
701 - * trailing-star globs (`utm_*` matches `utm_source`, `utm_medium`,
702 - * etc.) so users don't have to enumerate every UTM variant.
1842 + * globs (`utm_*` matches `utm_source`, `utm_medium`, etc.) so users
1843 + * don't have to enumerate every UTM variant, and `~regex`.
1844 + *
1845 + * Matching is whole-name, not "contains" — a param name is an
1846 + * identifier, not a path. Under the old contains match the shipped
1847 + * default `ref` also swallowed `preference`, `product_ref` and
1848 + * `referrer`: those params were dropped from the cache key, so
1849 + * `/shop?preference=1` was served — and, on a cold entry, WRITTEN as —
1850 + * `/shop`. Same for `_ga` vs `_gallery`, and for the unanchored
1851 + * `~utm_…` default vs `my_utm_source`. A param name that is genuinely
1852 + * unknown now bypasses the cache, which is the safe direction.
703 1853 */
704 1854 private static function query_key_is_ignored( string $key, array $ignored ): bool {
705 - return Glob_Matcher::any_match( $ignored, $key );
1855 + if ( in_array( $key, self::NEVER_IGNORED_QUERY_PARAMS, true ) ) {
1856 + return false;
1857 + }
1858 + return Glob_Matcher::any_match_name( $ignored, $key );
706 1859 }
707 1860
1861 + /**
1862 + * Query params no ignored-params entry can match, glob or regex.
1863 + *
1864 + * A measuring request asks for the page as it is before optimisation
1865 + * (`xspeed_css=off`), with a one-time value (`xspeed_nc`) so no cache
1866 + * has a copy of it. The answer must be rendered for that request. A
1867 + * list entry such as `xspeed_*` or `*` would make both params
1868 + * decoration: the drop-in would serve the canonical, already-optimised
1869 + * entry before any plugin loads, and the measurement would describe
1870 + * the optimised page instead of the source.
1871 + */
1872 + public const NEVER_IGNORED_QUERY_PARAMS = array( 'xspeed_css', 'xspeed_nc' );
1873 +
708 1874 public static function cache_key() {
709 1875 $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : 'default';
710 1876
711 1877 // Cacheable 404s share ONE generic per-host entry — keying them by
@@ -779,10 +1945,301 @@
779 1945 }
780 1946 return (bool) preg_match( '/(Mobile|Android|Silk\/|Kindle|BlackBerry|Opera Mini|Opera Mobi)/i', $ua );
781 1947 }
782 1948
1949 + /**
1950 + * Filesystem-safe directory name for a host, or '' when unusable.
1951 + *
1952 + * The charset MUST match the static tree (store_static()) and the
1953 + * drop-in's own copy, or the paths disagree about where an entry lives.
1954 + * The colon of `host:port` is stripped: it is legal in a Host header but
1955 + * not portable in a path.
1956 + *
1957 + * @param string $host Raw host, e.g. from HTTP_HOST.
1958 + * @return string Safe directory segment, or '' if nothing usable remains.
1959 + */
1960 + /**
1961 + * The host segment of the STATIC tree — `xspeed-static/<host>/…`, which
1962 + * the web server resolves without PHP.
1963 + *
1964 + * Different from host_dir(): here the port is folded INTO the segment
1965 + * (`localhost:8080` → `localhost8080`) rather than dropped, because the
1966 + * generated server rules have to reproduce this from their own variables
1967 + * and nginx's `$host` has no port to drop — see the `$xspeed_host`
1968 + * derivation in nginx_snippet(). Shared by the write and the purge so the
1969 + * two can't drift; when they did, purging a page on a ported host deleted
1970 + * nothing and the stale copy kept being served by the rewrite.
1971 + */
1972 + public static function static_host_dir( string $host ): string {
1973 + return (string) preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host );
1974 + }
1975 +
1976 + public static function host_dir( string $host ): string {
1977 + $host = str_replace( "\0", '', $host );
1978 + // Drop the port BEFORE filtering, or `example.com:8080` collapses to
1979 + // `example.com8080` — which both loses the boundary and could collide
1980 + // with a real host of that name.
1981 + $colon = strpos( $host, ':' );
1982 + if ( false !== $colon ) {
1983 + $host = substr( $host, 0, $colon );
1984 + }
1985 + $host = preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host );
1986 + // Collapse any run of dots so no traversal sequence can survive the
1987 + // charset filter (`a/../b` would otherwise reduce to `a..b`).
1988 + $host = preg_replace( '/\.{2,}/', '.', (string) $host );
1989 + $host = trim( (string) $host, '.-' );
1990 + return '' === $host ? '' : $host;
1991 + }
1992 +
1993 + /**
1994 + * The per-site bucket a cache entry belongs to: `<host>` on a single
1995 + * site, `<host>/<path-prefix>` for a subdirectory multisite blog.
1996 + *
1997 + * On multisite every blog shares one cache directory, and a flat md5
1998 + * filename carries no clue which site wrote it — so purging one subsite
1999 + * swept the whole network cold. (#6)
2000 + *
2001 + * Host alone is NOT enough: a subdirectory network (the common layout)
2002 + * puts every blog on the same host, so `example.com/` and
2003 + * `example.com/siteb/` would share a bucket and keep purging each other.
2004 + * The path prefix is what separates them, and it is derivable from the
2005 + * REQUEST_URI alone — which matters because the drop-in must compute
2006 + * this identical value before WordPress (and get_blog_details()) exist.
2007 + *
2008 + * Subdomain and domain-mapped networks differ by host already, so they
2009 + * get a bare host bucket and are unaffected.
2010 + *
2011 + * @param string $host Raw host.
2012 + * @param string $uri Raw REQUEST_URI (query string is ignored).
2013 + * @return string Bucket path, always non-empty.
2014 + */
2015 + public static function site_bucket( string $host, string $uri ): string {
2016 + $dir = self::host_dir( $host );
2017 + if ( '' === $dir ) {
2018 + $dir = 'default';
2019 + }
2020 +
2021 + $prefix = self::site_path_prefix();
2022 + return '' === $prefix ? $dir : $dir . '/' . $prefix;
2023 + }
2024 +
2025 + /**
2026 + * The current blog's path prefix as a single safe segment ('' for the
2027 + * root blog or a non-multisite install). `/siteb/` becomes `siteb`;
2028 + * a nested `/a/b/` becomes `a-b` so the bucket stays one level deep.
2029 + *
2030 + * Written to a sidecar for the drop-in by sync_site_paths().
2031 + */
2032 + public static function site_path_prefix(): string {
2033 + if ( ! function_exists( 'is_multisite' ) || ! is_multisite() ) {
2034 + return '';
2035 + }
2036 + if ( function_exists( 'is_subdomain_install' ) && is_subdomain_install() ) {
2037 + return ''; // Hosts already differ; no prefix needed.
2038 + }
2039 + $path = function_exists( 'get_blog_details' ) ? (string) get_blog_details()->path : '/';
2040 + return self::path_prefix_segment( $path );
2041 + }
2042 +
2043 + /**
2044 + * The bucket an arbitrary URL's cache entry lives in.
2045 + *
2046 + * `site_bucket()` answers for the CURRENT request; this answers for a URL
2047 + * that may belong to another blog entirely — which is what a per-URL purge
2048 + * is usually doing (WP-CLI, cron, the MCP tool, a network-admin action).
2049 + *
2050 + * The blog is resolved from the URL itself: on a subdirectory network
2051 + * `get_blog_details()` is asked which blog owns `<host><path>`, and its
2052 + * registered path becomes the prefix. Deriving the prefix from the URL's
2053 + * first path segment directly would be wrong — `/shop/` on the main blog
2054 + * is a page, not a subsite, and would send the purge into a bucket that
2055 + * does not exist. (QA B2 on #166)
2056 + *
2057 + * @param string $host Host of the URL being purged.
2058 + * @param string $path Path of the URL being purged.
2059 + * @return string Bucket path, always non-empty.
2060 + */
2061 + public static function bucket_for_url( string $host, string $path ): string {
2062 + $dir = self::host_dir( $host );
2063 + if ( '' === $dir ) {
2064 + $dir = 'default';
2065 + }
2066 +
2067 + if ( ! function_exists( 'is_multisite' ) || ! is_multisite() ) {
2068 + return $dir;
2069 + }
2070 + if ( function_exists( 'is_subdomain_install' ) && is_subdomain_install() ) {
2071 + return $dir; // Hosts already differ; no prefix.
2072 + }
2073 + if ( ! function_exists( 'get_blog_details' ) ) {
2074 + return $dir;
2075 + }
2076 +
2077 + // Longest registered blog path that prefixes this URL wins, so
2078 + // `/one/2026/post/` resolves to blog `/one/` and not to the root blog.
2079 + $blog = self::blog_for_path( $host, $path );
2080 + if ( null === $blog ) {
2081 + return $dir;
2082 + }
2083 + $prefix = self::path_prefix_segment( (string) $blog );
2084 + return '' === $prefix ? $dir : $dir . '/' . $prefix;
2085 + }
2086 +
2087 + /**
2088 + * The registered path of the blog that owns `<host><path>`, or null.
2089 + *
2090 + * Uses get_blog_details() with a domain/path pair rather than scanning
2091 + * every blog, so a large network costs one lookup per candidate segment
2092 + * instead of a full table read.
2093 + */
2094 + private static function blog_for_path( string $host, string $path ): ?string {
2095 + $segments = array_values( array_filter( explode( '/', trim( $path, '/' ) ) ) );
2096 +
2097 + // Try the longest candidate first: /a/b/ before /a/ before /.
2098 + for ( $take = min( count( $segments ), 2 ); $take >= 1; $take-- ) {
2099 + $candidate = '/' . implode( '/', array_slice( $segments, 0, $take ) ) . '/';
2100 + $details = get_blog_details(
2101 + array(
2102 + 'domain' => $host,
2103 + 'path' => $candidate,
2104 + ),
2105 + false
2106 + );
2107 + if ( $details && ! empty( $details->path ) ) {
2108 + return (string) $details->path;
2109 + }
2110 + }
2111 + return null;
2112 + }
2113 +
2114 + /**
2115 + * Normalise a blog path ('/', '/siteb/', '/a/b/') into a single
2116 + * filesystem-safe segment. Shared with the drop-in's copy.
2117 + */
2118 + public static function path_prefix_segment( string $path ): string {
2119 + $path = trim( str_replace( "\0", '', $path ), '/' );
2120 + if ( '' === $path ) {
2121 + return '';
2122 + }
2123 + $path = preg_replace( '/[^a-zA-Z0-9._\-\/]/', '', $path );
2124 + $path = str_replace( '/', '-', (string) $path );
2125 + return trim( (string) $path, '.-' );
2126 + }
2127 +
2128 + /**
2129 + * The current blog's path as the static tree stores it — real slashes
2130 + * preserved, because that tree mirrors the URL
2131 + * (`xspeed-static/{host}{request_uri}/index.html`) rather than using a
2132 + * single flattened segment. '' for a root blog / single site.
2133 + */
2134 + public static function site_path_raw(): string {
2135 + if ( ! function_exists( 'is_multisite' ) || ! is_multisite() ) {
2136 + return '';
2137 + }
2138 + if ( function_exists( 'is_subdomain_install' ) && is_subdomain_install() ) {
2139 + return '';
2140 + }
2141 + $path = function_exists( 'get_blog_details' ) ? (string) get_blog_details()->path : '/';
2142 + $path = trim( str_replace( "\0", '', $path ), '/' );
2143 + if ( '' === $path ) {
2144 + return '';
2145 + }
2146 + $path = preg_replace( '#[^a-zA-Z0-9._\-/]#', '', $path );
2147 + return trim( (string) $path, '/' );
2148 + }
2149 +
2150 + /**
2151 + * Static-tree root for the current site: `<host>` plus the blog's real
2152 + * path. Mirrors store_static()'s layout so a scoped purge deletes
2153 + * exactly this blog's pages.
2154 + */
2155 + public static function current_static_scope(): string {
2156 + // Same switch_to_blog() caveat as current_host_dir() — see current_host().
2157 + // Keep the port folded into the segment exactly as store_static() does.
2158 + $dir = self::static_host_dir( self::current_host() );
2159 + if ( '' === $dir ) {
2160 + $dir = 'default';
2161 + }
2162 + $path = self::site_path_raw();
2163 + return '' === $path ? $dir : $dir . '/' . $path;
2164 + }
2165 +
2166 + /**
2167 + * The bucket for the CURRENT request. Never empty, so an entry is never
2168 + * written to the tree root (which is what the unscoped sweeps used to
2169 + * delete indiscriminately).
2170 + */
2171 + public static function current_host_dir(): string {
2172 + $host = self::current_host();
2173 + $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/';
2174 + return self::site_bucket( $host, $uri );
2175 + }
2176 +
2177 + /**
2178 + * The host the CURRENT blog is served from.
2179 + *
2180 + * Deliberately NOT just $_SERVER['HTTP_HOST']: inside a
2181 + * switch_to_blog() the request header still names whichever site is
2182 + * serving the admin screen, while the cache entries we want belong to
2183 + * the switched-to blog. On a subdomain network the host IS the bucket,
2184 + * so reading the header there would make Pro's per-site "purge this
2185 + * site" button clear the network admin's own cache instead — the very
2186 + * bug this scoping exists to fix, surviving in one topology.
2187 + *
2188 + * get_blog_details() follows the switch, so prefer it whenever we are
2189 + * on multisite, and fall back to the request header otherwise.
2190 + */
2191 + public static function current_host(): string {
2192 + if ( function_exists( 'is_multisite' ) && is_multisite() && function_exists( 'get_blog_details' ) ) {
2193 + $details = get_blog_details();
2194 + if ( $details && ! empty( $details->domain ) ) {
2195 + return (string) $details->domain;
2196 + }
2197 + }
2198 +
2199 + if ( isset( $_SERVER['HTTP_HOST'] ) ) {
2200 + return sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) );
2201 + }
2202 +
2203 + /*
2204 + * No request header — WP-CLI, or WP-Cron driven by system cron.
2205 + *
2206 + * Returning '' here made the bucket resolve to the literal `default`
2207 + * while HTTP requests were writing to `<host>/`, so a scheduled purge
2208 + * swept an empty directory and reported success, and get_stats()
2209 + * reported 0 cached pages on a site with a full cache. That is the
2210 + * normal setup on any host running DISABLE_WP_CRON, which is most of
2211 + * them. Fall back to the site's own registered host. (QA D4 on #166)
2212 + */
2213 + if ( function_exists( 'home_url' ) ) {
2214 + $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.
2215 + if ( is_array( $parts ) && ! empty( $parts['host'] ) ) {
2216 + return (string) $parts['host'];
2217 + }
2218 + }
2219 +
2220 + return '';
2221 + }
2222 +
2223 + /**
2224 + * Ensure the current site's cache directory exists, with the silence
2225 + * index in both it and the shared root. Returns the directory.
2226 + */
2227 + public static function ensure_host_dir(): string {
2228 + $dir = XSPEED_CACHE_DIR . '/' . self::current_host_dir();
2229 + if ( ! file_exists( XSPEED_CACHE_DIR ) ) {
2230 + wp_mkdir_p( XSPEED_CACHE_DIR );
2231 + self::write_silence( XSPEED_CACHE_DIR );
2232 + }
2233 + if ( ! file_exists( $dir ) ) {
2234 + wp_mkdir_p( $dir );
2235 + self::write_silence( $dir );
2236 + }
2237 + return $dir;
2238 + }
2239 +
783 2240 public static function cache_file_for( $key ) {
784 - return XSPEED_CACHE_DIR . '/' . $key . '.html';
2241 + return XSPEED_CACHE_DIR . '/' . self::current_host_dir() . '/' . $key . '.html';
785 2242 }
786 2243
787 2244 /**
788 2245 * If a precompressed Brotli sibling (`<file>.br`) exists and the client
@@ -813,8 +2270,11 @@
813 2270 $br = $file . '.br';
814 2271 if ( ! is_string( $br ) || ! file_exists( $br ) || ! is_readable( $br ) ) {
815 2272 return null;
816 2273 }
2274 + if ( ! self::brotli_sibling_is_usable( $file, $br ) ) {
2275 + return null; // fall through to the plain .html
2276 + }
817 2277 header( 'Content-Encoding: br' );
818 2278 header( 'Vary: Accept-Encoding', false );
819 2279 // The byte length changes for the compressed body — drop any
820 2280 // Content-Length the caller may have set so the stream isn't
@@ -823,8 +2283,217 @@
823 2283 return $br;
824 2284 }
825 2285
826 2286 /**
2287 + * Is a precompressed `.br` sibling safe to serve?
2288 + *
2289 + * Existence is not enough. The sibling is written with a plain
2290 + * file_put_contents() — no atomic rename — so a crash, a full disk, or a
2291 + * read that races the write leaves a TRUNCATED file behind. Serving that
2292 + * with `Content-Encoding: br` hands the browser a stream it cannot
2293 + * inflate: it renders nothing at all (document.body is null) and the
2294 + * navigation can hang. A 16-byte .br for a 172KB page reproduces it
2295 + * exactly. (#286)
2296 + *
2297 + * Brotli has no magic number, and no byte-level marker distinguishes a
2298 + * truncated stream from a short valid one (the ISLAST bit is bit-packed,
2299 + * not byte-aligned). So this checks only what CAN be known by stat:
2300 + *
2301 + * - Not empty. A zero-byte sibling is unambiguously broken.
2302 + * - Not older than the HTML. A stale sibling would serve the PREVIOUS
2303 + * revision of the page under the current entry's ETag.
2304 + *
2305 + * A size-RATIO floor was tried here and removed. Brotli's ratio is
2306 + * unbounded on repetitive input: a ~1 MB page of table rows or a product
2307 + * grid — the ordinary shape of a big generated page — compresses to
2308 + * about 0.04%, so a 2% floor rejected a perfectly good sibling and sent
2309 + * visitors the uncompressed page instead, silently. Measured: 963 KB of
2310 + * repeated markup → 89 bytes at q5 (0.009%). No floor can separate
2311 + * "impossibly small" from "extremely compressible" for arbitrary HTML.
2312 + *
2313 + * Truncation is prevented at the WRITE side instead — see
2314 + * write_atomic(), which the Brotli writer uses so a partial file is
2315 + * never visible under the final name. Detection at read time cannot be
2316 + * made correct; not creating the bad file can.
2317 + *
2318 + * Anything suspicious returns false and the caller streams the plain
2319 + * .html — slower, always correct. Serving an uninflatable body is worse
2320 + * than serving no compression at all.
2321 + *
2322 + * @param string $file Absolute path to the .html cache file.
2323 + * @param string $br Absolute path to its .br sibling.
2324 + * @return bool True when the sibling may be served.
2325 + */
2326 + /**
2327 + * Write a cache sidecar so a partial file is never visible.
2328 + *
2329 + * `file_put_contents()` truncates the target and then fills it, so any
2330 + * reader arriving mid-write — or any crash, full disk, or killed worker
2331 + * — leaves a SHORT file under the real name. For HTML that degrades to a
2332 + * clipped page; for a `.br` sibling it is worse, because a truncated
2333 + * brotli stream is not a short page but an UNINFLATABLE one: the browser
2334 + * renders nothing at all and the navigation can hang.
2335 + *
2336 + * Writing to a unique temp file in the same directory and renaming is
2337 + * atomic on POSIX, so readers see either the previous complete file or
2338 + * the new complete file, never a partial one. This is the half of #286
2339 + * that is actually fixable — a read-time heuristic cannot tell a
2340 + * truncated brotli stream from a very small valid one, but a truncated
2341 + * file that never becomes visible needs no detection.
2342 + *
2343 + * @param string $path Absolute destination path.
2344 + * @param string $contents Bytes to write.
2345 + * @return bool True when the destination now holds exactly $contents.
2346 + */
2347 + public static function write_atomic( string $path, string $contents ): bool {
2348 + $dir = dirname( $path );
2349 + // 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.
2350 + if ( ! is_dir( $dir ) || ! is_writable( $dir ) ) {
2351 + return false;
2352 + }
2353 +
2354 + // Same directory, so the rename stays on one filesystem — a rename
2355 + // across devices is a copy and loses atomicity.
2356 + $tmp = @tempnam( $dir, '.xspeed-tmp-' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a failure returns false and the caller skips the write.
2357 + if ( ! is_string( $tmp ) || '' === $tmp ) {
2358 + return false;
2359 + }
2360 +
2361 + // 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.
2362 + $written = @file_put_contents( $tmp, $contents ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- handled by the length check below.
2363 +
2364 + // A short write is exactly the failure this function exists to
2365 + // prevent, so verify the byte count before publishing the file.
2366 + if ( false === $written || $written !== strlen( $contents ) ) {
2367 + @unlink( $tmp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort cleanup of our own temp file; non-fatal.
2368 + return false;
2369 + }
2370 +
2371 + // tempnam() creates the file 0600; cache files must stay readable by
2372 + // the web server, which may run as a different user.
2373 + @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.
2374 +
2375 + // 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.
2376 + if ( ! @rename( $tmp, $path ) ) {
2377 + @unlink( $tmp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort cleanup; non-fatal.
2378 + return false;
2379 + }
2380 +
2381 + return true;
2382 + }
2383 +
2384 + public static function brotli_sibling_is_usable( string $file, string $br ): bool {
2385 + $br_size = (int) @filesize( $br ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a stat failure means "don't serve it", handled by the <= 0 check.
2386 + if ( $br_size <= 0 ) {
2387 + return false;
2388 + }
2389 +
2390 + $html_size = (int) @filesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- as above.
2391 + if ( $html_size <= 0 ) {
2392 + return false;
2393 + }
2394 +
2395 + // A sibling older than the page it compresses is stale.
2396 + $br_mtime = (int) @filemtime( $br ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- as above.
2397 + $html_mtime = (int) @filemtime( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- as above.
2398 + if ( $br_mtime > 0 && $html_mtime > 0 && $br_mtime < $html_mtime ) {
2399 + return false;
2400 + }
2401 +
2402 + // The writer recorded how many bytes it produced. Where that record
2403 + // exists, truncation is a certainty rather than an inference: a
2404 + // stream shorter than its own declared length cannot inflate, and
2405 + // one that matches was published whole. This is what a size ratio
2406 + // could never be — brotli's ratio is unbounded on repetitive input,
2407 + // so a 0.01% sibling of a generated page is genuinely valid.
2408 + //
2409 + // Absent for a sibling written before this version, or by an add-on
2410 + // that writes the file directly. That case keeps the checks above
2411 + // and no more, which is where a pre-existing truncated file on a
2412 + // live site still slips through — write_atomic() stops NEW ones,
2413 + // but it cannot retroactively vouch for what is already on disk.
2414 + $expected = self::brotli_expected_size( $br );
2415 + if ( $expected > 0 && $br_size !== $expected ) {
2416 + return false;
2417 + }
2418 +
2419 + return true;
2420 + }
2421 +
2422 + /**
2423 + * Path of the sidecar recording a `.br` sibling's complete byte count.
2424 + *
2425 + * Kept beside the sibling as `<file>.html.br.size` rather than folded
2426 + * into the entry's `.meta`: the static tree the web server serves has no
2427 + * `.meta` at all, and the two trees must answer this question the same
2428 + * way. Every path that deletes a `.br` deletes this with it.
2429 + *
2430 + * @param string $br Absolute path to the `.br` sibling.
2431 + * @return string Absolute path to its size sidecar.
2432 + */
2433 + public static function brotli_size_sidecar( string $br ): string {
2434 + return $br . '.size';
2435 + }
2436 +
2437 + /**
2438 + * The byte count the writer recorded for a `.br` sibling, or 0 when no
2439 + * record exists (a sibling predating this version, or written by an
2440 + * add-on that bypassed write_brotli_sibling()).
2441 + *
2442 + * @param string $br Absolute path to the `.br` sibling.
2443 + * @return int Expected size in bytes, or 0 when unknown.
2444 + */
2445 + public static function brotli_expected_size( string $br ): int {
2446 + $sidecar = self::brotli_size_sidecar( $br );
2447 + if ( ! is_file( $sidecar ) ) {
2448 + return 0;
2449 + }
2450 + // 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.
2451 + $raw = @file_get_contents( $sidecar ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- an unreadable sidecar means "unknown", handled by the cast below.
2452 + return max( 0, (int) trim( (string) $raw ) );
2453 + }
2454 +
2455 + /**
2456 + * Publish a `.br` sibling together with the record of its own length.
2457 + *
2458 + * The single writer every producer of a `.br` should route through — the
2459 + * Pro Brotli module included. Publishing the body atomically stops a
2460 + * truncated file from ever becoming visible; recording the byte count
2461 + * lets the serve path prove wholeness for the files that already exist
2462 + * on disk when this ships.
2463 + *
2464 + * Order matters: the size sidecar is removed first and written last, so
2465 + * a reader arriving mid-update sees "no record" (checks above still
2466 + * apply) rather than the previous body's length against the new body.
2467 + *
2468 + * @param string $br Absolute path to the `.br` sibling to write.
2469 + * @param string $contents Compressed bytes.
2470 + * @return bool True when both the sibling and its size record are in place.
2471 + */
2472 + public static function write_brotli_sibling( string $br, string $contents ): bool {
2473 + $sidecar = self::brotli_size_sidecar( $br );
2474 + if ( is_file( $sidecar ) ) {
2475 + wp_delete_file( $sidecar );
2476 + }
2477 +
2478 + if ( ! self::write_atomic( $br, $contents ) ) {
2479 + return false;
2480 + }
2481 +
2482 + if ( self::write_atomic( $sidecar, (string) strlen( $contents ) ) ) {
2483 + return true;
2484 + }
2485 +
2486 + // The body landed but its length did not. That sibling is servable
2487 + // and unguarded — exactly the file this function exists to prevent —
2488 + // and the caller has no way to know. Withdraw it: a MISS costs one
2489 + // uncompressed response, where an unguarded sibling can cost a blank
2490 + // page for as long as the entry lives.
2491 + wp_delete_file( $br );
2492 + return false;
2493 + }
2494 +
2495 + /**
827 2496 * Sidecar metadata file for a cache entry. Holds response bits the HIT
828 2497 * path must replay — Content-Type (cached feeds → application/rss+xml,
829 2498 * sitemaps → text/xml) and status (a cached 404 must serve 404, not
830 2499 * 200). JSON, one tiny file per entry, written only when there's
@@ -830,9 +2499,9 @@
830 2499 * 200). JSON, one tiny file per entry, written only when there's
831 2500 * something non-default to replay.
832 2501 */
833 2502 public static function cache_meta_for( $key ) {
834 - return XSPEED_CACHE_DIR . '/' . $key . '.meta';
2503 + return XSPEED_CACHE_DIR . '/' . self::current_host_dir() . '/' . $key . '.meta';
835 2504 }
836 2505
837 2506 /**
838 2507 * Read the .meta sidecar for a cache entry as an array, or [] if none.
@@ -908,8 +2577,34 @@
908 2577 * @param int $max_age Computed max-age in seconds.
909 2578 */
910 2579 $max_age = (int) apply_filters( 'xspeed_cache_max_age', $max_age );
911 2580
2581 + // Honour the per-entry TTL the .meta sidecar carries, when it is
2582 + // SHORTER than what we just resolved. The sidecar records the TTL
2583 + // this specific entry was written under — a nonce cap (#236), a Pro
2584 + // feed/404 expiry — and the drop-in already reads it. is_expired()
2585 + // did not, so on the engine path a capped entry was still served for
2586 + // the full configured lifetime: exactly the stale nonce the cap
2587 + // exists to prevent. Only ever shortens, so an entry can never be
2588 + // kept alive past the configured maximum by a stale sidecar.
2589 + // Derive the sidecar from the FILE we were handed rather than
2590 + // recomputing cache_key(): callers legitimately ask about an entry
2591 + // that isn't the current request's (Cache_GC sweeps, Pro's warmer),
2592 + // and cache_key() would answer for the wrong one — besides needing a
2593 + // request context this function has no business requiring.
2594 + $meta_file = preg_replace( '/\.html$/', '.meta', (string) $file );
2595 + if ( is_string( $meta_file ) && $meta_file !== $file && is_readable( $meta_file ) ) {
2596 + // 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.
2597 + $raw = file_get_contents( $meta_file );
2598 + $decoded = is_string( $raw ) ? json_decode( $raw, true ) : null;
2599 + if ( is_array( $decoded ) && isset( $decoded['ttl'] ) ) {
2600 + $entry_ttl = (int) $decoded['ttl'];
2601 + if ( $entry_ttl > 0 && ( $max_age < 1 || $entry_ttl < $max_age ) ) {
2602 + $max_age = $entry_ttl;
2603 + }
2604 + }
2605 + }
2606 +
912 2607 // A missing file is "expired" — the caller should re-render. Guard
913 2608 // filemtime() rather than letting it warn: callers legitimately ask
914 2609 // about a file that isn't there (Pro's predictive warmer probes for
915 2610 // freshness, and Cache_GC can collect an entry between the check and
@@ -1005,18 +2700,51 @@
1005 2700 $buffer = $full;
1006 2701 }
1007 2702 }
1008 2703
1009 - if ( ! file_exists( XSPEED_CACHE_DIR ) ) {
1010 - wp_mkdir_p( XSPEED_CACHE_DIR );
1011 - self::write_silence( XSPEED_CACHE_DIR );
2704 + // AFTER minification on purpose — the HTML minifier strips comments,
2705 + // so signing earlier would erase the signature from every minified
2706 + // page. Baked into the cached bytes so all three serve paths (nginx
2707 + // static rewrite, .htaccess, the PHP drop-in) carry it identically.
2708 + $full = self::signed( $full );
2709 + if ( $single_chunk ) {
2710 + $buffer = $full;
1012 2711 }
1013 2712
1014 - // Path safety: cache_file_for() builds `XSPEED_CACHE_DIR . '/' . $key . '.html'`
1015 - // where $key comes from md5() — guaranteed to be exactly 32 lowercase
1016 - // hex chars, so no traversal sequence ('..', '/', null byte, etc.)
1017 - // can appear. The write is therefore always inside XSPEED_CACHE_DIR.
1018 - $key = self::cache_key();
2713 + // Per-site directory — see ensure_host_dir(). (#6)
2714 + self::ensure_host_dir();
2715 +
2716 + // Path safety: cache_file_for() builds
2717 + // `XSPEED_CACHE_DIR . '/' . <host> . '/' . $key . '.html'` where $key
2718 + // comes from md5() — guaranteed to be exactly 32 lowercase hex chars —
2719 + // and <host> is filtered by host_dir() to [A-Za-z0-9.-] with leading
2720 + // dots trimmed, so no traversal sequence ('..', '/', null byte, etc.)
2721 + // can appear in either segment. The write is therefore always inside
2722 + // XSPEED_CACHE_DIR.
2723 + $key = self::cache_key();
2724 +
2725 + // Query-string gate. should_cache() waved this request through
2726 + // because every param is on the ignored_query_params allow-list, and
2727 + // cache_key() drops the query so reads share the canonical entry.
2728 + // That sharing is safe on READ but not on WRITE: this response was
2729 + // rendered WITH the params, and WordPress reflects REQUEST_URI into
2730 + // form actions, share links and plugin smart tags — so storing it
2731 + // would serve an attacker-chosen variant under the clean URL for the
2732 + // whole TTL (#241).
2733 + //
2734 + // This sits BELOW the transforms deliberately. Returning above them
2735 + // also skipped xspeed_cache_final_html, and every listener disables
2736 + // its own fallback ob_start() when the page cache is on precisely
2737 + // because that filter is the shared transport — so a visitor
2738 + // arriving on ?utm_source=… was served HTML with no LCP preload, no
2739 + // preconnect, no CDN rewrite, no CSS combine and no HTML minify.
2740 + // That is the ad-click and newsletter cohort getting the least
2741 + // optimised page on the site. Only the WRITE is skipped, which is
2742 + // what this fix was always meant to do — and it is where the
2743 + // deferred writer has always placed its own copy of the guard.
2744 + if ( self::query_string_blocks_write() ) {
2745 + return $buffer;
2746 + }
1019 2747 $file = self::cache_file_for( $key );
1020 2748
1021 2749 // A render-time translation plugin (TranslatePress) wraps our buffer,
1022 2750 // so the bytes we hold here are still UNTRANSLATED — its callback has
@@ -1061,9 +2789,9 @@
1061 2789 // Persist a non-default Content-Type so the HIT path can replay it
1062 2790 // (cached feeds must serve application/rss+xml, not text/html).
1063 2791 // Only written when the response set a content-type other than
1064 2792 // the HTML default — pages don't pay for an extra file.
1065 - self::write_meta( $key );
2793 + self::write_meta( $key, $full );
1066 2794
1067 2795 // Static-cache tree (xspeed-static/{host}{path}/index.html). The
1068 2796 // .htaccess rewrite block serves this file directly via the web
1069 2797 // server, bypassing PHP for ~3-5× lower TTFB vs the drop-in path.
@@ -1078,9 +2806,16 @@
1078 2806 // 200, FBS-82406) or a non-HTML content-type (a cached feed would go
1079 2807 // out as text/html, FBS-82407). The web server serves these .html files
1080 2808 // directly with no PHP, so there's no .meta replay — keep them on the
1081 2809 // drop-in / PHP path instead, which DOES replay status + content-type.
1082 - if ( self::static_rewrite_allowed() && self::response_is_plain_html() ) {
2810 + // The static tree cannot replay a sidecar. A file served straight by
2811 + // the web server carries the headers baked into the rule that serves
2812 + // the whole site — the very answer this entry exists because it
2813 + // disagreed with. Same reasoning as the status and content-type
2814 + // cases: what the fast path cannot replay belongs on the drop-in path.
2815 + if ( self::static_rewrite_allowed()
2816 + && self::response_is_plain_html()
2817 + && array() === self::per_entry_edge_headers() ) {
1083 2818 self::store_static( $full );
1084 2819 }
1085 2820
1086 2821 return $buffer;
@@ -1096,13 +2831,149 @@
1096 2831 * $uri has its query string stripped, null bytes removed, '..'
1097 2832 * sequences collapsed, and after concatenation we verify the
1098 2833 * resolved real path stays inside XSPEED_CACHE_STATIC_DIR before
1099 2834 * any write. Anything off the happy path returns silently.
2835 + *
2836 + * INVARIANT — the static tree is keyed by `{host}{path}` and NOTHING
2837 + * else, and both generated rewrites refuse any request that carries a
2838 + * query string at all (`RewriteCond %{QUERY_STRING} ^$` on Apache,
2839 + * `if ($args)` in nginx_snippet()). So a response may only be stored
2840 + * here when cache_key() adds no discriminator beyond `{host}{path}`:
2841 + * a query-keyed entry can never be *served* from here, only mis-served
2842 + * as the bare path. Any future opt-in that folds a query param into the
2843 + * key needs a guard below, exactly like the search one.
1100 2844 */
2845 + /**
2846 + * Transient holding the most recent static-tree refusal.
2847 + *
2848 + * Short-lived on purpose: it describes what the last cacheable render
2849 + * actually did, so a stale entry would keep warning about a page whose
2850 + * nonces have since been removed. A site that still refuses simply
2851 + * rewrites it on the next render. (#372)
2852 + */
2853 + private const STATIC_SKIP_TRANSIENT = 'xspeed_static_skip';
2854 +
2855 + /**
2856 + * Remember why a page was kept out of the static tree, for Health.
2857 + *
2858 + * Records the URL, the reason, and — for the nonce case — the distinct
2859 + * nonce KEYS found, which is what makes the finding actionable: the names
2860 + * (`eael_login_nonce`, `post_grid_pagination_nonce`, …) trace straight back
2861 + * to the plugin emitting them, and it is usually a widget the site does not
2862 + * use on that page. Only key names are kept, never the nonce values.
2863 + *
2864 + * @param string $reason Machine-readable refusal reason.
2865 + * @param string $html The response, for extracting the nonce keys.
2866 + */
2867 + private static function note_static_skip( string $reason, string $html = '' ): void {
2868 + if ( ! function_exists( 'set_transient' ) ) {
2869 + return;
2870 + }
2871 +
2872 + $keys = array();
2873 + if ( 'nonce' === $reason && '' !== $html ) {
2874 + // Must recognise the SAME shapes response_has_nonce() refuses on,
2875 + // or a page is skipped and reported with no keys at all — which is
2876 + // most of them, since the plain `name="_wpnonce"` form field is the
2877 + // commonest shape by far and only the JSON one was handled here.
2878 + // The keys are the actionable half of the message, so a mismatch
2879 + // leaves the admin with bad news and nothing to act on.
2880 + //
2881 + // Both alternations capture the KEY only: each value pattern sits
2882 + // outside the capture group, so a nonce secret can never be stored.
2883 + $found = array();
2884 + if ( preg_match_all( '/name=["\']([a-z0-9_\-\[\]]*nonce[a-z0-9_\-\[\]]*)["\']/i', $html, $m ) ) {
2885 + $found = array_merge( $found, $m[1] );
2886 + }
2887 + if ( preg_match_all( '/["\']([a-z0-9_\-]*nonce[a-z0-9_\-]*)["\']\s*:\s*["\'][a-f0-9]{8,}["\']/i', $html, $m ) ) {
2888 + $found = array_merge( $found, $m[1] );
2889 + }
2890 + // The query-arg shape (`?_wpnonce=…`) has no key name to report
2891 + // beyond the literal, so name it explicitly rather than reporting
2892 + // nothing for a page that was genuinely refused.
2893 + if ( preg_match( '/[?&]_wpnonce=/i', $html ) ) {
2894 + $found[] = '_wpnonce';
2895 + }
2896 + $keys = array_slice( array_values( array_unique( $found ) ), 0, 10 );
2897 + }
2898 +
2899 + $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
2900 +
2901 + set_transient(
2902 + self::STATIC_SKIP_TRANSIENT,
2903 + array(
2904 + 'reason' => $reason,
2905 + 'url' => (string) strtok( $uri, '?' ),
2906 + 'keys' => $keys,
2907 + 'at' => time(),
2908 + ),
2909 + HOUR_IN_SECONDS
2910 + );
2911 + }
2912 +
2913 + /**
2914 + * The most recent static-tree refusal, or an empty array when there is none.
2915 + *
2916 + * @return array{reason:string,url:string,keys:string[],at:int}|array{}
2917 + */
2918 + public static function last_static_skip(): array {
2919 + $stored = function_exists( 'get_transient' ) ? get_transient( self::STATIC_SKIP_TRANSIENT ) : false;
2920 + return is_array( $stored ) && ! empty( $stored['reason'] ) ? $stored : array();
2921 + }
2922 +
1101 2923 private static function store_static( string $html ): void {
2924 + // Search results are keyed by term in cache_key() (`|s=<term>`) but
2925 + // carry the *path* of whatever URL was searched from — for the usual
2926 + // `/?s=<term>` that path is `/`. Writing them here would file the
2927 + // results page as `{host}/index.html` and the web server would serve
2928 + // it to every visitor as the homepage: an unauthenticated visitor
2929 + // poisons the front page with one request. Searches stay on the
2930 + // drop-in, which replays the term-keyed entry correctly. (#191)
2931 + //
2932 + // This is a superset of the query-string check the exclusion gate
2933 + // does: it also covers `/?%73=<term>`, which decodes to the same
2934 + // search (the shape #109 fixed on the gate side).
2935 + if ( self::should_cache_search() ) {
2936 + return;
2937 + }
2938 +
2939 + // Same hazard for the allow-listed query params: store_static()
2940 + // strips the query and files the response under the bare path, which
2941 + // the web server then serves to every visitor of the clean URL with
2942 + // no PHP involved at all — so none of the engine's checks can catch
2943 + // it later (#241). The callers already gate on this, but the guard
2944 + // is repeated here because this tree is the most dangerous of the
2945 + // three write sites and must not depend on its callers.
2946 + if ( self::request_has_query_string() ) {
2947 + return;
2948 + }
2949 +
2950 + // A nonce-bearing page is served here with NO PHP: no TTL check and
2951 + // no .meta replay, so the per-entry cap that keeps the drop-in honest
2952 + // (#236) cannot reach a file once it is written. Only Cache_GC removes
2953 + // it, and until it does the page hands every visitor the same nonce —
2954 + // which, once that nonce dies, breaks every anonymous form on it.
2955 + //
2956 + // Refusing outright was the safe answer, and it cost every
2957 + // nonce-bearing page the static tree entirely: a site whose homepage
2958 + // carries one unused login nonce ran PHP on every request forever.
2959 + // The nonce's own remaining life is the better gate — the page is
2960 + // written and its deadline recorded below for GC to enforce.
2961 + //
2962 + // A nonce we cannot put a clock on is still refused, and that refusal
2963 + // is still recorded: it stays completely silent otherwise, because the
2964 + // drop-in answers HIT while Health reports the fast path active from a
2965 + // probe that writes its OWN file and never proves real pages reach the
2966 + // tree. (#372)
2967 + $nonce_ttl = self::response_has_nonce( $html ) ? self::nonce_capped_ttl( $html, 0 ) : 0;
2968 + if ( self::response_has_nonce( $html ) && $nonce_ttl < 1 ) {
2969 + self::note_static_skip( 'nonce', $html );
2970 + return;
2971 + }
2972 +
1102 2973 $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
1103 2974 $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
1104 - $host = preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host );
2975 + $host = self::static_host_dir( $host );
1105 2976 $uri = str_replace( "\0", '', $uri );
1106 2977 $uri = (string) strtok( $uri, '?' );
1107 2978 if ( '' === $host || '' === $uri ) {
1108 2979 return;
@@ -1133,8 +3004,18 @@
1133 3004 }
1134 3005 // 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.
1135 3006 $written = file_put_contents( $file, $html, LOCK_EX );
1136 3007
3008 + // A nonce-bearing page expires on the nonce's schedule, not the site's.
3009 + // Nothing reads this file at serve time — the web server hands over
3010 + // index.html without PHP — so the deadline is recorded beside it for
3011 + // GC, which is the only thing that can enforce it. Written before the
3012 + // action below so a listener that shells out cannot race the sweep.
3013 + if ( false !== $written && $nonce_ttl > 0 ) {
3014 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- same rationale as the write above.
3015 + file_put_contents( $dir . '/.xspeed-expires', (string) ( time() + $nonce_ttl ), LOCK_EX );
3016 + }
3017 +
1137 3018 if ( false !== $written ) {
1138 3019 /**
1139 3020 * Fires after a static cache file (index.html) is written.
1140 3021 *
@@ -1186,9 +3067,236 @@
1186 3067 }
1187 3068 return true;
1188 3069 }
1189 3070
1190 - private static function write_meta( string $key ): void {
3071 + /**
3072 + * Append the cache signature comment to a finished page.
3073 + *
3074 + * The plugin's one outward version signal: external scanners (the
3075 + * xspeedcache.com speed test among them) read it to detect xSpeed and
3076 + * its version on a cached page, the way other cache plugins sign their
3077 + * output. Callers apply it AFTER HTML minification — the minifier strips
3078 + * comments — and before every cache write, so all serve paths carry the
3079 + * same bytes.
3080 + *
3081 + * The generation time is baked in here, at write time, in UTC. It is the
3082 + * moment the cached bytes were produced — NOT the moment they were served
3083 + * — because all three serve paths replay the same stored file, and two of
3084 + * them (the nginx/`.htaccess` static rewrite) run no PHP at all and so
3085 + * could never stamp a serve-time value. Reading the age of a page is the
3086 + * point: `generated` plus the current clock tells you how stale it is.
3087 + * `gmdate()` (not `current_time()`) keeps the value comparable across
3088 + * sites regardless of the configured timezone.
3089 + *
3090 + * @param string $html Finished page HTML.
3091 + * @return string HTML with the signature appended (or unchanged when a
3092 + * filter removed it).
3093 + */
3094 + private static function signed( string $html ): string {
3095 + $version = defined( 'XSPEED_VERSION' ) ? XSPEED_VERSION : '';
3096 + $generated = gmdate( 'Y-m-d H:i:s' ) . ' UTC';
3097 + // The literal ' | xspeedcache.com' must survive intact, and what
3098 + // precedes it is where an edition suffix lands: Pro appends itself by
3099 + // str_replace()-ing on that exact token
3100 + // (Pro_Plugin::sign_cache_signature). So the stamp goes AFTER it —
3101 + // placed before, it sits between the version and the anchor and
3102 + // composes as "generated <date> + Pro v1.1.3".
3103 + $signature = sprintf(
3104 + '<!-- Page cached by xSpeed Cache v%s | xspeedcache.com | generated %s -->',
3105 + $version,
3106 + $generated
3107 + );
3108 +
3109 + /**
3110 + * Filter: xspeed_cache_signature
3111 + *
3112 + * The HTML comment appended to every cached page. Add-ons append
3113 + * their own edition/version here; white-label setups return '' to
3114 + * remove the comment entirely. Must remain a valid HTML comment (or
3115 + * an empty string) — it ships inside the cached body.
3116 + *
3117 + * @param string $signature The signature comment.
3118 + * @param string $version The plugin version baked into it.
3119 + * @param string $generated The write-time timestamp baked into it,
3120 + * formatted `Y-m-d H:i:s UTC`.
3121 + */
3122 + $signature = (string) apply_filters( 'xspeed_cache_signature', $signature, $version, $generated );
3123 + if ( '' === trim( $signature ) ) {
3124 + return $html;
3125 + }
3126 + return $html . "\n" . $signature;
3127 + }
3128 +
3129 + /**
3130 + * Does this response carry a WordPress nonce?
3131 + *
3132 + * Anonymous nonces depend only on the tick (user 0, empty session
3133 + * token), so they are identical for every visitor — which is exactly why
3134 + * they cache "successfully" and then fail silently once the tick moves.
3135 + *
3136 + * Matches any form field whose NAME contains "nonce" — `_wpnonce`,
3137 + * `_wpnonce_<action>`, Tutor's `_tutor_nonce`, CF7's `_wpcf7_nonce` and
3138 + * WooCommerce's `woocommerce-add-to-cart-nonce` (which does NOT start
3139 + * with an underscore, so a `_`-anchored pattern misses it) — plus the
3140 + * `_wpnonce=` form used in nonce-bearing URLs. Deliberately keyed on
3141 + * `name=` so prose, CSS classes and data attributes don't false-positive.
3142 + *
3143 + * @param string $html Rendered response body.
3144 + */
3145 + public static function response_has_nonce( string $html ): bool {
3146 + if ( '' === $html ) {
3147 + return false;
3148 + }
3149 +
3150 + /*
3151 + * Three shapes, because a nonce reaches the page in three ways:
3152 + *
3153 + * 1. A form field name — `_wpnonce`, `woocommerce-login-nonce`, and
3154 + * the GROUPED names form builders emit (`data[_wpnonce]`,
3155 + * `frm[nonce]`). The character class deliberately allows `[` and
3156 + * `]` so grouping does not hide the field: form builders are
3157 + * exactly the kind of plugin #236 is about, and a missed page
3158 + * keeps the old broken behaviour silently.
3159 + * 2. A query argument (`?_wpnonce=`) on a link.
3160 + * 3. A nonce handed to the page's own scripts rather than placed in
3161 + * a visible form — `wp_localize_script()` output and inline JSON
3162 + * both land as a `"nonce":"…"`-shaped pair.
3163 + */
3164 + return 1 === preg_match(
3165 + '/(name=["\'][a-z0-9_\-\[\]]*nonce[a-z0-9_\-\[\]]*["\']'
3166 + . '|[?&]_wpnonce='
3167 + . '|["\'][a-z0-9_\-]*nonce[a-z0-9_\-]*["\']\s*:\s*["\'][a-f0-9]{8,}["\'])/i',
3168 + $html
3169 + );
3170 + }
3171 +
3172 + /**
3173 + * The TTL (seconds) a response may be cached for, capped to the nonce
3174 + * lifetime when it carries one.
3175 + *
3176 + * WordPress nonces are valid for at most `nonce_life` — 24h by default —
3177 + * because wp_verify_nonce() accepts the current tick and the previous
3178 + * one. Our own lifetime maximum is 720h and the shipped Aggressive
3179 + * preset is 168h, so on any site configured above 24h every anonymous
3180 + * front-end form carried a DEAD nonce for the majority of the cache's
3181 + * life and every submission was rejected — with the other plugin's error
3182 + * string ("Nonce not matched"), so the report never reached us (#236).
3183 + *
3184 + * `nonce_life` is the MAXIMUM a nonce can live, not the minimum, so it
3185 + * is the wrong number to cap with. wp_nonce_tick() buckets time into
3186 + * `nonce_life / 2` slices; a nonce minted x seconds into its bucket is
3187 + * valid for `nonce_life - x`, where x can be as large as a full bucket.
3188 + * Capping the entry at `nonce_life` therefore still served a dead nonce
3189 + * for up to half of every entry's life — 0-12h of each 24h entry,
3190 + * averaging 6h, re-rolled by every purge so it reads as intermittent.
3191 + * Capping at the guaranteed-valid remainder closes the window at every
3192 + * tick phase, at the cost of caching nonce-bearing pages for 12h rather
3193 + * than 24h.
3194 + *
3195 + * Capping is per-entry, so only nonce-bearing pages pay for it; the rest
3196 + * of the site keeps the configured lifetime.
3197 + *
3198 + * @param string $html Rendered response body.
3199 + * @param int $ttl Otherwise-resolved TTL in seconds.
3200 + * @return int TTL to actually use.
3201 + */
3202 + /**
3203 + * The nonce lifetime to cap against, in seconds.
3204 + *
3205 + * `nonce_life` is a TWO-argument filter in core:
3206 + *
3207 + * $nonce_life = apply_filters( 'nonce_life', DAY_IN_SECONDS, $action );
3208 + *
3209 + * Applying it with one argument is not merely incomplete — a callback
3210 + * that declares both parameters as required (the documented shape, and
3211 + * what a site branching per action must write) raises ArgumentCountError
3212 + * the moment we call it. That fatal lands in the shutdown cache write,
3213 + * so the visitor still sees a perfectly normal page while the sidecar is
3214 + * never written: the entry then keeps the FULL configured lifetime
3215 + * carrying a dead nonce, which is precisely the bug #236 set out to fix.
3216 + * Worse, the entry stays that way until a purge, even after the site
3217 + * removes whatever customised the lifetime.
3218 + *
3219 + * We are inspecting rendered markup, so we cannot know which action
3220 + * minted the nonce we found. Two consequences:
3221 + *
3222 + * 1. We pass `''` as the action. A per-action callback therefore sees
3223 + * the same "unknown action" value core itself passes when a nonce is
3224 + * created with no action, and can branch on it deliberately.
3225 + * 2. A page may carry nonces from SEVERAL actions with different
3226 + * lifetimes. The entry can only have one TTL, so the safe choice is
3227 + * the SHORTEST lifetime any action on the site resolves to — capping
3228 + * to a longer one would serve a dead nonce for the shorter action.
3229 + * Sites can narrow this with `xspeed_cache_nonce_life_actions`.
3230 + *
3231 + * @param string $html Response body being cached.
3232 + * @return int Nonce lifetime in seconds (0 = do not cap).
3233 + */
3234 + private static function nonce_life_seconds( string $html ): int {
3235 + /**
3236 + * Filter the nonce actions whose lifetimes are consulted when
3237 + * capping a cache entry.
3238 + *
3239 + * The default `''` is the "action unknown" case — we are reading
3240 + * rendered HTML, not minting a nonce. A site whose `nonce_life`
3241 + * callback shortens specific actions can list them here so the cap
3242 + * accounts for the shortest one that could appear on the page.
3243 + *
3244 + * @since 1.1.8
3245 + * @param string[] $actions Nonce actions to resolve.
3246 + * @param string $html The response body being cached.
3247 + */
3248 + $actions = (array) apply_filters( 'xspeed_cache_nonce_life_actions', array( '' ), $html );
3249 + if ( empty( $actions ) ) {
3250 + $actions = array( '' );
3251 + }
3252 +
3253 + $shortest = 0;
3254 + foreach ( $actions as $action ) {
3255 + // Both arguments, exactly as core passes them.
3256 + $life = (int) apply_filters( 'nonce_life', DAY_IN_SECONDS, (string) $action );
3257 + if ( $life < 1 ) {
3258 + continue;
3259 + }
3260 + if ( 0 === $shortest || $life < $shortest ) {
3261 + $shortest = $life;
3262 + }
3263 + }
3264 +
3265 + return $shortest;
3266 + }
3267 +
3268 + public static function nonce_capped_ttl( string $html, int $ttl ): int {
3269 + if ( ! self::response_has_nonce( $html ) ) {
3270 + return $ttl;
3271 + }
3272 +
3273 + $nonce_life = self::nonce_life_seconds( $html );
3274 + if ( $nonce_life < 1 ) {
3275 + return $ttl;
3276 + }
3277 +
3278 + // Half of nonce_life is the GUARANTEED-valid remainder — see above.
3279 + $guaranteed = max( 1, intdiv( $nonce_life, 2 ) );
3280 + $capped = ( $ttl > 0 ) ? min( $ttl, $guaranteed ) : $guaranteed;
3281 +
3282 + /**
3283 + * Filter the nonce-capped TTL for a cache entry.
3284 + *
3285 + * Escape hatch for a site whose nonce-shaped markup is decorative —
3286 + * return the uncapped $ttl to keep the configured lifetime. Most
3287 + * sites should leave this alone: serving a dead nonce breaks every
3288 + * anonymous form on the page.
3289 + *
3290 + * @param int $capped TTL after the nonce cap (seconds).
3291 + * @param int $ttl TTL before the cap (seconds).
3292 + * @param int $nonce_life Current nonce lifetime (seconds).
3293 + * @param string $html The response body being cached.
3294 + */
3295 + return (int) apply_filters( 'xspeed_cache_nonce_ttl_cap', $capped, $ttl, $nonce_life, $html );
3296 + }
3297 +
3298 + private static function write_meta( string $key, string $html = '' ): void {
1191 3299 $content_type = '';
1192 3300 foreach ( headers_list() as $header ) {
1193 3301 if ( 0 === stripos( $header, 'content-type:' ) ) {
1194 3302 $content_type = trim( substr( $header, strlen( 'content-type:' ) ) );
@@ -1209,15 +3317,48 @@
1209 3317 // call is_expired() / the xspeed_cache_max_age filter (they run before
1210 3318 // WP), so persist the resolved max-age here whenever it differs from
1211 3319 // the plain page TTL — e.g. the Pro feed cache's 12h vs the 24h page
1212 3320 // default. The fast paths read this to expire correctly. (FBS-82407)
1213 - $opts = Settings_Manager::get( 'cache' );
1214 - $default_ttl = (int) $opts['cache_expiry'] * HOUR_IN_SECONDS;
1215 - $ttl = (int) apply_filters( 'xspeed_cache_max_age', $default_ttl );
3321 + // This MUST resolve the TTL the same way is_expired() does, including
3322 + // the per-post override — the sidecar is the only channel that can
3323 + // carry a per-entry TTL into the pre-boot fast paths. Omitting the
3324 + // override here left an editor's "expire this post after 1h" visible
3325 + // to the engine but invisible to the drop-in, which kept serving the
3326 + // entry until the global lifetime elapsed (#240 AC#3). Handing the
3327 + // filter the same base as is_expired() also keeps a filter that
3328 + // SCALES its input (e.g. $max_age * 2) consistent between the two.
3329 + $opts = Settings_Manager::get( 'cache' );
3330 + $default_ttl = (int) $opts['cache_expiry'] * HOUR_IN_SECONDS;
3331 + $max_age = $default_ttl;
3332 + $post_override = Cache_Rules::expiry_override_seconds_for_post( Cache_Rules::current_post_id() );
3333 + if ( null !== $post_override ) {
3334 + $max_age = $post_override;
3335 + }
3336 + /** This filter is documented in includes/class-cache.php */
3337 + $ttl = (int) apply_filters( 'xspeed_cache_max_age', $max_age );
3338 +
3339 + // A response carrying a nonce may not outlive that nonce, however
3340 + // long the site's configured lifetime is (#236). This runs AFTER the
3341 + // max-age filter so it caps whatever the filter resolved rather than
3342 + // being overridden by it — a Pro module lengthening the TTL must not
3343 + // be able to reintroduce a dead nonce.
3344 + $ttl = self::nonce_capped_ttl( $html, $ttl );
3345 +
1216 3346 if ( $ttl > 0 && $ttl !== $default_ttl ) {
1217 3347 $meta['ttl'] = $ttl;
1218 3348 }
1219 3349
3350 + // This entry's edge headers, when they differ from the site-wide set
3351 + // baked into the drop-in. The sidecar is the only channel that can
3352 + // carry a per-page answer into the pre-boot fast path, and the drop-in
3353 + // REPLACES the baked set with it rather than merging: the two describe
3354 + // the same response, so merging would leave the baked lifetime in
3355 + // place beside the hold meant to overrule it.
3356 + $edge = self::per_entry_edge_headers();
3357 + if ( array() !== $edge ) {
3358 + $meta['edge_headers'] = $edge;
3359 + }
3360 +
1220 3361 // Nothing to replay → no sidecar.
1221 3362 if ( empty( $meta ) ) {
1222 3363 return;
1223 3364 }
@@ -1247,17 +3388,754 @@
1247 3388 * @param string $url Absolute URL, or site-relative path ("/about/").
1248 3389 * @param string $cause Who asked, for the purge log. See purge_all().
1249 3390 * @return int Number of cache files removed.
1250 3391 */
3392 + /**
3393 + * Post types that are not "viewable" but ARE the presentation layer.
3394 + *
3395 + * `is_post_type_viewable()` answers "does this type have a front end of
3396 + * its own?" — which is the right question for `shop_order`, but the
3397 + * wrong one for the types core uses to render every OTHER page. A
3398 + * template part, a global-styles record, a navigation or a synced
3399 + * pattern has no permalink, yet editing one changes how the whole site
3400 + * looks. Gating purges on viewability alone meant a Site Editor save
3401 + * invalidated nothing and visitors kept the old design for the full
3402 + * TTL — up to 30 days at the maximum lifetime. (#270 regression)
3403 + *
3404 + * @return string[]
3405 + */
3406 + /**
3407 + * Could this post change alter anything an anonymous visitor had cached?
3408 + *
3409 + * Deleting one post fired a full purge for the post AND for every stored
3410 + * revision, because wp_delete_post() removes each revision through
3411 + * wp_delete_post() again and every one of those fires before_delete_post
3412 + * with post_type 'revision'. A post with six revisions cost seven whole-
3413 + * site sweeps, each one also announcing to LiteSpeed, purging the object
3414 + * cache network-wide on Redis, rewriting the stats option and running
3415 + * every xspeed_after_purge_all listener -- including Pro's Cloudflare
3416 + * purge, so seven API calls. Trashing cost two, via save_post and then
3417 + * trashed_post. (QA #348)
3418 + *
3419 + * The check lives here, ahead of purge_all(), so one early return covers
3420 + * the local sweep, the server-cache announcement and both action hooks.
3421 + * It deliberately does NOT live inside purge_all(): a manual, CLI or
3422 + * explicit caller asked for a purge and must get one.
3423 + *
3424 + * @param int $post_id Post being saved or removed.
3425 + * @param mixed $post Post object when the hook passed one.
3426 + * @param string $event 'save' or 'remove'.
3427 + */
3428 + private static function post_change_is_cacheable_content( $post_id, $post, string $event ): bool {
3429 + $post_id = (int) $post_id;
3430 +
3431 + // Only `save_post` and `before_delete_post` hand over a post object.
3432 + // `trashed_post` passes ( $post_id, $previous_status ) -- a STRING --
3433 + // so reaching for ->post_status on the second argument finds nothing
3434 + // and the status rule below would never fire. Read the row instead.
3435 + if ( ! is_object( $post ) && function_exists( 'get_post' ) ) {
3436 + $post = get_post( $post_id );
3437 + }
3438 +
3439 + $type = is_object( $post ) && isset( $post->post_type )
3440 + ? (string) $post->post_type
3441 + : (string) ( function_exists( 'get_post_type' ) ? get_post_type( $post_id ) : '' );
3442 + if ( '' === $type ) {
3443 + return false;
3444 + }
3445 +
3446 + // A revision is a copy of content nobody can browse to.
3447 + if ( 'revision' === $type ) {
3448 + return false;
3449 + }
3450 + if ( function_exists( 'wp_is_post_revision' ) && wp_is_post_revision( $post_id ) ) {
3451 + return false;
3452 + }
3453 + if ( function_exists( 'wp_is_post_autosave' ) && wp_is_post_autosave( $post_id ) ) {
3454 + return false;
3455 + }
3456 +
3457 + $status = is_object( $post ) && isset( $post->post_status ) ? (string) $post->post_status : '';
3458 +
3459 + // Clicking "Add New" inserts an auto-draft and fires save_post. There
3460 + // is nothing cached of a post that has never existed publicly.
3461 + if ( 'auto-draft' === $status ) {
3462 + return false;
3463 + }
3464 +
3465 + // Unknown/!viewable → nothing anonymous can see changed, UNLESS the
3466 + // type is itself part of how pages render (#270 regression).
3467 + if ( function_exists( 'is_post_type_viewable' )
3468 + && ! is_post_type_viewable( $type )
3469 + && ! in_array( $type, self::presentation_post_types(), true )
3470 + ) {
3471 + return false;
3472 + }
3473 +
3474 + // Deleting something that was already invisible changes no cached
3475 + // page: the transition that hid it purged at the time. This is what
3476 + // makes emptying a trash of a hundred posts cost nothing rather than
3477 + // a hundred full sweeps.
3478 + //
3479 + // It also collapses trashing to a single purge: wp_trash_post() fires
3480 + // save_post first, where the post is genuinely disappearing from
3481 + // listings and SHOULD purge, then trashed_post, by which point the
3482 + // row reads 'trash' and is skipped. A status we cannot read, on a row
3483 + // that still reports a type, means assume viewable -- erring toward
3484 + // an extra purge, never toward serving a stale page. A row that is
3485 + // gone entirely reports no type either and was refused above.
3486 + // 'inherit' is an INTERNAL status in core, so is_post_status_viewable()
3487 + // says no -- but an attachment carrying it is genuinely public. Judge
3488 + // those on the post type alone, which is already checked above.
3489 + if ( 'remove' === $event && '' !== $status && 'inherit' !== $status
3490 + && function_exists( 'is_post_status_viewable' )
3491 + && ! is_post_status_viewable( $status )
3492 + ) {
3493 + return false;
3494 + }
3495 +
3496 + return true;
3497 + }
3498 +
3499 + public static function presentation_post_types(): array {
3500 + $types = array(
3501 + 'wp_template', // Site Editor templates.
3502 + 'wp_template_part', // Header / footer / reusable parts.
3503 + 'wp_global_styles', // Colours, typography, spacing.
3504 + 'wp_navigation', // Navigation block menus.
3505 + 'nav_menu_item', // Classic menus.
3506 + 'wp_block', // Synced patterns / reusable blocks.
3507 + );
3508 +
3509 + /**
3510 + * Filter the non-viewable post types that still invalidate the cache.
3511 + *
3512 + * Add a type here when it has no front end of its own but changes
3513 + * how other pages render (a theme's own layout CPT, for example).
3514 + *
3515 + * @param string[] $types Post type slugs.
3516 + */
3517 + return (array) apply_filters( 'xspeed_presentation_post_types', $types );
3518 + }
3519 +
3520 + /**
3521 + * Describe a broad hook invalidation for response-cache adapters.
3522 + *
3523 + * Term, menu, theme and plugin changes can alter navigation, archives or
3524 + * markup across the site, so they require a site response-cache purge.
3525 + * Content saves also require this scope while their local operation is a
3526 + * complete bucket sweep.
3527 + *
3528 + * A new term is `content`, not `presentation`. It has no posts yet, so no
3529 + * page renders it until a post is saved with it, and that save is its own
3530 + * content purge. Classed as presentation, it cleared the host's whole
3531 + * nginx cache every time a post was published with a tag that did not
3532 + * exist yet, which is most publishing. Renaming or deleting a term stays
3533 + * presentation: the new name shows on every post in the term, and Nginx
3534 + * Helper purges only the homepage for either. (QA #448)
3535 + *
3536 + * @return array{scope:string,intent:string,urls:array<int,string>}
3537 + */
3538 + private static function invalidation_for_hook( string $hook ): array {
3539 + $presentation = array(
3540 + 'switch_theme',
3541 + 'activated_plugin',
3542 + 'deactivated_plugin',
3543 + 'edited_term',
3544 + 'delete_term',
3545 + 'wp_update_nav_menu',
3546 + );
3547 +
3548 + return array(
3549 + 'scope' => 'site',
3550 + 'intent' => in_array( $hook, $presentation, true ) ? 'presentation' : 'content',
3551 + 'urls' => array(),
3552 + );
3553 + }
3554 +
3555 +
3556 + /**
3557 + * save_post → purge only when the saved thing can appear on a cached page.
3558 + *
3559 + * Revisions and autosaves are never rendered. Non-viewable post types —
3560 + * WooCommerce's `shop_order` / `shop_order_placehold` / `shop_order_refund`
3561 + * / `shop_coupon`, Flamingo's `flamingo_inbound` (#229), Tutor's
3562 + * `tutor_enrolled` (#231) — are invisible to anonymous visitors, so
3563 + * writing one changes nothing that is cached. (#243)
3564 + *
3565 + * The exception is the presentation types above, which are non-viewable
3566 + * yet render every page — they are allow-listed BEFORE the viewability
3567 + * test. (#270 regression)
3568 + *
3569 + * @param int $post_id Saved post ID.
3570 + * @param \WP_Post $post Saved post object.
3571 + */
3572 + public static function on_save_post( $post_id, $post = null ): void {
3573 + if ( ! self::post_change_is_cacheable_content( $post_id, $post, 'save' ) ) {
3574 + return;
3575 + }
3576 +
3577 + $post_type = is_object( $post ) && isset( $post->post_type )
3578 + ? (string) $post->post_type
3579 + : (string) get_post_type( $post_id );
3580 +
3581 + // Name the trigger rather than logging a bare numeric id — the old
3582 + // wiring passed the post ID into $cause, so the log read
3583 + // "Cache purged (46)" with no indication of what caused it. (#243)
3584 + $presentation = in_array( $post_type, self::presentation_post_types(), true );
3585 + self::purge_all(
3586 + 'post:' . $post_type,
3587 + null,
3588 + array(
3589 + // purge_all() sweeps every local response in this site's bucket.
3590 + // Without dependency tracking, the server cache must match that
3591 + // same boundary or unrelated pages can remain stale there.
3592 + 'scope' => 'site',
3593 + 'intent' => $presentation ? 'presentation' : 'content',
3594 + 'urls' => array(),
3595 + )
3596 + );
3597 + if ( class_exists( '\XSpeed\Minifier' ) ) {
3598 + Minifier::purge_minified();
3599 + }
3600 + }
3601 +
3602 + /**
3603 + * Delete/trash invalidation while the post type is still available.
3604 + * The local and server response-cache sweeps share the same site boundary.
3605 + *
3606 + * @param int $post_id Removed post ID.
3607 + * @param object|null $post Post object supplied by core when available.
3608 + */
3609 + public static function on_post_removed( $post_id, $post = null ): void {
3610 + if ( ! self::post_change_is_cacheable_content( $post_id, $post, 'remove' ) ) {
3611 + return;
3612 + }
3613 +
3614 + $post_type = is_object( $post ) && isset( $post->post_type )
3615 + ? (string) $post->post_type
3616 + : (string) get_post_type( $post_id );
3617 +
3618 + self::purge_all(
3619 + 'post-removed:' . $post_type,
3620 + null,
3621 + array(
3622 + 'scope' => 'site',
3623 + // Match on_save_post: a presentation type changes how pages
3624 + // render rather than what they say.
3625 + 'intent' => in_array( $post_type, self::presentation_post_types(), true )
3626 + ? 'presentation'
3627 + : 'content',
3628 + 'urls' => array(),
3629 + )
3630 + );
3631 + }
3632 +
3633 + /** Purge site responses when moderation changes visible comments. */
3634 + public static function on_comment_status( $comment_id, $status = '' ): void {
3635 + $comment = function_exists( 'get_comment' ) ? get_comment( (int) $comment_id ) : null;
3636 + $post_id = is_object( $comment ) && isset( $comment->comment_post_ID ) ? (int) $comment->comment_post_ID : 0;
3637 + if ( $post_id < 1 || ! function_exists( 'get_permalink' ) ) {
3638 + return;
3639 + }
3640 + $url = get_permalink( $post_id );
3641 + if ( ! is_string( $url ) || '' === $url ) {
3642 + return;
3643 + }
3644 + self::purge_all(
3645 + 'comment-status:' . (string) $status,
3646 + null,
3647 + array(
3648 + 'scope' => 'site',
3649 + 'intent' => 'content',
3650 + 'urls' => array(),
3651 + )
3652 + );
3653 + }
3654 +
3655 + /**
3656 + * comment_post → purge just the commented-on URL, and only once the
3657 + * comment is actually visible.
3658 + *
3659 + * A comment held for moderation changes nothing on the front end, and an
3660 + * approved one changes exactly one page — not the whole site. Product
3661 + * reviews are comments and guest reviews are on by default, so under the
3662 + * old wiring any visitor could flush a store's entire cache, repeatedly,
3663 + * with no account. (#243)
3664 + *
3665 + * @param int $comment_id New comment ID.
3666 + * @param int|string $approved 1 when approved, 0 when held, 'spam'.
3667 + * @param array $data Comment data.
3668 + */
3669 + public static function on_comment_post( $comment_id, $approved = 0, $data = array() ): void {
3670 + if ( 1 !== (int) $approved ) {
3671 + return;
3672 + }
3673 + $post_id = is_array( $data ) && isset( $data['comment_post_ID'] ) ? (int) $data['comment_post_ID'] : 0;
3674 + if ( $post_id < 1 ) {
3675 + return;
3676 + }
3677 + $url = get_permalink( $post_id );
3678 + if ( is_string( $url ) && '' !== $url ) {
3679 + self::purge_url( $url, 'comment' );
3680 + }
3681 + }
3682 +
3683 + /**
3684 + * user_register / profile_update → purge only when the user can author
3685 + * content that appears on the front end.
3686 + *
3687 + * A customer registering at checkout changes no rendered page, and cannot
3688 + * change an enqueued asset — so it must not purge the cache, and must not
3689 + * rebuild the minified bundles. Checkout account-creation fired FOUR
3690 + * full-site purges plus four purge_minified() runs in a single request
3691 + * before this gate. (#243)
3692 + *
3693 + * @param int $user_id Affected user.
3694 + */
3695 + public static function on_user_change( $user_id ): void {
3696 + $user = function_exists( 'get_userdata' ) ? get_userdata( (int) $user_id ) : null;
3697 + if ( ! $user ) {
3698 + return;
3699 + }
3700 +
3701 + // Only roles that can publish can change a rendered page. WooCommerce
3702 + // customers and WordPress subscribers cannot.
3703 + if ( ! user_can( $user, 'edit_posts' ) ) {
3704 + return;
3705 + }
3706 +
3707 + $url = get_author_posts_url( (int) $user_id );
3708 + if ( is_string( $url ) && '' !== $url ) {
3709 + self::purge_url( $url, 'user' );
3710 + }
3711 + }
3712 +
3713 + /**
3714 + * Purge everything a product's price / stock / sale state is rendered on.
3715 + *
3716 + * The product permalink is not enough: the shop archive and the product's
3717 + * category and tag archives render the same price and Sale! badge, and
3718 + * #242 reproduces all three going stale together.
3719 + *
3720 + * Accepts a product ID or a WC_Product. A variation resolves to its
3721 + * parent, which is the page that actually renders.
3722 + *
3723 + * @param int|object $product Product ID or WC_Product.
3724 + */
3725 + public static function purge_product( $product ): void {
3726 + $product_id = is_object( $product ) && method_exists( $product, 'get_id' )
3727 + ? (int) $product->get_id()
3728 + : (int) $product;
3729 + if ( $product_id < 1 ) {
3730 + return;
3731 + }
3732 +
3733 + // Variations are never rendered on their own URL.
3734 + $parent = (int) wp_get_post_parent_id( $product_id );
3735 + if ( $parent > 0 ) {
3736 + $product_id = $parent;
3737 + }
3738 +
3739 + $urls = array();
3740 +
3741 + $permalink = get_permalink( $product_id );
3742 + if ( is_string( $permalink ) && '' !== $permalink ) {
3743 + $urls[] = $permalink;
3744 + }
3745 +
3746 + // The shop archive.
3747 + if ( function_exists( 'wc_get_page_id' ) ) {
3748 + $shop_id = (int) wc_get_page_id( 'shop' );
3749 + if ( $shop_id > 0 ) {
3750 + $shop_url = get_permalink( $shop_id );
3751 + if ( is_string( $shop_url ) && '' !== $shop_url ) {
3752 + $urls[] = $shop_url;
3753 + }
3754 + }
3755 + }
3756 +
3757 + // Every category / tag archive this product appears on.
3758 + foreach ( array( 'product_cat', 'product_tag' ) as $taxonomy ) {
3759 + $terms = get_the_terms( $product_id, $taxonomy );
3760 + if ( ! is_array( $terms ) ) {
3761 + continue;
3762 + }
3763 + foreach ( $terms as $term ) {
3764 + $term_url = get_term_link( $term );
3765 + if ( is_string( $term_url ) && '' !== $term_url ) {
3766 + $urls[] = $term_url;
3767 + }
3768 + }
3769 + }
3770 +
3771 + // The front page, when it is not the shop page but still lists
3772 + // products (a block/shortcode storefront).
3773 + $front_id = (int) get_option( 'page_on_front' );
3774 + if ( $front_id > 0 ) {
3775 + $front_url = get_permalink( $front_id );
3776 + if ( is_string( $front_url ) && '' !== $front_url ) {
3777 + $urls[] = $front_url;
3778 + }
3779 + }
3780 +
3781 + /**
3782 + * Filter the URLs purged when a product changes.
3783 + *
3784 + * A storefront that renders products somewhere else — a landing page,
3785 + * a custom archive — can add its URLs here rather than falling back
3786 + * to purging the whole site.
3787 + *
3788 + * @param string[] $urls URLs about to be purged.
3789 + * @param int $product_id The product that changed.
3790 + */
3791 + $urls = (array) apply_filters( 'xspeed_purge_product_urls', $urls, $product_id );
3792 +
3793 + foreach ( array_unique( array_filter( $urls ) ) as $url ) {
3794 + self::purge_url( (string) $url, 'product' );
3795 + }
3796 + }
3797 +
3798 + /**
3799 + * Adapter for the WooCommerce stock actions that pass a product OBJECT
3800 + * where the status actions pass an ID.
3801 + *
3802 + * @param object $product WC_Product (or variation).
3803 + */
3804 + public static function purge_product_object( $product ): void {
3805 + self::purge_product( $product );
3806 + }
3807 +
3808 + /**
3809 + * Re-entry guard for the purge-event contract.
3810 + *
3811 + * A listener on `xspeed_after_purge_url` legitimately purges its own
3812 + * layer, and a server-cache or CDN adapter that calls back into xSpeed
3813 + * while doing so re-enters this method — unbounded, because each pass
3814 + * looks like a fresh purge.
3815 + *
3816 + * A single global flag stops too much: a nested purge of a DIFFERENT URL is
3817 + * a real purge whose listeners must hear about it. But a per-request
3818 + * "already published" set stops too much in the other direction — a
3819 + * network purge loops every blog in one request, and on a subdirectory
3820 + * network they share a host, so blogs 2..N would be silently skipped. It
3821 + * also grows for the life of the process.
3822 + *
3823 + * So the guard tracks what is IN FLIGHT, not what has been published: a
3824 + * target is marked while its own dispatch is on the stack and unmarked
3825 + * when it returns. Re-entering the same target recurses, so it is refused;
3826 + * purging the same URL again later is a new event and publishes. The set
3827 + * is bounded by call depth rather than by how many URLs a request touches.
3828 + *
3829 + * @var array<string,bool>
3830 + */
3831 + private static $purge_events_in_flight = array();
3832 +
3833 + /** Monotonic count used to detect whether a delegated purge published. */
3834 + private static $purge_event_sequence = 0;
3835 +
3836 + /**
3837 + * Publish a purge event exactly once, with bounded arguments.
3838 + *
3839 + * Deliberately carries only what an integration needs to invalidate its
3840 + * own copy: the canonical URL (or null for a full purge), the site host,
3841 + * the cause label, and how many files went. No filesystem paths, no cache
3842 + * contents, no request headers, no user data. The URL query and caller-
3843 + * supplied cause may nevertheless contain sensitive text, so listeners
3844 + * must redact them in logs or unrelated destinations that do not need the
3845 + * exact cache key.
3846 + *
3847 + * A listener that throws must not take the purge down with it: the files
3848 + * are already gone by the time we get here, and an integration's bad day
3849 + * is not a reason to report a failed purge to the caller.
3850 + *
3851 + * @param string $hook Hook name to emit.
3852 + * @param array<string,mixed> $context Bounded context, see above.
3853 + */
3854 + private static function dispatch_purge_event( string $hook, array $context ): void {
3855 + if ( ! function_exists( 'do_action' ) ) {
3856 + return;
3857 + }
3858 + $target = $hook . '|' . ( isset( $context['url'] ) ? (string) $context['url'] : '' )
3859 + . '|' . ( isset( $context['host'] ) ? (string) $context['host'] : '' );
3860 + if ( isset( self::$purge_events_in_flight[ $target ] ) ) {
3861 + return;
3862 + }
3863 + self::$purge_events_in_flight[ $target ] = true;
3864 + ++self::$purge_event_sequence;
3865 +
3866 + // Our own integrations get their own try. Sharing one with the public
3867 + // action below meant a listener on the extension seam could throw and
3868 + // take the contract event down with it — the mirror of the failure
3869 + // this separation exists to prevent.
3870 + try {
3871 + // Built-in server-cache integrations run FIRST, and by a direct
3872 + // call rather than as listeners on the action below.
3873 + //
3874 + // WordPress stops dispatching an action's remaining callbacks when
3875 + // one of them throws. As a listener, our LiteSpeed forwarding
3876 + // would then be skipped by any unrelated third-party callback that
3877 + // happened to be registered earlier and blew up — and the visible
3878 + // result is the worst kind: xSpeed reports a successful purge while
3879 + // the server keeps serving stale HTML. Shipped behaviour must not
3880 + // be hostage to a listener's bug.
3881 + self::forward_to_server_caches( $context );
3882 + } catch ( \Throwable $e ) {
3883 + self::log_purge_listener_error( $hook, $e );
3884 + }
3885 +
3886 + try {
3887 + self::do_action_isolated( $hook, $context );
3888 + } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
3889 + // Swallow: see docblock. The purge succeeded regardless.
3890 + self::log_purge_listener_error( $hook, $e );
3891 + } finally {
3892 + unset( self::$purge_events_in_flight[ $target ] );
3893 + }
3894 + }
3895 +
3896 + /**
3897 + * Run every listener on a purge hook, isolating each from the others.
3898 + *
3899 + * `do_action()` dispatches callbacks in one loop, so the first one to
3900 + * throw takes every LATER listener down with it. On a purge that meant a
3901 + * failing CDN integration silently cancelled the ones queued behind it —
3902 + * and because the throw was swallowed to keep the purge itself succeeding,
3903 + * the user was told the clear worked while two edges were never touched.
3904 + * Invisible unless WP_DEBUG happened to be on. (QA #348)
3905 + *
3906 + * Each callback gets its own try/catch here, so one integration's bad day
3907 + * costs only that integration. Priority order is preserved. Falls back to
3908 + * a plain `do_action()` when the filter registry is not the shape we
3909 + * expect, so an unusual environment degrades to the old behaviour rather
3910 + * than skipping listeners entirely.
3911 + *
3912 + * @param string $hook Hook name to emit.
3913 + * @param mixed $arg Single argument passed to each listener.
3914 + */
3915 + public static function do_action_isolated( string $hook, $arg ): void {
3916 + global $wp_filter;
3917 +
3918 + // Walking $wp_filter by hand and calling each callback directly was the
3919 + // obvious way to do this, and it was wrong: it bypasses WordPress, so
3920 + // `current_filter()` came back empty, `did_action()` stayed at 0, the
3921 + // `all` hook never fired, and Query Monitor and Debug Bar could not see
3922 + // the very contract this class publishes. A shared handler branching on
3923 + // current_filter() picked the wrong branch. (QA #348 round 2, issue 3)
3924 + //
3925 + // So let do_action() dispatch — WordPress keeps its bookkeeping — and
3926 + // isolate one level down instead: each registered callback is swapped
3927 + // for a wrapper that runs it inside a try/catch. One listener throwing
3928 + // then costs only that listener, which is the whole point, without
3929 + // costing the hook its identity.
3930 + if ( ! isset( $wp_filter[ $hook ] ) || ! ( $wp_filter[ $hook ] instanceof \WP_Hook ) ) {
3931 + do_action( $hook, $arg );
3932 + return;
3933 + }
3934 +
3935 + $hook_object = $wp_filter[ $hook ];
3936 + $original = $hook_object->callbacks;
3937 + if ( ! is_array( $original ) || array() === $original ) {
3938 + do_action( $hook, $arg );
3939 + return;
3940 + }
3941 +
3942 + $wrapped = array();
3943 + $restorations = array();
3944 + foreach ( $original as $priority => $group ) {
3945 + if ( ! is_array( $group ) ) {
3946 + $wrapped[ $priority ] = $group;
3947 + continue;
3948 + }
3949 + foreach ( $group as $id => $registered ) {
3950 + if ( ! isset( $registered['function'] ) || ! is_callable( $registered['function'] ) ) {
3951 + $wrapped[ $priority ][ $id ] = $registered;
3952 + continue;
3953 + }
3954 + $callback = $registered['function'];
3955 + $wrapper = static function ( ...$args ) use ( $callback, $hook ) {
3956 + try {
3957 + return $callback( ...$args );
3958 + } catch ( \Throwable $e ) {
3959 + self::log_purge_listener_error( $hook, $e );
3960 + return null;
3961 + }
3962 + };
3963 + $wrapped[ $priority ][ $id ] = array(
3964 + // Keep accepted_args: a listener registered for 0 or 1
3965 + // arguments must still be called the way it asked.
3966 + 'accepted_args' => $registered['accepted_args'] ?? 1,
3967 + 'function' => $wrapper,
3968 + );
3969 + $restorations[ $priority ][ $id ] = array(
3970 + 'original' => $registered,
3971 + 'wrapper' => $wrapper,
3972 + );
3973 + }
3974 + }
3975 +
3976 + $hook_object->callbacks = $wrapped;
3977 + try {
3978 + do_action( $hook, $arg );
3979 + } finally {
3980 + // Restore only wrappers still present. Native add/remove operations
3981 + // performed by listeners must survive this temporary substitution.
3982 + foreach ( $restorations as $priority => $group ) {
3983 + foreach ( $group as $id => $restore ) {
3984 + $current = $hook_object->callbacks[ $priority ][ $id ]['function'] ?? null;
3985 + if ( $current === $restore['wrapper'] ) {
3986 + $hook_object->callbacks[ $priority ][ $id ] = $restore['original'];
3987 + }
3988 + }
3989 + }
3990 + }
3991 + }
3992 +
3993 + /**
3994 + * Name a listener that threw, under WP_DEBUG only.
3995 + *
3996 + * Gated like the rest of Free's diagnostics: a third-party listener
3997 + * throwing on every purge must not fill a production log.
3998 + */
3999 + private static function log_purge_listener_error( string $hook, \Throwable $e ): void {
4000 + // An \Error — a TypeError from one of OUR listeners, say — is a bug
4001 + // rather than a runtime condition a third party imposed on us, and
4002 + // swallowing it silently in production turns it into a purge that
4003 + // quietly stops working. Those are logged whatever WP_DEBUG says;
4004 + // third-party \Exceptions stay gated so a noisy integration cannot
4005 + // fill a production log.
4006 + $always = $e instanceof \Error;
4007 + if ( ( $always || ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ) && function_exists( 'error_log' ) ) {
4008 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- names a third-party listener that threw during a purge.
4009 + error_log( '[xspeed] a ' . $hook . ' listener threw: ' . $e->getMessage() );
4010 + }
4011 + }
4012 +
4013 + /** Test seam: clear the in-flight set left behind by an aborted dispatch. */
4014 + public static function reset_purge_events(): void {
4015 + self::$purge_events_in_flight = array();
4016 + self::$purge_event_sequence = 0;
4017 + }
4018 +
4019 + /**
4020 + * Hand the purge to the caches we ship integrations for.
4021 + *
4022 + * Isolated from the public action on purpose — see dispatch_purge_event().
4023 + * Guarded so a missing class (a partial upgrade, a stripped build) cannot
4024 + * turn a working purge into a fatal.
4025 + *
4026 + * @param array<string,mixed> $context Bounded purge context.
4027 + */
4028 + private static function forward_to_server_caches( array $context ): void {
4029 + if ( class_exists( __NAMESPACE__ . '\\Server_Caches' ) ) {
4030 + Server_Caches::forward( $context );
4031 + }
4032 + }
4033 +
4034 + /**
4035 + * `host[:port]` for a cache key, from a parsed URL.
4036 + *
4037 + * The port is kept, because `cache_key()` hashes the raw `HTTP_HOST` and
4038 + * that carries `:8080` on any install not served from 80/443 — dropping it
4039 + * computed a different md5, found no file, and reported "already cold"
4040 + * while the page kept serving HIT.
4041 + *
4042 + * A port that is the DEFAULT for the scheme is dropped, though, because
4043 + * `HTTP_HOST` does not carry one: a browser sends `Host: site.com` for
4044 + * `https://site.com:443/`. Keeping it hashed `site.com:443` against a file
4045 + * stored under `site.com` — the same silent no-op in the other direction,
4046 + * and the one QA hit passing a canonical URL with the port spelled out.
4047 + * (QA #348)
4048 + *
4049 + * @param array<string,mixed> $parts Output of wp_parse_url().
4050 + */
4051 + private static function host_port_of( array $parts ): string {
4052 + if ( ! isset( $parts['host'] ) ) {
4053 + return '';
4054 + }
4055 + $host = strtolower( (string) $parts['host'] );
4056 + if ( '' === $host || ! isset( $parts['port'] ) ) {
4057 + return $host;
4058 + }
4059 + $port = (int) $parts['port'];
4060 + $scheme = isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : '';
4061 + if ( ( 'https' === $scheme && 443 === $port ) || ( 'http' === $scheme && 80 === $port ) ) {
4062 + return $host;
4063 + }
4064 + return $host . ':' . $port;
4065 + }
4066 +
1251 4067 public static function purge_url( string $url, string $cause = 'manual' ): int {
4068 + // A URL that names nothing is not a purge of everything. An empty or
4069 + // blank string used to fall through to the home_url() default below
4070 + // and clear the HOMEPAGE — so a third party calling
4071 + // `purge_url( get_permalink( $id ) )` on a post whose permalink came
4072 + // back empty silently purged the front page instead of nothing. The
4073 + // CLI and the MCP tool reject empties before reaching this, so only
4074 + // direct API callers were exposed, but they are exactly the audience
4075 + // this public contract is for. (QA #348)
4076 + if ( '' === trim( $url ) ) {
4077 + return 0;
4078 + }
1252 4079 $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.
1253 4080 if ( ! is_array( $parts ) ) {
1254 4081 return 0;
1255 4082 }
1256 - $host = isset( $parts['host'] ) ? strtolower( (string) $parts['host'] ) : '';
4083 + // Absolute URLs are accepted only for HTTP response caches. Schemes such
4084 + // as ftp:, file: and javascript: can parse cleanly but do not name a page
4085 + // xSpeed or a server response cache can invalidate. A leading-slash path
4086 + // remains a supported site-relative target.
4087 + if ( isset( $parts['scheme'] ) && ! in_array( strtolower( (string) $parts['scheme'] ), array( 'http', 'https' ), true ) ) {
4088 + return 0;
4089 + }
4090 + if ( isset( $parts['scheme'] ) && empty( $parts['host'] ) ) {
4091 + return 0;
4092 + }
4093 + // Reject a string that parsed but is not a URL we can act on: no
4094 + // scheme AND no host AND no leading-slash path means something like
4095 + // `ht!tp://[[[` or a bare word, which parse_url() hands back as a
4096 + // relative "path". Forwarding that produced `purge_url(/ht!tp://[[[)`
4097 + // — a nonsense tag sent to LiteSpeed for every malformed call.
4098 + if ( ! isset( $parts['scheme'] ) && ! isset( $parts['host'] ) ) {
4099 + $raw = isset( $parts['path'] ) ? (string) $parts['path'] : '';
4100 + if ( '' === $raw || '/' !== $raw[0] ) {
4101 + return 0;
4102 + }
4103 + }
4104 + // Keep the port. `cache_key()` hashes the raw `HTTP_HOST`, which
4105 + // carries `:8080` on any install not served from 80/443 — while
4106 + // parse_url() splits the port into its own component, so a purge that
4107 + // used the bare host computed a different md5, found no file, and
4108 + // reported "already cold". A silent no-op: the page kept serving HIT
4109 + // until its TTL ran out. Intranet installs, panel hosts on :8443 and
4110 + // proxies that forward `Host: site.com:8080` all hit this.
4111 + // A scheme-less `site.test:443/page/` is a supported explicit-host
4112 + // target. Infer a scheme only when it names THIS site's hostname: then
4113 + // its explicit default port is the same origin and the same local cache
4114 + // key. Never apply this to another host or to a non-default port.
4115 + if ( ! isset( $parts['scheme'] ) && isset( $parts['host'], $parts['port'] ) && function_exists( 'home_url' ) ) {
4116 + $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.
4117 + if ( is_array( $home ) && ! empty( $home['host'] ) && ! empty( $home['scheme'] )
4118 + && strtolower( (string) $home['host'] ) === strtolower( (string) $parts['host'] )
4119 + ) {
4120 + $home_scheme = strtolower( (string) $home['scheme'] );
4121 + $port = (int) $parts['port'];
4122 + $home_port = isset( $home['port'] )
4123 + ? (int) $home['port']
4124 + : ( 'https' === $home_scheme ? 443 : ( 'http' === $home_scheme ? 80 : 0 ) );
4125 + if ( $home_port === $port
4126 + && ( ( 'https' === $home_scheme && 443 === $port ) || ( 'http' === $home_scheme && 80 === $port ) )
4127 + ) {
4128 + $parts['scheme'] = $home_scheme;
4129 + }
4130 + }
4131 + }
4132 + $host = self::host_port_of( $parts );
1257 4133 if ( '' === $host && function_exists( 'home_url' ) ) {
1258 4134 $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.
1259 - $host = is_array( $home ) && isset( $home['host'] ) ? strtolower( (string) $home['host'] ) : '';
4135 + if ( is_array( $home ) ) {
4136 + $host = self::host_port_of( $home );
4137 + }
1260 4138 }
1261 4139 if ( '' === $host ) {
1262 4140 return 0;
1263 4141 }
@@ -1275,19 +4153,34 @@
1275 4153 $forms[] = rtrim( $path, '/' ) . '/';
1276 4154 }
1277 4155 $forms = array_unique( $forms );
1278 4156
4157 + /*
4158 + * Entries live under the bucket they were written for, and this URL's
4159 + * site may not be the one serving THIS request (a cross-site purge on
4160 + * multisite, WP-CLI, or cron). Build the directory from the URL's own
4161 + * host AND path. (#6)
4162 + *
4163 + * Host alone is wrong on a subdirectory network: `store()` wrote to
4164 + * `<host>/<prefix>/`, so looking in `<host>/` found nothing and the
4165 + * call reported "already cold" while the page kept serving HIT — a
4166 + * false success, which is worse than an error. The prefix has to come
4167 + * from the URL being purged rather than from the current blog, because
4168 + * the caller is usually purging some OTHER site. (QA B2 on #166)
4169 + */
4170 + $base = XSPEED_CACHE_DIR . '/' . self::bucket_for_url( $host, $path );
4171 +
1279 4172 $count = 0;
1280 4173 foreach ( $forms as $uri ) {
1281 4174 // '' = mobile_separate off; '|m' / '|d' = the device buckets.
1282 4175 foreach ( array( '', '|m', '|d' ) as $device ) {
1283 4176 $key = md5( $host . $uri . $device );
1284 - $file = self::cache_file_for( $key );
4177 + $file = $base . '/' . $key . '.html';
1285 4178 if ( is_file( $file ) ) {
1286 4179 wp_delete_file( $file );
1287 4180 ++$count;
1288 4181 }
1289 - foreach ( array( XSPEED_CACHE_DIR . '/' . $key . '.meta', $file . '.br' ) as $sidecar ) {
4182 + foreach ( array( $base . '/' . $key . '.meta', $file . '.br', self::brotli_size_sidecar( $file . '.br' ) ) as $sidecar ) {
1290 4183 if ( is_file( $sidecar ) ) {
1291 4184 wp_delete_file( $sidecar );
1292 4185 }
1293 4186 }
@@ -1295,16 +4188,20 @@
1295 4188 }
1296 4189
1297 4190 // Static tree (served directly by the nginx/.htaccess rewrite).
1298 4191 if ( defined( 'XSPEED_CACHE_STATIC_DIR' ) ) {
1299 - $dir = rtrim( XSPEED_CACHE_STATIC_DIR, '/' ) . '/' . $host . ( '/' === $path ? '' : rtrim( $path, '/' ) );
4192 + // Same transform the write used — `localhost:8080` files under
4193 + // `localhost8080`, so the bare host found nothing here either.
4194 + $dir = rtrim( XSPEED_CACHE_STATIC_DIR, '/' ) . '/' . self::static_host_dir( $host ) . ( '/' === $path ? '' : rtrim( $path, '/' ) );
1300 4195 $file = $dir . '/index.html';
1301 4196 if ( is_file( $file ) ) {
1302 4197 wp_delete_file( $file );
1303 4198 ++$count;
1304 4199 }
1305 - if ( is_file( $file . '.br' ) ) {
1306 - wp_delete_file( $file . '.br' );
4200 + foreach ( array( $file . '.br', self::brotli_size_sidecar( $file . '.br' ) ) as $sidecar ) {
4201 + if ( is_file( $sidecar ) ) {
4202 + wp_delete_file( $sidecar );
4203 + }
1307 4204 }
1308 4205 }
1309 4206
1310 4207 if ( $count > 0 ) {
@@ -1321,50 +4218,257 @@
1321 4218 Activity_Log::INFO
1322 4219 );
1323 4220 }
1324 4221
4222 + /**
4223 + * Fires after one URL's cached copy has been purged.
4224 + *
4225 + * The single-URL counterpart to `xspeed_after_purge_all`. Subscribe
4226 + * here to invalidate a cache xSpeed does not own — a server-level
4227 + * cache such as LiteSpeed's LSCache, a reverse proxy, or a CDN — for
4228 + * the same URL.
4229 + *
4230 + * Only fires when the purge actually ran. A malformed URL, a URL with
4231 + * no resolvable host, or a traversal attempt returns earlier and
4232 + * publishes nothing, so a listener can treat this as "xSpeed purged
4233 + * this URL" rather than "xSpeed was asked to". `removed` may legitimately
4234 + * be 0: the URL was not in xSpeed's cache, which says nothing about
4235 + * whether it is in yours.
4236 + *
4237 + * Fires at most once per purge. A listener that calls back into
4238 + * xSpeed's purge API will not re-enter this event.
4239 + *
4240 + * @since 1.2.3
4241 + *
4242 + * @param array $context {
4243 + * Bounded description of the purge. URL queries and caller-supplied
4244 + * causes can contain sensitive values and are not logging fields.
4245 + *
4246 + * @type string $url Canonical scheme://host/path[?query] of the purged URL.
4247 + * The query is preserved because caches in front
4248 + * commonly key on it; xSpeed's own sweep is
4249 + * path-based, so `removed` describes that.
4250 + * @type string $host Host (with port when non-standard).
4251 + * @type string $path Path component, leading slash.
4252 + * @type string $cause Short label for who asked. See purge_all().
4253 + * @type int $removed Number of cache files removed.
4254 + * @type string $scope Actionable adapter scope: `urls`.
4255 + * @type string $intent Why responses changed: `content`.
4256 + * @type string[] $urls Exact response URLs to invalidate.
4257 + * }
4258 + */
4259 + $canonical_url = self::canonical_purge_url(
4260 + $host,
4261 + $path,
4262 + isset( $parts['query'] ) ? (string) $parts['query'] : '',
4263 + isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : ''
4264 + );
4265 + self::dispatch_purge_event(
4266 + 'xspeed_after_purge_url',
4267 + array(
4268 + 'url' => $canonical_url,
4269 + 'host' => $host,
4270 + 'path' => $path,
4271 + 'cause' => $cause,
4272 + 'removed' => $count,
4273 + 'scope' => 'urls',
4274 + 'intent' => 'content',
4275 + 'urls' => array( $canonical_url ),
4276 + )
4277 + );
4278 +
1325 4279 return $count;
1326 4280 }
1327 4281
1328 - public static function purge_all( string $cause = 'manual' ) {
4282 + /** Host this site's purge is scoped to, for the purge-event context. */
4283 + private static function current_purge_host(): string {
4284 + if ( ! function_exists( 'home_url' ) ) {
4285 + return '';
4286 + }
4287 + $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.
4288 + if ( ! is_array( $home ) || empty( $home['host'] ) ) {
4289 + return '';
4290 + }
4291 + // Same default-port normalisation as purge_url(): a site whose
4292 + // home_url() carries `:443` (normal behind a proxy) otherwise stamps
4293 + // every full-purge event with a host that matches none of its own
4294 + // URLs, so the LiteSpeed forward stood down site-wide. (QA #348)
4295 + return self::host_port_of( $home );
4296 + }
4297 +
4298 + /**
4299 + * Rebuild the canonical URL a purge applied to.
4300 + *
4301 + * Built from the parts the purge itself used, so a listener is told the
4302 + * URL we acted on rather than the string the caller happened to pass —
4303 + * those differ whenever the caller supplied a site-relative path, a
4304 + * different scheme, or a query string the cache key ignores.
4305 + */
4306 + private static function canonical_purge_url( string $host, string $path, string $query = '', string $url_scheme = '' ): string {
4307 + // The purged URL's own scheme wins. purge_url() explicitly supports
4308 + // cross-site purges (multisite, WP-CLI, cron), where composing the
4309 + // current site's scheme onto another site's host builds a URL that was
4310 + // never served — and a CDN listener then purges the wrong key and
4311 + // reports success.
4312 + if ( '' !== $url_scheme ) {
4313 + return $url_scheme . '://' . $host . $path . ( '' !== $query ? '?' . $query : '' );
4314 + }
4315 + $scheme = function_exists( 'is_ssl' ) && is_ssl() ? 'https' : 'http';
4316 + if ( function_exists( 'home_url' ) ) {
4317 + $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.
4318 + if ( is_array( $home ) && ! empty( $home['scheme'] ) ) {
4319 + $scheme = (string) $home['scheme'];
4320 + }
4321 + }
4322 + // The query is carried even though OUR sweep above is path-based.
4323 + // Caches in front commonly key on the full request line — LiteSpeed
4324 + // tags `/shop/?page=2` separately from `/shop/` — so publishing the
4325 + // bare path would have a listener confidently purge the wrong entry
4326 + // and report success. Telling it exactly what was asked for lets it
4327 + // act correctly; `removed` still describes only what WE removed.
4328 + return $scheme . '://' . $host . $path . ( '' !== $query ? '?' . $query : '' );
4329 + }
4330 +
4331 + /**
4332 + * Sweep this site's cache files.
4333 + *
4334 + * On multisite every blog shares one cache directory, so an unscoped
4335 + * sweep here took the whole network cold — one subsite's settings save
4336 + * or post publish rebuilt every other site from PHP. Entries are stored
4337 + * per host (see host_dir()), and the sweep is scoped to match, so a
4338 + * purge originating on site-a leaves site-b's cache warm. (#6)
4339 + *
4340 + * Clears the files only: the flat tree, the static tree, the REST
4341 + * responses and the minified assets. The object-cache flush, the stats
4342 + * update, `xspeed_after_purge_all`, the `xspeed_after_purge` contract
4343 + * event and the log entry live in purge_all(), which is still the entry
4344 + * point for every existing caller. Split out so `wp xspeed purge` can
4345 + * report the local sweep as one line item and the object cache as
4346 + * another, each with its own status — see Purge_Runner.
4347 + *
4348 + * @param string|null $host Host to purge. Defaults to the current site.
4349 + * Pass '*' to sweep the ENTIRE tree — network
4350 + * admin's "purge all sites", and the migration
4351 + * of pre-#6 entries that sit in the tree root.
4352 + * @return array{pages:int,rest:int,assets:int,bytes:int} Entries removed
4353 + * per store, and the bytes freed by the two file sweeps
4354 + * that measure themselves.
4355 + */
4356 + public static function purge_local( ?string $host = null ): array {
4357 + $network_wide = ( '*' === $host );
4358 + self::$sweep_bytes = 0;
4359 + // The flat tree buckets by a flattened segment (host/a-b) while the
4360 + // static tree mirrors the URL (host/a/b), so they need separate
4361 + // scopes — see current_host_dir() vs current_static_scope().
4362 + $static_scope = '';
4363 + if ( null === $host || $network_wide ) {
4364 + $scope = $network_wide ? '' : self::current_host_dir();
4365 + $static_scope = $network_wide ? '' : self::current_static_scope();
4366 + } else {
4367 + $dir = self::host_dir( $host );
4368 + $scope = '' === $dir ? 'default' : $dir;
4369 + $static_dir = self::static_host_dir( $host );
4370 + $static_scope = '' === $static_dir ? 'default' : $static_dir;
4371 + }
4372 +
1329 4373 $count = 0;
1330 4374 if ( is_dir( XSPEED_CACHE_DIR ) ) {
1331 - $files = glob( XSPEED_CACHE_DIR . '/*.html' );
1332 - if ( $files ) {
1333 - $count = count( $files );
1334 - foreach ( $files as $f ) {
1335 - wp_delete_file( $f );
4375 + // Scoped to one host directory, or the whole tree (including the
4376 + // legacy top-level entries written before #6) when network-wide.
4377 + /*
4378 + * Network-wide sweeps go TWO levels deep, not one. A subdirectory
4379 + * subsite's bucket is `<host>/<prefix>/`, so globbing only
4380 + * `<cache>/*` reached the main site and left every subsite's
4381 + * entries in place. (QA D5 on #166)
4382 + *
4383 + * A scoped purge also has to cover its own nested buckets: when
4384 + * the main blog of a subdirectory network purges, `<host>/` is its
4385 + * bucket and `<host>/one/` belongs to another blog — so the scoped
4386 + * branch deliberately does NOT descend, which is what keeps
4387 + * site-level purges isolated.
4388 + */
4389 + $roots = $network_wide
4390 + ? array_merge(
4391 + array( XSPEED_CACHE_DIR ),
4392 + array_filter( (array) glob( XSPEED_CACHE_DIR . '/*', GLOB_ONLYDIR ) ),
4393 + array_filter( (array) glob( XSPEED_CACHE_DIR . '/*/*', GLOB_ONLYDIR ) )
4394 + )
4395 + : array( XSPEED_CACHE_DIR . '/' . $scope );
4396 +
4397 + foreach ( $roots as $root ) {
4398 + /*
4399 + * min/ and rest/ are swept by their own purgers below; never
4400 + * treat them as host buckets.
4401 + *
4402 + * Checked on every path SEGMENT, not just the basename: now
4403 + * that the network-wide glob descends two levels it can reach
4404 + * `min/combined`, whose basename is `combined` and would sail
4405 + * past a basename-only test — deleting the combined
4406 + * stylesheets out from under the pages that link them.
4407 + */
4408 + if ( ! $network_wide || XSPEED_CACHE_DIR !== $root ) {
4409 + $relative = trim( str_replace( XSPEED_CACHE_DIR, '', (string) $root ), '/' );
4410 + $segments = '' === $relative ? array() : explode( '/', $relative );
4411 + if ( array_intersect( $segments, array( 'min', 'rest' ) ) ) {
4412 + continue;
4413 + }
1336 4414 }
1337 - }
1338 - // Remove the .meta sidecars (content-type for feeds/sitemaps)
1339 - // alongside their .html entries. Not counted — they're not
1340 - // cache "pages", just per-entry metadata.
1341 - $meta = glob( XSPEED_CACHE_DIR . '/*.meta' );
1342 - if ( $meta ) {
1343 - foreach ( $meta as $m ) {
1344 - wp_delete_file( $m );
4415 + if ( ! is_dir( $root ) ) {
4416 + continue;
1345 4417 }
1346 - }
1347 - // Remove precompressed siblings (e.g. <key>.html.br from the Pro
1348 - // Brotli module). Not counted — same as .meta. Without this a
1349 - // purge leaves stale .br bodies behind: disk bloat, and a
1350 - // staleness window if precompression is later disabled.
1351 - $br = glob( XSPEED_CACHE_DIR . '/*.br' );
1352 - if ( $br ) {
1353 - foreach ( $br as $b ) {
1354 - wp_delete_file( $b );
4418 + $files = glob( $root . '/*.html' );
4419 + if ( $files ) {
4420 + $count += count( $files );
4421 + foreach ( $files as $f ) {
4422 + self::sweep_delete( $f );
4423 + }
1355 4424 }
4425 + // Remove the .meta sidecars (content-type for feeds/sitemaps)
4426 + // alongside their .html entries. Not counted — they're not
4427 + // cache "pages", just per-entry metadata.
4428 + $meta = glob( $root . '/*.meta' );
4429 + if ( $meta ) {
4430 + foreach ( $meta as $m ) {
4431 + self::sweep_delete( $m );
4432 + }
4433 + }
4434 + // Remove precompressed siblings (e.g. <key>.html.br from the Pro
4435 + // Brotli module). Not counted — same as .meta. Without this a
4436 + // purge leaves stale .br bodies behind: disk bloat, and a
4437 + // staleness window if precompression is later disabled.
4438 + $br = glob( $root . '/*.br' );
4439 + if ( $br ) {
4440 + foreach ( $br as $b ) {
4441 + self::sweep_delete( $b );
4442 + }
4443 + }
4444 + // `*.br` does not match `*.br.size` — same reason as the flat-root
4445 + // sweep above: a size record outliving its body would later be
4446 + // read against a different sibling's bytes.
4447 + $br_size = glob( $root . '/*.br.size' );
4448 + if ( $br_size ) {
4449 + foreach ( $br_size as $b ) {
4450 + self::sweep_delete( $b );
4451 + }
4452 + }
1356 4453 }
1357 4454 }
1358 4455 // Static-cache tree purge — recursive because the layout is
1359 4456 // xspeed-static/{host}/{path}/index.html, so a flat glob can't
1360 - // reach everything.
4457 + // reach everything. Already host-segmented, so scoping is just a
4458 + // matter of starting one level down.
1361 4459 if ( is_dir( XSPEED_CACHE_STATIC_DIR ) ) {
1362 - $count += self::rmtree_html( XSPEED_CACHE_STATIC_DIR );
4460 + $static_root = $network_wide
4461 + ? XSPEED_CACHE_STATIC_DIR
4462 + : XSPEED_CACHE_STATIC_DIR . '/' . $static_scope;
4463 + if ( is_dir( $static_root ) ) {
4464 + $count += self::rmtree_html( $static_root );
4465 + }
1363 4466 }
1364 4467 // REST response cache (cache/xspeed/rest/*.json) — same purge
1365 4468 // triggers (publish, settings change) invalidate it too.
1366 - $count += Rest_Cache::purge();
4469 + $rest = Rest_Cache::purge();
4470 + $count += $rest;
1367 4471
1368 4472 // Minified + combined CSS/JS (cache/xspeed/min/ and min/combined/).
1369 4473 // purge_all is a full filesystem sweep and must clear these too, even
1370 4474 // when the Minify module is currently disabled — orphaned min/ files
@@ -1370,19 +4474,93 @@
1370 4474 // when the Minify module is currently disabled — orphaned min/ files
1371 4475 // from a feature the user later turned off must still be removed, and
1372 4476 // a stale combined-<hash>.css that the regenerated page no longer
1373 4477 // references otherwise 404s and breaks the frontend. (FBS-83114/83116)
1374 - if ( class_exists( '\\XSpeed\\Minifier' ) ) {
1375 - Minifier::purge_minified();
4478 + $assets = class_exists( '\\XSpeed\\Minifier' ) ? Minifier::purge_minified() : 0;
4479 +
4480 + return array(
4481 + 'pages' => $count - $rest,
4482 + 'rest' => $rest,
4483 + 'assets' => $assets,
4484 + 'bytes' => self::$sweep_bytes,
4485 + );
4486 + }
4487 +
4488 + /**
4489 + * Flush the persistent object cache (Redis / Memcached).
4490 + *
4491 + * Runs regardless of whether the Object Cache module is currently
4492 + * enabled — a drop-in installed earlier keeps serving until flushed.
4493 + *
4494 + * @param bool $network_wide Flush every blog's entries. wp_cache_flush()
4495 + * is NETWORK-global, so on multisite the
4496 + * default prefers the blog-scoped group flush
4497 + * (WP 6.1+) — otherwise one site's purge drops
4498 + * every other site's object cache, the same bug
4499 + * #6 fixed for the page cache.
4500 + * @return bool Whether a flush was actually performed.
4501 + */
4502 + public static function flush_object_cache( bool $network_wide = false ): bool {
4503 + if ( ! $network_wide && is_multisite() && function_exists( 'wp_cache_flush_group' ) && function_exists( 'wp_cache_supports' ) && wp_cache_supports( 'flush_group' ) ) {
4504 + // Blog-scoped groups only; a shared/global group (site options,
4505 + // user meta) is intentionally left alone.
4506 + foreach ( array( 'options', 'posts', 'terms', 'post_meta', 'comment' ) as $group ) {
4507 + wp_cache_flush_group( $group );
4508 + }
4509 + return true;
1376 4510 }
4511 + if ( function_exists( 'wp_cache_flush' ) ) {
4512 + return (bool) wp_cache_flush();
4513 + }
4514 + return false;
4515 + }
1377 4516
1378 - // Persistent object cache (Redis / Memcached). Flush regardless of
1379 - // whether the Object Cache module is currently enabled — a drop-in
1380 - // installed earlier keeps serving until flushed.
1381 - if ( function_exists( 'wp_cache_flush' ) ) {
1382 - wp_cache_flush();
4517 + /**
4518 + * Purge this site's cache: the local sweep, then the object cache, then
4519 + * the bookkeeping every caller expects (stats, `xspeed_after_purge_all`,
4520 + * inventory invalidation, purge log).
4521 + *
4522 + * @param string $cause Who asked, for the purge log.
4523 + * @param string|null $host See purge_local().
4524 + * @param array<string,mixed> $invalidation Public adapter policy. `scope`
4525 + * is urls/site/network/none,
4526 + * `intent` explains why, and
4527 + * `urls` supplies exact targets.
4528 + * @return int Page + REST entries removed.
4529 + */
4530 + public static function purge_all( string $cause = 'manual', ?string $host = null, array $invalidation = array() ) {
4531 + $network_wide = ( '*' === $host );
4532 + $adapter_scope = isset( $invalidation['scope'] ) && is_string( $invalidation['scope'] )
4533 + ? $invalidation['scope']
4534 + : ( $network_wide ? 'network' : 'site' );
4535 + if ( ! in_array( $adapter_scope, array( 'urls', 'site', 'network', 'none' ), true ) ) {
4536 + $adapter_scope = $network_wide ? 'network' : 'site';
1383 4537 }
4538 + if ( $network_wide ) {
4539 + $adapter_scope = 'network';
4540 + }
4541 + $intent = isset( $invalidation['intent'] ) && is_string( $invalidation['intent'] ) && '' !== $invalidation['intent']
4542 + ? $invalidation['intent']
4543 + : 'complete';
4544 + $urls = isset( $invalidation['urls'] ) && is_array( $invalidation['urls'] )
4545 + ? array_values( array_unique( array_filter( $invalidation['urls'], 'is_string' ) ) )
4546 + : array();
4547 + // This method always sweeps a complete local bucket. A narrower adapter
4548 + // announcement would claim unrelated local pages stayed warm when they
4549 + // did not, leaving their server copies stale. Until purge_all() gains
4550 + // dependency-aware local deletion, its response scope cannot be `urls`.
4551 + if ( 'urls' === $adapter_scope ) {
4552 + $adapter_scope = $network_wide ? 'network' : 'site';
4553 + }
4554 + if ( 'site' === $adapter_scope || 'network' === $adapter_scope || 'none' === $adapter_scope ) {
4555 + $urls = array();
4556 + }
1384 4557
4558 + $removed = self::purge_local( $host );
4559 + $count = $removed['pages'] + $removed['rest'];
4560 +
4561 + self::flush_object_cache( $network_wide );
4562 +
1385 4563 self::update_stats( array( 'last_purge' => time() ) );
1386 4564
1387 4565 // Fire AFTER the local sweep so module listeners (Critical CSS,
1388 4566 // Unused CSS, Cloudflare edge purge) run — this action had three
@@ -1388,10 +4566,64 @@
1388 4566 // Unused CSS, Cloudflare edge purge) run — this action had three
1389 4567 // registered listeners but was never emitted. Treat it as additive
1390 4568 // (CDN / edge invalidation), not the mechanism for clearing local
1391 4569 // files. (FBS-83114)
1392 - do_action( 'xspeed_after_purge_all', $cause );
4570 + // Wrapped: this action predates the purge-event contract and has its
4571 + // own third-party listeners. One of them throwing used to abort
4572 + // purge_all() here, which now also means the contract event below
4573 + // never fires and a server cache keeps serving stale HTML. The local
4574 + // sweep is already done by this point, so swallowing is strictly safer
4575 + // than letting a listener decide the rest of the method runs.
4576 + try {
4577 + // Isolated per listener: one throwing used to cancel every
4578 + // listener queued behind it — Critical CSS, Unused CSS and the
4579 + // Cloudflare edge purge all hang off this hook. (QA #348)
4580 + self::do_action_isolated( 'xspeed_after_purge_all', $cause );
4581 + } catch ( \Throwable $e ) {
4582 + self::log_purge_listener_error( 'xspeed_after_purge_all', $e );
4583 + }
1393 4584
4585 + /**
4586 + * Fires after a full purge, with the same bounded context shape as
4587 + * `xspeed_after_purge_url`.
4588 + *
4589 + * Distinct from `xspeed_after_purge_all` on purpose. That action is
4590 + * the long-standing internal signal — it passes a bare `$cause` string
4591 + * and Free's own modules use it for local bookkeeping. This one is the
4592 + * documented contract for OUTSIDE integrations: same argument shape as
4593 + * the per-URL event, so a server-cache or CDN adapter can subscribe to
4594 + * both with one handler and branch on a null `url`.
4595 + *
4596 + * Fires at most once per purge, and not at all when a listener's own
4597 + * purge re-enters xSpeed.
4598 + *
4599 + * @since 1.2.3
4600 + *
4601 + * @param array $context {
4602 + * @type null $url Always null — a full purge has no single URL.
4603 + * @type string $host Host swept, or '*' for the entire tree.
4604 + * @type null $path Always null.
4605 + * @type string $cause Short label for who asked.
4606 + * @type int $removed Number of cache files removed.
4607 + * @type string $scope Adapter action: urls/site/network/none.
4608 + * @type string $intent content/presentation/complete or a caller-defined intent.
4609 + * @type string[] $urls Exact targets when scope is urls.
4610 + * }
4611 + */
4612 + self::dispatch_purge_event(
4613 + 'xspeed_after_purge',
4614 + array(
4615 + 'url' => null,
4616 + 'host' => null === $host ? self::current_purge_host() : (string) $host,
4617 + 'path' => null,
4618 + 'cause' => $cause,
4619 + 'removed' => $count,
4620 + 'scope' => $adapter_scope,
4621 + 'intent' => $intent,
4622 + 'urls' => $urls,
4623 + )
4624 + );
4625 +
1394 4626 // The list behind the "Cached pages" card is memoized for a minute;
1395 4627 // a purge has to drop it or the drill-down shows pages that no
1396 4628 // longer exist.
1397 4629 Cache_Inventory::invalidate();
@@ -1408,8 +4640,610 @@
1408 4640 return $count;
1409 4641 }
1410 4642
1411 4643 /**
4644 + * Purge everything after a plugin / theme / core update completes.
4645 + *
4646 + * Bound to `upgrader_process_complete`, which is the only hook an update
4647 + * fires — no activation hook runs, so without this the cached HTML (and
4648 + * the asset URLs baked into it) outlives the code that produced it.
4649 + *
4650 + * Runs for plugin, theme and core updates alike, including bulk runs and
4651 + * auto-updates, and purges the WHOLE network rather than the current
4652 + * site — see the call below. Translation updates are skipped: they
4653 + * change no markup a cached page depends on, and language packs update
4654 + * often enough that purging on them would keep a multilingual site
4655 + * permanently cold.
4656 + *
4657 + * Note this cannot be folded into the `$invalidate_hooks` loop above:
4658 + * that binds `purge_all` directly, and `purge_all( string $cause )` would
4659 + * then receive the WP_Upgrader instance as its cause.
4660 + *
4661 + * @param mixed $upgrader WP_Upgrader instance (unused).
4662 + * @param array $hook_extra Context for the completed operation.
4663 + * @return void
4664 + */
4665 + public static function purge_after_upgrade( $upgrader = null, $hook_extra = array() ) {
4666 + $cleared = self::$upgrade_cleared_destination;
4667 +
4668 + if ( ! self::upgrade_produced_something( $upgrader ) ) {
4669 + return;
4670 + }
4671 +
4672 + if ( ! self::upgrade_should_purge( is_array( $hook_extra ) ? $hook_extra : array(), $cleared ) ) {
4673 + return;
4674 + }
4675 +
4676 + self::purge_for_upgrade();
4677 + }
4678 +
4679 + /**
4680 + * Whether this request's upgrader removed an existing copy.
4681 + *
4682 + * @var bool
4683 + */
4684 + private static $upgrade_cleared_destination = false;
4685 +
4686 + /**
4687 + * How many `upgrader_process_complete` dispatches are on the stack.
4688 + *
4689 + * @var int
4690 + */
4691 + private static $upgrade_dispatch_depth = 0;
4692 +
4693 + /**
4694 + * Enter an `upgrader_process_complete` dispatch.
4695 + *
4696 + * Bound at PHP_INT_MIN, so it runs before any listener that might read
4697 + * the replacement signal. Public because it is a hook target.
4698 + *
4699 + * @return void
4700 + */
4701 + public static function note_upgrade_dispatch(): void {
4702 + ++self::$upgrade_dispatch_depth;
4703 + }
4704 +
4705 + /**
4706 + * Drop the replacement signal once every listener has read it.
4707 + *
4708 + * Bound at PHP_INT_MAX so a second upgrade in the same request starts
4709 + * clean, without taking the answer away from the add-on callbacks that
4710 + * run at the same priority as ours.
4711 + *
4712 + * Only the OUTERMOST dispatch clears it. A nested run — core's language
4713 + * pack upgrader, or any add-on that installs something from this hook —
4714 + * fires the action again, and clearing there would answer for a run that
4715 + * has not finished. Called directly (no dispatch on the stack) it still
4716 + * clears, which is what a test wants.
4717 + *
4718 + * Known limit: a nested run INHERITS the outer run's signal, because the
4719 + * only evidence we get is a filter that fires before the nested dispatch
4720 + * begins and carries no upgrader identity. So a fresh install performed
4721 + * from inside a replacement run reads as a replacement and purges once
4722 + * more than it needs to. A cold cache is the cheap direction, and the
4723 + * alternative — scoping the signal per upgrader — is not knowable from
4724 + * `upgrader_clear_destination`.
4725 + *
4726 + * The observed-destination signal is dropped here too. The two are read
4727 + * together and have to expire together: leaving "the directory was not
4728 + * there" behind would let a first install answer for whatever ran next
4729 + * in the same request, and that one's mistake is a cache left stale.
4730 + *
4731 + * @return void
4732 + */
4733 + public static function forget_cleared_destination(): void {
4734 + if ( self::$upgrade_dispatch_depth > 0 ) {
4735 + --self::$upgrade_dispatch_depth;
4736 + }
4737 +
4738 + if ( 0 === self::$upgrade_dispatch_depth ) {
4739 + self::$upgrade_cleared_destination = false;
4740 + self::$upgrade_destination_existed = null;
4741 + self::$upgrade_destination_folder = '';
4742 + }
4743 + }
4744 +
4745 + /**
4746 + * Whether the destination this run installs into was already there.
4747 + *
4748 + * Null while unknown — an upgrader whose target we cannot work out keeps
4749 + * the old behaviour rather than being guessed at.
4750 + *
4751 + * @var bool|null
4752 + */
4753 + private static $upgrade_destination_existed = null;
4754 +
4755 + /**
4756 + * The folder name that answer was measured against, so the destination
4757 + * WordPress reports on `upgrader_clear_destination` can be checked
4758 + * against it. Empty when nothing was measured.
4759 + *
4760 + * @var string
4761 + */
4762 + private static $upgrade_destination_folder = '';
4763 +
4764 + /**
4765 + * Note whether the package's destination exists, before it is cleared.
4766 + *
4767 + * `upgrader_source_selection` is the last hook that fires while the old
4768 + * copy is still on disk, and the extracted source folder name is the
4769 + * directory the package will install into. A pass-through listener: the
4770 + * source is returned untouched.
4771 + *
4772 + * @param mixed $source Extracted package directory.
4773 + * @param mixed $remote_src Unused; the package's remote source.
4774 + * @param mixed $upgrader The upgrader instance, if one was supplied.
4775 + * @param mixed $hook_extra Context supplied by the upgrader.
4776 + * @return mixed The source, unchanged.
4777 + */
4778 + public static function note_destination_state( $source, $remote_src = '', $upgrader = null, $hook_extra = array() ) {
4779 + self::$upgrade_destination_existed = null;
4780 + self::$upgrade_destination_folder = '';
4781 +
4782 + $root = self::upgrade_destination_root( $upgrader, is_array( $hook_extra ) ? $hook_extra : array() );
4783 + if ( null !== $root && is_string( $source ) && '' !== $source ) {
4784 + $folder = basename( rtrim( $source, '/\\' ) );
4785 + if ( '' !== $folder ) {
4786 + self::$upgrade_destination_existed = is_dir( rtrim( $root, '/\\' ) . '/' . $folder );
4787 + self::$upgrade_destination_folder = $folder;
4788 + }
4789 + }
4790 +
4791 + return $source;
4792 + }
4793 +
4794 + /**
4795 + * Where a package of this kind installs to, or null if we cannot tell.
4796 + *
4797 + * @param mixed $upgrader The upgrader instance, if one was supplied.
4798 + * @param array $hook_extra Context supplied by the upgrader.
4799 + * @return string|null
4800 + */
4801 + private static function upgrade_destination_root( $upgrader, array $hook_extra ): ?string {
4802 + $type = isset( $hook_extra['type'] ) ? (string) $hook_extra['type'] : '';
4803 +
4804 + if ( 'plugin' === $type || $upgrader instanceof \Plugin_Upgrader ) {
4805 + return defined( 'WP_PLUGIN_DIR' ) ? WP_PLUGIN_DIR : null;
4806 + }
4807 +
4808 + if ( 'theme' === $type || $upgrader instanceof \Theme_Upgrader ) {
4809 + return function_exists( 'get_theme_root' ) ? (string) get_theme_root() : null;
4810 + }
4811 +
4812 + return null;
4813 + }
4814 +
4815 + /**
4816 + * Record that the upgrader cleared an existing destination.
4817 + *
4818 + * A pass-through listener on `upgrader_clear_destination`: WordPress only
4819 + * fires it when `clear_destination` was set AND something was there to
4820 + * remove, which is the one signal that separates an upload-and-replace
4821 + * from a first-time install. The filtered value is returned untouched.
4822 + *
4823 + * @param true|\WP_Error $removed Whether the destination was cleared.
4824 + * @return true|\WP_Error
4825 + */
4826 + public static function note_cleared_destination( $removed, $local_destination = '', $remote_destination = '', $hook_extra = array() ) {
4827 + if ( ! is_wp_error( $removed ) ) {
4828 + self::$upgrade_cleared_destination = true;
4829 + }
4830 +
4831 + // $remote_destination is the directory WordPress actually cleared,
4832 + // derived from the source AFTER every `upgrader_source_selection`
4833 + // listener ran. If its folder is not the one note_destination_state()
4834 + // measured, a listener that ran after ours renamed the package, and
4835 + // the "was it there?" answer is about a directory that was never going
4836 + // to be written. Unknown is the answer that purges, so that is what it
4837 + // becomes. Only the last segment is compared: over FTP the remote
4838 + // path sits under the server's own root, not WP_PLUGIN_DIR. (#407 QA)
4839 + if ( is_string( $remote_destination ) && '' !== $remote_destination ) {
4840 + $cleared_folder = basename( rtrim( $remote_destination, '/\\' ) );
4841 + if ( '' !== $cleared_folder && $cleared_folder !== self::$upgrade_destination_folder ) {
4842 + self::$upgrade_destination_existed = null;
4843 + }
4844 + }
4845 +
4846 + return $removed;
4847 + }
4848 +
4849 + /**
4850 + * Did the completed run actually replace anything?
4851 + *
4852 + * `upgrader_process_complete` fires whether the run succeeded or failed —
4853 + * the failure branch in WP_Upgrader::run() only feeds the skin before the
4854 + * action fires. A run that installed nothing changed no markup, so purging
4855 + * for it is a cold cache bought for nothing.
4856 + *
4857 + * Deliberately conservative: this returns false ONLY when every result we
4858 + * can see is an error. An upgrader we cannot read, a mixed bulk run, or a
4859 + * missing result all fall through to purging, which is the safe direction
4860 + * everywhere else in this handler.
4861 + *
4862 + * @param mixed $upgrader WP_Upgrader instance, or anything else.
4863 + * @return bool
4864 + */
4865 + public static function upgrade_produced_something( $upgrader ): bool {
4866 + if ( ! is_object( $upgrader ) ) {
4867 + return true;
4868 + }
4869 +
4870 + // A bulk run collects one entry per item; `result` alone would only
4871 + // describe the last of them.
4872 + if ( isset( $upgrader->results ) && is_array( $upgrader->results ) && ! empty( $upgrader->results ) ) {
4873 + foreach ( $upgrader->results as $result ) {
4874 + if ( ! is_wp_error( $result ) && ! empty( $result ) ) {
4875 + return true;
4876 + }
4877 + }
4878 + return false;
4879 + }
4880 +
4881 + if ( ! property_exists( $upgrader, 'result' ) ) {
4882 + return true;
4883 + }
4884 +
4885 + return ! is_wp_error( $upgrader->result ) && ! empty( $upgrader->result );
4886 + }
4887 +
4888 + /**
4889 + * Decide whether a completed operation invalidates the cache.
4890 + *
4891 + * Split out from the handler so the decision is testable on its own:
4892 + * purge_all() reaches straight for glob() and unlink(), which a unit test
4893 + * cannot observe honestly, while every rule that matters lives here.
4894 + *
4895 + * @param array $hook_extra Context for the completed operation.
4896 + * @return bool
4897 + */
4898 + public static function upgrade_should_purge( array $hook_extra, bool $destination_cleared = false ): bool {
4899 + if ( ! self::upgrade_replaced_code( $hook_extra, $destination_cleared ) ) {
4900 + return false;
4901 + }
4902 +
4903 + // An update to xSpeed ITSELF always purges, whatever the setting says.
4904 + // This plugin's own code is what rendered every cached page — the
4905 + // minifier, lazy-loader, resource hints and CDN rewriter all changed
4906 + // underneath it — so serving that HTML after an update means serving
4907 + // output from a version that no longer exists. Minified assets make it
4908 + // concrete rather than theoretical: their filenames are keyed on the
4909 + // source filemtime, so they regenerate under NEW hashes while the
4910 + // cached pages still link the old ones, and the page requests files
4911 + // that are no longer on disk. Offering an opt-out for that would be
4912 + // offering a broken site.
4913 + return self::upgrade_touches_xspeed( $hook_extra ) || self::purge_on_upgrade_enabled();
4914 + }
4915 +
4916 + /**
4917 + * Did this completed run replace code that renders pages?
4918 + *
4919 + * The half of the decision that has nothing to do with our settings: it
4920 + * asks only whether live code changed underneath the output we cached.
4921 + * Add-ons that keep their own derived artifacts — generated CSS, captured
4922 + * selectors, fingerprints — need the same answer and must not have to
4923 + * rebuild these rules, or they drift apart. Call it with the hook's own
4924 + * `$hook_extra`; the upload-and-replace signal is read from this request.
4925 + *
4926 + * Deliberately independent of the "Purge After Updates" setting. That
4927 + * setting governs the page cache, not whether an add-on's derived data is
4928 + * still valid.
4929 + *
4930 + * @param array $hook_extra Context for the completed operation.
4931 + * @param bool|null $destination_cleared Override the recorded signal; null reads this request's.
4932 + * @return bool
4933 + */
4934 + public static function upgrade_replaced_code( array $hook_extra, ?bool $destination_cleared = null ): bool {
4935 + $cleared = null === $destination_cleared ? self::$upgrade_cleared_destination : $destination_cleared;
4936 +
4937 + $type = isset( $hook_extra['type'] ) ? (string) $hook_extra['type'] : '';
4938 + $action = isset( $hook_extra['action'] ) ? (string) $hook_extra['action'] : '';
4939 +
4940 + // `upgrader_process_complete` fires for INSTALLS as well as updates.
4941 + // A freshly installed plugin is inactive and a freshly installed theme
4942 + // is not the active one, so neither can change a single rendered page
4943 + // — but the first cut of this handler purged the whole tree anyway, so
4944 + // evaluating three plugins in a row emptied the cache three times.
4945 + //
4946 + // 'install' alone is NOT enough to skip on, because WordPress reports
4947 + // an upload-and-replace as an install: `Plugin_Upgrader::install()`
4948 + // hardcodes `action => install` and `overwrite_package` does not change
4949 + // it, so "Replace current with uploaded" and `wp plugin install <zip>
4950 + // --force` both arrive here labelled install while genuinely replacing
4951 + // live code. That is how a plugin distributed as a zip is updated, and
4952 + // skipping it put back the stale markup this handler exists to clear.
4953 + //
4954 + // The distinguishing signal is whether the destination was cleared:
4955 + // WP_Upgrader only fires `upgrader_clear_destination` when it removed
4956 + // something that was already there. Installing beside nothing does not.
4957 + if ( 'install' === $action && ! $cleared ) {
4958 + return false;
4959 + }
4960 +
4961 + // A cleared destination is only evidence of a replacement if there was
4962 + // something in it. Core returns success from clear_destination() for a
4963 + // destination that never existed, so a first-time install arrived here
4964 + // looking exactly like an upload-and-replace and bought a cold cache
4965 + // for a plugin that is not even active yet. Only acted on when we
4966 + // positively know the directory was absent.
4967 + if ( 'install' === $action && false === self::$upgrade_destination_existed ) {
4968 + return false;
4969 + }
4970 +
4971 + // 'translation' is the one update type that cannot change rendered
4972 + // markup. Anything else — including an empty type from a custom
4973 + // updater — is treated as cache-invalidating, because guessing wrong
4974 + // in that direction only costs a cold cache.
4975 + if ( 'translation' === $type ) {
4976 + return false;
4977 + }
4978 +
4979 + return true;
4980 + }
4981 +
4982 + /**
4983 + * Purge everything an update can invalidate.
4984 + *
4985 + * Network-wide ('*'), not the calling site's bucket. A plugin, theme or
4986 + * core update replaces code shared by EVERY site on the network, so a
4987 + * scoped purge would clear the site that happened to run the updater and
4988 + * leave every other subsite serving pre-update HTML for the whole TTL —
4989 + * the very bug this handler exists to fix, one level down. On single-site
4990 + * this is identical to the scoped call, since there is only ever one
4991 + * bucket.
4992 + *
4993 + * @return void
4994 + */
4995 + private static function purge_for_upgrade(): void {
4996 + self::purge_all( 'upgrade', '*' );
4997 + Minifier::purge_minified();
4998 + }
4999 +
5000 + /**
5001 + * Purge after an unattended background update run.
5002 + *
5003 + * `automatic_updates_complete` passes ONE argument, and it is not a
5004 + * hook_extra: it is WordPress's results array, keyed by what was updated
5005 + * ('core', 'plugin', 'theme', 'translation'). Handing it to
5006 + * purge_after_upgrade() put it in the unused $upgrader slot and left the
5007 + * type empty, so a night on which only a language pack updated purged
5008 + * every cached page — the exact case the translation exemption exists to
5009 + * prevent, and WordPress auto-updates language packs by default.
5010 + *
5011 + * @param array $results Update results, keyed by type.
5012 + * @return void
5013 + */
5014 + public static function purge_after_auto_updates( $results = array() ): void {
5015 + if ( ! self::auto_updates_should_purge( is_array( $results ) ? $results : array() ) ) {
5016 + return;
5017 + }
5018 +
5019 + self::purge_for_upgrade();
5020 + }
5021 +
5022 + /**
5023 + * Decide whether a background update run invalidates the cache.
5024 + *
5025 + * @param array $results Update results, keyed by type.
5026 + * @return bool
5027 + */
5028 + public static function auto_updates_should_purge( array $results ): bool {
5029 + // An unrecognisable payload is treated as invalidating, the same
5030 + // direction every other unknown takes here.
5031 + if ( empty( $results ) ) {
5032 + return true;
5033 + }
5034 +
5035 + // Failed items are listed alongside successful ones — WP_Automatic_Updater
5036 + // appends an entry whatever the outcome — and a night on which every
5037 + // update failed replaced no code, so it invalidates nothing.
5038 + $updated = array();
5039 + foreach ( $results as $type => $items ) {
5040 + if ( ! is_array( $items ) ) {
5041 + continue;
5042 + }
5043 + foreach ( $items as $item ) {
5044 + $result = is_object( $item ) && isset( $item->result ) ? $item->result : true;
5045 + if ( ! is_wp_error( $result ) && ! empty( $result ) ) {
5046 + $updated[] = (string) $type;
5047 + break;
5048 + }
5049 + }
5050 + }
5051 +
5052 + if ( empty( $updated ) ) {
5053 + return false;
5054 + }
5055 +
5056 + // Nothing but language packs: a language pack changes no markup a
5057 + // cached page depends on, and purging on one would keep a multilingual
5058 + // site permanently cold.
5059 + if ( array( 'translation' ) === array_values( array_unique( $updated ) ) ) {
5060 + return false;
5061 + }
5062 +
5063 + return self::auto_updates_touch_xspeed( $results ) || self::purge_on_upgrade_enabled();
5064 + }
5065 +
5066 + /**
5067 + * Does a background run include one of our own plugins?
5068 + *
5069 + * Same rule as a foreground self-update, read out of the results array's
5070 + * shape instead of a hook_extra: each plugin entry carries the update
5071 + * object on `->item->plugin`.
5072 + *
5073 + * @param array $results Update results, keyed by type.
5074 + * @return bool
5075 + */
5076 + private static function auto_updates_touch_xspeed( array $results ): bool {
5077 + if ( empty( $results['plugin'] ) || ! is_array( $results['plugin'] ) ) {
5078 + return false;
5079 + }
5080 +
5081 + $ours = self::self_update_plugins();
5082 + foreach ( $results['plugin'] as $entry ) {
5083 + $item = is_object( $entry ) && isset( $entry->item ) ? $entry->item : null;
5084 + $file = is_object( $item ) && isset( $item->plugin ) ? (string) $item->plugin : '';
5085 + if ( '' !== $file && in_array( $file, $ours, true ) ) {
5086 + return true;
5087 + }
5088 + }
5089 +
5090 + return false;
5091 + }
5092 +
5093 + /**
5094 + * Is the "Purge After Updates" setting on?
5095 + *
5096 + * Gates THIRD-PARTY updates only — an xSpeed self-update ignores it, see
5097 + * purge_after_upgrade(). Defaults to true when the option has never been
5098 + * written, matching the schema default in CacheModule: an unset value on
5099 + * an existing install must not read as "the user turned this off".
5100 + *
5101 + * Unlike LiteSpeed, which ships the equivalent toggle OFF, this defaults
5102 + * ON — a cold cache costs one slow request, whereas stale HTML is a wrong
5103 + * page for up to the full TTL and the site owner has no way to tell why.
5104 + *
5105 + * @return bool
5106 + */
5107 + private static function purge_on_upgrade_enabled(): bool {
5108 + $opts = Settings_Manager::get( 'cache' );
5109 + return ! array_key_exists( 'purge_on_upgrade', $opts ) || ! empty( $opts['purge_on_upgrade'] );
5110 + }
5111 +
5112 + /**
5113 + * Does this completed update include xSpeed itself?
5114 + *
5115 + * Mirrors the payload shapes Plugin::maybe_restore_after_update() reads:
5116 + * a single update carries 'plugin', a bulk run carries 'plugins'.
5117 + *
5118 + * @param array $hook_extra Context for the completed operation.
5119 + * @return bool
5120 + */
5121 + private static function upgrade_touches_xspeed( array $hook_extra ): bool {
5122 + if ( ! isset( $hook_extra['type'] ) || 'plugin' !== $hook_extra['type'] ) {
5123 + return false;
5124 + }
5125 +
5126 + $updated = array();
5127 + if ( isset( $hook_extra['plugins'] ) && is_array( $hook_extra['plugins'] ) ) {
5128 + // Strings only: array_intersect() stringifies what it is given, so
5129 + // an object without __toString in a custom updater's payload would
5130 + // be a fatal rather than a miss.
5131 + $updated = array_filter( $hook_extra['plugins'], 'is_string' );
5132 + } elseif ( isset( $hook_extra['plugin'] ) && is_string( $hook_extra['plugin'] ) ) {
5133 + $updated = array( $hook_extra['plugin'] );
5134 + }
5135 +
5136 + return (bool) array_intersect( self::self_update_plugins(), $updated );
5137 + }
5138 +
5139 + /**
5140 + * Plugin files whose update counts as an update to us.
5141 + *
5142 + * The self-update rule is "our own code rendered this cached HTML, so it
5143 + * must not survive the code being replaced". That is true of any add-on
5144 + * that writes into the same page: an add-on inlines critical CSS, rewrites
5145 + * stylesheet links and image URLs, and produces the compressed and static
5146 + * copies, so its update leaves exactly the stale markup this rule exists
5147 + * to clear. Free cannot name an add-on, so it asks instead.
5148 + *
5149 + * Filter: xspeed_self_update_plugins
5150 + *
5151 + * Add-ons add their own `plugin_basename( __FILE__ )`. Entries are matched
5152 + * against the plugin files WordPress reports for the completed update, so
5153 + * a value that is not a `dir/file.php` basename simply never matches.
5154 + *
5155 + * @param string[] $plugins Plugin basenames treated as our own.
5156 + * @return string[]
5157 + */
5158 + private static function self_update_plugins(): array {
5159 + $ours = array( plugin_basename( XSPEED_FILE ) );
5160 +
5161 + /** This filter is documented above. */
5162 + $filtered = apply_filters( 'xspeed_self_update_plugins', $ours );
5163 +
5164 + // Our own file is merged back afterwards rather than trusted to survive
5165 + // the round trip. A listener that returns null, a bare string, or a
5166 + // list it built from scratch would otherwise drop it, and the plugin
5167 + // would quietly stop exempting its OWN update from the setting — a
5168 + // failure no add-on author would think to test for.
5169 + $claimed = array_filter( is_array( $filtered ) ? $filtered : array(), 'is_string' );
5170 +
5171 + return array_values( array_unique( array_merge( $ours, array_filter( $claimed ) ) ) );
5172 + }
5173 +
5174 + /**
5175 + * Invalidate caches of RENDERED output owned by other plugins.
5176 + *
5177 + * purge_all() sweeps only what xSpeed wrote. A page builder that stores
5178 + * rendered HTML or generated CSS of its own — Elementor's element cache
5179 + * and `uploads/elementor/css/`, and the equivalents in Beaver / Divi /
5180 + * Bricks / Oxygen — keeps whatever asset URLs were current when it was
5181 + * written, and no xSpeed purge has ever reached it.
5182 + *
5183 + * That only matters for rewrites that happen DURING render rather than on
5184 + * the finished page. Minify, combine, lazy-load and resource hints all run
5185 + * on `xspeed_cache_final_html` or a `template_redirect` buffer — after the
5186 + * builder has already stored its copy — so nothing they emit can leak.
5187 + * The CDN module's `wp_get_attachment_url` filter is the one that can.
5188 + *
5189 + * Called ONLY from purges where asset URLs themselves can have changed
5190 + * (a CDN settings write, an explicit Purge All). NOT from purge_all(),
5191 + * which also runs on every post publish — regenerating every builder CSS
5192 + * file that often would cost more than it saves, and the builder already
5193 + * invalidates its own copy for the post being saved.
5194 + *
5195 + * @param string $cause Who asked. Threaded through to the listeners and
5196 + * the activity log.
5197 + * @return string[] Labels of the caches that were actually cleared.
5198 + */
5199 + public static function purge_render_caches( string $cause = 'manual' ): array {
5200 + /**
5201 + * Clear render caches belonging to other plugins.
5202 + *
5203 + * A listener does its own work and appends a human-readable label for
5204 + * what it cleared, so the activity log can name it. Returning
5205 + * `$cleared` unchanged means "nothing of mine is installed" and is the
5206 + * correct no-op — never a failure.
5207 + *
5208 + * Detect the owning plugin by class or constant, not by an
5209 + * `is_plugin_active()` path check: a renamed plugin folder must not
5210 + * silently disable the integration.
5211 + *
5212 + * @param string[] $cleared Labels of caches cleared so far.
5213 + * @param string $cause Why the purge is happening.
5214 + */
5215 + $cleared = (array) apply_filters( 'xspeed_purge_third_party_render_caches', array(), $cause );
5216 +
5217 + // Labels are strings destined for the activity feed. Anything else a
5218 + // third-party listener returns is dropped rather than coerced — a
5219 + // stray `0` or `null` in the log reads as a cache we cleared.
5220 + $cleared = array_values(
5221 + array_filter(
5222 + $cleared,
5223 + static function ( $label ) {
5224 + return is_string( $label ) && '' !== trim( $label );
5225 + }
5226 + )
5227 + );
5228 +
5229 + if ( ! $cleared ) {
5230 + return $cleared;
5231 + }
5232 +
5233 + // Logged separately from the page-cache purge above it. "I turned the
5234 + // CDN off and the images are still wrong" is only diagnosable if the
5235 + // feed says which OTHER plugin's cache was regenerated and when.
5236 + Activity_Log::record(
5237 + 'cache_purged',
5238 + sprintf( 'Render caches cleared (%s) — %s', $cause, implode( ', ', $cleared ) ),
5239 + Activity_Log::INFO
5240 + );
5241 +
5242 + return $cleared;
5243 + }
5244 +
5245 + /**
1412 5246 * The per-type purge menu, LiteSpeed-style. Each entry is a cache type
1413 5247 * the user can purge individually from the admin-bar dropdown. `visible`
1414 5248 * controls whether the item shows (active + licensed module only) — it
1415 5249 * NEVER limits Purge All, which always sweeps everything on disk.
@@ -1477,30 +5311,23 @@
1477 5311 */
1478 5312 public static function purge_type( string $type, string $cause = 'manual' ): int {
1479 5313 switch ( $type ) {
1480 5314 case 'all':
1481 - return self::purge_all( $cause );
5315 + $count = self::purge_all( $cause );
5316 + // "Purge All" is the user saying they don't trust anything
5317 + // stored anywhere — the one purge that should also reach
5318 + // caches of rendered output we don't own. purge_all() itself
5319 + // deliberately does NOT, because it also runs on every post
5320 + // publish. (See Render_Caches.)
5321 + self::purge_render_caches( $cause );
5322 + return $count;
1482 5323
1483 5324 case 'page':
1484 - $count = 0;
1485 - if ( is_dir( XSPEED_CACHE_DIR ) ) {
1486 - foreach ( (array) glob( XSPEED_CACHE_DIR . '/*.html' ) as $f ) {
1487 - wp_delete_file( $f );
1488 - ++$count;
1489 - }
1490 - foreach ( (array) glob( XSPEED_CACHE_DIR . '/*.meta' ) as $m ) {
1491 - wp_delete_file( $m );
1492 - }
1493 - foreach ( (array) glob( XSPEED_CACHE_DIR . '/*.br' ) as $b ) {
1494 - wp_delete_file( $b );
1495 - }
1496 - }
1497 - if ( is_dir( XSPEED_CACHE_STATIC_DIR ) ) {
1498 - $count += self::rmtree_html( XSPEED_CACHE_STATIC_DIR );
1499 - }
5325 + $count = self::purge_pages();
1500 5326 self::update_stats( array( 'last_purge' => time() ) );
1501 5327 Cache_Inventory::invalidate();
1502 5328 self::record_partial_purge( 'page', $cause, $count );
5329 + self::announce_purge( $cause, $count );
1503 5330 return $count;
1504 5331
1505 5332 case 'assets':
1506 5333 if ( class_exists( '\\XSpeed\\Minifier' ) ) {
@@ -1505,10 +5332,32 @@
1505 5332 case 'assets':
1506 5333 if ( class_exists( '\\XSpeed\\Minifier' ) ) {
1507 5334 Minifier::purge_minified();
1508 5335 }
1509 - self::record_partial_purge( 'assets', $cause, null );
1510 - return 0;
5336 + // Deleting min/ without clearing the pages that link it left
5337 + // every cached page pointing at files that no longer exist.
5338 + // WordPress answers the missing asset by 301-ing to its
5339 + // pretty-permalink form and serving the 404 TEMPLATE as
5340 + // `HTTP 200 text/html`, which the browser accepts as a
5341 + // stylesheet and parses to zero rules — no console error, no
5342 + // network failure, no 4xx anywhere in devtools. The pages
5343 + // stayed broken for the rest of the TTL (7 days on
5344 + // Aggressive, up to 30), and the admin who clicked could not
5345 + // see it: they are logged in, so their own requests bypass
5346 + // the page cache and re-render, regenerating the assets as a
5347 + // side effect. Only anonymous visitors were served the stale
5348 + // HTML. (#244)
5349 + //
5350 + // The assets are the pages' dependency, so invalidating them
5351 + // invalidates the pages. Same invariant Cache_GC enforces
5352 + // with is_referenced(): never leave a cached page pointing at
5353 + // an asset that is gone.
5354 + $count = self::purge_pages();
5355 + self::update_stats( array( 'last_purge' => time() ) );
5356 + Cache_Inventory::invalidate();
5357 + self::record_partial_purge( 'assets', $cause, $count );
5358 + self::announce_purge( $cause, $count );
5359 + return $count;
1511 5360
1512 5361 case 'object':
1513 5362 if ( function_exists( 'wp_cache_flush' ) ) {
1514 5363 wp_cache_flush();
@@ -1518,19 +5367,156 @@
1518 5367
1519 5368 case 'rest':
1520 5369 $count = Rest_Cache::purge();
1521 5370 self::record_partial_purge( 'REST responses', $cause, $count );
5371 + self::announce_purge( $cause, $count );
1522 5372 return $count;
1523 5373
1524 5374 default:
1525 - // Pro / third-party type — let the owning module handle it.
1526 - do_action( 'xspeed_purge_type_' . $type );
1527 - self::record_partial_purge( $type, $cause, null );
1528 - return 0;
5375 + return self::purge_type_unhandled( $type, $cause );
1529 5376 }
1530 5377 }
1531 5378
1532 5379 /**
5380 + * Delete this site's cached pages from both the flat and static trees.
5381 + *
5382 + * Extracted so the `assets` purge can reuse it: minified assets are a
5383 + * dependency of the cached HTML, so clearing them must clear the pages
5384 + * too or the pages are left referencing deleted files (#244).
5385 + *
5386 + * @return int Number of page entries removed.
5387 + */
5388 + private static function purge_pages(): int {
5389 + $count = 0;
5390 + // Scoped to this site — see purge_all(). (#6)
5391 + $scope = self::current_host_dir();
5392 + $flat_root = XSPEED_CACHE_DIR . '/' . $scope;
5393 + if ( is_dir( $flat_root ) ) {
5394 + foreach ( (array) glob( $flat_root . '/*.html' ) as $f ) {
5395 + wp_delete_file( $f );
5396 + ++$count;
5397 + }
5398 + foreach ( (array) glob( $flat_root . '/*.meta' ) as $m ) {
5399 + wp_delete_file( $m );
5400 + }
5401 + foreach ( (array) glob( $flat_root . '/*.br' ) as $b ) {
5402 + wp_delete_file( $b );
5403 + }
5404 + // `*.br` does not match `*.br.size`; a size record outliving its
5405 + // body would later be read against a DIFFERENT sibling's bytes.
5406 + foreach ( (array) glob( $flat_root . '/*.br.size' ) as $b ) {
5407 + wp_delete_file( $b );
5408 + }
5409 + }
5410 + $static_root = XSPEED_CACHE_STATIC_DIR . '/' . self::current_static_scope();
5411 + if ( is_dir( $static_root ) ) {
5412 + $count += self::rmtree_html( $static_root );
5413 + }
5414 +
5415 + return $count;
5416 + }
5417 +
5418 + /**
5419 + * A purge type this class does not own — a Pro or third-party module
5420 + * registered it via the `xspeed_purge_types` filter, so hand it off.
5421 + *
5422 + * @param string $type Purge-type slug.
5423 + * @param string $cause Who asked.
5424 + */
5425 + private static function purge_type_unhandled( string $type, string $cause ): int {
5426 + $event_sequence = self::$purge_event_sequence;
5427 + $hook = 'xspeed_purge_type_' . $type;
5428 + $has_handler = false !== has_action( $hook );
5429 + do_action( $hook );
5430 + self::record_partial_purge( $type, $cause, null );
5431 +
5432 + // Announce, same as the types this class owns. Pro's "Purge Critical
5433 + // CSS" and "Purge Unused CSS" arrive here, and they change what a
5434 + // cached page CONTAINS — critical CSS is inlined into the HTML, so a
5435 + // server cache goes on serving pages with the old styles baked in.
5436 + // Fixing the three Free buttons and leaving these two silent left the
5437 + // same hole for the tier most likely to be using both plugins.
5438 + // (QA #348 round 2, issue 2)
5439 + //
5440 + // Unknown slugs must not turn into a site-wide purge merely because no
5441 + // handler exists. These are the response-changing Pro types Free knows;
5442 + // third parties can declare another through the filter. A registered
5443 + // handler plus this explicit response scope is the handled signal.
5444 + $scope = in_array( $type, array( 'critical-css', 'unused-css' ), true ) ? 'site' : 'none';
5445 + /**
5446 + * Declare whether a handled custom purge type changes cached responses.
5447 + *
5448 + * @since 1.2.3
5449 + * @param string $scope site/network/none.
5450 + * @param string $type Purge-type slug.
5451 + */
5452 + $scope = (string) apply_filters( 'xspeed_purge_type_response_scope', $scope, $type );
5453 + if ( $has_handler
5454 + && $event_sequence === self::$purge_event_sequence
5455 + && in_array( $scope, array( 'site', 'network' ), true )
5456 + ) {
5457 + self::announce_purge( $cause, 0, $scope, 'presentation' );
5458 + }
5459 +
5460 + return 0;
5461 + }
5462 +
5463 + /**
5464 + * Tell the server cache that a PARTIAL purge cleared cached responses.
5465 + *
5466 + * "Purge Page / Static Cache", "Purge CSS / JS Cache" and "Purge REST
5467 + * Cache" each delete cached RESPONSES for the whole site, so a cache in
5468 + * front of PHP is now serving copies xSpeed has just thrown away. Only
5469 + * "Purge All" announced itself, which left three of the four toolbar
5470 + * buttons doing exactly what this contract exists to prevent: clearing
5471 + * our copy while the server kept serving the stale one. The `assets` case
5472 + * was the sharpest — it deletes the minified bundles too, so LiteSpeed
5473 + * went on serving pages whose CSS and JS no longer exist. (QA #348)
5474 + *
5475 + * Sent as the full-purge shape (`url` null) because that is what happened:
5476 + * every cached page for this site went, not one address. `object` is not
5477 + * announced — flushing the object cache changes no rendered response a
5478 + * server cache could be holding.
5479 + *
5480 + * Public because Purge_Runner sweeps the local files itself, through
5481 + * purge_local(), rather than through purge_all() — so it has to announce
5482 + * on its own behalf or `wp xspeed purge` and the dashboard button clear
5483 + * our copy while LiteSpeed keeps serving the stale one.
5484 + *
5485 + * @param string $cause Who asked.
5486 + * @param int $removed Entries removed locally.
5487 + * @param string $scope Actionable adapter scope.
5488 + * @param string $intent Reason rendered responses changed.
5489 + */
5490 + public static function announce_purge( string $cause, int $removed, string $scope = 'site', string $intent = 'complete' ): void {
5491 + // Announcing is additive: the local sweep has already happened and
5492 + // succeeded. Notification must never be able to turn a working purge
5493 + // into a fatal, so anything the URL helpers do in an unusual context
5494 + // (early boot, a drop-in, a bare test harness) is contained here
5495 + // rather than propagating to the caller.
5496 + if ( ! function_exists( 'home_url' ) || ! function_exists( 'do_action' ) ) {
5497 + return;
5498 + }
5499 + try {
5500 + self::dispatch_purge_event(
5501 + 'xspeed_after_purge',
5502 + array(
5503 + 'url' => null,
5504 + 'host' => self::current_purge_host(),
5505 + 'path' => null,
5506 + 'cause' => $cause,
5507 + 'removed' => $removed,
5508 + 'scope' => $scope,
5509 + 'intent' => $intent,
5510 + 'urls' => array(),
5511 + )
5512 + );
5513 + } catch ( \Throwable $e ) {
5514 + self::log_purge_listener_error( 'xspeed_after_purge', $e );
5515 + }
5516 + }
5517 +
5518 + /**
1533 5519 * Log a partial purge so the drill-down behind "Last purge" shows every
1534 5520 * clear, not only the full ones. Without this a site whose object cache
1535 5521 * is flushed on a schedule looks, from the log, like nothing happens.
1536 5522 *
@@ -1557,8 +5543,24 @@
1557 5543 Activity_Log::record( 'cache_purged', $message, Activity_Log::INFO );
1558 5544 }
1559 5545
1560 5546 /**
5547 + * Clear the static tree only, leaving the flat cache in place.
5548 + *
5549 + * A narrower purge_all() for the case where only the web-server tree can
5550 + * be wrong: its files are keyed by `{host}{path}` and nothing else, so a
5551 + * response filed under the wrong path poisons it while the flat cache —
5552 + * keyed by cache_key(), discriminators included — stays correct. Avoids
5553 + * throwing away Critical CSS, minified bundles and the object cache to
5554 + * fix a static-only problem.
5555 + *
5556 + * @return int Number of index.html files removed.
5557 + */
5558 + public static function purge_static_tree(): int {
5559 + return self::rmtree_html( XSPEED_CACHE_STATIC_DIR );
5560 + }
5561 +
5562 + /**
1561 5563 * Recursively delete every `index.html` (and its precompressed
1562 5564 * `index.html.br` sibling, if the Pro Brotli module wrote one) plus
1563 5565 * empty directories inside the static-cache tree. Used by purge_all().
1564 5566 * Returns the number of .html files removed so purge stats stay accurate
@@ -1564,8 +5566,24 @@
1564 5566 * Returns the number of .html files removed so purge stats stay accurate
1565 5567 * across the flat + static caches — .br siblings are not counted
1566 5568 * (they're encodings of a page, not pages).
1567 5569 */
5570 + /**
5571 + * Delete a cache file, adding its size to the current sweep's byte
5572 + * total. filesize() is silenced and re-checked because the file can
5573 + * vanish between the glob and the unlink — a concurrent purge, or the
5574 + * cache GC — and a warning there would be noise, not news.
5575 + *
5576 + * @param string $file Absolute path inside the cache tree.
5577 + */
5578 + private static function sweep_delete( string $file ): void {
5579 + $size = @filesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- the file may be gone already; see docblock.
5580 + if ( is_int( $size ) ) {
5581 + self::$sweep_bytes += $size;
5582 + }
5583 + wp_delete_file( $file );
5584 + }
5585 +
1568 5586 private static function rmtree_html( string $dir ): int {
1569 5587 if ( ! is_dir( $dir ) ) {
1570 5588 return 0;
1571 5589 }
@@ -1589,14 +5607,16 @@
1589 5607 @rmdir( $path );
1590 5608 continue;
1591 5609 }
1592 5610 if ( substr( $entry, -5 ) === '.html' ) {
1593 - wp_delete_file( $path );
5611 + self::sweep_delete( $path );
1594 5612 ++$removed;
1595 - } elseif ( substr( $entry, -3 ) === '.br' ) {
1596 - // Precompressed sibling (index.html.br). Remove it too so a
1597 - // purge doesn't orphan stale Brotli bodies. Not counted.
1598 - wp_delete_file( $path );
5613 + } elseif ( substr( $entry, -3 ) === '.br' || substr( $entry, -8 ) === '.br.size' ) {
5614 + // Precompressed sibling (index.html.br) and the record of its
5615 + // length. Remove both so a purge doesn't orphan stale Brotli
5616 + // bodies, or a size record that would later be read against a
5617 + // different sibling's bytes. Not counted.
5618 + self::sweep_delete( $path );
1599 5619 }
1600 5620 }
1601 5621 return $removed;
1602 5622 }
@@ -1641,10 +5661,14 @@
1641 5661
1642 5662 public static function get_stats() {
1643 5663 $count = 0;
1644 5664 $size = 0;
1645 - if ( is_dir( XSPEED_CACHE_DIR ) ) {
1646 - $files = glob( XSPEED_CACHE_DIR . '/*.html' );
5665 + // This site's entries only — on multisite the tree is shared, so an
5666 + // unscoped count reported the whole network's pages on every
5667 + // subsite's dashboard. (#6)
5668 + $flat_root = XSPEED_CACHE_DIR . '/' . self::current_host_dir();
5669 + if ( is_dir( $flat_root ) ) {
5670 + $files = glob( $flat_root . '/*.html' );
1647 5671 if ( $files ) {
1648 5672 $count = count( $files );
1649 5673 foreach ( $files as $f ) {
1650 5674 $size += filesize( $f );
@@ -1667,8 +5691,12 @@
1667 5691 Hit_Counter::collect_server_log_hits();
1668 5692
1669 5693 $stats = get_option( 'xspeed_stats', array() );
1670 5694 $totals = Hit_Counter::totals_24h();
5695 + // One read of the ground truth for both fields below: it costs a
5696 + // stat of advanced-cache.php and a tokenize of wp-config.php, and
5697 + // this runs on every dashboard poll.
5698 + $serving = self::page_cache_operational();
1671 5699 return array(
1672 5700 'cached_pages' => $count,
1673 5701 'cache_size' => $size,
1674 5702 'last_purge' => isset( $stats['last_purge'] ) ? (int) $stats['last_purge'] : 0,
@@ -1685,12 +5713,80 @@
1685 5713 // True when an edge cache (Cloudflare) fronts the origin, so hits are
1686 5714 // absorbed before reaching PHP. The dashboard labels the ratio
1687 5715 // "origin-layer only" instead of implying it's the full picture. (#118)
1688 5716 'edge_cache' => self::edge_cache_detected(),
5717 + // LiteSpeed Static Fast Path (#509): the web server serves hits
5718 + // with no PHP, no way to tag them, and no way to count them. The
5719 + // dashboard labels the ratio as PHP-layer only so a low number
5720 + // reads as the trade the user chose, not a fault.
5721 + //
5722 + // rewrite_installed() is part of the condition (QA on #513): when
5723 + // the .htaccess write failed (read-only file), hits still take
5724 + // the drop-in path and ARE counted — the disclosure would be the
5725 + // opposite of the truth. Health carries the "block missing"
5726 + // warning for that state; this flag only speaks when static
5727 + // serving is genuinely in effect.
5728 + 'static_hits_uncounted' => (
5729 + Server::LITESPEED === Server::type()
5730 + && ! empty( Settings::get()['cache_enabled'] )
5731 + && self::static_rewrite_allowed()
5732 + && self::rewrite_installed()
5733 + ),
5734 + /*
5735 + * Whether the page cache is actually SERVING, as opposed to
5736 + * switched on in settings. The hero read the setting alone and
5737 + * announced "Active — serving cached HTML"; a site whose
5738 + * advanced-cache.php had been taken over by another cache plugin
5739 + * got that line while every response carried
5740 + * `X-XSpeed-Cache: BYPASS`. The setting is the user's intent;
5741 + * this is the outcome, and the dashboard needs both to explain
5742 + * the difference.
5743 + */
5744 + 'page_cache_serving' => $serving,
5745 + /*
5746 + * Why not, when intent and outcome disagree. Only computed in
5747 + * that state — the detector sweep behind it is far more work than
5748 + * a stats call should do on an ordinary healthy site.
5749 + */
5750 + 'page_cache_blocked_reason' => ( ! $serving && ! empty( Settings::get()['cache_enabled'] ) )
5751 + ? ( self::acquisition_blocker() ?? self::not_serving_reason() )
5752 + : null,
1689 5753 );
1690 5754 }
1691 5755
1692 5756 /**
5757 + * Why the cache is not serving, when nothing REFUSES to enable it.
5758 + *
5759 + * acquisition_blocker() answers "may we take the field", and since a
5760 + * foreign drop-in became takeable it answers null on a site where another
5761 + * plugin is nonetheless holding that file. Intent and outcome still
5762 + * disagree there, and the dashboard was left reporting the symptom -- not
5763 + * serving -- with no reason under it, which is exactly the state a user
5764 + * cannot act on.
5765 + *
5766 + * So this names the holder and says what to do: enabling takes it over.
5767 + */
5768 + private static function not_serving_reason(): ?string {
5769 + $owner = self::dropin_owner();
5770 + if ( self::DROPIN_FOREIGN !== $owner && self::DROPIN_UNREADABLE !== $owner ) {
5771 + return null;
5772 + }
5773 +
5774 + if ( self::DROPIN_UNREADABLE === $owner ) {
5775 + return __( 'advanced-cache.php cannot be read, so xSpeed cannot tell whose page cache is installed.', 'xspeed' );
5776 + }
5777 +
5778 + $label = Page_Cache_Detector::dropin_owner_label();
5779 + return $label
5780 + ? sprintf(
5781 + /* translators: %s: the page-caching plugin that owns advanced-cache.php. */
5782 + __( '%s is serving the page cache. Turn the xSpeed cache off and on again to take it over.', 'xspeed' ),
5783 + $label
5784 + )
5785 + : __( 'Another plugin is serving the page cache. Turn the xSpeed cache off and on again to take it over.', 'xspeed' );
5786 + }
5787 +
5788 + /**
1693 5789 * Whether the current request should be kept OUT of the cache hit/miss
1694 5790 * ratio: a genuine 404, or a known bot / scanner. Runs at template_redirect
1695 5791 * time, so is_404() is resolved. (#118)
1696 5792 */
@@ -1697,8 +5793,13 @@
1697 5793 private static function miss_is_excluded(): bool {
1698 5794 if ( function_exists( 'is_404' ) && is_404() ) {
1699 5795 return true;
1700 5796 }
5797 + // A marked request is ours whatever its UA says: a renamed warmer,
5798 + // or a probe that has to send a browser's UA.
5799 + if ( Self_Traffic::request_is_marked() ) {
5800 + return true;
5801 + }
1701 5802 $ua = isset( $_SERVER['HTTP_USER_AGENT'] )
1702 5803 ? sanitize_text_field( wp_unslash( (string) $_SERVER['HTTP_USER_AGENT'] ) )
1703 5804 : '';
1704 5805 return Hit_Counter::is_bot_ua( $ua );
@@ -1704,16 +5805,20 @@
1704 5805 return Hit_Counter::is_bot_ua( $ua );
1705 5806 }
1706 5807
1707 5808 /**
1708 - * Whether an edge cache fronts this origin. Today: the Cloudflare
1709 - * integration is connected — so an unknown share of hits is served at the
1710 - * edge and never counted here, making the origin ratio a partial view the
1711 - * dashboard must label as such. (#118)
5809 + * Whether an edge cache fronts this origin, so an unknown share of hits
5810 + * is served there and never counted here — which makes the origin ratio a
5811 + * partial view the dashboard has to label as such. (#118)
5812 + *
5813 + * This used to mean "the Cloudflare module is switched on", which answered
5814 + * no for every site fronted by anything else, and no for a site on
5815 + * Cloudflare that had never opened our Cloudflare panel. Both of those
5816 + * sites had their ratio presented as the whole story. Edge_Provider knows
5817 + * better and knows it per request, so ask it.
1712 5818 */
1713 5819 private static function edge_cache_detected(): bool {
1714 - $cf = get_option( 'xspeed_module_cloudflare', array() );
1715 - return is_array( $cf ) && ! empty( $cf['enabled'] );
5820 + return Edge_Provider::NONE !== Edge_Provider::detect()['confidence'];
1716 5821 }
1717 5822
1718 5823 /**
1719 5824 * Apply the user's enable/disable choice. Called from the REST toggle
@@ -1727,11 +5832,19 @@
1727 5832 * already has cache_enabled = true is a different act and is handled
1728 5833 * by restore_dropin_if_enabled() on activation and auto_heal() at
1729 5834 * runtime; without it every plugin update silently un-caches the site.
1730 5835 *
5836 + * Enabling is gated on acquisition_blocker(): if another plugin owns the
5837 + * drop-in, or WP_CACHE is written in a form we must not rewrite, nothing
5838 + * is written and the returned state carries `blocked` + a reason the
5839 + * caller can show. Callers must persist `cache_enabled` from the returned
5840 + * `enabled`, never from what they asked for.
5841 + *
1731 5842 * @param bool $enable User's choice.
1732 5843 * @return array{
1733 5844 * enabled: bool,
5845 + * blocked: bool,
5846 + * blocked_reason: ?string,
1734 5847 * dropin_installed: bool,
1735 5848 * wp_cache_constant: bool,
1736 5849 * wp_config_writable: bool,
1737 5850 * manual_snippet: ?string
@@ -1736,29 +5849,228 @@
1736 5849 * wp_config_writable: bool,
1737 5850 * manual_snippet: ?string
1738 5851 * }
1739 5852 */
1740 - public static function toggle( $enable ) {
5853 + public static function toggle( $enable, bool $consented = true ) {
5854 + Page_Cache_Detector::invalidate();
5855 + $expected = Page_Cache_Detector::inspect()['revision'];
5856 + /** Diagnostic seam; changing the expected revision can only force a safe refusal. */
5857 + $expected = (string) apply_filters( 'xspeed_page_cache_expected_revision', $expected );
5858 + $lock = self::page_cache_lock();
5859 + if ( ! is_resource( $lock ) ) {
5860 + return self::blocked_toggle_state( __( 'Could not lock page-cache ownership. Try again.', 'xspeed' ) );
5861 + }
5862 + try {
5863 + Page_Cache_Detector::invalidate();
5864 + $fresh = Page_Cache_Detector::inspect()['revision'];
5865 + if ( ! hash_equals( (string) $expected, (string) $fresh ) ) {
5866 + return self::blocked_toggle_state( __( 'Page-cache ownership changed while xSpeed was checking it. Nothing was changed; try again.', 'xspeed' ) );
5867 + }
5868 + $state = self::toggle_unlocked( (bool) $enable, $consented );
5869 + return $state;
5870 + } finally {
5871 + flock( $lock, LOCK_UN );
5872 + fclose( $lock );
5873 + }
5874 + }
5875 +
5876 + /** Run the page-cache mutation while toggle() owns the scoped lock. */
5877 + /**
5878 + * @param bool $consented The user asked for this in the dashboard, so a
5879 + * foreign drop-in may be taken over. False on the
5880 + * unattended paths, which stand down instead.
5881 + */
5882 + private static function toggle_unlocked( bool $enable, bool $consented = true ) {
1741 5883 $enable = (bool) $enable;
1742 5884
1743 5885 if ( $enable ) {
1744 - $dropin_ok = self::install_dropin();
1745 - $wp_config_ok = self::set_wp_cache_constant( true );
5886 + /*
5887 + * Preflight. The drop-in and the WP_CACHE define are shared,
5888 + * single-occupancy state; if we do not own them, no part of this
5889 + * runs — not the drop-in, not wp-config.php, not the rewrite
5890 + * block. Refusing whole is the point: a partial enable leaves the
5891 + * site claiming a cache it cannot serve.
5892 + *
5893 + * Every caller routes through here (REST, onboarding, MCP, CLI,
5894 + * the optimize runner, Pro's migration), so the gate lives here
5895 + * rather than being re-implemented at each entry point.
5896 + *
5897 + * Except when there is nothing to acquire. A site where we
5898 + * already own the drop-in and are already serving is being asked
5899 + * to stay as it is, and the gate answers a different question —
5900 + * "is the field free to take" — which a merely ACTIVE competitor
5901 + * makes false. So "make sure caching is on", from an AI agent,
5902 + * the optimize runner or Pro's migration, came back as a refusal
5903 + * telling the user to deactivate a plugin on a site that was
5904 + * caching perfectly. The dashboard never saw it, because nobody
5905 + * presses Enable on a cache that is already enabled.
5906 + *
5907 + * Only the GATE is skipped. The writes below still run, and every
5908 + * one of them is individually idempotent — which matters, because
5909 + * this is the path CacheModule re-bakes the drop-in through when
5910 + * an exclusion rule or the TTL changes (#240, #251), and the path
5911 + * auto_heal() restores a stripped WP_CACHE through. Returning
5912 + * early here left both of those doing nothing at all, silently,
5913 + * on exactly the healthy sites this branch is about.
5914 + */
5915 + $reasserting = self::page_cache_operational() && self::DROPIN_XSPEED === self::dropin_owner();
5916 + $blocker = $reasserting ? null : self::acquisition_blocker();
5917 +
5918 + /*
5919 + * Taking over another plugin's drop-in needs the user to have
5920 + * asked for it. On the dashboard they did -- they clicked the
5921 + * switch, having been told whose file it is. The UNATTENDED
5922 + * callers have no such click: restore_dropin_if_enabled() runs
5923 + * after a plugin update and auto_heal() on an admin page load,
5924 + * both from nothing more than `cache_enabled` still being true.
5925 + *
5926 + * A competitor installed since that flag was set would have its
5927 + * page cache seized by a background repair, which is the silent
5928 + * acquisition this plugin refuses to perform. So those callers
5929 + * pass $consented = false and stand down instead.
5930 + */
5931 + if ( null === $blocker && ! $consented && self::DROPIN_FOREIGN === self::dropin_owner() ) {
5932 + // Name the owner. This string is rendered by host plugins
5933 + // through Host::enable_page_cache(), and an unnamed refusal
5934 + // is what made every host invent its own explanation.
5935 + $owner_label = Page_Cache_Detector::dropin_owner_label();
5936 + return self::blocked_toggle_state(
5937 + $owner_label
5938 + ? sprintf(
5939 + /* translators: %s: the page-caching plugin that owns advanced-cache.php. */
5940 + __( '%s owns advanced-cache.php, so xSpeed left it alone. Enable the cache from the xSpeed dashboard to take it over.', 'xspeed' ),
5941 + $owner_label
5942 + )
5943 + : __( 'Another plugin owns advanced-cache.php, so xSpeed left it alone. Enable the cache from the xSpeed dashboard to take it over.', 'xspeed' )
5944 + );
5945 + }
5946 + if ( null !== $blocker ) {
5947 + Activity_Log::record(
5948 + 'cache_enable_blocked',
5949 + 'Cache not enabled — ' . $blocker,
5950 + Activity_Log::WARN
5951 + );
5952 +
5953 + return self::blocked_toggle_state( $blocker );
5954 + }
5955 +
5956 + $dropin_path = WP_CONTENT_DIR . '/advanced-cache.php';
5957 + $config_path = self::wp_config_path();
5958 + $dropin_before = file_exists( $dropin_path ) ? self::read_file( $dropin_path ) : null;
5959 + $config_before = '' !== $config_path ? self::read_file( $config_path ) : null;
5960 + $dropin_ok = self::install_dropin();
5961 + if ( ! $dropin_ok ) {
5962 + $partial = self::read_file( $dropin_path );
5963 + if ( is_string( $partial ) && xspeed_has_canonical_dropin_signature( $partial ) ) {
5964 + self::rollback_page_cache_artifacts( $dropin_path, $dropin_before, $partial, $config_path, $config_before, null );
5965 + }
5966 + /*
5967 + * Preflight said the field was clear, so this is a filesystem
5968 + * failure (or a drop-in that appeared in between). Without the
5969 + * drop-in there is no cache to enable, and persisting
5970 + * cache_enabled anyway is what produced sites reporting a
5971 + * healthy cache while serving every request uncached.
5972 + */
5973 + $reason = __( 'Could not write wp-content/advanced-cache.php. Check filesystem permissions.', 'xspeed' );
5974 + Activity_Log::record(
5975 + 'cache_enable_blocked',
5976 + 'Cache not enabled — ' . $reason,
5977 + Activity_Log::WARN
5978 + );
5979 +
5980 + return array(
5981 + 'enabled' => false,
5982 + 'blocked' => true,
5983 + 'blocked_reason' => $reason,
5984 + 'dropin_installed' => false,
5985 + 'wp_cache_constant' => false,
5986 + 'rewrite_installed' => false,
5987 + 'wp_config_writable' => self::wp_config_writable(),
5988 + 'manual_snippet' => null,
5989 + 'nginx_snippet' => self::nginx_snippet(),
5990 + 'nginx_server_block' => self::full_nginx_server_block(),
5991 + );
5992 + }
5993 +
5994 + $dropin_written = self::read_file( $dropin_path );
5995 + self::set_wp_cache_constant( true );
5996 + $config_written = '' !== $config_path ? self::read_file( $config_path ) : null;
5997 + Page_Cache_Detector::invalidate();
5998 + $dropin_ours = self::DROPIN_XSPEED === self::dropin_owner();
5999 + $constant_state = self::wp_cache_define_state();
6000 + $constant_ok = 'true' === $constant_state;
6001 +
6002 + /*
6003 + * A wp-config.php we cannot write at all is a supported state, not
6004 + * a failed transaction. Plenty of managed hosts ship the file
6005 + * read-only; there the drop-in is ours and installed, the cache
6006 + * works the moment WP_CACHE exists, and the one line to paste
6007 + * comes back as `manual_snippet`. Rolling back instead left those
6008 + * hosts unable to turn the page cache on by any route — including
6009 + * when the user had already pasted the define, since the write
6010 + * fails on an unwritable file whatever value is already there.
6011 + *
6012 + * `undefined` ONLY. `false` looks eligible — this method would
6013 + * have rewritten it — but the snippet we hand back cannot work
6014 + * there: the file already says `define( 'WP_CACHE', false )`, the
6015 + * first define() call wins, and a user who pastes our line via
6016 + * FTP ends up with a cache that never serves AND a `duplicate`
6017 + * wp-config that blocks every future toggle in both directions.
6018 + * They have to edit the existing line, which means refusing here
6019 + * and saying so. `duplicate` and `dynamic` are refused by
6020 + * acquisition_blocker() before we get here, and if one appears in
6021 + * the race window it must still fail closed.
6022 + */
6023 + $manual_mode = ! $constant_ok
6024 + && 'undefined' === $constant_state
6025 + && ! self::can_write_wp_config();
6026 +
6027 + if ( ! $dropin_ours || ( ! $constant_ok && ! $manual_mode ) ) {
6028 + if ( ! self::can_write_wp_config() ) {
6029 + $reason = 'false' === $constant_state
6030 + ? __( "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' )
6031 + : __( 'xSpeed could not verify the complete page-cache write, and wp-config.php is not writable. Its changes were rolled back.', 'xspeed' );
6032 + } else {
6033 + $reason = __( 'xSpeed could not verify the complete page-cache write. Its changes were rolled back.', 'xspeed' );
6034 + }
6035 + self::rollback_page_cache_artifacts( $dropin_path, $dropin_before, $dropin_written, $config_path, $config_before, $config_written );
6036 + return self::blocked_toggle_state( $reason );
6037 + }
6038 + $wp_config_ok = $constant_ok;
1746 6039 $rewrite_ok = self::install_rewrite();
1747 6040 self::ensure_hits_log_file();
1748 6041 self::sync_mobile_flag();
1749 6042 $snippet = $wp_config_ok ? null : "define( 'WP_CACHE', true );";
6043 + Settings::update( array( 'cache_enabled' => true ) );
6044 + if ( empty( Settings::get()['cache_enabled'] ) ) {
6045 + self::remove_rewrite();
6046 + self::rollback_page_cache_artifacts( $dropin_path, $dropin_before, $dropin_written, $config_path, $config_before, $config_written );
6047 + delete_option( 'xspeed_page_cache_ownership_receipt' );
6048 + return self::blocked_toggle_state( __( 'xSpeed could not save the page-cache setting. Its file changes were rolled back.', 'xspeed' ) );
6049 + }
1750 6050
1751 - Activity_Log::record(
1752 - 'cache_enabled_event',
1753 - $wp_config_ok
1754 - ? 'Cache enabled. Drop-in installed, WP_CACHE constant set.'
1755 - : 'Cache enabled. Drop-in installed; wp-config.php not writable — add the WP_CACHE snippet manually.',
1756 - $wp_config_ok ? Activity_Log::SUCCESS : Activity_Log::WARN
1757 - );
6051 + /*
6052 + * Only when this call actually changed something. auto_heal() runs
6053 + * the enable transaction on every admin_init, and an unconditional
6054 + * entry filled the 50-slot log with identical "Cache enabled" lines
6055 + * within 50 wp-admin page loads, evicting every real event — plus
6056 + * an option write per admin request. The sentence is also false
6057 + * when nothing was installed.
6058 + */
6059 + if ( $dropin_written !== $dropin_before || $config_written !== $config_before ) {
6060 + Activity_Log::record(
6061 + 'cache_enabled_event',
6062 + $wp_config_ok
6063 + ? 'Cache enabled. Drop-in installed, WP_CACHE constant set.'
6064 + : 'Cache enabled. Drop-in installed; wp-config.php not writable — add the WP_CACHE snippet manually.',
6065 + $wp_config_ok ? Activity_Log::SUCCESS : Activity_Log::WARN
6066 + );
6067 + }
1758 6068
1759 6069 return array(
1760 6070 'enabled' => true,
6071 + 'blocked' => false,
6072 + 'blocked_reason' => null,
1761 6073 'dropin_installed' => (bool) $dropin_ok,
1762 6074 'wp_cache_constant' => (bool) $wp_config_ok,
1763 6075 'rewrite_installed' => (bool) $rewrite_ok,
1764 6076 'wp_config_writable' => self::wp_config_writable(),
@@ -1771,26 +6083,168 @@
1771 6083 'nginx_server_block' => self::full_nginx_server_block(),
1772 6084 );
1773 6085 }
1774 6086
6087 + /*
6088 + * Whose advanced-cache.php is on disk decides how much of the disable
6089 + * below may run. Read it once, before anything is touched.
6090 + */
6091 + $owner = self::dropin_owner();
6092 + $not_ours = self::DROPIN_FOREIGN === $owner || self::DROPIN_UNREADABLE === $owner;
6093 + if ( ! self::set_wp_cache_constant( false ) ) {
6094 + /*
6095 + * The mirror of the enable path. A wp-config.php nobody can write
6096 + * does not trap the user in a cache they turned off: WP_CACHE on
6097 + * its own does nothing once advanced-cache.php is gone, and core
6098 + * simply skips the missing drop-in. Refusing here left the
6099 + * read-only managed hosts able to enable the page cache and never
6100 + * able to disable it again.
6101 + *
6102 + * A drop-in that is not ours reaches the same conclusion by a
6103 + * different road. WP_CACHE is then the switch for THEIR cache, so
6104 + * set_wp_cache_constant() refuses it — correctly, and permanently,
6105 + * because nothing the user does to xSpeed will make that file ours
6106 + * again. Treating that refusal as a failed disable was a trap with
6107 + * no exit: install any competing cache plugin while xSpeed's cache
6108 + * was on, and xSpeed's toggle could never be turned off again,
6109 + * while the dashboard went on claiming a cache that was serving
6110 + * nothing. Turning xSpeed off is entirely within our own state —
6111 + * our setting, our rewrite block — so it proceeds, and their
6112 + * constant and their file are left exactly as they are.
6113 + */
6114 + /*
6115 + * Every reason set_wp_cache_constant() refuses is structural
6116 + * except one, and the exception is the only one worth blocking
6117 + * on. It will not touch a constant it cannot prove is ours; it
6118 + * will not rewrite a define it cannot read as a literal —
6119 + * duplicate, dynamic, or inside a conditional; and it cannot
6120 + * write a file the filesystem will not let it write. None of
6121 + * those improve on a retry, and all of them leave a WP_CACHE
6122 + * that does nothing once our drop-in is gone. What is left — our
6123 + * own constant, in a shape we can rewrite, in a file we can
6124 + * write, and the write still failed — is a real I/O failure, and
6125 + * that one still refuses so the user is not told a cache was
6126 + * turned off while it goes on serving.
6127 + *
6128 + * The proof, not the drop-in, is the test. A user who pasted our
6129 + * manual snippet on a locked-down host has a WP_CACHE line with
6130 + * no receipt on it; if their drop-in later goes missing, we can
6131 + * never prove that line is ours, so refusing left the toggle
6132 + * stuck on with no way out but enabling first and disabling
6133 + * again. Nothing loads a drop-in that is not there, so the line
6134 + * is inert either way and the disable proceeds without it.
6135 + */
6136 + $leave_it = ! self::wp_cache_define_is_ours_to_remove( $owner )
6137 + || ! in_array( self::wp_cache_define_state(), array( 'true', 'false', 'undefined' ), true )
6138 + || ! self::can_write_wp_config();
6139 + if ( ! $leave_it ) {
6140 + return self::blocked_toggle_state( __( 'xSpeed could not safely remove its WP_CACHE setting. The cache remains enabled.', 'xspeed' ) );
6141 + }
6142 + }
1775 6143 self::remove_dropin();
1776 - self::set_wp_cache_constant( false );
6144 + if ( self::DROPIN_XSPEED === self::dropin_owner() ) {
6145 + // Put WP_CACHE back, and say so if we could not. Reporting a
6146 + // hardcoded `enabled: true` here claimed a working cache on a
6147 + // site whose constant we had just failed to restore.
6148 + // Put WP_CACHE back, then read the outcome off disk rather than
6149 + // trusting the write's return value — a write can report failure
6150 + // for a value that was already correct, and the question the
6151 + // caller needs answered is whether the cache serves.
6152 + self::set_wp_cache_constant( true );
6153 + return self::blocked_toggle_state(
6154 + self::page_cache_operational()
6155 + ? __( 'xSpeed could not remove its page-cache drop-in. The cache remains enabled.', 'xspeed' )
6156 + : __( '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' )
6157 + );
6158 + }
1777 6159 self::remove_rewrite();
6160 + /*
6161 + * The .htaccess block serves cached HTML straight off disk without
6162 + * ever reaching PHP, so a block we failed to remove keeps answering
6163 + * requests from a cache the user just turned off — and nothing else
6164 + * in this method can stop it. remove_rewrite() also returns false
6165 + * when there is no .htaccess to clean, which is the ordinary case,
6166 + * so ask the file rather than trust the return value.
6167 + */
6168 + if ( self::rewrite_installed() ) {
6169 + if ( $not_ours ) {
6170 + // Nothing to roll back — under a foreign drop-in this method
6171 + // removed no drop-in and wrote no constant, and it could not
6172 + // put either back if it wanted to. Say what is actually left.
6173 + 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' ) );
6174 + }
6175 + /*
6176 + * Roll the disable back. Both calls can fail — a filesystem that
6177 + * would not let us remove the block may not let us write the
6178 + * drop-in either — and discarding their results reported an
6179 + * enabled cache over a site left with no drop-in and no
6180 + * constant. Fall through to the default state so the artifact
6181 + * fields are read from disk rather than asserted.
6182 + */
6183 + self::install_dropin();
6184 + self::set_wp_cache_constant( true );
6185 + // Both of those can fail — a filesystem that would not let us
6186 + // remove the block may not let us write the drop-in either — so
6187 + // the message follows what is on disk afterwards, not what the
6188 + // calls returned.
6189 + return self::blocked_toggle_state(
6190 + self::page_cache_operational()
6191 + ? __( 'xSpeed could not remove its rewrite rules from .htaccess, which would keep serving cached pages. The cache remains enabled.', 'xspeed' )
6192 + : __( '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' )
6193 + );
6194 + }
1778 6195 // Drop the device-bucket marker too — with the drop-in gone there's
1779 6196 // nothing left to read it, and leaving it behind would dirty a fresh
1780 6197 // re-enable (and leaks across test runs).
1781 6198 self::sync_mobile_flag( false );
6199 + Settings::update( array( 'cache_enabled' => false ) );
6200 + if ( ! empty( Settings::get()['cache_enabled'] ) ) {
6201 + if ( $not_ours ) {
6202 + // Same as above: there is nothing of ours on disk to restore.
6203 + return self::blocked_toggle_state( __( 'xSpeed could not save the disabled state.', 'xspeed' ) );
6204 + }
6205 + self::install_dropin();
6206 + self::set_wp_cache_constant( true );
6207 + return self::blocked_toggle_state( __( 'xSpeed could not save the disabled state. The page cache was restored.', 'xspeed' ) );
6208 + }
1782 6209
6210 + // A WP_CACHE we could not remove because wp-config.php is read-only
6211 + // is left behind deliberately (see above) — say so rather than
6212 + // reporting a constant that is still in the file as gone.
6213 + $constant_left = 'true' === self::wp_cache_define_state();
6214 + /*
6215 + * Say why the constant is still there, because there are now three
6216 + * different reasons and they call for different advice. Keyed off the
6217 + * same facts $leave_it was, so the log cannot drift from the decision
6218 + * it is describing — it did, briefly, and reported a wp-config.php as
6219 + * unwritable when the real reason was that we could not prove the
6220 + * line was ours.
6221 + */
6222 + if ( self::DROPIN_UNREADABLE === $owner ) {
6223 + $log_message = 'Cache disabled. advanced-cache.php could not be read, so it and the WP_CACHE setting were left untouched.';
6224 + } elseif ( $not_ours ) {
6225 + $log_message = 'Cache disabled. Another plugin owns advanced-cache.php, so its drop-in and its WP_CACHE setting were left untouched.';
6226 + } elseif ( ! $constant_left ) {
6227 + $log_message = 'Cache disabled. Drop-in removed.';
6228 + } elseif ( ! self::wp_cache_define_is_ours_to_remove( $owner ) ) {
6229 + $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.';
6230 + } elseif ( ! self::can_write_wp_config() ) {
6231 + $log_message = 'Cache disabled. Drop-in removed; wp-config.php not writable, so WP_CACHE was left in place (harmless without the drop-in).';
6232 + } else {
6233 + $log_message = 'Cache disabled. Drop-in removed; WP_CACHE was left in place (harmless without the drop-in).';
6234 + }
1783 6235 Activity_Log::record(
1784 6236 'cache_disabled_event',
1785 - 'Cache disabled. Drop-in removed.',
1786 - Activity_Log::INFO
6237 + $log_message,
6238 + $constant_left ? Activity_Log::WARN : Activity_Log::INFO
1787 6239 );
1788 6240
1789 6241 return array(
1790 6242 'enabled' => false,
6243 + 'blocked' => false,
6244 + 'blocked_reason' => null,
1791 6245 'dropin_installed' => false,
1792 - 'wp_cache_constant' => false,
6246 + 'wp_cache_constant' => $constant_left,
1793 6247 'rewrite_installed' => false,
1794 6248 'wp_config_writable' => self::wp_config_writable(),
1795 6249 'manual_snippet' => null,
1796 6250 'nginx_snippet' => self::nginx_snippet(),
@@ -1797,9 +6251,134 @@
1797 6251 'nginx_server_block' => self::full_nginx_server_block(),
1798 6252 );
1799 6253 }
1800 6254
6255 + /** Acquire the local lock that serializes page-cache ownership changes. */
6256 + private static function page_cache_lock() {
6257 + $path = WP_CONTENT_DIR . '/.xspeed-page-cache.lock';
6258 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen,WordPress.PHP.NoSilencedErrors.Discouraged -- flock requires a local handle; failure is a safe blocked result.
6259 + $lock = @fopen( $path, 'c+' );
6260 + if ( ! is_resource( $lock ) || ! flock( $lock, LOCK_EX ) ) {
6261 + return false;
6262 + }
6263 + return $lock;
6264 + }
6265 +
1801 6266 /**
6267 + * Build the stable response shape for a refused transaction.
6268 + *
6269 + * The artifact fields report what is ON DISK, not zeros. A refusal means
6270 + * xSpeed changed nothing — on a site already running our cache that is
6271 + * exactly the state where the drop-in and WP_CACHE are both still in
6272 + * place and still serving hits. Hardcoding false told the dashboard the
6273 + * cache had been dismantled every time a refusal was returned.
6274 + */
6275 + private static function blocked_toggle_state( string $reason ): array {
6276 + /*
6277 + * `enabled` answers ONE question: is the page cache operational right
6278 + * now. Not what was asked for, and not what the option says.
6279 + *
6280 + * WordPress loads advanced-cache.php only when WP_CACHE is truthy, so
6281 + * those two files together are the whole answer, and reading them is
6282 + * the only source that cannot go stale. Both of the alternatives were
6283 + * tried here and both produced wrong answers on real paths: a
6284 + * hardcoded false told a caller the cache had gone away on a site
6285 + * still serving hits, and the persisted setting told a caller the
6286 + * cache was healthy after a rollback had just removed the artifacts
6287 + * — the option is not written until the end of the transaction, so
6288 + * mid-transaction it is stale by construction.
6289 + *
6290 + * Deliberately not a parameter. Every branch that got to choose its
6291 + * own answer eventually chose wrong.
6292 + */
6293 + return array(
6294 + 'enabled' => self::page_cache_operational(),
6295 + 'blocked' => true,
6296 + 'blocked_reason' => $reason,
6297 + 'dropin_installed' => self::DROPIN_XSPEED === self::dropin_owner(),
6298 + 'wp_cache_constant' => 'true' === self::wp_cache_define_state(),
6299 + 'rewrite_installed' => self::rewrite_installed(),
6300 + 'wp_config_writable' => self::wp_config_writable(),
6301 + 'manual_snippet' => null,
6302 + 'nginx_snippet' => self::nginx_snippet(),
6303 + 'nginx_server_block' => self::full_nginx_server_block(),
6304 + );
6305 + }
6306 +
6307 + /**
6308 + * The wp-config.php line a user must paste, or null when none is needed.
6309 + *
6310 + * Non-null only where the drop-in is ours and WP_CACHE is not set to true
6311 + * in a file we can write — the read-only managed host. Everywhere else the
6312 + * constant is ours to manage and there is nothing to ask for.
6313 + */
6314 + public static function manual_wp_cache_snippet(): ?string {
6315 + if ( self::DROPIN_XSPEED !== self::dropin_owner() ) {
6316 + return null;
6317 + }
6318 + if ( 'true' === self::wp_cache_define_state() ) {
6319 + return null;
6320 + }
6321 + return self::wp_config_writable() ? null : "define( 'WP_CACHE', true );";
6322 + }
6323 +
6324 + /**
6325 + * Is the page cache serving right now?
6326 + *
6327 + * Two things decide it, and `WP_CACHE` is not one of them.
6328 + *
6329 + * xSpeed serves a cached page from `template_redirect` whenever the
6330 + * setting is on — see the `HIT (php)` mark on that path, which exists
6331 + * precisely for "the drop-in isn't loaded". `advanced-cache.php` and the
6332 + * `WP_CACHE` constant that loads it are the FAST path: they answer before
6333 + * WordPress boots, which is worth a lot of milliseconds and nothing at
6334 + * all to the question of whether pages are being served from cache.
6335 + *
6336 + * Conflating the two reported a dead cache over a live one. On a managed
6337 + * host with an unwritable wp-config.php — the exact case the manual
6338 + * snippet exists for — one card said "Your cache works on every request",
6339 + * "On, but not serving", "nothing will be cached until you add this line"
6340 + * and "hit ratio 67%", all at once, and told the user to edit a file they
6341 + * have no permission to write. The released 1.2.1 reported that site as
6342 + * active, correctly.
6343 + *
6344 + * So: the setting, and whether anyone else holds the drop-in. A foreign
6345 + * drop-in answers before WordPress loads us, so ours never runs and we
6346 + * genuinely are not serving. An unreadable one we must assume the same of.
6347 + * Everything else — our drop-in, or none at all — serves.
6348 + *
6349 + * Public because it is part of the host-plugin contract — see Host. A
6350 + * plugin that installed xSpeed needs to be able to say whether the cache
6351 + * it asked for is actually serving, and no combination of settings reads
6352 + * answers that.
6353 + */
6354 + public static function page_cache_operational(): bool {
6355 + $settings = Settings::get();
6356 + if ( empty( $settings['cache_enabled'] ) ) {
6357 + return false;
6358 + }
6359 + $owner = self::dropin_owner();
6360 + return self::DROPIN_FOREIGN !== $owner && self::DROPIN_UNREADABLE !== $owner;
6361 + }
6362 +
6363 + /** Restore exact snapshots only while disk still matches our own write. */
6364 + 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 {
6365 + // Roll back only files that still carry xSpeed's just-written state.
6366 + if ( null !== $dropin_written && hash_equals( $dropin_written, (string) self::read_file( $dropin_path ) ) ) {
6367 + if ( null === $dropin_before ) {
6368 + wp_delete_file( $dropin_path );
6369 + } else {
6370 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- Exact compare-and-swap rollback under the scoped lock.
6371 + file_put_contents( $dropin_path, $dropin_before );
6372 + }
6373 + }
6374 + 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 ) ) {
6375 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- Exact compare-and-swap rollback under the scoped lock.
6376 + file_put_contents( $config_path, $config_before );
6377 + }
6378 + }
6379 +
6380 + /**
1802 6381 * Check wp-config.php writability via WP_Filesystem. Plugin Check flags
1803 6382 * direct is_writable() under WordPress.WP.AlternativeFunctions.
1804 6383 */
1805 6384 private static function wp_config_writable() {
@@ -1836,10 +6415,11 @@
1836 6415 * they don't share a user at all. A default-umask 0644 file is then
1837 6416 * unwritable by nginx, the access_log write silently fails, and the
1838 6417 * dashboard shows a 0% hit ratio even though static HITs are serving.
1839 6418 * So we widen the dir to 0777 and the file to 0666 — group/other write —
1840 - * so whatever uid nginx runs as can append. (The file holds only HIT
1841 - * request lines, no secrets.)
6419 + * so whatever uid nginx runs as can append. The file holds HIT request
6420 + * lines and must be protected like an access log: paths and queries can
6421 + * contain sensitive values.
1842 6422 */
1843 6423 /**
1844 6424 * Directory holding the nginx hit log. Lives under uploads/, NOT the
1845 6425 * cache dir — uninstall.php and a cache purge both delete the cache
@@ -1882,12 +6462,218 @@
1882 6462 * and the fast pre-WP path was silently dead.
1883 6463 *
1884 6464 * @param bool|null $enabled Force a state; null reads the current setting.
1885 6465 */
6466 + /**
6467 + * Write the subdirectory-multisite path list the drop-in needs to work
6468 + * out which blog a request belongs to.
6469 + *
6470 + * The drop-in runs before WordPress, so it cannot call is_multisite()
6471 + * or get_blog_details(). It can only see REQUEST_URI — so we persist the
6472 + * network's blog paths (one per line, longest first) next to the cache
6473 + * files, exactly as sync_mobile_flag() persists the device flag. The
6474 + * drop-in prefix-matches the URI against that list to pick the same
6475 + * bucket Cache::current_host_dir() picks. (#6)
6476 + *
6477 + * No file is written for a single site or a subdomain network — there
6478 + * the host alone identifies the blog and the bucket carries no prefix.
6479 + */
6480 + public static function sync_site_paths(): void {
6481 + $file = XSPEED_CACHE_DIR . '/.site-paths';
6482 +
6483 + $needed = function_exists( 'is_multisite' ) && is_multisite()
6484 + && ( ! function_exists( 'is_subdomain_install' ) || ! is_subdomain_install() );
6485 +
6486 + if ( ! $needed ) {
6487 + if ( file_exists( $file ) ) {
6488 + // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal.
6489 + @unlink( $file );
6490 + }
6491 + return;
6492 + }
6493 +
6494 + if ( ! function_exists( 'get_sites' ) ) {
6495 + return;
6496 + }
6497 +
6498 + $paths = array();
6499 + foreach ( get_sites( array( 'number' => 0 ) ) as $site ) {
6500 + $prefix = self::path_prefix_segment( (string) $site->path );
6501 + if ( '' !== $prefix ) {
6502 + // Store the raw path so the drop-in can prefix-match a URI,
6503 + // alongside the segment it maps to.
6504 + $paths[ trim( (string) $site->path, '/' ) ] = $prefix;
6505 + }
6506 + }
6507 +
6508 + if ( empty( $paths ) ) {
6509 + if ( file_exists( $file ) ) {
6510 + // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- see above.
6511 + @unlink( $file );
6512 + }
6513 + return;
6514 + }
6515 +
6516 + // Longest path first so /a/b wins over /a.
6517 + uksort(
6518 + $paths,
6519 + static function ( $x, $y ) {
6520 + return strlen( (string) $y ) <=> strlen( (string) $x );
6521 + }
6522 + );
6523 +
6524 + $lines = array();
6525 + foreach ( $paths as $raw => $segment ) {
6526 + $lines[] = $raw . '|' . $segment;
6527 + }
6528 +
6529 + if ( ! is_dir( XSPEED_CACHE_DIR ) && ! wp_mkdir_p( XSPEED_CACHE_DIR ) ) {
6530 + return;
6531 + }
6532 + // 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.
6533 + file_put_contents( $file, implode( "\n", $lines ), LOCK_EX );
6534 + }
6535 +
6536 + /**
6537 + * Compile `ignored_query_params` into a regex the DROP-IN can use.
6538 + *
6539 + * Tracking traffic was cached but never served fast. should_cache()
6540 + * learned to allow `?utm_source=…` through and cache_key() strips the
6541 + * query, so `/post` and `/post?utm_source=x` share one entry — but the
6542 + * drop-in still bailed on ANY query string, so every visitor from an
6543 + * email or ad campaign paid a full WordPress boot to be handed a file
6544 + * that was already on disk. On a marketing site that is most of the
6545 + * paid traffic taking the slowest path. (#13)
6546 + *
6547 + * The drop-in runs before WordPress, so it cannot read the option or
6548 + * call Glob_Matcher. It gets a precompiled alternation instead, written
6549 + * next to the cache files exactly as sync_mobile_flag() writes the
6550 + * device flag. Regenerated whenever cache settings are saved.
6551 + *
6552 + * Only the KEYS matter: a param whose name is on the list contributes
6553 + * nothing to the response, so the entry keyed without it is correct.
6554 + * Anything not on the list means the drop-in must stand down and let
6555 + * PHP decide — the file is deleted rather than left stale when the
6556 + * list is empty, so a missing sidecar always fails safe.
6557 + */
6558 + public static function sync_query_allowlist(): void {
6559 + $file = XSPEED_CACHE_DIR . '/.ignored-query-params';
6560 +
6561 + /*
6562 + * Stored read, not Settings_Manager::get() — this runs from boot(),
6563 + * before translation is legal (see stored_cache_opts()).
6564 + *
6565 + * A raw read applies no schema defaults, and this field's default is a
6566 + * long tracking-parameter list, NOT empty. Falling back to array()
6567 + * would strip that whole allow-list from the drop-in on any install
6568 + * that has never saved the Cache panel. So fall back to the schema's
6569 + * own default, read from the module without building its labels.
6570 + */
6571 + $opts = self::stored_cache_opts();
6572 + $ignored = is_array( $opts['ignored_query_params'] ?? null )
6573 + ? $opts['ignored_query_params']
6574 + : \XSpeed\Modules\Cache\CacheModule::default_ignored_query_params();
6575 +
6576 + $parts = array();
6577 + foreach ( $ignored as $pattern ) {
6578 + $pattern = trim( (string) $pattern );
6579 + if ( '' === $pattern ) {
6580 + continue;
6581 + }
6582 + if ( '~' === $pattern[0] ) {
6583 + // Raw regex, PHP-side dialect. Keep it — unlike a server
6584 + // config, the drop-in runs the same PCRE engine, so the
6585 + // pattern behaves identically. Anchored below with the rest.
6586 + $body = substr( $pattern, 1 );
6587 + if ( '' !== $body && false !== @preg_match( '#^(?:' . $body . ')$#', '' ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a malformed user pattern must be dropped, not fatal.
6588 + $parts[] = $body;
6589 + }
6590 + continue;
6591 + }
6592 + // Glob semantics, same as Glob_Matcher: * is any run, ? is one.
6593 + $esc = preg_quote( $pattern, '#' );
6594 + $esc = str_replace( array( '\*', '\?' ), array( '.*', '.' ), $esc );
6595 + $parts[] = $esc;
6596 + }
6597 +
6598 + if ( empty( $parts ) ) {
6599 + if ( file_exists( $file ) ) {
6600 + // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal.
6601 + @unlink( $file );
6602 + }
6603 + return;
6604 + }
6605 +
6606 + if ( ! is_dir( XSPEED_CACHE_DIR ) && ! wp_mkdir_p( XSPEED_CACHE_DIR ) ) {
6607 + return;
6608 + }
6609 +
6610 + // The drop-in anchors this as `^…$`, so the lookahead refuses the
6611 + // never-ignored names whole, whatever entry would have matched them.
6612 + $never = implode( '|', array_map( static fn ( $p ) => preg_quote( $p, '#' ), self::NEVER_IGNORED_QUERY_PARAMS ) );
6613 + $payload = '(?!(?:' . $never . ')$)(?:' . implode( '|', array_unique( $parts ) ) . ')';
6614 +
6615 + // Only write when the value actually changed. This runs from
6616 + // reconcile_mobile_separate() on CacheModule::boot(), so an
6617 + // unconditional write cost a file write and an exclusive lock on every
6618 + // request that boots WordPress — every MISS, every BYPASS, every admin
6619 + // screen, every REST call. sync_mobile_flag() below is the model: it
6620 + // touches the marker only when the setting flips.
6621 + // 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.
6622 + if ( is_readable( $file ) && (string) @file_get_contents( $file ) === $payload ) {
6623 + return;
6624 + }
6625 +
6626 + // 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.
6627 + file_put_contents( $file, $payload, LOCK_EX );
6628 + }
6629 +
6630 + /**
6631 + * CacheModule's STORED settings, read straight from the option.
6632 + *
6633 + * `Settings_Manager::get( 'cache' )` builds CacheModule's settings schema,
6634 + * whose labels are declared through `__()`. The reconcile chain below runs
6635 + * from `CacheModule::boot()` on `plugins_loaded` — before
6636 + * `after_setup_theme`, the point WordPress 6.7+ treats as safe to
6637 + * translate — so going through the schema there fires
6638 + * `_load_textdomain_just_in_time` on every request AND resolves the labels
6639 + * against a domain that is not loaded yet.
6640 + *
6641 + * The callers here need stored values, not schema metadata, so a raw read
6642 + * is equivalent. It applies NO defaults or coercion: read each key with a
6643 + * fallback matching the schema's own default.
6644 + *
6645 + * @return array<string,mixed>
6646 + */
6647 + private static function stored_cache_opts(): array {
6648 + $stored = get_option( Settings_Manager::OPTION_PREFIX . 'cache', array() );
6649 + return is_array( $stored ) ? $stored : array();
6650 + }
6651 +
6652 + /**
6653 + * Strict truthiness for the LiteSpeed Static Fast Path opt-in.
6654 + *
6655 + * On non-LiteSpeed servers the key is out of the schema and carried by
6656 + * preserved_keys(), so a REST/MCP write lands VERBATIM — QA on #513
6657 + * stored the string "false" on Apache and the fast path installed
6658 + * itself the moment the site moved to LiteSpeed, because
6659 + * empty("false") is false. Only an explicit, unambiguous "yes" may
6660 + * enable a path that trades away hit tagging; any other value —
6661 + * "false", "no", arbitrary junk — stays OFF, which is the default the
6662 + * user never left.
6663 + */
6664 + private static function litespeed_optin_enabled( $value ): bool {
6665 + if ( true === $value || 1 === $value ) {
6666 + return true;
6667 + }
6668 + return is_string( $value )
6669 + && in_array( strtolower( trim( $value ) ), array( '1', 'true', 'on', 'yes' ), true );
6670 + }
6671 +
1886 6672 public static function sync_mobile_flag( $enabled = null ): void {
1887 6673 if ( null === $enabled ) {
1888 - $opts = Settings_Manager::get( 'cache' );
1889 - $enabled = ! empty( $opts['mobile_separate'] );
6674 + $stored = self::stored_cache_opts();
6675 + $enabled = ! empty( $stored['mobile_separate'] );
1890 6676 }
1891 6677 $dir = XSPEED_CACHE_DIR;
1892 6678 $flag = $dir . '/.mobile-separate';
1893 6679 if ( $enabled ) {
@@ -1953,8 +6739,16 @@
1953 6739 * reconcile, and toggle() handles install/teardown itself.
1954 6740 */
1955 6741 public static function reconcile_mobile_separate(): void {
1956 6742 self::sync_mobile_flag();
6743 + if ( defined( 'XSPEED_CACHE_DIR' ) ) {
6744 + // Keep the drop-in's view of the network's blog paths current — a
6745 + // site added or removed changes which bucket its URLs belong to. (#6)
6746 + self::sync_site_paths();
6747 + // Keep the drop-in's copy of the query allow-list current — a param
6748 + // added in settings must reach the fast path too. (#13)
6749 + self::sync_query_allowlist();
6750 + }
1957 6751
1958 6752 // The rewrite/static reconciliation below needs the plugin's path
1959 6753 // constants. They're absent in early-boot / unit-test contexts where
1960 6754 // only the drop-in flag matters — bail to the flag-only behavior then.
@@ -1970,8 +6764,31 @@
1970 6764
1971 6765 $rewrite_present = self::rewrite_installed();
1972 6766 $rewrite_wanted = self::static_rewrite_allowed();
1973 6767
6768 + // Did the thing that actually invalidates cache KEYS change?
6769 + // mobile_separate buckets entries as |d / |m, so flipping it makes
6770 + // stored entries mis-bucketed and they must go. A rewrite-state
6771 + // mismatch from anything else (e.g. mod_headers detection, a hand-
6772 + // edited .htaccess) changes no key at all — the same files are still
6773 + // valid, they're just served by PHP instead of by the web server.
6774 + // Purging there is what let one WP-CLI call wipe the whole cache on
6775 + // every bootstrap. (#138)
6776 + //
6777 + // Read the setting from the SAME place static_rewrite_allowed() and
6778 + // sync_mobile_flag() do — the cache module's settings, not the
6779 + // top-level xspeed_options — or this marker would track a key that
6780 + // never changes and a real flip would go unnoticed.
6781 + // Stored read — this runs from boot(); see stored_cache_opts().
6782 + $cache_opts = self::stored_cache_opts();
6783 + $mobile_now = ! empty( $cache_opts['mobile_separate'] );
6784 + $mobile_last = get_option( 'xspeed_last_mobile_separate', null );
6785 + $mobile_flipped = ( null !== $mobile_last && (bool) (int) $mobile_last !== $mobile_now );
6786 +
6787 + if ( (string) (int) $mobile_now !== (string) $mobile_last ) {
6788 + update_option( 'xspeed_last_mobile_separate', $mobile_now ? '1' : '0', false );
6789 + }
6790 +
1974 6791 if ( $rewrite_present === $rewrite_wanted ) {
1975 6792 // Already consistent — nothing flipped, leave caches intact so a
1976 6793 // plain settings save (e.g. expiry change) doesn't blow the cache.
1977 6794 return;
@@ -1976,17 +6793,19 @@
1976 6793 // plain settings save (e.g. expiry change) doesn't blow the cache.
1977 6794 return;
1978 6795 }
1979 6796
1980 - // The setting flipped. Bring the rewrite into line and purge the
1981 - // now-misbucketed cache so the next request re-primes under the new
1982 - // device scheme.
6797 + // Bring the rewrite into line with what this server actually supports.
1983 6798 if ( $rewrite_wanted ) {
1984 6799 self::install_rewrite();
1985 6800 } else {
1986 6801 self::remove_rewrite();
1987 6802 }
1988 - self::purge_all( 'mobile_separate changed' );
6803 +
6804 + // Only discard cache contents when the device bucketing changed.
6805 + if ( $mobile_flipped ) {
6806 + self::purge_all( 'mobile_separate changed' );
6807 + }
1989 6808 }
1990 6809
1991 6810 /**
1992 6811 * Whether the server-level static-rewrite fast path may be used.
@@ -2023,12 +6842,24 @@
2023 6842 * the truth there. (Apache keeps the static fast path — it honors the
2024 6843 * header.) See maybe_emit_lscache_headers() for the paired LSCache
2025 6844 * stand-down that stops LiteSpeed's own module from shadowing the
2026 6845 * drop-in.
6846 + *
6847 + * Opt-in (#509): `litespeed_static_rewrite` re-enables the fast path on
6848 + * LiteSpeed for users who value raw TTFB over hit accounting. The trade
6849 + * is stated in the setting's copy: statically served hits carry no
6850 + * X-XSpeed-Cache header and are not counted (LiteSpeed logs the
6851 + * original request line, so even the access-log scan cannot see
6852 + * them — see Hit_Counter::collect_server_log_hits()). The drop-in
6853 + * default above stays — nobody is surprised into an unverifiable cache.
2027 6854 */
2028 6855 public static function static_rewrite_allowed(): bool {
2029 - // LiteSpeed: drop-in serves hits (visible + counted) — see docblock.
2030 - if ( Server::LITESPEED === Server::type() ) {
6856 + // Stored read — reached from boot(); see stored_cache_opts().
6857 + $opts = self::stored_cache_opts();
6858 + // LiteSpeed: drop-in serves hits (visible + counted) unless the user
6859 + // explicitly opted into the static fast path — see docblock.
6860 + if ( Server::LITESPEED === Server::type()
6861 + && ! self::litespeed_optin_enabled( $opts['litespeed_static_rewrite'] ?? false ) ) {
2031 6862 return false;
2032 6863 }
2033 6864 // Apache without mod_headers is in EXACTLY the position LiteSpeed
2034 6865 // is in above: it can run the RewriteRule and serve the static
@@ -2042,9 +6873,8 @@
2042 6873 // pinned at 0% on a working Apache cache.)
2043 6874 if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) {
2044 6875 return false;
2045 6876 }
2046 - $opts = Settings_Manager::get( 'cache' );
2047 6877 return empty( $opts['mobile_separate'] );
2048 6878 }
2049 6879
2050 6880 /**
@@ -2095,8 +6925,45 @@
2095 6925 $inconclusive = (bool) ( $probe['inconclusive'] ?? false );
2096 6926 $reason = (string) ( $probe['reason'] ?? '' );
2097 6927 $block_reason = self::static_rewrite_block_reason();
2098 6928
6929 + // Same observed-refusal check Health makes. This is the shared path for
6930 + // `wp xspeed cache recheck-rewrite` and POST /cache/recheck-rewrite —
6931 + // and, because a CLI command is automatically an MCP tool, for the
6932 + // AI-facing surface too. Leaving it out would have fixed the dashboard
6933 + // while the CLI kept answering that the fast path was active. (#372)
6934 + if ( '' === $block_reason ) {
6935 + $skip = self::last_static_skip();
6936 + if ( ! empty( $skip['reason'] ) ) {
6937 + $block_reason = 'skipped_' . (string) $skip['reason'];
6938 + }
6939 + }
6940 +
6941 + // With page caching off there is nothing to serve, so `active` can
6942 + // never be true here whatever the raw probe says. probe_static_rewrite()
6943 + // writes its OWN file under the static tree and fetches that, which
6944 + // succeeds whenever the server can serve a static file at all — and on
6945 + // nginx the snippet is server-level, so it keeps succeeding after the
6946 + // cache is switched off.
6947 + //
6948 + // block_reason() used to carry this meaning by accident: it returned
6949 + // 'mobile_separate' with caching off, and the refusal branch below
6950 + // forced active=false. Now that it correctly reports '' (nothing can
6951 + // block a fast path that isn't in use), this consumer has to state the
6952 + // condition itself — otherwise `wp xspeed cache recheck-rewrite` and
6953 + // POST /cache/recheck-rewrite claim "the web server is serving cache
6954 + // hits directly" on a site with no cache. That is a positive false
6955 + // claim rather than a nag, i.e. worse than the bug being fixed.
6956 + $cache_opts = Settings::get();
6957 + if ( empty( $cache_opts['cache_enabled'] ) ) {
6958 + return array(
6959 + 'active' => false,
6960 + 'inconclusive' => false,
6961 + 'reason' => 'Page caching is off, so there is no cache for the web server to serve.',
6962 + 'block_reason' => '',
6963 + );
6964 + }
6965 +
2099 6966 // A known refusal outranks the probe, and also outranks
2100 6967 // "inconclusive" — a blocked rewrite whose probe merely failed to
2101 6968 // complete is still definitely blocked.
2102 6969 if ( '' !== $block_reason ) {
@@ -2125,8 +6992,12 @@
2125 6992 case 'mobile_separate':
2126 6993 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.';
2127 6994 case 'no_mod_headers':
2128 6995 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.";
6996 + case 'litespeed_dropin':
6997 + 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.';
6998 + case 'skipped_nonce':
6999 + 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.';
2129 7000 default:
2130 7001 return sprintf( 'The static rewrite is disabled (%s).', $code );
2131 7002 }
2132 7003 }
@@ -2131,16 +7002,43 @@
2131 7002 }
2132 7003 }
2133 7004
2134 7005 public static function static_rewrite_block_reason(): string {
7006 + // Nothing can be blocking the fast path when there is no cache to
7007 + // serve from it. Without this the dashboard told users with page
7008 + // caching switched OFF that Separate Mobile Cache "is disabling
7009 + // faster static serving" — a fast path they were not using, about a
7010 + // cache that did not exist. Every caller of this is a user-facing
7011 + // explanation of why the rewrite is off, so "the cache is off" is
7012 + // the honest answer, and it is silence. (#108)
7013 + $opts = Settings::get();
7014 + if ( empty( $opts['cache_enabled'] ) ) {
7015 + return '';
7016 + }
2135 7017 if ( Server::LITESPEED === Server::type() ) {
2136 - return ''; // Intended on LiteSpeed — not a "block".
7018 + // The opt-in is read RAW (stored_cache_opts), not through
7019 + // Settings_Manager::get(): the schema's bool coercion is a PHP
7020 + // cast, and (bool) "false" is true — so a junk string stored on
7021 + // another server (where the key bypasses the schema) would come
7022 + // back from the coercion layer as an ENABLE. Raw + the strict
7023 + // parse below is the same read static_rewrite_allowed() makes,
7024 + // so the two can't disagree either. (QA on #513)
7025 + $stored = self::stored_cache_opts();
7026 + // The intended default — but no longer silent: with the opt-in
7027 + // off, Health must be able to explain the PHP path and point at
7028 + // the toggle instead of falling through to "reinstall the block"
7029 + // advice that cannot work here. (#509)
7030 + if ( ! self::litespeed_optin_enabled( $stored['litespeed_static_rewrite'] ?? false ) ) {
7031 + return 'litespeed_dropin';
7032 + }
7033 + $cache_opts = Settings_Manager::get( 'cache' );
7034 + return ! empty( $cache_opts['mobile_separate'] ) ? 'mobile_separate' : '';
2137 7035 }
2138 7036 if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) {
2139 7037 return 'no_mod_headers';
2140 7038 }
2141 - $opts = Settings_Manager::get( 'cache' );
2142 - return ! empty( $opts['mobile_separate'] ) ? 'mobile_separate' : '';
7039 + $cache_opts = Settings_Manager::get( 'cache' );
7040 + return ! empty( $cache_opts['mobile_separate'] ) ? 'mobile_separate' : '';
2143 7041 }
2144 7042
2145 7043 /**
2146 7044 * Whether migration flagged Separate Mobile Cache for user review. Set by
@@ -2150,10 +7048,21 @@
2150 7048 * this flag so the dashboard can invite the user to turn it back on only
2151 7049 * if their site genuinely serves different HTML per device. (FBS-83145)
2152 7050 */
2153 7051 public static function mobile_separate_needs_review(): bool {
2154 - $opts = Settings_Manager::get( 'cache' );
2155 - return ! empty( $opts['mobile_separate_review'] );
7052 + // Same reasoning as static_rewrite_block_reason(): the invitation is
7053 + // "turn this back on if your site needs it, to regain the fast path",
7054 + // which is meaningless with page caching off — there is no fast path
7055 + // to regain, and the equality probe behind the prompt would fetch
7056 + // pages that aren't being cached. Gated here rather than at the two
7057 + // payload call sites (Admin + Rest_Api) so `enabled`, `blocking` and
7058 + // `needs_review` are consistently gated on the same condition. (#108)
7059 + $opts = Settings::get();
7060 + if ( empty( $opts['cache_enabled'] ) ) {
7061 + return false;
7062 + }
7063 + $cache_opts = Settings_Manager::get( 'cache' );
7064 + return ! empty( $cache_opts['mobile_separate_review'] );
2156 7065 }
2157 7066
2158 7067 /**
2159 7068 * Clear the review flag — called when the user has acted on the prompt
@@ -2216,9 +7125,11 @@
2216 7125 'redirection' => 2,
2217 7126 // Bust any per-device cache so we compare freshly-rendered
2218 7127 // HTML, and pass the device UA the site would branch on.
2219 7128 'user-agent' => $ua,
2220 - 'headers' => array( 'Cache-Control' => 'no-cache' ),
7129 + // A real device UA by design, so only the header marks
7130 + // this as ours to analytics and the hit ratio.
7131 + 'headers' => Self_Traffic::headers( array( 'Cache-Control' => 'no-cache' ) ),
2221 7132 )
2222 7133 );
2223 7134 if ( is_wp_error( $resp ) || 200 !== (int) wp_remote_retrieve_response_code( $resp ) ) {
2224 7135 return null;
@@ -2254,18 +7165,111 @@
2254 7165 * handful of well-known noise sources and collapses whitespace, so a site
2255 7166 * that truly serves different markup per device still compares as different.
2256 7167 */
2257 7168 private static function normalize_html_for_diff( string $html ): string {
7169 + // Every rule here errs toward "they differ" being WRONG rather than
7170 + // "they match" being wrong: this check only ever tells a user it is
7171 + // SAFE to turn Separate Mobile Cache off, so a false "identical"
7172 + // would cost them device-specific output. The risk of being too
7173 + // conservative is milder but real — the useful answer never appears,
7174 + // and the feature's whole pitch ("we'll prove it's safe to turn
7175 + // off") silently never pays out. These close the gaps that made a
7176 + // mismatch effectively guaranteed on an ordinary WordPress site. (#108)
2258 7177 $patterns = array(
2259 - // WP nonces (data-nonce="...", _wpnonce=..., "nonce":"...").
2260 - '/(_wpnonce|nonce|_ajax_nonce)["\']?\s*[:=]\s*["\']?[a-f0-9]{10}/i',
2261 - // Generic 10+ hex tokens (CSRF, cache-buster hashes, session ids).
2262 - '/\b[a-f0-9]{16,}\b/i',
7178 + // WP nonces in attribute or JSON form: data-nonce="…",
7179 + // _wpnonce=…, "nonce":"…". The `[:=]` adjacency below misses
7180 + // wp_nonce_field()'s own markup — `name="_wpnonce" value="ab…"`
7181 + // puts `value=` between the key and the token — which is the
7182 + // single most common nonce shape in WordPress, so that form is
7183 + // matched explicitly first.
7184 + '/name=["\']?(_wpnonce|_ajax_nonce)["\']?\s+value=["\']?[a-z0-9]{8,}/i',
7185 + // CSP nonces on script/style tags. Base64, so uppercase and
7186 + // +/= appear — the hex-only rules below can never match one,
7187 + // and a CSP-enabled site therefore differed on every fetch.
7188 + // MUST precede the generic nonce rule: that one stops at the
7189 + // first non-alphanumeric, leaving the rest of the token behind
7190 + // and the two responses still unequal.
7191 + // The quotes are optional so HTML5's legal unquoted attribute
7192 + // form (`<script nonce=AbCd+q/r=>`) is covered too — without
7193 + // that it fell through to the generic rule, which is the exact
7194 + // failure this rule exists to remove.
7195 + '/\bnonce=(["\'])?[A-Za-z0-9+\/=_-]{8,}(?(1)\1)/',
7196 + '/(_wpnonce|nonce|_ajax_nonce)["\']?\s*[:=]\s*["\']?[a-z0-9]{8,}/i',
7197 + // Generic hex tokens: cache busters, session ids, md5/sha
7198 + // digests. Was 16+, which left an 11-15 char gap above the
7199 + // 10-char nonce rule.
7200 + //
7201 + // The token MUST contain at least one a-f letter. `[a-f0-9]`
7202 + // also matches every decimal digit, so a bare `{10,}` erased
7203 + // every 10+ digit INTEGER anywhere in the document — including
7204 + // visible body text. A page whose desktop and mobile HTML
7205 + // differed only by a per-device numeric id (an AdSense slot, an
7206 + // A/B bucket, an analytics property) then compared as identical,
7207 + // and the check told the user it was safe to switch off the very
7208 + // setting keeping that output correct — the one direction this
7209 + // function must never fail in. Decimal-only runs are left to the
7210 + // bounded epoch rule below, which is deliberately narrower.
7211 + //
7212 + // Known, accepted (QA R2): a token whose letters all fall in a-f
7213 + // reads as a digest, so a per-device `ABC1234567890` strips even
7214 + // though it is an id, not a hash. Deliberately left open — the
7215 + // alternatives all cost more than the bug:
7216 + //
7217 + // Token shape (lowercase-only, case-uniformity, a trailing
7218 + // letter) cannot separate it. `ABC1234567890` and
7219 + // `ABCDEF012345` — an uppercase digest this rule SHOULD strip —
7220 + // are both all-hex, uniformly cased, letters-then-digits.
7221 + // Each variant fixed the id only by sparing the digest.
7222 + //
7223 + // Letter density does separate them (23% letters vs 50%), but
7224 + // measured over 2000 md5/sha1/sha256 samples, requiring letters
7225 + // spread through the token leaves 21-67% of REAL digests
7226 + // unmatched depending on the window. Digest noise is most of
7227 + // what this function exists to remove, so that trade guts it.
7228 + //
7229 + // Context (protecting data-* attribute values from this rule)
7230 + // works for ids and still strips digests in URLs, classes and
7231 + // query strings — but regresses a CHANGING digest inside a
7232 + // non-nonce data-* attribute, and needs a two-pass
7233 + // hold/restore. Viable if R2 is ever worth pressing; its
7234 + // failure at least errs toward "differ".
7235 + //
7236 + // An A-F-only prefix on a per-device id is rare, and the earlier
7237 + // nonce rules already claim the data-nonce/_wpnonce shapes.
7238 + '/\b(?=[a-f0-9]{10,}\b)[0-9]*[a-f][a-f0-9]*\b/i',
2263 7239 // wp-generated unique ids (e.g. wp-block ids, aria ids).
2264 7240 '/(id|for|aria-[a-z]+)="[^"]*-[0-9]{3,}"/i',
2265 7241 // ISO-ish timestamps + epoch-looking numbers in query strings.
2266 7242 '/\?ver=[0-9.]+/',
2267 7243 '/[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.+Z-]+/',
7244 + // Our own signature's generation stamp. The two fetches are
7245 + // sequential and each writes its own entry, so this differs on
7246 + // essentially every comparison — and it is space-separated, so
7247 + // the ISO rule above (which requires a literal `T`) never
7248 + // touches it. Without this the probe reports "differ" for every
7249 + // site and the "safe to turn Separate Mobile Cache off" verdict
7250 + // can never appear.
7251 + '/generated [0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2} UTC/',
7252 + // Raw epoch seconds. The two fetches are sequential, so any
7253 + // template printing time() guaranteed a mismatch.
7254 + //
7255 + // This is the ONLY rule that may strip a decimal-only run, so
7256 + // its bound is load-bearing rather than decorative — every digit
7257 + // it gives away is a class of per-device id it silently erases.
7258 + // `1[0-9]{9}` was too loose: it claimed the whole
7259 + // 1000000000-1999999999 range (2001-2033) to cover timestamps
7260 + // nobody serves, and took every 10-digit AdSense slot, order id
7261 + // and SKU beginning with 1 along with it — reproducing the exact
7262 + // false-"identical" verdict the hex rule above was tightened to
7263 + // stop. `1[6-9]` covers 2020-2033, which is the only span a live
7264 + // site can actually print, and collides with roughly a tenth as
7265 + // many ids.
7266 + //
7267 + // Not airtight — an id beginning 16-19 still collides. Closing
7268 + // that properly means scoping this to places a timestamp really
7269 + // appears (an attribute value, a query parameter, a JSON value)
7270 + // rather than bare body text; the bound is the cheap 90% of it.
7271 + '/\b1[6-9][0-9]{8}\b/',
2268 7272 );
2269 7273 $html = (string) preg_replace( $patterns, 'X', $html );
2270 7274 // Collapse all whitespace so trivial formatting differences don't count.
2271 7275 return trim( (string) preg_replace( '/\s+/', ' ', $html ) );
@@ -2394,11 +7398,16 @@
2394 7398 // while a page was cold — on a warm page nginx served the shared
2395 7399 // anonymous copy to carts, members and bypassed bots alike. The
2396 7400 // three historical names survive as a floor inside cookie_rule().
2397 7401 // `~*` is case-insensitive, matching PHP's stripos()/glob checks.
2398 - $cache_opts = Settings_Manager::get( 'cache' );
7402 + // Stored read — reached from boot(); see stored_cache_opts(). The
7403 + // fallbacks below mirror the schema's own defaults, which a raw read
7404 + // does not apply.
7405 + $cache_opts = self::stored_cache_opts();
2399 7406 $cookie_rule = Server_Rules::cookie_rule(
2400 - is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array()
7407 + is_array( $cache_opts['excluded_cookies'] ?? null )
7408 + ? $cache_opts['excluded_cookies']
7409 + : \XSpeed\Modules\Cache\CacheModule::DEFAULT_EXCLUDED_COOKIES
2401 7410 );
2402 7411 $lines[] = 'if ($http_cookie ~* "(' . $cookie_rule['regex'] . ')") { set $xspeed_no_cache "$xspeed_no_cache-cookie"; }';
2403 7412
2404 7413 $ua_rule = Server_Rules::user_agent_rule(
@@ -2409,8 +7418,27 @@
2409 7418 // fast path entirely.
2410 7419 if ( '' !== $ua_rule['regex'] ) {
2411 7420 $lines[] = 'if ($http_user_agent ~* "(' . $ua_rule['regex'] . ')") { set $xspeed_no_cache "$xspeed_no_cache-ua"; }';
2412 7421 }
7422 +
7423 + // URL exclusions. Without this an excluded URL was only excluded
7424 + // while its page was cold: PHP won't write a static file for one, so
7425 + // there is usually nothing to serve — but a page cached BEFORE the
7426 + // rule was added still has its file on disk, and nginx serves it
7427 + // without ever asking PHP. The exclusion then does nothing until the
7428 + // next purge. (#169)
7429 + //
7430 + // Matched against $uri, not $request_uri: $uri is the decoded path
7431 + // without the query string, which is what Cache::should_cache()
7432 + // tests. Using $request_uri would make `/cart` fail to match
7433 + // `/cart?x=1` inconsistently with PHP. Same empty-regex guard as the
7434 + // UA rule above — an empty alternation matches everything.
7435 + $url_rule = Server_Rules::url_rule(
7436 + is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array()
7437 + );
7438 + if ( '' !== $url_rule['regex'] ) {
7439 + $lines[] = 'if ($uri ~* "(' . $url_rule['regex'] . ')") { set $xspeed_no_cache "$xspeed_no_cache-url"; }';
7440 + }
2413 7441 $lines[] = 'if (!-f "$document_root' . $rel . '/$xspeed_host$uri/index.html") { set $xspeed_no_cache "$xspeed_no_cache-nofile"; }';
2414 7442 // Neither `add_header` nor `access_log` is allowed inside an `if{}`
2415 7443 // at server level (nginx rejects with "directive is not allowed
2416 7444 // here"). The logging therefore lives in a `location` block that
@@ -2440,8 +7468,24 @@
2440 7468 // missing. So: hits are logged, and a user deleting the log can't take
2441 7469 // nginx down.
2442 7470 $lines[] = ' access_log ' . $hits_abs . ' combined buffer=16k flush=5s;';
2443 7471 $lines[] = ' add_header X-XSpeed-Cache "HIT (nginx)" always;';
7472 + // Edge/CDN headers from the same seam the drop-in bakes. nginx serves
7473 + // this path without ever starting PHP, so the answer cannot be
7474 + // resolved per request — the pairs are resolved HERE, when the
7475 + // snippet is generated, and a change of answer needs the snippet
7476 + // regenerated and re-pasted to take effect.
7477 + //
7478 + // Skipped entirely when the static path is switched off. The only
7479 + // reason that can fire under `bake` is mobile-split, and mobile-split
7480 + // is also what switches the static path off — so the block would be
7481 + // baked with a hold it can never serve, and would start serving it
7482 + // the moment the setting is turned off and static files reappear,
7483 + // until somebody regenerates and re-pastes. A rule that can only be
7484 + // served once its premise is false is guaranteed to be stale.
7485 + foreach ( self::static_rewrite_allowed() ? self::edge_headers_for( 'HIT', 'bake' ) : array() as $name => $value ) {
7486 + $lines[] = ' add_header ' . $name . ' "' . self::quote_directive_value( $value ) . '" always;';
7487 + }
2444 7488 $lines[] = '}';
2445 7489 return implode( "\n", $lines );
2446 7490 }
2447 7491
@@ -2559,34 +7603,15 @@
2559 7603 if ( empty( $opts['cache_enabled'] ) ) {
2560 7604 return false;
2561 7605 }
2562 7606
2563 - $restored = false;
7607 + $state = self::toggle( true, false );
7608 + // A refusal reports whether the cache SERVES, which on this path can
7609 + // be true for reasons that have nothing to do with this call — so a
7610 + // refusal would otherwise log "drop-in restored" for a restore that
7611 + // was declined. Restored means the transaction went through.
7612 + $restored = empty( $state['blocked'] ) && ! empty( $state['enabled'] );
2564 7613
2565 - // Only (re)install when the drop-in is missing, foreign, or an
2566 - // older version of ours — never rewrite a current, healthy file.
2567 - $target = WP_CONTENT_DIR . '/advanced-cache.php';
2568 - $needs = true;
2569 - if ( file_exists( $target ) ) {
2570 - $contents = @file_get_contents( $target ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Best-effort read; a failure just means we reinstall.
2571 - if ( is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' ) ) {
2572 - $source = @file_get_contents( XSPEED_DIR . 'includes/advanced-cache.php' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Same.
2573 - $needs = self::dropin_version( $contents ) < self::dropin_version( is_string( $source ) ? $source : '' );
2574 - }
2575 - }
2576 - if ( $needs && self::install_dropin() ) {
2577 - $restored = true;
2578 - }
2579 -
2580 - // WP_CACHE lives in wp-config.php, which the upgrade doesn't touch —
2581 - // but a foreign cache plugin or a hand-edit can drop it, and without
2582 - // it core never loads the drop-in at all.
2583 - if ( ! defined( 'WP_CACHE' ) || ! WP_CACHE ) {
2584 - if ( self::set_wp_cache_constant( true ) ) {
2585 - $restored = true;
2586 - }
2587 - }
2588 -
2589 7614 if ( $restored ) {
2590 7615 Activity_Log::record(
2591 7616 'cache_dropin_restored',
2592 7617 'Cache drop-in restored after a plugin update — caching was already enabled.',
@@ -2620,32 +7645,16 @@
2620 7645 if ( empty( $opts['cache_enabled'] ) ) {
2621 7646 return;
2622 7647 }
2623 7648
2624 - $dropin_target = WP_CONTENT_DIR . '/advanced-cache.php';
2625 - $dropin_ours = false;
2626 - $dropin_stale = false;
2627 - if ( file_exists( $dropin_target ) ) {
2628 - $contents = @file_get_contents( $dropin_target ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
2629 - $dropin_ours = is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' );
2630 - // Reinstall when OUR drop-in is an older version than the source —
2631 - // the marker alone can't distinguish an old copy from a new one, so
2632 - // a serve-logic change (e.g. the .meta read for 404s/feeds) would
2633 - // otherwise never reach existing cache-enabled sites until a manual
2634 - // cache toggle. (FBS-82406/82407)
2635 - if ( $dropin_ours ) {
2636 - $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
2637 - }
7649 + $state = self::toggle( true, false );
7650 + // A refusal means something else now owns the page-cache field, or
7651 + // the write could not be verified. Either way this is not the moment
7652 + // to go on maintaining our rewrite block and log file.
7653 + if ( ! empty( $state['blocked'] ) || empty( $state['enabled'] ) ) {
7654 + return;
2638 7655 }
2639 7656
2640 - if ( ! $dropin_ours || $dropin_stale ) {
2641 - self::install_dropin();
2642 - }
2643 -
2644 - if ( ! defined( 'WP_CACHE' ) || ! WP_CACHE ) {
2645 - self::set_wp_cache_constant( true );
2646 - }
2647 -
2648 7657 // Rewrite block goes last. It's what turns the static-cache
2649 7658 // tree into a PHP-bypass — every cache hit served by the web
2650 7659 // server directly. Without it we still cache, just at drop-in
2651 7660 // speed (~85ms TTFB) instead of static-file speed (~25-40ms).
@@ -2769,9 +7778,9 @@
2769 7778 // so the closing quote here cannot be escaped away.
2770 7779 $lines[] = ' RewriteCond %{HTTP_USER_AGENT} "!(' . $ua_rule['regex'] . ')" [NC]';
2771 7780 }
2772 7781
2773 - return array_merge(
7782 + $block = array_merge(
2774 7783 $lines,
2775 7784 array(
2776 7785 // Capture REQUEST_URI without its trailing slash into %1.
2777 7786 // store_static() writes `{host}{uri-without-trailing-slash}/index.html`,
@@ -2789,9 +7798,9 @@
2789 7798 // `^` matches the empty string AND any non-empty path, so it
2790 7799 // covers `/` and `/blog` alike. (Confirmed on OpenLiteSpeed
2791 7800 // 1.8: `.` → homepage served by PHP drop-in; `^` → served
2792 7801 // directly from the static file.)
2793 - ' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [L]',
7802 + ' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [E=XSPEED_STATIC_HIT:1,L]',
2794 7803 '</IfModule>',
2795 7804 // Mark the statically-served response as a cache HIT.
2796 7805 //
2797 7806 // A file served by the rewrite above bypasses PHP entirely, so
@@ -2813,11 +7822,32 @@
2813 7822 '<IfModule mod_headers.c>',
2814 7823 ' <FilesMatch "\\.html$">',
2815 7824 ' Header always set X-XSpeed-Cache "HIT (static)"',
2816 7825 ' </FilesMatch>',
2817 - '</IfModule>',
2818 7826 )
2819 7827 );
7828 +
7829 + // Edge/CDN headers from the same seam the drop-in bakes. Like the
7830 + // nginx snippet, the static rewrite answers without PHP, so the pairs
7831 + // are resolved when the block is GENERATED rather than per request.
7832 + //
7833 + // `env=` rather than the `<FilesMatch>` scoping above, because these
7834 + // must ride only on responses the rewrite produced. The marker header
7835 + // stays filename-scoped: it is inert, and narrowing it would change a
7836 + // header QA reads.
7837 + // Same reasoning as the nginx snippet: a bake hold can only come from
7838 + // mobile-split, and mobile-split is what turns this path off.
7839 + $edge_lines = array();
7840 + foreach ( self::static_rewrite_allowed() ? self::edge_headers_for( 'HIT', 'bake' ) : array() as $edge_name => $edge_value ) {
7841 + $edge_lines = array_merge(
7842 + $edge_lines,
7843 + self::static_hit_directives(
7844 + ' Header always set ' . $edge_name . ' "' . self::quote_directive_value( $edge_value ) . '"'
7845 + )
7846 + );
7847 + }
7848 +
7849 + return array_merge( $block, $edge_lines, array( '</IfModule>' ) );
2820 7850 }
2821 7851
2822 7852 /**
2823 7853 * Active probe that confirms the web-server static-rewrite path is
@@ -2872,9 +7902,12 @@
2872 7902
2873 7903 $home = home_url( '/' );
2874 7904 $host = (string) wp_parse_url( $home, PHP_URL_HOST );
2875 7905 if ( '' === $host ) {
2876 - $result = array( 'active' => false, 'reason' => 'home_url has no host' );
7906 + // Environmental failure, not evidence the server config is wrong —
7907 + // mark it inconclusive so Health surfaces say "could not verify"
7908 + // instead of demanding a snippet paste. (#480)
7909 + $result = array( 'active' => false, 'inconclusive' => true, 'reason' => 'home_url has no host' );
2877 7910 set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
2878 7911 return $result;
2879 7912 }
2880 7913
@@ -2891,9 +7924,12 @@
2891 7924 if ( ! file_exists( $probe_dir ) ) {
2892 7925 wp_mkdir_p( $probe_dir );
2893 7926 }
2894 7927 if ( ! is_dir( $probe_dir ) ) {
2895 - $result = array( 'active' => false, 'reason' => 'cannot create probe dir' );
7928 + // A cache-dir permissions problem — the probe never ran, so this
7929 + // says nothing about the nginx config. Inconclusive, not
7930 + // "required". (#480)
7931 + $result = array( 'active' => false, 'inconclusive' => true, 'reason' => 'cannot create probe dir' );
2896 7932 set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
2897 7933 return $result;
2898 7934 }
2899 7935 // 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.
@@ -2913,9 +7949,9 @@
2913 7949 // don't repeat the wait every minute.
2914 7950 'timeout' => 3,
2915 7951 'sslverify' => ! $is_local,
2916 7952 'redirection' => 0,
2917 - 'headers' => array( 'Cache-Control' => 'no-cache' ),
7953 + 'headers' => Self_Traffic::headers( array( 'Cache-Control' => 'no-cache' ) ),
2918 7954 )
2919 7955 );
2920 7956
2921 7957 // Best-effort cleanup so we don't accumulate probe dirs even
@@ -3049,8 +8085,20 @@
3049 8085 $cleaned = self::strip_marker_block( $existing, 'xSpeed Static Cache' );
3050 8086 $block = self::marker_block( 'xSpeed Static Cache', self::rewrite_block_lines() );
3051 8087 $next = $block . ( '' === $cleaned ? '' : "\n" . $cleaned );
3052 8088
8089 + /*
8090 + * Nothing to change. auto_heal() runs the whole enable transaction on
8091 + * every admin_init and this is called unconditionally from it, so
8092 + * without this every wp-admin request truncated and rewrote .htaccess
8093 + * with byte-identical content. Apache reads that file without a lock,
8094 + * so the truncate window is a real 500 on a busy admin, and the churn
8095 + * trips host file-integrity monitors.
8096 + */
8097 + if ( $next === $existing ) {
8098 + return true;
8099 + }
8100 +
3053 8101 // 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.
3054 8102 return false !== file_put_contents( $htaccess, $next, LOCK_EX );
3055 8103 }
3056 8104
@@ -3100,9 +8148,21 @@
3100 8148 * follows it. Idempotent — returns the input unchanged if the
3101 8149 * marker isn't present.
3102 8150 */
3103 8151 private static function strip_marker_block( string $contents, string $marker ): string {
3104 - $pattern = '/# BEGIN ' . preg_quote( $marker, '/' ) . '\b.*?# END ' . preg_quote( $marker, '/' ) . "\b[^\n]*\n?\n?/s";
8152 + /*
8153 + * The body may not contain another BEGIN for this marker.
8154 + *
8155 + * `.*?` is non-greedy but still spans anything, so an ORPHANED
8156 + * `# BEGIN xSpeed Static Cache` — an END line lost to a hand edit or
8157 + * a partial write — paired with the END of the NEXT block and deleted
8158 + * everything between them. On a site where the orphan sits above
8159 + * `# BEGIN WordPress`, that takes WordPress's own rewrite rules with
8160 + * it and every permalink 404s. Refusing to cross a second BEGIN makes
8161 + * the orphan a no-op instead of a site-wide outage.
8162 + */
8163 + $begin = '# BEGIN ' . preg_quote( $marker, '/' ) . '\b';
8164 + $pattern = '/' . $begin . '(?:(?!' . $begin . ').)*?# END ' . preg_quote( $marker, '/' ) . "\b[^\n]*\n?\n?/s";
3105 8165 $out = preg_replace( $pattern, '', $contents );
3106 8166 return is_string( $out ) ? $out : $contents;
3107 8167 }
3108 8168
@@ -3126,8 +8186,417 @@
3126 8186 }
3127 8187 return 0;
3128 8188 }
3129 8189
8190 + /** The advanced-cache.php drop-in is ours. */
8191 + public const DROPIN_XSPEED = 'xspeed';
8192 + /** Someone else's drop-in is installed. */
8193 + public const DROPIN_FOREIGN = 'foreign';
8194 + /** No drop-in installed. */
8195 + public const DROPIN_NONE = 'none';
8196 + /** A drop-in is installed and we could not read it. */
8197 + public const DROPIN_UNREADABLE = 'unreadable';
8198 + /**
8199 + * Present but holding nothing -- empty, or whitespace only. WP Rocket
8200 + * truncates advanced-cache.php to 0 bytes on deactivate, and calling that
8201 + * FOREIGN made it a permanent blocker with no owner to ask. (#391)
8202 + */
8203 + public const DROPIN_ABANDONED = 'abandoned';
8204 +
8205 + /**
8206 + * Who owns wp-content/advanced-cache.php right now.
8207 + *
8208 + * WordPress gives every caching plugin the same single file to live in,
8209 + * so "is there a drop-in" and "is it ours" are completely different
8210 + * questions, and only the second one licenses a write. An unreadable
8211 + * drop-in is deliberately its own answer rather than folding into
8212 + * "foreign": we cannot even name what we would be destroying.
8213 + *
8214 + * @return string One of the DROPIN_* constants.
8215 + */
8216 + public static function dropin_owner(): string {
8217 + require_once XSPEED_DIR . 'includes/wp-cache-constant.php';
8218 + $target = WP_CONTENT_DIR . '/advanced-cache.php';
8219 + if ( ! file_exists( $target ) ) {
8220 + return self::DROPIN_NONE;
8221 + }
8222 +
8223 + $contents = self::read_file( $target );
8224 + if ( null === $contents ) {
8225 + return self::DROPIN_UNREADABLE;
8226 + }
8227 +
8228 + if ( xspeed_has_canonical_dropin_signature( $contents ) ) {
8229 + return self::DROPIN_XSPEED;
8230 + }
8231 +
8232 + // Nothing in the file means nothing owns it. Kept distinct from
8233 + // FOREIGN so the acquisition gate can tell "someone else's cache" from
8234 + // "a husk the last plugin left behind". (#391)
8235 + if ( '' === trim( $contents ) ) {
8236 + return self::DROPIN_ABANDONED;
8237 + }
8238 +
8239 + /*
8240 + * The other half of the same question, and it cannot be answered from
8241 + * the bytes: a file we cannot attribute is a COMPETITOR only while
8242 + * some page cache is actually running. With every candidate switched
8243 + * off it is abandoned -- a hosting company's own cache, a hand-rolled
8244 + * one, or a plugin that was deleted without cleaning up.
8245 + *
8246 + * Asking the detector rather than re-deriving it here is the point:
8247 + * these two answers disagreeing is a split brain with a bad ending --
8248 + * acquisition_blocker() opens the gate, install_dropin() then refuses
8249 + * on FOREIGN, and toggle() blames the filesystem for a write it never
8250 + * attempted. One question, one answer. (#391, #393)
8251 + */
8252 + if ( class_exists( __NAMESPACE__ . '\\Page_Cache_Detector' ) ) {
8253 + $owner = (string) ( Page_Cache_Detector::inspect()['dropin']['owner'] ?? '' );
8254 +
8255 + // Attributable to a named plugin -> somebody's cache, whatever its
8256 + // activation state. Only a file NOBODY can be shown to own, with
8257 + // nothing running, is abandoned.
8258 + if ( Page_Cache_Detector::OWNER_UNKNOWN === $owner
8259 + && ! Page_Cache_Detector::another_page_cache_is_active() ) {
8260 + return self::DROPIN_ABANDONED;
8261 + }
8262 + }
8263 +
8264 + return self::DROPIN_FOREIGN;
8265 + }
8266 +
8267 + /**
8268 + * Why xSpeed must not install its page-cache artifacts right now, or null
8269 + * when it may.
8270 + *
8271 + * This is the single gate in front of every write that touches shared
8272 + * state — the drop-in and the WP_CACHE define. Both are single-occupancy:
8273 + * whatever is there belongs to exactly one plugin, and taking it silently
8274 + * breaks that plugin's caching with no way back.
8275 + *
8276 + * Returns a user-facing string, so a REST caller can hand it straight to
8277 + * the dashboard instead of reporting a bare failure.
8278 + */
8279 + public static function acquisition_blocker(): ?string {
8280 + Page_Cache_Detector::invalidate();
8281 + $verdict = Page_Cache_Detector::classify();
8282 + $owner = self::dropin_owner();
8283 + // The reason we refuse, whether that reason already names a plugin,
8284 + // and every other page cache the detector counted anywhere in the
8285 + // verdict. See the tail of this method for why all three are needed.
8286 + $primary = null;
8287 + $primary_names = false;
8288 + $named = array();
8289 + foreach ( $verdict['blockers'] as $blocker ) {
8290 + $code = (string) ( $blocker['code'] ?? '' );
8291 + // The shared detector quite correctly reports xSpeed itself as a
8292 + // page-cache owner. That is not a competitor to this transaction.
8293 + //
8294 + // Except when the two disagree about the DROP-IN. The detector
8295 + // accepts our marker anywhere in a file's header; this plugin's
8296 + // own check requires it to open the header, because only this
8297 + // side authorizes overwriting and deleting. A foreign drop-in
8298 + // that merely carries our marker further down its header is
8299 + // attributed to us by the detector, and skipping it here dropped
8300 + // the refusal entirely — the write then failed on the stricter
8301 + // check and the user was told to go and fix file permissions.
8302 + // Where they disagree, believe the stricter one.
8303 + if ( self::PLUGIN_FILE === ( $blocker['plugin'] ?? null ) ) {
8304 + $about_dropin = in_array(
8305 + $code,
8306 + array(
8307 + Page_Cache_Detector::BLOCKER_FOREIGN_DROPIN,
8308 + Page_Cache_Detector::BLOCKER_UNKNOWN_DROPIN,
8309 + ),
8310 + true
8311 + );
8312 + if ( ! $about_dropin || self::DROPIN_XSPEED === $owner ) {
8313 + continue;
8314 + }
8315 + }
8316 + if ( Page_Cache_Detector::BLOCKER_WP_CACHE_ORPHANED === $code && self::DROPIN_XSPEED === $owner ) {
8317 + continue;
8318 + }
8319 + /*
8320 + * Another plugin's drop-in is no longer a refusal.
8321 + *
8322 + * It used to be: whoever held advanced-cache.php kept it, and
8323 + * enabling was blocked with "deactivate its page cache first".
8324 + * That left a user who had asked for our cache with no way to get
8325 + * it — on a live site the only exit was deleting a file over SSH,
8326 + * and the message could not even say which of its two causes
8327 + * applied ("is active OR owns advanced-cache.php").
8328 + *
8329 + * Turning the page cache on is the instruction to serve pages
8330 + * from cache, and that is not possible without this file. So we
8331 + * take it, and the dashboard says whose file it is first —
8332 + * dropin_disclosure() names the owner, the user confirms, and
8333 + * install_dropin() writes ours over the top.
8334 + *
8335 + * A still-active competitor is deliberately NOT re-added as a
8336 + * blocker below: it is caught by `active_page_cache`, which the
8337 + * capability rule already downgrades to a note. Two page caches
8338 + * installed at once is the user's call to make, not ours to
8339 + * refuse — they just told us which one they want serving.
8340 + *
8341 + * UNREADABLE is the exception and stays a refusal: we cannot name
8342 + * what we would destroy, and install_dropin() refuses it too, so
8343 + * opening the gate here would only produce a failed write.
8344 + */
8345 + $about_dropin_owner = in_array(
8346 + $code,
8347 + array(
8348 + Page_Cache_Detector::BLOCKER_FOREIGN_DROPIN,
8349 + Page_Cache_Detector::BLOCKER_UNKNOWN_DROPIN,
8350 + ),
8351 + true
8352 + );
8353 + if ( $about_dropin_owner && self::DROPIN_UNREADABLE !== $owner ) {
8354 + continue;
8355 + }
8356 + /*
8357 + * Capability is not possession. `active_page_cache` and
8358 + * `multiple_page_caches` both fire on a plugin that merely CAN
8359 + * cache pages — the detector cannot prove a competitor's page
8360 + * cache is off, so it counts it. As a warning that is right. As
8361 + * a gate it refuses a write that takes nothing from anyone.
8362 + *
8363 + * This gate guards exactly two files: advanced-cache.php and the
8364 + * WP_CACHE define that loads it. A plugin that does not hold the
8365 + * drop-in has nothing here for us to overwrite, and one that does
8366 + * is already refused by `foreign_dropin` / `unknown_dropin` a few
8367 + * lines up. So when the field is ours or empty, an active
8368 + * competitor is a note, not a refusal.
8369 + *
8370 + * QA found this on a live OpenLiteSpeed site keeping LiteSpeed
8371 + * Cache for images and CDN with its page cache off, while xSpeed
8372 + * served the pages. One click of the off switch and it could not
8373 + * be turned back on: the only way out was deactivating LiteSpeed
8374 + * entirely, and the message told them to "deactivate its page
8375 + * cache" — which they already had.
8376 + */
8377 + $about_capability = in_array(
8378 + $code,
8379 + array(
8380 + Page_Cache_Detector::BLOCKER_ACTIVE_PAGE_CACHE,
8381 + Page_Cache_Detector::BLOCKER_MULTIPLE_PAGE_CACHES,
8382 + ),
8383 + true
8384 + );
8385 + /*
8386 + * FOREIGN belongs in this list now, and it is the whole point.
8387 + *
8388 + * The rule is still "capability is not possession": these two
8389 + * blockers fire on any plugin that CAN cache pages, which the
8390 + * detector cannot prove is switched off. What changed is that a
8391 + * competitor holding the drop-in no longer stops us either — we
8392 + * take the file, having said whose it is. So there is nothing
8393 + * left for a merely-installed competitor to protect, and keeping
8394 + * the refusal here would put back the dead end by another route:
8395 + * "another page cache is active" on a site where the user has
8396 + * just told us, by name, which cache they want serving.
8397 + *
8398 + * UNREADABLE is deliberately still absent — that one refuses.
8399 + */
8400 + if ( $about_capability
8401 + && in_array( $owner, array( self::DROPIN_XSPEED, self::DROPIN_NONE, self::DROPIN_FOREIGN, self::DROPIN_ABANDONED ), true ) ) {
8402 + continue;
8403 + }
8404 + if ( Page_Cache_Detector::BLOCKER_MULTIPLE_PAGE_CACHES === $code ) {
8405 + $others = self::other_page_cache_names( $blocker );
8406 + if ( array() === $others ) {
8407 + // We were the only owner counted — nothing to refuse —
8408 + // unless the list is missing entirely, which is an older
8409 + // detector copy we still must not talk past.
8410 + if ( null === $primary && array() === (array) ( $blocker['plugins'] ?? array() ) ) {
8411 + $primary = self::ownership_blocker_message( '', '' );
8412 + }
8413 + continue;
8414 + }
8415 + $named = array_values( array_unique( array_merge( $named, $others ) ) );
8416 + if ( null === $primary ) {
8417 + $primary = self::multiple_page_caches_message( $others );
8418 + $primary_names = true;
8419 + }
8420 + continue;
8421 + }
8422 + if ( null === $primary ) {
8423 + $label = (string) ( $blocker['label'] ?? '' );
8424 + $primary = self::ownership_blocker_message( $code, $label );
8425 + $primary_names = '' !== $label;
8426 + }
8427 + }
8428 +
8429 + if ( null === $primary ) {
8430 + return null;
8431 + }
8432 + /*
8433 + * The first blocker decides WHY we refuse; it does not always know
8434 + * WHO. The detector can only attribute a drop-in it recognises, and
8435 + * an unrecognised one produces "its owner cannot be proved" — the
8436 + * sentence a W3 Total Cache site used to get while a later blocker in
8437 + * the same verdict was holding the name "W3 Total Cache".
8438 + *
8439 + * So keep the reason and add the names, rather than swapping one for
8440 + * the other: the plugin the user must deal with is not necessarily
8441 + * the owner of the file we could not identify, and promoting the
8442 + * named blocker would have told them to deactivate a plugin that is
8443 + * not what is in their way.
8444 + */
8445 + if ( $primary_names || array() === $named ) {
8446 + return $primary;
8447 + }
8448 + if ( 1 === count( $named ) ) {
8449 + return sprintf(
8450 + /* translators: 1: the refusal reason, 2: a page-caching plugin's name. */
8451 + __( '%1$s %2$s is also active on this site — deactivate its page cache before enabling xSpeed.', 'xspeed' ),
8452 + $primary,
8453 + $named[0]
8454 + );
8455 + }
8456 + return sprintf(
8457 + /* translators: 1: the refusal reason, 2: comma-separated page-caching plugin names. */
8458 + __( '%1$s These page caches are also active on this site: %2$s. Deactivate them before enabling xSpeed.', 'xspeed' ),
8459 + $primary,
8460 + implode( ', ', $named )
8461 + );
8462 + }
8463 +
8464 + /** How xSpeed's own plugin file appears in the detector's catalog. */
8465 + private const PLUGIN_FILE = 'xspeed/xspeed.php';
8466 +
8467 + /**
8468 + * Name the OTHER page caches behind a `multiple_page_caches` refusal.
8469 + *
8470 + * This blocker has no single owner, so the detector leaves `plugin` and
8471 + * `label` null and hands over the full list instead. Left unhandled it
8472 + * fell through to the anonymous fallback sentence — and it is the blocker
8473 + * an ordinary site hits most: xSpeed counts toward "multiple", so the
8474 + * count reaches two the moment one other page-cache plugin is activated,
8475 + * even one that has not written a drop-in. A site running our cache that
8476 + * activated LiteSpeed could not re-enable it and was told only that "the
8477 + * page-cache field is occupied".
8478 + *
8479 + * Returns an empty list when xSpeed was the only owner counted, or when
8480 + * an older detector copy sent no list at all — the caller distinguishes
8481 + * the two by looking at `plugins`.
8482 + *
8483 + * @param array<string,mixed> $blocker One entry from Detector::classify().
8484 + * @return string[]
8485 + */
8486 + private static function other_page_cache_names( array $blocker ): array {
8487 + $plugins = array_values( (array) ( $blocker['plugins'] ?? array() ) );
8488 + $labels = array_values( (array) ( $blocker['labels'] ?? array() ) );
8489 +
8490 + $others = array();
8491 + foreach ( $plugins as $i => $plugin ) {
8492 + if ( self::PLUGIN_FILE === $plugin ) {
8493 + continue;
8494 + }
8495 + $others[] = isset( $labels[ $i ] ) && '' !== (string) $labels[ $i ]
8496 + ? (string) $labels[ $i ]
8497 + : (string) $plugin;
8498 + }
8499 + return array_values( array_unique( $others ) );
8500 + }
8501 +
8502 + /**
8503 + * The refusal sentence for a `multiple_page_caches` blocker.
8504 + *
8505 + * @param string[] $others Page caches other than xSpeed. Never empty.
8506 + */
8507 + private static function multiple_page_caches_message( array $others ): string {
8508 + if ( 1 === count( $others ) ) {
8509 + return self::ownership_blocker_message( Page_Cache_Detector::BLOCKER_ACTIVE_PAGE_CACHE, $others[0] );
8510 + }
8511 + return sprintf(
8512 + /* translators: %s: comma-separated list of page-caching plugin names. */
8513 + __( 'More than one page cache is active on this site (%s). Turn off the other page caches before enabling xSpeed.', 'xspeed' ),
8514 + implode( ', ', $others )
8515 + );
8516 + }
8517 +
8518 + private static function ownership_blocker_message( string $code, string $label ): string {
8519 + if ( '' !== $label ) {
8520 + return sprintf( __( '%s is active or owns advanced-cache.php. Deactivate its page cache before enabling xSpeed.', 'xspeed' ), $label );
8521 + }
8522 + $messages = array(
8523 + 'wp_cache_orphaned' => __( 'WP_CACHE is true but no page-cache drop-in owner can be proved. xSpeed will not claim it.', 'xspeed' ),
8524 + 'wp_cache_duplicate' => __( 'wp-config.php defines WP_CACHE more than once. Remove the duplicate before enabling the cache.', 'xspeed' ),
8525 + 'wp_cache_dynamic' => __( 'WP_CACHE is set from an expression in wp-config.php. xSpeed will not rewrite it.', 'xspeed' ),
8526 + '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' ),
8527 + 'wp_config_unreadable' => __( 'wp-config.php cannot be read, so xSpeed cannot safely change page-cache ownership.', 'xspeed' ),
8528 + 'unknown_dropin' => __( 'advanced-cache.php is occupied but its owner cannot be proved. xSpeed will not replace it.', 'xspeed' ),
8529 + 'unreadable_dropin' => __( 'advanced-cache.php cannot be read, so xSpeed cannot prove its owner.', 'xspeed' ),
8530 + );
8531 + return $messages[ $code ] ?? __( 'The page-cache field is occupied or cannot be verified. xSpeed will not change it.', 'xspeed' );
8532 + }
8533 +
8534 + /**
8535 + * How WP_CACHE is written in wp-config.php, as opposed to what it
8536 + * evaluates to at runtime.
8537 + *
8538 + * The literal is what matters to a writer: a value behind an expression,
8539 + * or two competing defines, cannot be rewritten by a regex without
8540 + * guessing — and a wrong guess silently disables page caching (ours or
8541 + * someone else's) with no error anywhere.
8542 + *
8543 + * @return string undefined | true | false | duplicate | dynamic | conditional | unreadable
8544 + */
8545 + public static function wp_cache_define_state(): string {
8546 + $path = self::wp_config_path();
8547 + if ( '' === $path ) {
8548 + return 'unreadable';
8549 + }
8550 +
8551 + $config = self::read_file( $path );
8552 + if ( null === $config ) {
8553 + return 'unreadable';
8554 + }
8555 +
8556 + require_once XSPEED_DIR . 'includes/wp-cache-constant.php';
8557 + $parsed = \xspeed_parse_wp_cache_defines( $config );
8558 + return $parsed['state'];
8559 + }
8560 +
8561 + /**
8562 + * Classify the captured right-hand side of a WP_CACHE define.
8563 + *
8564 + * Hosts and older tutorials write the value several ways —
8565 + * `1`, `'1'`, `TRUE` — and all of them are literals a rewrite can safely
8566 + * replace. Only a value we cannot evaluate by looking at it (a variable, a
8567 + * function call, a ternary) counts as dynamic, because that is the case
8568 + * where rewriting means guessing.
8569 + *
8570 + * @return string true | false | dynamic
8571 + */
8572 + private static function classify_wp_cache_literal( string $raw ): string {
8573 + $literal = strtolower( trim( $raw ) );
8574 + $literal = trim( $literal, "'\"" );
8575 +
8576 + if ( in_array( $literal, array( 'true', '1' ), true ) ) {
8577 + return 'true';
8578 + }
8579 + if ( in_array( $literal, array( 'false', '0', '', 'null' ), true ) ) {
8580 + return 'false';
8581 + }
8582 + return 'dynamic';
8583 + }
8584 +
8585 + /**
8586 + * Read a file for an ownership decision. Null on any failure — callers
8587 + * treat null as "unknown", never as "empty", because an empty string
8588 + * would read as "no marker found" and license an overwrite.
8589 + */
8590 + private static function read_file( string $path ): ?string {
8591 + if ( ! is_readable( $path ) ) {
8592 + return null;
8593 + }
8594 + // 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.
8595 + $contents = @file_get_contents( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- A failed read is a valid answer ("unknown"), not an error to surface.
8596 + return is_string( $contents ) ? $contents : null;
8597 + }
8598 +
3130 8599 public static function install_dropin() {
3131 8600 $source = XSPEED_DIR . 'includes/advanced-cache.php';
3132 8601 $target = WP_CONTENT_DIR . '/advanced-cache.php';
3133 8602 if ( ! file_exists( $source ) ) {
@@ -3133,8 +8602,26 @@
3133 8602 if ( ! file_exists( $source ) ) {
3134 8603 return false;
3135 8604 }
3136 8605
8606 + /*
8607 + * A drop-in we cannot READ is the one thing still refused here. Not
8608 + * because of who owns it — we no longer refuse on ownership — but
8609 + * because an unreadable file is usually a permissions problem, and
8610 + * writing over it would fail anyway or destroy something we were
8611 + * never able to look at.
8612 + *
8613 + * Everything else is ours to take. Enabling the page cache IS the
8614 + * user's instruction to serve the cache, and serving it means holding
8615 + * advanced-cache.php; the dashboard says whose file it is replacing
8616 + * before the click (Page_Cache_Detector::dropin_disclosure()), so the
8617 + * takeover is consented rather than silent.
8618 + */
8619 + $owner = self::dropin_owner();
8620 + if ( self::DROPIN_UNREADABLE === $owner ) {
8621 + return false;
8622 + }
8623 +
3137 8624 global $wp_filesystem;
3138 8625 if ( ! function_exists( 'WP_Filesystem' ) ) {
3139 8626 require_once ABSPATH . 'wp-admin/includes/file.php';
3140 8627 }
@@ -3186,36 +8673,65 @@
3186 8673 '@@XSPEED_UA_RE@@',
3187 8674 str_replace( "'", "\\'", $ua_rule['regex'] ),
3188 8675 $source_contents
3189 8676 );
8677 + // Which user agents must not count toward the hit ratio. Built here
8678 + // because `xspeed_self_user_agents` is a filter the drop-in cannot
8679 + // call. A renamed warmer is caught by Self_Traffic::HEADER instead.
8680 + $source_contents = str_replace(
8681 + '@@XSPEED_HIT_EXCLUDE_RE@@',
8682 + str_replace( "'", "\\'", Hit_Counter::excluded_ua_regex() ),
8683 + $source_contents
8684 + );
3190 8685
8686 + /*
8687 + * Ours or absent — the ownership gate at the top of this method ruled
8688 + * out everything else. The old code path that moved a foreign drop-in
8689 + * into uploads/xspeed-backups and wrote ours on top is gone: it
8690 + * disabled the other plugin's page cache the moment an xSpeed install
8691 + * ran, with nothing in its own UI to explain why.
8692 + */
8693 +
8694 + // Bake the configured cache lifetime in. The drop-in runs before
8695 + // WordPress loads, so it cannot read the option — it previously fell
8696 + // back to a hardcoded 86400 for every ordinary page, because
8697 + // write_meta() only emits a `ttl` sidecar when the value DIFFERS from
8698 + // the page default. That made the admin's "1 to 720 hours" control a
8699 + // no-op at the layer that actually answers the request: 12h served
8700 + // stale for up to 2x the configured lifetime, and 168h lost the fast
8701 + // path for 6 of every 7 days (issue #240).
8702 + //
8703 + // This is re-baked on every cache settings save (see CacheModule::boot),
8704 + // exactly like the cookie / user-agent rules above.
8705 + $expiry_hours = isset( $cache_opts['cache_expiry'] ) ? (int) $cache_opts['cache_expiry'] : 24;
8706 + if ( $expiry_hours < 1 || $expiry_hours > 720 ) {
8707 + $expiry_hours = 24;
8708 + }
8709 + $source_contents = str_replace(
8710 + '@@XSPEED_DEFAULT_TTL@@',
8711 + (string) ( $expiry_hours * HOUR_IN_SECONDS ),
8712 + $source_contents
8713 + );
8714 +
8715 + // Bake the site-wide edge answer in. Resolved in a `bake` context, so
8716 + // nothing per-page and nothing a request header vouched for can reach
8717 + // it: a bake runs once, in an admin or CLI request, and answers for
8718 + // every page on the site. A page that disagrees gets a sidecar
8719 + // instead — see per_entry_edge_headers().
8720 + //
8721 + // Re-baked on every cache settings save (see CacheModule::boot),
8722 + // exactly like the cookie, user-agent and lifetime rules above.
8723 + $source_contents = str_replace(
8724 + "'@@XSPEED_EDGE_HEADERS@@'",
8725 + self::edge_headers_literal( self::edge_headers_for( 'HIT', 'bake' ) ),
8726 + $source_contents
8727 + );
8728 +
3191 8729 if ( file_exists( $target ) ) {
3192 8730 $existing = $wp_filesystem->get_contents( $target );
3193 - $is_xspeed = is_string( $existing ) && false !== strpos( $existing, 'XSPEED_DROPIN' );
3194 -
3195 - if ( $is_xspeed ) {
3196 - if ( $existing === $source_contents ) {
3197 - return true;
3198 - }
3199 - return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE );
8731 + if ( is_string( $existing ) && $existing === $source_contents ) {
8732 + return true;
3200 8733 }
3201 -
3202 - // Foreign drop-in (e.g. left over from another cache plugin) — back it up
3203 - // before overwriting so the user can recover if needed. Uploads dir
3204 - // (not wp-content root) keeps the backup out of WordPress's reserved
3205 - // drop-in location.
3206 - $upload = wp_upload_dir( null, false );
3207 - $basedir = isset( $upload['basedir'] ) ? trailingslashit( $upload['basedir'] ) . 'xspeed-backups' : false;
3208 - if ( $basedir ) {
3209 - if ( ! file_exists( $basedir ) ) {
3210 - wp_mkdir_p( $basedir );
3211 - self::write_silence( $basedir );
3212 - }
3213 - $backup = $basedir . '/advanced-cache.foreign-' . gmdate( 'Ymd-His' ) . '.php.bak';
3214 - $wp_filesystem->move( $target, $backup, true );
3215 - } else {
3216 - $wp_filesystem->delete( $target );
3217 - }
3218 8734 }
3219 8735
3220 8736 return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE );
3221 8737 }
@@ -3235,58 +8751,201 @@
3235 8751 return;
3236 8752 }
3237 8753
3238 8754 $contents = $wp_filesystem->get_contents( $target );
3239 - if ( is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' ) ) {
8755 + if ( is_string( $contents ) && xspeed_has_canonical_dropin_signature( $contents ) ) {
3240 8756 wp_delete_file( $target );
3241 8757 }
3242 8758 }
3243 8759
8760 + /**
8761 + * Where wp-config.php actually is.
8762 + *
8763 + * WordPress core supports the file one directory ABOVE ABSPATH, and
8764 + * plenty of installs use that layout. This used to look only in ABSPATH
8765 + * and bail, so on those sites the constant could never be written — while
8766 + * Health, which did fall back to the parent, reported the file writable
8767 + * and told the user to toggle the cache off and on. The advice could
8768 + * never work, and its fallback hint ("another plugin left WP_CACHE false
8769 + * behind") was wrong too: there was no define at all. (#19, QA on #174)
8770 + *
8771 + * Returns '' when no wp-config.php can be found in either location.
8772 + */
8773 + public static function wp_config_path(): string {
8774 + $candidates = array( ABSPATH . 'wp-config.php', dirname( ABSPATH ) . '/wp-config.php' );
8775 + foreach ( $candidates as $path ) {
8776 + if ( file_exists( $path ) ) {
8777 + return $path;
8778 + }
8779 + }
8780 + return '';
8781 + }
8782 +
8783 + /**
8784 + * Can we actually write the constant right now?
8785 + *
8786 + * This is the single oracle for that question — Health asks THIS rather
8787 + * than running its own `wp_is_writable()` test, so the message a user
8788 + * reads can never disagree with what the plugin will do. The two differed
8789 + * in both directions: on the path (above) and on the test itself, since
8790 + * an FTP/SSH WP_Filesystem transport can refuse a file that
8791 + * `wp_is_writable()` reports as writable. (#19, QA on #174)
8792 + */
8793 + public static function can_write_wp_config(): bool {
8794 + $wp_config = self::wp_config_path();
8795 + if ( '' === $wp_config ) {
8796 + return false;
8797 + }
8798 +
8799 + global $wp_filesystem;
8800 + if ( ! function_exists( 'WP_Filesystem' ) ) {
8801 + require_once ABSPATH . 'wp-admin/includes/file.php';
8802 + }
8803 + WP_Filesystem();
8804 + return (bool) ( $wp_filesystem && $wp_filesystem->is_writable( $wp_config ) );
8805 + }
8806 +
3244 8807 public static function set_wp_cache_constant( $enable ) {
3245 - $wp_config = ABSPATH . 'wp-config.php';
3246 - if ( ! file_exists( $wp_config ) ) {
8808 + $wp_config = self::wp_config_path();
8809 + if ( '' === $wp_config ) {
3247 8810 return false;
3248 8811 }
3249 8812
8813 + /*
8814 + * WP_CACHE belongs to whoever owns the drop-in — it is the switch that
8815 + * makes core load that one file. Editing it while someone else's
8816 + * drop-in is installed either turns THEIR cache on or off; either way
8817 + * it is a write to another plugin's state. So: no ownership, no edit.
8818 + */
8819 + $owner = self::dropin_owner();
8820 + if ( self::DROPIN_FOREIGN === $owner || self::DROPIN_UNREADABLE === $owner ) {
8821 + return false;
8822 + }
8823 +
8824 + $state = self::wp_cache_define_state();
8825 + if ( 'duplicate' === $state || 'dynamic' === $state ) {
8826 + // Two competing defines, or a value behind an expression. A regex
8827 + // rewrite here is a guess, and a wrong guess silently kills page
8828 + // caching with no error anywhere.
8829 + return false;
8830 + }
3250 8831 global $wp_filesystem;
3251 8832 if ( ! function_exists( 'WP_Filesystem' ) ) {
3252 8833 require_once ABSPATH . 'wp-admin/includes/file.php';
3253 8834 }
3254 8835 WP_Filesystem();
3255 - if ( ! $wp_filesystem || ! $wp_filesystem->is_writable( $wp_config ) ) {
8836 + if ( ! $wp_filesystem ) {
3256 8837 return false;
3257 8838 }
3258 8839
3259 8840 $config = $wp_filesystem->get_contents( $wp_config );
8841 + if ( ! is_string( $config ) ) {
8842 + return false;
8843 + }
8844 + require_once XSPEED_DIR . 'includes/wp-cache-constant.php';
8845 + $marker = $enable ? self::wp_cache_receipt() : '';
8846 + $updated = xspeed_rewrite_wp_cache_define( $config, (bool) $enable, $marker );
8847 + if ( ! is_string( $updated ) ) {
8848 + return false;
8849 + }
3260 8850
3261 - if ( $enable ) {
3262 - // Own the constant. A previous caching plugin (e.g. WP Rocket sets
3263 - // it false on deactivate) can leave `define( 'WP_CACHE', false );`
3264 - // behind — presence alone is not enough, the VALUE must be true or
3265 - // WordPress never loads advanced-cache.php and our drop-in is dead.
3266 - if ( preg_match( "/define\\(\\s*['\"]WP_CACHE['\"]\\s*,/", $config ) ) {
3267 - $rewritten = preg_replace(
3268 - "/define\\(\\s*['\"]WP_CACHE['\"]\\s*,\\s*[^)]*\\)\\s*;/",
3269 - "define( 'WP_CACHE', true );",
3270 - $config,
3271 - 1
3272 - );
3273 - // If an existing define was already `true`, the rewrite is a
3274 - // no-op string-wise; either way we end on WP_CACHE === true.
3275 - if ( null !== $rewritten ) {
3276 - $config = $rewritten;
3277 - }
3278 - } else {
3279 - $config = preg_replace( '/(<\?php)/', "$1\ndefine( 'WP_CACHE', true );", $config, 1 );
8851 + /*
8852 + * Removing a WP_CACHE line we cannot prove we wrote is somebody else's
8853 + * configuration, so a disable needs either our drop-in or our receipt.
8854 + *
8855 + * The test is on the REWRITE, not on the request: it used to run
8856 + * before the rewrite and refuse a disable that had nothing to remove.
8857 + * An ordinary site with no drop-in and no define — every fresh
8858 + * install — therefore failed to turn page caching off, so the
8859 + * onboarding wizard reported "setup needs attention" to every user who
8860 + * declined it and Migration reported the cache import as failed.
8861 + */
8862 + if ( ! $enable && $updated !== $config
8863 + && self::DROPIN_XSPEED !== $owner
8864 + && ! self::wp_cache_receipt_matches_source( $config ) ) {
8865 + return false;
8866 + }
8867 +
8868 + /*
8869 + * Nothing to write. auto_heal() runs the whole enable transaction on
8870 + * every admin_init, so without this every wp-admin request rewrote
8871 + * wp-config.php with byte-identical content: pointless disk churn
8872 + * that trips host file-integrity monitors and widens the window for
8873 + * a concurrent write on a busy admin.
8874 + *
8875 + * It is also what makes a correct WP_CACHE on a read-only
8876 + * wp-config.php succeed. A managed host that ships the file
8877 + * unwritable, on a site where the user already pasted the define,
8878 + * is in the state we wanted — the writability test below is about
8879 + * whether we can CHANGE the file, and there is nothing to change.
8880 + */
8881 + if ( $updated === $config ) {
8882 + if ( ! $enable ) {
8883 + // Our line is not in the file, so the receipt that proved we
8884 + // wrote it is stale — drop it on the same terms as a real
8885 + // removal, or uninstall keeps a claim on nothing.
8886 + delete_option( 'xspeed_page_cache_ownership_receipt' );
3280 8887 }
3281 - } else {
3282 - $config = preg_replace( "/define\\(\\s*['\"]WP_CACHE['\"]\\s*,\\s*true\\s*\\);\\s*\\n?/", '', $config );
8888 + return true;
3283 8889 }
3284 8890
3285 - return (bool) $wp_filesystem->put_contents( $wp_config, $config, FS_CHMOD_FILE );
8891 + if ( ! $wp_filesystem->is_writable( $wp_config ) ) {
8892 + return false;
8893 + }
8894 + $written = (bool) $wp_filesystem->put_contents( $wp_config, $updated, FS_CHMOD_FILE );
8895 + if ( $written && ! $enable ) {
8896 + delete_option( 'xspeed_page_cache_ownership_receipt' );
8897 + }
8898 + return $written;
3286 8899 }
3287 8900
8901 + private static function wp_cache_receipt(): string {
8902 + $receipt = get_option( 'xspeed_page_cache_ownership_receipt', '' );
8903 + if ( is_string( $receipt ) && preg_match( '/^[a-f0-9]{32}$/', $receipt ) ) {
8904 + return $receipt;
8905 + }
8906 + $receipt = substr( hash( 'sha256', XSPEED_DIR . microtime( true ) . mt_rand() ), 0, 32 );
8907 + update_option( 'xspeed_page_cache_ownership_receipt', $receipt, false );
8908 + return $receipt;
8909 + }
8910 +
3288 8911 /**
8912 + * Is the WP_CACHE line in wp-config.php ours to REMOVE?
8913 + *
8914 + * Two different questions live here and only one of them matters. "Did we
8915 + * write it" is answered by our drop-in on disk or by our receipt comment
8916 + * beside the define. "Is it ours to remove" also asks what the line does
8917 + * NOW — and once a competitor owns advanced-cache.php, a line we wrote
8918 + * ourselves is the switch that loads THEIR drop-in. They had no reason to
8919 + * touch an already-true define, so our receipt is still sitting on it.
8920 + * Removing it there would stop their live page cache.
8921 + *
8922 + * So a foreign or unreadable owner is never ours to remove, whatever the
8923 + * receipt says, and the caller treats that as a reason to leave the line
8924 + * and get on with disabling our own cache — not as a reason to refuse.
8925 + */
8926 + private static function wp_cache_define_is_ours_to_remove( string $owner ): bool {
8927 + if ( self::DROPIN_FOREIGN === $owner || self::DROPIN_UNREADABLE === $owner ) {
8928 + return false;
8929 + }
8930 + if ( self::DROPIN_XSPEED === $owner ) {
8931 + return true;
8932 + }
8933 + $path = self::wp_config_path();
8934 + if ( '' === $path ) {
8935 + return false;
8936 + }
8937 + $config = self::read_file( $path );
8938 + return is_string( $config ) && self::wp_cache_receipt_matches_source( $config );
8939 + }
8940 +
8941 + private static function wp_cache_receipt_matches_source( string $source ): bool {
8942 + $receipt = get_option( 'xspeed_page_cache_ownership_receipt', '' );
8943 + require_once XSPEED_DIR . 'includes/wp-cache-constant.php';
8944 + return xspeed_wp_cache_receipt_matches( $source, $receipt );
8945 + }
8946 +
8947 + /**
3289 8948 * Admin-bar purge menu — a parent node plus one child per visible cache
3290 8949 * type (LiteSpeed-style), instead of a single "Purge All" link. Each
3291 8950 * child posts to the same admin-post handler with its type slug. The
3292 8951 * per-type items only appear for active/licensed modules; "Purge All"
@@ -3309,10 +8968,59 @@
3309 8968 'href' => admin_url( 'admin.php?page=' . Admin::PAGE_SLUG ),
3310 8969 )
3311 8970 );
3312 8971
3313 - foreach ( self::purge_types() as $slug => $type ) {
3314 - if ( empty( $type['visible'] ) ) {
8972 + // Settings first, then the two whole-errand actions (Purge All,
8973 + // Purge this URL), then the per-type items. The order is the one WP
8974 + // Rocket uses, and it front-loads what people open this menu for:
8975 + // nobody reaches for "Purge Object Cache" as often as they reach for
8976 + // the page they are looking at.
8977 + $wp_admin_bar->add_node(
8978 + array(
8979 + 'id' => 'xspeed-purge-settings',
8980 + 'parent' => 'xspeed-purge',
8981 + 'title' => esc_html__( 'Settings', 'xspeed' ),
8982 + 'href' => admin_url( 'admin.php?page=' . Admin::PAGE_SLUG ),
8983 + )
8984 + );
8985 +
8986 + $types = self::purge_types();
8987 +
8988 + // 'all' is rendered out of band so the single-URL item can sit
8989 + // directly under it. A filter that reorders or drops it is honoured:
8990 + // the loop below skips whatever was emitted here.
8991 + $emitted = array();
8992 + if ( ! empty( $types['all']['visible'] ) ) {
8993 + $wp_admin_bar->add_node(
8994 + array(
8995 + 'id' => 'xspeed-purge-all',
8996 + 'parent' => 'xspeed-purge',
8997 + 'title' => esc_html( $types['all']['label'] ),
8998 + 'href' => self::purge_type_url( 'all' ),
8999 + )
9000 + );
9001 + $emitted['all'] = true;
9002 + }
9003 +
9004 + // Only when the current screen is about one thing — a front-end view,
9005 + // or a published post's edit screen. On a list table or a settings
9006 + // page there is nothing for "this" to mean, so the item stays hidden
9007 + // rather than silently targeting the dashboard. Purge_Ui decides both
9008 + // the label and the scope, which differ between the two contexts.
9009 + $context = Purge_Ui::context_node();
9010 + if ( null !== $context ) {
9011 + $wp_admin_bar->add_node(
9012 + array(
9013 + 'id' => 'xspeed-purge-this-url',
9014 + 'parent' => 'xspeed-purge',
9015 + 'title' => esc_html( $context['title'] ),
9016 + 'href' => $context['href'],
9017 + )
9018 + );
9019 + }
9020 +
9021 + foreach ( $types as $slug => $type ) {
9022 + if ( empty( $type['visible'] ) || isset( $emitted[ $slug ] ) ) {
3315 9023 continue;
3316 9024 }
3317 9025 $wp_admin_bar->add_node(
3318 9026 array(
@@ -3347,11 +9055,34 @@
3347 9055 // Only honour known types; anything else falls back to a full purge.
3348 9056 if ( ! array_key_exists( $type, self::purge_types() ) ) {
3349 9057 $type = 'all';
3350 9058 }
9059 +
9060 + // Answer the browser BEFORE purging. "Purge All" fans out to the local
9061 + // sweep, the object cache, CSS/edge listeners (outbound HTTP) and
9062 + // third-party render caches, all in this one request — on a large site
9063 + // that can outlive PHP-FPM's request_terminate_timeout, FPM kills the
9064 + // worker mid-purge, and nginx answers the admin's click with a 502.
9065 + // fastcgi_finish_request() exists on exactly those FPM setups: send
9066 + // the redirect, close the connection, then keep purging in the same
9067 + // process. Elsewhere (mod_php, CLI tests) fall back to purge-then-
9068 + // redirect as before.
9069 + $redirect = self::safe_purge_redirect( wp_get_referer() );
9070 + if ( function_exists( 'ignore_user_abort' ) ) {
9071 + ignore_user_abort( true );
9072 + }
9073 + if ( function_exists( 'set_time_limit' ) ) {
9074 + @set_time_limit( 300 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort under safe-mode-like restrictions.
9075 + }
9076 + if ( function_exists( 'fastcgi_finish_request' ) ) {
9077 + wp_safe_redirect( $redirect );
9078 + fastcgi_finish_request();
9079 + self::purge_type( $type );
9080 + exit;
9081 + }
9082 +
3351 9083 self::purge_type( $type );
3352 -
3353 - wp_safe_redirect( self::safe_purge_redirect( wp_get_referer() ) );
9084 + wp_safe_redirect( $redirect );
3354 9085 exit;
3355 9086 }
3356 9087
3357 9088 /**