PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / trunk
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN vtrunk
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 1.2.1 1.2.2 1.2.3
xspeed / includes / modules / Cache / CacheModule.php

CacheModule.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN trunk, 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