1use crate::config::WebhookConfig;
2use crate::handler::{spawn_pr_job, WebhookHandler};
3use crate::payload::ProviderKind;
4use crate::signature::{verify_gitea_signature, verify_github_signature};
5use axum::{
6 body::Bytes,
7 extract::State,
8 http::{HeaderMap, StatusCode},
9 response::IntoResponse,
10 routing::{get, post},
11 Json, Router,
12};
13use serde_json::json;
14use std::sync::Arc;
15use tower_http::trace::TraceLayer;
16use tracing::warn;
17
18#[derive(Clone)]
19struct AppState {
20 handler: Arc<WebhookHandler>,
21 secret: String,
22}
23
24pub async fn run_server(config: WebhookConfig) -> Result<(), std::io::Error> {
25 let bind = config.bind_addr.clone();
26 let secret = config.webhook_secret.clone();
27 let handler = Arc::new(WebhookHandler::new(config));
28
29 let state = AppState { handler, secret };
30
31 let app = Router::new()
32 .route("/health", get(health))
33 .route("/webhook", post(webhook))
34 .route("/webhook/github", post(webhook_github))
35 .route("/webhook/gitea", post(webhook_gitea))
36 .with_state(state)
37 .layer(TraceLayer::new_for_http());
38
39 let listener = tokio::net::TcpListener::bind(&bind).await?;
40 tracing::info!(%bind, "CAC webhook server listening");
41 axum::serve(listener, app).await?;
42 Ok(())
43}
44
45async fn health() -> impl IntoResponse {
46 Json(json!({ "status": "ok", "service": "compliance-as-code-webhook" }))
47}
48
49async fn webhook(
50 State(state): State<AppState>,
51 headers: HeaderMap,
52 body: Bytes,
53) -> impl IntoResponse {
54 let provider = detect_provider(&headers);
55 handle_webhook(state, provider, headers, body).await
56}
57
58async fn webhook_github(
59 State(state): State<AppState>,
60 headers: HeaderMap,
61 body: Bytes,
62) -> impl IntoResponse {
63 handle_webhook(state, ProviderKind::GitHub, headers, body).await
64}
65
66async fn webhook_gitea(
67 State(state): State<AppState>,
68 headers: HeaderMap,
69 body: Bytes,
70) -> impl IntoResponse {
71 handle_webhook(state, ProviderKind::Gitea, headers, body).await
72}
73
74async fn handle_webhook(
75 state: AppState,
76 provider: ProviderKind,
77 headers: HeaderMap,
78 body: Bytes,
79) -> impl IntoResponse {
80 if !verify_request(&state.secret, provider, &headers, &body) {
81 warn!("webhook signature verification failed");
82 return (StatusCode::UNAUTHORIZED, Json(json!({ "error": "invalid signature" })));
83 }
84
85 let event = event_name(provider, &headers);
86 if event != "pull_request" {
87 return (
88 StatusCode::OK,
89 Json(json!({ "status": "ignored", "event": event })),
90 );
91 }
92
93 let ctx = match WebhookHandler::parse_event(provider, &body) {
94 Ok(Some(ctx)) => ctx,
95 Ok(None) => {
96 return (
97 StatusCode::OK,
98 Json(json!({ "status": "ignored", "reason": "unsupported action" })),
99 );
100 }
101 Err(err) => {
102 warn!(error = %err, "failed to parse webhook payload");
103 return (
104 StatusCode::BAD_REQUEST,
105 Json(json!({ "error": err.to_string() })),
106 );
107 }
108 };
109
110 spawn_pr_job(state.handler.clone(), ctx);
111 (
112 StatusCode::ACCEPTED,
113 Json(json!({ "status": "accepted", "message": "compliance scan queued" })),
114 )
115}
116
117fn detect_provider(headers: &HeaderMap) -> ProviderKind {
118 if headers.contains_key("x-github-event") {
119 ProviderKind::GitHub
120 } else {
121 ProviderKind::Gitea
122 }
123}
124
125fn event_name(provider: ProviderKind, headers: &HeaderMap) -> String {
126 match provider {
127 ProviderKind::GitHub => header_value(headers, "x-github-event"),
128 ProviderKind::Gitea => {
129 header_value(headers, "x-gitea-event").or(header_value(headers, "x-gitea-event-type"))
130 }
131 }
132 .unwrap_or_else(|| "unknown".into())
133}
134
135fn verify_request(
136 secret: &str,
137 provider: ProviderKind,
138 headers: &HeaderMap,
139 body: &[u8],
140) -> bool {
141 if secret.is_empty() || secret == "change-me" {
142 return true;
143 }
144 match provider {
145 ProviderKind::GitHub => headers
146 .get("x-hub-signature-256")
147 .and_then(|v| v.to_str().ok())
148 .map(|sig| verify_github_signature(secret, body, sig))
149 .unwrap_or(false),
150 ProviderKind::Gitea => headers
151 .get("x-gitea-signature")
152 .and_then(|v| v.to_str().ok())
153 .map(|sig| verify_gitea_signature(secret, body, sig))
154 .unwrap_or(false),
155 }
156}
157
158fn header_value(headers: &HeaderMap, name: &str) -> Option<String> {
159 headers
160 .get(name)
161 .and_then(|v| v.to_str().ok())
162 .map(str::to_string)
163}