OXYGENHUB
# aa0bfbd11f02 oxygenhub / crates / hub / src / hooks.rs
13.3 KiB · Rust · last changed Outbound requests only to public addresses 20 days ago source blame history raw
1
//! Webhooks (hub spec §12.2): queueing an event for every hook that wants
2
//! it, signing and delivering with retries, and the payload envelope.
3
4
use crate::db::{self, hooks as hdb};
5
use crate::Shared;
6
use hub_api::WebhookEnvelope;
7
8
/// Queue `event` for the hooks of a repository (and its organisation).
9
pub async fn emit(app: &Shared, repo: &db::repos::RepoRow, event: &str, actor: &str, data: serde_json::Value) {
10
    let hooks = match hdb::hooks_hearing(&app.db, repo.id, &repo.owner).await {
11
        Ok(h) => h,
12
        Err(e) => {
13
            tracing::warn!("hooks: {e}");
14
            return;
15
        }
16
    };
17
    if hooks.is_empty() {
18
        return;
19
    }
20
    let repository = hub_api::Repository {
21
        owner: repo.owner.clone(),
22
        name: repo.name.clone(),
23
        description: repo.description.clone(),
24
        default_bookmark: repo.default_bookmark.clone(),
25
        visibility: repo.visibility(),
26
        created_ms: repo.created_ms,
27
        updated_ms: repo.updated_ms,
28
        clone_url: format!("{}/{}", app.config.base_url(), repo.slug()),
29
    };
30
    for h in hooks.into_iter().filter(|h| h.wants(event)) {
31
        let delivery = crate::auth::random_hex(16);
32
        let envelope = WebhookEnvelope {
33
            event: event.into(),
34
            delivery: delivery.clone(),
35
            repository: Some(repository.clone()),
36
            actor: actor.into(),
37
            at_ms: db::now_ms(),
38
            data: data.clone(),
39
        };
40
        let payload = serde_json::to_string(&envelope).unwrap_or_default();
41
        if let Err(e) = hdb::queue_delivery(&app.db, h.id, &delivery, event, &payload).await {
42
            tracing::warn!("queueing delivery: {e}");
43
        }
44
    }
45
    app.events.notify_deliveries();
46
}
47
48
/// What a chat message says about an event: a title, the page it links
49
/// to (absolute) and a short detail.
50
pub fn summary(env: &WebhookEnvelope, base: &str) -> (String, String, String) {
51
    let repo = env.repository.as_ref().map(|r| format!("{}/{}", r.owner, r.name)).unwrap_or_default();
52
    let page = |path: &str| format!("{}/{repo}{path}", base.trim_end_matches('/'));
53
    let d = &env.data;
54
    let text = |v: &serde_json::Value| v.as_str().unwrap_or("").to_string();
55
    let num = |v: &serde_json::Value| v.as_i64().unwrap_or(0);
56
    let actor = if env.actor.is_empty() { "Someone".to_string() } else { env.actor.clone() };
57
    let issue_n = if d["issue"]["number"].is_i64() { num(&d["issue"]["number"]) } else { num(&d["number"]) };
58
    let issue_title = text(&d["issue"]["title"]);
59
    match env.event.as_str() {
60
        "ping" => (
61
            "OxygenHub is connected".into(),
62
            if repo.is_empty() { base.to_string() } else { page("") },
63
            "Events from OxygenHub will arrive here.".into(),
64
        ),
65
        "push" => {
66
            let lines: Vec<String> = d["updates"]
67
                .as_array()
68
                .map(|u| {
69
                    u.iter()
70
                        .map(|x| {
71
                            let n = num(&x["changes"]);
72
                            format!("{}: {n} change{}", text(&x["reference"]), if n == 1 { "" } else { "s" })
73
                        })
74
                        .collect()
75
                })
76
                .unwrap_or_default();
77
            let first = d["updates"][0]["reference"].as_str().unwrap_or("").to_string();
78
            (format!("{actor} pushed to {repo}"), page(&format!("/changes/{first}")), lines.join("\n"))
79
        }
80
        "issue.opened" => {
81
            (format!("{actor} opened issue #{issue_n}: {issue_title}"), page(&format!("/issue/{issue_n}")), text(&d["issue"]["body"]))
82
        }
83
        "issue.closed" | "issue.reopened" | "issue.edited" => {
84
            let verb = env.event.trim_start_matches("issue.");
85
            (format!("{actor} {verb} issue #{issue_n}: {issue_title}"), page(&format!("/issue/{issue_n}")), String::new())
86
        }
87
        "comment.issue" => {
88
            let body = if d["comment"]["body"].is_string() { text(&d["comment"]["body"]) } else { String::new() };
89
            (
90
                format!(
91
                    "{actor} commented on issue #{issue_n}{}",
92
                    if issue_title.is_empty() { String::new() } else { format!(": {issue_title}") }
93
                ),
94
                page(&format!("/issue/{issue_n}")),
95
                body,
96
            )
97
        }
98
        "review" => (
99
            format!("{actor} reviewed proposal #{}: {}", num(&d["number"]), text(&d["verdict"])),
100
            page(&format!("/proposal/{}", num(&d["number"]))),
101
            String::new(),
102
        ),
103
        "release.published" => (
104
            format!("{actor} released {} ({})", text(&d["title"]), text(&d["tag"])),
105
            page(&format!("/releases/tag/{}", text(&d["tag"]))),
106
            String::new(),
107
        ),
108
        "check" => (
109
            format!("Check {} {} on {repo}", text(&d["check"]["name"]), text(&d["check"]["status"])),
110
            text(&d["check"]["url"]).chars().take(500).collect::<String>().replace(' ', "%20"),
111
            text(&d["check"]["summary"]),
112
        ),
113
        e if e.starts_with("proposal.") => {
114
            let n = num(&d["number"]);
115
            let verb = e.trim_start_matches("proposal.");
116
            let title = text(&d["title"]);
117
            (
118
                format!("{actor} {verb} proposal #{n}{}", if title.is_empty() { String::new() } else { format!(": {title}") }),
119
                page(&format!("/proposal/{n}")),
120
                String::new(),
121
            )
122
        }
123
        e => (format!("{e} on {repo}"), page(""), String::new()),
124
    }
125
}
126
127
fn cut(s: &str, max: usize) -> String {
128
    if s.chars().count() <= max {
129
        s.to_string()
130
    } else {
131
        format!("{}…", s.chars().take(max.saturating_sub(1)).collect::<String>())
132
    }
133
}
134
135
/// The body a hook receives in its format; the envelope itself for `oxygenhub`.
136
pub fn body_for(format: &str, payload: &str, base: &str) -> String {
137
    let Ok(env) = serde_json::from_str::<WebhookEnvelope>(payload) else { return payload.to_string() };
138
    let (title, url, detail) = summary(&env, base);
139
    let repo = env.repository.as_ref().map(|r| format!("{}/{}", r.owner, r.name)).unwrap_or_else(|| "OxygenHub".into());
140
    match format {
141
        "discord" => {
142
            let mut embed = serde_json::json!({
143
                "title": cut(&title, 256),
144
                "description": cut(&detail, 2000),
145
                "color": 0x0064e0,
146
                "footer": { "text": cut(&format!("{repo} · {}", env.event), 200) },
147
                "timestamp": crate::render::time::rfc3339(env.at_ms as u64),
148
            });
149
            if url.starts_with("http") {
150
                embed["url"] = serde_json::Value::String(url);
151
            }
152
            // No @everyone, @here or role pings from anything a user typed.
153
            serde_json::json!({ "username": "OxygenHub", "allowed_mentions": { "parse": [] }, "embeds": [embed] }).to_string()
154
        }
155
        "slack" => {
156
            let esc = |s: &str| s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;");
157
            let head = if url.starts_with("http") { format!("<{}|{}>", url, esc(&title)) } else { esc(&title) };
158
            let text = if detail.is_empty() { head } else { format!("{head}\n>{}", esc(&cut(&detail, 1500)).replace('\n', "\n>")) };
159
            serde_json::json!({ "text": text }).to_string()
160
        }
161
        _ => payload.to_string(),
162
    }
163
}
164
165
/// `X-Oxyhub-Signature` for a body: keyed BLAKE3 with the hook's secret.
166
pub fn signature(secret: &str, body: &[u8]) -> String {
167
    let key = blake3::hash(secret.as_bytes());
168
    format!("blake3-keyed={}", hex::encode(blake3::keyed_hash(key.as_bytes(), body).as_bytes()))
169
}
170
171
/// Deliver everything that is due; returns how many were attempted.
172
pub async fn deliver_due(app: &Shared) -> usize {
173
    let due = match hdb::due_deliveries(&app.db, 50).await {
174
        Ok(d) => d,
175
        Err(_) => return 0,
176
    };
177
    let mut n = 0;
178
    for d in due {
179
        let Ok(Some(hook)) = hdb::hook(&app.db, d.hook_id).await else { continue };
180
        n += 1;
181
        // Only public addresses, pinned, no redirects (#219).
182
        let client = match crate::net::client(&hook.url, &app.config.outbound.allow, std::time::Duration::from_secs(20)).await {
183
            Ok(c) => c,
184
            Err(why) => {
185
                hdb::record_attempt(&app.db, d.id, d.attempts, None, &format!("not sent: {why}")).await.ok();
186
                continue;
187
            }
188
        };
189
        let body = body_for(&hook.format, &d.payload, app.config.base_url());
190
        let (status, response) = match client
191
            .post(&hook.url)
192
            .header("content-type", "application/json")
193
            .header("x-oxyhub-event", &d.event)
194
            .header("x-oxyhub-delivery", &d.delivery)
195
            .header("x-oxyhub-signature", signature(&hook.secret, body.as_bytes()))
196
            .body(body)
197
            .send()
198
            .await
199
        {
200
            Ok(r) => {
201
                let status = r.status().as_u16() as i64;
202
                let text = r.text().await.unwrap_or_default();
203
                (Some(status), text)
204
            }
205
            Err(e) => (None, e.to_string()),
206
        };
207
        hdb::record_attempt(&app.db, d.id, d.attempts, status, &response).await.ok();
208
    }
209
    n
210
}
211
212
/// The background worker: delivers when nudged and every minute anyway.
213
pub async fn worker(app: Shared) {
214
    let mut rx = app.events.deliveries.subscribe();
215
    loop {
216
        deliver_due(&app).await;
217
        let cutoff = db::now_ms() - app.config.retention.webhook_log_days as i64 * 86_400_000;
218
        hdb::prune_deliveries(&app.db, cutoff).await.ok();
219
        tokio::select! {
220
            _ = rx.recv() => {}
221
            _ = tokio::time::sleep(std::time::Duration::from_secs(60)) => {}
222
        }
223
    }
224
}
225
226
#[cfg(test)]
227
mod tests {
228
    use super::*;
229
230
    fn envelope(event: &str, data: serde_json::Value) -> String {
231
        serde_json::to_string(&WebhookEnvelope {
232
            event: event.into(),
233
            delivery: "d".into(),
234
            repository: Some(hub_api::Repository {
235
                owner: "andymccall".into(),
236
                name: "oxygen".into(),
237
                description: String::new(),
238
                default_bookmark: "main".into(),
239
                visibility: hub_api::Visibility::Public,
240
                created_ms: 0,
241
                updated_ms: 0,
242
                clone_url: String::new(),
243
            }),
244
            actor: "andy".into(),
245
            at_ms: 1_790_000_000_000,
246
            data,
247
        })
248
        .unwrap()
249
    }
250
251
    #[test]
252
    fn discord_gets_an_embed_without_mentions() {
253
        let p = envelope(
254
            "issue.opened",
255
            serde_json::json!({ "issue": { "number": 12, "title": "@everyone crash", "body": "x".repeat(3000) } }),
256
        );
257
        let v: serde_json::Value = serde_json::from_str(&body_for("discord", &p, "https://hub.example:7343/")).unwrap();
258
        let e = &v["embeds"][0];
259
        assert_eq!(e["title"], "andy opened issue #12: @everyone crash");
260
        assert_eq!(e["url"], "https://hub.example:7343/andymccall/oxygen/issue/12");
261
        assert_eq!(e["description"].as_str().unwrap().chars().count(), 2000, "cut to Discord's limits");
262
        assert_eq!(v["allowed_mentions"]["parse"], serde_json::json!([]), "no pings from user text");
263
        assert_eq!(v["username"], "OxygenHub");
264
        let push = envelope("push", serde_json::json!({ "updates": [{ "reference": "main", "changes": 3 }] }));
265
        let v: serde_json::Value = serde_json::from_str(&body_for("discord", &push, "https://h")).unwrap();
266
        assert_eq!(v["embeds"][0]["title"], "andy pushed to andymccall/oxygen");
267
        assert_eq!(v["embeds"][0]["description"], "main: 3 changes");
268
        assert_eq!(v["embeds"][0]["url"], "https://h/andymccall/oxygen/changes/main");
269
        let ping = envelope("ping", serde_json::json!({}));
270
        assert!(body_for("discord", &ping, "https://h").contains("OxygenHub is connected"));
271
    }
272
273
    #[test]
274
    fn slack_text_is_escaped_and_the_envelope_is_unchanged() {
275
        let p = envelope(
276
            "comment.issue",
277
            serde_json::json!({ "number": 3, "issue": { "number": 3, "title": "<b>" }, "comment": { "body": "a & b" } }),
278
        );
279
        let v: serde_json::Value = serde_json::from_str(&body_for("slack", &p, "https://h")).unwrap();
280
        assert_eq!(v["text"], "<https://h/andymccall/oxygen/issue/3|andy commented on issue #3: &lt;b&gt;>\n>a &amp; b");
281
        assert_eq!(body_for("oxygenhub", &p, "https://h"), p);
282
    }
283
284
    #[test]
285
    fn formats_follow_the_url_and_urls_are_masked() {
286
        use crate::db::hooks::{format_for_url, masked_url};
287
        assert_eq!(format_for_url("https://discord.com/api/webhooks/155/abc"), "discord");
288
        assert_eq!(format_for_url("https://hooks.slack.com/services/T/B/x"), "slack");
289
        assert_eq!(format_for_url("https://discord.com.evil.example/api/webhooks/1"), "oxygenhub");
290
        assert_eq!(format_for_url("https://ci.example/hook"), "oxygenhub");
291
        let m = masked_url("https://discord.com/api/webhooks/1554146511347126332/6g_WnXRdNOiVoo8sGXXW_OuIUceluD8lIUckxb9KHPwZ895XE");
292
        assert_eq!(m, "https://discord.com/api/webhooks/1554…/6g_W…");
293
        assert!(!m.contains("6g_WnX"), "the token is not shown");
294
        assert_eq!(masked_url("https://ci.example/hook?token=secret"), "https://ci.example/hook?…");
295
    }
296
297
    #[test]
298
    fn signatures_are_keyed() {
299
        let a = signature("s1", b"body");
300
        assert!(a.starts_with("blake3-keyed="));
301
        assert_ne!(a, signature("s2", b"body"));
302
        assert_ne!(a, signature("s1", b"other"));
303
    }
304
}