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

750 lines 29.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 /**
34 * Default cache lifetime in hours (7 days).
35 *
36 * Named so the readers that need a fallback share ONE value with the
37 * schema below. Three of them carried their own hardcoded `?? 24`, which
38 * silently became a stale copy the moment the default moved. They are
39 * unreachable today — Settings_Manager::get() always merges defaults —
40 * but an unreachable wrong number is still a trap for the next change.
41 * (#284 B5)
42 */
43 public const DEFAULT_EXPIRY_HOURS = 24 * 7;
44
45 public function ui_metadata(): array {
46 return array(
47 'label' => 'Page Cache',
48 'icon' => 'Database',
49 'description' => 'Page caching for non-logged-in visitors.',
50 );
51 }
52
53 /**
54 * @inheritDoc
55 *
56 * Nothing exempt. Purging only ever touches xSpeed's own cache, so on an
57 * occupied site the setting is inert either way — but a host installing xSpeed
58 * on a user's behalf should leave nothing switched on that the user did not
59 * ask for, and "inert today" is a weak reason to make an exception.
60 */
61 public function conflict_safe_exempt(): array {
62 return array();
63 }
64
65 public function settings_schema(): array {
66 return array(
67 'cache_expiry' => array(
68 'type' => 'int',
69 // Matches the wizard's Balanced preset, which is what a fresh
70 // install starts on — a shorter module default meant the two
71 // disagreed about what "default" means. (#284)
72 'default' => self::DEFAULT_EXPIRY_HOURS,
73 'min' => 1,
74 'max' => 720,
75 'label' => 'Cache Expiry (hours)',
76 'unit' => 'hours',
77 'description' => 'How long cached pages live before regenerating. 1 to 720 hours (30 days).',
78 ),
79 'excluded_urls' => array(
80 'type' => 'list',
81 // Comprehensive LiteSpeed / WP Rocket-parity default URL
82 // exclusions (FBS-82181). Plain text = "contains", glob via
83 // * ? [ ], or a `~` prefix for raw regex (e.g. ~wp-.*\.php).
84 'default' => array(
85 '/wp-admin/',
86 '/wp-json/',
87 '/xmlrpc.php',
88 '~wp-.*\.php',
89 '/feed/',
90 'index.php',
91 '~sitemap(_index)?\.xml',
92 // Bare (no trailing slash) so "contains" matches both
93 // /cart and /cart/items — WooCommerce serves both forms.
94 '/cart',
95 '/checkout',
96 '/my-account',
97 'ao_noptirocket',
98 'ao_speedup_cachebuster',
99 'removed_item',
100 '/wc-api',
101 '/edd-api',
102 '/wp-login',
103 ),
104 'item_type' => 'string',
105 'label' => 'Excluded URLs',
106 '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).',
107 ),
108 'excluded_cookies' => array(
109 'type' => 'list',
110 // Cookies that signal a logged-in / transactional visitor
111 // whose response must not be served from a shared cache.
112 // `~` prefix = raw regex (e.g. ~wordpress_[a-f0-9]+). (FBS-82181)
113 'default' => array(
114 'comment_author',
115 '~wordpress_[a-f0-9]+',
116 'wp-postpass',
117 'wordpress_no_cache',
118 'wordpress_logged_in',
119 'edd_items_in_cart',
120 'woocommerce_items_in_cart',
121 'fct_cart_hash',
122 'comment_',
123 'woocommerce_',
124 'wordpress',
125 'xf_',
126 'edd_',
127 'jetpack',
128 'yith_wcwl_session_',
129 'yith_wrvp_',
130 'wpsc_',
131 'ecwid',
132 'ec_',
133 'bookly',
134 ),
135 'item_type' => 'string',
136 'label' => 'Excluded Cookies',
137 '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.',
138 ),
139 'bypass_user_agents' => array(
140 'type' => 'list',
141 'default' => array(),
142 'item_type' => 'string',
143 'label' => 'Bypass User Agents',
144 '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.',
145 ),
146 'ignored_query_params' => array(
147 'type' => 'list',
148 // Analytics / ad / session query keys stripped before the
149 // cache key is computed, so /post?utm_source=x and /post
150 // share one entry. `~` prefix = raw regex. (FBS-82181)
151 // Matched whole-name, so every entry here means the param
152 // it names and nothing that merely contains it.
153 'default' => array(
154 '__s',
155 '_ga',
156 '_ke',
157 '~[a-zA-Z0-9_-]+_sid',
158 'adgroupid',
159 'age-verified',
160 'ao_noptimize',
161 'campaignid',
162 'ck_subscriber_id',
163 'cn-reloaded',
164 'dclid',
165 'epik',
166 'fb_action_ids',
167 'fb_action_types',
168 'fb_source',
169 'fbclid',
170 'gclid',
171 'jobid',
172 'mc_cid',
173 'mc_eid',
174 'mkt_tok',
175 'msclkid',
176 'ref',
177 // Twitter/X (`ref_src`, `ref_url`) and Facebook (`refid`)
178 // decorations. Enumerated because param names match
179 // whole-name: the bare `ref` above no longer absorbs them,
180 // and a `ref*` glob would over-match `referrer` and
181 // `refund_id`, which are page-selecting.
182 'ref_src',
183 'ref_url',
184 'refid',
185 '~session_[a-zA-Z0-9_-]+_alive',
186 'sseid',
187 'sslid',
188 'usqp',
189 '~utm_[a-zA-Z0-9_-]+',
190 ),
191 'item_type' => 'string',
192 'label' => 'Ignored Query Parameters',
193 '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. Each entry matches a whole param name — plain text is an exact name, and glob (utm_*) or ~regex are anchored too, so "ref" does not also match "preference". One per line.',
194 ),
195 'purge_on_upgrade' => array(
196 'type' => 'bool',
197 'default' => true,
198 'label' => 'Purge After Updates',
199 'description' => 'Clear the page cache when a plugin, theme or WordPress core is updated. Cached HTML is produced by the code being replaced, so leaving it in place serves pre-update markup — and links to minified assets that no longer exist — until the cache expires. Translation updates are ignored, since a language pack changes no markup a cached page depends on. Updates to xSpeed itself always purge, regardless of this setting.',
200 ),
201 'mobile_separate' => array(
202 'type' => 'bool',
203 'default' => false,
204 'label' => 'Separate Mobile Cache',
205 '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.',
206 ),
207 );
208 }
209
210 /**
211 * `mobile_separate_review` lives outside the schema: migration sets it
212 * (bool) when a source plugin had "separate mobile cache" on, so the
213 * dashboard can prompt the user to re-enable it deliberately instead of
214 * silently importing it (which would kill the device-blind static fast
215 * path). Without preserving it here, the first schema-driven cache save
216 * would rebuild the option from the schema alone and drop the flag before
217 * the user ever saw the prompt. (FBS-83145)
218 *
219 * @return string[]
220 */
221 public function preserved_keys(): array {
222 return array( 'mobile_separate_review' );
223 }
224
225 /**
226 * Seed per-module option from the legacy xspeed_options blob if we
227 * haven't done so yet. Idempotent — once xspeed_module_cache exists
228 * or the legacy keys are gone, this is a no-op. Runs on both boot
229 * and activate so installs on every code path are covered.
230 */
231 public function boot(): void {
232 $this->seed_from_legacy_if_needed();
233
234 // Keep every mobile_separate-dependent artifact (the drop-in's
235 // `.mobile-separate` flag, the device-blind server rewrite, and the
236 // device-keyed caches) in lockstep with the setting — on boot, and
237 // whenever the cache settings are saved. The drop-in can't read WP
238 // options, so it reads the sidecar marker Cache maintains here.
239 \XSpeed\Cache::reconcile_mobile_separate();
240 // Both write paths matter. On a fresh install `xspeed_module_cache`
241 // does not exist yet, so core's update_option() delegates to
242 // add_option() and fires `add_option_…` INSTEAD of
243 // `update_option_…`. Hooking only the latter meant the very first
244 // save of Cache Expiry never re-baked the drop-in: the panel and the
245 // DB read the new value while the drop-in kept enforcing the old
246 // one, and re-saving the same value could not recover it because
247 // update_option() short-circuits on an unchanged value (#251).
248 $xspeed_resync_cache_artifacts = static function () {
249 \XSpeed\Cache::reconcile_mobile_separate();
250 // Re-bake the cookie / user-agent exclusion rules into the
251 // drop-in. It runs before WordPress loads and so carries a
252 // COPY of those rules, substituted at install time — and
253 // auto_heal() deliberately only reinstalls when the file is
254 // missing, foreign, or an older version, none of which a
255 // settings change makes true. Without this, adding an
256 // excluded cookie left the drop-in serving the shared
257 // anonymous page to exactly the visitors it excluded, until
258 // the next plugin upgrade happened to reinstall it.
259 //
260 // ONLY when page caching is actually on, for the same reason
261 // refresh_rewrite_if_installed() below refuses to write a
262 // block that isn't there: re-baking is maintenance of an
263 // artifact the user opted into, never a way to acquire one.
264 // Re-baking unconditionally reached past our own module — a
265 // site that had declined our page cache got the drop-in
266 // installed anyway on the next Cache Expiry save, and the
267 // following toggle(false) then removed it. auto_heal() has
268 // always gated on this flag; this path simply never did.
269 // (#251)
270 //
271 // Through toggle() rather than install_dropin() so the re-bake
272 // gets the same ownership check, lock and rollback as every
273 // other page-cache write. A drop-in that turned out not to be
274 // ours between the save and now is refused here too.
275 $xspeed_options = get_option( 'xspeed_options', array() );
276 if ( ! empty( $xspeed_options['cache_enabled'] ) ) {
277 \XSpeed\Cache::toggle( true );
278 }
279 // Same staleness applies to the .htaccess block, which is
280 // written to disk from the same generator. Refresh it only
281 // when a block is already installed — writing one here would
282 // enable the static path on a site that never opted in.
283 \XSpeed\Cache::refresh_rewrite_if_installed();
284 };
285 add_action( 'update_option_xspeed_module_cache', $xspeed_resync_cache_artifacts );
286 add_action( 'add_option_xspeed_module_cache', $xspeed_resync_cache_artifacts );
287
288 // Time-driven collection of expired entries and superseded minified
289 // assets. Scheduled here as well as in activate() because a site that
290 // upgrades into this version never runs the activation hook again.
291 add_action( \XSpeed\Cache_GC::CRON_HOOK, array( \XSpeed\Cache_GC::class, 'run' ) );
292 \XSpeed\Cache_GC::ensure_scheduled();
293 }
294
295 public function activate(): void {
296 $this->seed_from_legacy_if_needed();
297 \XSpeed\Cache_GC::ensure_scheduled();
298 }
299
300 public function deactivate(): void {
301 \XSpeed\Cache_GC::unschedule();
302 }
303
304 private function seed_from_legacy_if_needed(): void {
305 if ( null !== get_option( 'xspeed_module_cache', null ) ) {
306 return;
307 }
308 $legacy = get_option( 'xspeed_options', array() );
309 if ( ! is_array( $legacy ) ) {
310 return;
311 }
312 $seed = array( '_version' => self::VERSION );
313 $dirty = false;
314 if ( array_key_exists( 'cache_expiry', $legacy ) ) {
315 $seed['cache_expiry'] = max( 1, min( 720, (int) $legacy['cache_expiry'] ) );
316 unset( $legacy['cache_expiry'] );
317 $dirty = true;
318 }
319 if ( array_key_exists( 'excluded_urls', $legacy ) ) {
320 $seed['excluded_urls'] = is_array( $legacy['excluded_urls'] ) ? array_values( array_filter( $legacy['excluded_urls'], 'is_string' ) ) : array();
321 unset( $legacy['excluded_urls'] );
322 $dirty = true;
323 }
324 if ( $dirty ) {
325 update_option( 'xspeed_module_cache', $seed );
326 update_option( 'xspeed_options', $legacy );
327 }
328 }
329
330 public function cli_commands(): array {
331 return array(
332 array(
333 'name' => 'xspeed optimize',
334 'callback' => array( $this, 'cli_optimize' ),
335 'shortdesc' => 'Measure, apply the recommended settings one at a time, verify the page still works after each, and report what changed. Use --dry-run to see the plan without touching anything.',
336 'synopsis' => array(
337 array(
338 'type' => 'assoc',
339 'name' => 'aggressiveness',
340 'description' => 'safe (removals + server-side only), standard (default), or aggressive (includes settings known to break some themes).',
341 'optional' => true,
342 'options' => array( 'safe', 'standard', 'aggressive' ),
343 ),
344 array(
345 'type' => 'flag',
346 'name' => 'dry-run',
347 'description' => 'Show the plan and stop. Changes nothing.',
348 'optional' => true,
349 ),
350 array(
351 'type' => 'assoc',
352 'name' => 'budget',
353 'description' => 'Seconds to spend before stopping between steps. Default 120.',
354 'optional' => true,
355 ),
356 ),
357 ),
358 array(
359 'name' => 'xspeed cache',
360 'callback' => array( $this, 'cli_handler' ),
361 '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, `recheck-rewrite` to re-run the static-rewrite probe, or `nginx-config` to print the unified nginx server-block for pasting into a vhost (site-wide purge / toggle use the dedicated commands).',
362 'synopsis' => array(
363 array(
364 'type' => 'positional',
365 'name' => 'action',
366 'options' => array( 'status', 'inventory', 'size', 'purge-log', 'purge-url', 'recheck-rewrite', 'nginx-config' ),
367 'optional' => true,
368 ),
369 array(
370 'type' => 'positional',
371 'name' => 'url',
372 'optional' => true,
373 ),
374 array(
375 'type' => 'assoc',
376 'name' => 'limit',
377 'description' => 'Rows to print for inventory / purge-log. Default 20.',
378 'optional' => true,
379 ),
380 array(
381 'type' => 'assoc',
382 'name' => 'cause',
383 'description' => 'Label recorded in the purge log for purge-url. Default "CLI".',
384 'optional' => true,
385 ),
386 array(
387 'type' => 'assoc',
388 'name' => 'server',
389 'description' => 'Server type to assume for nginx-config, skipping detection. Detection needs SERVER_SOFTWARE, which the command line does not have; an undetectable host is assumed to be nginx anyway, so this is for stating it outright — or for the case detection is positively wrong, such as nginx in front of Apache.',
390 'options' => array( 'nginx', 'apache', 'litespeed' ),
391 'optional' => true,
392 ),
393 ),
394 ),
395 );
396 }
397
398 /**
399 * `wp xspeed optimize` — run the autopilot.
400 *
401 * Prints what it DID, not what it hoped to do: applied steps, reverted
402 * steps with the reason they were undone, and the problems it could not
403 * touch. A run that changes nothing prints that plainly rather than a
404 * success banner.
405 *
406 * @param array<int,string> $args Positional args (unused).
407 * @param array<string,string> $assoc Flags.
408 */
409 public function cli_optimize( array $args, array $assoc ): void {
410 $result = \XSpeed\Optimize_Runner::run(
411 array(
412 'aggressiveness' => (string) ( $assoc['aggressiveness'] ?? 'standard' ),
413 'dry_run' => isset( $assoc['dry-run'] ),
414 'budget_seconds' => isset( $assoc['budget'] ) ? (int) $assoc['budget'] : 120,
415 )
416 );
417
418 if ( is_wp_error( $result ) ) {
419 \WP_CLI::error( $result->get_error_message() );
420 return;
421 }
422
423 if ( ! empty( $result['dry_run'] ) ) {
424 \WP_CLI::log( 'Plan (' . count( $result['plan'] ) . ' steps, nothing applied):' );
425 foreach ( $result['plan'] as $step ) {
426 \WP_CLI::log( ' - ' . $step['change'] . ' [' . $step['tier'] . ']' );
427 }
428 foreach ( $result['skipped'] as $row ) {
429 \WP_CLI::log( ' skipped: ' . $row['id'] . '' . $row['why'] );
430 }
431 return;
432 }
433
434 if ( isset( $result['message'] ) ) {
435 \WP_CLI::success( (string) $result['message'] );
436 }
437
438 foreach ( $result['applied'] as $row ) {
439 \WP_CLI::log( '' . $row['change'] );
440 }
441 foreach ( $result['reverted'] as $row ) {
442 \WP_CLI::warning( 'Undone: ' . $row['id'] . '' . $row['why'] );
443 }
444 foreach ( $result['unfixable'] as $row ) {
445 \WP_CLI::log( ' ! ' . $row['issue'] . ( '' !== $row['fix'] ? '' . $row['fix'] : '' ) );
446 }
447
448 if ( ! empty( $result['applied'] ) ) {
449 \WP_CLI::success( count( $result['applied'] ) . ' change(s) applied and verified.' );
450 }
451 }
452
453 public function cli_handler( array $args, array $assoc ): void {
454 $action = isset( $args[0] ) ? (string) $args[0] : 'status';
455 $limit = isset( $assoc['limit'] ) ? max( 1, (int) $assoc['limit'] ) : 20;
456
457 /*
458 * Print the unified nginx server-block so an installer, provisioning
459 * script, or another plugin can fetch it non-interactively and write
460 * it into a vhost. Previously this was only reachable via
461 * `wp eval 'echo \XSpeed\Cache::full_nginx_server_block();'`, which
462 * is not a supported surface (and is unavailable over MCP, where
463 * run_command dispatches these same callbacks).
464 *
465 * Output discipline matters here: the config goes to STDOUT with
466 * nothing else, so `wp xspeed cache nginx-config > site.conf` yields a
467 * pasteable file. Every diagnostic goes to STDERR via WP_CLI::warning
468 * / ::error, and a non-nginx host or an empty block exits non-zero so
469 * a script can branch on it rather than writing an empty file.
470 *
471 * --server exists because detection cannot work here. WP-CLI runs
472 * without SERVER_SOFTWARE, so Server::type() falls back to the value
473 * a previous web request cached — and on a site provisioned entirely
474 * over WP-CLI there is no such value, leaving `unknown` on a genuine
475 * nginx host. Rather than guess (a loopback request is the one thing
476 * least likely to work mid-provisioning), let the caller state it:
477 * the script writing to /etc/nginx/ already knows the answer.
478 * Without the flag nothing changes, so a script sweeping a mixed
479 * fleet still gets its non-zero exit on Apache.
480 *
481 * It pins Server::type() rather than being passed down, because the
482 * decision is re-made at every level: full_nginx_server_block(),
483 * Cache::nginx_snippet(), and each module's own nginx_directives()
484 * all ask independently. Threading an argument through would leave
485 * the deeper gates still detecting, and the command would emit a
486 * config missing its cache rewrite — worse than refusing outright.
487 */
488 if ( 'nginx-config' === $action ) {
489 /*
490 * Scoped to this one generation pass, not the request. Under
491 * real WP-CLI the process ends here either way, but the same
492 * callback runs over MCP, where several commands share one PHP
493 * request — a pin left in place made the NEXT command report
494 * this host as nginx too.
495 */
496 $pin = null;
497 $assume = null;
498
499 if ( isset( $assoc['server'] ) ) {
500 $assume = strtolower( trim( (string) $assoc['server'] ) );
501 } elseif ( \XSpeed\Server::UNKNOWN === \XSpeed\Server::type() ) {
502 /*
503 * Nothing to detect from, and the action names the server:
504 * `nginx-config` is the request, so absence of evidence
505 * defers to it. Positive evidence to the contrary still
506 * wins — an Apache or LiteSpeed host is told it needs no
507 * nginx block at all, which is the answer that helps.
508 */
509 $assume = \XSpeed\Server::NGINX;
510
511 /*
512 * Only where warnings have somewhere else to go. Real WP-CLI
513 * sends them to STDERR, leaving the config clean on STDOUT.
514 * The MCP shim has ONE buffer for both, so warning there
515 * would prepend "Warning: …" to the config itself and hand
516 * the caller a file nginx refuses. The constant is the
517 * discriminator: real WP-CLI defines it, the shim defines
518 * only the class.
519 */
520 if ( defined( 'WP_CLI' ) && \WP_CLI ) {
521 \WP_CLI::warning(
522 'Could not detect the web server — no recognisable SERVER_SOFTWARE, and no web request has cached one yet. Assuming nginx, which is what this command generates. Pass --server= to state it explicitly, or load any page once to settle detection.'
523 );
524 }
525 }
526
527 if ( null !== $assume ) {
528 $pinned = $assume;
529 $pin = static function () use ( $pinned ) {
530 return $pinned;
531 };
532 add_filter( 'xspeed_server_type', $pin );
533 }
534
535 $block = \XSpeed\Cache::full_nginx_server_block();
536 $server = \XSpeed\Server::type();
537
538 if ( null !== $pin ) {
539 remove_filter( 'xspeed_server_type', $pin );
540 }
541
542 if ( ! is_string( $block ) || '' === trim( $block ) ) {
543 /*
544 * $server cannot be UNKNOWN here: an undetectable host was
545 * already assumed to be nginx above, so anything left is a
546 * server we positively identified — and telling an Apache or
547 * LiteSpeed operator that .htaccess already covers them is
548 * more useful than handing them a block to paste nowhere.
549 */
550 if ( \XSpeed\Server::NGINX !== $server ) {
551 \WP_CLI::error(
552 sprintf(
553 'No nginx server-block to print — this site is running on %s. On Apache and LiteSpeed xSpeed writes its rules to .htaccess automatically.',
554 $server
555 )
556 );
557 return;
558 }
559 \WP_CLI::error( 'No nginx directives to print — page caching and every module that contributes directives are currently disabled.' );
560 return;
561 }
562
563 // STDOUT only: no WP_CLI::log() prefixing, so redirection gives a
564 // clean file. WP_CLI::line() writes the raw string.
565 \WP_CLI::line( rtrim( $block, "\n" ) );
566 return;
567 }
568
569 /*
570 * Force a fresh static-rewrite probe. The result is cached for five
571 * minutes and nothing invalidated it, so after fixing an nginx config
572 * there was no way to re-check — the "configure your server" banner
573 * just stayed up. (FBS-84012)
574 */
575 if ( 'recheck-rewrite' === $action ) {
576 // Qualify the raw probe against known config refusals before
577 // reporting. The probe fetches its OWN file from the static tree,
578 // which succeeds even when no real page is served that way — so
579 // an unqualified `active` reported "the web server is serving
580 // cache hits directly" on sites whose every page returned
581 // HIT (php). See Cache::qualify_rewrite_probe().
582 $probe = \XSpeed\Cache::qualify_rewrite_probe( \XSpeed\Cache::recheck_static_rewrite() );
583 $blocked = '' !== (string) $probe['block_reason'];
584
585 if ( $probe['active'] ) {
586 \WP_CLI::success( 'Static rewrite is active — the web server is serving cache hits directly.' );
587 return;
588 }
589 if ( $blocked ) {
590 \WP_CLI::warning( sprintf( 'Static rewrite is not active: %s', (string) $probe['reason'] ) );
591 return;
592 }
593 if ( $probe['inconclusive'] ) {
594 \WP_CLI::warning( sprintf( 'Could not verify the static rewrite: %s', (string) $probe['reason'] ) );
595 \WP_CLI::log( 'This is a probe failure, not proof that your server config is wrong.' );
596 return;
597 }
598 \WP_CLI::warning( sprintf( 'Static rewrite is not active: %s', (string) ( $probe['reason'] ?: 'unknown' ) ) );
599 return;
600 }
601
602 if ( 'purge-url' === $action ) {
603 $url = isset( $args[1] ) ? trim( (string) $args[1] ) : '';
604 if ( '' === $url ) {
605 \WP_CLI::error( 'Usage: wp xspeed cache purge-url <url-or-path>' );
606 return;
607 }
608 $cause = isset( $assoc['cause'] ) && '' !== trim( (string) $assoc['cause'] ) ? trim( (string) $assoc['cause'] ) : 'CLI';
609 $removed = \XSpeed\Cache::purge_url( $url, $cause );
610 if ( $removed > 0 ) {
611 \WP_CLI::success( sprintf( 'Purged %d cache file(s) for %s', $removed, $url ) );
612 } else {
613 \WP_CLI::log( sprintf( 'No cache entries found for %s (already cold, or the URL never cached).', $url ) );
614 }
615 return;
616 }
617
618 if ( 'inventory' === $action ) {
619 $this->cli_inventory( $limit );
620 return;
621 }
622
623 if ( 'size' === $action ) {
624 $this->cli_size();
625 return;
626 }
627
628 if ( 'purge-log' === $action ) {
629 $this->cli_purge_log( $limit );
630 return;
631 }
632
633 $opts = Settings_Manager::get( self::SLUG );
634 \WP_CLI::log( 'cache_expiry ' . $opts['cache_expiry'] . 'h' );
635 \WP_CLI::log( 'excluded_urls ' . count( $opts['excluded_urls'] ) . ' entries' );
636 foreach ( $opts['excluded_urls'] as $u ) {
637 \WP_CLI::log( ' - ' . $u );
638 }
639 }
640
641 /** `wp xspeed cache inventory [--limit=N]` — which pages are cached, and how old. */
642 private function cli_inventory( int $limit ): void {
643 $data = \XSpeed\Cache_Inventory::entries( $limit );
644
645 if ( empty( $data['entries'] ) ) {
646 \WP_CLI::log( 'Cache is empty — no cached pages on disk.' );
647 return;
648 }
649
650 \WP_CLI::log( sprintf( '%d cached page(s); showing %d.', $data['total'], count( $data['entries'] ) ) );
651 if ( ! empty( $data['capped'] ) ) {
652 \WP_CLI::warning( sprintf( 'Scan stopped at %d files — the list is a recent sample, not the whole cache.', \XSpeed\Cache_Inventory::SCAN_CAP ) );
653 }
654 foreach ( $data['entries'] as $entry ) {
655 \WP_CLI::log(
656 sprintf(
657 ' %-58s %8s %s [%s]',
658 null === $entry['url'] ? '(url unknown: ' . $entry['key'] . ')' : $entry['url'],
659 size_format( (int) $entry['bytes'] ),
660 $this->relative_age( (int) $entry['age'] ),
661 implode( '+', (array) $entry['stored_in'] )
662 )
663 );
664 }
665 }
666
667 /** `wp xspeed cache size` — where the cache's disk usage goes. */
668 private function cli_size(): void {
669 $data = \XSpeed\Cache_Inventory::size_breakdown();
670
671 \WP_CLI::log( sprintf( 'Total %s across %d file(s).', size_format( (int) $data['total_bytes'] ), (int) $data['total_files'] ) );
672 foreach ( $data['buckets'] as $bucket ) {
673 if ( 0 === (int) $bucket['files'] ) {
674 continue;
675 }
676 \WP_CLI::log( sprintf( ' %-32s %10s %d file(s)', $bucket['label'], size_format( (int) $bucket['bytes'] ), (int) $bucket['files'] ) );
677 }
678 if ( (int) $data['compressed_bytes'] > 0 ) {
679 \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'] ) ) );
680 }
681 }
682
683 /** `wp xspeed cache purge-log [--limit=N]` — what cleared the cache, when, and why. */
684 private function cli_purge_log( int $limit ): void {
685 $data = \XSpeed\Cache_Inventory::purge_log( $limit );
686
687 if ( empty( $data['events'] ) ) {
688 \WP_CLI::log( 'No purge events recorded yet.' );
689 return;
690 }
691 foreach ( $data['events'] as $event ) {
692 \WP_CLI::log( sprintf( ' %s %s', $this->relative_age( max( 0, time() - (int) $event['ts'] ) ), $event['message'] ) );
693 }
694 }
695
696 /** Compact "4h ago" for CLI columns. */
697 private function relative_age( int $seconds ): string {
698 if ( $seconds < 60 ) {
699 return $seconds . 's ago';
700 }
701 if ( $seconds < 3600 ) {
702 return (int) floor( $seconds / 60 ) . 'm ago';
703 }
704 if ( $seconds < 86400 ) {
705 return (int) floor( $seconds / 3600 ) . 'h ago';
706 }
707 return (int) floor( $seconds / 86400 ) . 'd ago';
708 }
709
710 /**
711 * Static-rewrite directives for the unified nginx server-block
712 * snippet. Returns null when cache is disabled — there's no rewrite
713 * to install in that state. Delegates to \XSpeed\Cache::nginx_snippet()
714 * which already produces nginx-detection-gated output.
715 */
716 public function nginx_directives(): ?string {
717 $opts = get_option( 'xspeed_options', array() );
718 if ( empty( $opts['cache_enabled'] ) ) {
719 return null;
720 }
721 return \XSpeed\Cache::nginx_snippet();
722 }
723
724 /**
725 * Page caching's master switch is `cache_enabled` in the GLOBAL
726 * `xspeed_options`, not a per-module `enabled` key -- Cache::toggle owns
727 * it because flipping it rewrites .htaccess and wp-config.php. The base
728 * implementation looks only at this module's own settings bag, so it
729 * found nothing and reported null: the plugin's headline feature was
730 * missing from its own "N on" count. (#363)
731 */
732 public function is_active(): ?bool {
733 $opts = get_option( 'xspeed_options', array() );
734 return ! empty( $opts['cache_enabled'] );
735 }
736
737 /**
738 * No reason shown: page caching has a single master switch, so the pill
739 * already says everything an (i) would. The switch lives on the Overview
740 * rather than on this page, but that is a "where is the control" question
741 * the panel itself should answer, not a reason to explain the verdict.
742 *
743 * The (i) is reserved for modules whose on/off is genuinely non-obvious
744 * -- counted from several flags, or from state outside the settings.
745 */
746 public function active_reason(): ?string {
747 return null;
748 }
749 }
750