| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | | use crate::db::{self, hooks as hdb}; |
| 5 | | use crate::Shared; |
| 6 | | use hub_api::WebhookEnvelope; |
| 7 | |
|
| 8 | |
|
| 9 | 1097× | pub async fn emit(app: &Shared, repo: &db::repos::RepoRow, event: &str, actor: &str, data: serde_json::Value) { |
| 10 | | |
| 11 | 1097× | app.bus.hook(repo.id, event, &data); |
| 12 | | |
| 13 | | |
| 14 | 1097× | if event == "check" { |
| 15 | 712× | if let Some(state) = data["check"]["state"].as_str() { |
| 16 | 712× | let (app, repo, state) = (app.clone(), repo.clone(), state.to_string()); |
| 17 | 712× | tokio::spawn(crate::autoland::on_check_task(app, repo, state)); |
| 18 | 712× | } |
| 19 | 385× | } |
| 20 | 1097× | let hooks = match hdb::hooks_hearing(&app.db, repo.id, &repo.owner).await { |
| 21 | 1096× | Ok(h) => h, |
| 22 | 0× | Err(e) => { |
| 23 | 0× | tracing::warn!("hooks: {e}"); |
| 24 | 0× | return; |
| 25 | | } |
| 26 | | }; |
| 27 | 1096× | if hooks.is_empty() { |
| 28 | 1079× | return; |
| 29 | 17× | } |
| 30 | 17× | let repository = hub_api::Repository { |
| 31 | 17× | owner: repo.owner.clone(), |
| 32 | 17× | name: repo.name.clone(), |
| 33 | 17× | description: repo.description.clone(), |
| 34 | 17× | default_bookmark: repo.default_bookmark.clone(), |
| 35 | 17× | visibility: repo.visibility(), |
| 36 | 17× | created_ms: repo.created_ms, |
| 37 | 17× | updated_ms: repo.updated_ms, |
| 38 | 17× | clone_url: format!("{}/{}", app.config.base_url(), repo.slug()), |
| 39 | 17× | }; |
| 40 | 17× | for h in hooks.into_iter().filter(|h| h.wants(event)) { |
| 41 | 12× | let delivery = crate::auth::random_hex(16); |
| 42 | 12× | let envelope = WebhookEnvelope { |
| 43 | 12× | event: event.into(), |
| 44 | 12× | delivery: delivery.clone(), |
| 45 | 12× | repository: Some(repository.clone()), |
| 46 | 12× | actor: actor.into(), |
| 47 | 12× | at_ms: db::now_ms(), |
| 48 | 12× | data: data.clone(), |
| 49 | 12× | }; |
| 50 | 12× | let payload = serde_json::to_string(&envelope).unwrap_or_default(); |
| 51 | 12× | match hdb::queue_delivery(&app.db, h.id, &delivery, event, &payload).await { |
| 52 | 12× | Ok(Some(_)) => {} |
| 53 | 0× | Ok(None) => tracing::warn!("webhook {} has {} deliveries waiting: {event} not queued", h.id, hdb::MAX_WAITING), |
| 54 | 0× | Err(e) => tracing::warn!("queueing delivery: {e}"), |
| 55 | | } |
| 56 | | } |
| 57 | 17× | app.bus.delivery_waiting(); |
| 58 | 1096× | } |
| 59 | |
|
| 60 | |
|
| 61 | |
|
| 62 | 18× | pub fn summary(env: &WebhookEnvelope, base: &str) -> (String, String, String) { |
| 63 | 18× | let repo = env.repository.as_ref().map(|r| format!("{}/{}", r.owner, r.name)).unwrap_or_default(); |
| 64 | 18× | let page = |path: &str| format!("{}/{repo}{path}", base.trim_end_matches('/')); |
| 65 | 18× | let d = &env.data; |
| 66 | 40× | let text = |v: &serde_json::Value| v.as_str().unwrap_or("").to_string(); |
| 67 | 28× | let num = |v: &serde_json::Value| v.as_i64().unwrap_or(0); |
| 68 | 18× | let actor = if env.actor.is_empty() { "Someone".to_string() } else { env.actor.clone() }; |
| 69 | 18× | let issue_n = if d["issue"]["number"].is_i64() { num(&d["issue"]["number"]) } else { num(&d["number"]) }; |
| 70 | 18× | let issue_title = text(&d["issue"]["title"]); |
| 71 | 18× | match env.event.as_str() { |
| 72 | 18× | "ping" => ( |
| 73 | 2× | "OxygenHub is connected".into(), |
| 74 | 2× | if repo.is_empty() { base.to_string() } else { page("") }, |
| 75 | 2× | "Events from OxygenHub will arrive here.".into(), |
| 76 | | ), |
| 77 | 16× | "push" => { |
| 78 | 6× | let lines: Vec<String> = d["updates"] |
| 79 | 6× | .as_array() |
| 80 | 6× | .map(|u| { |
| 81 | 6× | u.iter() |
| 82 | 6× | .map(|x| { |
| 83 | 6× | let n = num(&x["changes"]); |
| 84 | 6× | format!("{}: {n} change{}", text(&x["reference"]), if n == 1 { "" } else { "s" }) |
| 85 | 6× | }) |
| 86 | 6× | .collect() |
| 87 | 6× | }) |
| 88 | 6× | .unwrap_or_default(); |
| 89 | 6× | let first = d["updates"][0]["reference"].as_str().unwrap_or("").to_string(); |
| 90 | 6× | (format!("{actor} pushed to {repo}"), page(&format!("/changes/{first}")), lines.join("\n")) |
| 91 | | } |
| 92 | 10× | "issue.opened" => { |
| 93 | 2× | (format!("{actor} opened issue #{issue_n}: {issue_title}"), page(&format!("/issue/{issue_n}")), text(&d["issue"]["body"])) |
| 94 | | } |
| 95 | 8× | "issue.closed" | "issue.reopened" | "issue.edited" => { |
| 96 | 0× | let verb = env.event.trim_start_matches("issue."); |
| 97 | 0× | (format!("{actor} {verb} issue #{issue_n}: {issue_title}"), page(&format!("/issue/{issue_n}")), String::new()) |
| 98 | | } |
| 99 | 8× | "comment.issue" => { |
| 100 | 2× | let body = if d["comment"]["body"].is_string() { text(&d["comment"]["body"]) } else { String::new() }; |
| 101 | | ( |
| 102 | 2× | format!( |
| 103 | | "{actor} commented on issue #{issue_n}{}", |
| 104 | 2× | if issue_title.is_empty() { String::new() } else { format!(": {issue_title}") } |
| 105 | | ), |
| 106 | 2× | page(&format!("/issue/{issue_n}")), |
| 107 | 2× | body, |
| 108 | | ) |
| 109 | | } |
| 110 | 6× | "review" => ( |
| 111 | 0× | format!("{actor} reviewed proposal #{}: {}", num(&d["number"]), text(&d["verdict"])), |
| 112 | 0× | page(&format!("/proposal/{}", num(&d["number"]))), |
| 113 | 0× | String::new(), |
| 114 | 0× | ), |
| 115 | 6× | "release.published" => ( |
| 116 | 0× | format!("{actor} released {} ({})", text(&d["title"]), text(&d["tag"])), |
| 117 | 0× | page(&format!("/releases/tag/{}", text(&d["tag"]))), |
| 118 | 0× | String::new(), |
| 119 | 0× | ), |
| 120 | 6× | "check" => ( |
| 121 | 2× | format!("Check {} {} on {repo}", text(&d["check"]["name"]), text(&d["check"]["status"])), |
| 122 | 2× | text(&d["check"]["url"]).chars().take(500).collect::<String>().replace(' ', "%20"), |
| 123 | 2× | text(&d["check"]["summary"]), |
| 124 | 2× | ), |
| 125 | 4× | e if e.starts_with("proposal.") => { |
| 126 | 4× | let n = num(&d["number"]); |
| 127 | 4× | let verb = e.trim_start_matches("proposal."); |
| 128 | 4× | let title = text(&d["title"]); |
| 129 | | ( |
| 130 | 4× | format!("{actor} {verb} proposal #{n}{}", if title.is_empty() { String::new() } else { format!(": {title}") }), |
| 131 | 4× | page(&format!("/proposal/{n}")), |
| 132 | 4× | String::new(), |
| 133 | | ) |
| 134 | | } |
| 135 | 0× | e => (format!("{e} on {repo}"), page(""), String::new()), |
| 136 | | } |
| 137 | 18× | } |
| 138 | |
|
| 139 | 13× | fn cut(s: &str, max: usize) -> String { |
| 140 | 13× | if s.chars().count() <= max { |
| 141 | 12× | s.to_string() |
| 142 | | } else { |
| 143 | 1× | format!("{}…", s.chars().take(max.saturating_sub(1)).collect::<String>()) |
| 144 | | } |
| 145 | 13× | } |
| 146 | |
|
| 147 | |
|
| 148 | 18× | pub fn body_for(format: &str, payload: &str, base: &str) -> String { |
| 149 | 18× | let Ok(env) = serde_json::from_str::<WebhookEnvelope>(payload) else { return payload.to_string() }; |
| 150 | 18× | let (title, url, detail) = summary(&env, base); |
| 151 | 18× | let repo = env.repository.as_ref().map(|r| format!("{}/{}", r.owner, r.name)).unwrap_or_else(|| "OxygenHub".into()); |
| 152 | 18× | match format { |
| 153 | 18× | "discord" => { |
| 154 | 4× | let mut embed = serde_json::json!({ |
| 155 | 4× | "title": cut(&title, 256), |
| 156 | 4× | "description": cut(&detail, 2000), |
| 157 | 4× | "color": 0x0064e0, |
| 158 | 4× | "footer": { "text": cut(&format!("{repo} · {}", env.event), 200) }, |
| 159 | 4× | "timestamp": crate::render::time::rfc3339(env.at_ms as u64), |
| 160 | | }); |
| 161 | 4× | if url.starts_with("http") { |
| 162 | 4× | embed["url"] = serde_json::Value::String(url); |
| 163 | 4× | } |
| 164 | | |
| 165 | 4× | serde_json::json!({ "username": "OxygenHub", "allowed_mentions": { "parse": [] }, "embeds": [embed] }).to_string() |
| 166 | | } |
| 167 | 14× | "slack" => { |
| 168 | 2× | let esc = |s: &str| s.replace('&', "&").replace('<', "<").replace('>', ">"); |
| 169 | 1× | let head = if url.starts_with("http") { format!("<{}|{}>", url, esc(&title)) } else { esc(&title) }; |
| 170 | 1× | let text = if detail.is_empty() { head } else { format!("{head}\n>{}", esc(&cut(&detail, 1500)).replace('\n', "\n>")) }; |
| 171 | 1× | serde_json::json!({ "text": text }).to_string() |
| 172 | | } |
| 173 | 13× | _ => payload.to_string(), |
| 174 | | } |
| 175 | 18× | } |
| 176 | |
|
| 177 | |
|
| 178 | 17× | pub fn signature(secret: &str, body: &[u8]) -> String { |
| 179 | 17× | let key = blake3::hash(secret.as_bytes()); |
| 180 | 17× | format!("blake3-keyed={}", hex::encode(blake3::keyed_hash(key.as_bytes(), body).as_bytes())) |
| 181 | 17× | } |
| 182 | |
|
| 183 | |
|
| 184 | 219× | pub async fn deliver_due(app: &Shared) -> usize { |
| 185 | 219× | let due = match hdb::due_deliveries(&app.db, 50).await { |
| 186 | 219× | Ok(d) => d, |
| 187 | 0× | Err(_) => return 0, |
| 188 | | }; |
| 189 | 219× | let mut n = 0; |
| 190 | 219× | for d in due { |
| 191 | 15× | let Ok(Some(hook)) = hdb::hook(&app.db, d.hook_id).await else { continue }; |
| 192 | 15× | n += 1; |
| 193 | | |
| 194 | 15× | let client = match crate::net::client(&hook.url, &app.config.outbound.allow, std::time::Duration::from_secs(20)).await { |
| 195 | 13× | Ok(c) => c, |
| 196 | 2× | Err(why) => { |
| 197 | 2× | hdb::record_attempt(&app.db, d.id, d.attempts, None, &format!("not sent: {why}")).await.ok(); |
| 198 | 2× | continue; |
| 199 | | } |
| 200 | | }; |
| 201 | 13× | let body = body_for(&hook.format, &d.payload, app.config.base_url()); |
| 202 | 13× | let (status, response) = match client |
| 203 | 13× | .post(&hook.url) |
| 204 | 13× | .header("content-type", "application/json") |
| 205 | 13× | .header("x-oxyhub-event", &d.event) |
| 206 | 13× | .header("x-oxyhub-delivery", &d.delivery) |
| 207 | 13× | .header("x-oxyhub-signature", signature(&hook.secret, body.as_bytes())) |
| 208 | 13× | .body(body) |
| 209 | 13× | .send() |
| 210 | 13× | .await |
| 211 | | { |
| 212 | 13× | Ok(r) => { |
| 213 | 13× | let status = r.status().as_u16() as i64; |
| 214 | 13× | (Some(status), capped_text(r, RESPONSE_LIMIT).await) |
| 215 | | } |
| 216 | 0× | Err(e) => (None, e.to_string()), |
| 217 | | }; |
| 218 | 13× | hdb::record_attempt(&app.db, d.id, d.attempts, status, &response).await.ok(); |
| 219 | | } |
| 220 | 219× | n |
| 221 | 219× | } |
| 222 | |
|
| 223 | |
|
| 224 | |
|
| 225 | | const RESPONSE_LIMIT: usize = 64 * 1024; |
| 226 | |
|
| 227 | |
|
| 228 | 15× | async fn capped_text(mut r: reqwest::Response, limit: usize) -> String { |
| 229 | 15× | let mut body = Vec::new(); |
| 230 | 29× | while let Ok(Some(chunk)) = r.chunk().await { |
| 231 | 15× | let room = limit - body.len(); |
| 232 | 15× | body.extend_from_slice(&chunk[..chunk.len().min(room)]); |
| 233 | 15× | if body.len() >= limit { |
| 234 | 1× | break; |
| 235 | 14× | } |
| 236 | | } |
| 237 | 15× | String::from_utf8_lossy(&body).into_owned() |
| 238 | 15× | } |
| 239 | |
|
| 240 | |
|
| 241 | 197× | pub async fn worker(app: Shared) { |
| 242 | 197× | let mut rx = app.bus.subscribe(crate::bus::Topic::Deliveries); |
| 243 | | loop { |
| 244 | 217× | deliver_due(&app).await; |
| 245 | 217× | let cutoff = db::now_ms() - app.config.retention.webhook_log_days as i64 * 86_400_000; |
| 246 | 217× | hdb::prune_deliveries(&app.db, cutoff).await.ok(); |
| 247 | 216× | tokio::select! { |
| 248 | 216× | _ = rx.recv() => {} |
| 249 | 216× | _ = 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] |
| 259 | 1× | async fn a_response_is_read_only_up_to_the_limit() { |
| 260 | | |
| 261 | 1× | let big = reqwest::Response::from(axum::http::Response::new("x".repeat(100 * 1024))); |
| 262 | 1× | assert_eq!(capped_text(big, RESPONSE_LIMIT).await.len(), RESPONSE_LIMIT); |
| 263 | 1× | let small = reqwest::Response::from(axum::http::Response::new("ok".to_string())); |
| 264 | 1× | assert_eq!(capped_text(small, RESPONSE_LIMIT).await, "ok"); |
| 265 | 1× | } |
| 266 | |
|
| 267 | 4× | fn envelope(event: &str, data: serde_json::Value) -> String { |
| 268 | 4× | serde_json::to_string(&WebhookEnvelope { |
| 269 | 4× | event: event.into(), |
| 270 | 4× | delivery: "d".into(), |
| 271 | 4× | repository: Some(hub_api::Repository { |
| 272 | 4× | owner: "andymccall".into(), |
| 273 | 4× | name: "oxygen".into(), |
| 274 | 4× | description: String::new(), |
| 275 | 4× | default_bookmark: "main".into(), |
| 276 | 4× | visibility: hub_api::Visibility::Public, |
| 277 | 4× | created_ms: 0, |
| 278 | 4× | updated_ms: 0, |
| 279 | 4× | clone_url: String::new(), |
| 280 | 4× | }), |
| 281 | 4× | actor: "andy".into(), |
| 282 | 4× | at_ms: 1_790_000_000_000, |
| 283 | 4× | data, |
| 284 | 4× | }) |
| 285 | 4× | .unwrap() |
| 286 | 4× | } |
| 287 | |
|
| 288 | | #[test] |
| 289 | 1× | fn discord_gets_an_embed_without_mentions() { |
| 290 | 1× | let p = envelope( |
| 291 | 1× | "issue.opened", |
| 292 | 1× | serde_json::json!({ "issue": { "number": 12, "title": "@everyone crash", "body": "x".repeat(3000) } }), |
| 293 | | ); |
| 294 | 1× | let v: serde_json::Value = serde_json::from_str(&body_for("discord", &p, "https://hub.example:7343/")).unwrap(); |
| 295 | 1× | let e = &v["embeds"][0]; |
| 296 | 1× | assert_eq!(e["title"], "andy opened issue #12: @everyone crash"); |
| 297 | 1× | assert_eq!(e["url"], "https://hub.example:7343/andymccall/oxygen/issue/12"); |
| 298 | 1× | assert_eq!(e["description"].as_str().unwrap().chars().count(), 2000, "cut to Discord's limits"); |
| 299 | 1× | assert_eq!(v["allowed_mentions"]["parse"], serde_json::json!([]), "no pings from user text"); |
| 300 | 1× | assert_eq!(v["username"], "OxygenHub"); |
| 301 | 1× | let push = envelope("push", serde_json::json!({ "updates": [{ "reference": "main", "changes": 3 }] })); |
| 302 | 1× | let v: serde_json::Value = serde_json::from_str(&body_for("discord", &push, "https://h")).unwrap(); |
| 303 | 1× | assert_eq!(v["embeds"][0]["title"], "andy pushed to andymccall/oxygen"); |
| 304 | 1× | assert_eq!(v["embeds"][0]["description"], "main: 3 changes"); |
| 305 | 1× | assert_eq!(v["embeds"][0]["url"], "https://h/andymccall/oxygen/changes/main"); |
| 306 | 1× | let ping = envelope("ping", serde_json::json!({})); |
| 307 | 1× | assert!(body_for("discord", &ping, "https://h").contains("OxygenHub is connected")); |
| 308 | 1× | } |
| 309 | |
|
| 310 | | #[test] |
| 311 | 1× | fn slack_text_is_escaped_and_the_envelope_is_unchanged() { |
| 312 | 1× | let p = envelope( |
| 313 | 1× | "comment.issue", |
| 314 | 1× | serde_json::json!({ "number": 3, "issue": { "number": 3, "title": "<b>" }, "comment": { "body": "a & b" } }), |
| 315 | | ); |
| 316 | 1× | let v: serde_json::Value = serde_json::from_str(&body_for("slack", &p, "https://h")).unwrap(); |
| 317 | 1× | assert_eq!(v["text"], "<https://h/andymccall/oxygen/issue/3|andy commented on issue #3: <b>>\n>a & b"); |
| 318 | 1× | assert_eq!(body_for("oxygenhub", &p, "https://h"), p); |
| 319 | 1× | } |
| 320 | |
|
| 321 | | #[test] |
| 322 | 1× | fn formats_follow_the_url_and_urls_are_masked() { |
| 323 | | use crate::db::hooks::{format_for_url, masked_url}; |
| 324 | 1× | assert_eq!(format_for_url("https://discord.com/api/webhooks/155/abc"), "discord"); |
| 325 | 1× | assert_eq!(format_for_url("https://hooks.slack.com/services/T/B/x"), "slack"); |
| 326 | 1× | assert_eq!(format_for_url("https://discord.com.evil.example/api/webhooks/1"), "oxygenhub"); |
| 327 | 1× | assert_eq!(format_for_url("https://ci.example/hook"), "oxygenhub"); |
| 328 | 1× | let m = masked_url("https://discord.com/api/webhooks/1554146511347126332/6g_WnXRdNOiVoo8sGXXW_OuIUceluD8lIUckxb9KHPwZ895XE"); |
| 329 | 1× | assert_eq!(m, "https://discord.com/api/webhooks/1554…/6g_W…"); |
| 330 | 1× | assert!(!m.contains("6g_WnX"), "the token is not shown"); |
| 331 | 1× | assert_eq!(masked_url("https://ci.example/hook?token=secret"), "https://ci.example/hook?…"); |
| 332 | 1× | } |
| 333 | |
|
| 334 | | #[test] |
| 335 | 1× | fn signatures_are_keyed() { |
| 336 | 1× | let a = signature("s1", b"body"); |
| 337 | 1× | assert!(a.starts_with("blake3-keyed=")); |
| 338 | 1× | assert_ne!(a, signature("s2", b"body")); |
| 339 | 1× | assert_ne!(a, signature("s1", b"other")); |
| 340 | 1× | } |
| 341 | | } |