pub mod service;
use crate::{ImageId, OwnerId, Secret};
use clap::ValueEnum;
use getrandom::getrandom;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha512;
use std::{
collections::BTreeSet,
fmt::{Display, Error as FmtError, Formatter},
str::FromStr,
time::SystemTime,
};
use time::OffsetDateTime;
use url::Url;
use uuid::Uuid;
pub const DIGEST_HEADER: &str = "x-freta-digest";
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Eq, PartialEq, Hash)]
pub struct WebhookId(Uuid);
impl WebhookId {
#[must_use]
pub fn new() -> Self {
Self(Uuid::new_v4())
}
}
impl Default for WebhookId {
fn default() -> Self {
Self::new()
}
}
impl Display for WebhookId {
fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), FmtError> {
write!(f, "{}", self.0)
}
}
impl FromStr for WebhookId {
type Err = uuid::Error;
fn from_str(uuid_str: &str) -> Result<Self, Self::Err> {
Uuid::parse_str(uuid_str).map(Self)
}
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Eq, PartialEq)]
pub struct WebhookEventId(Uuid);
impl WebhookEventId {
#[must_use]
pub fn new() -> Self {
Self(new_uuid_v7())
}
}
impl Default for WebhookEventId {
fn default() -> Self {
Self::new()
}
}
impl Display for WebhookEventId {
fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), FmtError> {
write!(f, "{}", self.0)
}
}
impl FromStr for WebhookEventId {
type Err = uuid::Error;
fn from_str(uuid_str: &str) -> Result<Self, Self::Err> {
Uuid::parse_str(uuid_str).map(Self)
}
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Serialize, Deserialize, Clone, ValueEnum, Ord, Eq, PartialEq, PartialOrd)]
#[serde(rename_all = "snake_case")]
#[value(rename_all = "snake_case")]
pub enum WebhookEventType {
#[clap(skip)]
Ping,
ImageCreated,
ImageDeleted,
ImageAnalysisCompleted,
ImageAnalysisFailed,
ImageStateUpdated,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct WebhookEvent {
pub event_id: WebhookEventId,
pub event_type: WebhookEventType,
#[serde(with = "time::serde::rfc3339")]
#[cfg_attr(feature = "schema", schemars(with = "String"))]
pub timestamp: OffsetDateTime,
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<ImageId>,
}
impl WebhookEvent {
#[must_use]
pub fn new(
event_type: WebhookEventType,
timestamp: OffsetDateTime,
image: Option<ImageId>,
) -> Self {
Self {
event_id: WebhookEventId::new(),
event_type,
timestamp,
image,
}
}
}
#[derive(thiserror::Error, Debug)]
pub enum HmacError {
#[error("invalid hmac token")]
InvalidHmacToken,
#[error("serialization error")]
Serialization(#[from] serde_json::Error),
}
impl WebhookEvent {
pub fn hmac_sha512(&self, hmac_token: &Secret) -> Result<String, HmacError> {
let event_as_bytes = serde_json::to_string(&self)?.as_bytes().to_vec();
hmac_sha512(&event_as_bytes, hmac_token)
}
}
pub fn hmac_sha512(bytes: &[u8], hmac_token: &Secret) -> Result<String, HmacError> {
let mut mac = Hmac::<Sha512>::new_from_slice(hmac_token.get_secret().as_bytes())
.map_err(|_| HmacError::InvalidHmacToken)?;
mac.update(bytes);
let result = mac.finalize().into_bytes();
let hmac_as_string = result
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>();
Ok(hmac_as_string)
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub enum WebhookEventState {
Pending,
Success,
Failure,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Webhook {
#[serde(
rename(deserialize = "Timestamp"),
alias = "last_updated",
skip_serializing_if = "Option::is_none",
default,
with = "time::serde::rfc3339::option"
)]
pub last_updated: Option<OffsetDateTime>,
#[serde(rename(deserialize = "PartitionKey"), alias = "owner_id")]
pub owner_id: OwnerId,
#[serde(rename(deserialize = "RowKey"), alias = "webhook_id")]
pub webhook_id: WebhookId,
pub url: Url,
pub event_types: BTreeSet<WebhookEventType>,
pub hmac_token: Option<Secret>,
}
impl Webhook {
#[must_use]
pub fn new(
owner_id: OwnerId,
url: Url,
event_types: BTreeSet<WebhookEventType>,
hmac_token: Option<Secret>,
) -> Self {
Self {
last_updated: None,
owner_id,
webhook_id: WebhookId::new(),
url,
event_types,
hmac_token,
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct WebhookLog {
#[serde(
rename(deserialize = "Timestamp"),
alias = "last_updated",
skip_serializing_if = "Option::is_none",
default,
with = "time::serde::rfc3339::option"
)]
pub last_updated: Option<OffsetDateTime>,
#[serde(rename(deserialize = "PartitionKey"), alias = "webhook_id")]
pub webhook_id: WebhookId,
#[serde(rename(deserialize = "RowKey"), alias = "event_id")]
pub event_id: WebhookEventId,
pub event: WebhookEvent,
pub state: WebhookEventState,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
impl WebhookLog {
#[must_use]
pub fn new(
webhook_id: WebhookId,
event_type: WebhookEventType,
timestamp: OffsetDateTime,
image_id: Option<ImageId>,
) -> Self {
let event = WebhookEvent::new(event_type, timestamp, image_id);
Self {
last_updated: None,
webhook_id,
event_id: event.event_id,
event,
state: WebhookEventState::Pending,
error: None,
}
}
}
#[allow(clippy::expect_used, clippy::cast_possible_truncation)]
fn new_uuid_v7() -> Uuid {
let now = SystemTime::UNIX_EPOCH
.elapsed()
.expect("getting elapsed time since UNIX_EPOCH should not fail")
.as_millis() as u64;
let mut random_bytes = [0_u8; 10];
getrandom(&mut random_bytes).expect("getting random value failed");
fmt_uuid_v7(now, random_bytes)
}
const fn fmt_uuid_v7(millis: u64, random_bytes: [u8; 10]) -> Uuid {
let millis_low = (millis & 0xFFFF) as u16;
let millis_high = ((millis >> 16) & 0xFFFF_FFFF) as u32;
let random_and_version =
(random_bytes[0] as u16 | ((random_bytes[1] as u16) << 8) & 0x0FFF) | (0x7 << 12);
let mut d4 = [0; 8];
d4[0] = (random_bytes[2] & 0x3F) | 0x80;
d4[1] = random_bytes[3];
d4[2] = random_bytes[4];
d4[3] = random_bytes[5];
d4[4] = random_bytes[6];
d4[5] = random_bytes[7];
d4[6] = random_bytes[8];
d4[7] = random_bytes[9];
Uuid::from_fields(millis_high, millis_low, random_and_version, &d4)
}
#[cfg(test)]
mod tests {
use super::*;
use std::{thread::sleep, time::Duration};
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
#[test]
fn test_uuid_v7_format() {
let examples = vec![
fmt_uuid_v7(0, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]),
fmt_uuid_v7(1_673_483_814 * 1000, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]),
fmt_uuid_v7(
1_673_483_814 * 1000,
[11, 12, 13, 14, 15, 16, 17, 18, 19, 20],
),
fmt_uuid_v7(
1_673_483_815 * 1000,
[11, 12, 13, 14, 15, 16, 17, 18, 19, 20],
),
];
insta::assert_json_snapshot!(examples);
}
#[test]
fn test_lexicographical_sorting() {
let two_millis = Duration::from_millis(2);
let mut uuids = vec![];
for _ in 0..100 {
uuids.push(new_uuid_v7().to_string());
sleep(two_millis);
}
let mut sorted = uuids.clone();
sorted.sort();
assert_eq!(
uuids, sorted,
"UUIDv7 should be lexicographically sorted during generation"
);
}
#[test]
fn test_hmac() -> Result<()> {
let event = WebhookEvent {
event_id: WebhookEventId(Uuid::from_u128(1)),
event_type: WebhookEventType::ImageCreated,
timestamp: OffsetDateTime::UNIX_EPOCH,
image: Some(Uuid::from_u128(0).into()),
};
let hmac = event.hmac_sha512(&Secret::new("testing"))?;
insta::assert_json_snapshot!(hmac);
let event_as_string = serde_json::to_string(&event)?;
insta::assert_json_snapshot!(event_as_string);
Ok(())
}
}