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!(
182 CaptchaProvider::from_str("hcaptcha"),
183 Some(CaptchaProvider::HCaptcha)
184 );
185 assert_eq!(
186 CaptchaProvider::from_str("HCAPTCHA"),
187 Some(CaptchaProvider::HCaptcha)
188 );
189 assert_eq!(
190 CaptchaProvider::from_str("turnstile"),
191 Some(CaptchaProvider::Turnstile)
192 );
193 assert_eq!(
194 CaptchaProvider::from_str("cloudflare"),
195 Some(CaptchaProvider::Turnstile)
196 );
197 assert_eq!(
198 CaptchaProvider::from_str("recaptcha"),
199 Some(CaptchaProvider::ReCaptcha)
200 );
201 assert_eq!(
202 CaptchaProvider::from_str("google"),
203 Some(CaptchaProvider::ReCaptcha)
204 );
205 assert_eq!(CaptchaProvider::from_str("nope"), None);
206 }
207
208 #[test]
209 fn endpoints_are_https() {
210 for p in [
211 CaptchaProvider::HCaptcha,
212 CaptchaProvider::Turnstile,
213 CaptchaProvider::ReCaptcha,
214 ] {
215 assert!(
216 p.endpoint().starts_with("https://"),
217 "endpoint must be https"
218 );
219 }
220 }
221
222 #[test]
223 fn empty_token_rejected_without_network() {
224 let cfg = CaptchaConfig {
225 provider: CaptchaProvider::HCaptcha,
226 secret: "test".into(),
227 min_score: 0.5,
228 };
229 assert!(cfg.verify("", None).is_err());
230 }
231}