//! 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"), } } 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, } 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.events.notify_deliveries(); } "Ping queued.".to_string() } _ => { if !(f.url.starts_with("http://") || f.url.starts_with("https://")) { 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)), }; db::hooks::create_hook(&app.db, repo_id, org_id, f.url.trim(), f.secret.trim(), f.events.trim()).await?; accounts::audit(&app.db, viewer.account(), "hook.created", scope.label(), f.url.trim()).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, } 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() })) } 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.events.notify_deliveries(); } Ok(()) }