1#![doc(html_root_url = "https://docs.rs/captcha-rs/latest")]
2
3use 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 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 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 for _ in 0..self.interference_lines {
228 draw_interference_line(&mut image, self.dark_mode);
229 }
230
231 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 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 let result = crate::verify(&token, "testjwt", secret).expect("Failed to verify JWT");
391 assert!(result);
392
393 let invalid_result = crate::verify(&token, "wrong", secret);
395 assert_eq!(invalid_result.unwrap(), false);
396
397 let invalid_secret_result = crate::verify(&token, "testjwt", "wrongsecret");
399 assert!(invalid_secret_result.is_none());
400
401 let invalid_token_result = crate::verify("invalid_token_string", "testjwt", secret);
403 assert!(invalid_token_result.is_none());
404 }
405}