use crate::error::AppError; use crate::models::common::Role; pub fn merge_optional_text(next: Option, current: Option) -> Option { next.map(|v| { let value = v.trim().to_string(); if value.is_empty() { None } else { Some(value) } }) .unwrap_or(current) } pub fn ensure_affected(rows_affected: u64, not_found: &str) -> Result<(), AppError> { if rows_affected == 0 { Err(AppError::NotFound(not_found.into())) } else { Ok(()) } } pub fn parse_enum( next: Option, current: T, unknown: T, name: &str, ) -> Result where T: std::str::FromStr + PartialEq, { let Some(value) = next else { return Ok(current); }; let parsed = value .trim() .parse::() .map_err(|_| AppError::BadRequest(format!("invalid {name}")))?; if parsed == unknown { return Err(AppError::BadRequest(format!("invalid {name}"))); } Ok(parsed) } pub fn required_text(value: String, field: &str) -> Result { let value = value.trim().to_string(); if value.is_empty() { return Err(AppError::BadRequest(format!("{field} is required"))); } Ok(value) } pub fn clamp_limit_offset(limit: i64, offset: i64) -> (i64, i64) { (limit.clamp(1, 100), offset.max(0)) } pub fn role_level(role: Role) -> i32 { match role { Role::Owner => 100, Role::Admin => 90, Role::Maintainer => 70, Role::Member => 50, Role::Contributor => 30, Role::Viewer => 10, Role::Guest => 5, _ => 0, } } pub fn constant_time_eq(a: &str, b: &str) -> bool { if a.len() != b.len() { return false; } a.bytes() .zip(b.bytes()) .fold(0, |acc, (x, y)| acc | (x ^ y)) == 0 } pub fn sha256_hex(data: &[u8]) -> String { use sha2::Digest; sha2::Sha256::digest(data) .iter() .map(|b| format!("{b:02x}")) .collect() } pub fn extract_storage_key_from_url(url: &str) -> Option { let path = url.split_once("://").map(|(_, rest)| rest)?; let path = path.split_once('/').map(|(_, rest)| rest).unwrap_or(path); if path.is_empty() { None } else { Some(path.to_string()) } } pub fn avatar_extension( content_type: Option<&str>, file_name: Option<&str>, ) -> Result<&'static str, AppError> { if let Some(ct) = content_type.map(str::trim).filter(|v| !v.is_empty()) { return match ct.to_ascii_lowercase().as_str() { "image/png" => Ok("png"), "image/jpeg" | "image/jpg" => Ok("jpg"), "image/webp" => Ok("webp"), "image/gif" => Ok("gif"), _ => Err(AppError::BadRequest( "unsupported avatar content type".into(), )), }; } let Some(file_name) = file_name else { return Err(AppError::BadRequest( "avatar content type is required".into(), )); }; match file_name .rsplit('.') .next() .unwrap_or_default() .to_ascii_lowercase() .as_str() { "png" => Ok("png"), "jpg" | "jpeg" => Ok("jpg"), "webp" => Ok("webp"), "gif" => Ok("gif"), _ => Err(AppError::BadRequest("unsupported avatar file type".into())), } } pub fn validate_avatar_size(size: usize, configured_max_size: u64) -> Result<(), AppError> { const MAX_AVATAR_SIZE: u64 = 5 * 1024 * 1024; const MIN_AVATAR_SIZE: u64 = 1024; if size == 0 { return Err(AppError::BadRequest("avatar is empty".into())); } let max_size = configured_max_size.clamp(MIN_AVATAR_SIZE, MAX_AVATAR_SIZE) as usize; if size > max_size { return Err(AppError::BadRequest("avatar is too large".into())); } Ok(()) } pub fn validate_password_strength(password: &str) -> Result<(), AppError> { if password.len() < 8 { return Err(AppError::PasswordTooWeak); } if !password.chars().any(|c| c.is_uppercase()) || !password.chars().any(|c| c.is_lowercase()) || !password.chars().any(|c| c.is_numeric()) { return Err(AppError::PasswordTooWeak); } Ok(()) }