···14- Crawler notifications via `requestCrawl`
15- Multi-channel notifications: email, discord, telegram, signal
16- Per-IP rate limiting on sensitive endpoints
01718## Running Locally
19···77just db-reset # Drop and recreate local database
78```
79000000000000000000080## Project Structure
8182```
···94 plc/ PLC directory client
95 circuit_breaker/ Circuit breaker for external services
96 rate_limit/ Per-IP rate limiting
097tests/ Integration tests
98migrations/ SQLx migrations
99```
···14- Crawler notifications via `requestCrawl`
15- Multi-channel notifications: email, discord, telegram, signal
16- Per-IP rate limiting on sensitive endpoints
17+- Built-in web UI for account management
1819## Running Locally
20···78just db-reset # Drop and recreate local database
79```
8081+## Web UI
82+83+BSPDS includes a built-in web frontend for users to manage their accounts. Users can:
84+85+- Sign in and register new accounts
86+- Manage app passwords
87+- View and create invite codes
88+- Update email and handle
89+- Configure notification preferences
90+- Browse their repository data
91+92+The frontend is built with svelte and deno, and is served directly by the PDS.
93+94+```bash
95+just frontend-dev # Run frontend dev server
96+just frontend-build # Build for production
97+just frontend-test # Run frontend tests
98+```
99+100## Project Structure
101102```
···114 plc/ PLC directory client
115 circuit_breaker/ Circuit breaker for external services
116 rate_limit/ Per-IP rate limiting
117+frontend/ Svelte web UI (deno)
118tests/ Integration tests
119migrations/ SQLx migrations
120```
+23-14
TODO.md
···258A 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.
259260### Architecture
261-- [ ] Static SPA served from PDS (or separate static host)
262- [ ] Frontend authenticates via OAuth 2.1 flow (same as any ATProto client)
263-- [ ] All operations use standard XRPC endpoints (existing + new PDS-specific ones below)
264-- [ ] No server-side sessions or CSRF - pure API client
265266### PDS-Specific XRPC Endpoints (new)
267Absolutely subject to change, "bspds" isn't even the real name of this pds thus far :D
268Anyway... endpoints for PDS settings not covered by standard ATProto:
269-- [ ] `com.bspds.account.getNotificationPrefs` - get preferred channel, verified channels
270-- [ ] `com.bspds.account.updateNotificationPrefs` - set preferred channel
271- [ ] `com.bspds.account.getNotificationHistory` - list past notifications
272- [ ] `com.bspds.account.verifyChannel` - initiate verification for Discord/Telegram/Signal
273- [ ] `com.bspds.account.confirmChannelVerification` - confirm with code
···276### Frontend Views
277Uses existing ATProto endpoints where possible:
278000000279User Dashboard
280-- [ ] Account overview (uses `com.atproto.server.getSession`, `com.atproto.admin.getAccountInfo`)
281- [ ] Active sessions view (needs new endpoint or extend existing)
282-- [ ] App passwords (uses `com.atproto.server.listAppPasswords`, `createAppPassword`, `revokeAppPassword`)
283-- [ ] Invite codes (uses `com.atproto.server.getAccountInviteCodes`, `createInviteCode`)
284285Notification Preferences
286-- [ ] Channel selector (uses `com.bspds.account.*` endpoints above)
287- [ ] Verification flows for Discord/Telegram/Signal
288- [ ] Notification history view
289290Account Settings
291-- [ ] Email change (uses `com.atproto.server.requestEmailUpdate`, `updateEmail`)
292-- [ ] Password change (uses `com.atproto.server.requestPasswordReset`, `resetPassword`)
293-- [ ] Handle change (uses `com.atproto.identity.updateHandle`)
294-- [ ] Account deletion (uses `com.atproto.server.requestAccountDelete`, `deleteAccount`)
295-- [ ] Data export (uses `com.atproto.sync.getRepo`)
000296297Admin Dashboard (privileged users only)
298- [ ] User list (uses `com.atproto.admin.getAccountInfos` with pagination)
···258A 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.
259260### Architecture
261+- [x] Static SPA served from PDS (or separate static host)
262- [ ] Frontend authenticates via OAuth 2.1 flow (same as any ATProto client)
263+- [x] All operations use standard XRPC endpoints (existing + new PDS-specific ones below)
264+- [x] No server-side sessions or CSRF - pure API client
265266### PDS-Specific XRPC Endpoints (new)
267Absolutely subject to change, "bspds" isn't even the real name of this pds thus far :D
268Anyway... endpoints for PDS settings not covered by standard ATProto:
269+- [x] `com.bspds.account.getNotificationPrefs` - get preferred channel, verified channels
270+- [x] `com.bspds.account.updateNotificationPrefs` - set preferred channel
271- [ ] `com.bspds.account.getNotificationHistory` - list past notifications
272- [ ] `com.bspds.account.verifyChannel` - initiate verification for Discord/Telegram/Signal
273- [ ] `com.bspds.account.confirmChannelVerification` - confirm with code
···276### Frontend Views
277Uses existing ATProto endpoints where possible:
278279+Authentication
280+- [x] Login page (uses `com.atproto.server.createSession`)
281+- [x] Registration page (uses `com.atproto.server.createAccount`)
282+- [x] Signup verification flow (uses `com.atproto.server.confirmSignup`, `resendVerification`)
283+- [ ] Password reset flow (uses `com.atproto.server.requestPasswordReset`, `resetPassword`)
284+285User Dashboard
286+- [x] Account overview (uses `com.atproto.server.getSession`, `com.atproto.admin.getAccountInfo`)
287- [ ] Active sessions view (needs new endpoint or extend existing)
288+- [x] App passwords (uses `com.atproto.server.listAppPasswords`, `createAppPassword`, `revokeAppPassword`)
289+- [x] Invite codes (uses `com.atproto.server.getAccountInviteCodes`, `createInviteCode`)
290291Notification Preferences
292+- [x] Channel selector (uses `com.bspds.account.*` endpoints above)
293- [ ] Verification flows for Discord/Telegram/Signal
294- [ ] Notification history view
295296Account Settings
297+- [x] Email change (uses `com.atproto.server.requestEmailUpdate`, `updateEmail`)
298+- [ ] Password change while logged in (needs new endpoint - change password with current password)
299+- [x] Handle change (uses `com.atproto.identity.updateHandle`)
300+- [x] Account deletion (uses `com.atproto.server.requestAccountDelete`, `deleteAccount`)
301+302+Data Management
303+- [x] Repo browser (browse collections, view/create/delete records via `com.atproto.repo.*`)
304+- [ ] Data export/download (CAR file download via `com.atproto.sync.getRepo`)
305306Admin Dashboard (privileged users only)
307- [ ] User list (uses `com.atproto.admin.getAccountInfos` with pagination)
+13
frontend/deno.json
···0000000000000
···1+{
2+ "tasks": {
3+ "dev": "deno run -A npm:vite",
4+ "build": "deno run -A npm:vite build",
5+ "preview": "deno run -A npm:vite preview",
6+ "test": "deno run -A npm:vitest",
7+ "test:run": "deno run -A npm:vitest run",
8+ "test:watch": "deno run -A npm:vitest watch",
9+ "test:ui": "deno run -A npm:vitest --ui",
10+ "test:coverage": "deno run -A npm:vitest run --coverage"
11+ },
12+ "nodeModulesDir": "auto"
13+}
···6 'password_reset',
7 'email_update',
8 'account_deletion',
9- 'admin_email'
0010);
1112CREATE TABLE IF NOT EXISTS users (
13 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
14 handle TEXT NOT NULL UNIQUE,
15- email TEXT NOT NULL UNIQUE,
16 did TEXT NOT NULL UNIQUE,
17 password_hash TEXT NOT NULL,
18 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
···2930 email_pending_verification TEXT,
31 email_confirmation_code TEXT,
32- email_confirmation_code_expires_at TIMESTAMPTZ
00000000000033);
3435CREATE INDEX IF NOT EXISTS idx_users_password_reset_code ON users(password_reset_code) WHERE password_reset_code IS NOT NULL;
36CREATE INDEX IF NOT EXISTS idx_users_email_confirmation_code ON users(email_confirmation_code) WHERE email_confirmation_code IS NOT NULL;
0003738CREATE TABLE IF NOT EXISTS invite_codes (
39 code TEXT PRIMARY KEY,
···62CREATE TABLE IF NOT EXISTS repos (
63 user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
64 repo_root_cid TEXT NOT NULL,
065 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
66 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
67);
···79 rkey TEXT NOT NULL,
80 record_cid TEXT NOT NULL,
81 takedown_ref TEXT,
082 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
83 UNIQUE(repo_id, collection, rkey)
84);
008586CREATE TABLE IF NOT EXISTS blobs (
87 cid TEXT PRIMARY KEY,
···265);
266267CREATE INDEX idx_oauth_dpop_jti_created_at ON oauth_dpop_jti(created_at);
0000000000000000000000000000000000000
···6 'password_reset',
7 'email_update',
8 'account_deletion',
9+ 'admin_email',
10+ 'plc_operation',
11+ 'two_factor_code'
12);
1314CREATE TABLE IF NOT EXISTS users (
15 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
16 handle TEXT NOT NULL UNIQUE,
17+ email TEXT UNIQUE,
18 did TEXT NOT NULL UNIQUE,
19 password_hash TEXT NOT NULL,
20 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
···3132 email_pending_verification TEXT,
33 email_confirmation_code TEXT,
34+ email_confirmation_code_expires_at TIMESTAMPTZ,
35+ email_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
36+37+ two_factor_enabled BOOLEAN NOT NULL DEFAULT FALSE,
38+39+ discord_id TEXT,
40+ discord_verified BOOLEAN NOT NULL DEFAULT FALSE,
41+42+ telegram_username TEXT,
43+ telegram_verified BOOLEAN NOT NULL DEFAULT FALSE,
44+45+ signal_number TEXT,
46+ signal_verified BOOLEAN NOT NULL DEFAULT FALSE
47);
4849CREATE INDEX IF NOT EXISTS idx_users_password_reset_code ON users(password_reset_code) WHERE password_reset_code IS NOT NULL;
50CREATE INDEX IF NOT EXISTS idx_users_email_confirmation_code ON users(email_confirmation_code) WHERE email_confirmation_code IS NOT NULL;
51+CREATE INDEX IF NOT EXISTS idx_users_discord_id ON users(discord_id) WHERE discord_id IS NOT NULL;
52+CREATE INDEX IF NOT EXISTS idx_users_telegram_username ON users(telegram_username) WHERE telegram_username IS NOT NULL;
53+CREATE INDEX IF NOT EXISTS idx_users_signal_number ON users(signal_number) WHERE signal_number IS NOT NULL;
5455CREATE TABLE IF NOT EXISTS invite_codes (
56 code TEXT PRIMARY KEY,
···79CREATE TABLE IF NOT EXISTS repos (
80 user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
81 repo_root_cid TEXT NOT NULL,
82+ repo_rev TEXT,
83 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
84 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
85);
···97 rkey TEXT NOT NULL,
98 record_cid TEXT NOT NULL,
99 takedown_ref TEXT,
100+ repo_rev TEXT,
101 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
102 UNIQUE(repo_id, collection, rkey)
103);
104+105+CREATE INDEX idx_records_repo_rev ON records(repo_rev);
106107CREATE TABLE IF NOT EXISTS blobs (
108 cid TEXT PRIMARY KEY,
···286);
287288CREATE INDEX idx_oauth_dpop_jti_created_at ON oauth_dpop_jti(created_at);
289+290+CREATE TABLE plc_operation_tokens (
291+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
292+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
293+ token TEXT NOT NULL UNIQUE,
294+ expires_at TIMESTAMPTZ NOT NULL,
295+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
296+);
297+298+CREATE INDEX idx_plc_op_tokens_user ON plc_operation_tokens(user_id);
299+CREATE INDEX idx_plc_op_tokens_expires ON plc_operation_tokens(expires_at);
300+301+CREATE TABLE IF NOT EXISTS account_preferences (
302+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
303+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
304+ name TEXT NOT NULL,
305+ value_json JSONB NOT NULL,
306+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
307+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
308+ UNIQUE(user_id, name)
309+);
310+311+CREATE INDEX IF NOT EXISTS idx_account_preferences_user_id ON account_preferences(user_id);
312+CREATE INDEX IF NOT EXISTS idx_account_preferences_name ON account_preferences(name);
313+314+CREATE TABLE oauth_2fa_challenge (
315+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
316+ did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
317+ request_uri TEXT NOT NULL,
318+ code TEXT NOT NULL,
319+ attempts INTEGER NOT NULL DEFAULT 0,
320+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
321+ expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '10 minutes'
322+);
323+324+CREATE INDEX idx_oauth_2fa_challenge_request_uri ON oauth_2fa_challenge(request_uri);
325+CREATE INDEX idx_oauth_2fa_challenge_expires ON oauth_2fa_challenge(expires_at);
-10
migrations/202512211406_plc_operation_tokens.sql
···1-CREATE TABLE plc_operation_tokens (
2- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
3- user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
4- token TEXT NOT NULL UNIQUE,
5- expires_at TIMESTAMPTZ NOT NULL,
6- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
7-);
8-9-CREATE INDEX idx_plc_op_tokens_user ON plc_operation_tokens(user_id);
10-CREATE INDEX idx_plc_op_tokens_expires ON plc_operation_tokens(expires_at);
···1-ALTER TYPE notification_type ADD VALUE 'plc_operation';
···0
-12
migrations/202512211500_account_preferences.sql
···1-CREATE TABLE IF NOT EXISTS account_preferences (
2- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
3- user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
4- name TEXT NOT NULL,
5- value_json JSONB NOT NULL,
6- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
7- updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
8- UNIQUE(user_id, name)
9-);
10-11-CREATE INDEX IF NOT EXISTS idx_account_preferences_user_id ON account_preferences(user_id);
12-CREATE INDEX IF NOT EXISTS idx_account_preferences_name ON account_preferences(name);
···000000000000
-2
migrations/202512211600_add_repo_rev.sql
···1-ALTER TABLE records ADD COLUMN repo_rev TEXT;
2-CREATE INDEX idx_records_repo_rev ON records(repo_rev);
···00
-16
migrations/202512211700_add_2fa.sql
···1-ALTER TABLE users ADD COLUMN two_factor_enabled BOOLEAN NOT NULL DEFAULT FALSE;
2-3-ALTER TYPE notification_type ADD VALUE 'two_factor_code';
4-5-CREATE TABLE oauth_2fa_challenge (
6- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
7- did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
8- request_uri TEXT NOT NULL,
9- code TEXT NOT NULL,
10- attempts INTEGER NOT NULL DEFAULT 0,
11- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
12- expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '10 minutes'
13-);
14-15-CREATE INDEX idx_oauth_2fa_challenge_request_uri ON oauth_2fa_challenge(request_uri);
16-CREATE INDEX idx_oauth_2fa_challenge_expires ON oauth_2fa_challenge(expires_at);
···0000000000000000
+13-1
src/api/admin/account/email.rs
···65 .await;
6667 let (user_id, email, handle) = match user {
68- Ok(Some(row)) => (row.id, row.email, row.handle),
00000000000069 Ok(None) => {
70 return (
71 StatusCode::NOT_FOUND,
···65 .await;
6667 let (user_id, email, handle) = match user {
68+ Ok(Some(row)) => {
69+ let email = match row.email {
70+ Some(e) => e,
71+ None => {
72+ return (
73+ StatusCode::BAD_REQUEST,
74+ Json(json!({"error": "NoEmail", "message": "Recipient has no email address"})),
75+ )
76+ .into_response();
77+ }
78+ };
79+ (row.id, email, row.handle)
80+ }
81 Ok(None) => {
82 return (
83 StatusCode::NOT_FOUND,
···5pub mod identity;
6pub mod moderation;
7pub mod notification;
08pub mod proxy;
9pub mod proxy_client;
10pub mod read_after_write;
···5pub mod identity;
6pub mod moderation;
7pub mod notification;
8+pub mod notification_prefs;
9pub mod proxy;
10pub mod proxy_client;
11pub mod read_after_write;
···18pub use meta::{describe_server, health, robots_txt};
19pub use password::{request_password_reset, reset_password};
20pub use service_auth::get_service_auth;
21-pub use session::{create_session, delete_session, get_session, refresh_session};
22pub use signing_key::reserve_signing_key;
···18pub use meta::{describe_server, health, robots_txt};
19pub use password::{request_password_reset, reset_password};
20pub use service_auth::get_service_auth;
21+pub use session::{confirm_signup, create_session, delete_session, get_session, refresh_session, resend_verification};
22pub use signing_key::reserve_signing_key;
+252-1
src/api/server/session.rs
···8 response::{IntoResponse, Response},
9};
10use bcrypt::verify;
011use serde::{Deserialize, Serialize};
12use serde_json::json;
13use tracing::{error, info, warn};
···64 }
6566 let row = match sqlx::query!(
67- "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",
00000068 input.identifier
69 )
70 .fetch_optional(&state.db)
···101 if !password_valid {
102 warn!("Password verification failed for login attempt");
103 return ApiError::AuthenticationFailedMsg("Invalid identifier or password".into()).into_response();
00000000000000000104 }
105106 let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) {
···361 }
362 }
363}
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
···8 response::{IntoResponse, Response},
9};
10use bcrypt::verify;
11+use chrono::Utc;
12use serde::{Deserialize, Serialize};
13use serde_json::json;
14use tracing::{error, info, warn};
···65 }
6667 let row = match sqlx::query!(
68+ r#"SELECT
69+ u.id, u.did, u.handle, u.password_hash,
70+ u.email_confirmed, u.discord_verified, u.telegram_verified, u.signal_verified,
71+ k.key_bytes, k.encryption_version
72+ FROM users u
73+ JOIN user_keys k ON u.id = k.user_id
74+ WHERE u.handle = $1 OR u.email = $1"#,
75 input.identifier
76 )
77 .fetch_optional(&state.db)
···108 if !password_valid {
109 warn!("Password verification failed for login attempt");
110 return ApiError::AuthenticationFailedMsg("Invalid identifier or password".into()).into_response();
111+ }
112+113+ let is_verified = row.email_confirmed
114+ || row.discord_verified
115+ || row.telegram_verified
116+ || row.signal_verified;
117+118+ if !is_verified {
119+ warn!("Login attempt for unverified account: {}", row.did);
120+ return (
121+ StatusCode::FORBIDDEN,
122+ Json(json!({
123+ "error": "AccountNotVerified",
124+ "message": "Please verify your account before logging in",
125+ "did": row.did
126+ })),
127+ ).into_response();
128 }
129130 let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) {
···385 }
386 }
387}
388+389+#[derive(Deserialize)]
390+#[serde(rename_all = "camelCase")]
391+pub struct ConfirmSignupInput {
392+ pub did: String,
393+ pub verification_code: String,
394+}
395+396+#[derive(Serialize)]
397+#[serde(rename_all = "camelCase")]
398+pub struct ConfirmSignupOutput {
399+ pub access_jwt: String,
400+ pub refresh_jwt: String,
401+ pub handle: String,
402+ pub did: String,
403+}
404+405+pub async fn confirm_signup(
406+ State(state): State<AppState>,
407+ Json(input): Json<ConfirmSignupInput>,
408+) -> Response {
409+ info!("confirm_signup called for DID: {}", input.did);
410+411+ let row = match sqlx::query!(
412+ r#"SELECT
413+ u.id, u.did, u.handle,
414+ u.email_confirmation_code,
415+ u.email_confirmation_code_expires_at,
416+ u.preferred_notification_channel as "channel: crate::notifications::NotificationChannel",
417+ k.key_bytes, k.encryption_version
418+ FROM users u
419+ JOIN user_keys k ON u.id = k.user_id
420+ WHERE u.did = $1"#,
421+ input.did
422+ )
423+ .fetch_optional(&state.db)
424+ .await
425+ {
426+ Ok(Some(row)) => row,
427+ Ok(None) => {
428+ warn!("User not found for confirm_signup: {}", input.did);
429+ return ApiError::InvalidRequest("Invalid DID or verification code".into()).into_response();
430+ }
431+ Err(e) => {
432+ error!("Database error in confirm_signup: {:?}", e);
433+ return ApiError::InternalError.into_response();
434+ }
435+ };
436+437+ let stored_code = match &row.email_confirmation_code {
438+ Some(code) => code,
439+ None => {
440+ warn!("No verification code found for user: {}", input.did);
441+ return ApiError::InvalidRequest("No pending verification".into()).into_response();
442+ }
443+ };
444+445+ if stored_code != &input.verification_code {
446+ warn!("Invalid verification code for user: {}", input.did);
447+ return ApiError::InvalidRequest("Invalid verification code".into()).into_response();
448+ }
449+450+ if let Some(expires_at) = row.email_confirmation_code_expires_at {
451+ if expires_at < Utc::now() {
452+ warn!("Verification code expired for user: {}", input.did);
453+ return ApiError::ExpiredTokenMsg("Verification code has expired".into()).into_response();
454+ }
455+ }
456+457+ let key_bytes = match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
458+ Ok(k) => k,
459+ Err(e) => {
460+ error!("Failed to decrypt user key: {:?}", e);
461+ return ApiError::InternalError.into_response();
462+ }
463+ };
464+465+ let verified_column = match row.channel {
466+ crate::notifications::NotificationChannel::Email => "email_confirmed",
467+ crate::notifications::NotificationChannel::Discord => "discord_verified",
468+ crate::notifications::NotificationChannel::Telegram => "telegram_verified",
469+ crate::notifications::NotificationChannel::Signal => "signal_verified",
470+ };
471+472+ let update_query = format!(
473+ "UPDATE users SET {} = TRUE, email_confirmation_code = NULL, email_confirmation_code_expires_at = NULL WHERE did = $1",
474+ verified_column
475+ );
476+477+ if let Err(e) = sqlx::query(&update_query)
478+ .bind(&input.did)
479+ .execute(&state.db)
480+ .await
481+ {
482+ error!("Failed to update verification status: {:?}", e);
483+ return ApiError::InternalError.into_response();
484+ }
485+486+ let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) {
487+ Ok(m) => m,
488+ Err(e) => {
489+ error!("Failed to create access token: {:?}", e);
490+ return ApiError::InternalError.into_response();
491+ }
492+ };
493+494+ let refresh_meta = match crate::auth::create_refresh_token_with_metadata(&row.did, &key_bytes) {
495+ Ok(m) => m,
496+ Err(e) => {
497+ error!("Failed to create refresh token: {:?}", e);
498+ return ApiError::InternalError.into_response();
499+ }
500+ };
501+502+ if let Err(e) = sqlx::query!(
503+ "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)",
504+ row.did,
505+ access_meta.jti,
506+ refresh_meta.jti,
507+ access_meta.expires_at,
508+ refresh_meta.expires_at
509+ )
510+ .execute(&state.db)
511+ .await
512+ {
513+ error!("Failed to insert session: {:?}", e);
514+ return ApiError::InternalError.into_response();
515+ }
516+517+ let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
518+ if let Err(e) = crate::notifications::enqueue_welcome(&state.db, row.id, &hostname).await {
519+ warn!("Failed to enqueue welcome notification: {:?}", e);
520+ }
521+522+ Json(ConfirmSignupOutput {
523+ access_jwt: access_meta.token,
524+ refresh_jwt: refresh_meta.token,
525+ handle: row.handle,
526+ did: row.did,
527+ }).into_response()
528+}
529+530+#[derive(Deserialize)]
531+#[serde(rename_all = "camelCase")]
532+pub struct ResendVerificationInput {
533+ pub did: String,
534+}
535+536+pub async fn resend_verification(
537+ State(state): State<AppState>,
538+ Json(input): Json<ResendVerificationInput>,
539+) -> Response {
540+ info!("resend_verification called for DID: {}", input.did);
541+542+ let row = match sqlx::query!(
543+ r#"SELECT
544+ id, handle, email,
545+ preferred_notification_channel as "channel: crate::notifications::NotificationChannel",
546+ discord_id, telegram_username, signal_number,
547+ email_confirmed, discord_verified, telegram_verified, signal_verified
548+ FROM users
549+ WHERE did = $1"#,
550+ input.did
551+ )
552+ .fetch_optional(&state.db)
553+ .await
554+ {
555+ Ok(Some(row)) => row,
556+ Ok(None) => {
557+ return ApiError::InvalidRequest("User not found".into()).into_response();
558+ }
559+ Err(e) => {
560+ error!("Database error in resend_verification: {:?}", e);
561+ return ApiError::InternalError.into_response();
562+ }
563+ };
564+565+ let is_verified = row.email_confirmed
566+ || row.discord_verified
567+ || row.telegram_verified
568+ || row.signal_verified;
569+570+ if is_verified {
571+ return ApiError::InvalidRequest("Account is already verified".into()).into_response();
572+ }
573+574+ let verification_code = format!("{:06}", rand::random::<u32>() % 1_000_000);
575+ let code_expires_at = Utc::now() + chrono::Duration::minutes(30);
576+577+ if let Err(e) = sqlx::query!(
578+ "UPDATE users SET email_confirmation_code = $1, email_confirmation_code_expires_at = $2 WHERE did = $3",
579+ verification_code,
580+ code_expires_at,
581+ input.did
582+ )
583+ .execute(&state.db)
584+ .await
585+ {
586+ error!("Failed to update verification code: {:?}", e);
587+ return ApiError::InternalError.into_response();
588+ }
589+590+ let (channel_str, recipient) = match row.channel {
591+ crate::notifications::NotificationChannel::Email => ("email", row.email.clone().unwrap_or_default()),
592+ crate::notifications::NotificationChannel::Discord => {
593+ ("discord", row.discord_id.unwrap_or_default())
594+ }
595+ crate::notifications::NotificationChannel::Telegram => {
596+ ("telegram", row.telegram_username.unwrap_or_default())
597+ }
598+ crate::notifications::NotificationChannel::Signal => {
599+ ("signal", row.signal_number.unwrap_or_default())
600+ }
601+ };
602+603+ if let Err(e) = crate::notifications::enqueue_signup_verification(
604+ &state.db,
605+ row.id,
606+ channel_str,
607+ &recipient,
608+ &verification_code,
609+ ).await {
610+ warn!("Failed to enqueue verification notification: {:?}", e);
611+ }
612+613+ Json(json!({"success": true})).into_response()
614+}
+5-1
src/crawlers.rs
···106 cb.record_success().await;
107 }
108 } else {
00109 warn!(
110 crawler = %url,
111- status = %response.status(),
00112 "Crawler notification returned non-success status"
113 );
114 if let Some(cb) = cb {
···106 cb.record_success().await;
107 }
108 } else {
109+ let status = response.status();
110+ let body = response.text().await.unwrap_or_default();
111 warn!(
112 crawler = %url,
113+ status = %status,
114+ body = %body,
115+ hostname = %hostname,
116 "Crawler notification returned non-success status"
117 );
118 if let Some(cb) = cb {