| @@ -29,8 +29,9 @@ | ||
| 29 | 29 | use XSpeed\Server; |
| 30 | 30 | use XSpeed\Admin; |
| 31 | 31 | use XSpeed\Settings; |
| 32 | 32 | use XSpeed\Settings_Manager; |
| 33 | +use XSpeed\Module_Registry; | |
| 33 | 34 | use XSpeed\Pro_Audit; |
| 34 | 35 | use XSpeed\Cache_Benchmark; |
| 35 | 36 | use XSpeed\Tier_Registry; |
| 36 | 37 | use XSpeed\Database_Cleaner; |
| @@ -37,11 +38,16 @@ | ||
| 37 | 38 | |
| 38 | 39 | defined( 'ABSPATH' ) || exit; |
| 39 | 40 | |
| 40 | 41 | final class Mcp_Tools { |
| 42 | + /** Pro extension contract supported by this Free build. */ | |
| 43 | + public const EXTENSION_API = 1; | |
| 41 | 44 | |
| 45 | + /** Raw broker tool envelope cap, enforced before JSON decoding. */ | |
| 46 | + public const MAX_TOOL_BODY_BYTES = 2 * 1024 * 1024; | |
| 47 | + | |
| 42 | 48 | /** Valid cache purge types. */ |
| 43 | - public const PURGE_TYPES = array( 'all', 'page', 'assets', 'object', 'rest' ); | |
| 49 | + public const PURGE_TYPES = array( 'all', 'page', 'assets', 'object', 'rest', 'cloudflare', 'cdn' ); | |
| 44 | 50 | |
| 45 | 51 | /** |
| 46 | 52 | * Per-call read-only override. Null means "defer to the pairing token's |
| 47 | 53 | * scope" (the JSON-RPC path that predates OAuth). true/false is set by |
| @@ -138,15 +144,15 @@ | ||
| 138 | 144 | 'write' => false, |
| 139 | 145 | 'handler' => array( self::class, 'list_modules' ), |
| 140 | 146 | ), |
| 141 | 147 | 'get_site_info' => array( |
| 142 | - 'description' => 'Get facts about this site and install: whether xSpeed Pro is active and licensed, plugin/WordPress/PHP versions, and the detected web server. Use this rather than inferring the tier from the module list.', | |
| 148 | + 'description' => 'Get facts about this site and install: whether xSpeed Pro is active and licensed, plugin/WordPress/PHP versions, and the detected web server. Use this rather than inferring the tier from the module list. `addons` maps each separately licensed add-on to `{licensed, status}`. `licensed` says whether the add-on\'s licence is active. `status` is the licence status the site has stored (`valid`, `expired`, `inactive`, and so on), or null when no key is stored. Both are read from stored state, never from the licence server. `addons` is `{}` when no add-on reports. A licensed add-on may still have nothing set up.', | |
| 143 | 149 | 'inputSchema' => self::object_schema( array(), array() ), |
| 144 | 150 | 'write' => false, |
| 145 | 151 | 'handler' => array( self::class, 'get_site_info' ), |
| 146 | 152 | ), |
| 147 | 153 | 'optimize_site' => array( |
| 148 | - 'description' => 'Make this site faster, end to end: measure, apply the recommended settings ONE AT A TIME, check the page still renders after each, and undo any change that breaks it. Returns what was applied, the site\'s last recorded score, `next_steps` (riskier settings that could help but are NOT applied automatically), and `unfixable` (problems no caching plugin can reach). ALWAYS relay all three to the user: report the score and what is still wrong, then — if `next_steps` is non-empty — describe each one WITH its stated `risk` and ASK whether to run again with aggressiveness "aggressive". Never enable aggressive settings without the user agreeing first, and never present `unfixable` items as things you can solve; they need the site owner or the host. A site where nothing was left to do is a real, good answer — say so plainly rather than apologising or retrying. Use `dry_run` to preview the plan.', | |
| 154 | + 'description' => 'Make this site faster, end to end: measure, apply the recommended settings ONE AT A TIME, check the page still renders after each, and undo any change that breaks it. Returns what was applied, the site\'s performance score, `next_steps` (riskier settings that could help but are NOT applied automatically), and `unfixable` (problems no caching plugin can reach). ALWAYS relay all three to the user: report the score and what is still wrong, then — if `next_steps` is non-empty — describe each one WITH its stated `risk` and ASK whether to run again with aggressiveness "aggressive". Never enable aggressive settings without the user agreeing first, and never present `unfixable` items as things you can solve; they need the site owner or the host. A site where nothing was left to do is a real, good answer — say so plainly rather than apologising or retrying. Use `dry_run` to preview the plan. The `score` object carries `age_seconds` and `stale`: quote the score WITH how recently it was measured, and never describe a stale score as the result this run produced — when a measurement could not be taken, say the number is old rather than implying it is current. WHEN CHANGES WERE APPLIED the response carries `verify_urls` and `verify_note`: the safety checks read HTML in PHP and cannot execute JavaScript, so a page can pass every one of them and still be broken in a browser. Before reporting success, OPEN each URL in `verify_urls` if you have any way to load a page and confirm it renders with no console errors; if you cannot, tell the user those URLs need checking and what a problem would look like. `verified: true` means the HTML checks passed — it is not a statement that the site works.', | |
| 149 | 155 | 'inputSchema' => self::object_schema( |
| 150 | 156 | array( |
| 151 | 157 | 'aggressiveness' => array( |
| 152 | 158 | 'type' => 'string', |
| @@ -156,8 +162,24 @@ | ||
| 156 | 162 | 'dry_run' => array( |
| 157 | 163 | 'type' => 'boolean', |
| 158 | 164 | 'description' => 'Return the plan without changing anything.', |
| 159 | 165 | ), |
| 166 | + 'measure_score' => array( | |
| 167 | + 'type' => 'string', | |
| 168 | + 'enum' => array( 'auto', 'never', 'always' ), | |
| 169 | + 'description' => 'Whether to take a fresh PageSpeed measurement. 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. Measurements are rate-limited, so a run inside the cooldown returns the stored score with its age rather than a new one.', | |
| 170 | + ), | |
| 171 | + 'target_score' => array( | |
| 172 | + 'type' => 'integer', | |
| 173 | + 'minimum' => 1, | |
| 174 | + 'maximum' => 100, | |
| 175 | + 'description' => 'Repeat the optimize cycle toward this score instead of running a single pass. Each round costs a real PageSpeed measurement and up to two minutes, so pass it only when the user asked for a specific number. Requires the iterative tuner; without it the run is a single pass and the report says so in `stopped_because`. The run also stops early when further rounds stop helping — either way, read `stopped_because` and relay it rather than retrying.', | |
| 176 | + ), | |
| 177 | + 'max_rounds' => array( | |
| 178 | + 'type' => 'integer', | |
| 179 | + 'minimum' => 1, | |
| 180 | + 'description' => 'Ceiling on rounds when target_score is set. Clamped to what the tuner allows.', | |
| 181 | + ), | |
| 160 | 182 | ), |
| 161 | 183 | array() |
| 162 | 184 | ), |
| 163 | 185 | 'write' => true, |
| @@ -175,9 +197,9 @@ | ||
| 175 | 197 | 'write' => false, |
| 176 | 198 | 'handler' => array( self::class, 'get_pro_audit' ), |
| 177 | 199 | ), |
| 178 | 200 | 'purge_cache' => array( |
| 179 | - 'description' => 'Purge the site cache. "type" selects what to purge: all, page, assets, object, or rest. Defaults to all.', | |
| 201 | + 'description' => 'Purge the site cache and report what was actually cleared, what was skipped and why. "type" selects what to purge: all, page, assets, object, rest, cloudflare, or cdn. Defaults to all.', | |
| 180 | 202 | 'inputSchema' => self::object_schema( |
| 181 | 203 | array( |
| 182 | 204 | 'type' => array( |
| 183 | 205 | 'type' => 'string', |
| @@ -279,9 +301,9 @@ | ||
| 279 | 301 | 'write' => true, |
| 280 | 302 | 'handler' => array( self::class, 'start_preloader' ), |
| 281 | 303 | ), |
| 282 | 304 | 'run_score' => array( |
| 283 | - 'description' => 'Run an external performance audit (PageSpeed Insights, or GTmetrix when configured) against this site and return the score plus Core Web Vitals. Available on every install. Spends the site\'s own configured API quota. Use get_score_history to read past runs without starting a new one.', | |
| 305 | + 'description' => 'Run an external performance audit (PageSpeed Insights, or GTmetrix when configured) against this site. Running it counts as the opt-in for external scores. With the site\'s own API key the score plus Core Web Vitals come back in the response; a keyless PageSpeed audit on a Hub-connected site is queued instead — the response says so, and the result lands in the history a minute or two later (poll with run_command "score status", or read get_score_history). Spends the site\'s own configured API quota when a key is set.', | |
| 284 | 306 | 'inputSchema' => self::object_schema( |
| 285 | 307 | array( |
| 286 | 308 | 'target' => array( |
| 287 | 309 | 'type' => 'string', |
| @@ -317,9 +339,9 @@ | ||
| 317 | 339 | 'write' => true, |
| 318 | 340 | 'handler' => array( self::class, 'run_score' ), |
| 319 | 341 | ), |
| 320 | 342 | 'run_pagespeed' => array( |
| 321 | - 'description' => 'Run an external performance audit (PageSpeed Insights, or GTmetrix when configured) and return the score + Core Web Vitals. Defaults to the site home page, mobile strategy. Requires external scores to be enabled in settings — the plugin makes no outbound calls otherwise.', | |
| 343 | + 'description' => 'Run an external performance audit (PageSpeed Insights, or GTmetrix when configured) and return the score + Core Web Vitals. Defaults to the site home page, mobile strategy. Running it counts as the opt-in for external scores; a keyless PageSpeed audit routes through xSpeed Hub when the site is connected.', | |
| 322 | 344 | 'inputSchema' => self::object_schema( |
| 323 | 345 | array( |
| 324 | 346 | 'url' => array( |
| 325 | 347 | 'type' => 'string', |
| @@ -351,10 +373,18 @@ | ||
| 351 | 373 | 'write' => true, |
| 352 | 374 | 'handler' => array( self::class, 'run_pagespeed' ), |
| 353 | 375 | ), |
| 354 | 376 | 'generate_critical_css' => array( |
| 355 | - 'description' => 'Generate above-the-fold Critical CSS for the site (Pro). Calls the external generator and stores the result.', | |
| 356 | - 'inputSchema' => self::object_schema( array(), array() ), | |
| 377 | + 'description' => 'Generate above-the-fold Critical CSS for one page (Pro). Calls the external generator and stores the result for that page\'s template. Defaults to the site home page; pass url to build it for another page or template.', | |
| 378 | + 'inputSchema' => self::object_schema( | |
| 379 | + array( | |
| 380 | + 'url' => array( | |
| 381 | + 'type' => 'string', | |
| 382 | + 'description' => 'Page on this site to generate Critical CSS for, as a full URL or a path such as /pricing/. Defaults to the site home page.', | |
| 383 | + ), | |
| 384 | + ), | |
| 385 | + array() | |
| 386 | + ), | |
| 357 | 387 | 'write' => true, |
| 358 | 388 | 'handler' => array( self::class, 'generate_critical_css' ), |
| 359 | 389 | ), |
| 360 | 390 | 'get_health' => array( |
| @@ -474,12 +504,20 @@ | ||
| 474 | 504 | 'toggle_object_cache' => array( |
| 475 | 505 | 'description' => 'Enable or disable the object cache drop-in. Verify the backend with test_object_cache first — enabling against an unreachable server slows every request.', |
| 476 | 506 | 'inputSchema' => self::object_schema( |
| 477 | 507 | array( |
| 478 | - 'enabled' => array( | |
| 508 | + 'enabled' => array( | |
| 479 | 509 | 'type' => 'boolean', |
| 480 | 510 | 'description' => 'true installs the drop-in, false removes it.', |
| 481 | 511 | ), |
| 512 | + 'takeover' => array( | |
| 513 | + 'type' => 'boolean', | |
| 514 | + 'description' => 'With enabled=true: switch from the plugin that owns object-cache.php. Without it, enabling refuses while another plugin owns the file.', | |
| 515 | + ), | |
| 516 | + 'restore' => array( | |
| 517 | + 'type' => 'boolean', | |
| 518 | + 'description' => 'With enabled=false: put back the plugin xSpeed switched from.', | |
| 519 | + ), | |
| 482 | 520 | ), |
| 483 | 521 | array( 'enabled' ) |
| 484 | 522 | ), |
| 485 | 523 | 'write' => true, |
| @@ -544,9 +582,9 @@ | ||
| 544 | 582 | 'write' => false, |
| 545 | 583 | 'handler' => array( self::class, 'list_commands' ), |
| 546 | 584 | ), |
| 547 | 585 | 'run_command' => array( |
| 548 | - 'description' => 'Run any xSpeed command — the full CLI surface (~50 commands across every module: cache, cloudflare, database, critical/unused CSS, pagespeed, images, migration, preloader, object cache, analytics, RUM, smart-* and more). Call list_commands first to discover names + options. Examples: run_command("cloudflare purge"), run_command("psi", {}, {"url":"https://site.com","strategy":"mobile"}). Permanently destructive commands ("database clean") additionally require a confirm_token from scan_database and are refused without one — this gateway is not a way around that confirmation.', | |
| 586 | + 'description' => 'Run any xSpeed command — the full CLI surface (~50 commands across every module: cache, cloudflare, database, critical/unused CSS, pagespeed, images, migration, preloader, object cache, analytics, RUM, smart-* and more). Call list_commands first to discover names + options. Examples: run_command("cloudflare purge"), run_command("psi", {}, {"url":"https://site.com","strategy":"mobile"}). Permanently destructive commands additionally require a confirm_token and are refused without one — this gateway is not a way around that confirmation. "database clean" takes its token from scan_database, which previews exactly what would be deleted; every other destructive command is refused once and the refusal carries a token, so repeating the same call with it confirms the action.', | |
| 549 | 587 | 'inputSchema' => self::object_schema( |
| 550 | 588 | array( |
| 551 | 589 | 'command' => array( |
| 552 | 590 | 'type' => 'string', |
| @@ -562,9 +600,9 @@ | ||
| 562 | 600 | 'description' => 'Named options / flags, e.g. { "url": "https://site.com", "strategy": "mobile", "force": true }.', |
| 563 | 601 | ), |
| 564 | 602 | 'confirm_token' => array( |
| 565 | 603 | 'type' => 'string', |
| 566 | - 'description' => 'Required ONLY for permanently destructive commands such as "database clean". Obtain it from scan_database, which previews exactly what would be deleted. Without it those commands are refused.', | |
| 604 | + 'description' => 'Required ONLY for permanently destructive commands. For "database clean" obtain it from scan_database, which previews exactly what would be deleted; for any other destructive command, make the call once without this argument and the refusal returns the token to repeat it with. Without it those commands are refused.', | |
| 567 | 605 | ), |
| 568 | 606 | ), |
| 569 | 607 | array( 'command' ) |
| 570 | 608 | ), |
| @@ -619,8 +657,9 @@ | ||
| 619 | 657 | 'update_settings' => 'xspeed settings', |
| 620 | 658 | 'run_pagespeed' => 'xspeed psi', |
| 621 | 659 | 'get_health' => 'xspeed health', |
| 622 | 660 | 'get_score_history' => 'xspeed score', |
| 661 | + 'purge_cache' => 'xspeed purge', | |
| 623 | 662 | ); |
| 624 | 663 | |
| 625 | 664 | $commands = Cli_Bridge::commands(); |
| 626 | 665 | foreach ( $conditional as $tool => $command ) { |
| @@ -652,8 +691,49 @@ | ||
| 652 | 691 | $catalog[ $name ] = $spec; |
| 653 | 692 | } |
| 654 | 693 | } |
| 655 | 694 | |
| 695 | + /** | |
| 696 | + * Add private tools owned by an installed extension. | |
| 697 | + * | |
| 698 | + * Extensions receive an EMPTY map and may only add new names. Core tool | |
| 699 | + * definitions cannot be replaced through this seam. A `hidden` tool is | |
| 700 | + * callable through the authenticated per-site proxy but omitted from | |
| 701 | + * tools/list. This is for broker-run workflows whose public tool must not | |
| 702 | + * be advertised until the broker-side worker exists. | |
| 703 | + * | |
| 704 | + * `hidden` is not a permission: the proxy takes the same pairing token | |
| 705 | + * the public channel does. It withholds a tool from discovery and from | |
| 706 | + * an OAuth grant, nothing more. See the gate in invoke(). | |
| 707 | + * | |
| 708 | + * `hidden` must be a real bool when present. It decides whether a tool | |
| 709 | + * is reachable from the public channel at all, so a truthy string is | |
| 710 | + * the kind of near-miss that should be rejected rather than guessed at. | |
| 711 | + * Specs that fail any check here are skipped, not repaired. | |
| 712 | + * | |
| 713 | + * @param array<string,array<string,mixed>> $tools Extension tool specs. | |
| 714 | + */ | |
| 715 | + $extensions = apply_filters( 'xspeed_mcp_extension_tools', array() ); | |
| 716 | + if ( is_array( $extensions ) ) { | |
| 717 | + foreach ( $extensions as $name => $spec ) { | |
| 718 | + if ( | |
| 719 | + ! is_string( $name ) | |
| 720 | + || 1 !== preg_match( '/^[a-z][a-z0-9_]{0,63}$/', $name ) | |
| 721 | + || isset( $catalog[ $name ] ) | |
| 722 | + || ! is_array( $spec ) | |
| 723 | + || ! isset( $spec['description'], $spec['inputSchema'], $spec['handler'], $spec['write'] ) | |
| 724 | + || ! is_string( $spec['description'] ) | |
| 725 | + || ! is_array( $spec['inputSchema'] ) | |
| 726 | + || ! is_callable( $spec['handler'] ) | |
| 727 | + || ! is_bool( $spec['write'] ) | |
| 728 | + || ( isset( $spec['hidden'] ) && ! is_bool( $spec['hidden'] ) ) | |
| 729 | + ) { | |
| 730 | + continue; | |
| 731 | + } | |
| 732 | + $catalog[ $name ] = $spec; | |
| 733 | + } | |
| 734 | + } | |
| 735 | + | |
| 656 | 736 | return $catalog; |
| 657 | 737 | } |
| 658 | 738 | |
| 659 | 739 | /** |
| @@ -886,8 +966,11 @@ | ||
| 886 | 966 | */ |
| 887 | 967 | public static function list(): array { |
| 888 | 968 | $out = array(); |
| 889 | 969 | foreach ( self::catalog() as $name => $spec ) { |
| 970 | + if ( ! empty( $spec['hidden'] ) ) { | |
| 971 | + continue; | |
| 972 | + } | |
| 890 | 973 | $out[] = array( |
| 891 | 974 | 'name' => $name, |
| 892 | 975 | 'description' => $spec['description'], |
| 893 | 976 | 'inputSchema' => $spec['inputSchema'], |
| @@ -926,8 +1009,39 @@ | ||
| 926 | 1009 | |
| 927 | 1010 | return $error; |
| 928 | 1011 | } |
| 929 | 1012 | |
| 1013 | + // Hidden tools are private broker stages, not undiscoverable public MCP | |
| 1014 | + // tools. Omitting one from tools/list is only presentation, so this | |
| 1015 | + // closes the JSON-RPC channel to them as well, returning the same 404 a | |
| 1016 | + // nonexistent name returns. Both entry paths set the channel before | |
| 1017 | + // every invoke, so a previous broker call cannot widen a later one. | |
| 1018 | + // | |
| 1019 | + // What this is NOT: a permission boundary. The broker REST route | |
| 1020 | + // authenticates with the SAME pairing token as the JSON-RPC route | |
| 1021 | + // (Mcp_Auth::permission and Mcp_Server::authorize both compare against | |
| 1022 | + // Mcp_Pairing::site_token()), so anyone holding that token — every | |
| 1023 | + // client the dashboard's connection recipes are written for — can call | |
| 1024 | + // a hidden tool by name on the broker route, and tell it from a | |
| 1025 | + // nonexistent one by the status. `hidden` keeps a tool off the | |
| 1026 | + // advertised surface and out of an OAuth grant's reach; it does not | |
| 1027 | + // make it safe for a pairing-token holder to run. Anything gated only | |
| 1028 | + // by `hidden` must be something that holder may already do. | |
| 1029 | + if ( ! empty( $catalog[ $name ]['hidden'] ) && 'broker' !== self::$channel ) { | |
| 1030 | + $error = new \WP_Error( | |
| 1031 | + 'xspeed_mcp_unknown_tool', | |
| 1032 | + sprintf( | |
| 1033 | + /* translators: %s: tool name. */ | |
| 1034 | + __( 'Unknown tool: %s', 'xspeed' ), | |
| 1035 | + $name | |
| 1036 | + ), | |
| 1037 | + array( 'status' => 404 ) | |
| 1038 | + ); | |
| 1039 | + Mcp_Activity_Log::record( $name, $args, false, $error->get_error_message(), 'write', self::$channel ); | |
| 1040 | + | |
| 1041 | + return $error; | |
| 1042 | + } | |
| 1043 | + | |
| 930 | 1044 | // Scope enforcement: a read-only connection cannot invoke a tool that |
| 931 | 1045 | // mutates state. run_command is a gateway to the full CLI surface, so |
| 932 | 1046 | // it's treated as write regardless of the wrapped command. The active |
| 933 | 1047 | // credential's scope (pairing token OR OAuth access token) is carried |
| @@ -962,16 +1076,32 @@ | ||
| 962 | 1076 | * covers each door at once — including any future tool that wraps the |
| 963 | 1077 | * same command. (#184) |
| 964 | 1078 | */ |
| 965 | 1079 | $destructive = self::destructive_action( $name, $args ); |
| 966 | - if ( '' !== $destructive ) { | |
| 967 | - $confirmed = self::verify_clean_token( $args ); | |
| 1080 | + if ( ! empty( $destructive ) ) { | |
| 1081 | + $confirmed = self::verify_confirm_token( $args, $name, $destructive ); | |
| 968 | 1082 | if ( is_wp_error( $confirmed ) ) { |
| 969 | - Mcp_Activity_Log::record( $name, $args, false, $confirmed->get_error_message(), 'write', self::$channel ); | |
| 1083 | + // The refusal message can carry a freshly minted token (see | |
| 1084 | + // confirm_required()); the log gets the redacted twin so a | |
| 1085 | + // live token is never persisted in wp_options twice over. | |
| 1086 | + Mcp_Activity_Log::record( $name, $args, false, self::loggable_error( $confirmed ), 'write', self::$channel ); | |
| 970 | 1087 | return $confirmed; |
| 971 | 1088 | } |
| 972 | 1089 | } |
| 973 | 1090 | |
| 1091 | + /* | |
| 1092 | + * The token authorised the call; it is not an argument any handler | |
| 1093 | + * takes. A generated command tool forwards every property it does not | |
| 1094 | + * recognise as a positional straight to Cli_Bridge::run() as a CLI | |
| 1095 | + * option, so leaving it in place would turn a correctly confirmed | |
| 1096 | + * `xspeed_cfe` call into `--confirm_token=…` and an unknown-option | |
| 1097 | + * failure: the gate satisfied and the action still unreachable, which | |
| 1098 | + * is the same broken feature by a later route. Dropped for every tool, | |
| 1099 | + * not just the gated ones, and dropped before the audit record so a | |
| 1100 | + * live token is never written into the activity log. | |
| 1101 | + */ | |
| 1102 | + unset( $args['confirm_token'] ); | |
| 1103 | + | |
| 974 | 1104 | self::$dispatching = true; |
| 975 | 1105 | try { |
| 976 | 1106 | $result = call_user_func( $catalog[ $name ]['handler'], $args ); |
| 977 | 1107 | |
| @@ -1048,8 +1178,13 @@ | ||
| 1048 | 1178 | |
| 1049 | 1179 | /** |
| 1050 | 1180 | * Cache status, stats, and detected server. |
| 1051 | 1181 | * |
| 1182 | + * `site_icon` is the Site Icon set under Appearance, or '' when none is | |
| 1183 | + * set. The Hub shows it beside the site's name. Asking the site for | |
| 1184 | + * /favicon.ico instead failed on nginx hosts, which answer .ico | |
| 1185 | + * requests as static files and never reach WordPress. | |
| 1186 | + * | |
| 1052 | 1187 | * @param array $args Unused. |
| 1053 | 1188 | * @return array |
| 1054 | 1189 | */ |
| 1055 | 1190 | public static function get_cache_status( array $args ) { |
| @@ -1058,8 +1193,9 @@ | ||
| 1058 | 1193 | return array( |
| 1059 | 1194 | 'cache_enabled' => (bool) ( $opts['cache_enabled'] ?? false ), |
| 1060 | 1195 | 'stats' => Cache::get_stats(), |
| 1061 | 1196 | 'server' => Server::type(), |
| 1197 | + 'site_icon' => (string) get_site_icon_url( 64 ), | |
| 1062 | 1198 | ); |
| 1063 | 1199 | } |
| 1064 | 1200 | |
| 1065 | 1201 | /** |
| @@ -1132,12 +1268,93 @@ | ||
| 1132 | 1268 | 'wp_version' => get_bloginfo( 'version' ), |
| 1133 | 1269 | 'php_version' => PHP_VERSION, |
| 1134 | 1270 | 'server' => Server::type(), |
| 1135 | 1271 | 'multisite' => is_multisite(), |
| 1272 | + 'addons' => self::addon_licences(), | |
| 1136 | 1273 | ); |
| 1137 | 1274 | } |
| 1138 | 1275 | |
| 1276 | + /** Most add-on entries `get_site_info` reports. */ | |
| 1277 | + private const MAX_ADDONS = 20; | |
| 1278 | + | |
| 1279 | + /** Longest licence status kept. The library's statuses are short slugs. */ | |
| 1280 | + private const MAX_ADDON_STATUS_LENGTH = 40; | |
| 1281 | + | |
| 1139 | 1282 | /** |
| 1283 | + * The licence state of each separately licensed add-on, keyed by slug. | |
| 1284 | + * | |
| 1285 | + * Free cannot know which add-ons exist, so it asks. An add-on answers from | |
| 1286 | + * the licence state it has stored. Callers poll this tool, and a | |
| 1287 | + * licence-server round trip here would make every call wait on it. | |
| 1288 | + * | |
| 1289 | + * Always an object, `{}` when nothing answers. An empty PHP array encodes | |
| 1290 | + * as `[]`, and a consumer reading `addons.some_slug` should not have to | |
| 1291 | + * handle a list as well. | |
| 1292 | + * | |
| 1293 | + * @return object Slug => `{licensed: bool, status: ?string}`. | |
| 1294 | + */ | |
| 1295 | + private static function addon_licences(): object { | |
| 1296 | + /** | |
| 1297 | + * Filter: xspeed_site_info_addons | |
| 1298 | + * | |
| 1299 | + * Licence state for add-ons sold separately, reported by the | |
| 1300 | + * `get_site_info` MCP tool. Seeded empty; add your own entry and | |
| 1301 | + * return the array: | |
| 1302 | + * | |
| 1303 | + * $addons['my_addon'] = array( 'licensed' => true, 'status' => 'valid' ); | |
| 1304 | + * | |
| 1305 | + * `licensed` is whether the add-on may run. `status` is the licence | |
| 1306 | + * status as stored, or null when no key is stored. Read stored state | |
| 1307 | + * only, never the licence server. Keys go through sanitize_key(), | |
| 1308 | + * statuses are trimmed to short slugs, and any other field is dropped. | |
| 1309 | + * | |
| 1310 | + * @param array $addons Entries gathered so far, keyed by slug. | |
| 1311 | + */ | |
| 1312 | + try { | |
| 1313 | + $raw = apply_filters( 'xspeed_site_info_addons', array() ); | |
| 1314 | + } catch ( \Throwable $e ) { | |
| 1315 | + // A broken add-on costs the add-on entries, not the whole tool. | |
| 1316 | + // The other facts are still true and the caller still needs them. | |
| 1317 | + // Pop our hook off the stack the throw left it on, for the reason | |
| 1318 | + // Pro_Audit::contributed() gives. | |
| 1319 | + if ( isset( $GLOBALS['wp_current_filter'] ) | |
| 1320 | + && is_array( $GLOBALS['wp_current_filter'] ) | |
| 1321 | + && end( $GLOBALS['wp_current_filter'] ) === 'xspeed_site_info_addons' ) { | |
| 1322 | + array_pop( $GLOBALS['wp_current_filter'] ); | |
| 1323 | + } | |
| 1324 | + return (object) array(); | |
| 1325 | + } | |
| 1326 | + | |
| 1327 | + $out = array(); | |
| 1328 | + foreach ( is_array( $raw ) ? $raw : array() as $slug => $entry ) { | |
| 1329 | + if ( count( $out ) >= self::MAX_ADDONS ) { | |
| 1330 | + break; | |
| 1331 | + } | |
| 1332 | + if ( ! is_string( $slug ) || ! is_array( $entry ) ) { | |
| 1333 | + continue; | |
| 1334 | + } | |
| 1335 | + $slug = sanitize_key( $slug ); | |
| 1336 | + if ( '' === $slug || isset( $out[ $slug ] ) ) { | |
| 1337 | + continue; | |
| 1338 | + } | |
| 1339 | + | |
| 1340 | + $status = $entry['status'] ?? null; | |
| 1341 | + $status = is_string( $status ) | |
| 1342 | + ? substr( sanitize_key( $status ), 0, self::MAX_ADDON_STATUS_LENGTH ) | |
| 1343 | + : ''; | |
| 1344 | + | |
| 1345 | + $out[ $slug ] = array( | |
| 1346 | + 'licensed' => (bool) ( $entry['licensed'] ?? false ), | |
| 1347 | + // An empty status says nothing a null does not, and two ways | |
| 1348 | + // of saying "none" is one more case for every consumer. | |
| 1349 | + 'status' => '' === $status ? null : $status, | |
| 1350 | + ); | |
| 1351 | + } | |
| 1352 | + | |
| 1353 | + return (object) $out; | |
| 1354 | + } | |
| 1355 | + | |
| 1356 | + /** | |
| 1140 | 1357 | * All registered module descriptors. |
| 1141 | 1358 | * |
| 1142 | 1359 | * @param array $args Unused. |
| 1143 | 1360 | * @return array |
| @@ -1157,14 +1374,29 @@ | ||
| 1157 | 1374 | * @param array<string,mixed> $args Tool arguments. |
| 1158 | 1375 | * @return array<string,mixed>|\WP_Error |
| 1159 | 1376 | */ |
| 1160 | 1377 | public static function optimize_site( array $args = array() ) { |
| 1161 | - return \XSpeed\Optimize_Runner::run( | |
| 1162 | - array( | |
| 1163 | - 'aggressiveness' => (string) ( $args['aggressiveness'] ?? 'standard' ), | |
| 1164 | - 'dry_run' => (bool) ( $args['dry_run'] ?? false ), | |
| 1165 | - ) | |
| 1378 | + $run = array( | |
| 1379 | + 'aggressiveness' => (string) ( $args['aggressiveness'] ?? 'standard' ), | |
| 1380 | + 'dry_run' => (bool) ( $args['dry_run'] ?? false ), | |
| 1381 | + 'measure_score' => (string) ( $args['measure_score'] ?? 'auto' ), | |
| 1166 | 1382 | ); |
| 1383 | + | |
| 1384 | + // Tuning arguments are forwarded ONLY when present, and are not | |
| 1385 | + // understood by Free — a listener on `xspeed_optimize_report` reads | |
| 1386 | + // them from that filter's `$context`. Passing them through rather | |
| 1387 | + // than naming them in the array above is deliberate: this handler | |
| 1388 | + // builds an explicit whitelist, so an argument it does not list is | |
| 1389 | + // silently dropped. A caller asking to reach a score would have got a | |
| 1390 | + // single pass and a success response — wrong behaviour with no error, | |
| 1391 | + // which is the expensive kind to diagnose. | |
| 1392 | + foreach ( array( 'target_score', 'max_rounds' ) as $key ) { | |
| 1393 | + if ( isset( $args[ $key ] ) && is_numeric( $args[ $key ] ) ) { | |
| 1394 | + $run[ $key ] = (int) $args[ $key ]; | |
| 1395 | + } | |
| 1396 | + } | |
| 1397 | + | |
| 1398 | + return \XSpeed\Optimize_Runner::run( $run ); | |
| 1167 | 1399 | } |
| 1168 | 1400 | |
| 1169 | 1401 | /** |
| 1170 | 1402 | * Before/after cache benchmark timings. |
| @@ -1212,12 +1444,43 @@ | ||
| 1212 | 1444 | } |
| 1213 | 1445 | // Named source, not the default "manual": the purge log's whole job |
| 1214 | 1446 | // is to let an admin see that the cache cleared because an assistant |
| 1215 | 1447 | // asked, not because someone clicked. |
| 1216 | - $count = Cache::purge_type( $type, __( 'AI assistant', 'xspeed' ) ); | |
| 1448 | + $cause = __( 'AI assistant', 'xspeed' ); | |
| 1449 | + | |
| 1450 | + /* | |
| 1451 | + * `page`, `assets` and `rest` are fine-grained slices of the local | |
| 1452 | + * sweep with no target of their own, and they predate this tool's | |
| 1453 | + * per-store report — an assistant asking for `page` means the HTML, | |
| 1454 | + * not the HTML plus the minified bundles plus every purge listener. | |
| 1455 | + * They stay on purge_type() so their meaning does not change under | |
| 1456 | + * callers already relying on it. | |
| 1457 | + * | |
| 1458 | + * Everything else routes through the runner — the same core function | |
| 1459 | + * the CLI and the REST callback use — so an assistant told "cache | |
| 1460 | + * cleared" is reading the same per-store verdict a human would get, | |
| 1461 | + * including a Cloudflare zone that refused the purge. | |
| 1462 | + */ | |
| 1463 | + if ( in_array( $type, array( 'page', 'assets', 'rest' ), true ) ) { | |
| 1464 | + return array( | |
| 1465 | + 'purged' => $type, | |
| 1466 | + 'count' => Cache::purge_type( $type, $cause ), | |
| 1467 | + 'ok' => true, | |
| 1468 | + 'stats' => Cache::get_stats(), | |
| 1469 | + ); | |
| 1470 | + } | |
| 1471 | + | |
| 1472 | + $report = \XSpeed\Purge_Runner::run( array( $type ), $cause ); | |
| 1473 | + $count = 0; | |
| 1474 | + foreach ( $report['types'] as $row ) { | |
| 1475 | + $count += (int) $row['entries']; | |
| 1476 | + } | |
| 1477 | + | |
| 1217 | 1478 | return array( |
| 1218 | 1479 | 'purged' => $type, |
| 1219 | 1480 | 'count' => $count, |
| 1481 | + 'ok' => $report['ok'], | |
| 1482 | + 'report' => $report['types'], | |
| 1220 | 1483 | 'stats' => Cache::get_stats(), |
| 1221 | 1484 | ); |
| 1222 | 1485 | } |
| 1223 | 1486 | |
| @@ -1497,13 +1760,26 @@ | ||
| 1497 | 1760 | * @return true|\WP_Error True when every key would be applied. |
| 1498 | 1761 | */ |
| 1499 | 1762 | private static function inspect_or_error( string $module, array $values ) { |
| 1500 | 1763 | $report = Settings_Manager::inspect_input( $module, $values ); |
| 1501 | - if ( empty( $report['unknown'] ) && empty( $report['invalid'] ) ) { | |
| 1764 | + if ( empty( $report['unknown'] ) && empty( $report['invalid'] ) && empty( $report['locked'] ) ) { | |
| 1502 | 1765 | return true; |
| 1503 | 1766 | } |
| 1504 | 1767 | |
| 1505 | 1768 | $parts = array(); |
| 1769 | + // A field pinned by a wp-config.php constant cannot be written. Say so | |
| 1770 | + // rather than returning a success the agent relays as "changed" over a | |
| 1771 | + // write that update() would silently drop. (#398) | |
| 1772 | + foreach ( $report['locked'] as $key ) { | |
| 1773 | + $spec = ( Module_Registry::get( $module ) ? Module_Registry::get( $module )->settings_schema()[ $key ] ?? array() : array() ); | |
| 1774 | + $constant = Settings_Manager::effective_constant( $module, $key, is_array( $spec ) ? $spec : array() ); | |
| 1775 | + $parts[] = sprintf( | |
| 1776 | + /* translators: 1: setting key, 2: wp-config.php constant name. */ | |
| 1777 | + __( '"%1$s" is defined in wp-config.php as %2$s and cannot be changed here', 'xspeed' ), | |
| 1778 | + $key, | |
| 1779 | + (string) $constant | |
| 1780 | + ); | |
| 1781 | + } | |
| 1506 | 1782 | foreach ( $report['unknown'] as $key ) { |
| 1507 | 1783 | $detail = sprintf( |
| 1508 | 1784 | /* translators: 1: setting key, 2: module slug. */ |
| 1509 | 1785 | __( '"%1$s" is not a setting of module "%2$s"', 'xspeed' ), |
| @@ -1544,8 +1820,9 @@ | ||
| 1544 | 1820 | array( |
| 1545 | 1821 | 'status' => 400, |
| 1546 | 1822 | 'refused_unknown' => $report['unknown'], |
| 1547 | 1823 | 'refused_invalid' => $report['invalid'], |
| 1824 | + 'refused_locked' => $report['locked'], | |
| 1548 | 1825 | 'would_apply' => $report['applied'], |
| 1549 | 1826 | ) |
| 1550 | 1827 | ); |
| 1551 | 1828 | } |
| @@ -1621,9 +1898,9 @@ | ||
| 1621 | 1898 | * longer describes reality and clean_database refuses. That closes the |
| 1622 | 1899 | * window where a scan is shown to a human, something changes, and the |
| 1623 | 1900 | * delete removes more than was agreed to. (#184) |
| 1624 | 1901 | */ |
| 1625 | - $result['confirm_token'] = self::mint_clean_token(); | |
| 1902 | + $result['confirm_token'] = self::mint_confirm_token( self::clean_fingerprint() ); | |
| 1626 | 1903 | $result['confirm_note'] = __( 'This preview deletes nothing. To delete what is listed, call clean_database with this confirm_token. It expires in 5 minutes and stops working if the database changes.', 'xspeed' ); |
| 1627 | 1904 | |
| 1628 | 1905 | return $result; |
| 1629 | 1906 | } |
| @@ -1668,76 +1945,215 @@ | ||
| 1668 | 1945 | ); |
| 1669 | 1946 | } |
| 1670 | 1947 | |
| 1671 | 1948 | /** |
| 1672 | - * Name the destructive action a call would run, or '' if it is harmless. | |
| 1949 | + * Name the destructive action a call would run, or an empty array if it | |
| 1950 | + * is harmless. | |
| 1673 | 1951 | * |
| 1674 | 1952 | * @param string $name Tool name. |
| 1675 | 1953 | * @param array $args Decoded tool arguments. |
| 1676 | - * @return string Canonical "<command> <action>", or '' when not destructive. | |
| 1954 | + * @return array{name?:string,action?:string} The classified pair, or array() when not destructive. | |
| 1677 | 1955 | */ |
| 1678 | - private static function destructive_action( string $name, array $args ): string { | |
| 1956 | + private static function destructive_action( string $name, array $args ): array { | |
| 1679 | 1957 | // The gateway carries the real command in its arguments; a typed tool |
| 1680 | 1958 | // is identified by the command it is mapped to. |
| 1681 | 1959 | if ( 'run_command' === $name ) { |
| 1682 | 1960 | $command = isset( $args['command'] ) ? (string) $args['command'] : ''; |
| 1683 | 1961 | if ( '' === $command ) { |
| 1684 | - return ''; | |
| 1962 | + return array(); | |
| 1685 | 1963 | } |
| 1686 | 1964 | $positional = isset( $args['args'] ) && is_array( $args['args'] ) ? $args['args'] : array(); |
| 1687 | - $resolved = Cli_Bridge::classify( $command, $positional ); | |
| 1688 | - } elseif ( 'clean_database' === $name ) { | |
| 1689 | - $resolved = Cli_Bridge::classify( 'db', array( 'clean' ) ); | |
| 1690 | - } else { | |
| 1965 | + return self::destructive_hit( Cli_Bridge::classify( $command, $positional ) ); | |
| 1966 | + } | |
| 1967 | + | |
| 1968 | + if ( 'clean_database' === $name ) { | |
| 1969 | + return self::destructive_hit( Cli_Bridge::classify( 'db', array( 'clean' ) ) ); | |
| 1970 | + } | |
| 1971 | + | |
| 1972 | + /* | |
| 1973 | + * The third door: the tool generated for every registered CLI command. | |
| 1974 | + * | |
| 1975 | + * run_command and clean_database were the two names this guard knew, | |
| 1976 | + * so a command whose destructive action the filter names was still | |
| 1977 | + * reachable without a confirm_token by calling its own generated tool | |
| 1978 | + * — `xspeed_cfe` with action `remove` ran what | |
| 1979 | + * run_command("cfe", ["remove"]) would have refused. The generated | |
| 1980 | + * tool takes its action as the `action` property, so resolve the name | |
| 1981 | + * back to a command and classify that action exactly as the gateway | |
| 1982 | + * classifies its own. | |
| 1983 | + */ | |
| 1984 | + $action = isset( $args['action'] ) ? (string) $args['action'] : ''; | |
| 1985 | + if ( '' === $action ) { | |
| 1986 | + return array(); | |
| 1987 | + } | |
| 1988 | + | |
| 1989 | + $candidates = self::cli_commands_by_tool_name()[ $name ] ?? array(); | |
| 1990 | + if ( empty( $candidates ) ) { | |
| 1991 | + /* | |
| 1992 | + * The map has no entry for this tool name. Two ways to get here | |
| 1993 | + * and neither may answer "harmless": | |
| 1994 | + * | |
| 1995 | + * - the registry is unavailable (an unbooted module, a call | |
| 1996 | + * arriving before modules register, a bare unit-test context); | |
| 1997 | + * - the registry is POPULATED but does not contain the command | |
| 1998 | + * this tool name came from — Cli_Bridge::commands() memoises | |
| 1999 | + * in a function-local static with no reset, so a module that | |
| 2000 | + * registers its commands after the first call is invisible to | |
| 2001 | + * the map while its generated tool is still dispatchable. | |
| 2002 | + * | |
| 2003 | + * This condition used to also require `array() === commands()`, | |
| 2004 | + * which made the second case fall through with no candidates at | |
| 2005 | + * all and return "not destructive" — the gate opening precisely | |
| 2006 | + * where it was least able to see. Fall back on the naive inverse | |
| 2007 | + * of cli_tool_name() in both cases. It is the mapping that can be | |
| 2008 | + * wrong (underscores in a command name), but wrong here means | |
| 2009 | + * asking for a confirm_token that was not strictly needed, never | |
| 2010 | + * skipping one that was. | |
| 2011 | + */ | |
| 2012 | + $candidates = array( str_replace( '_', ' ', $name ) ); | |
| 2013 | + } | |
| 2014 | + | |
| 2015 | + foreach ( $candidates as $command ) { | |
| 2016 | + $hit = self::destructive_hit( Cli_Bridge::classify( $command, array( $action ) ) ); | |
| 2017 | + if ( ! empty( $hit ) ) { | |
| 2018 | + return $hit; | |
| 2019 | + } | |
| 2020 | + } | |
| 2021 | + | |
| 2022 | + return array(); | |
| 2023 | + } | |
| 2024 | + | |
| 2025 | + /** | |
| 2026 | + * Render a classified pair as the canonical "<command> <action>". | |
| 2027 | + * | |
| 2028 | + * @param array{name?:string,action?:string} $action Pair from destructive_action(). | |
| 2029 | + * @return string Canonical name, or '' for the empty (harmless) pair. | |
| 2030 | + */ | |
| 2031 | + private static function canonical_action( array $action ): string { | |
| 2032 | + if ( empty( $action['name'] ) ) { | |
| 1691 | 2033 | return ''; |
| 1692 | 2034 | } |
| 2035 | + return trim( $action['name'] . ' ' . ( $action['action'] ?? '' ) ); | |
| 2036 | + } | |
| 1693 | 2037 | |
| 2038 | + /** | |
| 2039 | + * Is a classified (command, action) pair one the filter calls destructive? | |
| 2040 | + * | |
| 2041 | + * @param array{name:string,action:string} $resolved Output of Cli_Bridge::classify(). | |
| 2042 | + * @return array{name?:string,action?:string} The pair, or array() when not destructive. | |
| 2043 | + */ | |
| 2044 | + private static function destructive_hit( array $resolved ): array { | |
| 1694 | 2045 | if ( '' === $resolved['name'] ) { |
| 1695 | - return ''; | |
| 2046 | + return array(); | |
| 1696 | 2047 | } |
| 1697 | 2048 | |
| 1698 | 2049 | $destructive = self::destructive_actions(); |
| 1699 | 2050 | if ( ! isset( $destructive[ $resolved['name'] ] ) ) { |
| 1700 | - return ''; | |
| 2051 | + return array(); | |
| 1701 | 2052 | } |
| 1702 | 2053 | if ( ! in_array( $resolved['action'], (array) $destructive[ $resolved['name'] ], true ) ) { |
| 1703 | - return ''; | |
| 2054 | + return array(); | |
| 1704 | 2055 | } |
| 1705 | 2056 | |
| 1706 | - return trim( $resolved['name'] . ' ' . $resolved['action'] ); | |
| 2057 | + return array( | |
| 2058 | + 'name' => $resolved['name'], | |
| 2059 | + 'action' => $resolved['action'], | |
| 2060 | + ); | |
| 1707 | 2061 | } |
| 1708 | 2062 | |
| 1709 | 2063 | /** |
| 1710 | - * Verify (and consume) the confirm_token minted by scan_database. | |
| 2064 | + * Which CLI command(s) a generated tool name could have come from. | |
| 1711 | 2065 | * |
| 1712 | - * @param array $args Decoded tool arguments. | |
| 2066 | + * cli_tool_name() replaces spaces with underscores, which is not | |
| 2067 | + * injective: a command named "xspeed foo bar" and one named | |
| 2068 | + * "xspeed foo_bar" produce the same tool name, and splitting the tool | |
| 2069 | + * name back on underscores cannot tell them apart. So the map is built | |
| 2070 | + * forwards — cli_tool_name() applied to every registered command, the | |
| 2071 | + * same function over the same input set that named the tools in the | |
| 2072 | + * first place. No Free command carries an underscore today; a Pro or | |
| 2073 | + * third-party module registering one must not quietly disarm this guard. | |
| 2074 | + * | |
| 2075 | + * A tool name claimed by two commands keeps both, and the caller treats | |
| 2076 | + * the call as destructive if any of them is — the fail-closed direction, | |
| 2077 | + * the same reasoning Cli_Bridge::classify() applies when the registry | |
| 2078 | + * cannot resolve an input at all. | |
| 2079 | + * | |
| 2080 | + * @return array<string,string[]> Tool name => the commands that produce it. | |
| 2081 | + */ | |
| 2082 | + private static function cli_commands_by_tool_name(): array { | |
| 2083 | + $map = array(); | |
| 2084 | + foreach ( array_keys( Cli_Bridge::commands() ) as $command ) { | |
| 2085 | + $tool_name = self::cli_tool_name( (string) $command ); | |
| 2086 | + if ( '' === $tool_name ) { | |
| 2087 | + continue; | |
| 2088 | + } | |
| 2089 | + $map[ $tool_name ][] = (string) $command; | |
| 2090 | + } | |
| 2091 | + return $map; | |
| 2092 | + } | |
| 2093 | + | |
| 2094 | + /** | |
| 2095 | + * Marker: this build gates destructive command actions on a confirm_token | |
| 2096 | + * whichever tool they are reached through, generated tools included. | |
| 2097 | + * | |
| 2098 | + * Add-ons that register a destructive CLI action need to know whether the | |
| 2099 | + * host plugin will demand the second factor for them. Where this method | |
| 2100 | + * is absent they have to refuse the action themselves when it arrives | |
| 2101 | + * from anywhere but the dashboard or real wp-cli; where it answers true | |
| 2102 | + * they can let the confirmation gate do the work. Keep it — an add-on | |
| 2103 | + * keys its refusal on the absence, so removing it is a behaviour change | |
| 2104 | + * in another plugin. | |
| 2105 | + */ | |
| 2106 | + public static function supports_command_confirmation(): bool { | |
| 2107 | + return true; | |
| 2108 | + } | |
| 2109 | + | |
| 2110 | + /** | |
| 2111 | + * The one destructive action whose token comes from a preview tool. | |
| 2112 | + * | |
| 2113 | + * `scan_database` is the only surface that shows what a delete would | |
| 2114 | + * remove, so its token is sealed to that preview and nothing else may | |
| 2115 | + * mint one — including `xspeed_db` with `action: clean`, which is just | |
| 2116 | + * another door onto the same rows. | |
| 2117 | + */ | |
| 2118 | + private const PREVIEW_CONFIRMED_ACTION = 'xspeed db clean'; | |
| 2119 | + | |
| 2120 | + /** | |
| 2121 | + * Verify (and consume) the confirm_token for a destructive action. | |
| 2122 | + * | |
| 2123 | + * @param array $args Decoded tool arguments. | |
| 2124 | + * @param string $tool Tool name the call arrived on. | |
| 2125 | + * @param array $action Classified pair from destructive_action(). | |
| 1713 | 2126 | * @return true|\WP_Error |
| 1714 | 2127 | */ |
| 1715 | - private static function verify_clean_token( array $args ) { | |
| 2128 | + private static function verify_confirm_token( array $args, string $tool, array $action ) { | |
| 1716 | 2129 | $token = isset( $args['confirm_token'] ) ? (string) $args['confirm_token'] : ''; |
| 1717 | 2130 | if ( '' === $token ) { |
| 1718 | - return new \WP_Error( | |
| 1719 | - 'xspeed_mcp_confirm_required', | |
| 1720 | - __( 'This permanently deletes content and cannot be undone. Call scan_database first to see exactly what would be removed, then pass the confirm_token it returns.', 'xspeed' ), | |
| 1721 | - array( 'status' => 400 ) | |
| 1722 | - ); | |
| 2131 | + return self::confirm_required( $tool, $action ); | |
| 1723 | 2132 | } |
| 1724 | 2133 | |
| 1725 | - // Single use: consumed whether or not the delete goes ahead, so one | |
| 1726 | - // approval can never authorise a second, different deletion. | |
| 1727 | - $sealed = self::consume_clean_token( $token ); | |
| 2134 | + // Computed only now: for the database clean the fingerprint is a full | |
| 2135 | + // bloat scan, and a call refused for having no token at all should not | |
| 2136 | + // pay for one. | |
| 2137 | + $expected = self::confirm_fingerprint( $action ); | |
| 2138 | + | |
| 2139 | + // Single use: consumed whether or not the action goes ahead, so one | |
| 2140 | + // approval can never authorise a second, different call. | |
| 2141 | + $sealed = self::consume_confirm_token( $token ); | |
| 1728 | 2142 | if ( '' === $sealed ) { |
| 1729 | 2143 | return new \WP_Error( |
| 1730 | 2144 | 'xspeed_mcp_confirm_invalid', |
| 1731 | - __( 'That confirm_token is unknown or has expired (they last 5 minutes). Run scan_database again and use the fresh token.', 'xspeed' ), | |
| 2145 | + __( 'That confirm_token is unknown or has expired (they last 5 minutes). Ask for a fresh one by making the same call without a confirm_token.', 'xspeed' ), | |
| 1732 | 2146 | array( 'status' => 400 ) |
| 1733 | 2147 | ); |
| 1734 | 2148 | } |
| 1735 | 2149 | |
| 1736 | - if ( ! hash_equals( $sealed, self::clean_fingerprint() ) ) { | |
| 2150 | + if ( ! hash_equals( $sealed, $expected ) ) { | |
| 1737 | 2151 | return new \WP_Error( |
| 1738 | 2152 | 'xspeed_mcp_confirm_stale', |
| 1739 | - __( 'The database changed since that scan, so the preview no longer describes what would be deleted. Run scan_database again and confirm against the new result.', 'xspeed' ), | |
| 2153 | + self::PREVIEW_CONFIRMED_ACTION === self::canonical_action( $action ) | |
| 2154 | + ? __( 'The database changed since that scan, so the preview no longer describes what would be deleted. Run scan_database again and confirm against the new result.', 'xspeed' ) | |
| 2155 | + : __( 'That confirm_token was issued for a different action, so it does not authorise this one. Repeat this exact call without a confirm_token to get one that does.', 'xspeed' ), | |
| 1740 | 2156 | array( 'status' => 409 ) |
| 1741 | 2157 | ); |
| 1742 | 2158 | } |
| 1743 | 2159 | |
| @@ -1743,18 +2159,160 @@ | ||
| 1743 | 2159 | |
| 1744 | 2160 | return true; |
| 1745 | 2161 | } |
| 1746 | 2162 | |
| 1747 | - /** Fingerprint of the scope, so a token cannot outlive what it described. */ | |
| 2163 | + /** | |
| 2164 | + * Refuse a destructive call that arrived without a confirm_token. | |
| 2165 | + * | |
| 2166 | + * Two shapes, because the two kinds of destructive action differ in where | |
| 2167 | + * a token can honestly come from. | |
| 2168 | + * | |
| 2169 | + * `xspeed db clean` has a preview — `scan_database` — and the token is | |
| 2170 | + * sealed to what that preview showed. Refuse and point at it; minting one | |
| 2171 | + * here would authorise a delete nobody had been shown. | |
| 2172 | + * | |
| 2173 | + * Every other destructive action has no preview, and until now borrowed | |
| 2174 | + * the database scope: the caller was told to "call scan_database first" | |
| 2175 | + * for a token sealed to a fingerprint of row counts, which | |
| 2176 | + * verify_confirm_token() would then reject as not matching. On an install | |
| 2177 | + * where `xspeed db` is not registered there was no minting path at all, | |
| 2178 | + * so the gate could not be passed by any sequence of calls. A guard that | |
| 2179 | + * cannot be satisfied is a broken feature, and the pressure it creates is | |
| 2180 | + * to delete the guard. (#184) | |
| 2181 | + * | |
| 2182 | + * So the refusal itself mints the token, sealed to this action. That is | |
| 2183 | + * not authentication — the connection is already authenticated and holds | |
| 2184 | + * write scope — it is a deliberate second round trip: the caller has to | |
| 2185 | + * read a message naming what it is about to destroy and decide to send | |
| 2186 | + * the call again. The token is single use, expires in five minutes, and | |
| 2187 | + * confirms nothing but the action it names. | |
| 2188 | + * | |
| 2189 | + * The token travels in the MESSAGE, not just the error data: | |
| 2190 | + * Mcp_Server::call_tool() renders a WP_Error as its message text alone | |
| 2191 | + * and drops the data, so a token that lived only in the data would never | |
| 2192 | + * reach the caller and the gate would stay unsatisfiable over MCP. | |
| 2193 | + * | |
| 2194 | + * @param string $tool Tool name the call arrived on. | |
| 2195 | + * @param array $action Classified pair from destructive_action(). | |
| 2196 | + * @return \WP_Error | |
| 2197 | + */ | |
| 2198 | + private static function confirm_required( string $tool, array $action ): \WP_Error { | |
| 2199 | + $canonical = self::canonical_action( $action ); | |
| 2200 | + | |
| 2201 | + if ( self::PREVIEW_CONFIRMED_ACTION === $canonical ) { | |
| 2202 | + return new \WP_Error( | |
| 2203 | + 'xspeed_mcp_confirm_required', | |
| 2204 | + __( 'This permanently deletes content and cannot be undone. Call scan_database first to see exactly what would be removed, then pass the confirm_token it returns.', 'xspeed' ), | |
| 2205 | + array( | |
| 2206 | + 'status' => 400, | |
| 2207 | + 'action' => $canonical, | |
| 2208 | + 'log_message' => sprintf( 'Refused %s: no confirm_token (scan_database mints it).', $canonical ), | |
| 2209 | + ) | |
| 2210 | + ); | |
| 2211 | + } | |
| 2212 | + | |
| 2213 | + $token = self::mint_confirm_token( self::confirm_fingerprint( $action ) ); | |
| 2214 | + | |
| 2215 | + $repeat = 'run_command' === $tool | |
| 2216 | + ? sprintf( | |
| 2217 | + /* translators: %s: the confirm_token to send back. */ | |
| 2218 | + __( 'Call run_command again with the same command plus confirm_token: %s', 'xspeed' ), | |
| 2219 | + $token | |
| 2220 | + ) | |
| 2221 | + : sprintf( | |
| 2222 | + /* translators: 1: tool name, 2: action value, 3: the confirm_token to send back. */ | |
| 2223 | + __( 'Call %1$s again with action: %2$s plus confirm_token: %3$s', 'xspeed' ), | |
| 2224 | + $tool, | |
| 2225 | + (string) ( $action['action'] ?? '' ), | |
| 2226 | + $token | |
| 2227 | + ); | |
| 2228 | + | |
| 2229 | + return new \WP_Error( | |
| 2230 | + 'xspeed_mcp_confirm_required', | |
| 2231 | + sprintf( | |
| 2232 | + /* translators: 1: canonical "<command> <action>", 2: how to repeat the call. */ | |
| 2233 | + __( '"%1$s" is destructive and cannot be undone, so it needs a second, deliberate call. %2$s — the token is single use and expires in 5 minutes.', 'xspeed' ), | |
| 2234 | + $canonical, | |
| 2235 | + $repeat | |
| 2236 | + ), | |
| 2237 | + array( | |
| 2238 | + 'status' => 400, | |
| 2239 | + 'action' => $canonical, | |
| 2240 | + 'confirm_token' => $token, | |
| 2241 | + 'expires_in' => self::CONFIRM_TOKEN_TTL, | |
| 2242 | + 'log_message' => sprintf( 'Refused %s: awaiting confirm_token.', $canonical ), | |
| 2243 | + ) | |
| 2244 | + ); | |
| 2245 | + } | |
| 2246 | + | |
| 2247 | + /** | |
| 2248 | + * The activity-log text for a refusal. | |
| 2249 | + * | |
| 2250 | + * A refusal that mints a token puts that token in its message, because | |
| 2251 | + * that is the only channel the caller can read it on. The activity log is | |
| 2252 | + * a different audience and a durable one, so it takes the redacted twin | |
| 2253 | + * the error carries alongside. | |
| 2254 | + * | |
| 2255 | + * @param \WP_Error $error Refusal to describe. | |
| 2256 | + * @return string | |
| 2257 | + */ | |
| 2258 | + private static function loggable_error( \WP_Error $error ): string { | |
| 2259 | + $data = $error->get_error_data(); | |
| 2260 | + if ( is_array( $data ) && ! empty( $data['log_message'] ) ) { | |
| 2261 | + return (string) $data['log_message']; | |
| 2262 | + } | |
| 2263 | + return $error->get_error_message(); | |
| 2264 | + } | |
| 2265 | + | |
| 2266 | + /** | |
| 2267 | + * The scope a confirm_token for this action is sealed to. | |
| 2268 | + * | |
| 2269 | + * `xspeed db clean` is sealed to the preview: the categories enabled and | |
| 2270 | + * the counts found, so the token dies the moment the database stops | |
| 2271 | + * matching what the operator was shown. | |
| 2272 | + * | |
| 2273 | + * Every other action is sealed to itself — the (command, action) pair — | |
| 2274 | + * because there is nothing else to bind to and binding to the database | |
| 2275 | + * would make the token both meaningless (it proves nothing about the | |
| 2276 | + * action) and fragile (any unrelated write invalidates it). A token for | |
| 2277 | + * `xspeed cfe pause` therefore does not confirm `xspeed cfe remove`. | |
| 2278 | + * | |
| 2279 | + * @param array $action Classified pair from destructive_action(). | |
| 2280 | + * @return string | |
| 2281 | + */ | |
| 2282 | + private static function confirm_fingerprint( array $action ): string { | |
| 2283 | + if ( self::PREVIEW_CONFIRMED_ACTION === self::canonical_action( $action ) ) { | |
| 2284 | + return self::clean_fingerprint(); | |
| 2285 | + } | |
| 2286 | + | |
| 2287 | + return hash( | |
| 2288 | + 'sha256', | |
| 2289 | + (string) wp_json_encode( | |
| 2290 | + array( | |
| 2291 | + (string) ( $action['name'] ?? '' ), | |
| 2292 | + (string) ( $action['action'] ?? '' ), | |
| 2293 | + ) | |
| 2294 | + ) | |
| 2295 | + ); | |
| 2296 | + } | |
| 2297 | + | |
| 2298 | + /** Fingerprint of the scan scope, so a token cannot outlive what it described. */ | |
| 1748 | 2299 | private static function clean_fingerprint(): string { |
| 1749 | 2300 | return hash( 'sha256', (string) wp_json_encode( self::clean_scope() ) ); |
| 1750 | 2301 | } |
| 1751 | 2302 | |
| 1752 | 2303 | /** Lifetime of a confirm_token, from mint to refusal. */ |
| 1753 | - private const CLEAN_TOKEN_TTL = 5 * MINUTE_IN_SECONDS; | |
| 2304 | + private const CONFIRM_TOKEN_TTL = 5 * MINUTE_IN_SECONDS; | |
| 1754 | 2305 | |
| 1755 | - /** Storage key for a minted token (the token itself is never stored). */ | |
| 1756 | - private static function clean_token_key( string $token ): string { | |
| 2306 | + /** | |
| 2307 | + * Storage key for a minted token (the token itself is never stored). | |
| 2308 | + * | |
| 2309 | + * The `xspeed_mcp_clean_` prefix predates tokens for actions other than | |
| 2310 | + * the database clean. It is the on-disk format, and purge_expired_confirm_ | |
| 2311 | + * tokens() sweeps by exactly this prefix, so renaming it would strand | |
| 2312 | + * every token minted by the running build. | |
| 2313 | + */ | |
| 2314 | + private static function confirm_token_key( string $token ): string { | |
| 1757 | 2315 | return 'xspeed_mcp_clean_' . hash( 'sha256', $token ); |
| 1758 | 2316 | } |
| 1759 | 2317 | |
| 1760 | 2318 | /* |
| @@ -1780,18 +2338,24 @@ | ||
| 1780 | 2338 | * object cache does. Expiry is carried in the stored value and checked on |
| 1781 | 2339 | * read, since options have no TTL of their own. |
| 1782 | 2340 | */ |
| 1783 | 2341 | |
| 1784 | - private static function mint_clean_token(): string { | |
| 2342 | + /** | |
| 2343 | + * Mint a single-use token sealed to a scope fingerprint. | |
| 2344 | + * | |
| 2345 | + * @param string $fingerprint Scope the token confirms — see confirm_fingerprint(). | |
| 2346 | + * @return string | |
| 2347 | + */ | |
| 2348 | + private static function mint_confirm_token( string $fingerprint ): string { | |
| 1785 | 2349 | $token = wp_generate_password( 32, false ); |
| 1786 | 2350 | |
| 1787 | 2351 | // autoload=no: this is read once, by one request, minutes from now. |
| 1788 | 2352 | add_option( |
| 1789 | - self::clean_token_key( $token ), | |
| 2353 | + self::confirm_token_key( $token ), | |
| 1790 | 2354 | wp_json_encode( |
| 1791 | 2355 | array( |
| 1792 | - 'fingerprint' => self::clean_fingerprint(), | |
| 1793 | - 'expires' => time() + self::CLEAN_TOKEN_TTL, | |
| 2356 | + 'fingerprint' => $fingerprint, | |
| 2357 | + 'expires' => time() + self::CONFIRM_TOKEN_TTL, | |
| 1794 | 2358 | ) |
| 1795 | 2359 | ), |
| 1796 | 2360 | '', |
| 1797 | 2361 | 'no' |
| @@ -1796,9 +2360,9 @@ | ||
| 1796 | 2360 | '', |
| 1797 | 2361 | 'no' |
| 1798 | 2362 | ); |
| 1799 | 2363 | |
| 1800 | - self::purge_expired_clean_tokens(); | |
| 2364 | + self::purge_expired_confirm_tokens(); | |
| 1801 | 2365 | |
| 1802 | 2366 | return $token; |
| 1803 | 2367 | } |
| 1804 | 2368 | |
| @@ -1807,17 +2371,61 @@ | ||
| 1807 | 2371 | * |
| 1808 | 2372 | * Consumes the record either way: a token is single use, so one approval |
| 1809 | 2373 | * can never authorise a second, different deletion. |
| 1810 | 2374 | */ |
| 1811 | - private static function consume_clean_token( string $token ): string { | |
| 1812 | - $key = self::clean_token_key( $token ); | |
| 2375 | + private static function consume_confirm_token( string $token ): string { | |
| 2376 | + global $wpdb; | |
| 2377 | + | |
| 2378 | + $key = self::confirm_token_key( $token ); | |
| 1813 | 2379 | $stored = get_option( $key ); |
| 1814 | 2380 | if ( ! is_string( $stored ) || '' === $stored ) { |
| 1815 | 2381 | return ''; |
| 1816 | 2382 | } |
| 1817 | 2383 | |
| 1818 | - delete_option( $key ); | |
| 2384 | + /* | |
| 2385 | + * The claim is the DELETE, and nothing before it. | |
| 2386 | + * | |
| 2387 | + * This used to read the option, decide it existed, and then call | |
| 2388 | + * delete_option() — check-then-act, with the whole verification | |
| 2389 | + * sitting inside the gap. Twenty calls carrying one token, arriving | |
| 2390 | + * together, all read the row before any of them removed it, and all | |
| 2391 | + * twenty passed. QA measured six getting through and the database | |
| 2392 | + * clean running six times. A persistent object cache widens it | |
| 2393 | + * further: get_option() keeps answering from cache after the row is | |
| 2394 | + * gone, so the read cannot be the gate under any timing. | |
| 2395 | + * | |
| 2396 | + * A single DELETE is the only step here MySQL makes atomic. InnoDB | |
| 2397 | + * takes a row lock, exactly one statement reports a row affected, and | |
| 2398 | + * every other concurrent caller sees zero however they got here. So | |
| 2399 | + * the row's disappearance IS the permission, and the read above is | |
| 2400 | + * demoted to what it should always have been — a way to recover the | |
| 2401 | + * fingerprint, not evidence of anything. | |
| 2402 | + * | |
| 2403 | + * Raw rather than delete_option() because delete_option() reports | |
| 2404 | + * whether it thinks a row existed, not whether THIS caller removed | |
| 2405 | + * it, and it decides that from a cache. rows_affected comes from the | |
| 2406 | + * server. | |
| 2407 | + */ | |
| 2408 | + // No database, no atomic claim, no confirmation. Refusing here costs a | |
| 2409 | + // caller one retry; the alternative is granting permission on the | |
| 2410 | + // strength of a read that was never proof of anything. | |
| 2411 | + if ( ! $wpdb instanceof \wpdb && ! is_object( $wpdb ) ) { | |
| 2412 | + return ''; | |
| 2413 | + } | |
| 1819 | 2414 | |
| 2415 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the atomic claim needs rows_affected from the server; the cache entry is dropped right after. | |
| 2416 | + $claimed = $wpdb->query( | |
| 2417 | + $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s", $key ) | |
| 2418 | + ); | |
| 2419 | + | |
| 2420 | + // Whoever won, the row is gone for everyone; a cache still holding it | |
| 2421 | + // would let a later read look live. | |
| 2422 | + wp_cache_delete( $key, 'options' ); | |
| 2423 | + | |
| 2424 | + if ( 1 !== (int) $claimed ) { | |
| 2425 | + return ''; | |
| 2426 | + } | |
| 2427 | + | |
| 1820 | 2428 | $data = json_decode( $stored, true ); |
| 1821 | 2429 | if ( ! is_array( $data ) || empty( $data['fingerprint'] ) ) { |
| 1822 | 2430 | return ''; |
| 1823 | 2431 | } |
| @@ -1834,9 +2442,9 @@ | ||
| 1834 | 2442 | * Options have no TTL, so an unused token would otherwise sit in |
| 1835 | 2443 | * wp_options forever — a scan that is never followed by a clean is the |
| 1836 | 2444 | * normal case, not the exception. |
| 1837 | 2445 | */ |
| 1838 | - private static function purge_expired_clean_tokens(): void { | |
| 2446 | + private static function purge_expired_confirm_tokens(): void { | |
| 1839 | 2447 | global $wpdb; |
| 1840 | 2448 | |
| 1841 | 2449 | if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) { |
| 1842 | 2450 | return; |
| @@ -2045,9 +2653,16 @@ | ||
| 2045 | 2653 | $on = filter_var( $args['enabled'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ); |
| 2046 | 2654 | if ( null === $on ) { |
| 2047 | 2655 | return new \WP_Error( 'xspeed_mcp_invalid_enabled', __( 'The enabled argument must be true or false.', 'xspeed' ), array( 'status' => 400 ) ); |
| 2048 | 2656 | } |
| 2049 | - return Cli_Bridge::run( 'objcache', array( $on ? 'enable' : 'disable' ) ); | |
| 2657 | + $assoc = array(); | |
| 2658 | + if ( $on && ! empty( $args['takeover'] ) && filter_var( $args['takeover'], FILTER_VALIDATE_BOOLEAN ) ) { | |
| 2659 | + $assoc['takeover'] = true; | |
| 2660 | + } | |
| 2661 | + if ( ! $on && ! empty( $args['restore'] ) && filter_var( $args['restore'], FILTER_VALIDATE_BOOLEAN ) ) { | |
| 2662 | + $assoc['restore'] = true; | |
| 2663 | + } | |
| 2664 | + return Cli_Bridge::run( 'objcache', array( $on ? 'enable' : 'disable' ), $assoc ); | |
| 2050 | 2665 | } |
| 2051 | 2666 | |
| 2052 | 2667 | /** |
| 2053 | 2668 | * List or clear stored Critical CSS. |
| @@ -2290,14 +2905,41 @@ | ||
| 2290 | 2905 | |
| 2291 | 2906 | /** |
| 2292 | 2907 | * Generate Critical CSS (Pro). |
| 2293 | 2908 | * |
| 2294 | - * @param array $args Unused. | |
| 2909 | + * The tool took no arguments, so it could only build the home page's | |
| 2910 | + * blob, while `wp xspeed ccss generate --page-url` could target any page. | |
| 2911 | + * Passed as `page-url`: `url` is a WP-CLI global the command never sees | |
| 2912 | + * from a real command line. (#559) | |
| 2913 | + * | |
| 2914 | + * @param array $args { url?:string } Full URL or site path. | |
| 2295 | 2915 | * @return array|\WP_Error |
| 2296 | 2916 | */ |
| 2297 | 2917 | public static function generate_critical_css( array $args ) { |
| 2298 | - unset( $args ); | |
| 2299 | - return Cli_Bridge::run( 'ccss', array( 'generate' ) ); | |
| 2918 | + $options = array(); | |
| 2919 | + if ( ! empty( $args['url'] ) ) { | |
| 2920 | + $url = trim( (string) $args['url'] ); | |
| 2921 | + // A site path is a page on this site, as it is for run_pagespeed. | |
| 2922 | + if ( '/' === substr( $url, 0, 1 ) && '//' !== substr( $url, 0, 2 ) ) { | |
| 2923 | + $url = home_url( $url ); | |
| 2924 | + } elseif ( ! preg_match( '#^https?://#i', $url ) ) { | |
| 2925 | + $url = ''; // `about/`, `//host/x`: neither a path nor a full URL. | |
| 2926 | + } | |
| 2927 | + $url = '' === $url ? '' : esc_url_raw( $url, array( 'http', 'https' ) ); | |
| 2928 | + // Only pages of this site. A render spends the site's quota, and | |
| 2929 | + // another host is not a page this site's Critical CSS can serve. | |
| 2930 | + $host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ); | |
| 2931 | + if ( '' !== $url && strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) !== $host ) { | |
| 2932 | + $url = ''; | |
| 2933 | + } | |
| 2934 | + // Refused rather than dropped: an empty value would quietly | |
| 2935 | + // build the home page and report success for the wrong page. | |
| 2936 | + if ( '' === $url ) { | |
| 2937 | + return new \WP_Error( 'xspeed_mcp_invalid_url', __( 'The url argument must be a page on this site, as a full http(s) URL or a path starting with /.', 'xspeed' ), array( 'status' => 400 ) ); | |
| 2938 | + } | |
| 2939 | + $options['page-url'] = $url; | |
| 2940 | + } | |
| 2941 | + return Cli_Bridge::run( 'ccss', array( 'generate' ), $options ); | |
| 2300 | 2942 | } |
| 2301 | 2943 | |
| 2302 | 2944 | /** |
| 2303 | 2945 | * Build a JSON Schema object node. |