+20
.sqlx/query-084bc136aa68b48346ea6acaa5d171d1dbd5ce5a5a18fa1e62cfd76558082076.json
+20
.sqlx/query-084bc136aa68b48346ea6acaa5d171d1dbd5ce5a5a18fa1e62cfd76558082076.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT COUNT(*) FROM records",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "count",
9
+
"type_info": "Int8"
10
+
}
11
+
],
12
+
"parameters": {
13
+
"Left": []
14
+
},
15
+
"nullable": [
16
+
null
17
+
]
18
+
},
19
+
"hash": "084bc136aa68b48346ea6acaa5d171d1dbd5ce5a5a18fa1e62cfd76558082076"
20
+
}
+30
.sqlx/query-0bb332a1a1b648aaeca02415b8d2fc39c045bac9b3897b1c886b6ffc68538bac.json
+30
.sqlx/query-0bb332a1a1b648aaeca02415b8d2fc39c045bac9b3897b1c886b6ffc68538bac.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n INSERT INTO notification_queue (user_id, channel, notification_type, recipient, subject, body, metadata)\n VALUES ($1, $2::notification_channel, 'channel_verification', $3, 'Verify your channel', $4, $5)\n ",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Uuid",
9
+
{
10
+
"Custom": {
11
+
"name": "notification_channel",
12
+
"kind": {
13
+
"Enum": [
14
+
"email",
15
+
"discord",
16
+
"telegram",
17
+
"signal"
18
+
]
19
+
}
20
+
}
21
+
},
22
+
"Text",
23
+
"Text",
24
+
"Jsonb"
25
+
]
26
+
},
27
+
"nullable": []
28
+
},
29
+
"hash": "0bb332a1a1b648aaeca02415b8d2fc39c045bac9b3897b1c886b6ffc68538bac"
30
+
}
+14
.sqlx/query-0e837bf8eb303dbd2d0ffad0167da75c15fb1859c658fc5957ab28ef674108a8.json
+14
.sqlx/query-0e837bf8eb303dbd2d0ffad0167da75c15fb1859c658fc5957ab28ef674108a8.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'telegram'",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Uuid"
9
+
]
10
+
},
11
+
"nullable": []
12
+
},
13
+
"hash": "0e837bf8eb303dbd2d0ffad0167da75c15fb1859c658fc5957ab28ef674108a8"
14
+
}
+28
.sqlx/query-126519f77d91aa1877b2c933a876c0283f9dc49444d68eca4e87461b82f9be32.json
+28
.sqlx/query-126519f77d91aa1877b2c933a876c0283f9dc49444d68eca4e87461b82f9be32.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT code, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "code",
9
+
"type_info": "Text"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "expires_at",
14
+
"type_info": "Timestamptz"
15
+
}
16
+
],
17
+
"parameters": {
18
+
"Left": [
19
+
"Uuid"
20
+
]
21
+
},
22
+
"nullable": [
23
+
false,
24
+
false
25
+
]
26
+
},
27
+
"hash": "126519f77d91aa1877b2c933a876c0283f9dc49444d68eca4e87461b82f9be32"
28
+
}
+4
-16
.sqlx/query-257aa21927a280477b9b59ad726a67a587a0164a38665594d4925b11bf2b260b.json
.sqlx/query-dfcfb9ccc41c389bf06548f815080e7601c636ddce2b5a04a2d33a19461a2fe3.json
+4
-16
.sqlx/query-257aa21927a280477b9b59ad726a67a587a0164a38665594d4925b11bf2b260b.json
.sqlx/query-dfcfb9ccc41c389bf06548f815080e7601c636ddce2b5a04a2d33a19461a2fe3.json
···
1
{
2
"db_name": "PostgreSQL",
3
-
"query": "SELECT\n u.id, u.did, u.handle, u.email,\n u.email_confirmation_code,\n u.email_confirmation_code_expires_at,\n u.preferred_notification_channel as \"channel: crate::notifications::NotificationChannel\",\n k.key_bytes, k.encryption_version\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.did = $1",
4
"describe": {
5
"columns": [
6
{
···
25
},
26
{
27
"ordinal": 4,
28
-
"name": "email_confirmation_code",
29
-
"type_info": "Text"
30
-
},
31
-
{
32
-
"ordinal": 5,
33
-
"name": "email_confirmation_code_expires_at",
34
-
"type_info": "Timestamptz"
35
-
},
36
-
{
37
-
"ordinal": 6,
38
"name": "channel: crate::notifications::NotificationChannel",
39
"type_info": {
40
"Custom": {
···
51
}
52
},
53
{
54
-
"ordinal": 7,
55
"name": "key_bytes",
56
"type_info": "Bytea"
57
},
58
{
59
-
"ordinal": 8,
60
"name": "encryption_version",
61
"type_info": "Int4"
62
}
···
71
false,
72
false,
73
true,
74
-
true,
75
-
true,
76
false,
77
false,
78
true
79
]
80
},
81
-
"hash": "257aa21927a280477b9b59ad726a67a587a0164a38665594d4925b11bf2b260b"
82
}
···
1
{
2
"db_name": "PostgreSQL",
3
+
"query": "SELECT\n u.id, u.did, u.handle, u.email,\n u.preferred_notification_channel as \"channel: crate::notifications::NotificationChannel\",\n k.key_bytes, k.encryption_version\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.did = $1",
4
"describe": {
5
"columns": [
6
{
···
25
},
26
{
27
"ordinal": 4,
28
"name": "channel: crate::notifications::NotificationChannel",
29
"type_info": {
30
"Custom": {
···
41
}
42
},
43
{
44
+
"ordinal": 5,
45
"name": "key_bytes",
46
"type_info": "Bytea"
47
},
48
{
49
+
"ordinal": 6,
50
"name": "encryption_version",
51
"type_info": "Int4"
52
}
···
61
false,
62
false,
63
true,
64
false,
65
false,
66
true
67
]
68
},
69
+
"hash": "dfcfb9ccc41c389bf06548f815080e7601c636ddce2b5a04a2d33a19461a2fe3"
70
}
+2
-1
.sqlx/query-303777d97e6ed344f8c699eae37b7b0c241c734a5b7726019c2a59ae277caee6.json
+2
-1
.sqlx/query-303777d97e6ed344f8c699eae37b7b0c241c734a5b7726019c2a59ae277caee6.json
+15
.sqlx/query-30aa8003262b82ee58f1819f1a74c195768dce6d4c8d297e8b7eab1d990f61c5.json
+15
.sqlx/query-30aa8003262b82ee58f1819f1a74c195768dce6d4c8d297e8b7eab1d990f61c5.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Text",
9
+
"Uuid"
10
+
]
11
+
},
12
+
"nullable": []
13
+
},
14
+
"hash": "30aa8003262b82ee58f1819f1a74c195768dce6d4c8d297e8b7eab1d990f61c5"
15
+
}
+17
.sqlx/query-4513affeac5d8f9b93be23bef92e88b6949869e7d2cd3b40125597e29d7e0d20.json
+17
.sqlx/query-4513affeac5d8f9b93be23bef92e88b6949869e7d2cd3b40125597e29d7e0d20.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at)\n VALUES ($1, 'email', $2, $3, $4)\n ON CONFLICT (user_id, channel) DO UPDATE\n SET code = $2, pending_identifier = $3, expires_at = $4, created_at = NOW()\n ",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Uuid",
9
+
"Text",
10
+
"Text",
11
+
"Timestamptz"
12
+
]
13
+
},
14
+
"nullable": []
15
+
},
16
+
"hash": "4513affeac5d8f9b93be23bef92e88b6949869e7d2cd3b40125597e29d7e0d20"
17
+
}
-17
.sqlx/query-4b9243c9ef4bf260d179a778536e815c8d563017ecda7dc530aeeebd5362d190.json
-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
-
}
···
+47
.sqlx/query-4bd5937b38e9ea67215a24f8f4ece05d107b4cdacdb59c30fa3782bb36942b26.json
+47
.sqlx/query-4bd5937b38e9ea67215a24f8f4ece05d107b4cdacdb59c30fa3782bb36942b26.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n SELECT code, pending_identifier, expires_at FROM channel_verifications\n WHERE user_id = $1 AND channel = $2::notification_channel\n ",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "code",
9
+
"type_info": "Text"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "pending_identifier",
14
+
"type_info": "Text"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "expires_at",
19
+
"type_info": "Timestamptz"
20
+
}
21
+
],
22
+
"parameters": {
23
+
"Left": [
24
+
"Uuid",
25
+
{
26
+
"Custom": {
27
+
"name": "notification_channel",
28
+
"kind": {
29
+
"Enum": [
30
+
"email",
31
+
"discord",
32
+
"telegram",
33
+
"signal"
34
+
]
35
+
}
36
+
}
37
+
}
38
+
]
39
+
},
40
+
"nullable": [
41
+
false,
42
+
true,
43
+
false
44
+
]
45
+
},
46
+
"hash": "4bd5937b38e9ea67215a24f8f4ece05d107b4cdacdb59c30fa3782bb36942b26"
47
+
}
+93
.sqlx/query-4f131ba30c73a48ddf5630e75f92a86b6ce8a00a4bcae60442d05abc785abc1a.json
+93
.sqlx/query-4f131ba30c73a48ddf5630e75f92a86b6ce8a00a4bcae60442d05abc785abc1a.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n SELECT\n created_at,\n channel as \"channel: String\",\n notification_type as \"notification_type: String\",\n status as \"status: String\",\n subject,\n body\n FROM notification_queue\n WHERE user_id = $1\n ORDER BY created_at DESC\n LIMIT 50\n ",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "created_at",
9
+
"type_info": "Timestamptz"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "channel: String",
14
+
"type_info": {
15
+
"Custom": {
16
+
"name": "notification_channel",
17
+
"kind": {
18
+
"Enum": [
19
+
"email",
20
+
"discord",
21
+
"telegram",
22
+
"signal"
23
+
]
24
+
}
25
+
}
26
+
}
27
+
},
28
+
{
29
+
"ordinal": 2,
30
+
"name": "notification_type: String",
31
+
"type_info": {
32
+
"Custom": {
33
+
"name": "notification_type",
34
+
"kind": {
35
+
"Enum": [
36
+
"welcome",
37
+
"email_verification",
38
+
"password_reset",
39
+
"email_update",
40
+
"account_deletion",
41
+
"admin_email",
42
+
"plc_operation",
43
+
"two_factor_code",
44
+
"channel_verification"
45
+
]
46
+
}
47
+
}
48
+
}
49
+
},
50
+
{
51
+
"ordinal": 3,
52
+
"name": "status: String",
53
+
"type_info": {
54
+
"Custom": {
55
+
"name": "notification_status",
56
+
"kind": {
57
+
"Enum": [
58
+
"pending",
59
+
"processing",
60
+
"sent",
61
+
"failed"
62
+
]
63
+
}
64
+
}
65
+
}
66
+
},
67
+
{
68
+
"ordinal": 4,
69
+
"name": "subject",
70
+
"type_info": "Text"
71
+
},
72
+
{
73
+
"ordinal": 5,
74
+
"name": "body",
75
+
"type_info": "Text"
76
+
}
77
+
],
78
+
"parameters": {
79
+
"Left": [
80
+
"Uuid"
81
+
]
82
+
},
83
+
"nullable": [
84
+
false,
85
+
false,
86
+
false,
87
+
false,
88
+
true,
89
+
false
90
+
]
91
+
},
92
+
"hash": "4f131ba30c73a48ddf5630e75f92a86b6ce8a00a4bcae60442d05abc785abc1a"
93
+
}
+2
-1
.sqlx/query-5d49bbf0307a0c642b0174d641de748fa648c97f8109255120e969c957ff95bf.json
+2
-1
.sqlx/query-5d49bbf0307a0c642b0174d641de748fa648c97f8109255120e969c957ff95bf.json
+34
.sqlx/query-62b370470a950d02c2daf6e4fd3ad231f1558c4c020026f857b0026dae4f513e.json
+34
.sqlx/query-62b370470a950d02c2daf6e4fd3ad231f1558c4c020026f857b0026dae4f513e.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT id, handle, email FROM users WHERE did = $1",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "id",
9
+
"type_info": "Uuid"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "handle",
14
+
"type_info": "Text"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "email",
19
+
"type_info": "Text"
20
+
}
21
+
],
22
+
"parameters": {
23
+
"Left": [
24
+
"Text"
25
+
]
26
+
},
27
+
"nullable": [
28
+
false,
29
+
false,
30
+
true
31
+
]
32
+
},
33
+
"hash": "62b370470a950d02c2daf6e4fd3ad231f1558c4c020026f857b0026dae4f513e"
34
+
}
+15
.sqlx/query-6cdae6ee4f1f3ba47fbe7b98573b07161029623905e42d28e107207c3ab91c08.json
+15
.sqlx/query-6cdae6ee4f1f3ba47fbe7b98573b07161029623905e42d28e107207c3ab91c08.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "UPDATE users SET telegram_username = $1, telegram_verified = TRUE, updated_at = NOW() WHERE id = $2",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Text",
9
+
"Uuid"
10
+
]
11
+
},
12
+
"nullable": []
13
+
},
14
+
"hash": "6cdae6ee4f1f3ba47fbe7b98573b07161029623905e42d28e107207c3ab91c08"
15
+
}
-15
.sqlx/query-76a239da5103f43b16a768d6970cc7e04d9d27c88cc54072818033a03bf53057.json
-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
-
}
···
+14
.sqlx/query-76a3a8d7e969c4e10e2326675720230bad1d85da784c826df7fe4f037e1cb7f1.json
+14
.sqlx/query-76a3a8d7e969c4e10e2326675720230bad1d85da784c826df7fe4f037e1cb7f1.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "UPDATE users SET discord_id = NULL, discord_verified = FALSE, updated_at = NOW() WHERE id = $1",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Uuid"
9
+
]
10
+
},
11
+
"nullable": []
12
+
},
13
+
"hash": "76a3a8d7e969c4e10e2326675720230bad1d85da784c826df7fe4f037e1cb7f1"
14
+
}
-22
.sqlx/query-8c9c899187a8b19747b1c25dbac1501de14985beafcfed5f0a23549e18da2c19.json
-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
-
}
···
+27
.sqlx/query-90f5c38a28537b2deddd0f897b8902a97547983851bd95a9ae496943064b1849.json
+27
.sqlx/query-90f5c38a28537b2deddd0f897b8902a97547983851bd95a9ae496943064b1849.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::notification_channel",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Uuid",
9
+
{
10
+
"Custom": {
11
+
"name": "notification_channel",
12
+
"kind": {
13
+
"Enum": [
14
+
"email",
15
+
"discord",
16
+
"telegram",
17
+
"signal"
18
+
]
19
+
}
20
+
}
21
+
}
22
+
]
23
+
},
24
+
"nullable": []
25
+
},
26
+
"hash": "90f5c38a28537b2deddd0f897b8902a97547983851bd95a9ae496943064b1849"
27
+
}
+20
.sqlx/query-91b5c8338e5842836f044b5d6f353ba77d8f2dc4177215d2293ab18a1ad5870e.json
+20
.sqlx/query-91b5c8338e5842836f044b5d6f353ba77d8f2dc4177215d2293ab18a1ad5870e.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT COALESCE(SUM(size_bytes), 0)::BIGINT FROM blobs",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "coalesce",
9
+
"type_info": "Int8"
10
+
}
11
+
],
12
+
"parameters": {
13
+
"Left": []
14
+
},
15
+
"nullable": [
16
+
null
17
+
]
18
+
},
19
+
"hash": "91b5c8338e5842836f044b5d6f353ba77d8f2dc4177215d2293ab18a1ad5870e"
20
+
}
+20
.sqlx/query-96e53c7d68298d0e99d64263d076b2d02891e7e5cbee233917405559c05878a4.json
+20
.sqlx/query-96e53c7d68298d0e99d64263d076b2d02891e7e5cbee233917405559c05878a4.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT COUNT(*) FROM repos",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "count",
9
+
"type_info": "Int8"
10
+
}
11
+
],
12
+
"parameters": {
13
+
"Left": []
14
+
},
15
+
"nullable": [
16
+
null
17
+
]
18
+
},
19
+
"hash": "96e53c7d68298d0e99d64263d076b2d02891e7e5cbee233917405559c05878a4"
20
+
}
+17
.sqlx/query-97b7414f11c3d696afe2d7007dbf52074bfda921bbb300f23bdf1ccb096b5ea5.json
+17
.sqlx/query-97b7414f11c3d696afe2d7007dbf52074bfda921bbb300f23bdf1ccb096b5ea5.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, 'email', $2, $3, $4)",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Uuid",
9
+
"Text",
10
+
"Text",
11
+
"Timestamptz"
12
+
]
13
+
},
14
+
"nullable": []
15
+
},
16
+
"hash": "97b7414f11c3d696afe2d7007dbf52074bfda921bbb300f23bdf1ccb096b5ea5"
17
+
}
+14
.sqlx/query-9af373d1bee5d79419f131a44c6e346a95bd3cafe2454dbe08411abb11f42161.json
+14
.sqlx/query-9af373d1bee5d79419f131a44c6e346a95bd3cafe2454dbe08411abb11f42161.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'discord'",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Uuid"
9
+
]
10
+
},
11
+
"nullable": []
12
+
},
13
+
"hash": "9af373d1bee5d79419f131a44c6e346a95bd3cafe2454dbe08411abb11f42161"
14
+
}
+30
.sqlx/query-9ebca49cb60b1891d3c1ef0087e189f2e35b982fce0c3313748c23c113a6e546.json
+30
.sqlx/query-9ebca49cb60b1891d3c1ef0087e189f2e35b982fce0c3313748c23c113a6e546.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at)\n VALUES ($1, $2::notification_channel, $3, $4, $5)\n ON CONFLICT (user_id, channel) DO UPDATE\n SET code = $3, pending_identifier = $4, expires_at = $5, created_at = NOW()\n ",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Uuid",
9
+
{
10
+
"Custom": {
11
+
"name": "notification_channel",
12
+
"kind": {
13
+
"Enum": [
14
+
"email",
15
+
"discord",
16
+
"telegram",
17
+
"signal"
18
+
]
19
+
}
20
+
}
21
+
},
22
+
"Text",
23
+
"Text",
24
+
"Timestamptz"
25
+
]
26
+
},
27
+
"nullable": []
28
+
},
29
+
"hash": "9ebca49cb60b1891d3c1ef0087e189f2e35b982fce0c3313748c23c113a6e546"
30
+
}
+34
.sqlx/query-a1464e8d15e8e46a3ebc21e591afb89b8f937469f8e33a43f2cd8e121526f3d3.json
+34
.sqlx/query-a1464e8d15e8e46a3ebc21e591afb89b8f937469f8e33a43f2cd8e121526f3d3.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT code, pending_identifier, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "code",
9
+
"type_info": "Text"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "pending_identifier",
14
+
"type_info": "Text"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "expires_at",
19
+
"type_info": "Timestamptz"
20
+
}
21
+
],
22
+
"parameters": {
23
+
"Left": [
24
+
"Uuid"
25
+
]
26
+
},
27
+
"nullable": [
28
+
false,
29
+
true,
30
+
false
31
+
]
32
+
},
33
+
"hash": "a1464e8d15e8e46a3ebc21e591afb89b8f937469f8e33a43f2cd8e121526f3d3"
34
+
}
+15
.sqlx/query-a1e383acb16c3514df0d138d77cd5c9965ca091d6a3d035eae9ef7a8bf8be4eb.json
+15
.sqlx/query-a1e383acb16c3514df0d138d77cd5c9965ca091d6a3d035eae9ef7a8bf8be4eb.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "UPDATE users SET discord_id = $1, discord_verified = TRUE, updated_at = NOW() WHERE id = $2",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Text",
9
+
"Uuid"
10
+
]
11
+
},
12
+
"nullable": []
13
+
},
14
+
"hash": "a1e383acb16c3514df0d138d77cd5c9965ca091d6a3d035eae9ef7a8bf8be4eb"
15
+
}
-16
.sqlx/query-a507e7cd1c4d31c70f8e7d6c80fe4b6f8ac0b712c63421989faa413556bef6f1.json
-16
.sqlx/query-a507e7cd1c4d31c70f8e7d6c80fe4b6f8ac0b712c63421989faa413556bef6f1.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "UPDATE users SET email_confirmation_code = $1, email_confirmation_code_expires_at = $2 WHERE did = $3",
4
-
"describe": {
5
-
"columns": [],
6
-
"parameters": {
7
-
"Left": [
8
-
"Text",
9
-
"Timestamptz",
10
-
"Text"
11
-
]
12
-
},
13
-
"nullable": []
14
-
},
15
-
"hash": "a507e7cd1c4d31c70f8e7d6c80fe4b6f8ac0b712c63421989faa413556bef6f1"
16
-
}
···
-46
.sqlx/query-a7e1e6092df6481e64bf0c2237737b846628ed20ffa70b81fa2e416d5776185a.json
-46
.sqlx/query-a7e1e6092df6481e64bf0c2237737b846628ed20ffa70b81fa2e416d5776185a.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "SELECT id, email, email_confirmation_code, email_confirmation_code_expires_at, email_pending_verification FROM users WHERE did = $1",
4
-
"describe": {
5
-
"columns": [
6
-
{
7
-
"ordinal": 0,
8
-
"name": "id",
9
-
"type_info": "Uuid"
10
-
},
11
-
{
12
-
"ordinal": 1,
13
-
"name": "email",
14
-
"type_info": "Text"
15
-
},
16
-
{
17
-
"ordinal": 2,
18
-
"name": "email_confirmation_code",
19
-
"type_info": "Text"
20
-
},
21
-
{
22
-
"ordinal": 3,
23
-
"name": "email_confirmation_code_expires_at",
24
-
"type_info": "Timestamptz"
25
-
},
26
-
{
27
-
"ordinal": 4,
28
-
"name": "email_pending_verification",
29
-
"type_info": "Text"
30
-
}
31
-
],
32
-
"parameters": {
33
-
"Left": [
34
-
"Text"
35
-
]
36
-
},
37
-
"nullable": [
38
-
false,
39
-
true,
40
-
true,
41
-
true,
42
-
true
43
-
]
44
-
},
45
-
"hash": "a7e1e6092df6481e64bf0c2237737b846628ed20ffa70b81fa2e416d5776185a"
46
-
}
···
+14
.sqlx/query-af3010ff76e52b7cb495091f2f36547360523b12f83e4eb178242edec052a0f2.json
+14
.sqlx/query-af3010ff76e52b7cb495091f2f36547360523b12f83e4eb178242edec052a0f2.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Uuid"
9
+
]
10
+
},
11
+
"nullable": []
12
+
},
13
+
"hash": "af3010ff76e52b7cb495091f2f36547360523b12f83e4eb178242edec052a0f2"
14
+
}
-40
.sqlx/query-b551a83dbe436c1d0e4ce674f23668a9d5ef7ac5b76a332a8f8b5dc2220e9ea5.json
-40
.sqlx/query-b551a83dbe436c1d0e4ce674f23668a9d5ef7ac5b76a332a8f8b5dc2220e9ea5.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "SELECT id, email_confirmation_code, email_confirmation_code_expires_at, email_pending_verification FROM users WHERE did = $1",
4
-
"describe": {
5
-
"columns": [
6
-
{
7
-
"ordinal": 0,
8
-
"name": "id",
9
-
"type_info": "Uuid"
10
-
},
11
-
{
12
-
"ordinal": 1,
13
-
"name": "email_confirmation_code",
14
-
"type_info": "Text"
15
-
},
16
-
{
17
-
"ordinal": 2,
18
-
"name": "email_confirmation_code_expires_at",
19
-
"type_info": "Timestamptz"
20
-
},
21
-
{
22
-
"ordinal": 3,
23
-
"name": "email_pending_verification",
24
-
"type_info": "Text"
25
-
}
26
-
],
27
-
"parameters": {
28
-
"Left": [
29
-
"Text"
30
-
]
31
-
},
32
-
"nullable": [
33
-
false,
34
-
true,
35
-
true,
36
-
true
37
-
]
38
-
},
39
-
"hash": "b551a83dbe436c1d0e4ce674f23668a9d5ef7ac5b76a332a8f8b5dc2220e9ea5"
40
-
}
···
+14
.sqlx/query-c7ebbeca2ba26ef7b5a7c00441f51f68eb47f1421fa0a937eaa5a79aec75001d.json
+14
.sqlx/query-c7ebbeca2ba26ef7b5a7c00441f51f68eb47f1421fa0a937eaa5a79aec75001d.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "UPDATE users SET telegram_username = NULL, telegram_verified = FALSE, updated_at = NOW() WHERE id = $1",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Uuid"
9
+
]
10
+
},
11
+
"nullable": []
12
+
},
13
+
"hash": "c7ebbeca2ba26ef7b5a7c00441f51f68eb47f1421fa0a937eaa5a79aec75001d"
14
+
}
+2
-1
.sqlx/query-cb6f48aaba124c79308d20e66c23adb44d1196296b7f93fad19b2d17548ed3de.json
+2
-1
.sqlx/query-cb6f48aaba124c79308d20e66c23adb44d1196296b7f93fad19b2d17548ed3de.json
+28
.sqlx/query-d6ec64a262936b8def9e5cad7d021df408b3a9b80fce5a7446647fbf276092ca.json
+28
.sqlx/query-d6ec64a262936b8def9e5cad7d021df408b3a9b80fce5a7446647fbf276092ca.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT id, email FROM users WHERE did = $1",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "id",
9
+
"type_info": "Uuid"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "email",
14
+
"type_info": "Text"
15
+
}
16
+
],
17
+
"parameters": {
18
+
"Left": [
19
+
"Text"
20
+
]
21
+
},
22
+
"nullable": [
23
+
false,
24
+
true
25
+
]
26
+
},
27
+
"hash": "d6ec64a262936b8def9e5cad7d021df408b3a9b80fce5a7446647fbf276092ca"
28
+
}
+15
.sqlx/query-db62569122d7561b2f1b7d0e276f5de156489b637a93db667fc5106450dcad0c.json
+15
.sqlx/query-db62569122d7561b2f1b7d0e276f5de156489b637a93db667fc5106450dcad0c.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, 'email_auth_factor', $2) ON CONFLICT (user_id, name) DO UPDATE SET value_json = $2",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Uuid",
9
+
"Jsonb"
10
+
]
11
+
},
12
+
"nullable": []
13
+
},
14
+
"hash": "db62569122d7561b2f1b7d0e276f5de156489b637a93db667fc5106450dcad0c"
15
+
}
+20
.sqlx/query-dc64e1d25d9ced3a49130cee99f6edc3f70a4917910cf3b76faefc24ac32159d.json
+20
.sqlx/query-dc64e1d25d9ced3a49130cee99f6edc3f70a4917910cf3b76faefc24ac32159d.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT COUNT(*) FROM users",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "count",
9
+
"type_info": "Int8"
10
+
}
11
+
],
12
+
"parameters": {
13
+
"Left": []
14
+
},
15
+
"nullable": [
16
+
null
17
+
]
18
+
},
19
+
"hash": "dc64e1d25d9ced3a49130cee99f6edc3f70a4917910cf3b76faefc24ac32159d"
20
+
}
+14
.sqlx/query-ea12e747e409ca5e27369b1b17014000c6f0d53743007046d820a76497a2fa37.json
+14
.sqlx/query-ea12e747e409ca5e27369b1b17014000c6f0d53743007046d820a76497a2fa37.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "UPDATE users SET signal_number = NULL, signal_verified = FALSE, updated_at = NOW() WHERE id = $1",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Uuid"
9
+
]
10
+
},
11
+
"nullable": []
12
+
},
13
+
"hash": "ea12e747e409ca5e27369b1b17014000c6f0d53743007046d820a76497a2fa37"
14
+
}
+14
.sqlx/query-ee95f960c0df276fd936ceaef8b75d341a4c84322e5de2b3630573dbef387839.json
+14
.sqlx/query-ee95f960c0df276fd936ceaef8b75d341a4c84322e5de2b3630573dbef387839.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'signal'",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Uuid"
9
+
]
10
+
},
11
+
"nullable": []
12
+
},
13
+
"hash": "ee95f960c0df276fd936ceaef8b75d341a4c84322e5de2b3630573dbef387839"
14
+
}
+15
.sqlx/query-f056a1ff7979927e5581342edb213d1f79c2541a5e992b2b2aa7c0ae006364c3.json
+15
.sqlx/query-f056a1ff7979927e5581342edb213d1f79c2541a5e992b2b2aa7c0ae006364c3.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "UPDATE users SET signal_number = $1, signal_verified = TRUE, updated_at = NOW() WHERE id = $2",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Text",
9
+
"Uuid"
10
+
]
11
+
},
12
+
"nullable": []
13
+
},
14
+
"hash": "f056a1ff7979927e5581342edb213d1f79c2541a5e992b2b2aa7c0ae006364c3"
15
+
}
+2
-2
.sqlx/query-f4f4b6a9e5d2345efa8e48380f66c819c1818030aa4bf26757d9fb40e654b693.json
.sqlx/query-6b67b2b6759f01be11d5997a3ad68d381f59a02235a6940877f62193af8d9761.json
+2
-2
.sqlx/query-f4f4b6a9e5d2345efa8e48380f66c819c1818030aa4bf26757d9fb40e654b693.json
.sqlx/query-6b67b2b6759f01be11d5997a3ad68d381f59a02235a6940877f62193af8d9761.json
···
1
{
2
"db_name": "PostgreSQL",
3
-
"query": "SELECT k.key_bytes, k.encryption_version, u.deactivated_at, u.takedown_ref\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.did = $1",
4
"describe": {
5
"columns": [
6
{
···
36
true
37
]
38
},
39
-
"hash": "f4f4b6a9e5d2345efa8e48380f66c819c1818030aa4bf26757d9fb40e654b693"
40
}
···
1
{
2
"db_name": "PostgreSQL",
3
+
"query": "SELECT k.key_bytes, k.encryption_version, u.deactivated_at, u.takedown_ref\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.did = $1",
4
"describe": {
5
"columns": [
6
{
···
36
true
37
]
38
},
39
+
"hash": "6b67b2b6759f01be11d5997a3ad68d381f59a02235a6940877f62193af8d9761"
40
}
-15
.sqlx/query-fb7eb6dcbe91352f2d154c384a93c6c55a91f735832618b464208b70d6a8f580.json
-15
.sqlx/query-fb7eb6dcbe91352f2d154c384a93c6c55a91f735832618b464208b70d6a8f580.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "\n UPDATE users\n SET email = $1,\n email_pending_verification = NULL,\n email_confirmation_code = NULL,\n email_confirmation_code_expires_at = NULL,\n updated_at = NOW()\n WHERE id = $2\n ",
4
-
"describe": {
5
-
"columns": [],
6
-
"parameters": {
7
-
"Left": [
8
-
"Text",
9
-
"Uuid"
10
-
]
11
-
},
12
-
"nullable": []
13
-
},
14
-
"hash": "fb7eb6dcbe91352f2d154c384a93c6c55a91f735832618b464208b70d6a8f580"
15
-
}
···
+5
-5
TODO.md
+5
-5
TODO.md
···
244
Anyway... endpoints for PDS settings not covered by standard ATProto:
245
- [x] `com.bspds.account.getNotificationPrefs` - get preferred channel, verified channels
246
- [x] `com.bspds.account.updateNotificationPrefs` - set preferred channel
247
-
- [ ] `com.bspds.account.getNotificationHistory` - list past notifications
248
-
- [ ] `com.bspds.account.verifyChannel` - initiate verification for Discord/Telegram/Signal
249
-
- [ ] `com.bspds.account.confirmChannelVerification` - confirm with code
250
-
- [ ] `com.bspds.admin.getServerStats` - user count, storage usage, etc.
251
### Frontend Views
252
Uses existing ATProto endpoints where possible:
253
Authentication
···
262
- [x] Invite codes (uses `com.atproto.server.getAccountInviteCodes`, `createInviteCode`)
263
Notification Preferences
264
- [x] Channel selector (uses `com.bspds.account.*` endpoints above)
265
-
- [ ] Verification flows for Discord/Telegram/Signal
266
- [ ] Notification history view
267
Account Settings
268
- [x] Email change (uses `com.atproto.server.requestEmailUpdate`, `updateEmail`)
···
244
Anyway... endpoints for PDS settings not covered by standard ATProto:
245
- [x] `com.bspds.account.getNotificationPrefs` - get preferred channel, verified channels
246
- [x] `com.bspds.account.updateNotificationPrefs` - set preferred channel
247
+
- [x] `com.bspds.account.getNotificationHistory` - list past notifications
248
+
- [x] `com.bspds.account.verifyChannel` - initiate verification for Discord/Telegram/Signal
249
+
- [x] `com.bspds.account.confirmChannelVerification` - confirm with code
250
+
- [x] `com.bspds.admin.getServerStats` - user count, storage usage, etc.
251
### Frontend Views
252
Uses existing ATProto endpoints where possible:
253
Authentication
···
262
- [x] Invite codes (uses `com.atproto.server.getAccountInviteCodes`, `createInviteCode`)
263
Notification Preferences
264
- [x] Channel selector (uses `com.bspds.account.*` endpoints above)
265
+
- [x] Verification flows for Discord/Telegram/Signal
266
- [ ] Notification history view
267
Account Settings
268
- [x] Email change (uses `com.atproto.server.requestEmailUpdate`, `updateEmail`)
+20
-14
frontend/vite.config.ts
+20
-14
frontend/vite.config.ts
···
1
-
import { defineConfig } from 'vite'
2
import { svelte } from '@sveltejs/vite-plugin-svelte'
3
-
export default defineConfig({
4
-
plugins: [svelte()],
5
-
build: {
6
-
outDir: 'dist',
7
-
},
8
-
server: {
9
-
port: 5173,
10
-
proxy: {
11
-
'/xrpc': 'http://localhost:3000',
12
-
'/oauth': 'http://localhost:3000',
13
-
'/.well-known': 'http://localhost:3000',
14
-
'/health': 'http://localhost:3000',
15
-
'/u': 'http://localhost:3000',
16
}
17
}
18
})
···
1
+
import { defineConfig, loadEnv } from 'vite'
2
import { svelte } from '@sveltejs/vite-plugin-svelte'
3
+
4
+
export default defineConfig(({ mode }) => {
5
+
const env = loadEnv(mode, process.cwd(), '')
6
+
const target = env.VITE_API_URL || 'http://localhost:3000'
7
+
8
+
return {
9
+
plugins: [svelte()],
10
+
build: {
11
+
outDir: 'dist',
12
+
},
13
+
server: {
14
+
port: 5173,
15
+
proxy: {
16
+
'/xrpc': target,
17
+
'/oauth': target,
18
+
'/.well-known': target,
19
+
'/health': target,
20
+
'/u': target,
21
+
}
22
}
23
}
24
})
+12
migrations/20251216_add_channel_verification.sql
+12
migrations/20251216_add_channel_verification.sql
···
···
1
+
ALTER TYPE notification_type ADD VALUE IF NOT EXISTS 'channel_verification';
2
+
3
+
CREATE TABLE IF NOT EXISTS channel_verifications (
4
+
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
5
+
channel notification_channel NOT NULL,
6
+
code TEXT NOT NULL,
7
+
expires_at TIMESTAMPTZ NOT NULL,
8
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
9
+
PRIMARY KEY (user_id, channel)
10
+
);
11
+
12
+
CREATE INDEX IF NOT EXISTS idx_channel_verifications_expires ON channel_verifications(expires_at);
+11
migrations/20251217_migrate_email_to_channel_verifications.sql
+11
migrations/20251217_migrate_email_to_channel_verifications.sql
···
···
1
+
ALTER TABLE channel_verifications ADD COLUMN pending_identifier TEXT;
2
+
3
+
INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at)
4
+
SELECT id, 'email', email_confirmation_code, email_pending_verification, email_confirmation_code_expires_at
5
+
FROM users
6
+
WHERE email_confirmation_code IS NOT NULL AND email_confirmation_code_expires_at IS NOT NULL;
7
+
8
+
ALTER TABLE users
9
+
DROP COLUMN email_confirmation_code,
10
+
DROP COLUMN email_confirmation_code_expires_at,
11
+
DROP COLUMN email_pending_verification;
+2
src/api/admin/mod.rs
+2
src/api/admin/mod.rs
···
1
pub mod account;
2
pub mod invite;
3
+
pub mod server_stats;
4
pub mod status;
5
6
pub use account::{
···
10
pub use invite::{
11
disable_account_invites, disable_invite_codes, enable_account_invites, get_invite_codes,
12
};
13
+
pub use server_stats::get_server_stats;
14
pub use status::{get_subject_status, update_subject_status};
+76
src/api/admin/server_stats.rs
+76
src/api/admin/server_stats.rs
···
···
1
+
use crate::state::AppState;
2
+
use axum::{
3
+
Json,
4
+
extract::State,
5
+
http::{HeaderMap, StatusCode},
6
+
response::{IntoResponse, Response},
7
+
};
8
+
use serde::Serialize;
9
+
use serde_json::json;
10
+
11
+
#[derive(Serialize)]
12
+
#[serde(rename_all = "camelCase")]
13
+
pub struct ServerStatsResponse {
14
+
pub user_count: i64,
15
+
pub repo_count: i64,
16
+
pub record_count: i64,
17
+
pub blob_storage_bytes: i64,
18
+
}
19
+
20
+
pub async fn get_server_stats(
21
+
State(state): State<AppState>,
22
+
headers: HeaderMap,
23
+
) -> Response {
24
+
let auth_header = headers.get("Authorization");
25
+
if auth_header.is_none() {
26
+
return (
27
+
StatusCode::UNAUTHORIZED,
28
+
Json(json!({"error": "AuthenticationRequired"})),
29
+
)
30
+
.into_response();
31
+
}
32
+
33
+
let user_count: i64 = match sqlx::query_scalar!("SELECT COUNT(*) FROM users")
34
+
.fetch_one(&state.db)
35
+
.await
36
+
{
37
+
Ok(Some(count)) => count,
38
+
Ok(None) => 0,
39
+
Err(_) => 0,
40
+
};
41
+
42
+
let repo_count: i64 = match sqlx::query_scalar!("SELECT COUNT(*) FROM repos")
43
+
.fetch_one(&state.db)
44
+
.await
45
+
{
46
+
Ok(Some(count)) => count,
47
+
Ok(None) => 0,
48
+
Err(_) => 0,
49
+
};
50
+
51
+
let record_count: i64 = match sqlx::query_scalar!("SELECT COUNT(*) FROM records")
52
+
.fetch_one(&state.db)
53
+
.await
54
+
{
55
+
Ok(Some(count)) => count,
56
+
Ok(None) => 0,
57
+
Err(_) => 0,
58
+
};
59
+
60
+
let blob_storage_bytes: i64 = match sqlx::query_scalar!("SELECT COALESCE(SUM(size_bytes), 0)::BIGINT FROM blobs")
61
+
.fetch_one(&state.db)
62
+
.await
63
+
{
64
+
Ok(Some(bytes)) => bytes,
65
+
Ok(None) => 0,
66
+
Err(_) => 0,
67
+
};
68
+
69
+
Json(ServerStatsResponse {
70
+
user_count,
71
+
repo_count,
72
+
record_count,
73
+
blob_storage_bytes,
74
+
})
75
+
.into_response()
76
+
}
+18
-4
src/api/identity/account.rs
+18
-4
src/api/identity/account.rs
···
382
let user_insert: Result<(uuid::Uuid,), _> = sqlx::query_as(
383
r#"INSERT INTO users (
384
handle, email, did, password_hash,
385
-
email_confirmation_code, email_confirmation_code_expires_at,
386
preferred_notification_channel,
387
discord_id, telegram_username, signal_number
388
-
) VALUES ($1, $2, $3, $4, $5, $6, $7::notification_channel, $8, $9, $10) RETURNING id"#,
389
)
390
.bind(short_handle)
391
.bind(&email)
392
.bind(&did)
393
.bind(&password_hash)
394
-
.bind(&verification_code)
395
-
.bind(code_expires_at)
396
.bind(verification_channel)
397
.bind(
398
input
···
460
.into_response();
461
}
462
};
463
let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) {
464
Ok(enc) => enc,
465
Err(e) => {
···
382
let user_insert: Result<(uuid::Uuid,), _> = sqlx::query_as(
383
r#"INSERT INTO users (
384
handle, email, did, password_hash,
385
preferred_notification_channel,
386
discord_id, telegram_username, signal_number
387
+
) VALUES ($1, $2, $3, $4, $5::notification_channel, $6, $7, $8) RETURNING id"#,
388
)
389
.bind(short_handle)
390
.bind(&email)
391
.bind(&did)
392
.bind(&password_hash)
393
.bind(verification_channel)
394
.bind(
395
input
···
457
.into_response();
458
}
459
};
460
+
461
+
if let Err(e) = sqlx::query!(
462
+
"INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, 'email', $2, $3, $4)",
463
+
user_id,
464
+
verification_code,
465
+
email,
466
+
code_expires_at
467
+
)
468
+
.execute(&mut *tx)
469
+
.await {
470
+
error!("Error inserting verification code: {:?}", e);
471
+
return (
472
+
StatusCode::INTERNAL_SERVER_ERROR,
473
+
Json(json!({"error": "InternalError"})),
474
+
)
475
+
.into_response();
476
+
}
477
let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) {
478
Ok(enc) => enc,
479
Err(e) => {
+1
src/api/mod.rs
+1
src/api/mod.rs
+336
-54
src/api/notification_prefs.rs
+336
-54
src/api/notification_prefs.rs
···
6
http::{HeaderMap, StatusCode},
7
response::{IntoResponse, Response},
8
};
9
use serde::{Deserialize, Serialize};
10
use serde_json::json;
11
use sqlx::Row;
12
use tracing::info;
13
14
#[derive(Serialize)]
15
#[serde(rename_all = "camelCase")]
···
95
.into_response()
96
}
97
98
#[derive(Deserialize)]
99
#[serde(rename_all = "camelCase")]
100
pub struct UpdateNotificationPrefsInput {
101
pub preferred_channel: Option<String>,
102
pub discord_id: Option<String>,
103
pub telegram_username: Option<String>,
104
pub signal_number: Option<String>,
105
}
106
107
pub async fn update_notification_prefs(
108
State(state): State<AppState>,
109
headers: HeaderMap,
···
129
.into_response();
130
}
131
};
132
if let Some(ref channel) = input.preferred_channel {
133
let valid_channels = ["email", "discord", "telegram", "signal"];
134
if !valid_channels.contains(&channel.as_str()) {
···
157
}
158
info!(did = %user.did, channel = %channel, "Updated preferred notification channel");
159
}
160
-
if let Some(ref discord_id) = input.discord_id {
161
-
let discord_id_clean: Option<&str> = if discord_id.is_empty() {
162
-
None
163
-
} else {
164
-
Some(discord_id.as_str())
165
-
};
166
-
if let Err(e) = sqlx::query(
167
-
r#"UPDATE users SET discord_id = $1, discord_verified = FALSE, updated_at = NOW() WHERE did = $2"#
168
-
)
169
-
.bind(discord_id_clean)
170
-
.bind(&user.did)
171
-
.execute(&state.db)
172
-
.await
173
-
{
174
return (
175
-
StatusCode::INTERNAL_SERVER_ERROR,
176
-
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
177
)
178
.into_response();
179
}
180
-
info!(did = %user.did, "Updated Discord ID");
181
}
182
if let Some(ref telegram) = input.telegram_username {
183
-
let telegram_clean: Option<&str> = if telegram.is_empty() {
184
-
None
185
} else {
186
-
Some(telegram.trim_start_matches('@'))
187
-
};
188
-
if let Err(e) = sqlx::query(
189
-
r#"UPDATE users SET telegram_username = $1, telegram_verified = FALSE, updated_at = NOW() WHERE did = $2"#
190
-
)
191
-
.bind(telegram_clean)
192
-
.bind(&user.did)
193
-
.execute(&state.db)
194
-
.await
195
-
{
196
-
return (
197
-
StatusCode::INTERNAL_SERVER_ERROR,
198
-
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
199
-
)
200
-
.into_response();
201
}
202
-
info!(did = %user.did, "Updated Telegram username");
203
}
204
if let Some(ref signal) = input.signal_number {
205
-
let signal_clean: Option<&str> = if signal.is_empty() {
206
-
None
207
-
} else {
208
-
Some(signal.as_str())
209
-
};
210
-
if let Err(e) = sqlx::query(
211
-
r#"UPDATE users SET signal_number = $1, signal_verified = FALSE, updated_at = NOW() WHERE did = $2"#
212
-
)
213
-
.bind(signal_clean)
214
-
.bind(&user.did)
215
-
.execute(&state.db)
216
-
.await
217
-
{
218
-
return (
219
-
StatusCode::INTERNAL_SERVER_ERROR,
220
-
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
221
)
222
-
.into_response();
223
}
224
-
info!(did = %user.did, "Updated Signal number");
225
}
226
-
Json(json!({"success": true})).into_response()
227
}
···
6
http::{HeaderMap, StatusCode},
7
response::{IntoResponse, Response},
8
};
9
+
use chrono::{Duration, Utc};
10
+
use rand::Rng;
11
use serde::{Deserialize, Serialize};
12
use serde_json::json;
13
use sqlx::Row;
14
use tracing::info;
15
+
16
+
fn generate_verification_code() -> String {
17
+
rand::thread_rng()
18
+
.sample_iter(&rand::distributions::Uniform::new(0, 10))
19
+
.take(6)
20
+
.map(|x| x.to_string())
21
+
.collect()
22
+
}
23
24
#[derive(Serialize)]
25
#[serde(rename_all = "camelCase")]
···
105
.into_response()
106
}
107
108
+
#[derive(Serialize)]
109
+
#[serde(rename_all = "camelCase")]
110
+
pub struct NotificationHistoryEntry {
111
+
pub created_at: String,
112
+
pub channel: String,
113
+
pub notification_type: String,
114
+
pub status: String,
115
+
pub subject: Option<String>,
116
+
pub body: String,
117
+
}
118
+
119
+
#[derive(Serialize)]
120
+
#[serde(rename_all = "camelCase")]
121
+
pub struct GetNotificationHistoryResponse {
122
+
pub notifications: Vec<NotificationHistoryEntry>,
123
+
}
124
+
125
+
pub async fn get_notification_history(
126
+
State(state): State<AppState>,
127
+
headers: HeaderMap,
128
+
) -> Response {
129
+
let token = match crate::auth::extract_bearer_token_from_header(
130
+
headers.get("Authorization").and_then(|h| h.to_str().ok()),
131
+
) {
132
+
Some(t) => t,
133
+
None => return (
134
+
StatusCode::UNAUTHORIZED,
135
+
Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})),
136
+
)
137
+
.into_response(),
138
+
};
139
+
let user = match validate_bearer_token(&state.db, &token).await {
140
+
Ok(u) => u,
141
+
Err(_) => {
142
+
return (
143
+
StatusCode::UNAUTHORIZED,
144
+
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token"})),
145
+
)
146
+
.into_response();
147
+
}
148
+
};
149
+
150
+
let user_id: uuid::Uuid = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", user.did)
151
+
.fetch_one(&state.db)
152
+
.await
153
+
{
154
+
Ok(id) => id,
155
+
Err(e) => return (
156
+
StatusCode::INTERNAL_SERVER_ERROR,
157
+
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
158
+
)
159
+
.into_response(),
160
+
};
161
+
162
+
let rows = match sqlx::query!(
163
+
r#"
164
+
SELECT
165
+
created_at,
166
+
channel as "channel: String",
167
+
notification_type as "notification_type: String",
168
+
status as "status: String",
169
+
subject,
170
+
body
171
+
FROM notification_queue
172
+
WHERE user_id = $1
173
+
ORDER BY created_at DESC
174
+
LIMIT 50
175
+
"#,
176
+
user_id
177
+
)
178
+
.fetch_all(&state.db)
179
+
.await
180
+
{
181
+
Ok(r) => r,
182
+
Err(e) => return (
183
+
StatusCode::INTERNAL_SERVER_ERROR,
184
+
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
185
+
)
186
+
.into_response(),
187
+
};
188
+
189
+
let notifications = rows.iter().map(|row| {
190
+
NotificationHistoryEntry {
191
+
created_at: row.created_at.to_rfc3339(),
192
+
channel: row.channel.clone(),
193
+
notification_type: row.notification_type.clone(),
194
+
status: row.status.clone(),
195
+
subject: row.subject.clone(),
196
+
body: row.body.clone(),
197
+
}
198
+
}).collect();
199
+
200
+
Json(GetNotificationHistoryResponse { notifications }).into_response()
201
+
}
202
+
203
#[derive(Deserialize)]
204
#[serde(rename_all = "camelCase")]
205
pub struct UpdateNotificationPrefsInput {
206
pub preferred_channel: Option<String>,
207
+
pub email: Option<String>,
208
pub discord_id: Option<String>,
209
pub telegram_username: Option<String>,
210
pub signal_number: Option<String>,
211
}
212
213
+
#[derive(Serialize)]
214
+
#[serde(rename_all = "camelCase")]
215
+
pub struct UpdateNotificationPrefsResponse {
216
+
pub success: bool,
217
+
#[serde(skip_serializing_if = "Vec::is_empty")]
218
+
pub verification_required: Vec<String>,
219
+
}
220
+
221
+
pub async fn request_channel_verification(
222
+
db: &sqlx::PgPool,
223
+
user_id: uuid::Uuid,
224
+
channel: &str,
225
+
identifier: &str,
226
+
handle: Option<&str>,
227
+
) -> Result<String, String> {
228
+
let code = generate_verification_code();
229
+
let expires_at = Utc::now() + Duration::minutes(10);
230
+
231
+
sqlx::query!(
232
+
r#"
233
+
INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at)
234
+
VALUES ($1, $2::notification_channel, $3, $4, $5)
235
+
ON CONFLICT (user_id, channel) DO UPDATE
236
+
SET code = $3, pending_identifier = $4, expires_at = $5, created_at = NOW()
237
+
"#,
238
+
user_id,
239
+
channel as _,
240
+
code,
241
+
identifier,
242
+
expires_at
243
+
)
244
+
.execute(db)
245
+
.await
246
+
.map_err(|e| format!("Database error: {}", e))?;
247
+
248
+
if channel == "email" {
249
+
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
250
+
let handle_str = handle.unwrap_or("user");
251
+
crate::notifications::enqueue_email_update(db, user_id, identifier, handle_str, &code, &hostname)
252
+
.await
253
+
.map_err(|e| format!("Failed to enqueue email notification: {}", e))?;
254
+
} else {
255
+
sqlx::query!(
256
+
r#"
257
+
INSERT INTO notification_queue (user_id, channel, notification_type, recipient, subject, body, metadata)
258
+
VALUES ($1, $2::notification_channel, 'channel_verification', $3, 'Verify your channel', $4, $5)
259
+
"#,
260
+
user_id,
261
+
channel as _,
262
+
identifier,
263
+
format!("Your verification code is: {}", code),
264
+
json!({"code": code})
265
+
)
266
+
.execute(db)
267
+
.await
268
+
.map_err(|e| format!("Failed to enqueue notification: {}", e))?;
269
+
}
270
+
271
+
Ok(code)
272
+
}
273
+
274
pub async fn update_notification_prefs(
275
State(state): State<AppState>,
276
headers: HeaderMap,
···
296
.into_response();
297
}
298
};
299
+
300
+
let user_row = match sqlx::query!(
301
+
"SELECT id, handle, email FROM users WHERE did = $1",
302
+
user.did
303
+
)
304
+
.fetch_one(&state.db)
305
+
.await
306
+
{
307
+
Ok(row) => row,
308
+
Err(e) => return (
309
+
StatusCode::INTERNAL_SERVER_ERROR,
310
+
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
311
+
)
312
+
.into_response(),
313
+
};
314
+
315
+
let user_id = user_row.id;
316
+
let handle = user_row.handle;
317
+
let current_email = user_row.email;
318
+
319
+
let mut verification_required: Vec<String> = Vec::new();
320
+
321
if let Some(ref channel) = input.preferred_channel {
322
let valid_channels = ["email", "discord", "telegram", "signal"];
323
if !valid_channels.contains(&channel.as_str()) {
···
346
}
347
info!(did = %user.did, channel = %channel, "Updated preferred notification channel");
348
}
349
+
350
+
if let Some(ref new_email) = input.email {
351
+
let email_clean = new_email.trim().to_lowercase();
352
+
if email_clean.is_empty() {
353
+
return (
354
+
StatusCode::BAD_REQUEST,
355
+
Json(json!({"error": "InvalidRequest", "message": "Email cannot be empty"})),
356
+
)
357
+
.into_response();
358
+
}
359
+
360
+
if !crate::api::validation::is_valid_email(&email_clean) {
361
return (
362
+
StatusCode::BAD_REQUEST,
363
+
Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})),
364
)
365
.into_response();
366
}
367
+
368
+
if current_email.as_ref().map(|e| e.to_lowercase()) == Some(email_clean.clone()) {
369
+
info!(did = %user.did, "Email unchanged, skipping");
370
+
} else {
371
+
let exists = sqlx::query!(
372
+
"SELECT 1 as one FROM users WHERE LOWER(email) = $1 AND id != $2",
373
+
email_clean,
374
+
user_id
375
+
)
376
+
.fetch_optional(&state.db)
377
+
.await;
378
+
379
+
if let Ok(Some(_)) = exists {
380
+
return (
381
+
StatusCode::BAD_REQUEST,
382
+
Json(json!({"error": "EmailTaken", "message": "Email already in use"})),
383
+
)
384
+
.into_response();
385
+
}
386
+
387
+
if let Err(e) = request_channel_verification(&state.db, user_id, "email", &email_clean, Some(&handle)).await {
388
+
return (
389
+
StatusCode::INTERNAL_SERVER_ERROR,
390
+
Json(json!({"error": "InternalError", "message": e})),
391
+
)
392
+
.into_response();
393
+
}
394
+
verification_required.push("email".to_string());
395
+
info!(did = %user.did, "Requested email verification");
396
+
}
397
}
398
+
399
+
if let Some(ref discord_id) = input.discord_id {
400
+
if discord_id.is_empty() {
401
+
if let Err(e) = sqlx::query!(
402
+
"UPDATE users SET discord_id = NULL, discord_verified = FALSE, updated_at = NOW() WHERE id = $1",
403
+
user_id
404
+
)
405
+
.execute(&state.db)
406
+
.await
407
+
{
408
+
return (
409
+
StatusCode::INTERNAL_SERVER_ERROR,
410
+
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
411
+
)
412
+
.into_response();
413
+
}
414
+
let _ = sqlx::query!(
415
+
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'discord'",
416
+
user_id
417
+
)
418
+
.execute(&state.db)
419
+
.await;
420
+
info!(did = %user.did, "Cleared Discord ID");
421
+
} else {
422
+
if let Err(e) = request_channel_verification(&state.db, user_id, "discord", discord_id, None).await {
423
+
return (
424
+
StatusCode::INTERNAL_SERVER_ERROR,
425
+
Json(json!({"error": "InternalError", "message": e})),
426
+
)
427
+
.into_response();
428
+
}
429
+
verification_required.push("discord".to_string());
430
+
info!(did = %user.did, "Requested Discord verification");
431
+
}
432
+
}
433
+
434
if let Some(ref telegram) = input.telegram_username {
435
+
let telegram_clean = telegram.trim_start_matches('@');
436
+
if telegram_clean.is_empty() {
437
+
if let Err(e) = sqlx::query!(
438
+
"UPDATE users SET telegram_username = NULL, telegram_verified = FALSE, updated_at = NOW() WHERE id = $1",
439
+
user_id
440
+
)
441
+
.execute(&state.db)
442
+
.await
443
+
{
444
+
return (
445
+
StatusCode::INTERNAL_SERVER_ERROR,
446
+
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
447
+
)
448
+
.into_response();
449
+
}
450
+
let _ = sqlx::query!(
451
+
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'telegram'",
452
+
user_id
453
+
)
454
+
.execute(&state.db)
455
+
.await;
456
+
info!(did = %user.did, "Cleared Telegram username");
457
} else {
458
+
if let Err(e) = request_channel_verification(&state.db, user_id, "telegram", telegram_clean, None).await {
459
+
return (
460
+
StatusCode::INTERNAL_SERVER_ERROR,
461
+
Json(json!({"error": "InternalError", "message": e})),
462
+
)
463
+
.into_response();
464
+
}
465
+
verification_required.push("telegram".to_string());
466
+
info!(did = %user.did, "Requested Telegram verification");
467
}
468
}
469
+
470
if let Some(ref signal) = input.signal_number {
471
+
if signal.is_empty() {
472
+
if let Err(e) = sqlx::query!(
473
+
"UPDATE users SET signal_number = NULL, signal_verified = FALSE, updated_at = NOW() WHERE id = $1",
474
+
user_id
475
+
)
476
+
.execute(&state.db)
477
+
.await
478
+
{
479
+
return (
480
+
StatusCode::INTERNAL_SERVER_ERROR,
481
+
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
482
+
)
483
+
.into_response();
484
+
}
485
+
let _ = sqlx::query!(
486
+
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'signal'",
487
+
user_id
488
)
489
+
.execute(&state.db)
490
+
.await;
491
+
info!(did = %user.did, "Cleared Signal number");
492
+
} else {
493
+
if let Err(e) = request_channel_verification(&state.db, user_id, "signal", signal, None).await {
494
+
return (
495
+
StatusCode::INTERNAL_SERVER_ERROR,
496
+
Json(json!({"error": "InternalError", "message": e})),
497
+
)
498
+
.into_response();
499
+
}
500
+
verification_required.push("signal".to_string());
501
+
info!(did = %user.did, "Requested Signal verification");
502
}
503
}
504
+
505
+
Json(UpdateNotificationPrefsResponse {
506
+
success: true,
507
+
verification_required,
508
+
}).into_response()
509
}
+171
-125
src/api/server/email.rs
+171
-125
src/api/server/email.rs
···
6
http::StatusCode,
7
response::{IntoResponse, Response},
8
};
9
-
use chrono::{Duration, Utc};
10
use serde::Deserialize;
11
use serde_json::json;
12
use tracing::{error, info, warn};
13
-
14
-
fn generate_confirmation_code() -> String {
15
-
crate::util::generate_token_code()
16
-
}
17
18
#[derive(Deserialize)]
19
#[serde(rename_all = "camelCase")]
···
41
)
42
.into_response();
43
}
44
let token = match crate::auth::extract_bearer_token_from_header(
45
headers.get("Authorization").and_then(|h| h.to_str().ok()),
46
) {
···
53
.into_response();
54
}
55
};
56
let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await;
57
let did = match auth_result {
58
Ok(user) => user.did,
59
Err(e) => return ApiError::from(e).into_response(),
60
};
61
-
let user = match sqlx::query!("SELECT id, handle FROM users WHERE did = $1", did)
62
.fetch_optional(&state.db)
63
.await
64
{
···
71
.into_response();
72
}
73
};
74
let user_id = user.id;
75
let handle = user.handle;
76
let email = input.email.trim().to_lowercase();
77
if !crate::api::validation::is_valid_email(&email) {
78
return (
79
StatusCode::BAD_REQUEST,
···
81
)
82
.into_response();
83
}
84
-
let exists = sqlx::query!("SELECT 1 as one FROM users WHERE LOWER(email) = $1", email)
85
-
.fetch_optional(&state.db)
86
-
.await;
87
if let Ok(Some(_)) = exists {
88
return (
89
StatusCode::BAD_REQUEST,
···
91
)
92
.into_response();
93
}
94
-
let code = generate_confirmation_code();
95
-
let expires_at = Utc::now() + Duration::minutes(10);
96
-
let update = sqlx::query!(
97
-
"UPDATE users SET email_pending_verification = $1, email_confirmation_code = $2, email_confirmation_code_expires_at = $3 WHERE id = $4",
98
-
email,
99
-
code,
100
-
expires_at,
101
-
user_id
102
)
103
-
.execute(&state.db)
104
-
.await;
105
-
if let Err(e) = update {
106
-
error!("DB error setting email update code: {:?}", e);
107
return (
108
StatusCode::INTERNAL_SERVER_ERROR,
109
Json(json!({"error": "InternalError"})),
110
)
111
.into_response();
112
}
113
-
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
114
-
if let Err(e) = crate::notifications::enqueue_email_update(
115
-
&state.db, user_id, &email, &handle, &code, &hostname,
116
-
)
117
-
.await
118
-
{
119
-
warn!("Failed to enqueue email update notification: {:?}", e);
120
-
}
121
info!("Email update requested for user {}", user_id);
122
(StatusCode::OK, Json(json!({ "tokenRequired": true }))).into_response()
123
}
···
149
)
150
.into_response();
151
}
152
let token = match crate::auth::extract_bearer_token_from_header(
153
headers.get("Authorization").and_then(|h| h.to_str().ok()),
154
) {
···
161
.into_response();
162
}
163
};
164
let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await;
165
let did = match auth_result {
166
Ok(user) => user.did,
167
Err(e) => return ApiError::from(e).into_response(),
168
};
169
-
let user = match sqlx::query!(
170
-
"SELECT id, email_confirmation_code, email_confirmation_code_expires_at, email_pending_verification FROM users WHERE did = $1",
171
-
did
172
)
173
.fetch_optional(&state.db)
174
.await
···
176
Ok(Some(row)) => row,
177
_ => {
178
return (
179
-
StatusCode::INTERNAL_SERVER_ERROR,
180
-
Json(json!({"error": "InternalError"})),
181
)
182
.into_response();
183
}
184
};
185
-
let user_id = user.id;
186
-
let stored_code = user.email_confirmation_code;
187
-
let expires_at = user.email_confirmation_code_expires_at;
188
-
let email_pending_verification = user.email_pending_verification;
189
let email = input.email.trim().to_lowercase();
190
let confirmation_code = input.token.trim();
191
-
let (pending_email, saved_code, expiry) =
192
-
match (email_pending_verification, stored_code, expires_at) {
193
-
(Some(p), Some(c), Some(e)) => (p, c, e),
194
-
_ => {
195
-
return (
196
-
StatusCode::BAD_REQUEST,
197
-
Json(
198
-
json!({"error": "InvalidRequest", "message": "No pending email update found"}),
199
-
),
200
-
)
201
-
.into_response();
202
-
}
203
-
};
204
if pending_email != email {
205
return (
206
StatusCode::BAD_REQUEST,
···
208
)
209
.into_response();
210
}
211
-
if saved_code != confirmation_code {
212
return (
213
StatusCode::BAD_REQUEST,
214
Json(json!({"error": "InvalidToken", "message": "Invalid token"})),
215
)
216
.into_response();
217
}
218
-
if Utc::now() > expiry {
219
return (
220
StatusCode::BAD_REQUEST,
221
Json(json!({"error": "ExpiredToken", "message": "Token has expired"})),
222
)
223
.into_response();
224
}
225
let update = sqlx::query!(
226
-
"UPDATE users SET email = $1, email_pending_verification = NULL, email_confirmation_code = NULL, email_confirmation_code_expires_at = NULL WHERE id = $2",
227
pending_email,
228
user_id
229
)
230
-
.execute(&state.db)
231
.await;
232
if let Err(e) = update {
233
error!("DB error finalizing email update: {:?}", e);
234
if e.as_database_error()
···
247
)
248
.into_response();
249
}
250
info!("Email updated for user {}", user_id);
251
(StatusCode::OK, Json(json!({}))).into_response()
252
}
···
277
.into_response();
278
}
279
};
280
let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await;
281
let did = match auth_result {
282
Ok(user) => user.did,
283
Err(e) => return ApiError::from(e).into_response(),
284
};
285
let user = match sqlx::query!(
286
-
"SELECT id, email, email_confirmation_code, email_confirmation_code_expires_at, email_pending_verification FROM users WHERE did = $1",
287
did
288
)
289
.fetch_optional(&state.db)
···
298
.into_response();
299
}
300
};
301
let user_id = user.id;
302
let current_email = user.email;
303
-
let stored_code = user.email_confirmation_code;
304
-
let expires_at = user.email_confirmation_code_expires_at;
305
-
let email_pending_verification = user.email_pending_verification;
306
let new_email = input.email.trim().to_lowercase();
307
if !crate::api::validation::is_valid_email(&new_email) {
308
return (
309
StatusCode::BAD_REQUEST,
···
311
)
312
.into_response();
313
}
314
if let Some(ref current) = current_email
315
-
&& new_email == current.to_lowercase() {
316
-
return (StatusCode::OK, Json(json!({}))).into_response();
317
-
}
318
-
let email_confirmed = stored_code.is_some() && email_pending_verification.is_some();
319
-
if email_confirmed {
320
let confirmation_token = match &input.token {
321
Some(t) => t.trim(),
322
None => {
323
return (
324
StatusCode::BAD_REQUEST,
325
-
Json(json!({"error": "TokenRequired", "message": "Token required for confirmed accounts. Call requestEmailUpdate first."})),
326
-
)
327
-
.into_response();
328
-
}
329
-
};
330
-
let pending_email = match email_pending_verification {
331
-
Some(p) => p,
332
-
None => {
333
-
return (
334
-
StatusCode::BAD_REQUEST,
335
-
Json(json!({"error": "InvalidRequest", "message": "No pending email update found"})),
336
)
337
.into_response();
338
}
339
};
340
if pending_email.to_lowercase() != new_email {
341
return (
342
StatusCode::BAD_REQUEST,
···
344
)
345
.into_response();
346
}
347
-
let saved_code = match stored_code {
348
-
Some(c) => c,
349
-
None => {
350
-
return (
351
-
StatusCode::BAD_REQUEST,
352
-
Json(json!({"error": "InvalidRequest", "message": "No pending email update found"})),
353
-
)
354
-
.into_response();
355
-
}
356
-
};
357
-
if saved_code != confirmation_token {
358
return (
359
StatusCode::BAD_REQUEST,
360
Json(json!({"error": "InvalidToken", "message": "Invalid token"})),
361
)
362
.into_response();
363
}
364
-
if let Some(exp) = expires_at
365
-
&& Utc::now() > exp {
366
-
return (
367
-
StatusCode::BAD_REQUEST,
368
-
Json(json!({"error": "ExpiredToken", "message": "Token has expired"})),
369
-
)
370
-
.into_response();
371
-
}
372
}
373
let exists = sqlx::query!(
374
"SELECT 1 as one FROM users WHERE LOWER(email) = $1 AND id != $2",
375
new_email,
···
377
)
378
.fetch_optional(&state.db)
379
.await;
380
if let Ok(Some(_)) = exists {
381
return (
382
StatusCode::BAD_REQUEST,
···
384
)
385
.into_response();
386
}
387
let update = sqlx::query!(
388
-
r#"
389
-
UPDATE users
390
-
SET email = $1,
391
-
email_pending_verification = NULL,
392
-
email_confirmation_code = NULL,
393
-
email_confirmation_code_expires_at = NULL,
394
-
updated_at = NOW()
395
-
WHERE id = $2
396
-
"#,
397
new_email,
398
user_id
399
)
400
-
.execute(&state.db)
401
.await;
402
-
match update {
403
-
Ok(_) => {
404
-
info!("Email updated for user {}", user_id);
405
-
(StatusCode::OK, Json(json!({}))).into_response()
406
-
}
407
-
Err(e) => {
408
-
error!("DB error finalizing email update: {:?}", e);
409
-
if e.as_database_error()
410
-
.map(|db_err| db_err.is_unique_violation())
411
-
.unwrap_or(false)
412
-
{
413
-
return (
414
-
StatusCode::BAD_REQUEST,
415
-
Json(json!({"error": "InvalidRequest", "message": "Email already in use"})),
416
-
)
417
-
.into_response();
418
-
}
419
-
(
420
-
StatusCode::INTERNAL_SERVER_ERROR,
421
-
Json(json!({"error": "InternalError"})),
422
)
423
-
.into_response()
424
}
425
}
426
}
···
6
http::StatusCode,
7
response::{IntoResponse, Response},
8
};
9
+
use chrono::Utc;
10
use serde::Deserialize;
11
use serde_json::json;
12
use tracing::{error, info, warn};
13
14
#[derive(Deserialize)]
15
#[serde(rename_all = "camelCase")]
···
37
)
38
.into_response();
39
}
40
+
41
let token = match crate::auth::extract_bearer_token_from_header(
42
headers.get("Authorization").and_then(|h| h.to_str().ok()),
43
) {
···
50
.into_response();
51
}
52
};
53
+
54
let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await;
55
let did = match auth_result {
56
Ok(user) => user.did,
57
Err(e) => return ApiError::from(e).into_response(),
58
};
59
+
60
+
let user = match sqlx::query!("SELECT id, handle, email FROM users WHERE did = $1", did)
61
.fetch_optional(&state.db)
62
.await
63
{
···
70
.into_response();
71
}
72
};
73
+
74
let user_id = user.id;
75
let handle = user.handle;
76
+
let current_email = user.email;
77
let email = input.email.trim().to_lowercase();
78
+
79
if !crate::api::validation::is_valid_email(&email) {
80
return (
81
StatusCode::BAD_REQUEST,
···
83
)
84
.into_response();
85
}
86
+
87
+
if current_email.as_ref().map(|e| e.to_lowercase()) == Some(email.clone()) {
88
+
return (StatusCode::OK, Json(json!({ "tokenRequired": false }))).into_response();
89
+
}
90
+
91
+
let exists = sqlx::query!(
92
+
"SELECT 1 as one FROM users WHERE LOWER(email) = $1 AND id != $2",
93
+
email,
94
+
user_id
95
+
)
96
+
.fetch_optional(&state.db)
97
+
.await;
98
+
99
if let Ok(Some(_)) = exists {
100
return (
101
StatusCode::BAD_REQUEST,
···
103
)
104
.into_response();
105
}
106
+
107
+
if let Err(e) = crate::api::notification_prefs::request_channel_verification(
108
+
&state.db,
109
+
user_id,
110
+
"email",
111
+
&email,
112
+
Some(&handle),
113
)
114
+
.await
115
+
{
116
+
error!("Failed to request email verification: {}", e);
117
return (
118
StatusCode::INTERNAL_SERVER_ERROR,
119
Json(json!({"error": "InternalError"})),
120
)
121
.into_response();
122
}
123
+
124
info!("Email update requested for user {}", user_id);
125
(StatusCode::OK, Json(json!({ "tokenRequired": true }))).into_response()
126
}
···
152
)
153
.into_response();
154
}
155
+
156
let token = match crate::auth::extract_bearer_token_from_header(
157
headers.get("Authorization").and_then(|h| h.to_str().ok()),
158
) {
···
165
.into_response();
166
}
167
};
168
+
169
let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await;
170
let did = match auth_result {
171
Ok(user) => user.did,
172
Err(e) => return ApiError::from(e).into_response(),
173
};
174
+
175
+
let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
176
+
.fetch_one(&state.db)
177
+
.await
178
+
{
179
+
Ok(id) => id,
180
+
Err(_) => {
181
+
return (
182
+
StatusCode::INTERNAL_SERVER_ERROR,
183
+
Json(json!({"error": "InternalError"})),
184
+
)
185
+
.into_response();
186
+
}
187
+
};
188
+
189
+
let verification = match sqlx::query!(
190
+
"SELECT code, pending_identifier, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
191
+
user_id
192
)
193
.fetch_optional(&state.db)
194
.await
···
196
Ok(Some(row)) => row,
197
_ => {
198
return (
199
+
StatusCode::BAD_REQUEST,
200
+
Json(json!({"error": "InvalidRequest", "message": "No pending email update found"})),
201
)
202
.into_response();
203
}
204
};
205
+
206
+
let pending_email = verification.pending_identifier.unwrap_or_default();
207
let email = input.email.trim().to_lowercase();
208
let confirmation_code = input.token.trim();
209
+
210
if pending_email != email {
211
return (
212
StatusCode::BAD_REQUEST,
···
214
)
215
.into_response();
216
}
217
+
218
+
if verification.code != confirmation_code {
219
return (
220
StatusCode::BAD_REQUEST,
221
Json(json!({"error": "InvalidToken", "message": "Invalid token"})),
222
)
223
.into_response();
224
}
225
+
226
+
if Utc::now() > verification.expires_at {
227
return (
228
StatusCode::BAD_REQUEST,
229
Json(json!({"error": "ExpiredToken", "message": "Token has expired"})),
230
)
231
.into_response();
232
}
233
+
234
+
let mut tx = match state.db.begin().await {
235
+
Ok(tx) => tx,
236
+
Err(_) => return ApiError::InternalError.into_response(),
237
+
};
238
+
239
let update = sqlx::query!(
240
+
"UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2",
241
pending_email,
242
user_id
243
)
244
+
.execute(&mut *tx)
245
.await;
246
+
247
if let Err(e) = update {
248
error!("DB error finalizing email update: {:?}", e);
249
if e.as_database_error()
···
262
)
263
.into_response();
264
}
265
+
266
+
if let Err(e) = sqlx::query!(
267
+
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
268
+
user_id
269
+
)
270
+
.execute(&mut *tx)
271
+
.await
272
+
{
273
+
error!("Failed to delete verification record: {:?}", e);
274
+
return ApiError::InternalError.into_response();
275
+
}
276
+
277
+
if let Err(_) = tx.commit().await {
278
+
return ApiError::InternalError.into_response();
279
+
}
280
+
281
info!("Email updated for user {}", user_id);
282
(StatusCode::OK, Json(json!({}))).into_response()
283
}
···
308
.into_response();
309
}
310
};
311
+
312
let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await;
313
let did = match auth_result {
314
Ok(user) => user.did,
315
Err(e) => return ApiError::from(e).into_response(),
316
};
317
+
318
let user = match sqlx::query!(
319
+
"SELECT id, email FROM users WHERE did = $1",
320
did
321
)
322
.fetch_optional(&state.db)
···
331
.into_response();
332
}
333
};
334
+
335
let user_id = user.id;
336
let current_email = user.email;
337
let new_email = input.email.trim().to_lowercase();
338
+
339
if !crate::api::validation::is_valid_email(&new_email) {
340
return (
341
StatusCode::BAD_REQUEST,
···
343
)
344
.into_response();
345
}
346
+
347
if let Some(ref current) = current_email
348
+
&& new_email == current.to_lowercase()
349
+
{
350
+
return (StatusCode::OK, Json(json!({}))).into_response();
351
+
}
352
+
353
+
let verification = sqlx::query!(
354
+
"SELECT code, pending_identifier, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
355
+
user_id
356
+
)
357
+
.fetch_optional(&state.db)
358
+
.await
359
+
.unwrap_or(None);
360
+
361
+
if let Some(ver) = verification {
362
let confirmation_token = match &input.token {
363
Some(t) => t.trim(),
364
None => {
365
return (
366
StatusCode::BAD_REQUEST,
367
+
Json(json!({"error": "TokenRequired", "message": "Token required. Call requestEmailUpdate first."})),
368
)
369
.into_response();
370
}
371
};
372
+
373
+
let pending_email = ver.pending_identifier.unwrap_or_default();
374
if pending_email.to_lowercase() != new_email {
375
return (
376
StatusCode::BAD_REQUEST,
···
378
)
379
.into_response();
380
}
381
+
382
+
if ver.code != confirmation_token {
383
return (
384
StatusCode::BAD_REQUEST,
385
Json(json!({"error": "InvalidToken", "message": "Invalid token"})),
386
)
387
.into_response();
388
}
389
+
390
+
if Utc::now() > ver.expires_at {
391
+
return (
392
+
StatusCode::BAD_REQUEST,
393
+
Json(json!({"error": "ExpiredToken", "message": "Token has expired"})),
394
+
)
395
+
.into_response();
396
+
}
397
}
398
+
399
let exists = sqlx::query!(
400
"SELECT 1 as one FROM users WHERE LOWER(email) = $1 AND id != $2",
401
new_email,
···
403
)
404
.fetch_optional(&state.db)
405
.await;
406
+
407
if let Ok(Some(_)) = exists {
408
return (
409
StatusCode::BAD_REQUEST,
···
411
)
412
.into_response();
413
}
414
+
415
+
let mut tx = match state.db.begin().await {
416
+
Ok(tx) => tx,
417
+
Err(_) => return ApiError::InternalError.into_response(),
418
+
};
419
+
420
let update = sqlx::query!(
421
+
"UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2",
422
new_email,
423
user_id
424
)
425
+
.execute(&mut *tx)
426
.await;
427
+
428
+
if let Err(e) = update {
429
+
error!("DB error finalizing email update: {:?}", e);
430
+
if e.as_database_error()
431
+
.map(|db_err| db_err.is_unique_violation())
432
+
.unwrap_or(false)
433
+
{
434
+
return (
435
+
StatusCode::BAD_REQUEST,
436
+
Json(json!({"error": "InvalidRequest", "message": "Email already in use"})),
437
)
438
+
.into_response();
439
}
440
+
return (
441
+
StatusCode::INTERNAL_SERVER_ERROR,
442
+
Json(json!({"error": "InternalError"})),
443
+
)
444
+
.into_response();
445
}
446
+
447
+
let _ = sqlx::query!(
448
+
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
449
+
user_id
450
+
)
451
+
.execute(&mut *tx)
452
+
.await;
453
+
454
+
if let Err(_) = tx.commit().await {
455
+
return ApiError::InternalError.into_response();
456
+
}
457
+
458
+
match sqlx::query!(
459
+
"INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, 'email_auth_factor', $2) ON CONFLICT (user_id, name) DO UPDATE SET value_json = $2",
460
+
user_id,
461
+
json!(input.email_auth_factor.unwrap_or(false))
462
+
)
463
+
.execute(&state.db)
464
+
.await
465
+
{
466
+
Ok(_) => {}
467
+
Err(e) => warn!("Failed to update email_auth_factor preference: {}", e),
468
+
}
469
+
470
+
info!("Email updated for user {}", user_id);
471
+
(StatusCode::OK, Json(json!({}))).into_response()
472
}
+46
-17
src/api/server/session.rs
+46
-17
src/api/server/session.rs
···
475
let row = match sqlx::query!(
476
r#"SELECT
477
u.id, u.did, u.handle, u.email,
478
-
u.email_confirmation_code,
479
-
u.email_confirmation_code_expires_at,
480
u.preferred_notification_channel as "channel: crate::notifications::NotificationChannel",
481
k.key_bytes, k.encryption_version
482
FROM users u
···
497
return ApiError::InternalError.into_response();
498
}
499
};
500
-
let stored_code = match &row.email_confirmation_code {
501
-
Some(code) => code,
502
-
None => {
503
warn!("No verification code found for user: {}", input.did);
504
return ApiError::InvalidRequest("No pending verification".into()).into_response();
505
}
506
};
507
-
if stored_code != &input.verification_code {
508
warn!("Invalid verification code for user: {}", input.did);
509
return ApiError::InvalidRequest("Invalid verification code".into()).into_response();
510
}
511
-
if let Some(expires_at) = row.email_confirmation_code_expires_at
512
-
&& expires_at < Utc::now() {
513
-
warn!("Verification code expired for user: {}", input.did);
514
-
return ApiError::ExpiredTokenMsg("Verification code has expired".into())
515
-
.into_response();
516
-
}
517
let key_bytes = match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
518
Ok(k) => k,
519
Err(e) => {
···
528
crate::notifications::NotificationChannel::Signal => "signal_verified",
529
};
530
let update_query = format!(
531
-
"UPDATE users SET {} = TRUE, email_confirmation_code = NULL, email_confirmation_code_expires_at = NULL WHERE did = $1",
532
verified_column
533
);
534
if let Err(e) = sqlx::query(&update_query)
···
539
error!("Failed to update verification status: {:?}", e);
540
return ApiError::InternalError.into_response();
541
}
542
let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) {
543
Ok(m) => m,
544
Err(e) => {
···
634
}
635
let verification_code = format!("{:06}", rand::random::<u32>() % 1_000_000);
636
let code_expires_at = Utc::now() + chrono::Duration::minutes(30);
637
if let Err(e) = sqlx::query!(
638
-
"UPDATE users SET email_confirmation_code = $1, email_confirmation_code_expires_at = $2 WHERE did = $3",
639
verification_code,
640
-
code_expires_at,
641
-
input.did
642
)
643
.execute(&state.db)
644
.await
···
648
}
649
let (channel_str, recipient) = match row.channel {
650
crate::notifications::NotificationChannel::Email => {
651
-
("email", row.email.clone().unwrap_or_default())
652
}
653
crate::notifications::NotificationChannel::Discord => {
654
("discord", row.discord_id.unwrap_or_default())
···
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
···
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) => {
···
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)
···
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) => {
···
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
···
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())
+191
src/api/verification.rs
+191
src/api/verification.rs
···
···
1
+
use crate::auth::validate_bearer_token;
2
+
use crate::state::AppState;
3
+
use axum::{
4
+
Json,
5
+
extract::State,
6
+
http::{HeaderMap, StatusCode},
7
+
response::{IntoResponse, Response},
8
+
};
9
+
use chrono::Utc;
10
+
use serde::Deserialize;
11
+
use serde_json::json;
12
+
use tracing::{error, info};
13
+
14
+
#[derive(Deserialize)]
15
+
#[serde(rename_all = "camelCase")]
16
+
pub struct ConfirmChannelVerificationInput {
17
+
pub channel: String,
18
+
pub code: String,
19
+
}
20
+
21
+
pub async fn confirm_channel_verification(
22
+
State(state): State<AppState>,
23
+
headers: HeaderMap,
24
+
Json(input): Json<ConfirmChannelVerificationInput>,
25
+
) -> Response {
26
+
let token = match crate::auth::extract_bearer_token_from_header(
27
+
headers.get("Authorization").and_then(|h| h.to_str().ok()),
28
+
) {
29
+
Some(t) => t,
30
+
None => return (
31
+
StatusCode::UNAUTHORIZED,
32
+
Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})),
33
+
)
34
+
.into_response(),
35
+
};
36
+
let user = match validate_bearer_token(&state.db, &token).await {
37
+
Ok(u) => u,
38
+
Err(_) => {
39
+
return (
40
+
StatusCode::UNAUTHORIZED,
41
+
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token"})),
42
+
)
43
+
.into_response();
44
+
}
45
+
};
46
+
47
+
let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", user.did)
48
+
.fetch_one(&state.db)
49
+
.await
50
+
{
51
+
Ok(id) => id,
52
+
Err(_) => return (
53
+
StatusCode::INTERNAL_SERVER_ERROR,
54
+
Json(json!({"error": "InternalError", "message": "User not found"})),
55
+
)
56
+
.into_response(),
57
+
};
58
+
59
+
let channel_str = input.channel.as_str();
60
+
if !["email", "discord", "telegram", "signal"].contains(&channel_str) {
61
+
return (
62
+
StatusCode::BAD_REQUEST,
63
+
Json(json!({"error": "InvalidRequest", "message": "Invalid channel"})),
64
+
)
65
+
.into_response();
66
+
}
67
+
68
+
let record = match sqlx::query!(
69
+
r#"
70
+
SELECT code, pending_identifier, expires_at FROM channel_verifications
71
+
WHERE user_id = $1 AND channel = $2::notification_channel
72
+
"#,
73
+
user_id,
74
+
channel_str as _
75
+
)
76
+
.fetch_optional(&state.db)
77
+
.await {
78
+
Ok(Some(r)) => r,
79
+
Ok(None) => return (
80
+
StatusCode::BAD_REQUEST,
81
+
Json(json!({"error": "InvalidRequest", "message": "No pending verification found. Update notification preferences first."})),
82
+
)
83
+
.into_response(),
84
+
Err(e) => return (
85
+
StatusCode::INTERNAL_SERVER_ERROR,
86
+
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
87
+
)
88
+
.into_response(),
89
+
};
90
+
91
+
let pending_identifier = match record.pending_identifier {
92
+
Some(p) => p,
93
+
None => return (
94
+
StatusCode::BAD_REQUEST,
95
+
Json(json!({"error": "InvalidRequest", "message": "No pending identifier found"})),
96
+
)
97
+
.into_response(),
98
+
};
99
+
100
+
if record.expires_at < Utc::now() {
101
+
return (
102
+
StatusCode::BAD_REQUEST,
103
+
Json(json!({"error": "ExpiredToken", "message": "Verification code expired"})),
104
+
)
105
+
.into_response();
106
+
}
107
+
108
+
if record.code != input.code {
109
+
return (
110
+
StatusCode::BAD_REQUEST,
111
+
Json(json!({"error": "InvalidCode", "message": "Invalid verification code"})),
112
+
)
113
+
.into_response();
114
+
}
115
+
116
+
let mut tx = match state.db.begin().await {
117
+
Ok(tx) => tx,
118
+
Err(_) => return (
119
+
StatusCode::INTERNAL_SERVER_ERROR,
120
+
Json(json!({"error": "InternalError"})),
121
+
)
122
+
.into_response(),
123
+
};
124
+
125
+
let update_result = match channel_str {
126
+
"email" => sqlx::query!(
127
+
"UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2",
128
+
pending_identifier,
129
+
user_id
130
+
).execute(&mut *tx).await,
131
+
"discord" => sqlx::query!(
132
+
"UPDATE users SET discord_id = $1, discord_verified = TRUE, updated_at = NOW() WHERE id = $2",
133
+
pending_identifier,
134
+
user_id
135
+
).execute(&mut *tx).await,
136
+
"telegram" => sqlx::query!(
137
+
"UPDATE users SET telegram_username = $1, telegram_verified = TRUE, updated_at = NOW() WHERE id = $2",
138
+
pending_identifier,
139
+
user_id
140
+
).execute(&mut *tx).await,
141
+
"signal" => sqlx::query!(
142
+
"UPDATE users SET signal_number = $1, signal_verified = TRUE, updated_at = NOW() WHERE id = $2",
143
+
pending_identifier,
144
+
user_id
145
+
).execute(&mut *tx).await,
146
+
_ => unreachable!(),
147
+
};
148
+
149
+
if let Err(e) = update_result {
150
+
error!("Failed to update user channel: {:?}", e);
151
+
if channel_str == "email" && e.as_database_error().map(|db| db.is_unique_violation()).unwrap_or(false) {
152
+
return (
153
+
StatusCode::BAD_REQUEST,
154
+
Json(json!({"error": "EmailTaken", "message": "Email already in use"})),
155
+
)
156
+
.into_response();
157
+
}
158
+
return (
159
+
StatusCode::INTERNAL_SERVER_ERROR,
160
+
Json(json!({"error": "InternalError", "message": "Failed to update channel"})),
161
+
)
162
+
.into_response();
163
+
}
164
+
165
+
if let Err(e) = sqlx::query!(
166
+
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::notification_channel",
167
+
user_id,
168
+
channel_str as _
169
+
)
170
+
.execute(&mut *tx)
171
+
.await {
172
+
error!("Failed to delete verification record: {:?}", e);
173
+
return (
174
+
StatusCode::INTERNAL_SERVER_ERROR,
175
+
Json(json!({"error": "InternalError"})),
176
+
)
177
+
.into_response();
178
+
}
179
+
180
+
if let Err(_) = tx.commit().await {
181
+
return (
182
+
StatusCode::INTERNAL_SERVER_ERROR,
183
+
Json(json!({"error": "InternalError"})),
184
+
)
185
+
.into_response();
186
+
}
187
+
188
+
info!(did = %user.did, channel = %channel_str, "Channel verified successfully");
189
+
190
+
Json(json!({"success": true})).into_response()
191
+
}
+12
src/lib.rs
+12
src/lib.rs
···
273
get(api::admin::get_invite_codes),
274
)
275
.route(
276
"/xrpc/com.atproto.admin.disableAccountInvites",
277
post(api::admin::disable_account_invites),
278
)
···
387
.route(
388
"/xrpc/com.bspds.account.updateNotificationPrefs",
389
post(api::notification_prefs::update_notification_prefs),
390
)
391
.route("/xrpc/{*method}", any(api::proxy::proxy_handler))
392
.layer(middleware::from_fn(metrics::metrics_middleware))
···
273
get(api::admin::get_invite_codes),
274
)
275
.route(
276
+
"/xrpc/com.bspds.admin.getServerStats",
277
+
get(api::admin::get_server_stats),
278
+
)
279
+
.route(
280
"/xrpc/com.atproto.admin.disableAccountInvites",
281
post(api::admin::disable_account_invites),
282
)
···
391
.route(
392
"/xrpc/com.bspds.account.updateNotificationPrefs",
393
post(api::notification_prefs::update_notification_prefs),
394
+
)
395
+
.route(
396
+
"/xrpc/com.bspds.account.getNotificationHistory",
397
+
get(api::notification_prefs::get_notification_history),
398
+
)
399
+
.route(
400
+
"/xrpc/com.bspds.account.confirmChannelVerification",
401
+
post(api::verification::confirm_channel_verification),
402
)
403
.route("/xrpc/{*method}", any(api::proxy::proxy_handler))
404
.layer(middleware::from_fn(metrics::metrics_middleware))
+221
tests/account_notifications.rs
+221
tests/account_notifications.rs
···
···
1
+
mod common;
2
+
use common::{base_url, client, create_account_and_login, get_db_connection_string};
3
+
use bspds::notifications::{NewNotification, NotificationType, enqueue_notification};
4
+
use serde_json::{Value, json};
5
+
use sqlx::PgPool;
6
+
7
+
async fn get_pool() -> PgPool {
8
+
let conn_str = 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_get_notification_history() {
18
+
let client = client();
19
+
let base = base_url().await;
20
+
let pool = get_pool().await;
21
+
let (token, did) = create_account_and_login(&client).await;
22
+
23
+
let user_id: uuid::Uuid = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
24
+
.fetch_one(&pool)
25
+
.await
26
+
.expect("User not found");
27
+
28
+
for i in 0..3 {
29
+
let notification = NewNotification::email(
30
+
user_id,
31
+
NotificationType::Welcome,
32
+
"test@example.com".to_string(),
33
+
format!("Subject {}", i),
34
+
format!("Body {}", i),
35
+
);
36
+
enqueue_notification(&pool, notification).await.expect("Failed to enqueue");
37
+
}
38
+
39
+
let resp = client
40
+
.get(format!("{}/xrpc/com.bspds.account.getNotificationHistory", base))
41
+
.header("Authorization", format!("Bearer {}", token))
42
+
.send()
43
+
.await
44
+
.unwrap();
45
+
46
+
assert_eq!(resp.status(), 200);
47
+
let body: Value = resp.json().await.unwrap();
48
+
let notifications = body["notifications"].as_array().unwrap();
49
+
assert_eq!(notifications.len(), 5);
50
+
51
+
assert_eq!(notifications[0]["subject"], "Subject 2");
52
+
assert_eq!(notifications[1]["subject"], "Subject 1");
53
+
assert_eq!(notifications[2]["subject"], "Subject 0");
54
+
}
55
+
56
+
#[tokio::test]
57
+
async fn test_verify_channel_discord() {
58
+
let client = client();
59
+
let base = base_url().await;
60
+
let (token, did) = create_account_and_login(&client).await;
61
+
62
+
let prefs = json!({
63
+
"discordId": "123456789"
64
+
});
65
+
let resp = client
66
+
.post(format!("{}/xrpc/com.bspds.account.updateNotificationPrefs", base))
67
+
.header("Authorization", format!("Bearer {}", token))
68
+
.json(&prefs)
69
+
.send()
70
+
.await
71
+
.unwrap();
72
+
assert_eq!(resp.status(), 200);
73
+
let body: Value = resp.json().await.unwrap();
74
+
assert!(body["verificationRequired"].as_array().unwrap().contains(&json!("discord")));
75
+
76
+
let pool = get_pool().await;
77
+
let user_id: uuid::Uuid = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
78
+
.fetch_one(&pool)
79
+
.await
80
+
.expect("User not found");
81
+
82
+
let code: String = sqlx::query_scalar!(
83
+
"SELECT code FROM channel_verifications WHERE user_id = $1 AND channel = 'discord'",
84
+
user_id
85
+
)
86
+
.fetch_one(&pool)
87
+
.await
88
+
.expect("Verification code not found");
89
+
90
+
let input = json!({
91
+
"channel": "discord",
92
+
"code": code
93
+
});
94
+
let resp = client
95
+
.post(format!("{}/xrpc/com.bspds.account.confirmChannelVerification", base))
96
+
.header("Authorization", format!("Bearer {}", token))
97
+
.json(&input)
98
+
.send()
99
+
.await
100
+
.unwrap();
101
+
assert_eq!(resp.status(), 200);
102
+
103
+
let resp = client
104
+
.get(format!("{}/xrpc/com.bspds.account.getNotificationPrefs", base))
105
+
.header("Authorization", format!("Bearer {}", token))
106
+
.send()
107
+
.await
108
+
.unwrap();
109
+
let body: Value = resp.json().await.unwrap();
110
+
assert_eq!(body["discordVerified"], true);
111
+
assert_eq!(body["discordId"], "123456789");
112
+
}
113
+
114
+
#[tokio::test]
115
+
async fn test_verify_channel_invalid_code() {
116
+
let client = client();
117
+
let base = base_url().await;
118
+
let (token, _did) = create_account_and_login(&client).await;
119
+
120
+
let prefs = json!({
121
+
"telegramUsername": "testuser"
122
+
});
123
+
let resp = client
124
+
.post(format!("{}/xrpc/com.bspds.account.updateNotificationPrefs", base))
125
+
.header("Authorization", format!("Bearer {}", token))
126
+
.json(&prefs)
127
+
.send()
128
+
.await
129
+
.unwrap();
130
+
assert_eq!(resp.status(), 200);
131
+
132
+
let input = json!({
133
+
"channel": "telegram",
134
+
"code": "000000"
135
+
});
136
+
let resp = client
137
+
.post(format!("{}/xrpc/com.bspds.account.confirmChannelVerification", base))
138
+
.header("Authorization", format!("Bearer {}", token))
139
+
.json(&input)
140
+
.send()
141
+
.await
142
+
.unwrap();
143
+
assert_eq!(resp.status(), 400);
144
+
}
145
+
146
+
#[tokio::test]
147
+
async fn test_verify_channel_not_set() {
148
+
let client = client();
149
+
let base = base_url().await;
150
+
let (token, _did) = create_account_and_login(&client).await;
151
+
152
+
let input = json!({
153
+
"channel": "signal",
154
+
"code": "123456"
155
+
});
156
+
let resp = client
157
+
.post(format!("{}/xrpc/com.bspds.account.confirmChannelVerification", base))
158
+
.header("Authorization", format!("Bearer {}", token))
159
+
.json(&input)
160
+
.send()
161
+
.await
162
+
.unwrap();
163
+
assert_eq!(resp.status(), 400);
164
+
}
165
+
166
+
#[tokio::test]
167
+
async fn test_update_email_via_notification_prefs() {
168
+
let client = client();
169
+
let base = base_url().await;
170
+
let pool = get_pool().await;
171
+
let (token, did) = create_account_and_login(&client).await;
172
+
173
+
let prefs = json!({
174
+
"email": "newemail@example.com"
175
+
});
176
+
let resp = client
177
+
.post(format!("{}/xrpc/com.bspds.account.updateNotificationPrefs", base))
178
+
.header("Authorization", format!("Bearer {}", token))
179
+
.json(&prefs)
180
+
.send()
181
+
.await
182
+
.unwrap();
183
+
assert_eq!(resp.status(), 200);
184
+
let body: Value = resp.json().await.unwrap();
185
+
assert!(body["verificationRequired"].as_array().unwrap().contains(&json!("email")));
186
+
187
+
let user_id: uuid::Uuid = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
188
+
.fetch_one(&pool)
189
+
.await
190
+
.expect("User not found");
191
+
192
+
let code: String = sqlx::query_scalar!(
193
+
"SELECT code FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
194
+
user_id
195
+
)
196
+
.fetch_one(&pool)
197
+
.await
198
+
.expect("Verification code not found");
199
+
200
+
let input = json!({
201
+
"channel": "email",
202
+
"code": code
203
+
});
204
+
let resp = client
205
+
.post(format!("{}/xrpc/com.bspds.account.confirmChannelVerification", base))
206
+
.header("Authorization", format!("Bearer {}", token))
207
+
.json(&input)
208
+
.send()
209
+
.await
210
+
.unwrap();
211
+
assert_eq!(resp.status(), 200);
212
+
213
+
let resp = client
214
+
.get(format!("{}/xrpc/com.bspds.account.getNotificationPrefs", base))
215
+
.header("Authorization", format!("Bearer {}", token))
216
+
.send()
217
+
.await
218
+
.unwrap();
219
+
let body: Value = resp.json().await.unwrap();
220
+
assert_eq!(body["email"], "newemail@example.com");
221
+
}
+41
tests/admin_stats.rs
+41
tests/admin_stats.rs
···
···
1
+
mod common;
2
+
use common::{base_url, client, create_account_and_login};
3
+
use serde_json::Value;
4
+
5
+
#[tokio::test]
6
+
async fn test_get_server_stats() {
7
+
let client = client();
8
+
let base = base_url().await;
9
+
let (token1, _) = create_account_and_login(&client).await;
10
+
11
+
let (_, _) = create_account_and_login(&client).await;
12
+
13
+
let resp = client
14
+
.get(format!("{}/xrpc/com.bspds.admin.getServerStats", base))
15
+
.header("Authorization", format!("Bearer {}", token1))
16
+
.send()
17
+
.await
18
+
.unwrap();
19
+
20
+
assert_eq!(resp.status(), 200);
21
+
let body: Value = resp.json().await.unwrap();
22
+
23
+
let user_count = body["userCount"].as_i64().unwrap();
24
+
assert!(user_count >= 2);
25
+
26
+
assert!(body["repoCount"].is_number());
27
+
assert!(body["recordCount"].is_number());
28
+
assert!(body["blobStorageBytes"].is_number());
29
+
}
30
+
31
+
#[tokio::test]
32
+
async fn test_get_server_stats_no_auth() {
33
+
let client = client();
34
+
let base = base_url().await;
35
+
let resp = client
36
+
.get(format!("{}/xrpc/com.bspds.admin.getServerStats", base))
37
+
.send()
38
+
.await
39
+
.unwrap();
40
+
assert_eq!(resp.status(), 401);
41
+
}
+9
-6
tests/common/mod.rs
+9
-6
tests/common/mod.rs
···
79
SERVER_URL.get_or_init(|| {
80
let (tx, rx) = std::sync::mpsc::channel();
81
std::thread::spawn(move || {
82
if std::env::var("DOCKER_HOST").is_err() {
83
if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
84
let podman_sock = std::path::Path::new(&runtime_dir).join("podman/podman.sock");
···
406
.await
407
.expect("Failed to connect to test database");
408
let verification_code: String = sqlx::query_scalar!(
409
-
"SELECT email_confirmation_code FROM users WHERE did = $1",
410
did
411
)
412
.fetch_one(&pool)
413
.await
414
-
.expect("Failed to get verification code")
415
-
.expect("No verification code found");
416
let confirm_payload = json!({
417
"did": did,
418
"verificationCode": verification_code
···
548
.await
549
.expect("Failed to connect to test database");
550
let verification_code: String = sqlx::query_scalar!(
551
-
"SELECT email_confirmation_code FROM users WHERE did = $1",
552
&did
553
)
554
.fetch_one(&pool)
555
.await
556
-
.expect("Failed to get verification code")
557
-
.expect("No verification code found");
558
let confirm_payload = json!({
559
"did": did,
560
"verificationCode": verification_code
···
79
SERVER_URL.get_or_init(|| {
80
let (tx, rx) = std::sync::mpsc::channel();
81
std::thread::spawn(move || {
82
+
unsafe {
83
+
std::env::set_var("BSPDS_ALLOW_INSECURE_SECRETS", "1");
84
+
}
85
if std::env::var("DOCKER_HOST").is_err() {
86
if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
87
let podman_sock = std::path::Path::new(&runtime_dir).join("podman/podman.sock");
···
409
.await
410
.expect("Failed to connect to test database");
411
let verification_code: String = sqlx::query_scalar!(
412
+
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'",
413
did
414
)
415
.fetch_one(&pool)
416
.await
417
+
.expect("Failed to get verification code");
418
+
419
let confirm_payload = json!({
420
"did": did,
421
"verificationCode": verification_code
···
551
.await
552
.expect("Failed to connect to test database");
553
let verification_code: String = sqlx::query_scalar!(
554
+
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'",
555
&did
556
)
557
.fetch_one(&pool)
558
.await
559
+
.expect("Failed to get verification code");
560
+
561
let confirm_payload = json!({
562
"did": did,
563
"verificationCode": verification_code
+34
-19
tests/email_update.rs
+34
-19
tests/email_update.rs
···
59
assert_eq!(res.status(), StatusCode::OK);
60
let body: Value = res.json().await.expect("Invalid JSON");
61
assert_eq!(body["tokenRequired"], true);
62
-
let user = sqlx::query!(
63
-
"SELECT email_pending_verification, email_confirmation_code, email FROM users WHERE handle = $1",
64
handle
65
)
66
.fetch_one(&pool)
67
.await
68
-
.expect("User not found");
69
assert_eq!(
70
-
user.email_pending_verification.as_deref(),
71
Some(new_email.as_str())
72
);
73
-
assert!(user.email_confirmation_code.is_some());
74
-
let code = user.email_confirmation_code.unwrap();
75
let res = client
76
.post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url))
77
.bearer_auth(&access_jwt)
···
84
.expect("Failed to confirm email");
85
assert_eq!(res.status(), StatusCode::OK);
86
let user = sqlx::query!(
87
-
"SELECT email, email_pending_verification, email_confirmation_code FROM users WHERE handle = $1",
88
handle
89
)
90
.fetch_one(&pool)
91
.await
92
.expect("User not found");
93
assert_eq!(user.email, Some(new_email));
94
-
assert!(user.email_pending_verification.is_none());
95
-
assert!(user.email_confirmation_code.is_none());
96
}
97
98
#[tokio::test]
···
174
.await
175
.expect("Failed to request email update");
176
assert_eq!(res.status(), StatusCode::OK);
177
-
let user = sqlx::query!(
178
-
"SELECT email_confirmation_code FROM users WHERE handle = $1",
179
handle
180
)
181
.fetch_one(&pool)
182
.await
183
-
.expect("User not found");
184
-
let code = user.email_confirmation_code.unwrap();
185
let res = client
186
.post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url))
187
.bearer_auth(&access_jwt)
···
293
.await
294
.expect("Failed to request email update");
295
assert_eq!(res.status(), StatusCode::OK);
296
-
let user = sqlx::query!(
297
-
"SELECT email_confirmation_code FROM users WHERE handle = $1",
298
handle
299
)
300
.fetch_one(&pool)
301
.await
302
-
.expect("User not found");
303
-
let code = user.email_confirmation_code.unwrap();
304
let res = client
305
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
306
.bearer_auth(&access_jwt)
···
313
.expect("Failed to update email");
314
assert_eq!(res.status(), StatusCode::OK);
315
let user = sqlx::query!(
316
-
"SELECT email, email_pending_verification FROM users WHERE handle = $1",
317
handle
318
)
319
.fetch_one(&pool)
320
.await
321
.expect("User not found");
322
assert_eq!(user.email, Some(new_email));
323
-
assert!(user.email_pending_verification.is_none());
324
}
325
326
#[tokio::test]
···
59
assert_eq!(res.status(), StatusCode::OK);
60
let body: Value = res.json().await.expect("Invalid JSON");
61
assert_eq!(body["tokenRequired"], true);
62
+
63
+
let verification = sqlx::query!(
64
+
"SELECT pending_identifier, code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'",
65
handle
66
)
67
.fetch_one(&pool)
68
.await
69
+
.expect("Verification not found");
70
+
71
assert_eq!(
72
+
verification.pending_identifier.as_deref(),
73
Some(new_email.as_str())
74
);
75
+
let code = verification.code;
76
let res = client
77
.post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url))
78
.bearer_auth(&access_jwt)
···
85
.expect("Failed to confirm email");
86
assert_eq!(res.status(), StatusCode::OK);
87
let user = sqlx::query!(
88
+
"SELECT email FROM users WHERE handle = $1",
89
handle
90
)
91
.fetch_one(&pool)
92
.await
93
.expect("User not found");
94
assert_eq!(user.email, Some(new_email));
95
+
96
+
let verification = sqlx::query!(
97
+
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'",
98
+
handle
99
+
)
100
+
.fetch_optional(&pool)
101
+
.await
102
+
.expect("DB error");
103
+
assert!(verification.is_none());
104
}
105
106
#[tokio::test]
···
182
.await
183
.expect("Failed to request email update");
184
assert_eq!(res.status(), StatusCode::OK);
185
+
let verification = sqlx::query!(
186
+
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'",
187
handle
188
)
189
.fetch_one(&pool)
190
.await
191
+
.expect("Verification not found");
192
+
let code = verification.code;
193
let res = client
194
.post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url))
195
.bearer_auth(&access_jwt)
···
301
.await
302
.expect("Failed to request email update");
303
assert_eq!(res.status(), StatusCode::OK);
304
+
let verification = sqlx::query!(
305
+
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'",
306
handle
307
)
308
.fetch_one(&pool)
309
.await
310
+
.expect("Verification not found");
311
+
let code = verification.code;
312
let res = client
313
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
314
.bearer_auth(&access_jwt)
···
321
.expect("Failed to update email");
322
assert_eq!(res.status(), StatusCode::OK);
323
let user = sqlx::query!(
324
+
"SELECT email FROM users WHERE handle = $1",
325
handle
326
)
327
.fetch_one(&pool)
328
.await
329
.expect("User not found");
330
assert_eq!(user.email, Some(new_email));
331
+
let verification = sqlx::query!(
332
+
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'",
333
+
handle
334
+
)
335
+
.fetch_optional(&pool)
336
+
.await
337
+
.expect("DB error");
338
+
assert!(verification.is_none());
339
}
340
341
#[tokio::test]
+2
-3
tests/jwt_security.rs
+2
-3
tests/jwt_security.rs
···
872
.await
873
.expect("Failed to connect to test database");
874
let verification_code: String = sqlx::query_scalar!(
875
-
"SELECT email_confirmation_code FROM users WHERE did = $1",
876
did
877
)
878
.fetch_one(&pool)
879
.await
880
-
.expect("Failed to get verification code")
881
-
.expect("No verification code found");
882
let confirm_res = http_client
883
.post(format!("{}/xrpc/com.atproto.server.confirmSignup", url))
884
.json(&json!({
···
872
.await
873
.expect("Failed to connect to test database");
874
let verification_code: String = sqlx::query_scalar!(
875
+
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'",
876
did
877
)
878
.fetch_one(&pool)
879
.await
880
+
.expect("Failed to get verification code");
881
let confirm_res = http_client
882
.post(format!("{}/xrpc/com.atproto.server.confirmSignup", url))
883
.json(&json!({