PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.6
1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 All 32 releases
← All changes | includes/class-cache.php +2086 -105 1.2.4 → 1.3.6 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,44 @@
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 + * A new term is `content`, not `presentation`. It has no posts yet, so no
3486 + * page renders it until a post is saved with it, and that save is its own
3487 + * content purge. Classed as presentation, it cleared the host's whole
3488 + * nginx cache every time a post was published with a tag that did not
3489 + * exist yet, which is most publishing. Renaming or deleting a term stays
3490 + * presentation: the new name shows on every post in the term, and Nginx
3491 + * Helper purges only the homepage for either. (QA #448)
3492 + *
3493 + * @return array{scope:string,intent:string,urls:array<int,string>}
3494 + */
3495 + private static function invalidation_for_hook( string $hook ): array {
3496 + $presentation = array(
3497 + 'switch_theme',
3498 + 'activated_plugin',
3499 + 'deactivated_plugin',
3500 + 'edited_term',
3501 + 'delete_term',
3502 + 'wp_update_nav_menu',
3503 + );
3504 +
3505 + return array(
3506 + 'scope' => 'site',
3507 + 'intent' => in_array( $hook, $presentation, true ) ? 'presentation' : 'content',
3508 + 'urls' => array(),
3509 + );
3510 + }
3511 +
3512 +
3513 + /**
2637 3514 * save_post → purge only when the saved thing can appear on a cached page.
2638 3515 *
2639 3516 * Revisions and autosaves are never rendered. Non-viewable post types —
2640 3517 * WooCommerce's `shop_order` / `shop_order_placehold` / `shop_order_refund`
@@ -2649,35 +3526,32 @@
2649 3526 * @param int $post_id Saved post ID.
2650 3527 * @param \WP_Post $post Saved post object.
2651 3528 */
2652 3529 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 ) ) {
3530 + if ( ! self::post_change_is_cacheable_content( $post_id, $post, 'save' ) ) {
2654 3531 return;
2655 3532 }
2656 - if ( function_exists( 'wp_is_post_autosave' ) && wp_is_post_autosave( $post_id ) ) {
2657 - return;
2658 - }
2659 3533
2660 3534 $post_type = is_object( $post ) && isset( $post->post_type )
2661 3535 ? (string) $post->post_type
2662 3536 : (string) get_post_type( $post_id );
2663 - if ( '' === $post_type ) {
2664 - return;
2665 - }
2666 3537
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 3538 // Name the trigger rather than logging a bare numeric id — the old
2677 3539 // wiring passed the post ID into $cause, so the log read
2678 3540 // "Cache purged (46)" with no indication of what caused it. (#243)
2679 - self::purge_all( 'post:' . $post_type );
3541 + $presentation = in_array( $post_type, self::presentation_post_types(), true );
3542 + self::purge_all(
3543 + 'post:' . $post_type,
3544 + null,
3545 + array(
3546 + // purge_all() sweeps every local response in this site's bucket.
3547 + // Without dependency tracking, the server cache must match that
3548 + // same boundary or unrelated pages can remain stale there.
3549 + 'scope' => 'site',
3550 + 'intent' => $presentation ? 'presentation' : 'content',
3551 + 'urls' => array(),
3552 + )
3553 + );
2680 3554 if ( class_exists( '\XSpeed\Minifier' ) ) {
2681 3555 Minifier::purge_minified();
2682 3556 }
2683 3557 }
@@ -2682,8 +3556,61 @@
2682 3556 }
2683 3557 }
2684 3558
2685 3559 /**
3560 + * Delete/trash invalidation while the post type is still available.
3561 + * The local and server response-cache sweeps share the same site boundary.
3562 + *
3563 + * @param int $post_id Removed post ID.
3564 + * @param object|null $post Post object supplied by core when available.
3565 + */
3566 + public static function on_post_removed( $post_id, $post = null ): void {
3567 + if ( ! self::post_change_is_cacheable_content( $post_id, $post, 'remove' ) ) {
3568 + return;
3569 + }
3570 +
3571 + $post_type = is_object( $post ) && isset( $post->post_type )
3572 + ? (string) $post->post_type
3573 + : (string) get_post_type( $post_id );
3574 +
3575 + self::purge_all(
3576 + 'post-removed:' . $post_type,
3577 + null,
3578 + array(
3579 + 'scope' => 'site',
3580 + // Match on_save_post: a presentation type changes how pages
3581 + // render rather than what they say.
3582 + 'intent' => in_array( $post_type, self::presentation_post_types(), true )
3583 + ? 'presentation'
3584 + : 'content',
3585 + 'urls' => array(),
3586 + )
3587 + );
3588 + }
3589 +
3590 + /** Purge site responses when moderation changes visible comments. */
3591 + public static function on_comment_status( $comment_id, $status = '' ): void {
3592 + $comment = function_exists( 'get_comment' ) ? get_comment( (int) $comment_id ) : null;
3593 + $post_id = is_object( $comment ) && isset( $comment->comment_post_ID ) ? (int) $comment->comment_post_ID : 0;
3594 + if ( $post_id < 1 || ! function_exists( 'get_permalink' ) ) {
3595 + return;
3596 + }
3597 + $url = get_permalink( $post_id );
3598 + if ( ! is_string( $url ) || '' === $url ) {
3599 + return;
3600 + }
3601 + self::purge_all(
3602 + 'comment-status:' . (string) $status,
3603 + null,
3604 + array(
3605 + 'scope' => 'site',
3606 + 'intent' => 'content',
3607 + 'urls' => array(),
3608 + )
3609 + );
3610 + }
3611 +
3612 + /**
2686 3613 * comment_post → purge just the commented-on URL, and only once the
2687 3614 * comment is actually visible.
2688 3615 *
2689 3616 * A comment held for moderation changes nothing on the front end, and an
@@ -2834,13 +3761,304 @@
2834 3761 public static function purge_product_object( $product ): void {
2835 3762 self::purge_product( $product );
2836 3763 }
2837 3764
3765 + /**
3766 + * Re-entry guard for the purge-event contract.
3767 + *
3768 + * A listener on `xspeed_after_purge_url` legitimately purges its own
3769 + * layer, and a server-cache or CDN adapter that calls back into xSpeed
3770 + * while doing so re-enters this method — unbounded, because each pass
3771 + * looks like a fresh purge.
3772 + *
3773 + * A single global flag stops too much: a nested purge of a DIFFERENT URL is
3774 + * a real purge whose listeners must hear about it. But a per-request
3775 + * "already published" set stops too much in the other direction — a
3776 + * network purge loops every blog in one request, and on a subdirectory
3777 + * network they share a host, so blogs 2..N would be silently skipped. It
3778 + * also grows for the life of the process.
3779 + *
3780 + * So the guard tracks what is IN FLIGHT, not what has been published: a
3781 + * target is marked while its own dispatch is on the stack and unmarked
3782 + * when it returns. Re-entering the same target recurses, so it is refused;
3783 + * purging the same URL again later is a new event and publishes. The set
3784 + * is bounded by call depth rather than by how many URLs a request touches.
3785 + *
3786 + * @var array<string,bool>
3787 + */
3788 + private static $purge_events_in_flight = array();
3789 +
3790 + /** Monotonic count used to detect whether a delegated purge published. */
3791 + private static $purge_event_sequence = 0;
3792 +
3793 + /**
3794 + * Publish a purge event exactly once, with bounded arguments.
3795 + *
3796 + * Deliberately carries only what an integration needs to invalidate its
3797 + * own copy: the canonical URL (or null for a full purge), the site host,
3798 + * the cause label, and how many files went. No filesystem paths, no cache
3799 + * contents, no request headers, no user data. The URL query and caller-
3800 + * supplied cause may nevertheless contain sensitive text, so listeners
3801 + * must redact them in logs or unrelated destinations that do not need the
3802 + * exact cache key.
3803 + *
3804 + * A listener that throws must not take the purge down with it: the files
3805 + * are already gone by the time we get here, and an integration's bad day
3806 + * is not a reason to report a failed purge to the caller.
3807 + *
3808 + * @param string $hook Hook name to emit.
3809 + * @param array<string,mixed> $context Bounded context, see above.
3810 + */
3811 + private static function dispatch_purge_event( string $hook, array $context ): void {
3812 + if ( ! function_exists( 'do_action' ) ) {
3813 + return;
3814 + }
3815 + $target = $hook . '|' . ( isset( $context['url'] ) ? (string) $context['url'] : '' )
3816 + . '|' . ( isset( $context['host'] ) ? (string) $context['host'] : '' );
3817 + if ( isset( self::$purge_events_in_flight[ $target ] ) ) {
3818 + return;
3819 + }
3820 + self::$purge_events_in_flight[ $target ] = true;
3821 + ++self::$purge_event_sequence;
3822 +
3823 + // Our own integrations get their own try. Sharing one with the public
3824 + // action below meant a listener on the extension seam could throw and
3825 + // take the contract event down with it — the mirror of the failure
3826 + // this separation exists to prevent.
3827 + try {
3828 + // Built-in server-cache integrations run FIRST, and by a direct
3829 + // call rather than as listeners on the action below.
3830 + //
3831 + // WordPress stops dispatching an action's remaining callbacks when
3832 + // one of them throws. As a listener, our LiteSpeed forwarding
3833 + // would then be skipped by any unrelated third-party callback that
3834 + // happened to be registered earlier and blew up — and the visible
3835 + // result is the worst kind: xSpeed reports a successful purge while
3836 + // the server keeps serving stale HTML. Shipped behaviour must not
3837 + // be hostage to a listener's bug.
3838 + self::forward_to_server_caches( $context );
3839 + } catch ( \Throwable $e ) {
3840 + self::log_purge_listener_error( $hook, $e );
3841 + }
3842 +
3843 + try {
3844 + self::do_action_isolated( $hook, $context );
3845 + } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
3846 + // Swallow: see docblock. The purge succeeded regardless.
3847 + self::log_purge_listener_error( $hook, $e );
3848 + } finally {
3849 + unset( self::$purge_events_in_flight[ $target ] );
3850 + }
3851 + }
3852 +
3853 + /**
3854 + * Run every listener on a purge hook, isolating each from the others.
3855 + *
3856 + * `do_action()` dispatches callbacks in one loop, so the first one to
3857 + * throw takes every LATER listener down with it. On a purge that meant a
3858 + * failing CDN integration silently cancelled the ones queued behind it —
3859 + * and because the throw was swallowed to keep the purge itself succeeding,
3860 + * the user was told the clear worked while two edges were never touched.
3861 + * Invisible unless WP_DEBUG happened to be on. (QA #348)
3862 + *
3863 + * Each callback gets its own try/catch here, so one integration's bad day
3864 + * costs only that integration. Priority order is preserved. Falls back to
3865 + * a plain `do_action()` when the filter registry is not the shape we
3866 + * expect, so an unusual environment degrades to the old behaviour rather
3867 + * than skipping listeners entirely.
3868 + *
3869 + * @param string $hook Hook name to emit.
3870 + * @param mixed $arg Single argument passed to each listener.
3871 + */
3872 + public static function do_action_isolated( string $hook, $arg ): void {
3873 + global $wp_filter;
3874 +
3875 + // Walking $wp_filter by hand and calling each callback directly was the
3876 + // obvious way to do this, and it was wrong: it bypasses WordPress, so
3877 + // `current_filter()` came back empty, `did_action()` stayed at 0, the
3878 + // `all` hook never fired, and Query Monitor and Debug Bar could not see
3879 + // the very contract this class publishes. A shared handler branching on
3880 + // current_filter() picked the wrong branch. (QA #348 round 2, issue 3)
3881 + //
3882 + // So let do_action() dispatch — WordPress keeps its bookkeeping — and
3883 + // isolate one level down instead: each registered callback is swapped
3884 + // for a wrapper that runs it inside a try/catch. One listener throwing
3885 + // then costs only that listener, which is the whole point, without
3886 + // costing the hook its identity.
3887 + if ( ! isset( $wp_filter[ $hook ] ) || ! ( $wp_filter[ $hook ] instanceof \WP_Hook ) ) {
3888 + do_action( $hook, $arg );
3889 + return;
3890 + }
3891 +
3892 + $hook_object = $wp_filter[ $hook ];
3893 + $original = $hook_object->callbacks;
3894 + if ( ! is_array( $original ) || array() === $original ) {
3895 + do_action( $hook, $arg );
3896 + return;
3897 + }
3898 +
3899 + $wrapped = array();
3900 + $restorations = array();
3901 + foreach ( $original as $priority => $group ) {
3902 + if ( ! is_array( $group ) ) {
3903 + $wrapped[ $priority ] = $group;
3904 + continue;
3905 + }
3906 + foreach ( $group as $id => $registered ) {
3907 + if ( ! isset( $registered['function'] ) || ! is_callable( $registered['function'] ) ) {
3908 + $wrapped[ $priority ][ $id ] = $registered;
3909 + continue;
3910 + }
3911 + $callback = $registered['function'];
3912 + $wrapper = static function ( ...$args ) use ( $callback, $hook ) {
3913 + try {
3914 + return $callback( ...$args );
3915 + } catch ( \Throwable $e ) {
3916 + self::log_purge_listener_error( $hook, $e );
3917 + return null;
3918 + }
3919 + };
3920 + $wrapped[ $priority ][ $id ] = array(
3921 + // Keep accepted_args: a listener registered for 0 or 1
3922 + // arguments must still be called the way it asked.
3923 + 'accepted_args' => $registered['accepted_args'] ?? 1,
3924 + 'function' => $wrapper,
3925 + );
3926 + $restorations[ $priority ][ $id ] = array(
3927 + 'original' => $registered,
3928 + 'wrapper' => $wrapper,
3929 + );
3930 + }
3931 + }
3932 +
3933 + $hook_object->callbacks = $wrapped;
3934 + try {
3935 + do_action( $hook, $arg );
3936 + } finally {
3937 + // Restore only wrappers still present. Native add/remove operations
3938 + // performed by listeners must survive this temporary substitution.
3939 + foreach ( $restorations as $priority => $group ) {
3940 + foreach ( $group as $id => $restore ) {
3941 + $current = $hook_object->callbacks[ $priority ][ $id ]['function'] ?? null;
3942 + if ( $current === $restore['wrapper'] ) {
3943 + $hook_object->callbacks[ $priority ][ $id ] = $restore['original'];
3944 + }
3945 + }
3946 + }
3947 + }
3948 + }
3949 +
3950 + /**
3951 + * Name a listener that threw, under WP_DEBUG only.
3952 + *
3953 + * Gated like the rest of Free's diagnostics: a third-party listener
3954 + * throwing on every purge must not fill a production log.
3955 + */
3956 + private static function log_purge_listener_error( string $hook, \Throwable $e ): void {
3957 + // An \Error — a TypeError from one of OUR listeners, say — is a bug
3958 + // rather than a runtime condition a third party imposed on us, and
3959 + // swallowing it silently in production turns it into a purge that
3960 + // quietly stops working. Those are logged whatever WP_DEBUG says;
3961 + // third-party \Exceptions stay gated so a noisy integration cannot
3962 + // fill a production log.
3963 + $always = $e instanceof \Error;
3964 + if ( ( $always || ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ) && function_exists( 'error_log' ) ) {
3965 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- names a third-party listener that threw during a purge.
3966 + error_log( '[xspeed] a ' . $hook . ' listener threw: ' . $e->getMessage() );
3967 + }
3968 + }
3969 +
3970 + /** Test seam: clear the in-flight set left behind by an aborted dispatch. */
3971 + public static function reset_purge_events(): void {
3972 + self::$purge_events_in_flight = array();
3973 + self::$purge_event_sequence = 0;
3974 + }
3975 +
3976 + /**
3977 + * Hand the purge to the caches we ship integrations for.
3978 + *
3979 + * Isolated from the public action on purpose — see dispatch_purge_event().
3980 + * Guarded so a missing class (a partial upgrade, a stripped build) cannot
3981 + * turn a working purge into a fatal.
3982 + *
3983 + * @param array<string,mixed> $context Bounded purge context.
3984 + */
3985 + private static function forward_to_server_caches( array $context ): void {
3986 + if ( class_exists( __NAMESPACE__ . '\\Server_Caches' ) ) {
3987 + Server_Caches::forward( $context );
3988 + }
3989 + }
3990 +
3991 + /**
3992 + * `host[:port]` for a cache key, from a parsed URL.
3993 + *
3994 + * The port is kept, because `cache_key()` hashes the raw `HTTP_HOST` and
3995 + * that carries `:8080` on any install not served from 80/443 — dropping it
3996 + * computed a different md5, found no file, and reported "already cold"
3997 + * while the page kept serving HIT.
3998 + *
3999 + * A port that is the DEFAULT for the scheme is dropped, though, because
4000 + * `HTTP_HOST` does not carry one: a browser sends `Host: site.com` for
4001 + * `https://site.com:443/`. Keeping it hashed `site.com:443` against a file
4002 + * stored under `site.com` — the same silent no-op in the other direction,
4003 + * and the one QA hit passing a canonical URL with the port spelled out.
4004 + * (QA #348)
4005 + *
4006 + * @param array<string,mixed> $parts Output of wp_parse_url().
4007 + */
4008 + private static function host_port_of( array $parts ): string {
4009 + if ( ! isset( $parts['host'] ) ) {
4010 + return '';
4011 + }
4012 + $host = strtolower( (string) $parts['host'] );
4013 + if ( '' === $host || ! isset( $parts['port'] ) ) {
4014 + return $host;
4015 + }
4016 + $port = (int) $parts['port'];
4017 + $scheme = isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : '';
4018 + if ( ( 'https' === $scheme && 443 === $port ) || ( 'http' === $scheme && 80 === $port ) ) {
4019 + return $host;
4020 + }
4021 + return $host . ':' . $port;
4022 + }
4023 +
2838 4024 public static function purge_url( string $url, string $cause = 'manual' ): int {
4025 + // A URL that names nothing is not a purge of everything. An empty or
4026 + // blank string used to fall through to the home_url() default below
4027 + // and clear the HOMEPAGE — so a third party calling
4028 + // `purge_url( get_permalink( $id ) )` on a post whose permalink came
4029 + // back empty silently purged the front page instead of nothing. The
4030 + // CLI and the MCP tool reject empties before reaching this, so only
4031 + // direct API callers were exposed, but they are exactly the audience
4032 + // this public contract is for. (QA #348)
4033 + if ( '' === trim( $url ) ) {
4034 + return 0;
4035 + }
2839 4036 $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 4037 if ( ! is_array( $parts ) ) {
2841 4038 return 0;
2842 4039 }
4040 + // Absolute URLs are accepted only for HTTP response caches. Schemes such
4041 + // as ftp:, file: and javascript: can parse cleanly but do not name a page
4042 + // xSpeed or a server response cache can invalidate. A leading-slash path
4043 + // remains a supported site-relative target.
4044 + if ( isset( $parts['scheme'] ) && ! in_array( strtolower( (string) $parts['scheme'] ), array( 'http', 'https' ), true ) ) {
4045 + return 0;
4046 + }
4047 + if ( isset( $parts['scheme'] ) && empty( $parts['host'] ) ) {
4048 + return 0;
4049 + }
4050 + // Reject a string that parsed but is not a URL we can act on: no
4051 + // scheme AND no host AND no leading-slash path means something like
4052 + // `ht!tp://[[[` or a bare word, which parse_url() hands back as a
4053 + // relative "path". Forwarding that produced `purge_url(/ht!tp://[[[)`
4054 + // — a nonsense tag sent to LiteSpeed for every malformed call.
4055 + if ( ! isset( $parts['scheme'] ) && ! isset( $parts['host'] ) ) {
4056 + $raw = isset( $parts['path'] ) ? (string) $parts['path'] : '';
4057 + if ( '' === $raw || '/' !== $raw[0] ) {
4058 + return 0;
4059 + }
4060 + }
2843 4061 // Keep the port. `cache_key()` hashes the raw `HTTP_HOST`, which
2844 4062 // carries `:8080` on any install not served from 80/443 — while
2845 4063 // parse_url() splits the port into its own component, so a purge that
2846 4064 // used the bare host computed a different md5, found no file, and
@@ -2846,19 +4064,34 @@
2846 4064 // used the bare host computed a different md5, found no file, and
2847 4065 // reported "already cold". A silent no-op: the page kept serving HIT
2848 4066 // until its TTL ran out. Intranet installs, panel hosts on :8443 and
2849 4067 // 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'];
4068 + // A scheme-less `site.test:443/page/` is a supported explicit-host
4069 + // target. Infer a scheme only when it names THIS site's hostname: then
4070 + // its explicit default port is the same origin and the same local cache
4071 + // key. Never apply this to another host or to a non-default port.
4072 + if ( ! isset( $parts['scheme'] ) && isset( $parts['host'], $parts['port'] ) && function_exists( 'home_url' ) ) {
4073 + $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.
4074 + if ( is_array( $home ) && ! empty( $home['host'] ) && ! empty( $home['scheme'] )
4075 + && strtolower( (string) $home['host'] ) === strtolower( (string) $parts['host'] )
4076 + ) {
4077 + $home_scheme = strtolower( (string) $home['scheme'] );
4078 + $port = (int) $parts['port'];
4079 + $home_port = isset( $home['port'] )
4080 + ? (int) $home['port']
4081 + : ( 'https' === $home_scheme ? 443 : ( 'http' === $home_scheme ? 80 : 0 ) );
4082 + if ( $home_port === $port
4083 + && ( ( 'https' === $home_scheme && 443 === $port ) || ( 'http' === $home_scheme && 80 === $port ) )
4084 + ) {
4085 + $parts['scheme'] = $home_scheme;
4086 + }
4087 + }
2853 4088 }
4089 + $host = self::host_port_of( $parts );
2854 4090 if ( '' === $host && function_exists( 'home_url' ) ) {
2855 4091 $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 - }
4092 + if ( is_array( $home ) ) {
4093 + $host = self::host_port_of( $home );
2861 4094 }
2862 4095 }
2863 4096 if ( '' === $host ) {
2864 4097 return 0;
@@ -2942,14 +4175,120 @@
2942 4175 Activity_Log::INFO
2943 4176 );
2944 4177 }
2945 4178
4179 + /**
4180 + * Fires after one URL's cached copy has been purged.
4181 + *
4182 + * The single-URL counterpart to `xspeed_after_purge_all`. Subscribe
4183 + * here to invalidate a cache xSpeed does not own — a server-level
4184 + * cache such as LiteSpeed's LSCache, a reverse proxy, or a CDN — for
4185 + * the same URL.
4186 + *
4187 + * Only fires when the purge actually ran. A malformed URL, a URL with
4188 + * no resolvable host, or a traversal attempt returns earlier and
4189 + * publishes nothing, so a listener can treat this as "xSpeed purged
4190 + * this URL" rather than "xSpeed was asked to". `removed` may legitimately
4191 + * be 0: the URL was not in xSpeed's cache, which says nothing about
4192 + * whether it is in yours.
4193 + *
4194 + * Fires at most once per purge. A listener that calls back into
4195 + * xSpeed's purge API will not re-enter this event.
4196 + *
4197 + * @since 1.2.3
4198 + *
4199 + * @param array $context {
4200 + * Bounded description of the purge. URL queries and caller-supplied
4201 + * causes can contain sensitive values and are not logging fields.
4202 + *
4203 + * @type string $url Canonical scheme://host/path[?query] of the purged URL.
4204 + * The query is preserved because caches in front
4205 + * commonly key on it; xSpeed's own sweep is
4206 + * path-based, so `removed` describes that.
4207 + * @type string $host Host (with port when non-standard).
4208 + * @type string $path Path component, leading slash.
4209 + * @type string $cause Short label for who asked. See purge_all().
4210 + * @type int $removed Number of cache files removed.
4211 + * @type string $scope Actionable adapter scope: `urls`.
4212 + * @type string $intent Why responses changed: `content`.
4213 + * @type string[] $urls Exact response URLs to invalidate.
4214 + * }
4215 + */
4216 + $canonical_url = self::canonical_purge_url(
4217 + $host,
4218 + $path,
4219 + isset( $parts['query'] ) ? (string) $parts['query'] : '',
4220 + isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : ''
4221 + );
4222 + self::dispatch_purge_event(
4223 + 'xspeed_after_purge_url',
4224 + array(
4225 + 'url' => $canonical_url,
4226 + 'host' => $host,
4227 + 'path' => $path,
4228 + 'cause' => $cause,
4229 + 'removed' => $count,
4230 + 'scope' => 'urls',
4231 + 'intent' => 'content',
4232 + 'urls' => array( $canonical_url ),
4233 + )
4234 + );
4235 +
2946 4236 return $count;
2947 4237 }
2948 4238
4239 + /** Host this site's purge is scoped to, for the purge-event context. */
4240 + private static function current_purge_host(): string {
4241 + if ( ! function_exists( 'home_url' ) ) {
4242 + return '';
4243 + }
4244 + $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.
4245 + if ( ! is_array( $home ) || empty( $home['host'] ) ) {
4246 + return '';
4247 + }
4248 + // Same default-port normalisation as purge_url(): a site whose
4249 + // home_url() carries `:443` (normal behind a proxy) otherwise stamps
4250 + // every full-purge event with a host that matches none of its own
4251 + // URLs, so the LiteSpeed forward stood down site-wide. (QA #348)
4252 + return self::host_port_of( $home );
4253 + }
4254 +
2949 4255 /**
2950 - * Purge this site's cache.
4256 + * Rebuild the canonical URL a purge applied to.
2951 4257 *
4258 + * Built from the parts the purge itself used, so a listener is told the
4259 + * URL we acted on rather than the string the caller happened to pass —
4260 + * those differ whenever the caller supplied a site-relative path, a
4261 + * different scheme, or a query string the cache key ignores.
4262 + */
4263 + private static function canonical_purge_url( string $host, string $path, string $query = '', string $url_scheme = '' ): string {
4264 + // The purged URL's own scheme wins. purge_url() explicitly supports
4265 + // cross-site purges (multisite, WP-CLI, cron), where composing the
4266 + // current site's scheme onto another site's host builds a URL that was
4267 + // never served — and a CDN listener then purges the wrong key and
4268 + // reports success.
4269 + if ( '' !== $url_scheme ) {
4270 + return $url_scheme . '://' . $host . $path . ( '' !== $query ? '?' . $query : '' );
4271 + }
4272 + $scheme = function_exists( 'is_ssl' ) && is_ssl() ? 'https' : 'http';
4273 + if ( function_exists( 'home_url' ) ) {
4274 + $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.
4275 + if ( is_array( $home ) && ! empty( $home['scheme'] ) ) {
4276 + $scheme = (string) $home['scheme'];
4277 + }
4278 + }
4279 + // The query is carried even though OUR sweep above is path-based.
4280 + // Caches in front commonly key on the full request line — LiteSpeed
4281 + // tags `/shop/?page=2` separately from `/shop/` — so publishing the
4282 + // bare path would have a listener confidently purge the wrong entry
4283 + // and report success. Telling it exactly what was asked for lets it
4284 + // act correctly; `removed` still describes only what WE removed.
4285 + return $scheme . '://' . $host . $path . ( '' !== $query ? '?' . $query : '' );
4286 + }
4287 +
4288 + /**
4289 + * Sweep this site's cache files.
4290 + *
2952 4291 * On multisite every blog shares one cache directory, so an unscoped
2953 4292 * sweep here took the whole network cold — one subsite's settings save
2954 4293 * or post publish rebuilt every other site from PHP. Entries are stored
2955 4294 * per host (see host_dir()), and the sweep is scoped to match, so a
@@ -2954,16 +4293,27 @@
2954 4293 * or post publish rebuilt every other site from PHP. Entries are stored
2955 4294 * per host (see host_dir()), and the sweep is scoped to match, so a
2956 4295 * purge originating on site-a leaves site-b's cache warm. (#6)
2957 4296 *
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.
4297 + * Clears the files only: the flat tree, the static tree, the REST
4298 + * responses and the minified assets. The object-cache flush, the stats
4299 + * update, `xspeed_after_purge_all`, the `xspeed_after_purge` contract
4300 + * event and the log entry live in purge_all(), which is still the entry
4301 + * point for every existing caller. Split out so `wp xspeed purge` can
4302 + * report the local sweep as one line item and the object cache as
4303 + * another, each with its own status — see Purge_Runner.
4304 + *
4305 + * @param string|null $host Host to purge. Defaults to the current site.
4306 + * Pass '*' to sweep the ENTIRE tree — network
4307 + * admin's "purge all sites", and the migration
4308 + * of pre-#6 entries that sit in the tree root.
4309 + * @return array{pages:int,rest:int,assets:int,bytes:int} Entries removed
4310 + * per store, and the bytes freed by the two file sweeps
4311 + * that measure themselves.
2963 4312 */
2964 - public static function purge_all( string $cause = 'manual', ?string $host = null ) {
2965 - $network_wide = ( '*' === $host );
4313 + public static function purge_local( ?string $host = null ): array {
4314 + $network_wide = ( '*' === $host );
4315 + self::$sweep_bytes = 0;
2966 4316 // The flat tree buckets by a flattened segment (host/a-b) while the
2967 4317 // static tree mirrors the URL (host/a/b), so they need separate
2968 4318 // scopes — see current_host_dir() vs current_static_scope().
2969 4319 $static_scope = '';
@@ -2972,9 +4322,10 @@
2972 4322 $static_scope = $network_wide ? '' : self::current_static_scope();
2973 4323 } else {
2974 4324 $dir = self::host_dir( $host );
2975 4325 $scope = '' === $dir ? 'default' : $dir;
2976 - $static_scope = $scope;
4326 + $static_dir = self::static_host_dir( $host );
4327 + $static_scope = '' === $static_dir ? 'default' : $static_dir;
2977 4328 }
2978 4329
2979 4330 $count = 0;
2980 4331 if ( is_dir( XSPEED_CACHE_DIR ) ) {
@@ -3024,9 +4375,9 @@
3024 4375 $files = glob( $root . '/*.html' );
3025 4376 if ( $files ) {
3026 4377 $count += count( $files );
3027 4378 foreach ( $files as $f ) {
3028 - wp_delete_file( $f );
4379 + self::sweep_delete( $f );
3029 4380 }
3030 4381 }
3031 4382 // Remove the .meta sidecars (content-type for feeds/sitemaps)
3032 4383 // alongside their .html entries. Not counted — they're not
@@ -3033,9 +4384,9 @@
3033 4384 // cache "pages", just per-entry metadata.
3034 4385 $meta = glob( $root . '/*.meta' );
3035 4386 if ( $meta ) {
3036 4387 foreach ( $meta as $m ) {
3037 - wp_delete_file( $m );
4388 + self::sweep_delete( $m );
3038 4389 }
3039 4390 }
3040 4391 // Remove precompressed siblings (e.g. <key>.html.br from the Pro
3041 4392 // Brotli module). Not counted — same as .meta. Without this a
@@ -3043,9 +4394,9 @@
3043 4394 // staleness window if precompression is later disabled.
3044 4395 $br = glob( $root . '/*.br' );
3045 4396 if ( $br ) {
3046 4397 foreach ( $br as $b ) {
3047 - wp_delete_file( $b );
4398 + self::sweep_delete( $b );
3048 4399 }
3049 4400 }
3050 4401 // `*.br` does not match `*.br.size` — same reason as the flat-root
3051 4402 // sweep above: a size record outliving its body would later be
@@ -3052,9 +4403,9 @@
3052 4403 // read against a different sibling's bytes.
3053 4404 $br_size = glob( $root . '/*.br.size' );
3054 4405 if ( $br_size ) {
3055 4406 foreach ( $br_size as $b ) {
3056 - wp_delete_file( $b );
4407 + self::sweep_delete( $b );
3057 4408 }
3058 4409 }
3059 4410 }
3060 4411 }
@@ -3071,9 +4422,10 @@
3071 4422 }
3072 4423 }
3073 4424 // REST response cache (cache/xspeed/rest/*.json) — same purge
3074 4425 // triggers (publish, settings change) invalidate it too.
3075 - $count += Rest_Cache::purge();
4426 + $rest = Rest_Cache::purge();
4427 + $count += $rest;
3076 4428
3077 4429 // Minified + combined CSS/JS (cache/xspeed/min/ and min/combined/).
3078 4430 // purge_all is a full filesystem sweep and must clear these too, even
3079 4431 // when the Minify module is currently disabled — orphaned min/ files
@@ -3079,20 +4431,33 @@
3079 4431 // when the Minify module is currently disabled — orphaned min/ files
3080 4432 // from a feature the user later turned off must still be removed, and
3081 4433 // a stale combined-<hash>.css that the regenerated page no longer
3082 4434 // references otherwise 404s and breaks the frontend. (FBS-83114/83116)
3083 - if ( class_exists( '\\XSpeed\\Minifier' ) ) {
3084 - Minifier::purge_minified();
3085 - }
4435 + $assets = class_exists( '\\XSpeed\\Minifier' ) ? Minifier::purge_minified() : 0;
3086 4436
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)
4437 + return array(
4438 + 'pages' => $count - $rest,
4439 + 'rest' => $rest,
4440 + 'assets' => $assets,
4441 + 'bytes' => self::$sweep_bytes,
4442 + );
4443 + }
4444 +
4445 + /**
4446 + * Flush the persistent object cache (Redis / Memcached).
4447 + *
4448 + * Runs regardless of whether the Object Cache module is currently
4449 + * enabled — a drop-in installed earlier keeps serving until flushed.
4450 + *
4451 + * @param bool $network_wide Flush every blog's entries. wp_cache_flush()
4452 + * is NETWORK-global, so on multisite the
4453 + * default prefers the blog-scoped group flush
4454 + * (WP 6.1+) — otherwise one site's purge drops
4455 + * every other site's object cache, the same bug
4456 + * #6 fixed for the page cache.
4457 + * @return bool Whether a flush was actually performed.
4458 + */
4459 + public static function flush_object_cache( bool $network_wide = false ): bool {
3095 4460 if ( ! $network_wide && is_multisite() && function_exists( 'wp_cache_flush_group' ) && function_exists( 'wp_cache_supports' ) && wp_cache_supports( 'flush_group' ) ) {
3096 4461 // Blog-scoped groups only; a shared/global group (site options,
3097 4462 // user meta) is intentionally left alone.
3098 4463 foreach ( array( 'options', 'posts', 'terms', 'post_meta', 'comment' ) as $group ) {
@@ -3097,12 +4462,62 @@
3097 4462 // user meta) is intentionally left alone.
3098 4463 foreach ( array( 'options', 'posts', 'terms', 'post_meta', 'comment' ) as $group ) {
3099 4464 wp_cache_flush_group( $group );
3100 4465 }
3101 - } elseif ( function_exists( 'wp_cache_flush' ) ) {
3102 - wp_cache_flush();
4466 + return true;
3103 4467 }
4468 + if ( function_exists( 'wp_cache_flush' ) ) {
4469 + return (bool) wp_cache_flush();
4470 + }
4471 + return false;
4472 + }
3104 4473
4474 + /**
4475 + * Purge this site's cache: the local sweep, then the object cache, then
4476 + * the bookkeeping every caller expects (stats, `xspeed_after_purge_all`,
4477 + * inventory invalidation, purge log).
4478 + *
4479 + * @param string $cause Who asked, for the purge log.
4480 + * @param string|null $host See purge_local().
4481 + * @param array<string,mixed> $invalidation Public adapter policy. `scope`
4482 + * is urls/site/network/none,
4483 + * `intent` explains why, and
4484 + * `urls` supplies exact targets.
4485 + * @return int Page + REST entries removed.
4486 + */
4487 + public static function purge_all( string $cause = 'manual', ?string $host = null, array $invalidation = array() ) {
4488 + $network_wide = ( '*' === $host );
4489 + $adapter_scope = isset( $invalidation['scope'] ) && is_string( $invalidation['scope'] )
4490 + ? $invalidation['scope']
4491 + : ( $network_wide ? 'network' : 'site' );
4492 + if ( ! in_array( $adapter_scope, array( 'urls', 'site', 'network', 'none' ), true ) ) {
4493 + $adapter_scope = $network_wide ? 'network' : 'site';
4494 + }
4495 + if ( $network_wide ) {
4496 + $adapter_scope = 'network';
4497 + }
4498 + $intent = isset( $invalidation['intent'] ) && is_string( $invalidation['intent'] ) && '' !== $invalidation['intent']
4499 + ? $invalidation['intent']
4500 + : 'complete';
4501 + $urls = isset( $invalidation['urls'] ) && is_array( $invalidation['urls'] )
4502 + ? array_values( array_unique( array_filter( $invalidation['urls'], 'is_string' ) ) )
4503 + : array();
4504 + // This method always sweeps a complete local bucket. A narrower adapter
4505 + // announcement would claim unrelated local pages stayed warm when they
4506 + // did not, leaving their server copies stale. Until purge_all() gains
4507 + // dependency-aware local deletion, its response scope cannot be `urls`.
4508 + if ( 'urls' === $adapter_scope ) {
4509 + $adapter_scope = $network_wide ? 'network' : 'site';
4510 + }
4511 + if ( 'site' === $adapter_scope || 'network' === $adapter_scope || 'none' === $adapter_scope ) {
4512 + $urls = array();
4513 + }
4514 +
4515 + $removed = self::purge_local( $host );
4516 + $count = $removed['pages'] + $removed['rest'];
4517 +
4518 + self::flush_object_cache( $network_wide );
4519 +
3105 4520 self::update_stats( array( 'last_purge' => time() ) );
3106 4521
3107 4522 // Fire AFTER the local sweep so module listeners (Critical CSS,
3108 4523 // Unused CSS, Cloudflare edge purge) run — this action had three
@@ -3108,10 +4523,64 @@
3108 4523 // Unused CSS, Cloudflare edge purge) run — this action had three
3109 4524 // registered listeners but was never emitted. Treat it as additive
3110 4525 // (CDN / edge invalidation), not the mechanism for clearing local
3111 4526 // files. (FBS-83114)
3112 - do_action( 'xspeed_after_purge_all', $cause );
4527 + // Wrapped: this action predates the purge-event contract and has its
4528 + // own third-party listeners. One of them throwing used to abort
4529 + // purge_all() here, which now also means the contract event below
4530 + // never fires and a server cache keeps serving stale HTML. The local
4531 + // sweep is already done by this point, so swallowing is strictly safer
4532 + // than letting a listener decide the rest of the method runs.
4533 + try {
4534 + // Isolated per listener: one throwing used to cancel every
4535 + // listener queued behind it — Critical CSS, Unused CSS and the
4536 + // Cloudflare edge purge all hang off this hook. (QA #348)
4537 + self::do_action_isolated( 'xspeed_after_purge_all', $cause );
4538 + } catch ( \Throwable $e ) {
4539 + self::log_purge_listener_error( 'xspeed_after_purge_all', $e );
4540 + }
3113 4541
4542 + /**
4543 + * Fires after a full purge, with the same bounded context shape as
4544 + * `xspeed_after_purge_url`.
4545 + *
4546 + * Distinct from `xspeed_after_purge_all` on purpose. That action is
4547 + * the long-standing internal signal — it passes a bare `$cause` string
4548 + * and Free's own modules use it for local bookkeeping. This one is the
4549 + * documented contract for OUTSIDE integrations: same argument shape as
4550 + * the per-URL event, so a server-cache or CDN adapter can subscribe to
4551 + * both with one handler and branch on a null `url`.
4552 + *
4553 + * Fires at most once per purge, and not at all when a listener's own
4554 + * purge re-enters xSpeed.
4555 + *
4556 + * @since 1.2.3
4557 + *
4558 + * @param array $context {
4559 + * @type null $url Always null — a full purge has no single URL.
4560 + * @type string $host Host swept, or '*' for the entire tree.
4561 + * @type null $path Always null.
4562 + * @type string $cause Short label for who asked.
4563 + * @type int $removed Number of cache files removed.
4564 + * @type string $scope Adapter action: urls/site/network/none.
4565 + * @type string $intent content/presentation/complete or a caller-defined intent.
4566 + * @type string[] $urls Exact targets when scope is urls.
4567 + * }
4568 + */
4569 + self::dispatch_purge_event(
4570 + 'xspeed_after_purge',
4571 + array(
4572 + 'url' => null,
4573 + 'host' => null === $host ? self::current_purge_host() : (string) $host,
4574 + 'path' => null,
4575 + 'cause' => $cause,
4576 + 'removed' => $count,
4577 + 'scope' => $adapter_scope,
4578 + 'intent' => $intent,
4579 + 'urls' => $urls,
4580 + )
4581 + );
4582 +
3114 4583 // The list behind the "Cached pages" card is memoized for a minute;
3115 4584 // a purge has to drop it or the drill-down shows pages that no
3116 4585 // longer exist.
3117 4586 Cache_Inventory::invalidate();
@@ -3711,8 +5180,9 @@
3711 5180 $count = self::purge_pages();
3712 5181 self::update_stats( array( 'last_purge' => time() ) );
3713 5182 Cache_Inventory::invalidate();
3714 5183 self::record_partial_purge( 'page', $cause, $count );
5184 + self::announce_purge( $cause, $count );
3715 5185 return $count;
3716 5186
3717 5187 case 'assets':
3718 5188 if ( class_exists( '\\XSpeed\\Minifier' ) ) {
@@ -3739,8 +5209,9 @@
3739 5209 $count = self::purge_pages();
3740 5210 self::update_stats( array( 'last_purge' => time() ) );
3741 5211 Cache_Inventory::invalidate();
3742 5212 self::record_partial_purge( 'assets', $cause, $count );
5213 + self::announce_purge( $cause, $count );
3743 5214 return $count;
3744 5215
3745 5216 case 'object':
3746 5217 if ( function_exists( 'wp_cache_flush' ) ) {
@@ -3751,8 +5222,9 @@
3751 5222
3752 5223 case 'rest':
3753 5224 $count = Rest_Cache::purge();
3754 5225 self::record_partial_purge( 'REST responses', $cause, $count );
5226 + self::announce_purge( $cause, $count );
3755 5227 return $count;
3756 5228
3757 5229 default:
3758 5230 return self::purge_type_unhandled( $type, $cause );
@@ -3805,15 +5277,101 @@
3805 5277 * @param string $type Purge-type slug.
3806 5278 * @param string $cause Who asked.
3807 5279 */
3808 5280 private static function purge_type_unhandled( string $type, string $cause ): int {
3809 - do_action( 'xspeed_purge_type_' . $type );
5281 + $event_sequence = self::$purge_event_sequence;
5282 + $hook = 'xspeed_purge_type_' . $type;
5283 + $has_handler = false !== has_action( $hook );
5284 + do_action( $hook );
3810 5285 self::record_partial_purge( $type, $cause, null );
3811 5286
5287 + // Announce, same as the types this class owns. Pro's "Purge Critical
5288 + // CSS" and "Purge Unused CSS" arrive here, and they change what a
5289 + // cached page CONTAINS — critical CSS is inlined into the HTML, so a
5290 + // server cache goes on serving pages with the old styles baked in.
5291 + // Fixing the three Free buttons and leaving these two silent left the
5292 + // same hole for the tier most likely to be using both plugins.
5293 + // (QA #348 round 2, issue 2)
5294 + //
5295 + // Unknown slugs must not turn into a site-wide purge merely because no
5296 + // handler exists. These are the response-changing Pro types Free knows;
5297 + // third parties can declare another through the filter. A registered
5298 + // handler plus this explicit response scope is the handled signal.
5299 + $scope = in_array( $type, array( 'critical-css', 'unused-css' ), true ) ? 'site' : 'none';
5300 + /**
5301 + * Declare whether a handled custom purge type changes cached responses.
5302 + *
5303 + * @since 1.2.3
5304 + * @param string $scope site/network/none.
5305 + * @param string $type Purge-type slug.
5306 + */
5307 + $scope = (string) apply_filters( 'xspeed_purge_type_response_scope', $scope, $type );
5308 + if ( $has_handler
5309 + && $event_sequence === self::$purge_event_sequence
5310 + && in_array( $scope, array( 'site', 'network' ), true )
5311 + ) {
5312 + self::announce_purge( $cause, 0, $scope, 'presentation' );
5313 + }
5314 +
3812 5315 return 0;
3813 5316 }
3814 5317
3815 5318 /**
5319 + * Tell the server cache that a PARTIAL purge cleared cached responses.
5320 + *
5321 + * "Purge Page / Static Cache", "Purge CSS / JS Cache" and "Purge REST
5322 + * Cache" each delete cached RESPONSES for the whole site, so a cache in
5323 + * front of PHP is now serving copies xSpeed has just thrown away. Only
5324 + * "Purge All" announced itself, which left three of the four toolbar
5325 + * buttons doing exactly what this contract exists to prevent: clearing
5326 + * our copy while the server kept serving the stale one. The `assets` case
5327 + * was the sharpest — it deletes the minified bundles too, so LiteSpeed
5328 + * went on serving pages whose CSS and JS no longer exist. (QA #348)
5329 + *
5330 + * Sent as the full-purge shape (`url` null) because that is what happened:
5331 + * every cached page for this site went, not one address. `object` is not
5332 + * announced — flushing the object cache changes no rendered response a
5333 + * server cache could be holding.
5334 + *
5335 + * Public because Purge_Runner sweeps the local files itself, through
5336 + * purge_local(), rather than through purge_all() — so it has to announce
5337 + * on its own behalf or `wp xspeed purge` and the dashboard button clear
5338 + * our copy while LiteSpeed keeps serving the stale one.
5339 + *
5340 + * @param string $cause Who asked.
5341 + * @param int $removed Entries removed locally.
5342 + * @param string $scope Actionable adapter scope.
5343 + * @param string $intent Reason rendered responses changed.
5344 + */
5345 + public static function announce_purge( string $cause, int $removed, string $scope = 'site', string $intent = 'complete' ): void {
5346 + // Announcing is additive: the local sweep has already happened and
5347 + // succeeded. Notification must never be able to turn a working purge
5348 + // into a fatal, so anything the URL helpers do in an unusual context
5349 + // (early boot, a drop-in, a bare test harness) is contained here
5350 + // rather than propagating to the caller.
5351 + if ( ! function_exists( 'home_url' ) || ! function_exists( 'do_action' ) ) {
5352 + return;
5353 + }
5354 + try {
5355 + self::dispatch_purge_event(
5356 + 'xspeed_after_purge',
5357 + array(
5358 + 'url' => null,
5359 + 'host' => self::current_purge_host(),
5360 + 'path' => null,
5361 + 'cause' => $cause,
5362 + 'removed' => $removed,
5363 + 'scope' => $scope,
5364 + 'intent' => $intent,
5365 + 'urls' => array(),
5366 + )
5367 + );
5368 + } catch ( \Throwable $e ) {
5369 + self::log_purge_listener_error( 'xspeed_after_purge', $e );
5370 + }
5371 + }
5372 +
5373 + /**
3816 5374 * Log a partial purge so the drill-down behind "Last purge" shows every
3817 5375 * clear, not only the full ones. Without this a site whose object cache
3818 5376 * is flushed on a schedule looks, from the log, like nothing happens.
3819 5377 *
@@ -3863,8 +5421,24 @@
3863 5421 * Returns the number of .html files removed so purge stats stay accurate
3864 5422 * across the flat + static caches — .br siblings are not counted
3865 5423 * (they're encodings of a page, not pages).
3866 5424 */
5425 + /**
5426 + * Delete a cache file, adding its size to the current sweep's byte
5427 + * total. filesize() is silenced and re-checked because the file can
5428 + * vanish between the glob and the unlink — a concurrent purge, or the
5429 + * cache GC — and a warning there would be noise, not news.
5430 + *
5431 + * @param string $file Absolute path inside the cache tree.
5432 + */
5433 + private static function sweep_delete( string $file ): void {
5434 + $size = @filesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- the file may be gone already; see docblock.
5435 + if ( is_int( $size ) ) {
5436 + self::$sweep_bytes += $size;
5437 + }
5438 + wp_delete_file( $file );
5439 + }
5440 +
3867 5441 private static function rmtree_html( string $dir ): int {
3868 5442 if ( ! is_dir( $dir ) ) {
3869 5443 return 0;
3870 5444 }
@@ -3888,9 +5462,9 @@
3888 5462 @rmdir( $path );
3889 5463 continue;
3890 5464 }
3891 5465 if ( substr( $entry, -5 ) === '.html' ) {
3892 - wp_delete_file( $path );
5466 + self::sweep_delete( $path );
3893 5467 ++$removed;
3894 5468 } elseif ( substr( $entry, -3 ) === '.br' || substr( $entry, -8 ) === '.br.size' ) {
3895 5469 // Precompressed sibling (index.html.br) and the record of its
3896 5470 // length. Remove both so a purge doesn't orphan stale Brotli
@@ -3895,9 +5469,9 @@
3895 5469 // Precompressed sibling (index.html.br) and the record of its
3896 5470 // length. Remove both so a purge doesn't orphan stale Brotli
3897 5471 // bodies, or a size record that would later be read against a
3898 5472 // different sibling's bytes. Not counted.
3899 - wp_delete_file( $path );
5473 + self::sweep_delete( $path );
3900 5474 }
3901 5475 }
3902 5476 return $removed;
3903 5477 }
@@ -3994,8 +5568,25 @@
3994 5568 // True when an edge cache (Cloudflare) fronts the origin, so hits are
3995 5569 // absorbed before reaching PHP. The dashboard labels the ratio
3996 5570 // "origin-layer only" instead of implying it's the full picture. (#118)
3997 5571 'edge_cache' => self::edge_cache_detected(),
5572 + // LiteSpeed Static Fast Path (#509): the web server serves hits
5573 + // with no PHP, no way to tag them, and no way to count them. The
5574 + // dashboard labels the ratio as PHP-layer only so a low number
5575 + // reads as the trade the user chose, not a fault.
5576 + //
5577 + // rewrite_installed() is part of the condition (QA on #513): when
5578 + // the .htaccess write failed (read-only file), hits still take
5579 + // the drop-in path and ARE counted — the disclosure would be the
5580 + // opposite of the truth. Health carries the "block missing"
5581 + // warning for that state; this flag only speaks when static
5582 + // serving is genuinely in effect.
5583 + 'static_hits_uncounted' => (
5584 + Server::LITESPEED === Server::type()
5585 + && ! empty( Settings::get()['cache_enabled'] )
5586 + && self::static_rewrite_allowed()
5587 + && self::rewrite_installed()
5588 + ),
3998 5589 /*
3999 5590 * Whether the page cache is actually SERVING, as opposed to
4000 5591 * switched on in settings. The hero read the setting alone and
4001 5592 * announced "Active — serving cached HTML"; a site whose
@@ -4011,14 +5602,46 @@
4011 5602 * that state — the detector sweep behind it is far more work than
4012 5603 * a stats call should do on an ordinary healthy site.
4013 5604 */
4014 5605 'page_cache_blocked_reason' => ( ! $serving && ! empty( Settings::get()['cache_enabled'] ) )
4015 - ? self::acquisition_blocker()
5606 + ? ( self::acquisition_blocker() ?? self::not_serving_reason() )
4016 5607 : null,
4017 5608 );
4018 5609 }
4019 5610
4020 5611 /**
5612 + * Why the cache is not serving, when nothing REFUSES to enable it.
5613 + *
5614 + * acquisition_blocker() answers "may we take the field", and since a
5615 + * foreign drop-in became takeable it answers null on a site where another
5616 + * plugin is nonetheless holding that file. Intent and outcome still
5617 + * disagree there, and the dashboard was left reporting the symptom -- not
5618 + * serving -- with no reason under it, which is exactly the state a user
5619 + * cannot act on.
5620 + *
5621 + * So this names the holder and says what to do: enabling takes it over.
5622 + */
5623 + private static function not_serving_reason(): ?string {
5624 + $owner = self::dropin_owner();
5625 + if ( self::DROPIN_FOREIGN !== $owner && self::DROPIN_UNREADABLE !== $owner ) {
5626 + return null;
5627 + }
5628 +
5629 + if ( self::DROPIN_UNREADABLE === $owner ) {
5630 + return __( 'advanced-cache.php cannot be read, so xSpeed cannot tell whose page cache is installed.', 'xspeed' );
5631 + }
5632 +
5633 + $label = Page_Cache_Detector::dropin_owner_label();
5634 + return $label
5635 + ? sprintf(
5636 + /* translators: %s: the page-caching plugin that owns advanced-cache.php. */
5637 + __( '%s is serving the page cache. Turn the xSpeed cache off and on again to take it over.', 'xspeed' ),
5638 + $label
5639 + )
5640 + : __( 'Another plugin is serving the page cache. Turn the xSpeed cache off and on again to take it over.', 'xspeed' );
5641 + }
5642 +
5643 + /**
4021 5644 * Whether the current request should be kept OUT of the cache hit/miss
4022 5645 * ratio: a genuine 404, or a known bot / scanner. Runs at template_redirect
4023 5646 * time, so is_404() is resolved. (#118)
4024 5647 */
@@ -4032,16 +5655,20 @@
4032 5655 return Hit_Counter::is_bot_ua( $ua );
4033 5656 }
4034 5657
4035 5658 /**
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)
5659 + * Whether an edge cache fronts this origin, so an unknown share of hits
5660 + * is served there and never counted here — which makes the origin ratio a
5661 + * partial view the dashboard has to label as such. (#118)
5662 + *
5663 + * This used to mean "the Cloudflare module is switched on", which answered
5664 + * no for every site fronted by anything else, and no for a site on
5665 + * Cloudflare that had never opened our Cloudflare panel. Both of those
5666 + * sites had their ratio presented as the whole story. Edge_Provider knows
5667 + * better and knows it per request, so ask it.
4040 5668 */
4041 5669 private static function edge_cache_detected(): bool {
4042 - $cf = get_option( 'xspeed_module_cloudflare', array() );
4043 - return is_array( $cf ) && ! empty( $cf['enabled'] );
5670 + return Edge_Provider::NONE !== Edge_Provider::detect()['confidence'];
4044 5671 }
4045 5672
4046 5673 /**
4047 5674 * Apply the user's enable/disable choice. Called from the REST toggle
@@ -4072,9 +5699,9 @@
4072 5699 * wp_config_writable: bool,
4073 5700 * manual_snippet: ?string
4074 5701 * }
4075 5702 */
4076 - public static function toggle( $enable ) {
5703 + public static function toggle( $enable, bool $consented = true ) {
4077 5704 Page_Cache_Detector::invalidate();
4078 5705 $expected = Page_Cache_Detector::inspect()['revision'];
4079 5706 /** Diagnostic seam; changing the expected revision can only force a safe refusal. */
4080 5707 $expected = (string) apply_filters( 'xspeed_page_cache_expected_revision', $expected );
@@ -4087,9 +5714,9 @@
4087 5714 $fresh = Page_Cache_Detector::inspect()['revision'];
4088 5715 if ( ! hash_equals( (string) $expected, (string) $fresh ) ) {
4089 5716 return self::blocked_toggle_state( __( 'Page-cache ownership changed while xSpeed was checking it. Nothing was changed; try again.', 'xspeed' ) );
4090 5717 }
4091 - $state = self::toggle_unlocked( (bool) $enable );
5718 + $state = self::toggle_unlocked( (bool) $enable, $consented );
4092 5719 return $state;
4093 5720 } finally {
4094 5721 flock( $lock, LOCK_UN );
4095 5722 fclose( $lock );
@@ -4096,9 +5723,14 @@
4096 5723 }
4097 5724 }
4098 5725
4099 5726 /** Run the page-cache mutation while toggle() owns the scoped lock. */
4100 - private static function toggle_unlocked( bool $enable ) {
5727 + /**
5728 + * @param bool $consented The user asked for this in the dashboard, so a
5729 + * foreign drop-in may be taken over. False on the
5730 + * unattended paths, which stand down instead.
5731 + */
5732 + private static function toggle_unlocked( bool $enable, bool $consented = true ) {
4101 5733 $enable = (bool) $enable;
4102 5734
4103 5735 if ( $enable ) {
4104 5736 /*
@@ -4131,8 +5763,37 @@
4131 5763 * on exactly the healthy sites this branch is about.
4132 5764 */
4133 5765 $reasserting = self::page_cache_operational() && self::DROPIN_XSPEED === self::dropin_owner();
4134 5766 $blocker = $reasserting ? null : self::acquisition_blocker();
5767 +
5768 + /*
5769 + * Taking over another plugin's drop-in needs the user to have
5770 + * asked for it. On the dashboard they did -- they clicked the
5771 + * switch, having been told whose file it is. The UNATTENDED
5772 + * callers have no such click: restore_dropin_if_enabled() runs
5773 + * after a plugin update and auto_heal() on an admin page load,
5774 + * both from nothing more than `cache_enabled` still being true.
5775 + *
5776 + * A competitor installed since that flag was set would have its
5777 + * page cache seized by a background repair, which is the silent
5778 + * acquisition this plugin refuses to perform. So those callers
5779 + * pass $consented = false and stand down instead.
5780 + */
5781 + if ( null === $blocker && ! $consented && self::DROPIN_FOREIGN === self::dropin_owner() ) {
5782 + // Name the owner. This string is rendered by host plugins
5783 + // through Host::enable_page_cache(), and an unnamed refusal
5784 + // is what made every host invent its own explanation.
5785 + $owner_label = Page_Cache_Detector::dropin_owner_label();
5786 + return self::blocked_toggle_state(
5787 + $owner_label
5788 + ? sprintf(
5789 + /* translators: %s: the page-caching plugin that owns advanced-cache.php. */
5790 + __( '%s owns advanced-cache.php, so xSpeed left it alone. Enable the cache from the xSpeed dashboard to take it over.', 'xspeed' ),
5791 + $owner_label
5792 + )
5793 + : __( 'Another plugin owns advanced-cache.php, so xSpeed left it alone. Enable the cache from the xSpeed dashboard to take it over.', 'xspeed' )
5794 + );
5795 + }
4135 5796 if ( null !== $blocker ) {
4136 5797 Activity_Log::record(
4137 5798 'cache_enable_blocked',
4138 5799 'Cache not enabled — ' . $blocker,
@@ -4604,10 +6265,11 @@
4604 6265 * they don't share a user at all. A default-umask 0644 file is then
4605 6266 * unwritable by nginx, the access_log write silently fails, and the
4606 6267 * dashboard shows a 0% hit ratio even though static HITs are serving.
4607 6268 * 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.)
6269 + * so whatever uid nginx runs as can append. The file holds HIT request
6270 + * lines and must be protected like an access log: paths and queries can
6271 + * contain sensitive values.
4610 6272 */
4611 6273 /**
4612 6274 * Directory holding the nginx hit log. Lives under uploads/, NOT the
4613 6275 * cache dir — uninstall.php and a cache purge both delete the cache
@@ -4745,10 +6407,22 @@
4745 6407 */
4746 6408 public static function sync_query_allowlist(): void {
4747 6409 $file = XSPEED_CACHE_DIR . '/.ignored-query-params';
4748 6410
4749 - $opts = Settings_Manager::get( 'cache' );
4750 - $ignored = is_array( $opts['ignored_query_params'] ?? null ) ? $opts['ignored_query_params'] : array();
6411 + /*
6412 + * Stored read, not Settings_Manager::get() — this runs from boot(),
6413 + * before translation is legal (see stored_cache_opts()).
6414 + *
6415 + * A raw read applies no schema defaults, and this field's default is a
6416 + * long tracking-parameter list, NOT empty. Falling back to array()
6417 + * would strip that whole allow-list from the drop-in on any install
6418 + * that has never saved the Cache panel. So fall back to the schema's
6419 + * own default, read from the module without building its labels.
6420 + */
6421 + $opts = self::stored_cache_opts();
6422 + $ignored = is_array( $opts['ignored_query_params'] ?? null )
6423 + ? $opts['ignored_query_params']
6424 + : \XSpeed\Modules\Cache\CacheModule::DEFAULT_IGNORED_QUERY_PARAMS;
4751 6425
4752 6426 $parts = array();
4753 6427 foreach ( $ignored as $pattern ) {
4754 6428 $pattern = trim( (string) $pattern );
@@ -4799,12 +6473,54 @@
4799 6473 // 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 6474 file_put_contents( $file, $payload, LOCK_EX );
4801 6475 }
4802 6476
6477 + /**
6478 + * CacheModule's STORED settings, read straight from the option.
6479 + *
6480 + * `Settings_Manager::get( 'cache' )` builds CacheModule's settings schema,
6481 + * whose labels are declared through `__()`. The reconcile chain below runs
6482 + * from `CacheModule::boot()` on `plugins_loaded` — before
6483 + * `after_setup_theme`, the point WordPress 6.7+ treats as safe to
6484 + * translate — so going through the schema there fires
6485 + * `_load_textdomain_just_in_time` on every request AND resolves the labels
6486 + * against a domain that is not loaded yet.
6487 + *
6488 + * The callers here need stored values, not schema metadata, so a raw read
6489 + * is equivalent. It applies NO defaults or coercion: read each key with a
6490 + * fallback matching the schema's own default.
6491 + *
6492 + * @return array<string,mixed>
6493 + */
6494 + private static function stored_cache_opts(): array {
6495 + $stored = get_option( Settings_Manager::OPTION_PREFIX . 'cache', array() );
6496 + return is_array( $stored ) ? $stored : array();
6497 + }
6498 +
6499 + /**
6500 + * Strict truthiness for the LiteSpeed Static Fast Path opt-in.
6501 + *
6502 + * On non-LiteSpeed servers the key is out of the schema and carried by
6503 + * preserved_keys(), so a REST/MCP write lands VERBATIM — QA on #513
6504 + * stored the string "false" on Apache and the fast path installed
6505 + * itself the moment the site moved to LiteSpeed, because
6506 + * empty("false") is false. Only an explicit, unambiguous "yes" may
6507 + * enable a path that trades away hit tagging; any other value —
6508 + * "false", "no", arbitrary junk — stays OFF, which is the default the
6509 + * user never left.
6510 + */
6511 + private static function litespeed_optin_enabled( $value ): bool {
6512 + if ( true === $value || 1 === $value ) {
6513 + return true;
6514 + }
6515 + return is_string( $value )
6516 + && in_array( strtolower( trim( $value ) ), array( '1', 'true', 'on', 'yes' ), true );
6517 + }
6518 +
4803 6519 public static function sync_mobile_flag( $enabled = null ): void {
4804 6520 if ( null === $enabled ) {
4805 - $opts = Settings_Manager::get( 'cache' );
4806 - $enabled = ! empty( $opts['mobile_separate'] );
6521 + $stored = self::stored_cache_opts();
6522 + $enabled = ! empty( $stored['mobile_separate'] );
4807 6523 }
4808 6524 $dir = XSPEED_CACHE_DIR;
4809 6525 $flag = $dir . '/.mobile-separate';
4810 6526 if ( $enabled ) {
@@ -4908,9 +6624,10 @@
4908 6624 // Read the setting from the SAME place static_rewrite_allowed() and
4909 6625 // sync_mobile_flag() do — the cache module's settings, not the
4910 6626 // top-level xspeed_options — or this marker would track a key that
4911 6627 // never changes and a real flip would go unnoticed.
4912 - $cache_opts = Settings_Manager::get( 'cache' );
6628 + // Stored read — this runs from boot(); see stored_cache_opts().
6629 + $cache_opts = self::stored_cache_opts();
4913 6630 $mobile_now = ! empty( $cache_opts['mobile_separate'] );
4914 6631 $mobile_last = get_option( 'xspeed_last_mobile_separate', null );
4915 6632 $mobile_flipped = ( null !== $mobile_last && (bool) (int) $mobile_last !== $mobile_now );
4916 6633
@@ -4972,12 +6689,24 @@
4972 6689 * the truth there. (Apache keeps the static fast path — it honors the
4973 6690 * header.) See maybe_emit_lscache_headers() for the paired LSCache
4974 6691 * stand-down that stops LiteSpeed's own module from shadowing the
4975 6692 * drop-in.
6693 + *
6694 + * Opt-in (#509): `litespeed_static_rewrite` re-enables the fast path on
6695 + * LiteSpeed for users who value raw TTFB over hit accounting. The trade
6696 + * is stated in the setting's copy: statically served hits carry no
6697 + * X-XSpeed-Cache header and are not counted (LiteSpeed logs the
6698 + * original request line, so even the access-log scan cannot see
6699 + * them — see Hit_Counter::collect_server_log_hits()). The drop-in
6700 + * default above stays — nobody is surprised into an unverifiable cache.
4976 6701 */
4977 6702 public static function static_rewrite_allowed(): bool {
4978 - // LiteSpeed: drop-in serves hits (visible + counted) — see docblock.
4979 - if ( Server::LITESPEED === Server::type() ) {
6703 + // Stored read — reached from boot(); see stored_cache_opts().
6704 + $opts = self::stored_cache_opts();
6705 + // LiteSpeed: drop-in serves hits (visible + counted) unless the user
6706 + // explicitly opted into the static fast path — see docblock.
6707 + if ( Server::LITESPEED === Server::type()
6708 + && ! self::litespeed_optin_enabled( $opts['litespeed_static_rewrite'] ?? false ) ) {
4980 6709 return false;
4981 6710 }
4982 6711 // Apache without mod_headers is in EXACTLY the position LiteSpeed
4983 6712 // is in above: it can run the RewriteRule and serve the static
@@ -4991,9 +6720,8 @@
4991 6720 // pinned at 0% on a working Apache cache.)
4992 6721 if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) {
4993 6722 return false;
4994 6723 }
4995 - $opts = Settings_Manager::get( 'cache' );
4996 6724 return empty( $opts['mobile_separate'] );
4997 6725 }
4998 6726
4999 6727 /**
@@ -5111,8 +6839,10 @@
5111 6839 case 'mobile_separate':
5112 6840 return 'Separate Mobile Cache is on, which disables the device-blind static rewrite. Cache hits are served by PHP instead. If your site serves the same HTML to every device, turn it off in Cache settings for much faster hits.';
5113 6841 case 'no_mod_headers':
5114 6842 return "Apache's mod_headers is not loaded, so the static rewrite cannot mark its responses as cache hits. Enable mod_headers, or leave hits on the PHP path.";
6843 + case 'litespeed_dropin':
6844 + return 'On LiteSpeed, cache hits are served by the PHP drop-in so every hit is tagged X-XSpeed-Cache and counted in the hit ratio — LiteSpeed\'s .htaccess engine cannot do either for statically served files. If raw TTFB matters more to you than hit accounting, turn on LiteSpeed Static Fast Path in Cache settings to serve hits straight from the web server.';
5115 6845 case 'skipped_nonce':
5116 6846 return 'The server config is correct, but pages are not reaching the static cache because they contain nonces, so hits are served by PHP instead. A static file is served with no PHP, so a nonce baked into one could never be refreshed and every anonymous form on the page would break once it expired — keeping these pages on PHP is deliberate. Nonces usually come from plugin widgets; disabling the ones the site does not use lets its pages be served statically again.';
5117 6847 default:
5118 6848 return sprintf( 'The static rewrite is disabled (%s).', $code );
@@ -5131,9 +6861,25 @@
5131 6861 if ( empty( $opts['cache_enabled'] ) ) {
5132 6862 return '';
5133 6863 }
5134 6864 if ( Server::LITESPEED === Server::type() ) {
5135 - return ''; // Intended on LiteSpeed — not a "block".
6865 + // The opt-in is read RAW (stored_cache_opts), not through
6866 + // Settings_Manager::get(): the schema's bool coercion is a PHP
6867 + // cast, and (bool) "false" is true — so a junk string stored on
6868 + // another server (where the key bypasses the schema) would come
6869 + // back from the coercion layer as an ENABLE. Raw + the strict
6870 + // parse below is the same read static_rewrite_allowed() makes,
6871 + // so the two can't disagree either. (QA on #513)
6872 + $stored = self::stored_cache_opts();
6873 + // The intended default — but no longer silent: with the opt-in
6874 + // off, Health must be able to explain the PHP path and point at
6875 + // the toggle instead of falling through to "reinstall the block"
6876 + // advice that cannot work here. (#509)
6877 + if ( ! self::litespeed_optin_enabled( $stored['litespeed_static_rewrite'] ?? false ) ) {
6878 + return 'litespeed_dropin';
6879 + }
6880 + $cache_opts = Settings_Manager::get( 'cache' );
6881 + return ! empty( $cache_opts['mobile_separate'] ) ? 'mobile_separate' : '';
5136 6882 }
5137 6883 if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) {
5138 6884 return 'no_mod_headers';
5139 6885 }
@@ -5497,11 +7243,16 @@
5497 7243 // while a page was cold — on a warm page nginx served the shared
5498 7244 // anonymous copy to carts, members and bypassed bots alike. The
5499 7245 // three historical names survive as a floor inside cookie_rule().
5500 7246 // `~*` is case-insensitive, matching PHP's stripos()/glob checks.
5501 - $cache_opts = Settings_Manager::get( 'cache' );
7247 + // Stored read — reached from boot(); see stored_cache_opts(). The
7248 + // fallbacks below mirror the schema's own defaults, which a raw read
7249 + // does not apply.
7250 + $cache_opts = self::stored_cache_opts();
5502 7251 $cookie_rule = Server_Rules::cookie_rule(
5503 - is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array()
7252 + is_array( $cache_opts['excluded_cookies'] ?? null )
7253 + ? $cache_opts['excluded_cookies']
7254 + : \XSpeed\Modules\Cache\CacheModule::DEFAULT_EXCLUDED_COOKIES
5504 7255 );
5505 7256 $lines[] = 'if ($http_cookie ~* "(' . $cookie_rule['regex'] . ')") { set $xspeed_no_cache "$xspeed_no_cache-cookie"; }';
5506 7257
5507 7258 $ua_rule = Server_Rules::user_agent_rule(
@@ -5562,8 +7313,24 @@
5562 7313 // missing. So: hits are logged, and a user deleting the log can't take
5563 7314 // nginx down.
5564 7315 $lines[] = ' access_log ' . $hits_abs . ' combined buffer=16k flush=5s;';
5565 7316 $lines[] = ' add_header X-XSpeed-Cache "HIT (nginx)" always;';
7317 + // Edge/CDN headers from the same seam the drop-in bakes. nginx serves
7318 + // this path without ever starting PHP, so the answer cannot be
7319 + // resolved per request — the pairs are resolved HERE, when the
7320 + // snippet is generated, and a change of answer needs the snippet
7321 + // regenerated and re-pasted to take effect.
7322 + //
7323 + // Skipped entirely when the static path is switched off. The only
7324 + // reason that can fire under `bake` is mobile-split, and mobile-split
7325 + // is also what switches the static path off — so the block would be
7326 + // baked with a hold it can never serve, and would start serving it
7327 + // the moment the setting is turned off and static files reappear,
7328 + // until somebody regenerates and re-pastes. A rule that can only be
7329 + // served once its premise is false is guaranteed to be stale.
7330 + foreach ( self::static_rewrite_allowed() ? self::edge_headers_for( 'HIT', 'bake' ) : array() as $name => $value ) {
7331 + $lines[] = ' add_header ' . $name . ' "' . self::quote_directive_value( $value ) . '" always;';
7332 + }
5566 7333 $lines[] = '}';
5567 7334 return implode( "\n", $lines );
5568 7335 }
5569 7336
@@ -5681,9 +7448,9 @@
5681 7448 if ( empty( $opts['cache_enabled'] ) ) {
5682 7449 return false;
5683 7450 }
5684 7451
5685 - $state = self::toggle( true );
7452 + $state = self::toggle( true, false );
5686 7453 // A refusal reports whether the cache SERVES, which on this path can
5687 7454 // be true for reasons that have nothing to do with this call — so a
5688 7455 // refusal would otherwise log "drop-in restored" for a restore that
5689 7456 // was declined. Restored means the transaction went through.
@@ -5723,9 +7490,9 @@
5723 7490 if ( empty( $opts['cache_enabled'] ) ) {
5724 7491 return;
5725 7492 }
5726 7493
5727 - $state = self::toggle( true );
7494 + $state = self::toggle( true, false );
5728 7495 // A refusal means something else now owns the page-cache field, or
5729 7496 // the write could not be verified. Either way this is not the moment
5730 7497 // to go on maintaining our rewrite block and log file.
5731 7498 if ( ! empty( $state['blocked'] ) || empty( $state['enabled'] ) ) {
@@ -5856,9 +7623,9 @@
5856 7623 // so the closing quote here cannot be escaped away.
5857 7624 $lines[] = ' RewriteCond %{HTTP_USER_AGENT} "!(' . $ua_rule['regex'] . ')" [NC]';
5858 7625 }
5859 7626
5860 - return array_merge(
7627 + $block = array_merge(
5861 7628 $lines,
5862 7629 array(
5863 7630 // Capture REQUEST_URI without its trailing slash into %1.
5864 7631 // store_static() writes `{host}{uri-without-trailing-slash}/index.html`,
@@ -5876,9 +7643,9 @@
5876 7643 // `^` matches the empty string AND any non-empty path, so it
5877 7644 // covers `/` and `/blog` alike. (Confirmed on OpenLiteSpeed
5878 7645 // 1.8: `.` → homepage served by PHP drop-in; `^` → served
5879 7646 // directly from the static file.)
5880 - ' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [L]',
7647 + ' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [E=XSPEED_STATIC_HIT:1,L]',
5881 7648 '</IfModule>',
5882 7649 // Mark the statically-served response as a cache HIT.
5883 7650 //
5884 7651 // A file served by the rewrite above bypasses PHP entirely, so
@@ -5900,11 +7667,32 @@
5900 7667 '<IfModule mod_headers.c>',
5901 7668 ' <FilesMatch "\\.html$">',
5902 7669 ' Header always set X-XSpeed-Cache "HIT (static)"',
5903 7670 ' </FilesMatch>',
5904 - '</IfModule>',
5905 7671 )
5906 7672 );
7673 +
7674 + // Edge/CDN headers from the same seam the drop-in bakes. Like the
7675 + // nginx snippet, the static rewrite answers without PHP, so the pairs
7676 + // are resolved when the block is GENERATED rather than per request.
7677 + //
7678 + // `env=` rather than the `<FilesMatch>` scoping above, because these
7679 + // must ride only on responses the rewrite produced. The marker header
7680 + // stays filename-scoped: it is inert, and narrowing it would change a
7681 + // header QA reads.
7682 + // Same reasoning as the nginx snippet: a bake hold can only come from
7683 + // mobile-split, and mobile-split is what turns this path off.
7684 + $edge_lines = array();
7685 + foreach ( self::static_rewrite_allowed() ? self::edge_headers_for( 'HIT', 'bake' ) : array() as $edge_name => $edge_value ) {
7686 + $edge_lines = array_merge(
7687 + $edge_lines,
7688 + self::static_hit_directives(
7689 + ' Header always set ' . $edge_name . ' "' . self::quote_directive_value( $edge_value ) . '"'
7690 + )
7691 + );
7692 + }
7693 +
7694 + return array_merge( $block, $edge_lines, array( '</IfModule>' ) );
5907 7695 }
5908 7696
5909 7697 /**
5910 7698 * Active probe that confirms the web-server static-rewrite path is
@@ -5959,9 +7747,12 @@
5959 7747
5960 7748 $home = home_url( '/' );
5961 7749 $host = (string) wp_parse_url( $home, PHP_URL_HOST );
5962 7750 if ( '' === $host ) {
5963 - $result = array( 'active' => false, 'reason' => 'home_url has no host' );
7751 + // Environmental failure, not evidence the server config is wrong —
7752 + // mark it inconclusive so Health surfaces say "could not verify"
7753 + // instead of demanding a snippet paste. (#480)
7754 + $result = array( 'active' => false, 'inconclusive' => true, 'reason' => 'home_url has no host' );
5964 7755 set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
5965 7756 return $result;
5966 7757 }
5967 7758
@@ -5978,9 +7769,12 @@
5978 7769 if ( ! file_exists( $probe_dir ) ) {
5979 7770 wp_mkdir_p( $probe_dir );
5980 7771 }
5981 7772 if ( ! is_dir( $probe_dir ) ) {
5982 - $result = array( 'active' => false, 'reason' => 'cannot create probe dir' );
7773 + // A cache-dir permissions problem — the probe never ran, so this
7774 + // says nothing about the nginx config. Inconclusive, not
7775 + // "required". (#480)
7776 + $result = array( 'active' => false, 'inconclusive' => true, 'reason' => 'cannot create probe dir' );
5983 7777 set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
5984 7778 return $result;
5985 7779 }
5986 7780 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin credentials we may not have here; the file is in our own cache dir.
@@ -6245,8 +8039,14 @@
6245 8039 /** No drop-in installed. */
6246 8040 public const DROPIN_NONE = 'none';
6247 8041 /** A drop-in is installed and we could not read it. */
6248 8042 public const DROPIN_UNREADABLE = 'unreadable';
8043 + /**
8044 + * Present but holding nothing -- empty, or whitespace only. WP Rocket
8045 + * truncates advanced-cache.php to 0 bytes on deactivate, and calling that
8046 + * FOREIGN made it a permanent blocker with no owner to ask. (#391)
8047 + */
8048 + public const DROPIN_ABANDONED = 'abandoned';
6249 8049
6250 8050 /**
6251 8051 * Who owns wp-content/advanced-cache.php right now.
6252 8052 *
@@ -6269,11 +8069,45 @@
6269 8069 if ( null === $contents ) {
6270 8070 return self::DROPIN_UNREADABLE;
6271 8071 }
6272 8072
6273 - return xspeed_has_canonical_dropin_signature( $contents )
6274 - ? self::DROPIN_XSPEED
6275 - : self::DROPIN_FOREIGN;
8073 + if ( xspeed_has_canonical_dropin_signature( $contents ) ) {
8074 + return self::DROPIN_XSPEED;
8075 + }
8076 +
8077 + // Nothing in the file means nothing owns it. Kept distinct from
8078 + // FOREIGN so the acquisition gate can tell "someone else's cache" from
8079 + // "a husk the last plugin left behind". (#391)
8080 + if ( '' === trim( $contents ) ) {
8081 + return self::DROPIN_ABANDONED;
8082 + }
8083 +
8084 + /*
8085 + * The other half of the same question, and it cannot be answered from
8086 + * the bytes: a file we cannot attribute is a COMPETITOR only while
8087 + * some page cache is actually running. With every candidate switched
8088 + * off it is abandoned -- a hosting company's own cache, a hand-rolled
8089 + * one, or a plugin that was deleted without cleaning up.
8090 + *
8091 + * Asking the detector rather than re-deriving it here is the point:
8092 + * these two answers disagreeing is a split brain with a bad ending --
8093 + * acquisition_blocker() opens the gate, install_dropin() then refuses
8094 + * on FOREIGN, and toggle() blames the filesystem for a write it never
8095 + * attempted. One question, one answer. (#391, #393)
8096 + */
8097 + if ( class_exists( __NAMESPACE__ . '\\Page_Cache_Detector' ) ) {
8098 + $owner = (string) ( Page_Cache_Detector::inspect()['dropin']['owner'] ?? '' );
8099 +
8100 + // Attributable to a named plugin -> somebody's cache, whatever its
8101 + // activation state. Only a file NOBODY can be shown to own, with
8102 + // nothing running, is abandoned.
8103 + if ( Page_Cache_Detector::OWNER_UNKNOWN === $owner
8104 + && ! Page_Cache_Detector::another_page_cache_is_active() ) {
8105 + return self::DROPIN_ABANDONED;
8106 + }
8107 + }
8108 +
8109 + return self::DROPIN_FOREIGN;
6276 8110 }
6277 8111
6278 8112 /**
6279 8113 * Why xSpeed must not install its page-cache artifacts right now, or null
@@ -6327,8 +8161,45 @@
6327 8161 if ( Page_Cache_Detector::BLOCKER_WP_CACHE_ORPHANED === $code && self::DROPIN_XSPEED === $owner ) {
6328 8162 continue;
6329 8163 }
6330 8164 /*
8165 + * Another plugin's drop-in is no longer a refusal.
8166 + *
8167 + * It used to be: whoever held advanced-cache.php kept it, and
8168 + * enabling was blocked with "deactivate its page cache first".
8169 + * That left a user who had asked for our cache with no way to get
8170 + * it — on a live site the only exit was deleting a file over SSH,
8171 + * and the message could not even say which of its two causes
8172 + * applied ("is active OR owns advanced-cache.php").
8173 + *
8174 + * Turning the page cache on is the instruction to serve pages
8175 + * from cache, and that is not possible without this file. So we
8176 + * take it, and the dashboard says whose file it is first —
8177 + * dropin_disclosure() names the owner, the user confirms, and
8178 + * install_dropin() writes ours over the top.
8179 + *
8180 + * A still-active competitor is deliberately NOT re-added as a
8181 + * blocker below: it is caught by `active_page_cache`, which the
8182 + * capability rule already downgrades to a note. Two page caches
8183 + * installed at once is the user's call to make, not ours to
8184 + * refuse — they just told us which one they want serving.
8185 + *
8186 + * UNREADABLE is the exception and stays a refusal: we cannot name
8187 + * what we would destroy, and install_dropin() refuses it too, so
8188 + * opening the gate here would only produce a failed write.
8189 + */
8190 + $about_dropin_owner = in_array(
8191 + $code,
8192 + array(
8193 + Page_Cache_Detector::BLOCKER_FOREIGN_DROPIN,
8194 + Page_Cache_Detector::BLOCKER_UNKNOWN_DROPIN,
8195 + ),
8196 + true
8197 + );
8198 + if ( $about_dropin_owner && self::DROPIN_UNREADABLE !== $owner ) {
8199 + continue;
8200 + }
8201 + /*
6331 8202 * Capability is not possession. `active_page_cache` and
6332 8203 * `multiple_page_caches` both fire on a plugin that merely CAN
6333 8204 * cache pages — the detector cannot prove a competitor's page
6334 8205 * cache is off, so it counts it. As a warning that is right. As
@@ -6355,9 +8226,25 @@
6355 8226 Page_Cache_Detector::BLOCKER_MULTIPLE_PAGE_CACHES,
6356 8227 ),
6357 8228 true
6358 8229 );
6359 - if ( $about_capability && in_array( $owner, array( self::DROPIN_XSPEED, self::DROPIN_NONE ), true ) ) {
8230 + /*
8231 + * FOREIGN belongs in this list now, and it is the whole point.
8232 + *
8233 + * The rule is still "capability is not possession": these two
8234 + * blockers fire on any plugin that CAN cache pages, which the
8235 + * detector cannot prove is switched off. What changed is that a
8236 + * competitor holding the drop-in no longer stops us either — we
8237 + * take the file, having said whose it is. So there is nothing
8238 + * left for a merely-installed competitor to protect, and keeping
8239 + * the refusal here would put back the dead end by another route:
8240 + * "another page cache is active" on a site where the user has
8241 + * just told us, by name, which cache they want serving.
8242 + *
8243 + * UNREADABLE is deliberately still absent — that one refuses.
8244 + */
8245 + if ( $about_capability
8246 + && in_array( $owner, array( self::DROPIN_XSPEED, self::DROPIN_NONE, self::DROPIN_FOREIGN, self::DROPIN_ABANDONED ), true ) ) {
6360 8247 continue;
6361 8248 }
6362 8249 if ( Page_Cache_Detector::BLOCKER_MULTIPLE_PAGE_CACHES === $code ) {
6363 8250 $others = self::other_page_cache_names( $blocker );
@@ -6561,14 +8448,22 @@
6561 8448 return false;
6562 8449 }
6563 8450
6564 8451 /*
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.
8452 + * A drop-in we cannot READ is the one thing still refused here. Not
8453 + * because of who owns it — we no longer refuse on ownership — but
8454 + * because an unreadable file is usually a permissions problem, and
8455 + * writing over it would fail anyway or destroy something we were
8456 + * never able to look at.
8457 + *
8458 + * Everything else is ours to take. Enabling the page cache IS the
8459 + * user's instruction to serve the cache, and serving it means holding
8460 + * advanced-cache.php; the dashboard says whose file it is replacing
8461 + * before the click (Page_Cache_Detector::dropin_disclosure()), so the
8462 + * takeover is consented rather than silent.
6568 8463 */
6569 8464 $owner = self::dropin_owner();
6570 - if ( self::DROPIN_FOREIGN === $owner || self::DROPIN_UNREADABLE === $owner ) {
8465 + if ( self::DROPIN_UNREADABLE === $owner ) {
6571 8466 return false;
6572 8467 }
6573 8468
6574 8469 global $wp_filesystem;
@@ -6653,8 +8548,22 @@
6653 8548 (string) ( $expiry_hours * HOUR_IN_SECONDS ),
6654 8549 $source_contents
6655 8550 );
6656 8551
8552 + // Bake the site-wide edge answer in. Resolved in a `bake` context, so
8553 + // nothing per-page and nothing a request header vouched for can reach
8554 + // it: a bake runs once, in an admin or CLI request, and answers for
8555 + // every page on the site. A page that disagrees gets a sidecar
8556 + // instead — see per_entry_edge_headers().
8557 + //
8558 + // Re-baked on every cache settings save (see CacheModule::boot),
8559 + // exactly like the cookie, user-agent and lifetime rules above.
8560 + $source_contents = str_replace(
8561 + "'@@XSPEED_EDGE_HEADERS@@'",
8562 + self::edge_headers_literal( self::edge_headers_for( 'HIT', 'bake' ) ),
8563 + $source_contents
8564 + );
8565 +
6657 8566 if ( file_exists( $target ) ) {
6658 8567 $existing = $wp_filesystem->get_contents( $target );
6659 8568 if ( is_string( $existing ) && $existing === $source_contents ) {
6660 8569 return true;
@@ -6896,10 +8805,59 @@
6896 8805 'href' => admin_url( 'admin.php?page=' . Admin::PAGE_SLUG ),
6897 8806 )
6898 8807 );
6899 8808
6900 - foreach ( self::purge_types() as $slug => $type ) {
6901 - if ( empty( $type['visible'] ) ) {
8809 + // Settings first, then the two whole-errand actions (Purge All,
8810 + // Purge this URL), then the per-type items. The order is the one WP
8811 + // Rocket uses, and it front-loads what people open this menu for:
8812 + // nobody reaches for "Purge Object Cache" as often as they reach for
8813 + // the page they are looking at.
8814 + $wp_admin_bar->add_node(
8815 + array(
8816 + 'id' => 'xspeed-purge-settings',
8817 + 'parent' => 'xspeed-purge',
8818 + 'title' => esc_html__( 'Settings', 'xspeed' ),
8819 + 'href' => admin_url( 'admin.php?page=' . Admin::PAGE_SLUG ),
8820 + )
8821 + );
8822 +
8823 + $types = self::purge_types();
8824 +
8825 + // 'all' is rendered out of band so the single-URL item can sit
8826 + // directly under it. A filter that reorders or drops it is honoured:
8827 + // the loop below skips whatever was emitted here.
8828 + $emitted = array();
8829 + if ( ! empty( $types['all']['visible'] ) ) {
8830 + $wp_admin_bar->add_node(
8831 + array(
8832 + 'id' => 'xspeed-purge-all',
8833 + 'parent' => 'xspeed-purge',
8834 + 'title' => esc_html( $types['all']['label'] ),
8835 + 'href' => self::purge_type_url( 'all' ),
8836 + )
8837 + );
8838 + $emitted['all'] = true;
8839 + }
8840 +
8841 + // Only when the current screen is about one thing — a front-end view,
8842 + // or a published post's edit screen. On a list table or a settings
8843 + // page there is nothing for "this" to mean, so the item stays hidden
8844 + // rather than silently targeting the dashboard. Purge_Ui decides both
8845 + // the label and the scope, which differ between the two contexts.
8846 + $context = Purge_Ui::context_node();
8847 + if ( null !== $context ) {
8848 + $wp_admin_bar->add_node(
8849 + array(
8850 + 'id' => 'xspeed-purge-this-url',
8851 + 'parent' => 'xspeed-purge',
8852 + 'title' => esc_html( $context['title'] ),
8853 + 'href' => $context['href'],
8854 + )
8855 + );
8856 + }
8857 +
8858 + foreach ( $types as $slug => $type ) {
8859 + if ( empty( $type['visible'] ) || isset( $emitted[ $slug ] ) ) {
6902 8860 continue;
6903 8861 }
6904 8862 $wp_admin_bar->add_node(
6905 8863 array(
@@ -6934,11 +8892,34 @@
6934 8892 // Only honour known types; anything else falls back to a full purge.
6935 8893 if ( ! array_key_exists( $type, self::purge_types() ) ) {
6936 8894 $type = 'all';
6937 8895 }
8896 +
8897 + // Answer the browser BEFORE purging. "Purge All" fans out to the local
8898 + // sweep, the object cache, CSS/edge listeners (outbound HTTP) and
8899 + // third-party render caches, all in this one request — on a large site
8900 + // that can outlive PHP-FPM's request_terminate_timeout, FPM kills the
8901 + // worker mid-purge, and nginx answers the admin's click with a 502.
8902 + // fastcgi_finish_request() exists on exactly those FPM setups: send
8903 + // the redirect, close the connection, then keep purging in the same
8904 + // process. Elsewhere (mod_php, CLI tests) fall back to purge-then-
8905 + // redirect as before.
8906 + $redirect = self::safe_purge_redirect( wp_get_referer() );
8907 + if ( function_exists( 'ignore_user_abort' ) ) {
8908 + ignore_user_abort( true );
8909 + }
8910 + if ( function_exists( 'set_time_limit' ) ) {
8911 + @set_time_limit( 300 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort under safe-mode-like restrictions.
8912 + }
8913 + if ( function_exists( 'fastcgi_finish_request' ) ) {
8914 + wp_safe_redirect( $redirect );
8915 + fastcgi_finish_request();
8916 + self::purge_type( $type );
8917 + exit;
8918 + }
8919 +
6938 8920 self::purge_type( $type );
6939 -
6940 - wp_safe_redirect( self::safe_purge_redirect( wp_get_referer() ) );
8921 + wp_safe_redirect( $redirect );
6941 8922 exit;
6942 8923 }
6943 8924
6944 8925 /**