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

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

1,225 lines 48.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Page cache engine.
4 *
5 * @package XSpeed
6 */
7
8 namespace XSpeed;
9
10 defined( 'ABSPATH' ) || exit;
11
12 class Cache {
13
14 /**
15 * Output-buffer nesting level at which we opened our cache buffer, so
16 * `close_buffer()` can flush ONLY our buffer and never disturb a buffer
17 * another plugin pushed on top of (or below) ours.
18 *
19 * @var int|null
20 */
21 private static $buffer_level = null;
22
23 public function __construct() {
24 add_action( 'template_redirect', array( $this, 'maybe_start_cache' ), 0 );
25
26 $invalidate_hooks = array( 'save_post', 'deleted_post', 'trashed_post', 'comment_post', 'wp_set_comment_status', 'switch_theme', 'activated_plugin', 'deactivated_plugin' );
27 foreach ( $invalidate_hooks as $hook ) {
28 add_action( $hook, array( __CLASS__, 'purge_all' ) );
29 add_action( $hook, array( 'XSpeed\\Minifier', 'purge_minified' ) );
30 }
31
32 add_action( 'update_option_xspeed_options', array( __CLASS__, 'on_settings_change' ), 10, 2 );
33
34 add_action( 'admin_bar_menu', array( $this, 'admin_bar_purge' ), 100 );
35 add_action( 'admin_post_xspeed_purge', array( $this, 'handle_admin_bar_purge' ) );
36 }
37
38 public static function on_settings_change( $old, $new ) {
39 // gzip_enabled moved to xspeed_module_gzip — GzipModule owns the
40 // .htaccess flip via its own update_option_xspeed_module_gzip hook.
41 // Same migration is planned for cache_expiry + excluded_urls
42 // (Cache module). Keep this handler around for whatever still
43 // lives in the legacy blob (cache_enabled is special and goes
44 // through Cache::toggle anyway).
45
46 // Any settings change — purge caches so changes take effect.
47 self::purge_all( 'settings change' );
48 Minifier::purge_minified();
49 }
50
51 public function maybe_start_cache() {
52 if ( ! self::should_cache() ) {
53 return;
54 }
55
56 $key = self::cache_key();
57 $file = self::cache_file_for( $key );
58
59 if ( file_exists( $file ) && ! self::is_expired( $file ) ) {
60 Hit_Counter::record_hit();
61 // 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.
62 readfile( $file );
63 exit;
64 }
65
66 // Cache miss → render fresh + write cache. On LiteSpeed servers
67 // we also signal the server-level LSCache module to cache this
68 // response, so subsequent requests skip PHP entirely (~5-15ms
69 // TTFB vs. our PHP drop-in's ~30ms floor). Defers if the
70 // LiteSpeed Cache plugin is active — that plugin owns its own
71 // header emission and conflicts with ours.
72 self::maybe_emit_lscache_headers();
73
74 // We're about to render fresh + cache → miss for this request.
75 Hit_Counter::record_miss();
76
77
78 // WP < 6.9 fallback: ob_start() with a callback, paired with an
79 // explicit shutdown close so the buffer lifecycle is visible to
80 // reviewers and Plugin Check, instead of relying on PHP's implicit
81 // request-end flush. We record our nesting level so close_buffer()
82 // flushes ONLY the buffer we opened.
83 ob_start( array( __CLASS__, 'finalize_buffer' ) );
84 self::$buffer_level = ob_get_level();
85
86 add_action( 'shutdown', array( __CLASS__, 'close_buffer' ), 0 );
87 }
88
89 /**
90 * Close the cache buffer opened by maybe_start_cache().
91 *
92 * Guarded by the recorded buffer level so we never flush a buffer that
93 * another plugin pushed on top of (or under) ours. If something else is
94 * currently on top, we leave the stack alone — PHP's shutdown sequence
95 * will unwind buffers in order and our finalize_buffer() callback will
96 * still run when our level becomes the topmost one.
97 */
98 public static function close_buffer() {
99 if ( null === self::$buffer_level ) {
100 return;
101 }
102 if ( ob_get_level() === self::$buffer_level ) {
103 ob_end_flush();
104 }
105 self::$buffer_level = null;
106 }
107
108 public static function should_cache() {
109 $opts = Settings::get();
110 if ( empty( $opts['cache_enabled'] ) ) {
111 return false;
112 }
113
114 if ( is_user_logged_in() || is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
115 return false;
116 }
117
118 if ( defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE ) {
119 return false;
120 }
121
122 // All exclusion knobs now owned by CacheModule.
123 $cache_opts = Settings_Manager::get( 'cache' );
124
125 $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : '';
126 if ( 'GET' !== $method ) {
127 return false;
128 }
129
130 // Query string handling: anything OUTSIDE the ignored-params
131 // allow-list (utm_*, fbclid, gclid by default) means a unique
132 // request that we don't want to share with the canonical cache
133 // entry. Skip cache rather than poison the key.
134 $query_raw = isset( $_SERVER['QUERY_STRING'] ) ? sanitize_text_field( wp_unslash( $_SERVER['QUERY_STRING'] ) ) : '';
135 if ( '' !== $query_raw ) {
136 $ignored = is_array( $cache_opts['ignored_query_params'] ?? null ) ? $cache_opts['ignored_query_params'] : array();
137 parse_str( $query_raw, $params );
138 foreach ( $params as $key => $_ ) {
139 if ( ! self::query_key_is_ignored( (string) $key, $ignored ) ) {
140 return false;
141 }
142 }
143 }
144
145 $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
146 $path = (string) strtok( $request_uri, '?' );
147
148 $excluded_urls = is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array();
149 if ( Glob_Matcher::any_match( $excluded_urls, $path ) ) {
150 return false;
151 }
152
153 // Cookie-based exclusion. We only check cookie NAMES (matching
154 // values would leak content-sensitive logic into the cache key
155 // rules); presence of any matching cookie name skips cache.
156 $excluded_cookies = is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array();
157 if ( ! empty( $excluded_cookies ) && ! empty( $_COOKIE ) ) {
158 foreach ( array_keys( $_COOKIE ) as $cookie_name ) {
159 if ( Glob_Matcher::any_match( $excluded_cookies, (string) $cookie_name ) ) {
160 return false;
161 }
162 }
163 }
164
165 // User-agent bypass list. Substring match (not glob) since UA
166 // strings have so much variation that glob anchoring rarely
167 // helps and confuses users.
168 $bypass_uas = is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array();
169 if ( ! empty( $bypass_uas ) ) {
170 $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
171 foreach ( $bypass_uas as $needle ) {
172 if ( '' !== $needle && false !== stripos( $ua, (string) $needle ) ) {
173 return false;
174 }
175 }
176 }
177
178 // Per-post override (Phase 3.4). Honored only on singular
179 // post-context requests — archives / 404s / taxonomies use the
180 // global policy above.
181 if ( Cache_Rules::should_skip_for_post( Cache_Rules::current_post_id() ) ) {
182 return false;
183 }
184
185 return true;
186 }
187
188 /**
189 * Is this query-string key on the ignored-params allow-list? Supports
190 * trailing-star globs (`utm_*` matches `utm_source`, `utm_medium`,
191 * etc.) so users don't have to enumerate every UTM variant.
192 */
193 private static function query_key_is_ignored( string $key, array $ignored ): bool {
194 return Glob_Matcher::any_match( $ignored, $key );
195 }
196
197 public static function cache_key() {
198 $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : 'default';
199 $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/';
200 // Strip the query string from the key so /post and /post?utm_*=…
201 // share the same cache entry. should_cache() above already
202 // rejected requests with non-ignored params, so by the time we
203 // build the key the only params left are safe to drop.
204 $uri = (string) strtok( $uri, '?' );
205
206 // Optional device bucket: when mobile_separate is on, mobile and
207 // desktop responses live in different cache files so themes that
208 // serve different HTML by device (AMP, WPtouch, Jetpack mobile)
209 // can't poison each other.
210 $device = '';
211 $opts = Settings_Manager::get( 'cache' );
212 if ( ! empty( $opts['mobile_separate'] ) ) {
213 $device = self::is_mobile_request() ? '|m' : '|d';
214 }
215
216 return md5( $host . $uri . $device );
217 }
218
219 /**
220 * Server-side mobile detection. Prefers WordPress's `wp_is_mobile()`
221 * which uses the same UA tokens as core (so our bucket aligns with
222 * whatever theme-side branching uses). Falls back to a tiny inline
223 * detector if wp_is_mobile() isn't loaded (e.g. the drop-in path).
224 */
225 private static function is_mobile_request(): bool {
226 if ( function_exists( 'wp_is_mobile' ) ) {
227 return (bool) wp_is_mobile();
228 }
229 $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
230 if ( '' === $ua ) {
231 return false;
232 }
233 // Mirrors the token list wp_is_mobile() uses internally.
234 return (bool) preg_match( '/(Mobile|Android|Silk\/|Kindle|BlackBerry|Opera Mini|Opera Mobi)/i', $ua );
235 }
236
237 public static function cache_file_for( $key ) {
238 return XSPEED_CACHE_DIR . '/' . $key . '.html';
239 }
240
241 public static function is_expired( $file ) {
242 // cache_expiry now owned by CacheModule; per-post override
243 // (Phase 3.4) shrinks the TTL further when the editor set one.
244 $opts = Settings_Manager::get( 'cache' );
245 $max_age = (int) $opts['cache_expiry'] * HOUR_IN_SECONDS;
246 $post_override = Cache_Rules::expiry_override_seconds_for_post( Cache_Rules::current_post_id() );
247 if ( null !== $post_override ) {
248 $max_age = $post_override;
249 }
250 return ( time() - filemtime( $file ) ) > $max_age;
251 }
252
253 /**
254 * Accumulator for the full response body across all output-handler phases.
255 *
256 * PHP invokes an ob_start() callback once per flush, and each invocation
257 * only receives the chunk produced *since the previous flush*. If anything
258 * during the render calls `ob_flush()` or `flush()` (some themes, lazy-
259 * load plugins, AMP, etc. do), the final-phase call would otherwise only
260 * see the tail of the page — and we'd cache a truncated response that
261 * gets served repeatedly until purge. We accumulate every chunk here so
262 * the cache file always reflects the complete page.
263 *
264 * @var string
265 */
266 private static $accumulated = '';
267
268 public static function finalize_buffer( $buffer, $phase = PHP_OUTPUT_HANDLER_FINAL ) {
269 self::$accumulated .= $buffer;
270
271 // On non-final phases (mid-request flushes), pass the current chunk
272 // through to the client unmodified and keep collecting. The WP 6.9
273 // filter path always passes the full body in one shot with the
274 // default $phase, so it falls straight through to the final block.
275 $is_final = ( $phase & ( PHP_OUTPUT_HANDLER_FINAL | PHP_OUTPUT_HANDLER_END ) ) !== 0;
276 if ( ! $is_final ) {
277 return $buffer;
278 }
279
280 $full = self::$accumulated;
281 self::$accumulated = '';
282
283 if ( strlen( $full ) < 255 ) {
284 return $buffer;
285 }
286
287 if ( function_exists( 'http_response_code' ) && 200 !== http_response_code() ) {
288 return $buffer;
289 }
290
291 // If no mid-request flush happened, $buffer === $full and we can
292 // safely minify the on-wire bytes too. Otherwise earlier chunks have
293 // already been sent unminified, so we minify only what goes to disk —
294 // the first visitor sees unminified HTML, every cache hit after that
295 // is minified.
296 $single_chunk = ( $buffer === $full );
297
298 // minify_html now owned by the Minify module; read through the
299 // module's storage so this stays consistent with the engine that
300 // applies CSS/JS minification.
301 $minify_opts = Settings_Manager::get( 'minify' );
302 if ( ! empty( $minify_opts['minify_html'] ) ) {
303 $full = Minifier::minify_html( $full );
304 if ( $single_chunk ) {
305 $buffer = $full;
306 }
307 }
308
309 if ( ! file_exists( XSPEED_CACHE_DIR ) ) {
310 wp_mkdir_p( XSPEED_CACHE_DIR );
311 self::write_silence( XSPEED_CACHE_DIR );
312 }
313
314 // Path safety: cache_file_for() builds `XSPEED_CACHE_DIR . '/' . $key . '.html'`
315 // where $key comes from md5() — guaranteed to be exactly 32 lowercase
316 // hex chars, so no traversal sequence ('..', '/', null byte, etc.)
317 // can appear. The write is therefore always inside XSPEED_CACHE_DIR.
318 $file = self::cache_file_for( self::cache_key() );
319 // 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.
320 file_put_contents( $file, $full, LOCK_EX );
321
322 // Static-cache tree (xspeed-static/{host}{path}/index.html). The
323 // .htaccess rewrite block serves this file directly via the web
324 // server, bypassing PHP for ~3-5× lower TTFB vs the drop-in path.
325 // store_static() returns silently on any path/permission issue —
326 // the drop-in remains the safety net.
327 self::store_static( $full );
328
329 return $buffer;
330 }
331
332 /**
333 * Write the current response to the static-cache tree at
334 * `xspeed-static/{host}{request_uri}/index.html`. The web-server
335 * rewrite block points at this path so cache hits skip PHP
336 * entirely. Caller already minified/finalized $html.
337 *
338 * Path safety: $host is restricted to a `[a-zA-Z0-9.\-]` allowlist;
339 * $uri has its query string stripped, null bytes removed, '..'
340 * sequences collapsed, and after concatenation we verify the
341 * resolved real path stays inside XSPEED_CACHE_STATIC_DIR before
342 * any write. Anything off the happy path returns silently.
343 */
344 private static function store_static( string $html ): void {
345 $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
346 $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
347 $host = preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host );
348 $uri = str_replace( "\0", '', $uri );
349 $uri = (string) strtok( $uri, '?' );
350 if ( '' === $host || '' === $uri ) {
351 return;
352 }
353 // Collapse any traversal sequences before path resolution.
354 $uri = preg_replace( '#/+#', '/', $uri );
355 if ( false !== strpos( $uri, '..' ) ) {
356 return;
357 }
358
359 $base = rtrim( XSPEED_CACHE_STATIC_DIR, '/' );
360 $dir = $base . '/' . $host . rtrim( $uri, '/' );
361 $file = $dir . '/index.html';
362
363 // Resolve the parent against the cache root to be sure the
364 // final path is inside our tree even if the OS does anything
365 // funny with multi-byte sequences.
366 $base_real = realpath( WP_CONTENT_DIR );
367 if ( false === $base_real || 0 !== strpos( $base, $base_real ) ) {
368 return;
369 }
370
371 if ( ! file_exists( $dir ) ) {
372 wp_mkdir_p( $dir );
373 }
374 if ( ! is_dir( $dir ) ) {
375 return;
376 }
377 // 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.
378 file_put_contents( $file, $html, LOCK_EX );
379 }
380
381 /**
382 * @param string $cause Free-form human reason. Recorded in the
383 * Activity log to give users context (e.g.
384 * 'post saved', 'settings change', 'manual',
385 * 'theme switch').
386 */
387 public static function purge_all( string $cause = 'manual' ) {
388 $count = 0;
389 if ( is_dir( XSPEED_CACHE_DIR ) ) {
390 $files = glob( XSPEED_CACHE_DIR . '/*.html' );
391 if ( $files ) {
392 $count = count( $files );
393 foreach ( $files as $f ) {
394 wp_delete_file( $f );
395 }
396 }
397 }
398 // Static-cache tree purge — recursive because the layout is
399 // xspeed-static/{host}/{path}/index.html, so a flat glob can't
400 // reach everything.
401 if ( is_dir( XSPEED_CACHE_STATIC_DIR ) ) {
402 $count += self::rmtree_html( XSPEED_CACHE_STATIC_DIR );
403 }
404 self::update_stats( array( 'last_purge' => time() ) );
405
406 // Trigger of WP_CLI / hook / admin-bar purges all hit the same
407 // path. Record once with the supplied cause so the dashboard
408 // activity feed reads naturally.
409 Activity_Log::record(
410 'cache_purged',
411 sprintf( 'Cache purged (%s) — %d file%s removed', $cause, $count, 1 === $count ? '' : 's' ),
412 Activity_Log::INFO
413 );
414 }
415
416 /**
417 * Recursively delete every `index.html` and empty directory inside
418 * the static-cache tree. Used by purge_all(). Returns the number of
419 * .html files removed so purge stats stay accurate across the flat
420 * + static caches.
421 */
422 private static function rmtree_html( string $dir ): int {
423 if ( ! is_dir( $dir ) ) {
424 return 0;
425 }
426 $removed = 0;
427 // SCANDIR_SORT_NONE skips alphabetic sort — we're going to walk
428 // the whole tree regardless of order.
429 $entries = @scandir( $dir, SCANDIR_SORT_NONE ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
430 if ( false === $entries ) {
431 return 0;
432 }
433 foreach ( $entries as $entry ) {
434 if ( '.' === $entry || '..' === $entry ) {
435 continue;
436 }
437 $path = $dir . '/' . $entry;
438 if ( is_dir( $path ) ) {
439 $removed += self::rmtree_html( $path );
440 // Best-effort empty-dir cleanup; ignore failures (a
441 // foreign file inside would block rmdir, which is fine).
442 // 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.
443 @rmdir( $path );
444 continue;
445 }
446 if ( substr( $entry, -5 ) === '.html' ) {
447 wp_delete_file( $path );
448 ++$removed;
449 }
450 }
451 return $removed;
452 }
453
454 /**
455 * Drop a "silence is golden" index.php into a directory so apaches/nginx
456 * with directory listing enabled don't expose cache contents.
457 */
458 public static function write_silence( $dir ) {
459 $file = trailingslashit( $dir ) . 'index.php';
460 if ( ! file_exists( $file ) ) {
461 // 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.
462 file_put_contents( $file, "<?php\n// Silence is golden.\n" );
463 }
464 }
465
466 /**
467 * Persist stats with autoload disabled — stats are only read in admin
468 * contexts, so there is no reason to inflate every frontend request's
469 * `wp_load_alloptions()` payload.
470 */
471 private static function update_stats( array $stats ) {
472 if ( false === get_option( 'xspeed_stats' ) ) {
473 add_option( 'xspeed_stats', $stats, '', 'no' );
474 return;
475 }
476 update_option( 'xspeed_stats', $stats );
477 }
478
479 public static function get_stats() {
480 $count = 0;
481 $size = 0;
482 if ( is_dir( XSPEED_CACHE_DIR ) ) {
483 $files = glob( XSPEED_CACHE_DIR . '/*.html' );
484 if ( $files ) {
485 $count = count( $files );
486 foreach ( $files as $f ) {
487 $size += filesize( $f );
488 }
489 }
490 }
491 // Drain the nginx HIT-log file written by the server-level
492 // rewrite (see nginx_snippet()) BEFORE reading totals — otherwise
493 // nginx-served HITs that never reach PHP go uncounted and the
494 // dashboard reports 0% hit-ratio on a perfectly working cache.
495 Hit_Counter::collect_nginx_log_hits();
496
497 $stats = get_option( 'xspeed_stats', array() );
498 $totals = Hit_Counter::totals_24h();
499 return array(
500 'cached_pages' => $count,
501 'cache_size' => $size,
502 'last_purge' => isset( $stats['last_purge'] ) ? (int) $stats['last_purge'] : 0,
503 // Rolling 24h cache performance — sourced from Hit_Counter's
504 // hourly buckets. The frontend uses hit_ratio to drive the
505 // CacheHero stat grid + the Health module's panel.
506 'hits_24h' => $totals['hits'],
507 'misses_24h' => $totals['misses'],
508 'hit_ratio' => $totals['ratio'],
509 );
510 }
511
512 /**
513 * Apply the user's enable/disable choice. Called only from the REST
514 * toggle endpoint, which is gated by current_user_can( 'manage_options' )
515 * and a verified REST nonce. This is the only place the drop-in and
516 * the WP_CACHE constant are written — they MUST NOT happen on
517 * register_activation_hook (WordPress.org review requirement).
518 *
519 * @param bool $enable User's choice.
520 * @return array{
521 * enabled: bool,
522 * dropin_installed: bool,
523 * wp_cache_constant: bool,
524 * wp_config_writable: bool,
525 * manual_snippet: ?string
526 * }
527 */
528 public static function toggle( $enable ) {
529 $enable = (bool) $enable;
530
531 if ( $enable ) {
532 $dropin_ok = self::install_dropin();
533 $wp_config_ok = self::set_wp_cache_constant( true );
534 $rewrite_ok = self::install_rewrite();
535 self::ensure_hits_log_file();
536 $snippet = $wp_config_ok ? null : "define( 'WP_CACHE', true );";
537
538 Activity_Log::record(
539 'cache_enabled_event',
540 $wp_config_ok
541 ? 'Cache enabled. Drop-in installed, WP_CACHE constant set.'
542 : 'Cache enabled. Drop-in installed; wp-config.php not writable — add the WP_CACHE snippet manually.',
543 $wp_config_ok ? Activity_Log::SUCCESS : Activity_Log::WARN
544 );
545
546 return array(
547 'enabled' => true,
548 'dropin_installed' => (bool) $dropin_ok,
549 'wp_cache_constant' => (bool) $wp_config_ok,
550 'rewrite_installed' => (bool) $rewrite_ok,
551 'wp_config_writable' => self::wp_config_writable(),
552 'manual_snippet' => $snippet,
553 'nginx_snippet' => self::nginx_snippet(),
554 );
555 }
556
557 self::remove_dropin();
558 self::set_wp_cache_constant( false );
559 self::remove_rewrite();
560
561 Activity_Log::record(
562 'cache_disabled_event',
563 'Cache disabled. Drop-in removed.',
564 Activity_Log::INFO
565 );
566
567 return array(
568 'enabled' => false,
569 'dropin_installed' => false,
570 'wp_cache_constant' => false,
571 'rewrite_installed' => false,
572 'wp_config_writable' => self::wp_config_writable(),
573 'manual_snippet' => null,
574 'nginx_snippet' => self::nginx_snippet(),
575 );
576 }
577
578 /**
579 * Check wp-config.php writability via WP_Filesystem. Plugin Check flags
580 * direct is_writable() under WordPress.WP.AlternativeFunctions.
581 */
582 private static function wp_config_writable() {
583 global $wp_filesystem;
584 if ( ! function_exists( 'WP_Filesystem' ) ) {
585 require_once ABSPATH . 'wp-admin/includes/file.php';
586 }
587 WP_Filesystem();
588
589 return $wp_filesystem ? (bool) $wp_filesystem->is_writable( ABSPATH . 'wp-config.php' ) : false;
590 }
591
592 /**
593 * Nginx server-block snippet mirroring the Apache rewrite block.
594 * We never auto-write nginx config — it sits outside the WordPress
595 * root and is owned by the server admin — but the dashboard
596 * surfaces this snippet when nginx is detected so the admin can
597 * paste it once and unlock the same PHP-bypass speedup we get on
598 * Apache / LiteSpeed via .htaccess.
599 *
600 * Returns null when the server isn't nginx (no point showing it).
601 */
602 /**
603 * Create wp-content/cache/xspeed/hits.log as an empty file so the
604 * server-level rewrite's `access_log` directive has somewhere to
605 * write on first request. Idempotent — touches an existing file
606 * without disturbing accumulated lines. Called from Cache::toggle()
607 * on enable and from auto_heal() when the file is missing.
608 *
609 * Permissions matter here. The file is created by PHP-FPM (often uid
610 * www-data), but the nginx process that appends HIT lines may run as a
611 * DIFFERENT uid — on multi-container hosts (e.g. xclude/Kinsta: nginx in
612 * its own container as uid `nginx`, PHP-FPM in another as `www-data`)
613 * they don't share a user at all. A default-umask 0644 file is then
614 * unwritable by nginx, the access_log write silently fails, and the
615 * dashboard shows a 0% hit ratio even though static HITs are serving.
616 * So we widen the dir to 0777 and the file to 0666 — group/other write —
617 * so whatever uid nginx runs as can append. (The file holds only HIT
618 * request lines, no secrets.)
619 */
620 public static function ensure_hits_log_file(): bool {
621 $dir = XSPEED_CACHE_DIR;
622 if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
623 return false;
624 }
625 // Ensure the dir is traversable + writable by a different-uid nginx.
626 // 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.
627 @chmod( $dir, 0777 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort; the access_log just stays empty if it fails.
628 $path = $dir . '/hits.log';
629 if ( ! file_exists( $path ) ) {
630 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch -- See docblock: must be a plain touch, not WP_Filesystem.
631 @touch( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- non-fatal helper; failures already covered by the dir check.
632 }
633 // World-writable so a different-uid nginx can append HIT lines.
634 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- See docblock.
635 @chmod( $path, 0666 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort.
636 return file_exists( $path );
637 }
638
639 public static function nginx_snippet(): ?string {
640 if ( Server::NGINX !== Server::type() ) {
641 return null;
642 }
643 $rel = '/' . ltrim( str_replace( ABSPATH, '/', XSPEED_CACHE_STATIC_DIR ), '/' );
644 $rel = rtrim( $rel, '/' );
645
646 // WP-Rocket-canonical pattern: every condition lives at
647 // SERVER level (outside any location block). Each one appends
648 // a tag to $xspeed_no_cache; the final check is a single
649 // string-equality against the unmodified default "no-cache".
650 // Only when ALL conditions pass does the rewrite fire,
651 // jumping the request to the static file's URL. nginx then
652 // restarts location matching against the new path, where
653 // regular static-file serving takes over.
654 //
655 // Why server-level + a single rewrite (instead of try_files
656 // inside `location /`): nginx's well-documented "if is evil"
657 // quirk silently disables `try_files`'s last fallback when
658 // any `if` in the same location is true. Moving the `if`s
659 // outside any location dodges the trap completely, because
660 // server-level rewrite is the documented stable path.
661 //
662 // `last` (not `break`) restarts location matching — required
663 // so the rewritten static-file URI gets served via the normal
664 // static-file location, not re-matched against `location /`
665 // where our own rewrite would loop.
666 //
667 // The cache existence check is the LAST condition in the
668 // chain so when the file isn't cached, $xspeed_no_cache
669 // gets a "-nofile" tag and the rewrite is skipped — the
670 // request falls through to whatever `location /` the user
671 // already had (typically `try_files $uri $uri/ /index.php?$args;`).
672 // Absolute path to the hit-log file from the nginx process's
673 // filesystem view. Nginx's `access_log buffer=N flush=Ns` form
674 // requires a literal path — `$document_root` variables are
675 // rejected — so we emit `WP_CONTENT_DIR/cache/xspeed/hits.log`
676 // computed by PHP. Works on every topology where the nginx
677 // process shares a filesystem with PHP (container or host).
678 $hits_abs = WP_CONTENT_DIR . '/cache/xspeed/hits.log';
679
680 $lines = array();
681 $lines[] = '# xSpeed static cache — paste at SERVER level (inside `server { }`,';
682 $lines[] = '# above your existing `location / { … }`; do NOT put it inside any';
683 $lines[] = '# location block).';
684 $lines[] = 'set $xspeed_no_cache "no-cache";';
685 $lines[] = 'if ($request_method != GET) { set $xspeed_no_cache "$xspeed_no_cache-method"; }';
686 $lines[] = 'if ($args) { set $xspeed_no_cache "$xspeed_no_cache-args"; }';
687 $lines[] = 'if ($http_cookie ~* "(wordpress_logged_in|comment_author|wp-postpass_)") { set $xspeed_no_cache "$xspeed_no_cache-cookie"; }';
688 $lines[] = 'if (!-f "$document_root' . $rel . '/$host$uri/index.html") { set $xspeed_no_cache "$xspeed_no_cache-nofile"; }';
689 // Neither `add_header` nor `access_log` is allowed inside an `if{}`
690 // at server level (nginx rejects with "directive is not allowed
691 // here"). The logging therefore lives in a `location` block that
692 // matches the rewritten URI after `rewrite … last;` restarts
693 // location matching. Every HIT lands there exactly once, every
694 // MISS / PHP-served request never matches it.
695 $lines[] = 'if ($xspeed_no_cache = "no-cache") {';
696 $lines[] = ' rewrite ^ ' . $rel . '/$host$uri/index.html last;';
697 $lines[] = '}';
698 $lines[] = '';
699 $lines[] = '# Serve + log the cached HIT. The `^~` modifier is REQUIRED:';
700 $lines[] = '# the rewrite above lands on a `…/index.html` URI, and nginx';
701 $lines[] = '# matches regex locations (e.g. a `~* \.html$` block a vhost';
702 $lines[] = '# commonly has) BEFORE plain prefix locations. Without `^~`,';
703 $lines[] = '# that regex block wins, this location never matches, the HIT';
704 $lines[] = '# is never logged, and on some vhosts the request falls through';
705 $lines[] = '# to PHP — i.e. the static rewrite silently does nothing.';
706 $lines[] = '# `^~` makes this prefix match beat any regex location, so the';
707 $lines[] = '# rewritten request always serves here from disk and logs once.';
708 $lines[] = 'location ^~ ' . $rel . '/ {';
709 $lines[] = ' internal;';
710 $lines[] = ' access_log ' . $hits_abs . ' combined buffer=16k flush=10s;';
711 $lines[] = ' # Visible HIT indicator for the fast path: this file was';
712 $lines[] = ' # served directly by nginx from xSpeed\'s static cache,';
713 $lines[] = ' # bypassing PHP entirely. The PHP drop-in sends the same';
714 $lines[] = ' # header with value "HIT (php)" on its slower fallback path.';
715 $lines[] = ' add_header X-XSpeed-Cache "HIT (nginx)" always;';
716 $lines[] = '}';
717 return implode( "\n", $lines );
718 }
719
720 /**
721 * Aggregate every enabled module's nginx_directives() into one
722 * pasteable server-block snippet. Replaces the per-module "paste
723 * this snippet" notices with a single consolidated paste — every
724 * future feature toggle just regenerates this output.
725 *
726 * Returns null on non-nginx hosts (nothing to paste).
727 *
728 * Sections render in module-registration order so the layout stays
729 * predictable; each module gets a comment header `# <slug>`.
730 */
731 public static function full_nginx_server_block(): ?string {
732 if ( Server::NGINX !== Server::type() ) {
733 return null;
734 }
735
736 $blocks = array();
737 foreach ( Module_Registry::all() as $module ) {
738 $directives = $module->nginx_directives();
739 if ( ! is_string( $directives ) || '' === trim( $directives ) ) {
740 continue;
741 }
742 $blocks[] = "# === " . $module->slug() . " ===\n" . rtrim( $directives );
743 }
744
745 if ( empty( $blocks ) ) {
746 return null;
747 }
748
749 $header = "# xSpeed unified nginx config — paste once into your\n"
750 . "# nginx vhost's `server { }` block (or container nginx\n"
751 . "# config for containerized hosts), above `location / { }`.\n"
752 . "# Regenerated on every dashboard load — re-paste after\n"
753 . "# toggling features so the directives reflect current state.\n";
754
755 return $header . "\n" . implode( "\n\n", $blocks ) . "\n";
756 }
757
758 /**
759 * Emit LiteSpeed Cache module headers on the cache-miss render
760 * path so the server caches the response and serves subsequent
761 * requests at edge speed without booting PHP again.
762 *
763 * LSCache reads two response headers:
764 * - X-LiteSpeed-Cache-Control: public,max-age=N → "cache for N s"
765 * - X-LiteSpeed-Tag: tag1,tag2 → tag the entry for selective
766 * purge later via X-LiteSpeed-Purge in any later response.
767 *
768 * Server detection runs through Server::type() so a non-LiteSpeed
769 * host (Apache / nginx / IIS) sees a no-op — the headers are
770 * harmless if emitted there, but we skip them to keep response
771 * headers tidy. The conflict check defers to the LiteSpeed Cache
772 * plugin when present so we don't double-cache.
773 */
774 public static function maybe_emit_lscache_headers(): void {
775 if ( headers_sent() ) {
776 return;
777 }
778 if ( Server::LITESPEED !== Server::type() ) {
779 return;
780 }
781 // is_plugin_active() lives in wp-admin/includes/plugin.php which
782 // isn't auto-loaded on front-end requests. Use the option layer
783 // directly to avoid pulling in admin code from a render path.
784 $active = (array) get_option( 'active_plugins', array() );
785 if ( in_array( 'litespeed-cache/litespeed-cache.php', $active, true ) ) {
786 return;
787 }
788
789 $opts = Settings_Manager::get( 'cache' );
790 $expiry = isset( $opts['cache_expiry'] ) ? (int) $opts['cache_expiry'] : DAY_IN_SECONDS;
791 $expiry = max( 60, min( $expiry, 30 * DAY_IN_SECONDS ) );
792
793 // Tags scope the entry so a single post change can purge just
794 // that page (or its archive) instead of the whole cache. We
795 // always send the global `xspeed` tag plus a path-derived one.
796 $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/';
797 $path_tag = 'xspeed_' . md5( (string) strtok( $request_uri, '?' ) );
798
799 header( 'X-LiteSpeed-Cache-Control: public,max-age=' . $expiry );
800 header( 'X-LiteSpeed-Tag: xspeed,' . $path_tag );
801 }
802
803 /**
804 * Reconcile drop-in + WP_CACHE + rewrite block with the user's
805 * saved choice. Runs on admin_init. Cheap when nothing's wrong
806 * (one option read + a handful of file_exists / defined checks);
807 * writes only when state has drifted (typical cause: plugin
808 * upgrade wiped the drop-in, foreign plugin removed our WP_CACHE
809 * define, or someone hand-edited .htaccess).
810 *
811 * Skipped during the WP plugin updater run so we don't race
812 * the upgrader's own filesystem operations.
813 */
814 public static function auto_heal(): void {
815 if ( defined( 'WP_INSTALLING' ) && WP_INSTALLING ) {
816 return;
817 }
818 if ( wp_doing_ajax() || wp_doing_cron() ) {
819 return;
820 }
821
822 $opts = get_option( 'xspeed_options', array() );
823 if ( empty( $opts['cache_enabled'] ) ) {
824 return;
825 }
826
827 $dropin_target = WP_CONTENT_DIR . '/advanced-cache.php';
828 $dropin_ours = false;
829 if ( file_exists( $dropin_target ) ) {
830 $contents = @file_get_contents( $dropin_target ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
831 $dropin_ours = is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' );
832 }
833
834 if ( ! $dropin_ours ) {
835 self::install_dropin();
836 }
837
838 if ( ! defined( 'WP_CACHE' ) || ! WP_CACHE ) {
839 self::set_wp_cache_constant( true );
840 }
841
842 // Rewrite block goes last. It's what turns the static-cache
843 // tree into a PHP-bypass — every cache hit served by the web
844 // server directly. Without it we still cache, just at drop-in
845 // speed (~85ms TTFB) instead of static-file speed (~25-40ms).
846 if ( ! self::rewrite_installed() ) {
847 self::install_rewrite();
848 }
849
850 // HITs log file — nginx writes one line per HIT served directly
851 // (see nginx_snippet()), Cache::get_stats() drains the file via
852 // Hit_Counter::collect_nginx_log_hits(). If the file vanishes
853 // (plugin upgrade wiped wp-content/cache/), nginx errors silently
854 // on the access_log directive and the counter stays at 0.
855 self::ensure_hits_log_file();
856 }
857
858 /**
859 * Build the .htaccess rules that map cacheable requests to the
860 * static-cache tree. Conditions are deliberately strict: GET only,
861 * empty query string, no session/comment-author/post-password
862 * cookie, and the static file must exist on disk. Anything that
863 * fails one of these falls through to PHP and the drop-in / full
864 * WordPress path.
865 *
866 * @return string[] Lines for insert_with_markers().
867 */
868 public static function rewrite_block_lines(): array {
869 // Path relative to ABSPATH so the rule lives in the site-root
870 // .htaccess regardless of where wp-content sits. WP_CONTENT_DIR
871 // can be moved, so we compute the document-root-relative form
872 // at install time and bake it into the rule.
873 $rel = str_replace( ABSPATH, '/', XSPEED_CACHE_STATIC_DIR );
874 $rel = '/' . ltrim( $rel, '/' );
875 $rel = rtrim( $rel, '/' );
876
877 return array(
878 '<IfModule mod_rewrite.c>',
879 ' RewriteEngine On',
880 ' RewriteCond %{REQUEST_METHOD} ^GET$',
881 ' RewriteCond %{QUERY_STRING} ^$',
882 ' RewriteCond %{HTTP_COOKIE} !(wordpress_logged_in|comment_author|wp-postpass_) [NC]',
883 // Capture REQUEST_URI without its trailing slash into %1.
884 // store_static() writes `{host}{uri-without-trailing-slash}/index.html`,
885 // so this normalization lets `/blog/` and `/blog` both hit
886 // the same cache file without producing the double-slash
887 // path that would skip the -f check below.
888 ' RewriteCond %{REQUEST_URI} ^(.*?)/?$',
889 ' RewriteCond %{DOCUMENT_ROOT}' . $rel . '/%{HTTP_HOST}%1/index.html -f',
890 ' RewriteRule . ' . $rel . '/%{HTTP_HOST}%1/index.html [L]',
891 '</IfModule>',
892 );
893 }
894
895 /**
896 * Active probe that confirms the web-server static-rewrite path is
897 * actually serving cached files. Writes a probe file with a random
898 * nonce, fetches it over HTTP at its public URL, and checks whether
899 * the response was served directly by the web server (Last-Modified
900 * + ETag headers + no X-Powered-By: PHP).
901 *
902 * Server-agnostic: same probe works for nginx (snippet pasted) and
903 * Apache / LiteSpeed (.htaccess block installed). If the rewrite
904 * isn't engaged, the request falls through to WordPress and PHP
905 * adds its own headers, which the probe detects and reports.
906 *
907 * Throttled via a 5-minute transient — we never want this running
908 * on every Health card paint.
909 *
910 * @return array{active:bool, reason:string, code?:int, php?:bool, expires?:int}
911 */
912 public static function probe_static_rewrite(): array {
913 $cached = get_transient( 'xspeed_rewrite_probe' );
914 if ( is_array( $cached ) ) {
915 return $cached;
916 }
917
918 $home = home_url( '/' );
919 $host = (string) wp_parse_url( $home, PHP_URL_HOST );
920 if ( '' === $host ) {
921 $result = array( 'active' => false, 'reason' => 'home_url has no host' );
922 set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
923 return $result;
924 }
925
926 // Use a randomised path AND nonce so a stale CDN cache entry
927 // from a prior probe can never make a broken install look
928 // healthy. Path is namespaced under __xspeed_probe__ so the
929 // directory listing stays obvious if cleanup misfires.
930 $slug = wp_generate_password( 12, false, false );
931 $nonce = wp_generate_password( 24, false, false );
932 $probe_dir = XSPEED_CACHE_STATIC_DIR . '/' . $host . '/__xspeed_probe__/' . $slug;
933 $probe_file = $probe_dir . '/index.html';
934 $probe_url = trailingslashit( $home ) . '__xspeed_probe__/' . $slug . '/';
935
936 if ( ! file_exists( $probe_dir ) ) {
937 wp_mkdir_p( $probe_dir );
938 }
939 if ( ! is_dir( $probe_dir ) ) {
940 $result = array( 'active' => false, 'reason' => 'cannot create probe dir' );
941 set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
942 return $result;
943 }
944 // 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.
945 file_put_contents( $probe_file, $nonce, LOCK_EX );
946
947 $resp = wp_remote_get(
948 $probe_url,
949 array(
950 'timeout' => 4,
951 'sslverify' => false,
952 'redirection' => 0,
953 'headers' => array( 'Cache-Control' => 'no-cache' ),
954 )
955 );
956
957 // Best-effort cleanup so we don't accumulate probe dirs even
958 // if subsequent calls all hit the transient.
959 if ( file_exists( $probe_file ) ) {
960 wp_delete_file( $probe_file );
961 }
962 if ( is_dir( $probe_dir ) ) {
963 // 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.
964 @rmdir( $probe_dir );
965 }
966
967 if ( is_wp_error( $resp ) ) {
968 $result = array(
969 'active' => false,
970 'reason' => 'http error: ' . $resp->get_error_message(),
971 );
972 set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
973 return $result;
974 }
975
976 $code = (int) wp_remote_retrieve_response_code( $resp );
977 $body = (string) wp_remote_retrieve_body( $resp );
978 $ua_php = '' !== (string) wp_remote_retrieve_header( $resp, 'x-powered-by' );
979 $has_etag = '' !== (string) wp_remote_retrieve_header( $resp, 'etag' )
980 || '' !== (string) wp_remote_retrieve_header( $resp, 'last-modified' );
981 $match = trim( $body ) === $nonce;
982
983 // "Active" = the web server served our raw nonce bytes back
984 // AND emitted the static-serve markers (ETag / Last-Modified)
985 // AND didn't add an X-Powered-By: PHP header. All three are
986 // individually noisy; together they're conclusive.
987 $active = $match && $has_etag && ! $ua_php && 200 === $code;
988
989 if ( $active ) {
990 $reason = 'static-served';
991 } elseif ( 200 === $code && $match && $ua_php ) {
992 $reason = 'php served the file instead of nginx/Apache (rewrite block missing)';
993 } elseif ( 200 === $code && ! $match ) {
994 $reason = 'unexpected body (CDN cached an older response?)';
995 } elseif ( 404 === $code ) {
996 $reason = 'probe URL returned 404 (rewrite block missing or wrong path)';
997 } else {
998 $reason = sprintf( 'unexpected response (HTTP %d, body %d B, php=%s)', $code, strlen( $body ), $ua_php ? 'yes' : 'no' );
999 }
1000
1001 $result = array(
1002 'active' => $active,
1003 'reason' => $reason,
1004 'code' => $code,
1005 'php' => $ua_php,
1006 );
1007 set_transient( 'xspeed_rewrite_probe', $result, 5 * MINUTE_IN_SECONDS );
1008 return $result;
1009 }
1010
1011 public static function rewrite_installed(): bool {
1012 $htaccess = ABSPATH . '.htaccess';
1013 if ( ! file_exists( $htaccess ) ) {
1014 return false;
1015 }
1016 $existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
1017 if ( ! is_string( $existing ) ) {
1018 return false;
1019 }
1020 return false !== strpos( $existing, '# BEGIN xSpeed Static Cache' );
1021 }
1022
1023 /**
1024 * Install the static-cache rewrite block at the TOP of .htaccess.
1025 *
1026 * Position matters: WordPress's own block ends with
1027 * `RewriteRule . /index.php [L]` which routes every non-file
1028 * request to PHP. The [L] flag stops the current rewrite pass,
1029 * but Apache restarts the cycle; on the second pass REQUEST_URI
1030 * is /index.php and no static-file check can match. The only
1031 * reliable position for a "serve static if it exists" rule is
1032 * before WordPress's block.
1033 *
1034 * WP's insert_with_markers() always appends, so we manage the
1035 * block manually: strip any prior xSpeed Static Cache markers,
1036 * then write our block followed by the rest of the file.
1037 */
1038 public static function install_rewrite(): bool {
1039 $htaccess = ABSPATH . '.htaccess';
1040 $existing = file_exists( $htaccess ) ? @file_get_contents( $htaccess ) : ''; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
1041 if ( false === $existing ) {
1042 $existing = '';
1043 }
1044 // Apache/LiteSpeed only. nginx hosts: rule won't fire, drop-in
1045 // covers; we skip the write so we don't litter their root.
1046 // 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.
1047 if ( file_exists( $htaccess ) && ! is_writable( $htaccess ) ) {
1048 return false;
1049 }
1050 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- See above.
1051 if ( ! file_exists( $htaccess ) && ! is_writable( ABSPATH ) ) {
1052 return false;
1053 }
1054
1055 $cleaned = self::strip_marker_block( $existing, 'xSpeed Static Cache' );
1056 $block = self::marker_block( 'xSpeed Static Cache', self::rewrite_block_lines() );
1057 $next = $block . ( '' === $cleaned ? '' : "\n" . $cleaned );
1058
1059 // 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.
1060 return false !== file_put_contents( $htaccess, $next, LOCK_EX );
1061 }
1062
1063 public static function remove_rewrite(): bool {
1064 $htaccess = ABSPATH . '.htaccess';
1065 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- See install_rewrite() rationale.
1066 if ( ! file_exists( $htaccess ) || ! is_writable( $htaccess ) ) {
1067 return false;
1068 }
1069 $existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
1070 if ( false === $existing ) {
1071 return false;
1072 }
1073 $cleaned = self::strip_marker_block( $existing, 'xSpeed Static Cache' );
1074 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents, PluginCheck.CodeAnalysis.WriteFile.ABSPATHDetected -- See install_rewrite() rationale.
1075 return false !== file_put_contents( $htaccess, $cleaned, LOCK_EX );
1076 }
1077
1078 /**
1079 * Strip a `# BEGIN <marker>` ... `# END <marker>` block from a
1080 * .htaccess-style file, including any blank line that immediately
1081 * follows it. Idempotent — returns the input unchanged if the
1082 * marker isn't present.
1083 */
1084 private static function strip_marker_block( string $contents, string $marker ): string {
1085 $pattern = '/# BEGIN ' . preg_quote( $marker, '/' ) . '\b.*?# END ' . preg_quote( $marker, '/' ) . "\b[^\n]*\n?\n?/s";
1086 $out = preg_replace( $pattern, '', $contents );
1087 return is_string( $out ) ? $out : $contents;
1088 }
1089
1090 private static function marker_block( string $marker, array $lines ): string {
1091 $header = "# BEGIN $marker\n";
1092 $header .= "# The directives (lines) between \"BEGIN $marker\" and \"END $marker\" are\n";
1093 $header .= "# dynamically generated, and should only be modified via WordPress filters.\n";
1094 $header .= "# Any changes to the directives between these markers will be overwritten.\n";
1095 $footer = "# END $marker\n";
1096 return $header . implode( "\n", $lines ) . "\n" . $footer;
1097 }
1098
1099 public static function install_dropin() {
1100 $source = XSPEED_DIR . 'includes/advanced-cache.php';
1101 $target = WP_CONTENT_DIR . '/advanced-cache.php';
1102 if ( ! file_exists( $source ) ) {
1103 return false;
1104 }
1105
1106 global $wp_filesystem;
1107 if ( ! function_exists( 'WP_Filesystem' ) ) {
1108 require_once ABSPATH . 'wp-admin/includes/file.php';
1109 }
1110 WP_Filesystem();
1111 if ( ! $wp_filesystem ) {
1112 return false;
1113 }
1114
1115 $source_contents = $wp_filesystem->get_contents( $source );
1116 if ( ! is_string( $source_contents ) ) {
1117 return false;
1118 }
1119
1120 if ( file_exists( $target ) ) {
1121 $existing = $wp_filesystem->get_contents( $target );
1122 $is_xspeed = is_string( $existing ) && false !== strpos( $existing, 'XSPEED_DROPIN' );
1123
1124 if ( $is_xspeed ) {
1125 if ( $existing === $source_contents ) {
1126 return true;
1127 }
1128 return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE );
1129 }
1130
1131 // Foreign drop-in (e.g. left over from another cache plugin) — back it up
1132 // before overwriting so the user can recover if needed. Uploads dir
1133 // (not wp-content root) keeps the backup out of WordPress's reserved
1134 // drop-in location.
1135 $upload = wp_upload_dir( null, false );
1136 $basedir = isset( $upload['basedir'] ) ? trailingslashit( $upload['basedir'] ) . 'xspeed-backups' : false;
1137 if ( $basedir ) {
1138 if ( ! file_exists( $basedir ) ) {
1139 wp_mkdir_p( $basedir );
1140 self::write_silence( $basedir );
1141 }
1142 $backup = $basedir . '/advanced-cache.foreign-' . gmdate( 'Ymd-His' ) . '.php.bak';
1143 $wp_filesystem->move( $target, $backup, true );
1144 } else {
1145 $wp_filesystem->delete( $target );
1146 }
1147 }
1148
1149 return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE );
1150 }
1151
1152 public static function remove_dropin() {
1153 $target = WP_CONTENT_DIR . '/advanced-cache.php';
1154 if ( ! file_exists( $target ) ) {
1155 return;
1156 }
1157
1158 global $wp_filesystem;
1159 if ( ! function_exists( 'WP_Filesystem' ) ) {
1160 require_once ABSPATH . 'wp-admin/includes/file.php';
1161 }
1162 WP_Filesystem();
1163 if ( ! $wp_filesystem ) {
1164 return;
1165 }
1166
1167 $contents = $wp_filesystem->get_contents( $target );
1168 if ( is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' ) ) {
1169 wp_delete_file( $target );
1170 }
1171 }
1172
1173 public static function set_wp_cache_constant( $enable ) {
1174 $wp_config = ABSPATH . 'wp-config.php';
1175 if ( ! file_exists( $wp_config ) ) {
1176 return false;
1177 }
1178
1179 global $wp_filesystem;
1180 if ( ! function_exists( 'WP_Filesystem' ) ) {
1181 require_once ABSPATH . 'wp-admin/includes/file.php';
1182 }
1183 WP_Filesystem();
1184 if ( ! $wp_filesystem || ! $wp_filesystem->is_writable( $wp_config ) ) {
1185 return false;
1186 }
1187
1188 $config = $wp_filesystem->get_contents( $wp_config );
1189
1190 if ( $enable ) {
1191 if ( strpos( $config, "define( 'WP_CACHE'" ) !== false || strpos( $config, "define('WP_CACHE'" ) !== false ) {
1192 return true;
1193 }
1194 $config = preg_replace( '/(<\?php)/', "$1\ndefine( 'WP_CACHE', true );", $config, 1 );
1195 } else {
1196 $config = preg_replace( "/define\\(\\s*['\"]WP_CACHE['\"]\\s*,\\s*true\\s*\\);\\s*\\n?/", '', $config );
1197 }
1198
1199 return (bool) $wp_filesystem->put_contents( $wp_config, $config, FS_CHMOD_FILE );
1200 }
1201
1202 public function admin_bar_purge( $wp_admin_bar ) {
1203 if ( ! current_user_can( 'manage_options' ) ) {
1204 return;
1205 }
1206 $wp_admin_bar->add_node(
1207 array(
1208 'id' => 'xspeed-purge',
1209 'title' => __( 'Purge xSpeed Cache', 'xspeed' ),
1210 'href' => wp_nonce_url( admin_url( 'admin-post.php?action=xspeed_purge' ), 'xspeed_purge' ),
1211 )
1212 );
1213 }
1214
1215 public function handle_admin_bar_purge() {
1216 if ( ! current_user_can( 'manage_options' ) ) {
1217 wp_die( esc_html__( 'Unauthorized.', 'xspeed' ), 403 );
1218 }
1219 check_admin_referer( 'xspeed_purge' );
1220 self::purge_all();
1221 wp_safe_redirect( wp_get_referer() ?: admin_url() );
1222 exit;
1223 }
1224 }
1225