OXYGENHUB

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 9756c67d3aec

closes #16
closes #17
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-28 15:20 UTC
Signature
✔ signed by Andy McCall
Hash
9756c67d3aec8640ec1c40e2e05a6b9d740a39e357f10c6012f749bb93a1914a
Depends on
18e381ea88c2 19bb31ff1f5c 76f262e1a666 8fb35df7d66e 93be9ce4653e b64e2537d702 d23464c0ac60 de505df5cf8d decb6304ba97
Depended on by
dc5c64296b0a c7067c7bda56 a7aa76ac52d7 c8684d44d549 5d90e0e045e2 c98935537c57 9a777fec4ed9 7974adb0be66 83d5052bf76c ef20fdc14e8b
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
36 atoms, 1 tree operation, 12 files

Browse the tree at this change

added crates/hub/migrations/0016_hook_format.sql +8 −0
1 +
-- The body a webhook receives (hub spec §12.2): `oxygenhub` (the signed
2 +
-- envelope), `discord` or `slack`. Discord and Slack webhooks made before
3 +
-- formats existed switch to their format, since they never accepted the envelope.
4 +
ALTER TABLE hooks ADD COLUMN format TEXT NOT NULL DEFAULT 'oxygenhub' CHECK (format IN ('oxygenhub', 'discord', 'slack'));
5 +
UPDATE hooks SET format = 'discord'
6 +
 WHERE url LIKE 'https://discord.com/api/webhooks/%' OR url LIKE 'https://discordapp.com/api/webhooks/%'
7 +
    OR url LIKE 'https://canary.discord.com/api/webhooks/%' OR url LIKE 'https://ptb.discord.com/api/webhooks/%';
8 +
UPDATE hooks SET format = 'slack' WHERE url LIKE 'https://hooks.slack.com/%';
modified crates/hub/src/db/hooks.rs +80 −1
…
12 unchanged lines
13 13
    pub events: String,
14 14
    pub active: bool,
15 15
    pub created_ms: i64,
16 +
    /// `oxygenhub`, `discord` or `slack`.
17 +
    pub format: String,
16 18
}
19 +
20 +
/// The formats a webhook can be delivered in.
21 +
pub const FORMATS: [&str; 3] = ["oxygenhub", "discord", "slack"];
17 22
23 +
/// The format a URL most likely wants: Discord and Slack webhooks accept
24 +
/// only their own bodies.
25 +
pub fn format_for_url(url: &str) -> &'static str {
26 +
    let host = url.split("://").nth(1).unwrap_or("").split(['/', ':']).next().unwrap_or("").to_ascii_lowercase();
27 +
    if matches!(host.as_str(), "discord.com" | "discordapp.com" | "canary.discord.com" | "ptb.discord.com")
28 +
        && url.contains("/api/webhooks/")
29 +
    {
30 +
        "discord"
31 +
    } else if host == "hooks.slack.com" {
32 +
        "slack"
33 +
    } else {
34 +
        "oxygenhub"
35 +
    }
