use serde::Deserialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CaptchaProvider {
HCaptcha,
Turnstile,
ReCaptcha,
}
impl CaptchaProvider {
fn endpoint(&self) -> &'static str {
match self {
Self::HCaptcha => "https://api.hcaptcha.com/siteverify",
Self::Turnstile => "https://challenges.cloudflare.com/turnstile/v0/siteverify",
Self::ReCaptcha => "https://www.google.com/recaptcha/api/siteverify",
}
}
fn from_str(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"hcaptcha" => Some(Self::HCaptcha),
"turnstile" | "cloudflare" => Some(Self::Turnstile),
"recaptcha" | "google" => Some(Self::ReCaptcha),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct CaptchaConfig {
pub provider: CaptchaProvider,
pub secret: String,
pub min_score: f64,
}
impl CaptchaConfig {
pub fn from_env() -> Option<Self> {
let provider = CaptchaProvider::from_str(&std::env::var("PYLON_CAPTCHA_PROVIDER").ok()?)?;
let secret = std::env::var("PYLON_CAPTCHA_SECRET").ok()?;
let min_score = std::env::var("PYLON_CAPTCHA_MIN_SCORE")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(0.5);
Some(Self {
provider,
secret,
min_score,
})
}
pub fn verify(&self, token: &str, remote_ip: Option<&str>) -> Result<(), String> {
if token.is_empty() {
return Err("CAPTCHA token is empty".into());
}
let mut body = format!(
"secret={}&response={}",
url_encode(&self.secret),
url_encode(token)
);
if let Some(ip) = remote_ip {
body.push_str("&remoteip=");
body.push_str(&url_encode(ip));
}
let agent = ureq::AgentBuilder::new()
.timeout_connect(std::time::Duration::from_secs(5))
.timeout_read(std::time::Duration::from_secs(5))
.build();
let resp = agent
.post(self.provider.endpoint())
.set("Content-Type", "application/x-www-form-urlencoded")
.send_string(&body)
.map_err(|e| format!("captcha network: {e}"))?
.into_string()
.map_err(|e| format!("captcha body: {e}"))?;
let parsed: SiteVerifyResponse =
serde_json::from_str(&resp).map_err(|e| format!("captcha bad JSON: {e}"))?;
if !parsed.success {
return Err(format!(
"captcha rejected: {}",
parsed.error_codes.unwrap_or_default().join(",")
));
}
if let CaptchaProvider::ReCaptcha = self.provider {
if let Some(score) = parsed.score {
if score < self.min_score {
return Err(format!(
"captcha score {score:.2} below threshold {:.2}",
self.min_score
));
}
}
}
if let Some(ts) = parsed.challenge_ts.as_deref() {
if let Ok(parsed_ts) = chrono::DateTime::parse_from_rfc3339(ts) {
let age_secs = chrono::Utc::now()
.signed_duration_since(parsed_ts.with_timezone(&chrono::Utc))
.num_seconds();
if age_secs > 120 {
return Err(format!("captcha token stale ({age_secs}s old)"));
}
}
}
Ok(())
}
}
#[derive(Debug, Deserialize)]
struct SiteVerifyResponse {
success: bool,
#[serde(default)]
score: Option<f64>,
#[serde(default, rename = "challenge_ts")]
challenge_ts: Option<String>,
#[serde(default, rename = "error-codes")]
error_codes: Option<Vec<String>>,
}
fn url_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provider_from_str_recognizes_aliases() {
assert_eq!(
CaptchaProvider::from_str("hcaptcha"),
Some(CaptchaProvider::HCaptcha)
);
assert_eq!(
CaptchaProvider::from_str("HCAPTCHA"),
Some(CaptchaProvider::HCaptcha)
);
assert_eq!(
CaptchaProvider::from_str("turnstile"),
Some(CaptchaProvider::Turnstile)
);
assert_eq!(
CaptchaProvider::from_str("cloudflare"),
Some(CaptchaProvider::Turnstile)
);
assert_eq!(
CaptchaProvider::from_str("recaptcha"),
Some(CaptchaProvider::ReCaptcha)
);
assert_eq!(
CaptchaProvider::from_str("google"),
Some(CaptchaProvider::ReCaptcha)
);
assert_eq!(CaptchaProvider::from_str("nope"), None);
}
#[test]
fn endpoints_are_https() {
for p in [
CaptchaProvider::HCaptcha,
CaptchaProvider::Turnstile,
CaptchaProvider::ReCaptcha,
] {
assert!(
p.endpoint().starts_with("https://"),
"endpoint must be https"
);
}
}
#[test]
fn empty_token_rejected_without_network() {
let cfg = CaptchaConfig {
provider: CaptchaProvider::HCaptcha,
secret: "test".into(),
min_score: 0.5,
};
assert!(cfg.verify("", None).is_err());
}
}