1mod destination;
29mod dispatch;
30mod render;
31mod sign;
32
33use axum::{
34 Json,
35 extract::{Path, State},
36 http::StatusCode,
37 response::IntoResponse,
38};
39use chrono::{DateTime, NaiveDate, Utc};
40use serde::{Deserialize, Serialize};
41use sha2::{Digest, Sha256};
42use sqlx::PgConnection;
43use uuid::Uuid;
44
45pub use destination::{Destination, EventKind, Kind, PREFIX};
46pub use dispatch::{Dispatched, client, dispatch_due, run_dispatcher};
47pub use sign::{hmac_sha256, signature};
48
49use crate::{alerts::AlertRule, app::AppState, audit, calendar::WorkdayKind, error::ApiError, login::CurrentUser};
50
51pub const PAYLOAD_VERSION: u32 = 1;
54
55#[derive(Debug, Clone, Default)]
57pub struct Webhooks {
58 destinations: Vec<Destination>,
61 public_url: Option<String>,
65}
66
67impl Webhooks {
68 pub fn from_vars(vars: impl IntoIterator<Item = (String, String)>, public_url: Option<String>) -> Result<Self, String> {
73 let mut destinations = vars
74 .into_iter()
75 .filter(|(key, _)| key.starts_with(PREFIX))
76 .map(|(key, value)| Destination::parse(&key, &value))
77 .collect::<Result<Vec<_>, _>>()?;
78 destinations.sort_by(|a, b| a.name.cmp(&b.name));
79
80 let public_url = match public_url.map(|url| url.trim().trim_end_matches('/').to_string()) {
81 Some(url) if url.is_empty() => None,
82 Some(url) if url.starts_with("https://") || url.starts_with("http://") => Some(url),
83 Some(_) => return Err("KASL_PUBLIC_URL is not an http(s) address".to_string()),
84 None => None,
85 };
86
87 Ok(Self { destinations, public_url })
88 }
89
90 pub fn new(destinations: Vec<Destination>, public_url: Option<&str>) -> Self {
92 let mut destinations = destinations;
93 destinations.sort_by(|a, b| a.name.cmp(&b.name));
94 Self {
95 destinations,
96 public_url: public_url.map(|url| url.trim_end_matches('/').to_string()),
97 }
98 }
99
100 pub fn destinations(&self) -> &[Destination] {
101 &self.destinations
102 }
103
104 pub fn get(&self, name: &str) -> Option<&Destination> {
105 self.destinations.iter().find(|destination| destination.name == name)
106 }
107
108 pub fn anyone_hears(&self, event: EventKind) -> bool {
111 self.destinations.iter().any(|destination| destination.hears(event))
112 }
113
114 fn link(&self, path: &str) -> Option<String> {
115 self.public_url.as_ref().map(|base| format!("{base}{path}"))
116 }
117}
118
119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
127pub struct Event {
128 pub version: u32,
129 pub id: Uuid,
132 pub event: EventKind,
133 pub occurred_at: DateTime<Utc>,
134 pub server_version: String,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub link: Option<String>,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub person: Option<Person>,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub alert: Option<AlertPayload>,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub day: Option<DayPayload>,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub by: Option<String>,
152}
153
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, sqlx::FromRow)]
156pub struct Person {
157 pub id: Uuid,
158 pub name: String,
159 pub department: Option<String>,
160}
161
162#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, sqlx::FromRow)]
166pub struct AlertPayload {
167 pub id: Uuid,
168 pub rule: AlertRule,
169 pub observed_seconds: i64,
170 pub against_seconds: Option<i64>,
171 pub subject_date: Option<NaiveDate>,
172 pub fired_at: DateTime<Utc>,
173}
174
175#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
177pub struct DayPayload {
178 pub date: NaiveDate,
179 pub kind: WorkdayKind,
180 pub started_at: DateTime<Utc>,
181 pub ended_at: DateTime<Utc>,
182 pub worked_seconds: i64,
184}
185
186impl Event {
187 fn new(event: EventKind, id: Uuid, now: DateTime<Utc>) -> Self {
188 Self {
189 version: PAYLOAD_VERSION,
190 id,
191 event,
192 occurred_at: now,
193 server_version: env!("CARGO_PKG_VERSION").to_string(),
194 link: None,
195 person: None,
196 alert: None,
197 day: None,
198 by: None,
199 }
200 }
201
202 pub fn alert(event: EventKind, alert: AlertPayload, person: Person, by: Option<String>, webhooks: &Webhooks, now: DateTime<Utc>) -> Self {
204 Self {
205 link: webhooks.link(&format!("/team/{}", person.id)),
209 person: Some(person),
210 by,
211 ..Self::new(event, derived_id(event, &[alert.id.as_bytes()]), now)
212 }
213 .with_alert(alert)
214 }
215
216 fn with_alert(mut self, alert: AlertPayload) -> Self {
217 self.alert = Some(alert);
218 self
219 }
220
221 pub fn day_closed(workday_id: Uuid, day: DayPayload, person: Person, webhooks: &Webhooks, now: DateTime<Utc>) -> Self {
223 let id = derived_id(EventKind::DayClosed, &[workday_id.as_bytes(), day.ended_at.to_rfc3339().as_bytes()]);
226 Self {
227 link: webhooks.link(&format!("/team/{}", person.id)),
228 person: Some(person),
229 day: Some(day),
230 ..Self::new(EventKind::DayClosed, id, now)
231 }
232 }
233
234 pub fn test(webhooks: &Webhooks, now: DateTime<Utc>) -> Self {
236 Self {
237 link: webhooks.link("/"),
238 ..Self::new(EventKind::Test, Uuid::new_v4(), now)
239 }
240 }
241}
242
243fn derived_id(event: EventKind, parts: &[&[u8]]) -> Uuid {
249 let mut hash = Sha256::new();
250 hash.update(event.name().as_bytes());
251 for part in parts {
252 hash.update([0u8]);
253 hash.update(part);
254 }
255 let digest = hash.finalize();
256 let mut bytes = [0u8; 16];
257 bytes.copy_from_slice(&digest[..16]);
258 uuid::Builder::from_custom_bytes(bytes).into_uuid()
259}
260
261pub async fn person(conn: &mut PgConnection, user_id: Uuid) -> Result<Person, ApiError> {
263 Ok(sqlx::query_as(
264 "SELECT u.id, u.display_name AS name, d.name AS department
265 FROM users u LEFT JOIN departments d ON d.id = u.department_id
266 WHERE u.id = $1",
267 )
268 .bind(user_id)
269 .fetch_one(conn)
270 .await?)
271}
272
273pub async fn enqueue(conn: &mut PgConnection, webhooks: &Webhooks, event: &Event) -> Result<u64, ApiError> {
279 let department = event.person.as_ref().and_then(|person| person.department.as_deref());
280 let mut queued = 0;
281 for destination in &webhooks.destinations {
282 if !destination.hears(event.event) {
283 continue;
284 }
285 if let Some(wanted) = &destination.department
289 && !department.is_some_and(|actual| actual.eq_ignore_ascii_case(wanted))
290 {
291 continue;
292 }
293 queued += enqueue_to(conn, destination, event).await?;
294 }
295 Ok(queued)
296}
297
298async fn enqueue_to(conn: &mut PgConnection, destination: &Destination, event: &Event) -> Result<u64, ApiError> {
300 let written = sqlx::query(
301 "INSERT INTO webhook_deliveries (event_id, destination, event, user_id, payload, created_at, next_attempt_at)
302 VALUES ($1, $2, $3, $4, $5, $6, $6)
303 ON CONFLICT (event_id, destination) DO NOTHING",
304 )
305 .bind(event.id)
306 .bind(&destination.name)
307 .bind(event.event)
308 .bind(event.person.as_ref().map(|person| person.id))
309 .bind(sqlx::types::Json(event))
310 .bind(event.occurred_at)
311 .execute(conn)
312 .await?;
313 Ok(written.rows_affected())
314}
315
316#[derive(Debug, Serialize)]
320pub struct DestinationView {
321 pub name: String,
322 pub kind: Kind,
323 pub target: String,
325 pub events: Vec<EventKind>,
326 pub department: Option<String>,
327 pub department_exists: Option<bool>,
331 pub pending: i64,
332 pub delivered: i64,
333 pub abandoned: i64,
334 pub last_delivered_at: Option<DateTime<Utc>>,
335 pub last_error: Option<String>,
336 pub last_error_at: Option<DateTime<Utc>>,
337}
338
339#[derive(Debug, Serialize, sqlx::FromRow)]
341pub struct DeliveryView {
342 pub id: Uuid,
343 pub event_id: Uuid,
344 pub destination: String,
345 pub event: EventKind,
346 pub person: Option<String>,
347 pub created_at: DateTime<Utc>,
348 pub attempts: i32,
349 pub next_attempt_at: DateTime<Utc>,
350 pub delivered_at: Option<DateTime<Utc>>,
351 pub abandoned_at: Option<DateTime<Utc>>,
352 pub last_status: Option<i32>,
353 pub last_error: Option<String>,
354}
355
356#[derive(Debug, Serialize)]
358pub struct Overview {
359 pub destinations: Vec<DestinationView>,
360 pub recent: Vec<DeliveryView>,
361 pub links: bool,
364}
365
366#[derive(Debug, sqlx::FromRow)]
367struct Counts {
368 destination: String,
369 pending: i64,
370 delivered: i64,
371 abandoned: i64,
372 last_delivered_at: Option<DateTime<Utc>>,
373}
374
375pub async fn overview(State(state): State<AppState>, user: CurrentUser) -> Result<impl IntoResponse, ApiError> {
378 user.require_admin()?;
379
380 let counts: Vec<Counts> = sqlx::query_as(
381 "SELECT destination,
382 count(*) FILTER (WHERE delivered_at IS NULL AND abandoned_at IS NULL) AS pending,
383 count(*) FILTER (WHERE delivered_at IS NOT NULL) AS delivered,
384 count(*) FILTER (WHERE abandoned_at IS NOT NULL) AS abandoned,
385 max(delivered_at) AS last_delivered_at
386 FROM webhook_deliveries GROUP BY destination",
387 )
388 .fetch_all(&state.pool)
389 .await?;
390
391 let failures: Vec<(String, String, DateTime<Utc>)> = sqlx::query_as(
392 "SELECT DISTINCT ON (destination) destination, last_error, coalesce(abandoned_at, next_attempt_at)
393 FROM webhook_deliveries
394 WHERE last_error IS NOT NULL AND delivered_at IS NULL
395 ORDER BY destination, created_at DESC",
396 )
397 .fetch_all(&state.pool)
398 .await?;
399
400 let departments: Vec<String> = sqlx::query_scalar("SELECT name FROM departments").fetch_all(&state.pool).await?;
401
402 let destinations = state
403 .webhooks
404 .destinations
405 .iter()
406 .map(|destination| {
407 let count = counts.iter().find(|count| count.destination == destination.name);
408 let failure = failures.iter().find(|(name, ..)| *name == destination.name);
409 DestinationView {
410 name: destination.name.clone(),
411 kind: destination.kind,
412 target: destination.shown_target(),
413 events: destination.events.clone(),
414 department: destination.department.clone(),
415 department_exists: destination
416 .department
417 .as_ref()
418 .map(|wanted| departments.iter().any(|name| name.eq_ignore_ascii_case(wanted))),
419 pending: count.map_or(0, |count| count.pending),
420 delivered: count.map_or(0, |count| count.delivered),
421 abandoned: count.map_or(0, |count| count.abandoned),
422 last_delivered_at: count.and_then(|count| count.last_delivered_at),
423 last_error: failure.map(|(_, error, _)| error.clone()),
424 last_error_at: failure.map(|(.., at)| *at),
425 }
426 })
427 .collect();
428
429 let recent: Vec<DeliveryView> = sqlx::query_as(
430 "SELECT w.id, w.event_id, w.destination, w.event, u.display_name AS person, w.created_at, w.attempts,
431 w.next_attempt_at, w.delivered_at, w.abandoned_at, w.last_status, w.last_error
432 FROM webhook_deliveries w LEFT JOIN users u ON u.id = w.user_id
433 ORDER BY w.created_at DESC, w.id
434 LIMIT 50",
435 )
436 .fetch_all(&state.pool)
437 .await?;
438
439 Ok(Json(Overview {
440 destinations,
441 recent,
442 links: state.webhooks.public_url.is_some(),
443 }))
444}
445
446pub async fn send_test(State(state): State<AppState>, user: CurrentUser, Path(name): Path<String>) -> Result<impl IntoResponse, ApiError> {
452 user.require_admin()?;
453
454 let Some(destination) = state.webhooks.get(&name) else {
455 return Err(ApiError::new(StatusCode::NOT_FOUND, format!("no destination named `{name}` is configured")));
456 };
457
458 let event = Event::test(&state.webhooks, Utc::now());
459 let mut conn = state.pool.acquire().await?;
460 enqueue_to(&mut conn, destination, &event).await?;
461 drop(conn);
462
463 audit::Entry::new(audit::action::WEBHOOK_TESTED)
466 .by(user.user_id)
467 .by_email(&user.email)
468 .with(serde_json::json!({ "destination": destination.name, "event_id": event.id }))
469 .record(&state.pool)
470 .await;
471
472 Ok((
473 StatusCode::ACCEPTED,
474 Json(serde_json::json!({ "event_id": event.id, "destination": destination.name })),
475 ))
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481
482 fn slack(name: &str, options: &str) -> Destination {
483 Destination::parse(name, &format!("slack https://hooks.slack.com/services/T/B/X {options}")).unwrap()
484 }
485
486 #[test]
487 fn every_webhook_variable_is_read_and_nothing_else() {
488 let webhooks = Webhooks::from_vars(
489 [
490 ("KASL_WEBHOOK_ZED".to_string(), "slack https://hooks.slack.com/z".to_string()),
491 ("KASL_WEBHOOK_ALPHA".to_string(), "slack https://hooks.slack.com/a".to_string()),
492 ("KASL_AGENTS".to_string(), "a@b.c:token".to_string()),
493 ("PATH".to_string(), "/usr/bin".to_string()),
494 ],
495 Some("https://kasl.example.com/".to_string()),
496 )
497 .unwrap();
498 let names: Vec<&str> = webhooks.destinations().iter().map(|d| d.name.as_str()).collect();
499 assert_eq!(names, ["alpha", "zed"], "sorted, and only the webhook variables");
500 assert_eq!(webhooks.link("/team/1").as_deref(), Some("https://kasl.example.com/team/1"));
501 }
502
503 #[test]
504 fn one_bad_destination_stops_the_start() {
505 let error = Webhooks::from_vars(
506 [
507 ("KASL_WEBHOOK_GOOD".to_string(), "slack https://hooks.slack.com/a".to_string()),
508 ("KASL_WEBHOOK_BAD".to_string(), "slak https://hooks.slack.com/b".to_string()),
509 ],
510 None,
511 )
512 .unwrap_err();
513 assert!(error.contains("KASL_WEBHOOK_BAD"), "{error}");
514
515 let error = Webhooks::from_vars([], Some("kasl.example.com".to_string())).unwrap_err();
516 assert!(error.contains("KASL_PUBLIC_URL"), "{error}");
517 }
518
519 #[test]
520 fn the_same_fact_gets_the_same_id() {
521 let alert = Uuid::new_v4();
522 assert_eq!(
523 derived_id(EventKind::AlertRaised, &[alert.as_bytes()]),
524 derived_id(EventKind::AlertRaised, &[alert.as_bytes()])
525 );
526 assert_ne!(
527 derived_id(EventKind::AlertRaised, &[alert.as_bytes()]),
528 derived_id(EventKind::AlertResolved, &[alert.as_bytes()]),
529 "raising and resolving one alert are two events",
530 );
531 }
532
533 #[test]
534 fn anyone_hears_follows_the_subscriptions() {
535 let webhooks = Webhooks::new(vec![slack("KASL_WEBHOOK_A", "events=day.closed")], None);
536 assert!(webhooks.anyone_hears(EventKind::DayClosed));
537 assert!(!webhooks.anyone_hears(EventKind::AlertRaised));
538 assert!(!Webhooks::default().anyone_hears(EventKind::AlertRaised));
539 }
540}