PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.2
1.4.2 1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 All 36 releases
← All changes | includes/class-cache.php +4373 -171 1.3.0 → 1.4.2 View file →
@@ -11,8 +11,64 @@
11 11
12 12 class Cache {
13 13
14 14 /**
15 + * Response header the generated server rules stamp themselves with, so a
16 + * cache hit says which version of the rules served it. See
17 + * rules_marker_expected() for what the value means.
18 + */
19 + public const RULES_HEADER = 'X-XSpeed-Rules';
20 +
21 + /**
22 + * Response header carrying the unix time the served HTML was generated.
23 + *
24 + * Free owns it on every path where PHP runs, and emits it from the same
25 + * `filemtime()` of the file it is about to send: the drop-in stamps it in
26 + * advanced-cache.php, and the template_redirect serve path stamps it in
27 + * serve_not_modified(). The nginx and Apache static paths never emit it —
28 + * no PHP runs there, so a consumer reads their `Last-Modified` instead.
29 + *
30 + * An add-on cannot supply this value, and edge_headers_for() strips it
31 + * from the `xspeed_edge_cache_headers` result in every context rather
32 + * than asking add-ons not to try. Nothing on the filter's side of the
33 + * seam knows which file is being served: mark() resolves the filter with
34 + * no file and no mtime, so an add-on has only time(), which is when the
35 + * page was SERVED. A downstream purge verifier compares this stamp with
36 + * the moment it asked for the purge, and a stamp that is always "now"
37 + * makes every purge look like it worked — worse than sending nothing.
38 + */
39 + public const BUILT_HEADER = 'X-XSpeed-Built';
40 +
41 + /**
42 + * The headers that can grant an edge a lifetime: `Cache-Control`,
43 + * `CDN-Cache-Control`, `Cloudflare-CDN-Cache-Control`,
44 + * `Surrogate-Control`, `Edge-Control`. Matched on the header NAME.
45 + *
46 + * advanced-cache.php carries a copy of this and of the pattern below,
47 + * because the class is not loaded when the drop-in runs. Change all four
48 + * together; EdgeLifetimeEntryCapTest compares them.
49 + */
50 + public const EDGE_LIFETIME_HEADER = '/(?:^|-)control$/i';
51 +
52 + /**
53 + * A lifetime directive inside one of those headers, and its seconds.
54 + * See cap_edge_lifetime().
55 + */
56 + public const EDGE_LIFETIME_DIRECTIVE = '/(?<![\w-])(max-age|s-maxage)\s*=\s*"?(\d+)"?/i';
57 +
58 + /** Map of rules hash => the settings fingerprint that produced it. */
59 + public const RULES_INPUTS_OPTION = 'xspeed_rules_inputs';
60 +
61 + /**
62 + * User meta holding {hash, at}: the rules version this admin says they
63 + * pasted into the server config. See rules_copied().
64 + */
65 + public const RULES_COPIED_META = 'xspeed_nginx_rules_copied';
66 +
67 + /** How many rules versions the map above remembers. */
68 + private const RULES_INPUTS_KEPT = 10;
69 +
70 + /**
15 71 * Output-buffer nesting level at which we opened our cache buffer, so
16 72 * `close_buffer()` can flush ONLY our buffer and never disturb a buffer
17 73 * another plugin pushed on top of (or below) ours.
18 74 *
@@ -45,8 +101,26 @@
45 101 private static $status_header = '';
46 102 private static $bypass_reason = '';
47 103
48 104 /**
105 + * Edge/CDN headers decided for this request, after sanitising.
106 + *
107 + * Same reason as $status_header: header() cannot be observed from the CLI
108 + * SAPI, so the pairs we sent are recorded here too.
109 + *
110 + * @var array<string,string>
111 + */
112 + private static $edge_headers = array();
113 +
114 + /**
115 + * This entry's edge headers when they differ from the site-wide bake,
116 + * resolved once per store. Null until asked.
117 + *
118 + * @var array<string,string>|null
119 + */
120 + private static $per_entry_edge = null;
121 +
122 + /**
49 123 * Cache key whose write was deferred to shutdown because a render-time
50 124 * translation plugin's buffer wraps ours. Null on every ordinary request.
51 125 *
52 126 * @var string|null
@@ -75,8 +149,31 @@
75 149 */
76 150 private static $render_completed = false;
77 151
78 152 /**
153 + * Minifier::purge_stamp() when this request's cache buffer opened.
154 + *
155 + * A render names its minified and combined files while it runs and is
156 + * stored when it ends. A purge that deletes those files in between would
157 + * otherwise leave a stored page linking files that are gone, for the
158 + * whole TTL. The stamp changes whenever such a purge deletes anything, so
159 + * a different value at store time means "do not store this one".
160 + *
161 + * Null when maybe_start_cache() opened no buffer. A direct call (tests,
162 + * add-ons) has nothing to compare against and stores as before.
163 + *
164 + * @var string|null
165 + */
166 + private static $asset_stamp_at_open = null;
167 +
168 + /**
169 + * The snapshot above, carried to the deferred (translated) writer.
170 + *
171 + * @var string|null
172 + */
173 + private static $deferred_asset_stamp = null;
174 +
175 + /**
79 176 * Hooks that get an argument-aware handler instead of a blanket purge.
80 177 *
81 178 * Each fires on an ordinary visitor action — an order, a review, a
82 179 * registration — where purge_all() cannot see WHAT changed and so wiped
@@ -89,9 +186,12 @@
89 186 * running alongside its replacement and silently undo #243.
90 187 */
91 188 private const TARGETED_INVALIDATION_HOOKS = array(
92 189 'save_post',
190 + 'before_delete_post',
191 + 'trashed_post',
93 192 'comment_post',
193 + 'wp_set_comment_status',
94 194 'user_register',
95 195 'profile_update',
96 196 );
97 197
@@ -155,9 +255,9 @@
155 255 // rendered author bylines / term-archive pages. Without them, an edit
156 256 // left the matching endpoint (and archives) stale for the full TTL.
157 257 // (FBS-82408)
158 258 $invalidate_hooks = array(
159 - 'save_post', 'deleted_post', 'trashed_post',
259 + 'save_post', 'before_delete_post', 'trashed_post',
160 260 'comment_post', 'wp_set_comment_status',
161 261 'switch_theme', 'activated_plugin', 'deactivated_plugin',
162 262 // Users → /wp/v2/users + author archives.
163 263 'profile_update', 'user_register', 'deleted_user',
@@ -187,12 +287,21 @@
187 287 }
188 288 add_action(
189 289 $hook,
190 290 static function () use ( $hook ): void {
191 - self::purge_all( 'hook:' . $hook );
291 + self::purge_all(
292 + 'hook:' . $hook,
293 + null,
294 + self::invalidation_for_hook( $hook )
295 + );
192 296 }
193 297 );
194 - add_action( $hook, array( 'XSpeed\\Minifier', 'purge_minified' ) );
298 + // No purge_minified() here. Minified and combined files are named
299 + // by content, so a render after this purge links the same names
300 + // when nothing changed and new names when something did. Deleting
301 + // them only opened a window where cached and in-flight pages link
302 + // files that are gone, and on a network it took every subsite's
303 + // files with it.
195 304 }
196 305
197 306 // Updating a plugin, theme or core changes the markup and the assets
198 307 // a page is built from, but fires NONE of the hooks above: WordPress
@@ -203,11 +312,11 @@
203 312 //
204 313 // The stale copy is not merely old, it is wrong in a way the user
205 314 // cannot see the cause of: they update a plugin to get a fix, the
206 315 // cache keeps serving the pre-fix HTML, and the update looks like it
207 - // did nothing. Minified assets do regenerate on their own (their key
208 - // includes the source filemtime), which makes it worse rather than
209 - // better — the cached pages still link the PREVIOUS hashes.
316 + // did nothing. Minified assets do regenerate on their own (they are
317 + // named by content), which makes it worse rather than better — the
318 + // cached pages still link the PREVIOUS names.
210 319 //
211 320 // Purge unconditionally on any completed update. Scoping it to
212 321 // "plugins that enqueue front-end assets" is not knowable here, and a
213 322 // cold cache after an update is the cheaper mistake. (#269)
@@ -232,9 +341,26 @@
232 341 // alone cannot tell "added beside nothing" from "replaced live code".
233 342 // This filter fires only when the upgrader removed an existing copy,
234 343 // which is exactly the difference. Registered as a filter listener
235 344 // that returns its input untouched. (#303)
236 - add_filter( 'upgrader_clear_destination', array( __CLASS__, 'note_cleared_destination' ), 10, 1 );
345 + add_filter( 'upgrader_clear_destination', array( __CLASS__, 'note_cleared_destination' ), 10, 4 );
346 + // …but `upgrader_clear_destination` fires whenever the upgrader was
347 + // ASKED to clear, not only when it removed something:
348 + // WP_Upgrader::clear_destination() returns true early when the
349 + // destination does not exist. Looked at before the delete, while the
350 + // old copy is still on disk. (#303)
351 + //
352 + // PHP_INT_MAX, because the folder name is only final once every other
353 + // listener has had its turn. Update libraries that normalise
354 + // `plugin-1.2.3/` to `plugin/` (Plugin Update Checker, EDD Software
355 + // Licensing, GitHub-sourced zips) rename the extracted directory on
356 + // this same filter at priority 10 or later, and core derives the real
357 + // destination from the FILTERED source. Measured at 10, a genuine
358 + // replacement read as "nothing was there" and the stale cache stayed.
359 + // note_cleared_destination() cross-checks the folder core actually
360 + // cleared against the one measured here, for a renamer that runs
361 + // later still. (#407 QA)
362 + add_filter( 'upgrader_source_selection', array( __CLASS__, 'note_destination_state' ), PHP_INT_MAX, 4 );
237 363 // Unattended auto-updates are the case that matters most here: they
238 364 // land overnight with nobody around to purge by hand, which is the
239 365 // exact scenario the stale cache goes undiagnosed in. WordPress fires
240 366 // this INSTEAD of a per-item upgrader_process_complete for some
@@ -244,8 +370,22 @@
244 370 // left $type empty, which read as "invalidating" and purged the whole
245 371 // cache for a language-pack-only run. Matches what LiteSpeed binds.
246 372 // (#298)
247 373 add_action( 'automatic_updates_complete', array( __CLASS__, 'purge_after_auto_updates' ), 10, 1 );
374 + // A Customizer publish changes theme mods, Additional CSS, the site
375 + // identity and widget areas, so markup and inline CSS change on every
376 + // page. It fires none of the hooks above. theme_mods_* is an ordinary
377 + // option, and Additional CSS is a non-viewable `custom_css` post that
378 + // the save_post gate rightly ignores. Without this, cached pages kept
379 + // the old header, colours and custom CSS for the whole TTL.
380 + add_action( 'customize_save_after', array( __CLASS__, 'on_customize_save' ), 10, 0 );
381 + // Adding, removing or editing a widget on the Widgets screen (classic
382 + // or block) only writes options: `sidebars_widgets` for which widget
383 + // sits where, `widget_<type>` for its settings. No hook above fires,
384 + // so the sidebar on every cached page stayed as it was until expiry.
385 + add_action( 'update_option_sidebars_widgets', array( __CLASS__, 'on_widgets_change' ), 10, 0 );
386 + add_action( 'add_option_sidebars_widgets', array( __CLASS__, 'on_widgets_change' ), 10, 0 );
387 + add_action( 'updated_option', array( __CLASS__, 'on_widget_option_change' ), 10, 1 );
248 388 // …except the four hooks above that fire on ordinary visitor actions.
249 389 // Attached bare, purge_all() can't see WHAT changed, so on a store
250 390 // every order, every product review and every checkout
251 391 // account-creation wiped 100% of the cache — all anonymous happy-path
@@ -258,21 +398,39 @@
258 398 // so save_post fires either way. The gate therefore keys on POST-TYPE
259 399 // VIEWABILITY, not on storage mode — which fixes both modes at once,
260 400 // and generalises to Flamingo (#229) and Tutor LMS (#231) too.
261 401 remove_action( 'save_post', array( __CLASS__, 'purge_all' ) );
262 - remove_action( 'save_post', array( 'XSpeed\\Minifier', 'purge_minified' ) );
263 402 add_action( 'save_post', array( __CLASS__, 'on_save_post' ), 10, 2 );
403 + // Bracket every post save, so a WooCommerce product save that runs
404 + // inside one (wp-admin's Update) can tell on_save_post() is about to
405 + // clear the whole site for the same product. See purge_product().
406 + add_action( 'save_post', array( __CLASS__, 'on_post_save_start' ), 0, 2 );
407 + add_action( 'save_post', array( __CLASS__, 'on_post_save_end' ), PHP_INT_MAX, 1 );
408 + // The narrow purge runs here, after the REST API has set the terms
409 + // (it sets them after `save_post`), with the post as it was before.
410 + add_action( 'wp_after_insert_post', array( __CLASS__, 'on_after_insert_post' ), 10, 4 );
411 + add_action( 'shutdown', array( __CLASS__, 'flush_pending_saves' ), 1, 0 );
412 + add_action( 'update_option_sticky_posts', array( __CLASS__, 'on_sticky_posts_change' ), 10, 2 );
413 + // The first sticky post on a site creates the option instead.
414 + add_action( 'add_option_sticky_posts', array( __CLASS__, 'on_sticky_posts_added' ), 10, 2 );
415 + add_action( 'pre_post_update', array( __CLASS__, 'on_pre_post_update' ), 10, 2 );
416 + add_action( 'before_delete_post', array( __CLASS__, 'on_post_removed' ), 10, 2 );
417 + add_action( 'trashed_post', array( __CLASS__, 'on_post_removed' ), 10, 2 );
418 + // wp_delete_post() hands an attachment to wp_delete_attachment() and
419 + // returns BEFORE before_delete_post fires, so deleting media reached
420 + // neither hook above. Attachment pages are public and media appears in
421 + // galleries, so that left cached pages showing a file that is gone.
422 + // (dev caught this via `deleted_post`, which this branch replaced.)
423 + add_action( 'delete_attachment', array( __CLASS__, 'on_post_removed' ), 10, 2 );
264 424
265 425 remove_action( 'comment_post', array( __CLASS__, 'purge_all' ) );
266 - remove_action( 'comment_post', array( 'XSpeed\\Minifier', 'purge_minified' ) );
267 426 add_action( 'comment_post', array( __CLASS__, 'on_comment_post' ), 10, 3 );
427 + add_action( 'wp_set_comment_status', array( __CLASS__, 'on_comment_status' ), 10, 2 );
268 428
269 429 remove_action( 'user_register', array( __CLASS__, 'purge_all' ) );
270 - remove_action( 'user_register', array( 'XSpeed\\Minifier', 'purge_minified' ) );
271 430 add_action( 'user_register', array( __CLASS__, 'on_user_change' ) );
272 431
273 432 remove_action( 'profile_update', array( __CLASS__, 'purge_all' ) );
274 - remove_action( 'profile_update', array( 'XSpeed\\Minifier', 'purge_minified' ) );
275 433 add_action( 'profile_update', array( __CLASS__, 'on_user_change' ) );
276 434
277 435 // Product data lives in post meta and lookup tables, NOT in wp_posts,
278 436 // so WC_Product_Data_Store_CPT::update() takes a direct $wpdb->update()
@@ -288,13 +446,20 @@
288 446 // disappears, and an order that reduces stock would leave the product
289 447 // page stale.
290 448 if ( class_exists( 'WooCommerce' ) ) {
291 449 foreach ( array( 'woocommerce_update_product', 'woocommerce_new_product' ) as $wc_hook ) {
292 - add_action( $wc_hook, array( __CLASS__, 'purge_product' ) );
450 + add_action( $wc_hook, array( __CLASS__, 'on_product_saved' ) );
293 451 }
294 - // Direct stock writes bypass the CRUD entirely.
295 - add_action( 'woocommerce_product_set_stock', array( __CLASS__, 'purge_product_object' ) );
296 - add_action( 'woocommerce_variation_set_stock', array( __CLASS__, 'purge_product_object' ) );
452 + // wc_update_product_stock() writes the stock with SQL, saves the
453 + // product (which fires woocommerce_update_product above), then
454 + // fires *_set_stock. The *_before_set_stock actions open that
455 + // write, so the *_set_stock that closes it can tell the save
456 + // inside it already purged. With `$updating` set it skips the
457 + // save, and *_set_stock is then the only purge.
458 + add_action( 'woocommerce_product_before_set_stock', array( __CLASS__, 'on_product_stock_write' ) );
459 + add_action( 'woocommerce_variation_before_set_stock', array( __CLASS__, 'on_product_stock_write' ) );
460 + add_action( 'woocommerce_product_set_stock', array( __CLASS__, 'on_product_stock_set' ) );
461 + add_action( 'woocommerce_variation_set_stock', array( __CLASS__, 'on_product_stock_set' ) );
297 462 add_action( 'woocommerce_product_set_stock_status', array( __CLASS__, 'purge_product' ) );
298 463 add_action( 'woocommerce_variation_set_stock_status', array( __CLASS__, 'purge_product' ) );
299 464 }
300 465
@@ -330,11 +495,11 @@
330 495 // (Cache module). Keep this handler around for whatever still
331 496 // lives in the legacy blob (cache_enabled is special and goes
332 497 // through Cache::toggle anyway).
333 498
334 - // Any settings change — purge caches so changes take effect.
499 + // Any settings change — purge caches so changes take effect. Minified
500 + // files are named by content, so there is nothing to delete for them.
335 501 self::purge_all( 'settings change' );
336 - Minifier::purge_minified();
337 502 }
338 503
339 504 /**
340 505 * Modules whose settings cannot change rendered HTML, so a write to them
@@ -398,10 +563,9 @@
398 563 if ( '' === $slug || in_array( $slug, self::non_rendering_modules(), true ) ) {
399 564 return;
400 565 }
401 566
402 - // Guard against re-entry: purge_all() and purge_minified() can write
403 - // options of their own (stats, timestamps), and a nested purge would
567 + // Guard against re-entry: purge_all() can write options of its own (stats, timestamps), and a nested purge would
404 568 // both waste work and risk recursing through this same hook.
405 569 static $purging = false;
406 570 if ( $purging ) {
407 571 return;
@@ -408,9 +572,8 @@
408 572 }
409 573 $purging = true;
410 574
411 575 self::purge_all( 'settings change' );
412 - Minifier::purge_minified();
413 576
414 577 $purging = false;
415 578 }
416 579
@@ -424,15 +587,53 @@
424 587 * along on `X-XSpeed-Reason`, but only under WP_DEBUG so production
425 588 * responses stay clean. Slugs are fixed per gate — never the matched
426 589 * pattern, cookie or user-agent, which would echo request input back.
427 590 *
428 - * @param string $value HIT (php) | MISS | BYPASS.
429 - * @param string $reason Fixed slug naming the gate, for BYPASS only.
591 + * @param string $value HIT (php) | MISS | BYPASS.
592 + * @param string $reason Fixed slug naming the gate, for BYPASS only.
593 + * @param int|null $lifetime_left On a HIT, the seconds the served entry has
594 + * left when it has a lifetime of its own;
595 + * null when it follows the site's. See
596 + * entry_lifetime_left().
430 597 */
431 - private static function mark( string $value, string $reason = '' ): void {
598 + private static function mark( string $value, string $reason = '', ?int $lifetime_left = null ): void {
432 599 self::$status_header = $value;
433 600 self::$bypass_reason = $reason;
601 + self::$edge_headers = array();
434 602
603 + // A served-from-cache response may carry edge/CDN headers an add-on
604 + // contributes — `CDN-Cache-Control`, `Cache-Tag` and friends. Only a
605 + // HIT resolves them here. A BYPASS never does: the response was
606 + // deliberately excluded from our cache, so telling a CDN to hold it
607 + // for a month would cache at the edge exactly what we refused to
608 + // cache here. A MISS never does either, stored or not: it is the
609 + // first render, and the one most likely to be replaced once critical
610 + // CSS and unused CSS have been generated. Pinning it at the edge pins
611 + // the version xSpeed is about to improve on. The edge caches from the
612 + // first HIT instead — one render later, and the right one.
613 + //
614 + // Resolved before the headers_sent() guard so the decision is
615 + // recorded (and observable in tests) even on a request that can no
616 + // longer send headers; only the emission below is conditional.
617 + if ( 'HIT' === self::edge_status( $value ) ) {
618 + self::$edge_headers = self::edge_headers_for( 'HIT' );
619 + }
620 +
621 + // Every status, not just a HIT. A page we declined to cache is the
622 + // one an edge most needs telling about: it goes out naked today, and
623 + // a CDN that stores HTML by default keeps somebody's cart.
624 + //
625 + // Resolved before the headers_sent() guard so the decision is
626 + // recorded (and observable in tests) even on a request that can no
627 + // longer send headers; only the emission below is conditional.
628 + self::$edge_headers = self::edge_headers_for( self::edge_status( $value ), 'request', $reason );
629 +
630 + // The same countdown the drop-in applies to this entry, so the two
631 + // PHP serve paths tell the edge the same thing about one page.
632 + if ( null !== $lifetime_left && 'HIT' === self::edge_status( $value ) ) {
633 + self::$edge_headers = self::cap_edge_lifetime( self::$edge_headers, $lifetime_left );
634 + }
635 +
435 636 if ( headers_sent() ) {
436 637 return;
437 638 }
438 639 header( 'X-XSpeed-Cache: ' . $value );
@@ -438,10 +639,354 @@
438 639 header( 'X-XSpeed-Cache: ' . $value );
439 640 if ( '' !== $reason && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
440 641 header( 'X-XSpeed-Reason: ' . $reason );
441 642 }
643 + foreach ( self::$edge_headers as $name => $val ) {
644 + header( $name . ': ' . $val );
645 + }
442 646 }
443 647
648 + /**
649 + * Normalize an `X-XSpeed-Cache` value to the vocabulary the edge seam
650 + * speaks.
651 + *
652 + * The header value carries which layer served the page (`HIT (php)`,
653 + * `HIT (nginx)`, `HIT (static)`); nothing deciding what to tell a CDN
654 + * cares, and making a caller match on three spellings of one outcome is
655 + * how a rule ends up applied on two paths out of three.
656 + */
657 + private static function edge_status( string $value ): string {
658 + return 0 === strpos( $value, 'HIT' ) ? 'HIT' : $value;
659 + }
660 +
661 + /**
662 + * Hash a generated rule set so the rules can identify themselves.
663 + *
664 + * @param string[] $lines Every line of the rule set EXCEPT the marker.
665 + */
666 + private static function rules_hash( array $lines ): string {
667 + return substr( sha1( implode( "\n", $lines ) ), 0, 8 );
668 + }
669 +
670 + /**
671 + * Insert the self-describing marker directive into a generated rule set.
672 + *
673 + * The hash is taken over the rule set WITHOUT this line, and that is the
674 + * whole trick: hashing the finished text instead would mean the act of
675 + * adding the marker changed the value the marker advertises, the probe
676 + * would never see a match, and every correctly installed block would
677 + * report itself out of date forever. The one property worth a test of its
678 + * own — RewriteProbeRulesStateTest covers it.
679 + *
680 + * More than one template is allowed because Apache needs the same marker
681 + * twice, under two environment-variable names (see rewrite_block_lines).
682 + * All of them carry the SAME hash, taken over the marker-less rule set, so
683 + * adding the second directive cannot change the value either advertises.
684 + *
685 + * @param string[] $lines The rule set, with no marker line in it.
686 + * @param string[] $templates sprintf templates for the directives; `%s` is the hash.
687 + * @param int $at Index to insert the marker at.
688 + * @return string[]
689 + */
690 + private static function with_rules_marker( array $lines, array $templates, int $at ): array {
691 + $hash = self::rules_hash( $lines );
692 + $directives = array();
693 + foreach ( $templates as $template ) {
694 + $directives[] = sprintf( $template, $hash );
695 + }
696 + array_splice( $lines, $at, 0, $directives );
697 + return $lines;
698 + }
699 +
700 + /**
701 + * The rules hash the CURRENT settings generate, i.e. what a correctly
702 + * installed rule set would be sending back.
703 + *
704 + * Read out of the generated artifact rather than recomputed, so there is
705 + * exactly one definition of the hash and no way for the generator and the
706 + * expectation to drift apart. Empty on a server whose fast path we do not
707 + * generate rules for.
708 + */
709 + public static function rules_marker_expected(): string {
710 + $type = Server::type();
711 + if ( Server::NGINX === $type ) {
712 + return self::extract_rules_marker( (string) self::nginx_snippet() );
713 + }
714 + if ( Server::APACHE === $type ) {
715 + return self::extract_rules_marker( implode( "\n", self::rewrite_block_lines() ) );
716 + }
717 + return '';
718 + }
719 +
720 + /** Pull the marker value out of a generated rule set ('' when absent). */
721 + public static function extract_rules_marker( string $source ): string {
722 + if ( preg_match( '/' . preg_quote( self::RULES_HEADER, '/' ) . ' "([0-9a-f]{8})"/', $source, $m ) ) {
723 + return $m[1];
724 + }
725 + return '';
726 + }
727 +
728 + /**
729 + * Fingerprint of every setting that feeds the generated server rules.
730 + *
731 + * Values are hashed rather than stored: this map is written from a read
732 + * path and has no business becoming a second copy of the user's exclusion
733 + * lists. A per-key hash is enough — the point is to name WHICH setting
734 + * moved between two rule versions, not to reconstruct the old value.
735 + *
736 + * @return array<string,string>
737 + */
738 + public static function rules_inputs(): array {
739 + $cache_opts = Settings_Manager::get( 'cache' );
740 + $fingerprint = static function ( $value ): string {
741 + $flat = array();
742 + foreach ( (array) $value as $key => $item ) {
743 + $flat[] = $key . '=' . ( is_scalar( $item ) ? (string) $item : '' );
744 + }
745 + return substr( sha1( implode( "\x1f", $flat ) ), 0, 8 );
746 + };
747 +
748 + return array(
749 + 'excluded_cookies' => $fingerprint( $cache_opts['excluded_cookies'] ?? array() ),
750 + 'bypass_user_agents' => $fingerprint( $cache_opts['bypass_user_agents'] ?? array() ),
751 + 'excluded_urls' => $fingerprint( $cache_opts['excluded_urls'] ?? array() ),
752 + 'edge_headers' => $fingerprint( self::edge_headers_for( 'HIT', 'rules' ) ),
753 + 'static_dir' => $fingerprint( array( XSPEED_CACHE_STATIC_DIR ) ),
754 + );
755 + }
756 +
757 + /**
758 + * Remember which settings produced a rules version.
759 + *
760 + * Without this a stale verdict can only say "stale". With it, the hash the
761 + * server sent back is a key into what the site's settings looked like when
762 + * those rules were generated, so the dashboard can name what changed since
763 + * the user last pasted. Bounded to the last few versions — the map exists
764 + * to explain a recent drift, not to keep a history.
765 + *
766 + * @param array<string,string> $inputs Fingerprint from rules_inputs().
767 + */
768 + private static function remember_rules_inputs( string $hash, array $inputs ): void {
769 + if ( '' === $hash ) {
770 + return;
771 + }
772 + $map = get_option( self::RULES_INPUTS_OPTION, array() );
773 + if ( ! is_array( $map ) ) {
774 + $map = array();
775 + }
776 + if ( isset( $map[ $hash ] ) && $map[ $hash ] === $inputs ) {
777 + return;
778 + }
779 + $map[ $hash ] = $inputs;
780 + if ( count( $map ) > self::RULES_INPUTS_KEPT ) {
781 + $map = array_slice( $map, -self::RULES_INPUTS_KEPT, null, true );
782 + }
783 + update_option( self::RULES_INPUTS_OPTION, $map, false );
784 + }
785 +
786 + /**
787 + * Which settings changed between the installed rules and the current ones.
788 + *
789 + * Empty when the installed version predates the map (nothing to compare
790 + * against) — the caller then says "out of date" without naming a cause,
791 + * which is honest.
792 + *
793 + * @param array<string,string> $current Fingerprint from rules_inputs().
794 + * @return string[] Setting keys.
795 + */
796 + private static function changed_rules_inputs( string $observed, array $current ): array {
797 + $map = get_option( self::RULES_INPUTS_OPTION, array() );
798 + if ( ! is_array( $map ) || ! isset( $map[ $observed ] ) || ! is_array( $map[ $observed ] ) ) {
799 + return array();
800 + }
801 +
802 + $was = $map[ $observed ];
803 + $changed = array();
804 + foreach ( $current as $key => $value ) {
805 + if ( ! array_key_exists( $key, $was ) || $was[ $key ] !== $value ) {
806 + $changed[] = $key;
807 + }
808 + }
809 + return $changed;
810 + }
811 +
812 + /**
813 + * Whether the rules the server is running are the rules these settings
814 + * generate.
815 + *
816 + * Nothing in WordPress can read a hand-pasted nginx server block, so
817 + * before this the dashboard could only guess — and guessed by telling
818 + * everyone to re-paste after every settings change. The generated rules
819 + * now carry their own hash and the probe reads it back:
820 + *
821 + * current — the marker matches what these settings generate.
822 + * stale — a marker came back, from a different version of the rules.
823 + * absent — the probe reached a verdict and saw no marker: either no
824 + * rules are installed (the request fell through to PHP) or
825 + * they predate the marker. Either way the fix is the same.
826 + * unknown — the probe could not tell, or this server has no rule set we
827 + * generate.
828 + *
829 + * A marker outranks the probe's own `active` verdict: the marker is direct
830 + * evidence of which rules answered, while `active` is inferred from
831 + * response shape.
832 + *
833 + * Scope: the static-cache rules only — nginx_snippet() on nginx, the
834 + * .htaccess block on Apache. full_nginx_server_block() pastes those
835 + * alongside other modules' directives, and a change to one of those does
836 + * NOT move this hash. Hashing the aggregate would mean the marker inside
837 + * the location block had to know the text it is embedded in, and the
838 + * snippet would hash differently depending on which caller asked for it.
839 + * Callers wording this for a human should say "cache rules", not "your
840 + * nginx config".
841 + *
842 + * `copied` is the mirror of what this admin last pasted — see
843 + * rules_copied(). It is always present, null included, because a client
844 + * keys on the key existing to decide whether the server remembers at all.
845 + *
846 + * @param array $probe Raw result from probe_static_rewrite().
847 + * @return array{expected:string,observed:string,state:string,changed:string[],copied:array{hash:string,at:int}|null}
848 + */
849 + public static function rules_state( array $probe ): array {
850 + $expected = self::rules_marker_expected();
851 + $observed = (string) ( $probe['rules'] ?? '' );
852 + $copied = self::rules_copied();
853 +
854 + if ( '' === $expected ) {
855 + return array(
856 + 'expected' => '',
857 + 'observed' => $observed,
858 + 'state' => 'unknown',
859 + 'changed' => array(),
860 + 'copied' => $copied,
861 + );
862 + }
863 +
864 + $inputs = self::rules_inputs();
865 + self::remember_rules_inputs( $expected, $inputs );
866 +
867 + if ( '' !== $observed ) {
868 + return array(
869 + 'expected' => $expected,
870 + 'observed' => $observed,
871 + 'state' => $observed === $expected ? 'current' : 'stale',
872 + 'changed' => $observed === $expected ? array() : self::changed_rules_inputs( $observed, $inputs ),
873 + 'copied' => $copied,
874 + );
875 + }
876 +
877 + // `absent` is a claim about what is installed, so it takes a probe that
878 + // completed a round trip and read the response headers. A result that
879 + // is still pending, one whose loopback failed, one a CDN answered, and
880 + // one that never got off the ground (no host in home_url, no writable
881 + // probe dir — neither of which sets `inconclusive`, and both of which
882 + // arrive here with no status code) all leave us knowing nothing.
883 + if ( empty( $probe['code'] ) || ! empty( $probe['pending'] ) || ! empty( $probe['inconclusive'] ) ) {
884 + return array(
885 + 'expected' => $expected,
886 + 'observed' => '',
887 + 'state' => 'unknown',
888 + 'changed' => array(),
889 + 'copied' => $copied,
890 + );
891 + }
892 +
893 + return array(
894 + 'expected' => $expected,
895 + 'observed' => '',
896 + 'state' => 'absent',
897 + 'changed' => array(),
898 + 'copied' => $copied,
899 + );
900 + }
901 +
902 + /**
903 + * Which version of the rules this admin says they pasted into the server.
904 + *
905 + * Their claim, not evidence — the probe is the evidence, and where the
906 + * probe can answer this is ignored. It exists for the `unknown` state: a
907 + * host whose loopback is blocked, or one behind a CDN that answers the
908 + * probe itself, where nothing can read back what is installed. There the
909 + * only thing left to go on is that someone said they had done it, and
910 + * without a record of that the panel asks every admin to paste the block
911 + * again forever.
912 + *
913 + * Per user rather than per site, because it is a claim a person made. A
914 + * second admin on the same site has not pasted anything and should not be
915 + * told the work is done. Null when nobody has claimed this version, when
916 + * the stored value is not a shape we wrote, or when there is no current
917 + * user at all (WP-CLI, cron).
918 + *
919 + * @return array{hash:string,at:int}|null
920 + */
921 + public static function rules_copied(): ?array {
922 + if ( ! function_exists( 'get_current_user_id' ) || ! function_exists( 'get_user_meta' ) ) {
923 + return null;
924 + }
925 + $user_id = (int) get_current_user_id();
926 + if ( $user_id <= 0 ) {
927 + return null;
928 + }
929 +
930 + $stored = get_user_meta( $user_id, self::RULES_COPIED_META, true );
931 + if ( ! is_array( $stored ) ) {
932 + return null;
933 + }
934 +
935 + $hash = (string) ( $stored['hash'] ?? '' );
936 + $at = (int) ( $stored['at'] ?? 0 );
937 + if ( ! self::is_rules_hash( $hash ) || $at <= 0 ) {
938 + return null;
939 + }
940 +
941 + return array(
942 + 'hash' => $hash,
943 + 'at' => $at,
944 + );
945 + }
946 +
947 + /**
948 + * Record that this admin pasted the rules whose marker is $hash.
949 + *
950 + * Rejects anything that is not one of our markers rather than storing it,
951 + * so the mirror can only ever hold a value rules_marker_expected() could
952 + * also produce — a stored string that matches nothing would read as "a
953 + * different version is installed" forever.
954 + *
955 + * @param string $hash The 8-hex rules marker the admin copied.
956 + * @return array{hash:string,at:int}|null The stored record, or null if refused.
957 + */
958 + public static function remember_rules_copied( string $hash ): ?array {
959 + // Trimmed but not case-folded: extract_rules_marker() reads a marker
960 + // back in lower case only, so an upper-case claim would never match
961 + // anything the probe could observe. Refuse it rather than store a
962 + // value that can only ever read as a different version.
963 + $hash = trim( $hash );
964 + if ( ! self::is_rules_hash( $hash ) ) {
965 + return null;
966 + }
967 + if ( ! function_exists( 'get_current_user_id' ) || ! function_exists( 'update_user_meta' ) ) {
968 + return null;
969 + }
970 + $user_id = (int) get_current_user_id();
971 + if ( $user_id <= 0 ) {
972 + return null;
973 + }
974 +
975 + $record = array(
976 + 'hash' => $hash,
977 + 'at' => time(),
978 + );
979 + update_user_meta( $user_id, self::RULES_COPIED_META, $record );
980 +
981 + return $record;
982 + }
983 +
984 + /** Whether a string is shaped like one of our rules markers. */
985 + public static function is_rules_hash( string $hash ): bool {
986 + return 1 === preg_match( '/^[0-9a-f]{8}$/', $hash );
987 + }
988 +
444 989 /** Record a bypass gate and answer "don't cache" in one statement. */
445 990 private static function bypass( string $reason ): bool {
446 991 self::mark( 'BYPASS', $reason );
447 992 return false;
@@ -457,8 +1002,923 @@
457 1002 return self::$bypass_reason;
458 1003 }
459 1004
460 1005 /**
1006 + * The edge/CDN pairs sent on this request ('' if none were).
1007 + *
1008 + * @return array<string,string>
1009 + */
1010 + public static function edge_headers(): array {
1011 + return self::$edge_headers;
1012 + }
1013 +
1014 + /**
1015 + * Bypass gates that do NOT ask a cache in front of us to stand down.
1016 + *
1017 + * Every other slug does. The split is the reason this reads the gate
1018 + * rather than the status: a bypass usually means "this response is
1019 + * personal, or someone decided this page is never stored", and an edge
1020 + * holding one of those does precisely what we refused to do. These two
1021 + * mean something else.
1022 + *
1023 + * `cache-disabled` is the user switching OUR page cache off. Nothing
1024 + * about the page became personal. Sending `no-store` on every page of a
1025 + * site whose owner chose a different cache would make a local toggle a
1026 + * site-wide side effect on infrastructure we do not own.
1027 + *
1028 + * `non-frontend` is admin, REST, cron and AJAX. Not ours to describe:
1029 + * WordPress already nocaches admin, and a REST caller sets its own
1030 + * policy.
1031 + */
1032 + private const HOLD_EXEMPT_BYPASS = array( 'cache-disabled', 'non-frontend' );
1033 +
1034 + /**
1035 + * Bypass gates that describe the SHAPE of the request rather than the
1036 + * visitor or the page.
1037 + *
1038 + * These still hold, but only once we have evidence of an edge — the same
1039 + * bar a MISS has to clear. The difference matters because the default
1040 + * excluded-URL list contains `/feed/`, the sitemap and `/wp-json/`, and
1041 + * `query-param` catches `?lang=fr`, `?paged=2`, and every page of a
1042 + * plain-permalink site.
1043 + *
1044 + * xSpeed refuses those because IT cannot key on a query string, not
1045 + * because the response is private. A CDN keys on the full URL and caches
1046 + * them correctly. Holding them unconditionally would have meant every
1047 + * default install stopped its feed and sitemap being edge-cached — a
1048 + * performance regression shipped to sites that never had a CDN in the
1049 + * first place, in the name of protecting them from one.
1050 + *
1051 + * The gates left out of this list are about the visitor (`logged-in`,
1052 + * `excluded-cookie`) or are somebody stating outright that this page is
1053 + * never to be stored (`donotcachepage`, `post-excluded`, `filtered`).
1054 + * Those hold whether or not we can see an edge.
1055 + */
1056 + private const REQUEST_SHAPE_BYPASS = array( 'query-param', 'non-get', 'user-agent' );
1057 +
1058 + /**
1059 + * Default exclusions that are about the site's plumbing, not its content.
1060 + *
1061 + * `excluded-url` covers two unlike things. The default list carries
1062 + * `/cart`, `/checkout`, `/my-account` and `/wp-login` — personal pages,
1063 + * and the reason this feature exists. It also carries the entries below:
1064 + * feeds, sitemaps, the REST root, the front controller. Those are public,
1065 + * cacheable, and hammered by pollers; a CDN keys on the full URL and
1066 + * serves them correctly, so telling it to stop is a cost with no benefit.
1067 + *
1068 + * Matched as exact strings against the stored list, never as patterns
1069 + * against the path. Three bugs came out of doing it the other way round:
1070 + * `strpos( $uri, '/feed' )` matched `/my-account/feedback/`, reading the
1071 + * whole URI let `/cart/?utm_source=/feed/` disguise a cart as a feed, and
1072 + * a bare `index.php` — which is in this list, and which every URL contains
1073 + * on an "almost pretty" permalink site — made every page on such a site
1074 + * look personal. Comparing the LIST ENTRY rather than the path cannot make
1075 + * any of those mistakes, and it keeps a pattern the site owner added
1076 + * themselves on the personal side where it belongs.
1077 + */
1078 + private const STRUCTURAL_EXCLUSIONS = array(
1079 + '/wp-json/',
1080 + '/xmlrpc.php',
1081 + '~wp-.*\.php',
1082 + '/feed/',
1083 + 'index.php',
1084 + '/robots.txt',
1085 + // Both spellings, and no entry here is ever retired. This is a
1086 + // RECOGNITION list, not a source of truth: it is matched against
1087 + // whatever the site has STORED, and a site that saved its settings
1088 + // before `~sitemap(_index)?\.xml` was widened to `sitemaps?` (for
1089 + // SEOPress, which ships sitemaps.xml) still has the old string in
1090 + // its option row. Dropping the old spelling when the default moved
1091 + // would read every upgraded site's sitemap exclusion as somebody's
1092 + // personal data and hold sitemaps off the CDN — the bug this whole
1093 + // predicate exists to prevent, reintroduced by a rename.
1094 + '~sitemaps?(_index)?\.xml',
1095 + '~sitemap(_index)?\.xml',
1096 + );
1097 + /**
1098 + * Header names no edge instruction may ever carry.
1099 + *
1100 + * These describe the transfer, not the caching policy, and one wrong
1101 + * value from a settings field is a white screen rather than a missing
1102 + * optimization.
1103 + */
1104 + private const NEVER_AN_EDGE_HEADER = array(
1105 + 'content-length',
1106 + 'content-encoding',
1107 + 'content-type',
1108 + 'transfer-encoding',
1109 + 'set-cookie',
1110 + 'location',
1111 + 'x-xspeed-cache',
1112 + 'x-xspeed-edge-hold',
1113 + // `X-XSpeed-Built` says when THIS PAGE's HTML was generated, which is
1114 + // what a downstream verifier compares against the moment it asked for
1115 + // a purge. Free owns it and never takes it from a filter, in any
1116 + // context — see the BUILT_HEADER docblock.
1117 + //
1118 + // Under `bake` a supplied value would be frozen into an artifact and
1119 + // report when the artifact was written: identical on every page and
1120 + // never moving. Under `request` it is no better, because the filter
1121 + // runs from mark() with no file and no mtime in reach, so the only
1122 + // value anything can produce there is time() — the moment of the
1123 + // SERVE, not of the build. Both make every purge check pass.
1124 + //
1125 + // Free stamps the real value where it has the file: the drop-in and
1126 + // serve_not_modified(), both from filemtime(). The static serve paths
1127 + // run no PHP and carry no stamp at all; their `Last-Modified` is read
1128 + // instead.
1129 + 'x-xspeed-built',
1130 + );
1131 +
1132 + /**
1133 + * Reasons that hold the edge off even when we detected nothing in front.
1134 + *
1135 + * `none` confidence means no evidence of a proxy, which is not proof
1136 + * there is none — a transparent proxy and a host page cache both leave
1137 + * the request untouched. So the question is what a wasted header costs
1138 + * against what a missed one does, and the answer differs by reason.
1139 + *
1140 + * These two are correctness failures. A cart page stored by something we
1141 + * could not see is the defect this exists to fix, and a mobile-split page
1142 + * served to the wrong device is a wrong page rather than a slow one.
1143 + * Ninety bytes on a response that was never cacheable is a cheap premium.
1144 + *
1145 + * `miss` and `pending` are performance hedges, and a hedge against a
1146 + * cache that does not exist is noise on every first render. Skipping them
1147 + * has a second benefit: because per_entry_edge_headers() compares `store`
1148 + * against `bake`, a `pending` hold that never fires leaves the two
1149 + * agreeing, which keeps the page on the static tree.
1150 + *
1151 + * `query-variant` waits for evidence as well. It is about purges. An edge
1152 + * keeps a separate copy for every query string, and a purge of the plain
1153 + * URL never reaches them. With no edge in front there are no copies.
1154 + */
1155 + private const HOLD_WITHOUT_EVIDENCE = array( 'bypass', 'mobile-split' );
1156 +
1157 + /**
1158 + * True only while query_variant_edge_headers() bakes the drop-in's
1159 + * answer for a URL no purge names.
1160 + *
1161 + * A bake has no request to read, so this is how it asks "what if this
1162 + * HIT carried a param no purge names?". The drop-in answers that for
1163 + * itself, per request. Never true outside that one call.
1164 + *
1165 + * @var bool
1166 + */
1167 + private static $baking_query_variant = false;
1168 +
1169 + /**
1170 + * Is a module still going to change this page after this response?
1171 + *
1172 + * Free itself never says yes — nothing in Free defers work past the
1173 + * request. Minification and combining write their file and return its URL
1174 + * inside the same render; the LCP preload is chosen by parsing the HTML
1175 + * being sent. It is the question that matters to anything caching in
1176 + * front of us, so Free asks it on their behalf and lets whoever owns the
1177 + * deferred work answer.
1178 + *
1179 + * Answer TRUE while the work is outstanding for the page being served.
1180 + * The cost of a false yes is one extra origin hit; the cost of a false no
1181 + * is an un-optimized page pinned at the edge for the full lifetime, which
1182 + * is the failure this exists to prevent — so when in doubt, say yes.
1183 + *
1184 + * Asked on a `request` only, and that boundary is the whole safety of it.
1185 + *
1186 + * A `bake` is generated once, in an admin or CLI request, and serves every
1187 + * static HIT on the site; a per-page answer frozen into it would be wrong
1188 + * for every other page.
1189 + *
1190 + * A `store` is worse, and cost a live site an afternoon. The pairs written
1191 + * at store time go into the `.meta` sidecar, which the drop-in replays on
1192 + * every later HIT — before plugins load, so nothing can re-ask this
1193 + * question. A hold written there therefore outlives the state that caused
1194 + * it, and the only thing that clears it is the page being stored again. On
1195 + * a site where the deferred work never completes, every re-store re-pins
1196 + * it, and the page is never edge-cacheable again. The symptom is a cache
1197 + * HIT carrying `no-store` and `X-XSpeed-Edge-Hold: pending` on a page
1198 + * whose deferred work finished long ago — the sidecar answering with
1199 + * state nothing can re-ask.
1200 + *
1201 + * Holding the MISS is what this is for, and it is enough: that response is
1202 + * the un-optimized one. The copy we then store is what an edge should
1203 + * mirror, and when the work does land the module purges the page, which
1204 + * reaches the edge. The purge is the correctness mechanism; this is only
1205 + * meant to cover the single render before it.
1206 + *
1207 + * @param string $context `request`, `store` or `bake`.
1208 + */
1209 + public static function edge_optimization_pending( string $context = 'request' ): bool {
1210 + if ( 'request' !== $context ) {
1211 + return false;
1212 + }
1213 +
1214 + /**
1215 + * Filter: xspeed_edge_optimization_pending
1216 + *
1217 + * @param bool $pending Whether deferred work will still change this page.
1218 + */
1219 + return (bool) apply_filters( 'xspeed_edge_optimization_pending', false );
1220 + }
1221 +
1222 + /**
1223 + * Does mobile cache split this URL into two renders?
1224 + *
1225 + * With `mobile_separate` on, Free keys its cache on device and serves a
1226 + * different page to a phone than to a desktop at the SAME url. No CDN
1227 + * varies on User-Agent, so an edge holding one of those renders serves it
1228 + * to everyone: whichever device asked first decides what the other sees,
1229 + * for the whole lifetime. A wrong page, not a slow one.
1230 + *
1231 + * Read from the stored option rather than through Settings_Manager: this
1232 + * is consulted from the serve path, where the module registry may not
1233 + * have run.
1234 + */
1235 + private static function mobile_cache_splits_html(): bool {
1236 + $stored = self::stored_cache_opts();
1237 + return ! empty( $stored['mobile_separate'] );
1238 + }
1239 +
1240 + /**
1241 + * Is this cached page being served for a URL that no purge names?
1242 + *
1243 + * Free serves `/post?utm_source=x` from the entry stored for `/post`,
1244 + * which is what the ignored-params list is for. An edge does not. It
1245 + * keys on the full URL, so every query string becomes its own copy. A
1246 + * purge that names `/post` clears one copy and leaves the others for the
1247 + * whole edge lifetime. A newsletter link can then serve last month's
1248 + * page. So such a HIT is held, and the edge keeps only URLs a purge can
1249 + * name. See query_carries_unpurged_param() for which query strings count.
1250 + *
1251 + * A HIT only. A MISS is already held as `miss`, and a BYPASS never
1252 + * reached the cache. And `request` only, because the query string belongs
1253 + * to the request, not to the entry. One stored file answers `/post` and
1254 + * every variant of it, so a hold written into its sidecar under `store`
1255 + * would be replayed by the drop-in for the plain URL as well. A `bake`
1256 + * says no unless query_variant_edge_headers() is asking for the drop-in.
1257 + *
1258 + * @param string $status `HIT`, `MISS` or `BYPASS`.
1259 + * @param string $context `request`, `store` or `bake`.
1260 + */
1261 + private static function serves_query_variant( string $status, string $context ): bool {
1262 + if ( 'HIT' !== $status ) {
1263 + return false;
1264 + }
1265 + if ( 'bake' === $context ) {
1266 + return self::$baking_query_variant;
1267 + }
1268 +
1269 + return 'request' === $context && self::query_carries_unpurged_param();
1270 + }
1271 +
1272 + /**
1273 + * Does this request's query string carry a param that keeps its URL out
1274 + * of every purge?
1275 + *
1276 + * Two kinds do. A param the cache key leaves out, which is anything on
1277 + * the ignored-params list, judged by query_key_is_ignored() against the
1278 + * same setting should_cache() reads. And the search and query-form feed
1279 + * params (`s`, FEED_QUERY_PARAMS). Those have entries keyed their own
1280 + * way, but they belong to opt-in caches whose URLs no purge names either.
1281 + *
1282 + * Any other param that reaches a HIT is part of the page's own address,
1283 + * which a purge names, so it is not held. A plain-permalink route such
1284 + * as `/?page_id=2` is the case this leaves alone.
1285 + */
1286 + private static function query_carries_unpurged_param(): bool {
1287 + $query_raw = isset( $_SERVER['QUERY_STRING'] ) ? (string) wp_unslash( $_SERVER['QUERY_STRING'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- parsed for its keys only, as should_cache() does; never echoed or stored.
1288 + if ( '' === trim( $query_raw ) ) {
1289 + return false;
1290 + }
1291 +
1292 + parse_str( $query_raw, $params );
1293 + $cache_opts = Settings_Manager::get( 'cache' );
1294 + $ignored = is_array( $cache_opts['ignored_query_params'] ?? null ) ? $cache_opts['ignored_query_params'] : array();
1295 + foreach ( array_keys( $params ) as $key ) {
1296 + $key = (string) $key;
1297 + if ( 's' === $key || in_array( $key, self::FEED_QUERY_PARAMS, true ) ) {
1298 + return true;
1299 + }
1300 + if ( self::query_key_is_ignored( $key, $ignored ) ) {
1301 + return true;
1302 + }
1303 + }
1304 +
1305 + return false;
1306 + }
1307 +
1308 + /**
1309 + * The edge answer the drop-in sends for a cached page served for a URL
1310 + * no purge names, or an empty array when that answer is the same as the
1311 + * plain URL's.
1312 + *
1313 + * The drop-in runs before plugins load and cannot ask edge_headers_for(),
1314 + * so this is baked in next to the plain answer. The drop-in applies it
1315 + * when a param in the query string matches the ignored-params list it
1316 + * already reads (`.ignored-query-params`, written from the same setting
1317 + * by sync_query_allowlist()). Search and feed params never reach it,
1318 + * because it hands those requests to PHP. The nginx and Apache
1319 + * rules need no copy. Both refuse any request with a query string
1320 + * (`if ($args)`, `RewriteCond %{QUERY_STRING} ^$`), so a variant always
1321 + * reaches PHP.
1322 + *
1323 + * Empty when the two answers agree. That covers no edge being known,
1324 + * a filter vetoing the hold, and a site where every page is held
1325 + * already (mobile-split). The drop-in then sends what it sends for the
1326 + * plain URL, including any sidecar the page has.
1327 + *
1328 + * @return array<string,string>
1329 + */
1330 + public static function query_variant_edge_headers(): array {
1331 + $plain = self::edge_headers_for( 'HIT', 'bake' );
1332 +
1333 + self::$baking_query_variant = true;
1334 + try {
1335 + $variant = self::edge_headers_for( 'HIT', 'bake' );
1336 + } finally {
1337 + self::$baking_query_variant = false;
1338 + }
1339 +
1340 + return $variant === $plain ? array() : $variant;
1341 + }
1342 +
1343 + /**
1344 + * Why, if at all, a cache in front of us should refuse to store this.
1345 + *
1346 + * @param string $status `HIT`, `MISS` or `BYPASS`.
1347 + * @param string $context `request`, `store` or `bake`.
1348 + * @param string $bypass_reason The gate slug, for BYPASS only.
1349 + * @return string '' or one of bypass|bypass-shape|miss|mobile-split|pending|query-variant.
1350 + */
1351 + private static function edge_hold_reason( string $status, string $context, string $bypass_reason ): string {
1352 + $reason = '';
1353 +
1354 + // The two exempt gates are answered before anything else, or a site
1355 + // with Separate Mobile Cache on would keep holding after the page
1356 + // cache was switched off — which is exactly the "a local toggle must
1357 + // not become a site-wide side effect on infrastructure we do not own"
1358 + // rule below, defeated by the ordering rather than by the logic.
1359 + if ( 'BYPASS' === $status && in_array( $bypass_reason, self::HOLD_EXEMPT_BYPASS, true ) ) {
1360 + /** This filter is documented below. */
1361 + return (string) apply_filters( 'xspeed_edge_hold_reason', '', $status, $context, $bypass_reason );
1362 + }
1363 +
1364 + // First, because it is the only reason true in every context: the
1365 + // setting is a property of the site, not of one request, so it is the
1366 + // one thing a baked artifact can honestly assert.
1367 + //
1368 + // It is also the only reason that holds a HIT — a response we DID
1369 + // cache — and that is deliberate rather than an artefact of the
1370 + // ordering. With mobile_separate on we key the cache by device and
1371 + // serve different HTML to a phone than to a desktop at the same URL.
1372 + // No CDN varies on User-Agent, so an edge holding one of those
1373 + // renders serves it to everyone and whichever device asked first
1374 + // decides what the other sees. Our copy is fine; theirs would be a
1375 + // wrong page. The static path is switched off in this mode anyway
1376 + // (static_rewrite_allowed()), so these hits come from the drop-in,
1377 + // which carries the same baked answer.
1378 + if ( self::mobile_cache_splits_html() ) {
1379 + $reason = 'mobile-split';
1380 + } elseif ( 'BYPASS' === $status ) {
1381 + $shaped = in_array( $bypass_reason, array( 'excluded-url', 'query-param' ), true )
1382 + ? ! self::path_is_a_personal_exclusion( $bypass_reason )
1383 + : in_array( $bypass_reason, self::REQUEST_SHAPE_BYPASS, true );
1384 + $reason = $shaped ? 'bypass-shape' : 'bypass';
1385 + } elseif ( self::edge_optimization_pending( $context ) ) {
1386 + $reason = 'pending';
1387 + } elseif ( 'MISS' === $status ) {
1388 + $reason = 'miss';
1389 + } elseif ( self::serves_query_variant( $status, $context ) ) {
1390 + // Last, so it only adds a hold where there was none. A variant
1391 + // still waiting for its CSS says `pending`, which is also true,
1392 + // and the CSS modules are still asked on every HIT as before.
1393 + $reason = 'query-variant';
1394 + }
1395 +
1396 + /**
1397 + * Filter: xspeed_edge_hold_reason
1398 + *
1399 + * Return '' to veto a hold, or a reason string to force one.
1400 + *
1401 + * @param string $reason '' or bypass|bypass-shape|miss|mobile-split|pending|query-variant.
1402 + * @param string $status `HIT`, `MISS` or `BYPASS`.
1403 + * @param string $context `request`, `store` or `bake`.
1404 + * @param string $bypass_reason The gate slug, for BYPASS only.
1405 + */
1406 + return (string) apply_filters( 'xspeed_edge_hold_reason', $reason, $status, $context, $bypass_reason );
1407 + }
1408 +
1409 + /**
1410 + * Was this page excluded because it is personal, or because it is
1411 + * plumbing we cannot key a cache entry on?
1412 + *
1413 + * Answers by removing the structural defaults from the site's own
1414 + * exclusion list and asking whether anything is left that matches. So a
1415 + * feed matches only `/feed/` and comes back false; `/my-account/feedback/`
1416 + * matches `/my-account` and comes back true; and on an "almost pretty"
1417 + * permalink site, where every path contains `index.php`, an ordinary page
1418 + * matches nothing else and is correctly treated as public.
1419 + *
1420 + * The path only, never the query string — a visitor writes that, and
1421 + * `/cart/?utm_source=/feed/` must not be able to talk a cart out of its
1422 + * hold. It is also what `should_cache()` matches the list against.
1423 + *
1424 + * Asked for a `query-param` bypass too, because the query gate runs
1425 + * BEFORE the URL gate, so `/cart/?add-to-cart=12` reports `query-param`
1426 + * and never reaches `excluded-url` at all. Which gate fired first says
1427 + * nothing about whose data is on the page.
1428 + */
1429 + private static function path_is_a_personal_exclusion( string $bypass_reason ): bool {
1430 + $path = self::request_path();
1431 + if ( '' === $path ) {
1432 + return false;
1433 + }
1434 +
1435 + // Through Settings_Manager, not the raw option, because the schema's
1436 + // default IS the structural list and a fresh install has never
1437 + // written the option. Read raw, every site that has not visited the
1438 + // settings screen looks like a site with no exclusions at all, takes
1439 + // the contradiction branch below, and reports its feeds as personal.
1440 + //
1441 + // Safe here where `mobile_cache_splits_html()` is not: we are only
1442 + // ever called with a bypass reason, and those come from
1443 + // `should_cache()`, which resolved the same settings through
1444 + // `Settings_Manager::get()` to produce them.
1445 + $opts = Settings_Manager::get( 'cache' );
1446 + $excluded = is_array( $opts['excluded_urls'] ?? null ) ? $opts['excluded_urls'] : array();
1447 + if ( array() === $excluded ) {
1448 + // An `excluded-url` bypass with no exclusion list is a
1449 + // contradiction — something excluded the request and the list
1450 + // cannot say what — so assume personal, because a wasted header
1451 + // costs a little origin traffic while a missing one serves
1452 + // somebody's basket to a stranger. A `query-param` bypass with an
1453 + // empty list is just an ordinary page carrying a parameter, and
1454 + // says nothing about the path at all.
1455 + return 'excluded-url' === $bypass_reason;
1456 + }
1457 +
1458 + $personal = array_values(
1459 + array_filter(
1460 + $excluded,
1461 + static fn ( $pattern ) => ! in_array( (string) $pattern, self::STRUCTURAL_EXCLUSIONS, true )
1462 + )
1463 + );
1464 +
1465 + return array() !== $personal && self::path_matches_exclusions( $personal, $path );
1466 + }
1467 +
1468 + /**
1469 + * The edge/CDN headers to send on a response with this cache status.
1470 + *
1471 + * @param string $status `HIT`, `MISS` or `BYPASS`.
1472 + * @param string $context `request` when resolved per request on the
1473 + * PHP serve path, `store` when resolved for
1474 + * one entry's sidecar, `bake` when resolved
1475 + * once and frozen into the drop-in, `rules`
1476 + * when resolved once for the nginx or Apache
1477 + * static block.
1478 + * @param string $bypass_reason The gate slug, for BYPASS only.
1479 + * @return array<string,string>
1480 + */
1481 + public static function edge_headers_for( string $status, string $context = 'request', string $bypass_reason = '' ): array {
1482 + // `rules` is a bake in every respect but one: the cache-headers
1483 + // filter is told it is answering for a pasted server rule. Such a
1484 + // rule serves every static HIT for as long as it stays pasted, and
1485 + // nothing regenerates it when the site's expiry or a page's own
1486 + // lifetime (a nonce cap) changes, so a lifetime given there cannot
1487 + // follow the page. The drop-in is rewritten by auto_heal(); a paste
1488 + // is not.
1489 + $filter_context = $context;
1490 + if ( 'rules' === $context ) {
1491 + $context = 'bake';
1492 + }
1493 +
1494 + $base = array();
1495 + if ( 'HIT' === $status ) {
1496 + /**
1497 + * Filter: xspeed_edge_cache_headers
1498 + *
1499 + * Response headers to add to a cached HTML response. The lifetime
1500 + * is HIT-only: it is a promise that this copy is worth keeping,
1501 + * and neither a first render nor a page we refused to cache is
1502 + * one. The filter is also asked with `MISS` when a first render
1503 + * is held, and only the `Cache-Tag` from that answer is sent, so
1504 + * a purge can name a copy an edge stored despite the hold.
1505 + *
1506 + * The same filter feeds four regimes and `$context` says which.
1507 + * On the PHP serve path it runs per request (`request`); at store
1508 + * time it runs for one entry (`store`); when the drop-in is
1509 + * generated it runs once (`bake`) and the result answers for
1510 + * every drop-in HIT on the site; when the nginx or Apache static
1511 + * block is generated it runs once (`rules`). Anything per-page — a
1512 + * post id in a cache tag, say — must be skipped under `bake` and
1513 + * `rules`. A server rule serves every static HIT for as long as it
1514 + * stays pasted, so a lifetime here can't follow the page: send
1515 + * none under `rules`.
1516 + *
1517 + * @param array<string,string> $headers Header name => value.
1518 + * @param string $status `HIT`, or `MISS` when only the tag of a held first render is wanted.
1519 + * @param string $context `request`, `store`, `bake` or `rules`.
1520 + */
1521 + $base = self::sanitize_edge_headers( (array) apply_filters( 'xspeed_edge_cache_headers', array(), 'HIT', $filter_context ) );
1522 + }
1523 +
1524 + $reason = self::edge_hold_reason( $status, $context, $bypass_reason );
1525 + if ( '' === $reason ) {
1526 + return $base;
1527 + }
1528 + $detected = Edge_Provider::detect( $context );
1529 + if ( Edge_Provider::is_off( $detected ) ) {
1530 + return $base;
1531 + }
1532 + if ( Edge_Provider::NONE === $detected['confidence']
1533 + && ! in_array( $reason, self::HOLD_WITHOUT_EVIDENCE, true ) ) {
1534 + return $base;
1535 + }
1536 +
1537 + $hold = Edge_Provider::hold_headers( $detected['provider'] );
1538 +
1539 + /**
1540 + * Filter: xspeed_edge_hold_headers
1541 + *
1542 + * The last word on what a hold INSTRUCTS. Runs before sanitising, so
1543 + * a value that cannot be sent as a header is still dropped, and
1544 + * before `X-XSpeed-Edge-Hold` is added, so it cannot rewrite the
1545 + * reason xSpeed held the page for — that is a diagnosis, not an
1546 + * instruction, and a forged one sends a reader after the wrong
1547 + * module.
1548 + *
1549 + * @param array<string,string> $hold Header name => value.
1550 + * @param array<string,string> $detected Provider, confidence, source.
1551 + * @param string $reason Why the hold fired.
1552 + * @param string $context `request`, `store` or `bake`.
1553 + */
1554 + $hold = (array) apply_filters( 'xspeed_edge_hold_headers', $hold, $detected, $reason, $context );
1555 +
1556 + // A hold replaces the lifetime rather than sitting beside it: the two
1557 + // describe the same response and would contradict each other. The
1558 + // cache tag survives, because a later purge still has to be able to
1559 + // name whatever the edge picked up on its own terms.
1560 + //
1561 + // On a HIT the tag came with `$base`. A MISS has no `$base`, and that
1562 + // is the case that mattered most: the first render after any purge.
1563 + // An edge whose rule overrides the origin's lifetime stores it
1564 + // despite the hold, and with no tag on it the next tag purge cannot
1565 + // reach it, so an edit stayed stale for the edge's whole lifetime
1566 + // (QA, 2026-09-23). So a held MISS asks the same filter for its tag
1567 + // and keeps the tag alone; the lifetime in the answer is dropped,
1568 + // because the hold is the instruction for this response.
1569 + $tag = $base['Cache-Tag'] ?? '';
1570 + if ( '' === $tag && 'MISS' === $status ) {
1571 + $tagged = self::sanitize_edge_headers( (array) apply_filters( 'xspeed_edge_cache_headers', array(), 'MISS', $filter_context ) );
1572 + $tag = $tagged['Cache-Tag'] ?? '';
1573 + }
1574 + if ( '' !== $tag ) {
1575 + $hold['Cache-Tag'] = $tag;
1576 + }
1577 +
1578 + // Never argue with a stronger answer WordPress already gave. It sends
1579 + // `no-store, private` of its own accord on a logged-in, 404 or
1580 + // password-protected response, from WP::send_headers() — which runs
1581 + // before template_redirect, so it is already on the wire by the time
1582 + // we get here. Ours is the weaker statement of the two; replacing it
1583 + // would be a downgrade dressed as a fix. Only meaningful per request:
1584 + // a bake has no response to inspect.
1585 + if ( 'request' === $context && isset( $hold['Cache-Control'] ) && self::cache_control_already_stronger() ) {
1586 + unset( $hold['Cache-Control'] );
1587 + }
1588 +
1589 + // A page we refused to cache must not carry a validator either. A
1590 + // `Last-Modified` left on it invites a conditional request, and a
1591 + // shared cache that gets a 304 back serves the copy it should not
1592 + // have stored. Only on a bypass, and only per request: a MISS is
1593 + // about to be stored by us, so its validator is ours to keep.
1594 + if ( 'request' === $context && 'bypass' === $reason && ! headers_sent() ) {
1595 + header_remove( 'Last-Modified' );
1596 + }
1597 +
1598 + $hold = self::sanitize_edge_headers( $hold );
1599 +
1600 + // Name the reason in the hold set itself, rather than sending it
1601 + // separately from mark().
1602 + //
1603 + // "Why is my page not being cached at the edge?" is the question this
1604 + // answers, and mark() could only answer it on the PHP serve path. The
1605 + // other emitters send whatever this function returns and never ran
1606 + // mark() at all — so the responses hardest to explain went out
1607 + // carrying `no-store` with nothing beside it to say why. Chiefly the
1608 + // drop-in, which serves from the `.meta` sidecar written under
1609 + // `store` and from the literal baked under `bake`, before plugins
1610 + // load and with no way to re-ask (the symptom
1611 + // edge_optimization_pending() describes above).
1612 + //
1613 + // The nginx and Apache blocks are a third path in principle and
1614 + // almost never in practice: they are only installed when
1615 + // static_rewrite_allowed() is true, and the one reason a stock site
1616 + // can hold in their bake is `mobile-split`, which is exactly what
1617 + // makes that false. `query-variant` is baked for the drop-in alone,
1618 + // since both blocks refuse a query string. They will carry a hold
1619 + // where a site forces one through `xspeed_edge_hold_reason`, and
1620 + // otherwise have none to carry.
1621 + //
1622 + // Added AFTER sanitising and banned in NEVER_AN_EDGE_HEADER, so
1623 + // neither of the two filters above can forge a reason or suppress the
1624 + // real one.
1625 + //
1626 + // Reduced to the slug CHARACTER CLASS, not checked against the six
1627 + // slugs: `xspeed_edge_hold_reason` is documented as able to force a
1628 + // reason, and a site that forces its own deserves to see it. What is
1629 + // not negotiable is the shape, because this value reaches an
1630 + // .htaccess and an nginx conf as well as a response header — so no
1631 + // CR/LF, no `$`, no `%`, no `\`, and a length a config file can hold.
1632 + $slug = preg_replace( '/[^a-z0-9-]/', '', strtolower( $reason ) );
1633 + if ( is_string( $slug ) && '' !== $slug ) {
1634 + $hold['X-XSpeed-Edge-Hold'] = substr( $slug, 0, 32 );
1635 + }
1636 +
1637 + return $hold;
1638 + }
1639 +
1640 + /** Has something already sent a Cache-Control at least as strict as ours? */
1641 + private static function cache_control_already_stronger(): bool {
1642 + foreach ( headers_list() as $line ) {
1643 + if ( 0 !== stripos( $line, 'cache-control:' ) ) {
1644 + continue;
1645 + }
1646 + if ( preg_match( '/\b(?:no-store|private)\b/i', $line ) ) {
1647 + return true;
1648 + }
1649 + }
1650 +
1651 + return false;
1652 + }
1653 +
1654 + /**
1655 + * Edge headers that belong to THIS page rather than to every page.
1656 + *
1657 + * `edge_headers_for('HIT','bake')` is the answer frozen into the drop-in
1658 + * and the server rules: one set, serving the whole site. But the answer
1659 + * for one URL can legitimately differ — a page whose deferred work is
1660 + * still outstanding, say — and that answer has nowhere to live, because
1661 + * the baked set is all the fast paths know about.
1662 + *
1663 + * So ask again in a `store` context, with the request still in scope, and
1664 + * return the pairs only when they differ from the baked ones. Identical is
1665 + * the overwhelmingly common case and writes nothing: pages do not pay a
1666 + * sidecar for an answer the drop-in already has.
1667 + *
1668 + * Memoised because two callers ask within one store — the sidecar writer
1669 + * and the static-tree guard — and the filters behind it are not required
1670 + * to be cheap.
1671 + *
1672 + * A page with a lifetime of its own also gets its own pairs, whenever
1673 + * they grant an edge any lifetime at all. The lifetime is cut to the
1674 + * page's (see cap_edge_lifetime()), and the pairs are returned even when
1675 + * the cut leaves them equal to the baked ones. Returning them keeps the
1676 + * page off the static tree, and it has to: only the two PHP paths can
1677 + * count a lifetime down as the copy ages. The web server sends the baked
1678 + * value from whatever age the file has reached, so a page capped to its
1679 + * nonce would reach the edge with hours of dead nonce still to serve.
1680 + *
1681 + * write_meta() resolves that lifetime and passes it on the first call.
1682 + * The static-tree guards ask afterwards and read the memo. Both store
1683 + * paths call write_meta() before their guard.
1684 + *
1685 + * @param int|null $own_ttl The entry's own lifetime in seconds, when it
1686 + * differs from the site's (the sidecar `ttl`).
1687 + * @return array<string,string> Empty when this page needs no override.
1688 + */
1689 + private static function per_entry_edge_headers( ?int $own_ttl = null ): array {
1690 + if ( is_array( self::$per_entry_edge ) ) {
1691 + return self::$per_entry_edge;
1692 + }
1693 + $baked = self::edge_headers_for( 'HIT', 'bake' );
1694 + $request = self::edge_headers_for( 'HIT', 'store' );
1695 + if ( null !== $own_ttl && self::grants_edge_lifetime( $request ) ) {
1696 + self::$per_entry_edge = self::cap_edge_lifetime( $request, $own_ttl );
1697 + return self::$per_entry_edge;
1698 + }
1699 + self::$per_entry_edge = ( $request === $baked ) ? array() : $request;
1700 +
1701 + return self::$per_entry_edge;
1702 + }
1703 +
1704 + /**
1705 + * Cut every lifetime an edge header grants down to `$seconds`.
1706 + *
1707 + * The lifetime in a HIT's pairs comes from `xspeed_edge_cache_headers`
1708 + * and describes the site, so it says nothing of a page whose own lifetime
1709 + * is shorter: one carrying a nonce (#236), one with a per-post expiry,
1710 + * one a `xspeed_cache_max_age` filter shortened. xSpeed rebuilds that page
1711 + * on time, and an edge holding it for the site's lifetime goes on serving
1712 + * the old copy anyway. Nothing tells the edge when our copy expires,
1713 + * because an expiry is not a purge. For a nonce page that is a broken
1714 + * form for every visitor until the next purge.
1715 + *
1716 + * Only shortens. A `max-age` or `s-maxage` already under `$seconds` is
1717 + * kept, so a hold's `s-maxage=0` stays 0. Only headers named `*-Control`
1718 + * are touched; a `Cache-Tag` that happens to contain the text is not a
1719 + * directive.
1720 + *
1721 + * @param array<string,string> $headers Header name => value.
1722 + * @param int $seconds The most any of them may grant.
1723 + * @return array<string,string>
1724 + */
1725 + public static function cap_edge_lifetime( array $headers, int $seconds ): array {
1726 + $seconds = max( 0, $seconds );
1727 + foreach ( $headers as $name => $value ) {
1728 + if ( ! preg_match( self::EDGE_LIFETIME_HEADER, (string) $name ) ) {
1729 + continue;
1730 + }
1731 + $capped = preg_replace_callback(
1732 + self::EDGE_LIFETIME_DIRECTIVE,
1733 + static function ( array $m ) use ( $seconds ): string {
1734 + return $m[1] . '=' . min( (int) $m[2], $seconds );
1735 + },
1736 + (string) $value
1737 + );
1738 + if ( is_string( $capped ) ) {
1739 + $headers[ $name ] = $capped;
1740 + }
1741 + }
1742 +
1743 + return $headers;
1744 + }
1745 +
1746 + /**
1747 + * Does any `*-Control` header in the set let an edge keep the response?
1748 + *
1749 + * @param array<string,string> $headers Header name => value.
1750 + */
1751 + private static function grants_edge_lifetime( array $headers ): bool {
1752 + foreach ( $headers as $name => $value ) {
1753 + if ( ! preg_match( self::EDGE_LIFETIME_HEADER, (string) $name ) ) {
1754 + continue;
1755 + }
1756 + if ( preg_match_all( self::EDGE_LIFETIME_DIRECTIVE, (string) $value, $m ) ) {
1757 + foreach ( $m[2] as $seconds ) {
1758 + if ( (int) $seconds > 0 ) {
1759 + return true;
1760 + }
1761 + }
1762 + }
1763 + }
1764 +
1765 + return false;
1766 + }
1767 +
1768 + /**
1769 + * Seconds a cached entry has left, when it has a lifetime of its own.
1770 + *
1771 + * Read from the sidecar `ttl`, which write_meta() records only when the
1772 + * entry's lifetime differs from the site's. That is the drop-in's rule
1773 + * too, so both PHP serve paths count down the same entries. Null for an
1774 + * entry that follows the site's lifetime: what the edge is told about
1775 + * those is the site's lifetime, unchanged.
1776 + *
1777 + * @param array<string,mixed> $meta The entry's sidecar, from read_meta().
1778 + * @param string $file The cached file being served.
1779 + */
1780 + private static function entry_lifetime_left( array $meta, string $file ): ?int {
1781 + $ttl = isset( $meta['ttl'] ) ? (int) $meta['ttl'] : 0;
1782 + if ( $ttl < 1 ) {
1783 + return null;
1784 + }
1785 + $mtime = file_exists( $file ) ? filemtime( $file ) : false;
1786 + if ( false === $mtime ) {
1787 + return null;
1788 + }
1789 +
1790 + return max( 0, $ttl - ( time() - (int) $mtime ) );
1791 + }
1792 +
1793 + /**
1794 + * Render baked pairs as a PHP array literal for the drop-in.
1795 + *
1796 + * Single-quoted literals with quotes escaped, because the result is
1797 + * written into a PHP file that must still parse. Values reaching here
1798 + * have already been through sanitize_edge_headers(), so neither name nor
1799 + * value can carry a newline.
1800 + *
1801 + * @param array<string,string> $headers Name => value.
1802 + */
1803 + private static function edge_headers_literal( array $headers ): string {
1804 + if ( array() === $headers ) {
1805 + return 'array()';
1806 + }
1807 + // var_export(), not hand-rolled quoting. A single-quoted PHP string
1808 + // escapes BOTH `'` and `\\`, and escaping only the first is how a
1809 + // value ending in a backslash — `X-Foo: C:\path\` from the custom
1810 + // headers box — leaves the literal unterminated. That file is
1811 + // included on every request once WP_CACHE is on, so the result is a
1812 + // parse error on the front end AND in wp-admin, with no way back
1813 + // except deleting the file over SSH.
1814 + $parts = array();
1815 + foreach ( $headers as $name => $value ) {
1816 + $parts[] = var_export( (string) $name, true ) . ' => ' . var_export( (string) $value, true );
1817 + }
1818 +
1819 + return 'array( ' . implode( ', ', $parts ) . ' )';
1820 + }
1821 +
1822 + /**
1823 + * Quote a header value for an nginx / Apache directive.
1824 + *
1825 + * Both accept a double-quoted string with backslash escapes, and both
1826 + * refuse to load a config where the quoting is wrong — a mis-escaped
1827 + * value takes the whole vhost down, not just this header.
1828 + */
1829 + private static function quote_directive_value( string $value ): string {
1830 + return str_replace( array( '\\', '"' ), array( '\\\\', '\\"' ), $value );
1831 + }
1832 +
1833 + /**
1834 + * The same directive twice — once per name Apache can expose the
1835 + * rewrite's environment variable under.
1836 + *
1837 + * `RewriteRule ... [E=XSPEED_STATIC_HIT:1]` in a per-directory context is
1838 + * an INTERNAL REDIRECT: Apache re-enters the request with the substituted
1839 + * path, and every variable set on the first pass is renamed with a
1840 + * `REDIRECT_` prefix for the second. `env=XSPEED_STATIC_HIT` is evaluated
1841 + * on that second pass, where nothing answers to that name any more, so
1842 + * the directive never fires — dropping the headers from precisely the
1843 + * responses they exist for. The rules marker rides on the same gate, so
1844 + * the probe also read its own marker as missing and called correctly
1845 + * installed rules stale.
1846 + *
1847 + * It cannot be written once: `env=` takes a single name with no
1848 + * alternation, and `expr=` — which could express both — is not dependable
1849 + * on LiteSpeed, which reads this same block. So both are emitted; the one
1850 + * whose variable is unset on a given pass does nothing.
1851 + *
1852 + * @param string $directive The directive, without its `env=` clause.
1853 + * @return string[]
1854 + */
1855 + private static function static_hit_directives( string $directive ): array {
1856 + return array(
1857 + $directive . ' env=XSPEED_STATIC_HIT',
1858 + $directive . ' env=REDIRECT_XSPEED_STATIC_HIT',
1859 + );
1860 + }
1861 +
1862 + /**
1863 + * Keep only pairs that can be sent as a header verbatim.
1864 + *
1865 + * These values reach three different emitters — PHP's header(), an nginx
1866 + * `add_header` and an Apache `Header always set` — so a name with a space
1867 + * or a value carrying CR/LF is not merely malformed, it is a
1868 + * response-splitting vector in the first and a broken server config in
1869 + * the other two. Names must be token-shaped; values lose CR/LF and are
1870 + * dropped if nothing survives.
1871 + *
1872 + * @param array<mixed,mixed> $headers Raw pairs.
1873 + * @return array<string,string>
1874 + */
1875 + public static function sanitize_edge_headers( array $headers ): array {
1876 + $clean = array();
1877 + foreach ( $headers as $name => $value ) {
1878 + // Never let one of these through, whoever asked. They describe the
1879 + // transfer rather than the caching policy, and getting one wrong
1880 + // from a settings field is a white screen: `Content-Encoding: gzip`
1881 + // on an uncompressed body, a `Content-Length` that disagrees with
1882 + // the bytes. `X-XSpeed-Cache` is ours and a second copy would lie
1883 + // to whoever reads it.
1884 + if ( is_string( $name ) && in_array( strtolower( $name ), self::NEVER_AN_EDGE_HEADER, true ) ) {
1885 + continue;
1886 + }
1887 + // `\z`, not `$`: PCRE's `$` also matches immediately BEFORE a
1888 + // trailing newline, so "Cache-Tag\n" passes a `$` check and gets
1889 + // concatenated raw into the generated .htaccess — splitting one
1890 + // Header directive across two lines, which is a syntax error
1891 + // Apache reports as a 500 on every request while `httpd -t` stays
1892 + // green (.htaccess is parsed per request, not at load).
1893 + if ( ! is_string( $name ) || ! preg_match( '/^[A-Za-z0-9-]+\z/', $name ) ) {
1894 + continue;
1895 + }
1896 + if ( ! is_string( $value ) && ! is_numeric( $value ) ) {
1897 + continue;
1898 + }
1899 + $value = trim( str_replace( array( "\r", "\n" ), '', (string) $value ) );
1900 + if ( '' === $value ) {
1901 + continue;
1902 + }
1903 + // `$` is a variable reference in an nginx string and `%` is a
1904 + // format tag to Apache's mod_headers, which rejects an
1905 + // unrecognised one — in .htaccess that is a 500 on every request
1906 + // while `httpd -t` still reports OK, because .htaccess is parsed
1907 + // per request. `\` escapes the quote in the PHP literal baked into
1908 + // the drop-in. None of them can be escaped reliably in all three
1909 + // places at once, and nothing a cache reads needs any of them, so
1910 + // the value is dropped rather than mangled.
1911 + if ( preg_match( '/[$%\\\\]/', $value ) ) {
1912 + continue;
1913 + }
1914 + $clean[ $name ] = $value;
1915 + }
1916 +
1917 + return $clean;
1918 + }
1919 +
1920 + /**
461 1921 * Bypass gates that describe THE VISITOR rather than THIS REQUEST.
462 1922 *
463 1923 * Only these may be recorded in the bypass cookie. A visitor-scoped
464 1924 * verdict stays true for the visitor's next request — they are still
@@ -519,9 +1979,19 @@
519 1979 $key = self::cache_key();
520 1980 $file = self::cache_file_for( $key );
521 1981
522 1982 if ( file_exists( $file ) && ! self::is_expired( $file ) ) {
523 - Hit_Counter::record_hit();
1983 + // Symmetric with the miss branch below: a bot, scanner or one of
1984 + // xSpeed's own warm/benchmark requests that lands a HIT must not
1985 + // inflate the ratio either — excluding only their misses would
1986 + // shrink the denominator while their hits kept feeding the
1987 + // numerator, making the displayed ratio MORE optimistic than
1988 + // before the exclusion existed.
1989 + if ( self::miss_is_excluded() ) {
1990 + Hit_Counter::record_excluded();
1991 + } else {
1992 + Hit_Counter::record_hit();
1993 + }
524 1994 // Emit the HIT marker on THIS path too. The drop-in
525 1995 // (advanced-cache.php) sends "HIT (php)" and the nginx static
526 1996 // rewrite sends "HIT (nginx)", but this template_redirect
527 1997 // serve path — the one that runs when the drop-in isn't loaded
@@ -527,14 +1997,18 @@
527 1997 // serve path — the one that runs when the drop-in isn't loaded
528 1998 // (e.g. WP_CACHE not true) — previously streamed the cached
529 1999 // file with NO marker, so a genuine HIT looked like a MISS in
530 2000 // the response headers. Same header + value as the drop-in.
531 - self::mark( 'HIT (php)' );
2001 + //
2002 + // The sidecar is read first because the HIT's edge lifetime
2003 + // depends on it: an entry with a lifetime of its own may not be
2004 + // kept at the edge past it. See entry_lifetime_left().
2005 + $meta = self::read_meta( $key );
2006 + self::mark( 'HIT (php)', '', self::entry_lifetime_left( $meta, $file ) );
532 2007 // Replay stored response bits so the HIT matches the original:
533 2008 // a non-HTML Content-Type (cached feeds, sitemaps) and a non-200
534 2009 // status (a cached 404 must serve 404, not 200). No-op for
535 2010 // ordinary pages, which write no .meta.
536 - $meta = self::read_meta( $key );
537 2011 if ( ! headers_sent() ) {
538 2012 if ( ! empty( $meta['status'] ) && function_exists( 'http_response_code' ) ) {
539 2013 http_response_code( (int) $meta['status'] );
540 2014 }
@@ -597,8 +2071,9 @@
597 2071 // explicit shutdown close so the buffer lifecycle is visible to
598 2072 // reviewers and Plugin Check, instead of relying on PHP's implicit
599 2073 // request-end flush. We record our nesting level so close_buffer()
600 2074 // flushes ONLY the buffer we opened.
2075 + self::$asset_stamp_at_open = class_exists( '\\XSpeed\\Minifier' ) ? Minifier::purge_stamp() : null;
601 2076 ob_start( array( __CLASS__, 'finalize_buffer' ) );
602 2077 self::$buffer_level = ob_get_level();
603 2078
604 2079 add_action( 'shutdown', array( __CLASS__, 'close_buffer' ), 0 );
@@ -701,8 +2176,11 @@
701 2176
702 2177 $completed = self::$render_completed;
703 2178 self::$render_completed = false;
704 2179
2180 + $asset_stamp = self::$deferred_asset_stamp;
2181 + self::$deferred_asset_stamp = null;
2182 +
705 2183 if ( null === $key ) {
706 2184 return;
707 2185 }
708 2186
@@ -753,8 +2231,14 @@
753 2231 if ( self::query_string_blocks_write() ) {
754 2232 return;
755 2233 }
756 2234
2235 + // Same guard as finalize_buffer(): the translation plugin's buffer
2236 + // adds time between render and store, not less.
2237 + if ( self::assets_purged_since( $asset_stamp ) ) {
2238 + return;
2239 + }
2240 +
757 2241 $file = self::cache_file_for( $key );
758 2242 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context for credentials; this runs on a frontend shutdown where it's unavailable.
759 2243 file_put_contents( $file, $full, LOCK_EX );
760 2244
@@ -765,19 +2249,46 @@
765 2249
766 2250 // Static tree too, under the same gates finalize_buffer() applies —
767 2251 // otherwise deferring the write would silently cost translated pages
768 2252 // the web-server fast path and leave them on the slower drop-in.
769 - if ( self::static_rewrite_allowed() && self::response_is_plain_html() ) {
2253 + // The static tree cannot replay a sidecar. A file served straight by
2254 + // the web server carries the headers baked into the rule that serves
2255 + // the whole site — the very answer this entry exists because it
2256 + // disagreed with. Same reasoning as the status and content-type
2257 + // cases: what the fast path cannot replay belongs on the drop-in path.
2258 + if ( self::static_rewrite_allowed()
2259 + && self::response_is_plain_html()
2260 + && array() === self::per_entry_edge_headers() ) {
770 2261 self::store_static( $full );
771 2262 }
772 2263 }
773 2264
2265 + /**
2266 + * Did a purge delete minified or combined files after $stamp was taken?
2267 + *
2268 + * @param string|null $stamp Minifier::purge_stamp() when the render began,
2269 + * or null when no snapshot was taken.
2270 + */
2271 + private static function assets_purged_since( ?string $stamp ): bool {
2272 + if ( null === $stamp || ! class_exists( '\\XSpeed\\Minifier' ) ) {
2273 + return false;
2274 + }
2275 + return Minifier::purge_stamp() !== $stamp;
2276 + }
2277 +
774 2278 public static function should_cache() {
775 2279 // Reset first: a single request only reaches this once (the sole
776 2280 // caller is maybe_start_cache()), but tests and any future caller
777 2281 // must never inherit the previous request's verdict.
778 - self::$status_header = '';
779 - self::$bypass_reason = '';
2282 + self::$status_header = '';
2283 + self::$bypass_reason = '';
2284 + self::$edge_headers = array();
2285 + self::$per_entry_edge = null;
2286 + // Under PHP-FPM a process serves one request and this is moot. Under
2287 + // a persistent worker runtime it is not: without it, an answer
2288 + // resolved from one visitor's forgeable headers would be reused for
2289 + // every later request the worker handles.
2290 + Edge_Provider::forget();
780 2291
781 2292 $opts = Settings::get();
782 2293 if ( empty( $opts['cache_enabled'] ) ) {
783 2294 return self::bypass( 'cache-disabled' );
@@ -828,8 +2339,20 @@
828 2339 * @param bool $cache_feed Whether to cache this feed request.
829 2340 */
830 2341 $cache_feed = $is_feed_request && (bool) apply_filters( 'xspeed_should_cache_feed', false );
831 2342
2343 + // WordPress's virtual robots.txt (and virtual favicon) are not HTML:
2344 + // caching one runs it through the whole HTML pipeline, which stamped
2345 + // the footer comment onto text/plain and let HTML minification
2346 + // collapse robots.txt to a single line — a line-based format, so
2347 + // every directive after the first was lost and crawlers read an
2348 + // invalid file. No opt-in filter here: there is no correct way to
2349 + // treat these as pages. (Reported live on a customer site.)
2350 + if ( ( function_exists( 'is_robots' ) && is_robots() )
2351 + || ( function_exists( 'is_favicon' ) && is_favicon() ) ) {
2352 + return self::bypass( 'non-html' );
2353 + }
2354 +
832 2355 // Query string handling: anything OUTSIDE the ignored-params
833 2356 // allow-list (utm_*, fbclid, gclid by default) means a unique
834 2357 // request that we don't want to share with the canonical cache
835 2358 // entry. Skip cache rather than poison the key.
@@ -853,9 +2376,9 @@
853 2376 continue;
854 2377 }
855 2378 // Allow query-form feed params through when feed caching opted
856 2379 // this request in (?feed=rss2 / &withcomments=1 on feeds).
857 - if ( $cache_feed && in_array( $key, array( 'feed', 'withcomments', 'withoutcomments' ), true ) ) {
2380 + if ( $cache_feed && in_array( $key, self::FEED_QUERY_PARAMS, true ) ) {
858 2381 continue;
859 2382 }
860 2383 if ( ! self::query_key_is_ignored( (string) $key, $ignored ) ) {
861 2384 // Slug only — never the param name, which is attacker-
@@ -864,13 +2387,12 @@
864 2387 }
865 2388 }
866 2389 }
867 2390
868 - $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
869 - $path = (string) strtok( $request_uri, '?' );
2391 + $path = self::request_path();
870 2392
871 2393 $excluded_urls = is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array();
872 - if ( ! $cache_feed && Glob_Matcher::any_match( $excluded_urls, $path ) ) {
2394 + if ( ! $cache_feed && self::path_matches_exclusions( $excluded_urls, $path ) ) {
873 2395 return self::bypass( 'excluded-url' );
874 2396 }
875 2397
876 2398 // Cookie-based exclusion. We only check cookie NAMES (matching
@@ -1018,8 +2540,32 @@
1018 2540 return '' !== trim( $query );
1019 2541 }
1020 2542
1021 2543 /**
2544 + * Whether this request's query string is empty or holds only params the
2545 + * cache ignores (`ignored_query_params`, utm_* and the like), so the
2546 + * page is the one the bare URL serves.
2547 + *
2548 + * The same test should_cache() applies, without its search and feed
2549 + * exceptions: those are keyed apart from the bare URL.
2550 + */
2551 + public static function query_has_only_ignored_params(): bool {
2552 + $query_raw = isset( $_SERVER['QUERY_STRING'] ) ? wp_unslash( $_SERVER['QUERY_STRING'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- as in should_cache(): only keys are read, via preg_match.
2553 + if ( '' === trim( (string) $query_raw ) ) {
2554 + return true;
2555 + }
2556 + $cache_opts = Settings_Manager::get( 'cache' );
2557 + $ignored = is_array( $cache_opts['ignored_query_params'] ?? null ) ? $cache_opts['ignored_query_params'] : array();
2558 + parse_str( (string) $query_raw, $params );
2559 + foreach ( $params as $key => $_ ) {
2560 + if ( ! self::query_key_is_ignored( (string) $key, $ignored ) ) {
2561 + return false;
2562 + }
2563 + }
2564 + return true;
2565 + }
2566 +
2567 + /**
1022 2568 * Would authoring a cache entry from THIS request file a query-string
1023 2569 * render under the bare URL?
1024 2570 *
1025 2571 * The one predicate both write sites ask, so they cannot drift.
@@ -1108,11 +2654,238 @@
1108 2654 * `~utm_…` default vs `my_utm_source`. A param name that is genuinely
1109 2655 * unknown now bypasses the cache, which is the safe direction.
1110 2656 */
1111 2657 private static function query_key_is_ignored( string $key, array $ignored ): bool {
2658 + if ( in_array( $key, self::NEVER_IGNORED_QUERY_PARAMS, true ) ) {
2659 + return false;
2660 + }
1112 2661 return Glob_Matcher::any_match_name( $ignored, $key );
1113 2662 }
1114 2663
2664 + /**
2665 + * Query params no ignored-params entry can match, glob or regex.
2666 + *
2667 + * A measuring request asks for the page as it is before optimisation
2668 + * (`xspeed_css=off`), with a one-time value (`xspeed_nc`) so no cache
2669 + * has a copy of it. The answer must be rendered for that request. A
2670 + * list entry such as `xspeed_*` or `*` would make both params
2671 + * decoration: the drop-in would serve the canonical, already-optimised
2672 + * entry before any plugin loads, and the measurement would describe
2673 + * the optimised page instead of the source.
2674 + */
2675 + public const NEVER_IGNORED_QUERY_PARAMS = array( 'xspeed_css', 'xspeed_nc' );
2676 +
2677 + /**
2678 + * The params a query-form feed may carry (`/?feed=rss2`) once feed
2679 + * caching has opted the request in. should_cache() lets them through,
2680 + * and serves_query_variant() holds a HIT that carries one.
2681 + */
2682 + private const FEED_QUERY_PARAMS = array( 'feed', 'withcomments', 'withoutcomments' );
2683 +
2684 + /**
2685 + * The one spelling of a URL path that every cache key is built from.
2686 + *
2687 + * WordPress stores a non-ASCII slug percent-encoded, so a page called
2688 + * `關於我們` is requested as `/%e9%97%9c%e6%96%bc%e6%88%91%e5%80%91/`. The
2689 + * same page also arrives as `/%E9%97%9C…/` (what a browser sends for an
2690 + * address typed or pasted into it) and as raw UTF-8 bytes. WordPress
2691 + * renders the same page for all three, so they get one key: escapes are
2692 + * lowercased (WordPress's own spelling, so a permalink comes back
2693 + * unchanged) and any byte outside printable ASCII is escaped the same way.
2694 + * Null bytes are dropped, as the drop-in does.
2695 + *
2696 + * Nothing is decoded. `/ab%6Fut/` stays a separate entry from `/about/`.
2697 + * Only spellings of the same bytes are merged, because merging spellings
2698 + * WordPress may route differently would let one page be served for
2699 + * another. A printable-ASCII path with no escapes comes back unchanged,
2700 + * so ordinary pages keep the keys they had.
2701 + *
2702 + * The path used to go through sanitize_text_field(), which deletes every
2703 + * `%XX` octet: `/第九屆-當代藝術-tagboat-award-特展/` was keyed as
2704 + * `/--tagboat-award-/`, every page whose slug differed only in non-ASCII
2705 + * characters shared one entry, and an all-non-ASCII slug became `//`,
2706 + * which store_static() wrote over the home page's static file.
2707 + *
2708 + * The drop-in carries a copy of this transform, because it runs before
2709 + * WordPress and this class load. Change both together.
2710 + *
2711 + * @param string $path A URL path, without the query string.
2712 + */
2713 + public static function normalize_path( string $path ): string {
2714 + return self::path_with_escape_case( $path, false );
2715 + }
2716 +
2717 + /**
2718 + * The spellings of one path that a cache in front of the site may hold.
2719 + *
2720 + * xSpeed keys every escape spelling of a path as one entry
2721 + * (normalize_path()). A CDN does not: Cloudflare keeps `/%E9%97%9C…/`,
2722 + * the spelling a browser sends, apart from `/%e9%97%9c…/`, the spelling
2723 + * of WordPress's own links. A purge that named one left the other
2724 + * serving the old page until its edge lifetime ran out.
2725 + *
2726 + * Returns the path as given, then normalize_path()'s spelling (lower-case
2727 + * escapes), then the same with upper-case escapes. Both are built from
2728 + * the path the local sweep cleared, so a cache in front is never asked
2729 + * about a page xSpeed did not purge. Duplicates drop out, so a path with
2730 + * no escapes has one spelling. Trailing-slash forms are the caller's job.
2731 + *
2732 + * @param string $path A URL path, without the query string.
2733 + * @return string[]
2734 + */
2735 + public static function escape_spellings( string $path ): array {
2736 + return array_values(
2737 + array_unique(
2738 + array( $path, self::path_with_escape_case( $path, false ), self::path_with_escape_case( $path, true ) )
2739 + )
2740 + );
2741 + }
2742 +
2743 + /**
2744 + * normalize_path() with the escapes in the case asked for.
2745 + *
2746 + * @param string $path A URL path, without the query string.
2747 + * @param bool $upper Upper-case escapes, or lower (the key's spelling).
2748 + */
2749 + private static function path_with_escape_case( string $path, bool $upper ): string {
2750 + return (string) preg_replace_callback(
2751 + '/%[0-9a-fA-F]{2}|[^\x21-\x7E]/',
2752 + static function ( array $m ) use ( $upper ): string {
2753 + $escape = '%' === $m[0][0] ? $m[0] : sprintf( '%%%02x', ord( $m[0] ) );
2754 + return $upper ? strtoupper( $escape ) : strtolower( $escape );
2755 + },
2756 + str_replace( "\0", '', $path )
2757 + );
2758 + }
2759 +
2760 + /**
2761 + * Percent-encode every byte 0x80 and up, lowercase hex.
2762 + *
2763 + * @param string $value A path or a whole URL.
2764 + */
2765 + private static function encode_non_ascii( string $value ): string {
2766 + return (string) preg_replace_callback(
2767 + '/[\x80-\xff]/',
2768 + static function ( array $m ): string {
2769 + return '%' . bin2hex( $m[0] );
2770 + },
2771 + $value
2772 + );
2773 + }
2774 +
2775 + /**
2776 + * Where a URL path lives in the static tree, relative to the host
2777 + * directory, with a leading slash and no trailing one ('' for the home
2778 + * page). Null when the path must not be written there.
2779 + *
2780 + * Decoded, because that is what the web server looks up: nginx builds the
2781 + * file name from `$uri` and Apache from `%{REQUEST_URI}`, and both hold
2782 + * the decoded path. A file stored under the encoded name is never found.
2783 + *
2784 + * Decoding is only safe for the escapes WordPress itself writes, which
2785 + * encode the bytes of non-ASCII letters (0x80 and up). An escaped ASCII
2786 + * byte is refused, because the server decodes it before the lookup:
2787 + * `%2F` would become a directory, `%2E%2E` a parent, and `%6F` would
2788 + * make `/ab%6Fut/` and `/about/` share one file although they are
2789 + * separate cache entries. The decoded path must also be valid UTF-8, with
2790 + * no control byte, backslash or `%`, no `..` anywhere and no `.`
2791 + * segment. A refused page is still cached by the drop-in; it only loses
2792 + * the no-PHP path.
2793 + *
2794 + * @param string $path A URL path, without the query string.
2795 + */
2796 + public static function static_path( string $path ): ?string {
2797 + if ( '' === $path || false !== strpos( $path, "\0" ) ) {
2798 + return null;
2799 + }
2800 + if ( preg_match_all( '/%([0-9a-fA-F]{2})/', $path, $escapes ) ) {
2801 + foreach ( $escapes[1] as $hex ) {
2802 + if ( hexdec( $hex ) < 0x80 ) {
2803 + return null;
2804 + }
2805 + }
2806 + }
2807 + $decoded = rawurldecode( $path );
2808 + if ( 1 !== preg_match( '//u', $decoded ) || preg_match( '/[\x00-\x1f\x7f\\\\%]/', $decoded ) ) {
2809 + return null;
2810 + }
2811 + $decoded = (string) preg_replace( '#/+#', '/', $decoded );
2812 + if ( false !== strpos( $decoded, '..' ) || preg_match( '#(^|/)\.(/|$)#', $decoded ) ) {
2813 + return null;
2814 + }
2815 + return rtrim( $decoded, '/' );
2816 + }
2817 +
2818 + /**
2819 + * The current request's path, normalized, without the query string.
2820 + *
2821 + * @param string $fallback Returned when the server sent no REQUEST_URI.
2822 + */
2823 + private static function request_path( string $fallback = '' ): string {
2824 + if ( ! isset( $_SERVER['REQUEST_URI'] ) ) {
2825 + return $fallback;
2826 + }
2827 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- normalize_path() is the sanitizer. sanitize_text_field() deletes percent-encoded octets and broke every non-ASCII slug (see normalize_path()). The value is hashed, matched, or checked by static_path() before it touches the disk.
2828 + $uri = (string) wp_unslash( $_SERVER['REQUEST_URI'] );
2829 + return self::normalize_path( (string) strtok( $uri, '?' ) );
2830 + }
2831 +
2832 + /**
2833 + * An Excluded URLs entry with its escapes in lower case.
2834 + *
2835 + * The request path is matched in normalize_path()'s spelling, so an entry
2836 + * copied from Chrome's address bar (`/%E8%81%AF…/`) never matched it.
2837 + * Only `%XX` sequences change; the rest of the entry, including a `~`
2838 + * regex, is left as typed.
2839 + *
2840 + * @param string $pattern One Excluded URLs entry.
2841 + */
2842 + public static function normalize_exclusion( string $pattern ): string {
2843 + return (string) preg_replace_callback(
2844 + '/%[0-9a-fA-F]{2}/',
2845 + static function ( array $m ): string {
2846 + return strtolower( $m[0] );
2847 + },
2848 + $pattern
2849 + );
2850 + }
2851 +
2852 + /**
2853 + * Does an exclusion list match this path, in either spelling?
2854 + *
2855 + * An admin may paste an exclusion as `/購物車`, as WordPress spells it, or
2856 + * as Chrome shows it in upper case. The entry's escapes are lowercased to
2857 + * match the path, and the decoded path is tried too, which is also what
2858 + * the nginx rule matches (`$uri` is decoded).
2859 + *
2860 + * @param string[] $patterns Exclusion entries.
2861 + * @param string $path Output of normalize_path().
2862 + */
2863 + private static function path_matches_exclusions( array $patterns, string $path ): bool {
2864 + $patterns = array_map(
2865 + static function ( $pattern ): string {
2866 + return self::normalize_exclusion( (string) $pattern );
2867 + },
2868 + $patterns
2869 + );
2870 + if ( Glob_Matcher::any_match( $patterns, $path ) ) {
2871 + return true;
2872 + }
2873 + $decoded = rawurldecode( $path );
2874 + return $decoded !== $path && Glob_Matcher::any_match( $patterns, $decoded );
2875 + }
2876 +
2877 + /**
2878 + * Whether this request's path matches an Excluded URLs list, by the
2879 + * page cache's own rule. Turbo Render asks it when it buffers the page
2880 + * itself (page cache off), so both read an entry the same way.
2881 + *
2882 + * @param string[] $patterns Exclusion entries.
2883 + */
2884 + public static function request_matches_exclusions( array $patterns ): bool {
2885 + return self::path_matches_exclusions( $patterns, self::request_path() );
2886 + }
2887 +
1115 2888 public static function cache_key() {
1116 2889 $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : 'default';
1117 2890
1118 2891 // Cacheable 404s share ONE generic per-host entry — keying them by
@@ -1122,14 +2895,13 @@
1122 2895 if ( self::should_cache_404() ) {
1123 2896 return md5( $host . '|404' );
1124 2897 }
1125 2898
1126 - $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/';
1127 2899 // Strip the query string from the key so /post and /post?utm_*=…
1128 2900 // share the same cache entry. should_cache() above already
1129 2901 // rejected requests with non-ignored params, so by the time we
1130 2902 // build the key the only params left are safe to drop.
1131 - $uri = (string) strtok( $uri, '?' );
2903 + $uri = self::request_path( '/' );
1132 2904
1133 2905 // Optional device bucket: when mobile_separate is on, mobile and
1134 2906 // desktop responses live in different cache files so themes that
1135 2907 // serve different HTML by device (AMP, WPtouch, Jetpack mobile)
@@ -1394,9 +3166,10 @@
1394 3166 * exactly this blog's pages.
1395 3167 */
1396 3168 public static function current_static_scope(): string {
1397 3169 // Same switch_to_blog() caveat as current_host_dir() — see current_host().
1398 - $dir = self::host_dir( self::current_host() );
3170 + // Keep the port folded into the segment exactly as store_static() does.
3171 + $dir = self::static_host_dir( self::current_host() );
1399 3172 if ( '' === $dir ) {
1400 3173 $dir = 'default';
1401 3174 }
1402 3175 $path = self::site_path_raw();
@@ -1409,10 +3182,9 @@
1409 3182 * delete indiscriminately).
1410 3183 */
1411 3184 public static function current_host_dir(): string {
1412 3185 $host = self::current_host();
1413 - $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/';
1414 - return self::site_bucket( $host, $uri );
3186 + return self::site_bucket( $host, self::request_path( '/' ) );
1415 3187 }
1416 3188
1417 3189 /**
1418 3190 * The host the CURRENT blog is served from.
@@ -1768,8 +3540,19 @@
1768 3540 * and returns true (caller should exit without a body). Returns false to
1769 3541 * proceed with a normal 200 body. Lets aggregators/browsers skip
1770 3542 * re-downloading an unchanged cached response. (FBS-82407 #5)
1771 3543 *
3544 + * Also stamps `X-XSpeed-Built` from the same mtime. This is the PHP serve
3545 + * path's build time, and the only place on it that holds the file: mark()
3546 + * has already run by the time we get here and resolved the edge-header
3547 + * filter without one. Both are emitted, and a purge verifier prefers the
3548 + * stamp: `Last-Modified` carries the same integer, but it is a generic
3549 + * header that the origin-side cache layer such a verifier exists to
3550 + * detect — or any proxy in between — may rewrite to its own store time,
3551 + * which would turn a stale origin into a pass. Nothing but xSpeed's own
3552 + * serve code writes `X-XSpeed-Built`, so a value older than the purge is
3553 + * proof the origin answered with old HTML.
3554 + *
1772 3555 * @param string $file Absolute path to the cache .html file.
1773 3556 * @return bool True when a 304 was sent.
1774 3557 */
1775 3558 public static function serve_not_modified( string $file ): bool {
@@ -1780,8 +3563,12 @@
1780 3563 $last_modified = gmdate( 'D, d M Y H:i:s', $mtime ) . ' GMT';
1781 3564 $etag = '"' . md5( $file . '|' . $mtime ) . '"';
1782 3565 header( 'Last-Modified: ' . $last_modified );
1783 3566 header( 'ETag: ' . $etag );
3567 + // Before the 304 branch below, so a conditional request that gets a
3568 + // bodyless 304 still carries the stamp. A verifier's fetch may well
3569 + // be conditional, and a 304 with no build time reads as unverifiable.
3570 + header( self::BUILT_HEADER . ': ' . $mtime );
1784 3571
1785 3572 $ims = isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ) ) : '';
1786 3573 $inm = isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_NONE_MATCH'] ) ) ) : '';
1787 3574
@@ -1886,8 +3673,13 @@
1886 3673
1887 3674 $full = self::$accumulated;
1888 3675 self::$accumulated = '';
1889 3676
3677 + // Consumed here, on every final path, so a later call can never
3678 + // compare against this request's snapshot.
3679 + $asset_stamp = self::$asset_stamp_at_open;
3680 + self::$asset_stamp_at_open = null;
3681 +
1890 3682 if ( strlen( $full ) < 255 ) {
1891 3683 return $buffer;
1892 3684 }
1893 3685
@@ -1922,11 +3714,23 @@
1922 3714 * The Preload module uses this to inject the LCP-image <link rel=preload>
1923 3715 * + preconnect hints and add fetchpriority="high" to the hero <img>.
1924 3716 * Keep listeners fast and idempotent; this is the on-wire body.
1925 3717 *
3718 + * A result with fewer than 255 non-whitespace bytes is treated as a
3719 + * failed listener and replaced by the rendered page.
3720 + *
1926 3721 * @param string $full Complete page HTML.
1927 3722 */
1928 - $full = (string) apply_filters( 'xspeed_cache_final_html', $full );
3723 + $rendered = $full;
3724 + $full = apply_filters( 'xspeed_cache_final_html', $full );
3725 +
3726 + // A listener whose preg_* call fails returns null, which a cast turns
3727 + // into ''. That empty string used to be signed, sent and cached, and
3728 + // nginx then served the signature comment as a HIT to every visitor
3729 + // until purge. Same floor as the rendered page above. (#723)
3730 + if ( ! is_string( $full ) || strlen( trim( $full ) ) < 255 ) {
3731 + $full = $rendered;
3732 + }
1929 3733 if ( $single_chunk ) {
1930 3734 $buffer = $full;
1931 3735 }
1932 3736
@@ -1983,8 +3787,16 @@
1983 3787 // deferred writer has always placed its own copy of the guard.
1984 3788 if ( self::query_string_blocks_write() ) {
1985 3789 return $buffer;
1986 3790 }
3791 +
3792 + // A purge deleted minified or combined files while this page was
3793 + // rendering, so it may link names that are gone. Serve it, but do
3794 + // not store it; the next request renders against the files as they
3795 + // are now.
3796 + if ( self::assets_purged_since( $asset_stamp ) ) {
3797 + return $buffer;
3798 + }
1987 3799 $file = self::cache_file_for( $key );
1988 3800
1989 3801 // A render-time translation plugin (TranslatePress) wraps our buffer,
1990 3802 // so the bytes we hold here are still UNTRANSLATED — its callback has
@@ -1992,9 +3804,10 @@
1992 3804 // and bake in its internal #TRPLINKPROCESSED markers. Hand off to
1993 3805 // shutdown, where the outer buffer has already translated, and let
1994 3806 // the pass-through below deliver this request untouched.
1995 3807 if ( self::translation_plugin_active() ) {
1996 - self::$deferred_key = $key;
3808 + self::$deferred_key = $key;
3809 + self::$deferred_asset_stamp = $asset_stamp;
1997 3810 // Reaching here means finalize_buffer() ran to completion: the
1998 3811 // status gate passed, should_cache() said yes, and PHP handed us
1999 3812 // the whole buffer. A wp_die() or exit() mid-render unwinds the
2000 3813 // buffer stack WITHOUT calling this callback, so the flag stays
@@ -2007,10 +3820,32 @@
2007 3820 return $buffer;
2008 3821 }
2009 3822
2010 3823 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context for credentials; cache writes happen on frontend requests where it's unavailable.
2011 - file_put_contents( $file, $full, LOCK_EX );
3824 + $stored = file_put_contents( $file, $full, LOCK_EX );
2012 3825
3826 + /*
3827 + * No edge headers on a MISS. Not even a stored one.
3828 + *
3829 + * A MISS is the FIRST render, and it is the render most likely to be
3830 + * replaced: critical CSS and unused CSS are generated after the fact
3831 + * and applied to later requests, so the copy written here is the
3832 + * pre-optimization one. Telling a CDN to hold it pins exactly the
3833 + * version xSpeed is about to improve on — reported from a live site,
3834 + * where Cloudflare had cached an unoptimized first render.
3835 + *
3836 + * `CDN-Cache-Control` is why it reaches the edge at all: Cloudflare
3837 + * honours it as the CDN-targeted directive whatever its own cache
3838 + * rules say, so an origin header is enough to pin HTML for the whole
3839 + * TTL even where edge page caching is switched off.
3840 + *
3841 + * A HIT is the safe moment and the honest one: it means xSpeed is
3842 + * serving its stored copy, that copy is what the edge would mirror,
3843 + * and a later regeneration purges it — which reaches the edge through
3844 + * the purge actions. So the edge caches from the second visitor on,
3845 + * one render later than before and the right one.
3846 + */
3847 +
2013 3848 /**
2014 3849 * Fires after the flat hash cache file ({md5}.html) is written.
2015 3850 *
2016 3851 * Mirror of `xspeed_static_file_written` for the flat cache. The PHP
@@ -2046,9 +3881,16 @@
2046 3881 // 200, FBS-82406) or a non-HTML content-type (a cached feed would go
2047 3882 // out as text/html, FBS-82407). The web server serves these .html files
2048 3883 // directly with no PHP, so there's no .meta replay — keep them on the
2049 3884 // drop-in / PHP path instead, which DOES replay status + content-type.
2050 - if ( self::static_rewrite_allowed() && self::response_is_plain_html() ) {
3885 + // The static tree cannot replay a sidecar. A file served straight by
3886 + // the web server carries the headers baked into the rule that serves
3887 + // the whole site — the very answer this entry exists because it
3888 + // disagreed with. Same reasoning as the status and content-type
3889 + // cases: what the fast path cannot replay belongs on the drop-in path.
3890 + if ( self::static_rewrite_allowed()
3891 + && self::response_is_plain_html()
3892 + && array() === self::per_entry_edge_headers() ) {
2051 3893 self::store_static( $full );
2052 3894 }
2053 3895
2054 3896 return $buffer;
@@ -2060,12 +3902,13 @@
2060 3902 * rewrite block points at this path so cache hits skip PHP
2061 3903 * entirely. Caller already minified/finalized $html.
2062 3904 *
2063 3905 * Path safety: $host is restricted to a `[a-zA-Z0-9.\-]` allowlist;
2064 - * $uri has its query string stripped, null bytes removed, '..'
2065 - * sequences collapsed, and after concatenation we verify the
2066 - * resolved real path stays inside XSPEED_CACHE_STATIC_DIR before
2067 - * any write. Anything off the happy path returns silently.
3906 + * the path goes through static_path(), which decodes it the way the
3907 + * web server will and refuses anything that could leave the host
3908 + * directory or share a file with another page. After the directory is
3909 + * created we verify its real path is inside XSPEED_CACHE_STATIC_DIR
3910 + * before any write. Anything off the happy path returns silently.
2068 3911 *
2069 3912 * INVARIANT — the static tree is keyed by `{host}{path}` and NOTHING
2070 3913 * else, and both generated rewrites refuse any request that carries a
2071 3914 * query string at all (`RewriteCond %{QUERY_STRING} ^$` on Apache,
@@ -2085,8 +3928,15 @@
2085 3928 */
2086 3929 private const STATIC_SKIP_TRANSIENT = 'xspeed_static_skip';
2087 3930
2088 3931 /**
3932 + * File beside a static index.html that holds its nonce deadline. GC reads
3933 + * it; every path that deletes the page must delete it too, or the folder
3934 + * can never be removed.
3935 + */
3936 + public const EXPIRES_MARKER = '.xspeed-expires';
3937 +
3938 + /**
2089 3939 * Remember why a page was kept out of the static tree, for Health.
2090 3940 *
2091 3941 * Records the URL, the reason, and — for the nonce case — the distinct
2092 3942 * nonce KEYS found, which is what makes the finding actionable: the names
@@ -2128,15 +3978,13 @@
2128 3978 }
2129 3979 $keys = array_slice( array_values( array_unique( $found ) ), 0, 10 );
2130 3980 }
2131 3981
2132 - $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
2133 -
2134 3982 set_transient(
2135 3983 self::STATIC_SKIP_TRANSIENT,
2136 3984 array(
2137 3985 'reason' => $reason,
2138 - 'url' => (string) strtok( $uri, '?' ),
3986 + 'url' => esc_url_raw( self::request_path() ),
2139 3987 'keys' => $keys,
2140 3988 'at' => time(),
2141 3989 ),
2142 3990 HOUR_IN_SECONDS
@@ -2203,23 +4051,21 @@
2203 4051 return;
2204 4052 }
2205 4053
2206 4054 $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
2207 - $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
2208 4055 $host = self::static_host_dir( $host );
2209 - $uri = str_replace( "\0", '', $uri );
2210 - $uri = (string) strtok( $uri, '?' );
4056 + $uri = self::request_path();
2211 4057 if ( '' === $host || '' === $uri ) {
2212 4058 return;
2213 4059 }
2214 - // Collapse any traversal sequences before path resolution.
2215 - $uri = preg_replace( '#/+#', '/', $uri );
2216 - if ( false !== strpos( $uri, '..' ) ) {
4060 + // Decoded the way the web server will decode it, or refused.
4061 + $rel = self::static_path( $uri );
4062 + if ( null === $rel ) {
2217 4063 return;
2218 4064 }
2219 4065
2220 4066 $base = rtrim( XSPEED_CACHE_STATIC_DIR, '/' );
2221 - $dir = $base . '/' . $host . rtrim( $uri, '/' );
4067 + $dir = $base . '/' . $host . $rel;
2222 4068 $file = $dir . '/index.html';
2223 4069
2224 4070 // Resolve the parent against the cache root to be sure the
2225 4071 // final path is inside our tree even if the OS does anything
@@ -2234,8 +4080,15 @@
2234 4080 }
2235 4081 if ( ! is_dir( $dir ) ) {
2236 4082 return;
2237 4083 }
4084 + // static_path() already refuses every traversal spelling. This
4085 + // catches a directory in the tree that resolves somewhere else.
4086 + $dir_real = realpath( $dir );
4087 + $static_real = realpath( $base );
4088 + if ( false === $dir_real || false === $static_real || 0 !== strpos( $dir_real . '/', $static_real . '/' ) ) {
4089 + return;
4090 + }
2238 4091 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- Same rationale as the flat-hash cache write above: WP_Filesystem isn't available on frontend requests, and the cache write must happen during shutdown.
2239 4092 $written = file_put_contents( $file, $html, LOCK_EX );
2240 4093
2241 4094 // A nonce-bearing page expires on the nonce's schedule, not the site's.
@@ -2244,9 +4097,9 @@
2244 4097 // GC, which is the only thing that can enforce it. Written before the
2245 4098 // action below so a listener that shells out cannot race the sweep.
2246 4099 if ( false !== $written && $nonce_ttl > 0 ) {
2247 4100 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- same rationale as the write above.
2248 - file_put_contents( $dir . '/.xspeed-expires', (string) ( time() + $nonce_ttl ), LOCK_EX );
4101 + file_put_contents( $dir . '/' . self::EXPIRES_MARKER, (string) ( time() + $nonce_ttl ), LOCK_EX );
2249 4102 }
2250 4103
2251 4104 if ( false !== $written ) {
2252 4105 /**
@@ -2579,8 +4432,23 @@
2579 4432 if ( $ttl > 0 && $ttl !== $default_ttl ) {
2580 4433 $meta['ttl'] = $ttl;
2581 4434 }
2582 4435
4436 + // This entry's edge headers, when they differ from the site-wide set
4437 + // baked into the drop-in. The sidecar is the only channel that can
4438 + // carry a per-page answer into the pre-boot fast path, and the drop-in
4439 + // REPLACES the baked set with it rather than merging: the two describe
4440 + // the same response, so merging would leave the baked lifetime in
4441 + // place beside the hold meant to overrule it.
4442 + //
4443 + // The lifetime resolved above goes with it, under the same condition
4444 + // as the `ttl` key: a page that does not follow the site's lifetime
4445 + // may not be kept at the edge past its own either.
4446 + $edge = self::per_entry_edge_headers( isset( $meta['ttl'] ) ? $ttl : null );
4447 + if ( array() !== $edge ) {
4448 + $meta['edge_headers'] = $edge;
4449 + }
4450 +
2583 4451 // Nothing to replay → no sidecar.
2584 4452 if ( empty( $meta ) ) {
2585 4453 return;
2586 4454 }
@@ -2624,8 +4492,101 @@
2624 4492 * TTL — up to 30 days at the maximum lifetime. (#270 regression)
2625 4493 *
2626 4494 * @return string[]
2627 4495 */
4496 + /**
4497 + * Could this post change alter anything an anonymous visitor had cached?
4498 + *
4499 + * Deleting one post fired a full purge for the post AND for every stored
4500 + * revision, because wp_delete_post() removes each revision through
4501 + * wp_delete_post() again and every one of those fires before_delete_post
4502 + * with post_type 'revision'. A post with six revisions cost seven whole-
4503 + * site sweeps, each one also announcing to LiteSpeed, purging the object
4504 + * cache network-wide on Redis, rewriting the stats option and running
4505 + * every xspeed_after_purge_all listener -- including Pro's Cloudflare
4506 + * purge, so seven API calls. Trashing cost two, via save_post and then
4507 + * trashed_post. (QA #348)
4508 + *
4509 + * The check lives here, ahead of purge_all(), so one early return covers
4510 + * the local sweep, the server-cache announcement and both action hooks.
4511 + * It deliberately does NOT live inside purge_all(): a manual, CLI or
4512 + * explicit caller asked for a purge and must get one.
4513 + *
4514 + * @param int $post_id Post being saved or removed.
4515 + * @param mixed $post Post object when the hook passed one.
4516 + * @param string $event 'save' or 'remove'.
4517 + */
4518 + private static function post_change_is_cacheable_content( $post_id, $post, string $event ): bool {
4519 + $post_id = (int) $post_id;
4520 +
4521 + // Only `save_post` and `before_delete_post` hand over a post object.
4522 + // `trashed_post` passes ( $post_id, $previous_status ) -- a STRING --
4523 + // so reaching for ->post_status on the second argument finds nothing
4524 + // and the status rule below would never fire. Read the row instead.
4525 + if ( ! is_object( $post ) && function_exists( 'get_post' ) ) {
4526 + $post = get_post( $post_id );
4527 + }
4528 +
4529 + $type = is_object( $post ) && isset( $post->post_type )
4530 + ? (string) $post->post_type
4531 + : (string) ( function_exists( 'get_post_type' ) ? get_post_type( $post_id ) : '' );
4532 + if ( '' === $type ) {
4533 + return false;
4534 + }
4535 +
4536 + // A revision is a copy of content nobody can browse to.
4537 + if ( 'revision' === $type ) {
4538 + return false;
4539 + }
4540 + if ( function_exists( 'wp_is_post_revision' ) && wp_is_post_revision( $post_id ) ) {
4541 + return false;
4542 + }
4543 + if ( function_exists( 'wp_is_post_autosave' ) && wp_is_post_autosave( $post_id ) ) {
4544 + return false;
4545 + }
4546 +
4547 + $status = is_object( $post ) && isset( $post->post_status ) ? (string) $post->post_status : '';
4548 +
4549 + // Clicking "Add New" inserts an auto-draft and fires save_post. There
4550 + // is nothing cached of a post that has never existed publicly.
4551 + if ( 'auto-draft' === $status ) {
4552 + return false;
4553 + }
4554 +
4555 + // Unknown/!viewable → nothing anonymous can see changed, UNLESS the
4556 + // type is itself part of how pages render (#270 regression).
4557 + if ( function_exists( 'is_post_type_viewable' )
4558 + && ! is_post_type_viewable( $type )
4559 + && ! in_array( $type, self::presentation_post_types(), true )
4560 + ) {
4561 + return false;
4562 + }
4563 +
4564 + // Deleting something that was already invisible changes no cached
4565 + // page: the transition that hid it purged at the time. This is what
4566 + // makes emptying a trash of a hundred posts cost nothing rather than
4567 + // a hundred full sweeps.
4568 + //
4569 + // It also collapses trashing to a single purge: wp_trash_post() fires
4570 + // save_post first, where the post is genuinely disappearing from
4571 + // listings and SHOULD purge, then trashed_post, by which point the
4572 + // row reads 'trash' and is skipped. A status we cannot read, on a row
4573 + // that still reports a type, means assume viewable -- erring toward
4574 + // an extra purge, never toward serving a stale page. A row that is
4575 + // gone entirely reports no type either and was refused above.
4576 + // 'inherit' is an INTERNAL status in core, so is_post_status_viewable()
4577 + // says no -- but an attachment carrying it is genuinely public. Judge
4578 + // those on the post type alone, which is already checked above.
4579 + if ( 'remove' === $event && '' !== $status && 'inherit' !== $status
4580 + && function_exists( 'is_post_status_viewable' )
4581 + && ! is_post_status_viewable( $status )
4582 + ) {
4583 + return false;
4584 + }
4585 +
4586 + return true;
4587 + }
4588 +
2628 4589 public static function presentation_post_types(): array {
2629 4590 $types = array(
2630 4591 'wp_template', // Site Editor templates.
2631 4592 'wp_template_part', // Header / footer / reusable parts.
@@ -2646,8 +4607,44 @@
2646 4607 return (array) apply_filters( 'xspeed_presentation_post_types', $types );
2647 4608 }
2648 4609
2649 4610 /**
4611 + * Describe a broad hook invalidation for response-cache adapters.
4612 + *
4613 + * Term, menu, theme and plugin changes can alter navigation, archives or
4614 + * markup across the site, so they require a site response-cache purge.
4615 + * Content saves also require this scope while their local operation is a
4616 + * complete bucket sweep.
4617 + *
4618 + * A new term is `content`, not `presentation`. It has no posts yet, so no
4619 + * page renders it until a post is saved with it, and that save is its own
4620 + * content purge. Classed as presentation, it cleared the host's whole
4621 + * nginx cache every time a post was published with a tag that did not
4622 + * exist yet, which is most publishing. Renaming or deleting a term stays
4623 + * presentation: the new name shows on every post in the term, and Nginx
4624 + * Helper purges only the homepage for either. (QA #448)
4625 + *
4626 + * @return array{scope:string,intent:string,urls:array<int,string>}
4627 + */
4628 + private static function invalidation_for_hook( string $hook ): array {
4629 + $presentation = array(
4630 + 'switch_theme',
4631 + 'activated_plugin',
4632 + 'deactivated_plugin',
4633 + 'edited_term',
4634 + 'delete_term',
4635 + 'wp_update_nav_menu',
4636 + );
4637 +
4638 + return array(
4639 + 'scope' => 'site',
4640 + 'intent' => in_array( $hook, $presentation, true ) ? 'presentation' : 'content',
4641 + 'urls' => array(),
4642 + );
4643 + }
4644 +
4645 +
4646 + /**
2650 4647 * save_post → purge only when the saved thing can appear on a cached page.
2651 4648 *
2652 4649 * Revisions and autosaves are never rendered. Non-viewable post types —
2653 4650 * WooCommerce's `shop_order` / `shop_order_placehold` / `shop_order_refund`
@@ -2662,38 +4659,500 @@
2662 4659 * @param int $post_id Saved post ID.
2663 4660 * @param \WP_Post $post Saved post object.
2664 4661 */
2665 4662 public static function on_save_post( $post_id, $post = null ): void {
2666 - if ( function_exists( 'wp_is_post_revision' ) && wp_is_post_revision( $post_id ) ) {
4663 + if ( ! self::post_change_is_cacheable_content( $post_id, $post, 'save' ) ) {
2667 4664 return;
2668 4665 }
2669 - if ( function_exists( 'wp_is_post_autosave' ) && wp_is_post_autosave( $post_id ) ) {
4666 +
4667 + $post_type = is_object( $post ) && isset( $post->post_type )
4668 + ? (string) $post->post_type
4669 + : (string) get_post_type( $post_id );
4670 +
4671 + // Name the trigger rather than logging a bare numeric id — the old
4672 + // wiring passed the post ID into $cause, so the log read
4673 + // "Cache purged (46)" with no indication of what caused it. (#243)
4674 + $presentation = in_array( $post_type, self::presentation_post_types(), true );
4675 +
4676 + // Clearing only the affected pages needs the post's terms, and the
4677 + // REST API sets those after `save_post`. Note the post and purge on
4678 + // `wp_after_insert_post`; flush_pending_saves() covers a save that
4679 + // never gets there.
4680 + if ( ! $presentation && self::narrow_purge_applies( $post_type ) ) {
4681 + self::$pending_saves[ (int) $post_id ] = true;
2670 4682 return;
2671 4683 }
2672 4684
4685 + self::purge_all(
4686 + 'post:' . $post_type,
4687 + null,
4688 + array(
4689 + // purge_all() sweeps every local response in this site's bucket.
4690 + // Without dependency tracking, the server cache must match that
4691 + // same boundary or unrelated pages can remain stale there.
4692 + 'scope' => 'site',
4693 + 'intent' => $presentation ? 'presentation' : 'content',
4694 + 'urls' => array(),
4695 + )
4696 + );
4697 + }
4698 +
4699 + /**
4700 + * Reasons a change that would have cleared only its own pages cleared
4701 + * the whole site, published as `fallback` in the purge context.
4702 + *
4703 + * - THEME_LIST: the change alters a post or comment list the theme draws
4704 + * on pages the rules cannot name.
4705 + * - LIMIT: the affected pages are more than Affected_Pages::LIMIT.
4706 + * - PENDING: the save never reached `wp_after_insert_post`, so there was
4707 + * no before-copy to work the old address out from.
4708 + * - FILTER: `xspeed_purge_affected_pages` returned false.
4709 + * - LISTING: the record of pages that run a post list of their own
4710 + * (Listing_Pages) cannot answer: a page of the type went unrecorded at
4711 + * its cap, more than Affected_Pages::LIMIT pages of the type would
4712 + * have to be named, or the index does not read back. Or the pages it
4713 + * named took the list past Affected_Pages::LIMIT.
4714 + *
4715 + * Every other purge publishes ''. A post type that always purges the
4716 + * whole site (a WooCommerce product) is a choice, not a fallback, and
4717 + * publishes '' too.
4718 + */
4719 + public const FALLBACK_THEME_LIST = 'theme_list';
4720 + public const FALLBACK_LIMIT = 'limit';
4721 + public const FALLBACK_PENDING = 'pending';
4722 + public const FALLBACK_FILTER = 'filter';
4723 + public const FALLBACK_LISTING = 'listing';
4724 +
4725 + /**
4726 + * Posts noted by on_save_post() for a narrow purge, keyed by ID.
4727 + *
4728 + * @var array<int,bool>
4729 + */
4730 + private static $pending_saves = array();
4731 +
4732 + /**
4733 + * Posts whose save purged their pages in this request, keyed by ID. That
4734 + * purge named page 1 of the posts page, so a sticky change made after
4735 + * it (the classic editor and Quick Edit stick after saving) has nothing
4736 + * left to clear there.
4737 + *
4738 + * @var array<int,bool>
4739 + */
4740 + private static $saves_purged = array();
4741 +
4742 + /**
4743 + * Purge the pages a save affected, once WordPress has finished saving.
4744 + *
4745 + * @param int $post_id Post ID.
4746 + * @param \WP_Post $post Post as saved.
4747 + * @param bool $update Whether an existing post was updated.
4748 + * @param \WP_Post|null $post_before Post before the save, null when new.
4749 + */
4750 + public static function on_after_insert_post( $post_id, $post = null, $update = false, $post_before = null ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- hook signature.
4751 + $post_id = (int) $post_id;
4752 + if ( ! isset( self::$pending_saves[ $post_id ] ) ) {
4753 + return;
4754 + }
4755 + unset( self::$pending_saves[ $post_id ] );
4756 + if ( ! $post instanceof \WP_Post ) {
4757 + $post = get_post( $post_id );
4758 + }
4759 + if ( ! $post instanceof \WP_Post ) {
4760 + return;
4761 + }
4762 + $post_before = $post_before instanceof \WP_Post ? $post_before : null;
4763 + if ( self::repeats_block_editor_save( $post, $post_before ) ) {
4764 + Affected_Pages::forget( $post_id );
4765 + return;
4766 + }
4767 + self::$saves_purged[ $post_id ] = true;
4768 + self::purge_post_change( $post, $post_before, 'post:' . $post->post_type );
4769 + }
4770 +
4771 + /** Meta WordPress rewrites on every save, which says nothing about the page. */
4772 + private const SAVE_NOISE_META = array( '_edit_lock', '_edit_last', '_encloseme', '_pingme' );
4773 +
4774 + /**
4775 + * Whether this save repeats the block editor save that just purged.
4776 + *
4777 + * The block editor saves a post through REST, then, when the screen has
4778 + * meta boxes (xSpeed's own Cache Rules box is one), posts them to
4779 + * post.php?meta-box-loader=1, which saves the post a second time. Both
4780 + * requests purged, so every save sent the same pages to every cache in
4781 + * front twice. The REST save notes what it purged for; the meta box
4782 + * request skips its purge when nothing a visitor sees has changed since:
4783 + * the post's fields, its terms and its meta. A meta box that saved meta
4784 + * the page prints (an SEO title, a custom field) still purges.
4785 + *
4786 + * @param \WP_Post $post Post as saved.
4787 + * @param \WP_Post|null $before Post before the save.
4788 + */
4789 + private static function repeats_block_editor_save( \WP_Post $post, ?\WP_Post $before ): bool {
4790 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- only names the request; WordPress verified the save's own nonce.
4791 + $meta_boxes = isset( $_GET['meta-box-loader'] );
4792 + $rest = defined( 'REST_REQUEST' ) && REST_REQUEST;
4793 + if ( ! $meta_boxes && ! $rest ) {
4794 + return false;
4795 + }
4796 + if ( ! Affected_Pages::is_public( $post ) && ! ( $before instanceof \WP_Post && Affected_Pages::is_public( $before ) ) ) {
4797 + return false;
4798 + }
4799 + $key = 'xspeed_saved_' . (int) $post->ID;
4800 + $fingerprint = self::save_fingerprint( $post );
4801 + if ( $meta_boxes ) {
4802 + if ( get_transient( $key ) === $fingerprint ) {
4803 + delete_transient( $key );
4804 + return true;
4805 + }
4806 + return false;
4807 + }
4808 + set_transient( $key, $fingerprint, 2 * MINUTE_IN_SECONDS );
4809 + return false;
4810 + }
4811 +
4812 + /**
4813 + * A hash of what a save can change that a visitor sees: the post's own
4814 + * fields, its terms and its meta.
4815 + *
4816 + * @param \WP_Post $post Post as saved.
4817 + */
4818 + private static function save_fingerprint( \WP_Post $post ): string {
4819 + $fields = array();
4820 + foreach ( array( 'post_type', 'post_status', 'post_date', 'post_title', 'post_name', 'post_content', 'post_excerpt', 'post_parent', 'menu_order', 'post_author', 'post_password', 'comment_status' ) as $field ) {
4821 + $fields[ $field ] = (string) ( $post->$field ?? '' );
4822 + }
4823 + $terms = array();
4824 + $taxonomies = get_object_taxonomies( (string) $post->post_type );
4825 + if ( is_array( $taxonomies ) && array() !== $taxonomies ) {
4826 + $ids = wp_get_object_terms( (int) $post->ID, $taxonomies, array( 'fields' => 'tt_ids' ) );
4827 + $terms = is_array( $ids ) ? array_map( 'intval', $ids ) : array();
4828 + sort( $terms );
4829 + }
4830 + $meta = get_post_meta( (int) $post->ID );
4831 + $meta = is_array( $meta ) ? array_diff_key( $meta, array_flip( self::SAVE_NOISE_META ) ) : array();
4832 + ksort( $meta );
4833 + return md5( (string) wp_json_encode( array( $fields, $terms, $meta ) ) );
4834 + }
4835 +
4836 + /**
4837 + * A save that never reached `wp_after_insert_post` still purges.
4838 + *
4839 + * `wp_insert_post()` called with `$fire_after_hooks = false` leaves that
4840 + * hook to its caller, and a caller can fail to fire it. Without this, the
4841 + * save would purge nothing at all. Site-wide, because there is no
4842 + * before-copy to work out the old address from.
4843 + */
4844 + public static function flush_pending_saves(): void {
4845 + if ( array() === self::$pending_saves ) {
4846 + return;
4847 + }
4848 + $ids = array_keys( self::$pending_saves );
4849 + self::$pending_saves = array();
4850 + self::purge_all(
4851 + 'post:pending',
4852 + null,
4853 + array(
4854 + 'scope' => 'site',
4855 + 'intent' => 'content',
4856 + 'urls' => array(),
4857 + 'fallback' => self::FALLBACK_PENDING,
4858 + )
4859 + );
4860 + foreach ( $ids as $id ) {
4861 + Affected_Pages::forget( (int) $id );
4862 + }
4863 + }
4864 +
4865 + /**
4866 + * Before an update is written: note the post's neighbours, which a new
4867 + * date or category takes it away from. Only for a post visitors can see
4868 + * now, and only when the save will purge narrowly; that costs four
4869 + * queries per update.
4870 + *
4871 + * @param int $post_id Post ID.
4872 + * @param array $data Unused: the new values.
4873 + */
4874 + public static function on_pre_post_update( $post_id, $data = array() ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- hook signature.
4875 + $post = get_post( (int) $post_id );
4876 + if ( ! $post instanceof \WP_Post || ! Affected_Pages::is_public( $post ) ) {
4877 + return;
4878 + }
4879 + if ( in_array( (string) $post->post_type, self::presentation_post_types(), true ) || ! self::narrow_purge_applies( (string) $post->post_type ) ) {
4880 + return;
4881 + }
4882 + Affected_Pages::remember_old_neighbours( $post );
4883 + }
4884 +
4885 + /**
4886 + * A post was stuck or unstuck.
4887 + *
4888 + * A theme list that puts sticky posts first (Twenty Twenty-Five's "More
4889 + * posts" under every post) changes on every page that draws it, so the
4890 + * whole site goes. The block editor changes stickiness inside the save,
4891 + * and the save's own purge sees it then. The classic editor and Quick
4892 + * Edit change it after the save has purged, which is why this hook
4893 + * purges the posts no save is waiting on.
4894 + *
4895 + * @param mixed $old_value Sticky post IDs before.
4896 + * @param mixed $value Sticky post IDs after.
4897 + */
4898 + public static function on_sticky_posts_change( $old_value, $value ): void {
4899 + $blog_page = false;
4900 + foreach ( Affected_Pages::remember_sticky_change( $old_value, $value ) as $post_id ) {
4901 + if ( isset( self::$pending_saves[ $post_id ] ) ) {
4902 + continue;
4903 + }
4904 + $post = get_post( $post_id );
4905 + if ( ! $post instanceof \WP_Post || ! Affected_Pages::is_public( $post ) || ! self::narrow_purge_applies( (string) $post->post_type ) ) {
4906 + continue;
4907 + }
4908 + if ( Affected_Pages::lists_show_sticky( (string) $post->post_type ) ) {
4909 + self::purge_all(
4910 + 'sticky:' . $post->post_type,
4911 + null,
4912 + array(
4913 + 'scope' => 'site',
4914 + 'intent' => 'content',
4915 + 'urls' => array(),
4916 + 'fallback' => self::FALLBACK_THEME_LIST,
4917 + )
4918 + );
4919 + return;
4920 + }
4921 + // WordPress's own blog list puts sticky posts first on its first
4922 + // page, whatever the theme draws, so a post stuck or unstuck by
4923 + // code, WP-CLI or a plugin moves on that page. A save in this
4924 + // request already cleared it.
4925 + if ( 'post' === $post->post_type && ! isset( self::$saves_purged[ $post_id ] ) ) {
4926 + $blog_page = true;
4927 + }
4928 + }
4929 + if ( $blog_page ) {
4930 + $url = Affected_Pages::posts_page_url();
4931 + if ( '' !== $url ) {
4932 + self::purge_urls( array( $url ), 'sticky:post' );
4933 + }
4934 + }
4935 + }
4936 +
4937 + /**
4938 + * The first post stuck on a site: WordPress creates `sticky_posts`
4939 + * instead of updating it, so update_option_sticky_posts never fires.
4940 + *
4941 + * @param string $option Option name.
4942 + * @param mixed $value Sticky post IDs.
4943 + */
4944 + public static function on_sticky_posts_added( $option, $value ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed -- hook signature.
4945 + self::on_sticky_posts_change( array(), $value );
4946 + }
4947 +
4948 + /**
4949 + * Whether a content change to this post type may clear only the pages
4950 + * it affects, before looking at the post itself.
4951 + *
4952 + * Off when the setting is off, during an import (one full purge runs at
4953 + * the end), and for post types whose lists the rules do not know:
4954 + * WooCommerce products, whose shop and category pages purge_product()
4955 + * already handles.
4956 + *
4957 + * @param string $post_type Post type.
4958 + */
4959 + private static function narrow_purge_applies( string $post_type ): bool {
4960 + if ( defined( 'WP_IMPORTING' ) && WP_IMPORTING ) {
4961 + return false;
4962 + }
4963 + $opts = Settings_Manager::get( 'cache' );
4964 + if ( empty( $opts['purge_affected_only'] ) ) {
4965 + return false;
4966 + }
4967 + /**
4968 + * Filter the post types whose saves always purge the whole site.
4969 + *
4970 + * @param string[] $types Post type slugs.
4971 + */
4972 + $site_wide = (array) apply_filters( 'xspeed_site_wide_purge_post_types', array( 'product', 'product_variation' ) );
4973 + return ! in_array( $post_type, $site_wide, true );
4974 + }
4975 +
4976 + /**
4977 + * Purge what a change to one post affects, or the whole site when the
4978 + * affected pages cannot be listed safely.
4979 + *
4980 + * The pages are the ones the rules name (Affected_Pages::for_post())
4981 + * and the recorded pages whose own post lists the change may alter
4982 + * (Listing_Pages::pages_for()).
4983 + *
4984 + * Site-wide when this change alters a post list the theme draws on
4985 + * pages the rules cannot name (Affected_Pages::lists_changed_by()), when
4986 + * the record of listing pages cannot answer, when the list is
4987 + * longer than Affected_Pages::LIMIT, or when the filter says so. Nothing
4988 + * when the post was not public before or after: no page anyone can see
4989 + * changed.
4990 + *
4991 + * @param \WP_Post $post Post as it is now.
4992 + * @param \WP_Post|null $before Post before the change.
4993 + * @param string $cause Purge log cause.
4994 + */
4995 + private static function purge_post_change( \WP_Post $post, ?\WP_Post $before, string $cause ): void {
4996 + // Checked before any list is consulted. A draft save moves the
4997 + // draft's date and can match a list of any kind, which would send a
4998 + // change nobody can see to a site-wide purge.
4999 + if ( ! Affected_Pages::is_public( $post ) && ! ( $before instanceof \WP_Post && Affected_Pages::is_public( $before ) ) ) {
5000 + Affected_Pages::forget( (int) $post->ID );
5001 + return;
5002 + }
5003 + $fallback = '';
5004 + $urls = null;
5005 + if ( Affected_Pages::lists_changed_by( $post, $before ) ) {
5006 + $fallback = self::FALLBACK_THEME_LIST;
5007 + } else {
5008 + $urls = Affected_Pages::for_post( $post, $before );
5009 + // Read before forget(): a plain edit is judged on the terms the
5010 + // save replaced.
5011 + $listing = Listing_Pages::enabled() ? Listing_Pages::pages_for( $post, $before ) : array();
5012 + if ( null === $listing ) {
5013 + $fallback = self::FALLBACK_LISTING;
5014 + $urls = null;
5015 + } else {
5016 + // Pages the rules did not name. Nginx Helper's own purge does
5017 + // not reach them either, so a fallback they cause has to say so.
5018 + $extra = array_diff( $listing, $urls );
5019 + $urls = array_values( array_unique( array_merge( $urls, $listing ) ) );
5020 + if ( count( $urls ) > Affected_Pages::LIMIT ) {
5021 + $fallback = array() === $extra ? self::FALLBACK_LIMIT : self::FALLBACK_LISTING;
5022 + $urls = null;
5023 + }
5024 + }
5025 + }
5026 + Affected_Pages::forget( (int) $post->ID );
5027 +
5028 + if ( is_array( $urls ) ) {
5029 + /**
5030 + * Filter the pages a post change clears.
5031 + *
5032 + * Return false to purge the whole site instead.
5033 + *
5034 + * @param string[]|false $urls Absolute URLs.
5035 + * @param \WP_Post $post The post that changed.
5036 + */
5037 + $urls = apply_filters( 'xspeed_purge_affected_pages', $urls, $post );
5038 + if ( ! is_array( $urls ) ) {
5039 + $fallback = self::FALLBACK_FILTER;
5040 + }
5041 + }
5042 +
5043 + if ( ! is_array( $urls ) ) {
5044 + self::purge_all(
5045 + $cause,
5046 + null,
5047 + array(
5048 + 'scope' => 'site',
5049 + 'intent' => 'content',
5050 + 'urls' => array(),
5051 + 'fallback' => $fallback,
5052 + )
5053 + );
5054 + return;
5055 + }
5056 + if ( array() !== $urls ) {
5057 + self::purge_urls( $urls, $cause );
5058 + }
5059 + }
5060 +
5061 + /**
5062 + * Delete/trash invalidation while the post type is still available.
5063 + * The local and server response-cache sweeps share the same site boundary.
5064 + *
5065 + * @param int $post_id Removed post ID.
5066 + * @param object|null $post Post object supplied by core when available.
5067 + */
5068 + public static function on_post_removed( $post_id, $post = null ): void {
5069 + if ( ! self::post_change_is_cacheable_content( $post_id, $post, 'remove' ) ) {
5070 + return;
5071 + }
5072 +
2673 5073 $post_type = is_object( $post ) && isset( $post->post_type )
2674 5074 ? (string) $post->post_type
2675 5075 : (string) get_post_type( $post_id );
2676 - if ( '' === $post_type ) {
5076 +
5077 + if ( ! in_array( $post_type, self::presentation_post_types(), true ) && self::narrow_purge_applies( $post_type ) ) {
5078 + $object = $post instanceof \WP_Post ? $post : get_post( (int) $post_id );
5079 + if ( $object instanceof \WP_Post ) {
5080 + if ( 'attachment' === $post_type ) {
5081 + // Its own page and the post it is attached to. Pages that
5082 + // show the file keep an <img> to a file that is gone either
5083 + // way: a fresh render prints the same tag.
5084 + $urls = array();
5085 + $link = get_permalink( $object );
5086 + if ( is_string( $link ) && '' !== $link ) {
5087 + $urls[] = $link;
5088 + }
5089 + $parent = $object->post_parent > 0 ? get_post( (int) $object->post_parent ) : null;
5090 + if ( $parent instanceof \WP_Post && 'publish' === $parent->post_status ) {
5091 + $parent_link = get_permalink( $parent );
5092 + if ( is_string( $parent_link ) && '' !== $parent_link ) {
5093 + $urls[] = $parent_link;
5094 + }
5095 + }
5096 + if ( array() !== $urls ) {
5097 + self::purge_urls( $urls, 'post-removed:attachment' );
5098 + }
5099 + return;
5100 + }
5101 + // Still published here: `before_delete_post` runs before the
5102 + // row goes. Trashing reaches the save path instead.
5103 + self::purge_post_change( $object, null, 'post-removed:' . $post_type );
5104 + return;
5105 + }
5106 + }
5107 +
5108 + self::purge_all(
5109 + 'post-removed:' . $post_type,
5110 + null,
5111 + array(
5112 + 'scope' => 'site',
5113 + // Match on_save_post: a presentation type changes how pages
5114 + // render rather than what they say.
5115 + 'intent' => in_array( $post_type, self::presentation_post_types(), true )
5116 + ? 'presentation'
5117 + : 'content',
5118 + 'urls' => array(),
5119 + )
5120 + );
5121 + }
5122 +
5123 + /** Purge site responses when moderation changes visible comments. */
5124 + public static function on_comment_status( $comment_id, $status = '' ): void {
5125 + $comment = function_exists( 'get_comment' ) ? get_comment( (int) $comment_id ) : null;
5126 + $post_id = is_object( $comment ) && isset( $comment->comment_post_ID ) ? (int) $comment->comment_post_ID : 0;
5127 + if ( $post_id < 1 || ! function_exists( 'get_permalink' ) ) {
2677 5128 return;
2678 5129 }
2679 -
2680 - // Unknown/!viewable → nothing anonymous can see changed, UNLESS the
2681 - // type is itself part of how pages render (#270 regression).
2682 - if ( function_exists( 'is_post_type_viewable' )
2683 - && ! is_post_type_viewable( $post_type )
2684 - && ! in_array( $post_type, self::presentation_post_types(), true )
2685 - ) {
5130 + $url = get_permalink( $post_id );
5131 + if ( ! is_string( $url ) || '' === $url ) {
2686 5132 return;
2687 5133 }
2688 -
2689 - // Name the trigger rather than logging a bare numeric id — the old
2690 - // wiring passed the post ID into $cause, so the log read
2691 - // "Cache purged (46)" with no indication of what caused it. (#243)
2692 - self::purge_all( 'post:' . $post_type );
2693 - if ( class_exists( '\XSpeed\Minifier' ) ) {
2694 - Minifier::purge_minified();
5134 + $post = function_exists( 'get_post' ) ? get_post( $post_id ) : null;
5135 + $narrow = $post instanceof \WP_Post && self::narrow_purge_applies( (string) $post->post_type );
5136 + if ( $narrow && ! Affected_Pages::site_lists_comments() ) {
5137 + $urls = Affected_Pages::for_comment( $post );
5138 + if ( array() !== $urls ) {
5139 + self::purge_urls( $urls, 'comment-status:' . (string) $status );
5140 + }
5141 + return;
2695 5142 }
5143 + self::purge_all(
5144 + 'comment-status:' . (string) $status,
5145 + null,
5146 + array(
5147 + 'scope' => 'site',
5148 + 'intent' => 'content',
5149 + 'urls' => array(),
5150 + // Narrow purges apply to this post, so the site-wide purge is
5151 + // for a recent-comments list on pages the rules cannot name.
5152 + 'fallback' => $narrow ? self::FALLBACK_THEME_LIST : '',
5153 + )
5154 + );
2696 5155 }
2697 5156
2698 5157 /**
2699 5158 * comment_post → purge just the commented-on URL, and only once the
@@ -2716,8 +5175,19 @@
2716 5175 $post_id = is_array( $data ) && isset( $data['comment_post_ID'] ) ? (int) $data['comment_post_ID'] : 0;
2717 5176 if ( $post_id < 1 ) {
2718 5177 return;
2719 5178 }
5179 + // With narrow purges on, the comment pages and comment feeds go too.
5180 + // No site-wide fallback here: a visitor's comment must never be able
5181 + // to clear the whole site (#243).
5182 + $post = function_exists( 'get_post' ) ? get_post( $post_id ) : null;
5183 + if ( $post instanceof \WP_Post && self::narrow_purge_applies( (string) $post->post_type ) ) {
5184 + $urls = Affected_Pages::for_comment( $post );
5185 + if ( array() !== $urls ) {
5186 + self::purge_urls( $urls, 'comment' );
5187 + }
5188 + return;
5189 + }
2720 5190 $url = get_permalink( $post_id );
2721 5191 if ( is_string( $url ) && '' !== $url ) {
2722 5192 self::purge_url( $url, 'comment' );
2723 5193 }
@@ -2778,8 +5248,18 @@
2778 5248 if ( $parent > 0 ) {
2779 5249 $product_id = $parent;
2780 5250 }
2781 5251
5252 + // wp-admin's Update saves the product through WooCommerce inside the
5253 + // post's own save_post, and Cache::on_save_post() then clears the
5254 + // whole site for it, since products are in
5255 + // xspeed_site_wide_purge_post_types. Sending these pages first only
5256 + // purged them twice. A price or stock change made without a post
5257 + // save (an order, the REST API, `$product->save()`) still purges here.
5258 + if ( self::site_wide_save_under_way( $product_id ) ) {
5259 + return;
5260 + }
5261 +
2782 5262 $urls = array();
2783 5263
2784 5264 $permalink = get_permalink( $product_id );
2785 5265 if ( is_string( $permalink ) && '' !== $permalink ) {
@@ -2832,17 +5312,91 @@
2832 5312 * @param int $product_id The product that changed.
2833 5313 */
2834 5314 $urls = (array) apply_filters( 'xspeed_purge_product_urls', $urls, $product_id );
2835 5315
2836 - foreach ( array_unique( array_filter( $urls ) ) as $url ) {
2837 - self::purge_url( (string) $url, 'product' );
5316 + $targets = array();
5317 + foreach ( $urls as $url ) {
5318 + if ( is_scalar( $url ) && '' !== (string) $url ) {
5319 + $targets[] = (string) $url;
5320 + }
2838 5321 }
5322 + if ( array() === $targets ) {
5323 + return;
5324 + }
5325 +
5326 + // One batch, as a post save sends: one purge-log row and one event
5327 + // naming every page, each in the spelling WordPress links to. A
5328 + // purge_url() per page published every page in both spellings,
5329 + // which doubled the calls to Nginx Helper, and wrote no purge-log
5330 + // row when xSpeed's own page cache was off and no file went.
5331 + // purge_urls() leaves the object cache alone, which matters here:
5332 + // this runs on every stock change at checkout.
5333 + self::purge_urls( array_values( array_unique( $targets ) ), 'product', 'content' );
2839 5334 }
2840 5335
2841 5336 /**
5337 + * Posts whose save_post is running in this request, keyed by ID, with
5338 + * the post as saved. Set before any other save_post listener and cleared
5339 + * after the last one.
5340 + *
5341 + * @var array<int,\WP_Post|null>
5342 + */
5343 + private static $saves_under_way = array();
5344 +
5345 + /**
5346 + * A post save has started.
5347 + *
5348 + * @param int $post_id Post ID.
5349 + * @param \WP_Post|null $post Post as saved.
5350 + */
5351 + public static function on_post_save_start( $post_id, $post = null ): void {
5352 + self::$saves_under_way[ (int) $post_id ] = is_object( $post ) ? $post : null;
5353 + }
5354 +
5355 + /**
5356 + * A post save has finished.
5357 + *
5358 + * @param int $post_id Post ID.
5359 + */
5360 + public static function on_post_save_end( $post_id ): void {
5361 + unset( self::$saves_under_way[ (int) $post_id ] );
5362 + }
5363 +
5364 + /**
5365 + * Whether a save_post for this post is running now and will end in
5366 + * on_save_post() clearing the whole site: the same checks it makes.
5367 + *
5368 + * @param int $post_id Post ID.
5369 + */
5370 + private static function site_wide_save_under_way( int $post_id ): bool {
5371 + if ( ! array_key_exists( $post_id, self::$saves_under_way ) ) {
5372 + return false;
5373 + }
5374 + $post = self::$saves_under_way[ $post_id ];
5375 + if ( ! self::post_change_is_cacheable_content( $post_id, $post, 'save' ) ) {
5376 + return false;
5377 + }
5378 + $post_type = is_object( $post ) && isset( $post->post_type )
5379 + ? (string) $post->post_type
5380 + : (string) get_post_type( $post_id );
5381 + if ( in_array( $post_type, self::presentation_post_types(), true ) ) {
5382 + return true;
5383 + }
5384 + return ! self::narrow_purge_applies( $post_type );
5385 + }
5386 +
5387 + /** Test seam: forget the post saves in progress. */
5388 + public static function reset_saves_under_way(): void {
5389 + self::$saves_under_way = array();
5390 + }
5391 +
5392 + /**
2842 5393 * Adapter for the WooCommerce stock actions that pass a product OBJECT
2843 5394 * where the status actions pass an ID.
2844 5395 *
5396 + * No longer wired to a hook (on_product_stock_set() is); kept because
5397 + * it is public.
5398 + *
2845 5399 * @param object $product WC_Product (or variation).
2846 5400 */
2847 5401 public static function purge_product_object( $product ): void {
2848 5402 self::purge_product( $product );
@@ -2847,13 +5401,409 @@
2847 5401 public static function purge_product_object( $product ): void {
2848 5402 self::purge_product( $product );
2849 5403 }
2850 5404
5405 + /**
5406 + * Stock writes wc_update_product_stock() has opened in this request,
5407 + * keyed by product ID: true once the save inside the write has purged.
5408 + *
5409 + * Entries are removed when the write's *_set_stock arrives, so this
5410 + * holds only writes still in progress.
5411 + *
5412 + * @var array<int,bool>
5413 + */
5414 + private static $stock_writes = array();
5415 +
5416 + /**
5417 + * A CRUD save of a product: purge its pages.
5418 + *
5419 + * Inside a wc_update_product_stock() write, this is the save that write
5420 + * makes, so note that the write's pages are already purged.
5421 + *
5422 + * @param int|object $product Product ID (what WooCommerce passes) or WC_Product.
5423 + */
5424 + public static function on_product_saved( $product ): void {
5425 + $product_id = self::product_id_of( $product );
5426 + if ( isset( self::$stock_writes[ $product_id ] ) ) {
5427 + self::$stock_writes[ $product_id ] = true;
5428 + }
5429 + self::purge_product( $product );
5430 + }
5431 +
5432 + /**
5433 + * wc_update_product_stock() is about to write a product's stock.
5434 + *
5435 + * @param object $product WC_Product (or variation) whose stock changes.
5436 + */
5437 + public static function on_product_stock_write( $product ): void {
5438 + $product_id = self::product_id_of( $product );
5439 + if ( $product_id > 0 ) {
5440 + self::$stock_writes[ $product_id ] = false;
5441 + }
5442 + }
5443 +
5444 + /**
5445 + * A product's stock changed: purge its pages, unless the save inside
5446 + * the same wc_update_product_stock() call already did.
5447 + *
5448 + * That save fires woocommerce_update_product for the same product a
5449 + * moment earlier, with the stock already written, so purging again
5450 + * here cleared the same pages twice in one call. Only that pairing is
5451 + * skipped: the flag is set by the save inside this write and cleared
5452 + * here, so the next write, a status change or a later save in the same
5453 + * request purges as usual, and so does a write made with `$updating`
5454 + * set, which skips the save. A *_set_stock that arrives without
5455 + * *_before_set_stock (WooCommerce's data store fires one mid-save when
5456 + * a CRUD save changes the quantity) is not part of a write and purges.
5457 + *
5458 + * @param object $product WC_Product (or variation).
5459 + */
5460 + public static function on_product_stock_set( $product ): void {
5461 + $product_id = self::product_id_of( $product );
5462 + $purged = ! empty( self::$stock_writes[ $product_id ] );
5463 + unset( self::$stock_writes[ $product_id ] );
5464 + if ( $purged ) {
5465 + return;
5466 + }
5467 + self::purge_product( $product );
5468 + }
5469 +
5470 + /** Test seam: forget the stock writes in progress. */
5471 + public static function reset_stock_writes(): void {
5472 + self::$stock_writes = array();
5473 + }
5474 +
5475 + /**
5476 + * The ID of a product passed as an ID or as a WC_Product.
5477 + *
5478 + * @param int|object $product Product ID or WC_Product.
5479 + */
5480 + private static function product_id_of( $product ): int {
5481 + if ( is_object( $product ) ) {
5482 + return method_exists( $product, 'get_id' ) ? (int) $product->get_id() : 0;
5483 + }
5484 + return is_numeric( $product ) ? (int) $product : 0;
5485 + }
5486 +
5487 + /**
5488 + * Re-entry guard for the purge-event contract.
5489 + *
5490 + * A listener on `xspeed_after_purge_url` legitimately purges its own
5491 + * layer, and a server-cache or CDN adapter that calls back into xSpeed
5492 + * while doing so re-enters this method — unbounded, because each pass
5493 + * looks like a fresh purge.
5494 + *
5495 + * A single global flag stops too much: a nested purge of a DIFFERENT URL is
5496 + * a real purge whose listeners must hear about it. But a per-request
5497 + * "already published" set stops too much in the other direction — a
5498 + * network purge loops every blog in one request, and on a subdirectory
5499 + * network they share a host, so blogs 2..N would be silently skipped. It
5500 + * also grows for the life of the process.
5501 + *
5502 + * So the guard tracks what is IN FLIGHT, not what has been published: a
5503 + * target is marked while its own dispatch is on the stack and unmarked
5504 + * when it returns. Re-entering the same target recurses, so it is refused;
5505 + * purging the same URL again later is a new event and publishes. The set
5506 + * is bounded by call depth rather than by how many URLs a request touches.
5507 + *
5508 + * @var array<string,bool>
5509 + */
5510 + private static $purge_events_in_flight = array();
5511 +
5512 + /** Monotonic count used to detect whether a delegated purge published. */
5513 + private static $purge_event_sequence = 0;
5514 +
5515 + /**
5516 + * Publish a purge event exactly once, with bounded arguments.
5517 + *
5518 + * Deliberately carries only what an integration needs to invalidate its
5519 + * own copy: the canonical URL (or null for a full purge), the site host,
5520 + * the cause label, and how many files went. No filesystem paths, no cache
5521 + * contents, no request headers, no user data. The URL query and caller-
5522 + * supplied cause may nevertheless contain sensitive text, so listeners
5523 + * must redact them in logs or unrelated destinations that do not need the
5524 + * exact cache key.
5525 + *
5526 + * A listener that throws must not take the purge down with it: the files
5527 + * are already gone by the time we get here, and an integration's bad day
5528 + * is not a reason to report a failed purge to the caller.
5529 + *
5530 + * @param string $hook Hook name to emit.
5531 + * @param array<string,mixed> $context Bounded context, see above.
5532 + */
5533 + private static function dispatch_purge_event( string $hook, array $context ): void {
5534 + if ( ! function_exists( 'do_action' ) ) {
5535 + return;
5536 + }
5537 + // Every event carries `fallback`, so one handler can read both the
5538 + // per-URL and the full-purge shape. '' unless a narrow purge fell
5539 + // back to the whole site.
5540 + $context['fallback'] = isset( $context['fallback'] ) && is_string( $context['fallback'] ) ? $context['fallback'] : '';
5541 + $target = $hook . '|' . ( isset( $context['url'] ) ? (string) $context['url'] : '' )
5542 + . '|' . ( isset( $context['host'] ) ? (string) $context['host'] : '' );
5543 + if ( isset( self::$purge_events_in_flight[ $target ] ) ) {
5544 + return;
5545 + }
5546 + self::$purge_events_in_flight[ $target ] = true;
5547 + ++self::$purge_event_sequence;
5548 +
5549 + // Our own integrations get their own try. Sharing one with the public
5550 + // action below meant a listener on the extension seam could throw and
5551 + // take the contract event down with it — the mirror of the failure
5552 + // this separation exists to prevent.
5553 + try {
5554 + // Built-in server-cache integrations run FIRST, and by a direct
5555 + // call rather than as listeners on the action below.
5556 + //
5557 + // WordPress stops dispatching an action's remaining callbacks when
5558 + // one of them throws. As a listener, our LiteSpeed forwarding
5559 + // would then be skipped by any unrelated third-party callback that
5560 + // happened to be registered earlier and blew up — and the visible
5561 + // result is the worst kind: xSpeed reports a successful purge while
5562 + // the server keeps serving stale HTML. Shipped behaviour must not
5563 + // be hostage to a listener's bug.
5564 + self::forward_to_server_caches( $context );
5565 + } catch ( \Throwable $e ) {
5566 + self::log_purge_listener_error( $hook, $e );
5567 + }
5568 +
5569 + // The edge xCloud provides, through its purge plugin. Direct for the
5570 + // same reason, and in its own try so a failure here cannot cost the
5571 + // host page caches above or the public action below.
5572 + try {
5573 + if ( class_exists( __NAMESPACE__ . '\\Managed_Edge_Purge' ) ) {
5574 + Managed_Edge_Purge::forward( $context );
5575 + }
5576 + } catch ( \Throwable $e ) {
5577 + self::log_purge_listener_error( $hook, $e );
5578 + }
5579 +
5580 + try {
5581 + self::do_action_isolated( $hook, $context );
5582 + } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
5583 + // Swallow: see docblock. The purge succeeded regardless.
5584 + self::log_purge_listener_error( $hook, $e );
5585 + } finally {
5586 + unset( self::$purge_events_in_flight[ $target ] );
5587 + }
5588 + }
5589 +
5590 + /**
5591 + * Run every listener on a purge hook, isolating each from the others.
5592 + *
5593 + * `do_action()` dispatches callbacks in one loop, so the first one to
5594 + * throw takes every LATER listener down with it. On a purge that meant a
5595 + * failing CDN integration silently cancelled the ones queued behind it —
5596 + * and because the throw was swallowed to keep the purge itself succeeding,
5597 + * the user was told the clear worked while two edges were never touched.
5598 + * Invisible unless WP_DEBUG happened to be on. (QA #348)
5599 + *
5600 + * Each callback gets its own try/catch here, so one integration's bad day
5601 + * costs only that integration. Priority order is preserved. Falls back to
5602 + * a plain `do_action()` when the filter registry is not the shape we
5603 + * expect, so an unusual environment degrades to the old behaviour rather
5604 + * than skipping listeners entirely.
5605 + *
5606 + * @param string $hook Hook name to emit.
5607 + * @param mixed $arg Single argument passed to each listener.
5608 + */
5609 + public static function do_action_isolated( string $hook, $arg ): void {
5610 + global $wp_filter;
5611 +
5612 + // Walking $wp_filter by hand and calling each callback directly was the
5613 + // obvious way to do this, and it was wrong: it bypasses WordPress, so
5614 + // `current_filter()` came back empty, `did_action()` stayed at 0, the
5615 + // `all` hook never fired, and Query Monitor and Debug Bar could not see
5616 + // the very contract this class publishes. A shared handler branching on
5617 + // current_filter() picked the wrong branch. (QA #348 round 2, issue 3)
5618 + //
5619 + // So let do_action() dispatch — WordPress keeps its bookkeeping — and
5620 + // isolate one level down instead: each registered callback is swapped
5621 + // for a wrapper that runs it inside a try/catch. One listener throwing
5622 + // then costs only that listener, which is the whole point, without
5623 + // costing the hook its identity.
5624 + if ( ! isset( $wp_filter[ $hook ] ) || ! ( $wp_filter[ $hook ] instanceof \WP_Hook ) ) {
5625 + do_action( $hook, $arg );
5626 + return;
5627 + }
5628 +
5629 + $hook_object = $wp_filter[ $hook ];
5630 + $original = $hook_object->callbacks;
5631 + if ( ! is_array( $original ) || array() === $original ) {
5632 + do_action( $hook, $arg );
5633 + return;
5634 + }
5635 +
5636 + $wrapped = array();
5637 + $restorations = array();
5638 + foreach ( $original as $priority => $group ) {
5639 + if ( ! is_array( $group ) ) {
5640 + $wrapped[ $priority ] = $group;
5641 + continue;
5642 + }
5643 + foreach ( $group as $id => $registered ) {
5644 + if ( ! isset( $registered['function'] ) || ! is_callable( $registered['function'] ) ) {
5645 + $wrapped[ $priority ][ $id ] = $registered;
5646 + continue;
5647 + }
5648 + $callback = $registered['function'];
5649 + $wrapper = static function ( ...$args ) use ( $callback, $hook ) {
5650 + try {
5651 + return $callback( ...$args );
5652 + } catch ( \Throwable $e ) {
5653 + self::log_purge_listener_error( $hook, $e );
5654 + return null;
5655 + }
5656 + };
5657 + $wrapped[ $priority ][ $id ] = array(
5658 + // Keep accepted_args: a listener registered for 0 or 1
5659 + // arguments must still be called the way it asked.
5660 + 'accepted_args' => $registered['accepted_args'] ?? 1,
5661 + 'function' => $wrapper,
5662 + );
5663 + $restorations[ $priority ][ $id ] = array(
5664 + 'original' => $registered,
5665 + 'wrapper' => $wrapper,
5666 + );
5667 + }
5668 + }
5669 +
5670 + $hook_object->callbacks = $wrapped;
5671 + try {
5672 + do_action( $hook, $arg );
5673 + } finally {
5674 + // Restore only wrappers still present. Native add/remove operations
5675 + // performed by listeners must survive this temporary substitution.
5676 + foreach ( $restorations as $priority => $group ) {
5677 + foreach ( $group as $id => $restore ) {
5678 + $current = $hook_object->callbacks[ $priority ][ $id ]['function'] ?? null;
5679 + if ( $current === $restore['wrapper'] ) {
5680 + $hook_object->callbacks[ $priority ][ $id ] = $restore['original'];
5681 + }
5682 + }
5683 + }
5684 + }
5685 + }
5686 +
5687 + /**
5688 + * Name a listener that threw, under WP_DEBUG only.
5689 + *
5690 + * Gated like the rest of Free's diagnostics: a third-party listener
5691 + * throwing on every purge must not fill a production log.
5692 + */
5693 + private static function log_purge_listener_error( string $hook, \Throwable $e ): void {
5694 + // An \Error — a TypeError from one of OUR listeners, say — is a bug
5695 + // rather than a runtime condition a third party imposed on us, and
5696 + // swallowing it silently in production turns it into a purge that
5697 + // quietly stops working. Those are logged whatever WP_DEBUG says;
5698 + // third-party \Exceptions stay gated so a noisy integration cannot
5699 + // fill a production log.
5700 + $always = $e instanceof \Error;
5701 + if ( ( $always || ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ) && function_exists( 'error_log' ) ) {
5702 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- names a third-party listener that threw during a purge.
5703 + error_log( '[xspeed] a ' . $hook . ' listener threw: ' . $e->getMessage() );
5704 + }
5705 + }
5706 +
5707 + /**
5708 + * Test seam: clear the in-flight set left behind by an aborted dispatch,
5709 + * and the record of which saves purged in this request.
5710 + */
5711 + public static function reset_purge_events(): void {
5712 + self::$purge_events_in_flight = array();
5713 + self::$purge_event_sequence = 0;
5714 + self::$saves_purged = array();
5715 + }
5716 +
5717 + /**
5718 + * Hand the purge to the caches we ship integrations for.
5719 + *
5720 + * Isolated from the public action on purpose — see dispatch_purge_event().
5721 + * Guarded so a missing class (a partial upgrade, a stripped build) cannot
5722 + * turn a working purge into a fatal.
5723 + *
5724 + * @param array<string,mixed> $context Bounded purge context.
5725 + */
5726 + private static function forward_to_server_caches( array $context ): void {
5727 + if ( class_exists( __NAMESPACE__ . '\\Server_Caches' ) ) {
5728 + Server_Caches::forward( $context );
5729 + }
5730 + }
5731 +
5732 + /**
5733 + * `host[:port]` for a cache key, from a parsed URL.
5734 + *
5735 + * The port is kept, because `cache_key()` hashes the raw `HTTP_HOST` and
5736 + * that carries `:8080` on any install not served from 80/443 — dropping it
5737 + * computed a different md5, found no file, and reported "already cold"
5738 + * while the page kept serving HIT.
5739 + *
5740 + * A port that is the DEFAULT for the scheme is dropped, though, because
5741 + * `HTTP_HOST` does not carry one: a browser sends `Host: site.com` for
5742 + * `https://site.com:443/`. Keeping it hashed `site.com:443` against a file
5743 + * stored under `site.com` — the same silent no-op in the other direction,
5744 + * and the one QA hit passing a canonical URL with the port spelled out.
5745 + * (QA #348)
5746 + *
5747 + * @param array<string,mixed> $parts Output of wp_parse_url().
5748 + */
5749 + private static function host_port_of( array $parts ): string {
5750 + if ( ! isset( $parts['host'] ) ) {
5751 + return '';
5752 + }
5753 + $host = strtolower( (string) $parts['host'] );
5754 + if ( '' === $host || ! isset( $parts['port'] ) ) {
5755 + return $host;
5756 + }
5757 + $port = (int) $parts['port'];
5758 + $scheme = isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : '';
5759 + if ( ( 'https' === $scheme && 443 === $port ) || ( 'http' === $scheme && 80 === $port ) ) {
5760 + return $host;
5761 + }
5762 + return $host . ':' . $port;
5763 + }
5764 +
2851 5765 public static function purge_url( string $url, string $cause = 'manual' ): int {
5766 + // A URL that names nothing is not a purge of everything. An empty or
5767 + // blank string used to fall through to the home_url() default below
5768 + // and clear the HOMEPAGE — so a third party calling
5769 + // `purge_url( get_permalink( $id ) )` on a post whose permalink came
5770 + // back empty silently purged the front page instead of nothing. The
5771 + // CLI and the MCP tool reject empties before reaching this, so only
5772 + // direct API callers were exposed, but they are exactly the audience
5773 + // this public contract is for. (QA #348)
5774 + if ( '' === trim( $url ) ) {
5775 + return 0;
5776 + }
5777 + // parse_url() can turn raw UTF-8 into underscores (it depends on the
5778 + // host's C library and locale), so a pasted `/關於我們/` is encoded
5779 + // first. normalize_path() below gives the same result either way.
5780 + $url = self::encode_non_ascii( $url );
2852 5781 $parts = function_exists( 'wp_parse_url' ) ? wp_parse_url( $url ) : parse_url( $url ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- fallback for early-boot contexts only.
2853 5782 if ( ! is_array( $parts ) ) {
2854 5783 return 0;
2855 5784 }
5785 + // Absolute URLs are accepted only for HTTP response caches. Schemes such
5786 + // as ftp:, file: and javascript: can parse cleanly but do not name a page
5787 + // xSpeed or a server response cache can invalidate. A leading-slash path
5788 + // remains a supported site-relative target.
5789 + if ( isset( $parts['scheme'] ) && ! in_array( strtolower( (string) $parts['scheme'] ), array( 'http', 'https' ), true ) ) {
5790 + return 0;
5791 + }
5792 + if ( isset( $parts['scheme'] ) && empty( $parts['host'] ) ) {
5793 + return 0;
5794 + }
5795 + // Reject a string that parsed but is not a URL we can act on: no
5796 + // scheme AND no host AND no leading-slash path means something like
5797 + // `ht!tp://[[[` or a bare word, which parse_url() hands back as a
5798 + // relative "path". Forwarding that produced `purge_url(/ht!tp://[[[)`
5799 + // — a nonsense tag sent to LiteSpeed for every malformed call.
5800 + if ( ! isset( $parts['scheme'] ) && ! isset( $parts['host'] ) ) {
5801 + $raw = isset( $parts['path'] ) ? (string) $parts['path'] : '';
5802 + if ( '' === $raw || '/' !== $raw[0] ) {
5803 + return 0;
5804 + }
5805 + }
2856 5806 // Keep the port. `cache_key()` hashes the raw `HTTP_HOST`, which
2857 5807 // carries `:8080` on any install not served from 80/443 — while
2858 5808 // parse_url() splits the port into its own component, so a purge that
2859 5809 // used the bare host computed a different md5, found no file, and
@@ -2859,19 +5809,34 @@
2859 5809 // used the bare host computed a different md5, found no file, and
2860 5810 // reported "already cold". A silent no-op: the page kept serving HIT
2861 5811 // until its TTL ran out. Intranet installs, panel hosts on :8443 and
2862 5812 // proxies that forward `Host: site.com:8080` all hit this.
2863 - $host = isset( $parts['host'] ) ? strtolower( (string) $parts['host'] ) : '';
2864 - if ( '' !== $host && isset( $parts['port'] ) ) {
2865 - $host .= ':' . (int) $parts['port'];
5813 + // A scheme-less `site.test:443/page/` is a supported explicit-host
5814 + // target. Infer a scheme only when it names THIS site's hostname: then
5815 + // its explicit default port is the same origin and the same local cache
5816 + // key. Never apply this to another host or to a non-default port.
5817 + if ( ! isset( $parts['scheme'] ) && isset( $parts['host'], $parts['port'] ) && function_exists( 'home_url' ) ) {
5818 + $home = function_exists( 'wp_parse_url' ) ? wp_parse_url( home_url( '/' ) ) : parse_url( home_url( '/' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- see above.
5819 + if ( is_array( $home ) && ! empty( $home['host'] ) && ! empty( $home['scheme'] )
5820 + && strtolower( (string) $home['host'] ) === strtolower( (string) $parts['host'] )
5821 + ) {
5822 + $home_scheme = strtolower( (string) $home['scheme'] );
5823 + $port = (int) $parts['port'];
5824 + $home_port = isset( $home['port'] )
5825 + ? (int) $home['port']
5826 + : ( 'https' === $home_scheme ? 443 : ( 'http' === $home_scheme ? 80 : 0 ) );
5827 + if ( $home_port === $port
5828 + && ( ( 'https' === $home_scheme && 443 === $port ) || ( 'http' === $home_scheme && 80 === $port ) )
5829 + ) {
5830 + $parts['scheme'] = $home_scheme;
5831 + }
5832 + }
2866 5833 }
5834 + $host = self::host_port_of( $parts );
2867 5835 if ( '' === $host && function_exists( 'home_url' ) ) {
2868 5836 $home = function_exists( 'wp_parse_url' ) ? wp_parse_url( home_url( '/' ) ) : parse_url( home_url( '/' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- see above.
2869 - if ( is_array( $home ) && isset( $home['host'] ) ) {
2870 - $host = strtolower( (string) $home['host'] );
2871 - if ( isset( $home['port'] ) ) {
2872 - $host .= ':' . (int) $home['port'];
2873 - }
5837 + if ( is_array( $home ) ) {
5838 + $host = self::host_port_of( $home );
2874 5839 }
2875 5840 }
2876 5841 if ( '' === $host ) {
2877 5842 return 0;
@@ -2880,15 +5845,20 @@
2880 5845 $path = '/' . ltrim( $path, '/' );
2881 5846 if ( false !== strpos( $path, '..' ) ) {
2882 5847 return 0;
2883 5848 }
5849 + // Same spelling cache_key() hashes, so a permalink, the same URL with
5850 + // `%E7` capitals, and a pasted `/關於我們/` all find the entry. The
5851 + // purge event below keeps the caller's spelling and adds the others
5852 + // (see escape_spellings()).
5853 + $key_path = self::normalize_path( $path );
2884 5854
2885 5855 // The cache key preserves REQUEST_URI's trailing-slash form, so
2886 5856 // purge both. Root stays a single '/'.
2887 - $forms = array( $path );
2888 - if ( '/' !== $path ) {
2889 - $forms[] = rtrim( $path, '/' );
2890 - $forms[] = rtrim( $path, '/' ) . '/';
5857 + $forms = array( $key_path );
5858 + if ( '/' !== $key_path ) {
5859 + $forms[] = rtrim( $key_path, '/' );
5860 + $forms[] = rtrim( $key_path, '/' ) . '/';
2891 5861 }
2892 5862 $forms = array_unique( $forms );
2893 5863
2894 5864 /*
@@ -2924,12 +5894,15 @@
2924 5894 }
2925 5895 }
2926 5896
2927 5897 // Static tree (served directly by the nginx/.htaccess rewrite).
2928 - if ( defined( 'XSPEED_CACHE_STATIC_DIR' ) ) {
5898 + // The static tree stores decoded names (see static_path()). A path
5899 + // static_path() refuses was never written there.
5900 + $rel = self::static_path( $key_path );
5901 + if ( defined( 'XSPEED_CACHE_STATIC_DIR' ) && null !== $rel ) {
2929 5902 // Same transform the write used — `localhost:8080` files under
2930 5903 // `localhost8080`, so the bare host found nothing here either.
2931 - $dir = rtrim( XSPEED_CACHE_STATIC_DIR, '/' ) . '/' . self::static_host_dir( $host ) . ( '/' === $path ? '' : rtrim( $path, '/' ) );
5904 + $dir = rtrim( XSPEED_CACHE_STATIC_DIR, '/' ) . '/' . self::static_host_dir( $host ) . $rel;
2932 5905 $file = $dir . '/index.html';
2933 5906 if ( is_file( $file ) ) {
2934 5907 wp_delete_file( $file );
2935 5908 ++$count;
@@ -2940,9 +5913,32 @@
2940 5913 }
2941 5914 }
2942 5915 }
2943 5916
2944 - if ( $count > 0 ) {
5917 + /*
5918 + * The per-URL purge event is published by `dispatch_purge_event()`
5919 + * below, NOT here.
5920 + *
5921 + * This branch used to publish it itself, with a raw `do_action` and a
5922 + * `{scope,url,urls,cause,count}` payload. #348 then landed the purge
5923 + * contract on dev: a canonical URL, `removed` rather than `count`,
5924 + * `intent`, per-listener isolation, and an in-flight guard so a
5925 + * listener that purges its own layer cannot re-enter the event.
5926 + *
5927 + * Keeping both meant every per-URL purge fired TWICE, in two payload
5928 + * shapes, and the raw call bypassed the guard — so a listener that
5929 + * called back into `purge_url()` recursed until the process ran out
5930 + * of memory. `CachePurgeEventContractTest::
5931 + * test_a_listener_re_purging_the_same_url_does_not_recurse` is what
5932 + * caught it, and it arrived from dev with the contract it defends.
5933 + *
5934 + * The contract wins: it is a superset of what this published, and it
5935 + * is what the LiteSpeed and nginx adapters are written against.
5936 + */
5937 +
5938 + // purge_url_reported() writes this row itself once the event has
5939 + // gone out, so it can name the caches the purge was sent to.
5940 + if ( $count > 0 && null === self::$url_batch && null === self::$reported_url_purge ) {
2945 5941 Cache_Inventory::invalidate();
2946 5942 Activity_Log::record(
2947 5943 'cache_purge_url',
2948 5944 sprintf(
@@ -2955,14 +5951,384 @@
2955 5951 Activity_Log::INFO
2956 5952 );
2957 5953 }
2958 5954
5955 + /**
5956 + * Fires after one URL's cached copy has been purged.
5957 + *
5958 + * The single-URL counterpart to `xspeed_after_purge_all`. Subscribe
5959 + * here to invalidate a cache xSpeed does not own — a server-level
5960 + * cache such as LiteSpeed's LSCache, a reverse proxy, or a CDN — for
5961 + * the same URL.
5962 + *
5963 + * Only fires when the purge actually ran. A malformed URL, a URL with
5964 + * no resolvable host, or a traversal attempt returns earlier and
5965 + * publishes nothing, so a listener can treat this as "xSpeed purged
5966 + * this URL" rather than "xSpeed was asked to". `removed` may legitimately
5967 + * be 0: the URL was not in xSpeed's cache, which says nothing about
5968 + * whether it is in yours.
5969 + *
5970 + * Fires at most once per purge. A listener that calls back into
5971 + * xSpeed's purge API will not re-enter this event.
5972 + *
5973 + * @since 1.2.3
5974 + *
5975 + * @param array $context {
5976 + * Bounded description of the purge. URL queries and caller-supplied
5977 + * causes can contain sensitive values and are not logging fields.
5978 + *
5979 + * @type string $url Canonical scheme://host/path[?query] of the purged URL.
5980 + * The query is preserved because caches in front
5981 + * commonly key on it; xSpeed's own sweep is
5982 + * path-based, so `removed` describes that.
5983 + * @type string $host Host (with port when non-standard).
5984 + * @type string $path Path component, leading slash.
5985 + * @type string $cause Short label for who asked. See purge_all().
5986 + * @type int $removed Number of cache files removed.
5987 + * @type string $scope Actionable adapter scope: `urls`.
5988 + * @type string $intent Why responses changed: `content`.
5989 + * @type string[] $urls Exact response URLs to invalidate: the
5990 + * canonical URL first, then the same URL
5991 + * with the other trailing-slash spelling
5992 + * (none for the root). A path with
5993 + * percent-escapes adds the same pair with
5994 + * the escapes in lower case and in upper
5995 + * case, where those differ from it.
5996 + * @type string $fallback Always '' on this event. Present so both
5997 + * purge events share one shape; see
5998 + * `xspeed_after_purge`.
5999 + * }
6000 + */
6001 + $query = isset( $parts['query'] ) ? (string) $parts['query'] : '';
6002 + $url_scheme = isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : '';
6003 + $canonical_url = self::canonical_purge_url( $host, $path, $query, $url_scheme );
6004 + // Every spelling the local sweep above cleared, because a cache in
6005 + // front keys each one on its own. Visitors reach a page as `/about/`
6006 + // while a caller often passes `/about` (or the reverse), and a
6007 + // non-ASCII page as `/%E9%97%9C…/` while get_permalink() passes
6008 + // `/%e9%97%9c…/`. Publishing one spelling left the edge serving the
6009 + // other, usually the one visitors actually use.
6010 + $event_urls = array();
6011 + foreach ( self::escape_spellings( $path ) as $spelling ) {
6012 + $event_urls[] = self::canonical_purge_url( $host, $spelling, $query, $url_scheme );
6013 + if ( '/' === $spelling ) {
6014 + continue;
6015 + }
6016 + $other = '/' === substr( $spelling, -1 ) ? rtrim( $spelling, '/' ) : $spelling . '/';
6017 + if ( '' !== $other ) {
6018 + $event_urls[] = self::canonical_purge_url( $host, $other, $query, $url_scheme );
6019 + }
6020 + }
6021 + // Inside purge_urls(): the batch publishes one event for every URL,
6022 + // in the trailing-slash form it was given. The local sweep above still
6023 + // took both forms. Escape spellings are still added, because the other
6024 + // escape case does not redirect: a cache in front keeps the browser's
6025 + // `%E9…` copy apart from the permalink's `%e9…` one.
6026 + if ( null !== self::$url_batch ) {
6027 + if ( '' === self::$url_batch['url'] ) {
6028 + self::$url_batch['url'] = $canonical_url;
6029 + self::$url_batch['host'] = $host;
6030 + self::$url_batch['path'] = $path;
6031 + }
6032 + self::$url_batch['removed'] += $count;
6033 + foreach ( self::escape_spellings( $path ) as $spelling ) {
6034 + self::$url_batch['urls'][ self::canonical_purge_url( $host, $spelling, $query, $url_scheme ) ] = true;
6035 + }
6036 + return $count;
6037 + }
6038 +
6039 + self::dispatch_purge_event(
6040 + 'xspeed_after_purge_url',
6041 + array(
6042 + 'url' => $canonical_url,
6043 + 'host' => $host,
6044 + 'path' => $path,
6045 + 'cause' => $cause,
6046 + 'removed' => $count,
6047 + 'scope' => 'urls',
6048 + 'intent' => 'content',
6049 + 'urls' => array_values( array_unique( $event_urls ) ),
6050 + 'fallback' => '',
6051 + )
6052 + );
6053 +
2959 6054 return $count;
2960 6055 }
2961 6056
2962 6057 /**
2963 - * Purge this site's cache.
6058 + * The batch purge_urls() is collecting, or null outside one.
2964 6059 *
6060 + * @var array{url:string,host:string,path:string,removed:int,urls:array<string,bool>}|null
6061 + */
6062 + private static $url_batch = null;
6063 +
6064 + /**
6065 + * The caches in front of PHP that took a purge while a report is open,
6066 + * keyed by label, or null when no report is open.
6067 + *
6068 + * @var array<string,bool>|null
6069 + */
6070 + private static $forward_report = null;
6071 +
6072 + /**
6073 + * Note that a purge was handed to a cache in front of PHP: a server
6074 + * cache, a host cache, a CDN or an edge.
6075 + *
6076 + * The built-in adapters call this when they accept a purge, whether
6077 + * they send it now or queue it for the end of the request. A third-party
6078 + * adapter listening on `xspeed_after_purge_url` may call it too, so an
6079 + * operator's purge names it. Does nothing unless a caller opened a
6080 + * report with purge_url_reported().
6081 + *
6082 + * @param string $layer Name to show, such as "Nginx Helper".
6083 + */
6084 + public static function note_purge_forwarded( string $layer ): void {
6085 + if ( null === self::$forward_report || '' === trim( $layer ) ) {
6086 + return;
6087 + }
6088 + self::$forward_report[ $layer ] = true;
6089 + }
6090 +
6091 + /**
6092 + * Run a purge and collect the caches in front of PHP it was sent to.
6093 + *
6094 + * @param callable $purge The purge to run.
6095 + * @return array{result:mixed,forwarded:string[]}
6096 + */
6097 + public static function report_forwarding( callable $purge ): array {
6098 + $outer = self::$forward_report;
6099 + self::$forward_report = array();
6100 + try {
6101 + $result = $purge();
6102 + } finally {
6103 + $forwarded = array_keys( (array) self::$forward_report );
6104 + self::$forward_report = null === $outer ? null : $outer + (array) self::$forward_report;
6105 + }
6106 + return array(
6107 + 'result' => $result,
6108 + 'forwarded' => $forwarded,
6109 + );
6110 + }
6111 +
6112 + /**
6113 + * Set while purge_url_reported() runs, so purge_url() leaves the
6114 + * purge-log row to it. Null otherwise.
6115 + *
6116 + * @var bool|null
6117 + */
6118 + private static $reported_url_purge = null;
6119 +
6120 + /**
6121 + * Purge one URL for an operator, and say where the purge went.
6122 + *
6123 + * purge_url() writes a purge-log row only when it removed a local file,
6124 + * because Pro's beacons call it on visitor requests. On a site whose
6125 + * pages are held only by a server cache (xSpeed's page cache off, nginx
6126 + * in front) an operator's purge then removed nothing locally, was sent
6127 + * to the server cache, and left no row and an "already cold" answer.
6128 + * This adds the row in that case. The CLI, the MCP tool (which runs the
6129 + * CLI) and the admin "Purge this URL" link use it.
6130 + *
6131 + * @param string $url Absolute URL or site-relative path.
6132 + * @param string $cause Who asked, for the purge log.
6133 + * @return array{removed:int,forwarded:string[]} Files removed here, and
6134 + * the caches the purge was sent to.
6135 + */
6136 + public static function purge_url_reported( string $url, string $cause ): array {
6137 + $outer = self::$reported_url_purge;
6138 + self::$reported_url_purge = true;
6139 + try {
6140 + $run = self::report_forwarding(
6141 + static function () use ( $url, $cause ): int {
6142 + return self::purge_url( $url, $cause );
6143 + }
6144 + );
6145 + } finally {
6146 + self::$reported_url_purge = $outer;
6147 + }
6148 + $removed = (int) $run['result'];
6149 + $forwarded = $run['forwarded'];
6150 +
6151 + if ( $removed > 0 ) {
6152 + Cache_Inventory::invalidate();
6153 + $message = array() === $forwarded
6154 + ? sprintf(
6155 + /* translators: 1: cause of the purge, 2: URL or path, 3: number of files removed. */
6156 + __( 'Purged one URL (%1$s) — %2$s, %3$d file(s) removed', 'xspeed' ),
6157 + $cause,
6158 + $url,
6159 + $removed
6160 + )
6161 + : sprintf(
6162 + /* translators: 1: cause of the purge, 2: URL or path, 3: number of files removed, 4: caches the purge was sent to. */
6163 + __( 'Purged one URL (%1$s) — %2$s, %3$d file(s) removed, sent to %4$s', 'xspeed' ),
6164 + $cause,
6165 + $url,
6166 + $removed,
6167 + implode( ', ', $forwarded )
6168 + );
6169 + Activity_Log::record( 'cache_purge_url', $message, Activity_Log::INFO );
6170 + } elseif ( array() !== $forwarded ) {
6171 + Activity_Log::record(
6172 + 'cache_purge_url',
6173 + sprintf(
6174 + /* translators: 1: cause of the purge, 2: URL or path, 3: caches the purge was sent to. */
6175 + __( 'Purged one URL (%1$s): %2$s, 0 file(s) removed here, sent to %3$s', 'xspeed' ),
6176 + $cause,
6177 + $url,
6178 + implode( ', ', $forwarded )
6179 + ),
6180 + Activity_Log::INFO
6181 + );
6182 + }
6183 +
6184 + return array(
6185 + 'removed' => $removed,
6186 + 'forwarded' => $forwarded,
6187 + );
6188 + }
6189 +
6190 + /**
6191 + * Purge several URLs as one purge: each URL's local copy goes as in
6192 + * purge_url(), then ONE `xspeed_after_purge_url` event carries every URL.
6193 + *
6194 + * One event rather than one per URL, so the purge log gets one line, an
6195 + * edge gets one batch, and a listener that counts purges counts one. The
6196 + * event is the same contract purge_url() publishes: `scope` is `urls`,
6197 + * `url` is the first URL, `urls` is all of them.
6198 + *
6199 + * Unlike purge_url(), `urls` holds each URL in the spelling it was given
6200 + * and not the other trailing-slash spelling too. Callers pass WordPress's
6201 + * own links (get_permalink(), get_term_link() and the like), which are
6202 + * the spelling visitors reach; the other spelling redirects to it, so a
6203 + * cache in front holds nothing stale under it. Both spellings doubled the
6204 + * list: a typical save named 34 pages as 67 URLs, past xCloud's 50-URL
6205 + * batch and the Hub's 30 per call. xSpeed's own sweep still removes both
6206 + * spellings of each. The escape spellings (escape_spellings()) are still
6207 + * listed, because `%E9…` does not redirect to `%e9…`; they add URLs only
6208 + * for a path with percent-escapes.
6209 + *
6210 + * The object cache is left alone, as purge_url() leaves it. WordPress
6211 + * already drops a post's own entries when the post, its terms or its
6212 + * comments change (clean_post_cache() and the like), so a flush here
6213 + * cleared nothing stale. What it did do: an approved visitor comment
6214 + * emptied Redis or Memcached for the whole site, as often as a visitor
6215 + * cared to comment. With a persistent object cache, transients live
6216 + * there too, so a flush could also drop work a purge listener had just
6217 + * queued in one. purge_all() still flushes.
6218 + *
6219 + * @param string[] $urls Absolute URLs or site-relative paths.
6220 + * @param string $cause Who asked, for the purge log.
6221 + * @param string $intent Why responses changed: `content` by default.
6222 + * @return int Cache files removed.
6223 + */
6224 + public static function purge_urls( array $urls, string $cause = 'manual', string $intent = 'content' ): int {
6225 + $outer = self::$url_batch;
6226 + self::$url_batch = array(
6227 + 'url' => '',
6228 + 'host' => '',
6229 + 'path' => '',
6230 + 'removed' => 0,
6231 + 'urls' => array(),
6232 + );
6233 + try {
6234 + foreach ( array_unique( array_filter( $urls, 'is_string' ) ) as $url ) {
6235 + self::purge_url( $url, $cause );
6236 + }
6237 + } finally {
6238 + $batch = self::$url_batch;
6239 + self::$url_batch = $outer;
6240 + }
6241 + if ( '' === $batch['url'] ) {
6242 + return 0;
6243 + }
6244 +
6245 + Cache_Inventory::invalidate();
6246 + self::update_stats( array( 'last_purge' => time() ) );
6247 + // Same type as one URL's purge, so the purge log and the dashboard's
6248 + // drill-down list it with the others.
6249 + Activity_Log::record(
6250 + 'cache_purge_url',
6251 + sprintf(
6252 + /* translators: 1: number of pages, 2: cause of the purge, 3: number of files removed. */
6253 + _n( 'Purged %1$d page (%2$s), %3$d file(s) removed', 'Purged %1$d pages (%2$s), %3$d file(s) removed', count( $urls ), 'xspeed' ),
6254 + count( $urls ),
6255 + $cause,
6256 + $batch['removed']
6257 + ),
6258 + Activity_Log::INFO
6259 + );
6260 +
6261 + self::dispatch_purge_event(
6262 + 'xspeed_after_purge_url',
6263 + array(
6264 + 'url' => $batch['url'],
6265 + 'host' => $batch['host'],
6266 + 'path' => $batch['path'],
6267 + 'cause' => $cause,
6268 + 'removed' => $batch['removed'],
6269 + 'scope' => 'urls',
6270 + 'intent' => $intent,
6271 + 'urls' => array_keys( $batch['urls'] ),
6272 + 'fallback' => '',
6273 + )
6274 + );
6275 +
6276 + return $batch['removed'];
6277 + }
6278 +
6279 + /** Host this site's purge is scoped to, for the purge-event context. */
6280 + private static function current_purge_host(): string {
6281 + if ( ! function_exists( 'home_url' ) ) {
6282 + return '';
6283 + }
6284 + $home = function_exists( 'wp_parse_url' ) ? wp_parse_url( home_url( '/' ) ) : parse_url( home_url( '/' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- host only.
6285 + if ( ! is_array( $home ) || empty( $home['host'] ) ) {
6286 + return '';
6287 + }
6288 + // Same default-port normalisation as purge_url(): a site whose
6289 + // home_url() carries `:443` (normal behind a proxy) otherwise stamps
6290 + // every full-purge event with a host that matches none of its own
6291 + // URLs, so the LiteSpeed forward stood down site-wide. (QA #348)
6292 + return self::host_port_of( $home );
6293 + }
6294 +
6295 + /**
6296 + * Rebuild the canonical URL a purge applied to.
6297 + *
6298 + * Built from the parts the purge itself used, so a listener is told the
6299 + * URL we acted on rather than the string the caller happened to pass —
6300 + * those differ whenever the caller supplied a site-relative path, a
6301 + * different scheme, or a query string the cache key ignores.
6302 + */
6303 + private static function canonical_purge_url( string $host, string $path, string $query = '', string $url_scheme = '' ): string {
6304 + // The purged URL's own scheme wins. purge_url() explicitly supports
6305 + // cross-site purges (multisite, WP-CLI, cron), where composing the
6306 + // current site's scheme onto another site's host builds a URL that was
6307 + // never served — and a CDN listener then purges the wrong key and
6308 + // reports success.
6309 + if ( '' !== $url_scheme ) {
6310 + return $url_scheme . '://' . $host . $path . ( '' !== $query ? '?' . $query : '' );
6311 + }
6312 + $scheme = function_exists( 'is_ssl' ) && is_ssl() ? 'https' : 'http';
6313 + if ( function_exists( 'home_url' ) ) {
6314 + $home = function_exists( 'wp_parse_url' ) ? wp_parse_url( home_url( '/' ) ) : parse_url( home_url( '/' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- scheme only.
6315 + if ( is_array( $home ) && ! empty( $home['scheme'] ) ) {
6316 + $scheme = (string) $home['scheme'];
6317 + }
6318 + }
6319 + // The query is carried even though OUR sweep above is path-based.
6320 + // Caches in front commonly key on the full request line — LiteSpeed
6321 + // tags `/shop/?page=2` separately from `/shop/` — so publishing the
6322 + // bare path would have a listener confidently purge the wrong entry
6323 + // and report success. Telling it exactly what was asked for lets it
6324 + // act correctly; `removed` still describes only what WE removed.
6325 + return $scheme . '://' . $host . $path . ( '' !== $query ? '?' . $query : '' );
6326 + }
6327 +
6328 + /**
6329 + * Sweep this site's cache files.
6330 + *
2965 6331 * On multisite every blog shares one cache directory, so an unscoped
2966 6332 * sweep here took the whole network cold — one subsite's settings save
2967 6333 * or post publish rebuilt every other site from PHP. Entries are stored
2968 6334 * per host (see host_dir()), and the sweep is scoped to match, so a
@@ -2969,12 +6335,13 @@
2969 6335 * purge originating on site-a leaves site-b's cache warm. (#6)
2970 6336 *
2971 6337 * Clears the files only: the flat tree, the static tree, the REST
2972 6338 * responses and the minified assets. The object-cache flush, the stats
2973 - * update, `xspeed_after_purge_all` and the log entry live in purge_all(),
2974 - * which is still the entry point for every existing caller. Split out so
2975 - * `wp xspeed purge` can report the local sweep as one line item and the
2976 - * object cache as another, each with its own status — see Purge_Runner.
6339 + * update, `xspeed_after_purge_all`, the `xspeed_after_purge` contract
6340 + * event and the log entry live in purge_all(), which is still the entry
6341 + * point for every existing caller. Split out so `wp xspeed purge` can
6342 + * report the local sweep as one line item and the object cache as
6343 + * another, each with its own status — see Purge_Runner.
2977 6344 *
2978 6345 * @param string|null $host Host to purge. Defaults to the current site.
2979 6346 * Pass '*' to sweep the ENTIRE tree — network
2980 6347 * admin's "purge all sites", and the migration
@@ -2995,9 +6362,10 @@
2995 6362 $static_scope = $network_wide ? '' : self::current_static_scope();
2996 6363 } else {
2997 6364 $dir = self::host_dir( $host );
2998 6365 $scope = '' === $dir ? 'default' : $dir;
2999 - $static_scope = $scope;
6366 + $static_dir = self::static_host_dir( $host );
6367 + $static_scope = '' === $static_dir ? 'default' : $static_dir;
3000 6368 }
3001 6369
3002 6370 $count = 0;
3003 6371 if ( is_dir( XSPEED_CACHE_DIR ) ) {
@@ -3097,15 +6465,22 @@
3097 6465 // triggers (publish, settings change) invalidate it too.
3098 6466 $rest = Rest_Cache::purge();
3099 6467 $count += $rest;
3100 6468
3101 - // Minified + combined CSS/JS (cache/xspeed/min/ and min/combined/).
3102 - // purge_all is a full filesystem sweep and must clear these too, even
3103 - // when the Minify module is currently disabled — orphaned min/ files
3104 - // from a feature the user later turned off must still be removed, and
3105 - // a stale combined-<hash>.css that the regenerated page no longer
3106 - // references otherwise 404s and breaks the frontend. (FBS-83114/83116)
3107 - $assets = class_exists( '\\XSpeed\\Minifier' ) ? Minifier::purge_minified() : 0;
6469 + // Minified + combined CSS/JS (cache/xspeed/min/ and min/combined/)
6470 + // go only on a NETWORK-wide sweep.
6471 + //
6472 + // They are named by content, so a site purge gains nothing by deleting
6473 + // them: the next render links the same names for unchanged sources
6474 + // and new names for changed ones. Deleting them did cost something.
6475 + // A page rendered just before the purge and stored just after linked
6476 + // files that were gone (unstyled until the TTL), and min/ is shared
6477 + // by every blog, so one subsite's purge broke every other subsite's
6478 + // cached pages. Orphans are Cache_GC's job; a network purge, an
6479 + // update, the explicit assets purge and deactivation still clear the
6480 + // tree, and the purge stamp stops a render in flight from being
6481 + // stored against deleted files.
6482 + $assets = ( $network_wide && class_exists( '\\XSpeed\\Minifier' ) ) ? Minifier::purge_minified() : 0;
3108 6483
3109 6484 return array(
3110 6485 'pages' => $count - $rest,
3111 6486 'rest' => $rest,
@@ -3149,15 +6524,47 @@
3149 6524 * inventory invalidation, purge log).
3150 6525 *
3151 6526 * @param string $cause Who asked, for the purge log.
3152 6527 * @param string|null $host See purge_local().
6528 + * @param array<string,mixed> $invalidation Public adapter policy. `scope`
6529 + * is urls/site/network/none,
6530 + * `intent` explains why, and
6531 + * `urls` supplies exact targets.
3153 6532 * @return int Page + REST entries removed.
3154 6533 */
3155 - public static function purge_all( string $cause = 'manual', ?string $host = null ) {
3156 - $network_wide = ( '*' === $host );
3157 - $removed = self::purge_local( $host );
3158 - $count = $removed['pages'] + $removed['rest'];
6534 + public static function purge_all( string $cause = 'manual', ?string $host = null, array $invalidation = array() ) {
6535 + $network_wide = ( '*' === $host );
6536 + $adapter_scope = isset( $invalidation['scope'] ) && is_string( $invalidation['scope'] )
6537 + ? $invalidation['scope']
6538 + : ( $network_wide ? 'network' : 'site' );
6539 + if ( ! in_array( $adapter_scope, array( 'urls', 'site', 'network', 'none' ), true ) ) {
6540 + $adapter_scope = $network_wide ? 'network' : 'site';
6541 + }
6542 + if ( $network_wide ) {
6543 + $adapter_scope = 'network';
6544 + }
6545 + $intent = isset( $invalidation['intent'] ) && is_string( $invalidation['intent'] ) && '' !== $invalidation['intent']
6546 + ? $invalidation['intent']
6547 + : 'complete';
6548 + $urls = isset( $invalidation['urls'] ) && is_array( $invalidation['urls'] )
6549 + ? array_values( array_unique( array_filter( $invalidation['urls'], 'is_string' ) ) )
6550 + : array();
6551 + // This method always sweeps a complete local bucket. A narrower adapter
6552 + // announcement would claim unrelated local pages stayed warm when they
6553 + // did not, leaving their server copies stale. Until purge_all() gains
6554 + // dependency-aware local deletion, its response scope cannot be `urls`.
6555 + if ( 'urls' === $adapter_scope ) {
6556 + $adapter_scope = $network_wide ? 'network' : 'site';
6557 + }
6558 + if ( 'site' === $adapter_scope || 'network' === $adapter_scope || 'none' === $adapter_scope ) {
6559 + $urls = array();
6560 + }
3159 6561
6562 + $fallback = isset( $invalidation['fallback'] ) && is_string( $invalidation['fallback'] ) ? $invalidation['fallback'] : '';
6563 +
6564 + $removed = self::purge_local( $host );
6565 + $count = $removed['pages'] + $removed['rest'];
6566 +
3160 6567 self::flush_object_cache( $network_wide );
3161 6568
3162 6569 self::update_stats( array( 'last_purge' => time() ) );
3163 6570
@@ -3165,10 +6572,78 @@
3165 6572 // Unused CSS, Cloudflare edge purge) run — this action had three
3166 6573 // registered listeners but was never emitted. Treat it as additive
3167 6574 // (CDN / edge invalidation), not the mechanism for clearing local
3168 6575 // files. (FBS-83114)
3169 - do_action( 'xspeed_after_purge_all', $cause );
6576 + // Wrapped: this action predates the purge-event contract and has its
6577 + // own third-party listeners. One of them throwing used to abort
6578 + // purge_all() here, which now also means the contract event below
6579 + // never fires and a server cache keeps serving stale HTML. The local
6580 + // sweep is already done by this point, so swallowing is strictly safer
6581 + // than letting a listener decide the rest of the method runs.
6582 + try {
6583 + // Isolated per listener: one throwing used to cancel every
6584 + // listener queued behind it — Critical CSS, Unused CSS and the
6585 + // Cloudflare edge purge all hang off this hook. (QA #348)
6586 + self::do_action_isolated( 'xspeed_after_purge_all', $cause );
6587 + } catch ( \Throwable $e ) {
6588 + self::log_purge_listener_error( 'xspeed_after_purge_all', $e );
6589 + }
3170 6590
6591 + /**
6592 + * Fires after a full purge, with the same bounded context shape as
6593 + * `xspeed_after_purge_url`.
6594 + *
6595 + * Distinct from `xspeed_after_purge_all` on purpose. That action is
6596 + * the long-standing internal signal — it passes a bare `$cause` string
6597 + * and Free's own modules use it for local bookkeeping. This one is the
6598 + * documented contract for OUTSIDE integrations: same argument shape as
6599 + * the per-URL event, so a server-cache or CDN adapter can subscribe to
6600 + * both with one handler and branch on a null `url`.
6601 + *
6602 + * Fires at most once per purge, and not at all when a listener's own
6603 + * purge re-enters xSpeed.
6604 + *
6605 + * @since 1.2.3
6606 + *
6607 + * @param array $context {
6608 + * @type null $url Always null — a full purge has no single URL.
6609 + * @type string $host Host swept, or '*' for the entire tree.
6610 + * @type null $path Always null.
6611 + * @type string $cause Short label for who asked.
6612 + * @type int $removed Number of cache files removed.
6613 + * @type string $scope Adapter action: urls/site/network/none.
6614 + * @type string $intent content/presentation/complete or a caller-defined intent.
6615 + * @type string[] $urls Exact targets when scope is urls.
6616 + * @type string $fallback Why a change that would have cleared
6617 + * only its own pages cleared the whole site
6618 + * instead: `theme_list`, `limit`, `pending`
6619 + * or `filter`. Empty for every other purge.
6620 + * See the purge-event contract in
6621 + * docs/guides/hooks-and-filters.md.
6622 + * }
6623 + */
6624 + self::dispatch_purge_event(
6625 + 'xspeed_after_purge',
6626 + array(
6627 + 'url' => null,
6628 + 'host' => null === $host ? self::current_purge_host() : (string) $host,
6629 + 'path' => null,
6630 + 'cause' => $cause,
6631 + 'removed' => $count,
6632 + 'scope' => $adapter_scope,
6633 + 'intent' => $intent,
6634 + 'urls' => $urls,
6635 + 'fallback' => $fallback,
6636 + )
6637 + );
6638 +
6639 + /*
6640 + * The generic event is published by the `dispatch_purge_event()` call
6641 + * directly above, NOT here. Same supersession as in `purge_url()`:
6642 + * this branch's raw `do_action` with a `count` payload predates #348's
6643 + * contract, and keeping both made a full purge publish twice.
6644 + */
6645 +
3171 6646 // The list behind the "Cached pages" card is memoized for a minute;
3172 6647 // a purge has to drop it or the drill-down shows pages that no
3173 6648 // longer exist.
3174 6649 Cache_Inventory::invalidate();
@@ -3267,8 +6742,13 @@
3267 6742 * more than it needs to. A cold cache is the cheap direction, and the
3268 6743 * alternative — scoping the signal per upgrader — is not knowable from
3269 6744 * `upgrader_clear_destination`.
3270 6745 *
6746 + * The observed-destination signal is dropped here too. The two are read
6747 + * together and have to expire together: leaving "the directory was not
6748 + * there" behind would let a first install answer for whatever ran next
6749 + * in the same request, and that one's mistake is a cache left stale.
6750 + *
3271 6751 * @return void
3272 6752 */
3273 6753 public static function forget_cleared_destination(): void {
3274 6754 if ( self::$upgrade_dispatch_depth > 0 ) {
@@ -3276,12 +6756,84 @@
3276 6756 }
3277 6757
3278 6758 if ( 0 === self::$upgrade_dispatch_depth ) {
3279 6759 self::$upgrade_cleared_destination = false;
6760 + self::$upgrade_destination_existed = null;
6761 + self::$upgrade_destination_folder = '';
3280 6762 }
3281 6763 }
3282 6764
3283 6765 /**
6766 + * Whether the destination this run installs into was already there.
6767 + *
6768 + * Null while unknown — an upgrader whose target we cannot work out keeps
6769 + * the old behaviour rather than being guessed at.
6770 + *
6771 + * @var bool|null
6772 + */
6773 + private static $upgrade_destination_existed = null;
6774 +
6775 + /**
6776 + * The folder name that answer was measured against, so the destination
6777 + * WordPress reports on `upgrader_clear_destination` can be checked
6778 + * against it. Empty when nothing was measured.
6779 + *
6780 + * @var string
6781 + */
6782 + private static $upgrade_destination_folder = '';
6783 +
6784 + /**
6785 + * Note whether the package's destination exists, before it is cleared.
6786 + *
6787 + * `upgrader_source_selection` is the last hook that fires while the old
6788 + * copy is still on disk, and the extracted source folder name is the
6789 + * directory the package will install into. A pass-through listener: the
6790 + * source is returned untouched.
6791 + *
6792 + * @param mixed $source Extracted package directory.
6793 + * @param mixed $remote_src Unused; the package's remote source.
6794 + * @param mixed $upgrader The upgrader instance, if one was supplied.
6795 + * @param mixed $hook_extra Context supplied by the upgrader.
6796 + * @return mixed The source, unchanged.
6797 + */
6798 + public static function note_destination_state( $source, $remote_src = '', $upgrader = null, $hook_extra = array() ) {
6799 + self::$upgrade_destination_existed = null;
6800 + self::$upgrade_destination_folder = '';
6801 +
6802 + $root = self::upgrade_destination_root( $upgrader, is_array( $hook_extra ) ? $hook_extra : array() );
6803 + if ( null !== $root && is_string( $source ) && '' !== $source ) {
6804 + $folder = basename( rtrim( $source, '/\\' ) );
6805 + if ( '' !== $folder ) {
6806 + self::$upgrade_destination_existed = is_dir( rtrim( $root, '/\\' ) . '/' . $folder );
6807 + self::$upgrade_destination_folder = $folder;
6808 + }
6809 + }
6810 +
6811 + return $source;
6812 + }
6813 +
6814 + /**
6815 + * Where a package of this kind installs to, or null if we cannot tell.
6816 + *
6817 + * @param mixed $upgrader The upgrader instance, if one was supplied.
6818 + * @param array $hook_extra Context supplied by the upgrader.
6819 + * @return string|null
6820 + */
6821 + private static function upgrade_destination_root( $upgrader, array $hook_extra ): ?string {
6822 + $type = isset( $hook_extra['type'] ) ? (string) $hook_extra['type'] : '';
6823 +
6824 + if ( 'plugin' === $type || $upgrader instanceof \Plugin_Upgrader ) {
6825 + return defined( 'WP_PLUGIN_DIR' ) ? WP_PLUGIN_DIR : null;
6826 + }
6827 +
6828 + if ( 'theme' === $type || $upgrader instanceof \Theme_Upgrader ) {
6829 + return function_exists( 'get_theme_root' ) ? (string) get_theme_root() : null;
6830 + }
6831 +
6832 + return null;
6833 + }
6834 +
6835 + /**
3284 6836 * Record that the upgrader cleared an existing destination.
3285 6837 *
3286 6838 * A pass-through listener on `upgrader_clear_destination`: WordPress only
3287 6839 * fires it when `clear_destination` was set AND something was there to
@@ -3290,13 +6842,28 @@
3290 6842 *
3291 6843 * @param true|\WP_Error $removed Whether the destination was cleared.
3292 6844 * @return true|\WP_Error
3293 6845 */
3294 - public static function note_cleared_destination( $removed ) {
6846 + public static function note_cleared_destination( $removed, $local_destination = '', $remote_destination = '', $hook_extra = array() ) {
3295 6847 if ( ! is_wp_error( $removed ) ) {
3296 6848 self::$upgrade_cleared_destination = true;
3297 6849 }
3298 6850
6851 + // $remote_destination is the directory WordPress actually cleared,
6852 + // derived from the source AFTER every `upgrader_source_selection`
6853 + // listener ran. If its folder is not the one note_destination_state()
6854 + // measured, a listener that ran after ours renamed the package, and
6855 + // the "was it there?" answer is about a directory that was never going
6856 + // to be written. Unknown is the answer that purges, so that is what it
6857 + // becomes. Only the last segment is compared: over FTP the remote
6858 + // path sits under the server's own root, not WP_PLUGIN_DIR. (#407 QA)
6859 + if ( is_string( $remote_destination ) && '' !== $remote_destination ) {
6860 + $cleared_folder = basename( rtrim( $remote_destination, '/\\' ) );
6861 + if ( '' !== $cleared_folder && $cleared_folder !== self::$upgrade_destination_folder ) {
6862 + self::$upgrade_destination_existed = null;
6863 + }
6864 + }
6865 +
3299 6866 return $removed;
3300 6867 }
3301 6868
3302 6869 /**
@@ -3410,8 +6977,18 @@
3410 6977 if ( 'install' === $action && ! $cleared ) {
3411 6978 return false;
3412 6979 }
3413 6980
6981 + // A cleared destination is only evidence of a replacement if there was
6982 + // something in it. Core returns success from clear_destination() for a
6983 + // destination that never existed, so a first-time install arrived here
6984 + // looking exactly like an upload-and-replace and bought a cold cache
6985 + // for a plugin that is not even active yet. Only acted on when we
6986 + // positively know the directory was absent.
6987 + if ( 'install' === $action && false === self::$upgrade_destination_existed ) {
6988 + return false;
6989 + }
6990 +
3414 6991 // 'translation' is the one update type that cannot change rendered
3415 6992 // markup. Anything else — including an empty type from a custom
3416 6993 // updater — is treated as cache-invalidating, because guessing wrong
3417 6994 // in that direction only costs a cold cache.
@@ -3435,10 +7012,10 @@
3435 7012 *
3436 7013 * @return void
3437 7014 */
3438 7015 private static function purge_for_upgrade(): void {
7016 + // Network-wide, so purge_local() also clears min/.
3439 7017 self::purge_all( 'upgrade', '*' );
3440 - Minifier::purge_minified();
3441 7018 }
3442 7019
3443 7020 /**
3444 7021 * Purge after an unattended background update run.
@@ -3768,37 +7345,13 @@
3768 7345 $count = self::purge_pages();
3769 7346 self::update_stats( array( 'last_purge' => time() ) );
3770 7347 Cache_Inventory::invalidate();
3771 7348 self::record_partial_purge( 'page', $cause, $count );
7349 + self::announce_purge( $cause, $count );
3772 7350 return $count;
3773 7351
3774 7352 case 'assets':
3775 - if ( class_exists( '\\XSpeed\\Minifier' ) ) {
3776 - Minifier::purge_minified();
3777 - }
3778 - // Deleting min/ without clearing the pages that link it left
3779 - // every cached page pointing at files that no longer exist.
3780 - // WordPress answers the missing asset by 301-ing to its
3781 - // pretty-permalink form and serving the 404 TEMPLATE as
3782 - // `HTTP 200 text/html`, which the browser accepts as a
3783 - // stylesheet and parses to zero rules — no console error, no
3784 - // network failure, no 4xx anywhere in devtools. The pages
3785 - // stayed broken for the rest of the TTL (7 days on
3786 - // Aggressive, up to 30), and the admin who clicked could not
3787 - // see it: they are logged in, so their own requests bypass
3788 - // the page cache and re-render, regenerating the assets as a
3789 - // side effect. Only anonymous visitors were served the stale
3790 - // HTML. (#244)
3791 - //
3792 - // The assets are the pages' dependency, so invalidating them
3793 - // invalidates the pages. Same invariant Cache_GC enforces
3794 - // with is_referenced(): never leave a cached page pointing at
3795 - // an asset that is gone.
3796 - $count = self::purge_pages();
3797 - self::update_stats( array( 'last_purge' => time() ) );
3798 - Cache_Inventory::invalidate();
3799 - self::record_partial_purge( 'assets', $cause, $count );
3800 - return $count;
7353 + return self::purge_assets( $cause );
3801 7354
3802 7355 case 'object':
3803 7356 if ( function_exists( 'wp_cache_flush' ) ) {
3804 7357 wp_cache_flush();
@@ -3808,8 +7361,9 @@
3808 7361
3809 7362 case 'rest':
3810 7363 $count = Rest_Cache::purge();
3811 7364 self::record_partial_purge( 'REST responses', $cause, $count );
7365 + self::announce_purge( $cause, $count );
3812 7366 return $count;
3813 7367
3814 7368 default:
3815 7369 return self::purge_type_unhandled( $type, $cause );
@@ -3816,8 +7370,163 @@
3816 7370 }
3817 7371 }
3818 7372
3819 7373 /**
7374 + * Whether a widget change already purged in this request.
7375 + *
7376 + * @var bool
7377 + */
7378 + private static $widgets_purged = false;
7379 +
7380 + /**
7381 + * A widget option changed: purge when it is a widget's settings.
7382 + *
7383 + * @param string $option Option name.
7384 + */
7385 + public static function on_widget_option_change( $option ): void {
7386 + if ( is_string( $option ) && 0 === strpos( $option, 'widget_' ) ) {
7387 + self::on_widgets_change();
7388 + }
7389 + }
7390 +
7391 + /**
7392 + * A widget was added, removed, moved or edited. Sidebars render on every
7393 + * page, so the whole site goes, once per request: one save writes both
7394 + * `sidebars_widgets` and the widget's own option.
7395 + *
7396 + * A Customizer publish writes the same options and purges on its own
7397 + * (on_customize_save()), so it is left to that.
7398 + */
7399 + public static function on_widgets_change(): void {
7400 + if ( self::$widgets_purged || did_action( 'customize_save' ) ) {
7401 + return;
7402 + }
7403 + self::$widgets_purged = true;
7404 + self::purge_all(
7405 + 'widgets',
7406 + null,
7407 + array(
7408 + 'scope' => 'site',
7409 + 'intent' => 'presentation',
7410 + 'urls' => array(),
7411 + )
7412 + );
7413 + }
7414 +
7415 + /**
7416 + * Purge this site's pages after a Customizer publish.
7417 + *
7418 + * A presentation change: every page's markup or inline CSS may differ, so
7419 + * the whole site bucket goes, as for a template or global-styles edit.
7420 + * min/ stays, because its files are named by content; this blog's
7421 + * manifests are dropped so the next render re-reads every source rather
7422 + * than trusting a signature. Bound with no arguments, because the hook
7423 + * passes the WP_Customize_Manager, which purge_all() would take as its
7424 + * cause.
7425 + */
7426 + public static function on_customize_save(): void {
7427 + self::purge_all(
7428 + 'customizer',
7429 + null,
7430 + array(
7431 + 'scope' => 'site',
7432 + 'intent' => 'presentation',
7433 + 'urls' => array(),
7434 + )
7435 + );
7436 + if ( class_exists( '\\XSpeed\\Minifier' ) ) {
7437 + Minifier::purge_manifests( Asset_Manifest::blog_id() );
7438 + }
7439 + }
7440 +
7441 + /**
7442 + * Purge minified and combined CSS/JS, and the pages that link them.
7443 + *
7444 + * Scope follows who shares what:
7445 + *
7446 + * - Single site: min/ is deleted outright.
7447 + * - One site of a network: min/ is shared by every blog, so only this
7448 + * blog's manifests go. Its next render re-reads each source and
7449 + * rebuilds only what changed; no other blog loses a file it links.
7450 + * Outputs nothing links any more are left to Cache_GC.
7451 + * - $network: min/ and every blog's pages, network-wide.
7452 + *
7453 + * Deleting min/ without clearing the pages that link it left every
7454 + * cached page pointing at files that no longer exist. WordPress answers
7455 + * the missing asset by 301-ing to its pretty-permalink form and serving
7456 + * the 404 TEMPLATE as `HTTP 200 text/html`, which the browser accepts as
7457 + * a stylesheet and parses to zero rules — no console error, no network
7458 + * failure, no 4xx anywhere in devtools. The pages stayed broken for the
7459 + * rest of the TTL, and the admin who clicked could not see it: they are
7460 + * logged in, so their own requests bypass the page cache. (#244) So the
7461 + * pages go too, and when files were actually deleted the edge is told
7462 + * through `xspeed_after_purge_all`, or it keeps serving HTML that links
7463 + * them.
7464 + *
7465 + * The public `xspeed_after_purge` event carries intent `assets`: rendered
7466 + * markup did not change, only asset files did. A listener that keeps
7467 + * measurements of rendered pages (selectors, layout) can keep them.
7468 + *
7469 + * @param string $cause Who asked.
7470 + * @param bool $network Purge every blog's pages and all of min/.
7471 + * @return int Page entries removed.
7472 + */
7473 + public static function purge_assets( string $cause, bool $network = false ): int {
7474 + $deleted = 0;
7475 + $rest = 0;
7476 + if ( $network ) {
7477 + // purge_local('*') clears min/ itself on a network-wide sweep.
7478 + $removed = self::purge_local( '*' );
7479 + $count = (int) $removed['pages'];
7480 + $rest = (int) $removed['rest'];
7481 + $deleted = (int) $removed['assets'];
7482 + } else {
7483 + if ( class_exists( '\\XSpeed\\Minifier' ) && is_multisite() ) {
7484 + Minifier::purge_manifests( Asset_Manifest::blog_id() );
7485 + } elseif ( class_exists( '\\XSpeed\\Minifier' ) ) {
7486 + $deleted = Minifier::purge_minified();
7487 + }
7488 + $count = self::purge_pages();
7489 + }
7490 +
7491 + self::update_stats( array( 'last_purge' => time() ) );
7492 + Cache_Inventory::invalidate();
7493 + self::record_partial_purge( 'assets', $cause, $count + $rest );
7494 +
7495 + if ( $deleted > 0 || $network ) {
7496 + try {
7497 + self::do_action_isolated( 'xspeed_after_purge_all', $cause );
7498 + } catch ( \Throwable $e ) {
7499 + self::log_purge_listener_error( 'xspeed_after_purge_all', $e );
7500 + }
7501 + }
7502 +
7503 + if ( ! $network ) {
7504 + self::announce_purge( $cause, $count, 'site', 'assets' );
7505 + } elseif ( function_exists( 'do_action' ) ) {
7506 + try {
7507 + self::dispatch_purge_event(
7508 + 'xspeed_after_purge',
7509 + array(
7510 + 'url' => null,
7511 + 'host' => '*',
7512 + 'path' => null,
7513 + 'cause' => $cause,
7514 + 'removed' => $count + $rest,
7515 + 'scope' => 'network',
7516 + 'intent' => 'assets',
7517 + 'urls' => array(),
7518 + )
7519 + );
7520 + } catch ( \Throwable $e ) {
7521 + self::log_purge_listener_error( 'xspeed_after_purge', $e );
7522 + }
7523 + }
7524 +
7525 + return $count + $rest;
7526 + }
7527 +
7528 + /**
3820 7529 * Delete this site's cached pages from both the flat and static trees.
3821 7530 *
3822 7531 * Extracted so the `assets` purge can reuse it: minified assets are a
3823 7532 * dependency of the cached HTML, so clearing them must clear the pages
@@ -3862,15 +7571,101 @@
3862 7571 * @param string $type Purge-type slug.
3863 7572 * @param string $cause Who asked.
3864 7573 */
3865 7574 private static function purge_type_unhandled( string $type, string $cause ): int {
3866 - do_action( 'xspeed_purge_type_' . $type );
7575 + $event_sequence = self::$purge_event_sequence;
7576 + $hook = 'xspeed_purge_type_' . $type;
7577 + $has_handler = false !== has_action( $hook );
7578 + do_action( $hook );
3867 7579 self::record_partial_purge( $type, $cause, null );
3868 7580
7581 + // Announce, same as the types this class owns. Pro's "Purge Critical
7582 + // CSS" and "Purge Unused CSS" arrive here, and they change what a
7583 + // cached page CONTAINS — critical CSS is inlined into the HTML, so a
7584 + // server cache goes on serving pages with the old styles baked in.
7585 + // Fixing the three Free buttons and leaving these two silent left the
7586 + // same hole for the tier most likely to be using both plugins.
7587 + // (QA #348 round 2, issue 2)
7588 + //
7589 + // Unknown slugs must not turn into a site-wide purge merely because no
7590 + // handler exists. These are the response-changing Pro types Free knows;
7591 + // third parties can declare another through the filter. A registered
7592 + // handler plus this explicit response scope is the handled signal.
7593 + $scope = in_array( $type, array( 'critical-css', 'unused-css' ), true ) ? 'site' : 'none';
7594 + /**
7595 + * Declare whether a handled custom purge type changes cached responses.
7596 + *
7597 + * @since 1.2.3
7598 + * @param string $scope site/network/none.
7599 + * @param string $type Purge-type slug.
7600 + */
7601 + $scope = (string) apply_filters( 'xspeed_purge_type_response_scope', $scope, $type );
7602 + if ( $has_handler
7603 + && $event_sequence === self::$purge_event_sequence
7604 + && in_array( $scope, array( 'site', 'network' ), true )
7605 + ) {
7606 + self::announce_purge( $cause, 0, $scope, 'presentation' );
7607 + }
7608 +
3869 7609 return 0;
3870 7610 }
3871 7611
3872 7612 /**
7613 + * Tell the server cache that a PARTIAL purge cleared cached responses.
7614 + *
7615 + * "Purge Page / Static Cache", "Purge CSS / JS Cache" and "Purge REST
7616 + * Cache" each delete cached RESPONSES for the whole site, so a cache in
7617 + * front of PHP is now serving copies xSpeed has just thrown away. Only
7618 + * "Purge All" announced itself, which left three of the four toolbar
7619 + * buttons doing exactly what this contract exists to prevent: clearing
7620 + * our copy while the server kept serving the stale one. The `assets` case
7621 + * was the sharpest — it deletes the minified bundles too, so LiteSpeed
7622 + * went on serving pages whose CSS and JS no longer exist. (QA #348)
7623 + *
7624 + * Sent as the full-purge shape (`url` null) because that is what happened:
7625 + * every cached page for this site went, not one address. `object` is not
7626 + * announced — flushing the object cache changes no rendered response a
7627 + * server cache could be holding.
7628 + *
7629 + * Public because Purge_Runner sweeps the local files itself, through
7630 + * purge_local(), rather than through purge_all() — so it has to announce
7631 + * on its own behalf or `wp xspeed purge` and the dashboard button clear
7632 + * our copy while LiteSpeed keeps serving the stale one.
7633 + *
7634 + * @param string $cause Who asked.
7635 + * @param int $removed Entries removed locally.
7636 + * @param string $scope Actionable adapter scope.
7637 + * @param string $intent Reason rendered responses changed.
7638 + */
7639 + public static function announce_purge( string $cause, int $removed, string $scope = 'site', string $intent = 'complete' ): void {
7640 + // Announcing is additive: the local sweep has already happened and
7641 + // succeeded. Notification must never be able to turn a working purge
7642 + // into a fatal, so anything the URL helpers do in an unusual context
7643 + // (early boot, a drop-in, a bare test harness) is contained here
7644 + // rather than propagating to the caller.
7645 + if ( ! function_exists( 'home_url' ) || ! function_exists( 'do_action' ) ) {
7646 + return;
7647 + }
7648 + try {
7649 + self::dispatch_purge_event(
7650 + 'xspeed_after_purge',
7651 + array(
7652 + 'url' => null,
7653 + 'host' => self::current_purge_host(),
7654 + 'path' => null,
7655 + 'cause' => $cause,
7656 + 'removed' => $removed,
7657 + 'scope' => $scope,
7658 + 'intent' => $intent,
7659 + 'urls' => array(),
7660 + )
7661 + );
7662 + } catch ( \Throwable $e ) {
7663 + self::log_purge_listener_error( 'xspeed_after_purge', $e );
7664 + }
7665 + }
7666 +
7667 + /**
3873 7668 * Log a partial purge so the drill-down behind "Last purge" shows every
3874 7669 * clear, not only the full ones. Without this a site whose object cache
3875 7670 * is flushed on a schedule looks, from the log, like nothing happens.
3876 7671 *
@@ -3894,8 +7689,42 @@
3894 7689 $count
3895 7690 );
3896 7691
3897 7692 Activity_Log::record( 'cache_purged', $message, Activity_Log::INFO );
7693 +
7694 + /*
7695 + * A partial purge is still a purge, and an edge in front of this site
7696 + * has to hear about it.
7697 + *
7698 + * "Purge Page / Static Cache" is always visible in the admin bar and
7699 + * clears every cached page for this site — the flat tree AND the
7700 + * static tree the generated nginx/Apache rules serve directly. It
7701 + * announced none of that: not `xspeed_after_purge_all`, which only
7702 + * purge_all() fires, and not the scoped actions. A CDN told to hold
7703 + * those pages went on serving the ones xSpeed had just deleted, for
7704 + * whatever lifetime it was given.
7705 + *
7706 + * Scope is `site` only for the two types that clear rendered HTML.
7707 + * An object-cache flush or a REST purge removes nothing an edge is
7708 + * holding, and announcing those as a site purge would have a CDN
7709 + * drop its whole cache every time a scheduled flush ran — worse than
7710 + * the silence this replaces. A type registered by another plugin
7711 + * through `xspeed_purge_types` is unknown here, so it says nothing
7712 + * rather than guessing.
7713 + *
7714 + * Not gated on `$count`, for the reason spelled out in purge_url():
7715 + * an empty local tree is not evidence that the edge is empty, and
7716 + * "Purge Page / Static Cache" with nothing left locally is precisely
7717 + * what someone clicks when the page they are looking at is stale.
7718 + * Answering that with silence made the button appear broken.
7719 + *
7720 + * That announcement is now `announce_purge()`'s, called beside this
7721 + * by the same callers (`page`, `assets`, `REST responses`). This
7722 + * function went back to being what its name says: a log entry. It
7723 + * used to publish as well, which double-fired every partial purge and
7724 + * — worse — announced `scope: none` for types #348's contract
7725 + * requires to stay silent about.
7726 + */
3898 7727 }
3899 7728
3900 7729 /**
3901 7730 * Clear the static tree only, leaving the flat cache in place.
@@ -3963,8 +7792,14 @@
3963 7792 }
3964 7793 if ( substr( $entry, -5 ) === '.html' ) {
3965 7794 self::sweep_delete( $path );
3966 7795 ++$removed;
7796 + } elseif ( self::EXPIRES_MARKER === $entry ) {
7797 + // store_static()'s deadline for a nonce page. Left behind, it
7798 + // kept every such folder from being removed, and the failed
7799 + // rmdir warned once per folder: Query Monitor turned those
7800 + // into enough response headers to 502 a post save (#636).
7801 + self::sweep_delete( $path );
3967 7802 } elseif ( substr( $entry, -3 ) === '.br' || substr( $entry, -8 ) === '.br.size' ) {
3968 7803 // Precompressed sibling (index.html.br) and the record of its
3969 7804 // length. Remove both so a purge doesn't orphan stale Brotli
3970 7805 // bodies, or a size record that would later be read against a
@@ -4067,8 +7902,25 @@
4067 7902 // True when an edge cache (Cloudflare) fronts the origin, so hits are
4068 7903 // absorbed before reaching PHP. The dashboard labels the ratio
4069 7904 // "origin-layer only" instead of implying it's the full picture. (#118)
4070 7905 'edge_cache' => self::edge_cache_detected(),
7906 + // LiteSpeed Static Fast Path (#509): the web server serves hits
7907 + // with no PHP, no way to tag them, and no way to count them. The
7908 + // dashboard labels the ratio as PHP-layer only so a low number
7909 + // reads as the trade the user chose, not a fault.
7910 + //
7911 + // rewrite_installed() is part of the condition (QA on #513): when
7912 + // the .htaccess write failed (read-only file), hits still take
7913 + // the drop-in path and ARE counted — the disclosure would be the
7914 + // opposite of the truth. Health carries the "block missing"
7915 + // warning for that state; this flag only speaks when static
7916 + // serving is genuinely in effect.
7917 + 'static_hits_uncounted' => (
7918 + Server::LITESPEED === Server::type()
7919 + && ! empty( Settings::get()['cache_enabled'] )
7920 + && self::static_rewrite_allowed()
7921 + && self::rewrite_installed()
7922 + ),
4071 7923 /*
4072 7924 * Whether the page cache is actually SERVING, as opposed to
4073 7925 * switched on in settings. The hero read the setting alone and
4074 7926 * announced "Active — serving cached HTML"; a site whose
@@ -4130,8 +7982,13 @@
4130 7982 private static function miss_is_excluded(): bool {
4131 7983 if ( function_exists( 'is_404' ) && is_404() ) {
4132 7984 return true;
4133 7985 }
7986 + // A marked request is ours whatever its UA says: a renamed warmer,
7987 + // or a probe that has to send a browser's UA.
7988 + if ( Self_Traffic::request_is_marked() ) {
7989 + return true;
7990 + }
4134 7991 $ua = isset( $_SERVER['HTTP_USER_AGENT'] )
4135 7992 ? sanitize_text_field( wp_unslash( (string) $_SERVER['HTTP_USER_AGENT'] ) )
4136 7993 : '';
4137 7994 return Hit_Counter::is_bot_ua( $ua );
@@ -4137,16 +7994,20 @@
4137 7994 return Hit_Counter::is_bot_ua( $ua );
4138 7995 }
4139 7996
4140 7997 /**
4141 - * Whether an edge cache fronts this origin. Today: the Cloudflare
4142 - * integration is connected — so an unknown share of hits is served at the
4143 - * edge and never counted here, making the origin ratio a partial view the
4144 - * dashboard must label as such. (#118)
7998 + * Whether an edge cache fronts this origin, so an unknown share of hits
7999 + * is served there and never counted here — which makes the origin ratio a
8000 + * partial view the dashboard has to label as such. (#118)
8001 + *
8002 + * This used to mean "the Cloudflare module is switched on", which answered
8003 + * no for every site fronted by anything else, and no for a site on
8004 + * Cloudflare that had never opened our Cloudflare panel. Both of those
8005 + * sites had their ratio presented as the whole story. Edge_Provider knows
8006 + * better and knows it per request, so ask it.
4145 8007 */
4146 8008 private static function edge_cache_detected(): bool {
4147 - $cf = get_option( 'xspeed_module_cloudflare', array() );
4148 - return is_array( $cf ) && ! empty( $cf['enabled'] );
8009 + return Edge_Provider::NONE !== Edge_Provider::detect()['confidence'];
4149 8010 }
4150 8011
4151 8012 /**
4152 8013 * Apply the user's enable/disable choice. Called from the REST toggle
@@ -4186,8 +8047,9 @@
4186 8047 $lock = self::page_cache_lock();
4187 8048 if ( ! is_resource( $lock ) ) {
4188 8049 return self::blocked_toggle_state( __( 'Could not lock page-cache ownership. Try again.', 'xspeed' ) );
4189 8050 }
8051 + $changed = false;
4190 8052 try {
4191 8053 Page_Cache_Detector::invalidate();
4192 8054 $fresh = Page_Cache_Detector::inspect()['revision'];
4193 8055 if ( ! hash_equals( (string) $expected, (string) $fresh ) ) {
@@ -4192,13 +8054,61 @@
4192 8054 $fresh = Page_Cache_Detector::inspect()['revision'];
4193 8055 if ( ! hash_equals( (string) $expected, (string) $fresh ) ) {
4194 8056 return self::blocked_toggle_state( __( 'Page-cache ownership changed while xSpeed was checking it. Nothing was changed; try again.', 'xspeed' ) );
4195 8057 }
4196 - $state = self::toggle_unlocked( (bool) $enable, $consented );
8058 + $before = self::page_cache_fingerprint();
8059 + $state = self::toggle_unlocked( (bool) $enable, $consented );
8060 + $changed = empty( $state['blocked'] ) && self::page_cache_fingerprint() !== $before;
4197 8061 return $state;
4198 8062 } finally {
4199 8063 flock( $lock, LOCK_UN );
4200 8064 fclose( $lock );
8065 +
8066 + /*
8067 + * Announce the change to anything caching in front of us.
8068 + *
8069 + * Turning the page cache on or off changes what every URL on this
8070 + * site returns, and a CDN holding renders made under the old state
8071 + * goes on serving them for their whole lifetime. Nothing told it.
8072 + * OFF is the less obvious half and matters as much: the edge
8073 + * otherwise keeps serving pages from a cache the site no longer
8074 + * has.
8075 + *
8076 + * Only when something actually changed. A refused toggle wrote
8077 + * nothing — that is the whole point of the refusal — and purging
8078 + * after it would announce a change that did not happen. Nor does a
8079 + * toggle that found the cache already in the state it asked for:
8080 + * auto_heal() re-enables on every admin_init, and gating on "not
8081 + * refused" alone made every wp-admin page load empty the page
8082 + * cache and purge the edge (QA, 2026-09-23). The fingerprint is
8083 + * compared instead, so restoring a stripped drop-in still counts.
8084 + *
8085 + * After the writes and OUTSIDE the lock. Before them, a purge
8086 + * would repopulate from the state we are in the middle of leaving,
8087 + * which is how a "purge didn't work" report is born; inside them,
8088 + * it would hold single-occupancy ownership for the length of a
8089 + * filesystem sweep.
8090 + */
8091 + if ( $changed ) {
8092 + /*
8093 + * On shutdown, not inline. `purge_all()` sweeps the cache
8094 + * directory and fires the purge actions, and a listener on
8095 + * those can make an HTTP call to an edge — so running it here
8096 + * would make the toggle as slow as the sweep and couple its
8097 + * response to a third party. The Cloudflare Enterprise purge
8098 + * queue already defers for the same reason.
8099 + *
8100 + * Still after the writes: shutdown runs at the end of THIS
8101 + * request, with the new state on disk.
8102 + */
8103 + add_action(
8104 + 'shutdown',
8105 + static function () {
8106 + self::purge_all( 'page cache toggled' );
8107 + },
8108 + 20
8109 + );
8110 + }
4201 8111 }
4202 8112 }
4203 8113
4204 8114 /** Run the page-cache mutation while toggle() owns the scoped lock. */
@@ -4206,8 +8116,25 @@
4206 8116 * @param bool $consented The user asked for this in the dashboard, so a
4207 8117 * foreign drop-in may be taken over. False on the
4208 8118 * unattended paths, which stand down instead.
4209 8119 */
8120 + /**
8121 + * What the page cache looks like on disk and in wp-config, as one string.
8122 + *
8123 + * `toggle()` compares it before and after its writes to tell a real ON/OFF
8124 + * flip, or a repaired drop-in, from a call that found everything already
8125 + * as asked. Only the latter must not announce a purge.
8126 + */
8127 + private static function page_cache_fingerprint(): string {
8128 + $dropin = self::read_file( WP_CONTENT_DIR . '/advanced-cache.php' );
8129 + return md5(
8130 + ( null === $dropin ? "\0none" : md5( $dropin ) )
8131 + . '|' . self::wp_cache_define_state()
8132 + . '|' . ( self::rewrite_installed() ? '1' : '0' )
8133 + . '|' . ( self::page_cache_operational() ? '1' : '0' )
8134 + );
8135 + }
8136 +
4210 8137 private static function toggle_unlocked( bool $enable, bool $consented = true ) {
4211 8138 $enable = (bool) $enable;
4212 8139
4213 8140 if ( $enable ) {
@@ -4743,10 +8670,11 @@
4743 8670 * they don't share a user at all. A default-umask 0644 file is then
4744 8671 * unwritable by nginx, the access_log write silently fails, and the
4745 8672 * dashboard shows a 0% hit ratio even though static HITs are serving.
4746 8673 * So we widen the dir to 0777 and the file to 0666 — group/other write —
4747 - * so whatever uid nginx runs as can append. (The file holds only HIT
4748 - * request lines, no secrets.)
8674 + * so whatever uid nginx runs as can append. The file holds HIT request
8675 + * lines and must be protected like an access log: paths and queries can
8676 + * contain sensitive values.
4749 8677 */
4750 8678 /**
4751 8679 * Directory holding the nginx hit log. Lives under uploads/, NOT the
4752 8680 * cache dir — uninstall.php and a cache purge both delete the cache
@@ -4760,11 +8688,22 @@
4760 8688 * Falls back to the cache dir only if uploads is somehow unavailable.
4761 8689 */
4762 8690 public static function hits_log_dir(): string {
4763 8691 if ( function_exists( 'wp_upload_dir' ) ) {
8692 + // One drop-in serves the whole network, so its hit log has one
8693 + // home: the main site's uploads. Resolved per blog, the path
8694 + // baked into the drop-in changed with whichever blog's admin
8695 + // last ran auto_heal(), and each rewrite read as a page-cache
8696 + // change and purged the site and the edge (QA, 2026-09-23).
8697 + // A subsite's uploads are `<main uploads>/sites/<id>`, so the
8698 + // suffix comes off rather than switching blogs to ask.
4764 8699 $uploads = wp_upload_dir( null, false );
4765 8700 if ( is_array( $uploads ) && empty( $uploads['error'] ) && ! empty( $uploads['basedir'] ) ) {
4766 - return rtrim( (string) $uploads['basedir'], '/' ) . '/xspeed';
8701 + $base = rtrim( (string) $uploads['basedir'], '/' );
8702 + if ( function_exists( 'is_multisite' ) && is_multisite() ) {
8703 + $base = (string) preg_replace( '#/sites/\d+$#', '', $base );
8704 + }
8705 + return $base . '/xspeed';
4767 8706 }
4768 8707 }
4769 8708 return XSPEED_CACHE_DIR;
4770 8709 }
@@ -4897,9 +8836,9 @@
4897 8836 */
4898 8837 $opts = self::stored_cache_opts();
4899 8838 $ignored = is_array( $opts['ignored_query_params'] ?? null )
4900 8839 ? $opts['ignored_query_params']
4901 - : \XSpeed\Modules\Cache\CacheModule::DEFAULT_IGNORED_QUERY_PARAMS;
8840 + : \XSpeed\Modules\Cache\CacheModule::default_ignored_query_params();
4902 8841
4903 8842 $parts = array();
4904 8843 foreach ( $ignored as $pattern ) {
4905 8844 $pattern = trim( (string) $pattern );
@@ -4933,9 +8872,12 @@
4933 8872 if ( ! is_dir( XSPEED_CACHE_DIR ) && ! wp_mkdir_p( XSPEED_CACHE_DIR ) ) {
4934 8873 return;
4935 8874 }
4936 8875
4937 - $payload = '(?:' . implode( '|', array_unique( $parts ) ) . ')';
8876 + // The drop-in anchors this as `^…$`, so the lookahead refuses the
8877 + // never-ignored names whole, whatever entry would have matched them.
8878 + $never = implode( '|', array_map( static fn ( $p ) => preg_quote( $p, '#' ), self::NEVER_IGNORED_QUERY_PARAMS ) );
8879 + $payload = '(?!(?:' . $never . ')$)(?:' . implode( '|', array_unique( $parts ) ) . ')';
4938 8880
4939 8881 // Only write when the value actually changed. This runs from
4940 8882 // reconcile_mobile_separate() on CacheModule::boot(), so an
4941 8883 // unconditional write cost a file write and an exclusive lock on every
@@ -4972,8 +8914,28 @@
4972 8914 $stored = get_option( Settings_Manager::OPTION_PREFIX . 'cache', array() );
4973 8915 return is_array( $stored ) ? $stored : array();
4974 8916 }
4975 8917
8918 + /**
8919 + * Strict truthiness for the LiteSpeed Static Fast Path opt-in.
8920 + *
8921 + * On non-LiteSpeed servers the key is out of the schema and carried by
8922 + * preserved_keys(), so a REST/MCP write lands VERBATIM — QA on #513
8923 + * stored the string "false" on Apache and the fast path installed
8924 + * itself the moment the site moved to LiteSpeed, because
8925 + * empty("false") is false. Only an explicit, unambiguous "yes" may
8926 + * enable a path that trades away hit tagging; any other value —
8927 + * "false", "no", arbitrary junk — stays OFF, which is the default the
8928 + * user never left.
8929 + */
8930 + private static function litespeed_optin_enabled( $value ): bool {
8931 + if ( true === $value || 1 === $value ) {
8932 + return true;
8933 + }
8934 + return is_string( $value )
8935 + && in_array( strtolower( trim( $value ) ), array( '1', 'true', 'on', 'yes' ), true );
8936 + }
8937 +
4976 8938 public static function sync_mobile_flag( $enabled = null ): void {
4977 8939 if ( null === $enabled ) {
4978 8940 $stored = self::stored_cache_opts();
4979 8941 $enabled = ! empty( $stored['mobile_separate'] );
@@ -5146,12 +9108,24 @@
5146 9108 * the truth there. (Apache keeps the static fast path — it honors the
5147 9109 * header.) See maybe_emit_lscache_headers() for the paired LSCache
5148 9110 * stand-down that stops LiteSpeed's own module from shadowing the
5149 9111 * drop-in.
9112 + *
9113 + * Opt-in (#509): `litespeed_static_rewrite` re-enables the fast path on
9114 + * LiteSpeed for users who value raw TTFB over hit accounting. The trade
9115 + * is stated in the setting's copy: statically served hits carry no
9116 + * X-XSpeed-Cache header and are not counted (LiteSpeed logs the
9117 + * original request line, so even the access-log scan cannot see
9118 + * them — see Hit_Counter::collect_server_log_hits()). The drop-in
9119 + * default above stays — nobody is surprised into an unverifiable cache.
5150 9120 */
5151 9121 public static function static_rewrite_allowed(): bool {
5152 - // LiteSpeed: drop-in serves hits (visible + counted) — see docblock.
5153 - if ( Server::LITESPEED === Server::type() ) {
9122 + // Stored read — reached from boot(); see stored_cache_opts().
9123 + $opts = self::stored_cache_opts();
9124 + // LiteSpeed: drop-in serves hits (visible + counted) unless the user
9125 + // explicitly opted into the static fast path — see docblock.
9126 + if ( Server::LITESPEED === Server::type()
9127 + && ! self::litespeed_optin_enabled( $opts['litespeed_static_rewrite'] ?? false ) ) {
5154 9128 return false;
5155 9129 }
5156 9130 // Apache without mod_headers is in EXACTLY the position LiteSpeed
5157 9131 // is in above: it can run the RewriteRule and serve the static
@@ -5165,10 +9139,8 @@
5165 9139 // pinned at 0% on a working Apache cache.)
5166 9140 if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) {
5167 9141 return false;
5168 9142 }
5169 - // Stored read — reached from boot(); see stored_cache_opts().
5170 - $opts = self::stored_cache_opts();
5171 9143 return empty( $opts['mobile_separate'] );
5172 9144 }
5173 9145
5174 9146 /**
@@ -5210,10 +9182,17 @@
5210 9182 * path is the pasted snippet and there is no .htaccess marker to find, so
5211 9183 * requiring one would report every correctly-configured nginx site as
5212 9184 * broken.
5213 9185 *
9186 + * Carries `rules` through as well. The dashboard bootstrap builds its own
9187 + * payload and so had it; every other caller — POST /cache/recheck-rewrite,
9188 + * `wp xspeed cache recheck-rewrite`, the recheck_rewrite_rules MCP tool —
9189 + * came through here and got four keys, so the one surface a user reaches
9190 + * AFTER pasting the block could not tell them whether the block took. An
9191 + * agent driving the same fix could not read it at all.
9192 + *
5214 9193 * @param array $probe Raw result from probe_static_rewrite().
5215 - * @return array{active:bool,inconclusive:bool,reason:string,block_reason:string}
9194 + * @return array{active:bool,inconclusive:bool,reason:string,block_reason:string,rules:array}
5216 9195 */
5217 9196 public static function qualify_rewrite_probe( array $probe ): array {
5218 9197 $active = (bool) ( $probe['active'] ?? false );
5219 9198 $inconclusive = (bool) ( $probe['inconclusive'] ?? false );
@@ -5218,8 +9197,9 @@
5218 9197 $active = (bool) ( $probe['active'] ?? false );
5219 9198 $inconclusive = (bool) ( $probe['inconclusive'] ?? false );
5220 9199 $reason = (string) ( $probe['reason'] ?? '' );
5221 9200 $block_reason = self::static_rewrite_block_reason();
9201 + $rules = self::rules_state( $probe );
5222 9202
5223 9203 // Same observed-refusal check Health makes. This is the shared path for
5224 9204 // `wp xspeed cache recheck-rewrite` and POST /cache/recheck-rewrite —
5225 9205 // and, because a CLI command is automatically an MCP tool, for the
@@ -5253,8 +9233,9 @@
5253 9233 'active' => false,
5254 9234 'inconclusive' => false,
5255 9235 'reason' => 'Page caching is off, so there is no cache for the web server to serve.',
5256 9236 'block_reason' => '',
9237 + 'rules' => $rules,
5257 9238 );
5258 9239 }
5259 9240
5260 9241 // A known refusal outranks the probe, and also outranks
@@ -5270,8 +9251,9 @@
5270 9251 'active' => $active,
5271 9252 'inconclusive' => $inconclusive,
5272 9253 'reason' => $reason,
5273 9254 'block_reason' => $block_reason,
9255 + 'rules' => $rules,
5274 9256 );
5275 9257 }
5276 9258
5277 9259 /**
@@ -5286,8 +9268,10 @@
5286 9268 case 'mobile_separate':
5287 9269 return 'Separate Mobile Cache is on, which disables the device-blind static rewrite. Cache hits are served by PHP instead. If your site serves the same HTML to every device, turn it off in Cache settings for much faster hits.';
5288 9270 case 'no_mod_headers':
5289 9271 return "Apache's mod_headers is not loaded, so the static rewrite cannot mark its responses as cache hits. Enable mod_headers, or leave hits on the PHP path.";
9272 + case 'litespeed_dropin':
9273 + return 'On LiteSpeed, cache hits are served by the PHP drop-in so every hit is tagged X-XSpeed-Cache and counted in the hit ratio — LiteSpeed\'s .htaccess engine cannot do either for statically served files. If raw TTFB matters more to you than hit accounting, turn on LiteSpeed Static Fast Path in Cache settings to serve hits straight from the web server.';
5290 9274 case 'skipped_nonce':
5291 9275 return 'The server config is correct, but pages are not reaching the static cache because they contain nonces, so hits are served by PHP instead. A static file is served with no PHP, so a nonce baked into one could never be refreshed and every anonymous form on the page would break once it expired — keeping these pages on PHP is deliberate. Nonces usually come from plugin widgets; disabling the ones the site does not use lets its pages be served statically again.';
5292 9276 default:
5293 9277 return sprintf( 'The static rewrite is disabled (%s).', $code );
@@ -5306,9 +9290,25 @@
5306 9290 if ( empty( $opts['cache_enabled'] ) ) {
5307 9291 return '';
5308 9292 }
5309 9293 if ( Server::LITESPEED === Server::type() ) {
5310 - return ''; // Intended on LiteSpeed — not a "block".
9294 + // The opt-in is read RAW (stored_cache_opts), not through
9295 + // Settings_Manager::get(): the schema's bool coercion is a PHP
9296 + // cast, and (bool) "false" is true — so a junk string stored on
9297 + // another server (where the key bypasses the schema) would come
9298 + // back from the coercion layer as an ENABLE. Raw + the strict
9299 + // parse below is the same read static_rewrite_allowed() makes,
9300 + // so the two can't disagree either. (QA on #513)
9301 + $stored = self::stored_cache_opts();
9302 + // The intended default — but no longer silent: with the opt-in
9303 + // off, Health must be able to explain the PHP path and point at
9304 + // the toggle instead of falling through to "reinstall the block"
9305 + // advice that cannot work here. (#509)
9306 + if ( ! self::litespeed_optin_enabled( $stored['litespeed_static_rewrite'] ?? false ) ) {
9307 + return 'litespeed_dropin';
9308 + }
9309 + $cache_opts = Settings_Manager::get( 'cache' );
9310 + return ! empty( $cache_opts['mobile_separate'] ) ? 'mobile_separate' : '';
5311 9311 }
5312 9312 if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) {
5313 9313 return 'no_mod_headers';
5314 9314 }
@@ -5401,9 +9401,11 @@
5401 9401 'redirection' => 2,
5402 9402 // Bust any per-device cache so we compare freshly-rendered
5403 9403 // HTML, and pass the device UA the site would branch on.
5404 9404 'user-agent' => $ua,
5405 - 'headers' => array( 'Cache-Control' => 'no-cache' ),
9405 + // A real device UA by design, so only the header marks
9406 + // this as ours to analytics and the hit ratio.
9407 + 'headers' => Self_Traffic::headers( array( 'Cache-Control' => 'no-cache' ) ),
5406 9408 )
5407 9409 );
5408 9410 if ( is_wp_error( $resp ) || 200 !== (int) wp_remote_retrieve_response_code( $resp ) ) {
5409 9411 return null;
@@ -5742,10 +9744,41 @@
5742 9744 // missing. So: hits are logged, and a user deleting the log can't take
5743 9745 // nginx down.
5744 9746 $lines[] = ' access_log ' . $hits_abs . ' combined buffer=16k flush=5s;';
5745 9747 $lines[] = ' add_header X-XSpeed-Cache "HIT (nginx)" always;';
9748 + // Edge/CDN headers from the same seam the drop-in bakes. nginx serves
9749 + // this path without ever starting PHP, so the answer cannot be
9750 + // resolved per request — the pairs are resolved HERE, when the
9751 + // snippet is generated, and a change of answer needs the snippet
9752 + // regenerated and re-pasted to take effect.
9753 + //
9754 + // Skipped entirely when the static path is switched off. The only
9755 + // reason that can fire under `bake` is mobile-split, and mobile-split
9756 + // is also what switches the static path off — so the block would be
9757 + // baked with a hold it can never serve, and would start serving it
9758 + // the moment the setting is turned off and static files reappear,
9759 + // until somebody regenerates and re-pastes. A rule that can only be
9760 + // served once its premise is false is guaranteed to be stale.
9761 + foreach ( self::static_rewrite_allowed() ? self::edge_headers_for( 'HIT', 'rules' ) : array() as $name => $value ) {
9762 + $lines[] = ' add_header ' . $name . ' "' . self::quote_directive_value( $value ) . '" always;';
9763 + }
5746 9764 $lines[] = '}';
5747 - return implode( "\n", $lines );
9765 +
9766 + // Stamp the block with a hash of itself. This is the only way to find
9767 + // out what a user actually pasted: the block lives in a server config
9768 + // WordPress cannot read, so until now the dashboard could not tell an
9769 + // up-to-date paste from one made three settings changes ago, and
9770 + // covered for that by telling everyone to re-paste after every save.
9771 + // A hit through this location now carries the version that served it
9772 + // and probe_static_rewrite() reads it back. See rules_state().
9773 + return implode(
9774 + "\n",
9775 + self::with_rules_marker(
9776 + $lines,
9777 + array( ' add_header ' . self::RULES_HEADER . ' "%s" always;' ),
9778 + count( $lines ) - 1
9779 + )
9780 + );
5748 9781 }
5749 9782
5750 9783 /**
5751 9784 * Aggregate every enabled module's nginx_directives() into one
@@ -5801,11 +9834,13 @@
5801 9834 * static-cache tree (with the X-XSpeed-Cache header + access-log HIT
5802 9835 * accounting), and PHP/the drop-in is the fallback. To guarantee
5803 9836 * LSCache doesn't shadow that with its own copy — some LiteSpeed
5804 9837 * configs cache by default — we send an explicit `no-cache` control so
5805 - * the server defers to our rewrite. Skipped when the LiteSpeed Cache
5806 - * plugin is active (it owns its own header policy; our Conflict
5807 - * registry handles that coexistence separately).
9838 + * the server defers to our rewrite. Skipped while the LiteSpeed Cache
9839 + * plugin's page cache may be on: it owns its own header policy then,
9840 + * and enabling ours is refused while its page cache is on (#653). With
9841 + * its page cache provably off, xSpeed owns the cache exactly as when
9842 + * the plugin is absent, so the stand-down is sent.
5808 9843 */
5809 9844 public static function maybe_emit_lscache_headers(): void {
5810 9845 if ( headers_sent() ) {
5811 9846 return;
@@ -5816,9 +9851,10 @@
5816 9851 // is_plugin_active() lives in wp-admin/includes/plugin.php which
5817 9852 // isn't auto-loaded on front-end requests. Use the option layer
5818 9853 // directly to avoid pulling in admin code from a render path.
5819 9854 $active = (array) get_option( 'active_plugins', array() );
5820 - if ( in_array( 'litespeed-cache/litespeed-cache.php', $active, true ) ) {
9855 + if ( in_array( 'litespeed-cache/litespeed-cache.php', $active, true )
9856 + && false !== Cache_Plugin_Catalog::litespeed_page_cache_state() ) {
5821 9857 return;
5822 9858 }
5823 9859
5824 9860 // Explicitly opt this response OUT of LSCache so the server can't
@@ -6036,9 +10072,9 @@
6036 10072 // so the closing quote here cannot be escaped away.
6037 10073 $lines[] = ' RewriteCond %{HTTP_USER_AGENT} "!(' . $ua_rule['regex'] . ')" [NC]';
6038 10074 }
6039 10075
6040 - return array_merge(
10076 + $block = array_merge(
6041 10077 $lines,
6042 10078 array(
6043 10079 // Capture REQUEST_URI without its trailing slash into %1.
6044 10080 // store_static() writes `{host}{uri-without-trailing-slash}/index.html`,
@@ -6056,9 +10092,15 @@
6056 10092 // `^` matches the empty string AND any non-empty path, so it
6057 10093 // covers `/` and `/blog` alike. (Confirmed on OpenLiteSpeed
6058 10094 // 1.8: `.` → homepage served by PHP drop-in; `^` → served
6059 10095 // directly from the static file.)
6060 - ' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [L]',
10096 + // `E=` tags the request the rewrite just served from the cache
10097 + // tree. The edge headers below key off it instead of the file
10098 + // name: <FilesMatch "\.html$"> in a document-root .htaccess
10099 + // matches EVERY .html on the site — a hand-uploaded /promo.html,
10100 + // a static export — and telling a CDN to hold those for the page
10101 + // TTL would pin files xSpeed never wrote and cannot purge.
10102 + ' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [E=XSPEED_STATIC_HIT:1,L]',
6061 10103 '</IfModule>',
6062 10104 // Mark the statically-served response as a cache HIT.
6063 10105 //
6064 10106 // A file served by the rewrite above bypasses PHP entirely, so
@@ -6080,11 +10122,53 @@
6080 10122 '<IfModule mod_headers.c>',
6081 10123 ' <FilesMatch "\\.html$">',
6082 10124 ' Header always set X-XSpeed-Cache "HIT (static)"',
6083 10125 ' </FilesMatch>',
6084 - '</IfModule>',
6085 10126 )
6086 10127 );
10128 +
10129 + // Edge/CDN headers from the same seam the drop-in bakes. Like the
10130 + // nginx snippet, the static rewrite answers without PHP, so the pairs
10131 + // are resolved when the block is GENERATED rather than per request.
10132 + //
10133 + // `env=` rather than the `<FilesMatch>` scoping above, because these
10134 + // must ride only on responses the rewrite produced. The X-XSpeed-Cache
10135 + // marker stays filename-scoped: it is inert, and narrowing it would
10136 + // change a header QA reads.
10137 + //
10138 + // Same reasoning as the nginx snippet: a bake hold can only come from
10139 + // mobile-split, and mobile-split is what turns this path off, so a
10140 + // hold baked here could only ever be served once its own premise had
10141 + // stopped being true.
10142 + $edge_lines = array();
10143 + foreach ( self::static_rewrite_allowed() ? self::edge_headers_for( 'HIT', 'rules' ) : array() as $edge_name => $edge_value ) {
10144 + $edge_lines = array_merge(
10145 + $edge_lines,
10146 + self::static_hit_directives(
10147 + ' Header always set ' . $edge_name . ' "' . self::quote_directive_value( $edge_value ) . '"'
10148 + )
10149 + );
10150 + }
10151 +
10152 + $block = array_merge( $block, $edge_lines, array( '</IfModule>' ) );
10153 +
10154 + // Same self-describing marker as the nginx snippet. Apache's block is
10155 + // written by us rather than pasted by hand, so it should never be out
10156 + // of date — but "should" is what refresh_rewrite_if_installed() not
10157 + // running looks like from the outside, and the probe can now say so
10158 + // instead of assuming. `env=` keeps it on responses the rewrite
10159 + // produced, so a hand-uploaded .html never claims to be our cache.
10160 + //
10161 + // Spliced in after the edge headers so the hash covers them: a
10162 + // changed edge answer has to make an installed block report itself
10163 + // stale, and a marker computed before they were appended would not
10164 + // move. The splice point steps back over them and `</IfModule>` to
10165 + // land inside `<FilesMatch>`, beside the X-XSpeed-Cache marker.
10166 + return self::with_rules_marker(
10167 + $block,
10168 + self::static_hit_directives( ' Header always set ' . self::RULES_HEADER . ' "%s"' ),
10169 + count( $block ) - count( $edge_lines ) - 2
10170 + );
6087 10171 }
6088 10172
6089 10173 /**
6090 10174 * Active probe that confirms the web-server static-rewrite path is
@@ -6139,9 +10223,12 @@
6139 10223
6140 10224 $home = home_url( '/' );
6141 10225 $host = (string) wp_parse_url( $home, PHP_URL_HOST );
6142 10226 if ( '' === $host ) {
6143 - $result = array( 'active' => false, 'reason' => 'home_url has no host' );
10227 + // Environmental failure, not evidence the server config is wrong —
10228 + // mark it inconclusive so Health surfaces say "could not verify"
10229 + // instead of demanding a snippet paste. (#480)
10230 + $result = array( 'active' => false, 'inconclusive' => true, 'reason' => 'home_url has no host' );
6144 10231 set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
6145 10232 return $result;
6146 10233 }
6147 10234
@@ -6158,9 +10245,12 @@
6158 10245 if ( ! file_exists( $probe_dir ) ) {
6159 10246 wp_mkdir_p( $probe_dir );
6160 10247 }
6161 10248 if ( ! is_dir( $probe_dir ) ) {
6162 - $result = array( 'active' => false, 'reason' => 'cannot create probe dir' );
10249 + // A cache-dir permissions problem — the probe never ran, so this
10250 + // says nothing about the nginx config. Inconclusive, not
10251 + // "required". (#480)
10252 + $result = array( 'active' => false, 'inconclusive' => true, 'reason' => 'cannot create probe dir' );
6163 10253 set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
6164 10254 return $result;
6165 10255 }
6166 10256 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin credentials we may not have here; the file is in our own cache dir.
@@ -6180,9 +10270,9 @@
6180 10270 // don't repeat the wait every minute.
6181 10271 'timeout' => 3,
6182 10272 'sslverify' => ! $is_local,
6183 10273 'redirection' => 0,
6184 - 'headers' => array( 'Cache-Control' => 'no-cache' ),
10274 + 'headers' => Self_Traffic::headers( array( 'Cache-Control' => 'no-cache' ) ),
6185 10275 )
6186 10276 );
6187 10277
6188 10278 // Best-effort cleanup so we don't accumulate probe dirs even
@@ -6218,8 +10308,17 @@
6218 10308 $ua_php = '' !== (string) wp_remote_retrieve_header( $resp, 'x-powered-by' );
6219 10309 $has_etag = '' !== (string) wp_remote_retrieve_header( $resp, 'etag' )
6220 10310 || '' !== (string) wp_remote_retrieve_header( $resp, 'last-modified' );
6221 10311 $match = trim( $body ) === $nonce;
10312 + // Which version of our generated rules answered, if any. Only the
10313 + // static path can set this — it is baked into the rules themselves —
10314 + // so its presence is direct evidence about what is installed, and its
10315 + // absence on a conclusive probe is evidence too. See rules_state().
10316 + // Validated to the shape we generate, so a proxy or another plugin
10317 + // sending something else under this name cannot be mistaken for a
10318 + // rules version and reported as "out of date".
10319 + $rules_raw = trim( (string) wp_remote_retrieve_header( $resp, strtolower( self::RULES_HEADER ) ) );
10320 + $rules = preg_match( '/^[0-9a-f]{8}\z/', $rules_raw ) ? $rules_raw : '';
6222 10321
6223 10322 // "Active" = the web server served our raw nonce bytes back
6224 10323 // AND emitted the static-serve markers (ETag / Last-Modified)
6225 10324 // AND didn't add an X-Powered-By: PHP header. All three are
@@ -6258,8 +10357,9 @@
6258 10357 'inconclusive' => $inconclusive,
6259 10358 'reason' => $reason,
6260 10359 'code' => $code,
6261 10360 'php' => $ua_php,
10361 + 'rules' => $rules,
6262 10362 );
6263 10363 set_transient( 'xspeed_rewrite_probe', $result, 5 * MINUTE_IN_SECONDS );
6264 10364 return $result;
6265 10365 }
@@ -6746,8 +10846,17 @@
6746 10846 );
6747 10847 }
6748 10848
6749 10849 private static function ownership_blocker_message( string $code, string $label ): string {
10850 + // The plugin can stay active. Only its page cache is in the way, so
10851 + // say that rather than "deactivate it". (#653)
10852 + if ( Page_Cache_Detector::BLOCKER_PAGE_CACHE_SWITCHED_ON === $code && '' !== $label ) {
10853 + return sprintf(
10854 + /* translators: %s: a page-caching plugin's name, e.g. LiteSpeed Cache. */
10855 + __( "%s's page cache is on. Turn it off in that plugin's settings before enabling xSpeed's page cache. The plugin can stay active.", 'xspeed' ),
10856 + $label
10857 + );
10858 + }
6750 10859 if ( '' !== $label ) {
6751 10860 return sprintf( __( '%s is active or owns advanced-cache.php. Deactivate its page cache before enabling xSpeed.', 'xspeed' ), $label );
6752 10861 }
6753 10862 $messages = array(
@@ -6904,8 +11013,16 @@
6904 11013 '@@XSPEED_UA_RE@@',
6905 11014 str_replace( "'", "\\'", $ua_rule['regex'] ),
6906 11015 $source_contents
6907 11016 );
11017 + // Which user agents must not count toward the hit ratio. Built here
11018 + // because `xspeed_self_user_agents` is a filter the drop-in cannot
11019 + // call. A renamed warmer is caught by Self_Traffic::HEADER instead.
11020 + $source_contents = str_replace(
11021 + '@@XSPEED_HIT_EXCLUDE_RE@@',
11022 + str_replace( "'", "\\'", Hit_Counter::excluded_ua_regex() ),
11023 + $source_contents
11024 + );
6908 11025
6909 11026 /*
6910 11027 * Ours or absent — the ownership gate at the top of this method ruled
6911 11028 * out everything else. The old code path that moved a foreign drop-in
@@ -6934,8 +11051,31 @@
6934 11051 (string) ( $expiry_hours * HOUR_IN_SECONDS ),
6935 11052 $source_contents
6936 11053 );
6937 11054
11055 + // Bake the site-wide edge answer in. Resolved in a `bake` context, so
11056 + // nothing per-page and nothing a request header vouched for can reach
11057 + // it: a bake runs once, in an admin or CLI request, and answers for
11058 + // every page on the site. A page that disagrees gets a sidecar
11059 + // instead — see per_entry_edge_headers().
11060 + //
11061 + // Re-baked on every cache settings save (see CacheModule::boot),
11062 + // exactly like the cookie, user-agent and lifetime rules above.
11063 + $source_contents = str_replace(
11064 + "'@@XSPEED_EDGE_HEADERS@@'",
11065 + self::edge_headers_literal( self::edge_headers_for( 'HIT', 'bake' ) ),
11066 + $source_contents
11067 + );
11068 +
11069 + // And the answer for the same HIT served for a URL with an ignored
11070 + // param in it, which the drop-in sends instead of the one above. An
11071 + // empty array means the two agree. See query_variant_edge_headers().
11072 + $source_contents = str_replace(
11073 + "'@@XSPEED_EDGE_QUERY_HOLD@@'",
11074 + self::edge_headers_literal( self::query_variant_edge_headers() ),
11075 + $source_contents
11076 + );
11077 +
6938 11078 if ( file_exists( $target ) ) {
6939 11079 $existing = $wp_filesystem->get_contents( $target );
6940 11080 if ( is_string( $existing ) && $existing === $source_contents ) {
6941 11081 return true;
@@ -6941,11 +11081,50 @@
6941 11081 return true;
6942 11082 }
6943 11083 }
6944 11084
6945 - return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE );
11085 + $written = (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE );
11086 + if ( $written ) {
11087 + self::forget_compiled_dropin( $target );
11088 + }
11089 + return $written;
6946 11090 }
6947 11091
11092 + /**
11093 + * Callable that drops a script's compiled copy. Replaced by tests only;
11094 + * opcache_invalidate() is a PHP internal that cannot be stubbed.
11095 + *
11096 + * @var callable|null
11097 + */
11098 + private static $opcache_invalidate = null;
11099 +
11100 + /**
11101 + * Make PHP compile the new drop-in on the next request.
11102 + *
11103 + * opcache keeps the compiled drop-in and checks the file's timestamp at
11104 + * most every `opcache.revalidate_freq` seconds (60 on lenzora.site), or
11105 + * never with `validate_timestamps` off. So a re-bake, such as the one
11106 + * that follows a change to "CDN or proxy in front", kept serving the old
11107 + * edge headers for up to a minute, or until PHP restarted.
11108 + *
11109 + * This reaches the opcache of the PHP process doing the write, which is
11110 + * the web server's own when the bake runs in a page or REST request. A
11111 + * WP-CLI bake has its own opcache, so the web server still waits for its
11112 + * next timestamp check there.
11113 + *
11114 + * @param string $target Absolute path of the drop-in just written.
11115 + */
11116 + private static function forget_compiled_dropin( string $target ): void {
11117 + $invalidate = self::$opcache_invalidate;
11118 + if ( null === $invalidate ) {
11119 + if ( ! function_exists( 'opcache_invalidate' ) ) {
11120 + return;
11121 + }
11122 + $invalidate = 'opcache_invalidate';
11123 + }
11124 + @call_user_func( $invalidate, $target, true ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- opcache may be disabled or restricted (opcache.restrict_api); nothing to do either way.
11125 + }
11126 +
6948 11127 public static function remove_dropin() {
6949 11128 $target = WP_CONTENT_DIR . '/advanced-cache.php';
6950 11129 if ( ! file_exists( $target ) ) {
6951 11130 return;
@@ -7264,11 +11443,34 @@
7264 11443 // Only honour known types; anything else falls back to a full purge.
7265 11444 if ( ! array_key_exists( $type, self::purge_types() ) ) {
7266 11445 $type = 'all';
7267 11446 }
11447 +
11448 + // Answer the browser BEFORE purging. "Purge All" fans out to the local
11449 + // sweep, the object cache, CSS/edge listeners (outbound HTTP) and
11450 + // third-party render caches, all in this one request — on a large site
11451 + // that can outlive PHP-FPM's request_terminate_timeout, FPM kills the
11452 + // worker mid-purge, and nginx answers the admin's click with a 502.
11453 + // fastcgi_finish_request() exists on exactly those FPM setups: send
11454 + // the redirect, close the connection, then keep purging in the same
11455 + // process. Elsewhere (mod_php, CLI tests) fall back to purge-then-
11456 + // redirect as before.
11457 + $redirect = self::safe_purge_redirect( wp_get_referer() );
11458 + if ( function_exists( 'ignore_user_abort' ) ) {
11459 + ignore_user_abort( true );
11460 + }
11461 + if ( function_exists( 'set_time_limit' ) ) {
11462 + @set_time_limit( 300 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort under safe-mode-like restrictions.
11463 + }
11464 + if ( function_exists( 'fastcgi_finish_request' ) ) {
11465 + wp_safe_redirect( $redirect );
11466 + fastcgi_finish_request();
11467 + self::purge_type( $type );
11468 + exit;
11469 + }
11470 +
7268 11471 self::purge_type( $type );
7269 -
7270 - wp_safe_redirect( self::safe_purge_redirect( wp_get_referer() ) );
11472 + wp_safe_redirect( $redirect );
7271 11473 exit;
7272 11474 }
7273 11475
7274 11476 /**