//! Webhooks (hub spec ยง12.2): queueing an event for every hook that wants //! it, signing and delivering with retries, and the payload envelope. use crate::db::{self, hooks as hdb}; use crate::Shared; use hub_api::WebhookEnvelope; /// Queue `event` for the hooks of a repository (and its organisation). pub async fn emit(app: &Shared, repo: &db::repos::RepoRow, event: &str, actor: &str, data: serde_json::Value) { let hooks = match hdb::hooks_hearing(&app.db, repo.id, &repo.owner).await { Ok(h) => h, Err(e) => { tracing::warn!("hooks: {e}"); return; } }; if hooks.is_empty() { return; } let repository = hub_api::Repository { owner: repo.owner.clone(), name: repo.name.clone(), description: repo.description.clone(), default_bookmark: repo.default_bookmark.clone(), visibility: repo.visibility(), created_ms: repo.created_ms, updated_ms: repo.updated_ms, clone_url: format!("{}/{}", app.config.base_url(), repo.slug()), }; for h in hooks.into_iter().filter(|h| h.wants(event)) { let delivery = crate::auth::random_hex(16); let envelope = WebhookEnvelope { event: event.into(), delivery: delivery.clone(), repository: Some(repository.clone()), actor: actor.into(), at_ms: db::now_ms(), data: data.clone(), }; let payload = serde_json::to_string(&envelope).unwrap_or_default(); if let Err(e) = hdb::queue_delivery(&app.db, h.id, &delivery, event, &payload).await { tracing::warn!("queueing delivery: {e}"); } } app.events.notify_deliveries(); } /// `X-Oxyhub-Signature` for a body: keyed BLAKE3 with the hook's secret. pub fn signature(secret: &str, body: &[u8]) -> String { let key = blake3::hash(secret.as_bytes()); format!("blake3-keyed={}", hex::encode(blake3::keyed_hash(key.as_bytes(), body).as_bytes())) } /// Deliver everything that is due; returns how many were attempted. pub async fn deliver_due(app: &Shared) -> usize { let due = match hdb::due_deliveries(&app.db, 50).await { Ok(d) => d, Err(_) => return 0, }; let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(20)) .user_agent(format!("oxygenhub/{}", env!("CARGO_PKG_VERSION"))) .build(); let Ok(client) = client else { return 0 }; let mut n = 0; for d in due { let Ok(Some(hook)) = hdb::hook(&app.db, d.hook_id).await else { continue }; n += 1; let (status, response) = match client .post(&hook.url) .header("content-type", "application/json") .header("x-oxyhub-event", &d.event) .header("x-oxyhub-delivery", &d.delivery) .header("x-oxyhub-signature", signature(&hook.secret, d.payload.as_bytes())) .body(d.payload.clone()) .send() .await { Ok(r) => { let status = r.status().as_u16() as i64; let text = r.text().await.unwrap_or_default(); (Some(status), text) } Err(e) => (None, e.to_string()), }; hdb::record_attempt(&app.db, d.id, d.attempts, status, &response).await.ok(); } n } /// The background worker: delivers when nudged and every minute anyway. pub async fn worker(app: Shared) { let mut rx = app.events.deliveries.subscribe(); loop { deliver_due(&app).await; let cutoff = db::now_ms() - app.config.retention.webhook_log_days as i64 * 86_400_000; hdb::prune_deliveries(&app.db, cutoff).await.ok(); tokio::select! { _ = rx.recv() => {} _ = tokio::time::sleep(std::time::Duration::from_secs(60)) => {} } } } #[cfg(test)] mod tests { use super::*; #[test] fn signatures_are_keyed() { let a = signature("s1", b"body"); assert!(a.starts_with("blake3-keyed=")); assert_ne!(a, signature("s2", b"body")); assert_ne!(a, signature("s1", b"other")); } }