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

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