PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.0
1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 All 35 releases
← All changes | includes/class-cache.php +3482 -154 1.3.2 → 1.4.0 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
@@ -197,9 +294,14 @@
197 294 self::invalidation_for_hook( $hook )
198 295 );
199 296 }
200 297 );
201 - 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.
202 304 }
203 305
204 306 // Updating a plugin, theme or core changes the markup and the assets
205 307 // a page is built from, but fires NONE of the hooks above: WordPress
@@ -210,11 +312,11 @@
210 312 //
211 313 // The stale copy is not merely old, it is wrong in a way the user
212 314 // cannot see the cause of: they update a plugin to get a fix, the
213 315 // cache keeps serving the pre-fix HTML, and the update looks like it
214 - // did nothing. Minified assets do regenerate on their own (their key
215 - // includes the source filemtime), which makes it worse rather than
216 - // 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.
217 319 //
218 320 // Purge unconditionally on any completed update. Scoping it to
219 321 // "plugins that enqueue front-end assets" is not knowable here, and a
220 322 // cold cache after an update is the cheaper mistake. (#269)
@@ -239,9 +341,26 @@
239 341 // alone cannot tell "added beside nothing" from "replaced live code".
240 342 // This filter fires only when the upgrader removed an existing copy,
241 343 // which is exactly the difference. Registered as a filter listener
242 344 // that returns its input untouched. (#303)
243 - 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 );
244 363 // Unattended auto-updates are the case that matters most here: they
245 364 // land overnight with nobody around to purge by hand, which is the
246 365 // exact scenario the stale cache goes undiagnosed in. WordPress fires
247 366 // this INSTEAD of a per-item upgrader_process_complete for some
@@ -251,8 +370,15 @@
251 370 // left $type empty, which read as "invalidating" and purged the whole
252 371 // cache for a language-pack-only run. Matches what LiteSpeed binds.
253 372 // (#298)
254 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 );
255 381 // …except the four hooks above that fire on ordinary visitor actions.
256 382 // Attached bare, purge_all() can't see WHAT changed, so on a store
257 383 // every order, every product review and every checkout
258 384 // account-creation wiped 100% of the cache — all anonymous happy-path
@@ -265,10 +391,22 @@
265 391 // so save_post fires either way. The gate therefore keys on POST-TYPE
266 392 // VIEWABILITY, not on storage mode — which fixes both modes at once,
267 393 // and generalises to Flamingo (#229) and Tutor LMS (#231) too.
268 394 remove_action( 'save_post', array( __CLASS__, 'purge_all' ) );
269 - remove_action( 'save_post', array( 'XSpeed\\Minifier', 'purge_minified' ) );
270 395 add_action( 'save_post', array( __CLASS__, 'on_save_post' ), 10, 2 );
396 + // Bracket every post save, so a WooCommerce product save that runs
397 + // inside one (wp-admin's Update) can tell on_save_post() is about to
398 + // clear the whole site for the same product. See purge_product().
399 + add_action( 'save_post', array( __CLASS__, 'on_post_save_start' ), 0, 2 );
400 + add_action( 'save_post', array( __CLASS__, 'on_post_save_end' ), PHP_INT_MAX, 1 );
401 + // The narrow purge runs here, after the REST API has set the terms
402 + // (it sets them after `save_post`), with the post as it was before.
403 + add_action( 'wp_after_insert_post', array( __CLASS__, 'on_after_insert_post' ), 10, 4 );
404 + add_action( 'shutdown', array( __CLASS__, 'flush_pending_saves' ), 1, 0 );
405 + add_action( 'update_option_sticky_posts', array( __CLASS__, 'on_sticky_posts_change' ), 10, 2 );
406 + // The first sticky post on a site creates the option instead.
407 + add_action( 'add_option_sticky_posts', array( __CLASS__, 'on_sticky_posts_added' ), 10, 2 );
408 + add_action( 'pre_post_update', array( __CLASS__, 'on_pre_post_update' ), 10, 2 );
271 409 add_action( 'before_delete_post', array( __CLASS__, 'on_post_removed' ), 10, 2 );
272 410 add_action( 'trashed_post', array( __CLASS__, 'on_post_removed' ), 10, 2 );
273 411 // wp_delete_post() hands an attachment to wp_delete_attachment() and
274 412 // returns BEFORE before_delete_post fires, so deleting media reached
@@ -277,18 +415,15 @@
277 415 // (dev caught this via `deleted_post`, which this branch replaced.)
278 416 add_action( 'delete_attachment', array( __CLASS__, 'on_post_removed' ), 10, 2 );
279 417
280 418 remove_action( 'comment_post', array( __CLASS__, 'purge_all' ) );
281 - remove_action( 'comment_post', array( 'XSpeed\\Minifier', 'purge_minified' ) );
282 419 add_action( 'comment_post', array( __CLASS__, 'on_comment_post' ), 10, 3 );
283 420 add_action( 'wp_set_comment_status', array( __CLASS__, 'on_comment_status' ), 10, 2 );
284 421
285 422 remove_action( 'user_register', array( __CLASS__, 'purge_all' ) );
286 - remove_action( 'user_register', array( 'XSpeed\\Minifier', 'purge_minified' ) );
287 423 add_action( 'user_register', array( __CLASS__, 'on_user_change' ) );
288 424
289 425 remove_action( 'profile_update', array( __CLASS__, 'purge_all' ) );
290 - remove_action( 'profile_update', array( 'XSpeed\\Minifier', 'purge_minified' ) );
291 426 add_action( 'profile_update', array( __CLASS__, 'on_user_change' ) );
292 427
293 428 // Product data lives in post meta and lookup tables, NOT in wp_posts,
294 429 // so WC_Product_Data_Store_CPT::update() takes a direct $wpdb->update()
@@ -304,13 +439,20 @@
304 439 // disappears, and an order that reduces stock would leave the product
305 440 // page stale.
306 441 if ( class_exists( 'WooCommerce' ) ) {
307 442 foreach ( array( 'woocommerce_update_product', 'woocommerce_new_product' ) as $wc_hook ) {
308 - add_action( $wc_hook, array( __CLASS__, 'purge_product' ) );
443 + add_action( $wc_hook, array( __CLASS__, 'on_product_saved' ) );
309 444 }
310 - // Direct stock writes bypass the CRUD entirely.
311 - add_action( 'woocommerce_product_set_stock', array( __CLASS__, 'purge_product_object' ) );
312 - add_action( 'woocommerce_variation_set_stock', array( __CLASS__, 'purge_product_object' ) );
445 + // wc_update_product_stock() writes the stock with SQL, saves the
446 + // product (which fires woocommerce_update_product above), then
447 + // fires *_set_stock. The *_before_set_stock actions open that
448 + // write, so the *_set_stock that closes it can tell the save
449 + // inside it already purged. With `$updating` set it skips the
450 + // save, and *_set_stock is then the only purge.
451 + add_action( 'woocommerce_product_before_set_stock', array( __CLASS__, 'on_product_stock_write' ) );
452 + add_action( 'woocommerce_variation_before_set_stock', array( __CLASS__, 'on_product_stock_write' ) );
453 + add_action( 'woocommerce_product_set_stock', array( __CLASS__, 'on_product_stock_set' ) );
454 + add_action( 'woocommerce_variation_set_stock', array( __CLASS__, 'on_product_stock_set' ) );
313 455 add_action( 'woocommerce_product_set_stock_status', array( __CLASS__, 'purge_product' ) );
314 456 add_action( 'woocommerce_variation_set_stock_status', array( __CLASS__, 'purge_product' ) );
315 457 }
316 458
@@ -346,11 +488,11 @@
346 488 // (Cache module). Keep this handler around for whatever still
347 489 // lives in the legacy blob (cache_enabled is special and goes
348 490 // through Cache::toggle anyway).
349 491
350 - // Any settings change — purge caches so changes take effect.
492 + // Any settings change — purge caches so changes take effect. Minified
493 + // files are named by content, so there is nothing to delete for them.
351 494 self::purge_all( 'settings change' );
352 - Minifier::purge_minified();
353 495 }
354 496
355 497 /**
356 498 * Modules whose settings cannot change rendered HTML, so a write to them
@@ -414,10 +556,9 @@
414 556 if ( '' === $slug || in_array( $slug, self::non_rendering_modules(), true ) ) {
415 557 return;
416 558 }
417 559
418 - // Guard against re-entry: purge_all() and purge_minified() can write
419 - // options of their own (stats, timestamps), and a nested purge would
560 + // Guard against re-entry: purge_all() can write options of its own (stats, timestamps), and a nested purge would
420 561 // both waste work and risk recursing through this same hook.
421 562 static $purging = false;
422 563 if ( $purging ) {
423 564 return;
@@ -424,9 +565,8 @@
424 565 }
425 566 $purging = true;
426 567
427 568 self::purge_all( 'settings change' );
428 - Minifier::purge_minified();
429 569
430 570 $purging = false;
431 571 }
432 572
@@ -440,15 +580,53 @@
440 580 * along on `X-XSpeed-Reason`, but only under WP_DEBUG so production
441 581 * responses stay clean. Slugs are fixed per gate — never the matched
442 582 * pattern, cookie or user-agent, which would echo request input back.
443 583 *
444 - * @param string $value HIT (php) | MISS | BYPASS.
445 - * @param string $reason Fixed slug naming the gate, for BYPASS only.
584 + * @param string $value HIT (php) | MISS | BYPASS.
585 + * @param string $reason Fixed slug naming the gate, for BYPASS only.
586 + * @param int|null $lifetime_left On a HIT, the seconds the served entry has
587 + * left when it has a lifetime of its own;
588 + * null when it follows the site's. See
589 + * entry_lifetime_left().
446 590 */
447 - private static function mark( string $value, string $reason = '' ): void {
591 + private static function mark( string $value, string $reason = '', ?int $lifetime_left = null ): void {
448 592 self::$status_header = $value;
449 593 self::$bypass_reason = $reason;
594 + self::$edge_headers = array();
450 595
596 + // A served-from-cache response may carry edge/CDN headers an add-on
597 + // contributes — `CDN-Cache-Control`, `Cache-Tag` and friends. Only a
598 + // HIT resolves them here. A BYPASS never does: the response was
599 + // deliberately excluded from our cache, so telling a CDN to hold it
600 + // for a month would cache at the edge exactly what we refused to
601 + // cache here. A MISS never does either, stored or not: it is the
602 + // first render, and the one most likely to be replaced once critical
603 + // CSS and unused CSS have been generated. Pinning it at the edge pins
604 + // the version xSpeed is about to improve on. The edge caches from the
605 + // first HIT instead — one render later, and the right one.
606 + //
607 + // Resolved before the headers_sent() guard so the decision is
608 + // recorded (and observable in tests) even on a request that can no
609 + // longer send headers; only the emission below is conditional.
610 + if ( 'HIT' === self::edge_status( $value ) ) {
611 + self::$edge_headers = self::edge_headers_for( 'HIT' );
612 + }
613 +
614 + // Every status, not just a HIT. A page we declined to cache is the
615 + // one an edge most needs telling about: it goes out naked today, and
616 + // a CDN that stores HTML by default keeps somebody's cart.
617 + //
618 + // Resolved before the headers_sent() guard so the decision is
619 + // recorded (and observable in tests) even on a request that can no
620 + // longer send headers; only the emission below is conditional.
621 + self::$edge_headers = self::edge_headers_for( self::edge_status( $value ), 'request', $reason );
622 +
623 + // The same countdown the drop-in applies to this entry, so the two
624 + // PHP serve paths tell the edge the same thing about one page.
625 + if ( null !== $lifetime_left && 'HIT' === self::edge_status( $value ) ) {
626 + self::$edge_headers = self::cap_edge_lifetime( self::$edge_headers, $lifetime_left );
627 + }
628 +
451 629 if ( headers_sent() ) {
452 630 return;
453 631 }
454 632 header( 'X-XSpeed-Cache: ' . $value );
@@ -454,10 +632,354 @@
454 632 header( 'X-XSpeed-Cache: ' . $value );
455 633 if ( '' !== $reason && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
456 634 header( 'X-XSpeed-Reason: ' . $reason );
457 635 }
636 + foreach ( self::$edge_headers as $name => $val ) {
637 + header( $name . ': ' . $val );
638 + }
458 639 }
459 640
641 + /**
642 + * Normalize an `X-XSpeed-Cache` value to the vocabulary the edge seam
643 + * speaks.
644 + *
645 + * The header value carries which layer served the page (`HIT (php)`,
646 + * `HIT (nginx)`, `HIT (static)`); nothing deciding what to tell a CDN
647 + * cares, and making a caller match on three spellings of one outcome is
648 + * how a rule ends up applied on two paths out of three.
649 + */
650 + private static function edge_status( string $value ): string {
651 + return 0 === strpos( $value, 'HIT' ) ? 'HIT' : $value;
652 + }
653 +
654 + /**
655 + * Hash a generated rule set so the rules can identify themselves.
656 + *
657 + * @param string[] $lines Every line of the rule set EXCEPT the marker.
658 + */
659 + private static function rules_hash( array $lines ): string {
660 + return substr( sha1( implode( "\n", $lines ) ), 0, 8 );
661 + }
662 +
663 + /**
664 + * Insert the self-describing marker directive into a generated rule set.
665 + *
666 + * The hash is taken over the rule set WITHOUT this line, and that is the
667 + * whole trick: hashing the finished text instead would mean the act of
668 + * adding the marker changed the value the marker advertises, the probe
669 + * would never see a match, and every correctly installed block would
670 + * report itself out of date forever. The one property worth a test of its
671 + * own — RewriteProbeRulesStateTest covers it.
672 + *
673 + * More than one template is allowed because Apache needs the same marker
674 + * twice, under two environment-variable names (see rewrite_block_lines).
675 + * All of them carry the SAME hash, taken over the marker-less rule set, so
676 + * adding the second directive cannot change the value either advertises.
677 + *
678 + * @param string[] $lines The rule set, with no marker line in it.
679 + * @param string[] $templates sprintf templates for the directives; `%s` is the hash.
680 + * @param int $at Index to insert the marker at.
681 + * @return string[]
682 + */
683 + private static function with_rules_marker( array $lines, array $templates, int $at ): array {
684 + $hash = self::rules_hash( $lines );
685 + $directives = array();
686 + foreach ( $templates as $template ) {
687 + $directives[] = sprintf( $template, $hash );
688 + }
689 + array_splice( $lines, $at, 0, $directives );
690 + return $lines;
691 + }
692 +
693 + /**
694 + * The rules hash the CURRENT settings generate, i.e. what a correctly
695 + * installed rule set would be sending back.
696 + *
697 + * Read out of the generated artifact rather than recomputed, so there is
698 + * exactly one definition of the hash and no way for the generator and the
699 + * expectation to drift apart. Empty on a server whose fast path we do not
700 + * generate rules for.
701 + */
702 + public static function rules_marker_expected(): string {
703 + $type = Server::type();
704 + if ( Server::NGINX === $type ) {
705 + return self::extract_rules_marker( (string) self::nginx_snippet() );
706 + }
707 + if ( Server::APACHE === $type ) {
708 + return self::extract_rules_marker( implode( "\n", self::rewrite_block_lines() ) );
709 + }
710 + return '';
711 + }
712 +
713 + /** Pull the marker value out of a generated rule set ('' when absent). */
714 + public static function extract_rules_marker( string $source ): string {
715 + if ( preg_match( '/' . preg_quote( self::RULES_HEADER, '/' ) . ' "([0-9a-f]{8})"/', $source, $m ) ) {
716 + return $m[1];
717 + }
718 + return '';
719 + }
720 +
721 + /**
722 + * Fingerprint of every setting that feeds the generated server rules.
723 + *
724 + * Values are hashed rather than stored: this map is written from a read
725 + * path and has no business becoming a second copy of the user's exclusion
726 + * lists. A per-key hash is enough — the point is to name WHICH setting
727 + * moved between two rule versions, not to reconstruct the old value.
728 + *
729 + * @return array<string,string>
730 + */
731 + public static function rules_inputs(): array {
732 + $cache_opts = Settings_Manager::get( 'cache' );
733 + $fingerprint = static function ( $value ): string {
734 + $flat = array();
735 + foreach ( (array) $value as $key => $item ) {
736 + $flat[] = $key . '=' . ( is_scalar( $item ) ? (string) $item : '' );
737 + }
738 + return substr( sha1( implode( "\x1f", $flat ) ), 0, 8 );
739 + };
740 +
741 + return array(
742 + 'excluded_cookies' => $fingerprint( $cache_opts['excluded_cookies'] ?? array() ),
743 + 'bypass_user_agents' => $fingerprint( $cache_opts['bypass_user_agents'] ?? array() ),
744 + 'excluded_urls' => $fingerprint( $cache_opts['excluded_urls'] ?? array() ),
745 + 'edge_headers' => $fingerprint( self::edge_headers_for( 'HIT', 'rules' ) ),
746 + 'static_dir' => $fingerprint( array( XSPEED_CACHE_STATIC_DIR ) ),
747 + );
748 + }
749 +
750 + /**
751 + * Remember which settings produced a rules version.
752 + *
753 + * Without this a stale verdict can only say "stale". With it, the hash the
754 + * server sent back is a key into what the site's settings looked like when
755 + * those rules were generated, so the dashboard can name what changed since
756 + * the user last pasted. Bounded to the last few versions — the map exists
757 + * to explain a recent drift, not to keep a history.
758 + *
759 + * @param array<string,string> $inputs Fingerprint from rules_inputs().
760 + */
761 + private static function remember_rules_inputs( string $hash, array $inputs ): void {
762 + if ( '' === $hash ) {
763 + return;
764 + }
765 + $map = get_option( self::RULES_INPUTS_OPTION, array() );
766 + if ( ! is_array( $map ) ) {
767 + $map = array();
768 + }
769 + if ( isset( $map[ $hash ] ) && $map[ $hash ] === $inputs ) {
770 + return;
771 + }
772 + $map[ $hash ] = $inputs;
773 + if ( count( $map ) > self::RULES_INPUTS_KEPT ) {
774 + $map = array_slice( $map, -self::RULES_INPUTS_KEPT, null, true );
775 + }
776 + update_option( self::RULES_INPUTS_OPTION, $map, false );
777 + }
778 +
779 + /**
780 + * Which settings changed between the installed rules and the current ones.
781 + *
782 + * Empty when the installed version predates the map (nothing to compare
783 + * against) — the caller then says "out of date" without naming a cause,
784 + * which is honest.
785 + *
786 + * @param array<string,string> $current Fingerprint from rules_inputs().
787 + * @return string[] Setting keys.
788 + */
789 + private static function changed_rules_inputs( string $observed, array $current ): array {
790 + $map = get_option( self::RULES_INPUTS_OPTION, array() );
791 + if ( ! is_array( $map ) || ! isset( $map[ $observed ] ) || ! is_array( $map[ $observed ] ) ) {
792 + return array();
793 + }
794 +
795 + $was = $map[ $observed ];
796 + $changed = array();
797 + foreach ( $current as $key => $value ) {
798 + if ( ! array_key_exists( $key, $was ) || $was[ $key ] !== $value ) {
799 + $changed[] = $key;
800 + }
801 + }
802 + return $changed;
803 + }
804 +
805 + /**
806 + * Whether the rules the server is running are the rules these settings
807 + * generate.
808 + *
809 + * Nothing in WordPress can read a hand-pasted nginx server block, so
810 + * before this the dashboard could only guess — and guessed by telling
811 + * everyone to re-paste after every settings change. The generated rules
812 + * now carry their own hash and the probe reads it back:
813 + *
814 + * current — the marker matches what these settings generate.
815 + * stale — a marker came back, from a different version of the rules.
816 + * absent — the probe reached a verdict and saw no marker: either no
817 + * rules are installed (the request fell through to PHP) or
818 + * they predate the marker. Either way the fix is the same.
819 + * unknown — the probe could not tell, or this server has no rule set we
820 + * generate.
821 + *
822 + * A marker outranks the probe's own `active` verdict: the marker is direct
823 + * evidence of which rules answered, while `active` is inferred from
824 + * response shape.
825 + *
826 + * Scope: the static-cache rules only — nginx_snippet() on nginx, the
827 + * .htaccess block on Apache. full_nginx_server_block() pastes those
828 + * alongside other modules' directives, and a change to one of those does
829 + * NOT move this hash. Hashing the aggregate would mean the marker inside
830 + * the location block had to know the text it is embedded in, and the
831 + * snippet would hash differently depending on which caller asked for it.
832 + * Callers wording this for a human should say "cache rules", not "your
833 + * nginx config".
834 + *
835 + * `copied` is the mirror of what this admin last pasted — see
836 + * rules_copied(). It is always present, null included, because a client
837 + * keys on the key existing to decide whether the server remembers at all.
838 + *
839 + * @param array $probe Raw result from probe_static_rewrite().
840 + * @return array{expected:string,observed:string,state:string,changed:string[],copied:array{hash:string,at:int}|null}
841 + */
842 + public static function rules_state( array $probe ): array {
843 + $expected = self::rules_marker_expected();
844 + $observed = (string) ( $probe['rules'] ?? '' );
845 + $copied = self::rules_copied();
846 +
847 + if ( '' === $expected ) {
848 + return array(
849 + 'expected' => '',
850 + 'observed' => $observed,
851 + 'state' => 'unknown',
852 + 'changed' => array(),
853 + 'copied' => $copied,
854 + );
855 + }
856 +
857 + $inputs = self::rules_inputs();
858 + self::remember_rules_inputs( $expected, $inputs );
859 +
860 + if ( '' !== $observed ) {
861 + return array(
862 + 'expected' => $expected,
863 + 'observed' => $observed,
864 + 'state' => $observed === $expected ? 'current' : 'stale',
865 + 'changed' => $observed === $expected ? array() : self::changed_rules_inputs( $observed, $inputs ),
866 + 'copied' => $copied,
867 + );
868 + }
869 +
870 + // `absent` is a claim about what is installed, so it takes a probe that
871 + // completed a round trip and read the response headers. A result that
872 + // is still pending, one whose loopback failed, one a CDN answered, and
873 + // one that never got off the ground (no host in home_url, no writable
874 + // probe dir — neither of which sets `inconclusive`, and both of which
875 + // arrive here with no status code) all leave us knowing nothing.
876 + if ( empty( $probe['code'] ) || ! empty( $probe['pending'] ) || ! empty( $probe['inconclusive'] ) ) {
877 + return array(
878 + 'expected' => $expected,
879 + 'observed' => '',
880 + 'state' => 'unknown',
881 + 'changed' => array(),
882 + 'copied' => $copied,
883 + );
884 + }
885 +
886 + return array(
887 + 'expected' => $expected,
888 + 'observed' => '',
889 + 'state' => 'absent',
890 + 'changed' => array(),
891 + 'copied' => $copied,
892 + );
893 + }
894 +
895 + /**
896 + * Which version of the rules this admin says they pasted into the server.
897 + *
898 + * Their claim, not evidence — the probe is the evidence, and where the
899 + * probe can answer this is ignored. It exists for the `unknown` state: a
900 + * host whose loopback is blocked, or one behind a CDN that answers the
901 + * probe itself, where nothing can read back what is installed. There the
902 + * only thing left to go on is that someone said they had done it, and
903 + * without a record of that the panel asks every admin to paste the block
904 + * again forever.
905 + *
906 + * Per user rather than per site, because it is a claim a person made. A
907 + * second admin on the same site has not pasted anything and should not be
908 + * told the work is done. Null when nobody has claimed this version, when
909 + * the stored value is not a shape we wrote, or when there is no current
910 + * user at all (WP-CLI, cron).
911 + *
912 + * @return array{hash:string,at:int}|null
913 + */
914 + public static function rules_copied(): ?array {
915 + if ( ! function_exists( 'get_current_user_id' ) || ! function_exists( 'get_user_meta' ) ) {
916 + return null;
917 + }
918 + $user_id = (int) get_current_user_id();
919 + if ( $user_id <= 0 ) {
920 + return null;
921 + }
922 +
923 + $stored = get_user_meta( $user_id, self::RULES_COPIED_META, true );
924 + if ( ! is_array( $stored ) ) {
925 + return null;
926 + }
927 +
928 + $hash = (string) ( $stored['hash'] ?? '' );
929 + $at = (int) ( $stored['at'] ?? 0 );
930 + if ( ! self::is_rules_hash( $hash ) || $at <= 0 ) {
931 + return null;
932 + }
933 +
934 + return array(
935 + 'hash' => $hash,
936 + 'at' => $at,
937 + );
938 + }
939 +
940 + /**
941 + * Record that this admin pasted the rules whose marker is $hash.
942 + *
943 + * Rejects anything that is not one of our markers rather than storing it,
944 + * so the mirror can only ever hold a value rules_marker_expected() could
945 + * also produce — a stored string that matches nothing would read as "a
946 + * different version is installed" forever.
947 + *
948 + * @param string $hash The 8-hex rules marker the admin copied.
949 + * @return array{hash:string,at:int}|null The stored record, or null if refused.
950 + */
951 + public static function remember_rules_copied( string $hash ): ?array {
952 + // Trimmed but not case-folded: extract_rules_marker() reads a marker
953 + // back in lower case only, so an upper-case claim would never match
954 + // anything the probe could observe. Refuse it rather than store a
955 + // value that can only ever read as a different version.
956 + $hash = trim( $hash );
957 + if ( ! self::is_rules_hash( $hash ) ) {
958 + return null;
959 + }
960 + if ( ! function_exists( 'get_current_user_id' ) || ! function_exists( 'update_user_meta' ) ) {
961 + return null;
962 + }
963 + $user_id = (int) get_current_user_id();
964 + if ( $user_id <= 0 ) {
965 + return null;
966 + }
967 +
968 + $record = array(
969 + 'hash' => $hash,
970 + 'at' => time(),
971 + );
972 + update_user_meta( $user_id, self::RULES_COPIED_META, $record );
973 +
974 + return $record;
975 + }
976 +
977 + /** Whether a string is shaped like one of our rules markers. */
978 + public static function is_rules_hash( string $hash ): bool {
979 + return 1 === preg_match( '/^[0-9a-f]{8}$/', $hash );
980 + }
981 +
460 982 /** Record a bypass gate and answer "don't cache" in one statement. */
461 983 private static function bypass( string $reason ): bool {
462 984 self::mark( 'BYPASS', $reason );
463 985 return false;
@@ -473,8 +995,923 @@
473 995 return self::$bypass_reason;
474 996 }
475 997
476 998 /**
999 + * The edge/CDN pairs sent on this request ('' if none were).
1000 + *
1001 + * @return array<string,string>
1002 + */
1003 + public static function edge_headers(): array {
1004 + return self::$edge_headers;
1005 + }
1006 +
1007 + /**
1008 + * Bypass gates that do NOT ask a cache in front of us to stand down.
1009 + *
1010 + * Every other slug does. The split is the reason this reads the gate
1011 + * rather than the status: a bypass usually means "this response is
1012 + * personal, or someone decided this page is never stored", and an edge
1013 + * holding one of those does precisely what we refused to do. These two
1014 + * mean something else.
1015 + *
1016 + * `cache-disabled` is the user switching OUR page cache off. Nothing
1017 + * about the page became personal. Sending `no-store` on every page of a
1018 + * site whose owner chose a different cache would make a local toggle a
1019 + * site-wide side effect on infrastructure we do not own.
1020 + *
1021 + * `non-frontend` is admin, REST, cron and AJAX. Not ours to describe:
1022 + * WordPress already nocaches admin, and a REST caller sets its own
1023 + * policy.
1024 + */
1025 + private const HOLD_EXEMPT_BYPASS = array( 'cache-disabled', 'non-frontend' );
1026 +
1027 + /**
1028 + * Bypass gates that describe the SHAPE of the request rather than the
1029 + * visitor or the page.
1030 + *
1031 + * These still hold, but only once we have evidence of an edge — the same
1032 + * bar a MISS has to clear. The difference matters because the default
1033 + * excluded-URL list contains `/feed/`, the sitemap and `/wp-json/`, and
1034 + * `query-param` catches `?lang=fr`, `?paged=2`, and every page of a
1035 + * plain-permalink site.
1036 + *
1037 + * xSpeed refuses those because IT cannot key on a query string, not
1038 + * because the response is private. A CDN keys on the full URL and caches
1039 + * them correctly. Holding them unconditionally would have meant every
1040 + * default install stopped its feed and sitemap being edge-cached — a
1041 + * performance regression shipped to sites that never had a CDN in the
1042 + * first place, in the name of protecting them from one.
1043 + *
1044 + * The gates left out of this list are about the visitor (`logged-in`,
1045 + * `excluded-cookie`) or are somebody stating outright that this page is
1046 + * never to be stored (`donotcachepage`, `post-excluded`, `filtered`).
1047 + * Those hold whether or not we can see an edge.
1048 + */
1049 + private const REQUEST_SHAPE_BYPASS = array( 'query-param', 'non-get', 'user-agent' );
1050 +
1051 + /**
1052 + * Default exclusions that are about the site's plumbing, not its content.
1053 + *
1054 + * `excluded-url` covers two unlike things. The default list carries
1055 + * `/cart`, `/checkout`, `/my-account` and `/wp-login` — personal pages,
1056 + * and the reason this feature exists. It also carries the entries below:
1057 + * feeds, sitemaps, the REST root, the front controller. Those are public,
1058 + * cacheable, and hammered by pollers; a CDN keys on the full URL and
1059 + * serves them correctly, so telling it to stop is a cost with no benefit.
1060 + *
1061 + * Matched as exact strings against the stored list, never as patterns
1062 + * against the path. Three bugs came out of doing it the other way round:
1063 + * `strpos( $uri, '/feed' )` matched `/my-account/feedback/`, reading the
1064 + * whole URI let `/cart/?utm_source=/feed/` disguise a cart as a feed, and
1065 + * a bare `index.php` — which is in this list, and which every URL contains
1066 + * on an "almost pretty" permalink site — made every page on such a site
1067 + * look personal. Comparing the LIST ENTRY rather than the path cannot make
1068 + * any of those mistakes, and it keeps a pattern the site owner added
1069 + * themselves on the personal side where it belongs.
1070 + */
1071 + private const STRUCTURAL_EXCLUSIONS = array(
1072 + '/wp-json/',
1073 + '/xmlrpc.php',
1074 + '~wp-.*\.php',
1075 + '/feed/',
1076 + 'index.php',
1077 + '/robots.txt',
1078 + // Both spellings, and no entry here is ever retired. This is a
1079 + // RECOGNITION list, not a source of truth: it is matched against
1080 + // whatever the site has STORED, and a site that saved its settings
1081 + // before `~sitemap(_index)?\.xml` was widened to `sitemaps?` (for
1082 + // SEOPress, which ships sitemaps.xml) still has the old string in
1083 + // its option row. Dropping the old spelling when the default moved
1084 + // would read every upgraded site's sitemap exclusion as somebody's
1085 + // personal data and hold sitemaps off the CDN — the bug this whole
1086 + // predicate exists to prevent, reintroduced by a rename.
1087 + '~sitemaps?(_index)?\.xml',
1088 + '~sitemap(_index)?\.xml',
1089 + );
1090 + /**
1091 + * Header names no edge instruction may ever carry.
1092 + *
1093 + * These describe the transfer, not the caching policy, and one wrong
1094 + * value from a settings field is a white screen rather than a missing
1095 + * optimization.
1096 + */
1097 + private const NEVER_AN_EDGE_HEADER = array(
1098 + 'content-length',
1099 + 'content-encoding',
1100 + 'content-type',
1101 + 'transfer-encoding',
1102 + 'set-cookie',
1103 + 'location',
1104 + 'x-xspeed-cache',
1105 + 'x-xspeed-edge-hold',
1106 + // `X-XSpeed-Built` says when THIS PAGE's HTML was generated, which is
1107 + // what a downstream verifier compares against the moment it asked for
1108 + // a purge. Free owns it and never takes it from a filter, in any
1109 + // context — see the BUILT_HEADER docblock.
1110 + //
1111 + // Under `bake` a supplied value would be frozen into an artifact and
1112 + // report when the artifact was written: identical on every page and
1113 + // never moving. Under `request` it is no better, because the filter
1114 + // runs from mark() with no file and no mtime in reach, so the only
1115 + // value anything can produce there is time() — the moment of the
1116 + // SERVE, not of the build. Both make every purge check pass.
1117 + //
1118 + // Free stamps the real value where it has the file: the drop-in and
1119 + // serve_not_modified(), both from filemtime(). The static serve paths
1120 + // run no PHP and carry no stamp at all; their `Last-Modified` is read
1121 + // instead.
1122 + 'x-xspeed-built',
1123 + );
1124 +
1125 + /**
1126 + * Reasons that hold the edge off even when we detected nothing in front.
1127 + *
1128 + * `none` confidence means no evidence of a proxy, which is not proof
1129 + * there is none — a transparent proxy and a host page cache both leave
1130 + * the request untouched. So the question is what a wasted header costs
1131 + * against what a missed one does, and the answer differs by reason.
1132 + *
1133 + * These two are correctness failures. A cart page stored by something we
1134 + * could not see is the defect this exists to fix, and a mobile-split page
1135 + * served to the wrong device is a wrong page rather than a slow one.
1136 + * Ninety bytes on a response that was never cacheable is a cheap premium.
1137 + *
1138 + * `miss` and `pending` are performance hedges, and a hedge against a
1139 + * cache that does not exist is noise on every first render. Skipping them
1140 + * has a second benefit: because per_entry_edge_headers() compares `store`
1141 + * against `bake`, a `pending` hold that never fires leaves the two
1142 + * agreeing, which keeps the page on the static tree.
1143 + *
1144 + * `query-variant` waits for evidence as well. It is about purges. An edge
1145 + * keeps a separate copy for every query string, and a purge of the plain
1146 + * URL never reaches them. With no edge in front there are no copies.
1147 + */
1148 + private const HOLD_WITHOUT_EVIDENCE = array( 'bypass', 'mobile-split' );
1149 +
1150 + /**
1151 + * True only while query_variant_edge_headers() bakes the drop-in's
1152 + * answer for a URL no purge names.
1153 + *
1154 + * A bake has no request to read, so this is how it asks "what if this
1155 + * HIT carried a param no purge names?". The drop-in answers that for
1156 + * itself, per request. Never true outside that one call.
1157 + *
1158 + * @var bool
1159 + */
1160 + private static $baking_query_variant = false;
1161 +
1162 + /**
1163 + * Is a module still going to change this page after this response?
1164 + *
1165 + * Free itself never says yes — nothing in Free defers work past the
1166 + * request. Minification and combining write their file and return its URL
1167 + * inside the same render; the LCP preload is chosen by parsing the HTML
1168 + * being sent. It is the question that matters to anything caching in
1169 + * front of us, so Free asks it on their behalf and lets whoever owns the
1170 + * deferred work answer.
1171 + *
1172 + * Answer TRUE while the work is outstanding for the page being served.
1173 + * The cost of a false yes is one extra origin hit; the cost of a false no
1174 + * is an un-optimized page pinned at the edge for the full lifetime, which
1175 + * is the failure this exists to prevent — so when in doubt, say yes.
1176 + *
1177 + * Asked on a `request` only, and that boundary is the whole safety of it.
1178 + *
1179 + * A `bake` is generated once, in an admin or CLI request, and serves every
1180 + * static HIT on the site; a per-page answer frozen into it would be wrong
1181 + * for every other page.
1182 + *
1183 + * A `store` is worse, and cost a live site an afternoon. The pairs written
1184 + * at store time go into the `.meta` sidecar, which the drop-in replays on
1185 + * every later HIT — before plugins load, so nothing can re-ask this
1186 + * question. A hold written there therefore outlives the state that caused
1187 + * it, and the only thing that clears it is the page being stored again. On
1188 + * a site where the deferred work never completes, every re-store re-pins
1189 + * it, and the page is never edge-cacheable again. The symptom is a cache
1190 + * HIT carrying `no-store` and `X-XSpeed-Edge-Hold: pending` on a page
1191 + * whose deferred work finished long ago — the sidecar answering with
1192 + * state nothing can re-ask.
1193 + *
1194 + * Holding the MISS is what this is for, and it is enough: that response is
1195 + * the un-optimized one. The copy we then store is what an edge should
1196 + * mirror, and when the work does land the module purges the page, which
1197 + * reaches the edge. The purge is the correctness mechanism; this is only
1198 + * meant to cover the single render before it.
1199 + *
1200 + * @param string $context `request`, `store` or `bake`.
1201 + */
1202 + public static function edge_optimization_pending( string $context = 'request' ): bool {
1203 + if ( 'request' !== $context ) {
1204 + return false;
1205 + }
1206 +
1207 + /**
1208 + * Filter: xspeed_edge_optimization_pending
1209 + *
1210 + * @param bool $pending Whether deferred work will still change this page.
1211 + */
1212 + return (bool) apply_filters( 'xspeed_edge_optimization_pending', false );
1213 + }
1214 +
1215 + /**
1216 + * Does mobile cache split this URL into two renders?
1217 + *
1218 + * With `mobile_separate` on, Free keys its cache on device and serves a
1219 + * different page to a phone than to a desktop at the SAME url. No CDN
1220 + * varies on User-Agent, so an edge holding one of those renders serves it
1221 + * to everyone: whichever device asked first decides what the other sees,
1222 + * for the whole lifetime. A wrong page, not a slow one.
1223 + *
1224 + * Read from the stored option rather than through Settings_Manager: this
1225 + * is consulted from the serve path, where the module registry may not
1226 + * have run.
1227 + */
1228 + private static function mobile_cache_splits_html(): bool {
1229 + $stored = self::stored_cache_opts();
1230 + return ! empty( $stored['mobile_separate'] );
1231 + }
1232 +
1233 + /**
1234 + * Is this cached page being served for a URL that no purge names?
1235 + *
1236 + * Free serves `/post?utm_source=x` from the entry stored for `/post`,
1237 + * which is what the ignored-params list is for. An edge does not. It
1238 + * keys on the full URL, so every query string becomes its own copy. A
1239 + * purge that names `/post` clears one copy and leaves the others for the
1240 + * whole edge lifetime. A newsletter link can then serve last month's
1241 + * page. So such a HIT is held, and the edge keeps only URLs a purge can
1242 + * name. See query_carries_unpurged_param() for which query strings count.
1243 + *
1244 + * A HIT only. A MISS is already held as `miss`, and a BYPASS never
1245 + * reached the cache. And `request` only, because the query string belongs
1246 + * to the request, not to the entry. One stored file answers `/post` and
1247 + * every variant of it, so a hold written into its sidecar under `store`
1248 + * would be replayed by the drop-in for the plain URL as well. A `bake`
1249 + * says no unless query_variant_edge_headers() is asking for the drop-in.
1250 + *
1251 + * @param string $status `HIT`, `MISS` or `BYPASS`.
1252 + * @param string $context `request`, `store` or `bake`.
1253 + */
1254 + private static function serves_query_variant( string $status, string $context ): bool {
1255 + if ( 'HIT' !== $status ) {
1256 + return false;
1257 + }
1258 + if ( 'bake' === $context ) {
1259 + return self::$baking_query_variant;
1260 + }
1261 +
1262 + return 'request' === $context && self::query_carries_unpurged_param();
1263 + }
1264 +
1265 + /**
1266 + * Does this request's query string carry a param that keeps its URL out
1267 + * of every purge?
1268 + *
1269 + * Two kinds do. A param the cache key leaves out, which is anything on
1270 + * the ignored-params list, judged by query_key_is_ignored() against the
1271 + * same setting should_cache() reads. And the search and query-form feed
1272 + * params (`s`, FEED_QUERY_PARAMS). Those have entries keyed their own
1273 + * way, but they belong to opt-in caches whose URLs no purge names either.
1274 + *
1275 + * Any other param that reaches a HIT is part of the page's own address,
1276 + * which a purge names, so it is not held. A plain-permalink route such
1277 + * as `/?page_id=2` is the case this leaves alone.
1278 + */
1279 + private static function query_carries_unpurged_param(): bool {
1280 + $query_raw = isset( $_SERVER['QUERY_STRING'] ) ? (string) wp_unslash( $_SERVER['QUERY_STRING'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- parsed for its keys only, as should_cache() does; never echoed or stored.
1281 + if ( '' === trim( $query_raw ) ) {
1282 + return false;
1283 + }
1284 +
1285 + parse_str( $query_raw, $params );
1286 + $cache_opts = Settings_Manager::get( 'cache' );
1287 + $ignored = is_array( $cache_opts['ignored_query_params'] ?? null ) ? $cache_opts['ignored_query_params'] : array();
1288 + foreach ( array_keys( $params ) as $key ) {
1289 + $key = (string) $key;
1290 + if ( 's' === $key || in_array( $key, self::FEED_QUERY_PARAMS, true ) ) {
1291 + return true;
1292 + }
1293 + if ( self::query_key_is_ignored( $key, $ignored ) ) {
1294 + return true;
1295 + }
1296 + }
1297 +
1298 + return false;
1299 + }
1300 +
1301 + /**
1302 + * The edge answer the drop-in sends for a cached page served for a URL
1303 + * no purge names, or an empty array when that answer is the same as the
1304 + * plain URL's.
1305 + *
1306 + * The drop-in runs before plugins load and cannot ask edge_headers_for(),
1307 + * so this is baked in next to the plain answer. The drop-in applies it
1308 + * when a param in the query string matches the ignored-params list it
1309 + * already reads (`.ignored-query-params`, written from the same setting
1310 + * by sync_query_allowlist()). Search and feed params never reach it,
1311 + * because it hands those requests to PHP. The nginx and Apache
1312 + * rules need no copy. Both refuse any request with a query string
1313 + * (`if ($args)`, `RewriteCond %{QUERY_STRING} ^$`), so a variant always
1314 + * reaches PHP.
1315 + *
1316 + * Empty when the two answers agree. That covers no edge being known,
1317 + * a filter vetoing the hold, and a site where every page is held
1318 + * already (mobile-split). The drop-in then sends what it sends for the
1319 + * plain URL, including any sidecar the page has.
1320 + *
1321 + * @return array<string,string>
1322 + */
1323 + public static function query_variant_edge_headers(): array {
1324 + $plain = self::edge_headers_for( 'HIT', 'bake' );
1325 +
1326 + self::$baking_query_variant = true;
1327 + try {
1328 + $variant = self::edge_headers_for( 'HIT', 'bake' );
1329 + } finally {
1330 + self::$baking_query_variant = false;
1331 + }
1332 +
1333 + return $variant === $plain ? array() : $variant;
1334 + }
1335 +
1336 + /**
1337 + * Why, if at all, a cache in front of us should refuse to store this.
1338 + *
1339 + * @param string $status `HIT`, `MISS` or `BYPASS`.
1340 + * @param string $context `request`, `store` or `bake`.
1341 + * @param string $bypass_reason The gate slug, for BYPASS only.
1342 + * @return string '' or one of bypass|bypass-shape|miss|mobile-split|pending|query-variant.
1343 + */
1344 + private static function edge_hold_reason( string $status, string $context, string $bypass_reason ): string {
1345 + $reason = '';
1346 +
1347 + // The two exempt gates are answered before anything else, or a site
1348 + // with Separate Mobile Cache on would keep holding after the page
1349 + // cache was switched off — which is exactly the "a local toggle must
1350 + // not become a site-wide side effect on infrastructure we do not own"
1351 + // rule below, defeated by the ordering rather than by the logic.
1352 + if ( 'BYPASS' === $status && in_array( $bypass_reason, self::HOLD_EXEMPT_BYPASS, true ) ) {
1353 + /** This filter is documented below. */
1354 + return (string) apply_filters( 'xspeed_edge_hold_reason', '', $status, $context, $bypass_reason );
1355 + }
1356 +
1357 + // First, because it is the only reason true in every context: the
1358 + // setting is a property of the site, not of one request, so it is the
1359 + // one thing a baked artifact can honestly assert.
1360 + //
1361 + // It is also the only reason that holds a HIT — a response we DID
1362 + // cache — and that is deliberate rather than an artefact of the
1363 + // ordering. With mobile_separate on we key the cache by device and
1364 + // serve different HTML to a phone than to a desktop at the same URL.
1365 + // No CDN varies on User-Agent, so an edge holding one of those
1366 + // renders serves it to everyone and whichever device asked first
1367 + // decides what the other sees. Our copy is fine; theirs would be a
1368 + // wrong page. The static path is switched off in this mode anyway
1369 + // (static_rewrite_allowed()), so these hits come from the drop-in,
1370 + // which carries the same baked answer.
1371 + if ( self::mobile_cache_splits_html() ) {
1372 + $reason = 'mobile-split';
1373 + } elseif ( 'BYPASS' === $status ) {
1374 + $shaped = in_array( $bypass_reason, array( 'excluded-url', 'query-param' ), true )
1375 + ? ! self::path_is_a_personal_exclusion( $bypass_reason )
1376 + : in_array( $bypass_reason, self::REQUEST_SHAPE_BYPASS, true );
1377 + $reason = $shaped ? 'bypass-shape' : 'bypass';
1378 + } elseif ( self::edge_optimization_pending( $context ) ) {
1379 + $reason = 'pending';
1380 + } elseif ( 'MISS' === $status ) {
1381 + $reason = 'miss';
1382 + } elseif ( self::serves_query_variant( $status, $context ) ) {
1383 + // Last, so it only adds a hold where there was none. A variant
1384 + // still waiting for its CSS says `pending`, which is also true,
1385 + // and the CSS modules are still asked on every HIT as before.
1386 + $reason = 'query-variant';
1387 + }
1388 +
1389 + /**
1390 + * Filter: xspeed_edge_hold_reason
1391 + *
1392 + * Return '' to veto a hold, or a reason string to force one.
1393 + *
1394 + * @param string $reason '' or bypass|bypass-shape|miss|mobile-split|pending|query-variant.
1395 + * @param string $status `HIT`, `MISS` or `BYPASS`.
1396 + * @param string $context `request`, `store` or `bake`.
1397 + * @param string $bypass_reason The gate slug, for BYPASS only.
1398 + */
1399 + return (string) apply_filters( 'xspeed_edge_hold_reason', $reason, $status, $context, $bypass_reason );
1400 + }
1401 +
1402 + /**
1403 + * Was this page excluded because it is personal, or because it is
1404 + * plumbing we cannot key a cache entry on?
1405 + *
1406 + * Answers by removing the structural defaults from the site's own
1407 + * exclusion list and asking whether anything is left that matches. So a
1408 + * feed matches only `/feed/` and comes back false; `/my-account/feedback/`
1409 + * matches `/my-account` and comes back true; and on an "almost pretty"
1410 + * permalink site, where every path contains `index.php`, an ordinary page
1411 + * matches nothing else and is correctly treated as public.
1412 + *
1413 + * The path only, never the query string — a visitor writes that, and
1414 + * `/cart/?utm_source=/feed/` must not be able to talk a cart out of its
1415 + * hold. It is also what `should_cache()` matches the list against.
1416 + *
1417 + * Asked for a `query-param` bypass too, because the query gate runs
1418 + * BEFORE the URL gate, so `/cart/?add-to-cart=12` reports `query-param`
1419 + * and never reaches `excluded-url` at all. Which gate fired first says
1420 + * nothing about whose data is on the page.
1421 + */
1422 + private static function path_is_a_personal_exclusion( string $bypass_reason ): bool {
1423 + $path = self::request_path();
1424 + if ( '' === $path ) {
1425 + return false;
1426 + }
1427 +
1428 + // Through Settings_Manager, not the raw option, because the schema's
1429 + // default IS the structural list and a fresh install has never
1430 + // written the option. Read raw, every site that has not visited the
1431 + // settings screen looks like a site with no exclusions at all, takes
1432 + // the contradiction branch below, and reports its feeds as personal.
1433 + //
1434 + // Safe here where `mobile_cache_splits_html()` is not: we are only
1435 + // ever called with a bypass reason, and those come from
1436 + // `should_cache()`, which resolved the same settings through
1437 + // `Settings_Manager::get()` to produce them.
1438 + $opts = Settings_Manager::get( 'cache' );
1439 + $excluded = is_array( $opts['excluded_urls'] ?? null ) ? $opts['excluded_urls'] : array();
1440 + if ( array() === $excluded ) {
1441 + // An `excluded-url` bypass with no exclusion list is a
1442 + // contradiction — something excluded the request and the list
1443 + // cannot say what — so assume personal, because a wasted header
1444 + // costs a little origin traffic while a missing one serves
1445 + // somebody's basket to a stranger. A `query-param` bypass with an
1446 + // empty list is just an ordinary page carrying a parameter, and
1447 + // says nothing about the path at all.
1448 + return 'excluded-url' === $bypass_reason;
1449 + }
1450 +
1451 + $personal = array_values(
1452 + array_filter(
1453 + $excluded,
1454 + static fn ( $pattern ) => ! in_array( (string) $pattern, self::STRUCTURAL_EXCLUSIONS, true )
1455 + )
1456 + );
1457 +
1458 + return array() !== $personal && self::path_matches_exclusions( $personal, $path );
1459 + }
1460 +
1461 + /**
1462 + * The edge/CDN headers to send on a response with this cache status.
1463 + *
1464 + * @param string $status `HIT`, `MISS` or `BYPASS`.
1465 + * @param string $context `request` when resolved per request on the
1466 + * PHP serve path, `store` when resolved for
1467 + * one entry's sidecar, `bake` when resolved
1468 + * once and frozen into the drop-in, `rules`
1469 + * when resolved once for the nginx or Apache
1470 + * static block.
1471 + * @param string $bypass_reason The gate slug, for BYPASS only.
1472 + * @return array<string,string>
1473 + */
1474 + public static function edge_headers_for( string $status, string $context = 'request', string $bypass_reason = '' ): array {
1475 + // `rules` is a bake in every respect but one: the cache-headers
1476 + // filter is told it is answering for a pasted server rule. Such a
1477 + // rule serves every static HIT for as long as it stays pasted, and
1478 + // nothing regenerates it when the site's expiry or a page's own
1479 + // lifetime (a nonce cap) changes, so a lifetime given there cannot
1480 + // follow the page. The drop-in is rewritten by auto_heal(); a paste
1481 + // is not.
1482 + $filter_context = $context;
1483 + if ( 'rules' === $context ) {
1484 + $context = 'bake';
1485 + }
1486 +
1487 + $base = array();
1488 + if ( 'HIT' === $status ) {
1489 + /**
1490 + * Filter: xspeed_edge_cache_headers
1491 + *
1492 + * Response headers to add to a cached HTML response. The lifetime
1493 + * is HIT-only: it is a promise that this copy is worth keeping,
1494 + * and neither a first render nor a page we refused to cache is
1495 + * one. The filter is also asked with `MISS` when a first render
1496 + * is held, and only the `Cache-Tag` from that answer is sent, so
1497 + * a purge can name a copy an edge stored despite the hold.
1498 + *
1499 + * The same filter feeds four regimes and `$context` says which.
1500 + * On the PHP serve path it runs per request (`request`); at store
1501 + * time it runs for one entry (`store`); when the drop-in is
1502 + * generated it runs once (`bake`) and the result answers for
1503 + * every drop-in HIT on the site; when the nginx or Apache static
1504 + * block is generated it runs once (`rules`). Anything per-page — a
1505 + * post id in a cache tag, say — must be skipped under `bake` and
1506 + * `rules`. A server rule serves every static HIT for as long as it
1507 + * stays pasted, so a lifetime here can't follow the page: send
1508 + * none under `rules`.
1509 + *
1510 + * @param array<string,string> $headers Header name => value.
1511 + * @param string $status `HIT`, or `MISS` when only the tag of a held first render is wanted.
1512 + * @param string $context `request`, `store`, `bake` or `rules`.
1513 + */
1514 + $base = self::sanitize_edge_headers( (array) apply_filters( 'xspeed_edge_cache_headers', array(), 'HIT', $filter_context ) );
1515 + }
1516 +
1517 + $reason = self::edge_hold_reason( $status, $context, $bypass_reason );
1518 + if ( '' === $reason ) {
1519 + return $base;
1520 + }
1521 + $detected = Edge_Provider::detect( $context );
1522 + if ( Edge_Provider::is_off( $detected ) ) {
1523 + return $base;
1524 + }
1525 + if ( Edge_Provider::NONE === $detected['confidence']
1526 + && ! in_array( $reason, self::HOLD_WITHOUT_EVIDENCE, true ) ) {
1527 + return $base;
1528 + }
1529 +
1530 + $hold = Edge_Provider::hold_headers( $detected['provider'] );
1531 +
1532 + /**
1533 + * Filter: xspeed_edge_hold_headers
1534 + *
1535 + * The last word on what a hold INSTRUCTS. Runs before sanitising, so
1536 + * a value that cannot be sent as a header is still dropped, and
1537 + * before `X-XSpeed-Edge-Hold` is added, so it cannot rewrite the
1538 + * reason xSpeed held the page for — that is a diagnosis, not an
1539 + * instruction, and a forged one sends a reader after the wrong
1540 + * module.
1541 + *
1542 + * @param array<string,string> $hold Header name => value.
1543 + * @param array<string,string> $detected Provider, confidence, source.
1544 + * @param string $reason Why the hold fired.
1545 + * @param string $context `request`, `store` or `bake`.
1546 + */
1547 + $hold = (array) apply_filters( 'xspeed_edge_hold_headers', $hold, $detected, $reason, $context );
1548 +
1549 + // A hold replaces the lifetime rather than sitting beside it: the two
1550 + // describe the same response and would contradict each other. The
1551 + // cache tag survives, because a later purge still has to be able to
1552 + // name whatever the edge picked up on its own terms.
1553 + //
1554 + // On a HIT the tag came with `$base`. A MISS has no `$base`, and that
1555 + // is the case that mattered most: the first render after any purge.
1556 + // An edge whose rule overrides the origin's lifetime stores it
1557 + // despite the hold, and with no tag on it the next tag purge cannot
1558 + // reach it, so an edit stayed stale for the edge's whole lifetime
1559 + // (QA, 2026-09-23). So a held MISS asks the same filter for its tag
1560 + // and keeps the tag alone; the lifetime in the answer is dropped,
1561 + // because the hold is the instruction for this response.
1562 + $tag = $base['Cache-Tag'] ?? '';
1563 + if ( '' === $tag && 'MISS' === $status ) {
1564 + $tagged = self::sanitize_edge_headers( (array) apply_filters( 'xspeed_edge_cache_headers', array(), 'MISS', $filter_context ) );
1565 + $tag = $tagged['Cache-Tag'] ?? '';
1566 + }
1567 + if ( '' !== $tag ) {
1568 + $hold['Cache-Tag'] = $tag;
1569 + }
1570 +
1571 + // Never argue with a stronger answer WordPress already gave. It sends
1572 + // `no-store, private` of its own accord on a logged-in, 404 or
1573 + // password-protected response, from WP::send_headers() — which runs
1574 + // before template_redirect, so it is already on the wire by the time
1575 + // we get here. Ours is the weaker statement of the two; replacing it
1576 + // would be a downgrade dressed as a fix. Only meaningful per request:
1577 + // a bake has no response to inspect.
1578 + if ( 'request' === $context && isset( $hold['Cache-Control'] ) && self::cache_control_already_stronger() ) {
1579 + unset( $hold['Cache-Control'] );
1580 + }
1581 +
1582 + // A page we refused to cache must not carry a validator either. A
1583 + // `Last-Modified` left on it invites a conditional request, and a
1584 + // shared cache that gets a 304 back serves the copy it should not
1585 + // have stored. Only on a bypass, and only per request: a MISS is
1586 + // about to be stored by us, so its validator is ours to keep.
1587 + if ( 'request' === $context && 'bypass' === $reason && ! headers_sent() ) {
1588 + header_remove( 'Last-Modified' );
1589 + }
1590 +
1591 + $hold = self::sanitize_edge_headers( $hold );
1592 +
1593 + // Name the reason in the hold set itself, rather than sending it
1594 + // separately from mark().
1595 + //
1596 + // "Why is my page not being cached at the edge?" is the question this
1597 + // answers, and mark() could only answer it on the PHP serve path. The
1598 + // other emitters send whatever this function returns and never ran
1599 + // mark() at all — so the responses hardest to explain went out
1600 + // carrying `no-store` with nothing beside it to say why. Chiefly the
1601 + // drop-in, which serves from the `.meta` sidecar written under
1602 + // `store` and from the literal baked under `bake`, before plugins
1603 + // load and with no way to re-ask (the symptom
1604 + // edge_optimization_pending() describes above).
1605 + //
1606 + // The nginx and Apache blocks are a third path in principle and
1607 + // almost never in practice: they are only installed when
1608 + // static_rewrite_allowed() is true, and the one reason a stock site
1609 + // can hold in their bake is `mobile-split`, which is exactly what
1610 + // makes that false. `query-variant` is baked for the drop-in alone,
1611 + // since both blocks refuse a query string. They will carry a hold
1612 + // where a site forces one through `xspeed_edge_hold_reason`, and
1613 + // otherwise have none to carry.
1614 + //
1615 + // Added AFTER sanitising and banned in NEVER_AN_EDGE_HEADER, so
1616 + // neither of the two filters above can forge a reason or suppress the
1617 + // real one.
1618 + //
1619 + // Reduced to the slug CHARACTER CLASS, not checked against the six
1620 + // slugs: `xspeed_edge_hold_reason` is documented as able to force a
1621 + // reason, and a site that forces its own deserves to see it. What is
1622 + // not negotiable is the shape, because this value reaches an
1623 + // .htaccess and an nginx conf as well as a response header — so no
1624 + // CR/LF, no `$`, no `%`, no `\`, and a length a config file can hold.
1625 + $slug = preg_replace( '/[^a-z0-9-]/', '', strtolower( $reason ) );
1626 + if ( is_string( $slug ) && '' !== $slug ) {
1627 + $hold['X-XSpeed-Edge-Hold'] = substr( $slug, 0, 32 );
1628 + }
1629 +
1630 + return $hold;
1631 + }
1632 +
1633 + /** Has something already sent a Cache-Control at least as strict as ours? */
1634 + private static function cache_control_already_stronger(): bool {
1635 + foreach ( headers_list() as $line ) {
1636 + if ( 0 !== stripos( $line, 'cache-control:' ) ) {
1637 + continue;
1638 + }
1639 + if ( preg_match( '/\b(?:no-store|private)\b/i', $line ) ) {
1640 + return true;
1641 + }
1642 + }
1643 +
1644 + return false;
1645 + }
1646 +
1647 + /**
1648 + * Edge headers that belong to THIS page rather than to every page.
1649 + *
1650 + * `edge_headers_for('HIT','bake')` is the answer frozen into the drop-in
1651 + * and the server rules: one set, serving the whole site. But the answer
1652 + * for one URL can legitimately differ — a page whose deferred work is
1653 + * still outstanding, say — and that answer has nowhere to live, because
1654 + * the baked set is all the fast paths know about.
1655 + *
1656 + * So ask again in a `store` context, with the request still in scope, and
1657 + * return the pairs only when they differ from the baked ones. Identical is
1658 + * the overwhelmingly common case and writes nothing: pages do not pay a
1659 + * sidecar for an answer the drop-in already has.
1660 + *
1661 + * Memoised because two callers ask within one store — the sidecar writer
1662 + * and the static-tree guard — and the filters behind it are not required
1663 + * to be cheap.
1664 + *
1665 + * A page with a lifetime of its own also gets its own pairs, whenever
1666 + * they grant an edge any lifetime at all. The lifetime is cut to the
1667 + * page's (see cap_edge_lifetime()), and the pairs are returned even when
1668 + * the cut leaves them equal to the baked ones. Returning them keeps the
1669 + * page off the static tree, and it has to: only the two PHP paths can
1670 + * count a lifetime down as the copy ages. The web server sends the baked
1671 + * value from whatever age the file has reached, so a page capped to its
1672 + * nonce would reach the edge with hours of dead nonce still to serve.
1673 + *
1674 + * write_meta() resolves that lifetime and passes it on the first call.
1675 + * The static-tree guards ask afterwards and read the memo. Both store
1676 + * paths call write_meta() before their guard.
1677 + *
1678 + * @param int|null $own_ttl The entry's own lifetime in seconds, when it
1679 + * differs from the site's (the sidecar `ttl`).
1680 + * @return array<string,string> Empty when this page needs no override.
1681 + */
1682 + private static function per_entry_edge_headers( ?int $own_ttl = null ): array {
1683 + if ( is_array( self::$per_entry_edge ) ) {
1684 + return self::$per_entry_edge;
1685 + }
1686 + $baked = self::edge_headers_for( 'HIT', 'bake' );
1687 + $request = self::edge_headers_for( 'HIT', 'store' );
1688 + if ( null !== $own_ttl && self::grants_edge_lifetime( $request ) ) {
1689 + self::$per_entry_edge = self::cap_edge_lifetime( $request, $own_ttl );
1690 + return self::$per_entry_edge;
1691 + }
1692 + self::$per_entry_edge = ( $request === $baked ) ? array() : $request;
1693 +
1694 + return self::$per_entry_edge;
1695 + }
1696 +
1697 + /**
1698 + * Cut every lifetime an edge header grants down to `$seconds`.
1699 + *
1700 + * The lifetime in a HIT's pairs comes from `xspeed_edge_cache_headers`
1701 + * and describes the site, so it says nothing of a page whose own lifetime
1702 + * is shorter: one carrying a nonce (#236), one with a per-post expiry,
1703 + * one a `xspeed_cache_max_age` filter shortened. xSpeed rebuilds that page
1704 + * on time, and an edge holding it for the site's lifetime goes on serving
1705 + * the old copy anyway. Nothing tells the edge when our copy expires,
1706 + * because an expiry is not a purge. For a nonce page that is a broken
1707 + * form for every visitor until the next purge.
1708 + *
1709 + * Only shortens. A `max-age` or `s-maxage` already under `$seconds` is
1710 + * kept, so a hold's `s-maxage=0` stays 0. Only headers named `*-Control`
1711 + * are touched; a `Cache-Tag` that happens to contain the text is not a
1712 + * directive.
1713 + *
1714 + * @param array<string,string> $headers Header name => value.
1715 + * @param int $seconds The most any of them may grant.
1716 + * @return array<string,string>
1717 + */
1718 + public static function cap_edge_lifetime( array $headers, int $seconds ): array {
1719 + $seconds = max( 0, $seconds );
1720 + foreach ( $headers as $name => $value ) {
1721 + if ( ! preg_match( self::EDGE_LIFETIME_HEADER, (string) $name ) ) {
1722 + continue;
1723 + }
1724 + $capped = preg_replace_callback(
1725 + self::EDGE_LIFETIME_DIRECTIVE,
1726 + static function ( array $m ) use ( $seconds ): string {
1727 + return $m[1] . '=' . min( (int) $m[2], $seconds );
1728 + },
1729 + (string) $value
1730 + );
1731 + if ( is_string( $capped ) ) {
1732 + $headers[ $name ] = $capped;
1733 + }
1734 + }
1735 +
1736 + return $headers;
1737 + }
1738 +
1739 + /**
1740 + * Does any `*-Control` header in the set let an edge keep the response?
1741 + *
1742 + * @param array<string,string> $headers Header name => value.
1743 + */
1744 + private static function grants_edge_lifetime( array $headers ): bool {
1745 + foreach ( $headers as $name => $value ) {
1746 + if ( ! preg_match( self::EDGE_LIFETIME_HEADER, (string) $name ) ) {
1747 + continue;
1748 + }
1749 + if ( preg_match_all( self::EDGE_LIFETIME_DIRECTIVE, (string) $value, $m ) ) {
1750 + foreach ( $m[2] as $seconds ) {
1751 + if ( (int) $seconds > 0 ) {
1752 + return true;
1753 + }
1754 + }
1755 + }
1756 + }
1757 +
1758 + return false;
1759 + }
1760 +
1761 + /**
1762 + * Seconds a cached entry has left, when it has a lifetime of its own.
1763 + *
1764 + * Read from the sidecar `ttl`, which write_meta() records only when the
1765 + * entry's lifetime differs from the site's. That is the drop-in's rule
1766 + * too, so both PHP serve paths count down the same entries. Null for an
1767 + * entry that follows the site's lifetime: what the edge is told about
1768 + * those is the site's lifetime, unchanged.
1769 + *
1770 + * @param array<string,mixed> $meta The entry's sidecar, from read_meta().
1771 + * @param string $file The cached file being served.
1772 + */
1773 + private static function entry_lifetime_left( array $meta, string $file ): ?int {
1774 + $ttl = isset( $meta['ttl'] ) ? (int) $meta['ttl'] : 0;
1775 + if ( $ttl < 1 ) {
1776 + return null;
1777 + }
1778 + $mtime = file_exists( $file ) ? filemtime( $file ) : false;
1779 + if ( false === $mtime ) {
1780 + return null;
1781 + }
1782 +
1783 + return max( 0, $ttl - ( time() - (int) $mtime ) );
1784 + }
1785 +
1786 + /**
1787 + * Render baked pairs as a PHP array literal for the drop-in.
1788 + *
1789 + * Single-quoted literals with quotes escaped, because the result is
1790 + * written into a PHP file that must still parse. Values reaching here
1791 + * have already been through sanitize_edge_headers(), so neither name nor
1792 + * value can carry a newline.
1793 + *
1794 + * @param array<string,string> $headers Name => value.
1795 + */
1796 + private static function edge_headers_literal( array $headers ): string {
1797 + if ( array() === $headers ) {
1798 + return 'array()';
1799 + }
1800 + // var_export(), not hand-rolled quoting. A single-quoted PHP string
1801 + // escapes BOTH `'` and `\\`, and escaping only the first is how a
1802 + // value ending in a backslash — `X-Foo: C:\path\` from the custom
1803 + // headers box — leaves the literal unterminated. That file is
1804 + // included on every request once WP_CACHE is on, so the result is a
1805 + // parse error on the front end AND in wp-admin, with no way back
1806 + // except deleting the file over SSH.
1807 + $parts = array();
1808 + foreach ( $headers as $name => $value ) {
1809 + $parts[] = var_export( (string) $name, true ) . ' => ' . var_export( (string) $value, true );
1810 + }
1811 +
1812 + return 'array( ' . implode( ', ', $parts ) . ' )';
1813 + }
1814 +
1815 + /**
1816 + * Quote a header value for an nginx / Apache directive.
1817 + *
1818 + * Both accept a double-quoted string with backslash escapes, and both
1819 + * refuse to load a config where the quoting is wrong — a mis-escaped
1820 + * value takes the whole vhost down, not just this header.
1821 + */
1822 + private static function quote_directive_value( string $value ): string {
1823 + return str_replace( array( '\\', '"' ), array( '\\\\', '\\"' ), $value );
1824 + }
1825 +
1826 + /**
1827 + * The same directive twice — once per name Apache can expose the
1828 + * rewrite's environment variable under.
1829 + *
1830 + * `RewriteRule ... [E=XSPEED_STATIC_HIT:1]` in a per-directory context is
1831 + * an INTERNAL REDIRECT: Apache re-enters the request with the substituted
1832 + * path, and every variable set on the first pass is renamed with a
1833 + * `REDIRECT_` prefix for the second. `env=XSPEED_STATIC_HIT` is evaluated
1834 + * on that second pass, where nothing answers to that name any more, so
1835 + * the directive never fires — dropping the headers from precisely the
1836 + * responses they exist for. The rules marker rides on the same gate, so
1837 + * the probe also read its own marker as missing and called correctly
1838 + * installed rules stale.
1839 + *
1840 + * It cannot be written once: `env=` takes a single name with no
1841 + * alternation, and `expr=` — which could express both — is not dependable
1842 + * on LiteSpeed, which reads this same block. So both are emitted; the one
1843 + * whose variable is unset on a given pass does nothing.
1844 + *
1845 + * @param string $directive The directive, without its `env=` clause.
1846 + * @return string[]
1847 + */
1848 + private static function static_hit_directives( string $directive ): array {
1849 + return array(
1850 + $directive . ' env=XSPEED_STATIC_HIT',
1851 + $directive . ' env=REDIRECT_XSPEED_STATIC_HIT',
1852 + );
1853 + }
1854 +
1855 + /**
1856 + * Keep only pairs that can be sent as a header verbatim.
1857 + *
1858 + * These values reach three different emitters — PHP's header(), an nginx
1859 + * `add_header` and an Apache `Header always set` — so a name with a space
1860 + * or a value carrying CR/LF is not merely malformed, it is a
1861 + * response-splitting vector in the first and a broken server config in
1862 + * the other two. Names must be token-shaped; values lose CR/LF and are
1863 + * dropped if nothing survives.
1864 + *
1865 + * @param array<mixed,mixed> $headers Raw pairs.
1866 + * @return array<string,string>
1867 + */
1868 + public static function sanitize_edge_headers( array $headers ): array {
1869 + $clean = array();
1870 + foreach ( $headers as $name => $value ) {
1871 + // Never let one of these through, whoever asked. They describe the
1872 + // transfer rather than the caching policy, and getting one wrong
1873 + // from a settings field is a white screen: `Content-Encoding: gzip`
1874 + // on an uncompressed body, a `Content-Length` that disagrees with
1875 + // the bytes. `X-XSpeed-Cache` is ours and a second copy would lie
1876 + // to whoever reads it.
1877 + if ( is_string( $name ) && in_array( strtolower( $name ), self::NEVER_AN_EDGE_HEADER, true ) ) {
1878 + continue;
1879 + }
1880 + // `\z`, not `$`: PCRE's `$` also matches immediately BEFORE a
1881 + // trailing newline, so "Cache-Tag\n" passes a `$` check and gets
1882 + // concatenated raw into the generated .htaccess — splitting one
1883 + // Header directive across two lines, which is a syntax error
1884 + // Apache reports as a 500 on every request while `httpd -t` stays
1885 + // green (.htaccess is parsed per request, not at load).
1886 + if ( ! is_string( $name ) || ! preg_match( '/^[A-Za-z0-9-]+\z/', $name ) ) {
1887 + continue;
1888 + }
1889 + if ( ! is_string( $value ) && ! is_numeric( $value ) ) {
1890 + continue;
1891 + }
1892 + $value = trim( str_replace( array( "\r", "\n" ), '', (string) $value ) );
1893 + if ( '' === $value ) {
1894 + continue;
1895 + }
1896 + // `$` is a variable reference in an nginx string and `%` is a
1897 + // format tag to Apache's mod_headers, which rejects an
1898 + // unrecognised one — in .htaccess that is a 500 on every request
1899 + // while `httpd -t` still reports OK, because .htaccess is parsed
1900 + // per request. `\` escapes the quote in the PHP literal baked into
1901 + // the drop-in. None of them can be escaped reliably in all three
1902 + // places at once, and nothing a cache reads needs any of them, so
1903 + // the value is dropped rather than mangled.
1904 + if ( preg_match( '/[$%\\\\]/', $value ) ) {
1905 + continue;
1906 + }
1907 + $clean[ $name ] = $value;
1908 + }
1909 +
1910 + return $clean;
1911 + }
1912 +
1913 + /**
477 1914 * Bypass gates that describe THE VISITOR rather than THIS REQUEST.
478 1915 *
479 1916 * Only these may be recorded in the bypass cookie. A visitor-scoped
480 1917 * verdict stays true for the visitor's next request — they are still
@@ -535,9 +1972,19 @@
535 1972 $key = self::cache_key();
536 1973 $file = self::cache_file_for( $key );
537 1974
538 1975 if ( file_exists( $file ) && ! self::is_expired( $file ) ) {
539 - Hit_Counter::record_hit();
1976 + // Symmetric with the miss branch below: a bot, scanner or one of
1977 + // xSpeed's own warm/benchmark requests that lands a HIT must not
1978 + // inflate the ratio either — excluding only their misses would
1979 + // shrink the denominator while their hits kept feeding the
1980 + // numerator, making the displayed ratio MORE optimistic than
1981 + // before the exclusion existed.
1982 + if ( self::miss_is_excluded() ) {
1983 + Hit_Counter::record_excluded();
1984 + } else {
1985 + Hit_Counter::record_hit();
1986 + }
540 1987 // Emit the HIT marker on THIS path too. The drop-in
541 1988 // (advanced-cache.php) sends "HIT (php)" and the nginx static
542 1989 // rewrite sends "HIT (nginx)", but this template_redirect
543 1990 // serve path — the one that runs when the drop-in isn't loaded
@@ -543,14 +1990,18 @@
543 1990 // serve path — the one that runs when the drop-in isn't loaded
544 1991 // (e.g. WP_CACHE not true) — previously streamed the cached
545 1992 // file with NO marker, so a genuine HIT looked like a MISS in
546 1993 // the response headers. Same header + value as the drop-in.
547 - self::mark( 'HIT (php)' );
1994 + //
1995 + // The sidecar is read first because the HIT's edge lifetime
1996 + // depends on it: an entry with a lifetime of its own may not be
1997 + // kept at the edge past it. See entry_lifetime_left().
1998 + $meta = self::read_meta( $key );
1999 + self::mark( 'HIT (php)', '', self::entry_lifetime_left( $meta, $file ) );
548 2000 // Replay stored response bits so the HIT matches the original:
549 2001 // a non-HTML Content-Type (cached feeds, sitemaps) and a non-200
550 2002 // status (a cached 404 must serve 404, not 200). No-op for
551 2003 // ordinary pages, which write no .meta.
552 - $meta = self::read_meta( $key );
553 2004 if ( ! headers_sent() ) {
554 2005 if ( ! empty( $meta['status'] ) && function_exists( 'http_response_code' ) ) {
555 2006 http_response_code( (int) $meta['status'] );
556 2007 }
@@ -613,8 +2064,9 @@
613 2064 // explicit shutdown close so the buffer lifecycle is visible to
614 2065 // reviewers and Plugin Check, instead of relying on PHP's implicit
615 2066 // request-end flush. We record our nesting level so close_buffer()
616 2067 // flushes ONLY the buffer we opened.
2068 + self::$asset_stamp_at_open = class_exists( '\\XSpeed\\Minifier' ) ? Minifier::purge_stamp() : null;
617 2069 ob_start( array( __CLASS__, 'finalize_buffer' ) );
618 2070 self::$buffer_level = ob_get_level();
619 2071
620 2072 add_action( 'shutdown', array( __CLASS__, 'close_buffer' ), 0 );
@@ -717,8 +2169,11 @@
717 2169
718 2170 $completed = self::$render_completed;
719 2171 self::$render_completed = false;
720 2172
2173 + $asset_stamp = self::$deferred_asset_stamp;
2174 + self::$deferred_asset_stamp = null;
2175 +
721 2176 if ( null === $key ) {
722 2177 return;
723 2178 }
724 2179
@@ -769,8 +2224,14 @@
769 2224 if ( self::query_string_blocks_write() ) {
770 2225 return;
771 2226 }
772 2227
2228 + // Same guard as finalize_buffer(): the translation plugin's buffer
2229 + // adds time between render and store, not less.
2230 + if ( self::assets_purged_since( $asset_stamp ) ) {
2231 + return;
2232 + }
2233 +
773 2234 $file = self::cache_file_for( $key );
774 2235 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context for credentials; this runs on a frontend shutdown where it's unavailable.
775 2236 file_put_contents( $file, $full, LOCK_EX );
776 2237
@@ -781,19 +2242,46 @@
781 2242
782 2243 // Static tree too, under the same gates finalize_buffer() applies —
783 2244 // otherwise deferring the write would silently cost translated pages
784 2245 // the web-server fast path and leave them on the slower drop-in.
785 - if ( self::static_rewrite_allowed() && self::response_is_plain_html() ) {
2246 + // The static tree cannot replay a sidecar. A file served straight by
2247 + // the web server carries the headers baked into the rule that serves
2248 + // the whole site — the very answer this entry exists because it
2249 + // disagreed with. Same reasoning as the status and content-type
2250 + // cases: what the fast path cannot replay belongs on the drop-in path.
2251 + if ( self::static_rewrite_allowed()
2252 + && self::response_is_plain_html()
2253 + && array() === self::per_entry_edge_headers() ) {
786 2254 self::store_static( $full );
787 2255 }
788 2256 }
789 2257
2258 + /**
2259 + * Did a purge delete minified or combined files after $stamp was taken?
2260 + *
2261 + * @param string|null $stamp Minifier::purge_stamp() when the render began,
2262 + * or null when no snapshot was taken.
2263 + */
2264 + private static function assets_purged_since( ?string $stamp ): bool {
2265 + if ( null === $stamp || ! class_exists( '\\XSpeed\\Minifier' ) ) {
2266 + return false;
2267 + }
2268 + return Minifier::purge_stamp() !== $stamp;
2269 + }
2270 +
790 2271 public static function should_cache() {
791 2272 // Reset first: a single request only reaches this once (the sole
792 2273 // caller is maybe_start_cache()), but tests and any future caller
793 2274 // must never inherit the previous request's verdict.
794 - self::$status_header = '';
795 - self::$bypass_reason = '';
2275 + self::$status_header = '';
2276 + self::$bypass_reason = '';
2277 + self::$edge_headers = array();
2278 + self::$per_entry_edge = null;
2279 + // Under PHP-FPM a process serves one request and this is moot. Under
2280 + // a persistent worker runtime it is not: without it, an answer
2281 + // resolved from one visitor's forgeable headers would be reused for
2282 + // every later request the worker handles.
2283 + Edge_Provider::forget();
796 2284
797 2285 $opts = Settings::get();
798 2286 if ( empty( $opts['cache_enabled'] ) ) {
799 2287 return self::bypass( 'cache-disabled' );
@@ -844,8 +2332,20 @@
844 2332 * @param bool $cache_feed Whether to cache this feed request.
845 2333 */
846 2334 $cache_feed = $is_feed_request && (bool) apply_filters( 'xspeed_should_cache_feed', false );
847 2335
2336 + // WordPress's virtual robots.txt (and virtual favicon) are not HTML:
2337 + // caching one runs it through the whole HTML pipeline, which stamped
2338 + // the footer comment onto text/plain and let HTML minification
2339 + // collapse robots.txt to a single line — a line-based format, so
2340 + // every directive after the first was lost and crawlers read an
2341 + // invalid file. No opt-in filter here: there is no correct way to
2342 + // treat these as pages. (Reported live on a customer site.)
2343 + if ( ( function_exists( 'is_robots' ) && is_robots() )
2344 + || ( function_exists( 'is_favicon' ) && is_favicon() ) ) {
2345 + return self::bypass( 'non-html' );
2346 + }
2347 +
848 2348 // Query string handling: anything OUTSIDE the ignored-params
849 2349 // allow-list (utm_*, fbclid, gclid by default) means a unique
850 2350 // request that we don't want to share with the canonical cache
851 2351 // entry. Skip cache rather than poison the key.
@@ -869,9 +2369,9 @@
869 2369 continue;
870 2370 }
871 2371 // Allow query-form feed params through when feed caching opted
872 2372 // this request in (?feed=rss2 / &withcomments=1 on feeds).
873 - if ( $cache_feed && in_array( $key, array( 'feed', 'withcomments', 'withoutcomments' ), true ) ) {
2373 + if ( $cache_feed && in_array( $key, self::FEED_QUERY_PARAMS, true ) ) {
874 2374 continue;
875 2375 }
876 2376 if ( ! self::query_key_is_ignored( (string) $key, $ignored ) ) {
877 2377 // Slug only — never the param name, which is attacker-
@@ -880,13 +2380,12 @@
880 2380 }
881 2381 }
882 2382 }
883 2383
884 - $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
885 - $path = (string) strtok( $request_uri, '?' );
2384 + $path = self::request_path();
886 2385
887 2386 $excluded_urls = is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array();
888 - if ( ! $cache_feed && Glob_Matcher::any_match( $excluded_urls, $path ) ) {
2387 + if ( ! $cache_feed && self::path_matches_exclusions( $excluded_urls, $path ) ) {
889 2388 return self::bypass( 'excluded-url' );
890 2389 }
891 2390
892 2391 // Cookie-based exclusion. We only check cookie NAMES (matching
@@ -1034,8 +2533,32 @@
1034 2533 return '' !== trim( $query );
1035 2534 }
1036 2535
1037 2536 /**
2537 + * Whether this request's query string is empty or holds only params the
2538 + * cache ignores (`ignored_query_params`, utm_* and the like), so the
2539 + * page is the one the bare URL serves.
2540 + *
2541 + * The same test should_cache() applies, without its search and feed
2542 + * exceptions: those are keyed apart from the bare URL.
2543 + */
2544 + public static function query_has_only_ignored_params(): bool {
2545 + $query_raw = isset( $_SERVER['QUERY_STRING'] ) ? wp_unslash( $_SERVER['QUERY_STRING'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- as in should_cache(): only keys are read, via preg_match.
2546 + if ( '' === trim( (string) $query_raw ) ) {
2547 + return true;
2548 + }
2549 + $cache_opts = Settings_Manager::get( 'cache' );
2550 + $ignored = is_array( $cache_opts['ignored_query_params'] ?? null ) ? $cache_opts['ignored_query_params'] : array();
2551 + parse_str( (string) $query_raw, $params );
2552 + foreach ( $params as $key => $_ ) {
2553 + if ( ! self::query_key_is_ignored( (string) $key, $ignored ) ) {
2554 + return false;
2555 + }
2556 + }
2557 + return true;
2558 + }
2559 +
2560 + /**
1038 2561 * Would authoring a cache entry from THIS request file a query-string
1039 2562 * render under the bare URL?
1040 2563 *
1041 2564 * The one predicate both write sites ask, so they cannot drift.
@@ -1124,11 +2647,227 @@
1124 2647 * `~utm_…` default vs `my_utm_source`. A param name that is genuinely
1125 2648 * unknown now bypasses the cache, which is the safe direction.
1126 2649 */
1127 2650 private static function query_key_is_ignored( string $key, array $ignored ): bool {
2651 + if ( in_array( $key, self::NEVER_IGNORED_QUERY_PARAMS, true ) ) {
2652 + return false;
2653 + }
1128 2654 return Glob_Matcher::any_match_name( $ignored, $key );
1129 2655 }
1130 2656
2657 + /**
2658 + * Query params no ignored-params entry can match, glob or regex.
2659 + *
2660 + * A measuring request asks for the page as it is before optimisation
2661 + * (`xspeed_css=off`), with a one-time value (`xspeed_nc`) so no cache
2662 + * has a copy of it. The answer must be rendered for that request. A
2663 + * list entry such as `xspeed_*` or `*` would make both params
2664 + * decoration: the drop-in would serve the canonical, already-optimised
2665 + * entry before any plugin loads, and the measurement would describe
2666 + * the optimised page instead of the source.
2667 + */
2668 + public const NEVER_IGNORED_QUERY_PARAMS = array( 'xspeed_css', 'xspeed_nc' );
2669 +
2670 + /**
2671 + * The params a query-form feed may carry (`/?feed=rss2`) once feed
2672 + * caching has opted the request in. should_cache() lets them through,
2673 + * and serves_query_variant() holds a HIT that carries one.
2674 + */
2675 + private const FEED_QUERY_PARAMS = array( 'feed', 'withcomments', 'withoutcomments' );
2676 +
2677 + /**
2678 + * The one spelling of a URL path that every cache key is built from.
2679 + *
2680 + * WordPress stores a non-ASCII slug percent-encoded, so a page called
2681 + * `關於我們` is requested as `/%e9%97%9c%e6%96%bc%e6%88%91%e5%80%91/`. The
2682 + * same page also arrives as `/%E9%97%9C…/` (what a browser sends for an
2683 + * address typed or pasted into it) and as raw UTF-8 bytes. WordPress
2684 + * renders the same page for all three, so they get one key: escapes are
2685 + * lowercased (WordPress's own spelling, so a permalink comes back
2686 + * unchanged) and any byte outside printable ASCII is escaped the same way.
2687 + * Null bytes are dropped, as the drop-in does.
2688 + *
2689 + * Nothing is decoded. `/ab%6Fut/` stays a separate entry from `/about/`.
2690 + * Only spellings of the same bytes are merged, because merging spellings
2691 + * WordPress may route differently would let one page be served for
2692 + * another. A printable-ASCII path with no escapes comes back unchanged,
2693 + * so ordinary pages keep the keys they had.
2694 + *
2695 + * The path used to go through sanitize_text_field(), which deletes every
2696 + * `%XX` octet: `/第九屆-當代藝術-tagboat-award-特展/` was keyed as
2697 + * `/--tagboat-award-/`, every page whose slug differed only in non-ASCII
2698 + * characters shared one entry, and an all-non-ASCII slug became `//`,
2699 + * which store_static() wrote over the home page's static file.
2700 + *
2701 + * The drop-in carries a copy of this transform, because it runs before
2702 + * WordPress and this class load. Change both together.
2703 + *
2704 + * @param string $path A URL path, without the query string.
2705 + */
2706 + public static function normalize_path( string $path ): string {
2707 + return self::path_with_escape_case( $path, false );
2708 + }
2709 +
2710 + /**
2711 + * The spellings of one path that a cache in front of the site may hold.
2712 + *
2713 + * xSpeed keys every escape spelling of a path as one entry
2714 + * (normalize_path()). A CDN does not: Cloudflare keeps `/%E9%97%9C…/`,
2715 + * the spelling a browser sends, apart from `/%e9%97%9c…/`, the spelling
2716 + * of WordPress's own links. A purge that named one left the other
2717 + * serving the old page until its edge lifetime ran out.
2718 + *
2719 + * Returns the path as given, then normalize_path()'s spelling (lower-case
2720 + * escapes), then the same with upper-case escapes. Both are built from
2721 + * the path the local sweep cleared, so a cache in front is never asked
2722 + * about a page xSpeed did not purge. Duplicates drop out, so a path with
2723 + * no escapes has one spelling. Trailing-slash forms are the caller's job.
2724 + *
2725 + * @param string $path A URL path, without the query string.
2726 + * @return string[]
2727 + */
2728 + public static function escape_spellings( string $path ): array {
2729 + return array_values(
2730 + array_unique(
2731 + array( $path, self::path_with_escape_case( $path, false ), self::path_with_escape_case( $path, true ) )
2732 + )
2733 + );
2734 + }
2735 +
2736 + /**
2737 + * normalize_path() with the escapes in the case asked for.
2738 + *
2739 + * @param string $path A URL path, without the query string.
2740 + * @param bool $upper Upper-case escapes, or lower (the key's spelling).
2741 + */
2742 + private static function path_with_escape_case( string $path, bool $upper ): string {
2743 + return (string) preg_replace_callback(
2744 + '/%[0-9a-fA-F]{2}|[^\x21-\x7E]/',
2745 + static function ( array $m ) use ( $upper ): string {
2746 + $escape = '%' === $m[0][0] ? $m[0] : sprintf( '%%%02x', ord( $m[0] ) );
2747 + return $upper ? strtoupper( $escape ) : strtolower( $escape );
2748 + },
2749 + str_replace( "\0", '', $path )
2750 + );
2751 + }
2752 +
2753 + /**
2754 + * Percent-encode every byte 0x80 and up, lowercase hex.
2755 + *
2756 + * @param string $value A path or a whole URL.
2757 + */
2758 + private static function encode_non_ascii( string $value ): string {
2759 + return (string) preg_replace_callback(
2760 + '/[\x80-\xff]/',
2761 + static function ( array $m ): string {
2762 + return '%' . bin2hex( $m[0] );
2763 + },
2764 + $value
2765 + );
2766 + }
2767 +
2768 + /**
2769 + * Where a URL path lives in the static tree, relative to the host
2770 + * directory, with a leading slash and no trailing one ('' for the home
2771 + * page). Null when the path must not be written there.
2772 + *
2773 + * Decoded, because that is what the web server looks up: nginx builds the
2774 + * file name from `$uri` and Apache from `%{REQUEST_URI}`, and both hold
2775 + * the decoded path. A file stored under the encoded name is never found.
2776 + *
2777 + * Decoding is only safe for the escapes WordPress itself writes, which
2778 + * encode the bytes of non-ASCII letters (0x80 and up). An escaped ASCII
2779 + * byte is refused, because the server decodes it before the lookup:
2780 + * `%2F` would become a directory, `%2E%2E` a parent, and `%6F` would
2781 + * make `/ab%6Fut/` and `/about/` share one file although they are
2782 + * separate cache entries. The decoded path must also be valid UTF-8, with
2783 + * no control byte, backslash or `%`, no `..` anywhere and no `.`
2784 + * segment. A refused page is still cached by the drop-in; it only loses
2785 + * the no-PHP path.
2786 + *
2787 + * @param string $path A URL path, without the query string.
2788 + */
2789 + public static function static_path( string $path ): ?string {
2790 + if ( '' === $path || false !== strpos( $path, "\0" ) ) {
2791 + return null;
2792 + }
2793 + if ( preg_match_all( '/%([0-9a-fA-F]{2})/', $path, $escapes ) ) {
2794 + foreach ( $escapes[1] as $hex ) {
2795 + if ( hexdec( $hex ) < 0x80 ) {
2796 + return null;
2797 + }
2798 + }
2799 + }
2800 + $decoded = rawurldecode( $path );
2801 + if ( 1 !== preg_match( '//u', $decoded ) || preg_match( '/[\x00-\x1f\x7f\\\\%]/', $decoded ) ) {
2802 + return null;
2803 + }
2804 + $decoded = (string) preg_replace( '#/+#', '/', $decoded );
2805 + if ( false !== strpos( $decoded, '..' ) || preg_match( '#(^|/)\.(/|$)#', $decoded ) ) {
2806 + return null;
2807 + }
2808 + return rtrim( $decoded, '/' );
2809 + }
2810 +
2811 + /**
2812 + * The current request's path, normalized, without the query string.
2813 + *
2814 + * @param string $fallback Returned when the server sent no REQUEST_URI.
2815 + */
2816 + private static function request_path( string $fallback = '' ): string {
2817 + if ( ! isset( $_SERVER['REQUEST_URI'] ) ) {
2818 + return $fallback;
2819 + }
2820 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- normalize_path() is the sanitizer. sanitize_text_field() deletes percent-encoded octets and broke every non-ASCII slug (see normalize_path()). The value is hashed, matched, or checked by static_path() before it touches the disk.
2821 + $uri = (string) wp_unslash( $_SERVER['REQUEST_URI'] );
2822 + return self::normalize_path( (string) strtok( $uri, '?' ) );
2823 + }
2824 +
2825 + /**
2826 + * An Excluded URLs entry with its escapes in lower case.
2827 + *
2828 + * The request path is matched in normalize_path()'s spelling, so an entry
2829 + * copied from Chrome's address bar (`/%E8%81%AF…/`) never matched it.
2830 + * Only `%XX` sequences change; the rest of the entry, including a `~`
2831 + * regex, is left as typed.
2832 + *
2833 + * @param string $pattern One Excluded URLs entry.
2834 + */
2835 + public static function normalize_exclusion( string $pattern ): string {
2836 + return (string) preg_replace_callback(
2837 + '/%[0-9a-fA-F]{2}/',
2838 + static function ( array $m ): string {
2839 + return strtolower( $m[0] );
2840 + },
2841 + $pattern
2842 + );
2843 + }
2844 +
2845 + /**
2846 + * Does an exclusion list match this path, in either spelling?
2847 + *
2848 + * An admin may paste an exclusion as `/購物車`, as WordPress spells it, or
2849 + * as Chrome shows it in upper case. The entry's escapes are lowercased to
2850 + * match the path, and the decoded path is tried too, which is also what
2851 + * the nginx rule matches (`$uri` is decoded).
2852 + *
2853 + * @param string[] $patterns Exclusion entries.
2854 + * @param string $path Output of normalize_path().
2855 + */
2856 + private static function path_matches_exclusions( array $patterns, string $path ): bool {
2857 + $patterns = array_map(
2858 + static function ( $pattern ): string {
2859 + return self::normalize_exclusion( (string) $pattern );
2860 + },
2861 + $patterns
2862 + );
2863 + if ( Glob_Matcher::any_match( $patterns, $path ) ) {
2864 + return true;
2865 + }
2866 + $decoded = rawurldecode( $path );
2867 + return $decoded !== $path && Glob_Matcher::any_match( $patterns, $decoded );
2868 + }
2869 +
1131 2870 public static function cache_key() {
1132 2871 $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : 'default';
1133 2872
1134 2873 // Cacheable 404s share ONE generic per-host entry — keying them by
@@ -1138,14 +2877,13 @@
1138 2877 if ( self::should_cache_404() ) {
1139 2878 return md5( $host . '|404' );
1140 2879 }
1141 2880
1142 - $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/';
1143 2881 // Strip the query string from the key so /post and /post?utm_*=…
1144 2882 // share the same cache entry. should_cache() above already
1145 2883 // rejected requests with non-ignored params, so by the time we
1146 2884 // build the key the only params left are safe to drop.
1147 - $uri = (string) strtok( $uri, '?' );
2885 + $uri = self::request_path( '/' );
1148 2886
1149 2887 // Optional device bucket: when mobile_separate is on, mobile and
1150 2888 // desktop responses live in different cache files so themes that
1151 2889 // serve different HTML by device (AMP, WPtouch, Jetpack mobile)
@@ -1426,10 +3164,9 @@
1426 3164 * delete indiscriminately).
1427 3165 */
1428 3166 public static function current_host_dir(): string {
1429 3167 $host = self::current_host();
1430 - $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/';
1431 - return self::site_bucket( $host, $uri );
3168 + return self::site_bucket( $host, self::request_path( '/' ) );
1432 3169 }
1433 3170
1434 3171 /**
1435 3172 * The host the CURRENT blog is served from.
@@ -1785,8 +3522,19 @@
1785 3522 * and returns true (caller should exit without a body). Returns false to
1786 3523 * proceed with a normal 200 body. Lets aggregators/browsers skip
1787 3524 * re-downloading an unchanged cached response. (FBS-82407 #5)
1788 3525 *
3526 + * Also stamps `X-XSpeed-Built` from the same mtime. This is the PHP serve
3527 + * path's build time, and the only place on it that holds the file: mark()
3528 + * has already run by the time we get here and resolved the edge-header
3529 + * filter without one. Both are emitted, and a purge verifier prefers the
3530 + * stamp: `Last-Modified` carries the same integer, but it is a generic
3531 + * header that the origin-side cache layer such a verifier exists to
3532 + * detect — or any proxy in between — may rewrite to its own store time,
3533 + * which would turn a stale origin into a pass. Nothing but xSpeed's own
3534 + * serve code writes `X-XSpeed-Built`, so a value older than the purge is
3535 + * proof the origin answered with old HTML.
3536 + *
1789 3537 * @param string $file Absolute path to the cache .html file.
1790 3538 * @return bool True when a 304 was sent.
1791 3539 */
1792 3540 public static function serve_not_modified( string $file ): bool {
@@ -1797,8 +3545,12 @@
1797 3545 $last_modified = gmdate( 'D, d M Y H:i:s', $mtime ) . ' GMT';
1798 3546 $etag = '"' . md5( $file . '|' . $mtime ) . '"';
1799 3547 header( 'Last-Modified: ' . $last_modified );
1800 3548 header( 'ETag: ' . $etag );
3549 + // Before the 304 branch below, so a conditional request that gets a
3550 + // bodyless 304 still carries the stamp. A verifier's fetch may well
3551 + // be conditional, and a 304 with no build time reads as unverifiable.
3552 + header( self::BUILT_HEADER . ': ' . $mtime );
1801 3553
1802 3554 $ims = isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ) ) : '';
1803 3555 $inm = isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_NONE_MATCH'] ) ) ) : '';
1804 3556
@@ -1903,8 +3655,13 @@
1903 3655
1904 3656 $full = self::$accumulated;
1905 3657 self::$accumulated = '';
1906 3658
3659 + // Consumed here, on every final path, so a later call can never
3660 + // compare against this request's snapshot.
3661 + $asset_stamp = self::$asset_stamp_at_open;
3662 + self::$asset_stamp_at_open = null;
3663 +
1907 3664 if ( strlen( $full ) < 255 ) {
1908 3665 return $buffer;
1909 3666 }
1910 3667
@@ -2000,8 +3757,16 @@
2000 3757 // deferred writer has always placed its own copy of the guard.
2001 3758 if ( self::query_string_blocks_write() ) {
2002 3759 return $buffer;
2003 3760 }
3761 +
3762 + // A purge deleted minified or combined files while this page was
3763 + // rendering, so it may link names that are gone. Serve it, but do
3764 + // not store it; the next request renders against the files as they
3765 + // are now.
3766 + if ( self::assets_purged_since( $asset_stamp ) ) {
3767 + return $buffer;
3768 + }
2004 3769 $file = self::cache_file_for( $key );
2005 3770
2006 3771 // A render-time translation plugin (TranslatePress) wraps our buffer,
2007 3772 // so the bytes we hold here are still UNTRANSLATED — its callback has
@@ -2009,9 +3774,10 @@
2009 3774 // and bake in its internal #TRPLINKPROCESSED markers. Hand off to
2010 3775 // shutdown, where the outer buffer has already translated, and let
2011 3776 // the pass-through below deliver this request untouched.
2012 3777 if ( self::translation_plugin_active() ) {
2013 - self::$deferred_key = $key;
3778 + self::$deferred_key = $key;
3779 + self::$deferred_asset_stamp = $asset_stamp;
2014 3780 // Reaching here means finalize_buffer() ran to completion: the
2015 3781 // status gate passed, should_cache() said yes, and PHP handed us
2016 3782 // the whole buffer. A wp_die() or exit() mid-render unwinds the
2017 3783 // buffer stack WITHOUT calling this callback, so the flag stays
@@ -2024,10 +3790,32 @@
2024 3790 return $buffer;
2025 3791 }
2026 3792
2027 3793 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context for credentials; cache writes happen on frontend requests where it's unavailable.
2028 - file_put_contents( $file, $full, LOCK_EX );
3794 + $stored = file_put_contents( $file, $full, LOCK_EX );
2029 3795
3796 + /*
3797 + * No edge headers on a MISS. Not even a stored one.
3798 + *
3799 + * A MISS is the FIRST render, and it is the render most likely to be
3800 + * replaced: critical CSS and unused CSS are generated after the fact
3801 + * and applied to later requests, so the copy written here is the
3802 + * pre-optimization one. Telling a CDN to hold it pins exactly the
3803 + * version xSpeed is about to improve on — reported from a live site,
3804 + * where Cloudflare had cached an unoptimized first render.
3805 + *
3806 + * `CDN-Cache-Control` is why it reaches the edge at all: Cloudflare
3807 + * honours it as the CDN-targeted directive whatever its own cache
3808 + * rules say, so an origin header is enough to pin HTML for the whole
3809 + * TTL even where edge page caching is switched off.
3810 + *
3811 + * A HIT is the safe moment and the honest one: it means xSpeed is
3812 + * serving its stored copy, that copy is what the edge would mirror,
3813 + * and a later regeneration purges it — which reaches the edge through
3814 + * the purge actions. So the edge caches from the second visitor on,
3815 + * one render later than before and the right one.
3816 + */
3817 +
2030 3818 /**
2031 3819 * Fires after the flat hash cache file ({md5}.html) is written.
2032 3820 *
2033 3821 * Mirror of `xspeed_static_file_written` for the flat cache. The PHP
@@ -2063,9 +3851,16 @@
2063 3851 // 200, FBS-82406) or a non-HTML content-type (a cached feed would go
2064 3852 // out as text/html, FBS-82407). The web server serves these .html files
2065 3853 // directly with no PHP, so there's no .meta replay — keep them on the
2066 3854 // drop-in / PHP path instead, which DOES replay status + content-type.
2067 - if ( self::static_rewrite_allowed() && self::response_is_plain_html() ) {
3855 + // The static tree cannot replay a sidecar. A file served straight by
3856 + // the web server carries the headers baked into the rule that serves
3857 + // the whole site — the very answer this entry exists because it
3858 + // disagreed with. Same reasoning as the status and content-type
3859 + // cases: what the fast path cannot replay belongs on the drop-in path.
3860 + if ( self::static_rewrite_allowed()
3861 + && self::response_is_plain_html()
3862 + && array() === self::per_entry_edge_headers() ) {
2068 3863 self::store_static( $full );
2069 3864 }
2070 3865
2071 3866 return $buffer;
@@ -2077,12 +3872,13 @@
2077 3872 * rewrite block points at this path so cache hits skip PHP
2078 3873 * entirely. Caller already minified/finalized $html.
2079 3874 *
2080 3875 * Path safety: $host is restricted to a `[a-zA-Z0-9.\-]` allowlist;
2081 - * $uri has its query string stripped, null bytes removed, '..'
2082 - * sequences collapsed, and after concatenation we verify the
2083 - * resolved real path stays inside XSPEED_CACHE_STATIC_DIR before
2084 - * any write. Anything off the happy path returns silently.
3876 + * the path goes through static_path(), which decodes it the way the
3877 + * web server will and refuses anything that could leave the host
3878 + * directory or share a file with another page. After the directory is
3879 + * created we verify its real path is inside XSPEED_CACHE_STATIC_DIR
3880 + * before any write. Anything off the happy path returns silently.
2085 3881 *
2086 3882 * INVARIANT — the static tree is keyed by `{host}{path}` and NOTHING
2087 3883 * else, and both generated rewrites refuse any request that carries a
2088 3884 * query string at all (`RewriteCond %{QUERY_STRING} ^$` on Apache,
@@ -2145,15 +3941,13 @@
2145 3941 }
2146 3942 $keys = array_slice( array_values( array_unique( $found ) ), 0, 10 );
2147 3943 }
2148 3944
2149 - $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
2150 -
2151 3945 set_transient(
2152 3946 self::STATIC_SKIP_TRANSIENT,
2153 3947 array(
2154 3948 'reason' => $reason,
2155 - 'url' => (string) strtok( $uri, '?' ),
3949 + 'url' => esc_url_raw( self::request_path() ),
2156 3950 'keys' => $keys,
2157 3951 'at' => time(),
2158 3952 ),
2159 3953 HOUR_IN_SECONDS
@@ -2220,23 +4014,21 @@
2220 4014 return;
2221 4015 }
2222 4016
2223 4017 $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
2224 - $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
2225 4018 $host = self::static_host_dir( $host );
2226 - $uri = str_replace( "\0", '', $uri );
2227 - $uri = (string) strtok( $uri, '?' );
4019 + $uri = self::request_path();
2228 4020 if ( '' === $host || '' === $uri ) {
2229 4021 return;
2230 4022 }
2231 - // Collapse any traversal sequences before path resolution.
2232 - $uri = preg_replace( '#/+#', '/', $uri );
2233 - if ( false !== strpos( $uri, '..' ) ) {
4023 + // Decoded the way the web server will decode it, or refused.
4024 + $rel = self::static_path( $uri );
4025 + if ( null === $rel ) {
2234 4026 return;
2235 4027 }
2236 4028
2237 4029 $base = rtrim( XSPEED_CACHE_STATIC_DIR, '/' );
2238 - $dir = $base . '/' . $host . rtrim( $uri, '/' );
4030 + $dir = $base . '/' . $host . $rel;
2239 4031 $file = $dir . '/index.html';
2240 4032
2241 4033 // Resolve the parent against the cache root to be sure the
2242 4034 // final path is inside our tree even if the OS does anything
@@ -2251,8 +4043,15 @@
2251 4043 }
2252 4044 if ( ! is_dir( $dir ) ) {
2253 4045 return;
2254 4046 }
4047 + // static_path() already refuses every traversal spelling. This
4048 + // catches a directory in the tree that resolves somewhere else.
4049 + $dir_real = realpath( $dir );
4050 + $static_real = realpath( $base );
4051 + if ( false === $dir_real || false === $static_real || 0 !== strpos( $dir_real . '/', $static_real . '/' ) ) {
4052 + return;
4053 + }
2255 4054 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- Same rationale as the flat-hash cache write above: WP_Filesystem isn't available on frontend requests, and the cache write must happen during shutdown.
2256 4055 $written = file_put_contents( $file, $html, LOCK_EX );
2257 4056
2258 4057 // A nonce-bearing page expires on the nonce's schedule, not the site's.
@@ -2596,8 +4395,23 @@
2596 4395 if ( $ttl > 0 && $ttl !== $default_ttl ) {
2597 4396 $meta['ttl'] = $ttl;
2598 4397 }
2599 4398
4399 + // This entry's edge headers, when they differ from the site-wide set
4400 + // baked into the drop-in. The sidecar is the only channel that can
4401 + // carry a per-page answer into the pre-boot fast path, and the drop-in
4402 + // REPLACES the baked set with it rather than merging: the two describe
4403 + // the same response, so merging would leave the baked lifetime in
4404 + // place beside the hold meant to overrule it.
4405 + //
4406 + // The lifetime resolved above goes with it, under the same condition
4407 + // as the `ttl` key: a page that does not follow the site's lifetime
4408 + // may not be kept at the edge past its own either.
4409 + $edge = self::per_entry_edge_headers( isset( $meta['ttl'] ) ? $ttl : null );
4410 + if ( array() !== $edge ) {
4411 + $meta['edge_headers'] = $edge;
4412 + }
4413 +
2600 4414 // Nothing to replay → no sidecar.
2601 4415 if ( empty( $meta ) ) {
2602 4416 return;
2603 4417 }
@@ -2763,8 +4577,16 @@
2763 4577 * markup across the site, so they require a site response-cache purge.
2764 4578 * Content saves also require this scope while their local operation is a
2765 4579 * complete bucket sweep.
2766 4580 *
4581 + * A new term is `content`, not `presentation`. It has no posts yet, so no
4582 + * page renders it until a post is saved with it, and that save is its own
4583 + * content purge. Classed as presentation, it cleared the host's whole
4584 + * nginx cache every time a post was published with a tag that did not
4585 + * exist yet, which is most publishing. Renaming or deleting a term stays
4586 + * presentation: the new name shows on every post in the term, and Nginx
4587 + * Helper purges only the homepage for either. (QA #448)
4588 + *
2767 4589 * @return array{scope:string,intent:string,urls:array<int,string>}
2768 4590 */
2769 4591 private static function invalidation_for_hook( string $hook ): array {
2770 4592 $presentation = array(
@@ -2770,9 +4592,8 @@
2770 4592 $presentation = array(
2771 4593 'switch_theme',
2772 4594 'activated_plugin',
2773 4595 'deactivated_plugin',
2774 - 'created_term',
2775 4596 'edited_term',
2776 4597 'delete_term',
2777 4598 'wp_update_nav_menu',
2778 4599 );
@@ -2813,8 +4634,18 @@
2813 4634 // Name the trigger rather than logging a bare numeric id — the old
2814 4635 // wiring passed the post ID into $cause, so the log read
2815 4636 // "Cache purged (46)" with no indication of what caused it. (#243)
2816 4637 $presentation = in_array( $post_type, self::presentation_post_types(), true );
4638 +
4639 + // Clearing only the affected pages needs the post's terms, and the
4640 + // REST API sets those after `save_post`. Note the post and purge on
4641 + // `wp_after_insert_post`; flush_pending_saves() covers a save that
4642 + // never gets there.
4643 + if ( ! $presentation && self::narrow_purge_applies( $post_type ) ) {
4644 + self::$pending_saves[ (int) $post_id ] = true;
4645 + return;
4646 + }
4647 +
2817 4648 self::purge_all(
2818 4649 'post:' . $post_type,
2819 4650 null,
2820 4651 array(
@@ -2825,14 +4656,373 @@
2825 4656 'intent' => $presentation ? 'presentation' : 'content',
2826 4657 'urls' => array(),
2827 4658 )
2828 4659 );
2829 - if ( class_exists( '\XSpeed\Minifier' ) ) {
2830 - Minifier::purge_minified();
4660 + }
4661 +
4662 + /**
4663 + * Reasons a change that would have cleared only its own pages cleared
4664 + * the whole site, published as `fallback` in the purge context.
4665 + *
4666 + * - THEME_LIST: the change alters a post or comment list the theme draws
4667 + * on pages the rules cannot name.
4668 + * - LIMIT: the affected pages are more than Affected_Pages::LIMIT.
4669 + * - PENDING: the save never reached `wp_after_insert_post`, so there was
4670 + * no before-copy to work the old address out from.
4671 + * - FILTER: `xspeed_purge_affected_pages` returned false.
4672 + * - LISTING: the record of pages that run a post list of their own
4673 + * (Listing_Pages) cannot answer: a page of the type went unrecorded at
4674 + * its cap, more than Affected_Pages::LIMIT pages of the type would
4675 + * have to be named, or the index does not read back. Or the pages it
4676 + * named took the list past Affected_Pages::LIMIT.
4677 + *
4678 + * Every other purge publishes ''. A post type that always purges the
4679 + * whole site (a WooCommerce product) is a choice, not a fallback, and
4680 + * publishes '' too.
4681 + */
4682 + public const FALLBACK_THEME_LIST = 'theme_list';
4683 + public const FALLBACK_LIMIT = 'limit';
4684 + public const FALLBACK_PENDING = 'pending';
4685 + public const FALLBACK_FILTER = 'filter';
4686 + public const FALLBACK_LISTING = 'listing';
4687 +
4688 + /**
4689 + * Posts noted by on_save_post() for a narrow purge, keyed by ID.
4690 + *
4691 + * @var array<int,bool>
4692 + */
4693 + private static $pending_saves = array();
4694 +
4695 + /**
4696 + * Posts whose save purged their pages in this request, keyed by ID. That
4697 + * purge named page 1 of the posts page, so a sticky change made after
4698 + * it (the classic editor and Quick Edit stick after saving) has nothing
4699 + * left to clear there.
4700 + *
4701 + * @var array<int,bool>
4702 + */
4703 + private static $saves_purged = array();
4704 +
4705 + /**
4706 + * Purge the pages a save affected, once WordPress has finished saving.
4707 + *
4708 + * @param int $post_id Post ID.
4709 + * @param \WP_Post $post Post as saved.
4710 + * @param bool $update Whether an existing post was updated.
4711 + * @param \WP_Post|null $post_before Post before the save, null when new.
4712 + */
4713 + public static function on_after_insert_post( $post_id, $post = null, $update = false, $post_before = null ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- hook signature.
4714 + $post_id = (int) $post_id;
4715 + if ( ! isset( self::$pending_saves[ $post_id ] ) ) {
4716 + return;
2831 4717 }
4718 + unset( self::$pending_saves[ $post_id ] );
4719 + if ( ! $post instanceof \WP_Post ) {
4720 + $post = get_post( $post_id );
4721 + }
4722 + if ( ! $post instanceof \WP_Post ) {
4723 + return;
4724 + }
4725 + $post_before = $post_before instanceof \WP_Post ? $post_before : null;
4726 + if ( self::repeats_block_editor_save( $post, $post_before ) ) {
4727 + Affected_Pages::forget( $post_id );
4728 + return;
4729 + }
4730 + self::$saves_purged[ $post_id ] = true;
4731 + self::purge_post_change( $post, $post_before, 'post:' . $post->post_type );
2832 4732 }
2833 4733
4734 + /** Meta WordPress rewrites on every save, which says nothing about the page. */
4735 + private const SAVE_NOISE_META = array( '_edit_lock', '_edit_last', '_encloseme', '_pingme' );
4736 +
2834 4737 /**
4738 + * Whether this save repeats the block editor save that just purged.
4739 + *
4740 + * The block editor saves a post through REST, then, when the screen has
4741 + * meta boxes (xSpeed's own Cache Rules box is one), posts them to
4742 + * post.php?meta-box-loader=1, which saves the post a second time. Both
4743 + * requests purged, so every save sent the same pages to every cache in
4744 + * front twice. The REST save notes what it purged for; the meta box
4745 + * request skips its purge when nothing a visitor sees has changed since:
4746 + * the post's fields, its terms and its meta. A meta box that saved meta
4747 + * the page prints (an SEO title, a custom field) still purges.
4748 + *
4749 + * @param \WP_Post $post Post as saved.
4750 + * @param \WP_Post|null $before Post before the save.
4751 + */
4752 + private static function repeats_block_editor_save( \WP_Post $post, ?\WP_Post $before ): bool {
4753 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- only names the request; WordPress verified the save's own nonce.
4754 + $meta_boxes = isset( $_GET['meta-box-loader'] );
4755 + $rest = defined( 'REST_REQUEST' ) && REST_REQUEST;
4756 + if ( ! $meta_boxes && ! $rest ) {
4757 + return false;
4758 + }
4759 + if ( ! Affected_Pages::is_public( $post ) && ! ( $before instanceof \WP_Post && Affected_Pages::is_public( $before ) ) ) {
4760 + return false;
4761 + }
4762 + $key = 'xspeed_saved_' . (int) $post->ID;
4763 + $fingerprint = self::save_fingerprint( $post );
4764 + if ( $meta_boxes ) {
4765 + if ( get_transient( $key ) === $fingerprint ) {
4766 + delete_transient( $key );
4767 + return true;
4768 + }
4769 + return false;
4770 + }
4771 + set_transient( $key, $fingerprint, 2 * MINUTE_IN_SECONDS );
4772 + return false;
4773 + }
4774 +
4775 + /**
4776 + * A hash of what a save can change that a visitor sees: the post's own
4777 + * fields, its terms and its meta.
4778 + *
4779 + * @param \WP_Post $post Post as saved.
4780 + */
4781 + private static function save_fingerprint( \WP_Post $post ): string {
4782 + $fields = array();
4783 + foreach ( array( 'post_type', 'post_status', 'post_date', 'post_title', 'post_name', 'post_content', 'post_excerpt', 'post_parent', 'menu_order', 'post_author', 'post_password', 'comment_status' ) as $field ) {
4784 + $fields[ $field ] = (string) ( $post->$field ?? '' );
4785 + }
4786 + $terms = array();
4787 + $taxonomies = get_object_taxonomies( (string) $post->post_type );
4788 + if ( is_array( $taxonomies ) && array() !== $taxonomies ) {
4789 + $ids = wp_get_object_terms( (int) $post->ID, $taxonomies, array( 'fields' => 'tt_ids' ) );
4790 + $terms = is_array( $ids ) ? array_map( 'intval', $ids ) : array();
4791 + sort( $terms );
4792 + }
4793 + $meta = get_post_meta( (int) $post->ID );
4794 + $meta = is_array( $meta ) ? array_diff_key( $meta, array_flip( self::SAVE_NOISE_META ) ) : array();
4795 + ksort( $meta );
4796 + return md5( (string) wp_json_encode( array( $fields, $terms, $meta ) ) );
4797 + }
4798 +
4799 + /**
4800 + * A save that never reached `wp_after_insert_post` still purges.
4801 + *
4802 + * `wp_insert_post()` called with `$fire_after_hooks = false` leaves that
4803 + * hook to its caller, and a caller can fail to fire it. Without this, the
4804 + * save would purge nothing at all. Site-wide, because there is no
4805 + * before-copy to work out the old address from.
4806 + */
4807 + public static function flush_pending_saves(): void {
4808 + if ( array() === self::$pending_saves ) {
4809 + return;
4810 + }
4811 + $ids = array_keys( self::$pending_saves );
4812 + self::$pending_saves = array();
4813 + self::purge_all(
4814 + 'post:pending',
4815 + null,
4816 + array(
4817 + 'scope' => 'site',
4818 + 'intent' => 'content',
4819 + 'urls' => array(),
4820 + 'fallback' => self::FALLBACK_PENDING,
4821 + )
4822 + );
4823 + foreach ( $ids as $id ) {
4824 + Affected_Pages::forget( (int) $id );
4825 + }
4826 + }
4827 +
4828 + /**
4829 + * Before an update is written: note the post's neighbours, which a new
4830 + * date or category takes it away from. Only for a post visitors can see
4831 + * now, and only when the save will purge narrowly; that costs four
4832 + * queries per update.
4833 + *
4834 + * @param int $post_id Post ID.
4835 + * @param array $data Unused: the new values.
4836 + */
4837 + public static function on_pre_post_update( $post_id, $data = array() ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- hook signature.
4838 + $post = get_post( (int) $post_id );
4839 + if ( ! $post instanceof \WP_Post || ! Affected_Pages::is_public( $post ) ) {
4840 + return;
4841 + }
4842 + if ( in_array( (string) $post->post_type, self::presentation_post_types(), true ) || ! self::narrow_purge_applies( (string) $post->post_type ) ) {
4843 + return;
4844 + }
4845 + Affected_Pages::remember_old_neighbours( $post );
4846 + }
4847 +
4848 + /**
4849 + * A post was stuck or unstuck.
4850 + *
4851 + * A theme list that puts sticky posts first (Twenty Twenty-Five's "More
4852 + * posts" under every post) changes on every page that draws it, so the
4853 + * whole site goes. The block editor changes stickiness inside the save,
4854 + * and the save's own purge sees it then. The classic editor and Quick
4855 + * Edit change it after the save has purged, which is why this hook
4856 + * purges the posts no save is waiting on.
4857 + *
4858 + * @param mixed $old_value Sticky post IDs before.
4859 + * @param mixed $value Sticky post IDs after.
4860 + */
4861 + public static function on_sticky_posts_change( $old_value, $value ): void {
4862 + $blog_page = false;
4863 + foreach ( Affected_Pages::remember_sticky_change( $old_value, $value ) as $post_id ) {
4864 + if ( isset( self::$pending_saves[ $post_id ] ) ) {
4865 + continue;
4866 + }
4867 + $post = get_post( $post_id );
4868 + if ( ! $post instanceof \WP_Post || ! Affected_Pages::is_public( $post ) || ! self::narrow_purge_applies( (string) $post->post_type ) ) {
4869 + continue;
4870 + }
4871 + if ( Affected_Pages::lists_show_sticky( (string) $post->post_type ) ) {
4872 + self::purge_all(
4873 + 'sticky:' . $post->post_type,
4874 + null,
4875 + array(
4876 + 'scope' => 'site',
4877 + 'intent' => 'content',
4878 + 'urls' => array(),
4879 + 'fallback' => self::FALLBACK_THEME_LIST,
4880 + )
4881 + );
4882 + return;
4883 + }
4884 + // WordPress's own blog list puts sticky posts first on its first
4885 + // page, whatever the theme draws, so a post stuck or unstuck by
4886 + // code, WP-CLI or a plugin moves on that page. A save in this
4887 + // request already cleared it.
4888 + if ( 'post' === $post->post_type && ! isset( self::$saves_purged[ $post_id ] ) ) {
4889 + $blog_page = true;
4890 + }
4891 + }
4892 + if ( $blog_page ) {
4893 + $url = Affected_Pages::posts_page_url();
4894 + if ( '' !== $url ) {
4895 + self::purge_urls( array( $url ), 'sticky:post' );
4896 + }
4897 + }
4898 + }
4899 +
4900 + /**
4901 + * The first post stuck on a site: WordPress creates `sticky_posts`
4902 + * instead of updating it, so update_option_sticky_posts never fires.
4903 + *
4904 + * @param string $option Option name.
4905 + * @param mixed $value Sticky post IDs.
4906 + */
4907 + public static function on_sticky_posts_added( $option, $value ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed -- hook signature.
4908 + self::on_sticky_posts_change( array(), $value );
4909 + }
4910 +
4911 + /**
4912 + * Whether a content change to this post type may clear only the pages
4913 + * it affects, before looking at the post itself.
4914 + *
4915 + * Off when the setting is off, during an import (one full purge runs at
4916 + * the end), and for post types whose lists the rules do not know:
4917 + * WooCommerce products, whose shop and category pages purge_product()
4918 + * already handles.
4919 + *
4920 + * @param string $post_type Post type.
4921 + */
4922 + private static function narrow_purge_applies( string $post_type ): bool {
4923 + if ( defined( 'WP_IMPORTING' ) && WP_IMPORTING ) {
4924 + return false;
4925 + }
4926 + $opts = Settings_Manager::get( 'cache' );
4927 + if ( empty( $opts['purge_affected_only'] ) ) {
4928 + return false;
4929 + }
4930 + /**
4931 + * Filter the post types whose saves always purge the whole site.
4932 + *
4933 + * @param string[] $types Post type slugs.
4934 + */
4935 + $site_wide = (array) apply_filters( 'xspeed_site_wide_purge_post_types', array( 'product', 'product_variation' ) );
4936 + return ! in_array( $post_type, $site_wide, true );
4937 + }
4938 +
4939 + /**
4940 + * Purge what a change to one post affects, or the whole site when the
4941 + * affected pages cannot be listed safely.
4942 + *
4943 + * The pages are the ones the rules name (Affected_Pages::for_post())
4944 + * and the recorded pages whose own post lists the change may alter
4945 + * (Listing_Pages::pages_for()).
4946 + *
4947 + * Site-wide when this change alters a post list the theme draws on
4948 + * pages the rules cannot name (Affected_Pages::lists_changed_by()), when
4949 + * the record of listing pages cannot answer, when the list is
4950 + * longer than Affected_Pages::LIMIT, or when the filter says so. Nothing
4951 + * when the post was not public before or after: no page anyone can see
4952 + * changed.
4953 + *
4954 + * @param \WP_Post $post Post as it is now.
4955 + * @param \WP_Post|null $before Post before the change.
4956 + * @param string $cause Purge log cause.
4957 + */
4958 + private static function purge_post_change( \WP_Post $post, ?\WP_Post $before, string $cause ): void {
4959 + // Checked before any list is consulted. A draft save moves the
4960 + // draft's date and can match a list of any kind, which would send a
4961 + // change nobody can see to a site-wide purge.
4962 + if ( ! Affected_Pages::is_public( $post ) && ! ( $before instanceof \WP_Post && Affected_Pages::is_public( $before ) ) ) {
4963 + Affected_Pages::forget( (int) $post->ID );
4964 + return;
4965 + }
4966 + $fallback = '';
4967 + $urls = null;
4968 + if ( Affected_Pages::lists_changed_by( $post, $before ) ) {
4969 + $fallback = self::FALLBACK_THEME_LIST;
4970 + } else {
4971 + $urls = Affected_Pages::for_post( $post, $before );
4972 + // Read before forget(): a plain edit is judged on the terms the
4973 + // save replaced.
4974 + $listing = Listing_Pages::enabled() ? Listing_Pages::pages_for( $post, $before ) : array();
4975 + if ( null === $listing ) {
4976 + $fallback = self::FALLBACK_LISTING;
4977 + $urls = null;
4978 + } else {
4979 + // Pages the rules did not name. Nginx Helper's own purge does
4980 + // not reach them either, so a fallback they cause has to say so.
4981 + $extra = array_diff( $listing, $urls );
4982 + $urls = array_values( array_unique( array_merge( $urls, $listing ) ) );
4983 + if ( count( $urls ) > Affected_Pages::LIMIT ) {
4984 + $fallback = array() === $extra ? self::FALLBACK_LIMIT : self::FALLBACK_LISTING;
4985 + $urls = null;
4986 + }
4987 + }
4988 + }
4989 + Affected_Pages::forget( (int) $post->ID );
4990 +
4991 + if ( is_array( $urls ) ) {
4992 + /**
4993 + * Filter the pages a post change clears.
4994 + *
4995 + * Return false to purge the whole site instead.
4996 + *
4997 + * @param string[]|false $urls Absolute URLs.
4998 + * @param \WP_Post $post The post that changed.
4999 + */
5000 + $urls = apply_filters( 'xspeed_purge_affected_pages', $urls, $post );
5001 + if ( ! is_array( $urls ) ) {
5002 + $fallback = self::FALLBACK_FILTER;
5003 + }
5004 + }
5005 +
5006 + if ( ! is_array( $urls ) ) {
5007 + self::purge_all(
5008 + $cause,
5009 + null,
5010 + array(
5011 + 'scope' => 'site',
5012 + 'intent' => 'content',
5013 + 'urls' => array(),
5014 + 'fallback' => $fallback,
5015 + )
5016 + );
5017 + return;
5018 + }
5019 + if ( array() !== $urls ) {
5020 + self::purge_urls( $urls, $cause );
5021 + }
5022 + }
5023 +
5024 + /**
2835 5025 * Delete/trash invalidation while the post type is still available.
2836 5026 * The local and server response-cache sweeps share the same site boundary.
2837 5027 *
2838 5028 * @param int $post_id Removed post ID.
@@ -2846,8 +5036,39 @@
2846 5036 $post_type = is_object( $post ) && isset( $post->post_type )
2847 5037 ? (string) $post->post_type
2848 5038 : (string) get_post_type( $post_id );
2849 5039
5040 + if ( ! in_array( $post_type, self::presentation_post_types(), true ) && self::narrow_purge_applies( $post_type ) ) {
5041 + $object = $post instanceof \WP_Post ? $post : get_post( (int) $post_id );
5042 + if ( $object instanceof \WP_Post ) {
5043 + if ( 'attachment' === $post_type ) {
5044 + // Its own page and the post it is attached to. Pages that
5045 + // show the file keep an <img> to a file that is gone either
5046 + // way: a fresh render prints the same tag.
5047 + $urls = array();
5048 + $link = get_permalink( $object );
5049 + if ( is_string( $link ) && '' !== $link ) {
5050 + $urls[] = $link;
5051 + }
5052 + $parent = $object->post_parent > 0 ? get_post( (int) $object->post_parent ) : null;
5053 + if ( $parent instanceof \WP_Post && 'publish' === $parent->post_status ) {
5054 + $parent_link = get_permalink( $parent );
5055 + if ( is_string( $parent_link ) && '' !== $parent_link ) {
5056 + $urls[] = $parent_link;
5057 + }
5058 + }
5059 + if ( array() !== $urls ) {
5060 + self::purge_urls( $urls, 'post-removed:attachment' );
5061 + }
5062 + return;
5063 + }
5064 + // Still published here: `before_delete_post` runs before the
5065 + // row goes. Trashing reaches the save path instead.
5066 + self::purge_post_change( $object, null, 'post-removed:' . $post_type );
5067 + return;
5068 + }
5069 + }
5070 +
2850 5071 self::purge_all(
2851 5072 'post-removed:' . $post_type,
2852 5073 null,
2853 5074 array(
@@ -2872,15 +5093,27 @@
2872 5093 $url = get_permalink( $post_id );
2873 5094 if ( ! is_string( $url ) || '' === $url ) {
2874 5095 return;
2875 5096 }
5097 + $post = function_exists( 'get_post' ) ? get_post( $post_id ) : null;
5098 + $narrow = $post instanceof \WP_Post && self::narrow_purge_applies( (string) $post->post_type );
5099 + if ( $narrow && ! Affected_Pages::site_lists_comments() ) {
5100 + $urls = Affected_Pages::for_comment( $post );
5101 + if ( array() !== $urls ) {
5102 + self::purge_urls( $urls, 'comment-status:' . (string) $status );
5103 + }
5104 + return;
5105 + }
2876 5106 self::purge_all(
2877 5107 'comment-status:' . (string) $status,
2878 5108 null,
2879 5109 array(
2880 - 'scope' => 'site',
2881 - 'intent' => 'content',
2882 - 'urls' => array(),
5110 + 'scope' => 'site',
5111 + 'intent' => 'content',
5112 + 'urls' => array(),
5113 + // Narrow purges apply to this post, so the site-wide purge is
5114 + // for a recent-comments list on pages the rules cannot name.
5115 + 'fallback' => $narrow ? self::FALLBACK_THEME_LIST : '',
2883 5116 )
2884 5117 );
2885 5118 }
2886 5119
@@ -2905,8 +5138,19 @@
2905 5138 $post_id = is_array( $data ) && isset( $data['comment_post_ID'] ) ? (int) $data['comment_post_ID'] : 0;
2906 5139 if ( $post_id < 1 ) {
2907 5140 return;
2908 5141 }
5142 + // With narrow purges on, the comment pages and comment feeds go too.
5143 + // No site-wide fallback here: a visitor's comment must never be able
5144 + // to clear the whole site (#243).
5145 + $post = function_exists( 'get_post' ) ? get_post( $post_id ) : null;
5146 + if ( $post instanceof \WP_Post && self::narrow_purge_applies( (string) $post->post_type ) ) {
5147 + $urls = Affected_Pages::for_comment( $post );
5148 + if ( array() !== $urls ) {
5149 + self::purge_urls( $urls, 'comment' );
5150 + }
5151 + return;
5152 + }
2909 5153 $url = get_permalink( $post_id );
2910 5154 if ( is_string( $url ) && '' !== $url ) {
2911 5155 self::purge_url( $url, 'comment' );
2912 5156 }
@@ -2967,8 +5211,18 @@
2967 5211 if ( $parent > 0 ) {
2968 5212 $product_id = $parent;
2969 5213 }
2970 5214
5215 + // wp-admin's Update saves the product through WooCommerce inside the
5216 + // post's own save_post, and Cache::on_save_post() then clears the
5217 + // whole site for it, since products are in
5218 + // xspeed_site_wide_purge_post_types. Sending these pages first only
5219 + // purged them twice. A price or stock change made without a post
5220 + // save (an order, the REST API, `$product->save()`) still purges here.
5221 + if ( self::site_wide_save_under_way( $product_id ) ) {
5222 + return;
5223 + }
5224 +
2971 5225 $urls = array();
2972 5226
2973 5227 $permalink = get_permalink( $product_id );
2974 5228 if ( is_string( $permalink ) && '' !== $permalink ) {
@@ -3021,17 +5275,91 @@
3021 5275 * @param int $product_id The product that changed.
3022 5276 */
3023 5277 $urls = (array) apply_filters( 'xspeed_purge_product_urls', $urls, $product_id );
3024 5278
3025 - foreach ( array_unique( array_filter( $urls ) ) as $url ) {
3026 - self::purge_url( (string) $url, 'product' );
5279 + $targets = array();
5280 + foreach ( $urls as $url ) {
5281 + if ( is_scalar( $url ) && '' !== (string) $url ) {
5282 + $targets[] = (string) $url;
5283 + }
3027 5284 }
5285 + if ( array() === $targets ) {
5286 + return;
5287 + }
5288 +
5289 + // One batch, as a post save sends: one purge-log row and one event
5290 + // naming every page, each in the spelling WordPress links to. A
5291 + // purge_url() per page published every page in both spellings,
5292 + // which doubled the calls to Nginx Helper, and wrote no purge-log
5293 + // row when xSpeed's own page cache was off and no file went.
5294 + // purge_urls() leaves the object cache alone, which matters here:
5295 + // this runs on every stock change at checkout.
5296 + self::purge_urls( array_values( array_unique( $targets ) ), 'product', 'content' );
3028 5297 }
3029 5298
3030 5299 /**
5300 + * Posts whose save_post is running in this request, keyed by ID, with
5301 + * the post as saved. Set before any other save_post listener and cleared
5302 + * after the last one.
5303 + *
5304 + * @var array<int,\WP_Post|null>
5305 + */
5306 + private static $saves_under_way = array();
5307 +
5308 + /**
5309 + * A post save has started.
5310 + *
5311 + * @param int $post_id Post ID.
5312 + * @param \WP_Post|null $post Post as saved.
5313 + */
5314 + public static function on_post_save_start( $post_id, $post = null ): void {
5315 + self::$saves_under_way[ (int) $post_id ] = is_object( $post ) ? $post : null;
5316 + }
5317 +
5318 + /**
5319 + * A post save has finished.
5320 + *
5321 + * @param int $post_id Post ID.
5322 + */
5323 + public static function on_post_save_end( $post_id ): void {
5324 + unset( self::$saves_under_way[ (int) $post_id ] );
5325 + }
5326 +
5327 + /**
5328 + * Whether a save_post for this post is running now and will end in
5329 + * on_save_post() clearing the whole site: the same checks it makes.
5330 + *
5331 + * @param int $post_id Post ID.
5332 + */
5333 + private static function site_wide_save_under_way( int $post_id ): bool {
5334 + if ( ! array_key_exists( $post_id, self::$saves_under_way ) ) {
5335 + return false;
5336 + }
5337 + $post = self::$saves_under_way[ $post_id ];
5338 + if ( ! self::post_change_is_cacheable_content( $post_id, $post, 'save' ) ) {
5339 + return false;
5340 + }
5341 + $post_type = is_object( $post ) && isset( $post->post_type )
5342 + ? (string) $post->post_type
5343 + : (string) get_post_type( $post_id );
5344 + if ( in_array( $post_type, self::presentation_post_types(), true ) ) {
5345 + return true;
5346 + }
5347 + return ! self::narrow_purge_applies( $post_type );
5348 + }
5349 +
5350 + /** Test seam: forget the post saves in progress. */
5351 + public static function reset_saves_under_way(): void {
5352 + self::$saves_under_way = array();
5353 + }
5354 +
5355 + /**
3031 5356 * Adapter for the WooCommerce stock actions that pass a product OBJECT
3032 5357 * where the status actions pass an ID.
3033 5358 *
5359 + * No longer wired to a hook (on_product_stock_set() is); kept because
5360 + * it is public.
5361 + *
3034 5362 * @param object $product WC_Product (or variation).
3035 5363 */
3036 5364 public static function purge_product_object( $product ): void {
3037 5365 self::purge_product( $product );
@@ -3037,8 +5365,90 @@
3037 5365 self::purge_product( $product );
3038 5366 }
3039 5367
3040 5368 /**
5369 + * Stock writes wc_update_product_stock() has opened in this request,
5370 + * keyed by product ID: true once the save inside the write has purged.
5371 + *
5372 + * Entries are removed when the write's *_set_stock arrives, so this
5373 + * holds only writes still in progress.
5374 + *
5375 + * @var array<int,bool>
5376 + */
5377 + private static $stock_writes = array();
5378 +
5379 + /**
5380 + * A CRUD save of a product: purge its pages.
5381 + *
5382 + * Inside a wc_update_product_stock() write, this is the save that write
5383 + * makes, so note that the write's pages are already purged.
5384 + *
5385 + * @param int|object $product Product ID (what WooCommerce passes) or WC_Product.
5386 + */
5387 + public static function on_product_saved( $product ): void {
5388 + $product_id = self::product_id_of( $product );
5389 + if ( isset( self::$stock_writes[ $product_id ] ) ) {
5390 + self::$stock_writes[ $product_id ] = true;
5391 + }
5392 + self::purge_product( $product );
5393 + }
5394 +
5395 + /**
5396 + * wc_update_product_stock() is about to write a product's stock.
5397 + *
5398 + * @param object $product WC_Product (or variation) whose stock changes.
5399 + */
5400 + public static function on_product_stock_write( $product ): void {
5401 + $product_id = self::product_id_of( $product );
5402 + if ( $product_id > 0 ) {
5403 + self::$stock_writes[ $product_id ] = false;
5404 + }
5405 + }
5406 +
5407 + /**
5408 + * A product's stock changed: purge its pages, unless the save inside
5409 + * the same wc_update_product_stock() call already did.
5410 + *
5411 + * That save fires woocommerce_update_product for the same product a
5412 + * moment earlier, with the stock already written, so purging again
5413 + * here cleared the same pages twice in one call. Only that pairing is
5414 + * skipped: the flag is set by the save inside this write and cleared
5415 + * here, so the next write, a status change or a later save in the same
5416 + * request purges as usual, and so does a write made with `$updating`
5417 + * set, which skips the save. A *_set_stock that arrives without
5418 + * *_before_set_stock (WooCommerce's data store fires one mid-save when
5419 + * a CRUD save changes the quantity) is not part of a write and purges.
5420 + *
5421 + * @param object $product WC_Product (or variation).
5422 + */
5423 + public static function on_product_stock_set( $product ): void {
5424 + $product_id = self::product_id_of( $product );
5425 + $purged = ! empty( self::$stock_writes[ $product_id ] );
5426 + unset( self::$stock_writes[ $product_id ] );
5427 + if ( $purged ) {
5428 + return;
5429 + }
5430 + self::purge_product( $product );
5431 + }
5432 +
5433 + /** Test seam: forget the stock writes in progress. */
5434 + public static function reset_stock_writes(): void {
5435 + self::$stock_writes = array();
5436 + }
5437 +
5438 + /**
5439 + * The ID of a product passed as an ID or as a WC_Product.
5440 + *
5441 + * @param int|object $product Product ID or WC_Product.
5442 + */
5443 + private static function product_id_of( $product ): int {
5444 + if ( is_object( $product ) ) {
5445 + return method_exists( $product, 'get_id' ) ? (int) $product->get_id() : 0;
5446 + }
5447 + return is_numeric( $product ) ? (int) $product : 0;
5448 + }
5449 +
5450 + /**
3041 5451 * Re-entry guard for the purge-event contract.
3042 5452 *
3043 5453 * A listener on `xspeed_after_purge_url` legitimately purges its own
3044 5454 * layer, and a server-cache or CDN adapter that calls back into xSpeed
@@ -3086,8 +5496,12 @@
3086 5496 private static function dispatch_purge_event( string $hook, array $context ): void {
3087 5497 if ( ! function_exists( 'do_action' ) ) {
3088 5498 return;
3089 5499 }
5500 + // Every event carries `fallback`, so one handler can read both the
5501 + // per-URL and the full-purge shape. '' unless a narrow purge fell
5502 + // back to the whole site.
5503 + $context['fallback'] = isset( $context['fallback'] ) && is_string( $context['fallback'] ) ? $context['fallback'] : '';
3090 5504 $target = $hook . '|' . ( isset( $context['url'] ) ? (string) $context['url'] : '' )
3091 5505 . '|' . ( isset( $context['host'] ) ? (string) $context['host'] : '' );
3092 5506 if ( isset( self::$purge_events_in_flight[ $target ] ) ) {
3093 5507 return;
@@ -3114,9 +5528,20 @@
3114 5528 } catch ( \Throwable $e ) {
3115 5529 self::log_purge_listener_error( $hook, $e );
3116 5530 }
3117 5531
5532 + // The edge xCloud provides, through its purge plugin. Direct for the
5533 + // same reason, and in its own try so a failure here cannot cost the
5534 + // host page caches above or the public action below.
3118 5535 try {
5536 + if ( class_exists( __NAMESPACE__ . '\\Managed_Edge_Purge' ) ) {
5537 + Managed_Edge_Purge::forward( $context );
5538 + }
5539 + } catch ( \Throwable $e ) {
5540 + self::log_purge_listener_error( $hook, $e );
5541 + }
5542 +
5543 + try {
3119 5544 self::do_action_isolated( $hook, $context );
3120 5545 } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
3121 5546 // Swallow: see docblock. The purge succeeded regardless.
3122 5547 self::log_purge_listener_error( $hook, $e );
@@ -3241,12 +5666,16 @@
3241 5666 error_log( '[xspeed] a ' . $hook . ' listener threw: ' . $e->getMessage() );
3242 5667 }
3243 5668 }
3244 5669
3245 - /** Test seam: clear the in-flight set left behind by an aborted dispatch. */
5670 + /**
5671 + * Test seam: clear the in-flight set left behind by an aborted dispatch,
5672 + * and the record of which saves purged in this request.
5673 + */
3246 5674 public static function reset_purge_events(): void {
3247 5675 self::$purge_events_in_flight = array();
3248 5676 self::$purge_event_sequence = 0;
5677 + self::$saves_purged = array();
3249 5678 }
3250 5679
3251 5680 /**
3252 5681 * Hand the purge to the caches we ship integrations for.
@@ -3307,8 +5736,12 @@
3307 5736 // this public contract is for. (QA #348)
3308 5737 if ( '' === trim( $url ) ) {
3309 5738 return 0;
3310 5739 }
5740 + // parse_url() can turn raw UTF-8 into underscores (it depends on the
5741 + // host's C library and locale), so a pasted `/關於我們/` is encoded
5742 + // first. normalize_path() below gives the same result either way.
5743 + $url = self::encode_non_ascii( $url );
3311 5744 $parts = function_exists( 'wp_parse_url' ) ? wp_parse_url( $url ) : parse_url( $url ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- fallback for early-boot contexts only.
3312 5745 if ( ! is_array( $parts ) ) {
3313 5746 return 0;
3314 5747 }
@@ -3375,15 +5808,20 @@
3375 5808 $path = '/' . ltrim( $path, '/' );
3376 5809 if ( false !== strpos( $path, '..' ) ) {
3377 5810 return 0;
3378 5811 }
5812 + // Same spelling cache_key() hashes, so a permalink, the same URL with
5813 + // `%E7` capitals, and a pasted `/關於我們/` all find the entry. The
5814 + // purge event below keeps the caller's spelling and adds the others
5815 + // (see escape_spellings()).
5816 + $key_path = self::normalize_path( $path );
3379 5817
3380 5818 // The cache key preserves REQUEST_URI's trailing-slash form, so
3381 5819 // purge both. Root stays a single '/'.
3382 - $forms = array( $path );
3383 - if ( '/' !== $path ) {
3384 - $forms[] = rtrim( $path, '/' );
3385 - $forms[] = rtrim( $path, '/' ) . '/';
5820 + $forms = array( $key_path );
5821 + if ( '/' !== $key_path ) {
5822 + $forms[] = rtrim( $key_path, '/' );
5823 + $forms[] = rtrim( $key_path, '/' ) . '/';
3386 5824 }
3387 5825 $forms = array_unique( $forms );
3388 5826
3389 5827 /*
@@ -3419,12 +5857,15 @@
3419 5857 }
3420 5858 }
3421 5859
3422 5860 // Static tree (served directly by the nginx/.htaccess rewrite).
3423 - if ( defined( 'XSPEED_CACHE_STATIC_DIR' ) ) {
5861 + // The static tree stores decoded names (see static_path()). A path
5862 + // static_path() refuses was never written there.
5863 + $rel = self::static_path( $key_path );
5864 + if ( defined( 'XSPEED_CACHE_STATIC_DIR' ) && null !== $rel ) {
3424 5865 // Same transform the write used — `localhost:8080` files under
3425 5866 // `localhost8080`, so the bare host found nothing here either.
3426 - $dir = rtrim( XSPEED_CACHE_STATIC_DIR, '/' ) . '/' . self::static_host_dir( $host ) . ( '/' === $path ? '' : rtrim( $path, '/' ) );
5867 + $dir = rtrim( XSPEED_CACHE_STATIC_DIR, '/' ) . '/' . self::static_host_dir( $host ) . $rel;
3427 5868 $file = $dir . '/index.html';
3428 5869 if ( is_file( $file ) ) {
3429 5870 wp_delete_file( $file );
3430 5871 ++$count;
@@ -3435,9 +5876,32 @@
3435 5876 }
3436 5877 }
3437 5878 }
3438 5879
3439 - if ( $count > 0 ) {
5880 + /*
5881 + * The per-URL purge event is published by `dispatch_purge_event()`
5882 + * below, NOT here.
5883 + *
5884 + * This branch used to publish it itself, with a raw `do_action` and a
5885 + * `{scope,url,urls,cause,count}` payload. #348 then landed the purge
5886 + * contract on dev: a canonical URL, `removed` rather than `count`,
5887 + * `intent`, per-listener isolation, and an in-flight guard so a
5888 + * listener that purges its own layer cannot re-enter the event.
5889 + *
5890 + * Keeping both meant every per-URL purge fired TWICE, in two payload
5891 + * shapes, and the raw call bypassed the guard — so a listener that
5892 + * called back into `purge_url()` recursed until the process ran out
5893 + * of memory. `CachePurgeEventContractTest::
5894 + * test_a_listener_re_purging_the_same_url_does_not_recurse` is what
5895 + * caught it, and it arrived from dev with the contract it defends.
5896 + *
5897 + * The contract wins: it is a superset of what this published, and it
5898 + * is what the LiteSpeed and nginx adapters are written against.
5899 + */
5900 +
5901 + // purge_url_reported() writes this row itself once the event has
5902 + // gone out, so it can name the caches the purge was sent to.
5903 + if ( $count > 0 && null === self::$url_batch && null === self::$reported_url_purge ) {
3440 5904 Cache_Inventory::invalidate();
3441 5905 Activity_Log::record(
3442 5906 'cache_purge_url',
3443 5907 sprintf(
@@ -3484,32 +5948,296 @@
3484 5948 * @type string $cause Short label for who asked. See purge_all().
3485 5949 * @type int $removed Number of cache files removed.
3486 5950 * @type string $scope Actionable adapter scope: `urls`.
3487 5951 * @type string $intent Why responses changed: `content`.
3488 - * @type string[] $urls Exact response URLs to invalidate.
5952 + * @type string[] $urls Exact response URLs to invalidate: the
5953 + * canonical URL first, then the same URL
5954 + * with the other trailing-slash spelling
5955 + * (none for the root). A path with
5956 + * percent-escapes adds the same pair with
5957 + * the escapes in lower case and in upper
5958 + * case, where those differ from it.
5959 + * @type string $fallback Always '' on this event. Present so both
5960 + * purge events share one shape; see
5961 + * `xspeed_after_purge`.
3489 5962 * }
3490 5963 */
3491 - $canonical_url = self::canonical_purge_url(
3492 - $host,
3493 - $path,
3494 - isset( $parts['query'] ) ? (string) $parts['query'] : '',
3495 - isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : ''
5964 + $query = isset( $parts['query'] ) ? (string) $parts['query'] : '';
5965 + $url_scheme = isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : '';
5966 + $canonical_url = self::canonical_purge_url( $host, $path, $query, $url_scheme );
5967 + // Every spelling the local sweep above cleared, because a cache in
5968 + // front keys each one on its own. Visitors reach a page as `/about/`
5969 + // while a caller often passes `/about` (or the reverse), and a
5970 + // non-ASCII page as `/%E9%97%9C…/` while get_permalink() passes
5971 + // `/%e9%97%9c…/`. Publishing one spelling left the edge serving the
5972 + // other, usually the one visitors actually use.
5973 + $event_urls = array();
5974 + foreach ( self::escape_spellings( $path ) as $spelling ) {
5975 + $event_urls[] = self::canonical_purge_url( $host, $spelling, $query, $url_scheme );
5976 + if ( '/' === $spelling ) {
5977 + continue;
5978 + }
5979 + $other = '/' === substr( $spelling, -1 ) ? rtrim( $spelling, '/' ) : $spelling . '/';
5980 + if ( '' !== $other ) {
5981 + $event_urls[] = self::canonical_purge_url( $host, $other, $query, $url_scheme );
5982 + }
5983 + }
5984 + // Inside purge_urls(): the batch publishes one event for every URL,
5985 + // in the trailing-slash form it was given. The local sweep above still
5986 + // took both forms. Escape spellings are still added, because the other
5987 + // escape case does not redirect: a cache in front keeps the browser's
5988 + // `%E9…` copy apart from the permalink's `%e9…` one.
5989 + if ( null !== self::$url_batch ) {
5990 + if ( '' === self::$url_batch['url'] ) {
5991 + self::$url_batch['url'] = $canonical_url;
5992 + self::$url_batch['host'] = $host;
5993 + self::$url_batch['path'] = $path;
5994 + }
5995 + self::$url_batch['removed'] += $count;
5996 + foreach ( self::escape_spellings( $path ) as $spelling ) {
5997 + self::$url_batch['urls'][ self::canonical_purge_url( $host, $spelling, $query, $url_scheme ) ] = true;
5998 + }
5999 + return $count;
6000 + }
6001 +
6002 + self::dispatch_purge_event(
6003 + 'xspeed_after_purge_url',
6004 + array(
6005 + 'url' => $canonical_url,
6006 + 'host' => $host,
6007 + 'path' => $path,
6008 + 'cause' => $cause,
6009 + 'removed' => $count,
6010 + 'scope' => 'urls',
6011 + 'intent' => 'content',
6012 + 'urls' => array_values( array_unique( $event_urls ) ),
6013 + 'fallback' => '',
6014 + )
3496 6015 );
6016 +
6017 + return $count;
6018 + }
6019 +
6020 + /**
6021 + * The batch purge_urls() is collecting, or null outside one.
6022 + *
6023 + * @var array{url:string,host:string,path:string,removed:int,urls:array<string,bool>}|null
6024 + */
6025 + private static $url_batch = null;
6026 +
6027 + /**
6028 + * The caches in front of PHP that took a purge while a report is open,
6029 + * keyed by label, or null when no report is open.
6030 + *
6031 + * @var array<string,bool>|null
6032 + */
6033 + private static $forward_report = null;
6034 +
6035 + /**
6036 + * Note that a purge was handed to a cache in front of PHP: a server
6037 + * cache, a host cache, a CDN or an edge.
6038 + *
6039 + * The built-in adapters call this when they accept a purge, whether
6040 + * they send it now or queue it for the end of the request. A third-party
6041 + * adapter listening on `xspeed_after_purge_url` may call it too, so an
6042 + * operator's purge names it. Does nothing unless a caller opened a
6043 + * report with purge_url_reported().
6044 + *
6045 + * @param string $layer Name to show, such as "Nginx Helper".
6046 + */
6047 + public static function note_purge_forwarded( string $layer ): void {
6048 + if ( null === self::$forward_report || '' === trim( $layer ) ) {
6049 + return;
6050 + }
6051 + self::$forward_report[ $layer ] = true;
6052 + }
6053 +
6054 + /**
6055 + * Run a purge and collect the caches in front of PHP it was sent to.
6056 + *
6057 + * @param callable $purge The purge to run.
6058 + * @return array{result:mixed,forwarded:string[]}
6059 + */
6060 + public static function report_forwarding( callable $purge ): array {
6061 + $outer = self::$forward_report;
6062 + self::$forward_report = array();
6063 + try {
6064 + $result = $purge();
6065 + } finally {
6066 + $forwarded = array_keys( (array) self::$forward_report );
6067 + self::$forward_report = null === $outer ? null : $outer + (array) self::$forward_report;
6068 + }
6069 + return array(
6070 + 'result' => $result,
6071 + 'forwarded' => $forwarded,
6072 + );
6073 + }
6074 +
6075 + /**
6076 + * Set while purge_url_reported() runs, so purge_url() leaves the
6077 + * purge-log row to it. Null otherwise.
6078 + *
6079 + * @var bool|null
6080 + */
6081 + private static $reported_url_purge = null;
6082 +
6083 + /**
6084 + * Purge one URL for an operator, and say where the purge went.
6085 + *
6086 + * purge_url() writes a purge-log row only when it removed a local file,
6087 + * because Pro's beacons call it on visitor requests. On a site whose
6088 + * pages are held only by a server cache (xSpeed's page cache off, nginx
6089 + * in front) an operator's purge then removed nothing locally, was sent
6090 + * to the server cache, and left no row and an "already cold" answer.
6091 + * This adds the row in that case. The CLI, the MCP tool (which runs the
6092 + * CLI) and the admin "Purge this URL" link use it.
6093 + *
6094 + * @param string $url Absolute URL or site-relative path.
6095 + * @param string $cause Who asked, for the purge log.
6096 + * @return array{removed:int,forwarded:string[]} Files removed here, and
6097 + * the caches the purge was sent to.
6098 + */
6099 + public static function purge_url_reported( string $url, string $cause ): array {
6100 + $outer = self::$reported_url_purge;
6101 + self::$reported_url_purge = true;
6102 + try {
6103 + $run = self::report_forwarding(
6104 + static function () use ( $url, $cause ): int {
6105 + return self::purge_url( $url, $cause );
6106 + }
6107 + );
6108 + } finally {
6109 + self::$reported_url_purge = $outer;
6110 + }
6111 + $removed = (int) $run['result'];
6112 + $forwarded = $run['forwarded'];
6113 +
6114 + if ( $removed > 0 ) {
6115 + Cache_Inventory::invalidate();
6116 + $message = array() === $forwarded
6117 + ? sprintf(
6118 + /* translators: 1: cause of the purge, 2: URL or path, 3: number of files removed. */
6119 + __( 'Purged one URL (%1$s) — %2$s, %3$d file(s) removed', 'xspeed' ),
6120 + $cause,
6121 + $url,
6122 + $removed
6123 + )
6124 + : sprintf(
6125 + /* translators: 1: cause of the purge, 2: URL or path, 3: number of files removed, 4: caches the purge was sent to. */
6126 + __( 'Purged one URL (%1$s) — %2$s, %3$d file(s) removed, sent to %4$s', 'xspeed' ),
6127 + $cause,
6128 + $url,
6129 + $removed,
6130 + implode( ', ', $forwarded )
6131 + );
6132 + Activity_Log::record( 'cache_purge_url', $message, Activity_Log::INFO );
6133 + } elseif ( array() !== $forwarded ) {
6134 + Activity_Log::record(
6135 + 'cache_purge_url',
6136 + sprintf(
6137 + /* translators: 1: cause of the purge, 2: URL or path, 3: caches the purge was sent to. */
6138 + __( 'Purged one URL (%1$s): %2$s, 0 file(s) removed here, sent to %3$s', 'xspeed' ),
6139 + $cause,
6140 + $url,
6141 + implode( ', ', $forwarded )
6142 + ),
6143 + Activity_Log::INFO
6144 + );
6145 + }
6146 +
6147 + return array(
6148 + 'removed' => $removed,
6149 + 'forwarded' => $forwarded,
6150 + );
6151 + }
6152 +
6153 + /**
6154 + * Purge several URLs as one purge: each URL's local copy goes as in
6155 + * purge_url(), then ONE `xspeed_after_purge_url` event carries every URL.
6156 + *
6157 + * One event rather than one per URL, so the purge log gets one line, an
6158 + * edge gets one batch, and a listener that counts purges counts one. The
6159 + * event is the same contract purge_url() publishes: `scope` is `urls`,
6160 + * `url` is the first URL, `urls` is all of them.
6161 + *
6162 + * Unlike purge_url(), `urls` holds each URL in the spelling it was given
6163 + * and not the other trailing-slash spelling too. Callers pass WordPress's
6164 + * own links (get_permalink(), get_term_link() and the like), which are
6165 + * the spelling visitors reach; the other spelling redirects to it, so a
6166 + * cache in front holds nothing stale under it. Both spellings doubled the
6167 + * list: a typical save named 34 pages as 67 URLs, past xCloud's 50-URL
6168 + * batch and the Hub's 30 per call. xSpeed's own sweep still removes both
6169 + * spellings of each. The escape spellings (escape_spellings()) are still
6170 + * listed, because `%E9…` does not redirect to `%e9…`; they add URLs only
6171 + * for a path with percent-escapes.
6172 + *
6173 + * The object cache is left alone, as purge_url() leaves it. WordPress
6174 + * already drops a post's own entries when the post, its terms or its
6175 + * comments change (clean_post_cache() and the like), so a flush here
6176 + * cleared nothing stale. What it did do: an approved visitor comment
6177 + * emptied Redis or Memcached for the whole site, as often as a visitor
6178 + * cared to comment. With a persistent object cache, transients live
6179 + * there too, so a flush could also drop work a purge listener had just
6180 + * queued in one. purge_all() still flushes.
6181 + *
6182 + * @param string[] $urls Absolute URLs or site-relative paths.
6183 + * @param string $cause Who asked, for the purge log.
6184 + * @param string $intent Why responses changed: `content` by default.
6185 + * @return int Cache files removed.
6186 + */
6187 + public static function purge_urls( array $urls, string $cause = 'manual', string $intent = 'content' ): int {
6188 + $outer = self::$url_batch;
6189 + self::$url_batch = array(
6190 + 'url' => '',
6191 + 'host' => '',
6192 + 'path' => '',
6193 + 'removed' => 0,
6194 + 'urls' => array(),
6195 + );
6196 + try {
6197 + foreach ( array_unique( array_filter( $urls, 'is_string' ) ) as $url ) {
6198 + self::purge_url( $url, $cause );
6199 + }
6200 + } finally {
6201 + $batch = self::$url_batch;
6202 + self::$url_batch = $outer;
6203 + }
6204 + if ( '' === $batch['url'] ) {
6205 + return 0;
6206 + }
6207 +
6208 + Cache_Inventory::invalidate();
6209 + self::update_stats( array( 'last_purge' => time() ) );
6210 + // Same type as one URL's purge, so the purge log and the dashboard's
6211 + // drill-down list it with the others.
6212 + Activity_Log::record(
6213 + 'cache_purge_url',
6214 + sprintf(
6215 + /* translators: 1: number of pages, 2: cause of the purge, 3: number of files removed. */
6216 + _n( 'Purged %1$d page (%2$s), %3$d file(s) removed', 'Purged %1$d pages (%2$s), %3$d file(s) removed', count( $urls ), 'xspeed' ),
6217 + count( $urls ),
6218 + $cause,
6219 + $batch['removed']
6220 + ),
6221 + Activity_Log::INFO
6222 + );
6223 +
3497 6224 self::dispatch_purge_event(
3498 6225 'xspeed_after_purge_url',
3499 6226 array(
3500 - 'url' => $canonical_url,
3501 - 'host' => $host,
3502 - 'path' => $path,
3503 - 'cause' => $cause,
3504 - 'removed' => $count,
3505 - 'scope' => 'urls',
3506 - 'intent' => 'content',
3507 - 'urls' => array( $canonical_url ),
6227 + 'url' => $batch['url'],
6228 + 'host' => $batch['host'],
6229 + 'path' => $batch['path'],
6230 + 'cause' => $cause,
6231 + 'removed' => $batch['removed'],
6232 + 'scope' => 'urls',
6233 + 'intent' => $intent,
6234 + 'urls' => array_keys( $batch['urls'] ),
6235 + 'fallback' => '',
3508 6236 )
3509 6237 );
3510 6238
3511 - return $count;
6239 + return $batch['removed'];
3512 6240 }
3513 6241
3514 6242 /** Host this site's purge is scoped to, for the purge-event context. */
3515 6243 private static function current_purge_host(): string {
@@ -3700,15 +6428,22 @@
3700 6428 // triggers (publish, settings change) invalidate it too.
3701 6429 $rest = Rest_Cache::purge();
3702 6430 $count += $rest;
3703 6431
3704 - // Minified + combined CSS/JS (cache/xspeed/min/ and min/combined/).
3705 - // purge_all is a full filesystem sweep and must clear these too, even
3706 - // when the Minify module is currently disabled — orphaned min/ files
3707 - // from a feature the user later turned off must still be removed, and
3708 - // a stale combined-<hash>.css that the regenerated page no longer
3709 - // references otherwise 404s and breaks the frontend. (FBS-83114/83116)
3710 - $assets = class_exists( '\\XSpeed\\Minifier' ) ? Minifier::purge_minified() : 0;
6432 + // Minified + combined CSS/JS (cache/xspeed/min/ and min/combined/)
6433 + // go only on a NETWORK-wide sweep.
6434 + //
6435 + // They are named by content, so a site purge gains nothing by deleting
6436 + // them: the next render links the same names for unchanged sources
6437 + // and new names for changed ones. Deleting them did cost something.
6438 + // A page rendered just before the purge and stored just after linked
6439 + // files that were gone (unstyled until the TTL), and min/ is shared
6440 + // by every blog, so one subsite's purge broke every other subsite's
6441 + // cached pages. Orphans are Cache_GC's job; a network purge, an
6442 + // update, the explicit assets purge and deactivation still clear the
6443 + // tree, and the purge stamp stops a render in flight from being
6444 + // stored against deleted files.
6445 + $assets = ( $network_wide && class_exists( '\\XSpeed\\Minifier' ) ) ? Minifier::purge_minified() : 0;
3711 6446
3712 6447 return array(
3713 6448 'pages' => $count - $rest,
3714 6449 'rest' => $rest,
@@ -3786,8 +6521,10 @@
3786 6521 if ( 'site' === $adapter_scope || 'network' === $adapter_scope || 'none' === $adapter_scope ) {
3787 6522 $urls = array();
3788 6523 }
3789 6524
6525 + $fallback = isset( $invalidation['fallback'] ) && is_string( $invalidation['fallback'] ) ? $invalidation['fallback'] : '';
6526 +
3790 6527 $removed = self::purge_local( $host );
3791 6528 $count = $removed['pages'] + $removed['rest'];
3792 6529
3793 6530 self::flush_object_cache( $network_wide );
@@ -3838,24 +6575,38 @@
3838 6575 * @type int $removed Number of cache files removed.
3839 6576 * @type string $scope Adapter action: urls/site/network/none.
3840 6577 * @type string $intent content/presentation/complete or a caller-defined intent.
3841 6578 * @type string[] $urls Exact targets when scope is urls.
6579 + * @type string $fallback Why a change that would have cleared
6580 + * only its own pages cleared the whole site
6581 + * instead: `theme_list`, `limit`, `pending`
6582 + * or `filter`. Empty for every other purge.
6583 + * See the purge-event contract in
6584 + * docs/guides/hooks-and-filters.md.
3842 6585 * }
3843 6586 */
3844 6587 self::dispatch_purge_event(
3845 6588 'xspeed_after_purge',
3846 6589 array(
3847 - 'url' => null,
3848 - 'host' => null === $host ? self::current_purge_host() : (string) $host,
3849 - 'path' => null,
3850 - 'cause' => $cause,
3851 - 'removed' => $count,
3852 - 'scope' => $adapter_scope,
3853 - 'intent' => $intent,
3854 - 'urls' => $urls,
6590 + 'url' => null,
6591 + 'host' => null === $host ? self::current_purge_host() : (string) $host,
6592 + 'path' => null,
6593 + 'cause' => $cause,
6594 + 'removed' => $count,
6595 + 'scope' => $adapter_scope,
6596 + 'intent' => $intent,
6597 + 'urls' => $urls,
6598 + 'fallback' => $fallback,
3855 6599 )
3856 6600 );
3857 6601
6602 + /*
6603 + * The generic event is published by the `dispatch_purge_event()` call
6604 + * directly above, NOT here. Same supersession as in `purge_url()`:
6605 + * this branch's raw `do_action` with a `count` payload predates #348's
6606 + * contract, and keeping both made a full purge publish twice.
6607 + */
6608 +
3858 6609 // The list behind the "Cached pages" card is memoized for a minute;
3859 6610 // a purge has to drop it or the drill-down shows pages that no
3860 6611 // longer exist.
3861 6612 Cache_Inventory::invalidate();
@@ -3954,8 +6705,13 @@
3954 6705 * more than it needs to. A cold cache is the cheap direction, and the
3955 6706 * alternative — scoping the signal per upgrader — is not knowable from
3956 6707 * `upgrader_clear_destination`.
3957 6708 *
6709 + * The observed-destination signal is dropped here too. The two are read
6710 + * together and have to expire together: leaving "the directory was not
6711 + * there" behind would let a first install answer for whatever ran next
6712 + * in the same request, and that one's mistake is a cache left stale.
6713 + *
3958 6714 * @return void
3959 6715 */
3960 6716 public static function forget_cleared_destination(): void {
3961 6717 if ( self::$upgrade_dispatch_depth > 0 ) {
@@ -3963,12 +6719,84 @@
3963 6719 }
3964 6720
3965 6721 if ( 0 === self::$upgrade_dispatch_depth ) {
3966 6722 self::$upgrade_cleared_destination = false;
6723 + self::$upgrade_destination_existed = null;
6724 + self::$upgrade_destination_folder = '';
3967 6725 }
3968 6726 }
3969 6727
3970 6728 /**
6729 + * Whether the destination this run installs into was already there.
6730 + *
6731 + * Null while unknown — an upgrader whose target we cannot work out keeps
6732 + * the old behaviour rather than being guessed at.
6733 + *
6734 + * @var bool|null
6735 + */
6736 + private static $upgrade_destination_existed = null;
6737 +
6738 + /**
6739 + * The folder name that answer was measured against, so the destination
6740 + * WordPress reports on `upgrader_clear_destination` can be checked
6741 + * against it. Empty when nothing was measured.
6742 + *
6743 + * @var string
6744 + */
6745 + private static $upgrade_destination_folder = '';
6746 +
6747 + /**
6748 + * Note whether the package's destination exists, before it is cleared.
6749 + *
6750 + * `upgrader_source_selection` is the last hook that fires while the old
6751 + * copy is still on disk, and the extracted source folder name is the
6752 + * directory the package will install into. A pass-through listener: the
6753 + * source is returned untouched.
6754 + *
6755 + * @param mixed $source Extracted package directory.
6756 + * @param mixed $remote_src Unused; the package's remote source.
6757 + * @param mixed $upgrader The upgrader instance, if one was supplied.
6758 + * @param mixed $hook_extra Context supplied by the upgrader.
6759 + * @return mixed The source, unchanged.
6760 + */
6761 + public static function note_destination_state( $source, $remote_src = '', $upgrader = null, $hook_extra = array() ) {
6762 + self::$upgrade_destination_existed = null;
6763 + self::$upgrade_destination_folder = '';
6764 +
6765 + $root = self::upgrade_destination_root( $upgrader, is_array( $hook_extra ) ? $hook_extra : array() );
6766 + if ( null !== $root && is_string( $source ) && '' !== $source ) {
6767 + $folder = basename( rtrim( $source, '/\\' ) );
6768 + if ( '' !== $folder ) {
6769 + self::$upgrade_destination_existed = is_dir( rtrim( $root, '/\\' ) . '/' . $folder );
6770 + self::$upgrade_destination_folder = $folder;
6771 + }
6772 + }
6773 +
6774 + return $source;
6775 + }
6776 +
6777 + /**
6778 + * Where a package of this kind installs to, or null if we cannot tell.
6779 + *
6780 + * @param mixed $upgrader The upgrader instance, if one was supplied.
6781 + * @param array $hook_extra Context supplied by the upgrader.
6782 + * @return string|null
6783 + */
6784 + private static function upgrade_destination_root( $upgrader, array $hook_extra ): ?string {
6785 + $type = isset( $hook_extra['type'] ) ? (string) $hook_extra['type'] : '';
6786 +
6787 + if ( 'plugin' === $type || $upgrader instanceof \Plugin_Upgrader ) {
6788 + return defined( 'WP_PLUGIN_DIR' ) ? WP_PLUGIN_DIR : null;
6789 + }
6790 +
6791 + if ( 'theme' === $type || $upgrader instanceof \Theme_Upgrader ) {
6792 + return function_exists( 'get_theme_root' ) ? (string) get_theme_root() : null;
6793 + }
6794 +
6795 + return null;
6796 + }
6797 +
6798 + /**
3971 6799 * Record that the upgrader cleared an existing destination.
3972 6800 *
3973 6801 * A pass-through listener on `upgrader_clear_destination`: WordPress only
3974 6802 * fires it when `clear_destination` was set AND something was there to
@@ -3977,13 +6805,28 @@
3977 6805 *
3978 6806 * @param true|\WP_Error $removed Whether the destination was cleared.
3979 6807 * @return true|\WP_Error
3980 6808 */
3981 - public static function note_cleared_destination( $removed ) {
6809 + public static function note_cleared_destination( $removed, $local_destination = '', $remote_destination = '', $hook_extra = array() ) {
3982 6810 if ( ! is_wp_error( $removed ) ) {
3983 6811 self::$upgrade_cleared_destination = true;
3984 6812 }
3985 6813
6814 + // $remote_destination is the directory WordPress actually cleared,
6815 + // derived from the source AFTER every `upgrader_source_selection`
6816 + // listener ran. If its folder is not the one note_destination_state()
6817 + // measured, a listener that ran after ours renamed the package, and
6818 + // the "was it there?" answer is about a directory that was never going
6819 + // to be written. Unknown is the answer that purges, so that is what it
6820 + // becomes. Only the last segment is compared: over FTP the remote
6821 + // path sits under the server's own root, not WP_PLUGIN_DIR. (#407 QA)
6822 + if ( is_string( $remote_destination ) && '' !== $remote_destination ) {
6823 + $cleared_folder = basename( rtrim( $remote_destination, '/\\' ) );
6824 + if ( '' !== $cleared_folder && $cleared_folder !== self::$upgrade_destination_folder ) {
6825 + self::$upgrade_destination_existed = null;
6826 + }
6827 + }
6828 +
3986 6829 return $removed;
3987 6830 }
3988 6831
3989 6832 /**
@@ -4097,8 +6940,18 @@
4097 6940 if ( 'install' === $action && ! $cleared ) {
4098 6941 return false;
4099 6942 }
4100 6943
6944 + // A cleared destination is only evidence of a replacement if there was
6945 + // something in it. Core returns success from clear_destination() for a
6946 + // destination that never existed, so a first-time install arrived here
6947 + // looking exactly like an upload-and-replace and bought a cold cache
6948 + // for a plugin that is not even active yet. Only acted on when we
6949 + // positively know the directory was absent.
6950 + if ( 'install' === $action && false === self::$upgrade_destination_existed ) {
6951 + return false;
6952 + }
6953 +
4101 6954 // 'translation' is the one update type that cannot change rendered
4102 6955 // markup. Anything else — including an empty type from a custom
4103 6956 // updater — is treated as cache-invalidating, because guessing wrong
4104 6957 // in that direction only costs a cold cache.
@@ -4122,10 +6975,10 @@
4122 6975 *
4123 6976 * @return void
4124 6977 */
4125 6978 private static function purge_for_upgrade(): void {
6979 + // Network-wide, so purge_local() also clears min/.
4126 6980 self::purge_all( 'upgrade', '*' );
4127 - Minifier::purge_minified();
4128 6981 }
4129 6982
4130 6983 /**
4131 6984 * Purge after an unattended background update run.
@@ -4459,35 +7312,9 @@
4459 7312 self::announce_purge( $cause, $count );
4460 7313 return $count;
4461 7314
4462 7315 case 'assets':
4463 - if ( class_exists( '\\XSpeed\\Minifier' ) ) {
4464 - Minifier::purge_minified();
4465 - }
4466 - // Deleting min/ without clearing the pages that link it left
4467 - // every cached page pointing at files that no longer exist.
4468 - // WordPress answers the missing asset by 301-ing to its
4469 - // pretty-permalink form and serving the 404 TEMPLATE as
4470 - // `HTTP 200 text/html`, which the browser accepts as a
4471 - // stylesheet and parses to zero rules — no console error, no
4472 - // network failure, no 4xx anywhere in devtools. The pages
4473 - // stayed broken for the rest of the TTL (7 days on
4474 - // Aggressive, up to 30), and the admin who clicked could not
4475 - // see it: they are logged in, so their own requests bypass
4476 - // the page cache and re-render, regenerating the assets as a
4477 - // side effect. Only anonymous visitors were served the stale
4478 - // HTML. (#244)
4479 - //
4480 - // The assets are the pages' dependency, so invalidating them
4481 - // invalidates the pages. Same invariant Cache_GC enforces
4482 - // with is_referenced(): never leave a cached page pointing at
4483 - // an asset that is gone.
4484 - $count = self::purge_pages();
4485 - self::update_stats( array( 'last_purge' => time() ) );
4486 - Cache_Inventory::invalidate();
4487 - self::record_partial_purge( 'assets', $cause, $count );
4488 - self::announce_purge( $cause, $count );
4489 - return $count;
7316 + return self::purge_assets( $cause );
4490 7317
4491 7318 case 'object':
4492 7319 if ( function_exists( 'wp_cache_flush' ) ) {
4493 7320 wp_cache_flush();
@@ -4506,8 +7333,121 @@
4506 7333 }
4507 7334 }
4508 7335
4509 7336 /**
7337 + * Purge this site's pages after a Customizer publish.
7338 + *
7339 + * A presentation change: every page's markup or inline CSS may differ, so
7340 + * the whole site bucket goes, as for a template or global-styles edit.
7341 + * min/ stays, because its files are named by content; this blog's
7342 + * manifests are dropped so the next render re-reads every source rather
7343 + * than trusting a signature. Bound with no arguments, because the hook
7344 + * passes the WP_Customize_Manager, which purge_all() would take as its
7345 + * cause.
7346 + */
7347 + public static function on_customize_save(): void {
7348 + self::purge_all(
7349 + 'customizer',
7350 + null,
7351 + array(
7352 + 'scope' => 'site',
7353 + 'intent' => 'presentation',
7354 + 'urls' => array(),
7355 + )
7356 + );
7357 + if ( class_exists( '\\XSpeed\\Minifier' ) ) {
7358 + Minifier::purge_manifests( Asset_Manifest::blog_id() );
7359 + }
7360 + }
7361 +
7362 + /**
7363 + * Purge minified and combined CSS/JS, and the pages that link them.
7364 + *
7365 + * Scope follows who shares what:
7366 + *
7367 + * - Single site: min/ is deleted outright.
7368 + * - One site of a network: min/ is shared by every blog, so only this
7369 + * blog's manifests go. Its next render re-reads each source and
7370 + * rebuilds only what changed; no other blog loses a file it links.
7371 + * Outputs nothing links any more are left to Cache_GC.
7372 + * - $network: min/ and every blog's pages, network-wide.
7373 + *
7374 + * Deleting min/ without clearing the pages that link it left every
7375 + * cached page pointing at files that no longer exist. WordPress answers
7376 + * the missing asset by 301-ing to its pretty-permalink form and serving
7377 + * the 404 TEMPLATE as `HTTP 200 text/html`, which the browser accepts as
7378 + * a stylesheet and parses to zero rules — no console error, no network
7379 + * failure, no 4xx anywhere in devtools. The pages stayed broken for the
7380 + * rest of the TTL, and the admin who clicked could not see it: they are
7381 + * logged in, so their own requests bypass the page cache. (#244) So the
7382 + * pages go too, and when files were actually deleted the edge is told
7383 + * through `xspeed_after_purge_all`, or it keeps serving HTML that links
7384 + * them.
7385 + *
7386 + * The public `xspeed_after_purge` event carries intent `assets`: rendered
7387 + * markup did not change, only asset files did. A listener that keeps
7388 + * measurements of rendered pages (selectors, layout) can keep them.
7389 + *
7390 + * @param string $cause Who asked.
7391 + * @param bool $network Purge every blog's pages and all of min/.
7392 + * @return int Page entries removed.
7393 + */
7394 + public static function purge_assets( string $cause, bool $network = false ): int {
7395 + $deleted = 0;
7396 + $rest = 0;
7397 + if ( $network ) {
7398 + // purge_local('*') clears min/ itself on a network-wide sweep.
7399 + $removed = self::purge_local( '*' );
7400 + $count = (int) $removed['pages'];
7401 + $rest = (int) $removed['rest'];
7402 + $deleted = (int) $removed['assets'];
7403 + } else {
7404 + if ( class_exists( '\\XSpeed\\Minifier' ) && is_multisite() ) {
7405 + Minifier::purge_manifests( Asset_Manifest::blog_id() );
7406 + } elseif ( class_exists( '\\XSpeed\\Minifier' ) ) {
7407 + $deleted = Minifier::purge_minified();
7408 + }
7409 + $count = self::purge_pages();
7410 + }
7411 +
7412 + self::update_stats( array( 'last_purge' => time() ) );
7413 + Cache_Inventory::invalidate();
7414 + self::record_partial_purge( 'assets', $cause, $count + $rest );
7415 +
7416 + if ( $deleted > 0 || $network ) {
7417 + try {
7418 + self::do_action_isolated( 'xspeed_after_purge_all', $cause );
7419 + } catch ( \Throwable $e ) {
7420 + self::log_purge_listener_error( 'xspeed_after_purge_all', $e );
7421 + }
7422 + }
7423 +
7424 + if ( ! $network ) {
7425 + self::announce_purge( $cause, $count, 'site', 'assets' );
7426 + } elseif ( function_exists( 'do_action' ) ) {
7427 + try {
7428 + self::dispatch_purge_event(
7429 + 'xspeed_after_purge',
7430 + array(
7431 + 'url' => null,
7432 + 'host' => '*',
7433 + 'path' => null,
7434 + 'cause' => $cause,
7435 + 'removed' => $count + $rest,
7436 + 'scope' => 'network',
7437 + 'intent' => 'assets',
7438 + 'urls' => array(),
7439 + )
7440 + );
7441 + } catch ( \Throwable $e ) {
7442 + self::log_purge_listener_error( 'xspeed_after_purge', $e );
7443 + }
7444 + }
7445 +
7446 + return $count + $rest;
7447 + }
7448 +
7449 + /**
4510 7450 * Delete this site's cached pages from both the flat and static trees.
4511 7451 *
4512 7452 * Extracted so the `assets` purge can reuse it: minified assets are a
4513 7453 * dependency of the cached HTML, so clearing them must clear the pages
@@ -4670,8 +7610,42 @@
4670 7610 $count
4671 7611 );
4672 7612
4673 7613 Activity_Log::record( 'cache_purged', $message, Activity_Log::INFO );
7614 +
7615 + /*
7616 + * A partial purge is still a purge, and an edge in front of this site
7617 + * has to hear about it.
7618 + *
7619 + * "Purge Page / Static Cache" is always visible in the admin bar and
7620 + * clears every cached page for this site — the flat tree AND the
7621 + * static tree the generated nginx/Apache rules serve directly. It
7622 + * announced none of that: not `xspeed_after_purge_all`, which only
7623 + * purge_all() fires, and not the scoped actions. A CDN told to hold
7624 + * those pages went on serving the ones xSpeed had just deleted, for
7625 + * whatever lifetime it was given.
7626 + *
7627 + * Scope is `site` only for the two types that clear rendered HTML.
7628 + * An object-cache flush or a REST purge removes nothing an edge is
7629 + * holding, and announcing those as a site purge would have a CDN
7630 + * drop its whole cache every time a scheduled flush ran — worse than
7631 + * the silence this replaces. A type registered by another plugin
7632 + * through `xspeed_purge_types` is unknown here, so it says nothing
7633 + * rather than guessing.
7634 + *
7635 + * Not gated on `$count`, for the reason spelled out in purge_url():
7636 + * an empty local tree is not evidence that the edge is empty, and
7637 + * "Purge Page / Static Cache" with nothing left locally is precisely
7638 + * what someone clicks when the page they are looking at is stale.
7639 + * Answering that with silence made the button appear broken.
7640 + *
7641 + * That announcement is now `announce_purge()`'s, called beside this
7642 + * by the same callers (`page`, `assets`, `REST responses`). This
7643 + * function went back to being what its name says: a log entry. It
7644 + * used to publish as well, which double-fired every partial purge and
7645 + * — worse — announced `scope: none` for types #348's contract
7646 + * requires to stay silent about.
7647 + */
4674 7648 }
4675 7649
4676 7650 /**
4677 7651 * Clear the static tree only, leaving the flat cache in place.
@@ -4843,8 +7817,25 @@
4843 7817 // True when an edge cache (Cloudflare) fronts the origin, so hits are
4844 7818 // absorbed before reaching PHP. The dashboard labels the ratio
4845 7819 // "origin-layer only" instead of implying it's the full picture. (#118)
4846 7820 'edge_cache' => self::edge_cache_detected(),
7821 + // LiteSpeed Static Fast Path (#509): the web server serves hits
7822 + // with no PHP, no way to tag them, and no way to count them. The
7823 + // dashboard labels the ratio as PHP-layer only so a low number
7824 + // reads as the trade the user chose, not a fault.
7825 + //
7826 + // rewrite_installed() is part of the condition (QA on #513): when
7827 + // the .htaccess write failed (read-only file), hits still take
7828 + // the drop-in path and ARE counted — the disclosure would be the
7829 + // opposite of the truth. Health carries the "block missing"
7830 + // warning for that state; this flag only speaks when static
7831 + // serving is genuinely in effect.
7832 + 'static_hits_uncounted' => (
7833 + Server::LITESPEED === Server::type()
7834 + && ! empty( Settings::get()['cache_enabled'] )
7835 + && self::static_rewrite_allowed()
7836 + && self::rewrite_installed()
7837 + ),
4847 7838 /*
4848 7839 * Whether the page cache is actually SERVING, as opposed to
4849 7840 * switched on in settings. The hero read the setting alone and
4850 7841 * announced "Active — serving cached HTML"; a site whose
@@ -4906,8 +7897,13 @@
4906 7897 private static function miss_is_excluded(): bool {
4907 7898 if ( function_exists( 'is_404' ) && is_404() ) {
4908 7899 return true;
4909 7900 }
7901 + // A marked request is ours whatever its UA says: a renamed warmer,
7902 + // or a probe that has to send a browser's UA.
7903 + if ( Self_Traffic::request_is_marked() ) {
7904 + return true;
7905 + }
4910 7906 $ua = isset( $_SERVER['HTTP_USER_AGENT'] )
4911 7907 ? sanitize_text_field( wp_unslash( (string) $_SERVER['HTTP_USER_AGENT'] ) )
4912 7908 : '';
4913 7909 return Hit_Counter::is_bot_ua( $ua );
@@ -4913,16 +7909,20 @@
4913 7909 return Hit_Counter::is_bot_ua( $ua );
4914 7910 }
4915 7911
4916 7912 /**
4917 - * Whether an edge cache fronts this origin. Today: the Cloudflare
4918 - * integration is connected — so an unknown share of hits is served at the
4919 - * edge and never counted here, making the origin ratio a partial view the
4920 - * dashboard must label as such. (#118)
7913 + * Whether an edge cache fronts this origin, so an unknown share of hits
7914 + * is served there and never counted here — which makes the origin ratio a
7915 + * partial view the dashboard has to label as such. (#118)
7916 + *
7917 + * This used to mean "the Cloudflare module is switched on", which answered
7918 + * no for every site fronted by anything else, and no for a site on
7919 + * Cloudflare that had never opened our Cloudflare panel. Both of those
7920 + * sites had their ratio presented as the whole story. Edge_Provider knows
7921 + * better and knows it per request, so ask it.
4921 7922 */
4922 7923 private static function edge_cache_detected(): bool {
4923 - $cf = get_option( 'xspeed_module_cloudflare', array() );
4924 - return is_array( $cf ) && ! empty( $cf['enabled'] );
7924 + return Edge_Provider::NONE !== Edge_Provider::detect()['confidence'];
4925 7925 }
4926 7926
4927 7927 /**
4928 7928 * Apply the user's enable/disable choice. Called from the REST toggle
@@ -4962,8 +7962,9 @@
4962 7962 $lock = self::page_cache_lock();
4963 7963 if ( ! is_resource( $lock ) ) {
4964 7964 return self::blocked_toggle_state( __( 'Could not lock page-cache ownership. Try again.', 'xspeed' ) );
4965 7965 }
7966 + $changed = false;
4966 7967 try {
4967 7968 Page_Cache_Detector::invalidate();
4968 7969 $fresh = Page_Cache_Detector::inspect()['revision'];
4969 7970 if ( ! hash_equals( (string) $expected, (string) $fresh ) ) {
@@ -4968,13 +7969,61 @@
4968 7969 $fresh = Page_Cache_Detector::inspect()['revision'];
4969 7970 if ( ! hash_equals( (string) $expected, (string) $fresh ) ) {
4970 7971 return self::blocked_toggle_state( __( 'Page-cache ownership changed while xSpeed was checking it. Nothing was changed; try again.', 'xspeed' ) );
4971 7972 }
4972 - $state = self::toggle_unlocked( (bool) $enable, $consented );
7973 + $before = self::page_cache_fingerprint();
7974 + $state = self::toggle_unlocked( (bool) $enable, $consented );
7975 + $changed = empty( $state['blocked'] ) && self::page_cache_fingerprint() !== $before;
4973 7976 return $state;
4974 7977 } finally {
4975 7978 flock( $lock, LOCK_UN );
4976 7979 fclose( $lock );
7980 +
7981 + /*
7982 + * Announce the change to anything caching in front of us.
7983 + *
7984 + * Turning the page cache on or off changes what every URL on this
7985 + * site returns, and a CDN holding renders made under the old state
7986 + * goes on serving them for their whole lifetime. Nothing told it.
7987 + * OFF is the less obvious half and matters as much: the edge
7988 + * otherwise keeps serving pages from a cache the site no longer
7989 + * has.
7990 + *
7991 + * Only when something actually changed. A refused toggle wrote
7992 + * nothing — that is the whole point of the refusal — and purging
7993 + * after it would announce a change that did not happen. Nor does a
7994 + * toggle that found the cache already in the state it asked for:
7995 + * auto_heal() re-enables on every admin_init, and gating on "not
7996 + * refused" alone made every wp-admin page load empty the page
7997 + * cache and purge the edge (QA, 2026-09-23). The fingerprint is
7998 + * compared instead, so restoring a stripped drop-in still counts.
7999 + *
8000 + * After the writes and OUTSIDE the lock. Before them, a purge
8001 + * would repopulate from the state we are in the middle of leaving,
8002 + * which is how a "purge didn't work" report is born; inside them,
8003 + * it would hold single-occupancy ownership for the length of a
8004 + * filesystem sweep.
8005 + */
8006 + if ( $changed ) {
8007 + /*
8008 + * On shutdown, not inline. `purge_all()` sweeps the cache
8009 + * directory and fires the purge actions, and a listener on
8010 + * those can make an HTTP call to an edge — so running it here
8011 + * would make the toggle as slow as the sweep and couple its
8012 + * response to a third party. The Cloudflare Enterprise purge
8013 + * queue already defers for the same reason.
8014 + *
8015 + * Still after the writes: shutdown runs at the end of THIS
8016 + * request, with the new state on disk.
8017 + */
8018 + add_action(
8019 + 'shutdown',
8020 + static function () {
8021 + self::purge_all( 'page cache toggled' );
8022 + },
8023 + 20
8024 + );
8025 + }
4977 8026 }
4978 8027 }
4979 8028
4980 8029 /** Run the page-cache mutation while toggle() owns the scoped lock. */
@@ -4982,8 +8031,25 @@
4982 8031 * @param bool $consented The user asked for this in the dashboard, so a
4983 8032 * foreign drop-in may be taken over. False on the
4984 8033 * unattended paths, which stand down instead.
4985 8034 */
8035 + /**
8036 + * What the page cache looks like on disk and in wp-config, as one string.
8037 + *
8038 + * `toggle()` compares it before and after its writes to tell a real ON/OFF
8039 + * flip, or a repaired drop-in, from a call that found everything already
8040 + * as asked. Only the latter must not announce a purge.
8041 + */
8042 + private static function page_cache_fingerprint(): string {
8043 + $dropin = self::read_file( WP_CONTENT_DIR . '/advanced-cache.php' );
8044 + return md5(
8045 + ( null === $dropin ? "\0none" : md5( $dropin ) )
8046 + . '|' . self::wp_cache_define_state()
8047 + . '|' . ( self::rewrite_installed() ? '1' : '0' )
8048 + . '|' . ( self::page_cache_operational() ? '1' : '0' )
8049 + );
8050 + }
8051 +
4986 8052 private static function toggle_unlocked( bool $enable, bool $consented = true ) {
4987 8053 $enable = (bool) $enable;
4988 8054
4989 8055 if ( $enable ) {
@@ -5537,11 +8603,22 @@
5537 8603 * Falls back to the cache dir only if uploads is somehow unavailable.
5538 8604 */
5539 8605 public static function hits_log_dir(): string {
5540 8606 if ( function_exists( 'wp_upload_dir' ) ) {
8607 + // One drop-in serves the whole network, so its hit log has one
8608 + // home: the main site's uploads. Resolved per blog, the path
8609 + // baked into the drop-in changed with whichever blog's admin
8610 + // last ran auto_heal(), and each rewrite read as a page-cache
8611 + // change and purged the site and the edge (QA, 2026-09-23).
8612 + // A subsite's uploads are `<main uploads>/sites/<id>`, so the
8613 + // suffix comes off rather than switching blogs to ask.
5541 8614 $uploads = wp_upload_dir( null, false );
5542 8615 if ( is_array( $uploads ) && empty( $uploads['error'] ) && ! empty( $uploads['basedir'] ) ) {
5543 - return rtrim( (string) $uploads['basedir'], '/' ) . '/xspeed';
8616 + $base = rtrim( (string) $uploads['basedir'], '/' );
8617 + if ( function_exists( 'is_multisite' ) && is_multisite() ) {
8618 + $base = (string) preg_replace( '#/sites/\d+$#', '', $base );
8619 + }
8620 + return $base . '/xspeed';
5544 8621 }
5545 8622 }
5546 8623 return XSPEED_CACHE_DIR;
5547 8624 }
@@ -5674,9 +8751,9 @@
5674 8751 */
5675 8752 $opts = self::stored_cache_opts();
5676 8753 $ignored = is_array( $opts['ignored_query_params'] ?? null )
5677 8754 ? $opts['ignored_query_params']
5678 - : \XSpeed\Modules\Cache\CacheModule::DEFAULT_IGNORED_QUERY_PARAMS;
8755 + : \XSpeed\Modules\Cache\CacheModule::default_ignored_query_params();
5679 8756
5680 8757 $parts = array();
5681 8758 foreach ( $ignored as $pattern ) {
5682 8759 $pattern = trim( (string) $pattern );
@@ -5710,9 +8787,12 @@
5710 8787 if ( ! is_dir( XSPEED_CACHE_DIR ) && ! wp_mkdir_p( XSPEED_CACHE_DIR ) ) {
5711 8788 return;
5712 8789 }
5713 8790
5714 - $payload = '(?:' . implode( '|', array_unique( $parts ) ) . ')';
8791 + // The drop-in anchors this as `^…$`, so the lookahead refuses the
8792 + // never-ignored names whole, whatever entry would have matched them.
8793 + $never = implode( '|', array_map( static fn ( $p ) => preg_quote( $p, '#' ), self::NEVER_IGNORED_QUERY_PARAMS ) );
8794 + $payload = '(?!(?:' . $never . ')$)(?:' . implode( '|', array_unique( $parts ) ) . ')';
5715 8795
5716 8796 // Only write when the value actually changed. This runs from
5717 8797 // reconcile_mobile_separate() on CacheModule::boot(), so an
5718 8798 // unconditional write cost a file write and an exclusive lock on every
@@ -5749,8 +8829,28 @@
5749 8829 $stored = get_option( Settings_Manager::OPTION_PREFIX . 'cache', array() );
5750 8830 return is_array( $stored ) ? $stored : array();
5751 8831 }
5752 8832
8833 + /**
8834 + * Strict truthiness for the LiteSpeed Static Fast Path opt-in.
8835 + *
8836 + * On non-LiteSpeed servers the key is out of the schema and carried by
8837 + * preserved_keys(), so a REST/MCP write lands VERBATIM — QA on #513
8838 + * stored the string "false" on Apache and the fast path installed
8839 + * itself the moment the site moved to LiteSpeed, because
8840 + * empty("false") is false. Only an explicit, unambiguous "yes" may
8841 + * enable a path that trades away hit tagging; any other value —
8842 + * "false", "no", arbitrary junk — stays OFF, which is the default the
8843 + * user never left.
8844 + */
8845 + private static function litespeed_optin_enabled( $value ): bool {
8846 + if ( true === $value || 1 === $value ) {
8847 + return true;
8848 + }
8849 + return is_string( $value )
8850 + && in_array( strtolower( trim( $value ) ), array( '1', 'true', 'on', 'yes' ), true );
8851 + }
8852 +
5753 8853 public static function sync_mobile_flag( $enabled = null ): void {
5754 8854 if ( null === $enabled ) {
5755 8855 $stored = self::stored_cache_opts();
5756 8856 $enabled = ! empty( $stored['mobile_separate'] );
@@ -5923,12 +9023,24 @@
5923 9023 * the truth there. (Apache keeps the static fast path — it honors the
5924 9024 * header.) See maybe_emit_lscache_headers() for the paired LSCache
5925 9025 * stand-down that stops LiteSpeed's own module from shadowing the
5926 9026 * drop-in.
9027 + *
9028 + * Opt-in (#509): `litespeed_static_rewrite` re-enables the fast path on
9029 + * LiteSpeed for users who value raw TTFB over hit accounting. The trade
9030 + * is stated in the setting's copy: statically served hits carry no
9031 + * X-XSpeed-Cache header and are not counted (LiteSpeed logs the
9032 + * original request line, so even the access-log scan cannot see
9033 + * them — see Hit_Counter::collect_server_log_hits()). The drop-in
9034 + * default above stays — nobody is surprised into an unverifiable cache.
5927 9035 */
5928 9036 public static function static_rewrite_allowed(): bool {
5929 - // LiteSpeed: drop-in serves hits (visible + counted) — see docblock.
5930 - if ( Server::LITESPEED === Server::type() ) {
9037 + // Stored read — reached from boot(); see stored_cache_opts().
9038 + $opts = self::stored_cache_opts();
9039 + // LiteSpeed: drop-in serves hits (visible + counted) unless the user
9040 + // explicitly opted into the static fast path — see docblock.
9041 + if ( Server::LITESPEED === Server::type()
9042 + && ! self::litespeed_optin_enabled( $opts['litespeed_static_rewrite'] ?? false ) ) {
5931 9043 return false;
5932 9044 }
5933 9045 // Apache without mod_headers is in EXACTLY the position LiteSpeed
5934 9046 // is in above: it can run the RewriteRule and serve the static
@@ -5942,10 +9054,8 @@
5942 9054 // pinned at 0% on a working Apache cache.)
5943 9055 if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) {
5944 9056 return false;
5945 9057 }
5946 - // Stored read — reached from boot(); see stored_cache_opts().
5947 - $opts = self::stored_cache_opts();
5948 9058 return empty( $opts['mobile_separate'] );
5949 9059 }
5950 9060
5951 9061 /**
@@ -5987,10 +9097,17 @@
5987 9097 * path is the pasted snippet and there is no .htaccess marker to find, so
5988 9098 * requiring one would report every correctly-configured nginx site as
5989 9099 * broken.
5990 9100 *
9101 + * Carries `rules` through as well. The dashboard bootstrap builds its own
9102 + * payload and so had it; every other caller — POST /cache/recheck-rewrite,
9103 + * `wp xspeed cache recheck-rewrite`, the recheck_rewrite_rules MCP tool —
9104 + * came through here and got four keys, so the one surface a user reaches
9105 + * AFTER pasting the block could not tell them whether the block took. An
9106 + * agent driving the same fix could not read it at all.
9107 + *
5991 9108 * @param array $probe Raw result from probe_static_rewrite().
5992 - * @return array{active:bool,inconclusive:bool,reason:string,block_reason:string}
9109 + * @return array{active:bool,inconclusive:bool,reason:string,block_reason:string,rules:array}
5993 9110 */
5994 9111 public static function qualify_rewrite_probe( array $probe ): array {
5995 9112 $active = (bool) ( $probe['active'] ?? false );
5996 9113 $inconclusive = (bool) ( $probe['inconclusive'] ?? false );
@@ -5995,8 +9112,9 @@
5995 9112 $active = (bool) ( $probe['active'] ?? false );
5996 9113 $inconclusive = (bool) ( $probe['inconclusive'] ?? false );
5997 9114 $reason = (string) ( $probe['reason'] ?? '' );
5998 9115 $block_reason = self::static_rewrite_block_reason();
9116 + $rules = self::rules_state( $probe );
5999 9117
6000 9118 // Same observed-refusal check Health makes. This is the shared path for
6001 9119 // `wp xspeed cache recheck-rewrite` and POST /cache/recheck-rewrite —
6002 9120 // and, because a CLI command is automatically an MCP tool, for the
@@ -6030,8 +9148,9 @@
6030 9148 'active' => false,
6031 9149 'inconclusive' => false,
6032 9150 'reason' => 'Page caching is off, so there is no cache for the web server to serve.',
6033 9151 'block_reason' => '',
9152 + 'rules' => $rules,
6034 9153 );
6035 9154 }
6036 9155
6037 9156 // A known refusal outranks the probe, and also outranks
@@ -6047,8 +9166,9 @@
6047 9166 'active' => $active,
6048 9167 'inconclusive' => $inconclusive,
6049 9168 'reason' => $reason,
6050 9169 'block_reason' => $block_reason,
9170 + 'rules' => $rules,
6051 9171 );
6052 9172 }
6053 9173
6054 9174 /**
@@ -6063,8 +9183,10 @@
6063 9183 case 'mobile_separate':
6064 9184 return 'Separate Mobile Cache is on, which disables the device-blind static rewrite. Cache hits are served by PHP instead. If your site serves the same HTML to every device, turn it off in Cache settings for much faster hits.';
6065 9185 case 'no_mod_headers':
6066 9186 return "Apache's mod_headers is not loaded, so the static rewrite cannot mark its responses as cache hits. Enable mod_headers, or leave hits on the PHP path.";
9187 + case 'litespeed_dropin':
9188 + return 'On LiteSpeed, cache hits are served by the PHP drop-in so every hit is tagged X-XSpeed-Cache and counted in the hit ratio — LiteSpeed\'s .htaccess engine cannot do either for statically served files. If raw TTFB matters more to you than hit accounting, turn on LiteSpeed Static Fast Path in Cache settings to serve hits straight from the web server.';
6067 9189 case 'skipped_nonce':
6068 9190 return 'The server config is correct, but pages are not reaching the static cache because they contain nonces, so hits are served by PHP instead. A static file is served with no PHP, so a nonce baked into one could never be refreshed and every anonymous form on the page would break once it expired — keeping these pages on PHP is deliberate. Nonces usually come from plugin widgets; disabling the ones the site does not use lets its pages be served statically again.';
6069 9191 default:
6070 9192 return sprintf( 'The static rewrite is disabled (%s).', $code );
@@ -6083,9 +9205,25 @@
6083 9205 if ( empty( $opts['cache_enabled'] ) ) {
6084 9206 return '';
6085 9207 }
6086 9208 if ( Server::LITESPEED === Server::type() ) {
6087 - return ''; // Intended on LiteSpeed — not a "block".
9209 + // The opt-in is read RAW (stored_cache_opts), not through
9210 + // Settings_Manager::get(): the schema's bool coercion is a PHP
9211 + // cast, and (bool) "false" is true — so a junk string stored on
9212 + // another server (where the key bypasses the schema) would come
9213 + // back from the coercion layer as an ENABLE. Raw + the strict
9214 + // parse below is the same read static_rewrite_allowed() makes,
9215 + // so the two can't disagree either. (QA on #513)
9216 + $stored = self::stored_cache_opts();
9217 + // The intended default — but no longer silent: with the opt-in
9218 + // off, Health must be able to explain the PHP path and point at
9219 + // the toggle instead of falling through to "reinstall the block"
9220 + // advice that cannot work here. (#509)
9221 + if ( ! self::litespeed_optin_enabled( $stored['litespeed_static_rewrite'] ?? false ) ) {
9222 + return 'litespeed_dropin';
9223 + }
9224 + $cache_opts = Settings_Manager::get( 'cache' );
9225 + return ! empty( $cache_opts['mobile_separate'] ) ? 'mobile_separate' : '';
6088 9226 }
6089 9227 if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) {
6090 9228 return 'no_mod_headers';
6091 9229 }
@@ -6178,9 +9316,11 @@
6178 9316 'redirection' => 2,
6179 9317 // Bust any per-device cache so we compare freshly-rendered
6180 9318 // HTML, and pass the device UA the site would branch on.
6181 9319 'user-agent' => $ua,
6182 - 'headers' => array( 'Cache-Control' => 'no-cache' ),
9320 + // A real device UA by design, so only the header marks
9321 + // this as ours to analytics and the hit ratio.
9322 + 'headers' => Self_Traffic::headers( array( 'Cache-Control' => 'no-cache' ) ),
6183 9323 )
6184 9324 );
6185 9325 if ( is_wp_error( $resp ) || 200 !== (int) wp_remote_retrieve_response_code( $resp ) ) {
6186 9326 return null;
@@ -6519,10 +9659,41 @@
6519 9659 // missing. So: hits are logged, and a user deleting the log can't take
6520 9660 // nginx down.
6521 9661 $lines[] = ' access_log ' . $hits_abs . ' combined buffer=16k flush=5s;';
6522 9662 $lines[] = ' add_header X-XSpeed-Cache "HIT (nginx)" always;';
9663 + // Edge/CDN headers from the same seam the drop-in bakes. nginx serves
9664 + // this path without ever starting PHP, so the answer cannot be
9665 + // resolved per request — the pairs are resolved HERE, when the
9666 + // snippet is generated, and a change of answer needs the snippet
9667 + // regenerated and re-pasted to take effect.
9668 + //
9669 + // Skipped entirely when the static path is switched off. The only
9670 + // reason that can fire under `bake` is mobile-split, and mobile-split
9671 + // is also what switches the static path off — so the block would be
9672 + // baked with a hold it can never serve, and would start serving it
9673 + // the moment the setting is turned off and static files reappear,
9674 + // until somebody regenerates and re-pastes. A rule that can only be
9675 + // served once its premise is false is guaranteed to be stale.
9676 + foreach ( self::static_rewrite_allowed() ? self::edge_headers_for( 'HIT', 'rules' ) : array() as $name => $value ) {
9677 + $lines[] = ' add_header ' . $name . ' "' . self::quote_directive_value( $value ) . '" always;';
9678 + }
6523 9679 $lines[] = '}';
6524 - return implode( "\n", $lines );
9680 +
9681 + // Stamp the block with a hash of itself. This is the only way to find
9682 + // out what a user actually pasted: the block lives in a server config
9683 + // WordPress cannot read, so until now the dashboard could not tell an
9684 + // up-to-date paste from one made three settings changes ago, and
9685 + // covered for that by telling everyone to re-paste after every save.
9686 + // A hit through this location now carries the version that served it
9687 + // and probe_static_rewrite() reads it back. See rules_state().
9688 + return implode(
9689 + "\n",
9690 + self::with_rules_marker(
9691 + $lines,
9692 + array( ' add_header ' . self::RULES_HEADER . ' "%s" always;' ),
9693 + count( $lines ) - 1
9694 + )
9695 + );
6525 9696 }
6526 9697
6527 9698 /**
6528 9699 * Aggregate every enabled module's nginx_directives() into one
@@ -6813,9 +9984,9 @@
6813 9984 // so the closing quote here cannot be escaped away.
6814 9985 $lines[] = ' RewriteCond %{HTTP_USER_AGENT} "!(' . $ua_rule['regex'] . ')" [NC]';
6815 9986 }
6816 9987
6817 - return array_merge(
9988 + $block = array_merge(
6818 9989 $lines,
6819 9990 array(
6820 9991 // Capture REQUEST_URI without its trailing slash into %1.
6821 9992 // store_static() writes `{host}{uri-without-trailing-slash}/index.html`,
@@ -6833,9 +10004,15 @@
6833 10004 // `^` matches the empty string AND any non-empty path, so it
6834 10005 // covers `/` and `/blog` alike. (Confirmed on OpenLiteSpeed
6835 10006 // 1.8: `.` → homepage served by PHP drop-in; `^` → served
6836 10007 // directly from the static file.)
6837 - ' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [L]',
10008 + // `E=` tags the request the rewrite just served from the cache
10009 + // tree. The edge headers below key off it instead of the file
10010 + // name: <FilesMatch "\.html$"> in a document-root .htaccess
10011 + // matches EVERY .html on the site — a hand-uploaded /promo.html,
10012 + // a static export — and telling a CDN to hold those for the page
10013 + // TTL would pin files xSpeed never wrote and cannot purge.
10014 + ' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [E=XSPEED_STATIC_HIT:1,L]',
6838 10015 '</IfModule>',
6839 10016 // Mark the statically-served response as a cache HIT.
6840 10017 //
6841 10018 // A file served by the rewrite above bypasses PHP entirely, so
@@ -6857,11 +10034,53 @@
6857 10034 '<IfModule mod_headers.c>',
6858 10035 ' <FilesMatch "\\.html$">',
6859 10036 ' Header always set X-XSpeed-Cache "HIT (static)"',
6860 10037 ' </FilesMatch>',
6861 - '</IfModule>',
6862 10038 )
6863 10039 );
10040 +
10041 + // Edge/CDN headers from the same seam the drop-in bakes. Like the
10042 + // nginx snippet, the static rewrite answers without PHP, so the pairs
10043 + // are resolved when the block is GENERATED rather than per request.
10044 + //
10045 + // `env=` rather than the `<FilesMatch>` scoping above, because these
10046 + // must ride only on responses the rewrite produced. The X-XSpeed-Cache
10047 + // marker stays filename-scoped: it is inert, and narrowing it would
10048 + // change a header QA reads.
10049 + //
10050 + // Same reasoning as the nginx snippet: a bake hold can only come from
10051 + // mobile-split, and mobile-split is what turns this path off, so a
10052 + // hold baked here could only ever be served once its own premise had
10053 + // stopped being true.
10054 + $edge_lines = array();
10055 + foreach ( self::static_rewrite_allowed() ? self::edge_headers_for( 'HIT', 'rules' ) : array() as $edge_name => $edge_value ) {
10056 + $edge_lines = array_merge(
10057 + $edge_lines,
10058 + self::static_hit_directives(
10059 + ' Header always set ' . $edge_name . ' "' . self::quote_directive_value( $edge_value ) . '"'
10060 + )
10061 + );
10062 + }
10063 +
10064 + $block = array_merge( $block, $edge_lines, array( '</IfModule>' ) );
10065 +
10066 + // Same self-describing marker as the nginx snippet. Apache's block is
10067 + // written by us rather than pasted by hand, so it should never be out
10068 + // of date — but "should" is what refresh_rewrite_if_installed() not
10069 + // running looks like from the outside, and the probe can now say so
10070 + // instead of assuming. `env=` keeps it on responses the rewrite
10071 + // produced, so a hand-uploaded .html never claims to be our cache.
10072 + //
10073 + // Spliced in after the edge headers so the hash covers them: a
10074 + // changed edge answer has to make an installed block report itself
10075 + // stale, and a marker computed before they were appended would not
10076 + // move. The splice point steps back over them and `</IfModule>` to
10077 + // land inside `<FilesMatch>`, beside the X-XSpeed-Cache marker.
10078 + return self::with_rules_marker(
10079 + $block,
10080 + self::static_hit_directives( ' Header always set ' . self::RULES_HEADER . ' "%s"' ),
10081 + count( $block ) - count( $edge_lines ) - 2
10082 + );
6864 10083 }
6865 10084
6866 10085 /**
6867 10086 * Active probe that confirms the web-server static-rewrite path is
@@ -6916,9 +10135,12 @@
6916 10135
6917 10136 $home = home_url( '/' );
6918 10137 $host = (string) wp_parse_url( $home, PHP_URL_HOST );
6919 10138 if ( '' === $host ) {
6920 - $result = array( 'active' => false, 'reason' => 'home_url has no host' );
10139 + // Environmental failure, not evidence the server config is wrong —
10140 + // mark it inconclusive so Health surfaces say "could not verify"
10141 + // instead of demanding a snippet paste. (#480)
10142 + $result = array( 'active' => false, 'inconclusive' => true, 'reason' => 'home_url has no host' );
6921 10143 set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
6922 10144 return $result;
6923 10145 }
6924 10146
@@ -6935,9 +10157,12 @@
6935 10157 if ( ! file_exists( $probe_dir ) ) {
6936 10158 wp_mkdir_p( $probe_dir );
6937 10159 }
6938 10160 if ( ! is_dir( $probe_dir ) ) {
6939 - $result = array( 'active' => false, 'reason' => 'cannot create probe dir' );
10161 + // A cache-dir permissions problem — the probe never ran, so this
10162 + // says nothing about the nginx config. Inconclusive, not
10163 + // "required". (#480)
10164 + $result = array( 'active' => false, 'inconclusive' => true, 'reason' => 'cannot create probe dir' );
6940 10165 set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
6941 10166 return $result;
6942 10167 }
6943 10168 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin credentials we may not have here; the file is in our own cache dir.
@@ -6957,9 +10182,9 @@
6957 10182 // don't repeat the wait every minute.
6958 10183 'timeout' => 3,
6959 10184 'sslverify' => ! $is_local,
6960 10185 'redirection' => 0,
6961 - 'headers' => array( 'Cache-Control' => 'no-cache' ),
10186 + 'headers' => Self_Traffic::headers( array( 'Cache-Control' => 'no-cache' ) ),
6962 10187 )
6963 10188 );
6964 10189
6965 10190 // Best-effort cleanup so we don't accumulate probe dirs even
@@ -6995,8 +10220,17 @@
6995 10220 $ua_php = '' !== (string) wp_remote_retrieve_header( $resp, 'x-powered-by' );
6996 10221 $has_etag = '' !== (string) wp_remote_retrieve_header( $resp, 'etag' )
6997 10222 || '' !== (string) wp_remote_retrieve_header( $resp, 'last-modified' );
6998 10223 $match = trim( $body ) === $nonce;
10224 + // Which version of our generated rules answered, if any. Only the
10225 + // static path can set this — it is baked into the rules themselves —
10226 + // so its presence is direct evidence about what is installed, and its
10227 + // absence on a conclusive probe is evidence too. See rules_state().
10228 + // Validated to the shape we generate, so a proxy or another plugin
10229 + // sending something else under this name cannot be mistaken for a
10230 + // rules version and reported as "out of date".
10231 + $rules_raw = trim( (string) wp_remote_retrieve_header( $resp, strtolower( self::RULES_HEADER ) ) );
10232 + $rules = preg_match( '/^[0-9a-f]{8}\z/', $rules_raw ) ? $rules_raw : '';
6999 10233
7000 10234 // "Active" = the web server served our raw nonce bytes back
7001 10235 // AND emitted the static-serve markers (ETag / Last-Modified)
7002 10236 // AND didn't add an X-Powered-By: PHP header. All three are
@@ -7035,8 +10269,9 @@
7035 10269 'inconclusive' => $inconclusive,
7036 10270 'reason' => $reason,
7037 10271 'code' => $code,
7038 10272 'php' => $ua_php,
10273 + 'rules' => $rules,
7039 10274 );
7040 10275 set_transient( 'xspeed_rewrite_probe', $result, 5 * MINUTE_IN_SECONDS );
7041 10276 return $result;
7042 10277 }
@@ -7681,8 +10916,16 @@
7681 10916 '@@XSPEED_UA_RE@@',
7682 10917 str_replace( "'", "\\'", $ua_rule['regex'] ),
7683 10918 $source_contents
7684 10919 );
10920 + // Which user agents must not count toward the hit ratio. Built here
10921 + // because `xspeed_self_user_agents` is a filter the drop-in cannot
10922 + // call. A renamed warmer is caught by Self_Traffic::HEADER instead.
10923 + $source_contents = str_replace(
10924 + '@@XSPEED_HIT_EXCLUDE_RE@@',
10925 + str_replace( "'", "\\'", Hit_Counter::excluded_ua_regex() ),
10926 + $source_contents
10927 + );
7685 10928
7686 10929 /*
7687 10930 * Ours or absent — the ownership gate at the top of this method ruled
7688 10931 * out everything else. The old code path that moved a foreign drop-in
@@ -7711,8 +10954,31 @@
7711 10954 (string) ( $expiry_hours * HOUR_IN_SECONDS ),
7712 10955 $source_contents
7713 10956 );
7714 10957
10958 + // Bake the site-wide edge answer in. Resolved in a `bake` context, so
10959 + // nothing per-page and nothing a request header vouched for can reach
10960 + // it: a bake runs once, in an admin or CLI request, and answers for
10961 + // every page on the site. A page that disagrees gets a sidecar
10962 + // instead — see per_entry_edge_headers().
10963 + //
10964 + // Re-baked on every cache settings save (see CacheModule::boot),
10965 + // exactly like the cookie, user-agent and lifetime rules above.
10966 + $source_contents = str_replace(
10967 + "'@@XSPEED_EDGE_HEADERS@@'",
10968 + self::edge_headers_literal( self::edge_headers_for( 'HIT', 'bake' ) ),
10969 + $source_contents
10970 + );
10971 +
10972 + // And the answer for the same HIT served for a URL with an ignored
10973 + // param in it, which the drop-in sends instead of the one above. An
10974 + // empty array means the two agree. See query_variant_edge_headers().
10975 + $source_contents = str_replace(
10976 + "'@@XSPEED_EDGE_QUERY_HOLD@@'",
10977 + self::edge_headers_literal( self::query_variant_edge_headers() ),
10978 + $source_contents
10979 + );
10980 +
7715 10981 if ( file_exists( $target ) ) {
7716 10982 $existing = $wp_filesystem->get_contents( $target );
7717 10983 if ( is_string( $existing ) && $existing === $source_contents ) {
7718 10984 return true;
@@ -7718,11 +10984,50 @@
7718 10984 return true;
7719 10985 }
7720 10986 }
7721 10987
7722 - return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE );
10988 + $written = (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE );
10989 + if ( $written ) {
10990 + self::forget_compiled_dropin( $target );
10991 + }
10992 + return $written;
7723 10993 }
7724 10994
10995 + /**
10996 + * Callable that drops a script's compiled copy. Replaced by tests only;
10997 + * opcache_invalidate() is a PHP internal that cannot be stubbed.
10998 + *
10999 + * @var callable|null
11000 + */
11001 + private static $opcache_invalidate = null;
11002 +
11003 + /**
11004 + * Make PHP compile the new drop-in on the next request.
11005 + *
11006 + * opcache keeps the compiled drop-in and checks the file's timestamp at
11007 + * most every `opcache.revalidate_freq` seconds (60 on lenzora.site), or
11008 + * never with `validate_timestamps` off. So a re-bake, such as the one
11009 + * that follows a change to "CDN or proxy in front", kept serving the old
11010 + * edge headers for up to a minute, or until PHP restarted.
11011 + *
11012 + * This reaches the opcache of the PHP process doing the write, which is
11013 + * the web server's own when the bake runs in a page or REST request. A
11014 + * WP-CLI bake has its own opcache, so the web server still waits for its
11015 + * next timestamp check there.
11016 + *
11017 + * @param string $target Absolute path of the drop-in just written.
11018 + */
11019 + private static function forget_compiled_dropin( string $target ): void {
11020 + $invalidate = self::$opcache_invalidate;
11021 + if ( null === $invalidate ) {
11022 + if ( ! function_exists( 'opcache_invalidate' ) ) {
11023 + return;
11024 + }
11025 + $invalidate = 'opcache_invalidate';
11026 + }
11027 + @call_user_func( $invalidate, $target, true ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- opcache may be disabled or restricted (opcache.restrict_api); nothing to do either way.
11028 + }
11029 +
7725 11030 public static function remove_dropin() {
7726 11031 $target = WP_CONTENT_DIR . '/advanced-cache.php';
7727 11032 if ( ! file_exists( $target ) ) {
7728 11033 return;
@@ -8041,11 +11346,34 @@
8041 11346 // Only honour known types; anything else falls back to a full purge.
8042 11347 if ( ! array_key_exists( $type, self::purge_types() ) ) {
8043 11348 $type = 'all';
8044 11349 }
11350 +
11351 + // Answer the browser BEFORE purging. "Purge All" fans out to the local
11352 + // sweep, the object cache, CSS/edge listeners (outbound HTTP) and
11353 + // third-party render caches, all in this one request — on a large site
11354 + // that can outlive PHP-FPM's request_terminate_timeout, FPM kills the
11355 + // worker mid-purge, and nginx answers the admin's click with a 502.
11356 + // fastcgi_finish_request() exists on exactly those FPM setups: send
11357 + // the redirect, close the connection, then keep purging in the same
11358 + // process. Elsewhere (mod_php, CLI tests) fall back to purge-then-
11359 + // redirect as before.
11360 + $redirect = self::safe_purge_redirect( wp_get_referer() );
11361 + if ( function_exists( 'ignore_user_abort' ) ) {
11362 + ignore_user_abort( true );
11363 + }
11364 + if ( function_exists( 'set_time_limit' ) ) {
11365 + @set_time_limit( 300 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort under safe-mode-like restrictions.
11366 + }
11367 + if ( function_exists( 'fastcgi_finish_request' ) ) {
11368 + wp_safe_redirect( $redirect );
11369 + fastcgi_finish_request();
11370 + self::purge_type( $type );
11371 + exit;
11372 + }
11373 +
8045 11374 self::purge_type( $type );
8046 -
8047 - wp_safe_redirect( self::safe_purge_redirect( wp_get_referer() ) );
11375 + wp_safe_redirect( $redirect );
8048 11376 exit;
8049 11377 }
8050 11378
8051 11379 /**