db = RT_WA_Analytics_DB::instance(); add_action( 'rest_api_init', array( $this, 'register_routes' ) ); } private $route_permissions = array(); public function register_routes() { $this->route_permissions = array( 'GET' => 'read', 'POST' => 'write', 'PUT' => 'write', 'PATCH' => 'write', 'DELETE' => 'write', ); register_rest_route( $this->namespace, '/stats', array( 'methods' => 'GET', 'callback' => array( $this, 'get_stats' ), 'permission_callback' => array( $this, 'check_api_key' ), ) ); register_rest_route( $this->namespace, '/visitors', array( 'methods' => 'GET', 'callback' => array( $this, 'get_visitors' ), 'permission_callback' => array( $this, 'check_api_key' ), ) ); register_rest_route( $this->namespace, '/visitors/(?P[a-f0-9\-]+)', array( 'methods' => 'GET', 'callback' => array( $this, 'get_visitor' ), 'permission_callback' => array( $this, 'check_api_key' ), ) ); register_rest_route( $this->namespace, '/sessions', array( 'methods' => 'GET', 'callback' => array( $this, 'get_sessions' ), 'permission_callback' => array( $this, 'check_api_key' ), ) ); register_rest_route( $this->namespace, '/sessions/(?P[a-f0-9\-]+)', array( 'methods' => 'GET', 'callback' => array( $this, 'get_session' ), 'permission_callback' => array( $this, 'check_api_key' ), ) ); register_rest_route( $this->namespace, '/events', array( 'methods' => 'GET', 'callback' => array( $this, 'get_events' ), 'permission_callback' => array( $this, 'check_api_key' ), ) ); register_rest_route( $this->namespace, '/track', array( 'methods' => 'POST', 'callback' => array( $this, 'track_event' ), 'permission_callback' => array( $this, 'check_api_key' ), ) ); register_rest_route( $this->namespace, '/analytics', array( 'methods' => 'GET', 'callback' => array( $this, 'get_analytics' ), 'permission_callback' => array( $this, 'check_api_key' ), ) ); register_rest_route( $this->namespace, '/leads', array( array( 'methods' => 'GET', 'callback' => array( $this, 'get_leads' ), 'permission_callback' => array( $this, 'check_api_key' ), ), array( 'methods' => 'POST', 'callback' => array( $this, 'create_lead' ), 'permission_callback' => array( $this, 'check_api_key' ), ), ) ); register_rest_route( $this->namespace, '/leads/(?P\d+)', array( array( 'methods' => 'GET', 'callback' => array( $this, 'get_lead' ), 'permission_callback' => array( $this, 'check_api_key' ), ), array( 'methods' => 'PUT', 'callback' => array( $this, 'update_lead' ), 'permission_callback' => array( $this, 'check_api_key' ), ), array( 'methods' => 'DELETE', 'callback' => array( $this, 'delete_lead' ), 'permission_callback' => array( $this, 'check_api_key' ), ), ) ); register_rest_route( $this->namespace, '/leads/(?P\d+)/status', array( 'methods' => 'POST', 'callback' => array( $this, 'update_lead_status' ), 'permission_callback' => array( $this, 'check_api_key' ), ) ); register_rest_route( $this->namespace, '/lead-analytics', array( 'methods' => 'GET', 'callback' => array( $this, 'get_lead_analytics' ), 'permission_callback' => array( $this, 'check_api_key' ), ) ); register_rest_route( $this->namespace, '/conversion-funnel', array( 'methods' => 'GET', 'callback' => array( $this, 'get_conversion_funnel' ), 'permission_callback' => array( $this, 'check_api_key' ), ) ); } public function check_api_key( $request ) { $settings = RT_WA_Analytics_Settings::instance(); if ( $settings->get( 'api_rate_limit', 60 ) ) { $result = $this->check_rate_limit( $request ); if ( is_wp_error( $result ) ) { return $result; } } $api_key = $request->get_header( 'X-API-Key' ); if ( empty( $api_key ) ) { return new WP_Error( 'rest_forbidden', __( 'API key is required.', 'zaaed-click2chat' ), array( 'status' => 401 ) ); } $key_data = $this->db->get_api_key( $api_key ); if ( ! $key_data ) { return new WP_Error( 'rest_forbidden', __( 'Invalid API key.', 'zaaed-click2chat' ), array( 'status' => 401 ) ); } $method = $request->get_method(); $needed = isset( $this->route_permissions[ $method ] ) ? $this->route_permissions[ $method ] : 'read'; $has = $key_data->permissions; // Fail closed: only the exact values below grant access. Any unknown or // malformed stored value is denied for both reads and writes. if ( $needed === 'write' ) { if ( $has !== 'write' ) { return new WP_Error( 'rest_forbidden', sprintf( /* translators: 1: API key permission level, 2: HTTP method, 3: required permission level. */ __( 'This API key (%1$s) does not have %2$s permission. Required: %3$s.', 'zaaed-click2chat' ), $has, strtoupper( $method ), $needed ), array( 'status' => 403 ) ); } } elseif ( $has !== 'read' && $has !== 'write' ) { return new WP_Error( 'rest_forbidden', sprintf( /* translators: 1: API key permission level, 2: HTTP method, 3: required permission level. */ __( 'This API key (%1$s) does not have %2$s permission. Required: %3$s.', 'zaaed-click2chat' ), $has, strtoupper( $method ), $needed ), array( 'status' => 403 ) ); } $key_limit = (int) $key_data->rate_limit; if ( $key_limit ) { $key_rate_key = 'rt_wa_key_rate_' . (int) $key_data->id; if ( RT_WA_Analytics_DB::rate_limit_hit( $key_rate_key, $key_limit ) ) { return new WP_Error( 'rate_limit', __( 'Rate limit exceeded.', 'zaaed-click2chat' ), array( 'status' => 429 ) ); } } global $wpdb; $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery $wpdb->prefix . 'rt_wa_api_keys', array( 'last_used' => current_time( 'mysql' ) ), array( 'id' => $key_data->id ) ); return true; } private function get_rate_limit_ip() { if ( ! empty( $_SERVER['REMOTE_ADDR'] ) ) { return sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ); } $forwarded = $this->get_client_ip(); return $forwarded ?: 'unknown'; } private function check_rate_limit( $request ) { $settings = RT_WA_Analytics_Settings::instance(); $rate_limit = (int) $settings->get( 'api_rate_limit', 60 ); $ip = $this->get_rate_limit_ip(); $transient_key = 'rt_wa_rate_' . md5( $ip ); if ( RT_WA_Analytics_DB::rate_limit_hit( $transient_key, $rate_limit ) ) { return new WP_Error( 'rate_limit', __( 'Rate limit exceeded.', 'zaaed-click2chat' ), array( 'status' => 429 ) ); } return true; } private function get_pagination( $request ) { $limit = (int) $request->get_param( 'limit' ); if ( $limit <= 0 ) { $limit = 20; } $limit = min( $limit, 500 ); $offset = (int) $request->get_param( 'offset' ); if ( $offset < 0 ) { $offset = 0; } return array( $limit, $offset ); } /** * Mask a lead row for API responses. API keys are not users, so contact * numbers are always masked and the internal HMAC lookup tokens are never * exposed (they would allow offline brute force of the phone space). * * @param object $lead * @return object */ private function mask_lead_row( $lead ) { $leads = RT_WA_Analytics_Leads::instance(); $lead->phone = $leads->mask_value( $lead->phone ); $lead->wa_phone = $leads->mask_value( $lead->wa_phone ); unset( $lead->phone_lookup, $lead->wa_phone_lookup ); return $lead; } /** * Mask the WhatsApp number on event rows returned through the API. * * @param array $events * @return array */ private function mask_event_phones( $events ) { $leads = RT_WA_Analytics_Leads::instance(); foreach ( (array) $events as &$event ) { if ( is_object( $event ) && isset( $event->whatsapp_number ) ) { $event->whatsapp_number = $leads->mask_value( $event->whatsapp_number ); } } return $events; } public function get_stats( $request ) { return rest_ensure_response( array( 'total_visitors' => $this->db->get_total_visitors(), 'total_sessions' => $this->db->get_total_sessions(), 'total_wa_clicks' => $this->db->get_total_wa_clicks(), 'conversion_rate' => $this->db->get_conversion_rate(), ) ); } public function get_visitors( $request ) { list($limit, $offset) = $this->get_pagination( $request ); $visitors = $this->db->get_visitors( $limit, $offset ); $total = $this->db->get_visitors_count(); foreach ( $visitors as $visitor ) { $visitor->ip = $this->mask_ip( $visitor->ip ); } return rest_ensure_response( array( 'visitors' => $visitors, 'total' => $total, 'limit' => $limit, 'offset' => $offset, ) ); } public function get_visitor( $request ) { $uuid = $request->get_param( 'uuid' ); $visitor = $this->db->get_visitor( $uuid ); if ( ! $visitor ) { return new WP_Error( 'not_found', __( 'Visitor not found.', 'zaaed-click2chat' ), array( 'status' => 404 ) ); } $sessions = $this->db->get_visitor_sessions( $uuid ); $events = $this->db->get_visitor_events( $uuid ); $visitor->ip = $this->mask_ip( $visitor->ip ); return rest_ensure_response( array( 'visitor' => $visitor, 'sessions' => $sessions, 'events' => $this->mask_event_phones( $events ), ) ); } public function get_sessions( $request ) { list($limit, $offset) = $this->get_pagination( $request ); $sessions = $this->db->get_sessions( $limit, $offset ); $total = $this->db->get_sessions_count(); return rest_ensure_response( array( 'sessions' => $sessions, 'total' => $total, 'limit' => $limit, 'offset' => $offset, ) ); } public function get_session( $request ) { $session_id = $request->get_param( 'session_id' ); $session = $this->db->get_session( $session_id ); if ( ! $session ) { return new WP_Error( 'not_found', __( 'Session not found.', 'zaaed-click2chat' ), array( 'status' => 404 ) ); } $events = $this->db->get_session_events( $session_id ); return rest_ensure_response( array( 'session' => $session, 'events' => $this->mask_event_phones( $events ), ) ); } public function get_events( $request ) { $filters = array(); if ( $request->get_param( 'event_type' ) ) { $filters['event_type'] = sanitize_text_field( $request->get_param( 'event_type' ) ); } if ( $request->get_param( 'date_from' ) ) { $filters['date_from'] = sanitize_text_field( $request->get_param( 'date_from' ) ); } if ( $request->get_param( 'date_to' ) ) { $filters['date_to'] = sanitize_text_field( $request->get_param( 'date_to' ) ); } if ( $request->get_param( 'visitor_uuid' ) ) { $filters['visitor_uuid'] = sanitize_text_field( $request->get_param( 'visitor_uuid' ) ); } if ( $request->get_param( 'session_id' ) ) { $filters['session_id'] = sanitize_text_field( $request->get_param( 'session_id' ) ); } $limit = 20; $offset = 0; list($limit, $offset) = $this->get_pagination( $request ); $events = $this->db->get_events( $limit, $offset, $filters ); $total = $this->db->get_events_count( $filters ); return rest_ensure_response( array( 'events' => $this->mask_event_phones( $events ), 'total' => $total, 'limit' => $limit, 'offset' => $offset, ) ); } public function track_event( $request ) { $params = $request->get_json_params(); $event_type = isset( $params['event_type'] ) ? sanitize_text_field( $params['event_type'] ) : ''; $visitor_uuid = isset( $params['visitor_uuid'] ) ? sanitize_text_field( $params['visitor_uuid'] ) : ''; $session_id = isset( $params['session_id'] ) ? sanitize_text_field( $params['session_id'] ) : ''; $page_url = isset( $params['page_url'] ) ? esc_url_raw( $params['page_url'] ) : ''; $whatsapp_number = isset( $params['whatsapp_number'] ) ? sanitize_text_field( $params['whatsapp_number'] ) : ''; if ( $whatsapp_number !== '' ) { $cipher = RT_WA_Analytics_Encryption::instance()->encrypt( $whatsapp_number ); $whatsapp_number = $cipher === false ? '' : $cipher; } if ( empty( $event_type ) ) { return new WP_Error( 'bad_request', __( 'Event type is required.', 'zaaed-click2chat' ), array( 'status' => 400 ) ); } // The phone number intentionally stays out of raw_data: it is stored // encrypted in its own column and must not be duplicated in plaintext. $safe_raw = array_intersect_key( $params, array_flip( array( 'event_type', 'visitor_uuid', 'session_id', 'page_url', 'device', 'browser', 'os', 'page_title', 'event_category', 'event_label', 'utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', ) ) ); $data = array( 'event_type' => $event_type, 'visitor_uuid' => $visitor_uuid, 'session_id' => $session_id, 'page_url' => $page_url, 'timestamp' => current_time( 'mysql' ), 'whatsapp_number' => $whatsapp_number, 'raw_data' => wp_json_encode( $safe_raw ), ); $this->db->insert_event( $data ); return rest_ensure_response( array( 'success' => true, 'message' => __( 'Event tracked', 'zaaed-click2chat' ), ) ); } public function get_analytics( $request ) { return rest_ensure_response( array( 'sources' => $this->db->get_sources_report(), 'campaigns' => $this->db->get_campaign_report(), 'landing_pages' => $this->db->get_landing_pages_report(), 'devices' => $this->db->get_device_report(), 'browsers' => $this->db->get_browser_report(), 'os' => $this->db->get_os_report(), 'hourly' => $this->db->get_hourly_report(), 'weekly' => $this->db->get_weekly_report(), 'monthly' => $this->db->get_monthly_report(), 'funnel' => $this->db->get_funnel_data(), ) ); } public function get_leads( $request ) { $leads = RT_WA_Analytics_Leads::instance(); list($limit, $offset) = $this->get_pagination( $request ); $filters = array(); if ( $request->get_param( 'status' ) ) { $filters['status'] = sanitize_key( $request->get_param( 'status' ) ); } if ( $request->get_param( 'search' ) ) { $filters['search'] = sanitize_text_field( $request->get_param( 'search' ) ); } if ( $request->get_param( 'campaign' ) ) { $filters['campaign'] = sanitize_text_field( $request->get_param( 'campaign' ) ); } if ( $request->get_param( 'source' ) ) { $filters['source'] = sanitize_text_field( $request->get_param( 'source' ) ); } if ( $request->get_param( 'date_from' ) ) { $filters['date_from'] = sanitize_text_field( $request->get_param( 'date_from' ) ); } if ( $request->get_param( 'date_to' ) ) { $filters['date_to'] = sanitize_text_field( $request->get_param( 'date_to' ) ); } $lead_rows = array_map( array( $this, 'mask_lead_row' ), $leads->get_leads( $limit, $offset, $filters ) ); return rest_ensure_response( array( 'leads' => $lead_rows, 'total' => $leads->get_leads_count( $filters ), 'limit' => $limit, 'offset' => $offset, ) ); } public function create_lead( $request ) { $leads = RT_WA_Analytics_Leads::instance(); $data = $leads->sanitize_lead_data( $request->get_json_params() ); if ( empty( $data['status'] ) ) { $data['status'] = $leads->get_default_status(); } $data['created_at'] = current_time( 'mysql' ); $id = $leads->insert_lead( $data ); if ( ! $id ) { return new WP_Error( 'insert_failed', __( 'Failed to create lead.', 'zaaed-click2chat' ), array( 'status' => 500 ) ); } $leads->log_status_change( $id, $data['status'] ); return rest_ensure_response( array( 'id' => $id, 'status' => $data['status'], ) ); } public function get_lead( $request ) { $leads = RT_WA_Analytics_Leads::instance(); $lead = $leads->get_lead( $request->get_param( 'id' ) ); if ( ! $lead ) { return new WP_Error( 'not_found', __( 'Lead not found.', 'zaaed-click2chat' ), array( 'status' => 404 ) ); } return rest_ensure_response( array( 'lead' => $this->mask_lead_row( $lead ), 'history' => $leads->get_status_history( $lead->id ), ) ); } public function update_lead( $request ) { $leads = RT_WA_Analytics_Leads::instance(); $lead = $leads->get_lead( $request->get_param( 'id' ) ); if ( ! $lead ) { return new WP_Error( 'not_found', __( 'Lead not found.', 'zaaed-click2chat' ), array( 'status' => 404 ) ); } $leads->update_lead( $lead->id, $leads->sanitize_lead_data( $request->get_json_params() ) ); return rest_ensure_response( array( 'id' => $lead->id, 'updated' => true, ) ); } public function delete_lead( $request ) { $leads = RT_WA_Analytics_Leads::instance(); $lead = $leads->get_lead( $request->get_param( 'id' ) ); if ( ! $lead ) { return new WP_Error( 'not_found', __( 'Lead not found.', 'zaaed-click2chat' ), array( 'status' => 404 ) ); } $leads->delete_lead( $lead->id ); return rest_ensure_response( array( 'deleted' => true ) ); } public function update_lead_status( $request ) { $leads = RT_WA_Analytics_Leads::instance(); $lead = $leads->get_lead( $request->get_param( 'id' ) ); $status = sanitize_key( $request->get_param( 'status' ) ); $note = sanitize_textarea_field( (string) $request->get_param( 'note', '' ) ); if ( ! $lead ) { return new WP_Error( 'not_found', __( 'Lead not found.', 'zaaed-click2chat' ), array( 'status' => 404 ) ); } if ( ! $leads->change_status( $lead->id, $status, $note ) ) { return new WP_Error( 'invalid_status', __( 'Invalid status.', 'zaaed-click2chat' ), array( 'status' => 400 ) ); } return rest_ensure_response( array( 'id' => $lead->id, 'status' => $status, ) ); } public function get_lead_analytics( $request ) { $leads = RT_WA_Analytics_Leads::instance(); $date_from = $request->get_param( 'date_from' ) ? sanitize_text_field( $request->get_param( 'date_from' ) ) : null; $date_to = $request->get_param( 'date_to' ) ? sanitize_text_field( $request->get_param( 'date_to' ) ) : null; return rest_ensure_response( array( 'kpis' => $leads->get_kpis( $date_from, $date_to ), 'campaigns' => $leads->get_campaign_report( 100, 0, $date_from, $date_to ), 'landing_pages' => $leads->get_landing_page_report( 100, 0, $date_from, $date_to ), ) ); } public function get_conversion_funnel( $request ) { $date_from = $request->get_param( 'date_from' ) ? sanitize_text_field( $request->get_param( 'date_from' ) ) : null; $date_to = $request->get_param( 'date_to' ) ? sanitize_text_field( $request->get_param( 'date_to' ) ) : null; return rest_ensure_response( RT_WA_Analytics_Leads::instance()->get_conversion_funnel( $date_from, $date_to ) ); } private function get_client_ip() { return RT_WA_Analytics_DB::get_client_ip(); } /** * Mask an IP for REST consumers (privacy-first: API output never exposes * the full visitor IP, regardless of the stored value). */ private function mask_ip( $ip ) { if ( empty( $ip ) || ! is_string( $ip ) ) { return $ip; } if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { $parts = explode( '.', $ip ); $parts[ count( $parts ) - 1 ] = '0'; return implode( '.', $parts ); } if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) { $binary = inet_pton( $ip ); if ( $binary !== false ) { $bytes = unpack( 'C*', $binary ); for ( $i = 9; $i <= 16; $i++ ) { $bytes[ $i ] = 0; } return inet_ntop( pack( 'C*', ...array_values( $bytes ) ) ); } } return $ip; } }