use anyhow::{anyhow, bail, Result};
use argon2::{
Argon2,
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng},
};
use chrono::Utc;
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set,
};
use uuid::Uuid;
use crate::addons::voicemail::models::mailbox::{
ActiveModel as MailboxActiveModel, Column as MailboxColumn, Entity as MailboxEntity,
Model as Mailbox,
};
const PIN_MIN_LEN: usize = 4;
const PIN_MAX_LEN: usize = 8;
pub fn validate_pin(pin: &str) -> Result<()> {
if pin.len() < PIN_MIN_LEN || pin.len() > PIN_MAX_LEN {
bail!(
"PIN must be {}-{} digits",
PIN_MIN_LEN,
PIN_MAX_LEN
);
}
if !pin.chars().all(|c| c.is_ascii_digit()) {
bail!("PIN must contain digits only");
}
let digits: Vec<u8> = pin.bytes().map(|b| b - b'0').collect();
if digits.windows(2).all(|w| w[0] == w[1]) {
bail!("PIN must not be all the same digit");
}
if digits.windows(2).all(|w| w[1] == w[0] + 1) {
bail!("PIN must not be a simple ascending sequence");
}
if digits.windows(2).all(|w| w[0] == w[1] + 1) {
bail!("PIN must not be a simple descending sequence");
}
Ok(())
}
fn hash_pin(pin: &str) -> Result<String> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let hash = argon2
.hash_password(pin.as_bytes(), &salt)
.map_err(|e| anyhow::anyhow!("PIN hashing failed: {}", e))?;
Ok(hash.to_string())
}
fn verify_pin_hash(pin: &str, hash: &str) -> Result<bool> {
let parsed = PasswordHash::new(hash)
.map_err(|e| anyhow::anyhow!("invalid PIN hash in database: {}", e))?;
Ok(Argon2::default()
.verify_password(pin.as_bytes(), &parsed)
.is_ok())
}
#[derive(Clone)]
pub struct MailboxService {
pub db: DatabaseConnection,
}
impl MailboxService {
pub fn new(db: DatabaseConnection) -> Self {
Self { db }
}
pub async fn find_by_extension(&self, extension: &str) -> Result<Option<Mailbox>> {
let row = MailboxEntity::find()
.filter(MailboxColumn::Extension.eq(extension))
.one(&self.db)
.await?;
Ok(row)
}
pub async fn create_mailbox(
&self,
extension: &str,
email: Option<&str>,
pin: &str,
) -> Result<Mailbox> {
validate_pin(pin)?;
if self.find_by_extension(extension).await?.is_some() {
bail!("mailbox for extension {} already exists", extension);
}
let now = Utc::now().naive_utc();
let row = MailboxActiveModel {
id: Set(Uuid::new_v4()),
extension: Set(extension.to_string()),
pin: Set(hash_pin(pin)?),
email: Set(email.map(str::to_string)),
greeting_path: Set(None),
created_at: Set(now),
updated_at: Set(now),
};
let id = row.id.clone().unwrap();
MailboxEntity::insert(row)
.exec_without_returning(&self.db)
.await?;
let model = MailboxEntity::find_by_id(id)
.one(&self.db)
.await?
.ok_or_else(|| anyhow!("mailbox not found after insert"))?;
Ok(model)
}
pub async fn get_or_create(
&self,
extension: &str,
) -> Result<(Mailbox, Option<String>)> {
if let Some(existing) = self.find_by_extension(extension).await? {
return Ok((existing, None));
}
let pin = generate_safe_pin();
let mailbox = self.create_mailbox(extension, None, &pin).await?;
Ok((mailbox, Some(pin)))
}
pub async fn verify_pin(&self, extension: &str, candidate_pin: &str) -> Result<bool> {
let mailbox = self
.find_by_extension(extension)
.await?
.ok_or_else(|| anyhow::anyhow!("no mailbox for extension {}", extension))?;
verify_pin_hash(candidate_pin, &mailbox.pin)
}
pub async fn set_pin(
&self,
extension: &str,
old_pin: Option<&str>,
new_pin: &str,
) -> Result<()> {
validate_pin(new_pin)?;
let mailbox = self
.find_by_extension(extension)
.await?
.ok_or_else(|| anyhow::anyhow!("no mailbox for extension {}", extension))?;
if let Some(old) = old_pin {
if !verify_pin_hash(old, &mailbox.pin)? {
bail!("incorrect current PIN");
}
}
if verify_pin_hash(new_pin, &mailbox.pin)? {
bail!("new PIN must differ from the current PIN");
}
let mut active: MailboxActiveModel = mailbox.into();
active.pin = Set(hash_pin(new_pin)?);
active.updated_at = Set(Utc::now().naive_utc());
active.update(&self.db).await?;
Ok(())
}
pub async fn reset_pin(&self, extension: &str) -> Result<String> {
let new_pin = generate_safe_pin();
self.set_pin(extension, None, &new_pin).await?;
Ok(new_pin)
}
pub async fn set_greeting_path(&self, extension: &str, path: Option<&str>) -> Result<()> {
let mailbox = self
.find_by_extension(extension)
.await?
.ok_or_else(|| anyhow::anyhow!("no mailbox for extension {}", extension))?;
let mut active: MailboxActiveModel = mailbox.into();
active.greeting_path = Set(path.map(str::to_string));
active.updated_at = Set(Utc::now().naive_utc());
active.update(&self.db).await?;
Ok(())
}
pub async fn set_email(&self, extension: &str, email: Option<&str>) -> Result<()> {
let mailbox = self
.find_by_extension(extension)
.await?
.ok_or_else(|| anyhow::anyhow!("no mailbox for extension {}", extension))?;
let mut active: MailboxActiveModel = mailbox.into();
active.email = Set(email.map(str::to_string));
active.updated_at = Set(Utc::now().naive_utc());
active.update(&self.db).await?;
Ok(())
}
}
fn generate_safe_pin() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let t = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let seed = t.as_micros() as u64;
let mixed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
let digits = format!("{:06}", mixed % 1_000_000);
if validate_pin(&digits).is_ok() {
digits
} else {
format!("{:06}", (mixed.wrapping_add(137) % 900_000) + 100_000)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pin_validation_rules() {
assert!(validate_pin("1234").is_err(), "ascending sequence");
assert!(validate_pin("4321").is_err(), "descending sequence");
assert!(validate_pin("1111").is_err(), "all-same");
assert!(validate_pin("abc").is_err(), "non-digits");
assert!(validate_pin("12").is_err(), "too short");
assert!(validate_pin("123456789").is_err(), "too long");
assert!(validate_pin("1357").is_ok());
assert!(validate_pin("9182").is_ok());
assert!(validate_pin("246810").is_ok());
}
#[test]
fn pin_hash_roundtrip() {
let pin = "9182";
let hash = hash_pin(pin).unwrap();
assert!(verify_pin_hash(pin, &hash).unwrap());
assert!(!verify_pin_hash("9183", &hash).unwrap());
}
#[test]
fn generated_pin_passes_policy() {
for _ in 0..20 {
let pin = generate_safe_pin();
assert_eq!(pin.len(), 6);
validate_pin(&pin).unwrap_or_else(|e| panic!("generated PIN '{}' failed: {}", pin, e));
}
}
}