Something went wrong. Try again.
A downstream fork of Tranquil PDS for use in margin.cafe
Something went wrong. Try again.
14 kB · 442 lines
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443use axum::{Json, extract::State};use serde::{Deserialize, Serialize};use serde_json::json;use tracing::info;use tranquil_db_traits::{CommsChannel, CommsStatus, CommsType};use tranquil_pds::api::error::{ApiError, DbResultExt};use tranquil_pds::auth::{Active, Auth};use tranquil_pds::state::AppState;use tranquil_types::{Did, Handle};
#[derive(Serialize)]#[serde(rename_all = "camelCase")]pub struct NotificationPrefsOutput { pub preferred_channel: CommsChannel, pub email: String, pub discord_username: Option<String>, pub discord_verified: bool, pub telegram_username: Option<String>, pub telegram_verified: bool, pub signal_username: Option<String>, pub signal_verified: bool,}
pub async fn get_notification_prefs( State(state): State<AppState>, auth: Auth<Active>,) -> Result<Json<NotificationPrefsOutput>, ApiError> { let prefs = state .repos .user .get_notification_prefs(&auth.did) .await .log_db_err("get notification prefs")? .ok_or(ApiError::AccountNotFound)?; Ok(Json(NotificationPrefsOutput { preferred_channel: prefs.preferred_channel, email: prefs.email, discord_username: prefs.discord_username, discord_verified: prefs.discord_verified, telegram_username: prefs.telegram_username, telegram_verified: prefs.telegram_verified, signal_username: prefs.signal_username, signal_verified: prefs.signal_verified, }))}
#[derive(Serialize)]#[serde(rename_all = "camelCase")]pub struct NotificationHistoryEntry { pub created_at: String, pub channel: CommsChannel, pub comms_type: CommsType, pub status: CommsStatus, pub subject: Option<String>, pub body: String,}
#[derive(Serialize)]#[serde(rename_all = "camelCase")]pub struct GetNotificationHistoryOutput { pub notifications: Vec<NotificationHistoryEntry>,}
pub async fn get_notification_history( State(state): State<AppState>, auth: Auth<Active>,) -> Result<Json<GetNotificationHistoryOutput>, ApiError> { let user_id = state .repos .user .get_id_by_did(&auth.did) .await .log_db_err("get user id by did")? .ok_or(ApiError::AccountNotFound)?;
let rows = state .repos .infra .get_notification_history(user_id, 50) .await .log_db_err("get notification history")?;
let sensitive_types = [ CommsType::EmailVerification, CommsType::PasswordReset, CommsType::EmailUpdate, CommsType::TwoFactorCode, CommsType::PasskeyRecovery, CommsType::MigrationVerification, CommsType::PlcOperation, CommsType::ChannelVerification, ];
let notifications = rows .iter() .map(|row| { let body = if sensitive_types.contains(&row.comms_type) { "[Code redacted for security]".to_string() } else { row.body.clone() }; NotificationHistoryEntry { created_at: row.created_at.to_rfc3339(), channel: row.channel, comms_type: row.comms_type, status: row.status, subject: row.subject.clone(), body, } }) .collect();
Ok(Json(GetNotificationHistoryOutput { notifications }))}
#[derive(Deserialize)]#[serde(rename_all = "camelCase")]pub struct UpdateNotificationPrefsInput { pub preferred_channel: Option<String>, pub email: Option<String>, pub discord_username: Option<String>, pub telegram_username: Option<String>, pub signal_username: Option<String>,}
#[derive(Serialize)]#[serde(rename_all = "camelCase")]pub struct UpdateNotificationPrefsOutput { pub success: bool, #[serde(skip_serializing_if = "Vec::is_empty")] pub verification_required: Vec<CommsChannel>,}
pub async fn request_channel_verification( state: &AppState, user_id: uuid::Uuid, did: &Did, channel: CommsChannel, identifier: &str, handle: Option<&Handle>,) -> Result<String, ApiError> { let token = tranquil_pds::auth::verification_token::generate_channel_update_token( did, channel, identifier, ); let formatted_token = tranquil_pds::auth::verification_token::format_token_for_display(&token);
match channel { CommsChannel::Email => { let hostname = &tranquil_config::get().server.hostname; let handle = handle.ok_or_else(|| { ApiError::InternalError(Some("Email verification requires a handle".into())) })?; tranquil_pds::comms::comms_repo::enqueue_email_update( state.repos.infra.as_ref(), user_id, identifier, handle, &formatted_token, hostname, ) .await .log_db_err("enqueue email verification")?; } _ => { let hostname = &tranquil_config::get().server.hostname; let encoded_token = urlencoding::encode(&formatted_token); let encoded_identifier = urlencoding::encode(identifier); let verify_link = format!( "https://{}/app/verify?token={}&identifier={}", hostname, encoded_token, encoded_identifier ); let prefs = state .repos .user .get_comms_prefs(user_id) .await .ok() .flatten(); let locale = prefs .as_ref() .and_then(|p| p.preferred_locale.as_deref()) .unwrap_or("en"); let strings = tranquil_pds::comms::get_strings(locale); let body = tranquil_pds::comms::format_message( strings.channel_verification_body, &[("code", &formatted_token), ("verify_link", &verify_link)], ); let subject = tranquil_pds::comms::format_message( strings.channel_verification_subject, &[("hostname", hostname)], ); let recipient = match channel { CommsChannel::Telegram => state .repos .user .get_telegram_chat_id(user_id) .await .ok() .flatten() .map(|id| id.to_string()) .unwrap_or_else(|| identifier.to_string()), _ => identifier.to_string(), }; state .repos .infra .enqueue_comms( Some(user_id), channel, tranquil_db_traits::CommsType::ChannelVerification, &recipient, Some(&subject), &body, Some(json!({"code": formatted_token})), ) .await .log_db_err("enqueue channel verification")?; } }
Ok(token)}
async fn process_messaging_channel_update( state: &AppState, user_id: uuid::Uuid, did: &Did, channel: CommsChannel, raw_value: &str, effective_channel: CommsChannel, verification_required: &mut Vec<CommsChannel>,) -> Result<(), ApiError> { let clean = match channel { CommsChannel::Discord => raw_value.trim().to_lowercase(), CommsChannel::Telegram => raw_value.trim_start_matches('@').to_string(), CommsChannel::Signal => raw_value.trim().trim_start_matches('@').to_lowercase(), CommsChannel::Email => raw_value.trim().to_lowercase(), };
if clean.is_empty() { if effective_channel == channel { return Err(ApiError::InvalidRequest(format!( "Cannot remove {:?} while it is the preferred notification channel", channel ))); } match channel { CommsChannel::Discord => state .repos .user .clear_discord(user_id) .await .log_db_err("clear discord")?, CommsChannel::Telegram => state .repos .user .clear_telegram(user_id) .await .log_db_err("clear telegram")?, CommsChannel::Signal => state .repos .user .clear_signal(user_id) .await .log_db_err("clear signal")?, CommsChannel::Email => {} }; info!(did = %did, channel = ?channel, "Cleared channel"); return Ok(()); }
let valid = match channel { CommsChannel::Discord => tranquil_pds::api::validation::is_valid_discord_username(&clean), CommsChannel::Telegram => tranquil_pds::api::validation::is_valid_telegram_username(&clean), CommsChannel::Signal => tranquil_pds::comms::is_valid_signal_username(&clean), CommsChannel::Email => tranquil_pds::api::validation::is_valid_email(&clean), }; if !valid { return Err(match channel { CommsChannel::Discord => ApiError::InvalidRequest( "Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(), ), CommsChannel::Telegram => ApiError::InvalidRequest( "Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(), ), CommsChannel::Signal => ApiError::InvalidRequest( "Invalid Signal username. Must be a 3-32 character nickname, a dot, then a 2-20 digit discriminator".into(), ), CommsChannel::Email => ApiError::InvalidEmail, }); }
match channel { CommsChannel::Discord => state .repos .user .set_unverified_discord(user_id, &clean) .await .log_db_err("set unverified discord")?, CommsChannel::Telegram => state .repos .user .set_unverified_telegram(user_id, &clean) .await .log_db_err("set unverified telegram")?, CommsChannel::Signal => state .repos .user .set_unverified_signal(user_id, &clean) .await .log_db_err("set unverified signal")?, CommsChannel::Email => {} };
if matches!(channel, CommsChannel::Signal) { request_channel_verification(state, user_id, did, channel, &clean, None).await?; }
verification_required.push(channel); info!(did = %did, channel = ?channel, value = %clean, "Stored unverified channel username"); Ok(())}
pub async fn update_notification_prefs( State(state): State<AppState>, auth: Auth<Active>, Json(input): Json<UpdateNotificationPrefsInput>,) -> Result<Json<UpdateNotificationPrefsOutput>, ApiError> { let user_row = state .repos .user .get_id_handle_email_by_did(&auth.did) .await .log_db_err("get user by did")? .ok_or(ApiError::AccountNotFound)?;
let user_id = user_row.id; let handle = user_row.handle; let current_email = user_row.email;
let current_prefs = state .repos .user .get_notification_prefs(&auth.did) .await .log_db_err("get notification prefs for update")? .ok_or(ApiError::AccountNotFound)?;
let effective_channel = input .preferred_channel .as_deref() .map(|ch| { ch.parse::<CommsChannel>().map_err(|_| { ApiError::InvalidRequest( "Invalid channel. Must be one of: email, discord, telegram, signal".into(), ) }) }) .transpose()? .unwrap_or(current_prefs.preferred_channel);
let mut verification_required: Vec<CommsChannel> = Vec::new();
if input.preferred_channel.is_some() { state .repos .user .update_preferred_comms_channel(&auth.did, effective_channel) .await .log_db_err("update preferred channel")?; info!(did = %auth.did, channel = ?effective_channel, "Updated preferred notification channel"); }
if let Some(ref new_email) = input.email { let email_clean = new_email.trim().to_lowercase(); if email_clean.is_empty() { return Err(ApiError::InvalidRequest("Email cannot be empty".into())); }
if !tranquil_pds::api::validation::is_valid_email(&email_clean) { return Err(ApiError::InvalidEmail); }
if current_email.as_ref().map(|e| e.to_lowercase()) != Some(email_clean.clone()) { request_channel_verification( &state, user_id, &auth.did, CommsChannel::Email, &email_clean, Some(&handle), ) .await?; verification_required.push(CommsChannel::Email); info!(did = %auth.did, "Requested email verification"); } }
if let Some(ref discord_username) = input.discord_username { process_messaging_channel_update( &state, user_id, &auth.did, CommsChannel::Discord, discord_username, effective_channel, &mut verification_required, ) .await?; }
if let Some(ref telegram) = input.telegram_username { process_messaging_channel_update( &state, user_id, &auth.did, CommsChannel::Telegram, telegram, effective_channel, &mut verification_required, ) .await?; }
if let Some(ref signal) = input.signal_username { process_messaging_channel_update( &state, user_id, &auth.did, CommsChannel::Signal, signal, effective_channel, &mut verification_required, ) .await?; }
Ok(Json(UpdateNotificationPrefsOutput { success: true, verification_required, }))}