Skip to main content

capmonstercloud_client/requests/
turnstile.rs

1use serde::{Serialize, Serializer};
2
3use super::{require_non_empty, CaptchaRequest};
4use crate::error::Result;
5use crate::proxy::ProxyInfo;
6use crate::timeouts::GetResultTimeouts;
7
8/// Regular Cloudflare Turnstile widget (no `cloudflareTaskType`). All subtypes (manual,
9/// non-interactive, invisible) are supported automatically. The result is a token.
10#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
11pub struct TurnstileStandard {
12    #[serde(flatten, skip_serializing_if = "Option::is_none")]
13    pub proxy: Option<ProxyInfo>,
14    /// Browser User-Agent (use a current Windows one).
15    #[serde(rename = "userAgent", skip_serializing_if = "Option::is_none")]
16    pub user_agent: Option<String>,
17    /// The `action` found in the callback used when the captcha is loaded.
18    #[serde(rename = "pageAction", skip_serializing_if = "Option::is_none")]
19    pub page_action: Option<String>,
20    /// The `cData` value of the widget.
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub data: Option<String>,
23}
24
25impl TurnstileStandard {
26    pub fn new() -> Self {
27        Self::default()
28    }
29
30    pub fn with_proxy(mut self, proxy: ProxyInfo) -> Self {
31        self.proxy = Some(proxy);
32        self
33    }
34
35    pub fn with_user_agent(mut self, value: impl Into<String>) -> Self {
36        self.user_agent = Some(value.into());
37        self
38    }
39
40    pub fn with_page_action(mut self, value: impl Into<String>) -> Self {
41        self.page_action = Some(value.into());
42        self
43    }
44
45    pub fn with_data(mut self, value: impl Into<String>) -> Self {
46        self.data = Some(value.into());
47        self
48    }
49}
50
51/// Cloudflare Challenge solved to a **token** (`cloudflareTaskType = "token"`).
52///
53/// The values are the parameters passed to `window.turnstile.render(el, paramsObj)`.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
55pub struct TurnstileChallengeToken {
56    /// The `action` of the callback; usually `managed` or `non-interactive`.
57    #[serde(rename = "pageAction")]
58    pub page_action: String,
59    /// Browser User-Agent (use a current Windows one).
60    #[serde(rename = "userAgent")]
61    pub user_agent: String,
62    /// The `cData` parameter, e.g. `874291f4retD1366`.
63    pub data: String,
64    /// The `chlPageData` parameter, e.g. `HUHDWUHuhuwfiweh32..uh2uhuhyugYUG=`.
65    #[serde(rename = "pageData")]
66    pub page_data: String,
67    #[serde(flatten, skip_serializing_if = "Option::is_none")]
68    pub proxy: Option<ProxyInfo>,
69    /// Link to the captcha script.
70    #[serde(rename = "apiJsUrl", skip_serializing_if = "Option::is_none")]
71    pub api_js_url: Option<String>,
72}
73
74impl TurnstileChallengeToken {
75    pub fn new(
76        page_action: impl Into<String>,
77        user_agent: impl Into<String>,
78        data: impl Into<String>,
79        page_data: impl Into<String>,
80    ) -> Self {
81        Self {
82            page_action: page_action.into(),
83            user_agent: user_agent.into(),
84            data: data.into(),
85            page_data: page_data.into(),
86            proxy: None,
87            api_js_url: None,
88        }
89    }
90
91    pub fn with_proxy(mut self, proxy: ProxyInfo) -> Self {
92        self.proxy = Some(proxy);
93        self
94    }
95
96    pub fn with_api_js_url(mut self, value: impl Into<String>) -> Self {
97        self.api_js_url = Some(value.into());
98        self
99    }
100}
101
102/// Cloudflare Challenge solved to the `cf_clearance` cookie (`cloudflareTaskType =
103/// "cf_clearance"`). Your **own proxy is mandatory**.
104#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
105pub struct TurnstileChallengeCookie {
106    /// Base64 of the "Just a moment" HTML page returned with status 403.
107    #[serde(rename = "htmlPageBase64")]
108    pub html_page_base64: String,
109    /// Browser User-Agent (use a current Windows one).
110    #[serde(rename = "userAgent")]
111    pub user_agent: String,
112    #[serde(flatten)]
113    pub proxy: ProxyInfo,
114}
115
116impl TurnstileChallengeCookie {
117    pub fn new(
118        html_page_base64: impl Into<String>,
119        user_agent: impl Into<String>,
120        proxy: ProxyInfo,
121    ) -> Self {
122        Self {
123            html_page_base64: html_page_base64.into(),
124            user_agent: user_agent.into(),
125            proxy,
126        }
127    }
128}
129
130/// Cloudflare Waiting Room (`cloudflareTaskType = "wait_room"`); the result is cookies.
131/// Your **own proxy is mandatory**.
132#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
133pub struct TurnstileWaitRoom {
134    /// Base64 of the page containing `<title>Waiting Room powered by Cloudflare</title>`.
135    #[serde(rename = "htmlPageBase64")]
136    pub html_page_base64: String,
137    /// Browser User-Agent (use a current Windows one).
138    #[serde(rename = "userAgent")]
139    pub user_agent: String,
140    #[serde(flatten)]
141    pub proxy: ProxyInfo,
142}
143
144impl TurnstileWaitRoom {
145    pub fn new(
146        html_page_base64: impl Into<String>,
147        user_agent: impl Into<String>,
148        proxy: ProxyInfo,
149    ) -> Self {
150        Self {
151            html_page_base64: html_page_base64.into(),
152            user_agent: user_agent.into(),
153            proxy,
154        }
155    }
156}
157
158/// What kind of Cloudflare protection is being solved.
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub enum TurnstileMode {
161    Standard(TurnstileStandard),
162    ChallengeToken(TurnstileChallengeToken),
163    ChallengeCookie(TurnstileChallengeCookie),
164    WaitRoom(TurnstileWaitRoom),
165}
166
167macro_rules! impl_from_mode {
168    ($($ty:ident => $variant:ident),*) => {$(
169        impl From<$ty> for TurnstileMode {
170            fn from(v: $ty) -> Self {
171                Self::$variant(v)
172            }
173        }
174    )*};
175}
176impl_from_mode!(
177    TurnstileStandard => Standard,
178    TurnstileChallengeToken => ChallengeToken,
179    TurnstileChallengeCookie => ChallengeCookie,
180    TurnstileWaitRoom => WaitRoom
181);
182
183#[derive(Serialize)]
184struct Tagged<'a, T: Serialize> {
185    #[serde(rename = "cloudflareTaskType")]
186    cloudflare_task_type: &'static str,
187    #[serde(flatten)]
188    inner: &'a T,
189}
190
191impl Serialize for TurnstileMode {
192    fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
193        match self {
194            TurnstileMode::Standard(v) => v.serialize(serializer),
195            TurnstileMode::ChallengeToken(v) => Tagged {
196                cloudflare_task_type: "token",
197                inner: v,
198            }
199            .serialize(serializer),
200            TurnstileMode::ChallengeCookie(v) => Tagged {
201                cloudflare_task_type: "cf_clearance",
202                inner: v,
203            }
204            .serialize(serializer),
205            TurnstileMode::WaitRoom(v) => Tagged {
206                cloudflare_task_type: "wait_room",
207                inner: v,
208            }
209            .serialize(serializer),
210        }
211    }
212}
213
214/// Cloudflare Turnstile / Challenge / Waiting Room (`TurnstileTask`).
215///
216/// Pick the mode with [`TurnstileMode`] (or pass one of the parameter structs directly):
217/// [`TurnstileStandard`], [`TurnstileChallengeToken`], [`TurnstileChallengeCookie`],
218/// [`TurnstileWaitRoom`]. Each one only has the fields the API accepts for that mode, and
219/// the required ones are constructor arguments.
220#[derive(Debug, Clone, Serialize)]
221pub struct TurnstileRequest {
222    /// URL of the page where the captcha is solved.
223    #[serde(rename = "websiteURL")]
224    pub website_url: String,
225    /// Turnstile site key, e.g. `0x4AAAAAAABUYP0XeMJF0xoy`. For `cf_clearance` / `wait_room`
226    /// any string may be passed (e.g. `xxxxxx`).
227    #[serde(rename = "websiteKey")]
228    pub website_key: String,
229    #[serde(flatten)]
230    pub mode: TurnstileMode,
231}
232
233impl TurnstileRequest {
234    pub fn new(
235        website_url: impl Into<String>,
236        website_key: impl Into<String>,
237        mode: impl Into<TurnstileMode>,
238    ) -> Self {
239        Self {
240            website_url: website_url.into(),
241            website_key: website_key.into(),
242            mode: mode.into(),
243        }
244    }
245
246    /// Regular Turnstile with no optional parameters.
247    pub fn standard(website_url: impl Into<String>, website_key: impl Into<String>) -> Self {
248        Self::new(website_url, website_key, TurnstileStandard::new())
249    }
250}
251
252impl CaptchaRequest for TurnstileRequest {
253    const TASK_TYPE: &'static str = "TurnstileTask";
254
255    fn timeouts(&self) -> GetResultTimeouts {
256        GetResultTimeouts::secs(1.0, 0.0, 1.0, 80.0)
257    }
258
259    fn validate(&self) -> Result<()> {
260        require_non_empty("websiteURL", &self.website_url)?;
261        require_non_empty("websiteKey", &self.website_key)?;
262        match &self.mode {
263            TurnstileMode::Standard(_) => {}
264            TurnstileMode::ChallengeToken(v) => {
265                require_non_empty("pageAction", &v.page_action)?;
266                require_non_empty("userAgent", &v.user_agent)?;
267                require_non_empty("data", &v.data)?;
268                require_non_empty("pageData", &v.page_data)?;
269            }
270            TurnstileMode::ChallengeCookie(v) => {
271                require_non_empty("htmlPageBase64", &v.html_page_base64)?;
272                require_non_empty("userAgent", &v.user_agent)?;
273            }
274            TurnstileMode::WaitRoom(v) => {
275                require_non_empty("htmlPageBase64", &v.html_page_base64)?;
276                require_non_empty("userAgent", &v.user_agent)?;
277            }
278        }
279        Ok(())
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use crate::error::Error;
287    use crate::proxy::ProxyType;
288    use serde_json::json;
289
290    fn proxy() -> ProxyInfo {
291        ProxyInfo::new(ProxyType::Http, "1.2.3.4", 8080).with_auth("u", "p")
292    }
293
294    #[test]
295    fn standard_minimal() {
296        let task = TurnstileRequest::standard("https://example.com", "0x4A")
297            .to_task()
298            .unwrap();
299        assert_eq!(
300            task,
301            json!({"type": "TurnstileTask", "websiteURL": "https://example.com",
302                   "websiteKey": "0x4A"})
303        );
304    }
305
306    #[test]
307    fn standard_full() {
308        let task = TurnstileRequest::new(
309            "https://example.com",
310            "0x4A",
311            TurnstileStandard::new()
312                .with_proxy(proxy())
313                .with_user_agent("UA")
314                .with_page_action("login")
315                .with_data("cdata"),
316        )
317        .to_task()
318        .unwrap();
319        assert_eq!(
320            task,
321            json!({"type": "TurnstileTask", "websiteURL": "https://example.com",
322                   "websiteKey": "0x4A", "userAgent": "UA", "pageAction": "login",
323                   "data": "cdata", "proxyType": "http", "proxyAddress": "1.2.3.4",
324                   "proxyPort": 8080, "proxyLogin": "u", "proxyPassword": "p"})
325        );
326    }
327
328    #[test]
329    fn challenge_token_minimal_and_full() {
330        let minimal = TurnstileRequest::new(
331            "https://example.com",
332            "0x4A",
333            TurnstileChallengeToken::new("managed", "UA", "cdata", "pdata"),
334        )
335        .to_task()
336        .unwrap();
337        assert_eq!(
338            minimal,
339            json!({"type": "TurnstileTask", "websiteURL": "https://example.com",
340                   "websiteKey": "0x4A", "cloudflareTaskType": "token",
341                   "pageAction": "managed", "userAgent": "UA", "data": "cdata",
342                   "pageData": "pdata"})
343        );
344
345        let full = TurnstileRequest::new(
346            "https://example.com",
347            "0x4A",
348            TurnstileChallengeToken::new("managed", "UA", "cdata", "pdata")
349                .with_proxy(proxy())
350                .with_api_js_url("https://challenges.cloudflare.com/api.js"),
351        )
352        .to_task()
353        .unwrap();
354        assert_eq!(
355            full,
356            json!({"type": "TurnstileTask", "websiteURL": "https://example.com",
357                   "websiteKey": "0x4A", "cloudflareTaskType": "token",
358                   "pageAction": "managed", "userAgent": "UA", "data": "cdata",
359                   "pageData": "pdata", "apiJsUrl": "https://challenges.cloudflare.com/api.js",
360                   "proxyType": "http", "proxyAddress": "1.2.3.4", "proxyPort": 8080,
361                   "proxyLogin": "u", "proxyPassword": "p"})
362        );
363    }
364
365    #[test]
366    fn cf_clearance_task() {
367        let task = TurnstileRequest::new(
368            "https://example.com",
369            "xxxxxx",
370            TurnstileChallengeCookie::new("PGh0bWw+", "UA", proxy()),
371        )
372        .to_task()
373        .unwrap();
374        assert_eq!(
375            task,
376            json!({"type": "TurnstileTask", "websiteURL": "https://example.com",
377                   "websiteKey": "xxxxxx", "cloudflareTaskType": "cf_clearance",
378                   "htmlPageBase64": "PGh0bWw+", "userAgent": "UA",
379                   "proxyType": "http", "proxyAddress": "1.2.3.4", "proxyPort": 8080,
380                   "proxyLogin": "u", "proxyPassword": "p"})
381        );
382    }
383
384    #[test]
385    fn wait_room_task() {
386        let task = TurnstileRequest::new(
387            "https://example.com",
388            "xxxxxx",
389            TurnstileWaitRoom::new("PGh0bWw+", "UA", proxy()),
390        )
391        .to_task()
392        .unwrap();
393        assert_eq!(
394            task,
395            json!({"type": "TurnstileTask", "websiteURL": "https://example.com",
396                   "websiteKey": "xxxxxx", "cloudflareTaskType": "wait_room",
397                   "htmlPageBase64": "PGh0bWw+", "userAgent": "UA",
398                   "proxyType": "http", "proxyAddress": "1.2.3.4", "proxyPort": 8080,
399                   "proxyLogin": "u", "proxyPassword": "p"})
400        );
401    }
402
403    #[test]
404    fn empty_html_is_rejected() {
405        let err = TurnstileRequest::new(
406            "https://example.com",
407            "xxxxxx",
408            TurnstileWaitRoom::new("", "UA", proxy()),
409        )
410        .to_task()
411        .unwrap_err();
412        assert!(matches!(err, Error::Validation(_)));
413    }
414
415    #[test]
416    fn empty_required_strings_are_rejected() {
417        let token = |a: &str, u: &str, d: &str, p: &str| {
418            TurnstileRequest::new(
419                "https://example.com",
420                "0x4A",
421                TurnstileChallengeToken::new(a, u, d, p),
422            )
423        };
424        for (req, field) in [
425            (TurnstileRequest::standard("", "0x4A"), "websiteURL"),
426            (
427                TurnstileRequest::standard("https://example.com", ""),
428                "websiteKey",
429            ),
430            (token("", "UA", "d", "p"), "pageAction"),
431            (token("a", "", "d", "p"), "userAgent"),
432            (token("a", "UA", "", "p"), "data"),
433            (token("a", "UA", "d", ""), "pageData"),
434        ] {
435            let err = req.to_task().unwrap_err();
436            assert!(
437                matches!(err, Error::Validation(ref m) if m.contains(field)),
438                "{field}"
439            );
440        }
441        let err = TurnstileRequest::new(
442            "https://example.com",
443            "0x4A",
444            TurnstileChallengeCookie::new("PGh0bWw+", "", proxy()),
445        )
446        .to_task()
447        .unwrap_err();
448        assert!(matches!(err, Error::Validation(ref m) if m.contains("userAgent")));
449    }
450}