PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.0
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
← All changes | includes/advanced-cache.php +408 -15 1.0.8 → 1.4.0 View file →
@@ -1,8 +1,8 @@
1 1 <?php
2 2 /**
3 3 * XSPEED_DROPIN
4 - * XSPEED_DROPIN_VERSION: 4
4 + * XSPEED_DROPIN_VERSION: 15
5 5 * Drop-in cache loader. Serves cached HTML before WordPress fully boots.
6 6 *
7 7 * Bump XSPEED_DROPIN_VERSION whenever this file's serve logic changes so
8 8 * Cache::ensure_dropin_current() reinstalls it on existing sites (the
@@ -12,8 +12,63 @@
12 12 * v3: conditional GET — emit Last-Modified + ETag, answer matching
13 13 * If-Modified-Since / If-None-Match with 304 (FBS-82407 #5).
14 14 * v4: bail when the `.maintenance-active` sentinel is present so a page
15 15 * cached while live isn't served during maintenance (FBS-82409 B1).
16 + * v5: per-site cache buckets — entries moved from `cache/xspeed/<md5>.html`
17 + * to `cache/xspeed/<host>[/<blog-path>]/<md5>.html` so a multisite
18 + * purge can be scoped to one blog. An un-bumped drop-in would keep
19 + * reading the old flat path, miss every entry and boot WordPress on
20 + * every request (#6).
21 + * v6: serve tracking-param requests from the fast path — read the
22 + * precompiled `ignored_query_params` allow-list instead of bailing on
23 + * any query string (#13). An un-bumped drop-in keeps the old bail and
24 + * campaign traffic keeps paying a full WordPress boot.
25 + * v7: the page TTL is baked in at install time from the `cache_expiry`
26 + * setting instead of a hardcoded 86400, so the drop-in enforces the
27 + * configured lifetime rather than a fixed 24h (#240).
28 + * v8: never serve an empty, stale, or short `.br` sibling — an uninflatable
29 + * brotli stream renders as a blank page. THIS FILE IS A COPY made when
30 + * caching was enabled, so without the bump an updated site keeps the old
31 + * serve logic and never receives the fix (#286).
32 + * v9: emit the edge/CDN headers baked in from `xspeed_edge_cache_headers`
33 + * on a HIT. Without the bump an existing site keeps a drop-in with no
34 + * placeholder to bake into, so an add-on's CDN headers appear on every
35 + * serve path except this one.
36 + * v10: emit `X-XSpeed-Built` — the served file's mtime — so a CDN that has
37 + * just purged can tell whether the origin answered with newer HTML or
38 + * with the same page again. Without the bump an existing site's drop-in
39 + * stays silent and every purge through it reads as unverifiable.
40 + * v11: a page whose edge answer differs from the site-wide one carries its
41 + * own pairs in the `.meta` sidecar, and they REPLACE the baked set.
42 + * Without the bump an existing drop-in keeps sending the baked pairs
43 + * for that page, lifetime and all.
44 + * v12: `XSPEED_EDGE_PROVIDER=off` empties the edge pairs here too, before
45 + * they are sent, so the emergency switch does not wait for a re-bake.
46 + * `X-XSpeed-Built` still goes out: it is a diagnostic, not an
47 + * instruction to the edge.
48 + * v13: a merge of two lines of history that had both used 9 and 10. On `dev`
49 + * they were: v9 carries the baked edge-header answer so a hold set for a
50 + * page reaches the paths that run without PHP, and v10 keeps bots,
51 + * scanners, cached 404s and xSpeed's own requests (by UA or the
52 + * X-XSpeed-Self header) out of hits.log. Everything in v9 to v12 above
53 + * and both of those are in this file. The bump is what makes a site on
54 + * either line rewrite its drop-in.
55 + * v14: the key path lowercases percent-escapes and escapes non-ASCII bytes,
56 + * the spelling Cache::normalize_path() now hashes, so `%E7`, `%e7` and
57 + * raw UTF-8 spellings of a non-ASCII slug share one key with the entry
58 + * PHP wrote. Before, PHP keyed a sanitize_text_field() copy that had
59 + * lost every escape, so these pages were never a HIT here. Without the
60 + * bump an existing site keeps computing the old key for them.
61 + * v15: two changes, one bump.
62 + * - A page with a lifetime of its own (the sidecar `ttl`: a nonce cap,
63 + * a per-post expiry) has every edge lifetime in its pairs cut to what
64 + * the copy has left. Without the bump an existing drop-in keeps
65 + * telling the edge to hold a nonce page for the site's whole lifetime.
66 + * - A HIT served for a URL carrying an ignored param (`?utm_source=…`)
67 + * sends the baked `query-variant` hold instead of the edge lifetime,
68 + * so an edge keeps only URLs a purge can name. A query string of `0`
69 + * is no longer read as no query string. Without the bump an existing
70 + * drop-in keeps telling the edge to store every variant.
16 71 *
17 72 * IMPORTANT: This file is included by wp-settings.php BEFORE
18 73 * wp-includes/formatting.php and wp-includes/load.php are loaded, so NO
19 74 * WordPress functions (sanitize_text_field, wp_unslash, is_admin,
@@ -32,11 +87,55 @@
32 87 if ( 'GET' !== $xspeed_method ) {
33 88 return;
34 89 }
35 90
36 -// Skip cached query-string requests (search, pagination via ?, etc.).
37 -if ( ! empty( $_SERVER['QUERY_STRING'] ) ) {
38 - return;
91 +// Query-string requests. A tracking param contributes nothing to the
92 +// response, and PHP already caches `/post?utm_source=x` under the same key
93 +// as `/post` — but this file used to bail on ANY query string, so every
94 +// visitor arriving from an email or ad campaign paid a full WordPress boot
95 +// to be handed a file that was already on disk. On a marketing site that is
96 +// most of the paid traffic taking the slowest path. (#13)
97 +//
98 +// We cannot read the option or call Glob_Matcher here (WordPress is not
99 +// loaded), so Cache::sync_query_allowlist() precompiles the user's
100 +// `ignored_query_params` into a regex next to the cache files. Every key
101 +// must match it; one that doesn't means the response could genuinely vary,
102 +// so we stand down and let PHP decide. A missing sidecar means the same —
103 +// fail safe, never guess.
104 +//
105 +// A key that matches is one the cache key leaves out, so the URL it makes is
106 +// one no purge names. `$xspeed_qs_unpurged` records that for the edge hold
107 +// further down, the same test Cache::query_carries_unpurged_param() makes.
108 +//
109 +// An empty string test rather than empty(): a query string of `0` is still a
110 +// query string, and empty() let `/post?0` through as if it were `/post`.
111 +$xspeed_qs_unpurged = false;
112 +// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Drop-in runs pre-WP. Only tested for emptiness here; parsed for its keys below.
113 +if ( isset( $_SERVER['QUERY_STRING'] ) && '' !== (string) $_SERVER['QUERY_STRING'] ) {
114 + $xspeed_allow_file = WP_CONTENT_DIR . '/cache/xspeed/.ignored-query-params';
115 + if ( ! is_readable( $xspeed_allow_file ) ) {
116 + return;
117 + }
118 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.PHP.NoSilencedErrors.Discouraged -- pre-WP drop-in; an unreadable sidecar degrades to "let PHP handle it".
119 + $xspeed_allow_re = trim( (string) @file_get_contents( $xspeed_allow_file ) );
120 + if ( '' === $xspeed_allow_re ) {
121 + return;
122 + }
123 +
124 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Drop-in runs pre-WP. parse_str() urldecodes exactly as WordPress does; only KEYS are consumed, and only as preg_match() input — never echoed, never executed.
125 + parse_str( str_replace( "\0", '', (string) $_SERVER['QUERY_STRING'] ), $xspeed_qs_params );
126 + if ( empty( $xspeed_qs_params ) ) {
127 + return;
128 + }
129 + foreach ( array_keys( $xspeed_qs_params ) as $xspeed_qs_key ) {
130 + // Anchored: a param named `referrer` must not be waved through by
131 + // a `ref` entry. Mirrors Glob_Matcher's full-string semantics.
132 + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a malformed baked pattern degrades to "let PHP handle it", never a warning per request.
133 + if ( 1 !== @preg_match( '#^' . $xspeed_allow_re . '$#', (string) $xspeed_qs_key ) ) {
134 + return;
135 + }
136 + $xspeed_qs_unpurged = true;
137 + }
39 138 }
40 139
41 140 // Honor explicit bypass header. xSpeed's own benchmark REST endpoint
42 141 // sends `X-XSpeed-Bypass: 1` so we can measure uncached TTFB for the
@@ -82,14 +181,52 @@
82 181 unset( $xspeed_cookie_value );
83 182 $xspeed_cookie_name = (string) $xspeed_cookie_name;
84 183 if ( 0 === strpos( $xspeed_cookie_name, 'wordpress_logged_in' )
85 184 || 0 === strpos( $xspeed_cookie_name, 'comment_author_' )
86 - || 0 === strpos( $xspeed_cookie_name, 'wp-postpass_' ) ) {
185 + || 0 === strpos( $xspeed_cookie_name, 'wp-postpass_' )
186 + // The generic bypass cookie PHP sets whenever it decides a
187 + // visitor must not be served from cache (Server_Rules::
188 + // BYPASS_COOKIE). Covers repeat visitors even when the baked
189 + // rules below are stale.
190 + || 'wordpress_no_cache' === $xspeed_cookie_name ) {
87 191 return;
88 192 }
89 193 }
90 194 }
91 195
196 +// The user's own excluded-cookie list, baked in at install time by
197 +// Cache::install_dropin() (the token is replaced with an escaped regex
198 +// built by Server_Rules). The drop-in runs before WordPress loads and so
199 +// cannot read the settings itself; without this, every cart / membership
200 +// / custom cookie rule applied only while a page was cold, and a warm
201 +// page was served to exactly the visitors the settings excluded.
202 +//
203 +// An un-substituted token means the drop-in was copied straight from a
204 +// source checkout — fall back to serving nothing from the fast path
205 +// rather than treating the literal token as a pattern.
206 +$xspeed_cookie_re = '@@XSPEED_COOKIE_RE@@';
207 +if ( '@@' !== substr( $xspeed_cookie_re, 0, 2 ) && '' !== $xspeed_cookie_re && ! empty( $_COOKIE ) ) {
208 + foreach ( array_keys( $_COOKIE ) as $xspeed_cookie_name ) {
209 + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a malformed baked pattern must degrade to "don't serve from cache", never warn on every request.
210 + if ( 1 === @preg_match( '#(' . $xspeed_cookie_re . ')#i', (string) $xspeed_cookie_name ) ) {
211 + return;
212 + }
213 + }
214 +}
215 +
216 +// Same for the user-agent bypass list. This is the rule the bypass cookie
217 +// can never cover: a bot's very first request to a warm page never
218 +// reaches PHP, so there is no earlier request in which to set a cookie.
219 +$xspeed_ua_re = '@@XSPEED_UA_RE@@';
220 +if ( '@@' !== substr( $xspeed_ua_re, 0, 2 ) && '' !== $xspeed_ua_re ) {
221 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Drop-in runs pre-WP. Value is only matched against a baked, pre-escaped regex; never echoed or executed.
222 + $xspeed_ua_raw = isset( $_SERVER['HTTP_USER_AGENT'] ) ? (string) $_SERVER['HTTP_USER_AGENT'] : '';
223 + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- see above; degrade to bypass rather than warn.
224 + if ( '' !== $xspeed_ua_raw && 1 === @preg_match( '#(' . $xspeed_ua_re . ')#i', $xspeed_ua_raw ) ) {
225 + return;
226 + }
227 +}
228 +
92 229 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Drop-in runs before wp_unslash()/sanitize_text_field() are loaded. Value is filtered through a strict allowlist regex below (letters, digits, dot, hyphen, colon) and only used as md5() input for the cache key.
93 230 $xspeed_host = isset( $_SERVER['HTTP_HOST'] ) ? (string) $_SERVER['HTTP_HOST'] : 'default';
94 231 $xspeed_host = str_replace( "\0", '', $xspeed_host );
95 232 // Restrict host to a safe charset (letters, digits, dot, hyphen, colon for port).
@@ -94,10 +231,25 @@
94 231 $xspeed_host = str_replace( "\0", '', $xspeed_host );
95 232 // Restrict host to a safe charset (letters, digits, dot, hyphen, colon for port).
96 233 $xspeed_host = preg_replace( '/[^a-zA-Z0-9.\-:]/', '', $xspeed_host );
97 234
98 -$xspeed_path_only = strtok( $xspeed_request_uri, '?' );
235 +$xspeed_path_only = (string) strtok( $xspeed_request_uri, '?' );
99 236
237 +// Path spelling. MUST mirror XSpeed\Cache::normalize_path() exactly.
238 +// WordPress links carry lower-case escapes and browsers send upper-case ones,
239 +// so escapes are lowercased, and a byte outside printable ASCII is escaped the
240 +// same way. Nothing is decoded. Without this a non-ASCII slug requested as
241 +// `%E7…` or as raw UTF-8 hashes to a different key than the one
242 +// Cache::store() wrote. A plain ASCII path passes through unchanged. The
243 +// site-path match below sees this spelling too; blog paths are plain ASCII.
244 +$xspeed_path_only = (string) preg_replace_callback(
245 + '/%[0-9a-fA-F]{2}|[^\x21-\x7E]/',
246 + static function ( $xspeed_m ) {
247 + return '%' === $xspeed_m[0][0] ? strtolower( $xspeed_m[0] ) : sprintf( '%%%02x', ord( $xspeed_m[0] ) );
248 + },
249 + $xspeed_path_only
250 +);
251 +
100 252 // Device bucket — MUST mirror XSpeed\Cache::cache_key() exactly, or the key
101 253 // the drop-in computes won't match the file Cache::store() wrote, the HIT
102 254 // branch below never fires, and every request falls through to a full
103 255 // WordPress boot (defeating the whole point of the pre-WP drop-in).
@@ -127,12 +279,63 @@
127 279 }
128 280 $xspeed_device = $xspeed_is_mobile ? '|m' : '|d';
129 281 }
130 282
131 -$xspeed_cache_key = md5( $xspeed_host . $xspeed_path_only . $xspeed_device );
132 -$xspeed_cache_file = WP_CONTENT_DIR . '/cache/xspeed/' . $xspeed_cache_key . '.html';
133 -$xspeed_meta_file = WP_CONTENT_DIR . '/cache/xspeed/' . $xspeed_cache_key . '.meta';
283 +$xspeed_cache_key = md5( $xspeed_host . $xspeed_path_only . $xspeed_device );
134 284
285 +// Per-site bucket. MUST mirror XSpeed\Cache::current_host_dir() exactly —
286 +// same charset, same trimmed dots, same 'default' fallback, same multisite
287 +// path prefix — or the drop-in looks in a directory Cache::store() never
288 +// wrote to, every HIT misses, and every request falls through to a full
289 +// WordPress boot.
290 +//
291 +// Note this is NOT $xspeed_host: the cache KEY keeps the colon of
292 +// `host:port` (it only ever feeds md5()), while the DIRECTORY cannot —
293 +// a colon is not portable in a path. (#6)
294 +$xspeed_host_dir = $xspeed_host;
295 +$xspeed_host_colon = strpos( $xspeed_host_dir, ':' );
296 +if ( false !== $xspeed_host_colon ) {
297 + $xspeed_host_dir = substr( $xspeed_host_dir, 0, $xspeed_host_colon );
298 +}
299 +$xspeed_host_dir = preg_replace( '/[^a-zA-Z0-9.\-]/', '', $xspeed_host_dir );
300 +$xspeed_host_dir = preg_replace( '/\.{2,}/', '.', (string) $xspeed_host_dir );
301 +$xspeed_host_dir = trim( (string) $xspeed_host_dir, '.-' );
302 +if ( '' === $xspeed_host_dir ) {
303 + $xspeed_host_dir = 'default';
304 +}
305 +
306 +// Subdirectory multisite: every blog shares one host, so the host alone
307 +// would put them all in one bucket and they would keep purging each other.
308 +// We cannot call is_multisite()/get_blog_details() here (WordPress is not
309 +// loaded), so Cache::sync_site_paths() persists the network's blog paths
310 +// as `<raw-path>|<segment>` lines, longest first. Prefix-match the URI.
311 +$xspeed_paths_file = WP_CONTENT_DIR . '/cache/xspeed/.site-paths';
312 +if ( file_exists( $xspeed_paths_file ) ) {
313 + $xspeed_uri_trimmed = ltrim( (string) $xspeed_path_only, '/' );
314 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- our own sidecar; WP_Filesystem is not loaded pre-WP.
315 + $xspeed_paths_raw = (string) @file_get_contents( $xspeed_paths_file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- unreadable sidecar just means "no prefix".
316 + foreach ( explode( "\n", $xspeed_paths_raw ) as $xspeed_path_line ) {
317 + $xspeed_sep = strpos( $xspeed_path_line, '|' );
318 + if ( false === $xspeed_sep ) {
319 + continue;
320 + }
321 + $xspeed_raw_path = substr( $xspeed_path_line, 0, $xspeed_sep );
322 + $xspeed_segment = substr( $xspeed_path_line, $xspeed_sep + 1 );
323 + if ( '' === $xspeed_raw_path || '' === $xspeed_segment ) {
324 + continue;
325 + }
326 + if ( $xspeed_uri_trimmed === $xspeed_raw_path
327 + || 0 === strpos( $xspeed_uri_trimmed, $xspeed_raw_path . '/' ) ) {
328 + $xspeed_host_dir .= '/' . $xspeed_segment;
329 + break;
330 + }
331 + }
332 +}
333 +
334 +$xspeed_cache_dir = WP_CONTENT_DIR . '/cache/xspeed/' . $xspeed_host_dir . '/';
335 +$xspeed_cache_file = $xspeed_cache_dir . $xspeed_cache_key . '.html';
336 +$xspeed_meta_file = $xspeed_cache_dir . $xspeed_cache_key . '.meta';
337 +
135 338 if ( file_exists( $xspeed_cache_file ) ) {
136 339 // Read the .meta sidecar (status / content_type / ttl) the same way the
137 340 // PHP HIT path does — the drop-in serves cached feeds and 404s too, so it
138 341 // must replay their Content-Type / status and honor their per-content TTL.
@@ -149,11 +352,32 @@
149 352 }
150 353 }
151 354 }
152 355
153 - // Per-content TTL from meta (e.g. feeds) falls back to the 24h page
154 - // default. HOUR_IN_SECONDS isn't defined yet (pre-WP), so use a literal.
155 - $xspeed_ttl = ( isset( $xspeed_meta['ttl'] ) && (int) $xspeed_meta['ttl'] > 0 ) ? (int) $xspeed_meta['ttl'] : 86400;
356 + // Per-content TTL from meta (e.g. feeds) falls back to the site's
357 + // configured cache_expiry, baked in at install time by
358 + // Cache::install_dropin() and re-baked on every settings save. The
359 + // drop-in runs before WordPress loads and so cannot read the option
360 + // itself; without this it applied a hardcoded 24h to every ordinary
361 + // page — write_meta() only writes a `ttl` sidecar when the value differs
362 + // from the page default, so ordinary pages carry no sidecar at all.
363 + // That served stale content under Conservative (12h) and refused the
364 + // fast path for 6 of 7 days under Aggressive (168h). (#240)
365 + //
366 + // An un-substituted token means the drop-in was copied straight from a
367 + // source checkout — fall back to the historical 24h literal rather than
368 + // treating the token as a number. HOUR_IN_SECONDS isn't defined yet.
369 + $xspeed_default_ttl = '@@XSPEED_DEFAULT_TTL@@';
370 + $xspeed_unbaked = ( '@@' === substr( $xspeed_default_ttl, 0, 2 ) || (int) $xspeed_default_ttl < 1 );
371 + $xspeed_default_ttl = $xspeed_unbaked ? 86400 : (int) $xspeed_default_ttl;
372 + if ( $xspeed_unbaked ) {
373 + // Make the un-substituted state observable. Serving the 24h literal
374 + // silently is exactly how the original bug stayed invisible; a site
375 + // on this path is enforcing a lifetime nobody configured.
376 + header( 'X-XSpeed-Cache-TTL: default (unbaked)' );
377 + }
378 +
379 + $xspeed_ttl = ( isset( $xspeed_meta['ttl'] ) && (int) $xspeed_meta['ttl'] > 0 ) ? (int) $xspeed_meta['ttl'] : $xspeed_default_ttl;
156 380 $xspeed_age = time() - filemtime( $xspeed_cache_file );
157 381 if ( $xspeed_age < $xspeed_ttl ) {
158 382 // PHP-served cache hit (the ~85ms fallback path). The nginx static
159 383 // rewrite sends "HIT (nginx)" for the fast 5-15ms path; same header,
@@ -159,8 +383,107 @@
159 383 // rewrite sends "HIT (nginx)" for the fast 5-15ms path; same header,
160 384 // distinct value so you can tell which layer served the page.
161 385 header( 'X-XSpeed-Cache: HIT (php)' );
162 386
387 + // Edge/CDN headers decided by Cache::edge_headers_for(). No filter
388 + // can run here — plugins are not loaded — so Cache::install_dropin()
389 + // bakes the resolved pairs into the literal below and re-bakes them
390 + // on every cache settings save.
391 + //
392 + // An un-substituted placeholder means this file was copied straight
393 + // from a source checkout: it stays a string, is_array() rejects it,
394 + // and the HIT is served with no edge headers rather than a fatal.
395 + $xspeed_edge_headers = '@@XSPEED_EDGE_HEADERS@@';
396 +
397 + // A page whose answer differs from the site-wide one carries its own
398 + // pairs in the sidecar. It REPLACES the baked set rather than adding
399 + // to it: the two describe the same response, and merging would leave
400 + // the baked lifetime in place beside the hold meant to overrule it.
401 + if ( isset( $xspeed_meta['edge_headers'] ) && is_array( $xspeed_meta['edge_headers'] ) ) {
402 + $xspeed_edge_headers = $xspeed_meta['edge_headers'];
403 + }
404 +
405 + // A URL carrying an ignored param gets the hold baked for it instead.
406 + // This file serves `/post?utm_source=x` from the entry stored for
407 + // `/post`, but an edge keys on the full URL, so it would store each
408 + // variant as its own copy, and a purge of `/post` never reaches them.
409 + // Holding the variants keeps the edge to URLs a purge can name. This
410 + // file keeps serving them; only the edge copy is refused.
411 + //
412 + // It replaces the sidecar too, since that carries a lifetime. The
413 + // page's `Cache-Tag` is kept, as Cache::edge_headers_for() keeps it on
414 + // every hold. An empty literal means the hold would say the same as
415 + // the plain answer, and nothing changes. An un-substituted token
416 + // stays a string and is ignored.
417 + $xspeed_query_hold = '@@XSPEED_EDGE_QUERY_HOLD@@';
418 + if ( $xspeed_qs_unpurged && is_array( $xspeed_query_hold ) && array() !== $xspeed_query_hold ) {
419 + $xspeed_query_tag = ( is_array( $xspeed_edge_headers ) && isset( $xspeed_edge_headers['Cache-Tag'] ) )
420 + ? $xspeed_edge_headers['Cache-Tag']
421 + : null;
422 + $xspeed_edge_headers = $xspeed_query_hold;
423 + if ( null !== $xspeed_query_tag ) {
424 + $xspeed_edge_headers['Cache-Tag'] = $xspeed_query_tag;
425 + }
426 + }
427 +
428 + // The one setting this file reads for itself. Everything else about
429 + // the edge answer is baked, because re-deriving it here would mean
430 + // loading options before WordPress exists. `off` is the exception
431 + // because it is the emergency switch: when something is wrong in
432 + // production at three in the morning, waiting for a re-bake is not an
433 + // answer. Any other value is a pin, and a pin is already baked in.
434 + if ( defined( 'XSPEED_EDGE_PROVIDER' ) && 'off' === strtolower( (string) XSPEED_EDGE_PROVIDER ) ) {
435 + $xspeed_edge_headers = array();
436 + }
437 +
438 + // A page with a lifetime of its own (a nonce it carries, a per-post
439 + // expiry: the sidecar `ttl`) may not be kept at the edge past it. A
440 + // lifetime in the pairs is the site's, so it is cut to what this copy
441 + // has left. Without the cut a page capped to its nonce went to the edge
442 + // with the site's lifetime and served a dead nonce until the next purge.
443 + //
444 + // The two patterns copy Cache::EDGE_LIFETIME_HEADER and
445 + // Cache::EDGE_LIFETIME_DIRECTIVE, and the cut copies
446 + // Cache::cap_edge_lifetime(): the class is not loaded yet.
447 + if ( is_array( $xspeed_edge_headers ) && isset( $xspeed_meta['ttl'] ) && (int) $xspeed_meta['ttl'] > 0 ) {
448 + $xspeed_left = max( 0, $xspeed_ttl - $xspeed_age );
449 + foreach ( $xspeed_edge_headers as $xspeed_edge_name => $xspeed_edge_value ) {
450 + if ( ! preg_match( '/(?:^|-)control$/i', (string) $xspeed_edge_name ) ) {
451 + continue;
452 + }
453 + $xspeed_capped = preg_replace_callback(
454 + '/(?<![\w-])(max-age|s-maxage)\s*=\s*"?(\d+)"?/i',
455 + static function ( array $m ) use ( $xspeed_left ): string {
456 + return $m[1] . '=' . min( (int) $m[2], $xspeed_left );
457 + },
458 + (string) $xspeed_edge_value
459 + );
460 + if ( is_string( $xspeed_capped ) ) {
461 + $xspeed_edge_headers[ $xspeed_edge_name ] = $xspeed_capped;
462 + }
463 + }
464 + }
465 +
466 + if ( is_array( $xspeed_edge_headers ) ) {
467 + foreach ( $xspeed_edge_headers as $xspeed_edge_name => $xspeed_edge_value ) {
468 + header( $xspeed_edge_name . ': ' . $xspeed_edge_value );
469 + }
470 + }
471 +
472 + // When this page's HTML was generated, so a CDN that just asked for a
473 + // purge can check whether the origin actually served something newer.
474 + // The cache file's mtime is that moment: store_static() writes the
475 + // file at the end of the render it came from.
476 + //
477 + // Emitted AFTER the baked pairs above and therefore replacing any
478 + // build stamp among them. A baked value is the time the DROP-IN was
479 + // installed, identical on every page for as long as it stays
480 + // installed, so it would answer "yes, freshly built" to every purge
481 + // check forever. Cache::edge_headers_for() already drops it from the
482 + // bake; this ordering means a drop-in installed by an older version
483 + // cannot lie either.
484 + header( 'X-XSpeed-Built: ' . (int) filemtime( $xspeed_cache_file ) );
485 +
163 486 // Record the HIT for the dashboard hit-ratio. The drop-in runs
164 487 // BEFORE WordPress loads, so it can't call Hit_Counter — instead
165 488 // it appends one line to the same hits.log the nginx static path
166 489 // uses, and Hit_Counter::collect_nginx_log_hits() drains + counts
@@ -178,10 +501,33 @@
178 501 $xspeed_hits_log = '@@XSPEED_HITS_LOG@@'; // replaced at install
179 502 if ( '@@' === substr( $xspeed_hits_log, 0, 2 ) ) {
180 503 $xspeed_hits_log = WP_CONTENT_DIR . '/uploads/xspeed/hits.log';
181 504 }
182 - // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- pre-WP drop-in; WP_Filesystem isn't loaded. One short line, append + lock; failures are non-fatal (the ratio just under-counts).
183 - @file_put_contents( $xspeed_hits_log, "hit\n", FILE_APPEND | LOCK_EX );
505 + // Don't count a bot, a scanner, a 404 or one of xSpeed's own
506 + // requests as a visitor hit. It has to be decided HERE: the log line
507 + // is just "hit" with no user agent, so Hit_Counter batch-counts these
508 + // lines blind and nothing downstream can reclassify one. The UA
509 + // pattern is baked in at install time from
510 + // Hit_Counter::excluded_ua_regex() (the drop-in runs before
511 + // WordPress, so it cannot ask). xSpeed's own requests also carry the
512 + // X-XSpeed-Self header (Self_Traffic::HEADER), which is what catches
513 + // a warmer renamed to a real browser's UA without dropping real
514 + // visitors on that browser. An empty UA counts as automated, like
515 + // is_bot_ua(''). A cached 404 is excluded on the PHP path too.
516 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- pre-WP drop-in; only matched against a baked pattern, never echoed or stored.
517 + $xspeed_hit_ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? (string) $_SERVER['HTTP_USER_AGENT'] : '';
518 + $xspeed_hit_ex = '@@XSPEED_HIT_EXCLUDE_RE@@';
519 + $xspeed_self = '' === $xspeed_hit_ua
520 + || ! empty( $_SERVER['HTTP_X_XSPEED_SELF'] )
521 + || ( isset( $xspeed_meta['status'] ) && 404 === (int) $xspeed_meta['status'] );
522 + if ( ! $xspeed_self && '@@' !== substr( $xspeed_hit_ex, 0, 2 ) && '' !== $xspeed_hit_ex ) {
523 + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a pattern this file did not compose is not worth a warning on every hit.
524 + $xspeed_self = 1 === @preg_match( '#(' . $xspeed_hit_ex . ')#i', $xspeed_hit_ua );
525 + }
526 + if ( ! $xspeed_self ) {
527 + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- pre-WP drop-in; WP_Filesystem isn't loaded. One short line, append + lock; failures are non-fatal (the ratio just under-counts).
528 + @file_put_contents( $xspeed_hits_log, "hit\n", FILE_APPEND | LOCK_EX );
529 + }
184 530
185 531 // Replay the cached response's status + content-type from .meta, so a
186 532 // cached 404 serves 404 (not a soft-404 200) and a cached feed serves
187 533 // application/rss+xml (not text/html). (FBS-82406, FBS-82407)
@@ -224,10 +570,57 @@
224 570 // lowercased + regex-matched, never echoed.
225 571 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- pre-WP drop-in; value is only lowercased + token-matched, never echoed or executed.
226 572 $xspeed_accept_enc = isset( $_SERVER['HTTP_ACCEPT_ENCODING'] ) ? strtolower( str_replace( "\0", '', (string) $_SERVER['HTTP_ACCEPT_ENCODING'] ) ) : '';
227 573 $xspeed_br_file = $xspeed_cache_file . '.br';
574 +
575 + // Existence is NOT enough: an empty or stale sibling is unservable.
576 + // Mirrors XSpeed\Cache::brotli_sibling_is_usable(); inlined because
577 + // this file runs before WordPress and cannot call it.
578 + //
579 + // Deliberately NO size-ratio floor. Brotli's ratio is unbounded on
580 + // repetitive input — a ~1 MB page of table rows compresses to about
581 + // 0.04% — so a floor rejects genuinely good siblings and silently
582 + // serves the uncompressed page.
583 + //
584 + // Truncation is instead caught exactly, from the byte count the
585 + // writer recorded in `<file>.br.size` when it published the sibling.
586 + // A stream shorter than its own declared length cannot inflate; one
587 + // that matches was published whole. Where no record exists — a
588 + // sibling written before this version, which is precisely the
589 + // already-broken file sitting on a live site right now — the checks
590 + // below still apply and the atomic writer stops new ones appearing.
591 + $xspeed_br_ok = false;
592 + if ( is_readable( $xspeed_br_file ) ) {
593 + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- pre-WP drop-in; a stat failure means "don't serve it", handled by the size checks.
594 + $xspeed_br_size = (int) @filesize( $xspeed_br_file );
595 + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- as above.
596 + $xspeed_html_size = (int) @filesize( $xspeed_cache_file );
597 + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- as above.
598 + $xspeed_br_mtime = (int) @filemtime( $xspeed_br_file );
599 +
600 + // MUST mirror XSpeed\Cache::brotli_expected_size(); 0 means "no
601 + // record", never "zero bytes".
602 + $xspeed_br_expected = 0;
603 + $xspeed_br_sidecar = $xspeed_br_file . '.size';
604 + if ( is_readable( $xspeed_br_sidecar ) ) {
605 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,WordPress.PHP.NoSilencedErrors.Discouraged -- pre-WP drop-in; an unreadable sidecar means "unknown", handled by the cast.
606 + $xspeed_br_expected = (int) trim( (string) @file_get_contents( $xspeed_br_sidecar ) );
607 + if ( $xspeed_br_expected < 0 ) {
608 + $xspeed_br_expected = 0;
609 + }
610 + }
611 +
612 + $xspeed_br_ok = $xspeed_br_size > 0
613 + && $xspeed_html_size > 0
614 + // Not stale: a sibling older than the page would serve the
615 + // previous revision under the current entry's ETag.
616 + && ( $xspeed_br_mtime <= 0 || $xspeed_mtime <= 0 || $xspeed_br_mtime >= $xspeed_mtime )
617 + // Not truncated, where the writer left a length to check.
618 + && ( $xspeed_br_expected <= 0 || $xspeed_br_size === $xspeed_br_expected );
619 + }
620 +
228 621 if ( preg_match( '/(^|[\s,])br([\s,;]|$)/', $xspeed_accept_enc )
229 - && is_readable( $xspeed_br_file ) ) {
622 + && $xspeed_br_ok ) {
230 623 header( 'Content-Encoding: br' );
231 624 header( 'Vary: Accept-Encoding', false );
232 625 header_remove( 'Content-Length' );
233 626 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- Drop-in runs before WP_Filesystem is available; readfile streams the precompressed sibling directly.