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

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