PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.1
1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 All 35 releases
xspeed / includes / class-edge-mode-probe.php

class-edge-mode-probe.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.4.1, at includes/class-edge-mode-probe.php

220 lines 7.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Whether the Cloudflare edge in front of the site obeys "do not store".
4 *
5 * xSpeed marks first renders, bypassed pages and per-visitor pages
6 * `no-store` for the edge. That only works when the edge reads the site's
7 * headers. A Cloudflare Cache Rule whose Edge TTL ignores the origin, or
8 * Cloudflare Enterprise bought from xCloud with Edge Page Caching in
9 * `override_origin` mode, stores every HTML response anyway, so a cart or
10 * account page can be served to the next visitor. Nothing on the site can
11 * read those settings, so this asks the edge directly: it requests a page
12 * that answers `no-store` twice, and a cached second answer means the edge
13 * ignores the site.
14 *
15 * The round-trips run from cron. Health only reads the stored verdict.
16 *
17 * @package XSpeed
18 */
19
20 namespace XSpeed;
21
22 defined( 'ABSPATH' ) || exit;
23
24 final class Edge_Mode_Probe {
25
26 /** Stored verdict. */
27 public const TRANSIENT = 'xspeed_edge_mode_probe';
28
29 /** Token the probe page answers to, set for the length of one probe. */
30 private const TOKEN_TRANSIENT = 'xspeed_edge_mode_probe_token';
31
32 /** Query parameter the probe page answers on. */
33 public const PARAM = 'xspeed_edge_probe';
34
35 /** Cron hook for the background run. */
36 public const CRON_HOOK = 'xspeed_edge_mode_probe_refresh';
37
38 /** The edge stored a `no-store` page: it ignores the site's headers. */
39 public const IGNORES_ORIGIN = 'ignores_origin';
40
41 /** The edge did not store it. */
42 public const RESPECTS_ORIGIN = 'respects_origin';
43
44 /** No Cloudflare in front, as far as the probe could see. */
45 public const NOT_CLOUDFLARE = 'not_cloudflare';
46
47 /** The probe could not finish. */
48 public const UNKNOWN = 'unknown';
49
50 /** Answer the probe request, before anything else renders. */
51 public static function boot(): void {
52 add_action( 'init', array( __CLASS__, 'maybe_answer' ), 0 );
53 }
54
55 /**
56 * The verdict from the two answers' Cloudflare headers.
57 *
58 * Pure, so the rule is tested without a network.
59 *
60 * @param array{ray:string,status:string} $first First answer.
61 * @param array{ray:string,status:string} $second Second answer.
62 */
63 public static function verdict( array $first, array $second ): string {
64 if ( '' === $first['ray'] && '' === $second['ray'] ) {
65 return self::NOT_CLOUDFLARE;
66 }
67 $stored = array( 'HIT', 'STALE', 'UPDATING', 'REVALIDATED' );
68 return in_array( strtoupper( $second['status'] ), $stored, true ) ? self::IGNORES_ORIGIN : self::RESPECTS_ORIGIN;
69 }
70
71 /**
72 * The stored verdict. A cold one schedules a background run and reads
73 * as unknown, so a dashboard load never waits on the edge.
74 *
75 * @return array{verdict:string,checked_at:int}
76 */
77 public static function cached(): array {
78 $cached = get_transient( self::TRANSIENT );
79 if ( is_array( $cached ) && isset( $cached['verdict'] ) ) {
80 return array(
81 'verdict' => (string) $cached['verdict'],
82 'checked_at' => (int) ( $cached['checked_at'] ?? 0 ),
83 );
84 }
85 self::schedule();
86 return array(
87 'verdict' => self::UNKNOWN,
88 'checked_at' => 0,
89 );
90 }
91
92 /** Queue one background run, unless one is pending. */
93 public static function schedule(): void {
94 if ( ! function_exists( 'wp_next_scheduled' ) || wp_next_scheduled( self::CRON_HOOK ) ) {
95 return;
96 }
97 wp_schedule_single_event( time() + 30, self::CRON_HOOK );
98 }
99
100 /**
101 * Run the probe: two requests through whatever is in front of the site.
102 *
103 * @return array{verdict:string,checked_at:int}
104 */
105 public static function run(): array {
106 $token = function_exists( 'wp_generate_password' ) ? wp_generate_password( 16, false ) : bin2hex( random_bytes( 8 ) );
107 set_transient( self::TOKEN_TRANSIENT, $token, 2 * MINUTE_IN_SECONDS );
108
109 $url = add_query_arg( self::PARAM, $token, home_url( '/' ) );
110 $first = self::fetch( $url );
111 $second = null === $first ? null : self::fetch( $url );
112 delete_transient( self::TOKEN_TRANSIENT );
113
114 if ( null === $first || null === $second ) {
115 $result = array(
116 'verdict' => self::UNKNOWN,
117 'checked_at' => time(),
118 );
119 set_transient( self::TRANSIENT, $result, HOUR_IN_SECONDS );
120 return $result;
121 }
122
123 $result = array(
124 'verdict' => self::verdict( $first, $second ),
125 'checked_at' => time(),
126 );
127 set_transient( self::TRANSIENT, $result, 12 * HOUR_IN_SECONDS );
128 return $result;
129 }
130
131 /**
132 * One request, or null when it failed.
133 *
134 * @param string $url Probe URL.
135 * @return array{ray:string,status:string}|null
136 */
137 private static function fetch( string $url ): ?array {
138 $res = wp_remote_get(
139 $url,
140 array(
141 'timeout' => 10,
142 'redirection' => 0,
143 'headers' => Self_Traffic::headers( array( 'User-Agent' => 'xSpeed Health Probe/1.0' ) ),
144 'cookies' => array(),
145 'sslverify' => ! Cookie_Inspector::is_local_host( home_url( '/' ) ),
146 )
147 );
148 if ( is_wp_error( $res ) || 200 !== (int) wp_remote_retrieve_response_code( $res ) ) {
149 return null;
150 }
151 return array(
152 'ray' => (string) wp_remote_retrieve_header( $res, 'cf-ray' ),
153 'status' => (string) wp_remote_retrieve_header( $res, 'cf-cache-status' ),
154 );
155 }
156
157 /**
158 * Answer the probe with a small HTML page marked `no-store` everywhere.
159 * Only while a probe is running, and only for its token.
160 */
161 public static function maybe_answer(): void {
162 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- an anonymous probe; the token below is the check.
163 $given = isset( $_GET[ self::PARAM ] ) ? sanitize_text_field( wp_unslash( $_GET[ self::PARAM ] ) ) : '';
164 if ( ! self::answers( $given ) ) {
165 return;
166 }
167 self::send_answer();
168 exit;
169 }
170
171 /**
172 * Whether a request carrying this token is the running probe.
173 *
174 * @param string $given Token from the query string.
175 */
176 public static function answers( string $given ): bool {
177 if ( '' === $given ) {
178 return false;
179 }
180 $token = get_transient( self::TOKEN_TRANSIENT );
181 return is_string( $token ) && '' !== $token && hash_equals( $token, $given );
182 }
183
184 /** The probe page's headers and body. */
185 public static function send_answer(): void {
186 if ( ! headers_sent() ) {
187 status_header( 200 );
188 header( 'Content-Type: text/html; charset=utf-8' );
189 header( 'Cache-Control: no-store, private' );
190 header( 'CDN-Cache-Control: no-store' );
191 header( 'Cloudflare-CDN-Cache-Control: no-store' );
192 }
193 echo '<!doctype html><title>xSpeed edge probe</title>';
194 }
195
196 /**
197 * Health row when the edge stores pages it was told not to. Null
198 * otherwise: a site whose edge obeys has nothing to act on.
199 *
200 * @param string $verdict Stored verdict.
201 * @param bool $xcloud_cfe Cloudflare Enterprise comes from xCloud's
202 * purge plugin on this site.
203 * @return array{id:string,tone:string,label:string,detail:string}|null
204 */
205 public static function health_row( string $verdict, bool $xcloud_cfe ): ?array {
206 if ( self::IGNORES_ORIGIN !== $verdict ) {
207 return null;
208 }
209 $fix = $xcloud_cfe
210 ? 'This domain\'s Cloudflare Enterprise comes from xCloud. Ask xCloud to set its edge caching mode to respect origin (edge_ttl_mode: respect_origin), or turn Edge Page Caching off in xCloud.'
211 : 'A Cloudflare Cache Rule probably sets Edge TTL to "Ignore cache-control header and use this TTL". Set it to "Respect origin TTL", or leave HTML out of that rule.';
212 return array(
213 'id' => 'edge_mode',
214 'tone' => Health::WARN,
215 'label' => 'Cloudflare stores pages marked do-not-store',
216 'detail' => 'A test page sent with "do not store" came back from Cloudflare\'s cache, so cart, account and other per-visitor pages can be served to the wrong visitor. ' . $fix,
217 );
218 }
219 }
220