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 +1715 -64 1.3.0 → 1.3.6 View file →
@@ -45,8 +45,26 @@
45 45 private static $status_header = '';
46 46 private static $bypass_reason = '';
47 47
48 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 + /**
49 67 * Cache key whose write was deferred to shutdown because a render-time
50 68 * translation plugin's buffer wraps ours. Null on every ordinary request.
51 69 *
52 70 * @var string|null
@@ -89,9 +107,12 @@
89 107 * running alongside its replacement and silently undo #243.
90 108 */
91 109 private const TARGETED_INVALIDATION_HOOKS = array(
92 110 'save_post',
111 + 'before_delete_post',
112 + 'trashed_post',
93 113 'comment_post',
114 + 'wp_set_comment_status',
94 115 'user_register',
95 116 'profile_update',
96 117 );
97 118
@@ -155,9 +176,9 @@
155 176 // rendered author bylines / term-archive pages. Without them, an edit
156 177 // left the matching endpoint (and archives) stale for the full TTL.
157 178 // (FBS-82408)
158 179 $invalidate_hooks = array(
159 - 'save_post', 'deleted_post', 'trashed_post',
180 + 'save_post', 'before_delete_post', 'trashed_post',
160 181 'comment_post', 'wp_set_comment_status',
161 182 'switch_theme', 'activated_plugin', 'deactivated_plugin',
162 183 // Users → /wp/v2/users + author archives.
163 184 'profile_update', 'user_register', 'deleted_user',
@@ -187,9 +208,13 @@
187 208 }
188 209 add_action(
189 210 $hook,
190 211 static function () use ( $hook ): void {
191 - self::purge_all( 'hook:' . $hook );
212 + self::purge_all(
213 + 'hook:' . $hook,
214 + null,
215 + self::invalidation_for_hook( $hook )
216 + );
192 217 }
193 218 );
194 219 add_action( $hook, array( 'XSpeed\\Minifier', 'purge_minified' ) );
195 220 }
@@ -260,12 +285,21 @@
260 285 // and generalises to Flamingo (#229) and Tutor LMS (#231) too.
261 286 remove_action( 'save_post', array( __CLASS__, 'purge_all' ) );
262 287 remove_action( 'save_post', array( 'XSpeed\\Minifier', 'purge_minified' ) );
263 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 );
264 297
265 298 remove_action( 'comment_post', array( __CLASS__, 'purge_all' ) );
266 299 remove_action( 'comment_post', array( 'XSpeed\\Minifier', 'purge_minified' ) );
267 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 );
268 302
269 303 remove_action( 'user_register', array( __CLASS__, 'purge_all' ) );
270 304 remove_action( 'user_register', array( 'XSpeed\\Minifier', 'purge_minified' ) );
271 305 add_action( 'user_register', array( __CLASS__, 'on_user_change' ) );
@@ -431,8 +465,17 @@
431 465 private static function mark( string $value, string $reason = '' ): void {
432 466 self::$status_header = $value;
433 467 self::$bypass_reason = $reason;
434 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 +
435 478 if ( headers_sent() ) {
436 479 return;
437 480 }
438 481 header( 'X-XSpeed-Cache: ' . $value );
@@ -438,10 +481,26 @@
438 481 header( 'X-XSpeed-Cache: ' . $value );
439 482 if ( '' !== $reason && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
440 483 header( 'X-XSpeed-Reason: ' . $reason );
441 484 }
485 + foreach ( self::$edge_headers as $name => $val ) {
486 + header( $name . ': ' . $val );
487 + }
442 488 }
443 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 +
444 503 /** Record a bypass gate and answer "don't cache" in one statement. */
445 504 private static function bypass( string $reason ): bool {
446 505 self::mark( 'BYPASS', $reason );
447 506 return false;
@@ -457,8 +516,639 @@
457 516 return self::$bypass_reason;
458 517 }
459 518
460 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 + /**
461 1151 * Bypass gates that describe THE VISITOR rather than THIS REQUEST.
462 1152 *
463 1153 * Only these may be recorded in the bypass cookie. A visitor-scoped
464 1154 * verdict stays true for the visitor's next request — they are still
@@ -765,9 +1455,16 @@
765 1455
766 1456 // Static tree too, under the same gates finalize_buffer() applies —
767 1457 // otherwise deferring the write would silently cost translated pages
768 1458 // the web-server fast path and leave them on the slower drop-in.
769 - 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() ) {
770 1467 self::store_static( $full );
771 1468 }
772 1469 }
773 1470
@@ -774,10 +1471,17 @@
774 1471 public static function should_cache() {
775 1472 // Reset first: a single request only reaches this once (the sole
776 1473 // caller is maybe_start_cache()), but tests and any future caller
777 1474 // must never inherit the previous request's verdict.
778 - self::$status_header = '';
779 - 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();
780 1484
781 1485 $opts = Settings::get();
782 1486 if ( empty( $opts['cache_enabled'] ) ) {
783 1487 return self::bypass( 'cache-disabled' );
@@ -828,8 +1532,20 @@
828 1532 * @param bool $cache_feed Whether to cache this feed request.
829 1533 */
830 1534 $cache_feed = $is_feed_request && (bool) apply_filters( 'xspeed_should_cache_feed', false );
831 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 +
832 1548 // Query string handling: anything OUTSIDE the ignored-params
833 1549 // allow-list (utm_*, fbclid, gclid by default) means a unique
834 1550 // request that we don't want to share with the canonical cache
835 1551 // entry. Skip cache rather than poison the key.
@@ -1394,9 +2110,10 @@
1394 2110 * exactly this blog's pages.
1395 2111 */
1396 2112 public static function current_static_scope(): string {
1397 2113 // Same switch_to_blog() caveat as current_host_dir() — see current_host().
1398 - $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() );
1399 2116 if ( '' === $dir ) {
1400 2117 $dir = 'default';
1401 2118 }
1402 2119 $path = self::site_path_raw();
@@ -2046,9 +2763,16 @@
2046 2763 // 200, FBS-82406) or a non-HTML content-type (a cached feed would go
2047 2764 // out as text/html, FBS-82407). The web server serves these .html files
2048 2765 // directly with no PHP, so there's no .meta replay — keep them on the
2049 2766 // drop-in / PHP path instead, which DOES replay status + content-type.
2050 - 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() ) {
2051 2775 self::store_static( $full );
2052 2776 }
2053 2777
2054 2778 return $buffer;
@@ -2579,8 +3303,19 @@
2579 3303 if ( $ttl > 0 && $ttl !== $default_ttl ) {
2580 3304 $meta['ttl'] = $ttl;
2581 3305 }
2582 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 +
2583 3318 // Nothing to replay → no sidecar.
2584 3319 if ( empty( $meta ) ) {
2585 3320 return;
2586 3321 }
@@ -2624,8 +3359,101 @@
2624 3359 * TTL — up to 30 days at the maximum lifetime. (#270 regression)
2625 3360 *
2626 3361 * @return string[]
2627 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 +
2628 3456 public static function presentation_post_types(): array {
2629 3457 $types = array(
2630 3458 'wp_template', // Site Editor templates.
2631 3459 'wp_template_part', // Header / footer / reusable parts.
@@ -2646,8 +3474,44 @@
2646 3474 return (array) apply_filters( 'xspeed_presentation_post_types', $types );
2647 3475 }
2648 3476
2649 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 + /**
2650 3514 * save_post → purge only when the saved thing can appear on a cached page.
2651 3515 *
2652 3516 * Revisions and autosaves are never rendered. Non-viewable post types —
2653 3517 * WooCommerce's `shop_order` / `shop_order_placehold` / `shop_order_refund`
@@ -2662,35 +3526,32 @@
2662 3526 * @param int $post_id Saved post ID.
2663 3527 * @param \WP_Post $post Saved post object.
2664 3528 */
2665 3529 public static function on_save_post( $post_id, $post = null ): void {
2666 - if ( function_exists( 'wp_is_post_revision' ) && wp_is_post_revision( $post_id ) ) {
3530 + if ( ! self::post_change_is_cacheable_content( $post_id, $post, 'save' ) ) {
2667 3531 return;
2668 3532 }
2669 - if ( function_exists( 'wp_is_post_autosave' ) && wp_is_post_autosave( $post_id ) ) {
2670 - return;
2671 - }
2672 3533
2673 3534 $post_type = is_object( $post ) && isset( $post->post_type )
2674 3535 ? (string) $post->post_type
2675 3536 : (string) get_post_type( $post_id );
2676 - if ( '' === $post_type ) {
2677 - return;
2678 - }
2679 3537
2680 - // Unknown/!viewable → nothing anonymous can see changed, UNLESS the
2681 - // type is itself part of how pages render (#270 regression).
2682 - if ( function_exists( 'is_post_type_viewable' )
2683 - && ! is_post_type_viewable( $post_type )
2684 - && ! in_array( $post_type, self::presentation_post_types(), true )
2685 - ) {
2686 - return;
2687 - }
2688 -
2689 3538 // Name the trigger rather than logging a bare numeric id — the old
2690 3539 // wiring passed the post ID into $cause, so the log read
2691 3540 // "Cache purged (46)" with no indication of what caused it. (#243)
2692 - 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 + );
2693 3554 if ( class_exists( '\XSpeed\Minifier' ) ) {
2694 3555 Minifier::purge_minified();
2695 3556 }
2696 3557 }
@@ -2695,8 +3556,61 @@
2695 3556 }
2696 3557 }
2697 3558
2698 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 + /**
2699 3613 * comment_post → purge just the commented-on URL, and only once the
2700 3614 * comment is actually visible.
2701 3615 *
2702 3616 * A comment held for moderation changes nothing on the front end, and an
@@ -2847,13 +3761,304 @@
2847 3761 public static function purge_product_object( $product ): void {
2848 3762 self::purge_product( $product );
2849 3763 }
2850 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 +
2851 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 + }
2852 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.
2853 4037 if ( ! is_array( $parts ) ) {
2854 4038 return 0;
2855 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 + }
2856 4061 // Keep the port. `cache_key()` hashes the raw `HTTP_HOST`, which
2857 4062 // carries `:8080` on any install not served from 80/443 — while
2858 4063 // parse_url() splits the port into its own component, so a purge that
2859 4064 // used the bare host computed a different md5, found no file, and
@@ -2859,19 +4064,34 @@
2859 4064 // used the bare host computed a different md5, found no file, and
2860 4065 // reported "already cold". A silent no-op: the page kept serving HIT
2861 4066 // until its TTL ran out. Intranet installs, panel hosts on :8443 and
2862 4067 // proxies that forward `Host: site.com:8080` all hit this.
2863 - $host = isset( $parts['host'] ) ? strtolower( (string) $parts['host'] ) : '';
2864 - if ( '' !== $host && isset( $parts['port'] ) ) {
2865 - $host .= ':' . (int) $parts['port'];
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 + }
2866 4088 }
4089 + $host = self::host_port_of( $parts );
2867 4090 if ( '' === $host && function_exists( 'home_url' ) ) {
2868 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.
2869 - if ( is_array( $home ) && isset( $home['host'] ) ) {
2870 - $host = strtolower( (string) $home['host'] );
2871 - if ( isset( $home['port'] ) ) {
2872 - $host .= ':' . (int) $home['port'];
2873 - }
4092 + if ( is_array( $home ) ) {
4093 + $host = self::host_port_of( $home );
2874 4094 }
2875 4095 }
2876 4096 if ( '' === $host ) {
2877 4097 return 0;
@@ -2955,14 +4175,120 @@
2955 4175 Activity_Log::INFO
2956 4176 );
2957 4177 }
2958 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 +
2959 4236 return $count;
2960 4237 }
2961 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 +
2962 4255 /**
2963 - * Purge this site's cache.
4256 + * Rebuild the canonical URL a purge applied to.
2964 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 + *
2965 4291 * On multisite every blog shares one cache directory, so an unscoped
2966 4292 * sweep here took the whole network cold — one subsite's settings save
2967 4293 * or post publish rebuilt every other site from PHP. Entries are stored
2968 4294 * per host (see host_dir()), and the sweep is scoped to match, so a
@@ -2969,12 +4295,13 @@
2969 4295 * purge originating on site-a leaves site-b's cache warm. (#6)
2970 4296 *
2971 4297 * Clears the files only: the flat tree, the static tree, the REST
2972 4298 * responses and the minified assets. The object-cache flush, the stats
2973 - * update, `xspeed_after_purge_all` and the log entry live in purge_all(),
2974 - * which is still the entry point for every existing caller. Split out so
2975 - * `wp xspeed purge` can report the local sweep as one line item and the
2976 - * object cache as another, each with its own status — see Purge_Runner.
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.
2977 4304 *
2978 4305 * @param string|null $host Host to purge. Defaults to the current site.
2979 4306 * Pass '*' to sweep the ENTIRE tree — network
2980 4307 * admin's "purge all sites", and the migration
@@ -2995,9 +4322,10 @@
2995 4322 $static_scope = $network_wide ? '' : self::current_static_scope();
2996 4323 } else {
2997 4324 $dir = self::host_dir( $host );
2998 4325 $scope = '' === $dir ? 'default' : $dir;
2999 - $static_scope = $scope;
4326 + $static_dir = self::static_host_dir( $host );
4327 + $static_scope = '' === $static_dir ? 'default' : $static_dir;
3000 4328 }
3001 4329
3002 4330 $count = 0;
3003 4331 if ( is_dir( XSPEED_CACHE_DIR ) ) {
@@ -3149,15 +4477,45 @@
3149 4477 * inventory invalidation, purge log).
3150 4478 *
3151 4479 * @param string $cause Who asked, for the purge log.
3152 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.
3153 4485 * @return int Page + REST entries removed.
3154 4486 */
3155 - public static function purge_all( string $cause = 'manual', ?string $host = null ) {
3156 - $network_wide = ( '*' === $host );
3157 - $removed = self::purge_local( $host );
3158 - $count = $removed['pages'] + $removed['rest'];
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 + }
3159 4514
4515 + $removed = self::purge_local( $host );
4516 + $count = $removed['pages'] + $removed['rest'];
4517 +
3160 4518 self::flush_object_cache( $network_wide );
3161 4519
3162 4520 self::update_stats( array( 'last_purge' => time() ) );
3163 4521
@@ -3165,10 +4523,64 @@
3165 4523 // Unused CSS, Cloudflare edge purge) run — this action had three
3166 4524 // registered listeners but was never emitted. Treat it as additive
3167 4525 // (CDN / edge invalidation), not the mechanism for clearing local
3168 4526 // files. (FBS-83114)
3169 - 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 + }
3170 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 +
3171 4583 // The list behind the "Cached pages" card is memoized for a minute;
3172 4584 // a purge has to drop it or the drill-down shows pages that no
3173 4585 // longer exist.
3174 4586 Cache_Inventory::invalidate();
@@ -3768,8 +5180,9 @@
3768 5180 $count = self::purge_pages();
3769 5181 self::update_stats( array( 'last_purge' => time() ) );
3770 5182 Cache_Inventory::invalidate();
3771 5183 self::record_partial_purge( 'page', $cause, $count );
5184 + self::announce_purge( $cause, $count );
3772 5185 return $count;
3773 5186
3774 5187 case 'assets':
3775 5188 if ( class_exists( '\\XSpeed\\Minifier' ) ) {
@@ -3796,8 +5209,9 @@
3796 5209 $count = self::purge_pages();
3797 5210 self::update_stats( array( 'last_purge' => time() ) );
3798 5211 Cache_Inventory::invalidate();
3799 5212 self::record_partial_purge( 'assets', $cause, $count );
5213 + self::announce_purge( $cause, $count );
3800 5214 return $count;
3801 5215
3802 5216 case 'object':
3803 5217 if ( function_exists( 'wp_cache_flush' ) ) {
@@ -3808,8 +5222,9 @@
3808 5222
3809 5223 case 'rest':
3810 5224 $count = Rest_Cache::purge();
3811 5225 self::record_partial_purge( 'REST responses', $cause, $count );
5226 + self::announce_purge( $cause, $count );
3812 5227 return $count;
3813 5228
3814 5229 default:
3815 5230 return self::purge_type_unhandled( $type, $cause );
@@ -3862,15 +5277,101 @@
3862 5277 * @param string $type Purge-type slug.
3863 5278 * @param string $cause Who asked.
3864 5279 */
3865 5280 private static function purge_type_unhandled( string $type, string $cause ): int {
3866 - 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 );
3867 5285 self::record_partial_purge( $type, $cause, null );
3868 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 +
3869 5315 return 0;
3870 5316 }
3871 5317
3872 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 + /**
3873 5374 * Log a partial purge so the drill-down behind "Last purge" shows every
3874 5375 * clear, not only the full ones. Without this a site whose object cache
3875 5376 * is flushed on a schedule looks, from the log, like nothing happens.
3876 5377 *
@@ -4067,8 +5568,25 @@
4067 5568 // True when an edge cache (Cloudflare) fronts the origin, so hits are
4068 5569 // absorbed before reaching PHP. The dashboard labels the ratio
4069 5570 // "origin-layer only" instead of implying it's the full picture. (#118)
4070 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 + ),
4071 5589 /*
4072 5590 * Whether the page cache is actually SERVING, as opposed to
4073 5591 * switched on in settings. The hero read the setting alone and
4074 5592 * announced "Active — serving cached HTML"; a site whose
@@ -4137,16 +5655,20 @@
4137 5655 return Hit_Counter::is_bot_ua( $ua );
4138 5656 }
4139 5657
4140 5658 /**
4141 - * Whether an edge cache fronts this origin. Today: the Cloudflare
4142 - * integration is connected — so an unknown share of hits is served at the
4143 - * edge and never counted here, making the origin ratio a partial view the
4144 - * dashboard must label as such. (#118)
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.
4145 5668 */
4146 5669 private static function edge_cache_detected(): bool {
4147 - $cf = get_option( 'xspeed_module_cloudflare', array() );
4148 - return is_array( $cf ) && ! empty( $cf['enabled'] );
5670 + return Edge_Provider::NONE !== Edge_Provider::detect()['confidence'];
4149 5671 }
4150 5672
4151 5673 /**
4152 5674 * Apply the user's enable/disable choice. Called from the REST toggle
@@ -4743,10 +6265,11 @@
4743 6265 * they don't share a user at all. A default-umask 0644 file is then
4744 6266 * unwritable by nginx, the access_log write silently fails, and the
4745 6267 * dashboard shows a 0% hit ratio even though static HITs are serving.
4746 6268 * So we widen the dir to 0777 and the file to 0666 — group/other write —
4747 - * so whatever uid nginx runs as can append. (The file holds only HIT
4748 - * request lines, no secrets.)
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.
4749 6272 */
4750 6273 /**
4751 6274 * Directory holding the nginx hit log. Lives under uploads/, NOT the
4752 6275 * cache dir — uninstall.php and a cache purge both delete the cache
@@ -4972,8 +6495,28 @@
4972 6495 $stored = get_option( Settings_Manager::OPTION_PREFIX . 'cache', array() );
4973 6496 return is_array( $stored ) ? $stored : array();
4974 6497 }
4975 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 +
4976 6519 public static function sync_mobile_flag( $enabled = null ): void {
4977 6520 if ( null === $enabled ) {
4978 6521 $stored = self::stored_cache_opts();
4979 6522 $enabled = ! empty( $stored['mobile_separate'] );
@@ -5146,12 +6689,24 @@
5146 6689 * the truth there. (Apache keeps the static fast path — it honors the
5147 6690 * header.) See maybe_emit_lscache_headers() for the paired LSCache
5148 6691 * stand-down that stops LiteSpeed's own module from shadowing the
5149 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.
5150 6701 */
5151 6702 public static function static_rewrite_allowed(): bool {
5152 - // LiteSpeed: drop-in serves hits (visible + counted) — see docblock.
5153 - 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 ) ) {
5154 6709 return false;
5155 6710 }
5156 6711 // Apache without mod_headers is in EXACTLY the position LiteSpeed
5157 6712 // is in above: it can run the RewriteRule and serve the static
@@ -5165,10 +6720,8 @@
5165 6720 // pinned at 0% on a working Apache cache.)
5166 6721 if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) {
5167 6722 return false;
5168 6723 }
5169 - // Stored read — reached from boot(); see stored_cache_opts().
5170 - $opts = self::stored_cache_opts();
5171 6724 return empty( $opts['mobile_separate'] );
5172 6725 }
5173 6726
5174 6727 /**
@@ -5286,8 +6839,10 @@
5286 6839 case 'mobile_separate':
5287 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.';
5288 6841 case 'no_mod_headers':
5289 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.';
5290 6845 case 'skipped_nonce':
5291 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.';
5292 6847 default:
5293 6848 return sprintf( 'The static rewrite is disabled (%s).', $code );
@@ -5306,9 +6861,25 @@
5306 6861 if ( empty( $opts['cache_enabled'] ) ) {
5307 6862 return '';
5308 6863 }
5309 6864 if ( Server::LITESPEED === Server::type() ) {
5310 - 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' : '';
5311 6882 }
5312 6883 if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) {
5313 6884 return 'no_mod_headers';
5314 6885 }
@@ -5742,8 +7313,24 @@
5742 7313 // missing. So: hits are logged, and a user deleting the log can't take
5743 7314 // nginx down.
5744 7315 $lines[] = ' access_log ' . $hits_abs . ' combined buffer=16k flush=5s;';
5745 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 + }
5746 7333 $lines[] = '}';
5747 7334 return implode( "\n", $lines );
5748 7335 }
5749 7336
@@ -6036,9 +7623,9 @@
6036 7623 // so the closing quote here cannot be escaped away.
6037 7624 $lines[] = ' RewriteCond %{HTTP_USER_AGENT} "!(' . $ua_rule['regex'] . ')" [NC]';
6038 7625 }
6039 7626
6040 - return array_merge(
7627 + $block = array_merge(
6041 7628 $lines,
6042 7629 array(
6043 7630 // Capture REQUEST_URI without its trailing slash into %1.
6044 7631 // store_static() writes `{host}{uri-without-trailing-slash}/index.html`,
@@ -6056,9 +7643,9 @@
6056 7643 // `^` matches the empty string AND any non-empty path, so it
6057 7644 // covers `/` and `/blog` alike. (Confirmed on OpenLiteSpeed
6058 7645 // 1.8: `.` → homepage served by PHP drop-in; `^` → served
6059 7646 // directly from the static file.)
6060 - ' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [L]',
7647 + ' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [E=XSPEED_STATIC_HIT:1,L]',
6061 7648 '</IfModule>',
6062 7649 // Mark the statically-served response as a cache HIT.
6063 7650 //
6064 7651 // A file served by the rewrite above bypasses PHP entirely, so
@@ -6080,11 +7667,32 @@
6080 7667 '<IfModule mod_headers.c>',
6081 7668 ' <FilesMatch "\\.html$">',
6082 7669 ' Header always set X-XSpeed-Cache "HIT (static)"',
6083 7670 ' </FilesMatch>',
6084 - '</IfModule>',
6085 7671 )
6086 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>' ) );
6087 7695 }
6088 7696
6089 7697 /**
6090 7698 * Active probe that confirms the web-server static-rewrite path is
@@ -6139,9 +7747,12 @@
6139 7747
6140 7748 $home = home_url( '/' );
6141 7749 $host = (string) wp_parse_url( $home, PHP_URL_HOST );
6142 7750 if ( '' === $host ) {
6143 - $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' );
6144 7755 set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
6145 7756 return $result;
6146 7757 }
6147 7758
@@ -6158,9 +7769,12 @@
6158 7769 if ( ! file_exists( $probe_dir ) ) {
6159 7770 wp_mkdir_p( $probe_dir );
6160 7771 }
6161 7772 if ( ! is_dir( $probe_dir ) ) {
6162 - $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' );
6163 7777 set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
6164 7778 return $result;
6165 7779 }
6166 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.
@@ -6934,8 +8548,22 @@
6934 8548 (string) ( $expiry_hours * HOUR_IN_SECONDS ),
6935 8549 $source_contents
6936 8550 );
6937 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 +
6938 8566 if ( file_exists( $target ) ) {
6939 8567 $existing = $wp_filesystem->get_contents( $target );
6940 8568 if ( is_string( $existing ) && $existing === $source_contents ) {
6941 8569 return true;
@@ -7264,11 +8892,34 @@
7264 8892 // Only honour known types; anything else falls back to a full purge.
7265 8893 if ( ! array_key_exists( $type, self::purge_types() ) ) {
7266 8894 $type = 'all';
7267 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 +
7268 8920 self::purge_type( $type );
7269 -
7270 - wp_safe_redirect( self::safe_purge_redirect( wp_get_referer() ) );
8921 + wp_safe_redirect( $redirect );
7271 8922 exit;
7272 8923 }
7273 8924
7274 8925 /**