robinpath_modules/modules/
webhook_mod.rs1use robinpath::{RobinPath, Value};
2
3pub fn register(rp: &mut RobinPath) {
4 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 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 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 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 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 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 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 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 #[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}