Skip to main content

sockudo_http/
webhook.rs

1use crate::{Result, SockudoError, Token, WebhookError};
2use serde::{Deserialize, Serialize};
3use std::collections::{BTreeMap, HashMap};
4
5/// Webhook for validating and accessing Sockudo webhook data
6#[derive(Debug)]
7pub struct Webhook {
8    token: Token,
9    key: Option<String>,
10    signature: Option<String>,
11    content_type: Option<String>,
12    body: String,
13    data: Option<WebhookData>,
14}
15
16/// Webhook data structure matching Sockudo's format
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct WebhookData {
19    /// The timestamp of the webhook in milliseconds
20    pub time_ms: i64,
21    /// The events received with the webhook
22    pub events: Vec<HashMap<String, String>>,
23}
24
25/// Strongly typed webhook event
26#[derive(Debug, Clone, PartialEq)]
27pub enum WebhookEvent {
28    ChannelOccupied {
29        channel: String,
30    },
31    ChannelVacated {
32        channel: String,
33    },
34    MemberAdded {
35        channel: String,
36        user_id: String,
37    },
38    MemberRemoved {
39        channel: String,
40        user_id: String,
41    },
42    ClientEvent {
43        channel: String,
44        event: String,
45        data: String,
46        socket_id: String,
47        user_id: Option<String>,
48    },
49    CacheMiss {
50        channel: String,
51        event: String,
52    },
53    Unknown(HashMap<String, String>),
54}
55
56impl Webhook {
57    /// Creates a new webhook from request data
58    pub fn new(token: &Token, headers: &BTreeMap<String, String>, body: &str) -> Self {
59        // Normalize header names to lowercase for case-insensitive lookup
60        let normalized_headers: BTreeMap<String, String> = headers
61            .iter()
62            .map(|(k, v)| (k.to_lowercase(), v.clone()))
63            .collect();
64
65        let key = normalized_headers.get("x-sockudo-key").cloned();
66        let signature = normalized_headers.get("x-sockudo-signature").cloned();
67        let content_type = normalized_headers.get("content-type").cloned();
68
69        let data = if Self::validate_content_type(&content_type) {
70            sonic_rs::from_str::<WebhookData>(body).ok()
71        } else {
72            None
73        };
74
75        Self {
76            token: token.clone(),
77            key,
78            signature,
79            content_type,
80            body: body.to_string(),
81            data,
82        }
83    }
84
85    /// Validates the webhook signature and content
86    pub fn is_valid(&self, extra_tokens: Option<&[Token]>) -> bool {
87        if !self.is_body_valid() {
88            return false;
89        }
90
91        let tokens_to_check = if let Some(extra) = extra_tokens {
92            let mut tokens = vec![&self.token];
93            tokens.extend(extra.iter());
94            tokens
95        } else {
96            vec![&self.token]
97        };
98
99        if let (Some(key), Some(signature)) = (&self.key, &self.signature) {
100            for token in tokens_to_check {
101                if key == &token.key && token.verify(&self.body, signature) {
102                    return true;
103                }
104            }
105        }
106
107        false
108    }
109
110    /// Checks if the content type is valid (application/json)
111    pub fn is_content_type_valid(&self) -> bool {
112        Self::validate_content_type(&self.content_type)
113    }
114
115    /// Private helper method to validate content type
116    fn validate_content_type(content_type: &Option<String>) -> bool {
117        match content_type {
118            Some(ct) => ct.starts_with("application/json"),
119            None => false,
120        }
121    }
122
123    /// Checks if the body is valid JSON
124    pub fn is_body_valid(&self) -> bool {
125        self.data.is_some()
126    }
127
128    /// Gets the parsed webhook data
129    pub fn get_data(&self) -> Result<&WebhookData> {
130        self.data.as_ref().ok_or_else(|| {
131            SockudoError::Webhook(WebhookError::new(
132                "Invalid webhook body",
133                self.content_type.clone(),
134                &self.body,
135                self.signature.clone(),
136            ))
137        })
138    }
139
140    /// Gets the raw events from webhook data
141    pub fn get_raw_events(&self) -> Result<&Vec<HashMap<String, String>>> {
142        Ok(&self.get_data()?.events)
143    }
144
145    /// Gets the events as strongly typed enums
146    pub fn get_events(&self) -> Result<Vec<WebhookEvent>> {
147        let raw_events = self.get_raw_events()?;
148        Ok(raw_events.iter().map(parse_webhook_event).collect())
149    }
150
151    /// Gets the timestamp from webhook data
152    pub fn get_time(&self) -> Result<std::time::SystemTime> {
153        let time_ms = self.get_data()?.time_ms;
154        if time_ms < 0 {
155            return Err(SockudoError::Webhook(WebhookError::new(
156                "Invalid negative timestamp",
157                self.content_type.clone(),
158                &self.body,
159                self.signature.clone(),
160            )));
161        }
162        let duration = std::time::Duration::from_millis(time_ms as u64);
163        Ok(std::time::UNIX_EPOCH + duration)
164    }
165
166    /// Gets the raw body
167    pub fn body(&self) -> &str {
168        &self.body
169    }
170
171    /// Gets the signature
172    pub fn signature(&self) -> Option<&str> {
173        self.signature.as_deref()
174    }
175
176    /// Gets the key from headers
177    pub fn key(&self) -> Option<&str> {
178        self.key.as_deref()
179    }
180
181    /// Finds events by type
182    pub fn find_events_by_type(&self, event_type: &str) -> Result<Vec<WebhookEvent>> {
183        let events = self.get_events()?;
184        Ok(events
185            .into_iter()
186            .filter(|e| e.event_name() == event_type)
187            .collect())
188    }
189
190    /// Finds events by channel
191    pub fn find_events_by_channel(&self, channel: &str) -> Result<Vec<WebhookEvent>> {
192        let events = self.get_events()?;
193        Ok(events
194            .into_iter()
195            .filter(|e| e.channel() == Some(channel))
196            .collect())
197    }
198}
199
200/// Parses a raw webhook event into a strongly typed event
201fn parse_webhook_event(raw: &HashMap<String, String>) -> WebhookEvent {
202    match raw.get("name").map(|s| s.as_str()) {
203        Some("channel_occupied") => {
204            if let Some(channel) = raw.get("channel") {
205                WebhookEvent::ChannelOccupied {
206                    channel: channel.clone(),
207                }
208            } else {
209                WebhookEvent::Unknown(raw.clone())
210            }
211        }
212        Some("channel_vacated") => {
213            if let Some(channel) = raw.get("channel") {
214                WebhookEvent::ChannelVacated {
215                    channel: channel.clone(),
216                }
217            } else {
218                WebhookEvent::Unknown(raw.clone())
219            }
220        }
221        Some("member_added") => {
222            if let (Some(channel), Some(user_id)) = (raw.get("channel"), raw.get("user_id")) {
223                WebhookEvent::MemberAdded {
224                    channel: channel.clone(),
225                    user_id: user_id.clone(),
226                }
227            } else {
228                WebhookEvent::Unknown(raw.clone())
229            }
230        }
231        Some("member_removed") => {
232            if let (Some(channel), Some(user_id)) = (raw.get("channel"), raw.get("user_id")) {
233                WebhookEvent::MemberRemoved {
234                    channel: channel.clone(),
235                    user_id: user_id.clone(),
236                }
237            } else {
238                WebhookEvent::Unknown(raw.clone())
239            }
240        }
241        Some("client_event") => {
242            if let (Some(channel), Some(event), Some(data), Some(socket_id)) = (
243                raw.get("channel"),
244                raw.get("event"),
245                raw.get("data"),
246                raw.get("socket_id"),
247            ) {
248                WebhookEvent::ClientEvent {
249                    channel: channel.clone(),
250                    event: event.clone(),
251                    data: data.clone(),
252                    socket_id: socket_id.clone(),
253                    user_id: raw.get("user_id").cloned(),
254                }
255            } else {
256                WebhookEvent::Unknown(raw.clone())
257            }
258        }
259        Some("cache_miss") => {
260            if let (Some(channel), Some(event)) = (raw.get("channel"), raw.get("event")) {
261                WebhookEvent::CacheMiss {
262                    channel: channel.clone(),
263                    event: event.clone(),
264                }
265            } else {
266                WebhookEvent::Unknown(raw.clone())
267            }
268        }
269        _ => WebhookEvent::Unknown(raw.clone()),
270    }
271}
272
273impl WebhookEvent {
274    /// Gets the event name
275    pub fn event_name(&self) -> &str {
276        match self {
277            WebhookEvent::ChannelOccupied { .. } => "channel_occupied",
278            WebhookEvent::ChannelVacated { .. } => "channel_vacated",
279            WebhookEvent::MemberAdded { .. } => "member_added",
280            WebhookEvent::MemberRemoved { .. } => "member_removed",
281            WebhookEvent::ClientEvent { .. } => "client_event",
282            WebhookEvent::CacheMiss { .. } => "cache_miss",
283            WebhookEvent::Unknown(map) => map.get("name").map(|s| s.as_str()).unwrap_or("unknown"),
284        }
285    }
286
287    /// Gets the channel name if applicable
288    pub fn channel(&self) -> Option<&str> {
289        match self {
290            WebhookEvent::ChannelOccupied { channel }
291            | WebhookEvent::ChannelVacated { channel }
292            | WebhookEvent::MemberAdded { channel, .. }
293            | WebhookEvent::MemberRemoved { channel, .. }
294            | WebhookEvent::ClientEvent { channel, .. }
295            | WebhookEvent::CacheMiss { channel, .. } => Some(channel),
296            WebhookEvent::Unknown(map) => map.get("channel").map(|s| s.as_str()),
297        }
298    }
299
300    /// Gets the user ID if applicable
301    pub fn user_id(&self) -> Option<&str> {
302        match self {
303            WebhookEvent::MemberAdded { user_id, .. }
304            | WebhookEvent::MemberRemoved { user_id, .. } => Some(user_id),
305            WebhookEvent::ClientEvent { user_id, .. } => user_id.as_deref(),
306            WebhookEvent::Unknown(map) => map.get("user_id").map(|s| s.as_str()),
307            _ => None,
308        }
309    }
310
311    /// Converts the event back to a HashMap
312    pub fn to_hashmap(&self) -> HashMap<String, String> {
313        let mut map = HashMap::new();
314
315        match self {
316            WebhookEvent::ChannelOccupied { channel } => {
317                map.insert("name".to_string(), "channel_occupied".to_string());
318                map.insert("channel".to_string(), channel.clone());
319            }
320            WebhookEvent::ChannelVacated { channel } => {
321                map.insert("name".to_string(), "channel_vacated".to_string());
322                map.insert("channel".to_string(), channel.clone());
323            }
324            WebhookEvent::MemberAdded { channel, user_id } => {
325                map.insert("name".to_string(), "member_added".to_string());
326                map.insert("channel".to_string(), channel.clone());
327                map.insert("user_id".to_string(), user_id.clone());
328            }
329            WebhookEvent::MemberRemoved { channel, user_id } => {
330                map.insert("name".to_string(), "member_removed".to_string());
331                map.insert("channel".to_string(), channel.clone());
332                map.insert("user_id".to_string(), user_id.clone());
333            }
334            WebhookEvent::ClientEvent {
335                channel,
336                event,
337                data,
338                socket_id,
339                user_id,
340            } => {
341                map.insert("name".to_string(), "client_event".to_string());
342                map.insert("channel".to_string(), channel.clone());
343                map.insert("event".to_string(), event.clone());
344                map.insert("data".to_string(), data.clone());
345                map.insert("socket_id".to_string(), socket_id.clone());
346                if let Some(uid) = user_id {
347                    map.insert("user_id".to_string(), uid.clone());
348                }
349            }
350            WebhookEvent::CacheMiss { channel, event } => {
351                map.insert("name".to_string(), "cache_miss".to_string());
352                map.insert("channel".to_string(), channel.clone());
353                map.insert("event".to_string(), event.clone());
354            }
355            WebhookEvent::Unknown(original) => {
356                return original.clone();
357            }
358        }
359
360        map
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367    #[test]
368    fn test_webhook_data_parsing() {
369        let json_str = r#"{
370            "time_ms": 1234567890,
371            "events": [
372                {"name": "channel_occupied", "channel": "test-channel"},
373                {"name": "member_added", "channel": "presence-channel", "user_id": "user123"}
374            ]
375        }"#;
376
377        let data: WebhookData = sonic_rs::from_str(json_str).unwrap();
378        assert_eq!(data.time_ms, 1234567890);
379        assert_eq!(data.events.len(), 2);
380        assert_eq!(
381            data.events[0].get("name"),
382            Some(&"channel_occupied".to_string())
383        );
384    }
385
386    #[test]
387    fn test_webhook_validation() {
388        let token = Token::new("test_key", "test_secret");
389        let body = r#"{"time_ms": 1234567890, "events": []}"#;
390        let signature = token.sign(body);
391
392        let mut headers = BTreeMap::new();
393        headers.insert("content-type".to_string(), "application/json".to_string());
394        headers.insert("x-sockudo-key".to_string(), "test_key".to_string());
395        headers.insert("x-sockudo-signature".to_string(), signature);
396
397        let webhook = Webhook::new(&token, &headers, body);
398        assert!(webhook.is_valid(None));
399    }
400
401    #[test]
402    fn test_event_parsing() {
403        let mut event_map = HashMap::new();
404        event_map.insert("name".to_string(), "channel_occupied".to_string());
405        event_map.insert("channel".to_string(), "test-channel".to_string());
406
407        let event = parse_webhook_event(&event_map);
408        assert!(matches!(event, WebhookEvent::ChannelOccupied { .. }));
409        assert_eq!(event.channel(), Some("test-channel"));
410    }
411
412    #[test]
413    fn test_event_round_trip() {
414        let event = WebhookEvent::MemberAdded {
415            channel: "presence-test".to_string(),
416            user_id: "user123".to_string(),
417        };
418
419        let map = event.to_hashmap();
420        let parsed = parse_webhook_event(&map);
421
422        assert_eq!(event, parsed);
423    }
424}