Changeset 3771056
- Timestamp:
- 10/28/2026 04:17:40 PM (61 minutes ago)
- Location:
- agentmatica-mcp-connector
- Files:
-
- 14 edited
- 1 copied
-
tags/0.7.5 (copied) (copied from agentmatica-mcp-connector/trunk )
-
tags/0.7.5/agentmatica.php (modified) (2 diffs)
-
tags/0.7.5/includes/class-customer-token.php (modified) (5 diffs)
-
tags/0.7.5/includes/modules/woocommerce/class-cart.php (modified) (12 diffs)
-
tags/0.7.5/includes/modules/woocommerce/class-order-guards.php (modified) (1 diff)
-
tags/0.7.5/includes/modules/woocommerce/class-order-mutations.php (modified) (4 diffs)
-
tags/0.7.5/includes/modules/woocommerce/class-orders.php (modified) (9 diffs)
-
tags/0.7.5/readme.txt (modified) (3 diffs)
-
trunk/agentmatica.php (modified) (2 diffs)
-
trunk/includes/class-customer-token.php (modified) (5 diffs)
-
trunk/includes/modules/woocommerce/class-cart.php (modified) (12 diffs)
-
trunk/includes/modules/woocommerce/class-order-guards.php (modified) (1 diff)
-
trunk/includes/modules/woocommerce/class-order-mutations.php (modified) (4 diffs)
-
trunk/includes/modules/woocommerce/class-orders.php (modified) (9 diffs)
-
trunk/readme.txt (modified) (3 diffs)
Legend:
- Unmodified
- Added
- Removed
-
agentmatica-mcp-connector/tags/0.7.5/agentmatica.php
r3766293 r3771056 4 4 * Plugin URI: https://agentmatica.com/docs/integrations/wordpress 5 5 * Description: Free MCP connector for Agentmatica.com. Expose WordPress content and optional WooCommerce tools to Agentmatica visitor chat. 6 * Version: 0.7. 36 * Version: 0.7.5 7 7 * Requires at least: 6.9 8 8 * Requires PHP: 8.0 … … 23 23 } 24 24 25 define( 'AGENTMATICA_VERSION', '0.7. 3' );25 define( 'AGENTMATICA_VERSION', '0.7.5' ); 26 26 define( 'AGENTMATICA_PLUGIN_FILE', __FILE__ ); 27 27 define( 'AGENTMATICA_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); -
agentmatica-mcp-connector/tags/0.7.5/includes/class-customer-token.php
r3766293 r3771056 19 19 add_action( 'rest_api_init', array( __CLASS__, 'register_routes' ) ); 20 20 add_action( 'wp_head', array( __CLASS__, 'print_rest_nonce' ), 1 ); 21 add_action( 'wp_head', array( __CLASS__, 'print_account_url' ), 1 ); 21 22 add_filter( 'rest_authentication_errors', array( __CLASS__, 'allow_cookie_auth_without_nonce' ) ); 22 23 } … … 33 34 $nonce = wp_create_nonce( 'wp_rest' ); 34 35 echo '<meta name="agentmatica-wp-rest-nonce" content="' . esc_attr( $nonce ) . '" />' . "\n"; 35 $json = wp_json_encode( $nonce );36 $json = wp_json_encode( $nonce ); 36 37 if ( is_string( $json ) ) { 37 38 wp_print_inline_script_tag( 'window.agentmaticaWpRestNonce=' . $json . ';' ); 38 39 } 40 } 41 42 /** 43 * Where the widget sends shoppers to sign in. The My Account page can be 44 * renamed, translated or under a subdirectory, so never guess /my-account/. 45 * Same URL for every visitor, so this is safe on cached pages. 46 */ 47 public static function print_account_url(): void { 48 if ( is_admin() || ! function_exists( 'wc_get_page_permalink' ) ) { 49 return; 50 } 51 52 $url = wc_get_page_permalink( 'myaccount' ); 53 if ( ! is_string( $url ) || $url === '' ) { 54 return; 55 } 56 echo '<meta name="agentmatica-woo-account-url" content="' . esc_url( $url ) . '" />' . "\n"; 39 57 } 40 58 … … 57 75 return $result; 58 76 } 77 // A nonce was sent and core rejected it: keep the error, never fall back. 78 if ( self::request_header( 'HTTP_X_WP_NONCE' ) !== '' ) { 79 return $result; 80 } 59 81 if ( ! self::request_is_same_origin_fetch() ) { 60 82 return $result; … … 87 109 * Top-level clicks send Sec-Fetch-Mode: navigate and must be refused. 88 110 */ 89 private static function request_is_same_origin_fetch(): bool { 111 public static function request_is_same_origin_fetch(): bool { 112 // Only fetch()/XHR: refuse navigations and subresources such as 113 // <img src="/wp-json/.../customer-token?session_key=..."> (mode no-cors, 114 // dest image), which also carry Sec-Fetch-Site: same-origin. 90 115 $mode = strtolower( self::request_header( 'HTTP_SEC_FETCH_MODE' ) ); 91 if ( $mode === 'navigate' ) { 116 if ( $mode !== '' && ! in_array( $mode, array( 'cors', 'same-origin' ), true ) ) { 117 return false; 118 } 119 $dest = strtolower( self::request_header( 'HTTP_SEC_FETCH_DEST' ) ); 120 if ( $dest !== '' && $dest !== 'empty' ) { 92 121 return false; 93 122 } … … 215 244 } 216 245 217 Agentmatica_WooCommerce_Orders::bind_logged_in_customer( 218 array( 219 'session_key' => $session_key, 220 'wp_user_id' => $user_id, 221 ) 222 ); 246 // Binding happens only when the Agentmatica server verifies this token 247 // (verify_customer_token), never just because a token was minted. 223 248 224 249 $exp = time() + self::TOKEN_TTL; -
agentmatica-mcp-connector/tags/0.7.5/includes/modules/woocommerce/class-cart.php
r3766293 r3771056 46 46 } 47 47 48 /** 49 * Storefront cart routes act on the shopper's Woo session cookie, so only the 50 * store's own pages (the widget's fetch) may call them, not other sites. 51 */ 52 public static function storefront_same_origin_permission(): bool { 53 return Agentmatica_Customer_Token::request_is_same_origin_fetch(); 54 } 55 48 56 public static function register_read_storefront_cart_route(): void { 49 57 register_rest_route( … … 53 61 'methods' => 'GET', 54 62 'callback' => array( __CLASS__, 'handle_read_storefront_cart_rest' ), 55 'permission_callback' => '__return_true',63 'permission_callback' => array( __CLASS__, 'storefront_same_origin_permission' ), 56 64 ) 57 65 ); … … 65 73 'methods' => 'POST', 66 74 'callback' => array( __CLASS__, 'handle_sync_storefront_cart_rest' ), 67 'permission_callback' => '__return_true',75 'permission_callback' => array( __CLASS__, 'storefront_same_origin_permission' ), 68 76 ) 69 77 ); … … 77 85 'methods' => 'POST', 78 86 'callback' => array( __CLASS__, 'handle_add_storefront_cart_item_rest' ), 79 'permission_callback' => '__return_true',87 'permission_callback' => array( __CLASS__, 'storefront_same_origin_permission' ), 80 88 ) 81 89 ); … … 102 110 $product_id = isset( $params['product_id'] ) ? (int) $params['product_id'] : 0; 103 111 $variation_id = isset( $params['variation_id'] ) ? (int) $params['variation_id'] : 0; 104 $quantity = isset( $params['quantity'] ) ? max( 1, (int) $params['quantity'] ) : 1;112 $quantity = self::clamp_line_quantity( $params['quantity'] ?? 1 ); 105 113 106 114 if ( $product_id <= 0 ) { … … 319 327 $backup_cart = WC()->cart->get_cart(); 320 328 321 remove_all_actions( 'woocommerce_cart_emptied' ); 322 WC()->cart->empty_cart( false ); 329 self::empty_cart_keeping_session(); 323 330 324 331 $added_any = false; … … 330 337 $product_id = isset( $line['product_id'] ) ? (int) $line['product_id'] : 0; 331 338 $variation_id = isset( $line['variation_id'] ) ? (int) $line['variation_id'] : 0; 332 $quantity = isset( $line['quantity'] ) ? max( 1, (int) $line['quantity'] ) : 1;339 $quantity = self::clamp_line_quantity( $line['quantity'] ?? 1 ); 333 340 if ( $variation_id > 0 ) { 334 341 $variation_product = wc_get_product( $variation_id ); … … 345 352 continue; 346 353 } 347 $added = self::add_line_to_wc_cart( $product_id, $quantity, $variation_id );354 $added = self::add_line_to_wc_cart( $product_id, $quantity, $variation_id ); 348 355 $sync_attempt[] = array( 349 356 'product_id' => $product_id, … … 394 401 * @return string|false Cart item key. 395 402 */ 403 /** Per-line cap for public cart routes; stock limits still apply in Woo. */ 404 private const MAX_LINE_QUANTITY = 99; 405 406 /** 407 * @param mixed $raw Quantity from a request. 408 */ 409 private static function clamp_line_quantity( $raw ): int { 410 $quantity = is_numeric( $raw ) ? (int) $raw : 1; 411 return min( self::MAX_LINE_QUANTITY, max( 1, $quantity ) ); 412 } 413 396 414 private static function add_line_to_wc_cart( int $product_id, int $quantity, int $variation_id ) { 397 415 if ( ! function_exists( 'WC' ) || ! WC()->cart instanceof WC_Cart ) { … … 517 535 $product_id = isset( $input['product_id'] ) ? (int) $input['product_id'] : 0; 518 536 $variation_id = isset( $input['variation_id'] ) ? (int) $input['variation_id'] : 0; 519 $quantity = isset( $input['quantity'] ) ? max( 1, (int) $input['quantity'] ) : 1;537 $quantity = self::clamp_line_quantity( $input['quantity'] ?? 1 ); 520 538 521 539 if ( $session_key === '' ) { … … 1014 1032 } 1015 1033 1016 remove_all_actions( 'woocommerce_cart_emptied' ); 1017 WC()->cart->empty_cart( false ); 1034 self::empty_cart_keeping_session(); 1018 1035 1019 1036 foreach ( $snapshot as $cart_item ) { … … 1027 1044 continue; 1028 1045 } 1029 self::add_line_to_wc_cart( $product_id, $quantity, $variation_id ); 1046 // Re-add the line exactly as it was: the saved attribute choices 1047 // ("Any size" variations have no fixed attributes) and any add-on 1048 // data. Rebuilding from get_variation_attributes() drops both. 1049 $variation = isset( $cart_item['variation'] ) && is_array( $cart_item['variation'] ) 1050 ? $cart_item['variation'] 1051 : array(); 1052 $item_data = array_diff_key( 1053 $cart_item, 1054 array_flip( self::CORE_CART_ITEM_KEYS ) 1055 ); 1056 try { 1057 WC()->cart->add_to_cart( $product_id, $quantity, $variation_id, $variation, $item_data ); 1058 } catch ( \Exception $exception ) { 1059 // Leave this line out; restoring the rest beats an empty cart. 1060 unset( $exception ); 1061 } 1062 } 1063 } 1064 1065 /** Keys Woo sets on every cart item; everything else is extension data. */ 1066 private const CORE_CART_ITEM_KEYS = array( 1067 'key', 1068 'product_id', 1069 'variation_id', 1070 'variation', 1071 'quantity', 1072 'data', 1073 'data_hash', 1074 'line_tax_data', 1075 'line_subtotal', 1076 'line_subtotal_tax', 1077 'line_total', 1078 'line_tax', 1079 ); 1080 1081 /** 1082 * empty_cart() fires woocommerce_cart_emptied, whose Woo callback destroys 1083 * the cart session. Detach only that callback for this call; other 1084 * plugins' listeners keep running. 1085 */ 1086 private static function empty_cart_keeping_session(): void { 1087 global $wp_filter; 1088 1089 $detached = array(); 1090 $hook = $wp_filter['woocommerce_cart_emptied'] ?? null; 1091 if ( $hook instanceof WP_Hook ) { 1092 foreach ( $hook->callbacks as $priority => $callbacks ) { 1093 foreach ( $callbacks as $callback ) { 1094 $function = $callback['function'] ?? null; 1095 if ( 1096 is_array( $function ) 1097 && isset( $function[0], $function[1] ) 1098 && $function[0] instanceof WC_Cart_Session 1099 && $function[1] === 'destroy_cart_session' 1100 ) { 1101 remove_action( 'woocommerce_cart_emptied', $function, (int) $priority ); 1102 $detached[] = array( $function, (int) $priority, (int) ( $callback['accepted_args'] ?? 1 ) ); 1103 } 1104 } 1105 } 1106 } 1107 1108 WC()->cart->empty_cart( false ); 1109 1110 foreach ( $detached as $entry ) { 1111 add_action( 'woocommerce_cart_emptied', $entry[0], $entry[1], $entry[2] ); 1030 1112 } 1031 1113 } -
agentmatica-mcp-connector/tags/0.7.5/includes/modules/woocommerce/class-order-guards.php
r3766293 r3771056 90 90 91 91 public static function order_owned_by_session( WC_Order $order, array $verified ): bool { 92 $wp_user_id = isset( $verified['wp_user_id'] ) ? (int) $verified['wp_user_id'] : 0; 93 if ( $wp_user_id > 0 ) { 94 // Logged-in sessions own orders through the account only. WooCommerce 95 // does not verify account emails, so matching the billing email would 96 // let anyone register with a guest shopper's email and act on their orders. 97 $customer_id = (int) $order->get_customer_id(); 98 return $customer_id > 0 && $customer_id === $wp_user_id; 99 } 100 92 101 $email = strtolower( trim( (string) ( $verified['email'] ?? '' ) ) ); 93 102 if ( $email !== '' && Agentmatica_WooCommerce_Orders::email_matches_order( $order, $email ) ) { 94 103 return true; 95 }96 97 $wp_user_id = isset( $verified['wp_user_id'] ) ? (int) $verified['wp_user_id'] : 0;98 if ( $wp_user_id > 0 ) {99 $customer_id = (int) $order->get_customer_id();100 if ( $customer_id > 0 && $customer_id === $wp_user_id ) {101 return true;102 }103 104 } 104 105 -
agentmatica-mcp-connector/tags/0.7.5/includes/modules/woocommerce/class-order-mutations.php
r3766293 r3771056 27 27 } 28 28 29 $login_required = Agentmatica_WooCommerce_Order_Guards::require_verified_customer_session( $verified ); 29 // Cancel and return need a logged-in store account (order number + email 30 // is printed on packing slips and emails; app is sign-in only too). 31 $login_required = Agentmatica_WooCommerce_Order_Guards::require_logged_in_session( $verified ); 30 32 if ( $login_required !== null ) { 31 33 return $login_required; … … 82 84 $refund_amount = (float) $order->get_total(); 83 85 if ( $refund_amount > 0 ) { 84 wc_create_refund( 86 // refund_payment sends the money back through the gateway. Without it 87 // WooCommerce only records a refund and the shopper is never paid. 88 $refund = wc_create_refund( 85 89 array( 86 'amount' => $refund_amount, 87 'reason' => __( 'Order cancelled via Agentmatica chat.', 'agentmatica-mcp-connector' ), 88 'order_id' => (int) $order->get_id(), 90 'amount' => $refund_amount, 91 'reason' => __( 'Order cancelled via Agentmatica chat.', 'agentmatica-mcp-connector' ), 92 'order_id' => (int) $order->get_id(), 93 'refund_payment' => true, 89 94 ) 90 95 ); 96 if ( is_wp_error( $refund ) ) { 97 $order->add_order_note( 98 sprintf( 99 /* translators: %s: gateway error message */ 100 __( 'Agentmatica cancelled this order but the automatic refund failed (%s). Please refund the customer manually.', 'agentmatica-mcp-connector' ), 101 $refund->get_error_message() 102 ) 103 ); 104 } 91 105 } 92 106 } … … 200 214 } 201 215 202 $login_required = Agentmatica_WooCommerce_Order_Guards::require_verified_customer_session( $verified ); 216 // Cancel and return need a logged-in store account (order number + email 217 // is printed on packing slips and emails; app is sign-in only too). 218 $login_required = Agentmatica_WooCommerce_Order_Guards::require_logged_in_session( $verified ); 203 219 if ( $login_required !== null ) { 204 220 return $login_required; … … 228 244 if ( $ineligible !== null ) { 229 245 return self::refused( 'create_return', $ineligible ); 246 } 247 248 if ( (string) $order->get_meta( '_agentmatica_return_request' ) !== '' ) { 249 return self::success( 250 'create_return', 251 __( 'A return has already been requested for this order.', 'agentmatica-mcp-connector' ), 252 'already_done', 253 (int) $order->get_id() 254 ); 230 255 } 231 256 -
agentmatica-mcp-connector/tags/0.7.5/includes/modules/woocommerce/class-orders.php
r3736423 r3771056 18 18 19 19 private const VERIFY_TRANSIENT_PREFIX = 'agentmatica_verify_'; 20 private const VERIFY_TTL = 45 * MINUTE_IN_SECONDS;20 private const VERIFY_TTL = 15 * MINUTE_IN_SECONDS; 21 21 private const RATE_SESSION_PREFIX = 'agentmatica_verify_rate_session_'; 22 22 private const RATE_IP_PREFIX = 'agentmatica_verify_rate_ip_'; 23 private const RATE_ORDER_PREFIX = 'agentmatica_verify_rate_order_'; 24 private const MAX_ATTEMPTS_ORDER = 5; 25 private const USER_SESSIONS_META = '_agentmatica_chat_sessions'; 23 26 private const MAX_ATTEMPTS_SESSION = 5; 24 27 private const MAX_ATTEMPTS_IP = 20; … … 27 30 public static function init(): void { 28 31 // Ability callbacks are registered via the WooCommerce module registry. 32 add_action( 'wp_logout', array( __CLASS__, 'clear_sessions_for_user' ) ); 33 } 34 35 /** 36 * Drop every chat binding for a user when they log out, so the next person on 37 * a shared device cannot act on their orders through the same widget session. 38 * 39 * @param int $user_id Logged-out user id (passed by wp_logout since WP 5.5). 40 */ 41 public static function clear_sessions_for_user( $user_id = 0 ): void { 42 $user_id = (int) $user_id; 43 if ( $user_id <= 0 ) { 44 return; 45 } 46 $keys = get_user_meta( $user_id, self::USER_SESSIONS_META, true ); 47 if ( is_array( $keys ) ) { 48 foreach ( $keys as $key ) { 49 delete_transient( self::VERIFY_TRANSIENT_PREFIX . Agentmatica_WooCommerce_Cart::sanitize_session_key( (string) $key ) ); 50 } 51 } 52 delete_user_meta( $user_id, self::USER_SESSIONS_META ); 29 53 } 30 54 … … 71 95 } 72 96 73 if ( self::is_rate_limited( $session_key ) ) {97 if ( self::is_rate_limited( $session_key ) || self::is_order_rate_limited( $order_number ) ) { 74 98 return array( 75 99 'success' => false, … … 79 103 80 104 self::record_attempt( $session_key ); 105 self::record_order_attempt( $order_number ); 81 106 82 107 $order = self::find_order_by_number( $order_number ); … … 126 151 } 127 152 128 $email = (string) ( $verified['email'] ?? '' ); 129 if ( $email === '' ) { 153 $wp_user_id = isset( $verified['wp_user_id'] ) ? (int) $verified['wp_user_id'] : 0; 154 $email = (string) ( $verified['email'] ?? '' ); 155 if ( $wp_user_id <= 0 && $email === '' ) { 130 156 return array( 131 157 'success' => false, … … 134 160 } 135 161 136 $orders = wc_get_orders( 137 array( 138 'limit' => 5, 139 'billing_email' => $email, 140 'orderby' => 'date', 141 'order' => 'DESC', 142 'return' => 'objects', 143 ) 144 ); 162 $query = array( 163 'limit' => 5, 164 'orderby' => 'date', 165 'order' => 'DESC', 166 'return' => 'objects', 167 ); 168 if ( $wp_user_id > 0 ) { 169 $query['customer_id'] = $wp_user_id; 170 } else { 171 $query['billing_email'] = $email; 172 } 173 $orders = wc_get_orders( $query ); 145 174 146 175 return array( … … 182 211 } 183 212 184 $email = (string) ( $verified['email'] ?? '' ); 185 if ( ! self::email_matches_order( $order, $email ) ) { 213 if ( ! Agentmatica_WooCommerce_Order_Guards::order_owned_by_session( $order, $verified ) ) { 186 214 return array( 187 215 'success' => false, … … 226 254 ); 227 255 } 256 257 $sessions = get_user_meta( $wp_user_id, self::USER_SESSIONS_META, true ); 258 $sessions = is_array( $sessions ) ? $sessions : array(); 259 $sessions = array_slice( array_values( array_unique( array_merge( array( $session_key ), $sessions ) ) ), 0, 20 ); 260 update_user_meta( $wp_user_id, self::USER_SESSIONS_META, $sessions ); 228 261 229 262 self::bind_verified_session( … … 325 358 set_transient( $ip_key, $ip_count + 1, HOUR_IN_SECONDS ); 326 359 } 360 } 361 362 /** 363 * Attempts per order number, across all chat sessions. Requests come from the 364 * Agentmatica server, so REMOTE_ADDR cannot tell shoppers apart and a new 365 * session_key resets the per-session count; this caps guessing on one order. 366 */ 367 private static function is_order_rate_limited( string $order_number ): bool { 368 $key = self::RATE_ORDER_PREFIX . md5( strtolower( ltrim( trim( $order_number ), '#' ) ) ); 369 return (int) get_transient( $key ) >= self::MAX_ATTEMPTS_ORDER; 370 } 371 372 private static function record_order_attempt( string $order_number ): void { 373 $key = self::RATE_ORDER_PREFIX . md5( strtolower( ltrim( trim( $order_number ), '#' ) ) ); 374 set_transient( $key, (int) get_transient( $key ) + 1, HOUR_IN_SECONDS ); 327 375 } 328 376 -
agentmatica-mcp-connector/tags/0.7.5/readme.txt
r3766293 r3771056 5 5 Tested up to: 7.1 6 6 Requires PHP: 8.0 7 Stable tag: 0.7. 37 Stable tag: 0.7.5 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 97 97 == Changelog == 98 98 99 = 0.7.5 = 100 * Security: a signed-in shopper can only see and change orders placed from their own account. Orders are no longer matched by billing email for signed-in shoppers, because store accounts are not email-verified. 101 * Security: logging out of the store ends the shopper's verified chat session, and a verified chat session now lasts 15 minutes instead of 45. 102 * Security: a customer token only links a chat to an account once Agentmatica has verified it, not when the token is issued. 103 * Security: order-number lookups are limited per order number, so guessing the email for one order is capped. 104 * Security: the storefront cart routes only accept requests from the store's own pages. 105 * Cancelling an order with a refund now sends the money back through the payment gateway. If the gateway cannot refund automatically, the order gets a note asking you to refund manually. 106 * Asking for a return twice on the same order no longer emails you twice. 107 108 = 0.7.4 = 109 * Cancel and return now require the shopper to be signed in to their store account, the same as shipping-address changes. Guests are passed to your team. 110 * The customer-token route only accepts a fetch from the widget: images and other embedded requests on the store are refused, and an invalid nonce is never ignored. 111 * The widget sends shoppers to your real My Account page, even if it is renamed, translated or in a subfolder. 112 * Cart sync keeps each item's chosen options and add-on data, and other plugins' cart hooks keep working. 113 * Cart routes cap a single line at 99. 114 * Also in 0.7.3 but missing from its notes: catalog search matches plural words ("screens" finds "screen"), chat cart adds respect stock, and the chat cart stays in sync with the store cart. 115 99 116 = 0.7.3 = 100 117 * Logged-in shoppers can get a customer token from the storefront widget. The route requires a `wp_rest` nonce or a same-origin fetch (not a clicked link). … … 147 164 == Upgrade Notice == 148 165 166 = 0.7.4 = 167 Cancel and return now need a signed-in store account. Safer customer-token checks, real My Account links, and cart sync that keeps item options. 168 149 169 = 0.7.3 = 150 170 Logged-in shoppers can be identified in chat; checkout links use the WooCommerce checkout URL. Cancel/return can use order number + email; address changes stay login-only. -
agentmatica-mcp-connector/trunk/agentmatica.php
r3766293 r3771056 4 4 * Plugin URI: https://agentmatica.com/docs/integrations/wordpress 5 5 * Description: Free MCP connector for Agentmatica.com. Expose WordPress content and optional WooCommerce tools to Agentmatica visitor chat. 6 * Version: 0.7. 36 * Version: 0.7.5 7 7 * Requires at least: 6.9 8 8 * Requires PHP: 8.0 … … 23 23 } 24 24 25 define( 'AGENTMATICA_VERSION', '0.7. 3' );25 define( 'AGENTMATICA_VERSION', '0.7.5' ); 26 26 define( 'AGENTMATICA_PLUGIN_FILE', __FILE__ ); 27 27 define( 'AGENTMATICA_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); -
agentmatica-mcp-connector/trunk/includes/class-customer-token.php
r3766293 r3771056 19 19 add_action( 'rest_api_init', array( __CLASS__, 'register_routes' ) ); 20 20 add_action( 'wp_head', array( __CLASS__, 'print_rest_nonce' ), 1 ); 21 add_action( 'wp_head', array( __CLASS__, 'print_account_url' ), 1 ); 21 22 add_filter( 'rest_authentication_errors', array( __CLASS__, 'allow_cookie_auth_without_nonce' ) ); 22 23 } … … 33 34 $nonce = wp_create_nonce( 'wp_rest' ); 34 35 echo '<meta name="agentmatica-wp-rest-nonce" content="' . esc_attr( $nonce ) . '" />' . "\n"; 35 $json = wp_json_encode( $nonce );36 $json = wp_json_encode( $nonce ); 36 37 if ( is_string( $json ) ) { 37 38 wp_print_inline_script_tag( 'window.agentmaticaWpRestNonce=' . $json . ';' ); 38 39 } 40 } 41 42 /** 43 * Where the widget sends shoppers to sign in. The My Account page can be 44 * renamed, translated or under a subdirectory, so never guess /my-account/. 45 * Same URL for every visitor, so this is safe on cached pages. 46 */ 47 public static function print_account_url(): void { 48 if ( is_admin() || ! function_exists( 'wc_get_page_permalink' ) ) { 49 return; 50 } 51 52 $url = wc_get_page_permalink( 'myaccount' ); 53 if ( ! is_string( $url ) || $url === '' ) { 54 return; 55 } 56 echo '<meta name="agentmatica-woo-account-url" content="' . esc_url( $url ) . '" />' . "\n"; 39 57 } 40 58 … … 57 75 return $result; 58 76 } 77 // A nonce was sent and core rejected it: keep the error, never fall back. 78 if ( self::request_header( 'HTTP_X_WP_NONCE' ) !== '' ) { 79 return $result; 80 } 59 81 if ( ! self::request_is_same_origin_fetch() ) { 60 82 return $result; … … 87 109 * Top-level clicks send Sec-Fetch-Mode: navigate and must be refused. 88 110 */ 89 private static function request_is_same_origin_fetch(): bool { 111 public static function request_is_same_origin_fetch(): bool { 112 // Only fetch()/XHR: refuse navigations and subresources such as 113 // <img src="/wp-json/.../customer-token?session_key=..."> (mode no-cors, 114 // dest image), which also carry Sec-Fetch-Site: same-origin. 90 115 $mode = strtolower( self::request_header( 'HTTP_SEC_FETCH_MODE' ) ); 91 if ( $mode === 'navigate' ) { 116 if ( $mode !== '' && ! in_array( $mode, array( 'cors', 'same-origin' ), true ) ) { 117 return false; 118 } 119 $dest = strtolower( self::request_header( 'HTTP_SEC_FETCH_DEST' ) ); 120 if ( $dest !== '' && $dest !== 'empty' ) { 92 121 return false; 93 122 } … … 215 244 } 216 245 217 Agentmatica_WooCommerce_Orders::bind_logged_in_customer( 218 array( 219 'session_key' => $session_key, 220 'wp_user_id' => $user_id, 221 ) 222 ); 246 // Binding happens only when the Agentmatica server verifies this token 247 // (verify_customer_token), never just because a token was minted. 223 248 224 249 $exp = time() + self::TOKEN_TTL; -
agentmatica-mcp-connector/trunk/includes/modules/woocommerce/class-cart.php
r3766293 r3771056 46 46 } 47 47 48 /** 49 * Storefront cart routes act on the shopper's Woo session cookie, so only the 50 * store's own pages (the widget's fetch) may call them, not other sites. 51 */ 52 public static function storefront_same_origin_permission(): bool { 53 return Agentmatica_Customer_Token::request_is_same_origin_fetch(); 54 } 55 48 56 public static function register_read_storefront_cart_route(): void { 49 57 register_rest_route( … … 53 61 'methods' => 'GET', 54 62 'callback' => array( __CLASS__, 'handle_read_storefront_cart_rest' ), 55 'permission_callback' => '__return_true',63 'permission_callback' => array( __CLASS__, 'storefront_same_origin_permission' ), 56 64 ) 57 65 ); … … 65 73 'methods' => 'POST', 66 74 'callback' => array( __CLASS__, 'handle_sync_storefront_cart_rest' ), 67 'permission_callback' => '__return_true',75 'permission_callback' => array( __CLASS__, 'storefront_same_origin_permission' ), 68 76 ) 69 77 ); … … 77 85 'methods' => 'POST', 78 86 'callback' => array( __CLASS__, 'handle_add_storefront_cart_item_rest' ), 79 'permission_callback' => '__return_true',87 'permission_callback' => array( __CLASS__, 'storefront_same_origin_permission' ), 80 88 ) 81 89 ); … … 102 110 $product_id = isset( $params['product_id'] ) ? (int) $params['product_id'] : 0; 103 111 $variation_id = isset( $params['variation_id'] ) ? (int) $params['variation_id'] : 0; 104 $quantity = isset( $params['quantity'] ) ? max( 1, (int) $params['quantity'] ) : 1;112 $quantity = self::clamp_line_quantity( $params['quantity'] ?? 1 ); 105 113 106 114 if ( $product_id <= 0 ) { … … 319 327 $backup_cart = WC()->cart->get_cart(); 320 328 321 remove_all_actions( 'woocommerce_cart_emptied' ); 322 WC()->cart->empty_cart( false ); 329 self::empty_cart_keeping_session(); 323 330 324 331 $added_any = false; … … 330 337 $product_id = isset( $line['product_id'] ) ? (int) $line['product_id'] : 0; 331 338 $variation_id = isset( $line['variation_id'] ) ? (int) $line['variation_id'] : 0; 332 $quantity = isset( $line['quantity'] ) ? max( 1, (int) $line['quantity'] ) : 1;339 $quantity = self::clamp_line_quantity( $line['quantity'] ?? 1 ); 333 340 if ( $variation_id > 0 ) { 334 341 $variation_product = wc_get_product( $variation_id ); … … 345 352 continue; 346 353 } 347 $added = self::add_line_to_wc_cart( $product_id, $quantity, $variation_id );354 $added = self::add_line_to_wc_cart( $product_id, $quantity, $variation_id ); 348 355 $sync_attempt[] = array( 349 356 'product_id' => $product_id, … … 394 401 * @return string|false Cart item key. 395 402 */ 403 /** Per-line cap for public cart routes; stock limits still apply in Woo. */ 404 private const MAX_LINE_QUANTITY = 99; 405 406 /** 407 * @param mixed $raw Quantity from a request. 408 */ 409 private static function clamp_line_quantity( $raw ): int { 410 $quantity = is_numeric( $raw ) ? (int) $raw : 1; 411 return min( self::MAX_LINE_QUANTITY, max( 1, $quantity ) ); 412 } 413 396 414 private static function add_line_to_wc_cart( int $product_id, int $quantity, int $variation_id ) { 397 415 if ( ! function_exists( 'WC' ) || ! WC()->cart instanceof WC_Cart ) { … … 517 535 $product_id = isset( $input['product_id'] ) ? (int) $input['product_id'] : 0; 518 536 $variation_id = isset( $input['variation_id'] ) ? (int) $input['variation_id'] : 0; 519 $quantity = isset( $input['quantity'] ) ? max( 1, (int) $input['quantity'] ) : 1;537 $quantity = self::clamp_line_quantity( $input['quantity'] ?? 1 ); 520 538 521 539 if ( $session_key === '' ) { … … 1014 1032 } 1015 1033 1016 remove_all_actions( 'woocommerce_cart_emptied' ); 1017 WC()->cart->empty_cart( false ); 1034 self::empty_cart_keeping_session(); 1018 1035 1019 1036 foreach ( $snapshot as $cart_item ) { … … 1027 1044 continue; 1028 1045 } 1029 self::add_line_to_wc_cart( $product_id, $quantity, $variation_id ); 1046 // Re-add the line exactly as it was: the saved attribute choices 1047 // ("Any size" variations have no fixed attributes) and any add-on 1048 // data. Rebuilding from get_variation_attributes() drops both. 1049 $variation = isset( $cart_item['variation'] ) && is_array( $cart_item['variation'] ) 1050 ? $cart_item['variation'] 1051 : array(); 1052 $item_data = array_diff_key( 1053 $cart_item, 1054 array_flip( self::CORE_CART_ITEM_KEYS ) 1055 ); 1056 try { 1057 WC()->cart->add_to_cart( $product_id, $quantity, $variation_id, $variation, $item_data ); 1058 } catch ( \Exception $exception ) { 1059 // Leave this line out; restoring the rest beats an empty cart. 1060 unset( $exception ); 1061 } 1062 } 1063 } 1064 1065 /** Keys Woo sets on every cart item; everything else is extension data. */ 1066 private const CORE_CART_ITEM_KEYS = array( 1067 'key', 1068 'product_id', 1069 'variation_id', 1070 'variation', 1071 'quantity', 1072 'data', 1073 'data_hash', 1074 'line_tax_data', 1075 'line_subtotal', 1076 'line_subtotal_tax', 1077 'line_total', 1078 'line_tax', 1079 ); 1080 1081 /** 1082 * empty_cart() fires woocommerce_cart_emptied, whose Woo callback destroys 1083 * the cart session. Detach only that callback for this call; other 1084 * plugins' listeners keep running. 1085 */ 1086 private static function empty_cart_keeping_session(): void { 1087 global $wp_filter; 1088 1089 $detached = array(); 1090 $hook = $wp_filter['woocommerce_cart_emptied'] ?? null; 1091 if ( $hook instanceof WP_Hook ) { 1092 foreach ( $hook->callbacks as $priority => $callbacks ) { 1093 foreach ( $callbacks as $callback ) { 1094 $function = $callback['function'] ?? null; 1095 if ( 1096 is_array( $function ) 1097 && isset( $function[0], $function[1] ) 1098 && $function[0] instanceof WC_Cart_Session 1099 && $function[1] === 'destroy_cart_session' 1100 ) { 1101 remove_action( 'woocommerce_cart_emptied', $function, (int) $priority ); 1102 $detached[] = array( $function, (int) $priority, (int) ( $callback['accepted_args'] ?? 1 ) ); 1103 } 1104 } 1105 } 1106 } 1107 1108 WC()->cart->empty_cart( false ); 1109 1110 foreach ( $detached as $entry ) { 1111 add_action( 'woocommerce_cart_emptied', $entry[0], $entry[1], $entry[2] ); 1030 1112 } 1031 1113 } -
agentmatica-mcp-connector/trunk/includes/modules/woocommerce/class-order-guards.php
r3766293 r3771056 90 90 91 91 public static function order_owned_by_session( WC_Order $order, array $verified ): bool { 92 $wp_user_id = isset( $verified['wp_user_id'] ) ? (int) $verified['wp_user_id'] : 0; 93 if ( $wp_user_id > 0 ) { 94 // Logged-in sessions own orders through the account only. WooCommerce 95 // does not verify account emails, so matching the billing email would 96 // let anyone register with a guest shopper's email and act on their orders. 97 $customer_id = (int) $order->get_customer_id(); 98 return $customer_id > 0 && $customer_id === $wp_user_id; 99 } 100 92 101 $email = strtolower( trim( (string) ( $verified['email'] ?? '' ) ) ); 93 102 if ( $email !== '' && Agentmatica_WooCommerce_Orders::email_matches_order( $order, $email ) ) { 94 103 return true; 95 }96 97 $wp_user_id = isset( $verified['wp_user_id'] ) ? (int) $verified['wp_user_id'] : 0;98 if ( $wp_user_id > 0 ) {99 $customer_id = (int) $order->get_customer_id();100 if ( $customer_id > 0 && $customer_id === $wp_user_id ) {101 return true;102 }103 104 } 104 105 -
agentmatica-mcp-connector/trunk/includes/modules/woocommerce/class-order-mutations.php
r3766293 r3771056 27 27 } 28 28 29 $login_required = Agentmatica_WooCommerce_Order_Guards::require_verified_customer_session( $verified ); 29 // Cancel and return need a logged-in store account (order number + email 30 // is printed on packing slips and emails; app is sign-in only too). 31 $login_required = Agentmatica_WooCommerce_Order_Guards::require_logged_in_session( $verified ); 30 32 if ( $login_required !== null ) { 31 33 return $login_required; … … 82 84 $refund_amount = (float) $order->get_total(); 83 85 if ( $refund_amount > 0 ) { 84 wc_create_refund( 86 // refund_payment sends the money back through the gateway. Without it 87 // WooCommerce only records a refund and the shopper is never paid. 88 $refund = wc_create_refund( 85 89 array( 86 'amount' => $refund_amount, 87 'reason' => __( 'Order cancelled via Agentmatica chat.', 'agentmatica-mcp-connector' ), 88 'order_id' => (int) $order->get_id(), 90 'amount' => $refund_amount, 91 'reason' => __( 'Order cancelled via Agentmatica chat.', 'agentmatica-mcp-connector' ), 92 'order_id' => (int) $order->get_id(), 93 'refund_payment' => true, 89 94 ) 90 95 ); 96 if ( is_wp_error( $refund ) ) { 97 $order->add_order_note( 98 sprintf( 99 /* translators: %s: gateway error message */ 100 __( 'Agentmatica cancelled this order but the automatic refund failed (%s). Please refund the customer manually.', 'agentmatica-mcp-connector' ), 101 $refund->get_error_message() 102 ) 103 ); 104 } 91 105 } 92 106 } … … 200 214 } 201 215 202 $login_required = Agentmatica_WooCommerce_Order_Guards::require_verified_customer_session( $verified ); 216 // Cancel and return need a logged-in store account (order number + email 217 // is printed on packing slips and emails; app is sign-in only too). 218 $login_required = Agentmatica_WooCommerce_Order_Guards::require_logged_in_session( $verified ); 203 219 if ( $login_required !== null ) { 204 220 return $login_required; … … 228 244 if ( $ineligible !== null ) { 229 245 return self::refused( 'create_return', $ineligible ); 246 } 247 248 if ( (string) $order->get_meta( '_agentmatica_return_request' ) !== '' ) { 249 return self::success( 250 'create_return', 251 __( 'A return has already been requested for this order.', 'agentmatica-mcp-connector' ), 252 'already_done', 253 (int) $order->get_id() 254 ); 230 255 } 231 256 -
agentmatica-mcp-connector/trunk/includes/modules/woocommerce/class-orders.php
r3736423 r3771056 18 18 19 19 private const VERIFY_TRANSIENT_PREFIX = 'agentmatica_verify_'; 20 private const VERIFY_TTL = 45 * MINUTE_IN_SECONDS;20 private const VERIFY_TTL = 15 * MINUTE_IN_SECONDS; 21 21 private const RATE_SESSION_PREFIX = 'agentmatica_verify_rate_session_'; 22 22 private const RATE_IP_PREFIX = 'agentmatica_verify_rate_ip_'; 23 private const RATE_ORDER_PREFIX = 'agentmatica_verify_rate_order_'; 24 private const MAX_ATTEMPTS_ORDER = 5; 25 private const USER_SESSIONS_META = '_agentmatica_chat_sessions'; 23 26 private const MAX_ATTEMPTS_SESSION = 5; 24 27 private const MAX_ATTEMPTS_IP = 20; … … 27 30 public static function init(): void { 28 31 // Ability callbacks are registered via the WooCommerce module registry. 32 add_action( 'wp_logout', array( __CLASS__, 'clear_sessions_for_user' ) ); 33 } 34 35 /** 36 * Drop every chat binding for a user when they log out, so the next person on 37 * a shared device cannot act on their orders through the same widget session. 38 * 39 * @param int $user_id Logged-out user id (passed by wp_logout since WP 5.5). 40 */ 41 public static function clear_sessions_for_user( $user_id = 0 ): void { 42 $user_id = (int) $user_id; 43 if ( $user_id <= 0 ) { 44 return; 45 } 46 $keys = get_user_meta( $user_id, self::USER_SESSIONS_META, true ); 47 if ( is_array( $keys ) ) { 48 foreach ( $keys as $key ) { 49 delete_transient( self::VERIFY_TRANSIENT_PREFIX . Agentmatica_WooCommerce_Cart::sanitize_session_key( (string) $key ) ); 50 } 51 } 52 delete_user_meta( $user_id, self::USER_SESSIONS_META ); 29 53 } 30 54 … … 71 95 } 72 96 73 if ( self::is_rate_limited( $session_key ) ) {97 if ( self::is_rate_limited( $session_key ) || self::is_order_rate_limited( $order_number ) ) { 74 98 return array( 75 99 'success' => false, … … 79 103 80 104 self::record_attempt( $session_key ); 105 self::record_order_attempt( $order_number ); 81 106 82 107 $order = self::find_order_by_number( $order_number ); … … 126 151 } 127 152 128 $email = (string) ( $verified['email'] ?? '' ); 129 if ( $email === '' ) { 153 $wp_user_id = isset( $verified['wp_user_id'] ) ? (int) $verified['wp_user_id'] : 0; 154 $email = (string) ( $verified['email'] ?? '' ); 155 if ( $wp_user_id <= 0 && $email === '' ) { 130 156 return array( 131 157 'success' => false, … … 134 160 } 135 161 136 $orders = wc_get_orders( 137 array( 138 'limit' => 5, 139 'billing_email' => $email, 140 'orderby' => 'date', 141 'order' => 'DESC', 142 'return' => 'objects', 143 ) 144 ); 162 $query = array( 163 'limit' => 5, 164 'orderby' => 'date', 165 'order' => 'DESC', 166 'return' => 'objects', 167 ); 168 if ( $wp_user_id > 0 ) { 169 $query['customer_id'] = $wp_user_id; 170 } else { 171 $query['billing_email'] = $email; 172 } 173 $orders = wc_get_orders( $query ); 145 174 146 175 return array( … … 182 211 } 183 212 184 $email = (string) ( $verified['email'] ?? '' ); 185 if ( ! self::email_matches_order( $order, $email ) ) { 213 if ( ! Agentmatica_WooCommerce_Order_Guards::order_owned_by_session( $order, $verified ) ) { 186 214 return array( 187 215 'success' => false, … … 226 254 ); 227 255 } 256 257 $sessions = get_user_meta( $wp_user_id, self::USER_SESSIONS_META, true ); 258 $sessions = is_array( $sessions ) ? $sessions : array(); 259 $sessions = array_slice( array_values( array_unique( array_merge( array( $session_key ), $sessions ) ) ), 0, 20 ); 260 update_user_meta( $wp_user_id, self::USER_SESSIONS_META, $sessions ); 228 261 229 262 self::bind_verified_session( … … 325 358 set_transient( $ip_key, $ip_count + 1, HOUR_IN_SECONDS ); 326 359 } 360 } 361 362 /** 363 * Attempts per order number, across all chat sessions. Requests come from the 364 * Agentmatica server, so REMOTE_ADDR cannot tell shoppers apart and a new 365 * session_key resets the per-session count; this caps guessing on one order. 366 */ 367 private static function is_order_rate_limited( string $order_number ): bool { 368 $key = self::RATE_ORDER_PREFIX . md5( strtolower( ltrim( trim( $order_number ), '#' ) ) ); 369 return (int) get_transient( $key ) >= self::MAX_ATTEMPTS_ORDER; 370 } 371 372 private static function record_order_attempt( string $order_number ): void { 373 $key = self::RATE_ORDER_PREFIX . md5( strtolower( ltrim( trim( $order_number ), '#' ) ) ); 374 set_transient( $key, (int) get_transient( $key ) + 1, HOUR_IN_SECONDS ); 327 375 } 328 376 -
agentmatica-mcp-connector/trunk/readme.txt
r3766293 r3771056 5 5 Tested up to: 7.1 6 6 Requires PHP: 8.0 7 Stable tag: 0.7. 37 Stable tag: 0.7.5 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 97 97 == Changelog == 98 98 99 = 0.7.5 = 100 * Security: a signed-in shopper can only see and change orders placed from their own account. Orders are no longer matched by billing email for signed-in shoppers, because store accounts are not email-verified. 101 * Security: logging out of the store ends the shopper's verified chat session, and a verified chat session now lasts 15 minutes instead of 45. 102 * Security: a customer token only links a chat to an account once Agentmatica has verified it, not when the token is issued. 103 * Security: order-number lookups are limited per order number, so guessing the email for one order is capped. 104 * Security: the storefront cart routes only accept requests from the store's own pages. 105 * Cancelling an order with a refund now sends the money back through the payment gateway. If the gateway cannot refund automatically, the order gets a note asking you to refund manually. 106 * Asking for a return twice on the same order no longer emails you twice. 107 108 = 0.7.4 = 109 * Cancel and return now require the shopper to be signed in to their store account, the same as shipping-address changes. Guests are passed to your team. 110 * The customer-token route only accepts a fetch from the widget: images and other embedded requests on the store are refused, and an invalid nonce is never ignored. 111 * The widget sends shoppers to your real My Account page, even if it is renamed, translated or in a subfolder. 112 * Cart sync keeps each item's chosen options and add-on data, and other plugins' cart hooks keep working. 113 * Cart routes cap a single line at 99. 114 * Also in 0.7.3 but missing from its notes: catalog search matches plural words ("screens" finds "screen"), chat cart adds respect stock, and the chat cart stays in sync with the store cart. 115 99 116 = 0.7.3 = 100 117 * Logged-in shoppers can get a customer token from the storefront widget. The route requires a `wp_rest` nonce or a same-origin fetch (not a clicked link). … … 147 164 == Upgrade Notice == 148 165 166 = 0.7.4 = 167 Cancel and return now need a signed-in store account. Safer customer-token checks, real My Account links, and cart sync that keeps item options. 168 149 169 = 0.7.3 = 150 170 Logged-in shoppers can be identified in chat; checkout links use the WooCommerce checkout URL. Cancel/return can use order number + email; address changes stay login-only.
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)