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