OXYGENHUB

Coverage / crates/hub/src/hooks.rs 91.5%

248 of 271 lines run, 42 of 51 functions · run #495 on main · run missed

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).
91070×
pub async fn emit(app: &Shared, repo: &db::repos::RepoRow, event: &str, actor: &str, data: serde_json::Value) {
10
    // Live pages hear of it whether or not any hook does (oxygenhub#203).
111070×
    app.bus.hook(repo.id, event, &data);
12
    // A check on a state may let a proposal set to land when its checks
13
    // pass land now (#261): every check is announced here.
141070×
    if event == "check" {
15700×
        if let Some(state) = data["check"]["state"].as_str() {
16700×
            let (app, repo, state) = (app.clone(), repo.clone(), state.to_string());
17700×
            tokio::spawn(crate::autoland::on_check_task(app, repo, state));
18700×
        }
19370×
    }
201070×
    let hooks = match hdb::hooks_hearing(&app.db, repo.id, &repo.owner).await {
211069×
        Ok(h) => h,
220×
        Err(e) => {
230×
            tracing::warn!("hooks: {e}");
240×
            return;
25
        }
26
    };
271069×
    if hooks.is_empty() {
281051×
        return;
2918×
    }
3018×
    let repository = hub_api::Repository {
3118×
        owner: repo.owner.clone(),
3218×
        name: repo.name.clone(),
3318×
        description: repo.description.clone(),
3418×
        default_bookmark: repo.default_bookmark.clone(),
3518×
        visibility: repo.visibility(),
3618×
        created_ms: repo.created_ms,
3718×
        updated_ms: repo.updated_ms,
3818×
        clone_url: format!("{}/{}", app.config.base_url(), repo.slug()),
3918×
    };
4018×
    for h in hooks.into_iter().filter(|h| h.wants(event)) {
4113×
        let delivery = crate::auth::random_hex(16);
4213×
        let envelope = WebhookEnvelope {
4313×
            event: event.into(),
4413×
            delivery: delivery.clone(),
4513×
            repository: Some(repository.clone()),
4613×
            actor: actor.into(),
4713×
            at_ms: db::now_ms(),
4813×
            data: data.clone(),
4913×
        };
5013×
        let payload = serde_json::to_string(&envelope).unwrap_or_default();
5113×
        match hdb::queue_delivery(&app.db, h.id, &delivery, event, &payload).await {
5213×
            Ok(Some(_)) => {}
530×
            Ok(None) => tracing::warn!("webhook {} has {} deliveries waiting: {event} not queued", h.id, hdb::MAX_WAITING),
540×
            Err(e) => tracing::warn!("queueing delivery: {e}"),
55
        }
56
    }
5718×
    app.bus.delivery_waiting();
581069×
}
59
60
/// What a chat message says about an event: a title, the page it links
61
/// to (absolute) and a short detail.
6218×
pub fn summary(env: &WebhookEnvelope, base: &str) -> (String, String, String) {
6318×
    let repo = env.repository.as_ref().map(|r| format!("{}/{}", r.owner, r.name)).unwrap_or_default();
6418×
    let page = |path: &str| format!("{}/{repo}{path}", base.trim_end_matches('/'));
6518×
    let d = &env.data;
6640×
    let text = |v: &serde_json::Value| v.as_str().unwrap_or("").to_string();
6728×
    let num = |v: &serde_json::Value| v.as_i64().unwrap_or(0);
6818×
    let actor = if env.actor.is_empty() { "Someone".to_string() } else { env.actor.clone() };
6918×
    let issue_n = if d["issue"]["number"].is_i64() { num(&d["issue"]["number"]) } else { num(&d["number"]) };
7018×
    let issue_title = text(&d["issue"]["title"]);
7118×
    match env.event.as_str() {
7218×
        "ping" => (
732×
            "OxygenHub is connected".into(),
742×
            if repo.is_empty() { base.to_string() } else { page("") },
752×
            "Events from OxygenHub will arrive here.".into(),
76
        ),
7716×
        "push" => {
786×
            let lines: Vec<String> = d["updates"]
796×
                .as_array()
806×
                .map(|u| {
816×
                    u.iter()
826×
                        .map(|x| {
836×
                            let n = num(&x["changes"]);
846×
                            format!("{}: {n} change{}", text(&x["reference"]), if n == 1 { "" } else { "s" })
856×
                        })
866×
                        .collect()
876×
                })
886×
                .unwrap_or_default();
896×
            let first = d["updates"][0]["reference"].as_str().unwrap_or("").to_string();
906×
            (format!("{actor} pushed to {repo}"), page(&format!("/changes/{first}")), lines.join("\n"))
91
        }
9210×
        "issue.opened" => {
932×
            (format!("{actor} opened issue #{issue_n}: {issue_title}"), page(&format!("/issue/{issue_n}")), text(&d["issue"]["body"]))
94
        }
958×
        "issue.closed" | "issue.reopened" | "issue.edited" => {
960×
            let verb = env.event.trim_start_matches("issue.");
970×
            (format!("{actor} {verb} issue #{issue_n}: {issue_title}"), page(&format!("/issue/{issue_n}")), String::new())
98
        }
998×
        "comment.issue" => {
1002×
            let body = if d["comment"]["body"].is_string() { text(&d["comment"]["body"]) } else { String::new() };
101
            (
1022×
                format!(
103
                    "{actor} commented on issue #{issue_n}{}",
1042×
                    if issue_title.is_empty() { String::new() } else { format!(": {issue_title}") }
105
                ),
1062×
                page(&format!("/issue/{issue_n}")),
1072×
                body,
108
            )
109
        }
1106×
        "review" => (
1110×
            format!("{actor} reviewed proposal #{}: {}", num(&d["number"]), text(&d["verdict"])),
1120×
            page(&format!("/proposal/{}", num(&d["number"]))),
1130×
            String::new(),
1140×
        ),
1156×
        "release.published" => (
1160×
            format!("{actor} released {} ({})", text(&d["title"]), text(&d["tag"])),
1170×
            page(&format!("/releases/tag/{}", text(&d["tag"]))),
1180×
            String::new(),
1190×
        ),
1206×
        "check" => (
1212×
            format!("Check {} {} on {repo}", text(&d["check"]["name"]), text(&d["check"]["status"])),
1222×
            text(&d["check"]["url"]).chars().take(500).collect::<String>().replace(' ', "%20"),
1232×
            text(&d["check"]["summary"]),
1242×
        ),
1254×
        e if e.starts_with("proposal.") => {
1264×
            let n = num(&d["number"]);
1274×
            let verb = e.trim_start_matches("proposal.");
1284×
            let title = text(&d["title"]);
129
            (
1304×
                format!("{actor} {verb} proposal #{n}{}", if title.is_empty() { String::new() } else { format!(": {title}") }),
1314×
                page(&format!("/proposal/{n}")),
1324×
                String::new(),
133
            )
134
        }
1350×
        e => (format!("{e} on {repo}"), page(""), String::new()),
136
    }
13718×
}
138
13913×
fn cut(s: &str, max: usize) -> String {
14013×
    if s.chars().count() <= max {
14112×
        s.to_string()
142
    } else {
1431×
        format!("{}…", s.chars().take(max.saturating_sub(1)).collect::<String>())
144
    }
14513×
}
146
147
/// The body a hook receives in its format; the envelope itself for `oxygenhub`.
14818×
pub fn body_for(format: &str, payload: &str, base: &str) -> String {
14918×
    let Ok(env) = serde_json::from_str::<WebhookEnvelope>(payload) else { return payload.to_string() };
15018×
    let (title, url, detail) = summary(&env, base);
15118×
    let repo = env.repository.as_ref().map(|r| format!("{}/{}", r.owner, r.name)).unwrap_or_else(|| "OxygenHub".into());
15218×
    match format {
15318×
        "discord" => {
1544×
            let mut embed = serde_json::json!({
1554×
                "title": cut(&title, 256),
1564×
                "description": cut(&detail, 2000),
1574×
                "color": 0x0064e0,
1584×
                "footer": { "text": cut(&format!("{repo} · {}", env.event), 200) },
1594×
                "timestamp": crate::render::time::rfc3339(env.at_ms as u64),
160
            });
1614×
            if url.starts_with("http") {
1624×
                embed["url"] = serde_json::Value::String(url);
1634×
            }
164
            // No @everyone, @here or role pings from anything a user typed.
1654×
            serde_json::json!({ "username": "OxygenHub", "allowed_mentions": { "parse": [] }, "embeds": [embed] }).to_string()
166
        }
16714×
        "slack" => {
1682×
            let esc = |s: &str| s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;");
1691×
            let head = if url.starts_with("http") { format!("<{}|{}>", url, esc(&title)) } else { esc(&title) };
1701×
            let text = if detail.is_empty() { head } else { format!("{head}\n>{}", esc(&cut(&detail, 1500)).replace('\n', "\n>")) };
1711×
            serde_json::json!({ "text": text }).to_string()
172
        }
17313×
        _ => payload.to_string(),
174
    }
17518×
}
176
177
/// `X-Oxyhub-Signature` for a body: keyed BLAKE3 with the hook's secret.
17817×
pub fn signature(secret: &str, body: &[u8]) -> String {
17917×
    let key = blake3::hash(secret.as_bytes());
18017×
    format!("blake3-keyed={}", hex::encode(blake3::keyed_hash(key.as_bytes(), body).as_bytes()))
18117×
}
182
183
/// Deliver everything that is due; returns how many were attempted.
184213×
pub async fn deliver_due(app: &Shared) -> usize {
185213×
    let due = match hdb::due_deliveries(&app.db, 50).await {
186213×
        Ok(d) => d,
1870×
        Err(_) => return 0,
188
    };
189213×
    let mut n = 0;
190213×
    for d in due {
19115×
        let Ok(Some(hook)) = hdb::hook(&app.db, d.hook_id).await else { continue };
19215×
        n += 1;
193
        // Only public addresses, pinned, no redirects (#219).
19415×
        let client = match crate::net::client(&hook.url, &app.config.outbound.allow, std::time::Duration::from_secs(20)).await {
19513×
            Ok(c) => c,
1961×
            Err(why) => {
1971×
                hdb::record_attempt(&app.db, d.id, d.attempts, None, &format!("not sent: {why}")).await.ok();
1981×
                continue;
199
            }
200
        };
20113×
        let body = body_for(&hook.format, &d.payload, app.config.base_url());
20213×
        let (status, response) = match client
20313×
            .post(&hook.url)
20413×
            .header("content-type", "application/json")
20513×
            .header("x-oxyhub-event", &d.event)
20613×
            .header("x-oxyhub-delivery", &d.delivery)
20713×
            .header("x-oxyhub-signature", signature(&hook.secret, body.as_bytes()))
20813×
            .body(body)
20913×
            .send()
21013×
            .await
211
        {
21213×
            Ok(r) => {
21313×
                let status = r.status().as_u16() as i64;
21413×
                (Some(status), capped_text(r, RESPONSE_LIMIT).await)
215
            }
2160×
            Err(e) => (None, e.to_string()),
217
        };
21813×
        hdb::record_attempt(&app.db, d.id, d.attempts, status, &response).await.ok();
219
    }
220212×
    n
221212×
}
222
223
/// How much of a webhook's response is read (#220): enough to show why a
224
/// delivery failed, never so much that an endpoint can fill memory.
225
const RESPONSE_LIMIT: usize = 64 * 1024;
226
227
/// A response's body as text, read in chunks and stopped at `limit` bytes.
22815×
async fn capped_text(mut r: reqwest::Response, limit: usize) -> String {
22915×
    let mut body = Vec::new();
23029×
    while let Ok(Some(chunk)) = r.chunk().await {
23115×
        let room = limit - body.len();
23215×
        body.extend_from_slice(&chunk[..chunk.len().min(room)]);
23315×
        if body.len() >= limit {
2341×
            break;
23514×
        }
236
    }
23715×
    String::from_utf8_lossy(&body).into_owned()
23815×
}
239
240
/// The background worker: delivers when nudged and every minute anyway.
241191×
pub async fn worker(app: Shared) {
242191×
    let mut rx = app.bus.subscribe(crate::bus::Topic::Deliveries);
243
    loop {
244211×
        deliver_due(&app).await;
245210×
        let cutoff = db::now_ms() - app.config.retention.webhook_log_days as i64 * 86_400_000;
246210×
        hdb::prune_deliveries(&app.db, cutoff).await.ok();
247210×
        tokio::select! {
248210×
            _ = rx.recv() => {}
249210×
            _ = tokio::time::sleep(std::time::Duration::from_secs(60)) => {}
250
        }
251
    }
252
}
253
254
#[cfg(test)]
255
mod tests {
256
    use super::*;
257
258
    #[tokio::test]
2591×
    async fn a_response_is_read_only_up_to_the_limit() {
260
        // #220: an endpoint answering 100 KiB is read to 64 KiB, no further.
2611×
        let big = reqwest::Response::from(axum::http::Response::new("x".repeat(100 * 1024)));
2621×
        assert_eq!(capped_text(big, RESPONSE_LIMIT).await.len(), RESPONSE_LIMIT);
2631×
        let small = reqwest::Response::from(axum::http::Response::new("ok".to_string()));
2641×
        assert_eq!(capped_text(small, RESPONSE_LIMIT).await, "ok");
2651×
    }
266
2674×
    fn envelope(event: &str, data: serde_json::Value) -> String {
2684×
        serde_json::to_string(&WebhookEnvelope {
2694×
            event: event.into(),
2704×
            delivery: "d".into(),
2714×
            repository: Some(hub_api::Repository {
2724×
                owner: "andymccall".into(),
2734×
                name: "oxygen".into(),
2744×
                description: String::new(),
2754×
                default_bookmark: "main".into(),
2764×
                visibility: hub_api::Visibility::Public,
2774×
                created_ms: 0,
2784×
                updated_ms: 0,
2794×
                clone_url: String::new(),
2804×
            }),
2814×
            actor: "andy".into(),
2824×
            at_ms: 1_790_000_000_000,
2834×
            data,
2844×
        })
2854×
        .unwrap()
2864×
    }
287
288
    #[test]
2891×
    fn discord_gets_an_embed_without_mentions() {
2901×
        let p = envelope(
2911×
            "issue.opened",
2921×
            serde_json::json!({ "issue": { "number": 12, "title": "@everyone crash", "body": "x".repeat(3000) } }),
293
        );
2941×
        let v: serde_json::Value = serde_json::from_str(&body_for("discord", &p, "https://hub.example:7343/")).unwrap();
2951×
        let e = &v["embeds"][0];
2961×
        assert_eq!(e["title"], "andy opened issue #12: @everyone crash");
2971×
        assert_eq!(e["url"], "https://hub.example:7343/andymccall/oxygen/issue/12");
2981×
        assert_eq!(e["description"].as_str().unwrap().chars().count(), 2000, "cut to Discord's limits");
2991×
        assert_eq!(v["allowed_mentions"]["parse"], serde_json::json!([]), "no pings from user text");
3001×
        assert_eq!(v["username"], "OxygenHub");
3011×
        let push = envelope("push", serde_json::json!({ "updates": [{ "reference": "main", "changes": 3 }] }));
3021×
        let v: serde_json::Value = serde_json::from_str(&body_for("discord", &push, "https://h")).unwrap();
3031×
        assert_eq!(v["embeds"][0]["title"], "andy pushed to andymccall/oxygen");
3041×
        assert_eq!(v["embeds"][0]["description"], "main: 3 changes");
3051×
        assert_eq!(v["embeds"][0]["url"], "https://h/andymccall/oxygen/changes/main");
3061×
        let ping = envelope("ping", serde_json::json!({}));
3071×
        assert!(body_for("discord", &ping, "https://h").contains("OxygenHub is connected"));
3081×
    }
309
310
    #[test]
3111×
    fn slack_text_is_escaped_and_the_envelope_is_unchanged() {
3121×
        let p = envelope(
3131×
            "comment.issue",
3141×
            serde_json::json!({ "number": 3, "issue": { "number": 3, "title": "<b>" }, "comment": { "body": "a & b" } }),
315
        );
3161×
        let v: serde_json::Value = serde_json::from_str(&body_for("slack", &p, "https://h")).unwrap();
3171×
        assert_eq!(v["text"], "<https://h/andymccall/oxygen/issue/3|andy commented on issue #3: &lt;b&gt;>\n>a &amp; b");
3181×
        assert_eq!(body_for("oxygenhub", &p, "https://h"), p);
3191×
    }
320
321
    #[test]
3221×
    fn formats_follow_the_url_and_urls_are_masked() {
323
        use crate::db::hooks::{format_for_url, masked_url};
3241×
        assert_eq!(format_for_url("https://discord.com/api/webhooks/155/abc"), "discord");
3251×
        assert_eq!(format_for_url("https://hooks.slack.com/services/T/B/x"), "slack");
3261×
        assert_eq!(format_for_url("https://discord.com.evil.example/api/webhooks/1"), "oxygenhub");
3271×
        assert_eq!(format_for_url("https://ci.example/hook"), "oxygenhub");
3281×
        let m = masked_url("https://discord.com/api/webhooks/1554146511347126332/6g_WnXRdNOiVoo8sGXXW_OuIUceluD8lIUckxb9KHPwZ895XE");
3291×
        assert_eq!(m, "https://discord.com/api/webhooks/1554…/6g_W…");
3301×
        assert!(!m.contains("6g_WnX"), "the token is not shown");
3311×
        assert_eq!(masked_url("https://ci.example/hook?token=secret"), "https://ci.example/hook?…");
3321×
    }
333
334
    #[test]
3351×
    fn signatures_are_keyed() {
3361×
        let a = signature("s1", b"body");
3371×
        assert!(a.starts_with("blake3-keyed="));
3381×
        assert_ne!(a, signature("s2", b"body"));
3391×
        assert_ne!(a, signature("s1", b"other"));
3401×
    }
341
}