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}