1pub mod service;
5
6use crate::{ImageId, OwnerId, Secret};
7use clap::ValueEnum;
8use getrandom::getrandom;
9use hmac::{Hmac, Mac};
10use serde::{Deserialize, Serialize};
11use sha2::Sha512;
12use std::{
13 collections::BTreeSet,
14 fmt::{Display, Error as FmtError, Formatter},
15 str::FromStr,
16 time::SystemTime,
17};
18use time::OffsetDateTime;
19use url::Url;
20use uuid::Uuid;
21
22pub const DIGEST_HEADER: &str = "x-freta-digest";
24
25#[derive(Serialize, Deserialize, Debug, Clone, Copy, Eq, PartialEq, Hash)]
27pub struct WebhookId(Uuid);
28
29impl WebhookId {
30 #[must_use]
32 pub fn new() -> Self {
33 Self(Uuid::new_v4())
34 }
35}
36
37impl Default for WebhookId {
38 fn default() -> Self {
39 Self::new()
40 }
41}
42
43impl Display for WebhookId {
44 fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), FmtError> {
45 write!(f, "{}", self.0)
46 }
47}
48
49impl FromStr for WebhookId {
50 type Err = uuid::Error;
51
52 fn from_str(uuid_str: &str) -> Result<Self, Self::Err> {
53 Uuid::parse_str(uuid_str).map(Self)
54 }
55}
56
57#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
59#[derive(Serialize, Deserialize, Debug, Clone, Copy, Eq, PartialEq)]
60pub struct WebhookEventId(Uuid);
61
62impl WebhookEventId {
63 #[must_use]
65 pub fn new() -> Self {
66 Self(new_uuid_v7())
67 }
68}
69
70impl Default for WebhookEventId {
71 fn default() -> Self {
72 Self::new()
73 }
74}
75
76impl Display for WebhookEventId {
77 fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), FmtError> {
78 write!(f, "{}", self.0)
79 }
80}
81
82impl FromStr for WebhookEventId {
83 type Err = uuid::Error;
84
85 fn from_str(uuid_str: &str) -> Result<Self, Self::Err> {
86 Uuid::parse_str(uuid_str).map(Self)
87 }
88}
89
90#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
92#[derive(Debug, Serialize, Deserialize, Clone, ValueEnum, Ord, Eq, PartialEq, PartialOrd)]
93#[serde(rename_all = "snake_case")]
94#[value(rename_all = "snake_case")]
95pub enum WebhookEventType {
96 #[clap(skip)]
97 Ping,
99 ImageCreated,
101 ImageDeleted,
103 ImageAnalysisCompleted,
105 ImageAnalysisFailed,
107 ImageStateUpdated,
109}
110
111#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
116#[derive(Debug, Serialize, Deserialize, Clone)]
117pub struct WebhookEvent {
118 pub event_id: WebhookEventId,
120
121 pub event_type: WebhookEventType,
123
124 #[serde(with = "time::serde::rfc3339")]
126 #[cfg_attr(feature = "schema", schemars(with = "String"))]
127 pub timestamp: OffsetDateTime,
128
129 #[serde(skip_serializing_if = "Option::is_none")]
131 pub image: Option<ImageId>,
132}
133
134impl WebhookEvent {
135 #[must_use]
137 pub fn new(
138 event_type: WebhookEventType,
139 timestamp: OffsetDateTime,
140 image: Option<ImageId>,
141 ) -> Self {
142 Self {
143 event_id: WebhookEventId::new(),
144 event_type,
145 timestamp,
146 image,
147 }
148 }
149}
150
151#[derive(thiserror::Error, Debug)]
153pub enum HmacError {
154 #[error("invalid hmac token")]
156 InvalidHmacToken,
157
158 #[error("serialization error")]
160 Serialization(#[from] serde_json::Error),
161}
162
163impl WebhookEvent {
164 pub fn hmac_sha512(&self, hmac_token: &Secret) -> Result<String, HmacError> {
169 let event_as_bytes = serde_json::to_string(&self)?.as_bytes().to_vec();
170 hmac_sha512(&event_as_bytes, hmac_token)
171 }
172}
173
174pub fn hmac_sha512(bytes: &[u8], hmac_token: &Secret) -> Result<String, HmacError> {
179 let mut mac = Hmac::<Sha512>::new_from_slice(hmac_token.get_secret().as_bytes())
180 .map_err(|_| HmacError::InvalidHmacToken)?;
181 mac.update(bytes);
182 let result = mac.finalize().into_bytes();
183 let hmac_as_string = result
184 .iter()
185 .map(|b| format!("{b:02x}"))
186 .collect::<String>();
187 Ok(hmac_as_string)
188}
189
190#[derive(Debug, Serialize, Deserialize, Clone)]
195pub enum WebhookEventState {
196 Pending,
198 Success,
200 Failure,
203}
204
205#[derive(Debug, Serialize, Deserialize, Clone)]
207pub struct Webhook {
208 #[serde(
210 rename(deserialize = "Timestamp"),
211 alias = "last_updated",
212 skip_serializing_if = "Option::is_none",
213 default,
214 with = "time::serde::rfc3339::option"
215 )]
216 pub last_updated: Option<OffsetDateTime>,
217
218 #[serde(rename(deserialize = "PartitionKey"), alias = "owner_id")]
220 pub owner_id: OwnerId,
221
222 #[serde(rename(deserialize = "RowKey"), alias = "webhook_id")]
224 pub webhook_id: WebhookId,
225
226 pub url: Url,
228
229 pub event_types: BTreeSet<WebhookEventType>,
231
232 pub hmac_token: Option<Secret>,
235}
236
237impl Webhook {
238 #[must_use]
240 pub fn new(
241 owner_id: OwnerId,
242 url: Url,
243 event_types: BTreeSet<WebhookEventType>,
244 hmac_token: Option<Secret>,
245 ) -> Self {
246 Self {
247 last_updated: None,
248 owner_id,
249 webhook_id: WebhookId::new(),
250 url,
251 event_types,
252 hmac_token,
253 }
254 }
255}
256
257#[derive(Debug, Serialize, Deserialize, Clone)]
259pub struct WebhookLog {
260 #[serde(
262 rename(deserialize = "Timestamp"),
263 alias = "last_updated",
264 skip_serializing_if = "Option::is_none",
265 default,
266 with = "time::serde::rfc3339::option"
267 )]
268 pub last_updated: Option<OffsetDateTime>,
269
270 #[serde(rename(deserialize = "PartitionKey"), alias = "webhook_id")]
272 pub webhook_id: WebhookId,
273
274 #[serde(rename(deserialize = "RowKey"), alias = "event_id")]
276 pub event_id: WebhookEventId,
277
278 pub event: WebhookEvent,
280
281 pub state: WebhookEventState,
283
284 #[serde(skip_serializing_if = "Option::is_none")]
286 pub error: Option<String>,
287}
288
289impl WebhookLog {
290 #[must_use]
292 pub fn new(
293 webhook_id: WebhookId,
294 event_type: WebhookEventType,
295 timestamp: OffsetDateTime,
296 image_id: Option<ImageId>,
297 ) -> Self {
298 let event = WebhookEvent::new(event_type, timestamp, image_id);
299 Self {
300 last_updated: None,
301 webhook_id,
302 event_id: event.event_id,
303 event,
304 state: WebhookEventState::Pending,
305 error: None,
306 }
307 }
308}
309
310#[allow(clippy::expect_used, clippy::cast_possible_truncation)]
329fn new_uuid_v7() -> Uuid {
330 let now = SystemTime::UNIX_EPOCH
331 .elapsed()
332 .expect("getting elapsed time since UNIX_EPOCH should not fail")
333 .as_millis() as u64;
334 let mut random_bytes = [0_u8; 10];
335 getrandom(&mut random_bytes).expect("getting random value failed");
336 fmt_uuid_v7(now, random_bytes)
337}
338
339const fn fmt_uuid_v7(millis: u64, random_bytes: [u8; 10]) -> Uuid {
360 let millis_low = (millis & 0xFFFF) as u16;
362 let millis_high = ((millis >> 16) & 0xFFFF_FFFF) as u32;
364
365 let random_and_version =
366 (random_bytes[0] as u16 | ((random_bytes[1] as u16) << 8) & 0x0FFF) | (0x7 << 12);
367
368 let mut d4 = [0; 8];
369
370 d4[0] = (random_bytes[2] & 0x3F) | 0x80;
371 d4[1] = random_bytes[3];
372 d4[2] = random_bytes[4];
373 d4[3] = random_bytes[5];
374 d4[4] = random_bytes[6];
375 d4[5] = random_bytes[7];
376 d4[6] = random_bytes[8];
377 d4[7] = random_bytes[9];
378
379 Uuid::from_fields(millis_high, millis_low, random_and_version, &d4)
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387 use std::{thread::sleep, time::Duration};
388
389 type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
390
391 #[test]
392 fn test_uuid_v7_format() {
393 let examples = vec![
394 fmt_uuid_v7(0, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]),
395 fmt_uuid_v7(1_673_483_814 * 1000, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]),
396 fmt_uuid_v7(
397 1_673_483_814 * 1000,
398 [11, 12, 13, 14, 15, 16, 17, 18, 19, 20],
399 ),
400 fmt_uuid_v7(
401 1_673_483_815 * 1000,
402 [11, 12, 13, 14, 15, 16, 17, 18, 19, 20],
403 ),
404 ];
405
406 insta::assert_json_snapshot!(examples);
407 }
408
409 #[test]
410 fn test_lexicographical_sorting() {
415 let two_millis = Duration::from_millis(2);
416 let mut uuids = vec![];
417
418 for _ in 0..100 {
419 uuids.push(new_uuid_v7().to_string());
420 sleep(two_millis);
424 }
425
426 let mut sorted = uuids.clone();
427 sorted.sort();
428
429 assert_eq!(
430 uuids, sorted,
431 "UUIDv7 should be lexicographically sorted during generation"
432 );
433 }
434
435 #[test]
436 fn test_hmac() -> Result<()> {
437 let event = WebhookEvent {
438 event_id: WebhookEventId(Uuid::from_u128(1)),
439 event_type: WebhookEventType::ImageCreated,
440 timestamp: OffsetDateTime::UNIX_EPOCH,
441 image: Some(Uuid::from_u128(0).into()),
442 };
443
444 let hmac = event.hmac_sha512(&Secret::new("testing"))?;
445 insta::assert_json_snapshot!(hmac);
446 let event_as_string = serde_json::to_string(&event)?;
447 insta::assert_json_snapshot!(event_as_string);
448
449 Ok(())
450 }
451}