OXYGENHUB
# adbc38e7ea09 oxygenhub / crates / hub / src / web / webhooks.rs
8.2 KiB · Rust · last changed Repository settings: one page per setting under a grouped sidebar (#278) 15 days ago source blame history raw
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
    /// Where its webhooks are listed: a repository's own settings page
34
    /// (#278), an organisation's section.
35
    pub fn webhooks_url(&self) -> String {
36
        match self {
37
            Scope::Repo { slug, .. } => format!("/{slug}/settings/webhooks"),
38
            Scope::Org { name, .. } => format!("/orgs/{name}/settings#webhooks"),
39
        }
40
    }
41
42
    fn label(&self) -> &str {
43
        match self {
44
            Scope::Repo { slug, .. } => slug,
45
            Scope::Org { name, .. } => name,
46
        }
47
    }
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.
82
    pub fn last_result(&self) -> Option<(bool, String)> {
83
        let d = self.last.as_ref()?;
84
        let ok = d.delivered_ms.is_some();
85
        let status = d.status.map(|s| s.to_string()).unwrap_or_else(|| "no answer".into());
86
        let why: String = d.response.chars().take(80).collect();
87
        Some((ok, if ok || why.is_empty() { status } else { format!("{status}: {why}") }))
88
    }
89
}
90
91
pub async fn rows(app: &Shared, hooks: Vec<db::hooks::Hook>) -> sqlx::Result<Vec<HookRow>> {
92
    let mut out = Vec::with_capacity(hooks.len());
93
    for hook in hooks {
94
        let last = db::hooks::last_attempt(&app.db, hook.id).await?;
95
        out.push(HookRow { masked: db::hooks::masked_url(&hook.url), last, hook });
96
    }
97
    Ok(out)
98
}
99
100
fn url_ok(url: &str) -> bool {
101
    (url.starts_with("http://") || url.starts_with("https://")) && url.len() <= 2000 && !url.contains(char::is_whitespace)
102
}
103
104
fn star() -> String {
105
    "*".into()
106
}
107
108
/// Apply the webhook form: `Ok(notice)` or `Err(message for the user)`.
109
pub async fn apply(app: &Shared, viewer: &Viewer, scope: &Scope, f: HookForm) -> sqlx::Result<Result<String, String>> {
110
    let owned = |h: Option<db::hooks::Hook>| h.filter(|h| scope.owns(h));
111
    Ok(Ok(match (f.action.as_str(), f.id) {
112
        ("delete", Some(id)) => {
113
            if owned(db::hooks::hook(&app.db, id).await?).is_some() {
114
                db::hooks::delete_hook(&app.db, id).await?;
115
                accounts::audit(&app.db, viewer.account(), "hook.deleted", scope.label(), "").await.ok();
116
            }
117
            "Webhook deleted.".to_string()
118
        }
119
        ("toggle", Some(id)) => {
120
            if let Some(h) = owned(db::hooks::hook(&app.db, id).await?) {
121
                db::hooks::set_hook_active(&app.db, id, !h.active).await?;
122
            }
123
            "Webhook updated.".to_string()
124
        }
125
        ("ping", Some(id)) => {
126
            if let Some(h) = owned(db::hooks::hook(&app.db, id).await?) {
127
                let delivery = crate::auth::random_hex(16);
128
                let env = hub_api::WebhookEnvelope {
129
                    event: "ping".into(),
130
                    delivery: delivery.clone(),
131
                    repository: None,
132
                    actor: viewer.account().map(|a| a.username.clone()).unwrap_or_default(),
133
                    at_ms: db::now_ms(),
134
                    data: serde_json::json!({ "zen": "changes commute" }),
135
                };
136
                db::hooks::queue_delivery(&app.db, h.id, &delivery, "ping", &serde_json::to_string(&env).unwrap_or_default()).await?;
137
                app.bus.delivery_waiting();
138
            }
139
            "Ping queued.".to_string()
140
        }
141
        ("update", Some(id)) => {
142
            let Some(h) = owned(db::hooks::hook(&app.db, id).await?) else { return Ok(Err("No such webhook.".into())) };
143
            let url = f.url.trim();
144
            if !url.is_empty() && !url_ok(url) {
145
                return Ok(Err("A webhook URL starts with http:// or https://.".into()));
146
            }
147
            let format = if db::hooks::FORMATS.contains(&f.format.as_str()) { f.format.as_str() } else { h.format.as_str() };
148
            let events = if f.events.trim().is_empty() { "*" } else { f.events.trim() };
149
            let secret = f.secret.trim();
150
            db::hooks::update_hook(
151
                &app.db,
152
                id,
153
                (!url.is_empty()).then_some(url),
154
                (!secret.is_empty()).then_some(secret),
155
                events,
156
                format,
157
                f.active.is_some(),
158
            )
159
            .await?;
160
            accounts::audit(
161
                &app.db,
162
                viewer.account(),
163
                "hook.updated",
164
                scope.label(),
165
                &db::hooks::masked_url(if url.is_empty() { &h.url } else { url }),
166
            )
167
            .await
168
            .ok();
169
            "Webhook saved.".to_string()
170
        }
171
        _ => {
172
            let url = f.url.trim();
173
            if !url_ok(url) {
174
                return Ok(Err("A webhook URL starts with http:// or https://.".into()));
175
            }
176
            let (repo_id, org_id) = match scope {
177
                Scope::Repo { id, .. } => (Some(*id), None),
178
                Scope::Org { id, .. } => (None, Some(*id)),
179
            };
180
            let h = db::hooks::create_hook(&app.db, repo_id, org_id, url, f.secret.trim(), f.events.trim()).await?;
181
            if db::hooks::FORMATS.contains(&f.format.as_str()) && f.format != h.format {
182
                db::hooks::set_hook_format(&app.db, h.id, &f.format).await?;
183
            }
184
            // Never the full URL in the log: it can hold a token.
185
            accounts::audit(&app.db, viewer.account(), "hook.created", scope.label(), &db::hooks::masked_url(url)).await.ok();
186
            "Webhook added.".to_string()
187
        }
188
    }))
189
}
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
204
pub async fn deliveries_page(
205
    app: &Shared,
206
    shell: Shell,
207
    scope: &Scope,
208
    repo: Option<RepoShell>,
209
    id: i64,
210
) -> Result<Page<DeliveriesPage>, Error> {
211
    let hook = db::hooks::hook(&app.db, id)
212
        .await
213
        .map_err(|e| Error::from_db(shell.clone(), e))?
214
        .filter(|h| scope.owns(h))
215
        .ok_or_else(|| Error::not_found(shell.clone()))?;
216
    let rows = db::hooks::deliveries_for_hook(&app.db, id, 100).await.map_err(|e| Error::from_db(shell.clone(), e))?;
217
    Ok(Page(DeliveriesPage { shell, repo, hook, rows, settings_url: scope.settings_url(), back_url: scope.webhooks_url() }))
218
}
219
220
pub async fn redeliver(app: &Shared, scope: &Scope, id: i64, delivery: i64) -> sqlx::Result<()> {
221
    let mine = db::hooks::hook(&app.db, id).await?.is_some_and(|h| scope.owns(&h));
222
    if mine && db::hooks::delivery(&app.db, delivery).await?.is_some_and(|d| d.hook_id == id) {
223
        db::hooks::redeliver(&app.db, delivery).await?;
224
        app.bus.delivery_waiting();
225
    }
226
    Ok(())
227
}