use aws_lc_rs::hmac;
use http::HeaderMap;
use serde_json::Value;
use vgi_forge::{ForgeError, ForgeEvent, ForgeEventKind, Resource, Result};
use crate::secret::Secret;
pub const HOOK_EVENTS: [&str; 1] = ["repository"];
pub fn verify_signature(secret: &Secret, headers: &HeaderMap, body: &[u8]) -> Result<()> {
let (name, value) = ["x-forgejo-signature", "x-gitea-signature"]
.into_iter()
.find_map(|n| headers.get(n).map(|v| (n, v)))
.ok_or_else(|| {
ForgeError::Webhook("missing X-Forgejo-Signature (or X-Gitea-Signature)".into())
})?;
let text = value
.to_str()
.map_err(|_| ForgeError::Webhook(format!("{name} is not ASCII")))?;
let tag =
hex::decode(text.trim()).map_err(|_| ForgeError::Webhook(format!("{name} is not hex")))?;
let key = hmac::Key::new(hmac::HMAC_SHA256, secret.expose().as_bytes());
hmac::verify(&key, body, &tag)
.map_err(|_| ForgeError::Webhook("signature does not match the body".into()))
}
pub fn sign_body(secret: &Secret, body: &[u8]) -> String {
let key = hmac::Key::new(hmac::HMAC_SHA256, secret.expose().as_bytes());
hex::encode(hmac::sign(&key, body).as_ref())
}
pub fn parse(
secret: &Secret,
host: &str,
headers: &HeaderMap,
body: &[u8],
) -> Result<Option<ForgeEvent>> {
verify_signature(secret, headers, body)?;
let event = header_str(headers, "x-forgejo-event")?
.or(header_str(headers, "x-gitea-event")?)
.ok_or_else(|| ForgeError::Webhook("missing X-Forgejo-Event".into()))?;
let delivery = header_str(headers, "x-forgejo-delivery")?
.or(header_str(headers, "x-gitea-delivery")?)
.map(str::to_string);
let payload: Value = serde_json::from_slice(body)
.map_err(|e| ForgeError::Webhook(format!("body is not JSON: {e}")))?;
let action = payload.get("action").and_then(Value::as_str).unwrap_or("");
let kind = match (event, action) {
("repository", "created") => {
let (repo, forge_id) = repository(host, &payload)?;
Some(ForgeEventKind::RepoCreated { repo, forge_id })
}
("repository", "deleted") => {
let (repo, forge_id) = repository(host, &payload)?;
Some(ForgeEventKind::RepoDeleted { repo, forge_id })
}
_ => None,
};
Ok(kind.map(|k| ForgeEvent::new(delivery, k)))
}
fn header_str<'a>(headers: &'a HeaderMap, name: &str) -> Result<Option<&'a str>> {
headers
.get(name)
.map(|v| {
v.to_str()
.map_err(|_| ForgeError::Webhook(format!("{name} is not ASCII")))
})
.transpose()
}
fn repository(host: &str, payload: &Value) -> Result<(Resource, u64)> {
let repo = &payload["repository"];
let full_name = repo
.get("full_name")
.and_then(Value::as_str)
.ok_or_else(|| ForgeError::Webhook("payload is missing `repository.full_name`".into()))?;
let resource = Resource::parse_owner_repo(&format!("{host}/{full_name}"))?;
resource
.require_owner_repo()
.map_err(|_| ForgeError::Webhook(format!("`{full_name}` is not an owner/repo name")))?;
let id = repo
.get("id")
.and_then(Value::as_u64)
.ok_or_else(|| ForgeError::Webhook("payload is missing numeric `repository.id`".into()))?;
Ok((resource, id))
}