use axum::body::{Bytes, to_bytes}; use axum::extract::{FromRef, FromRequest, Path, State}; use axum::http::Request; use axum::response::IntoResponse; use axum::response::sse::{Event, KeepAlive, Sse}; use axum::routing::{get, post}; use axum::{Json, Router}; use futures_util::stream::{self, Stream, StreamExt}; use reqwest::StatusCode; use ring::hmac; use sentry::integrations::tower::{NewSentryLayer, SentryHttpLayer}; use serde_json::{Value, json}; use tokio_stream::wrappers::{BroadcastStream, errors::BroadcastStreamRecvError}; use tower::ServiceBuilder; use tower_http::services::ServeDir; use tower_http::trace::TraceLayer; use tracing::{info, instrument, warn}; use crate::consts::{GITEA_EVENT_TYPE_HEADER_NAME, GITEA_SIG_HEADER_NAME, MAX_WEBHOOK_BODY_SIZE}; use crate::errors::AppError; use crate::event_bus::JobEvent; use crate::gitea::WebhookType; use crate::metrics; use crate::schemas::JobStatus; use crate::state::AppState; pub async fn start(app_state: AppState) -> anyhow::Result<()> { let http_port = app_state.config.http_port; let shutdown = app_state.shutdown.clone(); // The dashboard is served from a static directory; the API routes below take // precedence (the directory is only the fallback). let front_dir = std::env::var("FRONT_DIR").unwrap_or_else(|_| String::from("front")); if !std::path::Path::new(&front_dir).is_dir() { warn!(dir = %front_dir, "front directory not found, the dashboard will 404"); } let dashboard = ServeDir::new(&front_dir); let app = Router::new() .route("/webhook", post(webhook)) .route("/reviews", get(reviews_list)) .route("/jobs", get(jobs_list)) .route("/events", get(lifecycle_events)) .route("/jobs/{id}/events", get(job_events)) .fallback_service(dashboard) .layer( ServiceBuilder::new() .layer(NewSentryLayer::>::new_from_top()) .layer(SentryHttpLayer::new()) .layer(TraceLayer::new_for_http()), ) .with_state(app_state); let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", http_port)).await?; info!("Listening API on port {}", http_port); axum::serve(listener, app) .with_graceful_shutdown(async move { shutdown.cancelled().await }) .await .map_err(anyhow::Error::from) .inspect(|_| info!("API shutting down complete"))?; Ok(()) } async fn reviews_list(State(app_state): State) -> Result { let reviews = app_state.reviews.list().await?; Ok(Json(json!({ "reviews": reviews }))) } async fn jobs_list(State(app_state): State) -> Result { let pending = app_state.queue.list(JobStatus::Pending).await?; let processing = app_state.queue.list(JobStatus::Processing).await?; Ok(Json(json!({ "pending": pending, "processing": processing, }))) } /// SSE stream of the global lifecycle events (the job list). /// /// The stream is closed as soon as the graceful shutdown starts: a live SSE /// connection would otherwise keep the server from stopping. async fn lifecycle_events( State(app_state): State, ) -> Sse>> { let rx = app_state.event_bus.subscribe_lifecycle(); let shutdown = app_state.shutdown.cancelled_owned(); let stream = BroadcastStream::new(rx).map(|result| match result { Ok(event) => sse_json("lifecycle", &event), Err(BroadcastStreamRecvError::Lagged(skipped)) => sse_gap(skipped), }); Sse::new(stream.take_until(shutdown)).keep_alive(KeepAlive::default()) } async fn job_events( State(app_state): State, Path(id): Path, ) -> Result>>, AppError> { let subscription = app_state .event_bus .subscribe_job(id) .ok_or(AppError::JobNotFoundErr(id))?; let shutdown = app_state.shutdown.cancelled_owned(); let history = subscription .history .into_iter() .map(|event| sse_json("progress", &event)); let live = BroadcastStream::new(subscription.live).map(|result| match result { Ok(event) => sse_json("progress", &event), Err(BroadcastStreamRecvError::Lagged(skipped)) => sse_gap(skipped), }); let stream = stream::iter(history).chain(live).take_until(shutdown); Ok(Sse::new(stream).keep_alive(KeepAlive::default())) } fn sse_json(name: &'static str, payload: &T) -> Result { Event::default().event(name).json_data(payload) } fn sse_gap(skipped: u64) -> Result { Ok(Event::default().event("gap").data(skipped.to_string())) } #[instrument(skip(app_state), fields(webhook_type), err)] async fn webhook( State(app_state): State, WebhookExtract(wb): WebhookExtract, ) -> Result { tracing::Span::current().record("webhook_type", tracing::field::debug(&wb)); let event_type = wb.event_type_str(); metrics::webhook_received(event_type); let event_id = wb.event_id(); match is_known(&app_state, event_id).await { Ok(true) => { metrics::webhook_duplicate(event_type); return Err(AppError::AlreadyProcessedErr); } Ok(false) => {} Err(err) => { metrics::webhook_enqueue_failed(event_type); return Err(AppError::EnqueueErr(anyhow::Error::from(err))); } } let job = match app_state.queue.push(wb).await { Ok(job) => job, Err(err) => { metrics::webhook_enqueue_failed(event_type); return Err(AppError::EnqueueErr(anyhow::Error::from(err))); } }; app_state.event_bus.publish(JobEvent::Queued { id: job.id, repo: job.repo, pr: job.pr, }); Ok((StatusCode::CREATED, "Task started")) } /// Whether an event is already queued, or already reviewed. async fn is_known(app_state: &AppState, event_id: u64) -> toasty::Result { Ok(app_state.queue.is_queued(event_id).await? || app_state.reviews.is_reviewed(event_id).await?) } pub struct WebhookExtract(pub WebhookType); impl FromRequest for WebhookExtract where AppState: FromRef, S: Send + Sync, { type Rejection = AppError; #[instrument(skip(req, state), err)] async fn from_request(req: axum::extract::Request, state: &S) -> Result { let app_state = AppState::from_ref(state); let headers = req.headers(); let sig_header = extract_header(GITEA_SIG_HEADER_NAME, headers)?; let type_header = extract_header(GITEA_EVENT_TYPE_HEADER_NAME, headers)?; let body_bytes = read_body(req.into_body()).await?; verify_signature( app_state.config.webhook_secret.as_bytes(), &sig_header, &body_bytes, )?; let body_str = String::from_utf8_lossy(&body_bytes).into_owned(); sentry::configure_scope(|scope| { scope.add_event_processor(move |mut event| { let mut request = event.request.take().unwrap_or_default(); request.data = Some(body_str.clone()); event.request = Some(request); Some(event) }); }); let webhook = parse_webhook(&type_header, &app_state.bot_name, &body_bytes)?; Ok(WebhookExtract(webhook)) } } fn extract_header(key: &str, headers: &axum::http::HeaderMap) -> Result { let value = headers .get(key) .ok_or(AppError::WebHookMissingHeaderErr(key.into()))? .to_str() .map_err(anyhow::Error::from)?; Ok(value.to_owned()) } async fn read_body(body: axum::body::Body) -> Result { to_bytes(body, MAX_WEBHOOK_BODY_SIZE) .await .map_err(anyhow::Error::from) .map_err(AppError::from) } fn parse_webhook(header: &str, bot_name: &str, body_bytes: &[u8]) -> Result { let Json(value) = Json::::from_bytes(body_bytes).map_err(|_| AppError::MalformedJsonErr)?; WebhookType::from_event(header, bot_name, value) } fn verify_signature(secret_key: &[u8], sig_header: &str, body: &[u8]) -> Result<(), AppError> { let sig_header_decoded = hex::decode(sig_header).map_err(|_| AppError::WebHookSigHeaderInvalidErr)?; let key = hmac::Key::new(hmac::HMAC_SHA256, secret_key); hmac::verify(&key, body, &sig_header_decoded).map_err(|_| AppError::WebHookSigHeaderInvalidErr) }