420dedbc1e
- Add IM service modules: audit, channel roles, custom emojis, forum tags, integrations, invitations, repo links, slash commands, stages, voice, webhooks - Add PR service modules: review requests, templates - Add repo service modules: contributors, release assets, git extras (archive, branch rename, commit extras, diff/merge, tag, tree) - Add user service: social (follow/block) - Add internal auth service - Update existing service modules with expanded functionality - Remove deleted IM modules: articles, delivery trace, drafts, follows, messages, polls, presence, reactions, threads
165 lines
4.6 KiB
Rust
165 lines
4.6 KiB
Rust
use crate::error::AppError;
|
|
use crate::models::common::Role;
|
|
use uuid::Uuid;
|
|
|
|
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(())
|
|
}
|
|
|
|
/// Build a `SET LOCAL app.current_user_id` statement with the UUID interpolated
|
|
/// directly. PostgreSQL `SET` is a utility command that does not support
|
|
/// parameterised `$1` placeholders through the extended query protocol.
|
|
///
|
|
/// Returns an `AssertSqlSafe` wrapper so sqlx 0.9 accepts the dynamic string.
|
|
pub fn set_local_user_id(user_uid: Uuid) -> sqlx::AssertSqlSafe<String> {
|
|
sqlx::AssertSqlSafe(format!("SET LOCAL app.current_user_id = '{user_uid}'"))
|
|
}
|