api_url = AGENTORIE_CHAT_API_URL; $this->license_key = agentorie_chat_get_setting( 'license_key', '' ); $this->load_dependencies(); $this->register_ajax_hooks(); $this->register_rest_hooks(); } /** * Load handler classes. * * @return void */ private function load_dependencies() { require_once AGENTORIE_CHAT_PLUGIN_DIR . 'includes/class-agentorie-api-helpers.php'; require_once AGENTORIE_CHAT_PLUGIN_DIR . 'includes/class-agentorie-api-saas.php'; require_once AGENTORIE_CHAT_PLUGIN_DIR . 'includes/class-agentorie-api-public.php'; require_once AGENTORIE_CHAT_PLUGIN_DIR . 'includes/class-agentorie-api-admin.php'; } /** * Register REST API hooks. * * @return void */ private function register_rest_hooks() { add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) ); // Bypass REST API restrictions for SaaS endpoints. // This ensures content indexing works even if security plugins block the REST API. // These endpoints have their own security via HMAC signature verification. add_filter( 'rest_authentication_errors', array( $this, 'allow_saas_endpoints' ), 1 ); } /** * Allow SaaS endpoints to bypass REST API restrictions. * * Security plugins like Wordfence, iThemes Security, or "Disable REST API" * may block REST API access for non-authenticated users. This filter * ensures our SaaS endpoints remain accessible since they have their own * security via HMAC signature verification. * * Why this is necessary: * - SaaS needs to fetch indexed content even if REST is blocked * - The endpoints have their own security: HMAC-SHA256 signature + active license required * * Why this is safe: * - Only bypasses for requests to our specific routes with signature header * - Actual signature verification happens in verify_saas_signature() * - We do NOT alter authentication for any other endpoint * * @since 1.0.0 * @param WP_Error|null|true $result Authentication result. * @return WP_Error|null|true */ public function allow_saas_endpoints( $result ) { // Only intervene if REST API is being blocked. if ( true === $result || null === $result ) { return $result; } // Require signature header to be present (actual verification in verify_saas_signature). if ( empty( $_SERVER['HTTP_X_AGENTORIE_SIGNATURE'] ) ) { return $result; } // List of SaaS endpoints to allow. $allowed_endpoints = array( 'get-indexed-content', 'search-content', ); // Check REST route parameter (for sites using ?rest_route= format). // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only route check. if ( isset( $_GET['rest_route'] ) ) { $rest_route = sanitize_text_field( wp_unslash( $_GET['rest_route'] ) ); // phpcs:enable WordPress.Security.NonceVerification.Recommended foreach ( $allowed_endpoints as $endpoint ) { if ( preg_match( '#^/agentorie-chat/v1/' . preg_quote( $endpoint, '#' ) . '/?$#', $rest_route ) ) { return true; } } } // Check REQUEST_URI (for sites using pretty permalinks /wp-json/). // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only used for route matching. $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; foreach ( $allowed_endpoints as $endpoint ) { if ( preg_match( '#/wp-json/agentorie-chat/v1/' . preg_quote( $endpoint, '#' ) . '/?(\?|$)#', $request_uri ) ) { return true; } } return $result; } /** * Register REST API routes. * * @return void */ public function register_rest_routes() { register_rest_route( 'agentorie-chat/v1', '/search-content', array( 'methods' => 'POST', 'callback' => array( Agentorie_API_SaaS::get_instance(), 'handle_search_content' ), 'permission_callback' => array( $this, 'verify_saas_signature' ), ) ); // Get all indexed content for AI injection. // Called by SaaS to retrieve full content instead of keyword search. register_rest_route( 'agentorie-chat/v1', '/get-indexed-content', array( 'methods' => 'GET', 'callback' => array( Agentorie_API_SaaS::get_instance(), 'handle_get_indexed_content' ), 'permission_callback' => array( $this, 'verify_saas_signature' ), ) ); // Get indexed WooCommerce products with enriched data for AI injection. // Called by SaaS to retrieve products separately from pages. register_rest_route( 'agentorie-chat/v1', '/get-indexed-products', array( 'methods' => 'GET', 'callback' => array( Agentorie_API_SaaS::get_instance(), 'handle_get_indexed_products' ), 'permission_callback' => array( $this, 'verify_saas_signature' ), ) ); // Public nonce refresh endpoint (non-cacheable). // Used by the widget when cached pages have expired nonces. register_rest_route( 'agentorie-chat/v1', '/refresh-nonce', array( 'methods' => 'GET', 'callback' => array( $this, 'handle_refresh_nonce' ), 'permission_callback' => '__return_true', ) ); // Note: /webhook route is registered in class-webhook.php. } /** * Handle nonce refresh request. * * Returns a fresh nonce for the widget. This endpoint is intentionally * not cached and should be called when the widget detects an expired nonce. * * @param WP_REST_Request $request The request object. * @return WP_REST_Response Response with fresh nonce. */ public function handle_refresh_nonce( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Required by WP REST API callback signature. // Rate limit: 10 requests per minute per IP. $client_ip = Agentorie_Session::get_client_ip(); if ( ! Agentorie_API_Helpers::check_rate_limit( 'nonce_refresh_' . $client_ip, 10, 60 ) ) { return new WP_REST_Response( array( 'success' => false, 'message' => __( 'Too many requests. Please wait.', 'agentorie-chat' ), ), 429 ); } // Generate fresh nonce. $nonce = wp_create_nonce( 'agentorie_chat_public_nonce' ); // Send no-cache headers. nocache_headers(); return new WP_REST_Response( array( 'success' => true, 'nonce' => $nonce, ), 200 ); } /** * Verify HMAC signature from SaaS. * * Validates that incoming requests from the SaaS server are authentic * by verifying the HMAC-SHA256 signature using the license key as secret. * * SECURITY: This method protects REST API endpoints from unauthorized access. * It ensures that only the SaaS backend (which knows the license key) can * make authenticated requests to protected endpoints like /search-content. * * @param WP_REST_Request $request The request object. * @return bool True if signature is valid. */ public function verify_saas_signature( $request ) { $signature = $request->get_header( 'X-Agentorie-Signature' ); // 1. No signature provided → reject if ( empty( $signature ) ) { return false; } // 2. CRITICAL: License key is empty → reject // Without this check, an attacker could calculate hash_hmac with empty string // and bypass authentication on non-activated installations. if ( empty( $this->license_key ) ) { return false; } // 3. License is not active → reject // Additional security layer: even with valid signature, require active license. $license_status = agentorie_chat_get_setting( 'license_status', 'inactive' ); if ( 'active' !== $license_status ) { return false; } // 4. Verify HMAC signature using license key as shared secret $body = $request->get_body(); $expected = hash_hmac( 'sha256', $body, $this->license_key ); return hash_equals( $expected, $signature ); } /** * Register AJAX hooks. * * @return void */ private function register_ajax_hooks() { $public = Agentorie_API_Public::get_instance(); $admin = Agentorie_API_Admin::get_instance(); // Nonce refresh - no nonce required (that's the point!). add_action( 'wp_ajax_agentorie_chat_refresh_nonce', array( $this, 'handle_ajax_refresh_nonce' ) ); add_action( 'wp_ajax_nopriv_agentorie_chat_refresh_nonce', array( $this, 'handle_ajax_refresh_nonce' ) ); // Public AJAX actions (logged-in and non-logged-in users). add_action( 'wp_ajax_agentorie_chat_send_message', array( $public, 'handle_send_message' ) ); add_action( 'wp_ajax_nopriv_agentorie_chat_send_message', array( $public, 'handle_send_message' ) ); add_action( 'wp_ajax_agentorie_chat_get_history', array( $public, 'handle_get_history' ) ); add_action( 'wp_ajax_nopriv_agentorie_chat_get_history', array( $public, 'handle_get_history' ) ); add_action( 'wp_ajax_agentorie_chat_send_contact', array( $public, 'handle_send_contact' ) ); add_action( 'wp_ajax_nopriv_agentorie_chat_send_contact', array( $public, 'handle_send_contact' ) ); add_action( 'wp_ajax_agentorie_chat_speech_to_text', array( $public, 'handle_speech_to_text' ) ); add_action( 'wp_ajax_nopriv_agentorie_chat_speech_to_text', array( $public, 'handle_speech_to_text' ) ); add_action( 'wp_ajax_agentorie_chat_text_to_speech', array( $public, 'handle_text_to_speech' ) ); add_action( 'wp_ajax_nopriv_agentorie_chat_text_to_speech', array( $public, 'handle_text_to_speech' ) ); add_action( 'wp_ajax_agentorie_chat_get_ws_token', array( $public, 'handle_get_ws_token' ) ); add_action( 'wp_ajax_nopriv_agentorie_chat_get_ws_token', array( $public, 'handle_get_ws_token' ) ); add_action( 'wp_ajax_agentorie_chat_save_message', array( $public, 'handle_save_message' ) ); add_action( 'wp_ajax_nopriv_agentorie_chat_save_message', array( $public, 'handle_save_message' ) ); add_action( 'wp_ajax_agentorie_chat_complete_onboarding', array( $public, 'handle_complete_onboarding' ) ); add_action( 'wp_ajax_nopriv_agentorie_chat_complete_onboarding', array( $public, 'handle_complete_onboarding' ) ); add_action( 'wp_ajax_agentorie_chat_translate', array( $public, 'handle_translate' ) ); add_action( 'wp_ajax_nopriv_agentorie_chat_translate', array( $public, 'handle_translate' ) ); // Admin-only AJAX actions. add_action( 'wp_ajax_agentorie_chat_admin_presence_ping', array( $admin, 'handle_presence_ping' ) ); add_action( 'wp_ajax_agentorie_chat_admin_get_conversations', array( $admin, 'handle_get_conversations' ) ); add_action( 'wp_ajax_agentorie_chat_admin_get_messages', array( $admin, 'handle_get_messages' ) ); add_action( 'wp_ajax_agentorie_chat_admin_send_message', array( $admin, 'handle_send_message' ) ); add_action( 'wp_ajax_agentorie_chat_admin_takeover', array( $admin, 'handle_takeover' ) ); add_action( 'wp_ajax_agentorie_chat_admin_release', array( $admin, 'handle_release' ) ); add_action( 'wp_ajax_agentorie_chat_admin_presence', array( $admin, 'handle_presence' ) ); add_action( 'wp_ajax_agentorie_chat_admin_update_flag', array( $admin, 'handle_update_flag' ) ); add_action( 'wp_ajax_agentorie_chat_admin_mark_read', array( $admin, 'handle_mark_read' ) ); add_action( 'wp_ajax_agentorie_chat_admin_delete_conversation', array( $admin, 'handle_delete_conversation' ) ); } /** * Handle AJAX nonce refresh request. * * This uses admin-ajax.php which properly handles WordPress cookies/auth. * * @return void */ public function handle_ajax_refresh_nonce() { // Rate limit: 10 requests per minute per IP. $client_ip = Agentorie_Session::get_client_ip(); if ( ! Agentorie_API_Helpers::check_rate_limit( 'nonce_refresh_' . $client_ip, 10, 60 ) ) { wp_send_json_error( array( 'message' => __( 'Too many requests. Please wait.', 'agentorie-chat' ) ), 429 ); return; } // Generate fresh nonce. $nonce = wp_create_nonce( 'agentorie_chat_public_nonce' ); // Send no-cache headers. nocache_headers(); wp_send_json_success( array( 'nonce' => $nonce ) ); } /** * Send message to SaaS. * * @param string $session_id Session ID. * @param string $message Message content. * @param array $context Context data. * @param bool $exclude_from_stats Exclude from stats. * @return array|WP_Error Response or error. */ public function send_message( $session_id, $message, $context = array(), $exclude_from_stats = false ) { return Agentorie_API_SaaS::get_instance()->send_message( $session_id, $message, $context, $exclude_from_stats ); } /** * Sync config with SaaS. * * @param array $config Configuration data. * @return array|WP_Error Response or error. */ public function sync_config( $config = array() ) { return Agentorie_API_SaaS::get_instance()->sync_config( $config ); } /** * Complete onboarding. * * @return array|WP_Error Response or error. */ public function complete_onboarding() { return Agentorie_API_SaaS::get_instance()->complete_onboarding(); } /** * Activate license. * * @param string $license_key License key. * @return array|WP_Error Response or error. */ public static function activate_license( $license_key ) { return Agentorie_API_SaaS::activate_license( $license_key ); } /** * Deactivate license. * * @param string $license_key License key. * @return array|WP_Error Response or error. */ public static function deactivate_license( $license_key = '' ) { return Agentorie_API_SaaS::deactivate_license( $license_key ); } /** * Check license status. * * @param string $license_key License key. * @param bool $force_refresh Force refresh (bypass cache). * @return array|WP_Error Response or error. */ public static function check_license( $license_key, $force_refresh = false ) { return Agentorie_API_SaaS::check_license( $license_key, $force_refresh ); } /** * Send admin message. * * @param string $session_id Session ID. * @param string $message Message content. * @param int $admin_id Admin user ID. * @return array|WP_Error Response or error. */ public function send_admin_message( $session_id, $message, $admin_id ) { return Agentorie_API_SaaS::get_instance()->send_admin_message( $session_id, $message, $admin_id ); } /** * Takeover conversation. * * @param string $session_id Session ID. * @param int $admin_id Admin user ID. * @return array|WP_Error Response or error. */ public function takeover_conversation( $session_id, $admin_id ) { return Agentorie_API_SaaS::get_instance()->takeover_conversation( $session_id, $admin_id ); } /** * Release conversation. * * @param string $session_id Session ID. * @param int $admin_id Admin user ID. * @return array|WP_Error Response or error. */ public function release_conversation( $session_id, $admin_id ) { return Agentorie_API_SaaS::get_instance()->release_conversation( $session_id, $admin_id ); } /** * Update presence. * * @param int $admin_id Admin user ID. * @param string $admin_name Admin display name. * @param bool $online Online status. * @return array|WP_Error Response or error. */ public function update_presence( $admin_id, $admin_name, $online ) { return Agentorie_API_SaaS::get_instance()->update_presence( $admin_id, $admin_name, $online ); } /** * Speech to text. * * @param string $audio_data Base64 audio data. * @param string $language Language code. * @return array|WP_Error Response or error. */ public function speech_to_text( $audio_data, $language = 'fr-FR' ) { return Agentorie_API_SaaS::get_instance()->speech_to_text( $audio_data, $language ); } /** * Text to speech. * * @param string $text Text content. * @param string $language Language code. * @return array|WP_Error Response or error. */ public function text_to_speech( $text, $language = 'fr-FR' ) { return Agentorie_API_SaaS::get_instance()->text_to_speech( $text, $language ); } /** * Check if API is reachable. * * @return bool True if reachable. */ public function is_api_reachable() { return Agentorie_API_SaaS::get_instance()->is_api_reachable(); } /** * Invalidate license cache. * * Forces a fresh license check on next request. * * @return void */ public static function invalidate_license_cache() { delete_transient( 'agentorie_chat_license_cache' ); } }