pretix_webhook/
builder.rs1use std::{
2 collections::BTreeSet,
3 fmt::{Debug, Display, Formatter},
4};
5
6use base64::{Engine as _, engine::general_purpose::STANDARD};
7use http::{HeaderMap, header};
8use pretix_webhook_events::WebhookEvent;
9use sha2::{Digest, Sha256};
10use subtle::{Choice, ConstantTimeEq};
11
12use crate::service::{DEFAULT_BODY_LIMIT, WebhookService};
13
14#[derive(Clone)]
16pub struct BasicAuthCredential {
17 digest: [u8; 32],
18}
19
20impl BasicAuthCredential {
21 #[must_use]
28 pub fn new(username: impl AsRef<str>, password: impl AsRef<str>) -> Self {
29 let mut hasher = Sha256::new();
30 hasher.update(username.as_ref().as_bytes());
31 hasher.update(b":");
32 hasher.update(password.as_ref().as_bytes());
33 Self {
34 digest: hasher.finalize().into(),
35 }
36 }
37}
38
39impl Debug for BasicAuthCredential {
40 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
41 formatter.write_str("BasicAuthCredential(REDACTED)")
42 }
43}
44
45#[derive(Clone)]
47pub struct WebhookServiceBuilder {
48 organizers: BTreeSet<String>,
49 events: BTreeSet<String>,
50 credentials: Vec<BasicAuthCredential>,
51 body_limit: usize,
52}
53
54impl Debug for WebhookServiceBuilder {
61 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
62 formatter
63 .debug_struct("WebhookServiceBuilder")
64 .field("organizers", &Redacted(self.organizers.len()))
65 .field("events", &Redacted(self.events.len()))
66 .field("credentials", &Redacted(self.credentials.len()))
67 .field("body_limit", &self.body_limit)
68 .finish()
69 }
70}
71
72struct Redacted(usize);
73
74impl Debug for Redacted {
75 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
76 write!(formatter, "<{} REDACTED>", self.0)
77 }
78}
79
80#[derive(Clone, Debug, Eq, PartialEq)]
85pub struct WebhookFilterError {
86 message: String,
87}
88
89impl Display for WebhookFilterError {
90 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
91 formatter.write_str(&self.message)
92 }
93}
94
95impl std::error::Error for WebhookFilterError {}
96
97impl WebhookServiceBuilder {
98 #[must_use]
100 pub fn new() -> Self {
101 Self::default()
102 }
103
104 pub fn allow_organizer(
111 mut self,
112 organizer: impl Into<String>,
113 ) -> Result<Self, WebhookFilterError> {
114 self.organizers
115 .insert(validate_filter("organizer", organizer.into())?);
116 Ok(self)
117 }
118
119 pub fn allow_event(mut self, event: impl Into<String>) -> Result<Self, WebhookFilterError> {
126 self.events.insert(validate_filter("event", event.into())?);
127 Ok(self)
128 }
129
130 #[must_use]
134 pub fn require_basic_auth(
135 mut self,
136 credentials: impl IntoIterator<Item = BasicAuthCredential>,
137 ) -> Self {
138 self.credentials = credentials.into_iter().collect();
139 self
140 }
141
142 #[must_use]
147 pub fn body_limit(mut self, body_limit: usize) -> Self {
148 self.body_limit = body_limit;
149 self
150 }
151
152 pub fn build<H>(self, handler: H) -> WebhookService<H> {
154 WebhookService::new(handler, self)
155 }
156
157 pub(super) fn allows(&self, event: &WebhookEvent) -> bool {
158 (self.organizers.is_empty()
159 || event
160 .organizer_slug()
161 .is_some_and(|organizer| self.organizers.contains(organizer)))
162 && (self.events.is_empty()
163 || !event.is_event_level()
164 || event
165 .event_slug()
166 .is_some_and(|event| self.events.contains(event)))
167 }
168
169 pub(super) fn authenticates(&self, headers: &HeaderMap) -> bool {
170 if self.credentials.is_empty() {
171 return true;
172 }
173
174 let Some(encoded) = headers
175 .get(header::AUTHORIZATION)
176 .and_then(|value| value.to_str().ok())
177 .and_then(|value| value.split_once(' '))
178 .filter(|(scheme, _)| scheme.eq_ignore_ascii_case("basic"))
179 .map(|(_, encoded)| encoded)
180 else {
181 return false;
182 };
183 let Ok(presented) = STANDARD.decode(encoded) else {
184 return false;
185 };
186 let digest: [u8; 32] = Sha256::digest(presented).into();
187
188 bool::from(
189 self.credentials
190 .iter()
191 .fold(Choice::from(0), |matched, credential| {
192 matched | credential.digest.ct_eq(&digest)
193 }),
194 )
195 }
196
197 pub(super) fn body_limit_bytes(&self) -> usize {
198 self.body_limit
199 }
200}
201
202impl Default for WebhookServiceBuilder {
203 fn default() -> Self {
204 Self {
205 organizers: BTreeSet::new(),
206 events: BTreeSet::new(),
207 credentials: Vec::new(),
208 body_limit: DEFAULT_BODY_LIMIT,
209 }
210 }
211}
212
213fn validate_filter(kind: &str, value: String) -> Result<String, WebhookFilterError> {
214 if value.is_empty() {
215 return Err(WebhookFilterError {
216 message: format!("invalid {kind} slug: it must not be empty"),
217 });
218 }
219
220 if value.trim() != value {
221 return Err(WebhookFilterError {
222 message: format!(
223 "invalid {kind} slug: leading and trailing whitespace are not allowed"
224 ),
225 });
226 }
227
228 Ok(value)
229}