Files
gitks/service/util.rs
T
2026-06-07 11:30:56 +08:00

155 lines
4.1 KiB
Rust

use crate::error::AppError;
use crate::models::common::Role;
pub fn merge_optional_text(next: Option<String>, current: Option<String>) -> Option<String> {
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<T>(
next: Option<String>,
current: T,
unknown: T,
name: &str,
) -> Result<T, AppError>
where
T: std::str::FromStr + PartialEq,
{
let Some(value) = next else {
return Ok(current);
};
let parsed = value
.trim()
.parse::<T>()
.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<String, AppError> {
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<String> {
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(())
}