this repo has no description
1use crate::api::ApiError; 2use crate::auth::BearerAuth; 3use crate::state::{AppState, RateLimitKind}; 4use axum::{ 5 Json, 6 extract::State, 7 http::{HeaderMap, StatusCode}, 8 response::{IntoResponse, Response}, 9}; 10use bcrypt::verify; 11use chrono::Utc; 12use serde::{Deserialize, Serialize}; 13use serde_json::json; 14use tracing::{error, info, warn}; 15 16fn extract_client_ip(headers: &HeaderMap) -> String { 17 if let Some(forwarded) = headers.get("x-forwarded-for") 18 && let Ok(value) = forwarded.to_str() 19 && let Some(first_ip) = value.split(',').next() { 20 return first_ip.trim().to_string(); 21 } 22 if let Some(real_ip) = headers.get("x-real-ip") 23 && let Ok(value) = real_ip.to_str() { 24 return value.trim().to_string(); 25 } 26 "unknown".to_string() 27} 28 29fn normalize_handle(identifier: &str, pds_hostname: &str) -> String { 30 let suffix = format!(".{}", pds_hostname); 31 if identifier.ends_with(&suffix) { 32 identifier[..identifier.len() - suffix.len()].to_string() 33 } else { 34 identifier.to_string() 35 } 36} 37 38#[derive(Deserialize)] 39pub struct CreateSessionInput { 40 pub identifier: String, 41 pub password: String, 42} 43 44#[derive(Serialize)] 45#[serde(rename_all = "camelCase")] 46pub struct CreateSessionOutput { 47 pub access_jwt: String, 48 pub refresh_jwt: String, 49 pub handle: String, 50 pub did: String, 51} 52 53pub async fn create_session( 54 State(state): State<AppState>, 55 headers: HeaderMap, 56 Json(input): Json<CreateSessionInput>, 57) -> Response { 58 info!("create_session called"); 59 let client_ip = extract_client_ip(&headers); 60 if !state 61 .check_rate_limit(RateLimitKind::Login, &client_ip) 62 .await 63 { 64 warn!(ip = %client_ip, "Login rate limit exceeded"); 65 return ( 66 StatusCode::TOO_MANY_REQUESTS, 67 Json(json!({ 68 "error": "RateLimitExceeded", 69 "message": "Too many login attempts. Please try again later." 70 })), 71 ) 72 .into_response(); 73 } 74 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 75 let normalized_identifier = normalize_handle(&input.identifier, &pds_hostname); 76 let row = match sqlx::query!( 77 r#"SELECT 78 u.id, u.did, u.handle, u.password_hash, 79 u.email_confirmed, u.discord_verified, u.telegram_verified, u.signal_verified, 80 k.key_bytes, k.encryption_version 81 FROM users u 82 JOIN user_keys k ON u.id = k.user_id 83 WHERE u.handle = $1 OR u.email = $1"#, 84 normalized_identifier 85 ) 86 .fetch_optional(&state.db) 87 .await 88 { 89 Ok(Some(row)) => row, 90 Ok(None) => { 91 let _ = verify( 92 &input.password, 93 "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.VTtYw1ZzQKZqmK", 94 ); 95 warn!("User not found for login attempt"); 96 return ApiError::AuthenticationFailedMsg("Invalid identifier or password".into()) 97 .into_response(); 98 } 99 Err(e) => { 100 error!("Database error fetching user: {:?}", e); 101 return ApiError::InternalError.into_response(); 102 } 103 }; 104 let key_bytes = match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) { 105 Ok(k) => k, 106 Err(e) => { 107 error!("Failed to decrypt user key: {:?}", e); 108 return ApiError::InternalError.into_response(); 109 } 110 }; 111 let password_valid = if verify(&input.password, &row.password_hash).unwrap_or(false) { 112 true 113 } else { 114 let app_passwords = sqlx::query!( 115 "SELECT password_hash FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20", 116 row.id 117 ) 118 .fetch_all(&state.db) 119 .await 120 .unwrap_or_default(); 121 app_passwords 122 .iter() 123 .any(|app| verify(&input.password, &app.password_hash).unwrap_or(false)) 124 }; 125 if !password_valid { 126 warn!("Password verification failed for login attempt"); 127 return ApiError::AuthenticationFailedMsg("Invalid identifier or password".into()) 128 .into_response(); 129 } 130 let is_verified = 131 row.email_confirmed || row.discord_verified || row.telegram_verified || row.signal_verified; 132 if !is_verified { 133 warn!("Login attempt for unverified account: {}", row.did); 134 return ( 135 StatusCode::FORBIDDEN, 136 Json(json!({ 137 "error": "AccountNotVerified", 138 "message": "Please verify your account before logging in", 139 "did": row.did 140 })), 141 ) 142 .into_response(); 143 } 144 let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) { 145 Ok(m) => m, 146 Err(e) => { 147 error!("Failed to create access token: {:?}", e); 148 return ApiError::InternalError.into_response(); 149 } 150 }; 151 let refresh_meta = match crate::auth::create_refresh_token_with_metadata(&row.did, &key_bytes) { 152 Ok(m) => m, 153 Err(e) => { 154 error!("Failed to create refresh token: {:?}", e); 155 return ApiError::InternalError.into_response(); 156 } 157 }; 158 if let Err(e) = sqlx::query!( 159 "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)", 160 row.did, 161 access_meta.jti, 162 refresh_meta.jti, 163 access_meta.expires_at, 164 refresh_meta.expires_at 165 ) 166 .execute(&state.db) 167 .await 168 { 169 error!("Failed to insert session: {:?}", e); 170 return ApiError::InternalError.into_response(); 171 } 172 let full_handle = format!("{}.{}", row.handle, pds_hostname); 173 Json(CreateSessionOutput { 174 access_jwt: access_meta.token, 175 refresh_jwt: refresh_meta.token, 176 handle: full_handle, 177 did: row.did, 178 }) 179 .into_response() 180} 181 182pub async fn get_session( 183 State(state): State<AppState>, 184 BearerAuth(auth_user): BearerAuth, 185) -> Response { 186 match sqlx::query!( 187 r#"SELECT 188 handle, email, email_confirmed, 189 preferred_notification_channel as "preferred_channel: crate::notifications::NotificationChannel", 190 discord_verified, telegram_verified, signal_verified 191 FROM users WHERE did = $1"#, 192 auth_user.did 193 ) 194 .fetch_optional(&state.db) 195 .await 196 { 197 Ok(Some(row)) => { 198 let (preferred_channel, preferred_channel_verified) = match row.preferred_channel { 199 crate::notifications::NotificationChannel::Email => ("email", row.email_confirmed), 200 crate::notifications::NotificationChannel::Discord => ("discord", row.discord_verified), 201 crate::notifications::NotificationChannel::Telegram => ("telegram", row.telegram_verified), 202 crate::notifications::NotificationChannel::Signal => ("signal", row.signal_verified), 203 }; 204 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 205 let full_handle = format!("{}.{}", row.handle, pds_hostname); 206 Json(json!({ 207 "handle": full_handle, 208 "did": auth_user.did, 209 "email": row.email, 210 "emailConfirmed": row.email_confirmed, 211 "preferredChannel": preferred_channel, 212 "preferredChannelVerified": preferred_channel_verified, 213 "active": true, 214 "didDoc": {} 215 })).into_response() 216 } 217 Ok(None) => ApiError::AuthenticationFailed.into_response(), 218 Err(e) => { 219 error!("Database error in get_session: {:?}", e); 220 ApiError::InternalError.into_response() 221 } 222 } 223} 224 225pub async fn delete_session( 226 State(state): State<AppState>, 227 headers: axum::http::HeaderMap, 228) -> Response { 229 let token = match crate::auth::extract_bearer_token_from_header( 230 headers.get("Authorization").and_then(|h| h.to_str().ok()), 231 ) { 232 Some(t) => t, 233 None => return ApiError::AuthenticationRequired.into_response(), 234 }; 235 let jti = match crate::auth::get_jti_from_token(&token) { 236 Ok(jti) => jti, 237 Err(_) => return ApiError::AuthenticationFailed.into_response(), 238 }; 239 let did = crate::auth::get_did_from_token(&token).ok(); 240 match sqlx::query!("DELETE FROM session_tokens WHERE access_jti = $1", jti) 241 .execute(&state.db) 242 .await 243 { 244 Ok(res) if res.rows_affected() > 0 => { 245 if let Some(did) = did { 246 let session_cache_key = format!("auth:session:{}:{}", did, jti); 247 let _ = state.cache.delete(&session_cache_key).await; 248 } 249 Json(json!({})).into_response() 250 } 251 Ok(_) => ApiError::AuthenticationFailed.into_response(), 252 Err(e) => { 253 error!("Database error in delete_session: {:?}", e); 254 ApiError::AuthenticationFailed.into_response() 255 } 256 } 257} 258 259pub async fn refresh_session( 260 State(state): State<AppState>, 261 headers: axum::http::HeaderMap, 262) -> Response { 263 let client_ip = crate::rate_limit::extract_client_ip(&headers, None); 264 if !state 265 .check_rate_limit(RateLimitKind::RefreshSession, &client_ip) 266 .await 267 { 268 tracing::warn!(ip = %client_ip, "Refresh session rate limit exceeded"); 269 return ( 270 axum::http::StatusCode::TOO_MANY_REQUESTS, 271 axum::Json(serde_json::json!({ 272 "error": "RateLimitExceeded", 273 "message": "Too many requests. Please try again later." 274 })), 275 ) 276 .into_response(); 277 } 278 let refresh_token = match crate::auth::extract_bearer_token_from_header( 279 headers.get("Authorization").and_then(|h| h.to_str().ok()), 280 ) { 281 Some(t) => t, 282 None => return ApiError::AuthenticationRequired.into_response(), 283 }; 284 let refresh_jti = match crate::auth::get_jti_from_token(&refresh_token) { 285 Ok(jti) => jti, 286 Err(_) => { 287 return ApiError::AuthenticationFailedMsg("Invalid token format".into()) 288 .into_response(); 289 } 290 }; 291 let mut tx = match state.db.begin().await { 292 Ok(tx) => tx, 293 Err(e) => { 294 error!("Failed to begin transaction: {:?}", e); 295 return ApiError::InternalError.into_response(); 296 } 297 }; 298 if let Ok(Some(session_id)) = sqlx::query_scalar!( 299 "SELECT session_id FROM used_refresh_tokens WHERE refresh_jti = $1 FOR UPDATE", 300 refresh_jti 301 ) 302 .fetch_optional(&mut *tx) 303 .await 304 { 305 warn!( 306 "Refresh token reuse detected! Revoking token family for session_id: {}", 307 session_id 308 ); 309 let _ = sqlx::query!("DELETE FROM session_tokens WHERE id = $1", session_id) 310 .execute(&mut *tx) 311 .await; 312 let _ = tx.commit().await; 313 return ApiError::ExpiredTokenMsg( 314 "Refresh token has been revoked due to suspected compromise".into(), 315 ) 316 .into_response(); 317 } 318 let session_row = match sqlx::query!( 319 r#"SELECT st.id, st.did, k.key_bytes, k.encryption_version 320 FROM session_tokens st 321 JOIN users u ON st.did = u.did 322 JOIN user_keys k ON u.id = k.user_id 323 WHERE st.refresh_jti = $1 AND st.refresh_expires_at > NOW() 324 FOR UPDATE OF st"#, 325 refresh_jti 326 ) 327 .fetch_optional(&mut *tx) 328 .await 329 { 330 Ok(Some(row)) => row, 331 Ok(None) => { 332 return ApiError::AuthenticationFailedMsg("Invalid refresh token".into()) 333 .into_response(); 334 } 335 Err(e) => { 336 error!("Database error fetching session: {:?}", e); 337 return ApiError::InternalError.into_response(); 338 } 339 }; 340 let key_bytes = 341 match crate::config::decrypt_key(&session_row.key_bytes, session_row.encryption_version) { 342 Ok(k) => k, 343 Err(e) => { 344 error!("Failed to decrypt user key: {:?}", e); 345 return ApiError::InternalError.into_response(); 346 } 347 }; 348 if crate::auth::verify_refresh_token(&refresh_token, &key_bytes).is_err() { 349 return ApiError::AuthenticationFailedMsg("Invalid refresh token".into()).into_response(); 350 } 351 let new_access_meta = 352 match crate::auth::create_access_token_with_metadata(&session_row.did, &key_bytes) { 353 Ok(m) => m, 354 Err(e) => { 355 error!("Failed to create access token: {:?}", e); 356 return ApiError::InternalError.into_response(); 357 } 358 }; 359 let new_refresh_meta = 360 match crate::auth::create_refresh_token_with_metadata(&session_row.did, &key_bytes) { 361 Ok(m) => m, 362 Err(e) => { 363 error!("Failed to create refresh token: {:?}", e); 364 return ApiError::InternalError.into_response(); 365 } 366 }; 367 match sqlx::query!( 368 "INSERT INTO used_refresh_tokens (refresh_jti, session_id) VALUES ($1, $2) ON CONFLICT (refresh_jti) DO NOTHING", 369 refresh_jti, 370 session_row.id 371 ) 372 .execute(&mut *tx) 373 .await 374 { 375 Ok(result) if result.rows_affected() == 0 => { 376 warn!("Concurrent refresh token reuse detected for session_id: {}", session_row.id); 377 let _ = sqlx::query!("DELETE FROM session_tokens WHERE id = $1", session_row.id) 378 .execute(&mut *tx) 379 .await; 380 let _ = tx.commit().await; 381 return ApiError::ExpiredTokenMsg("Refresh token has been revoked due to suspected compromise".into()).into_response(); 382 } 383 Err(e) => { 384 error!("Failed to record used refresh token: {:?}", e); 385 return ApiError::InternalError.into_response(); 386 } 387 Ok(_) => {} 388 } 389 if let Err(e) = sqlx::query!( 390 "UPDATE session_tokens SET access_jti = $1, refresh_jti = $2, access_expires_at = $3, refresh_expires_at = $4, updated_at = NOW() WHERE id = $5", 391 new_access_meta.jti, 392 new_refresh_meta.jti, 393 new_access_meta.expires_at, 394 new_refresh_meta.expires_at, 395 session_row.id 396 ) 397 .execute(&mut *tx) 398 .await 399 { 400 error!("Database error updating session: {:?}", e); 401 return ApiError::InternalError.into_response(); 402 } 403 if let Err(e) = tx.commit().await { 404 error!("Failed to commit transaction: {:?}", e); 405 return ApiError::InternalError.into_response(); 406 } 407 match sqlx::query!( 408 r#"SELECT 409 handle, email, email_confirmed, 410 preferred_notification_channel as "preferred_channel: crate::notifications::NotificationChannel", 411 discord_verified, telegram_verified, signal_verified 412 FROM users WHERE did = $1"#, 413 session_row.did 414 ) 415 .fetch_optional(&state.db) 416 .await 417 { 418 Ok(Some(u)) => { 419 let (preferred_channel, preferred_channel_verified) = match u.preferred_channel { 420 crate::notifications::NotificationChannel::Email => ("email", u.email_confirmed), 421 crate::notifications::NotificationChannel::Discord => ("discord", u.discord_verified), 422 crate::notifications::NotificationChannel::Telegram => ("telegram", u.telegram_verified), 423 crate::notifications::NotificationChannel::Signal => ("signal", u.signal_verified), 424 }; 425 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 426 let full_handle = format!("{}.{}", u.handle, pds_hostname); 427 Json(json!({ 428 "accessJwt": new_access_meta.token, 429 "refreshJwt": new_refresh_meta.token, 430 "handle": full_handle, 431 "did": session_row.did, 432 "email": u.email, 433 "emailConfirmed": u.email_confirmed, 434 "preferredChannel": preferred_channel, 435 "preferredChannelVerified": preferred_channel_verified, 436 "active": true 437 })).into_response() 438 } 439 Ok(None) => { 440 error!("User not found for existing session: {}", session_row.did); 441 ApiError::InternalError.into_response() 442 } 443 Err(e) => { 444 error!("Database error fetching user: {:?}", e); 445 ApiError::InternalError.into_response() 446 } 447 } 448} 449 450#[derive(Deserialize)] 451#[serde(rename_all = "camelCase")] 452pub struct ConfirmSignupInput { 453 pub did: String, 454 pub verification_code: String, 455} 456 457#[derive(Serialize)] 458#[serde(rename_all = "camelCase")] 459pub struct ConfirmSignupOutput { 460 pub access_jwt: String, 461 pub refresh_jwt: String, 462 pub handle: String, 463 pub did: String, 464 pub email: Option<String>, 465 pub email_confirmed: bool, 466 pub preferred_channel: String, 467 pub preferred_channel_verified: bool, 468} 469 470pub async fn confirm_signup( 471 State(state): State<AppState>, 472 Json(input): Json<ConfirmSignupInput>, 473) -> Response { 474 info!("confirm_signup called for DID: {}", input.did); 475 let row = match sqlx::query!( 476 r#"SELECT 477 u.id, u.did, u.handle, u.email, 478 u.preferred_notification_channel as "channel: crate::notifications::NotificationChannel", 479 k.key_bytes, k.encryption_version 480 FROM users u 481 JOIN user_keys k ON u.id = k.user_id 482 WHERE u.did = $1"#, 483 input.did 484 ) 485 .fetch_optional(&state.db) 486 .await 487 { 488 Ok(Some(row)) => row, 489 Ok(None) => { 490 warn!("User not found for confirm_signup: {}", input.did); 491 return ApiError::InvalidRequest("Invalid DID or verification code".into()).into_response(); 492 } 493 Err(e) => { 494 error!("Database error in confirm_signup: {:?}", e); 495 return ApiError::InternalError.into_response(); 496 } 497 }; 498 499 let verification = match sqlx::query!( 500 "SELECT code, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'", 501 row.id 502 ) 503 .fetch_optional(&state.db) 504 .await 505 { 506 Ok(Some(v)) => v, 507 Ok(None) => { 508 warn!("No verification code found for user: {}", input.did); 509 return ApiError::InvalidRequest("No pending verification".into()).into_response(); 510 } 511 Err(e) => { 512 error!("Database error fetching verification: {:?}", e); 513 return ApiError::InternalError.into_response(); 514 } 515 }; 516 517 if verification.code != input.verification_code { 518 warn!("Invalid verification code for user: {}", input.did); 519 return ApiError::InvalidRequest("Invalid verification code".into()).into_response(); 520 } 521 if verification.expires_at < Utc::now() { 522 warn!("Verification code expired for user: {}", input.did); 523 return ApiError::ExpiredTokenMsg("Verification code has expired".into()) 524 .into_response(); 525 } 526 527 let key_bytes = match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) { 528 Ok(k) => k, 529 Err(e) => { 530 error!("Failed to decrypt user key: {:?}", e); 531 return ApiError::InternalError.into_response(); 532 } 533 }; 534 let verified_column = match row.channel { 535 crate::notifications::NotificationChannel::Email => "email_confirmed", 536 crate::notifications::NotificationChannel::Discord => "discord_verified", 537 crate::notifications::NotificationChannel::Telegram => "telegram_verified", 538 crate::notifications::NotificationChannel::Signal => "signal_verified", 539 }; 540 let update_query = format!( 541 "UPDATE users SET {} = TRUE WHERE did = $1", 542 verified_column 543 ); 544 if let Err(e) = sqlx::query(&update_query) 545 .bind(&input.did) 546 .execute(&state.db) 547 .await 548 { 549 error!("Failed to update verification status: {:?}", e); 550 return ApiError::InternalError.into_response(); 551 } 552 553 if let Err(e) = sqlx::query!( 554 "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'email'", 555 row.id 556 ) 557 .execute(&state.db) 558 .await { 559 error!("Failed to delete verification record: {:?}", e); 560 } 561 562 let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) { 563 Ok(m) => m, 564 Err(e) => { 565 error!("Failed to create access token: {:?}", e); 566 return ApiError::InternalError.into_response(); 567 } 568 }; 569 let refresh_meta = match crate::auth::create_refresh_token_with_metadata(&row.did, &key_bytes) { 570 Ok(m) => m, 571 Err(e) => { 572 error!("Failed to create refresh token: {:?}", e); 573 return ApiError::InternalError.into_response(); 574 } 575 }; 576 if let Err(e) = sqlx::query!( 577 "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)", 578 row.did, 579 access_meta.jti, 580 refresh_meta.jti, 581 access_meta.expires_at, 582 refresh_meta.expires_at 583 ) 584 .execute(&state.db) 585 .await 586 { 587 error!("Failed to insert session: {:?}", e); 588 return ApiError::InternalError.into_response(); 589 } 590 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 591 if let Err(e) = crate::notifications::enqueue_welcome(&state.db, row.id, &hostname).await { 592 warn!("Failed to enqueue welcome notification: {:?}", e); 593 } 594 let email_confirmed = matches!( 595 row.channel, 596 crate::notifications::NotificationChannel::Email 597 ); 598 let preferred_channel = match row.channel { 599 crate::notifications::NotificationChannel::Email => "email", 600 crate::notifications::NotificationChannel::Discord => "discord", 601 crate::notifications::NotificationChannel::Telegram => "telegram", 602 crate::notifications::NotificationChannel::Signal => "signal", 603 }; 604 Json(ConfirmSignupOutput { 605 access_jwt: access_meta.token, 606 refresh_jwt: refresh_meta.token, 607 handle: row.handle, 608 did: row.did, 609 email: row.email, 610 email_confirmed, 611 preferred_channel: preferred_channel.to_string(), 612 preferred_channel_verified: true, 613 }) 614 .into_response() 615} 616 617#[derive(Deserialize)] 618#[serde(rename_all = "camelCase")] 619pub struct ResendVerificationInput { 620 pub did: String, 621} 622 623pub async fn resend_verification( 624 State(state): State<AppState>, 625 Json(input): Json<ResendVerificationInput>, 626) -> Response { 627 info!("resend_verification called for DID: {}", input.did); 628 let row = match sqlx::query!( 629 r#"SELECT 630 id, handle, email, 631 preferred_notification_channel as "channel: crate::notifications::NotificationChannel", 632 discord_id, telegram_username, signal_number, 633 email_confirmed, discord_verified, telegram_verified, signal_verified 634 FROM users 635 WHERE did = $1"#, 636 input.did 637 ) 638 .fetch_optional(&state.db) 639 .await 640 { 641 Ok(Some(row)) => row, 642 Ok(None) => { 643 return ApiError::InvalidRequest("User not found".into()).into_response(); 644 } 645 Err(e) => { 646 error!("Database error in resend_verification: {:?}", e); 647 return ApiError::InternalError.into_response(); 648 } 649 }; 650 let is_verified = 651 row.email_confirmed || row.discord_verified || row.telegram_verified || row.signal_verified; 652 if is_verified { 653 return ApiError::InvalidRequest("Account is already verified".into()).into_response(); 654 } 655 let verification_code = format!("{:06}", rand::random::<u32>() % 1_000_000); 656 let code_expires_at = Utc::now() + chrono::Duration::minutes(30); 657 658 let email = row.email.clone(); 659 660 if let Err(e) = sqlx::query!( 661 r#" 662 INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) 663 VALUES ($1, 'email', $2, $3, $4) 664 ON CONFLICT (user_id, channel) DO UPDATE 665 SET code = $2, pending_identifier = $3, expires_at = $4, created_at = NOW() 666 "#, 667 row.id, 668 verification_code, 669 email, 670 code_expires_at 671 ) 672 .execute(&state.db) 673 .await 674 { 675 error!("Failed to update verification code: {:?}", e); 676 return ApiError::InternalError.into_response(); 677 } 678 let (channel_str, recipient) = match row.channel { 679 crate::notifications::NotificationChannel::Email => { 680 ("email", row.email.unwrap_or_default()) 681 } 682 crate::notifications::NotificationChannel::Discord => { 683 ("discord", row.discord_id.unwrap_or_default()) 684 } 685 crate::notifications::NotificationChannel::Telegram => { 686 ("telegram", row.telegram_username.unwrap_or_default()) 687 } 688 crate::notifications::NotificationChannel::Signal => { 689 ("signal", row.signal_number.unwrap_or_default()) 690 } 691 }; 692 if let Err(e) = crate::notifications::enqueue_signup_verification( 693 &state.db, 694 row.id, 695 channel_str, 696 &recipient, 697 &verification_code, 698 ) 699 .await 700 { 701 warn!("Failed to enqueue verification notification: {:?}", e); 702 } 703 Json(json!({"success": true})).into_response() 704}