36 +
}
37 +
38 +
/// A URL for display: scheme, host and path, with long segments (the tokens
39 +
/// Discord and Slack keep in the path) and any query cut to a few characters.
40 +
pub fn masked_url(url: &str) -> String {
41 +
    let (scheme, rest) = url.split_once("://").unwrap_or(("", url));
42 +
    let (path, query) = rest.split_once('?').map(|(p, q)| (p, Some(q))).unwrap_or((rest, None));
43 +
    let parts: Vec<String> =
44 +
        path.split('/')
45 +
            .enumerate()
46 +
            .map(|(i, seg)| {
47 +
                if i > 0 && seg.chars().count() > 14 {
48 +
                    format!("{}…", seg.chars().take(4).collect::<String>())
49 +
                } else {
50 +
                    seg.to_string()
51 +
                }
52 +
            })
53 +
            .collect();
54 +
    let mut out = if scheme.is_empty() { parts.join("/") } else { format!("{scheme}://{}", parts.join("/")) };
55 +
    if query.is_some() {
56 +
        out.push_str("?…");
57 +
    }
58 +
    out
59 +
}
60 +
18 61
impl Hook {
19 62
    /// Whether the hook wants `event` (`*`, or a comma list of prefixes such as `push,proposal`).
20 63
    pub fn wants(&self, event: &str) -> bool {
…
46 unchanged lines
67 110
    secret: &str,
68 111
    events: &str,
69 112
) -> sqlx::Result<Hook> {
70 -
    sqlx::query_as("INSERT INTO hooks (repo_id, org_id, url, secret, events, created_ms) VALUES (?, ?, ?, ?, ?, ?) RETURNING *")
113 +
    sqlx::query_as("INSERT INTO hooks (repo_id, org_id, url, secret, events, created_ms, format) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *")
71 114
        .bind(repo_id)
72 115
        .bind(org_id)
73 116
        .bind(url)
74 117
        .bind(secret)
75 118
        .bind(events)
76 119
        .bind(now_ms())
120 +
        .bind(format_for_url(url))
77 121
        .fetch_one(pool)
122 +
        .await
123 +
}
124 +
125 +
/// Change a hook; `None` keeps the URL or secret as it is.
126 +
pub async fn update_hook(
127 +
    pool: &Pool,
128 +
    id: i64,
129 +
    url: Option<&str>,
130 +
    secret: Option<&str>,
131 +
    events: &str,
132 +
    format: &str,
133 +
    active: bool,
134 +
) -> sqlx::Result<()> {
135 +
    sqlx::query("UPDATE hooks SET url = COALESCE(?, url), secret = COALESCE(?, secret), events = ?, format = ?, active = ? WHERE id = ?")
136 +
        .bind(url)
137 +
        .bind(secret)
138 +
        .bind(events)
139 +
        .bind(format)
140 +
        .bind(active)
141 +
        .bind(id)
142 +
        .execute(pool)
143 +
        .await?;
144 +
    Ok(())
145 +
}
146 +
147 +
pub async fn set_hook_format(pool: &Pool, id: i64, format: &str) -> sqlx::Result<()> {
148 +
    sqlx::query("UPDATE hooks SET format = ? WHERE id = ?").bind(format).bind(id).execute(pool).await?;
149 +
    Ok(())
150 +
}
151 +
152 +
/// The newest delivery that was attempted, for the settings table.
153 +
pub async fn last_attempt(pool: &Pool, hook_id: i64) -> sqlx::Result<Option<Delivery>> {
154 +
    sqlx::query_as("SELECT * FROM deliveries WHERE hook_id = ? AND attempts > 0 ORDER BY id DESC LIMIT 1")
155 +
        .bind(hook_id)
156 +
        .fetch_optional(pool)
78 157
        .await
79 158
}
80 159
…
146 unchanged lines
modified crates/hub/src/hooks.rs +187 −2
…
44 unchanged lines
45 45
    app.events.notify_deliveries();
46 46
}
47 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 +
48 165
/// `X-Oxyhub-Signature` for a body: keyed BLAKE3 with the hook's secret.
49 166
pub fn signature(secret: &str, body: &[u8]) -> String {
50 167
    let key = blake3::hash(secret.as_bytes());
…
15 unchanged lines
66 183
    for d in due {
67 184
        let Ok(Some(hook)) = hdb::hook(&app.db, d.hook_id).await else { continue };
68 185
        n += 1;
186 +
        let body = body_for(&hook.format, &d.payload, app.config.base_url());
69 187
        let (status, response) = match client
70 188
            .post(&hook.url)
71 189
            .header("content-type", "application/json")
72 190
            .header("x-oxyhub-event", &d.event)
73 191
            .header("x-oxyhub-delivery", &d.delivery)
74 -
            .header("x-oxyhub-signature", signature(&hook.secret, d.payload.as_bytes()))
75 -
            .body(d.payload.clone())
192 +
            .header("x-oxyhub-signature", signature(&hook.secret, body.as_bytes()))
193 +
            .body(body)
76 194
            .send()
77 195
            .await
78 196
        {
…
26 unchanged lines
105 223
#[cfg(test)]
106 224
mod tests {
107 225
    use super::*;
226 +
227 +
    fn envelope(event: &str, data: serde_json::Value) -> String {
228 +
        serde_json::to_string(&WebhookEnvelope {
229 +
            event: event.into(),
230 +
            delivery: "d".into(),
231 +
            repository: Some(hub_api::Repository {
232 +
                owner: "andymccall".into(),
233 +
                name: "oxygen".into(),
234 +
                description: String::new(),
235 +
                default_bookmark: "main".into(),
236 +
                visibility: hub_api::Visibility::Public,
237 +
                created_ms: 0,
238 +
                updated_ms: 0,
239 +
                clone_url: String::new(),
240 +
            }),
241 +
            actor: "andy".into(),
242 +
            at_ms: 1_790_000_000_000,
243 +
            data,
244 +
        })
245 +
        .unwrap()
246 +
    }
247 +
248 +
    #[test]
249 +
    fn discord_gets_an_embed_without_mentions() {
250 +
        let p = envelope(
251 +
            "issue.opened",
252 +
            serde_json::json!({ "issue": { "number": 12, "title": "@everyone crash", "body": "x".repeat(3000) } }),
253 +
        );
254 +
        let v: serde_json::Value = serde_json::from_str(&body_for("discord", &p, "https://hub.example:7343/")).unwrap();
255 +
        let e = &v["embeds"][0];
256 +
        assert_eq!(e["title"], "andy opened issue #12: @everyone crash");
257 +
        assert_eq!(e["url"], "https://hub.example:7343/andymccall/oxygen/issue/12");
258 +
        assert_eq!(e["description"].as_str().unwrap().chars().count(), 2000, "cut to Discord's limits");
259 +
        assert_eq!(v["allowed_mentions"]["parse"], serde_json::json!([]), "no pings from user text");
260 +
        assert_eq!(v["username"], "OxygenHub");
261 +
        let push = envelope("push", serde_json::json!({ "updates": [{ "reference": "main", "changes": 3 }] }));
262 +
        let v: serde_json::Value = serde_json::from_str(&body_for("discord", &push, "https://h")).unwrap();
263 +
        assert_eq!(v["embeds"][0]["title"], "andy pushed to andymccall/oxygen");
264 +
        assert_eq!(v["embeds"][0]["description"], "main: 3 changes");
265 +
        assert_eq!(v["embeds"][0]["url"], "https://h/andymccall/oxygen/changes/main");
266 +
        let ping = envelope("ping", serde_json::json!({}));
267 +
        assert!(body_for("discord", &ping, "https://h").contains("OxygenHub is connected"));
268 +
    }
269 +
270 +
    #[test]
271 +
    fn slack_text_is_escaped_and_the_envelope_is_unchanged() {
272 +
        let p = envelope(
273 +
            "comment.issue",
274 +
            serde_json::json!({ "number": 3, "issue": { "number": 3, "title": "<b>" }, "comment": { "body": "a & b" } }),
275 +
        );
276 +
        let v: serde_json::Value = serde_json::from_str(&body_for("slack", &p, "https://h")).unwrap();
277 +
        assert_eq!(v["text"], "<https://h/andymccall/oxygen/issue/3|andy commented on issue #3: &lt;b&gt;>\n>a &amp; b");
278 +
        assert_eq!(body_for("oxygenhub", &p, "https://h"), p);
279 +
    }
280 +
281 +
    #[test]
282 +
    fn formats_follow_the_url_and_urls_are_masked() {
283 +
        use crate::db::hooks::{format_for_url, masked_url};
284 +
        assert_eq!(format_for_url("https://discord.com/api/webhooks/155/abc"), "discord");
285 +
        assert_eq!(format_for_url("https://hooks.slack.com/services/T/B/x"), "slack");
286 +
        assert_eq!(format_for_url("https://discord.com.evil.example/api/webhooks/1"), "oxygenhub");
287 +
        assert_eq!(format_for_url("https://ci.example/hook"), "oxygenhub");
288 +
        let m = masked_url("https://discord.com/api/webhooks/1554146511347126332/6g_WnXRdNOiVoo8sGXXW_OuIUceluD8lIUckxb9KHPwZ895XE");
289 +
        assert_eq!(m, "https://discord.com/api/webhooks/1554…/6g_W…");
290 +
        assert!(!m.contains("6g_WnX"), "the token is not shown");
291 +
        assert_eq!(masked_url("https://ci.example/hook?token=secret"), "https://ci.example/hook?…");
292 +
    }
108 293
109 294
    #[test]
110 295
    fn signatures_are_keyed() {
…
6 unchanged lines
modified crates/hub/src/web/issues.rs +25 −0
…
274 unchanged lines
275 275
        },
276 276
    )
277 277
    .await;
278 +
    crate::hooks::emit(
279 +
        &app,
280 +
        &row,
281 +
        "issue.opened",
282 +
        &me.account.username,
283 +
        serde_json::json!({ "issue": { "number": issue.number, "title": issue.title, "body": issue.body } }),
284 +
    )
285 +
    .await;
278 286
    Ok(Redirect::to(&url).into_response())
279 287
}
280 288
…
178 unchanged lines
459 467
        kind = "issue.reopened";
460 468
    }
461 469
    reindex(&app, row.id, &i).await;
470 +
    let hook_event = if kind == "issue.comment" { "comment.issue" } else { kind };
471 +
    crate::hooks::emit(
472 +
        &app,
473 +
        &row,
474 +
        hook_event,
475 +
        &me.account.username,
476 +
        serde_json::json!({ "number": number, "issue": { "number": number, "title": i.title }, "comment": { "body": f.body.trim() } }),
477 +
    )
478 +
    .await;
462 479
    let direct = i.author_id.into_iter().collect();
463 480
    notify::deliver(
464 481
        &app,
…
121 unchanged lines
586 603
    };
587 604
    idb::add_event(&app.db, i.id, &me.account.username, event, "").await.ok();
588 605
    if !kind.is_empty() {
606 +
        crate::hooks::emit(
607 +
            &app,
608 +
            &row,
609 +
            kind,
610 +
            &me.account.username,
611 +
            serde_json::json!({ "issue": { "number": number, "title": i.title }, "reason": event }),
612 +
        )
613 +
        .await;
589 614
        let direct = i.author_id.into_iter().collect();
590 615
        notify::deliver(
591 616
            &app,
…
367 unchanged lines
modified crates/hub/src/web/orgs.rs +2 −1
…
60 unchanged lines
61 61
    org: accounts::Organisation,
62 62
    members: Vec<(accounts::Account, String)>,
63 63
    teams: Vec<(accounts::Team, Vec<accounts::Account>)>,
64 -
    hooks: Vec<crate::db::hooks::Hook>,
64 +
    hooks: Vec<super::webhooks::HookRow>,
65 65
    notice: Option<String>,
66 66
    error: Option<String>,
67 67
}
…
12 unchanged lines
80 80
        teams.push((t, members));
81 81
    }
82 82
    let hooks = crate::db::hooks::hooks_for_org(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
83 +
    let hooks = super::webhooks::rows(app, hooks).await.map_err(|e| Error::from_db(shell.clone(), e))?;
83 84
    Ok(Page(OrgSettingsPage { shell, org, members, teams, hooks, notice, error }))
84 85
}
85 86
…
213 unchanged lines
modified crates/hub/src/web/repo_settings.rs +2 −1
…
86 unchanged lines
87 87
    teams: Vec<accounts::Team>,
88 88
    role: Role,
89 89
    requirements: db::proposals::Requirements,
90 -
    hooks: Vec<db::hooks::Hook>,
90 +
    hooks: Vec<super::webhooks::HookRow>,
91 91
    required_checks: String,
92 92
    notice: Option<String>,
93 93
    error: Option<String>,
…
15 unchanged lines
109 109
    let repo = RepoShell::new(app, &row, "settings").with_role(role);
110 110
    let requirements = db::proposals::requirements(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
111 111
    let hooks = db::hooks::hooks_for_repo(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
112 +
    let hooks = super::webhooks::rows(app, hooks).await.map_err(|e| Error::from_db(shell.clone(), e))?;
112 113
    let required_checks = db::hooks::required_checks(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.join(", ");
113 114
    Ok(Page(RepoSettingsPage { shell, repo, row, collaborators, teams, role, requirements, hooks, required_checks, notice, error }))
114 115
}
…
273 unchanged lines
modified crates/hub/src/web/webhooks.rs +76 −3
…
51 unchanged lines
52 52
    id: Option<i64>,
53 53
    #[serde(default)]
54 54
    action: String,
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)
55 93
}
56 94
57 95
fn star() -> String {
…
33 unchanged lines
91 129
            }
92 130
            "Ping queued.".to_string()
93 131
        }
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 +
        }
