···1414- Crawler notifications via `requestCrawl`
1515- Multi-channel notifications: email, discord, telegram, signal
1616- Per-IP rate limiting on sensitive endpoints
1717+- Built-in web UI for account management
17181819## Running Locally
1920···7778just db-reset # Drop and recreate local database
7879```
79808181+## Web UI
8282+8383+BSPDS includes a built-in web frontend for users to manage their accounts. Users can:
8484+8585+- Sign in and register new accounts
8686+- Manage app passwords
8787+- View and create invite codes
8888+- Update email and handle
8989+- Configure notification preferences
9090+- Browse their repository data
9191+9292+The frontend is built with svelte and deno, and is served directly by the PDS.
9393+9494+```bash
9595+just frontend-dev # Run frontend dev server
9696+just frontend-build # Build for production
9797+just frontend-test # Run frontend tests
9898+```
9999+80100## Project Structure
8110182102```
···94114 plc/ PLC directory client
95115 circuit_breaker/ Circuit breaker for external services
96116 rate_limit/ Per-IP rate limiting
117117+frontend/ Svelte web UI (deno)
97118tests/ Integration tests
98119migrations/ SQLx migrations
99120```
+23-14
TODO.md
···258258A single-page web app for account management. The frontend (JS framework) calls existing ATProto XRPC endpoints - no server-side rendering or bespoke HTML form handlers.
259259260260### Architecture
261261-- [ ] Static SPA served from PDS (or separate static host)
261261+- [x] Static SPA served from PDS (or separate static host)
262262- [ ] Frontend authenticates via OAuth 2.1 flow (same as any ATProto client)
263263-- [ ] All operations use standard XRPC endpoints (existing + new PDS-specific ones below)
264264-- [ ] No server-side sessions or CSRF - pure API client
263263+- [x] All operations use standard XRPC endpoints (existing + new PDS-specific ones below)
264264+- [x] No server-side sessions or CSRF - pure API client
265265266266### PDS-Specific XRPC Endpoints (new)
267267Absolutely subject to change, "bspds" isn't even the real name of this pds thus far :D
268268Anyway... endpoints for PDS settings not covered by standard ATProto:
269269-- [ ] `com.bspds.account.getNotificationPrefs` - get preferred channel, verified channels
270270-- [ ] `com.bspds.account.updateNotificationPrefs` - set preferred channel
269269+- [x] `com.bspds.account.getNotificationPrefs` - get preferred channel, verified channels
270270+- [x] `com.bspds.account.updateNotificationPrefs` - set preferred channel
271271- [ ] `com.bspds.account.getNotificationHistory` - list past notifications
272272- [ ] `com.bspds.account.verifyChannel` - initiate verification for Discord/Telegram/Signal
273273- [ ] `com.bspds.account.confirmChannelVerification` - confirm with code
···276276### Frontend Views
277277Uses existing ATProto endpoints where possible:
278278279279+Authentication
280280+- [x] Login page (uses `com.atproto.server.createSession`)
281281+- [x] Registration page (uses `com.atproto.server.createAccount`)
282282+- [x] Signup verification flow (uses `com.atproto.server.confirmSignup`, `resendVerification`)
283283+- [ ] Password reset flow (uses `com.atproto.server.requestPasswordReset`, `resetPassword`)
284284+279285User Dashboard
280280-- [ ] Account overview (uses `com.atproto.server.getSession`, `com.atproto.admin.getAccountInfo`)
286286+- [x] Account overview (uses `com.atproto.server.getSession`, `com.atproto.admin.getAccountInfo`)
281287- [ ] Active sessions view (needs new endpoint or extend existing)
282282-- [ ] App passwords (uses `com.atproto.server.listAppPasswords`, `createAppPassword`, `revokeAppPassword`)
283283-- [ ] Invite codes (uses `com.atproto.server.getAccountInviteCodes`, `createInviteCode`)
288288+- [x] App passwords (uses `com.atproto.server.listAppPasswords`, `createAppPassword`, `revokeAppPassword`)
289289+- [x] Invite codes (uses `com.atproto.server.getAccountInviteCodes`, `createInviteCode`)
284290285291Notification Preferences
286286-- [ ] Channel selector (uses `com.bspds.account.*` endpoints above)
292292+- [x] Channel selector (uses `com.bspds.account.*` endpoints above)
287293- [ ] Verification flows for Discord/Telegram/Signal
288294- [ ] Notification history view
289295290296Account Settings
291291-- [ ] Email change (uses `com.atproto.server.requestEmailUpdate`, `updateEmail`)
292292-- [ ] Password change (uses `com.atproto.server.requestPasswordReset`, `resetPassword`)
293293-- [ ] Handle change (uses `com.atproto.identity.updateHandle`)
294294-- [ ] Account deletion (uses `com.atproto.server.requestAccountDelete`, `deleteAccount`)
295295-- [ ] Data export (uses `com.atproto.sync.getRepo`)
297297+- [x] Email change (uses `com.atproto.server.requestEmailUpdate`, `updateEmail`)
298298+- [ ] Password change while logged in (needs new endpoint - change password with current password)
299299+- [x] Handle change (uses `com.atproto.identity.updateHandle`)
300300+- [x] Account deletion (uses `com.atproto.server.requestAccountDelete`, `deleteAccount`)
301301+302302+Data Management
303303+- [x] Repo browser (browse collections, view/create/delete records via `com.atproto.repo.*`)
304304+- [ ] Data export/download (CAR file download via `com.atproto.sync.getRepo`)
296305297306Admin Dashboard (privileged users only)
298307- [ ] User list (uses `com.atproto.admin.getAccountInfos` with pagination)
+13
frontend/deno.json
···11+{
22+ "tasks": {
33+ "dev": "deno run -A npm:vite",
44+ "build": "deno run -A npm:vite build",
55+ "preview": "deno run -A npm:vite preview",
66+ "test": "deno run -A npm:vitest",
77+ "test:run": "deno run -A npm:vitest run",
88+ "test:watch": "deno run -A npm:vitest watch",
99+ "test:ui": "deno run -A npm:vitest --ui",
1010+ "test:coverage": "deno run -A npm:vitest run --coverage"
1111+ },
1212+ "nodeModulesDir": "auto"
1313+}
···66 'password_reset',
77 'email_update',
88 'account_deletion',
99- 'admin_email'
99+ 'admin_email',
1010+ 'plc_operation',
1111+ 'two_factor_code'
1012);
11131214CREATE TABLE IF NOT EXISTS users (
1315 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
1416 handle TEXT NOT NULL UNIQUE,
1515- email TEXT NOT NULL UNIQUE,
1717+ email TEXT UNIQUE,
1618 did TEXT NOT NULL UNIQUE,
1719 password_hash TEXT NOT NULL,
1820 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
···29313032 email_pending_verification TEXT,
3133 email_confirmation_code TEXT,
3232- email_confirmation_code_expires_at TIMESTAMPTZ
3434+ email_confirmation_code_expires_at TIMESTAMPTZ,
3535+ email_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
3636+3737+ two_factor_enabled BOOLEAN NOT NULL DEFAULT FALSE,
3838+3939+ discord_id TEXT,
4040+ discord_verified BOOLEAN NOT NULL DEFAULT FALSE,
4141+4242+ telegram_username TEXT,
4343+ telegram_verified BOOLEAN NOT NULL DEFAULT FALSE,
4444+4545+ signal_number TEXT,
4646+ signal_verified BOOLEAN NOT NULL DEFAULT FALSE
3347);
34483549CREATE INDEX IF NOT EXISTS idx_users_password_reset_code ON users(password_reset_code) WHERE password_reset_code IS NOT NULL;
3650CREATE INDEX IF NOT EXISTS idx_users_email_confirmation_code ON users(email_confirmation_code) WHERE email_confirmation_code IS NOT NULL;
5151+CREATE INDEX IF NOT EXISTS idx_users_discord_id ON users(discord_id) WHERE discord_id IS NOT NULL;
5252+CREATE INDEX IF NOT EXISTS idx_users_telegram_username ON users(telegram_username) WHERE telegram_username IS NOT NULL;
5353+CREATE INDEX IF NOT EXISTS idx_users_signal_number ON users(signal_number) WHERE signal_number IS NOT NULL;
37543855CREATE TABLE IF NOT EXISTS invite_codes (
3956 code TEXT PRIMARY KEY,
···6279CREATE TABLE IF NOT EXISTS repos (
6380 user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
6481 repo_root_cid TEXT NOT NULL,
8282+ repo_rev TEXT,
6583 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
6684 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
6785);
···7997 rkey TEXT NOT NULL,
8098 record_cid TEXT NOT NULL,
8199 takedown_ref TEXT,
100100+ repo_rev TEXT,
82101 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
83102 UNIQUE(repo_id, collection, rkey)
84103);
104104+105105+CREATE INDEX idx_records_repo_rev ON records(repo_rev);
8510686107CREATE TABLE IF NOT EXISTS blobs (
87108 cid TEXT PRIMARY KEY,
···265286);
266287267288CREATE INDEX idx_oauth_dpop_jti_created_at ON oauth_dpop_jti(created_at);
289289+290290+CREATE TABLE plc_operation_tokens (
291291+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
292292+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
293293+ token TEXT NOT NULL UNIQUE,
294294+ expires_at TIMESTAMPTZ NOT NULL,
295295+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
296296+);
297297+298298+CREATE INDEX idx_plc_op_tokens_user ON plc_operation_tokens(user_id);
299299+CREATE INDEX idx_plc_op_tokens_expires ON plc_operation_tokens(expires_at);
300300+301301+CREATE TABLE IF NOT EXISTS account_preferences (
302302+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
303303+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
304304+ name TEXT NOT NULL,
305305+ value_json JSONB NOT NULL,
306306+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
307307+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
308308+ UNIQUE(user_id, name)
309309+);
310310+311311+CREATE INDEX IF NOT EXISTS idx_account_preferences_user_id ON account_preferences(user_id);
312312+CREATE INDEX IF NOT EXISTS idx_account_preferences_name ON account_preferences(name);
313313+314314+CREATE TABLE oauth_2fa_challenge (
315315+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
316316+ did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
317317+ request_uri TEXT NOT NULL,
318318+ code TEXT NOT NULL,
319319+ attempts INTEGER NOT NULL DEFAULT 0,
320320+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
321321+ expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '10 minutes'
322322+);
323323+324324+CREATE INDEX idx_oauth_2fa_challenge_request_uri ON oauth_2fa_challenge(request_uri);
325325+CREATE INDEX idx_oauth_2fa_challenge_expires ON oauth_2fa_challenge(expires_at);
-10
migrations/202512211406_plc_operation_tokens.sql
···11-CREATE TABLE plc_operation_tokens (
22- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
33- user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
44- token TEXT NOT NULL UNIQUE,
55- expires_at TIMESTAMPTZ NOT NULL,
66- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
77-);
88-99-CREATE INDEX idx_plc_op_tokens_user ON plc_operation_tokens(user_id);
1010-CREATE INDEX idx_plc_op_tokens_expires ON plc_operation_tokens(expires_at);
···11-ALTER TYPE notification_type ADD VALUE 'plc_operation';
-12
migrations/202512211500_account_preferences.sql
···11-CREATE TABLE IF NOT EXISTS account_preferences (
22- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
33- user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
44- name TEXT NOT NULL,
55- value_json JSONB NOT NULL,
66- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
77- updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
88- UNIQUE(user_id, name)
99-);
1010-1111-CREATE INDEX IF NOT EXISTS idx_account_preferences_user_id ON account_preferences(user_id);
1212-CREATE INDEX IF NOT EXISTS idx_account_preferences_name ON account_preferences(name);
-2
migrations/202512211600_add_repo_rev.sql
···11-ALTER TABLE records ADD COLUMN repo_rev TEXT;
22-CREATE INDEX idx_records_repo_rev ON records(repo_rev);
-16
migrations/202512211700_add_2fa.sql
···11-ALTER TABLE users ADD COLUMN two_factor_enabled BOOLEAN NOT NULL DEFAULT FALSE;
22-33-ALTER TYPE notification_type ADD VALUE 'two_factor_code';
44-55-CREATE TABLE oauth_2fa_challenge (
66- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
77- did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
88- request_uri TEXT NOT NULL,
99- code TEXT NOT NULL,
1010- attempts INTEGER NOT NULL DEFAULT 0,
1111- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
1212- expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '10 minutes'
1313-);
1414-1515-CREATE INDEX idx_oauth_2fa_challenge_request_uri ON oauth_2fa_challenge(request_uri);
1616-CREATE INDEX idx_oauth_2fa_challenge_expires ON oauth_2fa_challenge(expires_at);
+13-1
src/api/admin/account/email.rs
···6565 .await;
66666767 let (user_id, email, handle) = match user {
6868- Ok(Some(row)) => (row.id, row.email, row.handle),
6868+ Ok(Some(row)) => {
6969+ let email = match row.email {
7070+ Some(e) => e,
7171+ None => {
7272+ return (
7373+ StatusCode::BAD_REQUEST,
7474+ Json(json!({"error": "NoEmail", "message": "Recipient has no email address"})),
7575+ )
7676+ .into_response();
7777+ }
7878+ };
7979+ (row.id, email, row.handle)
8080+ }
6981 Ok(None) => {
7082 return (
7183 StatusCode::NOT_FOUND,
···55pub mod identity;
66pub mod moderation;
77pub mod notification;
88+pub mod notification_prefs;
89pub mod proxy;
910pub mod proxy_client;
1011pub mod read_after_write;
···1818pub use meta::{describe_server, health, robots_txt};
1919pub use password::{request_password_reset, reset_password};
2020pub use service_auth::get_service_auth;
2121-pub use session::{create_session, delete_session, get_session, refresh_session};
2121+pub use session::{confirm_signup, create_session, delete_session, get_session, refresh_session, resend_verification};
2222pub use signing_key::reserve_signing_key;
+252-1
src/api/server/session.rs
···88 response::{IntoResponse, Response},
99};
1010use bcrypt::verify;
1111+use chrono::Utc;
1112use serde::{Deserialize, Serialize};
1213use serde_json::json;
1314use tracing::{error, info, warn};
···6465 }
65666667 let row = match sqlx::query!(
6767- "SELECT u.id, u.did, u.handle, u.password_hash, k.key_bytes, k.encryption_version FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.handle = $1 OR u.email = $1",
6868+ r#"SELECT
6969+ u.id, u.did, u.handle, u.password_hash,
7070+ u.email_confirmed, u.discord_verified, u.telegram_verified, u.signal_verified,
7171+ k.key_bytes, k.encryption_version
7272+ FROM users u
7373+ JOIN user_keys k ON u.id = k.user_id
7474+ WHERE u.handle = $1 OR u.email = $1"#,
6875 input.identifier
6976 )
7077 .fetch_optional(&state.db)
···101108 if !password_valid {
102109 warn!("Password verification failed for login attempt");
103110 return ApiError::AuthenticationFailedMsg("Invalid identifier or password".into()).into_response();
111111+ }
112112+113113+ let is_verified = row.email_confirmed
114114+ || row.discord_verified
115115+ || row.telegram_verified
116116+ || row.signal_verified;
117117+118118+ if !is_verified {
119119+ warn!("Login attempt for unverified account: {}", row.did);
120120+ return (
121121+ StatusCode::FORBIDDEN,
122122+ Json(json!({
123123+ "error": "AccountNotVerified",
124124+ "message": "Please verify your account before logging in",
125125+ "did": row.did
126126+ })),
127127+ ).into_response();
104128 }
105129106130 let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) {
···361385 }
362386 }
363387}
388388+389389+#[derive(Deserialize)]
390390+#[serde(rename_all = "camelCase")]
391391+pub struct ConfirmSignupInput {
392392+ pub did: String,
393393+ pub verification_code: String,
394394+}
395395+396396+#[derive(Serialize)]
397397+#[serde(rename_all = "camelCase")]
398398+pub struct ConfirmSignupOutput {
399399+ pub access_jwt: String,
400400+ pub refresh_jwt: String,
401401+ pub handle: String,
402402+ pub did: String,
403403+}
404404+405405+pub async fn confirm_signup(
406406+ State(state): State<AppState>,
407407+ Json(input): Json<ConfirmSignupInput>,
408408+) -> Response {
409409+ info!("confirm_signup called for DID: {}", input.did);
410410+411411+ let row = match sqlx::query!(
412412+ r#"SELECT
413413+ u.id, u.did, u.handle,
414414+ u.email_confirmation_code,
415415+ u.email_confirmation_code_expires_at,
416416+ u.preferred_notification_channel as "channel: crate::notifications::NotificationChannel",
417417+ k.key_bytes, k.encryption_version
418418+ FROM users u
419419+ JOIN user_keys k ON u.id = k.user_id
420420+ WHERE u.did = $1"#,
421421+ input.did
422422+ )
423423+ .fetch_optional(&state.db)
424424+ .await
425425+ {
426426+ Ok(Some(row)) => row,
427427+ Ok(None) => {
428428+ warn!("User not found for confirm_signup: {}", input.did);
429429+ return ApiError::InvalidRequest("Invalid DID or verification code".into()).into_response();
430430+ }
431431+ Err(e) => {
432432+ error!("Database error in confirm_signup: {:?}", e);
433433+ return ApiError::InternalError.into_response();
434434+ }
435435+ };
436436+437437+ let stored_code = match &row.email_confirmation_code {
438438+ Some(code) => code,
439439+ None => {
440440+ warn!("No verification code found for user: {}", input.did);
441441+ return ApiError::InvalidRequest("No pending verification".into()).into_response();
442442+ }
443443+ };
444444+445445+ if stored_code != &input.verification_code {
446446+ warn!("Invalid verification code for user: {}", input.did);
447447+ return ApiError::InvalidRequest("Invalid verification code".into()).into_response();
448448+ }
449449+450450+ if let Some(expires_at) = row.email_confirmation_code_expires_at {
451451+ if expires_at < Utc::now() {
452452+ warn!("Verification code expired for user: {}", input.did);
453453+ return ApiError::ExpiredTokenMsg("Verification code has expired".into()).into_response();
454454+ }
455455+ }
456456+457457+ let key_bytes = match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
458458+ Ok(k) => k,
459459+ Err(e) => {
460460+ error!("Failed to decrypt user key: {:?}", e);
461461+ return ApiError::InternalError.into_response();
462462+ }
463463+ };
464464+465465+ let verified_column = match row.channel {
466466+ crate::notifications::NotificationChannel::Email => "email_confirmed",
467467+ crate::notifications::NotificationChannel::Discord => "discord_verified",
468468+ crate::notifications::NotificationChannel::Telegram => "telegram_verified",
469469+ crate::notifications::NotificationChannel::Signal => "signal_verified",
470470+ };
471471+472472+ let update_query = format!(
473473+ "UPDATE users SET {} = TRUE, email_confirmation_code = NULL, email_confirmation_code_expires_at = NULL WHERE did = $1",
474474+ verified_column
475475+ );
476476+477477+ if let Err(e) = sqlx::query(&update_query)
478478+ .bind(&input.did)
479479+ .execute(&state.db)
480480+ .await
481481+ {
482482+ error!("Failed to update verification status: {:?}", e);
483483+ return ApiError::InternalError.into_response();
484484+ }
485485+486486+ let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) {
487487+ Ok(m) => m,
488488+ Err(e) => {
489489+ error!("Failed to create access token: {:?}", e);
490490+ return ApiError::InternalError.into_response();
491491+ }
492492+ };
493493+494494+ let refresh_meta = match crate::auth::create_refresh_token_with_metadata(&row.did, &key_bytes) {
495495+ Ok(m) => m,
496496+ Err(e) => {
497497+ error!("Failed to create refresh token: {:?}", e);
498498+ return ApiError::InternalError.into_response();
499499+ }
500500+ };
501501+502502+ if let Err(e) = sqlx::query!(
503503+ "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)",
504504+ row.did,
505505+ access_meta.jti,
506506+ refresh_meta.jti,
507507+ access_meta.expires_at,
508508+ refresh_meta.expires_at
509509+ )
510510+ .execute(&state.db)
511511+ .await
512512+ {
513513+ error!("Failed to insert session: {:?}", e);
514514+ return ApiError::InternalError.into_response();
515515+ }
516516+517517+ let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
518518+ if let Err(e) = crate::notifications::enqueue_welcome(&state.db, row.id, &hostname).await {
519519+ warn!("Failed to enqueue welcome notification: {:?}", e);
520520+ }
521521+522522+ Json(ConfirmSignupOutput {
523523+ access_jwt: access_meta.token,
524524+ refresh_jwt: refresh_meta.token,
525525+ handle: row.handle,
526526+ did: row.did,
527527+ }).into_response()
528528+}
529529+530530+#[derive(Deserialize)]
531531+#[serde(rename_all = "camelCase")]
532532+pub struct ResendVerificationInput {
533533+ pub did: String,
534534+}
535535+536536+pub async fn resend_verification(
537537+ State(state): State<AppState>,
538538+ Json(input): Json<ResendVerificationInput>,
539539+) -> Response {
540540+ info!("resend_verification called for DID: {}", input.did);
541541+542542+ let row = match sqlx::query!(
543543+ r#"SELECT
544544+ id, handle, email,
545545+ preferred_notification_channel as "channel: crate::notifications::NotificationChannel",
546546+ discord_id, telegram_username, signal_number,
547547+ email_confirmed, discord_verified, telegram_verified, signal_verified
548548+ FROM users
549549+ WHERE did = $1"#,
550550+ input.did
551551+ )
552552+ .fetch_optional(&state.db)
553553+ .await
554554+ {
555555+ Ok(Some(row)) => row,
556556+ Ok(None) => {
557557+ return ApiError::InvalidRequest("User not found".into()).into_response();
558558+ }
559559+ Err(e) => {
560560+ error!("Database error in resend_verification: {:?}", e);
561561+ return ApiError::InternalError.into_response();
562562+ }
563563+ };
564564+565565+ let is_verified = row.email_confirmed
566566+ || row.discord_verified
567567+ || row.telegram_verified
568568+ || row.signal_verified;
569569+570570+ if is_verified {
571571+ return ApiError::InvalidRequest("Account is already verified".into()).into_response();
572572+ }
573573+574574+ let verification_code = format!("{:06}", rand::random::<u32>() % 1_000_000);
575575+ let code_expires_at = Utc::now() + chrono::Duration::minutes(30);
576576+577577+ if let Err(e) = sqlx::query!(
578578+ "UPDATE users SET email_confirmation_code = $1, email_confirmation_code_expires_at = $2 WHERE did = $3",
579579+ verification_code,
580580+ code_expires_at,
581581+ input.did
582582+ )
583583+ .execute(&state.db)
584584+ .await
585585+ {
586586+ error!("Failed to update verification code: {:?}", e);
587587+ return ApiError::InternalError.into_response();
588588+ }
589589+590590+ let (channel_str, recipient) = match row.channel {
591591+ crate::notifications::NotificationChannel::Email => ("email", row.email.clone().unwrap_or_default()),
592592+ crate::notifications::NotificationChannel::Discord => {
593593+ ("discord", row.discord_id.unwrap_or_default())
594594+ }
595595+ crate::notifications::NotificationChannel::Telegram => {
596596+ ("telegram", row.telegram_username.unwrap_or_default())
597597+ }
598598+ crate::notifications::NotificationChannel::Signal => {
599599+ ("signal", row.signal_number.unwrap_or_default())
600600+ }
601601+ };
602602+603603+ if let Err(e) = crate::notifications::enqueue_signup_verification(
604604+ &state.db,
605605+ row.id,
606606+ channel_str,
607607+ &recipient,
608608+ &verification_code,
609609+ ).await {
610610+ warn!("Failed to enqueue verification notification: {:?}", e);
611611+ }
612612+613613+ Json(json!({"success": true})).into_response()
614614+}
+5-1
src/crawlers.rs
···106106 cb.record_success().await;
107107 }
108108 } else {
109109+ let status = response.status();
110110+ let body = response.text().await.unwrap_or_default();
109111 warn!(
110112 crawler = %url,
111111- status = %response.status(),
113113+ status = %status,
114114+ body = %body,
115115+ hostname = %hostname,
112116 "Crawler notification returned non-success status"
113117 );
114118 if let Some(cb) = cb {