Something went wrong. Try again.
Our Personal Data Server from scratch!
Something went wrong. Try again.
9.6 kB · 289 lines
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290use 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, PasswordHash};
pub struct ResolvedRepo { pub user_id: uuid::Uuid, pub did: Did, pub handle: Handle,}
fn qualify_handle(handle: &Handle) -> Result<Handle, ApiError> { 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<ResolvedRepo, ApiError> { 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<uuid::Uuid, ApiError> { 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<U, F>( uses: Vec<tranquil_db_traits::InviteCodeUse>, map_use: F,) -> HashMap<tranquil_types::InviteCode, Vec<U>>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<String> { 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<String>, verification_methods: Vec<serde_json::Value>, 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<String, ApiError> { 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<Did, ApiError> { if !tranquil_pds::util::is_self_hosted_did_web_enabled() { return Err(ApiError::SelfHostedDidWebDisabled); } let encoded_handle = handle.replace(':', "%3A"); Did::new(format!("did:web:{}", encoded_handle)) .map_err(|_| ApiError::InvalidHandle(Some("Handle is not a valid did:web".into())))}
pub enum CredentialMatch { MainPassword, AppPassword { name: String, scopes: Option<String>, controller_did: Option<Did>, },}
pub async fn verify_credential( session_repo: &dyn SessionRepository, user_id: uuid::Uuid, password: &str, password_hash: Option<&PasswordHash>,) -> Option<CredentialMatch> { let main_valid = password_hash .map(|h| bcrypt::verify(password, h.as_str()).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.as_str()).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<PasswordHash, ApiError> { bcrypt::hash(value, DEFAULT_COST) .map(PasswordHash::new) .map_err(|e| { error!("Bcrypt hash error: {:?}", e); ApiError::InternalError(None) })}
pub async fn hash_password_async(password: &str) -> Result<PasswordHash, ApiError> { 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(PasswordHash::new) .map_err(|e| { error!("Failed to hash password: {:?}", e); ApiError::InternalError(None) })}
pub fn validate_token_hash( expires_at: Option<DateTime<Utc>>, 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), }, }}