#![doc = include_str!("../README.md")]
#![deny(warnings)]
#![deny(missing_docs)]
#![deny(clippy::print_stdout)]
#![cfg_attr(docsrs, feature(doc_cfg))]
mod captcha_gen;
mod finder;
mod storage;
use std::{sync::Arc, time::Duration};
use salvo_core::{
handler::{none_skipper, Skipper},
Depot, FlowCtrl, Handler, Request, Response,
};
pub use {captcha_gen::*, finder::*, storage::*};
pub const CAPTCHA_STATE_KEY: &str = "::salvo_captcha::captcha_state";
#[non_exhaustive]
pub struct Captcha<S, F>
where
S: CaptchaStorage,
F: CaptchaFinder,
{
finder: F,
storage: Arc<S>,
skipper: Box<dyn Skipper>,
case_sensitive: bool,
}
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
pub enum CaptchaState {
#[default]
Skipped,
Passed,
TokenNotFound,
AnswerNotFound,
WrongToken,
WrongAnswer,
StorageError,
}
pub struct CaptchaBuilder<S, F>
where
S: CaptchaStorage,
F: CaptchaFinder,
{
storage: S,
finder: F,
captcha_expired_after: Duration,
clean_interval: Duration,
skipper: Box<dyn Skipper>,
case_sensitive: bool,
}
impl<S, F> CaptchaBuilder<Arc<S>, F>
where
S: CaptchaStorage,
F: CaptchaFinder,
{
pub fn new(storage: Arc<S>, finder: F) -> Self {
CaptchaBuilder {
storage,
finder,
captcha_expired_after: Duration::from_secs(60 * 5),
clean_interval: Duration::from_secs(60),
skipper: Box::new(none_skipper),
case_sensitive: true,
}
}
pub fn case_insensitive(mut self) -> Self {
self.case_sensitive = false;
self
}
pub fn expired_after(mut self, expired_after: impl Into<Duration>) -> Self {
self.captcha_expired_after = expired_after.into();
self
}
pub fn clean_interval(mut self, interval: impl Into<Duration>) -> Self {
self.clean_interval = interval.into();
self
}
pub fn skipper(mut self, skipper: impl Skipper) -> Self {
self.skipper = Box::new(skipper);
self
}
pub fn build(self) -> Captcha<S, F> {
Captcha::new(
self.storage,
self.finder,
self.captcha_expired_after,
self.clean_interval,
self.skipper,
self.case_sensitive,
)
}
}
impl<S, F> Captcha<S, F>
where
S: CaptchaStorage,
F: CaptchaFinder,
{
fn new(
storage: Arc<S>,
finder: F,
captcha_expired_after: Duration,
clean_interval: Duration,
skipper: Box<dyn Skipper>,
case_sensitive: bool,
) -> Self {
let task_storage = Arc::clone(&storage);
tokio::spawn(async move {
loop {
if let Err(err) = task_storage.clear_expired(captcha_expired_after).await {
log::error!("Captcha storage error: {err}")
}
tokio::time::sleep(clean_interval).await;
}
});
Self {
finder,
storage,
skipper,
case_sensitive,
}
}
}
pub trait CaptchaDepotExt {
fn get_captcha_state(&self) -> CaptchaState;
}
impl CaptchaDepotExt for Depot {
fn get_captcha_state(&self) -> CaptchaState {
self.get(CAPTCHA_STATE_KEY).cloned().unwrap_or_default()
}
}
#[salvo_core::async_trait]
impl<S, F> Handler for Captcha<S, F>
where
S: CaptchaStorage,
F: CaptchaFinder,
{
async fn handle(
&self,
req: &mut Request,
depot: &mut Depot,
_: &mut Response,
_: &mut FlowCtrl,
) {
if self.skipper.as_ref().skipped(req, depot) {
log::info!("Captcha check is skipped");
depot.insert(CAPTCHA_STATE_KEY, CaptchaState::Skipped);
return;
}
let token = match self.finder.find_token(req).await {
Some(Some(token)) => token,
Some(None) => {
log::info!("Captcha token is not found in request");
depot.insert(CAPTCHA_STATE_KEY, CaptchaState::TokenNotFound);
return;
}
None => {
log::error!("Invalid token found in request");
depot.insert(CAPTCHA_STATE_KEY, CaptchaState::WrongToken);
return;
}
};
let answer = match self.finder.find_answer(req).await {
Some(Some(answer)) => answer,
Some(None) => {
log::info!("Captcha answer is not found in request");
depot.insert(CAPTCHA_STATE_KEY, CaptchaState::AnswerNotFound);
return;
}
None => {
log::error!("Invalid answer found in request");
depot.insert(CAPTCHA_STATE_KEY, CaptchaState::WrongAnswer);
return;
}
};
match self.storage.get_answer(&token).await {
Ok(Some(captch_answer)) => {
log::info!("Captcha answer is exist in storage for token: {token}");
if (captch_answer == answer && self.case_sensitive)
|| captch_answer.eq_ignore_ascii_case(&answer)
{
log::info!("Captcha answer is correct for token: {token}");
self.storage.clear_by_token(&token).await.ok();
depot.insert(CAPTCHA_STATE_KEY, CaptchaState::Passed);
} else {
log::info!("Captcha answer is wrong for token: {token}");
depot.insert(CAPTCHA_STATE_KEY, CaptchaState::WrongAnswer);
}
}
Ok(None) => {
log::info!("Captcha answer is not exist in storage for token: {token}");
depot.insert(CAPTCHA_STATE_KEY, CaptchaState::WrongToken);
}
Err(err) => {
log::error!("Failed to get captcha answer from storage: {err}");
depot.insert(CAPTCHA_STATE_KEY, CaptchaState::StorageError);
}
};
}
}