1use serde::Deserialize;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum CaptchaProvider {
24 HCaptcha,
25 Turnstile,
26 ReCaptcha,
27}
28
29impl CaptchaProvider {
30 fn endpoint(&self) -> &'static str {
31 match self {
32 Self::HCaptcha => "https://api.hcaptcha.com/siteverify",
34 Self::Turnstile => "https://challenges.cloudflare.com/turnstile/v0/siteverify",
36 Self::ReCaptcha => "https://www.google.com/recaptcha/api/siteverify",
38 }
39 }
40
41 fn from_str(s: &str) -> Option<Self> {
42 match s.to_ascii_lowercase().as_str() {
43 "hcaptcha" => Some(Self::HCaptcha),
44 "turnstile" | "cloudflare" => Some(Self::Turnstile),
45 "recaptcha" | "google" => Some(Self::ReCaptcha),
46 _ => None,
47 }
48 }
49}
50
51#[derive(Debug, Clone)]
52pub struct CaptchaConfig {
53 pub provider: CaptchaProvider,
54 pub secret: String,
55 pub min_score: f64,
59}
60
61impl CaptchaConfig {
62 pub fn from_env() -> Option<Self> {
66 let provider = CaptchaProvider::from_str(&std::env::var("PYLON_CAPTCHA_PROVIDER").ok()?)?;
67 let secret = std::env::var("PYLON_CAPTCHA_SECRET").ok()?;
68 let min_score = std::env::var("PYLON_CAPTCHA_MIN_SCORE")
69 .ok()
70 .and_then(|s| s.parse().ok())
71 .unwrap_or(0.5);
72 Some(Self {
73 provider,
74 secret,
75 min_score,
76 })
77 }
78
79 pub fn verify(&self, token: &str, remote_ip: Option<&str>) -> Result<(), String> {
83 if token.is_empty() {
84 return Err("CAPTCHA token is empty".into());
85 }
86 let mut body = format!(
87 "secret={}&response={}",
88 url_encode(&self.secret),
89 url_encode(token)
90 );
91 if let Some(ip) = remote_ip {
92 body.push_str("&remoteip=");
93 body.push_str(&url_encode(ip));
94 }
95 let agent = ureq::AgentBuilder::new()
96 .timeout_connect(std::time::Duration::from_secs(5))
97 .timeout_read(std::time::Duration::from_secs(5))
98 .build();
99 let resp = agent
100 .post(self.provider.endpoint())
101 .set("Content-Type", "application/x-www-form-urlencoded")
102 .send_string(&body)
103 .map_err(|e| format!("captcha network: {e}"))?
104 .into_string()
105 .map_err(|e| format!("captcha body: {e}"))?;
106 let parsed: SiteVerifyResponse =
107 serde_json::from_str(&resp).map_err(|e| format!("captcha bad JSON: {e}"))?;
108 if !parsed.success {
109 return Err(format!(
110 "captcha rejected: {}",
111 parsed.error_codes.unwrap_or_default().join(",")
112 ));
113 }
114 if let CaptchaProvider::ReCaptcha = self.provider {
115 if let Some(score) = parsed.score {
118 if score < self.min_score {
119 return Err(format!(
120 "captcha score {score:.2} below threshold {:.2}",
121 self.min_score
122 ));
123 }
124 }
125 }
126 if let Some(ts) = parsed.challenge_ts.as_deref() {
130 if let Ok(parsed_ts) = chrono::DateTime::parse_from_rfc3339(ts) {
131 let age_secs = chrono::Utc::now()
132 .signed_duration_since(parsed_ts.with_timezone(&chrono::Utc))
133 .num_seconds();
134 if age_secs > 120 {
135 return Err(format!("captcha token stale ({age_secs}s old)"));
136 }
137 }
138 }
139 Ok(())
140 }
141}
142
143#[derive(Debug, Deserialize)]
145struct SiteVerifyResponse {
146 success: bool,
147 #[serde(default)]
149 score: Option<f64>,
150 #[serde(default, rename = "challenge_ts")]
155 challenge_ts: Option<String>,
156 #[serde(default, rename = "error-codes")]
159 error_codes: Option<Vec<String>>,
160}
161
162fn url_encode(s: &str) -> String {
163 let mut out = String::with_capacity(s.len());
164 for b in s.bytes() {
165 match b {
166 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
167 out.push(b as char)
168 }
169 _ => out.push_str(&format!("%{b:02X}")),
170 }
171 }
172 out
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[test]
180 fn provider_from_str_recognizes_aliases() {
181 assert_eq!(CaptchaProvider::from_str("hcaptcha"), Some(CaptchaProvider::HCaptcha));
182 assert_eq!(CaptchaProvider::from_str("HCAPTCHA"), Some(CaptchaProvider::HCaptcha));
183 assert_eq!(CaptchaProvider::from_str("turnstile"), Some(CaptchaProvider::Turnstile));
184 assert_eq!(CaptchaProvider::from_str("cloudflare"), Some(CaptchaProvider::Turnstile));
185 assert_eq!(CaptchaProvider::from_str("recaptcha"), Some(CaptchaProvider::ReCaptcha));
186 assert_eq!(CaptchaProvider::from_str("google"), Some(CaptchaProvider::ReCaptcha));
187 assert_eq!(CaptchaProvider::from_str("nope"), None);
188 }
189
190 #[test]
191 fn endpoints_are_https() {
192 for p in [CaptchaProvider::HCaptcha, CaptchaProvider::Turnstile, CaptchaProvider::ReCaptcha] {
193 assert!(p.endpoint().starts_with("https://"), "endpoint must be https");
194 }
195 }
196
197 #[test]
198 fn empty_token_rejected_without_network() {
199 let cfg = CaptchaConfig {
200 provider: CaptchaProvider::HCaptcha,
201 secret: "test".into(),
202 min_score: 0.5,
203 };
204 assert!(cfg.verify("", None).is_err());
205 }
206}