use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use super::server::AppState;
#[derive(serde::Deserialize)]
pub(crate) struct WebhookPayload {
pub channel: String,
pub sender: String,
pub body: String,
}
impl WebhookPayload {
pub(crate) fn validate(&self) -> Result<(), &'static str> {
if self.sender.len() > 256 {
return Err("sender exceeds 256 bytes");
}
if self.channel.len() > 256 {
return Err("channel exceeds 256 bytes");
}
if self.body.len() > 65536 {
return Err("body exceeds 65536 bytes");
}
Ok(())
}
}
#[derive(serde::Serialize)]
struct WebhookResponse {
status: &'static str,
}
#[derive(serde::Serialize)]
struct HealthResponse {
status: &'static str,
uptime_secs: u64,
}
pub(crate) async fn webhook_handler(
State(state): State<AppState>,
Json(payload): Json<WebhookPayload>,
) -> impl IntoResponse {
if let Err(e) = payload.validate() {
return (StatusCode::UNPROCESSABLE_ENTITY, e).into_response();
}
let sender = zeph_common::sanitize::strip_control_chars_preserve_whitespace(&payload.sender);
let channel = zeph_common::sanitize::strip_control_chars_preserve_whitespace(&payload.channel);
let msg = format!("[{}@{}] {}", sender, channel, payload.body);
match state.webhook_tx.send(msg).await {
Ok(()) => Json(WebhookResponse { status: "accepted" }).into_response(),
Err(_) => StatusCode::SERVICE_UNAVAILABLE.into_response(),
}
}
pub(crate) async fn health_handler(State(state): State<AppState>) -> impl IntoResponse {
Json(HealthResponse {
status: "ok",
uptime_secs: state.started_at.elapsed().as_secs(),
})
}
#[cfg(feature = "prometheus")]
pub(crate) async fn metrics_handler(
axum::extract::State(registry): axum::extract::State<
std::sync::Arc<prometheus_client::registry::Registry>,
>,
) -> impl axum::response::IntoResponse {
let mut buf = String::new();
match prometheus_client::encoding::text::encode(&mut buf, ®istry) {
Ok(()) => (
[(
axum::http::header::CONTENT_TYPE,
"application/openmetrics-text; version=1.0.0; charset=utf-8",
)],
buf,
)
.into_response(),
Err(e) => {
tracing::error!("failed to encode prometheus metrics: {e}");
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
"metrics encoding failed",
)
.into_response()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn health_response_serializes() {
let resp = HealthResponse {
status: "ok",
uptime_secs: 42,
};
let json = serde_json::to_string(&resp).unwrap();
assert!(json.contains("\"status\":\"ok\""));
}
#[test]
fn webhook_payload_deserializes() {
let json = r#"{"channel":"discord","sender":"user1","body":"hello"}"#;
let payload: WebhookPayload = serde_json::from_str(json).unwrap();
assert_eq!(payload.channel, "discord");
assert_eq!(payload.sender, "user1");
assert_eq!(payload.body, "hello");
}
#[test]
fn validate_accepts_valid_payload() {
let payload = WebhookPayload {
channel: "ch".into(),
sender: "user".into(),
body: "hello".into(),
};
assert!(payload.validate().is_ok());
}
#[test]
fn validate_rejects_oversized_sender() {
let payload = WebhookPayload {
channel: "ch".into(),
sender: "a".repeat(257),
body: "hello".into(),
};
assert!(payload.validate().is_err());
}
#[test]
fn validate_rejects_oversized_channel() {
let payload = WebhookPayload {
channel: "c".repeat(257),
sender: "user".into(),
body: "hello".into(),
};
assert!(payload.validate().is_err());
}
#[test]
fn validate_rejects_oversized_body() {
let payload = WebhookPayload {
channel: "ch".into(),
sender: "user".into(),
body: "b".repeat(65537),
};
assert!(payload.validate().is_err());
}
#[test]
fn sanitize_strips_control_chars_keeps_newline() {
let input = "hel\x01lo\x7f\nworld";
let result = zeph_common::sanitize::strip_control_chars_preserve_whitespace(input);
assert_eq!(result, "hello\nworld");
}
#[test]
fn sanitize_strips_null_byte() {
let input = "he\x00llo";
let result = zeph_common::sanitize::strip_control_chars_preserve_whitespace(input);
assert_eq!(result, "hello");
}
#[test]
fn validate_accepts_at_limit_sender() {
let payload = WebhookPayload {
channel: "ch".into(),
sender: "a".repeat(256),
body: "hello".into(),
};
assert!(payload.validate().is_ok());
}
#[test]
fn validate_accepts_at_limit_channel() {
let payload = WebhookPayload {
channel: "c".repeat(256),
sender: "user".into(),
body: "hello".into(),
};
assert!(payload.validate().is_ok());
}
#[test]
fn validate_accepts_at_limit_body() {
let payload = WebhookPayload {
channel: "ch".into(),
sender: "user".into(),
body: "b".repeat(65536),
};
assert!(payload.validate().is_ok());
}
}