Compare commits

..

No commits in common. "79ff05d91dcf2c7a457ab7832baae8312b263b10" and "eb295d5b25d227d2d4d635ff6a5902d860719482" have entirely different histories.

12 changed files with 322 additions and 282 deletions

View file

@ -1,25 +0,0 @@
name: Rust CI
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
rust_ci:
name: Rust CI
runs-on: debian
steps:
- uses: actions/checkout@v4
- uses: https://codeberg.org/TheAwiteb/rust-action@v1.75
- name: Check MSRV
run: cargo +1.75 build
- name: Build the source code
run: cargo build
- name: Check the code format
run: cargo fmt -- --check
- name: Run cargo-check
run: cargo check
- name: Run cargo-clippy
run: cargo clippy -- -D warnings

1
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1 @@
github: "TheAwiteb"

View file

@ -1,34 +0,0 @@
name: Auto close PR
on:
pull_request:
types: [opened, reopened]
jobs:
close_pr:
name: Auto close PR
runs-on: ubuntu-latest
steps:
- name: Send close comment
run: |
curl -L \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $PAT" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.number }}/comments \
-d '{"body":"${{ env.BODY }}"}'
env:
PAT: ${{ secrets.PAT }}
BODY: This repository is mirror only and you cannot create a pull request for it. Please open your PR in https://git.4rs.nl/awiteb/salvo-captcha
- name: Close the PR
run: |
curl -L \
-X PATCH \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $PAT" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.number }} \
-d '{"state":"closed"}'
env:
PAT: ${{ secrets.PAT }}

35
.github/workflows/cd.yml vendored Normal file
View file

@ -0,0 +1,35 @@
name: CD
on:
push:
tags:
- v[0-9]+.[0-9]+.[0-9]+
jobs:
release:
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: dtolnay/rust-toolchain@stable
- name: Build Changelog 🏗
id: changelog
uses: mikepenz/release-changelog-builder-action@v4.0.0
with:
configuration: "./.github/config/changelog-builder-action.json"
env:
GITHUB_TOKEN: ${{ secrets.PAT }}
- name: Create Release 🖋
uses: softprops/action-gh-release@v1
with:
prerelease: ${{ contains(github.ref, 'rc') || contains(github.ref, 'pre') }}
body: ${{steps.changelog.outputs.changelog}}
env:
GITHUB_TOKEN: ${{ secrets.PAT }}
- name: Publish to crates.io 🚀
uses: katyo/publish-crates@v2
with:
token: ${{ secrets.PAT }}
registry-token: ${{ secrets.CARGO_REGISTRY_TOKEN }}

44
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,44 @@
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
build:
name: Rust build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: dtolnay/rust-toolchain@stable
- run: cargo build --workspace --all-features --all-targets
rustfmt:
name: Rust format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: dtolnay/rust-toolchain@stable
- run: cargo fmt -- --check
check:
name: Rust check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: dtolnay/rust-toolchain@stable
- run: cargo check --workspace --all-features --all-targets
clippy:
name: Rust clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: dtolnay/rust-toolchain@stable
- run: cargo clippy --workspace --all-features -- -D warnings
test:
name: Rust test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: dtolnay/rust-toolchain@stable
- run: cargo test --workspace --all-features

View file

@ -14,21 +14,19 @@ categories = ["web-programming", "network-programming"]
[dependencies]
cacache = { version = "13", default-features = false, features = ["tokio-runtime", "mmap"], optional = true }
async-trait = "0.1.77"
cacache = { version = "12.0.0", 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 }
easy-ext = "1.0.1"
log = "0.4.20"
salvo_core = "^ 0.65"
uuid = { version = "1.7.0", features = ["v4"], optional = true }
[features]
cacache-storage = ["dep:cacache", "dep:uuid"]
[dev-dependencies]
tempfile = "3.9"
tokio = { version = "1.35", features = ["macros", "rt-multi-thread"] }
base64 = "0.21"
salvo = { version = ">= 0.65, < 0.69", default-features = false, features = ["server", "http1","http2", "affix"] }
[[example]]
name = "simple_login"
required-features = ["cacache-storage"]
tempfile = ">= 3.9"
tokio = { version = ">= 1.35", features = ["macros", "rt-multi-thread"] }
base64 = ">= 0.21"
salvo = { version = ">= 0.65", features = ["affix"] }

