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

1,103 lines 42.2 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 $stats = get_option( 'xspeed_stats', array() );
492 $totals = Hit_Counter::totals_24h();
493 return array(
494 'cached_pages' => $count,
495 'cache_size' => $size,
496 'last_purge' => isset( $stats['last_purge'] ) ? (int) $stats['last_purge'] : 0,
497 // Rolling 24h cache performance — sourced from Hit_Counter's
498 // hourly buckets. The frontend uses hit_ratio to drive the
499 // CacheHero stat grid + the Health module's panel.
500 'hits_24h' => $totals['hits'],
501 'misses_24h' => $totals['misses'],
502 'hit_ratio' => $totals['ratio'],
503 );
504 }
505
506 /**
507 * Apply the user's enable/disable choice. Called only from the REST
508 * toggle endpoint, which is gated by current_user_can( 'manage_options' )
509 * and a verified REST nonce. This is the only place the drop-in and
510 * the WP_CACHE constant are written — they MUST NOT happen on
511 * register_activation_hook (WordPress.org review requirement).
512 *
513 * @param bool $enable User's choice.
514 * @return array{
515 * enabled: bool,
516 * dropin_installed: bool,
517 * wp_cache_constant: bool,
518 * wp_config_writable: bool,
519 * manual_snippet: ?string
520 * }
521 */
522 public static function toggle( $enable ) {
523 $enable = (bool) $enable;
524
525 if ( $enable ) {
526 $dropin_ok = self::install_dropin();
527 $wp_config_ok = self::set_wp_cache_constant( true );
528 $rewrite_ok = self::install_rewrite();
529 $snippet = $wp_config_ok ? null : "define( 'WP_CACHE', true );";
530
531 Activity_Log::record(
532 'cache_enabled_event',
533 $wp_config_ok
534 ? 'Cache enabled. Drop-in installed, WP_CACHE constant set.'
535 : 'Cache enabled. Drop-in installed; wp-config.php not writable — add the WP_CACHE snippet manually.',
536 $wp_config_ok ? Activity_Log::SUCCESS : Activity_Log::WARN
537 );
538
539 return array(
540 'enabled' => true,
541 'dropin_installed' => (bool) $dropin_ok,
542 'wp_cache_constant' => (bool) $wp_config_ok,
543 'rewrite_installed' => (bool) $rewrite_ok,
544 'wp_config_writable' => self::wp_config_writable(),
545 'manual_snippet' => $snippet,
546 'nginx_snippet' => self::nginx_snippet(),
547 );
548 }
549
550 self::remove_dropin();
551 self::set_wp_cache_constant( false );
552 self::remove_rewrite();
553
554 Activity_Log::record(
555 'cache_disabled_event',
556 'Cache disabled. Drop-in removed.',
557 Activity_Log::INFO
558 );
559
560 return array(
561 'enabled' => false,
562 'dropin_installed' => false,
563 'wp_cache_constant' => false,
564 'rewrite_installed' => false,
565 'wp_config_writable' => self::wp_config_writable(),
566 'manual_snippet' => null,
567 'nginx_snippet' => self::nginx_snippet(),
568 );
569 }
570
571 /**
572 * Check wp-config.php writability via WP_Filesystem. Plugin Check flags
573 * direct is_writable() under WordPress.WP.AlternativeFunctions.
574 */
575 private static function wp_config_writable() {
576 global $wp_filesystem;
577 if ( ! function_exists( 'WP_Filesystem' ) ) {
578 require_once ABSPATH . 'wp-admin/includes/file.php';
579 }
580 WP_Filesystem();
581
582 return $wp_filesystem ? (bool) $wp_filesystem->is_writable( ABSPATH . 'wp-config.php' ) : false;
583 }
584
585 /**
586 * Nginx server-block snippet mirroring the Apache rewrite block.
587 * We never auto-write nginx config — it sits outside the WordPress
588 * root and is owned by the server admin — but the dashboard
589 * surfaces this snippet when nginx is detected so the admin can
590 * paste it once and unlock the same PHP-bypass speedup we get on
591 * Apache / LiteSpeed via .htaccess.
592 *
593 * Returns null when the server isn't nginx (no point showing it).
594 */
595 public static function nginx_snippet(): ?string {
596 if ( Server::NGINX !== Server::type() ) {
597 return null;
598 }
599 $rel = '/' . ltrim( str_replace( ABSPATH, '/', XSPEED_CACHE_STATIC_DIR ), '/' );
600 $rel = rtrim( $rel, '/' );
601
602 // WP-Rocket-canonical pattern: every condition lives at
603 // SERVER level (outside any location block). Each one appends
604 // a tag to $xspeed_no_cache; the final check is a single
605 // string-equality against the unmodified default "no-cache".
606 // Only when ALL conditions pass does the rewrite fire,
607 // jumping the request to the static file's URL. nginx then
608 // restarts location matching against the new path, where
609 // regular static-file serving takes over.
610 //
611 // Why server-level + a single rewrite (instead of try_files
612 // inside `location /`): nginx's well-documented "if is evil"
613 // quirk silently disables `try_files`'s last fallback when
614 // any `if` in the same location is true. Moving the `if`s
615 // outside any location dodges the trap completely, because
616 // server-level rewrite is the documented stable path.
617 //
618 // `last` (not `break`) restarts location matching — required
619 // so the rewritten static-file URI gets served via the normal
620 // static-file location, not re-matched against `location /`
621 // where our own rewrite would loop.
622 //
623 // The cache existence check is the LAST condition in the
624 // chain so when the file isn't cached, $xspeed_no_cache
625 // gets a "-nofile" tag and the rewrite is skipped — the
626 // request falls through to whatever `location /` the user
627 // already had (typically `try_files $uri $uri/ /index.php?$args;`).
628 $lines = array();
629 $lines[] = '# xSpeed static cache — paste at SERVER level (inside `server { }`,';
630 $lines[] = '# above your existing `location / { … }`; do NOT put it inside any';
631 $lines[] = '# location block).';
632 $lines[] = 'set $xspeed_no_cache "no-cache";';
633 $lines[] = 'if ($request_method != GET) { set $xspeed_no_cache "$xspeed_no_cache-method"; }';
634 $lines[] = 'if ($args) { set $xspeed_no_cache "$xspeed_no_cache-args"; }';
635 $lines[] = 'if ($http_cookie ~* "(wordpress_logged_in|comment_author|wp-postpass_)") { set $xspeed_no_cache "$xspeed_no_cache-cookie"; }';
636 $lines[] = 'if (!-f "$document_root' . $rel . '/$host$uri/index.html") { set $xspeed_no_cache "$xspeed_no_cache-nofile"; }';
637 $lines[] = 'if ($xspeed_no_cache = "no-cache") {';
638 $lines[] = ' rewrite ^ ' . $rel . '/$host$uri/index.html last;';
639 $lines[] = '}';
640 return implode( "\n", $lines );
641 }
642
643 /**
644 * Emit LiteSpeed Cache module headers on the cache-miss render
645 * path so the server caches the response and serves subsequent
646 * requests at edge speed without booting PHP again.
647 *
648 * LSCache reads two response headers:
649 * - X-LiteSpeed-Cache-Control: public,max-age=N → "cache for N s"
650 * - X-LiteSpeed-Tag: tag1,tag2 → tag the entry for selective
651 * purge later via X-LiteSpeed-Purge in any later response.
652 *
653 * Server detection runs through Server::type() so a non-LiteSpeed
654 * host (Apache / nginx / IIS) sees a no-op — the headers are
655 * harmless if emitted there, but we skip them to keep response
656 * headers tidy. The conflict check defers to the LiteSpeed Cache
657 * plugin when present so we don't double-cache.
658 */
659 public static function maybe_emit_lscache_headers(): void {
660 if ( headers_sent() ) {
661 return;
662 }
663 if ( Server::LITESPEED !== Server::type() ) {
664 return;
665 }
666 // is_plugin_active() lives in wp-admin/includes/plugin.php which
667 // isn't auto-loaded on front-end requests. Use the option layer
668 // directly to avoid pulling in admin code from a render path.
669 $active = (array) get_option( 'active_plugins', array() );
670 if ( in_array( 'litespeed-cache/litespeed-cache.php', $active, true ) ) {
671 return;
672 }
673
674 $opts = Settings_Manager::get( 'cache' );
675 $expiry = isset( $opts['cache_expiry'] ) ? (int) $opts['cache_expiry'] : DAY_IN_SECONDS;
676 $expiry = max( 60, min( $expiry, 30 * DAY_IN_SECONDS ) );
677
678 // Tags scope the entry so a single post change can purge just
679 // that page (or its archive) instead of the whole cache. We
680 // always send the global `xspeed` tag plus a path-derived one.
681 $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/';
682 $path_tag = 'xspeed_' . md5( (string) strtok( $request_uri, '?' ) );
683
684 header( 'X-LiteSpeed-Cache-Control: public,max-age=' . $expiry );
685 header( 'X-LiteSpeed-Tag: xspeed,' . $path_tag );
686 }
687
688 /**
689 * Reconcile drop-in + WP_CACHE + rewrite block with the user's
690 * saved choice. Runs on admin_init. Cheap when nothing's wrong
691 * (one option read + a handful of file_exists / defined checks);
692 * writes only when state has drifted (typical cause: plugin
693 * upgrade wiped the drop-in, foreign plugin removed our WP_CACHE
694 * define, or someone hand-edited .htaccess).
695 *
696 * Skipped during the WP plugin updater run so we don't race
697 * the upgrader's own filesystem operations.
698 */
699 public static function auto_heal(): void {
700 if ( defined( 'WP_INSTALLING' ) && WP_INSTALLING ) {
701 return;
702 }
703 if ( wp_doing_ajax() || wp_doing_cron() ) {
704 return;
705 }
706
707 $opts = get_option( 'xspeed_options', array() );
708 if ( empty( $opts['cache_enabled'] ) ) {
709 return;
710 }
711
712 $dropin_target = WP_CONTENT_DIR . '/advanced-cache.php';
713 $dropin_ours = false;
714 if ( file_exists( $dropin_target ) ) {
715 $contents = @file_get_contents( $dropin_target ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
716 $dropin_ours = is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' );
717 }
718
719 if ( ! $dropin_ours ) {
720 self::install_dropin();
721 }
722
723 if ( ! defined( 'WP_CACHE' ) || ! WP_CACHE ) {
724 self::set_wp_cache_constant( true );
725 }
726
727 // Rewrite block goes last. It's what turns the static-cache
728 // tree into a PHP-bypass — every cache hit served by the web
729 // server directly. Without it we still cache, just at drop-in
730 // speed (~85ms TTFB) instead of static-file speed (~25-40ms).
731 if ( ! self::rewrite_installed() ) {
732 self::install_rewrite();
733 }
734 }
735
736 /**
737 * Build the .htaccess rules that map cacheable requests to the
738 * static-cache tree. Conditions are deliberately strict: GET only,
739 * empty query string, no session/comment-author/post-password
740 * cookie, and the static file must exist on disk. Anything that
741 * fails one of these falls through to PHP and the drop-in / full
742 * WordPress path.
743 *
744 * @return string[] Lines for insert_with_markers().
745 */
746 public static function rewrite_block_lines(): array {
747 // Path relative to ABSPATH so the rule lives in the site-root
748 // .htaccess regardless of where wp-content sits. WP_CONTENT_DIR
749 // can be moved, so we compute the document-root-relative form
750 // at install time and bake it into the rule.
751 $rel = str_replace( ABSPATH, '/', XSPEED_CACHE_STATIC_DIR );
752 $rel = '/' . ltrim( $rel, '/' );
753 $rel = rtrim( $rel, '/' );
754
755 return array(
756 '<IfModule mod_rewrite.c>',
757 ' RewriteEngine On',
758 ' RewriteCond %{REQUEST_METHOD} ^GET$',
759 ' RewriteCond %{QUERY_STRING} ^$',
760 ' RewriteCond %{HTTP_COOKIE} !(wordpress_logged_in|comment_author|wp-postpass_) [NC]',
761 // Capture REQUEST_URI without its trailing slash into %1.
762 // store_static() writes `{host}{uri-without-trailing-slash}/index.html`,
763 // so this normalization lets `/blog/` and `/blog` both hit
764 // the same cache file without producing the double-slash
765 // path that would skip the -f check below.
766 ' RewriteCond %{REQUEST_URI} ^(.*?)/?$',
767 ' RewriteCond %{DOCUMENT_ROOT}' . $rel . '/%{HTTP_HOST}%1/index.html -f',
768 ' RewriteRule . ' . $rel . '/%{HTTP_HOST}%1/index.html [L]',
769 '</IfModule>',
770 );
771 }
772
773 /**
774 * Active probe that confirms the web-server static-rewrite path is
775 * actually serving cached files. Writes a probe file with a random
776 * nonce, fetches it over HTTP at its public URL, and checks whether
777 * the response was served directly by the web server (Last-Modified
778 * + ETag headers + no X-Powered-By: PHP).
779 *
780 * Server-agnostic: same probe works for nginx (snippet pasted) and
781 * Apache / LiteSpeed (.htaccess block installed). If the rewrite
782 * isn't engaged, the request falls through to WordPress and PHP
783 * adds its own headers, which the probe detects and reports.
784 *
785 * Throttled via a 5-minute transient — we never want this running
786 * on every Health card paint.
787 *
788 * @return array{active:bool, reason:string, code?:int, php?:bool, expires?:int}
789 */
790 public static function probe_static_rewrite(): array {
791 $cached = get_transient( 'xspeed_rewrite_probe' );
792 if ( is_array( $cached ) ) {
793 return $cached;
794 }
795
796 $home = home_url( '/' );
797 $host = (string) wp_parse_url( $home, PHP_URL_HOST );
798 if ( '' === $host ) {
799 $result = array( 'active' => false, 'reason' => 'home_url has no host' );
800 set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
801 return $result;
802 }
803
804 // Use a randomised path AND nonce so a stale CDN cache entry
805 // from a prior probe can never make a broken install look
806 // healthy. Path is namespaced under __xspeed_probe__ so the
807 // directory listing stays obvious if cleanup misfires.
808 $slug = wp_generate_password( 12, false, false );
809 $nonce = wp_generate_password( 24, false, false );
810 $probe_dir = XSPEED_CACHE_STATIC_DIR . '/' . $host . '/__xspeed_probe__/' . $slug;
811 $probe_file = $probe_dir . '/index.html';
812 $probe_url = trailingslashit( $home ) . '__xspeed_probe__/' . $slug . '/';
813
814 if ( ! file_exists( $probe_dir ) ) {
815 wp_mkdir_p( $probe_dir );
816 }
817 if ( ! is_dir( $probe_dir ) ) {
818 $result = array( 'active' => false, 'reason' => 'cannot create probe dir' );
819 set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
820 return $result;
821 }
822 // 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.
823 file_put_contents( $probe_file, $nonce, LOCK_EX );
824
825 $resp = wp_remote_get(
826 $probe_url,
827 array(
828 'timeout' => 4,
829 'sslverify' => false,
830 'redirection' => 0,
831 'headers' => array( 'Cache-Control' => 'no-cache' ),
832 )
833 );
834
835 // Best-effort cleanup so we don't accumulate probe dirs even
836 // if subsequent calls all hit the transient.
837 if ( file_exists( $probe_file ) ) {
838 wp_delete_file( $probe_file );
839 }
840 if ( is_dir( $probe_dir ) ) {
841 // 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.
842 @rmdir( $probe_dir );
843 }
844
845 if ( is_wp_error( $resp ) ) {
846 $result = array(
847 'active' => false,
848 'reason' => 'http error: ' . $resp->get_error_message(),
849 );
850 set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
851 return $result;
852 }
853
854 $code = (int) wp_remote_retrieve_response_code( $resp );
855 $body = (string) wp_remote_retrieve_body( $resp );
856 $ua_php = '' !== (string) wp_remote_retrieve_header( $resp, 'x-powered-by' );
857 $has_etag = '' !== (string) wp_remote_retrieve_header( $resp, 'etag' )
858 || '' !== (string) wp_remote_retrieve_header( $resp, 'last-modified' );
859 $match = trim( $body ) === $nonce;
860
861 // "Active" = the web server served our raw nonce bytes back
862 // AND emitted the static-serve markers (ETag / Last-Modified)
863 // AND didn't add an X-Powered-By: PHP header. All three are
864 // individually noisy; together they're conclusive.
865 $active = $match && $has_etag && ! $ua_php && 200 === $code;
866
867 if ( $active ) {
868 $reason = 'static-served';
869 } elseif ( 200 === $code && $match && $ua_php ) {
870 $reason = 'php served the file instead of nginx/Apache (rewrite block missing)';
871 } elseif ( 200 === $code && ! $match ) {
872 $reason = 'unexpected body (CDN cached an older response?)';
873 } elseif ( 404 === $code ) {
874 $reason = 'probe URL returned 404 (rewrite block missing or wrong path)';
875 } else {
876 $reason = sprintf( 'unexpected response (HTTP %d, body %d B, php=%s)', $code, strlen( $body ), $ua_php ? 'yes' : 'no' );
877 }
878
879 $result = array(
880 'active' => $active,
881 'reason' => $reason,
882 'code' => $code,
883 'php' => $ua_php,
884 );
885 set_transient( 'xspeed_rewrite_probe', $result, 5 * MINUTE_IN_SECONDS );
886 return $result;
887 }
888
889 public static function rewrite_installed(): bool {
890 $htaccess = ABSPATH . '.htaccess';
891 if ( ! file_exists( $htaccess ) ) {
892 return false;
893 }
894 $existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
895 if ( ! is_string( $existing ) ) {
896 return false;
897 }
898 return false !== strpos( $existing, '# BEGIN xSpeed Static Cache' );
899 }
900
901 /**
902 * Install the static-cache rewrite block at the TOP of .htaccess.
903 *
904 * Position matters: WordPress's own block ends with
905 * `RewriteRule . /index.php [L]` which routes every non-file
906 * request to PHP. The [L] flag stops the current rewrite pass,
907 * but Apache restarts the cycle; on the second pass REQUEST_URI
908 * is /index.php and no static-file check can match. The only
909 * reliable position for a "serve static if it exists" rule is
910 * before WordPress's block.
911 *
912 * WP's insert_with_markers() always appends, so we manage the
913 * block manually: strip any prior xSpeed Static Cache markers,
914 * then write our block followed by the rest of the file.
915 */
916 public static function install_rewrite(): bool {
917 $htaccess = ABSPATH . '.htaccess';
918 $existing = file_exists( $htaccess ) ? @file_get_contents( $htaccess ) : ''; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
919 if ( false === $existing ) {
920 $existing = '';
921 }
922 // Apache/LiteSpeed only. nginx hosts: rule won't fire, drop-in
923 // covers; we skip the write so we don't litter their root.
924 // 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.
925 if ( file_exists( $htaccess ) && ! is_writable( $htaccess ) ) {
926 return false;
927 }
928 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- See above.
929 if ( ! file_exists( $htaccess ) && ! is_writable( ABSPATH ) ) {
930 return false;
931 }
932
933 $cleaned = self::strip_marker_block( $existing, 'xSpeed Static Cache' );
934 $block = self::marker_block( 'xSpeed Static Cache', self::rewrite_block_lines() );
935 $next = $block . ( '' === $cleaned ? '' : "\n" . $cleaned );
936
937 // 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.
938 return false !== file_put_contents( $htaccess, $next, LOCK_EX );
939 }
940
941 public static function remove_rewrite(): bool {
942 $htaccess = ABSPATH . '.htaccess';
943 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- See install_rewrite() rationale.
944 if ( ! file_exists( $htaccess ) || ! is_writable( $htaccess ) ) {
945 return false;
946 }
947 $existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
948 if ( false === $existing ) {
949 return false;
950 }
951 $cleaned = self::strip_marker_block( $existing, 'xSpeed Static Cache' );
952 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents, PluginCheck.CodeAnalysis.WriteFile.ABSPATHDetected -- See install_rewrite() rationale.
953 return false !== file_put_contents( $htaccess, $cleaned, LOCK_EX );
954 }
955
956 /**
957 * Strip a `# BEGIN <marker>` ... `# END <marker>` block from a
958 * .htaccess-style file, including any blank line that immediately
959 * follows it. Idempotent — returns the input unchanged if the
960 * marker isn't present.
961 */
962 private static function strip_marker_block( string $contents, string $marker ): string {
963 $pattern = '/# BEGIN ' . preg_quote( $marker, '/' ) . '\b.*?# END ' . preg_quote( $marker, '/' ) . "\b[^\n]*\n?\n?/s";
964 $out = preg_replace( $pattern, '', $contents );
965 return is_string( $out ) ? $out : $contents;
966 }
967
968 private static function marker_block( string $marker, array $lines ): string {
969 $header = "# BEGIN $marker\n";
970 $header .= "# The directives (lines) between \"BEGIN $marker\" and \"END $marker\" are\n";
971 $header .= "# dynamically generated, and should only be modified via WordPress filters.\n";
972 $header .= "# Any changes to the directives between these markers will be overwritten.\n";
973 $footer = "# END $marker\n";
974 return $header . implode( "\n", $lines ) . "\n" . $footer;
975 }
976
977 public static function install_dropin() {
978 $source = XSPEED_DIR . 'includes/advanced-cache.php';
979 $target = WP_CONTENT_DIR . '/advanced-cache.php';
980 if ( ! file_exists( $source ) ) {
981 return false;
982 }
983
984 global $wp_filesystem;
985 if ( ! function_exists( 'WP_Filesystem' ) ) {
986 require_once ABSPATH . 'wp-admin/includes/file.php';
987 }
988 WP_Filesystem();
989 if ( ! $wp_filesystem ) {
990 return false;
991 }
992
993 $source_contents = $wp_filesystem->get_contents( $source );
994 if ( ! is_string( $source_contents ) ) {
995 return false;
996 }
997
998 if ( file_exists( $target ) ) {
999 $existing = $wp_filesystem->get_contents( $target );
1000 $is_xspeed = is_string( $existing ) && false !== strpos( $existing, 'XSPEED_DROPIN' );
1001
1002 if ( $is_xspeed ) {
1003 if ( $existing === $source_contents ) {
1004 return true;
1005 }
1006 return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE );
1007 }
1008
1009 // Foreign drop-in (e.g. left over from another cache plugin) — back it up
1010 // before overwriting so the user can recover if needed. Uploads dir
1011 // (not wp-content root) keeps the backup out of WordPress's reserved
1012 // drop-in location.
1013 $upload = wp_upload_dir( null, false );
1014 $basedir = isset( $upload['basedir'] ) ? trailingslashit( $upload['basedir'] ) . 'xspeed-backups' : false;
1015 if ( $basedir ) {
1016 if ( ! file_exists( $basedir ) ) {
1017 wp_mkdir_p( $basedir );
1018 self::write_silence( $basedir );
1019 }
1020 $backup = $basedir . '/advanced-cache.foreign-' . gmdate( 'Ymd-His' ) . '.php.bak';
1021 $wp_filesystem->move( $target, $backup, true );
1022 } else {
1023 $wp_filesystem->delete( $target );
1024 }
1025 }
1026
1027 return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE );
1028 }
1029
1030 public static function remove_dropin() {
1031 $target = WP_CONTENT_DIR . '/advanced-cache.php';
1032 if ( ! file_exists( $target ) ) {
1033 return;
1034 }
1035
1036 global $wp_filesystem;
1037 if ( ! function_exists( 'WP_Filesystem' ) ) {
1038 require_once ABSPATH . 'wp-admin/includes/file.php';
1039 }
1040 WP_Filesystem();
1041 if ( ! $wp_filesystem ) {
1042 return;
1043 }
1044
1045 $contents = $wp_filesystem->get_contents( $target );
1046 if ( is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' ) ) {
1047 wp_delete_file( $target );
1048 }
1049 }
1050
1051 public static function set_wp_cache_constant( $enable ) {
1052 $wp_config = ABSPATH . 'wp-config.php';
1053 if ( ! file_exists( $wp_config ) ) {
1054 return false;
1055 }
1056
1057 global $wp_filesystem;
1058 if ( ! function_exists( 'WP_Filesystem' ) ) {
1059 require_once ABSPATH . 'wp-admin/includes/file.php';
1060 }
1061 WP_Filesystem();
1062 if ( ! $wp_filesystem || ! $wp_filesystem->is_writable( $wp_config ) ) {
1063 return false;
1064 }
1065
1066 $config = $wp_filesystem->get_contents( $wp_config );
1067
1068 if ( $enable ) {
1069 if ( strpos( $config, "define( 'WP_CACHE'" ) !== false || strpos( $config, "define('WP_CACHE'" ) !== false ) {
1070 return true;
1071 }
1072 $config = preg_replace( '/(<\?php)/', "$1\ndefine( 'WP_CACHE', true );", $config, 1 );
1073 } else {
1074 $config = preg_replace( "/define\\(\\s*['\"]WP_CACHE['\"]\\s*,\\s*true\\s*\\);\\s*\\n?/", '', $config );
1075 }
1076
1077 return (bool) $wp_filesystem->put_contents( $wp_config, $config, FS_CHMOD_FILE );
1078 }
1079
1080 public function admin_bar_purge( $wp_admin_bar ) {
1081 if ( ! current_user_can( 'manage_options' ) ) {
1082 return;
1083 }
1084 $wp_admin_bar->add_node(
1085 array(
1086 'id' => 'xspeed-purge',
1087 'title' => __( 'Purge xSpeed Cache', 'xspeed' ),
1088 'href' => wp_nonce_url( admin_url( 'admin-post.php?action=xspeed_purge' ), 'xspeed_purge' ),
1089 )
1090 );
1091 }
1092
1093 public function handle_admin_bar_purge() {
1094 if ( ! current_user_can( 'manage_options' ) ) {
1095 wp_die( esc_html__( 'Unauthorized.', 'xspeed' ), 403 );
1096 }
1097 check_admin_referer( 'xspeed_purge' );
1098 self::purge_all();
1099 wp_safe_redirect( wp_get_referer() ?: admin_url() );
1100 exit;
1101 }
1102 }
1103