mod destination;
mod dispatch;
mod render;
mod sign;
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
};
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use sqlx::PgConnection;
use uuid::Uuid;
pub use destination::{Destination, EventKind, Kind, PREFIX};
pub use dispatch::{Dispatched, client, dispatch_due, run_dispatcher};
pub use sign::{hmac_sha256, signature};
use crate::{alerts::AlertRule, app::AppState, audit, calendar::WorkdayKind, error::ApiError, login::CurrentUser};
pub const PAYLOAD_VERSION: u32 = 1;
#[derive(Debug, Clone, Default)]
pub struct Webhooks {
destinations: Vec<Destination>,
public_url: Option<String>,
}
impl Webhooks {
pub fn from_vars(vars: impl IntoIterator<Item = (String, String)>, public_url: Option<String>) -> Result<Self, String> {
let mut destinations = vars
.into_iter()
.filter(|(key, _)| key.starts_with(PREFIX))
.map(|(key, value)| Destination::parse(&key, &value))
.collect::<Result<Vec<_>, _>>()?;
destinations.sort_by(|a, b| a.name.cmp(&b.name));
let public_url = match public_url.map(|url| url.trim().trim_end_matches('/').to_string()) {
Some(url) if url.is_empty() => None,
Some(url) if url.starts_with("https://") || url.starts_with("http://") => Some(url),
Some(_) => return Err("KASL_PUBLIC_URL is not an http(s) address".to_string()),
None => None,
};
Ok(Self { destinations, public_url })
}
pub fn new(destinations: Vec<Destination>, public_url: Option<&str>) -> Self {
let mut destinations = destinations;
destinations.sort_by(|a, b| a.name.cmp(&b.name));
Self {
destinations,
public_url: public_url.map(|url| url.trim_end_matches('/').to_string()),
}
}
pub fn destinations(&self) -> &[Destination] {
&self.destinations
}
pub fn get(&self, name: &str) -> Option<&Destination> {
self.destinations.iter().find(|destination| destination.name == name)
}
pub fn anyone_hears(&self, event: EventKind) -> bool {
self.destinations.iter().any(|destination| destination.hears(event))
}
fn link(&self, path: &str) -> Option<String> {
self.public_url.as_ref().map(|base| format!("{base}{path}"))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Event {
pub version: u32,
pub id: Uuid,
pub event: EventKind,
pub occurred_at: DateTime<Utc>,
pub server_version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub link: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub person: Option<Person>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub alert: Option<AlertPayload>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub day: Option<DayPayload>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub by: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, sqlx::FromRow)]
pub struct Person {
pub id: Uuid,
pub name: String,
pub department: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, sqlx::FromRow)]
pub struct AlertPayload {
pub id: Uuid,
pub rule: AlertRule,
pub observed_seconds: i64,
pub against_seconds: Option<i64>,
pub subject_date: Option<NaiveDate>,
pub fired_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DayPayload {
pub date: NaiveDate,
pub kind: WorkdayKind,
pub started_at: DateTime<Utc>,
pub ended_at: DateTime<Utc>,
pub worked_seconds: i64,
}
impl Event {
fn new(event: EventKind, id: Uuid, now: DateTime<Utc>) -> Self {
Self {
version: PAYLOAD_VERSION,
id,
event,
occurred_at: now,
server_version: env!("CARGO_PKG_VERSION").to_string(),
link: None,
person: None,
alert: None,
day: None,
by: None,
}
}
pub fn alert(event: EventKind, alert: AlertPayload, person: Person, by: Option<String>, webhooks: &Webhooks, now: DateTime<Utc>) -> Self {
Self {
link: webhooks.link(&format!("/team/{}", person.id)),
person: Some(person),
by,
..Self::new(event, derived_id(event, &[alert.id.as_bytes()]), now)
}
.with_alert(alert)
}
fn with_alert(mut self, alert: AlertPayload) -> Self {
self.alert = Some(alert);
self
}
pub fn day_closed(workday_id: Uuid, day: DayPayload, person: Person, webhooks: &Webhooks, now: DateTime<Utc>) -> Self {
let id = derived_id(EventKind::DayClosed, &[workday_id.as_bytes(), day.ended_at.to_rfc3339().as_bytes()]);
Self {
link: webhooks.link(&format!("/team/{}", person.id)),
person: Some(person),
day: Some(day),
..Self::new(EventKind::DayClosed, id, now)
}
}
pub fn test(webhooks: &Webhooks, now: DateTime<Utc>) -> Self {
Self {
link: webhooks.link("/"),
..Self::new(EventKind::Test, Uuid::new_v4(), now)
}
}
}
fn derived_id(event: EventKind, parts: &[&[u8]]) -> Uuid {
let mut hash = Sha256::new();
hash.update(event.name().as_bytes());
for part in parts {
hash.update([0u8]);
hash.update(part);
}
let digest = hash.finalize();
let mut bytes = [0u8; 16];
bytes.copy_from_slice(&digest[..16]);
uuid::Builder::from_custom_bytes(bytes).into_uuid()
}
pub async fn person(conn: &mut PgConnection, user_id: Uuid) -> Result<Person, ApiError> {
Ok(sqlx::query_as(
"SELECT u.id, u.display_name AS name, d.name AS department
FROM users u LEFT JOIN departments d ON d.id = u.department_id
WHERE u.id = $1",
)
.bind(user_id)
.fetch_one(conn)
.await?)
}
pub async fn enqueue(conn: &mut PgConnection, webhooks: &Webhooks, event: &Event) -> Result<u64, ApiError> {
let department = event.person.as_ref().and_then(|person| person.department.as_deref());
let mut queued = 0;
for destination in &webhooks.destinations {
if !destination.hears(event.event) {
continue;
}
if let Some(wanted) = &destination.department
&& !department.is_some_and(|actual| actual.eq_ignore_ascii_case(wanted))
{
continue;
}
queued += enqueue_to(conn, destination, event).await?;
}
Ok(queued)
}
async fn enqueue_to(conn: &mut PgConnection, destination: &Destination, event: &Event) -> Result<u64, ApiError> {
let written = sqlx::query(
"INSERT INTO webhook_deliveries (event_id, destination, event, user_id, payload, created_at, next_attempt_at)
VALUES ($1, $2, $3, $4, $5, $6, $6)
ON CONFLICT (event_id, destination) DO NOTHING",
)
.bind(event.id)
.bind(&destination.name)
.bind(event.event)
.bind(event.person.as_ref().map(|person| person.id))
.bind(sqlx::types::Json(event))
.bind(event.occurred_at)
.execute(conn)
.await?;
Ok(written.rows_affected())
}
#[derive(Debug, Serialize)]
pub struct DestinationView {
pub name: String,
pub kind: Kind,
pub target: String,
pub events: Vec<EventKind>,
pub department: Option<String>,
pub department_exists: Option<bool>,
pub pending: i64,
pub delivered: i64,
pub abandoned: i64,
pub last_delivered_at: Option<DateTime<Utc>>,
pub last_error: Option<String>,
pub last_error_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct DeliveryView {
pub id: Uuid,
pub event_id: Uuid,
pub destination: String,
pub event: EventKind,
pub person: Option<String>,
pub created_at: DateTime<Utc>,
pub attempts: i32,
pub next_attempt_at: DateTime<Utc>,
pub delivered_at: Option<DateTime<Utc>>,
pub abandoned_at: Option<DateTime<Utc>>,
pub last_status: Option<i32>,
pub last_error: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct Overview {
pub destinations: Vec<DestinationView>,
pub recent: Vec<DeliveryView>,
pub links: bool,
}
#[derive(Debug, sqlx::FromRow)]
struct Counts {
destination: String,
pending: i64,
delivered: i64,
abandoned: i64,
last_delivered_at: Option<DateTime<Utc>>,
}
pub async fn overview(State(state): State<AppState>, user: CurrentUser) -> Result<impl IntoResponse, ApiError> {
user.require_admin()?;
let counts: Vec<Counts> = sqlx::query_as(
"SELECT destination,
count(*) FILTER (WHERE delivered_at IS NULL AND abandoned_at IS NULL) AS pending,
count(*) FILTER (WHERE delivered_at IS NOT NULL) AS delivered,
count(*) FILTER (WHERE abandoned_at IS NOT NULL) AS abandoned,
max(delivered_at) AS last_delivered_at
FROM webhook_deliveries GROUP BY destination",
)
.fetch_all(&state.pool)
.await?;
let failures: Vec<(String, String, DateTime<Utc>)> = sqlx::query_as(
"SELECT DISTINCT ON (destination) destination, last_error, coalesce(abandoned_at, next_attempt_at)
FROM webhook_deliveries
WHERE last_error IS NOT NULL AND delivered_at IS NULL
ORDER BY destination, created_at DESC",
)
.fetch_all(&state.pool)
.await?;
let departments: Vec<String> = sqlx::query_scalar("SELECT name FROM departments").fetch_all(&state.pool).await?;
let destinations = state
.webhooks
.destinations
.iter()
.map(|destination| {
let count = counts.iter().find(|count| count.destination == destination.name);
let failure = failures.iter().find(|(name, ..)| *name == destination.name);
DestinationView {
name: destination.name.clone(),
kind: destination.kind,
target: destination.shown_target(),
events: destination.events.clone(),
department: destination.department.clone(),
department_exists: destination
.department
.as_ref()
.map(|wanted| departments.iter().any(|name| name.eq_ignore_ascii_case(wanted))),
pending: count.map_or(0, |count| count.pending),
delivered: count.map_or(0, |count| count.delivered),
abandoned: count.map_or(0, |count| count.abandoned),
last_delivered_at: count.and_then(|count| count.last_delivered_at),
last_error: failure.map(|(_, error, _)| error.clone()),
last_error_at: failure.map(|(.., at)| *at),
}
})
.collect();
let recent: Vec<DeliveryView> = sqlx::query_as(
"SELECT w.id, w.event_id, w.destination, w.event, u.display_name AS person, w.created_at, w.attempts,
w.next_attempt_at, w.delivered_at, w.abandoned_at, w.last_status, w.last_error
FROM webhook_deliveries w LEFT JOIN users u ON u.id = w.user_id
ORDER BY w.created_at DESC, w.id
LIMIT 50",
)
.fetch_all(&state.pool)
.await?;
Ok(Json(Overview {
destinations,
recent,
links: state.webhooks.public_url.is_some(),
}))
}
pub async fn send_test(State(state): State<AppState>, user: CurrentUser, Path(name): Path<String>) -> Result<impl IntoResponse, ApiError> {
user.require_admin()?;
let Some(destination) = state.webhooks.get(&name) else {
return Err(ApiError::new(StatusCode::NOT_FOUND, format!("no destination named `{name}` is configured")));
};
let event = Event::test(&state.webhooks, Utc::now());
let mut conn = state.pool.acquire().await?;
enqueue_to(&mut conn, destination, &event).await?;
drop(conn);
audit::Entry::new(audit::action::WEBHOOK_TESTED)
.by(user.user_id)
.by_email(&user.email)
.with(serde_json::json!({ "destination": destination.name, "event_id": event.id }))
.record(&state.pool)
.await;
Ok((
StatusCode::ACCEPTED,
Json(serde_json::json!({ "event_id": event.id, "destination": destination.name })),
))
}
#[cfg(test)]
mod tests {
use super::*;
fn slack(name: &str, options: &str) -> Destination {
Destination::parse(name, &format!("slack https://hooks.slack.com/services/T/B/X {options}")).unwrap()
}
#[test]
fn every_webhook_variable_is_read_and_nothing_else() {
let webhooks = Webhooks::from_vars(
[
("KASL_WEBHOOK_ZED".to_string(), "slack https://hooks.slack.com/z".to_string()),
("KASL_WEBHOOK_ALPHA".to_string(), "slack https://hooks.slack.com/a".to_string()),
("KASL_AGENTS".to_string(), "a@b.c:token".to_string()),
("PATH".to_string(), "/usr/bin".to_string()),
],
Some("https://kasl.example.com/".to_string()),
)
.unwrap();
let names: Vec<&str> = webhooks.destinations().iter().map(|d| d.name.as_str()).collect();
assert_eq!(names, ["alpha", "zed"], "sorted, and only the webhook variables");
assert_eq!(webhooks.link("/team/1").as_deref(), Some("https://kasl.example.com/team/1"));
}
#[test]
fn one_bad_destination_stops_the_start() {
let error = Webhooks::from_vars(
[
("KASL_WEBHOOK_GOOD".to_string(), "slack https://hooks.slack.com/a".to_string()),
("KASL_WEBHOOK_BAD".to_string(), "slak https://hooks.slack.com/b".to_string()),
],
None,
)
.unwrap_err();
assert!(error.contains("KASL_WEBHOOK_BAD"), "{error}");
let error = Webhooks::from_vars([], Some("kasl.example.com".to_string())).unwrap_err();
assert!(error.contains("KASL_PUBLIC_URL"), "{error}");
}
#[test]
fn the_same_fact_gets_the_same_id() {
let alert = Uuid::new_v4();
assert_eq!(
derived_id(EventKind::AlertRaised, &[alert.as_bytes()]),
derived_id(EventKind::AlertRaised, &[alert.as_bytes()])
);
assert_ne!(
derived_id(EventKind::AlertRaised, &[alert.as_bytes()]),
derived_id(EventKind::AlertResolved, &[alert.as_bytes()]),
"raising and resolving one alert are two events",
);
}
#[test]
fn anyone_hears_follows_the_subscriptions() {
let webhooks = Webhooks::new(vec![slack("KASL_WEBHOOK_A", "events=day.closed")], None);
assert!(webhooks.anyone_hears(EventKind::DayClosed));
assert!(!webhooks.anyone_hears(EventKind::AlertRaised));
assert!(!Webhooks::default().anyone_hears(EventKind::AlertRaised));
}
}