salvo_captcha/finder/
form_finder.rs

1// Copyright (c) 2024-2025, Awiteb <a@4rs.nl>
2//     A captcha middleware for Salvo framework.
3//
4// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
5// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
6// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
7// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
8// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
9// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
10// THE SOFTWARE.
11
12use salvo_core::http::Request;
13
14use crate::CaptchaFinder;
15
16/// Find the captcha token and answer from the form
17#[derive(Debug)]
18pub struct CaptchaFormFinder {
19    /// The form name of the captcha token
20    ///
21    /// Default: "captcha_token"
22    pub token_name: String,
23
24    /// The form name of the captcha answer
25    ///
26    /// Default: "captcha_answer"
27    pub answer_name: String,
28}
29
30impl CaptchaFormFinder {
31    /// Create a new CaptchaFormFinder
32    pub fn new() -> Self {
33        Self::default()
34    }
35
36    /// Set the token form name
37    pub fn token_name(mut self, token_name: String) -> Self {
38        self.token_name = token_name;
39        self
40    }
41
42    /// Set the answer form name
43    pub fn answer_name(mut self, answer_name: String) -> Self {
44        self.answer_name = answer_name;
45        self
46    }
47}
48
49impl Default for CaptchaFormFinder {
50    /// Create a default CaptchaFormFinder with:
51    /// - token_name: "captcha_token"
52    /// - answer_name: "captcha_answer"
53    fn default() -> Self {
54        Self {
55            token_name: "captcha_token".to_string(),
56            answer_name: "captcha_answer".to_string(),
57        }
58    }
59}
60
61impl CaptchaFinder for CaptchaFormFinder {
62    async fn find_token(&self, req: &mut Request) -> Option<Option<String>> {
63        req.form_data()
64            .await
65            .ok()
66            .and_then(|form| form.fields.get(&self.token_name).cloned().map(Some))
67    }
68
69    async fn find_answer(&self, req: &mut Request) -> Option<Option<String>> {
70        req.form_data()
71            .await
72            .ok()
73            .and_then(|form| form.fields.get(&self.answer_name).cloned().map(Some))
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use salvo_core::http::{header, HeaderValue, ReqBody};
80
81    use super::*;
82
83    #[tokio::test]
84    #[rstest::rstest]
85    #[case::not_found(
86        None,
87        None,
88        None,
89        None,
90        "application/x-www-form-urlencoded",
91        None,
92        None
93    )]
94    #[case::not_found(None, None, None, None, "text/plain", None, None)]
95    #[case::normal(
96        None,
97        None,
98        Some(("captcha_token", "token")),
99        Some(("captcha_answer", "answer")),
100        "application/x-www-form-urlencoded",
101        Some(Some("token")),
102        Some(Some("answer"))
103    )]
104    #[case::custom_keys(
105        Some("custom_token"),
106        Some("custom_answer"),
107        Some(("custom_token", "token")),
108        Some(("custom_answer", "answer")),
109        "application/x-www-form-urlencoded",
110        Some(Some("token")),
111        Some(Some("answer"))
112    )]
113    #[case::only_token(
114        None,
115        None,
116        Some(("captcha_token", "token")),
117        None,
118        "application/x-www-form-urlencoded",
119        Some(Some("token")),
120        None
121    )]
122    #[case::only_answer(
123        None,
124        None,
125        None,
126        Some(("captcha_answer", "answer")),
127        "application/x-www-form-urlencoded",
128        None,
129        Some(Some("answer"))
130    )]
131    #[case::custom_not_found(
132        Some("custom_token"),
133        Some("custom_answer"),
134        None,
135        None,
136        "application/x-www-form-urlencoded",
137        None,
138        None
139    )]
140    #[case::custom_not_found_with_body(
141        Some("custom_token"),
142        Some("custom_answer"),
143        Some(("captcha_token", "token")),
144        Some(("captcha_answer", "answer")),
145        "application/x-www-form-urlencoded",
146        None,
147        None
148    )]
149    #[case::invalid_type(
150        None,
151        None,
152        Some(("captcha_token", "token")),
153        Some(("captcha_answer", "answer")),
154        "application/json",
155        None,
156        None
157    )]
158    async fn test_form_finder(
159        #[case] custom_token_key: Option<&'static str>,
160        #[case] custom_answer_key: Option<&'static str>,
161        #[case] token_key_val: Option<(&'static str, &'static str)>,
162        #[case] answer_key_val: Option<(&'static str, &'static str)>,
163        #[case] content_type: &'static str,
164        #[case] excepted_token: Option<Option<&'static str>>,
165        #[case] excepted_answer: Option<Option<&'static str>>,
166    ) {
167        let mut req = Request::default();
168        let mut finder = CaptchaFormFinder::new();
169        if let Some(token_key) = custom_token_key {
170            finder = finder.token_name(token_key.to_string())
171        }
172        if let Some(answer_key) = custom_answer_key {
173            finder = finder.answer_name(answer_key.to_string())
174        }
175
176        let body = token_key_val
177            .zip(answer_key_val)
178            .map(|((t_k, t_v), (a_k, a_v))| format!("{t_k}={t_v}&{a_k}={a_v}"))
179            .unwrap_or_else(|| {
180                token_key_val
181                    .or(answer_key_val)
182                    .map(|(k, v)| format!("{k}={v}"))
183                    .unwrap_or_default()
184            });
185
186        *req.body_mut() = ReqBody::Once(body.into());
187        let headers = req.headers_mut();
188        headers.insert(
189            header::CONTENT_TYPE,
190            HeaderValue::from_str(content_type).unwrap(),
191        );
192
193        assert_eq!(
194            finder.find_token(&mut req).await,
195            excepted_token.map(|o| o.map(ToOwned::to_owned))
196        );
197        assert_eq!(
198            finder.find_answer(&mut req).await,
199            excepted_answer.map(|o| o.map(ToOwned::to_owned))
200        );
201    }
202}