PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.7
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
xspeed / includes / class-cache.php

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

4,117 lines 170.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Page cache engine.
4 *
5 * @package XSpeed
6 */
7
8 namespace XSpeed;
9
10 defined( 'ABSPATH' ) || exit;
11
12 class Cache {
13
14 /**
15 * Output-buffer nesting level at which we opened our cache buffer, so
16 * `close_buffer()` can flush ONLY our buffer and never disturb a buffer
17 * another plugin pushed on top of (or below) ours.
18 *
19 * @var int|null
20 */
21 private static $buffer_level = null;
22
23 /**
24 * The `X-XSpeed-Cache` value decided for this request, and — when the
25 * decision was BYPASS — the slug of the gate that made it.
26 *
27 * Recorded as well as sent so unit tests (CLI SAPI, where header() is a
28 * no-op and headers_sent() is meaningless) can assert on the decision.
29 *
30 * @var string
31 */
32 private static $status_header = '';
33 private static $bypass_reason = '';
34
35 /**
36 * Cache key whose write was deferred to shutdown because a render-time
37 * translation plugin's buffer wraps ours. Null on every ordinary request.
38 *
39 * @var string|null
40 */
41 private static $deferred_key = null;
42
43 /**
44 * Translated page HTML captured by the outer buffer, for the deferred
45 * write. Only populated when a translation plugin is active.
46 *
47 * @var string
48 */
49 private static $translated_output = '';
50
51 /**
52 * Did finalize_buffer() run to completion on this request?
53 *
54 * The deferred translated write runs as a PHP shutdown function, which
55 * fires after a `wp_die()` or a bare `exit()` exactly as it does after a
56 * clean render. Only finalize_buffer() sets this, and only at the point
57 * where it has the full buffer in hand — so an aborted render leaves it
58 * false and the writer declines rather than caching a truncated page
59 * under the real key.
60 *
61 * @var bool
62 */
63 private static $render_completed = false;
64
65 public function __construct() {
66 /**
67 * When the page-cache output buffer opens.
68 *
69 * Filterable because buffer ORDER decides what gets cached. PHP's
70 * output buffers are LIFO: the last one opened is innermost, and its
71 * callback runs first. A render-time translation plugin that opens
72 * an outer buffer therefore translates AFTER we have already captured
73 * and cached the raw HTML — see translation_buffer_compat().
74 *
75 * @param string $hook Hook to open the buffer on.
76 * @param int $priority Priority for that hook.
77 */
78 $hook = (string) apply_filters( 'xspeed_cache_buffer_hook', 'template_redirect' );
79 $priority = (int) apply_filters( 'xspeed_cache_buffer_priority', 0 );
80 add_action( $hook, array( $this, 'maybe_start_cache' ), $priority );
81
82 // When a render-time translation plugin is present, open one extra
83 // buffer OUTSIDE its own so we can capture post-translation HTML.
84 // TranslatePress opens on `init` priority 0, so we take a negative
85 // priority to land outside it. This buffer only collects bytes for
86 // the deferred cache write — it never modifies the response.
87 add_action(
88 'init',
89 static function () {
90 if ( ! self::translation_plugin_active() ) {
91 return;
92 }
93 // `init` fires on EVERY request type, and
94 // translation_plugin_active() is a class_exists() check that
95 // is true site-wide — so without this guard the buffer opened
96 // on REST, admin-ajax, cron and WP-CLI too. None of those
97 // reach template_redirect, so $deferred_key stays null and
98 // the collected bytes are never released: a long-running
99 // WP-CLI command copied every byte of its output into a
100 // string that grew for the life of the process.
101 if ( is_admin()
102 || wp_doing_ajax()
103 || wp_doing_cron()
104 || ( defined( 'REST_REQUEST' ) && REST_REQUEST )
105 || ( defined( 'WP_CLI' ) && WP_CLI )
106 || ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) ) {
107 return;
108 }
109 ob_start(
110 static function ( $chunk ) {
111 self::$translated_output .= $chunk;
112 return $chunk;
113 }
114 );
115 },
116 (int) apply_filters( 'xspeed_translation_outer_buffer_priority', -100 )
117 );
118
119 // Events that should invalidate cached output. Beyond posts/comments,
120 // this covers user and term changes — the REST cache can serve
121 // /wp/v2/users, /wp/v2/categories, /wp/v2/tags, and these also affect
122 // rendered author bylines / term-archive pages. Without them, an edit
123 // left the matching endpoint (and archives) stale for the full TTL.
124 // (FBS-82408)
125 $invalidate_hooks = array(
126 'save_post', 'deleted_post', 'trashed_post',
127 'comment_post', 'wp_set_comment_status',
128 'switch_theme', 'activated_plugin', 'deactivated_plugin',
129 // Users → /wp/v2/users + author archives.
130 'profile_update', 'user_register', 'deleted_user',
131 // Terms → /wp/v2/{taxonomy} + term archives.
132 'created_term', 'edited_term', 'delete_term',
133 );
134 foreach ( $invalidate_hooks as $hook ) {
135 add_action( $hook, array( __CLASS__, 'purge_all' ) );
136 add_action( $hook, array( 'XSpeed\\Minifier', 'purge_minified' ) );
137 }
138
139 add_action( 'update_option_xspeed_options', array( __CLASS__, 'on_settings_change' ), 10, 2 );
140
141 add_action( 'admin_bar_menu', array( $this, 'admin_bar_purge' ), 100 );
142 add_action( 'admin_post_xspeed_purge', array( $this, 'handle_admin_bar_purge' ) );
143 }
144
145 public static function on_settings_change( $old, $new ) {
146 // gzip_enabled moved to xspeed_module_gzip — GzipModule owns the
147 // .htaccess flip via its own update_option_xspeed_module_gzip hook.
148 // Same migration is planned for cache_expiry + excluded_urls
149 // (Cache module). Keep this handler around for whatever still
150 // lives in the legacy blob (cache_enabled is special and goes
151 // through Cache::toggle anyway).
152
153 // Any settings change — purge caches so changes take effect.
154 self::purge_all( 'settings change' );
155 Minifier::purge_minified();
156 }
157
158 /**
159 * Stamp the request's cache decision on the response.
160 *
161 * `X-XSpeed-Cache` was only ever written on the serve-from-cache paths,
162 * so a miss and a deliberate bypass both came back with no header at all
163 * — indistinguishable from a `curl -I`, the first thing anyone reaches
164 * for when a site "isn't caching" (issue #10). The reason slug rides
165 * along on `X-XSpeed-Reason`, but only under WP_DEBUG so production
166 * responses stay clean. Slugs are fixed per gate — never the matched
167 * pattern, cookie or user-agent, which would echo request input back.
168 *
169 * @param string $value HIT (php) | MISS | BYPASS.
170 * @param string $reason Fixed slug naming the gate, for BYPASS only.
171 */
172 private static function mark( string $value, string $reason = '' ): void {
173 self::$status_header = $value;
174 self::$bypass_reason = $reason;
175
176 if ( headers_sent() ) {
177 return;
178 }
179 header( 'X-XSpeed-Cache: ' . $value );
180 if ( '' !== $reason && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
181 header( 'X-XSpeed-Reason: ' . $reason );
182 }
183 }
184
185 /** Record a bypass gate and answer "don't cache" in one statement. */
186 private static function bypass( string $reason ): bool {
187 self::mark( 'BYPASS', $reason );
188 return false;
189 }
190
191 /** The X-XSpeed-Cache value decided for this request ('' if none yet). */
192 public static function status_header(): string {
193 return self::$status_header;
194 }
195
196 /** The bypass gate slug for this request ('' unless BYPASS). */
197 public static function bypass_reason(): string {
198 return self::$bypass_reason;
199 }
200
201 public function maybe_start_cache() {
202 if ( ! self::should_cache() ) {
203 // PHP has just evaluated the FULL exclusion rule list — including
204 // the `~regex` patterns the server config can't express — and
205 // decided this visitor must not be served from cache. Record that
206 // verdict in the conventional bypass cookie so the web server can
207 // enforce it on subsequent requests without starting PHP.
208 //
209 // This is what stops most settings changes from needing an nginx
210 // reload: the config tests one fixed cookie name forever, and the
211 // rule list behind it can change freely.
212 self::sync_bypass_cookie( true );
213 return;
214 }
215
216 // Cacheable: clear any stale bypass cookie, or a visitor who once
217 // had a cart would keep skipping the fast path long after checkout.
218 self::sync_bypass_cookie( false );
219
220 $key = self::cache_key();
221 $file = self::cache_file_for( $key );
222
223 if ( file_exists( $file ) && ! self::is_expired( $file ) ) {
224 Hit_Counter::record_hit();
225 // Emit the HIT marker on THIS path too. The drop-in
226 // (advanced-cache.php) sends "HIT (php)" and the nginx static
227 // rewrite sends "HIT (nginx)", but this template_redirect
228 // serve path — the one that runs when the drop-in isn't loaded
229 // (e.g. WP_CACHE not true) — previously streamed the cached
230 // file with NO marker, so a genuine HIT looked like a MISS in
231 // the response headers. Same header + value as the drop-in.
232 self::mark( 'HIT (php)' );
233 // Replay stored response bits so the HIT matches the original:
234 // a non-HTML Content-Type (cached feeds, sitemaps) and a non-200
235 // status (a cached 404 must serve 404, not 200). No-op for
236 // ordinary pages, which write no .meta.
237 $meta = self::read_meta( $key );
238 if ( ! headers_sent() ) {
239 if ( ! empty( $meta['status'] ) && function_exists( 'http_response_code' ) ) {
240 http_response_code( (int) $meta['status'] );
241 }
242 if ( ! empty( $meta['content_type'] ) && is_string( $meta['content_type'] ) ) {
243 header( 'Content-Type: ' . $meta['content_type'] );
244 }
245 // Conditional GET: emit Last-Modified + ETag and answer a
246 // matching If-Modified-Since / If-None-Match with 304 so
247 // aggregators (and browsers) skip re-downloading an unchanged
248 // cached response — the bandwidth win feeds are about.
249 // (FBS-82407 #5)
250 if ( self::serve_not_modified( $file ) ) {
251 exit; // 304 sent, no body.
252 }
253 }
254 // Serve the precompressed Brotli sibling when the client accepts
255 // it (an add-on, the Pro Brotli module, wrote <file>.br). On this
256 // PHP serve path the web server never sees the .br, so without
257 // this a br-capable client got the plain .html — precompression
258 // did nothing here. Falls through to plain readfile otherwise.
259 $br = self::maybe_serve_brotli( $file );
260 if ( null !== $br ) {
261 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- streaming a static cache file directly; WP_Filesystem would buffer through PHP memory and is not appropriate for response streaming.
262 readfile( $br );
263 exit;
264 }
265 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- readfile is optimal for streaming a static cache file directly to the visitor; WP_Filesystem would buffer through PHP memory and is not appropriate for response streaming.
266 readfile( $file );
267 exit;
268 }
269
270 // Cache miss → render fresh + write cache. On LiteSpeed we send an
271 // explicit "stand down" header so the server's LSCache module does
272 // NOT cache + shadow our response — xSpeed's own .htaccess static
273 // rewrite owns hit serving (and hit accounting) here, exactly as on
274 // Apache. See maybe_emit_lscache_headers() for the full rationale.
275 self::maybe_emit_lscache_headers();
276
277 // We're about to render fresh + cache → miss for this request.
278 // …UNLESS this request is a 404 or a known bot/scanner. Those reach the
279 // render path too, but counting them as cache misses makes the ratio
280 // meaningless — a wave of `/wp-x7.php` scanner 404s reads as a collapsing
281 // cache when nothing is wrong. Runs at template_redirect (priority 0), so
282 // is_404() is already resolved. Excluded requests are tallied separately
283 // for the "you absorbed N scanner hits" line, not dropped. (#118)
284 if ( self::miss_is_excluded() ) {
285 Hit_Counter::record_excluded();
286 } else {
287 Hit_Counter::record_miss();
288 }
289
290 // Stamp it, so "eligible but not cached yet" is visibly different
291 // from "deliberately bypassed" (issue #10). Headers can't be sent
292 // after the body starts, so this has to happen here, not in
293 // finalize_buffer() — nothing has been output at template_redirect.
294 self::mark( 'MISS' );
295
296
297 // WP < 6.9 fallback: ob_start() with a callback, paired with an
298 // explicit shutdown close so the buffer lifecycle is visible to
299 // reviewers and Plugin Check, instead of relying on PHP's implicit
300 // request-end flush. We record our nesting level so close_buffer()
301 // flushes ONLY the buffer we opened.
302 ob_start( array( __CLASS__, 'finalize_buffer' ) );
303 self::$buffer_level = ob_get_level();
304
305 add_action( 'shutdown', array( __CLASS__, 'close_buffer' ), 0 );
306 }
307
308 /**
309 * Close the cache buffer opened by maybe_start_cache().
310 *
311 * Guarded by the recorded buffer level so we never flush a buffer that
312 * another plugin pushed on top of (or under) ours. If something else is
313 * currently on top, we leave the stack alone — PHP's shutdown sequence
314 * will unwind buffers in order and our finalize_buffer() callback will
315 * still run when our level becomes the topmost one.
316 */
317 public static function close_buffer() {
318 if ( null === self::$buffer_level ) {
319 return;
320 }
321 if ( ob_get_level() === self::$buffer_level ) {
322 ob_end_flush();
323 }
324 self::$buffer_level = null;
325 }
326
327 /**
328 * Are we buffering this request?
329 *
330 * Asked by Css_Combine_Buffer, which needs the finished HTML but must not
331 * open a second buffer when this one is already going to hand it the page
332 * through `xspeed_cache_final_html`. False here means the request is not
333 * cacheable — cache off, excluded URL, logged in — and the combiner has to
334 * provide its own buffer or it silently stops working. (#195)
335 */
336 public static function is_buffering(): bool {
337 return null !== self::$buffer_level;
338 }
339
340 /**
341 * Is a render-time translation plugin going to wrap our output buffer?
342 *
343 * TranslatePress opens its translation buffer on `init` priority 0. We
344 * open ours on `template_redirect`, which runs much later, so ours nests
345 * INSIDE theirs. PHP unwinds output buffers LIFO — innermost callback
346 * first — so `finalize_buffer()` saw the raw, pre-translation HTML and
347 * cached that, while the live visitor still got the translated bytes from
348 * TRP's outer buffer.
349 *
350 * Result: the first (MISS) visitor to /fr/some-page/ got correct French;
351 * every visitor after got English body text under a `lang="fr-FR"`
352 * document, plus TRP's internal `#TRPLINKPROCESSED` link markers, which
353 * TRP strips at the very end of its own buffer and which therefore leak
354 * into anything captured from inside it.
355 *
356 * Note the ordering cannot be fixed from TRP's side: its
357 * `trp_start_output_buffer_priority` filter only moves the PRIORITY on
358 * `init`, and `init` always fires before `template_redirect` whatever the
359 * priority. The buffer that has to move is ours.
360 *
361 * Detected by main class rather than plugin path, so a renamed directory
362 * or a bundled copy still matches.
363 */
364 public static function translation_plugin_active(): bool {
365 $active = class_exists( 'TRP_Translate_Press' );
366
367 /**
368 * Whether to treat this request as wrapped by a translation buffer.
369 *
370 * Lets a site add another render-time translation plugin (or opt out)
371 * without patching the engine.
372 *
373 * @param bool $active
374 */
375 return (bool) apply_filters( 'xspeed_translation_plugin_active', $active );
376 }
377
378 /**
379 * Write the cache file for a request whose output was wrapped by a
380 * render-time translation plugin.
381 *
382 * Registered as a PHP shutdown function (not a WP `shutdown` action) so
383 * it runs after PHP has unwound the output-buffer stack — by which point
384 * the translation plugin's callback has transformed the bytes and its
385 * internal markers are gone.
386 *
387 * finalize_buffer() has already applied the status gate, the
388 * xspeed_cache_final_html filter and HTML minification to the
389 * untranslated copy and then declined to write it. Here we re-run only
390 * what's needed on the translated bytes: minify, write, and fire the
391 * same downstream hooks so Brotli / static-tree listeners behave
392 * identically to the ordinary path.
393 */
394 public static function write_deferred_translated_cache(): void {
395 $key = self::$deferred_key;
396 self::$deferred_key = null;
397
398 // Release the collected bytes BEFORE the early return, so the static
399 // is cleared on every path rather than only when a key survived.
400 $full = self::$translated_output;
401 self::$translated_output = '';
402
403 $completed = self::$render_completed;
404 self::$render_completed = false;
405
406 if ( null === $key ) {
407 return;
408 }
409
410 // Did the render actually finish?
411 //
412 // This runs as a PHP shutdown function, which fires after a wp_die()
413 // or a bare exit() just as readily as after a clean render — but in
414 // those cases finalize_buffer() never returned, so the bytes we hold
415 // are a page that was cut off partway through. The length and
416 // TRPLINKPROCESSED checks below don't catch that: a fatal after the
417 // footer's translated markup is both over 255 bytes and free of TRP
418 // markers, i.e. truncated but entirely plausible. Caching it would
419 // freeze a half-rendered page under the real key for the full TTL.
420 //
421 // Serving this one URL uncached is the cheap failure; the corrupt
422 // cache entry is the expensive one.
423 if ( ! $completed ) {
424 return;
425 }
426
427 if ( strlen( $full ) < 255 ) {
428 return;
429 }
430
431 // Refuse to cache a copy still carrying the translation plugin's
432 // internal link markers. TRP strips these at the very end of its own
433 // buffer, so their presence means we captured too early — and a
434 // cached page containing them is SEO-visible damage. Better to serve
435 // this URL uncached than to freeze broken markup for the full TTL.
436 if ( false !== strpos( $full, 'TRPLINKPROCESSED' ) ) {
437 return;
438 }
439
440 $minify_opts = Settings_Manager::get( 'minify' );
441 if ( ! empty( $minify_opts['minify_html'] ) ) {
442 $full = Minifier::minify_html( $full );
443 }
444
445 // Per-site directory: on multisite every blog shares this tree, so
446 // entries are bucketed by host to keep one site's purge from
447 // sweeping the whole network. (#6)
448 self::ensure_host_dir();
449
450 $file = self::cache_file_for( $key );
451 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context for credentials; this runs on a frontend shutdown where it's unavailable.
452 file_put_contents( $file, $full, LOCK_EX );
453
454 /** This action is documented in includes/class-cache.php */
455 do_action( 'xspeed_flat_file_written', $file, $full );
456
457 self::write_meta( $key );
458
459 // Static tree too, under the same gates finalize_buffer() applies —
460 // otherwise deferring the write would silently cost translated pages
461 // the web-server fast path and leave them on the slower drop-in.
462 if ( self::static_rewrite_allowed() && self::response_is_plain_html() ) {
463 self::store_static( $full );
464 }
465 }
466
467 public static function should_cache() {
468 // Reset first: a single request only reaches this once (the sole
469 // caller is maybe_start_cache()), but tests and any future caller
470 // must never inherit the previous request's verdict.
471 self::$status_header = '';
472 self::$bypass_reason = '';
473
474 $opts = Settings::get();
475 if ( empty( $opts['cache_enabled'] ) ) {
476 return self::bypass( 'cache-disabled' );
477 }
478
479 if ( is_user_logged_in() ) {
480 return self::bypass( 'logged-in' );
481 }
482
483 if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
484 return self::bypass( 'non-frontend' );
485 }
486
487 if ( defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE ) {
488 return self::bypass( 'donotcachepage' );
489 }
490
491 // All exclusion knobs now owned by CacheModule.
492 $cache_opts = Settings_Manager::get( 'cache' );
493
494 $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : '';
495 if ( 'GET' !== $method ) {
496 return self::bypass( 'non-get' );
497 }
498
499 // Search-results requests carry a `s` query param, which the
500 // query-string gate below would normally reject as "dynamic". An
501 // add-on (xspeed-pro search cache) can opt them in: when this is a
502 // genuine is_search() and the filter returns true, the `s` param is
503 // treated as cacheable (the search term goes into the cache key so
504 // different searches stay distinct — see cache_key()).
505 $cache_search = self::should_cache_search();
506
507 // Feed opt-in is resolved BEFORE the query-string gate so query-form
508 // feeds (/?feed=rss2, used on plain-permalink sites) aren't rejected
509 // as "dynamic" by that gate — the `feed` param is then allowed through
510 // just like the search `s` param. Feeds are excluded by default (the
511 // `/feed/` pattern in excluded_urls); an add-on (xspeed-pro feed cache)
512 // opts them back in via the filter. (FBS-82407 #4)
513 $is_feed_request = function_exists( 'is_feed' ) && is_feed();
514 /**
515 * Whether to cache the current feed request.
516 *
517 * Default false → feeds fall through to the normal URL-exclusion
518 * rules (so `/feed/` keeps them out). A listener returning true
519 * opts this feed request into caching.
520 *
521 * @param bool $cache_feed Whether to cache this feed request.
522 */
523 $cache_feed = $is_feed_request && (bool) apply_filters( 'xspeed_should_cache_feed', false );
524
525 // Query string handling: anything OUTSIDE the ignored-params
526 // allow-list (utm_*, fbclid, gclid by default) means a unique
527 // request that we don't want to share with the canonical cache
528 // entry. Skip cache rather than poison the key.
529 //
530 // Parse the RAW query string, NOT a sanitize_text_field() copy:
531 // that filter strips percent-encoded octets (%XX), so `?%73=…`
532 // would lose its `s` key here while WordPress still decodes it to
533 // a search request — the gate would wave the request through and
534 // cache_key() would file the search page under the bare URL,
535 // letting an attacker poison the homepage cache with `/?%73=<spam>`.
536 // parse_str() does its own urldecoding, matching WP's own parse, and
537 // only the KEYS are used below (fed to Glob_Matcher → preg_match,
538 // never echoed or executed), so no sanitization is needed here.
539 $query_raw = isset( $_SERVER['QUERY_STRING'] ) ? wp_unslash( $_SERVER['QUERY_STRING'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- see note above: parse_str() urldecodes to match WP; only keys are consumed, via preg_match, never output.
540 if ( '' !== $query_raw ) {
541 $ignored = is_array( $cache_opts['ignored_query_params'] ?? null ) ? $cache_opts['ignored_query_params'] : array();
542 parse_str( $query_raw, $params );
543 foreach ( $params as $key => $_ ) {
544 // Allow the search param through when search caching is on.
545 if ( $cache_search && 's' === $key ) {
546 continue;
547 }
548 // Allow query-form feed params through when feed caching opted
549 // this request in (?feed=rss2 / &withcomments=1 on feeds).
550 if ( $cache_feed && in_array( $key, array( 'feed', 'withcomments', 'withoutcomments' ), true ) ) {
551 continue;
552 }
553 if ( ! self::query_key_is_ignored( (string) $key, $ignored ) ) {
554 // Slug only — never the param name, which is attacker-
555 // controlled and would be reflected into a header.
556 return self::bypass( 'query-param' );
557 }
558 }
559 }
560
561 $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
562 $path = (string) strtok( $request_uri, '?' );
563
564 $excluded_urls = is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array();
565 if ( ! $cache_feed && Glob_Matcher::any_match( $excluded_urls, $path ) ) {
566 return self::bypass( 'excluded-url' );
567 }
568
569 // Cookie-based exclusion. We only check cookie NAMES (matching
570 // values would leak content-sensitive logic into the cache key
571 // rules); presence of any matching cookie name skips cache.
572 $excluded_cookies = is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array();
573 if ( ! empty( $excluded_cookies ) && ! empty( $_COOKIE ) ) {
574 foreach ( array_keys( $_COOKIE ) as $cookie_name ) {
575 if ( Glob_Matcher::any_match( $excluded_cookies, (string) $cookie_name ) ) {
576 return self::bypass( 'excluded-cookie' );
577 }
578 }
579 }
580
581 // User-agent bypass list. Substring match (not glob) since UA
582 // strings have so much variation that glob anchoring rarely
583 // helps and confuses users.
584 $bypass_uas = is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array();
585 if ( ! empty( $bypass_uas ) ) {
586 $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
587 foreach ( $bypass_uas as $needle ) {
588 if ( '' !== $needle && false !== stripos( $ua, (string) $needle ) ) {
589 return self::bypass( 'user-agent' );
590 }
591 }
592 }
593
594 // Per-post override (Phase 3.4). Honored only on singular
595 // post-context requests — archives / 404s / taxonomies use the
596 // global policy above.
597 if ( Cache_Rules::should_skip_for_post( Cache_Rules::current_post_id() ) ) {
598 return self::bypass( 'post-excluded' );
599 }
600
601 /**
602 * Final say on whether the current request is cacheable.
603 *
604 * Runs at template_redirect (full WP context), so listeners may use
605 * conditional tags (is_search(), is_feed(), is_404(),
606 * wp_is_maintenance_mode(), …). The core engine has already applied
607 * its own exclusion rules and reached `true`; a listener returning
608 * false vetoes caching for this request. This is the documented
609 * extension point add-ons (xspeed-pro) hook to add their own
610 * request-level cache policy without forking the engine.
611 *
612 * Note: this gates the WRITE side. The pre-WP drop-in
613 * (advanced-cache.php) cannot run PHP filters, so request types that
614 * must never be *served* from a stale file are handled by not
615 * writing them here and/or by purging — see the conflict notes in
616 * advanced-cache.php.
617 *
618 * @param bool $should_cache Whether to cache the current request.
619 */
620 if ( ! apply_filters( 'xspeed_should_cache', true ) ) {
621 // One slug for every listener — a third-party callback name is
622 // not ours to put in a response header. Which listener vetoed is
623 // a WP_DEBUG-level question the filter itself can answer.
624 return self::bypass( 'filtered' );
625 }
626
627 return true;
628 }
629
630 /**
631 * Whether the current request is a 404 we may cache.
632 *
633 * True only when: it's a genuine main-query is_404(), an add-on opted
634 * in via `xspeed_should_cache_404` (default false), and the request
635 * isn't a transient 404 we must never freeze — maintenance mode or a
636 * 404 emitted while the DB/site is in an error state. The xspeed-pro
637 * 404 cache flips the filter; Free never caches 404s on its own.
638 */
639 public static function should_cache_404(): bool {
640 if ( ! function_exists( 'is_404' ) || ! is_404() ) {
641 return false;
642 }
643 // Never cache a 404 served because the site is down for
644 // maintenance — that screen disappears the moment maintenance
645 // ends, and a cached copy would outlive it.
646 if ( function_exists( 'wp_is_maintenance_mode' ) && wp_is_maintenance_mode() ) {
647 return false;
648 }
649
650 /**
651 * Whether to cache the current 404 response.
652 *
653 * Default false. A listener returning true opts the (genuine)
654 * 404 into the page cache, served back for any unknown URL under
655 * one generic key. The 404 status is preserved on the HIT.
656 *
657 * @param bool $cache_404 Whether to cache this 404.
658 */
659 return (bool) apply_filters( 'xspeed_should_cache_404', false );
660 }
661
662 /**
663 * Whether the current request is an internal search-results page we
664 * may cache.
665 *
666 * True only when: it's a genuine main-query is_search() with a
667 * non-empty term, and an add-on opted in via `xspeed_should_cache_search`
668 * (default false). The search term is folded into the cache key (see
669 * search_term() / cache_key()) so different searches stay distinct.
670 * The xspeed-pro search cache flips the filter; Free never caches
671 * search results on its own.
672 */
673 public static function should_cache_search(): bool {
674 if ( ! function_exists( 'is_search' ) || ! is_search() ) {
675 return false;
676 }
677 // Empty search (`?s=`) renders the same as a normal archive and
678 // carries no term to key on — let it fall through to the usual
679 // rules rather than caching an ambiguous entry.
680 if ( '' === self::search_term() ) {
681 return false;
682 }
683
684 /**
685 * Whether to cache the current search-results request.
686 *
687 * Default false. A listener returning true opts the search page
688 * into the cache, keyed by the normalized search term.
689 *
690 * @param bool $cache_search Whether to cache this search request.
691 */
692 return (bool) apply_filters( 'xspeed_should_cache_search', false );
693 }
694
695 /**
696 * The current request's normalized search term, or '' if none. Reads
697 * the raw `s` query param (works on the pre-WP drop-in path too, where
698 * get_search_query() isn't available), trims + lowercases so
699 * "WordPress" and "wordpress" share one entry, and collapses internal
700 * whitespace.
701 */
702 public static function search_term(): string {
703 $raw = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only cache-key derivation from a public search param; no state change.
704 $raw = trim( $raw );
705 if ( '' === $raw ) {
706 return '';
707 }
708 $raw = preg_replace( '/\s+/', ' ', $raw );
709 return function_exists( 'mb_strtolower' ) ? mb_strtolower( $raw ) : strtolower( $raw );
710 }
711
712 /**
713 * Is this query-string key on the ignored-params allow-list? Supports
714 * trailing-star globs (`utm_*` matches `utm_source`, `utm_medium`,
715 * etc.) so users don't have to enumerate every UTM variant.
716 */
717 private static function query_key_is_ignored( string $key, array $ignored ): bool {
718 return Glob_Matcher::any_match( $ignored, $key );
719 }
720
721 public static function cache_key() {
722 $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : 'default';
723
724 // Cacheable 404s share ONE generic per-host entry — keying them by
725 // URL would let a scanner flood (millions of random paths) bloat
726 // the cache with identical 404 bodies. Both the write and the HIT
727 // lookup run through here, so they agree on the key automatically.
728 if ( self::should_cache_404() ) {
729 return md5( $host . '|404' );
730 }
731
732 $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/';
733 // Strip the query string from the key so /post and /post?utm_*=…
734 // share the same cache entry. should_cache() above already
735 // rejected requests with non-ignored params, so by the time we
736 // build the key the only params left are safe to drop.
737 $uri = (string) strtok( $uri, '?' );
738
739 // Optional device bucket: when mobile_separate is on, mobile and
740 // desktop responses live in different cache files so themes that
741 // serve different HTML by device (AMP, WPtouch, Jetpack mobile)
742 // can't poison each other.
743 $device = '';
744 $opts = Settings_Manager::get( 'cache' );
745 if ( ! empty( $opts['mobile_separate'] ) ) {
746 $device = self::is_mobile_request() ? '|m' : '|d';
747 }
748
749 // Search-results requests fold the normalized term into the key so
750 // /?s=foo and /?s=bar get distinct entries (the query string is
751 // otherwise stripped above). Only added when search caching opted
752 // in, so non-search URLs are unaffected.
753 $search = self::should_cache_search() ? '|s=' . self::search_term() : '';
754
755 // Query-form feeds (/?feed=rss2 vs /?feed=atom) share the same path
756 // once the query is stripped, so fold the feed type into the key to
757 // keep the flavors distinct. Pretty-permalink feeds (/feed/rss/) carry
758 // the type in $uri already and are unaffected. (FBS-82407 #4)
759 $feed = '';
760 if ( function_exists( 'is_feed' ) && is_feed() && function_exists( 'get_query_var' ) ) {
761 $feed_type = (string) get_query_var( 'feed' );
762 if ( '' !== $feed_type ) {
763 $feed = '|feed=' . preg_replace( '/[^a-z0-9]/i', '', $feed_type );
764 }
765 }
766
767 return md5( $host . $uri . $device . $search . $feed );
768 }
769
770 /**
771 * Server-side mobile detection. Prefers WordPress's `wp_is_mobile()`
772 * which uses the same UA tokens as core (so our bucket aligns with
773 * whatever theme-side branching uses). Falls back to a tiny inline
774 * detector if wp_is_mobile() isn't loaded (e.g. the drop-in path).
775 */
776 private static function is_mobile_request(): bool {
777 if ( function_exists( 'wp_is_mobile' ) ) {
778 return (bool) wp_is_mobile();
779 }
780 // Fallback for the rare context where wp_is_mobile() isn't loaded.
781 // Mirrors core's wp_is_mobile() EXACTLY — including the
782 // Sec-CH-UA-Mobile client hint it checks *before* UA tokens — so the
783 // bucket this picks matches whatever the engine's primary path (and
784 // the drop-in's own copy of this logic) would pick for the same
785 // request. Drift here re-introduces the cross-path key mismatch.
786 if ( isset( $_SERVER['HTTP_SEC_CH_UA_MOBILE'] ) ) {
787 return '?1' === $_SERVER['HTTP_SEC_CH_UA_MOBILE'];
788 }
789 $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
790 if ( '' === $ua ) {
791 return false;
792 }
793 return (bool) preg_match( '/(Mobile|Android|Silk\/|Kindle|BlackBerry|Opera Mini|Opera Mobi)/i', $ua );
794 }
795
796 /**
797 * Filesystem-safe directory name for a host, or '' when unusable.
798 *
799 * The charset MUST match the static tree (store_static()) and the
800 * drop-in's own copy, or the paths disagree about where an entry lives.
801 * The colon of `host:port` is stripped: it is legal in a Host header but
802 * not portable in a path.
803 *
804 * @param string $host Raw host, e.g. from HTTP_HOST.
805 * @return string Safe directory segment, or '' if nothing usable remains.
806 */
807 public static function host_dir( string $host ): string {
808 $host = str_replace( "\0", '', $host );
809 // Drop the port BEFORE filtering, or `example.com:8080` collapses to
810 // `example.com8080` — which both loses the boundary and could collide
811 // with a real host of that name.
812 $colon = strpos( $host, ':' );
813 if ( false !== $colon ) {
814 $host = substr( $host, 0, $colon );
815 }
816 $host = preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host );
817 // Collapse any run of dots so no traversal sequence can survive the
818 // charset filter (`a/../b` would otherwise reduce to `a..b`).
819 $host = preg_replace( '/\.{2,}/', '.', (string) $host );
820 $host = trim( (string) $host, '.-' );
821 return '' === $host ? '' : $host;
822 }
823
824 /**
825 * The per-site bucket a cache entry belongs to: `<host>` on a single
826 * site, `<host>/<path-prefix>` for a subdirectory multisite blog.
827 *
828 * On multisite every blog shares one cache directory, and a flat md5
829 * filename carries no clue which site wrote it — so purging one subsite
830 * swept the whole network cold. (#6)
831 *
832 * Host alone is NOT enough: a subdirectory network (the common layout)
833 * puts every blog on the same host, so `example.com/` and
834 * `example.com/siteb/` would share a bucket and keep purging each other.
835 * The path prefix is what separates them, and it is derivable from the
836 * REQUEST_URI alone — which matters because the drop-in must compute
837 * this identical value before WordPress (and get_blog_details()) exist.
838 *
839 * Subdomain and domain-mapped networks differ by host already, so they
840 * get a bare host bucket and are unaffected.
841 *
842 * @param string $host Raw host.
843 * @param string $uri Raw REQUEST_URI (query string is ignored).
844 * @return string Bucket path, always non-empty.
845 */
846 public static function site_bucket( string $host, string $uri ): string {
847 $dir = self::host_dir( $host );
848 if ( '' === $dir ) {
849 $dir = 'default';
850 }
851
852 $prefix = self::site_path_prefix();
853 return '' === $prefix ? $dir : $dir . '/' . $prefix;
854 }
855
856 /**
857 * The current blog's path prefix as a single safe segment ('' for the
858 * root blog or a non-multisite install). `/siteb/` becomes `siteb`;
859 * a nested `/a/b/` becomes `a-b` so the bucket stays one level deep.
860 *
861 * Written to a sidecar for the drop-in by sync_site_paths().
862 */
863 public static function site_path_prefix(): string {
864 if ( ! function_exists( 'is_multisite' ) || ! is_multisite() ) {
865 return '';
866 }
867 if ( function_exists( 'is_subdomain_install' ) && is_subdomain_install() ) {
868 return ''; // Hosts already differ; no prefix needed.
869 }
870 $path = function_exists( 'get_blog_details' ) ? (string) get_blog_details()->path : '/';
871 return self::path_prefix_segment( $path );
872 }
873
874 /**
875 * The bucket an arbitrary URL's cache entry lives in.
876 *
877 * `site_bucket()` answers for the CURRENT request; this answers for a URL
878 * that may belong to another blog entirely — which is what a per-URL purge
879 * is usually doing (WP-CLI, cron, the MCP tool, a network-admin action).
880 *
881 * The blog is resolved from the URL itself: on a subdirectory network
882 * `get_blog_details()` is asked which blog owns `<host><path>`, and its
883 * registered path becomes the prefix. Deriving the prefix from the URL's
884 * first path segment directly would be wrong — `/shop/` on the main blog
885 * is a page, not a subsite, and would send the purge into a bucket that
886 * does not exist. (QA B2 on #166)
887 *
888 * @param string $host Host of the URL being purged.
889 * @param string $path Path of the URL being purged.
890 * @return string Bucket path, always non-empty.
891 */
892 public static function bucket_for_url( string $host, string $path ): string {
893 $dir = self::host_dir( $host );
894 if ( '' === $dir ) {
895 $dir = 'default';
896 }
897
898 if ( ! function_exists( 'is_multisite' ) || ! is_multisite() ) {
899 return $dir;
900 }
901 if ( function_exists( 'is_subdomain_install' ) && is_subdomain_install() ) {
902 return $dir; // Hosts already differ; no prefix.
903 }
904 if ( ! function_exists( 'get_blog_details' ) ) {
905 return $dir;
906 }
907
908 // Longest registered blog path that prefixes this URL wins, so
909 // `/one/2026/post/` resolves to blog `/one/` and not to the root blog.
910 $blog = self::blog_for_path( $host, $path );
911 if ( null === $blog ) {
912 return $dir;
913 }
914 $prefix = self::path_prefix_segment( (string) $blog );
915 return '' === $prefix ? $dir : $dir . '/' . $prefix;
916 }
917
918 /**
919 * The registered path of the blog that owns `<host><path>`, or null.
920 *
921 * Uses get_blog_details() with a domain/path pair rather than scanning
922 * every blog, so a large network costs one lookup per candidate segment
923 * instead of a full table read.
924 */
925 private static function blog_for_path( string $host, string $path ): ?string {
926 $segments = array_values( array_filter( explode( '/', trim( $path, '/' ) ) ) );
927
928 // Try the longest candidate first: /a/b/ before /a/ before /.
929 for ( $take = min( count( $segments ), 2 ); $take >= 1; $take-- ) {
930 $candidate = '/' . implode( '/', array_slice( $segments, 0, $take ) ) . '/';
931 $details = get_blog_details(
932 array(
933 'domain' => $host,
934 'path' => $candidate,
935 ),
936 false
937 );
938 if ( $details && ! empty( $details->path ) ) {
939 return (string) $details->path;
940 }
941 }
942 return null;
943 }
944
945 /**
946 * Normalise a blog path ('/', '/siteb/', '/a/b/') into a single
947 * filesystem-safe segment. Shared with the drop-in's copy.
948 */
949 public static function path_prefix_segment( string $path ): string {
950 $path = trim( str_replace( "\0", '', $path ), '/' );
951 if ( '' === $path ) {
952 return '';
953 }
954 $path = preg_replace( '/[^a-zA-Z0-9._\-\/]/', '', $path );
955 $path = str_replace( '/', '-', (string) $path );
956 return trim( (string) $path, '.-' );
957 }
958
959 /**
960 * The current blog's path as the static tree stores it — real slashes
961 * preserved, because that tree mirrors the URL
962 * (`xspeed-static/{host}{request_uri}/index.html`) rather than using a
963 * single flattened segment. '' for a root blog / single site.
964 */
965 public static function site_path_raw(): string {
966 if ( ! function_exists( 'is_multisite' ) || ! is_multisite() ) {
967 return '';
968 }
969 if ( function_exists( 'is_subdomain_install' ) && is_subdomain_install() ) {
970 return '';
971 }
972 $path = function_exists( 'get_blog_details' ) ? (string) get_blog_details()->path : '/';
973 $path = trim( str_replace( "\0", '', $path ), '/' );
974 if ( '' === $path ) {
975 return '';
976 }
977 $path = preg_replace( '#[^a-zA-Z0-9._\-/]#', '', $path );
978 return trim( (string) $path, '/' );
979 }
980
981 /**
982 * Static-tree root for the current site: `<host>` plus the blog's real
983 * path. Mirrors store_static()'s layout so a scoped purge deletes
984 * exactly this blog's pages.
985 */
986 public static function current_static_scope(): string {
987 // Same switch_to_blog() caveat as current_host_dir() — see current_host().
988 $dir = self::host_dir( self::current_host() );
989 if ( '' === $dir ) {
990 $dir = 'default';
991 }
992 $path = self::site_path_raw();
993 return '' === $path ? $dir : $dir . '/' . $path;
994 }
995
996 /**
997 * The bucket for the CURRENT request. Never empty, so an entry is never
998 * written to the tree root (which is what the unscoped sweeps used to
999 * delete indiscriminately).
1000 */
1001 public static function current_host_dir(): string {
1002 $host = self::current_host();
1003 $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/';
1004 return self::site_bucket( $host, $uri );
1005 }
1006
1007 /**
1008 * The host the CURRENT blog is served from.
1009 *
1010 * Deliberately NOT just $_SERVER['HTTP_HOST']: inside a
1011 * switch_to_blog() the request header still names whichever site is
1012 * serving the admin screen, while the cache entries we want belong to
1013 * the switched-to blog. On a subdomain network the host IS the bucket,
1014 * so reading the header there would make Pro's per-site "purge this
1015 * site" button clear the network admin's own cache instead — the very
1016 * bug this scoping exists to fix, surviving in one topology.
1017 *
1018 * get_blog_details() follows the switch, so prefer it whenever we are
1019 * on multisite, and fall back to the request header otherwise.
1020 */
1021 public static function current_host(): string {
1022 if ( function_exists( 'is_multisite' ) && is_multisite() && function_exists( 'get_blog_details' ) ) {
1023 $details = get_blog_details();
1024 if ( $details && ! empty( $details->domain ) ) {
1025 return (string) $details->domain;
1026 }
1027 }
1028
1029 if ( isset( $_SERVER['HTTP_HOST'] ) ) {
1030 return sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) );
1031 }
1032
1033 /*
1034 * No request header — WP-CLI, or WP-Cron driven by system cron.
1035 *
1036 * Returning '' here made the bucket resolve to the literal `default`
1037 * while HTTP requests were writing to `<host>/`, so a scheduled purge
1038 * swept an empty directory and reported success, and get_stats()
1039 * reported 0 cached pages on a site with a full cache. That is the
1040 * normal setup on any host running DISABLE_WP_CRON, which is most of
1041 * them. Fall back to the site's own registered host. (QA D4 on #166)
1042 */
1043 if ( function_exists( 'home_url' ) ) {
1044 $parts = function_exists( 'wp_parse_url' ) ? wp_parse_url( home_url( '/' ) ) : parse_url( home_url( '/' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- early-boot fallback only.
1045 if ( is_array( $parts ) && ! empty( $parts['host'] ) ) {
1046 return (string) $parts['host'];
1047 }
1048 }
1049
1050 return '';
1051 }
1052
1053 /**
1054 * Ensure the current site's cache directory exists, with the silence
1055 * index in both it and the shared root. Returns the directory.
1056 */
1057 public static function ensure_host_dir(): string {
1058 $dir = XSPEED_CACHE_DIR . '/' . self::current_host_dir();
1059 if ( ! file_exists( XSPEED_CACHE_DIR ) ) {
1060 wp_mkdir_p( XSPEED_CACHE_DIR );
1061 self::write_silence( XSPEED_CACHE_DIR );
1062 }
1063 if ( ! file_exists( $dir ) ) {
1064 wp_mkdir_p( $dir );
1065 self::write_silence( $dir );
1066 }
1067 return $dir;
1068 }
1069
1070 public static function cache_file_for( $key ) {
1071 return XSPEED_CACHE_DIR . '/' . self::current_host_dir() . '/' . $key . '.html';
1072 }
1073
1074 /**
1075 * If a precompressed Brotli sibling (`<file>.br`) exists and the client
1076 * advertises `Accept-Encoding: br`, emit the Brotli response headers and
1077 * return the `.br` path to stream. Returns null to fall through to the
1078 * plain file. Keeps the PHP serve path in parity with the web server's
1079 * static .br serving (mod_brotli / ngx_brotli rewrite).
1080 *
1081 * Free has no Brotli logic of its own — this only fires when an add-on
1082 * (the Pro Brotli module) actually wrote the .br, so it's a safe no-op
1083 * on Free-only installs.
1084 *
1085 * @param string $file Absolute path to the cached .html file.
1086 * @return string|null The .br path to stream, or null to serve $file.
1087 */
1088 public static function maybe_serve_brotli( string $file ): ?string {
1089 if ( headers_sent() ) {
1090 return null;
1091 }
1092 $accept = isset( $_SERVER['HTTP_ACCEPT_ENCODING'] )
1093 ? strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT_ENCODING'] ) ) )
1094 : '';
1095 // Match `br` as a token (comma/space delimited), not a substring, so
1096 // a hypothetical "xbr" encoding can't false-positive.
1097 if ( ! preg_match( '/(^|[\s,])br([\s,;]|$)/', $accept ) ) {
1098 return null;
1099 }
1100 $br = $file . '.br';
1101 if ( ! is_string( $br ) || ! file_exists( $br ) || ! is_readable( $br ) ) {
1102 return null;
1103 }
1104 header( 'Content-Encoding: br' );
1105 header( 'Vary: Accept-Encoding', false );
1106 // The byte length changes for the compressed body — drop any
1107 // Content-Length the caller may have set so the stream isn't
1108 // truncated/padded. readfile() lets the SAPI set the right length.
1109 header_remove( 'Content-Length' );
1110 return $br;
1111 }
1112
1113 /**
1114 * Sidecar metadata file for a cache entry. Holds response bits the HIT
1115 * path must replay — Content-Type (cached feeds → application/rss+xml,
1116 * sitemaps → text/xml) and status (a cached 404 must serve 404, not
1117 * 200). JSON, one tiny file per entry, written only when there's
1118 * something non-default to replay.
1119 */
1120 public static function cache_meta_for( $key ) {
1121 return XSPEED_CACHE_DIR . '/' . self::current_host_dir() . '/' . $key . '.meta';
1122 }
1123
1124 /**
1125 * Read the .meta sidecar for a cache entry as an array, or [] if none.
1126 * Keys: 'content_type' (string), 'status' (int), 'ttl' (int seconds).
1127 * Used on the HIT path to replay content-type/status before streaming
1128 * the file, and by Cache_GC to age an entry by its own TTL rather than
1129 * the global one — hence public.
1130 */
1131 public static function read_meta( $key ): array {
1132 $meta_file = self::cache_meta_for( $key );
1133 if ( ! file_exists( $meta_file ) ) {
1134 return array();
1135 }
1136 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- our own cache dir; WP_Filesystem needs admin creds unavailable on a frontend HIT.
1137 $raw = file_get_contents( $meta_file );
1138 $data = json_decode( (string) $raw, true );
1139 return is_array( $data ) ? $data : array();
1140 }
1141
1142 /**
1143 * Conditional-GET support for a cache HIT. Emits Last-Modified + ETag
1144 * derived from the cache file's mtime, and — when the request's
1145 * If-Modified-Since / If-None-Match still match — sends 304 Not Modified
1146 * and returns true (caller should exit without a body). Returns false to
1147 * proceed with a normal 200 body. Lets aggregators/browsers skip
1148 * re-downloading an unchanged cached response. (FBS-82407 #5)
1149 *
1150 * @param string $file Absolute path to the cache .html file.
1151 * @return bool True when a 304 was sent.
1152 */
1153 public static function serve_not_modified( string $file ): bool {
1154 $mtime = (int) filemtime( $file );
1155 if ( $mtime <= 0 ) {
1156 return false;
1157 }
1158 $last_modified = gmdate( 'D, d M Y H:i:s', $mtime ) . ' GMT';
1159 $etag = '"' . md5( $file . '|' . $mtime ) . '"';
1160 header( 'Last-Modified: ' . $last_modified );
1161 header( 'ETag: ' . $etag );
1162
1163 $ims = isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ) ) : '';
1164 $inm = isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_NONE_MATCH'] ) ) ) : '';
1165
1166 $etag_match = '' !== $inm && false !== strpos( $inm, $etag );
1167 $time_match = '' !== $ims && ( strtotime( $ims ) >= $mtime );
1168
1169 if ( $etag_match || $time_match ) {
1170 if ( function_exists( 'http_response_code' ) ) {
1171 http_response_code( 304 );
1172 }
1173 return true;
1174 }
1175 return false;
1176 }
1177
1178 public static function is_expired( $file ) {
1179 // cache_expiry now owned by CacheModule; per-post override
1180 // (Phase 3.4) shrinks the TTL further when the editor set one.
1181 $opts = Settings_Manager::get( 'cache' );
1182 $max_age = (int) $opts['cache_expiry'] * HOUR_IN_SECONDS;
1183 $post_override = Cache_Rules::expiry_override_seconds_for_post( Cache_Rules::current_post_id() );
1184 if ( null !== $post_override ) {
1185 $max_age = $post_override;
1186 }
1187
1188 /**
1189 * Filter the max-age (seconds) for the current cache entry.
1190 *
1191 * Lets an add-on apply a request-type-specific TTL — e.g. the
1192 * xspeed-pro feed cache gives feeds a longer expiry than pages,
1193 * since aggregators tolerate more staleness. Return seconds.
1194 *
1195 * @param int $max_age Computed max-age in seconds.
1196 */
1197 $max_age = (int) apply_filters( 'xspeed_cache_max_age', $max_age );
1198
1199 // A missing file is "expired" — the caller should re-render. Guard
1200 // filemtime() rather than letting it warn: callers legitimately ask
1201 // about a file that isn't there (Pro's predictive warmer probes for
1202 // freshness, and Cache_GC can collect an entry between the check and
1203 // the read), and on a site with WP_DEBUG the warning is noise.
1204 $mtime = file_exists( $file ) ? filemtime( $file ) : false;
1205 if ( false === $mtime ) {
1206 return true;
1207 }
1208
1209 return ( time() - (int) $mtime ) > $max_age;
1210 }
1211
1212 /**
1213 * Accumulator for the full response body across all output-handler phases.
1214 *
1215 * PHP invokes an ob_start() callback once per flush, and each invocation
1216 * only receives the chunk produced *since the previous flush*. If anything
1217 * during the render calls `ob_flush()` or `flush()` (some themes, lazy-
1218 * load plugins, AMP, etc. do), the final-phase call would otherwise only
1219 * see the tail of the page — and we'd cache a truncated response that
1220 * gets served repeatedly until purge. We accumulate every chunk here so
1221 * the cache file always reflects the complete page.
1222 *
1223 * @var string
1224 */
1225 private static $accumulated = '';
1226
1227 public static function finalize_buffer( $buffer, $phase = PHP_OUTPUT_HANDLER_FINAL ) {
1228 self::$accumulated .= $buffer;
1229
1230 // On non-final phases (mid-request flushes), pass the current chunk
1231 // through to the client unmodified and keep collecting. The WP 6.9
1232 // filter path always passes the full body in one shot with the
1233 // default $phase, so it falls straight through to the final block.
1234 $is_final = ( $phase & ( PHP_OUTPUT_HANDLER_FINAL | PHP_OUTPUT_HANDLER_END ) ) !== 0;
1235 if ( ! $is_final ) {
1236 return $buffer;
1237 }
1238
1239 $full = self::$accumulated;
1240 self::$accumulated = '';
1241
1242 if ( strlen( $full ) < 255 ) {
1243 return $buffer;
1244 }
1245
1246 // Status gate. We cache 200 by default. A 404 may be cached too,
1247 // but only when an add-on (xspeed-pro 404 cache) opts in for a
1248 // genuine is_404() — never a transient 404 (maintenance screen,
1249 // DB error, or a 404 emitted outside the main query), which would
1250 // otherwise be frozen until purge. Any other status is skipped.
1251 $status = function_exists( 'http_response_code' ) ? (int) http_response_code() : 200;
1252 if ( 200 !== $status ) {
1253 if ( 404 !== $status || ! self::should_cache_404() ) {
1254 return $buffer;
1255 }
1256 }
1257
1258 // If no mid-request flush happened, $buffer === $full and we can
1259 // safely minify the on-wire bytes too. Otherwise earlier chunks have
1260 // already been sent unminified, so we minify only what goes to disk —
1261 // the first visitor sees unminified HTML, every cache hit after that
1262 // is minified.
1263 $single_chunk = ( $buffer === $full );
1264
1265 /**
1266 * Filter: xspeed_cache_final_html
1267 *
1268 * Last chance to transform the fully-rendered page HTML before it is
1269 * minified and written to the cache file. Runs on cache MISS only, so
1270 * whatever a listener injects here is baked into the cached HTML and
1271 * replayed on every subsequent HIT (the drop-in short-circuits before
1272 * PHP on a HIT — a wp_head hook would never fire there).
1273 *
1274 * The Preload module uses this to inject the LCP-image <link rel=preload>
1275 * + preconnect hints and add fetchpriority="high" to the hero <img>.
1276 * Keep listeners fast and idempotent; this is the on-wire body.
1277 *
1278 * @param string $full Complete page HTML.
1279 */
1280 $full = (string) apply_filters( 'xspeed_cache_final_html', $full );
1281 if ( $single_chunk ) {
1282 $buffer = $full;
1283 }
1284
1285 // minify_html now owned by the Minify module; read through the
1286 // module's storage so this stays consistent with the engine that
1287 // applies CSS/JS minification.
1288 $minify_opts = Settings_Manager::get( 'minify' );
1289 if ( ! empty( $minify_opts['minify_html'] ) ) {
1290 $full = Minifier::minify_html( $full );
1291 if ( $single_chunk ) {
1292 $buffer = $full;
1293 }
1294 }
1295
1296 // Per-site directory — see ensure_host_dir(). (#6)
1297 self::ensure_host_dir();
1298
1299 // Path safety: cache_file_for() builds
1300 // `XSPEED_CACHE_DIR . '/' . <host> . '/' . $key . '.html'` where $key
1301 // comes from md5() — guaranteed to be exactly 32 lowercase hex chars —
1302 // and <host> is filtered by host_dir() to [A-Za-z0-9.-] with leading
1303 // dots trimmed, so no traversal sequence ('..', '/', null byte, etc.)
1304 // can appear in either segment. The write is therefore always inside
1305 // XSPEED_CACHE_DIR.
1306 $key = self::cache_key();
1307 $file = self::cache_file_for( $key );
1308
1309 // A render-time translation plugin (TranslatePress) wraps our buffer,
1310 // so the bytes we hold here are still UNTRANSLATED — its callback has
1311 // not run yet, and writing now would cache English under a French URL
1312 // and bake in its internal #TRPLINKPROCESSED markers. Hand off to
1313 // shutdown, where the outer buffer has already translated, and let
1314 // the pass-through below deliver this request untouched.
1315 if ( self::translation_plugin_active() ) {
1316 self::$deferred_key = $key;
1317 // Reaching here means finalize_buffer() ran to completion: the
1318 // status gate passed, should_cache() said yes, and PHP handed us
1319 // the whole buffer. A wp_die() or exit() mid-render unwinds the
1320 // buffer stack WITHOUT calling this callback, so the flag stays
1321 // false and the shutdown writer declines — see the guard there.
1322 self::$render_completed = true;
1323 // A PHP shutdown function, not a WP `shutdown` action: this must
1324 // run after the output-buffer stack has unwound, and WP's
1325 // shutdown action fires while our outer buffer is still open.
1326 register_shutdown_function( array( __CLASS__, 'write_deferred_translated_cache' ) );
1327 return $buffer;
1328 }
1329
1330 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context for credentials; cache writes happen on frontend requests where it's unavailable.
1331 file_put_contents( $file, $full, LOCK_EX );
1332
1333 /**
1334 * Fires after the flat hash cache file ({md5}.html) is written.
1335 *
1336 * Mirror of `xspeed_static_file_written` for the flat cache. The PHP
1337 * serve path (Cache::maybe_serve_brotli / the drop-in) serves THIS
1338 * file and looks for a `{md5}.html.br` sibling — which only the Pro
1339 * Brotli listener on this hook writes. Without it the .br sibling was
1340 * never created and the PHP path could never serve Brotli (FBS-83039,
1341 * Blocker 2): the static-tree .br (written on xspeed_static_file_written)
1342 * lives in a different cache layout the PHP path never reads.
1343 *
1344 * @param string $file Absolute path to the flat cache file just written.
1345 * @param string $full The HTML written to it.
1346 */
1347 do_action( 'xspeed_flat_file_written', $file, $full );
1348
1349 // Persist a non-default Content-Type so the HIT path can replay it
1350 // (cached feeds must serve application/rss+xml, not text/html).
1351 // Only written when the response set a content-type other than
1352 // the HTML default — pages don't pay for an extra file.
1353 self::write_meta( $key );
1354
1355 // Static-cache tree (xspeed-static/{host}{path}/index.html). The
1356 // .htaccess rewrite block serves this file directly via the web
1357 // server, bypassing PHP for ~3-5× lower TTFB vs the drop-in path.
1358 // store_static() returns silently on any path/permission issue —
1359 // the drop-in remains the safety net.
1360 //
1361 // Skip it entirely when mobile_separate is on: the rewrite is
1362 // disabled in that mode (static_rewrite_allowed()), so a static file
1363 // would only be dead weight — and a device-blind one at that.
1364 // Skip the static-tree write for responses the web server can't replay
1365 // correctly: a non-200 status (a cached 404 would be served as a soft
1366 // 200, FBS-82406) or a non-HTML content-type (a cached feed would go
1367 // out as text/html, FBS-82407). The web server serves these .html files
1368 // directly with no PHP, so there's no .meta replay — keep them on the
1369 // drop-in / PHP path instead, which DOES replay status + content-type.
1370 if ( self::static_rewrite_allowed() && self::response_is_plain_html() ) {
1371 self::store_static( $full );
1372 }
1373
1374 return $buffer;
1375 }
1376
1377 /**
1378 * Write the current response to the static-cache tree at
1379 * `xspeed-static/{host}{request_uri}/index.html`. The web-server
1380 * rewrite block points at this path so cache hits skip PHP
1381 * entirely. Caller already minified/finalized $html.
1382 *
1383 * Path safety: $host is restricted to a `[a-zA-Z0-9.\-]` allowlist;
1384 * $uri has its query string stripped, null bytes removed, '..'
1385 * sequences collapsed, and after concatenation we verify the
1386 * resolved real path stays inside XSPEED_CACHE_STATIC_DIR before
1387 * any write. Anything off the happy path returns silently.
1388 *
1389 * INVARIANT — the static tree is keyed by `{host}{path}` and NOTHING
1390 * else, and both generated rewrites refuse any request that carries a
1391 * query string at all (`RewriteCond %{QUERY_STRING} ^$` on Apache,
1392 * `if ($args)` in nginx_snippet()). So a response may only be stored
1393 * here when cache_key() adds no discriminator beyond `{host}{path}`:
1394 * a query-keyed entry can never be *served* from here, only mis-served
1395 * as the bare path. Any future opt-in that folds a query param into the
1396 * key needs a guard below, exactly like the search one.
1397 */
1398 private static function store_static( string $html ): void {
1399 // Search results are keyed by term in cache_key() (`|s=<term>`) but
1400 // carry the *path* of whatever URL was searched from — for the usual
1401 // `/?s=<term>` that path is `/`. Writing them here would file the
1402 // results page as `{host}/index.html` and the web server would serve
1403 // it to every visitor as the homepage: an unauthenticated visitor
1404 // poisons the front page with one request. Searches stay on the
1405 // drop-in, which replays the term-keyed entry correctly. (#191)
1406 //
1407 // This is a superset of the query-string check the exclusion gate
1408 // does: it also covers `/?%73=<term>`, which decodes to the same
1409 // search (the shape #109 fixed on the gate side).
1410 if ( self::should_cache_search() ) {
1411 return;
1412 }
1413
1414 $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
1415 $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
1416 $host = preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host );
1417 $uri = str_replace( "\0", '', $uri );
1418 $uri = (string) strtok( $uri, '?' );
1419 if ( '' === $host || '' === $uri ) {
1420 return;
1421 }
1422 // Collapse any traversal sequences before path resolution.
1423 $uri = preg_replace( '#/+#', '/', $uri );
1424 if ( false !== strpos( $uri, '..' ) ) {
1425 return;
1426 }
1427
1428 $base = rtrim( XSPEED_CACHE_STATIC_DIR, '/' );
1429 $dir = $base . '/' . $host . rtrim( $uri, '/' );
1430 $file = $dir . '/index.html';
1431
1432 // Resolve the parent against the cache root to be sure the
1433 // final path is inside our tree even if the OS does anything
1434 // funny with multi-byte sequences.
1435 $base_real = realpath( WP_CONTENT_DIR );
1436 if ( false === $base_real || 0 !== strpos( $base, $base_real ) ) {
1437 return;
1438 }
1439
1440 if ( ! file_exists( $dir ) ) {
1441 wp_mkdir_p( $dir );
1442 }
1443 if ( ! is_dir( $dir ) ) {
1444 return;
1445 }
1446 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- Same rationale as the flat-hash cache write above: WP_Filesystem isn't available on frontend requests, and the cache write must happen during shutdown.
1447 $written = file_put_contents( $file, $html, LOCK_EX );
1448
1449 if ( false !== $written ) {
1450 /**
1451 * Fires after a static cache file (index.html) is written.
1452 *
1453 * The extension point for serving pre-compressed siblings:
1454 * the xspeed-pro Brotli module writes `index.html.br` next to
1455 * the file here so the web server's static rewrite can serve a
1456 * Brotli copy to clients that advertise `Accept-Encoding: br`,
1457 * falling back to GZIP / the plain file otherwise. No core
1458 * behavior depends on a listener being present.
1459 *
1460 * @param string $file Absolute path to the static cache file just written.
1461 * @param string $html The HTML written to it.
1462 */
1463 do_action( 'xspeed_static_file_written', $file, $html );
1464 }
1465 }
1466
1467 /**
1468 * Write the .meta sidecar for a cache entry when the response carries
1469 * anything the HIT path must replay beyond a plain 200 text/html:
1470 * - a non-HTML Content-Type (cached feeds → application/rss+xml,
1471 * sitemaps → text/xml, …), and/or
1472 * - a non-200 status (a cached 404 must serve 404, not 200).
1473 *
1474 * Ordinary 200 text/html pages get NO .meta file, so the common path
1475 * stays a single write.
1476 *
1477 * @param string $key Cache key for the current request.
1478 */
1479 /**
1480 * True only for a plain 200 text/html response — the only kind the
1481 * web-server static tree can serve correctly (it streams the .html with
1482 * no PHP, so it can't replay a 404 status or a feed Content-Type). Used
1483 * to gate store_static() so cached 404s / feeds stay on the replay-capable
1484 * drop-in / PHP path. (FBS-82406, FBS-82407)
1485 */
1486 private static function response_is_plain_html(): bool {
1487 $status = function_exists( 'http_response_code' ) ? (int) http_response_code() : 200;
1488 if ( 200 !== $status && $status > 0 ) {
1489 return false;
1490 }
1491 foreach ( headers_list() as $header ) {
1492 if ( 0 === stripos( $header, 'content-type:' ) ) {
1493 $ct = trim( substr( $header, strlen( 'content-type:' ) ) );
1494 if ( '' !== $ct && false === stripos( $ct, 'text/html' ) ) {
1495 return false;
1496 }
1497 }
1498 }
1499 return true;
1500 }
1501
1502 private static function write_meta( string $key ): void {
1503 $content_type = '';
1504 foreach ( headers_list() as $header ) {
1505 if ( 0 === stripos( $header, 'content-type:' ) ) {
1506 $content_type = trim( substr( $header, strlen( 'content-type:' ) ) );
1507 }
1508 }
1509 $status = function_exists( 'http_response_code' ) ? (int) http_response_code() : 200;
1510
1511 $meta = array();
1512 $is_default_type = ( '' === $content_type || false !== stripos( $content_type, 'text/html' ) );
1513 if ( ! $is_default_type ) {
1514 $meta['content_type'] = $content_type;
1515 }
1516 if ( 200 !== $status && $status > 0 ) {
1517 $meta['status'] = $status;
1518 }
1519
1520 // Per-content TTL (seconds). The drop-in and static fast paths can't
1521 // call is_expired() / the xspeed_cache_max_age filter (they run before
1522 // WP), so persist the resolved max-age here whenever it differs from
1523 // the plain page TTL — e.g. the Pro feed cache's 12h vs the 24h page
1524 // default. The fast paths read this to expire correctly. (FBS-82407)
1525 $opts = Settings_Manager::get( 'cache' );
1526 $default_ttl = (int) $opts['cache_expiry'] * HOUR_IN_SECONDS;
1527 $ttl = (int) apply_filters( 'xspeed_cache_max_age', $default_ttl );
1528 if ( $ttl > 0 && $ttl !== $default_ttl ) {
1529 $meta['ttl'] = $ttl;
1530 }
1531
1532 // Nothing to replay → no sidecar.
1533 if ( empty( $meta ) ) {
1534 return;
1535 }
1536
1537 $payload = wp_json_encode( $meta );
1538 if ( false === $payload ) {
1539 return;
1540 }
1541 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- our own cache dir; WP_Filesystem needs admin creds unavailable on a frontend shutdown write.
1542 file_put_contents( self::cache_meta_for( $key ), $payload, LOCK_EX );
1543 }
1544
1545 /**
1546 * @param string $cause Free-form human reason. Recorded in the
1547 * Activity log to give users context (e.g.
1548 * 'post saved', 'settings change', 'manual',
1549 * 'theme switch').
1550 */
1551 /**
1552 * Purge the cache entries for ONE URL — every variant of it: the
1553 * flat-hash entry (+ .meta / .html.br siblings), both device buckets
1554 * (mobile_separate keys them separately), both trailing-slash forms,
1555 * and the static-tree index.html (+ .br) the server rewrite serves.
1556 * The rest of the cache is untouched — this is the surgical
1557 * alternative to purge_all for "I just edited this one page".
1558 *
1559 * @param string $url Absolute URL, or site-relative path ("/about/").
1560 * @param string $cause Who asked, for the purge log. See purge_all().
1561 * @return int Number of cache files removed.
1562 */
1563 public static function purge_url( string $url, string $cause = 'manual' ): int {
1564 $parts = function_exists( 'wp_parse_url' ) ? wp_parse_url( $url ) : parse_url( $url ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- fallback for early-boot contexts only.
1565 if ( ! is_array( $parts ) ) {
1566 return 0;
1567 }
1568 $host = isset( $parts['host'] ) ? strtolower( (string) $parts['host'] ) : '';
1569 if ( '' === $host && function_exists( 'home_url' ) ) {
1570 $home = function_exists( 'wp_parse_url' ) ? wp_parse_url( home_url( '/' ) ) : parse_url( home_url( '/' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- see above.
1571 $host = is_array( $home ) && isset( $home['host'] ) ? strtolower( (string) $home['host'] ) : '';
1572 }
1573 if ( '' === $host ) {
1574 return 0;
1575 }
1576 $path = isset( $parts['path'] ) ? (string) $parts['path'] : '/';
1577 $path = '/' . ltrim( $path, '/' );
1578 if ( false !== strpos( $path, '..' ) ) {
1579 return 0;
1580 }
1581
1582 // The cache key preserves REQUEST_URI's trailing-slash form, so
1583 // purge both. Root stays a single '/'.
1584 $forms = array( $path );
1585 if ( '/' !== $path ) {
1586 $forms[] = rtrim( $path, '/' );
1587 $forms[] = rtrim( $path, '/' ) . '/';
1588 }
1589 $forms = array_unique( $forms );
1590
1591 /*
1592 * Entries live under the bucket they were written for, and this URL's
1593 * site may not be the one serving THIS request (a cross-site purge on
1594 * multisite, WP-CLI, or cron). Build the directory from the URL's own
1595 * host AND path. (#6)
1596 *
1597 * Host alone is wrong on a subdirectory network: `store()` wrote to
1598 * `<host>/<prefix>/`, so looking in `<host>/` found nothing and the
1599 * call reported "already cold" while the page kept serving HIT — a
1600 * false success, which is worse than an error. The prefix has to come
1601 * from the URL being purged rather than from the current blog, because
1602 * the caller is usually purging some OTHER site. (QA B2 on #166)
1603 */
1604 $base = XSPEED_CACHE_DIR . '/' . self::bucket_for_url( $host, $path );
1605
1606 $count = 0;
1607 foreach ( $forms as $uri ) {
1608 // '' = mobile_separate off; '|m' / '|d' = the device buckets.
1609 foreach ( array( '', '|m', '|d' ) as $device ) {
1610 $key = md5( $host . $uri . $device );
1611 $file = $base . '/' . $key . '.html';
1612 if ( is_file( $file ) ) {
1613 wp_delete_file( $file );
1614 ++$count;
1615 }
1616 foreach ( array( $base . '/' . $key . '.meta', $file . '.br' ) as $sidecar ) {
1617 if ( is_file( $sidecar ) ) {
1618 wp_delete_file( $sidecar );
1619 }
1620 }
1621 }
1622 }
1623
1624 // Static tree (served directly by the nginx/.htaccess rewrite).
1625 if ( defined( 'XSPEED_CACHE_STATIC_DIR' ) ) {
1626 $dir = rtrim( XSPEED_CACHE_STATIC_DIR, '/' ) . '/' . $host . ( '/' === $path ? '' : rtrim( $path, '/' ) );
1627 $file = $dir . '/index.html';
1628 if ( is_file( $file ) ) {
1629 wp_delete_file( $file );
1630 ++$count;
1631 }
1632 if ( is_file( $file . '.br' ) ) {
1633 wp_delete_file( $file . '.br' );
1634 }
1635 }
1636
1637 if ( $count > 0 ) {
1638 Cache_Inventory::invalidate();
1639 Activity_Log::record(
1640 'cache_purge_url',
1641 sprintf(
1642 /* translators: 1: cause of the purge, 2: URL or path, 3: number of files removed. */
1643 __( 'Purged one URL (%1$s) — %2$s, %3$d file(s) removed', 'xspeed' ),
1644 $cause,
1645 $host . $path,
1646 $count
1647 ),
1648 Activity_Log::INFO
1649 );
1650 }
1651
1652 return $count;
1653 }
1654
1655 /**
1656 * Purge this site's cache.
1657 *
1658 * On multisite every blog shares one cache directory, so an unscoped
1659 * sweep here took the whole network cold — one subsite's settings save
1660 * or post publish rebuilt every other site from PHP. Entries are stored
1661 * per host (see host_dir()), and the sweep is scoped to match, so a
1662 * purge originating on site-a leaves site-b's cache warm. (#6)
1663 *
1664 * @param string $cause Who asked, for the purge log.
1665 * @param string|null $host Host to purge. Defaults to the current site.
1666 * Pass '*' to sweep the ENTIRE tree — network
1667 * admin's "purge all sites", and the migration
1668 * of pre-#6 entries that sit in the tree root.
1669 */
1670 public static function purge_all( string $cause = 'manual', ?string $host = null ) {
1671 $network_wide = ( '*' === $host );
1672 // The flat tree buckets by a flattened segment (host/a-b) while the
1673 // static tree mirrors the URL (host/a/b), so they need separate
1674 // scopes — see current_host_dir() vs current_static_scope().
1675 $static_scope = '';
1676 if ( null === $host || $network_wide ) {
1677 $scope = $network_wide ? '' : self::current_host_dir();
1678 $static_scope = $network_wide ? '' : self::current_static_scope();
1679 } else {
1680 $dir = self::host_dir( $host );
1681 $scope = '' === $dir ? 'default' : $dir;
1682 $static_scope = $scope;
1683 }
1684
1685 $count = 0;
1686 if ( is_dir( XSPEED_CACHE_DIR ) ) {
1687 // Scoped to one host directory, or the whole tree (including the
1688 // legacy top-level entries written before #6) when network-wide.
1689 /*
1690 * Network-wide sweeps go TWO levels deep, not one. A subdirectory
1691 * subsite's bucket is `<host>/<prefix>/`, so globbing only
1692 * `<cache>/*` reached the main site and left every subsite's
1693 * entries in place. (QA D5 on #166)
1694 *
1695 * A scoped purge also has to cover its own nested buckets: when
1696 * the main blog of a subdirectory network purges, `<host>/` is its
1697 * bucket and `<host>/one/` belongs to another blog — so the scoped
1698 * branch deliberately does NOT descend, which is what keeps
1699 * site-level purges isolated.
1700 */
1701 $roots = $network_wide
1702 ? array_merge(
1703 array( XSPEED_CACHE_DIR ),
1704 array_filter( (array) glob( XSPEED_CACHE_DIR . '/*', GLOB_ONLYDIR ) ),
1705 array_filter( (array) glob( XSPEED_CACHE_DIR . '/*/*', GLOB_ONLYDIR ) )
1706 )
1707 : array( XSPEED_CACHE_DIR . '/' . $scope );
1708
1709 foreach ( $roots as $root ) {
1710 /*
1711 * min/ and rest/ are swept by their own purgers below; never
1712 * treat them as host buckets.
1713 *
1714 * Checked on every path SEGMENT, not just the basename: now
1715 * that the network-wide glob descends two levels it can reach
1716 * `min/combined`, whose basename is `combined` and would sail
1717 * past a basename-only test — deleting the combined
1718 * stylesheets out from under the pages that link them.
1719 */
1720 if ( ! $network_wide || XSPEED_CACHE_DIR !== $root ) {
1721 $relative = trim( str_replace( XSPEED_CACHE_DIR, '', (string) $root ), '/' );
1722 $segments = '' === $relative ? array() : explode( '/', $relative );
1723 if ( array_intersect( $segments, array( 'min', 'rest' ) ) ) {
1724 continue;
1725 }
1726 }
1727 if ( ! is_dir( $root ) ) {
1728 continue;
1729 }
1730 $files = glob( $root . '/*.html' );
1731 if ( $files ) {
1732 $count += count( $files );
1733 foreach ( $files as $f ) {
1734 wp_delete_file( $f );
1735 }
1736 }
1737 // Remove the .meta sidecars (content-type for feeds/sitemaps)
1738 // alongside their .html entries. Not counted — they're not
1739 // cache "pages", just per-entry metadata.
1740 $meta = glob( $root . '/*.meta' );
1741 if ( $meta ) {
1742 foreach ( $meta as $m ) {
1743 wp_delete_file( $m );
1744 }
1745 }
1746 // Remove precompressed siblings (e.g. <key>.html.br from the Pro
1747 // Brotli module). Not counted — same as .meta. Without this a
1748 // purge leaves stale .br bodies behind: disk bloat, and a
1749 // staleness window if precompression is later disabled.
1750 $br = glob( $root . '/*.br' );
1751 if ( $br ) {
1752 foreach ( $br as $b ) {
1753 wp_delete_file( $b );
1754 }
1755 }
1756 }
1757 }
1758 // Static-cache tree purge — recursive because the layout is
1759 // xspeed-static/{host}/{path}/index.html, so a flat glob can't
1760 // reach everything. Already host-segmented, so scoping is just a
1761 // matter of starting one level down.
1762 if ( is_dir( XSPEED_CACHE_STATIC_DIR ) ) {
1763 $static_root = $network_wide
1764 ? XSPEED_CACHE_STATIC_DIR
1765 : XSPEED_CACHE_STATIC_DIR . '/' . $static_scope;
1766 if ( is_dir( $static_root ) ) {
1767 $count += self::rmtree_html( $static_root );
1768 }
1769 }
1770 // REST response cache (cache/xspeed/rest/*.json) — same purge
1771 // triggers (publish, settings change) invalidate it too.
1772 $count += Rest_Cache::purge();
1773
1774 // Minified + combined CSS/JS (cache/xspeed/min/ and min/combined/).
1775 // purge_all is a full filesystem sweep and must clear these too, even
1776 // when the Minify module is currently disabled — orphaned min/ files
1777 // from a feature the user later turned off must still be removed, and
1778 // a stale combined-<hash>.css that the regenerated page no longer
1779 // references otherwise 404s and breaks the frontend. (FBS-83114/83116)
1780 if ( class_exists( '\\XSpeed\\Minifier' ) ) {
1781 Minifier::purge_minified();
1782 }
1783
1784 // Persistent object cache (Redis / Memcached). Flush regardless of
1785 // whether the Object Cache module is currently enabled — a drop-in
1786 // installed earlier keeps serving until flushed.
1787 //
1788 // wp_cache_flush() is NETWORK-global: on multisite it would drop
1789 // every other site's object cache too, which is the same bug this
1790 // change fixes for the page cache. Prefer the blog-scoped flush
1791 // (WP 6.1+) unless we were explicitly asked to go network-wide. (#6)
1792 if ( ! $network_wide && is_multisite() && function_exists( 'wp_cache_flush_group' ) && function_exists( 'wp_cache_supports' ) && wp_cache_supports( 'flush_group' ) ) {
1793 // Blog-scoped groups only; a shared/global group (site options,
1794 // user meta) is intentionally left alone.
1795 foreach ( array( 'options', 'posts', 'terms', 'post_meta', 'comment' ) as $group ) {
1796 wp_cache_flush_group( $group );
1797 }
1798 } elseif ( function_exists( 'wp_cache_flush' ) ) {
1799 wp_cache_flush();
1800 }
1801
1802 self::update_stats( array( 'last_purge' => time() ) );
1803
1804 // Fire AFTER the local sweep so module listeners (Critical CSS,
1805 // Unused CSS, Cloudflare edge purge) run — this action had three
1806 // registered listeners but was never emitted. Treat it as additive
1807 // (CDN / edge invalidation), not the mechanism for clearing local
1808 // files. (FBS-83114)
1809 do_action( 'xspeed_after_purge_all', $cause );
1810
1811 // The list behind the "Cached pages" card is memoized for a minute;
1812 // a purge has to drop it or the drill-down shows pages that no
1813 // longer exist.
1814 Cache_Inventory::invalidate();
1815
1816 // Trigger of WP_CLI / hook / admin-bar purges all hit the same
1817 // path. Record once with the supplied cause so the dashboard
1818 // activity feed reads naturally.
1819 Activity_Log::record(
1820 'cache_purged',
1821 sprintf( 'Cache purged (%s) — %d file%s removed', $cause, $count, 1 === $count ? '' : 's' ),
1822 Activity_Log::INFO
1823 );
1824
1825 return $count;
1826 }
1827
1828 /**
1829 * The per-type purge menu, LiteSpeed-style. Each entry is a cache type
1830 * the user can purge individually from the admin-bar dropdown. `visible`
1831 * controls whether the item shows (active + licensed module only) — it
1832 * NEVER limits Purge All, which always sweeps everything on disk.
1833 *
1834 * Pro registers its own types (Critical CSS, Unused CSS, …) by filtering
1835 * `xspeed_purge_types`, so Free degrades gracefully when Pro is absent.
1836 *
1837 * @return array<string,array{label:string,visible:bool}>
1838 */
1839 public static function purge_types(): array {
1840 $minify_on = false;
1841 if ( class_exists( '\\XSpeed\\Settings_Manager' ) ) {
1842 $min = Settings_Manager::get( 'minify' );
1843 $minify_on = ! empty( $min['minify_css'] ) || ! empty( $min['minify_js'] ) || ! empty( $min['combine_css'] ) || ! empty( $min['combine_js'] );
1844 }
1845 // Object cache is "active" when an external object-cache drop-in is in
1846 // use — the canonical WP signal, independent of our settings option.
1847 $oc_on = function_exists( 'wp_using_ext_object_cache' ) && wp_using_ext_object_cache();
1848
1849 $types = array(
1850 'all' => array(
1851 'label' => __( 'Purge All', 'xspeed' ),
1852 'visible' => true,
1853 ),
1854 'page' => array(
1855 'label' => __( 'Purge Page / Static Cache', 'xspeed' ),
1856 'visible' => true,
1857 ),
1858 'assets' => array(
1859 'label' => __( 'Purge CSS / JS Cache', 'xspeed' ),
1860 'visible' => $minify_on,
1861 ),
1862 'object' => array(
1863 'label' => __( 'Purge Object Cache', 'xspeed' ),
1864 'visible' => $oc_on,
1865 ),
1866 'rest' => array(
1867 'label' => __( 'Purge REST Cache', 'xspeed' ),
1868 'visible' => true,
1869 ),
1870 );
1871
1872 /**
1873 * Filter the admin-bar purge-type menu. Pro modules add their own
1874 * (Critical CSS, Unused CSS, CDN). Adding a type here only adds a
1875 * MENU item — purge_type() must know how to handle the same slug.
1876 *
1877 * @param array $types Map of slug => [label, visible].
1878 */
1879 return (array) apply_filters( 'xspeed_purge_types', $types );
1880 }
1881
1882 /**
1883 * Purge a single cache type by slug. 'all' delegates to purge_all();
1884 * every other slug clears just its own artifacts. Unknown slugs (e.g. a
1885 * Pro type) fan out via the `xspeed_purge_type_{slug}` action so the
1886 * owning module can handle it. Returns the number of items removed where
1887 * countable.
1888 *
1889 * @param string $type Cache type slug.
1890 * @param string $cause Who asked. Threaded through so the purge log can
1891 * tell an AI assistant's purge apart from a click —
1892 * "the cache cleared four times today" is only
1893 * actionable once you know what kept clearing it.
1894 */
1895 public static function purge_type( string $type, string $cause = 'manual' ): int {
1896 switch ( $type ) {
1897 case 'all':
1898 return self::purge_all( $cause );
1899
1900 case 'page':
1901 $count = 0;
1902 // Scoped to this site — see purge_all(). (#6)
1903 $scope = self::current_host_dir();
1904 $flat_root = XSPEED_CACHE_DIR . '/' . $scope;
1905 if ( is_dir( $flat_root ) ) {
1906 foreach ( (array) glob( $flat_root . '/*.html' ) as $f ) {
1907 wp_delete_file( $f );
1908 ++$count;
1909 }
1910 foreach ( (array) glob( $flat_root . '/*.meta' ) as $m ) {
1911 wp_delete_file( $m );
1912 }
1913 foreach ( (array) glob( $flat_root . '/*.br' ) as $b ) {
1914 wp_delete_file( $b );
1915 }
1916 }
1917 $static_root = XSPEED_CACHE_STATIC_DIR . '/' . self::current_static_scope();
1918 if ( is_dir( $static_root ) ) {
1919 $count += self::rmtree_html( $static_root );
1920 }
1921 self::update_stats( array( 'last_purge' => time() ) );
1922 Cache_Inventory::invalidate();
1923 self::record_partial_purge( 'page', $cause, $count );
1924 return $count;
1925
1926 case 'assets':
1927 if ( class_exists( '\\XSpeed\\Minifier' ) ) {
1928 Minifier::purge_minified();
1929 }
1930 self::record_partial_purge( 'assets', $cause, null );
1931 return 0;
1932
1933 case 'object':
1934 if ( function_exists( 'wp_cache_flush' ) ) {
1935 wp_cache_flush();
1936 }
1937 self::record_partial_purge( 'object cache', $cause, null );
1938 return 0;
1939
1940 case 'rest':
1941 $count = Rest_Cache::purge();
1942 self::record_partial_purge( 'REST responses', $cause, $count );
1943 return $count;
1944
1945 default:
1946 // Pro / third-party type — let the owning module handle it.
1947 do_action( 'xspeed_purge_type_' . $type );
1948 self::record_partial_purge( $type, $cause, null );
1949 return 0;
1950 }
1951 }
1952
1953 /**
1954 * Log a partial purge so the drill-down behind "Last purge" shows every
1955 * clear, not only the full ones. Without this a site whose object cache
1956 * is flushed on a schedule looks, from the log, like nothing happens.
1957 *
1958 * @param string $what Human label for the slice purged.
1959 * @param string $cause Who asked.
1960 * @param int|null $count Items removed, when countable.
1961 */
1962 private static function record_partial_purge( string $what, string $cause, ?int $count ): void {
1963 $message = null === $count
1964 ? sprintf(
1965 /* translators: 1: what was purged, 2: cause of the purge. */
1966 __( 'Purged %1$s (%2$s)', 'xspeed' ),
1967 $what,
1968 $cause
1969 )
1970 : sprintf(
1971 /* translators: 1: what was purged, 2: cause of the purge, 3: number of files removed. */
1972 __( 'Purged %1$s (%2$s) — %3$d file(s) removed', 'xspeed' ),
1973 $what,
1974 $cause,
1975 $count
1976 );
1977
1978 Activity_Log::record( 'cache_purged', $message, Activity_Log::INFO );
1979 }
1980
1981 /**
1982 * Clear the static tree only, leaving the flat cache in place.
1983 *
1984 * A narrower purge_all() for the case where only the web-server tree can
1985 * be wrong: its files are keyed by `{host}{path}` and nothing else, so a
1986 * response filed under the wrong path poisons it while the flat cache —
1987 * keyed by cache_key(), discriminators included — stays correct. Avoids
1988 * throwing away Critical CSS, minified bundles and the object cache to
1989 * fix a static-only problem.
1990 *
1991 * @return int Number of index.html files removed.
1992 */
1993 public static function purge_static_tree(): int {
1994 return self::rmtree_html( XSPEED_CACHE_STATIC_DIR );
1995 }
1996
1997 /**
1998 * Recursively delete every `index.html` (and its precompressed
1999 * `index.html.br` sibling, if the Pro Brotli module wrote one) plus
2000 * empty directories inside the static-cache tree. Used by purge_all().
2001 * Returns the number of .html files removed so purge stats stay accurate
2002 * across the flat + static caches — .br siblings are not counted
2003 * (they're encodings of a page, not pages).
2004 */
2005 private static function rmtree_html( string $dir ): int {
2006 if ( ! is_dir( $dir ) ) {
2007 return 0;
2008 }
2009 $removed = 0;
2010 // SCANDIR_SORT_NONE skips alphabetic sort — we're going to walk
2011 // the whole tree regardless of order.
2012 $entries = @scandir( $dir, SCANDIR_SORT_NONE ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
2013 if ( false === $entries ) {
2014 return 0;
2015 }
2016 foreach ( $entries as $entry ) {
2017 if ( '.' === $entry || '..' === $entry ) {
2018 continue;
2019 }
2020 $path = $dir . '/' . $entry;
2021 if ( is_dir( $path ) ) {
2022 $removed += self::rmtree_html( $path );
2023 // Best-effort empty-dir cleanup; ignore failures (a
2024 // foreign file inside would block rmdir, which is fine).
2025 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Best-effort empty-dir cleanup; WP_Filesystem needs admin credentials we don't have during a normal purge.
2026 @rmdir( $path );
2027 continue;
2028 }
2029 if ( substr( $entry, -5 ) === '.html' ) {
2030 wp_delete_file( $path );
2031 ++$removed;
2032 } elseif ( substr( $entry, -3 ) === '.br' ) {
2033 // Precompressed sibling (index.html.br). Remove it too so a
2034 // purge doesn't orphan stale Brotli bodies. Not counted.
2035 wp_delete_file( $path );
2036 }
2037 }
2038 return $removed;
2039 }
2040
2041 /**
2042 * Drop a "silence is golden" index.php into a directory so apaches/nginx
2043 * with directory listing enabled don't expose cache contents.
2044 */
2045 public static function write_silence( $dir ) {
2046 $file = trailingslashit( $dir ) . 'index.php';
2047 if ( ! file_exists( $file ) ) {
2048 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context for credentials; cache dir setup may run during a frontend page render.
2049 file_put_contents( $file, "<?php\n// Silence is golden.\n" );
2050 }
2051 }
2052
2053 /**
2054 * The raw xspeed_stats option as an array. Keys currently in use:
2055 * 'last_purge', 'last_gc', 'gc_removed', 'gc_removed_total'.
2056 */
2057 public static function get_stats_option(): array {
2058 $stats = get_option( 'xspeed_stats', array() );
2059 return is_array( $stats ) ? $stats : array();
2060 }
2061
2062 /**
2063 * Persist stats with autoload disabled — stats are only read in admin
2064 * contexts, so there is no reason to inflate every frontend request's
2065 * `wp_load_alloptions()` payload.
2066 *
2067 * MERGES into whatever is already stored. It used to overwrite, which
2068 * was harmless while `last_purge` was the only key — with the GC keys
2069 * alongside it, a purge would have wiped the GC history and vice versa.
2070 */
2071 public static function update_stats( array $stats ) {
2072 if ( false === get_option( 'xspeed_stats', false ) ) {
2073 add_option( 'xspeed_stats', $stats, '', 'no' );
2074 return;
2075 }
2076 update_option( 'xspeed_stats', array_merge( self::get_stats_option(), $stats ) );
2077 }
2078
2079 public static function get_stats() {
2080 $count = 0;
2081 $size = 0;
2082 // This site's entries only — on multisite the tree is shared, so an
2083 // unscoped count reported the whole network's pages on every
2084 // subsite's dashboard. (#6)
2085 $flat_root = XSPEED_CACHE_DIR . '/' . self::current_host_dir();
2086 if ( is_dir( $flat_root ) ) {
2087 $files = glob( $flat_root . '/*.html' );
2088 if ( $files ) {
2089 $count = count( $files );
2090 foreach ( $files as $f ) {
2091 $size += filesize( $f );
2092 }
2093 }
2094 }
2095 // Drain the HIT-log file BEFORE reading totals. Two serve paths that
2096 // bypass the normal in-PHP record_hit() append one line per HIT here:
2097 // the nginx server-level rewrite (see nginx_snippet(), never reaches
2098 // PHP) and the advanced-cache.php drop-in (runs pre-WordPress, can't
2099 // reach Hit_Counter). Without this drain both look like a 0% hit-ratio
2100 // on a perfectly working cache.
2101 Hit_Counter::collect_nginx_log_hits();
2102
2103 // Apache/LiteSpeed static-rewrite HITs are served straight from disk
2104 // by .htaccess and never reach PHP either — but there's no .htaccess
2105 // equivalent of nginx's access_log directive, so we count them by
2106 // scanning the web server's own access log incrementally. No-op when
2107 // the log isn't readable (managed hosts) — see the method docblock.
2108 Hit_Counter::collect_server_log_hits();
2109
2110 $stats = get_option( 'xspeed_stats', array() );
2111 $totals = Hit_Counter::totals_24h();
2112 return array(
2113 'cached_pages' => $count,
2114 'cache_size' => $size,
2115 'last_purge' => isset( $stats['last_purge'] ) ? (int) $stats['last_purge'] : 0,
2116 // Rolling 24h cache performance — sourced from Hit_Counter's
2117 // hourly buckets. The frontend uses hit_ratio to drive the
2118 // CacheHero stat grid + the Health module's panel.
2119 'hits_24h' => $totals['hits'],
2120 'misses_24h' => $totals['misses'],
2121 'hit_ratio' => $totals['ratio'],
2122 // Requests kept OUT of the ratio (404s + bots) — surfaced as its own
2123 // "absorbed N scanner/bot requests" line rather than distorting the
2124 // cache-performance number. (#118)
2125 'excluded_24h' => $totals['excluded'],
2126 // True when an edge cache (Cloudflare) fronts the origin, so hits are
2127 // absorbed before reaching PHP. The dashboard labels the ratio
2128 // "origin-layer only" instead of implying it's the full picture. (#118)
2129 'edge_cache' => self::edge_cache_detected(),
2130 );
2131 }
2132
2133 /**
2134 * Whether the current request should be kept OUT of the cache hit/miss
2135 * ratio: a genuine 404, or a known bot / scanner. Runs at template_redirect
2136 * time, so is_404() is resolved. (#118)
2137 */
2138 private static function miss_is_excluded(): bool {
2139 if ( function_exists( 'is_404' ) && is_404() ) {
2140 return true;
2141 }
2142 $ua = isset( $_SERVER['HTTP_USER_AGENT'] )
2143 ? sanitize_text_field( wp_unslash( (string) $_SERVER['HTTP_USER_AGENT'] ) )
2144 : '';
2145 return Hit_Counter::is_bot_ua( $ua );
2146 }
2147
2148 /**
2149 * Whether an edge cache fronts this origin. Today: the Cloudflare
2150 * integration is connected — so an unknown share of hits is served at the
2151 * edge and never counted here, making the origin ratio a partial view the
2152 * dashboard must label as such. (#118)
2153 */
2154 private static function edge_cache_detected(): bool {
2155 $cf = get_option( 'xspeed_module_cloudflare', array() );
2156 return is_array( $cf ) && ! empty( $cf['enabled'] );
2157 }
2158
2159 /**
2160 * Apply the user's enable/disable choice. Called from the REST toggle
2161 * endpoint, which is gated by current_user_can( 'manage_options' ) and
2162 * a verified REST nonce.
2163 *
2164 * This is the only path that ENABLES caching — a drop-in is never
2165 * created for a user who hasn't opted in, which is the guideline that
2166 * matters (a plugin must not install drop-ins or edit wp-config.php
2167 * on a fresh activation). RESTORING the drop-in for a site that
2168 * already has cache_enabled = true is a different act and is handled
2169 * by restore_dropin_if_enabled() on activation and auto_heal() at
2170 * runtime; without it every plugin update silently un-caches the site.
2171 *
2172 * @param bool $enable User's choice.
2173 * @return array{
2174 * enabled: bool,
2175 * dropin_installed: bool,
2176 * wp_cache_constant: bool,
2177 * wp_config_writable: bool,
2178 * manual_snippet: ?string
2179 * }
2180 */
2181 public static function toggle( $enable ) {
2182 $enable = (bool) $enable;
2183
2184 if ( $enable ) {
2185 $dropin_ok = self::install_dropin();
2186 $wp_config_ok = self::set_wp_cache_constant( true );
2187 $rewrite_ok = self::install_rewrite();
2188 self::ensure_hits_log_file();
2189 self::sync_mobile_flag();
2190 $snippet = $wp_config_ok ? null : "define( 'WP_CACHE', true );";
2191
2192 Activity_Log::record(
2193 'cache_enabled_event',
2194 $wp_config_ok
2195 ? 'Cache enabled. Drop-in installed, WP_CACHE constant set.'
2196 : 'Cache enabled. Drop-in installed; wp-config.php not writable — add the WP_CACHE snippet manually.',
2197 $wp_config_ok ? Activity_Log::SUCCESS : Activity_Log::WARN
2198 );
2199
2200 return array(
2201 'enabled' => true,
2202 'dropin_installed' => (bool) $dropin_ok,
2203 'wp_cache_constant' => (bool) $wp_config_ok,
2204 'rewrite_installed' => (bool) $rewrite_ok,
2205 'wp_config_writable' => self::wp_config_writable(),
2206 'manual_snippet' => $snippet,
2207 'nginx_snippet' => self::nginx_snippet(),
2208 // Unified server-block snippet aggregating every enabled
2209 // module's directives — the same value the dashboard and
2210 // Health insight render. The wizard shows this so all three
2211 // surfaces stay in lockstep. Null on non-nginx hosts.
2212 'nginx_server_block' => self::full_nginx_server_block(),
2213 );
2214 }
2215
2216 self::remove_dropin();
2217 self::set_wp_cache_constant( false );
2218 self::remove_rewrite();
2219 // Drop the device-bucket marker too — with the drop-in gone there's
2220 // nothing left to read it, and leaving it behind would dirty a fresh
2221 // re-enable (and leaks across test runs).
2222 self::sync_mobile_flag( false );
2223
2224 Activity_Log::record(
2225 'cache_disabled_event',
2226 'Cache disabled. Drop-in removed.',
2227 Activity_Log::INFO
2228 );
2229
2230 return array(
2231 'enabled' => false,
2232 'dropin_installed' => false,
2233 'wp_cache_constant' => false,
2234 'rewrite_installed' => false,
2235 'wp_config_writable' => self::wp_config_writable(),
2236 'manual_snippet' => null,
2237 'nginx_snippet' => self::nginx_snippet(),
2238 'nginx_server_block' => self::full_nginx_server_block(),
2239 );
2240 }
2241
2242 /**
2243 * Check wp-config.php writability via WP_Filesystem. Plugin Check flags
2244 * direct is_writable() under WordPress.WP.AlternativeFunctions.
2245 */
2246 private static function wp_config_writable() {
2247 global $wp_filesystem;
2248 if ( ! function_exists( 'WP_Filesystem' ) ) {
2249 require_once ABSPATH . 'wp-admin/includes/file.php';
2250 }
2251 WP_Filesystem();
2252
2253 return $wp_filesystem ? (bool) $wp_filesystem->is_writable( ABSPATH . 'wp-config.php' ) : false;
2254 }
2255
2256 /**
2257 * Nginx server-block snippet mirroring the Apache rewrite block.
2258 * We never auto-write nginx config — it sits outside the WordPress
2259 * root and is owned by the server admin — but the dashboard
2260 * surfaces this snippet when nginx is detected so the admin can
2261 * paste it once and unlock the same PHP-bypass speedup we get on
2262 * Apache / LiteSpeed via .htaccess.
2263 *
2264 * Returns null when the server isn't nginx (no point showing it).
2265 */
2266 /**
2267 * Create wp-content/cache/xspeed/hits.log as an empty file so the
2268 * server-level rewrite's `access_log` directive has somewhere to
2269 * write on first request. Idempotent — touches an existing file
2270 * without disturbing accumulated lines. Called from Cache::toggle()
2271 * on enable and from auto_heal() when the file is missing.
2272 *
2273 * Permissions matter here. The file is created by PHP-FPM (often uid
2274 * www-data), but the nginx process that appends HIT lines may run as a
2275 * DIFFERENT uid — on multi-container hosts (e.g. xclude/Kinsta: nginx in
2276 * its own container as uid `nginx`, PHP-FPM in another as `www-data`)
2277 * they don't share a user at all. A default-umask 0644 file is then
2278 * unwritable by nginx, the access_log write silently fails, and the
2279 * dashboard shows a 0% hit ratio even though static HITs are serving.
2280 * So we widen the dir to 0777 and the file to 0666 — group/other write —
2281 * so whatever uid nginx runs as can append. (The file holds only HIT
2282 * request lines, no secrets.)
2283 */
2284 /**
2285 * Directory holding the nginx hit log. Lives under uploads/, NOT the
2286 * cache dir — uninstall.php and a cache purge both delete the cache
2287 * dir, which would orphan the pasted nginx `access_log` directive's
2288 * parent directory and make `nginx -t` fail [emerg], taking down every
2289 * vhost on the host (FBS-82478). uploads/ always exists, isn't a
2290 * plugin-managed cache dir, and is never deleted on uninstall — so the
2291 * directive's target dir survives both, and nginx (which creates a
2292 * missing log FILE but not a missing DIR) can always open it.
2293 *
2294 * Falls back to the cache dir only if uploads is somehow unavailable.
2295 */
2296 public static function hits_log_dir(): string {
2297 if ( function_exists( 'wp_upload_dir' ) ) {
2298 $uploads = wp_upload_dir( null, false );
2299 if ( is_array( $uploads ) && empty( $uploads['error'] ) && ! empty( $uploads['basedir'] ) ) {
2300 return rtrim( (string) $uploads['basedir'], '/' ) . '/xspeed';
2301 }
2302 }
2303 return XSPEED_CACHE_DIR;
2304 }
2305
2306 /** Absolute path to the nginx hit log file. */
2307 public static function hits_log_path(): string {
2308 return self::hits_log_dir() . '/hits.log';
2309 }
2310
2311 /**
2312 * Sync the drop-in's mobile-bucket flag file with the `mobile_separate`
2313 * setting. The drop-in (advanced-cache.php) runs before WordPress loads,
2314 * so it can't read the option — instead it checks for a zero-byte
2315 * `.mobile-separate` marker next to the cache files. When the setting is
2316 * on we touch the marker; when off we remove it. The drop-in's cache_key
2317 * computation keys off the marker's presence so its '|m'/'|d' device
2318 * bucket stays in lockstep with Cache::cache_key().
2319 *
2320 * Without this, turning on mobile_separate made Cache::store() write keys
2321 * with a '|d'/'|m' suffix the drop-in never reproduced — so the drop-in's
2322 * file_exists() always missed, every HIT fell through to a full WP boot,
2323 * and the fast pre-WP path was silently dead.
2324 *
2325 * @param bool|null $enabled Force a state; null reads the current setting.
2326 */
2327 /**
2328 * Write the subdirectory-multisite path list the drop-in needs to work
2329 * out which blog a request belongs to.
2330 *
2331 * The drop-in runs before WordPress, so it cannot call is_multisite()
2332 * or get_blog_details(). It can only see REQUEST_URI — so we persist the
2333 * network's blog paths (one per line, longest first) next to the cache
2334 * files, exactly as sync_mobile_flag() persists the device flag. The
2335 * drop-in prefix-matches the URI against that list to pick the same
2336 * bucket Cache::current_host_dir() picks. (#6)
2337 *
2338 * No file is written for a single site or a subdomain network — there
2339 * the host alone identifies the blog and the bucket carries no prefix.
2340 */
2341 public static function sync_site_paths(): void {
2342 $file = XSPEED_CACHE_DIR . '/.site-paths';
2343
2344 $needed = function_exists( 'is_multisite' ) && is_multisite()
2345 && ( ! function_exists( 'is_subdomain_install' ) || ! is_subdomain_install() );
2346
2347 if ( ! $needed ) {
2348 if ( file_exists( $file ) ) {
2349 // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal.
2350 @unlink( $file );
2351 }
2352 return;
2353 }
2354
2355 if ( ! function_exists( 'get_sites' ) ) {
2356 return;
2357 }
2358
2359 $paths = array();
2360 foreach ( get_sites( array( 'number' => 0 ) ) as $site ) {
2361 $prefix = self::path_prefix_segment( (string) $site->path );
2362 if ( '' !== $prefix ) {
2363 // Store the raw path so the drop-in can prefix-match a URI,
2364 // alongside the segment it maps to.
2365 $paths[ trim( (string) $site->path, '/' ) ] = $prefix;
2366 }
2367 }
2368
2369 if ( empty( $paths ) ) {
2370 if ( file_exists( $file ) ) {
2371 // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- see above.
2372 @unlink( $file );
2373 }
2374 return;
2375 }
2376
2377 // Longest path first so /a/b wins over /a.
2378 uksort(
2379 $paths,
2380 static function ( $x, $y ) {
2381 return strlen( (string) $y ) <=> strlen( (string) $x );
2382 }
2383 );
2384
2385 $lines = array();
2386 foreach ( $paths as $raw => $segment ) {
2387 $lines[] = $raw . '|' . $segment;
2388 }
2389
2390 if ( ! is_dir( XSPEED_CACHE_DIR ) && ! wp_mkdir_p( XSPEED_CACHE_DIR ) ) {
2391 return;
2392 }
2393 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- read by the pre-WP drop-in; WP_Filesystem needs admin credentials unavailable here.
2394 file_put_contents( $file, implode( "\n", $lines ), LOCK_EX );
2395 }
2396
2397 public static function sync_mobile_flag( $enabled = null ): void {
2398 if ( null === $enabled ) {
2399 $opts = Settings_Manager::get( 'cache' );
2400 $enabled = ! empty( $opts['mobile_separate'] );
2401 }
2402 $dir = XSPEED_CACHE_DIR;
2403 $flag = $dir . '/.mobile-separate';
2404 if ( $enabled ) {
2405 if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
2406 return;
2407 }
2408 if ( ! file_exists( $flag ) ) {
2409 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch, WordPress.PHP.NoSilencedErrors.Discouraged -- read by the pre-WP drop-in via file_exists(); must be a plain marker, not WP_Filesystem.
2410 @touch( $flag );
2411 }
2412 return;
2413 }
2414 if ( file_exists( $flag ) ) {
2415 // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal.
2416 @unlink( $flag );
2417 }
2418 }
2419
2420 /**
2421 * Write / remove the `.maintenance-active` sentinel next to the cache
2422 * files. The pre-WP drop-in checks for this marker and bails when present,
2423 * so a page cached while the site was live is NOT served during
2424 * maintenance / coming-soon mode — WordPress loads and renders the
2425 * maintenance screen instead. The Pro Maintenance-Cache module drives this
2426 * on the maintenance on/off transition. (FBS-82409 B1)
2427 *
2428 * @param bool $active True to arm the sentinel (entering maintenance),
2429 * false to clear it (site recovered).
2430 */
2431 public static function sync_maintenance_flag( bool $active ): void {
2432 $dir = XSPEED_CACHE_DIR;
2433 $flag = $dir . '/.maintenance-active';
2434 if ( $active ) {
2435 if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
2436 return;
2437 }
2438 if ( ! file_exists( $flag ) ) {
2439 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch, WordPress.PHP.NoSilencedErrors.Discouraged -- read by the pre-WP drop-in via file_exists(); must be a plain marker, not WP_Filesystem.
2440 @touch( $flag );
2441 }
2442 return;
2443 }
2444 if ( file_exists( $flag ) ) {
2445 // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal.
2446 @unlink( $flag );
2447 }
2448 }
2449
2450 /**
2451 * Reconcile every mobile_separate-dependent artifact to the current
2452 * setting. Called on boot and whenever the cache settings are saved, so
2453 * flipping mobile_separate at runtime can't leave the install in a
2454 * half-converted state.
2455 *
2456 * Three things must agree with the setting:
2457 * 1. the drop-in's `.mobile-separate` flag (sync_mobile_flag()),
2458 * 2. the device-blind server rewrite — present only when OFF
2459 * (static_rewrite_allowed()),
2460 * 3. the now-stale static-cache tree + page cache, which were keyed
2461 * under the old scheme and would serve wrong-device HTML.
2462 *
2463 * No-ops when the cache is disabled — there's nothing installed to
2464 * reconcile, and toggle() handles install/teardown itself.
2465 */
2466 public static function reconcile_mobile_separate(): void {
2467 self::sync_mobile_flag();
2468 // Keep the drop-in's view of the network's blog paths current — a
2469 // site added or removed changes which bucket its URLs belong to. (#6)
2470 if ( defined( 'XSPEED_CACHE_DIR' ) ) {
2471 self::sync_site_paths();
2472 }
2473
2474 // The rewrite/static reconciliation below needs the plugin's path
2475 // constants. They're absent in early-boot / unit-test contexts where
2476 // only the drop-in flag matters — bail to the flag-only behavior then.
2477 if ( ! defined( 'XSPEED_CACHE_STATIC_DIR' ) ) {
2478 return;
2479 }
2480
2481 // Only touch the rewrite + caches when caching is actually on.
2482 $opts = get_option( 'xspeed_options', array() );
2483 if ( empty( $opts['cache_enabled'] ) ) {
2484 return;
2485 }
2486
2487 $rewrite_present = self::rewrite_installed();
2488 $rewrite_wanted = self::static_rewrite_allowed();
2489
2490 // Did the thing that actually invalidates cache KEYS change?
2491 // mobile_separate buckets entries as |d / |m, so flipping it makes
2492 // stored entries mis-bucketed and they must go. A rewrite-state
2493 // mismatch from anything else (e.g. mod_headers detection, a hand-
2494 // edited .htaccess) changes no key at all — the same files are still
2495 // valid, they're just served by PHP instead of by the web server.
2496 // Purging there is what let one WP-CLI call wipe the whole cache on
2497 // every bootstrap. (#138)
2498 //
2499 // Read the setting from the SAME place static_rewrite_allowed() and
2500 // sync_mobile_flag() do — the cache module's settings, not the
2501 // top-level xspeed_options — or this marker would track a key that
2502 // never changes and a real flip would go unnoticed.
2503 $cache_opts = Settings_Manager::get( 'cache' );
2504 $mobile_now = ! empty( $cache_opts['mobile_separate'] );
2505 $mobile_last = get_option( 'xspeed_last_mobile_separate', null );
2506 $mobile_flipped = ( null !== $mobile_last && (bool) (int) $mobile_last !== $mobile_now );
2507
2508 if ( (string) (int) $mobile_now !== (string) $mobile_last ) {
2509 update_option( 'xspeed_last_mobile_separate', $mobile_now ? '1' : '0', false );
2510 }
2511
2512 if ( $rewrite_present === $rewrite_wanted ) {
2513 // Already consistent — nothing flipped, leave caches intact so a
2514 // plain settings save (e.g. expiry change) doesn't blow the cache.
2515 return;
2516 }
2517
2518 // Bring the rewrite into line with what this server actually supports.
2519 if ( $rewrite_wanted ) {
2520 self::install_rewrite();
2521 } else {
2522 self::remove_rewrite();
2523 }
2524
2525 // Only discard cache contents when the device bucketing changed.
2526 if ( $mobile_flipped ) {
2527 self::purge_all( 'mobile_separate changed' );
2528 }
2529 }
2530
2531 /**
2532 * Whether the server-level static-rewrite fast path may be used.
2533 *
2534 * The rewrite serves `{host}{path}/index.html` straight from the web
2535 * server, keyed only by host + path — it has no way to run our PHP
2536 * device detection, so it can't tell mobile from desktop. When
2537 * `mobile_separate` is on, a single static file would be shared across
2538 * devices and whoever primed it wins (mobile visitors could get desktop
2539 * HTML, or vice-versa). Rather than duplicate a wp_is_mobile()-equivalent
2540 * UA matcher into .htaccess AND the nginx snippet (three copies that
2541 * would inevitably drift), we simply DON'T engage the static rewrite when
2542 * mobile_separate is on. Requests then fall through to the PHP drop-in,
2543 * which buckets correctly — a small TTFB cost (~85ms vs ~30ms) paid only
2544 * on mobile-separate sites, in exchange for guaranteed correctness.
2545 *
2546 * LiteSpeed exclusion (2026-06-16): on LiteSpeed — OpenLiteSpeed in
2547 * particular — `.htaccess` CAN run our RewriteRule to serve the static
2548 * file, but its `.htaccess` engine ignores `mod_headers`, so we cannot
2549 * stamp the served response with `X-XSpeed-Cache: HIT`, AND there is no
2550 * `.htaccess` equivalent of nginx's per-location `access_log` to record
2551 * the hit. The result was a cache that worked but was invisible: no HIT
2552 * header and a hit-ratio frozen near 0%. Every OTHER server gives the
2553 * user a visible HIT header + a counted hit (nginx via add_header +
2554 * access_log in its snippet; Apache via the `<IfModule mod_headers.c>`
2555 * block in rewrite_block_lines(), WHEN that module is loaded — when it is
2556 * not, Apache takes this same drop-in fallback). To keep LiteSpeed
2557 * CONSISTENT with the rest, we route its hits
2558 * through the PHP drop-in instead — the drop-in emits
2559 * `X-XSpeed-Cache: HIT (php)` and calls Hit_Counter inline, exactly the
2560 * observable behavior the other servers get. The cost is the drop-in's
2561 * ~30ms TTFB vs the static path's ~10ms, paid only on LiteSpeed; in
2562 * exchange the dashboard hit-ratio and the response header finally tell
2563 * the truth there. (Apache keeps the static fast path — it honors the
2564 * header.) See maybe_emit_lscache_headers() for the paired LSCache
2565 * stand-down that stops LiteSpeed's own module from shadowing the
2566 * drop-in.
2567 */
2568 public static function static_rewrite_allowed(): bool {
2569 // LiteSpeed: drop-in serves hits (visible + counted) — see docblock.
2570 if ( Server::LITESPEED === Server::type() ) {
2571 return false;
2572 }
2573 // Apache without mod_headers is in EXACTLY the position LiteSpeed
2574 // is in above: it can run the RewriteRule and serve the static
2575 // file, but it cannot stamp `X-XSpeed-Cache` on the response, so
2576 // the hit is invisible to the user and uncountable by
2577 // Hit_Counter. The docblock above used to assert Apache "honors
2578 // mod_headers" and left it on the fast path unconditionally —
2579 // true only when the module is actually loaded. Fall back to the
2580 // drop-in when it isn't, trading ~10ms of TTFB for a hit that
2581 // shows up in the header and the ratio. (Field report: hit ratio
2582 // pinned at 0% on a working Apache cache.)
2583 if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) {
2584 return false;
2585 }
2586 $opts = Settings_Manager::get( 'cache' );
2587 return empty( $opts['mobile_separate'] );
2588 }
2589
2590 /**
2591 * Why the device-blind static rewrite is NOT installed, when it isn't.
2592 * Returns 'mobile_separate' when Separate Mobile Cache is the blocker
2593 * (the static file is one-per-URL, so it can't coexist with per-device
2594 * buckets), 'no_mod_headers' when Apache can't stamp the HIT header,
2595 * '' otherwise. Lets the dashboard explain the slow path instead of
2596 * silently falling back to PHP serving. (FBS-83145)
2597 *
2598 * Every refusal in static_rewrite_allowed() that is NOT self-explanatory
2599 * must have a branch here. Otherwise the Health card falls through to
2600 * "Block missing — toggle Enable Cache off and on to reinstall it",
2601 * advice that cannot work: the same condition that suppressed the write
2602 * suppresses the reinstall, and auto_heal() strips the block again on
2603 * the next admin page load. (Field report: Apache host with mod_headers
2604 * unloaded sat on the slow path with no way to find out why.)
2605 */
2606 /**
2607 * Qualify a raw probe result with what we already KNOW about config.
2608 *
2609 * probe_static_rewrite() writes its own file under the static-cache tree
2610 * and fetches that, which succeeds whenever the web server can serve a
2611 * static file at all — including when static_rewrite_allowed() is false
2612 * and no real page is on the static path. So `active: true` on its own is
2613 * not evidence that pages are being served statically.
2614 *
2615 * The reachable case is nginx with Separate Mobile Cache on: the snippet
2616 * lives in the server block and we cannot remove it, pages are
2617 * deliberately routed to the PHP drop-in, but the probe file is still
2618 * served directly.
2619 *
2620 * The Health panel learned this in 88b4b50; the CLI, REST and MCP paths
2621 * did not, so they kept reporting "active" in exactly that configuration.
2622 * Rather than repeat the reasoning at each call site, they now all come
2623 * through here.
2624 *
2625 * Deliberately does NOT consult rewrite_installed(): on nginx the fast
2626 * path is the pasted snippet and there is no .htaccess marker to find, so
2627 * requiring one would report every correctly-configured nginx site as
2628 * broken.
2629 *
2630 * @param array $probe Raw result from probe_static_rewrite().
2631 * @return array{active:bool,inconclusive:bool,reason:string,block_reason:string}
2632 */
2633 public static function qualify_rewrite_probe( array $probe ): array {
2634 $active = (bool) ( $probe['active'] ?? false );
2635 $inconclusive = (bool) ( $probe['inconclusive'] ?? false );
2636 $reason = (string) ( $probe['reason'] ?? '' );
2637 $block_reason = self::static_rewrite_block_reason();
2638
2639 // With page caching off there is nothing to serve, so `active` can
2640 // never be true here whatever the raw probe says. probe_static_rewrite()
2641 // writes its OWN file under the static tree and fetches that, which
2642 // succeeds whenever the server can serve a static file at all — and on
2643 // nginx the snippet is server-level, so it keeps succeeding after the
2644 // cache is switched off.
2645 //
2646 // block_reason() used to carry this meaning by accident: it returned
2647 // 'mobile_separate' with caching off, and the refusal branch below
2648 // forced active=false. Now that it correctly reports '' (nothing can
2649 // block a fast path that isn't in use), this consumer has to state the
2650 // condition itself — otherwise `wp xspeed cache recheck-rewrite` and
2651 // POST /cache/recheck-rewrite claim "the web server is serving cache
2652 // hits directly" on a site with no cache. That is a positive false
2653 // claim rather than a nag, i.e. worse than the bug being fixed.
2654 $cache_opts = Settings::get();
2655 if ( empty( $cache_opts['cache_enabled'] ) ) {
2656 return array(
2657 'active' => false,
2658 'inconclusive' => false,
2659 'reason' => 'Page caching is off, so there is no cache for the web server to serve.',
2660 'block_reason' => '',
2661 );
2662 }
2663
2664 // A known refusal outranks the probe, and also outranks
2665 // "inconclusive" — a blocked rewrite whose probe merely failed to
2666 // complete is still definitely blocked.
2667 if ( '' !== $block_reason ) {
2668 $active = false;
2669 $inconclusive = false;
2670 $reason = self::block_reason_text( $block_reason );
2671 }
2672
2673 return array(
2674 'active' => $active,
2675 'inconclusive' => $inconclusive,
2676 'reason' => $reason,
2677 'block_reason' => $block_reason,
2678 );
2679 }
2680
2681 /**
2682 * Human-readable explanation for a static_rewrite_block_reason() code.
2683 *
2684 * Each one has to say what to DO about it: "mobile_separate" alone tells
2685 * a user nothing, and the whole point of surfacing a refusal instead of
2686 * the probe verdict is that it is actionable.
2687 */
2688 public static function block_reason_text( string $code ): string {
2689 switch ( $code ) {
2690 case 'mobile_separate':
2691 return 'Separate Mobile Cache is on, which disables the device-blind static rewrite. Cache hits are served by PHP instead. If your site serves the same HTML to every device, turn it off in Cache settings for much faster hits.';
2692 case 'no_mod_headers':
2693 return "Apache's mod_headers is not loaded, so the static rewrite cannot mark its responses as cache hits. Enable mod_headers, or leave hits on the PHP path.";
2694 default:
2695 return sprintf( 'The static rewrite is disabled (%s).', $code );
2696 }
2697 }
2698
2699 public static function static_rewrite_block_reason(): string {
2700 // Nothing can be blocking the fast path when there is no cache to
2701 // serve from it. Without this the dashboard told users with page
2702 // caching switched OFF that Separate Mobile Cache "is disabling
2703 // faster static serving" — a fast path they were not using, about a
2704 // cache that did not exist. Every caller of this is a user-facing
2705 // explanation of why the rewrite is off, so "the cache is off" is
2706 // the honest answer, and it is silence. (#108)
2707 $opts = Settings::get();
2708 if ( empty( $opts['cache_enabled'] ) ) {
2709 return '';
2710 }
2711 if ( Server::LITESPEED === Server::type() ) {
2712 return ''; // Intended on LiteSpeed — not a "block".
2713 }
2714 if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) {
2715 return 'no_mod_headers';
2716 }
2717 $cache_opts = Settings_Manager::get( 'cache' );
2718 return ! empty( $cache_opts['mobile_separate'] ) ? 'mobile_separate' : '';
2719 }
2720
2721 /**
2722 * Whether migration flagged Separate Mobile Cache for user review. Set by
2723 * Migration::map_mobile_separate() when a source plugin (WP Rocket / WP
2724 * Super Cache / LiteSpeed) had its "separate mobile cache" option on: we
2725 * import it as OFF (to keep the device-blind static fast path) but record
2726 * this flag so the dashboard can invite the user to turn it back on only
2727 * if their site genuinely serves different HTML per device. (FBS-83145)
2728 */
2729 public static function mobile_separate_needs_review(): bool {
2730 // Same reasoning as static_rewrite_block_reason(): the invitation is
2731 // "turn this back on if your site needs it, to regain the fast path",
2732 // which is meaningless with page caching off — there is no fast path
2733 // to regain, and the equality probe behind the prompt would fetch
2734 // pages that aren't being cached. Gated here rather than at the two
2735 // payload call sites (Admin + Rest_Api) so `enabled`, `blocking` and
2736 // `needs_review` are consistently gated on the same condition. (#108)
2737 $opts = Settings::get();
2738 if ( empty( $opts['cache_enabled'] ) ) {
2739 return false;
2740 }
2741 $cache_opts = Settings_Manager::get( 'cache' );
2742 return ! empty( $cache_opts['mobile_separate_review'] );
2743 }
2744
2745 /**
2746 * Clear the review flag — called when the user has acted on the prompt
2747 * (dismissed it, or turned Separate Mobile Cache on/off deliberately) so
2748 * the dashboard callout doesn't nag forever. Writes the option directly
2749 * (bypassing Settings_Manager) so it never touches schema fields.
2750 */
2751 public static function clear_mobile_separate_review(): void {
2752 $stored = get_option( 'xspeed_module_cache', array() );
2753 if ( ! is_array( $stored ) || empty( $stored['mobile_separate_review'] ) ) {
2754 return;
2755 }
2756 unset( $stored['mobile_separate_review'] );
2757 update_option( 'xspeed_module_cache', $stored );
2758 }
2759
2760 /**
2761 * On-demand probe: does the homepage serve materially the same HTML to a
2762 * desktop and a mobile browser? Fetches home_url() twice over loopback —
2763 * once with a desktop User-Agent, once with a mobile one — strips
2764 * per-request noise (nonces, CSRF tokens, session ids, inline timestamps),
2765 * and compares. When identical, Separate Mobile Cache is almost certainly
2766 * unnecessary and the user can turn it off to regain the static fast path.
2767 *
2768 * NEVER run automatically (no page-load cost) — only from the dashboard
2769 * "Check now" button. Result is cached for 10 minutes so a double-click or
2770 * a re-render doesn't fire two more self-requests. (FBS-83145)
2771 *
2772 * @return array{ identical:bool, checked:bool, reason?:string, desktop_bytes?:int, mobile_bytes?:int }
2773 */
2774 public static function probe_mobile_equality(): array {
2775 $cached = get_transient( 'xspeed_mobile_equality_probe' );
2776 if ( is_array( $cached ) ) {
2777 return $cached;
2778 }
2779
2780 $home = home_url( '/' );
2781 $host = (string) wp_parse_url( $home, PHP_URL_HOST );
2782 if ( '' === $host ) {
2783 $result = array( 'identical' => false, 'checked' => false, 'reason' => 'home_url has no host' );
2784 set_transient( 'xspeed_mobile_equality_probe', $result, MINUTE_IN_SECONDS );
2785 return $result;
2786 }
2787
2788 // Match WP core's own mobile detection (wp_is_mobile) so the probe
2789 // reflects what the site would actually branch on. iPhone Safari for
2790 // mobile; a current desktop Chrome UA for desktop.
2791 $desktop_ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
2792 $mobile_ua = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
2793
2794 $is_local = function_exists( 'wp_get_environment_type' )
2795 && in_array( wp_get_environment_type(), array( 'local', 'development' ), true );
2796
2797 $fetch = static function ( string $ua ) use ( $home, $is_local ) {
2798 $resp = wp_remote_get(
2799 $home,
2800 array(
2801 'timeout' => 5,
2802 'sslverify' => ! $is_local,
2803 'redirection' => 2,
2804 // Bust any per-device cache so we compare freshly-rendered
2805 // HTML, and pass the device UA the site would branch on.
2806 'user-agent' => $ua,
2807 'headers' => array( 'Cache-Control' => 'no-cache' ),
2808 )
2809 );
2810 if ( is_wp_error( $resp ) || 200 !== (int) wp_remote_retrieve_response_code( $resp ) ) {
2811 return null;
2812 }
2813 return (string) wp_remote_retrieve_body( $resp );
2814 };
2815
2816 $desktop = $fetch( $desktop_ua );
2817 $mobile = $fetch( $mobile_ua );
2818
2819 if ( null === $desktop || null === $mobile ) {
2820 $result = array( 'identical' => false, 'checked' => false, 'reason' => 'could not fetch homepage twice' );
2821 set_transient( 'xspeed_mobile_equality_probe', $result, MINUTE_IN_SECONDS );
2822 return $result;
2823 }
2824
2825 $identical = self::normalize_html_for_diff( $desktop ) === self::normalize_html_for_diff( $mobile );
2826
2827 $result = array(
2828 'identical' => $identical,
2829 'checked' => true,
2830 'desktop_bytes' => strlen( $desktop ),
2831 'mobile_bytes' => strlen( $mobile ),
2832 );
2833 set_transient( 'xspeed_mobile_equality_probe', $result, 10 * MINUTE_IN_SECONDS );
2834 return $result;
2835 }
2836
2837 /**
2838 * Strip per-request noise from HTML so a desktop-vs-mobile diff reflects
2839 * real structural differences, not nonces / session ids / timestamps that
2840 * change on every render. Deliberately conservative: it normalizes the
2841 * handful of well-known noise sources and collapses whitespace, so a site
2842 * that truly serves different markup per device still compares as different.
2843 */
2844 private static function normalize_html_for_diff( string $html ): string {
2845 // Every rule here errs toward "they differ" being WRONG rather than
2846 // "they match" being wrong: this check only ever tells a user it is
2847 // SAFE to turn Separate Mobile Cache off, so a false "identical"
2848 // would cost them device-specific output. The risk of being too
2849 // conservative is milder but real — the useful answer never appears,
2850 // and the feature's whole pitch ("we'll prove it's safe to turn
2851 // off") silently never pays out. These close the gaps that made a
2852 // mismatch effectively guaranteed on an ordinary WordPress site. (#108)
2853 $patterns = array(
2854 // WP nonces in attribute or JSON form: data-nonce="…",
2855 // _wpnonce=…, "nonce":"…". The `[:=]` adjacency below misses
2856 // wp_nonce_field()'s own markup — `name="_wpnonce" value="ab…"`
2857 // puts `value=` between the key and the token — which is the
2858 // single most common nonce shape in WordPress, so that form is
2859 // matched explicitly first.
2860 '/name=["\']?(_wpnonce|_ajax_nonce)["\']?\s+value=["\']?[a-z0-9]{8,}/i',
2861 // CSP nonces on script/style tags. Base64, so uppercase and
2862 // +/= appear — the hex-only rules below can never match one,
2863 // and a CSP-enabled site therefore differed on every fetch.
2864 // MUST precede the generic nonce rule: that one stops at the
2865 // first non-alphanumeric, leaving the rest of the token behind
2866 // and the two responses still unequal.
2867 // The quotes are optional so HTML5's legal unquoted attribute
2868 // form (`<script nonce=AbCd+q/r=>`) is covered too — without
2869 // that it fell through to the generic rule, which is the exact
2870 // failure this rule exists to remove.
2871 '/\bnonce=(["\'])?[A-Za-z0-9+\/=_-]{8,}(?(1)\1)/',
2872 '/(_wpnonce|nonce|_ajax_nonce)["\']?\s*[:=]\s*["\']?[a-z0-9]{8,}/i',
2873 // Generic hex tokens: cache busters, session ids, md5/sha
2874 // digests. Was 16+, which left an 11-15 char gap above the
2875 // 10-char nonce rule.
2876 //
2877 // The token MUST contain at least one a-f letter. `[a-f0-9]`
2878 // also matches every decimal digit, so a bare `{10,}` erased
2879 // every 10+ digit INTEGER anywhere in the document — including
2880 // visible body text. A page whose desktop and mobile HTML
2881 // differed only by a per-device numeric id (an AdSense slot, an
2882 // A/B bucket, an analytics property) then compared as identical,
2883 // and the check told the user it was safe to switch off the very
2884 // setting keeping that output correct — the one direction this
2885 // function must never fail in. Decimal-only runs are left to the
2886 // bounded epoch rule below, which is deliberately narrower.
2887 //
2888 // Known, accepted (QA R2): a token whose letters all fall in a-f
2889 // reads as a digest, so a per-device `ABC1234567890` strips even
2890 // though it is an id, not a hash. Deliberately left open — the
2891 // alternatives all cost more than the bug:
2892 //
2893 // Token shape (lowercase-only, case-uniformity, a trailing
2894 // letter) cannot separate it. `ABC1234567890` and
2895 // `ABCDEF012345` — an uppercase digest this rule SHOULD strip —
2896 // are both all-hex, uniformly cased, letters-then-digits.
2897 // Each variant fixed the id only by sparing the digest.
2898 //
2899 // Letter density does separate them (23% letters vs 50%), but
2900 // measured over 2000 md5/sha1/sha256 samples, requiring letters
2901 // spread through the token leaves 21-67% of REAL digests
2902 // unmatched depending on the window. Digest noise is most of
2903 // what this function exists to remove, so that trade guts it.
2904 //
2905 // Context (protecting data-* attribute values from this rule)
2906 // works for ids and still strips digests in URLs, classes and
2907 // query strings — but regresses a CHANGING digest inside a
2908 // non-nonce data-* attribute, and needs a two-pass
2909 // hold/restore. Viable if R2 is ever worth pressing; its
2910 // failure at least errs toward "differ".
2911 //
2912 // An A-F-only prefix on a per-device id is rare, and the earlier
2913 // nonce rules already claim the data-nonce/_wpnonce shapes.
2914 '/\b(?=[a-f0-9]{10,}\b)[0-9]*[a-f][a-f0-9]*\b/i',
2915 // wp-generated unique ids (e.g. wp-block ids, aria ids).
2916 '/(id|for|aria-[a-z]+)="[^"]*-[0-9]{3,}"/i',
2917 // ISO-ish timestamps + epoch-looking numbers in query strings.
2918 '/\?ver=[0-9.]+/',
2919 '/[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.+Z-]+/',
2920 // Raw epoch seconds. The two fetches are sequential, so any
2921 // template printing time() guaranteed a mismatch.
2922 //
2923 // This is the ONLY rule that may strip a decimal-only run, so
2924 // its bound is load-bearing rather than decorative — every digit
2925 // it gives away is a class of per-device id it silently erases.
2926 // `1[0-9]{9}` was too loose: it claimed the whole
2927 // 1000000000-1999999999 range (2001-2033) to cover timestamps
2928 // nobody serves, and took every 10-digit AdSense slot, order id
2929 // and SKU beginning with 1 along with it — reproducing the exact
2930 // false-"identical" verdict the hex rule above was tightened to
2931 // stop. `1[6-9]` covers 2020-2033, which is the only span a live
2932 // site can actually print, and collides with roughly a tenth as
2933 // many ids.
2934 //
2935 // Not airtight — an id beginning 16-19 still collides. Closing
2936 // that properly means scoping this to places a timestamp really
2937 // appears (an attribute value, a query parameter, a JSON value)
2938 // rather than bare body text; the bound is the cheap 90% of it.
2939 '/\b1[6-9][0-9]{8}\b/',
2940 );
2941 $html = (string) preg_replace( $patterns, 'X', $html );
2942 // Collapse all whitespace so trivial formatting differences don't count.
2943 return trim( (string) preg_replace( '/\s+/', ' ', $html ) );
2944 }
2945
2946 public static function ensure_hits_log_file(): bool {
2947 // TWO writers append to this log, and an earlier fix conflated them:
2948 //
2949 // 1. nginx, via the server-level `access_log` directive in
2950 // nginx_snippet() — a DIFFERENT uid, which is why the file needs
2951 // to be world-writable there.
2952 // 2. the PHP drop-in (advanced-cache.php), on EVERY server. A hit it
2953 // serves bypasses WordPress entirely, so it can't call
2954 // Hit_Counter::record_hit() — appending here is the only way that
2955 // hit is ever counted.
2956 //
2957 // The nginx-only early return that used to sit at the top of this
2958 // method was fixing something real: chmod() on a file PHP doesn't own
2959 // raises "Operation not permitted", and off nginx that chmod buys
2960 // nothing. But it took directory creation with it, so on LiteSpeed
2961 // (which always serves via the drop-in), on Apache without mod_headers,
2962 // and anywhere mobile_separate forces the drop-in path, writer 2 was
2963 // appending to a file whose parent directory did not exist. The append
2964 // is @-suppressed and documented as non-fatal, so every one of those
2965 // hits vanished and the dashboard ratio sat at 0% forever.
2966 //
2967 // So: create the dir + file everywhere, and keep only the chmod gated
2968 // to nginx.
2969 $dir = self::hits_log_dir();
2970 if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
2971 return false;
2972 }
2973
2974 $is_nginx = ( Server::NGINX === Server::type() );
2975
2976 if ( $is_nginx ) {
2977 // Ensure the dir is traversable + writable by a different-uid nginx.
2978 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- nginx (a separate uid in multi-container setups) must be able to create/append the log; WP_Filesystem layers ownership overrides that defeat that intent.
2979 @chmod( $dir, 0777 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort; the access_log just stays empty if it fails.
2980 }
2981
2982 $path = self::hits_log_path();
2983 if ( ! file_exists( $path ) ) {
2984 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch -- See docblock: must be a plain touch, not WP_Filesystem.
2985 @touch( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- non-fatal helper; failures already covered by the dir check.
2986 }
2987
2988 if ( $is_nginx ) {
2989 // World-writable so a different-uid nginx can append HIT lines.
2990 // Off nginx the drop-in appends as the same uid that owns the file,
2991 // so this is unnecessary — and would emit the "Operation not
2992 // permitted" warnings the old early return was added to silence.
2993 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- See docblock.
2994 @chmod( $path, 0666 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort.
2995 }
2996
2997 return file_exists( $path );
2998 }
2999
3000 public static function nginx_snippet(): ?string {
3001 if ( Server::NGINX !== Server::type() ) {
3002 return null;
3003 }
3004 $rel = '/' . ltrim( str_replace( ABSPATH, '/', XSPEED_CACHE_STATIC_DIR ), '/' );
3005 $rel = rtrim( $rel, '/' );
3006
3007 // WP-Rocket-canonical pattern: every condition lives at
3008 // SERVER level (outside any location block). Each one appends
3009 // a tag to $xspeed_no_cache; the final check is a single
3010 // string-equality against the unmodified default "no-cache".
3011 // Only when ALL conditions pass does the rewrite fire,
3012 // jumping the request to the static file's URL. nginx then
3013 // restarts location matching against the new path, where
3014 // regular static-file serving takes over.
3015 //
3016 // Why server-level + a single rewrite (instead of try_files
3017 // inside `location /`): nginx's well-documented "if is evil"
3018 // quirk silently disables `try_files`'s last fallback when
3019 // any `if` in the same location is true. Moving the `if`s
3020 // outside any location dodges the trap completely, because
3021 // server-level rewrite is the documented stable path.
3022 //
3023 // `last` (not `break`) restarts location matching — required
3024 // so the rewritten static-file URI gets served via the normal
3025 // static-file location, not re-matched against `location /`
3026 // where our own rewrite would loop.
3027 //
3028 // The cache existence check is the LAST condition in the
3029 // chain so when the file isn't cached, $xspeed_no_cache
3030 // gets a "-nofile" tag and the rewrite is skipped — the
3031 // request falls through to whatever `location /` the user
3032 // already had (typically `try_files $uri $uri/ /index.php?$args;`).
3033 // Absolute path to the hit-log file from the nginx process's
3034 // filesystem view. Nginx's `access_log buffer=N flush=Ns` form
3035 // requires a literal path — `$document_root` variables are
3036 // rejected — so PHP computes it. Lives under uploads/ (NOT the
3037 // cache dir): a cache purge or uninstall deletes the cache dir,
3038 // which would orphan this directive's parent directory and make
3039 // `nginx -t` fail [emerg] for EVERY vhost on the host
3040 // (FBS-82478). uploads/ survives both, so the directive can
3041 // never take nginx down. Works on every topology where the nginx
3042 // process shares a filesystem with PHP (container or host).
3043 $hits_abs = self::hits_log_path();
3044
3045 $lines = array();
3046 $lines[] = '# xSpeed static cache — paste at server level, above location / { }.';
3047 // Cache host must match the on-disk dir PHP writes: store_static() /
3048 // static_host() take HTTP_HOST and strip every char outside
3049 // [a-zA-Z0-9.\-] — i.e. it removes the colon but KEEPS the port digits
3050 // (localhost:8192 → localhost8192). nginx's own $host can't reproduce
3051 // that: $host has the port already stripped ENTIRELY (→ localhost), so
3052 // the -f check looks for localhost/... while PHP wrote localhost8192/...
3053 // and the rewrite never fires on a non-standard port. Derive
3054 // $xspeed_host from $http_host (which keeps the port) and drop just the
3055 // colon, so it equals the PHP dir on every port. On standard ports
3056 // $http_host has no colon, so $xspeed_host == $host == the bare domain.
3057 $lines[] = 'set $xspeed_host $http_host;'; // default: no port → unchanged (e.g. example.com)
3058 $lines[] = 'if ($http_host ~ "^([^:]+):(\\d+)$") { set $xspeed_host $1$2; }'; // host:port → hostport (matches PHP static_host())
3059 $lines[] = 'set $xspeed_no_cache "no-cache";';
3060 $lines[] = 'if ($request_method != GET) { set $xspeed_no_cache "$xspeed_no_cache-method"; }';
3061 $lines[] = 'if ($args) { set $xspeed_no_cache "$xspeed_no_cache-args"; }';
3062 // Cookie + user-agent exclusions, generated from the user's actual
3063 // settings rather than a hardcoded list. Before this, the rule
3064 // tested three fixed cookie names and no user agent at all, so
3065 // every excluded_cookies / bypass_user_agents entry applied only
3066 // while a page was cold — on a warm page nginx served the shared
3067 // anonymous copy to carts, members and bypassed bots alike. The
3068 // three historical names survive as a floor inside cookie_rule().
3069 // `~*` is case-insensitive, matching PHP's stripos()/glob checks.
3070 $cache_opts = Settings_Manager::get( 'cache' );
3071 $cookie_rule = Server_Rules::cookie_rule(
3072 is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array()
3073 );
3074 $lines[] = 'if ($http_cookie ~* "(' . $cookie_rule['regex'] . ')") { set $xspeed_no_cache "$xspeed_no_cache-cookie"; }';
3075
3076 $ua_rule = Server_Rules::user_agent_rule(
3077 is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array()
3078 );
3079 // Emitted only when the list is non-empty — an empty alternation
3080 // would compile to `(...)` matching every request and disable the
3081 // fast path entirely.
3082 if ( '' !== $ua_rule['regex'] ) {
3083 $lines[] = 'if ($http_user_agent ~* "(' . $ua_rule['regex'] . ')") { set $xspeed_no_cache "$xspeed_no_cache-ua"; }';
3084 }
3085 $lines[] = 'if (!-f "$document_root' . $rel . '/$xspeed_host$uri/index.html") { set $xspeed_no_cache "$xspeed_no_cache-nofile"; }';
3086 // Neither `add_header` nor `access_log` is allowed inside an `if{}`
3087 // at server level (nginx rejects with "directive is not allowed
3088 // here"). The logging therefore lives in a `location` block that
3089 // matches the rewritten URI after `rewrite … last;` restarts
3090 // location matching. Every HIT lands there exactly once, every
3091 // MISS / PHP-served request never matches it.
3092 $lines[] = 'if ($xspeed_no_cache = "no-cache") {';
3093 $lines[] = ' rewrite ^ ' . $rel . '/$xspeed_host$uri/index.html last;';
3094 $lines[] = '}';
3095 $lines[] = '';
3096 $lines[] = '# Serve + log the cached HIT — `^~` is required so this beats any regex location.';
3097 $lines[] = 'location ^~ ' . $rel . '/ {';
3098 $lines[] = ' internal;';
3099 // LITERAL log path (not `set $var; access_log $var`). The variable form
3100 // makes nginx open the log lazily per-request and SILENTLY drop the
3101 // line if the open fails — so on a working host hits were served
3102 // (X-XSpeed-Cache fires regardless) but nothing was ever written and
3103 // the hit ratio sat at 0%. A literal path makes nginx open the file at
3104 // config load and actually log every hit.
3105 //
3106 // Deleting the log FILE is still safe with a literal path: nginx
3107 // recreates it on the next write/reload and `nginx -t` stays green
3108 // (verified). The only thing that [emerg]s `nginx -t` is a missing
3109 // parent DIRECTORY — and the log lives under uploads/xspeed/, which
3110 // survives cache purge + uninstall, and which ensure_hits_log_file()
3111 // (run on every admin_init via auto_heal) recreates if it ever goes
3112 // missing. So: hits are logged, and a user deleting the log can't take
3113 // nginx down.
3114 $lines[] = ' access_log ' . $hits_abs . ' combined buffer=16k flush=5s;';
3115 $lines[] = ' add_header X-XSpeed-Cache "HIT (nginx)" always;';
3116 $lines[] = '}';
3117 return implode( "\n", $lines );
3118 }
3119
3120 /**
3121 * Aggregate every enabled module's nginx_directives() into one
3122 * pasteable server-block snippet. Replaces the per-module "paste
3123 * this snippet" notices with a single consolidated paste — every
3124 * future feature toggle just regenerates this output.
3125 *
3126 * Returns null on non-nginx hosts (nothing to paste).
3127 *
3128 * Sections render in module-registration order so the layout stays
3129 * predictable; each module gets a comment header `# <slug>`.
3130 */
3131 public static function full_nginx_server_block(): ?string {
3132 if ( Server::NGINX !== Server::type() ) {
3133 return null;
3134 }
3135
3136 $blocks = array();
3137 foreach ( Module_Registry::all() as $module ) {
3138 $directives = $module->nginx_directives();
3139 if ( ! is_string( $directives ) || '' === trim( $directives ) ) {
3140 continue;
3141 }
3142 $blocks[] = "# === " . $module->slug() . " ===\n" . rtrim( $directives );
3143 }
3144
3145 if ( empty( $blocks ) ) {
3146 return null;
3147 }
3148
3149 $header = "# xSpeed unified nginx config — paste into `server { }`, above `location / { }`; re-paste after toggling features.\n";
3150
3151 return $header . "\n" . implode( "\n\n", $blocks ) . "\n";
3152 }
3153
3154 /**
3155 * Tell LiteSpeed's LSCache module to stand down on the cache-miss
3156 * render path.
3157 *
3158 * History: this method used to emit X-LiteSpeed-Cache-Control:
3159 * public,max-age=N + X-LiteSpeed-Tag, handing caching to the server's
3160 * LSCache store. That delegation backfired — once LSCache cached a
3161 * page it served every subsequent request from its OWN store and
3162 * intercepted the request before our site-root .htaccess static
3163 * rewrite could run. Net effect on LiteSpeed hosts: no X-XSpeed-Cache
3164 * header, our static-cache tree never served, the HIT log never
3165 * written (hit ratio frozen at 0%), and the Health probe reporting a
3166 * false "cache running on PHP fallback" because it never saw an
3167 * xSpeed-served response.
3168 *
3169 * xSpeed now owns the cache on LiteSpeed exactly as it does on Apache:
3170 * our `.htaccess` mod_rewrite block serves hits straight from the
3171 * static-cache tree (with the X-XSpeed-Cache header + access-log HIT
3172 * accounting), and PHP/the drop-in is the fallback. To guarantee
3173 * LSCache doesn't shadow that with its own copy — some LiteSpeed
3174 * configs cache by default — we send an explicit `no-cache` control so
3175 * the server defers to our rewrite. Skipped when the LiteSpeed Cache
3176 * plugin is active (it owns its own header policy; our Conflict
3177 * registry handles that coexistence separately).
3178 */
3179 public static function maybe_emit_lscache_headers(): void {
3180 if ( headers_sent() ) {
3181 return;
3182 }
3183 if ( Server::LITESPEED !== Server::type() ) {
3184 return;
3185 }
3186 // is_plugin_active() lives in wp-admin/includes/plugin.php which
3187 // isn't auto-loaded on front-end requests. Use the option layer
3188 // directly to avoid pulling in admin code from a render path.
3189 $active = (array) get_option( 'active_plugins', array() );
3190 if ( in_array( 'litespeed-cache/litespeed-cache.php', $active, true ) ) {
3191 return;
3192 }
3193
3194 // Explicitly opt this response OUT of LSCache so the server can't
3195 // shadow our static-rewrite cache with its own internal copy.
3196 header( 'X-LiteSpeed-Cache-Control: no-cache' );
3197 }
3198
3199 /**
3200 * Restore the drop-in + WP_CACHE constant for a site that had caching
3201 * ON before this activation — and ONLY for such a site.
3202 *
3203 * WordPress runs an upgrade as deactivate → wipe plugin files →
3204 * install → activate. The wipe takes advanced-cache.php with it, so
3205 * without this the site serves 100% uncached from the moment the
3206 * update finishes until the next authenticated wp-admin page load
3207 * (auto_heal() is on admin_init). On a site whose admin logs in
3208 * rarely that window is hours or days of silent cache loss, while
3209 * the dashboard still reports cache_enabled = true. (FBS field
3210 * report against 1.1.2 / Pro 1.0.5.)
3211 *
3212 * The `cache_enabled` guard is the whole contract: a FRESH install
3213 * has the option unset, so activation writes nothing and the user
3214 * still opts in explicitly through Cache::toggle() via the
3215 * /cache/toggle REST endpoint. We only ever put back state the user
3216 * already chose — repair, never a new install path. This is what
3217 * keeps us on the right side of the "don't create drop-ins the user
3218 * didn't ask for" guideline while matching what WP Rocket, W3 Total
3219 * Cache and WP Super Cache all do on activation.
3220 *
3221 * @return bool True when a restore was performed.
3222 */
3223 public static function restore_dropin_if_enabled(): bool {
3224 if ( defined( 'WP_INSTALLING' ) && WP_INSTALLING ) {
3225 return false;
3226 }
3227
3228 // The user's saved choice. Absent/false on a fresh install => no
3229 // drop-in is written and nothing touches wp-config.php.
3230 $opts = get_option( 'xspeed_options', array() );
3231 if ( empty( $opts['cache_enabled'] ) ) {
3232 return false;
3233 }
3234
3235 $restored = false;
3236
3237 // Only (re)install when the drop-in is missing, foreign, or an
3238 // older version of ours — never rewrite a current, healthy file.
3239 $target = WP_CONTENT_DIR . '/advanced-cache.php';
3240 $needs = true;
3241 if ( file_exists( $target ) ) {
3242 $contents = @file_get_contents( $target ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Best-effort read; a failure just means we reinstall.
3243 if ( is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' ) ) {
3244 $source = @file_get_contents( XSPEED_DIR . 'includes/advanced-cache.php' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Same.
3245 $needs = self::dropin_version( $contents ) < self::dropin_version( is_string( $source ) ? $source : '' );
3246 }
3247 }
3248 if ( $needs && self::install_dropin() ) {
3249 $restored = true;
3250 }
3251
3252 // WP_CACHE lives in wp-config.php, which the upgrade doesn't touch —
3253 // but a foreign cache plugin or a hand-edit can drop it, and without
3254 // it core never loads the drop-in at all.
3255 if ( ! defined( 'WP_CACHE' ) || ! WP_CACHE ) {
3256 if ( self::set_wp_cache_constant( true ) ) {
3257 $restored = true;
3258 }
3259 }
3260
3261 if ( $restored ) {
3262 Activity_Log::record(
3263 'cache_dropin_restored',
3264 'Cache drop-in restored after a plugin update — caching was already enabled.',
3265 Activity_Log::SUCCESS
3266 );
3267 }
3268
3269 return $restored;
3270 }
3271
3272 /**
3273 * Reconcile drop-in + WP_CACHE + rewrite block with the user's
3274 * saved choice. Runs on admin_init. Cheap when nothing's wrong
3275 * (one option read + a handful of file_exists / defined checks);
3276 * writes only when state has drifted (typical cause: plugin
3277 * upgrade wiped the drop-in, foreign plugin removed our WP_CACHE
3278 * define, or someone hand-edited .htaccess).
3279 *
3280 * Skipped during the WP plugin updater run so we don't race
3281 * the upgrader's own filesystem operations.
3282 */
3283 public static function auto_heal(): void {
3284 if ( defined( 'WP_INSTALLING' ) && WP_INSTALLING ) {
3285 return;
3286 }
3287 if ( wp_doing_ajax() || wp_doing_cron() ) {
3288 return;
3289 }
3290
3291 $opts = get_option( 'xspeed_options', array() );
3292 if ( empty( $opts['cache_enabled'] ) ) {
3293 return;
3294 }
3295
3296 $dropin_target = WP_CONTENT_DIR . '/advanced-cache.php';
3297 $dropin_ours = false;
3298 $dropin_stale = false;
3299 if ( file_exists( $dropin_target ) ) {
3300 $contents = @file_get_contents( $dropin_target ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
3301 $dropin_ours = is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' );
3302 // Reinstall when OUR drop-in is an older version than the source —
3303 // the marker alone can't distinguish an old copy from a new one, so
3304 // a serve-logic change (e.g. the .meta read for 404s/feeds) would
3305 // otherwise never reach existing cache-enabled sites until a manual
3306 // cache toggle. (FBS-82406/82407)
3307 if ( $dropin_ours ) {
3308 $dropin_stale = self::dropin_version( (string) $contents ) < self::dropin_version( @file_get_contents( XSPEED_DIR . 'includes/advanced-cache.php' ) ?: '' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
3309 }
3310 }
3311
3312 if ( ! $dropin_ours || $dropin_stale ) {
3313 self::install_dropin();
3314 }
3315
3316 if ( ! defined( 'WP_CACHE' ) || ! WP_CACHE ) {
3317 self::set_wp_cache_constant( true );
3318 }
3319
3320 // Rewrite block goes last. It's what turns the static-cache
3321 // tree into a PHP-bypass — every cache hit served by the web
3322 // server directly. Without it we still cache, just at drop-in
3323 // speed (~85ms TTFB) instead of static-file speed (~25-40ms).
3324 //
3325 // Reconcile against mobile_separate: the rewrite is device-blind, so
3326 // it must be ABSENT when mobile_separate is on and PRESENT otherwise.
3327 // auto_heal() runs periodically, so it also repairs a rewrite that
3328 // was left installed before mobile_separate was switched on.
3329 if ( self::static_rewrite_allowed() ) {
3330 if ( ! self::rewrite_installed() ) {
3331 self::install_rewrite();
3332 }
3333 } elseif ( self::rewrite_installed() ) {
3334 self::remove_rewrite();
3335 }
3336
3337 // HITs log file — nginx writes one line per HIT served directly
3338 // (see nginx_snippet()), Cache::get_stats() drains the file via
3339 // Hit_Counter::collect_nginx_log_hits(). If the file vanishes
3340 // (plugin upgrade wiped wp-content/cache/), nginx errors silently
3341 // on the access_log directive and the counter stays at 0.
3342 self::ensure_hits_log_file();
3343 }
3344
3345 /**
3346 * Keep the generic bypass cookie in sync with PHP's caching verdict.
3347 *
3348 * The server config tests exactly one cookie name (Server_Rules::
3349 * BYPASS_COOKIE) forever, and PHP decides what that name means. Adding
3350 * a new excluded cookie therefore needs no config change and no nginx
3351 * reload — the reason this exists.
3352 *
3353 * Session cookie (expiry 0) so it dies with the browser session, and
3354 * deliberately NOT HttpOnly-sensitive: it carries no identity, only the
3355 * boolean "don't serve this visitor a shared cached page".
3356 *
3357 * Honest limit: this can only ever help a visitor PHP has already seen
3358 * once. A bot's first request to a warm page never reaches PHP, which
3359 * is why user-agent rules are still written into the server config
3360 * rather than relying on this.
3361 *
3362 * @param bool $bypass Whether this visitor must skip the cache.
3363 */
3364 private static function sync_bypass_cookie( bool $bypass ): void {
3365 if ( headers_sent() ) {
3366 return;
3367 }
3368
3369 $name = Server_Rules::BYPASS_COOKIE;
3370 $has = isset( $_COOKIE[ $name ] );
3371
3372 // Only touch the header when the state actually changes — a
3373 // Set-Cookie on every request would make the response uncacheable
3374 // for intermediary caches and add noise to every hit.
3375 if ( $bypass === $has ) {
3376 return;
3377 }
3378
3379 $path = defined( 'COOKIEPATH' ) && COOKIEPATH ? COOKIEPATH : '/';
3380 $domain = defined( 'COOKIE_DOMAIN' ) ? COOKIE_DOMAIN : '';
3381
3382 if ( $bypass ) {
3383 setcookie( $name, '1', 0, $path, (string) $domain, is_ssl(), false );
3384 $_COOKIE[ $name ] = '1';
3385 } else {
3386 setcookie( $name, '', time() - 3600, $path, (string) $domain, is_ssl(), false );
3387 unset( $_COOKIE[ $name ] );
3388 }
3389 }
3390
3391 /**
3392 * Build the .htaccess rules that map cacheable requests to the
3393 * static-cache tree. Conditions are deliberately strict: GET only,
3394 * empty query string, no session/comment-author/post-password
3395 * cookie, and the static file must exist on disk. Anything that
3396 * fails one of these falls through to PHP and the drop-in / full
3397 * WordPress path.
3398 *
3399 * @return string[] Lines for insert_with_markers().
3400 */
3401 public static function rewrite_block_lines(): array {
3402 // Path relative to ABSPATH so the rule lives in the site-root
3403 // .htaccess regardless of where wp-content sits. WP_CONTENT_DIR
3404 // can be moved, so we compute the document-root-relative form
3405 // at install time and bake it into the rule.
3406 $rel = str_replace( ABSPATH, '/', XSPEED_CACHE_STATIC_DIR );
3407 $rel = '/' . ltrim( $rel, '/' );
3408 $rel = rtrim( $rel, '/' );
3409
3410 // Cookie + user-agent exclusions generated from the live settings.
3411 // See the matching block in nginx_snippet() — same generator, same
3412 // floor, so both servers enforce an identical policy. Apache reads
3413 // .htaccess on every request and we already self-heal this file, so
3414 // Apache/LiteSpeed users get the fix on upgrade with no action.
3415 $cache_opts = Settings_Manager::get( 'cache' );
3416 $cookie_rule = Server_Rules::cookie_rule(
3417 is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array()
3418 );
3419 $ua_rule = Server_Rules::user_agent_rule(
3420 is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array()
3421 );
3422
3423 $lines = array(
3424 '<IfModule mod_rewrite.c>',
3425 ' RewriteEngine On',
3426 ' RewriteCond %{REQUEST_METHOD} ^GET$',
3427 ' RewriteCond %{QUERY_STRING} ^$',
3428 ' RewriteCond %{HTTP_COOKIE} !(' . $cookie_rule['regex'] . ') [NC]',
3429 );
3430
3431 // Only emit the UA condition when there's something to match —
3432 // `!()` would negate an always-true empty match and refuse every
3433 // request, silently disabling the static path.
3434 if ( '' !== $ua_rule['regex'] ) {
3435 // Quoted, because RewriteCond is whitespace-delimited and real
3436 // user-agent fragments contain spaces ("Mozilla/5.0 (compatible").
3437 // Unquoted, a space adds an argument and Apache answers every
3438 // request with a 500 — and because .htaccess is parsed per
3439 // request, `httpd -t` still reports Syntax OK. Server_Rules has
3440 // already excluded quotes and backslashes from the alternation,
3441 // so the closing quote here cannot be escaped away.
3442 $lines[] = ' RewriteCond %{HTTP_USER_AGENT} "!(' . $ua_rule['regex'] . ')" [NC]';
3443 }
3444
3445 return array_merge(
3446 $lines,
3447 array(
3448 // Capture REQUEST_URI without its trailing slash into %1.
3449 // store_static() writes `{host}{uri-without-trailing-slash}/index.html`,
3450 // so this normalization lets `/blog/` and `/blog` both hit
3451 // the same cache file without producing the double-slash
3452 // path that would skip the -f check below.
3453 ' RewriteCond %{REQUEST_URI} ^(.*?)/?$',
3454 ' RewriteCond %{DOCUMENT_ROOT}' . $rel . '/%{HTTP_HOST}%1/index.html -f',
3455 // Pattern is `^`, NOT `.`. The per-directory rewrite engine
3456 // strips the leading slash before matching, so the HOMEPAGE
3457 // request `/` arrives here as an EMPTY path. `.` requires at
3458 // least one character and therefore never matches the homepage
3459 // — on LiteSpeed (which honors this strictly) the front page
3460 // fell through to PHP while every inner page rewrote fine.
3461 // `^` matches the empty string AND any non-empty path, so it
3462 // covers `/` and `/blog` alike. (Confirmed on OpenLiteSpeed
3463 // 1.8: `.` → homepage served by PHP drop-in; `^` → served
3464 // directly from the static file.)
3465 ' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [L]',
3466 '</IfModule>',
3467 // Mark the statically-served response as a cache HIT.
3468 //
3469 // A file served by the rewrite above bypasses PHP entirely, so
3470 // this directive is the ONLY thing that can identify it as
3471 // cached — both for the user reading response headers and for
3472 // Hit_Counter, which reconciles static hits from the access
3473 // log. Without it the cache works perfectly and reports a 0%
3474 // hit ratio, which reads as "the plugin is broken". (Field
3475 // report against 1.1.2: homepage served byte-identical from
3476 // the static tree, no X-XSpeed-Cache header on any response.)
3477 //
3478 // `always` so the header is set on the 200 from the rewritten
3479 // file, not only on the successful-response table. The
3480 // <IfModule> guard keeps a server without mod_headers from
3481 // 500ing on an unknown directive — on such a host the header
3482 // is silently dropped, which is exactly why
3483 // static_rewrite_allowed() refuses the static path there and
3484 // routes hits through the drop-in instead.
3485 '<IfModule mod_headers.c>',
3486 ' <FilesMatch "\\.html$">',
3487 ' Header always set X-XSpeed-Cache "HIT (static)"',
3488 ' </FilesMatch>',
3489 '</IfModule>',
3490 )
3491 );
3492 }
3493
3494 /**
3495 * Active probe that confirms the web-server static-rewrite path is
3496 * actually serving cached files. Writes a probe file with a random
3497 * nonce, fetches it over HTTP at its public URL, and checks whether
3498 * the response was served directly by the web server (Last-Modified
3499 * + ETag headers + no X-Powered-By: PHP).
3500 *
3501 * Server-agnostic: same probe works for nginx (snippet pasted) and
3502 * Apache / LiteSpeed (.htaccess block installed). If the rewrite
3503 * isn't engaged, the request falls through to WordPress and PHP
3504 * adds its own headers, which the probe detects and reports.
3505 *
3506 * Throttled via a 5-minute transient — we never want this running
3507 * on every Health card paint.
3508 *
3509 * @return array{active:bool, reason:string, code?:int, php?:bool, expires?:int}
3510 */
3511 /**
3512 * @param bool $allow_probe When false (the default), return ONLY a cached
3513 * result and never make an HTTP request — so admin page loads are never
3514 * blocked by the loopback probe. The actual HTTP probe only runs when a
3515 * caller explicitly opts in (the Health tab / cron). Previously this ran
3516 * synchronously on every dashboard bootstrap, so a slow/timing-out
3517 * loopback request added up to `timeout` seconds to admin page loads on
3518 * hosts that block self-requests. (FBS-82142)
3519 */
3520 /**
3521 * Discard the cached probe result and run a fresh one.
3522 *
3523 * Without this there was no way to re-check: the result sat in a transient
3524 * for five minutes and nothing ever deleted it, so a user who fixed their
3525 * nginx config kept seeing "nginx detected — configure for max cache speed"
3526 * with no means of confirming the fix worked. (FBS-84012)
3527 */
3528 public static function recheck_static_rewrite(): array {
3529 delete_transient( 'xspeed_rewrite_probe' );
3530 return self::probe_static_rewrite( true );
3531 }
3532
3533 public static function probe_static_rewrite( bool $allow_probe = false ): array {
3534 $cached = get_transient( 'xspeed_rewrite_probe' );
3535 if ( is_array( $cached ) ) {
3536 return $cached;
3537 }
3538 // No cached result yet and the caller doesn't want to pay for a live
3539 // HTTP probe (e.g. the admin bootstrap): report "pending" without
3540 // blocking. The Health tab will run the real probe on demand.
3541 if ( ! $allow_probe ) {
3542 return array( 'active' => false, 'reason' => 'probe pending', 'pending' => true );
3543 }
3544
3545 $home = home_url( '/' );
3546 $host = (string) wp_parse_url( $home, PHP_URL_HOST );
3547 if ( '' === $host ) {
3548 $result = array( 'active' => false, 'reason' => 'home_url has no host' );
3549 set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
3550 return $result;
3551 }
3552
3553 // Use a randomised path AND nonce so a stale CDN cache entry
3554 // from a prior probe can never make a broken install look
3555 // healthy. Path is namespaced under __xspeed_probe__ so the
3556 // directory listing stays obvious if cleanup misfires.
3557 $slug = wp_generate_password( 12, false, false );
3558 $nonce = wp_generate_password( 24, false, false );
3559 $probe_dir = XSPEED_CACHE_STATIC_DIR . '/' . $host . '/__xspeed_probe__/' . $slug;
3560 $probe_file = $probe_dir . '/index.html';
3561 $probe_url = trailingslashit( $home ) . '__xspeed_probe__/' . $slug . '/';
3562
3563 if ( ! file_exists( $probe_dir ) ) {
3564 wp_mkdir_p( $probe_dir );
3565 }
3566 if ( ! is_dir( $probe_dir ) ) {
3567 $result = array( 'active' => false, 'reason' => 'cannot create probe dir' );
3568 set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
3569 return $result;
3570 }
3571 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin credentials we may not have here; the file is in our own cache dir.
3572 file_put_contents( $probe_file, $nonce, LOCK_EX );
3573
3574 // Verify TLS by default — disabling it site-wide is a needless MITM
3575 // exposure (FBS-82142). Only relax verification in local/dev
3576 // environments, where self-signed certs are common and there's no
3577 // real attacker in the loop.
3578 $is_local = function_exists( 'wp_get_environment_type' )
3579 && in_array( wp_get_environment_type(), array( 'local', 'development' ), true );
3580 $resp = wp_remote_get(
3581 $probe_url,
3582 array(
3583 // 3s cap so a host that hangs on loopback self-requests can't
3584 // stall the caller for long; the result/error is cached so we
3585 // don't repeat the wait every minute.
3586 'timeout' => 3,
3587 'sslverify' => ! $is_local,
3588 'redirection' => 0,
3589 'headers' => array( 'Cache-Control' => 'no-cache' ),
3590 )
3591 );
3592
3593 // Best-effort cleanup so we don't accumulate probe dirs even
3594 // if subsequent calls all hit the transient.
3595 if ( file_exists( $probe_file ) ) {
3596 wp_delete_file( $probe_file );
3597 }
3598 if ( is_dir( $probe_dir ) ) {
3599 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Best-effort probe-dir cleanup; WP_Filesystem needs admin credentials we don't have here.
3600 @rmdir( $probe_dir );
3601 }
3602
3603 if ( is_wp_error( $resp ) ) {
3604 $result = array(
3605 'active' => false,
3606 // The request never completed, so we learned NOTHING about the
3607 // rewrite. Flagged inconclusive so the UI doesn't tell the user
3608 // to configure a server that may already be configured — a
3609 // blocked loopback, a self-signed cert, or a timeout is a probe
3610 // failure, not a missing rewrite. (FBS-84012)
3611 'inconclusive' => true,
3612 'reason' => 'http error: ' . $resp->get_error_message(),
3613 );
3614 // Cache the failure for the full 5 minutes (not 1) so a host that
3615 // times out on the loopback probe isn't re-probed — and re-stalled
3616 // — on every page load within the window. (FBS-82142)
3617 set_transient( 'xspeed_rewrite_probe', $result, 5 * MINUTE_IN_SECONDS );
3618 return $result;
3619 }
3620
3621 $code = (int) wp_remote_retrieve_response_code( $resp );
3622 $body = (string) wp_remote_retrieve_body( $resp );
3623 $ua_php = '' !== (string) wp_remote_retrieve_header( $resp, 'x-powered-by' );
3624 $has_etag = '' !== (string) wp_remote_retrieve_header( $resp, 'etag' )
3625 || '' !== (string) wp_remote_retrieve_header( $resp, 'last-modified' );
3626 $match = trim( $body ) === $nonce;
3627
3628 // "Active" = the web server served our raw nonce bytes back
3629 // AND emitted the static-serve markers (ETag / Last-Modified)
3630 // AND didn't add an X-Powered-By: PHP header. All three are
3631 // individually noisy; together they're conclusive.
3632 $active = $match && $has_etag && ! $ua_php && 200 === $code;
3633
3634 /*
3635 * `inconclusive` separates "we proved the rewrite isn't serving" from
3636 * "the probe couldn't tell". Only the former should drive a
3637 * configure-your-server banner; the latter previously rendered the
3638 * same alarming copy at a user who had already configured nginx
3639 * correctly, and there was no way to clear it. (FBS-84012)
3640 */
3641 $inconclusive = false;
3642 if ( $active ) {
3643 $reason = 'static-served';
3644 } elseif ( 200 === $code && $match && $ua_php ) {
3645 $reason = 'php served the file instead of nginx/Apache (rewrite block missing)';
3646 } elseif ( 200 === $code && ! $match ) {
3647 // Something answered 200 with content that isn't our nonce — a CDN,
3648 // a proxy, a security plugin. That tells us nothing about the
3649 // origin's rewrite.
3650 $reason = 'unexpected body (CDN cached an older response?)';
3651 $inconclusive = true;
3652 } elseif ( 404 === $code ) {
3653 $reason = 'probe URL returned 404 (rewrite block missing or wrong path)';
3654 } else {
3655 // Redirects, 403s from a WAF, 5xx — the probe never reached a
3656 // verdict about the rewrite itself.
3657 $reason = sprintf( 'unexpected response (HTTP %d, body %d B, php=%s)', $code, strlen( $body ), $ua_php ? 'yes' : 'no' );
3658 $inconclusive = true;
3659 }
3660
3661 $result = array(
3662 'active' => $active,
3663 'inconclusive' => $inconclusive,
3664 'reason' => $reason,
3665 'code' => $code,
3666 'php' => $ua_php,
3667 );
3668 set_transient( 'xspeed_rewrite_probe', $result, 5 * MINUTE_IN_SECONDS );
3669 return $result;
3670 }
3671
3672 public static function rewrite_installed(): bool {
3673 $htaccess = ABSPATH . '.htaccess';
3674 if ( ! file_exists( $htaccess ) ) {
3675 return false;
3676 }
3677 $existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
3678 if ( ! is_string( $existing ) ) {
3679 return false;
3680 }
3681 return false !== strpos( $existing, '# BEGIN xSpeed Static Cache' );
3682 }
3683
3684 /**
3685 * Install the static-cache rewrite block at the TOP of .htaccess.
3686 *
3687 * Position matters: WordPress's own block ends with
3688 * `RewriteRule . /index.php [L]` which routes every non-file
3689 * request to PHP. The [L] flag stops the current rewrite pass,
3690 * but Apache restarts the cycle; on the second pass REQUEST_URI
3691 * is /index.php and no static-file check can match. The only
3692 * reliable position for a "serve static if it exists" rule is
3693 * before WordPress's block.
3694 *
3695 * WP's insert_with_markers() always appends, so we manage the
3696 * block manually: strip any prior xSpeed Static Cache markers,
3697 * then write our block followed by the rest of the file.
3698 */
3699 public static function install_rewrite(): bool {
3700 // The static rewrite is device-blind; never install it when
3701 // mobile_separate is on (see static_rewrite_allowed()).
3702 if ( ! self::static_rewrite_allowed() ) {
3703 return false;
3704 }
3705 $htaccess = ABSPATH . '.htaccess';
3706 $existing = file_exists( $htaccess ) ? @file_get_contents( $htaccess ) : ''; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
3707 if ( false === $existing ) {
3708 $existing = '';
3709 }
3710 // Apache/LiteSpeed only. nginx hosts: rule won't fire, drop-in
3711 // covers; we skip the write so we don't litter their root.
3712 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Pre-flight check before file_put_contents; WP_Filesystem requires admin credentials we don't have inside a manage_options REST request.
3713 if ( file_exists( $htaccess ) && ! is_writable( $htaccess ) ) {
3714 return false;
3715 }
3716 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- See above.
3717 if ( ! file_exists( $htaccess ) && ! is_writable( ABSPATH ) ) {
3718 return false;
3719 }
3720
3721 $cleaned = self::strip_marker_block( $existing, 'xSpeed Static Cache' );
3722 $block = self::marker_block( 'xSpeed Static Cache', self::rewrite_block_lines() );
3723 $next = $block . ( '' === $cleaned ? '' : "\n" . $cleaned );
3724
3725 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents, PluginCheck.CodeAnalysis.WriteFile.ABSPATHDetected -- WP_Filesystem requires admin credentials we don't have here; toggle() runs in a REST request authorized by manage_options nonce. The target is the site's .htaccess (configuration file managed by WP core itself), not user data — wp_upload_dir() doesn't apply.
3726 return false !== file_put_contents( $htaccess, $next, LOCK_EX );
3727 }
3728
3729 /**
3730 * Rewrite the .htaccess block in place when — and only when — one is
3731 * already installed.
3732 *
3733 * The block embeds the generated cookie / user-agent exclusion rules,
3734 * so it goes stale the moment those settings change. install_rewrite()
3735 * regenerates it from the live settings, but calling that unconditionally
3736 * on every save would CREATE a block on sites that never enabled the
3737 * static path — silently turning on server-level serving nobody asked
3738 * for. So we refresh only what's already there.
3739 *
3740 * @return bool True when a block was present and rewritten.
3741 */
3742 public static function refresh_rewrite_if_installed(): bool {
3743 $htaccess = ABSPATH . '.htaccess';
3744 if ( ! file_exists( $htaccess ) ) {
3745 return false;
3746 }
3747 $existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Best-effort read; an unreadable file simply means nothing to refresh.
3748 if ( ! is_string( $existing ) || false === strpos( $existing, '# BEGIN xSpeed Static Cache' ) ) {
3749 return false;
3750 }
3751 return self::install_rewrite();
3752 }
3753
3754 public static function remove_rewrite(): bool {
3755 $htaccess = ABSPATH . '.htaccess';
3756 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- See install_rewrite() rationale.
3757 if ( ! file_exists( $htaccess ) || ! is_writable( $htaccess ) ) {
3758 return false;
3759 }
3760 $existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
3761 if ( false === $existing ) {
3762 return false;
3763 }
3764 $cleaned = self::strip_marker_block( $existing, 'xSpeed Static Cache' );
3765 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents, PluginCheck.CodeAnalysis.WriteFile.ABSPATHDetected -- See install_rewrite() rationale.
3766 return false !== file_put_contents( $htaccess, $cleaned, LOCK_EX );
3767 }
3768
3769 /**
3770 * Strip a `# BEGIN <marker>` ... `# END <marker>` block from a
3771 * .htaccess-style file, including any blank line that immediately
3772 * follows it. Idempotent — returns the input unchanged if the
3773 * marker isn't present.
3774 */
3775 private static function strip_marker_block( string $contents, string $marker ): string {
3776 $pattern = '/# BEGIN ' . preg_quote( $marker, '/' ) . '\b.*?# END ' . preg_quote( $marker, '/' ) . "\b[^\n]*\n?\n?/s";
3777 $out = preg_replace( $pattern, '', $contents );
3778 return is_string( $out ) ? $out : $contents;
3779 }
3780
3781 private static function marker_block( string $marker, array $lines ): string {
3782 $header = "# BEGIN $marker\n";
3783 $header .= "# The directives (lines) between \"BEGIN $marker\" and \"END $marker\" are\n";
3784 $header .= "# dynamically generated, and should only be modified via WordPress filters.\n";
3785 $header .= "# Any changes to the directives between these markers will be overwritten.\n";
3786 $footer = "# END $marker\n";
3787 return $header . implode( "\n", $lines ) . "\n" . $footer;
3788 }
3789
3790 /**
3791 * Parse the `XSPEED_DROPIN_VERSION: N` stamp out of a drop-in's source.
3792 * Returns 0 when absent (an un-stamped older copy reinstalls). Used to
3793 * detect a stale installed drop-in vs the bundled source.
3794 */
3795 private static function dropin_version( string $contents ): int {
3796 if ( preg_match( '/XSPEED_DROPIN_VERSION:\s*(\d+)/', $contents, $m ) ) {
3797 return (int) $m[1];
3798 }
3799 return 0;
3800 }
3801
3802 public static function install_dropin() {
3803 $source = XSPEED_DIR . 'includes/advanced-cache.php';
3804 $target = WP_CONTENT_DIR . '/advanced-cache.php';
3805 if ( ! file_exists( $source ) ) {
3806 return false;
3807 }
3808
3809 global $wp_filesystem;
3810 if ( ! function_exists( 'WP_Filesystem' ) ) {
3811 require_once ABSPATH . 'wp-admin/includes/file.php';
3812 }
3813 WP_Filesystem();
3814 if ( ! $wp_filesystem ) {
3815 return false;
3816 }
3817
3818 $source_contents = $wp_filesystem->get_contents( $source );
3819 if ( ! is_string( $source_contents ) ) {
3820 return false;
3821 }
3822
3823 // Bake the absolute hit-log path into the drop-in. It runs before
3824 // WordPress loads, so it can't resolve wp_upload_dir() itself — we
3825 // substitute the @@XSPEED_HITS_LOG@@ token with the real uploads path
3826 // (never the cache dir; see hits_log_dir() / FBS-82478). Use a single
3827 // quoted PHP string literal so the installed file stays valid PHP.
3828 $source_contents = str_replace(
3829 '@@XSPEED_HITS_LOG@@',
3830 str_replace( "'", "\\'", self::hits_log_path() ),
3831 $source_contents
3832 );
3833
3834 // Bake the cookie + user-agent exclusion rules in too. The drop-in
3835 // runs before WordPress loads, so it cannot read the settings — and
3836 // without them it served the shared anonymous page to any visitor
3837 // PHP had not yet seen (a first-time cart visitor, a bypassed bot).
3838 // The generic bypass cookie only covers repeat visitors; these two
3839 // regexes are what make the FIRST request correct.
3840 //
3841 // Both are already fully escaped by Server_Rules, and each is
3842 // embedded as a single-quoted PHP literal, so a settings value can
3843 // neither break the drop-in's syntax nor execute.
3844 $cache_opts = Settings_Manager::get( 'cache' );
3845 $cookie_rule = Server_Rules::cookie_rule(
3846 is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array()
3847 );
3848 $ua_rule = Server_Rules::user_agent_rule(
3849 is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array()
3850 );
3851
3852 $source_contents = str_replace(
3853 '@@XSPEED_COOKIE_RE@@',
3854 str_replace( "'", "\\'", $cookie_rule['regex'] ),
3855 $source_contents
3856 );
3857 $source_contents = str_replace(
3858 '@@XSPEED_UA_RE@@',
3859 str_replace( "'", "\\'", $ua_rule['regex'] ),
3860 $source_contents
3861 );
3862
3863 if ( file_exists( $target ) ) {
3864 $existing = $wp_filesystem->get_contents( $target );
3865 $is_xspeed = is_string( $existing ) && false !== strpos( $existing, 'XSPEED_DROPIN' );
3866
3867 if ( $is_xspeed ) {
3868 if ( $existing === $source_contents ) {
3869 return true;
3870 }
3871 return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE );
3872 }
3873
3874 // Foreign drop-in (e.g. left over from another cache plugin) — back it up
3875 // before overwriting so the user can recover if needed. Uploads dir
3876 // (not wp-content root) keeps the backup out of WordPress's reserved
3877 // drop-in location.
3878 $upload = wp_upload_dir( null, false );
3879 $basedir = isset( $upload['basedir'] ) ? trailingslashit( $upload['basedir'] ) . 'xspeed-backups' : false;
3880 if ( $basedir ) {
3881 if ( ! file_exists( $basedir ) ) {
3882 wp_mkdir_p( $basedir );
3883 self::write_silence( $basedir );
3884 }
3885 $backup = $basedir . '/advanced-cache.foreign-' . gmdate( 'Ymd-His' ) . '.php.bak';
3886 $wp_filesystem->move( $target, $backup, true );
3887 } else {
3888 $wp_filesystem->delete( $target );
3889 }
3890 }
3891
3892 return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE );
3893 }
3894
3895 public static function remove_dropin() {
3896 $target = WP_CONTENT_DIR . '/advanced-cache.php';
3897 if ( ! file_exists( $target ) ) {
3898 return;
3899 }
3900
3901 global $wp_filesystem;
3902 if ( ! function_exists( 'WP_Filesystem' ) ) {
3903 require_once ABSPATH . 'wp-admin/includes/file.php';
3904 }
3905 WP_Filesystem();
3906 if ( ! $wp_filesystem ) {
3907 return;
3908 }
3909
3910 $contents = $wp_filesystem->get_contents( $target );
3911 if ( is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' ) ) {
3912 wp_delete_file( $target );
3913 }
3914 }
3915
3916 /**
3917 * Where wp-config.php actually is.
3918 *
3919 * WordPress core supports the file one directory ABOVE ABSPATH, and
3920 * plenty of installs use that layout. This used to look only in ABSPATH
3921 * and bail, so on those sites the constant could never be written — while
3922 * Health, which did fall back to the parent, reported the file writable
3923 * and told the user to toggle the cache off and on. The advice could
3924 * never work, and its fallback hint ("another plugin left WP_CACHE false
3925 * behind") was wrong too: there was no define at all. (#19, QA on #174)
3926 *
3927 * Returns '' when no wp-config.php can be found in either location.
3928 */
3929 public static function wp_config_path(): string {
3930 $candidates = array( ABSPATH . 'wp-config.php', dirname( ABSPATH ) . '/wp-config.php' );
3931 foreach ( $candidates as $path ) {
3932 if ( file_exists( $path ) ) {
3933 return $path;
3934 }
3935 }
3936 return '';
3937 }
3938
3939 /**
3940 * Can we actually write the constant right now?
3941 *
3942 * This is the single oracle for that question — Health asks THIS rather
3943 * than running its own `wp_is_writable()` test, so the message a user
3944 * reads can never disagree with what the plugin will do. The two differed
3945 * in both directions: on the path (above) and on the test itself, since
3946 * an FTP/SSH WP_Filesystem transport can refuse a file that
3947 * `wp_is_writable()` reports as writable. (#19, QA on #174)
3948 */
3949 public static function can_write_wp_config(): bool {
3950 $wp_config = self::wp_config_path();
3951 if ( '' === $wp_config ) {
3952 return false;
3953 }
3954
3955 global $wp_filesystem;
3956 if ( ! function_exists( 'WP_Filesystem' ) ) {
3957 require_once ABSPATH . 'wp-admin/includes/file.php';
3958 }
3959 WP_Filesystem();
3960 return (bool) ( $wp_filesystem && $wp_filesystem->is_writable( $wp_config ) );
3961 }
3962
3963 public static function set_wp_cache_constant( $enable ) {
3964 $wp_config = self::wp_config_path();
3965 if ( '' === $wp_config ) {
3966 return false;
3967 }
3968
3969 global $wp_filesystem;
3970 if ( ! function_exists( 'WP_Filesystem' ) ) {
3971 require_once ABSPATH . 'wp-admin/includes/file.php';
3972 }
3973 WP_Filesystem();
3974 if ( ! $wp_filesystem || ! $wp_filesystem->is_writable( $wp_config ) ) {
3975 return false;
3976 }
3977
3978 $config = $wp_filesystem->get_contents( $wp_config );
3979
3980 if ( $enable ) {
3981 // Own the constant. A previous caching plugin (e.g. WP Rocket sets
3982 // it false on deactivate) can leave `define( 'WP_CACHE', false );`
3983 // behind — presence alone is not enough, the VALUE must be true or
3984 // WordPress never loads advanced-cache.php and our drop-in is dead.
3985 if ( preg_match( "/define\\(\\s*['\"]WP_CACHE['\"]\\s*,/", $config ) ) {
3986 $rewritten = preg_replace(
3987 "/define\\(\\s*['\"]WP_CACHE['\"]\\s*,\\s*[^)]*\\)\\s*;/",
3988 "define( 'WP_CACHE', true );",
3989 $config,
3990 1
3991 );
3992 // If an existing define was already `true`, the rewrite is a
3993 // no-op string-wise; either way we end on WP_CACHE === true.
3994 if ( null !== $rewritten ) {
3995 $config = $rewritten;
3996 }
3997 } else {
3998 $config = preg_replace( '/(<\?php)/', "$1\ndefine( 'WP_CACHE', true );", $config, 1 );
3999 }
4000 } else {
4001 // Shared with uninstall.php so the two removal paths can't drift
4002 // — they already had, which is why every non-lowercase spelling
4003 // of the value survived a disable. (#9)
4004 require_once XSPEED_DIR . 'includes/wp-cache-constant.php';
4005 $config = xspeed_strip_wp_cache_define( $config );
4006 }
4007
4008 return (bool) $wp_filesystem->put_contents( $wp_config, $config, FS_CHMOD_FILE );
4009 }
4010
4011 /**
4012 * Admin-bar purge menu — a parent node plus one child per visible cache
4013 * type (LiteSpeed-style), instead of a single "Purge All" link. Each
4014 * child posts to the same admin-post handler with its type slug. The
4015 * per-type items only appear for active/licensed modules; "Purge All"
4016 * always shows and always sweeps everything. (FBS-83114)
4017 *
4018 * The parent node links to the settings page rather than a purge URL —
4019 * clicking the top-level item used to wipe the whole cache instantly with
4020 * no confirmation, which is far too destructive for a stray click. Purging
4021 * stays available (and explicit) through the child items. (FBS-84068)
4022 */
4023 public function admin_bar_purge( $wp_admin_bar ) {
4024 if ( ! current_user_can( 'manage_options' ) ) {
4025 return;
4026 }
4027
4028 $wp_admin_bar->add_node(
4029 array(
4030 'id' => 'xspeed-purge',
4031 'title' => __( 'xSpeed Cache', 'xspeed' ),
4032 'href' => admin_url( 'admin.php?page=' . Admin::PAGE_SLUG ),
4033 )
4034 );
4035
4036 foreach ( self::purge_types() as $slug => $type ) {
4037 if ( empty( $type['visible'] ) ) {
4038 continue;
4039 }
4040 $wp_admin_bar->add_node(
4041 array(
4042 'id' => 'xspeed-purge-' . $slug,
4043 'parent' => 'xspeed-purge',
4044 'title' => esc_html( $type['label'] ),
4045 'href' => self::purge_type_url( $slug ),
4046 )
4047 );
4048 }
4049 }
4050
4051 /**
4052 * Nonce-protected admin-post URL for purging a single type. The nonce
4053 * action is per-type so a leaked URL can't be replayed for a different
4054 * scope.
4055 */
4056 private static function purge_type_url( string $type ): string {
4057 return wp_nonce_url(
4058 admin_url( 'admin-post.php?action=xspeed_purge&type=' . rawurlencode( $type ) ),
4059 'xspeed_purge_' . $type
4060 );
4061 }
4062
4063 public function handle_admin_bar_purge() {
4064 if ( ! current_user_can( 'manage_options' ) ) {
4065 wp_die( esc_html__( 'Unauthorized.', 'xspeed' ), 403 );
4066 }
4067 $type = isset( $_GET['type'] ) ? sanitize_key( wp_unslash( $_GET['type'] ) ) : 'all';
4068 check_admin_referer( 'xspeed_purge_' . $type );
4069
4070 // Only honour known types; anything else falls back to a full purge.
4071 if ( ! array_key_exists( $type, self::purge_types() ) ) {
4072 $type = 'all';
4073 }
4074 self::purge_type( $type );
4075
4076 wp_safe_redirect( self::safe_purge_redirect( wp_get_referer() ) );
4077 exit;
4078 }
4079
4080 /**
4081 * Resolve a safe redirect target for an admin-bar purge.
4082 *
4083 * The purge sends the admin back where they came from — but the referer
4084 * can be a ONE-SHOT action URL (e.g. update.php?action=upload-plugin from
4085 * installing a plugin zip, or any *.php?action=… that consumed a POST /
4086 * temp upload). Redirecting there re-runs the action with nothing to act
4087 * on, so WordPress dies — the classic "Please select a file" from
4088 * File_Upload_Upgrader. Strip the transient action args so we return to a
4089 * safe, re-GET-able view of the same page; fall back to the dashboard when
4090 * there is no usable referer.
4091 *
4092 * @param string|false $referer Raw wp_get_referer() value.
4093 * @return string Safe URL to redirect to.
4094 */
4095 public static function safe_purge_redirect( $referer ): string {
4096 $referer = is_string( $referer ) ? $referer : '';
4097 if ( '' === $referer ) {
4098 return admin_url();
4099 }
4100
4101 // A referer that lands on an action-processing endpoint (update.php,
4102 // update-core.php, plugin/theme install/upload flows) can't be safely
4103 // re-requested — send them to the dashboard instead of replaying it.
4104 $path = (string) wp_parse_url( $referer, PHP_URL_PATH );
4105 if ( preg_match( '#/wp-admin/(update|update-core)\.php$#', $path ) ) {
4106 return admin_url();
4107 }
4108
4109 // Otherwise keep them on the same page but drop the query args that
4110 // would re-trigger a form action or upload on load.
4111 return remove_query_arg(
4112 array( 'action', 'action2', 'package', 'overwrite', 'plugin', 'theme', 'file', '_wpnonce', '_ajax_nonce' ),
4113 $referer
4114 );
4115 }
4116 }
4117