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

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

2,755 lines 113.7 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 // Events that should invalidate cached output. Beyond posts/comments,
27 // this covers user and term changes — the REST cache can serve
28 // /wp/v2/users, /wp/v2/categories, /wp/v2/tags, and these also affect
29 // rendered author bylines / term-archive pages. Without them, an edit
30 // left the matching endpoint (and archives) stale for the full TTL.
31 // (FBS-82408)
32 $invalidate_hooks = array(
33 'save_post', 'deleted_post', 'trashed_post',
34 'comment_post', 'wp_set_comment_status',
35 'switch_theme', 'activated_plugin', 'deactivated_plugin',
36 // Users → /wp/v2/users + author archives.
37 'profile_update', 'user_register', 'deleted_user',
38 // Terms → /wp/v2/{taxonomy} + term archives.
39 'created_term', 'edited_term', 'delete_term',
40 );
41 foreach ( $invalidate_hooks as $hook ) {
42 add_action( $hook, array( __CLASS__, 'purge_all' ) );
43 add_action( $hook, array( 'XSpeed\\Minifier', 'purge_minified' ) );
44 }
45
46 add_action( 'update_option_xspeed_options', array( __CLASS__, 'on_settings_change' ), 10, 2 );
47
48 add_action( 'admin_bar_menu', array( $this, 'admin_bar_purge' ), 100 );
49 add_action( 'admin_post_xspeed_purge', array( $this, 'handle_admin_bar_purge' ) );
50 }
51
52 public static function on_settings_change( $old, $new ) {
53 // gzip_enabled moved to xspeed_module_gzip — GzipModule owns the
54 // .htaccess flip via its own update_option_xspeed_module_gzip hook.
55 // Same migration is planned for cache_expiry + excluded_urls
56 // (Cache module). Keep this handler around for whatever still
57 // lives in the legacy blob (cache_enabled is special and goes
58 // through Cache::toggle anyway).
59
60 // Any settings change — purge caches so changes take effect.
61 self::purge_all( 'settings change' );
62 Minifier::purge_minified();
63 }
64
65 public function maybe_start_cache() {
66 if ( ! self::should_cache() ) {
67 return;
68 }
69
70 $key = self::cache_key();
71 $file = self::cache_file_for( $key );
72
73 if ( file_exists( $file ) && ! self::is_expired( $file ) ) {
74 Hit_Counter::record_hit();
75 // Emit the HIT marker on THIS path too. The drop-in
76 // (advanced-cache.php) sends "HIT (php)" and the nginx static
77 // rewrite sends "HIT (nginx)", but this template_redirect
78 // serve path — the one that runs when the drop-in isn't loaded
79 // (e.g. WP_CACHE not true) — previously streamed the cached
80 // file with NO marker, so a genuine HIT looked like a MISS in
81 // the response headers. Same header + value as the drop-in.
82 if ( ! headers_sent() ) {
83 header( 'X-XSpeed-Cache: HIT (php)' );
84 }
85 // Replay stored response bits so the HIT matches the original:
86 // a non-HTML Content-Type (cached feeds, sitemaps) and a non-200
87 // status (a cached 404 must serve 404, not 200). No-op for
88 // ordinary pages, which write no .meta.
89 $meta = self::read_meta( $key );
90 if ( ! headers_sent() ) {
91 if ( ! empty( $meta['status'] ) && function_exists( 'http_response_code' ) ) {
92 http_response_code( (int) $meta['status'] );
93 }
94 if ( ! empty( $meta['content_type'] ) && is_string( $meta['content_type'] ) ) {
95 header( 'Content-Type: ' . $meta['content_type'] );
96 }
97 // Conditional GET: emit Last-Modified + ETag and answer a
98 // matching If-Modified-Since / If-None-Match with 304 so
99 // aggregators (and browsers) skip re-downloading an unchanged
100 // cached response — the bandwidth win feeds are about.
101 // (FBS-82407 #5)
102 if ( self::serve_not_modified( $file ) ) {
103 exit; // 304 sent, no body.
104 }
105 }
106 // Serve the precompressed Brotli sibling when the client accepts
107 // it (an add-on, the Pro Brotli module, wrote <file>.br). On this
108 // PHP serve path the web server never sees the .br, so without
109 // this a br-capable client got the plain .html — precompression
110 // did nothing here. Falls through to plain readfile otherwise.
111 $br = self::maybe_serve_brotli( $file );
112 if ( null !== $br ) {
113 // 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.
114 readfile( $br );
115 exit;
116 }
117 // 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.
118 readfile( $file );
119 exit;
120 }
121
122 // Cache miss → render fresh + write cache. On LiteSpeed we send an
123 // explicit "stand down" header so the server's LSCache module does
124 // NOT cache + shadow our response — xSpeed's own .htaccess static
125 // rewrite owns hit serving (and hit accounting) here, exactly as on
126 // Apache. See maybe_emit_lscache_headers() for the full rationale.
127 self::maybe_emit_lscache_headers();
128
129 // We're about to render fresh + cache → miss for this request.
130 Hit_Counter::record_miss();
131
132
133 // WP < 6.9 fallback: ob_start() with a callback, paired with an
134 // explicit shutdown close so the buffer lifecycle is visible to
135 // reviewers and Plugin Check, instead of relying on PHP's implicit
136 // request-end flush. We record our nesting level so close_buffer()
137 // flushes ONLY the buffer we opened.
138 ob_start( array( __CLASS__, 'finalize_buffer' ) );
139 self::$buffer_level = ob_get_level();
140
141 add_action( 'shutdown', array( __CLASS__, 'close_buffer' ), 0 );
142 }
143
144 /**
145 * Close the cache buffer opened by maybe_start_cache().
146 *
147 * Guarded by the recorded buffer level so we never flush a buffer that
148 * another plugin pushed on top of (or under) ours. If something else is
149 * currently on top, we leave the stack alone — PHP's shutdown sequence
150 * will unwind buffers in order and our finalize_buffer() callback will
151 * still run when our level becomes the topmost one.
152 */
153 public static function close_buffer() {
154 if ( null === self::$buffer_level ) {
155 return;
156 }
157 if ( ob_get_level() === self::$buffer_level ) {
158 ob_end_flush();
159 }
160 self::$buffer_level = null;
161 }
162
163 public static function should_cache() {
164 $opts = Settings::get();
165 if ( empty( $opts['cache_enabled'] ) ) {
166 return false;
167 }
168
169 if ( is_user_logged_in() || is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
170 return false;
171 }
172
173 if ( defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE ) {
174 return false;
175 }
176
177 // All exclusion knobs now owned by CacheModule.
178 $cache_opts = Settings_Manager::get( 'cache' );
179
180 $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : '';
181 if ( 'GET' !== $method ) {
182 return false;
183 }
184
185 // Search-results requests carry a `s` query param, which the
186 // query-string gate below would normally reject as "dynamic". An
187 // add-on (xspeed-pro search cache) can opt them in: when this is a
188 // genuine is_search() and the filter returns true, the `s` param is
189 // treated as cacheable (the search term goes into the cache key so
190 // different searches stay distinct — see cache_key()).
191 $cache_search = self::should_cache_search();
192
193 // Feed opt-in is resolved BEFORE the query-string gate so query-form
194 // feeds (/?feed=rss2, used on plain-permalink sites) aren't rejected
195 // as "dynamic" by that gate — the `feed` param is then allowed through
196 // just like the search `s` param. Feeds are excluded by default (the
197 // `/feed/` pattern in excluded_urls); an add-on (xspeed-pro feed cache)
198 // opts them back in via the filter. (FBS-82407 #4)
199 $is_feed_request = function_exists( 'is_feed' ) && is_feed();
200 /**
201 * Whether to cache the current feed request.
202 *
203 * Default false → feeds fall through to the normal URL-exclusion
204 * rules (so `/feed/` keeps them out). A listener returning true
205 * opts this feed request into caching.
206 *
207 * @param bool $cache_feed Whether to cache this feed request.
208 */
209 $cache_feed = $is_feed_request && (bool) apply_filters( 'xspeed_should_cache_feed', false );
210
211 // Query string handling: anything OUTSIDE the ignored-params
212 // allow-list (utm_*, fbclid, gclid by default) means a unique
213 // request that we don't want to share with the canonical cache
214 // entry. Skip cache rather than poison the key.
215 //
216 // Parse the RAW query string, NOT a sanitize_text_field() copy:
217 // that filter strips percent-encoded octets (%XX), so `?%73=…`
218 // would lose its `s` key here while WordPress still decodes it to
219 // a search request — the gate would wave the request through and
220 // cache_key() would file the search page under the bare URL,
221 // letting an attacker poison the homepage cache with `/?%73=<spam>`.
222 // parse_str() does its own urldecoding, matching WP's own parse, and
223 // only the KEYS are used below (fed to Glob_Matcher → preg_match,
224 // never echoed or executed), so no sanitization is needed here.
225 $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.
226 if ( '' !== $query_raw ) {
227 $ignored = is_array( $cache_opts['ignored_query_params'] ?? null ) ? $cache_opts['ignored_query_params'] : array();
228 parse_str( $query_raw, $params );
229 foreach ( $params as $key => $_ ) {
230 // Allow the search param through when search caching is on.
231 if ( $cache_search && 's' === $key ) {
232 continue;
233 }
234 // Allow query-form feed params through when feed caching opted
235 // this request in (?feed=rss2 / &withcomments=1 on feeds).
236 if ( $cache_feed && in_array( $key, array( 'feed', 'withcomments', 'withoutcomments' ), true ) ) {
237 continue;
238 }
239 if ( ! self::query_key_is_ignored( (string) $key, $ignored ) ) {
240 return false;
241 }
242 }
243 }
244
245 $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
246 $path = (string) strtok( $request_uri, '?' );
247
248 $excluded_urls = is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array();
249 if ( ! $cache_feed && Glob_Matcher::any_match( $excluded_urls, $path ) ) {
250 return false;
251 }
252
253 // Cookie-based exclusion. We only check cookie NAMES (matching
254 // values would leak content-sensitive logic into the cache key
255 // rules); presence of any matching cookie name skips cache.
256 $excluded_cookies = is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array();
257 if ( ! empty( $excluded_cookies ) && ! empty( $_COOKIE ) ) {
258 foreach ( array_keys( $_COOKIE ) as $cookie_name ) {
259 if ( Glob_Matcher::any_match( $excluded_cookies, (string) $cookie_name ) ) {
260 return false;
261 }
262 }
263 }
264
265 // User-agent bypass list. Substring match (not glob) since UA
266 // strings have so much variation that glob anchoring rarely
267 // helps and confuses users.
268 $bypass_uas = is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array();
269 if ( ! empty( $bypass_uas ) ) {
270 $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
271 foreach ( $bypass_uas as $needle ) {
272 if ( '' !== $needle && false !== stripos( $ua, (string) $needle ) ) {
273 return false;
274 }
275 }
276 }
277
278 // Per-post override (Phase 3.4). Honored only on singular
279 // post-context requests — archives / 404s / taxonomies use the
280 // global policy above.
281 if ( Cache_Rules::should_skip_for_post( Cache_Rules::current_post_id() ) ) {
282 return false;
283 }
284
285 /**
286 * Final say on whether the current request is cacheable.
287 *
288 * Runs at template_redirect (full WP context), so listeners may use
289 * conditional tags (is_search(), is_feed(), is_404(),
290 * wp_is_maintenance_mode(), …). The core engine has already applied
291 * its own exclusion rules and reached `true`; a listener returning
292 * false vetoes caching for this request. This is the documented
293 * extension point add-ons (xspeed-pro) hook to add their own
294 * request-level cache policy without forking the engine.
295 *
296 * Note: this gates the WRITE side. The pre-WP drop-in
297 * (advanced-cache.php) cannot run PHP filters, so request types that
298 * must never be *served* from a stale file are handled by not
299 * writing them here and/or by purging — see the conflict notes in
300 * advanced-cache.php.
301 *
302 * @param bool $should_cache Whether to cache the current request.
303 */
304 return (bool) apply_filters( 'xspeed_should_cache', true );
305 }
306
307 /**
308 * Whether the current request is a 404 we may cache.
309 *
310 * True only when: it's a genuine main-query is_404(), an add-on opted
311 * in via `xspeed_should_cache_404` (default false), and the request
312 * isn't a transient 404 we must never freeze — maintenance mode or a
313 * 404 emitted while the DB/site is in an error state. The xspeed-pro
314 * 404 cache flips the filter; Free never caches 404s on its own.
315 */
316 public static function should_cache_404(): bool {
317 if ( ! function_exists( 'is_404' ) || ! is_404() ) {
318 return false;
319 }
320 // Never cache a 404 served because the site is down for
321 // maintenance — that screen disappears the moment maintenance
322 // ends, and a cached copy would outlive it.
323 if ( function_exists( 'wp_is_maintenance_mode' ) && wp_is_maintenance_mode() ) {
324 return false;
325 }
326
327 /**
328 * Whether to cache the current 404 response.
329 *
330 * Default false. A listener returning true opts the (genuine)
331 * 404 into the page cache, served back for any unknown URL under
332 * one generic key. The 404 status is preserved on the HIT.
333 *
334 * @param bool $cache_404 Whether to cache this 404.
335 */
336 return (bool) apply_filters( 'xspeed_should_cache_404', false );
337 }
338
339 /**
340 * Whether the current request is an internal search-results page we
341 * may cache.
342 *
343 * True only when: it's a genuine main-query is_search() with a
344 * non-empty term, and an add-on opted in via `xspeed_should_cache_search`
345 * (default false). The search term is folded into the cache key (see
346 * search_term() / cache_key()) so different searches stay distinct.
347 * The xspeed-pro search cache flips the filter; Free never caches
348 * search results on its own.
349 */
350 public static function should_cache_search(): bool {
351 if ( ! function_exists( 'is_search' ) || ! is_search() ) {
352 return false;
353 }
354 // Empty search (`?s=`) renders the same as a normal archive and
355 // carries no term to key on — let it fall through to the usual
356 // rules rather than caching an ambiguous entry.
357 if ( '' === self::search_term() ) {
358 return false;
359 }
360
361 /**
362 * Whether to cache the current search-results request.
363 *
364 * Default false. A listener returning true opts the search page
365 * into the cache, keyed by the normalized search term.
366 *
367 * @param bool $cache_search Whether to cache this search request.
368 */
369 return (bool) apply_filters( 'xspeed_should_cache_search', false );
370 }
371
372 /**
373 * The current request's normalized search term, or '' if none. Reads
374 * the raw `s` query param (works on the pre-WP drop-in path too, where
375 * get_search_query() isn't available), trims + lowercases so
376 * "WordPress" and "wordpress" share one entry, and collapses internal
377 * whitespace.
378 */
379 public static function search_term(): string {
380 $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.
381 $raw = trim( $raw );
382 if ( '' === $raw ) {
383 return '';
384 }
385 $raw = preg_replace( '/\s+/', ' ', $raw );
386 return function_exists( 'mb_strtolower' ) ? mb_strtolower( $raw ) : strtolower( $raw );
387 }
388
389 /**
390 * Is this query-string key on the ignored-params allow-list? Supports
391 * trailing-star globs (`utm_*` matches `utm_source`, `utm_medium`,
392 * etc.) so users don't have to enumerate every UTM variant.
393 */
394 private static function query_key_is_ignored( string $key, array $ignored ): bool {
395 return Glob_Matcher::any_match( $ignored, $key );
396 }
397
398 public static function cache_key() {
399 $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : 'default';
400
401 // Cacheable 404s share ONE generic per-host entry — keying them by
402 // URL would let a scanner flood (millions of random paths) bloat
403 // the cache with identical 404 bodies. Both the write and the HIT
404 // lookup run through here, so they agree on the key automatically.
405 if ( self::should_cache_404() ) {
406 return md5( $host . '|404' );
407 }
408
409 $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/';
410 // Strip the query string from the key so /post and /post?utm_*=…
411 // share the same cache entry. should_cache() above already
412 // rejected requests with non-ignored params, so by the time we
413 // build the key the only params left are safe to drop.
414 $uri = (string) strtok( $uri, '?' );
415
416 // Optional device bucket: when mobile_separate is on, mobile and
417 // desktop responses live in different cache files so themes that
418 // serve different HTML by device (AMP, WPtouch, Jetpack mobile)
419 // can't poison each other.
420 $device = '';
421 $opts = Settings_Manager::get( 'cache' );
422 if ( ! empty( $opts['mobile_separate'] ) ) {
423 $device = self::is_mobile_request() ? '|m' : '|d';
424 }
425
426 // Search-results requests fold the normalized term into the key so
427 // /?s=foo and /?s=bar get distinct entries (the query string is
428 // otherwise stripped above). Only added when search caching opted
429 // in, so non-search URLs are unaffected.
430 $search = self::should_cache_search() ? '|s=' . self::search_term() : '';
431
432 // Query-form feeds (/?feed=rss2 vs /?feed=atom) share the same path
433 // once the query is stripped, so fold the feed type into the key to
434 // keep the flavors distinct. Pretty-permalink feeds (/feed/rss/) carry
435 // the type in $uri already and are unaffected. (FBS-82407 #4)
436 $feed = '';
437 if ( function_exists( 'is_feed' ) && is_feed() && function_exists( 'get_query_var' ) ) {
438 $feed_type = (string) get_query_var( 'feed' );
439 if ( '' !== $feed_type ) {
440 $feed = '|feed=' . preg_replace( '/[^a-z0-9]/i', '', $feed_type );
441 }
442 }
443
444 return md5( $host . $uri . $device . $search . $feed );
445 }
446
447 /**
448 * Server-side mobile detection. Prefers WordPress's `wp_is_mobile()`
449 * which uses the same UA tokens as core (so our bucket aligns with
450 * whatever theme-side branching uses). Falls back to a tiny inline
451 * detector if wp_is_mobile() isn't loaded (e.g. the drop-in path).
452 */
453 private static function is_mobile_request(): bool {
454 if ( function_exists( 'wp_is_mobile' ) ) {
455 return (bool) wp_is_mobile();
456 }
457 // Fallback for the rare context where wp_is_mobile() isn't loaded.
458 // Mirrors core's wp_is_mobile() EXACTLY — including the
459 // Sec-CH-UA-Mobile client hint it checks *before* UA tokens — so the
460 // bucket this picks matches whatever the engine's primary path (and
461 // the drop-in's own copy of this logic) would pick for the same
462 // request. Drift here re-introduces the cross-path key mismatch.
463 if ( isset( $_SERVER['HTTP_SEC_CH_UA_MOBILE'] ) ) {
464 return '?1' === $_SERVER['HTTP_SEC_CH_UA_MOBILE'];
465 }
466 $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
467 if ( '' === $ua ) {
468 return false;
469 }
470 return (bool) preg_match( '/(Mobile|Android|Silk\/|Kindle|BlackBerry|Opera Mini|Opera Mobi)/i', $ua );
471 }
472
473 public static function cache_file_for( $key ) {
474 return XSPEED_CACHE_DIR . '/' . $key . '.html';
475 }
476
477 /**
478 * If a precompressed Brotli sibling (`<file>.br`) exists and the client
479 * advertises `Accept-Encoding: br`, emit the Brotli response headers and
480 * return the `.br` path to stream. Returns null to fall through to the
481 * plain file. Keeps the PHP serve path in parity with the web server's
482 * static .br serving (mod_brotli / ngx_brotli rewrite).
483 *
484 * Free has no Brotli logic of its own — this only fires when an add-on
485 * (the Pro Brotli module) actually wrote the .br, so it's a safe no-op
486 * on Free-only installs.
487 *
488 * @param string $file Absolute path to the cached .html file.
489 * @return string|null The .br path to stream, or null to serve $file.
490 */
491 public static function maybe_serve_brotli( string $file ): ?string {
492 if ( headers_sent() ) {
493 return null;
494 }
495 $accept = isset( $_SERVER['HTTP_ACCEPT_ENCODING'] )
496 ? strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT_ENCODING'] ) ) )
497 : '';
498 // Match `br` as a token (comma/space delimited), not a substring, so
499 // a hypothetical "xbr" encoding can't false-positive.
500 if ( ! preg_match( '/(^|[\s,])br([\s,;]|$)/', $accept ) ) {
501 return null;
502 }
503 $br = $file . '.br';
504 if ( ! is_string( $br ) || ! file_exists( $br ) || ! is_readable( $br ) ) {
505 return null;
506 }
507 header( 'Content-Encoding: br' );
508 header( 'Vary: Accept-Encoding', false );
509 // The byte length changes for the compressed body — drop any
510 // Content-Length the caller may have set so the stream isn't
511 // truncated/padded. readfile() lets the SAPI set the right length.
512 header_remove( 'Content-Length' );
513 return $br;
514 }
515
516 /**
517 * Sidecar metadata file for a cache entry. Holds response bits the HIT
518 * path must replay — Content-Type (cached feeds → application/rss+xml,
519 * sitemaps → text/xml) and status (a cached 404 must serve 404, not
520 * 200). JSON, one tiny file per entry, written only when there's
521 * something non-default to replay.
522 */
523 public static function cache_meta_for( $key ) {
524 return XSPEED_CACHE_DIR . '/' . $key . '.meta';
525 }
526
527 /**
528 * Read the .meta sidecar for a cache entry as an array, or [] if none.
529 * Keys: 'content_type' (string), 'status' (int). Used on the HIT path
530 * to replay them before streaming the file.
531 */
532 private static function read_meta( $key ): array {
533 $meta_file = self::cache_meta_for( $key );
534 if ( ! file_exists( $meta_file ) ) {
535 return array();
536 }
537 // 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.
538 $raw = file_get_contents( $meta_file );
539 $data = json_decode( (string) $raw, true );
540 return is_array( $data ) ? $data : array();
541 }
542
543 /**
544 * Conditional-GET support for a cache HIT. Emits Last-Modified + ETag
545 * derived from the cache file's mtime, and — when the request's
546 * If-Modified-Since / If-None-Match still match — sends 304 Not Modified
547 * and returns true (caller should exit without a body). Returns false to
548 * proceed with a normal 200 body. Lets aggregators/browsers skip
549 * re-downloading an unchanged cached response. (FBS-82407 #5)
550 *
551 * @param string $file Absolute path to the cache .html file.
552 * @return bool True when a 304 was sent.
553 */
554 public static function serve_not_modified( string $file ): bool {
555 $mtime = (int) filemtime( $file );
556 if ( $mtime <= 0 ) {
557 return false;
558 }
559 $last_modified = gmdate( 'D, d M Y H:i:s', $mtime ) . ' GMT';
560 $etag = '"' . md5( $file . '|' . $mtime ) . '"';
561 header( 'Last-Modified: ' . $last_modified );
562 header( 'ETag: ' . $etag );
563
564 $ims = isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ) ) : '';
565 $inm = isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_NONE_MATCH'] ) ) ) : '';
566
567 $etag_match = '' !== $inm && false !== strpos( $inm, $etag );
568 $time_match = '' !== $ims && ( strtotime( $ims ) >= $mtime );
569
570 if ( $etag_match || $time_match ) {
571 if ( function_exists( 'http_response_code' ) ) {
572 http_response_code( 304 );
573 }
574 return true;
575 }
576 return false;
577 }
578
579 public static function is_expired( $file ) {
580 // cache_expiry now owned by CacheModule; per-post override
581 // (Phase 3.4) shrinks the TTL further when the editor set one.
582 $opts = Settings_Manager::get( 'cache' );
583 $max_age = (int) $opts['cache_expiry'] * HOUR_IN_SECONDS;
584 $post_override = Cache_Rules::expiry_override_seconds_for_post( Cache_Rules::current_post_id() );
585 if ( null !== $post_override ) {
586 $max_age = $post_override;
587 }
588
589 /**
590 * Filter the max-age (seconds) for the current cache entry.
591 *
592 * Lets an add-on apply a request-type-specific TTL — e.g. the
593 * xspeed-pro feed cache gives feeds a longer expiry than pages,
594 * since aggregators tolerate more staleness. Return seconds.
595 *
596 * @param int $max_age Computed max-age in seconds.
597 */
598 $max_age = (int) apply_filters( 'xspeed_cache_max_age', $max_age );
599
600 return ( time() - filemtime( $file ) ) > $max_age;
601 }
602
603 /**
604 * Accumulator for the full response body across all output-handler phases.
605 *
606 * PHP invokes an ob_start() callback once per flush, and each invocation
607 * only receives the chunk produced *since the previous flush*. If anything
608 * during the render calls `ob_flush()` or `flush()` (some themes, lazy-
609 * load plugins, AMP, etc. do), the final-phase call would otherwise only
610 * see the tail of the page — and we'd cache a truncated response that
611 * gets served repeatedly until purge. We accumulate every chunk here so
612 * the cache file always reflects the complete page.
613 *
614 * @var string
615 */
616 private static $accumulated = '';
617
618 public static function finalize_buffer( $buffer, $phase = PHP_OUTPUT_HANDLER_FINAL ) {
619 self::$accumulated .= $buffer;
620
621 // On non-final phases (mid-request flushes), pass the current chunk
622 // through to the client unmodified and keep collecting. The WP 6.9
623 // filter path always passes the full body in one shot with the
624 // default $phase, so it falls straight through to the final block.
625 $is_final = ( $phase & ( PHP_OUTPUT_HANDLER_FINAL | PHP_OUTPUT_HANDLER_END ) ) !== 0;
626 if ( ! $is_final ) {
627 return $buffer;
628 }
629
630 $full = self::$accumulated;
631 self::$accumulated = '';
632
633 if ( strlen( $full ) < 255 ) {
634 return $buffer;
635 }
636
637 // Status gate. We cache 200 by default. A 404 may be cached too,
638 // but only when an add-on (xspeed-pro 404 cache) opts in for a
639 // genuine is_404() — never a transient 404 (maintenance screen,
640 // DB error, or a 404 emitted outside the main query), which would
641 // otherwise be frozen until purge. Any other status is skipped.
642 $status = function_exists( 'http_response_code' ) ? (int) http_response_code() : 200;
643 if ( 200 !== $status ) {
644 if ( 404 !== $status || ! self::should_cache_404() ) {
645 return $buffer;
646 }
647 }
648
649 // If no mid-request flush happened, $buffer === $full and we can
650 // safely minify the on-wire bytes too. Otherwise earlier chunks have
651 // already been sent unminified, so we minify only what goes to disk —
652 // the first visitor sees unminified HTML, every cache hit after that
653 // is minified.
654 $single_chunk = ( $buffer === $full );
655
656 /**
657 * Filter: xspeed_cache_final_html
658 *
659 * Last chance to transform the fully-rendered page HTML before it is
660 * minified and written to the cache file. Runs on cache MISS only, so
661 * whatever a listener injects here is baked into the cached HTML and
662 * replayed on every subsequent HIT (the drop-in short-circuits before
663 * PHP on a HIT — a wp_head hook would never fire there).
664 *
665 * The Preload module uses this to inject the LCP-image <link rel=preload>
666 * + preconnect hints and add fetchpriority="high" to the hero <img>.
667 * Keep listeners fast and idempotent; this is the on-wire body.
668 *
669 * @param string $full Complete page HTML.
670 */
671 $full = (string) apply_filters( 'xspeed_cache_final_html', $full );
672 if ( $single_chunk ) {
673 $buffer = $full;
674 }
675
676 // minify_html now owned by the Minify module; read through the
677 // module's storage so this stays consistent with the engine that
678 // applies CSS/JS minification.
679 $minify_opts = Settings_Manager::get( 'minify' );
680 if ( ! empty( $minify_opts['minify_html'] ) ) {
681 $full = Minifier::minify_html( $full );
682 if ( $single_chunk ) {
683 $buffer = $full;
684 }
685 }
686
687 if ( ! file_exists( XSPEED_CACHE_DIR ) ) {
688 wp_mkdir_p( XSPEED_CACHE_DIR );
689 self::write_silence( XSPEED_CACHE_DIR );
690 }
691
692 // Path safety: cache_file_for() builds `XSPEED_CACHE_DIR . '/' . $key . '.html'`
693 // where $key comes from md5() — guaranteed to be exactly 32 lowercase
694 // hex chars, so no traversal sequence ('..', '/', null byte, etc.)
695 // can appear. The write is therefore always inside XSPEED_CACHE_DIR.
696 $key = self::cache_key();
697 $file = self::cache_file_for( $key );
698 // 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.
699 file_put_contents( $file, $full, LOCK_EX );
700
701 /**
702 * Fires after the flat hash cache file ({md5}.html) is written.
703 *
704 * Mirror of `xspeed_static_file_written` for the flat cache. The PHP
705 * serve path (Cache::maybe_serve_brotli / the drop-in) serves THIS
706 * file and looks for a `{md5}.html.br` sibling — which only the Pro
707 * Brotli listener on this hook writes. Without it the .br sibling was
708 * never created and the PHP path could never serve Brotli (FBS-83039,
709 * Blocker 2): the static-tree .br (written on xspeed_static_file_written)
710 * lives in a different cache layout the PHP path never reads.
711 *
712 * @param string $file Absolute path to the flat cache file just written.
713 * @param string $full The HTML written to it.
714 */
715 do_action( 'xspeed_flat_file_written', $file, $full );
716
717 // Persist a non-default Content-Type so the HIT path can replay it
718 // (cached feeds must serve application/rss+xml, not text/html).
719 // Only written when the response set a content-type other than
720 // the HTML default — pages don't pay for an extra file.
721 self::write_meta( $key );
722
723 // Static-cache tree (xspeed-static/{host}{path}/index.html). The
724 // .htaccess rewrite block serves this file directly via the web
725 // server, bypassing PHP for ~3-5× lower TTFB vs the drop-in path.
726 // store_static() returns silently on any path/permission issue —
727 // the drop-in remains the safety net.
728 //
729 // Skip it entirely when mobile_separate is on: the rewrite is
730 // disabled in that mode (static_rewrite_allowed()), so a static file
731 // would only be dead weight — and a device-blind one at that.
732 // Skip the static-tree write for responses the web server can't replay
733 // correctly: a non-200 status (a cached 404 would be served as a soft
734 // 200, FBS-82406) or a non-HTML content-type (a cached feed would go
735 // out as text/html, FBS-82407). The web server serves these .html files
736 // directly with no PHP, so there's no .meta replay — keep them on the
737 // drop-in / PHP path instead, which DOES replay status + content-type.
738 if ( self::static_rewrite_allowed() && self::response_is_plain_html() ) {
739 self::store_static( $full );
740 }
741
742 return $buffer;
743 }
744
745 /**
746 * Write the current response to the static-cache tree at
747 * `xspeed-static/{host}{request_uri}/index.html`. The web-server
748 * rewrite block points at this path so cache hits skip PHP
749 * entirely. Caller already minified/finalized $html.
750 *
751 * Path safety: $host is restricted to a `[a-zA-Z0-9.\-]` allowlist;
752 * $uri has its query string stripped, null bytes removed, '..'
753 * sequences collapsed, and after concatenation we verify the
754 * resolved real path stays inside XSPEED_CACHE_STATIC_DIR before
755 * any write. Anything off the happy path returns silently.
756 */
757 private static function store_static( string $html ): void {
758 $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
759 $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
760 $host = preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host );
761 $uri = str_replace( "\0", '', $uri );
762 $uri = (string) strtok( $uri, '?' );
763 if ( '' === $host || '' === $uri ) {
764 return;
765 }
766 // Collapse any traversal sequences before path resolution.
767 $uri = preg_replace( '#/+#', '/', $uri );
768 if ( false !== strpos( $uri, '..' ) ) {
769 return;
770 }
771
772 $base = rtrim( XSPEED_CACHE_STATIC_DIR, '/' );
773 $dir = $base . '/' . $host . rtrim( $uri, '/' );
774 $file = $dir . '/index.html';
775
776 // Resolve the parent against the cache root to be sure the
777 // final path is inside our tree even if the OS does anything
778 // funny with multi-byte sequences.
779 $base_real = realpath( WP_CONTENT_DIR );
780 if ( false === $base_real || 0 !== strpos( $base, $base_real ) ) {
781 return;
782 }
783
784 if ( ! file_exists( $dir ) ) {
785 wp_mkdir_p( $dir );
786 }
787 if ( ! is_dir( $dir ) ) {
788 return;
789 }
790 // 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.
791 $written = file_put_contents( $file, $html, LOCK_EX );
792
793 if ( false !== $written ) {
794 /**
795 * Fires after a static cache file (index.html) is written.
796 *
797 * The extension point for serving pre-compressed siblings:
798 * the xspeed-pro Brotli module writes `index.html.br` next to
799 * the file here so the web server's static rewrite can serve a
800 * Brotli copy to clients that advertise `Accept-Encoding: br`,
801 * falling back to GZIP / the plain file otherwise. No core
802 * behavior depends on a listener being present.
803 *
804 * @param string $file Absolute path to the static cache file just written.
805 * @param string $html The HTML written to it.
806 */
807 do_action( 'xspeed_static_file_written', $file, $html );
808 }
809 }
810
811 /**
812 * Write the .meta sidecar for a cache entry when the response carries
813 * anything the HIT path must replay beyond a plain 200 text/html:
814 * - a non-HTML Content-Type (cached feeds → application/rss+xml,
815 * sitemaps → text/xml, …), and/or
816 * - a non-200 status (a cached 404 must serve 404, not 200).
817 *
818 * Ordinary 200 text/html pages get NO .meta file, so the common path
819 * stays a single write.
820 *
821 * @param string $key Cache key for the current request.
822 */
823 /**
824 * True only for a plain 200 text/html response — the only kind the
825 * web-server static tree can serve correctly (it streams the .html with
826 * no PHP, so it can't replay a 404 status or a feed Content-Type). Used
827 * to gate store_static() so cached 404s / feeds stay on the replay-capable
828 * drop-in / PHP path. (FBS-82406, FBS-82407)
829 */
830 private static function response_is_plain_html(): bool {
831 $status = function_exists( 'http_response_code' ) ? (int) http_response_code() : 200;
832 if ( 200 !== $status && $status > 0 ) {
833 return false;
834 }
835 foreach ( headers_list() as $header ) {
836 if ( 0 === stripos( $header, 'content-type:' ) ) {
837 $ct = trim( substr( $header, strlen( 'content-type:' ) ) );
838 if ( '' !== $ct && false === stripos( $ct, 'text/html' ) ) {
839 return false;
840 }
841 }
842 }
843 return true;
844 }
845
846 private static function write_meta( string $key ): void {
847 $content_type = '';
848 foreach ( headers_list() as $header ) {
849 if ( 0 === stripos( $header, 'content-type:' ) ) {
850 $content_type = trim( substr( $header, strlen( 'content-type:' ) ) );
851 }
852 }
853 $status = function_exists( 'http_response_code' ) ? (int) http_response_code() : 200;
854
855 $meta = array();
856 $is_default_type = ( '' === $content_type || false !== stripos( $content_type, 'text/html' ) );
857 if ( ! $is_default_type ) {
858 $meta['content_type'] = $content_type;
859 }
860 if ( 200 !== $status && $status > 0 ) {
861 $meta['status'] = $status;
862 }
863
864 // Per-content TTL (seconds). The drop-in and static fast paths can't
865 // call is_expired() / the xspeed_cache_max_age filter (they run before
866 // WP), so persist the resolved max-age here whenever it differs from
867 // the plain page TTL — e.g. the Pro feed cache's 12h vs the 24h page
868 // default. The fast paths read this to expire correctly. (FBS-82407)
869 $opts = Settings_Manager::get( 'cache' );
870 $default_ttl = (int) $opts['cache_expiry'] * HOUR_IN_SECONDS;
871 $ttl = (int) apply_filters( 'xspeed_cache_max_age', $default_ttl );
872 if ( $ttl > 0 && $ttl !== $default_ttl ) {
873 $meta['ttl'] = $ttl;
874 }
875
876 // Nothing to replay → no sidecar.
877 if ( empty( $meta ) ) {
878 return;
879 }
880
881 $payload = wp_json_encode( $meta );
882 if ( false === $payload ) {
883 return;
884 }
885 // 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.
886 file_put_contents( self::cache_meta_for( $key ), $payload, LOCK_EX );
887 }
888
889 /**
890 * @param string $cause Free-form human reason. Recorded in the
891 * Activity log to give users context (e.g.
892 * 'post saved', 'settings change', 'manual',
893 * 'theme switch').
894 */
895 /**
896 * Purge the cache entries for ONE URL — every variant of it: the
897 * flat-hash entry (+ .meta / .html.br siblings), both device buckets
898 * (mobile_separate keys them separately), both trailing-slash forms,
899 * and the static-tree index.html (+ .br) the server rewrite serves.
900 * The rest of the cache is untouched — this is the surgical
901 * alternative to purge_all for "I just edited this one page".
902 *
903 * @param string $url Absolute URL, or site-relative path ("/about/").
904 * @param string $cause Who asked, for the purge log. See purge_all().
905 * @return int Number of cache files removed.
906 */
907 public static function purge_url( string $url, string $cause = 'manual' ): int {
908 $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.
909 if ( ! is_array( $parts ) ) {
910 return 0;
911 }
912 $host = isset( $parts['host'] ) ? strtolower( (string) $parts['host'] ) : '';
913 if ( '' === $host && function_exists( 'home_url' ) ) {
914 $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.
915 $host = is_array( $home ) && isset( $home['host'] ) ? strtolower( (string) $home['host'] ) : '';
916 }
917 if ( '' === $host ) {
918 return 0;
919 }
920 $path = isset( $parts['path'] ) ? (string) $parts['path'] : '/';
921 $path = '/' . ltrim( $path, '/' );
922 if ( false !== strpos( $path, '..' ) ) {
923 return 0;
924 }
925
926 // The cache key preserves REQUEST_URI's trailing-slash form, so
927 // purge both. Root stays a single '/'.
928 $forms = array( $path );
929 if ( '/' !== $path ) {
930 $forms[] = rtrim( $path, '/' );
931 $forms[] = rtrim( $path, '/' ) . '/';
932 }
933 $forms = array_unique( $forms );
934
935 $count = 0;
936 foreach ( $forms as $uri ) {
937 // '' = mobile_separate off; '|m' / '|d' = the device buckets.
938 foreach ( array( '', '|m', '|d' ) as $device ) {
939 $key = md5( $host . $uri . $device );
940 $file = self::cache_file_for( $key );
941 if ( is_file( $file ) ) {
942 wp_delete_file( $file );
943 ++$count;
944 }
945 foreach ( array( XSPEED_CACHE_DIR . '/' . $key . '.meta', $file . '.br' ) as $sidecar ) {
946 if ( is_file( $sidecar ) ) {
947 wp_delete_file( $sidecar );
948 }
949 }
950 }
951 }
952
953 // Static tree (served directly by the nginx/.htaccess rewrite).
954 if ( defined( 'XSPEED_CACHE_STATIC_DIR' ) ) {
955 $dir = rtrim( XSPEED_CACHE_STATIC_DIR, '/' ) . '/' . $host . ( '/' === $path ? '' : rtrim( $path, '/' ) );
956 $file = $dir . '/index.html';
957 if ( is_file( $file ) ) {
958 wp_delete_file( $file );
959 ++$count;
960 }
961 if ( is_file( $file . '.br' ) ) {
962 wp_delete_file( $file . '.br' );
963 }
964 }
965
966 if ( $count > 0 ) {
967 Cache_Inventory::invalidate();
968 Activity_Log::record(
969 'cache_purge_url',
970 sprintf(
971 /* translators: 1: cause of the purge, 2: URL or path, 3: number of files removed. */
972 __( 'Purged one URL (%1$s) — %2$s, %3$d file(s) removed', 'xspeed' ),
973 $cause,
974 $host . $path,
975 $count
976 ),
977 Activity_Log::INFO
978 );
979 }
980
981 return $count;
982 }
983
984 public static function purge_all( string $cause = 'manual' ) {
985 $count = 0;
986 if ( is_dir( XSPEED_CACHE_DIR ) ) {
987 $files = glob( XSPEED_CACHE_DIR . '/*.html' );
988 if ( $files ) {
989 $count = count( $files );
990 foreach ( $files as $f ) {
991 wp_delete_file( $f );
992 }
993 }
994 // Remove the .meta sidecars (content-type for feeds/sitemaps)
995 // alongside their .html entries. Not counted — they're not
996 // cache "pages", just per-entry metadata.
997 $meta = glob( XSPEED_CACHE_DIR . '/*.meta' );
998 if ( $meta ) {
999 foreach ( $meta as $m ) {
1000 wp_delete_file( $m );
1001 }
1002 }
1003 // Remove precompressed siblings (e.g. <key>.html.br from the Pro
1004 // Brotli module). Not counted — same as .meta. Without this a
1005 // purge leaves stale .br bodies behind: disk bloat, and a
1006 // staleness window if precompression is later disabled.
1007 $br = glob( XSPEED_CACHE_DIR . '/*.br' );
1008 if ( $br ) {
1009 foreach ( $br as $b ) {
1010 wp_delete_file( $b );
1011 }
1012 }
1013 }
1014 // Static-cache tree purge — recursive because the layout is
1015 // xspeed-static/{host}/{path}/index.html, so a flat glob can't
1016 // reach everything.
1017 if ( is_dir( XSPEED_CACHE_STATIC_DIR ) ) {
1018 $count += self::rmtree_html( XSPEED_CACHE_STATIC_DIR );
1019 }
1020 // REST response cache (cache/xspeed/rest/*.json) — same purge
1021 // triggers (publish, settings change) invalidate it too.
1022 $count += Rest_Cache::purge();
1023
1024 // Minified + combined CSS/JS (cache/xspeed/min/ and min/combined/).
1025 // purge_all is a full filesystem sweep and must clear these too, even
1026 // when the Minify module is currently disabled — orphaned min/ files
1027 // from a feature the user later turned off must still be removed, and
1028 // a stale combined-<hash>.css that the regenerated page no longer
1029 // references otherwise 404s and breaks the frontend. (FBS-83114/83116)
1030 if ( class_exists( '\\XSpeed\\Minifier' ) ) {
1031 Minifier::purge_minified();
1032 }
1033
1034 // Persistent object cache (Redis / Memcached). Flush regardless of
1035 // whether the Object Cache module is currently enabled — a drop-in
1036 // installed earlier keeps serving until flushed.
1037 if ( function_exists( 'wp_cache_flush' ) ) {
1038 wp_cache_flush();
1039 }
1040
1041 self::update_stats( array( 'last_purge' => time() ) );
1042
1043 // Fire AFTER the local sweep so module listeners (Critical CSS,
1044 // Unused CSS, Cloudflare edge purge) run — this action had three
1045 // registered listeners but was never emitted. Treat it as additive
1046 // (CDN / edge invalidation), not the mechanism for clearing local
1047 // files. (FBS-83114)
1048 do_action( 'xspeed_after_purge_all', $cause );
1049
1050 // The list behind the "Cached pages" card is memoized for a minute;
1051 // a purge has to drop it or the drill-down shows pages that no
1052 // longer exist.
1053 Cache_Inventory::invalidate();
1054
1055 // Trigger of WP_CLI / hook / admin-bar purges all hit the same
1056 // path. Record once with the supplied cause so the dashboard
1057 // activity feed reads naturally.
1058 Activity_Log::record(
1059 'cache_purged',
1060 sprintf( 'Cache purged (%s) — %d file%s removed', $cause, $count, 1 === $count ? '' : 's' ),
1061 Activity_Log::INFO
1062 );
1063
1064 return $count;
1065 }
1066
1067 /**
1068 * The per-type purge menu, LiteSpeed-style. Each entry is a cache type
1069 * the user can purge individually from the admin-bar dropdown. `visible`
1070 * controls whether the item shows (active + licensed module only) — it
1071 * NEVER limits Purge All, which always sweeps everything on disk.
1072 *
1073 * Pro registers its own types (Critical CSS, Unused CSS, …) by filtering
1074 * `xspeed_purge_types`, so Free degrades gracefully when Pro is absent.
1075 *
1076 * @return array<string,array{label:string,visible:bool}>
1077 */
1078 public static function purge_types(): array {
1079 $minify_on = false;
1080 if ( class_exists( '\\XSpeed\\Settings_Manager' ) ) {
1081 $min = Settings_Manager::get( 'minify' );
1082 $minify_on = ! empty( $min['minify_css'] ) || ! empty( $min['minify_js'] ) || ! empty( $min['combine_css'] ) || ! empty( $min['combine_js'] );
1083 }
1084 // Object cache is "active" when an external object-cache drop-in is in
1085 // use — the canonical WP signal, independent of our settings option.
1086 $oc_on = function_exists( 'wp_using_ext_object_cache' ) && wp_using_ext_object_cache();
1087
1088 $types = array(
1089 'all' => array(
1090 'label' => __( 'Purge All', 'xspeed' ),
1091 'visible' => true,
1092 ),
1093 'page' => array(
1094 'label' => __( 'Purge Page / Static Cache', 'xspeed' ),
1095 'visible' => true,
1096 ),
1097 'assets' => array(
1098 'label' => __( 'Purge CSS / JS Cache', 'xspeed' ),
1099 'visible' => $minify_on,
1100 ),
1101 'object' => array(
1102 'label' => __( 'Purge Object Cache', 'xspeed' ),
1103 'visible' => $oc_on,
1104 ),
1105 'rest' => array(
1106 'label' => __( 'Purge REST Cache', 'xspeed' ),
1107 'visible' => true,
1108 ),
1109 );
1110
1111 /**
1112 * Filter the admin-bar purge-type menu. Pro modules add their own
1113 * (Critical CSS, Unused CSS, CDN). Adding a type here only adds a
1114 * MENU item — purge_type() must know how to handle the same slug.
1115 *
1116 * @param array $types Map of slug => [label, visible].
1117 */
1118 return (array) apply_filters( 'xspeed_purge_types', $types );
1119 }
1120
1121 /**
1122 * Purge a single cache type by slug. 'all' delegates to purge_all();
1123 * every other slug clears just its own artifacts. Unknown slugs (e.g. a
1124 * Pro type) fan out via the `xspeed_purge_type_{slug}` action so the
1125 * owning module can handle it. Returns the number of items removed where
1126 * countable.
1127 *
1128 * @param string $type Cache type slug.
1129 * @param string $cause Who asked. Threaded through so the purge log can
1130 * tell an AI assistant's purge apart from a click —
1131 * "the cache cleared four times today" is only
1132 * actionable once you know what kept clearing it.
1133 */
1134 public static function purge_type( string $type, string $cause = 'manual' ): int {
1135 switch ( $type ) {
1136 case 'all':
1137 return self::purge_all( $cause );
1138
1139 case 'page':
1140 $count = 0;
1141 if ( is_dir( XSPEED_CACHE_DIR ) ) {
1142 foreach ( (array) glob( XSPEED_CACHE_DIR . '/*.html' ) as $f ) {
1143 wp_delete_file( $f );
1144 ++$count;
1145 }
1146 foreach ( (array) glob( XSPEED_CACHE_DIR . '/*.meta' ) as $m ) {
1147 wp_delete_file( $m );
1148 }
1149 foreach ( (array) glob( XSPEED_CACHE_DIR . '/*.br' ) as $b ) {
1150 wp_delete_file( $b );
1151 }
1152 }
1153 if ( is_dir( XSPEED_CACHE_STATIC_DIR ) ) {
1154 $count += self::rmtree_html( XSPEED_CACHE_STATIC_DIR );
1155 }
1156 self::update_stats( array( 'last_purge' => time() ) );
1157 Cache_Inventory::invalidate();
1158 self::record_partial_purge( 'page', $cause, $count );
1159 return $count;
1160
1161 case 'assets':
1162 if ( class_exists( '\\XSpeed\\Minifier' ) ) {
1163 Minifier::purge_minified();
1164 }
1165 self::record_partial_purge( 'assets', $cause, null );
1166 return 0;
1167
1168 case 'object':
1169 if ( function_exists( 'wp_cache_flush' ) ) {
1170 wp_cache_flush();
1171 }
1172 self::record_partial_purge( 'object cache', $cause, null );
1173 return 0;
1174
1175 case 'rest':
1176 $count = Rest_Cache::purge();
1177 self::record_partial_purge( 'REST responses', $cause, $count );
1178 return $count;
1179
1180 default:
1181 // Pro / third-party type — let the owning module handle it.
1182 do_action( 'xspeed_purge_type_' . $type );
1183 self::record_partial_purge( $type, $cause, null );
1184 return 0;
1185 }
1186 }
1187
1188 /**
1189 * Log a partial purge so the drill-down behind "Last purge" shows every
1190 * clear, not only the full ones. Without this a site whose object cache
1191 * is flushed on a schedule looks, from the log, like nothing happens.
1192 *
1193 * @param string $what Human label for the slice purged.
1194 * @param string $cause Who asked.
1195 * @param int|null $count Items removed, when countable.
1196 */
1197 private static function record_partial_purge( string $what, string $cause, ?int $count ): void {
1198 $message = null === $count
1199 ? sprintf(
1200 /* translators: 1: what was purged, 2: cause of the purge. */
1201 __( 'Purged %1$s (%2$s)', 'xspeed' ),
1202 $what,
1203 $cause
1204 )
1205 : sprintf(
1206 /* translators: 1: what was purged, 2: cause of the purge, 3: number of files removed. */
1207 __( 'Purged %1$s (%2$s) — %3$d file(s) removed', 'xspeed' ),
1208 $what,
1209 $cause,
1210 $count
1211 );
1212
1213 Activity_Log::record( 'cache_purged', $message, Activity_Log::INFO );
1214 }
1215
1216 /**
1217 * Recursively delete every `index.html` (and its precompressed
1218 * `index.html.br` sibling, if the Pro Brotli module wrote one) plus
1219 * empty directories inside the static-cache tree. Used by purge_all().
1220 * Returns the number of .html files removed so purge stats stay accurate
1221 * across the flat + static caches — .br siblings are not counted
1222 * (they're encodings of a page, not pages).
1223 */
1224 private static function rmtree_html( string $dir ): int {
1225 if ( ! is_dir( $dir ) ) {
1226 return 0;
1227 }
1228 $removed = 0;
1229 // SCANDIR_SORT_NONE skips alphabetic sort — we're going to walk
1230 // the whole tree regardless of order.
1231 $entries = @scandir( $dir, SCANDIR_SORT_NONE ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
1232 if ( false === $entries ) {
1233 return 0;
1234 }
1235 foreach ( $entries as $entry ) {
1236 if ( '.' === $entry || '..' === $entry ) {
1237 continue;
1238 }
1239 $path = $dir . '/' . $entry;
1240 if ( is_dir( $path ) ) {
1241 $removed += self::rmtree_html( $path );
1242 // Best-effort empty-dir cleanup; ignore failures (a
1243 // foreign file inside would block rmdir, which is fine).
1244 // 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.
1245 @rmdir( $path );
1246 continue;
1247 }
1248 if ( substr( $entry, -5 ) === '.html' ) {
1249 wp_delete_file( $path );
1250 ++$removed;
1251 } elseif ( substr( $entry, -3 ) === '.br' ) {
1252 // Precompressed sibling (index.html.br). Remove it too so a
1253 // purge doesn't orphan stale Brotli bodies. Not counted.
1254 wp_delete_file( $path );
1255 }
1256 }
1257 return $removed;
1258 }
1259
1260 /**
1261 * Drop a "silence is golden" index.php into a directory so apaches/nginx
1262 * with directory listing enabled don't expose cache contents.
1263 */
1264 public static function write_silence( $dir ) {
1265 $file = trailingslashit( $dir ) . 'index.php';
1266 if ( ! file_exists( $file ) ) {
1267 // 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.
1268 file_put_contents( $file, "<?php\n// Silence is golden.\n" );
1269 }
1270 }
1271
1272 /**
1273 * Persist stats with autoload disabled — stats are only read in admin
1274 * contexts, so there is no reason to inflate every frontend request's
1275 * `wp_load_alloptions()` payload.
1276 */
1277 private static function update_stats( array $stats ) {
1278 if ( false === get_option( 'xspeed_stats' ) ) {
1279 add_option( 'xspeed_stats', $stats, '', 'no' );
1280 return;
1281 }
1282 update_option( 'xspeed_stats', $stats );
1283 }
1284
1285 public static function get_stats() {
1286 $count = 0;
1287 $size = 0;
1288 if ( is_dir( XSPEED_CACHE_DIR ) ) {
1289 $files = glob( XSPEED_CACHE_DIR . '/*.html' );
1290 if ( $files ) {
1291 $count = count( $files );
1292 foreach ( $files as $f ) {
1293 $size += filesize( $f );
1294 }
1295 }
1296 }
1297 // Drain the HIT-log file BEFORE reading totals. Two serve paths that
1298 // bypass the normal in-PHP record_hit() append one line per HIT here:
1299 // the nginx server-level rewrite (see nginx_snippet(), never reaches
1300 // PHP) and the advanced-cache.php drop-in (runs pre-WordPress, can't
1301 // reach Hit_Counter). Without this drain both look like a 0% hit-ratio
1302 // on a perfectly working cache.
1303 Hit_Counter::collect_nginx_log_hits();
1304
1305 // Apache/LiteSpeed static-rewrite HITs are served straight from disk
1306 // by .htaccess and never reach PHP either — but there's no .htaccess
1307 // equivalent of nginx's access_log directive, so we count them by
1308 // scanning the web server's own access log incrementally. No-op when
1309 // the log isn't readable (managed hosts) — see the method docblock.
1310 Hit_Counter::collect_server_log_hits();
1311
1312 $stats = get_option( 'xspeed_stats', array() );
1313 $totals = Hit_Counter::totals_24h();
1314 return array(
1315 'cached_pages' => $count,
1316 'cache_size' => $size,
1317 'last_purge' => isset( $stats['last_purge'] ) ? (int) $stats['last_purge'] : 0,
1318 // Rolling 24h cache performance — sourced from Hit_Counter's
1319 // hourly buckets. The frontend uses hit_ratio to drive the
1320 // CacheHero stat grid + the Health module's panel.
1321 'hits_24h' => $totals['hits'],
1322 'misses_24h' => $totals['misses'],
1323 'hit_ratio' => $totals['ratio'],
1324 );
1325 }
1326
1327 /**
1328 * Apply the user's enable/disable choice. Called from the REST toggle
1329 * endpoint, which is gated by current_user_can( 'manage_options' ) and
1330 * a verified REST nonce.
1331 *
1332 * This is the only path that ENABLES caching — a drop-in is never
1333 * created for a user who hasn't opted in, which is the guideline that
1334 * matters (a plugin must not install drop-ins or edit wp-config.php
1335 * on a fresh activation). RESTORING the drop-in for a site that
1336 * already has cache_enabled = true is a different act and is handled
1337 * by restore_dropin_if_enabled() on activation and auto_heal() at
1338 * runtime; without it every plugin update silently un-caches the site.
1339 *
1340 * @param bool $enable User's choice.
1341 * @return array{
1342 * enabled: bool,
1343 * dropin_installed: bool,
1344 * wp_cache_constant: bool,
1345 * wp_config_writable: bool,
1346 * manual_snippet: ?string
1347 * }
1348 */
1349 public static function toggle( $enable ) {
1350 $enable = (bool) $enable;
1351
1352 if ( $enable ) {
1353 $dropin_ok = self::install_dropin();
1354 $wp_config_ok = self::set_wp_cache_constant( true );
1355 $rewrite_ok = self::install_rewrite();
1356 self::ensure_hits_log_file();
1357 self::sync_mobile_flag();
1358 $snippet = $wp_config_ok ? null : "define( 'WP_CACHE', true );";
1359
1360 Activity_Log::record(
1361 'cache_enabled_event',
1362 $wp_config_ok
1363 ? 'Cache enabled. Drop-in installed, WP_CACHE constant set.'
1364 : 'Cache enabled. Drop-in installed; wp-config.php not writable — add the WP_CACHE snippet manually.',
1365 $wp_config_ok ? Activity_Log::SUCCESS : Activity_Log::WARN
1366 );
1367
1368 return array(
1369 'enabled' => true,
1370 'dropin_installed' => (bool) $dropin_ok,
1371 'wp_cache_constant' => (bool) $wp_config_ok,
1372 'rewrite_installed' => (bool) $rewrite_ok,
1373 'wp_config_writable' => self::wp_config_writable(),
1374 'manual_snippet' => $snippet,
1375 'nginx_snippet' => self::nginx_snippet(),
1376 // Unified server-block snippet aggregating every enabled
1377 // module's directives — the same value the dashboard and
1378 // Health insight render. The wizard shows this so all three
1379 // surfaces stay in lockstep. Null on non-nginx hosts.
1380 'nginx_server_block' => self::full_nginx_server_block(),
1381 );
1382 }
1383
1384 self::remove_dropin();
1385 self::set_wp_cache_constant( false );
1386 self::remove_rewrite();
1387 // Drop the device-bucket marker too — with the drop-in gone there's
1388 // nothing left to read it, and leaving it behind would dirty a fresh
1389 // re-enable (and leaks across test runs).
1390 self::sync_mobile_flag( false );
1391
1392 Activity_Log::record(
1393 'cache_disabled_event',
1394 'Cache disabled. Drop-in removed.',
1395 Activity_Log::INFO
1396 );
1397
1398 return array(
1399 'enabled' => false,
1400 'dropin_installed' => false,
1401 'wp_cache_constant' => false,
1402 'rewrite_installed' => false,
1403 'wp_config_writable' => self::wp_config_writable(),
1404 'manual_snippet' => null,
1405 'nginx_snippet' => self::nginx_snippet(),
1406 'nginx_server_block' => self::full_nginx_server_block(),
1407 );
1408 }
1409
1410 /**
1411 * Check wp-config.php writability via WP_Filesystem. Plugin Check flags
1412 * direct is_writable() under WordPress.WP.AlternativeFunctions.
1413 */
1414 private static function wp_config_writable() {
1415 global $wp_filesystem;
1416 if ( ! function_exists( 'WP_Filesystem' ) ) {
1417 require_once ABSPATH . 'wp-admin/includes/file.php';
1418 }
1419 WP_Filesystem();
1420
1421 return $wp_filesystem ? (bool) $wp_filesystem->is_writable( ABSPATH . 'wp-config.php' ) : false;
1422 }
1423
1424 /**
1425 * Nginx server-block snippet mirroring the Apache rewrite block.
1426 * We never auto-write nginx config — it sits outside the WordPress
1427 * root and is owned by the server admin — but the dashboard
1428 * surfaces this snippet when nginx is detected so the admin can
1429 * paste it once and unlock the same PHP-bypass speedup we get on
1430 * Apache / LiteSpeed via .htaccess.
1431 *
1432 * Returns null when the server isn't nginx (no point showing it).
1433 */
1434 /**
1435 * Create wp-content/cache/xspeed/hits.log as an empty file so the
1436 * server-level rewrite's `access_log` directive has somewhere to
1437 * write on first request. Idempotent — touches an existing file
1438 * without disturbing accumulated lines. Called from Cache::toggle()
1439 * on enable and from auto_heal() when the file is missing.
1440 *
1441 * Permissions matter here. The file is created by PHP-FPM (often uid
1442 * www-data), but the nginx process that appends HIT lines may run as a
1443 * DIFFERENT uid — on multi-container hosts (e.g. xclude/Kinsta: nginx in
1444 * its own container as uid `nginx`, PHP-FPM in another as `www-data`)
1445 * they don't share a user at all. A default-umask 0644 file is then
1446 * unwritable by nginx, the access_log write silently fails, and the
1447 * dashboard shows a 0% hit ratio even though static HITs are serving.
1448 * So we widen the dir to 0777 and the file to 0666 — group/other write —
1449 * so whatever uid nginx runs as can append. (The file holds only HIT
1450 * request lines, no secrets.)
1451 */
1452 /**
1453 * Directory holding the nginx hit log. Lives under uploads/, NOT the
1454 * cache dir — uninstall.php and a cache purge both delete the cache
1455 * dir, which would orphan the pasted nginx `access_log` directive's
1456 * parent directory and make `nginx -t` fail [emerg], taking down every
1457 * vhost on the host (FBS-82478). uploads/ always exists, isn't a
1458 * plugin-managed cache dir, and is never deleted on uninstall — so the
1459 * directive's target dir survives both, and nginx (which creates a
1460 * missing log FILE but not a missing DIR) can always open it.
1461 *
1462 * Falls back to the cache dir only if uploads is somehow unavailable.
1463 */
1464 public static function hits_log_dir(): string {
1465 if ( function_exists( 'wp_upload_dir' ) ) {
1466 $uploads = wp_upload_dir( null, false );
1467 if ( is_array( $uploads ) && empty( $uploads['error'] ) && ! empty( $uploads['basedir'] ) ) {
1468 return rtrim( (string) $uploads['basedir'], '/' ) . '/xspeed';
1469 }
1470 }
1471 return XSPEED_CACHE_DIR;
1472 }
1473
1474 /** Absolute path to the nginx hit log file. */
1475 public static function hits_log_path(): string {
1476 return self::hits_log_dir() . '/hits.log';
1477 }
1478
1479 /**
1480 * Sync the drop-in's mobile-bucket flag file with the `mobile_separate`
1481 * setting. The drop-in (advanced-cache.php) runs before WordPress loads,
1482 * so it can't read the option — instead it checks for a zero-byte
1483 * `.mobile-separate` marker next to the cache files. When the setting is
1484 * on we touch the marker; when off we remove it. The drop-in's cache_key
1485 * computation keys off the marker's presence so its '|m'/'|d' device
1486 * bucket stays in lockstep with Cache::cache_key().
1487 *
1488 * Without this, turning on mobile_separate made Cache::store() write keys
1489 * with a '|d'/'|m' suffix the drop-in never reproduced — so the drop-in's
1490 * file_exists() always missed, every HIT fell through to a full WP boot,
1491 * and the fast pre-WP path was silently dead.
1492 *
1493 * @param bool|null $enabled Force a state; null reads the current setting.
1494 */
1495 public static function sync_mobile_flag( $enabled = null ): void {
1496 if ( null === $enabled ) {
1497 $opts = Settings_Manager::get( 'cache' );
1498 $enabled = ! empty( $opts['mobile_separate'] );
1499 }
1500 $dir = XSPEED_CACHE_DIR;
1501 $flag = $dir . '/.mobile-separate';
1502 if ( $enabled ) {
1503 if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
1504 return;
1505 }
1506 if ( ! file_exists( $flag ) ) {
1507 // 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.
1508 @touch( $flag );
1509 }
1510 return;
1511 }
1512 if ( file_exists( $flag ) ) {
1513 // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal.
1514 @unlink( $flag );
1515 }
1516 }
1517
1518 /**
1519 * Write / remove the `.maintenance-active` sentinel next to the cache
1520 * files. The pre-WP drop-in checks for this marker and bails when present,
1521 * so a page cached while the site was live is NOT served during
1522 * maintenance / coming-soon mode — WordPress loads and renders the
1523 * maintenance screen instead. The Pro Maintenance-Cache module drives this
1524 * on the maintenance on/off transition. (FBS-82409 B1)
1525 *
1526 * @param bool $active True to arm the sentinel (entering maintenance),
1527 * false to clear it (site recovered).
1528 */
1529 public static function sync_maintenance_flag( bool $active ): void {
1530 $dir = XSPEED_CACHE_DIR;
1531 $flag = $dir . '/.maintenance-active';
1532 if ( $active ) {
1533 if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
1534 return;
1535 }
1536 if ( ! file_exists( $flag ) ) {
1537 // 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.
1538 @touch( $flag );
1539 }
1540 return;
1541 }
1542 if ( file_exists( $flag ) ) {
1543 // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal.
1544 @unlink( $flag );
1545 }
1546 }
1547
1548 /**
1549 * Reconcile every mobile_separate-dependent artifact to the current
1550 * setting. Called on boot and whenever the cache settings are saved, so
1551 * flipping mobile_separate at runtime can't leave the install in a
1552 * half-converted state.
1553 *
1554 * Three things must agree with the setting:
1555 * 1. the drop-in's `.mobile-separate` flag (sync_mobile_flag()),
1556 * 2. the device-blind server rewrite — present only when OFF
1557 * (static_rewrite_allowed()),
1558 * 3. the now-stale static-cache tree + page cache, which were keyed
1559 * under the old scheme and would serve wrong-device HTML.
1560 *
1561 * No-ops when the cache is disabled — there's nothing installed to
1562 * reconcile, and toggle() handles install/teardown itself.
1563 */
1564 public static function reconcile_mobile_separate(): void {
1565 self::sync_mobile_flag();
1566
1567 // The rewrite/static reconciliation below needs the plugin's path
1568 // constants. They're absent in early-boot / unit-test contexts where
1569 // only the drop-in flag matters — bail to the flag-only behavior then.
1570 if ( ! defined( 'XSPEED_CACHE_STATIC_DIR' ) ) {
1571 return;
1572 }
1573
1574 // Only touch the rewrite + caches when caching is actually on.
1575 $opts = get_option( 'xspeed_options', array() );
1576 if ( empty( $opts['cache_enabled'] ) ) {
1577 return;
1578 }
1579
1580 $rewrite_present = self::rewrite_installed();
1581 $rewrite_wanted = self::static_rewrite_allowed();
1582
1583 if ( $rewrite_present === $rewrite_wanted ) {
1584 // Already consistent — nothing flipped, leave caches intact so a
1585 // plain settings save (e.g. expiry change) doesn't blow the cache.
1586 return;
1587 }
1588
1589 // The setting flipped. Bring the rewrite into line and purge the
1590 // now-misbucketed cache so the next request re-primes under the new
1591 // device scheme.
1592 if ( $rewrite_wanted ) {
1593 self::install_rewrite();
1594 } else {
1595 self::remove_rewrite();
1596 }
1597 self::purge_all( 'mobile_separate changed' );
1598 }
1599
1600 /**
1601 * Whether the server-level static-rewrite fast path may be used.
1602 *
1603 * The rewrite serves `{host}{path}/index.html` straight from the web
1604 * server, keyed only by host + path — it has no way to run our PHP
1605 * device detection, so it can't tell mobile from desktop. When
1606 * `mobile_separate` is on, a single static file would be shared across
1607 * devices and whoever primed it wins (mobile visitors could get desktop
1608 * HTML, or vice-versa). Rather than duplicate a wp_is_mobile()-equivalent
1609 * UA matcher into .htaccess AND the nginx snippet (three copies that
1610 * would inevitably drift), we simply DON'T engage the static rewrite when
1611 * mobile_separate is on. Requests then fall through to the PHP drop-in,
1612 * which buckets correctly — a small TTFB cost (~85ms vs ~30ms) paid only
1613 * on mobile-separate sites, in exchange for guaranteed correctness.
1614 *
1615 * LiteSpeed exclusion (2026-06-16): on LiteSpeed — OpenLiteSpeed in
1616 * particular — `.htaccess` CAN run our RewriteRule to serve the static
1617 * file, but its `.htaccess` engine ignores `mod_headers`, so we cannot
1618 * stamp the served response with `X-XSpeed-Cache: HIT`, AND there is no
1619 * `.htaccess` equivalent of nginx's per-location `access_log` to record
1620 * the hit. The result was a cache that worked but was invisible: no HIT
1621 * header and a hit-ratio frozen near 0%. Every OTHER server gives the
1622 * user a visible HIT header + a counted hit (nginx via add_header +
1623 * access_log in its snippet; Apache via the `<IfModule mod_headers.c>`
1624 * block in rewrite_block_lines(), WHEN that module is loaded — when it is
1625 * not, Apache takes this same drop-in fallback). To keep LiteSpeed
1626 * CONSISTENT with the rest, we route its hits
1627 * through the PHP drop-in instead — the drop-in emits
1628 * `X-XSpeed-Cache: HIT (php)` and calls Hit_Counter inline, exactly the
1629 * observable behavior the other servers get. The cost is the drop-in's
1630 * ~30ms TTFB vs the static path's ~10ms, paid only on LiteSpeed; in
1631 * exchange the dashboard hit-ratio and the response header finally tell
1632 * the truth there. (Apache keeps the static fast path — it honors the
1633 * header.) See maybe_emit_lscache_headers() for the paired LSCache
1634 * stand-down that stops LiteSpeed's own module from shadowing the
1635 * drop-in.
1636 */
1637 public static function static_rewrite_allowed(): bool {
1638 // LiteSpeed: drop-in serves hits (visible + counted) — see docblock.
1639 if ( Server::LITESPEED === Server::type() ) {
1640 return false;
1641 }
1642 // Apache without mod_headers is in EXACTLY the position LiteSpeed
1643 // is in above: it can run the RewriteRule and serve the static
1644 // file, but it cannot stamp `X-XSpeed-Cache` on the response, so
1645 // the hit is invisible to the user and uncountable by
1646 // Hit_Counter. The docblock above used to assert Apache "honors
1647 // mod_headers" and left it on the fast path unconditionally —
1648 // true only when the module is actually loaded. Fall back to the
1649 // drop-in when it isn't, trading ~10ms of TTFB for a hit that
1650 // shows up in the header and the ratio. (Field report: hit ratio
1651 // pinned at 0% on a working Apache cache.)
1652 if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) {
1653 return false;
1654 }
1655 $opts = Settings_Manager::get( 'cache' );
1656 return empty( $opts['mobile_separate'] );
1657 }
1658
1659 /**
1660 * Why the device-blind static rewrite is NOT installed, when it isn't.
1661 * Returns 'mobile_separate' when Separate Mobile Cache is the blocker
1662 * (the static file is one-per-URL, so it can't coexist with per-device
1663 * buckets), 'no_mod_headers' when Apache can't stamp the HIT header,
1664 * '' otherwise. Lets the dashboard explain the slow path instead of
1665 * silently falling back to PHP serving. (FBS-83145)
1666 *
1667 * Every refusal in static_rewrite_allowed() that is NOT self-explanatory
1668 * must have a branch here. Otherwise the Health card falls through to
1669 * "Block missing — toggle Enable Cache off and on to reinstall it",
1670 * advice that cannot work: the same condition that suppressed the write
1671 * suppresses the reinstall, and auto_heal() strips the block again on
1672 * the next admin page load. (Field report: Apache host with mod_headers
1673 * unloaded sat on the slow path with no way to find out why.)
1674 */
1675 public static function static_rewrite_block_reason(): string {
1676 if ( Server::LITESPEED === Server::type() ) {
1677 return ''; // Intended on LiteSpeed — not a "block".
1678 }
1679 if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) {
1680 return 'no_mod_headers';
1681 }
1682 $opts = Settings_Manager::get( 'cache' );
1683 return ! empty( $opts['mobile_separate'] ) ? 'mobile_separate' : '';
1684 }
1685
1686 /**
1687 * Whether migration flagged Separate Mobile Cache for user review. Set by
1688 * Migration::map_mobile_separate() when a source plugin (WP Rocket / WP
1689 * Super Cache / LiteSpeed) had its "separate mobile cache" option on: we
1690 * import it as OFF (to keep the device-blind static fast path) but record
1691 * this flag so the dashboard can invite the user to turn it back on only
1692 * if their site genuinely serves different HTML per device. (FBS-83145)
1693 */
1694 public static function mobile_separate_needs_review(): bool {
1695 $opts = Settings_Manager::get( 'cache' );
1696 return ! empty( $opts['mobile_separate_review'] );
1697 }
1698
1699 /**
1700 * Clear the review flag — called when the user has acted on the prompt
1701 * (dismissed it, or turned Separate Mobile Cache on/off deliberately) so
1702 * the dashboard callout doesn't nag forever. Writes the option directly
1703 * (bypassing Settings_Manager) so it never touches schema fields.
1704 */
1705 public static function clear_mobile_separate_review(): void {
1706 $stored = get_option( 'xspeed_module_cache', array() );
1707 if ( ! is_array( $stored ) || empty( $stored['mobile_separate_review'] ) ) {
1708 return;
1709 }
1710 unset( $stored['mobile_separate_review'] );
1711 update_option( 'xspeed_module_cache', $stored );
1712 }
1713
1714 /**
1715 * On-demand probe: does the homepage serve materially the same HTML to a
1716 * desktop and a mobile browser? Fetches home_url() twice over loopback —
1717 * once with a desktop User-Agent, once with a mobile one — strips
1718 * per-request noise (nonces, CSRF tokens, session ids, inline timestamps),
1719 * and compares. When identical, Separate Mobile Cache is almost certainly
1720 * unnecessary and the user can turn it off to regain the static fast path.
1721 *
1722 * NEVER run automatically (no page-load cost) — only from the dashboard
1723 * "Check now" button. Result is cached for 10 minutes so a double-click or
1724 * a re-render doesn't fire two more self-requests. (FBS-83145)
1725 *
1726 * @return array{ identical:bool, checked:bool, reason?:string, desktop_bytes?:int, mobile_bytes?:int }
1727 */
1728 public static function probe_mobile_equality(): array {
1729 $cached = get_transient( 'xspeed_mobile_equality_probe' );
1730 if ( is_array( $cached ) ) {
1731 return $cached;
1732 }
1733
1734 $home = home_url( '/' );
1735 $host = (string) wp_parse_url( $home, PHP_URL_HOST );
1736 if ( '' === $host ) {
1737 $result = array( 'identical' => false, 'checked' => false, 'reason' => 'home_url has no host' );
1738 set_transient( 'xspeed_mobile_equality_probe', $result, MINUTE_IN_SECONDS );
1739 return $result;
1740 }
1741
1742 // Match WP core's own mobile detection (wp_is_mobile) so the probe
1743 // reflects what the site would actually branch on. iPhone Safari for
1744 // mobile; a current desktop Chrome UA for desktop.
1745 $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';
1746 $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';
1747
1748 $is_local = function_exists( 'wp_get_environment_type' )
1749 && in_array( wp_get_environment_type(), array( 'local', 'development' ), true );
1750
1751 $fetch = static function ( string $ua ) use ( $home, $is_local ) {
1752 $resp = wp_remote_get(
1753 $home,
1754 array(
1755 'timeout' => 5,
1756 'sslverify' => ! $is_local,
1757 'redirection' => 2,
1758 // Bust any per-device cache so we compare freshly-rendered
1759 // HTML, and pass the device UA the site would branch on.
1760 'user-agent' => $ua,
1761 'headers' => array( 'Cache-Control' => 'no-cache' ),
1762 )
1763 );
1764 if ( is_wp_error( $resp ) || 200 !== (int) wp_remote_retrieve_response_code( $resp ) ) {
1765 return null;
1766 }
1767 return (string) wp_remote_retrieve_body( $resp );
1768 };
1769
1770 $desktop = $fetch( $desktop_ua );
1771 $mobile = $fetch( $mobile_ua );
1772
1773 if ( null === $desktop || null === $mobile ) {
1774 $result = array( 'identical' => false, 'checked' => false, 'reason' => 'could not fetch homepage twice' );
1775 set_transient( 'xspeed_mobile_equality_probe', $result, MINUTE_IN_SECONDS );
1776 return $result;
1777 }
1778
1779 $identical = self::normalize_html_for_diff( $desktop ) === self::normalize_html_for_diff( $mobile );
1780
1781 $result = array(
1782 'identical' => $identical,
1783 'checked' => true,
1784 'desktop_bytes' => strlen( $desktop ),
1785 'mobile_bytes' => strlen( $mobile ),
1786 );
1787 set_transient( 'xspeed_mobile_equality_probe', $result, 10 * MINUTE_IN_SECONDS );
1788 return $result;
1789 }
1790
1791 /**
1792 * Strip per-request noise from HTML so a desktop-vs-mobile diff reflects
1793 * real structural differences, not nonces / session ids / timestamps that
1794 * change on every render. Deliberately conservative: it normalizes the
1795 * handful of well-known noise sources and collapses whitespace, so a site
1796 * that truly serves different markup per device still compares as different.
1797 */
1798 private static function normalize_html_for_diff( string $html ): string {
1799 $patterns = array(
1800 // WP nonces (data-nonce="...", _wpnonce=..., "nonce":"...").
1801 '/(_wpnonce|nonce|_ajax_nonce)["\']?\s*[:=]\s*["\']?[a-f0-9]{10}/i',
1802 // Generic 10+ hex tokens (CSRF, cache-buster hashes, session ids).
1803 '/\b[a-f0-9]{16,}\b/i',
1804 // wp-generated unique ids (e.g. wp-block ids, aria ids).
1805 '/(id|for|aria-[a-z]+)="[^"]*-[0-9]{3,}"/i',
1806 // ISO-ish timestamps + epoch-looking numbers in query strings.
1807 '/\?ver=[0-9.]+/',
1808 '/[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.+Z-]+/',
1809 );
1810 $html = (string) preg_replace( $patterns, 'X', $html );
1811 // Collapse all whitespace so trivial formatting differences don't count.
1812 return trim( (string) preg_replace( '/\s+/', ' ', $html ) );
1813 }
1814
1815 public static function ensure_hits_log_file(): bool {
1816 // The HITs log exists ONLY so a server-level nginx `access_log`
1817 // directive has a world-writable file to append to (see nginx_snippet()
1818 // + Hit_Counter::collect_nginx_log_hits()). On Apache/LiteSpeed/managed
1819 // hosts nothing writes it, so creating it — and, worse, chmod()-ing it
1820 // world-writable — is pointless AND fails with "Operation not permitted"
1821 // when PHP can't chmod files it doesn't own (a warning that surfaces in
1822 // logs that capture @-suppressed errors). Skip the whole thing off nginx.
1823 if ( Server::NGINX !== Server::type() ) {
1824 return false;
1825 }
1826 $dir = self::hits_log_dir();
1827 if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
1828 return false;
1829 }
1830 // Ensure the dir is traversable + writable by a different-uid nginx.
1831 // 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.
1832 @chmod( $dir, 0777 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort; the access_log just stays empty if it fails.
1833 $path = self::hits_log_path();
1834 if ( ! file_exists( $path ) ) {
1835 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch -- See docblock: must be a plain touch, not WP_Filesystem.
1836 @touch( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- non-fatal helper; failures already covered by the dir check.
1837 }
1838 // World-writable so a different-uid nginx can append HIT lines.
1839 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- See docblock.
1840 @chmod( $path, 0666 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort.
1841 return file_exists( $path );
1842 }
1843
1844 public static function nginx_snippet(): ?string {
1845 if ( Server::NGINX !== Server::type() ) {
1846 return null;
1847 }
1848 $rel = '/' . ltrim( str_replace( ABSPATH, '/', XSPEED_CACHE_STATIC_DIR ), '/' );
1849 $rel = rtrim( $rel, '/' );
1850
1851 // WP-Rocket-canonical pattern: every condition lives at
1852 // SERVER level (outside any location block). Each one appends
1853 // a tag to $xspeed_no_cache; the final check is a single
1854 // string-equality against the unmodified default "no-cache".
1855 // Only when ALL conditions pass does the rewrite fire,
1856 // jumping the request to the static file's URL. nginx then
1857 // restarts location matching against the new path, where
1858 // regular static-file serving takes over.
1859 //
1860 // Why server-level + a single rewrite (instead of try_files
1861 // inside `location /`): nginx's well-documented "if is evil"
1862 // quirk silently disables `try_files`'s last fallback when
1863 // any `if` in the same location is true. Moving the `if`s
1864 // outside any location dodges the trap completely, because
1865 // server-level rewrite is the documented stable path.
1866 //
1867 // `last` (not `break`) restarts location matching — required
1868 // so the rewritten static-file URI gets served via the normal
1869 // static-file location, not re-matched against `location /`
1870 // where our own rewrite would loop.
1871 //
1872 // The cache existence check is the LAST condition in the
1873 // chain so when the file isn't cached, $xspeed_no_cache
1874 // gets a "-nofile" tag and the rewrite is skipped — the
1875 // request falls through to whatever `location /` the user
1876 // already had (typically `try_files $uri $uri/ /index.php?$args;`).
1877 // Absolute path to the hit-log file from the nginx process's
1878 // filesystem view. Nginx's `access_log buffer=N flush=Ns` form
1879 // requires a literal path — `$document_root` variables are
1880 // rejected — so PHP computes it. Lives under uploads/ (NOT the
1881 // cache dir): a cache purge or uninstall deletes the cache dir,
1882 // which would orphan this directive's parent directory and make
1883 // `nginx -t` fail [emerg] for EVERY vhost on the host
1884 // (FBS-82478). uploads/ survives both, so the directive can
1885 // never take nginx down. Works on every topology where the nginx
1886 // process shares a filesystem with PHP (container or host).
1887 $hits_abs = self::hits_log_path();
1888
1889 $lines = array();
1890 $lines[] = '# xSpeed static cache — paste at server level, above location / { }.';
1891 // Cache host must match the on-disk dir PHP writes: store_static() /
1892 // static_host() take HTTP_HOST and strip every char outside
1893 // [a-zA-Z0-9.\-] — i.e. it removes the colon but KEEPS the port digits
1894 // (localhost:8192 → localhost8192). nginx's own $host can't reproduce
1895 // that: $host has the port already stripped ENTIRELY (→ localhost), so
1896 // the -f check looks for localhost/... while PHP wrote localhost8192/...
1897 // and the rewrite never fires on a non-standard port. Derive
1898 // $xspeed_host from $http_host (which keeps the port) and drop just the
1899 // colon, so it equals the PHP dir on every port. On standard ports
1900 // $http_host has no colon, so $xspeed_host == $host == the bare domain.
1901 $lines[] = 'set $xspeed_host $http_host;'; // default: no port → unchanged (e.g. example.com)
1902 $lines[] = 'if ($http_host ~ "^([^:]+):(\\d+)$") { set $xspeed_host $1$2; }'; // host:port → hostport (matches PHP static_host())
1903 $lines[] = 'set $xspeed_no_cache "no-cache";';
1904 $lines[] = 'if ($request_method != GET) { set $xspeed_no_cache "$xspeed_no_cache-method"; }';
1905 $lines[] = 'if ($args) { set $xspeed_no_cache "$xspeed_no_cache-args"; }';
1906 $lines[] = 'if ($http_cookie ~* "(wordpress_logged_in|comment_author|wp-postpass_)") { set $xspeed_no_cache "$xspeed_no_cache-cookie"; }';
1907 $lines[] = 'if (!-f "$document_root' . $rel . '/$xspeed_host$uri/index.html") { set $xspeed_no_cache "$xspeed_no_cache-nofile"; }';
1908 // Neither `add_header` nor `access_log` is allowed inside an `if{}`
1909 // at server level (nginx rejects with "directive is not allowed
1910 // here"). The logging therefore lives in a `location` block that
1911 // matches the rewritten URI after `rewrite … last;` restarts
1912 // location matching. Every HIT lands there exactly once, every
1913 // MISS / PHP-served request never matches it.
1914 $lines[] = 'if ($xspeed_no_cache = "no-cache") {';
1915 $lines[] = ' rewrite ^ ' . $rel . '/$xspeed_host$uri/index.html last;';
1916 $lines[] = '}';
1917 $lines[] = '';
1918 $lines[] = '# Serve + log the cached HIT — `^~` is required so this beats any regex location.';
1919 $lines[] = 'location ^~ ' . $rel . '/ {';
1920 $lines[] = ' internal;';
1921 // LITERAL log path (not `set $var; access_log $var`). The variable form
1922 // makes nginx open the log lazily per-request and SILENTLY drop the
1923 // line if the open fails — so on a working host hits were served
1924 // (X-XSpeed-Cache fires regardless) but nothing was ever written and
1925 // the hit ratio sat at 0%. A literal path makes nginx open the file at
1926 // config load and actually log every hit.
1927 //
1928 // Deleting the log FILE is still safe with a literal path: nginx
1929 // recreates it on the next write/reload and `nginx -t` stays green
1930 // (verified). The only thing that [emerg]s `nginx -t` is a missing
1931 // parent DIRECTORY — and the log lives under uploads/xspeed/, which
1932 // survives cache purge + uninstall, and which ensure_hits_log_file()
1933 // (run on every admin_init via auto_heal) recreates if it ever goes
1934 // missing. So: hits are logged, and a user deleting the log can't take
1935 // nginx down.
1936 $lines[] = ' access_log ' . $hits_abs . ' combined buffer=16k flush=5s;';
1937 $lines[] = ' add_header X-XSpeed-Cache "HIT (nginx)" always;';
1938 $lines[] = '}';
1939 return implode( "\n", $lines );
1940 }
1941
1942 /**
1943 * Aggregate every enabled module's nginx_directives() into one
1944 * pasteable server-block snippet. Replaces the per-module "paste
1945 * this snippet" notices with a single consolidated paste — every
1946 * future feature toggle just regenerates this output.
1947 *
1948 * Returns null on non-nginx hosts (nothing to paste).
1949 *
1950 * Sections render in module-registration order so the layout stays
1951 * predictable; each module gets a comment header `# <slug>`.
1952 */
1953 public static function full_nginx_server_block(): ?string {
1954 if ( Server::NGINX !== Server::type() ) {
1955 return null;
1956 }
1957
1958 $blocks = array();
1959 foreach ( Module_Registry::all() as $module ) {
1960 $directives = $module->nginx_directives();
1961 if ( ! is_string( $directives ) || '' === trim( $directives ) ) {
1962 continue;
1963 }
1964 $blocks[] = "# === " . $module->slug() . " ===\n" . rtrim( $directives );
1965 }
1966
1967 if ( empty( $blocks ) ) {
1968 return null;
1969 }
1970
1971 $header = "# xSpeed unified nginx config — paste into `server { }`, above `location / { }`; re-paste after toggling features.\n";
1972
1973 return $header . "\n" . implode( "\n\n", $blocks ) . "\n";
1974 }
1975
1976 /**
1977 * Tell LiteSpeed's LSCache module to stand down on the cache-miss
1978 * render path.
1979 *
1980 * History: this method used to emit X-LiteSpeed-Cache-Control:
1981 * public,max-age=N + X-LiteSpeed-Tag, handing caching to the server's
1982 * LSCache store. That delegation backfired — once LSCache cached a
1983 * page it served every subsequent request from its OWN store and
1984 * intercepted the request before our site-root .htaccess static
1985 * rewrite could run. Net effect on LiteSpeed hosts: no X-XSpeed-Cache
1986 * header, our static-cache tree never served, the HIT log never
1987 * written (hit ratio frozen at 0%), and the Health probe reporting a
1988 * false "cache running on PHP fallback" because it never saw an
1989 * xSpeed-served response.
1990 *
1991 * xSpeed now owns the cache on LiteSpeed exactly as it does on Apache:
1992 * our `.htaccess` mod_rewrite block serves hits straight from the
1993 * static-cache tree (with the X-XSpeed-Cache header + access-log HIT
1994 * accounting), and PHP/the drop-in is the fallback. To guarantee
1995 * LSCache doesn't shadow that with its own copy — some LiteSpeed
1996 * configs cache by default — we send an explicit `no-cache` control so
1997 * the server defers to our rewrite. Skipped when the LiteSpeed Cache
1998 * plugin is active (it owns its own header policy; our Conflict
1999 * registry handles that coexistence separately).
2000 */
2001 public static function maybe_emit_lscache_headers(): void {
2002 if ( headers_sent() ) {
2003 return;
2004 }
2005 if ( Server::LITESPEED !== Server::type() ) {
2006 return;
2007 }
2008 // is_plugin_active() lives in wp-admin/includes/plugin.php which
2009 // isn't auto-loaded on front-end requests. Use the option layer
2010 // directly to avoid pulling in admin code from a render path.
2011 $active = (array) get_option( 'active_plugins', array() );
2012 if ( in_array( 'litespeed-cache/litespeed-cache.php', $active, true ) ) {
2013 return;
2014 }
2015
2016 // Explicitly opt this response OUT of LSCache so the server can't
2017 // shadow our static-rewrite cache with its own internal copy.
2018 header( 'X-LiteSpeed-Cache-Control: no-cache' );
2019 }
2020
2021 /**
2022 * Restore the drop-in + WP_CACHE constant for a site that had caching
2023 * ON before this activation — and ONLY for such a site.
2024 *
2025 * WordPress runs an upgrade as deactivate → wipe plugin files →
2026 * install → activate. The wipe takes advanced-cache.php with it, so
2027 * without this the site serves 100% uncached from the moment the
2028 * update finishes until the next authenticated wp-admin page load
2029 * (auto_heal() is on admin_init). On a site whose admin logs in
2030 * rarely that window is hours or days of silent cache loss, while
2031 * the dashboard still reports cache_enabled = true. (FBS field
2032 * report against 1.1.2 / Pro 1.0.5.)
2033 *
2034 * The `cache_enabled` guard is the whole contract: a FRESH install
2035 * has the option unset, so activation writes nothing and the user
2036 * still opts in explicitly through Cache::toggle() via the
2037 * /cache/toggle REST endpoint. We only ever put back state the user
2038 * already chose — repair, never a new install path. This is what
2039 * keeps us on the right side of the "don't create drop-ins the user
2040 * didn't ask for" guideline while matching what WP Rocket, W3 Total
2041 * Cache and WP Super Cache all do on activation.
2042 *
2043 * @return bool True when a restore was performed.
2044 */
2045 public static function restore_dropin_if_enabled(): bool {
2046 if ( defined( 'WP_INSTALLING' ) && WP_INSTALLING ) {
2047 return false;
2048 }
2049
2050 // The user's saved choice. Absent/false on a fresh install => no
2051 // drop-in is written and nothing touches wp-config.php.
2052 $opts = get_option( 'xspeed_options', array() );
2053 if ( empty( $opts['cache_enabled'] ) ) {
2054 return false;
2055 }
2056
2057 $restored = false;
2058
2059 // Only (re)install when the drop-in is missing, foreign, or an
2060 // older version of ours — never rewrite a current, healthy file.
2061 $target = WP_CONTENT_DIR . '/advanced-cache.php';
2062 $needs = true;
2063 if ( file_exists( $target ) ) {
2064 $contents = @file_get_contents( $target ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Best-effort read; a failure just means we reinstall.
2065 if ( is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' ) ) {
2066 $source = @file_get_contents( XSPEED_DIR . 'includes/advanced-cache.php' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Same.
2067 $needs = self::dropin_version( $contents ) < self::dropin_version( is_string( $source ) ? $source : '' );
2068 }
2069 }
2070 if ( $needs && self::install_dropin() ) {
2071 $restored = true;
2072 }
2073
2074 // WP_CACHE lives in wp-config.php, which the upgrade doesn't touch —
2075 // but a foreign cache plugin or a hand-edit can drop it, and without
2076 // it core never loads the drop-in at all.
2077 if ( ! defined( 'WP_CACHE' ) || ! WP_CACHE ) {
2078 if ( self::set_wp_cache_constant( true ) ) {
2079 $restored = true;
2080 }
2081 }
2082
2083 if ( $restored ) {
2084 Activity_Log::record(
2085 'cache_dropin_restored',
2086 'Cache drop-in restored after a plugin update — caching was already enabled.',
2087 Activity_Log::SUCCESS
2088 );
2089 }
2090
2091 return $restored;
2092 }
2093
2094 /**
2095 * Reconcile drop-in + WP_CACHE + rewrite block with the user's
2096 * saved choice. Runs on admin_init. Cheap when nothing's wrong
2097 * (one option read + a handful of file_exists / defined checks);
2098 * writes only when state has drifted (typical cause: plugin
2099 * upgrade wiped the drop-in, foreign plugin removed our WP_CACHE
2100 * define, or someone hand-edited .htaccess).
2101 *
2102 * Skipped during the WP plugin updater run so we don't race
2103 * the upgrader's own filesystem operations.
2104 */
2105 public static function auto_heal(): void {
2106 if ( defined( 'WP_INSTALLING' ) && WP_INSTALLING ) {
2107 return;
2108 }
2109 if ( wp_doing_ajax() || wp_doing_cron() ) {
2110 return;
2111 }
2112
2113 $opts = get_option( 'xspeed_options', array() );
2114 if ( empty( $opts['cache_enabled'] ) ) {
2115 return;
2116 }
2117
2118 $dropin_target = WP_CONTENT_DIR . '/advanced-cache.php';
2119 $dropin_ours = false;
2120 $dropin_stale = false;
2121 if ( file_exists( $dropin_target ) ) {
2122 $contents = @file_get_contents( $dropin_target ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
2123 $dropin_ours = is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' );
2124 // Reinstall when OUR drop-in is an older version than the source —
2125 // the marker alone can't distinguish an old copy from a new one, so
2126 // a serve-logic change (e.g. the .meta read for 404s/feeds) would
2127 // otherwise never reach existing cache-enabled sites until a manual
2128 // cache toggle. (FBS-82406/82407)
2129 if ( $dropin_ours ) {
2130 $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
2131 }
2132 }
2133
2134 if ( ! $dropin_ours || $dropin_stale ) {
2135 self::install_dropin();
2136 }
2137
2138 if ( ! defined( 'WP_CACHE' ) || ! WP_CACHE ) {
2139 self::set_wp_cache_constant( true );
2140 }
2141
2142 // Rewrite block goes last. It's what turns the static-cache
2143 // tree into a PHP-bypass — every cache hit served by the web
2144 // server directly. Without it we still cache, just at drop-in
2145 // speed (~85ms TTFB) instead of static-file speed (~25-40ms).
2146 //
2147 // Reconcile against mobile_separate: the rewrite is device-blind, so
2148 // it must be ABSENT when mobile_separate is on and PRESENT otherwise.
2149 // auto_heal() runs periodically, so it also repairs a rewrite that
2150 // was left installed before mobile_separate was switched on.
2151 if ( self::static_rewrite_allowed() ) {
2152 if ( ! self::rewrite_installed() ) {
2153 self::install_rewrite();
2154 }
2155 } elseif ( self::rewrite_installed() ) {
2156 self::remove_rewrite();
2157 }
2158
2159 // HITs log file — nginx writes one line per HIT served directly
2160 // (see nginx_snippet()), Cache::get_stats() drains the file via
2161 // Hit_Counter::collect_nginx_log_hits(). If the file vanishes
2162 // (plugin upgrade wiped wp-content/cache/), nginx errors silently
2163 // on the access_log directive and the counter stays at 0.
2164 self::ensure_hits_log_file();
2165 }
2166
2167 /**
2168 * Build the .htaccess rules that map cacheable requests to the
2169 * static-cache tree. Conditions are deliberately strict: GET only,
2170 * empty query string, no session/comment-author/post-password
2171 * cookie, and the static file must exist on disk. Anything that
2172 * fails one of these falls through to PHP and the drop-in / full
2173 * WordPress path.
2174 *
2175 * @return string[] Lines for insert_with_markers().
2176 */
2177 public static function rewrite_block_lines(): array {
2178 // Path relative to ABSPATH so the rule lives in the site-root
2179 // .htaccess regardless of where wp-content sits. WP_CONTENT_DIR
2180 // can be moved, so we compute the document-root-relative form
2181 // at install time and bake it into the rule.
2182 $rel = str_replace( ABSPATH, '/', XSPEED_CACHE_STATIC_DIR );
2183 $rel = '/' . ltrim( $rel, '/' );
2184 $rel = rtrim( $rel, '/' );
2185
2186 return array(
2187 '<IfModule mod_rewrite.c>',
2188 ' RewriteEngine On',
2189 ' RewriteCond %{REQUEST_METHOD} ^GET$',
2190 ' RewriteCond %{QUERY_STRING} ^$',
2191 ' RewriteCond %{HTTP_COOKIE} !(wordpress_logged_in|comment_author|wp-postpass_) [NC]',
2192 // Capture REQUEST_URI without its trailing slash into %1.
2193 // store_static() writes `{host}{uri-without-trailing-slash}/index.html`,
2194 // so this normalization lets `/blog/` and `/blog` both hit
2195 // the same cache file without producing the double-slash
2196 // path that would skip the -f check below.
2197 ' RewriteCond %{REQUEST_URI} ^(.*?)/?$',
2198 ' RewriteCond %{DOCUMENT_ROOT}' . $rel . '/%{HTTP_HOST}%1/index.html -f',
2199 // Pattern is `^`, NOT `.`. The per-directory rewrite engine
2200 // strips the leading slash before matching, so the HOMEPAGE
2201 // request `/` arrives here as an EMPTY path. `.` requires at
2202 // least one character and therefore never matches the homepage
2203 // — on LiteSpeed (which honors this strictly) the front page
2204 // fell through to PHP while every inner page rewrote fine.
2205 // `^` matches the empty string AND any non-empty path, so it
2206 // covers `/` and `/blog` alike. (Confirmed on OpenLiteSpeed
2207 // 1.8: `.` → homepage served by PHP drop-in; `^` → served
2208 // directly from the static file.)
2209 ' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [L]',
2210 '</IfModule>',
2211 // Mark the statically-served response as a cache HIT.
2212 //
2213 // A file served by the rewrite above bypasses PHP entirely, so
2214 // this directive is the ONLY thing that can identify it as
2215 // cached — both for the user reading response headers and for
2216 // Hit_Counter, which reconciles static hits from the access
2217 // log. Without it the cache works perfectly and reports a 0%
2218 // hit ratio, which reads as "the plugin is broken". (Field
2219 // report against 1.1.2: homepage served byte-identical from
2220 // the static tree, no X-XSpeed-Cache header on any response.)
2221 //
2222 // `always` so the header is set on the 200 from the rewritten
2223 // file, not only on the successful-response table. The
2224 // <IfModule> guard keeps a server without mod_headers from
2225 // 500ing on an unknown directive — on such a host the header
2226 // is silently dropped, which is exactly why
2227 // static_rewrite_allowed() refuses the static path there and
2228 // routes hits through the drop-in instead.
2229 '<IfModule mod_headers.c>',
2230 ' <FilesMatch "\\.html$">',
2231 ' Header always set X-XSpeed-Cache "HIT (static)"',
2232 ' </FilesMatch>',
2233 '</IfModule>',
2234 );
2235 }
2236
2237 /**
2238 * Active probe that confirms the web-server static-rewrite path is
2239 * actually serving cached files. Writes a probe file with a random
2240 * nonce, fetches it over HTTP at its public URL, and checks whether
2241 * the response was served directly by the web server (Last-Modified
2242 * + ETag headers + no X-Powered-By: PHP).
2243 *
2244 * Server-agnostic: same probe works for nginx (snippet pasted) and
2245 * Apache / LiteSpeed (.htaccess block installed). If the rewrite
2246 * isn't engaged, the request falls through to WordPress and PHP
2247 * adds its own headers, which the probe detects and reports.
2248 *
2249 * Throttled via a 5-minute transient — we never want this running
2250 * on every Health card paint.
2251 *
2252 * @return array{active:bool, reason:string, code?:int, php?:bool, expires?:int}
2253 */
2254 /**
2255 * @param bool $allow_probe When false (the default), return ONLY a cached
2256 * result and never make an HTTP request — so admin page loads are never
2257 * blocked by the loopback probe. The actual HTTP probe only runs when a
2258 * caller explicitly opts in (the Health tab / cron). Previously this ran
2259 * synchronously on every dashboard bootstrap, so a slow/timing-out
2260 * loopback request added up to `timeout` seconds to admin page loads on
2261 * hosts that block self-requests. (FBS-82142)
2262 */
2263 /**
2264 * Discard the cached probe result and run a fresh one.
2265 *
2266 * Without this there was no way to re-check: the result sat in a transient
2267 * for five minutes and nothing ever deleted it, so a user who fixed their
2268 * nginx config kept seeing "nginx detected — configure for max cache speed"
2269 * with no means of confirming the fix worked. (FBS-84012)
2270 */
2271 public static function recheck_static_rewrite(): array {
2272 delete_transient( 'xspeed_rewrite_probe' );
2273 return self::probe_static_rewrite( true );
2274 }
2275
2276 public static function probe_static_rewrite( bool $allow_probe = false ): array {
2277 $cached = get_transient( 'xspeed_rewrite_probe' );
2278 if ( is_array( $cached ) ) {
2279 return $cached;
2280 }
2281 // No cached result yet and the caller doesn't want to pay for a live
2282 // HTTP probe (e.g. the admin bootstrap): report "pending" without
2283 // blocking. The Health tab will run the real probe on demand.
2284 if ( ! $allow_probe ) {
2285 return array( 'active' => false, 'reason' => 'probe pending', 'pending' => true );
2286 }
2287
2288 $home = home_url( '/' );
2289 $host = (string) wp_parse_url( $home, PHP_URL_HOST );
2290 if ( '' === $host ) {
2291 $result = array( 'active' => false, 'reason' => 'home_url has no host' );
2292 set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
2293 return $result;
2294 }
2295
2296 // Use a randomised path AND nonce so a stale CDN cache entry
2297 // from a prior probe can never make a broken install look
2298 // healthy. Path is namespaced under __xspeed_probe__ so the
2299 // directory listing stays obvious if cleanup misfires.
2300 $slug = wp_generate_password( 12, false, false );
2301 $nonce = wp_generate_password( 24, false, false );
2302 $probe_dir = XSPEED_CACHE_STATIC_DIR . '/' . $host . '/__xspeed_probe__/' . $slug;
2303 $probe_file = $probe_dir . '/index.html';
2304 $probe_url = trailingslashit( $home ) . '__xspeed_probe__/' . $slug . '/';
2305
2306 if ( ! file_exists( $probe_dir ) ) {
2307 wp_mkdir_p( $probe_dir );
2308 }
2309 if ( ! is_dir( $probe_dir ) ) {
2310 $result = array( 'active' => false, 'reason' => 'cannot create probe dir' );
2311 set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
2312 return $result;
2313 }
2314 // 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.
2315 file_put_contents( $probe_file, $nonce, LOCK_EX );
2316
2317 // Verify TLS by default — disabling it site-wide is a needless MITM
2318 // exposure (FBS-82142). Only relax verification in local/dev
2319 // environments, where self-signed certs are common and there's no
2320 // real attacker in the loop.
2321 $is_local = function_exists( 'wp_get_environment_type' )
2322 && in_array( wp_get_environment_type(), array( 'local', 'development' ), true );
2323 $resp = wp_remote_get(
2324 $probe_url,
2325 array(
2326 // 3s cap so a host that hangs on loopback self-requests can't
2327 // stall the caller for long; the result/error is cached so we
2328 // don't repeat the wait every minute.
2329 'timeout' => 3,
2330 'sslverify' => ! $is_local,
2331 'redirection' => 0,
2332 'headers' => array( 'Cache-Control' => 'no-cache' ),
2333 )
2334 );
2335
2336 // Best-effort cleanup so we don't accumulate probe dirs even
2337 // if subsequent calls all hit the transient.
2338 if ( file_exists( $probe_file ) ) {
2339 wp_delete_file( $probe_file );
2340 }
2341 if ( is_dir( $probe_dir ) ) {
2342 // 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.
2343 @rmdir( $probe_dir );
2344 }
2345
2346 if ( is_wp_error( $resp ) ) {
2347 $result = array(
2348 'active' => false,
2349 // The request never completed, so we learned NOTHING about the
2350 // rewrite. Flagged inconclusive so the UI doesn't tell the user
2351 // to configure a server that may already be configured — a
2352 // blocked loopback, a self-signed cert, or a timeout is a probe
2353 // failure, not a missing rewrite. (FBS-84012)
2354 'inconclusive' => true,
2355 'reason' => 'http error: ' . $resp->get_error_message(),
2356 );
2357 // Cache the failure for the full 5 minutes (not 1) so a host that
2358 // times out on the loopback probe isn't re-probed — and re-stalled
2359 // — on every page load within the window. (FBS-82142)
2360 set_transient( 'xspeed_rewrite_probe', $result, 5 * MINUTE_IN_SECONDS );
2361 return $result;
2362 }
2363
2364 $code = (int) wp_remote_retrieve_response_code( $resp );
2365 $body = (string) wp_remote_retrieve_body( $resp );
2366 $ua_php = '' !== (string) wp_remote_retrieve_header( $resp, 'x-powered-by' );
2367 $has_etag = '' !== (string) wp_remote_retrieve_header( $resp, 'etag' )
2368 || '' !== (string) wp_remote_retrieve_header( $resp, 'last-modified' );
2369 $match = trim( $body ) === $nonce;
2370
2371 // "Active" = the web server served our raw nonce bytes back
2372 // AND emitted the static-serve markers (ETag / Last-Modified)
2373 // AND didn't add an X-Powered-By: PHP header. All three are
2374 // individually noisy; together they're conclusive.
2375 $active = $match && $has_etag && ! $ua_php && 200 === $code;
2376
2377 /*
2378 * `inconclusive` separates "we proved the rewrite isn't serving" from
2379 * "the probe couldn't tell". Only the former should drive a
2380 * configure-your-server banner; the latter previously rendered the
2381 * same alarming copy at a user who had already configured nginx
2382 * correctly, and there was no way to clear it. (FBS-84012)
2383 */
2384 $inconclusive = false;
2385 if ( $active ) {
2386 $reason = 'static-served';
2387 } elseif ( 200 === $code && $match && $ua_php ) {
2388 $reason = 'php served the file instead of nginx/Apache (rewrite block missing)';
2389 } elseif ( 200 === $code && ! $match ) {
2390 // Something answered 200 with content that isn't our nonce — a CDN,
2391 // a proxy, a security plugin. That tells us nothing about the
2392 // origin's rewrite.
2393 $reason = 'unexpected body (CDN cached an older response?)';
2394 $inconclusive = true;
2395 } elseif ( 404 === $code ) {
2396 $reason = 'probe URL returned 404 (rewrite block missing or wrong path)';
2397 } else {
2398 // Redirects, 403s from a WAF, 5xx — the probe never reached a
2399 // verdict about the rewrite itself.
2400 $reason = sprintf( 'unexpected response (HTTP %d, body %d B, php=%s)', $code, strlen( $body ), $ua_php ? 'yes' : 'no' );
2401 $inconclusive = true;
2402 }
2403
2404 $result = array(
2405 'active' => $active,
2406 'inconclusive' => $inconclusive,
2407 'reason' => $reason,
2408 'code' => $code,
2409 'php' => $ua_php,
2410 );
2411 set_transient( 'xspeed_rewrite_probe', $result, 5 * MINUTE_IN_SECONDS );
2412 return $result;
2413 }
2414
2415 public static function rewrite_installed(): bool {
2416 $htaccess = ABSPATH . '.htaccess';
2417 if ( ! file_exists( $htaccess ) ) {
2418 return false;
2419 }
2420 $existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
2421 if ( ! is_string( $existing ) ) {
2422 return false;
2423 }
2424 return false !== strpos( $existing, '# BEGIN xSpeed Static Cache' );
2425 }
2426
2427 /**
2428 * Install the static-cache rewrite block at the TOP of .htaccess.
2429 *
2430 * Position matters: WordPress's own block ends with
2431 * `RewriteRule . /index.php [L]` which routes every non-file
2432 * request to PHP. The [L] flag stops the current rewrite pass,
2433 * but Apache restarts the cycle; on the second pass REQUEST_URI
2434 * is /index.php and no static-file check can match. The only
2435 * reliable position for a "serve static if it exists" rule is
2436 * before WordPress's block.
2437 *
2438 * WP's insert_with_markers() always appends, so we manage the
2439 * block manually: strip any prior xSpeed Static Cache markers,
2440 * then write our block followed by the rest of the file.
2441 */
2442 public static function install_rewrite(): bool {
2443 // The static rewrite is device-blind; never install it when
2444 // mobile_separate is on (see static_rewrite_allowed()).
2445 if ( ! self::static_rewrite_allowed() ) {
2446 return false;
2447 }
2448 $htaccess = ABSPATH . '.htaccess';
2449 $existing = file_exists( $htaccess ) ? @file_get_contents( $htaccess ) : ''; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
2450 if ( false === $existing ) {
2451 $existing = '';
2452 }
2453 // Apache/LiteSpeed only. nginx hosts: rule won't fire, drop-in
2454 // covers; we skip the write so we don't litter their root.
2455 // 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.
2456 if ( file_exists( $htaccess ) && ! is_writable( $htaccess ) ) {
2457 return false;
2458 }
2459 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- See above.
2460 if ( ! file_exists( $htaccess ) && ! is_writable( ABSPATH ) ) {
2461 return false;
2462 }
2463
2464 $cleaned = self::strip_marker_block( $existing, 'xSpeed Static Cache' );
2465 $block = self::marker_block( 'xSpeed Static Cache', self::rewrite_block_lines() );
2466 $next = $block . ( '' === $cleaned ? '' : "\n" . $cleaned );
2467
2468 // 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.
2469 return false !== file_put_contents( $htaccess, $next, LOCK_EX );
2470 }
2471
2472 public static function remove_rewrite(): bool {
2473 $htaccess = ABSPATH . '.htaccess';
2474 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- See install_rewrite() rationale.
2475 if ( ! file_exists( $htaccess ) || ! is_writable( $htaccess ) ) {
2476 return false;
2477 }
2478 $existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
2479 if ( false === $existing ) {
2480 return false;
2481 }
2482 $cleaned = self::strip_marker_block( $existing, 'xSpeed Static Cache' );
2483 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents, PluginCheck.CodeAnalysis.WriteFile.ABSPATHDetected -- See install_rewrite() rationale.
2484 return false !== file_put_contents( $htaccess, $cleaned, LOCK_EX );
2485 }
2486
2487 /**
2488 * Strip a `# BEGIN <marker>` ... `# END <marker>` block from a
2489 * .htaccess-style file, including any blank line that immediately
2490 * follows it. Idempotent — returns the input unchanged if the
2491 * marker isn't present.
2492 */
2493 private static function strip_marker_block( string $contents, string $marker ): string {
2494 $pattern = '/# BEGIN ' . preg_quote( $marker, '/' ) . '\b.*?# END ' . preg_quote( $marker, '/' ) . "\b[^\n]*\n?\n?/s";
2495 $out = preg_replace( $pattern, '', $contents );
2496 return is_string( $out ) ? $out : $contents;
2497 }
2498
2499 private static function marker_block( string $marker, array $lines ): string {
2500 $header = "# BEGIN $marker\n";
2501 $header .= "# The directives (lines) between \"BEGIN $marker\" and \"END $marker\" are\n";
2502 $header .= "# dynamically generated, and should only be modified via WordPress filters.\n";
2503 $header .= "# Any changes to the directives between these markers will be overwritten.\n";
2504 $footer = "# END $marker\n";
2505 return $header . implode( "\n", $lines ) . "\n" . $footer;
2506 }
2507
2508 /**
2509 * Parse the `XSPEED_DROPIN_VERSION: N` stamp out of a drop-in's source.
2510 * Returns 0 when absent (an un-stamped older copy reinstalls). Used to
2511 * detect a stale installed drop-in vs the bundled source.
2512 */
2513 private static function dropin_version( string $contents ): int {
2514 if ( preg_match( '/XSPEED_DROPIN_VERSION:\s*(\d+)/', $contents, $m ) ) {
2515 return (int) $m[1];
2516 }
2517 return 0;
2518 }
2519
2520 public static function install_dropin() {
2521 $source = XSPEED_DIR . 'includes/advanced-cache.php';
2522 $target = WP_CONTENT_DIR . '/advanced-cache.php';
2523 if ( ! file_exists( $source ) ) {
2524 return false;
2525 }
2526
2527 global $wp_filesystem;
2528 if ( ! function_exists( 'WP_Filesystem' ) ) {
2529 require_once ABSPATH . 'wp-admin/includes/file.php';
2530 }
2531 WP_Filesystem();
2532 if ( ! $wp_filesystem ) {
2533 return false;
2534 }
2535
2536 $source_contents = $wp_filesystem->get_contents( $source );
2537 if ( ! is_string( $source_contents ) ) {
2538 return false;
2539 }
2540
2541 // Bake the absolute hit-log path into the drop-in. It runs before
2542 // WordPress loads, so it can't resolve wp_upload_dir() itself — we
2543 // substitute the @@XSPEED_HITS_LOG@@ token with the real uploads path
2544 // (never the cache dir; see hits_log_dir() / FBS-82478). Use a single
2545 // quoted PHP string literal so the installed file stays valid PHP.
2546 $source_contents = str_replace(
2547 '@@XSPEED_HITS_LOG@@',
2548 str_replace( "'", "\\'", self::hits_log_path() ),
2549 $source_contents
2550 );
2551
2552 if ( file_exists( $target ) ) {
2553 $existing = $wp_filesystem->get_contents( $target );
2554 $is_xspeed = is_string( $existing ) && false !== strpos( $existing, 'XSPEED_DROPIN' );
2555
2556 if ( $is_xspeed ) {
2557 if ( $existing === $source_contents ) {
2558 return true;
2559 }
2560 return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE );
2561 }
2562
2563 // Foreign drop-in (e.g. left over from another cache plugin) — back it up
2564 // before overwriting so the user can recover if needed. Uploads dir
2565 // (not wp-content root) keeps the backup out of WordPress's reserved
2566 // drop-in location.
2567 $upload = wp_upload_dir( null, false );
2568 $basedir = isset( $upload['basedir'] ) ? trailingslashit( $upload['basedir'] ) . 'xspeed-backups' : false;
2569 if ( $basedir ) {
2570 if ( ! file_exists( $basedir ) ) {
2571 wp_mkdir_p( $basedir );
2572 self::write_silence( $basedir );
2573 }
2574 $backup = $basedir . '/advanced-cache.foreign-' . gmdate( 'Ymd-His' ) . '.php.bak';
2575 $wp_filesystem->move( $target, $backup, true );
2576 } else {
2577 $wp_filesystem->delete( $target );
2578 }
2579 }
2580
2581 return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE );
2582 }
2583
2584 public static function remove_dropin() {
2585 $target = WP_CONTENT_DIR . '/advanced-cache.php';
2586 if ( ! file_exists( $target ) ) {
2587 return;
2588 }
2589
2590 global $wp_filesystem;
2591 if ( ! function_exists( 'WP_Filesystem' ) ) {
2592 require_once ABSPATH . 'wp-admin/includes/file.php';
2593 }
2594 WP_Filesystem();
2595 if ( ! $wp_filesystem ) {
2596 return;
2597 }
2598
2599 $contents = $wp_filesystem->get_contents( $target );
2600 if ( is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' ) ) {
2601 wp_delete_file( $target );
2602 }
2603 }
2604
2605 public static function set_wp_cache_constant( $enable ) {
2606 $wp_config = ABSPATH . 'wp-config.php';
2607 if ( ! file_exists( $wp_config ) ) {
2608 return false;
2609 }
2610
2611 global $wp_filesystem;
2612 if ( ! function_exists( 'WP_Filesystem' ) ) {
2613 require_once ABSPATH . 'wp-admin/includes/file.php';
2614 }
2615 WP_Filesystem();
2616 if ( ! $wp_filesystem || ! $wp_filesystem->is_writable( $wp_config ) ) {
2617 return false;
2618 }
2619
2620 $config = $wp_filesystem->get_contents( $wp_config );
2621
2622 if ( $enable ) {
2623 // Own the constant. A previous caching plugin (e.g. WP Rocket sets
2624 // it false on deactivate) can leave `define( 'WP_CACHE', false );`
2625 // behind — presence alone is not enough, the VALUE must be true or
2626 // WordPress never loads advanced-cache.php and our drop-in is dead.
2627 if ( preg_match( "/define\\(\\s*['\"]WP_CACHE['\"]\\s*,/", $config ) ) {
2628 $rewritten = preg_replace(
2629 "/define\\(\\s*['\"]WP_CACHE['\"]\\s*,\\s*[^)]*\\)\\s*;/",
2630 "define( 'WP_CACHE', true );",
2631 $config,
2632 1
2633 );
2634 // If an existing define was already `true`, the rewrite is a
2635 // no-op string-wise; either way we end on WP_CACHE === true.
2636 if ( null !== $rewritten ) {
2637 $config = $rewritten;
2638 }
2639 } else {
2640 $config = preg_replace( '/(<\?php)/', "$1\ndefine( 'WP_CACHE', true );", $config, 1 );
2641 }
2642 } else {
2643 $config = preg_replace( "/define\\(\\s*['\"]WP_CACHE['\"]\\s*,\\s*true\\s*\\);\\s*\\n?/", '', $config );
2644 }
2645
2646 return (bool) $wp_filesystem->put_contents( $wp_config, $config, FS_CHMOD_FILE );
2647 }
2648
2649 /**
2650 * Admin-bar purge menu — a parent node plus one child per visible cache
2651 * type (LiteSpeed-style), instead of a single "Purge All" link. Each
2652 * child posts to the same admin-post handler with its type slug. The
2653 * per-type items only appear for active/licensed modules; "Purge All"
2654 * always shows and always sweeps everything. (FBS-83114)
2655 *
2656 * The parent node links to the settings page rather than a purge URL —
2657 * clicking the top-level item used to wipe the whole cache instantly with
2658 * no confirmation, which is far too destructive for a stray click. Purging
2659 * stays available (and explicit) through the child items. (FBS-84068)
2660 */
2661 public function admin_bar_purge( $wp_admin_bar ) {
2662 if ( ! current_user_can( 'manage_options' ) ) {
2663 return;
2664 }
2665
2666 $wp_admin_bar->add_node(
2667 array(
2668 'id' => 'xspeed-purge',
2669 'title' => __( 'xSpeed Cache', 'xspeed' ),
2670 'href' => admin_url( 'admin.php?page=' . Admin::PAGE_SLUG ),
2671 )
2672 );
2673
2674 foreach ( self::purge_types() as $slug => $type ) {
2675 if ( empty( $type['visible'] ) ) {
2676 continue;
2677 }
2678 $wp_admin_bar->add_node(
2679 array(
2680 'id' => 'xspeed-purge-' . $slug,
2681 'parent' => 'xspeed-purge',
2682 'title' => esc_html( $type['label'] ),
2683 'href' => self::purge_type_url( $slug ),
2684 )
2685 );
2686 }
2687 }
2688
2689 /**
2690 * Nonce-protected admin-post URL for purging a single type. The nonce
2691 * action is per-type so a leaked URL can't be replayed for a different
2692 * scope.
2693 */
2694 private static function purge_type_url( string $type ): string {
2695 return wp_nonce_url(
2696 admin_url( 'admin-post.php?action=xspeed_purge&type=' . rawurlencode( $type ) ),
2697 'xspeed_purge_' . $type
2698 );
2699 }
2700
2701 public function handle_admin_bar_purge() {
2702 if ( ! current_user_can( 'manage_options' ) ) {
2703 wp_die( esc_html__( 'Unauthorized.', 'xspeed' ), 403 );
2704 }
2705 $type = isset( $_GET['type'] ) ? sanitize_key( wp_unslash( $_GET['type'] ) ) : 'all';
2706 check_admin_referer( 'xspeed_purge_' . $type );
2707
2708 // Only honour known types; anything else falls back to a full purge.
2709 if ( ! array_key_exists( $type, self::purge_types() ) ) {
2710 $type = 'all';
2711 }
2712 self::purge_type( $type );
2713
2714 wp_safe_redirect( self::safe_purge_redirect( wp_get_referer() ) );
2715 exit;
2716 }
2717
2718 /**
2719 * Resolve a safe redirect target for an admin-bar purge.
2720 *
2721 * The purge sends the admin back where they came from — but the referer
2722 * can be a ONE-SHOT action URL (e.g. update.php?action=upload-plugin from
2723 * installing a plugin zip, or any *.php?action=… that consumed a POST /
2724 * temp upload). Redirecting there re-runs the action with nothing to act
2725 * on, so WordPress dies — the classic "Please select a file" from
2726 * File_Upload_Upgrader. Strip the transient action args so we return to a
2727 * safe, re-GET-able view of the same page; fall back to the dashboard when
2728 * there is no usable referer.
2729 *
2730 * @param string|false $referer Raw wp_get_referer() value.
2731 * @return string Safe URL to redirect to.
2732 */
2733 public static function safe_purge_redirect( $referer ): string {
2734 $referer = is_string( $referer ) ? $referer : '';
2735 if ( '' === $referer ) {
2736 return admin_url();
2737 }
2738
2739 // A referer that lands on an action-processing endpoint (update.php,
2740 // update-core.php, plugin/theme install/upload flows) can't be safely
2741 // re-requested — send them to the dashboard instead of replaying it.
2742 $path = (string) wp_parse_url( $referer, PHP_URL_PATH );
2743 if ( preg_match( '#/wp-admin/(update|update-core)\.php$#', $path ) ) {
2744 return admin_url();
2745 }
2746
2747 // Otherwise keep them on the same page but drop the query args that
2748 // would re-trigger a form action or upload on load.
2749 return remove_query_arg(
2750 array( 'action', 'action2', 'package', 'overwrite', 'plugin', 'theme', 'file', '_wpnonce', '_ajax_nonce' ),
2751 $referer
2752 );
2753 }
2754 }
2755