geronimo_captcha/
manager.rs

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