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

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