vgi_forge_forgejo/
webhook.rs1use aws_lc_rs::hmac;
21use http::HeaderMap;
22use serde_json::Value;
23use vgi_forge::{ForgeError, ForgeEvent, ForgeEventKind, Resource, Result};
24
25use crate::secret::Secret;
26
27pub const HOOK_EVENTS: [&str; 1] = ["repository"];
29
30pub fn verify_signature(secret: &Secret, headers: &HeaderMap, body: &[u8]) -> Result<()> {
33 let (name, value) = ["x-forgejo-signature", "x-gitea-signature"]
34 .into_iter()
35 .find_map(|n| headers.get(n).map(|v| (n, v)))
36 .ok_or_else(|| {
37 ForgeError::Webhook("missing X-Forgejo-Signature (or X-Gitea-Signature)".into())
38 })?;
39 let text = value
40 .to_str()
41 .map_err(|_| ForgeError::Webhook(format!("{name} is not ASCII")))?;
42 let tag =
43 hex::decode(text.trim()).map_err(|_| ForgeError::Webhook(format!("{name} is not hex")))?;
44 let key = hmac::Key::new(hmac::HMAC_SHA256, secret.expose().as_bytes());
45 hmac::verify(&key, body, &tag)
47 .map_err(|_| ForgeError::Webhook("signature does not match the body".into()))
48}
49
50pub fn sign_body(secret: &Secret, body: &[u8]) -> String {
53 let key = hmac::Key::new(hmac::HMAC_SHA256, secret.expose().as_bytes());
54 hex::encode(hmac::sign(&key, body).as_ref())
55}
56
57pub fn parse(
59 secret: &Secret,
60 host: &str,
61 headers: &HeaderMap,
62 body: &[u8],
63) -> Result<Option<ForgeEvent>> {
64 verify_signature(secret, headers, body)?;
65
66 let event = header_str(headers, "x-forgejo-event")?
67 .or(header_str(headers, "x-gitea-event")?)
68 .ok_or_else(|| ForgeError::Webhook("missing X-Forgejo-Event".into()))?;
69 let delivery = header_str(headers, "x-forgejo-delivery")?
70 .or(header_str(headers, "x-gitea-delivery")?)
71 .map(str::to_string);
72 let payload: Value = serde_json::from_slice(body)
73 .map_err(|e| ForgeError::Webhook(format!("body is not JSON: {e}")))?;
74 let action = payload.get("action").and_then(Value::as_str).unwrap_or("");
75
76 let kind = match (event, action) {
77 ("repository", "created") => {
78 let (repo, forge_id) = repository(host, &payload)?;
79 Some(ForgeEventKind::RepoCreated { repo, forge_id })
80 }
81 ("repository", "deleted") => {
82 let (repo, forge_id) = repository(host, &payload)?;
83 Some(ForgeEventKind::RepoDeleted { repo, forge_id })
84 }
85 _ => None,
86 };
87 Ok(kind.map(|k| ForgeEvent::new(delivery, k)))
88}
89
90fn header_str<'a>(headers: &'a HeaderMap, name: &str) -> Result<Option<&'a str>> {
91 headers
92 .get(name)
93 .map(|v| {
94 v.to_str()
95 .map_err(|_| ForgeError::Webhook(format!("{name} is not ASCII")))
96 })
97 .transpose()
98}
99
100fn repository(host: &str, payload: &Value) -> Result<(Resource, u64)> {
101 let repo = &payload["repository"];
102 let full_name = repo
103 .get("full_name")
104 .and_then(Value::as_str)
105 .ok_or_else(|| ForgeError::Webhook("payload is missing `repository.full_name`".into()))?;
106 let resource = Resource::parse_owner_repo(&format!("{host}/{full_name}"))?;
107 resource
108 .require_owner_repo()
109 .map_err(|_| ForgeError::Webhook(format!("`{full_name}` is not an owner/repo name")))?;
110 let id = repo
111 .get("id")
112 .and_then(Value::as_u64)
113 .ok_or_else(|| ForgeError::Webhook("payload is missing numeric `repository.id`".into()))?;
114 Ok((resource, id))
115}