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() }