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