geronimo_captcha/
manager.rs1use crate::error::{CaptchaError, Result};
2use crate::image::NoiseOptions;
3use crate::registry::ChallengeRegistry;
4use crate::sprite::{SpriteFormat, SpriteTarget};
5use crate::{RegistryCheckResult, challenge};
6
7use rand::prelude::IndexedRandom;
8use rand::rng;
9use std::sync::Arc;
10use tracing::{info, warn};
11use zeroize::Zeroizing;
12
13pub const SAMPLE_IMAGES: &[&[u8]] = &[
14 include_bytes!("../assets/sample1.jpg"),
15 include_bytes!("../assets/sample2.jpg"),
16 include_bytes!("../assets/sample3.jpg"),
17 include_bytes!("../assets/sample4.jpg"),
18 include_bytes!("../assets/sample5.jpg"),
19 include_bytes!("../assets/sample6.jpg"),
20 include_bytes!("../assets/sample7.jpg"),
21];
22
23pub struct CaptchaManager {
24 registry: Option<Arc<dyn ChallengeRegistry>>,
25 challenge_ttl: u64,
26 noise: NoiseOptions,
27 secret: Zeroizing<Vec<u8>>,
28 gen_opts: challenge::GenerationOptions,
29}
30
31impl CaptchaManager {
32 pub fn new(
33 secret: String,
34 challenge_ttl: u64,
35 noise: NoiseOptions,
36 registry: Option<Arc<dyn ChallengeRegistry>>,
37 gen_opts: challenge::GenerationOptions,
38 ) -> Self {
39 Self {
40 registry,
41 challenge_ttl,
42 noise,
43 secret: Zeroizing::new(secret.into_bytes()),
44 gen_opts,
45 }
46 }
47
48 pub fn generate_challenge<T: SpriteTarget>(&self) -> Result<challenge::CaptchaChallenge<T>> {
49 let sample_image = match SAMPLE_IMAGES.choose(&mut rng()) {
50 Some(img) => *img,
51 None => return Err(CaptchaError::Internal("no sample images available".into())),
52 };
53
54 let challenge = challenge::generate::<T>(
55 sample_image,
56 self.secret.as_slice(),
57 &self.gen_opts,
58 self.noise,
59 )?;
60
61 if let Some(reg) = &self.registry {
62 reg.register(&challenge.challenge_id);
63 }
64
65 let (format, quality, lossless) = match self.gen_opts.sprite_format {
66 SpriteFormat::Jpeg { quality } => ("jpeg", quality, false),
67 SpriteFormat::Webp { quality, lossless } => (
68 if lossless { "webp-lossless" } else { "webp" },
69 quality,
70 lossless,
71 ),
72 };
73
74 info!(
75 cell_size = self.gen_opts.cell_size,
76 format = format,
77 quality = quality,
78 lossless = lossless,
79 "captcha generated"
80 );
81
82 Ok(challenge)
83 }
84
85 pub fn verify_challenge(&self, challenge_id: &str, selected_index: u8) -> Result<bool> {
86 if challenge_id.is_empty() {
87 return Err(CaptchaError::InvalidInput(
88 "Challenge ID cannot be empty".into(),
89 ));
90 }
91
92 if selected_index == 0 || selected_index > 9 {
93 return Err(CaptchaError::InvalidInput(
94 "Selected index out of bounds".into(),
95 ));
96 }
97
98 if let Some(registry) = &self.registry {
99 let result = registry.check(challenge_id);
100 if result != RegistryCheckResult::Ok {
101 warn!("challenge rejected by registry: {result}");
102 return Err(CaptchaError::Registry(result));
103 }
104 }
105
106 let valid = challenge::verify(
107 self.secret.as_slice(),
108 challenge_id,
109 selected_index,
110 self.challenge_ttl,
111 );
112
113 if valid {
114 if let Some(registry) = &self.registry {
115 registry.verify(challenge_id);
116 }
117
118 info!("captcha verified successfully");
119 } else if let Some(registry) = &self.registry {
120 registry.note_attempt(challenge_id, false);
121 warn!("captcha verification failed");
122 }
123
124 Ok(valid)
125 }
126}