this repo has no description

More email-related endpoints

+52
.sqlx/query-3377750b73c3831cbd6c96b971ea8b6d4da38f1bc740afce3136d86c27b8ce8d.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT s.did, k.key_bytes, u.id as user_id, u.email_confirmation_code, u.email_confirmation_code_expires_at, u.email_pending_verification\n FROM sessions s\n JOIN users u ON s.did = u.did\n JOIN user_keys k ON u.id = k.user_id\n WHERE s.access_jwt = $1\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "did", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "key_bytes", 14 + "type_info": "Bytea" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "user_id", 19 + "type_info": "Uuid" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "email_confirmation_code", 24 + "type_info": "Text" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "email_confirmation_code_expires_at", 29 + "type_info": "Timestamptz" 30 + }, 31 + { 32 + "ordinal": 5, 33 + "name": "email_pending_verification", 34 + "type_info": "Text" 35 + } 36 + ], 37 + "parameters": { 38 + "Left": [ 39 + "Text" 40 + ] 41 + }, 42 + "nullable": [ 43 + false, 44 + false, 45 + false, 46 + true, 47 + true, 48 + true 49 + ] 50 + }, 51 + "hash": "3377750b73c3831cbd6c96b971ea8b6d4da38f1bc740afce3136d86c27b8ce8d" 52 + }
+17
.sqlx/query-4b9243c9ef4bf260d179a778536e815c8d563017ecda7dc530aeeebd5362d190.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET email_pending_verification = $1, email_confirmation_code = $2, email_confirmation_code_expires_at = $3 WHERE id = $4", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text", 10 + "Timestamptz", 11 + "Uuid" 12 + ] 13 + }, 14 + "nullable": [] 15 + }, 16 + "hash": "4b9243c9ef4bf260d179a778536e815c8d563017ecda7dc530aeeebd5362d190" 17 + }
+40
.sqlx/query-6a233f0ca94195935bf32ee749c8429c2292bb3907f129e06aff033a31681175.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT s.did, k.key_bytes, u.id as user_id, u.handle\n FROM sessions s\n JOIN users u ON s.did = u.did\n JOIN user_keys k ON u.id = k.user_id\n WHERE s.access_jwt = $1\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "did", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "key_bytes", 14 + "type_info": "Bytea" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "user_id", 19 + "type_info": "Uuid" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "handle", 24 + "type_info": "Text" 25 + } 26 + ], 27 + "parameters": { 28 + "Left": [ 29 + "Text" 30 + ] 31 + }, 32 + "nullable": [ 33 + false, 34 + false, 35 + false, 36 + false 37 + ] 38 + }, 39 + "hash": "6a233f0ca94195935bf32ee749c8429c2292bb3907f129e06aff033a31681175" 40 + }
+15
.sqlx/query-76a239da5103f43b16a768d6970cc7e04d9d27c88cc54072818033a03bf53057.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET email = $1, email_pending_verification = NULL, email_confirmation_code = NULL, email_confirmation_code_expires_at = NULL WHERE id = $2", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Uuid" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "76a239da5103f43b16a768d6970cc7e04d9d27c88cc54072818033a03bf53057" 15 + }
+22
.sqlx/query-8c9c899187a8b19747b1c25dbac1501de14985beafcfed5f0a23549e18da2c19.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT 1 as one FROM users WHERE LOWER(email) = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "one", 9 + "type_info": "Int4" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + null 19 + ] 20 + }, 21 + "hash": "8c9c899187a8b19747b1c25dbac1501de14985beafcfed5f0a23549e18da2c19" 22 + }
+2 -2
TODO.md
··· 35 - [x] Implement `com.atproto.server.getServiceAuth` (Cross-service auth). 36 - [x] Implement `com.atproto.server.listAppPasswords`. 37 - [x] Implement `com.atproto.server.requestAccountDelete`. 38 - - [ ] Implement `com.atproto.server.requestEmailConfirmation` / `requestEmailUpdate`. 39 - [x] Implement `com.atproto.server.requestPasswordReset` / `resetPassword`. 40 - [ ] Implement `com.atproto.server.reserveSigningKey`. 41 - [x] Implement `com.atproto.server.revokeAppPassword`. 42 - [ ] Implement `com.atproto.server.updateEmail`. 43 - - [ ] Implement `com.atproto.server.confirmEmail`. 44 45 ## Repository Operations (`com.atproto.repo`) 46 - [ ] Record CRUD
··· 35 - [x] Implement `com.atproto.server.getServiceAuth` (Cross-service auth). 36 - [x] Implement `com.atproto.server.listAppPasswords`. 37 - [x] Implement `com.atproto.server.requestAccountDelete`. 38 + - [x] Implement `com.atproto.server.requestEmailConfirmation` / `requestEmailUpdate`. 39 - [x] Implement `com.atproto.server.requestPasswordReset` / `resetPassword`. 40 - [ ] Implement `com.atproto.server.reserveSigningKey`. 41 - [x] Implement `com.atproto.server.revokeAppPassword`. 42 - [ ] Implement `com.atproto.server.updateEmail`. 43 + - [x] Implement `com.atproto.server.confirmEmail`. 44 45 ## Repository Operations (`com.atproto.repo`) 46 - [ ] Record CRUD
+157
migrations/202512211400_initial_schema.sql
···
··· 1 + CREATE TYPE notification_channel AS ENUM ('email', 'discord', 'telegram', 'signal'); 2 + CREATE TYPE notification_status AS ENUM ('pending', 'processing', 'sent', 'failed'); 3 + CREATE TYPE notification_type AS ENUM ( 4 + 'welcome', 5 + 'email_verification', 6 + 'password_reset', 7 + 'email_update', 8 + 'account_deletion', 9 + 'admin_email' 10 + ); 11 + 12 + CREATE 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(), 19 + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 20 + 21 + -- status & moderation 22 + deactivated_at TIMESTAMPTZ, 23 + invites_disabled BOOLEAN DEFAULT FALSE, 24 + takedown_ref TEXT, 25 + 26 + -- notifs 27 + preferred_notification_channel notification_channel NOT NULL DEFAULT 'email', 28 + 29 + -- auth & verification 30 + password_reset_code TEXT, 31 + password_reset_code_expires_at TIMESTAMPTZ, 32 + 33 + email_pending_verification TEXT, 34 + email_confirmation_code TEXT, 35 + email_confirmation_code_expires_at TIMESTAMPTZ 36 + ); 37 + 38 + CREATE INDEX IF NOT EXISTS idx_users_password_reset_code ON users(password_reset_code) WHERE password_reset_code IS NOT NULL; 39 + CREATE INDEX IF NOT EXISTS idx_users_email_confirmation_code ON users(email_confirmation_code) WHERE email_confirmation_code IS NOT NULL; 40 + 41 + CREATE TABLE IF NOT EXISTS invite_codes ( 42 + code TEXT PRIMARY KEY, 43 + available_uses INT NOT NULL DEFAULT 1, 44 + created_by_user UUID NOT NULL REFERENCES users(id), 45 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 46 + disabled BOOLEAN DEFAULT FALSE 47 + ); 48 + 49 + CREATE TABLE IF NOT EXISTS invite_code_uses ( 50 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 51 + code TEXT NOT NULL REFERENCES invite_codes(code), 52 + used_by_user UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, 53 + used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 54 + UNIQUE(code, used_by_user) 55 + ); 56 + 57 + -- TODO: encrypt at rest! 58 + CREATE TABLE IF NOT EXISTS user_keys ( 59 + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, 60 + key_bytes BYTEA NOT NULL, 61 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 62 + ); 63 + 64 + CREATE TABLE IF NOT EXISTS repos ( 65 + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, 66 + repo_root_cid TEXT NOT NULL, 67 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 68 + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 69 + ); 70 + 71 + -- content addressable storage 72 + CREATE TABLE IF NOT EXISTS blocks ( 73 + cid BYTEA PRIMARY KEY, 74 + data BYTEA NOT NULL, 75 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 76 + ); 77 + 78 + -- denormalized index for fast queries 79 + CREATE TABLE IF NOT EXISTS records ( 80 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 81 + repo_id UUID NOT NULL REFERENCES repos(user_id) ON DELETE CASCADE, 82 + collection TEXT NOT NULL, 83 + rkey TEXT NOT NULL, 84 + record_cid TEXT NOT NULL, 85 + takedown_ref TEXT, 86 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 87 + UNIQUE(repo_id, collection, rkey) 88 + ); 89 + 90 + CREATE TABLE IF NOT EXISTS blobs ( 91 + cid TEXT PRIMARY KEY, 92 + mime_type TEXT NOT NULL, 93 + size_bytes BIGINT NOT NULL, 94 + created_by_user UUID NOT NULL REFERENCES users(id), 95 + storage_key TEXT NOT NULL, 96 + takedown_ref TEXT, 97 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 98 + ); 99 + 100 + CREATE TABLE IF NOT EXISTS sessions ( 101 + access_jwt TEXT PRIMARY KEY, 102 + refresh_jwt TEXT NOT NULL UNIQUE, 103 + did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, 104 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 105 + ); 106 + 107 + CREATE TABLE IF NOT EXISTS app_passwords ( 108 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 109 + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, 110 + name TEXT NOT NULL, 111 + password_hash TEXT NOT NULL, 112 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 113 + privileged BOOLEAN NOT NULL DEFAULT FALSE, 114 + UNIQUE(user_id, name) 115 + ); 116 + 117 + -- naughty list 118 + CREATE TABLE reports ( 119 + id BIGINT PRIMARY KEY, 120 + reason_type TEXT NOT NULL, 121 + reason TEXT, 122 + subject_json JSONB NOT NULL, 123 + reported_by_did TEXT NOT NULL, 124 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 125 + ); 126 + 127 + CREATE TABLE IF NOT EXISTS account_deletion_requests ( 128 + token TEXT PRIMARY KEY, 129 + did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, 130 + expires_at TIMESTAMPTZ NOT NULL, 131 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 132 + ); 133 + 134 + CREATE TABLE IF NOT EXISTS notification_queue ( 135 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 136 + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, 137 + channel notification_channel NOT NULL DEFAULT 'email', 138 + notification_type notification_type NOT NULL, 139 + status notification_status NOT NULL DEFAULT 'pending', 140 + recipient TEXT NOT NULL, 141 + subject TEXT, 142 + body TEXT NOT NULL, 143 + metadata JSONB, 144 + attempts INT NOT NULL DEFAULT 0, 145 + max_attempts INT NOT NULL DEFAULT 3, 146 + last_error TEXT, 147 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 148 + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 149 + scheduled_for TIMESTAMPTZ NOT NULL DEFAULT NOW(), 150 + processed_at TIMESTAMPTZ 151 + ); 152 + 153 + CREATE INDEX idx_notification_queue_status_scheduled 154 + ON notification_queue(status, scheduled_for) 155 + WHERE status = 'pending'; 156 + 157 + CREATE INDEX idx_notification_queue_user_id ON notification_queue(user_id);
-80
migrations/202512211400_initial_tables.sql
··· 1 - -- A very basic schema to get started. 2 - -- TODO: PRODUCTIONIZE BABY 3 - 4 - CREATE TABLE IF NOT EXISTS users ( 5 - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 6 - handle TEXT NOT NULL UNIQUE, 7 - email TEXT NOT NULL UNIQUE, 8 - did TEXT NOT NULL UNIQUE, 9 - password_hash TEXT NOT NULL, 10 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 11 - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 12 - ); 13 - 14 - CREATE TABLE IF NOT EXISTS invite_codes ( 15 - code TEXT PRIMARY KEY, 16 - available_uses INT NOT NULL DEFAULT 1, 17 - created_by_user UUID NOT NULL REFERENCES users(id), 18 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 19 - ); 20 - 21 - CREATE TABLE IF NOT EXISTS invite_code_uses ( 22 - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 23 - code TEXT NOT NULL REFERENCES invite_codes(code), 24 - used_by_user UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, 25 - used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 26 - UNIQUE(code, used_by_user) 27 - ); 28 - 29 - -- OIII THIS TABLE CONTAINS PLAINTEXT PRIVATE KEYS, TODO: encrypt at rest! 30 - CREATE TABLE IF NOT EXISTS user_keys ( 31 - user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, 32 - -- Storing as raw bytes 33 - -- secp256k1 is 32 bytes 34 - key_bytes BYTEA NOT NULL, 35 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 36 - ); 37 - 38 - CREATE TABLE IF NOT EXISTS repos ( 39 - user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, 40 - repo_root_cid TEXT NOT NULL, 41 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 42 - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 43 - ); 44 - 45 - CREATE TABLE IF NOT EXISTS blocks ( 46 - cid BYTEA PRIMARY KEY, 47 - data BYTEA NOT NULL, 48 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 49 - ); 50 - 51 - -- A denormalized table to quickly query for records 52 - -- TODO: Do I actually need this? 53 - CREATE TABLE IF NOT EXISTS records ( 54 - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 55 - repo_id UUID NOT NULL REFERENCES repos(user_id) ON DELETE CASCADE, 56 - collection TEXT NOT NULL, 57 - rkey TEXT NOT NULL, 58 - record_cid TEXT NOT NULL, 59 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 60 - UNIQUE(repo_id, collection, rkey) 61 - ); 62 - 63 - CREATE TABLE IF NOT EXISTS blobs ( 64 - cid TEXT PRIMARY KEY, 65 - mime_type TEXT NOT NULL, 66 - size_bytes BIGINT NOT NULL, 67 - created_by_user UUID NOT NULL REFERENCES users(id), 68 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 69 - 70 - -- The key/path in the S3 bucket 71 - storage_key TEXT NOT NULL 72 - ); 73 - 74 - CREATE TABLE IF NOT EXISTS sessions ( 75 - access_jwt TEXT PRIMARY KEY, 76 - refresh_jwt TEXT NOT NULL UNIQUE, 77 - did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, 78 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 79 - ); 80 -
···
-9
migrations/202512211500_app_passwords.sql
··· 1 - CREATE TABLE IF NOT EXISTS app_passwords ( 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 - password_hash TEXT NOT NULL, 6 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 7 - privileged BOOLEAN NOT NULL DEFAULT FALSE, 8 - UNIQUE(user_id, name) 9 - );
···
-11
migrations/202512211600_moderation_and_status.sql
··· 1 - ALTER TABLE users ADD COLUMN deactivated_at TIMESTAMPTZ; 2 - 3 - -- * reports u * 4 - CREATE TABLE reports ( 5 - id BIGINT PRIMARY KEY, 6 - reason_type TEXT NOT NULL, 7 - reason TEXT, 8 - subject_json JSONB NOT NULL, 9 - reported_by_did TEXT NOT NULL, 10 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 11 - );
···
-3
migrations/202512211700_invite_enhancements.sql
··· 1 - ALTER TABLE invite_codes ADD COLUMN disabled BOOLEAN DEFAULT FALSE; 2 - 3 - ALTER TABLE users ADD COLUMN invites_disabled BOOLEAN DEFAULT FALSE;
···
-5
migrations/202512211800_takedown_refs.sql
··· 1 - ALTER TABLE users ADD COLUMN takedown_ref TEXT; 2 - 3 - ALTER TABLE records ADD COLUMN takedown_ref TEXT; 4 - 5 - ALTER TABLE blobs ADD COLUMN takedown_ref TEXT;
···
-6
migrations/202512211900_account_deletion_tokens.sql
··· 1 - CREATE TABLE IF NOT EXISTS account_deletion_requests ( 2 - token TEXT PRIMARY KEY, 3 - did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, 4 - expires_at TIMESTAMPTZ NOT NULL, 5 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 6 - );
···
-36
migrations/202512212000_notification_queue.sql
··· 1 - CREATE TYPE notification_channel AS ENUM ('email', 'discord', 'telegram', 'signal'); 2 - CREATE TYPE notification_status AS ENUM ('pending', 'processing', 'sent', 'failed'); 3 - CREATE TYPE notification_type AS ENUM ( 4 - 'welcome', 5 - 'email_verification', 6 - 'password_reset', 7 - 'email_update', 8 - 'account_deletion' 9 - ); 10 - 11 - CREATE TABLE IF NOT EXISTS notification_queue ( 12 - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 13 - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, 14 - channel notification_channel NOT NULL DEFAULT 'email', 15 - notification_type notification_type NOT NULL, 16 - status notification_status NOT NULL DEFAULT 'pending', 17 - recipient TEXT NOT NULL, 18 - subject TEXT, 19 - body TEXT NOT NULL, 20 - metadata JSONB, 21 - attempts INT NOT NULL DEFAULT 0, 22 - max_attempts INT NOT NULL DEFAULT 3, 23 - last_error TEXT, 24 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 25 - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 26 - scheduled_for TIMESTAMPTZ NOT NULL DEFAULT NOW(), 27 - processed_at TIMESTAMPTZ 28 - ); 29 - 30 - CREATE INDEX idx_notification_queue_status_scheduled 31 - ON notification_queue(status, scheduled_for) 32 - WHERE status = 'pending'; 33 - 34 - CREATE INDEX idx_notification_queue_user_id ON notification_queue(user_id); 35 - 36 - ALTER TABLE users ADD COLUMN IF NOT EXISTS preferred_notification_channel notification_channel NOT NULL DEFAULT 'email';
···
-4
migrations/202512212100_password_reset.sql
··· 1 - ALTER TABLE users ADD COLUMN IF NOT EXISTS password_reset_code TEXT; 2 - ALTER TABLE users ADD COLUMN IF NOT EXISTS password_reset_code_expires_at TIMESTAMPTZ; 3 - 4 - CREATE INDEX IF NOT EXISTS idx_users_password_reset_code ON users(password_reset_code) WHERE password_reset_code IS NOT NULL;
···
-1
migrations/202512212200_admin_email_type.sql
··· 1 - ALTER TYPE notification_type ADD VALUE IF NOT EXISTS 'admin_email';
···
+3 -3
src/api/server/mod.rs
··· 5 pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes}; 6 pub use meta::{describe_server, health}; 7 pub use session::{ 8 - activate_account, check_account_status, create_app_password, create_session, 9 deactivate_account, delete_session, get_service_auth, get_session, list_app_passwords, 10 - refresh_session, request_account_delete, request_password_reset, reset_password, 11 - revoke_app_password, 12 };
··· 5 pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes}; 6 pub use meta::{describe_server, health}; 7 pub use session::{ 8 + activate_account, check_account_status, confirm_email, create_app_password, create_session, 9 deactivate_account, delete_session, get_service_auth, get_session, list_app_passwords, 10 + refresh_session, request_account_delete, request_email_update, request_password_reset, 11 + reset_password, revoke_app_password, 12 };
+268
src/api/server/session.rs
··· 1419 1420 (StatusCode::OK, Json(json!({}))).into_response() 1421 }
··· 1419 1420 (StatusCode::OK, Json(json!({}))).into_response() 1421 } 1422 + 1423 + #[derive(Deserialize)] 1424 + #[serde(rename_all = "camelCase")] 1425 + pub struct RequestEmailUpdateInput { 1426 + pub email: String, 1427 + } 1428 + 1429 + pub async fn request_email_update( 1430 + State(state): State<AppState>, 1431 + headers: axum::http::HeaderMap, 1432 + Json(input): Json<RequestEmailUpdateInput>, 1433 + ) -> Response { 1434 + let auth_header = headers.get("Authorization"); 1435 + if auth_header.is_none() { 1436 + return ( 1437 + StatusCode::UNAUTHORIZED, 1438 + Json(json!({"error": "AuthenticationRequired"})), 1439 + ) 1440 + .into_response(); 1441 + } 1442 + 1443 + let token = auth_header 1444 + .unwrap() 1445 + .to_str() 1446 + .unwrap_or("") 1447 + .replace("Bearer ", ""); 1448 + 1449 + let session = sqlx::query!( 1450 + r#" 1451 + SELECT s.did, k.key_bytes, u.id as user_id, u.handle 1452 + FROM sessions s 1453 + JOIN users u ON s.did = u.did 1454 + JOIN user_keys k ON u.id = k.user_id 1455 + WHERE s.access_jwt = $1 1456 + "#, 1457 + token 1458 + ) 1459 + .fetch_optional(&state.db) 1460 + .await; 1461 + 1462 + let (_did, key_bytes, user_id, handle) = match session { 1463 + Ok(Some(row)) => (row.did, row.key_bytes, row.user_id, row.handle), 1464 + Ok(None) => { 1465 + return ( 1466 + StatusCode::UNAUTHORIZED, 1467 + Json(json!({"error": "AuthenticationFailed"})), 1468 + ) 1469 + .into_response(); 1470 + } 1471 + Err(e) => { 1472 + error!("DB error in request_email_update: {:?}", e); 1473 + return ( 1474 + StatusCode::INTERNAL_SERVER_ERROR, 1475 + Json(json!({"error": "InternalError"})), 1476 + ) 1477 + .into_response(); 1478 + } 1479 + }; 1480 + 1481 + if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { 1482 + return ( 1483 + StatusCode::UNAUTHORIZED, 1484 + Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), 1485 + ) 1486 + .into_response(); 1487 + } 1488 + 1489 + let email = input.email.trim().to_lowercase(); 1490 + if email.is_empty() { 1491 + return ( 1492 + StatusCode::BAD_REQUEST, 1493 + Json(json!({"error": "InvalidRequest", "message": "email is required"})), 1494 + ) 1495 + .into_response(); 1496 + } 1497 + 1498 + let exists = sqlx::query!("SELECT 1 as one FROM users WHERE LOWER(email) = $1", email) 1499 + .fetch_optional(&state.db) 1500 + .await; 1501 + 1502 + if let Ok(Some(_)) = exists { 1503 + return ( 1504 + StatusCode::BAD_REQUEST, 1505 + Json(json!({"error": "EmailTaken", "message": "Email already taken"})), 1506 + ) 1507 + .into_response(); 1508 + } 1509 + 1510 + let code = generate_reset_code(); 1511 + let expires_at = Utc::now() + Duration::minutes(10); 1512 + 1513 + let update = sqlx::query!( 1514 + "UPDATE users SET email_pending_verification = $1, email_confirmation_code = $2, email_confirmation_code_expires_at = $3 WHERE id = $4", 1515 + email, 1516 + code, 1517 + expires_at, 1518 + user_id 1519 + ) 1520 + .execute(&state.db) 1521 + .await; 1522 + 1523 + if let Err(e) = update { 1524 + error!("DB error setting email update code: {:?}", e); 1525 + return ( 1526 + StatusCode::INTERNAL_SERVER_ERROR, 1527 + Json(json!({"error": "InternalError"})), 1528 + ) 1529 + .into_response(); 1530 + } 1531 + 1532 + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 1533 + if let Err(e) = crate::notifications::enqueue_email_update( 1534 + &state.db, 1535 + user_id, 1536 + &email, 1537 + &handle, 1538 + &code, 1539 + &hostname, 1540 + ) 1541 + .await 1542 + { 1543 + warn!("Failed to enqueue email update notification: {:?}", e); 1544 + } 1545 + 1546 + info!("Email update requested for user {}", user_id); 1547 + 1548 + (StatusCode::OK, Json(json!({ "tokenRequired": true }))).into_response() 1549 + } 1550 + 1551 + #[derive(Deserialize)] 1552 + #[serde(rename_all = "camelCase")] 1553 + pub struct ConfirmEmailInput { 1554 + pub email: String, 1555 + pub token: String, 1556 + } 1557 + 1558 + pub async fn confirm_email( 1559 + State(state): State<AppState>, 1560 + headers: axum::http::HeaderMap, 1561 + Json(input): Json<ConfirmEmailInput>, 1562 + ) -> Response { 1563 + let auth_header = headers.get("Authorization"); 1564 + if auth_header.is_none() { 1565 + return ( 1566 + StatusCode::UNAUTHORIZED, 1567 + Json(json!({"error": "AuthenticationRequired"})), 1568 + ) 1569 + .into_response(); 1570 + } 1571 + 1572 + let token = auth_header 1573 + .unwrap() 1574 + .to_str() 1575 + .unwrap_or("") 1576 + .replace("Bearer ", ""); 1577 + 1578 + let session = sqlx::query!( 1579 + r#" 1580 + SELECT s.did, k.key_bytes, u.id as user_id, u.email_confirmation_code, u.email_confirmation_code_expires_at, u.email_pending_verification 1581 + FROM sessions s 1582 + JOIN users u ON s.did = u.did 1583 + JOIN user_keys k ON u.id = k.user_id 1584 + WHERE s.access_jwt = $1 1585 + "#, 1586 + token 1587 + ) 1588 + .fetch_optional(&state.db) 1589 + .await; 1590 + 1591 + let (_did, key_bytes, user_id, stored_code, expires_at, email_pending_verification) = match session { 1592 + Ok(Some(row)) => ( 1593 + row.did, 1594 + row.key_bytes, 1595 + row.user_id, 1596 + row.email_confirmation_code, 1597 + row.email_confirmation_code_expires_at, 1598 + row.email_pending_verification, 1599 + ), 1600 + Ok(None) => { 1601 + return ( 1602 + StatusCode::UNAUTHORIZED, 1603 + Json(json!({"error": "AuthenticationFailed"})), 1604 + ) 1605 + .into_response(); 1606 + } 1607 + Err(e) => { 1608 + error!("DB error in confirm_email: {:?}", e); 1609 + return ( 1610 + StatusCode::INTERNAL_SERVER_ERROR, 1611 + Json(json!({"error": "InternalError"})), 1612 + ) 1613 + .into_response(); 1614 + } 1615 + }; 1616 + 1617 + if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { 1618 + return ( 1619 + StatusCode::UNAUTHORIZED, 1620 + Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), 1621 + ) 1622 + .into_response(); 1623 + } 1624 + 1625 + let email = input.email.trim().to_lowercase(); 1626 + let confirmation_code = input.token.trim(); 1627 + 1628 + if email_pending_verification.is_none() || stored_code.is_none() || expires_at.is_none() { 1629 + return ( 1630 + StatusCode::BAD_REQUEST, 1631 + Json(json!({"error": "InvalidRequest", "message": "No pending email update found"})), 1632 + ) 1633 + .into_response(); 1634 + } 1635 + 1636 + let email_pending_verification = email_pending_verification.unwrap(); 1637 + if email_pending_verification != email { 1638 + return ( 1639 + StatusCode::BAD_REQUEST, 1640 + Json(json!({"error": "InvalidRequest", "message": "Email does not match pending update"})), 1641 + ) 1642 + .into_response(); 1643 + } 1644 + 1645 + if stored_code.unwrap() != confirmation_code { 1646 + return ( 1647 + StatusCode::BAD_REQUEST, 1648 + Json(json!({"error": "InvalidToken", "message": "Invalid token"})), 1649 + ) 1650 + .into_response(); 1651 + } 1652 + 1653 + if Utc::now() > expires_at.unwrap() { 1654 + return ( 1655 + StatusCode::BAD_REQUEST, 1656 + Json(json!({"error": "ExpiredToken", "message": "Token has expired"})), 1657 + ) 1658 + .into_response(); 1659 + } 1660 + 1661 + let update = sqlx::query!( 1662 + "UPDATE users SET email = $1, email_pending_verification = NULL, email_confirmation_code = NULL, email_confirmation_code_expires_at = NULL WHERE id = $2", 1663 + email_pending_verification, 1664 + user_id 1665 + ) 1666 + .execute(&state.db) 1667 + .await; 1668 + 1669 + if let Err(e) = update { 1670 + error!("DB error finalizing email update: {:?}", e); 1671 + if e.as_database_error().map(|db_err| db_err.is_unique_violation()).unwrap_or(false) { 1672 + return ( 1673 + StatusCode::BAD_REQUEST, 1674 + Json(json!({"error": "EmailTaken", "message": "Email already taken"})), 1675 + ) 1676 + .into_response(); 1677 + } 1678 + 1679 + return ( 1680 + StatusCode::INTERNAL_SERVER_ERROR, 1681 + Json(json!({"error": "InternalError"})), 1682 + ) 1683 + .into_response(); 1684 + } 1685 + 1686 + info!("Email updated for user {}", user_id); 1687 + 1688 + (StatusCode::OK, Json(json!({}))).into_response() 1689 + }
+8
src/lib.rs
··· 164 post(api::server::reset_password), 165 ) 166 .route( 167 "/xrpc/com.atproto.identity.updateHandle", 168 post(api::identity::update_handle), 169 )
··· 164 post(api::server::reset_password), 165 ) 166 .route( 167 + "/xrpc/com.atproto.server.requestEmailUpdate", 168 + post(api::server::request_email_update), 169 + ) 170 + .route( 171 + "/xrpc/com.atproto.server.confirmEmail", 172 + post(api::server::confirm_email), 173 + ) 174 + .route( 175 "/xrpc/com.atproto.identity.updateHandle", 176 post(api::identity::update_handle), 177 )
+236
tests/email_update.rs
···
··· 1 + mod common; 2 + 3 + use reqwest::StatusCode; 4 + use serde_json::{json, Value}; 5 + use sqlx::PgPool; 6 + 7 + async fn get_pool() -> PgPool { 8 + let conn_str = common::get_db_connection_string().await; 9 + sqlx::postgres::PgPoolOptions::new() 10 + .max_connections(5) 11 + .connect(&conn_str) 12 + .await 13 + .expect("Failed to connect to test database") 14 + } 15 + 16 + #[tokio::test] 17 + async fn test_email_update_flow_success() { 18 + let client = common::client(); 19 + let base_url = common::base_url().await; 20 + let pool = get_pool().await; 21 + 22 + let handle = format!("emailup_{}", uuid::Uuid::new_v4()); 23 + let email = format!("{}@example.com", handle); 24 + let payload = json!({ 25 + "handle": handle, 26 + "email": email, 27 + "password": "password" 28 + }); 29 + 30 + let res = client 31 + .post(format!("{}/xrpc/com.atproto.server.createAccount", base_url)) 32 + .json(&payload) 33 + .send() 34 + .await 35 + .expect("Failed to create account"); 36 + assert_eq!(res.status(), StatusCode::OK); 37 + let body: Value = res.json().await.expect("Invalid JSON"); 38 + let access_jwt = body["accessJwt"].as_str().expect("No accessJwt"); 39 + 40 + let new_email = format!("new_{}@example.com", handle); 41 + let res = client 42 + .post(format!("{}/xrpc/com.atproto.server.requestEmailUpdate", base_url)) 43 + .bearer_auth(access_jwt) 44 + .json(&json!({"email": new_email})) 45 + .send() 46 + .await 47 + .expect("Failed to request email update"); 48 + assert_eq!(res.status(), StatusCode::OK); 49 + let body: Value = res.json().await.expect("Invalid JSON"); 50 + assert_eq!(body["tokenRequired"], true); 51 + 52 + let user = sqlx::query!( 53 + "SELECT email_pending_verification, email_confirmation_code, email FROM users WHERE handle = $1", 54 + handle 55 + ) 56 + .fetch_one(&pool) 57 + .await 58 + .expect("User not found"); 59 + 60 + assert_eq!(user.email_pending_verification.as_deref(), Some(new_email.as_str())); 61 + assert!(user.email_confirmation_code.is_some()); 62 + let code = user.email_confirmation_code.unwrap(); 63 + 64 + let res = client 65 + .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) 66 + .bearer_auth(access_jwt) 67 + .json(&json!({ 68 + "email": new_email, 69 + "token": code 70 + })) 71 + .send() 72 + .await 73 + .expect("Failed to confirm email"); 74 + assert_eq!(res.status(), StatusCode::OK); 75 + 76 + let user = sqlx::query!( 77 + "SELECT email, email_pending_verification, email_confirmation_code FROM users WHERE handle = $1", 78 + handle 79 + ) 80 + .fetch_one(&pool) 81 + .await 82 + .expect("User not found"); 83 + 84 + assert_eq!(user.email, new_email); 85 + assert!(user.email_pending_verification.is_none()); 86 + assert!(user.email_confirmation_code.is_none()); 87 + } 88 + 89 + #[tokio::test] 90 + async fn test_request_email_update_taken_email() { 91 + let client = common::client(); 92 + let base_url = common::base_url().await; 93 + 94 + let handle1 = format!("emailup_taken1_{}", uuid::Uuid::new_v4()); 95 + let email1 = format!("{}@example.com", handle1); 96 + let res = client 97 + .post(format!("{}/xrpc/com.atproto.server.createAccount", base_url)) 98 + .json(&json!({ 99 + "handle": handle1, 100 + "email": email1, 101 + "password": "password" 102 + })) 103 + .send() 104 + .await 105 + .expect("Failed to create account 1"); 106 + assert_eq!(res.status(), StatusCode::OK); 107 + 108 + let handle2 = format!("emailup_taken2_{}", uuid::Uuid::new_v4()); 109 + let email2 = format!("{}@example.com", handle2); 110 + let res = client 111 + .post(format!("{}/xrpc/com.atproto.server.createAccount", base_url)) 112 + .json(&json!({ 113 + "handle": handle2, 114 + "email": email2, 115 + "password": "password" 116 + })) 117 + .send() 118 + .await 119 + .expect("Failed to create account 2"); 120 + assert_eq!(res.status(), StatusCode::OK); 121 + let body: Value = res.json().await.expect("Invalid JSON"); 122 + let access_jwt2 = body["accessJwt"].as_str().expect("No accessJwt"); 123 + 124 + let res = client 125 + .post(format!("{}/xrpc/com.atproto.server.requestEmailUpdate", base_url)) 126 + .bearer_auth(access_jwt2) 127 + .json(&json!({"email": email1})) 128 + .send() 129 + .await 130 + .expect("Failed to request email update"); 131 + 132 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 133 + let body: Value = res.json().await.expect("Invalid JSON"); 134 + assert_eq!(body["error"], "EmailTaken"); 135 + } 136 + 137 + #[tokio::test] 138 + async fn test_confirm_email_invalid_token() { 139 + let client = common::client(); 140 + let base_url = common::base_url().await; 141 + 142 + let handle = format!("emailup_inv_{}", uuid::Uuid::new_v4()); 143 + let email = format!("{}@example.com", handle); 144 + let res = client 145 + .post(format!("{}/xrpc/com.atproto.server.createAccount", base_url)) 146 + .json(&json!({ 147 + "handle": handle, 148 + "email": email, 149 + "password": "password" 150 + })) 151 + .send() 152 + .await 153 + .expect("Failed to create account"); 154 + assert_eq!(res.status(), StatusCode::OK); 155 + let body: Value = res.json().await.expect("Invalid JSON"); 156 + let access_jwt = body["accessJwt"].as_str().expect("No accessJwt"); 157 + 158 + let new_email = format!("new_{}@example.com", handle); 159 + let res = client 160 + .post(format!("{}/xrpc/com.atproto.server.requestEmailUpdate", base_url)) 161 + .bearer_auth(access_jwt) 162 + .json(&json!({"email": new_email})) 163 + .send() 164 + .await 165 + .expect("Failed to request email update"); 166 + assert_eq!(res.status(), StatusCode::OK); 167 + 168 + let res = client 169 + .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) 170 + .bearer_auth(access_jwt) 171 + .json(&json!({ 172 + "email": new_email, 173 + "token": "wrong-token" 174 + })) 175 + .send() 176 + .await 177 + .expect("Failed to confirm email"); 178 + 179 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 180 + let body: Value = res.json().await.expect("Invalid JSON"); 181 + assert_eq!(body["error"], "InvalidToken"); 182 + } 183 + 184 + #[tokio::test] 185 + async fn test_confirm_email_wrong_email() { 186 + let client = common::client(); 187 + let base_url = common::base_url().await; 188 + let pool = get_pool().await; 189 + 190 + let handle = format!("emailup_wrong_{}", uuid::Uuid::new_v4()); 191 + let email = format!("{}@example.com", handle); 192 + let res = client 193 + .post(format!("{}/xrpc/com.atproto.server.createAccount", base_url)) 194 + .json(&json!({ 195 + "handle": handle, 196 + "email": email, 197 + "password": "password" 198 + })) 199 + .send() 200 + .await 201 + .expect("Failed to create account"); 202 + assert_eq!(res.status(), StatusCode::OK); 203 + let body: Value = res.json().await.expect("Invalid JSON"); 204 + let access_jwt = body["accessJwt"].as_str().expect("No accessJwt"); 205 + 206 + let new_email = format!("new_{}@example.com", handle); 207 + let res = client 208 + .post(format!("{}/xrpc/com.atproto.server.requestEmailUpdate", base_url)) 209 + .bearer_auth(access_jwt) 210 + .json(&json!({"email": new_email})) 211 + .send() 212 + .await 213 + .expect("Failed to request email update"); 214 + assert_eq!(res.status(), StatusCode::OK); 215 + 216 + let user = sqlx::query!("SELECT email_confirmation_code FROM users WHERE handle = $1", handle) 217 + .fetch_one(&pool) 218 + .await 219 + .expect("User not found"); 220 + let code = user.email_confirmation_code.unwrap(); 221 + 222 + let res = client 223 + .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) 224 + .bearer_auth(access_jwt) 225 + .json(&json!({ 226 + "email": "another_random@example.com", 227 + "token": code 228 + })) 229 + .send() 230 + .await 231 + .expect("Failed to confirm email"); 232 + 233 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 234 + let body: Value = res.json().await.expect("Invalid JSON"); 235 + assert_eq!(body["message"], "Email does not match pending update"); 236 + }