diff --git a/crates/tranquil-api/src/actor/preferences.rs b/crates/tranquil-api/src/actor/preferences.rs index c674194..4c63e66 100644 --- a/crates/tranquil-api/src/actor/preferences.rs +++ b/crates/tranquil-api/src/actor/preferences.rs @@ -69,7 +69,7 @@ pub async fn get_preferences(State(state): State, auth: Auth MAX_PREFERENCE_SIZE { return PrefValidation::TooLarge(pref_str.len()); } - let pref_type = match pref.get("$type").and_then(|t| t.as_str()) { + let pref_type = match pref.get("$type").and_then(Value::as_str) { Some(t) => t, None => return PrefValidation::MissingType, }; @@ -179,7 +179,7 @@ pub async fn put_preferences( .preferences .into_iter() .filter_map(|pref| { - let pref_type = pref.get("$type").and_then(|t| t.as_str())?; + let pref_type = pref.get("$type").and_then(Value::as_str)?; if pref_type == DECLARED_AGE_PREF { return None; } diff --git a/crates/tranquil-api/src/age_assurance.rs b/crates/tranquil-api/src/age_assurance.rs index 8c8cfba..6a528f3 100644 --- a/crates/tranquil-api/src/age_assurance.rs +++ b/crates/tranquil-api/src/age_assurance.rs @@ -1,37 +1,60 @@ use axum::{ Json, extract::State, - http::{HeaderMap, Method, StatusCode}, - response::{IntoResponse, Response}, + http::{HeaderMap, Method}, }; -use serde_json::json; +use serde::Serialize; use tranquil_pds::auth::{ AccountRequirement, extract_auth_token_from_header, validate_token_with_dpop, }; use tranquil_pds::state::AppState; -pub async fn get_state(State(state): State, headers: HeaderMap) -> Response { +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgeAssuranceState { + pub status: &'static str, + pub access: &'static str, + pub last_initiated_at: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgeAssuranceMetadata { + pub account_created_at: Option, +} + +#[derive(Serialize)] +pub struct GetAgeAssuranceOutput { + pub state: AgeAssuranceState, + pub metadata: AgeAssuranceMetadata, +} + +#[derive(Serialize)] +pub struct AgeAssuranceStatusOutput { + pub status: &'static str, +} + +pub async fn get_state( + State(state): State, + headers: HeaderMap, +) -> Json { let created_at = get_account_created_at(&state, &headers).await; let now = chrono::Utc::now().to_rfc3339(); - ( - StatusCode::OK, - Json(json!({ - "state": { - "status": "assured", - "access": "full", - "lastInitiatedAt": now - }, - "metadata": { - "accountCreatedAt": created_at - } - })), - ) - .into_response() + Json(GetAgeAssuranceOutput { + state: AgeAssuranceState { + status: "assured", + access: "full", + last_initiated_at: now, + }, + metadata: AgeAssuranceMetadata { + account_created_at: created_at, + }, + }) } -pub async fn get_age_assurance_state() -> Response { - (StatusCode::OK, Json(json!({"status": "assured"}))).into_response() +pub async fn get_age_assurance_state() -> Json { + Json(AgeAssuranceStatusOutput { status: "assured" }) } async fn get_account_created_at(state: &AppState, headers: &HeaderMap) -> Option { diff --git a/crates/tranquil-api/src/delegation.rs b/crates/tranquil-api/src/delegation.rs index e109838..26cd40e 100644 --- a/crates/tranquil-api/src/delegation.rs +++ b/crates/tranquil-api/src/delegation.rs @@ -2,13 +2,14 @@ use crate::identity::provision::{create_plc_did, init_genesis_repo}; use axum::{ Json, extract::{Query, State}, - http::StatusCode, - response::{IntoResponse, Response}, }; use serde::{Deserialize, Serialize}; use serde_json::json; use tracing::{error, info, warn}; use tranquil_pds::api::error::ApiError; +use tranquil_pds::api::{ + AccountsOutput, AuditLogOutput, ControllersOutput, PresetsOutput, SuccessResponse, +}; use tranquil_pds::auth::{Active, Auth}; use tranquil_pds::delegation::{ DelegationActionType, SCOPE_PRESETS, ValidatedDelegationScope, verify_can_add_controllers, @@ -21,20 +22,15 @@ use tranquil_pds::types::{Did, Handle}; pub async fn list_controllers( State(state): State, auth: Auth, -) -> Result { - let controllers = match state +) -> Result>>, ApiError> { + let controllers = state .delegation_repo .get_delegations_for_account(&auth.did) .await - { - Ok(c) => c, - Err(e) => { + .map_err(|e| { tracing::error!("Failed to list controllers: {:?}", e); - return Ok( - ApiError::InternalError(Some("Failed to list controllers".into())).into_response(), - ); - } - }; + ApiError::InternalError(Some("Failed to list controllers".into())) + })?; let resolve_futures = controllers.into_iter().map(|mut c| { let did_resolver = state.did_resolver.clone(); @@ -44,7 +40,7 @@ pub async fn list_controllers( .resolve_did_document(c.did.as_str()) .await .and_then(|doc| tranquil_types::did_doc::extract_handle(&doc)) - .map(|h| h.into()); + .map(Into::into); } c } @@ -52,7 +48,7 @@ pub async fn list_controllers( let controllers = futures::future::join_all(resolve_futures).await; - Ok(Json(serde_json::json!({ "controllers": controllers })).into_response()) + Ok(Json(ControllersOutput { controllers })) } #[derive(Debug, Deserialize)] @@ -65,7 +61,7 @@ pub async fn add_controller( State(state): State, auth: Auth, Json(input): Json, -) -> Result { +) -> Result, ApiError> { let resolved = tranquil_pds::delegation::resolve_identity(&state, &input.controller_did) .await .ok_or(ApiError::ControllerNotFound)?; @@ -74,9 +70,9 @@ pub async fn add_controller( && let Some(ref pds_url) = resolved.pds_url { if !pds_url.starts_with("https://") { - return Ok( - ApiError::InvalidDelegation("Controller PDS must use HTTPS".into()).into_response(), - ); + return Err(ApiError::InvalidDelegation( + "Controller PDS must use HTTPS".into(), + )); } match state .cross_pds_oauth @@ -84,10 +80,9 @@ pub async fn add_controller( .await { Some(true) => { - return Ok(ApiError::InvalidDelegation( + return Err(ApiError::InvalidDelegation( "Cannot add a delegated account from another PDS as a controller".into(), - ) - .into_response()); + )); } Some(false) => {} None => { @@ -100,10 +95,7 @@ pub async fn add_controller( } } - let can_add = match verify_can_add_controllers(&state, &auth).await { - Ok(proof) => proof, - Err(response) => return Ok(response), - }; + let can_add = verify_can_add_controllers(&state, &auth).await?; if resolved.is_local && state @@ -112,10 +104,9 @@ pub async fn add_controller( .await .unwrap_or(false) { - return Ok(ApiError::InvalidDelegation( + return Err(ApiError::InvalidDelegation( "Cannot add a controlled account as a controller".into(), - ) - .into_response()); + )); } match state @@ -136,7 +127,7 @@ pub async fn add_controller( can_add.did(), Some(&input.controller_did), DelegationActionType::GrantCreated, - Some(serde_json::json!({ + Some(json!({ "granted_scopes": input.granted_scopes.as_str(), "is_local": resolved.is_local })), @@ -145,17 +136,13 @@ pub async fn add_controller( ) .await; - Ok(( - StatusCode::OK, - Json(serde_json::json!({ - "success": true - })), - ) - .into_response()) + Ok(Json(SuccessResponse { success: true })) } Err(e) => { tracing::error!("Failed to add controller: {:?}", e); - Ok(ApiError::InternalError(Some("Failed to add controller".into())).into_response()) + Err(ApiError::InternalError(Some( + "Failed to add controller".into(), + ))) } } } @@ -169,7 +156,7 @@ pub async fn remove_controller( State(state): State, auth: Auth, Json(input): Json, -) -> Result { +) -> Result, ApiError> { match state .delegation_repo .revoke_delegation(&auth.did, &input.controller_did, &auth.did) @@ -197,7 +184,7 @@ pub async fn remove_controller( &auth.did, Some(&input.controller_did), DelegationActionType::GrantRevoked, - Some(serde_json::json!({ + Some(json!({ "revoked_app_passwords": revoked_app_passwords, "revoked_oauth_tokens": revoked_oauth_tokens })), @@ -206,18 +193,14 @@ pub async fn remove_controller( ) .await; - Ok(( - StatusCode::OK, - Json(serde_json::json!({ - "success": true - })), - ) - .into_response()) + Ok(Json(SuccessResponse { success: true })) } - Ok(false) => Ok(ApiError::DelegationNotFound.into_response()), + Ok(false) => Err(ApiError::DelegationNotFound), Err(e) => { tracing::error!("Failed to remove controller: {:?}", e); - Ok(ApiError::InternalError(Some("Failed to remove controller".into())).into_response()) + Err(ApiError::InternalError(Some( + "Failed to remove controller".into(), + ))) } } } @@ -232,7 +215,7 @@ pub async fn update_controller_scopes( State(state): State, auth: Auth, Json(input): Json, -) -> Result { +) -> Result, ApiError> { match state .delegation_repo .update_delegation_scopes(&auth.did, &input.controller_did, &input.granted_scopes) @@ -246,7 +229,7 @@ pub async fn update_controller_scopes( &auth.did, Some(&input.controller_did), DelegationActionType::ScopesModified, - Some(serde_json::json!({ + Some(json!({ "new_scopes": input.granted_scopes.as_str() })), None, @@ -254,21 +237,14 @@ pub async fn update_controller_scopes( ) .await; - Ok(( - StatusCode::OK, - Json(serde_json::json!({ - "success": true - })), - ) - .into_response()) + Ok(Json(SuccessResponse { success: true })) } - Ok(false) => Ok(ApiError::DelegationNotFound.into_response()), + Ok(false) => Err(ApiError::DelegationNotFound), Err(e) => { tracing::error!("Failed to update controller scopes: {:?}", e); - Ok( - ApiError::InternalError(Some("Failed to update controller scopes".into())) - .into_response(), - ) + Err(ApiError::InternalError(Some( + "Failed to update controller scopes".into(), + ))) } } } @@ -276,23 +252,17 @@ pub async fn update_controller_scopes( pub async fn list_controlled_accounts( State(state): State, auth: Auth, -) -> Result { - let accounts = match state +) -> Result>>, ApiError> { + let accounts = state .delegation_repo .get_accounts_controlled_by(&auth.did) .await - { - Ok(a) => a, - Err(e) => { + .map_err(|e| { tracing::error!("Failed to list controlled accounts: {:?}", e); - return Ok( - ApiError::InternalError(Some("Failed to list controlled accounts".into())) - .into_response(), - ); - } - }; + ApiError::InternalError(Some("Failed to list controlled accounts".into())) + })?; - Ok(Json(serde_json::json!({ "accounts": accounts })).into_response()) + Ok(Json(AccountsOutput { accounts })) } #[derive(Debug, Deserialize)] @@ -311,23 +281,18 @@ pub async fn get_audit_log( State(state): State, auth: Auth, Query(params): Query, -) -> Result { +) -> Result>>, ApiError> { let limit = params.limit.clamp(1, 100); let offset = params.offset.max(0); - let entries = match state + let entries = state .delegation_repo .get_audit_log_for_account(&auth.did, limit, offset) .await - { - Ok(e) => e, - Err(e) => { + .map_err(|e| { tracing::error!("Failed to get audit log: {:?}", e); - return Ok( - ApiError::InternalError(Some("Failed to get audit log".into())).into_response(), - ); - } - }; + ApiError::InternalError(Some("Failed to get audit log".into())) + })?; let total = state .delegation_repo @@ -335,11 +300,14 @@ pub async fn get_audit_log( .await .unwrap_or_default(); - Ok(Json(serde_json::json!({ "entries": entries, "total": total })).into_response()) + Ok(Json(AuditLogOutput { entries, total })) } -pub async fn get_scope_presets() -> Response { - Json(serde_json::json!({ "presets": SCOPE_PRESETS })).into_response() +pub async fn get_scope_presets() +-> Json> { + Json(PresetsOutput { + presets: SCOPE_PRESETS, + }) } #[derive(Debug, Deserialize)] @@ -353,7 +321,7 @@ pub struct CreateDelegatedAccountInput { #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] -pub struct CreateDelegatedAccountResponse { +pub struct CreateDelegatedAccountOutput { pub did: Did, pub handle: Handle, } @@ -363,18 +331,11 @@ pub async fn create_delegated_account( _rate_limit: RateLimited, auth: Auth, Json(input): Json, -) -> Result { - let can_control = match verify_can_control_accounts(&state, &auth).await { - Ok(proof) => proof, - Err(response) => return Ok(response), - }; +) -> Result, ApiError> { + let can_control = verify_can_control_accounts(&state, &auth).await?; - let handle = match tranquil_pds::api::validation::resolve_handle_input(&input.handle) { - Ok(h) => h, - Err(e) => { - return Ok(ApiError::InvalidRequest(e.to_string()).into_response()); - } - }; + let handle = tranquil_pds::api::validation::resolve_handle_input(&input.handle) + .map_err(|e| ApiError::InvalidRequest(e.to_string()))?; let email = input .email @@ -384,18 +345,18 @@ pub async fn create_delegated_account( if let Some(ref email) = email && !tranquil_pds::api::validation::is_valid_email(email) { - return Ok(ApiError::InvalidEmail.into_response()); + return Err(ApiError::InvalidEmail); } let validated_invite_code = if let Some(ref code) = input.invite_code { match state.infra_repo.validate_invite_code(code).await { Ok(validated) => Some(validated), - Err(_) => return Ok(ApiError::InvalidInviteCode.into_response()), + Err(_) => return Err(ApiError::InvalidInviteCode), } } else { let invite_required = tranquil_config::get().server.invite_code_required; if invite_required { - return Ok(ApiError::InviteCodeRequired.into_response()); + return Err(ApiError::InviteCodeRequired); } None }; @@ -409,6 +370,7 @@ pub async fn create_delegated_account( info!(did = %did, handle = %handle, controller = %can_control.did(), "Created DID for delegated account"); let repo = init_genesis_repo(&state, &did, &plc.signing_key, &plc.signing_key_bytes).await?; + let repo_for_seq = repo.clone(); let create_input = tranquil_db_traits::CreateDelegatedAccountInput { handle: handle.clone(), @@ -431,14 +393,14 @@ pub async fn create_delegated_account( { Ok(id) => id, Err(tranquil_db_traits::CreateAccountError::HandleTaken) => { - return Ok(ApiError::HandleNotAvailable(None).into_response()); + return Err(ApiError::HandleNotAvailable(None)); } Err(tranquil_db_traits::CreateAccountError::EmailTaken) => { - return Ok(ApiError::EmailTaken.into_response()); + return Err(ApiError::EmailTaken); } Err(e) => { error!("Error creating delegated account: {:?}", e); - return Ok(ApiError::InternalError(None).into_response()); + return Err(ApiError::InternalError(None)); } }; @@ -451,36 +413,14 @@ pub async fn create_delegated_account( warn!("Failed to record invite code use for {}: {:?}", did, e); } - if let Err(e) = - tranquil_pds::repo_ops::sequence_identity_event(&state, &did, Some(&handle)).await - { - warn!("Failed to sequence identity event for {}: {}", did, e); - } - if let Err(e) = tranquil_pds::repo_ops::sequence_account_event( + crate::identity::provision::sequence_new_account( &state, &did, - tranquil_db_traits::AccountStatus::Active, + &handle, + &repo_for_seq, + handle.as_str(), ) - .await - { - warn!("Failed to sequence account event for {}: {}", did, e); - } - - let profile_record = json!({ - "$type": "app.bsky.actor.profile", - "displayName": handle - }); - if let Err(e) = tranquil_pds::repo_ops::create_record_internal( - &state, - &did, - &tranquil_pds::types::PROFILE_COLLECTION, - &tranquil_pds::types::PROFILE_RKEY, - &profile_record, - ) - .await - { - warn!("Failed to create default profile for {}: {}", did, e); - } + .await; let _ = state .delegation_repo @@ -500,7 +440,7 @@ pub async fn create_delegated_account( info!(did = %did, handle = %handle, controller = %&auth.did, "Delegated account created"); - Ok(Json(CreateDelegatedAccountResponse { did, handle }).into_response()) + Ok(Json(CreateDelegatedAccountOutput { did, handle })) } #[derive(Debug, Deserialize)] @@ -511,7 +451,7 @@ pub struct ResolveControllerParams { pub async fn resolve_controller( State(state): State, Query(params): Query, -) -> Result { +) -> Result, ApiError> { let identifier = params.identifier.trim().trim_start_matches('@'); let did: Did = if identifier.starts_with("did:") { @@ -538,5 +478,5 @@ pub async fn resolve_controller( .await .ok_or(ApiError::ControllerNotFound)?; - Ok(Json(resolved).into_response()) + Ok(Json(resolved)) } diff --git a/crates/tranquil-api/src/notification_prefs.rs b/crates/tranquil-api/src/notification_prefs.rs index 233de6d..a00dcc7 100644 --- a/crates/tranquil-api/src/notification_prefs.rs +++ b/crates/tranquil-api/src/notification_prefs.rs @@ -1,20 +1,16 @@ -use axum::{ - Json, - extract::State, - response::{IntoResponse, Response}, -}; +use 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; +use tranquil_pds::api::error::{ApiError, DbResultExt}; use tranquil_pds::auth::{Active, Auth}; use tranquil_pds::state::AppState; use tranquil_types::Did; #[derive(Serialize)] #[serde(rename_all = "camelCase")] -pub struct NotificationPrefsResponse { +pub struct NotificationPrefsOutput { pub preferred_channel: CommsChannel, pub email: String, pub discord_username: Option, @@ -28,14 +24,14 @@ pub struct NotificationPrefsResponse { pub async fn get_notification_prefs( State(state): State, auth: Auth, -) -> Result { +) -> Result, ApiError> { let prefs = state .user_repo .get_notification_prefs(&auth.did) .await - .map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))? + .log_db_err("get notification prefs")? .ok_or(ApiError::AccountNotFound)?; - Ok(Json(NotificationPrefsResponse { + Ok(Json(NotificationPrefsOutput { preferred_channel: prefs.preferred_channel, email: prefs.email, discord_username: prefs.discord_username, @@ -44,8 +40,7 @@ pub async fn get_notification_prefs( telegram_verified: prefs.telegram_verified, signal_username: prefs.signal_username, signal_verified: prefs.signal_verified, - }) - .into_response()) + })) } #[derive(Serialize)] @@ -61,26 +56,26 @@ pub struct NotificationHistoryEntry { #[derive(Serialize)] #[serde(rename_all = "camelCase")] -pub struct GetNotificationHistoryResponse { +pub struct GetNotificationHistoryOutput { pub notifications: Vec, } pub async fn get_notification_history( State(state): State, auth: Auth, -) -> Result { +) -> Result, ApiError> { let user_id = state .user_repo .get_id_by_did(&auth.did) .await - .map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))? + .log_db_err("get user id by did")? .ok_or(ApiError::AccountNotFound)?; let rows = state .infra_repo .get_notification_history(user_id, 50) .await - .map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?; + .log_db_err("get notification history")?; let sensitive_types = [ CommsType::EmailVerification, @@ -112,7 +107,7 @@ pub async fn get_notification_history( }) .collect(); - Ok(Json(GetNotificationHistoryResponse { notifications }).into_response()) + Ok(Json(GetNotificationHistoryOutput { notifications })) } #[derive(Deserialize)] @@ -127,7 +122,7 @@ pub struct UpdateNotificationPrefsInput { #[derive(Serialize)] #[serde(rename_all = "camelCase")] -pub struct UpdateNotificationPrefsResponse { +pub struct UpdateNotificationPrefsOutput { pub success: bool, #[serde(skip_serializing_if = "Vec::is_empty")] pub verification_required: Vec, @@ -159,12 +154,7 @@ pub async fn request_channel_verification( hostname, ) .await - .map_err(|e| { - ApiError::InternalError(Some(format!( - "Failed to enqueue email notification: {}", - e - ))) - })?; + .log_db_err("enqueue email verification")?; } _ => { let hostname = &tranquil_config::get().server.hostname; @@ -216,25 +206,117 @@ pub async fn request_channel_verification( Some(json!({"code": formatted_token})), ) .await - .map_err(|e| { - ApiError::InternalError(Some(format!("Failed to enqueue notification: {}", e))) - })?; + .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, +) -> 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 + .user_repo + .clear_discord(user_id) + .await + .log_db_err("clear discord")?, + CommsChannel::Telegram => state + .user_repo + .clear_telegram(user_id) + .await + .log_db_err("clear telegram")?, + CommsChannel::Signal => state + .user_repo + .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 3-32 characters followed by .XX (e.g. username.01)".into(), + ), + CommsChannel::Email => ApiError::InvalidEmail, + }); + } + + match channel { + CommsChannel::Discord => state + .user_repo + .set_unverified_discord(user_id, &clean) + .await + .log_db_err("set unverified discord")?, + CommsChannel::Telegram => state + .user_repo + .set_unverified_telegram(user_id, &clean) + .await + .log_db_err("set unverified telegram")?, + CommsChannel::Signal => state + .user_repo + .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, auth: Auth, Json(input): Json, -) -> Result { +) -> Result, ApiError> { let user_row = state .user_repo .get_id_handle_email_by_did(&auth.did) .await - .map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))? + .log_db_err("get user by did")? .ok_or(ApiError::AccountNotFound)?; let user_id = user_row.id; @@ -245,7 +327,7 @@ pub async fn update_notification_prefs( .user_repo .get_notification_prefs(&auth.did) .await - .map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))? + .log_db_err("get notification prefs for update")? .ok_or(ApiError::AccountNotFound)?; let effective_channel = input @@ -268,7 +350,7 @@ pub async fn update_notification_prefs( .user_repo .update_preferred_comms_channel(&auth.did, effective_channel) .await - .map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?; + .log_db_err("update preferred channel")?; info!(did = %auth.did, channel = ?effective_channel, "Updated preferred notification channel"); } @@ -298,107 +380,46 @@ pub async fn update_notification_prefs( } if let Some(ref discord_username) = input.discord_username { - let discord_clean = discord_username.trim().to_lowercase(); - if discord_clean.is_empty() { - if effective_channel == CommsChannel::Discord { - return Err(ApiError::InvalidRequest( - "Cannot remove Discord while it is the preferred notification channel".into(), - )); - } - state - .user_repo - .clear_discord(user_id) - .await - .map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?; - info!(did = %auth.did, "Cleared Discord"); - } else if !tranquil_pds::api::validation::is_valid_discord_username(&discord_clean) { - return Err(ApiError::InvalidRequest( - "Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)" - .into(), - )); - } else { - state - .user_repo - .set_unverified_discord(user_id, &discord_clean) - .await - .map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?; - verification_required.push(CommsChannel::Discord); - info!(did = %auth.did, discord_username = %discord_clean, "Stored unverified 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 { - let telegram_clean = telegram.trim_start_matches('@'); - if telegram_clean.is_empty() { - if effective_channel == CommsChannel::Telegram { - return Err(ApiError::InvalidRequest( - "Cannot remove Telegram while it is the preferred notification channel".into(), - )); - } - state - .user_repo - .clear_telegram(user_id) - .await - .map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?; - info!(did = %auth.did, "Cleared Telegram username"); - } else if !tranquil_pds::api::validation::is_valid_telegram_username(telegram_clean) { - return Err(ApiError::InvalidRequest( - "Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore" - .into(), - )); - } else { - state - .user_repo - .set_unverified_telegram(user_id, telegram_clean) - .await - .map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?; - verification_required.push(CommsChannel::Telegram); - info!(did = %auth.did, telegram_username = %telegram_clean, "Stored unverified 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 { - let signal_clean = signal.trim().trim_start_matches('@').to_lowercase(); - if signal_clean.is_empty() { - if effective_channel == CommsChannel::Signal { - return Err(ApiError::InvalidRequest( - "Cannot remove Signal while it is the preferred notification channel".into(), - )); - } - state - .user_repo - .clear_signal(user_id) - .await - .map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?; - info!(did = %auth.did, "Cleared Signal username"); - } else if !tranquil_pds::comms::is_valid_signal_username(&signal_clean) { - return Err(ApiError::InvalidRequest( - "Invalid Signal username. Must be 3-32 characters followed by .XX (e.g. username.01)" - .into(), - )); - } else { - state - .user_repo - .set_unverified_signal(user_id, &signal_clean) - .await - .map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?; - request_channel_verification( - &state, - user_id, - &auth.did, - CommsChannel::Signal, - &signal_clean, - None, - ) - .await?; - verification_required.push(CommsChannel::Signal); - info!(did = %auth.did, signal_username = %signal_clean, "Stored unverified Signal username"); - } + process_messaging_channel_update( + &state, + user_id, + &auth.did, + CommsChannel::Signal, + signal, + effective_channel, + &mut verification_required, + ) + .await?; } - Ok(Json(UpdateNotificationPrefsResponse { + Ok(Json(UpdateNotificationPrefsOutput { success: true, verification_required, - }) - .into_response()) + })) } diff --git a/crates/tranquil-api/src/server/email.rs b/crates/tranquil-api/src/server/email.rs index 087ea91..68bc868 100644 --- a/crates/tranquil-api/src/server/email.rs +++ b/crates/tranquil-api/src/server/email.rs @@ -12,8 +12,11 @@ use subtle::ConstantTimeEq; use tracing::{error, info, warn}; use tranquil_db_traits::CommsChannel; use tranquil_pds::api::error::{ApiError, DbResultExt}; -use tranquil_pds::api::{EmptyResponse, TokenRequiredResponse, VerifiedResponse}; +use tranquil_pds::api::{ + EmailUpdateStatusOutput, EmptyResponse, InUseOutput, TokenRequiredResponse, VerifiedResponse, +}; use tranquil_pds::auth::{Auth, NotTakendown}; +use tranquil_pds::oauth::scopes::{AccountAction, AccountAttr}; use tranquil_pds::rate_limit::{EmailUpdateLimit, RateLimited, VerificationCheckLimit}; use tranquil_pds::state::AppState; @@ -48,15 +51,8 @@ pub async fn request_email_update( _rate_limit: RateLimited, auth: Auth, input: Option>, -) -> Result { - if let Err(e) = tranquil_pds::auth::scope_check::check_account_scope( - &auth.auth_source, - auth.scope.as_deref(), - tranquil_pds::oauth::scopes::AccountAttr::Email, - tranquil_pds::oauth::scopes::AccountAction::Manage, - ) { - return Ok(e); - } +) -> Result, ApiError> { + auth.check_account_scope(AccountAttr::Email, AccountAction::Manage)?; let user = state .user_repo @@ -119,7 +115,7 @@ pub async fn request_email_update( } info!("Email update requested for user {}", user.id); - Ok(TokenRequiredResponse::response(token_required).into_response()) + Ok(Json(TokenRequiredResponse { token_required })) } #[derive(Deserialize)] @@ -134,15 +130,8 @@ pub async fn confirm_email( _rate_limit: RateLimited, auth: Auth, Json(input): Json, -) -> Result { - if let Err(e) = tranquil_pds::auth::scope_check::check_account_scope( - &auth.auth_source, - auth.scope.as_deref(), - tranquil_pds::oauth::scopes::AccountAttr::Email, - tranquil_pds::oauth::scopes::AccountAction::Manage, - ) { - return Ok(e); - } +) -> Result, ApiError> { + auth.check_account_scope(AccountAttr::Email, AccountAction::Manage)?; let did = &auth.did; let user = state @@ -163,7 +152,7 @@ pub async fn confirm_email( } if user.email_verified { - return Ok(EmptyResponse::ok().into_response()); + return Ok(Json(EmptyResponse {})); } let confirmation_code = @@ -196,7 +185,7 @@ pub async fn confirm_email( .log_db_err("confirming email")?; info!("Email confirmed for user {}", user.id); - Ok(EmptyResponse::ok().into_response()) + Ok(Json(EmptyResponse {})) } #[derive(Deserialize)] @@ -212,15 +201,8 @@ pub async fn update_email( State(state): State, auth: Auth, Json(input): Json, -) -> Result { - if let Err(e) = tranquil_pds::auth::scope_check::check_account_scope( - &auth.auth_source, - auth.scope.as_deref(), - tranquil_pds::oauth::scopes::AccountAttr::Email, - tranquil_pds::oauth::scopes::AccountAction::Manage, - ) { - return Ok(e); - } +) -> Result, ApiError> { + auth.check_account_scope(AccountAttr::Email, AccountAction::Manage)?; let did = &auth.did; let user = state @@ -279,7 +261,7 @@ pub async fn update_email( ApiError::InternalError(Some("Failed to update 2FA setting".into())) })?; } - return Ok(EmptyResponse::ok().into_response()); + return Ok(Json(EmptyResponse {})); } if email_verified { @@ -394,7 +376,7 @@ pub async fn update_email( } info!("Email updated for user {}", user_id); - Ok(EmptyResponse::ok().into_response()) + Ok(Json(EmptyResponse {})) } #[derive(Deserialize)] @@ -406,19 +388,18 @@ pub async fn check_email_verified( State(state): State, _rate_limit: RateLimited, Json(input): Json, -) -> Response { - match state +) -> Result, ApiError> { + let verified = state .user_repo .check_email_verified_by_identifier(&input.identifier) .await - { - Ok(Some(verified)) => VerifiedResponse::response(verified).into_response(), - Ok(None) => ApiError::AccountNotFound.into_response(), - Err(e) => { + .map_err(|e| { error!("DB error checking email verified: {:?}", e); - ApiError::InternalError(None).into_response() - } - } + ApiError::InternalError(None) + })? + .ok_or(ApiError::AccountNotFound)?; + + Ok(Json(VerifiedResponse { verified })) } #[derive(Deserialize)] @@ -431,19 +412,18 @@ pub async fn check_channel_verified( State(state): State, _rate_limit: RateLimited, Json(input): Json, -) -> Response { - match state +) -> Result, ApiError> { + let verified = state .user_repo .check_channel_verified_by_did(&input.did, input.channel) .await - { - Ok(Some(verified)) => VerifiedResponse::response(verified).into_response(), - Ok(None) => ApiError::AccountNotFound.into_response(), - Err(e) => { + .map_err(|e| { error!("DB error checking channel verified: {:?}", e); - ApiError::InternalError(None).into_response() - } - } + ApiError::InternalError(None) + })? + .ok_or(ApiError::AccountNotFound)?; + + Ok(Json(VerifiedResponse { verified })) } #[derive(Deserialize)] @@ -545,37 +525,37 @@ pub async fn check_email_update_status( State(state): State, _rate_limit: RateLimited, auth: Auth, -) -> Result { - if let Err(e) = tranquil_pds::auth::scope_check::check_account_scope( - &auth.auth_source, - auth.scope.as_deref(), - tranquil_pds::oauth::scopes::AccountAttr::Email, - tranquil_pds::oauth::scopes::AccountAction::Read, - ) { - return Ok(e); - } +) -> Result, ApiError> { + auth.check_account_scope(AccountAttr::Email, AccountAction::Read)?; let cache_key = email_update_cache_key(&auth.did); let pending_json = match state.cache.get(&cache_key).await { Some(json) => json, None => { - return Ok(Json(json!({ "pending": false, "authorized": false })).into_response()); + return Ok(Json(EmailUpdateStatusOutput { + pending: false, + authorized: false, + new_email: None, + })); } }; let pending: PendingEmailUpdate = match serde_json::from_str(&pending_json) { Ok(p) => p, Err(_) => { - return Ok(Json(json!({ "pending": false, "authorized": false })).into_response()); + return Ok(Json(EmailUpdateStatusOutput { + pending: false, + authorized: false, + new_email: None, + })); } }; - Ok(Json(json!({ - "pending": true, - "authorized": pending.authorized, - "newEmail": pending.new_email, + Ok(Json(EmailUpdateStatusOutput { + pending: true, + authorized: pending.authorized, + new_email: Some(pending.new_email), })) - .into_response()) } #[derive(Deserialize)] @@ -587,22 +567,20 @@ pub async fn check_email_in_use( State(state): State, _rate_limit: RateLimited, Json(input): Json, -) -> Response { +) -> Result, ApiError> { let email = input.email.trim().to_lowercase(); if email.is_empty() { - return ApiError::InvalidRequest("email is required".into()).into_response(); + return Err(ApiError::InvalidRequest("email is required".into())); } - let count = match state.user_repo.count_accounts_by_email(&email).await { - Ok(c) => c, - Err(e) => { + let count = state + .user_repo + .count_accounts_by_email(&email) + .await + .map_err(|e| { error!("DB error checking email usage: {:?}", e); - return ApiError::InternalError(None).into_response(); - } - }; + ApiError::InternalError(None) + })?; - Json(json!({ - "inUse": count > 0, - })) - .into_response() + Ok(Json(InUseOutput { in_use: count > 0 })) } diff --git a/crates/tranquil-api/src/server/meta.rs b/crates/tranquil-api/src/server/meta.rs index 49a37ba..03bd286 100644 --- a/crates/tranquil-api/src/server/meta.rs +++ b/crates/tranquil-api/src/server/meta.rs @@ -1,11 +1,11 @@ use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; -use serde_json::json; +use serde::Serialize; +use tranquil_db_traits::CommsChannel; use tranquil_pds::BUILD_VERSION; use tranquil_pds::state::AppState; use tranquil_pds::util::{discord_app_id, discord_bot_username, telegram_bot_username}; -fn get_available_comms_channels() -> Vec { - use tranquil_db_traits::CommsChannel; +fn get_available_comms_channels() -> Vec { let cfg = tranquil_config::get(); let mut channels = vec![CommsChannel::Email]; if cfg.discord.bot_token.is_some() { @@ -31,59 +31,87 @@ pub fn is_self_hosted_did_web_enabled() -> bool { tranquil_config::get().server.enable_pds_hosted_did_web } -pub async fn describe_server() -> impl IntoResponse { +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DescribeServerLinks { + #[serde(skip_serializing_if = "Option::is_none")] + pub privacy_policy: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub terms_of_service: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DescribeServerContact { + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DescribeServerOutput { + pub available_user_domains: Vec, + pub invite_code_required: bool, + pub did: String, + pub links: DescribeServerLinks, + pub contact: DescribeServerContact, + pub version: &'static str, + pub available_comms_channels: Vec, + pub self_hosted_did_web_enabled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub discord_bot_username: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub discord_app_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub telegram_bot_username: Option, +} + +pub async fn describe_server() -> Json { let cfg = tranquil_config::get(); let pds_hostname = &cfg.server.hostname; - let domains = cfg.server.user_handle_domain_list(); - let invite_code_required = cfg.server.invite_code_required; - let privacy_policy = cfg.server.privacy_policy_url.clone(); - let terms_of_service = cfg.server.terms_of_service_url.clone(); - let contact_email = cfg.server.contact_email.clone(); - let mut links = serde_json::Map::new(); - if let Some(pp) = privacy_policy { - links.insert("privacyPolicy".to_string(), json!(pp)); - } - if let Some(tos) = terms_of_service { - links.insert("termsOfService".to_string(), json!(tos)); - } - let mut contact = serde_json::Map::new(); - if let Some(email) = contact_email { - contact.insert("email".to_string(), json!(email)); - } - let mut response = json!({ - "availableUserDomains": domains, - "inviteCodeRequired": invite_code_required, - "did": format!("did:web:{}", pds_hostname), - "links": links, - "contact": contact, - "version": BUILD_VERSION, - "availableCommsChannels": get_available_comms_channels(), - "selfHostedDidWebEnabled": is_self_hosted_did_web_enabled() - }); - if let Some(bot_username) = discord_bot_username() { - response["discordBotUsername"] = json!(bot_username); - } - if let Some(app_id) = discord_app_id() { - response["discordAppId"] = json!(app_id); - } - if let Some(bot_username) = telegram_bot_username() { - response["telegramBotUsername"] = json!(bot_username); - } - Json(response) + + Json(DescribeServerOutput { + available_user_domains: cfg.server.user_handle_domain_list(), + invite_code_required: cfg.server.invite_code_required, + did: format!("did:web:{}", pds_hostname), + links: DescribeServerLinks { + privacy_policy: cfg.server.privacy_policy_url.clone(), + terms_of_service: cfg.server.terms_of_service_url.clone(), + }, + contact: DescribeServerContact { + email: cfg.server.contact_email.clone(), + }, + version: BUILD_VERSION, + available_comms_channels: get_available_comms_channels(), + self_hosted_did_web_enabled: is_self_hosted_did_web_enabled(), + discord_bot_username: discord_bot_username().map(String::from), + discord_app_id: discord_app_id().map(String::from), + telegram_bot_username: telegram_bot_username().map(String::from), + }) } +#[derive(Serialize)] +pub struct HealthOutput { + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option<&'static str>, +} + pub async fn health(State(state): State) -> impl IntoResponse { match state.infra_repo.health_check().await { Ok(true) => ( StatusCode::OK, - Json(json!({ - "version": format!("tranquil {}", BUILD_VERSION) - })), + Json(HealthOutput { + version: Some(format!("tranquil {}", BUILD_VERSION)), + error: None, + }), ), _ => ( StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ - "error": "Service Unavailable" - })), + Json(HealthOutput { + version: None, + error: Some("Service Unavailable"), + }), ), } }