| @@ -25,29 +25,180 @@ | ||
| 25 | 25 | use XSpeed\Settings_Manager; |
| 26 | 26 | |
| 27 | 27 | final class CacheModule extends Module { |
| 28 | 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 | + * Click and campaign IDs added to the defaults in 1.3.6. | |
| 113 | + * | |
| 114 | + * Each is unique per click and never changes the page, yet each one | |
| 115 | + * bypassed the page cache and, with xSpeed Pro, was a new CSS entry to | |
| 116 | + * build: Google Merchant's `srsltid` and Ads' `gad_*`/`gbraid`/`wbraid`, | |
| 117 | + * GA4's cross-domain `_gl`, TikTok, X, Instagram, Yandex, HubSpot email | |
| 118 | + * and LinkedIn IDs. Kept as their own list so the upgrade can add exactly | |
| 119 | + * these to a saved list without re-adding anything a site removed. | |
| 120 | + * | |
| 121 | + * @var string[] | |
| 122 | + */ | |
| 123 | + public const TRACKING_PARAMS_1_3_6 = array( | |
| 124 | + '_gl', | |
| 125 | + '_hsenc', | |
| 126 | + '_hsmi', | |
| 127 | + 'gad_campaignid', | |
| 128 | + 'gad_source', | |
| 129 | + 'gbraid', | |
| 130 | + 'igshid', | |
| 131 | + 'li_fat_id', | |
| 132 | + 'srsltid', | |
| 133 | + 'ttclid', | |
| 134 | + 'twclid', | |
| 135 | + 'wbraid', | |
| 136 | + 'yclid', | |
| 137 | + ); | |
| 138 | + | |
| 139 | + | |
| 140 | + /** | |
| 141 | + * The shipped default list: the base list plus later additions, sorted. | |
| 142 | + * | |
| 143 | + * @return string[] | |
| 144 | + */ | |
| 145 | + public static function default_ignored_query_params(): array { | |
| 146 | + $all = array_values( array_unique( array_merge( self::DEFAULT_IGNORED_QUERY_PARAMS, self::TRACKING_PARAMS_1_3_6 ) ) ); | |
| 147 | + sort( $all ); | |
| 148 | + return $all; | |
| 149 | + } | |
| 150 | + | |
| 29 | 151 | public const SLUG = 'cache'; |
| 30 | 152 | public const TIER = self::TIER_FREE; |
| 31 | 153 | public const VERSION = '1.0.0'; |
| 32 | 154 | |
| 155 | + /** | |
| 156 | + * Default cache lifetime in hours (7 days). | |
| 157 | + * | |
| 158 | + * Named so the readers that need a fallback share ONE value with the | |
| 159 | + * schema below. Three of them carried their own hardcoded `?? 24`, which | |
| 160 | + * silently became a stale copy the moment the default moved. They are | |
| 161 | + * unreachable today — Settings_Manager::get() always merges defaults — | |
| 162 | + * but an unreachable wrong number is still a trap for the next change. | |
| 163 | + * (#284 B5) | |
| 164 | + */ | |
| 165 | + public const DEFAULT_EXPIRY_HOURS = 24 * 7; | |
| 166 | + | |
| 33 | 167 | public function ui_metadata(): array { |
| 34 | 168 | return array( |
| 35 | - 'label' => 'Page Cache', | |
| 169 | + 'label' => __( 'Page Cache', 'xspeed' ), | |
| 36 | 170 | 'icon' => 'Database', |
| 37 | - 'description' => 'Page caching for non-logged-in visitors.', | |
| 171 | + 'description' => __( 'Saves each page as a file and serves it to logged-out visitors.', 'xspeed' ), | |
| 172 | + 'group' => 'cache', | |
| 38 | 173 | ); |
| 39 | 174 | } |
| 40 | 175 | |
| 176 | + /** | |
| 177 | + * @inheritDoc | |
| 178 | + * | |
| 179 | + * Nothing exempt. Purging only ever touches xSpeed's own cache, so on an | |
| 180 | + * occupied site the setting is inert either way — but a host installing xSpeed | |
| 181 | + * on a user's behalf should leave nothing switched on that the user did not | |
| 182 | + * ask for, and "inert today" is a weak reason to make an exception. | |
| 183 | + */ | |
| 184 | + public function conflict_safe_exempt(): array { | |
| 185 | + return array(); | |
| 186 | + } | |
| 187 | + | |
| 41 | 188 | public function settings_schema(): array { |
| 42 | - return array( | |
| 189 | + $schema = array( | |
| 43 | 190 | 'cache_expiry' => array( |
| 44 | 191 | 'type' => 'int', |
| 45 | - 'default' => 24, | |
| 192 | + // Matches the wizard's Balanced preset, which is what a fresh | |
| 193 | + // install starts on — a shorter module default meant the two | |
| 194 | + // disagreed about what "default" means. (#284) | |
| 195 | + 'default' => self::DEFAULT_EXPIRY_HOURS, | |
| 46 | 196 | 'min' => 1, |
| 47 | 197 | 'max' => 720, |
| 48 | - 'label' => 'Cache Expiry (hours)', | |
| 49 | - 'description' => 'How long cached pages live before regenerating. 1 to 720 hours (30 days).', | |
| 198 | + 'label' => __( 'Cache expiry (hours)', 'xspeed' ), | |
| 199 | + 'unit' => 'hours', | |
| 200 | + 'description' => __( 'How long a cached page is kept before xSpeed builds it again. 1 to 720 hours (30 days).', 'xspeed' ), | |
| 50 | 201 | ), |
| 51 | 202 | 'excluded_urls' => array( |
| 52 | 203 | 'type' => 'list', |
| 53 | 204 | // Comprehensive LiteSpeed / WP Rocket-parity default URL |
| @@ -59,9 +210,11 @@ | ||
| 59 | 210 | '/xmlrpc.php', |
| 60 | 211 | '~wp-.*\.php', |
| 61 | 212 | '/feed/', |
| 62 | 213 | 'index.php', |
| 63 | - '~sitemap(_index)?\.xml', | |
| 214 | + // `sitemaps?` — SEOPress generates sitemaps.xml (plural). | |
| 215 | + '~sitemaps?(_index)?\.xml', | |
| 216 | + '/robots.txt', | |
| 64 | 217 | // Bare (no trailing slash) so "contains" matches both |
| 65 | 218 | // /cart and /cart/items — WooCommerce serves both forms. |
| 66 | 219 | '/cart', |
| 67 | 220 | '/checkout', |
| @@ -72,11 +225,12 @@ | ||
| 72 | 225 | '/wc-api', |
| 73 | 226 | '/edd-api', |
| 74 | 227 | '/wp-login', |
| 75 | 228 | ), |
| 76 | - 'item_type' => 'string', | |
| 77 | - 'label' => 'Excluded URLs', | |
| 78 | - 'description' => 'One pattern per line. Plain text matches anywhere in the URL (e.g. /cart). Use glob for anchored matches (/cart/* matches /cart/items but not /foo/cart/bar; *.pdf matches PDFs). Prefix with ~ for a raw regex (e.g. ~wp-.*\.php).', | |
| 229 | + // `path`: kept as typed, percent-encoded slugs included. | |
| 230 | + 'item_type' => 'path', | |
| 231 | + 'label' => __( 'Excluded URLs', 'xspeed' ), | |
| 232 | + 'description' => __( 'Pages whose URL matches a line here are never cached. Plain text matches anywhere (/cart). A pattern with * matches from the start of the path: /cart/* matches /cart/items but not /shop/cart/items. ~ starts a regex.', 'xspeed' ), | |
| 79 | 233 | ), |
| 80 | 234 | 'excluded_cookies' => array( |
| 81 | 235 | 'type' => 'list', |
| 82 | 236 | // Cookies that signal a logged-in / transactional visitor |
| @@ -81,40 +235,19 @@ | ||
| 81 | 235 | 'type' => 'list', |
| 82 | 236 | // Cookies that signal a logged-in / transactional visitor |
| 83 | 237 | // whose response must not be served from a shared cache. |
| 84 | 238 | // `~` prefix = raw regex (e.g. ~wordpress_[a-f0-9]+). (FBS-82181) |
| 85 | - 'default' => array( | |
| 86 | - 'comment_author', | |
| 87 | - '~wordpress_[a-f0-9]+', | |
| 88 | - 'wp-postpass', | |
| 89 | - 'wordpress_no_cache', | |
| 90 | - 'wordpress_logged_in', | |
| 91 | - 'edd_items_in_cart', | |
| 92 | - 'woocommerce_items_in_cart', | |
| 93 | - 'fct_cart_hash', | |
| 94 | - 'comment_', | |
| 95 | - 'woocommerce_', | |
| 96 | - 'wordpress', | |
| 97 | - 'xf_', | |
| 98 | - 'edd_', | |
| 99 | - 'jetpack', | |
| 100 | - 'yith_wcwl_session_', | |
| 101 | - 'yith_wrvp_', | |
| 102 | - 'wpsc_', | |
| 103 | - 'ecwid', | |
| 104 | - 'ec_', | |
| 105 | - 'bookly', | |
| 106 | - ), | |
| 239 | + 'default' => self::DEFAULT_EXCLUDED_COOKIES, | |
| 107 | 240 | 'item_type' => 'string', |
| 108 | - 'label' => 'Excluded Cookies', | |
| 109 | - 'description' => 'Skip cache for any visitor whose request carries a cookie whose NAME matches one of these patterns. Plain text = "contains"; glob (woocommerce_*) and ~regex (~wordpress_[a-f0-9]+) supported. One per line.', | |
| 241 | + 'label' => __( 'Excluded cookies', 'xspeed' ), | |
| 242 | + 'description' => __( 'Visitors with a cookie whose name matches a line here always get a fresh page. One per line; * and ~regex work.', 'xspeed' ), | |
| 110 | 243 | ), |
| 111 | 244 | 'bypass_user_agents' => array( |
| 112 | 245 | 'type' => 'list', |
| 113 | 246 | 'default' => array(), |
| 114 | 247 | 'item_type' => 'string', |
| 115 | - 'label' => 'Bypass User Agents', | |
| 116 | - 'description' => 'Substring match against the visitor User-Agent. Matched UAs bypass cache (useful for screenshot bots, internal previews, monitoring). Glob + ~regex supported. One per line.', | |
| 248 | + 'label' => __( 'Excluded browsers and bots', 'xspeed' ), | |
| 249 | + 'description' => __( 'Visitors whose user agent contains a line here always get a fresh page. Useful for screenshot bots and uptime monitors.', 'xspeed' ), | |
| 117 | 250 | ), |
| 118 | 251 | 'ignored_query_params' => array( |
| 119 | 252 | 'type' => 'list', |
| 120 | 253 | // Analytics / ad / session query keys stripped before the |
| @@ -119,52 +252,131 @@ | ||
| 119 | 252 | 'type' => 'list', |
| 120 | 253 | // Analytics / ad / session query keys stripped before the |
| 121 | 254 | // cache key is computed, so /post?utm_source=x and /post |
| 122 | 255 | // share one entry. `~` prefix = raw regex. (FBS-82181) |
| 123 | - 'default' => array( | |
| 124 | - '__s', | |
| 125 | - '_ga', | |
| 126 | - '_ke', | |
| 127 | - '~[a-zA-Z0-9_-]+_sid', | |
| 128 | - 'adgroupid', | |
| 129 | - 'age-verified', | |
| 130 | - 'ao_noptimize', | |
| 131 | - 'campaignid', | |
| 132 | - 'ck_subscriber_id', | |
| 133 | - 'cn-reloaded', | |
| 134 | - 'dclid', | |
| 135 | - 'epik', | |
| 136 | - 'fb_action_ids', | |
| 137 | - 'fb_action_types', | |
| 138 | - 'fb_source', | |
| 139 | - 'fbclid', | |
| 140 | - 'gclid', | |
| 141 | - 'jobid', | |
| 142 | - 'mc_cid', | |
| 143 | - 'mc_eid', | |
| 144 | - 'mkt_tok', | |
| 145 | - 'msclkid', | |
| 146 | - 'ref', | |
| 147 | - '~session_[a-zA-Z0-9_-]+_alive', | |
| 148 | - 'sseid', | |
| 149 | - 'sslid', | |
| 150 | - 'usqp', | |
| 151 | - '~utm_[a-zA-Z0-9_-]+', | |
| 152 | - ), | |
| 256 | + // Matched whole-name, so every entry here means the param | |
| 257 | + // it names and nothing that merely contains it. | |
| 258 | + 'default' => self::default_ignored_query_params(), | |
| 153 | 259 | 'item_type' => 'string', |
| 154 | - 'label' => 'Ignored Query Parameters', | |
| 155 | - 'description' => 'Query keys removed from the URL before computing the cache key, so /post?utm_source=x and /post share a cache entry. Defaults cover the common analytics + ad + session params. Glob + ~regex supported. One per line.', | |
| 260 | + 'label' => __( 'Ignored query parameters', 'xspeed' ), | |
| 261 | + 'advanced' => true, | |
| 262 | + 'description' => __( 'URL parameters to ignore, so /post?utm_source=x gets the same cached page as /post. Each line matches a whole parameter name.', 'xspeed' ), | |
| 156 | 263 | ), |
| 264 | + 'purge_on_upgrade' => array( | |
| 265 | + 'type' => 'bool', | |
| 266 | + 'default' => true, | |
| 267 | + 'label' => __( 'Clear cache after updates', 'xspeed' ), | |
| 268 | + 'description' => __( 'Clear the page cache when a plugin, theme or WordPress is updated, so visitors never get old pages with broken asset links.', 'xspeed' ), | |
| 269 | + ), | |
| 270 | + 'purge_affected_only' => array( | |
| 271 | + 'type' => 'bool', | |
| 272 | + 'default' => true, | |
| 273 | + 'label' => __( 'Clear only the pages a change affects', 'xspeed' ), | |
| 274 | + 'description' => __( 'When you publish, edit or delete a post, clear that post, the pages that list it and its feeds instead of the whole site. Pages with a post grid from a page builder, a block plugin or the theme are noted as they are built and cleared when the change can affect their list; turn this off if a grid still shows an old list.', 'xspeed' ), | |
| 275 | + 'info_title' => __( 'Which pages are cleared', 'xspeed' ), | |
| 276 | + 'info' => __( 'The post, its old address if it moved, the home page, the blog page, its categories, tags and author pages with every page of each, the date archives, feeds, the posts next to it, pages that show a Latest Posts or Query Loop block, directly or in a synced pattern, and pages noted running a post grid of their own (a page builder, block plugin, related-posts or theme list) that the change can affect. If your theme shows a list that can be on any page, and the change alters it, xSpeed clears the whole site instead: a Recent Posts, Archives or Calendar widget, a post list block, or a latest-posts grid from a page builder or plugin (usually a new post, or an edit to one of the newest few it shows), a page list or menu (a page published, withdrawn, renamed or moved), or a Categories or Tag Cloud list (a post moved to other categories or tags).', 'xspeed' ), | |
| 277 | + ), | |
| 157 | 278 | 'mobile_separate' => array( |
| 158 | 279 | 'type' => 'bool', |
| 159 | 280 | 'default' => false, |
| 160 | - 'label' => 'Separate Mobile Cache', | |
| 161 | - 'description' => 'Keep mobile and desktop responses in separate cache buckets. Turn on for AMP, mobile-specific themes (WPtouch / Jetpack mobile theme), or any setup that serves different HTML by device.', | |
| 281 | + 'label' => __( 'Separate mobile cache', 'xspeed' ), | |
| 282 | + 'description' => __( 'Keep a separate cached copy for phones. Turn on only if your theme or plugins show different pages on mobile.', 'xspeed' ), | |
| 162 | 283 | ), |
| 284 | + 'edge_provider' => array( | |
| 285 | + 'type' => 'enum', | |
| 286 | + 'default' => 'auto', | |
| 287 | + 'options' => array( 'auto', 'off', 'cloudflare', 'fastly', 'varnish', 'nginx', 'akamai', 'cloudfront', 'google', 'keycdn', 'bunny', 'sucuri', 'incapsula', 'generic', 'custom' ), | |
| 288 | + 'option_labels' => array( | |
| 289 | + 'auto' => 'Detect automatically', | |
| 290 | + 'off' => 'Off (no edge lifetime or cache tags)', | |
| 291 | + 'cloudflare' => 'Cloudflare', | |
| 292 | + 'varnish' => 'Varnish', | |
| 293 | + 'nginx' => 'nginx proxy cache', | |
| 294 | + 'cloudfront' => 'Amazon CloudFront', | |
| 295 | + 'google' => 'Google Cloud CDN', | |
| 296 | + 'keycdn' => 'KeyCDN', | |
| 297 | + 'bunny' => 'Bunny', | |
| 298 | + // These four cannot be presented as supported on the same | |
| 299 | + // footing as the ones above. Vendor documentation either | |
| 300 | + // does not establish that they honour what we send, or | |
| 301 | + // establishes that they ignore origin cache headers until | |
| 302 | + // the property is configured to respect them — Akamai | |
| 303 | + // caches for a theoretically infinite time by default, and | |
| 304 | + // Sucuri's default caching level ignores the headers | |
| 305 | + // outright. Naming them without the caveat would promise a | |
| 306 | + // protection the CDN is not currently giving. | |
| 307 | + 'fastly' => 'Fastly (needs CDN configuration)', | |
| 308 | + 'akamai' => 'Akamai (needs CDN configuration)', | |
| 309 | + 'sucuri' => 'Sucuri (needs CDN configuration)', | |
| 310 | + 'incapsula' => 'Imperva / Incapsula (needs CDN configuration)', | |
| 311 | + 'generic' => 'Something else', | |
| 312 | + 'custom' => 'Custom headers', | |
| 313 | + ), | |
| 314 | + 'label' => __( 'CDN or proxy in front', 'xspeed' ), | |
| 315 | + 'description' => __( 'Tells your CDN or proxy not to store pages xSpeed does not cache. Leave on Detect automatically unless you know your provider.', 'xspeed' ), | |
| 316 | + 'advanced' => true, | |
| 317 | + 'info_title' => __( 'Cache in front of this site', 'xspeed' ), | |
| 318 | + 'info' => __( 'Naming your provider narrows the headers to the one it reads. Detect automatically works it out per request and otherwise sends a set every cache ignores unless it understands it, so it is safe not to know. Off stops the edge lifetime and cache tags. When an add-on that manages the edge, such as a Cloudflare Enterprise add-on, is active, the "don\'t store" headers for logged-in, cart and search pages still go out. Run "wp xspeed cache edge" to see what was detected and what gets sent.', 'xspeed' ) | |
| 319 | + ), | |
| 320 | + 'edge_custom_headers' => array( | |
| 321 | + 'type' => 'list', | |
| 322 | + 'default' => array(), | |
| 323 | + 'item_type' => 'string', | |
| 324 | + 'label' => __( 'Custom CDN headers', 'xspeed' ), | |
| 325 | + 'advanced' => true, | |
| 326 | + 'dependsOn' => array( 'field' => 'edge_provider', 'value' => 'custom' ), | |
| 327 | + 'description' => __( 'One header per line, as Name: value, for example "Surrogate-Control: no-store". Lines starting with # are ignored.', 'xspeed' ), | |
| 328 | + 'info_title' => __( 'Custom edge headers', 'xspeed' ), | |
| 329 | + 'info' => __( 'These replace the headers xSpeed would have picked for your CDN. Two baselines are still added underneath: a Cache-Control, and "X-Accel-Expires: 0" for a page cache running in nginx on your own server. Name either one yourself and yours is used instead. Values containing $, % or a backslash are dropped — the same pairs go into nginx and Apache directives, where those cannot be escaped safely. Content-Length, Content-Encoding, Content-Type, Transfer-Encoding, Set-Cookie and Location are refused.', 'xspeed' ) | |
| 330 | + ), | |
| 163 | 331 | ); |
| 332 | + | |
| 333 | + // LiteSpeed-only opt-in (#509): meaningless on any other server, so | |
| 334 | + // the field only exists in the schema where it can act — elsewhere the | |
| 335 | + // stored value survives via preserved_keys(). Inserted right after | |
| 336 | + // mobile_separate, its sibling static-fast-path trade-off. | |
| 337 | + if ( \XSpeed\Server::LITESPEED === \XSpeed\Server::type() ) { | |
| 338 | + $litespeed = array( | |
| 339 | + 'litespeed_static_rewrite' => array( | |
| 340 | + 'type' => 'bool', | |
| 341 | + 'default' => false, | |
| 342 | + 'label' => __( 'LiteSpeed static fast path', 'xspeed' ), | |
| 343 | + 'advanced' => true, | |
| 344 | + 'description' => __( 'LiteSpeed serves cached pages without running PHP, which is faster on slow hosts. These visits are not counted in the dashboard hit ratio.', 'xspeed' ), | |
| 345 | + ), | |
| 346 | + ); | |
| 347 | + $pos = (int) array_search( 'mobile_separate', array_keys( $schema ), true ) + 1; | |
| 348 | + $schema = array_slice( $schema, 0, $pos, true ) + $litespeed + array_slice( $schema, $pos, null, true ); | |
| 349 | + } | |
| 350 | + | |
| 351 | + return $schema; | |
| 164 | 352 | } |
| 165 | 353 | |
| 166 | 354 | /** |
| 355 | + * `mobile_separate_review` lives outside the schema: migration sets it | |
| 356 | + * (bool) when a source plugin had "separate mobile cache" on, so the | |
| 357 | + * dashboard can prompt the user to re-enable it deliberately instead of | |
| 358 | + * silently importing it (which would kill the device-blind static fast | |
| 359 | + * path). Without preserving it here, the first schema-driven cache save | |
| 360 | + * would rebuild the option from the schema alone and drop the flag before | |
| 361 | + * the user ever saw the prompt. (FBS-83145) | |
| 362 | + * | |
| 363 | + * @return string[] | |
| 364 | + */ | |
| 365 | + public function preserved_keys(): array { | |
| 366 | + $keys = array( 'mobile_separate_review' ); | |
| 367 | + // On non-LiteSpeed servers the litespeed_static_rewrite field is not | |
| 368 | + // in the schema (see settings_schema()), so a schema-driven save | |
| 369 | + // would silently drop a value chosen while the site ran LiteSpeed. | |
| 370 | + // Preserve it so moving LiteSpeed → other → LiteSpeed keeps the | |
| 371 | + // user's choice. On LiteSpeed itself the schema owns the key. | |
| 372 | + if ( \XSpeed\Server::LITESPEED !== \XSpeed\Server::type() ) { | |
| 373 | + $keys[] = 'litespeed_static_rewrite'; | |
| 374 | + } | |
| 375 | + return $keys; | |
| 376 | + } | |
| 377 | + | |
| 378 | + /** | |
| 167 | 379 | * Seed per-module option from the legacy xspeed_options blob if we |
| 168 | 380 | * haven't done so yet. Idempotent — once xspeed_module_cache exists |
| 169 | 381 | * or the legacy keys are gone, this is a no-op. Runs on both boot |
| 170 | 382 | * and activate so installs on every code path are covered. |
| @@ -177,20 +389,72 @@ | ||
| 177 | 389 | // device-keyed caches) in lockstep with the setting — on boot, and |
| 178 | 390 | // whenever the cache settings are saved. The drop-in can't read WP |
| 179 | 391 | // options, so it reads the sidecar marker Cache maintains here. |
| 180 | 392 | \XSpeed\Cache::reconcile_mobile_separate(); |
| 181 | - add_action( | |
| 182 | - 'update_option_xspeed_module_cache', | |
| 183 | - static function () { | |
| 184 | - \XSpeed\Cache::reconcile_mobile_separate(); | |
| 393 | + // Both write paths matter. On a fresh install `xspeed_module_cache` | |
| 394 | + // does not exist yet, so core's update_option() delegates to | |
| 395 | + // add_option() and fires `add_option_…` INSTEAD of | |
| 396 | + // `update_option_…`. Hooking only the latter meant the very first | |
| 397 | + // save of Cache Expiry never re-baked the drop-in: the panel and the | |
| 398 | + // DB read the new value while the drop-in kept enforcing the old | |
| 399 | + // one, and re-saving the same value could not recover it because | |
| 400 | + // update_option() short-circuits on an unchanged value (#251). | |
| 401 | + $xspeed_resync_cache_artifacts = static function () { | |
| 402 | + \XSpeed\Cache::reconcile_mobile_separate(); | |
| 403 | + // Re-bake the cookie / user-agent exclusion rules into the | |
| 404 | + // drop-in. It runs before WordPress loads and so carries a | |
| 405 | + // COPY of those rules, substituted at install time — and | |
| 406 | + // auto_heal() deliberately only reinstalls when the file is | |
| 407 | + // missing, foreign, or an older version, none of which a | |
| 408 | + // settings change makes true. Without this, adding an | |
| 409 | + // excluded cookie left the drop-in serving the shared | |
| 410 | + // anonymous page to exactly the visitors it excluded, until | |
| 411 | + // the next plugin upgrade happened to reinstall it. | |
| 412 | + // | |
| 413 | + // ONLY when page caching is actually on, for the same reason | |
| 414 | + // refresh_rewrite_if_installed() below refuses to write a | |
| 415 | + // block that isn't there: re-baking is maintenance of an | |
| 416 | + // artifact the user opted into, never a way to acquire one. | |
| 417 | + // Re-baking unconditionally reached past our own module — a | |
| 418 | + // site that had declined our page cache got the drop-in | |
| 419 | + // installed anyway on the next Cache Expiry save, and the | |
| 420 | + // following toggle(false) then removed it. auto_heal() has | |
| 421 | + // always gated on this flag; this path simply never did. | |
| 422 | + // (#251) | |
| 423 | + // | |
| 424 | + // Through toggle() rather than install_dropin() so the re-bake | |
| 425 | + // gets the same ownership check, lock and rollback as every | |
| 426 | + // other page-cache write. A drop-in that turned out not to be | |
| 427 | + // ours between the save and now is refused here too. | |
| 428 | + $xspeed_options = get_option( 'xspeed_options', array() ); | |
| 429 | + if ( ! empty( $xspeed_options['cache_enabled'] ) ) { | |
| 430 | + \XSpeed\Cache::toggle( true ); | |
| 185 | 431 | } |
| 186 | - ); | |
| 432 | + // Same staleness applies to the .htaccess block, which is | |
| 433 | + // written to disk from the same generator. Refresh it only | |
| 434 | + // when a block is already installed — writing one here would | |
| 435 | + // enable the static path on a site that never opted in. | |
| 436 | + \XSpeed\Cache::refresh_rewrite_if_installed(); | |
| 437 | + }; | |
| 438 | + add_action( 'update_option_xspeed_module_cache', $xspeed_resync_cache_artifacts ); | |
| 439 | + add_action( 'add_option_xspeed_module_cache', $xspeed_resync_cache_artifacts ); | |
| 440 | + | |
| 441 | + // Time-driven collection of expired entries and superseded minified | |
| 442 | + // assets. Scheduled here as well as in activate() because a site that | |
| 443 | + // upgrades into this version never runs the activation hook again. | |
| 444 | + add_action( \XSpeed\Cache_GC::CRON_HOOK, array( \XSpeed\Cache_GC::class, 'run' ) ); | |
| 445 | + \XSpeed\Cache_GC::ensure_scheduled(); | |
| 187 | 446 | } |
| 188 | 447 | |
| 189 | 448 | public function activate(): void { |
| 190 | 449 | $this->seed_from_legacy_if_needed(); |
| 450 | + \XSpeed\Cache_GC::ensure_scheduled(); | |
| 191 | 451 | } |
| 192 | 452 | |
| 453 | + public function deactivate(): void { | |
| 454 | + \XSpeed\Cache_GC::unschedule(); | |
| 455 | + } | |
| 456 | + | |
| 193 | 457 | private function seed_from_legacy_if_needed(): void { |
| 194 | 458 | if ( null !== get_option( 'xspeed_module_cache', null ) ) { |
| 195 | 459 | return; |
| 196 | 460 | } |
| @@ -218,24 +482,513 @@ | ||
| 218 | 482 | |
| 219 | 483 | public function cli_commands(): array { |
| 220 | 484 | return array( |
| 221 | 485 | array( |
| 486 | + 'name' => 'xspeed optimize', | |
| 487 | + 'callback' => array( $this, 'cli_optimize' ), | |
| 488 | + '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.', | |
| 489 | + 'synopsis' => array( | |
| 490 | + array( | |
| 491 | + 'type' => 'assoc', | |
| 492 | + 'name' => 'aggressiveness', | |
| 493 | + 'description' => 'safe (removals + server-side only), standard (default), or aggressive (includes settings known to break some themes).', | |
| 494 | + 'optional' => true, | |
| 495 | + 'options' => array( 'safe', 'standard', 'aggressive' ), | |
| 496 | + ), | |
| 497 | + array( | |
| 498 | + 'type' => 'flag', | |
| 499 | + 'name' => 'dry-run', | |
| 500 | + 'description' => 'Show the plan and stop. Changes nothing.', | |
| 501 | + 'optional' => true, | |
| 502 | + ), | |
| 503 | + array( | |
| 504 | + 'type' => 'assoc', | |
| 505 | + 'name' => 'budget', | |
| 506 | + 'description' => 'Seconds to spend before stopping between steps. Default 120.', | |
| 507 | + 'optional' => true, | |
| 508 | + ), | |
| 509 | + array( | |
| 510 | + 'type' => 'assoc', | |
| 511 | + 'name' => 'measure-score', | |
| 512 | + '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.', | |
| 513 | + 'optional' => true, | |
| 514 | + 'options' => array( 'auto', 'never', 'always' ), | |
| 515 | + ), | |
| 516 | + ), | |
| 517 | + ), | |
| 518 | + array( | |
| 519 | + 'name' => 'xspeed purge', | |
| 520 | + 'callback' => array( $this, 'cli_purge' ), | |
| 521 | + '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.', | |
| 522 | + '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.', | |
| 523 | + 'synopsis' => array( | |
| 524 | + array( | |
| 525 | + 'type' => 'assoc', | |
| 526 | + 'name' => 'type', | |
| 527 | + 'description' => 'What to clear: all (default), page, object, cloudflare, xcloud (sites with xCloud\'s purge plugin), cdn — or a group name (edge). Comma-separate to clear several.', | |
| 528 | + 'optional' => true, | |
| 529 | + ), | |
| 530 | + array( | |
| 531 | + 'type' => 'assoc', | |
| 532 | + 'name' => 'cause', | |
| 533 | + 'description' => 'Label recorded in the purge log, so `wp xspeed cache purge-log` can tell this run apart from a click. Default "CLI".', | |
| 534 | + 'optional' => true, | |
| 535 | + ), | |
| 536 | + array( | |
| 537 | + 'type' => 'assoc', | |
| 538 | + 'name' => 'format', | |
| 539 | + 'description' => 'table (default, one line per store) or json (the full report, for scripts).', | |
| 540 | + 'optional' => true, | |
| 541 | + 'options' => array( 'table', 'json' ), | |
| 542 | + ), | |
| 543 | + ), | |
| 544 | + ), | |
| 545 | + array( | |
| 222 | 546 | 'name' => 'xspeed cache', |
| 223 | 547 | 'callback' => array( $this, 'cli_handler' ), |
| 224 | - 'shortdesc' => 'Inspect Cache module settings (purge / toggle use the dedicated commands).', | |
| 548 | + '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, or `edge` to show which cache is in front of the site and what xSpeed tells it, or `listings` to show which pages were recorded running a post list of their own (cleared by a narrow purge when a save may change that list). To clear the whole site use `wp xspeed purge`.', | |
| 225 | 549 | 'synopsis' => array( |
| 226 | 550 | array( |
| 227 | 551 | 'type' => 'positional', |
| 228 | 552 | 'name' => 'action', |
| 229 | - 'options' => array( 'status' ), | |
| 553 | + 'options' => array( 'status', 'inventory', 'size', 'purge-log', 'purge-url', 'recheck-rewrite', 'nginx-config', 'edge', 'listings' ), | |
| 230 | 554 | 'optional' => true, |
| 231 | 555 | ), |
| 556 | + array( | |
| 557 | + 'type' => 'positional', | |
| 558 | + 'name' => 'url', | |
| 559 | + 'optional' => true, | |
| 560 | + ), | |
| 561 | + array( | |
| 562 | + 'type' => 'assoc', | |
| 563 | + 'name' => 'limit', | |
| 564 | + 'description' => 'Rows to print for inventory / purge-log (default 20), or sample pages for listings (default 5).', | |
| 565 | + 'optional' => true, | |
| 566 | + ), | |
| 567 | + array( | |
| 568 | + 'type' => 'assoc', | |
| 569 | + 'name' => 'cause', | |
| 570 | + 'description' => 'Label recorded in the purge log for purge-url. Default "CLI".', | |
| 571 | + 'optional' => true, | |
| 572 | + ), | |
| 573 | + array( | |
| 574 | + 'type' => 'assoc', | |
| 575 | + 'name' => 'server', | |
| 576 | + '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.', | |
| 577 | + 'options' => array( 'nginx', 'apache', 'litespeed' ), | |
| 578 | + 'optional' => true, | |
| 579 | + ), | |
| 232 | 580 | ), |
| 233 | 581 | ), |
| 234 | 582 | ); |
| 235 | 583 | } |
| 236 | 584 | |
| 585 | + /** | |
| 586 | + * `wp xspeed optimize` — run the autopilot. | |
| 587 | + * | |
| 588 | + * Prints what it DID, not what it hoped to do: applied steps, reverted | |
| 589 | + * steps with the reason they were undone, and the problems it could not | |
| 590 | + * touch. A run that changes nothing prints that plainly rather than a | |
| 591 | + * success banner. | |
| 592 | + * | |
| 593 | + * @param array<int,string> $args Positional args (unused). | |
| 594 | + * @param array<string,string> $assoc Flags. | |
| 595 | + */ | |
| 596 | + public function cli_optimize( array $args, array $assoc ): void { | |
| 597 | + $result = \XSpeed\Optimize_Runner::run( | |
| 598 | + array( | |
| 599 | + 'aggressiveness' => (string) ( $assoc['aggressiveness'] ?? 'standard' ), | |
| 600 | + 'dry_run' => isset( $assoc['dry-run'] ), | |
| 601 | + 'budget_seconds' => isset( $assoc['budget'] ) ? (int) $assoc['budget'] : 120, | |
| 602 | + 'measure_score' => (string) ( $assoc['measure-score'] ?? 'auto' ), | |
| 603 | + ) | |
| 604 | + ); | |
| 605 | + | |
| 606 | + if ( is_wp_error( $result ) ) { | |
| 607 | + \WP_CLI::error( $result->get_error_message() ); | |
| 608 | + return; | |
| 609 | + } | |
| 610 | + | |
| 611 | + if ( ! empty( $result['dry_run'] ) ) { | |
| 612 | + // The summary carries the score AND its age. Printing the plan | |
| 613 | + // without it left the one number a reader wants off the only | |
| 614 | + // command they run before deciding to apply anything. | |
| 615 | + if ( isset( $result['message'] ) ) { | |
| 616 | + \WP_CLI::log( (string) $result['message'] ); | |
| 617 | + } | |
| 618 | + \WP_CLI::log( 'Plan (' . count( $result['plan'] ) . ' steps, nothing applied):' ); | |
| 619 | + foreach ( $result['plan'] as $step ) { | |
| 620 | + \WP_CLI::log( ' - ' . $step['change'] . ' [' . $step['tier'] . ']' ); | |
| 621 | + } | |
| 622 | + foreach ( $result['skipped'] as $row ) { | |
| 623 | + \WP_CLI::log( ' skipped: ' . $row['id'] . ' — ' . $row['why'] ); | |
| 624 | + } | |
| 625 | + return; | |
| 626 | + } | |
| 627 | + | |
| 628 | + if ( isset( $result['message'] ) ) { | |
| 629 | + \WP_CLI::success( (string) $result['message'] ); | |
| 630 | + } | |
| 631 | + | |
| 632 | + foreach ( $result['applied'] as $row ) { | |
| 633 | + \WP_CLI::log( ' ✓ ' . $row['change'] ); | |
| 634 | + } | |
| 635 | + foreach ( $result['reverted'] as $row ) { | |
| 636 | + \WP_CLI::warning( 'Undone: ' . $row['id'] . ' — ' . $row['why'] ); | |
| 637 | + } | |
| 638 | + foreach ( $result['unfixable'] as $row ) { | |
| 639 | + \WP_CLI::log( ' ! ' . $row['issue'] . ( '' !== $row['fix'] ? ' — ' . $row['fix'] : '' ) ); | |
| 640 | + } | |
| 641 | + | |
| 642 | + if ( ! empty( $result['applied'] ) ) { | |
| 643 | + // "applied and verified" was more than the checks earn. They read | |
| 644 | + // HTML in PHP and cannot run JavaScript, so this line was telling | |
| 645 | + // someone the site was fine when the only honest claim is that | |
| 646 | + // nothing in the markup looked broken. | |
| 647 | + \WP_CLI::success( count( $result['applied'] ) . ' change(s) applied; HTML checks passed.' ); | |
| 648 | + | |
| 649 | + if ( ! empty( $result['verify_urls'] ) ) { | |
| 650 | + \WP_CLI::log( '' ); | |
| 651 | + \WP_CLI::log( 'Now open these and check they render, with no console errors:' ); | |
| 652 | + foreach ( $result['verify_urls'] as $u ) { | |
| 653 | + \WP_CLI::log( ' ' . $u ); | |
| 654 | + } | |
| 655 | + } | |
| 656 | + } | |
| 657 | + } | |
| 658 | + | |
| 659 | + /** | |
| 660 | + * `wp xspeed purge` — clear every cache xSpeed owns, in one call. | |
| 661 | + * | |
| 662 | + * Reports per store rather than printing a success banner, because the | |
| 663 | + * banner was the bug: a site whose Cloudflare token had lost its purge | |
| 664 | + * permission saw "cache cleared" and kept serving stale HTML from the | |
| 665 | + * edge. What is skipped is as much of the answer as what is cleared, so | |
| 666 | + * every skip prints its reason. | |
| 667 | + * | |
| 668 | + * Exit code follows the same distinction. A store that is not configured | |
| 669 | + * has nothing to clear and does not fail the run — otherwise every CI | |
| 670 | + * pipeline on a site without Redis goes red for a purge that did exactly | |
| 671 | + * what it should. A store that IS configured and refused is a failure. | |
| 672 | + * | |
| 673 | + * There is deliberately no `--url`: WP-CLI reserves that flag for | |
| 674 | + * multisite site selection and consumes it before a handler ever sees it. | |
| 675 | + * Clearing one page is `wp xspeed cache purge-url <url>`. | |
| 676 | + * | |
| 677 | + * @param array<int,string> $args Positional args (unused). | |
| 678 | + * @param array<string,string> $assoc Flags. | |
| 679 | + */ | |
| 680 | + public function cli_purge( array $args, array $assoc ): void { | |
| 681 | + unset( $args ); | |
| 682 | + | |
| 683 | + $requested = array_values( | |
| 684 | + array_filter( | |
| 685 | + array_map( 'trim', explode( ',', (string) ( $assoc['type'] ?? 'all' ) ) ) | |
| 686 | + ) | |
| 687 | + ); | |
| 688 | + if ( ! $requested ) { | |
| 689 | + $requested = array( 'all' ); | |
| 690 | + } | |
| 691 | + | |
| 692 | + $accepted = \XSpeed\Purge_Runner::accepted_types(); | |
| 693 | + $unknown = array_diff( $requested, $accepted ); | |
| 694 | + if ( $unknown ) { | |
| 695 | + // Refuse before purging anything: a typo in --type must not | |
| 696 | + // quietly clear a DIFFERENT store than the one named. | |
| 697 | + \WP_CLI::error( | |
| 698 | + sprintf( | |
| 699 | + 'Unknown purge type: %s. Expected one of: %s', | |
| 700 | + implode( ', ', $unknown ), | |
| 701 | + implode( ', ', $accepted ) | |
| 702 | + ) | |
| 703 | + ); | |
| 704 | + return; | |
| 705 | + } | |
| 706 | + | |
| 707 | + $cause = isset( $assoc['cause'] ) && '' !== trim( (string) $assoc['cause'] ) ? trim( (string) $assoc['cause'] ) : 'CLI'; | |
| 708 | + $report = \XSpeed\Purge_Runner::run( $requested, $cause ); | |
| 709 | + | |
| 710 | + if ( 'json' === ( $assoc['format'] ?? 'table' ) ) { | |
| 711 | + // The report goes to STDOUT alone so `... --format=json | jq` works; | |
| 712 | + // the failure message goes to STDERR via ::error, which is also | |
| 713 | + // what produces the non-zero exit. | |
| 714 | + \WP_CLI::line( (string) wp_json_encode( $report ) ); | |
| 715 | + if ( ! $report['ok'] ) { | |
| 716 | + \WP_CLI::error( 'One or more cache stores failed to purge; see the report above.' ); | |
| 717 | + } | |
| 718 | + return; | |
| 719 | + } | |
| 720 | + | |
| 721 | + $cleared = 0; | |
| 722 | + $skipped = 0; | |
| 723 | + $failed = 0; | |
| 724 | + foreach ( $report['types'] as $row ) { | |
| 725 | + switch ( $row['status'] ) { | |
| 726 | + case \XSpeed\Purge_Runner::CLEARED: | |
| 727 | + ++$cleared; | |
| 728 | + \WP_CLI::log( sprintf( ' cleared %s%s', $row['label'], self::purge_amount( $row ) ) ); | |
| 729 | + break; | |
| 730 | + case \XSpeed\Purge_Runner::FAILED: | |
| 731 | + ++$failed; | |
| 732 | + \WP_CLI::log( sprintf( ' FAILED %s — %s', $row['label'], $row['reason'] ) ); | |
| 733 | + break; | |
| 734 | + default: | |
| 735 | + ++$skipped; | |
| 736 | + \WP_CLI::log( sprintf( ' skipped %s — %s', $row['label'], $row['reason'] ) ); | |
| 737 | + } | |
| 738 | + } | |
| 739 | + | |
| 740 | + if ( $failed ) { | |
| 741 | + \WP_CLI::error( | |
| 742 | + sprintf( | |
| 743 | + '%d of %d cache store(s) failed to purge; %d cleared, %d skipped.', | |
| 744 | + $failed, | |
| 745 | + count( $report['types'] ), | |
| 746 | + $cleared, | |
| 747 | + $skipped | |
| 748 | + ) | |
| 749 | + ); | |
| 750 | + return; | |
| 751 | + } | |
| 752 | + | |
| 753 | + if ( ! $cleared ) { | |
| 754 | + // Not a success banner: nothing was purged, and saying so is the | |
| 755 | + // honest answer for a --type nobody has configured. | |
| 756 | + \WP_CLI::log( sprintf( 'Nothing to purge — %d store(s) skipped.', $skipped ) ); | |
| 757 | + return; | |
| 758 | + } | |
| 759 | + | |
| 760 | + \WP_CLI::success( sprintf( 'Purged %d cache store(s); %d skipped.', $cleared, $skipped ) ); | |
| 761 | + } | |
| 762 | + | |
| 763 | + /** | |
| 764 | + * The " — 42 entries (1.3 MB)" tail on a cleared line. | |
| 765 | + * | |
| 766 | + * Entries and bytes are both optional: a Redis FLUSHALL reports neither, | |
| 767 | + * and printing "0 entries" for it would read as an empty cache rather | |
| 768 | + * than an uncountable one. | |
| 769 | + * | |
| 770 | + * @param array{entries:int|null,bytes:int|null} $row Report row. | |
| 771 | + */ | |
| 772 | + private static function purge_amount( array $row ): string { | |
| 773 | + $parts = array(); | |
| 774 | + if ( null !== $row['entries'] ) { | |
| 775 | + $parts[] = sprintf( '%d entr%s', $row['entries'], 1 === (int) $row['entries'] ? 'y' : 'ies' ); | |
| 776 | + } | |
| 777 | + if ( null !== $row['bytes'] && $row['bytes'] > 0 ) { | |
| 778 | + $parts[] = size_format( $row['bytes'], 1 ); | |
| 779 | + } | |
| 780 | + | |
| 781 | + return $parts ? ' — ' . implode( ', ', $parts ) : ''; | |
| 782 | + } | |
| 783 | + | |
| 237 | 784 | public function cli_handler( array $args, array $assoc ): void { |
| 785 | + $action = isset( $args[0] ) ? (string) $args[0] : 'status'; | |
| 786 | + $limit = isset( $assoc['limit'] ) ? max( 1, (int) $assoc['limit'] ) : 20; | |
| 787 | + | |
| 788 | + /* | |
| 789 | + * Print the unified nginx server-block so an installer, provisioning | |
| 790 | + * script, or another plugin can fetch it non-interactively and write | |
| 791 | + * it into a vhost. Previously this was only reachable via | |
| 792 | + * `wp eval 'echo \XSpeed\Cache::full_nginx_server_block();'`, which | |
| 793 | + * is not a supported surface (and is unavailable over MCP, where | |
| 794 | + * run_command dispatches these same callbacks). | |
| 795 | + * | |
| 796 | + * Output discipline matters here: the config goes to STDOUT with | |
| 797 | + * nothing else, so `wp xspeed cache nginx-config > site.conf` yields a | |
| 798 | + * pasteable file. Every diagnostic goes to STDERR via WP_CLI::warning | |
| 799 | + * / ::error, and a non-nginx host or an empty block exits non-zero so | |
| 800 | + * a script can branch on it rather than writing an empty file. | |
| 801 | + * | |
| 802 | + * --server exists because detection cannot work here. WP-CLI runs | |
| 803 | + * without SERVER_SOFTWARE, so Server::type() falls back to the value | |
| 804 | + * a previous web request cached — and on a site provisioned entirely | |
| 805 | + * over WP-CLI there is no such value, leaving `unknown` on a genuine | |
| 806 | + * nginx host. Rather than guess (a loopback request is the one thing | |
| 807 | + * least likely to work mid-provisioning), let the caller state it: | |
| 808 | + * the script writing to /etc/nginx/ already knows the answer. | |
| 809 | + * Without the flag nothing changes, so a script sweeping a mixed | |
| 810 | + * fleet still gets its non-zero exit on Apache. | |
| 811 | + * | |
| 812 | + * It pins Server::type() rather than being passed down, because the | |
| 813 | + * decision is re-made at every level: full_nginx_server_block(), | |
| 814 | + * Cache::nginx_snippet(), and each module's own nginx_directives() | |
| 815 | + * all ask independently. Threading an argument through would leave | |
| 816 | + * the deeper gates still detecting, and the command would emit a | |
| 817 | + * config missing its cache rewrite — worse than refusing outright. | |
| 818 | + */ | |
| 819 | + if ( 'nginx-config' === $action ) { | |
| 820 | + /* | |
| 821 | + * Scoped to this one generation pass, not the request. Under | |
| 822 | + * real WP-CLI the process ends here either way, but the same | |
| 823 | + * callback runs over MCP, where several commands share one PHP | |
| 824 | + * request — a pin left in place made the NEXT command report | |
| 825 | + * this host as nginx too. | |
| 826 | + */ | |
| 827 | + $pin = null; | |
| 828 | + $assume = null; | |
| 829 | + | |
| 830 | + if ( isset( $assoc['server'] ) ) { | |
| 831 | + $assume = strtolower( trim( (string) $assoc['server'] ) ); | |
| 832 | + } elseif ( \XSpeed\Server::UNKNOWN === \XSpeed\Server::type() ) { | |
| 833 | + /* | |
| 834 | + * Nothing to detect from, and the action names the server: | |
| 835 | + * `nginx-config` is the request, so absence of evidence | |
| 836 | + * defers to it. Positive evidence to the contrary still | |
| 837 | + * wins — an Apache or LiteSpeed host is told it needs no | |
| 838 | + * nginx block at all, which is the answer that helps. | |
| 839 | + */ | |
| 840 | + $assume = \XSpeed\Server::NGINX; | |
| 841 | + | |
| 842 | + /* | |
| 843 | + * Only where warnings have somewhere else to go. Real WP-CLI | |
| 844 | + * sends them to STDERR, leaving the config clean on STDOUT. | |
| 845 | + * The MCP shim has ONE buffer for both, so warning there | |
| 846 | + * would prepend "Warning: …" to the config itself and hand | |
| 847 | + * the caller a file nginx refuses. The constant is the | |
| 848 | + * discriminator: real WP-CLI defines it, the shim defines | |
| 849 | + * only the class. | |
| 850 | + */ | |
| 851 | + if ( defined( 'WP_CLI' ) && \WP_CLI ) { | |
| 852 | + \WP_CLI::warning( | |
| 853 | + '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.' | |
| 854 | + ); | |
| 855 | + } | |
| 856 | + } | |
| 857 | + | |
| 858 | + if ( null !== $assume ) { | |
| 859 | + $pinned = $assume; | |
| 860 | + $pin = static function () use ( $pinned ) { | |
| 861 | + return $pinned; | |
| 862 | + }; | |
| 863 | + add_filter( 'xspeed_server_type', $pin ); | |
| 864 | + } | |
| 865 | + | |
| 866 | + $block = \XSpeed\Cache::full_nginx_server_block(); | |
| 867 | + $server = \XSpeed\Server::type(); | |
| 868 | + | |
| 869 | + if ( null !== $pin ) { | |
| 870 | + remove_filter( 'xspeed_server_type', $pin ); | |
| 871 | + } | |
| 872 | + | |
| 873 | + if ( ! is_string( $block ) || '' === trim( $block ) ) { | |
| 874 | + /* | |
| 875 | + * $server cannot be UNKNOWN here: an undetectable host was | |
| 876 | + * already assumed to be nginx above, so anything left is a | |
| 877 | + * server we positively identified — and telling an Apache or | |
| 878 | + * LiteSpeed operator that .htaccess already covers them is | |
| 879 | + * more useful than handing them a block to paste nowhere. | |
| 880 | + */ | |
| 881 | + if ( \XSpeed\Server::NGINX !== $server ) { | |
| 882 | + \WP_CLI::error( | |
| 883 | + sprintf( | |
| 884 | + 'No nginx server-block to print — this site is running on %s. On Apache and LiteSpeed xSpeed writes its rules to .htaccess automatically.', | |
| 885 | + $server | |
| 886 | + ) | |
| 887 | + ); | |
| 888 | + return; | |
| 889 | + } | |
| 890 | + \WP_CLI::error( 'No nginx directives to print — page caching and every module that contributes directives are currently disabled.' ); | |
| 891 | + return; | |
| 892 | + } | |
| 893 | + | |
| 894 | + // STDOUT only: no WP_CLI::log() prefixing, so redirection gives a | |
| 895 | + // clean file. WP_CLI::line() writes the raw string. | |
| 896 | + \WP_CLI::line( rtrim( $block, "\n" ) ); | |
| 897 | + return; | |
| 898 | + } | |
| 899 | + | |
| 900 | + /* | |
| 901 | + * Force a fresh static-rewrite probe. The result is cached for five | |
| 902 | + * minutes and nothing invalidated it, so after fixing an nginx config | |
| 903 | + * there was no way to re-check — the "configure your server" banner | |
| 904 | + * just stayed up. (FBS-84012) | |
| 905 | + */ | |
| 906 | + if ( 'recheck-rewrite' === $action ) { | |
| 907 | + // Qualify the raw probe against known config refusals before | |
| 908 | + // reporting. The probe fetches its OWN file from the static tree, | |
| 909 | + // which succeeds even when no real page is served that way — so | |
| 910 | + // an unqualified `active` reported "the web server is serving | |
| 911 | + // cache hits directly" on sites whose every page returned | |
| 912 | + // HIT (php). See Cache::qualify_rewrite_probe(). | |
| 913 | + $probe = \XSpeed\Cache::qualify_rewrite_probe( \XSpeed\Cache::recheck_static_rewrite() ); | |
| 914 | + $blocked = '' !== (string) $probe['block_reason']; | |
| 915 | + | |
| 916 | + // Whether the installed rules are the ones these settings | |
| 917 | + // generate. Reported before the verdict below because it is the | |
| 918 | + // question someone running this command has just acted on — they | |
| 919 | + // pasted a block and want to know if it took — and because | |
| 920 | + // "active" is true for a stale block too. | |
| 921 | + $this->cli_report_rules_state( (array) ( $probe['rules'] ?? array() ) ); | |
| 922 | + | |
| 923 | + if ( $probe['active'] ) { | |
| 924 | + \WP_CLI::success( 'Static rewrite is active — the web server is serving cache hits directly.' ); | |
| 925 | + return; | |
| 926 | + } | |
| 927 | + if ( $blocked ) { | |
| 928 | + \WP_CLI::warning( sprintf( 'Static rewrite is not active: %s', (string) $probe['reason'] ) ); | |
| 929 | + return; | |
| 930 | + } | |
| 931 | + if ( $probe['inconclusive'] ) { | |
| 932 | + \WP_CLI::warning( sprintf( 'Could not verify the static rewrite: %s', (string) $probe['reason'] ) ); | |
| 933 | + \WP_CLI::log( 'This is a probe failure, not proof that your server config is wrong.' ); | |
| 934 | + return; | |
| 935 | + } | |
| 936 | + \WP_CLI::warning( sprintf( 'Static rewrite is not active: %s', (string) ( $probe['reason'] ?: 'unknown' ) ) ); | |
| 937 | + return; | |
| 938 | + } | |
| 939 | + | |
| 940 | + if ( 'purge-url' === $action ) { | |
| 941 | + $url = isset( $args[1] ) ? trim( (string) $args[1] ) : ''; | |
| 942 | + if ( '' === $url ) { | |
| 943 | + \WP_CLI::error( 'Usage: wp xspeed cache purge-url <url-or-path>' ); | |
| 944 | + return; | |
| 945 | + } | |
| 946 | + $cause = isset( $assoc['cause'] ) && '' !== trim( (string) $assoc['cause'] ) ? trim( (string) $assoc['cause'] ) : 'CLI'; | |
| 947 | + $result = \XSpeed\Cache::purge_url_reported( $url, $cause ); | |
| 948 | + $removed = $result['removed']; | |
| 949 | + $forwarded = implode( ', ', $result['forwarded'] ); | |
| 950 | + if ( $removed > 0 ) { | |
| 951 | + \WP_CLI::success( | |
| 952 | + '' === $forwarded | |
| 953 | + ? sprintf( 'Purged %d cache file(s) for %s', $removed, $url ) | |
| 954 | + : sprintf( 'Purged %d cache file(s) for %s, and sent the purge to %s', $removed, $url, $forwarded ) | |
| 955 | + ); | |
| 956 | + } elseif ( '' !== $forwarded ) { | |
| 957 | + // xSpeed's own cache held nothing, but a cache in front of | |
| 958 | + // PHP took the purge: that copy is the one visitors get. | |
| 959 | + \WP_CLI::success( sprintf( 'Sent the purge for %s to %s. xSpeed\'s own cache held no copy.', $url, $forwarded ) ); | |
| 960 | + } else { | |
| 961 | + \WP_CLI::log( sprintf( 'No cache entries found for %s (already cold, or the URL never cached).', $url ) ); | |
| 962 | + } | |
| 963 | + return; | |
| 964 | + } | |
| 965 | + | |
| 966 | + if ( 'inventory' === $action ) { | |
| 967 | + $this->cli_inventory( $limit ); | |
| 968 | + return; | |
| 969 | + } | |
| 970 | + | |
| 971 | + if ( 'size' === $action ) { | |
| 972 | + $this->cli_size(); | |
| 973 | + return; | |
| 974 | + } | |
| 975 | + | |
| 976 | + if ( 'purge-log' === $action ) { | |
| 977 | + $this->cli_purge_log( $limit ); | |
| 978 | + return; | |
| 979 | + } | |
| 980 | + | |
| 981 | + if ( 'edge' === $action ) { | |
| 982 | + $this->cli_edge(); | |
| 983 | + return; | |
| 984 | + } | |
| 985 | + | |
| 986 | + if ( 'listings' === $action ) { | |
| 987 | + $this->cli_listings( isset( $assoc['limit'] ) ? $limit : 5 ); | |
| 988 | + return; | |
| 989 | + } | |
| 990 | + | |
| 238 | 991 | $opts = Settings_Manager::get( self::SLUG ); |
| 239 | 992 | \WP_CLI::log( 'cache_expiry ' . $opts['cache_expiry'] . 'h' ); |
| 240 | 993 | \WP_CLI::log( 'excluded_urls ' . count( $opts['excluded_urls'] ) . ' entries' ); |
| 241 | 994 | foreach ( $opts['excluded_urls'] as $u ) { |
| @@ -240,11 +993,277 @@ | ||
| 240 | 993 | \WP_CLI::log( 'excluded_urls ' . count( $opts['excluded_urls'] ) . ' entries' ); |
| 241 | 994 | foreach ( $opts['excluded_urls'] as $u ) { |
| 242 | 995 | \WP_CLI::log( ' - ' . $u ); |
| 243 | 996 | } |
| 997 | + $edge = \XSpeed\Edge_Provider::detect(); | |
| 998 | + \WP_CLI::log( 'edge ' . ( '' !== $edge['provider'] ? $edge['provider'] : $edge['confidence'] ) . ' (' . $edge['source'] . ')' ); | |
| 244 | 999 | } |
| 245 | 1000 | |
| 246 | 1001 | /** |
| 1002 | + * `wp xspeed cache edge` — what we think is in front, and what we say to it. | |
| 1003 | + * | |
| 1004 | + * Worth printing even when nothing is held back. "You are behind | |
| 1005 | + * Cloudflare, and a Cache Rule set to ignore origin headers overrides | |
| 1006 | + * anything xSpeed sends" is the answer to a support question that | |
| 1007 | + * otherwise costs someone a week, and it is true whether or not a hold | |
| 1008 | + * ever fires. | |
| 1009 | + */ | |
| 1010 | + private function cli_edge(): void { | |
| 1011 | + $answer = \XSpeed\Edge_Provider::detect(); | |
| 1012 | + | |
| 1013 | + \WP_CLI::log( 'provider ' . ( '' !== $answer['provider'] ? $answer['provider'] : '(none named)' ) ); | |
| 1014 | + \WP_CLI::log( 'confidence ' . $answer['confidence'] ); | |
| 1015 | + \WP_CLI::log( 'source ' . $answer['source'] ); | |
| 1016 | + | |
| 1017 | + // A pin outranks detection by design, so nothing re-checks it on the | |
| 1018 | + // site's behalf. Saying the two disagree is the whole mechanism by | |
| 1019 | + // which a site that changed CDN ever finds out. | |
| 1020 | + $sniffed = \XSpeed\Edge_Provider::sniffed(); | |
| 1021 | + if ( in_array( $answer['source'], array( 'setting', 'constant', 'filter' ), true ) | |
| 1022 | + && '' !== $sniffed['provider'] | |
| 1023 | + && $sniffed['provider'] !== $answer['provider'] ) { | |
| 1024 | + \WP_CLI::warning( | |
| 1025 | + sprintf( | |
| 1026 | + 'This request looks like %s, but the provider is pinned to %s. If the site moved, change it — the pinned answer is also baked into the drop-in and the server rules.', | |
| 1027 | + $sniffed['provider'], | |
| 1028 | + '' !== $answer['provider'] ? $answer['provider'] : 'off' | |
| 1029 | + ) | |
| 1030 | + ); | |
| 1031 | + } | |
| 1032 | + | |
| 1033 | + if ( \XSpeed\Edge_Provider::is_off( $answer ) ) { | |
| 1034 | + \WP_CLI::log( '' ); | |
| 1035 | + \WP_CLI::log( 'Nothing is sent: this is switched off.' ); | |
| 1036 | + return; | |
| 1037 | + } | |
| 1038 | + | |
| 1039 | + // Resolved through edge_headers_for() rather than straight off the | |
| 1040 | + // provider, so this prints what the serve path would ACTUALLY send — | |
| 1041 | + // including `X-XSpeed-Edge-Hold`, and including the evidence gate. | |
| 1042 | + // Listing the provider's raw set ignored that gate and told operators | |
| 1043 | + // a first render would be held on a site where it would not be. | |
| 1044 | + // | |
| 1045 | + // `bake`, not `request`. Two reasons, and the second one matters: | |
| 1046 | + // this command answers for the site rather than for one response, and | |
| 1047 | + // `request` fires `xspeed_edge_optimization_pending`, whose Pro | |
| 1048 | + // listener resolves the CSS plan — which by its own description is | |
| 1049 | + // what queues a build. A read-only command must not burn a build | |
| 1050 | + // slot, quarantine an entry or purge a page just by being run, and | |
| 1051 | + // under WP-CLI it would do all three against the home page. | |
| 1052 | + $bypass = \XSpeed\Cache::edge_headers_for( 'BYPASS', 'bake', 'logged-in' ); | |
| 1053 | + $miss = \XSpeed\Cache::edge_headers_for( 'MISS', 'bake' ); | |
| 1054 | + | |
| 1055 | + \WP_CLI::log( '' ); | |
| 1056 | + \WP_CLI::log( 'On a page xSpeed refuses to cache (a cart, a logged-in view):' ); | |
| 1057 | + foreach ( $bypass as $name => $value ) { | |
| 1058 | + \WP_CLI::log( sprintf( ' %s: %s', $name, $value ) ); | |
| 1059 | + } | |
| 1060 | + | |
| 1061 | + \WP_CLI::log( '' ); | |
| 1062 | + if ( array() === $miss ) { | |
| 1063 | + \WP_CLI::log( 'On a first render: nothing. A MISS is a performance hedge, so it is held only where a cache in front was detected — and none was. Name the provider in Cache In Front Of This Site to cover first renders too.' ); | |
| 1064 | + } else { | |
| 1065 | + \WP_CLI::log( 'On a first render:' ); | |
| 1066 | + foreach ( $miss as $name => $value ) { | |
| 1067 | + \WP_CLI::log( sprintf( ' %s: %s', $name, $value ) ); | |
| 1068 | + } | |
| 1069 | + } | |
| 1070 | + | |
| 1071 | + // The same bake the drop-in gets, so it asks no per-page question. | |
| 1072 | + $variant = \XSpeed\Cache::query_variant_edge_headers(); | |
| 1073 | + | |
| 1074 | + \WP_CLI::log( '' ); | |
| 1075 | + if ( array() === $variant ) { | |
| 1076 | + \WP_CLI::log( 'A cached page requested with an ignored parameter (?utm_source=x) gets the same headers as without one. It is held only where a cache in front was detected, and with Separate Mobile Cache on every page is held already.' ); | |
| 1077 | + } else { | |
| 1078 | + \WP_CLI::log( 'On a cached page requested with an ignored parameter (?utm_source=x), a cached search or a query-form feed:' ); | |
| 1079 | + foreach ( $variant as $name => $value ) { | |
| 1080 | + \WP_CLI::log( sprintf( ' %s: %s', $name, $value ) ); | |
| 1081 | + } | |
| 1082 | + } | |
| 1083 | + | |
| 1084 | + \WP_CLI::log( '' ); | |
| 1085 | + \WP_CLI::log( 'X-XSpeed-Edge-Hold names why a response was held: bypass, bypass-shape, miss, mobile-split, pending or query-variant. No header means nothing was held.' ); | |
| 1086 | + | |
| 1087 | + if ( 'cloudflare' === $answer['provider'] ) { | |
| 1088 | + \WP_CLI::log( '' ); | |
| 1089 | + \WP_CLI::log( 'A Cloudflare Cache Rule whose Edge TTL is "Ignore cache-control header and use this TTL" overrides all of the above. Use "Respect origin TTL" on that rule if pages are still being stored.' ); | |
| 1090 | + } | |
| 1091 | + } | |
| 1092 | + | |
| 1093 | + /** | |
| 1094 | + * `wp xspeed cache listings`: the pages recorded running a post list | |
| 1095 | + * outside the main loop, which a narrow purge adds when a save may | |
| 1096 | + * change that list. | |
| 1097 | + * | |
| 1098 | + * For measuring how wide that set gets on a real site: close to every | |
| 1099 | + * page means a narrow purge saves little there. | |
| 1100 | + * | |
| 1101 | + * @param int $samples Pages to print. | |
| 1102 | + */ | |
| 1103 | + private function cli_listings( int $samples ): void { | |
| 1104 | + $narrow = ! empty( Settings_Manager::get( self::SLUG )['purge_affected_only'] ); | |
| 1105 | + $stats = \XSpeed\Listing_Pages::stats( $samples ); | |
| 1106 | + $limit = \XSpeed\Affected_Pages::LIMIT; | |
| 1107 | + | |
| 1108 | + \WP_CLI::log( 'narrow purge ' . ( $narrow ? 'on' : 'off' ) ); | |
| 1109 | + \WP_CLI::log( 'recording ' . ( \XSpeed\Listing_Pages::enabled() ? 'on' : 'off' ) ); | |
| 1110 | + \WP_CLI::log( 'directory ' . $stats['dir'] ); | |
| 1111 | + | |
| 1112 | + \WP_CLI::log( '' ); | |
| 1113 | + \WP_CLI::log( 'Newest-N lists (' . count( $stats['specs'] ) . ' of ' . \XSpeed\Listing_Pages::SPEC_CAP . '): a change to one of the newest N clears the whole site.' ); | |
| 1114 | + foreach ( $stats['specs'] as $spec ) { | |
| 1115 | + \WP_CLI::log( sprintf( ' newest %d of %s%s', $spec['n'], implode( ', ', $spec['types'] ), $spec['sticky'] ? ', sticky posts first' : '' ) ); | |
| 1116 | + } | |
| 1117 | + | |
| 1118 | + \WP_CLI::log( '' ); | |
| 1119 | + \WP_CLI::log( sprintf( 'Pages with other lists: %d recorded (%d files, cap %d); %d posts in the shown-on index.', $stats['pages'], $stats['files'], \XSpeed\Listing_Pages::CAP, $stats['posts'] ) ); | |
| 1120 | + foreach ( $stats['types'] as $type => $counts ) { | |
| 1121 | + \WP_CLI::log( | |
| 1122 | + sprintf( | |
| 1123 | + ' %-14s %d pages, %d not precise%s', | |
| 1124 | + $type, | |
| 1125 | + $counts['all'], | |
| 1126 | + $counts['loose'], | |
| 1127 | + $counts['loose'] > $limit | |
| 1128 | + ? ' (over ' . $limit . ' not precise: any change to a post of this type clears the whole site)' | |
| 1129 | + : ( $counts['all'] > $limit ? ' (over ' . $limit . ': a change that is not a plain edit clears the whole site)' : '' ) | |
| 1130 | + ) | |
| 1131 | + ); | |
| 1132 | + } | |
| 1133 | + | |
| 1134 | + if ( null !== $stats['full'] ) { | |
| 1135 | + \WP_CLI::warning( | |
| 1136 | + sprintf( | |
| 1137 | + 'The record is full, and pages listing %s went unrecorded: a change to a post of those types clears the whole site until a save finds the record under the cap.', | |
| 1138 | + implode( ', ', array_map( 'strval', (array) ( $stats['full']['types'] ?? array( 'any' ) ) ) ) | |
| 1139 | + ) | |
| 1140 | + ); | |
| 1141 | + } | |
| 1142 | + if ( array() === $stats['samples'] ) { | |
| 1143 | + return; | |
| 1144 | + } | |
| 1145 | + \WP_CLI::log( '' ); | |
| 1146 | + foreach ( $stats['samples'] as $record ) { | |
| 1147 | + \WP_CLI::log( | |
| 1148 | + sprintf( | |
| 1149 | + ' %s [%s] %s', | |
| 1150 | + $record['url'], | |
| 1151 | + implode( ', ', $record['types'] ), | |
| 1152 | + $record['precise'] ? 'precise, ' . count( $record['ids'] ) . ' post(s) shown' : 'not precise' | |
| 1153 | + ) | |
| 1154 | + ); | |
| 1155 | + } | |
| 1156 | + } | |
| 1157 | + | |
| 1158 | + /** | |
| 1159 | + * Say which version of the cache rules the server is running. | |
| 1160 | + * | |
| 1161 | + * The generated block stamps every static hit with a hash of itself, and | |
| 1162 | + * the probe reads that back — the only way to tell what someone actually | |
| 1163 | + * pasted into a config WordPress cannot open. `stale` names the settings | |
| 1164 | + * that moved since, where we can tell, because "re-paste the block" with | |
| 1165 | + * no reason attached is what makes people ignore it. | |
| 1166 | + * | |
| 1167 | + * @param array $rules Cache::rules_state() output. | |
| 1168 | + */ | |
| 1169 | + private function cli_report_rules_state( array $rules ): void { | |
| 1170 | + $state = (string) ( $rules['state'] ?? '' ); | |
| 1171 | + | |
| 1172 | + if ( 'current' === $state ) { | |
| 1173 | + \WP_CLI::log( 'Cache rules: current — the installed rules are the ones these settings generate.' ); | |
| 1174 | + return; | |
| 1175 | + } | |
| 1176 | + if ( 'stale' === $state ) { | |
| 1177 | + $changed = array_filter( array_map( 'strval', (array) ( $rules['changed'] ?? array() ) ) ); | |
| 1178 | + \WP_CLI::log( | |
| 1179 | + '' === implode( '', $changed ) | |
| 1180 | + ? 'Cache rules: stale — a different version of the rules is installed. Re-paste the block.' | |
| 1181 | + : sprintf( | |
| 1182 | + 'Cache rules: stale — a different version of the rules is installed (%s changed since). Re-paste the block.', | |
| 1183 | + implode( ', ', $changed ) | |
| 1184 | + ) | |
| 1185 | + ); | |
| 1186 | + return; | |
| 1187 | + } | |
| 1188 | + if ( 'absent' === $state ) { | |
| 1189 | + \WP_CLI::log( 'Cache rules: absent — no xSpeed rules are installed, or they predate the version marker.' ); | |
| 1190 | + return; | |
| 1191 | + } | |
| 1192 | + | |
| 1193 | + \WP_CLI::log( 'Cache rules: unknown — the probe could not read a rules marker back from this server.' ); | |
| 1194 | + } | |
| 1195 | + | |
| 1196 | + /** `wp xspeed cache inventory [--limit=N]` — which pages are cached, and how old. */ | |
| 1197 | + private function cli_inventory( int $limit ): void { | |
| 1198 | + $data = \XSpeed\Cache_Inventory::entries( $limit ); | |
| 1199 | + | |
| 1200 | + if ( empty( $data['entries'] ) ) { | |
| 1201 | + \WP_CLI::log( 'Cache is empty — no cached pages on disk.' ); | |
| 1202 | + return; | |
| 1203 | + } | |
| 1204 | + | |
| 1205 | + \WP_CLI::log( sprintf( '%d cached page(s); showing %d.', $data['total'], count( $data['entries'] ) ) ); | |
| 1206 | + if ( ! empty( $data['capped'] ) ) { | |
| 1207 | + \WP_CLI::warning( sprintf( 'Scan stopped at %d files — the list is a recent sample, not the whole cache.', \XSpeed\Cache_Inventory::SCAN_CAP ) ); | |
| 1208 | + } | |
| 1209 | + foreach ( $data['entries'] as $entry ) { | |
| 1210 | + \WP_CLI::log( | |
| 1211 | + sprintf( | |
| 1212 | + ' %-58s %8s %s [%s]', | |
| 1213 | + null === $entry['url'] ? '(url unknown: ' . $entry['key'] . ')' : $entry['url'], | |
| 1214 | + size_format( (int) $entry['bytes'] ), | |
| 1215 | + $this->relative_age( (int) $entry['age'] ), | |
| 1216 | + implode( '+', (array) $entry['stored_in'] ) | |
| 1217 | + ) | |
| 1218 | + ); | |
| 1219 | + } | |
| 1220 | + } | |
| 1221 | + | |
| 1222 | + /** `wp xspeed cache size` — where the cache's disk usage goes. */ | |
| 1223 | + private function cli_size(): void { | |
| 1224 | + $data = \XSpeed\Cache_Inventory::size_breakdown(); | |
| 1225 | + | |
| 1226 | + \WP_CLI::log( sprintf( 'Total %s across %d file(s).', size_format( (int) $data['total_bytes'] ), (int) $data['total_files'] ) ); | |
| 1227 | + foreach ( $data['buckets'] as $bucket ) { | |
| 1228 | + if ( 0 === (int) $bucket['files'] ) { | |
| 1229 | + continue; | |
| 1230 | + } | |
| 1231 | + \WP_CLI::log( sprintf( ' %-32s %10s %d file(s)', $bucket['label'], size_format( (int) $bucket['bytes'] ), (int) $bucket['files'] ) ); | |
| 1232 | + } | |
| 1233 | + if ( (int) $data['compressed_bytes'] > 0 ) { | |
| 1234 | + \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'] ) ) ); | |
| 1235 | + } | |
| 1236 | + } | |
| 1237 | + | |
| 1238 | + /** `wp xspeed cache purge-log [--limit=N]` — what cleared the cache, when, and why. */ | |
| 1239 | + private function cli_purge_log( int $limit ): void { | |
| 1240 | + $data = \XSpeed\Cache_Inventory::purge_log( $limit ); | |
| 1241 | + | |
| 1242 | + if ( empty( $data['events'] ) ) { | |
| 1243 | + \WP_CLI::log( 'No purge events recorded yet.' ); | |
| 1244 | + return; | |
| 1245 | + } | |
| 1246 | + foreach ( $data['events'] as $event ) { | |
| 1247 | + \WP_CLI::log( sprintf( ' %s %s', $this->relative_age( max( 0, time() - (int) $event['ts'] ) ), $event['message'] ) ); | |
| 1248 | + } | |
| 1249 | + } | |
| 1250 | + | |
| 1251 | + /** Compact "4h ago" for CLI columns. */ | |
| 1252 | + private function relative_age( int $seconds ): string { | |
| 1253 | + if ( $seconds < 60 ) { | |
| 1254 | + return $seconds . 's ago'; | |
| 1255 | + } | |
| 1256 | + if ( $seconds < 3600 ) { | |
| 1257 | + return (int) floor( $seconds / 60 ) . 'm ago'; | |
| 1258 | + } | |
| 1259 | + if ( $seconds < 86400 ) { | |
| 1260 | + return (int) floor( $seconds / 3600 ) . 'h ago'; | |
| 1261 | + } | |
| 1262 | + return (int) floor( $seconds / 86400 ) . 'd ago'; | |
| 1263 | + } | |
| 1264 | + | |
| 1265 | + /** | |
| 247 | 1266 | * Static-rewrite directives for the unified nginx server-block |
| 248 | 1267 | * snippet. Returns null when cache is disabled — there's no rewrite |
| 249 | 1268 | * to install in that state. Delegates to \XSpeed\Cache::nginx_snippet() |
| 250 | 1269 | * which already produces nginx-detection-gated output. |
| @@ -254,6 +1273,32 @@ | ||
| 254 | 1273 | if ( empty( $opts['cache_enabled'] ) ) { |
| 255 | 1274 | return null; |
| 256 | 1275 | } |
| 257 | 1276 | return \XSpeed\Cache::nginx_snippet(); |
| 1277 | + } | |
| 1278 | + | |
| 1279 | + /** | |
| 1280 | + * Page caching's master switch is `cache_enabled` in the GLOBAL | |
| 1281 | + * `xspeed_options`, not a per-module `enabled` key -- Cache::toggle owns | |
| 1282 | + * it because flipping it rewrites .htaccess and wp-config.php. The base | |
| 1283 | + * implementation looks only at this module's own settings bag, so it | |
| 1284 | + * found nothing and reported null: the plugin's headline feature was | |
| 1285 | + * missing from its own "N on" count. (#363) | |
| 1286 | + */ | |
| 1287 | + public function is_active(): ?bool { | |
| 1288 | + $opts = get_option( 'xspeed_options', array() ); | |
| 1289 | + return ! empty( $opts['cache_enabled'] ); | |
| 1290 | + } | |
| 1291 | + | |
| 1292 | + /** | |
| 1293 | + * No reason shown: page caching has a single master switch, so the pill | |
| 1294 | + * already says everything an (i) would. The switch lives on the Overview | |
| 1295 | + * rather than on this page, but that is a "where is the control" question | |
| 1296 | + * the panel itself should answer, not a reason to explain the verdict. | |
| 1297 | + * | |
| 1298 | + * The (i) is reserved for modules whose on/off is genuinely non-obvious | |
| 1299 | + * -- counted from several flags, or from state outside the settings. | |
| 1300 | + */ | |
| 1301 | + public function active_reason(): ?string { | |
| 1302 | + return null; | |
| 258 | 1303 | } |
| 259 | 1304 | } |