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