__( 'Security token expired. Refreshing...', 'agentorie-chat' ), 'code' => 'nonce_expired', 'refresh' => true, ), 401 ); } /** * Verify admin access. * * @return bool True if authorized. */ public static function verify_admin_access() { if ( ! current_user_can( 'manage_options' ) ) { self::send_error( __( 'Permission denied.', 'agentorie-chat' ), 403 ); return false; } if ( ! self::verify_nonce( 'agentorie_chat_admin_nonce' ) ) { self::send_error( __( 'Security check failed.', 'agentorie-chat' ), 403 ); return false; } $admin_id = get_current_user_id(); if ( ! self::check_rate_limit( 'admin_' . $admin_id, 120, 60 ) ) { self::send_error( __( 'Too many requests. Please wait.', 'agentorie-chat' ), 429 ); return false; } return true; } /** * Get input from POST or JSON body with sanitization. * * All input values are sanitized according to their expected type. * No raw/unsanitized data is ever returned. * * @param string $key Input key. * @param mixed $default_value Default value. * @param string $type Type of sanitization: 'text', 'textarea', 'email', 'url', 'key', 'int', 'absint', 'bool', 'array', 'base64', 'json'. * @return mixed Sanitized input value. */ public static function get_input( $key, $default_value = '', $type = 'text' ) { // phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce verification is done in calling methods (verify_nonce, verify_admin_access, etc.) $value = null; if ( isset( $_POST[ $key ] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitization is performed below in the switch statement based on $type parameter. $value = wp_unslash( $_POST[ $key ] ); } else { static $json_input = null; if ( null === $json_input ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading php://input for JSON API requests $raw_input = file_get_contents( 'php://input' ); $json_input = json_decode( $raw_input, true ); if ( ! is_array( $json_input ) ) { $json_input = array(); } } if ( isset( $json_input[ $key ] ) ) { $value = $json_input[ $key ]; } } // phpcs:enable // Return default if no value found. if ( null === $value ) { return $default_value; } // Sanitize based on type - every type returns sanitized/validated data. switch ( $type ) { case 'text': return is_string( $value ) ? sanitize_text_field( $value ) : $default_value; case 'textarea': return is_string( $value ) ? sanitize_textarea_field( $value ) : $default_value; case 'email': return is_string( $value ) ? sanitize_email( $value ) : $default_value; case 'url': return is_string( $value ) ? esc_url_raw( $value ) : $default_value; case 'key': return is_string( $value ) ? sanitize_key( $value ) : $default_value; case 'int': return (int) $value; case 'absint': return absint( $value ); case 'bool': return filter_var( $value, FILTER_VALIDATE_BOOLEAN ); case 'array': if ( ! is_array( $value ) ) { return $default_value; } return array_map( 'sanitize_text_field', $value ); case 'base64': // For base64-encoded data (e.g., audio files). // Validates format and removes any non-base64 characters. if ( ! is_string( $value ) ) { return $default_value; } // Allow data URI format: data:audio/webm;base64,XXXX. if ( preg_match( '/^data:[a-z0-9\/+.-]+;base64,/i', $value ) ) { // Extract and validate the base64 part. $parts = explode( ',', $value, 2 ); if ( count( $parts ) === 2 && preg_match( '/^[A-Za-z0-9+\/=]+$/', $parts[1] ) ) { return $value; // Valid data URI with base64. } return $default_value; } // Plain base64 string. if ( preg_match( '/^[A-Za-z0-9+\/=]+$/', $value ) ) { return $value; } return $default_value; case 'json': // For JSON string input - returns decoded array or default. // This is safer than returning raw JSON string. if ( ! is_string( $value ) ) { return $default_value; } $decoded = json_decode( $value, true ); if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $decoded ) ) { return $default_value; } return $decoded; default: return is_string( $value ) ? sanitize_text_field( $value ) : $default_value; } } /** * Sanitize message content. * * @param string $message Message content. * @param int $max_length Maximum length. * @return string Sanitized message. */ public static function sanitize_message( $message, $max_length = 1000 ) { $message = trim( $message ); $message = wp_strip_all_tags( $message ); $message = sanitize_textarea_field( $message ); return mb_substr( $message, 0, $max_length, 'UTF-8' ); } /** * Check message rate limit. * * @param string $session_id Session ID. * @return bool True if within limit. */ public static function check_message_rate_limit( $session_id ) { $rate_limit = (int) agentorie_chat_get_setting( 'rate_limit', 10 ); $window = 60; $key = 'agentorie_chat_msg_rate_' . md5( $session_id ); $now = time(); $timestamps = get_transient( $key ); if ( ! is_array( $timestamps ) ) { $timestamps = array(); } $timestamps = array_filter( $timestamps, function ( $ts ) use ( $now, $window ) { return ( $now - $ts ) < $window; } ); if ( count( $timestamps ) >= $rate_limit ) { return false; } $timestamps[] = $now; set_transient( $key, $timestamps, $window ); return true; } /** * Check general rate limit. * * @param string $identifier Unique identifier. * @param int $limit Max requests. * @param int $window Time window in seconds. * @return bool True if within limit. */ public static function check_rate_limit( $identifier, $limit = 30, $window = 3600 ) { $key = 'agentorie_chat_rate_' . md5( $identifier ); $data = get_transient( $key ); $now = time(); if ( false === $data ) { set_transient( $key, array( 'count' => 1, 'time' => $now, ), $window ); return true; } if ( $now - $data['time'] > $window ) { set_transient( $key, array( 'count' => 1, 'time' => $now, ), $window ); return true; } if ( $data['count'] >= $limit ) { return false; } ++$data['count']; set_transient( $key, $data, $window ); return true; } /** * Check a per-IP rate limit for public proxy endpoints. * * Acts as the backstop against floods: the per-session limits can be * bypassed by rotating session IDs, but the client IP cannot. Excluded * IPs (site administrators and configured test IPs) always bypass the * limit so the site owner is never throttled while testing. When the * client IP cannot be resolved, the request is allowed rather than * blocking legitimate traffic. * * @since 1.1.9 * @param string $bucket Bucket name (e.g. 'global', 'chat', 'translate', 'voice'). * @param int $limit Maximum requests allowed in the window. * @param int $window Window length in seconds. * @return bool True if the request is within the limit (allowed). */ public static function check_ip_rate_limit( $bucket, $limit, $window = self::RATE_LIMIT_WINDOW ) { if ( self::is_excluded_ip() ) { return true; } $ip = agentorie_chat_get_client_ip(); if ( empty( $ip ) ) { return true; } return self::check_rate_limit( 'ip_' . $bucket . '_' . $ip, $limit, $window ); } /** * Check if content is spam. * * Detects spam patterns without blocking legitimate business topics. * * @param string $message Message content. * @return bool True if spam. */ public static function is_spam_content( $message ) { // Detect character repetition (e.g., "aaaaaaaaaaaaa"). if ( preg_match( '/(.)\1{10,}/', $message ) ) { return true; } // Detect excessive links (more than 5). $link_count = preg_match_all( '/https?:\/\//', $message ); if ( $link_count > 2 ) { return true; } return false; } /** * Check if IP is excluded from stats. * * @return bool True if excluded. */ public static function is_excluded_ip() { if ( current_user_can( 'manage_options' ) ) { return true; } $excluded_ips = agentorie_chat_get_setting( 'exclude_ip', '' ); if ( empty( $excluded_ips ) ) { return false; } $client_ip = Agentorie_Session::get_client_ip(); if ( empty( $client_ip ) ) { return false; } $ip_list = preg_split( '/[\s,]+/', $excluded_ips, -1, PREG_SPLIT_NO_EMPTY ); $ip_list = array_map( 'trim', $ip_list ); $ip_list = array_filter( $ip_list ); return in_array( $client_ip, $ip_list, true ); } /** * Check if admin is online. * * @return bool True if online. */ public static function is_admin_online() { $presence = get_transient( 'agentorie_chat_admin_presence' ); return ! empty( $presence ); } /** * Set admin presence. * * @param bool $active Active status. */ public static function set_admin_presence( $active ) { if ( $active ) { set_transient( 'agentorie_chat_admin_presence', get_current_user_id(), 5 * MINUTE_IN_SECONDS ); } else { delete_transient( 'agentorie_chat_admin_presence' ); } } /** * Send contact notification email. * * @param string $session_id Session ID. * @param string $name Contact name. * @param string $email Contact email. * @param string $phone Contact phone. * @param string $message Contact message. */ public static function send_contact_notification( $session_id, $name, $email, $phone, $message ) { if ( ! agentorie_chat_get_setting( 'email_on_contact', true ) ) { return; } $to = agentorie_chat_get_setting( 'notification_email', get_option( 'admin_email' ) ); /* translators: %s: contact name */ $subject = sprintf( __( '[Agentorie Chat] New contact request from %s', 'agentorie-chat' ), $name ); $body = sprintf( /* translators: %1$s: name, %2$s: email, %3$s: phone, %4$s: message, %5$s: session ID */ __( "New contact request:\n\nName: %1\$s\nEmail: %2\$s\nPhone: %3\$s\n\nMessage:\n%4\$s\n\nSession ID: %5\$s", 'agentorie-chat' ), $name, $email ? $email : 'N/A', $phone ? $phone : 'N/A', $message ? $message : 'N/A', $session_id ); wp_mail( $to, $subject, $body ); } /** * Send success response. * * @param array $data Response data. */ public static function send_success( $data = array() ) { wp_send_json_success( $data ); } /** * Send error response. * * @param string $message Error message. * @param int|array $code HTTP status code, or array of extra data to merge (defaults to 400). */ public static function send_error( $message, $code = 400 ) { $data = array( 'message' => $message ); // Support passing extra data as array (merge into response body). if ( is_array( $code ) ) { $data = array_merge( $data, $code ); $code = 400; } wp_send_json_error( $data, $code ); } }