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};
31use http::header::CONTENT_TYPE;
32
33pub const MODULE_NAME: &str = "webhooks";
35
36const MIGRATION_INIT: SqlMigration = SqlMigration::new(
39 "0001",
40 "init",
41 include_str!("../migrations/sqlite/0001_init.sql"),
42);
43
44const MAX_ATTEMPTS_RANGE: std::ops::RangeInclusive<u32> = 1..=1_000;
47
48#[derive(Debug, Clone)]
50pub struct Webhooks {
51 max_attempts: u32,
54 signature_alias: Option<String>,
57}
58
59impl Default for Webhooks {
60 fn default() -> Self {
61 Self::new()
62 }
63}
64
65impl Webhooks {
66 #[must_use]
68 pub fn new() -> Self {
69 Self {
70 max_attempts: 5,
71 signature_alias: None,
72 }
73 }
74
75 pub fn from_config(cfg: &dyn Config) -> Result<Self, ConfigError> {
87 match signature_alias(cfg) {
88 Ok(signature_alias) => Ok(Self {
89 signature_alias,
90 ..Self::new()
91 }),
92 Err(problem) => {
93 let mut errors = ConfigError::default();
94 errors.push(problem);
95 Err(errors)
96 }
97 }
98 }
99
100 #[must_use]
104 pub fn signature_headers(&self) -> Vec<&str> {
105 let mut names = vec![SIGNATURE_HEADER];
106 names.extend(self.signature_alias.as_deref());
107 names
108 }
109
110 #[must_use]
115 pub fn max_attempts(mut self, max_attempts: u32) -> Self {
116 self.max_attempts =
117 max_attempts.clamp(*MAX_ATTEMPTS_RANGE.start(), *MAX_ATTEMPTS_RANGE.end());
118 self
119 }
120
121 pub async fn create_endpoint(
136 &self,
137 db: &dyn Database,
138 subject: &str,
139 url: &str,
140 event_types: &[&str],
141 now: &str,
142 ) -> Result<CreatedEndpoint, EndpointError> {
143 if subject.trim().is_empty() || subject.len() > 255 {
144 return Err(EndpointError::InvalidSubject);
145 }
146 if !is_http_url(url) {
147 return Err(EndpointError::InvalidUrl(url.to_owned()));
148 }
149 for candidate in event_types {
150 let candidate = candidate.trim();
151 if !candidate.is_empty() && !is_valid_event_type(candidate) {
152 return Err(EndpointError::InvalidEventType(candidate.to_owned()));
153 }
154 }
155 let secret = generate_secret()?;
156 let id = UlidIdGen.ulid();
157 db.execute(&store::insert_endpoint_statement(
158 &id,
159 subject,
160 url,
161 &secret,
162 &normalize_event_types(event_types),
163 now,
164 ))
165 .await?;
166 Ok(CreatedEndpoint {
167 endpoint_id: id,
168 secret,
169 })
170 }
171
172 pub async fn endpoints(
179 &self,
180 db: &dyn Database,
181 subject: &str,
182 ) -> Result<Vec<Endpoint>, DbError> {
183 store::endpoints_for_subject(db, subject).await
184 }
185
186 pub async fn delete_endpoint(
194 &self,
195 db: &dyn Database,
196 subject: &str,
197 endpoint_id: &str,
198 ) -> Result<bool, DbError> {
199 store::delete_endpoint(db, subject, endpoint_id).await
200 }
201
202 pub async fn publish(
220 &self,
221 db: &dyn Database,
222 subject: &str,
223 event_type: &str,
224 data: &serde_json::Value,
225 now: &str,
226 ) -> Result<Published, PublishError> {
227 if !is_valid_event_type(event_type) {
228 return Err(PublishError::InvalidEventType(event_type.to_owned()));
229 }
230 let endpoints = store::endpoints_for_subject(db, subject).await?;
231 let event_id = UlidIdGen.ulid();
232 let mut statements = Vec::new();
233 for endpoint in &endpoints {
234 if !matches_filter(&endpoint.event_types, event_type) {
235 continue;
236 }
237 statements.push(store::enqueue_statement(
238 &store::DeliveryJob {
239 endpoint_id: endpoint.id.clone(),
240 event_id: event_id.clone(),
241 event_type: event_type.to_owned(),
242 subject: subject.to_owned(),
243 created_at: now.to_owned(),
244 data: data.clone(),
245 },
246 subject,
247 now,
248 ));
249 }
250 Ok(Published {
251 event_id,
252 endpoints: statements.len(),
253 statements,
254 })
255 }
256
257 pub async fn drain_with(&self, ctx: &ModuleContext) -> Result<DrainReport, DeliveryError> {
280 deliver::drain(ctx, self.max_attempts).await
281 }
282
283 pub async fn replay(
294 &self,
295 db: &dyn Database,
296 subject: &str,
297 dead_letter_id: &str,
298 now: &str,
299 ) -> Result<bool, DbError> {
300 store::replay(db, subject, dead_letter_id, now).await
301 }
302
303 pub async fn deliveries(
310 &self,
311 db: &dyn Database,
312 subject: &str,
313 limit: u64,
314 ) -> Result<Vec<Delivery>, DbError> {
315 store::deliveries(db, subject, limit).await
316 }
317
318 pub async fn dead_letters(
325 &self,
326 db: &dyn Database,
327 subject: &str,
328 limit: u64,
329 ) -> Result<Vec<DeadLetter>, DbError> {
330 store::dead_letters(db, subject, limit).await
331 }
332}
333
334impl Module for Webhooks {
335 fn name(&self) -> &'static str {
336 MODULE_NAME
337 }
338
339 fn version(&self) -> &'static str {
340 env!("CARGO_PKG_VERSION")
341 }
342
343 fn requires(&self) -> &'static [Port] {
344 &[Port::Db, Port::HttpClient, Port::Clock]
347 }
348
349 fn tables(&self) -> &'static [&'static str] {
350 &[
351 store::ENDPOINTS,
352 store::OUTBOX,
353 store::DELIVERIES,
354 store::DEAD_LETTERS,
355 ]
356 }
357
358 fn personal_data(&self) -> &'static [PersonalDataSet] {
362 const SETS: &[PersonalDataSet] = &[
363 PersonalDataSet {
364 table: store::ENDPOINTS,
365 subject: "subject",
366 kind: cratefield_core::DataKind::Identifier,
367 disposition: cratefield_core::Disposition::Erase,
368 description: "The webhook URLs this venture delivers your events to, one row \
369 each. Each row holds a signing secret, which is why it is \
370 redacted here; deleting your account deletes the endpoints and \
371 their secrets.",
372 redacted: &["secret"],
373 subject_via: None,
374 },
375 PersonalDataSet {
376 table: store::OUTBOX,
377 subject: "subject",
378 kind: cratefield_core::DataKind::Content,
379 disposition: cratefield_core::Disposition::Erase,
380 description: "Events queued for delivery to the endpoints above, with the \
381 account they are about. A delivery is erased with the account \
382 even if it has not been sent yet.",
383 redacted: &[],
384 subject_via: None,
385 },
386 PersonalDataSet {
387 table: store::DELIVERIES,
388 subject: "subject",
389 kind: cratefield_core::DataKind::Usage,
390 disposition: cratefield_core::Disposition::Erase,
391 description: "The log of delivery attempts: which endpoint, which event, what \
392 status code came back, and when. It shows which of your events \
393 left the venture, and nothing else.",
394 redacted: &[],
395 subject_via: None,
396 },
397 PersonalDataSet {
398 table: store::DEAD_LETTERS,
399 subject: "subject",
400 kind: cratefield_core::DataKind::Content,
401 disposition: cratefield_core::Disposition::Erase,
402 description: "Events that gave up after repeated failed deliveries, kept so \
403 they can be replayed. They carry the same data as the queued \
404 events above, and go when your account is deleted.",
405 redacted: &[],
406 subject_via: None,
407 },
408 ];
409 SETS
410 }
411
412 fn migrations(&self) -> Migrations {
413 const MIGRATIONS: [SqlMigration; 1] = [MIGRATION_INIT];
414 const _: () = cratefield_core::assert_migration_set(&MIGRATIONS);
417 Migrations {
418 sqlite: &MIGRATIONS,
419 postgres: &[],
420 }
421 }
422
423 fn validate_config(&self, cfg: &dyn Config) -> Result<(), ConfigError> {
424 let module = ModuleConfig::new(MODULE_NAME, cfg);
425 let mut errors = ConfigError::default();
426 if let Some(raw) = cfg
427 .get(&module.key("MAX_ATTEMPTS"))
428 .map(|raw| raw.parse::<u32>())
429 && !raw.is_ok_and(|value| MAX_ATTEMPTS_RANGE.contains(&value))
430 {
431 errors.push(format!(
432 "webhooks: {} must be a whole number between 1 and 1000",
433 module.key("MAX_ATTEMPTS")
434 ));
435 }
436 if let Err(problem) = signature_alias(cfg) {
437 errors.push(problem);
438 }
439 errors.into_result()
440 }
441
442 fn router(&self, _ctx: ModuleContext) -> axum::Router {
446 axum::Router::new()
447 }
448
449 fn scheduled<'a>(
455 &'a self,
456 ctx: &'a ModuleContext,
457 cron: &'a str,
458 ) -> BoxFuture<'a, Result<(), AnyError>> {
459 Box::pin(async move {
460 let report = self
461 .drain_with(ctx)
462 .await
463 .map_err(|err| Box::new(err) as AnyError)?;
464 if report.claimed > 0 || report.deferred > 0 {
467 tracing::info!(?report, cron, "drained the webhooks outbox");
468 }
469 Ok(())
470 })
471 }
472}
473
474#[derive(Clone, PartialEq, Eq)]
478pub struct CreatedEndpoint {
479 pub endpoint_id: String,
480 pub secret: String,
481}
482
483impl std::fmt::Debug for CreatedEndpoint {
487 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
488 f.debug_struct("CreatedEndpoint")
489 .field("endpoint_id", &self.endpoint_id)
490 .field("secret", &"[redacted]")
491 .finish()
492 }
493}
494
495#[derive(Debug, Clone, PartialEq, Eq)]
497pub enum EndpointError {
498 InvalidUrl(String),
501 InvalidSubject,
503 InvalidEventType(String),
507 Entropy(String),
510 Database(DbError),
512}
513
514impl std::fmt::Display for EndpointError {
515 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
516 match self {
517 EndpointError::InvalidUrl(url) => {
518 write!(f, "webhooks: not an http(s) URL: {url:?}")
519 }
520 EndpointError::InvalidSubject => {
521 f.write_str("webhooks: the subject must be 1..=255 bytes")
522 }
523 EndpointError::InvalidEventType(event_type) => {
524 write!(f, "webhooks: not a usable event type: {event_type:?}")
525 }
526 EndpointError::Entropy(error) => write!(f, "webhooks: {error}"),
527 EndpointError::Database(error) => write!(f, "webhooks: {error}"),
528 }
529 }
530}
531
532impl std::error::Error for EndpointError {}
533
534impl From<DbError> for EndpointError {
535 fn from(error: DbError) -> Self {
536 EndpointError::Database(error)
537 }
538}
539
540#[derive(Debug, Clone, PartialEq, Eq)]
542pub enum PublishError {
543 InvalidEventType(String),
548 Database(DbError),
550}
551
552impl std::fmt::Display for PublishError {
553 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
554 match self {
555 PublishError::InvalidEventType(event_type) => {
556 write!(f, "webhooks: not a usable event type: {event_type:?}")
557 }
558 PublishError::Database(error) => write!(f, "webhooks: {error}"),
559 }
560 }
561}
562
563impl std::error::Error for PublishError {}
564
565impl From<DbError> for PublishError {
566 fn from(error: DbError) -> Self {
567 PublishError::Database(error)
568 }
569}
570
571#[derive(Debug, Clone)]
575pub struct Published {
576 pub event_id: String,
580 pub endpoints: usize,
583 statements: Vec<cratefield_core::Statement>,
584}
585
586impl Published {
587 #[must_use]
590 pub fn into_statements(self) -> Vec<cratefield_core::Statement> {
591 self.statements
592 }
593}
594
595fn is_valid_event_type(event_type: &str) -> bool {
601 (1..=128).contains(&event_type.len()) && event_type.chars().all(|c| c.is_ascii_graphic())
602}
603
604pub(crate) fn signature_alias(cfg: &dyn Config) -> Result<Option<String>, String> {
608 let key = ModuleConfig::new(MODULE_NAME, cfg).key("SIGNATURE_ALIAS");
609 let Some(raw) = cfg.get(&key).filter(|raw| !raw.is_empty()) else {
610 return Ok(None);
611 };
612 let reserved = [
613 CONTENT_TYPE.as_str(),
614 SIGNATURE_HEADER,
615 EVENT_ID_HEADER,
616 EVENT_TYPE_HEADER,
617 "host",
618 "content-length",
619 "transfer-encoding",
620 "connection",
621 "te",
622 "trailer",
623 "upgrade",
624 "keep-alive",
625 "proxy-authorization",
626 "proxy-connection",
627 ];
628 let usable = http::HeaderName::from_bytes(raw.as_bytes()).is_ok_and(|name| {
629 !reserved
630 .iter()
631 .any(|sent| sent.eq_ignore_ascii_case(name.as_str()))
632 });
633 if usable {
634 Ok(Some(raw))
635 } else {
636 Err(format!(
637 "webhooks: {key} must be an HTTP header name the module does not already send"
638 ))
639 }
640}
641
642#[must_use]
648pub fn matches_filter(filter: &str, event_type: &str) -> bool {
649 let filter = filter.trim();
650 filter == "*"
651 || filter
652 .split(',')
653 .map(str::trim)
654 .any(|candidate| candidate == event_type)
655}
656
657#[must_use]
660pub fn normalize_event_types(event_types: &[&str]) -> String {
661 let mut seen = Vec::new();
662 for candidate in event_types {
663 let candidate = candidate.trim();
664 if !candidate.is_empty() && !seen.contains(&candidate) {
665 seen.push(candidate);
666 }
667 }
668 if seen.is_empty() {
669 "*".to_owned()
670 } else {
671 seen.join(",")
672 }
673}
674
675fn is_http_url(url: &str) -> bool {
680 let max = 2048;
681 (url.starts_with("https://") || url.starts_with("http://"))
682 && url.len() <= max
683 && !url.contains(char::is_whitespace)
684 && match url.split_once("://") {
685 Some((_, rest)) => {
688 let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
689 !authority.is_empty()
690 }
691 None => false,
692 }
693}
694
695fn generate_secret() -> Result<String, EndpointError> {
700 let mut bytes = [0_u8; 32];
701 getrandom::fill(&mut bytes).map_err(|error| EndpointError::Entropy(error.to_string()))?;
702 let mut hex = String::with_capacity(2 * bytes.len());
703 for byte in bytes {
704 use std::fmt::Write as _;
706 let _ = write!(hex, "{byte:02x}");
707 }
708 Ok(format!("whsec_{hex}"))
709}
710
711#[cfg(test)]
712mod tests {
713 use super::*;
714
715 #[test]
716 fn defaults_are_the_documented_ones() {
717 let module = Webhooks::new();
718 assert_eq!(module.name(), "webhooks");
719 assert_eq!(module.version(), env!("CARGO_PKG_VERSION"));
720 assert_eq!(module.requires(), [Port::Db, Port::HttpClient, Port::Clock]);
721 assert_eq!(
722 module.tables(),
723 [
724 "webhooks_endpoints",
725 "webhooks_outbox",
726 "webhooks_deliveries",
727 "webhooks_dead_letters"
728 ]
729 );
730 assert_eq!(module.max_attempts, 5);
731 }
732
733 #[test]
734 fn max_attempts_stays_within_the_range_the_config_must_pass() {
735 assert_eq!(Webhooks::new().max_attempts(0).max_attempts, 1);
736 assert_eq!(Webhooks::new().max_attempts(2).max_attempts, 2);
737 assert_eq!(Webhooks::new().max_attempts(1_000).max_attempts, 1_000);
738 assert_eq!(Webhooks::new().max_attempts(5_000).max_attempts, 1_000);
739 }
740
741 #[test]
742 fn a_created_endpoints_debug_never_carries_the_secret() {
743 let created = CreatedEndpoint {
744 endpoint_id: "ep_01HXYZ".to_owned(),
745 secret: "whsec_hunter2".to_owned(),
746 };
747 let rendered = format!("{created:?}");
748 assert!(rendered.contains("ep_01HXYZ"), "{rendered}");
749 assert!(rendered.contains("[redacted]"), "{rendered}");
750 assert!(!rendered.contains("hunter2"), "{rendered}");
751 }
752
753 #[test]
754 fn event_types_must_be_header_safe_tokens() {
755 assert!(is_valid_event_type("order.paid"));
756 assert!(is_valid_event_type("order-paid_v2"));
757 assert!(!is_valid_event_type(""), "empty");
758 assert!(!is_valid_event_type(" order.paid"), "leading space");
759 assert!(!is_valid_event_type("order paid"), "inner space");
760 assert!(!is_valid_event_type("command\ninjection"), "control char");
761 assert!(!is_valid_event_type("héllo"), "non-ASCII");
762 assert!(is_valid_event_type(&"x".repeat(128)), "the max is allowed");
763 assert!(!is_valid_event_type(&"x".repeat(129)), "over the max");
764 }
765
766 #[test]
767 fn a_star_filter_matches_everything_and_a_list_matches_exactly() {
768 assert!(matches_filter("*", "order.paid"));
769 assert!(matches_filter("*", ""));
770 assert!(matches_filter("order.paid", "order.paid"));
771 assert!(matches_filter(
772 " order.paid , order.refunded ",
773 "order.refunded"
774 ));
775 assert!(!matches_filter("order.paid", "order.paid.v2"));
776 assert!(!matches_filter("order.paid", "order.refunded"));
777 assert!(
778 !matches_filter("", "order.paid"),
779 "empty stores as *, never as nothing"
780 );
781 }
782
783 #[test]
784 fn filters_normalise_to_a_stored_list_or_a_star() {
785 assert_eq!(
786 normalize_event_types(&["order.paid", "order.refunded"]),
787 "order.paid,order.refunded"
788 );
789 assert_eq!(normalize_event_types(&[" order.paid ", ""]), "order.paid");
790 assert_eq!(normalize_event_types(&["a", "a", "b"]), "a,b");
791 assert_eq!(normalize_event_types(&[]), "*");
792 assert_eq!(normalize_event_types(&["", " "]), "*");
793 }
794
795 #[test]
796 fn urls_must_be_http_s_with_nowhere_for_whitespace_to_hide() {
797 assert!(is_http_url("https://example.com/hook"));
798 assert!(is_http_url("http://example.com"));
799 assert!(!is_http_url("example.com/hook"));
800 assert!(!is_http_url("ftp://example.com"));
801 assert!(!is_http_url("https://"));
802 assert!(!is_http_url("http:///path"), "no host at all");
803 assert!(!is_http_url("https:///q?a=1"), "no host, only a query");
804 assert!(is_http_url("http://example.com?a=1"), "authority, no path");
805 assert!(!is_http_url("https://example.com/a b"));
806 assert!(!is_http_url(""));
807 assert!(!is_http_url("https://example.com/\n"));
808 }
809
810 #[test]
811 fn secrets_are_prefixed_hex_and_never_repeat() {
812 let a = generate_secret().expect("os randomness");
813 let b = generate_secret().expect("os randomness");
814 assert!(a.starts_with("whsec_"), "{a}");
815 let hex = a.strip_prefix("whsec_").expect("prefix");
816 assert_eq!(hex.len(), 64, "32 bytes in hex");
817 assert!(
818 hex.chars()
819 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
820 );
821 assert_ne!(a, b, "two secrets are two draws");
822 }
823
824 #[test]
825 fn a_bad_config_is_collected_not_thrown_one_at_a_time() {
826 let ok = MapConfig::from_pairs([("WEBHOOKS_MAX_ATTEMPTS", "3")]);
827 assert!(Webhooks::new().validate_config(&ok).is_ok());
828
829 let empty = MapConfig::from_pairs([("WEBHOOKS_MAX_ATTEMPTS", "")]);
830 let error = Webhooks::new()
831 .validate_config(&empty)
832 .expect_err("an unparsable attempt count is refused");
833 assert!(
834 error.to_string().contains("WEBHOOKS_MAX_ATTEMPTS"),
835 "{error}"
836 );
837
838 let zero = MapConfig::from_pairs([("WEBHOOKS_MAX_ATTEMPTS", "0")]);
839 assert!(Webhooks::new().validate_config(&zero).is_err());
840
841 let ceiling = MapConfig::from_pairs([("WEBHOOKS_MAX_ATTEMPTS", "1000")]);
843 assert!(Webhooks::new().validate_config(&ceiling).is_ok());
844 let past = MapConfig::from_pairs([("WEBHOOKS_MAX_ATTEMPTS", "1001")]);
845 assert!(Webhooks::new().validate_config(&past).is_err());
846 }
847
848 use cratefield_core::MapConfig;
849}