PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.0
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 1.2.0 All 28 releases
xspeed / includes / advanced-cache.php

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

337 lines 18.7 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: 5
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 *
22 * IMPORTANT: This file is included by wp-settings.php BEFORE
23 * wp-includes/formatting.php and wp-includes/load.php are loaded, so NO
24 * WordPress functions (sanitize_text_field, wp_unslash, is_admin,
25 * HOUR_IN_SECONDS, etc.) are available here. Use raw PHP only.
26 *
27 * @package XSpeed
28 */
29
30 if ( ! defined( 'ABSPATH' ) ) {
31 exit;
32 }
33
34 // Only handle plain GET requests.
35 // 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.
36 $xspeed_method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( (string) $_SERVER['REQUEST_METHOD'] ) : '';
37 if ( 'GET' !== $xspeed_method ) {
38 return;
39 }
40
41 // Skip cached query-string requests (search, pagination via ?, etc.).
42 if ( ! empty( $_SERVER['QUERY_STRING'] ) ) {
43 return;
44 }
45
46 // Honor explicit bypass header. xSpeed's own benchmark REST endpoint
47 // sends `X-XSpeed-Bypass: 1` so we can measure uncached TTFB for the
48 // before/after comparison on the dashboard. Harmless if a third party
49 // sends it — they just get an uncached response.
50 // 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.
51 if ( ! empty( $_SERVER['HTTP_X_XSPEED_BYPASS'] ) ) {
52 return;
53 }
54
55 // Maintenance / coming-soon sentinel. The Pro Maintenance-Cache module writes
56 // `.maintenance-active` next to the cache files whenever the site enters
57 // maintenance / coming-soon mode, and removes it on recovery. The write-side
58 // veto alone can't stop a page cached while the site was live from being
59 // served here (this drop-in runs before WordPress loads), so we bail out and
60 // let WordPress render the maintenance / coming-soon screen instead of serving
61 // a stale real-site page. (FBS-82409 B1)
62 if ( file_exists( WP_CONTENT_DIR . '/cache/xspeed/.maintenance-active' ) ) {
63 return;
64 }
65
66 if ( ! isset( $_SERVER['REQUEST_URI'] ) ) {
67 return;
68 }
69
70 // Raw-PHP sanitization: strip null bytes only. This value is used for
71 // substring comparisons and as input to md5() — never echoed, never
72 // executed, never written to disk as data. Magic quotes was removed in
73 // PHP 5.4 and the plugin requires PHP 7.4+, so no unslashing is needed.
74 // 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.
75 $xspeed_request_uri = str_replace( "\0", '', (string) $_SERVER['REQUEST_URI'] );
76
77 // Skip admin / login requests.
78 if ( false !== strpos( $xspeed_request_uri, '/wp-admin' ) || false !== strpos( $xspeed_request_uri, '/wp-login' ) ) {
79 return;
80 }
81
82 // Skip logged-in users and comment authors — never serve a cached page to
83 // someone who has a session cookie. Reading raw cookies; we only inspect
84 // names, not values.
85 if ( ! empty( $_COOKIE ) ) {
86 foreach ( $_COOKIE as $xspeed_cookie_name => $xspeed_cookie_value ) {
87 unset( $xspeed_cookie_value );
88 $xspeed_cookie_name = (string) $xspeed_cookie_name;
89 if ( 0 === strpos( $xspeed_cookie_name, 'wordpress_logged_in' )
90 || 0 === strpos( $xspeed_cookie_name, 'comment_author_' )
91 || 0 === strpos( $xspeed_cookie_name, 'wp-postpass_' )
92 // The generic bypass cookie PHP sets whenever it decides a
93 // visitor must not be served from cache (Server_Rules::
94 // BYPASS_COOKIE). Covers repeat visitors even when the baked
95 // rules below are stale.
96 || 'wordpress_no_cache' === $xspeed_cookie_name ) {
97 return;
98 }
99 }
100 }
101
102 // The user's own excluded-cookie list, baked in at install time by
103 // Cache::install_dropin() (the token is replaced with an escaped regex
104 // built by Server_Rules). The drop-in runs before WordPress loads and so
105 // cannot read the settings itself; without this, every cart / membership
106 // / custom cookie rule applied only while a page was cold, and a warm
107 // page was served to exactly the visitors the settings excluded.
108 //
109 // An un-substituted token means the drop-in was copied straight from a
110 // source checkout — fall back to serving nothing from the fast path
111 // rather than treating the literal token as a pattern.
112 $xspeed_cookie_re = '@@XSPEED_COOKIE_RE@@';
113 if ( '@@' !== substr( $xspeed_cookie_re, 0, 2 ) && '' !== $xspeed_cookie_re && ! empty( $_COOKIE ) ) {
114 foreach ( array_keys( $_COOKIE ) as $xspeed_cookie_name ) {
115 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a malformed baked pattern must degrade to "don't serve from cache", never warn on every request.
116 if ( 1 === @preg_match( '#(' . $xspeed_cookie_re . ')#i', (string) $xspeed_cookie_name ) ) {
117 return;
118 }
119 }
120 }
121
122 // Same for the user-agent bypass list. This is the rule the bypass cookie
123 // can never cover: a bot's very first request to a warm page never
124 // reaches PHP, so there is no earlier request in which to set a cookie.
125 $xspeed_ua_re = '@@XSPEED_UA_RE@@';
126 if ( '@@' !== substr( $xspeed_ua_re, 0, 2 ) && '' !== $xspeed_ua_re ) {
127 // 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.
128 $xspeed_ua_raw = isset( $_SERVER['HTTP_USER_AGENT'] ) ? (string) $_SERVER['HTTP_USER_AGENT'] : '';
129 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- see above; degrade to bypass rather than warn.
130 if ( '' !== $xspeed_ua_raw && 1 === @preg_match( '#(' . $xspeed_ua_re . ')#i', $xspeed_ua_raw ) ) {
131 return;
132 }
133 }
134
135 // 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.
136 $xspeed_host = isset( $_SERVER['HTTP_HOST'] ) ? (string) $_SERVER['HTTP_HOST'] : 'default';
137 $xspeed_host = str_replace( "\0", '', $xspeed_host );
138 // Restrict host to a safe charset (letters, digits, dot, hyphen, colon for port).
139 $xspeed_host = preg_replace( '/[^a-zA-Z0-9.\-:]/', '', $xspeed_host );
140
141 $xspeed_path_only = strtok( $xspeed_request_uri, '?' );
142
143 // Device bucket — MUST mirror XSpeed\Cache::cache_key() exactly, or the key
144 // the drop-in computes won't match the file Cache::store() wrote, the HIT
145 // branch below never fires, and every request falls through to a full
146 // WordPress boot (defeating the whole point of the pre-WP drop-in).
147 //
148 // Cache::cache_key() appends '|m' / '|d' when the cache module's
149 // `mobile_separate` setting is on. The drop-in can't read WP options
150 // (it runs before WordPress loads), so Cache writes a zero-byte sidecar
151 // flag — `.mobile-separate` next to the cache files — whenever that setting
152 // is on, and removes it when off (see Cache::sync_mobile_flag()). We mirror
153 // the same UA token list wp_is_mobile() uses, the same one Cache's inline
154 // fallback detector uses.
155 $xspeed_device = '';
156 if ( file_exists( WP_CONTENT_DIR . '/cache/xspeed/.mobile-separate' ) ) {
157 // Mirror core's wp_is_mobile() EXACTLY (which Cache::is_mobile_request()
158 // defers to): check the Sec-CH-UA-Mobile client hint first, then fall
159 // back to the same UA token list. Any divergence from the engine's
160 // detection re-introduces the key mismatch this whole flag exists to
161 // prevent.
162 $xspeed_is_mobile = false;
163 if ( isset( $_SERVER['HTTP_SEC_CH_UA_MOBILE'] ) ) {
164 // 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.
165 $xspeed_is_mobile = ( '?1' === $_SERVER['HTTP_SEC_CH_UA_MOBILE'] );
166 } else {
167 // 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.
168 $xspeed_ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? (string) $_SERVER['HTTP_USER_AGENT'] : '';
169 $xspeed_is_mobile = (bool) preg_match( '/(Mobile|Android|Silk\/|Kindle|BlackBerry|Opera Mini|Opera Mobi)/i', $xspeed_ua );
170 }
171 $xspeed_device = $xspeed_is_mobile ? '|m' : '|d';
172 }
173
174 $xspeed_cache_key = md5( $xspeed_host . $xspeed_path_only . $xspeed_device );
175
176 // Per-site bucket. MUST mirror XSpeed\Cache::current_host_dir() exactly —
177 // same charset, same trimmed dots, same 'default' fallback, same multisite
178 // path prefix — or the drop-in looks in a directory Cache::store() never
179 // wrote to, every HIT misses, and every request falls through to a full
180 // WordPress boot.
181 //
182 // Note this is NOT $xspeed_host: the cache KEY keeps the colon of
183 // `host:port` (it only ever feeds md5()), while the DIRECTORY cannot —
184 // a colon is not portable in a path. (#6)
185 $xspeed_host_dir = $xspeed_host;
186 $xspeed_host_colon = strpos( $xspeed_host_dir, ':' );
187 if ( false !== $xspeed_host_colon ) {
188 $xspeed_host_dir = substr( $xspeed_host_dir, 0, $xspeed_host_colon );
189 }
190 $xspeed_host_dir = preg_replace( '/[^a-zA-Z0-9.\-]/', '', $xspeed_host_dir );
191 $xspeed_host_dir = preg_replace( '/\.{2,}/', '.', (string) $xspeed_host_dir );
192 $xspeed_host_dir = trim( (string) $xspeed_host_dir, '.-' );
193 if ( '' === $xspeed_host_dir ) {
194 $xspeed_host_dir = 'default';
195 }
196
197 // Subdirectory multisite: every blog shares one host, so the host alone
198 // would put them all in one bucket and they would keep purging each other.
199 // We cannot call is_multisite()/get_blog_details() here (WordPress is not
200 // loaded), so Cache::sync_site_paths() persists the network's blog paths
201 // as `<raw-path>|<segment>` lines, longest first. Prefix-match the URI.
202 $xspeed_paths_file = WP_CONTENT_DIR . '/cache/xspeed/.site-paths';
203 if ( file_exists( $xspeed_paths_file ) ) {
204 $xspeed_uri_trimmed = ltrim( (string) $xspeed_path_only, '/' );
205 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- our own sidecar; WP_Filesystem is not loaded pre-WP.
206 $xspeed_paths_raw = (string) @file_get_contents( $xspeed_paths_file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- unreadable sidecar just means "no prefix".
207 foreach ( explode( "\n", $xspeed_paths_raw ) as $xspeed_path_line ) {
208 $xspeed_sep = strpos( $xspeed_path_line, '|' );
209 if ( false === $xspeed_sep ) {
210 continue;
211 }
212 $xspeed_raw_path = substr( $xspeed_path_line, 0, $xspeed_sep );
213 $xspeed_segment = substr( $xspeed_path_line, $xspeed_sep + 1 );
214 if ( '' === $xspeed_raw_path || '' === $xspeed_segment ) {
215 continue;
216 }
217 if ( $xspeed_uri_trimmed === $xspeed_raw_path
218 || 0 === strpos( $xspeed_uri_trimmed, $xspeed_raw_path . '/' ) ) {
219 $xspeed_host_dir .= '/' . $xspeed_segment;
220 break;
221 }
222 }
223 }
224
225 $xspeed_cache_dir = WP_CONTENT_DIR . '/cache/xspeed/' . $xspeed_host_dir . '/';
226 $xspeed_cache_file = $xspeed_cache_dir . $xspeed_cache_key . '.html';
227 $xspeed_meta_file = $xspeed_cache_dir . $xspeed_cache_key . '.meta';
228
229 if ( file_exists( $xspeed_cache_file ) ) {
230 // Read the .meta sidecar (status / content_type / ttl) the same way the
231 // PHP HIT path does — the drop-in serves cached feeds and 404s too, so it
232 // must replay their Content-Type / status and honor their per-content TTL.
233 // Ordinary 200 text/html pages have no .meta (the common path stays fast).
234 // (FBS-82406 soft-404, FBS-82407 feed content-type + TTL)
235 $xspeed_meta = array();
236 if ( file_exists( $xspeed_meta_file ) ) {
237 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- pre-WP drop-in; one tiny JSON sidecar.
238 $xspeed_meta_raw = file_get_contents( $xspeed_meta_file );
239 if ( false !== $xspeed_meta_raw ) {
240 $xspeed_decoded = json_decode( $xspeed_meta_raw, true );
241 if ( is_array( $xspeed_decoded ) ) {
242 $xspeed_meta = $xspeed_decoded;
243 }
244 }
245 }
246
247 // Per-content TTL from meta (e.g. feeds) falls back to the 24h page
248 // default. HOUR_IN_SECONDS isn't defined yet (pre-WP), so use a literal.
249 $xspeed_ttl = ( isset( $xspeed_meta['ttl'] ) && (int) $xspeed_meta['ttl'] > 0 ) ? (int) $xspeed_meta['ttl'] : 86400;
250 $xspeed_age = time() - filemtime( $xspeed_cache_file );
251 if ( $xspeed_age < $xspeed_ttl ) {
252 // PHP-served cache hit (the ~85ms fallback path). The nginx static
253 // rewrite sends "HIT (nginx)" for the fast 5-15ms path; same header,
254 // distinct value so you can tell which layer served the page.
255 header( 'X-XSpeed-Cache: HIT (php)' );
256
257 // Record the HIT for the dashboard hit-ratio. The drop-in runs
258 // BEFORE WordPress loads, so it can't call Hit_Counter — instead
259 // it appends one line to the same hits.log the nginx static path
260 // uses, and Hit_Counter::collect_nginx_log_hits() drains + counts
261 // both on the next dashboard load. Without this, every drop-in HIT
262 // was served but never counted, so the hit ratio sat at 0.
263 // Best-effort: a failed append must never break serving the page.
264 //
265 // Path is baked in at install time by Cache::install_dropin(), which
266 // replaces the @@XSPEED_HITS_LOG@@ token on the next line with the
267 // resolved absolute path (uploads/xspeed/hits.log — NOT the cache dir,
268 // which gets deleted on purge/uninstall and would take nginx down,
269 // FBS-82478). The default below is the fallback for an un-substituted
270 // drop-in (e.g. run straight from a dev source checkout); the installed
271 // copy always carries the absolute uploads path.
272 $xspeed_hits_log = '@@XSPEED_HITS_LOG@@'; // replaced at install
273 if ( '@@' === substr( $xspeed_hits_log, 0, 2 ) ) {
274 $xspeed_hits_log = WP_CONTENT_DIR . '/uploads/xspeed/hits.log';
275 }
276 // 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).
277 @file_put_contents( $xspeed_hits_log, "hit\n", FILE_APPEND | LOCK_EX );
278
279 // Replay the cached response's status + content-type from .meta, so a
280 // cached 404 serves 404 (not a soft-404 200) and a cached feed serves
281 // application/rss+xml (not text/html). (FBS-82406, FBS-82407)
282 if ( ! empty( $xspeed_meta['status'] ) && function_exists( 'http_response_code' ) ) {
283 http_response_code( (int) $xspeed_meta['status'] );
284 }
285 if ( ! empty( $xspeed_meta['content_type'] ) && is_string( $xspeed_meta['content_type'] ) ) {
286 header( 'Content-Type: ' . $xspeed_meta['content_type'] );
287 }
288
289 // Conditional GET: Last-Modified + ETag from the cache file's mtime,
290 // answer a matching If-Modified-Since / If-None-Match with 304 so
291 // aggregators skip re-downloading an unchanged cached feed/page.
292 // (FBS-82407 #5)
293 $xspeed_mtime = (int) filemtime( $xspeed_cache_file );
294 if ( $xspeed_mtime > 0 ) {
295 $xspeed_lastmod = gmdate( 'D, d M Y H:i:s', $xspeed_mtime ) . ' GMT';
296 $xspeed_etag = '"' . md5( $xspeed_cache_file . '|' . $xspeed_mtime ) . '"';
297 header( 'Last-Modified: ' . $xspeed_lastmod );
298 header( 'ETag: ' . $xspeed_etag );
299 // 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.
300 $xspeed_inm = isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) ? trim( (string) $_SERVER['HTTP_IF_NONE_MATCH'] ) : '';
301 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- as above.
302 $xspeed_ims = isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ? trim( (string) $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) : '';
303 if ( ( '' !== $xspeed_inm && false !== strpos( $xspeed_inm, $xspeed_etag ) )
304 || ( '' !== $xspeed_ims && false !== ( $xspeed_ims_ts = strtotime( $xspeed_ims ) ) && $xspeed_ims_ts >= $xspeed_mtime ) ) {
305 if ( function_exists( 'http_response_code' ) ) {
306 http_response_code( 304 );
307 }
308 exit;
309 }
310 }
311
312 // Serve the precompressed Brotli sibling when the client accepts it
313 // and the Pro Brotli module wrote <file>.br. MUST mirror
314 // XSpeed\Cache::maybe_serve_brotli() on the non-drop-in serve path —
315 // both decide on the same Accept-Encoding token match + sibling
316 // existence, so the response is identical whichever path serves.
317 // pre-WP: no sanitize_text_field()/wp_unslash(); the value is only
318 // lowercased + regex-matched, never echoed.
319 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- pre-WP drop-in; value is only lowercased + token-matched, never echoed or executed.
320 $xspeed_accept_enc = isset( $_SERVER['HTTP_ACCEPT_ENCODING'] ) ? strtolower( str_replace( "\0", '', (string) $_SERVER['HTTP_ACCEPT_ENCODING'] ) ) : '';
321 $xspeed_br_file = $xspeed_cache_file . '.br';
322 if ( preg_match( '/(^|[\s,])br([\s,;]|$)/', $xspeed_accept_enc )
323 && is_readable( $xspeed_br_file ) ) {
324 header( 'Content-Encoding: br' );
325 header( 'Vary: Accept-Encoding', false );
326 header_remove( 'Content-Length' );
327 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- Drop-in runs before WP_Filesystem is available; readfile streams the precompressed sibling directly.
328 readfile( $xspeed_br_file );
329 exit;
330 }
331
332 // 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.
333 readfile( $xspeed_cache_file );
334 exit;
335 }
336 }
337