Compare commits

...

4 commits

Author SHA1 Message Date
79ff05d91d
refactor: Refactor the CaptchaFinder and CaptchaStorage to work with token and answer as string
All checks were successful
Rust CI / Rust CI (push) Successful in 1m31s
Signed-off-by: Awiteb <a@4rs.nl>
2024-08-09 20:14:39 +00:00
28e9406032
chore: Improve dependencies versions
Signed-off-by: Awiteb <a@4rs.nl>
2024-08-09 19:58:45 +00:00
91b5680b22
chore: Improve the tests
Signed-off-by: Awiteb <a@4rs.nl>
2024-08-08 14:53:28 +00:00
e6121b3f52
chore: Moving to Forgejo instead of Github
Signed-off-by: Awiteb <a@4rs.nl>
2024-08-08 14:51:21 +00:00
12 changed files with 282 additions and 322 deletions

25
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,25 @@
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
View file

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

34
.github/workflows/auto_close_pr.yml vendored Normal file
View file

@ -0,0 +1,34 @@
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 }}

View file

@ -1,35 +0,0 @@
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 }}

View file

@ -1,44 +0,0 @@
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,19 +14,21 @@ categories = ["web-programming", "network-programming"]
[dependencies] [dependencies]
async-trait = "0.1.77" cacache = { version = "13", default-features = false, features = ["tokio-runtime", "mmap"], optional = true }
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 }
easy-ext = "1.0.1" log = "0.4"
log = "0.4.20" salvo_core = { version = ">= 0.65, < 0.69", default-features = false }
salvo_core = "^ 0.65" uuid = { version = "1.7", features = ["v4"], optional = true }
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", features = ["affix"] } salvo = { version = ">= 0.65, < 0.69", default-features = false, features = ["server", "http1","http2", "affix"] }
[[example]]
name = "simple_login"
required-features = ["cacache-storage"]

View file

