mod admin;
pub use admin::admin;
use axum::{
async_trait,
extract::{FromRequest, Request},
http::StatusCode,
response::{IntoResponse, Response},
};
use webhooksmith::signing;
use serde::de::DeserializeOwned;
use tower_layer::Layer;
use std::sync::Arc;
const MAX_BODY_BYTES: usize = 1_048_576;
#[derive(Clone)]
struct WebhookSecret(Arc<String>);
#[derive(Clone)]
pub struct WebhookSecretLayer {
secret: Arc<String>,
}
impl WebhookSecretLayer {
pub fn new(secret: impl Into<String>) -> Self {
Self { secret: Arc::new(secret.into()) }
}
}
impl<S> Layer<S> for WebhookSecretLayer {
type Service = WebhookSecretService<S>;
fn layer(&self, inner: S) -> Self::Service {
WebhookSecretService {
inner,
secret: self.secret.clone(),
}
}
}
#[derive(Clone)]
pub struct WebhookSecretService<S> {
inner: S,
secret: Arc<String>,
}
impl<S, B> tower::Service<Request<B>> for WebhookSecretService<S>
where
S: tower::Service<Request<B>>,
{
type Response = S::Response;
type Error = S::Error;
type Future = S::Future;
fn poll_ready(
&mut self,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, mut req: Request<B>) -> Self::Future {
req.extensions_mut()
.insert(WebhookSecret(self.secret.clone()));
self.inner.call(req)
}
}
#[derive(Debug)]
pub enum WebhookRejection {
MissingSecret,
MissingTimestamp,
MissingSignature,
BodyTooLarge,
InvalidSignature,
InvalidBody(serde_json::Error),
}
impl IntoResponse for WebhookRejection {
fn into_response(self) -> Response {
let (status, msg) = match &self {
Self::MissingSecret => (StatusCode::INTERNAL_SERVER_ERROR, "webhook secret not configured"),
Self::MissingTimestamp => (StatusCode::BAD_REQUEST, "missing x-hooksmith-timestamp header"),
Self::MissingSignature => (StatusCode::UNAUTHORIZED, "missing x-hooksmith-signature header"),
Self::BodyTooLarge => (StatusCode::PAYLOAD_TOO_LARGE, "request body too large"),
Self::InvalidSignature => (StatusCode::UNAUTHORIZED, "invalid webhook signature"),
Self::InvalidBody(_) => (StatusCode::UNPROCESSABLE_ENTITY, "invalid JSON body"),
};
(status, msg).into_response()
}
}
pub struct WebhookPayload {
pub event_type: String,
pub event_id: Option<String>,
pub timestamp: i64,
pub body: serde_json::Value,
}
async fn extract_and_verify(req: Request) -> Result<WebhookPayload, WebhookRejection> {
let secret = req
.extensions()
.get::<WebhookSecret>()
.ok_or(WebhookRejection::MissingSecret)?
.0
.clone();
let timestamp: i64 = req
.headers()
.get("x-hooksmith-timestamp")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok())
.ok_or(WebhookRejection::MissingTimestamp)?;
let signature = req
.headers()
.get("x-hooksmith-signature")
.and_then(|v| v.to_str().ok())
.ok_or(WebhookRejection::MissingSignature)?
.to_owned();
let event_type = req
.headers()
.get("x-hooksmith-event-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("unknown")
.to_owned();
let event_id = req
.headers()
.get("x-hooksmith-event-id")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_owned());
let bytes = axum::body::to_bytes(req.into_body(), MAX_BODY_BYTES)
.await
.map_err(|_| WebhookRejection::BodyTooLarge)?;
if !signing::verify(&secret, timestamp, &bytes, &signature) {
tracing::warn!(
event_type = %event_type,
"webhook signature verification failed"
);
return Err(WebhookRejection::InvalidSignature);
}
let body: serde_json::Value =
serde_json::from_slice(&bytes).map_err(WebhookRejection::InvalidBody)?;
Ok(WebhookPayload { event_type, event_id, timestamp, body })
}
pub struct VerifiedWebhook(pub WebhookPayload);
#[async_trait]
impl<S> FromRequest<S> for VerifiedWebhook
where
S: Send + Sync,
{
type Rejection = WebhookRejection;
async fn from_request(req: Request, _state: &S) -> Result<Self, Self::Rejection> {
Ok(Self(extract_and_verify(req).await?))
}
}
pub struct TypedWebhook<T>(pub T);
#[async_trait]
impl<S, T> FromRequest<S> for TypedWebhook<T>
where
S: Send + Sync,
T: DeserializeOwned,
{
type Rejection = WebhookRejection;
async fn from_request(req: Request, _state: &S) -> Result<Self, Self::Rejection> {
let payload = extract_and_verify(req).await?;
let typed: T =
serde_json::from_value(payload.body).map_err(WebhookRejection::InvalidBody)?;
Ok(Self(typed))
}
}