PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.4
1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 All 30 releases
← All changes | includes/class-cache.php +1605 -55 1.3.01.3.4 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,37 @@
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 + * @return array{scope:string,intent:string,urls:array<int,string>}
3486 + */
3487 + private static function invalidation_for_hook( string $hook ): array {
3488 + $presentation = array(
3489 + 'switch_theme',
3490 + 'activated_plugin',
3491 + 'deactivated_plugin',
3492 + 'created_term',
3493 + 'edited_term',
3494 + 'delete_term',
3495 + 'wp_update_nav_menu',
3496 + );
3497 +
3498 + return array(
3499 + 'scope' => 'site',
3500 + 'intent' => in_array( $hook, $presentation, true ) ? 'presentation' : 'content',
3501 + 'urls' => array(),
3502 + );
3503 + }
3504 +
3505 +
3506 + /**
2650 3507 * save_post → purge only when the saved thing can appear on a cached page.
2651 3508 *
2652 3509 * Revisions and autosaves are never rendered. Non-viewable post types —
2653 3510 * WooCommerce's `shop_order` / `shop_order_placehold` / `shop_order_refund`
@@ -2662,35 +3519,32 @@
2662 3519 * @param int $post_id Saved post ID.
2663 3520 * @param \WP_Post $post Saved post object.
2664 3521 */
2665 3522 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 ) ) {
3523 + if ( ! self::post_change_is_cacheable_content( $post_id, $post, 'save' ) ) {
2667 3524 return;
2668 3525 }
2669 - if ( function_exists( 'wp_is_post_autosave' ) && wp_is_post_autosave( $post_id ) ) {
2670 - return;
2671 - }
2672 3526
2673 3527 $post_type = is_object( $post ) && isset( $post->post_type )
2674 3528 ? (string) $post->post_type
2675 3529 : (string) get_post_type( $post_id );
2676 - if ( '' === $post_type ) {
2677 - return;
2678 - }
2679 3530
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 3531 // Name the trigger rather than logging a bare numeric id — the old
2690 3532 // wiring passed the post ID into $cause, so the log read
2691 3533 // "Cache purged (46)" with no indication of what caused it. (#243)
2692 - self::purge_all( 'post:' . $post_type );
3534 + $presentation = in_array( $post_type, self::presentation_post_types(), true );
3535 + self::purge_all(
3536 + 'post:' . $post_type,
3537 + null,
3538 + array(
3539 + // purge_all() sweeps every local response in this site's bucket.
3540 + // Without dependency tracking, the server cache must match that
3541 + // same boundary or unrelated pages can remain stale there.
3542 + 'scope' => 'site',
3543 + 'intent' => $presentation ? 'presentation' : 'content',
3544 + 'urls' => array(),
3545 + )
3546 + );
2693 3547 if ( class_exists( '\XSpeed\Minifier' ) ) {
2694 3548 Minifier::purge_minified();
2695 3549 }
2696 3550 }
@@ -2695,8 +3549,61 @@
2695 3549 }
2696 3550 }
2697 3551
2698 3552 /**
3553 + * Delete/trash invalidation while the post type is still available.
3554 + * The local and server response-cache sweeps share the same site boundary.
3555 + *
3556 + * @param int $post_id Removed post ID.
3557 + * @param object|null $post Post object supplied by core when available.
3558 + */
3559 + public static function on_post_removed( $post_id, $post = null ): void {
3560 + if ( ! self::post_change_is_cacheable_content( $post_id, $post, 'remove' ) ) {
3561 + return;
3562 + }
3563 +
3564 + $post_type = is_object( $post ) && isset( $post->post_type )
3565 + ? (string) $post->post_type
3566 + : (string) get_post_type( $post_id );
3567 +
3568 + self::purge_all(
3569 + 'post-removed:' . $post_type,
3570 + null,
3571 + array(
3572 + 'scope' => 'site',
3573 + // Match on_save_post: a presentation type changes how pages
3574 + // render rather than what they say.
3575 + 'intent' => in_array( $post_type, self::presentation_post_types(), true )
3576 + ? 'presentation'
3577 + : 'content',
3578 + 'urls' => array(),
3579 + )
3580 + );
3581 + }
3582 +
3583 + /** Purge site responses when moderation changes visible comments. */
3584 + public static function on_comment_status( $comment_id, $status = '' ): void {
3585 + $comment = function_exists( 'get_comment' ) ? get_comment( (int) $comment_id ) : null;
3586 + $post_id = is_object( $comment ) && isset( $comment->comment_post_ID ) ? (int) $comment->comment_post_ID : 0;
3587 + if ( $post_id < 1 || ! function_exists( 'get_permalink' ) ) {
3588 + return;
3589 + }
3590 + $url = get_permalink( $post_id );
3591 + if ( ! is_string( $url ) || '' === $url ) {
3592 + return;
3593 + }
3594 + self::purge_all(
3595 + 'comment-status:' . (string) $status,
3596 + null,
3597 + array(
3598 + 'scope' => 'site',
3599 + 'intent' => 'content',
3600 + 'urls' => array(),
3601 + )
3602 + );
3603 + }
3604 +
3605 + /**
2699 3606 * comment_post → purge just the commented-on URL, and only once the
2700 3607 * comment is actually visible.
2701 3608 *
2702 3609 * A comment held for moderation changes nothing on the front end, and an
@@ -2847,13 +3754,304 @@
2847 3754 public static function purge_product_object( $product ): void {
2848 3755 self::purge_product( $product );
2849 3756 }
2850 3757
3758 + /**
3759 + * Re-entry guard for the purge-event contract.
3760 + *
3761 + * A listener on `xspeed_after_purge_url` legitimately purges its own
3762 + * layer, and a server-cache or CDN adapter that calls back into xSpeed
3763 + * while doing so re-enters this method — unbounded, because each pass
3764 + * looks like a fresh purge.
3765 + *
3766 + * A single global flag stops too much: a nested purge of a DIFFERENT URL is
3767 + * a real purge whose listeners must hear about it. But a per-request
3768 + * "already published" set stops too much in the other direction — a
3769 + * network purge loops every blog in one request, and on a subdirectory
3770 + * network they share a host, so blogs 2..N would be silently skipped. It
3771 + * also grows for the life of the process.
3772 + *
3773 + * So the guard tracks what is IN FLIGHT, not what has been published: a
3774 + * target is marked while its own dispatch is on the stack and unmarked
3775 + * when it returns. Re-entering the same target recurses, so it is refused;
3776 + * purging the same URL again later is a new event and publishes. The set
3777 + * is bounded by call depth rather than by how many URLs a request touches.
3778 + *
3779 + * @var array<string,bool>
3780 + */
3781 + private static $purge_events_in_flight = array();
3782 +
3783 + /** Monotonic count used to detect whether a delegated purge published. */
3784 + private static $purge_event_sequence = 0;
3785 +
3786 + /**
3787 + * Publish a purge event exactly once, with bounded arguments.
3788 + *
3789 + * Deliberately carries only what an integration needs to invalidate its
3790 + * own copy: the canonical URL (or null for a full purge), the site host,
3791 + * the cause label, and how many files went. No filesystem paths, no cache
3792 + * contents, no request headers, no user data. The URL query and caller-
3793 + * supplied cause may nevertheless contain sensitive text, so listeners
3794 + * must redact them in logs or unrelated destinations that do not need the
3795 + * exact cache key.
3796 + *
3797 + * A listener that throws must not take the purge down with it: the files
3798 + * are already gone by the time we get here, and an integration's bad day
3799 + * is not a reason to report a failed purge to the caller.
3800 + *
3801 + * @param string $hook Hook name to emit.
3802 + * @param array<string,mixed> $context Bounded context, see above.
3803 + */
3804 + private static function dispatch_purge_event( string $hook, array $context ): void {
3805 + if ( ! function_exists( 'do_action' ) ) {
3806 + return;
3807 + }
3808 + $target = $hook . '|' . ( isset( $context['url'] ) ? (string) $context['url'] : '' )
3809 + . '|' . ( isset( $context['host'] ) ? (string) $context['host'] : '' );
3810 + if ( isset( self::$purge_events_in_flight[ $target ] ) ) {
3811 + return;
3812 + }
3813 + self::$purge_events_in_flight[ $target ] = true;
3814 + ++self::$purge_event_sequence;
3815 +
3816 + // Our own integrations get their own try. Sharing one with the public
3817 + // action below meant a listener on the extension seam could throw and
3818 + // take the contract event down with it — the mirror of the failure
3819 + // this separation exists to prevent.
3820 + try {
3821 + // Built-in server-cache integrations run FIRST, and by a direct
3822 + // call rather than as listeners on the action below.
3823 + //
3824 + // WordPress stops dispatching an action's remaining callbacks when
3825 + // one of them throws. As a listener, our LiteSpeed forwarding
3826 + // would then be skipped by any unrelated third-party callback that
3827 + // happened to be registered earlier and blew up — and the visible
3828 + // result is the worst kind: xSpeed reports a successful purge while
3829 + // the server keeps serving stale HTML. Shipped behaviour must not
3830 + // be hostage to a listener's bug.
3831 + self::forward_to_server_caches( $context );
3832 + } catch ( \Throwable $e ) {
3833 + self::log_purge_listener_error( $hook, $e );
3834 + }
3835 +
3836 + try {
3837 + self::do_action_isolated( $hook, $context );
3838 + } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
3839 + // Swallow: see docblock. The purge succeeded regardless.
3840 + self::log_purge_listener_error( $hook, $e );
3841 + } finally {
3842 + unset( self::$purge_events_in_flight[ $target ] );
3843 + }
3844 + }
3845 +
3846 + /**
3847 + * Run every listener on a purge hook, isolating each from the others.
3848 + *
3849 + * `do_action()` dispatches callbacks in one loop, so the first one to
3850 + * throw takes every LATER listener down with it. On a purge that meant a
3851 + * failing CDN integration silently cancelled the ones queued behind it —
3852 + * and because the throw was swallowed to keep the purge itself succeeding,
3853 + * the user was told the clear worked while two edges were never touched.
3854 + * Invisible unless WP_DEBUG happened to be on. (QA #348)
3855 + *
3856 + * Each callback gets its own try/catch here, so one integration's bad day
3857 + * costs only that integration. Priority order is preserved. Falls back to
3858 + * a plain `do_action()` when the filter registry is not the shape we
3859 + * expect, so an unusual environment degrades to the old behaviour rather
3860 + * than skipping listeners entirely.
3861 + *
3862 + * @param string $hook Hook name to emit.
3863 + * @param mixed $arg Single argument passed to each listener.
3864 + */
3865 + public static function do_action_isolated( string $hook, $arg ): void {
3866 + global $wp_filter;
3867 +
3868 + // Walking $wp_filter by hand and calling each callback directly was the
3869 + // obvious way to do this, and it was wrong: it bypasses WordPress, so
3870 + // `current_filter()` came back empty, `did_action()` stayed at 0, the
3871 + // `all` hook never fired, and Query Monitor and Debug Bar could not see
3872 + // the very contract this class publishes. A shared handler branching on
3873 + // current_filter() picked the wrong branch. (QA #348 round 2, issue 3)
3874 + //
3875 + // So let do_action() dispatch — WordPress keeps its bookkeeping — and
3876 + // isolate one level down instead: each registered callback is swapped
3877 + // for a wrapper that runs it inside a try/catch. One listener throwing
3878 + // then costs only that listener, which is the whole point, without
3879 + // costing the hook its identity.
3880 + if ( ! isset( $wp_filter[ $hook ] ) || ! ( $wp_filter[ $hook ] instanceof \WP_Hook ) ) {
3881 + do_action( $hook, $arg );
3882 + return;
3883 + }
3884 +
3885 + $hook_object = $wp_filter[ $hook ];
3886 + $original = $hook_object->callbacks;
3887 + if ( ! is_array( $original ) || array() === $original ) {
3888 + do_action( $hook, $arg );
3889 + return;
3890 + }
3891 +
3892 + $wrapped = array();
3893 + $restorations = array();
3894 + foreach ( $original as $priority => $group ) {
3895 + if ( ! is_array( $group ) ) {
3896 + $wrapped[ $priority ] = $group;
3897 + continue;
3898 + }
3899 + foreach ( $group as $id => $registered ) {
3900 + if ( ! isset( $registered['function'] ) || ! is_callable( $registered['function'] ) ) {
3901 + $wrapped[ $priority ][ $id ] = $registered;
3902 + continue;
3903 + }
3904 + $callback = $registered['function'];
3905 + $wrapper = static function ( ...$args ) use ( $callback, $hook ) {
3906 + try {
3907 + return $callback( ...$args );
3908 + } catch ( \Throwable $e ) {
3909 + self::log_purge_listener_error( $hook, $e );
3910 + return null;
3911 + }
3912 + };
3913 + $wrapped[ $priority ][ $id ] = array(
3914 + // Keep accepted_args: a listener registered for 0 or 1
3915 + // arguments must still be called the way it asked.
3916 + 'accepted_args' => $registered['accepted_args'] ?? 1,
3917 + 'function' => $wrapper,
3918 + );
3919 + $restorations[ $priority ][ $id ] = array(
3920 + 'original' => $registered,
3921 + 'wrapper' => $wrapper,
3922 + );
3923 + }
3924 + }
3925 +
3926 + $hook_object->callbacks = $wrapped;
3927 + try {
3928 + do_action( $hook, $arg );
3929 + } finally {
3930 + // Restore only wrappers still present. Native add/remove operations
3931 + // performed by listeners must survive this temporary substitution.
3932 + foreach ( $restorations as $priority => $group ) {
3933 + foreach ( $group as $id => $restore ) {
3934 + $current = $hook_object->callbacks[ $priority ][ $id ]['function'] ?? null;
3935 + if ( $current === $restore['wrapper'] ) {
3936 + $hook_object->callbacks[ $priority ][ $id ] = $restore['original'];
3937 + }
3938 + }
3939 + }
3940 + }
3941 + }
3942 +
3943 + /**
3944 + * Name a listener that threw, under WP_DEBUG only.
3945 + *
3946 + * Gated like the rest of Free's diagnostics: a third-party listener
3947 + * throwing on every purge must not fill a production log.
3948 + */
3949 + private static function log_purge_listener_error( string $hook, \Throwable $e ): void {
3950 + // An \Error — a TypeError from one of OUR listeners, say — is a bug
3951 + // rather than a runtime condition a third party imposed on us, and
3952 + // swallowing it silently in production turns it into a purge that
3953 + // quietly stops working. Those are logged whatever WP_DEBUG says;
3954 + // third-party \Exceptions stay gated so a noisy integration cannot
3955 + // fill a production log.
3956 + $always = $e instanceof \Error;
3957 + if ( ( $always || ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ) && function_exists( 'error_log' ) ) {
3958 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- names a third-party listener that threw during a purge.
3959 + error_log( '[xspeed] a ' . $hook . ' listener threw: ' . $e->getMessage() );
3960 + }
3961 + }
3962 +
3963 + /** Test seam: clear the in-flight set left behind by an aborted dispatch. */
3964 + public static function reset_purge_events(): void {
3965 + self::$purge_events_in_flight = array();
3966 + self::$purge_event_sequence = 0;
3967 + }
3968 +
3969 + /**
3970 + * Hand the purge to the caches we ship integrations for.
3971 + *
3972 + * Isolated from the public action on purpose — see dispatch_purge_event().
3973 + * Guarded so a missing class (a partial upgrade, a stripped build) cannot
3974 + * turn a working purge into a fatal.
3975 + *
3976 + * @param array<string,mixed> $context Bounded purge context.
3977 + */
3978 + private static function forward_to_server_caches( array $context ): void {
3979 + if ( class_exists( __NAMESPACE__ . '\\Server_Caches' ) ) {
3980 + Server_Caches::forward( $context );
3981 + }
3982 + }
3983 +
3984 + /**
3985 + * `host[:port]` for a cache key, from a parsed URL.
3986 + *
3987 + * The port is kept, because `cache_key()` hashes the raw `HTTP_HOST` and
3988 + * that carries `:8080` on any install not served from 80/443 — dropping it
3989 + * computed a different md5, found no file, and reported "already cold"
3990 + * while the page kept serving HIT.
3991 + *
3992 + * A port that is the DEFAULT for the scheme is dropped, though, because
3993 + * `HTTP_HOST` does not carry one: a browser sends `Host: site.com` for
3994 + * `https://site.com:443/`. Keeping it hashed `site.com:443` against a file
3995 + * stored under `site.com` — the same silent no-op in the other direction,
3996 + * and the one QA hit passing a canonical URL with the port spelled out.
3997 + * (QA #348)
3998 + *
3999 + * @param array<string,mixed> $parts Output of wp_parse_url().
4000 + */
4001 + private static function host_port_of( array $parts ): string {
4002 + if ( ! isset( $parts['host'] ) ) {
4003 + return '';
4004 + }
4005 + $host = strtolower( (string) $parts['host'] );
4006 + if ( '' === $host || ! isset( $parts['port'] ) ) {
4007 + return $host;
4008 + }
4009 + $port = (int) $parts['port'];
4010 + $scheme = isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : '';
4011 + if ( ( 'https' === $scheme && 443 === $port ) || ( 'http' === $scheme && 80 === $port ) ) {
4012 + return $host;
4013 + }
4014 + return $host . ':' . $port;
4015 + }
4016 +
2851 4017 public static function purge_url( string $url, string $cause = 'manual' ): int {
4018 + // A URL that names nothing is not a purge of everything. An empty or
4019 + // blank string used to fall through to the home_url() default below
4020 + // and clear the HOMEPAGE — so a third party calling
4021 + // `purge_url( get_permalink( $id ) )` on a post whose permalink came
4022 + // back empty silently purged the front page instead of nothing. The
4023 + // CLI and the MCP tool reject empties before reaching this, so only
4024 + // direct API callers were exposed, but they are exactly the audience
4025 + // this public contract is for. (QA #348)
4026 + if ( '' === trim( $url ) ) {
4027 + return 0;
4028 + }
2852 4029 $parts = function_exists( 'wp_parse_url' ) ? wp_parse_url( $url ) : parse_url( $url ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- fallback for early-boot contexts only.
2853 4030 if ( ! is_array( $parts ) ) {
2854 4031 return 0;
2855 4032 }
4033 + // Absolute URLs are accepted only for HTTP response caches. Schemes such
4034 + // as ftp:, file: and javascript: can parse cleanly but do not name a page
4035 + // xSpeed or a server response cache can invalidate. A leading-slash path
4036 + // remains a supported site-relative target.
4037 + if ( isset( $parts['scheme'] ) && ! in_array( strtolower( (string) $parts['scheme'] ), array( 'http', 'https' ), true ) ) {
4038 + return 0;
4039 + }
4040 + if ( isset( $parts['scheme'] ) && empty( $parts['host'] ) ) {
4041 + return 0;
4042 + }
4043 + // Reject a string that parsed but is not a URL we can act on: no
4044 + // scheme AND no host AND no leading-slash path means something like
4045 + // `ht!tp://[[[` or a bare word, which parse_url() hands back as a
4046 + // relative "path". Forwarding that produced `purge_url(/ht!tp://[[[)`
4047 + // — a nonsense tag sent to LiteSpeed for every malformed call.
4048 + if ( ! isset( $parts['scheme'] ) && ! isset( $parts['host'] ) ) {
4049 + $raw = isset( $parts['path'] ) ? (string) $parts['path'] : '';
4050 + if ( '' === $raw || '/' !== $raw[0] ) {
4051 + return 0;
4052 + }
4053 + }
2856 4054 // Keep the port. `cache_key()` hashes the raw `HTTP_HOST`, which
2857 4055 // carries `:8080` on any install not served from 80/443 — while
2858 4056 // parse_url() splits the port into its own component, so a purge that
2859 4057 // used the bare host computed a different md5, found no file, and
@@ -2859,19 +4057,34 @@
2859 4057 // used the bare host computed a different md5, found no file, and
2860 4058 // reported "already cold". A silent no-op: the page kept serving HIT
2861 4059 // until its TTL ran out. Intranet installs, panel hosts on :8443 and
2862 4060 // 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'];
4061 + // A scheme-less `site.test:443/page/` is a supported explicit-host
4062 + // target. Infer a scheme only when it names THIS site's hostname: then
4063 + // its explicit default port is the same origin and the same local cache
4064 + // key. Never apply this to another host or to a non-default port.
4065 + if ( ! isset( $parts['scheme'] ) && isset( $parts['host'], $parts['port'] ) && function_exists( 'home_url' ) ) {
4066 + $home = function_exists( 'wp_parse_url' ) ? wp_parse_url( home_url( '/' ) ) : parse_url( home_url( '/' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- see above.
4067 + if ( is_array( $home ) && ! empty( $home['host'] ) && ! empty( $home['scheme'] )
4068 + && strtolower( (string) $home['host'] ) === strtolower( (string) $parts['host'] )
4069 + ) {
4070 + $home_scheme = strtolower( (string) $home['scheme'] );
4071 + $port = (int) $parts['port'];
4072 + $home_port = isset( $home['port'] )
4073 + ? (int) $home['port']
4074 + : ( 'https' === $home_scheme ? 443 : ( 'http' === $home_scheme ? 80 : 0 ) );
4075 + if ( $home_port === $port
4076 + && ( ( 'https' === $home_scheme && 443 === $port ) || ( 'http' === $home_scheme && 80 === $port ) )
4077 + ) {
4078 + $parts['scheme'] = $home_scheme;
4079 + }
4080 + }
2866 4081 }
4082 + $host = self::host_port_of( $parts );
2867 4083 if ( '' === $host && function_exists( 'home_url' ) ) {
2868 4084 $home = function_exists( 'wp_parse_url' ) ? wp_parse_url( home_url( '/' ) ) : parse_url( home_url( '/' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- see above.
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 - }
4085 + if ( is_array( $home ) ) {
4086 + $host = self::host_port_of( $home );
2874 4087 }
2875 4088 }
2876 4089 if ( '' === $host ) {
2877 4090 return 0;
@@ -2955,14 +4168,120 @@
2955 4168 Activity_Log::INFO
2956 4169 );
2957 4170 }
2958 4171
4172 + /**
4173 + * Fires after one URL's cached copy has been purged.
4174 + *
4175 + * The single-URL counterpart to `xspeed_after_purge_all`. Subscribe
4176 + * here to invalidate a cache xSpeed does not own — a server-level
4177 + * cache such as LiteSpeed's LSCache, a reverse proxy, or a CDN — for
4178 + * the same URL.
4179 + *
4180 + * Only fires when the purge actually ran. A malformed URL, a URL with
4181 + * no resolvable host, or a traversal attempt returns earlier and
4182 + * publishes nothing, so a listener can treat this as "xSpeed purged
4183 + * this URL" rather than "xSpeed was asked to". `removed` may legitimately
4184 + * be 0: the URL was not in xSpeed's cache, which says nothing about
4185 + * whether it is in yours.
4186 + *
4187 + * Fires at most once per purge. A listener that calls back into
4188 + * xSpeed's purge API will not re-enter this event.
4189 + *
4190 + * @since 1.2.3
4191 + *
4192 + * @param array $context {
4193 + * Bounded description of the purge. URL queries and caller-supplied
4194 + * causes can contain sensitive values and are not logging fields.
4195 + *
4196 + * @type string $url Canonical scheme://host/path[?query] of the purged URL.
4197 + * The query is preserved because caches in front
4198 + * commonly key on it; xSpeed's own sweep is
4199 + * path-based, so `removed` describes that.
4200 + * @type string $host Host (with port when non-standard).
4201 + * @type string $path Path component, leading slash.
4202 + * @type string $cause Short label for who asked. See purge_all().
4203 + * @type int $removed Number of cache files removed.
4204 + * @type string $scope Actionable adapter scope: `urls`.
4205 + * @type string $intent Why responses changed: `content`.
4206 + * @type string[] $urls Exact response URLs to invalidate.
4207 + * }
4208 + */
4209 + $canonical_url = self::canonical_purge_url(
4210 + $host,
4211 + $path,
4212 + isset( $parts['query'] ) ? (string) $parts['query'] : '',
4213 + isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : ''
4214 + );
4215 + self::dispatch_purge_event(
4216 + 'xspeed_after_purge_url',
4217 + array(
4218 + 'url' => $canonical_url,
4219 + 'host' => $host,
4220 + 'path' => $path,
4221 + 'cause' => $cause,
4222 + 'removed' => $count,
4223 + 'scope' => 'urls',
4224 + 'intent' => 'content',
4225 + 'urls' => array( $canonical_url ),
4226 + )
4227 + );
4228 +
2959 4229 return $count;
2960 4230 }
2961 4231
4232 + /** Host this site's purge is scoped to, for the purge-event context. */
4233 + private static function current_purge_host(): string {
4234 + if ( ! function_exists( 'home_url' ) ) {
4235 + return '';
4236 + }
4237 + $home = function_exists( 'wp_parse_url' ) ? wp_parse_url( home_url( '/' ) ) : parse_url( home_url( '/' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- host only.
4238 + if ( ! is_array( $home ) || empty( $home['host'] ) ) {
4239 + return '';
4240 + }
4241 + // Same default-port normalisation as purge_url(): a site whose
4242 + // home_url() carries `:443` (normal behind a proxy) otherwise stamps
4243 + // every full-purge event with a host that matches none of its own
4244 + // URLs, so the LiteSpeed forward stood down site-wide. (QA #348)
4245 + return self::host_port_of( $home );
4246 + }
4247 +
2962 4248 /**
2963 - * Purge this site's cache.
4249 + * Rebuild the canonical URL a purge applied to.
2964 4250 *
4251 + * Built from the parts the purge itself used, so a listener is told the
4252 + * URL we acted on rather than the string the caller happened to pass —
4253 + * those differ whenever the caller supplied a site-relative path, a
4254 + * different scheme, or a query string the cache key ignores.
4255 + */
4256 + private static function canonical_purge_url( string $host, string $path, string $query = '', string $url_scheme = '' ): string {
4257 + // The purged URL's own scheme wins. purge_url() explicitly supports
4258 + // cross-site purges (multisite, WP-CLI, cron), where composing the
4259 + // current site's scheme onto another site's host builds a URL that was
4260 + // never served — and a CDN listener then purges the wrong key and
4261 + // reports success.
4262 + if ( '' !== $url_scheme ) {
4263 + return $url_scheme . '://' . $host . $path . ( '' !== $query ? '?' . $query : '' );
4264 + }
4265 + $scheme = function_exists( 'is_ssl' ) && is_ssl() ? 'https' : 'http';
4266 + if ( function_exists( 'home_url' ) ) {
4267 + $home = function_exists( 'wp_parse_url' ) ? wp_parse_url( home_url( '/' ) ) : parse_url( home_url( '/' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- scheme only.
4268 + if ( is_array( $home ) && ! empty( $home['scheme'] ) ) {
4269 + $scheme = (string) $home['scheme'];
4270 + }
4271 + }
4272 + // The query is carried even though OUR sweep above is path-based.
4273 + // Caches in front commonly key on the full request line — LiteSpeed
4274 + // tags `/shop/?page=2` separately from `/shop/` — so publishing the
4275 + // bare path would have a listener confidently purge the wrong entry
4276 + // and report success. Telling it exactly what was asked for lets it
4277 + // act correctly; `removed` still describes only what WE removed.
4278 + return $scheme . '://' . $host . $path . ( '' !== $query ? '?' . $query : '' );
4279 + }
4280 +
4281 + /**
4282 + * Sweep this site's cache files.
4283 + *
2965 4284 * On multisite every blog shares one cache directory, so an unscoped
2966 4285 * sweep here took the whole network cold — one subsite's settings save
2967 4286 * or post publish rebuilt every other site from PHP. Entries are stored
2968 4287 * per host (see host_dir()), and the sweep is scoped to match, so a
@@ -2969,12 +4288,13 @@
2969 4288 * purge originating on site-a leaves site-b's cache warm. (#6)
2970 4289 *
2971 4290 * Clears the files only: the flat tree, the static tree, the REST
2972 4291 * 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.
4292 + * update, `xspeed_after_purge_all`, the `xspeed_after_purge` contract
4293 + * event and the log entry live in purge_all(), which is still the entry
4294 + * point for every existing caller. Split out so `wp xspeed purge` can
4295 + * report the local sweep as one line item and the object cache as
4296 + * another, each with its own status — see Purge_Runner.
2977 4297 *
2978 4298 * @param string|null $host Host to purge. Defaults to the current site.
2979 4299 * Pass '*' to sweep the ENTIRE tree — network
2980 4300 * admin's "purge all sites", and the migration
@@ -2995,9 +4315,10 @@
2995 4315 $static_scope = $network_wide ? '' : self::current_static_scope();
2996 4316 } else {
2997 4317 $dir = self::host_dir( $host );
2998 4318 $scope = '' === $dir ? 'default' : $dir;
2999 - $static_scope = $scope;
4319 + $static_dir = self::static_host_dir( $host );
4320 + $static_scope = '' === $static_dir ? 'default' : $static_dir;
3000 4321 }
3001 4322
3002 4323 $count = 0;
3003 4324 if ( is_dir( XSPEED_CACHE_DIR ) ) {
@@ -3149,15 +4470,45 @@
3149 4470 * inventory invalidation, purge log).
3150 4471 *
3151 4472 * @param string $cause Who asked, for the purge log.
3152 4473 * @param string|null $host See purge_local().
4474 + * @param array<string,mixed> $invalidation Public adapter policy. `scope`
4475 + * is urls/site/network/none,
4476 + * `intent` explains why, and
4477 + * `urls` supplies exact targets.
3153 4478 * @return int Page + REST entries removed.
3154 4479 */
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'];
4480 + public static function purge_all( string $cause = 'manual', ?string $host = null, array $invalidation = array() ) {
4481 + $network_wide = ( '*' === $host );
4482 + $adapter_scope = isset( $invalidation['scope'] ) && is_string( $invalidation['scope'] )
4483 + ? $invalidation['scope']
4484 + : ( $network_wide ? 'network' : 'site' );
4485 + if ( ! in_array( $adapter_scope, array( 'urls', 'site', 'network', 'none' ), true ) ) {
4486 + $adapter_scope = $network_wide ? 'network' : 'site';
4487 + }
4488 + if ( $network_wide ) {
4489 + $adapter_scope = 'network';
4490 + }
4491 + $intent = isset( $invalidation['intent'] ) && is_string( $invalidation['intent'] ) && '' !== $invalidation['intent']
4492 + ? $invalidation['intent']
4493 + : 'complete';
4494 + $urls = isset( $invalidation['urls'] ) && is_array( $invalidation['urls'] )
4495 + ? array_values( array_unique( array_filter( $invalidation['urls'], 'is_string' ) ) )
4496 + : array();
4497 + // This method always sweeps a complete local bucket. A narrower adapter
4498 + // announcement would claim unrelated local pages stayed warm when they
4499 + // did not, leaving their server copies stale. Until purge_all() gains
4500 + // dependency-aware local deletion, its response scope cannot be `urls`.
4501 + if ( 'urls' === $adapter_scope ) {
4502 + $adapter_scope = $network_wide ? 'network' : 'site';
4503 + }
4504 + if ( 'site' === $adapter_scope || 'network' === $adapter_scope || 'none' === $adapter_scope ) {
4505 + $urls = array();
4506 + }
3159 4507
4508 + $removed = self::purge_local( $host );
4509 + $count = $removed['pages'] + $removed['rest'];
4510 +
3160 4511 self::flush_object_cache( $network_wide );
3161 4512
3162 4513 self::update_stats( array( 'last_purge' => time() ) );
3163 4514
@@ -3165,10 +4516,64 @@
3165 4516 // Unused CSS, Cloudflare edge purge) run — this action had three
3166 4517 // registered listeners but was never emitted. Treat it as additive
3167 4518 // (CDN / edge invalidation), not the mechanism for clearing local
3168 4519 // files. (FBS-83114)
3169 - do_action( 'xspeed_after_purge_all', $cause );
4520 + // Wrapped: this action predates the purge-event contract and has its
4521 + // own third-party listeners. One of them throwing used to abort
4522 + // purge_all() here, which now also means the contract event below
4523 + // never fires and a server cache keeps serving stale HTML. The local
4524 + // sweep is already done by this point, so swallowing is strictly safer
4525 + // than letting a listener decide the rest of the method runs.
4526 + try {
4527 + // Isolated per listener: one throwing used to cancel every
4528 + // listener queued behind it — Critical CSS, Unused CSS and the
4529 + // Cloudflare edge purge all hang off this hook. (QA #348)
4530 + self::do_action_isolated( 'xspeed_after_purge_all', $cause );
4531 + } catch ( \Throwable $e ) {
4532 + self::log_purge_listener_error( 'xspeed_after_purge_all', $e );
4533 + }
3170 4534
4535 + /**
4536 + * Fires after a full purge, with the same bounded context shape as
4537 + * `xspeed_after_purge_url`.
4538 + *
4539 + * Distinct from `xspeed_after_purge_all` on purpose. That action is
4540 + * the long-standing internal signal — it passes a bare `$cause` string
4541 + * and Free's own modules use it for local bookkeeping. This one is the
4542 + * documented contract for OUTSIDE integrations: same argument shape as
4543 + * the per-URL event, so a server-cache or CDN adapter can subscribe to
4544 + * both with one handler and branch on a null `url`.
4545 + *
4546 + * Fires at most once per purge, and not at all when a listener's own
4547 + * purge re-enters xSpeed.
4548 + *
4549 + * @since 1.2.3
4550 + *
4551 + * @param array $context {
4552 + * @type null $url Always null — a full purge has no single URL.
4553 + * @type string $host Host swept, or '*' for the entire tree.
4554 + * @type null $path Always null.
4555 + * @type string $cause Short label for who asked.
4556 + * @type int $removed Number of cache files removed.
4557 + * @type string $scope Adapter action: urls/site/network/none.
4558 + * @type string $intent content/presentation/complete or a caller-defined intent.
4559 + * @type string[] $urls Exact targets when scope is urls.
4560 + * }
4561 + */
4562 + self::dispatch_purge_event(
4563 + 'xspeed_after_purge',
4564 + array(
4565 + 'url' => null,
4566 + 'host' => null === $host ? self::current_purge_host() : (string) $host,
4567 + 'path' => null,
4568 + 'cause' => $cause,
4569 + 'removed' => $count,
4570 + 'scope' => $adapter_scope,
4571 + 'intent' => $intent,
4572 + 'urls' => $urls,
4573 + )
4574 + );
4575 +
3171 4576 // The list behind the "Cached pages" card is memoized for a minute;
3172 4577 // a purge has to drop it or the drill-down shows pages that no
3173 4578 // longer exist.
3174 4579 Cache_Inventory::invalidate();
@@ -3768,8 +5173,9 @@
3768 5173 $count = self::purge_pages();
3769 5174 self::update_stats( array( 'last_purge' => time() ) );
3770 5175 Cache_Inventory::invalidate();
3771 5176 self::record_partial_purge( 'page', $cause, $count );
5177 + self::announce_purge( $cause, $count );
3772 5178 return $count;
3773 5179
3774 5180 case 'assets':
3775 5181 if ( class_exists( '\\XSpeed\\Minifier' ) ) {
@@ -3796,8 +5202,9 @@
3796 5202 $count = self::purge_pages();
3797 5203 self::update_stats( array( 'last_purge' => time() ) );
3798 5204 Cache_Inventory::invalidate();
3799 5205 self::record_partial_purge( 'assets', $cause, $count );
5206 + self::announce_purge( $cause, $count );
3800 5207 return $count;
3801 5208
3802 5209 case 'object':
3803 5210 if ( function_exists( 'wp_cache_flush' ) ) {
@@ -3808,8 +5215,9 @@
3808 5215
3809 5216 case 'rest':
3810 5217 $count = Rest_Cache::purge();
3811 5218 self::record_partial_purge( 'REST responses', $cause, $count );
5219 + self::announce_purge( $cause, $count );
3812 5220 return $count;
3813 5221
3814 5222 default:
3815 5223 return self::purge_type_unhandled( $type, $cause );
@@ -3862,15 +5270,101 @@
3862 5270 * @param string $type Purge-type slug.
3863 5271 * @param string $cause Who asked.
3864 5272 */
3865 5273 private static function purge_type_unhandled( string $type, string $cause ): int {
3866 - do_action( 'xspeed_purge_type_' . $type );
5274 + $event_sequence = self::$purge_event_sequence;
5275 + $hook = 'xspeed_purge_type_' . $type;
5276 + $has_handler = false !== has_action( $hook );
5277 + do_action( $hook );
3867 5278 self::record_partial_purge( $type, $cause, null );
3868 5279
5280 + // Announce, same as the types this class owns. Pro's "Purge Critical
5281 + // CSS" and "Purge Unused CSS" arrive here, and they change what a
5282 + // cached page CONTAINS — critical CSS is inlined into the HTML, so a
5283 + // server cache goes on serving pages with the old styles baked in.
5284 + // Fixing the three Free buttons and leaving these two silent left the
5285 + // same hole for the tier most likely to be using both plugins.
5286 + // (QA #348 round 2, issue 2)
5287 + //
5288 + // Unknown slugs must not turn into a site-wide purge merely because no
5289 + // handler exists. These are the response-changing Pro types Free knows;
5290 + // third parties can declare another through the filter. A registered
5291 + // handler plus this explicit response scope is the handled signal.
5292 + $scope = in_array( $type, array( 'critical-css', 'unused-css' ), true ) ? 'site' : 'none';
5293 + /**
5294 + * Declare whether a handled custom purge type changes cached responses.
5295 + *
5296 + * @since 1.2.3
5297 + * @param string $scope site/network/none.
5298 + * @param string $type Purge-type slug.
5299 + */
5300 + $scope = (string) apply_filters( 'xspeed_purge_type_response_scope', $scope, $type );
5301 + if ( $has_handler
5302 + && $event_sequence === self::$purge_event_sequence
5303 + && in_array( $scope, array( 'site', 'network' ), true )
5304 + ) {
5305 + self::announce_purge( $cause, 0, $scope, 'presentation' );
5306 + }
5307 +
3869 5308 return 0;
3870 5309 }
3871 5310
3872 5311 /**
5312 + * Tell the server cache that a PARTIAL purge cleared cached responses.
5313 + *
5314 + * "Purge Page / Static Cache", "Purge CSS / JS Cache" and "Purge REST
5315 + * Cache" each delete cached RESPONSES for the whole site, so a cache in
5316 + * front of PHP is now serving copies xSpeed has just thrown away. Only
5317 + * "Purge All" announced itself, which left three of the four toolbar
5318 + * buttons doing exactly what this contract exists to prevent: clearing
5319 + * our copy while the server kept serving the stale one. The `assets` case
5320 + * was the sharpest — it deletes the minified bundles too, so LiteSpeed
5321 + * went on serving pages whose CSS and JS no longer exist. (QA #348)
5322 + *
5323 + * Sent as the full-purge shape (`url` null) because that is what happened:
5324 + * every cached page for this site went, not one address. `object` is not
5325 + * announced — flushing the object cache changes no rendered response a
5326 + * server cache could be holding.
5327 + *
5328 + * Public because Purge_Runner sweeps the local files itself, through
5329 + * purge_local(), rather than through purge_all() — so it has to announce
5330 + * on its own behalf or `wp xspeed purge` and the dashboard button clear
5331 + * our copy while LiteSpeed keeps serving the stale one.
5332 + *
5333 + * @param string $cause Who asked.
5334 + * @param int $removed Entries removed locally.
5335 + * @param string $scope Actionable adapter scope.
5336 + * @param string $intent Reason rendered responses changed.
5337 + */
5338 + public static function announce_purge( string $cause, int $removed, string $scope = 'site', string $intent = 'complete' ): void {
5339 + // Announcing is additive: the local sweep has already happened and
5340 + // succeeded. Notification must never be able to turn a working purge
5341 + // into a fatal, so anything the URL helpers do in an unusual context
5342 + // (early boot, a drop-in, a bare test harness) is contained here
5343 + // rather than propagating to the caller.
5344 + if ( ! function_exists( 'home_url' ) || ! function_exists( 'do_action' ) ) {
5345 + return;
5346 + }
5347 + try {
5348 + self::dispatch_purge_event(
5349 + 'xspeed_after_purge',
5350 + array(
5351 + 'url' => null,
5352 + 'host' => self::current_purge_host(),
5353 + 'path' => null,
5354 + 'cause' => $cause,
5355 + 'removed' => $removed,
5356 + 'scope' => $scope,
5357 + 'intent' => $intent,
5358 + 'urls' => array(),
5359 + )
5360 + );
5361 + } catch ( \Throwable $e ) {
5362 + self::log_purge_listener_error( 'xspeed_after_purge', $e );
5363 + }
5364 + }
5365 +
5366 + /**
3873 5367 * Log a partial purge so the drill-down behind "Last purge" shows every
3874 5368 * clear, not only the full ones. Without this a site whose object cache
3875 5369 * is flushed on a schedule looks, from the log, like nothing happens.
3876 5370 *
@@ -4137,16 +5631,20 @@
4137 5631 return Hit_Counter::is_bot_ua( $ua );
4138 5632 }
4139 5633
4140 5634 /**
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)
5635 + * Whether an edge cache fronts this origin, so an unknown share of hits
5636 + * is served there and never counted here — which makes the origin ratio a
5637 + * partial view the dashboard has to label as such. (#118)
5638 + *
5639 + * This used to mean "the Cloudflare module is switched on", which answered
5640 + * no for every site fronted by anything else, and no for a site on
5641 + * Cloudflare that had never opened our Cloudflare panel. Both of those
5642 + * sites had their ratio presented as the whole story. Edge_Provider knows
5643 + * better and knows it per request, so ask it.
4145 5644 */
4146 5645 private static function edge_cache_detected(): bool {
4147 - $cf = get_option( 'xspeed_module_cloudflare', array() );
4148 - return is_array( $cf ) && ! empty( $cf['enabled'] );
5646 + return Edge_Provider::NONE !== Edge_Provider::detect()['confidence'];
4149 5647 }
4150 5648
4151 5649 /**
4152 5650 * Apply the user's enable/disable choice. Called from the REST toggle
@@ -4743,10 +6241,11 @@
4743 6241 * they don't share a user at all. A default-umask 0644 file is then
4744 6242 * unwritable by nginx, the access_log write silently fails, and the
4745 6243 * dashboard shows a 0% hit ratio even though static HITs are serving.
4746 6244 * 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.)
6245 + * so whatever uid nginx runs as can append. The file holds HIT request
6246 + * lines and must be protected like an access log: paths and queries can
6247 + * contain sensitive values.
4749 6248 */
4750 6249 /**
4751 6250 * Directory holding the nginx hit log. Lives under uploads/, NOT the
4752 6251 * cache dir — uninstall.php and a cache purge both delete the cache
@@ -5742,8 +7241,24 @@
5742 7241 // missing. So: hits are logged, and a user deleting the log can't take
5743 7242 // nginx down.
5744 7243 $lines[] = ' access_log ' . $hits_abs . ' combined buffer=16k flush=5s;';
5745 7244 $lines[] = ' add_header X-XSpeed-Cache "HIT (nginx)" always;';
7245 + // Edge/CDN headers from the same seam the drop-in bakes. nginx serves
7246 + // this path without ever starting PHP, so the answer cannot be
7247 + // resolved per request — the pairs are resolved HERE, when the
7248 + // snippet is generated, and a change of answer needs the snippet
7249 + // regenerated and re-pasted to take effect.
7250 + //
7251 + // Skipped entirely when the static path is switched off. The only
7252 + // reason that can fire under `bake` is mobile-split, and mobile-split
7253 + // is also what switches the static path off — so the block would be
7254 + // baked with a hold it can never serve, and would start serving it
7255 + // the moment the setting is turned off and static files reappear,
7256 + // until somebody regenerates and re-pastes. A rule that can only be
7257 + // served once its premise is false is guaranteed to be stale.
7258 + foreach ( self::static_rewrite_allowed() ? self::edge_headers_for( 'HIT', 'bake' ) : array() as $name => $value ) {
7259 + $lines[] = ' add_header ' . $name . ' "' . self::quote_directive_value( $value ) . '" always;';
7260 + }
5746 7261 $lines[] = '}';
5747 7262 return implode( "\n", $lines );
5748 7263 }
5749 7264
@@ -6036,9 +7551,9 @@
6036 7551 // so the closing quote here cannot be escaped away.
6037 7552 $lines[] = ' RewriteCond %{HTTP_USER_AGENT} "!(' . $ua_rule['regex'] . ')" [NC]';
6038 7553 }
6039 7554
6040 - return array_merge(
7555 + $block = array_merge(
6041 7556 $lines,
6042 7557 array(
6043 7558 // Capture REQUEST_URI without its trailing slash into %1.
6044 7559 // store_static() writes `{host}{uri-without-trailing-slash}/index.html`,
@@ -6056,9 +7571,9 @@
6056 7571 // `^` matches the empty string AND any non-empty path, so it
6057 7572 // covers `/` and `/blog` alike. (Confirmed on OpenLiteSpeed
6058 7573 // 1.8: `.` → homepage served by PHP drop-in; `^` → served
6059 7574 // directly from the static file.)
6060 - ' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [L]',
7575 + ' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [E=XSPEED_STATIC_HIT:1,L]',
6061 7576 '</IfModule>',
6062 7577 // Mark the statically-served response as a cache HIT.
6063 7578 //
6064 7579 // A file served by the rewrite above bypasses PHP entirely, so
@@ -6080,11 +7595,32 @@
6080 7595 '<IfModule mod_headers.c>',
6081 7596 ' <FilesMatch "\\.html$">',
6082 7597 ' Header always set X-XSpeed-Cache "HIT (static)"',
6083 7598 ' </FilesMatch>',
6084 - '</IfModule>',
6085 7599 )
6086 7600 );
7601 +
7602 + // Edge/CDN headers from the same seam the drop-in bakes. Like the
7603 + // nginx snippet, the static rewrite answers without PHP, so the pairs
7604 + // are resolved when the block is GENERATED rather than per request.
7605 + //
7606 + // `env=` rather than the `<FilesMatch>` scoping above, because these
7607 + // must ride only on responses the rewrite produced. The marker header
7608 + // stays filename-scoped: it is inert, and narrowing it would change a
7609 + // header QA reads.
7610 + // Same reasoning as the nginx snippet: a bake hold can only come from
7611 + // mobile-split, and mobile-split is what turns this path off.
7612 + $edge_lines = array();
7613 + foreach ( self::static_rewrite_allowed() ? self::edge_headers_for( 'HIT', 'bake' ) : array() as $edge_name => $edge_value ) {
7614 + $edge_lines = array_merge(
7615 + $edge_lines,
7616 + self::static_hit_directives(
7617 + ' Header always set ' . $edge_name . ' "' . self::quote_directive_value( $edge_value ) . '"'
7618 + )
7619 + );
7620 + }
7621 +
7622 + return array_merge( $block, $edge_lines, array( '</IfModule>' ) );
6087 7623 }
6088 7624
6089 7625 /**
6090 7626 * Active probe that confirms the web-server static-rewrite path is
@@ -6931,8 +8467,22 @@
6931 8467 }
6932 8468 $source_contents = str_replace(
6933 8469 '@@XSPEED_DEFAULT_TTL@@',
6934 8470 (string) ( $expiry_hours * HOUR_IN_SECONDS ),
8471 + $source_contents
8472 + );
8473 +
8474 + // Bake the site-wide edge answer in. Resolved in a `bake` context, so
8475 + // nothing per-page and nothing a request header vouched for can reach
8476 + // it: a bake runs once, in an admin or CLI request, and answers for
8477 + // every page on the site. A page that disagrees gets a sidecar
8478 + // instead — see per_entry_edge_headers().
8479 + //
8480 + // Re-baked on every cache settings save (see CacheModule::boot),
8481 + // exactly like the cookie, user-agent and lifetime rules above.
8482 + $source_contents = str_replace(
8483 + "'@@XSPEED_EDGE_HEADERS@@'",
8484 + self::edge_headers_literal( self::edge_headers_for( 'HIT', 'bake' ) ),
6935 8485 $source_contents
6936 8486 );
6937 8487
6938 8488 if ( file_exists( $target ) ) {