| @@ -38,9 +38,14 @@ | ||
| 38 | 38 | |
| 39 | 39 | defined( 'ABSPATH' ) || exit; |
| 40 | 40 | |
| 41 | 41 | final class Mcp_Tools { |
| 42 | + /** Pro extension contract supported by this Free build. */ | |
| 43 | + public const EXTENSION_API = 1; | |
| 42 | 44 | |
| 45 | + /** Raw broker tool envelope cap, enforced before JSON decoding. */ | |
| 46 | + public const MAX_TOOL_BODY_BYTES = 2 * 1024 * 1024; | |
| 47 | + | |
| 43 | 48 | /** Valid cache purge types. */ |
| 44 | 49 | public const PURGE_TYPES = array( 'all', 'page', 'assets', 'object', 'rest', 'cloudflare', 'cdn' ); |
| 45 | 50 | |
| 46 | 51 | /** |
| @@ -139,9 +144,9 @@ | ||
| 139 | 144 | 'write' => false, |
| 140 | 145 | 'handler' => array( self::class, 'list_modules' ), |
| 141 | 146 | ), |
| 142 | 147 | 'get_site_info' => array( |
| 143 | - '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.', | |
| 144 | 149 | 'inputSchema' => self::object_schema( array(), array() ), |
| 145 | 150 | 'write' => false, |
| 146 | 151 | 'handler' => array( self::class, 'get_site_info' ), |
| 147 | 152 | ), |
| @@ -368,10 +373,18 @@ | ||
| 368 | 373 | 'write' => true, |
| 369 | 374 | 'handler' => array( self::class, 'run_pagespeed' ), |
| 370 | 375 | ), |
| 371 | 376 | 'generate_critical_css' => array( |
| 372 | - 'description' => 'Generate above-the-fold Critical CSS for the site (Pro). Calls the external generator and stores the result.', | |
| 373 | - '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 | + ), | |
| 374 | 387 | 'write' => true, |
| 375 | 388 | 'handler' => array( self::class, 'generate_critical_css' ), |
| 376 | 389 | ), |
| 377 | 390 | 'get_health' => array( |
| @@ -491,12 +504,20 @@ | ||
| 491 | 504 | 'toggle_object_cache' => array( |
| 492 | 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.', |
| 493 | 506 | 'inputSchema' => self::object_schema( |
| 494 | 507 | array( |
| 495 | - 'enabled' => array( | |
| 508 | + 'enabled' => array( | |
| 496 | 509 | 'type' => 'boolean', |
| 497 | 510 | 'description' => 'true installs the drop-in, false removes it.', |
| 498 | 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 | + ), | |
| 499 | 520 | ), |
| 500 | 521 | array( 'enabled' ) |
| 501 | 522 | ), |
| 502 | 523 | 'write' => true, |
| @@ -561,9 +582,9 @@ | ||
| 561 | 582 | 'write' => false, |
| 562 | 583 | 'handler' => array( self::class, 'list_commands' ), |
| 563 | 584 | ), |
| 564 | 585 | 'run_command' => array( |
| 565 | - '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.', | |
| 566 | 587 | 'inputSchema' => self::object_schema( |
| 567 | 588 | array( |
| 568 | 589 | 'command' => array( |
| 569 | 590 | 'type' => 'string', |
| @@ -579,9 +600,9 @@ | ||
| 579 | 600 | 'description' => 'Named options / flags, e.g. { "url": "https://site.com", "strategy": "mobile", "force": true }.', |
| 580 | 601 | ), |
| 581 | 602 | 'confirm_token' => array( |
| 582 | 603 | 'type' => 'string', |
| 583 | - '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.', | |
| 584 | 605 | ), |
| 585 | 606 | ), |
| 586 | 607 | array( 'command' ) |
| 587 | 608 | ), |
| @@ -670,8 +691,49 @@ | ||
| 670 | 691 | $catalog[ $name ] = $spec; |
| 671 | 692 | } |
| 672 | 693 | } |
| 673 | 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 | + | |
| 674 | 736 | return $catalog; |
| 675 | 737 | } |
| 676 | 738 | |
| 677 | 739 | /** |
| @@ -904,8 +966,11 @@ | ||
| 904 | 966 | */ |
| 905 | 967 | public static function list(): array { |
| 906 | 968 | $out = array(); |
| 907 | 969 | foreach ( self::catalog() as $name => $spec ) { |
| 970 | + if ( ! empty( $spec['hidden'] ) ) { | |
| 971 | + continue; | |
| 972 | + } | |
| 908 | 973 | $out[] = array( |
| 909 | 974 | 'name' => $name, |
| 910 | 975 | 'description' => $spec['description'], |
| 911 | 976 | 'inputSchema' => $spec['inputSchema'], |
| @@ -944,8 +1009,39 @@ | ||
| 944 | 1009 | |
| 945 | 1010 | return $error; |
| 946 | 1011 | } |
| 947 | 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 | + | |
| 948 | 1044 | // Scope enforcement: a read-only connection cannot invoke a tool that |
| 949 | 1045 | // mutates state. run_command is a gateway to the full CLI surface, so |
| 950 | 1046 | // it's treated as write regardless of the wrapped command. The active |
| 951 | 1047 | // credential's scope (pairing token OR OAuth access token) is carried |
| @@ -980,16 +1076,32 @@ | ||
| 980 | 1076 | * covers each door at once — including any future tool that wraps the |
| 981 | 1077 | * same command. (#184) |
| 982 | 1078 | */ |
| 983 | 1079 | $destructive = self::destructive_action( $name, $args ); |
| 984 | - if ( '' !== $destructive ) { | |
| 985 | - $confirmed = self::verify_clean_token( $args ); | |
| 1080 | + if ( ! empty( $destructive ) ) { | |
| 1081 | + $confirmed = self::verify_confirm_token( $args, $name, $destructive ); | |
| 986 | 1082 | if ( is_wp_error( $confirmed ) ) { |
| 987 | - 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 ); | |
| 988 | 1087 | return $confirmed; |
| 989 | 1088 | } |
| 990 | 1089 | } |
| 991 | 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 | + | |
| 992 | 1104 | self::$dispatching = true; |
| 993 | 1105 | try { |
| 994 | 1106 | $result = call_user_func( $catalog[ $name ]['handler'], $args ); |
| 995 | 1107 | |
| @@ -1066,8 +1178,13 @@ | ||
| 1066 | 1178 | |
| 1067 | 1179 | /** |
| 1068 | 1180 | * Cache status, stats, and detected server. |
| 1069 | 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 | + * | |
| 1070 | 1187 | * @param array $args Unused. |
| 1071 | 1188 | * @return array |
| 1072 | 1189 | */ |
| 1073 | 1190 | public static function get_cache_status( array $args ) { |
| @@ -1076,8 +1193,9 @@ | ||
| 1076 | 1193 | return array( |
| 1077 | 1194 | 'cache_enabled' => (bool) ( $opts['cache_enabled'] ?? false ), |
| 1078 | 1195 | 'stats' => Cache::get_stats(), |
| 1079 | 1196 | 'server' => Server::type(), |
| 1197 | + 'site_icon' => (string) get_site_icon_url( 64 ), | |
| 1080 | 1198 | ); |
| 1081 | 1199 | } |
| 1082 | 1200 | |
| 1083 | 1201 | /** |
| @@ -1150,12 +1268,93 @@ | ||
| 1150 | 1268 | 'wp_version' => get_bloginfo( 'version' ), |
| 1151 | 1269 | 'php_version' => PHP_VERSION, |
| 1152 | 1270 | 'server' => Server::type(), |
| 1153 | 1271 | 'multisite' => is_multisite(), |
| 1272 | + 'addons' => self::addon_licences(), | |
| 1154 | 1273 | ); |
| 1155 | 1274 | } |
| 1156 | 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 | + | |
| 1157 | 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 | + /** | |
| 1158 | 1357 | * All registered module descriptors. |
| 1159 | 1358 | * |
| 1160 | 1359 | * @param array $args Unused. |
| 1161 | 1360 | * @return array |
| @@ -1699,9 +1898,9 @@ | ||
| 1699 | 1898 | * longer describes reality and clean_database refuses. That closes the |
| 1700 | 1899 | * window where a scan is shown to a human, something changes, and the |
| 1701 | 1900 | * delete removes more than was agreed to. (#184) |
| 1702 | 1901 | */ |
| 1703 | - $result['confirm_token'] = self::mint_clean_token(); | |
| 1902 | + $result['confirm_token'] = self::mint_confirm_token( self::clean_fingerprint() ); | |
| 1704 | 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' ); |
| 1705 | 1904 | |
| 1706 | 1905 | return $result; |
| 1707 | 1906 | } |
| @@ -1746,76 +1945,215 @@ | ||
| 1746 | 1945 | ); |
| 1747 | 1946 | } |
| 1748 | 1947 | |
| 1749 | 1948 | /** |
| 1750 | - * 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. | |
| 1751 | 1951 | * |
| 1752 | 1952 | * @param string $name Tool name. |
| 1753 | 1953 | * @param array $args Decoded tool arguments. |
| 1754 | - * @return string Canonical "<command> <action>", or '' when not destructive. | |
| 1954 | + * @return array{name?:string,action?:string} The classified pair, or array() when not destructive. | |
| 1755 | 1955 | */ |
| 1756 | - private static function destructive_action( string $name, array $args ): string { | |
| 1956 | + private static function destructive_action( string $name, array $args ): array { | |
| 1757 | 1957 | // The gateway carries the real command in its arguments; a typed tool |
| 1758 | 1958 | // is identified by the command it is mapped to. |
| 1759 | 1959 | if ( 'run_command' === $name ) { |
| 1760 | 1960 | $command = isset( $args['command'] ) ? (string) $args['command'] : ''; |
| 1761 | 1961 | if ( '' === $command ) { |
| 1762 | - return ''; | |
| 1962 | + return array(); | |
| 1763 | 1963 | } |
| 1764 | 1964 | $positional = isset( $args['args'] ) && is_array( $args['args'] ) ? $args['args'] : array(); |
| 1765 | - $resolved = Cli_Bridge::classify( $command, $positional ); | |
| 1766 | - } elseif ( 'clean_database' === $name ) { | |
| 1767 | - $resolved = Cli_Bridge::classify( 'db', array( 'clean' ) ); | |
| 1768 | - } 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'] ) ) { | |
| 1769 | 2033 | return ''; |
| 1770 | 2034 | } |
| 2035 | + return trim( $action['name'] . ' ' . ( $action['action'] ?? '' ) ); | |
| 2036 | + } | |
| 1771 | 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 { | |
| 1772 | 2045 | if ( '' === $resolved['name'] ) { |
| 1773 | - return ''; | |
| 2046 | + return array(); | |
| 1774 | 2047 | } |
| 1775 | 2048 | |
| 1776 | 2049 | $destructive = self::destructive_actions(); |
| 1777 | 2050 | if ( ! isset( $destructive[ $resolved['name'] ] ) ) { |
| 1778 | - return ''; | |
| 2051 | + return array(); | |
| 1779 | 2052 | } |
| 1780 | 2053 | if ( ! in_array( $resolved['action'], (array) $destructive[ $resolved['name'] ], true ) ) { |
| 1781 | - return ''; | |
| 2054 | + return array(); | |
| 1782 | 2055 | } |
| 1783 | 2056 | |
| 1784 | - return trim( $resolved['name'] . ' ' . $resolved['action'] ); | |
| 2057 | + return array( | |
| 2058 | + 'name' => $resolved['name'], | |
| 2059 | + 'action' => $resolved['action'], | |
| 2060 | + ); | |
| 1785 | 2061 | } |
| 1786 | 2062 | |
| 1787 | 2063 | /** |
| 1788 | - * Verify (and consume) the confirm_token minted by scan_database. | |
| 2064 | + * Which CLI command(s) a generated tool name could have come from. | |
| 1789 | 2065 | * |
| 1790 | - * @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(). | |
| 1791 | 2126 | * @return true|\WP_Error |
| 1792 | 2127 | */ |
| 1793 | - private static function verify_clean_token( array $args ) { | |
| 2128 | + private static function verify_confirm_token( array $args, string $tool, array $action ) { | |
| 1794 | 2129 | $token = isset( $args['confirm_token'] ) ? (string) $args['confirm_token'] : ''; |
| 1795 | 2130 | if ( '' === $token ) { |
| 1796 | - return new \WP_Error( | |
| 1797 | - 'xspeed_mcp_confirm_required', | |
| 1798 | - __( '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' ), | |
| 1799 | - array( 'status' => 400 ) | |
| 1800 | - ); | |
| 2131 | + return self::confirm_required( $tool, $action ); | |
| 1801 | 2132 | } |
| 1802 | 2133 | |
| 1803 | - // Single use: consumed whether or not the delete goes ahead, so one | |
| 1804 | - // approval can never authorise a second, different deletion. | |
| 1805 | - $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 ); | |
| 1806 | 2142 | if ( '' === $sealed ) { |
| 1807 | 2143 | return new \WP_Error( |
| 1808 | 2144 | 'xspeed_mcp_confirm_invalid', |
| 1809 | - __( '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' ), | |
| 1810 | 2146 | array( 'status' => 400 ) |
| 1811 | 2147 | ); |
| 1812 | 2148 | } |
| 1813 | 2149 | |
| 1814 | - if ( ! hash_equals( $sealed, self::clean_fingerprint() ) ) { | |
| 2150 | + if ( ! hash_equals( $sealed, $expected ) ) { | |
| 1815 | 2151 | return new \WP_Error( |
| 1816 | 2152 | 'xspeed_mcp_confirm_stale', |
| 1817 | - __( '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' ), | |
| 1818 | 2156 | array( 'status' => 409 ) |
| 1819 | 2157 | ); |
| 1820 | 2158 | } |
| 1821 | 2159 | |
| @@ -1821,18 +2159,160 @@ | ||
| 1821 | 2159 | |
| 1822 | 2160 | return true; |
| 1823 | 2161 | } |
| 1824 | 2162 | |
| 1825 | - /** 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. */ | |
| 1826 | 2299 | private static function clean_fingerprint(): string { |
| 1827 | 2300 | return hash( 'sha256', (string) wp_json_encode( self::clean_scope() ) ); |
| 1828 | 2301 | } |
| 1829 | 2302 | |
| 1830 | 2303 | /** Lifetime of a confirm_token, from mint to refusal. */ |
| 1831 | - private const CLEAN_TOKEN_TTL = 5 * MINUTE_IN_SECONDS; | |
| 2304 | + private const CONFIRM_TOKEN_TTL = 5 * MINUTE_IN_SECONDS; | |
| 1832 | 2305 | |
| 1833 | - /** Storage key for a minted token (the token itself is never stored). */ | |
| 1834 | - 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 { | |
| 1835 | 2315 | return 'xspeed_mcp_clean_' . hash( 'sha256', $token ); |
| 1836 | 2316 | } |
| 1837 | 2317 | |
| 1838 | 2318 | /* |
| @@ -1858,18 +2338,24 @@ | ||
| 1858 | 2338 | * object cache does. Expiry is carried in the stored value and checked on |
| 1859 | 2339 | * read, since options have no TTL of their own. |
| 1860 | 2340 | */ |
| 1861 | 2341 | |
| 1862 | - 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 { | |
| 1863 | 2349 | $token = wp_generate_password( 32, false ); |
| 1864 | 2350 | |
| 1865 | 2351 | // autoload=no: this is read once, by one request, minutes from now. |
| 1866 | 2352 | add_option( |
| 1867 | - self::clean_token_key( $token ), | |
| 2353 | + self::confirm_token_key( $token ), | |
| 1868 | 2354 | wp_json_encode( |
| 1869 | 2355 | array( |
| 1870 | - 'fingerprint' => self::clean_fingerprint(), | |
| 1871 | - 'expires' => time() + self::CLEAN_TOKEN_TTL, | |
| 2356 | + 'fingerprint' => $fingerprint, | |
| 2357 | + 'expires' => time() + self::CONFIRM_TOKEN_TTL, | |
| 1872 | 2358 | ) |
| 1873 | 2359 | ), |
| 1874 | 2360 | '', |
| 1875 | 2361 | 'no' |
| @@ -1874,9 +2360,9 @@ | ||
| 1874 | 2360 | '', |
| 1875 | 2361 | 'no' |
| 1876 | 2362 | ); |
| 1877 | 2363 | |
| 1878 | - self::purge_expired_clean_tokens(); | |
| 2364 | + self::purge_expired_confirm_tokens(); | |
| 1879 | 2365 | |
| 1880 | 2366 | return $token; |
| 1881 | 2367 | } |
| 1882 | 2368 | |
| @@ -1885,17 +2371,61 @@ | ||
| 1885 | 2371 | * |
| 1886 | 2372 | * Consumes the record either way: a token is single use, so one approval |
| 1887 | 2373 | * can never authorise a second, different deletion. |
| 1888 | 2374 | */ |
| 1889 | - private static function consume_clean_token( string $token ): string { | |
| 1890 | - $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 ); | |
| 1891 | 2379 | $stored = get_option( $key ); |
| 1892 | 2380 | if ( ! is_string( $stored ) || '' === $stored ) { |
| 1893 | 2381 | return ''; |
| 1894 | 2382 | } |
| 1895 | 2383 | |
| 1896 | - 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 | + } | |
| 1897 | 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 | + | |
| 1898 | 2428 | $data = json_decode( $stored, true ); |
| 1899 | 2429 | if ( ! is_array( $data ) || empty( $data['fingerprint'] ) ) { |
| 1900 | 2430 | return ''; |
| 1901 | 2431 | } |
| @@ -1912,9 +2442,9 @@ | ||
| 1912 | 2442 | * Options have no TTL, so an unused token would otherwise sit in |
| 1913 | 2443 | * wp_options forever — a scan that is never followed by a clean is the |
| 1914 | 2444 | * normal case, not the exception. |
| 1915 | 2445 | */ |
| 1916 | - private static function purge_expired_clean_tokens(): void { | |
| 2446 | + private static function purge_expired_confirm_tokens(): void { | |
| 1917 | 2447 | global $wpdb; |
| 1918 | 2448 | |
| 1919 | 2449 | if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) { |
| 1920 | 2450 | return; |
| @@ -2123,9 +2653,16 @@ | ||
| 2123 | 2653 | $on = filter_var( $args['enabled'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ); |
| 2124 | 2654 | if ( null === $on ) { |
| 2125 | 2655 | return new \WP_Error( 'xspeed_mcp_invalid_enabled', __( 'The enabled argument must be true or false.', 'xspeed' ), array( 'status' => 400 ) ); |
| 2126 | 2656 | } |
| 2127 | - 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 ); | |
| 2128 | 2665 | } |
| 2129 | 2666 | |
| 2130 | 2667 | /** |
| 2131 | 2668 | * List or clear stored Critical CSS. |
| @@ -2368,14 +2905,41 @@ | ||
| 2368 | 2905 | |
| 2369 | 2906 | /** |
| 2370 | 2907 | * Generate Critical CSS (Pro). |
| 2371 | 2908 | * |
| 2372 | - * @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. | |
| 2373 | 2915 | * @return array|\WP_Error |
| 2374 | 2916 | */ |
| 2375 | 2917 | public static function generate_critical_css( array $args ) { |
| 2376 | - unset( $args ); | |
| 2377 | - 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 ); | |
| 2378 | 2942 | } |
| 2379 | 2943 | |
| 2380 | 2944 | /** |
| 2381 | 2945 | * Build a JSON Schema object node. |