use axum::{
extract::{Path, Query},
http::StatusCode,
response::{IntoResponse, Redirect},
};
use base64::Engine;
use hmac::{Hmac, Mac};
use serde::Deserialize;
use sha2::Sha256;
use tracing::{info, warn};
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Deserialize)]
pub struct ClickQuery {
pub u: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DecodedClickRef {
pub kref: String,
pub verified: bool,
}
const SIG_LEN: usize = 8;
fn split_signed(raw: &[u8]) -> Option<(&[u8], &[u8])> {
if raw.len() < SIG_LEN + 1 {
return None;
}
let sep_idx = raw.len() - SIG_LEN - 1;
if raw[sep_idx] != b':' {
return None;
}
Some((&raw[..sep_idx], &raw[sep_idx + 1..]))
}
pub fn decode_kref(token: &str, secret: Option<&str>) -> Result<DecodedClickRef, String> {
let raw = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(token.as_bytes())
.map_err(|e| format!("base64 decode failed: {e}"))?;
let signed_split = split_signed(&raw);
let Some(secret) = secret else {
let kref = String::from_utf8_lossy(&raw).into_owned();
return Ok(DecodedClickRef {
kref,
verified: false,
});
};
let Some((body_bytes, sig_bytes)) = signed_split else {
let kref = String::from_utf8_lossy(&raw).into_owned();
return Ok(DecodedClickRef {
kref,
verified: false,
});
};
let kref =
String::from_utf8(body_bytes.to_vec()).map_err(|e| format!("kref is not utf-8: {e}"))?;
let mut mac =
HmacSha256::new_from_slice(secret.as_bytes()).map_err(|e| format!("hmac key init: {e}"))?;
mac.update(body_bytes);
let full = mac.finalize().into_bytes();
let expected = &full[..SIG_LEN];
let verified = constant_time_eq(expected, sig_bytes);
Ok(DecodedClickRef { kref, verified })
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
fn click_secret() -> Option<String> {
match std::env::var("CLICK_TRACKING_SECRET") {
Ok(v) if !v.trim().is_empty() => Some(v),
_ => None,
}
}
pub async fn handle_click(
Path(encoded): Path<String>,
Query(query): Query<ClickQuery>,
) -> impl IntoResponse {
let secret = click_secret();
let decoded = decode_kref(&encoded, secret.as_deref());
let dest = match query.u.as_deref() {
Some(u) if !u.is_empty() => u.to_string(),
_ => {
warn!(
"click_tracking: missing 'u' query param for token {}",
truncate(&encoded, 40)
);
return (StatusCode::BAD_REQUEST, "click tracker missing destination").into_response();
}
};
match decoded {
Ok(DecodedClickRef { kref, verified }) => {
info!(
target: "click_tracking",
kref = %kref,
verified = verified,
dest = %dest,
"click received"
);
}
Err(err) => {
warn!(
"click_tracking: failed to decode token {}: {}",
truncate(&encoded, 40),
err
);
}
}
Redirect::to(&dest).into_response()
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}…", &s[..max])
}
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
const KREF: &str = "kref://Construct/Outreach/contacts/acme.contact";
const SECRET: &str = "s3cret";
fn encode_token(kref: &str, secret: Option<&str>) -> String {
let body = kref.as_bytes();
let payload = if let Some(s) = secret {
let mut mac = HmacSha256::new_from_slice(s.as_bytes()).unwrap();
mac.update(body);
let sig = mac.finalize().into_bytes();
let mut combined = Vec::with_capacity(body.len() + 1 + 8);
combined.extend_from_slice(body);
combined.push(b':');
combined.extend_from_slice(&sig[..8]);
combined
} else {
body.to_vec()
};
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload)
}
#[test]
fn decode_unsigned_roundtrip() {
let token = encode_token(KREF, None);
let out = decode_kref(&token, None).unwrap();
assert_eq!(out.kref, KREF);
assert!(!out.verified);
}
#[test]
fn decode_signed_roundtrip_verifies() {
let token = encode_token(KREF, Some(SECRET));
let out = decode_kref(&token, Some(SECRET)).unwrap();
assert_eq!(out.kref, KREF);
assert!(out.verified, "matching secret should set verified=true");
}
#[test]
fn wrong_secret_decodes_but_does_not_verify() {
let token = encode_token(KREF, Some(SECRET));
let out = decode_kref(&token, Some("wrong")).unwrap();
assert_eq!(out.kref, KREF);
assert!(!out.verified);
}
#[test]
fn signed_token_decoded_without_secret_still_extracts_kref() {
let token = encode_token(KREF, Some(SECRET));
let out = decode_kref(&token, None).unwrap();
assert!(!out.verified);
assert!(out.kref.starts_with(KREF));
}
#[test]
fn invalid_base64_errors() {
let result = decode_kref("not!valid!base64@@@", None);
assert!(result.is_err());
}
#[test]
fn unsigned_token_with_secret_present_does_not_verify() {
let token = encode_token(KREF, None);
let out = decode_kref(&token, Some(SECRET)).unwrap();
assert_eq!(out.kref, KREF);
assert!(!out.verified);
}
#[test]
fn constant_time_eq_basics() {
assert!(constant_time_eq(b"abc", b"abc"));
assert!(!constant_time_eq(b"abc", b"abd"));
assert!(!constant_time_eq(b"abc", b"abcd"));
assert!(constant_time_eq(b"", b""));
}
}