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

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

1,505 lines 62.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 public function __construct() {
24 add_action( 'template_redirect', array( $this, 'maybe_start_cache' ), 0 );
25
26 $invalidate_hooks = array( 'save_post', 'deleted_post', 'trashed_post', 'comment_post', 'wp_set_comment_status', 'switch_theme', 'activated_plugin', 'deactivated_plugin' );
27 foreach ( $invalidate_hooks as $hook ) {
28 add_action( $hook, array( __CLASS__, 'purge_all' ) );
29 add_action( $hook, array( 'XSpeed\\Minifier', 'purge_minified' ) );
30 }
31
32 add_action( 'update_option_xspeed_options', array( __CLASS__, 'on_settings_change' ), 10, 2 );
33
34 add_action( 'admin_bar_menu', array( $this, 'admin_bar_purge' ), 100 );
35 add_action( 'admin_post_xspeed_purge', array( $this, 'handle_admin_bar_purge' ) );
36 }
37
38 public static function on_settings_change( $old, $new ) {
39 // gzip_enabled moved to xspeed_module_gzip — GzipModule owns the
40 // .htaccess flip via its own update_option_xspeed_module_gzip hook.
41 // Same migration is planned for cache_expiry + excluded_urls
42 // (Cache module). Keep this handler around for whatever still
43 // lives in the legacy blob (cache_enabled is special and goes
44 // through Cache::toggle anyway).
45
46 // Any settings change — purge caches so changes take effect.
47 self::purge_all( 'settings change' );
48 Minifier::purge_minified();
49 }
50
51 public function maybe_start_cache() {
52 if ( ! self::should_cache() ) {
53 return;
54 }
55
56 $key = self::cache_key();
57 $file = self::cache_file_for( $key );
58
59 if ( file_exists( $file ) && ! self::is_expired( $file ) ) {
60 Hit_Counter::record_hit();
61 // Emit the HIT marker on THIS path too. The drop-in
62 // (advanced-cache.php) sends "HIT (php)" and the nginx static
63 // rewrite sends "HIT (nginx)", but this template_redirect
64 // serve path — the one that runs when the drop-in isn't loaded
65 // (e.g. WP_CACHE not true) — previously streamed the cached
66 // file with NO marker, so a genuine HIT looked like a MISS in
67 // the response headers. Same header + value as the drop-in.
68 if ( ! headers_sent() ) {
69 header( 'X-XSpeed-Cache: HIT (php)' );
70 }
71 // 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.
72 readfile( $file );
73 exit;
74 }
75
76 // Cache miss → render fresh + write cache. On LiteSpeed we send an
77 // explicit "stand down" header so the server's LSCache module does
78 // NOT cache + shadow our response — xSpeed's own .htaccess static
79 // rewrite owns hit serving (and hit accounting) here, exactly as on
80 // Apache. See maybe_emit_lscache_headers() for the full rationale.
81 self::maybe_emit_lscache_headers();
82
83 // We're about to render fresh + cache → miss for this request.
84 Hit_Counter::record_miss();
85
86
87 // WP < 6.9 fallback: ob_start() with a callback, paired with an
88 // explicit shutdown close so the buffer lifecycle is visible to
89 // reviewers and Plugin Check, instead of relying on PHP's implicit
90 // request-end flush. We record our nesting level so close_buffer()
91 // flushes ONLY the buffer we opened.
92 ob_start( array( __CLASS__, 'finalize_buffer' ) );
93 self::$buffer_level = ob_get_level();
94
95 add_action( 'shutdown', array( __CLASS__, 'close_buffer' ), 0 );
96 }
97
98 /**
99 * Close the cache buffer opened by maybe_start_cache().
100 *
101 * Guarded by the recorded buffer level so we never flush a buffer that
102 * another plugin pushed on top of (or under) ours. If something else is
103 * currently on top, we leave the stack alone — PHP's shutdown sequence
104 * will unwind buffers in order and our finalize_buffer() callback will
105 * still run when our level becomes the topmost one.
106 */
107 public static function close_buffer() {
108 if ( null === self::$buffer_level ) {
109 return;
110 }
111 if ( ob_get_level() === self::$buffer_level ) {
112 ob_end_flush();
113 }
114 self::$buffer_level = null;
115 }
116
117 public static function should_cache() {
118 $opts = Settings::get();
119 if ( empty( $opts['cache_enabled'] ) ) {
120 return false;
121 }
122
123 if ( is_user_logged_in() || is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
124 return false;
125 }
126
127 if ( defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE ) {
128 return false;
129 }
130
131 // All exclusion knobs now owned by CacheModule.
132 $cache_opts = Settings_Manager::get( 'cache' );
133
134 $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : '';
135 if ( 'GET' !== $method ) {
136 return false;
137 }
138
139 // Query string handling: anything OUTSIDE the ignored-params
140 // allow-list (utm_*, fbclid, gclid by default) means a unique
141 // request that we don't want to share with the canonical cache
142 // entry. Skip cache rather than poison the key.
143 $query_raw = isset( $_SERVER['QUERY_STRING'] ) ? sanitize_text_field( wp_unslash( $_SERVER['QUERY_STRING'] ) ) : '';
144 if ( '' !== $query_raw ) {
145 $ignored = is_array( $cache_opts['ignored_query_params'] ?? null ) ? $cache_opts['ignored_query_params'] : array();
146 parse_str( $query_raw, $params );
147 foreach ( $params as $key => $_ ) {
148 if ( ! self::query_key_is_ignored( (string) $key, $ignored ) ) {
149 return false;
150 }
151 }
152 }
153
154 $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
155 $path = (string) strtok( $request_uri, '?' );
156
157 $excluded_urls = is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array();
158 if ( Glob_Matcher::any_match( $excluded_urls, $path ) ) {
159 return false;
160 }
161
162 // Cookie-based exclusion. We only check cookie NAMES (matching
163 // values would leak content-sensitive logic into the cache key
164 // rules); presence of any matching cookie name skips cache.
165 $excluded_cookies = is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array();
166 if ( ! empty( $excluded_cookies ) && ! empty( $_COOKIE ) ) {
167 foreach ( array_keys( $_COOKIE ) as $cookie_name ) {
168 if ( Glob_Matcher::any_match( $excluded_cookies, (string) $cookie_name ) ) {
169 return false;
170 }
171 }
172 }
173
174 // User-agent bypass list. Substring match (not glob) since UA
175 // strings have so much variation that glob anchoring rarely
176 // helps and confuses users.
177 $bypass_uas = is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array();
178 if ( ! empty( $bypass_uas ) ) {
179 $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
180 foreach ( $bypass_uas as $needle ) {
181 if ( '' !== $needle && false !== stripos( $ua, (string) $needle ) ) {
182 return false;
183 }
184 }
185 }
186
187 // Per-post override (Phase 3.4). Honored only on singular
188 // post-context requests — archives / 404s / taxonomies use the
189 // global policy above.
190 if ( Cache_Rules::should_skip_for_post( Cache_Rules::current_post_id() ) ) {
191 return false;
192 }
193
194 return true;
195 }
196
197 /**
198 * Is this query-string key on the ignored-params allow-list? Supports
199 * trailing-star globs (`utm_*` matches `utm_source`, `utm_medium`,
200 * etc.) so users don't have to enumerate every UTM variant.
201 */
202 private static function query_key_is_ignored( string $key, array $ignored ): bool {
203 return Glob_Matcher::any_match( $ignored, $key );
204 }
205
206 public static function cache_key() {
207 $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : 'default';
208 $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/';
209 // Strip the query string from the key so /post and /post?utm_*=…
210 // share the same cache entry. should_cache() above already
211 // rejected requests with non-ignored params, so by the time we
212 // build the key the only params left are safe to drop.
213 $uri = (string) strtok( $uri, '?' );
214
215 // Optional device bucket: when mobile_separate is on, mobile and
216 // desktop responses live in different cache files so themes that
217 // serve different HTML by device (AMP, WPtouch, Jetpack mobile)
218 // can't poison each other.
219 $device = '';
220 $opts = Settings_Manager::get( 'cache' );
221 if ( ! empty( $opts['mobile_separate'] ) ) {
222 $device = self::is_mobile_request() ? '|m' : '|d';
223 }
224
225 return md5( $host . $uri . $device );
226 }
227
228 /**
229 * Server-side mobile detection. Prefers WordPress's `wp_is_mobile()`
230 * which uses the same UA tokens as core (so our bucket aligns with
231 * whatever theme-side branching uses). Falls back to a tiny inline
232 * detector if wp_is_mobile() isn't loaded (e.g. the drop-in path).
233 */
234 private static function is_mobile_request(): bool {
235 if ( function_exists( 'wp_is_mobile' ) ) {
236 return (bool) wp_is_mobile();
237 }
238 // Fallback for the rare context where wp_is_mobile() isn't loaded.
239 // Mirrors core's wp_is_mobile() EXACTLY — including the
240 // Sec-CH-UA-Mobile client hint it checks *before* UA tokens — so the
241 // bucket this picks matches whatever the engine's primary path (and
242 // the drop-in's own copy of this logic) would pick for the same
243 // request. Drift here re-introduces the cross-path key mismatch.
244 if ( isset( $_SERVER['HTTP_SEC_CH_UA_MOBILE'] ) ) {
245 return '?1' === $_SERVER['HTTP_SEC_CH_UA_MOBILE'];
246 }
247 $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
248 if ( '' === $ua ) {
249 return false;
250 }
251 return (bool) preg_match( '/(Mobile|Android|Silk\/|Kindle|BlackBerry|Opera Mini|Opera Mobi)/i', $ua );
252 }
253
254 public static function cache_file_for( $key ) {
255 return XSPEED_CACHE_DIR . '/' . $key . '.html';
256 }
257
258 public static function is_expired( $file ) {
259 // cache_expiry now owned by CacheModule; per-post override
260 // (Phase 3.4) shrinks the TTL further when the editor set one.
261 $opts = Settings_Manager::get( 'cache' );
262 $max_age = (int) $opts['cache_expiry'] * HOUR_IN_SECONDS;
263 $post_override = Cache_Rules::expiry_override_seconds_for_post( Cache_Rules::current_post_id() );
264 if ( null !== $post_override ) {
265 $max_age = $post_override;
266 }
267 return ( time() - filemtime( $file ) ) > $max_age;
268 }
269
270 /**
271 * Accumulator for the full response body across all output-handler phases.
272 *
273 * PHP invokes an ob_start() callback once per flush, and each invocation
274 * only receives the chunk produced *since the previous flush*. If anything
275 * during the render calls `ob_flush()` or `flush()` (some themes, lazy-
276 * load plugins, AMP, etc. do), the final-phase call would otherwise only
277 * see the tail of the page — and we'd cache a truncated response that
278 * gets served repeatedly until purge. We accumulate every chunk here so
279 * the cache file always reflects the complete page.
280 *
281 * @var string
282 */
283 private static $accumulated = '';
284
285 public static function finalize_buffer( $buffer, $phase = PHP_OUTPUT_HANDLER_FINAL ) {
286 self::$accumulated .= $buffer;
287
288 // On non-final phases (mid-request flushes), pass the current chunk
289 // through to the client unmodified and keep collecting. The WP 6.9
290 // filter path always passes the full body in one shot with the
291 // default $phase, so it falls straight through to the final block.
292 $is_final = ( $phase & ( PHP_OUTPUT_HANDLER_FINAL | PHP_OUTPUT_HANDLER_END ) ) !== 0;
293 if ( ! $is_final ) {
294 return $buffer;
295 }
296
297 $full = self::$accumulated;
298 self::$accumulated = '';
299
300 if ( strlen( $full ) < 255 ) {
301 return $buffer;
302 }
303
304 if ( function_exists( 'http_response_code' ) && 200 !== http_response_code() ) {
305 return $buffer;
306 }
307
308 // If no mid-request flush happened, $buffer === $full and we can
309 // safely minify the on-wire bytes too. Otherwise earlier chunks have
310 // already been sent unminified, so we minify only what goes to disk —
311 // the first visitor sees unminified HTML, every cache hit after that
312 // is minified.
313 $single_chunk = ( $buffer === $full );
314
315 // minify_html now owned by the Minify module; read through the
316 // module's storage so this stays consistent with the engine that
317 // applies CSS/JS minification.
318 $minify_opts = Settings_Manager::get( 'minify' );
319 if ( ! empty( $minify_opts['minify_html'] ) ) {
320 $full = Minifier::minify_html( $full );
321 if ( $single_chunk ) {
322 $buffer = $full;
323 }
324 }
325
326 if ( ! file_exists( XSPEED_CACHE_DIR ) ) {
327 wp_mkdir_p( XSPEED_CACHE_DIR );
328 self::write_silence( XSPEED_CACHE_DIR );
329 }
330
331 // Path safety: cache_file_for() builds `XSPEED_CACHE_DIR . '/' . $key . '.html'`
332 // where $key comes from md5() — guaranteed to be exactly 32 lowercase
333 // hex chars, so no traversal sequence ('..', '/', null byte, etc.)
334 // can appear. The write is therefore always inside XSPEED_CACHE_DIR.
335 $file = self::cache_file_for( self::cache_key() );
336 // 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.
337 file_put_contents( $file, $full, LOCK_EX );
338
339 // Static-cache tree (xspeed-static/{host}{path}/index.html). The
340 // .htaccess rewrite block serves this file directly via the web
341 // server, bypassing PHP for ~3-5× lower TTFB vs the drop-in path.
342 // store_static() returns silently on any path/permission issue —
343 // the drop-in remains the safety net.
344 //
345 // Skip it entirely when mobile_separate is on: the rewrite is
346 // disabled in that mode (static_rewrite_allowed()), so a static file
347 // would only be dead weight — and a device-blind one at that.
348 if ( self::static_rewrite_allowed() ) {
349 self::store_static( $full );
350 }
351
352 return $buffer;
353 }
354
355 /**
356 * Write the current response to the static-cache tree at
357 * `xspeed-static/{host}{request_uri}/index.html`. The web-server
358 * rewrite block points at this path so cache hits skip PHP
359 * entirely. Caller already minified/finalized $html.
360 *
361 * Path safety: $host is restricted to a `[a-zA-Z0-9.\-]` allowlist;
362 * $uri has its query string stripped, null bytes removed, '..'
363 * sequences collapsed, and after concatenation we verify the
364 * resolved real path stays inside XSPEED_CACHE_STATIC_DIR before
365 * any write. Anything off the happy path returns silently.
366 */
367 private static function store_static( string $html ): void {
368 $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
369 $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
370 $host = preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host );
371 $uri = str_replace( "\0", '', $uri );
372 $uri = (string) strtok( $uri, '?' );
373 if ( '' === $host || '' === $uri ) {
374 return;
375 }
376 // Collapse any traversal sequences before path resolution.
377 $uri = preg_replace( '#/+#', '/', $uri );
378 if ( false !== strpos( $uri, '..' ) ) {
379 return;
380 }
381
382 $base = rtrim( XSPEED_CACHE_STATIC_DIR, '/' );
383 $dir = $base . '/' . $host . rtrim( $uri, '/' );
384 $file = $dir . '/index.html';
385
386 // Resolve the parent against the cache root to be sure the
387 // final path is inside our tree even if the OS does anything
388 // funny with multi-byte sequences.
389 $base_real = realpath( WP_CONTENT_DIR );
390 if ( false === $base_real || 0 !== strpos( $base, $base_real ) ) {
391 return;
392 }
393
394 if ( ! file_exists( $dir ) ) {
395 wp_mkdir_p( $dir );
396 }
397 if ( ! is_dir( $dir ) ) {
398 return;
399 }
400 // 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.
401 file_put_contents( $file, $html, LOCK_EX );
402 }
403
404 /**
405 * @param string $cause Free-form human reason. Recorded in the
406 * Activity log to give users context (e.g.
407 * 'post saved', 'settings change', 'manual',
408 * 'theme switch').
409 */
410 public static function purge_all( string $cause = 'manual' ) {
411 $count = 0;
412 if ( is_dir( XSPEED_CACHE_DIR ) ) {
413 $files = glob( XSPEED_CACHE_DIR . '/*.html' );
414 if ( $files ) {
415 $count = count( $files );
416 foreach ( $files as $f ) {
417 wp_delete_file( $f );
418 }
419 }
420 }
421 // Static-cache tree purge — recursive because the layout is
422 // xspeed-static/{host}/{path}/index.html, so a flat glob can't
423 // reach everything.
424 if ( is_dir( XSPEED_CACHE_STATIC_DIR ) ) {
425 $count += self::rmtree_html( XSPEED_CACHE_STATIC_DIR );
426 }
427 self::update_stats( array( 'last_purge' => time() ) );
428
429 // Trigger of WP_CLI / hook / admin-bar purges all hit the same
430 // path. Record once with the supplied cause so the dashboard
431 // activity feed reads naturally.
432 Activity_Log::record(
433 'cache_purged',
434 sprintf( 'Cache purged (%s) — %d file%s removed', $cause, $count, 1 === $count ? '' : 's' ),
435 Activity_Log::INFO
436 );
437 }
438
439 /**
440 * Recursively delete every `index.html` and empty directory inside
441 * the static-cache tree. Used by purge_all(). Returns the number of
442 * .html files removed so purge stats stay accurate across the flat
443 * + static caches.
444 */
445 private static function rmtree_html( string $dir ): int {
446 if ( ! is_dir( $dir ) ) {
447 return 0;
448 }
449 $removed = 0;
450 // SCANDIR_SORT_NONE skips alphabetic sort — we're going to walk
451 // the whole tree regardless of order.
452 $entries = @scandir( $dir, SCANDIR_SORT_NONE ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
453 if ( false === $entries ) {
454 return 0;
455 }
456 foreach ( $entries as $entry ) {
457 if ( '.' === $entry || '..' === $entry ) {
458 continue;
459 }
460 $path = $dir . '/' . $entry;
461 if ( is_dir( $path ) ) {
462 $removed += self::rmtree_html( $path );
463 // Best-effort empty-dir cleanup; ignore failures (a
464 // foreign file inside would block rmdir, which is fine).
465 // 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.
466 @rmdir( $path );
467 continue;
468 }
469 if ( substr( $entry, -5 ) === '.html' ) {
470 wp_delete_file( $path );
471 ++$removed;
472 }
473 }
474 return $removed;
475 }
476
477 /**
478 * Drop a "silence is golden" index.php into a directory so apaches/nginx
479 * with directory listing enabled don't expose cache contents.
480 */
481 public static function write_silence( $dir ) {
482 $file = trailingslashit( $dir ) . 'index.php';
483 if ( ! file_exists( $file ) ) {
484 // 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.
485 file_put_contents( $file, "<?php\n// Silence is golden.\n" );
486 }
487 }
488
489 /**
490 * Persist stats with autoload disabled — stats are only read in admin
491 * contexts, so there is no reason to inflate every frontend request's
492 * `wp_load_alloptions()` payload.
493 */
494 private static function update_stats( array $stats ) {
495 if ( false === get_option( 'xspeed_stats' ) ) {
496 add_option( 'xspeed_stats', $stats, '', 'no' );
497 return;
498 }
499 update_option( 'xspeed_stats', $stats );
500 }
501
502 public static function get_stats() {
503 $count = 0;
504 $size = 0;
505 if ( is_dir( XSPEED_CACHE_DIR ) ) {
506 $files = glob( XSPEED_CACHE_DIR . '/*.html' );
507 if ( $files ) {
508 $count = count( $files );
509 foreach ( $files as $f ) {
510 $size += filesize( $f );
511 }
512 }
513 }
514 // Drain the HIT-log file BEFORE reading totals. Two serve paths that
515 // bypass the normal in-PHP record_hit() append one line per HIT here:
516 // the nginx server-level rewrite (see nginx_snippet(), never reaches
517 // PHP) and the advanced-cache.php drop-in (runs pre-WordPress, can't
518 // reach Hit_Counter). Without this drain both look like a 0% hit-ratio
519 // on a perfectly working cache.
520 Hit_Counter::collect_nginx_log_hits();
521
522 // Apache/LiteSpeed static-rewrite HITs are served straight from disk
523 // by .htaccess and never reach PHP either — but there's no .htaccess
524 // equivalent of nginx's access_log directive, so we count them by
525 // scanning the web server's own access log incrementally. No-op when
526 // the log isn't readable (managed hosts) — see the method docblock.
527 Hit_Counter::collect_server_log_hits();
528
529 $stats = get_option( 'xspeed_stats', array() );
530 $totals = Hit_Counter::totals_24h();
531 return array(
532 'cached_pages' => $count,
533 'cache_size' => $size,
534 'last_purge' => isset( $stats['last_purge'] ) ? (int) $stats['last_purge'] : 0,
535 // Rolling 24h cache performance — sourced from Hit_Counter's
536 // hourly buckets. The frontend uses hit_ratio to drive the
537 // CacheHero stat grid + the Health module's panel.
538 'hits_24h' => $totals['hits'],
539 'misses_24h' => $totals['misses'],
540 'hit_ratio' => $totals['ratio'],
541 );
542 }
543
544 /**
545 * Apply the user's enable/disable choice. Called only from the REST
546 * toggle endpoint, which is gated by current_user_can( 'manage_options' )
547 * and a verified REST nonce. This is the only place the drop-in and
548 * the WP_CACHE constant are written — they MUST NOT happen on
549 * register_activation_hook (WordPress.org review requirement).
550 *
551 * @param bool $enable User's choice.
552 * @return array{
553 * enabled: bool,
554 * dropin_installed: bool,
555 * wp_cache_constant: bool,
556 * wp_config_writable: bool,
557 * manual_snippet: ?string
558 * }
559 */
560 public static function toggle( $enable ) {
561 $enable = (bool) $enable;
562
563 if ( $enable ) {
564 $dropin_ok = self::install_dropin();
565 $wp_config_ok = self::set_wp_cache_constant( true );
566 $rewrite_ok = self::install_rewrite();
567 self::ensure_hits_log_file();
568 self::sync_mobile_flag();
569 $snippet = $wp_config_ok ? null : "define( 'WP_CACHE', true );";
570
571 Activity_Log::record(
572 'cache_enabled_event',
573 $wp_config_ok
574 ? 'Cache enabled. Drop-in installed, WP_CACHE constant set.'
575 : 'Cache enabled. Drop-in installed; wp-config.php not writable — add the WP_CACHE snippet manually.',
576 $wp_config_ok ? Activity_Log::SUCCESS : Activity_Log::WARN
577 );
578
579 return array(
580 'enabled' => true,
581 'dropin_installed' => (bool) $dropin_ok,
582 'wp_cache_constant' => (bool) $wp_config_ok,
583 'rewrite_installed' => (bool) $rewrite_ok,
584 'wp_config_writable' => self::wp_config_writable(),
585 'manual_snippet' => $snippet,
586 'nginx_snippet' => self::nginx_snippet(),
587 // Unified server-block snippet aggregating every enabled
588 // module's directives — the same value the dashboard and
589 // Health insight render. The wizard shows this so all three
590 // surfaces stay in lockstep. Null on non-nginx hosts.
591 'nginx_server_block' => self::full_nginx_server_block(),
592 );
593 }
594
595 self::remove_dropin();
596 self::set_wp_cache_constant( false );
597 self::remove_rewrite();
598 // Drop the device-bucket marker too — with the drop-in gone there's
599 // nothing left to read it, and leaving it behind would dirty a fresh
600 // re-enable (and leaks across test runs).
601 self::sync_mobile_flag( false );
602
603 Activity_Log::record(
604 'cache_disabled_event',
605 'Cache disabled. Drop-in removed.',
606 Activity_Log::INFO
607 );
608
609 return array(
610 'enabled' => false,
611 'dropin_installed' => false,
612 'wp_cache_constant' => false,
613 'rewrite_installed' => false,
614 'wp_config_writable' => self::wp_config_writable(),
615 'manual_snippet' => null,
616 'nginx_snippet' => self::nginx_snippet(),
617 'nginx_server_block' => self::full_nginx_server_block(),
618 );
619 }
620
621 /**
622 * Check wp-config.php writability via WP_Filesystem. Plugin Check flags
623 * direct is_writable() under WordPress.WP.AlternativeFunctions.
624 */
625 private static function wp_config_writable() {
626 global $wp_filesystem;
627 if ( ! function_exists( 'WP_Filesystem' ) ) {
628 require_once ABSPATH . 'wp-admin/includes/file.php';
629 }
630 WP_Filesystem();
631
632 return $wp_filesystem ? (bool) $wp_filesystem->is_writable( ABSPATH . 'wp-config.php' ) : false;
633 }
634
635 /**
636 * Nginx server-block snippet mirroring the Apache rewrite block.
637 * We never auto-write nginx config — it sits outside the WordPress
638 * root and is owned by the server admin — but the dashboard
639 * surfaces this snippet when nginx is detected so the admin can
640 * paste it once and unlock the same PHP-bypass speedup we get on
641 * Apache / LiteSpeed via .htaccess.
642 *
643 * Returns null when the server isn't nginx (no point showing it).
644 */
645 /**
646 * Create wp-content/cache/xspeed/hits.log as an empty file so the
647 * server-level rewrite's `access_log` directive has somewhere to
648 * write on first request. Idempotent — touches an existing file
649 * without disturbing accumulated lines. Called from Cache::toggle()
650 * on enable and from auto_heal() when the file is missing.
651 *
652 * Permissions matter here. The file is created by PHP-FPM (often uid
653 * www-data), but the nginx process that appends HIT lines may run as a
654 * DIFFERENT uid — on multi-container hosts (e.g. xclude/Kinsta: nginx in
655 * its own container as uid `nginx`, PHP-FPM in another as `www-data`)
656 * they don't share a user at all. A default-umask 0644 file is then
657 * unwritable by nginx, the access_log write silently fails, and the
658 * dashboard shows a 0% hit ratio even though static HITs are serving.
659 * So we widen the dir to 0777 and the file to 0666 — group/other write —
660 * so whatever uid nginx runs as can append. (The file holds only HIT
661 * request lines, no secrets.)
662 */
663 /**
664 * Directory holding the nginx hit log. Lives under uploads/, NOT the
665 * cache dir — uninstall.php and a cache purge both delete the cache
666 * dir, which would orphan the pasted nginx `access_log` directive's
667 * parent directory and make `nginx -t` fail [emerg], taking down every
668 * vhost on the host (FBS-82478). uploads/ always exists, isn't a
669 * plugin-managed cache dir, and is never deleted on uninstall — so the
670 * directive's target dir survives both, and nginx (which creates a
671 * missing log FILE but not a missing DIR) can always open it.
672 *
673 * Falls back to the cache dir only if uploads is somehow unavailable.
674 */
675 public static function hits_log_dir(): string {
676 if ( function_exists( 'wp_upload_dir' ) ) {
677 $uploads = wp_upload_dir( null, false );
678 if ( is_array( $uploads ) && empty( $uploads['error'] ) && ! empty( $uploads['basedir'] ) ) {
679 return rtrim( (string) $uploads['basedir'], '/' ) . '/xspeed';
680 }
681 }
682 return XSPEED_CACHE_DIR;
683 }
684
685 /** Absolute path to the nginx hit log file. */
686 public static function hits_log_path(): string {
687 return self::hits_log_dir() . '/hits.log';
688 }
689
690 /**
691 * Sync the drop-in's mobile-bucket flag file with the `mobile_separate`
692 * setting. The drop-in (advanced-cache.php) runs before WordPress loads,
693 * so it can't read the option — instead it checks for a zero-byte
694 * `.mobile-separate` marker next to the cache files. When the setting is
695 * on we touch the marker; when off we remove it. The drop-in's cache_key
696 * computation keys off the marker's presence so its '|m'/'|d' device
697 * bucket stays in lockstep with Cache::cache_key().
698 *
699 * Without this, turning on mobile_separate made Cache::store() write keys
700 * with a '|d'/'|m' suffix the drop-in never reproduced — so the drop-in's
701 * file_exists() always missed, every HIT fell through to a full WP boot,
702 * and the fast pre-WP path was silently dead.
703 *
704 * @param bool|null $enabled Force a state; null reads the current setting.
705 */
706 public static function sync_mobile_flag( $enabled = null ): void {
707 if ( null === $enabled ) {
708 $opts = Settings_Manager::get( 'cache' );
709 $enabled = ! empty( $opts['mobile_separate'] );
710 }
711 $dir = XSPEED_CACHE_DIR;
712 $flag = $dir . '/.mobile-separate';
713 if ( $enabled ) {
714 if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
715 return;
716 }
717 if ( ! file_exists( $flag ) ) {
718 // 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.
719 @touch( $flag );
720 }
721 return;
722 }
723 if ( file_exists( $flag ) ) {
724 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal.
725 @unlink( $flag );
726 }
727 }
728
729 /**
730 * Reconcile every mobile_separate-dependent artifact to the current
731 * setting. Called on boot and whenever the cache settings are saved, so
732 * flipping mobile_separate at runtime can't leave the install in a
733 * half-converted state.
734 *
735 * Three things must agree with the setting:
736 * 1. the drop-in's `.mobile-separate` flag (sync_mobile_flag()),
737 * 2. the device-blind server rewrite — present only when OFF
738 * (static_rewrite_allowed()),
739 * 3. the now-stale static-cache tree + page cache, which were keyed
740 * under the old scheme and would serve wrong-device HTML.
741 *
742 * No-ops when the cache is disabled — there's nothing installed to
743 * reconcile, and toggle() handles install/teardown itself.
744 */
745 public static function reconcile_mobile_separate(): void {
746 self::sync_mobile_flag();
747
748 // The rewrite/static reconciliation below needs the plugin's path
749 // constants. They're absent in early-boot / unit-test contexts where
750 // only the drop-in flag matters — bail to the flag-only behavior then.
751 if ( ! defined( 'XSPEED_CACHE_STATIC_DIR' ) ) {
752 return;
753 }
754
755 // Only touch the rewrite + caches when caching is actually on.
756 $opts = get_option( 'xspeed_options', array() );
757 if ( empty( $opts['cache_enabled'] ) ) {
758 return;
759 }
760
761 $rewrite_present = self::rewrite_installed();
762 $rewrite_wanted = self::static_rewrite_allowed();
763
764 if ( $rewrite_present === $rewrite_wanted ) {
765 // Already consistent — nothing flipped, leave caches intact so a
766 // plain settings save (e.g. expiry change) doesn't blow the cache.
767 return;
768 }
769
770 // The setting flipped. Bring the rewrite into line and purge the
771 // now-misbucketed cache so the next request re-primes under the new
772 // device scheme.
773 if ( $rewrite_wanted ) {
774 self::install_rewrite();
775 } else {
776 self::remove_rewrite();
777 }
778 self::purge_all( 'mobile_separate changed' );
779 }
780
781 /**
782 * Whether the server-level static-rewrite fast path may be used.
783 *
784 * The rewrite serves `{host}{path}/index.html` straight from the web
785 * server, keyed only by host + path — it has no way to run our PHP
786 * device detection, so it can't tell mobile from desktop. When
787 * `mobile_separate` is on, a single static file would be shared across
788 * devices and whoever primed it wins (mobile visitors could get desktop
789 * HTML, or vice-versa). Rather than duplicate a wp_is_mobile()-equivalent
790 * UA matcher into .htaccess AND the nginx snippet (three copies that
791 * would inevitably drift), we simply DON'T engage the static rewrite when
792 * mobile_separate is on. Requests then fall through to the PHP drop-in,
793 * which buckets correctly — a small TTFB cost (~85ms vs ~30ms) paid only
794 * on mobile-separate sites, in exchange for guaranteed correctness.
795 *
796 * LiteSpeed exclusion (2026-06-16): on LiteSpeed — OpenLiteSpeed in
797 * particular — `.htaccess` CAN run our RewriteRule to serve the static
798 * file, but its `.htaccess` engine ignores `mod_headers`, so we cannot
799 * stamp the served response with `X-XSpeed-Cache: HIT`, AND there is no
800 * `.htaccess` equivalent of nginx's per-location `access_log` to record
801 * the hit. The result was a cache that worked but was invisible: no HIT
802 * header and a hit-ratio frozen near 0%. Every OTHER server gives the
803 * user a visible HIT header + a counted hit (nginx via add_header +
804 * access_log in its snippet; Apache via .htaccess mod_headers, which it
805 * honors). To keep LiteSpeed CONSISTENT with the rest, we route its hits
806 * through the PHP drop-in instead — the drop-in emits
807 * `X-XSpeed-Cache: HIT (php)` and calls Hit_Counter inline, exactly the
808 * observable behavior the other servers get. The cost is the drop-in's
809 * ~30ms TTFB vs the static path's ~10ms, paid only on LiteSpeed; in
810 * exchange the dashboard hit-ratio and the response header finally tell
811 * the truth there. (Apache keeps the static fast path — it honors the
812 * header.) See maybe_emit_lscache_headers() for the paired LSCache
813 * stand-down that stops LiteSpeed's own module from shadowing the
814 * drop-in.
815 */
816 public static function static_rewrite_allowed(): bool {
817 // LiteSpeed: drop-in serves hits (visible + counted) — see docblock.
818 if ( Server::LITESPEED === Server::type() ) {
819 return false;
820 }
821 $opts = Settings_Manager::get( 'cache' );
822 return empty( $opts['mobile_separate'] );
823 }
824
825 public static function ensure_hits_log_file(): bool {
826 $dir = self::hits_log_dir();
827 if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
828 return false;
829 }
830 // Ensure the dir is traversable + writable by a different-uid nginx.
831 // 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.
832 @chmod( $dir, 0777 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort; the access_log just stays empty if it fails.
833 $path = self::hits_log_path();
834 if ( ! file_exists( $path ) ) {
835 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch -- See docblock: must be a plain touch, not WP_Filesystem.
836 @touch( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- non-fatal helper; failures already covered by the dir check.
837 }
838 // World-writable so a different-uid nginx can append HIT lines.
839 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- See docblock.
840 @chmod( $path, 0666 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort.
841 return file_exists( $path );
842 }
843
844 public static function nginx_snippet(): ?string {
845 if ( Server::NGINX !== Server::type() ) {
846 return null;
847 }
848 $rel = '/' . ltrim( str_replace( ABSPATH, '/', XSPEED_CACHE_STATIC_DIR ), '/' );
849 $rel = rtrim( $rel, '/' );
850
851 // WP-Rocket-canonical pattern: every condition lives at
852 // SERVER level (outside any location block). Each one appends
853 // a tag to $xspeed_no_cache; the final check is a single
854 // string-equality against the unmodified default "no-cache".
855 // Only when ALL conditions pass does the rewrite fire,
856 // jumping the request to the static file's URL. nginx then
857 // restarts location matching against the new path, where
858 // regular static-file serving takes over.
859 //
860 // Why server-level + a single rewrite (instead of try_files
861 // inside `location /`): nginx's well-documented "if is evil"
862 // quirk silently disables `try_files`'s last fallback when
863 // any `if` in the same location is true. Moving the `if`s
864 // outside any location dodges the trap completely, because
865 // server-level rewrite is the documented stable path.
866 //
867 // `last` (not `break`) restarts location matching — required
868 // so the rewritten static-file URI gets served via the normal
869 // static-file location, not re-matched against `location /`
870 // where our own rewrite would loop.
871 //
872 // The cache existence check is the LAST condition in the
873 // chain so when the file isn't cached, $xspeed_no_cache
874 // gets a "-nofile" tag and the rewrite is skipped — the
875 // request falls through to whatever `location /` the user
876 // already had (typically `try_files $uri $uri/ /index.php?$args;`).
877 // Absolute path to the hit-log file from the nginx process's
878 // filesystem view. Nginx's `access_log buffer=N flush=Ns` form
879 // requires a literal path — `$document_root` variables are
880 // rejected — so PHP computes it. Lives under uploads/ (NOT the
881 // cache dir): a cache purge or uninstall deletes the cache dir,
882 // which would orphan this directive's parent directory and make
883 // `nginx -t` fail [emerg] for EVERY vhost on the host
884 // (FBS-82478). uploads/ survives both, so the directive can
885 // never take nginx down. Works on every topology where the nginx
886 // process shares a filesystem with PHP (container or host).
887 $hits_abs = self::hits_log_path();
888
889 $lines = array();
890 $lines[] = '# xSpeed static cache — paste at server level, above location / { }.';
891 // Cache host must match the on-disk dir PHP writes: store_static() /
892 // static_host() take HTTP_HOST and strip every char outside
893 // [a-zA-Z0-9.\-] — i.e. it removes the colon but KEEPS the port digits
894 // (localhost:8192 → localhost8192). nginx's own $host can't reproduce
895 // that: $host has the port already stripped ENTIRELY (→ localhost), so
896 // the -f check looks for localhost/... while PHP wrote localhost8192/...
897 // and the rewrite never fires on a non-standard port. Derive
898 // $xspeed_host from $http_host (which keeps the port) and drop just the
899 // colon, so it equals the PHP dir on every port. On standard ports
900 // $http_host has no colon, so $xspeed_host == $host == the bare domain.
901 $lines[] = 'set $xspeed_host $http_host;'; // default: no port → unchanged (e.g. example.com)
902 $lines[] = 'if ($http_host ~ "^([^:]+):(\\d+)$") { set $xspeed_host $1$2; }'; // host:port → hostport (matches PHP static_host())
903 $lines[] = 'set $xspeed_no_cache "no-cache";';
904 $lines[] = 'if ($request_method != GET) { set $xspeed_no_cache "$xspeed_no_cache-method"; }';
905 $lines[] = 'if ($args) { set $xspeed_no_cache "$xspeed_no_cache-args"; }';
906 $lines[] = 'if ($http_cookie ~* "(wordpress_logged_in|comment_author|wp-postpass_)") { set $xspeed_no_cache "$xspeed_no_cache-cookie"; }';
907 $lines[] = 'if (!-f "$document_root' . $rel . '/$xspeed_host$uri/index.html") { set $xspeed_no_cache "$xspeed_no_cache-nofile"; }';
908 // Neither `add_header` nor `access_log` is allowed inside an `if{}`
909 // at server level (nginx rejects with "directive is not allowed
910 // here"). The logging therefore lives in a `location` block that
911 // matches the rewritten URI after `rewrite … last;` restarts
912 // location matching. Every HIT lands there exactly once, every
913 // MISS / PHP-served request never matches it.
914 $lines[] = 'if ($xspeed_no_cache = "no-cache") {';
915 $lines[] = ' rewrite ^ ' . $rel . '/$xspeed_host$uri/index.html last;';
916 $lines[] = '}';
917 $lines[] = '';
918 $lines[] = '# Serve + log the cached HIT — `^~` is required so this beats any regex location.';
919 $lines[] = 'location ^~ ' . $rel . '/ {';
920 $lines[] = ' internal;';
921 // LITERAL log path (not `set $var; access_log $var`). The variable form
922 // makes nginx open the log lazily per-request and SILENTLY drop the
923 // line if the open fails — so on a working host hits were served
924 // (X-XSpeed-Cache fires regardless) but nothing was ever written and
925 // the hit ratio sat at 0%. A literal path makes nginx open the file at
926 // config load and actually log every hit.
927 //
928 // Deleting the log FILE is still safe with a literal path: nginx
929 // recreates it on the next write/reload and `nginx -t` stays green
930 // (verified). The only thing that [emerg]s `nginx -t` is a missing
931 // parent DIRECTORY — and the log lives under uploads/xspeed/, which
932 // survives cache purge + uninstall, and which ensure_hits_log_file()
933 // (run on every admin_init via auto_heal) recreates if it ever goes
934 // missing. So: hits are logged, and a user deleting the log can't take
935 // nginx down.
936 $lines[] = ' access_log ' . $hits_abs . ' combined buffer=16k flush=5s;';
937 $lines[] = ' add_header X-XSpeed-Cache "HIT (nginx)" always;';
938 $lines[] = '}';
939 return implode( "\n", $lines );
940 }
941
942 /**
943 * Aggregate every enabled module's nginx_directives() into one
944 * pasteable server-block snippet. Replaces the per-module "paste
945 * this snippet" notices with a single consolidated paste — every
946 * future feature toggle just regenerates this output.
947 *
948 * Returns null on non-nginx hosts (nothing to paste).
949 *
950 * Sections render in module-registration order so the layout stays
951 * predictable; each module gets a comment header `# <slug>`.
952 */
953 public static function full_nginx_server_block(): ?string {
954 if ( Server::NGINX !== Server::type() ) {
955 return null;
956 }
957
958 $blocks = array();
959 foreach ( Module_Registry::all() as $module ) {
960 $directives = $module->nginx_directives();
961 if ( ! is_string( $directives ) || '' === trim( $directives ) ) {
962 continue;
963 }
964 $blocks[] = "# === " . $module->slug() . " ===\n" . rtrim( $directives );
965 }
966
967 if ( empty( $blocks ) ) {
968 return null;
969 }
970
971 $header = "# xSpeed unified nginx config — paste into `server { }`, above `location / { }`; re-paste after toggling features.\n";
972
973 return $header . "\n" . implode( "\n\n", $blocks ) . "\n";
974 }
975
976 /**
977 * Tell LiteSpeed's LSCache module to stand down on the cache-miss
978 * render path.
979 *
980 * History: this method used to emit X-LiteSpeed-Cache-Control:
981 * public,max-age=N + X-LiteSpeed-Tag, handing caching to the server's
982 * LSCache store. That delegation backfired — once LSCache cached a
983 * page it served every subsequent request from its OWN store and
984 * intercepted the request before our site-root .htaccess static
985 * rewrite could run. Net effect on LiteSpeed hosts: no X-XSpeed-Cache
986 * header, our static-cache tree never served, the HIT log never
987 * written (hit ratio frozen at 0%), and the Health probe reporting a
988 * false "cache running on PHP fallback" because it never saw an
989 * xSpeed-served response.
990 *
991 * xSpeed now owns the cache on LiteSpeed exactly as it does on Apache:
992 * our `.htaccess` mod_rewrite block serves hits straight from the
993 * static-cache tree (with the X-XSpeed-Cache header + access-log HIT
994 * accounting), and PHP/the drop-in is the fallback. To guarantee
995 * LSCache doesn't shadow that with its own copy — some LiteSpeed
996 * configs cache by default — we send an explicit `no-cache` control so
997 * the server defers to our rewrite. Skipped when the LiteSpeed Cache
998 * plugin is active (it owns its own header policy; our Conflict
999 * registry handles that coexistence separately).
1000 */
1001 public static function maybe_emit_lscache_headers(): void {
1002 if ( headers_sent() ) {
1003 return;
1004 }
1005 if ( Server::LITESPEED !== Server::type() ) {
1006 return;
1007 }
1008 // is_plugin_active() lives in wp-admin/includes/plugin.php which
1009 // isn't auto-loaded on front-end requests. Use the option layer
1010 // directly to avoid pulling in admin code from a render path.
1011 $active = (array) get_option( 'active_plugins', array() );
1012 if ( in_array( 'litespeed-cache/litespeed-cache.php', $active, true ) ) {
1013 return;
1014 }
1015
1016 // Explicitly opt this response OUT of LSCache so the server can't
1017 // shadow our static-rewrite cache with its own internal copy.
1018 header( 'X-LiteSpeed-Cache-Control: no-cache' );
1019 }
1020
1021 /**
1022 * Reconcile drop-in + WP_CACHE + rewrite block with the user's
1023 * saved choice. Runs on admin_init. Cheap when nothing's wrong
1024 * (one option read + a handful of file_exists / defined checks);
1025 * writes only when state has drifted (typical cause: plugin
1026 * upgrade wiped the drop-in, foreign plugin removed our WP_CACHE
1027 * define, or someone hand-edited .htaccess).
1028 *
1029 * Skipped during the WP plugin updater run so we don't race
1030 * the upgrader's own filesystem operations.
1031 */
1032 public static function auto_heal(): void {
1033 if ( defined( 'WP_INSTALLING' ) && WP_INSTALLING ) {
1034 return;
1035 }
1036 if ( wp_doing_ajax() || wp_doing_cron() ) {
1037 return;
1038 }
1039
1040 $opts = get_option( 'xspeed_options', array() );
1041 if ( empty( $opts['cache_enabled'] ) ) {
1042 return;
1043 }
1044
1045 $dropin_target = WP_CONTENT_DIR . '/advanced-cache.php';
1046 $dropin_ours = false;
1047 if ( file_exists( $dropin_target ) ) {
1048 $contents = @file_get_contents( $dropin_target ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
1049 $dropin_ours = is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' );
1050 }
1051
1052 if ( ! $dropin_ours ) {
1053 self::install_dropin();
1054 }
1055
1056 if ( ! defined( 'WP_CACHE' ) || ! WP_CACHE ) {
1057 self::set_wp_cache_constant( true );
1058 }
1059
1060 // Rewrite block goes last. It's what turns the static-cache
1061 // tree into a PHP-bypass — every cache hit served by the web
1062 // server directly. Without it we still cache, just at drop-in
1063 // speed (~85ms TTFB) instead of static-file speed (~25-40ms).
1064 //
1065 // Reconcile against mobile_separate: the rewrite is device-blind, so
1066 // it must be ABSENT when mobile_separate is on and PRESENT otherwise.
1067 // auto_heal() runs periodically, so it also repairs a rewrite that
1068 // was left installed before mobile_separate was switched on.
1069 if ( self::static_rewrite_allowed() ) {
1070 if ( ! self::rewrite_installed() ) {
1071 self::install_rewrite();
1072 }
1073 } elseif ( self::rewrite_installed() ) {
1074 self::remove_rewrite();
1075 }
1076
1077 // HITs log file — nginx writes one line per HIT served directly
1078 // (see nginx_snippet()), Cache::get_stats() drains the file via
1079 // Hit_Counter::collect_nginx_log_hits(). If the file vanishes
1080 // (plugin upgrade wiped wp-content/cache/), nginx errors silently
1081 // on the access_log directive and the counter stays at 0.
1082 self::ensure_hits_log_file();
1083 }
1084
1085 /**
1086 * Build the .htaccess rules that map cacheable requests to the
1087 * static-cache tree. Conditions are deliberately strict: GET only,
1088 * empty query string, no session/comment-author/post-password
1089 * cookie, and the static file must exist on disk. Anything that
1090 * fails one of these falls through to PHP and the drop-in / full
1091 * WordPress path.
1092 *
1093 * @return string[] Lines for insert_with_markers().
1094 */
1095 public static function rewrite_block_lines(): array {
1096 // Path relative to ABSPATH so the rule lives in the site-root
1097 // .htaccess regardless of where wp-content sits. WP_CONTENT_DIR
1098 // can be moved, so we compute the document-root-relative form
1099 // at install time and bake it into the rule.
1100 $rel = str_replace( ABSPATH, '/', XSPEED_CACHE_STATIC_DIR );
1101 $rel = '/' . ltrim( $rel, '/' );
1102 $rel = rtrim( $rel, '/' );
1103
1104 return array(
1105 '<IfModule mod_rewrite.c>',
1106 ' RewriteEngine On',
1107 ' RewriteCond %{REQUEST_METHOD} ^GET$',
1108 ' RewriteCond %{QUERY_STRING} ^$',
1109 ' RewriteCond %{HTTP_COOKIE} !(wordpress_logged_in|comment_author|wp-postpass_) [NC]',
1110 // Capture REQUEST_URI without its trailing slash into %1.
1111 // store_static() writes `{host}{uri-without-trailing-slash}/index.html`,
1112 // so this normalization lets `/blog/` and `/blog` both hit
1113 // the same cache file without producing the double-slash
1114 // path that would skip the -f check below.
1115 ' RewriteCond %{REQUEST_URI} ^(.*?)/?$',
1116 ' RewriteCond %{DOCUMENT_ROOT}' . $rel . '/%{HTTP_HOST}%1/index.html -f',
1117 // Pattern is `^`, NOT `.`. The per-directory rewrite engine
1118 // strips the leading slash before matching, so the HOMEPAGE
1119 // request `/` arrives here as an EMPTY path. `.` requires at
1120 // least one character and therefore never matches the homepage
1121 // — on LiteSpeed (which honors this strictly) the front page
1122 // fell through to PHP while every inner page rewrote fine.
1123 // `^` matches the empty string AND any non-empty path, so it
1124 // covers `/` and `/blog` alike. (Confirmed on OpenLiteSpeed
1125 // 1.8: `.` → homepage served by PHP drop-in; `^` → served
1126 // directly from the static file.)
1127 ' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [L]',
1128 '</IfModule>',
1129 );
1130 }
1131
1132 /**
1133 * Active probe that confirms the web-server static-rewrite path is
1134 * actually serving cached files. Writes a probe file with a random
1135 * nonce, fetches it over HTTP at its public URL, and checks whether
1136 * the response was served directly by the web server (Last-Modified
1137 * + ETag headers + no X-Powered-By: PHP).
1138 *
1139 * Server-agnostic: same probe works for nginx (snippet pasted) and
1140 * Apache / LiteSpeed (.htaccess block installed). If the rewrite
1141 * isn't engaged, the request falls through to WordPress and PHP
1142 * adds its own headers, which the probe detects and reports.
1143 *
1144 * Throttled via a 5-minute transient — we never want this running
1145 * on every Health card paint.
1146 *
1147 * @return array{active:bool, reason:string, code?:int, php?:bool, expires?:int}
1148 */
1149 /**
1150 * @param bool $allow_probe When false (the default), return ONLY a cached
1151 * result and never make an HTTP request — so admin page loads are never
1152 * blocked by the loopback probe. The actual HTTP probe only runs when a
1153 * caller explicitly opts in (the Health tab / cron). Previously this ran
1154 * synchronously on every dashboard bootstrap, so a slow/timing-out
1155 * loopback request added up to `timeout` seconds to admin page loads on
1156 * hosts that block self-requests. (FBS-82142)
1157 */
1158 public static function probe_static_rewrite( bool $allow_probe = false ): array {
1159 $cached = get_transient( 'xspeed_rewrite_probe' );
1160 if ( is_array( $cached ) ) {
1161 return $cached;
1162 }
1163 // No cached result yet and the caller doesn't want to pay for a live
1164 // HTTP probe (e.g. the admin bootstrap): report "pending" without
1165 // blocking. The Health tab will run the real probe on demand.
1166 if ( ! $allow_probe ) {
1167 return array( 'active' => false, 'reason' => 'probe pending', 'pending' => true );
1168 }
1169
1170 $home = home_url( '/' );
1171 $host = (string) wp_parse_url( $home, PHP_URL_HOST );
1172 if ( '' === $host ) {
1173 $result = array( 'active' => false, 'reason' => 'home_url has no host' );
1174 set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
1175 return $result;
1176 }
1177
1178 // Use a randomised path AND nonce so a stale CDN cache entry
1179 // from a prior probe can never make a broken install look
1180 // healthy. Path is namespaced under __xspeed_probe__ so the
1181 // directory listing stays obvious if cleanup misfires.
1182 $slug = wp_generate_password( 12, false, false );
1183 $nonce = wp_generate_password( 24, false, false );
1184 $probe_dir = XSPEED_CACHE_STATIC_DIR . '/' . $host . '/__xspeed_probe__/' . $slug;
1185 $probe_file = $probe_dir . '/index.html';
1186 $probe_url = trailingslashit( $home ) . '__xspeed_probe__/' . $slug . '/';
1187
1188 if ( ! file_exists( $probe_dir ) ) {
1189 wp_mkdir_p( $probe_dir );
1190 }
1191 if ( ! is_dir( $probe_dir ) ) {
1192 $result = array( 'active' => false, 'reason' => 'cannot create probe dir' );
1193 set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
1194 return $result;
1195 }
1196 // 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.
1197 file_put_contents( $probe_file, $nonce, LOCK_EX );
1198
1199 // Verify TLS by default — disabling it site-wide is a needless MITM
1200 // exposure (FBS-82142). Only relax verification in local/dev
1201 // environments, where self-signed certs are common and there's no
1202 // real attacker in the loop.
1203 $is_local = function_exists( 'wp_get_environment_type' )
1204 && in_array( wp_get_environment_type(), array( 'local', 'development' ), true );
1205 $resp = wp_remote_get(
1206 $probe_url,
1207 array(
1208 // 3s cap so a host that hangs on loopback self-requests can't
1209 // stall the caller for long; the result/error is cached so we
1210 // don't repeat the wait every minute.
1211 'timeout' => 3,
1212 'sslverify' => ! $is_local,
1213 'redirection' => 0,
1214 'headers' => array( 'Cache-Control' => 'no-cache' ),
1215 )
1216 );
1217
1218 // Best-effort cleanup so we don't accumulate probe dirs even
1219 // if subsequent calls all hit the transient.
1220 if ( file_exists( $probe_file ) ) {
1221 wp_delete_file( $probe_file );
1222 }
1223 if ( is_dir( $probe_dir ) ) {
1224 // 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.
1225 @rmdir( $probe_dir );
1226 }
1227
1228 if ( is_wp_error( $resp ) ) {
1229 $result = array(
1230 'active' => false,
1231 'reason' => 'http error: ' . $resp->get_error_message(),
1232 );
1233 // Cache the failure for the full 5 minutes (not 1) so a host that
1234 // times out on the loopback probe isn't re-probed — and re-stalled
1235 // — on every page load within the window. (FBS-82142)
1236 set_transient( 'xspeed_rewrite_probe', $result, 5 * MINUTE_IN_SECONDS );
1237 return $result;
1238 }
1239
1240 $code = (int) wp_remote_retrieve_response_code( $resp );
1241 $body = (string) wp_remote_retrieve_body( $resp );
1242 $ua_php = '' !== (string) wp_remote_retrieve_header( $resp, 'x-powered-by' );
1243 $has_etag = '' !== (string) wp_remote_retrieve_header( $resp, 'etag' )
1244 || '' !== (string) wp_remote_retrieve_header( $resp, 'last-modified' );
1245 $match = trim( $body ) === $nonce;
1246
1247 // "Active" = the web server served our raw nonce bytes back
1248 // AND emitted the static-serve markers (ETag / Last-Modified)
1249 // AND didn't add an X-Powered-By: PHP header. All three are
1250 // individually noisy; together they're conclusive.
1251 $active = $match && $has_etag && ! $ua_php && 200 === $code;
1252
1253 if ( $active ) {
1254 $reason = 'static-served';
1255 } elseif ( 200 === $code && $match && $ua_php ) {
1256 $reason = 'php served the file instead of nginx/Apache (rewrite block missing)';
1257 } elseif ( 200 === $code && ! $match ) {
1258 $reason = 'unexpected body (CDN cached an older response?)';
1259 } elseif ( 404 === $code ) {
1260 $reason = 'probe URL returned 404 (rewrite block missing or wrong path)';
1261 } else {
1262 $reason = sprintf( 'unexpected response (HTTP %d, body %d B, php=%s)', $code, strlen( $body ), $ua_php ? 'yes' : 'no' );
1263 }
1264
1265 $result = array(
1266 'active' => $active,
1267 'reason' => $reason,
1268 'code' => $code,
1269 'php' => $ua_php,
1270 );
1271 set_transient( 'xspeed_rewrite_probe', $result, 5 * MINUTE_IN_SECONDS );
1272 return $result;
1273 }
1274
1275 public static function rewrite_installed(): bool {
1276 $htaccess = ABSPATH . '.htaccess';
1277 if ( ! file_exists( $htaccess ) ) {
1278 return false;
1279 }
1280 $existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
1281 if ( ! is_string( $existing ) ) {
1282 return false;
1283 }
1284 return false !== strpos( $existing, '# BEGIN xSpeed Static Cache' );
1285 }
1286
1287 /**
1288 * Install the static-cache rewrite block at the TOP of .htaccess.
1289 *
1290 * Position matters: WordPress's own block ends with
1291 * `RewriteRule . /index.php [L]` which routes every non-file
1292 * request to PHP. The [L] flag stops the current rewrite pass,
1293 * but Apache restarts the cycle; on the second pass REQUEST_URI
1294 * is /index.php and no static-file check can match. The only
1295 * reliable position for a "serve static if it exists" rule is
1296 * before WordPress's block.
1297 *
1298 * WP's insert_with_markers() always appends, so we manage the
1299 * block manually: strip any prior xSpeed Static Cache markers,
1300 * then write our block followed by the rest of the file.
1301 */
1302 public static function install_rewrite(): bool {
1303 // The static rewrite is device-blind; never install it when
1304 // mobile_separate is on (see static_rewrite_allowed()).
1305 if ( ! self::static_rewrite_allowed() ) {
1306 return false;
1307 }
1308 $htaccess = ABSPATH . '.htaccess';
1309 $existing = file_exists( $htaccess ) ? @file_get_contents( $htaccess ) : ''; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
1310 if ( false === $existing ) {
1311 $existing = '';
1312 }
1313 // Apache/LiteSpeed only. nginx hosts: rule won't fire, drop-in
1314 // covers; we skip the write so we don't litter their root.
1315 // 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.
1316 if ( file_exists( $htaccess ) && ! is_writable( $htaccess ) ) {
1317 return false;
1318 }
1319 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- See above.
1320 if ( ! file_exists( $htaccess ) && ! is_writable( ABSPATH ) ) {
1321 return false;
1322 }
1323
1324 $cleaned = self::strip_marker_block( $existing, 'xSpeed Static Cache' );
1325 $block = self::marker_block( 'xSpeed Static Cache', self::rewrite_block_lines() );
1326 $next = $block . ( '' === $cleaned ? '' : "\n" . $cleaned );
1327
1328 // 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.
1329 return false !== file_put_contents( $htaccess, $next, LOCK_EX );
1330 }
1331
1332 public static function remove_rewrite(): bool {
1333 $htaccess = ABSPATH . '.htaccess';
1334 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- See install_rewrite() rationale.
1335 if ( ! file_exists( $htaccess ) || ! is_writable( $htaccess ) ) {
1336 return false;
1337 }
1338 $existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
1339 if ( false === $existing ) {
1340 return false;
1341 }
1342 $cleaned = self::strip_marker_block( $existing, 'xSpeed Static Cache' );
1343 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents, PluginCheck.CodeAnalysis.WriteFile.ABSPATHDetected -- See install_rewrite() rationale.
1344 return false !== file_put_contents( $htaccess, $cleaned, LOCK_EX );
1345 }
1346
1347 /**
1348 * Strip a `# BEGIN <marker>` ... `# END <marker>` block from a
1349 * .htaccess-style file, including any blank line that immediately
1350 * follows it. Idempotent — returns the input unchanged if the
1351 * marker isn't present.
1352 */
1353 private static function strip_marker_block( string $contents, string $marker ): string {
1354 $pattern = '/# BEGIN ' . preg_quote( $marker, '/' ) . '\b.*?# END ' . preg_quote( $marker, '/' ) . "\b[^\n]*\n?\n?/s";
1355 $out = preg_replace( $pattern, '', $contents );
1356 return is_string( $out ) ? $out : $contents;
1357 }
1358
1359 private static function marker_block( string $marker, array $lines ): string {
1360 $header = "# BEGIN $marker\n";
1361 $header .= "# The directives (lines) between \"BEGIN $marker\" and \"END $marker\" are\n";
1362 $header .= "# dynamically generated, and should only be modified via WordPress filters.\n";
1363 $header .= "# Any changes to the directives between these markers will be overwritten.\n";
1364 $footer = "# END $marker\n";
1365 return $header . implode( "\n", $lines ) . "\n" . $footer;
1366 }
1367
1368 public static function install_dropin() {
1369 $source = XSPEED_DIR . 'includes/advanced-cache.php';
1370 $target = WP_CONTENT_DIR . '/advanced-cache.php';
1371 if ( ! file_exists( $source ) ) {
1372 return false;
1373 }
1374
1375 global $wp_filesystem;
1376 if ( ! function_exists( 'WP_Filesystem' ) ) {
1377 require_once ABSPATH . 'wp-admin/includes/file.php';
1378 }
1379 WP_Filesystem();
1380 if ( ! $wp_filesystem ) {
1381 return false;
1382 }
1383
1384 $source_contents = $wp_filesystem->get_contents( $source );
1385 if ( ! is_string( $source_contents ) ) {
1386 return false;
1387 }
1388
1389 // Bake the absolute hit-log path into the drop-in. It runs before
1390 // WordPress loads, so it can't resolve wp_upload_dir() itself — we
1391 // substitute the @@XSPEED_HITS_LOG@@ token with the real uploads path
1392 // (never the cache dir; see hits_log_dir() / FBS-82478). Use a single
1393 // quoted PHP string literal so the installed file stays valid PHP.
1394 $source_contents = str_replace(
1395 '@@XSPEED_HITS_LOG@@',
1396 str_replace( "'", "\\'", self::hits_log_path() ),
1397 $source_contents
1398 );
1399
1400 if ( file_exists( $target ) ) {
1401 $existing = $wp_filesystem->get_contents( $target );
1402 $is_xspeed = is_string( $existing ) && false !== strpos( $existing, 'XSPEED_DROPIN' );
1403
1404 if ( $is_xspeed ) {
1405 if ( $existing === $source_contents ) {
1406 return true;
1407 }
1408 return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE );
1409 }
1410
1411 // Foreign drop-in (e.g. left over from another cache plugin) — back it up
1412 // before overwriting so the user can recover if needed. Uploads dir
1413 // (not wp-content root) keeps the backup out of WordPress's reserved
1414 // drop-in location.
1415 $upload = wp_upload_dir( null, false );
1416 $basedir = isset( $upload['basedir'] ) ? trailingslashit( $upload['basedir'] ) . 'xspeed-backups' : false;
1417 if ( $basedir ) {
1418 if ( ! file_exists( $basedir ) ) {
1419 wp_mkdir_p( $basedir );
1420 self::write_silence( $basedir );
1421 }
1422 $backup = $basedir . '/advanced-cache.foreign-' . gmdate( 'Ymd-His' ) . '.php.bak';
1423 $wp_filesystem->move( $target, $backup, true );
1424 } else {
1425 $wp_filesystem->delete( $target );
1426 }
1427 }
1428
1429 return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE );
1430 }
1431
1432 public static function remove_dropin() {
1433 $target = WP_CONTENT_DIR . '/advanced-cache.php';
1434 if ( ! file_exists( $target ) ) {
1435 return;
1436 }
1437
1438 global $wp_filesystem;
1439 if ( ! function_exists( 'WP_Filesystem' ) ) {
1440 require_once ABSPATH . 'wp-admin/includes/file.php';
1441 }
1442 WP_Filesystem();
1443 if ( ! $wp_filesystem ) {
1444 return;
1445 }
1446
1447 $contents = $wp_filesystem->get_contents( $target );
1448 if ( is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' ) ) {
1449 wp_delete_file( $target );
1450 }
1451 }
1452
1453 public static function set_wp_cache_constant( $enable ) {
1454 $wp_config = ABSPATH . 'wp-config.php';
1455 if ( ! file_exists( $wp_config ) ) {
1456 return false;
1457 }
1458
1459 global $wp_filesystem;
1460 if ( ! function_exists( 'WP_Filesystem' ) ) {
1461 require_once ABSPATH . 'wp-admin/includes/file.php';
1462 }
1463 WP_Filesystem();
1464 if ( ! $wp_filesystem || ! $wp_filesystem->is_writable( $wp_config ) ) {
1465 return false;
1466 }
1467
1468 $config = $wp_filesystem->get_contents( $wp_config );
1469
1470 if ( $enable ) {
1471 if ( strpos( $config, "define( 'WP_CACHE'" ) !== false || strpos( $config, "define('WP_CACHE'" ) !== false ) {
1472 return true;
1473 }
1474 $config = preg_replace( '/(<\?php)/', "$1\ndefine( 'WP_CACHE', true );", $config, 1 );
1475 } else {
1476 $config = preg_replace( "/define\\(\\s*['\"]WP_CACHE['\"]\\s*,\\s*true\\s*\\);\\s*\\n?/", '', $config );
1477 }
1478
1479 return (bool) $wp_filesystem->put_contents( $wp_config, $config, FS_CHMOD_FILE );
1480 }
1481
1482 public function admin_bar_purge( $wp_admin_bar ) {
1483 if ( ! current_user_can( 'manage_options' ) ) {
1484 return;
1485 }
1486 $wp_admin_bar->add_node(
1487 array(
1488 'id' => 'xspeed-purge',
1489 'title' => __( 'Purge xSpeed Cache', 'xspeed' ),
1490 'href' => wp_nonce_url( admin_url( 'admin-post.php?action=xspeed_purge' ), 'xspeed_purge' ),
1491 )
1492 );
1493 }
1494
1495 public function handle_admin_bar_purge() {
1496 if ( ! current_user_can( 'manage_options' ) ) {
1497 wp_die( esc_html__( 'Unauthorized.', 'xspeed' ), 403 );
1498 }
1499 check_admin_referer( 'xspeed_purge' );
1500 self::purge_all();
1501 wp_safe_redirect( wp_get_referer() ?: admin_url() );
1502 exit;
1503 }
1504 }
1505