1use crate::models::{Webhook, WebhookDelivery};
2use crate::Database;
3use anyhow::Result;
4
5pub fn create_webhook(
7 db: &Database,
8 name: &str,
9 url: &str,
10 secret: Option<&str>,
11 events: &str,
12) -> Result<i64> {
13 let conn = db.get()?;
14 conn.execute(
15 "INSERT INTO webhooks (name, url, secret, events) VALUES (?1, ?2, ?3, ?4)",
16 rusqlite::params![name, url, secret, events],
17 )?;
18 Ok(conn.last_insert_rowid())
19}
20
21pub fn update_webhook(
23 db: &Database,
24 id: i64,
25 name: &str,
26 url: &str,
27 secret: Option<&str>,
28 events: &str,
29 active: bool,
30) -> Result<()> {
31 let conn = db.get()?;
32 conn.execute(
33 "UPDATE webhooks SET name = ?1, url = ?2, secret = ?3, events = ?4, active = ?5, updated_at = CURRENT_TIMESTAMP WHERE id = ?6",
34 rusqlite::params![name, url, secret, events, active, id],
35 )?;
36 Ok(())
37}
38
39pub fn delete_webhook(db: &Database, id: i64) -> Result<()> {
41 let conn = db.get()?;
42 conn.execute("DELETE FROM webhooks WHERE id = ?", [id])?;
43 Ok(())
44}
45
46pub fn list_webhooks(db: &Database) -> Result<Vec<Webhook>> {
48 let conn = db.get()?;
49 let mut stmt = conn.prepare(
50 "SELECT id, name, url, secret, events, active, created_at, updated_at FROM webhooks ORDER BY created_at DESC",
51 )?;
52 let webhooks = stmt
53 .query_map([], |row| {
54 Ok(Webhook {
55 id: row.get(0)?,
56 name: row.get(1)?,
57 url: row.get(2)?,
58 secret: row.get(3)?,
59 events: row.get(4)?,
60 active: row.get(5)?,
61 created_at: row.get(6)?,
62 updated_at: row.get(7)?,
63 })
64 })?
65 .filter_map(|r| r.ok())
66 .collect();
67 Ok(webhooks)
68}
69
70pub fn get_webhook(db: &Database, id: i64) -> Result<Option<Webhook>> {
72 let conn = db.get()?;
73 let webhook = conn
74 .query_row(
75 "SELECT id, name, url, secret, events, active, created_at, updated_at FROM webhooks WHERE id = ?",
76 [id],
77 |row| {
78 Ok(Webhook {
79 id: row.get(0)?,
80 name: row.get(1)?,
81 url: row.get(2)?,
82 secret: row.get(3)?,
83 events: row.get(4)?,
84 active: row.get(5)?,
85 created_at: row.get(6)?,
86 updated_at: row.get(7)?,
87 })
88 },
89 )
90 .ok();
91 Ok(webhook)
92}
93
94pub fn list_deliveries(db: &Database, webhook_id: i64, limit: i64) -> Result<Vec<WebhookDelivery>> {
96 let conn = db.get()?;
97 let mut stmt = conn.prepare(
98 "SELECT id, webhook_id, event, payload, response_status, response_body, success, attempts, delivered_at
99 FROM webhook_deliveries WHERE webhook_id = ? ORDER BY delivered_at DESC LIMIT ?",
100 )?;
101 let deliveries = stmt
102 .query_map(rusqlite::params![webhook_id, limit], |row| {
103 Ok(WebhookDelivery {
104 id: row.get(0)?,
105 webhook_id: row.get(1)?,
106 event: row.get(2)?,
107 payload: row.get(3)?,
108 response_status: row.get(4)?,
109 response_body: row.get(5)?,
110 success: row.get(6)?,
111 attempts: row.get(7)?,
112 delivered_at: row.get(8)?,
113 })
114 })?
115 .filter_map(|r| r.ok())
116 .collect();
117 Ok(deliveries)
118}
119
120#[cfg(feature = "webhooks")]
124pub fn fire_webhooks(db: &Database, event: &str, payload: serde_json::Value) {
125 let webhooks = match list_webhooks(db) {
126 Ok(w) => w,
127 Err(e) => {
128 tracing::error!("Failed to list webhooks: {}", e);
129 return;
130 }
131 };
132
133 let active_webhooks: Vec<Webhook> = webhooks
134 .into_iter()
135 .filter(|w| w.active && w.handles_event(event))
136 .collect();
137
138 if active_webhooks.is_empty() {
139 return;
140 }
141
142 let event = event.to_string();
143 let db = db.clone();
144
145 tokio::spawn(async move {
146 for webhook in active_webhooks {
147 let payload_str = payload.to_string();
148 let delivery_id = uuid::Uuid::new_v4().to_string();
149
150 let signature = webhook.secret.as_ref().map(|secret| {
152 use hmac::{Hmac, Mac};
153 use sha2::Sha256;
154
155 type HmacSha256 = Hmac<Sha256>;
156 let mut mac =
157 HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC key length");
158 mac.update(payload_str.as_bytes());
159 let result = mac.finalize();
160 format!("sha256={}", hex::encode(result.into_bytes()))
161 });
162
163 let mut attempts = 0;
164 let max_attempts = 3;
165 let mut success = false;
166 let mut response_status = None;
167 let mut response_body = None;
168
169 while attempts < max_attempts && !success {
170 attempts += 1;
171
172 let client = reqwest::Client::new();
173 let mut request = client
174 .post(&webhook.url)
175 .header("Content-Type", "application/json")
176 .header("X-Pebble-Event", &event)
177 .header("X-Pebble-Delivery", &delivery_id)
178 .header("User-Agent", "Pebble-CMS-Webhook/1.0");
179
180 if let Some(ref sig) = signature {
181 request = request.header("X-Pebble-Signature", sig);
182 }
183
184 match request.body(payload_str.clone()).send().await {
185 Ok(resp) => {
186 let status = resp.status().as_u16() as i32;
187 response_status = Some(status);
188 response_body = resp.text().await.ok();
189 success = (200..300).contains(&status);
190 }
191 Err(e) => {
192 response_body = Some(e.to_string());
193 }
194 }
195
196 if !success && attempts < max_attempts {
197 let delay = std::time::Duration::from_secs(1 << (2 * (attempts - 1)));
198 tokio::time::sleep(delay).await;
199 }
200 }
201
202 if let Ok(conn) = db.get() {
204 let _ = conn.execute(
205 "INSERT INTO webhook_deliveries (webhook_id, event, payload, response_status, response_body, success, attempts)
206 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
207 rusqlite::params![
208 webhook.id,
209 event,
210 payload_str,
211 response_status,
212 response_body,
213 success,
214 attempts,
215 ],
216 );
217 }
218
219 if success {
220 tracing::info!(
221 "Webhook delivered: {} -> {} ({})",
222 event,
223 webhook.url,
224 response_status.unwrap_or(0)
225 );
226 } else {
227 tracing::warn!(
228 "Webhook failed after {} attempts: {} -> {}",
229 attempts,
230 event,
231 webhook.url
232 );
233 }
234 }
235 });
236}
237
238#[cfg(not(feature = "webhooks"))]
240pub fn fire_webhooks(_db: &Database, _event: &str, _payload: serde_json::Value) {
241 }