PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.6
1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 1.2.0 All 28 releases
xspeed / includes / class-cache.php

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

3,475 lines 143.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 if ( ! file_exists( XSPEED_CACHE_DIR ) ) {
446 wp_mkdir_p( XSPEED_CACHE_DIR );
447 self::write_silence( XSPEED_CACHE_DIR );
448 }
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 public static function cache_file_for( $key ) {
797 return XSPEED_CACHE_DIR . '/' . $key . '.html';
798 }
799
800 /**
801 * If a precompressed Brotli sibling (`<file>.br`) exists and the client
802 * advertises `Accept-Encoding: br`, emit the Brotli response headers and
803 * return the `.br` path to stream. Returns null to fall through to the
804 * plain file. Keeps the PHP serve path in parity with the web server's
805 * static .br serving (mod_brotli / ngx_brotli rewrite).
806 *
807 * Free has no Brotli logic of its own — this only fires when an add-on
808 * (the Pro Brotli module) actually wrote the .br, so it's a safe no-op
809 * on Free-only installs.
810 *
811 * @param string $file Absolute path to the cached .html file.
812 * @return string|null The .br path to stream, or null to serve $file.
813 */
814 public static function maybe_serve_brotli( string $file ): ?string {
815 if ( headers_sent() ) {
816 return null;
817 }
818 $accept = isset( $_SERVER['HTTP_ACCEPT_ENCODING'] )
819 ? strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT_ENCODING'] ) ) )
820 : '';
821 // Match `br` as a token (comma/space delimited), not a substring, so
822 // a hypothetical "xbr" encoding can't false-positive.
823 if ( ! preg_match( '/(^|[\s,])br([\s,;]|$)/', $accept ) ) {
824 return null;
825 }
826 $br = $file . '.br';
827 if ( ! is_string( $br ) || ! file_exists( $br ) || ! is_readable( $br ) ) {
828 return null;
829 }
830 header( 'Content-Encoding: br' );
831 header( 'Vary: Accept-Encoding', false );
832 // The byte length changes for the compressed body — drop any
833 // Content-Length the caller may have set so the stream isn't
834 // truncated/padded. readfile() lets the SAPI set the right length.
835 header_remove( 'Content-Length' );
836 return $br;
837 }
838
839 /**
840 * Sidecar metadata file for a cache entry. Holds response bits the HIT
841 * path must replay — Content-Type (cached feeds → application/rss+xml,
842 * sitemaps → text/xml) and status (a cached 404 must serve 404, not
843 * 200). JSON, one tiny file per entry, written only when there's
844 * something non-default to replay.
845 */
846 public static function cache_meta_for( $key ) {
847 return XSPEED_CACHE_DIR . '/' . $key . '.meta';
848 }
849
850 /**
851 * Read the .meta sidecar for a cache entry as an array, or [] if none.
852 * Keys: 'content_type' (string), 'status' (int), 'ttl' (int seconds).
853 * Used on the HIT path to replay content-type/status before streaming
854 * the file, and by Cache_GC to age an entry by its own TTL rather than
855 * the global one — hence public.
856 */
857 public static function read_meta( $key ): array {
858 $meta_file = self::cache_meta_for( $key );
859 if ( ! file_exists( $meta_file ) ) {
860 return array();
861 }
862 // 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.
863 $raw = file_get_contents( $meta_file );
864 $data = json_decode( (string) $raw, true );
865 return is_array( $data ) ? $data : array();
866 }
867
868 /**
869 * Conditional-GET support for a cache HIT. Emits Last-Modified + ETag
870 * derived from the cache file's mtime, and — when the request's
871 * If-Modified-Since / If-None-Match still match — sends 304 Not Modified
872 * and returns true (caller should exit without a body). Returns false to
873 * proceed with a normal 200 body. Lets aggregators/browsers skip
874 * re-downloading an unchanged cached response. (FBS-82407 #5)
875 *
876 * @param string $file Absolute path to the cache .html file.
877 * @return bool True when a 304 was sent.
878 */
879 public static function serve_not_modified( string $file ): bool {
880 $mtime = (int) filemtime( $file );
881 if ( $mtime <= 0 ) {
882 return false;
883 }
884 $last_modified = gmdate( 'D, d M Y H:i:s', $mtime ) . ' GMT';
885 $etag = '"' . md5( $file . '|' . $mtime ) . '"';
886 header( 'Last-Modified: ' . $last_modified );
887 header( 'ETag: ' . $etag );
888
889 $ims = isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ) ) : '';
890 $inm = isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_NONE_MATCH'] ) ) ) : '';
891
892 $etag_match = '' !== $inm && false !== strpos( $inm, $etag );
893 $time_match = '' !== $ims && ( strtotime( $ims ) >= $mtime );
894
895 if ( $etag_match || $time_match ) {
896 if ( function_exists( 'http_response_code' ) ) {
897 http_response_code( 304 );
898 }
899 return true;
900 }
901 return false;
902 }
903
904 public static function is_expired( $file ) {
905 // cache_expiry now owned by CacheModule; per-post override
906 // (Phase 3.4) shrinks the TTL further when the editor set one.
907 $opts = Settings_Manager::get( 'cache' );
908 $max_age = (int) $opts['cache_expiry'] * HOUR_IN_SECONDS;
909 $post_override = Cache_Rules::expiry_override_seconds_for_post( Cache_Rules::current_post_id() );
910 if ( null !== $post_override ) {
911 $max_age = $post_override;
912 }
913
914 /**
915 * Filter the max-age (seconds) for the current cache entry.
916 *
917 * Lets an add-on apply a request-type-specific TTL — e.g. the
918 * xspeed-pro feed cache gives feeds a longer expiry than pages,
919 * since aggregators tolerate more staleness. Return seconds.
920 *
921 * @param int $max_age Computed max-age in seconds.
922 */
923 $max_age = (int) apply_filters( 'xspeed_cache_max_age', $max_age );
924
925 // A missing file is "expired" — the caller should re-render. Guard
926 // filemtime() rather than letting it warn: callers legitimately ask
927 // about a file that isn't there (Pro's predictive warmer probes for
928 // freshness, and Cache_GC can collect an entry between the check and
929 // the read), and on a site with WP_DEBUG the warning is noise.
930 $mtime = file_exists( $file ) ? filemtime( $file ) : false;
931 if ( false === $mtime ) {
932 return true;
933 }
934
935 return ( time() - (int) $mtime ) > $max_age;
936 }
937
938 /**
939 * Accumulator for the full response body across all output-handler phases.
940 *
941 * PHP invokes an ob_start() callback once per flush, and each invocation
942 * only receives the chunk produced *since the previous flush*. If anything
943 * during the render calls `ob_flush()` or `flush()` (some themes, lazy-
944 * load plugins, AMP, etc. do), the final-phase call would otherwise only
945 * see the tail of the page — and we'd cache a truncated response that
946 * gets served repeatedly until purge. We accumulate every chunk here so
947 * the cache file always reflects the complete page.
948 *
949 * @var string
950 */
951 private static $accumulated = '';
952
953 public static function finalize_buffer( $buffer, $phase = PHP_OUTPUT_HANDLER_FINAL ) {
954 self::$accumulated .= $buffer;
955
956 // On non-final phases (mid-request flushes), pass the current chunk
957 // through to the client unmodified and keep collecting. The WP 6.9
958 // filter path always passes the full body in one shot with the
959 // default $phase, so it falls straight through to the final block.
960 $is_final = ( $phase & ( PHP_OUTPUT_HANDLER_FINAL | PHP_OUTPUT_HANDLER_END ) ) !== 0;
961 if ( ! $is_final ) {
962 return $buffer;
963 }
964
965 $full = self::$accumulated;
966 self::$accumulated = '';
967
968 if ( strlen( $full ) < 255 ) {
969 return $buffer;
970 }
971
972 // Status gate. We cache 200 by default. A 404 may be cached too,
973 // but only when an add-on (xspeed-pro 404 cache) opts in for a
974 // genuine is_404() — never a transient 404 (maintenance screen,
975 // DB error, or a 404 emitted outside the main query), which would
976 // otherwise be frozen until purge. Any other status is skipped.
977 $status = function_exists( 'http_response_code' ) ? (int) http_response_code() : 200;
978 if ( 200 !== $status ) {
979 if ( 404 !== $status || ! self::should_cache_404() ) {
980 return $buffer;
981 }
982 }
983
984 // If no mid-request flush happened, $buffer === $full and we can
985 // safely minify the on-wire bytes too. Otherwise earlier chunks have
986 // already been sent unminified, so we minify only what goes to disk —
987 // the first visitor sees unminified HTML, every cache hit after that
988 // is minified.
989 $single_chunk = ( $buffer === $full );
990
991 /**
992 * Filter: xspeed_cache_final_html
993 *
994 * Last chance to transform the fully-rendered page HTML before it is
995 * minified and written to the cache file. Runs on cache MISS only, so
996 * whatever a listener injects here is baked into the cached HTML and
997 * replayed on every subsequent HIT (the drop-in short-circuits before
998 * PHP on a HIT — a wp_head hook would never fire there).
999 *
1000 * The Preload module uses this to inject the LCP-image <link rel=preload>
1001 * + preconnect hints and add fetchpriority="high" to the hero <img>.
1002 * Keep listeners fast and idempotent; this is the on-wire body.
1003 *
1004 * @param string $full Complete page HTML.
1005 */
1006 $full = (string) apply_filters( 'xspeed_cache_final_html', $full );
1007 if ( $single_chunk ) {
1008 $buffer = $full;
1009 }
1010
1011 // minify_html now owned by the Minify module; read through the
1012 // module's storage so this stays consistent with the engine that
1013 // applies CSS/JS minification.
1014 $minify_opts = Settings_Manager::get( 'minify' );
1015 if ( ! empty( $minify_opts['minify_html'] ) ) {
1016 $full = Minifier::minify_html( $full );
1017 if ( $single_chunk ) {
1018 $buffer = $full;
1019 }
1020 }
1021
1022 if ( ! file_exists( XSPEED_CACHE_DIR ) ) {
1023 wp_mkdir_p( XSPEED_CACHE_DIR );
1024 self::write_silence( XSPEED_CACHE_DIR );
1025 }
1026
1027 // Path safety: cache_file_for() builds `XSPEED_CACHE_DIR . '/' . $key . '.html'`
1028 // where $key comes from md5() — guaranteed to be exactly 32 lowercase
1029 // hex chars, so no traversal sequence ('..', '/', null byte, etc.)
1030 // can appear. The write is therefore always inside XSPEED_CACHE_DIR.
1031 $key = self::cache_key();
1032 $file = self::cache_file_for( $key );
1033
1034 // A render-time translation plugin (TranslatePress) wraps our buffer,
1035 // so the bytes we hold here are still UNTRANSLATED — its callback has
1036 // not run yet, and writing now would cache English under a French URL
1037 // and bake in its internal #TRPLINKPROCESSED markers. Hand off to
1038 // shutdown, where the outer buffer has already translated, and let
1039 // the pass-through below deliver this request untouched.
1040 if ( self::translation_plugin_active() ) {
1041 self::$deferred_key = $key;
1042 // Reaching here means finalize_buffer() ran to completion: the
1043 // status gate passed, should_cache() said yes, and PHP handed us
1044 // the whole buffer. A wp_die() or exit() mid-render unwinds the
1045 // buffer stack WITHOUT calling this callback, so the flag stays
1046 // false and the shutdown writer declines — see the guard there.
1047 self::$render_completed = true;
1048 // A PHP shutdown function, not a WP `shutdown` action: this must
1049 // run after the output-buffer stack has unwound, and WP's
1050 // shutdown action fires while our outer buffer is still open.
1051 register_shutdown_function( array( __CLASS__, 'write_deferred_translated_cache' ) );
1052 return $buffer;
1053 }
1054
1055 // 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.
1056 file_put_contents( $file, $full, LOCK_EX );
1057
1058 /**
1059 * Fires after the flat hash cache file ({md5}.html) is written.
1060 *
1061 * Mirror of `xspeed_static_file_written` for the flat cache. The PHP
1062 * serve path (Cache::maybe_serve_brotli / the drop-in) serves THIS
1063 * file and looks for a `{md5}.html.br` sibling — which only the Pro
1064 * Brotli listener on this hook writes. Without it the .br sibling was
1065 * never created and the PHP path could never serve Brotli (FBS-83039,
1066 * Blocker 2): the static-tree .br (written on xspeed_static_file_written)
1067 * lives in a different cache layout the PHP path never reads.
1068 *
1069 * @param string $file Absolute path to the flat cache file just written.
1070 * @param string $full The HTML written to it.
1071 */
1072 do_action( 'xspeed_flat_file_written', $file, $full );
1073
1074 // Persist a non-default Content-Type so the HIT path can replay it
1075 // (cached feeds must serve application/rss+xml, not text/html).
1076 // Only written when the response set a content-type other than
1077 // the HTML default — pages don't pay for an extra file.
1078 self::write_meta( $key );
1079
1080 // Static-cache tree (xspeed-static/{host}{path}/index.html). The
1081 // .htaccess rewrite block serves this file directly via the web
1082 // server, bypassing PHP for ~3-5× lower TTFB vs the drop-in path.
1083 // store_static() returns silently on any path/permission issue —
1084 // the drop-in remains the safety net.
1085 //
1086 // Skip it entirely when mobile_separate is on: the rewrite is
1087 // disabled in that mode (static_rewrite_allowed()), so a static file
1088 // would only be dead weight — and a device-blind one at that.
1089 // Skip the static-tree write for responses the web server can't replay
1090 // correctly: a non-200 status (a cached 404 would be served as a soft
1091 // 200, FBS-82406) or a non-HTML content-type (a cached feed would go
1092 // out as text/html, FBS-82407). The web server serves these .html files
1093 // directly with no PHP, so there's no .meta replay — keep them on the
1094 // drop-in / PHP path instead, which DOES replay status + content-type.
1095 if ( self::static_rewrite_allowed() && self::response_is_plain_html() ) {
1096 self::store_static( $full );
1097 }
1098
1099 return $buffer;
1100 }
1101
1102 /**
1103 * Write the current response to the static-cache tree at
1104 * `xspeed-static/{host}{request_uri}/index.html`. The web-server
1105 * rewrite block points at this path so cache hits skip PHP
1106 * entirely. Caller already minified/finalized $html.
1107 *
1108 * Path safety: $host is restricted to a `[a-zA-Z0-9.\-]` allowlist;
1109 * $uri has its query string stripped, null bytes removed, '..'
1110 * sequences collapsed, and after concatenation we verify the
1111 * resolved real path stays inside XSPEED_CACHE_STATIC_DIR before
1112 * any write. Anything off the happy path returns silently.
1113 *
1114 * INVARIANT — the static tree is keyed by `{host}{path}` and NOTHING
1115 * else, and both generated rewrites refuse any request that carries a
1116 * query string at all (`RewriteCond %{QUERY_STRING} ^$` on Apache,
1117 * `if ($args)` in nginx_snippet()). So a response may only be stored
1118 * here when cache_key() adds no discriminator beyond `{host}{path}`:
1119 * a query-keyed entry can never be *served* from here, only mis-served
1120 * as the bare path. Any future opt-in that folds a query param into the
1121 * key needs a guard below, exactly like the search one.
1122 */
1123 private static function store_static( string $html ): void {
1124 // Search results are keyed by term in cache_key() (`|s=<term>`) but
1125 // carry the *path* of whatever URL was searched from — for the usual
1126 // `/?s=<term>` that path is `/`. Writing them here would file the
1127 // results page as `{host}/index.html` and the web server would serve
1128 // it to every visitor as the homepage: an unauthenticated visitor
1129 // poisons the front page with one request. Searches stay on the
1130 // drop-in, which replays the term-keyed entry correctly. (#191)
1131 //
1132 // This is a superset of the query-string check the exclusion gate
1133 // does: it also covers `/?%73=<term>`, which decodes to the same
1134 // search (the shape #109 fixed on the gate side).
1135 if ( self::should_cache_search() ) {
1136 return;
1137 }
1138
1139 $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
1140 $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
1141 $host = preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host );
1142 $uri = str_replace( "\0", '', $uri );
1143 $uri = (string) strtok( $uri, '?' );
1144 if ( '' === $host || '' === $uri ) {
1145 return;
1146 }
1147 // Collapse any traversal sequences before path resolution.
1148 $uri = preg_replace( '#/+#', '/', $uri );
1149 if ( false !== strpos( $uri, '..' ) ) {
1150 return;
1151 }
1152
1153 $base = rtrim( XSPEED_CACHE_STATIC_DIR, '/' );
1154 $dir = $base . '/' . $host . rtrim( $uri, '/' );
1155 $file = $dir . '/index.html';
1156
1157 // Resolve the parent against the cache root to be sure the
1158 // final path is inside our tree even if the OS does anything
1159 // funny with multi-byte sequences.
1160 $base_real = realpath( WP_CONTENT_DIR );
1161 if ( false === $base_real || 0 !== strpos( $base, $base_real ) ) {
1162 return;
1163 }
1164
1165 if ( ! file_exists( $dir ) ) {
1166 wp_mkdir_p( $dir );
1167 }
1168 if ( ! is_dir( $dir ) ) {
1169 return;
1170 }
1171 // 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.
1172 $written = file_put_contents( $file, $html, LOCK_EX );
1173
1174 if ( false !== $written ) {
1175 /**
1176 * Fires after a static cache file (index.html) is written.
1177 *
1178 * The extension point for serving pre-compressed siblings:
1179 * the xspeed-pro Brotli module writes `index.html.br` next to
1180 * the file here so the web server's static rewrite can serve a
1181 * Brotli copy to clients that advertise `Accept-Encoding: br`,
1182 * falling back to GZIP / the plain file otherwise. No core
1183 * behavior depends on a listener being present.
1184 *
1185 * @param string $file Absolute path to the static cache file just written.
1186 * @param string $html The HTML written to it.
1187 */
1188 do_action( 'xspeed_static_file_written', $file, $html );
1189 }
1190 }
1191
1192 /**
1193 * Write the .meta sidecar for a cache entry when the response carries
1194 * anything the HIT path must replay beyond a plain 200 text/html:
1195 * - a non-HTML Content-Type (cached feeds → application/rss+xml,
1196 * sitemaps → text/xml, …), and/or
1197 * - a non-200 status (a cached 404 must serve 404, not 200).
1198 *
1199 * Ordinary 200 text/html pages get NO .meta file, so the common path
1200 * stays a single write.
1201 *
1202 * @param string $key Cache key for the current request.
1203 */
1204 /**
1205 * True only for a plain 200 text/html response — the only kind the
1206 * web-server static tree can serve correctly (it streams the .html with
1207 * no PHP, so it can't replay a 404 status or a feed Content-Type). Used
1208 * to gate store_static() so cached 404s / feeds stay on the replay-capable
1209 * drop-in / PHP path. (FBS-82406, FBS-82407)
1210 */
1211 private static function response_is_plain_html(): bool {
1212 $status = function_exists( 'http_response_code' ) ? (int) http_response_code() : 200;
1213 if ( 200 !== $status && $status > 0 ) {
1214 return false;
1215 }
1216 foreach ( headers_list() as $header ) {
1217 if ( 0 === stripos( $header, 'content-type:' ) ) {
1218 $ct = trim( substr( $header, strlen( 'content-type:' ) ) );
1219 if ( '' !== $ct && false === stripos( $ct, 'text/html' ) ) {
1220 return false;
1221 }
1222 }
1223 }
1224 return true;
1225 }
1226
1227 private static function write_meta( string $key ): void {
1228 $content_type = '';
1229 foreach ( headers_list() as $header ) {
1230 if ( 0 === stripos( $header, 'content-type:' ) ) {
1231 $content_type = trim( substr( $header, strlen( 'content-type:' ) ) );
1232 }
1233 }
1234 $status = function_exists( 'http_response_code' ) ? (int) http_response_code() : 200;
1235
1236 $meta = array();
1237 $is_default_type = ( '' === $content_type || false !== stripos( $content_type, 'text/html' ) );
1238 if ( ! $is_default_type ) {
1239 $meta['content_type'] = $content_type;
1240 }
1241 if ( 200 !== $status && $status > 0 ) {
1242 $meta['status'] = $status;
1243 }
1244
1245 // Per-content TTL (seconds). The drop-in and static fast paths can't
1246 // call is_expired() / the xspeed_cache_max_age filter (they run before
1247 // WP), so persist the resolved max-age here whenever it differs from
1248 // the plain page TTL — e.g. the Pro feed cache's 12h vs the 24h page
1249 // default. The fast paths read this to expire correctly. (FBS-82407)
1250 $opts = Settings_Manager::get( 'cache' );
1251 $default_ttl = (int) $opts['cache_expiry'] * HOUR_IN_SECONDS;
1252 $ttl = (int) apply_filters( 'xspeed_cache_max_age', $default_ttl );
1253 if ( $ttl > 0 && $ttl !== $default_ttl ) {
1254 $meta['ttl'] = $ttl;
1255 }
1256
1257 // Nothing to replay → no sidecar.
1258 if ( empty( $meta ) ) {
1259 return;
1260 }
1261
1262 $payload = wp_json_encode( $meta );
1263 if ( false === $payload ) {
1264 return;
1265 }
1266 // 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.
1267 file_put_contents( self::cache_meta_for( $key ), $payload, LOCK_EX );
1268 }
1269
1270 /**
1271 * @param string $cause Free-form human reason. Recorded in the
1272 * Activity log to give users context (e.g.
1273 * 'post saved', 'settings change', 'manual',
1274 * 'theme switch').
1275 */
1276 /**
1277 * Purge the cache entries for ONE URL — every variant of it: the
1278 * flat-hash entry (+ .meta / .html.br siblings), both device buckets
1279 * (mobile_separate keys them separately), both trailing-slash forms,
1280 * and the static-tree index.html (+ .br) the server rewrite serves.
1281 * The rest of the cache is untouched — this is the surgical
1282 * alternative to purge_all for "I just edited this one page".
1283 *
1284 * @param string $url Absolute URL, or site-relative path ("/about/").
1285 * @param string $cause Who asked, for the purge log. See purge_all().
1286 * @return int Number of cache files removed.
1287 */
1288 public static function purge_url( string $url, string $cause = 'manual' ): int {
1289 $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.
1290 if ( ! is_array( $parts ) ) {
1291 return 0;
1292 }
1293 $host = isset( $parts['host'] ) ? strtolower( (string) $parts['host'] ) : '';
1294 if ( '' === $host && function_exists( 'home_url' ) ) {
1295 $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.
1296 $host = is_array( $home ) && isset( $home['host'] ) ? strtolower( (string) $home['host'] ) : '';
1297 }
1298 if ( '' === $host ) {
1299 return 0;
1300 }
1301 $path = isset( $parts['path'] ) ? (string) $parts['path'] : '/';
1302 $path = '/' . ltrim( $path, '/' );
1303 if ( false !== strpos( $path, '..' ) ) {
1304 return 0;
1305 }
1306
1307 // The cache key preserves REQUEST_URI's trailing-slash form, so
1308 // purge both. Root stays a single '/'.
1309 $forms = array( $path );
1310 if ( '/' !== $path ) {
1311 $forms[] = rtrim( $path, '/' );
1312 $forms[] = rtrim( $path, '/' ) . '/';
1313 }
1314 $forms = array_unique( $forms );
1315
1316 $count = 0;
1317 foreach ( $forms as $uri ) {
1318 // '' = mobile_separate off; '|m' / '|d' = the device buckets.
1319 foreach ( array( '', '|m', '|d' ) as $device ) {
1320 $key = md5( $host . $uri . $device );
1321 $file = self::cache_file_for( $key );
1322 if ( is_file( $file ) ) {
1323 wp_delete_file( $file );
1324 ++$count;
1325 }
1326 foreach ( array( XSPEED_CACHE_DIR . '/' . $key . '.meta', $file . '.br' ) as $sidecar ) {
1327 if ( is_file( $sidecar ) ) {
1328 wp_delete_file( $sidecar );
1329 }
1330 }
1331 }
1332 }
1333
1334 // Static tree (served directly by the nginx/.htaccess rewrite).
1335 if ( defined( 'XSPEED_CACHE_STATIC_DIR' ) ) {
1336 $dir = rtrim( XSPEED_CACHE_STATIC_DIR, '/' ) . '/' . $host . ( '/' === $path ? '' : rtrim( $path, '/' ) );
1337 $file = $dir . '/index.html';
1338 if ( is_file( $file ) ) {
1339 wp_delete_file( $file );
1340 ++$count;
1341 }
1342 if ( is_file( $file . '.br' ) ) {
1343 wp_delete_file( $file . '.br' );
1344 }
1345 }
1346
1347 if ( $count > 0 ) {
1348 Cache_Inventory::invalidate();
1349 Activity_Log::record(
1350 'cache_purge_url',
1351 sprintf(
1352 /* translators: 1: cause of the purge, 2: URL or path, 3: number of files removed. */
1353 __( 'Purged one URL (%1$s) — %2$s, %3$d file(s) removed', 'xspeed' ),
1354 $cause,
1355 $host . $path,
1356 $count
1357 ),
1358 Activity_Log::INFO
1359 );
1360 }
1361
1362 return $count;
1363 }
1364
1365 public static function purge_all( string $cause = 'manual' ) {
1366 $count = 0;
1367 if ( is_dir( XSPEED_CACHE_DIR ) ) {
1368 $files = glob( XSPEED_CACHE_DIR . '/*.html' );
1369 if ( $files ) {
1370 $count = count( $files );
1371 foreach ( $files as $f ) {
1372 wp_delete_file( $f );
1373 }
1374 }
1375 // Remove the .meta sidecars (content-type for feeds/sitemaps)
1376 // alongside their .html entries. Not counted — they're not
1377 // cache "pages", just per-entry metadata.
1378 $meta = glob( XSPEED_CACHE_DIR . '/*.meta' );
1379 if ( $meta ) {
1380 foreach ( $meta as $m ) {
1381 wp_delete_file( $m );
1382 }
1383 }
1384 // Remove precompressed siblings (e.g. <key>.html.br from the Pro
1385 // Brotli module). Not counted — same as .meta. Without this a
1386 // purge leaves stale .br bodies behind: disk bloat, and a
1387 // staleness window if precompression is later disabled.
1388 $br = glob( XSPEED_CACHE_DIR . '/*.br' );
1389 if ( $br ) {
1390 foreach ( $br as $b ) {
1391 wp_delete_file( $b );
1392 }
1393 }
1394 }
1395 // Static-cache tree purge — recursive because the layout is
1396 // xspeed-static/{host}/{path}/index.html, so a flat glob can't
1397 // reach everything.
1398 if ( is_dir( XSPEED_CACHE_STATIC_DIR ) ) {
1399 $count += self::rmtree_html( XSPEED_CACHE_STATIC_DIR );
1400 }
1401 // REST response cache (cache/xspeed/rest/*.json) — same purge
1402 // triggers (publish, settings change) invalidate it too.
1403 $count += Rest_Cache::purge();
1404
1405 // Minified + combined CSS/JS (cache/xspeed/min/ and min/combined/).
1406 // purge_all is a full filesystem sweep and must clear these too, even
1407 // when the Minify module is currently disabled — orphaned min/ files
1408 // from a feature the user later turned off must still be removed, and
1409 // a stale combined-<hash>.css that the regenerated page no longer
1410 // references otherwise 404s and breaks the frontend. (FBS-83114/83116)
1411 if ( class_exists( '\\XSpeed\\Minifier' ) ) {
1412 Minifier::purge_minified();
1413 }
1414
1415 // Persistent object cache (Redis / Memcached). Flush regardless of
1416 // whether the Object Cache module is currently enabled — a drop-in
1417 // installed earlier keeps serving until flushed.
1418 if ( function_exists( 'wp_cache_flush' ) ) {
1419 wp_cache_flush();
1420 }
1421
1422 self::update_stats( array( 'last_purge' => time() ) );
1423
1424 // Fire AFTER the local sweep so module listeners (Critical CSS,
1425 // Unused CSS, Cloudflare edge purge) run — this action had three
1426 // registered listeners but was never emitted. Treat it as additive
1427 // (CDN / edge invalidation), not the mechanism for clearing local
1428 // files. (FBS-83114)
1429 do_action( 'xspeed_after_purge_all', $cause );
1430
1431 // The list behind the "Cached pages" card is memoized for a minute;
1432 // a purge has to drop it or the drill-down shows pages that no
1433 // longer exist.
1434 Cache_Inventory::invalidate();
1435
1436 // Trigger of WP_CLI / hook / admin-bar purges all hit the same
1437 // path. Record once with the supplied cause so the dashboard
1438 // activity feed reads naturally.
1439 Activity_Log::record(
1440 'cache_purged',
1441 sprintf( 'Cache purged (%s) — %d file%s removed', $cause, $count, 1 === $count ? '' : 's' ),
1442 Activity_Log::INFO
1443 );
1444
1445 return $count;
1446 }
1447
1448 /**
1449 * The per-type purge menu, LiteSpeed-style. Each entry is a cache type
1450 * the user can purge individually from the admin-bar dropdown. `visible`
1451 * controls whether the item shows (active + licensed module only) — it
1452 * NEVER limits Purge All, which always sweeps everything on disk.
1453 *
1454 * Pro registers its own types (Critical CSS, Unused CSS, …) by filtering
1455 * `xspeed_purge_types`, so Free degrades gracefully when Pro is absent.
1456 *
1457 * @return array<string,array{label:string,visible:bool}>
1458 */
1459 public static function purge_types(): array {
1460 $minify_on = false;
1461 if ( class_exists( '\\XSpeed\\Settings_Manager' ) ) {
1462 $min = Settings_Manager::get( 'minify' );
1463 $minify_on = ! empty( $min['minify_css'] ) || ! empty( $min['minify_js'] ) || ! empty( $min['combine_css'] ) || ! empty( $min['combine_js'] );
1464 }
1465 // Object cache is "active" when an external object-cache drop-in is in
1466 // use — the canonical WP signal, independent of our settings option.
1467 $oc_on = function_exists( 'wp_using_ext_object_cache' ) && wp_using_ext_object_cache();
1468
1469 $types = array(
1470 'all' => array(
1471 'label' => __( 'Purge All', 'xspeed' ),
1472 'visible' => true,
1473 ),
1474 'page' => array(
1475 'label' => __( 'Purge Page / Static Cache', 'xspeed' ),
1476 'visible' => true,
1477 ),
1478 'assets' => array(
1479 'label' => __( 'Purge CSS / JS Cache', 'xspeed' ),
1480 'visible' => $minify_on,
1481 ),
1482 'object' => array(
1483 'label' => __( 'Purge Object Cache', 'xspeed' ),
1484 'visible' => $oc_on,
1485 ),
1486 'rest' => array(
1487 'label' => __( 'Purge REST Cache', 'xspeed' ),
1488 'visible' => true,
1489 ),
1490 );
1491
1492 /**
1493 * Filter the admin-bar purge-type menu. Pro modules add their own
1494 * (Critical CSS, Unused CSS, CDN). Adding a type here only adds a
1495 * MENU item — purge_type() must know how to handle the same slug.
1496 *
1497 * @param array $types Map of slug => [label, visible].
1498 */
1499 return (array) apply_filters( 'xspeed_purge_types', $types );
1500 }
1501
1502 /**
1503 * Purge a single cache type by slug. 'all' delegates to purge_all();
1504 * every other slug clears just its own artifacts. Unknown slugs (e.g. a
1505 * Pro type) fan out via the `xspeed_purge_type_{slug}` action so the
1506 * owning module can handle it. Returns the number of items removed where
1507 * countable.
1508 *
1509 * @param string $type Cache type slug.
1510 * @param string $cause Who asked. Threaded through so the purge log can
1511 * tell an AI assistant's purge apart from a click —
1512 * "the cache cleared four times today" is only
1513 * actionable once you know what kept clearing it.
1514 */
1515 public static function purge_type( string $type, string $cause = 'manual' ): int {
1516 switch ( $type ) {
1517 case 'all':
1518 return self::purge_all( $cause );
1519
1520 case 'page':
1521 $count = 0;
1522 if ( is_dir( XSPEED_CACHE_DIR ) ) {
1523 foreach ( (array) glob( XSPEED_CACHE_DIR . '/*.html' ) as $f ) {
1524 wp_delete_file( $f );
1525 ++$count;
1526 }
1527 foreach ( (array) glob( XSPEED_CACHE_DIR . '/*.meta' ) as $m ) {
1528 wp_delete_file( $m );
1529 }
1530 foreach ( (array) glob( XSPEED_CACHE_DIR . '/*.br' ) as $b ) {
1531 wp_delete_file( $b );
1532 }
1533 }
1534 if ( is_dir( XSPEED_CACHE_STATIC_DIR ) ) {
1535 $count += self::rmtree_html( XSPEED_CACHE_STATIC_DIR );
1536 }
1537 self::update_stats( array( 'last_purge' => time() ) );
1538 Cache_Inventory::invalidate();
1539 self::record_partial_purge( 'page', $cause, $count );
1540 return $count;
1541
1542 case 'assets':
1543 if ( class_exists( '\\XSpeed\\Minifier' ) ) {
1544 Minifier::purge_minified();
1545 }
1546 self::record_partial_purge( 'assets', $cause, null );
1547 return 0;
1548
1549 case 'object':
1550 if ( function_exists( 'wp_cache_flush' ) ) {
1551 wp_cache_flush();
1552 }
1553 self::record_partial_purge( 'object cache', $cause, null );
1554 return 0;
1555
1556 case 'rest':
1557 $count = Rest_Cache::purge();
1558 self::record_partial_purge( 'REST responses', $cause, $count );
1559 return $count;
1560
1561 default:
1562 // Pro / third-party type — let the owning module handle it.
1563 do_action( 'xspeed_purge_type_' . $type );
1564 self::record_partial_purge( $type, $cause, null );
1565 return 0;
1566 }
1567 }
1568
1569 /**
1570 * Log a partial purge so the drill-down behind "Last purge" shows every
1571 * clear, not only the full ones. Without this a site whose object cache
1572 * is flushed on a schedule looks, from the log, like nothing happens.
1573 *
1574 * @param string $what Human label for the slice purged.
1575 * @param string $cause Who asked.
1576 * @param int|null $count Items removed, when countable.
1577 */
1578 private static function record_partial_purge( string $what, string $cause, ?int $count ): void {
1579 $message = null === $count
1580 ? sprintf(
1581 /* translators: 1: what was purged, 2: cause of the purge. */
1582 __( 'Purged %1$s (%2$s)', 'xspeed' ),
1583 $what,
1584 $cause
1585 )
1586 : sprintf(
1587 /* translators: 1: what was purged, 2: cause of the purge, 3: number of files removed. */
1588 __( 'Purged %1$s (%2$s) — %3$d file(s) removed', 'xspeed' ),
1589 $what,
1590 $cause,
1591 $count
1592 );
1593
1594 Activity_Log::record( 'cache_purged', $message, Activity_Log::INFO );
1595 }
1596
1597 /**
1598 * Clear the static tree only, leaving the flat cache in place.
1599 *
1600 * A narrower purge_all() for the case where only the web-server tree can
1601 * be wrong: its files are keyed by `{host}{path}` and nothing else, so a
1602 * response filed under the wrong path poisons it while the flat cache —
1603 * keyed by cache_key(), discriminators included — stays correct. Avoids
1604 * throwing away Critical CSS, minified bundles and the object cache to
1605 * fix a static-only problem.
1606 *
1607 * @return int Number of index.html files removed.
1608 */
1609 public static function purge_static_tree(): int {
1610 return self::rmtree_html( XSPEED_CACHE_STATIC_DIR );
1611 }
1612
1613 /**
1614 * Recursively delete every `index.html` (and its precompressed
1615 * `index.html.br` sibling, if the Pro Brotli module wrote one) plus
1616 * empty directories inside the static-cache tree. Used by purge_all().
1617 * Returns the number of .html files removed so purge stats stay accurate
1618 * across the flat + static caches — .br siblings are not counted
1619 * (they're encodings of a page, not pages).
1620 */
1621 private static function rmtree_html( string $dir ): int {
1622 if ( ! is_dir( $dir ) ) {
1623 return 0;
1624 }
1625 $removed = 0;
1626 // SCANDIR_SORT_NONE skips alphabetic sort — we're going to walk
1627 // the whole tree regardless of order.
1628 $entries = @scandir( $dir, SCANDIR_SORT_NONE ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
1629 if ( false === $entries ) {
1630 return 0;
1631 }
1632 foreach ( $entries as $entry ) {
1633 if ( '.' === $entry || '..' === $entry ) {
1634 continue;
1635 }
1636 $path = $dir . '/' . $entry;
1637 if ( is_dir( $path ) ) {
1638 $removed += self::rmtree_html( $path );
1639 // Best-effort empty-dir cleanup; ignore failures (a
1640 // foreign file inside would block rmdir, which is fine).
1641 // 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.
1642 @rmdir( $path );
1643 continue;
1644 }
1645 if ( substr( $entry, -5 ) === '.html' ) {
1646 wp_delete_file( $path );
1647 ++$removed;
1648 } elseif ( substr( $entry, -3 ) === '.br' ) {
1649 // Precompressed sibling (index.html.br). Remove it too so a
1650 // purge doesn't orphan stale Brotli bodies. Not counted.
1651 wp_delete_file( $path );
1652 }
1653 }
1654 return $removed;
1655 }
1656
1657 /**
1658 * Drop a "silence is golden" index.php into a directory so apaches/nginx
1659 * with directory listing enabled don't expose cache contents.
1660 */
1661 public static function write_silence( $dir ) {
1662 $file = trailingslashit( $dir ) . 'index.php';
1663 if ( ! file_exists( $file ) ) {
1664 // 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.
1665 file_put_contents( $file, "<?php\n// Silence is golden.\n" );
1666 }
1667 }
1668
1669 /**
1670 * The raw xspeed_stats option as an array. Keys currently in use:
1671 * 'last_purge', 'last_gc', 'gc_removed', 'gc_removed_total'.
1672 */
1673 public static function get_stats_option(): array {
1674 $stats = get_option( 'xspeed_stats', array() );
1675 return is_array( $stats ) ? $stats : array();
1676 }
1677
1678 /**
1679 * Persist stats with autoload disabled — stats are only read in admin
1680 * contexts, so there is no reason to inflate every frontend request's
1681 * `wp_load_alloptions()` payload.
1682 *
1683 * MERGES into whatever is already stored. It used to overwrite, which
1684 * was harmless while `last_purge` was the only key — with the GC keys
1685 * alongside it, a purge would have wiped the GC history and vice versa.
1686 */
1687 public static function update_stats( array $stats ) {
1688 if ( false === get_option( 'xspeed_stats', false ) ) {
1689 add_option( 'xspeed_stats', $stats, '', 'no' );
1690 return;
1691 }
1692 update_option( 'xspeed_stats', array_merge( self::get_stats_option(), $stats ) );
1693 }
1694
1695 public static function get_stats() {
1696 $count = 0;
1697 $size = 0;
1698 if ( is_dir( XSPEED_CACHE_DIR ) ) {
1699 $files = glob( XSPEED_CACHE_DIR . '/*.html' );
1700 if ( $files ) {
1701 $count = count( $files );
1702 foreach ( $files as $f ) {
1703 $size += filesize( $f );
1704 }
1705 }
1706 }
1707 // Drain the HIT-log file BEFORE reading totals. Two serve paths that
1708 // bypass the normal in-PHP record_hit() append one line per HIT here:
1709 // the nginx server-level rewrite (see nginx_snippet(), never reaches
1710 // PHP) and the advanced-cache.php drop-in (runs pre-WordPress, can't
1711 // reach Hit_Counter). Without this drain both look like a 0% hit-ratio
1712 // on a perfectly working cache.
1713 Hit_Counter::collect_nginx_log_hits();
1714
1715 // Apache/LiteSpeed static-rewrite HITs are served straight from disk
1716 // by .htaccess and never reach PHP either — but there's no .htaccess
1717 // equivalent of nginx's access_log directive, so we count them by
1718 // scanning the web server's own access log incrementally. No-op when
1719 // the log isn't readable (managed hosts) — see the method docblock.
1720 Hit_Counter::collect_server_log_hits();
1721
1722 $stats = get_option( 'xspeed_stats', array() );
1723 $totals = Hit_Counter::totals_24h();
1724 return array(
1725 'cached_pages' => $count,
1726 'cache_size' => $size,
1727 'last_purge' => isset( $stats['last_purge'] ) ? (int) $stats['last_purge'] : 0,
1728 // Rolling 24h cache performance — sourced from Hit_Counter's
1729 // hourly buckets. The frontend uses hit_ratio to drive the
1730 // CacheHero stat grid + the Health module's panel.
1731 'hits_24h' => $totals['hits'],
1732 'misses_24h' => $totals['misses'],
1733 'hit_ratio' => $totals['ratio'],
1734 // Requests kept OUT of the ratio (404s + bots) — surfaced as its own
1735 // "absorbed N scanner/bot requests" line rather than distorting the
1736 // cache-performance number. (#118)
1737 'excluded_24h' => $totals['excluded'],
1738 // True when an edge cache (Cloudflare) fronts the origin, so hits are
1739 // absorbed before reaching PHP. The dashboard labels the ratio
1740 // "origin-layer only" instead of implying it's the full picture. (#118)
1741 'edge_cache' => self::edge_cache_detected(),
1742 );
1743 }
1744
1745 /**
1746 * Whether the current request should be kept OUT of the cache hit/miss
1747 * ratio: a genuine 404, or a known bot / scanner. Runs at template_redirect
1748 * time, so is_404() is resolved. (#118)
1749 */
1750 private static function miss_is_excluded(): bool {
1751 if ( function_exists( 'is_404' ) && is_404() ) {
1752 return true;
1753 }
1754 $ua = isset( $_SERVER['HTTP_USER_AGENT'] )
1755 ? sanitize_text_field( wp_unslash( (string) $_SERVER['HTTP_USER_AGENT'] ) )
1756 : '';
1757 return Hit_Counter::is_bot_ua( $ua );
1758 }
1759
1760 /**
1761 * Whether an edge cache fronts this origin. Today: the Cloudflare
1762 * integration is connected — so an unknown share of hits is served at the
1763 * edge and never counted here, making the origin ratio a partial view the
1764 * dashboard must label as such. (#118)
1765 */
1766 private static function edge_cache_detected(): bool {
1767 $cf = get_option( 'xspeed_module_cloudflare', array() );
1768 return is_array( $cf ) && ! empty( $cf['enabled'] );
1769 }
1770
1771 /**
1772 * Apply the user's enable/disable choice. Called from the REST toggle
1773 * endpoint, which is gated by current_user_can( 'manage_options' ) and
1774 * a verified REST nonce.
1775 *
1776 * This is the only path that ENABLES caching — a drop-in is never
1777 * created for a user who hasn't opted in, which is the guideline that
1778 * matters (a plugin must not install drop-ins or edit wp-config.php
1779 * on a fresh activation). RESTORING the drop-in for a site that
1780 * already has cache_enabled = true is a different act and is handled
1781 * by restore_dropin_if_enabled() on activation and auto_heal() at
1782 * runtime; without it every plugin update silently un-caches the site.
1783 *
1784 * @param bool $enable User's choice.
1785 * @return array{
1786 * enabled: bool,
1787 * dropin_installed: bool,
1788 * wp_cache_constant: bool,
1789 * wp_config_writable: bool,
1790 * manual_snippet: ?string
1791 * }
1792 */
1793 public static function toggle( $enable ) {
1794 $enable = (bool) $enable;
1795
1796 if ( $enable ) {
1797 $dropin_ok = self::install_dropin();
1798 $wp_config_ok = self::set_wp_cache_constant( true );
1799 $rewrite_ok = self::install_rewrite();
1800 self::ensure_hits_log_file();
1801 self::sync_mobile_flag();
1802 $snippet = $wp_config_ok ? null : "define( 'WP_CACHE', true );";
1803
1804 Activity_Log::record(
1805 'cache_enabled_event',
1806 $wp_config_ok
1807 ? 'Cache enabled. Drop-in installed, WP_CACHE constant set.'
1808 : 'Cache enabled. Drop-in installed; wp-config.php not writable — add the WP_CACHE snippet manually.',
1809 $wp_config_ok ? Activity_Log::SUCCESS : Activity_Log::WARN
1810 );
1811
1812 return array(
1813 'enabled' => true,
1814 'dropin_installed' => (bool) $dropin_ok,
1815 'wp_cache_constant' => (bool) $wp_config_ok,
1816 'rewrite_installed' => (bool) $rewrite_ok,
1817 'wp_config_writable' => self::wp_config_writable(),
1818 'manual_snippet' => $snippet,
1819 'nginx_snippet' => self::nginx_snippet(),
1820 // Unified server-block snippet aggregating every enabled
1821 // module's directives — the same value the dashboard and
1822 // Health insight render. The wizard shows this so all three
1823 // surfaces stay in lockstep. Null on non-nginx hosts.
1824 'nginx_server_block' => self::full_nginx_server_block(),
1825 );
1826 }
1827
1828 self::remove_dropin();
1829 self::set_wp_cache_constant( false );
1830 self::remove_rewrite();
1831 // Drop the device-bucket marker too — with the drop-in gone there's
1832 // nothing left to read it, and leaving it behind would dirty a fresh
1833 // re-enable (and leaks across test runs).
1834 self::sync_mobile_flag( false );
1835
1836 Activity_Log::record(
1837 'cache_disabled_event',
1838 'Cache disabled. Drop-in removed.',
1839 Activity_Log::INFO
1840 );
1841
1842 return array(
1843 'enabled' => false,
1844 'dropin_installed' => false,
1845 'wp_cache_constant' => false,
1846 'rewrite_installed' => false,
1847 'wp_config_writable' => self::wp_config_writable(),
1848 'manual_snippet' => null,
1849 'nginx_snippet' => self::nginx_snippet(),
1850 'nginx_server_block' => self::full_nginx_server_block(),
1851 );
1852 }
1853
1854 /**
1855 * Check wp-config.php writability via WP_Filesystem. Plugin Check flags
1856 * direct is_writable() under WordPress.WP.AlternativeFunctions.
1857 */
1858 private static function wp_config_writable() {
1859 global $wp_filesystem;
1860 if ( ! function_exists( 'WP_Filesystem' ) ) {
1861 require_once ABSPATH . 'wp-admin/includes/file.php';
1862 }
1863 WP_Filesystem();
1864
1865 return $wp_filesystem ? (bool) $wp_filesystem->is_writable( ABSPATH . 'wp-config.php' ) : false;
1866 }
1867
1868 /**
1869 * Nginx server-block snippet mirroring the Apache rewrite block.
1870 * We never auto-write nginx config — it sits outside the WordPress
1871 * root and is owned by the server admin — but the dashboard
1872 * surfaces this snippet when nginx is detected so the admin can
1873 * paste it once and unlock the same PHP-bypass speedup we get on
1874 * Apache / LiteSpeed via .htaccess.
1875 *
1876 * Returns null when the server isn't nginx (no point showing it).
1877 */
1878 /**
1879 * Create wp-content/cache/xspeed/hits.log as an empty file so the
1880 * server-level rewrite's `access_log` directive has somewhere to
1881 * write on first request. Idempotent — touches an existing file
1882 * without disturbing accumulated lines. Called from Cache::toggle()
1883 * on enable and from auto_heal() when the file is missing.
1884 *
1885 * Permissions matter here. The file is created by PHP-FPM (often uid
1886 * www-data), but the nginx process that appends HIT lines may run as a
1887 * DIFFERENT uid — on multi-container hosts (e.g. xclude/Kinsta: nginx in
1888 * its own container as uid `nginx`, PHP-FPM in another as `www-data`)
1889 * they don't share a user at all. A default-umask 0644 file is then
1890 * unwritable by nginx, the access_log write silently fails, and the
1891 * dashboard shows a 0% hit ratio even though static HITs are serving.
1892 * So we widen the dir to 0777 and the file to 0666 — group/other write —
1893 * so whatever uid nginx runs as can append. (The file holds only HIT
1894 * request lines, no secrets.)
1895 */
1896 /**
1897 * Directory holding the nginx hit log. Lives under uploads/, NOT the
1898 * cache dir — uninstall.php and a cache purge both delete the cache
1899 * dir, which would orphan the pasted nginx `access_log` directive's
1900 * parent directory and make `nginx -t` fail [emerg], taking down every
1901 * vhost on the host (FBS-82478). uploads/ always exists, isn't a
1902 * plugin-managed cache dir, and is never deleted on uninstall — so the
1903 * directive's target dir survives both, and nginx (which creates a
1904 * missing log FILE but not a missing DIR) can always open it.
1905 *
1906 * Falls back to the cache dir only if uploads is somehow unavailable.
1907 */
1908 public static function hits_log_dir(): string {
1909 if ( function_exists( 'wp_upload_dir' ) ) {
1910 $uploads = wp_upload_dir( null, false );
1911 if ( is_array( $uploads ) && empty( $uploads['error'] ) && ! empty( $uploads['basedir'] ) ) {
1912 return rtrim( (string) $uploads['basedir'], '/' ) . '/xspeed';
1913 }
1914 }
1915 return XSPEED_CACHE_DIR;
1916 }
1917
1918 /** Absolute path to the nginx hit log file. */
1919 public static function hits_log_path(): string {
1920 return self::hits_log_dir() . '/hits.log';
1921 }
1922
1923 /**
1924 * Sync the drop-in's mobile-bucket flag file with the `mobile_separate`
1925 * setting. The drop-in (advanced-cache.php) runs before WordPress loads,
1926 * so it can't read the option — instead it checks for a zero-byte
1927 * `.mobile-separate` marker next to the cache files. When the setting is
1928 * on we touch the marker; when off we remove it. The drop-in's cache_key
1929 * computation keys off the marker's presence so its '|m'/'|d' device
1930 * bucket stays in lockstep with Cache::cache_key().
1931 *
1932 * Without this, turning on mobile_separate made Cache::store() write keys
1933 * with a '|d'/'|m' suffix the drop-in never reproduced — so the drop-in's
1934 * file_exists() always missed, every HIT fell through to a full WP boot,
1935 * and the fast pre-WP path was silently dead.
1936 *
1937 * @param bool|null $enabled Force a state; null reads the current setting.
1938 */
1939 public static function sync_mobile_flag( $enabled = null ): void {
1940 if ( null === $enabled ) {
1941 $opts = Settings_Manager::get( 'cache' );
1942 $enabled = ! empty( $opts['mobile_separate'] );
1943 }
1944 $dir = XSPEED_CACHE_DIR;
1945 $flag = $dir . '/.mobile-separate';
1946 if ( $enabled ) {
1947 if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
1948 return;
1949 }
1950 if ( ! file_exists( $flag ) ) {
1951 // 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.
1952 @touch( $flag );
1953 }
1954 return;
1955 }
1956 if ( file_exists( $flag ) ) {
1957 // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal.
1958 @unlink( $flag );
1959 }
1960 }
1961
1962 /**
1963 * Write / remove the `.maintenance-active` sentinel next to the cache
1964 * files. The pre-WP drop-in checks for this marker and bails when present,
1965 * so a page cached while the site was live is NOT served during
1966 * maintenance / coming-soon mode — WordPress loads and renders the
1967 * maintenance screen instead. The Pro Maintenance-Cache module drives this
1968 * on the maintenance on/off transition. (FBS-82409 B1)
1969 *
1970 * @param bool $active True to arm the sentinel (entering maintenance),
1971 * false to clear it (site recovered).
1972 */
1973 public static function sync_maintenance_flag( bool $active ): void {
1974 $dir = XSPEED_CACHE_DIR;
1975 $flag = $dir . '/.maintenance-active';
1976 if ( $active ) {
1977 if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
1978 return;
1979 }
1980 if ( ! file_exists( $flag ) ) {
1981 // 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.
1982 @touch( $flag );
1983 }
1984 return;
1985 }
1986 if ( file_exists( $flag ) ) {
1987 // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal.
1988 @unlink( $flag );
1989 }
1990 }
1991
1992 /**
1993 * Reconcile every mobile_separate-dependent artifact to the current
1994 * setting. Called on boot and whenever the cache settings are saved, so
1995 * flipping mobile_separate at runtime can't leave the install in a
1996 * half-converted state.
1997 *
1998 * Three things must agree with the setting:
1999 * 1. the drop-in's `.mobile-separate` flag (sync_mobile_flag()),
2000 * 2. the device-blind server rewrite — present only when OFF
2001 * (static_rewrite_allowed()),
2002 * 3. the now-stale static-cache tree + page cache, which were keyed
2003 * under the old scheme and would serve wrong-device HTML.
2004 *
2005 * No-ops when the cache is disabled — there's nothing installed to
2006 * reconcile, and toggle() handles install/teardown itself.
2007 */
2008 public static function reconcile_mobile_separate(): void {
2009 self::sync_mobile_flag();
2010
2011 // The rewrite/static reconciliation below needs the plugin's path
2012 // constants. They're absent in early-boot / unit-test contexts where
2013 // only the drop-in flag matters — bail to the flag-only behavior then.
2014 if ( ! defined( 'XSPEED_CACHE_STATIC_DIR' ) ) {
2015 return;
2016 }
2017
2018 // Only touch the rewrite + caches when caching is actually on.
2019 $opts = get_option( 'xspeed_options', array() );
2020 if ( empty( $opts['cache_enabled'] ) ) {
2021 return;
2022 }
2023
2024 $rewrite_present = self::rewrite_installed();
2025 $rewrite_wanted = self::static_rewrite_allowed();
2026
2027 // Did the thing that actually invalidates cache KEYS change?
2028 // mobile_separate buckets entries as |d / |m, so flipping it makes
2029 // stored entries mis-bucketed and they must go. A rewrite-state
2030 // mismatch from anything else (e.g. mod_headers detection, a hand-
2031 // edited .htaccess) changes no key at all — the same files are still
2032 // valid, they're just served by PHP instead of by the web server.
2033 // Purging there is what let one WP-CLI call wipe the whole cache on
2034 // every bootstrap. (#138)
2035 //
2036 // Read the setting from the SAME place static_rewrite_allowed() and
2037 // sync_mobile_flag() do — the cache module's settings, not the
2038 // top-level xspeed_options — or this marker would track a key that
2039 // never changes and a real flip would go unnoticed.
2040 $cache_opts = Settings_Manager::get( 'cache' );
2041 $mobile_now = ! empty( $cache_opts['mobile_separate'] );
2042 $mobile_last = get_option( 'xspeed_last_mobile_separate', null );
2043 $mobile_flipped = ( null !== $mobile_last && (bool) (int) $mobile_last !== $mobile_now );
2044
2045 if ( (string) (int) $mobile_now !== (string) $mobile_last ) {
2046 update_option( 'xspeed_last_mobile_separate', $mobile_now ? '1' : '0', false );
2047 }
2048
2049 if ( $rewrite_present === $rewrite_wanted ) {
2050 // Already consistent — nothing flipped, leave caches intact so a
2051 // plain settings save (e.g. expiry change) doesn't blow the cache.
2052 return;
2053 }
2054
2055 // Bring the rewrite into line with what this server actually supports.
2056 if ( $rewrite_wanted ) {
2057 self::install_rewrite();
2058 } else {
2059 self::remove_rewrite();
2060 }
2061
2062 // Only discard cache contents when the device bucketing changed.
2063 if ( $mobile_flipped ) {
2064 self::purge_all( 'mobile_separate changed' );
2065 }
2066 }
2067
2068 /**
2069 * Whether the server-level static-rewrite fast path may be used.
2070 *
2071 * The rewrite serves `{host}{path}/index.html` straight from the web
2072 * server, keyed only by host + path — it has no way to run our PHP
2073 * device detection, so it can't tell mobile from desktop. When
2074 * `mobile_separate` is on, a single static file would be shared across
2075 * devices and whoever primed it wins (mobile visitors could get desktop
2076 * HTML, or vice-versa). Rather than duplicate a wp_is_mobile()-equivalent
2077 * UA matcher into .htaccess AND the nginx snippet (three copies that
2078 * would inevitably drift), we simply DON'T engage the static rewrite when
2079 * mobile_separate is on. Requests then fall through to the PHP drop-in,
2080 * which buckets correctly — a small TTFB cost (~85ms vs ~30ms) paid only
2081 * on mobile-separate sites, in exchange for guaranteed correctness.
2082 *
2083 * LiteSpeed exclusion (2026-06-16): on LiteSpeed — OpenLiteSpeed in
2084 * particular — `.htaccess` CAN run our RewriteRule to serve the static
2085 * file, but its `.htaccess` engine ignores `mod_headers`, so we cannot
2086 * stamp the served response with `X-XSpeed-Cache: HIT`, AND there is no
2087 * `.htaccess` equivalent of nginx's per-location `access_log` to record
2088 * the hit. The result was a cache that worked but was invisible: no HIT
2089 * header and a hit-ratio frozen near 0%. Every OTHER server gives the
2090 * user a visible HIT header + a counted hit (nginx via add_header +
2091 * access_log in its snippet; Apache via the `<IfModule mod_headers.c>`
2092 * block in rewrite_block_lines(), WHEN that module is loaded — when it is
2093 * not, Apache takes this same drop-in fallback). To keep LiteSpeed
2094 * CONSISTENT with the rest, we route its hits
2095 * through the PHP drop-in instead — the drop-in emits
2096 * `X-XSpeed-Cache: HIT (php)` and calls Hit_Counter inline, exactly the
2097 * observable behavior the other servers get. The cost is the drop-in's
2098 * ~30ms TTFB vs the static path's ~10ms, paid only on LiteSpeed; in
2099 * exchange the dashboard hit-ratio and the response header finally tell
2100 * the truth there. (Apache keeps the static fast path — it honors the
2101 * header.) See maybe_emit_lscache_headers() for the paired LSCache
2102 * stand-down that stops LiteSpeed's own module from shadowing the
2103 * drop-in.
2104 */
2105 public static function static_rewrite_allowed(): bool {
2106 // LiteSpeed: drop-in serves hits (visible + counted) — see docblock.
2107 if ( Server::LITESPEED === Server::type() ) {
2108 return false;
2109 }
2110 // Apache without mod_headers is in EXACTLY the position LiteSpeed
2111 // is in above: it can run the RewriteRule and serve the static
2112 // file, but it cannot stamp `X-XSpeed-Cache` on the response, so
2113 // the hit is invisible to the user and uncountable by
2114 // Hit_Counter. The docblock above used to assert Apache "honors
2115 // mod_headers" and left it on the fast path unconditionally —
2116 // true only when the module is actually loaded. Fall back to the
2117 // drop-in when it isn't, trading ~10ms of TTFB for a hit that
2118 // shows up in the header and the ratio. (Field report: hit ratio
2119 // pinned at 0% on a working Apache cache.)
2120 if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) {
2121 return false;
2122 }
2123 $opts = Settings_Manager::get( 'cache' );
2124 return empty( $opts['mobile_separate'] );
2125 }
2126
2127 /**
2128 * Why the device-blind static rewrite is NOT installed, when it isn't.
2129 * Returns 'mobile_separate' when Separate Mobile Cache is the blocker
2130 * (the static file is one-per-URL, so it can't coexist with per-device
2131 * buckets), 'no_mod_headers' when Apache can't stamp the HIT header,
2132 * '' otherwise. Lets the dashboard explain the slow path instead of
2133 * silently falling back to PHP serving. (FBS-83145)
2134 *
2135 * Every refusal in static_rewrite_allowed() that is NOT self-explanatory
2136 * must have a branch here. Otherwise the Health card falls through to
2137 * "Block missing — toggle Enable Cache off and on to reinstall it",
2138 * advice that cannot work: the same condition that suppressed the write
2139 * suppresses the reinstall, and auto_heal() strips the block again on
2140 * the next admin page load. (Field report: Apache host with mod_headers
2141 * unloaded sat on the slow path with no way to find out why.)
2142 */
2143 /**
2144 * Qualify a raw probe result with what we already KNOW about config.
2145 *
2146 * probe_static_rewrite() writes its own file under the static-cache tree
2147 * and fetches that, which succeeds whenever the web server can serve a
2148 * static file at all — including when static_rewrite_allowed() is false
2149 * and no real page is on the static path. So `active: true` on its own is
2150 * not evidence that pages are being served statically.
2151 *
2152 * The reachable case is nginx with Separate Mobile Cache on: the snippet
2153 * lives in the server block and we cannot remove it, pages are
2154 * deliberately routed to the PHP drop-in, but the probe file is still
2155 * served directly.
2156 *
2157 * The Health panel learned this in 88b4b50; the CLI, REST and MCP paths
2158 * did not, so they kept reporting "active" in exactly that configuration.
2159 * Rather than repeat the reasoning at each call site, they now all come
2160 * through here.
2161 *
2162 * Deliberately does NOT consult rewrite_installed(): on nginx the fast
2163 * path is the pasted snippet and there is no .htaccess marker to find, so
2164 * requiring one would report every correctly-configured nginx site as
2165 * broken.
2166 *
2167 * @param array $probe Raw result from probe_static_rewrite().
2168 * @return array{active:bool,inconclusive:bool,reason:string,block_reason:string}
2169 */
2170 public static function qualify_rewrite_probe( array $probe ): array {
2171 $active = (bool) ( $probe['active'] ?? false );
2172 $inconclusive = (bool) ( $probe['inconclusive'] ?? false );
2173 $reason = (string) ( $probe['reason'] ?? '' );
2174 $block_reason = self::static_rewrite_block_reason();
2175
2176 // A known refusal outranks the probe, and also outranks
2177 // "inconclusive" — a blocked rewrite whose probe merely failed to
2178 // complete is still definitely blocked.
2179 if ( '' !== $block_reason ) {
2180 $active = false;
2181 $inconclusive = false;
2182 $reason = self::block_reason_text( $block_reason );
2183 }
2184
2185 return array(
2186 'active' => $active,
2187 'inconclusive' => $inconclusive,
2188 'reason' => $reason,
2189 'block_reason' => $block_reason,
2190 );
2191 }
2192
2193 /**
2194 * Human-readable explanation for a static_rewrite_block_reason() code.
2195 *
2196 * Each one has to say what to DO about it: "mobile_separate" alone tells
2197 * a user nothing, and the whole point of surfacing a refusal instead of
2198 * the probe verdict is that it is actionable.
2199 */
2200 public static function block_reason_text( string $code ): string {
2201 switch ( $code ) {
2202 case 'mobile_separate':
2203 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.';
2204 case 'no_mod_headers':
2205 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.";
2206 default:
2207 return sprintf( 'The static rewrite is disabled (%s).', $code );
2208 }
2209 }
2210
2211 public static function static_rewrite_block_reason(): string {
2212 if ( Server::LITESPEED === Server::type() ) {
2213 return ''; // Intended on LiteSpeed — not a "block".
2214 }
2215 if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) {
2216 return 'no_mod_headers';
2217 }
2218 $opts = Settings_Manager::get( 'cache' );
2219 return ! empty( $opts['mobile_separate'] ) ? 'mobile_separate' : '';
2220 }
2221
2222 /**
2223 * Whether migration flagged Separate Mobile Cache for user review. Set by
2224 * Migration::map_mobile_separate() when a source plugin (WP Rocket / WP
2225 * Super Cache / LiteSpeed) had its "separate mobile cache" option on: we
2226 * import it as OFF (to keep the device-blind static fast path) but record
2227 * this flag so the dashboard can invite the user to turn it back on only
2228 * if their site genuinely serves different HTML per device. (FBS-83145)
2229 */
2230 public static function mobile_separate_needs_review(): bool {
2231 $opts = Settings_Manager::get( 'cache' );
2232 return ! empty( $opts['mobile_separate_review'] );
2233 }
2234
2235 /**
2236 * Clear the review flag — called when the user has acted on the prompt
2237 * (dismissed it, or turned Separate Mobile Cache on/off deliberately) so
2238 * the dashboard callout doesn't nag forever. Writes the option directly
2239 * (bypassing Settings_Manager) so it never touches schema fields.
2240 */
2241 public static function clear_mobile_separate_review(): void {
2242 $stored = get_option( 'xspeed_module_cache', array() );
2243 if ( ! is_array( $stored ) || empty( $stored['mobile_separate_review'] ) ) {
2244 return;
2245 }
2246 unset( $stored['mobile_separate_review'] );
2247 update_option( 'xspeed_module_cache', $stored );
2248 }
2249
2250 /**
2251 * On-demand probe: does the homepage serve materially the same HTML to a
2252 * desktop and a mobile browser? Fetches home_url() twice over loopback —
2253 * once with a desktop User-Agent, once with a mobile one — strips
2254 * per-request noise (nonces, CSRF tokens, session ids, inline timestamps),
2255 * and compares. When identical, Separate Mobile Cache is almost certainly
2256 * unnecessary and the user can turn it off to regain the static fast path.
2257 *
2258 * NEVER run automatically (no page-load cost) — only from the dashboard
2259 * "Check now" button. Result is cached for 10 minutes so a double-click or
2260 * a re-render doesn't fire two more self-requests. (FBS-83145)
2261 *
2262 * @return array{ identical:bool, checked:bool, reason?:string, desktop_bytes?:int, mobile_bytes?:int }
2263 */
2264 public static function probe_mobile_equality(): array {
2265 $cached = get_transient( 'xspeed_mobile_equality_probe' );
2266 if ( is_array( $cached ) ) {
2267 return $cached;
2268 }
2269
2270 $home = home_url( '/' );
2271 $host = (string) wp_parse_url( $home, PHP_URL_HOST );
2272 if ( '' === $host ) {
2273 $result = array( 'identical' => false, 'checked' => false, 'reason' => 'home_url has no host' );
2274 set_transient( 'xspeed_mobile_equality_probe', $result, MINUTE_IN_SECONDS );
2275 return $result;
2276 }
2277
2278 // Match WP core's own mobile detection (wp_is_mobile) so the probe
2279 // reflects what the site would actually branch on. iPhone Safari for
2280 // mobile; a current desktop Chrome UA for desktop.
2281 $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';
2282 $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';
2283
2284 $is_local = function_exists( 'wp_get_environment_type' )
2285 && in_array( wp_get_environment_type(), array( 'local', 'development' ), true );
2286
2287 $fetch = static function ( string $ua ) use ( $home, $is_local ) {
2288 $resp = wp_remote_get(
2289 $home,
2290 array(
2291 'timeout' => 5,
2292 'sslverify' => ! $is_local,
2293 'redirection' => 2,
2294 // Bust any per-device cache so we compare freshly-rendered
2295 // HTML, and pass the device UA the site would branch on.
2296 'user-agent' => $ua,
2297 'headers' => array( 'Cache-Control' => 'no-cache' ),
2298 )
2299 );
2300 if ( is_wp_error( $resp ) || 200 !== (int) wp_remote_retrieve_response_code( $resp ) ) {
2301 return null;
2302 }
2303 return (string) wp_remote_retrieve_body( $resp );
2304 };
2305
2306 $desktop = $fetch( $desktop_ua );
2307 $mobile = $fetch( $mobile_ua );
2308
2309 if ( null === $desktop || null === $mobile ) {
2310 $result = array( 'identical' => false, 'checked' => false, 'reason' => 'could not fetch homepage twice' );
2311 set_transient( 'xspeed_mobile_equality_probe', $result, MINUTE_IN_SECONDS );
2312 return $result;
2313 }
2314
2315 $identical = self::normalize_html_for_diff( $desktop ) === self::normalize_html_for_diff( $mobile );
2316
2317 $result = array(
2318 'identical' => $identical,
2319 'checked' => true,
2320 'desktop_bytes' => strlen( $desktop ),
2321 'mobile_bytes' => strlen( $mobile ),
2322 );
2323 set_transient( 'xspeed_mobile_equality_probe', $result, 10 * MINUTE_IN_SECONDS );
2324 return $result;
2325 }
2326
2327 /**
2328 * Strip per-request noise from HTML so a desktop-vs-mobile diff reflects
2329 * real structural differences, not nonces / session ids / timestamps that
2330 * change on every render. Deliberately conservative: it normalizes the
2331 * handful of well-known noise sources and collapses whitespace, so a site
2332 * that truly serves different markup per device still compares as different.
2333 */
2334 private static function normalize_html_for_diff( string $html ): string {
2335 $patterns = array(
2336 // WP nonces (data-nonce="...", _wpnonce=..., "nonce":"...").
2337 '/(_wpnonce|nonce|_ajax_nonce)["\']?\s*[:=]\s*["\']?[a-f0-9]{10}/i',
2338 // Generic 10+ hex tokens (CSRF, cache-buster hashes, session ids).
2339 '/\b[a-f0-9]{16,}\b/i',
2340 // wp-generated unique ids (e.g. wp-block ids, aria ids).
2341 '/(id|for|aria-[a-z]+)="[^"]*-[0-9]{3,}"/i',
2342 // ISO-ish timestamps + epoch-looking numbers in query strings.
2343 '/\?ver=[0-9.]+/',
2344 '/[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.+Z-]+/',
2345 );
2346 $html = (string) preg_replace( $patterns, 'X', $html );
2347 // Collapse all whitespace so trivial formatting differences don't count.
2348 return trim( (string) preg_replace( '/\s+/', ' ', $html ) );
2349 }
2350
2351 public static function ensure_hits_log_file(): bool {
2352 // TWO writers append to this log, and an earlier fix conflated them:
2353 //
2354 // 1. nginx, via the server-level `access_log` directive in
2355 // nginx_snippet() — a DIFFERENT uid, which is why the file needs
2356 // to be world-writable there.
2357 // 2. the PHP drop-in (advanced-cache.php), on EVERY server. A hit it
2358 // serves bypasses WordPress entirely, so it can't call
2359 // Hit_Counter::record_hit() — appending here is the only way that
2360 // hit is ever counted.
2361 //
2362 // The nginx-only early return that used to sit at the top of this
2363 // method was fixing something real: chmod() on a file PHP doesn't own
2364 // raises "Operation not permitted", and off nginx that chmod buys
2365 // nothing. But it took directory creation with it, so on LiteSpeed
2366 // (which always serves via the drop-in), on Apache without mod_headers,
2367 // and anywhere mobile_separate forces the drop-in path, writer 2 was
2368 // appending to a file whose parent directory did not exist. The append
2369 // is @-suppressed and documented as non-fatal, so every one of those
2370 // hits vanished and the dashboard ratio sat at 0% forever.
2371 //
2372 // So: create the dir + file everywhere, and keep only the chmod gated
2373 // to nginx.
2374 $dir = self::hits_log_dir();
2375 if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
2376 return false;
2377 }
2378
2379 $is_nginx = ( Server::NGINX === Server::type() );
2380
2381 if ( $is_nginx ) {
2382 // Ensure the dir is traversable + writable by a different-uid nginx.
2383 // 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.
2384 @chmod( $dir, 0777 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort; the access_log just stays empty if it fails.
2385 }
2386
2387 $path = self::hits_log_path();
2388 if ( ! file_exists( $path ) ) {
2389 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch -- See docblock: must be a plain touch, not WP_Filesystem.
2390 @touch( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- non-fatal helper; failures already covered by the dir check.
2391 }
2392
2393 if ( $is_nginx ) {
2394 // World-writable so a different-uid nginx can append HIT lines.
2395 // Off nginx the drop-in appends as the same uid that owns the file,
2396 // so this is unnecessary — and would emit the "Operation not
2397 // permitted" warnings the old early return was added to silence.
2398 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- See docblock.
2399 @chmod( $path, 0666 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort.
2400 }
2401
2402 return file_exists( $path );
2403 }
2404
2405 public static function nginx_snippet(): ?string {
2406 if ( Server::NGINX !== Server::type() ) {
2407 return null;
2408 }
2409 $rel = '/' . ltrim( str_replace( ABSPATH, '/', XSPEED_CACHE_STATIC_DIR ), '/' );
2410 $rel = rtrim( $rel, '/' );
2411
2412 // WP-Rocket-canonical pattern: every condition lives at
2413 // SERVER level (outside any location block). Each one appends
2414 // a tag to $xspeed_no_cache; the final check is a single
2415 // string-equality against the unmodified default "no-cache".
2416 // Only when ALL conditions pass does the rewrite fire,
2417 // jumping the request to the static file's URL. nginx then
2418 // restarts location matching against the new path, where
2419 // regular static-file serving takes over.
2420 //
2421 // Why server-level + a single rewrite (instead of try_files
2422 // inside `location /`): nginx's well-documented "if is evil"
2423 // quirk silently disables `try_files`'s last fallback when
2424 // any `if` in the same location is true. Moving the `if`s
2425 // outside any location dodges the trap completely, because
2426 // server-level rewrite is the documented stable path.
2427 //
2428 // `last` (not `break`) restarts location matching — required
2429 // so the rewritten static-file URI gets served via the normal
2430 // static-file location, not re-matched against `location /`
2431 // where our own rewrite would loop.
2432 //
2433 // The cache existence check is the LAST condition in the
2434 // chain so when the file isn't cached, $xspeed_no_cache
2435 // gets a "-nofile" tag and the rewrite is skipped — the
2436 // request falls through to whatever `location /` the user
2437 // already had (typically `try_files $uri $uri/ /index.php?$args;`).
2438 // Absolute path to the hit-log file from the nginx process's
2439 // filesystem view. Nginx's `access_log buffer=N flush=Ns` form
2440 // requires a literal path — `$document_root` variables are
2441 // rejected — so PHP computes it. Lives under uploads/ (NOT the
2442 // cache dir): a cache purge or uninstall deletes the cache dir,
2443 // which would orphan this directive's parent directory and make
2444 // `nginx -t` fail [emerg] for EVERY vhost on the host
2445 // (FBS-82478). uploads/ survives both, so the directive can
2446 // never take nginx down. Works on every topology where the nginx
2447 // process shares a filesystem with PHP (container or host).
2448 $hits_abs = self::hits_log_path();
2449
2450 $lines = array();
2451 $lines[] = '# xSpeed static cache — paste at server level, above location / { }.';
2452 // Cache host must match the on-disk dir PHP writes: store_static() /
2453 // static_host() take HTTP_HOST and strip every char outside
2454 // [a-zA-Z0-9.\-] — i.e. it removes the colon but KEEPS the port digits
2455 // (localhost:8192 → localhost8192). nginx's own $host can't reproduce
2456 // that: $host has the port already stripped ENTIRELY (→ localhost), so
2457 // the -f check looks for localhost/... while PHP wrote localhost8192/...
2458 // and the rewrite never fires on a non-standard port. Derive
2459 // $xspeed_host from $http_host (which keeps the port) and drop just the
2460 // colon, so it equals the PHP dir on every port. On standard ports
2461 // $http_host has no colon, so $xspeed_host == $host == the bare domain.
2462 $lines[] = 'set $xspeed_host $http_host;'; // default: no port → unchanged (e.g. example.com)
2463 $lines[] = 'if ($http_host ~ "^([^:]+):(\\d+)$") { set $xspeed_host $1$2; }'; // host:port → hostport (matches PHP static_host())
2464 $lines[] = 'set $xspeed_no_cache "no-cache";';
2465 $lines[] = 'if ($request_method != GET) { set $xspeed_no_cache "$xspeed_no_cache-method"; }';
2466 $lines[] = 'if ($args) { set $xspeed_no_cache "$xspeed_no_cache-args"; }';
2467 // Cookie + user-agent exclusions, generated from the user's actual
2468 // settings rather than a hardcoded list. Before this, the rule
2469 // tested three fixed cookie names and no user agent at all, so
2470 // every excluded_cookies / bypass_user_agents entry applied only
2471 // while a page was cold — on a warm page nginx served the shared
2472 // anonymous copy to carts, members and bypassed bots alike. The
2473 // three historical names survive as a floor inside cookie_rule().
2474 // `~*` is case-insensitive, matching PHP's stripos()/glob checks.
2475 $cache_opts = Settings_Manager::get( 'cache' );
2476 $cookie_rule = Server_Rules::cookie_rule(
2477 is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array()
2478 );
2479 $lines[] = 'if ($http_cookie ~* "(' . $cookie_rule['regex'] . ')") { set $xspeed_no_cache "$xspeed_no_cache-cookie"; }';
2480
2481 $ua_rule = Server_Rules::user_agent_rule(
2482 is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array()
2483 );
2484 // Emitted only when the list is non-empty — an empty alternation
2485 // would compile to `(...)` matching every request and disable the
2486 // fast path entirely.
2487 if ( '' !== $ua_rule['regex'] ) {
2488 $lines[] = 'if ($http_user_agent ~* "(' . $ua_rule['regex'] . ')") { set $xspeed_no_cache "$xspeed_no_cache-ua"; }';
2489 }
2490 $lines[] = 'if (!-f "$document_root' . $rel . '/$xspeed_host$uri/index.html") { set $xspeed_no_cache "$xspeed_no_cache-nofile"; }';
2491 // Neither `add_header` nor `access_log` is allowed inside an `if{}`
2492 // at server level (nginx rejects with "directive is not allowed
2493 // here"). The logging therefore lives in a `location` block that
2494 // matches the rewritten URI after `rewrite … last;` restarts
2495 // location matching. Every HIT lands there exactly once, every
2496 // MISS / PHP-served request never matches it.
2497 $lines[] = 'if ($xspeed_no_cache = "no-cache") {';
2498 $lines[] = ' rewrite ^ ' . $rel . '/$xspeed_host$uri/index.html last;';
2499 $lines[] = '}';
2500 $lines[] = '';
2501 $lines[] = '# Serve + log the cached HIT — `^~` is required so this beats any regex location.';
2502 $lines[] = 'location ^~ ' . $rel . '/ {';
2503 $lines[] = ' internal;';
2504 // LITERAL log path (not `set $var; access_log $var`). The variable form
2505 // makes nginx open the log lazily per-request and SILENTLY drop the
2506 // line if the open fails — so on a working host hits were served
2507 // (X-XSpeed-Cache fires regardless) but nothing was ever written and
2508 // the hit ratio sat at 0%. A literal path makes nginx open the file at
2509 // config load and actually log every hit.
2510 //
2511 // Deleting the log FILE is still safe with a literal path: nginx
2512 // recreates it on the next write/reload and `nginx -t` stays green
2513 // (verified). The only thing that [emerg]s `nginx -t` is a missing
2514 // parent DIRECTORY — and the log lives under uploads/xspeed/, which
2515 // survives cache purge + uninstall, and which ensure_hits_log_file()
2516 // (run on every admin_init via auto_heal) recreates if it ever goes
2517 // missing. So: hits are logged, and a user deleting the log can't take
2518 // nginx down.
2519 $lines[] = ' access_log ' . $hits_abs . ' combined buffer=16k flush=5s;';
2520 $lines[] = ' add_header X-XSpeed-Cache "HIT (nginx)" always;';
2521 $lines[] = '}';
2522 return implode( "\n", $lines );
2523 }
2524
2525 /**
2526 * Aggregate every enabled module's nginx_directives() into one
2527 * pasteable server-block snippet. Replaces the per-module "paste
2528 * this snippet" notices with a single consolidated paste — every
2529 * future feature toggle just regenerates this output.
2530 *
2531 * Returns null on non-nginx hosts (nothing to paste).
2532 *
2533 * Sections render in module-registration order so the layout stays
2534 * predictable; each module gets a comment header `# <slug>`.
2535 */
2536 public static function full_nginx_server_block(): ?string {
2537 if ( Server::NGINX !== Server::type() ) {
2538 return null;
2539 }
2540
2541 $blocks = array();
2542 foreach ( Module_Registry::all() as $module ) {
2543 $directives = $module->nginx_directives();
2544 if ( ! is_string( $directives ) || '' === trim( $directives ) ) {
2545 continue;
2546 }
2547 $blocks[] = "# === " . $module->slug() . " ===\n" . rtrim( $directives );
2548 }
2549
2550 if ( empty( $blocks ) ) {
2551 return null;
2552 }
2553
2554 $header = "# xSpeed unified nginx config — paste into `server { }`, above `location / { }`; re-paste after toggling features.\n";
2555
2556 return $header . "\n" . implode( "\n\n", $blocks ) . "\n";
2557 }
2558
2559 /**
2560 * Tell LiteSpeed's LSCache module to stand down on the cache-miss
2561 * render path.
2562 *
2563 * History: this method used to emit X-LiteSpeed-Cache-Control:
2564 * public,max-age=N + X-LiteSpeed-Tag, handing caching to the server's
2565 * LSCache store. That delegation backfired — once LSCache cached a
2566 * page it served every subsequent request from its OWN store and
2567 * intercepted the request before our site-root .htaccess static
2568 * rewrite could run. Net effect on LiteSpeed hosts: no X-XSpeed-Cache
2569 * header, our static-cache tree never served, the HIT log never
2570 * written (hit ratio frozen at 0%), and the Health probe reporting a
2571 * false "cache running on PHP fallback" because it never saw an
2572 * xSpeed-served response.
2573 *
2574 * xSpeed now owns the cache on LiteSpeed exactly as it does on Apache:
2575 * our `.htaccess` mod_rewrite block serves hits straight from the
2576 * static-cache tree (with the X-XSpeed-Cache header + access-log HIT
2577 * accounting), and PHP/the drop-in is the fallback. To guarantee
2578 * LSCache doesn't shadow that with its own copy — some LiteSpeed
2579 * configs cache by default — we send an explicit `no-cache` control so
2580 * the server defers to our rewrite. Skipped when the LiteSpeed Cache
2581 * plugin is active (it owns its own header policy; our Conflict
2582 * registry handles that coexistence separately).
2583 */
2584 public static function maybe_emit_lscache_headers(): void {
2585 if ( headers_sent() ) {
2586 return;
2587 }
2588 if ( Server::LITESPEED !== Server::type() ) {
2589 return;
2590 }
2591 // is_plugin_active() lives in wp-admin/includes/plugin.php which
2592 // isn't auto-loaded on front-end requests. Use the option layer
2593 // directly to avoid pulling in admin code from a render path.
2594 $active = (array) get_option( 'active_plugins', array() );
2595 if ( in_array( 'litespeed-cache/litespeed-cache.php', $active, true ) ) {
2596 return;
2597 }
2598
2599 // Explicitly opt this response OUT of LSCache so the server can't
2600 // shadow our static-rewrite cache with its own internal copy.
2601 header( 'X-LiteSpeed-Cache-Control: no-cache' );
2602 }
2603
2604 /**
2605 * Restore the drop-in + WP_CACHE constant for a site that had caching
2606 * ON before this activation — and ONLY for such a site.
2607 *
2608 * WordPress runs an upgrade as deactivate → wipe plugin files →
2609 * install → activate. The wipe takes advanced-cache.php with it, so
2610 * without this the site serves 100% uncached from the moment the
2611 * update finishes until the next authenticated wp-admin page load
2612 * (auto_heal() is on admin_init). On a site whose admin logs in
2613 * rarely that window is hours or days of silent cache loss, while
2614 * the dashboard still reports cache_enabled = true. (FBS field
2615 * report against 1.1.2 / Pro 1.0.5.)
2616 *
2617 * The `cache_enabled` guard is the whole contract: a FRESH install
2618 * has the option unset, so activation writes nothing and the user
2619 * still opts in explicitly through Cache::toggle() via the
2620 * /cache/toggle REST endpoint. We only ever put back state the user
2621 * already chose — repair, never a new install path. This is what
2622 * keeps us on the right side of the "don't create drop-ins the user
2623 * didn't ask for" guideline while matching what WP Rocket, W3 Total
2624 * Cache and WP Super Cache all do on activation.
2625 *
2626 * @return bool True when a restore was performed.
2627 */
2628 public static function restore_dropin_if_enabled(): bool {
2629 if ( defined( 'WP_INSTALLING' ) && WP_INSTALLING ) {
2630 return false;
2631 }
2632
2633 // The user's saved choice. Absent/false on a fresh install => no
2634 // drop-in is written and nothing touches wp-config.php.
2635 $opts = get_option( 'xspeed_options', array() );
2636 if ( empty( $opts['cache_enabled'] ) ) {
2637 return false;
2638 }
2639
2640 $restored = false;
2641
2642 // Only (re)install when the drop-in is missing, foreign, or an
2643 // older version of ours — never rewrite a current, healthy file.
2644 $target = WP_CONTENT_DIR . '/advanced-cache.php';
2645 $needs = true;
2646 if ( file_exists( $target ) ) {
2647 $contents = @file_get_contents( $target ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Best-effort read; a failure just means we reinstall.
2648 if ( is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' ) ) {
2649 $source = @file_get_contents( XSPEED_DIR . 'includes/advanced-cache.php' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Same.
2650 $needs = self::dropin_version( $contents ) < self::dropin_version( is_string( $source ) ? $source : '' );
2651 }
2652 }
2653 if ( $needs && self::install_dropin() ) {
2654 $restored = true;
2655 }
2656
2657 // WP_CACHE lives in wp-config.php, which the upgrade doesn't touch —
2658 // but a foreign cache plugin or a hand-edit can drop it, and without
2659 // it core never loads the drop-in at all.
2660 if ( ! defined( 'WP_CACHE' ) || ! WP_CACHE ) {
2661 if ( self::set_wp_cache_constant( true ) ) {
2662 $restored = true;
2663 }
2664 }
2665
2666 if ( $restored ) {
2667 Activity_Log::record(
2668 'cache_dropin_restored',
2669 'Cache drop-in restored after a plugin update — caching was already enabled.',
2670 Activity_Log::SUCCESS
2671 );
2672 }
2673
2674 return $restored;
2675 }
2676
2677 /**
2678 * Reconcile drop-in + WP_CACHE + rewrite block with the user's
2679 * saved choice. Runs on admin_init. Cheap when nothing's wrong
2680 * (one option read + a handful of file_exists / defined checks);
2681 * writes only when state has drifted (typical cause: plugin
2682 * upgrade wiped the drop-in, foreign plugin removed our WP_CACHE
2683 * define, or someone hand-edited .htaccess).
2684 *
2685 * Skipped during the WP plugin updater run so we don't race
2686 * the upgrader's own filesystem operations.
2687 */
2688 public static function auto_heal(): void {
2689 if ( defined( 'WP_INSTALLING' ) && WP_INSTALLING ) {
2690 return;
2691 }
2692 if ( wp_doing_ajax() || wp_doing_cron() ) {
2693 return;
2694 }
2695
2696 $opts = get_option( 'xspeed_options', array() );
2697 if ( empty( $opts['cache_enabled'] ) ) {
2698 return;
2699 }
2700
2701 $dropin_target = WP_CONTENT_DIR . '/advanced-cache.php';
2702 $dropin_ours = false;
2703 $dropin_stale = false;
2704 if ( file_exists( $dropin_target ) ) {
2705 $contents = @file_get_contents( $dropin_target ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
2706 $dropin_ours = is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' );
2707 // Reinstall when OUR drop-in is an older version than the source —
2708 // the marker alone can't distinguish an old copy from a new one, so
2709 // a serve-logic change (e.g. the .meta read for 404s/feeds) would
2710 // otherwise never reach existing cache-enabled sites until a manual
2711 // cache toggle. (FBS-82406/82407)
2712 if ( $dropin_ours ) {
2713 $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
2714 }
2715 }
2716
2717 if ( ! $dropin_ours || $dropin_stale ) {
2718 self::install_dropin();
2719 }
2720
2721 if ( ! defined( 'WP_CACHE' ) || ! WP_CACHE ) {
2722 self::set_wp_cache_constant( true );
2723 }
2724
2725 // Rewrite block goes last. It's what turns the static-cache
2726 // tree into a PHP-bypass — every cache hit served by the web
2727 // server directly. Without it we still cache, just at drop-in
2728 // speed (~85ms TTFB) instead of static-file speed (~25-40ms).
2729 //
2730 // Reconcile against mobile_separate: the rewrite is device-blind, so
2731 // it must be ABSENT when mobile_separate is on and PRESENT otherwise.
2732 // auto_heal() runs periodically, so it also repairs a rewrite that
2733 // was left installed before mobile_separate was switched on.
2734 if ( self::static_rewrite_allowed() ) {
2735 if ( ! self::rewrite_installed() ) {
2736 self::install_rewrite();
2737 }
2738 } elseif ( self::rewrite_installed() ) {
2739 self::remove_rewrite();
2740 }
2741
2742 // HITs log file — nginx writes one line per HIT served directly
2743 // (see nginx_snippet()), Cache::get_stats() drains the file via
2744 // Hit_Counter::collect_nginx_log_hits(). If the file vanishes
2745 // (plugin upgrade wiped wp-content/cache/), nginx errors silently
2746 // on the access_log directive and the counter stays at 0.
2747 self::ensure_hits_log_file();
2748 }
2749
2750 /**
2751 * Keep the generic bypass cookie in sync with PHP's caching verdict.
2752 *
2753 * The server config tests exactly one cookie name (Server_Rules::
2754 * BYPASS_COOKIE) forever, and PHP decides what that name means. Adding
2755 * a new excluded cookie therefore needs no config change and no nginx
2756 * reload — the reason this exists.
2757 *
2758 * Session cookie (expiry 0) so it dies with the browser session, and
2759 * deliberately NOT HttpOnly-sensitive: it carries no identity, only the
2760 * boolean "don't serve this visitor a shared cached page".
2761 *
2762 * Honest limit: this can only ever help a visitor PHP has already seen
2763 * once. A bot's first request to a warm page never reaches PHP, which
2764 * is why user-agent rules are still written into the server config
2765 * rather than relying on this.
2766 *
2767 * @param bool $bypass Whether this visitor must skip the cache.
2768 */
2769 private static function sync_bypass_cookie( bool $bypass ): void {
2770 if ( headers_sent() ) {
2771 return;
2772 }
2773
2774 $name = Server_Rules::BYPASS_COOKIE;
2775 $has = isset( $_COOKIE[ $name ] );
2776
2777 // Only touch the header when the state actually changes — a
2778 // Set-Cookie on every request would make the response uncacheable
2779 // for intermediary caches and add noise to every hit.
2780 if ( $bypass === $has ) {
2781 return;
2782 }
2783
2784 $path = defined( 'COOKIEPATH' ) && COOKIEPATH ? COOKIEPATH : '/';
2785 $domain = defined( 'COOKIE_DOMAIN' ) ? COOKIE_DOMAIN : '';
2786
2787 if ( $bypass ) {
2788 setcookie( $name, '1', 0, $path, (string) $domain, is_ssl(), false );
2789 $_COOKIE[ $name ] = '1';
2790 } else {
2791 setcookie( $name, '', time() - 3600, $path, (string) $domain, is_ssl(), false );
2792 unset( $_COOKIE[ $name ] );
2793 }
2794 }
2795
2796 /**
2797 * Build the .htaccess rules that map cacheable requests to the
2798 * static-cache tree. Conditions are deliberately strict: GET only,
2799 * empty query string, no session/comment-author/post-password
2800 * cookie, and the static file must exist on disk. Anything that
2801 * fails one of these falls through to PHP and the drop-in / full
2802 * WordPress path.
2803 *
2804 * @return string[] Lines for insert_with_markers().
2805 */
2806 public static function rewrite_block_lines(): array {
2807 // Path relative to ABSPATH so the rule lives in the site-root
2808 // .htaccess regardless of where wp-content sits. WP_CONTENT_DIR
2809 // can be moved, so we compute the document-root-relative form
2810 // at install time and bake it into the rule.
2811 $rel = str_replace( ABSPATH, '/', XSPEED_CACHE_STATIC_DIR );
2812 $rel = '/' . ltrim( $rel, '/' );
2813 $rel = rtrim( $rel, '/' );
2814
2815 // Cookie + user-agent exclusions generated from the live settings.
2816 // See the matching block in nginx_snippet() — same generator, same
2817 // floor, so both servers enforce an identical policy. Apache reads
2818 // .htaccess on every request and we already self-heal this file, so
2819 // Apache/LiteSpeed users get the fix on upgrade with no action.
2820 $cache_opts = Settings_Manager::get( 'cache' );
2821 $cookie_rule = Server_Rules::cookie_rule(
2822 is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array()
2823 );
2824 $ua_rule = Server_Rules::user_agent_rule(
2825 is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array()
2826 );
2827
2828 $lines = array(
2829 '<IfModule mod_rewrite.c>',
2830 ' RewriteEngine On',
2831 ' RewriteCond %{REQUEST_METHOD} ^GET$',
2832 ' RewriteCond %{QUERY_STRING} ^$',
2833 ' RewriteCond %{HTTP_COOKIE} !(' . $cookie_rule['regex'] . ') [NC]',
2834 );
2835
2836 // Only emit the UA condition when there's something to match —
2837 // `!()` would negate an always-true empty match and refuse every
2838 // request, silently disabling the static path.
2839 if ( '' !== $ua_rule['regex'] ) {
2840 // Quoted, because RewriteCond is whitespace-delimited and real
2841 // user-agent fragments contain spaces ("Mozilla/5.0 (compatible").
2842 // Unquoted, a space adds an argument and Apache answers every
2843 // request with a 500 — and because .htaccess is parsed per
2844 // request, `httpd -t` still reports Syntax OK. Server_Rules has
2845 // already excluded quotes and backslashes from the alternation,
2846 // so the closing quote here cannot be escaped away.
2847 $lines[] = ' RewriteCond %{HTTP_USER_AGENT} "!(' . $ua_rule['regex'] . ')" [NC]';
2848 }
2849
2850 return array_merge(
2851 $lines,
2852 array(
2853 // Capture REQUEST_URI without its trailing slash into %1.
2854 // store_static() writes `{host}{uri-without-trailing-slash}/index.html`,
2855 // so this normalization lets `/blog/` and `/blog` both hit
2856 // the same cache file without producing the double-slash
2857 // path that would skip the -f check below.
2858 ' RewriteCond %{REQUEST_URI} ^(.*?)/?$',
2859 ' RewriteCond %{DOCUMENT_ROOT}' . $rel . '/%{HTTP_HOST}%1/index.html -f',
2860 // Pattern is `^`, NOT `.`. The per-directory rewrite engine
2861 // strips the leading slash before matching, so the HOMEPAGE
2862 // request `/` arrives here as an EMPTY path. `.` requires at
2863 // least one character and therefore never matches the homepage
2864 // — on LiteSpeed (which honors this strictly) the front page
2865 // fell through to PHP while every inner page rewrote fine.
2866 // `^` matches the empty string AND any non-empty path, so it
2867 // covers `/` and `/blog` alike. (Confirmed on OpenLiteSpeed
2868 // 1.8: `.` → homepage served by PHP drop-in; `^` → served
2869 // directly from the static file.)
2870 ' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [L]',
2871 '</IfModule>',
2872 // Mark the statically-served response as a cache HIT.
2873 //
2874 // A file served by the rewrite above bypasses PHP entirely, so
2875 // this directive is the ONLY thing that can identify it as
2876 // cached — both for the user reading response headers and for
2877 // Hit_Counter, which reconciles static hits from the access
2878 // log. Without it the cache works perfectly and reports a 0%
2879 // hit ratio, which reads as "the plugin is broken". (Field
2880 // report against 1.1.2: homepage served byte-identical from
2881 // the static tree, no X-XSpeed-Cache header on any response.)
2882 //
2883 // `always` so the header is set on the 200 from the rewritten
2884 // file, not only on the successful-response table. The
2885 // <IfModule> guard keeps a server without mod_headers from
2886 // 500ing on an unknown directive — on such a host the header
2887 // is silently dropped, which is exactly why
2888 // static_rewrite_allowed() refuses the static path there and
2889 // routes hits through the drop-in instead.
2890 '<IfModule mod_headers.c>',
2891 ' <FilesMatch "\\.html$">',
2892 ' Header always set X-XSpeed-Cache "HIT (static)"',
2893 ' </FilesMatch>',
2894 '</IfModule>',
2895 )
2896 );
2897 }
2898
2899 /**
2900 * Active probe that confirms the web-server static-rewrite path is
2901 * actually serving cached files. Writes a probe file with a random
2902 * nonce, fetches it over HTTP at its public URL, and checks whether
2903 * the response was served directly by the web server (Last-Modified
2904 * + ETag headers + no X-Powered-By: PHP).
2905 *
2906 * Server-agnostic: same probe works for nginx (snippet pasted) and
2907 * Apache / LiteSpeed (.htaccess block installed). If the rewrite
2908 * isn't engaged, the request falls through to WordPress and PHP
2909 * adds its own headers, which the probe detects and reports.
2910 *
2911 * Throttled via a 5-minute transient — we never want this running
2912 * on every Health card paint.
2913 *
2914 * @return array{active:bool, reason:string, code?:int, php?:bool, expires?:int}
2915 */
2916 /**
2917 * @param bool $allow_probe When false (the default), return ONLY a cached
2918 * result and never make an HTTP request — so admin page loads are never
2919 * blocked by the loopback probe. The actual HTTP probe only runs when a
2920 * caller explicitly opts in (the Health tab / cron). Previously this ran
2921 * synchronously on every dashboard bootstrap, so a slow/timing-out
2922 * loopback request added up to `timeout` seconds to admin page loads on
2923 * hosts that block self-requests. (FBS-82142)
2924 */
2925 /**
2926 * Discard the cached probe result and run a fresh one.
2927 *
2928 * Without this there was no way to re-check: the result sat in a transient
2929 * for five minutes and nothing ever deleted it, so a user who fixed their
2930 * nginx config kept seeing "nginx detected — configure for max cache speed"
2931 * with no means of confirming the fix worked. (FBS-84012)
2932 */
2933 public static function recheck_static_rewrite(): array {
2934 delete_transient( 'xspeed_rewrite_probe' );
2935 return self::probe_static_rewrite( true );
2936 }
2937
2938 public static function probe_static_rewrite( bool $allow_probe = false ): array {
2939 $cached = get_transient( 'xspeed_rewrite_probe' );
2940 if ( is_array( $cached ) ) {
2941 return $cached;
2942 }
2943 // No cached result yet and the caller doesn't want to pay for a live
2944 // HTTP probe (e.g. the admin bootstrap): report "pending" without
2945 // blocking. The Health tab will run the real probe on demand.
2946 if ( ! $allow_probe ) {
2947 return array( 'active' => false, 'reason' => 'probe pending', 'pending' => true );
2948 }
2949
2950 $home = home_url( '/' );
2951 $host = (string) wp_parse_url( $home, PHP_URL_HOST );
2952 if ( '' === $host ) {
2953 $result = array( 'active' => false, 'reason' => 'home_url has no host' );
2954 set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
2955 return $result;
2956 }
2957
2958 // Use a randomised path AND nonce so a stale CDN cache entry
2959 // from a prior probe can never make a broken install look
2960 // healthy. Path is namespaced under __xspeed_probe__ so the
2961 // directory listing stays obvious if cleanup misfires.
2962 $slug = wp_generate_password( 12, false, false );
2963 $nonce = wp_generate_password( 24, false, false );
2964 $probe_dir = XSPEED_CACHE_STATIC_DIR . '/' . $host . '/__xspeed_probe__/' . $slug;
2965 $probe_file = $probe_dir . '/index.html';
2966 $probe_url = trailingslashit( $home ) . '__xspeed_probe__/' . $slug . '/';
2967
2968 if ( ! file_exists( $probe_dir ) ) {
2969 wp_mkdir_p( $probe_dir );
2970 }
2971 if ( ! is_dir( $probe_dir ) ) {
2972 $result = array( 'active' => false, 'reason' => 'cannot create probe dir' );
2973 set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
2974 return $result;
2975 }
2976 // 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.
2977 file_put_contents( $probe_file, $nonce, LOCK_EX );
2978
2979 // Verify TLS by default — disabling it site-wide is a needless MITM
2980 // exposure (FBS-82142). Only relax verification in local/dev
2981 // environments, where self-signed certs are common and there's no
2982 // real attacker in the loop.
2983 $is_local = function_exists( 'wp_get_environment_type' )
2984 && in_array( wp_get_environment_type(), array( 'local', 'development' ), true );
2985 $resp = wp_remote_get(
2986 $probe_url,
2987 array(
2988 // 3s cap so a host that hangs on loopback self-requests can't
2989 // stall the caller for long; the result/error is cached so we
2990 // don't repeat the wait every minute.
2991 'timeout' => 3,
2992 'sslverify' => ! $is_local,
2993 'redirection' => 0,
2994 'headers' => array( 'Cache-Control' => 'no-cache' ),
2995 )
2996 );
2997
2998 // Best-effort cleanup so we don't accumulate probe dirs even
2999 // if subsequent calls all hit the transient.
3000 if ( file_exists( $probe_file ) ) {
3001 wp_delete_file( $probe_file );
3002 }
3003 if ( is_dir( $probe_dir ) ) {
3004 // 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.
3005 @rmdir( $probe_dir );
3006 }
3007
3008 if ( is_wp_error( $resp ) ) {
3009 $result = array(
3010 'active' => false,
3011 // The request never completed, so we learned NOTHING about the
3012 // rewrite. Flagged inconclusive so the UI doesn't tell the user
3013 // to configure a server that may already be configured — a
3014 // blocked loopback, a self-signed cert, or a timeout is a probe
3015 // failure, not a missing rewrite. (FBS-84012)
3016 'inconclusive' => true,
3017 'reason' => 'http error: ' . $resp->get_error_message(),
3018 );
3019 // Cache the failure for the full 5 minutes (not 1) so a host that
3020 // times out on the loopback probe isn't re-probed — and re-stalled
3021 // — on every page load within the window. (FBS-82142)
3022 set_transient( 'xspeed_rewrite_probe', $result, 5 * MINUTE_IN_SECONDS );
3023 return $result;
3024 }
3025
3026 $code = (int) wp_remote_retrieve_response_code( $resp );
3027 $body = (string) wp_remote_retrieve_body( $resp );
3028 $ua_php = '' !== (string) wp_remote_retrieve_header( $resp, 'x-powered-by' );
3029 $has_etag = '' !== (string) wp_remote_retrieve_header( $resp, 'etag' )
3030 || '' !== (string) wp_remote_retrieve_header( $resp, 'last-modified' );
3031 $match = trim( $body ) === $nonce;
3032
3033 // "Active" = the web server served our raw nonce bytes back
3034 // AND emitted the static-serve markers (ETag / Last-Modified)
3035 // AND didn't add an X-Powered-By: PHP header. All three are
3036 // individually noisy; together they're conclusive.
3037 $active = $match && $has_etag && ! $ua_php && 200 === $code;
3038
3039 /*
3040 * `inconclusive` separates "we proved the rewrite isn't serving" from
3041 * "the probe couldn't tell". Only the former should drive a
3042 * configure-your-server banner; the latter previously rendered the
3043 * same alarming copy at a user who had already configured nginx
3044 * correctly, and there was no way to clear it. (FBS-84012)
3045 */
3046 $inconclusive = false;
3047 if ( $active ) {
3048 $reason = 'static-served';
3049 } elseif ( 200 === $code && $match && $ua_php ) {
3050 $reason = 'php served the file instead of nginx/Apache (rewrite block missing)';
3051 } elseif ( 200 === $code && ! $match ) {
3052 // Something answered 200 with content that isn't our nonce — a CDN,
3053 // a proxy, a security plugin. That tells us nothing about the
3054 // origin's rewrite.
3055 $reason = 'unexpected body (CDN cached an older response?)';
3056 $inconclusive = true;
3057 } elseif ( 404 === $code ) {
3058 $reason = 'probe URL returned 404 (rewrite block missing or wrong path)';
3059 } else {
3060 // Redirects, 403s from a WAF, 5xx — the probe never reached a
3061 // verdict about the rewrite itself.
3062 $reason = sprintf( 'unexpected response (HTTP %d, body %d B, php=%s)', $code, strlen( $body ), $ua_php ? 'yes' : 'no' );
3063 $inconclusive = true;
3064 }
3065
3066 $result = array(
3067 'active' => $active,
3068 'inconclusive' => $inconclusive,
3069 'reason' => $reason,
3070 'code' => $code,
3071 'php' => $ua_php,
3072 );
3073 set_transient( 'xspeed_rewrite_probe', $result, 5 * MINUTE_IN_SECONDS );
3074 return $result;
3075 }
3076
3077 public static function rewrite_installed(): bool {
3078 $htaccess = ABSPATH . '.htaccess';
3079 if ( ! file_exists( $htaccess ) ) {
3080 return false;
3081 }
3082 $existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
3083 if ( ! is_string( $existing ) ) {
3084 return false;
3085 }
3086 return false !== strpos( $existing, '# BEGIN xSpeed Static Cache' );
3087 }
3088
3089 /**
3090 * Install the static-cache rewrite block at the TOP of .htaccess.
3091 *
3092 * Position matters: WordPress's own block ends with
3093 * `RewriteRule . /index.php [L]` which routes every non-file
3094 * request to PHP. The [L] flag stops the current rewrite pass,
3095 * but Apache restarts the cycle; on the second pass REQUEST_URI
3096 * is /index.php and no static-file check can match. The only
3097 * reliable position for a "serve static if it exists" rule is
3098 * before WordPress's block.
3099 *
3100 * WP's insert_with_markers() always appends, so we manage the
3101 * block manually: strip any prior xSpeed Static Cache markers,
3102 * then write our block followed by the rest of the file.
3103 */
3104 public static function install_rewrite(): bool {
3105 // The static rewrite is device-blind; never install it when
3106 // mobile_separate is on (see static_rewrite_allowed()).
3107 if ( ! self::static_rewrite_allowed() ) {
3108 return false;
3109 }
3110 $htaccess = ABSPATH . '.htaccess';
3111 $existing = file_exists( $htaccess ) ? @file_get_contents( $htaccess ) : ''; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
3112 if ( false === $existing ) {
3113 $existing = '';
3114 }
3115 // Apache/LiteSpeed only. nginx hosts: rule won't fire, drop-in
3116 // covers; we skip the write so we don't litter their root.
3117 // 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.
3118 if ( file_exists( $htaccess ) && ! is_writable( $htaccess ) ) {
3119 return false;
3120 }
3121 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- See above.
3122 if ( ! file_exists( $htaccess ) && ! is_writable( ABSPATH ) ) {
3123 return false;
3124 }
3125
3126 $cleaned = self::strip_marker_block( $existing, 'xSpeed Static Cache' );
3127 $block = self::marker_block( 'xSpeed Static Cache', self::rewrite_block_lines() );
3128 $next = $block . ( '' === $cleaned ? '' : "\n" . $cleaned );
3129
3130 // 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.
3131 return false !== file_put_contents( $htaccess, $next, LOCK_EX );
3132 }
3133
3134 /**
3135 * Rewrite the .htaccess block in place when — and only when — one is
3136 * already installed.
3137 *
3138 * The block embeds the generated cookie / user-agent exclusion rules,
3139 * so it goes stale the moment those settings change. install_rewrite()
3140 * regenerates it from the live settings, but calling that unconditionally
3141 * on every save would CREATE a block on sites that never enabled the
3142 * static path — silently turning on server-level serving nobody asked
3143 * for. So we refresh only what's already there.
3144 *
3145 * @return bool True when a block was present and rewritten.
3146 */
3147 public static function refresh_rewrite_if_installed(): bool {
3148 $htaccess = ABSPATH . '.htaccess';
3149 if ( ! file_exists( $htaccess ) ) {
3150 return false;
3151 }
3152 $existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Best-effort read; an unreadable file simply means nothing to refresh.
3153 if ( ! is_string( $existing ) || false === strpos( $existing, '# BEGIN xSpeed Static Cache' ) ) {
3154 return false;
3155 }
3156 return self::install_rewrite();
3157 }
3158
3159 public static function remove_rewrite(): bool {
3160 $htaccess = ABSPATH . '.htaccess';
3161 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- See install_rewrite() rationale.
3162 if ( ! file_exists( $htaccess ) || ! is_writable( $htaccess ) ) {
3163 return false;
3164 }
3165 $existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
3166 if ( false === $existing ) {
3167 return false;
3168 }
3169 $cleaned = self::strip_marker_block( $existing, 'xSpeed Static Cache' );
3170 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents, PluginCheck.CodeAnalysis.WriteFile.ABSPATHDetected -- See install_rewrite() rationale.
3171 return false !== file_put_contents( $htaccess, $cleaned, LOCK_EX );
3172 }
3173
3174 /**
3175 * Strip a `# BEGIN <marker>` ... `# END <marker>` block from a
3176 * .htaccess-style file, including any blank line that immediately
3177 * follows it. Idempotent — returns the input unchanged if the
3178 * marker isn't present.
3179 */
3180 private static function strip_marker_block( string $contents, string $marker ): string {
3181 $pattern = '/# BEGIN ' . preg_quote( $marker, '/' ) . '\b.*?# END ' . preg_quote( $marker, '/' ) . "\b[^\n]*\n?\n?/s";
3182 $out = preg_replace( $pattern, '', $contents );
3183 return is_string( $out ) ? $out : $contents;
3184 }
3185
3186 private static function marker_block( string $marker, array $lines ): string {
3187 $header = "# BEGIN $marker\n";
3188 $header .= "# The directives (lines) between \"BEGIN $marker\" and \"END $marker\" are\n";
3189 $header .= "# dynamically generated, and should only be modified via WordPress filters.\n";
3190 $header .= "# Any changes to the directives between these markers will be overwritten.\n";
3191 $footer = "# END $marker\n";
3192 return $header . implode( "\n", $lines ) . "\n" . $footer;
3193 }
3194
3195 /**
3196 * Parse the `XSPEED_DROPIN_VERSION: N` stamp out of a drop-in's source.
3197 * Returns 0 when absent (an un-stamped older copy reinstalls). Used to
3198 * detect a stale installed drop-in vs the bundled source.
3199 */
3200 private static function dropin_version( string $contents ): int {
3201 if ( preg_match( '/XSPEED_DROPIN_VERSION:\s*(\d+)/', $contents, $m ) ) {
3202 return (int) $m[1];
3203 }
3204 return 0;
3205 }
3206
3207 public static function install_dropin() {
3208 $source = XSPEED_DIR . 'includes/advanced-cache.php';
3209 $target = WP_CONTENT_DIR . '/advanced-cache.php';
3210 if ( ! file_exists( $source ) ) {
3211 return false;
3212 }
3213
3214 global $wp_filesystem;
3215 if ( ! function_exists( 'WP_Filesystem' ) ) {
3216 require_once ABSPATH . 'wp-admin/includes/file.php';
3217 }
3218 WP_Filesystem();
3219 if ( ! $wp_filesystem ) {
3220 return false;
3221 }
3222
3223 $source_contents = $wp_filesystem->get_contents( $source );
3224 if ( ! is_string( $source_contents ) ) {
3225 return false;
3226 }
3227
3228 // Bake the absolute hit-log path into the drop-in. It runs before
3229 // WordPress loads, so it can't resolve wp_upload_dir() itself — we
3230 // substitute the @@XSPEED_HITS_LOG@@ token with the real uploads path
3231 // (never the cache dir; see hits_log_dir() / FBS-82478). Use a single
3232 // quoted PHP string literal so the installed file stays valid PHP.
3233 $source_contents = str_replace(
3234 '@@XSPEED_HITS_LOG@@',
3235 str_replace( "'", "\\'", self::hits_log_path() ),
3236 $source_contents
3237 );
3238
3239 // Bake the cookie + user-agent exclusion rules in too. The drop-in
3240 // runs before WordPress loads, so it cannot read the settings — and
3241 // without them it served the shared anonymous page to any visitor
3242 // PHP had not yet seen (a first-time cart visitor, a bypassed bot).
3243 // The generic bypass cookie only covers repeat visitors; these two
3244 // regexes are what make the FIRST request correct.
3245 //
3246 // Both are already fully escaped by Server_Rules, and each is
3247 // embedded as a single-quoted PHP literal, so a settings value can
3248 // neither break the drop-in's syntax nor execute.
3249 $cache_opts = Settings_Manager::get( 'cache' );
3250 $cookie_rule = Server_Rules::cookie_rule(
3251 is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array()
3252 );
3253 $ua_rule = Server_Rules::user_agent_rule(
3254 is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array()
3255 );
3256
3257 $source_contents = str_replace(
3258 '@@XSPEED_COOKIE_RE@@',
3259 str_replace( "'", "\\'", $cookie_rule['regex'] ),
3260 $source_contents
3261 );
3262 $source_contents = str_replace(
3263 '@@XSPEED_UA_RE@@',
3264 str_replace( "'", "\\'", $ua_rule['regex'] ),
3265 $source_contents
3266 );
3267
3268 if ( file_exists( $target ) ) {
3269 $existing = $wp_filesystem->get_contents( $target );
3270 $is_xspeed = is_string( $existing ) && false !== strpos( $existing, 'XSPEED_DROPIN' );
3271
3272 if ( $is_xspeed ) {
3273 if ( $existing === $source_contents ) {
3274 return true;
3275 }
3276 return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE );
3277 }
3278
3279 // Foreign drop-in (e.g. left over from another cache plugin) — back it up
3280 // before overwriting so the user can recover if needed. Uploads dir
3281 // (not wp-content root) keeps the backup out of WordPress's reserved
3282 // drop-in location.
3283 $upload = wp_upload_dir( null, false );
3284 $basedir = isset( $upload['basedir'] ) ? trailingslashit( $upload['basedir'] ) . 'xspeed-backups' : false;
3285 if ( $basedir ) {
3286 if ( ! file_exists( $basedir ) ) {
3287 wp_mkdir_p( $basedir );
3288 self::write_silence( $basedir );
3289 }
3290 $backup = $basedir . '/advanced-cache.foreign-' . gmdate( 'Ymd-His' ) . '.php.bak';
3291 $wp_filesystem->move( $target, $backup, true );
3292 } else {
3293 $wp_filesystem->delete( $target );
3294 }
3295 }
3296
3297 return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE );
3298 }
3299
3300 public static function remove_dropin() {
3301 $target = WP_CONTENT_DIR . '/advanced-cache.php';
3302 if ( ! file_exists( $target ) ) {
3303 return;
3304 }
3305
3306 global $wp_filesystem;
3307 if ( ! function_exists( 'WP_Filesystem' ) ) {
3308 require_once ABSPATH . 'wp-admin/includes/file.php';
3309 }
3310 WP_Filesystem();
3311 if ( ! $wp_filesystem ) {
3312 return;
3313 }
3314
3315 $contents = $wp_filesystem->get_contents( $target );
3316 if ( is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' ) ) {
3317 wp_delete_file( $target );
3318 }
3319 }
3320
3321 public static function set_wp_cache_constant( $enable ) {
3322 $wp_config = ABSPATH . 'wp-config.php';
3323 if ( ! file_exists( $wp_config ) ) {
3324 return false;
3325 }
3326
3327 global $wp_filesystem;
3328 if ( ! function_exists( 'WP_Filesystem' ) ) {
3329 require_once ABSPATH . 'wp-admin/includes/file.php';
3330 }
3331 WP_Filesystem();
3332 if ( ! $wp_filesystem || ! $wp_filesystem->is_writable( $wp_config ) ) {
3333 return false;
3334 }
3335
3336 $config = $wp_filesystem->get_contents( $wp_config );
3337
3338 if ( $enable ) {
3339 // Own the constant. A previous caching plugin (e.g. WP Rocket sets
3340 // it false on deactivate) can leave `define( 'WP_CACHE', false );`
3341 // behind — presence alone is not enough, the VALUE must be true or
3342 // WordPress never loads advanced-cache.php and our drop-in is dead.
3343 if ( preg_match( "/define\\(\\s*['\"]WP_CACHE['\"]\\s*,/", $config ) ) {
3344 $rewritten = preg_replace(
3345 "/define\\(\\s*['\"]WP_CACHE['\"]\\s*,\\s*[^)]*\\)\\s*;/",
3346 "define( 'WP_CACHE', true );",
3347 $config,
3348 1
3349 );
3350 // If an existing define was already `true`, the rewrite is a
3351 // no-op string-wise; either way we end on WP_CACHE === true.
3352 if ( null !== $rewritten ) {
3353 $config = $rewritten;
3354 }
3355 } else {
3356 $config = preg_replace( '/(<\?php)/', "$1\ndefine( 'WP_CACHE', true );", $config, 1 );
3357 }
3358 } else {
3359 // Shared with uninstall.php so the two removal paths can't drift
3360 // — they already had, which is why every non-lowercase spelling
3361 // of the value survived a disable. (#9)
3362 require_once XSPEED_DIR . 'includes/wp-cache-constant.php';
3363 $config = xspeed_strip_wp_cache_define( $config );
3364 }
3365
3366 return (bool) $wp_filesystem->put_contents( $wp_config, $config, FS_CHMOD_FILE );
3367 }
3368
3369 /**
3370 * Admin-bar purge menu — a parent node plus one child per visible cache
3371 * type (LiteSpeed-style), instead of a single "Purge All" link. Each
3372 * child posts to the same admin-post handler with its type slug. The
3373 * per-type items only appear for active/licensed modules; "Purge All"
3374 * always shows and always sweeps everything. (FBS-83114)
3375 *
3376 * The parent node links to the settings page rather than a purge URL —
3377 * clicking the top-level item used to wipe the whole cache instantly with
3378 * no confirmation, which is far too destructive for a stray click. Purging
3379 * stays available (and explicit) through the child items. (FBS-84068)
3380 */
3381 public function admin_bar_purge( $wp_admin_bar ) {
3382 if ( ! current_user_can( 'manage_options' ) ) {
3383 return;
3384 }
3385
3386 $wp_admin_bar->add_node(
3387 array(
3388 'id' => 'xspeed-purge',
3389 'title' => __( 'xSpeed Cache', 'xspeed' ),
3390 'href' => admin_url( 'admin.php?page=' . Admin::PAGE_SLUG ),
3391 )
3392 );
3393
3394 foreach ( self::purge_types() as $slug => $type ) {
3395 if ( empty( $type['visible'] ) ) {
3396 continue;
3397 }
3398 $wp_admin_bar->add_node(
3399 array(
3400 'id' => 'xspeed-purge-' . $slug,
3401 'parent' => 'xspeed-purge',
3402 'title' => esc_html( $type['label'] ),
3403 'href' => self::purge_type_url( $slug ),
3404 )
3405 );
3406 }
3407 }
3408
3409 /**
3410 * Nonce-protected admin-post URL for purging a single type. The nonce
3411 * action is per-type so a leaked URL can't be replayed for a different
3412 * scope.
3413 */
3414 private static function purge_type_url( string $type ): string {
3415 return wp_nonce_url(
3416 admin_url( 'admin-post.php?action=xspeed_purge&type=' . rawurlencode( $type ) ),
3417 'xspeed_purge_' . $type
3418 );
3419 }
3420
3421 public function handle_admin_bar_purge() {
3422 if ( ! current_user_can( 'manage_options' ) ) {
3423 wp_die( esc_html__( 'Unauthorized.', 'xspeed' ), 403 );
3424 }
3425 $type = isset( $_GET['type'] ) ? sanitize_key( wp_unslash( $_GET['type'] ) ) : 'all';
3426 check_admin_referer( 'xspeed_purge_' . $type );
3427
3428 // Only honour known types; anything else falls back to a full purge.
3429 if ( ! array_key_exists( $type, self::purge_types() ) ) {
3430 $type = 'all';
3431 }
3432 self::purge_type( $type );
3433
3434 wp_safe_redirect( self::safe_purge_redirect( wp_get_referer() ) );
3435 exit;
3436 }
3437
3438 /**
3439 * Resolve a safe redirect target for an admin-bar purge.
3440 *
3441 * The purge sends the admin back where they came from — but the referer
3442 * can be a ONE-SHOT action URL (e.g. update.php?action=upload-plugin from
3443 * installing a plugin zip, or any *.php?action=… that consumed a POST /
3444 * temp upload). Redirecting there re-runs the action with nothing to act
3445 * on, so WordPress dies — the classic "Please select a file" from
3446 * File_Upload_Upgrader. Strip the transient action args so we return to a
3447 * safe, re-GET-able view of the same page; fall back to the dashboard when
3448 * there is no usable referer.
3449 *
3450 * @param string|false $referer Raw wp_get_referer() value.
3451 * @return string Safe URL to redirect to.
3452 */
3453 public static function safe_purge_redirect( $referer ): string {
3454 $referer = is_string( $referer ) ? $referer : '';
3455 if ( '' === $referer ) {
3456 return admin_url();
3457 }
3458
3459 // A referer that lands on an action-processing endpoint (update.php,
3460 // update-core.php, plugin/theme install/upload flows) can't be safely
3461 // re-requested — send them to the dashboard instead of replaying it.
3462 $path = (string) wp_parse_url( $referer, PHP_URL_PATH );
3463 if ( preg_match( '#/wp-admin/(update|update-core)\.php$#', $path ) ) {
3464 return admin_url();
3465 }
3466
3467 // Otherwise keep them on the same page but drop the query args that
3468 // would re-trigger a form action or upload on load.
3469 return remove_query_arg(
3470 array( 'action', 'action2', 'package', 'overwrite', 'plugin', 'theme', 'file', '_wpnonce', '_ajax_nonce' ),
3471 $referer
3472 );
3473 }
3474 }
3475