'Page Cache', 'icon' => 'Database', 'description' => 'Page caching for non-logged-in visitors.', ); } public function settings_schema(): array { return array( 'cache_expiry' => array( 'type' => 'int', 'default' => 24, 'min' => 1, 'max' => 720, 'label' => 'Cache Expiry (hours)', 'unit' => 'hours', 'description' => 'How long cached pages live before regenerating. 1 to 720 hours (30 days).', ), 'excluded_urls' => array( 'type' => 'list', // Comprehensive LiteSpeed / WP Rocket-parity default URL // exclusions (FBS-82181). Plain text = "contains", glob via // * ? [ ], or a `~` prefix for raw regex (e.g. ~wp-.*\.php). 'default' => array( '/wp-admin/', '/wp-json/', '/xmlrpc.php', '~wp-.*\.php', '/feed/', 'index.php', '~sitemap(_index)?\.xml', // Bare (no trailing slash) so "contains" matches both // /cart and /cart/items — WooCommerce serves both forms. '/cart', '/checkout', '/my-account', 'ao_noptirocket', 'ao_speedup_cachebuster', 'removed_item', '/wc-api', '/edd-api', '/wp-login', ), 'item_type' => 'string', 'label' => 'Excluded URLs', '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).', ), 'excluded_cookies' => array( 'type' => 'list', // Cookies that signal a logged-in / transactional visitor // whose response must not be served from a shared cache. // `~` prefix = raw regex (e.g. ~wordpress_[a-f0-9]+). (FBS-82181) 'default' => array( 'comment_author', '~wordpress_[a-f0-9]+', 'wp-postpass', 'wordpress_no_cache', 'wordpress_logged_in', 'edd_items_in_cart', 'woocommerce_items_in_cart', 'fct_cart_hash', 'comment_', 'woocommerce_', 'wordpress', 'xf_', 'edd_', 'jetpack', 'yith_wcwl_session_', 'yith_wrvp_', 'wpsc_', 'ecwid', 'ec_', 'bookly', ), 'item_type' => 'string', 'label' => 'Excluded Cookies', '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.', ), 'bypass_user_agents' => array( 'type' => 'list', 'default' => array(), 'item_type' => 'string', 'label' => 'Bypass User Agents', '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.', ), 'ignored_query_params' => array( 'type' => 'list', // Analytics / ad / session query keys stripped before the // cache key is computed, so /post?utm_source=x and /post // share one entry. `~` prefix = raw regex. (FBS-82181) 'default' => array( '__s', '_ga', '_ke', '~[a-zA-Z0-9_-]+_sid', 'adgroupid', 'age-verified', 'ao_noptimize', 'campaignid', 'ck_subscriber_id', 'cn-reloaded', 'dclid', 'epik', 'fb_action_ids', 'fb_action_types', 'fb_source', 'fbclid', 'gclid', 'jobid', 'mc_cid', 'mc_eid', 'mkt_tok', 'msclkid', 'ref', '~session_[a-zA-Z0-9_-]+_alive', 'sseid', 'sslid', 'usqp', '~utm_[a-zA-Z0-9_-]+', ), 'item_type' => 'string', 'label' => 'Ignored Query Parameters', 'description' => 'Query keys removed from the URL before computing the cache key, so /post?utm_source=x and /post share a cache entry. Defaults cover the common analytics + ad + session params. Glob + ~regex supported. One per line.', ), 'mobile_separate' => array( 'type' => 'bool', 'default' => false, 'label' => 'Separate Mobile Cache', '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.', ), ); } /** * `mobile_separate_review` lives outside the schema: migration sets it * (bool) when a source plugin had "separate mobile cache" on, so the * dashboard can prompt the user to re-enable it deliberately instead of * silently importing it (which would kill the device-blind static fast * path). Without preserving it here, the first schema-driven cache save * would rebuild the option from the schema alone and drop the flag before * the user ever saw the prompt. (FBS-83145) * * @return string[] */ public function preserved_keys(): array { return array( 'mobile_separate_review' ); } /** * Seed per-module option from the legacy xspeed_options blob if we * haven't done so yet. Idempotent — once xspeed_module_cache exists * or the legacy keys are gone, this is a no-op. Runs on both boot * and activate so installs on every code path are covered. */ public function boot(): void { $this->seed_from_legacy_if_needed(); // Keep every mobile_separate-dependent artifact (the drop-in's // `.mobile-separate` flag, the device-blind server rewrite, and the // device-keyed caches) in lockstep with the setting — on boot, and // whenever the cache settings are saved. The drop-in can't read WP // options, so it reads the sidecar marker Cache maintains here. \XSpeed\Cache::reconcile_mobile_separate(); add_action( 'update_option_xspeed_module_cache', static function () { \XSpeed\Cache::reconcile_mobile_separate(); // Re-bake the cookie / user-agent exclusion rules into the // drop-in. It runs before WordPress loads and so carries a // COPY of those rules, substituted at install time — and // auto_heal() deliberately only reinstalls when the file is // missing, foreign, or an older version, none of which a // settings change makes true. Without this, adding an // excluded cookie left the drop-in serving the shared // anonymous page to exactly the visitors it excluded, until // the next plugin upgrade happened to reinstall it. \XSpeed\Cache::install_dropin(); // Same staleness applies to the .htaccess block, which is // written to disk from the same generator. Refresh it only // when a block is already installed — writing one here would // enable the static path on a site that never opted in. \XSpeed\Cache::refresh_rewrite_if_installed(); } ); // Time-driven collection of expired entries and superseded minified // assets. Scheduled here as well as in activate() because a site that // upgrades into this version never runs the activation hook again. add_action( \XSpeed\Cache_GC::CRON_HOOK, array( \XSpeed\Cache_GC::class, 'run' ) ); \XSpeed\Cache_GC::ensure_scheduled(); } public function activate(): void { $this->seed_from_legacy_if_needed(); \XSpeed\Cache_GC::ensure_scheduled(); } public function deactivate(): void { \XSpeed\Cache_GC::unschedule(); } private function seed_from_legacy_if_needed(): void { if ( null !== get_option( 'xspeed_module_cache', null ) ) { return; } $legacy = get_option( 'xspeed_options', array() ); if ( ! is_array( $legacy ) ) { return; } $seed = array( '_version' => self::VERSION ); $dirty = false; if ( array_key_exists( 'cache_expiry', $legacy ) ) { $seed['cache_expiry'] = max( 1, min( 720, (int) $legacy['cache_expiry'] ) ); unset( $legacy['cache_expiry'] ); $dirty = true; } if ( array_key_exists( 'excluded_urls', $legacy ) ) { $seed['excluded_urls'] = is_array( $legacy['excluded_urls'] ) ? array_values( array_filter( $legacy['excluded_urls'], 'is_string' ) ) : array(); unset( $legacy['excluded_urls'] ); $dirty = true; } if ( $dirty ) { update_option( 'xspeed_module_cache', $seed ); update_option( 'xspeed_options', $legacy ); } } public function cli_commands(): array { return array( array( 'name' => 'xspeed cache', 'callback' => array( $this, 'cli_handler' ), '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 ` to clear one page, or `recheck-rewrite` to re-run the static-rewrite probe (site-wide purge / toggle use the dedicated commands).', 'synopsis' => array( array( 'type' => 'positional', 'name' => 'action', 'options' => array( 'status', 'inventory', 'size', 'purge-log', 'purge-url', 'recheck-rewrite' ), 'optional' => true, ), array( 'type' => 'positional', 'name' => 'url', 'optional' => true, ), array( 'type' => 'assoc', 'name' => 'limit', 'description' => 'Rows to print for inventory / purge-log. Default 20.', 'optional' => true, ), array( 'type' => 'assoc', 'name' => 'cause', 'description' => 'Label recorded in the purge log for purge-url. Default "CLI".', 'optional' => true, ), ), ), ); } public function cli_handler( array $args, array $assoc ): void { $action = isset( $args[0] ) ? (string) $args[0] : 'status'; $limit = isset( $assoc['limit'] ) ? max( 1, (int) $assoc['limit'] ) : 20; /* * Force a fresh static-rewrite probe. The result is cached for five * minutes and nothing invalidated it, so after fixing an nginx config * there was no way to re-check — the "configure your server" banner * just stayed up. (FBS-84012) */ if ( 'recheck-rewrite' === $action ) { // Qualify the raw probe against known config refusals before // reporting. The probe fetches its OWN file from the static tree, // which succeeds even when no real page is served that way — so // an unqualified `active` reported "the web server is serving // cache hits directly" on sites whose every page returned // HIT (php). See Cache::qualify_rewrite_probe(). $probe = \XSpeed\Cache::qualify_rewrite_probe( \XSpeed\Cache::recheck_static_rewrite() ); $blocked = '' !== (string) $probe['block_reason']; if ( $probe['active'] ) { \WP_CLI::success( 'Static rewrite is active — the web server is serving cache hits directly.' ); return; } if ( $blocked ) { \WP_CLI::warning( sprintf( 'Static rewrite is not active: %s', (string) $probe['reason'] ) ); return; } if ( $probe['inconclusive'] ) { \WP_CLI::warning( sprintf( 'Could not verify the static rewrite: %s', (string) $probe['reason'] ) ); \WP_CLI::log( 'This is a probe failure, not proof that your server config is wrong.' ); return; } \WP_CLI::warning( sprintf( 'Static rewrite is not active: %s', (string) ( $probe['reason'] ?: 'unknown' ) ) ); return; } if ( 'purge-url' === $action ) { $url = isset( $args[1] ) ? trim( (string) $args[1] ) : ''; if ( '' === $url ) { \WP_CLI::error( 'Usage: wp xspeed cache purge-url ' ); return; } $cause = isset( $assoc['cause'] ) && '' !== trim( (string) $assoc['cause'] ) ? trim( (string) $assoc['cause'] ) : 'CLI'; $removed = \XSpeed\Cache::purge_url( $url, $cause ); if ( $removed > 0 ) { \WP_CLI::success( sprintf( 'Purged %d cache file(s) for %s', $removed, $url ) ); } else { \WP_CLI::log( sprintf( 'No cache entries found for %s (already cold, or the URL never cached).', $url ) ); } return; } if ( 'inventory' === $action ) { $this->cli_inventory( $limit ); return; } if ( 'size' === $action ) { $this->cli_size(); return; } if ( 'purge-log' === $action ) { $this->cli_purge_log( $limit ); return; } $opts = Settings_Manager::get( self::SLUG ); \WP_CLI::log( 'cache_expiry ' . $opts['cache_expiry'] . 'h' ); \WP_CLI::log( 'excluded_urls ' . count( $opts['excluded_urls'] ) . ' entries' ); foreach ( $opts['excluded_urls'] as $u ) { \WP_CLI::log( ' - ' . $u ); } } /** `wp xspeed cache inventory [--limit=N]` — which pages are cached, and how old. */ private function cli_inventory( int $limit ): void { $data = \XSpeed\Cache_Inventory::entries( $limit ); if ( empty( $data['entries'] ) ) { \WP_CLI::log( 'Cache is empty — no cached pages on disk.' ); return; } \WP_CLI::log( sprintf( '%d cached page(s); showing %d.', $data['total'], count( $data['entries'] ) ) ); if ( ! empty( $data['capped'] ) ) { \WP_CLI::warning( sprintf( 'Scan stopped at %d files — the list is a recent sample, not the whole cache.', \XSpeed\Cache_Inventory::SCAN_CAP ) ); } foreach ( $data['entries'] as $entry ) { \WP_CLI::log( sprintf( ' %-58s %8s %s [%s]', null === $entry['url'] ? '(url unknown: ' . $entry['key'] . ')' : $entry['url'], size_format( (int) $entry['bytes'] ), $this->relative_age( (int) $entry['age'] ), implode( '+', (array) $entry['stored_in'] ) ) ); } } /** `wp xspeed cache size` — where the cache's disk usage goes. */ private function cli_size(): void { $data = \XSpeed\Cache_Inventory::size_breakdown(); \WP_CLI::log( sprintf( 'Total %s across %d file(s).', size_format( (int) $data['total_bytes'] ), (int) $data['total_files'] ) ); foreach ( $data['buckets'] as $bucket ) { if ( 0 === (int) $bucket['files'] ) { continue; } \WP_CLI::log( sprintf( ' %-32s %10s %d file(s)', $bucket['label'], size_format( (int) $bucket['bytes'] ), (int) $bucket['files'] ) ); } if ( (int) $data['compressed_bytes'] > 0 ) { \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'] ) ) ); } } /** `wp xspeed cache purge-log [--limit=N]` — what cleared the cache, when, and why. */ private function cli_purge_log( int $limit ): void { $data = \XSpeed\Cache_Inventory::purge_log( $limit ); if ( empty( $data['events'] ) ) { \WP_CLI::log( 'No purge events recorded yet.' ); return; } foreach ( $data['events'] as $event ) { \WP_CLI::log( sprintf( ' %s %s', $this->relative_age( max( 0, time() - (int) $event['ts'] ) ), $event['message'] ) ); } } /** Compact "4h ago" for CLI columns. */ private function relative_age( int $seconds ): string { if ( $seconds < 60 ) { return $seconds . 's ago'; } if ( $seconds < 3600 ) { return (int) floor( $seconds / 60 ) . 'm ago'; } if ( $seconds < 86400 ) { return (int) floor( $seconds / 3600 ) . 'h ago'; } return (int) floor( $seconds / 86400 ) . 'd ago'; } /** * Static-rewrite directives for the unified nginx server-block * snippet. Returns null when cache is disabled — there's no rewrite * to install in that state. Delegates to \XSpeed\Cache::nginx_snippet() * which already produces nginx-detection-gated output. */ public function nginx_directives(): ?string { $opts = get_option( 'xspeed_options', array() ); if ( empty( $opts['cache_enabled'] ) ) { return null; } return \XSpeed\Cache::nginx_snippet(); } }