Skip to main content

pebble_cms/services/
webhook.rs

1use crate::models::{Webhook, WebhookDelivery};
2use crate::Database;
3use anyhow::Result;
4
5/// Create a new webhook.
6pub 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
21/// Update an existing webhook.
22pub 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
39/// Delete a webhook by ID.
40pub 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
46/// List all webhooks.
47pub 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
70/// Get a single webhook by ID.
71pub 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
94/// List recent deliveries for a webhook.
95pub 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/// Fire webhooks for a given event. Sends HTTP POST to each matching active webhook.
121/// Uses HMAC-SHA256 to sign the payload if the webhook has a secret.
122/// Runs asynchronously via tokio::spawn — does not block the caller.
123#[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            // Compute HMAC signature if secret is set
151            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            // Log delivery
203            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/// No-op version when webhooks feature is disabled.
239#[cfg(not(feature = "webhooks"))]
240pub fn fire_webhooks(_db: &Database, _event: &str, _payload: serde_json::Value) {
241    // Webhooks feature not enabled
242}