use bcrypt::{DEFAULT_COST, hash}; use chrono::{DateTime, Utc}; use std::collections::HashMap; use tracing::error; use tranquil_db_traits::{CommsChannel, DidWebOverrides, SessionRepository, UserRepository}; use tranquil_pds::api::error::ApiError; use tranquil_pds::api::error::DbResultExt; use tranquil_pds::types::{AtIdentifier, Did, Handle}; pub struct ResolvedRepo { pub user_id: uuid::Uuid, pub did: Did, pub handle: Handle, } fn qualify_handle(handle: &Handle) -> Result { let raw = handle.as_str(); let qualified = match raw.contains('.') { true => return Ok(handle.clone()), false => format!( "{}.{}", raw, tranquil_config::get().server.hostname_without_port() ), }; qualified .parse() .map_err(|_| ApiError::InvalidRequest("Invalid handle format".into())) } pub async fn resolve_repo( user_repo: &dyn UserRepository, repo: &AtIdentifier, ) -> Result { let row = match repo { AtIdentifier::Did(did) => user_repo .get_by_did(did) .await .log_db_err("resolving repo by DID")?, AtIdentifier::Handle(handle) => { let qualified = qualify_handle(handle)?; user_repo .get_by_handle(&qualified) .await .log_db_err("resolving repo by handle")? } }; row.map(|r| ResolvedRepo { user_id: r.id, did: r.did, handle: r.handle, }) .ok_or(ApiError::RepoNotFound(Some("Repo not found".into()))) } pub async fn resolve_repo_user_id( user_repo: &dyn UserRepository, repo: &AtIdentifier, ) -> Result { let id = match repo { AtIdentifier::Did(did) => user_repo .get_id_by_did(did) .await .log_db_err("resolving repo user ID by DID")?, AtIdentifier::Handle(handle) => { let qualified = qualify_handle(handle)?; user_repo .get_id_by_handle(&qualified) .await .log_db_err("resolving repo user ID by handle")? } }; id.ok_or(ApiError::RepoNotFound(Some("Repo not found".into()))) } pub fn group_invite_uses_by_code( uses: Vec, map_use: F, ) -> HashMap> where F: Fn(tranquil_db_traits::InviteCodeUse) -> U, { uses.into_iter().fold(HashMap::new(), |mut acc, u| { let code = u.code.clone(); acc.entry(code).or_default().push(map_use(u)); acc }) } pub fn resolve_also_known_as( overrides: Option<&DidWebOverrides>, current_handle: &str, ) -> Vec { overrides .filter(|ovr| !ovr.also_known_as.is_empty()) .map(|ovr| ovr.also_known_as.clone()) .unwrap_or_else(|| vec![format!("at://{}", current_handle)]) } pub fn build_did_document( did: &str, also_known_as: Vec, verification_methods: Vec, service_endpoint: &str, ) -> serde_json::Value { serde_json::json!({ "@context": [ "https://www.w3.org/ns/did/v1", "https://w3id.org/security/multikey/v1", "https://w3id.org/security/suites/secp256k1-2019/v1" ], "id": did, "alsoKnownAs": also_known_as, "verificationMethod": verification_methods, "service": [{ "id": "#atproto_pds", "type": tranquil_pds::plc::ServiceType::Pds.as_str(), "serviceEndpoint": service_endpoint }] }) } pub async fn set_channel_verified_flag( user_repo: &dyn UserRepository, user_id: uuid::Uuid, channel: CommsChannel, ) -> Result<(), ApiError> { match channel { CommsChannel::Email => user_repo .set_email_verified_flag(user_id) .await .log_db_err("updating email verified status")?, CommsChannel::Discord => user_repo .set_discord_verified_flag(user_id) .await .log_db_err("updating discord verified status")?, CommsChannel::Telegram => user_repo .set_telegram_verified_flag(user_id) .await .log_db_err("updating telegram verified status")?, CommsChannel::Signal => user_repo .set_signal_verified_flag(user_id) .await .log_db_err("updating signal verified status")?, }; Ok(()) } pub struct ChannelInput<'a> { pub email: Option<&'a str>, pub discord_username: Option<&'a str>, pub telegram_username: Option<&'a str>, pub signal_username: Option<&'a str>, } pub fn extract_verification_recipient( channel: CommsChannel, input: &ChannelInput<'_>, ) -> Result { match channel { CommsChannel::Email => match input.email { Some(e) if !e.trim().is_empty() => Ok(e.trim().to_string()), _ => Err(ApiError::MissingEmail), }, CommsChannel::Discord => match input.discord_username { Some(username) if !username.trim().is_empty() => { let clean = username.trim().to_lowercase(); if !tranquil_pds::api::validation::is_valid_discord_username(&clean) { return Err(ApiError::InvalidRequest( "Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(), )); } Ok(clean) } _ => Err(ApiError::MissingDiscordId), }, CommsChannel::Telegram => match input.telegram_username { Some(username) if !username.trim().is_empty() => { let clean = username.trim().trim_start_matches('@'); if !tranquil_pds::api::validation::is_valid_telegram_username(clean) { return Err(ApiError::InvalidRequest( "Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(), )); } Ok(clean.to_string()) } _ => Err(ApiError::MissingTelegramUsername), }, CommsChannel::Signal => match input.signal_username { Some(username) if !username.trim().is_empty() => { Ok(username.trim().trim_start_matches('@').to_lowercase()) } _ => Err(ApiError::MissingSignalNumber), }, } } pub fn create_self_hosted_did_web(handle: &str) -> Result { if !tranquil_pds::util::is_self_hosted_did_web_enabled() { return Err(ApiError::SelfHostedDidWebDisabled); } let encoded_handle = handle.replace(':', "%3A"); Ok(format!("did:web:{}", encoded_handle)) } pub enum CredentialMatch { MainPassword, AppPassword { name: String, scopes: Option, controller_did: Option, }, } pub async fn verify_credential( session_repo: &dyn SessionRepository, user_id: uuid::Uuid, password: &str, password_hash: Option<&str>, ) -> Option { let main_valid = password_hash .map(|h| bcrypt::verify(password, h).unwrap_or(false)) .unwrap_or(false); if main_valid { return Some(CredentialMatch::MainPassword); } let app_passwords = session_repo .get_app_passwords_for_login(user_id) .await .unwrap_or_default(); app_passwords .into_iter() .find(|app| bcrypt::verify(password, &app.password_hash).unwrap_or(false)) .map(|app| { let scopes = app.scopes.unwrap_or_else(|| { if app.privilege.is_privileged() { "transition:generic transition:chat.bsky".to_string() } else { "transition:generic".to_string() } }); CredentialMatch::AppPassword { name: app.name, scopes: Some(scopes), controller_did: app.created_by_controller_did, } }) } pub fn hash_or_internal_error(value: &str) -> Result { bcrypt::hash(value, DEFAULT_COST).map_err(|e| { error!("Bcrypt hash error: {:?}", e); ApiError::InternalError(None) }) } pub async fn hash_password_async(password: &str) -> Result { let password = password.to_string(); tokio::task::spawn_blocking(move || hash(password, DEFAULT_COST)) .await .map_err(|e| { error!("Failed to spawn blocking task: {:?}", e); ApiError::InternalError(None) })? .map_err(|e| { error!("Failed to hash password: {:?}", e); ApiError::InternalError(None) }) } pub fn validate_token_hash( expires_at: Option>, stored_hash: &str, input_token: &str, expired_err: ApiError, invalid_err: ApiError, ) -> Result<(), ApiError> { match expires_at { Some(exp) if exp < Utc::now() => Err(expired_err), _ => match bcrypt::verify(input_token, stored_hash).unwrap_or(false) { true => Ok(()), false => Err(invalid_err), }, } }