PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.2
1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 All 31 releases
← All changes | includes/class-cache.php +15 -794 1.3.51.3.2 View file →
@@ -45,26 +45,8 @@
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 - /**
67 49 * Cache key whose write was deferred to shutdown because a render-time
68 50 * translation plugin's buffer wraps ours. Null on every ordinary request.
69 51 *
70 52 * @var string|null
@@ -465,17 +447,8 @@
465 447 private static function mark( string $value, string $reason = '' ): void {
466 448 self::$status_header = $value;
467 449 self::$bypass_reason = $reason;
468 450
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 -
478 451 if ( headers_sent() ) {
479 452 return;
480 453 }
481 454 header( 'X-XSpeed-Cache: ' . $value );
@@ -481,26 +454,10 @@
481 454 header( 'X-XSpeed-Cache: ' . $value );
482 455 if ( '' !== $reason && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
483 456 header( 'X-XSpeed-Reason: ' . $reason );
484 457 }
485 - foreach ( self::$edge_headers as $name => $val ) {
486 - header( $name . ': ' . $val );
487 - }
488 458 }
489 459
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 -
503 460 /** Record a bypass gate and answer "don't cache" in one statement. */
504 461 private static function bypass( string $reason ): bool {
505 462 self::mark( 'BYPASS', $reason );
506 463 return false;
@@ -516,639 +473,8 @@
516 473 return self::$bypass_reason;
517 474 }
518 475
519 476 /**
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 - /**
1151 477 * Bypass gates that describe THE VISITOR rather than THIS REQUEST.
1152 478 *
1153 479 * Only these may be recorded in the bypass cookie. A visitor-scoped
1154 480 * verdict stays true for the visitor's next request — they are still
@@ -1455,16 +781,9 @@
1455 781
1456 782 // Static tree too, under the same gates finalize_buffer() applies —
1457 783 // otherwise deferring the write would silently cost translated pages
1458 784 // the web-server fast path and leave them on the slower drop-in.
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() ) {
785 + if ( self::static_rewrite_allowed() && self::response_is_plain_html() ) {
1467 786 self::store_static( $full );
1468 787 }
1469 788 }
1470 789
@@ -1471,17 +790,10 @@
1471 790 public static function should_cache() {
1472 791 // Reset first: a single request only reaches this once (the sole
1473 792 // caller is maybe_start_cache()), but tests and any future caller
1474 793 // must never inherit the previous request's verdict.
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();
794 + self::$status_header = '';
795 + self::$bypass_reason = '';
1484 796
1485 797 $opts = Settings::get();
1486 798 if ( empty( $opts['cache_enabled'] ) ) {
1487 799 return self::bypass( 'cache-disabled' );
@@ -1532,20 +844,8 @@
1532 844 * @param bool $cache_feed Whether to cache this feed request.
1533 845 */
1534 846 $cache_feed = $is_feed_request && (bool) apply_filters( 'xspeed_should_cache_feed', false );
1535 847
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 -
1548 848 // Query string handling: anything OUTSIDE the ignored-params
1549 849 // allow-list (utm_*, fbclid, gclid by default) means a unique
1550 850 // request that we don't want to share with the canonical cache
1551 851 // entry. Skip cache rather than poison the key.
@@ -2763,16 +2063,9 @@
2763 2063 // 200, FBS-82406) or a non-HTML content-type (a cached feed would go
2764 2064 // out as text/html, FBS-82407). The web server serves these .html files
2765 2065 // directly with no PHP, so there's no .meta replay — keep them on the
2766 2066 // drop-in / PHP path instead, which DOES replay status + content-type.
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() ) {
2067 + if ( self::static_rewrite_allowed() && self::response_is_plain_html() ) {
2775 2068 self::store_static( $full );
2776 2069 }
2777 2070
2778 2071 return $buffer;
@@ -3303,19 +2596,8 @@
3303 2596 if ( $ttl > 0 && $ttl !== $default_ttl ) {
3304 2597 $meta['ttl'] = $ttl;
3305 2598 }
3306 2599
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 -
3318 2600 // Nothing to replay → no sidecar.
3319 2601 if ( empty( $meta ) ) {
3320 2602 return;
3321 2603 }
@@ -5631,20 +4913,16 @@
5631 4913 return Hit_Counter::is_bot_ua( $ua );
5632 4914 }
5633 4915
5634 4916 /**
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.
4917 + * Whether an edge cache fronts this origin. Today: the Cloudflare
4918 + * integration is connected — so an unknown share of hits is served at the
4919 + * edge and never counted here, making the origin ratio a partial view the
4920 + * dashboard must label as such. (#118)
5644 4921 */
5645 4922 private static function edge_cache_detected(): bool {
5646 - return Edge_Provider::NONE !== Edge_Provider::detect()['confidence'];
4923 + $cf = get_option( 'xspeed_module_cloudflare', array() );
4924 + return is_array( $cf ) && ! empty( $cf['enabled'] );
5647 4925 }
5648 4926
5649 4927 /**
5650 4928 * Apply the user's enable/disable choice. Called from the REST toggle
@@ -7241,24 +6519,8 @@
7241 6519 // missing. So: hits are logged, and a user deleting the log can't take
7242 6520 // nginx down.
7243 6521 $lines[] = ' access_log ' . $hits_abs . ' combined buffer=16k flush=5s;';
7244 6522 $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 - }
7261 6523 $lines[] = '}';
7262 6524 return implode( "\n", $lines );
7263 6525 }
7264 6526
@@ -7551,9 +6813,9 @@
7551 6813 // so the closing quote here cannot be escaped away.
7552 6814 $lines[] = ' RewriteCond %{HTTP_USER_AGENT} "!(' . $ua_rule['regex'] . ')" [NC]';
7553 6815 }
7554 6816
7555 - $block = array_merge(
6817 + return array_merge(
7556 6818 $lines,
7557 6819 array(
7558 6820 // Capture REQUEST_URI without its trailing slash into %1.
7559 6821 // store_static() writes `{host}{uri-without-trailing-slash}/index.html`,
@@ -7571,9 +6833,9 @@
7571 6833 // `^` matches the empty string AND any non-empty path, so it
7572 6834 // covers `/` and `/blog` alike. (Confirmed on OpenLiteSpeed
7573 6835 // 1.8: `.` → homepage served by PHP drop-in; `^` → served
7574 6836 // directly from the static file.)
7575 - ' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [E=XSPEED_STATIC_HIT:1,L]',
6837 + ' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [L]',
7576 6838 '</IfModule>',
7577 6839 // Mark the statically-served response as a cache HIT.
7578 6840 //
7579 6841 // A file served by the rewrite above bypasses PHP entirely, so
@@ -7595,32 +6857,11 @@
7595 6857 '<IfModule mod_headers.c>',
7596 6858 ' <FilesMatch "\\.html$">',
7597 6859 ' Header always set X-XSpeed-Cache "HIT (static)"',
7598 6860 ' </FilesMatch>',
6861 + '</IfModule>',
7599 6862 )
7600 6863 );
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>' ) );
7623 6864 }
7624 6865
7625 6866 /**
7626 6867 * Active probe that confirms the web-server static-rewrite path is
@@ -7675,12 +6916,9 @@
7675 6916
7676 6917 $home = home_url( '/' );
7677 6918 $host = (string) wp_parse_url( $home, PHP_URL_HOST );
7678 6919 if ( '' === $host ) {
7679 - // Environmental failure, not evidence the server config is wrong —
7680 - // mark it inconclusive so Health surfaces say "could not verify"
7681 - // instead of demanding a snippet paste. (#480)
7682 - $result = array( 'active' => false, 'inconclusive' => true, 'reason' => 'home_url has no host' );
6920 + $result = array( 'active' => false, 'reason' => 'home_url has no host' );
7683 6921 set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
7684 6922 return $result;
7685 6923 }
7686 6924
@@ -7697,12 +6935,9 @@
7697 6935 if ( ! file_exists( $probe_dir ) ) {
7698 6936 wp_mkdir_p( $probe_dir );
7699 6937 }
7700 6938 if ( ! is_dir( $probe_dir ) ) {
7701 - // A cache-dir permissions problem — the probe never ran, so this
7702 - // says nothing about the nginx config. Inconclusive, not
7703 - // "required". (#480)
7704 - $result = array( 'active' => false, 'inconclusive' => true, 'reason' => 'cannot create probe dir' );
6939 + $result = array( 'active' => false, 'reason' => 'cannot create probe dir' );
7705 6940 set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
7706 6941 return $result;
7707 6942 }
7708 6943 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin credentials we may not have here; the file is in our own cache dir.
@@ -8473,22 +7708,8 @@
8473 7708 }
8474 7709 $source_contents = str_replace(
8475 7710 '@@XSPEED_DEFAULT_TTL@@',
8476 7711 (string) ( $expiry_hours * HOUR_IN_SECONDS ),
8477 - $source_contents
8478 - );
8479 -
8480 - // Bake the site-wide edge answer in. Resolved in a `bake` context, so
8481 - // nothing per-page and nothing a request header vouched for can reach
8482 - // it: a bake runs once, in an admin or CLI request, and answers for
8483 - // every page on the site. A page that disagrees gets a sidecar
8484 - // instead — see per_entry_edge_headers().
8485 - //
8486 - // Re-baked on every cache settings save (see CacheModule::boot),
8487 - // exactly like the cookie, user-agent and lifetime rules above.
8488 - $source_contents = str_replace(
8489 - "'@@XSPEED_EDGE_HEADERS@@'",
8490 - self::edge_headers_literal( self::edge_headers_for( 'HIT', 'bake' ) ),
8491 7712 $source_contents
8492 7713 );
8493 7714
8494 7715 if ( file_exists( $target ) ) {