use axum::{extract::State, http::StatusCode, response::IntoResponse}; use serde::Deserialize; use subtle::ConstantTimeEq; use crate::AppState; #[derive(Debug, Deserialize)] pub struct WebhookPayload { pub secret: String, #[serde(default)] pub athlete_id: Option, #[serde(default)] pub activity_id: Option, } pub async fn receive( State(state): State, axum::Json(payload): axum::Json, ) -> impl IntoResponse { let Some(expected_secret) = state.config.intervals_webhook_secret.as_deref() else { tracing::error!("Intervals.icu webhook secret is not configured"); return ( StatusCode::INTERNAL_SERVER_ERROR, "webhook secret not configured", ) .into_response(); }; let secret_matches = payload.secret.as_bytes().ct_eq(expected_secret.as_bytes()); if !bool::from(secret_matches) { tracing::warn!("received webhook with invalid secret"); return (StatusCode::UNAUTHORIZED, "invalid webhook secret").into_response(); } let Some(activity_id) = payload.activity_id else { tracing::warn!("received webhook without activity_id"); return (StatusCode::BAD_REQUEST, "missing activity_id").into_response(); }; let athlete_id = payload .athlete_id .or_else(|| std::env::var("INTERVALS_ATHLETE_ID").ok()); let Some(athlete_id) = athlete_id else { tracing::error!( "webhook has no athlete_id and \ INTERVALS_ATHLETE_ID is not configured" ); return (StatusCode::BAD_REQUEST, "missing athlete_id").into_response(); }; let state_clone = state.clone(); tokio::spawn(async move { if let Err(error) = crate::process_activity(state_clone, athlete_id, activity_id.clone()).await { tracing::error!( %error, activity_id = %activity_id, "failed to process webhook activity" ); } }); (StatusCode::ACCEPTED, "activity queued").into_response() }