PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.3
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.3
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
← All changes | includes/advanced-cache.php +252 -11 1.0.81.3.3 View file →
@@ -1,8 +1,8 @@
1 1 <?php
2 2 /**
3 3 * XSPEED_DROPIN
4 - * XSPEED_DROPIN_VERSION: 4
4 + * XSPEED_DROPIN_VERSION: 9
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,24 @@
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).
16 32 *
17 33 * IMPORTANT: This file is included by wp-settings.php BEFORE
18 34 * wp-includes/formatting.php and wp-includes/load.php are loaded, so NO
19 35 * WordPress functions (sanitize_text_field, wp_unslash, is_admin,
@@ -32,11 +48,45 @@
32 48 if ( 'GET' !== $xspeed_method ) {
33 49 return;
34 50 }
35 51
36 -// Skip cached query-string requests (search, pagination via ?, etc.).
52 +// Query-string requests. A tracking param contributes nothing to the
53 +// response, and PHP already caches `/post?utm_source=x` under the same key
54 +// as `/post` — but this file used to bail on ANY query string, so every
55 +// visitor arriving from an email or ad campaign paid a full WordPress boot
56 +// to be handed a file that was already on disk. On a marketing site that is
57 +// most of the paid traffic taking the slowest path. (#13)
58 +//
59 +// We cannot read the option or call Glob_Matcher here (WordPress is not
60 +// loaded), so Cache::sync_query_allowlist() precompiles the user's
61 +// `ignored_query_params` into a regex next to the cache files. Every key
62 +// must match it; one that doesn't means the response could genuinely vary,
63 +// so we stand down and let PHP decide. A missing sidecar means the same —
64 +// fail safe, never guess.
37 65 if ( ! empty( $_SERVER['QUERY_STRING'] ) ) {
38 - return;
66 + $xspeed_allow_file = WP_CONTENT_DIR . '/cache/xspeed/.ignored-query-params';
67 + if ( ! is_readable( $xspeed_allow_file ) ) {
68 + return;
69 + }
70 + // 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".
71 + $xspeed_allow_re = trim( (string) @file_get_contents( $xspeed_allow_file ) );
72 + if ( '' === $xspeed_allow_re ) {
73 + return;
74 + }
75 +
76 + // 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.
77 + parse_str( str_replace( "\0", '', (string) $_SERVER['QUERY_STRING'] ), $xspeed_qs_params );
78 + if ( empty( $xspeed_qs_params ) ) {
79 + return;
80 + }
81 + foreach ( array_keys( $xspeed_qs_params ) as $xspeed_qs_key ) {
82 + // Anchored: a param named `referrer` must not be waved through by
83 + // a `ref` entry. Mirrors Glob_Matcher's full-string semantics.
84 + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a malformed baked pattern degrades to "let PHP handle it", never a warning per request.
85 + if ( 1 !== @preg_match( '#^' . $xspeed_allow_re . '$#', (string) $xspeed_qs_key ) ) {
86 + return;
87 + }
88 + }
39 89 }
40 90
41 91 // Honor explicit bypass header. xSpeed's own benchmark REST endpoint
42 92 // sends `X-XSpeed-Bypass: 1` so we can measure uncached TTFB for the
@@ -82,14 +132,52 @@
82 132 unset( $xspeed_cookie_value );
83 133 $xspeed_cookie_name = (string) $xspeed_cookie_name;
84 134 if ( 0 === strpos( $xspeed_cookie_name, 'wordpress_logged_in' )
85 135 || 0 === strpos( $xspeed_cookie_name, 'comment_author_' )
86 - || 0 === strpos( $xspeed_cookie_name, 'wp-postpass_' ) ) {
136 + || 0 === strpos( $xspeed_cookie_name, 'wp-postpass_' )
137 + // The generic bypass cookie PHP sets whenever it decides a
138 + // visitor must not be served from cache (Server_Rules::
139 + // BYPASS_COOKIE). Covers repeat visitors even when the baked
140 + // rules below are stale.
141 + || 'wordpress_no_cache' === $xspeed_cookie_name ) {
87 142 return;
88 143 }
89 144 }
90 145 }
91 146
147 +// The user's own excluded-cookie list, baked in at install time by
148 +// Cache::install_dropin() (the token is replaced with an escaped regex
149 +// built by Server_Rules). The drop-in runs before WordPress loads and so
150 +// cannot read the settings itself; without this, every cart / membership
151 +// / custom cookie rule applied only while a page was cold, and a warm
152 +// page was served to exactly the visitors the settings excluded.
153 +//
154 +// An un-substituted token means the drop-in was copied straight from a
155 +// source checkout — fall back to serving nothing from the fast path
156 +// rather than treating the literal token as a pattern.
157 +$xspeed_cookie_re = '@@XSPEED_COOKIE_RE@@';
158 +if ( '@@' !== substr( $xspeed_cookie_re, 0, 2 ) && '' !== $xspeed_cookie_re && ! empty( $_COOKIE ) ) {
159 + foreach ( array_keys( $_COOKIE ) as $xspeed_cookie_name ) {
160 + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a malformed baked pattern must degrade to "don't serve from cache", never warn on every request.
161 + if ( 1 === @preg_match( '#(' . $xspeed_cookie_re . ')#i', (string) $xspeed_cookie_name ) ) {
162 + return;
163 + }
164 + }
165 +}
166 +
167 +// Same for the user-agent bypass list. This is the rule the bypass cookie
168 +// can never cover: a bot's very first request to a warm page never
169 +// reaches PHP, so there is no earlier request in which to set a cookie.
170 +$xspeed_ua_re = '@@XSPEED_UA_RE@@';
171 +if ( '@@' !== substr( $xspeed_ua_re, 0, 2 ) && '' !== $xspeed_ua_re ) {
172 + // 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.
173 + $xspeed_ua_raw = isset( $_SERVER['HTTP_USER_AGENT'] ) ? (string) $_SERVER['HTTP_USER_AGENT'] : '';
174 + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- see above; degrade to bypass rather than warn.
175 + if ( '' !== $xspeed_ua_raw && 1 === @preg_match( '#(' . $xspeed_ua_re . ')#i', $xspeed_ua_raw ) ) {
176 + return;
177 + }
178 +}
179 +
92 180 // 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 181 $xspeed_host = isset( $_SERVER['HTTP_HOST'] ) ? (string) $_SERVER['HTTP_HOST'] : 'default';
94 182 $xspeed_host = str_replace( "\0", '', $xspeed_host );
95 183 // Restrict host to a safe charset (letters, digits, dot, hyphen, colon for port).
@@ -127,12 +215,63 @@
127 215 }
128 216 $xspeed_device = $xspeed_is_mobile ? '|m' : '|d';
129 217 }
130 218
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';
219 +$xspeed_cache_key = md5( $xspeed_host . $xspeed_path_only . $xspeed_device );
134 220
221 +// Per-site bucket. MUST mirror XSpeed\Cache::current_host_dir() exactly —
222 +// same charset, same trimmed dots, same 'default' fallback, same multisite
223 +// path prefix — or the drop-in looks in a directory Cache::store() never
224 +// wrote to, every HIT misses, and every request falls through to a full
225 +// WordPress boot.
226 +//
227 +// Note this is NOT $xspeed_host: the cache KEY keeps the colon of
228 +// `host:port` (it only ever feeds md5()), while the DIRECTORY cannot —
229 +// a colon is not portable in a path. (#6)
230 +$xspeed_host_dir = $xspeed_host;
231 +$xspeed_host_colon = strpos( $xspeed_host_dir, ':' );
232 +if ( false !== $xspeed_host_colon ) {
233 + $xspeed_host_dir = substr( $xspeed_host_dir, 0, $xspeed_host_colon );
234 +}
235 +$xspeed_host_dir = preg_replace( '/[^a-zA-Z0-9.\-]/', '', $xspeed_host_dir );
236 +$xspeed_host_dir = preg_replace( '/\.{2,}/', '.', (string) $xspeed_host_dir );
237 +$xspeed_host_dir = trim( (string) $xspeed_host_dir, '.-' );
238 +if ( '' === $xspeed_host_dir ) {
239 + $xspeed_host_dir = 'default';
240 +}
241 +
242 +// Subdirectory multisite: every blog shares one host, so the host alone
243 +// would put them all in one bucket and they would keep purging each other.
244 +// We cannot call is_multisite()/get_blog_details() here (WordPress is not
245 +// loaded), so Cache::sync_site_paths() persists the network's blog paths
246 +// as `<raw-path>|<segment>` lines, longest first. Prefix-match the URI.
247 +$xspeed_paths_file = WP_CONTENT_DIR . '/cache/xspeed/.site-paths';
248 +if ( file_exists( $xspeed_paths_file ) ) {
249 + $xspeed_uri_trimmed = ltrim( (string) $xspeed_path_only, '/' );
250 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- our own sidecar; WP_Filesystem is not loaded pre-WP.
251 + $xspeed_paths_raw = (string) @file_get_contents( $xspeed_paths_file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- unreadable sidecar just means "no prefix".
252 + foreach ( explode( "\n", $xspeed_paths_raw ) as $xspeed_path_line ) {
253 + $xspeed_sep = strpos( $xspeed_path_line, '|' );
254 + if ( false === $xspeed_sep ) {
255 + continue;
256 + }
257 + $xspeed_raw_path = substr( $xspeed_path_line, 0, $xspeed_sep );
258 + $xspeed_segment = substr( $xspeed_path_line, $xspeed_sep + 1 );
259 + if ( '' === $xspeed_raw_path || '' === $xspeed_segment ) {
260 + continue;
261 + }
262 + if ( $xspeed_uri_trimmed === $xspeed_raw_path
263 + || 0 === strpos( $xspeed_uri_trimmed, $xspeed_raw_path . '/' ) ) {
264 + $xspeed_host_dir .= '/' . $xspeed_segment;
265 + break;
266 + }
267 + }
268 +}
269 +
270 +$xspeed_cache_dir = WP_CONTENT_DIR . '/cache/xspeed/' . $xspeed_host_dir . '/';
271 +$xspeed_cache_file = $xspeed_cache_dir . $xspeed_cache_key . '.html';
272 +$xspeed_meta_file = $xspeed_cache_dir . $xspeed_cache_key . '.meta';
273 +
135 274 if ( file_exists( $xspeed_cache_file ) ) {
136 275 // Read the .meta sidecar (status / content_type / ttl) the same way the
137 276 // PHP HIT path does — the drop-in serves cached feeds and 404s too, so it
138 277 // must replay their Content-Type / status and honor their per-content TTL.
@@ -149,11 +288,32 @@
149 288 }
150 289 }
151 290 }
152 291
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;
292 + // Per-content TTL from meta (e.g. feeds) falls back to the site's
293 + // configured cache_expiry, baked in at install time by
294 + // Cache::install_dropin() and re-baked on every settings save. The
295 + // drop-in runs before WordPress loads and so cannot read the option
296 + // itself; without this it applied a hardcoded 24h to every ordinary
297 + // page — write_meta() only writes a `ttl` sidecar when the value differs
298 + // from the page default, so ordinary pages carry no sidecar at all.
299 + // That served stale content under Conservative (12h) and refused the
300 + // fast path for 6 of 7 days under Aggressive (168h). (#240)
301 + //
302 + // An un-substituted token means the drop-in was copied straight from a
303 + // source checkout — fall back to the historical 24h literal rather than
304 + // treating the token as a number. HOUR_IN_SECONDS isn't defined yet.
305 + $xspeed_default_ttl = '@@XSPEED_DEFAULT_TTL@@';
306 + $xspeed_unbaked = ( '@@' === substr( $xspeed_default_ttl, 0, 2 ) || (int) $xspeed_default_ttl < 1 );
307 + $xspeed_default_ttl = $xspeed_unbaked ? 86400 : (int) $xspeed_default_ttl;
308 + if ( $xspeed_unbaked ) {
309 + // Make the un-substituted state observable. Serving the 24h literal
310 + // silently is exactly how the original bug stayed invisible; a site
311 + // on this path is enforcing a lifetime nobody configured.
312 + header( 'X-XSpeed-Cache-TTL: default (unbaked)' );
313 + }
314 +
315 + $xspeed_ttl = ( isset( $xspeed_meta['ttl'] ) && (int) $xspeed_meta['ttl'] > 0 ) ? (int) $xspeed_meta['ttl'] : $xspeed_default_ttl;
156 316 $xspeed_age = time() - filemtime( $xspeed_cache_file );
157 317 if ( $xspeed_age < $xspeed_ttl ) {
158 318 // PHP-served cache hit (the ~85ms fallback path). The nginx static
159 319 // rewrite sends "HIT (nginx)" for the fast 5-15ms path; same header,
@@ -159,8 +319,42 @@
159 319 // rewrite sends "HIT (nginx)" for the fast 5-15ms path; same header,
160 320 // distinct value so you can tell which layer served the page.
161 321 header( 'X-XSpeed-Cache: HIT (php)' );
162 322
323 + // Edge/CDN headers decided by Cache::edge_headers_for(). No filter
324 + // can run here — plugins are not loaded — so Cache::install_dropin()
325 + // bakes the resolved pairs into the literal below and re-bakes them
326 + // on every cache settings save.
327 + //
328 + // An un-substituted placeholder means this file was copied straight
329 + // from a source checkout: it stays a string, is_array() rejects it,
330 + // and the HIT is served with no edge headers rather than a fatal.
331 + $xspeed_edge_headers = '@@XSPEED_EDGE_HEADERS@@';
332 +
333 + // A page whose answer differs from the site-wide one carries its own
334 + // pairs in the sidecar. It REPLACES the baked set rather than adding
335 + // to it: the two describe the same response, and merging would leave
336 + // the baked lifetime in place beside the hold meant to overrule it.
337 + if ( isset( $xspeed_meta['edge_headers'] ) && is_array( $xspeed_meta['edge_headers'] ) ) {
338 + $xspeed_edge_headers = $xspeed_meta['edge_headers'];
339 + }
340 +
341 + // The one setting this file reads for itself. Everything else about
342 + // the edge answer is baked, because re-deriving it here would mean
343 + // loading options before WordPress exists. `off` is the exception
344 + // because it is the emergency switch: when something is wrong in
345 + // production at three in the morning, waiting for a re-bake is not an
346 + // answer. Any other value is a pin, and a pin is already baked in.
347 + if ( defined( 'XSPEED_EDGE_PROVIDER' ) && 'off' === strtolower( (string) XSPEED_EDGE_PROVIDER ) ) {
348 + $xspeed_edge_headers = array();
349 + }
350 +
351 + if ( is_array( $xspeed_edge_headers ) ) {
352 + foreach ( $xspeed_edge_headers as $xspeed_edge_name => $xspeed_edge_value ) {
353 + header( $xspeed_edge_name . ': ' . $xspeed_edge_value );
354 + }
355 + }
356 +
163 357 // Record the HIT for the dashboard hit-ratio. The drop-in runs
164 358 // BEFORE WordPress loads, so it can't call Hit_Counter — instead
165 359 // it appends one line to the same hits.log the nginx static path
166 360 // uses, and Hit_Counter::collect_nginx_log_hits() drains + counts
@@ -224,10 +418,57 @@
224 418 // lowercased + regex-matched, never echoed.
225 419 // 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 420 $xspeed_accept_enc = isset( $_SERVER['HTTP_ACCEPT_ENCODING'] ) ? strtolower( str_replace( "\0", '', (string) $_SERVER['HTTP_ACCEPT_ENCODING'] ) ) : '';
227 421 $xspeed_br_file = $xspeed_cache_file . '.br';
422 +
423 + // Existence is NOT enough: an empty or stale sibling is unservable.
424 + // Mirrors XSpeed\Cache::brotli_sibling_is_usable(); inlined because
425 + // this file runs before WordPress and cannot call it.
426 + //
427 + // Deliberately NO size-ratio floor. Brotli's ratio is unbounded on
428 + // repetitive input — a ~1 MB page of table rows compresses to about
429 + // 0.04% — so a floor rejects genuinely good siblings and silently
430 + // serves the uncompressed page.
431 + //
432 + // Truncation is instead caught exactly, from the byte count the
433 + // writer recorded in `<file>.br.size` when it published the sibling.
434 + // A stream shorter than its own declared length cannot inflate; one
435 + // that matches was published whole. Where no record exists — a
436 + // sibling written before this version, which is precisely the
437 + // already-broken file sitting on a live site right now — the checks
438 + // below still apply and the atomic writer stops new ones appearing.
439 + $xspeed_br_ok = false;
440 + if ( is_readable( $xspeed_br_file ) ) {
441 + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- pre-WP drop-in; a stat failure means "don't serve it", handled by the size checks.
442 + $xspeed_br_size = (int) @filesize( $xspeed_br_file );
443 + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- as above.
444 + $xspeed_html_size = (int) @filesize( $xspeed_cache_file );
445 + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- as above.
446 + $xspeed_br_mtime = (int) @filemtime( $xspeed_br_file );
447 +
448 + // MUST mirror XSpeed\Cache::brotli_expected_size(); 0 means "no
449 + // record", never "zero bytes".
450 + $xspeed_br_expected = 0;
451 + $xspeed_br_sidecar = $xspeed_br_file . '.size';
452 + if ( is_readable( $xspeed_br_sidecar ) ) {
453 + // 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.
454 + $xspeed_br_expected = (int) trim( (string) @file_get_contents( $xspeed_br_sidecar ) );
455 + if ( $xspeed_br_expected < 0 ) {
456 + $xspeed_br_expected = 0;
457 + }
458 + }
459 +
460 + $xspeed_br_ok = $xspeed_br_size > 0
461 + && $xspeed_html_size > 0
462 + // Not stale: a sibling older than the page would serve the
463 + // previous revision under the current entry's ETag.
464 + && ( $xspeed_br_mtime <= 0 || $xspeed_mtime <= 0 || $xspeed_br_mtime >= $xspeed_mtime )
465 + // Not truncated, where the writer left a length to check.
466 + && ( $xspeed_br_expected <= 0 || $xspeed_br_size === $xspeed_br_expected );
467 + }
468 +
228 469 if ( preg_match( '/(^|[\s,])br([\s,;]|$)/', $xspeed_accept_enc )
229 - && is_readable( $xspeed_br_file ) ) {
470 + && $xspeed_br_ok ) {
230 471 header( 'Content-Encoding: br' );
231 472 header( 'Vary: Accept-Encoding', false );
232 473 header_remove( 'Content-Length' );
233 474 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- Drop-in runs before WP_Filesystem is available; readfile streams the precompressed sibling directly.