1#![doc = include_str!("../README.md")]
13#![deny(warnings)]
14#![deny(missing_docs)]
15#![deny(clippy::print_stdout)]
16#![cfg_attr(docsrs, feature(doc_cfg))]
17
18mod captcha_gen;
19mod finder;
20mod storage;
21
22use std::{sync::Arc, time::Duration};
23
24use salvo_core::{
25 handler::{none_skipper, Skipper},
26 Depot, FlowCtrl, Handler, Request, Response,
27};
28pub use {captcha_gen::*, finder::*, storage::*};
29
30pub const CAPTCHA_STATE_KEY: &str = "::salvo_captcha::captcha_state";
32
33#[non_exhaustive]
49pub struct Captcha<S, F>
50where
51 S: CaptchaStorage,
52 F: CaptchaFinder,
53{
54 finder: F,
56 storage: Arc<S>,
58 skipper: Box<dyn Skipper>,
60 case_sensitive: bool,
62}
63
64#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
66pub enum CaptchaState {
67 #[default]
69 Skipped,
70 Passed,
72 TokenNotFound,
74 AnswerNotFound,
76 WrongToken,
78 WrongAnswer,
80 StorageError,
82}
83
84pub struct CaptchaBuilder<S, F>
86where
87 S: CaptchaStorage,
88 F: CaptchaFinder,
89{
90 storage: S,
91 finder: F,
92 captcha_expired_after: Duration,
93 clean_interval: Duration,
94 skipper: Box<dyn Skipper>,
95 case_sensitive: bool,
96}
97
98impl<S, F> CaptchaBuilder<Arc<S>, F>
99where
100 S: CaptchaStorage,
101 F: CaptchaFinder,
102{
103 pub fn new(storage: Arc<S>, finder: F) -> Self {
105 CaptchaBuilder {
106 storage,
107 finder,
108 captcha_expired_after: Duration::from_secs(60 * 5),
109 clean_interval: Duration::from_secs(60),
110 skipper: Box::new(none_skipper),
111 case_sensitive: true,
112 }
113 }
114
115 pub fn case_insensitive(mut self) -> Self {
119 self.case_sensitive = false;
120 self
121 }
122
123 pub fn expired_after(mut self, expired_after: impl Into<Duration>) -> Self {
127 self.captcha_expired_after = expired_after.into();
128 self
129 }
130
131 pub fn clean_interval(mut self, interval: impl Into<Duration>) -> Self {
135 self.clean_interval = interval.into();
136 self
137 }
138
139 pub fn skipper(mut self, skipper: impl Skipper) -> Self {
143 self.skipper = Box::new(skipper);
144 self
145 }
146
147 pub fn build(self) -> Captcha<S, F> {
149 Captcha::new(
150 self.storage,
151 self.finder,
152 self.captcha_expired_after,
153 self.clean_interval,
154 self.skipper,
155 self.case_sensitive,
156 )
157 }
158}
159
160impl<S, F> Captcha<S, F>
161where
162 S: CaptchaStorage,
163 F: CaptchaFinder,
164{
165 fn new(
167 storage: Arc<S>,
168 finder: F,
169 captcha_expired_after: Duration,
170 clean_interval: Duration,
171 skipper: Box<dyn Skipper>,
172 case_sensitive: bool,
173 ) -> Self {
174 let task_storage = Arc::clone(&storage);
175
176 tokio::spawn(async move {
177 loop {
178 if let Err(err) = task_storage.clear_expired(captcha_expired_after).await {
179 log::error!("Captcha storage error: {err}")
180 }
181 tokio::time::sleep(clean_interval).await;
182 }
183 });
184
185 Self {
186 finder,
187 storage,
188 skipper,
189 case_sensitive,
190 }
191 }
192}
193
194pub trait CaptchaDepotExt {
197 fn get_captcha_state(&self) -> CaptchaState;
199}
200
201impl CaptchaDepotExt for Depot {
202 fn get_captcha_state(&self) -> CaptchaState {
203 self.get(CAPTCHA_STATE_KEY).cloned().unwrap_or_default()
204 }
205}
206
207#[salvo_core::async_trait]
208impl<S, F> Handler for Captcha<S, F>
209where
210 S: CaptchaStorage,
211 F: CaptchaFinder,
212{
213 async fn handle(
214 &self,
215 req: &mut Request,
216 depot: &mut Depot,
217 _: &mut Response,
218 _: &mut FlowCtrl,
219 ) {
220 if self.skipper.as_ref().skipped(req, depot) {
221 log::info!("Captcha check is skipped");
222 depot.insert(CAPTCHA_STATE_KEY, CaptchaState::Skipped);
223 return;
224 }
225
226 let token = match self.finder.find_token(req).await {
227 Some(Some(token)) => token,
228 Some(None) => {
229 log::info!("Captcha token is not found in request");
230 depot.insert(CAPTCHA_STATE_KEY, CaptchaState::TokenNotFound);
231 return;
232 }
233 None => {
234 log::error!("Invalid token found in request");
235 depot.insert(CAPTCHA_STATE_KEY, CaptchaState::WrongToken);
236 return;
237 }
238 };
239
240 let answer = match self.finder.find_answer(req).await {
241 Some(Some(answer)) => answer,
242 Some(None) => {
243 log::info!("Captcha answer is not found in request");
244 depot.insert(CAPTCHA_STATE_KEY, CaptchaState::AnswerNotFound);
245 return;
246 }
247 None => {
248 log::error!("Invalid answer found in request");
249 depot.insert(CAPTCHA_STATE_KEY, CaptchaState::WrongAnswer);
250 return;
251 }
252 };
253
254 match self.storage.get_answer(&token).await {
255 Ok(Some(captch_answer)) => {
256 log::info!("Captcha answer is exist in storage for token: {token}");
257 if (captch_answer == answer && self.case_sensitive)
258 || captch_answer.eq_ignore_ascii_case(&answer)
259 {
260 log::info!("Captcha answer is correct for token: {token}");
261 self.storage.clear_by_token(&token).await.ok();
262 depot.insert(CAPTCHA_STATE_KEY, CaptchaState::Passed);
263 } else {
264 log::info!("Captcha answer is wrong for token: {token}");
265 depot.insert(CAPTCHA_STATE_KEY, CaptchaState::WrongAnswer);
266 }
267 }
268 Ok(None) => {
269 log::info!("Captcha answer is not exist in storage for token: {token}");
270 depot.insert(CAPTCHA_STATE_KEY, CaptchaState::WrongToken);
271 }
272 Err(err) => {
273 log::error!("Failed to get captcha answer from storage: {err}");
274 depot.insert(CAPTCHA_STATE_KEY, CaptchaState::StorageError);
275 }
276 };
277 }
278}