Skip to main content

captcha_rs/
lib.rs

1#![doc(html_root_url = "https://docs.rs/captcha-rs/latest")]
2
3//! Generate a verification image.
4//!
5//! ```rust
6//! use captcha_rs::{CaptchaBuilder};
7//!
8//! let captcha = CaptchaBuilder::new()
9//!     .length(5)
10//!     .width(130)
11//!     .height(40)
12//!     .dark_mode(false)
13//!     .complexity(1) // min: 1, max: 10
14//!     .compression(40) // min: 1, max: 99
15//!     .build();
16//!
17//! println!("text: {}", captcha.text);
18//! let base_img = captcha.to_base64();
19//! println!("base_img: {}", base_img);
20//! ```
21use image::DynamicImage;
22use imageproc::noise::{gaussian_noise_mut, salt_and_pepper_noise_mut};
23use rand::{rng, Rng};
24
25use crate::captcha::{
26    cyclic_write_character, draw_interference_ellipse, draw_interference_line, get_image,
27    to_base64_str,
28};
29
30mod captcha;
31
32pub struct Captcha {
33    pub text: String,
34    pub image: DynamicImage,
35    pub compression: u8,
36    pub dark_mode: bool,
37}
38
39#[cfg(feature = "stateless")]
40#[derive(Debug, serde::Serialize, serde::Deserialize)]
41struct Claims {
42    hash: String,
43    exp: usize,
44}
45
46impl Captcha {
47    pub fn to_base64(&self) -> String {
48        to_base64_str(&self.image, self.compression)
49    }
50
51    #[cfg(feature = "stateless")]
52    pub fn as_token(&self, secret: &str, expiration_seconds: u64) -> Option<String> {
53        use jsonwebtoken::{encode, EncodingKey, Header};
54        use sha2::{Digest, Sha256};
55        use std::time::{SystemTime, UNIX_EPOCH};
56
57        let exp = SystemTime::now()
58            .duration_since(UNIX_EPOCH)
59            .expect("Time went backwards")
60            .as_secs()
61            + expiration_seconds;
62
63        let mut hasher = Sha256::new();
64        hasher.update(secret.as_bytes());
65        hasher.update(self.text.to_lowercase().as_bytes());
66        let hash_result = hasher.finalize();
67        let hash = base64::Engine::encode(
68            &base64::engine::general_purpose::URL_SAFE_NO_PAD,
69            hash_result,
70        );
71
72        let claims = Claims {
73            hash,
74            exp: exp as usize,
75        };
76
77        encode(
78            &Header::default(),
79            &claims,
80            &EncodingKey::from_secret(secret.as_ref()),
81        ).ok()
82    }
83
84    #[cfg(feature = "stateless")]
85    pub fn as_tuple(&self, secret: &str, expiration_seconds: u64) -> Option<(String, String)> {
86        self.as_token(secret, expiration_seconds)
87            .map(|token| (self.to_base64(), token))
88    }
89}
90
91#[cfg(feature = "stateless")]
92pub fn verify(token: &str, provided_solution: &str, secret: &str) -> Option<bool> {
93    use jsonwebtoken::{decode, DecodingKey, Validation};
94    use sha2::{Digest, Sha256};
95
96    let token_data = decode::<Claims>(
97        token,
98        &DecodingKey::from_secret(secret.as_ref()),
99        &Validation::default(),
100    ).ok()?;
101
102    let mut hasher = Sha256::new();
103    hasher.update(secret.as_bytes());
104    hasher.update(provided_solution.to_lowercase().as_bytes());
105    let expected_hash_result = hasher.finalize();
106    let expected_hash = base64::Engine::encode(
107        &base64::engine::general_purpose::URL_SAFE_NO_PAD,
108        expected_hash_result,
109    );
110
111    Some(token_data.claims.hash == expected_hash)
112}
113
114#[derive(Default)]
115pub struct CaptchaBuilder {
116    text: Option<String>,
117    length: usize,
118    characters: Vec<char>,
119    width: u32,
120    height: u32,
121    dark_mode: bool,
122    complexity: u32,
123    compression: u8,
124    drop_shadow: bool,
125    interference_lines: usize,
126    interference_ellipses: usize,
127    distortion: u32,
128}
129
130impl CaptchaBuilder {
131    pub fn new() -> Self {
132        CaptchaBuilder {
133            text: None,
134            length: 5,
135            characters: captcha::BASIC_CHAR.to_vec(),
136            width: 130,
137            height: 40,
138            dark_mode: false,
139            complexity: 1,
140            compression: 40,
141            drop_shadow: false,
142            interference_lines: 2,
143            interference_ellipses: 2,
144            distortion: 0,
145        }
146    }
147
148    pub fn text(mut self, text: String) -> Self {
149        self.text = Some(text.chars().take(32).collect());
150        self
151    }
152
153    pub fn length(mut self, length: usize) -> Self {
154        self.length = length.clamp(1, 32);
155        self
156    }
157
158    pub fn chars(mut self, chars: Vec<char>) -> Self {
159        self.characters = chars;
160        self
161    }
162
163    pub fn width(mut self, width: u32) -> Self {
164        self.width = width.clamp(30, 2000);
165        self
166    }
167
168    pub fn height(mut self, height: u32) -> Self {
169        self.height = height.clamp(20, 2000);
170        self
171    }
172
173    pub fn dark_mode(mut self, dark_mode: bool) -> Self {
174        self.dark_mode = dark_mode;
175        self
176    }
177
178    pub fn complexity(mut self, complexity: u32) -> Self {
179        self.complexity = complexity.clamp(1, 10);
180        self
181    }
182
183    pub fn compression(mut self, compression: u8) -> Self {
184        self.compression = compression.clamp(1, 99);
185        self
186    }
187
188    pub fn drop_shadow(mut self, drop_shadow: bool) -> Self {
189        self.drop_shadow = drop_shadow;
190        self
191    }
192
193    pub fn interference_lines(mut self, lines: usize) -> Self {
194        self.interference_lines = lines.min(100);
195        self
196    }
197
198    pub fn interference_ellipses(mut self, ellipses: usize) -> Self {
199        self.interference_ellipses = ellipses.min(100);
200        self
201    }
202
203    pub fn distortion(mut self, distortion: u32) -> Self {
204        self.distortion = distortion.min(100);
205        self
206    }
207
208    pub fn build(self) -> Captcha {
209        let text = match self.text {
210            Some(t) if !t.is_empty() => t,
211            _ => captcha::get_captcha(self.length, &self.characters).join(""),
212        };
213
214        // Create a background image
215        let mut image = get_image(self.width, self.height, self.dark_mode);
216
217        let res: Vec<String> = text.chars().map(|x| x.to_string()).collect();
218
219        // Loop to write the verification code string into the background image
220        cyclic_write_character(&res, &mut image, self.dark_mode, self.drop_shadow);
221
222        if self.distortion > 0 {
223            captcha::apply_wavy_distortion(&mut image, self.distortion);
224        }
225
226        // Draw interference lines
227        for _ in 0..self.interference_lines {
228            draw_interference_line(&mut image, self.dark_mode);
229        }
230
231        // Draw distraction circles
232        draw_interference_ellipse(self.interference_ellipses, &mut image, self.dark_mode);
233
234        if self.complexity > 1 {
235            let mut rng = rng();
236
237            gaussian_noise_mut(
238                &mut image,
239                (self.complexity - 1) as f64,
240                ((5 * self.complexity) - 5) as f64,
241                rng.random::<u64>(),
242            );
243
244            salt_and_pepper_noise_mut(
245                &mut image,
246                (0.002 * self.complexity as f64) - 0.002,
247                rng.random::<u64>(),
248            );
249        }
250
251        Captcha {
252            text,
253            image: DynamicImage::ImageRgb8(image),
254            compression: self.compression,
255            dark_mode: self.dark_mode,
256        }
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use crate::CaptchaBuilder;
263
264    #[test]
265    fn it_generates_a_captcha() {
266        let _dark_mode = false;
267        let _text_length = 5;
268        let _width = 130;
269        let _height = 40;
270
271        let start = std::time::Instant::now();
272
273        let captcha = CaptchaBuilder::new()
274            .text(String::from("based"))
275            .width(200)
276            .height(70)
277            .dark_mode(false)
278            .build();
279
280        let duration = start.elapsed();
281        println!("Time elapsed in generating captcha() is: {:?}", duration);
282
283        assert_eq!(captcha.text.len(), 5);
284        let base_img = captcha.to_base64();
285        assert!(base_img.starts_with("data:image/jpeg;base64,"));
286        println!("text: {}", captcha.text);
287        println!("base_img: {}", base_img);
288    }
289
290    #[test]
291    fn it_generates_captcha_using_builder() {
292        let start = std::time::Instant::now();
293        let captcha = CaptchaBuilder::new()
294            .length(5)
295            .width(200)
296            .height(70)
297            .dark_mode(false)
298            .complexity(5)
299            .compression(40)
300            .build();
301
302        let duration = start.elapsed();
303        println!("Time elapsed in generating captcha() is: {:?}", duration);
304
305        assert_eq!(captcha.text.len(), 5);
306        let base_img = captcha.to_base64();
307        assert!(base_img.starts_with("data:image/jpeg;base64,"));
308        println!("text: {}", captcha.text);
309        println!("base_img: {}", base_img);
310    }
311
312    #[test]
313    fn it_handles_empty_text_and_small_dimensions() {
314        let captcha = CaptchaBuilder::new()
315            .text(String::new())
316            .width(10)
317            .height(10)
318            .compression(0)
319            .build();
320
321        assert!(!captcha.text.is_empty());
322        let base_img = captcha.to_base64();
323        assert!(base_img.starts_with("data:image/jpeg;base64,"));
324    }
325
326    #[test]
327    fn it_generates_captcha_with_distortion() {
328        let captcha = CaptchaBuilder::new()
329            .text(String::from("wavy"))
330            .width(200)
331            .height(70)
332            .distortion(5)
333            .build();
334
335        assert_eq!(captcha.text, "wavy");
336        let base_img = captcha.to_base64();
337        assert!(base_img.starts_with("data:image/jpeg;base64,"));
338    }
339
340    #[test]
341    fn it_generates_captcha_with_custom_interference_and_shadow() {
342        let captcha = CaptchaBuilder::new()
343            .text(String::from("shadow"))
344            .width(200)
345            .height(70)
346            .drop_shadow(true)
347            .interference_lines(5)
348            .interference_ellipses(5)
349            .build();
350
351        assert_eq!(captcha.text, "shadow");
352        let base_img = captcha.to_base64();
353        assert!(base_img.starts_with("data:image/jpeg;base64,"));
354    }
355
356    #[test]
357    fn it_generates_captcha_with_custom_characters() {
358        let captcha = CaptchaBuilder::new()
359            .chars(vec!['A', 'B'])
360            .length(10)
361            .width(200)
362            .height(70)
363            .build();
364
365        assert_eq!(captcha.text.len(), 10);
366        assert!(captcha.text.chars().all(|c| c == 'A' || c == 'B'));
367        let base_img = captcha.to_base64();
368        assert!(base_img.starts_with("data:image/jpeg;base64,"));
369    }
370
371    #[test]
372    #[cfg(feature = "stateless")]
373    fn it_generates_and_verifies_jwt() {
374        let captcha = CaptchaBuilder::new()
375            .text(String::from("TestJWT"))
376            .width(200)
377            .height(70)
378            .build();
379
380        let secret = "supersecretkey";
381
382        // Test as_tuple
383        let (base64, tuple_token) = captcha.as_tuple(secret, 60).expect("Failed to create tuple");
384        assert!(base64.starts_with("data:image/jpeg;base64,"));
385        assert!(!tuple_token.is_empty());
386
387        let token = captcha.as_token(secret, 60).expect("Failed to create JWT");
388
389        // Valid test
390        let result = crate::verify(&token, "testjwt", secret).expect("Failed to verify JWT");
391        assert!(result);
392
393        // Invalid solution test
394        let invalid_result = crate::verify(&token, "wrong", secret);
395        assert_eq!(invalid_result.unwrap(), false);
396
397        // Invalid secret test
398        let invalid_secret_result = crate::verify(&token, "testjwt", "wrongsecret");
399        assert!(invalid_secret_result.is_none());
400
401        // Invalid token test
402        let invalid_token_result = crate::verify("invalid_token_string", "testjwt", secret);
403        assert!(invalid_token_result.is_none());
404    }
405}