#![doc = include_str!("../README.md")]
#![forbid(unsafe_code)]
use async_trait::async_trait;
use bytes::Bytes;
use cratefield_core::{
Clock, Credential, Destination, Filed, HttpClient, HttpError, TicketDraft, TicketStatus,
Tracker, TrackerError, retry_after,
};
use hmac::{Hmac, KeyInit, Mac};
use http::header::CONTENT_TYPE;
use http::{Request, StatusCode};
use sha2::Sha256;
use std::fmt::Write as _;
use std::sync::Arc;
const SIGNATURE_HEADER: &str = "Cratefield-Signature";
pub struct WebhookTracker {
http: Arc<dyn HttpClient>,
clock: Arc<dyn Clock>,
secret: String,
}
impl std::fmt::Debug for WebhookTracker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WebhookTracker")
.field("secret", &"[redacted]")
.finish_non_exhaustive()
}
}
impl WebhookTracker {
pub fn new(
http: Arc<dyn HttpClient>,
clock: Arc<dyn Clock>,
secret: impl Into<String>,
) -> Self {
Self {
http,
clock,
secret: secret.into(),
}
}
fn map_status(
status: StatusCode,
body: &str,
retry_after: Option<std::time::Duration>,
) -> TrackerError {
let detail = match serde_json::from_str::<ErrorResponse>(body) {
Ok(parsed) if !parsed.message.is_empty() => parsed.message,
_ => body.to_string(),
};
match status {
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => TrackerError::Unauthorized,
StatusCode::NOT_FOUND | StatusCode::UNPROCESSABLE_ENTITY => {
TrackerError::Rejected(format!("webhook {status}: {detail}"))
}
status if status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS => {
TrackerError::Transient { retry_after }
}
status if status.is_client_error() => {
TrackerError::Rejected(format!("webhook {status}: {detail}"))
}
_ => TrackerError::Transient { retry_after },
}
}
}
#[async_trait]
impl Tracker for WebhookTracker {
async fn file(
&self,
dest: &Destination,
_cred: &Credential,
draft: &TicketDraft,
) -> Result<Filed, TrackerError> {
let url = match dest {
Destination::Webhook { url } => url,
other => return Err(TrackerError::unsupported_destination(other)),
};
let signed_at = self.clock.now().unix_timestamp();
let payload = TicketPayload {
title: &draft.title,
body: &draft.body_markdown,
labels: &draft.labels,
idempotency_key: &draft.idempotency_key,
filed_at: signed_at,
};
let raw_body = serde_json::to_vec(&payload)
.map_err(|_| TrackerError::Transient { retry_after: None })?;
let request = Request::builder()
.method(http::Method::POST)
.uri(url.as_str())
.header(CONTENT_TYPE, "application/json")
.header(
SIGNATURE_HEADER,
signature_header(&self.secret, signed_at, &raw_body)?,
)
.body(Bytes::from(raw_body))
.map_err(|_| TrackerError::Transient { retry_after: None })?;
let response = self.http.send(request).await.map_err(|err: HttpError| {
match err {
HttpError::BlockedDestination(detail) => {
TrackerError::Rejected(format!("webhook destination refused: {detail}"))
}
other => {
tracing::warn!(
provider = "webhook",
outcome = "failed",
error = %other,
"tracker outcome"
);
TrackerError::Transient { retry_after: None }
}
}
})?;
let status = response.status();
if !status.is_success() {
let text = String::from_utf8_lossy(response.body()).to_string();
let delay = retry_after(response.headers(), self.clock.as_ref());
return Err(Self::map_status(status, &text, delay));
}
tracing::info!(
provider = "webhook",
outcome = "accepted",
idempotency = %draft.idempotency_key,
"tracker outcome"
);
Ok(Filed {
external_id: draft.idempotency_key.clone(),
url: url.clone(),
})
}
async fn status(
&self,
_dest: &Destination,
_cred: &Credential,
_external_id: &str,
) -> Result<TicketStatus, TrackerError> {
Err(TrackerError::Rejected(
"a webhook files but does not track: the receiver returns no ticket to read back"
.to_owned(),
))
}
}
#[derive(serde::Serialize)]
struct TicketPayload<'a> {
title: &'a str,
body: &'a str,
labels: &'a [String],
idempotency_key: &'a str,
filed_at: i64,
}
fn signature_header(secret: &str, signed_at: i64, body: &[u8]) -> Result<String, TrackerError> {
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
.map_err(|_| TrackerError::Transient { retry_after: None })?;
mac.update(signed_at.to_string().as_bytes());
mac.update(b".");
mac.update(body);
let mac_hex = lower_hex(&mac.finalize().into_bytes());
Ok(format!("t={signed_at},v1={mac_hex}"))
}
fn lower_hex(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
let _ = write!(out, "{byte:02x}");
}
out
}
#[derive(serde::Deserialize)]
struct ErrorResponse {
#[serde(default)]
message: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_signature_header_carries_a_bound_timestamp_and_lowercase_hex() {
let header =
signature_header("a test secret", 1_234, b"body bytes").expect("hmac accepts any key");
assert!(header.starts_with("t=1234,v1="), "{header}");
let hex = header.trim_start_matches("t=1234,v1=");
assert_eq!(hex.len(), 64, "SHA-256 in hex: {header}");
assert!(
hex.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
"the hex is lowercase: {header}"
);
}
#[test]
fn debug_of_the_adapter_redacts_the_secret() {
let adapter = WebhookTracker::new(
Arc::new(NoHttp),
Arc::new(cratefield_core::SystemClock),
"whsec_super_secret_signing_key",
);
let printed = format!("{adapter:?}");
assert!(printed.contains("WebhookTracker"), "{printed}");
assert!(
!printed.contains("whsec_super_secret_signing_key"),
"{printed}"
);
assert!(printed.contains("[redacted]"), "{printed}");
}
struct NoHttp;
#[async_trait]
impl HttpClient for NoHttp {
async fn send(&self, _request: Request<Bytes>) -> Result<http::Response<Bytes>, HttpError> {
Err(HttpError::Transport("not used".to_owned()))
}
}
}