'application/json' ); if ( '' !== $credential ) { $headers['Authorization'] = 'Bearer ' . $credential; } $args = array( 'method' => $method, 'timeout' => 20, 'redirection' => 0, 'headers' => $headers, 'limit_response_size' => 32768 ); if ( 'POST' === $method ) { $args['headers']['Content-Type'] = 'application/json'; $args['body'] = wp_json_encode( $body ); } $local_host_filter = static function ( $external, $host, $url ) use ( $base ) { return wp_parse_url( $base, PHP_URL_HOST ) === $host && 0 === strpos( $url, $base . '/' ) ? true : $external; }; $local_ports_filter = static function ( $ports ) use ( $base ) { $port = wp_parse_url( $base, PHP_URL_PORT ); if ( $port ) { $ports[] = $port; } return $ports; }; if ( call_talk_widget_allows_local_http() ) { add_filter( 'http_request_host_is_external', $local_host_filter, 10, 3 ); add_filter( 'http_allowed_safe_ports', $local_ports_filter ); } try { $response = 'POST' === $method ? wp_safe_remote_post( $base . $path, $args ) : wp_safe_remote_request( $base . $path, $args ); } finally { remove_filter( 'http_request_host_is_external', $local_host_filter, 10 ); remove_filter( 'http_allowed_safe_ports', $local_ports_filter ); } return $response; } function call_talk_widget_random_secret(): string { return rtrim( strtr( base64_encode( random_bytes( 32 ) ), '+/', '-_' ), '=' ); } function call_talk_widget_pending_key(): string { return 'call_talk_widget_connect_' . get_current_user_id(); } /** Returns an authorization URL; activation never calls this function. */ function call_talk_widget_start_connection() { if ( ! current_user_can( 'manage_options' ) ) { return new WP_Error( 'permission', __( 'Only a WordPress administrator can connect this website.', 'call-talk-widget' ) ); } $base = call_talk_widget_service_base(); $site = site_url( '/' ); if ( '' === $base || ( 'https' !== wp_parse_url( $site, PHP_URL_SCHEME ) && ! call_talk_widget_allows_local_http() ) ) { return new WP_Error( 'https_required', __( 'Use HTTPS for this website and the Call Talk service before connecting.', 'call-talk-widget' ) ); } $state = call_talk_widget_random_secret(); $verifier = call_talk_widget_random_secret(); $pending = array( 'user_id' => get_current_user_id(), 'state' => $state, 'verifier' => $verifier, 'session_hash' => hash( 'sha256', wp_get_session_token() ), 'base' => $base, 'site_url' => $site, 'expires_at' => time() + CALL_TALK_WIDGET_CONNECT_TTL, ); set_transient( call_talk_widget_pending_key(), $pending, CALL_TALK_WIDGET_CONNECT_TTL ); $response = call_talk_widget_service_request( $base, '/api/integrations/wordpress/requests', 'POST', array( 'site_url' => $site, 'callback_url' => admin_url( 'admin-post.php?action=call_talk_widget_connect_callback' ), 'state' => $state, 'code_challenge' => rtrim( strtr( base64_encode( hash( 'sha256', $verifier, true ) ), '+/', '-_' ), '=' ), ) ); if ( is_wp_error( $response ) || 201 !== wp_remote_retrieve_response_code( $response ) ) { delete_transient( call_talk_widget_pending_key() ); return new WP_Error( 'request_failed', __( 'Call Talk could not start the connection. Check the service address and try again.', 'call-talk-widget' ) ); } $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! is_array( $data ) || ! isset( $data['request_id'], $data['authorization_url'] ) || ! is_string( $data['request_id'] ) || ! preg_match( '/^[A-Za-z0-9-]{1,128}$/', $data['request_id'] ) || $base . '/integrations/wordpress/connect/' . rawurlencode( $data['request_id'] ) !== $data['authorization_url'] ) { delete_transient( call_talk_widget_pending_key() ); return new WP_Error( 'invalid_authorization', __( 'Call Talk returned an unexpected authorization address. No connection was saved. Try again.', 'call-talk-widget' ) ); } return $data['authorization_url']; } /** Consume state before exchange, so the callback cannot be replayed. */ function call_talk_widget_complete_connection( string $state, string $code, string $error = '' ) { if ( ! current_user_can( 'manage_options' ) ) { return new WP_Error( 'permission', __( 'Only a WordPress administrator can complete this connection.', 'call-talk-widget' ) ); } $pending = get_transient( call_talk_widget_pending_key() ); if ( ! is_array( $pending ) || ! isset( $pending['state'], $pending['verifier'], $pending['user_id'], $pending['session_hash'], $pending['expires_at'], $pending['base'], $pending['site_url'] ) || get_current_user_id() !== $pending['user_id'] || time() >= $pending['expires_at'] || ! hash_equals( $pending['session_hash'], hash( 'sha256', wp_get_session_token() ) ) || ! hash_equals( $pending['state'], $state ) ) { return new WP_Error( 'invalid_state', __( 'This connection link has expired or belongs to another WordPress session. Start Connect to Call Talk again.', 'call-talk-widget' ) ); } delete_transient( call_talk_widget_pending_key() ); if ( '' !== $error ) { return new WP_Error( 'access_denied', __( 'The connection was cancelled in Call Talk. Your existing settings were kept.', 'call-talk-widget' ) ); } if ( ! preg_match( '/^[A-Za-z0-9_-]{20,256}$/', $code ) ) { return new WP_Error( 'invalid_code', __( 'Call Talk did not return a valid connection code. Start the connection again.', 'call-talk-widget' ) ); } $response = call_talk_widget_service_request( $pending['base'], '/api/integrations/wordpress/token', 'POST', array( 'code' => $code, 'code_verifier' => $pending['verifier'] ) ); if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { return new WP_Error( 'exchange_failed', __( 'Call Talk could not finish the connection. The code may have expired. Start the connection again.', 'call-talk-widget' ) ); } $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! call_talk_widget_valid_connection_response( $data, $pending ) ) { return new WP_Error( 'invalid_connection', __( 'Call Talk returned an unexpected connection response. No settings were changed. Try connecting again.', 'call-talk-widget' ) ); } $previous_connection = call_talk_widget_connection(); $connection = array( 'connection_id' => $data['connection_id'], 'credential' => $data['connection_token'], 'base' => $pending['base'], 'site' => $data['site'], 'account' => $data['account'], 'dashboard_url' => $data['dashboard_url'], 'connected_at' => time(), ); // Restricted service credentials are never registered as Settings API or REST options. update_option( CALL_TALK_WIDGET_CONNECTION_KEY, $connection, false ); update_option( CALL_TALK_WIDGET_TOKEN_KEY, $data['widget_token'] ); update_option( CALL_TALK_WIDGET_COMPANY_KEY, '' ); update_option( CALL_TALK_WIDGET_SCRIPT_URL_KEY, $pending['base'] . '/widget.js' ); update_option( CALL_TALK_WIDGET_CONSENT_KEY, 1 ); update_option( CALL_TALK_WIDGET_ENABLED_KEY, 1 ); if ( isset( $previous_connection['connection_id'], $previous_connection['base'], $previous_connection['credential'] ) && $previous_connection['connection_id'] !== $connection['connection_id'] && is_string( $previous_connection['base'] ) && is_string( $previous_connection['credential'] ) ) { // A different company/service gets a new connection. Revoke only on the old service origin. // Cleanup failure must not undo the newly approved, successfully saved connection. call_talk_widget_service_request( $previous_connection['base'], '/api/integrations/wordpress/connection', 'DELETE', array(), $previous_connection['credential'] ); } return true; } function call_talk_widget_valid_connection_response( $data, array $pending ): bool { if ( ! is_array( $data ) || ! isset( $data['connection_id'], $data['connection_token'], $data['widget_token'], $data['site']['url'], $data['site']['domain'], $data['account']['company_id'], $data['account']['company_name'], $data['dashboard_url'] ) ) { return false; } foreach ( array( 'connection_id', 'connection_token', 'widget_token', 'dashboard_url' ) as $key ) { if ( ! is_string( $data[ $key ] ) || '' === $data[ $key ] ) { return false; } } return strlen( $data['connection_token'] ) <= 4096 && ! preg_match( '/[\x00-\x20\x7f]/', $data['connection_token'] ) && preg_match( '/^[A-Za-z0-9_-]{1,160}$/', $data['widget_token'] ) && is_string( $data['site']['url'] ) && rtrim( $data['site']['url'], '/' ) === rtrim( $pending['site_url'], '/' ) && is_string( $data['site']['domain'] ) && is_scalar( $data['account']['company_id'] ) && is_string( $data['account']['company_name'] ) && $pending['base'] . '/admin/widget-installation' === $data['dashboard_url']; } /** Disable locally immediately; retain a failed revocation credential for retry. */ function call_talk_widget_disconnect() { if ( ! current_user_can( 'manage_options' ) ) { return new WP_Error( 'permission', __( 'Only a WordPress administrator can disconnect this website.', 'call-talk-widget' ) ); } update_option( CALL_TALK_WIDGET_ENABLED_KEY, 0 ); update_option( CALL_TALK_WIDGET_CONSENT_KEY, 0 ); delete_transient( call_talk_widget_pending_key() ); $connection = call_talk_widget_connection(); if ( ! empty( $connection['credential'] ) && ! empty( $connection['base'] ) ) { $response = call_talk_widget_service_request( $connection['base'], '/api/integrations/wordpress/connection', 'DELETE', array(), $connection['credential'] ); if ( is_wp_error( $response ) || ! in_array( wp_remote_retrieve_response_code( $response ), array( 204, 401, 404 ), true ) ) { return new WP_Error( 'revoke_failed', __( 'The widget is off on this website, but Call Talk could not confirm disconnection. Retry Disconnect when the service is available.', 'call-talk-widget' ) ); } } delete_option( CALL_TALK_WIDGET_CONNECTION_KEY ); update_option( CALL_TALK_WIDGET_TOKEN_KEY, '' ); update_option( CALL_TALK_WIDGET_COMPANY_KEY, '' ); return true; } function call_talk_widget_require_post_admin( string $nonce_action ): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to manage this connection.', 'call-talk-widget' ), '', array( 'response' => 403 ) ); } $request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : ''; if ( 'POST' !== $request_method ) { wp_die( esc_html__( 'Submit the connection form from Call Talk settings.', 'call-talk-widget' ), '', array( 'response' => 405 ) ); } check_admin_referer( $nonce_action ); } function call_talk_widget_flash_and_redirect( $result, string $success ): void { set_transient( 'call_talk_widget_notice_' . get_current_user_id(), array( 'type' => is_wp_error( $result ) ? 'error' : 'success', 'message' => is_wp_error( $result ) ? $result->get_error_message() : $success ), 60 ); wp_safe_redirect( call_talk_widget_settings_url() ); exit; } function call_talk_widget_handle_connect(): void { call_talk_widget_require_post_admin( 'call_talk_widget_connect' ); // Nonce-protected POST is the only entry point that starts an external request. // phpcs:ignore WordPress.Security.NonceVerification.Missing -- The helper above verifies the connection nonce, administrator capability, and POST method. if ( ! isset( $_POST['call_talk_connect_consent'] ) || '1' !== $_POST['call_talk_connect_consent'] ) { call_talk_widget_flash_and_redirect( new WP_Error( 'consent', __( 'Review the service connection and select the permission checkbox before connecting.', 'call-talk-widget' ) ), '' ); } $result = call_talk_widget_start_connection(); if ( is_wp_error( $result ) ) { call_talk_widget_flash_and_redirect( $result, '' ); } $allow_host = static function ( $hosts ) use ( $result ) { $hosts[] = wp_parse_url( $result, PHP_URL_HOST ); return $hosts; }; add_filter( 'allowed_redirect_hosts', $allow_host ); wp_safe_redirect( $result ); remove_filter( 'allowed_redirect_hosts', $allow_host ); exit; } function call_talk_widget_handle_connect_callback(): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Sign in as the WordPress administrator who started this connection.', 'call-talk-widget' ), '', array( 'response' => 403 ) ); } nocache_headers(); header( 'Referrer-Policy: no-referrer' ); // The random, short-lived state and PKCE verifier replace a form nonce on this service callback. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Validated below using user-bound single-use state and the saved PKCE verifier. $state = isset( $_GET['state'] ) && is_string( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Validated below using user-bound single-use state and the saved PKCE verifier. $code = isset( $_GET['code'] ) && is_string( $_GET['code'] ) ? sanitize_text_field( wp_unslash( $_GET['code'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Validated below using user-bound single-use state and the saved PKCE verifier. $error = isset( $_GET['error'] ) && is_string( $_GET['error'] ) ? sanitize_text_field( wp_unslash( $_GET['error'] ) ) : ''; $result = call_talk_widget_complete_connection( $state, $code, $error ); call_talk_widget_flash_and_redirect( $result, __( 'Connected to Call Talk. The widget is enabled on this website.', 'call-talk-widget' ) ); } function call_talk_widget_handle_disconnect(): void { call_talk_widget_require_post_admin( 'call_talk_widget_disconnect' ); call_talk_widget_flash_and_redirect( call_talk_widget_disconnect(), __( 'Disconnected. The widget is off. Your Call Talk account and conversation history were kept.', 'call-talk-widget' ) ); } /** Render metadata only: the restricted connection credential stays server-side. */ function call_talk_widget_render_connection_panel(): void { $connection = call_talk_widget_connection(); $notice_key = 'call_talk_widget_notice_' . get_current_user_id(); $notice = get_transient( $notice_key ); if ( is_array( $notice ) && isset( $notice['type'], $notice['message'] ) ) { delete_transient( $notice_key ); ?>

·

·