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
xspeed / includes / advanced-cache.php

advanced-cache.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.4.0, at includes/advanced-cache.php

636 lines 35.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * XSPEED_DROPIN
4 * XSPEED_DROPIN_VERSION: 15
5 * Drop-in cache loader. Serves cached HTML before WordPress fully boots.
6 *
7 * Bump XSPEED_DROPIN_VERSION whenever this file's serve logic changes so
8 * Cache::ensure_dropin_current() reinstalls it on existing sites (the
9 * "is it ours?" marker alone can't tell an old copy from a new one).
10 * v2: read .meta on the fast path — replay 404 status + feed Content-Type
11 * and honor per-content TTL (FBS-82406, FBS-82407).
12 * v3: conditional GET — emit Last-Modified + ETag, answer matching
13 * If-Modified-Since / If-None-Match with 304 (FBS-82407 #5).
14 * v4: bail when the `.maintenance-active` sentinel is present so a page
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.
71 *
72 * IMPORTANT: This file is included by wp-settings.php BEFORE
73 * wp-includes/formatting.php and wp-includes/load.php are loaded, so NO
74 * WordPress functions (sanitize_text_field, wp_unslash, is_admin,
75 * HOUR_IN_SECONDS, etc.) are available here. Use raw PHP only.
76 *
77 * @package XSpeed
78 */
79
80 if ( ! defined( 'ABSPATH' ) ) {
81 exit;
82 }
83
84 // Only handle plain GET requests.
85 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Drop-in runs before wp-includes/formatting.php loads, so wp_unslash() and sanitize_text_field() are unavailable. Value is upper-cased and matched against the literal string 'GET'; never echoed, never executed.
86 $xspeed_method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( (string) $_SERVER['REQUEST_METHOD'] ) : '';
87 if ( 'GET' !== $xspeed_method ) {
88 return;
89 }
90
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 }
138 }
139
140 // Honor explicit bypass header. xSpeed's own benchmark REST endpoint
141 // sends `X-XSpeed-Bypass: 1` so we can measure uncached TTFB for the
142 // before/after comparison on the dashboard. Harmless if a third party
143 // sends it — they just get an uncached response.
144 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Drop-in runs before WP loads. Value is only used as an isset() check + literal string comparison, never echoed.
145 if ( ! empty( $_SERVER['HTTP_X_XSPEED_BYPASS'] ) ) {
146 return;
147 }
148
149 // Maintenance / coming-soon sentinel. The Pro Maintenance-Cache module writes
150 // `.maintenance-active` next to the cache files whenever the site enters
151 // maintenance / coming-soon mode, and removes it on recovery. The write-side
152 // veto alone can't stop a page cached while the site was live from being
153 // served here (this drop-in runs before WordPress loads), so we bail out and
154 // let WordPress render the maintenance / coming-soon screen instead of serving
155 // a stale real-site page. (FBS-82409 B1)
156 if ( file_exists( WP_CONTENT_DIR . '/cache/xspeed/.maintenance-active' ) ) {
157 return;
158 }
159
160 if ( ! isset( $_SERVER['REQUEST_URI'] ) ) {
161 return;
162 }
163
164 // Raw-PHP sanitization: strip null bytes only. This value is used for
165 // substring comparisons and as input to md5() — never echoed, never
166 // executed, never written to disk as data. Magic quotes was removed in
167 // PHP 5.4 and the plugin requires PHP 7.4+, so no unslashing is needed.
168 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Drop-in runs before wp_unslash()/sanitize_text_field() are loaded; null-byte strip is the strongest sanitizer available pre-WP-bootstrap. Value is only used for substring comparison and as md5() input.
169 $xspeed_request_uri = str_replace( "\0", '', (string) $_SERVER['REQUEST_URI'] );
170
171 // Skip admin / login requests.
172 if ( false !== strpos( $xspeed_request_uri, '/wp-admin' ) || false !== strpos( $xspeed_request_uri, '/wp-login' ) ) {
173 return;
174 }
175
176 // Skip logged-in users and comment authors — never serve a cached page to
177 // someone who has a session cookie. Reading raw cookies; we only inspect
178 // names, not values.
179 if ( ! empty( $_COOKIE ) ) {
180 foreach ( $_COOKIE as $xspeed_cookie_name => $xspeed_cookie_value ) {
181 unset( $xspeed_cookie_value );
182 $xspeed_cookie_name = (string) $xspeed_cookie_name;
183 if ( 0 === strpos( $xspeed_cookie_name, 'wordpress_logged_in' )
184 || 0 === strpos( $xspeed_cookie_name, 'comment_author_' )
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 ) {
191 return;
192 }
193 }
194 }
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
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.
230 $xspeed_host = isset( $_SERVER['HTTP_HOST'] ) ? (string) $_SERVER['HTTP_HOST'] : 'default';
231 $xspeed_host = str_replace( "\0", '', $xspeed_host );
232 // Restrict host to a safe charset (letters, digits, dot, hyphen, colon for port).
233 $xspeed_host = preg_replace( '/[^a-zA-Z0-9.\-:]/', '', $xspeed_host );
234
235 $xspeed_path_only = (string) strtok( $xspeed_request_uri, '?' );
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
252 // Device bucket — MUST mirror XSpeed\Cache::cache_key() exactly, or the key
253 // the drop-in computes won't match the file Cache::store() wrote, the HIT
254 // branch below never fires, and every request falls through to a full
255 // WordPress boot (defeating the whole point of the pre-WP drop-in).
256 //
257 // Cache::cache_key() appends '|m' / '|d' when the cache module's
258 // `mobile_separate` setting is on. The drop-in can't read WP options
259 // (it runs before WordPress loads), so Cache writes a zero-byte sidecar
260 // flag — `.mobile-separate` next to the cache files — whenever that setting
261 // is on, and removes it when off (see Cache::sync_mobile_flag()). We mirror
262 // the same UA token list wp_is_mobile() uses, the same one Cache's inline
263 // fallback detector uses.
264 $xspeed_device = '';
265 if ( file_exists( WP_CONTENT_DIR . '/cache/xspeed/.mobile-separate' ) ) {
266 // Mirror core's wp_is_mobile() EXACTLY (which Cache::is_mobile_request()
267 // defers to): check the Sec-CH-UA-Mobile client hint first, then fall
268 // back to the same UA token list. Any divergence from the engine's
269 // detection re-introduces the key mismatch this whole flag exists to
270 // prevent.
271 $xspeed_is_mobile = false;
272 if ( isset( $_SERVER['HTTP_SEC_CH_UA_MOBILE'] ) ) {
273 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Drop-in runs pre-WP. Value is compared against the literal '?1', never echoed or executed.
274 $xspeed_is_mobile = ( '?1' === $_SERVER['HTTP_SEC_CH_UA_MOBILE'] );
275 } else {
276 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Drop-in runs before wp_unslash()/sanitize_text_field() load. Value is only matched against a literal token regex, never echoed or executed.
277 $xspeed_ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? (string) $_SERVER['HTTP_USER_AGENT'] : '';
278 $xspeed_is_mobile = (bool) preg_match( '/(Mobile|Android|Silk\/|Kindle|BlackBerry|Opera Mini|Opera Mobi)/i', $xspeed_ua );
279 }
280 $xspeed_device = $xspeed_is_mobile ? '|m' : '|d';
281 }
282
283 $xspeed_cache_key = md5( $xspeed_host . $xspeed_path_only . $xspeed_device );
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
338 if ( file_exists( $xspeed_cache_file ) ) {
339 // Read the .meta sidecar (status / content_type / ttl) the same way the
340 // PHP HIT path does — the drop-in serves cached feeds and 404s too, so it
341 // must replay their Content-Type / status and honor their per-content TTL.
342 // Ordinary 200 text/html pages have no .meta (the common path stays fast).
343 // (FBS-82406 soft-404, FBS-82407 feed content-type + TTL)
344 $xspeed_meta = array();
345 if ( file_exists( $xspeed_meta_file ) ) {
346 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- pre-WP drop-in; one tiny JSON sidecar.
347 $xspeed_meta_raw = file_get_contents( $xspeed_meta_file );
348 if ( false !== $xspeed_meta_raw ) {
349 $xspeed_decoded = json_decode( $xspeed_meta_raw, true );
350 if ( is_array( $xspeed_decoded ) ) {
351 $xspeed_meta = $xspeed_decoded;
352 }
353 }
354 }
355
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;
380 $xspeed_age = time() - filemtime( $xspeed_cache_file );
381 if ( $xspeed_age < $xspeed_ttl ) {
382 // PHP-served cache hit (the ~85ms fallback path). The nginx static
383 // rewrite sends "HIT (nginx)" for the fast 5-15ms path; same header,
384 // distinct value so you can tell which layer served the page.
385 header( 'X-XSpeed-Cache: HIT (php)' );
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
486 // Record the HIT for the dashboard hit-ratio. The drop-in runs
487 // BEFORE WordPress loads, so it can't call Hit_Counter — instead
488 // it appends one line to the same hits.log the nginx static path
489 // uses, and Hit_Counter::collect_nginx_log_hits() drains + counts
490 // both on the next dashboard load. Without this, every drop-in HIT
491 // was served but never counted, so the hit ratio sat at 0.
492 // Best-effort: a failed append must never break serving the page.
493 //
494 // Path is baked in at install time by Cache::install_dropin(), which
495 // replaces the @@XSPEED_HITS_LOG@@ token on the next line with the
496 // resolved absolute path (uploads/xspeed/hits.log — NOT the cache dir,
497 // which gets deleted on purge/uninstall and would take nginx down,
498 // FBS-82478). The default below is the fallback for an un-substituted
499 // drop-in (e.g. run straight from a dev source checkout); the installed
500 // copy always carries the absolute uploads path.
501 $xspeed_hits_log = '@@XSPEED_HITS_LOG@@'; // replaced at install
502 if ( '@@' === substr( $xspeed_hits_log, 0, 2 ) ) {
503 $xspeed_hits_log = WP_CONTENT_DIR . '/uploads/xspeed/hits.log';
504 }
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 }
530
531 // Replay the cached response's status + content-type from .meta, so a
532 // cached 404 serves 404 (not a soft-404 200) and a cached feed serves
533 // application/rss+xml (not text/html). (FBS-82406, FBS-82407)
534 if ( ! empty( $xspeed_meta['status'] ) && function_exists( 'http_response_code' ) ) {
535 http_response_code( (int) $xspeed_meta['status'] );
536 }
537 if ( ! empty( $xspeed_meta['content_type'] ) && is_string( $xspeed_meta['content_type'] ) ) {
538 header( 'Content-Type: ' . $xspeed_meta['content_type'] );
539 }
540
541 // Conditional GET: Last-Modified + ETag from the cache file's mtime,
542 // answer a matching If-Modified-Since / If-None-Match with 304 so
543 // aggregators skip re-downloading an unchanged cached feed/page.
544 // (FBS-82407 #5)
545 $xspeed_mtime = (int) filemtime( $xspeed_cache_file );
546 if ( $xspeed_mtime > 0 ) {
547 $xspeed_lastmod = gmdate( 'D, d M Y H:i:s', $xspeed_mtime ) . ' GMT';
548 $xspeed_etag = '"' . md5( $xspeed_cache_file . '|' . $xspeed_mtime ) . '"';
549 header( 'Last-Modified: ' . $xspeed_lastmod );
550 header( 'ETag: ' . $xspeed_etag );
551 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- pre-WP drop-in; values only compared to a server-generated etag / parsed as a date, never echoed or executed.
552 $xspeed_inm = isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) ? trim( (string) $_SERVER['HTTP_IF_NONE_MATCH'] ) : '';
553 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- as above.
554 $xspeed_ims = isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ? trim( (string) $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) : '';
555 if ( ( '' !== $xspeed_inm && false !== strpos( $xspeed_inm, $xspeed_etag ) )
556 || ( '' !== $xspeed_ims && false !== ( $xspeed_ims_ts = strtotime( $xspeed_ims ) ) && $xspeed_ims_ts >= $xspeed_mtime ) ) {
557 if ( function_exists( 'http_response_code' ) ) {
558 http_response_code( 304 );
559 }
560 exit;
561 }
562 }
563
564 // Serve the precompressed Brotli sibling when the client accepts it
565 // and the Pro Brotli module wrote <file>.br. MUST mirror
566 // XSpeed\Cache::maybe_serve_brotli() on the non-drop-in serve path —
567 // both decide on the same Accept-Encoding token match + sibling
568 // existence, so the response is identical whichever path serves.
569 // pre-WP: no sanitize_text_field()/wp_unslash(); the value is only
570 // lowercased + regex-matched, never echoed.
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.
572 $xspeed_accept_enc = isset( $_SERVER['HTTP_ACCEPT_ENCODING'] ) ? strtolower( str_replace( "\0", '', (string) $_SERVER['HTTP_ACCEPT_ENCODING'] ) ) : '';
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
621 if ( preg_match( '/(^|[\s,])br([\s,;]|$)/', $xspeed_accept_enc )
622 && $xspeed_br_ok ) {
623 header( 'Content-Encoding: br' );
624 header( 'Vary: Accept-Encoding', false );
625 header_remove( 'Content-Length' );
626 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- Drop-in runs before WP_Filesystem is available; readfile streams the precompressed sibling directly.
627 readfile( $xspeed_br_file );
628 exit;
629 }
630
631 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- Drop-in runs before WP_Filesystem is available; readfile is optimal for streaming a static cache file to the visitor.
632 readfile( $xspeed_cache_file );
633 exit;
634 }
635 }
636