OXYGENHUB

Coverage / crates/hub/src/web/webhooks.rs 85.1%

126 of 148 lines run, 20 of 52 functions · run #493 on diffusion-pin-bookmark · run missed

1
//! Webhooks of a repository or an organisation (hub spec §12.2): the
2
//! settings form, deliveries and redelivery, shared by both scopes.
3
4
use super::{filters, Error, Page, RepoShell, Shell};
5
use crate::auth::Viewer;
6
use crate::db::{self, accounts};
7
use crate::Shared;
8
use askama::Template;
9
use serde::Deserialize;
10
11
/// Whose webhooks.
12
pub enum Scope {
13
    Repo { id: i64, slug: String },
14
    Org { id: i64, name: String },
15
}
16
17
impl Scope {
188×
    fn owns(&self, h: &db::hooks::Hook) -> bool {
198×
        match self {
204×
            Scope::Repo { id, .. } => h.repo_id == Some(*id),
214×
            Scope::Org { id, .. } => h.org_id == Some(*id),
22
        }
238×
    }
24
25
    /// Where the settings page is.
261×
    pub fn settings_url(&self) -> String {
271×
        match self {
280×
            Scope::Repo { slug, .. } => format!("/{slug}/settings"),
291×
            Scope::Org { name, .. } => format!("/orgs/{name}/settings"),
30
        }
311×
    }
32
33
    /// Where its webhooks are listed: a repository's own settings page
34
    /// (#278), an organisation's section.
351×
    pub fn webhooks_url(&self) -> String {
361×
        match self {
370×
            Scope::Repo { slug, .. } => format!("/{slug}/settings/webhooks"),
381×
            Scope::Org { name, .. } => format!("/orgs/{name}/settings#webhooks"),
39
        }
401×
    }
41
424×
    fn label(&self) -> &str {
434×
        match self {
443×
            Scope::Repo { slug, .. } => slug,
451×
            Scope::Org { name, .. } => name,
46
        }
474×
    }
48
}
49
50
#[derive(Deserialize)]
51
pub struct HookForm {
52
    #[serde(rename = "_csrf")]
53
    pub csrf_token: String,
54
    #[serde(default)]
55
    url: String,
56
    #[serde(default)]
57
    secret: String,
58
    #[serde(default = "star")]
59
    events: String,
60
    #[serde(default)]
61
    id: Option<i64>,
62
    #[serde(default)]
63
    action: String,
64
    /// `oxygenhub`, `discord` or `slack`; guessed from the URL when absent.
65
    #[serde(default)]
66
    format: String,
67
    /// For `update`: whether the hook stays active.
68
    #[serde(default)]
69
    active: Option<String>,
70
}
71
72
/// A hook as the settings table shows it: the URL masked, and how its
73
/// newest delivery went.
74
pub struct HookRow {
75
    pub hook: db::hooks::Hook,
76
    pub masked: String,
77
    pub last: Option<db::hooks::Delivery>,
78
}
79
80
impl HookRow {
81
    /// `✔ 204` or `✘ 400: why`, for the table.
8210×
    pub fn last_result(&self) -> Option<(bool, String)> {
8310×
        let d = self.last.as_ref()?;
846×
        let ok = d.delivered_ms.is_some();
856×
        let status = d.status.map(|s| s.to_string()).unwrap_or_else(|| "no answer".into());
866×
        let why: String = d.response.chars().take(80).collect();
876×
        Some((ok, if ok || why.is_empty() { status } else { format!("{status}: {why}") }))
8810×
    }
89
}
90
91164×
pub async fn rows(app: &Shared, hooks: Vec<db::hooks::Hook>) -> sqlx::Result<Vec<HookRow>> {
92164×
    let mut out = Vec::with_capacity(hooks.len());
93164×
    for hook in hooks {
9412×
        let last = db::hooks::last_attempt(&app.db, hook.id).await?;
9512×
        out.push(HookRow { masked: db::hooks::masked_url(&hook.url), last, hook });
96
    }
97164×
    Ok(out)
98164×
}
99
1004×
fn url_ok(url: &str) -> bool {
1014×
    (url.starts_with("http://") || url.starts_with("https://")) && url.len() <= 2000 && !url.contains(char::is_whitespace)
1024×
}
103
1044×
fn star() -> String {
1054×
    "*".into()
1064×
}
107
108
/// Apply the webhook form: `Ok(notice)` or `Err(message for the user)`.
10910×
pub async fn apply(app: &Shared, viewer: &Viewer, scope: &Scope, f: HookForm) -> sqlx::Result<Result<String, String>> {
11010×
    let owned = |h: Option<db::hooks::Hook>| h.filter(|h| scope.owns(h));
11110×
    Ok(Ok(match (f.action.as_str(), f.id) {
11210×
        ("delete", Some(id)) => {
1132×
            if owned(db::hooks::hook(&app.db, id).await?).is_some() {
1140×
                db::hooks::delete_hook(&app.db, id).await?;
1150×
                accounts::audit(&app.db, viewer.account(), "hook.deleted", scope.label(), "").await.ok();
1162×
            }
1172×
            "Webhook deleted.".to_string()
118
        }
1198×
        ("toggle", Some(id)) => {
1200×
            if let Some(h) = owned(db::hooks::hook(&app.db, id).await?) {
1210×
                db::hooks::set_hook_active(&app.db, id, !h.active).await?;
1220×
            }
1230×
            "Webhook updated.".to_string()
124
        }
1258×
        ("ping", Some(id)) => {
1262×
            if let Some(h) = owned(db::hooks::hook(&app.db, id).await?) {
1272×
                let delivery = crate::auth::random_hex(16);
1282×
                let env = hub_api::WebhookEnvelope {
1292×
                    event: "ping".into(),
1302×
                    delivery: delivery.clone(),
1312×
                    repository: None,
1322×
                    actor: viewer.account().map(|a| a.username.clone()).unwrap_or_default(),
1332×
                    at_ms: db::now_ms(),
1342×
                    data: serde_json::json!({ "zen": "changes commute" }),
135
                };
1362×
                db::hooks::queue_delivery(&app.db, h.id, &delivery, "ping", &serde_json::to_string(&env).unwrap_or_default()).await?;
1372×
                app.bus.delivery_waiting();
1380×
            }
1392×
            "Ping queued.".to_string()
140
        }
1416×
        ("update", Some(id)) => {
1423×
            let Some(h) = owned(db::hooks::hook(&app.db, id).await?) else { return Ok(Err("No such webhook.".into())) };
1433×
            let url = f.url.trim();
1443×
            if !url.is_empty() && !url_ok(url) {
1451×
                return Ok(Err("A webhook URL starts with http:// or https://.".into()));
1462×
            }
1472×
            let format = if db::hooks::FORMATS.contains(&f.format.as_str()) { f.format.as_str() } else { h.format.as_str() };
1482×
            let events = if f.events.trim().is_empty() { "*" } else { f.events.trim() };
1492×
            let secret = f.secret.trim();
1502×
            db::hooks::update_hook(
1512×
                &app.db,
1522×
                id,
1532×
                (!url.is_empty()).then_some(url),
1542×
                (!secret.is_empty()).then_some(secret),
1552×
                events,
1562×
                format,
1572×
                f.active.is_some(),
1582×
            )
1592×
            .await?;
1602×
            accounts::audit(
1612×
                &app.db,
1622×
                viewer.account(),
1632×
                "hook.updated",
1642×
                scope.label(),
1652×
                &db::hooks::masked_url(if url.is_empty() { &h.url } else { url }),
166
            )
1672×
            .await
1682×
            .ok();
1692×
            "Webhook saved.".to_string()
170
        }
171
        _ => {
1723×
            let url = f.url.trim();
1733×
            if !url_ok(url) {
1741×
                return Ok(Err("A webhook URL starts with http:// or https://.".into()));
1752×
            }
1762×
            let (repo_id, org_id) = match scope {
1771×
                Scope::Repo { id, .. } => (Some(*id), None),
1781×
                Scope::Org { id, .. } => (None, Some(*id)),
179
            };
1802×
            let h = db::hooks::create_hook(&app.db, repo_id, org_id, url, f.secret.trim(), f.events.trim()).await?;
1812×
            if db::hooks::FORMATS.contains(&f.format.as_str()) && f.format != h.format {
1821×
                db::hooks::set_hook_format(&app.db, h.id, &f.format).await?;
1831×
            }
184
            // Never the full URL in the log: it can hold a token.
1852×
            accounts::audit(&app.db, viewer.account(), "hook.created", scope.label(), &db::hooks::masked_url(url)).await.ok();
1862×
            "Webhook added.".to_string()
187
        }
188
    }))
18910×
}
190
191
#[derive(Template)]
192
#[template(path = "deliveries.html")]
193
pub struct DeliveriesPage {
194
    shell: Shell,
195
    /// The repository's tabs, for a repository's webhook.
196
    repo: Option<RepoShell>,
197
    hook: db::hooks::Hook,
198
    rows: Vec<db::hooks::Delivery>,
199
    settings_url: String,
200
    /// The page listing the webhooks, to go back to.
201
    back_url: String,
202
}
203
2042×
pub async fn deliveries_page(
2052×
    app: &Shared,
2062×
    shell: Shell,
2072×
    scope: &Scope,
2082×
    repo: Option<RepoShell>,
2092×
    id: i64,
2102×
) -> Result<Page<DeliveriesPage>, Error> {
2112×
    let hook = db::hooks::hook(&app.db, id)
2122×
        .await
2132×
        .map_err(|e| Error::from_db(shell.clone(), e))?
2142×
        .filter(|h| scope.owns(h))
2152×
        .ok_or_else(|| Error::not_found(shell.clone()))?;
2161×
    let rows = db::hooks::deliveries_for_hook(&app.db, id, 100).await.map_err(|e| Error::from_db(shell.clone(), e))?;
2171×
    Ok(Page(DeliveriesPage { shell, repo, hook, rows, settings_url: scope.settings_url(), back_url: scope.webhooks_url() }))
2182×
}
219
2200×
pub async fn redeliver(app: &Shared, scope: &Scope, id: i64, delivery: i64) -> sqlx::Result<()> {
2210×
    let mine = db::hooks::hook(&app.db, id).await?.is_some_and(|h| scope.owns(&h));
2220×
    if mine && db::hooks::delivery(&app.db, delivery).await?.is_some_and(|d| d.hook_id == id) {
2230×
        db::hooks::redeliver(&app.db, delivery).await?;
2240×
        app.bus.delivery_waiting();
2250×
    }
2260×
    Ok(())
2270×
}