mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-07-27 05:12:37 +00:00
277 lines
8.8 KiB
Rust
277 lines
8.8 KiB
Rust
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<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<String, 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<String, ApiError> {
|
|
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<String>,
|
|
controller_did: Option<Did>,
|
|
},
|
|
}
|
|
|
|
pub async fn verify_credential(
|
|
session_repo: &dyn SessionRepository,
|
|
user_id: uuid::Uuid,
|
|
password: &str,
|
|
password_hash: Option<&str>,
|
|
) -> Option<CredentialMatch> {
|
|
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| CredentialMatch::AppPassword {
|
|
name: app.name,
|
|
scopes: app.scopes,
|
|
controller_did: app.created_by_controller_did,
|
|
})
|
|
}
|
|
|
|
pub fn hash_or_internal_error(value: &str) -> Result<String, ApiError> {
|
|
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<String, 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_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),
|
|
},
|
|
}
|
|
}
|