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.email_confirmation_code, 479 u.email_confirmation_code_expires_at, 480 u.preferred_notification_channel as "channel: crate::notifications::NotificationChannel", 481 k.key_bytes, k.encryption_version 482 FROM users u 483 JOIN user_keys k ON u.id = k.user_id 484 WHERE u.did = $1"#, 485 input.did 486 ) 487 .fetch_optional(&state.db) 488 .await 489 { 490 Ok(Some(row)) => row, 491 Ok(None) => { 492 warn!("User not found for confirm_signup: {}", input.did); 493 return ApiError::InvalidRequest("Invalid DID or verification code".into()).into_response(); 494 } 495 Err(e) => { 496 error!("Database error in confirm_signup: {:?}", e); 497 return ApiError::InternalError.into_response(); 498 } 499 }; 500 let stored_code = match &row.email_confirmation_code { 501 Some(code) => code, 502 None => { 503 warn!("No verification code found for user: {}", input.did); 504 return ApiError::InvalidRequest("No pending verification".into()).into_response(); 505 } 506 }; 507 if stored_code != &input.verification_code { 508 warn!("Invalid verification code for user: {}", input.did); 509 return ApiError::InvalidRequest("Invalid verification code".into()).into_response(); 510 } 511 if let Some(expires_at) = row.email_confirmation_code_expires_at 512 && expires_at < Utc::now() { 513 warn!("Verification code expired for user: {}", input.did); 514 return ApiError::ExpiredTokenMsg("Verification code has expired".into()) 515 .into_response(); 516 } 517 let key_bytes = match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) { 518 Ok(k) => k, 519 Err(e) => { 520 error!("Failed to decrypt user key: {:?}", e); 521 return ApiError::InternalError.into_response(); 522 } 523 }; 524 let verified_column = match row.channel { 525 crate::notifications::NotificationChannel::Email => "email_confirmed", 526 crate::notifications::NotificationChannel::Discord => "discord_verified", 527 crate::notifications::NotificationChannel::Telegram => "telegram_verified", 528 crate::notifications::NotificationChannel::Signal => "signal_verified", 529 }; 530 let update_query = format!( 531 "UPDATE users SET {} = TRUE, email_confirmation_code = NULL, email_confirmation_code_expires_at = NULL WHERE did = $1", 532 verified_column 533 ); 534 if let Err(e) = sqlx::query(&update_query) 535 .bind(&input.did) 536 .execute(&state.db) 537 .await 538 { 539 error!("Failed to update verification status: {:?}", e); 540 return ApiError::InternalError.into_response(); 541 } 542 let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) { 543 Ok(m) => m, 544 Err(e) => { 545 error!("Failed to create access token: {:?}", e); 546 return ApiError::InternalError.into_response(); 547 } 548 }; 549 let refresh_meta = match crate::auth::create_refresh_token_with_metadata(&row.did, &key_bytes) { 550 Ok(m) => m, 551 Err(e) => { 552 error!("Failed to create refresh token: {:?}", e); 553 return ApiError::InternalError.into_response(); 554 } 555 }; 556 if let Err(e) = sqlx::query!( 557 "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)", 558 row.did, 559 access_meta.jti, 560 refresh_meta.jti, 561 access_meta.expires_at, 562 refresh_meta.expires_at 563 ) 564 .execute(&state.db) 565 .await 566 { 567 error!("Failed to insert session: {:?}", e); 568 return ApiError::InternalError.into_response(); 569 } 570 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 571 if let Err(e) = crate::notifications::enqueue_welcome(&state.db, row.id, &hostname).await { 572 warn!("Failed to enqueue welcome notification: {:?}", e); 573 } 574 let email_confirmed = matches!( 575 row.channel, 576 crate::notifications::NotificationChannel::Email 577 ); 578 let preferred_channel = match row.channel { 579 crate::notifications::NotificationChannel::Email => "email", 580 crate::notifications::NotificationChannel::Discord => "discord", 581 crate::notifications::NotificationChannel::Telegram => "telegram", 582 crate::notifications::NotificationChannel::Signal => "signal", 583 }; 584 Json(ConfirmSignupOutput { 585 access_jwt: access_meta.token, 586 refresh_jwt: refresh_meta.token, 587 handle: row.handle, 588 did: row.did, 589 email: row.email, 590 email_confirmed, 591 preferred_channel: preferred_channel.to_string(), 592 preferred_channel_verified: true, 593 }) 594 .into_response() 595} 596 597#[derive(Deserialize)] 598#[serde(rename_all = "camelCase")] 599pub struct ResendVerificationInput { 600 pub did: String, 601} 602 603pub async fn resend_verification( 604 State(state): State<AppState>, 605 Json(input): Json<ResendVerificationInput>, 606) -> Response { 607 info!("resend_verification called for DID: {}", input.did); 608 let row = match sqlx::query!( 609 r#"SELECT 610 id, handle, email, 611 preferred_notification_channel as "channel: crate::notifications::NotificationChannel", 612 discord_id, telegram_username, signal_number, 613 email_confirmed, discord_verified, telegram_verified, signal_verified 614 FROM users 615 WHERE did = $1"#, 616 input.did 617 ) 618 .fetch_optional(&state.db) 619 .await 620 { 621 Ok(Some(row)) => row, 622 Ok(None) => { 623 return ApiError::InvalidRequest("User not found".into()).into_response(); 624 } 625 Err(e) => { 626 error!("Database error in resend_verification: {:?}", e); 627 return ApiError::InternalError.into_response(); 628 } 629 }; 630 let is_verified = 631 row.email_confirmed || row.discord_verified || row.telegram_verified || row.signal_verified; 632 if is_verified { 633 return ApiError::InvalidRequest("Account is already verified".into()).into_response(); 634 } 635 let verification_code = format!("{:06}", rand::random::<u32>() % 1_000_000); 636 let code_expires_at = Utc::now() + chrono::Duration::minutes(30); 637 if let Err(e) = sqlx::query!( 638 "UPDATE users SET email_confirmation_code = $1, email_confirmation_code_expires_at = $2 WHERE did = $3", 639 verification_code, 640 code_expires_at, 641 input.did 642 ) 643 .execute(&state.db) 644 .await 645 { 646 error!("Failed to update verification code: {:?}", e); 647 return ApiError::InternalError.into_response(); 648 } 649 let (channel_str, recipient) = match row.channel { 650 crate::notifications::NotificationChannel::Email => { 651 ("email", row.email.clone().unwrap_or_default()) 652 } 653 crate::notifications::NotificationChannel::Discord => { 654 ("discord", row.discord_id.unwrap_or_default()) 655 } 656 crate::notifications::NotificationChannel::Telegram => { 657 ("telegram", row.telegram_username.unwrap_or_default()) 658 } 659 crate::notifications::NotificationChannel::Signal => { 660 ("signal", row.signal_number.unwrap_or_default()) 661 } 662 }; 663 if let Err(e) = crate::notifications::enqueue_signup_verification( 664 &state.db, 665 row.id, 666 channel_str, 667 &recipient, 668 &verification_code, 669 ) 670 .await 671 { 672 warn!("Failed to enqueue verification notification: {:?}", e); 673 } 674 Json(json!({"success": true})).into_response() 675}