use crate::error::{CaptchaError, Result};
use crate::image::NoiseOptions;
use crate::registry::ChallengeRegistry;
use crate::sprite::{SpriteFormat, SpriteTarget};
use crate::{RegistryCheckResult, challenge};
use rand::prelude::IndexedRandom;
use rand::rng;
use std::sync::Arc;
use tracing::{info, warn};
use zeroize::Zeroizing;
pub const SAMPLE_IMAGES: &[&[u8]] = &[
include_bytes!("../assets/sample1.jpg"),
include_bytes!("../assets/sample2.jpg"),
include_bytes!("../assets/sample3.jpg"),
include_bytes!("../assets/sample4.jpg"),
include_bytes!("../assets/sample5.jpg"),
include_bytes!("../assets/sample6.jpg"),
include_bytes!("../assets/sample7.jpg"),
];
pub struct CaptchaManager {
registry: Option<Arc<dyn ChallengeRegistry>>,
challenge_ttl: u64,
noise: NoiseOptions,
secret: Zeroizing<Vec<u8>>,
gen_opts: challenge::GenerationOptions,
}
impl CaptchaManager {
pub fn new(
secret: String,
challenge_ttl: u64,
noise: NoiseOptions,
registry: Option<Arc<dyn ChallengeRegistry>>,
gen_opts: challenge::GenerationOptions,
) -> Self {
Self {
registry,
challenge_ttl,
noise,
secret: Zeroizing::new(secret.into_bytes()),
gen_opts,
}
}
pub fn generate_challenge<T: SpriteTarget>(&self) -> Result<challenge::CaptchaChallenge<T>> {
let sample_image = match SAMPLE_IMAGES.choose(&mut rng()) {
Some(img) => *img,
None => return Err(CaptchaError::Internal("no sample images available".into())),
};
let challenge = challenge::generate::<T>(
sample_image,
self.secret.as_slice(),
&self.gen_opts,
self.noise,
)?;
if let Some(reg) = &self.registry {
reg.register(&challenge.challenge_id);
}
let (format, quality, lossless) = match self.gen_opts.sprite_format {
SpriteFormat::Jpeg { quality } => ("jpeg", quality, false),
SpriteFormat::Webp { quality, lossless } => (
if lossless { "webp-lossless" } else { "webp" },
quality,
lossless,
),
};
info!(
cell_size = self.gen_opts.cell_size,
format = format,
quality = quality,
lossless = lossless,
"captcha generated"
);
Ok(challenge)
}
pub fn verify_challenge(&self, challenge_id: &str, selected_index: u8) -> Result<bool> {
if challenge_id.is_empty() {
return Err(CaptchaError::InvalidInput(
"Challenge ID cannot be empty".into(),
));
}
if selected_index == 0 || selected_index > 9 {
return Err(CaptchaError::InvalidInput(
"Selected index out of bounds".into(),
));
}
if let Some(registry) = &self.registry {
let result = registry.check(challenge_id);
if result != RegistryCheckResult::Ok {
warn!("challenge rejected by registry: {result}");
return Err(CaptchaError::Registry(result));
}
}
let valid = challenge::verify(
self.secret.as_slice(),
challenge_id,
selected_index,
self.challenge_ttl,
);
if valid {
if let Some(registry) = &self.registry {
registry.verify(challenge_id);
}
info!("captcha verified successfully");
} else if let Some(registry) = &self.registry {
registry.note_attempt(challenge_id, false);
warn!("captcha verification failed");
}
Ok(valid)
}
}