use std::sync::OnceLock;
use hmac::{Hmac, Mac};
use sha2::Sha256;
static WEBHOOK_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
fn webhook_client() -> &'static reqwest::Client {
WEBHOOK_CLIENT.get_or_init(|| {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.connect_timeout(std::time::Duration::from_secs(5))
.build()
.expect("Failed to build webhook HTTP client")
})
}
#[derive(serde::Serialize, Clone)]
pub struct WebhookDocument {
pub slug: String,
pub title: String,
pub url: String,
pub api_url: String,
}
pub fn dispatch_webhook(
webhook_url: String,
webhook_secret: Option<String>,
event: &str,
timestamp: String,
document: WebhookDocument,
) {
let payload = match serde_json::to_string(&serde_json::json!({
"event": event,
"timestamp": timestamp,
"document": {
"slug": document.slug,
"title": document.title,
"url": document.url,
"api_url": document.api_url,
}
})) {
Ok(p) => p,
Err(e) => {
tracing::warn!(error = %e, "Failed to serialize webhook payload — not dispatching");
return;
}
};
tokio::spawn(async move {
let client = webhook_client();
let signature = webhook_secret.as_deref().and_then(|secret| {
match compute_hmac_signature(payload.as_bytes(), secret.as_bytes()) {
Ok(sig) => Some(sig),
Err(e) => {
tracing::warn!(error = %e, "Failed to compute webhook signature — sending unsigned");
None
}
}
});
let mut request = client
.post(&webhook_url)
.header("Content-Type", "application/json")
.body(payload);
if let Some(sig) = signature {
request = request.header("X-Twofold-Signature", format!("sha256={sig}"));
}
match request.send().await {
Ok(resp) if resp.status().is_success() => {
tracing::debug!(
url = %webhook_url,
status = %resp.status(),
"Webhook delivered"
);
}
Ok(resp) => {
tracing::warn!(
url = %webhook_url,
status = %resp.status(),
"Webhook delivery failed (non-2xx response)"
);
}
Err(e) if e.is_timeout() => {
tracing::warn!(url = %webhook_url, "Webhook timed out (5s)");
}
Err(e) => {
tracing::warn!(url = %webhook_url, error = %e, "Webhook delivery error");
}
}
});
}
pub(crate) fn compute_hmac_signature(body: &[u8], key: &[u8]) -> Result<String, String> {
let mut mac =
Hmac::<Sha256>::new_from_slice(key).map_err(|e| format!("HMAC key error: {e}"))?;
mac.update(body);
let result = mac.finalize().into_bytes();
Ok(result.iter().map(|b| format!("{b:02x}")).collect())
}
#[cfg(test)]
mod tests {
use super::compute_hmac_signature;
#[test]
fn hmac_rfc4231_test_case_1() {
let key = [0x0bu8; 20];
let msg = b"Hi There";
let result = compute_hmac_signature(msg, &key).expect("HMAC must not fail");
assert_eq!(
result, "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7",
"HMAC-SHA256 output must match RFC 4231 TC1 vector"
);
}
#[test]
fn hmac_different_message_different_digest() {
let key = [0x0bu8; 20];
let result1 = compute_hmac_signature(b"Hi There", &key).unwrap();
let result2 = compute_hmac_signature(b"Hi There!", &key).unwrap();
assert_ne!(
result1, result2,
"different messages must produce different HMAC outputs"
);
}
#[test]
fn hmac_different_key_different_digest() {
let result1 = compute_hmac_signature(b"Hi There", &[0x0bu8; 20]).unwrap();
let result2 = compute_hmac_signature(b"Hi There", b"different-key").unwrap();
assert_ne!(
result1, result2,
"different keys must produce different HMAC outputs"
);
}
}