OXYGENHUB
# 9a777fec4ed9 oxygenhub / crates / hub / src / web / webhooks.rs
7.8 KiB · Rust · last changed Webhooks: Discord and Slack formats (Discord hooks switched over), a masked URL that wraps, the last delivery in the table, an edit form, and web issue events delivered 1 month ago source blame history raw
8fb35df7 Andy McCall ↶ 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 {
18
    fn owns(&self, h: &db::hooks::Hook) -> bool {
19
        match self {
20
            Scope::Repo { id, .. } => h.repo_id == Some(*id),
21
            Scope::Org { id, .. } => h.org_id == Some(*id),
22
        }
23
    }
24
25
    /// Where the settings page is.
26
    pub fn settings_url(&self) -> String {
27
        match self {
28
            Scope::Repo { slug, .. } => format!("/{slug}/settings"),
29
            Scope::Org { name, .. } => format!("/orgs/{name}/settings"),
30
        }
31
    }
32
33
    fn label(&self) -> &str {
34
        match self {
35
            Scope::Repo { slug, .. } => slug,
36
            Scope::Org { name, .. } => name,
37
        }
38
    }
39
}
40
41
#[derive(Deserialize)]
42
pub struct HookForm {
43
    #[serde(rename = "_csrf")]
44
    pub csrf_token: String,
45
    #[serde(default)]
46
    url: String,
47
    #[serde(default)]
48
    secret: String,
49
    #[serde(default = "star")]
50
    events: String,
51
    #[serde(default)]
52
    id: Option<i64>,
53
    #[serde(default)]
54
    action: String,
9756c67d Andy McCall ↶ 55
    /// `oxygenhub`, `discord` or `slack`; guessed from the URL when absent.
56
    #[serde(default)]
57
    format: String,
58
    /// For `update`: whether the hook stays active.
59
    #[serde(default)]
60
    active: Option<String>,
61
}
62
63
/// A hook as the settings table shows it: the URL masked, and how its
64
/// newest delivery went.
65
pub struct HookRow {
66
    pub hook: db::hooks::Hook,
67
    pub masked: String,
68
    pub last: Option<db::hooks::Delivery>,
69
}
70
71
impl HookRow {
72
    /// `✔ 204` or `✘ 400: why`, for the table.
73
    pub fn last_result(&self) -> Option<(bool, String)> {
74
        let d = self.last.as_ref()?;
75
        let ok = d.delivered_ms.is_some();
76
        let status = d.status.map(|s| s.to_string()).unwrap_or_else(|| "no answer".into());
77
        let why: String = d.response.chars().take(80).collect();
78
        Some((ok, if ok || why.is_empty() { status } else { format!("{status}: {why}") }))
79
    }
80
}
81
82
pub async fn rows(app: &Shared, hooks: Vec<db::hooks::Hook>) -> sqlx::Result<Vec<HookRow>> {
83
    let mut out = Vec::with_capacity(hooks.len());
84
    for hook in hooks {
85
        let last = db::hooks::last_attempt(&app.db, hook.id).await?;
86
        out.push(HookRow { masked: db::hooks::masked_url(&hook.url), last, hook });
87
    }
88
    Ok(out)
89
}
90
91
fn url_ok(url: &str) -> bool {
92
    (url.starts_with("http://") || url.starts_with("https://")) && url.len() <= 2000 && !url.contains(char::is_whitespace)
8fb35df7 Andy McCall ↶ 93
}
94
95
fn star() -> String {
96
    "*".into()
97
}
98
99
/// Apply the webhook form: `Ok(notice)` or `Err(message for the user)`.
100
pub async fn apply(app: &Shared, viewer: &Viewer, scope: &Scope, f: HookForm) -> sqlx::Result<Result<String, String>> {
101
    let owned = |h: Option<db::hooks::Hook>| h.filter(|h| scope.owns(h));
102
    Ok(Ok(match (f.action.as_str(), f.id) {
103
        ("delete", Some(id)) => {
104
            if owned(db::hooks::hook(&app.db, id).await?).is_some() {
105
                db::hooks::delete_hook(&app.db, id).await?;
106
                accounts::audit(&app.db, viewer.account(), "hook.deleted", scope.label(), "").await.ok();
107
            }
108
            "Webhook deleted.".to_string()
109
        }
110
        ("toggle", Some(id)) => {
111
            if let Some(h) = owned(db::hooks::hook(&app.db, id).await?) {
112
                db::hooks::set_hook_active(&app.db, id, !h.active).await?;
113
            }
114
            "Webhook updated.".to_string()
115
        }
116
        ("ping", Some(id)) => {
117
            if let Some(h) = owned(db::hooks::hook(&app.db, id).await?) {
118
                let delivery = crate::auth::random_hex(16);
119
                let env = hub_api::WebhookEnvelope {
120
                    event: "ping".into(),
121
                    delivery: delivery.clone(),
122
                    repository: None,
123
                    actor: viewer.account().map(|a| a.username.clone()).unwrap_or_default(),
124
                    at_ms: db::now_ms(),
125
                    data: serde_json::json!({ "zen": "changes commute" }),
126
                };
127
                db::hooks::queue_delivery(&app.db, h.id, &delivery, "ping", &serde_json::to_string(&env).unwrap_or_default()).await?;
128
                app.events.notify_deliveries();
129
            }
130
            "Ping queued.".to_string()
131
        }
9756c67d Andy McCall ↶ 132
        ("update", Some(id)) => {
133
            let Some(h) = owned(db::hooks::hook(&app.db, id).await?) else { return Ok(Err("No such webhook.".into())) };
134
            let url = f.url.trim();
135
            if !url.is_empty() && !url_ok(url) {
136
                return Ok(Err("A webhook URL starts with http:// or https://.".into()));
137
            }
138
            let format = if db::hooks::FORMATS.contains(&f.format.as_str()) { f.format.as_str() } else { h.format.as_str() };
139
            let events = if f.events.trim().is_empty() { "*" } else { f.events.trim() };
140
            let secret = f.secret.trim();
141
            db::hooks::update_hook(
142
                &app.db,
143
                id,
144
                (!url.is_empty()).then_some(url),
145
                (!secret.is_empty()).then_some(secret),
146
                events,
147
                format,
148
                f.active.is_some(),
149
            )
150
            .await?;
151
            accounts::audit(
152
                &app.db,
153
                viewer.account(),
154
                "hook.updated",
155
                scope.label(),
156
                &db::hooks::masked_url(if url.is_empty() { &h.url } else { url }),
157
            )
158
            .await
159
            .ok();
160
            "Webhook saved.".to_string()
161
        }
8fb35df7 Andy McCall ↶ 162
        _ => {
9756c67d Andy McCall ↶ 163
            let url = f.url.trim();
164
            if !url_ok(url) {
8fb35df7 Andy McCall ↶ 165
                return Ok(Err("A webhook URL starts with http:// or https://.".into()));
166
            }
167
            let (repo_id, org_id) = match scope {
168
                Scope::Repo { id, .. } => (Some(*id), None),
169
                Scope::Org { id, .. } => (None, Some(*id)),
170
            };
9756c67d Andy McCall ↶ 171
            let h = db::hooks::create_hook(&app.db, repo_id, org_id, url, f.secret.trim(), f.events.trim()).await?;
172
            if db::hooks::FORMATS.contains(&f.format.as_str()) && f.format != h.format {
173
                db::hooks::set_hook_format(&app.db, h.id, &f.format).await?;
174
            }
175
            // Never the full URL in the log: it can hold a token.
176
            accounts::audit(&app.db, viewer.account(), "hook.created", scope.label(), &db::hooks::masked_url(url)).await.ok();
8fb35df7 Andy McCall ↶ 177
            "Webhook added.".to_string()
178
        }
179
    }))
180
}
181
182
#[derive(Template)]
183
#[template(path = "deliveries.html")]
184
pub struct DeliveriesPage {
185
    shell: Shell,
186
    /// The repository's tabs, for a repository's webhook.
187
    repo: Option<RepoShell>,
188
    hook: db::hooks::Hook,
189
    rows: Vec<db::hooks::Delivery>,
190
    settings_url: String,
191
}
192
193
pub async fn deliveries_page(
194
    app: &Shared,
195
    shell: Shell,
196
    scope: &Scope,
197
    repo: Option<RepoShell>,
198
    id: i64,
199
) -> Result<Page<DeliveriesPage>, Error> {
200
    let hook = db::hooks::hook(&app.db, id)
201
        .await
202
        .map_err(|e| Error::from_db(shell.clone(), e))?
203
        .filter(|h| scope.owns(h))
204
        .ok_or_else(|| Error::not_found(shell.clone()))?;
205
    let rows = db::hooks::deliveries_for_hook(&app.db, id, 100).await.map_err(|e| Error::from_db(shell.clone(), e))?;
206
    Ok(Page(DeliveriesPage { shell, repo, hook, rows, settings_url: scope.settings_url() }))
207
}
208
209
pub async fn redeliver(app: &Shared, scope: &Scope, id: i64, delivery: i64) -> sqlx::Result<()> {
210
    let mine = db::hooks::hook(&app.db, id).await?.is_some_and(|h| scope.owns(&h));
211
    if mine && db::hooks::delivery(&app.db, delivery).await?.is_some_and(|d| d.hook_id == id) {
212
        db::hooks::redeliver(&app.db, delivery).await?;
213
        app.events.notify_deliveries();
214
    }
215
    Ok(())
216
}