+2
-2
.env.example
+2
-2
.env.example
···
93
93
# MAIL_FROM_ADDRESS=noreply@example.com
94
94
# MAIL_FROM_NAME=My PDS
95
95
# SENDMAIL_PATH=/usr/sbin/sendmail
96
-
# Discord notifications (via webhook)
97
-
# DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
96
+
# Discord notifications (via bot DM)
97
+
# DISCORD_BOT_TOKEN=bot-token
98
98
# Telegram notifications (via bot)
99
99
# TELEGRAM_BOT_TOKEN=bot-token
100
100
# TELEGRAM_WEBHOOK_SECRET=random-secret
+15
.sqlx/query-0350df76ef1a934f5262ae05856a921017a2dc02fe73513dc1243e219ebc3b76.json
+15
.sqlx/query-0350df76ef1a934f5262ae05856a921017a2dc02fe73513dc1243e219ebc3b76.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "UPDATE users SET\n signal_username = $1,\n signal_verified = CASE WHEN LOWER(signal_username) = LOWER($1) THEN signal_verified ELSE FALSE END,\n updated_at = NOW()\n WHERE id = $2",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Text",
9
+
"Uuid"
10
+
]
11
+
},
12
+
"nullable": []
13
+
},
14
+
"hash": "0350df76ef1a934f5262ae05856a921017a2dc02fe73513dc1243e219ebc3b76"
15
+
}
+24
.sqlx/query-0f80177bbdffb0186895e7f000462ecd3b34e50c3604d7b48664a9f85058eb70.json
+24
.sqlx/query-0f80177bbdffb0186895e7f000462ecd3b34e50c3604d7b48664a9f85058eb70.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "UPDATE users SET discord_id = $2, discord_verified = TRUE, updated_at = NOW() WHERE LOWER(discord_username) = LOWER($1) AND discord_username IS NOT NULL AND handle = $3 RETURNING id",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "id",
9
+
"type_info": "Uuid"
10
+
}
11
+
],
12
+
"parameters": {
13
+
"Left": [
14
+
"Text",
15
+
"Text",
16
+
"Text"
17
+
]
18
+
},
19
+
"nullable": [
20
+
false
21
+
]
22
+
},
23
+
"hash": "0f80177bbdffb0186895e7f000462ecd3b34e50c3604d7b48664a9f85058eb70"
24
+
}
+22
.sqlx/query-15e75af384c9d948b655a4ec2442e345ee8d30ab4b4aa71656d0e47d0dc36693.json
+22
.sqlx/query-15e75af384c9d948b655a4ec2442e345ee8d30ab4b4aa71656d0e47d0dc36693.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT id FROM users WHERE LOWER(discord_username) = LOWER($1) AND discord_username IS NOT NULL AND deactivated_at IS NULL FOR UPDATE NOWAIT",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "id",
9
+
"type_info": "Uuid"
10
+
}
11
+
],
12
+
"parameters": {
13
+
"Left": [
14
+
"Text"
15
+
]
16
+
},
17
+
"nullable": [
18
+
false
19
+
]
20
+
},
21
+
"hash": "15e75af384c9d948b655a4ec2442e345ee8d30ab4b4aa71656d0e47d0dc36693"
22
+
}
+14
.sqlx/query-3c2dde5b27aeaf0dfe5a5c00d4b309d361518bb30890f688088cfd114ae3ce07.json
+14
.sqlx/query-3c2dde5b27aeaf0dfe5a5c00d4b309d361518bb30890f688088cfd114ae3ce07.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "UPDATE users SET signal_username = 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": "3c2dde5b27aeaf0dfe5a5c00d4b309d361518bb30890f688088cfd114ae3ce07"
14
+
}
+14
-8
.sqlx/query-c48c9af71d8dab70ea2f11df30d2cc4976732a47430bd471f5aa47afc16e5740.json
.sqlx/query-5b43c61801fb580a9cc08094a611505bd35383ba5c6453e72e819f80bacc19b2.json
+14
-8
.sqlx/query-c48c9af71d8dab70ea2f11df30d2cc4976732a47430bd471f5aa47afc16e5740.json
.sqlx/query-5b43c61801fb580a9cc08094a611505bd35383ba5c6453e72e819f80bacc19b2.json
···
1
1
{
2
2
"db_name": "PostgreSQL",
3
-
"query": "SELECT\n email,\n preferred_comms_channel as \"preferred_channel!: CommsChannel\",\n discord_id,\n discord_verified,\n telegram_username,\n telegram_verified,\n telegram_chat_id,\n signal_number,\n signal_verified\n FROM users WHERE did = $1",
3
+
"query": "SELECT\n email,\n preferred_comms_channel as \"preferred_channel!: CommsChannel\",\n discord_id,\n discord_username,\n discord_verified,\n telegram_username,\n telegram_verified,\n telegram_chat_id,\n signal_username,\n signal_verified\n FROM users WHERE did = $1",
4
4
"describe": {
5
5
"columns": [
6
6
{
···
32
32
},
33
33
{
34
34
"ordinal": 3,
35
+
"name": "discord_username",
36
+
"type_info": "Text"
37
+
},
38
+
{
39
+
"ordinal": 4,
35
40
"name": "discord_verified",
36
41
"type_info": "Bool"
37
42
},
38
43
{
39
-
"ordinal": 4,
44
+
"ordinal": 5,
40
45
"name": "telegram_username",
41
46
"type_info": "Text"
42
47
},
43
48
{
44
-
"ordinal": 5,
49
+
"ordinal": 6,
45
50
"name": "telegram_verified",
46
51
"type_info": "Bool"
47
52
},
48
53
{
49
-
"ordinal": 6,
54
+
"ordinal": 7,
50
55
"name": "telegram_chat_id",
51
56
"type_info": "Int8"
52
57
},
53
58
{
54
-
"ordinal": 7,
55
-
"name": "signal_number",
59
+
"ordinal": 8,
60
+
"name": "signal_username",
56
61
"type_info": "Text"
57
62
},
58
63
{
59
-
"ordinal": 8,
64
+
"ordinal": 9,
60
65
"name": "signal_verified",
61
66
"type_info": "Bool"
62
67
}
···
70
75
true,
71
76
false,
72
77
true,
78
+
true,
73
79
false,
74
80
true,
75
81
false,
···
78
84
false
79
85
]
80
86
},
81
-
"hash": "c48c9af71d8dab70ea2f11df30d2cc4976732a47430bd471f5aa47afc16e5740"
87
+
"hash": "5b43c61801fb580a9cc08094a611505bd35383ba5c6453e72e819f80bacc19b2"
82
88
}
-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
-
}
+14
.sqlx/query-7ff9f138ecb4974169490a998bcaaede516e0452219f14d006c43af6f959db21.json
+14
.sqlx/query-7ff9f138ecb4974169490a998bcaaede516e0452219f14d006c43af6f959db21.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "UPDATE users SET discord_id = NULL, discord_username = 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": "7ff9f138ecb4974169490a998bcaaede516e0452219f14d006c43af6f959db21"
14
+
}
+15
.sqlx/query-91376af625ed07da85a7a091997c3256ece8a0c8dda4baefeadb2b9627214d2a.json
+15
.sqlx/query-91376af625ed07da85a7a091997c3256ece8a0c8dda4baefeadb2b9627214d2a.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "UPDATE users SET\n discord_username = $1,\n discord_verified = CASE WHEN LOWER(discord_username) = LOWER($1) THEN discord_verified ELSE FALSE END,\n discord_id = CASE WHEN LOWER(discord_username) = LOWER($1) THEN discord_id ELSE NULL END,\n updated_at = NOW()\n WHERE id = $2",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Text",
9
+
"Uuid"
10
+
]
11
+
},
12
+
"nullable": []
13
+
},
14
+
"hash": "91376af625ed07da85a7a091997c3256ece8a0c8dda4baefeadb2b9627214d2a"
15
+
}
+3
-3
.sqlx/query-45fac6171726d2c1990a3bb37a6dac592efa7f1bedcb29824ce8792093872722.json
.sqlx/query-a844774d8dd3c50c5faf3de5d43f534b80234759c8437434e467ca33ea10fd1f.json
+3
-3
.sqlx/query-45fac6171726d2c1990a3bb37a6dac592efa7f1bedcb29824ce8792093872722.json
.sqlx/query-a844774d8dd3c50c5faf3de5d43f534b80234759c8437434e467ca33ea10fd1f.json
···
1
1
{
2
2
"db_name": "PostgreSQL",
3
-
"query": "SELECT preferred_comms_channel as \"preferred_comms_channel: String\", discord_id FROM users WHERE did = $1",
3
+
"query": "SELECT preferred_comms_channel as \"preferred_comms_channel: String\", discord_username FROM users WHERE did = $1",
4
4
"describe": {
5
5
"columns": [
6
6
{
···
22
22
},
23
23
{
24
24
"ordinal": 1,
25
-
"name": "discord_id",
25
+
"name": "discord_username",
26
26
"type_info": "Text"
27
27
}
28
28
],
···
36
36
true
37
37
]
38
38
},
39
-
"hash": "45fac6171726d2c1990a3bb37a6dac592efa7f1bedcb29824ce8792093872722"
39
+
"hash": "a844774d8dd3c50c5faf3de5d43f534b80234759c8437434e467ca33ea10fd1f"
40
40
}
+4
-4
.sqlx/query-a01d6b316fc64250992bccb3b4b2cea9205d8828fac58c84ec929c8a0465588c.json
.sqlx/query-b542a4235ceb6285cad12eed162098a9a7775d81a7e3e9e8bd976f134d260b27.json
+4
-4
.sqlx/query-a01d6b316fc64250992bccb3b4b2cea9205d8828fac58c84ec929c8a0465588c.json
.sqlx/query-b542a4235ceb6285cad12eed162098a9a7775d81a7e3e9e8bd976f134d260b27.json
···
1
1
{
2
2
"db_name": "PostgreSQL",
3
-
"query": "SELECT\n id, handle, email,\n preferred_comms_channel as \"channel: CommsChannel\",\n discord_id, telegram_username, signal_number,\n email_verified, discord_verified, telegram_verified, signal_verified\n FROM users\n WHERE did = $1",
3
+
"query": "SELECT\n id, handle, email,\n preferred_comms_channel as \"channel: CommsChannel\",\n discord_username, telegram_username, signal_username,\n email_verified, discord_verified, telegram_verified, signal_verified\n FROM users\n WHERE did = $1",
4
4
"describe": {
5
5
"columns": [
6
6
{
···
37
37
},
38
38
{
39
39
"ordinal": 4,
40
-
"name": "discord_id",
40
+
"name": "discord_username",
41
41
"type_info": "Text"
42
42
},
43
43
{
···
47
47
},
48
48
{
49
49
"ordinal": 6,
50
-
"name": "signal_number",
50
+
"name": "signal_username",
51
51
"type_info": "Text"
52
52
},
53
53
{
···
90
90
false
91
91
]
92
92
},
93
-
"hash": "a01d6b316fc64250992bccb3b4b2cea9205d8828fac58c84ec929c8a0465588c"
93
+
"hash": "b542a4235ceb6285cad12eed162098a9a7775d81a7e3e9e8bd976f134d260b27"
94
94
}
+15
.sqlx/query-b863a9d1feb2f6c76c7c1c9bd09730fa0ca9f5c722357f90f36e3b47e825f824.json
+15
.sqlx/query-b863a9d1feb2f6c76c7c1c9bd09730fa0ca9f5c722357f90f36e3b47e825f824.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "UPDATE users SET signal_username = $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": "b863a9d1feb2f6c76c7c1c9bd09730fa0ca9f5c722357f90f36e3b47e825f824"
15
+
}
+3
-3
.sqlx/query-63c2a9079c147be6d04bf02c63ea7a3d0b0db3f35438380c0fe7e4c60b420c67.json
.sqlx/query-b8829a4375f55ff7f357ceab94d1ea1087f284b66fc814e7c4742c0a54f78020.json
+3
-3
.sqlx/query-63c2a9079c147be6d04bf02c63ea7a3d0b0db3f35438380c0fe7e4c60b420c67.json
.sqlx/query-b8829a4375f55ff7f357ceab94d1ea1087f284b66fc814e7c4742c0a54f78020.json
···
1
1
{
2
2
"db_name": "PostgreSQL",
3
-
"query": "SELECT email, handle, preferred_comms_channel as \"preferred_channel!: CommsChannel\", preferred_locale, telegram_chat_id, discord_id, signal_number\n FROM users WHERE id = $1",
3
+
"query": "SELECT email, handle, preferred_comms_channel as \"preferred_channel!: CommsChannel\", preferred_locale, telegram_chat_id, discord_id, signal_username\n FROM users WHERE id = $1",
4
4
"describe": {
5
5
"columns": [
6
6
{
···
47
47
},
48
48
{
49
49
"ordinal": 6,
50
-
"name": "signal_number",
50
+
"name": "signal_username",
51
51
"type_info": "Text"
52
52
}
53
53
],
···
66
66
true
67
67
]
68
68
},
69
-
"hash": "63c2a9079c147be6d04bf02c63ea7a3d0b0db3f35438380c0fe7e4c60b420c67"
69
+
"hash": "b8829a4375f55ff7f357ceab94d1ea1087f284b66fc814e7c4742c0a54f78020"
70
70
}
+4
-4
.sqlx/query-671fb43192b9f37862f9eae2c5cec206ae1664ab5d595df4058c44abd257c910.json
.sqlx/query-c8657f67fdd3cc0fb40dfaabd5cde460d84ea1de18f620ee4ece2623ad153911.json
+4
-4
.sqlx/query-671fb43192b9f37862f9eae2c5cec206ae1664ab5d595df4058c44abd257c910.json
.sqlx/query-c8657f67fdd3cc0fb40dfaabd5cde460d84ea1de18f620ee4ece2623ad153911.json
···
1
1
{
2
2
"db_name": "PostgreSQL",
3
-
"query": "SELECT\n u.id, u.did, u.handle, u.email,\n u.preferred_comms_channel as \"channel: CommsChannel\",\n u.discord_id, u.telegram_username, u.signal_number,\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",
3
+
"query": "SELECT\n u.id, u.did, u.handle, u.email,\n u.preferred_comms_channel as \"channel: CommsChannel\",\n u.discord_username, u.telegram_username, u.signal_username,\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
4
"describe": {
5
5
"columns": [
6
6
{
···
42
42
},
43
43
{
44
44
"ordinal": 5,
45
-
"name": "discord_id",
45
+
"name": "discord_username",
46
46
"type_info": "Text"
47
47
},
48
48
{
···
52
52
},
53
53
{
54
54
"ordinal": 7,
55
-
"name": "signal_number",
55
+
"name": "signal_username",
56
56
"type_info": "Text"
57
57
},
58
58
{
···
84
84
true
85
85
]
86
86
},
87
-
"hash": "671fb43192b9f37862f9eae2c5cec206ae1664ab5d595df4058c44abd257c910"
87
+
"hash": "c8657f67fdd3cc0fb40dfaabd5cde460d84ea1de18f620ee4ece2623ad153911"
88
88
}
-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
-
}
+23
.sqlx/query-eab6c3df913b420839e9752cf1163d2f4b4a1464ca6184b5275d769c7930e96c.json
+23
.sqlx/query-eab6c3df913b420839e9752cf1163d2f4b4a1464ca6184b5275d769c7930e96c.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "UPDATE users SET discord_id = $2, discord_verified = TRUE, updated_at = NOW() WHERE id = $1 RETURNING id",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "id",
9
+
"type_info": "Uuid"
10
+
}
11
+
],
12
+
"parameters": {
13
+
"Left": [
14
+
"Uuid",
15
+
"Text"
16
+
]
17
+
},
18
+
"nullable": [
19
+
false
20
+
]
21
+
},
22
+
"hash": "eab6c3df913b420839e9752cf1163d2f4b4a1464ca6184b5275d769c7930e96c"
23
+
}
-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
Cargo.lock
+2
Cargo.lock
+1
-1
crates/tranquil-comms/src/lib.rs
+1
-1
crates/tranquil-comms/src/lib.rs
···
8
8
};
9
9
pub use sender::{
10
10
CommsSender, DiscordSender, EmailSender, SendError, SignalSender, TelegramSender,
11
-
is_valid_phone_number, mime_encode_header, sanitize_header_value,
11
+
is_valid_phone_number, is_valid_signal_username, mime_encode_header, sanitize_header_value,
12
12
};
13
13
pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms};
+14
crates/tranquil-comms/src/locale.rs
+14
crates/tranquil-comms/src/locale.rs
···
33
33
pub migration_verification_body: &'static str,
34
34
pub channel_verified_subject: &'static str,
35
35
pub channel_verified_body: &'static str,
36
+
pub channel_verification_subject: &'static str,
37
+
pub channel_verification_body: &'static str,
36
38
}
37
39
38
40
pub fn get_strings(locale: &str) -> &'static NotificationStrings {
···
70
72
migration_verification_body: "Welcome to {hostname}!\n\nYour account has been migrated successfully. To complete the setup, please verify your email address.\n\nYour verification code is:\n{code}\n\nCopy the code above and enter it at:\n{verify_page}\n\nThis code will expire in 48 hours.\n\nOr if you like to live dangerously:\n{verify_link}\n\nIf you did not migrate your account, please ignore this email.",
71
73
channel_verified_subject: "Channel verified - {hostname}",
72
74
channel_verified_body: "Hello {handle},\n\n{channel} has been verified as a notification channel for your account on {hostname}.",
75
+
channel_verification_subject: "Verify your channel - {hostname}",
76
+
channel_verification_body: "Your verification code is:\n{code}\n\nOr verify directly:\n{verify_link}",
73
77
};
74
78
75
79
static STRINGS_ZH: NotificationStrings = NotificationStrings {
···
96
100
migration_verification_body: "欢迎来到 {hostname}!\n\n您的账户已成功迁移。要完成设置,请验证您的邮箱地址。\n\n您的验证码是:\n{code}\n\n复制上述验证码并在此输入:\n{verify_page}\n\n此验证码将在 48 小时后过期。\n\n或者直接点击链接:\n{verify_link}\n\n如果您没有迁移账户,请忽略此邮件。",
97
101
channel_verified_subject: "通知渠道已验证 - {hostname}",
98
102
channel_verified_body: "您好 {handle},\n\n{channel} 已被验证为您在 {hostname} 上的通知渠道。",
103
+
channel_verification_subject: "验证您的渠道 - {hostname}",
104
+
channel_verification_body: "您的验证码是:\n{code}\n\n或直接验证:\n{verify_link}",
99
105
};
100
106
101
107
static STRINGS_JA: NotificationStrings = NotificationStrings {
···
122
128
migration_verification_body: "{hostname} へようこそ!\n\nアカウントの移行が完了しました。設定を完了するには、メールアドレスを認証してください。\n\n認証コードは:\n{code}\n\n上記のコードをコピーして、こちらで入力してください:\n{verify_page}\n\nこのコードは48時間後に期限切れとなります。\n\n自己責任でワンクリック認証:\n{verify_link}\n\nアカウントを移行していない場合は、このメールを無視してください。",
123
129
channel_verified_subject: "通知チャンネル認証完了 - {hostname}",
124
130
channel_verified_body: "{handle} 様\n\n{channel} が {hostname} の通知チャンネルとして認証されました。",
131
+
channel_verification_subject: "チャンネルを認証 - {hostname}",
132
+
channel_verification_body: "認証コードは:\n{code}\n\n直接認証:\n{verify_link}",
125
133
};
126
134
127
135
static STRINGS_KO: NotificationStrings = NotificationStrings {
···
148
156
migration_verification_body: "{hostname}에 오신 것을 환영합니다!\n\n계정 마이그레이션이 완료되었습니다. 설정을 완료하려면 이메일 주소를 인증하세요.\n\n인증 코드는:\n{code}\n\n위 코드를 복사하여 여기에 입력하세요:\n{verify_page}\n\n이 코드는 48시간 후에 만료됩니다.\n\n위험을 감수하고 원클릭 인증:\n{verify_link}\n\n계정을 마이그레이션하지 않았다면 이 이메일을 무시하세요.",
149
157
channel_verified_subject: "알림 채널 인증 완료 - {hostname}",
150
158
channel_verified_body: "안녕하세요 {handle}님,\n\n{channel}이(가) {hostname}의 알림 채널로 인증되었습니다.",
159
+
channel_verification_subject: "채널 인증 - {hostname}",
160
+
channel_verification_body: "인증 코드:\n{code}\n\n직접 인증:\n{verify_link}",
151
161
};
152
162
153
163
static STRINGS_SV: NotificationStrings = NotificationStrings {
···
174
184
migration_verification_body: "Välkommen till {hostname}!\n\nDitt konto har migrerats framgångsrikt. För att slutföra installationen, verifiera din e-postadress.\n\nDin verifieringskod är:\n{code}\n\nKopiera koden ovan och ange den på:\n{verify_page}\n\nDenna kod upphör om 48 timmar.\n\nEller om du gillar att leva farligt:\n{verify_link}\n\nOm du inte migrerade ditt konto kan du ignorera detta meddelande.",
175
185
channel_verified_subject: "Aviseringskanal verifierad - {hostname}",
176
186
channel_verified_body: "Hej {handle},\n\n{channel} har verifierats som aviseringskanal för ditt konto på {hostname}.",
187
+
channel_verification_subject: "Verifiera din kanal - {hostname}",
188
+
channel_verification_body: "Din verifieringskod är:\n{code}\n\nEller verifiera direkt:\n{verify_link}",
177
189
};
178
190
179
191
static STRINGS_FI: NotificationStrings = NotificationStrings {
···
200
212
migration_verification_body: "Tervetuloa palveluun {hostname}!\n\nTilisi on siirretty onnistuneesti. Viimeistele asennus vahvistamalla sähköpostiosoitteesi.\n\nVahvistuskoodisi on:\n{code}\n\nKopioi koodi yllä ja syötä se osoitteessa:\n{verify_page}\n\nTämä koodi vanhenee 48 tunnissa.\n\nTai jos pidät vaarallisesta elämästä:\n{verify_link}\n\nJos et siirtänyt tiliäsi, voit jättää tämän viestin huomiotta.",
201
213
channel_verified_subject: "Ilmoituskanava vahvistettu - {hostname}",
202
214
channel_verified_body: "Hei {handle},\n\n{channel} on vahvistettu ilmoituskanavaksi tilillesi palvelussa {hostname}.",
215
+
channel_verification_subject: "Vahvista kanavasi - {hostname}",
216
+
channel_verification_body: "Vahvistuskoodisi on:\n{code}\n\nTai vahvista suoraan:\n{verify_link}",
203
217
};
204
218
205
219
pub fn format_message(template: &str, vars: &[(&str, &str)]) -> String {
+284
-30
crates/tranquil-comms/src/sender.rs
+284
-30
crates/tranquil-comms/src/sender.rs
···
6
6
use std::time::Duration;
7
7
use tokio::io::AsyncWriteExt;
8
8
use tokio::process::Command;
9
+
use tokio::time::timeout;
9
10
10
11
use super::types::{CommsChannel, QueuedComms};
11
12
···
85
86
!remaining.is_empty() && remaining.chars().all(|c| c.is_ascii_digit())
86
87
}
87
88
89
+
pub fn is_valid_signal_username(username: &str) -> bool {
90
+
if username.len() < 6 || username.len() > 35 {
91
+
return false;
92
+
}
93
+
let Some((base, discriminator)) = username.rsplit_once('.') else {
94
+
return false;
95
+
};
96
+
if base.len() < 3 || base.len() > 32 {
97
+
return false;
98
+
}
99
+
if !base.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
100
+
return false;
101
+
}
102
+
if !base.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) {
103
+
return false;
104
+
}
105
+
discriminator.len() == 2 && discriminator.chars().all(|c| c.is_ascii_digit())
106
+
}
107
+
88
108
pub struct EmailSender {
89
109
from_address: String,
90
110
from_name: String,
···
154
174
}
155
175
}
156
176
177
+
const DISCORD_API_BASE: &str = "https://discord.com/api/v10";
178
+
179
+
#[derive(Clone)]
157
180
pub struct DiscordSender {
158
-
webhook_url: String,
181
+
bot_token: String,
159
182
http_client: Client,
160
183
}
161
184
162
185
impl DiscordSender {
163
-
pub fn new(webhook_url: String) -> Self {
186
+
pub fn new(bot_token: String) -> Self {
164
187
Self {
165
-
webhook_url,
188
+
bot_token,
166
189
http_client: create_http_client(),
167
190
}
168
191
}
169
192
170
193
pub fn from_env() -> Option<Self> {
171
-
let webhook_url = std::env::var("DISCORD_WEBHOOK_URL").ok()?;
172
-
Some(Self::new(webhook_url))
194
+
let bot_token = std::env::var("DISCORD_BOT_TOKEN").ok()?;
195
+
Some(Self::new(bot_token))
196
+
}
197
+
198
+
fn auth_header(&self) -> String {
199
+
format!("Bot {}", self.bot_token)
200
+
}
201
+
202
+
pub async fn resolve_application_info(&self) -> Result<(String, String), SendError> {
203
+
let url = format!("{}/applications/@me", DISCORD_API_BASE);
204
+
let response = self
205
+
.http_client
206
+
.get(&url)
207
+
.header("Authorization", self.auth_header())
208
+
.send()
209
+
.await
210
+
.map_err(|e| {
211
+
SendError::ExternalService(format!(
212
+
"Discord application info request failed: {}",
213
+
e
214
+
))
215
+
})?;
216
+
217
+
if !response.status().is_success() {
218
+
let body = response.text().await.unwrap_or_default();
219
+
return Err(SendError::ExternalService(format!(
220
+
"Discord application info returned error: {}",
221
+
body
222
+
)));
223
+
}
224
+
225
+
let data: serde_json::Value = response.json().await.map_err(|e| {
226
+
SendError::ExternalService(format!("Failed to parse Discord application info: {}", e))
227
+
})?;
228
+
229
+
let app_id = data
230
+
.get("id")
231
+
.and_then(|v| v.as_str())
232
+
.map(|s| s.to_string())
233
+
.ok_or_else(|| SendError::ExternalService("Application info missing id".to_string()))?;
234
+
235
+
let verify_key = data
236
+
.get("verify_key")
237
+
.and_then(|v| v.as_str())
238
+
.map(|s| s.to_string())
239
+
.ok_or_else(|| {
240
+
SendError::ExternalService("Application info missing verify_key".to_string())
241
+
})?;
242
+
243
+
Ok((app_id, verify_key))
244
+
}
245
+
246
+
pub async fn register_slash_command(&self, app_id: &str) -> Result<(), SendError> {
247
+
let url = format!("{}/applications/{}/commands", DISCORD_API_BASE, app_id);
248
+
let payload = serde_json::json!({
249
+
"name": "start",
250
+
"description": "Verify your PDS account",
251
+
"type": 1,
252
+
"options": [{
253
+
"name": "handle",
254
+
"description": "Your PDS handle (e.g. alice.example.com)",
255
+
"type": 3,
256
+
"required": false
257
+
}]
258
+
});
259
+
let response = self
260
+
.http_client
261
+
.post(&url)
262
+
.header("Authorization", self.auth_header())
263
+
.json(&payload)
264
+
.send()
265
+
.await
266
+
.map_err(|e| {
267
+
SendError::ExternalService(format!("Register command request failed: {}", e))
268
+
})?;
269
+
270
+
if !response.status().is_success() {
271
+
let body = response.text().await.unwrap_or_default();
272
+
return Err(SendError::ExternalService(format!(
273
+
"Register command returned error: {}",
274
+
body
275
+
)));
276
+
}
277
+
Ok(())
278
+
}
279
+
280
+
pub async fn set_interactions_endpoint(
281
+
&self,
282
+
app_id: &str,
283
+
url: &str,
284
+
) -> Result<(), SendError> {
285
+
let patch_url = format!("{}/applications/{}", DISCORD_API_BASE, app_id);
286
+
let payload = serde_json::json!({
287
+
"interactions_endpoint_url": url
288
+
});
289
+
let response = self
290
+
.http_client
291
+
.patch(&patch_url)
292
+
.header("Authorization", self.auth_header())
293
+
.json(&payload)
294
+
.send()
295
+
.await
296
+
.map_err(|e| {
297
+
SendError::ExternalService(format!("Set interactions endpoint failed: {}", e))
298
+
})?;
299
+
300
+
if !response.status().is_success() {
301
+
let body = response.text().await.unwrap_or_default();
302
+
return Err(SendError::ExternalService(format!(
303
+
"Set interactions endpoint returned error: {}",
304
+
body
305
+
)));
306
+
}
307
+
Ok(())
308
+
}
309
+
310
+
pub async fn resolve_bot_username(&self) -> Result<String, SendError> {
311
+
let url = format!("{}/users/@me", DISCORD_API_BASE);
312
+
let response = self
313
+
.http_client
314
+
.get(&url)
315
+
.header("Authorization", self.auth_header())
316
+
.send()
317
+
.await
318
+
.map_err(|e| {
319
+
SendError::ExternalService(format!("Discord getMe request failed: {}", e))
320
+
})?;
321
+
322
+
if !response.status().is_success() {
323
+
let body = response.text().await.unwrap_or_default();
324
+
return Err(SendError::ExternalService(format!(
325
+
"Discord getMe returned error: {}",
326
+
body
327
+
)));
328
+
}
329
+
330
+
let data: serde_json::Value = response.json().await.map_err(|e| {
331
+
SendError::ExternalService(format!("Failed to parse Discord getMe response: {}", e))
332
+
})?;
333
+
334
+
data.get("username")
335
+
.and_then(|u| u.as_str())
336
+
.map(|s| s.to_string())
337
+
.ok_or_else(|| {
338
+
SendError::ExternalService("Discord getMe response missing username".to_string())
339
+
})
340
+
}
341
+
342
+
async fn open_dm_channel(&self, user_id: &str) -> Result<String, SendError> {
343
+
let url = format!("{}/users/@me/channels", DISCORD_API_BASE);
344
+
let payload = json!({ "recipient_id": user_id });
345
+
346
+
let response = self
347
+
.http_client
348
+
.post(&url)
349
+
.header("Authorization", self.auth_header())
350
+
.json(&payload)
351
+
.send()
352
+
.await
353
+
.map_err(|e| {
354
+
SendError::ExternalService(format!("Discord DM channel request failed: {}", e))
355
+
})?;
356
+
357
+
if !response.status().is_success() {
358
+
let status = response.status();
359
+
let body = response.text().await.unwrap_or_default();
360
+
return Err(SendError::ExternalService(format!(
361
+
"Discord DM channel creation returned {}: {}",
362
+
status, body
363
+
)));
364
+
}
365
+
366
+
let data: serde_json::Value = response.json().await.map_err(|e| {
367
+
SendError::ExternalService(format!(
368
+
"Failed to parse Discord DM channel response: {}",
369
+
e
370
+
))
371
+
})?;
372
+
373
+
data.get("id")
374
+
.and_then(|id| id.as_str())
375
+
.map(|s| s.to_string())
376
+
.ok_or_else(|| {
377
+
SendError::ExternalService("Discord DM channel response missing id".to_string())
378
+
})
173
379
}
174
380
}
175
381
···
180
386
}
181
387
182
388
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
389
+
let channel_id = self.open_dm_channel(¬ification.recipient).await?;
390
+
183
391
let subject = notification.subject.as_deref().unwrap_or("Notification");
184
392
let content = format!("**{}**\n\n{}", subject, notification.body);
185
-
let payload = json!({
186
-
"content": content,
187
-
"username": "Tranquil PDS"
188
-
});
393
+
let payload = json!({ "content": content });
394
+
let url = format!("{}/channels/{}/messages", DISCORD_API_BASE, channel_id);
395
+
189
396
let mut last_error = None;
190
397
for attempt in 0..MAX_RETRIES {
191
398
let result = self
192
399
.http_client
193
-
.post(&self.webhook_url)
400
+
.post(&url)
401
+
.header("Authorization", self.auth_header())
194
402
.json(&payload)
195
403
.send()
196
404
.await;
···
201
409
}
202
410
let status = response.status();
203
411
if is_retryable_status(status) && attempt < MAX_RETRIES - 1 {
204
-
last_error = Some(format!("Discord webhook returned {}", status));
412
+
last_error = Some(format!("Discord API returned {}", status));
205
413
retry_delay(attempt).await;
206
414
continue;
207
415
}
208
416
let body = response.text().await.unwrap_or_default();
209
417
return Err(SendError::ExternalService(format!(
210
-
"Discord webhook returned {}: {}",
418
+
"Discord API returned {}: {}",
211
419
status, body
212
420
)));
213
421
}
···
386
594
}
387
595
}
388
596
597
+
const SIGNAL_TIMEOUT_SECS: u64 = 30;
598
+
599
+
fn is_retryable_signal_error(stderr: &str) -> bool {
600
+
let lower = stderr.to_lowercase();
601
+
lower.contains("timeout")
602
+
|| lower.contains("timed out")
603
+
|| lower.contains("connection refused")
604
+
|| lower.contains("network")
605
+
|| lower.contains("temporarily")
606
+
|| lower.contains("try again")
607
+
|| lower.contains("rate limit")
608
+
}
609
+
389
610
#[async_trait]
390
611
impl CommsSender for SignalSender {
391
612
fn channel(&self) -> CommsChannel {
···
394
615
395
616
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
396
617
let recipient = ¬ification.recipient;
397
-
if !is_valid_phone_number(recipient) {
618
+
if !is_valid_signal_username(recipient) {
398
619
return Err(SendError::InvalidRecipient(format!(
399
-
"Invalid phone number format: {}",
620
+
"Invalid Signal username format: {}",
400
621
recipient
401
622
)));
402
623
}
403
624
let subject = notification.subject.as_deref().unwrap_or("Notification");
404
625
let message = format!("{}\n\n{}", subject, notification.body);
405
-
let output = Command::new(&self.signal_cli_path)
406
-
.arg("-u")
407
-
.arg(&self.sender_number)
408
-
.arg("send")
409
-
.arg("-m")
410
-
.arg(&message)
411
-
.arg(recipient)
412
-
.output()
413
-
.await?;
414
-
if !output.status.success() {
415
-
let stderr = String::from_utf8_lossy(&output.stderr);
416
-
return Err(SendError::ExternalService(format!(
417
-
"signal-cli failed: {}",
418
-
stderr
419
-
)));
626
+
627
+
let mut last_error = None;
628
+
for attempt in 0..MAX_RETRIES {
629
+
let cmd_future = Command::new(&self.signal_cli_path)
630
+
.arg("-u")
631
+
.arg(&self.sender_number)
632
+
.arg("send")
633
+
.arg("--username")
634
+
.arg(recipient)
635
+
.arg("-m")
636
+
.arg(&message)
637
+
.output();
638
+
639
+
let result = timeout(Duration::from_secs(SIGNAL_TIMEOUT_SECS), cmd_future).await;
640
+
641
+
match result {
642
+
Ok(Ok(output)) if output.status.success() => return Ok(()),
643
+
Ok(Ok(output)) => {
644
+
let stderr = String::from_utf8_lossy(&output.stderr);
645
+
if is_retryable_signal_error(&stderr) && attempt < MAX_RETRIES - 1 {
646
+
last_error = Some(format!("signal-cli failed: {}", stderr));
647
+
retry_delay(attempt).await;
648
+
continue;
649
+
}
650
+
return Err(SendError::ExternalService(format!(
651
+
"signal-cli failed: {}",
652
+
stderr
653
+
)));
654
+
}
655
+
Ok(Err(e)) => {
656
+
if attempt < MAX_RETRIES - 1 {
657
+
last_error = Some(format!("signal-cli spawn failed: {}", e));
658
+
retry_delay(attempt).await;
659
+
continue;
660
+
}
661
+
return Err(SendError::ProcessSpawn(e));
662
+
}
663
+
Err(_) => {
664
+
if attempt < MAX_RETRIES - 1 {
665
+
last_error = Some("signal-cli timed out".to_string());
666
+
retry_delay(attempt).await;
667
+
continue;
668
+
}
669
+
return Err(SendError::Timeout);
670
+
}
671
+
}
420
672
}
421
-
Ok(())
673
+
Err(SendError::MaxRetriesExceeded(
674
+
last_error.unwrap_or_else(|| "Unknown error".to_string()),
675
+
))
422
676
}
423
677
}
+6
crates/tranquil-db-traits/src/error.rs
+6
crates/tranquil-db-traits/src/error.rs
+1
crates/tranquil-db-traits/src/repo.rs
+1
crates/tranquil-db-traits/src/repo.rs
+33
-13
crates/tranquil-db-traits/src/user.rs
+33
-13
crates/tranquil-db-traits/src/user.rs
···
228
228
229
229
async fn clear_signal(&self, user_id: Uuid) -> Result<(), DbError>;
230
230
231
+
async fn set_unverified_signal(
232
+
&self,
233
+
user_id: Uuid,
234
+
signal_username: &str,
235
+
) -> Result<(), DbError>;
236
+
231
237
async fn set_unverified_telegram(
232
238
&self,
233
239
user_id: Uuid,
···
243
249
244
250
async fn get_telegram_chat_id(&self, user_id: Uuid) -> Result<Option<i64>, DbError>;
245
251
252
+
async fn set_unverified_discord(
253
+
&self,
254
+
user_id: Uuid,
255
+
discord_username: &str,
256
+
) -> Result<(), DbError>;
257
+
258
+
async fn store_discord_user_id(
259
+
&self,
260
+
discord_username: &str,
261
+
discord_id: &str,
262
+
handle: Option<&str>,
263
+
) -> Result<Option<Uuid>, DbError>;
264
+
246
265
async fn get_verification_info(
247
266
&self,
248
267
did: &Did,
···
261
280
async fn verify_signal_channel(
262
281
&self,
263
282
user_id: Uuid,
264
-
signal_number: &str,
283
+
signal_username: &str,
265
284
) -> Result<(), DbError>;
266
285
267
286
async fn set_email_verified_flag(&self, user_id: Uuid) -> Result<(), DbError>;
···
598
617
pub preferred_locale: Option<String>,
599
618
pub telegram_chat_id: Option<i64>,
600
619
pub discord_id: Option<String>,
601
-
pub signal_number: Option<String>,
620
+
pub signal_username: Option<String>,
602
621
}
603
622
604
623
#[derive(Debug, Clone)]
···
656
675
pub email: String,
657
676
pub preferred_channel: CommsChannel,
658
677
pub discord_id: Option<String>,
678
+
pub discord_username: Option<String>,
659
679
pub discord_verified: bool,
660
680
pub telegram_username: Option<String>,
661
681
pub telegram_verified: bool,
662
682
pub telegram_chat_id: Option<i64>,
663
-
pub signal_number: Option<String>,
683
+
pub signal_username: Option<String>,
664
684
pub signal_verified: bool,
665
685
}
666
686
···
829
849
pub handle: Handle,
830
850
pub email: Option<String>,
831
851
pub channel: CommsChannel,
832
-
pub discord_id: Option<String>,
852
+
pub discord_username: Option<String>,
833
853
pub telegram_username: Option<String>,
834
-
pub signal_number: Option<String>,
854
+
pub signal_username: Option<String>,
835
855
pub key_bytes: Vec<u8>,
836
856
pub encryption_version: Option<i32>,
837
857
}
···
842
862
pub handle: Handle,
843
863
pub email: Option<String>,
844
864
pub channel: CommsChannel,
845
-
pub discord_id: Option<String>,
865
+
pub discord_username: Option<String>,
846
866
pub telegram_username: Option<String>,
847
-
pub signal_number: Option<String>,
867
+
pub signal_username: Option<String>,
848
868
pub channel_verification: ChannelVerificationStatus,
849
869
}
850
870
···
932
952
pub did: Did,
933
953
pub password_hash: String,
934
954
pub preferred_comms_channel: CommsChannel,
935
-
pub discord_id: Option<String>,
955
+
pub discord_username: Option<String>,
936
956
pub telegram_username: Option<String>,
937
-
pub signal_number: Option<String>,
957
+
pub signal_username: Option<String>,
938
958
pub deactivated_at: Option<DateTime<Utc>>,
939
959
pub encrypted_key_bytes: Vec<u8>,
940
960
pub encryption_version: i32,
···
982
1002
pub email: String,
983
1003
pub did: Did,
984
1004
pub preferred_comms_channel: CommsChannel,
985
-
pub discord_id: Option<String>,
1005
+
pub discord_username: Option<String>,
986
1006
pub telegram_username: Option<String>,
987
-
pub signal_number: Option<String>,
1007
+
pub signal_username: Option<String>,
988
1008
pub setup_token_hash: String,
989
1009
pub setup_expires_at: DateTime<Utc>,
990
1010
pub deactivated_at: Option<DateTime<Utc>>,
···
1004
1024
pub email: Option<String>,
1005
1025
pub did: Did,
1006
1026
pub preferred_comms_channel: CommsChannel,
1007
-
pub discord_id: Option<String>,
1027
+
pub discord_username: Option<String>,
1008
1028
pub telegram_username: Option<String>,
1009
-
pub signal_number: Option<String>,
1029
+
pub signal_username: Option<String>,
1010
1030
pub encrypted_key_bytes: Vec<u8>,
1011
1031
pub encryption_version: i32,
1012
1032
pub commit_cid: String,
+10
-2
crates/tranquil-db/src/postgres/repo.rs
+10
-2
crates/tranquil-db/src/postgres/repo.rs
···
1131
1131
user_id: Uuid,
1132
1132
blocks: &[ImportBlock],
1133
1133
records: &[ImportRecord],
1134
+
expected_root_cid: Option<&CidLink>,
1134
1135
) -> Result<(), ImportRepoError> {
1135
1136
let mut tx = self
1136
1137
.pool
···
1153
1154
ImportRepoError::Database(e.to_string())
1154
1155
})?;
1155
1156
1156
-
if repo.is_none() {
1157
-
return Err(ImportRepoError::RepoNotFound);
1157
+
let repo = match repo {
1158
+
Some(r) => r,
1159
+
None => return Err(ImportRepoError::RepoNotFound),
1160
+
};
1161
+
1162
+
if let Some(expected) = expected_root_cid {
1163
+
if repo.repo_root_cid.as_str() != expected.as_str() {
1164
+
return Err(ImportRepoError::ConcurrentModification);
1165
+
}
1158
1166
}
1159
1167
1160
1168
let block_chunks: Vec<Vec<&ImportBlock>> = blocks
+131
-26
crates/tranquil-db/src/postgres/user.rs
+131
-26
crates/tranquil-db/src/postgres/user.rs
···
311
311
312
312
async fn get_comms_prefs(&self, user_id: Uuid) -> Result<Option<UserCommsPrefs>, DbError> {
313
313
let row = sqlx::query!(
314
-
r#"SELECT email, handle, preferred_comms_channel as "preferred_channel!: CommsChannel", preferred_locale, telegram_chat_id, discord_id, signal_number
314
+
r#"SELECT email, handle, preferred_comms_channel as "preferred_channel!: CommsChannel", preferred_locale, telegram_chat_id, discord_id, signal_username
315
315
FROM users WHERE id = $1"#,
316
316
user_id
317
317
)
···
325
325
preferred_locale: r.preferred_locale,
326
326
telegram_chat_id: r.telegram_chat_id,
327
327
discord_id: r.discord_id,
328
-
signal_number: r.signal_number,
328
+
signal_username: r.signal_username,
329
329
}))
330
330
}
331
331
···
636
636
email,
637
637
preferred_comms_channel as "preferred_channel!: CommsChannel",
638
638
discord_id,
639
+
discord_username,
639
640
discord_verified,
640
641
telegram_username,
641
642
telegram_verified,
642
643
telegram_chat_id,
643
-
signal_number,
644
+
signal_username,
644
645
signal_verified
645
646
FROM users WHERE did = $1"#,
646
647
did.as_str()
···
652
653
email: r.email.unwrap_or_default(),
653
654
preferred_channel: r.preferred_channel,
654
655
discord_id: r.discord_id,
656
+
discord_username: r.discord_username,
655
657
discord_verified: r.discord_verified,
656
658
telegram_username: r.telegram_username,
657
659
telegram_verified: r.telegram_verified,
658
660
telegram_chat_id: r.telegram_chat_id,
659
-
signal_number: r.signal_number,
661
+
signal_username: r.signal_username,
660
662
signal_verified: r.signal_verified,
661
663
}))
662
664
}
···
697
699
698
700
async fn clear_discord(&self, user_id: Uuid) -> Result<(), DbError> {
699
701
sqlx::query!(
700
-
"UPDATE users SET discord_id = NULL, discord_verified = FALSE, updated_at = NOW() WHERE id = $1",
702
+
"UPDATE users SET discord_id = NULL, discord_username = NULL, discord_verified = FALSE, updated_at = NOW() WHERE id = $1",
701
703
user_id
702
704
)
703
705
.execute(&self.pool)
···
719
721
720
722
async fn clear_signal(&self, user_id: Uuid) -> Result<(), DbError> {
721
723
sqlx::query!(
722
-
"UPDATE users SET signal_number = NULL, signal_verified = FALSE, updated_at = NOW() WHERE id = $1",
724
+
"UPDATE users SET signal_username = NULL, signal_verified = FALSE, updated_at = NOW() WHERE id = $1",
723
725
user_id
724
726
)
725
727
.execute(&self.pool)
···
796
798
async fn verify_signal_channel(
797
799
&self,
798
800
user_id: Uuid,
799
-
signal_number: &str,
801
+
signal_username: &str,
800
802
) -> Result<(), DbError> {
801
803
sqlx::query!(
802
-
"UPDATE users SET signal_number = $1, signal_verified = TRUE, updated_at = NOW() WHERE id = $2",
803
-
signal_number,
804
+
"UPDATE users SET signal_username = $1, signal_verified = TRUE, updated_at = NOW() WHERE id = $2",
805
+
signal_username,
804
806
user_id
805
807
)
806
808
.execute(&self.pool)
···
1548
1550
r#"SELECT
1549
1551
u.id, u.did, u.handle, u.email,
1550
1552
u.preferred_comms_channel as "channel: CommsChannel",
1551
-
u.discord_id, u.telegram_username, u.signal_number,
1553
+
u.discord_username, u.telegram_username, u.signal_username,
1552
1554
k.key_bytes, k.encryption_version
1553
1555
FROM users u
1554
1556
JOIN user_keys k ON u.id = k.user_id
···
1565
1567
handle: Handle::from(row.handle),
1566
1568
email: row.email,
1567
1569
channel: row.channel,
1568
-
discord_id: row.discord_id,
1570
+
discord_username: row.discord_username,
1569
1571
telegram_username: row.telegram_username,
1570
-
signal_number: row.signal_number,
1572
+
signal_username: row.signal_username,
1571
1573
key_bytes: row.key_bytes,
1572
1574
encryption_version: row.encryption_version,
1573
1575
})
···
1582
1584
r#"SELECT
1583
1585
id, handle, email,
1584
1586
preferred_comms_channel as "channel: CommsChannel",
1585
-
discord_id, telegram_username, signal_number,
1587
+
discord_username, telegram_username, signal_username,
1586
1588
email_verified, discord_verified, telegram_verified, signal_verified
1587
1589
FROM users
1588
1590
WHERE did = $1"#,
···
1597
1599
handle: Handle::from(row.handle),
1598
1600
email: row.email,
1599
1601
channel: row.channel,
1600
-
discord_id: row.discord_id,
1602
+
discord_username: row.discord_username,
1601
1603
telegram_username: row.telegram_username,
1602
-
signal_number: row.signal_number,
1604
+
signal_username: row.signal_username,
1603
1605
channel_verification: ChannelVerificationStatus::new(
1604
1606
row.email_verified,
1605
1607
row.discord_verified,
···
1662
1664
"SELECT COUNT(*) FROM users WHERE LOWER(email) = LOWER($1) AND deactivated_at IS NULL"
1663
1665
}
1664
1666
CommsChannel::Discord => {
1665
-
"SELECT COUNT(*) FROM users WHERE discord_id = $1 AND deactivated_at IS NULL"
1667
+
"SELECT COUNT(*) FROM users WHERE LOWER(discord_username) = LOWER($1) AND deactivated_at IS NULL"
1666
1668
}
1667
1669
CommsChannel::Telegram => {
1668
1670
"SELECT COUNT(*) FROM users WHERE LOWER(telegram_username) = LOWER($1) AND deactivated_at IS NULL"
1669
1671
}
1670
1672
CommsChannel::Signal => {
1671
-
"SELECT COUNT(*) FROM users WHERE signal_number = $1 AND deactivated_at IS NULL"
1673
+
"SELECT COUNT(*) FROM users WHERE signal_username = $1 AND deactivated_at IS NULL"
1672
1674
}
1673
1675
};
1674
1676
sqlx::query_scalar(query)
···
2385
2387
r#"INSERT INTO users (
2386
2388
handle, email, did, password_hash,
2387
2389
preferred_comms_channel,
2388
-
discord_id, telegram_username, signal_number,
2390
+
discord_username, telegram_username, signal_username,
2389
2391
is_admin, deactivated_at, email_verified
2390
2392
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, FALSE) RETURNING id"#,
2391
2393
)
···
2394
2396
.bind(input.did.as_str())
2395
2397
.bind(&input.password_hash)
2396
2398
.bind(input.preferred_comms_channel)
2397
-
.bind(&input.discord_id)
2399
+
.bind(&input.discord_username)
2398
2400
.bind(&input.telegram_username)
2399
-
.bind(&input.signal_number)
2401
+
.bind(&input.signal_username)
2400
2402
.bind(is_first_user)
2401
2403
.bind(input.deactivated_at)
2402
2404
.fetch_one(&mut *tx)
···
2652
2654
r#"INSERT INTO users (
2653
2655
handle, email, did, password_hash, password_required,
2654
2656
preferred_comms_channel,
2655
-
discord_id, telegram_username, signal_number,
2657
+
discord_username, telegram_username, signal_username,
2656
2658
recovery_token, recovery_token_expires_at,
2657
2659
is_admin, deactivated_at
2658
2660
) VALUES ($1, $2, $3, NULL, FALSE, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id"#,
···
2661
2663
.bind(&input.email)
2662
2664
.bind(input.did.as_str())
2663
2665
.bind(input.preferred_comms_channel)
2664
-
.bind(&input.discord_id)
2666
+
.bind(&input.discord_username)
2665
2667
.bind(&input.telegram_username)
2666
-
.bind(&input.signal_number)
2668
+
.bind(&input.signal_username)
2667
2669
.bind(&input.setup_token_hash)
2668
2670
.bind(input.setup_expires_at)
2669
2671
.bind(is_first_user)
···
2816
2818
let user_insert: Result<(uuid::Uuid,), _> = sqlx::query_as(
2817
2819
r#"INSERT INTO users (
2818
2820
handle, email, did, password_hash, password_required,
2819
-
preferred_comms_channel, discord_id, telegram_username, signal_number,
2821
+
preferred_comms_channel, discord_username, telegram_username, signal_username,
2820
2822
is_admin
2821
2823
) VALUES ($1, $2, $3, NULL, FALSE, $4, $5, $6, $7, $8) RETURNING id"#,
2822
2824
)
···
2824
2826
.bind(&input.email)
2825
2827
.bind(input.did.as_str())
2826
2828
.bind(input.preferred_comms_channel)
2827
-
.bind(&input.discord_id)
2829
+
.bind(&input.discord_username)
2828
2830
.bind(&input.telegram_username)
2829
-
.bind(&input.signal_number)
2831
+
.bind(&input.signal_username)
2830
2832
.bind(is_first_user)
2831
2833
.fetch_one(&mut *tx)
2832
2834
.await;
···
3190
3192
Ok(())
3191
3193
}
3192
3194
3195
+
async fn set_unverified_signal(
3196
+
&self,
3197
+
user_id: Uuid,
3198
+
signal_username: &str,
3199
+
) -> Result<(), DbError> {
3200
+
sqlx::query!(
3201
+
r#"UPDATE users SET
3202
+
signal_username = $1,
3203
+
signal_verified = CASE WHEN LOWER(signal_username) = LOWER($1) THEN signal_verified ELSE FALSE END,
3204
+
updated_at = NOW()
3205
+
WHERE id = $2"#,
3206
+
signal_username,
3207
+
user_id
3208
+
)
3209
+
.execute(&self.pool)
3210
+
.await
3211
+
.map_err(map_sqlx_error)?;
3212
+
Ok(())
3213
+
}
3214
+
3215
+
async fn set_unverified_discord(
3216
+
&self,
3217
+
user_id: Uuid,
3218
+
discord_username: &str,
3219
+
) -> Result<(), DbError> {
3220
+
sqlx::query!(
3221
+
r#"UPDATE users SET
3222
+
discord_username = $1,
3223
+
discord_verified = CASE WHEN LOWER(discord_username) = LOWER($1) THEN discord_verified ELSE FALSE END,
3224
+
discord_id = CASE WHEN LOWER(discord_username) = LOWER($1) THEN discord_id ELSE NULL END,
3225
+
updated_at = NOW()
3226
+
WHERE id = $2"#,
3227
+
discord_username,
3228
+
user_id
3229
+
)
3230
+
.execute(&self.pool)
3231
+
.await
3232
+
.map_err(map_sqlx_error)?;
3233
+
Ok(())
3234
+
}
3235
+
3236
+
async fn store_discord_user_id(
3237
+
&self,
3238
+
discord_username: &str,
3239
+
discord_id: &str,
3240
+
handle: Option<&str>,
3241
+
) -> Result<Option<Uuid>, DbError> {
3242
+
let result = match handle {
3243
+
Some(h) => sqlx::query_scalar!(
3244
+
"UPDATE users SET discord_id = $2, discord_verified = TRUE, updated_at = NOW() WHERE LOWER(discord_username) = LOWER($1) AND discord_username IS NOT NULL AND handle = $3 RETURNING id",
3245
+
discord_username,
3246
+
discord_id,
3247
+
h
3248
+
)
3249
+
.fetch_optional(&self.pool)
3250
+
.await
3251
+
.map_err(map_sqlx_error)?,
3252
+
None => {
3253
+
let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?;
3254
+
3255
+
let matching: Vec<uuid::Uuid> = match sqlx::query_scalar!(
3256
+
"SELECT id FROM users WHERE LOWER(discord_username) = LOWER($1) AND discord_username IS NOT NULL AND deactivated_at IS NULL FOR UPDATE NOWAIT",
3257
+
discord_username
3258
+
)
3259
+
.fetch_all(&mut *tx)
3260
+
.await
3261
+
{
3262
+
Ok(ids) => ids,
3263
+
Err(sqlx::Error::Database(ref db_err))
3264
+
if db_err.code().as_deref() == Some("55P03") =>
3265
+
{
3266
+
return Err(DbError::LockContention);
3267
+
}
3268
+
Err(e) => return Err(map_sqlx_error(e)),
3269
+
};
3270
+
3271
+
let result = match matching.len() {
3272
+
0 => None,
3273
+
1 => {
3274
+
sqlx::query_scalar!(
3275
+
"UPDATE users SET discord_id = $2, discord_verified = TRUE, updated_at = NOW() WHERE id = $1 RETURNING id",
3276
+
matching[0],
3277
+
discord_id
3278
+
)
3279
+
.fetch_optional(&mut *tx)
3280
+
.await
3281
+
.map_err(map_sqlx_error)?
3282
+
}
3283
+
_ => {
3284
+
tx.rollback().await.ok();
3285
+
return Err(DbError::Ambiguous(
3286
+
"Multiple accounts use this Discord username. Type: /start your-handle.example.com".to_string(),
3287
+
));
3288
+
}
3289
+
};
3290
+
3291
+
tx.commit().await.map_err(map_sqlx_error)?;
3292
+
result
3293
+
}
3294
+
};
3295
+
Ok(result)
3296
+
}
3297
+
3193
3298
async fn store_telegram_chat_id(
3194
3299
&self,
3195
3300
telegram_username: &str,
+2
crates/tranquil-pds/Cargo.toml
+2
crates/tranquil-pds/Cargo.toml
···
32
32
chrono = { workspace = true }
33
33
cid = { workspace = true }
34
34
dotenvy = { workspace = true }
35
+
ed25519-dalek = { workspace = true }
35
36
futures = { workspace = true }
37
+
hex = { workspace = true }
36
38
futures-util = { workspace = true }
37
39
governor = { workspace = true }
38
40
hickory-resolver = { workspace = true }
+301
crates/tranquil-pds/src/api/discord_webhook.rs
+301
crates/tranquil-pds/src/api/discord_webhook.rs
···
1
+
use axum::{
2
+
Json,
3
+
extract::State,
4
+
http::StatusCode,
5
+
response::{IntoResponse, Response},
6
+
};
7
+
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
8
+
use serde::Deserialize;
9
+
use serde_json::json;
10
+
use tracing::{debug, info, warn};
11
+
use tranquil_types::Handle;
12
+
13
+
use crate::comms::comms_repo;
14
+
use crate::state::AppState;
15
+
use crate::util::{discord_public_key, pds_hostname};
16
+
17
+
#[derive(Deserialize)]
18
+
struct Interaction {
19
+
#[serde(rename = "type")]
20
+
interaction_type: u8,
21
+
data: Option<InteractionData>,
22
+
member: Option<InteractionMember>,
23
+
user: Option<InteractionUser>,
24
+
}
25
+
26
+
#[derive(Deserialize)]
27
+
struct InteractionData {
28
+
name: Option<String>,
29
+
options: Option<Vec<InteractionOption>>,
30
+
}
31
+
32
+
#[derive(Deserialize)]
33
+
struct InteractionOption {
34
+
name: String,
35
+
value: serde_json::Value,
36
+
}
37
+
38
+
#[derive(Deserialize)]
39
+
struct InteractionMember {
40
+
user: Option<InteractionUser>,
41
+
}
42
+
43
+
#[derive(Deserialize)]
44
+
struct InteractionUser {
45
+
id: String,
46
+
username: Option<String>,
47
+
}
48
+
49
+
fn verify_signature(
50
+
public_key: &VerifyingKey,
51
+
timestamp: &str,
52
+
body: &str,
53
+
signature: &str,
54
+
) -> bool {
55
+
let sig_bytes = match hex::decode(signature) {
56
+
Ok(b) => b,
57
+
Err(_) => return false,
58
+
};
59
+
let signature = match Signature::from_slice(&sig_bytes) {
60
+
Ok(s) => s,
61
+
Err(_) => return false,
62
+
};
63
+
let message = format!("{}{}", timestamp, body);
64
+
public_key.verify(message.as_bytes(), &signature).is_ok()
65
+
}
66
+
67
+
fn parse_start_handle(options: Option<&[InteractionOption]>) -> Option<String> {
68
+
options
69
+
.and_then(|opts| opts.iter().find(|o| o.name == "handle"))
70
+
.and_then(|o| o.value.as_str())
71
+
.map(|s| s.trim())
72
+
.filter(|s| !s.is_empty())
73
+
.map(|s| s.to_string())
74
+
}
75
+
76
+
pub async fn handle_discord_webhook(
77
+
State(state): State<AppState>,
78
+
headers: axum::http::HeaderMap,
79
+
body: String,
80
+
) -> Response {
81
+
let public_key = match discord_public_key() {
82
+
Some(pk) => pk,
83
+
None => {
84
+
warn!("Discord webhook called but public key is not configured");
85
+
return StatusCode::FORBIDDEN.into_response();
86
+
}
87
+
};
88
+
89
+
let signature = headers
90
+
.get("x-signature-ed25519")
91
+
.and_then(|v| v.to_str().ok())
92
+
.unwrap_or_default();
93
+
let timestamp = headers
94
+
.get("x-signature-timestamp")
95
+
.and_then(|v| v.to_str().ok())
96
+
.unwrap_or_default();
97
+
98
+
if !verify_signature(public_key, timestamp, &body, signature) {
99
+
return StatusCode::UNAUTHORIZED.into_response();
100
+
}
101
+
102
+
let interaction: Interaction = match serde_json::from_str(&body) {
103
+
Ok(i) => i,
104
+
Err(_) => return StatusCode::BAD_REQUEST.into_response(),
105
+
};
106
+
107
+
match interaction.interaction_type {
108
+
1 => Json(json!({"type": 1})).into_response(),
109
+
2 => handle_command(state, interaction).await,
110
+
other => {
111
+
debug!(interaction_type = other, "Received unknown Discord interaction type");
112
+
StatusCode::OK.into_response()
113
+
}
114
+
}
115
+
}
116
+
117
+
async fn handle_command(state: AppState, interaction: Interaction) -> Response {
118
+
let command_name = interaction
119
+
.data
120
+
.as_ref()
121
+
.and_then(|d| d.name.as_deref())
122
+
.unwrap_or_default();
123
+
124
+
if command_name != "start" {
125
+
return Json(json!({
126
+
"type": 4,
127
+
"data": {"content": "Unknown command", "flags": 64}
128
+
}))
129
+
.into_response();
130
+
}
131
+
132
+
let user = interaction
133
+
.member
134
+
.as_ref()
135
+
.and_then(|m| m.user.as_ref())
136
+
.or(interaction.user.as_ref());
137
+
138
+
let (discord_user_id, discord_username) = match user {
139
+
Some(u) => (u.id.clone(), u.username.clone().unwrap_or_default()),
140
+
None => {
141
+
return Json(json!({
142
+
"type": 4,
143
+
"data": {"content": "Could not identify user", "flags": 64}
144
+
}))
145
+
.into_response();
146
+
}
147
+
};
148
+
149
+
let handle = parse_start_handle(interaction.data.as_ref().and_then(|d| d.options.as_deref()));
150
+
151
+
if let Some(ref h) = handle {
152
+
if Handle::new(h).is_err() {
153
+
return Json(json!({
154
+
"type": 4,
155
+
"data": {"content": "Invalid handle format. Handle should look like: alice.example.com", "flags": 64}
156
+
}))
157
+
.into_response();
158
+
}
159
+
}
160
+
161
+
debug!(
162
+
discord_username = %discord_username,
163
+
discord_user_id = %discord_user_id,
164
+
handle = ?handle,
165
+
"Received /start from Discord user"
166
+
);
167
+
168
+
match state
169
+
.user_repo
170
+
.store_discord_user_id(&discord_username, &discord_user_id, handle.as_deref())
171
+
.await
172
+
{
173
+
Ok(Some(user_id)) => {
174
+
info!(
175
+
discord_username = %discord_username,
176
+
discord_user_id = %discord_user_id,
177
+
"Verified Discord user and stored user ID"
178
+
);
179
+
if let Err(e) = comms_repo::enqueue_channel_verified(
180
+
state.user_repo.as_ref(),
181
+
state.infra_repo.as_ref(),
182
+
user_id,
183
+
"discord",
184
+
&discord_user_id,
185
+
pds_hostname(),
186
+
)
187
+
.await
188
+
{
189
+
warn!(error = %e, "Failed to enqueue channel verified notification");
190
+
}
191
+
Json(json!({
192
+
"type": 4,
193
+
"data": {"content": "Verified", "flags": 64}
194
+
}))
195
+
.into_response()
196
+
}
197
+
Ok(None) => {
198
+
debug!(
199
+
discord_username = %discord_username,
200
+
"No matching user found for Discord username"
201
+
);
202
+
Json(json!({
203
+
"type": 4,
204
+
"data": {"content": "No account found with this Discord username. Set your Discord username in your PDS settings first.", "flags": 64}
205
+
}))
206
+
.into_response()
207
+
}
208
+
Err(tranquil_db_traits::DbError::Ambiguous(msg)) => {
209
+
debug!(
210
+
discord_username = %discord_username,
211
+
"Ambiguous Discord username match"
212
+
);
213
+
Json(json!({
214
+
"type": 4,
215
+
"data": {"content": msg, "flags": 64}
216
+
}))
217
+
.into_response()
218
+
}
219
+
Err(tranquil_db_traits::DbError::LockContention) => {
220
+
debug!(
221
+
discord_username = %discord_username,
222
+
"Lock contention during Discord verification"
223
+
);
224
+
Json(json!({
225
+
"type": 4,
226
+
"data": {"content": "Server busy, please try again in a moment.", "flags": 64}
227
+
}))
228
+
.into_response()
229
+
}
230
+
Err(e) => {
231
+
warn!(
232
+
discord_username = %discord_username,
233
+
error = %e,
234
+
"Failed to store Discord user ID"
235
+
);
236
+
Json(json!({
237
+
"type": 4,
238
+
"data": {"content": "Verification failed. Try again later.", "flags": 64}
239
+
}))
240
+
.into_response()
241
+
}
242
+
}
243
+
}
244
+
245
+
#[cfg(test)]
246
+
mod tests {
247
+
use super::*;
248
+
249
+
#[test]
250
+
fn parse_handle_from_options() {
251
+
let options = vec![InteractionOption {
252
+
name: "handle".to_string(),
253
+
value: serde_json::json!("lewis.buttercup.wizardry.systems"),
254
+
}];
255
+
assert_eq!(
256
+
parse_start_handle(Some(&options)),
257
+
Some("lewis.buttercup.wizardry.systems".to_string()),
258
+
);
259
+
}
260
+
261
+
#[test]
262
+
fn parse_handle_no_options() {
263
+
assert_eq!(parse_start_handle(None), None);
264
+
}
265
+
266
+
#[test]
267
+
fn parse_handle_empty_options() {
268
+
let options: Vec<InteractionOption> = vec![];
269
+
assert_eq!(parse_start_handle(Some(&options)), None);
270
+
}
271
+
272
+
#[test]
273
+
fn parse_handle_wrong_option_name() {
274
+
let options = vec![InteractionOption {
275
+
name: "other".to_string(),
276
+
value: serde_json::json!("test"),
277
+
}];
278
+
assert_eq!(parse_start_handle(Some(&options)), None);
279
+
}
280
+
281
+
#[test]
282
+
fn parse_handle_empty_string() {
283
+
let options = vec![InteractionOption {
284
+
name: "handle".to_string(),
285
+
value: serde_json::json!(""),
286
+
}];
287
+
assert_eq!(parse_start_handle(Some(&options)), None);
288
+
}
289
+
290
+
#[test]
291
+
fn parse_handle_whitespace_trimmed() {
292
+
let options = vec![InteractionOption {
293
+
name: "handle".to_string(),
294
+
value: serde_json::json!(" alice.example.com "),
295
+
}];
296
+
assert_eq!(
297
+
parse_start_handle(Some(&options)),
298
+
Some("alice.example.com".to_string()),
299
+
);
300
+
}
301
+
}
+1
-1
crates/tranquil-pds/src/api/error.rs
+1
-1
crates/tranquil-pds/src/api/error.rs
···
409
409
Some("Telegram username is required when using Telegram verification".to_string())
410
410
}
411
411
Self::MissingSignalNumber => {
412
-
Some("Signal phone number is required when using Signal verification".to_string())
412
+
Some("Signal username is required when using Signal verification".to_string())
413
413
}
414
414
Self::InvalidVerificationChannel => Some("Invalid verification channel".to_string()),
415
415
Self::SelfHostedDidWebDisabled => {
+25
-15
crates/tranquil-pds/src/api/identity/account.rs
+25
-15
crates/tranquil-pds/src/api/identity/account.rs
···
35
35
pub did_type: Option<String>,
36
36
pub signing_key: Option<String>,
37
37
pub verification_channel: Option<String>,
38
-
pub discord_id: Option<String>,
38
+
pub discord_username: Option<String>,
39
39
pub telegram_username: Option<String>,
40
-
pub signal_number: Option<String>,
40
+
pub signal_username: Option<String>,
41
41
}
42
42
43
43
#[derive(Serialize)]
···
203
203
Some(email) if !email.trim().is_empty() => email.trim().to_string(),
204
204
_ => return ApiError::MissingEmail.into_response(),
205
205
},
206
-
"discord" => match &input.discord_id {
207
-
Some(id) if !id.trim().is_empty() => id.trim().to_string(),
206
+
"discord" => match &input.discord_username {
207
+
Some(username) if !username.trim().is_empty() => {
208
+
let clean = username.trim().to_lowercase();
209
+
if !crate::api::validation::is_valid_discord_username(&clean) {
210
+
return ApiError::InvalidRequest(
211
+
"Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(),
212
+
).into_response();
213
+
}
214
+
clean
215
+
}
208
216
_ => return ApiError::MissingDiscordId.into_response(),
209
217
},
210
218
"telegram" => match &input.telegram_username {
···
219
227
}
220
228
_ => return ApiError::MissingTelegramUsername.into_response(),
221
229
},
222
-
"signal" => match &input.signal_number {
223
-
Some(number) if !number.trim().is_empty() => number.trim().to_string(),
230
+
"signal" => match &input.signal_username {
231
+
Some(username) if !username.trim().is_empty() => {
232
+
username.trim().trim_start_matches('@').to_lowercase()
233
+
}
224
234
_ => return ApiError::MissingSignalNumber.into_response(),
225
235
},
226
236
_ => return ApiError::InvalidVerificationChannel.into_response(),
···
633
643
did: unsafe { Did::new_unchecked(&did) },
634
644
password_hash,
635
645
preferred_comms_channel,
636
-
discord_id: input
637
-
.discord_id
646
+
discord_username: input
647
+
.discord_username
638
648
.as_deref()
639
-
.map(|s| s.trim())
640
-
.filter(|s| !s.is_empty())
641
-
.map(String::from),
649
+
.map(|s| s.trim().to_lowercase())
650
+
.filter(|s| !s.is_empty()),
642
651
telegram_username: input
643
652
.telegram_username
644
653
.as_deref()
645
654
.map(|s| s.trim().trim_start_matches('@'))
646
655
.filter(|s| !s.is_empty())
647
656
.map(String::from),
648
-
signal_number: input
649
-
.signal_number
657
+
signal_username: input
658
+
.signal_username
650
659
.as_deref()
651
-
.map(|s| s.trim())
660
+
.map(|s| s.trim().trim_start_matches('@'))
652
661
.filter(|s| !s.is_empty())
653
-
.map(String::from),
662
+
.map(|s| s.to_lowercase()),
654
663
deactivated_at,
655
664
encrypted_key_bytes,
656
665
encryption_version: crate::config::ENCRYPTION_VERSION,
···
750
759
let formatted_token =
751
760
crate::auth::verification_token::format_token_for_display(&verification_token);
752
761
if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification(
762
+
state.user_repo.as_ref(),
753
763
state.infra_repo.as_ref(),
754
764
user_id,
755
765
verification_channel,
+1
crates/tranquil-pds/src/api/mod.rs
+1
crates/tranquil-pds/src/api/mod.rs
+55
-21
crates/tranquil-pds/src/api/notification_prefs.rs
+55
-21
crates/tranquil-pds/src/api/notification_prefs.rs
···
17
17
pub struct NotificationPrefsResponse {
18
18
pub preferred_channel: CommsChannel,
19
19
pub email: String,
20
-
pub discord_id: Option<String>,
20
+
pub discord_username: Option<String>,
21
21
pub discord_verified: bool,
22
22
pub telegram_username: Option<String>,
23
23
pub telegram_verified: bool,
24
-
pub signal_number: Option<String>,
24
+
pub signal_username: Option<String>,
25
25
pub signal_verified: bool,
26
26
}
27
27
···
38
38
Ok(Json(NotificationPrefsResponse {
39
39
preferred_channel: prefs.preferred_channel,
40
40
email: prefs.email,
41
-
discord_id: prefs.discord_id,
41
+
discord_username: prefs.discord_username,
42
42
discord_verified: prefs.discord_verified,
43
43
telegram_username: prefs.telegram_username,
44
44
telegram_verified: prefs.telegram_verified,
45
-
signal_number: prefs.signal_number,
45
+
signal_username: prefs.signal_username,
46
46
signal_verified: prefs.signal_verified,
47
47
})
48
48
.into_response())
···
120
120
pub struct UpdateNotificationPrefsInput {
121
121
pub preferred_channel: Option<String>,
122
122
pub email: Option<String>,
123
-
pub discord_id: Option<String>,
123
+
pub discord_username: Option<String>,
124
124
pub telegram_username: Option<String>,
125
-
pub signal_number: Option<String>,
125
+
pub signal_username: Option<String>,
126
126
}
127
127
128
128
#[derive(Serialize)]
···
172
172
"https://{}/app/verify?token={}&identifier={}",
173
173
hostname, encoded_token, encoded_identifier
174
174
);
175
-
let body = format!(
176
-
"Your verification code is: {}\n\nOr verify directly:\n{}",
177
-
formatted_token, verify_link
175
+
let prefs = state
176
+
.user_repo
177
+
.get_comms_prefs(user_id)
178
+
.await
179
+
.ok()
180
+
.flatten();
181
+
let locale = prefs
182
+
.as_ref()
183
+
.and_then(|p| p.preferred_locale.as_deref())
184
+
.unwrap_or("en");
185
+
let strings = crate::comms::get_strings(locale);
186
+
let body = crate::comms::format_message(
187
+
strings.channel_verification_body,
188
+
&[("code", &formatted_token), ("verify_link", &verify_link)],
189
+
);
190
+
let subject = crate::comms::format_message(
191
+
strings.channel_verification_subject,
192
+
&[("hostname", hostname)],
178
193
);
179
194
let recipient = match comms_channel {
180
195
tranquil_db_traits::CommsChannel::Telegram => state
···
194
209
comms_channel,
195
210
tranquil_db_traits::CommsType::ChannelVerification,
196
211
&recipient,
197
-
Some("Verify your channel"),
212
+
Some(&subject),
198
213
&body,
199
214
Some(json!({"code": formatted_token})),
200
215
)
···
291
306
}
292
307
}
293
308
294
-
if let Some(ref discord_id) = input.discord_id {
295
-
if discord_id.is_empty() {
309
+
if let Some(ref discord_username) = input.discord_username {
310
+
let discord_clean = discord_username.trim().to_lowercase();
311
+
if discord_clean.is_empty() {
296
312
if effective_channel == CommsChannel::Discord {
297
313
return Err(ApiError::InvalidRequest(
298
314
"Cannot remove Discord while it is the preferred notification channel".into(),
···
303
319
.clear_discord(user_id)
304
320
.await
305
321
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
306
-
info!(did = %auth.did, "Cleared Discord ID");
322
+
info!(did = %auth.did, "Cleared Discord");
323
+
} else if !crate::api::validation::is_valid_discord_username(&discord_clean) {
324
+
return Err(ApiError::InvalidRequest(
325
+
"Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)"
326
+
.into(),
327
+
));
307
328
} else {
308
-
request_channel_verification(&state, user_id, &auth.did, "discord", discord_id, None)
329
+
state
330
+
.user_repo
331
+
.set_unverified_discord(user_id, &discord_clean)
309
332
.await
310
-
.map_err(|e| ApiError::InternalError(Some(e)))?;
333
+
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
311
334
verification_required.push("discord".to_string());
312
-
info!(did = %auth.did, "Requested Discord verification");
335
+
info!(did = %auth.did, discord_username = %discord_clean, "Stored unverified Discord username");
313
336
}
314
337
}
315
338
···
343
366
}
344
367
}
345
368
346
-
if let Some(ref signal) = input.signal_number {
347
-
if signal.is_empty() {
369
+
if let Some(ref signal) = input.signal_username {
370
+
let signal_clean = signal.trim().trim_start_matches('@').to_lowercase();
371
+
if signal_clean.is_empty() {
348
372
if effective_channel == CommsChannel::Signal {
349
373
return Err(ApiError::InvalidRequest(
350
374
"Cannot remove Signal while it is the preferred notification channel".into(),
···
355
379
.clear_signal(user_id)
356
380
.await
357
381
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
358
-
info!(did = %auth.did, "Cleared Signal number");
382
+
info!(did = %auth.did, "Cleared Signal username");
383
+
} else if !crate::comms::is_valid_signal_username(&signal_clean) {
384
+
return Err(ApiError::InvalidRequest(
385
+
"Invalid Signal username. Must be 3-32 characters followed by .XX (e.g. username.01)"
386
+
.into(),
387
+
));
359
388
} else {
360
-
request_channel_verification(&state, user_id, &auth.did, "signal", signal, None)
389
+
state
390
+
.user_repo
391
+
.set_unverified_signal(user_id, &signal_clean)
392
+
.await
393
+
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
394
+
request_channel_verification(&state, user_id, &auth.did, "signal", &signal_clean, None)
361
395
.await
362
396
.map_err(|e| ApiError::InternalError(Some(e)))?;
363
397
verification_required.push("signal".to_string());
364
-
info!(did = %auth.did, "Requested Signal verification");
398
+
info!(did = %auth.did, signal_username = %signal_clean, "Stored unverified Signal username");
365
399
}
366
400
}
367
401
+18
-1
crates/tranquil-pds/src/api/repo/import.rs
+18
-1
crates/tranquil-pds/src/api/repo/import.rs
···
55
55
return Err(ApiError::AccountTakedown);
56
56
}
57
57
let user_id = user.id;
58
+
let expected_root_cid = state
59
+
.repo_repo
60
+
.get_repo_root_cid_by_user_id(user_id)
61
+
.await
62
+
.map_err(|e| {
63
+
error!("DB error fetching repo root: {:?}", e);
64
+
ApiError::InternalError(None)
65
+
})?;
58
66
let (root, blocks) = match parse_car(&body).await {
59
67
Ok((r, b)) => (r, b),
60
68
Err(ImportError::InvalidRootCount) => {
···
191
199
.and_then(|s| s.parse().ok())
192
200
.unwrap_or(DEFAULT_MAX_BLOCKS);
193
201
let _write_lock = state.repo_write_locks.lock(user_id).await;
194
-
match apply_import(&state.repo_repo, user_id, root, blocks.clone(), max_blocks).await {
202
+
match apply_import(
203
+
&state.repo_repo,
204
+
user_id,
205
+
root,
206
+
blocks.clone(),
207
+
max_blocks,
208
+
expected_root_cid.as_ref(),
209
+
)
210
+
.await
211
+
{
195
212
Ok(import_result) => {
196
213
info!(
197
214
"Successfully imported {} records for user {}",
+1
crates/tranquil-pds/src/api/server/email.rs
+1
crates/tranquil-pds/src/api/server/email.rs
···
367
367
crate::auth::verification_token::format_token_for_display(&verification_token);
368
368
let hostname = pds_hostname();
369
369
if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification(
370
+
state.user_repo.as_ref(),
370
371
state.infra_repo.as_ref(),
371
372
user_id,
372
373
"email",
+8
-2
crates/tranquil-pds/src/api/server/meta.rs
+8
-2
crates/tranquil-pds/src/api/server/meta.rs
···
1
1
use crate::state::AppState;
2
-
use crate::util::{pds_hostname, telegram_bot_username};
2
+
use crate::util::{discord_app_id, discord_bot_username, pds_hostname, telegram_bot_username};
3
3
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
4
4
use serde_json::json;
5
5
6
6
fn get_available_comms_channels() -> Vec<&'static str> {
7
7
let mut channels = vec!["email"];
8
-
if std::env::var("DISCORD_WEBHOOK_URL").is_ok() {
8
+
if std::env::var("DISCORD_BOT_TOKEN").is_ok() {
9
9
channels.push("discord");
10
10
}
11
11
if std::env::var("TELEGRAM_BOT_TOKEN").is_ok() {
···
62
62
"availableCommsChannels": get_available_comms_channels(),
63
63
"selfHostedDidWebEnabled": is_self_hosted_did_web_enabled()
64
64
});
65
+
if let Some(bot_username) = discord_bot_username() {
66
+
response["discordBotUsername"] = json!(bot_username);
67
+
}
68
+
if let Some(app_id) = discord_app_id() {
69
+
response["discordAppId"] = json!(app_id);
70
+
}
65
71
if let Some(bot_username) = telegram_bot_username() {
66
72
response["telegramBotUsername"] = json!(bot_username);
67
73
}
+25
-15
crates/tranquil-pds/src/api/server/passkey_account.rs
+25
-15
crates/tranquil-pds/src/api/server/passkey_account.rs
···
50
50
pub did_type: Option<String>,
51
51
pub signing_key: Option<String>,
52
52
pub verification_channel: Option<String>,
53
-
pub discord_id: Option<String>,
53
+
pub discord_username: Option<String>,
54
54
pub telegram_username: Option<String>,
55
-
pub signal_number: Option<String>,
55
+
pub signal_username: Option<String>,
56
56
}
57
57
58
58
#[derive(Serialize)]
···
167
167
Some(e) if !e.is_empty() => e.clone(),
168
168
_ => return ApiError::MissingEmail.into_response(),
169
169
},
170
-
"discord" => match &input.discord_id {
171
-
Some(id) if !id.trim().is_empty() => id.trim().to_string(),
170
+
"discord" => match &input.discord_username {
171
+
Some(username) if !username.trim().is_empty() => {
172
+
let clean = username.trim().to_lowercase();
173
+
if !crate::api::validation::is_valid_discord_username(&clean) {
174
+
return ApiError::InvalidRequest(
175
+
"Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(),
176
+
).into_response();
177
+
}
178
+
clean
179
+
}
172
180
_ => return ApiError::MissingDiscordId.into_response(),
173
181
},
174
182
"telegram" => match &input.telegram_username {
···
183
191
}
184
192
_ => return ApiError::MissingTelegramUsername.into_response(),
185
193
},
186
-
"signal" => match &input.signal_number {
187
-
Some(number) if !number.trim().is_empty() => number.trim().to_string(),
194
+
"signal" => match &input.signal_username {
195
+
Some(username) if !username.trim().is_empty() => {
196
+
username.trim().trim_start_matches('@').to_lowercase()
197
+
}
188
198
_ => return ApiError::MissingSignalNumber.into_response(),
189
199
},
190
200
_ => return ApiError::InvalidVerificationChannel.into_response(),
···
409
419
email: email.clone().unwrap_or_default(),
410
420
did: did_typed.clone(),
411
421
preferred_comms_channel,
412
-
discord_id: input
413
-
.discord_id
422
+
discord_username: input
423
+
.discord_username
414
424
.as_deref()
415
-
.map(|s| s.trim())
416
-
.filter(|s| !s.is_empty())
417
-
.map(String::from),
425
+
.map(|s| s.trim().to_lowercase())
426
+
.filter(|s| !s.is_empty()),
418
427
telegram_username: input
419
428
.telegram_username
420
429
.as_deref()
421
430
.map(|s| s.trim().trim_start_matches('@'))
422
431
.filter(|s| !s.is_empty())
423
432
.map(String::from),
424
-
signal_number: input
425
-
.signal_number
433
+
signal_username: input
434
+
.signal_username
426
435
.as_deref()
427
-
.map(|s| s.trim())
436
+
.map(|s| s.trim().trim_start_matches('@'))
428
437
.filter(|s| !s.is_empty())
429
-
.map(String::from),
438
+
.map(|s| s.to_lowercase()),
430
439
setup_token_hash,
431
440
setup_expires_at,
432
441
deactivated_at,
···
501
510
let formatted_token =
502
511
crate::auth::verification_token::format_token_for_display(&verification_token);
503
512
if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification(
513
+
state.user_repo.as_ref(),
504
514
state.infra_repo.as_ref(),
505
515
user_id,
506
516
verification_channel,
+5
-4
crates/tranquil-pds/src/api/server/session.rs
+5
-4
crates/tranquil-pds/src/api/server/session.rs
···
634
634
let (channel_str, identifier) = match row.channel {
635
635
tranquil_db_traits::CommsChannel::Email => ("email", row.email.clone().unwrap_or_default()),
636
636
tranquil_db_traits::CommsChannel::Discord => {
637
-
("discord", row.discord_id.clone().unwrap_or_default())
637
+
("discord", row.discord_username.clone().unwrap_or_default())
638
638
}
639
639
tranquil_db_traits::CommsChannel::Telegram => (
640
640
"telegram",
641
641
row.telegram_username.clone().unwrap_or_default(),
642
642
),
643
643
tranquil_db_traits::CommsChannel::Signal => {
644
-
("signal", row.signal_number.clone().unwrap_or_default())
644
+
("signal", row.signal_username.clone().unwrap_or_default())
645
645
}
646
646
};
647
647
···
786
786
let (channel_str, recipient) = match row.channel {
787
787
tranquil_db_traits::CommsChannel::Email => ("email", row.email.clone().unwrap_or_default()),
788
788
tranquil_db_traits::CommsChannel::Discord => {
789
-
("discord", row.discord_id.clone().unwrap_or_default())
789
+
("discord", row.discord_username.clone().unwrap_or_default())
790
790
}
791
791
tranquil_db_traits::CommsChannel::Telegram => (
792
792
"telegram",
793
793
row.telegram_username.clone().unwrap_or_default(),
794
794
),
795
795
tranquil_db_traits::CommsChannel::Signal => {
796
-
("signal", row.signal_number.clone().unwrap_or_default())
796
+
("signal", row.signal_username.clone().unwrap_or_default())
797
797
}
798
798
};
799
799
···
804
804
805
805
let hostname = pds_hostname();
806
806
if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification(
807
+
state.user_repo.as_ref(),
807
808
state.infra_repo.as_ref(),
808
809
row.id,
809
810
channel_str,
+35
-3
crates/tranquil-pds/src/api/validation.rs
+35
-3
crates/tranquil-pds/src/api/validation.rs
···
351
351
352
352
pub fn is_valid_telegram_username(username: &str) -> bool {
353
353
let clean = username.strip_prefix('@').unwrap_or(username);
354
-
(5..=32).contains(&clean.len())
355
-
&& clean
354
+
(5..=32).contains(&clean.len()) && clean.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
355
+
}
356
+
357
+
pub fn is_valid_discord_username(username: &str) -> bool {
358
+
(2..=32).contains(&username.len())
359
+
&& username
356
360
.chars()
357
-
.all(|c| c.is_ascii_alphanumeric() || c == '_')
361
+
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '.')
362
+
&& !username.starts_with('.')
363
+
&& !username.ends_with('.')
364
+
&& !username.contains("..")
358
365
}
359
366
360
367
#[cfg(test)]
···
534
541
fn test_trimmed_whitespace() {
535
542
assert!(is_valid_email(" user@example.com "));
536
543
}
544
+
545
+
#[test]
546
+
fn test_valid_discord_usernames() {
547
+
assert!(is_valid_discord_username("ab"));
548
+
assert!(is_valid_discord_username("alice"));
549
+
assert!(is_valid_discord_username("user_name"));
550
+
assert!(is_valid_discord_username("user.name"));
551
+
assert!(is_valid_discord_username("user123"));
552
+
assert!(is_valid_discord_username("a_b.c_d"));
553
+
assert!(is_valid_discord_username("12345678901234567890123456789012"));
554
+
}
555
+
556
+
#[test]
557
+
fn test_invalid_discord_usernames() {
558
+
assert!(!is_valid_discord_username(""));
559
+
assert!(!is_valid_discord_username("a"));
560
+
assert!(!is_valid_discord_username("Alice"));
561
+
assert!(!is_valid_discord_username("ALICE"));
562
+
assert!(!is_valid_discord_username("user-name"));
563
+
assert!(!is_valid_discord_username(".username"));
564
+
assert!(!is_valid_discord_username("username."));
565
+
assert!(!is_valid_discord_username("user..name"));
566
+
assert!(!is_valid_discord_username("user name"));
567
+
assert!(!is_valid_discord_username("123456789012345678901234567890123"));
568
+
}
537
569
}
+2
-2
crates/tranquil-pds/src/comms/mod.rs
+2
-2
crates/tranquil-pds/src/comms/mod.rs
···
3
3
pub use tranquil_comms::{
4
4
CommsChannel, CommsSender, CommsStatus, CommsType, DEFAULT_LOCALE, DiscordSender, EmailSender,
5
5
NewComms, NotificationStrings, QueuedComms, SendError, SignalSender, TelegramSender,
6
-
VALID_LOCALES, format_message, get_strings, is_valid_phone_number, mime_encode_header,
7
-
sanitize_header_value, validate_locale,
6
+
VALID_LOCALES, format_message, get_strings, is_valid_phone_number, is_valid_signal_username,
7
+
mime_encode_header, sanitize_header_value, validate_locale,
8
8
};
9
9
10
10
pub use service::{CommsService, channel_display_name, repo as comms_repo};
+20
-24
crates/tranquil-pds/src/comms/service.rs
+20
-24
crates/tranquil-pds/src/comms/service.rs
···
281
281
})
282
282
.unwrap_or_else(email_fallback),
283
283
tranquil_db_traits::CommsChannel::Signal => prefs
284
-
.signal_number
284
+
.signal_username
285
285
.as_ref()
286
286
.filter(|n| !n.is_empty())
287
287
.map(|n| ResolvedRecipient {
···
639
639
}
640
640
641
641
pub async fn enqueue_signup_verification(
642
+
user_repo: &dyn UserRepository,
642
643
infra_repo: &dyn InfraRepository,
643
644
user_id: Uuid,
644
645
channel: &str,
···
647
648
hostname: &str,
648
649
) -> Result<Uuid, DbError> {
649
650
let comms_channel = channel_from_str(channel);
650
-
let strings = get_strings("en");
651
-
let (verify_page, verify_link) = match comms_channel {
652
-
tranquil_db_traits::CommsChannel::Email => {
653
-
let encoded_email = urlencoding::encode(recipient);
654
-
let encoded_token = urlencoding::encode(code);
655
-
(
656
-
format!("https://{}/app/verify", hostname),
657
-
format!(
658
-
"https://{}/app/verify?token={}&identifier={}",
659
-
hostname, encoded_token, encoded_email
660
-
),
661
-
)
662
-
}
663
-
_ => (String::new(), String::new()),
664
-
};
651
+
let prefs = user_repo.get_comms_prefs(user_id).await.ok().flatten();
652
+
let locale = prefs
653
+
.as_ref()
654
+
.and_then(|p| p.preferred_locale.as_deref())
655
+
.unwrap_or("en");
656
+
let strings = get_strings(locale);
657
+
let encoded_token = urlencoding::encode(code);
658
+
let encoded_recipient = urlencoding::encode(recipient);
659
+
let verify_page = format!("https://{}/app/verify", hostname);
660
+
let verify_link = format!(
661
+
"https://{}/app/verify?token={}&identifier={}",
662
+
hostname, encoded_token, encoded_recipient
663
+
);
665
664
let body = format_message(
666
665
strings.signup_verification_body,
667
666
&[
···
671
670
("verify_link", &verify_link),
672
671
],
673
672
);
674
-
let subject = match comms_channel {
675
-
tranquil_db_traits::CommsChannel::Email => Some(format_message(
676
-
strings.signup_verification_subject,
677
-
&[("hostname", hostname)],
678
-
)),
679
-
_ => None,
680
-
};
673
+
let subject = format_message(
674
+
strings.signup_verification_subject,
675
+
&[("hostname", hostname)],
676
+
);
681
677
infra_repo
682
678
.enqueue_comms(
683
679
Some(user_id),
684
680
comms_channel,
685
681
CommsType::EmailVerification,
686
682
recipient,
687
-
subject.as_deref(),
683
+
Some(&subject),
688
684
&body,
689
685
None,
690
686
)
+7
-1
crates/tranquil-pds/src/lib.rs
+7
-1
crates/tranquil-pds/src/lib.rs
···
645
645
.route("/u/{handle}/did.json", get(api::identity::user_did_doc))
646
646
.route(
647
647
"/webhook/telegram",
648
-
post(api::telegram_webhook::handle_telegram_webhook),
648
+
post(api::telegram_webhook::handle_telegram_webhook)
649
+
.layer(DefaultBodyLimit::max(64 * 1024)),
650
+
)
651
+
.route(
652
+
"/webhook/discord",
653
+
post(api::discord_webhook::handle_discord_webhook)
654
+
.layer(DefaultBodyLimit::max(64 * 1024)),
649
655
)
650
656
.layer(DefaultBodyLimit::max(util::get_max_blob_size()))
651
657
.layer(middleware::from_fn(metrics::metrics_middleware))
+62
-3
crates/tranquil-pds/src/main.rs
+62
-3
crates/tranquil-pds/src/main.rs
···
64
64
});
65
65
66
66
let mut comms_service = CommsService::new(state.infra_repo.clone());
67
+
let mut deferred_discord_endpoint: Option<(DiscordSender, String, String)> = None;
67
68
68
69
if let Some(email_sender) = EmailSender::from_env() {
69
70
info!("Email comms enabled");
···
74
75
75
76
if let Some(discord_sender) = DiscordSender::from_env() {
76
77
info!("Discord comms enabled");
78
+
match discord_sender.resolve_bot_username().await {
79
+
Ok(username) => {
80
+
info!(bot_username = %username, "Resolved Discord bot username");
81
+
tranquil_pds::util::set_discord_bot_username(username);
82
+
}
83
+
Err(e) => {
84
+
warn!("Failed to resolve Discord bot username: {}", e);
85
+
}
86
+
}
87
+
match discord_sender.resolve_application_info().await {
88
+
Ok((app_id, verify_key)) => {
89
+
info!(app_id = %app_id, "Resolved Discord application info");
90
+
tranquil_pds::util::set_discord_app_id(app_id.clone());
91
+
match hex::decode(&verify_key)
92
+
.ok()
93
+
.and_then(|bytes| <[u8; 32]>::try_from(bytes.as_slice()).ok())
94
+
.and_then(|bytes| ed25519_dalek::VerifyingKey::from_bytes(&bytes).ok())
95
+
{
96
+
Some(public_key) => {
97
+
tranquil_pds::util::set_discord_public_key(public_key);
98
+
info!("Discord Ed25519 public key loaded");
99
+
let hostname = std::env::var("PDS_HOSTNAME")
100
+
.unwrap_or_else(|_| "localhost".to_string());
101
+
let webhook_url = format!("https://{}/webhook/discord", hostname);
102
+
match discord_sender.register_slash_command(&app_id).await {
103
+
Ok(()) => info!("Discord /start slash command registered"),
104
+
Err(e) => warn!("Failed to register Discord slash command: {}", e),
105
+
}
106
+
deferred_discord_endpoint =
107
+
Some((discord_sender.clone(), app_id, webhook_url));
108
+
}
109
+
None => {
110
+
warn!("Failed to parse Discord verify_key as Ed25519 public key");
111
+
}
112
+
}
113
+
}
114
+
Err(e) => {
115
+
warn!("Failed to resolve Discord application info: {}", e);
116
+
}
117
+
}
77
118
comms_service = comms_service.register_sender(discord_sender);
78
119
}
79
120
···
172
213
.await
173
214
.map_err(|e| format!("Failed to bind to {}: {}", addr, e))?;
174
215
175
-
let server_result = axum::serve(listener, app)
176
-
.with_graceful_shutdown(shutdown.clone().cancelled_owned())
177
-
.await;
216
+
let server_handle = tokio::spawn(async move {
217
+
axum::serve(listener, app)
218
+
.with_graceful_shutdown(shutdown.clone().cancelled_owned())
219
+
.await
220
+
});
221
+
222
+
if let Some((sender, app_id, webhook_url)) = deferred_discord_endpoint {
223
+
tokio::spawn(async move {
224
+
match sender
225
+
.set_interactions_endpoint(&app_id, &webhook_url)
226
+
.await
227
+
{
228
+
Ok(()) => info!(url = %webhook_url, "Discord interactions endpoint registered"),
229
+
Err(e) => warn!("Failed to set Discord interactions endpoint: {}", e),
230
+
}
231
+
});
232
+
}
233
+
234
+
let server_result = server_handle
235
+
.await
236
+
.map_err(|e| format!("Server task panicked: {}", e))?;
178
237
179
238
comms_handle.await.ok();
180
239
+23
-12
crates/tranquil-pds/src/sso/endpoints.rs
+23
-12
crates/tranquil-pds/src/sso/endpoints.rs
···
817
817
pub email: Option<String>,
818
818
pub invite_code: Option<String>,
819
819
pub verification_channel: Option<String>,
820
-
pub discord_id: Option<String>,
820
+
pub discord_username: Option<String>,
821
821
pub telegram_username: Option<String>,
822
-
pub signal_number: Option<String>,
822
+
pub signal_username: Option<String>,
823
823
pub did_type: Option<String>,
824
824
pub did: Option<String>,
825
825
}
···
894
894
_ => return Err(ApiError::MissingEmail),
895
895
}
896
896
}
897
-
"discord" => match &input.discord_id {
898
-
Some(id) if !id.trim().is_empty() => id.trim().to_string(),
897
+
"discord" => match &input.discord_username {
898
+
Some(username) if !username.trim().is_empty() => {
899
+
let clean = username.trim().to_lowercase();
900
+
if !crate::api::validation::is_valid_discord_username(&clean) {
901
+
return Err(ApiError::InvalidRequest(
902
+
"Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(),
903
+
));
904
+
}
905
+
clean
906
+
}
899
907
_ => return Err(ApiError::MissingDiscordId),
900
908
},
901
909
"telegram" => match &input.telegram_username {
···
910
918
}
911
919
_ => return Err(ApiError::MissingTelegramUsername),
912
920
},
913
-
"signal" => match &input.signal_number {
914
-
Some(number) if !number.trim().is_empty() => number.trim().to_string(),
921
+
"signal" => match &input.signal_username {
922
+
Some(username) if !username.trim().is_empty() => {
923
+
username.trim().trim_start_matches('@').to_lowercase()
924
+
}
915
925
_ => return Err(ApiError::MissingSignalNumber),
916
926
},
917
927
_ => return Err(ApiError::InvalidVerificationChannel),
···
1104
1114
email: email.clone(),
1105
1115
did: did_typed.clone(),
1106
1116
preferred_comms_channel,
1107
-
discord_id: input
1108
-
.discord_id
1117
+
discord_username: input
1118
+
.discord_username
1109
1119
.clone()
1110
-
.map(|s| s.trim().to_string())
1120
+
.map(|s| s.trim().to_lowercase())
1111
1121
.filter(|s| !s.is_empty()),
1112
1122
telegram_username: input
1113
1123
.telegram_username
1114
1124
.clone()
1115
1125
.map(|s| s.trim().trim_start_matches('@').to_string())
1116
1126
.filter(|s| !s.is_empty()),
1117
-
signal_number: input
1118
-
.signal_number
1127
+
signal_username: input
1128
+
.signal_username
1119
1129
.clone()
1120
-
.map(|s| s.trim().to_string())
1130
+
.map(|s| s.trim().trim_start_matches('@').to_lowercase())
1121
1131
.filter(|s| !s.is_empty()),
1122
1132
encrypted_key_bytes: encrypted_key_bytes.clone(),
1123
1133
encryption_version: crate::config::ENCRYPTION_VERSION,
···
1354
1364
let formatted_token =
1355
1365
crate::auth::verification_token::format_token_for_display(&verification_token);
1356
1366
if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification(
1367
+
state.user_repo.as_ref(),
1357
1368
state.infra_repo.as_ref(),
1358
1369
uid,
1359
1370
verification_channel,
+3
-1
crates/tranquil-pds/src/sync/import.rs
+3
-1
crates/tranquil-pds/src/sync/import.rs
···
9
9
use thiserror::Error;
10
10
use tracing::debug;
11
11
use tranquil_db::{ImportBlock, ImportRecord, ImportRepoError, RepoRepository};
12
+
use tranquil_types::CidLink;
12
13
use uuid::Uuid;
13
14
14
15
#[derive(Error, Debug)]
···
323
324
root: Cid,
324
325
blocks: HashMap<Cid, Bytes>,
325
326
max_blocks: usize,
327
+
expected_root_cid: Option<&CidLink>,
326
328
) -> Result<ImportResult, ImportError> {
327
329
if blocks.len() > max_blocks {
328
330
return Err(ImportError::SizeLimitExceeded);
···
364
366
.collect();
365
367
366
368
repo_repo
367
-
.import_repo_data(user_id, &import_blocks, &import_records)
369
+
.import_repo_data(user_id, &import_blocks, &import_records, expected_root_cid)
368
370
.await?;
369
371
370
372
debug!(
+27
crates/tranquil-pds/src/util.rs
+27
crates/tranquil-pds/src/util.rs
···
14
14
static MAX_BLOB_SIZE: OnceLock<usize> = OnceLock::new();
15
15
static PDS_HOSTNAME: OnceLock<String> = OnceLock::new();
16
16
static PDS_HOSTNAME_WITHOUT_PORT: OnceLock<String> = OnceLock::new();
17
+
static DISCORD_BOT_USERNAME: OnceLock<String> = OnceLock::new();
18
+
static DISCORD_PUBLIC_KEY: OnceLock<ed25519_dalek::VerifyingKey> = OnceLock::new();
19
+
static DISCORD_APP_ID: OnceLock<String> = OnceLock::new();
17
20
static TELEGRAM_BOT_USERNAME: OnceLock<String> = OnceLock::new();
18
21
19
22
pub fn get_max_blob_size() -> usize {
···
105
108
})
106
109
}
107
110
111
+
pub fn set_discord_bot_username(username: String) {
112
+
DISCORD_BOT_USERNAME.set(username).ok();
113
+
}
114
+
115
+
pub fn discord_bot_username() -> Option<&'static str> {
116
+
DISCORD_BOT_USERNAME.get().map(|s| s.as_str())
117
+
}
118
+
119
+
pub fn set_discord_public_key(key: ed25519_dalek::VerifyingKey) {
120
+
DISCORD_PUBLIC_KEY.set(key).ok();
121
+
}
122
+
123
+
pub fn discord_public_key() -> Option<&'static ed25519_dalek::VerifyingKey> {
124
+
DISCORD_PUBLIC_KEY.get()
125
+
}
126
+
127
+
pub fn set_discord_app_id(app_id: String) {
128
+
DISCORD_APP_ID.set(app_id).ok();
129
+
}
130
+
131
+
pub fn discord_app_id() -> Option<&'static str> {
132
+
DISCORD_APP_ID.get().map(|s| s.as_str())
133
+
}
134
+
108
135
pub fn set_telegram_bot_username(username: String) {
109
136
TELEGRAM_BOT_USERNAME.set(username).ok();
110
137
}
+4
-41
crates/tranquil-pds/tests/account_notifications.rs
+4
-41
crates/tranquil-pds/tests/account_notifications.rs
···
1
1
mod common;
2
2
use common::{base_url, client, create_account_and_login, get_test_db_pool};
3
3
use serde_json::{Value, json};
4
-
use sqlx::Row;
5
4
6
5
#[tokio::test]
7
6
async fn test_get_notification_history() {
···
51
50
async fn test_verify_channel_discord() {
52
51
let client = client();
53
52
let base = base_url().await;
54
-
let (token, did) = create_account_and_login(&client).await;
53
+
let (token, _did) = create_account_and_login(&client).await;
55
54
56
55
let prefs = json!({
57
-
"discordId": "123456789"
56
+
"discordUsername": "testuser123"
58
57
});
59
58
let resp = client
60
59
.post(format!("{}/xrpc/_account.updateNotificationPrefs", base))
···
72
71
.contains(&json!("discord"))
73
72
);
74
73
75
-
let pool = get_test_db_pool().await;
76
-
let user_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users WHERE did = $1")
77
-
.bind(&did)
78
-
.fetch_one(pool)
79
-
.await
80
-
.expect("User not found");
81
-
82
-
let row = sqlx::query(
83
-
"SELECT body, metadata FROM comms_queue WHERE user_id = $1 AND comms_type = 'channel_verification' ORDER BY created_at DESC LIMIT 1",
84
-
)
85
-
.bind(user_id)
86
-
.fetch_one(pool)
87
-
.await
88
-
.expect("Verification code not found");
89
-
90
-
let metadata: Option<serde_json::Value> = row.get("metadata");
91
-
let code = metadata
92
-
.as_ref()
93
-
.and_then(|m| m.get("code"))
94
-
.and_then(|c| c.as_str())
95
-
.expect("No code in metadata");
96
-
97
-
let input = json!({
98
-
"channel": "discord",
99
-
"identifier": "123456789",
100
-
"code": code
101
-
});
102
-
let resp = client
103
-
.post(format!("{}/xrpc/_account.confirmChannelVerification", base))
104
-
.header("Authorization", format!("Bearer {}", token))
105
-
.json(&input)
106
-
.send()
107
-
.await
108
-
.unwrap();
109
-
assert_eq!(resp.status(), 200);
110
-
111
74
let resp = client
112
75
.get(format!("{}/xrpc/_account.getNotificationPrefs", base))
113
76
.header("Authorization", format!("Bearer {}", token))
···
115
78
.await
116
79
.unwrap();
117
80
let body: Value = resp.json().await.unwrap();
118
-
assert_eq!(body["discordVerified"], true);
119
-
assert_eq!(body["discordId"], "123456789");
81
+
assert_eq!(body["discordVerified"], false);
82
+
assert_eq!(body["discordUsername"], "testuser123");
120
83
}
121
84
122
85
#[tokio::test]
+33
-1
crates/tranquil-pds/tests/security_fixes.rs
+33
-1
crates/tranquil-pds/tests/security_fixes.rs
···
1
1
mod common;
2
-
use tranquil_pds::comms::{SendError, is_valid_phone_number, sanitize_header_value};
2
+
use tranquil_pds::comms::{SendError, is_valid_phone_number, is_valid_signal_username, sanitize_header_value};
3
3
use tranquil_pds::image::{ImageError, ImageProcessor};
4
4
5
5
#[test]
···
80
80
}
81
81
}
82
82
83
+
#[test]
84
+
fn test_signal_username_validation() {
85
+
assert!(is_valid_signal_username("alice.01"));
86
+
assert!(is_valid_signal_username("bob_smith.99"));
87
+
assert!(is_valid_signal_username("user123.42"));
88
+
assert!(is_valid_signal_username("lu1.01"));
89
+
assert!(is_valid_signal_username("abc.00"));
90
+
assert!(is_valid_signal_username("a_very_long_username_here.55"));
91
+
92
+
assert!(!is_valid_signal_username("alice"));
93
+
assert!(!is_valid_signal_username("alice.1"));
94
+
assert!(!is_valid_signal_username("alice.001"));
95
+
assert!(!is_valid_signal_username(".01"));
96
+
assert!(!is_valid_signal_username("ab.01"));
97
+
assert!(!is_valid_signal_username(""));
98
+
assert!(!is_valid_signal_username("1alice.01"));
99
+
assert!(!is_valid_signal_username("alice!.01"));
100
+
assert!(!is_valid_signal_username("alice .01"));
101
+
102
+
assert!(!is_valid_signal_username("a".repeat(33).as_str()));
103
+
104
+
["alice.01; rm -rf /", "bob.01 && cat /etc/passwd", "user.01`id`", "test.01$(whoami)"]
105
+
.iter()
106
+
.for_each(|malicious| {
107
+
assert!(
108
+
!is_valid_signal_username(malicious),
109
+
"Command injection '{}' should be rejected",
110
+
malicious
111
+
);
112
+
});
113
+
}
114
+
83
115
#[test]
84
116
fn test_image_file_size_limits() {
85
117
let processor = ImageProcessor::new();
+3
-3
crates/tranquil-pds/tests/sso.rs
+3
-3
crates/tranquil-pds/tests/sso.rs
···
1035
1035
"token": token,
1036
1036
"handle": handle_prefix,
1037
1037
"verification_channel": "discord",
1038
-
"discord_id": discord_id
1038
+
"discord_username": discord_id
1039
1039
}))
1040
1040
.send()
1041
1041
.await
···
1054
1054
1055
1055
let did_str = body["did"].as_str().unwrap();
1056
1056
let user = sqlx::query!(
1057
-
r#"SELECT preferred_comms_channel as "preferred_comms_channel: String", discord_id FROM users WHERE did = $1"#,
1057
+
r#"SELECT preferred_comms_channel as "preferred_comms_channel: String", discord_username FROM users WHERE did = $1"#,
1058
1058
did_str,
1059
1059
)
1060
1060
.fetch_one(pool)
···
1062
1062
.unwrap();
1063
1063
1064
1064
assert_eq!(user.preferred_comms_channel, "discord");
1065
-
assert_eq!(user.discord_id, Some(discord_id.to_string()));
1065
+
assert_eq!(user.discord_username, Some(discord_id.to_string()));
1066
1066
}
1067
1067
1068
1068
#[tokio::test]
+17
-4
crates/tranquil-pds/tests/whole_story.rs
+17
-4
crates/tranquil-pds/tests/whole_story.rs
···
1948
1948
.expect("Import request failed");
1949
1949
let status = res.status();
1950
1950
let body: Value = res.json().await.unwrap_or_default();
1951
-
assert_eq!(status, StatusCode::OK, "Import should succeed: {:?}", body);
1951
+
let is_concurrent_modification = status == StatusCode::BAD_REQUEST
1952
+
&& body.get("error").and_then(|e| e.as_str()) == Some("InvalidSwap");
1953
+
assert!(
1954
+
status == StatusCode::OK || is_concurrent_modification,
1955
+
"Import should succeed or fail with InvalidSwap due to concurrent writes: {:?}",
1956
+
body
1957
+
);
1958
+
status == StatusCode::OK
1952
1959
}
1953
1960
};
1954
1961
···
1987
1994
})
1988
1995
.collect();
1989
1996
1990
-
tokio::join!(import_future, join_all(write_futures));
1997
+
let (import_succeeded, _) = tokio::join!(import_future, join_all(write_futures));
1991
1998
1992
1999
let final_posts = client
1993
2000
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base))
···
2003
2010
let final_body: Value = final_posts.json().await.unwrap();
2004
2011
let record_count = final_body["records"].as_array().unwrap().len();
2005
2012
2006
-
let min_expected = write_count;
2013
+
let min_expected = if import_succeeded {
2014
+
write_count + 1
2015
+
} else {
2016
+
write_count
2017
+
};
2007
2018
assert!(
2008
2019
record_count >= min_expected,
2009
-
"Expected at least {} records (from writes), got {} (import may also contribute records)",
2020
+
"Expected at least {} records (writes={}, import_succeeded={}), got {}",
2010
2021
min_expected,
2022
+
write_count,
2023
+
import_succeeded,
2011
2024
record_count
2012
2025
);
2013
2026
}
+51
-36
frontend/src/components/dashboard/CommsContent.svelte
+51
-36
frontend/src/components/dashboard/CommsContent.svelte
···
18
18
let preferredChannel = $state('email')
19
19
let availableCommsChannels = $state<string[]>(['email'])
20
20
let telegramBotUsername = $state<string | undefined>(undefined)
21
+
let discordBotUsername = $state<string | undefined>(undefined)
22
+
let discordAppId = $state<string | undefined>(undefined)
21
23
let email = $state('')
22
-
let discordId = $state('')
24
+
let discordUsername = $state('')
23
25
let discordVerified = $state(false)
24
26
let telegramUsername = $state('')
25
27
let telegramVerified = $state(false)
26
-
let signalNumber = $state('')
28
+
let signalUsername = $state('')
27
29
let signalVerified = $state(false)
28
-
let savedDiscordId = $state('')
30
+
let savedDiscordUsername = $state('')
29
31
let savedTelegramUsername = $state('')
30
-
let savedSignalNumber = $state('')
32
+
let savedSignalUsername = $state('')
31
33
let verifyingChannel = $state<string | null>(null)
32
34
let verificationCode = $state('')
33
35
let historyLoading = $state(true)
···
57
59
])
58
60
preferredChannel = prefs.preferredChannel
59
61
email = prefs.email
60
-
discordId = prefs.discordId ?? ''
62
+
discordUsername = prefs.discordUsername ?? ''
61
63
discordVerified = prefs.discordVerified
62
64
telegramUsername = prefs.telegramUsername ?? ''
63
65
telegramVerified = prefs.telegramVerified
64
-
signalNumber = prefs.signalNumber ?? ''
66
+
signalUsername = prefs.signalUsername ?? ''
65
67
signalVerified = prefs.signalVerified
66
-
savedDiscordId = discordId
68
+
savedDiscordUsername = discordUsername
67
69
savedTelegramUsername = telegramUsername
68
-
savedSignalNumber = signalNumber
70
+
savedSignalUsername = signalUsername
69
71
availableCommsChannels = serverInfo.availableCommsChannels ?? ['email']
70
72
telegramBotUsername = serverInfo.telegramBotUsername
73
+
discordBotUsername = serverInfo.discordBotUsername
74
+
discordAppId = serverInfo.discordAppId
71
75
} catch (e) {
72
76
toast.error(e instanceof ApiError ? e.message : $_('comms.failedToLoad'))
73
77
} finally {
···
81
85
try {
82
86
const result = await api.updateNotificationPrefs(session.accessJwt, {
83
87
preferredChannel,
84
-
discordId: discordId !== savedDiscordId ? discordId : undefined,
88
+
discordUsername: discordUsername !== savedDiscordUsername ? discordUsername : undefined,
85
89
telegramUsername: telegramUsername !== savedTelegramUsername ? telegramUsername : undefined,
86
-
signalNumber: signalNumber !== savedSignalNumber ? signalNumber : undefined,
90
+
signalUsername: signalUsername !== savedSignalUsername ? signalUsername : undefined,
87
91
})
88
92
await refreshSession()
89
93
toast.success($_('comms.preferencesSaved'))
90
-
savedDiscordId = discordId
94
+
savedDiscordUsername = discordUsername
91
95
savedTelegramUsername = telegramUsername
92
-
savedSignalNumber = signalNumber
96
+
savedSignalUsername = signalUsername
93
97
const channelToVerify = result.verificationRequired?.find(
94
98
(ch: string) => ch === 'discord' || ch === 'telegram' || ch === 'signal'
95
99
)
···
108
112
if (!verificationCode) return
109
113
110
114
const identifierMap: Record<string, string> = {
111
-
discord: discordId,
115
+
discord: discordUsername,
112
116
telegram: telegramUsername,
113
-
signal: signalNumber
117
+
signal: signalUsername
114
118
}
115
119
const identifier = identifierMap[channel]
116
120
if (!identifier) return
···
190
194
if (!isChannelAvailableOnServer(channelId)) return false
191
195
if (channelId === 'email') return true
192
196
const hasIdentifier: Record<string, boolean> = {
193
-
discord: !!discordId,
197
+
discord: !!discordUsername,
194
198
telegram: !!telegramUsername,
195
-
signal: !!signalNumber
199
+
signal: !!signalUsername
196
200
}
197
201
return hasIdentifier[channelId] ?? false
198
202
}
···
245
249
{#if isChannelAvailableOnServer('discord')}
246
250
<div class="config-item">
247
251
<div class="config-header">
248
-
<label for="discord">{$_('register.discordId')}</label>
249
-
{#if discordId}
252
+
<label for="discord">{$_('register.discordUsername')}</label>
253
+
{#if discordUsername}
250
254
<span class="status" class:verified={discordVerified} class:unverified={!discordVerified}>
251
255
{preferredChannel === 'discord' && discordVerified ? $_('comms.primary') : discordVerified ? $_('comms.verified') : $_('comms.notVerified')}
252
256
</span>
···
256
260
<input
257
261
id="discord"
258
262
type="text"
259
-
bind:value={discordId}
260
-
onblur={() => checkChannelInUse('discord', discordId)}
261
-
placeholder={$_('register.discordIdPlaceholder')}
263
+
bind:value={discordUsername}
264
+
onblur={() => checkChannelInUse('discord', discordUsername)}
265
+
placeholder={$_('register.discordUsernamePlaceholder')}
262
266
disabled={saving}
263
267
/>
264
-
{#if discordId && discordId === savedDiscordId && !discordVerified}
265
-
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'discord'}>{$_('comms.verifyButton')}</button>
266
-
{/if}
267
268
</div>
268
269
{#if discordInUse}
269
270
<p class="hint warning">{$_('comms.discordInUseWarning')}</p>
270
271
{/if}
271
-
{#if verifyingChannel === 'discord'}
272
-
<div class="verify-form">
273
-
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="128" />
274
-
<button type="button" onclick={() => handleVerify('discord')}>{$_('comms.submit')}</button>
275
-
<button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button>
272
+
{#if discordUsername && discordUsername === savedDiscordUsername && !discordVerified && discordBotUsername}
273
+
{@const encodedHandle = session.handle.replaceAll('.', '_')}
274
+
<div class="discord-verify-prompt">
275
+
{#if discordAppId}
276
+
<a href="https://discord.com/users/{discordAppId}" target="_blank" rel="noopener">{$_('comms.discordOpenLink')}</a>
277
+
{/if}
278
+
<span class="manual-hint">{$_('comms.discordStartBot', { values: { botUsername: discordBotUsername, handle: session.handle } })}</span>
276
279
</div>
277
280
{/if}
278
281
</div>
···
314
317
{#if isChannelAvailableOnServer('signal')}
315
318
<div class="config-item">
316
319
<div class="config-header">
317
-
<label for="signal">{$_('register.signalNumber')}</label>
318
-
{#if signalNumber}
320
+
<label for="signal">{$_('register.signalUsername')}</label>
321
+
{#if signalUsername}
319
322
<span class="status" class:verified={signalVerified} class:unverified={!signalVerified}>
320
323
{preferredChannel === 'signal' && signalVerified ? $_('comms.primary') : signalVerified ? $_('comms.verified') : $_('comms.notVerified')}
321
324
</span>
···
324
327
<div class="config-input">
325
328
<input
326
329
id="signal"
327
-
type="tel"
328
-
bind:value={signalNumber}
329
-
onblur={() => checkChannelInUse('signal', signalNumber)}
330
-
placeholder={$_('register.signalNumberPlaceholder')}
330
+
type="text"
331
+
bind:value={signalUsername}
332
+
onblur={() => checkChannelInUse('signal', signalUsername)}
333
+
placeholder={$_('register.signalUsernamePlaceholder')}
331
334
disabled={saving}
332
335
/>
333
-
{#if signalNumber && signalNumber === savedSignalNumber && !signalVerified}
336
+
{#if signalUsername && signalUsername === savedSignalUsername && !signalVerified}
334
337
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'signal'}>{$_('comms.verifyButton')}</button>
335
338
{/if}
336
339
</div>
···
533
536
color: var(--text-primary);
534
537
}
535
538
539
+
.discord-verify-prompt {
540
+
display: flex;
541
+
flex-direction: column;
542
+
gap: var(--space-2);
543
+
padding: var(--space-3) var(--space-4);
544
+
background: var(--accent-bg, var(--bg-card));
545
+
border: 1px solid var(--accent, var(--border-color));
546
+
border-radius: var(--radius-md);
547
+
font-size: var(--text-sm);
548
+
color: var(--text-primary);
549
+
}
550
+
536
551
.manual-hint {
537
552
font-size: var(--text-xs);
538
553
color: var(--text-secondary);
+8
-8
frontend/src/lib/api.ts
+8
-8
frontend/src/lib/api.ts
···
404
404
did: params.did,
405
405
signingKey: params.signingKey,
406
406
verificationChannel: params.verificationChannel,
407
-
discordId: params.discordId,
407
+
discordUsername: params.discordUsername,
408
408
telegramUsername: params.telegramUsername,
409
-
signalNumber: params.signalNumber,
409
+
signalUsername: params.signalUsername,
410
410
}),
411
411
});
412
412
const data = await response.json();
···
656
656
657
657
updateNotificationPrefs(token: AccessToken, prefs: {
658
658
preferredChannel?: string;
659
-
discordId?: string;
659
+
discordUsername?: string;
660
660
telegramUsername?: string;
661
-
signalNumber?: string;
661
+
signalUsername?: string;
662
662
}): Promise<UpdateNotificationPrefsResponse> {
663
663
return xrpc("_account.updateNotificationPrefs", {
664
664
method: "POST",
···
1133
1133
did?: Did;
1134
1134
signingKey?: string;
1135
1135
verificationChannel?: VerificationChannel;
1136
-
discordId?: string;
1136
+
discordUsername?: string;
1137
1137
telegramUsername?: string;
1138
-
signalNumber?: string;
1138
+
signalUsername?: string;
1139
1139
}, byodToken?: string): Promise<PasskeyAccountCreateResponse> {
1140
1140
const url = `${API_BASE}/_account.createPasskeyAccount`;
1141
1141
const headers: Record<string, string> = {
···
1854
1854
token: AccessToken,
1855
1855
prefs: {
1856
1856
preferredChannel?: string;
1857
-
discordId?: string;
1857
+
discordUsername?: string;
1858
1858
telegramUsername?: string;
1859
-
signalNumber?: string;
1859
+
signalUsername?: string;
1860
1860
},
1861
1861
): Promise<Result<UpdateNotificationPrefsResponse, ApiError>> {
1862
1862
return xrpcResult("_account.updateNotificationPrefs", {
+16
-3
frontend/src/lib/registration/VerificationStep.svelte
+16
-3
frontend/src/lib/registration/VerificationStep.svelte
···
14
14
let resending = $state(false)
15
15
let resendMessage = $state<string | null>(null)
16
16
let telegramBotUsername = $state<string | undefined>(undefined)
17
+
let discordBotUsername = $state<string | undefined>(undefined)
18
+
let discordAppId = $state<string | undefined>(undefined)
17
19
18
20
let pollingInterval: ReturnType<typeof setInterval> | null = null
19
21
20
22
const isTelegram = $derived(flow.info.verificationChannel === 'telegram')
23
+
const isDiscord = $derived(flow.info.verificationChannel === 'discord')
24
+
const isBotVerified = $derived(isTelegram || isDiscord)
21
25
22
26
onMount(async () => {
23
-
if (isTelegram) {
27
+
if (isTelegram || isDiscord) {
24
28
try {
25
29
const serverInfo = await api.describeServer()
26
30
telegramBotUsername = serverInfo.telegramBotUsername
31
+
discordBotUsername = serverInfo.discordBotUsername
32
+
discordAppId = serverInfo.discordAppId
27
33
} catch {
28
34
}
29
35
}
30
36
})
31
37
32
38
$effect(() => {
33
-
if (flow.state.step === 'verify' && flow.account && (isTelegram || !verificationCode.trim())) {
39
+
if (flow.state.step === 'verify' && flow.account && (isBotVerified || !verificationCode.trim())) {
34
40
pollingInterval = setInterval(async () => {
35
-
if (!isTelegram && verificationCode.trim()) return
41
+
if (!isBotVerified && verificationCode.trim()) return
36
42
const advanced = await flow.checkAndAdvanceIfVerified()
37
43
if (advanced && pollingInterval) {
38
44
clearInterval(pollingInterval)
···
105
111
or send <code>/start {handle}</code> to <code>@{telegramBotUsername}</code> manually.
106
112
</p>
107
113
<p class="info-text waiting">Waiting for verification...</p>
114
+
{:else if isDiscord && discordAppId}
115
+
{@const handle = flow.account?.handle ?? `${flow.info.handle.trim()}.${flow.state.pdsHostname}`}
116
+
<p class="info-text">
117
+
<a href="https://discord.com/users/{discordAppId}" target="_blank" rel="noopener">Open Discord to verify</a>,
118
+
or send <code>/start {handle}</code> to <strong>{discordBotUsername ?? 'the bot'}</strong> manually.
119
+
</p>
120
+
<p class="info-text waiting">Waiting for verification...</p>
108
121
{:else}
109
122
<p class="info-text">
110
123
We've sent a verification code to your {channelLabel(flow.info.verificationChannel)}.
+6
-6
frontend/src/lib/registration/flow.svelte.ts
+6
-6
frontend/src/lib/registration/flow.svelte.ts
···
56
56
didType: "plc",
57
57
externalDid: "",
58
58
verificationChannel: "email",
59
-
discordId: "",
59
+
discordUsername: "",
60
60
telegramUsername: "",
61
-
signalNumber: "",
61
+
signalUsername: "",
62
62
},
63
63
externalDidWeb: {
64
64
keyMode: "reserved",
···
252
252
? state.externalDidWeb.reservedSigningKey
253
253
: undefined,
254
254
verificationChannel: state.info.verificationChannel,
255
-
discordId: state.info.discordId?.trim() || undefined,
255
+
discordUsername: state.info.discordUsername?.trim() || undefined,
256
256
telegramUsername: state.info.telegramUsername?.trim() || undefined,
257
-
signalNumber: state.info.signalNumber?.trim() || undefined,
257
+
signalUsername: state.info.signalUsername?.trim() || undefined,
258
258
}, byodToken);
259
259
260
260
state.account = {
···
305
305
? state.externalDidWeb.reservedSigningKey
306
306
: undefined,
307
307
verificationChannel: state.info.verificationChannel,
308
-
discordId: state.info.discordId?.trim() || undefined,
308
+
discordUsername: state.info.discordUsername?.trim() || undefined,
309
309
telegramUsername: state.info.telegramUsername?.trim() || undefined,
310
-
signalNumber: state.info.signalNumber?.trim() || undefined,
310
+
signalUsername: state.info.signalUsername?.trim() || undefined,
311
311
}, byodToken);
312
312
313
313
state.account = {
+2
-2
frontend/src/lib/registration/types.ts
+2
-2
frontend/src/lib/registration/types.ts
···
28
28
didType: DidType;
29
29
externalDid?: string;
30
30
verificationChannel: VerificationChannel;
31
-
discordId?: string;
31
+
discordUsername?: string;
32
32
telegramUsername?: string;
33
-
signalNumber?: string;
33
+
signalUsername?: string;
34
34
}
35
35
36
36
export interface ExternalDidWebState {
+4
-4
frontend/src/lib/types/api.ts
+4
-4
frontend/src/lib/types/api.ts
···
185
185
did?: string;
186
186
signingKey?: string;
187
187
verificationChannel?: VerificationChannel;
188
-
discordId?: string;
188
+
discordUsername?: string;
189
189
telegramUsername?: string;
190
-
signalNumber?: string;
190
+
signalUsername?: string;
191
191
}
192
192
193
193
export interface CreateAccountResult {
···
254
254
export interface NotificationPrefs {
255
255
preferredChannel: VerificationChannel;
256
256
email: EmailAddress;
257
-
discordId: string | null;
257
+
discordUsername: string | null;
258
258
discordVerified: boolean;
259
259
telegramUsername: string | null;
260
260
telegramVerified: boolean;
261
-
signalNumber: string | null;
261
+
signalUsername: string | null;
262
262
signalVerified: boolean;
263
263
}
264
264
+2
-2
frontend/src/lib/types/schemas.ts
+2
-2
frontend/src/lib/types/schemas.ts
···
173
173
export const notificationPrefsSchema = z.object({
174
174
preferredChannel: verificationChannel,
175
175
email: email,
176
-
discordId: z.string().nullable(),
176
+
discordUsername: z.string().nullable(),
177
177
discordVerified: z.boolean(),
178
178
telegramUsername: z.string().nullable(),
179
179
telegramVerified: z.boolean(),
180
-
signalNumber: z.string().nullable(),
180
+
signalUsername: z.string().nullable(),
181
181
signalVerified: z.boolean(),
182
182
});
183
183
+15
-15
frontend/src/locales/en.json
+15
-15
frontend/src/locales/en.json
···
84
84
"emailAddress": "Email Address",
85
85
"emailPlaceholder": "you@example.com",
86
86
"discord": "Discord",
87
-
"discordId": "Discord User ID",
88
-
"discordIdPlaceholder": "123456789012345678",
89
-
"discordIdHint": "Enable Developer Mode to find your ID",
90
-
"discordInUseWarning": "Discord ID in use by another account",
87
+
"discordUsername": "Discord Username",
88
+
"discordUsernamePlaceholder": "yourusername",
89
+
"discordInUseWarning": "Discord username in use by another account",
91
90
"telegram": "Telegram",
92
91
"telegramUsername": "Telegram Username",
93
92
"telegramUsernamePlaceholder": "@yourusername",
94
93
"telegramInUseWarning": "Telegram username in use by another account",
95
94
"signal": "Signal",
96
-
"signalNumber": "Signal Phone Number",
97
-
"signalNumberPlaceholder": "+1234567890",
98
-
"signalNumberHint": "Include country code",
99
-
"signalInUseWarning": "Signal number in use by another account",
95
+
"signalUsername": "Signal Username",
96
+
"signalUsernamePlaceholder": "username.01",
97
+
"signalInUseWarning": "Signal username in use by another account",
100
98
"notConfigured": "not configured",
101
99
"inviteCode": "Invite Code",
102
100
"inviteCodePlaceholder": "Enter your invite code",
···
118
116
"externalDidRequired": "External did:web is required",
119
117
"externalDidFormat": "External DID must start with did:web:",
120
118
"emailRequired": "Email is required for email verification",
121
-
"discordIdRequired": "Discord ID is required for Discord verification",
119
+
"discordUsernameRequired": "Discord username is required for Discord verification",
122
120
"telegramRequired": "Telegram username is required for Telegram verification",
123
-
"signalRequired": "Phone number is required for Signal verification"
121
+
"signalRequired": "Signal username is required for Signal verification"
124
122
}
125
123
},
126
124
"dashboard": {
···
437
435
"verifiedSuccess": "{channel} verified successfully",
438
436
"messageHistory": "Message History",
439
437
"noMessages": "No messages found.",
440
-
"discordInUseWarning": "This Discord ID is already associated with another account.",
438
+
"discordInUseWarning": "This Discord username is already associated with another account.",
441
439
"telegramInUseWarning": "This Telegram username is already associated with another account.",
442
-
"signalInUseWarning": "This Signal number is already associated with another account.",
440
+
"signalInUseWarning": "This Signal username is already associated with another account.",
443
441
"telegramStartBot": "Or send /start {handle} to @{botUsername} manually",
444
-
"telegramOpenLink": "Open Telegram to verify"
442
+
"telegramOpenLink": "Open Telegram to verify",
443
+
"discordStartBot": "DM @{botUsername} on Discord and send /start {handle}",
444
+
"discordOpenLink": "Open Discord to verify"
445
445
},
446
446
"repoExplorer": {
447
447
"collections": "Collections",
···
779
779
"externalDidRequired": "External did:web is required",
780
780
"externalDidFormat": "External DID must start with did:web:",
781
781
"emailRequired": "Email is required for email verification",
782
-
"discordRequired": "Discord ID is required for Discord verification",
782
+
"discordRequired": "Discord username is required for Discord verification",
783
783
"telegramRequired": "Telegram username is required for Telegram verification",
784
-
"signalRequired": "Phone number required",
784
+
"signalRequired": "Signal username required",
785
785
"passkeysNotSupported": "Passkeys not supported in this browser",
786
786
"passkeyCancelled": "Cancelled",
787
787
"passkeyFailed": "Passkey registration failed"
+14
-14
frontend/src/locales/fi.json
+14
-14
frontend/src/locales/fi.json
···
84
84
"emailAddress": "Sähköpostiosoite",
85
85
"emailPlaceholder": "sinä@esimerkki.fi",
86
86
"discord": "Discord",
87
-
"discordId": "Discord-käyttäjätunnus",
88
-
"discordIdPlaceholder": "Discord-käyttäjätunnuksesi",
89
-
"discordIdHint": "Numeerinen Discord-käyttäjätunnuksesi (ota Kehittäjätila käyttöön löytääksesi sen)",
90
-
"discordInUseWarning": "Tämä Discord-tunnus on jo yhdistetty toiseen tiliin.",
87
+
"discordUsername": "Discord-käyttäjänimi",
88
+
"discordUsernamePlaceholder": "käyttäjänimesi",
89
+
"discordInUseWarning": "Tämä Discord-käyttäjänimi on jo yhdistetty toiseen tiliin.",
91
90
"telegram": "Telegram",
92
91
"telegramUsername": "Telegram-käyttäjänimi",
93
92
"telegramUsernamePlaceholder": "@käyttäjänimesi",
94
93
"telegramInUseWarning": "Tämä Telegram-käyttäjänimi on jo yhdistetty toiseen tiliin.",
95
94
"signal": "Signal",
96
-
"signalNumber": "Signal-puhelinnumero",
97
-
"signalNumberPlaceholder": "+358401234567",
98
-
"signalNumberHint": "Sisällytä maakoodi (esim. +358 Suomelle)",
99
-
"signalInUseWarning": "Tämä Signal-numero on jo yhdistetty toiseen tiliin.",
95
+
"signalUsername": "Signal-käyttäjänimi",
96
+
"signalUsernamePlaceholder": "käyttäjänimi.01",
97
+
"signalInUseWarning": "Tämä Signal-käyttäjänimi on jo yhdistetty toiseen tiliin.",
100
98
"notConfigured": "ei määritetty",
101
99
"inviteCode": "Kutsukoodi",
102
100
"inviteCodePlaceholder": "Syötä kutsukoodisi",
···
118
116
"externalDidRequired": "Ulkoinen did:web vaaditaan",
119
117
"externalDidFormat": "Ulkoisen DID:n on alettava did:web:",
120
118
"emailRequired": "Sähköposti vaaditaan sähköpostivahvistukseen",
121
-
"discordIdRequired": "Discord-tunnus vaaditaan Discord-vahvistukseen",
119
+
"discordUsernameRequired": "Discord-käyttäjänimi vaaditaan Discord-vahvistukseen",
122
120
"telegramRequired": "Telegram-käyttäjänimi vaaditaan Telegram-vahvistukseen",
123
-
"signalRequired": "Puhelinnumero vaaditaan Signal-vahvistukseen"
121
+
"signalRequired": "Signal-käyttäjänimi vaaditaan Signal-vahvistukseen"
124
122
}
125
123
},
126
124
"dashboard": {
···
433
431
"verifiedSuccess": "{channel} vahvistettu",
434
432
"messageHistory": "Viestihistoria",
435
433
"noMessages": "Viestejä ei löytynyt.",
436
-
"discordInUseWarning": "Tämä Discord-tunnus on jo yhdistetty toiseen tiliin.",
434
+
"discordInUseWarning": "Tämä Discord-käyttäjänimi on jo yhdistetty toiseen tiliin.",
437
435
"telegramInUseWarning": "Tämä Telegram-käyttäjänimi on jo yhdistetty toiseen tiliin.",
438
-
"signalInUseWarning": "Tämä Signal-numero on jo yhdistetty toiseen tiliin.",
436
+
"signalInUseWarning": "Tämä Signal-käyttäjänimi on jo yhdistetty toiseen tiliin.",
439
437
"telegramStartBot": "Tai lähetä /start {handle} käyttäjälle @{botUsername} manuaalisesti",
440
438
"telegramOpenLink": "Avaa Telegram vahvistaaksesi",
439
+
"discordStartBot": "Lähetä @{botUsername}-botille viesti /start {handle} Discordissa",
440
+
"discordOpenLink": "Avaa Discord vahvistaaksesi",
441
441
"failedToLoad": "Asetusten lataus epäonnistui",
442
442
"failedToSave": "Asetusten tallennus epäonnistui",
443
443
"failedToVerify": "Vahvistus epäonnistui",
···
751
751
"passkeysNotSupported": "Pääsyavaimia ei tueta tässä selaimessa. Luo salasanapohjainen tili tai käytä selainta, joka tukee pääsyavaimia.",
752
752
"passkeyCancelled": "Pääsyavaimen luominen peruutettu",
753
753
"passkeyFailed": "Pääsyavaimen rekisteröinti epäonnistui",
754
-
"signalRequired": "Puhelinnumero vaaditaan Signal-vahvistukseen",
754
+
"signalRequired": "Signal-käyttäjänimi vaaditaan Signal-vahvistukseen",
755
755
"inviteRequired": "Kutsukoodi vaaditaan",
756
756
"externalDidRequired": "Ulkoinen did:web vaaditaan",
757
757
"emailRequired": "Sähköposti vaaditaan sähköpostivahvistukseen",
758
758
"telegramRequired": "Telegram-käyttäjänimi vaaditaan Telegram-vahvistukseen",
759
759
"externalDidFormat": "Ulkoisen DID:n on alettava did:web:",
760
-
"discordRequired": "Discord-tunnus vaaditaan Discord-vahvistukseen"
760
+
"discordRequired": "Discord-käyttäjänimi vaaditaan Discord-vahvistukseen"
761
761
},
762
762
"identityType": "Identiteettityyppi",
763
763
"identityTypeHint": "Valitse, miten hajautettua identiteettiäsi hallitaan.",
+14
-14
frontend/src/locales/ja.json
+14
-14
frontend/src/locales/ja.json
···
84
84
"emailAddress": "メールアドレス",
85
85
"emailPlaceholder": "you@example.com",
86
86
"discord": "Discord",
87
-
"discordId": "Discord ユーザー ID",
88
-
"discordIdPlaceholder": "Discord ユーザー ID",
89
-
"discordIdHint": "数値の Discord ユーザー ID(開発者モードを有効にして確認)",
90
-
"discordInUseWarning": "この Discord ID は既に別のアカウントに関連付けられています。",
87
+
"discordUsername": "Discord ユーザー名",
88
+
"discordUsernamePlaceholder": "yourusername",
89
+
"discordInUseWarning": "この Discord ユーザー名は既に別のアカウントに関連付けられています。",
91
90
"telegram": "Telegram",
92
91
"telegramUsername": "Telegram ユーザー名",
93
92
"telegramUsernamePlaceholder": "@yourusername",
94
93
"telegramInUseWarning": "この Telegram ユーザー名は既に別のアカウントに関連付けられています。",
95
94
"signal": "Signal",
96
-
"signalNumber": "Signal 電話番号",
97
-
"signalNumberPlaceholder": "+81XXXXXXXXXX",
98
-
"signalNumberHint": "国番号を含めてください(例: 日本は +81)",
99
-
"signalInUseWarning": "この Signal 番号は既に別のアカウントに関連付けられています。",
95
+
"signalUsername": "Signal ユーザー名",
96
+
"signalUsernamePlaceholder": "username.01",
97
+
"signalInUseWarning": "この Signal ユーザー名は既に別のアカウントに使用されています。",
100
98
"notConfigured": "未設定",
101
99
"inviteCode": "招待コード",
102
100
"inviteCodePlaceholder": "招待コードを入力",
···
118
116
"externalDidRequired": "外部 did:web は必須です",
119
117
"externalDidFormat": "外部 DID は did:web: で始まる必要があります",
120
118
"emailRequired": "メール認証にはメールアドレスが必要です",
121
-
"discordIdRequired": "Discord 認証には Discord ID が必要です",
119
+
"discordUsernameRequired": "Discord 認証には Discord ユーザー名が必要です",
122
120
"telegramRequired": "Telegram 認証には Telegram ユーザー名が必要です",
123
-
"signalRequired": "Signal 認証には電話番号が必要です"
121
+
"signalRequired": "Signal 認証にはユーザー名が必要です"
124
122
}
125
123
},
126
124
"dashboard": {
···
433
431
"verifiedSuccess": "{channel} を確認しました",
434
432
"messageHistory": "メッセージ履歴",
435
433
"noMessages": "メッセージが見つかりません。",
436
-
"discordInUseWarning": "この Discord ID は既に別のアカウントに関連付けられています。",
434
+
"discordInUseWarning": "この Discord ユーザー名は既に別のアカウントに関連付けられています。",
437
435
"telegramInUseWarning": "この Telegram ユーザー名は既に別のアカウントに関連付けられています。",
438
-
"signalInUseWarning": "この Signal 番号は既に別のアカウントに関連付けられています。",
436
+
"signalInUseWarning": "この Signal ユーザー名は既に別のアカウントに使用されています。",
439
437
"telegramStartBot": "または @{botUsername} に /start {handle} を手動で送信",
440
438
"telegramOpenLink": "Telegram で確認する",
439
+
"discordStartBot": "Discordで @{botUsername} にDMして /start {handle} を送信",
440
+
"discordOpenLink": "Discordで認証",
441
441
"failedToLoad": "設定の読み込みに失敗しました",
442
442
"failedToSave": "設定の保存に失敗しました",
443
443
"failedToVerify": "確認に失敗しました",
···
751
751
"passkeysNotSupported": "このブラウザではパスキーがサポートされていません。パスワードベースのアカウントを作成するか、パスキーをサポートするブラウザを使用してください。",
752
752
"passkeyCancelled": "パスキーの作成がキャンセルされました",
753
753
"passkeyFailed": "パスキーの登録に失敗しました",
754
-
"signalRequired": "Signal認証には電話番号が必要です",
754
+
"signalRequired": "Signal認証にはユーザー名が必要です",
755
755
"inviteRequired": "招待コードが必要です",
756
756
"externalDidRequired": "外部did:webが必要です",
757
757
"emailRequired": "メール認証にはメールアドレスが必要です",
758
758
"telegramRequired": "Telegram認証にはTelegramユーザー名が必要です",
759
759
"externalDidFormat": "外部DIDはdid:web:で始まる必要があります",
760
-
"discordRequired": "Discord認証にはDiscord IDが必要です"
760
+
"discordRequired": "Discord認証にはDiscordユーザー名が必要です"
761
761
},
762
762
"identityType": "アイデンティティタイプ",
763
763
"identityTypeHint": "分散型アイデンティティの管理方法を選択してください。",
+14
-14
frontend/src/locales/ko.json
+14
-14
frontend/src/locales/ko.json
···
84
84
"emailAddress": "이메일 주소",
85
85
"emailPlaceholder": "you@example.com",
86
86
"discord": "Discord",
87
-
"discordId": "Discord 사용자 ID",
88
-
"discordIdPlaceholder": "Discord 사용자 ID",
89
-
"discordIdHint": "숫자 Discord 사용자 ID (개발자 모드를 활성화하여 찾기)",
90
-
"discordInUseWarning": "이 Discord ID는 이미 다른 계정과 연결되어 있습니다.",
87
+
"discordUsername": "Discord 사용자명",
88
+
"discordUsernamePlaceholder": "yourusername",
89
+
"discordInUseWarning": "이 Discord 사용자명은 이미 다른 계정과 연결되어 있습니다.",
91
90
"telegram": "Telegram",
92
91
"telegramUsername": "Telegram 사용자 이름",
93
92
"telegramUsernamePlaceholder": "@yourusername",
94
93
"telegramInUseWarning": "이 Telegram 사용자 이름은 이미 다른 계정과 연결되어 있습니다.",
95
94
"signal": "Signal",
96
-
"signalNumber": "Signal 전화번호",
97
-
"signalNumberPlaceholder": "+821012345678",
98
-
"signalNumberHint": "국가 코드 포함 (예: 한국 +82)",
99
-
"signalInUseWarning": "이 Signal 번호는 이미 다른 계정과 연결되어 있습니다.",
95
+
"signalUsername": "Signal 사용자명",
96
+
"signalUsernamePlaceholder": "username.01",
97
+
"signalInUseWarning": "이 Signal 사용자명은 이미 다른 계정에서 사용 중입니다.",
100
98
"notConfigured": "구성되지 않음",
101
99
"inviteCode": "초대 코드",
102
100
"inviteCodePlaceholder": "초대 코드 입력",
···
118
116
"externalDidRequired": "외부 did:web은 필수입니다",
119
117
"externalDidFormat": "외부 DID는 did:web:으로 시작해야 합니다",
120
118
"emailRequired": "이메일 인증에는 이메일이 필요합니다",
121
-
"discordIdRequired": "Discord 인증에는 Discord ID가 필요합니다",
119
+
"discordUsernameRequired": "Discord 인증에는 Discord 사용자명이 필요합니다",
122
120
"telegramRequired": "Telegram 인증에는 Telegram 사용자 이름이 필요합니다",
123
-
"signalRequired": "Signal 인증에는 전화번호가 필요합니다"
121
+
"signalRequired": "Signal 인증에는 사용자명이 필요합니다"
124
122
}
125
123
},
126
124
"dashboard": {
···
433
431
"verifiedSuccess": "{channel} 인증 완료",
434
432
"messageHistory": "메시지 기록",
435
433
"noMessages": "메시지가 없습니다.",
436
-
"discordInUseWarning": "이 Discord ID는 이미 다른 계정과 연결되어 있습니다.",
434
+
"discordInUseWarning": "이 Discord 사용자명은 이미 다른 계정과 연결되어 있습니다.",
437
435
"telegramInUseWarning": "이 Telegram 사용자 이름은 이미 다른 계정과 연결되어 있습니다.",
438
-
"signalInUseWarning": "이 Signal 번호는 이미 다른 계정과 연결되어 있습니다.",
436
+
"signalInUseWarning": "이 Signal 사용자명은 이미 다른 계정에서 사용 중입니다.",
439
437
"telegramStartBot": "또는 @{botUsername}에게 /start {handle}을 직접 보내세요",
440
438
"telegramOpenLink": "Telegram에서 인증하기",
439
+
"discordStartBot": "Discord에서 @{botUsername}에게 DM으로 /start {handle} 보내기",
440
+
"discordOpenLink": "Discord에서 인증",
441
441
"failedToLoad": "설정 로딩 실패",
442
442
"failedToSave": "설정 저장 실패",
443
443
"failedToVerify": "인증 실패",
···
751
751
"passkeysNotSupported": "이 브라우저에서 패스키가 지원되지 않습니다. 비밀번호 기반 계정을 만들거나 패스키를 지원하는 브라우저를 사용하세요.",
752
752
"passkeyCancelled": "패스키 생성이 취소되었습니다",
753
753
"passkeyFailed": "패스키 등록에 실패했습니다",
754
-
"signalRequired": "Signal 인증에는 전화번호가 필요합니다",
754
+
"signalRequired": "Signal 인증에는 사용자명이 필요합니다",
755
755
"inviteRequired": "초대 코드가 필요합니다",
756
756
"externalDidRequired": "외부 did:web이 필요합니다",
757
757
"emailRequired": "이메일 인증에는 이메일이 필요합니다",
758
758
"telegramRequired": "Telegram 인증에는 Telegram 사용자 이름이 필요합니다",
759
759
"externalDidFormat": "외부 DID는 did:web:으로 시작해야 합니다",
760
-
"discordRequired": "Discord 인증에는 Discord ID가 필요합니다"
760
+
"discordRequired": "Discord 인증에는 Discord 사용자명이 필요합니다"
761
761
},
762
762
"identityType": "아이덴티티 유형",
763
763
"identityTypeHint": "분산 아이덴티티 관리 방법을 선택하세요.",
+14
-14
frontend/src/locales/sv.json
+14
-14
frontend/src/locales/sv.json
···
84
84
"emailAddress": "E-postadress",
85
85
"emailPlaceholder": "du@exempel.se",
86
86
"discord": "Discord",
87
-
"discordId": "Discord användar-ID",
88
-
"discordIdPlaceholder": "Ditt Discord användar-ID",
89
-
"discordIdHint": "Ditt numeriska Discord användar-ID (aktivera Utvecklarläge för att hitta det)",
90
-
"discordInUseWarning": "Detta Discord-ID är redan kopplat till ett annat konto.",
87
+
"discordUsername": "Discord-användarnamn",
88
+
"discordUsernamePlaceholder": "dittanvändarnamn",
89
+
"discordInUseWarning": "Detta Discord-användarnamn är redan kopplat till ett annat konto.",
91
90
"telegram": "Telegram",
92
91
"telegramUsername": "Telegram-användarnamn",
93
92
"telegramUsernamePlaceholder": "@dittanvändarnamn",
94
93
"telegramInUseWarning": "Detta Telegram-användarnamn är redan kopplat till ett annat konto.",
95
94
"signal": "Signal",
96
-
"signalNumber": "Signal-telefonnummer",
97
-
"signalNumberPlaceholder": "+46701234567",
98
-
"signalNumberHint": "Inkludera landskod (t.ex. +46 för Sverige)",
99
-
"signalInUseWarning": "Detta Signal-nummer är redan kopplat till ett annat konto.",
95
+
"signalUsername": "Signal-användarnamn",
96
+
"signalUsernamePlaceholder": "användarnamn.01",
97
+
"signalInUseWarning": "Detta Signal-användarnamn är redan kopplat till ett annat konto.",
100
98
"notConfigured": "ej konfigurerad",
101
99
"inviteCode": "Inbjudningskod",
102
100
"inviteCodePlaceholder": "Ange din inbjudningskod",
···
118
116
"externalDidRequired": "Extern did:web krävs",
119
117
"externalDidFormat": "Extern DID måste börja med did:web:",
120
118
"emailRequired": "E-post krävs för e-postverifiering",
121
-
"discordIdRequired": "Discord-ID krävs för Discord-verifiering",
119
+
"discordUsernameRequired": "Discord-användarnamn krävs för Discord-verifiering",
122
120
"telegramRequired": "Telegram-användarnamn krävs för Telegram-verifiering",
123
-
"signalRequired": "Telefonnummer krävs för Signal-verifiering"
121
+
"signalRequired": "Signal-användarnamn krävs för Signal-verifiering"
124
122
}
125
123
},
126
124
"dashboard": {
···
433
431
"verifiedSuccess": "{channel} verifierad",
434
432
"messageHistory": "Meddelandehistorik",
435
433
"noMessages": "Inga meddelanden hittades.",
436
-
"discordInUseWarning": "Detta Discord-ID är redan kopplat till ett annat konto.",
434
+
"discordInUseWarning": "Detta Discord-användarnamn är redan kopplat till ett annat konto.",
437
435
"telegramInUseWarning": "Detta Telegram-användarnamn är redan kopplat till ett annat konto.",
438
-
"signalInUseWarning": "Detta Signal-nummer är redan kopplat till ett annat konto.",
436
+
"signalInUseWarning": "Detta Signal-användarnamn är redan kopplat till ett annat konto.",
439
437
"telegramStartBot": "Eller skicka /start {handle} till @{botUsername} manuellt",
440
438
"telegramOpenLink": "Öppna Telegram för att verifiera",
439
+
"discordStartBot": "DM:a @{botUsername} på Discord och skicka /start {handle}",
440
+
"discordOpenLink": "Öppna Discord för att verifiera",
441
441
"failedToLoad": "Kunde inte ladda inställningar",
442
442
"failedToSave": "Kunde inte spara inställningar",
443
443
"failedToVerify": "Verifiering misslyckades",
···
751
751
"passkeysNotSupported": "Nycklar stöds inte i denna webbläsare. Skapa ett lösenordsbaserat konto eller använd en webbläsare som stöder nycklar.",
752
752
"passkeyCancelled": "Nyckelskapande avbröts",
753
753
"passkeyFailed": "Nyckelregistrering misslyckades",
754
-
"signalRequired": "Telefonnummer krävs för Signal-verifiering",
754
+
"signalRequired": "Signal-användarnamn krävs för Signal-verifiering",
755
755
"inviteRequired": "Inbjudningskod krävs",
756
756
"externalDidRequired": "Extern did:web krävs",
757
757
"emailRequired": "E-post krävs för e-postverifiering",
758
758
"telegramRequired": "Telegram-användarnamn krävs för Telegram-verifiering",
759
759
"externalDidFormat": "Extern DID måste börja med did:web:",
760
-
"discordRequired": "Discord-ID krävs för Discord-verifiering"
760
+
"discordRequired": "Discord-användarnamn krävs för Discord-verifiering"
761
761
},
762
762
"identityType": "Identitetstyp",
763
763
"identityTypeHint": "Välj hur din decentraliserade identitet ska hanteras.",
+14
-14
frontend/src/locales/zh.json
+14
-14
frontend/src/locales/zh.json
···
84
84
"emailAddress": "电子邮件地址",
85
85
"emailPlaceholder": "you@example.com",
86
86
"discord": "Discord",
87
-
"discordId": "Discord 用户 ID",
88
-
"discordIdPlaceholder": "您的 Discord 用户 ID",
89
-
"discordIdHint": "您的 Discord 数字用户 ID(开启开发者模式后可以复制)",
90
-
"discordInUseWarning": "此 Discord ID 已与另一个账户关联。",
87
+
"discordUsername": "Discord 用户名",
88
+
"discordUsernamePlaceholder": "yourusername",
89
+
"discordInUseWarning": "此 Discord 用户名已与另一个账户关联。",
91
90
"telegram": "Telegram",
92
91
"telegramUsername": "Telegram 用户名",
93
92
"telegramUsernamePlaceholder": "@yourusername",
94
93
"telegramInUseWarning": "此 Telegram 用户名已与另一个账户关联。",
95
94
"signal": "Signal",
96
-
"signalNumber": "Signal 电话号码",
97
-
"signalNumberPlaceholder": "+1234567890",
98
-
"signalNumberHint": "包含国家代码(例如中国为 +86)",
99
-
"signalInUseWarning": "此 Signal 号码已与另一个账户关联。",
95
+
"signalUsername": "Signal 用户名",
96
+
"signalUsernamePlaceholder": "username.01",
97
+
"signalInUseWarning": "此 Signal 用户名已被其他账户使用。",
100
98
"notConfigured": "未配置",
101
99
"inviteCode": "邀请码",
102
100
"inviteCodePlaceholder": "输入您的邀请码",
···
118
116
"externalDidRequired": "请输入您的 did:web",
119
117
"externalDidFormat": "DID 必须以 did:web: 开头",
120
118
"emailRequired": "使用邮箱验证需要填写邮箱地址",
121
-
"discordIdRequired": "使用 Discord 验证需要填写 Discord ID",
119
+
"discordUsernameRequired": "使用 Discord 验证需要填写 Discord 用户名",
122
120
"telegramRequired": "使用 Telegram 验证需要填写用户名",
123
-
"signalRequired": "使用 Signal 验证需要填写电话号码"
121
+
"signalRequired": "使用 Signal 验证需要填写用户名"
124
122
}
125
123
},
126
124
"dashboard": {
···
433
431
"verifiedSuccess": "{channel} 验证成功",
434
432
"messageHistory": "消息历史",
435
433
"noMessages": "暂无消息记录",
436
-
"discordInUseWarning": "此 Discord ID 已与另一个账户关联。",
434
+
"discordInUseWarning": "此 Discord 用户名已与另一个账户关联。",
437
435
"telegramInUseWarning": "此 Telegram 用户名已与另一个账户关联。",
438
-
"signalInUseWarning": "此 Signal 号码已与另一个账户关联。",
436
+
"signalInUseWarning": "此 Signal 用户名已与另一个账户关联。",
439
437
"telegramStartBot": "或手动向 @{botUsername} 发送 /start {handle}",
440
438
"telegramOpenLink": "打开 Telegram 验证",
439
+
"discordStartBot": "在 Discord 上私信 @{botUsername} 并发送 /start {handle}",
440
+
"discordOpenLink": "打开 Discord 验证",
441
441
"failedToLoad": "加载偏好设置失败",
442
442
"failedToSave": "保存偏好设置失败",
443
443
"failedToVerify": "验证失败",
···
774
774
"externalDidRequired": "请输入您的 did:web",
775
775
"externalDidFormat": "DID 必须以 did:web: 开头",
776
776
"emailRequired": "使用邮箱验证需要填写邮箱地址",
777
-
"discordRequired": "使用 Discord 验证需要填写 Discord ID",
777
+
"discordRequired": "使用 Discord 验证需要填写 Discord 用户名",
778
778
"telegramRequired": "使用 Telegram 验证需要填写用户名",
779
-
"signalRequired": "使用 Signal 验证需要填写电话号码",
779
+
"signalRequired": "使用 Signal 验证需要填写用户名",
780
780
"passkeysNotSupported": "此浏览器不支持通行密钥。请使用其他浏览器或使用密码注册。",
781
781
"passkeyCancelled": "通行密钥创建已取消",
782
782
"passkeyFailed": "通行密钥注册失败"
+10
-11
frontend/src/routes/OAuthRegister.svelte
+10
-11
frontend/src/routes/OAuthRegister.svelte
···
123
123
if (!info.email.trim()) return $_('registerPasskey.errors.emailRequired')
124
124
break
125
125
case 'discord':
126
-
if (!info.discordId?.trim()) return $_('registerPasskey.errors.discordRequired')
126
+
if (!info.discordUsername?.trim()) return $_('registerPasskey.errors.discordRequired')
127
127
break
128
128
case 'telegram':
129
129
if (!info.telegramUsername?.trim()) return $_('registerPasskey.errors.telegramRequired')
130
130
break
131
131
case 'signal':
132
-
if (!info.signalNumber?.trim()) return $_('registerPasskey.errors.signalRequired')
132
+
if (!info.signalUsername?.trim()) return $_('registerPasskey.errors.signalRequired')
133
133
break
134
134
}
135
135
return null
···
389
389
</div>
390
390
{:else if flow.info.verificationChannel === 'discord'}
391
391
<div class="field">
392
-
<label for="discord-id">{$_('register.discordId')}</label>
392
+
<label for="discord-username">{$_('register.discordUsername')}</label>
393
393
<input
394
-
id="discord-id"
394
+
id="discord-username"
395
395
type="text"
396
-
bind:value={flow.info.discordId}
397
-
placeholder={$_('register.discordIdPlaceholder')}
396
+
bind:value={flow.info.discordUsername}
397
+
placeholder={$_('register.discordUsernamePlaceholder')}
398
398
disabled={flow.state.submitting}
399
399
required
400
400
/>
401
-
<p class="hint">{$_('register.discordIdHint')}</p>
402
401
</div>
403
402
{:else if flow.info.verificationChannel === 'telegram'}
404
403
<div class="field">
···
414
413
</div>
415
414
{:else if flow.info.verificationChannel === 'signal'}
416
415
<div class="field">
417
-
<label for="signal-number">{$_('register.signalNumber')}</label>
416
+
<label for="signal-number">{$_('register.signalUsername')}</label>
418
417
<input
419
418
id="signal-number"
420
419
type="tel"
421
-
bind:value={flow.info.signalNumber}
422
-
placeholder={$_('register.signalNumberPlaceholder')}
420
+
bind:value={flow.info.signalUsername}
421
+
placeholder={$_('register.signalUsernamePlaceholder')}
423
422
disabled={flow.state.submitting}
424
423
required
425
424
/>
426
-
<p class="hint">{$_('register.signalNumberHint')}</p>
425
+
<p class="hint">{$_('register.signalUsernameHint')}</p>
427
426
</div>
428
427
{/if}
429
428
</div>
+14
-15
frontend/src/routes/OAuthSsoRegister.svelte
+14
-15
frontend/src/routes/OAuthSsoRegister.svelte
···
30
30
let providerEmailOriginal = $state<string | null>(null)
31
31
let inviteCode = $state('')
32
32
let verificationChannel = $state('email')
33
-
let discordId = $state('')
33
+
let discordUsername = $state('')
34
34
let telegramUsername = $state('')
35
-
let signalNumber = $state('')
35
+
let signalUsername = $state('')
36
36
37
37
let handleAvailable = $state<boolean | null>(null)
38
38
let checkingHandle = $state(false)
···
189
189
case 'email':
190
190
return !!email.trim()
191
191
case 'discord':
192
-
return !!discordId.trim()
192
+
return !!discordUsername.trim()
193
193
case 'telegram':
194
194
return !!telegramUsername.trim()
195
195
case 'signal':
196
-
return !!signalNumber.trim()
196
+
return !!signalUsername.trim()
197
197
default:
198
198
return false
199
199
}
···
234
234
email: email || null,
235
235
invite_code: inviteCode || null,
236
236
verification_channel: verificationChannel,
237
-
discord_id: discordId || null,
237
+
discord_username: discordUsername || null,
238
238
telegram_username: telegramUsername || null,
239
-
signal_number: signalNumber || null,
239
+
signal_username: signalUsername || null,
240
240
did_type: didType,
241
241
did: didType === 'web-external' ? externalDid.trim() : null,
242
242
}),
···
379
379
</div>
380
380
{:else if verificationChannel === 'discord'}
381
381
<div class="field">
382
-
<label for="discord-id">{$_('register.discordId')}</label>
382
+
<label for="discord-username">{$_('register.discordUsername')}</label>
383
383
<input
384
-
id="discord-id"
384
+
id="discord-username"
385
385
type="text"
386
-
bind:value={discordId}
387
-
placeholder={$_('register.discordIdPlaceholder')}
386
+
bind:value={discordUsername}
387
+
placeholder={$_('register.discordUsernamePlaceholder')}
388
388
disabled={submitting}
389
389
required
390
390
/>
391
-
<p class="hint">{$_('register.discordIdHint')}</p>
392
391
</div>
393
392
{:else if verificationChannel === 'telegram'}
394
393
<div class="field">
···
404
403
</div>
405
404
{:else if verificationChannel === 'signal'}
406
405
<div class="field">
407
-
<label for="signal-number">{$_('register.signalNumber')}</label>
406
+
<label for="signal-number">{$_('register.signalUsername')}</label>
408
407
<input
409
408
id="signal-number"
410
409
type="tel"
411
-
bind:value={signalNumber}
412
-
placeholder={$_('register.signalNumberPlaceholder')}
410
+
bind:value={signalUsername}
411
+
placeholder={$_('register.signalUsernamePlaceholder')}
413
412
disabled={submitting}
414
413
required
415
414
/>
416
-
<p class="hint">{$_('register.signalNumberHint')}</p>
415
+
<p class="hint">{$_('register.signalUsernameHint')}</p>
417
416
</div>
418
417
{/if}
419
418
</div>
+10
-11
frontend/src/routes/Register.svelte
+10
-11
frontend/src/routes/Register.svelte
···
136
136
if (!info.email.trim()) return $_('registerPasskey.errors.emailRequired')
137
137
break
138
138
case 'discord':
139
-
if (!info.discordId?.trim()) return $_('registerPasskey.errors.discordRequired')
139
+
if (!info.discordUsername?.trim()) return $_('registerPasskey.errors.discordRequired')
140
140
break
141
141
case 'telegram':
142
142
if (!info.telegramUsername?.trim()) return $_('registerPasskey.errors.telegramRequired')
143
143
break
144
144
case 'signal':
145
-
if (!info.signalNumber?.trim()) return $_('registerPasskey.errors.signalRequired')
145
+
if (!info.signalUsername?.trim()) return $_('registerPasskey.errors.signalRequired')
146
146
break
147
147
}
148
148
return null
···
409
409
</div>
410
410
{:else if flow.info.verificationChannel === 'discord'}
411
411
<div class="field">
412
-
<label for="discord-id">{$_('register.discordId')}</label>
412
+
<label for="discord-username">{$_('register.discordUsername')}</label>
413
413
<input
414
-
id="discord-id"
414
+
id="discord-username"
415
415
type="text"
416
-
bind:value={flow.info.discordId}
417
-
placeholder={$_('register.discordIdPlaceholder')}
416
+
bind:value={flow.info.discordUsername}
417
+
placeholder={$_('register.discordUsernamePlaceholder')}
418
418
disabled={flow.state.submitting}
419
419
required
420
420
/>
421
-
<p class="hint">{$_('register.discordIdHint')}</p>
422
421
</div>
423
422
{:else if flow.info.verificationChannel === 'telegram'}
424
423
<div class="field">
···
434
433
</div>
435
434
{:else if flow.info.verificationChannel === 'signal'}
436
435
<div class="field">
437
-
<label for="signal-number">{$_('register.signalNumber')}</label>
436
+
<label for="signal-number">{$_('register.signalUsername')}</label>
438
437
<input
439
438
id="signal-number"
440
439
type="tel"
441
-
bind:value={flow.info.signalNumber}
442
-
placeholder={$_('register.signalNumberPlaceholder')}
440
+
bind:value={flow.info.signalUsername}
441
+
placeholder={$_('register.signalUsernamePlaceholder')}
443
442
disabled={flow.state.submitting}
444
443
required
445
444
/>
446
-
<p class="hint">{$_('register.signalNumberHint')}</p>
445
+
<p class="hint">{$_('register.signalUsernameHint')}</p>
447
446
</div>
448
447
{/if}
449
448
+12
-13
frontend/src/routes/RegisterPassword.svelte
+12
-13
frontend/src/routes/RegisterPassword.svelte
···
133
133
if (!info.email.trim()) return $_('register.validation.emailRequired')
134
134
break
135
135
case 'discord':
136
-
if (!info.discordId?.trim()) return $_('register.validation.discordIdRequired')
136
+
if (!info.discordUsername?.trim()) return $_('register.validation.discordUsernameRequired')
137
137
break
138
138
case 'telegram':
139
139
if (!info.telegramUsername?.trim()) return $_('register.validation.telegramRequired')
140
140
break
141
141
case 'signal':
142
-
if (!info.signalNumber?.trim()) return $_('register.validation.signalRequired')
142
+
if (!info.signalUsername?.trim()) return $_('register.validation.signalRequired')
143
143
break
144
144
}
145
145
return null
···
381
381
</div>
382
382
{:else if flow.info.verificationChannel === 'discord'}
383
383
<div class="field">
384
-
<label for="discord-id">{$_('register.discordId')}</label>
384
+
<label for="discord-username">{$_('register.discordUsername')}</label>
385
385
<input
386
-
id="discord-id"
386
+
id="discord-username"
387
387
type="text"
388
-
bind:value={flow.info.discordId}
389
-
onblur={() => flow?.checkCommsChannelInUse('discord', flow.info.discordId ?? '')}
390
-
placeholder={$_('register.discordIdPlaceholder')}
388
+
bind:value={flow.info.discordUsername}
389
+
onblur={() => flow?.checkCommsChannelInUse('discord', flow.info.discordUsername ?? '')}
390
+
placeholder={$_('register.discordUsernamePlaceholder')}
391
391
disabled={flow.state.submitting}
392
392
required
393
393
/>
394
-
<p class="hint">{$_('register.discordIdHint')}</p>
395
394
{#if flow.state.discordInUse}
396
395
<p class="hint warning">{$_('register.discordInUseWarning')}</p>
397
396
{/if}
···
414
413
</div>
415
414
{:else if flow.info.verificationChannel === 'signal'}
416
415
<div class="field">
417
-
<label for="signal-number">{$_('register.signalNumber')}</label>
416
+
<label for="signal-number">{$_('register.signalUsername')}</label>
418
417
<input
419
418
id="signal-number"
420
419
type="tel"
421
-
bind:value={flow.info.signalNumber}
422
-
onblur={() => flow?.checkCommsChannelInUse('signal', flow.info.signalNumber ?? '')}
423
-
placeholder={$_('register.signalNumberPlaceholder')}
420
+
bind:value={flow.info.signalUsername}
421
+
onblur={() => flow?.checkCommsChannelInUse('signal', flow.info.signalUsername ?? '')}
422
+
placeholder={$_('register.signalUsernamePlaceholder')}
424
423
disabled={flow.state.submitting}
425
424
required
426
425
/>
427
-
<p class="hint">{$_('register.signalNumberHint')}</p>
426
+
<p class="hint">{$_('register.signalUsernameHint')}</p>
428
427
{#if flow.state.signalInUse}
429
428
<p class="hint warning">{$_('register.signalInUseWarning')}</p>
430
429
{/if}
+14
-15
frontend/src/routes/SsoRegisterComplete.svelte
+14
-15
frontend/src/routes/SsoRegisterComplete.svelte
···
40
40
let providerEmailOriginal = $state<string | null>(null)
41
41
let inviteCode = $state('')
42
42
let verificationChannel = $state('email')
43
-
let discordId = $state('')
43
+
let discordUsername = $state('')
44
44
let telegramUsername = $state('')
45
-
let signalNumber = $state('')
45
+
let signalUsername = $state('')
46
46
47
47
let handleAvailable = $state<boolean | null>(null)
48
48
let checkingHandle = $state(false)
···
204
204
case 'email':
205
205
return !!email.trim()
206
206
case 'discord':
207
-
return !!discordId.trim()
207
+
return !!discordUsername.trim()
208
208
case 'telegram':
209
209
return !!telegramUsername.trim()
210
210
case 'signal':
211
-
return !!signalNumber.trim()
211
+
return !!signalUsername.trim()
212
212
default:
213
213
return false
214
214
}
···
281
281
email: email || null,
282
282
invite_code: inviteCode || null,
283
283
verification_channel: verificationChannel,
284
-
discord_id: discordId || null,
284
+
discord_username: discordUsername || null,
285
285
telegram_username: telegramUsername || null,
286
-
signal_number: signalNumber || null,
286
+
signal_username: signalUsername || null,
287
287
did_type: didType,
288
288
did: didType === 'web-external' ? externalDid.trim() : null,
289
289
}),
···
452
452
</div>
453
453
{:else if verificationChannel === 'discord'}
454
454
<div class="field">
455
-
<label for="discord-id">{$_('register.discordId')}</label>
455
+
<label for="discord-username">{$_('register.discordUsername')}</label>
456
456
<input
457
-
id="discord-id"
457
+
id="discord-username"
458
458
type="text"
459
-
bind:value={discordId}
460
-
placeholder={$_('register.discordIdPlaceholder')}
459
+
bind:value={discordUsername}
460
+
placeholder={$_('register.discordUsernamePlaceholder')}
461
461
disabled={submitting}
462
462
required
463
463
/>
464
-
<p class="hint">{$_('register.discordIdHint')}</p>
465
464
</div>
466
465
{:else if verificationChannel === 'telegram'}
467
466
<div class="field">
···
477
476
</div>
478
477
{:else if verificationChannel === 'signal'}
479
478
<div class="field">
480
-
<label for="signal-number">{$_('register.signalNumber')}</label>
479
+
<label for="signal-number">{$_('register.signalUsername')}</label>
481
480
<input
482
481
id="signal-number"
483
482
type="tel"
484
-
bind:value={signalNumber}
485
-
placeholder={$_('register.signalNumberPlaceholder')}
483
+
bind:value={signalUsername}
484
+
placeholder={$_('register.signalUsernamePlaceholder')}
486
485
disabled={submitting}
487
486
required
488
487
/>
489
-
<p class="hint">{$_('register.signalNumberHint')}</p>
488
+
<p class="hint">{$_('register.signalUsernameHint')}</p>
490
489
</div>
491
490
{/if}
492
491
</div>
+24
-8
frontend/src/routes/Verify.svelte
+24
-8
frontend/src/routes/Verify.svelte
···
33
33
let tokenFromUrl = $state(false)
34
34
let oauthRequestUri = $state<string | null>(null)
35
35
let telegramBotUsername = $state<string | undefined>(undefined)
36
+
let discordBotUsername = $state<string | undefined>(undefined)
37
+
let discordAppId = $state<string | undefined>(undefined)
36
38
37
39
const auth = $derived(getAuthState())
38
40
···
42
44
43
45
const session = $derived(getSession())
44
46
const isTelegram = $derived(pendingVerification?.channel === 'telegram')
47
+
const isDiscord = $derived(pendingVerification?.channel === 'discord')
48
+
const isBotVerified = $derived(isTelegram || isDiscord)
45
49
46
50
function parseQueryParams(): Record<string, string> {
47
51
return Object.fromEntries(new URLSearchParams(window.location.search))
···
102
106
}))
103
107
}
104
108
105
-
if (pendingVerification?.channel === 'telegram') {
109
+
if (pendingVerification?.channel === 'telegram' || pendingVerification?.channel === 'discord') {
106
110
try {
107
111
const serverInfo = await api.describeServer()
108
112
telegramBotUsername = serverInfo.telegramBotUsername
113
+
discordBotUsername = serverInfo.discordBotUsername
114
+
discordAppId = serverInfo.discordAppId
109
115
} catch {
110
116
}
111
117
}
···
121
127
122
128
let pollingVerification = false
123
129
$effect(() => {
124
-
if (mode === 'signup' && pendingVerification && (isTelegram || !verificationCode.trim())) {
130
+
if (mode === 'signup' && pendingVerification && (isBotVerified || !verificationCode.trim())) {
125
131
const currentPending = pendingVerification
126
132
const interval = setInterval(async () => {
127
-
if (pollingVerification || (!isTelegram && verificationCode.trim())) return
133
+
if (pollingVerification || (!isBotVerified && verificationCode.trim())) return
128
134
pollingVerification = true
129
135
try {
130
136
const result = await api.checkChannelVerified(currentPending.did, currentPending.channel)
···
447
453
448
454
{#if isTelegram && telegramBotUsername}
449
455
{@const encodedHandle = pendingVerification.handle.replaceAll('.', '_')}
450
-
<div class="telegram-hint">
456
+
<div class="bot-hint">
451
457
<p>
452
458
<a href="https://t.me/{telegramBotUsername}?start={encodedHandle}" target="_blank" rel="noopener">{$_('comms.telegramOpenLink')}</a>
453
459
</p>
···
456
462
</p>
457
463
<p class="waiting-text">{$_('verify.pleaseWait')}</p>
458
464
</div>
465
+
{:else if isDiscord && discordAppId}
466
+
<div class="bot-hint">
467
+
<p>
468
+
<a href="https://discord.com/users/{discordAppId}" target="_blank" rel="noopener">{$_('comms.discordOpenLink')}</a>
469
+
</p>
470
+
<p class="manual-text">
471
+
{$_('comms.discordStartBot', { values: { botUsername: discordBotUsername ?? 'the bot', handle: pendingVerification.handle } })}
472
+
</p>
473
+
<p class="waiting-text">{$_('verify.pleaseWait')}</p>
474
+
</div>
459
475
{:else}
460
476
<form onsubmit={(e) => { e.preventDefault(); handleSignupVerification(e); }}>
461
477
<div class="field">
···
610
626
padding: var(--space-4) var(--space-8);
611
627
}
612
628
613
-
.telegram-hint {
629
+
.bot-hint {
614
630
padding: var(--space-4);
615
631
background: var(--bg-secondary);
616
632
border-radius: var(--radius-md);
617
633
}
618
634
619
-
.telegram-hint p {
635
+
.bot-hint p {
620
636
margin: 0;
621
637
}
622
638
623
-
.telegram-hint .manual-text {
639
+
.bot-hint .manual-text {
624
640
font-size: var(--text-sm);
625
641
color: var(--text-secondary);
626
642
margin-top: var(--space-1);
627
643
}
628
644
629
-
.telegram-hint .waiting-text {
645
+
.bot-hint .waiting-text {
630
646
font-size: var(--text-sm);
631
647
color: var(--text-secondary);
632
648
margin-top: var(--space-2);
+2
-2
frontend/src/tests/mocks.ts
+2
-2
frontend/src/tests/mocks.ts
···
254
254
notificationPrefs: (overrides?: Record<string, unknown>) => ({
255
255
preferredChannel: "email",
256
256
email: "test@example.com",
257
-
discordId: null,
257
+
discordUsername: null,
258
258
discordVerified: false,
259
259
telegramUsername: null,
260
260
telegramVerified: false,
261
-
signalNumber: null,
261
+
signalUsername: null,
262
262
signalVerified: false,
263
263
...overrides,
264
264
}),
+3
migrations/20260205_discord_username.sql
+3
migrations/20260205_discord_username.sql
+1
migrations/20260206_signal_username.sql
+1
migrations/20260206_signal_username.sql
···
1
+
ALTER TABLE users RENAME COLUMN signal_number TO signal_username;