use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use crate::solver::oracle::BLOCK_PHRASES;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ValidationVerdict {
Accepted,
Rejected,
Inconclusive,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValidatorMethod {
Post,
Get,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenEncoding {
FormUrlEncoded,
Json,
BearerHeader,
}
#[derive(Debug, Clone)]
pub struct TokenValidator {
pub endpoint: String,
pub method: ValidatorMethod,
pub encoding: TokenEncoding,
pub field: String,
pub timeout: Duration,
}
impl TokenValidator {
pub fn new(endpoint: impl Into<String>) -> Self {
Self {
endpoint: endpoint.into(),
method: ValidatorMethod::Post,
encoding: TokenEncoding::FormUrlEncoded,
field: "cf-turnstile-response".into(),
timeout: Duration::from_secs(10),
}
}
pub fn with_field(mut self, field: impl Into<String>) -> Self {
self.field = field.into();
self
}
pub fn with_encoding(mut self, encoding: TokenEncoding) -> Self {
self.encoding = encoding;
self
}
pub fn with_method(mut self, method: ValidatorMethod) -> Self {
self.method = method;
self
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub async fn validate(&self, token: &str) -> Result<ValidationVerdict> {
let client = reqwest::Client::builder()
.timeout(self.timeout)
.build()?;
let req = match (self.method, self.encoding) {
(ValidatorMethod::Post, TokenEncoding::FormUrlEncoded) => client
.post(&self.endpoint)
.form(&[(self.field.as_str(), token)]),
(ValidatorMethod::Post, TokenEncoding::Json) => client
.post(&self.endpoint)
.json(&serde_json::json!({ self.field.as_str(): token })),
(ValidatorMethod::Post, TokenEncoding::BearerHeader) => client
.post(&self.endpoint)
.bearer_auth(token),
(ValidatorMethod::Get, TokenEncoding::FormUrlEncoded) => client
.get(&self.endpoint)
.query(&[(self.field.as_str(), token)]),
(ValidatorMethod::Get, TokenEncoding::Json) => client
.get(&self.endpoint)
.header("X-Token", token),
(ValidatorMethod::Get, TokenEncoding::BearerHeader) => client
.get(&self.endpoint)
.bearer_auth(token),
};
let response = match req.send().await {
Ok(r) => r,
Err(_) => return Ok(ValidationVerdict::Inconclusive),
};
Ok(classify_response(response.status().as_u16(), response.text().await.ok().as_deref()))
}
}
pub fn classify_response(status: u16, body: Option<&str>) -> ValidationVerdict {
if !(200..300).contains(&status) {
return ValidationVerdict::Rejected;
}
if let Some(body) = body {
let lower = body.to_lowercase();
if BLOCK_PHRASES.iter().any(|p| lower.contains(p)) {
return ValidationVerdict::Rejected;
}
}
ValidationVerdict::Accepted
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_4xx_rejected() {
assert_eq!(classify_response(403, None), ValidationVerdict::Rejected);
assert_eq!(classify_response(429, None), ValidationVerdict::Rejected);
}
#[test]
fn classify_5xx_rejected() {
assert_eq!(classify_response(500, None), ValidationVerdict::Rejected);
assert_eq!(classify_response(503, None), ValidationVerdict::Rejected);
}
#[test]
fn classify_2xx_clean_body_accepted() {
assert_eq!(
classify_response(200, Some("{\"ok\": true}")),
ValidationVerdict::Accepted,
);
assert_eq!(
classify_response(204, None),
ValidationVerdict::Accepted,
);
}
#[test]
fn classify_2xx_with_block_phrase_rejected() {
assert_eq!(
classify_response(200, Some("Sorry, you have been blocked.")),
ValidationVerdict::Rejected,
);
assert_eq!(
classify_response(200, Some("Access Denied")),
ValidationVerdict::Rejected,
);
assert_eq!(
classify_response(200, Some("Pardon Our Interruption")),
ValidationVerdict::Rejected,
);
}
#[test]
fn classify_2xx_block_phrase_case_insensitive() {
assert_eq!(
classify_response(200, Some("REQUEST BLOCKED")),
ValidationVerdict::Rejected,
);
}
#[test]
fn validator_builder_chains() {
let v = TokenValidator::new("https://x.test/v")
.with_field("g-recaptcha-response")
.with_encoding(TokenEncoding::Json)
.with_method(ValidatorMethod::Get)
.with_timeout(Duration::from_secs(2));
assert_eq!(v.endpoint, "https://x.test/v");
assert_eq!(v.field, "g-recaptcha-response");
assert_eq!(v.encoding, TokenEncoding::Json);
assert_eq!(v.method, ValidatorMethod::Get);
assert_eq!(v.timeout, Duration::from_secs(2));
}
#[test]
fn default_field_is_turnstile() {
let v = TokenValidator::new("https://x.test/v");
assert_eq!(v.field, "cf-turnstile-response");
}
}