94 162
        _ => {
95 -
            if !(f.url.starts_with("http://") || f.url.starts_with("https://")) {
163 +
            let url = f.url.trim();
164 +
            if !url_ok(url) {
96 165
                return Ok(Err("A webhook URL starts with http:// or https://.".into()));
97 166
            }
98 167
            let (repo_id, org_id) = match scope {
99 168
                Scope::Repo { id, .. } => (Some(*id), None),
100 169
                Scope::Org { id, .. } => (None, Some(*id)),
101 170
            };
102 -
            db::hooks::create_hook(&app.db, repo_id, org_id, f.url.trim(), f.secret.trim(), f.events.trim()).await?;
103 -
            accounts::audit(&app.db, viewer.account(), "hook.created", scope.label(), f.url.trim()).await.ok();
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();
104 177
            "Webhook added.".to_string()
105 178
        }
106 179
    }))
…
37 unchanged lines
modified crates/hub/tests/api.rs +94 −0
…
483 unchanged lines
484 484
    assert_eq!(v["labels"], serde_json::json!([]));
485 485
    assert_eq!(api(app, "DELETE", "/api/v1/repos/andy/demo/labels/flow", Some(&andy_t), None).await.0, StatusCode::NOT_FOUND);
486 486
}
487 +
488 +
#[tokio::test(flavor = "multi_thread")]
489 +
async fn webhook_formats_editing_and_web_issue_events() {
490 +
    let h = hub().await;
491 +
    let app = &h.app;
492 +
    let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery"))
493 +
        .await
494 +
        .cookie
495 +
        .unwrap();
496 +
    let page = call(app, "GET", "/settings/tokens", None, Some(&andy), None, None).await;
497 +
    let r = call(
498 +
        app,
499 +
        "POST",
500 +
        "/settings/tokens",
501 +
        None,
502 +
        Some(&andy),
503 +
        None,
504 +
        Some(&format!("_csrf={}&name=t&scopes=repo%3Aadmin", csrf_of(&page.body))),
505 +
    )
506 +
    .await;
507 +
    let token = r.body.split("oxyhub_").nth(1).map(|s| format!("oxyhub_{}", s.split('<').next().unwrap().trim())).unwrap();
508 +
    assert_eq!(
509 +
        api(app, "POST", "/api/v1/repos", Some(&token), Some(r#"{"owner":"andy","name":"demo","visibility":"public"}"#)).await.0,
510 +
        StatusCode::CREATED
511 +
    );
512 +
    let rx = receiver().await;
513 +
    *rx.fail_first.lock().unwrap() = false;
514 +
    let settings = call(app, "GET", "/andy/demo/settings", None, Some(&andy), None, None).await.body;
515 +
    let csrf = csrf_of(&settings);
516 +
    let form = |f: String| {
517 +
        let andy = andy.clone();
518 +
        async move { call(app, "POST", "/andy/demo/settings/hooks", None, Some(&andy), None, Some(&f)).await.body }
519 +
    };
520 +
    let url = rx.url.replace(':', "%3A").replace('/', "%2F");
521 +
    assert!(form(format!("_csrf={csrf}&url={url}&format=discord&events=*")).await.contains("Webhook added"));
522 +
    let hook = oxygenhub::db::hooks::hooks_for_repo(&app.db, 1).await.unwrap().remove(0);
523 +
    assert_eq!(hook.format, "discord");
524 +
    // Ping arrives as a Discord message.
525 +
    form(format!("_csrf={csrf}&id={}&action=ping", hook.id)).await;
526 +
    let mut got = String::new();
527 +
    for _ in 0..100 {
528 +
        if let Some((_, _, body)) = rx.seen.lock().unwrap().first() {
529 +
            got = body.clone();
530 +
        }
531 +
        if !got.is_empty() {
532 +
            break;
533 +
        }
534 +
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
535 +
    }
536 +
    let v: serde_json::Value = serde_json::from_str(&got).unwrap();
537 +
    assert_eq!(v["embeds"][0]["title"], "OxygenHub is connected");
538 +
    assert_eq!(v["allowed_mentions"]["parse"], serde_json::json!([]));
539 +
    let page = call(app, "GET", "/andy/demo/settings", None, Some(&andy), None, None).await.body;
540 +
    assert!(page.contains("last delivery <span class=\"sig sig-valid\">✔</span> 200"), "the table shows how it went");
541 +
542 +
    // Editing: a blank URL and secret keep theirs; format, events and active change.
543 +
    assert!(form(format!("_csrf={csrf}&id={}&action=update&url=&secret=&events=issue&format=oxygenhub&active=1", hook.id))
544 +
        .await
545 +
        .contains("Webhook saved"));
546 +
    let edited = oxygenhub::db::hooks::hook(&app.db, hook.id).await.unwrap().unwrap();
547 +
    assert_eq!(
548 +
        (edited.url.as_str(), edited.events.as_str(), edited.format.as_str(), edited.active),
549 +
        (rx.url.as_str(), "issue", "oxygenhub", true)
550 +
    );
551 +
    assert!(form(format!("_csrf={csrf}&id={}&action=update&url=ftp%3A%2F%2Fx&events=*", hook.id)).await.contains("starts with http"));
552 +
553 +
    // An issue opened on the web now reaches the hook, as the signed envelope.
554 +
    let new = call(app, "GET", "/andy/demo/issues/new", None, Some(&andy), None, None).await.body;
555 +
    call(
556 +
        app,
557 +
        "POST",
558 +
        "/andy/demo/issues/new",
559 +
        None,
560 +
        Some(&andy),
561 +
        None,
562 +
        Some(&format!("_csrf={}&title=From+the+web&body=hello", csrf_of(&new))),
563 +
    )
564 +
    .await;
565 +
    let mut envelope = None;
566 +
    for _ in 0..100 {
567 +
        envelope = rx.seen.lock().unwrap().iter().find(|(e, _, _)| e == "issue.opened").cloned();
568 +
        if envelope.is_some() {
569 +
            break;
570 +
        }
571 +
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
572 +
    }
573 +
    let (_, sig, body) = envelope.expect("issue.opened delivered");
574 +
    let v: serde_json::Value = serde_json::from_str(&body).unwrap();
575 +
    assert_eq!(v["data"]["issue"]["title"], "From the web");
576 +
    assert!(sig.starts_with("blake3-keyed="));
577 +
    // A disabled hook through the edit form (no active box).
578 +
    form(format!("_csrf={csrf}&id={}&action=update&url=&events=*&format=oxygenhub", hook.id)).await;
579 +
    assert!(!oxygenhub::db::hooks::hook(&app.db, hook.id).await.unwrap().unwrap().active);
580 +
}
modified docs/spec.md +1 −1
…
402 unchanged lines
403 403
404 404
### 12.2 Webhooks
405 405
406 -
Per repository and per organisation: a URL, a secret, an event selection, active flag, and delivery log with redelivery. Repository admins manage a repository's webhooks under its settings; organisation owners manage the organisation's under its settings, and those hear every repository the organisation owns. Payloads are JSON with `X-Oxyhub-Event`, `X-Oxyhub-Delivery`, and `X-Oxyhub-Signature: blake3-keyed=<hex>` computed as keyed BLAKE3 over the body. Events: `push` (reference updated: old and new state, heads, changes), `proposal` (opened, edited, review requested, landed, closed, conflicted), `review`, `comment`, `issue`, `release`, `check`, `policy`. Deliveries retry with backoff up to 24 hours.
406 +
Per repository and per organisation: a URL, a secret, an event selection, active flag, and delivery log with redelivery. Each webhook has a **format**: `oxygenhub` (the signed JSON envelope below), `discord` (an embed with a title, link and short detail for the event, sent with mentions disabled so no user text can ping anyone) or `slack` (escaped `text`); a Discord or Slack URL picks its format by default, and every event has a one-line summary for the chat formats. The settings show a webhook's URL with its long path segments and query cut short, since chat webhooks keep a secret token there (the audit log records the same masked form), and each webhook's last delivery result; a webhook can be edited (URL, secret, events, format, active), a blank URL or secret keeping the current one. Repository admins manage a repository's webhooks under its settings; organisation owners manage the organisation's under its settings, and those hear every repository the organisation owns. Payloads are JSON with `X-Oxyhub-Event`, `X-Oxyhub-Delivery`, and `X-Oxyhub-Signature: blake3-keyed=<hex>` computed as keyed BLAKE3 over the body. Events: `push` (reference updated: old and new state, heads, changes), `proposal` (opened, edited, review requested, landed, closed, conflicted), `review`, `comment`, `issue`, `release`, `check`, `policy`. Deliveries retry with backoff up to 24 hours.
407 407
408 408
## 13. Checks and OxygenFlow
409 409
…
167 unchanged lines
modified templates/deliveries.html +1 −1
…
3 unchanged lines
4 4
{% block tabs %}{% if let Some(r) = repo %}{% call m::tabs(r, "settings") %}{% endif %}{% endblock %}
5 5
{% block mainclass %} class="wide"{% endblock %}
6 6
{% block content %}
7 -
<div class="head"><h1>Deliveries <small>{{ hook.url }} · events {{ hook.events }}</small></h1></div>
7 +
<div class="head"><h1>Deliveries <small><code class="hook-url">{{ crate::db::hooks::masked_url(hook.url) }}</code> · {{ hook.format }} · events {{ hook.events }}</small></h1></div>
8 8
<p><a href="{{ settings_url }}#webhooks">← settings</a></p>
9 9
{% if rows.is_empty() %}<p class="empty">Nothing delivered yet.</p>{% else %}
10 10
<div class="table"><table>
…
10 unchanged lines
modified templates/style.css +8 −0
…
509 unchanged lines
510 510
.state-open { color: var(--ok); } .state-closed, .state-landed { color: var(--acc); }
511 511
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
512 512
.card-item.note > p { color: var(--ink); }
513 +
514 +
/* Webhooks (hub spec §12.2): long URLs wrap inside the panel. */
515 +
ul.hooks { list-style: none; margin: 0 0 12px; padding: 0; display: flex; flex-direction: column; gap: 8px; }
516 +
li.hook { border: 1px solid var(--line); background: var(--soft); padding: 8px 12px; min-width: 0; }
517 +
.hook-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
518 +
code.hook-url { overflow-wrap: anywhere; word-break: break-all; }
519 +
li.hook p { margin: 4px 0; }
520 +
li.hook .hook-edit { margin-top: 4px; }
modified templates/webhooks_panel.html +42 −7
…
2 unchanged lines
3 3
  <h2>Webhooks</h2>
4 4
  <p class="small mute">{{ what }}</p>
5 5
  {% if hooks.is_empty() %}<p class="mute small">None.</p>{% else %}
6 -
  <table class="small"><tr><th>URL</th><th>Events</th><th>Active</th><th></th></tr>
7 -
  {% for h in hooks %}<tr><td>{{ h.url }}</td><td>{{ h.events }}</td><td>{% if h.active %}yes{% else %}no{% endif %}</td>
8 -
  <td><a class="small" href="{{ base }}/hooks/{{ h.id }}/deliveries">deliveries</a>
9 -
  <form class="tiny" method="post" action="{{ base }}/hooks"><input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="id" value="{{ h.id }}"><button name="action" value="ping">ping</button> <button name="action" value="toggle">{% if h.active %}disable{% else %}enable{% endif %}</button> <button name="action" value="delete">delete</button></form></td></tr>{% endfor %}
10 -
  </table>{% endif %}
6 +
  <ul class="hooks">
7 +
  {% for r in hooks %}
8 +
  <li class="hook">
9 +
    <div class="hook-head">
10 +
      <code class="hook-url" title="The path is shortened: webhook URLs often carry a secret token">{{ r.masked }}</code>
11 +
      <span class="tag">{{ r.hook.format }}</span>
12 +
      {% if r.hook.active %}<span class="tag head">active</span>{% else %}<span class="tag dim">disabled</span>{% endif %}
13 +
    </div>
14 +
    <p class="small mute">events <code>{{ r.hook.events }}</code> ·
15 +
      {% if let Some((ok, text)) = r.last_result() %}last delivery {% if ok %}<span class="sig sig-valid">✔</span>{% else %}<span class="sig sig-invalid">✘</span>{% endif %} {{ text }}{% else %}nothing delivered yet{% endif %}
16 +
      · <a href="{{ base }}/hooks/{{ r.hook.id }}/deliveries">deliveries</a></p>
17 +
    <form class="tiny" method="post" action="{{ base }}/hooks"><input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="id" value="{{ r.hook.id }}"><button name="action" value="ping">ping</button> <button name="action" value="toggle">{% if r.hook.active %}disable{% else %}enable{% endif %}</button> <button name="action" value="delete" onclick="return confirm('Delete this webhook?')">delete</button></form>
18 +
    <details class="hook-edit"><summary class="small">edit</summary>
19 +
      <form class="card" method="post" action="{{ base }}/hooks">
20 +
        <input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="id" value="{{ r.hook.id }}"><input type="hidden" name="action" value="update">
21 +
        <label for="hu{{ r.hook.id }}">URL</label><input type="text" id="hu{{ r.hook.id }}" name="url" placeholder="{{ r.masked }}" autocomplete="off">
22 +
        <p class="small mute">Leave empty to keep the current URL.</p>
23 +
        <label for="hs{{ r.hook.id }}">Secret</label><input type="text" id="hs{{ r.hook.id }}" name="secret" placeholder="unchanged" autocomplete="off">
24 +
        <label for="he{{ r.hook.id }}">Events</label><input type="text" id="he{{ r.hook.id }}" name="events" value="{{ r.hook.events }}">
25 +
        <label for="hf{{ r.hook.id }}">Format</label>
26 +
        <select id="hf{{ r.hook.id }}" name="format">
27 +
          <option value="oxygenhub"{% if r.hook.format == "oxygenhub" %} selected{% endif %}>OxygenHub (signed JSON envelope)</option>
28 +
          <option value="discord"{% if r.hook.format == "discord" %} selected{% endif %}>Discord</option>
29 +
          <option value="slack"{% if r.hook.format == "slack" %} selected{% endif %}>Slack</option>
30 +
        </select>
31 +
        <div class="choice"><label><input type="checkbox" name="active" value="1"{% if r.hook.active %} checked{% endif %}> active</label></div>
32 +
        <button type="submit">SAVE</button>
33 +
      </form>
34 +
    </details>
35 +
  </li>
36 +
  {% endfor %}
37 +
  </ul>{% endif %}
11 38
  <form class="card" method="post" action="{{ base }}/hooks">
12 39
    <input type="hidden" name="_csrf" value="{{ csrf }}">
13 -
    <label for="hurl">URL</label><input type="text" id="hurl" name="url" placeholder="https://…" required>
14 -
    <label for="hsecret">Secret (signs each delivery)</label><input type="text" id="hsecret" name="secret">
40 +
    <h3>Add a webhook</h3>
41 +
    <label for="hurl">URL</label><input type="text" id="hurl" name="url" placeholder="https://…" required autocomplete="off">
42 +
    <label for="hformat">Format</label>
43 +
    <select id="hformat" name="format">
44 +
      <option value="">From the URL (Discord and Slack are recognised)</option>
45 +
      <option value="oxygenhub">OxygenHub (signed JSON envelope)</option>
46 +
      <option value="discord">Discord</option>
47 +
      <option value="slack">Slack</option>
48 +
    </select>
49 +
    <label for="hsecret">Secret (signs each delivery)</label><input type="text" id="hsecret" name="secret" autocomplete="off">
15 50
    <label for="hevents">Events (<code>*</code>, or a list such as <code>push,proposal,issue</code>)</label><input type="text" id="hevents" name="events" value="*">
16 51
    <button type="submit">ADD WEBHOOK</button>
17 52
  </form>
…
2 unchanged lines