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

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

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