From a13b0e2309695aca2df344b115c9d6767ba15fd0 Mon Sep 17 00:00:00 2001 From: lashman Date: Wed, 12 Nov 2025 09:03:41 +0200 Subject: [PATCH] webhook delivery --- cruciverb-server/src/webhooks.rs | 78 ++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 cruciverb-server/src/webhooks.rs diff --git a/cruciverb-server/src/webhooks.rs b/cruciverb-server/src/webhooks.rs new file mode 100644 index 0000000..e271d8c --- /dev/null +++ b/cruciverb-server/src/webhooks.rs @@ -0,0 +1,78 @@ +use std::sync::Arc; +use crate::state::AppState; + +pub async fn fire_event(state: &Arc, event: &str, payload: serde_json::Value) { + let dbi = state.db.lock().await; + let hooks = crate::db::get_enabled_webhooks(&dbi.db, event); + drop(dbi); + + for (id, url, secret, format) in hooks { + if crate::security::is_internal_url(&url) { + tracing::warn!("skipping webhook to internal URL: {}", url); + continue; + } + let body = match format.as_str() { + "discord" => format_discord(event, &payload), + "slack" => format_slack(event, &payload), + _ => serde_json::json!({ "event": event, "data": payload }).to_string(), + }; + + let client = reqwest::Client::builder() + .redirect(crate::security::safe_redirect_policy()) + .build() + .unwrap_or_default(); + let mut req = client.post(&url) + .header("Content-Type", "application/json") + .header("X-Cruciverb-Event", event); + + if let Some(ref sec) = secret { + use sha2::{Sha256, Digest}; + let sig = hex::encode(Sha256::digest(format!("{}{}", body, sec).as_bytes())); + req = req.header("X-Cruciverb-Signature", sig); + } + + let state_clone = state.clone(); + let id_clone = id.clone(); + tokio::spawn(async move { + match req.body(body).send().await { + Ok(res) if res.status().is_success() => { + if crate::security::is_internal_url(res.url().as_str()) { + tracing::warn!("skipping webhook delivery to internal URL after redirect: {}", res.url()); + } + } + _ => { + let dbi = state_clone.db.lock().await; + crate::db::record_webhook_failure(&dbi.db, &id_clone); + } + } + }); + } +} + +fn format_discord(event: &str, payload: &serde_json::Value) -> String { + let title = match event { + "daily_puzzle.published" => "New daily puzzle", + "puzzle.completed" => "Puzzle completed", + "achievement.unlocked" => "Achievement unlocked", + _ => event, + }; + let desc = payload.get("description").and_then(|v| v.as_str()).unwrap_or(""); + serde_json::json!({ + "embeds": [{ + "title": title, + "description": desc, + "color": 13848143, + }] + }).to_string() +} + +fn format_slack(event: &str, payload: &serde_json::Value) -> String { + let desc = payload.get("description").and_then(|v| v.as_str()).unwrap_or(event); + serde_json::json!({ + "text": desc, + "blocks": [{ + "type": "section", + "text": { "type": "mrkdwn", "text": format!("*{}*\n{}", event, desc) } + }] + }).to_string() +}