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

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

514 lines 28.9 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: 10
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: carry the baked edge-header answer, so a hold set for a page reaches
33 * the paths that run without PHP.
34 * v10: keep bots, scanners, cached 404s and xSpeed's own requests (by UA
35 * or the X-XSpeed-Self header) out of hits.log. Without
36 * the bump an existing install keeps writing every crawler HIT into the
37 * ratio while its misses are excluded, which reads MORE optimistic than
38 * having no exclusion at all.
39 *
40 * IMPORTANT: This file is included by wp-settings.php BEFORE
41 * wp-includes/formatting.php and wp-includes/load.php are loaded, so NO
42 * WordPress functions (sanitize_text_field, wp_unslash, is_admin,
43 * HOUR_IN_SECONDS, etc.) are available here. Use raw PHP only.
44 *
45 * @package XSpeed
46 */
47
48 if ( ! defined( 'ABSPATH' ) ) {
49 exit;
50 }
51
52 // Only handle plain GET requests.
53 // 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.
54 $xspeed_method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( (string) $_SERVER['REQUEST_METHOD'] ) : '';
55 if ( 'GET' !== $xspeed_method ) {
56 return;
57 }
58
59 // Query-string requests. A tracking param contributes nothing to the
60 // response, and PHP already caches `/post?utm_source=x` under the same key
61 // as `/post` — but this file used to bail on ANY query string, so every
62 // visitor arriving from an email or ad campaign paid a full WordPress boot
63 // to be handed a file that was already on disk. On a marketing site that is
64 // most of the paid traffic taking the slowest path. (#13)
65 //
66 // We cannot read the option or call Glob_Matcher here (WordPress is not
67 // loaded), so Cache::sync_query_allowlist() precompiles the user's
68 // `ignored_query_params` into a regex next to the cache files. Every key
69 // must match it; one that doesn't means the response could genuinely vary,
70 // so we stand down and let PHP decide. A missing sidecar means the same —
71 // fail safe, never guess.
72 if ( ! empty( $_SERVER['QUERY_STRING'] ) ) {
73 $xspeed_allow_file = WP_CONTENT_DIR . '/cache/xspeed/.ignored-query-params';
74 if ( ! is_readable( $xspeed_allow_file ) ) {
75 return;
76 }
77 // 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".
78 $xspeed_allow_re = trim( (string) @file_get_contents( $xspeed_allow_file ) );
79 if ( '' === $xspeed_allow_re ) {
80 return;
81 }
82
83 // 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.
84 parse_str( str_replace( "\0", '', (string) $_SERVER['QUERY_STRING'] ), $xspeed_qs_params );
85 if ( empty( $xspeed_qs_params ) ) {
86 return;
87 }
88 foreach ( array_keys( $xspeed_qs_params ) as $xspeed_qs_key ) {
89 // Anchored: a param named `referrer` must not be waved through by
90 // a `ref` entry. Mirrors Glob_Matcher's full-string semantics.
91 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a malformed baked pattern degrades to "let PHP handle it", never a warning per request.
92 if ( 1 !== @preg_match( '#^' . $xspeed_allow_re . '$#', (string) $xspeed_qs_key ) ) {
93 return;
94 }
95 }
96 }
97
98 // Honor explicit bypass header. xSpeed's own benchmark REST endpoint
99 // sends `X-XSpeed-Bypass: 1` so we can measure uncached TTFB for the
100 // before/after comparison on the dashboard. Harmless if a third party
101 // sends it — they just get an uncached response.
102 // 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.
103 if ( ! empty( $_SERVER['HTTP_X_XSPEED_BYPASS'] ) ) {
104 return;
105 }
106
107 // Maintenance / coming-soon sentinel. The Pro Maintenance-Cache module writes
108 // `.maintenance-active` next to the cache files whenever the site enters
109 // maintenance / coming-soon mode, and removes it on recovery. The write-side
110 // veto alone can't stop a page cached while the site was live from being
111 // served here (this drop-in runs before WordPress loads), so we bail out and
112 // let WordPress render the maintenance / coming-soon screen instead of serving
113 // a stale real-site page. (FBS-82409 B1)
114 if ( file_exists( WP_CONTENT_DIR . '/cache/xspeed/.maintenance-active' ) ) {
115 return;
116 }
117
118 if ( ! isset( $_SERVER['REQUEST_URI'] ) ) {
119 return;
120 }
121
122 // Raw-PHP sanitization: strip null bytes only. This value is used for
123 // substring comparisons and as input to md5() — never echoed, never
124 // executed, never written to disk as data. Magic quotes was removed in
125 // PHP 5.4 and the plugin requires PHP 7.4+, so no unslashing is needed.
126 // 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.
127 $xspeed_request_uri = str_replace( "\0", '', (string) $_SERVER['REQUEST_URI'] );
128
129 // Skip admin / login requests.
130 if ( false !== strpos( $xspeed_request_uri, '/wp-admin' ) || false !== strpos( $xspeed_request_uri, '/wp-login' ) ) {
131 return;
132 }
133
134 // Skip logged-in users and comment authors — never serve a cached page to
135 // someone who has a session cookie. Reading raw cookies; we only inspect
136 // names, not values.
137 if ( ! empty( $_COOKIE ) ) {
138 foreach ( $_COOKIE as $xspeed_cookie_name => $xspeed_cookie_value ) {
139 unset( $xspeed_cookie_value );
140 $xspeed_cookie_name = (string) $xspeed_cookie_name;
141 if ( 0 === strpos( $xspeed_cookie_name, 'wordpress_logged_in' )
142 || 0 === strpos( $xspeed_cookie_name, 'comment_author_' )
143 || 0 === strpos( $xspeed_cookie_name, 'wp-postpass_' )
144 // The generic bypass cookie PHP sets whenever it decides a
145 // visitor must not be served from cache (Server_Rules::
146 // BYPASS_COOKIE). Covers repeat visitors even when the baked
147 // rules below are stale.
148 || 'wordpress_no_cache' === $xspeed_cookie_name ) {
149 return;
150 }
151 }
152 }
153
154 // The user's own excluded-cookie list, baked in at install time by
155 // Cache::install_dropin() (the token is replaced with an escaped regex
156 // built by Server_Rules). The drop-in runs before WordPress loads and so
157 // cannot read the settings itself; without this, every cart / membership
158 // / custom cookie rule applied only while a page was cold, and a warm
159 // page was served to exactly the visitors the settings excluded.
160 //
161 // An un-substituted token means the drop-in was copied straight from a
162 // source checkout — fall back to serving nothing from the fast path
163 // rather than treating the literal token as a pattern.
164 $xspeed_cookie_re = '@@XSPEED_COOKIE_RE@@';
165 if ( '@@' !== substr( $xspeed_cookie_re, 0, 2 ) && '' !== $xspeed_cookie_re && ! empty( $_COOKIE ) ) {
166 foreach ( array_keys( $_COOKIE ) as $xspeed_cookie_name ) {
167 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a malformed baked pattern must degrade to "don't serve from cache", never warn on every request.
168 if ( 1 === @preg_match( '#(' . $xspeed_cookie_re . ')#i', (string) $xspeed_cookie_name ) ) {
169 return;
170 }
171 }
172 }
173
174 // Same for the user-agent bypass list. This is the rule the bypass cookie
175 // can never cover: a bot's very first request to a warm page never
176 // reaches PHP, so there is no earlier request in which to set a cookie.
177 $xspeed_ua_re = '@@XSPEED_UA_RE@@';
178 if ( '@@' !== substr( $xspeed_ua_re, 0, 2 ) && '' !== $xspeed_ua_re ) {
179 // 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.
180 $xspeed_ua_raw = isset( $_SERVER['HTTP_USER_AGENT'] ) ? (string) $_SERVER['HTTP_USER_AGENT'] : '';
181 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- see above; degrade to bypass rather than warn.
182 if ( '' !== $xspeed_ua_raw && 1 === @preg_match( '#(' . $xspeed_ua_re . ')#i', $xspeed_ua_raw ) ) {
183 return;
184 }
185 }
186
187 // 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.
188 $xspeed_host = isset( $_SERVER['HTTP_HOST'] ) ? (string) $_SERVER['HTTP_HOST'] : 'default';
189 $xspeed_host = str_replace( "\0", '', $xspeed_host );
190 // Restrict host to a safe charset (letters, digits, dot, hyphen, colon for port).
191 $xspeed_host = preg_replace( '/[^a-zA-Z0-9.\-:]/', '', $xspeed_host );
192
193 $xspeed_path_only = strtok( $xspeed_request_uri, '?' );
194
195 // Device bucket — MUST mirror XSpeed\Cache::cache_key() exactly, or the key
196 // the drop-in computes won't match the file Cache::store() wrote, the HIT
197 // branch below never fires, and every request falls through to a full
198 // WordPress boot (defeating the whole point of the pre-WP drop-in).
199 //
200 // Cache::cache_key() appends '|m' / '|d' when the cache module's
201 // `mobile_separate` setting is on. The drop-in can't read WP options
202 // (it runs before WordPress loads), so Cache writes a zero-byte sidecar
203 // flag — `.mobile-separate` next to the cache files — whenever that setting
204 // is on, and removes it when off (see Cache::sync_mobile_flag()). We mirror
205 // the same UA token list wp_is_mobile() uses, the same one Cache's inline
206 // fallback detector uses.
207 $xspeed_device = '';
208 if ( file_exists( WP_CONTENT_DIR . '/cache/xspeed/.mobile-separate' ) ) {
209 // Mirror core's wp_is_mobile() EXACTLY (which Cache::is_mobile_request()
210 // defers to): check the Sec-CH-UA-Mobile client hint first, then fall
211 // back to the same UA token list. Any divergence from the engine's
212 // detection re-introduces the key mismatch this whole flag exists to
213 // prevent.
214 $xspeed_is_mobile = false;
215 if ( isset( $_SERVER['HTTP_SEC_CH_UA_MOBILE'] ) ) {
216 // 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.
217 $xspeed_is_mobile = ( '?1' === $_SERVER['HTTP_SEC_CH_UA_MOBILE'] );
218 } else {
219 // 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.
220 $xspeed_ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? (string) $_SERVER['HTTP_USER_AGENT'] : '';
221 $xspeed_is_mobile = (bool) preg_match( '/(Mobile|Android|Silk\/|Kindle|BlackBerry|Opera Mini|Opera Mobi)/i', $xspeed_ua );
222 }
223 $xspeed_device = $xspeed_is_mobile ? '|m' : '|d';
224 }
225
226 $xspeed_cache_key = md5( $xspeed_host . $xspeed_path_only . $xspeed_device );
227
228 // Per-site bucket. MUST mirror XSpeed\Cache::current_host_dir() exactly —
229 // same charset, same trimmed dots, same 'default' fallback, same multisite
230 // path prefix — or the drop-in looks in a directory Cache::store() never
231 // wrote to, every HIT misses, and every request falls through to a full
232 // WordPress boot.
233 //
234 // Note this is NOT $xspeed_host: the cache KEY keeps the colon of
235 // `host:port` (it only ever feeds md5()), while the DIRECTORY cannot —
236 // a colon is not portable in a path. (#6)
237 $xspeed_host_dir = $xspeed_host;
238 $xspeed_host_colon = strpos( $xspeed_host_dir, ':' );
239 if ( false !== $xspeed_host_colon ) {
240 $xspeed_host_dir = substr( $xspeed_host_dir, 0, $xspeed_host_colon );
241 }
242 $xspeed_host_dir = preg_replace( '/[^a-zA-Z0-9.\-]/', '', $xspeed_host_dir );
243 $xspeed_host_dir = preg_replace( '/\.{2,}/', '.', (string) $xspeed_host_dir );
244 $xspeed_host_dir = trim( (string) $xspeed_host_dir, '.-' );
245 if ( '' === $xspeed_host_dir ) {
246 $xspeed_host_dir = 'default';
247 }
248
249 // Subdirectory multisite: every blog shares one host, so the host alone
250 // would put them all in one bucket and they would keep purging each other.
251 // We cannot call is_multisite()/get_blog_details() here (WordPress is not
252 // loaded), so Cache::sync_site_paths() persists the network's blog paths
253 // as `<raw-path>|<segment>` lines, longest first. Prefix-match the URI.
254 $xspeed_paths_file = WP_CONTENT_DIR . '/cache/xspeed/.site-paths';
255 if ( file_exists( $xspeed_paths_file ) ) {
256 $xspeed_uri_trimmed = ltrim( (string) $xspeed_path_only, '/' );
257 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- our own sidecar; WP_Filesystem is not loaded pre-WP.
258 $xspeed_paths_raw = (string) @file_get_contents( $xspeed_paths_file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- unreadable sidecar just means "no prefix".
259 foreach ( explode( "\n", $xspeed_paths_raw ) as $xspeed_path_line ) {
260 $xspeed_sep = strpos( $xspeed_path_line, '|' );
261 if ( false === $xspeed_sep ) {
262 continue;
263 }
264 $xspeed_raw_path = substr( $xspeed_path_line, 0, $xspeed_sep );
265 $xspeed_segment = substr( $xspeed_path_line, $xspeed_sep + 1 );
266 if ( '' === $xspeed_raw_path || '' === $xspeed_segment ) {
267 continue;
268 }
269 if ( $xspeed_uri_trimmed === $xspeed_raw_path
270 || 0 === strpos( $xspeed_uri_trimmed, $xspeed_raw_path . '/' ) ) {
271 $xspeed_host_dir .= '/' . $xspeed_segment;
272 break;
273 }
274 }
275 }
276
277 $xspeed_cache_dir = WP_CONTENT_DIR . '/cache/xspeed/' . $xspeed_host_dir . '/';
278 $xspeed_cache_file = $xspeed_cache_dir . $xspeed_cache_key . '.html';
279 $xspeed_meta_file = $xspeed_cache_dir . $xspeed_cache_key . '.meta';
280
281 if ( file_exists( $xspeed_cache_file ) ) {
282 // Read the .meta sidecar (status / content_type / ttl) the same way the
283 // PHP HIT path does — the drop-in serves cached feeds and 404s too, so it
284 // must replay their Content-Type / status and honor their per-content TTL.
285 // Ordinary 200 text/html pages have no .meta (the common path stays fast).
286 // (FBS-82406 soft-404, FBS-82407 feed content-type + TTL)
287 $xspeed_meta = array();
288 if ( file_exists( $xspeed_meta_file ) ) {
289 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- pre-WP drop-in; one tiny JSON sidecar.
290 $xspeed_meta_raw = file_get_contents( $xspeed_meta_file );
291 if ( false !== $xspeed_meta_raw ) {
292 $xspeed_decoded = json_decode( $xspeed_meta_raw, true );
293 if ( is_array( $xspeed_decoded ) ) {
294 $xspeed_meta = $xspeed_decoded;
295 }
296 }
297 }
298
299 // Per-content TTL from meta (e.g. feeds) falls back to the site's
300 // configured cache_expiry, baked in at install time by
301 // Cache::install_dropin() and re-baked on every settings save. The
302 // drop-in runs before WordPress loads and so cannot read the option
303 // itself; without this it applied a hardcoded 24h to every ordinary
304 // page — write_meta() only writes a `ttl` sidecar when the value differs
305 // from the page default, so ordinary pages carry no sidecar at all.
306 // That served stale content under Conservative (12h) and refused the
307 // fast path for 6 of 7 days under Aggressive (168h). (#240)
308 //
309 // An un-substituted token means the drop-in was copied straight from a
310 // source checkout — fall back to the historical 24h literal rather than
311 // treating the token as a number. HOUR_IN_SECONDS isn't defined yet.
312 $xspeed_default_ttl = '@@XSPEED_DEFAULT_TTL@@';
313 $xspeed_unbaked = ( '@@' === substr( $xspeed_default_ttl, 0, 2 ) || (int) $xspeed_default_ttl < 1 );
314 $xspeed_default_ttl = $xspeed_unbaked ? 86400 : (int) $xspeed_default_ttl;
315 if ( $xspeed_unbaked ) {
316 // Make the un-substituted state observable. Serving the 24h literal
317 // silently is exactly how the original bug stayed invisible; a site
318 // on this path is enforcing a lifetime nobody configured.
319 header( 'X-XSpeed-Cache-TTL: default (unbaked)' );
320 }
321
322 $xspeed_ttl = ( isset( $xspeed_meta['ttl'] ) && (int) $xspeed_meta['ttl'] > 0 ) ? (int) $xspeed_meta['ttl'] : $xspeed_default_ttl;
323 $xspeed_age = time() - filemtime( $xspeed_cache_file );
324 if ( $xspeed_age < $xspeed_ttl ) {
325 // PHP-served cache hit (the ~85ms fallback path). The nginx static
326 // rewrite sends "HIT (nginx)" for the fast 5-15ms path; same header,
327 // distinct value so you can tell which layer served the page.
328 header( 'X-XSpeed-Cache: HIT (php)' );
329
330 // Edge/CDN headers decided by Cache::edge_headers_for(). No filter
331 // can run here — plugins are not loaded — so Cache::install_dropin()
332 // bakes the resolved pairs into the literal below and re-bakes them
333 // on every cache settings save.
334 //
335 // An un-substituted placeholder means this file was copied straight
336 // from a source checkout: it stays a string, is_array() rejects it,
337 // and the HIT is served with no edge headers rather than a fatal.
338 $xspeed_edge_headers = '@@XSPEED_EDGE_HEADERS@@';
339
340 // A page whose answer differs from the site-wide one carries its own
341 // pairs in the sidecar. It REPLACES the baked set rather than adding
342 // to it: the two describe the same response, and merging would leave
343 // the baked lifetime in place beside the hold meant to overrule it.
344 if ( isset( $xspeed_meta['edge_headers'] ) && is_array( $xspeed_meta['edge_headers'] ) ) {
345 $xspeed_edge_headers = $xspeed_meta['edge_headers'];
346 }
347
348 // The one setting this file reads for itself. Everything else about
349 // the edge answer is baked, because re-deriving it here would mean
350 // loading options before WordPress exists. `off` is the exception
351 // because it is the emergency switch: when something is wrong in
352 // production at three in the morning, waiting for a re-bake is not an
353 // answer. Any other value is a pin, and a pin is already baked in.
354 if ( defined( 'XSPEED_EDGE_PROVIDER' ) && 'off' === strtolower( (string) XSPEED_EDGE_PROVIDER ) ) {
355 $xspeed_edge_headers = array();
356 }
357
358 if ( is_array( $xspeed_edge_headers ) ) {
359 foreach ( $xspeed_edge_headers as $xspeed_edge_name => $xspeed_edge_value ) {
360 header( $xspeed_edge_name . ': ' . $xspeed_edge_value );
361 }
362 }
363
364 // Record the HIT for the dashboard hit-ratio. The drop-in runs
365 // BEFORE WordPress loads, so it can't call Hit_Counter — instead
366 // it appends one line to the same hits.log the nginx static path
367 // uses, and Hit_Counter::collect_nginx_log_hits() drains + counts
368 // both on the next dashboard load. Without this, every drop-in HIT
369 // was served but never counted, so the hit ratio sat at 0.
370 // Best-effort: a failed append must never break serving the page.
371 //
372 // Path is baked in at install time by Cache::install_dropin(), which
373 // replaces the @@XSPEED_HITS_LOG@@ token on the next line with the
374 // resolved absolute path (uploads/xspeed/hits.log — NOT the cache dir,
375 // which gets deleted on purge/uninstall and would take nginx down,
376 // FBS-82478). The default below is the fallback for an un-substituted
377 // drop-in (e.g. run straight from a dev source checkout); the installed
378 // copy always carries the absolute uploads path.
379 $xspeed_hits_log = '@@XSPEED_HITS_LOG@@'; // replaced at install
380 if ( '@@' === substr( $xspeed_hits_log, 0, 2 ) ) {
381 $xspeed_hits_log = WP_CONTENT_DIR . '/uploads/xspeed/hits.log';
382 }
383 // Don't count a bot, a scanner, a 404 or one of xSpeed's own
384 // requests as a visitor hit. It has to be decided HERE: the log line
385 // is just "hit" with no user agent, so Hit_Counter batch-counts these
386 // lines blind and nothing downstream can reclassify one. The UA
387 // pattern is baked in at install time from
388 // Hit_Counter::excluded_ua_regex() (the drop-in runs before
389 // WordPress, so it cannot ask). xSpeed's own requests also carry the
390 // X-XSpeed-Self header (Self_Traffic::HEADER), which is what catches
391 // a warmer renamed to a real browser's UA without dropping real
392 // visitors on that browser. An empty UA counts as automated, like
393 // is_bot_ua(''). A cached 404 is excluded on the PHP path too.
394 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- pre-WP drop-in; only matched against a baked pattern, never echoed or stored.
395 $xspeed_hit_ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? (string) $_SERVER['HTTP_USER_AGENT'] : '';
396 $xspeed_hit_ex = '@@XSPEED_HIT_EXCLUDE_RE@@';
397 $xspeed_self = '' === $xspeed_hit_ua
398 || ! empty( $_SERVER['HTTP_X_XSPEED_SELF'] )
399 || ( isset( $xspeed_meta['status'] ) && 404 === (int) $xspeed_meta['status'] );
400 if ( ! $xspeed_self && '@@' !== substr( $xspeed_hit_ex, 0, 2 ) && '' !== $xspeed_hit_ex ) {
401 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a pattern this file did not compose is not worth a warning on every hit.
402 $xspeed_self = 1 === @preg_match( '#(' . $xspeed_hit_ex . ')#i', $xspeed_hit_ua );
403 }
404 if ( ! $xspeed_self ) {
405 // 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).
406 @file_put_contents( $xspeed_hits_log, "hit\n", FILE_APPEND | LOCK_EX );
407 }
408
409 // Replay the cached response's status + content-type from .meta, so a
410 // cached 404 serves 404 (not a soft-404 200) and a cached feed serves
411 // application/rss+xml (not text/html). (FBS-82406, FBS-82407)
412 if ( ! empty( $xspeed_meta['status'] ) && function_exists( 'http_response_code' ) ) {
413 http_response_code( (int) $xspeed_meta['status'] );
414 }
415 if ( ! empty( $xspeed_meta['content_type'] ) && is_string( $xspeed_meta['content_type'] ) ) {
416 header( 'Content-Type: ' . $xspeed_meta['content_type'] );
417 }
418
419 // Conditional GET: Last-Modified + ETag from the cache file's mtime,
420 // answer a matching If-Modified-Since / If-None-Match with 304 so
421 // aggregators skip re-downloading an unchanged cached feed/page.
422 // (FBS-82407 #5)
423 $xspeed_mtime = (int) filemtime( $xspeed_cache_file );
424 if ( $xspeed_mtime > 0 ) {
425 $xspeed_lastmod = gmdate( 'D, d M Y H:i:s', $xspeed_mtime ) . ' GMT';
426 $xspeed_etag = '"' . md5( $xspeed_cache_file . '|' . $xspeed_mtime ) . '"';
427 header( 'Last-Modified: ' . $xspeed_lastmod );
428 header( 'ETag: ' . $xspeed_etag );
429 // 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.
430 $xspeed_inm = isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) ? trim( (string) $_SERVER['HTTP_IF_NONE_MATCH'] ) : '';
431 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- as above.
432 $xspeed_ims = isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ? trim( (string) $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) : '';
433 if ( ( '' !== $xspeed_inm && false !== strpos( $xspeed_inm, $xspeed_etag ) )
434 || ( '' !== $xspeed_ims && false !== ( $xspeed_ims_ts = strtotime( $xspeed_ims ) ) && $xspeed_ims_ts >= $xspeed_mtime ) ) {
435 if ( function_exists( 'http_response_code' ) ) {
436 http_response_code( 304 );
437 }
438 exit;
439 }
440 }
441
442 // Serve the precompressed Brotli sibling when the client accepts it
443 // and the Pro Brotli module wrote <file>.br. MUST mirror
444 // XSpeed\Cache::maybe_serve_brotli() on the non-drop-in serve path —
445 // both decide on the same Accept-Encoding token match + sibling
446 // existence, so the response is identical whichever path serves.
447 // pre-WP: no sanitize_text_field()/wp_unslash(); the value is only
448 // lowercased + regex-matched, never echoed.
449 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- pre-WP drop-in; value is only lowercased + token-matched, never echoed or executed.
450 $xspeed_accept_enc = isset( $_SERVER['HTTP_ACCEPT_ENCODING'] ) ? strtolower( str_replace( "\0", '', (string) $_SERVER['HTTP_ACCEPT_ENCODING'] ) ) : '';
451 $xspeed_br_file = $xspeed_cache_file . '.br';
452
453 // Existence is NOT enough: an empty or stale sibling is unservable.
454 // Mirrors XSpeed\Cache::brotli_sibling_is_usable(); inlined because
455 // this file runs before WordPress and cannot call it.
456 //
457 // Deliberately NO size-ratio floor. Brotli's ratio is unbounded on
458 // repetitive input — a ~1 MB page of table rows compresses to about
459 // 0.04% — so a floor rejects genuinely good siblings and silently
460 // serves the uncompressed page.
461 //
462 // Truncation is instead caught exactly, from the byte count the
463 // writer recorded in `<file>.br.size` when it published the sibling.
464 // A stream shorter than its own declared length cannot inflate; one
465 // that matches was published whole. Where no record exists — a
466 // sibling written before this version, which is precisely the
467 // already-broken file sitting on a live site right now — the checks
468 // below still apply and the atomic writer stops new ones appearing.
469 $xspeed_br_ok = false;
470 if ( is_readable( $xspeed_br_file ) ) {
471 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- pre-WP drop-in; a stat failure means "don't serve it", handled by the size checks.
472 $xspeed_br_size = (int) @filesize( $xspeed_br_file );
473 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- as above.
474 $xspeed_html_size = (int) @filesize( $xspeed_cache_file );
475 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- as above.
476 $xspeed_br_mtime = (int) @filemtime( $xspeed_br_file );
477
478 // MUST mirror XSpeed\Cache::brotli_expected_size(); 0 means "no
479 // record", never "zero bytes".
480 $xspeed_br_expected = 0;
481 $xspeed_br_sidecar = $xspeed_br_file . '.size';
482 if ( is_readable( $xspeed_br_sidecar ) ) {
483 // 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.
484 $xspeed_br_expected = (int) trim( (string) @file_get_contents( $xspeed_br_sidecar ) );
485 if ( $xspeed_br_expected < 0 ) {
486 $xspeed_br_expected = 0;
487 }
488 }
489
490 $xspeed_br_ok = $xspeed_br_size > 0
491 && $xspeed_html_size > 0
492 // Not stale: a sibling older than the page would serve the
493 // previous revision under the current entry's ETag.
494 && ( $xspeed_br_mtime <= 0 || $xspeed_mtime <= 0 || $xspeed_br_mtime >= $xspeed_mtime )
495 // Not truncated, where the writer left a length to check.
496 && ( $xspeed_br_expected <= 0 || $xspeed_br_size === $xspeed_br_expected );
497 }
498
499 if ( preg_match( '/(^|[\s,])br([\s,;]|$)/', $xspeed_accept_enc )
500 && $xspeed_br_ok ) {
501 header( 'Content-Encoding: br' );
502 header( 'Vary: Accept-Encoding', false );
503 header_remove( 'Content-Length' );
504 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- Drop-in runs before WP_Filesystem is available; readfile streams the precompressed sibling directly.
505 readfile( $xspeed_br_file );
506 exit;
507 }
508
509 // 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.
510 readfile( $xspeed_cache_file );
511 exit;
512 }
513 }
514