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] [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 } captcha = { version = "0.0.9", default-features = false }
log = "0.4" easy-ext = "1.0.1"
salvo_core = { version = ">= 0.65, < 0.69", default-features = false } log = "0.4.20"
uuid = { version = "1.7", features = ["v4"], optional = true } salvo_core = "^ 0.65"
uuid = { version = "1.7.0", features = ["v4"], optional = true }
[features] [features]
cacache-storage = ["dep:cacache", "dep:uuid"] cacache-storage = ["dep:cacache", "dep:uuid"]
[dev-dependencies] [dev-dependencies]
tempfile = "3.9" tempfile = ">= 3.9"
tokio = { version = "1.35", features = ["macros", "rt-multi-thread"] } tokio = { version = ">= 1.35", features = ["macros", "rt-multi-thread"] }
base64 = "0.21" base64 = ">= 0.21"
salvo = { version = ">= 0.65, < 0.69", default-features = false, features = ["server", "http1","http2", "affix"] } salvo = { version = ">= 0.65", features = ["affix"] }
[[example]]
name = "simple_login"
required-features = ["cacache-storage"]

View file

@ -8,8 +8,7 @@ A captcha middleware for [salvo](salvo.rs) framework. It uses [`captcha`](https:
</div> </div>
## Add to your project ### Add to your project
First, add the following to your `Cargo.toml`: First, add the following to your `Cargo.toml`:
```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 $ cargo add salvo-captcha
``` ```
## Usage ### Usage
See the [examples](examples) directory for a complete example. 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 ```toml
[dependencies] [dependencies]
salvo-captcha = { version = "0.1", features = ["cacache-storage"] } 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 | Difficulty | Image |
|------|------------|-------|
| Name | Easy | Medium | Hard | | `Amelia` | Easy | ![Simple](https://i.suar.me/1JaxG/s) |
| :----: | :----------------------------------: | :----------------------------------: | :----------------------------------: | | `Amelia` | Medium | ![Simple](https://i.suar.me/l7zBl/s) |
| Amelia | ![Simple](https://i.suar.me/1JaxG/s) | ![Simple](https://i.suar.me/l7zBl/s) | ![Simple](https://i.suar.me/qXAlx/s) | | `Amelia` | Hard | ![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) | | `Lucy` | Easy | ![Simple](https://i.suar.me/edwBG/s) |
| Mila | ![Simple](https://i.suar.me/dO78z/s) | ![Simple](https://i.suar.me/PXBwK/s) | ![Simple](https://i.suar.me/8edgE/s) | | `Lucy` | Medium | ![Simple](https://i.suar.me/NJmg0/s) |
| `Lucy` | Hard | ![Simple](https://i.suar.me/OJK7M/s) |
## Mirrors | `Mila` | Easy | ![Simple](https://i.suar.me/dO78z/s) |
| `Mila` | Medium | ![Simple](https://i.suar.me/PXBwK/s) |
- Github (https://github.com/TheAwiteb/salvo-captcha) | `Mila` | Hard | ![Simple](https://i.suar.me/8edgE/s) |
- Codeberg (https://codeberg.org/awiteb/salvo-captcha)
### Main Repository
- My Git (https://git.4rs.nl/awiteb/salvo-captcha)
## License
### 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](LICENSE) or http://opensource.org/licenses/MIT.
[`CaptchaStore`]: https://docs.rs/salvo_captcha/0.1.0/salvo_captcha/trait.CaptchaStore.html [`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: // You can see a video of this example here:
// //
// Run the example with `cargo run --example simple_login --features cacache-storage` // 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}; use std::{sync::Arc, time::Duration};
@ -10,6 +19,9 @@ use base64::{engine::GeneralPurpose, Engine};
use salvo::prelude::*; use salvo::prelude::*;
use salvo_captcha::*; 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 // To convert the image to base64, to show it in the browser
const BASE_64_ENGINE: GeneralPurpose = GeneralPurpose::new( const BASE_64_ENGINE: GeneralPurpose = GeneralPurpose::new(
&base64::alphabet::STANDARD, &base64::alphabet::STANDARD,
@ -17,7 +29,7 @@ const BASE_64_ENGINE: GeneralPurpose = GeneralPurpose::new(
); );
#[handler] #[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 // Get the captcha from the depot
let captcha_storage = depot.obtain::<Arc<CacacheStorage>>().unwrap(); let captcha_storage = depot.obtain::<Arc<CacacheStorage>>().unwrap();
@ -39,7 +51,7 @@ async fn index(res: &mut Response, depot: &mut Depot) {
#[handler] #[handler]
async fn auth(req: &mut Request, res: &mut Response, depot: &mut Depot) { 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 // 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 // Not important, just for demo
let Some(username) = req.form::<String>("username").await else { let Some(username) = req.form::<String>("username").await else {
res.status_code(StatusCode::BAD_REQUEST); res.status_code(StatusCode::BAD_REQUEST);
@ -64,7 +76,7 @@ async fn auth(req: &mut Request, res: &mut Response, depot: &mut Depot) {
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let captcha_middleware = Captcha::new( let captcha_middleware = MyCaptcha::new(
CacacheStorage::new("./captcha-cache"), CacacheStorage::new("./captcha-cache"),
CaptchaFormFinder::new(), CaptchaFormFinder::new(),
) )
@ -103,7 +115,6 @@ async fn main() {
); );
let acceptor = TcpListener::new(("127.0.0.1", 5800)).bind().await; 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; Server::new(acceptor).serve(router).await;
captcha_cleaner.await.ok(); 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}; use crate::{CaptchaDifficulty, CaptchaName, CaptchaStorage};
/// Captcha generator, used to generate a new captcha image. This trait are implemented for all [`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, &self,
name: CaptchaName, name: CaptchaName,
difficulty: CaptchaDifficulty, 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 { async {
let Some((captcha_answer, captcha_image)) = let Some((captcha_answer, captcha_image)) =
@ -31,7 +20,7 @@ pub trait CaptchaGenerator: CaptchaStorage {
return Ok(None); 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))) 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 // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE. // THE SOFTWARE.
use std::marker::PhantomData;
use salvo_core::http::header::HeaderName; use salvo_core::http::header::HeaderName;
use salvo_core::http::Request; use salvo_core::http::Request;
/// Trait to find the captcha token and answer from the request. /// Trait to find the captcha token and answer from the request.
pub trait CaptchaFinder: Send + Sync { 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. /// Find the captcha token from the request.
/// ///
/// ### Returns /// Return [`None`] if the request does not contain a captcha token. An error is returned if the token is invalid format.
/// - 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
fn find_token( fn find_token(
&self, &self,
req: &mut Request, 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. /// Find the captcha answer from the request.
/// ///
/// ### Returns /// Return [`None`] if the request does not contain a captcha answer. An error is returned if the answer is invalid format.
/// - 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
fn find_answer( fn find_answer(
&self, &self,
req: &mut Request, 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 /// Find the captcha token and answer from the header
#[derive(Debug)] #[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 /// The header name of the captcha token
/// ///
/// Default: "x-captcha-token" /// Default: "x-captcha-token"
@ -53,7 +65,13 @@ pub struct CaptchaHeaderFinder {
/// Find the captcha token and answer from the form /// Find the captcha token and answer from the form
#[derive(Debug)] #[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 /// The form name of the captcha token
/// ///
/// Default: "captcha_token" /// Default: "captcha_token"
@ -65,7 +83,11 @@ pub struct CaptchaFormFinder {
pub answer_name: String, 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 /// Create a new CaptchaHeaderFinder
pub fn new() -> Self { pub fn new() -> Self {
Self::default() 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 /// Create a new CaptchaFormFinder
pub fn new() -> Self { pub fn new() -> Self {
Self::default() 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: /// Create a default CaptchaHeaderFinder with:
/// - token_header: "x-captcha-token" /// - token_header: "x-captcha-token"
/// - answer_header: "x-captcha-answer" /// - answer_header: "x-captcha-answer"
fn default() -> Self { fn default() -> Self {
Self { Self {
phantom: PhantomData,
token_header: HeaderName::from_static("x-captcha-token"), token_header: HeaderName::from_static("x-captcha-token"),
answer_header: HeaderName::from_static("x-captcha-answer"), 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: /// Create a default CaptchaFormFinder with:
/// - token_name: "captcha_token" /// - token_name: "captcha_token"
/// - answer_name: "captcha_answer" /// - answer_name: "captcha_answer"
fn default() -> Self { fn default() -> Self {
Self { Self {
phantom: PhantomData,
token_name: "captcha_token".to_string(), token_name: "captcha_token".to_string(),
answer_name: "captcha_answer".to_string(), answer_name: "captcha_answer".to_string(),
} }
} }
} }
impl CaptchaFinder for CaptchaHeaderFinder { impl<T, A> CaptchaFinder for CaptchaHeaderFinder<T, A>
async fn find_token(&self, req: &mut Request) -> Option<Option<String>> { 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() req.headers()
.get(&self.token_header) .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() req.headers()
.get(&self.answer_header) .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 { impl<T, A> CaptchaFinder for CaptchaFormFinder<T, A>
async fn find_token(&self, req: &mut Request) -> Option<Option<String>> { where
req.form_data() 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 .await
.map(|form| form.fields.get(&self.token_name).cloned()) .map(|t| Self::Token::try_from(t.to_string()))
.ok() .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.form_data() req.form::<String>(&self.answer_name)
.await .await
.map(|form| form.fields.get(&self.answer_name).cloned()) .map(|a| Self::Answer::try_from(a.to_string()))
.ok() .transpose()
} }
} }
@ -166,129 +234,77 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_captcha_header_finder() { async fn test_captcha_header_finder() {
let finder = CaptchaHeaderFinder::new(); let finder = CaptchaHeaderFinder::<String, String>::new();
let mut req = Request::default(); let mut req = Request::default();
let headers = req.headers_mut(); let headers = req.headers_mut();
let token = uuid::Uuid::new_v4();
headers.insert( headers.insert(
HeaderName::from_static("x-captcha-token"), HeaderName::from_static("x-captcha-token"),
HeaderValue::from_str("token").unwrap(), HeaderValue::from_str(&token.to_string()).unwrap(),
); );
headers.insert( headers.insert(
HeaderName::from_static("x-captcha-answer"), HeaderName::from_static("x-captcha-answer"),
HeaderValue::from_static("answer"), HeaderValue::from_static("answer"),
); );
assert_eq!( assert_eq!(
finder.find_token(&mut req).await, 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, finder.find_answer(&mut req).await,
Some(Some("answer".to_owned())) Ok(Some(a)) if a == *"answer"
); ));
} }
#[tokio::test] #[tokio::test]
async fn test_captcha_header_finder_customized() { async fn test_captcha_header_finder_customized() {
let finder = CaptchaHeaderFinder::new() let finder = CaptchaHeaderFinder::<String, String>::new()
.token_header(HeaderName::from_static("token")) .token_header(HeaderName::from_static("token"))
.answer_header(HeaderName::from_static("answer")); .answer_header(HeaderName::from_static("answer"));
let mut req = Request::default(); let mut req = Request::default();
let headers = req.headers_mut(); let headers = req.headers_mut();
let token = uuid::Uuid::new_v4();
headers.insert( headers.insert(
HeaderName::from_static("token"), HeaderName::from_static("token"),
HeaderValue::from_str("token").unwrap(), HeaderValue::from_str(&token.to_string()).unwrap(),
); );
headers.insert( headers.insert(
HeaderName::from_static("answer"), HeaderName::from_static("answer"),
HeaderValue::from_static("answer"), HeaderValue::from_static("answer"),
); );
assert_eq!( assert_eq!(
finder.find_token(&mut req).await, 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, finder.find_answer(&mut req).await,
Some(Some("answer".to_owned())) Ok(Some(a)) if a == *"answer"
); ));
} }
#[tokio::test] #[tokio::test]
async fn test_captcha_header_finder_none() { async fn test_captcha_header_finder_none() {
let finder = CaptchaHeaderFinder::new(); let finder = CaptchaHeaderFinder::<String, String>::new();
let mut req = Request::default(); let mut req = Request::default();
assert_eq!(finder.find_token(&mut req).await, Some(None)); assert_eq!(finder.find_token(&mut req).await, Ok(None));
assert_eq!(finder.find_answer(&mut req).await, Some(None)); assert_eq!(finder.find_answer(&mut req).await, Ok(None));
} }
#[tokio::test] #[tokio::test]
async fn test_captcha_header_finder_customized_none() { async fn test_captcha_header_finder_customized_none() {
let finder = CaptchaHeaderFinder::new() let finder = CaptchaHeaderFinder::<String, String>::new()
.token_header(HeaderName::from_static("token")) .token_header(HeaderName::from_static("token"))
.answer_header(HeaderName::from_static("answer")); .answer_header(HeaderName::from_static("answer"));
let mut req = Request::default(); let mut req = Request::default();
let headers = req.headers_mut();
headers.insert( assert_eq!(finder.find_token(&mut req).await, Ok(None));
HeaderName::from_static("token"), assert_eq!(finder.find_answer(&mut req).await, Ok(None));
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);
} }
#[tokio::test] #[tokio::test]
async fn test_captcha_form_finder() { async fn test_captcha_form_finder() {
let finder = CaptchaFormFinder::new(); let finder = CaptchaFormFinder::<String, String>::new();
let mut req = Request::default(); let mut req = Request::default();
*req.body_mut() = ReqBody::Once("captcha_token=token&captcha_answer=answer".into()); *req.body_mut() = ReqBody::Once("captcha_token=token&captcha_answer=answer".into());
let headers = req.headers_mut(); let headers = req.headers_mut();
@ -299,17 +315,17 @@ mod tests {
assert_eq!( assert_eq!(
finder.find_token(&mut req).await, 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, finder.find_answer(&mut req).await,
Some(Some("answer".to_owned())) Ok(Some(a)) if a == *"answer"
); ));
} }
#[tokio::test] #[tokio::test]
async fn test_captcha_form_finder_customized() { async fn test_captcha_form_finder_customized() {
let finder = CaptchaFormFinder::new() let finder = CaptchaFormFinder::<String, String>::new()
.token_name("token".to_string()) .token_name("token".to_string())
.answer_name("answer".to_string()); .answer_name("answer".to_string());
let mut req = Request::default(); let mut req = Request::default();
@ -322,17 +338,17 @@ mod tests {
assert_eq!( assert_eq!(
finder.find_token(&mut req).await, 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, finder.find_answer(&mut req).await,
Some(Some("answer".to_owned())) Ok(Some(a)) if a == *"answer"
); ));
} }
#[tokio::test] #[tokio::test]
async fn test_captcha_form_finder_none() { async fn test_captcha_form_finder_none() {
let finder = CaptchaFormFinder::new(); let finder = CaptchaFormFinder::<String, String>::new();
let mut req = Request::default(); let mut req = Request::default();
*req.body_mut() = ReqBody::Once("".into()); *req.body_mut() = ReqBody::Once("".into());
let headers = req.headers_mut(); let headers = req.headers_mut();
@ -341,13 +357,13 @@ mod tests {
HeaderValue::from_str(&ContentType::form_url_encoded().to_string()).unwrap(), HeaderValue::from_str(&ContentType::form_url_encoded().to_string()).unwrap(),
); );
assert_eq!(finder.find_token(&mut req).await, Some(None)); assert_eq!(finder.find_token(&mut req).await, Ok(None));
assert_eq!(finder.find_answer(&mut req).await, Some(None)); assert_eq!(finder.find_answer(&mut req).await, Ok(None));
} }
#[tokio::test] #[tokio::test]
async fn test_captcha_form_finder_customized_none() { async fn test_captcha_form_finder_customized_none() {
let finder = CaptchaFormFinder::new() let finder = CaptchaFormFinder::<String, String>::new()
.token_name("token".to_string()) .token_name("token".to_string())
.answer_name("answer".to_string()); .answer_name("answer".to_string());
let mut req = Request::default(); let mut req = Request::default();
@ -358,13 +374,13 @@ mod tests {
HeaderValue::from_str(&ContentType::form_url_encoded().to_string()).unwrap(), HeaderValue::from_str(&ContentType::form_url_encoded().to_string()).unwrap(),
); );
assert_eq!(finder.find_token(&mut req).await, Some(None)); assert_eq!(finder.find_token(&mut req).await, Ok(None));
assert_eq!(finder.find_answer(&mut req).await, Some(None)); assert_eq!(finder.find_answer(&mut req).await, Ok(None));
} }
#[tokio::test] #[tokio::test]
async fn test_captcha_form_finder_invalid() { async fn test_captcha_form_finder_invalid() {
let finder = CaptchaFormFinder::new(); let finder = CaptchaFormFinder::<String, String>::new();
let mut req = Request::default(); let mut req = Request::default();
*req.body_mut() = ReqBody::Once("captcha_token=token&captcha_answer=answer".into()); *req.body_mut() = ReqBody::Once("captcha_token=token&captcha_answer=answer".into());
let headers = req.headers_mut(); let headers = req.headers_mut();
@ -373,7 +389,7 @@ mod tests {
HeaderValue::from_str(&ContentType::json().to_string()).unwrap(), HeaderValue::from_str(&ContentType::json().to_string()).unwrap(),
); );
assert_eq!(finder.find_token(&mut req).await, Some(None)); assert_eq!(finder.find_token(&mut req).await, Ok(None));
assert_eq!(finder.find_answer(&mut req).await, Some(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> pub struct Captcha<S, F>
where where
S: CaptchaStorage, 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. /// The captcha finder, used to find the captcha token and answer from the request.
finder: F, finder: F,
@ -47,20 +47,20 @@ where
} }
/// The captcha states of the request /// The captcha states of the request
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CaptchaState { 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. /// The captcha is checked and passed. If the captcha is passed, it will be cleared from the storage.
Passed, Passed,
/// The captcha check is skipped. This depends on the skipper.
Skipped,
/// Can't find the captcha token in the request /// Can't find the captcha token in the request
TokenNotFound, TokenNotFound,
/// Can't find the captcha answer in the request /// Can't find the captcha answer in the request
AnswerNotFound, 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, 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, WrongAnswer,
/// Storage error /// Storage error
StorageError, StorageError,
@ -69,13 +69,13 @@ pub enum CaptchaState {
impl<S, F> Captcha<S, F> impl<S, F> Captcha<S, F>
where where
S: CaptchaStorage, S: CaptchaStorage,
F: CaptchaFinder, F: CaptchaFinder<Token = S::Token, Answer = S::Answer>,
{ {
/// Create a new Captcha /// 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 { Self {
finder, finder: finder.into(),
storage, storage: storage.into(),
skipper: Box::new(none_skipper), skipper: Box::new(none_skipper),
} }
} }
@ -94,22 +94,19 @@ where
/// The captcha extension of the depot. /// The captcha extension of the depot.
/// Used to get the captcha info from 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 /// Get the captcha state from the depot
fn get_captcha_state(&self) -> CaptchaState; pub fn get_captcha_state(&self) -> Option<&CaptchaState> {
} self.get(CAPTCHA_STATE_KEY).ok()
impl CaptchaDepotExt for Depot {
fn get_captcha_state(&self) -> CaptchaState {
self.get(CAPTCHA_STATE_KEY).cloned().unwrap_or_default()
} }
} }
#[salvo_core::async_trait] #[async_trait::async_trait]
impl<S, F> Handler for Captcha<S, F> impl<S, F> Handler for Captcha<S, F>
where where
S: CaptchaStorage, S: CaptchaStorage,
F: CaptchaFinder + 'static, // why? F: CaptchaFinder<Token = S::Token, Answer = S::Answer> + 'static,
{ {
async fn handle( async fn handle(
&self, &self,
@ -125,28 +122,28 @@ where
} }
let token = match self.finder.find_token(req).await { let token = match self.finder.find_token(req).await {
Some(Some(token)) => token, Ok(Some(token)) => token,
Some(None) => { Ok(None) => {
log::info!("Captcha token is not found in request"); log::info!("Captcha token is not found in request");
depot.insert(CAPTCHA_STATE_KEY, CaptchaState::TokenNotFound); depot.insert(CAPTCHA_STATE_KEY, CaptchaState::TokenNotFound);
return; return;
} }
None => { Err(err) => {
log::error!("Invalid token found in request"); log::error!("Failed to find captcha token from request: {err:?}");
depot.insert(CAPTCHA_STATE_KEY, CaptchaState::WrongToken); depot.insert(CAPTCHA_STATE_KEY, CaptchaState::WrongToken);
return; return;
} }
}; };
let answer = match self.finder.find_answer(req).await { let answer = match self.finder.find_answer(req).await {
Some(Some(answer)) => answer, Ok(Some(answer)) => answer,
Some(None) => { Ok(None) => {
log::info!("Captcha answer is not found in request"); log::info!("Captcha answer is not found in request");
depot.insert(CAPTCHA_STATE_KEY, CaptchaState::AnswerNotFound); depot.insert(CAPTCHA_STATE_KEY, CaptchaState::AnswerNotFound);
return; return;
} }
None => { Err(err) => {
log::error!("Invalid answer found in request"); log::error!("Failed to find captcha answer from request: {err:?}");
depot.insert(CAPTCHA_STATE_KEY, CaptchaState::WrongAnswer); depot.insert(CAPTCHA_STATE_KEY, CaptchaState::WrongAnswer);
return; return;
} }

View file

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