Skip to main content

rustauth_plugins/captcha/
options.rs

1//! CAPTCHA options.
2
3use rustauth_core::error::RustAuthError;
4use serde::{Deserialize, Serialize};
5
6use super::error::CaptchaConfigError;
7
8pub const DEFAULT_ENDPOINTS: &[&str] = &[
9    "/sign-up/email",
10    "/sign-in/email",
11    "/request-password-reset",
12];
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15pub enum CaptchaProvider {
16    #[serde(rename = "cloudflare-turnstile")]
17    CloudflareTurnstile,
18    #[serde(rename = "google-recaptcha")]
19    GoogleRecaptcha,
20    #[serde(rename = "hcaptcha")]
21    HCaptcha,
22    #[serde(rename = "captchafox")]
23    CaptchaFox,
24}
25
26impl CaptchaProvider {
27    pub fn site_verify_url(self) -> &'static str {
28        match self {
29            Self::CloudflareTurnstile => {
30                "https://challenges.cloudflare.com/turnstile/v0/siteverify"
31            }
32            Self::GoogleRecaptcha => "https://www.google.com/recaptcha/api/siteverify",
33            Self::HCaptcha => "https://api.hcaptcha.com/siteverify",
34            Self::CaptchaFox => "https://api.captchafox.com/siteverify",
35        }
36    }
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct CaptchaOptions {
41    pub provider: CaptchaProvider,
42    #[serde(skip_serializing)]
43    pub secret_key: String,
44    #[serde(default)]
45    pub endpoints: Vec<String>,
46    #[serde(default)]
47    pub site_verify_url_override: Option<String>,
48    #[serde(default)]
49    pub min_score: Option<f64>,
50    #[serde(default)]
51    pub site_key: Option<String>,
52    #[serde(skip)]
53    pub http_client: Option<reqwest::Client>,
54}
55
56impl CaptchaOptions {
57    #[must_use]
58    pub fn builder() -> CaptchaOptionsBuilder {
59        CaptchaOptionsBuilder::default()
60    }
61
62    pub fn with_provider(provider: CaptchaProvider, secret_key: impl Into<String>) -> Self {
63        Self {
64            provider,
65            secret_key: secret_key.into(),
66            endpoints: Vec::new(),
67            site_verify_url_override: None,
68            min_score: None,
69            site_key: None,
70            http_client: None,
71        }
72    }
73
74    pub fn cloudflare_turnstile(secret_key: impl Into<String>) -> Self {
75        Self::with_provider(CaptchaProvider::CloudflareTurnstile, secret_key)
76    }
77
78    pub fn google_recaptcha(secret_key: impl Into<String>) -> Self {
79        Self::with_provider(CaptchaProvider::GoogleRecaptcha, secret_key)
80    }
81
82    pub fn hcaptcha(secret_key: impl Into<String>) -> Self {
83        Self::with_provider(CaptchaProvider::HCaptcha, secret_key)
84    }
85
86    pub fn captchafox(secret_key: impl Into<String>) -> Self {
87        Self::with_provider(CaptchaProvider::CaptchaFox, secret_key)
88    }
89
90    #[must_use]
91    pub fn endpoints<I, S>(mut self, endpoints: I) -> Self
92    where
93        I: IntoIterator<Item = S>,
94        S: Into<String>,
95    {
96        self.endpoints = endpoints.into_iter().map(Into::into).collect();
97        self
98    }
99
100    #[must_use]
101    pub fn site_verify_url_override(mut self, url: impl Into<String>) -> Self {
102        self.site_verify_url_override = Some(url.into());
103        self
104    }
105
106    #[must_use]
107    pub fn min_score(mut self, min_score: f64) -> Self {
108        self.min_score = Some(min_score);
109        self
110    }
111
112    #[must_use]
113    pub fn site_key(mut self, site_key: impl Into<String>) -> Self {
114        self.site_key = Some(site_key.into());
115        self
116    }
117
118    #[must_use]
119    pub fn http_client(mut self, http_client: reqwest::Client) -> Self {
120        self.http_client = Some(http_client);
121        self
122    }
123
124    pub(crate) fn validate(&self) -> Result<(), CaptchaConfigError> {
125        if self.secret_key.trim().is_empty() {
126            return Err(CaptchaConfigError::MissingSecretKey);
127        }
128        Ok(())
129    }
130
131    pub(crate) fn with_defaults(mut self) -> Self {
132        if self.endpoints.is_empty() {
133            self.endpoints = DEFAULT_ENDPOINTS
134                .iter()
135                .map(|endpoint| (*endpoint).to_owned())
136                .collect();
137        }
138        self
139    }
140
141    pub(crate) fn site_verify_url(&self) -> &str {
142        self.site_verify_url_override
143            .as_deref()
144            .unwrap_or_else(|| self.provider.site_verify_url())
145    }
146
147    pub(crate) fn http_client_ref(&self) -> reqwest::Client {
148        self.http_client.clone().unwrap_or_default()
149    }
150
151    pub(crate) fn google_min_score(&self) -> f64 {
152        self.min_score.unwrap_or(0.5)
153    }
154}
155
156#[derive(Debug, Clone, Default)]
157pub struct CaptchaOptionsBuilder {
158    provider: Option<CaptchaProvider>,
159    secret_key: Option<String>,
160    endpoints: Option<Vec<String>>,
161    site_verify_url_override: Option<Option<String>>,
162    min_score: Option<Option<f64>>,
163    site_key: Option<Option<String>>,
164    http_client: Option<Option<reqwest::Client>>,
165}
166
167impl CaptchaOptionsBuilder {
168    #[must_use]
169    pub fn provider(mut self, provider: CaptchaProvider) -> Self {
170        self.provider = Some(provider);
171        self
172    }
173
174    #[must_use]
175    pub fn secret_key(mut self, secret_key: impl Into<String>) -> Self {
176        self.secret_key = Some(secret_key.into());
177        self
178    }
179
180    #[must_use]
181    pub fn endpoints(mut self, endpoints: Vec<String>) -> Self {
182        self.endpoints = Some(endpoints);
183        self
184    }
185
186    #[must_use]
187    pub fn site_verify_url_override(mut self, url: impl Into<String>) -> Self {
188        self.site_verify_url_override = Some(Some(url.into()));
189        self
190    }
191
192    #[must_use]
193    pub fn min_score(mut self, min_score: f64) -> Self {
194        self.min_score = Some(Some(min_score));
195        self
196    }
197
198    #[must_use]
199    pub fn site_key(mut self, site_key: impl Into<String>) -> Self {
200        self.site_key = Some(Some(site_key.into()));
201        self
202    }
203
204    #[must_use]
205    pub fn http_client(mut self, http_client: reqwest::Client) -> Self {
206        self.http_client = Some(Some(http_client));
207        self
208    }
209
210    pub fn build(self) -> Result<CaptchaOptions, RustAuthError> {
211        let provider = self.provider.ok_or_else(|| {
212            RustAuthError::InvalidConfig("captcha provider is required".to_owned())
213        })?;
214        let secret_key = self.secret_key.ok_or_else(|| {
215            RustAuthError::InvalidConfig("captcha secret_key is required".to_owned())
216        })?;
217        let options = CaptchaOptions {
218            provider,
219            secret_key,
220            endpoints: self.endpoints.unwrap_or_default(),
221            site_verify_url_override: self.site_verify_url_override.unwrap_or(None),
222            min_score: self.min_score.unwrap_or(None),
223            site_key: self.site_key.unwrap_or(None),
224            http_client: self.http_client.unwrap_or(None),
225        };
226        options
227            .validate()
228            .map_err(|error| RustAuthError::InvalidConfig(error.to_string()))?;
229        Ok(options)
230    }
231}