1#![doc = include_str!("../README.md")]
17#![forbid(unsafe_code)]
18
19mod clock;
20mod deliver;
21mod store;
22
23pub use crate::deliver::{
24 DeliveryError, DrainReport, EVENT_ID_HEADER, EVENT_TYPE_HEADER, SIGNATURE_HEADER,
25};
26pub use crate::store::{DeadLetter, Delivery, Endpoint, TOPIC_DELIVER};
27use cratefield_core::{
28 AnyError, BoxFuture, Config, ConfigError, Database, DbError, IdGen, Migrations, Module,
29 ModuleConfig, ModuleContext, PersonalDataSet, Port, SqlMigration, UlidIdGen,
30};
31
32pub const MODULE_NAME: &str = "webhooks";
34
35const MIGRATION_INIT: SqlMigration = SqlMigration::new(
38 "0001",
39 "init",
40 include_str!("../migrations/sqlite/0001_init.sql"),
41);
42
43const MAX_ATTEMPTS_RANGE: std::ops::RangeInclusive<u32> = 1..=1_000;
46
47#[derive(Debug, Clone)]
49pub struct Webhooks {
50 max_attempts: u32,
53}
54
55impl Default for Webhooks {
56 fn default() -> Self {
57 Self::new()
58 }
59}
60
61impl Webhooks {
62 #[must_use]
64 pub fn new() -> Self {
65 Self { max_attempts: 5 }
66 }
67
68 #[must_use]
73 pub fn max_attempts(mut self, max_attempts: u32) -> Self {
74 self.max_attempts =
75 max_attempts.clamp(*MAX_ATTEMPTS_RANGE.start(), *MAX_ATTEMPTS_RANGE.end());
76 self
77 }
78
79 pub async fn create_endpoint(
94 &self,
95 db: &dyn Database,
96 subject: &str,
97 url: &str,
98 event_types: &[&str],
99 now: &str,
100 ) -> Result<CreatedEndpoint, EndpointError> {
101 if subject.trim().is_empty() || subject.len() > 255 {
102 return Err(EndpointError::InvalidSubject);
103 }
104 if !is_http_url(url) {
105 return Err(EndpointError::InvalidUrl(url.to_owned()));
106 }
107 for candidate in event_types {
108 let candidate = candidate.trim();
109 if !candidate.is_empty() && !is_valid_event_type(candidate) {
110 return Err(EndpointError::InvalidEventType(candidate.to_owned()));
111 }
112 }
113 let secret = generate_secret()?;
114 let id = UlidIdGen.ulid();
115 db.execute(&store::insert_endpoint_statement(
116 &id,
117 subject,
118 url,
119 &secret,
120 &normalize_event_types(event_types),
121 now,
122 ))
123 .await?;
124 Ok(CreatedEndpoint {
125 endpoint_id: id,
126 secret,
127 })
128 }
129
130 pub async fn endpoints(
137 &self,
138 db: &dyn Database,
139 subject: &str,
140 ) -> Result<Vec<Endpoint>, DbError> {
141 store::endpoints_for_subject(db, subject).await
142 }
143
144 pub async fn delete_endpoint(
152 &self,
153 db: &dyn Database,
154 subject: &str,
155 endpoint_id: &str,
156 ) -> Result<bool, DbError> {
157 store::delete_endpoint(db, subject, endpoint_id).await
158 }
159
160 pub async fn publish(
178 &self,
179 db: &dyn Database,
180 subject: &str,
181 event_type: &str,
182 data: &serde_json::Value,
183 now: &str,
184 ) -> Result<Published, PublishError> {
185 if !is_valid_event_type(event_type) {
186 return Err(PublishError::InvalidEventType(event_type.to_owned()));
187 }
188 let endpoints = store::endpoints_for_subject(db, subject).await?;
189 let event_id = UlidIdGen.ulid();
190 let mut statements = Vec::new();
191 for endpoint in &endpoints {
192 if !matches_filter(&endpoint.event_types, event_type) {
193 continue;
194 }
195 statements.push(store::enqueue_statement(
196 &store::DeliveryJob {
197 endpoint_id: endpoint.id.clone(),
198 event_id: event_id.clone(),
199 event_type: event_type.to_owned(),
200 subject: subject.to_owned(),
201 created_at: now.to_owned(),
202 data: data.clone(),
203 },
204 subject,
205 now,
206 ));
207 }
208 Ok(Published {
209 event_id,
210 endpoints: statements.len(),
211 statements,
212 })
213 }
214
215 pub async fn drain_with(&self, ctx: &ModuleContext) -> Result<DrainReport, DeliveryError> {
230 deliver::drain(ctx, self.max_attempts).await
231 }
232
233 pub async fn replay(
244 &self,
245 db: &dyn Database,
246 subject: &str,
247 dead_letter_id: &str,
248 now: &str,
249 ) -> Result<bool, DbError> {
250 store::replay(db, subject, dead_letter_id, now).await
251 }
252
253 pub async fn deliveries(
260 &self,
261 db: &dyn Database,
262 subject: &str,
263 limit: u64,
264 ) -> Result<Vec<Delivery>, DbError> {
265 store::deliveries(db, subject, limit).await
266 }
267
268 pub async fn dead_letters(
275 &self,
276 db: &dyn Database,
277 subject: &str,
278 limit: u64,
279 ) -> Result<Vec<DeadLetter>, DbError> {
280 store::dead_letters(db, subject, limit).await
281 }
282}
283
284impl Module for Webhooks {
285 fn name(&self) -> &'static str {
286 MODULE_NAME
287 }
288
289 fn version(&self) -> &'static str {
290 env!("CARGO_PKG_VERSION")
291 }
292
293 fn requires(&self) -> &'static [Port] {
294 &[Port::Db, Port::HttpClient, Port::Clock]
297 }
298
299 fn tables(&self) -> &'static [&'static str] {
300 &[
301 store::ENDPOINTS,
302 store::OUTBOX,
303 store::DELIVERIES,
304 store::DEAD_LETTERS,
305 ]
306 }
307
308 fn personal_data(&self) -> &'static [PersonalDataSet] {
312 const SETS: &[PersonalDataSet] = &[
313 PersonalDataSet {
314 table: store::ENDPOINTS,
315 subject: "subject",
316 kind: cratefield_core::DataKind::Identifier,
317 disposition: cratefield_core::Disposition::Erase,
318 description: "The webhook URLs this venture delivers your events to, one row \
319 each. Each row holds a signing secret, which is why it is \
320 redacted here; deleting your account deletes the endpoints and \
321 their secrets.",
322 redacted: &["secret"],
323 subject_via: None,
324 },
325 PersonalDataSet {
326 table: store::OUTBOX,
327 subject: "subject",
328 kind: cratefield_core::DataKind::Content,
329 disposition: cratefield_core::Disposition::Erase,
330 description: "Events queued for delivery to the endpoints above, with the \
331 account they are about. A delivery is erased with the account \
332 even if it has not been sent yet.",
333 redacted: &[],
334 subject_via: None,
335 },
336 PersonalDataSet {
337 table: store::DELIVERIES,
338 subject: "subject",
339 kind: cratefield_core::DataKind::Usage,
340 disposition: cratefield_core::Disposition::Erase,
341 description: "The log of delivery attempts: which endpoint, which event, what \
342 status code came back, and when. It shows which of your events \
343 left the venture, and nothing else.",
344 redacted: &[],
345 subject_via: None,
346 },
347 PersonalDataSet {
348 table: store::DEAD_LETTERS,
349 subject: "subject",
350 kind: cratefield_core::DataKind::Content,
351 disposition: cratefield_core::Disposition::Erase,
352 description: "Events that gave up after repeated failed deliveries, kept so \
353 they can be replayed. They carry the same data as the queued \
354 events above, and go when your account is deleted.",
355 redacted: &[],
356 subject_via: None,
357 },
358 ];
359 SETS
360 }
361
362 fn migrations(&self) -> Migrations {
363 const MIGRATIONS: [SqlMigration; 1] = [MIGRATION_INIT];
364 const _: () = cratefield_core::assert_migration_set(&MIGRATIONS);
367 Migrations {
368 sqlite: &MIGRATIONS,
369 postgres: &[],
370 }
371 }
372
373 fn validate_config(&self, cfg: &dyn Config) -> Result<(), ConfigError> {
374 let module = ModuleConfig::new(MODULE_NAME, cfg);
375 let mut errors = ConfigError::default();
376 if let Some(raw) = cfg
377 .get(&module.key("MAX_ATTEMPTS"))
378 .map(|raw| raw.parse::<u32>())
379 && !raw.is_ok_and(|value| MAX_ATTEMPTS_RANGE.contains(&value))
380 {
381 errors.push(format!(
382 "webhooks: {} must be a whole number between 1 and 1000",
383 module.key("MAX_ATTEMPTS")
384 ));
385 }
386 errors.into_result()
387 }
388
389 fn router(&self, _ctx: ModuleContext) -> axum::Router {
393 axum::Router::new()
394 }
395
396 fn scheduled<'a>(
400 &'a self,
401 ctx: &'a ModuleContext,
402 cron: &'a str,
403 ) -> BoxFuture<'a, Result<(), AnyError>> {
404 Box::pin(async move {
405 let report = self
406 .drain_with(ctx)
407 .await
408 .map_err(|err| Box::new(err) as AnyError)?;
409 if report.claimed > 0 {
410 tracing::info!(?report, cron, "drained the webhooks outbox");
411 }
412 Ok(())
413 })
414 }
415}
416
417#[derive(Clone, PartialEq, Eq)]
421pub struct CreatedEndpoint {
422 pub endpoint_id: String,
423 pub secret: String,
424}
425
426impl std::fmt::Debug for CreatedEndpoint {
430 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
431 f.debug_struct("CreatedEndpoint")
432 .field("endpoint_id", &self.endpoint_id)
433 .field("secret", &"[redacted]")
434 .finish()
435 }
436}
437
438#[derive(Debug, Clone, PartialEq, Eq)]
440pub enum EndpointError {
441 InvalidUrl(String),
444 InvalidSubject,
446 InvalidEventType(String),
450 Entropy(String),
453 Database(DbError),
455}
456
457impl std::fmt::Display for EndpointError {
458 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
459 match self {
460 EndpointError::InvalidUrl(url) => {
461 write!(f, "webhooks: not an http(s) URL: {url:?}")
462 }
463 EndpointError::InvalidSubject => {
464 f.write_str("webhooks: the subject must be 1..=255 bytes")
465 }
466 EndpointError::InvalidEventType(event_type) => {
467 write!(f, "webhooks: not a usable event type: {event_type:?}")
468 }
469 EndpointError::Entropy(error) => write!(f, "webhooks: {error}"),
470 EndpointError::Database(error) => write!(f, "webhooks: {error}"),
471 }
472 }
473}
474
475impl std::error::Error for EndpointError {}
476
477impl From<DbError> for EndpointError {
478 fn from(error: DbError) -> Self {
479 EndpointError::Database(error)
480 }
481}
482
483#[derive(Debug, Clone, PartialEq, Eq)]
485pub enum PublishError {
486 InvalidEventType(String),
491 Database(DbError),
493}
494
495impl std::fmt::Display for PublishError {
496 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
497 match self {
498 PublishError::InvalidEventType(event_type) => {
499 write!(f, "webhooks: not a usable event type: {event_type:?}")
500 }
501 PublishError::Database(error) => write!(f, "webhooks: {error}"),
502 }
503 }
504}
505
506impl std::error::Error for PublishError {}
507
508impl From<DbError> for PublishError {
509 fn from(error: DbError) -> Self {
510 PublishError::Database(error)
511 }
512}
513
514#[derive(Debug, Clone)]
518pub struct Published {
519 pub event_id: String,
523 pub endpoints: usize,
526 statements: Vec<cratefield_core::Statement>,
527}
528
529impl Published {
530 #[must_use]
533 pub fn into_statements(self) -> Vec<cratefield_core::Statement> {
534 self.statements
535 }
536}
537
538fn is_valid_event_type(event_type: &str) -> bool {
544 (1..=128).contains(&event_type.len()) && event_type.chars().all(|c| c.is_ascii_graphic())
545}
546
547#[must_use]
553pub fn matches_filter(filter: &str, event_type: &str) -> bool {
554 let filter = filter.trim();
555 filter == "*"
556 || filter
557 .split(',')
558 .map(str::trim)
559 .any(|candidate| candidate == event_type)
560}
561
562#[must_use]
565pub fn normalize_event_types(event_types: &[&str]) -> String {
566 let mut seen = Vec::new();
567 for candidate in event_types {
568 let candidate = candidate.trim();
569 if !candidate.is_empty() && !seen.contains(&candidate) {
570 seen.push(candidate);
571 }
572 }
573 if seen.is_empty() {
574 "*".to_owned()
575 } else {
576 seen.join(",")
577 }
578}
579
580fn is_http_url(url: &str) -> bool {
585 let max = 2048;
586 (url.starts_with("https://") || url.starts_with("http://"))
587 && url.len() <= max
588 && !url.contains(char::is_whitespace)
589 && match url.split_once("://") {
590 Some((_, rest)) => {
593 let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
594 !authority.is_empty()
595 }
596 None => false,
597 }
598}
599
600fn generate_secret() -> Result<String, EndpointError> {
605 let mut bytes = [0_u8; 32];
606 getrandom::fill(&mut bytes).map_err(|error| EndpointError::Entropy(error.to_string()))?;
607 let mut hex = String::with_capacity(2 * bytes.len());
608 for byte in bytes {
609 use std::fmt::Write as _;
611 let _ = write!(hex, "{byte:02x}");
612 }
613 Ok(format!("whsec_{hex}"))
614}
615
616#[cfg(test)]
617mod tests {
618 use super::*;
619
620 #[test]
621 fn defaults_are_the_documented_ones() {
622 let module = Webhooks::new();
623 assert_eq!(module.name(), "webhooks");
624 assert_eq!(module.version(), env!("CARGO_PKG_VERSION"));
625 assert_eq!(module.requires(), [Port::Db, Port::HttpClient, Port::Clock]);
626 assert_eq!(
627 module.tables(),
628 [
629 "webhooks_endpoints",
630 "webhooks_outbox",
631 "webhooks_deliveries",
632 "webhooks_dead_letters"
633 ]
634 );
635 assert_eq!(module.max_attempts, 5);
636 }
637
638 #[test]
639 fn max_attempts_stays_within_the_range_the_config_must_pass() {
640 assert_eq!(Webhooks::new().max_attempts(0).max_attempts, 1);
641 assert_eq!(Webhooks::new().max_attempts(2).max_attempts, 2);
642 assert_eq!(Webhooks::new().max_attempts(1_000).max_attempts, 1_000);
643 assert_eq!(Webhooks::new().max_attempts(5_000).max_attempts, 1_000);
644 }
645
646 #[test]
647 fn a_created_endpoints_debug_never_carries_the_secret() {
648 let created = CreatedEndpoint {
649 endpoint_id: "ep_01HXYZ".to_owned(),
650 secret: "whsec_hunter2".to_owned(),
651 };
652 let rendered = format!("{created:?}");
653 assert!(rendered.contains("ep_01HXYZ"), "{rendered}");
654 assert!(rendered.contains("[redacted]"), "{rendered}");
655 assert!(!rendered.contains("hunter2"), "{rendered}");
656 }
657
658 #[test]
659 fn event_types_must_be_header_safe_tokens() {
660 assert!(is_valid_event_type("order.paid"));
661 assert!(is_valid_event_type("order-paid_v2"));
662 assert!(!is_valid_event_type(""), "empty");
663 assert!(!is_valid_event_type(" order.paid"), "leading space");
664 assert!(!is_valid_event_type("order paid"), "inner space");
665 assert!(!is_valid_event_type("command\ninjection"), "control char");
666 assert!(!is_valid_event_type("héllo"), "non-ASCII");
667 assert!(is_valid_event_type(&"x".repeat(128)), "the max is allowed");
668 assert!(!is_valid_event_type(&"x".repeat(129)), "over the max");
669 }
670
671 #[test]
672 fn a_star_filter_matches_everything_and_a_list_matches_exactly() {
673 assert!(matches_filter("*", "order.paid"));
674 assert!(matches_filter("*", ""));
675 assert!(matches_filter("order.paid", "order.paid"));
676 assert!(matches_filter(
677 " order.paid , order.refunded ",
678 "order.refunded"
679 ));
680 assert!(!matches_filter("order.paid", "order.paid.v2"));
681 assert!(!matches_filter("order.paid", "order.refunded"));
682 assert!(
683 !matches_filter("", "order.paid"),
684 "empty stores as *, never as nothing"
685 );
686 }
687
688 #[test]
689 fn filters_normalise_to_a_stored_list_or_a_star() {
690 assert_eq!(
691 normalize_event_types(&["order.paid", "order.refunded"]),
692 "order.paid,order.refunded"
693 );
694 assert_eq!(normalize_event_types(&[" order.paid ", ""]), "order.paid");
695 assert_eq!(normalize_event_types(&["a", "a", "b"]), "a,b");
696 assert_eq!(normalize_event_types(&[]), "*");
697 assert_eq!(normalize_event_types(&["", " "]), "*");
698 }
699
700 #[test]
701 fn urls_must_be_http_s_with_nowhere_for_whitespace_to_hide() {
702 assert!(is_http_url("https://example.com/hook"));
703 assert!(is_http_url("http://example.com"));
704 assert!(!is_http_url("example.com/hook"));
705 assert!(!is_http_url("ftp://example.com"));
706 assert!(!is_http_url("https://"));
707 assert!(!is_http_url("http:///path"), "no host at all");
708 assert!(!is_http_url("https:///q?a=1"), "no host, only a query");
709 assert!(is_http_url("http://example.com?a=1"), "authority, no path");
710 assert!(!is_http_url("https://example.com/a b"));
711 assert!(!is_http_url(""));
712 assert!(!is_http_url("https://example.com/\n"));
713 }
714
715 #[test]
716 fn secrets_are_prefixed_hex_and_never_repeat() {
717 let a = generate_secret().expect("os randomness");
718 let b = generate_secret().expect("os randomness");
719 assert!(a.starts_with("whsec_"), "{a}");
720 let hex = a.strip_prefix("whsec_").expect("prefix");
721 assert_eq!(hex.len(), 64, "32 bytes in hex");
722 assert!(
723 hex.chars()
724 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
725 );
726 assert_ne!(a, b, "two secrets are two draws");
727 }
728
729 #[test]
730 fn a_bad_config_is_collected_not_thrown_one_at_a_time() {
731 let ok = MapConfig::from_pairs([("WEBHOOKS_MAX_ATTEMPTS", "3")]);
732 assert!(Webhooks::new().validate_config(&ok).is_ok());
733
734 let empty = MapConfig::from_pairs([("WEBHOOKS_MAX_ATTEMPTS", "")]);
735 let error = Webhooks::new()
736 .validate_config(&empty)
737 .expect_err("an unparsable attempt count is refused");
738 assert!(
739 error.to_string().contains("WEBHOOKS_MAX_ATTEMPTS"),
740 "{error}"
741 );
742
743 let zero = MapConfig::from_pairs([("WEBHOOKS_MAX_ATTEMPTS", "0")]);
744 assert!(Webhooks::new().validate_config(&zero).is_err());
745
746 let ceiling = MapConfig::from_pairs([("WEBHOOKS_MAX_ATTEMPTS", "1000")]);
748 assert!(Webhooks::new().validate_config(&ceiling).is_ok());
749 let past = MapConfig::from_pairs([("WEBHOOKS_MAX_ATTEMPTS", "1001")]);
750 assert!(Webhooks::new().validate_config(&past).is_err());
751 }
752
753 use cratefield_core::MapConfig;
754}