PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.4
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 / modules / Cache / CacheModule.php

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

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