PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.6
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 1.2.0 All 28 releases
xspeed / includes / modules / Cache / CacheModule.php

CacheModule.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.1.6, at includes/modules/Cache/CacheModule.php

455 lines 16.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Cache module.
4 *
5 * Owns the cache_expiry and excluded_urls settings. cache_enabled is
6 * deliberately NOT in this schema — flipping it triggers the
7 * advanced-cache.php drop-in install + WP_CACHE constant edit in
8 * wp-config.php, which is a sensitive single-purpose code path and lives
9 * in Cache::toggle() with its own dedicated /xspeed/v1/cache/toggle REST
10 * route. The dashboard's Cache page renders the special hero UI for it
11 * above this module's schema-driven settings panel.
12 *
13 * Tier: Free.
14 *
15 * @package XSpeed
16 */
17
18 declare(strict_types=1);
19
20 namespace XSpeed\Modules\Cache;
21
22 defined( 'ABSPATH' ) || exit;
23
24 use XSpeed\Module;
25 use XSpeed\Settings_Manager;
26
27 final class CacheModule extends Module {
28
29 public const SLUG = 'cache';
30 public const TIER = self::TIER_FREE;
31 public const VERSION = '1.0.0';
32
33 public function ui_metadata(): array {
34 return array(
35 'label' => 'Page Cache',
36 'icon' => 'Database',
37 'description' => 'Page caching for non-logged-in visitors.',
38 );
39 }
40
41 public function settings_schema(): array {
42 return array(
43 'cache_expiry' => array(
44 'type' => 'int',
45 'default' => 24,
46 'min' => 1,
47 'max' => 720,
48 'label' => 'Cache Expiry (hours)',
49 'unit' => 'hours',
50 'description' => 'How long cached pages live before regenerating. 1 to 720 hours (30 days).',
51 ),
52 'excluded_urls' => array(
53 'type' => 'list',
54 // Comprehensive LiteSpeed / WP Rocket-parity default URL
55 // exclusions (FBS-82181). Plain text = "contains", glob via
56 // * ? [ ], or a `~` prefix for raw regex (e.g. ~wp-.*\.php).
57 'default' => array(
58 '/wp-admin/',
59 '/wp-json/',
60 '/xmlrpc.php',
61 '~wp-.*\.php',
62 '/feed/',
63 'index.php',
64 '~sitemap(_index)?\.xml',
65 // Bare (no trailing slash) so "contains" matches both
66 // /cart and /cart/items — WooCommerce serves both forms.
67 '/cart',
68 '/checkout',
69 '/my-account',
70 'ao_noptirocket',
71 'ao_speedup_cachebuster',
72 'removed_item',
73 '/wc-api',
74 '/edd-api',
75 '/wp-login',
76 ),
77 'item_type' => 'string',
78 'label' => 'Excluded URLs',
79 'description' => 'One pattern per line. Plain text matches anywhere in the URL (e.g. /cart). Use glob for anchored matches (/cart/* matches /cart/items but not /foo/cart/bar; *.pdf matches PDFs). Prefix with ~ for a raw regex (e.g. ~wp-.*\.php).',
80 ),
81 'excluded_cookies' => array(
82 'type' => 'list',
83 // Cookies that signal a logged-in / transactional visitor
84 // whose response must not be served from a shared cache.
85 // `~` prefix = raw regex (e.g. ~wordpress_[a-f0-9]+). (FBS-82181)
86 'default' => array(
87 'comment_author',
88 '~wordpress_[a-f0-9]+',
89 'wp-postpass',
90 'wordpress_no_cache',
91 'wordpress_logged_in',
92 'edd_items_in_cart',
93 'woocommerce_items_in_cart',
94 'fct_cart_hash',
95 'comment_',
96 'woocommerce_',
97 'wordpress',
98 'xf_',
99 'edd_',
100 'jetpack',
101 'yith_wcwl_session_',
102 'yith_wrvp_',
103 'wpsc_',
104 'ecwid',
105 'ec_',
106 'bookly',
107 ),
108 'item_type' => 'string',
109 'label' => 'Excluded Cookies',
110 'description' => 'Skip cache for any visitor whose request carries a cookie whose NAME matches one of these patterns. Plain text = "contains"; glob (woocommerce_*) and ~regex (~wordpress_[a-f0-9]+) supported. One per line.',
111 ),
112 'bypass_user_agents' => array(
113 'type' => 'list',
114 'default' => array(),
115 'item_type' => 'string',
116 'label' => 'Bypass User Agents',
117 'description' => 'Substring match against the visitor User-Agent. Matched UAs bypass cache (useful for screenshot bots, internal previews, monitoring). Glob + ~regex supported. One per line.',
118 ),
119 'ignored_query_params' => array(
120 'type' => 'list',
121 // Analytics / ad / session query keys stripped before the
122 // cache key is computed, so /post?utm_source=x and /post
123 // share one entry. `~` prefix = raw regex. (FBS-82181)
124 'default' => array(
125 '__s',
126 '_ga',
127 '_ke',
128 '~[a-zA-Z0-9_-]+_sid',
129 'adgroupid',
130 'age-verified',
131 'ao_noptimize',
132 'campaignid',
133 'ck_subscriber_id',
134 'cn-reloaded',
135 'dclid',
136 'epik',
137 'fb_action_ids',
138 'fb_action_types',
139 'fb_source',
140 'fbclid',
141 'gclid',
142 'jobid',
143 'mc_cid',
144 'mc_eid',
145 'mkt_tok',
146 'msclkid',
147 'ref',
148 '~session_[a-zA-Z0-9_-]+_alive',
149 'sseid',
150 'sslid',
151 'usqp',
152 '~utm_[a-zA-Z0-9_-]+',
153 ),
154 'item_type' => 'string',
155 'label' => 'Ignored Query Parameters',
156 'description' => 'Query keys removed from the URL before computing the cache key, so /post?utm_source=x and /post share a cache entry. Defaults cover the common analytics + ad + session params. Glob + ~regex supported. One per line.',
157 ),
158 'mobile_separate' => array(
159 'type' => 'bool',
160 'default' => false,
161 'label' => 'Separate Mobile Cache',
162 'description' => 'Keep mobile and desktop responses in separate cache buckets. Turn on for AMP, mobile-specific themes (WPtouch / Jetpack mobile theme), or any setup that serves different HTML by device.',
163 ),
164 );
165 }
166
167 /**
168 * `mobile_separate_review` lives outside the schema: migration sets it
169 * (bool) when a source plugin had "separate mobile cache" on, so the
170 * dashboard can prompt the user to re-enable it deliberately instead of
171 * silently importing it (which would kill the device-blind static fast
172 * path). Without preserving it here, the first schema-driven cache save
173 * would rebuild the option from the schema alone and drop the flag before
174 * the user ever saw the prompt. (FBS-83145)
175 *
176 * @return string[]
177 */
178 public function preserved_keys(): array {
179 return array( 'mobile_separate_review' );
180 }
181
182 /**
183 * Seed per-module option from the legacy xspeed_options blob if we
184 * haven't done so yet. Idempotent — once xspeed_module_cache exists
185 * or the legacy keys are gone, this is a no-op. Runs on both boot
186 * and activate so installs on every code path are covered.
187 */
188 public function boot(): void {
189 $this->seed_from_legacy_if_needed();
190
191 // Keep every mobile_separate-dependent artifact (the drop-in's
192 // `.mobile-separate` flag, the device-blind server rewrite, and the
193 // device-keyed caches) in lockstep with the setting — on boot, and
194 // whenever the cache settings are saved. The drop-in can't read WP
195 // options, so it reads the sidecar marker Cache maintains here.
196 \XSpeed\Cache::reconcile_mobile_separate();
197 add_action(
198 'update_option_xspeed_module_cache',
199 static function () {
200 \XSpeed\Cache::reconcile_mobile_separate();
201 // Re-bake the cookie / user-agent exclusion rules into the
202 // drop-in. It runs before WordPress loads and so carries a
203 // COPY of those rules, substituted at install time — and
204 // auto_heal() deliberately only reinstalls when the file is
205 // missing, foreign, or an older version, none of which a
206 // settings change makes true. Without this, adding an
207 // excluded cookie left the drop-in serving the shared
208 // anonymous page to exactly the visitors it excluded, until
209 // the next plugin upgrade happened to reinstall it.
210 \XSpeed\Cache::install_dropin();
211 // Same staleness applies to the .htaccess block, which is
212 // written to disk from the same generator. Refresh it only
213 // when a block is already installed — writing one here would
214 // enable the static path on a site that never opted in.
215 \XSpeed\Cache::refresh_rewrite_if_installed();
216 }
217 );
218
219 // Time-driven collection of expired entries and superseded minified
220 // assets. Scheduled here as well as in activate() because a site that
221 // upgrades into this version never runs the activation hook again.
222 add_action( \XSpeed\Cache_GC::CRON_HOOK, array( \XSpeed\Cache_GC::class, 'run' ) );
223 \XSpeed\Cache_GC::ensure_scheduled();
224 }
225
226 public function activate(): void {
227 $this->seed_from_legacy_if_needed();
228 \XSpeed\Cache_GC::ensure_scheduled();
229 }
230
231 public function deactivate(): void {
232 \XSpeed\Cache_GC::unschedule();
233 }
234
235 private function seed_from_legacy_if_needed(): void {
236 if ( null !== get_option( 'xspeed_module_cache', null ) ) {
237 return;
238 }
239 $legacy = get_option( 'xspeed_options', array() );
240 if ( ! is_array( $legacy ) ) {
241 return;
242 }
243 $seed = array( '_version' => self::VERSION );
244 $dirty = false;
245 if ( array_key_exists( 'cache_expiry', $legacy ) ) {
246 $seed['cache_expiry'] = max( 1, min( 720, (int) $legacy['cache_expiry'] ) );
247 unset( $legacy['cache_expiry'] );
248 $dirty = true;
249 }
250 if ( array_key_exists( 'excluded_urls', $legacy ) ) {
251 $seed['excluded_urls'] = is_array( $legacy['excluded_urls'] ) ? array_values( array_filter( $legacy['excluded_urls'], 'is_string' ) ) : array();
252 unset( $legacy['excluded_urls'] );
253 $dirty = true;
254 }
255 if ( $dirty ) {
256 update_option( 'xspeed_module_cache', $seed );
257 update_option( 'xspeed_options', $legacy );
258 }
259 }
260
261 public function cli_commands(): array {
262 return array(
263 array(
264 'name' => 'xspeed cache',
265 'callback' => array( $this, 'cli_handler' ),
266 'shortdesc' => 'Inspect the Cache module: `status` (settings), `inventory` (which pages are cached, and how old), `size` (where the disk usage goes), `purge-log` (what cleared the cache, when and why), `purge-url <url>` to clear one page, or `recheck-rewrite` to re-run the static-rewrite probe (site-wide purge / toggle use the dedicated commands).',
267 'synopsis' => array(
268 array(
269 'type' => 'positional',
270 'name' => 'action',
271 'options' => array( 'status', 'inventory', 'size', 'purge-log', 'purge-url', 'recheck-rewrite' ),
272 'optional' => true,
273 ),
274 array(
275 'type' => 'positional',
276 'name' => 'url',
277 'optional' => true,
278 ),
279 array(
280 'type' => 'assoc',
281 'name' => 'limit',
282 'description' => 'Rows to print for inventory / purge-log. Default 20.',
283 'optional' => true,
284 ),
285 array(
286 'type' => 'assoc',
287 'name' => 'cause',
288 'description' => 'Label recorded in the purge log for purge-url. Default "CLI".',
289 'optional' => true,
290 ),
291 ),
292 ),
293 );
294 }
295
296 public function cli_handler( array $args, array $assoc ): void {
297 $action = isset( $args[0] ) ? (string) $args[0] : 'status';
298 $limit = isset( $assoc['limit'] ) ? max( 1, (int) $assoc['limit'] ) : 20;
299
300 /*
301 * Force a fresh static-rewrite probe. The result is cached for five
302 * minutes and nothing invalidated it, so after fixing an nginx config
303 * there was no way to re-check — the "configure your server" banner
304 * just stayed up. (FBS-84012)
305 */
306 if ( 'recheck-rewrite' === $action ) {
307 // Qualify the raw probe against known config refusals before
308 // reporting. The probe fetches its OWN file from the static tree,
309 // which succeeds even when no real page is served that way — so
310 // an unqualified `active` reported "the web server is serving
311 // cache hits directly" on sites whose every page returned
312 // HIT (php). See Cache::qualify_rewrite_probe().
313 $probe = \XSpeed\Cache::qualify_rewrite_probe( \XSpeed\Cache::recheck_static_rewrite() );
314 $blocked = '' !== (string) $probe['block_reason'];
315
316 if ( $probe['active'] ) {
317 \WP_CLI::success( 'Static rewrite is active — the web server is serving cache hits directly.' );
318 return;
319 }
320 if ( $blocked ) {
321 \WP_CLI::warning( sprintf( 'Static rewrite is not active: %s', (string) $probe['reason'] ) );
322 return;
323 }
324 if ( $probe['inconclusive'] ) {
325 \WP_CLI::warning( sprintf( 'Could not verify the static rewrite: %s', (string) $probe['reason'] ) );
326 \WP_CLI::log( 'This is a probe failure, not proof that your server config is wrong.' );
327 return;
328 }
329 \WP_CLI::warning( sprintf( 'Static rewrite is not active: %s', (string) ( $probe['reason'] ?: 'unknown' ) ) );
330 return;
331 }
332
333 if ( 'purge-url' === $action ) {
334 $url = isset( $args[1] ) ? trim( (string) $args[1] ) : '';
335 if ( '' === $url ) {
336 \WP_CLI::error( 'Usage: wp xspeed cache purge-url <url-or-path>' );
337 return;
338 }
339 $cause = isset( $assoc['cause'] ) && '' !== trim( (string) $assoc['cause'] ) ? trim( (string) $assoc['cause'] ) : 'CLI';
340 $removed = \XSpeed\Cache::purge_url( $url, $cause );
341 if ( $removed > 0 ) {
342 \WP_CLI::success( sprintf( 'Purged %d cache file(s) for %s', $removed, $url ) );
343 } else {
344 \WP_CLI::log( sprintf( 'No cache entries found for %s (already cold, or the URL never cached).', $url ) );
345 }
346 return;
347 }
348
349 if ( 'inventory' === $action ) {
350 $this->cli_inventory( $limit );
351 return;
352 }
353
354 if ( 'size' === $action ) {
355 $this->cli_size();
356 return;
357 }
358
359 if ( 'purge-log' === $action ) {
360 $this->cli_purge_log( $limit );
361 return;
362 }
363
364 $opts = Settings_Manager::get( self::SLUG );
365 \WP_CLI::log( 'cache_expiry ' . $opts['cache_expiry'] . 'h' );
366 \WP_CLI::log( 'excluded_urls ' . count( $opts['excluded_urls'] ) . ' entries' );
367 foreach ( $opts['excluded_urls'] as $u ) {
368 \WP_CLI::log( ' - ' . $u );
369 }
370 }
371
372 /** `wp xspeed cache inventory [--limit=N]` — which pages are cached, and how old. */
373 private function cli_inventory( int $limit ): void {
374 $data = \XSpeed\Cache_Inventory::entries( $limit );
375
376 if ( empty( $data['entries'] ) ) {
377 \WP_CLI::log( 'Cache is empty — no cached pages on disk.' );
378 return;
379 }
380
381 \WP_CLI::log( sprintf( '%d cached page(s); showing %d.', $data['total'], count( $data['entries'] ) ) );
382 if ( ! empty( $data['capped'] ) ) {
383 \WP_CLI::warning( sprintf( 'Scan stopped at %d files — the list is a recent sample, not the whole cache.', \XSpeed\Cache_Inventory::SCAN_CAP ) );
384 }
385 foreach ( $data['entries'] as $entry ) {
386 \WP_CLI::log(
387 sprintf(
388 ' %-58s %8s %s [%s]',
389 null === $entry['url'] ? '(url unknown: ' . $entry['key'] . ')' : $entry['url'],
390 size_format( (int) $entry['bytes'] ),
391 $this->relative_age( (int) $entry['age'] ),
392 implode( '+', (array) $entry['stored_in'] )
393 )
394 );
395 }
396 }
397
398 /** `wp xspeed cache size` — where the cache's disk usage goes. */
399 private function cli_size(): void {
400 $data = \XSpeed\Cache_Inventory::size_breakdown();
401
402 \WP_CLI::log( sprintf( 'Total %s across %d file(s).', size_format( (int) $data['total_bytes'] ), (int) $data['total_files'] ) );
403 foreach ( $data['buckets'] as $bucket ) {
404 if ( 0 === (int) $bucket['files'] ) {
405 continue;
406 }
407 \WP_CLI::log( sprintf( ' %-32s %10s %d file(s)', $bucket['label'], size_format( (int) $bucket['bytes'] ), (int) $bucket['files'] ) );
408 }
409 if ( (int) $data['compressed_bytes'] > 0 ) {
410 \WP_CLI::log( sprintf( 'Precompressed on disk: %s (pages without a precompressed copy are compressed by the web server at request time).', size_format( (int) $data['compressed_bytes'] ) ) );
411 }
412 }
413
414 /** `wp xspeed cache purge-log [--limit=N]` — what cleared the cache, when, and why. */
415 private function cli_purge_log( int $limit ): void {
416 $data = \XSpeed\Cache_Inventory::purge_log( $limit );
417
418 if ( empty( $data['events'] ) ) {
419 \WP_CLI::log( 'No purge events recorded yet.' );
420 return;
421 }
422 foreach ( $data['events'] as $event ) {
423 \WP_CLI::log( sprintf( ' %s %s', $this->relative_age( max( 0, time() - (int) $event['ts'] ) ), $event['message'] ) );
424 }
425 }
426
427 /** Compact "4h ago" for CLI columns. */
428 private function relative_age( int $seconds ): string {
429 if ( $seconds < 60 ) {
430 return $seconds . 's ago';
431 }
432 if ( $seconds < 3600 ) {
433 return (int) floor( $seconds / 60 ) . 'm ago';
434 }
435 if ( $seconds < 86400 ) {
436 return (int) floor( $seconds / 3600 ) . 'h ago';
437 }
438 return (int) floor( $seconds / 86400 ) . 'd ago';
439 }
440
441 /**
442 * Static-rewrite directives for the unified nginx server-block
443 * snippet. Returns null when cache is disabled — there's no rewrite
444 * to install in that state. Delegates to \XSpeed\Cache::nginx_snippet()
445 * which already produces nginx-detection-gated output.
446 */
447 public function nginx_directives(): ?string {
448 $opts = get_option( 'xspeed_options', array() );
449 if ( empty( $opts['cache_enabled'] ) ) {
450 return null;
451 }
452 return \XSpeed\Cache::nginx_snippet();
453 }
454 }
455