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;
}
};
let payload_owned = payload.clone();
tokio::spawn(async move {
let client = webhook_client();
let mut request = client
.post(&webhook_url)
.header("Content-Type", "application/json")
.body(payload_owned.clone());
if let Some(ref secret) = webhook_secret {
match compute_hmac_signature(payload_owned.as_bytes(), secret.as_bytes()) {
Ok(sig) => {
request = request.header(
"X-Twofold-Signature",
format!("sha256={sig}"),
);
}
Err(e) => {
tracing::warn!(error = %e, "Failed to compute webhook signature — sending unsigned");
}
}
}
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");
}
}
});
}
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())
}