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

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

416 lines 14.1 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 }
201 );
202 }
203
204 public function activate(): void {
205 $this->seed_from_legacy_if_needed();
206 }
207
208 private function seed_from_legacy_if_needed(): void {
209 if ( null !== get_option( 'xspeed_module_cache', null ) ) {
210 return;
211 }
212 $legacy = get_option( 'xspeed_options', array() );
213 if ( ! is_array( $legacy ) ) {
214 return;
215 }
216 $seed = array( '_version' => self::VERSION );
217 $dirty = false;
218 if ( array_key_exists( 'cache_expiry', $legacy ) ) {
219 $seed['cache_expiry'] = max( 1, min( 720, (int) $legacy['cache_expiry'] ) );
220 unset( $legacy['cache_expiry'] );
221 $dirty = true;
222 }
223 if ( array_key_exists( 'excluded_urls', $legacy ) ) {
224 $seed['excluded_urls'] = is_array( $legacy['excluded_urls'] ) ? array_values( array_filter( $legacy['excluded_urls'], 'is_string' ) ) : array();
225 unset( $legacy['excluded_urls'] );
226 $dirty = true;
227 }
228 if ( $dirty ) {
229 update_option( 'xspeed_module_cache', $seed );
230 update_option( 'xspeed_options', $legacy );
231 }
232 }
233
234 public function cli_commands(): array {
235 return array(
236 array(
237 'name' => 'xspeed cache',
238 'callback' => array( $this, 'cli_handler' ),
239 '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).',
240 'synopsis' => array(
241 array(
242 'type' => 'positional',
243 'name' => 'action',
244 'options' => array( 'status', 'inventory', 'size', 'purge-log', 'purge-url', 'recheck-rewrite' ),
245 'optional' => true,
246 ),
247 array(
248 'type' => 'positional',
249 'name' => 'url',
250 'optional' => true,
251 ),
252 array(
253 'type' => 'assoc',
254 'name' => 'limit',
255 'description' => 'Rows to print for inventory / purge-log. Default 20.',
256 'optional' => true,
257 ),
258 array(
259 'type' => 'assoc',
260 'name' => 'cause',
261 'description' => 'Label recorded in the purge log for purge-url. Default "CLI".',
262 'optional' => true,
263 ),
264 ),
265 ),
266 );
267 }
268
269 public function cli_handler( array $args, array $assoc ): void {
270 $action = isset( $args[0] ) ? (string) $args[0] : 'status';
271 $limit = isset( $assoc['limit'] ) ? max( 1, (int) $assoc['limit'] ) : 20;
272
273 /*
274 * Force a fresh static-rewrite probe. The result is cached for five
275 * minutes and nothing invalidated it, so after fixing an nginx config
276 * there was no way to re-check — the "configure your server" banner
277 * just stayed up. (FBS-84012)
278 */
279 if ( 'recheck-rewrite' === $action ) {
280 $probe = \XSpeed\Cache::recheck_static_rewrite();
281 if ( ! empty( $probe['active'] ) ) {
282 \WP_CLI::success( 'Static rewrite is active — the web server is serving cache hits directly.' );
283 return;
284 }
285 if ( ! empty( $probe['inconclusive'] ) ) {
286 \WP_CLI::warning( sprintf( 'Could not verify the static rewrite: %s', (string) ( $probe['reason'] ?? 'unknown' ) ) );
287 \WP_CLI::log( 'This is a probe failure, not proof that your server config is wrong.' );
288 return;
289 }
290 \WP_CLI::warning( sprintf( 'Static rewrite is not active: %s', (string) ( $probe['reason'] ?? 'unknown' ) ) );
291 return;
292 }
293
294 if ( 'purge-url' === $action ) {
295 $url = isset( $args[1] ) ? trim( (string) $args[1] ) : '';
296 if ( '' === $url ) {
297 \WP_CLI::error( 'Usage: wp xspeed cache purge-url <url-or-path>' );
298 return;
299 }
300 $cause = isset( $assoc['cause'] ) && '' !== trim( (string) $assoc['cause'] ) ? trim( (string) $assoc['cause'] ) : 'CLI';
301 $removed = \XSpeed\Cache::purge_url( $url, $cause );
302 if ( $removed > 0 ) {
303 \WP_CLI::success( sprintf( 'Purged %d cache file(s) for %s', $removed, $url ) );
304 } else {
305 \WP_CLI::log( sprintf( 'No cache entries found for %s (already cold, or the URL never cached).', $url ) );
306 }
307 return;
308 }
309
310 if ( 'inventory' === $action ) {
311 $this->cli_inventory( $limit );
312 return;
313 }
314
315 if ( 'size' === $action ) {
316 $this->cli_size();
317 return;
318 }
319
320 if ( 'purge-log' === $action ) {
321 $this->cli_purge_log( $limit );
322 return;
323 }
324
325 $opts = Settings_Manager::get( self::SLUG );
326 \WP_CLI::log( 'cache_expiry ' . $opts['cache_expiry'] . 'h' );
327 \WP_CLI::log( 'excluded_urls ' . count( $opts['excluded_urls'] ) . ' entries' );
328 foreach ( $opts['excluded_urls'] as $u ) {
329 \WP_CLI::log( ' - ' . $u );
330 }
331 }
332
333 /** `wp xspeed cache inventory [--limit=N]` — which pages are cached, and how old. */
334 private function cli_inventory( int $limit ): void {
335 $data = \XSpeed\Cache_Inventory::entries( $limit );
336
337 if ( empty( $data['entries'] ) ) {
338 \WP_CLI::log( 'Cache is empty — no cached pages on disk.' );
339 return;
340 }
341
342 \WP_CLI::log( sprintf( '%d cached page(s); showing %d.', $data['total'], count( $data['entries'] ) ) );
343 if ( ! empty( $data['capped'] ) ) {
344 \WP_CLI::warning( sprintf( 'Scan stopped at %d files — the list is a recent sample, not the whole cache.', \XSpeed\Cache_Inventory::SCAN_CAP ) );
345 }
346 foreach ( $data['entries'] as $entry ) {
347 \WP_CLI::log(
348 sprintf(
349 ' %-58s %8s %s [%s]',
350 null === $entry['url'] ? '(url unknown: ' . $entry['key'] . ')' : $entry['url'],
351 size_format( (int) $entry['bytes'] ),
352 $this->relative_age( (int) $entry['age'] ),
353 implode( '+', (array) $entry['stored_in'] )
354 )
355 );
356 }
357 }
358
359 /** `wp xspeed cache size` — where the cache's disk usage goes. */
360 private function cli_size(): void {
361 $data = \XSpeed\Cache_Inventory::size_breakdown();
362
363 \WP_CLI::log( sprintf( 'Total %s across %d file(s).', size_format( (int) $data['total_bytes'] ), (int) $data['total_files'] ) );
364 foreach ( $data['buckets'] as $bucket ) {
365 if ( 0 === (int) $bucket['files'] ) {
366 continue;
367 }
368 \WP_CLI::log( sprintf( ' %-32s %10s %d file(s)', $bucket['label'], size_format( (int) $bucket['bytes'] ), (int) $bucket['files'] ) );
369 }
370 if ( (int) $data['compressed_bytes'] > 0 ) {
371 \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'] ) ) );
372 }
373 }
374
375 /** `wp xspeed cache purge-log [--limit=N]` — what cleared the cache, when, and why. */
376 private function cli_purge_log( int $limit ): void {
377 $data = \XSpeed\Cache_Inventory::purge_log( $limit );
378
379 if ( empty( $data['events'] ) ) {
380 \WP_CLI::log( 'No purge events recorded yet.' );
381 return;
382 }
383 foreach ( $data['events'] as $event ) {
384 \WP_CLI::log( sprintf( ' %s %s', $this->relative_age( max( 0, time() - (int) $event['ts'] ) ), $event['message'] ) );
385 }
386 }
387
388 /** Compact "4h ago" for CLI columns. */
389 private function relative_age( int $seconds ): string {
390 if ( $seconds < 60 ) {
391 return $seconds . 's ago';
392 }
393 if ( $seconds < 3600 ) {
394 return (int) floor( $seconds / 60 ) . 'm ago';
395 }
396 if ( $seconds < 86400 ) {
397 return (int) floor( $seconds / 3600 ) . 'h ago';
398 }
399 return (int) floor( $seconds / 86400 ) . 'd ago';
400 }
401
402 /**
403 * Static-rewrite directives for the unified nginx server-block
404 * snippet. Returns null when cache is disabled — there's no rewrite
405 * to install in that state. Delegates to \XSpeed\Cache::nginx_snippet()
406 * which already produces nginx-detection-gated output.
407 */
408 public function nginx_directives(): ?string {
409 $opts = get_option( 'xspeed_options', array() );
410 if ( empty( $opts['cache_enabled'] ) ) {
411 return null;
412 }
413 return \XSpeed\Cache::nginx_snippet();
414 }
415 }
416