@ -8,7 +8,8 @@ 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
@ -22,36 +23,41 @@ 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
### 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.
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.
| Name | Difficulty | Image | In this table, you can see the difference between the difficulties and the name of the captcha.
|------|------------|-------|
| `Amelia` | Easy | ![Simple](https://i.suar.me/1JaxG/s) | | Name | Easy | Medium | Hard |
| `Amelia` | Medium | ![Simple](https://i.suar.me/l7zBl/s) | | :----: | :----------------------------------: | :----------------------------------: | :----------------------------------: |
| `Amelia` | Hard | ![Simple](https://i.suar.me/qXAlx/s) | | Amelia | ![Simple](https://i.suar.me/1JaxG/s) | ![Simple](https://i.suar.me/l7zBl/s) | ![Simple](https://i.suar.me/qXAlx/s) |
| `Lucy` | Easy | ![Simple](https://i.suar.me/edwBG/s) | | Lucy | ![Simple](https://i.suar.me/edwBG/s) | ![Simple](https://i.suar.me/NJmg0/s) | ![Simple](https://i.suar.me/OJK7M/s) |
| `Lucy` | Medium | ![Simple](https://i.suar.me/NJmg0/s) | | Mila | ![Simple](https://i.suar.me/dO78z/s) | ![Simple](https://i.suar.me/PXBwK/s) | ![Simple](https://i.suar.me/8edgE/s) |
| `Lucy` | Hard | ![Simple](https://i.suar.me/OJK7M/s) |
| `Mila` | Easy | ![Simple](https://i.suar.me/dO78z/s) | ## Mirrors
| `Mila` | Medium | ![Simple](https://i.suar.me/PXBwK/s) |
| `Mila` | Hard | ![Simple](https://i.suar.me/8edgE/s) | - 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
### 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,15 +3,6 @@
// 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};
@ -19,9 +10,6 @@ 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,
@ -29,7 +17,7 @@ const BASE_64_ENGINE: GeneralPurpose = GeneralPurpose::new(
); );
#[handler] #[handler]
async fn index(_req: &mut Request, res: &mut Response, depot: &mut Depot) { async fn index(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();
@ -51,7 +39,7 @@ async fn index(_req: &mut Request, 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().unwrap(); let captcha_state = depot.get_captcha_state();
// 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);
@ -76,7 +64,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 = MyCaptcha::new( let captcha_middleware = Captcha::new(
CacacheStorage::new("./captcha-cache"), CacacheStorage::new("./captcha-cache"),
CaptchaFormFinder::new(), CaptchaFormFinder::new(),
) )
@ -115,6 +103,7 @@ 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,3 +1,14 @@
// 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`].
@ -11,7 +22,7 @@ pub trait CaptchaGenerator: CaptchaStorage {
&self, &self,
name: CaptchaName, name: CaptchaName,
difficulty: CaptchaDifficulty, difficulty: CaptchaDifficulty,
) -> impl std::future::Future<Output = Result<Option<(Self::Token, Vec<u8>)>, Self::Error>> + Send ) -> impl std::future::Future<Output = Result<Option<(String, Vec<u8>)>, Self::Error>> + Send
{ {
async { async {
let Some((captcha_answer, captcha_image)) = let Some((captcha_answer, captcha_image)) =
@ -20,7 +31,7 @@ pub trait CaptchaGenerator: CaptchaStorage {
return Ok(None); return Ok(None);
}; };
let token = self.store_answer(captcha_answer.into()).await?; let token = self.store_answer(captcha_answer).await?;
Ok(Some((token, captcha_image))) Ok(Some((token, captcha_image)))
} }
} }

View file

@ -9,49 +9,37 @@
// 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.
/// ///
/// Return [`None`] if the request does not contain a captcha token. An error is returned if the token is invalid format. /// ### 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
fn find_token( fn find_token(
&self, &self,
req: &mut Request, req: &mut Request,
) -> impl std::future::Future<Output = Result<Option<Self::Token>, Self::TError>> + std::marker::Send; ) -> impl std::future::Future<Output = Option<Option<String>>> + std::marker::Send;
/// Find the captcha answer from the request. /// Find the captcha answer from the request.
/// ///
/// Return [`None`] if the request does not contain a captcha answer. An error is returned if the answer is invalid format. /// ### 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
fn find_answer( fn find_answer(
&self, &self,
req: &mut Request, req: &mut Request,
) -> impl std::future::Future<Output = Result<Option<Self::Answer>, Self::AError>> + std::marker::Send; ) -> impl std::future::Future<Output = Option<Option<String>>> + 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<T, A> pub struct CaptchaHeaderFinder {
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"
@ -65,13 +53,7 @@ where
/// 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<T, A> pub struct CaptchaFormFinder {
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"
@ -83,11 +65,7 @@ where
pub answer_name: String, pub answer_name: String,
} }
impl<T, A> CaptchaHeaderFinder<T, A> impl CaptchaHeaderFinder {
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()
@ -106,11 +84,7 @@ where
} }
} }
impl<T, A> CaptchaFormFinder<T, A> impl CaptchaFormFinder {
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()
@ -129,99 +103,57 @@ where
} }
} }
impl<T, A> Default for CaptchaHeaderFinder<T, A> impl Default for CaptchaHeaderFinder {
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<T, A> Default for CaptchaFormFinder<T, A> impl Default for CaptchaFormFinder {
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<T, A> CaptchaFinder for CaptchaHeaderFinder<T, A> impl CaptchaFinder for CaptchaHeaderFinder {
where async fn find_token(&self, req: &mut Request) -> Option<Option<String>> {
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)
.and_then(|t| t.to_str().ok()) .map(|t| t.to_str().map(ToString::to_string).ok())
.map(|t| Self::Token::try_from(t.to_string()))
.transpose()
} }
async fn find_answer(&self, req: &mut Request) -> Result<Option<Self::Answer>, Self::AError> { async fn find_answer(&self, req: &mut Request) -> Option<Option<String>> {
req.headers() req.headers()
.get(&self.answer_header) .get(&self.answer_header)
.and_then(|a| a.to_str().ok()) .map(|a| a.to_str().map(ToString::to_string).ok())
.map(|a| Self::Answer::try_from(a.to_string()))
.transpose()
} }
} }
impl<T, A> CaptchaFinder for CaptchaFormFinder<T, A> impl CaptchaFinder for CaptchaFormFinder {
where async fn find_token(&self, req: &mut Request) -> Option<Option<String>> {
T: TryFrom<String> + Sync + Send, req.form_data()
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(|t| Self::Token::try_from(t.to_string())) .map(|form| form.fields.get(&self.token_name).cloned())
.transpose() .ok()
} }
async fn find_answer(&self, req: &mut Request) -> Result<Option<Self::Answer>, Self::AError> { async fn find_answer(&self, req: &mut Request) -> Option<Option<String>> {
req.form::<String>(&self.answer_name) req.form_data()
.await .await
.map(|a| Self::Answer::try_from(a.to_string())) .map(|form| form.fields.get(&self.answer_name).cloned())
.transpose() .ok()
} }
} }
@ -234,77 +166,129 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_captcha_header_finder() { async fn test_captcha_header_finder() {
let finder = CaptchaHeaderFinder::<String, String>::new(); let finder = CaptchaHeaderFinder::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.to_string()).unwrap(), HeaderValue::from_str("token").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,
Ok(Some(token.to_string())) Some(Some("token".to_owned()))
); );
assert!(matches!( assert_eq!(
finder.find_answer(&mut req).await, finder.find_answer(&mut req).await,
Ok(Some(a)) if a == *"answer" Some(Some("answer".to_owned()))
)); );
} }
#[tokio::test] #[tokio::test]
async fn test_captcha_header_finder_customized() { async fn test_captcha_header_finder_customized() {
let finder = CaptchaHeaderFinder::<String, String>::new() let finder = CaptchaHeaderFinder::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.to_string()).unwrap(), HeaderValue::from_str("token").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,
Ok(Some(token.to_string())) Some(Some("token".to_owned()))
); );
assert!(matches!( assert_eq!(
finder.find_answer(&mut req).await, finder.find_answer(&mut req).await,
Ok(Some(a)) if a == *"answer" Some(Some("answer".to_owned()))
)); );
} }
#[tokio::test] #[tokio::test]
async fn test_captcha_header_finder_none() { async fn test_captcha_header_finder_none() {
let finder = CaptchaHeaderFinder::<String, String>::new(); let finder = CaptchaHeaderFinder::new();
let mut req = Request::default(); let mut req = Request::default();
assert_eq!(finder.find_token(&mut req).await, Ok(None)); assert_eq!(finder.find_token(&mut req).await, Some(None));
assert_eq!(finder.find_answer(&mut req).await, Ok(None)); assert_eq!(finder.find_answer(&mut req).await, Some(None));
} }
#[tokio::test] #[tokio::test]
async fn test_captcha_header_finder_customized_none() { async fn test_captcha_header_finder_customized_none() {
let finder = CaptchaHeaderFinder::<String, String>::new() let finder = CaptchaHeaderFinder::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();
assert_eq!(finder.find_token(&mut req).await, Ok(None)); headers.insert(
assert_eq!(finder.find_answer(&mut req).await, Ok(None)); 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);
} }
#[tokio::test] #[tokio::test]
async fn test_captcha_form_finder() { async fn test_captcha_form_finder() {
let finder = CaptchaFormFinder::<String, String>::new(); let finder = CaptchaFormFinder::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();
@ -315,17 +299,17 @@ mod tests {
assert_eq!( assert_eq!(
finder.find_token(&mut req).await, finder.find_token(&mut req).await,
Ok(Some("token".to_string())) Some(Some("token".to_owned()))
); );
assert!(matches!( assert_eq!(
finder.find_answer(&mut req).await, finder.find_answer(&mut req).await,
Ok(Some(a)) if a == *"answer" Some(Some("answer".to_owned()))
)); );
} }
#[tokio::test] #[tokio::test]
async fn test_captcha_form_finder_customized() { async fn test_captcha_form_finder_customized() {
let finder = CaptchaFormFinder::<String, String>::new() let finder = CaptchaFormFinder::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();
@ -338,17 +322,17 @@ mod tests {
assert_eq!( assert_eq!(
finder.find_token(&mut req).await, finder.find_token(&mut req).await,
Ok(Some("token".to_string())) Some(Some("token".to_owned()))
); );
assert!(matches!( assert_eq!(
finder.find_answer(&mut req).await, finder.find_answer(&mut req).await,
Ok(Some(a)) if a == *"answer" Some(Some("answer".to_owned()))
)); );
} }
#[tokio::test] #[tokio::test]
async fn test_captcha_form_finder_none() { async fn test_captcha_form_finder_none() {
let finder = CaptchaFormFinder::<String, String>::new(); let finder = CaptchaFormFinder::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();
@ -357,13 +341,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, Ok(None)); assert_eq!(finder.find_token(&mut req).await, Some(None));
assert_eq!(finder.find_answer(&mut req).await, Ok(None)); assert_eq!(finder.find_answer(&mut req).await, Some(None));
} }
#[tokio::test] #[tokio::test]
async fn test_captcha_form_finder_customized_none() { async fn test_captcha_form_finder_customized_none() {
let finder = CaptchaFormFinder::<String, String>::new() let finder = CaptchaFormFinder::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();
@ -374,13 +358,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, Ok(None)); assert_eq!(finder.find_token(&mut req).await, Some(None));
assert_eq!(finder.find_answer(&mut req).await, Ok(None)); assert_eq!(finder.find_answer(&mut req).await, Some(None));
} }
#[tokio::test] #[tokio::test]
async fn test_captcha_form_finder_invalid() { async fn test_captcha_form_finder_invalid() {
let finder = CaptchaFormFinder::<String, String>::new(); let finder = CaptchaFormFinder::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();
@ -389,7 +373,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, Ok(None)); assert_eq!(finder.find_token(&mut req).await, Some(None));
assert_eq!(finder.find_answer(&mut req).await, Ok(None)); assert_eq!(finder.find_answer(&mut req).await, Some(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<Token = S::Token, Answer = S::Answer>, F: CaptchaFinder,
{ {
/// 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(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Default, 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,
/// The captcha token is wrong, can't find the captcha in the storage. /// Can't find the captcha token in the storage or the token is wrong (not valid string)
/// Maybe the captcha token entered by the user is wrong, or the captcha is expired, because the storage has been cleared.
WrongToken, WrongToken,
/// The captcha answer is wrong. This will not clear the captcha from the storage. /// Can't find the captcha answer in the storage or the answer is wrong (not valid string)
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<Token = S::Token, Answer = S::Answer>, F: CaptchaFinder,
{ {
/// Create a new Captcha /// Create a new Captcha
pub fn new(storage: impl Into<S>, finder: impl Into<F>) -> Self { pub fn new(storage: S, finder: F) -> Self {
Self { Self {
finder: finder.into(), finder,
storage: storage.into(), storage,
skipper: Box::new(none_skipper), skipper: Box::new(none_skipper),
} }
} }
@ -94,19 +94,22 @@ 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.
#[easy_ext::ext(CaptchaDepotExt)] pub trait CaptchaDepotExt {
impl Depot {
/// Get the captcha state from the depot /// Get the captcha state from the depot
pub fn get_captcha_state(&self) -> Option<&CaptchaState> { fn get_captcha_state(&self) -> 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()
} }
} }
#[async_trait::async_trait] #[salvo_core::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<Token = S::Token, Answer = S::Answer> + 'static, F: CaptchaFinder + 'static, // why?
{ {
async fn handle( async fn handle(
&self, &self,
@ -122,28 +125,28 @@ where
} }
let token = match self.finder.find_token(req).await { let token = match self.finder.find_token(req).await {
Ok(Some(token)) => token, Some(Some(token)) => token,
Ok(None) => { Some(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;
} }
Err(err) => { None => {
log::error!("Failed to find captcha token from request: {err:?}"); log::error!("Invalid token found in request");
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 {
Ok(Some(answer)) => answer, Some(Some(answer)) => answer,
Ok(None) => { Some(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;
} }
Err(err) => { None => {
log::error!("Failed to find captcha answer from request: {err:?}"); log::error!("Invalid answer found in request");
depot.insert(CAPTCHA_STATE_KEY, CaptchaState::WrongAnswer); depot.insert(CAPTCHA_STATE_KEY, CaptchaState::WrongAnswer);
return; return;
} }

View file

@ -26,24 +26,20 @@ 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: Self::Answer, answer: String,
) -> impl std::future::Future<Output = Result<Self::Token, Self::Error>> + Send; ) -> impl std::future::Future<Output = Result<String, 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: &Self::Token, token: &str,
) -> impl std::future::Future<Output = Result<Option<Self::Answer>, Self::Error>> + Send; ) -> impl std::future::Future<Output = Result<Option<String>, Self::Error>> + Send;
/// Clear the expired captcha. /// Clear the expired captcha.
fn clear_expired( fn clear_expired(
@ -54,7 +50,7 @@ where
/// Clear the captcha by token. /// Clear the captcha by token.
fn clear_by_token( fn clear_by_token(
&self, &self,
token: &Self::Token, token: &str,
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send; ) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
} }
@ -86,19 +82,17 @@ 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: Self::Answer) -> Result<Self::Token, Self::Error> { async fn store_answer(&self, answer: String) -> Result<String, 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: &Self::Token) -> Result<Option<Self::Answer>, Self::Error> { async fn get_answer(&self, token: &str) -> Result<Option<String>, 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.to_string()).await { match cacache::read(&self.cache_dir, token).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(
@ -120,7 +114,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 must be later than UNIX_EPOCH") .expect("SystemTime before UNIX EPOCH!")
.as_millis(); .as_millis();
let expired_after = expired_after.as_millis(); let expired_after = expired_after.as_millis();
@ -143,10 +137,10 @@ impl CaptchaStorage for CacacheStorage {
Ok(()) Ok(())
} }
async fn clear_by_token(&self, token: &Self::Token) -> Result<(), Self::Error> { async fn clear_by_token(&self, token: &str) -> 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.to_string()).await remove_opts.remove(&self.cache_dir, token).await
} }
} }
@ -155,20 +149,18 @@ 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: Self::Answer, answer: String,
) -> impl std::future::Future<Output = Result<Self::Token, Self::Error>> + Send { ) -> impl std::future::Future<Output = Result<String, Self::Error>> + Send {
self.as_ref().store_answer(answer) self.as_ref().store_answer(answer)
} }
fn get_answer( fn get_answer(
&self, &self,
token: &Self::Token, token: &str,
) -> impl std::future::Future<Output = Result<Option<Self::Answer>, Self::Error>> + Send { ) -> impl std::future::Future<Output = Result<Option<String>, Self::Error>> + Send {
self.as_ref().get_answer(token) self.as_ref().get_answer(token)
} }
@ -181,18 +173,18 @@ where
fn clear_by_token( fn clear_by_token(
&self, &self,
token: &Self::Token, token: &str,
) -> 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()
@ -215,7 +207,6 @@ 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()
@ -240,7 +231,6 @@ 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()
@ -265,7 +255,6 @@ 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()
@ -284,14 +273,13 @@ 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".to_owned()) .get_answer("token")
.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()
@ -312,14 +300,13 @@ mod tests {
Some("answer".to_owned()) Some("answer".to_owned())
); );
assert!(storage assert!(storage
.get_answer(&"token".to_owned()) .get_answer("token")
.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")
@ -330,7 +317,6 @@ 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()