use axum::{ body::Bytes, extract::{Path, State}, http::{HeaderMap, StatusCode}, }; use hmac::{Hmac, Mac}; use sha2::Sha256; use crate::{builder, AppState}; type HmacSha256 = Hmac; pub async fn github( State(s): State, Path(app_id): Path, headers: HeaderMap, body: Bytes, ) -> StatusCode { let app = match sqlx::query_as::<_, crate::models::App>("SELECT * FROM apps WHERE id = ?") .bind(&app_id) .fetch_optional(&s.db) .await { Ok(Some(a)) => a, _ => return StatusCode::NOT_FOUND, }; // Verify HMAC-SHA256 signature. let sig = headers .get("x-hub-signature-256") .and_then(|v| v.to_str().ok()) .unwrap_or(""); if !verify_sig(&app.webhook_secret, &body, sig) { tracing::warn!("Bad webhook signature for app {}", app_id); return StatusCode::UNAUTHORIZED; } // Only deploy on pushes to the configured branch. let payload: serde_json::Value = match serde_json::from_slice(&body) { Ok(v) => v, Err(_) => return StatusCode::BAD_REQUEST, }; let pushed_ref = payload["ref"].as_str().unwrap_or(""); let expected_ref = format!("refs/heads/{}", app.branch); if pushed_ref != expected_ref { return StatusCode::OK; // different branch — silently ignore } let sha = payload["after"].as_str().map(String::from); if let Err(e) = builder::enqueue_deploy(&s, &app_id, "webhook", sha).await { tracing::error!("Enqueue deploy for {}: {}", app_id, e); return StatusCode::INTERNAL_SERVER_ERROR; } StatusCode::OK } /// Constant-time HMAC-SHA256 signature check. fn verify_sig(secret: &str, body: &[u8], sig_header: &str) -> bool { let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) { Ok(m) => m, Err(_) => return false, }; mac.update(body); let expected = format!("sha256={}", hex::encode(mac.finalize().into_bytes())); // Constant-time compare to avoid timing side-channels. expected.len() == sig_header.len() && expected .bytes() .zip(sig_header.bytes()) .fold(0u8, |acc, (a, b)| acc | (a ^ b)) == 0 }