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

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

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