View file

@ -8,8 +8,7 @@ A captcha middleware for [salvo](salvo.rs) framework. It uses [`captcha`](https:
</div>
## Add to your project
### Add to your project
First, add the following to your `Cargo.toml`:
```toml
@ -23,41 +22,36 @@ Or use [cargo-add](https://doc.rust-lang.org/cargo/commands/cargo-add.html) to a
$ cargo add salvo-captcha
```
## Usage
### Usage
See the [examples](examples) directory for a complete example.
## 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.
### 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.
```toml
[dependencies]
salvo-captcha = { version = "0.1", features = ["cacache-storage"] }
```
## Captcha name and difficulty
### Captcha name and difficulty
In this table you can see the different between the difficulties and the name of the captcha.
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) |
## Mirrors
- 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)
## License
| Name | Difficulty | Image |
|------|------------|-------|
| `Amelia` | Easy | ![Simple](https://i.suar.me/1JaxG/s) |
| `Amelia` | Medium | ![Simple](https://i.suar.me/l7zBl/s) |
| `Amelia` | Hard | ![Simple](https://i.suar.me/qXAlx/s) |
| `Lucy` | Easy | ![Simple](https://i.suar.me/edwBG/s) |
| `Lucy` | Medium | ![Simple](https://i.suar.me/NJmg0/s) |
| `Lucy` | Hard | ![Simple](https://i.suar.me/OJK7M/s) |
| `Mila` | Easy | ![Simple](https://i.suar.me/dO78z/s) |
| `Mila` | Medium | ![Simple](https://i.suar.me/PXBwK/s) |
| `Mila` | Hard | ![Simple](https://i.suar.me/8edgE/s) |
### License
This project is licensed under the MIT license for more details see [LICENSE](LICENSE) or http://opensource.org/licenses/MIT.
[`CaptchaStore`]: https://docs.rs/salvo_captcha/0.1.0/salvo_captcha/trait.CaptchaStore.html
[`cacache-rs`]: https://github.com/zkat/cacache-rs
[cacache-rs]: https://github.com/zkat/cacache-rs

View file

@ -3,6 +3,15 @@
// You can see a video of this example here:
//
// Run the example with `cargo run --example simple_login --features cacache-storage`
//
// Or set up a crate for it, with the following `Cargo.toml`:
// ```toml
// [dependencies]
// base64 = ">= 0.21"
// salvo = { version = ">= 0.65", features = ["affix"] }
// salvo-captcha = { version = ">= 0.1", features = ["cacache-storage"] }
// tokio = { version = ">= 1.35", features = ["macros", "rt-multi-thread", "time"] }
// ```
use std::{sync::Arc, time::Duration};
@ -10,6 +19,9 @@ use base64::{engine::GeneralPurpose, Engine};
use salvo::prelude::*;
use salvo_captcha::*;
// The type of the captcha
type MyCaptcha = Captcha<CacacheStorage, CaptchaFormFinder<String, String>>;
// To convert the image to base64, to show it in the browser
const BASE_64_ENGINE: GeneralPurpose = GeneralPurpose::new(
&base64::alphabet::STANDARD,
@ -17,7 +29,7 @@ const BASE_64_ENGINE: GeneralPurpose = GeneralPurpose::new(
);
#[handler]
async fn index(res: &mut Response, depot: &mut Depot) {
async fn index(_req: &mut Request, res: &mut Response, depot: &mut Depot) {
// Get the captcha from the depot
let captcha_storage = depot.obtain::<Arc<CacacheStorage>>().unwrap();
@ -39,7 +51,7 @@ async fn index(res: &mut Response, depot: &mut Depot) {
#[handler]
async fn auth(req: &mut Request, res: &mut Response, depot: &mut Depot) {
// Get the captcha state from the depot, where we can know if the captcha is passed
let captcha_state = depot.get_captcha_state();
let captcha_state = depot.get_captcha_state().unwrap();
// Not important, just for demo
let Some(username) = req.form::<String>("username").await else {
res.status_code(StatusCode::BAD_REQUEST);
@ -64,7 +76,7 @@ async fn auth(req: &mut Request, res: &mut Response, depot: &mut Depot) {
#[tokio::main]
async fn main() {
let captcha_middleware = Captcha::new(
let captcha_middleware = MyCaptcha::new(
CacacheStorage::new("./captcha-cache"),
CaptchaFormFinder::new(),
)
@ -103,7 +115,6 @@ 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();
}

View file

@ -1,14 +1,3 @@
// 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`].
@ -22,7 +11,7 @@ pub trait CaptchaGenerator: CaptchaStorage {
&self,
name: CaptchaName,
difficulty: CaptchaDifficulty,
) -> impl std::future::Future<Output = Result<Option<(String, Vec<u8>)>, Self::Error>> + Send
) -> impl std::future::Future<Output = Result<Option<(Self::Token, Vec<u8>)>, Self::Error>> + Send
{
async {
let Some((captcha_answer, captcha_image)) =
@ -31,7 +20,7 @@ pub trait CaptchaGenerator: CaptchaStorage {
return Ok(None);
};
let token = self.store_answer(captcha_answer).await?;
let token = self.store_answer(captcha_answer.into()).await?;
Ok(Some((token, captcha_image)))
}
}

View file

@ -9,37 +9,49 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
use std::marker::PhantomData;
use salvo_core::http::header::HeaderName;
use salvo_core::http::Request;
/// Trait to find the captcha token and answer from the request.
pub trait CaptchaFinder: Send + Sync {
/// The token type
type Token: TryFrom<String> + Sync + Send;
/// The answer type
type Answer: TryFrom<String> + Sync + Send;
/// The token error type
type TError: std::fmt::Debug + Send;
/// The answer error type
type AError: std::fmt::Debug + Send;
/// Find the captcha token from the request.
///
/// ### Returns
/// - None: If the token is not found
/// - Some(None): If the token is found but is invalid (e.g. not a valid string)
/// - Some(Some(token)): If the token is found
/// Return [`None`] if the request does not contain a captcha token. An error is returned if the token is invalid format.
fn find_token(
&self,
req: &mut Request,
) -> impl std::future::Future<Output = Option<Option<String>>> + std::marker::Send;
) -> impl std::future::Future<Output = Result<Option<Self::Token>, Self::TError>> + std::marker::Send;
/// Find the captcha answer from the request.
///
/// ### Returns
/// - None: If the answer is not found
/// - Some(None): If the answer is found but is invalid (e.g. not a valid string)
/// - Some(Some(answer)): If the answer is found
/// Return [`None`] if the request does not contain a captcha answer. An error is returned if the answer is invalid format.
fn find_answer(
&self,
req: &mut Request,
) -> impl std::future::Future<Output = Option<Option<String>>> + std::marker::Send;
) -> impl std::future::Future<Output = Result<Option<Self::Answer>, Self::AError>> + std::marker::Send;
}
/// Find the captcha token and answer from the header
#[derive(Debug)]
pub struct CaptchaHeaderFinder {
pub struct CaptchaHeaderFinder<T, A>
where
T: TryFrom<String> + Sync + Send,
A: TryFrom<String> + Sync + Send,
{
phantom: PhantomData<(T, A)>,
/// The header name of the captcha token
///
/// Default: "x-captcha-token"
@ -53,7 +65,13 @@ pub struct CaptchaHeaderFinder {
/// Find the captcha token and answer from the form
#[derive(Debug)]
pub struct CaptchaFormFinder {
pub struct CaptchaFormFinder<T, A>
where
T: TryFrom<String> + Sync + Send,
A: TryFrom<String> + Sync + Send,
{
phantom: PhantomData<(T, A)>,
/// The form name of the captcha token
///
/// Default: "captcha_token"
@ -65,7 +83,11 @@ pub struct CaptchaFormFinder {
pub answer_name: String,
}
impl CaptchaHeaderFinder {
impl<T, A> CaptchaHeaderFinder<T, A>
where
T: TryFrom<String> + Sync + Send,
A: TryFrom<String> + Sync + Send,
{
/// Create a new CaptchaHeaderFinder
pub fn new() -> Self {
Self::default()
@ -84,7 +106,11 @@ impl CaptchaHeaderFinder {
}
}
impl CaptchaFormFinder {
impl<T, A> CaptchaFormFinder<T, A>
where
T: TryFrom<String> + Sync + Send,
A: TryFrom<String> + Sync + Send,
{
/// Create a new CaptchaFormFinder
pub fn new() -> Self {
Self::default()
@ -103,57 +129,99 @@ impl CaptchaFormFinder {
}
}
impl Default for CaptchaHeaderFinder {
impl<T, A> Default for CaptchaHeaderFinder<T, A>
where
T: TryFrom<String> + Sync + Send,
A: TryFrom<String> + Sync + Send,
{
/// Create a default CaptchaHeaderFinder with:
/// - token_header: "x-captcha-token"
/// - answer_header: "x-captcha-answer"
fn default() -> Self {
Self {
phantom: PhantomData,
token_header: HeaderName::from_static("x-captcha-token"),
answer_header: HeaderName::from_static("x-captcha-answer"),
}
}
}
impl Default for CaptchaFormFinder {
impl<T, A> Default for CaptchaFormFinder<T, A>
where
T: TryFrom<String> + Sync + Send,
A: TryFrom<String> + Sync + Send,
{
/// Create a default CaptchaFormFinder with:
/// - token_name: "captcha_token"
/// - answer_name: "captcha_answer"
fn default() -> Self {
Self {
phantom: PhantomData,
token_name: "captcha_token".to_string(),
answer_name: "captcha_answer".to_string(),
}
}
}
impl CaptchaFinder for CaptchaHeaderFinder {
async fn find_token(&self, req: &mut Request) -> Option<Option<String>> {
impl<T, A> CaptchaFinder for CaptchaHeaderFinder<T, A>
where
T: TryFrom<String> + Sync + Send,
A: TryFrom<String> + Sync + Send,
<T as TryFrom<String>>::Error: Send,
<T as TryFrom<String>>::Error: std::fmt::Debug,
<A as TryFrom<String>>::Error: Send,
<A as TryFrom<String>>::Error: std::fmt::Debug,
{
type Token = T;
type Answer = A;
type TError = <T as TryFrom<String>>::Error;
type AError = <A as TryFrom<String>>::Error;
async fn find_token(&self, req: &mut Request) -> Result<Option<Self::Token>, Self::TError> {
req.headers()
.get(&self.token_header)
.map(|t| t.to_str().map(ToString::to_string).ok())
.and_then(|t| t.to_str().ok())
.map(|t| Self::Token::try_from(t.to_string()))
.transpose()
}
async fn find_answer(&self, req: &mut Request) -> Option<Option<String>> {
async fn find_answer(&self, req: &mut Request) -> Result<Option<Self::Answer>, Self::AError> {
req.headers()
.get(&self.answer_header)
.map(|a| a.to_str().map(ToString::to_string).ok())
.and_then(|a| a.to_str().ok())
.map(|a| Self::Answer::try_from(a.to_string()))
.transpose()
}
}
impl CaptchaFinder for CaptchaFormFinder {
async fn find_token(&self, req: &mut Request) -> Option<Option<String>> {
req.form_data()
impl<T, A> CaptchaFinder for CaptchaFormFinder<T, A>
where
T: TryFrom<String> + Sync + Send,
A: TryFrom<String> + Sync + Send,
<T as TryFrom<String>>::Error: Send,
<T as TryFrom<String>>::Error: std::fmt::Debug,
<A as TryFrom<String>>::Error: Send,
<A as TryFrom<String>>::Error: std::fmt::Debug,
{
type Token = T;
type Answer = A;
type TError = <T as TryFrom<String>>::Error;
type AError = <A as TryFrom<String>>::Error;
async fn find_token(&self, req: &mut Request) -> Result<Option<Self::Token>, Self::TError> {
req.form::<String>(&self.token_name)
.await
.map(|form| form.fields.get(&self.token_name).cloned())
.ok()
.map(|t| Self::Token::try_from(t.to_string()))
.transpose()
}
async fn find_answer(&self, req: &mut Request) -> Option<Option<String>> {
req.form_data()
async fn find_answer(&self, req: &mut Request) -> Result<Option<Self::Answer>, Self::AError> {
req.form::<String>(&self.answer_name)
.await
.map(|form| form.fields.get(&self.answer_name).cloned())
.ok()
.map(|a| Self::Answer::try_from(a.to_string()))
.transpose()
}
}
@ -166,129 +234,77 @@ mod tests {
#[tokio::test]
async fn test_captcha_header_finder() {
let finder = CaptchaHeaderFinder::new();
let finder = CaptchaHeaderFinder::<String, String>::new();
let mut req = Request::default();
let headers = req.headers_mut();
let token = uuid::Uuid::new_v4();
headers.insert(
HeaderName::from_static("x-captcha-token"),
HeaderValue::from_str("token").unwrap(),
HeaderValue::from_str(&token.to_string()).unwrap(),
);
headers.insert(
HeaderName::from_static("x-captcha-answer"),
HeaderValue::from_static("answer"),
);
assert_eq!(
finder.find_token(&mut req).await,
Some(Some("token".to_owned()))
Ok(Some(token.to_string()))
);
assert_eq!(
assert!(matches!(
finder.find_answer(&mut req).await,
Some(Some("answer".to_owned()))
);
Ok(Some(a)) if a == *"answer"
));
}
#[tokio::test]
async fn test_captcha_header_finder_customized() {
let finder = CaptchaHeaderFinder::new()
let finder = CaptchaHeaderFinder::<String, String>::new()
.token_header(HeaderName::from_static("token"))
.answer_header(HeaderName::from_static("answer"));
let mut req = Request::default();
let headers = req.headers_mut();
let token = uuid::Uuid::new_v4();
headers.insert(
HeaderName::from_static("token"),
HeaderValue::from_str("token").unwrap(),
HeaderValue::from_str(&token.to_string()).unwrap(),
);
headers.insert(
HeaderName::from_static("answer"),
HeaderValue::from_static("answer"),
);
assert_eq!(
finder.find_token(&mut req).await,
Some(Some("token".to_owned()))
Ok(Some(token.to_string()))
);
assert_eq!(
assert!(matches!(
finder.find_answer(&mut req).await,
Some(Some("answer".to_owned()))
);
Ok(Some(a)) if a == *"answer"
));
}
#[tokio::test]
async fn test_captcha_header_finder_none() {
let finder = CaptchaHeaderFinder::new();
let finder = CaptchaHeaderFinder::<String, String>::new();
let mut req = Request::default();
assert_eq!(finder.find_token(&mut req).await, Some(None));
assert_eq!(finder.find_answer(&mut req).await, Some(None));
assert_eq!(finder.find_token(&mut req).await, Ok(None));
assert_eq!(finder.find_answer(&mut req).await, Ok(None));
}
#[tokio::test]
async fn test_captcha_header_finder_customized_none() {
let finder = CaptchaHeaderFinder::new()
let finder = CaptchaHeaderFinder::<String, String>::new()
.token_header(HeaderName::from_static("token"))
.answer_header(HeaderName::from_static("answer"));
let mut req = Request::default();
let headers = req.headers_mut();
headers.insert(
HeaderName::from_static("token"),
HeaderValue::from_str("token").unwrap(),
);
headers.insert(
HeaderName::from_static("answer"),
HeaderValue::from_static("answer"),
);
assert_eq!(
finder.find_token(&mut req).await,
Some(Some("token".to_owned()))
);
assert_eq!(
finder.find_answer(&mut req).await,
Some(Some("answer".to_owned()))
);
assert_eq!(finder.find_token(&mut req).await, Some(None));
assert_eq!(finder.find_answer(&mut req).await, Some(None));
}
#[tokio::test]
async fn test_captcha_header_finder_invalid_string() {
let finder = CaptchaHeaderFinder::new()
.token_header(HeaderName::from_static("token"))
.answer_header(HeaderName::from_static("answer"));
let mut req = Request::default();
let headers = req.headers_mut();
headers.insert(
HeaderName::from_static("token"),
HeaderValue::from_str("مرحبا").unwrap(),
);
headers.insert(
HeaderName::from_static("answer"),
HeaderValue::from_static("مرحبا"),
);
assert_eq!(
finder.find_token(&mut req).await,
Some(Some("token".to_owned()))
);
assert_eq!(
finder.find_answer(&mut req).await,
Some(Some("answer".to_owned()))
);
assert_eq!(finder.find_token(&mut req).await, None);
assert_eq!(finder.find_answer(&mut req).await, None);
assert_eq!(finder.find_token(&mut req).await, Ok(None));
assert_eq!(finder.find_answer(&mut req).await, Ok(None));
}
#[tokio::test]
async fn test_captcha_form_finder() {
let finder = CaptchaFormFinder::new();
let finder = CaptchaFormFinder::<String, String>::new();
let mut req = Request::default();
*req.body_mut() = ReqBody::Once("captcha_token=token&captcha_answer=answer".into());
let headers = req.headers_mut();
@ -299,17 +315,17 @@ mod tests {
assert_eq!(
finder.find_token(&mut req).await,
Some(Some("token".to_owned()))
Ok(Some("token".to_string()))
);
assert_eq!(
assert!(matches!(
finder.find_answer(&mut req).await,
Some(Some("answer".to_owned()))
);
Ok(Some(a)) if a == *"answer"
));
}
#[tokio::test]
async fn test_captcha_form_finder_customized() {
let finder = CaptchaFormFinder::new()
let finder = CaptchaFormFinder::<String, String>::new()
.token_name("token".to_string())
.answer_name("answer".to_string());
let mut req = Request::default();
@ -322,17 +338,17 @@ mod tests {
assert_eq!(
finder.find_token(&mut req).await,
Some(Some("token".to_owned()))
Ok(Some("token".to_string()))
);
assert_eq!(
assert!(matches!(
finder.find_answer(&mut req).await,
Some(Some("answer".to_owned()))
);
Ok(Some(a)) if a == *"answer"
));
}
#[tokio::test]
async fn test_captcha_form_finder_none() {
let finder = CaptchaFormFinder::new();
let finder = CaptchaFormFinder::<String, String>::new();
let mut req = Request::default();
*req.body_mut() = ReqBody::Once("".into());
let headers = req.headers_mut();
@ -341,13 +357,13 @@ mod tests {
HeaderValue::from_str(&ContentType::form_url_encoded().to_string()).unwrap(),
);
assert_eq!(finder.find_token(&mut req).await, Some(None));
assert_eq!(finder.find_answer(&mut req).await, Some(None));
assert_eq!(finder.find_token(&mut req).await, Ok(None));
assert_eq!(finder.find_answer(&mut req).await, Ok(None));
}
#[tokio::test]
async fn test_captcha_form_finder_customized_none() {
let finder = CaptchaFormFinder::new()
let finder = CaptchaFormFinder::<String, String>::new()
.token_name("token".to_string())
.answer_name("answer".to_string());
let mut req = Request::default();
@ -358,13 +374,13 @@ mod tests {
HeaderValue::from_str(&ContentType::form_url_encoded().to_string()).unwrap(),
);
assert_eq!(finder.find_token(&mut req).await, Some(None));
assert_eq!(finder.find_answer(&mut req).await, Some(None));
assert_eq!(finder.find_token(&mut req).await, Ok(None));
assert_eq!(finder.find_answer(&mut req).await, Ok(None));
}
#[tokio::test]
async fn test_captcha_form_finder_invalid() {
let finder = CaptchaFormFinder::new();
let finder = CaptchaFormFinder::<String, String>::new();
let mut req = Request::default();
*req.body_mut() = ReqBody::Once("captcha_token=token&captcha_answer=answer".into());
let headers = req.headers_mut();
@ -373,7 +389,7 @@ mod tests {
HeaderValue::from_str(&ContentType::json().to_string()).unwrap(),
);
assert_eq!(finder.find_token(&mut req).await, Some(None));
assert_eq!(finder.find_answer(&mut req).await, Some(None));
assert_eq!(finder.find_token(&mut req).await, Ok(None));
assert_eq!(finder.find_answer(&mut req).await, Ok(None));
}
}

View file

@ -36,7 +36,7 @@ pub const CAPTCHA_STATE_KEY: &str = "::salvo_captcha::captcha_state";
pub struct Captcha<S, F>
where
S: CaptchaStorage,
F: CaptchaFinder,
F: CaptchaFinder<Token = S::Token, Answer = S::Answer>,
{
/// The captcha finder, used to find the captcha token and answer from the request.
finder: F,
@ -47,20 +47,20 @@ where
}
/// The captcha states of the request
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CaptchaState {
/// The captcha check is skipped. This depends on the skipper.
#[default]
Skipped,
/// The captcha is checked and passed. If the captcha is passed, it will be cleared from the storage.
Passed,
/// The captcha check is skipped. This depends on the skipper.
Skipped,
/// Can't find the captcha token in the request
TokenNotFound,
/// Can't find the captcha answer in the request
AnswerNotFound,
/// Can't find the captcha token in the storage or the token is wrong (not valid string)
/// The captcha token is wrong, can't find the captcha in the storage.
/// Maybe the captcha token entered by the user is wrong, or the captcha is expired, because the storage has been cleared.
WrongToken,
/// Can't find the captcha answer in the storage or the answer is wrong (not valid string)
/// The captcha answer is wrong. This will not clear the captcha from the storage.
WrongAnswer,
/// Storage error
StorageError,
@ -69,13 +69,13 @@ pub enum CaptchaState {
impl<S, F> Captcha<S, F>
where
S: CaptchaStorage,
F: CaptchaFinder,
F: CaptchaFinder<Token = S::Token, Answer = S::Answer>,
{
/// Create a new Captcha
pub fn new(storage: S, finder: F) -> Self {
pub fn new(storage: impl Into<S>, finder: impl Into<F>) -> Self {
Self {
finder,
storage,
finder: finder.into(),
storage: storage.into(),
skipper: Box::new(none_skipper),
}
}
@ -94,22 +94,19 @@ where
/// The captcha extension of the depot.
/// Used to get the captcha info from the depot.
pub trait CaptchaDepotExt {
#[easy_ext::ext(CaptchaDepotExt)]
impl Depot {
/// Get the captcha state from the depot
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()
pub fn get_captcha_state(&self) -> Option<&CaptchaState> {
self.get(CAPTCHA_STATE_KEY).ok()
}
}
#[salvo_core::async_trait]
#[async_trait::async_trait]
impl<S, F> Handler for Captcha<S, F>
where
S: CaptchaStorage,
F: CaptchaFinder + 'static, // why?
F: CaptchaFinder<Token = S::Token, Answer = S::Answer> + 'static,
{
async fn handle(
&self,
@ -125,28 +122,28 @@ where
}
let token = match self.finder.find_token(req).await {
Some(Some(token)) => token,
Some(None) => {
Ok(Some(token)) => token,
Ok(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");
Err(err) => {
log::error!("Failed to find captcha token from request: {err:?}");
depot.insert(CAPTCHA_STATE_KEY, CaptchaState::WrongToken);
return;
}
};
let answer = match self.finder.find_answer(req).await {
Some(Some(answer)) => answer,
Some(None) => {
Ok(Some(answer)) => answer,
Ok(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");
Err(err) => {
log::error!("Failed to find captcha answer from request: {err:?}");
depot.insert(CAPTCHA_STATE_KEY, CaptchaState::WrongAnswer);
return;
}

View file

@ -26,20 +26,24 @@ pub trait CaptchaStorage: Send + Sync + 'static
where
Self: Clone + std::fmt::Debug,
{
/// The token type
type Token: TryFrom<String> + std::fmt::Display + Send + Sync;
/// The answer type
type Answer: std::cmp::PartialEq + From<String> + Send + Sync;
/// The error type of the storage.
type Error: std::fmt::Display + std::fmt::Debug + Send;
/// Store the captcha token and answer.
fn store_answer(
&self,
answer: String,
) -> impl std::future::Future<Output = Result<String, Self::Error>> + Send;
answer: Self::Answer,
) -> impl std::future::Future<Output = Result<Self::Token, Self::Error>> + Send;
/// Returns the answer of the captcha token. This method will return None if the token is not exist.
fn get_answer(
&self,
token: &str,
) -> impl std::future::Future<Output = Result<Option<String>, Self::Error>> + Send;
token: &Self::Token,
) -> impl std::future::Future<Output = Result<Option<Self::Answer>, Self::Error>> + Send;
/// Clear the expired captcha.
fn clear_expired(
@ -50,7 +54,7 @@ where
/// Clear the captcha by token.
fn clear_by_token(
&self,
token: &str,
token: &Self::Token,
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
}
@ -82,17 +86,19 @@ impl CacacheStorage {
#[cfg(feature = "cacache-storage")]
impl CaptchaStorage for CacacheStorage {
type Error = cacache::Error;
type Token = String;
type Answer = String;
async fn store_answer(&self, answer: String) -> Result<String, Self::Error> {
async fn store_answer(&self, answer: Self::Answer) -> Result<Self::Token, Self::Error> {
let token = uuid::Uuid::new_v4();
log::info!("Storing captcha answer to cacache for token: {token}");
cacache::write(&self.cache_dir, token.to_string(), answer.as_bytes()).await?;
Ok(token.to_string())
}
async fn get_answer(&self, token: &str) -> Result<Option<String>, Self::Error> {
async fn get_answer(&self, token: &Self::Token) -> Result<Option<Self::Answer>, Self::Error> {
log::info!("Getting captcha answer from cacache for token: {token}");
match cacache::read(&self.cache_dir, token).await {
match cacache::read(&self.cache_dir, token.to_string()).await {
Ok(answer) => {
log::info!("Captcha answer is exist in cacache for token: {token}");
Ok(Some(
@ -114,7 +120,7 @@ impl CaptchaStorage for CacacheStorage {
async fn clear_expired(&self, expired_after: Duration) -> Result<(), Self::Error> {
let now = SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("SystemTime before UNIX EPOCH!")
.expect("SystemTime must be later than UNIX_EPOCH")
.as_millis();
let expired_after = expired_after.as_millis();
@ -137,10 +143,10 @@ impl CaptchaStorage for CacacheStorage {
Ok(())
}
async fn clear_by_token(&self, token: &str) -> Result<(), Self::Error> {
async fn clear_by_token(&self, token: &Self::Token) -> Result<(), Self::Error> {
log::info!("Clearing captcha token from cacache: {token}");
let remove_opts = cacache::RemoveOpts::new().remove_fully(true);
remove_opts.remove(&self.cache_dir, token).await
remove_opts.remove(&self.cache_dir, token.to_string()).await
}
}
@ -149,18 +155,20 @@ where
T: CaptchaStorage,
{
type Error = T::Error;
type Token = T::Token;
type Answer = T::Answer;
fn store_answer(
&self,
answer: String,
) -> impl std::future::Future<Output = Result<String, Self::Error>> + Send {
answer: Self::Answer,
) -> impl std::future::Future<Output = Result<Self::Token, Self::Error>> + Send {
self.as_ref().store_answer(answer)
}
fn get_answer(
&self,
token: &str,
) -> impl std::future::Future<Output = Result<Option<String>, Self::Error>> + Send {
token: &Self::Token,
) -> impl std::future::Future<Output = Result<Option<Self::Answer>, Self::Error>> + Send {
self.as_ref().get_answer(token)
}
@ -173,18 +181,18 @@ where
fn clear_by_token(
&self,
token: &str,
token: &Self::Token,
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send {
self.as_ref().clear_by_token(token)
}
}
#[cfg(test)]
#[cfg(feature = "cacache-storage")]
mod tests {
use super::*;
#[tokio::test]
#[cfg(feature = "cacache-storage")]
async fn cacache_store_captcha() {
let storage = CacacheStorage::new(
tempfile::tempdir()
@ -207,6 +215,7 @@ mod tests {
}
#[tokio::test]
#[cfg(feature = "cacache-storage")]
async fn cacache_clear_expired() {
let storage = CacacheStorage::new(
tempfile::tempdir()
@ -231,6 +240,7 @@ mod tests {
}
#[tokio::test]
#[cfg(feature = "cacache-storage")]
async fn cacache_clear_by_token() {
let storage = CacacheStorage::new(
tempfile::tempdir()
@ -255,6 +265,7 @@ mod tests {
}
#[tokio::test]
#[cfg(feature = "cacache-storage")]
async fn cacache_is_token_exist() {
let storage = CacacheStorage::new(
tempfile::tempdir()
@ -273,13 +284,14 @@ mod tests {
.expect("failed to check if token is exist")
.is_some());
assert!(storage
.get_answer("token")
.get_answer(&"token".to_owned())
.await
.expect("failed to check if token is exist")
.is_none());
}
#[tokio::test]
#[cfg(feature = "cacache-storage")]
async fn cacache_get_answer() {
let storage = CacacheStorage::new(
tempfile::tempdir()
@ -300,13 +312,14 @@ mod tests {
Some("answer".to_owned())
);
assert!(storage
.get_answer("token")
.get_answer(&"token".to_owned())
.await
.expect("failed to get captcha answer")
.is_none());
}
#[tokio::test]
#[cfg(feature = "cacache-storage")]
async fn cacache_cache_dir() {
let cache_dir = tempfile::tempdir()
.expect("failed to create temp file")
@ -317,6 +330,7 @@ mod tests {
}
#[tokio::test]
#[cfg(feature = "cacache-storage")]
async fn cacache_clear_expired_with_expired_after() {
let storage = CacacheStorage::new(
tempfile::tempdir()