mod errors;
mod fairing;
mod models;
mod verification;
use std::{
borrow::Cow, error::Error, fmt::Debug, marker::PhantomData, sync::LazyLock, time::Duration,
};
pub use chrono;
pub use errors::{ReCaptchaError, ReCaptchaErrorCode};
pub use fairing::ReCaptchaFairing;
pub use models::*;
use reqwest::Client;
pub use rocket_client_addr::{self, ClientIp};
use validators::prelude::*;
pub use validators::{self, errors::RegexError};
pub use verification::ReCaptchaVerification;
use verification::ReCaptchaVerificationInner;
pub const API_URL: &str = "https://www.google.com/recaptcha/api/siteverify";
pub const API_URL_RECAPTCHA_NET: &str = "https://www.recaptcha.net/recaptcha/api/siteverify";
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
static DEFAULT_CLIENT: LazyLock<Client> = LazyLock::new(|| {
Client::builder().timeout(DEFAULT_TIMEOUT).build().expect("cannot build an HTTP client")
});
mod sealed {
pub trait Sealed {}
}
pub trait ReCaptchaVariant: sealed::Sealed + Debug + Clone + Sync + Send + 'static {
const VERSION_STR: &'static str;
const FAIRING_NAME: &'static str;
}
#[derive(Debug, Clone, Copy)]
pub struct V3;
impl sealed::Sealed for V3 {}
impl ReCaptchaVariant for V3 {
const FAIRING_NAME: &'static str = "reCAPTCHA v3";
const VERSION_STR: &'static str = "v3";
}
#[derive(Debug, Clone, Copy)]
pub struct V2;
impl sealed::Sealed for V2 {}
impl ReCaptchaVariant for V2 {
const FAIRING_NAME: &'static str = "reCAPTCHA v2";
const VERSION_STR: &'static str = "v2";
}
#[derive(Debug, Clone)]
pub struct ReCaptcha<V: ReCaptchaVariant = V3> {
html_key: Option<ReCaptchaKey>,
secret_key: ReCaptchaKey,
client: Client,
api_url: Cow<'static, str>,
phantom: PhantomData<V>,
}
impl<V: ReCaptchaVariant> ReCaptcha<V> {
#[inline]
pub fn new(html_key: Option<ReCaptchaKey>, secret_key: ReCaptchaKey) -> ReCaptcha<V> {
ReCaptcha {
html_key,
secret_key,
client: DEFAULT_CLIENT.clone(),
api_url: Cow::from(API_URL),
phantom: PhantomData,
}
}
#[inline]
pub fn parse_str<S1: AsRef<str>, S2: AsRef<str>>(
html_key: Option<S1>,
secret_key: S2,
) -> Result<ReCaptcha<V>, RegexError> {
#[allow(clippy::manual_map)]
let html_key = match html_key {
Some(html_key) => Some(ReCaptchaKey::parse_str(html_key.as_ref())?),
None => None,
};
let secret_key = ReCaptchaKey::parse_str(secret_key.as_ref())?;
Ok(ReCaptcha::<V>::new(html_key, secret_key))
}
#[inline]
pub fn parse_string<S1: Into<String>, S2: Into<String>>(
html_key: Option<S1>,
secret_key: S2,
) -> Result<ReCaptcha<V>, RegexError> {
#[allow(clippy::manual_map)]
let html_key = match html_key {
Some(html_key) => Some(ReCaptchaKey::parse_string(html_key.into())?),
None => None,
};
let secret_key = ReCaptchaKey::parse_string(secret_key.into())?;
Ok(ReCaptcha::<V>::new(html_key, secret_key))
}
#[inline]
pub fn html_key(&self) -> Option<&ReCaptchaKey> {
self.html_key.as_ref()
}
#[inline]
pub fn secret_key(&self) -> &ReCaptchaKey {
&self.secret_key
}
#[inline]
pub fn api_url(&self) -> &str {
self.api_url.as_ref()
}
#[inline]
pub fn set_api_url<S: Into<Cow<'static, str>>>(&mut self, api_url: S) {
self.api_url = api_url.into();
}
#[inline]
pub fn set_client(&mut self, client: Client) {
self.client = client;
}
}
impl ReCaptcha {
#[inline]
pub fn fairing() -> ReCaptchaFairing<V3> {
ReCaptchaFairing::<V3>::new()
}
#[inline]
pub fn fairing_v2() -> ReCaptchaFairing<V2> {
ReCaptchaFairing::<V2>::new()
}
}
impl<V: ReCaptchaVariant> ReCaptcha<V> {
pub async fn verify(
&self,
recaptcha_token: &ReCaptchaToken,
remote_ip: Option<&ClientIp>,
) -> Result<ReCaptchaVerification, ReCaptchaError> {
let mut form: Vec<(&str, Cow<str>)> = Vec::with_capacity(3);
form.push(("secret", Cow::from(self.secret_key.as_str())));
form.push(("response", Cow::from(recaptcha_token.as_str())));
if let Some(remote_ip) = remote_ip {
form.push(("remoteip", Cow::from(remote_ip.ip().to_string())));
}
let response = self
.client
.post(self.api_url.as_ref())
.form(&form)
.send()
.await
.map_err(|error| ReCaptchaError::Request(describe_error(&error)))?;
let status = response.status();
if !status.is_success() {
return Err(ReCaptchaError::UnexpectedStatusCode(status.as_u16()));
}
let result: ReCaptchaVerificationInner = response
.json()
.await
.map_err(|error| ReCaptchaError::UnexpectedResponse(describe_error(&error)))?;
if !result.success {
return Err(ReCaptchaError::ErrorCodes(
result
.error_codes
.unwrap_or_default()
.into_iter()
.map(ReCaptchaErrorCode::from)
.collect(),
));
}
let challenge_ts = result.challenge_ts.ok_or_else(|| {
ReCaptchaError::UnexpectedResponse("There is no `challenge_ts` field.".to_string())
})?;
let hostname = result.hostname.ok_or_else(|| {
ReCaptchaError::UnexpectedResponse("There is no `hostname` field.".to_string())
})?;
Ok(ReCaptchaVerification {
score: result.score.unwrap_or(1.0),
action: result.action,
challenge_ts,
hostname,
})
}
}
fn describe_error(error: &dyn Error) -> String {
let mut text = error.to_string();
let mut source = error.source();
while let Some(error) = source {
text.push_str(": ");
text.push_str(&error.to_string());
source = error.source();
}
text
}