Compare commits
14 commits
8d1e26c305
...
a294ebfe96
Author | SHA1 | Date | |
---|---|---|---|
a294ebfe96 | |||
29b4b80071 | |||
b67ebe9aea | |||
bc6c08b3ec | |||
472e712c9a | |||
4240da2409 | |||
c6956ad729 | |||
28425d939f | |||
27d985114a | |||
e21ce0d524 | |||
25f90ecfaf | |||
110a8728e1 | |||
0ad044904a | |||
6520ab62e5 |
14 changed files with 700 additions and 129 deletions
15
Cargo.toml
15
Cargo.toml
|
@ -14,14 +14,17 @@ categories = ["web-programming", "network-programming"]
|
|||
|
||||
|
||||
[dependencies]
|
||||
cacache = { version = "13", default-features = false, features = ["tokio-runtime", "mmap"], optional = true }
|
||||
captcha = { version = "0.0.9", default-features = false }
|
||||
log = "0.4"
|
||||
salvo_core = { version = ">= 0.65, < 0.69", default-features = false }
|
||||
uuid = { version = "1.7", features = ["v4"], optional = true }
|
||||
log = "0.4"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
tokio = { version = "1", default-features = false }
|
||||
cacache = { version = "13", default-features = false, features = ["tokio-runtime", "mmap"], optional = true }
|
||||
captcha = { version = "0.0.9", default-features = false, optional = true}
|
||||
either = { version = "1.13.0", default-features = false }
|
||||
|
||||
[features]
|
||||
cacache-storage = ["dep:cacache", "dep:uuid"]
|
||||
cacache-storage = ["dep:cacache"]
|
||||
simple_generator = ["dep:captcha"]
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.9"
|
||||
|
@ -32,4 +35,4 @@ rstest = "0.22.0"
|
|||
|
||||
[[example]]
|
||||
name = "simple_login"
|
||||
required-features = ["cacache-storage"]
|
||||
required-features = ["simple_generator"]
|
||||
|
|
2
Justfile
2
Justfile
|
@ -19,7 +19,7 @@ _default:
|
|||
# Run the CI (Local use only)
|
||||
@ci:
|
||||
cargo fmt --all --check
|
||||
cargo build -F 'cacache-storage' --example simple_login
|
||||
cargo build -F 'simple_generator' --example simple_login
|
||||
cargo clippy --workspace --all-targets --examples --tests --all-features -- -D warnings
|
||||
cargo nextest run --workspace --all-targets --all-features
|
||||
@{{JUST_EXECUTABLE}} msrv
|
||||
|
|
54
README.md
54
README.md
|
@ -2,7 +2,7 @@
|
|||
|
||||
# salvo-captcha
|
||||
|
||||
A captcha middleware for [salvo](salvo.rs) framework. It uses [`captcha`](https://github.com/daniel-e/captcha) crate to generate captcha images.
|
||||
A captcha middleware for [salvo](salvo.rs) framework. With fully customizable captchas generator, storage, and finders
|
||||
|
||||
[![salvo-captcha-video](https://i.suar.me/9NjJ1)](https://ibb.co/XVRVMZj)
|
||||
|
||||
|
@ -14,10 +14,10 @@ First, add the following to your `Cargo.toml`:
|
|||
|
||||
```toml
|
||||
[dependencies]
|
||||
salvo-captcha = "0.1"
|
||||
salvo-captcha = "0.2"
|
||||
```
|
||||
|
||||
Or use [cargo-add](https://doc.rust-lang.org/cargo/commands/cargo-add.html) to add the dependency to your `Cargo.toml`:
|
||||
Or use [`cargo add`] to add the dependency to your `Cargo.toml`:
|
||||
|
||||
```sh
|
||||
$ cargo add salvo-captcha
|
||||
|
@ -25,39 +25,59 @@ $ cargo add salvo-captcha
|
|||
|
||||
## Usage
|
||||
|
||||
See the [examples](examples) directory for a complete example.
|
||||
See the [examples] directory for a complete example. You can also see the implemented generators, storages, and finders in the source code.
|
||||
|
||||
## Storage
|
||||
|
||||
The storage of the captcha is handled by a [`CaptchaStore`] trait. You can implement your own storage or use the [`cacache-rs`] by enabling the `cacache-storage` feature.
|
||||
There is a default storage, which is [`MemoryStorage`] it's a simple in-memory storage. You can implement your own storage by implementing the [`CaptchaStorage`] trait.
|
||||
|
||||
### Cacache Storage
|
||||
|
||||
A high-performance, concurrent, content-addressable disk cache. The storage is based on [`cacache-rs`] crate. to use it, you need to enable the `cacache-storage` feature.
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
salvo-captcha = { version = "0.1", features = ["cacache-storage"] }
|
||||
salvo-captcha = { version = "0.2", features = ["cacache-storage"] }
|
||||
```
|
||||
|
||||
## Captcha name and difficulty
|
||||
## Captcha Finder
|
||||
|
||||
We provide fully customizable query parameters, form fields, and headers to find the captcha token and the captcha answer. You can implement your own finder by implementing the [`CaptchaFinder`] trait.
|
||||
|
||||
## Captcha Generator
|
||||
|
||||
We provide [`SimpleCaptchaGenerator`] which is a simple captcha generator based on the [`captcha`] crate. You can implement your own captcha generator by implementing the [`CaptchaGenerator`] trait.
|
||||
|
||||
### Captcha name and difficulty
|
||||
|
||||
In this table, you can see the difference between the difficulties and the name of the captcha.
|
||||
|
||||
| Name | Easy | Medium | Hard |
|
||||
| :----: | :----------------------------------: | :----------------------------------: | :----------------------------------: |
|
||||
| Amelia | ![Simple](https://i.suar.me/1JaxG/s) | ![Simple](https://i.suar.me/l7zBl/s) | ![Simple](https://i.suar.me/qXAlx/s) |
|
||||
| Lucy | ![Simple](https://i.suar.me/edwBG/s) | ![Simple](https://i.suar.me/NJmg0/s) | ![Simple](https://i.suar.me/OJK7M/s) |
|
||||
| Mila | ![Simple](https://i.suar.me/dO78z/s) | ![Simple](https://i.suar.me/PXBwK/s) | ![Simple](https://i.suar.me/8edgE/s) |
|
||||
| Name | Easy | Medium | Hard |
|
||||
| :-------------: | :----------------------------------: | :----------------------------------: | :----------------------------------: |
|
||||
| Normal | ![Simple](https://i.suar.me/edwBG/s) | ![Simple](https://i.suar.me/NJmg0/s) | ![Simple](https://i.suar.me/OJK7M/s) |
|
||||
| SlightlyTwisted | ![Simple](https://i.suar.me/1JaxG/s) | ![Simple](https://i.suar.me/l7zBl/s) | ![Simple](https://i.suar.me/qXAlx/s) |
|
||||
| VeryTwisted | ![Simple](https://i.suar.me/dO78z/s) | ![Simple](https://i.suar.me/PXBwK/s) | ![Simple](https://i.suar.me/8edgE/s) |
|
||||
|
||||
## Mirrors
|
||||
|
||||
- Github (https://github.com/TheAwiteb/salvo-captcha)
|
||||
- Codeberg (https://codeberg.org/awiteb/salvo-captcha)
|
||||
- Github (<https://github.com/TheAwiteb/salvo-captcha>)
|
||||
- Codeberg (<https://codeberg.org/awiteb/salvo-captcha>)
|
||||
|
||||
### Main Repository
|
||||
|
||||
- My Git (https://git.4rs.nl/awiteb/salvo-captcha)
|
||||
- My Git (<https://git.4rs.nl/awiteb/salvo-captcha>)
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT license for more details see [LICENSE](LICENSE) or http://opensource.org/licenses/MIT.
|
||||
This project is licensed under the MIT license for more details see [LICENSE] or <http://opensource.org/licenses/MIT>.
|
||||
|
||||
[`CaptchaStore`]: https://docs.rs/salvo_captcha/0.1.0/salvo_captcha/trait.CaptchaStore.html
|
||||
[`MemoryStorage`]: https://docs.rs/salvo-captcha/latest/salvo_captcha/struct.MemoryStorage.html
|
||||
[`CaptchaStorage`]: https://docs.rs/salvo-captcha/latest/salvo_captcha/trait.CaptchaStorage.html
|
||||
[`cacache-rs`]: https://github.com/zkat/cacache-rs
|
||||
[`SimpleCaptchaGenerator`]: https://docs.rs/salvo-captcha/latest/salvo_captcha/struct.SimpleCaptchaGenerator.html
|
||||
[`CaptchaGenerator`]: https://docs.rs/salvo-captcha/latest/salvo_captcha/trait.CaptchaGenerator.html
|
||||
[`CaptchaFinder`]: https://docs.rs/salvo-captcha/latest/salvo_captcha/trait.CaptchaFinder.html
|
||||
[examples]: https://git.4rs.nl/awiteb/salvo-captcha/src/branch/master/examples
|
||||
[`captcha`]: https://github.com/daniel-e/captcha
|
||||
[LICENSE]: https://git.4rs.nl/awiteb/salvo-captcha/src/branch/master/LICENSE
|
||||
[`cargo add`]: https://doc.rust-lang.org/cargo/commands/cargo-add.html
|
|
@ -4,7 +4,7 @@
|
|||
//
|
||||
// Run the example with `cargo run --example simple_login --features cacache-storage`
|
||||
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use std::sync::Arc;
|
||||
|
||||
use base64::{engine::GeneralPurpose, Engine};
|
||||
use salvo::prelude::*;
|
||||
|
@ -16,18 +16,22 @@ const BASE_64_ENGINE: GeneralPurpose = GeneralPurpose::new(
|
|||
base64::engine::general_purpose::PAD,
|
||||
);
|
||||
|
||||
const SIMPLE_GENERATOR: SimpleGenerator =
|
||||
SimpleGenerator::new(CaptchaName::Normal, CaptchaDifficulty::Medium);
|
||||
|
||||
#[handler]
|
||||
async fn index(res: &mut Response, depot: &mut Depot) {
|
||||
// Get the captcha from the depot
|
||||
let captcha_storage = depot.obtain::<Arc<CacacheStorage>>().unwrap();
|
||||
let captcha_storage = depot.obtain::<Arc<MemoryStorage>>().unwrap();
|
||||
|
||||
// Create a new captcha
|
||||
let (token, image) = captcha_storage
|
||||
.as_ref()
|
||||
.new_captcha(CaptchaName::Mila, CaptchaDifficulty::Medium)
|
||||
.await
|
||||
.expect("Failed to save captcha")
|
||||
.expect("Failed to create captcha");
|
||||
let Ok((token, image)) = captcha_storage.new_captcha(SIMPLE_GENERATOR).await else {
|
||||
res.status_code(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
res.render(Text::Html(
|
||||
"<html><body><h1>Server Error 500</h1></body></html>",
|
||||
));
|
||||
return;
|
||||
};
|
||||
|
||||
// Convert the image to base64
|
||||
let image = BASE_64_ENGINE.encode(image);
|
||||
|
@ -64,36 +68,16 @@ async fn auth(req: &mut Request, res: &mut Response, depot: &mut Depot) {
|
|||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let captcha_middleware = Captcha::new(
|
||||
CacacheStorage::new("./captcha-cache"),
|
||||
CaptchaFormFinder::new(),
|
||||
)
|
||||
.skipper(|req: &mut Request, _: &Depot| {
|
||||
// Skip the captcha if the request path is /skipped
|
||||
req.uri().path() == "/skipped"
|
||||
});
|
||||
let captcha_storage = Arc::new(captcha_middleware.storage().clone());
|
||||
|
||||
// Set up a task to clean the expired captcha, just call the `clear_expired` function from the storage
|
||||
let captcha_cleaner = tokio::spawn({
|
||||
let cleanner_storage = captcha_storage.clone();
|
||||
async move {
|
||||
let captcha_expired_after = Duration::from_secs(60 * 5);
|
||||
let clean_interval = Duration::from_secs(60);
|
||||
|
||||
// Just this loop, to clean the expired captcha every 60 seconds, each captcha will be expired after 5 minutes
|
||||
loop {
|
||||
cleanner_storage
|
||||
.clear_expired(captcha_expired_after)
|
||||
.await
|
||||
.ok(); // You should log this error
|
||||
tokio::time::sleep(clean_interval).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
let captcha_storage = Arc::new(MemoryStorage::new());
|
||||
let captcha_middleware =
|
||||
CaptchaBuilder::new(Arc::clone(&captcha_storage), CaptchaFormFinder::new())
|
||||
// Skip the captcha if the request path is /skipped
|
||||
.skipper(|req: &mut Request, _: &Depot| req.uri().path() == "/skipped")
|
||||
.case_insensitive()
|
||||
.build();
|
||||
|
||||
let router = Router::new()
|
||||
.hoop(affix::inject(captcha_storage.clone()))
|
||||
.hoop(affix::inject(captcha_storage))
|
||||
.push(Router::with_path("/").get(index))
|
||||
.push(
|
||||
Router::new()
|
||||
|
@ -103,9 +87,7 @@ async fn main() {
|
|||
);
|
||||
|
||||
let acceptor = TcpListener::new(("127.0.0.1", 5800)).bind().await;
|
||||
println!("Starting server on http://127.0.0.1:5800");
|
||||
Server::new(acceptor).serve(router).await;
|
||||
captcha_cleaner.await.ok();
|
||||
}
|
||||
|
||||
fn index_page(captcha_image: String, captcha_token: String) -> String {
|
||||
|
|
|
@ -1,40 +0,0 @@
|
|||
// Copyright (c) 2024, Awiteb <a@4rs.nl>
|
||||
// A captcha middleware for Salvo framework.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
use crate::{CaptchaDifficulty, CaptchaName, CaptchaStorage};
|
||||
|
||||
/// Captcha generator, used to generate a new captcha image. This trait are implemented for all [`CaptchaStorage`].
|
||||
pub trait CaptchaGenerator: CaptchaStorage {
|
||||
/// Create a new captcha image and return the token and the image encoded as png. Will return None if the captcha crate failed to create the captcha.
|
||||
///
|
||||
/// The returned captcha image is 220x110 pixels.
|
||||
///
|
||||
/// For more information about the captcha name and difficulty, see the [`README.md`](https://git.4rs.nl/awiteb/salvo-captcha/#captcha-name-and-difficulty).
|
||||
fn new_captcha(
|
||||
&self,
|
||||
name: CaptchaName,
|
||||
difficulty: CaptchaDifficulty,
|
||||
) -> impl std::future::Future<Output = Result<Option<(String, Vec<u8>)>, Self::Error>> + Send
|
||||
{
|
||||
async {
|
||||
let Some((captcha_answer, captcha_image)) =
|
||||
captcha::by_name(difficulty, name).as_tuple()
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let token = self.store_answer(captcha_answer).await?;
|
||||
Ok(Some((token, captcha_image)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> CaptchaGenerator for T where T: CaptchaStorage {}
|
27
src/captcha_gen/mod.rs
Normal file
27
src/captcha_gen/mod.rs
Normal file
|
@ -0,0 +1,27 @@
|
|||
// Copyright (c) 2024, Awiteb <a@4rs.nl>
|
||||
// A captcha middleware for Salvo framework.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#[cfg(feature = "simple_generator")]
|
||||
mod simple_generator;
|
||||
|
||||
#[cfg(feature = "simple_generator")]
|
||||
pub use simple_generator::*;
|
||||
|
||||
/// Captcha generator, used to generate a new captcha image and answer.
|
||||
pub trait CaptchaGenerator: Send {
|
||||
/// The error type of the captcha generator
|
||||
type Error: std::error::Error;
|
||||
|
||||
/// Create a new captcha image and return the answer and the image encoded as png
|
||||
fn new_captcha(
|
||||
&self,
|
||||
) -> impl std::future::Future<Output = Result<(String, Vec<u8>), Self::Error>> + Send;
|
||||
}
|
107
src/captcha_gen/simple_generator.rs
Normal file
107
src/captcha_gen/simple_generator.rs
Normal file
|
@ -0,0 +1,107 @@
|
|||
// Copyright (c) 2024, Awiteb <a@4rs.nl>
|
||||
// A captcha middleware for Salvo framework.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
use crate::CaptchaGenerator;
|
||||
|
||||
use std::fmt::Display;
|
||||
|
||||
/// Supported captcha names
|
||||
///
|
||||
/// See [`README.md`](https://git.4rs.nl/awiteb/salvo-captcha/#captcha-name-and-difficulty) for more information.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum CaptchaName {
|
||||
/// Plain text, without any distortion
|
||||
Normal,
|
||||
/// Slightly twisted text
|
||||
SlightlyTwisted,
|
||||
/// Very twisted text
|
||||
VeryTwisted,
|
||||
}
|
||||
|
||||
/// Supported captcha difficulties
|
||||
///
|
||||
/// See [`README.md`](https://git.4rs.nl/awiteb/salvo-captcha/#captcha-name-and-difficulty) for more information.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum CaptchaDifficulty {
|
||||
/// Easy to read text
|
||||
Easy,
|
||||
/// Medium difficulty text
|
||||
Medium,
|
||||
/// Hard to read text
|
||||
Hard,
|
||||
}
|
||||
|
||||
impl From<CaptchaName> for captcha::CaptchaName {
|
||||
/// Function to convert the [`CaptchaName`] to the [`captcha::CaptchaName`]
|
||||
fn from(value: CaptchaName) -> Self {
|
||||
match value {
|
||||
CaptchaName::Normal => Self::Lucy,
|
||||
CaptchaName::SlightlyTwisted => Self::Amelia,
|
||||
CaptchaName::VeryTwisted => Self::Mila,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CaptchaDifficulty> for captcha::Difficulty {
|
||||
/// Function to convert the [`CaptchaDifficulty`] to the [`captcha::Difficulty`]
|
||||
fn from(value: CaptchaDifficulty) -> captcha::Difficulty {
|
||||
match value {
|
||||
CaptchaDifficulty::Easy => Self::Easy,
|
||||
CaptchaDifficulty::Medium => Self::Medium,
|
||||
CaptchaDifficulty::Hard => Self::Hard,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// Error type for the [`SimpleGenerator`]
|
||||
pub enum SimpleGeneratorError {
|
||||
/// Failed to encode the captcha to png image
|
||||
FaildEncodedToPng,
|
||||
}
|
||||
|
||||
impl Display for SimpleGeneratorError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Faild to encode the captcha to png image")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SimpleGeneratorError {}
|
||||
|
||||
/// The simple captcha generator
|
||||
pub struct SimpleGenerator {
|
||||
name: CaptchaName,
|
||||
difficulty: CaptchaDifficulty,
|
||||
}
|
||||
|
||||
impl SimpleGenerator {
|
||||
/// Create new [`SimpleGenerator`] instance
|
||||
pub const fn new(name: CaptchaName, difficulty: CaptchaDifficulty) -> Self {
|
||||
Self { name, difficulty }
|
||||
}
|
||||
}
|
||||
|
||||
impl CaptchaGenerator for SimpleGenerator {
|
||||
type Error = SimpleGeneratorError;
|
||||
|
||||
/// The returned captcha image is 220x110 pixels in png format.
|
||||
///
|
||||
/// For more information about the captcha name and difficulty, see the [`README.md`](https://git.4rs.nl/awiteb/salvo-captcha/#captcha-name-and-difficulty).
|
||||
async fn new_captcha(&self) -> Result<(String, Vec<u8>), Self::Error> {
|
||||
let Some((captcha_answer, captcha_image)) =
|
||||
captcha::by_name(self.difficulty.into(), self.name.into()).as_tuple()
|
||||
else {
|
||||
return Err(SimpleGeneratorError::FaildEncodedToPng);
|
||||
};
|
||||
|
||||
Ok((captcha_answer, captcha_image))
|
||||
}
|
||||
}
|
|
@ -110,6 +110,24 @@ mod tests {
|
|||
Some(Some("token")),
|
||||
Some(Some("answer"))
|
||||
)]
|
||||
#[case::only_token(
|
||||
None,
|
||||
None,
|
||||
Some(("captcha_token", "token")),
|
||||
None,
|
||||
"application/x-www-form-urlencoded",
|
||||
Some(Some("token")),
|
||||
None
|
||||
)]
|
||||
#[case::only_answer(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(("captcha_answer", "answer")),
|
||||
"application/x-www-form-urlencoded",
|
||||
None,
|
||||
Some(Some("answer"))
|
||||
)]
|
||||
#[case::custom_not_found(
|
||||
Some("custom_token"),
|
||||
Some("custom_answer"),
|
||||
|
|
|
@ -96,6 +96,22 @@ mod tests {
|
|||
Some(Some("token")),
|
||||
Some(Some("answer"))
|
||||
)]
|
||||
#[case::only_token(
|
||||
None,
|
||||
None,
|
||||
Some(("x-captcha-token", "token")),
|
||||
None,
|
||||
Some(Some("token")),
|
||||
None
|
||||
)]
|
||||
#[case::only_answer(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(("x-captcha-answer", "answer")),
|
||||
None,
|
||||
Some(Some("answer"))
|
||||
)]
|
||||
#[case::custom_not_found(Some("custom-token"), Some("custom-answer"), None, None, None, None)]
|
||||
#[case::custom_not_found_with_headers(
|
||||
Some("custom-token"),
|
||||
|
|
|
@ -13,12 +13,14 @@ use salvo_core::http::Request;
|
|||
|
||||
mod form_finder;
|
||||
mod header_finder;
|
||||
mod query_finder;
|
||||
|
||||
pub use form_finder::*;
|
||||
pub use header_finder::*;
|
||||
pub use query_finder::*;
|
||||
|
||||
/// Trait to find the captcha token and answer from the request.
|
||||
pub trait CaptchaFinder: Send + Sync {
|
||||
pub trait CaptchaFinder: Send + Sync + 'static {
|
||||
/// Find the captcha token from the request.
|
||||
///
|
||||
/// ### Returns
|
||||
|
|
151
src/finder/query_finder.rs
Normal file
151
src/finder/query_finder.rs
Normal file
|
@ -0,0 +1,151 @@
|
|||
// Copyright (c) 2024, Awiteb <a@4rs.nl>
|
||||
// A captcha middleware for Salvo framework.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
use salvo_core::http::Request;
|
||||
|
||||
use crate::CaptchaFinder;
|
||||
|
||||
/// Find the captcha token and answer from the url query
|
||||
#[derive(Debug)]
|
||||
pub struct CaptchaQueryFinder {
|
||||
/// The query name of the captcha token
|
||||
///
|
||||
/// Default: "c_t"
|
||||
pub token_name: String,
|
||||
|
||||
/// The query name of the captcha answer
|
||||
///
|
||||
/// Default: "c_a"
|
||||
pub answer_name: String,
|
||||
}
|
||||
|
||||
impl CaptchaQueryFinder {
|
||||
/// Create a new [`CaptchaQueryFinder`]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Set the token query name
|
||||
pub fn token_name(mut self, token_name: String) -> Self {
|
||||
self.token_name = token_name;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the answer query name
|
||||
pub fn answer_name(mut self, answer_name: String) -> Self {
|
||||
self.answer_name = answer_name;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CaptchaQueryFinder {
|
||||
/// Create a default [`CaptchaQueryFinder`] with:
|
||||
/// - token_name: "c_t"
|
||||
/// - answer_name: "c_a"
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
token_name: "c_t".to_string(),
|
||||
answer_name: "c_a".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CaptchaFinder for CaptchaQueryFinder {
|
||||
async fn find_token(&self, req: &mut Request) -> Option<Option<String>> {
|
||||
req.queries()
|
||||
.get(&self.token_name)
|
||||
.map(|o| Some(o.to_owned()))
|
||||
}
|
||||
|
||||
async fn find_answer(&self, req: &mut Request) -> Option<Option<String>> {
|
||||
req.queries()
|
||||
.get(&self.answer_name)
|
||||
.map(|o| Some(o.to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[rstest::rstest]
|
||||
#[case::not_found(None, None, None, None, None, None)]
|
||||
#[case::normal(
|
||||
None,
|
||||
None,
|
||||
Some(("c_t", "token")),
|
||||
Some(("c_a", "answer")),
|
||||
Some(Some("token")),
|
||||
Some(Some("answer"))
|
||||
)]
|
||||
#[case::custom_keys(
|
||||
Some("cc_t"),
|
||||
Some("cc_a"),
|
||||
Some(("cc_t", "token")),
|
||||
Some(("cc_a", "answer")),
|
||||
Some(Some("token")),
|
||||
Some(Some("answer"))
|
||||
)]
|
||||
#[case::only_token(
|
||||
None,
|
||||
None,
|
||||
Some(("c_t", "token")),
|
||||
None,
|
||||
Some(Some("token")),
|
||||
None
|
||||
)]
|
||||
#[case::only_answer(None, None, None, Some(("c_a", "ans")), None, Some(Some("ans")))]
|
||||
#[case::custom_not_found(Some("cc_t"), Some("cc_a"), None, None, None, None)]
|
||||
#[case::custom_not_found_with_query(
|
||||
Some("cc_t"),
|
||||
Some("cc_a"),
|
||||
Some(("c_t", "token")),
|
||||
Some(("c_a", "answer")),
|
||||
None,
|
||||
None
|
||||
)]
|
||||
async fn test_query_finder(
|
||||
#[case] custom_token_key: Option<&'static str>,
|
||||
#[case] custom_answer_key: Option<&'static str>,
|
||||
#[case] token_key_val: Option<(&'static str, &'static str)>,
|
||||
#[case] answer_key_val: Option<(&'static str, &'static str)>,
|
||||
#[case] excepted_token: Option<Option<&'static str>>,
|
||||
#[case] excepted_answer: Option<Option<&'static str>>,
|
||||
) {
|
||||
let mut req = Request::default();
|
||||
let mut finder = CaptchaQueryFinder::new();
|
||||
if let Some(token_key) = custom_token_key {
|
||||
finder = finder.token_name(token_key.to_string())
|
||||
}
|
||||
if let Some(answer_key) = custom_answer_key {
|
||||
finder = finder.answer_name(answer_key.to_string())
|
||||
}
|
||||
|
||||
let queries = req.queries_mut();
|
||||
|
||||
if let Some((k, v)) = token_key_val {
|
||||
queries.insert(k.to_owned(), v.to_owned());
|
||||
}
|
||||
if let Some((k, v)) = answer_key_val {
|
||||
queries.insert(k.to_owned(), v.to_owned());
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
finder.find_token(&mut req).await,
|
||||
excepted_token.map(|o| o.map(ToOwned::to_owned))
|
||||
);
|
||||
assert_eq!(
|
||||
finder.find_answer(&mut req).await,
|
||||
excepted_answer.map(|o| o.map(ToOwned::to_owned))
|
||||
);
|
||||
}
|
||||
}
|
128
src/lib.rs
128
src/lib.rs
|
@ -18,21 +18,19 @@ 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::*};
|
||||
|
||||
// Exports from other crates
|
||||
pub use captcha::{CaptchaName, Difficulty as CaptchaDifficulty};
|
||||
|
||||
/// Key used to insert the captcha state into the depot
|
||||
pub const CAPTCHA_STATE_KEY: &str = "::salvo_captcha::captcha_state";
|
||||
|
||||
/// Captcha struct, contains the token and answer.
|
||||
#[non_exhaustive]
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub struct Captcha<S, F>
|
||||
where
|
||||
S: CaptchaStorage,
|
||||
|
@ -41,9 +39,11 @@ where
|
|||
/// The captcha finder, used to find the captcha token and answer from the request.
|
||||
finder: F,
|
||||
/// The storage of the captcha, used to store and get the captcha token and answer.
|
||||
storage: S,
|
||||
storage: Arc<S>,
|
||||
/// The skipper of the captcha, used to skip the captcha check.
|
||||
skipper: Box<dyn Skipper>,
|
||||
/// The case sensitive of the captcha answer.
|
||||
case_sensitive: bool,
|
||||
}
|
||||
|
||||
/// The captcha states of the request
|
||||
|
@ -66,30 +66,114 @@ pub enum CaptchaState {
|
|||
StorageError,
|
||||
}
|
||||
|
||||
/// The [`Captcha`] builder
|
||||
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,
|
||||
{
|
||||
/// Create a new [`CaptchaBuilder`] with the given storage and finder.
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the case sensitive of the captcha, default is case sensitive.
|
||||
///
|
||||
/// This will make the captcha case insensitive, for example, the answer "Hello" will be the same as "hello".
|
||||
pub fn case_insensitive(mut self) -> Self {
|
||||
self.case_sensitive = false;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the duration after which the captcha will be expired, default is 5 minutes.
|
||||
///
|
||||
/// After the captcha is expired, it will be removed from the storage, and the user needs to get a new captcha.
|
||||
pub fn expired_after(mut self, expired_after: impl Into<Duration>) -> Self {
|
||||
self.captcha_expired_after = expired_after.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the interval to clean the expired captcha, default is 1 minute.
|
||||
///
|
||||
/// The expired captcha will be removed from the storage every interval.
|
||||
pub fn clean_interval(mut self, interval: impl Into<Duration>) -> Self {
|
||||
self.clean_interval = interval.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the skipper of the captcha, default without skipper.
|
||||
///
|
||||
/// The skipper is used to skip the captcha check, for example, you can skip the captcha check for the admin user.
|
||||
pub fn skipper(mut self, skipper: impl Skipper) -> Self {
|
||||
self.skipper = Box::new(skipper);
|
||||
self
|
||||
}
|
||||
|
||||
/// Build the [`Captcha`] with the given configuration.
|
||||
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,
|
||||
{
|
||||
/// Create a new Captcha
|
||||
pub fn new(storage: S, finder: F) -> Self {
|
||||
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: Box::new(none_skipper),
|
||||
skipper,
|
||||
case_sensitive,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the captcha storage
|
||||
pub fn storage(&self) -> &S {
|
||||
&self.storage
|
||||
}
|
||||
|
||||
/// Set the captcha skipper, the skipper will be used to check if the captcha check should be skipped.
|
||||
pub fn skipper(mut self, skipper: impl Skipper) -> Self {
|
||||
self.skipper = Box::new(skipper);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// The captcha extension of the depot.
|
||||
|
@ -109,7 +193,7 @@ impl CaptchaDepotExt for Depot {
|
|||
impl<S, F> Handler for Captcha<S, F>
|
||||
where
|
||||
S: CaptchaStorage,
|
||||
F: CaptchaFinder + 'static, // why?
|
||||
F: CaptchaFinder,
|
||||
{
|
||||
async fn handle(
|
||||
&self,
|
||||
|
@ -118,7 +202,7 @@ where
|
|||
_: &mut Response,
|
||||
_: &mut FlowCtrl,
|
||||
) {
|
||||
if self.skipper.skipped(req, depot) {
|
||||
if self.skipper.as_ref().skipped(req, depot) {
|
||||
log::info!("Captcha check is skipped");
|
||||
depot.insert(CAPTCHA_STATE_KEY, CaptchaState::Skipped);
|
||||
return;
|
||||
|
@ -155,7 +239,9 @@ where
|
|||
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 {
|
||||
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);
|
||||
|
|
182
src/storage/memory_storage.rs
Normal file
182
src/storage/memory_storage.rs
Normal file
|
@ -0,0 +1,182 @@
|
|||
// Copyright (c) 2024, Awiteb <a@4rs.nl>
|
||||
// A captcha middleware for Salvo framework.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#![allow(warnings)]
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
convert::Infallible,
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::CaptchaStorage;
|
||||
|
||||
/// Captcha storage implementation using an in-memory HashMap.
|
||||
#[derive(Debug)]
|
||||
pub struct MemoryStorage(RwLock<HashMap<String, (u64, String)>>);
|
||||
|
||||
impl MemoryStorage {
|
||||
/// Create a new instance of [`MemoryStorage`].
|
||||
pub fn new() -> Self {
|
||||
Self(RwLock::new(HashMap::new()))
|
||||
}
|
||||
}
|
||||
|
||||
impl CaptchaStorage for MemoryStorage {
|
||||
type Error = Infallible;
|
||||
|
||||
async fn store_answer(&self, answer: String) -> Result<String, Self::Error> {
|
||||
let token = uuid::Uuid::new_v4().to_string();
|
||||
let mut write_lock = self.0.write().await;
|
||||
write_lock.insert(token.clone(), (now(), answer));
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
async fn get_answer(&self, token: &str) -> Result<Option<String>, Self::Error> {
|
||||
let reader = self.0.read().await;
|
||||
Ok(reader.get(token).map(|(_, answer)| answer.to_owned()))
|
||||
}
|
||||
|
||||
async fn clear_expired(&self, expired_after: Duration) -> Result<(), Self::Error> {
|
||||
let expired_after = now() - expired_after.as_secs();
|
||||
|
||||
let mut write_lock = self.0.write().await;
|
||||
write_lock.retain(|_, (timestamp, _)| *timestamp > expired_after);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn clear_by_token(&self, token: &str) -> Result<(), Self::Error> {
|
||||
let mut write_lock = self.0.write().await;
|
||||
write_lock.retain(|c_token, (_, _)| c_token != token);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn now() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("SystemTime before UNIX EPOCH!")
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn memory_store_captcha() {
|
||||
let storage = MemoryStorage::new();
|
||||
|
||||
let token = storage
|
||||
.store_answer("answer".to_owned())
|
||||
.await
|
||||
.expect("failed to store captcha");
|
||||
assert_eq!(
|
||||
storage
|
||||
.get_answer(&token)
|
||||
.await
|
||||
.expect("failed to get captcha answer"),
|
||||
Some("answer".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn memory_clear_expired() {
|
||||
let storage = MemoryStorage::new();
|
||||
|
||||
let token = storage
|
||||
.store_answer("answer".to_owned())
|
||||
.await
|
||||
.expect("failed to store captcha");
|
||||
storage
|
||||
.clear_expired(Duration::from_secs(0))
|
||||
.await
|
||||
.expect("failed to clear expired captcha");
|
||||
assert!(storage
|
||||
.get_answer(&token)
|
||||
.await
|
||||
.expect("failed to get captcha answer")
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn memory_clear_by_token() {
|
||||
let storage = MemoryStorage::new();
|
||||
|
||||
let token = storage
|
||||
.store_answer("answer".to_owned())
|
||||
.await
|
||||
.expect("failed to store captcha");
|
||||
storage
|
||||
.clear_by_token(&token)
|
||||
.await
|
||||
.expect("failed to clear captcha by token");
|
||||
assert!(storage
|
||||
.get_answer(&token)
|
||||
.await
|
||||
.expect("failed to get captcha answer")
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn memory_is_token_exist() {
|
||||
let storage = MemoryStorage::new();
|
||||
|
||||
let token = storage
|
||||
.store_answer("answer".to_owned())
|
||||
.await
|
||||
.expect("failed to store captcha");
|
||||
assert!(storage
|
||||
.get_answer(&token)
|
||||
.await
|
||||
.expect("failed to check if token is exist")
|
||||
.is_some());
|
||||
assert!(storage
|
||||
.get_answer("token")
|
||||
.await
|
||||
.expect("failed to check if token is exist")
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn memory_clear_expired_with_expired_after() {
|
||||
let storage = MemoryStorage::new();
|
||||
|
||||
let token = storage
|
||||
.store_answer("answer".to_owned())
|
||||
.await
|
||||
.expect("failed to store captcha");
|
||||
storage
|
||||
.clear_expired(Duration::from_secs(1))
|
||||
.await
|
||||
.expect("failed to clear expired captcha");
|
||||
assert_eq!(
|
||||
storage
|
||||
.get_answer(&token)
|
||||
.await
|
||||
.expect("failed to get captcha answer"),
|
||||
Some("answer".to_owned())
|
||||
);
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
storage
|
||||
.clear_expired(Duration::from_secs(1))
|
||||
.await
|
||||
.expect("failed to clear expired captcha");
|
||||
assert!(storage
|
||||
.get_answer(&token)
|
||||
.await
|
||||
.expect("failed to get captcha answer")
|
||||
.is_none());
|
||||
}
|
||||
}
|
|
@ -13,21 +13,20 @@ use std::{sync::Arc, time::Duration};
|
|||
|
||||
#[cfg(feature = "cacache-storage")]
|
||||
mod cacache_storage;
|
||||
mod memory_storage;
|
||||
|
||||
#[cfg(feature = "cacache-storage")]
|
||||
pub use cacache_storage::*;
|
||||
pub use memory_storage::*;
|
||||
|
||||
/// Trait to store the captcha token and answer. is also clear the expired captcha.
|
||||
///
|
||||
/// The trait will be implemented for `Arc<T>` if `T` implements the trait.
|
||||
///
|
||||
/// The trait is thread safe, so the storage can be shared between threads.
|
||||
pub trait CaptchaStorage: Send + Sync + 'static
|
||||
where
|
||||
Self: Clone + std::fmt::Debug,
|
||||
{
|
||||
pub trait CaptchaStorage: Send + Sync + 'static {
|
||||
/// The error type of the storage.
|
||||
type Error: std::fmt::Display + std::fmt::Debug + Send;
|
||||
type Error: std::error::Error + Send;
|
||||
|
||||
/// Store the captcha token and answer.
|
||||
fn store_answer(
|
||||
|
@ -52,6 +51,24 @@ where
|
|||
&self,
|
||||
token: &str,
|
||||
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
|
||||
|
||||
/// Create a new captcha image and return the answer and the image encoded as png.
|
||||
///
|
||||
/// This method will store the answer in the storage.
|
||||
fn new_captcha<G: crate::CaptchaGenerator>(
|
||||
&self,
|
||||
generator: G,
|
||||
) -> impl std::future::Future<
|
||||
Output = Result<(String, Vec<u8>), either::Either<Self::Error, G::Error>>,
|
||||
> + Send {
|
||||
async move {
|
||||
let (answer, image) = generator.new_captcha().await.map_err(either::Right)?;
|
||||
Ok((
|
||||
self.store_answer(answer).await.map_err(either::Left)?,
|
||||
image,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> CaptchaStorage for Arc<T>
|
||||
|
|
Loading…
Reference in a new issue