//! Webhooks of a repository or an organisation (hub spec §12.2): the //! settings form, deliveries and redelivery, shared by both scopes. use super::{filters, Error, Page, RepoShell, Shell}; use crate::auth::Viewer; use crate::db::{self, accounts}; use crate::Shared; use askama::Template; use serde::Deserialize; /// Whose webhooks. pub enum Scope { Repo { id: i64, slug: String }, Org { id: i64, name: String }, } impl Scope { fn owns(&self, h: &db::hooks::Hook) -> bool { match self { Scope::Repo { id, .. } => h.repo_id == Some(*id), Scope::Org { id, .. } => h.org_id == Some(*id), } } /// Where the settings page is. pub fn settings_url(&self) -> String { match self { Scope::Repo { slug, .. } => format!("/{slug}/settings"), Scope::Org { name, .. } => format!("/orgs/{name}/settings"), } } /// Where its webhooks are listed: a repository's own settings page /// (#278), an organisation's section. pub fn webhooks_url(&self) -> String { match self { Scope::Repo { slug, .. } => format!("/{slug}/settings/webhooks"), Scope::Org { name, .. } => format!("/orgs/{name}/settings#webhooks"), } } fn label(&self) -> &str { match self { Scope::Repo { slug, .. } => slug, Scope::Org { name, .. } => name, } } } #[derive(Deserialize)] pub struct HookForm { #[serde(rename = "_csrf")] pub csrf_token: String, #[serde(default)] url: String, #[serde(default)] secret: String, #[serde(default = "star")] events: String, #[serde(default)] id: Option, #[serde(default)] action: String, /// `oxygenhub`, `discord` or `slack`; guessed from the URL when absent. #[serde(default)] format: String, /// For `update`: whether the hook stays active. #[serde(default)] active: Option, } /// A hook as the settings table shows it: the URL masked, and how its /// newest delivery went. pub struct HookRow { pub hook: db::hooks::Hook, pub masked: String, pub last: Option, } impl HookRow { /// `✔ 204` or `✘ 400: why`, for the table. pub fn last_result(&self) -> Option<(bool, String)> { let d = self.last.as_ref()?; let ok = d.delivered_ms.is_some(); let status = d.status.map(|s| s.to_string()).unwrap_or_else(|| "no answer".into()); let why: String = d.response.chars().take(80).collect(); Some((ok, if ok || why.is_empty() { status } else { format!("{status}: {why}") })) } } pub async fn rows(app: &Shared, hooks: Vec) -> sqlx::Result> { let mut out = Vec::with_capacity(hooks.len()); for hook in hooks { let last = db::hooks::last_attempt(&app.db, hook.id).await?; out.push(HookRow { masked: db::hooks::masked_url(&hook.url), last, hook }); } Ok(out) } fn url_ok(url: &str) -> bool { (url.starts_with("http://") || url.starts_with("https://")) && url.len() <= 2000 && !url.contains(char::is_whitespace) } fn star() -> String { "*".into() } /// Apply the webhook form: `Ok(notice)` or `Err(message for the user)`. pub async fn apply(app: &Shared, viewer: &Viewer, scope: &Scope, f: HookForm) -> sqlx::Result> { let owned = |h: Option| h.filter(|h| scope.owns(h)); Ok(Ok(match (f.action.as_str(), f.id) { ("delete", Some(id)) => { if owned(db::hooks::hook(&app.db, id).await?).is_some() { db::hooks::delete_hook(&app.db, id).await?; accounts::audit(&app.db, viewer.account(), "hook.deleted", scope.label(), "").await.ok(); } "Webhook deleted.".to_string() } ("toggle", Some(id)) => { if let Some(h) = owned(db::hooks::hook(&app.db, id).await?) { db::hooks::set_hook_active(&app.db, id, !h.active).await?; } "Webhook updated.".to_string() } ("ping", Some(id)) => { if let Some(h) = owned(db::hooks::hook(&app.db, id).await?) { let delivery = crate::auth::random_hex(16); let env = hub_api::WebhookEnvelope { event: "ping".into(), delivery: delivery.clone(), repository: None, actor: viewer.account().map(|a| a.username.clone()).unwrap_or_default(), at_ms: db::now_ms(), data: serde_json::json!({ "zen": "changes commute" }), }; db::hooks::queue_delivery(&app.db, h.id, &delivery, "ping", &serde_json::to_string(&env).unwrap_or_default()).await?; app.bus.delivery_waiting(); } "Ping queued.".to_string() } ("update", Some(id)) => { let Some(h) = owned(db::hooks::hook(&app.db, id).await?) else { return Ok(Err("No such webhook.".into())) }; let url = f.url.trim(); if !url.is_empty() && !url_ok(url) { return Ok(Err("A webhook URL starts with http:// or https://.".into())); } let format = if db::hooks::FORMATS.contains(&f.format.as_str()) { f.format.as_str() } else { h.format.as_str() }; let events = if f.events.trim().is_empty() { "*" } else { f.events.trim() }; let secret = f.secret.trim(); db::hooks::update_hook( &app.db, id, (!url.is_empty()).then_some(url), (!secret.is_empty()).then_some(secret), events, format, f.active.is_some(), ) .await?; accounts::audit( &app.db, viewer.account(), "hook.updated", scope.label(), &db::hooks::masked_url(if url.is_empty() { &h.url } else { url }), ) .await .ok(); "Webhook saved.".to_string() } _ => { let url = f.url.trim(); if !url_ok(url) { return Ok(Err("A webhook URL starts with http:// or https://.".into())); } let (repo_id, org_id) = match scope { Scope::Repo { id, .. } => (Some(*id), None), Scope::Org { id, .. } => (None, Some(*id)), }; let h = db::hooks::create_hook(&app.db, repo_id, org_id, url, f.secret.trim(), f.events.trim()).await?; if db::hooks::FORMATS.contains(&f.format.as_str()) && f.format != h.format { db::hooks::set_hook_format(&app.db, h.id, &f.format).await?; } // Never the full URL in the log: it can hold a token. accounts::audit(&app.db, viewer.account(), "hook.created", scope.label(), &db::hooks::masked_url(url)).await.ok(); "Webhook added.".to_string() } })) } #[derive(Template)] #[template(path = "deliveries.html")] pub struct DeliveriesPage { shell: Shell, /// The repository's tabs, for a repository's webhook. repo: Option, hook: db::hooks::Hook, rows: Vec, settings_url: String, /// The page listing the webhooks, to go back to. back_url: String, } pub async fn deliveries_page( app: &Shared, shell: Shell, scope: &Scope, repo: Option, id: i64, ) -> Result, Error> { let hook = db::hooks::hook(&app.db, id) .await .map_err(|e| Error::from_db(shell.clone(), e))? .filter(|h| scope.owns(h)) .ok_or_else(|| Error::not_found(shell.clone()))?; let rows = db::hooks::deliveries_for_hook(&app.db, id, 100).await.map_err(|e| Error::from_db(shell.clone(), e))?; Ok(Page(DeliveriesPage { shell, repo, hook, rows, settings_url: scope.settings_url(), back_url: scope.webhooks_url() })) } pub async fn redeliver(app: &Shared, scope: &Scope, id: i64, delivery: i64) -> sqlx::Result<()> { let mine = db::hooks::hook(&app.db, id).await?.is_some_and(|h| scope.owns(&h)); if mine && db::hooks::delivery(&app.db, delivery).await?.is_some_and(|d| d.hook_id == id) { db::hooks::redeliver(&app.db, delivery).await?; app.bus.delivery_waiting(); } Ok(()) }