geronimo_captcha/
manager.rs

1use 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}