cf_turnstile/
lib.rs

1#![doc = include_str!("../README.md")]
2use connector::Connector;
3use error::{SiteVerifyErrors, TurnstileError};
4use http_body_util::{BodyExt, Full};
5use hyper::{
6    body::Bytes,
7    header::{CONTENT_TYPE, USER_AGENT},
8    Method, Request,
9};
10use hyper_util::{client::legacy::Client as HyperClient, rt::TokioExecutor};
11use secrecy::{ExposeSecret, Secret};
12use serde::{Deserialize, Serialize};
13
14mod connector;
15pub mod error;
16
17#[cfg(test)]
18mod test;
19
20/// A client for the Cloudflare Turnstile API.
21pub struct TurnstileClient {
22    secret: Secret<String>,
23    http: HyperClient<Connector, Full<Bytes>>,
24}
25
26/// Represents a request to the Turnstile API.
27///
28/// <https://developers.cloudflare.com/turnstile/get-started/server-side-validation/#accepted-parameters>
29#[derive(Debug, Default, Clone, Serialize, Deserialize)]
30pub struct SiteVerifyRequest {
31    /// The secret key for the Turnstile API.
32    pub secret: Option<String>,
33    /// The response token from the client.
34    pub response: String,
35    /// The remote IP address of the client providing the respose.
36    #[serde(rename = "remote_ip")]
37    pub remote_ip: Option<String>,
38    /// The idempotency key for the request.
39    #[cfg(feature = "idempotency")]
40    pub idempotency_key: Option<uuid::Uuid>,
41}
42
43/// Represents a succerssful response from the Turnstile API.
44///
45/// <https://developers.cloudflare.com/turnstile/get-started/server-side-validation/#error-codes:~:text=Successful%20validation%20response>
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct SiteVerifyResponse {
48    /// Whether the request was successful.
49    pub success: bool,
50    /// The timestamp of the request.
51    #[serde(rename = "challenge_ts")]
52    pub timestamp: String,
53    /// The hostname of the request.
54    pub hostname: String,
55    /// The action that was invoked by the turnstile.
56    pub action: String,
57    /// Data provided by the client.
58    pub cdata: String,
59}
60
61impl From<RawSiteVerifyResponse> for SiteVerifyResponse {
62    fn from(raw: RawSiteVerifyResponse) -> Self {
63        Self {
64            success: raw.success,
65            timestamp: raw.timestamp.unwrap_or_default(),
66            hostname: raw.hostname.unwrap_or_default(),
67            action: raw.action.unwrap_or_default(),
68            cdata: raw.cdata.unwrap_or_default(),
69        }
70    }
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74struct RawSiteVerifyResponse {
75    success: bool,
76    #[serde(rename = "challenge_ts")]
77    timestamp: Option<String>,
78    hostname: Option<String>,
79    #[serde(rename = "error-codes")]
80    error_codes: SiteVerifyErrors,
81    action: Option<String>,
82    cdata: Option<String>,
83}
84
85const TURNSTILE_USER_AGENT: &str = concat!(
86    "cf-turnstile (",
87    env!("CARGO_PKG_HOMEPAGE"),
88    ", ",
89    env!("CARGO_PKG_VERSION"),
90    ")",
91);
92
93impl TurnstileClient {
94    /// Create a new Turnstile client.
95    pub fn new(secret: Secret<String>) -> Self {
96        let connector = connector::create();
97        let http =
98            hyper_util::client::legacy::Client::builder(TokioExecutor::new()).build(connector);
99
100        Self { http, secret }
101    }
102
103    /// Verify a Cloudflare Turnstile response.
104    pub async fn siteverify(
105        &self,
106        request: SiteVerifyRequest,
107    ) -> Result<SiteVerifyResponse, TurnstileError> {
108        // if request secret is none, set it:
109        let request = if request.secret.is_none() {
110            SiteVerifyRequest {
111                secret: Some(self.secret.expose_secret().clone()),
112                ..request
113            }
114        } else {
115            request.clone()
116        };
117
118        let body = Full::new(Bytes::from(serde_json::to_string(&request)?));
119
120        let request = Request::builder()
121            .method(Method::POST)
122            .uri("https://challenges.cloudflare.com/turnstile/v0/siteverify")
123            .header(USER_AGENT, TURNSTILE_USER_AGENT)
124            .header(CONTENT_TYPE, "application/json")
125            .body(body)
126            .expect("request builder");
127
128        let response = self.http.request(request).await?;
129
130        let body_bytes = response.collect().await?.to_bytes();
131        let body = serde_json::from_slice::<RawSiteVerifyResponse>(&body_bytes)?;
132
133        if !body.error_codes.is_empty() {
134            return Err(TurnstileError::SiteVerifyError(body.error_codes));
135        }
136
137        let transformed = SiteVerifyResponse::from(body);
138
139        Ok(transformed)
140    }
141}
142
143/// Generate a new idempotency key.
144#[cfg(feature = "idempotency")]
145pub fn generate_indepotency_key() -> Option<uuid::Uuid> {
146    Some(uuid::Uuid::new_v4())
147}