PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.4
1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 All 30 releases
← All changes | includes/class-cache.php +1978 -98 1.2.41.3.4 View file →
@@ -20,8 +20,21 @@
20 20 */
21 21 private static $buffer_level = null;
22 22
23 23 /**
24 + * Bytes freed by the current sweep, accumulated by sweep_delete().
25 + *
26 + * A counter rather than a return value because the two sweeps that free
27 + * the bytes — the flat glob loop and the recursive static walk — already
28 + * report a FILE count, and `wp xspeed purge` needs both numbers from a
29 + * single pass. Re-walking the tree to size it would double the I/O on
30 + * exactly the caches large enough for the number to matter.
31 + *
32 + * @var int
33 + */
34 + private static $sweep_bytes = 0;
35 +
36 + /**
24 37 * The `X-XSpeed-Cache` value decided for this request, and — when the
25 38 * decision was BYPASS — the slug of the gate that made it.
26 39 *
27 40 * Recorded as well as sent so unit tests (CLI SAPI, where header() is a
@@ -32,8 +45,26 @@
32 45 private static $status_header = '';
33 46 private static $bypass_reason = '';
34 47
35 48 /**
49 + * Edge/CDN headers decided for this request, after sanitising.
50 + *
51 + * Same reason as $status_header: header() cannot be observed from the CLI
52 + * SAPI, so the pairs we sent are recorded here too.
53 + *
54 + * @var array<string,string>
55 + */
56 + private static $edge_headers = array();
57 +
58 + /**
59 + * This entry's edge headers when they differ from the site-wide bake,
60 + * resolved once per store. Null until asked.
61 + *
62 + * @var array<string,string>|null
63 + */
64 + private static $per_entry_edge = null;
65 +
66 + /**
36 67 * Cache key whose write was deferred to shutdown because a render-time
37 68 * translation plugin's buffer wraps ours. Null on every ordinary request.
38 69 *
39 70 * @var string|null
@@ -76,9 +107,12 @@
76 107 * running alongside its replacement and silently undo #243.
77 108 */
78 109 private const TARGETED_INVALIDATION_HOOKS = array(
79 110 'save_post',
111 + 'before_delete_post',
112 + 'trashed_post',
80 113 'comment_post',
114 + 'wp_set_comment_status',
81 115 'user_register',
82 116 'profile_update',
83 117 );
84 118
@@ -142,9 +176,9 @@
142 176 // rendered author bylines / term-archive pages. Without them, an edit
143 177 // left the matching endpoint (and archives) stale for the full TTL.
144 178 // (FBS-82408)
145 179 $invalidate_hooks = array(
146 - 'save_post', 'deleted_post', 'trashed_post',
180 + 'save_post', 'before_delete_post', 'trashed_post',
147 181 'comment_post', 'wp_set_comment_status',
148 182 'switch_theme', 'activated_plugin', 'deactivated_plugin',
149 183 // Users → /wp/v2/users + author archives.
150 184 'profile_update', 'user_register', 'deleted_user',
@@ -174,9 +208,13 @@
174 208 }
175 209 add_action(
176 210 $hook,
177 211 static function () use ( $hook ): void {
178 - self::purge_all( 'hook:' . $hook );
212 + self::purge_all(
213 + 'hook:' . $hook,
214 + null,
215 + self::invalidation_for_hook( $hook )
216 + );
179 217 }
180 218 );
181 219 add_action( $hook, array( 'XSpeed\\Minifier', 'purge_minified' ) );
182 220 }
@@ -247,12 +285,21 @@
247 285 // and generalises to Flamingo (#229) and Tutor LMS (#231) too.
248 286 remove_action( 'save_post', array( __CLASS__, 'purge_all' ) );
249 287 remove_action( 'save_post', array( 'XSpeed\\Minifier', 'purge_minified' ) );
250 288 add_action( 'save_post', array( __CLASS__, 'on_save_post' ), 10, 2 );
289 + add_action( 'before_delete_post', array( __CLASS__, 'on_post_removed' ), 10, 2 );
290 + add_action( 'trashed_post', array( __CLASS__, 'on_post_removed' ), 10, 2 );
291 + // wp_delete_post() hands an attachment to wp_delete_attachment() and
292 + // returns BEFORE before_delete_post fires, so deleting media reached
293 + // neither hook above. Attachment pages are public and media appears in
294 + // galleries, so that left cached pages showing a file that is gone.
295 + // (dev caught this via `deleted_post`, which this branch replaced.)
296 + add_action( 'delete_attachment', array( __CLASS__, 'on_post_removed' ), 10, 2 );
251 297
252 298 remove_action( 'comment_post', array( __CLASS__, 'purge_all' ) );
253 299 remove_action( 'comment_post', array( 'XSpeed\\Minifier', 'purge_minified' ) );
254 300 add_action( 'comment_post', array( __CLASS__, 'on_comment_post' ), 10, 3 );
301 + add_action( 'wp_set_comment_status', array( __CLASS__, 'on_comment_status' ), 10, 2 );
255 302
256 303 remove_action( 'user_register', array( __CLASS__, 'purge_all' ) );
257 304 remove_action( 'user_register', array( 'XSpeed\\Minifier', 'purge_minified' ) );
258 305 add_action( 'user_register', array( __CLASS__, 'on_user_change' ) );
@@ -418,8 +465,17 @@
418 465 private static function mark( string $value, string $reason = '' ): void {
419 466 self::$status_header = $value;
420 467 self::$bypass_reason = $reason;
421 468
469 + // Every status, not just a HIT. A page we declined to cache is the
470 + // one an edge most needs telling about: it goes out naked today, and
471 + // a CDN that stores HTML by default keeps somebody's cart.
472 + //
473 + // Resolved before the headers_sent() guard so the decision is
474 + // recorded (and observable in tests) even on a request that can no
475 + // longer send headers; only the emission below is conditional.
476 + self::$edge_headers = self::edge_headers_for( self::edge_status( $value ), 'request', $reason );
477 +
422 478 if ( headers_sent() ) {
423 479 return;
424 480 }
425 481 header( 'X-XSpeed-Cache: ' . $value );
@@ -425,10 +481,26 @@
425 481 header( 'X-XSpeed-Cache: ' . $value );
426 482 if ( '' !== $reason && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
427 483 header( 'X-XSpeed-Reason: ' . $reason );
428 484 }
485 + foreach ( self::$edge_headers as $name => $val ) {
486 + header( $name . ': ' . $val );
487 + }
429 488 }
430 489
490 + /**
491 + * Normalize an `X-XSpeed-Cache` value to the vocabulary the edge seam
492 + * speaks.
493 + *
494 + * The header value carries which layer served the page (`HIT (php)`,
495 + * `HIT (nginx)`, `HIT (static)`); nothing deciding what to tell a CDN
496 + * cares, and making a caller match on three spellings of one outcome is
497 + * how a rule ends up applied on two paths out of three.
498 + */
499 + private static function edge_status( string $value ): string {
500 + return 0 === strpos( $value, 'HIT' ) ? 'HIT' : $value;
501 + }
502 +
431 503 /** Record a bypass gate and answer "don't cache" in one statement. */
432 504 private static function bypass( string $reason ): bool {
433 505 self::mark( 'BYPASS', $reason );
434 506 return false;
@@ -444,8 +516,639 @@
444 516 return self::$bypass_reason;
445 517 }
446 518
447 519 /**
520 + * The edge/CDN pairs sent on this request ('' if none were).
521 + *
522 + * @return array<string,string>
523 + */
524 + public static function edge_headers(): array {
525 + return self::$edge_headers;
526 + }
527 +
528 + /**
529 + * Bypass gates that do NOT ask a cache in front of us to stand down.
530 + *
531 + * Every other slug does. The split is the reason this reads the gate
532 + * rather than the status: a bypass usually means "this response is
533 + * personal, or someone decided this page is never stored", and an edge
534 + * holding one of those does precisely what we refused to do. These two
535 + * mean something else.
536 + *
537 + * `cache-disabled` is the user switching OUR page cache off. Nothing
538 + * about the page became personal. Sending `no-store` on every page of a
539 + * site whose owner chose a different cache would make a local toggle a
540 + * site-wide side effect on infrastructure we do not own.
541 + *
542 + * `non-frontend` is admin, REST, cron and AJAX. Not ours to describe:
543 + * WordPress already nocaches admin, and a REST caller sets its own
544 + * policy.
545 + */
546 + private const HOLD_EXEMPT_BYPASS = array( 'cache-disabled', 'non-frontend' );
547 +
548 + /**
549 + * Bypass gates that describe the SHAPE of the request rather than the
550 + * visitor or the page.
551 + *
552 + * These still hold, but only once we have evidence of an edge — the same
553 + * bar a MISS has to clear. The difference matters because the default
554 + * excluded-URL list contains `/feed/`, the sitemap and `/wp-json/`, and
555 + * `query-param` catches `?lang=fr`, `?paged=2`, and every page of a
556 + * plain-permalink site.
557 + *
558 + * xSpeed refuses those because IT cannot key on a query string, not
559 + * because the response is private. A CDN keys on the full URL and caches
560 + * them correctly. Holding them unconditionally would have meant every
561 + * default install stopped its feed and sitemap being edge-cached — a
562 + * performance regression shipped to sites that never had a CDN in the
563 + * first place, in the name of protecting them from one.
564 + *
565 + * The gates left out of this list are about the visitor (`logged-in`,
566 + * `excluded-cookie`) or are somebody stating outright that this page is
567 + * never to be stored (`donotcachepage`, `post-excluded`, `filtered`).
568 + * Those hold whether or not we can see an edge.
569 + */
570 + private const REQUEST_SHAPE_BYPASS = array( 'query-param', 'non-get', 'user-agent' );
571 +
572 + /**
573 + * Default exclusions that are about the site's plumbing, not its content.
574 + *
575 + * `excluded-url` covers two unlike things. The default list carries
576 + * `/cart`, `/checkout`, `/my-account` and `/wp-login` — personal pages,
577 + * and the reason this feature exists. It also carries the entries below:
578 + * feeds, sitemaps, the REST root, the front controller. Those are public,
579 + * cacheable, and hammered by pollers; a CDN keys on the full URL and
580 + * serves them correctly, so telling it to stop is a cost with no benefit.
581 + *
582 + * Matched as exact strings against the stored list, never as patterns
583 + * against the path. Three bugs came out of doing it the other way round:
584 + * `strpos( $uri, '/feed' )` matched `/my-account/feedback/`, reading the
585 + * whole URI let `/cart/?utm_source=/feed/` disguise a cart as a feed, and
586 + * a bare `index.php` — which is in this list, and which every URL contains
587 + * on an "almost pretty" permalink site — made every page on such a site
588 + * look personal. Comparing the LIST ENTRY rather than the path cannot make
589 + * any of those mistakes, and it keeps a pattern the site owner added
590 + * themselves on the personal side where it belongs.
591 + */
592 + private const STRUCTURAL_EXCLUSIONS = array(
593 + '/wp-json/',
594 + '/xmlrpc.php',
595 + '~wp-.*\.php',
596 + '/feed/',
597 + 'index.php',
598 + '/robots.txt',
599 + // Both spellings, and no entry here is ever retired. This is a
600 + // RECOGNITION list, not a source of truth: it is matched against
601 + // whatever the site has STORED, and a site that saved its settings
602 + // before `~sitemap(_index)?\.xml` was widened to `sitemaps?` (for
603 + // SEOPress, which ships sitemaps.xml) still has the old string in
604 + // its option row. Dropping the old spelling when the default moved
605 + // would read every upgraded site's sitemap exclusion as somebody's
606 + // personal data and hold sitemaps off the CDN — the bug this whole
607 + // predicate exists to prevent, reintroduced by a rename.
608 + '~sitemaps?(_index)?\.xml',
609 + '~sitemap(_index)?\.xml',
610 + );
611 + /**
612 + * Header names no edge instruction may ever carry.
613 + *
614 + * These describe the transfer, not the caching policy, and one wrong
615 + * value from a settings field is a white screen rather than a missing
616 + * optimization.
617 + */
618 + private const NEVER_AN_EDGE_HEADER = array(
619 + 'content-length',
620 + 'content-encoding',
621 + 'content-type',
622 + 'transfer-encoding',
623 + 'set-cookie',
624 + 'location',
625 + 'x-xspeed-cache',
626 + 'x-xspeed-edge-hold',
627 + );
628 +
629 + /**
630 + * Reasons that hold the edge off even when we detected nothing in front.
631 + *
632 + * `none` confidence means no evidence of a proxy, which is not proof
633 + * there is none — a transparent proxy and a host page cache both leave
634 + * the request untouched. So the question is what a wasted header costs
635 + * against what a missed one does, and the answer differs by reason.
636 + *
637 + * These two are correctness failures. A cart page stored by something we
638 + * could not see is the defect this exists to fix, and a mobile-split page
639 + * served to the wrong device is a wrong page rather than a slow one.
640 + * Ninety bytes on a response that was never cacheable is a cheap premium.
641 + *
642 + * `miss` and `pending` are performance hedges, and a hedge against a
643 + * cache that does not exist is noise on every first render. Skipping them
644 + * has a second benefit: because per_entry_edge_headers() compares `store`
645 + * against `bake`, a `pending` hold that never fires leaves the two
646 + * agreeing, which keeps the page on the static tree.
647 + */
648 + private const HOLD_WITHOUT_EVIDENCE = array( 'bypass', 'mobile-split' );
649 +
650 + /**
651 + * Is a module still going to change this page after this response?
652 + *
653 + * Free itself never says yes — nothing in Free defers work past the
654 + * request. Minification and combining write their file and return its URL
655 + * inside the same render; the LCP preload is chosen by parsing the HTML
656 + * being sent. It is the question that matters to anything caching in
657 + * front of us, so Free asks it on their behalf and lets whoever owns the
658 + * deferred work answer.
659 + *
660 + * Answer TRUE while the work is outstanding for the page being served.
661 + * The cost of a false yes is one extra origin hit; the cost of a false no
662 + * is an un-optimized page pinned at the edge for the full lifetime, which
663 + * is the failure this exists to prevent — so when in doubt, say yes.
664 + *
665 + * Asked on a `request` only, and that boundary is the whole safety of it.
666 + *
667 + * A `bake` is generated once, in an admin or CLI request, and serves every
668 + * static HIT on the site; a per-page answer frozen into it would be wrong
669 + * for every other page.
670 + *
671 + * A `store` is worse, and cost a live site an afternoon. The pairs written
672 + * at store time go into the `.meta` sidecar, which the drop-in replays on
673 + * every later HIT — before plugins load, so nothing can re-ask this
674 + * question. A hold written there therefore outlives the state that caused
675 + * it, and the only thing that clears it is the page being stored again. On
676 + * a site where the deferred work never completes, every re-store re-pins
677 + * it, and the page is never edge-cacheable again. The symptom is a cache
678 + * HIT carrying `no-store` and `X-XSpeed-Edge-Hold: pending` on a page
679 + * whose deferred work finished long ago — the sidecar answering with
680 + * state nothing can re-ask.
681 + *
682 + * Holding the MISS is what this is for, and it is enough: that response is
683 + * the un-optimized one. The copy we then store is what an edge should
684 + * mirror, and when the work does land the module purges the page, which
685 + * reaches the edge. The purge is the correctness mechanism; this is only
686 + * meant to cover the single render before it.
687 + *
688 + * @param string $context `request`, `store` or `bake`.
689 + */
690 + public static function edge_optimization_pending( string $context = 'request' ): bool {
691 + if ( 'request' !== $context ) {
692 + return false;
693 + }
694 +
695 + /**
696 + * Filter: xspeed_edge_optimization_pending
697 + *
698 + * @param bool $pending Whether deferred work will still change this page.
699 + */
700 + return (bool) apply_filters( 'xspeed_edge_optimization_pending', false );
701 + }
702 +
703 + /**
704 + * Does mobile cache split this URL into two renders?
705 + *
706 + * With `mobile_separate` on, Free keys its cache on device and serves a
707 + * different page to a phone than to a desktop at the SAME url. No CDN
708 + * varies on User-Agent, so an edge holding one of those renders serves it
709 + * to everyone: whichever device asked first decides what the other sees,
710 + * for the whole lifetime. A wrong page, not a slow one.
711 + *
712 + * Read from the stored option rather than through Settings_Manager: this
713 + * is consulted from the serve path, where the module registry may not
714 + * have run.
715 + */
716 + private static function mobile_cache_splits_html(): bool {
717 + $stored = self::stored_cache_opts();
718 + return ! empty( $stored['mobile_separate'] );
719 + }
720 +
721 + /**
722 + * Why, if at all, a cache in front of us should refuse to store this.
723 + *
724 + * @param string $status `HIT`, `MISS` or `BYPASS`.
725 + * @param string $context `request`, `store` or `bake`.
726 + * @param string $bypass_reason The gate slug, for BYPASS only.
727 + * @return string '' or one of bypass|bypass-shape|miss|mobile-split|pending.
728 + */
729 + private static function edge_hold_reason( string $status, string $context, string $bypass_reason ): string {
730 + $reason = '';
731 +
732 + // The two exempt gates are answered before anything else, or a site
733 + // with Separate Mobile Cache on would keep holding after the page
734 + // cache was switched off — which is exactly the "a local toggle must
735 + // not become a site-wide side effect on infrastructure we do not own"
736 + // rule below, defeated by the ordering rather than by the logic.
737 + if ( 'BYPASS' === $status && in_array( $bypass_reason, self::HOLD_EXEMPT_BYPASS, true ) ) {
738 + /** This filter is documented below. */
739 + return (string) apply_filters( 'xspeed_edge_hold_reason', '', $status, $context, $bypass_reason );
740 + }
741 +
742 + // First, because it is the only reason true in every context: the
743 + // setting is a property of the site, not of one request, so it is the
744 + // one thing a baked artifact can honestly assert.
745 + //
746 + // It is also the only reason that holds a HIT — a response we DID
747 + // cache — and that is deliberate rather than an artefact of the
748 + // ordering. With mobile_separate on we key the cache by device and
749 + // serve different HTML to a phone than to a desktop at the same URL.
750 + // No CDN varies on User-Agent, so an edge holding one of those
751 + // renders serves it to everyone and whichever device asked first
752 + // decides what the other sees. Our copy is fine; theirs would be a
753 + // wrong page. The static path is switched off in this mode anyway
754 + // (static_rewrite_allowed()), so these hits come from the drop-in,
755 + // which carries the same baked answer.
756 + if ( self::mobile_cache_splits_html() ) {
757 + $reason = 'mobile-split';
758 + } elseif ( 'BYPASS' === $status ) {
759 + $shaped = in_array( $bypass_reason, array( 'excluded-url', 'query-param' ), true )
760 + ? ! self::path_is_a_personal_exclusion( $bypass_reason )
761 + : in_array( $bypass_reason, self::REQUEST_SHAPE_BYPASS, true );
762 + $reason = $shaped ? 'bypass-shape' : 'bypass';
763 + } elseif ( self::edge_optimization_pending( $context ) ) {
764 + $reason = 'pending';
765 + } elseif ( 'MISS' === $status ) {
766 + $reason = 'miss';
767 + }
768 +
769 + /**
770 + * Filter: xspeed_edge_hold_reason
771 + *
772 + * Return '' to veto a hold, or a reason string to force one.
773 + *
774 + * @param string $reason '' or bypass|bypass-shape|miss|mobile-split|pending.
775 + * @param string $status `HIT`, `MISS` or `BYPASS`.
776 + * @param string $context `request`, `store` or `bake`.
777 + * @param string $bypass_reason The gate slug, for BYPASS only.
778 + */
779 + return (string) apply_filters( 'xspeed_edge_hold_reason', $reason, $status, $context, $bypass_reason );
780 + }
781 +
782 + /**
783 + * Was this page excluded because it is personal, or because it is
784 + * plumbing we cannot key a cache entry on?
785 + *
786 + * Answers by removing the structural defaults from the site's own
787 + * exclusion list and asking whether anything is left that matches. So a
788 + * feed matches only `/feed/` and comes back false; `/my-account/feedback/`
789 + * matches `/my-account` and comes back true; and on an "almost pretty"
790 + * permalink site, where every path contains `index.php`, an ordinary page
791 + * matches nothing else and is correctly treated as public.
792 + *
793 + * The path only, never the query string — a visitor writes that, and
794 + * `/cart/?utm_source=/feed/` must not be able to talk a cart out of its
795 + * hold. It is also what `should_cache()` matches the list against.
796 + *
797 + * Asked for a `query-param` bypass too, because the query gate runs
798 + * BEFORE the URL gate, so `/cart/?add-to-cart=12` reports `query-param`
799 + * and never reaches `excluded-url` at all. Which gate fired first says
800 + * nothing about whose data is on the page.
801 + */
802 + private static function path_is_a_personal_exclusion( string $bypass_reason ): bool {
803 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- reading the path of the request being served; there is no form here to nonce.
804 + $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
805 + $path = (string) strtok( $uri, '?' );
806 + if ( '' === $path ) {
807 + return false;
808 + }
809 +
810 + // Through Settings_Manager, not the raw option, because the schema's
811 + // default IS the structural list and a fresh install has never
812 + // written the option. Read raw, every site that has not visited the
813 + // settings screen looks like a site with no exclusions at all, takes
814 + // the contradiction branch below, and reports its feeds as personal.
815 + //
816 + // Safe here where `mobile_cache_splits_html()` is not: we are only
817 + // ever called with a bypass reason, and those come from
818 + // `should_cache()`, which resolved the same settings through
819 + // `Settings_Manager::get()` to produce them.
820 + $opts = Settings_Manager::get( 'cache' );
821 + $excluded = is_array( $opts['excluded_urls'] ?? null ) ? $opts['excluded_urls'] : array();
822 + if ( array() === $excluded ) {
823 + // An `excluded-url` bypass with no exclusion list is a
824 + // contradiction — something excluded the request and the list
825 + // cannot say what — so assume personal, because a wasted header
826 + // costs a little origin traffic while a missing one serves
827 + // somebody's basket to a stranger. A `query-param` bypass with an
828 + // empty list is just an ordinary page carrying a parameter, and
829 + // says nothing about the path at all.
830 + return 'excluded-url' === $bypass_reason;
831 + }
832 +
833 + $personal = array_values(
834 + array_filter(
835 + $excluded,
836 + static fn ( $pattern ) => ! in_array( (string) $pattern, self::STRUCTURAL_EXCLUSIONS, true )
837 + )
838 + );
839 +
840 + return array() !== $personal && Glob_Matcher::any_match( $personal, $path );
841 + }
842 +
843 + /**
844 + * The edge/CDN headers to send on a response with this cache status.
845 + *
846 + * @param string $status `HIT`, `MISS` or `BYPASS`.
847 + * @param string $context `request` when resolved per request on the
848 + * PHP serve path, `store` when resolved for
849 + * one entry's sidecar, `bake` when resolved
850 + * once and frozen into an artifact.
851 + * @param string $bypass_reason The gate slug, for BYPASS only.
852 + * @return array<string,string>
853 + */
854 + public static function edge_headers_for( string $status, string $context = 'request', string $bypass_reason = '' ): array {
855 + $base = array();
856 + if ( 'HIT' === $status ) {
857 + /**
858 + * Filter: xspeed_edge_cache_headers
859 + *
860 + * Response headers to add to a cached HTML response. A HIT-only
861 + * contract: a lifetime is a promise that this copy is worth
862 + * keeping, and neither a first render nor a page we refused to
863 + * cache is one.
864 + *
865 + * The same filter feeds three regimes and `$context` says which.
866 + * On the PHP serve path it runs per request (`request`); at store
867 + * time it runs for one entry (`store`); when the drop-in or a
868 + * server rule is generated it runs once (`bake`) and the result
869 + * answers for every static HIT on the site. Anything per-page — a
870 + * post id in a cache tag, say — must be skipped under `bake`.
871 + *
872 + * @param array<string,string> $headers Header name => value.
873 + * @param string $status Always `HIT` here.
874 + * @param string $context `request`, `store` or `bake`.
875 + */
876 + $base = self::sanitize_edge_headers( (array) apply_filters( 'xspeed_edge_cache_headers', array(), 'HIT', $context ) );
877 + }
878 +
879 + $reason = self::edge_hold_reason( $status, $context, $bypass_reason );
880 + if ( '' === $reason ) {
881 + return $base;
882 + }
883 + $detected = Edge_Provider::detect( $context );
884 + if ( Edge_Provider::is_off( $detected ) ) {
885 + return $base;
886 + }
887 + if ( Edge_Provider::NONE === $detected['confidence']
888 + && ! in_array( $reason, self::HOLD_WITHOUT_EVIDENCE, true ) ) {
889 + return $base;
890 + }
891 +
892 + $hold = Edge_Provider::hold_headers( $detected['provider'] );
893 +
894 + /**
895 + * Filter: xspeed_edge_hold_headers
896 + *
897 + * The last word on what a hold INSTRUCTS. Runs before sanitising, so
898 + * a value that cannot be sent as a header is still dropped, and
899 + * before `X-XSpeed-Edge-Hold` is added, so it cannot rewrite the
900 + * reason xSpeed held the page for — that is a diagnosis, not an
901 + * instruction, and a forged one sends a reader after the wrong
902 + * module.
903 + *
904 + * @param array<string,string> $hold Header name => value.
905 + * @param array<string,string> $detected Provider, confidence, source.
906 + * @param string $reason Why the hold fired.
907 + * @param string $context `request`, `store` or `bake`.
908 + */
909 + $hold = (array) apply_filters( 'xspeed_edge_hold_headers', $hold, $detected, $reason, $context );
910 +
911 + // A hold replaces the lifetime rather than sitting beside it: the two
912 + // describe the same response and would contradict each other. The
913 + // cache tag survives, because a later purge still has to be able to
914 + // name whatever the edge picked up on its own terms.
915 + if ( isset( $base['Cache-Tag'] ) ) {
916 + $hold['Cache-Tag'] = $base['Cache-Tag'];
917 + }
918 +
919 + // Never argue with a stronger answer WordPress already gave. It sends
920 + // `no-store, private` of its own accord on a logged-in, 404 or
921 + // password-protected response, from WP::send_headers() — which runs
922 + // before template_redirect, so it is already on the wire by the time
923 + // we get here. Ours is the weaker statement of the two; replacing it
924 + // would be a downgrade dressed as a fix. Only meaningful per request:
925 + // a bake has no response to inspect.
926 + if ( 'request' === $context && isset( $hold['Cache-Control'] ) && self::cache_control_already_stronger() ) {
927 + unset( $hold['Cache-Control'] );
928 + }
929 +
930 + // A page we refused to cache must not carry a validator either. A
931 + // `Last-Modified` left on it invites a conditional request, and a
932 + // shared cache that gets a 304 back serves the copy it should not
933 + // have stored. Only on a bypass, and only per request: a MISS is
934 + // about to be stored by us, so its validator is ours to keep.
935 + if ( 'request' === $context && 'bypass' === $reason && ! headers_sent() ) {
936 + header_remove( 'Last-Modified' );
937 + }
938 +
939 + $hold = self::sanitize_edge_headers( $hold );
940 +
941 + // Name the reason in the hold set itself, rather than sending it
942 + // separately from mark().
943 + //
944 + // "Why is my page not being cached at the edge?" is the question this
945 + // answers, and mark() could only answer it on the PHP serve path. The
946 + // other emitters send whatever this function returns and never ran
947 + // mark() at all — so the responses hardest to explain went out
948 + // carrying `no-store` with nothing beside it to say why. Chiefly the
949 + // drop-in, which serves from the `.meta` sidecar written under
950 + // `store` and from the literal baked under `bake`, before plugins
951 + // load and with no way to re-ask (the symptom
952 + // edge_optimization_pending() describes above).
953 + //
954 + // The nginx and Apache blocks are a third path in principle and
955 + // almost never in practice: they are only installed when
956 + // static_rewrite_allowed() is true, and the one reason a stock site
957 + // can hold under `bake` is `mobile-split`, which is exactly what
958 + // makes that false. They will carry it where a site forces a hold
959 + // through `xspeed_edge_hold_reason`, and otherwise have no hold to
960 + // carry.
961 + //
962 + // Added AFTER sanitising and banned in NEVER_AN_EDGE_HEADER, so
963 + // neither of the two filters above can forge a reason or suppress the
964 + // real one.
965 + //
966 + // Reduced to the slug CHARACTER CLASS, not checked against the five
967 + // slugs: `xspeed_edge_hold_reason` is documented as able to force a
968 + // reason, and a site that forces its own deserves to see it. What is
969 + // not negotiable is the shape, because this value reaches an
970 + // .htaccess and an nginx conf as well as a response header — so no
971 + // CR/LF, no `$`, no `%`, no `\`, and a length a config file can hold.
972 + $slug = preg_replace( '/[^a-z0-9-]/', '', strtolower( $reason ) );
973 + if ( is_string( $slug ) && '' !== $slug ) {
974 + $hold['X-XSpeed-Edge-Hold'] = substr( $slug, 0, 32 );
975 + }
976 +
977 + return $hold;
978 + }
979 +
980 + /** Has something already sent a Cache-Control at least as strict as ours? */
981 + private static function cache_control_already_stronger(): bool {
982 + foreach ( headers_list() as $line ) {
983 + if ( 0 !== stripos( $line, 'cache-control:' ) ) {
984 + continue;
985 + }
986 + if ( preg_match( '/\b(?:no-store|private)\b/i', $line ) ) {
987 + return true;
988 + }
989 + }
990 +
991 + return false;
992 + }
993 +
994 + /**
995 + * Edge headers that belong to THIS page rather than to every page.
996 + *
997 + * `edge_headers_for('HIT','bake')` is the answer frozen into the drop-in
998 + * and the server rules: one set, serving the whole site. But the answer
999 + * for one URL can legitimately differ — a page whose deferred work is
1000 + * still outstanding, say — and that answer has nowhere to live, because
1001 + * the baked set is all the fast paths know about.
1002 + *
1003 + * So ask again in a `store` context, with the request still in scope, and
1004 + * return the pairs only when they differ from the baked ones. Identical is
1005 + * the overwhelmingly common case and writes nothing: pages do not pay a
1006 + * sidecar for an answer the drop-in already has.
1007 + *
1008 + * Memoised because two callers ask within one store — the sidecar writer
1009 + * and the static-tree guard — and the filters behind it are not required
1010 + * to be cheap.
1011 + *
1012 + * @return array<string,string> Empty when this page needs no override.
1013 + */
1014 + private static function per_entry_edge_headers(): array {
1015 + if ( is_array( self::$per_entry_edge ) ) {
1016 + return self::$per_entry_edge;
1017 + }
1018 + $baked = self::edge_headers_for( 'HIT', 'bake' );
1019 + $request = self::edge_headers_for( 'HIT', 'store' );
1020 + self::$per_entry_edge = ( $request === $baked ) ? array() : $request;
1021 +
1022 + return self::$per_entry_edge;
1023 + }
1024 +
1025 + /**
1026 + * Render baked pairs as a PHP array literal for the drop-in.
1027 + *
1028 + * Single-quoted literals with quotes escaped, because the result is
1029 + * written into a PHP file that must still parse. Values reaching here
1030 + * have already been through sanitize_edge_headers(), so neither name nor
1031 + * value can carry a newline.
1032 + *
1033 + * @param array<string,string> $headers Name => value.
1034 + */
1035 + private static function edge_headers_literal( array $headers ): string {
1036 + if ( array() === $headers ) {
1037 + return 'array()';
1038 + }
1039 + // var_export(), not hand-rolled quoting. A single-quoted PHP string
1040 + // escapes BOTH `'` and `\\`, and escaping only the first is how a
1041 + // value ending in a backslash — `X-Foo: C:\path\` from the custom
1042 + // headers box — leaves the literal unterminated. That file is
1043 + // included on every request once WP_CACHE is on, so the result is a
1044 + // parse error on the front end AND in wp-admin, with no way back
1045 + // except deleting the file over SSH.
1046 + $parts = array();
1047 + foreach ( $headers as $name => $value ) {
1048 + $parts[] = var_export( (string) $name, true ) . ' => ' . var_export( (string) $value, true );
1049 + }
1050 +
1051 + return 'array( ' . implode( ', ', $parts ) . ' )';
1052 + }
1053 +
1054 + /**
1055 + * Quote a header value for an nginx / Apache directive.
1056 + *
1057 + * Both accept a double-quoted string with backslash escapes, and both
1058 + * refuse to load a config where the quoting is wrong — a mis-escaped
1059 + * value takes the whole vhost down, not just this header.
1060 + */
1061 + private static function quote_directive_value( string $value ): string {
1062 + return str_replace( array( '\\', '"' ), array( '\\\\', '\\"' ), $value );
1063 + }
1064 +
1065 + /**
1066 + * The same directive twice — once per name Apache can expose the
1067 + * rewrite's environment variable under.
1068 + *
1069 + * `RewriteRule ... [E=XSPEED_STATIC_HIT:1]` in a per-directory context is
1070 + * an INTERNAL REDIRECT: Apache re-enters the request with the substituted
1071 + * path, and every variable set on the first pass is renamed with a
1072 + * `REDIRECT_` prefix for the second. `env=XSPEED_STATIC_HIT` is evaluated
1073 + * on that second pass, where nothing answers to that name any more, so
1074 + * the directive never fires — dropping the headers from precisely the
1075 + * responses they exist for.
1076 + *
1077 + * It cannot be written once: `env=` takes a single name with no
1078 + * alternation, and `expr=` — which could express both — is not dependable
1079 + * on LiteSpeed, which reads this same block. So both are emitted; the one
1080 + * whose variable is unset on a given pass does nothing.
1081 + *
1082 + * @param string $directive The directive, without its `env=` clause.
1083 + * @return string[]
1084 + */
1085 + private static function static_hit_directives( string $directive ): array {
1086 + return array(
1087 + $directive . ' env=XSPEED_STATIC_HIT',
1088 + $directive . ' env=REDIRECT_XSPEED_STATIC_HIT',
1089 + );
1090 + }
1091 +
1092 + /**
1093 + * Keep only pairs that can be sent as a header verbatim.
1094 + *
1095 + * These values reach three different emitters — PHP's header(), an nginx
1096 + * `add_header` and an Apache `Header always set` — so a name with a space
1097 + * or a value carrying CR/LF is not merely malformed, it is a
1098 + * response-splitting vector in the first and a broken server config in
1099 + * the other two. Names must be token-shaped; values lose CR/LF and are
1100 + * dropped if nothing survives.
1101 + *
1102 + * @param array<mixed,mixed> $headers Raw pairs.
1103 + * @return array<string,string>
1104 + */
1105 + public static function sanitize_edge_headers( array $headers ): array {
1106 + $clean = array();
1107 + foreach ( $headers as $name => $value ) {
1108 + // Never let one of these through, whoever asked. They describe the
1109 + // transfer rather than the caching policy, and getting one wrong
1110 + // from a settings field is a white screen: `Content-Encoding: gzip`
1111 + // on an uncompressed body, a `Content-Length` that disagrees with
1112 + // the bytes. `X-XSpeed-Cache` is ours and a second copy would lie
1113 + // to whoever reads it.
1114 + if ( is_string( $name ) && in_array( strtolower( $name ), self::NEVER_AN_EDGE_HEADER, true ) ) {
1115 + continue;
1116 + }
1117 + // `\z`, not `$`: PCRE's `$` also matches immediately BEFORE a
1118 + // trailing newline, so "Cache-Tag\n" passes a `$` check and gets
1119 + // concatenated raw into the generated .htaccess — splitting one
1120 + // Header directive across two lines, which is a syntax error
1121 + // Apache reports as a 500 on every request while `httpd -t` stays
1122 + // green (.htaccess is parsed per request, not at load).
1123 + if ( ! is_string( $name ) || ! preg_match( '/^[A-Za-z0-9-]+\z/', $name ) ) {
1124 + continue;
1125 + }
1126 + if ( ! is_string( $value ) && ! is_numeric( $value ) ) {
1127 + continue;
1128 + }
1129 + $value = trim( str_replace( array( "\r", "\n" ), '', (string) $value ) );
1130 + if ( '' === $value ) {
1131 + continue;
1132 + }
1133 + // `$` is a variable reference in an nginx string and `%` is a
1134 + // format tag to Apache's mod_headers, which rejects an
1135 + // unrecognised one — in .htaccess that is a 500 on every request
1136 + // while `httpd -t` still reports OK, because .htaccess is parsed
1137 + // per request. `\` escapes the quote in the PHP literal baked into
1138 + // the drop-in. None of them can be escaped reliably in all three
1139 + // places at once, and nothing a cache reads needs any of them, so
1140 + // the value is dropped rather than mangled.
1141 + if ( preg_match( '/[$%\\\\]/', $value ) ) {
1142 + continue;
1143 + }
1144 + $clean[ $name ] = $value;
1145 + }
1146 +
1147 + return $clean;
1148 + }
1149 +
1150 + /**
448 1151 * Bypass gates that describe THE VISITOR rather than THIS REQUEST.
449 1152 *
450 1153 * Only these may be recorded in the bypass cookie. A visitor-scoped
451 1154 * verdict stays true for the visitor's next request — they are still
@@ -752,9 +1455,16 @@
752 1455
753 1456 // Static tree too, under the same gates finalize_buffer() applies —
754 1457 // otherwise deferring the write would silently cost translated pages
755 1458 // the web-server fast path and leave them on the slower drop-in.
756 - if ( self::static_rewrite_allowed() && self::response_is_plain_html() ) {
1459 + // The static tree cannot replay a sidecar. A file served straight by
1460 + // the web server carries the headers baked into the rule that serves
1461 + // the whole site — the very answer this entry exists because it
1462 + // disagreed with. Same reasoning as the status and content-type
1463 + // cases: what the fast path cannot replay belongs on the drop-in path.
1464 + if ( self::static_rewrite_allowed()
1465 + && self::response_is_plain_html()
1466 + && array() === self::per_entry_edge_headers() ) {
757 1467 self::store_static( $full );
758 1468 }
759 1469 }
760 1470
@@ -761,10 +1471,17 @@
761 1471 public static function should_cache() {
762 1472 // Reset first: a single request only reaches this once (the sole
763 1473 // caller is maybe_start_cache()), but tests and any future caller
764 1474 // must never inherit the previous request's verdict.
765 - self::$status_header = '';
766 - self::$bypass_reason = '';
1475 + self::$status_header = '';
1476 + self::$bypass_reason = '';
1477 + self::$edge_headers = array();
1478 + self::$per_entry_edge = null;
1479 + // Under PHP-FPM a process serves one request and this is moot. Under
1480 + // a persistent worker runtime it is not: without it, an answer
1481 + // resolved from one visitor's forgeable headers would be reused for
1482 + // every later request the worker handles.
1483 + Edge_Provider::forget();
767 1484
768 1485 $opts = Settings::get();
769 1486 if ( empty( $opts['cache_enabled'] ) ) {
770 1487 return self::bypass( 'cache-disabled' );
@@ -815,8 +1532,20 @@
815 1532 * @param bool $cache_feed Whether to cache this feed request.
816 1533 */
817 1534 $cache_feed = $is_feed_request && (bool) apply_filters( 'xspeed_should_cache_feed', false );
818 1535
1536 + // WordPress's virtual robots.txt (and virtual favicon) are not HTML:
1537 + // caching one runs it through the whole HTML pipeline, which stamped
1538 + // the footer comment onto text/plain and let HTML minification
1539 + // collapse robots.txt to a single line — a line-based format, so
1540 + // every directive after the first was lost and crawlers read an
1541 + // invalid file. No opt-in filter here: there is no correct way to
1542 + // treat these as pages. (Reported live on a customer site.)
1543 + if ( ( function_exists( 'is_robots' ) && is_robots() )
1544 + || ( function_exists( 'is_favicon' ) && is_favicon() ) ) {
1545 + return self::bypass( 'non-html' );
1546 + }
1547 +
819 1548 // Query string handling: anything OUTSIDE the ignored-params
820 1549 // allow-list (utm_*, fbclid, gclid by default) means a unique
821 1550 // request that we don't want to share with the canonical cache
822 1551 // entry. Skip cache rather than poison the key.
@@ -1381,9 +2110,10 @@
1381 2110 * exactly this blog's pages.
1382 2111 */
1383 2112 public static function current_static_scope(): string {
1384 2113 // Same switch_to_blog() caveat as current_host_dir() — see current_host().
1385 - $dir = self::host_dir( self::current_host() );
2114 + // Keep the port folded into the segment exactly as store_static() does.
2115 + $dir = self::static_host_dir( self::current_host() );
1386 2116 if ( '' === $dir ) {
1387 2117 $dir = 'default';
1388 2118 }
1389 2119 $path = self::site_path_raw();
@@ -2033,9 +2763,16 @@
2033 2763 // 200, FBS-82406) or a non-HTML content-type (a cached feed would go
2034 2764 // out as text/html, FBS-82407). The web server serves these .html files
2035 2765 // directly with no PHP, so there's no .meta replay — keep them on the
2036 2766 // drop-in / PHP path instead, which DOES replay status + content-type.
2037 - if ( self::static_rewrite_allowed() && self::response_is_plain_html() ) {
2767 + // The static tree cannot replay a sidecar. A file served straight by
2768 + // the web server carries the headers baked into the rule that serves
2769 + // the whole site — the very answer this entry exists because it
2770 + // disagreed with. Same reasoning as the status and content-type
2771 + // cases: what the fast path cannot replay belongs on the drop-in path.
2772 + if ( self::static_rewrite_allowed()
2773 + && self::response_is_plain_html()
2774 + && array() === self::per_entry_edge_headers() ) {
2038 2775 self::store_static( $full );
2039 2776 }
2040 2777
2041 2778 return $buffer;
@@ -2566,8 +3303,19 @@
2566 3303 if ( $ttl > 0 && $ttl !== $default_ttl ) {
2567 3304 $meta['ttl'] = $ttl;
2568 3305 }
2569 3306
3307 + // This entry's edge headers, when they differ from the site-wide set
3308 + // baked into the drop-in. The sidecar is the only channel that can
3309 + // carry a per-page answer into the pre-boot fast path, and the drop-in
3310 + // REPLACES the baked set with it rather than merging: the two describe
3311 + // the same response, so merging would leave the baked lifetime in
3312 + // place beside the hold meant to overrule it.
3313 + $edge = self::per_entry_edge_headers();
3314 + if ( array() !== $edge ) {
3315 + $meta['edge_headers'] = $edge;
3316 + }
3317 +
2570 3318 // Nothing to replay → no sidecar.
2571 3319 if ( empty( $meta ) ) {
2572 3320 return;
2573 3321 }
@@ -2611,8 +3359,101 @@
2611 3359 * TTL — up to 30 days at the maximum lifetime. (#270 regression)
2612 3360 *
2613 3361 * @return string[]
2614 3362 */
3363 + /**
3364 + * Could this post change alter anything an anonymous visitor had cached?
3365 + *
3366 + * Deleting one post fired a full purge for the post AND for every stored
3367 + * revision, because wp_delete_post() removes each revision through
3368 + * wp_delete_post() again and every one of those fires before_delete_post
3369 + * with post_type 'revision'. A post with six revisions cost seven whole-
3370 + * site sweeps, each one also announcing to LiteSpeed, purging the object
3371 + * cache network-wide on Redis, rewriting the stats option and running
3372 + * every xspeed_after_purge_all listener -- including Pro's Cloudflare
3373 + * purge, so seven API calls. Trashing cost two, via save_post and then
3374 + * trashed_post. (QA #348)
3375 + *
3376 + * The check lives here, ahead of purge_all(), so one early return covers
3377 + * the local sweep, the server-cache announcement and both action hooks.
3378 + * It deliberately does NOT live inside purge_all(): a manual, CLI or
3379 + * explicit caller asked for a purge and must get one.
3380 + *
3381 + * @param int $post_id Post being saved or removed.
3382 + * @param mixed $post Post object when the hook passed one.
3383 + * @param string $event 'save' or 'remove'.
3384 + */
3385 + private static function post_change_is_cacheable_content( $post_id, $post, string $event ): bool {
3386 + $post_id = (int) $post_id;
3387 +
3388 + // Only `save_post` and `before_delete_post` hand over a post object.
3389 + // `trashed_post` passes ( $post_id, $previous_status ) -- a STRING --
3390 + // so reaching for ->post_status on the second argument finds nothing
3391 + // and the status rule below would never fire. Read the row instead.
3392 + if ( ! is_object( $post ) && function_exists( 'get_post' ) ) {
3393 + $post = get_post( $post_id );
3394 + }
3395 +
3396 + $type = is_object( $post ) && isset( $post->post_type )
3397 + ? (string) $post->post_type
3398 + : (string) ( function_exists( 'get_post_type' ) ? get_post_type( $post_id ) : '' );
3399 + if ( '' === $type ) {
3400 + return false;
3401 + }
3402 +
3403 + // A revision is a copy of content nobody can browse to.
3404 + if ( 'revision' === $type ) {
3405 + return false;
3406 + }
3407 + if ( function_exists( 'wp_is_post_revision' ) && wp_is_post_revision( $post_id ) ) {
3408 + return false;
3409 + }
3410 + if ( function_exists( 'wp_is_post_autosave' ) && wp_is_post_autosave( $post_id ) ) {
3411 + return false;
3412 + }
3413 +
3414 + $status = is_object( $post ) && isset( $post->post_status ) ? (string) $post->post_status : '';
3415 +
3416 + // Clicking "Add New" inserts an auto-draft and fires save_post. There
3417 + // is nothing cached of a post that has never existed publicly.
3418 + if ( 'auto-draft' === $status ) {
3419 + return false;
3420 + }
3421 +
3422 + // Unknown/!viewable → nothing anonymous can see changed, UNLESS the
3423 + // type is itself part of how pages render (#270 regression).
3424 + if ( function_exists( 'is_post_type_viewable' )
3425 + && ! is_post_type_viewable( $type )
3426 + && ! in_array( $type, self::presentation_post_types(), true )
3427 + ) {
3428 + return false;
3429 + }
3430 +
3431 + // Deleting something that was already invisible changes no cached
3432 + // page: the transition that hid it purged at the time. This is what
3433 + // makes emptying a trash of a hundred posts cost nothing rather than
3434 + // a hundred full sweeps.
3435 + //
3436 + // It also collapses trashing to a single purge: wp_trash_post() fires
3437 + // save_post first, where the post is genuinely disappearing from
3438 + // listings and SHOULD purge, then trashed_post, by which point the
3439 + // row reads 'trash' and is skipped. A status we cannot read, on a row
3440 + // that still reports a type, means assume viewable -- erring toward
3441 + // an extra purge, never toward serving a stale page. A row that is
3442 + // gone entirely reports no type either and was refused above.
3443 + // 'inherit' is an INTERNAL status in core, so is_post_status_viewable()
3444 + // says no -- but an attachment carrying it is genuinely public. Judge
3445 + // those on the post type alone, which is already checked above.
3446 + if ( 'remove' === $event && '' !== $status && 'inherit' !== $status
3447 + && function_exists( 'is_post_status_viewable' )
3448 + && ! is_post_status_viewable( $status )
3449 + ) {
3450 + return false;
3451 + }
3452 +
3453 + return true;
3454 + }
3455 +
2615 3456 public static function presentation_post_types(): array {
2616 3457 $types = array(
2617 3458 'wp_template', // Site Editor templates.
2618 3459 'wp_template_part', // Header / footer / reusable parts.
@@ -2633,8 +3474,37 @@
2633 3474 return (array) apply_filters( 'xspeed_presentation_post_types', $types );
2634 3475 }
2635 3476
2636 3477 /**
3478 + * Describe a broad hook invalidation for response-cache adapters.
3479 + *
3480 + * Term, menu, theme and plugin changes can alter navigation, archives or
3481 + * markup across the site, so they require a site response-cache purge.
3482 + * Content saves also require this scope while their local operation is a
3483 + * complete bucket sweep.
3484 + *
3485 + * @return array{scope:string,intent:string,urls:array<int,string>}
3486 + */
3487 + private static function invalidation_for_hook( string $hook ): array {
3488 + $presentation = array(
3489 + 'switch_theme',
3490 + 'activated_plugin',
3491 + 'deactivated_plugin',
3492 + 'created_term',
3493 + 'edited_term',
3494 + 'delete_term',
3495 + 'wp_update_nav_menu',
3496 + );
3497 +
3498 + return array(
3499 + 'scope' => 'site',
3500 + 'intent' => in_array( $hook, $presentation, true ) ? 'presentation' : 'content',
3501 + 'urls' => array(),
3502 + );
3503 + }
3504 +
3505 +
3506 + /**
2637 3507 * save_post → purge only when the saved thing can appear on a cached page.
2638 3508 *
2639 3509 * Revisions and autosaves are never rendered. Non-viewable post types —
2640 3510 * WooCommerce's `shop_order` / `shop_order_placehold` / `shop_order_refund`
@@ -2649,35 +3519,32 @@
2649 3519 * @param int $post_id Saved post ID.
2650 3520 * @param \WP_Post $post Saved post object.
2651 3521 */
2652 3522 public static function on_save_post( $post_id, $post = null ): void {
2653 - if ( function_exists( 'wp_is_post_revision' ) && wp_is_post_revision( $post_id ) ) {
3523 + if ( ! self::post_change_is_cacheable_content( $post_id, $post, 'save' ) ) {
2654 3524 return;
2655 3525 }
2656 - if ( function_exists( 'wp_is_post_autosave' ) && wp_is_post_autosave( $post_id ) ) {
2657 - return;
2658 - }
2659 3526
2660 3527 $post_type = is_object( $post ) && isset( $post->post_type )
2661 3528 ? (string) $post->post_type
2662 3529 : (string) get_post_type( $post_id );
2663 - if ( '' === $post_type ) {
2664 - return;
2665 - }
2666 3530
2667 - // Unknown/!viewable → nothing anonymous can see changed, UNLESS the
2668 - // type is itself part of how pages render (#270 regression).
2669 - if ( function_exists( 'is_post_type_viewable' )
2670 - && ! is_post_type_viewable( $post_type )
2671 - && ! in_array( $post_type, self::presentation_post_types(), true )
2672 - ) {
2673 - return;
2674 - }
2675 -
2676 3531 // Name the trigger rather than logging a bare numeric id — the old
2677 3532 // wiring passed the post ID into $cause, so the log read
2678 3533 // "Cache purged (46)" with no indication of what caused it. (#243)
2679 - self::purge_all( 'post:' . $post_type );
3534 + $presentation = in_array( $post_type, self::presentation_post_types(), true );
3535 + self::purge_all(
3536 + 'post:' . $post_type,
3537 + null,
3538 + array(
3539 + // purge_all() sweeps every local response in this site's bucket.
3540 + // Without dependency tracking, the server cache must match that
3541 + // same boundary or unrelated pages can remain stale there.
3542 + 'scope' => 'site',
3543 + 'intent' => $presentation ? 'presentation' : 'content',
3544 + 'urls' => array(),
3545 + )
3546 + );
2680 3547 if ( class_exists( '\XSpeed\Minifier' ) ) {
2681 3548 Minifier::purge_minified();
2682 3549 }
2683 3550 }
@@ -2682,8 +3549,61 @@
2682 3549 }
2683 3550 }
2684 3551
2685 3552 /**
3553 + * Delete/trash invalidation while the post type is still available.
3554 + * The local and server response-cache sweeps share the same site boundary.
3555 + *
3556 + * @param int $post_id Removed post ID.
3557 + * @param object|null $post Post object supplied by core when available.
3558 + */
3559 + public static function on_post_removed( $post_id, $post = null ): void {
3560 + if ( ! self::post_change_is_cacheable_content( $post_id, $post, 'remove' ) ) {
3561 + return;
3562 + }
3563 +
3564 + $post_type = is_object( $post ) && isset( $post->post_type )
3565 + ? (string) $post->post_type
3566 + : (string) get_post_type( $post_id );
3567 +
3568 + self::purge_all(
3569 + 'post-removed:' . $post_type,
3570 + null,
3571 + array(
3572 + 'scope' => 'site',
3573 + // Match on_save_post: a presentation type changes how pages
3574 + // render rather than what they say.
3575 + 'intent' => in_array( $post_type, self::presentation_post_types(), true )
3576 + ? 'presentation'
3577 + : 'content',
3578 + 'urls' => array(),
3579 + )
3580 + );
3581 + }
3582 +
3583 + /** Purge site responses when moderation changes visible comments. */
3584 + public static function on_comment_status( $comment_id, $status = '' ): void {
3585 + $comment = function_exists( 'get_comment' ) ? get_comment( (int) $comment_id ) : null;
3586 + $post_id = is_object( $comment ) && isset( $comment->comment_post_ID ) ? (int) $comment->comment_post_ID : 0;
3587 + if ( $post_id < 1 || ! function_exists( 'get_permalink' ) ) {
3588 + return;
3589 + }
3590 + $url = get_permalink( $post_id );
3591 + if ( ! is_string( $url ) || '' === $url ) {
3592 + return;
3593 + }
3594 + self::purge_all(
3595 + 'comment-status:' . (string) $status,
3596 + null,
3597 + array(
3598 + 'scope' => 'site',
3599 + 'intent' => 'content',
3600 + 'urls' => array(),
3601 + )
3602 + );
3603 + }
3604 +
3605 + /**
2686 3606 * comment_post → purge just the commented-on URL, and only once the
2687 3607 * comment is actually visible.
2688 3608 *
2689 3609 * A comment held for moderation changes nothing on the front end, and an
@@ -2834,13 +3754,304 @@
2834 3754 public static function purge_product_object( $product ): void {
2835 3755 self::purge_product( $product );
2836 3756 }
2837 3757
3758 + /**
3759 + * Re-entry guard for the purge-event contract.
3760 + *
3761 + * A listener on `xspeed_after_purge_url` legitimately purges its own
3762 + * layer, and a server-cache or CDN adapter that calls back into xSpeed
3763 + * while doing so re-enters this method — unbounded, because each pass
3764 + * looks like a fresh purge.
3765 + *
3766 + * A single global flag stops too much: a nested purge of a DIFFERENT URL is
3767 + * a real purge whose listeners must hear about it. But a per-request
3768 + * "already published" set stops too much in the other direction — a
3769 + * network purge loops every blog in one request, and on a subdirectory
3770 + * network they share a host, so blogs 2..N would be silently skipped. It
3771 + * also grows for the life of the process.
3772 + *
3773 + * So the guard tracks what is IN FLIGHT, not what has been published: a
3774 + * target is marked while its own dispatch is on the stack and unmarked
3775 + * when it returns. Re-entering the same target recurses, so it is refused;
3776 + * purging the same URL again later is a new event and publishes. The set
3777 + * is bounded by call depth rather than by how many URLs a request touches.
3778 + *
3779 + * @var array<string,bool>
3780 + */
3781 + private static $purge_events_in_flight = array();
3782 +
3783 + /** Monotonic count used to detect whether a delegated purge published. */
3784 + private static $purge_event_sequence = 0;
3785 +
3786 + /**
3787 + * Publish a purge event exactly once, with bounded arguments.
3788 + *
3789 + * Deliberately carries only what an integration needs to invalidate its
3790 + * own copy: the canonical URL (or null for a full purge), the site host,
3791 + * the cause label, and how many files went. No filesystem paths, no cache
3792 + * contents, no request headers, no user data. The URL query and caller-
3793 + * supplied cause may nevertheless contain sensitive text, so listeners
3794 + * must redact them in logs or unrelated destinations that do not need the
3795 + * exact cache key.
3796 + *
3797 + * A listener that throws must not take the purge down with it: the files
3798 + * are already gone by the time we get here, and an integration's bad day
3799 + * is not a reason to report a failed purge to the caller.
3800 + *
3801 + * @param string $hook Hook name to emit.
3802 + * @param array<string,mixed> $context Bounded context, see above.
3803 + */
3804 + private static function dispatch_purge_event( string $hook, array $context ): void {
3805 + if ( ! function_exists( 'do_action' ) ) {
3806 + return;
3807 + }
3808 + $target = $hook . '|' . ( isset( $context['url'] ) ? (string) $context['url'] : '' )
3809 + . '|' . ( isset( $context['host'] ) ? (string) $context['host'] : '' );
3810 + if ( isset( self::$purge_events_in_flight[ $target ] ) ) {
3811 + return;
3812 + }
3813 + self::$purge_events_in_flight[ $target ] = true;
3814 + ++self::$purge_event_sequence;
3815 +
3816 + // Our own integrations get their own try. Sharing one with the public
3817 + // action below meant a listener on the extension seam could throw and
3818 + // take the contract event down with it — the mirror of the failure
3819 + // this separation exists to prevent.
3820 + try {
3821 + // Built-in server-cache integrations run FIRST, and by a direct
3822 + // call rather than as listeners on the action below.
3823 + //
3824 + // WordPress stops dispatching an action's remaining callbacks when
3825 + // one of them throws. As a listener, our LiteSpeed forwarding
3826 + // would then be skipped by any unrelated third-party callback that
3827 + // happened to be registered earlier and blew up — and the visible
3828 + // result is the worst kind: xSpeed reports a successful purge while
3829 + // the server keeps serving stale HTML. Shipped behaviour must not
3830 + // be hostage to a listener's bug.
3831 + self::forward_to_server_caches( $context );
3832 + } catch ( \Throwable $e ) {
3833 + self::log_purge_listener_error( $hook, $e );
3834 + }
3835 +
3836 + try {
3837 + self::do_action_isolated( $hook, $context );
3838 + } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
3839 + // Swallow: see docblock. The purge succeeded regardless.
3840 + self::log_purge_listener_error( $hook, $e );
3841 + } finally {
3842 + unset( self::$purge_events_in_flight[ $target ] );
3843 + }
3844 + }
3845 +
3846 + /**
3847 + * Run every listener on a purge hook, isolating each from the others.
3848 + *
3849 + * `do_action()` dispatches callbacks in one loop, so the first one to
3850 + * throw takes every LATER listener down with it. On a purge that meant a
3851 + * failing CDN integration silently cancelled the ones queued behind it —
3852 + * and because the throw was swallowed to keep the purge itself succeeding,
3853 + * the user was told the clear worked while two edges were never touched.
3854 + * Invisible unless WP_DEBUG happened to be on. (QA #348)
3855 + *
3856 + * Each callback gets its own try/catch here, so one integration's bad day
3857 + * costs only that integration. Priority order is preserved. Falls back to
3858 + * a plain `do_action()` when the filter registry is not the shape we
3859 + * expect, so an unusual environment degrades to the old behaviour rather
3860 + * than skipping listeners entirely.
3861 + *
3862 + * @param string $hook Hook name to emit.
3863 + * @param mixed $arg Single argument passed to each listener.
3864 + */
3865 + public static function do_action_isolated( string $hook, $arg ): void {
3866 + global $wp_filter;
3867 +
3868 + // Walking $wp_filter by hand and calling each callback directly was the
3869 + // obvious way to do this, and it was wrong: it bypasses WordPress, so
3870 + // `current_filter()` came back empty, `did_action()` stayed at 0, the
3871 + // `all` hook never fired, and Query Monitor and Debug Bar could not see
3872 + // the very contract this class publishes. A shared handler branching on
3873 + // current_filter() picked the wrong branch. (QA #348 round 2, issue 3)
3874 + //
3875 + // So let do_action() dispatch — WordPress keeps its bookkeeping — and
3876 + // isolate one level down instead: each registered callback is swapped
3877 + // for a wrapper that runs it inside a try/catch. One listener throwing
3878 + // then costs only that listener, which is the whole point, without
3879 + // costing the hook its identity.
3880 + if ( ! isset( $wp_filter[ $hook ] ) || ! ( $wp_filter[ $hook ] instanceof \WP_Hook ) ) {
3881 + do_action( $hook, $arg );
3882 + return;
3883 + }
3884 +
3885 + $hook_object = $wp_filter[ $hook ];
3886 + $original = $hook_object->callbacks;
3887 + if ( ! is_array( $original ) || array() === $original ) {
3888 + do_action( $hook, $arg );
3889 + return;
3890 + }
3891 +
3892 + $wrapped = array();
3893 + $restorations = array();
3894 + foreach ( $original as $priority => $group ) {
3895 + if ( ! is_array( $group ) ) {
3896 + $wrapped[ $priority ] = $group;
3897 + continue;
3898 + }
3899 + foreach ( $group as $id => $registered ) {
3900 + if ( ! isset( $registered['function'] ) || ! is_callable( $registered['function'] ) ) {
3901 + $wrapped[ $priority ][ $id ] = $registered;
3902 + continue;
3903 + }
3904 + $callback = $registered['function'];
3905 + $wrapper = static function ( ...$args ) use ( $callback, $hook ) {
3906 + try {
3907 + return $callback( ...$args );
3908 + } catch ( \Throwable $e ) {
3909 + self::log_purge_listener_error( $hook, $e );
3910 + return null;
3911 + }
3912 + };
3913 + $wrapped[ $priority ][ $id ] = array(
3914 + // Keep accepted_args: a listener registered for 0 or 1
3915 + // arguments must still be called the way it asked.
3916 + 'accepted_args' => $registered['accepted_args'] ?? 1,
3917 + 'function' => $wrapper,
3918 + );
3919 + $restorations[ $priority ][ $id ] = array(
3920 + 'original' => $registered,
3921 + 'wrapper' => $wrapper,
3922 + );
3923 + }
3924 + }
3925 +
3926 + $hook_object->callbacks = $wrapped;
3927 + try {
3928 + do_action( $hook, $arg );
3929 + } finally {
3930 + // Restore only wrappers still present. Native add/remove operations
3931 + // performed by listeners must survive this temporary substitution.
3932 + foreach ( $restorations as $priority => $group ) {
3933 + foreach ( $group as $id => $restore ) {
3934 + $current = $hook_object->callbacks[ $priority ][ $id ]['function'] ?? null;
3935 + if ( $current === $restore['wrapper'] ) {
3936 + $hook_object->callbacks[ $priority ][ $id ] = $restore['original'];
3937 + }
3938 + }
3939 + }
3940 + }
3941 + }
3942 +
3943 + /**
3944 + * Name a listener that threw, under WP_DEBUG only.
3945 + *
3946 + * Gated like the rest of Free's diagnostics: a third-party listener
3947 + * throwing on every purge must not fill a production log.
3948 + */
3949 + private static function log_purge_listener_error( string $hook, \Throwable $e ): void {
3950 + // An \Error — a TypeError from one of OUR listeners, say — is a bug
3951 + // rather than a runtime condition a third party imposed on us, and
3952 + // swallowing it silently in production turns it into a purge that
3953 + // quietly stops working. Those are logged whatever WP_DEBUG says;
3954 + // third-party \Exceptions stay gated so a noisy integration cannot
3955 + // fill a production log.
3956 + $always = $e instanceof \Error;
3957 + if ( ( $always || ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ) && function_exists( 'error_log' ) ) {
3958 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- names a third-party listener that threw during a purge.
3959 + error_log( '[xspeed] a ' . $hook . ' listener threw: ' . $e->getMessage() );
3960 + }
3961 + }
3962 +
3963 + /** Test seam: clear the in-flight set left behind by an aborted dispatch. */
3964 + public static function reset_purge_events(): void {
3965 + self::$purge_events_in_flight = array();
3966 + self::$purge_event_sequence = 0;
3967 + }
3968 +
3969 + /**
3970 + * Hand the purge to the caches we ship integrations for.
3971 + *
3972 + * Isolated from the public action on purpose — see dispatch_purge_event().
3973 + * Guarded so a missing class (a partial upgrade, a stripped build) cannot
3974 + * turn a working purge into a fatal.
3975 + *
3976 + * @param array<string,mixed> $context Bounded purge context.
3977 + */
3978 + private static function forward_to_server_caches( array $context ): void {
3979 + if ( class_exists( __NAMESPACE__ . '\\Server_Caches' ) ) {
3980 + Server_Caches::forward( $context );
3981 + }
3982 + }
3983 +
3984 + /**
3985 + * `host[:port]` for a cache key, from a parsed URL.
3986 + *
3987 + * The port is kept, because `cache_key()` hashes the raw `HTTP_HOST` and
3988 + * that carries `:8080` on any install not served from 80/443 — dropping it
3989 + * computed a different md5, found no file, and reported "already cold"
3990 + * while the page kept serving HIT.
3991 + *
3992 + * A port that is the DEFAULT for the scheme is dropped, though, because
3993 + * `HTTP_HOST` does not carry one: a browser sends `Host: site.com` for
3994 + * `https://site.com:443/`. Keeping it hashed `site.com:443` against a file
3995 + * stored under `site.com` — the same silent no-op in the other direction,
3996 + * and the one QA hit passing a canonical URL with the port spelled out.
3997 + * (QA #348)
3998 + *
3999 + * @param array<string,mixed> $parts Output of wp_parse_url().
4000 + */
4001 + private static function host_port_of( array $parts ): string {
4002 + if ( ! isset( $parts['host'] ) ) {
4003 + return '';
4004 + }
4005 + $host = strtolower( (string) $parts['host'] );
4006 + if ( '' === $host || ! isset( $parts['port'] ) ) {
4007 + return $host;
4008 + }
4009 + $port = (int) $parts['port'];
4010 + $scheme = isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : '';
4011 + if ( ( 'https' === $scheme && 443 === $port ) || ( 'http' === $scheme && 80 === $port ) ) {
4012 + return $host;
4013 + }
4014 + return $host . ':' . $port;
4015 + }
4016 +
2838 4017 public static function purge_url( string $url, string $cause = 'manual' ): int {
4018 + // A URL that names nothing is not a purge of everything. An empty or
4019 + // blank string used to fall through to the home_url() default below
4020 + // and clear the HOMEPAGE — so a third party calling
4021 + // `purge_url( get_permalink( $id ) )` on a post whose permalink came
4022 + // back empty silently purged the front page instead of nothing. The
4023 + // CLI and the MCP tool reject empties before reaching this, so only
4024 + // direct API callers were exposed, but they are exactly the audience
4025 + // this public contract is for. (QA #348)
4026 + if ( '' === trim( $url ) ) {
4027 + return 0;
4028 + }
2839 4029 $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.
2840 4030 if ( ! is_array( $parts ) ) {
2841 4031 return 0;
2842 4032 }
4033 + // Absolute URLs are accepted only for HTTP response caches. Schemes such
4034 + // as ftp:, file: and javascript: can parse cleanly but do not name a page
4035 + // xSpeed or a server response cache can invalidate. A leading-slash path
4036 + // remains a supported site-relative target.
4037 + if ( isset( $parts['scheme'] ) && ! in_array( strtolower( (string) $parts['scheme'] ), array( 'http', 'https' ), true ) ) {
4038 + return 0;
4039 + }
4040 + if ( isset( $parts['scheme'] ) && empty( $parts['host'] ) ) {
4041 + return 0;
4042 + }
4043 + // Reject a string that parsed but is not a URL we can act on: no
4044 + // scheme AND no host AND no leading-slash path means something like
4045 + // `ht!tp://[[[` or a bare word, which parse_url() hands back as a
4046 + // relative "path". Forwarding that produced `purge_url(/ht!tp://[[[)`
4047 + // — a nonsense tag sent to LiteSpeed for every malformed call.
4048 + if ( ! isset( $parts['scheme'] ) && ! isset( $parts['host'] ) ) {
4049 + $raw = isset( $parts['path'] ) ? (string) $parts['path'] : '';
4050 + if ( '' === $raw || '/' !== $raw[0] ) {
4051 + return 0;
4052 + }
4053 + }
2843 4054 // Keep the port. `cache_key()` hashes the raw `HTTP_HOST`, which
2844 4055 // carries `:8080` on any install not served from 80/443 — while
2845 4056 // parse_url() splits the port into its own component, so a purge that
2846 4057 // used the bare host computed a different md5, found no file, and
@@ -2846,19 +4057,34 @@
2846 4057 // used the bare host computed a different md5, found no file, and
2847 4058 // reported "already cold". A silent no-op: the page kept serving HIT
2848 4059 // until its TTL ran out. Intranet installs, panel hosts on :8443 and
2849 4060 // proxies that forward `Host: site.com:8080` all hit this.
2850 - $host = isset( $parts['host'] ) ? strtolower( (string) $parts['host'] ) : '';
2851 - if ( '' !== $host && isset( $parts['port'] ) ) {
2852 - $host .= ':' . (int) $parts['port'];
4061 + // A scheme-less `site.test:443/page/` is a supported explicit-host
4062 + // target. Infer a scheme only when it names THIS site's hostname: then
4063 + // its explicit default port is the same origin and the same local cache
4064 + // key. Never apply this to another host or to a non-default port.
4065 + if ( ! isset( $parts['scheme'] ) && isset( $parts['host'], $parts['port'] ) && function_exists( 'home_url' ) ) {
4066 + $home = function_exists( 'wp_parse_url' ) ? wp_parse_url( home_url( '/' ) ) : parse_url( home_url( '/' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- see above.
4067 + if ( is_array( $home ) && ! empty( $home['host'] ) && ! empty( $home['scheme'] )
4068 + && strtolower( (string) $home['host'] ) === strtolower( (string) $parts['host'] )
4069 + ) {
4070 + $home_scheme = strtolower( (string) $home['scheme'] );
4071 + $port = (int) $parts['port'];
4072 + $home_port = isset( $home['port'] )
4073 + ? (int) $home['port']
4074 + : ( 'https' === $home_scheme ? 443 : ( 'http' === $home_scheme ? 80 : 0 ) );
4075 + if ( $home_port === $port
4076 + && ( ( 'https' === $home_scheme && 443 === $port ) || ( 'http' === $home_scheme && 80 === $port ) )
4077 + ) {
4078 + $parts['scheme'] = $home_scheme;
4079 + }
4080 + }
2853 4081 }
4082 + $host = self::host_port_of( $parts );
2854 4083 if ( '' === $host && function_exists( 'home_url' ) ) {
2855 4084 $home = function_exists( 'wp_parse_url' ) ? wp_parse_url( home_url( '/' ) ) : parse_url( home_url( '/' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- see above.
2856 - if ( is_array( $home ) && isset( $home['host'] ) ) {
2857 - $host = strtolower( (string) $home['host'] );
2858 - if ( isset( $home['port'] ) ) {
2859 - $host .= ':' . (int) $home['port'];
2860 - }
4085 + if ( is_array( $home ) ) {
4086 + $host = self::host_port_of( $home );
2861 4087 }
2862 4088 }
2863 4089 if ( '' === $host ) {
2864 4090 return 0;
@@ -2942,14 +4168,120 @@
2942 4168 Activity_Log::INFO
2943 4169 );
2944 4170 }
2945 4171
4172 + /**
4173 + * Fires after one URL's cached copy has been purged.
4174 + *
4175 + * The single-URL counterpart to `xspeed_after_purge_all`. Subscribe
4176 + * here to invalidate a cache xSpeed does not own — a server-level
4177 + * cache such as LiteSpeed's LSCache, a reverse proxy, or a CDN — for
4178 + * the same URL.
4179 + *
4180 + * Only fires when the purge actually ran. A malformed URL, a URL with
4181 + * no resolvable host, or a traversal attempt returns earlier and
4182 + * publishes nothing, so a listener can treat this as "xSpeed purged
4183 + * this URL" rather than "xSpeed was asked to". `removed` may legitimately
4184 + * be 0: the URL was not in xSpeed's cache, which says nothing about
4185 + * whether it is in yours.
4186 + *
4187 + * Fires at most once per purge. A listener that calls back into
4188 + * xSpeed's purge API will not re-enter this event.
4189 + *
4190 + * @since 1.2.3
4191 + *
4192 + * @param array $context {
4193 + * Bounded description of the purge. URL queries and caller-supplied
4194 + * causes can contain sensitive values and are not logging fields.
4195 + *
4196 + * @type string $url Canonical scheme://host/path[?query] of the purged URL.
4197 + * The query is preserved because caches in front
4198 + * commonly key on it; xSpeed's own sweep is
4199 + * path-based, so `removed` describes that.
4200 + * @type string $host Host (with port when non-standard).
4201 + * @type string $path Path component, leading slash.
4202 + * @type string $cause Short label for who asked. See purge_all().
4203 + * @type int $removed Number of cache files removed.
4204 + * @type string $scope Actionable adapter scope: `urls`.
4205 + * @type string $intent Why responses changed: `content`.
4206 + * @type string[] $urls Exact response URLs to invalidate.
4207 + * }
4208 + */
4209 + $canonical_url = self::canonical_purge_url(
4210 + $host,
4211 + $path,
4212 + isset( $parts['query'] ) ? (string) $parts['query'] : '',
4213 + isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : ''
4214 + );
4215 + self::dispatch_purge_event(
4216 + 'xspeed_after_purge_url',
4217 + array(
4218 + 'url' => $canonical_url,
4219 + 'host' => $host,
4220 + 'path' => $path,
4221 + 'cause' => $cause,
4222 + 'removed' => $count,
4223 + 'scope' => 'urls',
4224 + 'intent' => 'content',
4225 + 'urls' => array( $canonical_url ),
4226 + )
4227 + );
4228 +
2946 4229 return $count;
2947 4230 }
2948 4231
4232 + /** Host this site's purge is scoped to, for the purge-event context. */
4233 + private static function current_purge_host(): string {
4234 + if ( ! function_exists( 'home_url' ) ) {
4235 + return '';
4236 + }
4237 + $home = function_exists( 'wp_parse_url' ) ? wp_parse_url( home_url( '/' ) ) : parse_url( home_url( '/' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- host only.
4238 + if ( ! is_array( $home ) || empty( $home['host'] ) ) {
4239 + return '';
4240 + }
4241 + // Same default-port normalisation as purge_url(): a site whose
4242 + // home_url() carries `:443` (normal behind a proxy) otherwise stamps
4243 + // every full-purge event with a host that matches none of its own
4244 + // URLs, so the LiteSpeed forward stood down site-wide. (QA #348)
4245 + return self::host_port_of( $home );
4246 + }
4247 +
2949 4248 /**
2950 - * Purge this site's cache.
4249 + * Rebuild the canonical URL a purge applied to.
2951 4250 *
4251 + * Built from the parts the purge itself used, so a listener is told the
4252 + * URL we acted on rather than the string the caller happened to pass —
4253 + * those differ whenever the caller supplied a site-relative path, a
4254 + * different scheme, or a query string the cache key ignores.
4255 + */
4256 + private static function canonical_purge_url( string $host, string $path, string $query = '', string $url_scheme = '' ): string {
4257 + // The purged URL's own scheme wins. purge_url() explicitly supports
4258 + // cross-site purges (multisite, WP-CLI, cron), where composing the
4259 + // current site's scheme onto another site's host builds a URL that was
4260 + // never served — and a CDN listener then purges the wrong key and
4261 + // reports success.
4262 + if ( '' !== $url_scheme ) {
4263 + return $url_scheme . '://' . $host . $path . ( '' !== $query ? '?' . $query : '' );
4264 + }
4265 + $scheme = function_exists( 'is_ssl' ) && is_ssl() ? 'https' : 'http';
4266 + if ( function_exists( 'home_url' ) ) {
4267 + $home = function_exists( 'wp_parse_url' ) ? wp_parse_url( home_url( '/' ) ) : parse_url( home_url( '/' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- scheme only.
4268 + if ( is_array( $home ) && ! empty( $home['scheme'] ) ) {
4269 + $scheme = (string) $home['scheme'];
4270 + }
4271 + }
4272 + // The query is carried even though OUR sweep above is path-based.
4273 + // Caches in front commonly key on the full request line — LiteSpeed
4274 + // tags `/shop/?page=2` separately from `/shop/` — so publishing the
4275 + // bare path would have a listener confidently purge the wrong entry
4276 + // and report success. Telling it exactly what was asked for lets it
4277 + // act correctly; `removed` still describes only what WE removed.
4278 + return $scheme . '://' . $host . $path . ( '' !== $query ? '?' . $query : '' );
4279 + }
4280 +
4281 + /**
4282 + * Sweep this site's cache files.
4283 + *
2952 4284 * On multisite every blog shares one cache directory, so an unscoped
2953 4285 * sweep here took the whole network cold — one subsite's settings save
2954 4286 * or post publish rebuilt every other site from PHP. Entries are stored
2955 4287 * per host (see host_dir()), and the sweep is scoped to match, so a
@@ -2954,16 +4286,27 @@
2954 4286 * or post publish rebuilt every other site from PHP. Entries are stored
2955 4287 * per host (see host_dir()), and the sweep is scoped to match, so a
2956 4288 * purge originating on site-a leaves site-b's cache warm. (#6)
2957 4289 *
2958 - * @param string $cause Who asked, for the purge log.
2959 - * @param string|null $host Host to purge. Defaults to the current site.
2960 - * Pass '*' to sweep the ENTIRE tree — network
2961 - * admin's "purge all sites", and the migration
2962 - * of pre-#6 entries that sit in the tree root.
4290 + * Clears the files only: the flat tree, the static tree, the REST
4291 + * responses and the minified assets. The object-cache flush, the stats
4292 + * update, `xspeed_after_purge_all`, the `xspeed_after_purge` contract
4293 + * event and the log entry live in purge_all(), which is still the entry
4294 + * point for every existing caller. Split out so `wp xspeed purge` can
4295 + * report the local sweep as one line item and the object cache as
4296 + * another, each with its own status — see Purge_Runner.
4297 + *
4298 + * @param string|null $host Host to purge. Defaults to the current site.
4299 + * Pass '*' to sweep the ENTIRE tree — network
4300 + * admin's "purge all sites", and the migration
4301 + * of pre-#6 entries that sit in the tree root.
4302 + * @return array{pages:int,rest:int,assets:int,bytes:int} Entries removed
4303 + * per store, and the bytes freed by the two file sweeps
4304 + * that measure themselves.
2963 4305 */
2964 - public static function purge_all( string $cause = 'manual', ?string $host = null ) {
2965 - $network_wide = ( '*' === $host );
4306 + public static function purge_local( ?string $host = null ): array {
4307 + $network_wide = ( '*' === $host );
4308 + self::$sweep_bytes = 0;
2966 4309 // The flat tree buckets by a flattened segment (host/a-b) while the
2967 4310 // static tree mirrors the URL (host/a/b), so they need separate
2968 4311 // scopes — see current_host_dir() vs current_static_scope().
2969 4312 $static_scope = '';
@@ -2972,9 +4315,10 @@
2972 4315 $static_scope = $network_wide ? '' : self::current_static_scope();
2973 4316 } else {
2974 4317 $dir = self::host_dir( $host );
2975 4318 $scope = '' === $dir ? 'default' : $dir;
2976 - $static_scope = $scope;
4319 + $static_dir = self::static_host_dir( $host );
4320 + $static_scope = '' === $static_dir ? 'default' : $static_dir;
2977 4321 }
2978 4322
2979 4323 $count = 0;
2980 4324 if ( is_dir( XSPEED_CACHE_DIR ) ) {
@@ -3024,9 +4368,9 @@
3024 4368 $files = glob( $root . '/*.html' );
3025 4369 if ( $files ) {
3026 4370 $count += count( $files );
3027 4371 foreach ( $files as $f ) {
3028 - wp_delete_file( $f );
4372 + self::sweep_delete( $f );
3029 4373 }
3030 4374 }
3031 4375 // Remove the .meta sidecars (content-type for feeds/sitemaps)
3032 4376 // alongside their .html entries. Not counted — they're not
@@ -3033,9 +4377,9 @@
3033 4377 // cache "pages", just per-entry metadata.
3034 4378 $meta = glob( $root . '/*.meta' );
3035 4379 if ( $meta ) {
3036 4380 foreach ( $meta as $m ) {
3037 - wp_delete_file( $m );
4381 + self::sweep_delete( $m );
3038 4382 }
3039 4383 }
3040 4384 // Remove precompressed siblings (e.g. <key>.html.br from the Pro
3041 4385 // Brotli module). Not counted — same as .meta. Without this a
@@ -3043,9 +4387,9 @@
3043 4387 // staleness window if precompression is later disabled.
3044 4388 $br = glob( $root . '/*.br' );
3045 4389 if ( $br ) {
3046 4390 foreach ( $br as $b ) {
3047 - wp_delete_file( $b );
4391 + self::sweep_delete( $b );
3048 4392 }
3049 4393 }
3050 4394 // `*.br` does not match `*.br.size` — same reason as the flat-root
3051 4395 // sweep above: a size record outliving its body would later be
@@ -3052,9 +4396,9 @@
3052 4396 // read against a different sibling's bytes.
3053 4397 $br_size = glob( $root . '/*.br.size' );
3054 4398 if ( $br_size ) {
3055 4399 foreach ( $br_size as $b ) {
3056 - wp_delete_file( $b );
4400 + self::sweep_delete( $b );
3057 4401 }
3058 4402 }
3059 4403 }
3060 4404 }
@@ -3071,9 +4415,10 @@
3071 4415 }
3072 4416 }
3073 4417 // REST response cache (cache/xspeed/rest/*.json) — same purge
3074 4418 // triggers (publish, settings change) invalidate it too.
3075 - $count += Rest_Cache::purge();
4419 + $rest = Rest_Cache::purge();
4420 + $count += $rest;
3076 4421
3077 4422 // Minified + combined CSS/JS (cache/xspeed/min/ and min/combined/).
3078 4423 // purge_all is a full filesystem sweep and must clear these too, even
3079 4424 // when the Minify module is currently disabled — orphaned min/ files
@@ -3079,20 +4424,33 @@
3079 4424 // when the Minify module is currently disabled — orphaned min/ files
3080 4425 // from a feature the user later turned off must still be removed, and
3081 4426 // a stale combined-<hash>.css that the regenerated page no longer
3082 4427 // references otherwise 404s and breaks the frontend. (FBS-83114/83116)
3083 - if ( class_exists( '\\XSpeed\\Minifier' ) ) {
3084 - Minifier::purge_minified();
3085 - }
4428 + $assets = class_exists( '\\XSpeed\\Minifier' ) ? Minifier::purge_minified() : 0;
3086 4429
3087 - // Persistent object cache (Redis / Memcached). Flush regardless of
3088 - // whether the Object Cache module is currently enabled — a drop-in
3089 - // installed earlier keeps serving until flushed.
3090 - //
3091 - // wp_cache_flush() is NETWORK-global: on multisite it would drop
3092 - // every other site's object cache too, which is the same bug this
3093 - // change fixes for the page cache. Prefer the blog-scoped flush
3094 - // (WP 6.1+) unless we were explicitly asked to go network-wide. (#6)
4430 + return array(
4431 + 'pages' => $count - $rest,
4432 + 'rest' => $rest,
4433 + 'assets' => $assets,
4434 + 'bytes' => self::$sweep_bytes,
4435 + );
4436 + }
4437 +
4438 + /**
4439 + * Flush the persistent object cache (Redis / Memcached).
4440 + *
4441 + * Runs regardless of whether the Object Cache module is currently
4442 + * enabled — a drop-in installed earlier keeps serving until flushed.
4443 + *
4444 + * @param bool $network_wide Flush every blog's entries. wp_cache_flush()
4445 + * is NETWORK-global, so on multisite the
4446 + * default prefers the blog-scoped group flush
4447 + * (WP 6.1+) — otherwise one site's purge drops
4448 + * every other site's object cache, the same bug
4449 + * #6 fixed for the page cache.
4450 + * @return bool Whether a flush was actually performed.
4451 + */
4452 + public static function flush_object_cache( bool $network_wide = false ): bool {
3095 4453 if ( ! $network_wide && is_multisite() && function_exists( 'wp_cache_flush_group' ) && function_exists( 'wp_cache_supports' ) && wp_cache_supports( 'flush_group' ) ) {
3096 4454 // Blog-scoped groups only; a shared/global group (site options,
3097 4455 // user meta) is intentionally left alone.
3098 4456 foreach ( array( 'options', 'posts', 'terms', 'post_meta', 'comment' ) as $group ) {
@@ -3097,12 +4455,62 @@
3097 4455 // user meta) is intentionally left alone.
3098 4456 foreach ( array( 'options', 'posts', 'terms', 'post_meta', 'comment' ) as $group ) {
3099 4457 wp_cache_flush_group( $group );
3100 4458 }
3101 - } elseif ( function_exists( 'wp_cache_flush' ) ) {
3102 - wp_cache_flush();
4459 + return true;
3103 4460 }
4461 + if ( function_exists( 'wp_cache_flush' ) ) {
4462 + return (bool) wp_cache_flush();
4463 + }
4464 + return false;
4465 + }
3104 4466
4467 + /**
4468 + * Purge this site's cache: the local sweep, then the object cache, then
4469 + * the bookkeeping every caller expects (stats, `xspeed_after_purge_all`,
4470 + * inventory invalidation, purge log).
4471 + *
4472 + * @param string $cause Who asked, for the purge log.
4473 + * @param string|null $host See purge_local().
4474 + * @param array<string,mixed> $invalidation Public adapter policy. `scope`
4475 + * is urls/site/network/none,
4476 + * `intent` explains why, and
4477 + * `urls` supplies exact targets.
4478 + * @return int Page + REST entries removed.
4479 + */
4480 + public static function purge_all( string $cause = 'manual', ?string $host = null, array $invalidation = array() ) {
4481 + $network_wide = ( '*' === $host );
4482 + $adapter_scope = isset( $invalidation['scope'] ) && is_string( $invalidation['scope'] )
4483 + ? $invalidation['scope']
4484 + : ( $network_wide ? 'network' : 'site' );
4485 + if ( ! in_array( $adapter_scope, array( 'urls', 'site', 'network', 'none' ), true ) ) {
4486 + $adapter_scope = $network_wide ? 'network' : 'site';
4487 + }
4488 + if ( $network_wide ) {
4489 + $adapter_scope = 'network';
4490 + }
4491 + $intent = isset( $invalidation['intent'] ) && is_string( $invalidation['intent'] ) && '' !== $invalidation['intent']
4492 + ? $invalidation['intent']
4493 + : 'complete';
4494 + $urls = isset( $invalidation['urls'] ) && is_array( $invalidation['urls'] )
4495 + ? array_values( array_unique( array_filter( $invalidation['urls'], 'is_string' ) ) )
4496 + : array();
4497 + // This method always sweeps a complete local bucket. A narrower adapter
4498 + // announcement would claim unrelated local pages stayed warm when they
4499 + // did not, leaving their server copies stale. Until purge_all() gains
4500 + // dependency-aware local deletion, its response scope cannot be `urls`.
4501 + if ( 'urls' === $adapter_scope ) {
4502 + $adapter_scope = $network_wide ? 'network' : 'site';
4503 + }
4504 + if ( 'site' === $adapter_scope || 'network' === $adapter_scope || 'none' === $adapter_scope ) {
4505 + $urls = array();
4506 + }
4507 +
4508 + $removed = self::purge_local( $host );
4509 + $count = $removed['pages'] + $removed['rest'];
4510 +
4511 + self::flush_object_cache( $network_wide );
4512 +
3105 4513 self::update_stats( array( 'last_purge' => time() ) );
3106 4514
3107 4515 // Fire AFTER the local sweep so module listeners (Critical CSS,
3108 4516 // Unused CSS, Cloudflare edge purge) run — this action had three
@@ -3108,10 +4516,64 @@
3108 4516 // Unused CSS, Cloudflare edge purge) run — this action had three
3109 4517 // registered listeners but was never emitted. Treat it as additive
3110 4518 // (CDN / edge invalidation), not the mechanism for clearing local
3111 4519 // files. (FBS-83114)
3112 - do_action( 'xspeed_after_purge_all', $cause );
4520 + // Wrapped: this action predates the purge-event contract and has its
4521 + // own third-party listeners. One of them throwing used to abort
4522 + // purge_all() here, which now also means the contract event below
4523 + // never fires and a server cache keeps serving stale HTML. The local
4524 + // sweep is already done by this point, so swallowing is strictly safer
4525 + // than letting a listener decide the rest of the method runs.
4526 + try {
4527 + // Isolated per listener: one throwing used to cancel every
4528 + // listener queued behind it — Critical CSS, Unused CSS and the
4529 + // Cloudflare edge purge all hang off this hook. (QA #348)
4530 + self::do_action_isolated( 'xspeed_after_purge_all', $cause );
4531 + } catch ( \Throwable $e ) {
4532 + self::log_purge_listener_error( 'xspeed_after_purge_all', $e );
4533 + }
3113 4534
4535 + /**
4536 + * Fires after a full purge, with the same bounded context shape as
4537 + * `xspeed_after_purge_url`.
4538 + *
4539 + * Distinct from `xspeed_after_purge_all` on purpose. That action is
4540 + * the long-standing internal signal — it passes a bare `$cause` string
4541 + * and Free's own modules use it for local bookkeeping. This one is the
4542 + * documented contract for OUTSIDE integrations: same argument shape as
4543 + * the per-URL event, so a server-cache or CDN adapter can subscribe to
4544 + * both with one handler and branch on a null `url`.
4545 + *
4546 + * Fires at most once per purge, and not at all when a listener's own
4547 + * purge re-enters xSpeed.
4548 + *
4549 + * @since 1.2.3
4550 + *
4551 + * @param array $context {
4552 + * @type null $url Always null — a full purge has no single URL.
4553 + * @type string $host Host swept, or '*' for the entire tree.
4554 + * @type null $path Always null.
4555 + * @type string $cause Short label for who asked.
4556 + * @type int $removed Number of cache files removed.
4557 + * @type string $scope Adapter action: urls/site/network/none.
4558 + * @type string $intent content/presentation/complete or a caller-defined intent.
4559 + * @type string[] $urls Exact targets when scope is urls.
4560 + * }
4561 + */
4562 + self::dispatch_purge_event(
4563 + 'xspeed_after_purge',
4564 + array(
4565 + 'url' => null,
4566 + 'host' => null === $host ? self::current_purge_host() : (string) $host,
4567 + 'path' => null,
4568 + 'cause' => $cause,
4569 + 'removed' => $count,
4570 + 'scope' => $adapter_scope,
4571 + 'intent' => $intent,
4572 + 'urls' => $urls,
4573 + )
4574 + );
4575 +
3114 4576 // The list behind the "Cached pages" card is memoized for a minute;
3115 4577 // a purge has to drop it or the drill-down shows pages that no
3116 4578 // longer exist.
3117 4579 Cache_Inventory::invalidate();
@@ -3711,8 +5173,9 @@
3711 5173 $count = self::purge_pages();
3712 5174 self::update_stats( array( 'last_purge' => time() ) );
3713 5175 Cache_Inventory::invalidate();
3714 5176 self::record_partial_purge( 'page', $cause, $count );
5177 + self::announce_purge( $cause, $count );
3715 5178 return $count;
3716 5179
3717 5180 case 'assets':
3718 5181 if ( class_exists( '\\XSpeed\\Minifier' ) ) {
@@ -3739,8 +5202,9 @@
3739 5202 $count = self::purge_pages();
3740 5203 self::update_stats( array( 'last_purge' => time() ) );
3741 5204 Cache_Inventory::invalidate();
3742 5205 self::record_partial_purge( 'assets', $cause, $count );
5206 + self::announce_purge( $cause, $count );
3743 5207 return $count;
3744 5208
3745 5209 case 'object':
3746 5210 if ( function_exists( 'wp_cache_flush' ) ) {
@@ -3751,8 +5215,9 @@
3751 5215
3752 5216 case 'rest':
3753 5217 $count = Rest_Cache::purge();
3754 5218 self::record_partial_purge( 'REST responses', $cause, $count );
5219 + self::announce_purge( $cause, $count );
3755 5220 return $count;
3756 5221
3757 5222 default:
3758 5223 return self::purge_type_unhandled( $type, $cause );
@@ -3805,15 +5270,101 @@
3805 5270 * @param string $type Purge-type slug.
3806 5271 * @param string $cause Who asked.
3807 5272 */
3808 5273 private static function purge_type_unhandled( string $type, string $cause ): int {
3809 - do_action( 'xspeed_purge_type_' . $type );
5274 + $event_sequence = self::$purge_event_sequence;
5275 + $hook = 'xspeed_purge_type_' . $type;
5276 + $has_handler = false !== has_action( $hook );
5277 + do_action( $hook );
3810 5278 self::record_partial_purge( $type, $cause, null );
3811 5279
5280 + // Announce, same as the types this class owns. Pro's "Purge Critical
5281 + // CSS" and "Purge Unused CSS" arrive here, and they change what a
5282 + // cached page CONTAINS — critical CSS is inlined into the HTML, so a
5283 + // server cache goes on serving pages with the old styles baked in.
5284 + // Fixing the three Free buttons and leaving these two silent left the
5285 + // same hole for the tier most likely to be using both plugins.
5286 + // (QA #348 round 2, issue 2)
5287 + //
5288 + // Unknown slugs must not turn into a site-wide purge merely because no
5289 + // handler exists. These are the response-changing Pro types Free knows;
5290 + // third parties can declare another through the filter. A registered
5291 + // handler plus this explicit response scope is the handled signal.
5292 + $scope = in_array( $type, array( 'critical-css', 'unused-css' ), true ) ? 'site' : 'none';
5293 + /**
5294 + * Declare whether a handled custom purge type changes cached responses.
5295 + *
5296 + * @since 1.2.3
5297 + * @param string $scope site/network/none.
5298 + * @param string $type Purge-type slug.
5299 + */
5300 + $scope = (string) apply_filters( 'xspeed_purge_type_response_scope', $scope, $type );
5301 + if ( $has_handler
5302 + && $event_sequence === self::$purge_event_sequence
5303 + && in_array( $scope, array( 'site', 'network' ), true )
5304 + ) {
5305 + self::announce_purge( $cause, 0, $scope, 'presentation' );
5306 + }
5307 +
3812 5308 return 0;
3813 5309 }
3814 5310
3815 5311 /**
5312 + * Tell the server cache that a PARTIAL purge cleared cached responses.
5313 + *
5314 + * "Purge Page / Static Cache", "Purge CSS / JS Cache" and "Purge REST
5315 + * Cache" each delete cached RESPONSES for the whole site, so a cache in
5316 + * front of PHP is now serving copies xSpeed has just thrown away. Only
5317 + * "Purge All" announced itself, which left three of the four toolbar
5318 + * buttons doing exactly what this contract exists to prevent: clearing
5319 + * our copy while the server kept serving the stale one. The `assets` case
5320 + * was the sharpest — it deletes the minified bundles too, so LiteSpeed
5321 + * went on serving pages whose CSS and JS no longer exist. (QA #348)
5322 + *
5323 + * Sent as the full-purge shape (`url` null) because that is what happened:
5324 + * every cached page for this site went, not one address. `object` is not
5325 + * announced — flushing the object cache changes no rendered response a
5326 + * server cache could be holding.
5327 + *
5328 + * Public because Purge_Runner sweeps the local files itself, through
5329 + * purge_local(), rather than through purge_all() — so it has to announce
5330 + * on its own behalf or `wp xspeed purge` and the dashboard button clear
5331 + * our copy while LiteSpeed keeps serving the stale one.
5332 + *
5333 + * @param string $cause Who asked.
5334 + * @param int $removed Entries removed locally.
5335 + * @param string $scope Actionable adapter scope.
5336 + * @param string $intent Reason rendered responses changed.
5337 + */
5338 + public static function announce_purge( string $cause, int $removed, string $scope = 'site', string $intent = 'complete' ): void {
5339 + // Announcing is additive: the local sweep has already happened and
5340 + // succeeded. Notification must never be able to turn a working purge
5341 + // into a fatal, so anything the URL helpers do in an unusual context
5342 + // (early boot, a drop-in, a bare test harness) is contained here
5343 + // rather than propagating to the caller.
5344 + if ( ! function_exists( 'home_url' ) || ! function_exists( 'do_action' ) ) {
5345 + return;
5346 + }
5347 + try {
5348 + self::dispatch_purge_event(
5349 + 'xspeed_after_purge',
5350 + array(
5351 + 'url' => null,
5352 + 'host' => self::current_purge_host(),
5353 + 'path' => null,
5354 + 'cause' => $cause,
5355 + 'removed' => $removed,
5356 + 'scope' => $scope,
5357 + 'intent' => $intent,
5358 + 'urls' => array(),
5359 + )
5360 + );
5361 + } catch ( \Throwable $e ) {
5362 + self::log_purge_listener_error( 'xspeed_after_purge', $e );
5363 + }
5364 + }
5365 +
5366 + /**
3816 5367 * Log a partial purge so the drill-down behind "Last purge" shows every
3817 5368 * clear, not only the full ones. Without this a site whose object cache
3818 5369 * is flushed on a schedule looks, from the log, like nothing happens.
3819 5370 *
@@ -3863,8 +5414,24 @@
3863 5414 * Returns the number of .html files removed so purge stats stay accurate
3864 5415 * across the flat + static caches — .br siblings are not counted
3865 5416 * (they're encodings of a page, not pages).
3866 5417 */
5418 + /**
5419 + * Delete a cache file, adding its size to the current sweep's byte
5420 + * total. filesize() is silenced and re-checked because the file can
5421 + * vanish between the glob and the unlink — a concurrent purge, or the
5422 + * cache GC — and a warning there would be noise, not news.
5423 + *
5424 + * @param string $file Absolute path inside the cache tree.
5425 + */
5426 + private static function sweep_delete( string $file ): void {
5427 + $size = @filesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- the file may be gone already; see docblock.
5428 + if ( is_int( $size ) ) {
5429 + self::$sweep_bytes += $size;
5430 + }
5431 + wp_delete_file( $file );
5432 + }
5433 +
3867 5434 private static function rmtree_html( string $dir ): int {
3868 5435 if ( ! is_dir( $dir ) ) {
3869 5436 return 0;
3870 5437 }
@@ -3888,9 +5455,9 @@
3888 5455 @rmdir( $path );
3889 5456 continue;
3890 5457 }
3891 5458 if ( substr( $entry, -5 ) === '.html' ) {
3892 - wp_delete_file( $path );
5459 + self::sweep_delete( $path );
3893 5460 ++$removed;
3894 5461 } elseif ( substr( $entry, -3 ) === '.br' || substr( $entry, -8 ) === '.br.size' ) {
3895 5462 // Precompressed sibling (index.html.br) and the record of its
3896 5463 // length. Remove both so a purge doesn't orphan stale Brotli
@@ -3895,9 +5462,9 @@
3895 5462 // Precompressed sibling (index.html.br) and the record of its
3896 5463 // length. Remove both so a purge doesn't orphan stale Brotli
3897 5464 // bodies, or a size record that would later be read against a
3898 5465 // different sibling's bytes. Not counted.
3899 - wp_delete_file( $path );
5466 + self::sweep_delete( $path );
3900 5467 }
3901 5468 }
3902 5469 return $removed;
3903 5470 }
@@ -4011,14 +5578,46 @@
4011 5578 * that state — the detector sweep behind it is far more work than
4012 5579 * a stats call should do on an ordinary healthy site.
4013 5580 */
4014 5581 'page_cache_blocked_reason' => ( ! $serving && ! empty( Settings::get()['cache_enabled'] ) )
4015 - ? self::acquisition_blocker()
5582 + ? ( self::acquisition_blocker() ?? self::not_serving_reason() )
4016 5583 : null,
4017 5584 );
4018 5585 }
4019 5586
4020 5587 /**
5588 + * Why the cache is not serving, when nothing REFUSES to enable it.
5589 + *
5590 + * acquisition_blocker() answers "may we take the field", and since a
5591 + * foreign drop-in became takeable it answers null on a site where another
5592 + * plugin is nonetheless holding that file. Intent and outcome still
5593 + * disagree there, and the dashboard was left reporting the symptom -- not
5594 + * serving -- with no reason under it, which is exactly the state a user
5595 + * cannot act on.
5596 + *
5597 + * So this names the holder and says what to do: enabling takes it over.
5598 + */
5599 + private static function not_serving_reason(): ?string {
5600 + $owner = self::dropin_owner();
5601 + if ( self::DROPIN_FOREIGN !== $owner && self::DROPIN_UNREADABLE !== $owner ) {
5602 + return null;
5603 + }
5604 +
5605 + if ( self::DROPIN_UNREADABLE === $owner ) {
5606 + return __( 'advanced-cache.php cannot be read, so xSpeed cannot tell whose page cache is installed.', 'xspeed' );
5607 + }
5608 +
5609 + $label = Page_Cache_Detector::dropin_owner_label();
5610 + return $label
5611 + ? sprintf(
5612 + /* translators: %s: the page-caching plugin that owns advanced-cache.php. */
5613 + __( '%s is serving the page cache. Turn the xSpeed cache off and on again to take it over.', 'xspeed' ),
5614 + $label
5615 + )
5616 + : __( 'Another plugin is serving the page cache. Turn the xSpeed cache off and on again to take it over.', 'xspeed' );
5617 + }
5618 +
5619 + /**
4021 5620 * Whether the current request should be kept OUT of the cache hit/miss
4022 5621 * ratio: a genuine 404, or a known bot / scanner. Runs at template_redirect
4023 5622 * time, so is_404() is resolved. (#118)
4024 5623 */
@@ -4032,16 +5631,20 @@
4032 5631 return Hit_Counter::is_bot_ua( $ua );
4033 5632 }
4034 5633
4035 5634 /**
4036 - * Whether an edge cache fronts this origin. Today: the Cloudflare
4037 - * integration is connected — so an unknown share of hits is served at the
4038 - * edge and never counted here, making the origin ratio a partial view the
4039 - * dashboard must label as such. (#118)
5635 + * Whether an edge cache fronts this origin, so an unknown share of hits
5636 + * is served there and never counted here — which makes the origin ratio a
5637 + * partial view the dashboard has to label as such. (#118)
5638 + *
5639 + * This used to mean "the Cloudflare module is switched on", which answered
5640 + * no for every site fronted by anything else, and no for a site on
5641 + * Cloudflare that had never opened our Cloudflare panel. Both of those
5642 + * sites had their ratio presented as the whole story. Edge_Provider knows
5643 + * better and knows it per request, so ask it.
4040 5644 */
4041 5645 private static function edge_cache_detected(): bool {
4042 - $cf = get_option( 'xspeed_module_cloudflare', array() );
4043 - return is_array( $cf ) && ! empty( $cf['enabled'] );
5646 + return Edge_Provider::NONE !== Edge_Provider::detect()['confidence'];
4044 5647 }
4045 5648
4046 5649 /**
4047 5650 * Apply the user's enable/disable choice. Called from the REST toggle
@@ -4072,9 +5675,9 @@
4072 5675 * wp_config_writable: bool,
4073 5676 * manual_snippet: ?string
4074 5677 * }
4075 5678 */
4076 - public static function toggle( $enable ) {
5679 + public static function toggle( $enable, bool $consented = true ) {
4077 5680 Page_Cache_Detector::invalidate();
4078 5681 $expected = Page_Cache_Detector::inspect()['revision'];
4079 5682 /** Diagnostic seam; changing the expected revision can only force a safe refusal. */
4080 5683 $expected = (string) apply_filters( 'xspeed_page_cache_expected_revision', $expected );
@@ -4087,9 +5690,9 @@
4087 5690 $fresh = Page_Cache_Detector::inspect()['revision'];
4088 5691 if ( ! hash_equals( (string) $expected, (string) $fresh ) ) {
4089 5692 return self::blocked_toggle_state( __( 'Page-cache ownership changed while xSpeed was checking it. Nothing was changed; try again.', 'xspeed' ) );
4090 5693 }
4091 - $state = self::toggle_unlocked( (bool) $enable );
5694 + $state = self::toggle_unlocked( (bool) $enable, $consented );
4092 5695 return $state;
4093 5696 } finally {
4094 5697 flock( $lock, LOCK_UN );
4095 5698 fclose( $lock );
@@ -4096,9 +5699,14 @@
4096 5699 }
4097 5700 }
4098 5701
4099 5702 /** Run the page-cache mutation while toggle() owns the scoped lock. */
4100 - private static function toggle_unlocked( bool $enable ) {
5703 + /**
5704 + * @param bool $consented The user asked for this in the dashboard, so a
5705 + * foreign drop-in may be taken over. False on the
5706 + * unattended paths, which stand down instead.
5707 + */
5708 + private static function toggle_unlocked( bool $enable, bool $consented = true ) {
4101 5709 $enable = (bool) $enable;
4102 5710
4103 5711 if ( $enable ) {
4104 5712 /*
@@ -4131,8 +5739,37 @@
4131 5739 * on exactly the healthy sites this branch is about.
4132 5740 */
4133 5741 $reasserting = self::page_cache_operational() && self::DROPIN_XSPEED === self::dropin_owner();
4134 5742 $blocker = $reasserting ? null : self::acquisition_blocker();
5743 +
5744 + /*
5745 + * Taking over another plugin's drop-in needs the user to have
5746 + * asked for it. On the dashboard they did -- they clicked the
5747 + * switch, having been told whose file it is. The UNATTENDED
5748 + * callers have no such click: restore_dropin_if_enabled() runs
5749 + * after a plugin update and auto_heal() on an admin page load,
5750 + * both from nothing more than `cache_enabled` still being true.
5751 + *
5752 + * A competitor installed since that flag was set would have its
5753 + * page cache seized by a background repair, which is the silent
5754 + * acquisition this plugin refuses to perform. So those callers
5755 + * pass $consented = false and stand down instead.
5756 + */
5757 + if ( null === $blocker && ! $consented && self::DROPIN_FOREIGN === self::dropin_owner() ) {
5758 + // Name the owner. This string is rendered by host plugins
5759 + // through Host::enable_page_cache(), and an unnamed refusal
5760 + // is what made every host invent its own explanation.
5761 + $owner_label = Page_Cache_Detector::dropin_owner_label();
5762 + return self::blocked_toggle_state(
5763 + $owner_label
5764 + ? sprintf(
5765 + /* translators: %s: the page-caching plugin that owns advanced-cache.php. */
5766 + __( '%s owns advanced-cache.php, so xSpeed left it alone. Enable the cache from the xSpeed dashboard to take it over.', 'xspeed' ),
5767 + $owner_label
5768 + )
5769 + : __( 'Another plugin owns advanced-cache.php, so xSpeed left it alone. Enable the cache from the xSpeed dashboard to take it over.', 'xspeed' )
5770 + );
5771 + }
4135 5772 if ( null !== $blocker ) {
4136 5773 Activity_Log::record(
4137 5774 'cache_enable_blocked',
4138 5775 'Cache not enabled — ' . $blocker,
@@ -4604,10 +6241,11 @@
4604 6241 * they don't share a user at all. A default-umask 0644 file is then
4605 6242 * unwritable by nginx, the access_log write silently fails, and the
4606 6243 * dashboard shows a 0% hit ratio even though static HITs are serving.
4607 6244 * So we widen the dir to 0777 and the file to 0666 — group/other write —
4608 - * so whatever uid nginx runs as can append. (The file holds only HIT
4609 - * request lines, no secrets.)
6245 + * so whatever uid nginx runs as can append. The file holds HIT request
6246 + * lines and must be protected like an access log: paths and queries can
6247 + * contain sensitive values.
4610 6248 */
4611 6249 /**
4612 6250 * Directory holding the nginx hit log. Lives under uploads/, NOT the
4613 6251 * cache dir — uninstall.php and a cache purge both delete the cache
@@ -4745,10 +6383,22 @@
4745 6383 */
4746 6384 public static function sync_query_allowlist(): void {
4747 6385 $file = XSPEED_CACHE_DIR . '/.ignored-query-params';
4748 6386
4749 - $opts = Settings_Manager::get( 'cache' );
4750 - $ignored = is_array( $opts['ignored_query_params'] ?? null ) ? $opts['ignored_query_params'] : array();
6387 + /*
6388 + * Stored read, not Settings_Manager::get() — this runs from boot(),
6389 + * before translation is legal (see stored_cache_opts()).
6390 + *
6391 + * A raw read applies no schema defaults, and this field's default is a
6392 + * long tracking-parameter list, NOT empty. Falling back to array()
6393 + * would strip that whole allow-list from the drop-in on any install
6394 + * that has never saved the Cache panel. So fall back to the schema's
6395 + * own default, read from the module without building its labels.
6396 + */
6397 + $opts = self::stored_cache_opts();
6398 + $ignored = is_array( $opts['ignored_query_params'] ?? null )
6399 + ? $opts['ignored_query_params']
6400 + : \XSpeed\Modules\Cache\CacheModule::DEFAULT_IGNORED_QUERY_PARAMS;
4751 6401
4752 6402 $parts = array();
4753 6403 foreach ( $ignored as $pattern ) {
4754 6404 $pattern = trim( (string) $pattern );
@@ -4799,12 +6449,34 @@
4799 6449 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- read by the pre-WP drop-in; WP_Filesystem needs admin credentials unavailable here.
4800 6450 file_put_contents( $file, $payload, LOCK_EX );
4801 6451 }
4802 6452
6453 + /**
6454 + * CacheModule's STORED settings, read straight from the option.
6455 + *
6456 + * `Settings_Manager::get( 'cache' )` builds CacheModule's settings schema,
6457 + * whose labels are declared through `__()`. The reconcile chain below runs
6458 + * from `CacheModule::boot()` on `plugins_loaded` — before
6459 + * `after_setup_theme`, the point WordPress 6.7+ treats as safe to
6460 + * translate — so going through the schema there fires
6461 + * `_load_textdomain_just_in_time` on every request AND resolves the labels
6462 + * against a domain that is not loaded yet.
6463 + *
6464 + * The callers here need stored values, not schema metadata, so a raw read
6465 + * is equivalent. It applies NO defaults or coercion: read each key with a
6466 + * fallback matching the schema's own default.
6467 + *
6468 + * @return array<string,mixed>
6469 + */
6470 + private static function stored_cache_opts(): array {
6471 + $stored = get_option( Settings_Manager::OPTION_PREFIX . 'cache', array() );
6472 + return is_array( $stored ) ? $stored : array();
6473 + }
6474 +
4803 6475 public static function sync_mobile_flag( $enabled = null ): void {
4804 6476 if ( null === $enabled ) {
4805 - $opts = Settings_Manager::get( 'cache' );
4806 - $enabled = ! empty( $opts['mobile_separate'] );
6477 + $stored = self::stored_cache_opts();
6478 + $enabled = ! empty( $stored['mobile_separate'] );
4807 6479 }
4808 6480 $dir = XSPEED_CACHE_DIR;
4809 6481 $flag = $dir . '/.mobile-separate';
4810 6482 if ( $enabled ) {
@@ -4908,9 +6580,10 @@
4908 6580 // Read the setting from the SAME place static_rewrite_allowed() and
4909 6581 // sync_mobile_flag() do — the cache module's settings, not the
4910 6582 // top-level xspeed_options — or this marker would track a key that
4911 6583 // never changes and a real flip would go unnoticed.
4912 - $cache_opts = Settings_Manager::get( 'cache' );
6584 + // Stored read — this runs from boot(); see stored_cache_opts().
6585 + $cache_opts = self::stored_cache_opts();
4913 6586 $mobile_now = ! empty( $cache_opts['mobile_separate'] );
4914 6587 $mobile_last = get_option( 'xspeed_last_mobile_separate', null );
4915 6588 $mobile_flipped = ( null !== $mobile_last && (bool) (int) $mobile_last !== $mobile_now );
4916 6589
@@ -4991,9 +6664,10 @@
4991 6664 // pinned at 0% on a working Apache cache.)
4992 6665 if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) {
4993 6666 return false;
4994 6667 }
4995 - $opts = Settings_Manager::get( 'cache' );
6668 + // Stored read — reached from boot(); see stored_cache_opts().
6669 + $opts = self::stored_cache_opts();
4996 6670 return empty( $opts['mobile_separate'] );
4997 6671 }
4998 6672
4999 6673 /**
@@ -5497,11 +7171,16 @@
5497 7171 // while a page was cold — on a warm page nginx served the shared
5498 7172 // anonymous copy to carts, members and bypassed bots alike. The
5499 7173 // three historical names survive as a floor inside cookie_rule().
5500 7174 // `~*` is case-insensitive, matching PHP's stripos()/glob checks.
5501 - $cache_opts = Settings_Manager::get( 'cache' );
7175 + // Stored read — reached from boot(); see stored_cache_opts(). The
7176 + // fallbacks below mirror the schema's own defaults, which a raw read
7177 + // does not apply.
7178 + $cache_opts = self::stored_cache_opts();
5502 7179 $cookie_rule = Server_Rules::cookie_rule(
5503 - is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array()
7180 + is_array( $cache_opts['excluded_cookies'] ?? null )
7181 + ? $cache_opts['excluded_cookies']
7182 + : \XSpeed\Modules\Cache\CacheModule::DEFAULT_EXCLUDED_COOKIES
5504 7183 );
5505 7184 $lines[] = 'if ($http_cookie ~* "(' . $cookie_rule['regex'] . ')") { set $xspeed_no_cache "$xspeed_no_cache-cookie"; }';
5506 7185
5507 7186 $ua_rule = Server_Rules::user_agent_rule(
@@ -5562,8 +7241,24 @@
5562 7241 // missing. So: hits are logged, and a user deleting the log can't take
5563 7242 // nginx down.
5564 7243 $lines[] = ' access_log ' . $hits_abs . ' combined buffer=16k flush=5s;';
5565 7244 $lines[] = ' add_header X-XSpeed-Cache "HIT (nginx)" always;';
7245 + // Edge/CDN headers from the same seam the drop-in bakes. nginx serves
7246 + // this path without ever starting PHP, so the answer cannot be
7247 + // resolved per request — the pairs are resolved HERE, when the
7248 + // snippet is generated, and a change of answer needs the snippet
7249 + // regenerated and re-pasted to take effect.
7250 + //
7251 + // Skipped entirely when the static path is switched off. The only
7252 + // reason that can fire under `bake` is mobile-split, and mobile-split
7253 + // is also what switches the static path off — so the block would be
7254 + // baked with a hold it can never serve, and would start serving it
7255 + // the moment the setting is turned off and static files reappear,
7256 + // until somebody regenerates and re-pastes. A rule that can only be
7257 + // served once its premise is false is guaranteed to be stale.
7258 + foreach ( self::static_rewrite_allowed() ? self::edge_headers_for( 'HIT', 'bake' ) : array() as $name => $value ) {
7259 + $lines[] = ' add_header ' . $name . ' "' . self::quote_directive_value( $value ) . '" always;';
7260 + }
5566 7261 $lines[] = '}';
5567 7262 return implode( "\n", $lines );
5568 7263 }
5569 7264
@@ -5681,9 +7376,9 @@
5681 7376 if ( empty( $opts['cache_enabled'] ) ) {
5682 7377 return false;
5683 7378 }
5684 7379
5685 - $state = self::toggle( true );
7380 + $state = self::toggle( true, false );
5686 7381 // A refusal reports whether the cache SERVES, which on this path can
5687 7382 // be true for reasons that have nothing to do with this call — so a
5688 7383 // refusal would otherwise log "drop-in restored" for a restore that
5689 7384 // was declined. Restored means the transaction went through.
@@ -5723,9 +7418,9 @@
5723 7418 if ( empty( $opts['cache_enabled'] ) ) {
5724 7419 return;
5725 7420 }
5726 7421
5727 - $state = self::toggle( true );
7422 + $state = self::toggle( true, false );
5728 7423 // A refusal means something else now owns the page-cache field, or
5729 7424 // the write could not be verified. Either way this is not the moment
5730 7425 // to go on maintaining our rewrite block and log file.
5731 7426 if ( ! empty( $state['blocked'] ) || empty( $state['enabled'] ) ) {
@@ -5856,9 +7551,9 @@
5856 7551 // so the closing quote here cannot be escaped away.
5857 7552 $lines[] = ' RewriteCond %{HTTP_USER_AGENT} "!(' . $ua_rule['regex'] . ')" [NC]';
5858 7553 }
5859 7554
5860 - return array_merge(
7555 + $block = array_merge(
5861 7556 $lines,
5862 7557 array(
5863 7558 // Capture REQUEST_URI without its trailing slash into %1.
5864 7559 // store_static() writes `{host}{uri-without-trailing-slash}/index.html`,
@@ -5876,9 +7571,9 @@
5876 7571 // `^` matches the empty string AND any non-empty path, so it
5877 7572 // covers `/` and `/blog` alike. (Confirmed on OpenLiteSpeed
5878 7573 // 1.8: `.` → homepage served by PHP drop-in; `^` → served
5879 7574 // directly from the static file.)
5880 - ' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [L]',
7575 + ' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [E=XSPEED_STATIC_HIT:1,L]',
5881 7576 '</IfModule>',
5882 7577 // Mark the statically-served response as a cache HIT.
5883 7578 //
5884 7579 // A file served by the rewrite above bypasses PHP entirely, so
@@ -5900,11 +7595,32 @@
5900 7595 '<IfModule mod_headers.c>',
5901 7596 ' <FilesMatch "\\.html$">',
5902 7597 ' Header always set X-XSpeed-Cache "HIT (static)"',
5903 7598 ' </FilesMatch>',
5904 - '</IfModule>',
5905 7599 )
5906 7600 );
7601 +
7602 + // Edge/CDN headers from the same seam the drop-in bakes. Like the
7603 + // nginx snippet, the static rewrite answers without PHP, so the pairs
7604 + // are resolved when the block is GENERATED rather than per request.
7605 + //
7606 + // `env=` rather than the `<FilesMatch>` scoping above, because these
7607 + // must ride only on responses the rewrite produced. The marker header
7608 + // stays filename-scoped: it is inert, and narrowing it would change a
7609 + // header QA reads.
7610 + // Same reasoning as the nginx snippet: a bake hold can only come from
7611 + // mobile-split, and mobile-split is what turns this path off.
7612 + $edge_lines = array();
7613 + foreach ( self::static_rewrite_allowed() ? self::edge_headers_for( 'HIT', 'bake' ) : array() as $edge_name => $edge_value ) {
7614 + $edge_lines = array_merge(
7615 + $edge_lines,
7616 + self::static_hit_directives(
7617 + ' Header always set ' . $edge_name . ' "' . self::quote_directive_value( $edge_value ) . '"'
7618 + )
7619 + );
7620 + }
7621 +
7622 + return array_merge( $block, $edge_lines, array( '</IfModule>' ) );
5907 7623 }
5908 7624
5909 7625 /**
5910 7626 * Active probe that confirms the web-server static-rewrite path is
@@ -6245,8 +7961,14 @@
6245 7961 /** No drop-in installed. */
6246 7962 public const DROPIN_NONE = 'none';
6247 7963 /** A drop-in is installed and we could not read it. */
6248 7964 public const DROPIN_UNREADABLE = 'unreadable';
7965 + /**
7966 + * Present but holding nothing -- empty, or whitespace only. WP Rocket
7967 + * truncates advanced-cache.php to 0 bytes on deactivate, and calling that
7968 + * FOREIGN made it a permanent blocker with no owner to ask. (#391)
7969 + */
7970 + public const DROPIN_ABANDONED = 'abandoned';
6249 7971
6250 7972 /**
6251 7973 * Who owns wp-content/advanced-cache.php right now.
6252 7974 *
@@ -6269,11 +7991,45 @@
6269 7991 if ( null === $contents ) {
6270 7992 return self::DROPIN_UNREADABLE;
6271 7993 }
6272 7994
6273 - return xspeed_has_canonical_dropin_signature( $contents )
6274 - ? self::DROPIN_XSPEED
6275 - : self::DROPIN_FOREIGN;
7995 + if ( xspeed_has_canonical_dropin_signature( $contents ) ) {
7996 + return self::DROPIN_XSPEED;
7997 + }
7998 +
7999 + // Nothing in the file means nothing owns it. Kept distinct from
8000 + // FOREIGN so the acquisition gate can tell "someone else's cache" from
8001 + // "a husk the last plugin left behind". (#391)
8002 + if ( '' === trim( $contents ) ) {
8003 + return self::DROPIN_ABANDONED;
8004 + }
8005 +
8006 + /*
8007 + * The other half of the same question, and it cannot be answered from
8008 + * the bytes: a file we cannot attribute is a COMPETITOR only while
8009 + * some page cache is actually running. With every candidate switched
8010 + * off it is abandoned -- a hosting company's own cache, a hand-rolled
8011 + * one, or a plugin that was deleted without cleaning up.
8012 + *
8013 + * Asking the detector rather than re-deriving it here is the point:
8014 + * these two answers disagreeing is a split brain with a bad ending --
8015 + * acquisition_blocker() opens the gate, install_dropin() then refuses
8016 + * on FOREIGN, and toggle() blames the filesystem for a write it never
8017 + * attempted. One question, one answer. (#391, #393)
8018 + */
8019 + if ( class_exists( __NAMESPACE__ . '\\Page_Cache_Detector' ) ) {
8020 + $owner = (string) ( Page_Cache_Detector::inspect()['dropin']['owner'] ?? '' );
8021 +
8022 + // Attributable to a named plugin -> somebody's cache, whatever its
8023 + // activation state. Only a file NOBODY can be shown to own, with
8024 + // nothing running, is abandoned.
8025 + if ( Page_Cache_Detector::OWNER_UNKNOWN === $owner
8026 + && ! Page_Cache_Detector::another_page_cache_is_active() ) {
8027 + return self::DROPIN_ABANDONED;
8028 + }
8029 + }
8030 +
8031 + return self::DROPIN_FOREIGN;
6276 8032 }
6277 8033
6278 8034 /**
6279 8035 * Why xSpeed must not install its page-cache artifacts right now, or null
@@ -6327,8 +8083,45 @@
6327 8083 if ( Page_Cache_Detector::BLOCKER_WP_CACHE_ORPHANED === $code && self::DROPIN_XSPEED === $owner ) {
6328 8084 continue;
6329 8085 }
6330 8086 /*
8087 + * Another plugin's drop-in is no longer a refusal.
8088 + *
8089 + * It used to be: whoever held advanced-cache.php kept it, and
8090 + * enabling was blocked with "deactivate its page cache first".
8091 + * That left a user who had asked for our cache with no way to get
8092 + * it — on a live site the only exit was deleting a file over SSH,
8093 + * and the message could not even say which of its two causes
8094 + * applied ("is active OR owns advanced-cache.php").
8095 + *
8096 + * Turning the page cache on is the instruction to serve pages
8097 + * from cache, and that is not possible without this file. So we
8098 + * take it, and the dashboard says whose file it is first —
8099 + * dropin_disclosure() names the owner, the user confirms, and
8100 + * install_dropin() writes ours over the top.
8101 + *
8102 + * A still-active competitor is deliberately NOT re-added as a
8103 + * blocker below: it is caught by `active_page_cache`, which the
8104 + * capability rule already downgrades to a note. Two page caches
8105 + * installed at once is the user's call to make, not ours to
8106 + * refuse — they just told us which one they want serving.
8107 + *
8108 + * UNREADABLE is the exception and stays a refusal: we cannot name
8109 + * what we would destroy, and install_dropin() refuses it too, so
8110 + * opening the gate here would only produce a failed write.
8111 + */
8112 + $about_dropin_owner = in_array(
8113 + $code,
8114 + array(
8115 + Page_Cache_Detector::BLOCKER_FOREIGN_DROPIN,
8116 + Page_Cache_Detector::BLOCKER_UNKNOWN_DROPIN,
8117 + ),
8118 + true
8119 + );
8120 + if ( $about_dropin_owner && self::DROPIN_UNREADABLE !== $owner ) {
8121 + continue;
8122 + }
8123 + /*
6331 8124 * Capability is not possession. `active_page_cache` and
6332 8125 * `multiple_page_caches` both fire on a plugin that merely CAN
6333 8126 * cache pages — the detector cannot prove a competitor's page
6334 8127 * cache is off, so it counts it. As a warning that is right. As
@@ -6355,9 +8148,25 @@
6355 8148 Page_Cache_Detector::BLOCKER_MULTIPLE_PAGE_CACHES,
6356 8149 ),
6357 8150 true
6358 8151 );
6359 - if ( $about_capability && in_array( $owner, array( self::DROPIN_XSPEED, self::DROPIN_NONE ), true ) ) {
8152 + /*
8153 + * FOREIGN belongs in this list now, and it is the whole point.
8154 + *
8155 + * The rule is still "capability is not possession": these two
8156 + * blockers fire on any plugin that CAN cache pages, which the
8157 + * detector cannot prove is switched off. What changed is that a
8158 + * competitor holding the drop-in no longer stops us either — we
8159 + * take the file, having said whose it is. So there is nothing
8160 + * left for a merely-installed competitor to protect, and keeping
8161 + * the refusal here would put back the dead end by another route:
8162 + * "another page cache is active" on a site where the user has
8163 + * just told us, by name, which cache they want serving.
8164 + *
8165 + * UNREADABLE is deliberately still absent — that one refuses.
8166 + */
8167 + if ( $about_capability
8168 + && in_array( $owner, array( self::DROPIN_XSPEED, self::DROPIN_NONE, self::DROPIN_FOREIGN, self::DROPIN_ABANDONED ), true ) ) {
6360 8169 continue;
6361 8170 }
6362 8171 if ( Page_Cache_Detector::BLOCKER_MULTIPLE_PAGE_CACHES === $code ) {
6363 8172 $others = self::other_page_cache_names( $blocker );
@@ -6561,14 +8370,22 @@
6561 8370 return false;
6562 8371 }
6563 8372
6564 8373 /*
6565 - * Ownership first, before any of the work below. WordPress gives every
6566 - * caching plugin the same single file, so a drop-in that is not ours is
6567 - * another plugin's live cache — refuse rather than replace it.
8374 + * A drop-in we cannot READ is the one thing still refused here. Not
8375 + * because of who owns it — we no longer refuse on ownership — but
8376 + * because an unreadable file is usually a permissions problem, and
8377 + * writing over it would fail anyway or destroy something we were
8378 + * never able to look at.
8379 + *
8380 + * Everything else is ours to take. Enabling the page cache IS the
8381 + * user's instruction to serve the cache, and serving it means holding
8382 + * advanced-cache.php; the dashboard says whose file it is replacing
8383 + * before the click (Page_Cache_Detector::dropin_disclosure()), so the
8384 + * takeover is consented rather than silent.
6568 8385 */
6569 8386 $owner = self::dropin_owner();
6570 - if ( self::DROPIN_FOREIGN === $owner || self::DROPIN_UNREADABLE === $owner ) {
8387 + if ( self::DROPIN_UNREADABLE === $owner ) {
6571 8388 return false;
6572 8389 }
6573 8390
6574 8391 global $wp_filesystem;
@@ -6653,8 +8470,22 @@
6653 8470 (string) ( $expiry_hours * HOUR_IN_SECONDS ),
6654 8471 $source_contents
6655 8472 );
6656 8473
8474 + // Bake the site-wide edge answer in. Resolved in a `bake` context, so
8475 + // nothing per-page and nothing a request header vouched for can reach
8476 + // it: a bake runs once, in an admin or CLI request, and answers for
8477 + // every page on the site. A page that disagrees gets a sidecar
8478 + // instead — see per_entry_edge_headers().
8479 + //
8480 + // Re-baked on every cache settings save (see CacheModule::boot),
8481 + // exactly like the cookie, user-agent and lifetime rules above.
8482 + $source_contents = str_replace(
8483 + "'@@XSPEED_EDGE_HEADERS@@'",
8484 + self::edge_headers_literal( self::edge_headers_for( 'HIT', 'bake' ) ),
8485 + $source_contents
8486 + );
8487 +
6657 8488 if ( file_exists( $target ) ) {
6658 8489 $existing = $wp_filesystem->get_contents( $target );
6659 8490 if ( is_string( $existing ) && $existing === $source_contents ) {
6660 8491 return true;
@@ -6896,10 +8727,59 @@
6896 8727 'href' => admin_url( 'admin.php?page=' . Admin::PAGE_SLUG ),
6897 8728 )
6898 8729 );
6899 8730
6900 - foreach ( self::purge_types() as $slug => $type ) {
6901 - if ( empty( $type['visible'] ) ) {
8731 + // Settings first, then the two whole-errand actions (Purge All,
8732 + // Purge this URL), then the per-type items. The order is the one WP
8733 + // Rocket uses, and it front-loads what people open this menu for:
8734 + // nobody reaches for "Purge Object Cache" as often as they reach for
8735 + // the page they are looking at.
8736 + $wp_admin_bar->add_node(
8737 + array(
8738 + 'id' => 'xspeed-purge-settings',
8739 + 'parent' => 'xspeed-purge',
8740 + 'title' => esc_html__( 'Settings', 'xspeed' ),
8741 + 'href' => admin_url( 'admin.php?page=' . Admin::PAGE_SLUG ),
8742 + )
8743 + );
8744 +
8745 + $types = self::purge_types();
8746 +
8747 + // 'all' is rendered out of band so the single-URL item can sit
8748 + // directly under it. A filter that reorders or drops it is honoured:
8749 + // the loop below skips whatever was emitted here.
8750 + $emitted = array();
8751 + if ( ! empty( $types['all']['visible'] ) ) {
8752 + $wp_admin_bar->add_node(
8753 + array(
8754 + 'id' => 'xspeed-purge-all',
8755 + 'parent' => 'xspeed-purge',
8756 + 'title' => esc_html( $types['all']['label'] ),
8757 + 'href' => self::purge_type_url( 'all' ),
8758 + )
8759 + );
8760 + $emitted['all'] = true;
8761 + }
8762 +
8763 + // Only when the current screen is about one thing — a front-end view,
8764 + // or a published post's edit screen. On a list table or a settings
8765 + // page there is nothing for "this" to mean, so the item stays hidden
8766 + // rather than silently targeting the dashboard. Purge_Ui decides both
8767 + // the label and the scope, which differ between the two contexts.
8768 + $context = Purge_Ui::context_node();
8769 + if ( null !== $context ) {
8770 + $wp_admin_bar->add_node(
8771 + array(
8772 + 'id' => 'xspeed-purge-this-url',
8773 + 'parent' => 'xspeed-purge',
8774 + 'title' => esc_html( $context['title'] ),
8775 + 'href' => $context['href'],
8776 + )
8777 + );
8778 + }
8779 +
8780 + foreach ( $types as $slug => $type ) {
8781 + if ( empty( $type['visible'] ) || isset( $emitted[ $slug ] ) ) {
6902 8782 continue;
6903 8783 }
6904 8784 $wp_admin_bar->add_node(
6905 8785 array(