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

457 lines 14.5 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 toggle: write or remove .htaccess rules.
40 $old_gzip = ! empty( $old['gzip_enabled'] );
41 $new_gzip = ! empty( $new['gzip_enabled'] );
42 if ( $old_gzip !== $new_gzip ) {
43 Gzip::apply( $new_gzip );
44 }
45 // Any settings change — purge caches so changes take effect.
46 self::purge_all();
47 Minifier::purge_minified();
48 }
49
50 public function maybe_start_cache() {
51 if ( ! self::should_cache() ) {
52 return;
53 }
54
55 $key = self::cache_key();
56 $file = self::cache_file_for( $key );
57
58 if ( file_exists( $file ) && ! self::is_expired( $file ) ) {
59 // 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.
60 readfile( $file );
61 exit;
62 }
63
64
65 // WP < 6.9 fallback: ob_start() with a callback, paired with an
66 // explicit shutdown close so the buffer lifecycle is visible to
67 // reviewers and Plugin Check, instead of relying on PHP's implicit
68 // request-end flush. We record our nesting level so close_buffer()
69 // flushes ONLY the buffer we opened.
70 ob_start( array( __CLASS__, 'finalize_buffer' ) );
71 self::$buffer_level = ob_get_level();
72
73 add_action( 'shutdown', array( __CLASS__, 'close_buffer' ), 0 );
74 }
75
76 /**
77 * Close the cache buffer opened by maybe_start_cache().
78 *
79 * Guarded by the recorded buffer level so we never flush a buffer that
80 * another plugin pushed on top of (or under) ours. If something else is
81 * currently on top, we leave the stack alone — PHP's shutdown sequence
82 * will unwind buffers in order and our finalize_buffer() callback will
83 * still run when our level becomes the topmost one.
84 */
85 public static function close_buffer() {
86 if ( null === self::$buffer_level ) {
87 return;
88 }
89 if ( ob_get_level() === self::$buffer_level ) {
90 ob_end_flush();
91 }
92 self::$buffer_level = null;
93 }
94
95 public static function should_cache() {
96 $opts = Settings::get();
97 if ( empty( $opts['cache_enabled'] ) ) {
98 return false;
99 }
100
101 if ( is_user_logged_in() || is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
102 return false;
103 }
104
105 if ( defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE ) {
106 return false;
107 }
108
109 $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : '';
110 if ( 'GET' !== $method ) {
111 return false;
112 }
113
114 if ( ! empty( $_SERVER['QUERY_STRING'] ) ) {
115 return false;
116 }
117
118 $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
119 foreach ( $opts['excluded_urls'] as $excluded ) {
120 if ( '' !== $excluded && false !== strpos( $request_uri, $excluded ) ) {
121 return false;
122 }
123 }
124
125 return true;
126 }
127
128 public static function cache_key() {
129 $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : 'default';
130 $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/';
131 $uri = strtok( $uri, '?' );
132 return md5( $host . $uri );
133 }
134
135 public static function cache_file_for( $key ) {
136 return XSPEED_CACHE_DIR . '/' . $key . '.html';
137 }
138
139 public static function is_expired( $file ) {
140 $opts = Settings::get();
141 $max_age = (int) $opts['cache_expiry'] * HOUR_IN_SECONDS;
142 return ( time() - filemtime( $file ) ) > $max_age;
143 }
144
145 /**
146 * Accumulator for the full response body across all output-handler phases.
147 *
148 * PHP invokes an ob_start() callback once per flush, and each invocation
149 * only receives the chunk produced *since the previous flush*. If anything
150 * during the render calls `ob_flush()` or `flush()` (some themes, lazy-
151 * load plugins, AMP, etc. do), the final-phase call would otherwise only
152 * see the tail of the page — and we'd cache a truncated response that
153 * gets served repeatedly until purge. We accumulate every chunk here so
154 * the cache file always reflects the complete page.
155 *
156 * @var string
157 */
158 private static $accumulated = '';
159
160 public static function finalize_buffer( $buffer, $phase = PHP_OUTPUT_HANDLER_FINAL ) {
161 self::$accumulated .= $buffer;
162
163 // On non-final phases (mid-request flushes), pass the current chunk
164 // through to the client unmodified and keep collecting. The WP 6.9
165 // filter path always passes the full body in one shot with the
166 // default $phase, so it falls straight through to the final block.
167 $is_final = ( $phase & ( PHP_OUTPUT_HANDLER_FINAL | PHP_OUTPUT_HANDLER_END ) ) !== 0;
168 if ( ! $is_final ) {
169 return $buffer;
170 }
171
172 $full = self::$accumulated;
173 self::$accumulated = '';
174
175 if ( strlen( $full ) < 255 ) {
176 return $buffer;
177 }
178
179 if ( function_exists( 'http_response_code' ) && 200 !== http_response_code() ) {
180 return $buffer;
181 }
182
183 // If no mid-request flush happened, $buffer === $full and we can
184 // safely minify the on-wire bytes too. Otherwise earlier chunks have
185 // already been sent unminified, so we minify only what goes to disk —
186 // the first visitor sees unminified HTML, every cache hit after that
187 // is minified.
188 $single_chunk = ( $buffer === $full );
189
190 $opts = Settings::get();
191 if ( ! empty( $opts['minify_html'] ) ) {
192 $full = Minifier::minify_html( $full );
193 if ( $single_chunk ) {
194 $buffer = $full;
195 }
196 }
197
198 if ( ! file_exists( XSPEED_CACHE_DIR ) ) {
199 wp_mkdir_p( XSPEED_CACHE_DIR );
200 self::write_silence( XSPEED_CACHE_DIR );
201 }
202
203 // Path safety: cache_file_for() builds `XSPEED_CACHE_DIR . '/' . $key . '.html'`
204 // where $key comes from md5() — guaranteed to be exactly 32 lowercase
205 // hex chars, so no traversal sequence ('..', '/', null byte, etc.)
206 // can appear. The write is therefore always inside XSPEED_CACHE_DIR.
207 $file = self::cache_file_for( self::cache_key() );
208 // 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.
209 file_put_contents( $file, $full, LOCK_EX );
210
211 return $buffer;
212 }
213
214 public static function purge_all() {
215 if ( ! is_dir( XSPEED_CACHE_DIR ) ) {
216 return;
217 }
218 $files = glob( XSPEED_CACHE_DIR . '/*.html' );
219 if ( $files ) {
220 foreach ( $files as $f ) {
221 wp_delete_file( $f );
222 }
223 }
224 self::update_stats( array( 'last_purge' => time() ) );
225 }
226
227 /**
228 * Drop a "silence is golden" index.php into a directory so apaches/nginx
229 * with directory listing enabled don't expose cache contents.
230 */
231 public static function write_silence( $dir ) {
232 $file = trailingslashit( $dir ) . 'index.php';
233 if ( ! file_exists( $file ) ) {
234 // 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.
235 file_put_contents( $file, "<?php\n// Silence is golden.\n" );
236 }
237 }
238
239 /**
240 * Persist stats with autoload disabled — stats are only read in admin
241 * contexts, so there is no reason to inflate every frontend request's
242 * `wp_load_alloptions()` payload.
243 */
244 private static function update_stats( array $stats ) {
245 if ( false === get_option( 'xspeed_stats' ) ) {
246 add_option( 'xspeed_stats', $stats, '', 'no' );
247 return;
248 }
249 update_option( 'xspeed_stats', $stats );
250 }
251
252 public static function get_stats() {
253 $count = 0;
254 $size = 0;
255 if ( is_dir( XSPEED_CACHE_DIR ) ) {
256 $files = glob( XSPEED_CACHE_DIR . '/*.html' );
257 if ( $files ) {
258 $count = count( $files );
259 foreach ( $files as $f ) {
260 $size += filesize( $f );
261 }
262 }
263 }
264 $stats = get_option( 'xspeed_stats', array() );
265 return array(
266 'cached_pages' => $count,
267 'cache_size' => $size,
268 'last_purge' => isset( $stats['last_purge'] ) ? (int) $stats['last_purge'] : 0,
269 );
270 }
271
272 /**
273 * Apply the user's enable/disable choice. Called only from the REST
274 * toggle endpoint, which is gated by current_user_can( 'manage_options' )
275 * and a verified REST nonce. This is the only place the drop-in and
276 * the WP_CACHE constant are written — they MUST NOT happen on
277 * register_activation_hook (WordPress.org review requirement).
278 *
279 * @param bool $enable User's choice.
280 * @return array{
281 * enabled: bool,
282 * dropin_installed: bool,
283 * wp_cache_constant: bool,
284 * wp_config_writable: bool,
285 * manual_snippet: ?string
286 * }
287 */
288 public static function toggle( $enable ) {
289 $enable = (bool) $enable;
290
291 if ( $enable ) {
292 $dropin_ok = self::install_dropin();
293 $wp_config_ok = self::set_wp_cache_constant( true );
294 $snippet = $wp_config_ok ? null : "define( 'WP_CACHE', true );";
295
296 return array(
297 'enabled' => true,
298 'dropin_installed' => (bool) $dropin_ok,
299 'wp_cache_constant' => (bool) $wp_config_ok,
300 'wp_config_writable' => self::wp_config_writable(),
301 'manual_snippet' => $snippet,
302 );
303 }
304
305 self::remove_dropin();
306 self::set_wp_cache_constant( false );
307
308 return array(
309 'enabled' => false,
310 'dropin_installed' => false,
311 'wp_cache_constant' => false,
312 'wp_config_writable' => self::wp_config_writable(),
313 'manual_snippet' => null,
314 );
315 }
316
317 /**
318 * Check wp-config.php writability via WP_Filesystem. Plugin Check flags
319 * direct is_writable() under WordPress.WP.AlternativeFunctions.
320 */
321 private static function wp_config_writable() {
322 global $wp_filesystem;
323 if ( ! function_exists( 'WP_Filesystem' ) ) {
324 require_once ABSPATH . 'wp-admin/includes/file.php';
325 }
326 WP_Filesystem();
327
328 return $wp_filesystem ? (bool) $wp_filesystem->is_writable( ABSPATH . 'wp-config.php' ) : false;
329 }
330
331 public static function install_dropin() {
332 $source = XSPEED_DIR . 'includes/advanced-cache.php';
333 $target = WP_CONTENT_DIR . '/advanced-cache.php';
334 if ( ! file_exists( $source ) ) {
335 return false;
336 }
337
338 global $wp_filesystem;
339 if ( ! function_exists( 'WP_Filesystem' ) ) {
340 require_once ABSPATH . 'wp-admin/includes/file.php';
341 }
342 WP_Filesystem();
343 if ( ! $wp_filesystem ) {
344 return false;
345 }
346
347 $source_contents = $wp_filesystem->get_contents( $source );
348 if ( ! is_string( $source_contents ) ) {
349 return false;
350 }
351
352 if ( file_exists( $target ) ) {
353 $existing = $wp_filesystem->get_contents( $target );
354 $is_xspeed = is_string( $existing ) && false !== strpos( $existing, 'XSPEED_DROPIN' );
355
356 if ( $is_xspeed ) {
357 if ( $existing === $source_contents ) {
358 return true;
359 }
360 return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE );
361 }
362
363 // Foreign drop-in (e.g. left over from another cache plugin) — back it up
364 // before overwriting so the user can recover if needed. Uploads dir
365 // (not wp-content root) keeps the backup out of WordPress's reserved
366 // drop-in location.
367 $upload = wp_upload_dir( null, false );
368 $basedir = isset( $upload['basedir'] ) ? trailingslashit( $upload['basedir'] ) . 'xspeed-backups' : false;
369 if ( $basedir ) {
370 if ( ! file_exists( $basedir ) ) {
371 wp_mkdir_p( $basedir );
372 self::write_silence( $basedir );
373 }
374 $backup = $basedir . '/advanced-cache.foreign-' . gmdate( 'Ymd-His' ) . '.php.bak';
375 $wp_filesystem->move( $target, $backup, true );
376 } else {
377 $wp_filesystem->delete( $target );
378 }
379 }
380
381 return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE );
382 }
383
384 public static function remove_dropin() {
385 $target = WP_CONTENT_DIR . '/advanced-cache.php';
386 if ( ! file_exists( $target ) ) {
387 return;
388 }
389
390 global $wp_filesystem;
391 if ( ! function_exists( 'WP_Filesystem' ) ) {
392 require_once ABSPATH . 'wp-admin/includes/file.php';
393 }
394 WP_Filesystem();
395 if ( ! $wp_filesystem ) {
396 return;
397 }
398
399 $contents = $wp_filesystem->get_contents( $target );
400 if ( is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' ) ) {
401 wp_delete_file( $target );
402 }
403 }
404
405 public static function set_wp_cache_constant( $enable ) {
406 $wp_config = ABSPATH . 'wp-config.php';
407 if ( ! file_exists( $wp_config ) ) {
408 return false;
409 }
410
411 global $wp_filesystem;
412 if ( ! function_exists( 'WP_Filesystem' ) ) {
413 require_once ABSPATH . 'wp-admin/includes/file.php';
414 }
415 WP_Filesystem();
416 if ( ! $wp_filesystem || ! $wp_filesystem->is_writable( $wp_config ) ) {
417 return false;
418 }
419
420 $config = $wp_filesystem->get_contents( $wp_config );
421
422 if ( $enable ) {
423 if ( strpos( $config, "define( 'WP_CACHE'" ) !== false || strpos( $config, "define('WP_CACHE'" ) !== false ) {
424 return true;
425 }
426 $config = preg_replace( '/(<\?php)/', "$1\ndefine( 'WP_CACHE', true );", $config, 1 );
427 } else {
428 $config = preg_replace( "/define\\(\\s*['\"]WP_CACHE['\"]\\s*,\\s*true\\s*\\);\\s*\\n?/", '', $config );
429 }
430
431 return (bool) $wp_filesystem->put_contents( $wp_config, $config, FS_CHMOD_FILE );
432 }
433
434 public function admin_bar_purge( $wp_admin_bar ) {
435 if ( ! current_user_can( 'manage_options' ) ) {
436 return;
437 }
438 $wp_admin_bar->add_node(
439 array(
440 'id' => 'xspeed-purge',
441 'title' => __( 'Purge xSpeed Cache', 'xspeed' ),
442 'href' => wp_nonce_url( admin_url( 'admin-post.php?action=xspeed_purge' ), 'xspeed_purge' ),
443 )
444 );
445 }
446
447 public function handle_admin_bar_purge() {
448 if ( ! current_user_can( 'manage_options' ) ) {
449 wp_die( esc_html__( 'Unauthorized.', 'xspeed' ), 403 );
450 }
451 check_admin_referer( 'xspeed_purge' );
452 self::purge_all();
453 wp_safe_redirect( wp_get_referer() ?: admin_url() );
454 exit;
455 }
456 }
457