Skip to main content

robinpath_modules/modules/
webhook_mod.rs

1use robinpath::{RobinPath, Value};
2
3pub fn register(rp: &mut RobinPath) {
4    // webhook.sign payload secret algorithm? → "algorithm=hex"
5    rp.register_builtin("webhook.sign", |args, _| {
6        let payload = args.first().map(|v| {
7            match v {
8                Value::Object(_) | Value::Array(_) => v.to_json_string(),
9                _ => v.to_display_string(),
10            }
11        }).unwrap_or_default();
12        let secret = args.get(1).map(|v| v.to_display_string()).unwrap_or_default();
13        let algorithm = args.get(2).map(|v| v.to_display_string()).unwrap_or_else(|| "sha256".to_string());
14        let sig = hmac_sign(&algorithm, &secret, &payload)?;
15        Ok(Value::String(format!("{}={}", algorithm, sig)))
16    });
17
18    // webhook.verify payload secret signature algorithm? → bool
19    rp.register_builtin("webhook.verify", |args, _| {
20        let payload = args.first().map(|v| {
21            match v {
22                Value::Object(_) | Value::Array(_) => v.to_json_string(),
23                _ => v.to_display_string(),
24            }
25        }).unwrap_or_default();
26        let secret = args.get(1).map(|v| v.to_display_string()).unwrap_or_default();
27        let signature = args.get(2).map(|v| v.to_display_string()).unwrap_or_default();
28        let default_algo = "sha256".to_string();
29
30        // Parse "algorithm=hex" format
31        let (algo, sig_hex) = if signature.contains('=') {
32            let parts: Vec<&str> = signature.splitn(2, '=').collect();
33            (parts[0].to_string(), parts[1].to_string())
34        } else {
35            let algo = args.get(3).map(|v| v.to_display_string()).unwrap_or(default_algo);
36            (algo, signature)
37        };
38
39        match hmac_sign(&algo, &secret, &payload) {
40            Ok(expected) => Ok(Value::Bool(constant_time_eq(&expected, &sig_hex))),
41            Err(_) => Ok(Value::Bool(false)),
42        }
43    });
44
45    // webhook.verifyTimestamp timestamp toleranceMs? → {valid, ageMs, toleranceMs}
46    rp.register_builtin("webhook.verifyTimestamp", |args, _| {
47        let timestamp = args.first().map(|v| v.to_number() as u64).unwrap_or(0);
48        let tolerance_ms = args.get(1).map(|v| v.to_number() as u64).unwrap_or(300000);
49        let now = std::time::SystemTime::now()
50            .duration_since(std::time::UNIX_EPOCH)
51            .unwrap_or_default()
52            .as_millis() as u64;
53        let diff = if now > timestamp { now - timestamp } else { timestamp - now };
54        let mut obj = indexmap::IndexMap::new();
55        obj.insert("valid".to_string(), Value::Bool(diff <= tolerance_ms));
56        obj.insert("ageMs".to_string(), Value::Number(diff as f64));
57        obj.insert("toleranceMs".to_string(), Value::Number(tolerance_ms as f64));
58        Ok(Value::Object(obj))
59    });
60
61    // webhook.parsePayload body contentType? → parsed data
62    rp.register_builtin("webhook.parsePayload", |args, _| {
63        let body = args.first().cloned().unwrap_or(Value::Null);
64        let content_type = args.get(1).map(|v| v.to_display_string())
65            .unwrap_or_else(|| "application/json".to_string());
66
67        if content_type.contains("json") {
68            let s = body.to_display_string();
69            match serde_json::from_str::<serde_json::Value>(&s) {
70                Ok(v) => Ok(Value::from(v)),
71                Err(_) => Ok(body),
72            }
73        } else if content_type.contains("x-www-form-urlencoded") {
74            let s = body.to_display_string();
75            let mut obj = indexmap::IndexMap::new();
76            for pair in s.split('&') {
77                if let Some(eq) = pair.find('=') {
78                    let key = url_decode(&pair[..eq]);
79                    let val = url_decode(&pair[eq + 1..]);
80                    obj.insert(key, Value::String(val));
81                }
82            }
83            Ok(Value::Object(obj))
84        } else {
85            Ok(Value::String(body.to_display_string()))
86        }
87    });
88
89    // webhook.buildPayload event data options? → {event, data, timestamp, id}
90    rp.register_builtin("webhook.buildPayload", |args, _| {
91        let event = args.first().map(|v| v.to_display_string()).unwrap_or_default();
92        let data = args.get(1).cloned().unwrap_or(Value::Null);
93        let opts = args.get(2).cloned().unwrap_or(Value::Null);
94        let now = std::time::SystemTime::now()
95            .duration_since(std::time::UNIX_EPOCH)
96            .unwrap_or_default()
97            .as_millis() as u64;
98        let id = if let Value::Object(obj) = &opts {
99            obj.get("id").map(|v| v.to_display_string())
100                .unwrap_or_else(|| format!("wh_{}", now))
101        } else {
102            format!("wh_{}", now)
103        };
104        let mut obj = indexmap::IndexMap::new();
105        obj.insert("event".to_string(), Value::String(event));
106        obj.insert("data".to_string(), data);
107        obj.insert("timestamp".to_string(), Value::Number(now as f64));
108        obj.insert("id".to_string(), Value::String(id));
109        if let Value::Object(o) = &opts {
110            if let Some(meta) = o.get("metadata") {
111                obj.insert("metadata".to_string(), meta.clone());
112            }
113        }
114        Ok(Value::Object(obj))
115    });
116
117    // webhook.headers secret payload event? algorithm? → headers object
118    rp.register_builtin("webhook.headers", |args, _| {
119        let secret = args.first().map(|v| v.to_display_string()).unwrap_or_default();
120        let payload = args.get(1).map(|v| {
121            match v {
122                Value::Object(_) | Value::Array(_) => v.to_json_string(),
123                _ => v.to_display_string(),
124            }
125        }).unwrap_or_default();
126        let event = args.get(2).map(|v| v.to_display_string());
127        let algorithm = args.get(3).map(|v| v.to_display_string()).unwrap_or_else(|| "sha256".to_string());
128        let now = std::time::SystemTime::now()
129            .duration_since(std::time::UNIX_EPOCH)
130            .unwrap_or_default()
131            .as_millis() as u64;
132        let mut obj = indexmap::IndexMap::new();
133        obj.insert("Content-Type".to_string(), Value::String("application/json".to_string()));
134        obj.insert("User-Agent".to_string(), Value::String("RobinPath-Webhook/1.0".to_string()));
135        obj.insert("X-Webhook-Timestamp".to_string(), Value::String(now.to_string()));
136        if !secret.is_empty() {
137            if let Ok(sig) = hmac_sign(&algorithm, &secret, &payload) {
138                obj.insert("X-Webhook-Signature".to_string(),
139                    Value::String(format!("{}={}", algorithm, sig)));
140            }
141        }
142        if let Some(ev) = event {
143            obj.insert("X-Webhook-Event".to_string(), Value::String(ev));
144        }
145        Ok(Value::Object(obj))
146    });
147
148    // webhook.isValidUrl url → bool
149    rp.register_builtin("webhook.isValidUrl", |args, _| {
150        let url_str = args.first().map(|v| v.to_display_string()).unwrap_or_default();
151        let valid = url_str.starts_with("http://") || url_str.starts_with("https://");
152        Ok(Value::Bool(valid && url::Url::parse(&url_str).is_ok()))
153    });
154
155    // webhook.send url payload options? → {status, ok, body} (requires reqwest)
156    #[cfg(feature = "api")]
157    rp.register_builtin("webhook.send", |args, _| {
158        let url_str = args.first().map(|v| v.to_display_string()).unwrap_or_default();
159        let payload = args.get(1).cloned().unwrap_or(Value::Null);
160        let opts = args.get(2).cloned().unwrap_or(Value::Null);
161        if url_str.is_empty() {
162            return Err("URL is required".to_string());
163        }
164        let body_str = match &payload {
165            Value::Object(_) | Value::Array(_) => payload.to_json_string(),
166            _ => payload.to_display_string(),
167        };
168        let secret = if let Value::Object(obj) = &opts {
169            obj.get("secret").map(|v| v.to_display_string())
170        } else {
171            None
172        };
173        let algorithm = if let Value::Object(obj) = &opts {
174            obj.get("algorithm").map(|v| v.to_display_string()).unwrap_or_else(|| "sha256".to_string())
175        } else {
176            "sha256".to_string()
177        };
178        let client = reqwest::blocking::Client::new();
179        let mut req = client.post(&url_str)
180            .header("Content-Type", "application/json")
181            .header("User-Agent", "RobinPath-Webhook/1.0");
182        if let Some(sec) = &secret {
183            if let Ok(sig) = hmac_sign(&algorithm, sec, &body_str) {
184                req = req.header("X-Webhook-Signature", format!("{}={}", algorithm, sig));
185            }
186        }
187        req = req.body(body_str);
188        match req.send() {
189            Ok(resp) => {
190                let status = resp.status().as_u16();
191                let ok = resp.status().is_success();
192                let resp_body = resp.text().unwrap_or_default();
193                let body_val = match serde_json::from_str::<serde_json::Value>(&resp_body) {
194                    Ok(j) => Value::from(j),
195                    Err(_) => Value::String(resp_body),
196                };
197                let mut obj = indexmap::IndexMap::new();
198                obj.insert("status".to_string(), Value::Number(status as f64));
199                obj.insert("ok".to_string(), Value::Bool(ok));
200                obj.insert("body".to_string(), body_val);
201                Ok(Value::Object(obj))
202            }
203            Err(e) => Err(format!("webhook.send error: {}", e)),
204        }
205    });
206}
207
208fn hmac_sign(algorithm: &str, secret: &str, data: &str) -> Result<String, String> {
209    use hmac::{Hmac, Mac};
210    match algorithm {
211        "sha256" => {
212            let mut mac = Hmac::<sha2::Sha256>::new_from_slice(secret.as_bytes())
213                .map_err(|e| format!("HMAC error: {}", e))?;
214            mac.update(data.as_bytes());
215            Ok(hex::encode(mac.finalize().into_bytes()))
216        }
217        "sha1" => {
218            let mut mac = Hmac::<sha1::Sha1>::new_from_slice(secret.as_bytes())
219                .map_err(|e| format!("HMAC error: {}", e))?;
220            mac.update(data.as_bytes());
221            Ok(hex::encode(mac.finalize().into_bytes()))
222        }
223        "sha512" => {
224            let mut mac = Hmac::<sha2::Sha512>::new_from_slice(secret.as_bytes())
225                .map_err(|e| format!("HMAC error: {}", e))?;
226            mac.update(data.as_bytes());
227            Ok(hex::encode(mac.finalize().into_bytes()))
228        }
229        _ => Err(format!("Unsupported algorithm: {}", algorithm)),
230    }
231}
232
233fn constant_time_eq(a: &str, b: &str) -> bool {
234    if a.len() != b.len() {
235        return false;
236    }
237    let mut diff = 0u8;
238    for (x, y) in a.bytes().zip(b.bytes()) {
239        diff |= x ^ y;
240    }
241    diff == 0
242}
243
244fn url_decode(s: &str) -> String {
245    let mut result = String::new();
246    let mut chars = s.bytes();
247    while let Some(b) = chars.next() {
248        if b == b'%' {
249            let h1 = chars.next().unwrap_or(0);
250            let h2 = chars.next().unwrap_or(0);
251            let hex_str = format!("{}{}", h1 as char, h2 as char);
252            if let Ok(val) = u8::from_str_radix(&hex_str, 16) {
253                result.push(val as char);
254            }
255        } else if b == b'+' {
256            result.push(' ');
257        } else {
258            result.push(b as char);
259        }
260    }
261    result
262}