mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-07-20 06:52:33 +00:00
fix: improved discord & signal comms
This commit is contained in:
+2
-2
@@ -93,8 +93,8 @@ BACKUP_STORAGE_PATH=/var/lib/tranquil/backups
|
||||
# MAIL_FROM_ADDRESS=noreply@example.com
|
||||
# MAIL_FROM_NAME=My PDS
|
||||
# SENDMAIL_PATH=/usr/sbin/sendmail
|
||||
# Discord notifications (via webhook)
|
||||
# DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
|
||||
# Discord notifications (via bot DM)
|
||||
# DISCORD_BOT_TOKEN=bot-token
|
||||
# Telegram notifications (via bot)
|
||||
# TELEGRAM_BOT_TOKEN=bot-token
|
||||
# TELEGRAM_WEBHOOK_SECRET=random-secret
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"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",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "0350df76ef1a934f5262ae05856a921017a2dc02fe73513dc1243e219ebc3b76"
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"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",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0f80177bbdffb0186895e7f000462ecd3b34e50c3604d7b48664a9f85058eb70"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"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",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "15e75af384c9d948b655a4ec2442e345ee8d30ab4b4aa71656d0e47d0dc36693"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE users SET signal_username = NULL, signal_verified = FALSE, updated_at = NOW() WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3c2dde5b27aeaf0dfe5a5c00d4b309d361518bb30890f688088cfd114ae3ce07"
|
||||
}
|
||||
+14
-8
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"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",
|
||||
"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",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -32,31 +32,36 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "discord_username",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "discord_verified",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"ordinal": 5,
|
||||
"name": "telegram_username",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"ordinal": 6,
|
||||
"name": "telegram_verified",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"ordinal": 7,
|
||||
"name": "telegram_chat_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "signal_number",
|
||||
"ordinal": 8,
|
||||
"name": "signal_username",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"ordinal": 9,
|
||||
"name": "signal_verified",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
@@ -70,6 +75,7 @@
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
@@ -78,5 +84,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c48c9af71d8dab70ea2f11df30d2cc4976732a47430bd471f5aa47afc16e5740"
|
||||
"hash": "5b43c61801fb580a9cc08094a611505bd35383ba5c6453e72e819f80bacc19b2"
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE users SET discord_id = NULL, discord_verified = FALSE, updated_at = NOW() WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "76a3a8d7e969c4e10e2326675720230bad1d85da784c826df7fe4f037e1cb7f1"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE users SET discord_id = NULL, discord_username = NULL, discord_verified = FALSE, updated_at = NOW() WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "7ff9f138ecb4974169490a998bcaaede516e0452219f14d006c43af6f959db21"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"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",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "91376af625ed07da85a7a091997c3256ece8a0c8dda4baefeadb2b9627214d2a"
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT preferred_comms_channel as \"preferred_comms_channel: String\", discord_id FROM users WHERE did = $1",
|
||||
"query": "SELECT preferred_comms_channel as \"preferred_comms_channel: String\", discord_username FROM users WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -22,7 +22,7 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "discord_id",
|
||||
"name": "discord_username",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
@@ -36,5 +36,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "45fac6171726d2c1990a3bb37a6dac592efa7f1bedcb29824ce8792093872722"
|
||||
"hash": "a844774d8dd3c50c5faf3de5d43f534b80234759c8437434e467ca33ea10fd1f"
|
||||
}
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"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",
|
||||
"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",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -37,7 +37,7 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "discord_id",
|
||||
"name": "discord_username",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
@@ -47,7 +47,7 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "signal_number",
|
||||
"name": "signal_username",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
@@ -90,5 +90,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "a01d6b316fc64250992bccb3b4b2cea9205d8828fac58c84ec929c8a0465588c"
|
||||
"hash": "b542a4235ceb6285cad12eed162098a9a7775d81a7e3e9e8bd976f134d260b27"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE users SET signal_username = $1, signal_verified = TRUE, updated_at = NOW() WHERE id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b863a9d1feb2f6c76c7c1c9bd09730fa0ca9f5c722357f90f36e3b47e825f824"
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"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",
|
||||
"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",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -47,7 +47,7 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "signal_number",
|
||||
"name": "signal_username",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
@@ -66,5 +66,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "63c2a9079c147be6d04bf02c63ea7a3d0b0db3f35438380c0fe7e4c60b420c67"
|
||||
"hash": "b8829a4375f55ff7f357ceab94d1ea1087f284b66fc814e7c4742c0a54f78020"
|
||||
}
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"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",
|
||||
"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",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -42,7 +42,7 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "discord_id",
|
||||
"name": "discord_username",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
@@ -52,7 +52,7 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "signal_number",
|
||||
"name": "signal_username",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
@@ -84,5 +84,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "671fb43192b9f37862f9eae2c5cec206ae1664ab5d595df4058c44abd257c910"
|
||||
"hash": "c8657f67fdd3cc0fb40dfaabd5cde460d84ea1de18f620ee4ece2623ad153911"
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE users SET signal_number = NULL, signal_verified = FALSE, updated_at = NOW() WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ea12e747e409ca5e27369b1b17014000c6f0d53743007046d820a76497a2fa37"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE users SET discord_id = $2, discord_verified = TRUE, updated_at = NOW() WHERE id = $1 RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "eab6c3df913b420839e9752cf1163d2f4b4a1464ca6184b5275d769c7930e96c"
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE users SET signal_number = $1, signal_verified = TRUE, updated_at = NOW() WHERE id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f056a1ff7979927e5581342edb213d1f79c2541a5e992b2b2aa7c0ae006364c3"
|
||||
}
|
||||
Generated
+2
@@ -6078,9 +6078,11 @@ dependencies = [
|
||||
"cid",
|
||||
"ctor",
|
||||
"dotenvy",
|
||||
"ed25519-dalek",
|
||||
"futures",
|
||||
"futures-util",
|
||||
"governor",
|
||||
"hex",
|
||||
"hickory-resolver",
|
||||
"hkdf",
|
||||
"hmac",
|
||||
|
||||
@@ -8,6 +8,6 @@ pub use locale::{
|
||||
};
|
||||
pub use sender::{
|
||||
CommsSender, DiscordSender, EmailSender, SendError, SignalSender, TelegramSender,
|
||||
is_valid_phone_number, mime_encode_header, sanitize_header_value,
|
||||
is_valid_phone_number, is_valid_signal_username, mime_encode_header, sanitize_header_value,
|
||||
};
|
||||
pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms};
|
||||
|
||||
@@ -33,6 +33,8 @@ pub struct NotificationStrings {
|
||||
pub migration_verification_body: &'static str,
|
||||
pub channel_verified_subject: &'static str,
|
||||
pub channel_verified_body: &'static str,
|
||||
pub channel_verification_subject: &'static str,
|
||||
pub channel_verification_body: &'static str,
|
||||
}
|
||||
|
||||
pub fn get_strings(locale: &str) -> &'static NotificationStrings {
|
||||
@@ -70,6 +72,8 @@ static STRINGS_EN: NotificationStrings = NotificationStrings {
|
||||
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.",
|
||||
channel_verified_subject: "Channel verified - {hostname}",
|
||||
channel_verified_body: "Hello {handle},\n\n{channel} has been verified as a notification channel for your account on {hostname}.",
|
||||
channel_verification_subject: "Verify your channel - {hostname}",
|
||||
channel_verification_body: "Your verification code is:\n{code}\n\nOr verify directly:\n{verify_link}",
|
||||
};
|
||||
|
||||
static STRINGS_ZH: NotificationStrings = NotificationStrings {
|
||||
@@ -96,6 +100,8 @@ static STRINGS_ZH: NotificationStrings = NotificationStrings {
|
||||
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如果您没有迁移账户,请忽略此邮件。",
|
||||
channel_verified_subject: "通知渠道已验证 - {hostname}",
|
||||
channel_verified_body: "您好 {handle},\n\n{channel} 已被验证为您在 {hostname} 上的通知渠道。",
|
||||
channel_verification_subject: "验证您的渠道 - {hostname}",
|
||||
channel_verification_body: "您的验证码是:\n{code}\n\n或直接验证:\n{verify_link}",
|
||||
};
|
||||
|
||||
static STRINGS_JA: NotificationStrings = NotificationStrings {
|
||||
@@ -122,6 +128,8 @@ static STRINGS_JA: NotificationStrings = NotificationStrings {
|
||||
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アカウントを移行していない場合は、このメールを無視してください。",
|
||||
channel_verified_subject: "通知チャンネル認証完了 - {hostname}",
|
||||
channel_verified_body: "{handle} 様\n\n{channel} が {hostname} の通知チャンネルとして認証されました。",
|
||||
channel_verification_subject: "チャンネルを認証 - {hostname}",
|
||||
channel_verification_body: "認証コードは:\n{code}\n\n直接認証:\n{verify_link}",
|
||||
};
|
||||
|
||||
static STRINGS_KO: NotificationStrings = NotificationStrings {
|
||||
@@ -148,6 +156,8 @@ static STRINGS_KO: NotificationStrings = NotificationStrings {
|
||||
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계정을 마이그레이션하지 않았다면 이 이메일을 무시하세요.",
|
||||
channel_verified_subject: "알림 채널 인증 완료 - {hostname}",
|
||||
channel_verified_body: "안녕하세요 {handle}님,\n\n{channel}이(가) {hostname}의 알림 채널로 인증되었습니다.",
|
||||
channel_verification_subject: "채널 인증 - {hostname}",
|
||||
channel_verification_body: "인증 코드:\n{code}\n\n직접 인증:\n{verify_link}",
|
||||
};
|
||||
|
||||
static STRINGS_SV: NotificationStrings = NotificationStrings {
|
||||
@@ -174,6 +184,8 @@ static STRINGS_SV: NotificationStrings = NotificationStrings {
|
||||
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.",
|
||||
channel_verified_subject: "Aviseringskanal verifierad - {hostname}",
|
||||
channel_verified_body: "Hej {handle},\n\n{channel} har verifierats som aviseringskanal för ditt konto på {hostname}.",
|
||||
channel_verification_subject: "Verifiera din kanal - {hostname}",
|
||||
channel_verification_body: "Din verifieringskod är:\n{code}\n\nEller verifiera direkt:\n{verify_link}",
|
||||
};
|
||||
|
||||
static STRINGS_FI: NotificationStrings = NotificationStrings {
|
||||
@@ -200,6 +212,8 @@ static STRINGS_FI: NotificationStrings = NotificationStrings {
|
||||
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.",
|
||||
channel_verified_subject: "Ilmoituskanava vahvistettu - {hostname}",
|
||||
channel_verified_body: "Hei {handle},\n\n{channel} on vahvistettu ilmoituskanavaksi tilillesi palvelussa {hostname}.",
|
||||
channel_verification_subject: "Vahvista kanavasi - {hostname}",
|
||||
channel_verification_body: "Vahvistuskoodisi on:\n{code}\n\nTai vahvista suoraan:\n{verify_link}",
|
||||
};
|
||||
|
||||
pub fn format_message(template: &str, vars: &[(&str, &str)]) -> String {
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::process::Command;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use super::types::{CommsChannel, QueuedComms};
|
||||
|
||||
@@ -85,6 +86,25 @@ pub fn is_valid_phone_number(number: &str) -> bool {
|
||||
!remaining.is_empty() && remaining.chars().all(|c| c.is_ascii_digit())
|
||||
}
|
||||
|
||||
pub fn is_valid_signal_username(username: &str) -> bool {
|
||||
if username.len() < 6 || username.len() > 35 {
|
||||
return false;
|
||||
}
|
||||
let Some((base, discriminator)) = username.rsplit_once('.') else {
|
||||
return false;
|
||||
};
|
||||
if base.len() < 3 || base.len() > 32 {
|
||||
return false;
|
||||
}
|
||||
if !base.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
|
||||
return false;
|
||||
}
|
||||
if !base.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) {
|
||||
return false;
|
||||
}
|
||||
discriminator.len() == 2 && discriminator.chars().all(|c| c.is_ascii_digit())
|
||||
}
|
||||
|
||||
pub struct EmailSender {
|
||||
from_address: String,
|
||||
from_name: String,
|
||||
@@ -154,22 +174,208 @@ impl CommsSender for EmailSender {
|
||||
}
|
||||
}
|
||||
|
||||
const DISCORD_API_BASE: &str = "https://discord.com/api/v10";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DiscordSender {
|
||||
webhook_url: String,
|
||||
bot_token: String,
|
||||
http_client: Client,
|
||||
}
|
||||
|
||||
impl DiscordSender {
|
||||
pub fn new(webhook_url: String) -> Self {
|
||||
pub fn new(bot_token: String) -> Self {
|
||||
Self {
|
||||
webhook_url,
|
||||
bot_token,
|
||||
http_client: create_http_client(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_env() -> Option<Self> {
|
||||
let webhook_url = std::env::var("DISCORD_WEBHOOK_URL").ok()?;
|
||||
Some(Self::new(webhook_url))
|
||||
let bot_token = std::env::var("DISCORD_BOT_TOKEN").ok()?;
|
||||
Some(Self::new(bot_token))
|
||||
}
|
||||
|
||||
fn auth_header(&self) -> String {
|
||||
format!("Bot {}", self.bot_token)
|
||||
}
|
||||
|
||||
pub async fn resolve_application_info(&self) -> Result<(String, String), SendError> {
|
||||
let url = format!("{}/applications/@me", DISCORD_API_BASE);
|
||||
let response = self
|
||||
.http_client
|
||||
.get(&url)
|
||||
.header("Authorization", self.auth_header())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
SendError::ExternalService(format!(
|
||||
"Discord application info request failed: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(SendError::ExternalService(format!(
|
||||
"Discord application info returned error: {}",
|
||||
body
|
||||
)));
|
||||
}
|
||||
|
||||
let data: serde_json::Value = response.json().await.map_err(|e| {
|
||||
SendError::ExternalService(format!("Failed to parse Discord application info: {}", e))
|
||||
})?;
|
||||
|
||||
let app_id = data
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| SendError::ExternalService("Application info missing id".to_string()))?;
|
||||
|
||||
let verify_key = data
|
||||
.get("verify_key")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| {
|
||||
SendError::ExternalService("Application info missing verify_key".to_string())
|
||||
})?;
|
||||
|
||||
Ok((app_id, verify_key))
|
||||
}
|
||||
|
||||
pub async fn register_slash_command(&self, app_id: &str) -> Result<(), SendError> {
|
||||
let url = format!("{}/applications/{}/commands", DISCORD_API_BASE, app_id);
|
||||
let payload = serde_json::json!({
|
||||
"name": "start",
|
||||
"description": "Verify your PDS account",
|
||||
"type": 1,
|
||||
"options": [{
|
||||
"name": "handle",
|
||||
"description": "Your PDS handle (e.g. alice.example.com)",
|
||||
"type": 3,
|
||||
"required": false
|
||||
}]
|
||||
});
|
||||
let response = self
|
||||
.http_client
|
||||
.post(&url)
|
||||
.header("Authorization", self.auth_header())
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
SendError::ExternalService(format!("Register command request failed: {}", e))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(SendError::ExternalService(format!(
|
||||
"Register command returned error: {}",
|
||||
body
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_interactions_endpoint(
|
||||
&self,
|
||||
app_id: &str,
|
||||
url: &str,
|
||||
) -> Result<(), SendError> {
|
||||
let patch_url = format!("{}/applications/{}", DISCORD_API_BASE, app_id);
|
||||
let payload = serde_json::json!({
|
||||
"interactions_endpoint_url": url
|
||||
});
|
||||
let response = self
|
||||
.http_client
|
||||
.patch(&patch_url)
|
||||
.header("Authorization", self.auth_header())
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
SendError::ExternalService(format!("Set interactions endpoint failed: {}", e))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(SendError::ExternalService(format!(
|
||||
"Set interactions endpoint returned error: {}",
|
||||
body
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn resolve_bot_username(&self) -> Result<String, SendError> {
|
||||
let url = format!("{}/users/@me", DISCORD_API_BASE);
|
||||
let response = self
|
||||
.http_client
|
||||
.get(&url)
|
||||
.header("Authorization", self.auth_header())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
SendError::ExternalService(format!("Discord getMe request failed: {}", e))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(SendError::ExternalService(format!(
|
||||
"Discord getMe returned error: {}",
|
||||
body
|
||||
)));
|
||||
}
|
||||
|
||||
let data: serde_json::Value = response.json().await.map_err(|e| {
|
||||
SendError::ExternalService(format!("Failed to parse Discord getMe response: {}", e))
|
||||
})?;
|
||||
|
||||
data.get("username")
|
||||
.and_then(|u| u.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| {
|
||||
SendError::ExternalService("Discord getMe response missing username".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
async fn open_dm_channel(&self, user_id: &str) -> Result<String, SendError> {
|
||||
let url = format!("{}/users/@me/channels", DISCORD_API_BASE);
|
||||
let payload = json!({ "recipient_id": user_id });
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.post(&url)
|
||||
.header("Authorization", self.auth_header())
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
SendError::ExternalService(format!("Discord DM channel request failed: {}", e))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(SendError::ExternalService(format!(
|
||||
"Discord DM channel creation returned {}: {}",
|
||||
status, body
|
||||
)));
|
||||
}
|
||||
|
||||
let data: serde_json::Value = response.json().await.map_err(|e| {
|
||||
SendError::ExternalService(format!(
|
||||
"Failed to parse Discord DM channel response: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
data.get("id")
|
||||
.and_then(|id| id.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| {
|
||||
SendError::ExternalService("Discord DM channel response missing id".to_string())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,17 +386,19 @@ impl CommsSender for DiscordSender {
|
||||
}
|
||||
|
||||
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
|
||||
let channel_id = self.open_dm_channel(¬ification.recipient).await?;
|
||||
|
||||
let subject = notification.subject.as_deref().unwrap_or("Notification");
|
||||
let content = format!("**{}**\n\n{}", subject, notification.body);
|
||||
let payload = json!({
|
||||
"content": content,
|
||||
"username": "Tranquil PDS"
|
||||
});
|
||||
let payload = json!({ "content": content });
|
||||
let url = format!("{}/channels/{}/messages", DISCORD_API_BASE, channel_id);
|
||||
|
||||
let mut last_error = None;
|
||||
for attempt in 0..MAX_RETRIES {
|
||||
let result = self
|
||||
.http_client
|
||||
.post(&self.webhook_url)
|
||||
.post(&url)
|
||||
.header("Authorization", self.auth_header())
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await;
|
||||
@@ -201,13 +409,13 @@ impl CommsSender for DiscordSender {
|
||||
}
|
||||
let status = response.status();
|
||||
if is_retryable_status(status) && attempt < MAX_RETRIES - 1 {
|
||||
last_error = Some(format!("Discord webhook returned {}", status));
|
||||
last_error = Some(format!("Discord API returned {}", status));
|
||||
retry_delay(attempt).await;
|
||||
continue;
|
||||
}
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(SendError::ExternalService(format!(
|
||||
"Discord webhook returned {}: {}",
|
||||
"Discord API returned {}: {}",
|
||||
status, body
|
||||
)));
|
||||
}
|
||||
@@ -386,6 +594,19 @@ impl SignalSender {
|
||||
}
|
||||
}
|
||||
|
||||
const SIGNAL_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
fn is_retryable_signal_error(stderr: &str) -> bool {
|
||||
let lower = stderr.to_lowercase();
|
||||
lower.contains("timeout")
|
||||
|| lower.contains("timed out")
|
||||
|| lower.contains("connection refused")
|
||||
|| lower.contains("network")
|
||||
|| lower.contains("temporarily")
|
||||
|| lower.contains("try again")
|
||||
|| lower.contains("rate limit")
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CommsSender for SignalSender {
|
||||
fn channel(&self) -> CommsChannel {
|
||||
@@ -394,30 +615,63 @@ impl CommsSender for SignalSender {
|
||||
|
||||
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
|
||||
let recipient = ¬ification.recipient;
|
||||
if !is_valid_phone_number(recipient) {
|
||||
if !is_valid_signal_username(recipient) {
|
||||
return Err(SendError::InvalidRecipient(format!(
|
||||
"Invalid phone number format: {}",
|
||||
"Invalid Signal username format: {}",
|
||||
recipient
|
||||
)));
|
||||
}
|
||||
let subject = notification.subject.as_deref().unwrap_or("Notification");
|
||||
let message = format!("{}\n\n{}", subject, notification.body);
|
||||
let output = Command::new(&self.signal_cli_path)
|
||||
.arg("-u")
|
||||
.arg(&self.sender_number)
|
||||
.arg("send")
|
||||
.arg("-m")
|
||||
.arg(&message)
|
||||
.arg(recipient)
|
||||
.output()
|
||||
.await?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(SendError::ExternalService(format!(
|
||||
"signal-cli failed: {}",
|
||||
stderr
|
||||
)));
|
||||
|
||||
let mut last_error = None;
|
||||
for attempt in 0..MAX_RETRIES {
|
||||
let cmd_future = Command::new(&self.signal_cli_path)
|
||||
.arg("-u")
|
||||
.arg(&self.sender_number)
|
||||
.arg("send")
|
||||
.arg("--username")
|
||||
.arg(recipient)
|
||||
.arg("-m")
|
||||
.arg(&message)
|
||||
.output();
|
||||
|
||||
let result = timeout(Duration::from_secs(SIGNAL_TIMEOUT_SECS), cmd_future).await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(output)) if output.status.success() => return Ok(()),
|
||||
Ok(Ok(output)) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if is_retryable_signal_error(&stderr) && attempt < MAX_RETRIES - 1 {
|
||||
last_error = Some(format!("signal-cli failed: {}", stderr));
|
||||
retry_delay(attempt).await;
|
||||
continue;
|
||||
}
|
||||
return Err(SendError::ExternalService(format!(
|
||||
"signal-cli failed: {}",
|
||||
stderr
|
||||
)));
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
if attempt < MAX_RETRIES - 1 {
|
||||
last_error = Some(format!("signal-cli spawn failed: {}", e));
|
||||
retry_delay(attempt).await;
|
||||
continue;
|
||||
}
|
||||
return Err(SendError::ProcessSpawn(e));
|
||||
}
|
||||
Err(_) => {
|
||||
if attempt < MAX_RETRIES - 1 {
|
||||
last_error = Some("signal-cli timed out".to_string());
|
||||
retry_delay(attempt).await;
|
||||
continue;
|
||||
}
|
||||
return Err(SendError::Timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Err(SendError::MaxRetriesExceeded(
|
||||
last_error.unwrap_or_else(|| "Unknown error".to_string()),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@ pub enum DbError {
|
||||
#[error("Serialization error: {0}")]
|
||||
Serialization(String),
|
||||
|
||||
#[error("Ambiguous: {0}")]
|
||||
Ambiguous(String),
|
||||
|
||||
#[error("Resource busy, try again")]
|
||||
LockContention,
|
||||
|
||||
#[error("Other database error: {0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
@@ -488,6 +488,7 @@ pub trait RepoRepository: Send + Sync {
|
||||
user_id: Uuid,
|
||||
blocks: &[ImportBlock],
|
||||
records: &[ImportRecord],
|
||||
expected_root_cid: Option<&CidLink>,
|
||||
) -> Result<(), ImportRepoError>;
|
||||
|
||||
async fn apply_commit(
|
||||
|
||||
@@ -228,6 +228,12 @@ pub trait UserRepository: Send + Sync {
|
||||
|
||||
async fn clear_signal(&self, user_id: Uuid) -> Result<(), DbError>;
|
||||
|
||||
async fn set_unverified_signal(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
signal_username: &str,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
async fn set_unverified_telegram(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
@@ -243,6 +249,19 @@ pub trait UserRepository: Send + Sync {
|
||||
|
||||
async fn get_telegram_chat_id(&self, user_id: Uuid) -> Result<Option<i64>, DbError>;
|
||||
|
||||
async fn set_unverified_discord(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
discord_username: &str,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
async fn store_discord_user_id(
|
||||
&self,
|
||||
discord_username: &str,
|
||||
discord_id: &str,
|
||||
handle: Option<&str>,
|
||||
) -> Result<Option<Uuid>, DbError>;
|
||||
|
||||
async fn get_verification_info(
|
||||
&self,
|
||||
did: &Did,
|
||||
@@ -261,7 +280,7 @@ pub trait UserRepository: Send + Sync {
|
||||
async fn verify_signal_channel(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
signal_number: &str,
|
||||
signal_username: &str,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
async fn set_email_verified_flag(&self, user_id: Uuid) -> Result<(), DbError>;
|
||||
@@ -598,7 +617,7 @@ pub struct UserCommsPrefs {
|
||||
pub preferred_locale: Option<String>,
|
||||
pub telegram_chat_id: Option<i64>,
|
||||
pub discord_id: Option<String>,
|
||||
pub signal_number: Option<String>,
|
||||
pub signal_username: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -656,11 +675,12 @@ pub struct NotificationPrefs {
|
||||
pub email: String,
|
||||
pub preferred_channel: CommsChannel,
|
||||
pub discord_id: Option<String>,
|
||||
pub discord_username: Option<String>,
|
||||
pub discord_verified: bool,
|
||||
pub telegram_username: Option<String>,
|
||||
pub telegram_verified: bool,
|
||||
pub telegram_chat_id: Option<i64>,
|
||||
pub signal_number: Option<String>,
|
||||
pub signal_username: Option<String>,
|
||||
pub signal_verified: bool,
|
||||
}
|
||||
|
||||
@@ -829,9 +849,9 @@ pub struct UserConfirmSignup {
|
||||
pub handle: Handle,
|
||||
pub email: Option<String>,
|
||||
pub channel: CommsChannel,
|
||||
pub discord_id: Option<String>,
|
||||
pub discord_username: Option<String>,
|
||||
pub telegram_username: Option<String>,
|
||||
pub signal_number: Option<String>,
|
||||
pub signal_username: Option<String>,
|
||||
pub key_bytes: Vec<u8>,
|
||||
pub encryption_version: Option<i32>,
|
||||
}
|
||||
@@ -842,9 +862,9 @@ pub struct UserResendVerification {
|
||||
pub handle: Handle,
|
||||
pub email: Option<String>,
|
||||
pub channel: CommsChannel,
|
||||
pub discord_id: Option<String>,
|
||||
pub discord_username: Option<String>,
|
||||
pub telegram_username: Option<String>,
|
||||
pub signal_number: Option<String>,
|
||||
pub signal_username: Option<String>,
|
||||
pub channel_verification: ChannelVerificationStatus,
|
||||
}
|
||||
|
||||
@@ -932,9 +952,9 @@ pub struct CreatePasswordAccountInput {
|
||||
pub did: Did,
|
||||
pub password_hash: String,
|
||||
pub preferred_comms_channel: CommsChannel,
|
||||
pub discord_id: Option<String>,
|
||||
pub discord_username: Option<String>,
|
||||
pub telegram_username: Option<String>,
|
||||
pub signal_number: Option<String>,
|
||||
pub signal_username: Option<String>,
|
||||
pub deactivated_at: Option<DateTime<Utc>>,
|
||||
pub encrypted_key_bytes: Vec<u8>,
|
||||
pub encryption_version: i32,
|
||||
@@ -982,9 +1002,9 @@ pub struct CreatePasskeyAccountInput {
|
||||
pub email: String,
|
||||
pub did: Did,
|
||||
pub preferred_comms_channel: CommsChannel,
|
||||
pub discord_id: Option<String>,
|
||||
pub discord_username: Option<String>,
|
||||
pub telegram_username: Option<String>,
|
||||
pub signal_number: Option<String>,
|
||||
pub signal_username: Option<String>,
|
||||
pub setup_token_hash: String,
|
||||
pub setup_expires_at: DateTime<Utc>,
|
||||
pub deactivated_at: Option<DateTime<Utc>>,
|
||||
@@ -1004,9 +1024,9 @@ pub struct CreateSsoAccountInput {
|
||||
pub email: Option<String>,
|
||||
pub did: Did,
|
||||
pub preferred_comms_channel: CommsChannel,
|
||||
pub discord_id: Option<String>,
|
||||
pub discord_username: Option<String>,
|
||||
pub telegram_username: Option<String>,
|
||||
pub signal_number: Option<String>,
|
||||
pub signal_username: Option<String>,
|
||||
pub encrypted_key_bytes: Vec<u8>,
|
||||
pub encryption_version: i32,
|
||||
pub commit_cid: String,
|
||||
|
||||
@@ -1131,6 +1131,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
user_id: Uuid,
|
||||
blocks: &[ImportBlock],
|
||||
records: &[ImportRecord],
|
||||
expected_root_cid: Option<&CidLink>,
|
||||
) -> Result<(), ImportRepoError> {
|
||||
let mut tx = self
|
||||
.pool
|
||||
@@ -1153,8 +1154,15 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
ImportRepoError::Database(e.to_string())
|
||||
})?;
|
||||
|
||||
if repo.is_none() {
|
||||
return Err(ImportRepoError::RepoNotFound);
|
||||
let repo = match repo {
|
||||
Some(r) => r,
|
||||
None => return Err(ImportRepoError::RepoNotFound),
|
||||
};
|
||||
|
||||
if let Some(expected) = expected_root_cid {
|
||||
if repo.repo_root_cid.as_str() != expected.as_str() {
|
||||
return Err(ImportRepoError::ConcurrentModification);
|
||||
}
|
||||
}
|
||||
|
||||
let block_chunks: Vec<Vec<&ImportBlock>> = blocks
|
||||
|
||||
@@ -311,7 +311,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
|
||||
async fn get_comms_prefs(&self, user_id: Uuid) -> Result<Option<UserCommsPrefs>, DbError> {
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT email, handle, preferred_comms_channel as "preferred_channel!: CommsChannel", preferred_locale, telegram_chat_id, discord_id, signal_number
|
||||
r#"SELECT email, handle, preferred_comms_channel as "preferred_channel!: CommsChannel", preferred_locale, telegram_chat_id, discord_id, signal_username
|
||||
FROM users WHERE id = $1"#,
|
||||
user_id
|
||||
)
|
||||
@@ -325,7 +325,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
preferred_locale: r.preferred_locale,
|
||||
telegram_chat_id: r.telegram_chat_id,
|
||||
discord_id: r.discord_id,
|
||||
signal_number: r.signal_number,
|
||||
signal_username: r.signal_username,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -636,11 +636,12 @@ impl UserRepository for PostgresUserRepository {
|
||||
email,
|
||||
preferred_comms_channel as "preferred_channel!: CommsChannel",
|
||||
discord_id,
|
||||
discord_username,
|
||||
discord_verified,
|
||||
telegram_username,
|
||||
telegram_verified,
|
||||
telegram_chat_id,
|
||||
signal_number,
|
||||
signal_username,
|
||||
signal_verified
|
||||
FROM users WHERE did = $1"#,
|
||||
did.as_str()
|
||||
@@ -652,11 +653,12 @@ impl UserRepository for PostgresUserRepository {
|
||||
email: r.email.unwrap_or_default(),
|
||||
preferred_channel: r.preferred_channel,
|
||||
discord_id: r.discord_id,
|
||||
discord_username: r.discord_username,
|
||||
discord_verified: r.discord_verified,
|
||||
telegram_username: r.telegram_username,
|
||||
telegram_verified: r.telegram_verified,
|
||||
telegram_chat_id: r.telegram_chat_id,
|
||||
signal_number: r.signal_number,
|
||||
signal_username: r.signal_username,
|
||||
signal_verified: r.signal_verified,
|
||||
}))
|
||||
}
|
||||
@@ -697,7 +699,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
|
||||
async fn clear_discord(&self, user_id: Uuid) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
"UPDATE users SET discord_id = NULL, discord_verified = FALSE, updated_at = NOW() WHERE id = $1",
|
||||
"UPDATE users SET discord_id = NULL, discord_username = NULL, discord_verified = FALSE, updated_at = NOW() WHERE id = $1",
|
||||
user_id
|
||||
)
|
||||
.execute(&self.pool)
|
||||
@@ -719,7 +721,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
|
||||
async fn clear_signal(&self, user_id: Uuid) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
"UPDATE users SET signal_number = NULL, signal_verified = FALSE, updated_at = NOW() WHERE id = $1",
|
||||
"UPDATE users SET signal_username = NULL, signal_verified = FALSE, updated_at = NOW() WHERE id = $1",
|
||||
user_id
|
||||
)
|
||||
.execute(&self.pool)
|
||||
@@ -796,11 +798,11 @@ impl UserRepository for PostgresUserRepository {
|
||||
async fn verify_signal_channel(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
signal_number: &str,
|
||||
signal_username: &str,
|
||||
) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
"UPDATE users SET signal_number = $1, signal_verified = TRUE, updated_at = NOW() WHERE id = $2",
|
||||
signal_number,
|
||||
"UPDATE users SET signal_username = $1, signal_verified = TRUE, updated_at = NOW() WHERE id = $2",
|
||||
signal_username,
|
||||
user_id
|
||||
)
|
||||
.execute(&self.pool)
|
||||
@@ -1548,7 +1550,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
r#"SELECT
|
||||
u.id, u.did, u.handle, u.email,
|
||||
u.preferred_comms_channel as "channel: CommsChannel",
|
||||
u.discord_id, u.telegram_username, u.signal_number,
|
||||
u.discord_username, u.telegram_username, u.signal_username,
|
||||
k.key_bytes, k.encryption_version
|
||||
FROM users u
|
||||
JOIN user_keys k ON u.id = k.user_id
|
||||
@@ -1565,9 +1567,9 @@ impl UserRepository for PostgresUserRepository {
|
||||
handle: Handle::from(row.handle),
|
||||
email: row.email,
|
||||
channel: row.channel,
|
||||
discord_id: row.discord_id,
|
||||
discord_username: row.discord_username,
|
||||
telegram_username: row.telegram_username,
|
||||
signal_number: row.signal_number,
|
||||
signal_username: row.signal_username,
|
||||
key_bytes: row.key_bytes,
|
||||
encryption_version: row.encryption_version,
|
||||
})
|
||||
@@ -1582,7 +1584,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
r#"SELECT
|
||||
id, handle, email,
|
||||
preferred_comms_channel as "channel: CommsChannel",
|
||||
discord_id, telegram_username, signal_number,
|
||||
discord_username, telegram_username, signal_username,
|
||||
email_verified, discord_verified, telegram_verified, signal_verified
|
||||
FROM users
|
||||
WHERE did = $1"#,
|
||||
@@ -1597,9 +1599,9 @@ impl UserRepository for PostgresUserRepository {
|
||||
handle: Handle::from(row.handle),
|
||||
email: row.email,
|
||||
channel: row.channel,
|
||||
discord_id: row.discord_id,
|
||||
discord_username: row.discord_username,
|
||||
telegram_username: row.telegram_username,
|
||||
signal_number: row.signal_number,
|
||||
signal_username: row.signal_username,
|
||||
channel_verification: ChannelVerificationStatus::new(
|
||||
row.email_verified,
|
||||
row.discord_verified,
|
||||
@@ -1662,13 +1664,13 @@ impl UserRepository for PostgresUserRepository {
|
||||
"SELECT COUNT(*) FROM users WHERE LOWER(email) = LOWER($1) AND deactivated_at IS NULL"
|
||||
}
|
||||
CommsChannel::Discord => {
|
||||
"SELECT COUNT(*) FROM users WHERE discord_id = $1 AND deactivated_at IS NULL"
|
||||
"SELECT COUNT(*) FROM users WHERE LOWER(discord_username) = LOWER($1) AND deactivated_at IS NULL"
|
||||
}
|
||||
CommsChannel::Telegram => {
|
||||
"SELECT COUNT(*) FROM users WHERE LOWER(telegram_username) = LOWER($1) AND deactivated_at IS NULL"
|
||||
}
|
||||
CommsChannel::Signal => {
|
||||
"SELECT COUNT(*) FROM users WHERE signal_number = $1 AND deactivated_at IS NULL"
|
||||
"SELECT COUNT(*) FROM users WHERE signal_username = $1 AND deactivated_at IS NULL"
|
||||
}
|
||||
};
|
||||
sqlx::query_scalar(query)
|
||||
@@ -2385,7 +2387,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
r#"INSERT INTO users (
|
||||
handle, email, did, password_hash,
|
||||
preferred_comms_channel,
|
||||
discord_id, telegram_username, signal_number,
|
||||
discord_username, telegram_username, signal_username,
|
||||
is_admin, deactivated_at, email_verified
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, FALSE) RETURNING id"#,
|
||||
)
|
||||
@@ -2394,9 +2396,9 @@ impl UserRepository for PostgresUserRepository {
|
||||
.bind(input.did.as_str())
|
||||
.bind(&input.password_hash)
|
||||
.bind(input.preferred_comms_channel)
|
||||
.bind(&input.discord_id)
|
||||
.bind(&input.discord_username)
|
||||
.bind(&input.telegram_username)
|
||||
.bind(&input.signal_number)
|
||||
.bind(&input.signal_username)
|
||||
.bind(is_first_user)
|
||||
.bind(input.deactivated_at)
|
||||
.fetch_one(&mut *tx)
|
||||
@@ -2652,7 +2654,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
r#"INSERT INTO users (
|
||||
handle, email, did, password_hash, password_required,
|
||||
preferred_comms_channel,
|
||||
discord_id, telegram_username, signal_number,
|
||||
discord_username, telegram_username, signal_username,
|
||||
recovery_token, recovery_token_expires_at,
|
||||
is_admin, deactivated_at
|
||||
) VALUES ($1, $2, $3, NULL, FALSE, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id"#,
|
||||
@@ -2661,9 +2663,9 @@ impl UserRepository for PostgresUserRepository {
|
||||
.bind(&input.email)
|
||||
.bind(input.did.as_str())
|
||||
.bind(input.preferred_comms_channel)
|
||||
.bind(&input.discord_id)
|
||||
.bind(&input.discord_username)
|
||||
.bind(&input.telegram_username)
|
||||
.bind(&input.signal_number)
|
||||
.bind(&input.signal_username)
|
||||
.bind(&input.setup_token_hash)
|
||||
.bind(input.setup_expires_at)
|
||||
.bind(is_first_user)
|
||||
@@ -2816,7 +2818,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
let user_insert: Result<(uuid::Uuid,), _> = sqlx::query_as(
|
||||
r#"INSERT INTO users (
|
||||
handle, email, did, password_hash, password_required,
|
||||
preferred_comms_channel, discord_id, telegram_username, signal_number,
|
||||
preferred_comms_channel, discord_username, telegram_username, signal_username,
|
||||
is_admin
|
||||
) VALUES ($1, $2, $3, NULL, FALSE, $4, $5, $6, $7, $8) RETURNING id"#,
|
||||
)
|
||||
@@ -2824,9 +2826,9 @@ impl UserRepository for PostgresUserRepository {
|
||||
.bind(&input.email)
|
||||
.bind(input.did.as_str())
|
||||
.bind(input.preferred_comms_channel)
|
||||
.bind(&input.discord_id)
|
||||
.bind(&input.discord_username)
|
||||
.bind(&input.telegram_username)
|
||||
.bind(&input.signal_number)
|
||||
.bind(&input.signal_username)
|
||||
.bind(is_first_user)
|
||||
.fetch_one(&mut *tx)
|
||||
.await;
|
||||
@@ -3190,6 +3192,109 @@ impl UserRepository for PostgresUserRepository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_unverified_signal(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
signal_username: &str,
|
||||
) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
r#"UPDATE users SET
|
||||
signal_username = $1,
|
||||
signal_verified = CASE WHEN LOWER(signal_username) = LOWER($1) THEN signal_verified ELSE FALSE END,
|
||||
updated_at = NOW()
|
||||
WHERE id = $2"#,
|
||||
signal_username,
|
||||
user_id
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_unverified_discord(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
discord_username: &str,
|
||||
) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
r#"UPDATE users SET
|
||||
discord_username = $1,
|
||||
discord_verified = CASE WHEN LOWER(discord_username) = LOWER($1) THEN discord_verified ELSE FALSE END,
|
||||
discord_id = CASE WHEN LOWER(discord_username) = LOWER($1) THEN discord_id ELSE NULL END,
|
||||
updated_at = NOW()
|
||||
WHERE id = $2"#,
|
||||
discord_username,
|
||||
user_id
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn store_discord_user_id(
|
||||
&self,
|
||||
discord_username: &str,
|
||||
discord_id: &str,
|
||||
handle: Option<&str>,
|
||||
) -> Result<Option<Uuid>, DbError> {
|
||||
let result = match handle {
|
||||
Some(h) => sqlx::query_scalar!(
|
||||
"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",
|
||||
discord_username,
|
||||
discord_id,
|
||||
h
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?,
|
||||
None => {
|
||||
let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?;
|
||||
|
||||
let matching: Vec<uuid::Uuid> = match sqlx::query_scalar!(
|
||||
"SELECT id FROM users WHERE LOWER(discord_username) = LOWER($1) AND discord_username IS NOT NULL AND deactivated_at IS NULL FOR UPDATE NOWAIT",
|
||||
discord_username
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await
|
||||
{
|
||||
Ok(ids) => ids,
|
||||
Err(sqlx::Error::Database(ref db_err))
|
||||
if db_err.code().as_deref() == Some("55P03") =>
|
||||
{
|
||||
return Err(DbError::LockContention);
|
||||
}
|
||||
Err(e) => return Err(map_sqlx_error(e)),
|
||||
};
|
||||
|
||||
let result = match matching.len() {
|
||||
0 => None,
|
||||
1 => {
|
||||
sqlx::query_scalar!(
|
||||
"UPDATE users SET discord_id = $2, discord_verified = TRUE, updated_at = NOW() WHERE id = $1 RETURNING id",
|
||||
matching[0],
|
||||
discord_id
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?
|
||||
}
|
||||
_ => {
|
||||
tx.rollback().await.ok();
|
||||
return Err(DbError::Ambiguous(
|
||||
"Multiple accounts use this Discord username. Type: /start your-handle.example.com".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
tx.commit().await.map_err(map_sqlx_error)?;
|
||||
result
|
||||
}
|
||||
};
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn store_telegram_chat_id(
|
||||
&self,
|
||||
telegram_username: &str,
|
||||
|
||||
@@ -32,7 +32,9 @@ bytes = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
cid = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
ed25519-dalek = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
governor = { workspace = true }
|
||||
hickory-resolver = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use tracing::{debug, info, warn};
|
||||
use tranquil_types::Handle;
|
||||
|
||||
use crate::comms::comms_repo;
|
||||
use crate::state::AppState;
|
||||
use crate::util::{discord_public_key, pds_hostname};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Interaction {
|
||||
#[serde(rename = "type")]
|
||||
interaction_type: u8,
|
||||
data: Option<InteractionData>,
|
||||
member: Option<InteractionMember>,
|
||||
user: Option<InteractionUser>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct InteractionData {
|
||||
name: Option<String>,
|
||||
options: Option<Vec<InteractionOption>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct InteractionOption {
|
||||
name: String,
|
||||
value: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct InteractionMember {
|
||||
user: Option<InteractionUser>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct InteractionUser {
|
||||
id: String,
|
||||
username: Option<String>,
|
||||
}
|
||||
|
||||
fn verify_signature(
|
||||
public_key: &VerifyingKey,
|
||||
timestamp: &str,
|
||||
body: &str,
|
||||
signature: &str,
|
||||
) -> bool {
|
||||
let sig_bytes = match hex::decode(signature) {
|
||||
Ok(b) => b,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let signature = match Signature::from_slice(&sig_bytes) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let message = format!("{}{}", timestamp, body);
|
||||
public_key.verify(message.as_bytes(), &signature).is_ok()
|
||||
}
|
||||
|
||||
fn parse_start_handle(options: Option<&[InteractionOption]>) -> Option<String> {
|
||||
options
|
||||
.and_then(|opts| opts.iter().find(|o| o.name == "handle"))
|
||||
.and_then(|o| o.value.as_str())
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
pub async fn handle_discord_webhook(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
body: String,
|
||||
) -> Response {
|
||||
let public_key = match discord_public_key() {
|
||||
Some(pk) => pk,
|
||||
None => {
|
||||
warn!("Discord webhook called but public key is not configured");
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let signature = headers
|
||||
.get("x-signature-ed25519")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or_default();
|
||||
let timestamp = headers
|
||||
.get("x-signature-timestamp")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
if !verify_signature(public_key, timestamp, &body, signature) {
|
||||
return StatusCode::UNAUTHORIZED.into_response();
|
||||
}
|
||||
|
||||
let interaction: Interaction = match serde_json::from_str(&body) {
|
||||
Ok(i) => i,
|
||||
Err(_) => return StatusCode::BAD_REQUEST.into_response(),
|
||||
};
|
||||
|
||||
match interaction.interaction_type {
|
||||
1 => Json(json!({"type": 1})).into_response(),
|
||||
2 => handle_command(state, interaction).await,
|
||||
other => {
|
||||
debug!(interaction_type = other, "Received unknown Discord interaction type");
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_command(state: AppState, interaction: Interaction) -> Response {
|
||||
let command_name = interaction
|
||||
.data
|
||||
.as_ref()
|
||||
.and_then(|d| d.name.as_deref())
|
||||
.unwrap_or_default();
|
||||
|
||||
if command_name != "start" {
|
||||
return Json(json!({
|
||||
"type": 4,
|
||||
"data": {"content": "Unknown command", "flags": 64}
|
||||
}))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let user = interaction
|
||||
.member
|
||||
.as_ref()
|
||||
.and_then(|m| m.user.as_ref())
|
||||
.or(interaction.user.as_ref());
|
||||
|
||||
let (discord_user_id, discord_username) = match user {
|
||||
Some(u) => (u.id.clone(), u.username.clone().unwrap_or_default()),
|
||||
None => {
|
||||
return Json(json!({
|
||||
"type": 4,
|
||||
"data": {"content": "Could not identify user", "flags": 64}
|
||||
}))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let handle = parse_start_handle(interaction.data.as_ref().and_then(|d| d.options.as_deref()));
|
||||
|
||||
if let Some(ref h) = handle {
|
||||
if Handle::new(h).is_err() {
|
||||
return Json(json!({
|
||||
"type": 4,
|
||||
"data": {"content": "Invalid handle format. Handle should look like: alice.example.com", "flags": 64}
|
||||
}))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
discord_username = %discord_username,
|
||||
discord_user_id = %discord_user_id,
|
||||
handle = ?handle,
|
||||
"Received /start from Discord user"
|
||||
);
|
||||
|
||||
match state
|
||||
.user_repo
|
||||
.store_discord_user_id(&discord_username, &discord_user_id, handle.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(Some(user_id)) => {
|
||||
info!(
|
||||
discord_username = %discord_username,
|
||||
discord_user_id = %discord_user_id,
|
||||
"Verified Discord user and stored user ID"
|
||||
);
|
||||
if let Err(e) = comms_repo::enqueue_channel_verified(
|
||||
state.user_repo.as_ref(),
|
||||
state.infra_repo.as_ref(),
|
||||
user_id,
|
||||
"discord",
|
||||
&discord_user_id,
|
||||
pds_hostname(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(error = %e, "Failed to enqueue channel verified notification");
|
||||
}
|
||||
Json(json!({
|
||||
"type": 4,
|
||||
"data": {"content": "Verified", "flags": 64}
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
Ok(None) => {
|
||||
debug!(
|
||||
discord_username = %discord_username,
|
||||
"No matching user found for Discord username"
|
||||
);
|
||||
Json(json!({
|
||||
"type": 4,
|
||||
"data": {"content": "No account found with this Discord username. Set your Discord username in your PDS settings first.", "flags": 64}
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
Err(tranquil_db_traits::DbError::Ambiguous(msg)) => {
|
||||
debug!(
|
||||
discord_username = %discord_username,
|
||||
"Ambiguous Discord username match"
|
||||
);
|
||||
Json(json!({
|
||||
"type": 4,
|
||||
"data": {"content": msg, "flags": 64}
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
Err(tranquil_db_traits::DbError::LockContention) => {
|
||||
debug!(
|
||||
discord_username = %discord_username,
|
||||
"Lock contention during Discord verification"
|
||||
);
|
||||
Json(json!({
|
||||
"type": 4,
|
||||
"data": {"content": "Server busy, please try again in a moment.", "flags": 64}
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
discord_username = %discord_username,
|
||||
error = %e,
|
||||
"Failed to store Discord user ID"
|
||||
);
|
||||
Json(json!({
|
||||
"type": 4,
|
||||
"data": {"content": "Verification failed. Try again later.", "flags": 64}
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_handle_from_options() {
|
||||
let options = vec![InteractionOption {
|
||||
name: "handle".to_string(),
|
||||
value: serde_json::json!("lewis.buttercup.wizardry.systems"),
|
||||
}];
|
||||
assert_eq!(
|
||||
parse_start_handle(Some(&options)),
|
||||
Some("lewis.buttercup.wizardry.systems".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_handle_no_options() {
|
||||
assert_eq!(parse_start_handle(None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_handle_empty_options() {
|
||||
let options: Vec<InteractionOption> = vec![];
|
||||
assert_eq!(parse_start_handle(Some(&options)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_handle_wrong_option_name() {
|
||||
let options = vec![InteractionOption {
|
||||
name: "other".to_string(),
|
||||
value: serde_json::json!("test"),
|
||||
}];
|
||||
assert_eq!(parse_start_handle(Some(&options)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_handle_empty_string() {
|
||||
let options = vec![InteractionOption {
|
||||
name: "handle".to_string(),
|
||||
value: serde_json::json!(""),
|
||||
}];
|
||||
assert_eq!(parse_start_handle(Some(&options)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_handle_whitespace_trimmed() {
|
||||
let options = vec![InteractionOption {
|
||||
name: "handle".to_string(),
|
||||
value: serde_json::json!(" alice.example.com "),
|
||||
}];
|
||||
assert_eq!(
|
||||
parse_start_handle(Some(&options)),
|
||||
Some("alice.example.com".to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -409,7 +409,7 @@ impl ApiError {
|
||||
Some("Telegram username is required when using Telegram verification".to_string())
|
||||
}
|
||||
Self::MissingSignalNumber => {
|
||||
Some("Signal phone number is required when using Signal verification".to_string())
|
||||
Some("Signal username is required when using Signal verification".to_string())
|
||||
}
|
||||
Self::InvalidVerificationChannel => Some("Invalid verification channel".to_string()),
|
||||
Self::SelfHostedDidWebDisabled => {
|
||||
|
||||
@@ -35,9 +35,9 @@ pub struct CreateAccountInput {
|
||||
pub did_type: Option<String>,
|
||||
pub signing_key: Option<String>,
|
||||
pub verification_channel: Option<String>,
|
||||
pub discord_id: Option<String>,
|
||||
pub discord_username: Option<String>,
|
||||
pub telegram_username: Option<String>,
|
||||
pub signal_number: Option<String>,
|
||||
pub signal_username: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -203,8 +203,16 @@ pub async fn create_account(
|
||||
Some(email) if !email.trim().is_empty() => email.trim().to_string(),
|
||||
_ => return ApiError::MissingEmail.into_response(),
|
||||
},
|
||||
"discord" => match &input.discord_id {
|
||||
Some(id) if !id.trim().is_empty() => id.trim().to_string(),
|
||||
"discord" => match &input.discord_username {
|
||||
Some(username) if !username.trim().is_empty() => {
|
||||
let clean = username.trim().to_lowercase();
|
||||
if !crate::api::validation::is_valid_discord_username(&clean) {
|
||||
return ApiError::InvalidRequest(
|
||||
"Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(),
|
||||
).into_response();
|
||||
}
|
||||
clean
|
||||
}
|
||||
_ => return ApiError::MissingDiscordId.into_response(),
|
||||
},
|
||||
"telegram" => match &input.telegram_username {
|
||||
@@ -219,8 +227,10 @@ pub async fn create_account(
|
||||
}
|
||||
_ => return ApiError::MissingTelegramUsername.into_response(),
|
||||
},
|
||||
"signal" => match &input.signal_number {
|
||||
Some(number) if !number.trim().is_empty() => number.trim().to_string(),
|
||||
"signal" => match &input.signal_username {
|
||||
Some(username) if !username.trim().is_empty() => {
|
||||
username.trim().trim_start_matches('@').to_lowercase()
|
||||
}
|
||||
_ => return ApiError::MissingSignalNumber.into_response(),
|
||||
},
|
||||
_ => return ApiError::InvalidVerificationChannel.into_response(),
|
||||
@@ -633,24 +643,23 @@ pub async fn create_account(
|
||||
did: unsafe { Did::new_unchecked(&did) },
|
||||
password_hash,
|
||||
preferred_comms_channel,
|
||||
discord_id: input
|
||||
.discord_id
|
||||
discord_username: input
|
||||
.discord_username
|
||||
.as_deref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from),
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty()),
|
||||
telegram_username: input
|
||||
.telegram_username
|
||||
.as_deref()
|
||||
.map(|s| s.trim().trim_start_matches('@'))
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from),
|
||||
signal_number: input
|
||||
.signal_number
|
||||
signal_username: input
|
||||
.signal_username
|
||||
.as_deref()
|
||||
.map(|s| s.trim())
|
||||
.map(|s| s.trim().trim_start_matches('@'))
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from),
|
||||
.map(|s| s.to_lowercase()),
|
||||
deactivated_at,
|
||||
encrypted_key_bytes,
|
||||
encryption_version: crate::config::ENCRYPTION_VERSION,
|
||||
@@ -750,6 +759,7 @@ pub async fn create_account(
|
||||
let formatted_token =
|
||||
crate::auth::verification_token::format_token_for_display(&verification_token);
|
||||
if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification(
|
||||
state.user_repo.as_ref(),
|
||||
state.infra_repo.as_ref(),
|
||||
user_id,
|
||||
verification_channel,
|
||||
|
||||
@@ -3,6 +3,7 @@ pub mod admin;
|
||||
pub mod age_assurance;
|
||||
pub mod backup;
|
||||
pub mod delegation;
|
||||
pub mod discord_webhook;
|
||||
pub mod error;
|
||||
pub mod identity;
|
||||
pub mod moderation;
|
||||
|
||||
@@ -17,11 +17,11 @@ use tranquil_db_traits::{CommsChannel, CommsStatus, CommsType};
|
||||
pub struct NotificationPrefsResponse {
|
||||
pub preferred_channel: CommsChannel,
|
||||
pub email: String,
|
||||
pub discord_id: Option<String>,
|
||||
pub discord_username: Option<String>,
|
||||
pub discord_verified: bool,
|
||||
pub telegram_username: Option<String>,
|
||||
pub telegram_verified: bool,
|
||||
pub signal_number: Option<String>,
|
||||
pub signal_username: Option<String>,
|
||||
pub signal_verified: bool,
|
||||
}
|
||||
|
||||
@@ -38,11 +38,11 @@ pub async fn get_notification_prefs(
|
||||
Ok(Json(NotificationPrefsResponse {
|
||||
preferred_channel: prefs.preferred_channel,
|
||||
email: prefs.email,
|
||||
discord_id: prefs.discord_id,
|
||||
discord_username: prefs.discord_username,
|
||||
discord_verified: prefs.discord_verified,
|
||||
telegram_username: prefs.telegram_username,
|
||||
telegram_verified: prefs.telegram_verified,
|
||||
signal_number: prefs.signal_number,
|
||||
signal_username: prefs.signal_username,
|
||||
signal_verified: prefs.signal_verified,
|
||||
})
|
||||
.into_response())
|
||||
@@ -120,9 +120,9 @@ pub async fn get_notification_history(
|
||||
pub struct UpdateNotificationPrefsInput {
|
||||
pub preferred_channel: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub discord_id: Option<String>,
|
||||
pub discord_username: Option<String>,
|
||||
pub telegram_username: Option<String>,
|
||||
pub signal_number: Option<String>,
|
||||
pub signal_username: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -172,9 +172,24 @@ pub async fn request_channel_verification(
|
||||
"https://{}/app/verify?token={}&identifier={}",
|
||||
hostname, encoded_token, encoded_identifier
|
||||
);
|
||||
let body = format!(
|
||||
"Your verification code is: {}\n\nOr verify directly:\n{}",
|
||||
formatted_token, verify_link
|
||||
let prefs = state
|
||||
.user_repo
|
||||
.get_comms_prefs(user_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let locale = prefs
|
||||
.as_ref()
|
||||
.and_then(|p| p.preferred_locale.as_deref())
|
||||
.unwrap_or("en");
|
||||
let strings = crate::comms::get_strings(locale);
|
||||
let body = crate::comms::format_message(
|
||||
strings.channel_verification_body,
|
||||
&[("code", &formatted_token), ("verify_link", &verify_link)],
|
||||
);
|
||||
let subject = crate::comms::format_message(
|
||||
strings.channel_verification_subject,
|
||||
&[("hostname", hostname)],
|
||||
);
|
||||
let recipient = match comms_channel {
|
||||
tranquil_db_traits::CommsChannel::Telegram => state
|
||||
@@ -194,7 +209,7 @@ pub async fn request_channel_verification(
|
||||
comms_channel,
|
||||
tranquil_db_traits::CommsType::ChannelVerification,
|
||||
&recipient,
|
||||
Some("Verify your channel"),
|
||||
Some(&subject),
|
||||
&body,
|
||||
Some(json!({"code": formatted_token})),
|
||||
)
|
||||
@@ -291,8 +306,9 @@ pub async fn update_notification_prefs(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref discord_id) = input.discord_id {
|
||||
if discord_id.is_empty() {
|
||||
if let Some(ref discord_username) = input.discord_username {
|
||||
let discord_clean = discord_username.trim().to_lowercase();
|
||||
if discord_clean.is_empty() {
|
||||
if effective_channel == CommsChannel::Discord {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Cannot remove Discord while it is the preferred notification channel".into(),
|
||||
@@ -303,13 +319,20 @@ pub async fn update_notification_prefs(
|
||||
.clear_discord(user_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
|
||||
info!(did = %auth.did, "Cleared Discord ID");
|
||||
info!(did = %auth.did, "Cleared Discord");
|
||||
} else if !crate::api::validation::is_valid_discord_username(&discord_clean) {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)"
|
||||
.into(),
|
||||
));
|
||||
} else {
|
||||
request_channel_verification(&state, user_id, &auth.did, "discord", discord_id, None)
|
||||
state
|
||||
.user_repo
|
||||
.set_unverified_discord(user_id, &discord_clean)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalError(Some(e)))?;
|
||||
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
|
||||
verification_required.push("discord".to_string());
|
||||
info!(did = %auth.did, "Requested Discord verification");
|
||||
info!(did = %auth.did, discord_username = %discord_clean, "Stored unverified Discord username");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,8 +366,9 @@ pub async fn update_notification_prefs(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref signal) = input.signal_number {
|
||||
if signal.is_empty() {
|
||||
if let Some(ref signal) = input.signal_username {
|
||||
let signal_clean = signal.trim().trim_start_matches('@').to_lowercase();
|
||||
if signal_clean.is_empty() {
|
||||
if effective_channel == CommsChannel::Signal {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Cannot remove Signal while it is the preferred notification channel".into(),
|
||||
@@ -355,13 +379,23 @@ pub async fn update_notification_prefs(
|
||||
.clear_signal(user_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
|
||||
info!(did = %auth.did, "Cleared Signal number");
|
||||
info!(did = %auth.did, "Cleared Signal username");
|
||||
} else if !crate::comms::is_valid_signal_username(&signal_clean) {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Invalid Signal username. Must be 3-32 characters followed by .XX (e.g. username.01)"
|
||||
.into(),
|
||||
));
|
||||
} else {
|
||||
request_channel_verification(&state, user_id, &auth.did, "signal", signal, None)
|
||||
state
|
||||
.user_repo
|
||||
.set_unverified_signal(user_id, &signal_clean)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
|
||||
request_channel_verification(&state, user_id, &auth.did, "signal", &signal_clean, None)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalError(Some(e)))?;
|
||||
verification_required.push("signal".to_string());
|
||||
info!(did = %auth.did, "Requested Signal verification");
|
||||
info!(did = %auth.did, signal_username = %signal_clean, "Stored unverified Signal username");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,14 @@ pub async fn import_repo(
|
||||
return Err(ApiError::AccountTakedown);
|
||||
}
|
||||
let user_id = user.id;
|
||||
let expected_root_cid = state
|
||||
.repo_repo
|
||||
.get_repo_root_cid_by_user_id(user_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error fetching repo root: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let (root, blocks) = match parse_car(&body).await {
|
||||
Ok((r, b)) => (r, b),
|
||||
Err(ImportError::InvalidRootCount) => {
|
||||
@@ -191,7 +199,16 @@ pub async fn import_repo(
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(DEFAULT_MAX_BLOCKS);
|
||||
let _write_lock = state.repo_write_locks.lock(user_id).await;
|
||||
match apply_import(&state.repo_repo, user_id, root, blocks.clone(), max_blocks).await {
|
||||
match apply_import(
|
||||
&state.repo_repo,
|
||||
user_id,
|
||||
root,
|
||||
blocks.clone(),
|
||||
max_blocks,
|
||||
expected_root_cid.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(import_result) => {
|
||||
info!(
|
||||
"Successfully imported {} records for user {}",
|
||||
|
||||
@@ -367,6 +367,7 @@ pub async fn update_email(
|
||||
crate::auth::verification_token::format_token_for_display(&verification_token);
|
||||
let hostname = pds_hostname();
|
||||
if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification(
|
||||
state.user_repo.as_ref(),
|
||||
state.infra_repo.as_ref(),
|
||||
user_id,
|
||||
"email",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use crate::state::AppState;
|
||||
use crate::util::{pds_hostname, telegram_bot_username};
|
||||
use crate::util::{discord_app_id, discord_bot_username, pds_hostname, telegram_bot_username};
|
||||
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
|
||||
use serde_json::json;
|
||||
|
||||
fn get_available_comms_channels() -> Vec<&'static str> {
|
||||
let mut channels = vec!["email"];
|
||||
if std::env::var("DISCORD_WEBHOOK_URL").is_ok() {
|
||||
if std::env::var("DISCORD_BOT_TOKEN").is_ok() {
|
||||
channels.push("discord");
|
||||
}
|
||||
if std::env::var("TELEGRAM_BOT_TOKEN").is_ok() {
|
||||
@@ -62,6 +62,12 @@ pub async fn describe_server() -> impl IntoResponse {
|
||||
"availableCommsChannels": get_available_comms_channels(),
|
||||
"selfHostedDidWebEnabled": is_self_hosted_did_web_enabled()
|
||||
});
|
||||
if let Some(bot_username) = discord_bot_username() {
|
||||
response["discordBotUsername"] = json!(bot_username);
|
||||
}
|
||||
if let Some(app_id) = discord_app_id() {
|
||||
response["discordAppId"] = json!(app_id);
|
||||
}
|
||||
if let Some(bot_username) = telegram_bot_username() {
|
||||
response["telegramBotUsername"] = json!(bot_username);
|
||||
}
|
||||
|
||||
@@ -50,9 +50,9 @@ pub struct CreatePasskeyAccountInput {
|
||||
pub did_type: Option<String>,
|
||||
pub signing_key: Option<String>,
|
||||
pub verification_channel: Option<String>,
|
||||
pub discord_id: Option<String>,
|
||||
pub discord_username: Option<String>,
|
||||
pub telegram_username: Option<String>,
|
||||
pub signal_number: Option<String>,
|
||||
pub signal_username: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -167,8 +167,16 @@ pub async fn create_passkey_account(
|
||||
Some(e) if !e.is_empty() => e.clone(),
|
||||
_ => return ApiError::MissingEmail.into_response(),
|
||||
},
|
||||
"discord" => match &input.discord_id {
|
||||
Some(id) if !id.trim().is_empty() => id.trim().to_string(),
|
||||
"discord" => match &input.discord_username {
|
||||
Some(username) if !username.trim().is_empty() => {
|
||||
let clean = username.trim().to_lowercase();
|
||||
if !crate::api::validation::is_valid_discord_username(&clean) {
|
||||
return ApiError::InvalidRequest(
|
||||
"Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(),
|
||||
).into_response();
|
||||
}
|
||||
clean
|
||||
}
|
||||
_ => return ApiError::MissingDiscordId.into_response(),
|
||||
},
|
||||
"telegram" => match &input.telegram_username {
|
||||
@@ -183,8 +191,10 @@ pub async fn create_passkey_account(
|
||||
}
|
||||
_ => return ApiError::MissingTelegramUsername.into_response(),
|
||||
},
|
||||
"signal" => match &input.signal_number {
|
||||
Some(number) if !number.trim().is_empty() => number.trim().to_string(),
|
||||
"signal" => match &input.signal_username {
|
||||
Some(username) if !username.trim().is_empty() => {
|
||||
username.trim().trim_start_matches('@').to_lowercase()
|
||||
}
|
||||
_ => return ApiError::MissingSignalNumber.into_response(),
|
||||
},
|
||||
_ => return ApiError::InvalidVerificationChannel.into_response(),
|
||||
@@ -409,24 +419,23 @@ pub async fn create_passkey_account(
|
||||
email: email.clone().unwrap_or_default(),
|
||||
did: did_typed.clone(),
|
||||
preferred_comms_channel,
|
||||
discord_id: input
|
||||
.discord_id
|
||||
discord_username: input
|
||||
.discord_username
|
||||
.as_deref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from),
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty()),
|
||||
telegram_username: input
|
||||
.telegram_username
|
||||
.as_deref()
|
||||
.map(|s| s.trim().trim_start_matches('@'))
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from),
|
||||
signal_number: input
|
||||
.signal_number
|
||||
signal_username: input
|
||||
.signal_username
|
||||
.as_deref()
|
||||
.map(|s| s.trim())
|
||||
.map(|s| s.trim().trim_start_matches('@'))
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from),
|
||||
.map(|s| s.to_lowercase()),
|
||||
setup_token_hash,
|
||||
setup_expires_at,
|
||||
deactivated_at,
|
||||
@@ -501,6 +510,7 @@ pub async fn create_passkey_account(
|
||||
let formatted_token =
|
||||
crate::auth::verification_token::format_token_for_display(&verification_token);
|
||||
if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification(
|
||||
state.user_repo.as_ref(),
|
||||
state.infra_repo.as_ref(),
|
||||
user_id,
|
||||
verification_channel,
|
||||
|
||||
@@ -634,14 +634,14 @@ pub async fn confirm_signup(
|
||||
let (channel_str, identifier) = match row.channel {
|
||||
tranquil_db_traits::CommsChannel::Email => ("email", row.email.clone().unwrap_or_default()),
|
||||
tranquil_db_traits::CommsChannel::Discord => {
|
||||
("discord", row.discord_id.clone().unwrap_or_default())
|
||||
("discord", row.discord_username.clone().unwrap_or_default())
|
||||
}
|
||||
tranquil_db_traits::CommsChannel::Telegram => (
|
||||
"telegram",
|
||||
row.telegram_username.clone().unwrap_or_default(),
|
||||
),
|
||||
tranquil_db_traits::CommsChannel::Signal => {
|
||||
("signal", row.signal_number.clone().unwrap_or_default())
|
||||
("signal", row.signal_username.clone().unwrap_or_default())
|
||||
}
|
||||
};
|
||||
|
||||
@@ -786,14 +786,14 @@ pub async fn resend_verification(
|
||||
let (channel_str, recipient) = match row.channel {
|
||||
tranquil_db_traits::CommsChannel::Email => ("email", row.email.clone().unwrap_or_default()),
|
||||
tranquil_db_traits::CommsChannel::Discord => {
|
||||
("discord", row.discord_id.clone().unwrap_or_default())
|
||||
("discord", row.discord_username.clone().unwrap_or_default())
|
||||
}
|
||||
tranquil_db_traits::CommsChannel::Telegram => (
|
||||
"telegram",
|
||||
row.telegram_username.clone().unwrap_or_default(),
|
||||
),
|
||||
tranquil_db_traits::CommsChannel::Signal => {
|
||||
("signal", row.signal_number.clone().unwrap_or_default())
|
||||
("signal", row.signal_username.clone().unwrap_or_default())
|
||||
}
|
||||
};
|
||||
|
||||
@@ -804,6 +804,7 @@ pub async fn resend_verification(
|
||||
|
||||
let hostname = pds_hostname();
|
||||
if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification(
|
||||
state.user_repo.as_ref(),
|
||||
state.infra_repo.as_ref(),
|
||||
row.id,
|
||||
channel_str,
|
||||
|
||||
@@ -351,10 +351,17 @@ pub fn is_valid_email(email: &str) -> bool {
|
||||
|
||||
pub fn is_valid_telegram_username(username: &str) -> bool {
|
||||
let clean = username.strip_prefix('@').unwrap_or(username);
|
||||
(5..=32).contains(&clean.len())
|
||||
&& clean
|
||||
(5..=32).contains(&clean.len()) && clean.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
}
|
||||
|
||||
pub fn is_valid_discord_username(username: &str) -> bool {
|
||||
(2..=32).contains(&username.len())
|
||||
&& username
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '.')
|
||||
&& !username.starts_with('.')
|
||||
&& !username.ends_with('.')
|
||||
&& !username.contains("..")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -534,4 +541,29 @@ mod tests {
|
||||
fn test_trimmed_whitespace() {
|
||||
assert!(is_valid_email(" user@example.com "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_discord_usernames() {
|
||||
assert!(is_valid_discord_username("ab"));
|
||||
assert!(is_valid_discord_username("alice"));
|
||||
assert!(is_valid_discord_username("user_name"));
|
||||
assert!(is_valid_discord_username("user.name"));
|
||||
assert!(is_valid_discord_username("user123"));
|
||||
assert!(is_valid_discord_username("a_b.c_d"));
|
||||
assert!(is_valid_discord_username("12345678901234567890123456789012"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_discord_usernames() {
|
||||
assert!(!is_valid_discord_username(""));
|
||||
assert!(!is_valid_discord_username("a"));
|
||||
assert!(!is_valid_discord_username("Alice"));
|
||||
assert!(!is_valid_discord_username("ALICE"));
|
||||
assert!(!is_valid_discord_username("user-name"));
|
||||
assert!(!is_valid_discord_username(".username"));
|
||||
assert!(!is_valid_discord_username("username."));
|
||||
assert!(!is_valid_discord_username("user..name"));
|
||||
assert!(!is_valid_discord_username("user name"));
|
||||
assert!(!is_valid_discord_username("123456789012345678901234567890123"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ mod service;
|
||||
pub use tranquil_comms::{
|
||||
CommsChannel, CommsSender, CommsStatus, CommsType, DEFAULT_LOCALE, DiscordSender, EmailSender,
|
||||
NewComms, NotificationStrings, QueuedComms, SendError, SignalSender, TelegramSender,
|
||||
VALID_LOCALES, format_message, get_strings, is_valid_phone_number, mime_encode_header,
|
||||
sanitize_header_value, validate_locale,
|
||||
VALID_LOCALES, format_message, get_strings, is_valid_phone_number, is_valid_signal_username,
|
||||
mime_encode_header, sanitize_header_value, validate_locale,
|
||||
};
|
||||
|
||||
pub use service::{CommsService, channel_display_name, repo as comms_repo};
|
||||
|
||||
@@ -281,7 +281,7 @@ fn resolve_recipient(
|
||||
})
|
||||
.unwrap_or_else(email_fallback),
|
||||
tranquil_db_traits::CommsChannel::Signal => prefs
|
||||
.signal_number
|
||||
.signal_username
|
||||
.as_ref()
|
||||
.filter(|n| !n.is_empty())
|
||||
.map(|n| ResolvedRecipient {
|
||||
@@ -639,6 +639,7 @@ pub mod repo {
|
||||
}
|
||||
|
||||
pub async fn enqueue_signup_verification(
|
||||
user_repo: &dyn UserRepository,
|
||||
infra_repo: &dyn InfraRepository,
|
||||
user_id: Uuid,
|
||||
channel: &str,
|
||||
@@ -647,21 +648,19 @@ pub mod repo {
|
||||
hostname: &str,
|
||||
) -> Result<Uuid, DbError> {
|
||||
let comms_channel = channel_from_str(channel);
|
||||
let strings = get_strings("en");
|
||||
let (verify_page, verify_link) = match comms_channel {
|
||||
tranquil_db_traits::CommsChannel::Email => {
|
||||
let encoded_email = urlencoding::encode(recipient);
|
||||
let encoded_token = urlencoding::encode(code);
|
||||
(
|
||||
format!("https://{}/app/verify", hostname),
|
||||
format!(
|
||||
"https://{}/app/verify?token={}&identifier={}",
|
||||
hostname, encoded_token, encoded_email
|
||||
),
|
||||
)
|
||||
}
|
||||
_ => (String::new(), String::new()),
|
||||
};
|
||||
let prefs = user_repo.get_comms_prefs(user_id).await.ok().flatten();
|
||||
let locale = prefs
|
||||
.as_ref()
|
||||
.and_then(|p| p.preferred_locale.as_deref())
|
||||
.unwrap_or("en");
|
||||
let strings = get_strings(locale);
|
||||
let encoded_token = urlencoding::encode(code);
|
||||
let encoded_recipient = urlencoding::encode(recipient);
|
||||
let verify_page = format!("https://{}/app/verify", hostname);
|
||||
let verify_link = format!(
|
||||
"https://{}/app/verify?token={}&identifier={}",
|
||||
hostname, encoded_token, encoded_recipient
|
||||
);
|
||||
let body = format_message(
|
||||
strings.signup_verification_body,
|
||||
&[
|
||||
@@ -671,20 +670,17 @@ pub mod repo {
|
||||
("verify_link", &verify_link),
|
||||
],
|
||||
);
|
||||
let subject = match comms_channel {
|
||||
tranquil_db_traits::CommsChannel::Email => Some(format_message(
|
||||
strings.signup_verification_subject,
|
||||
&[("hostname", hostname)],
|
||||
)),
|
||||
_ => None,
|
||||
};
|
||||
let subject = format_message(
|
||||
strings.signup_verification_subject,
|
||||
&[("hostname", hostname)],
|
||||
);
|
||||
infra_repo
|
||||
.enqueue_comms(
|
||||
Some(user_id),
|
||||
comms_channel,
|
||||
CommsType::EmailVerification,
|
||||
recipient,
|
||||
subject.as_deref(),
|
||||
Some(&subject),
|
||||
&body,
|
||||
None,
|
||||
)
|
||||
|
||||
@@ -645,7 +645,13 @@ pub fn app(state: AppState) -> Router {
|
||||
.route("/u/{handle}/did.json", get(api::identity::user_did_doc))
|
||||
.route(
|
||||
"/webhook/telegram",
|
||||
post(api::telegram_webhook::handle_telegram_webhook),
|
||||
post(api::telegram_webhook::handle_telegram_webhook)
|
||||
.layer(DefaultBodyLimit::max(64 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/webhook/discord",
|
||||
post(api::discord_webhook::handle_discord_webhook)
|
||||
.layer(DefaultBodyLimit::max(64 * 1024)),
|
||||
)
|
||||
.layer(DefaultBodyLimit::max(util::get_max_blob_size()))
|
||||
.layer(middleware::from_fn(metrics::metrics_middleware))
|
||||
|
||||
@@ -64,6 +64,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
});
|
||||
|
||||
let mut comms_service = CommsService::new(state.infra_repo.clone());
|
||||
let mut deferred_discord_endpoint: Option<(DiscordSender, String, String)> = None;
|
||||
|
||||
if let Some(email_sender) = EmailSender::from_env() {
|
||||
info!("Email comms enabled");
|
||||
@@ -74,6 +75,46 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
if let Some(discord_sender) = DiscordSender::from_env() {
|
||||
info!("Discord comms enabled");
|
||||
match discord_sender.resolve_bot_username().await {
|
||||
Ok(username) => {
|
||||
info!(bot_username = %username, "Resolved Discord bot username");
|
||||
tranquil_pds::util::set_discord_bot_username(username);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to resolve Discord bot username: {}", e);
|
||||
}
|
||||
}
|
||||
match discord_sender.resolve_application_info().await {
|
||||
Ok((app_id, verify_key)) => {
|
||||
info!(app_id = %app_id, "Resolved Discord application info");
|
||||
tranquil_pds::util::set_discord_app_id(app_id.clone());
|
||||
match hex::decode(&verify_key)
|
||||
.ok()
|
||||
.and_then(|bytes| <[u8; 32]>::try_from(bytes.as_slice()).ok())
|
||||
.and_then(|bytes| ed25519_dalek::VerifyingKey::from_bytes(&bytes).ok())
|
||||
{
|
||||
Some(public_key) => {
|
||||
tranquil_pds::util::set_discord_public_key(public_key);
|
||||
info!("Discord Ed25519 public key loaded");
|
||||
let hostname = std::env::var("PDS_HOSTNAME")
|
||||
.unwrap_or_else(|_| "localhost".to_string());
|
||||
let webhook_url = format!("https://{}/webhook/discord", hostname);
|
||||
match discord_sender.register_slash_command(&app_id).await {
|
||||
Ok(()) => info!("Discord /start slash command registered"),
|
||||
Err(e) => warn!("Failed to register Discord slash command: {}", e),
|
||||
}
|
||||
deferred_discord_endpoint =
|
||||
Some((discord_sender.clone(), app_id, webhook_url));
|
||||
}
|
||||
None => {
|
||||
warn!("Failed to parse Discord verify_key as Ed25519 public key");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to resolve Discord application info: {}", e);
|
||||
}
|
||||
}
|
||||
comms_service = comms_service.register_sender(discord_sender);
|
||||
}
|
||||
|
||||
@@ -172,9 +213,27 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.await
|
||||
.map_err(|e| format!("Failed to bind to {}: {}", addr, e))?;
|
||||
|
||||
let server_result = axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown.clone().cancelled_owned())
|
||||
.await;
|
||||
let server_handle = tokio::spawn(async move {
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown.clone().cancelled_owned())
|
||||
.await
|
||||
});
|
||||
|
||||
if let Some((sender, app_id, webhook_url)) = deferred_discord_endpoint {
|
||||
tokio::spawn(async move {
|
||||
match sender
|
||||
.set_interactions_endpoint(&app_id, &webhook_url)
|
||||
.await
|
||||
{
|
||||
Ok(()) => info!(url = %webhook_url, "Discord interactions endpoint registered"),
|
||||
Err(e) => warn!("Failed to set Discord interactions endpoint: {}", e),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let server_result = server_handle
|
||||
.await
|
||||
.map_err(|e| format!("Server task panicked: {}", e))?;
|
||||
|
||||
comms_handle.await.ok();
|
||||
|
||||
|
||||
@@ -817,9 +817,9 @@ pub struct CompleteRegistrationInput {
|
||||
pub email: Option<String>,
|
||||
pub invite_code: Option<String>,
|
||||
pub verification_channel: Option<String>,
|
||||
pub discord_id: Option<String>,
|
||||
pub discord_username: Option<String>,
|
||||
pub telegram_username: Option<String>,
|
||||
pub signal_number: Option<String>,
|
||||
pub signal_username: Option<String>,
|
||||
pub did_type: Option<String>,
|
||||
pub did: Option<String>,
|
||||
}
|
||||
@@ -894,8 +894,16 @@ pub async fn complete_registration(
|
||||
_ => return Err(ApiError::MissingEmail),
|
||||
}
|
||||
}
|
||||
"discord" => match &input.discord_id {
|
||||
Some(id) if !id.trim().is_empty() => id.trim().to_string(),
|
||||
"discord" => match &input.discord_username {
|
||||
Some(username) if !username.trim().is_empty() => {
|
||||
let clean = username.trim().to_lowercase();
|
||||
if !crate::api::validation::is_valid_discord_username(&clean) {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(),
|
||||
));
|
||||
}
|
||||
clean
|
||||
}
|
||||
_ => return Err(ApiError::MissingDiscordId),
|
||||
},
|
||||
"telegram" => match &input.telegram_username {
|
||||
@@ -910,8 +918,10 @@ pub async fn complete_registration(
|
||||
}
|
||||
_ => return Err(ApiError::MissingTelegramUsername),
|
||||
},
|
||||
"signal" => match &input.signal_number {
|
||||
Some(number) if !number.trim().is_empty() => number.trim().to_string(),
|
||||
"signal" => match &input.signal_username {
|
||||
Some(username) if !username.trim().is_empty() => {
|
||||
username.trim().trim_start_matches('@').to_lowercase()
|
||||
}
|
||||
_ => return Err(ApiError::MissingSignalNumber),
|
||||
},
|
||||
_ => return Err(ApiError::InvalidVerificationChannel),
|
||||
@@ -1104,20 +1114,20 @@ pub async fn complete_registration(
|
||||
email: email.clone(),
|
||||
did: did_typed.clone(),
|
||||
preferred_comms_channel,
|
||||
discord_id: input
|
||||
.discord_id
|
||||
discord_username: input
|
||||
.discord_username
|
||||
.clone()
|
||||
.map(|s| s.trim().to_string())
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty()),
|
||||
telegram_username: input
|
||||
.telegram_username
|
||||
.clone()
|
||||
.map(|s| s.trim().trim_start_matches('@').to_string())
|
||||
.filter(|s| !s.is_empty()),
|
||||
signal_number: input
|
||||
.signal_number
|
||||
signal_username: input
|
||||
.signal_username
|
||||
.clone()
|
||||
.map(|s| s.trim().to_string())
|
||||
.map(|s| s.trim().trim_start_matches('@').to_lowercase())
|
||||
.filter(|s| !s.is_empty()),
|
||||
encrypted_key_bytes: encrypted_key_bytes.clone(),
|
||||
encryption_version: crate::config::ENCRYPTION_VERSION,
|
||||
@@ -1354,6 +1364,7 @@ pub async fn complete_registration(
|
||||
let formatted_token =
|
||||
crate::auth::verification_token::format_token_for_display(&verification_token);
|
||||
if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification(
|
||||
state.user_repo.as_ref(),
|
||||
state.infra_repo.as_ref(),
|
||||
uid,
|
||||
verification_channel,
|
||||
|
||||
@@ -9,6 +9,7 @@ use std::sync::Arc;
|
||||
use thiserror::Error;
|
||||
use tracing::debug;
|
||||
use tranquil_db::{ImportBlock, ImportRecord, ImportRepoError, RepoRepository};
|
||||
use tranquil_types::CidLink;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
@@ -323,6 +324,7 @@ pub async fn apply_import(
|
||||
root: Cid,
|
||||
blocks: HashMap<Cid, Bytes>,
|
||||
max_blocks: usize,
|
||||
expected_root_cid: Option<&CidLink>,
|
||||
) -> Result<ImportResult, ImportError> {
|
||||
if blocks.len() > max_blocks {
|
||||
return Err(ImportError::SizeLimitExceeded);
|
||||
@@ -364,7 +366,7 @@ pub async fn apply_import(
|
||||
.collect();
|
||||
|
||||
repo_repo
|
||||
.import_repo_data(user_id, &import_blocks, &import_records)
|
||||
.import_repo_data(user_id, &import_blocks, &import_records, expected_root_cid)
|
||||
.await?;
|
||||
|
||||
debug!(
|
||||
|
||||
@@ -14,6 +14,9 @@ const DEFAULT_MAX_BLOB_SIZE: usize = 10 * 1024 * 1024 * 1024;
|
||||
static MAX_BLOB_SIZE: OnceLock<usize> = OnceLock::new();
|
||||
static PDS_HOSTNAME: OnceLock<String> = OnceLock::new();
|
||||
static PDS_HOSTNAME_WITHOUT_PORT: OnceLock<String> = OnceLock::new();
|
||||
static DISCORD_BOT_USERNAME: OnceLock<String> = OnceLock::new();
|
||||
static DISCORD_PUBLIC_KEY: OnceLock<ed25519_dalek::VerifyingKey> = OnceLock::new();
|
||||
static DISCORD_APP_ID: OnceLock<String> = OnceLock::new();
|
||||
static TELEGRAM_BOT_USERNAME: OnceLock<String> = OnceLock::new();
|
||||
|
||||
pub fn get_max_blob_size() -> usize {
|
||||
@@ -105,6 +108,30 @@ pub fn pds_hostname_without_port() -> &'static str {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_discord_bot_username(username: String) {
|
||||
DISCORD_BOT_USERNAME.set(username).ok();
|
||||
}
|
||||
|
||||
pub fn discord_bot_username() -> Option<&'static str> {
|
||||
DISCORD_BOT_USERNAME.get().map(|s| s.as_str())
|
||||
}
|
||||
|
||||
pub fn set_discord_public_key(key: ed25519_dalek::VerifyingKey) {
|
||||
DISCORD_PUBLIC_KEY.set(key).ok();
|
||||
}
|
||||
|
||||
pub fn discord_public_key() -> Option<&'static ed25519_dalek::VerifyingKey> {
|
||||
DISCORD_PUBLIC_KEY.get()
|
||||
}
|
||||
|
||||
pub fn set_discord_app_id(app_id: String) {
|
||||
DISCORD_APP_ID.set(app_id).ok();
|
||||
}
|
||||
|
||||
pub fn discord_app_id() -> Option<&'static str> {
|
||||
DISCORD_APP_ID.get().map(|s| s.as_str())
|
||||
}
|
||||
|
||||
pub fn set_telegram_bot_username(username: String) {
|
||||
TELEGRAM_BOT_USERNAME.set(username).ok();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
mod common;
|
||||
use common::{base_url, client, create_account_and_login, get_test_db_pool};
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::Row;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_notification_history() {
|
||||
@@ -51,10 +50,10 @@ async fn test_get_notification_history() {
|
||||
async fn test_verify_channel_discord() {
|
||||
let client = client();
|
||||
let base = base_url().await;
|
||||
let (token, did) = create_account_and_login(&client).await;
|
||||
let (token, _did) = create_account_and_login(&client).await;
|
||||
|
||||
let prefs = json!({
|
||||
"discordId": "123456789"
|
||||
"discordUsername": "testuser123"
|
||||
});
|
||||
let resp = client
|
||||
.post(format!("{}/xrpc/_account.updateNotificationPrefs", base))
|
||||
@@ -72,42 +71,6 @@ async fn test_verify_channel_discord() {
|
||||
.contains(&json!("discord"))
|
||||
);
|
||||
|
||||
let pool = get_test_db_pool().await;
|
||||
let user_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users WHERE did = $1")
|
||||
.bind(&did)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("User not found");
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT body, metadata FROM comms_queue WHERE user_id = $1 AND comms_type = 'channel_verification' ORDER BY created_at DESC LIMIT 1",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("Verification code not found");
|
||||
|
||||
let metadata: Option<serde_json::Value> = row.get("metadata");
|
||||
let code = metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("code"))
|
||||
.and_then(|c| c.as_str())
|
||||
.expect("No code in metadata");
|
||||
|
||||
let input = json!({
|
||||
"channel": "discord",
|
||||
"identifier": "123456789",
|
||||
"code": code
|
||||
});
|
||||
let resp = client
|
||||
.post(format!("{}/xrpc/_account.confirmChannelVerification", base))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.json(&input)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
let resp = client
|
||||
.get(format!("{}/xrpc/_account.getNotificationPrefs", base))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
@@ -115,8 +78,8 @@ async fn test_verify_channel_discord() {
|
||||
.await
|
||||
.unwrap();
|
||||
let body: Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["discordVerified"], true);
|
||||
assert_eq!(body["discordId"], "123456789");
|
||||
assert_eq!(body["discordVerified"], false);
|
||||
assert_eq!(body["discordUsername"], "testuser123");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
mod common;
|
||||
use tranquil_pds::comms::{SendError, is_valid_phone_number, sanitize_header_value};
|
||||
use tranquil_pds::comms::{SendError, is_valid_phone_number, is_valid_signal_username, sanitize_header_value};
|
||||
use tranquil_pds::image::{ImageError, ImageProcessor};
|
||||
|
||||
#[test]
|
||||
@@ -80,6 +80,38 @@ fn test_phone_number_validation() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_signal_username_validation() {
|
||||
assert!(is_valid_signal_username("alice.01"));
|
||||
assert!(is_valid_signal_username("bob_smith.99"));
|
||||
assert!(is_valid_signal_username("user123.42"));
|
||||
assert!(is_valid_signal_username("lu1.01"));
|
||||
assert!(is_valid_signal_username("abc.00"));
|
||||
assert!(is_valid_signal_username("a_very_long_username_here.55"));
|
||||
|
||||
assert!(!is_valid_signal_username("alice"));
|
||||
assert!(!is_valid_signal_username("alice.1"));
|
||||
assert!(!is_valid_signal_username("alice.001"));
|
||||
assert!(!is_valid_signal_username(".01"));
|
||||
assert!(!is_valid_signal_username("ab.01"));
|
||||
assert!(!is_valid_signal_username(""));
|
||||
assert!(!is_valid_signal_username("1alice.01"));
|
||||
assert!(!is_valid_signal_username("alice!.01"));
|
||||
assert!(!is_valid_signal_username("alice .01"));
|
||||
|
||||
assert!(!is_valid_signal_username("a".repeat(33).as_str()));
|
||||
|
||||
["alice.01; rm -rf /", "bob.01 && cat /etc/passwd", "user.01`id`", "test.01$(whoami)"]
|
||||
.iter()
|
||||
.for_each(|malicious| {
|
||||
assert!(
|
||||
!is_valid_signal_username(malicious),
|
||||
"Command injection '{}' should be rejected",
|
||||
malicious
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_file_size_limits() {
|
||||
let processor = ImageProcessor::new();
|
||||
|
||||
@@ -1035,7 +1035,7 @@ async fn test_sso_complete_registration_multichannel_discord() {
|
||||
"token": token,
|
||||
"handle": handle_prefix,
|
||||
"verification_channel": "discord",
|
||||
"discord_id": discord_id
|
||||
"discord_username": discord_id
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
@@ -1054,7 +1054,7 @@ async fn test_sso_complete_registration_multichannel_discord() {
|
||||
|
||||
let did_str = body["did"].as_str().unwrap();
|
||||
let user = sqlx::query!(
|
||||
r#"SELECT preferred_comms_channel as "preferred_comms_channel: String", discord_id FROM users WHERE did = $1"#,
|
||||
r#"SELECT preferred_comms_channel as "preferred_comms_channel: String", discord_username FROM users WHERE did = $1"#,
|
||||
did_str,
|
||||
)
|
||||
.fetch_one(pool)
|
||||
@@ -1062,7 +1062,7 @@ async fn test_sso_complete_registration_multichannel_discord() {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(user.preferred_comms_channel, "discord");
|
||||
assert_eq!(user.discord_id, Some(discord_id.to_string()));
|
||||
assert_eq!(user.discord_username, Some(discord_id.to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1948,7 +1948,14 @@ async fn test_concurrent_import_and_writes() {
|
||||
.expect("Import request failed");
|
||||
let status = res.status();
|
||||
let body: Value = res.json().await.unwrap_or_default();
|
||||
assert_eq!(status, StatusCode::OK, "Import should succeed: {:?}", body);
|
||||
let is_concurrent_modification = status == StatusCode::BAD_REQUEST
|
||||
&& body.get("error").and_then(|e| e.as_str()) == Some("InvalidSwap");
|
||||
assert!(
|
||||
status == StatusCode::OK || is_concurrent_modification,
|
||||
"Import should succeed or fail with InvalidSwap due to concurrent writes: {:?}",
|
||||
body
|
||||
);
|
||||
status == StatusCode::OK
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1987,7 +1994,7 @@ async fn test_concurrent_import_and_writes() {
|
||||
})
|
||||
.collect();
|
||||
|
||||
tokio::join!(import_future, join_all(write_futures));
|
||||
let (import_succeeded, _) = tokio::join!(import_future, join_all(write_futures));
|
||||
|
||||
let final_posts = client
|
||||
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base))
|
||||
@@ -2003,11 +2010,17 @@ async fn test_concurrent_import_and_writes() {
|
||||
let final_body: Value = final_posts.json().await.unwrap();
|
||||
let record_count = final_body["records"].as_array().unwrap().len();
|
||||
|
||||
let min_expected = write_count;
|
||||
let min_expected = if import_succeeded {
|
||||
write_count + 1
|
||||
} else {
|
||||
write_count
|
||||
};
|
||||
assert!(
|
||||
record_count >= min_expected,
|
||||
"Expected at least {} records (from writes), got {} (import may also contribute records)",
|
||||
"Expected at least {} records (writes={}, import_succeeded={}), got {}",
|
||||
min_expected,
|
||||
write_count,
|
||||
import_succeeded,
|
||||
record_count
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,16 +18,18 @@
|
||||
let preferredChannel = $state('email')
|
||||
let availableCommsChannels = $state<string[]>(['email'])
|
||||
let telegramBotUsername = $state<string | undefined>(undefined)
|
||||
let discordBotUsername = $state<string | undefined>(undefined)
|
||||
let discordAppId = $state<string | undefined>(undefined)
|
||||
let email = $state('')
|
||||
let discordId = $state('')
|
||||
let discordUsername = $state('')
|
||||
let discordVerified = $state(false)
|
||||
let telegramUsername = $state('')
|
||||
let telegramVerified = $state(false)
|
||||
let signalNumber = $state('')
|
||||
let signalUsername = $state('')
|
||||
let signalVerified = $state(false)
|
||||
let savedDiscordId = $state('')
|
||||
let savedDiscordUsername = $state('')
|
||||
let savedTelegramUsername = $state('')
|
||||
let savedSignalNumber = $state('')
|
||||
let savedSignalUsername = $state('')
|
||||
let verifyingChannel = $state<string | null>(null)
|
||||
let verificationCode = $state('')
|
||||
let historyLoading = $state(true)
|
||||
@@ -57,17 +59,19 @@
|
||||
])
|
||||
preferredChannel = prefs.preferredChannel
|
||||
email = prefs.email
|
||||
discordId = prefs.discordId ?? ''
|
||||
discordUsername = prefs.discordUsername ?? ''
|
||||
discordVerified = prefs.discordVerified
|
||||
telegramUsername = prefs.telegramUsername ?? ''
|
||||
telegramVerified = prefs.telegramVerified
|
||||
signalNumber = prefs.signalNumber ?? ''
|
||||
signalUsername = prefs.signalUsername ?? ''
|
||||
signalVerified = prefs.signalVerified
|
||||
savedDiscordId = discordId
|
||||
savedDiscordUsername = discordUsername
|
||||
savedTelegramUsername = telegramUsername
|
||||
savedSignalNumber = signalNumber
|
||||
savedSignalUsername = signalUsername
|
||||
availableCommsChannels = serverInfo.availableCommsChannels ?? ['email']
|
||||
telegramBotUsername = serverInfo.telegramBotUsername
|
||||
discordBotUsername = serverInfo.discordBotUsername
|
||||
discordAppId = serverInfo.discordAppId
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : $_('comms.failedToLoad'))
|
||||
} finally {
|
||||
@@ -81,15 +85,15 @@
|
||||
try {
|
||||
const result = await api.updateNotificationPrefs(session.accessJwt, {
|
||||
preferredChannel,
|
||||
discordId: discordId !== savedDiscordId ? discordId : undefined,
|
||||
discordUsername: discordUsername !== savedDiscordUsername ? discordUsername : undefined,
|
||||
telegramUsername: telegramUsername !== savedTelegramUsername ? telegramUsername : undefined,
|
||||
signalNumber: signalNumber !== savedSignalNumber ? signalNumber : undefined,
|
||||
signalUsername: signalUsername !== savedSignalUsername ? signalUsername : undefined,
|
||||
})
|
||||
await refreshSession()
|
||||
toast.success($_('comms.preferencesSaved'))
|
||||
savedDiscordId = discordId
|
||||
savedDiscordUsername = discordUsername
|
||||
savedTelegramUsername = telegramUsername
|
||||
savedSignalNumber = signalNumber
|
||||
savedSignalUsername = signalUsername
|
||||
const channelToVerify = result.verificationRequired?.find(
|
||||
(ch: string) => ch === 'discord' || ch === 'telegram' || ch === 'signal'
|
||||
)
|
||||
@@ -108,9 +112,9 @@
|
||||
if (!verificationCode) return
|
||||
|
||||
const identifierMap: Record<string, string> = {
|
||||
discord: discordId,
|
||||
discord: discordUsername,
|
||||
telegram: telegramUsername,
|
||||
signal: signalNumber
|
||||
signal: signalUsername
|
||||
}
|
||||
const identifier = identifierMap[channel]
|
||||
if (!identifier) return
|
||||
@@ -190,9 +194,9 @@
|
||||
if (!isChannelAvailableOnServer(channelId)) return false
|
||||
if (channelId === 'email') return true
|
||||
const hasIdentifier: Record<string, boolean> = {
|
||||
discord: !!discordId,
|
||||
discord: !!discordUsername,
|
||||
telegram: !!telegramUsername,
|
||||
signal: !!signalNumber
|
||||
signal: !!signalUsername
|
||||
}
|
||||
return hasIdentifier[channelId] ?? false
|
||||
}
|
||||
@@ -245,8 +249,8 @@
|
||||
{#if isChannelAvailableOnServer('discord')}
|
||||
<div class="config-item">
|
||||
<div class="config-header">
|
||||
<label for="discord">{$_('register.discordId')}</label>
|
||||
{#if discordId}
|
||||
<label for="discord">{$_('register.discordUsername')}</label>
|
||||
{#if discordUsername}
|
||||
<span class="status" class:verified={discordVerified} class:unverified={!discordVerified}>
|
||||
{preferredChannel === 'discord' && discordVerified ? $_('comms.primary') : discordVerified ? $_('comms.verified') : $_('comms.notVerified')}
|
||||
</span>
|
||||
@@ -256,23 +260,22 @@
|
||||
<input
|
||||
id="discord"
|
||||
type="text"
|
||||
bind:value={discordId}
|
||||
onblur={() => checkChannelInUse('discord', discordId)}
|
||||
placeholder={$_('register.discordIdPlaceholder')}
|
||||
bind:value={discordUsername}
|
||||
onblur={() => checkChannelInUse('discord', discordUsername)}
|
||||
placeholder={$_('register.discordUsernamePlaceholder')}
|
||||
disabled={saving}
|
||||
/>
|
||||
{#if discordId && discordId === savedDiscordId && !discordVerified}
|
||||
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'discord'}>{$_('comms.verifyButton')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if discordInUse}
|
||||
<p class="hint warning">{$_('comms.discordInUseWarning')}</p>
|
||||
{/if}
|
||||
{#if verifyingChannel === 'discord'}
|
||||
<div class="verify-form">
|
||||
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="128" />
|
||||
<button type="button" onclick={() => handleVerify('discord')}>{$_('comms.submit')}</button>
|
||||
<button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button>
|
||||
{#if discordUsername && discordUsername === savedDiscordUsername && !discordVerified && discordBotUsername}
|
||||
{@const encodedHandle = session.handle.replaceAll('.', '_')}
|
||||
<div class="discord-verify-prompt">
|
||||
{#if discordAppId}
|
||||
<a href="https://discord.com/users/{discordAppId}" target="_blank" rel="noopener">{$_('comms.discordOpenLink')}</a>
|
||||
{/if}
|
||||
<span class="manual-hint">{$_('comms.discordStartBot', { values: { botUsername: discordBotUsername, handle: session.handle } })}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -314,8 +317,8 @@
|
||||
{#if isChannelAvailableOnServer('signal')}
|
||||
<div class="config-item">
|
||||
<div class="config-header">
|
||||
<label for="signal">{$_('register.signalNumber')}</label>
|
||||
{#if signalNumber}
|
||||
<label for="signal">{$_('register.signalUsername')}</label>
|
||||
{#if signalUsername}
|
||||
<span class="status" class:verified={signalVerified} class:unverified={!signalVerified}>
|
||||
{preferredChannel === 'signal' && signalVerified ? $_('comms.primary') : signalVerified ? $_('comms.verified') : $_('comms.notVerified')}
|
||||
</span>
|
||||
@@ -324,13 +327,13 @@
|
||||
<div class="config-input">
|
||||
<input
|
||||
id="signal"
|
||||
type="tel"
|
||||
bind:value={signalNumber}
|
||||
onblur={() => checkChannelInUse('signal', signalNumber)}
|
||||
placeholder={$_('register.signalNumberPlaceholder')}
|
||||
type="text"
|
||||
bind:value={signalUsername}
|
||||
onblur={() => checkChannelInUse('signal', signalUsername)}
|
||||
placeholder={$_('register.signalUsernamePlaceholder')}
|
||||
disabled={saving}
|
||||
/>
|
||||
{#if signalNumber && signalNumber === savedSignalNumber && !signalVerified}
|
||||
{#if signalUsername && signalUsername === savedSignalUsername && !signalVerified}
|
||||
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'signal'}>{$_('comms.verifyButton')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -533,6 +536,18 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.discord-verify-prompt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--accent-bg, var(--bg-card));
|
||||
border: 1px solid var(--accent, var(--border-color));
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.manual-hint {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
|
||||
@@ -404,9 +404,9 @@ export const api = {
|
||||
did: params.did,
|
||||
signingKey: params.signingKey,
|
||||
verificationChannel: params.verificationChannel,
|
||||
discordId: params.discordId,
|
||||
discordUsername: params.discordUsername,
|
||||
telegramUsername: params.telegramUsername,
|
||||
signalNumber: params.signalNumber,
|
||||
signalUsername: params.signalUsername,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
@@ -656,9 +656,9 @@ export const api = {
|
||||
|
||||
updateNotificationPrefs(token: AccessToken, prefs: {
|
||||
preferredChannel?: string;
|
||||
discordId?: string;
|
||||
discordUsername?: string;
|
||||
telegramUsername?: string;
|
||||
signalNumber?: string;
|
||||
signalUsername?: string;
|
||||
}): Promise<UpdateNotificationPrefsResponse> {
|
||||
return xrpc("_account.updateNotificationPrefs", {
|
||||
method: "POST",
|
||||
@@ -1133,9 +1133,9 @@ export const api = {
|
||||
did?: Did;
|
||||
signingKey?: string;
|
||||
verificationChannel?: VerificationChannel;
|
||||
discordId?: string;
|
||||
discordUsername?: string;
|
||||
telegramUsername?: string;
|
||||
signalNumber?: string;
|
||||
signalUsername?: string;
|
||||
}, byodToken?: string): Promise<PasskeyAccountCreateResponse> {
|
||||
const url = `${API_BASE}/_account.createPasskeyAccount`;
|
||||
const headers: Record<string, string> = {
|
||||
@@ -1854,9 +1854,9 @@ export const typedApi = {
|
||||
token: AccessToken,
|
||||
prefs: {
|
||||
preferredChannel?: string;
|
||||
discordId?: string;
|
||||
discordUsername?: string;
|
||||
telegramUsername?: string;
|
||||
signalNumber?: string;
|
||||
signalUsername?: string;
|
||||
},
|
||||
): Promise<Result<UpdateNotificationPrefsResponse, ApiError>> {
|
||||
return xrpcResult("_account.updateNotificationPrefs", {
|
||||
|
||||
@@ -14,25 +14,31 @@
|
||||
let resending = $state(false)
|
||||
let resendMessage = $state<string | null>(null)
|
||||
let telegramBotUsername = $state<string | undefined>(undefined)
|
||||
let discordBotUsername = $state<string | undefined>(undefined)
|
||||
let discordAppId = $state<string | undefined>(undefined)
|
||||
|
||||
let pollingInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const isTelegram = $derived(flow.info.verificationChannel === 'telegram')
|
||||
const isDiscord = $derived(flow.info.verificationChannel === 'discord')
|
||||
const isBotVerified = $derived(isTelegram || isDiscord)
|
||||
|
||||
onMount(async () => {
|
||||
if (isTelegram) {
|
||||
if (isTelegram || isDiscord) {
|
||||
try {
|
||||
const serverInfo = await api.describeServer()
|
||||
telegramBotUsername = serverInfo.telegramBotUsername
|
||||
discordBotUsername = serverInfo.discordBotUsername
|
||||
discordAppId = serverInfo.discordAppId
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (flow.state.step === 'verify' && flow.account && (isTelegram || !verificationCode.trim())) {
|
||||
if (flow.state.step === 'verify' && flow.account && (isBotVerified || !verificationCode.trim())) {
|
||||
pollingInterval = setInterval(async () => {
|
||||
if (!isTelegram && verificationCode.trim()) return
|
||||
if (!isBotVerified && verificationCode.trim()) return
|
||||
const advanced = await flow.checkAndAdvanceIfVerified()
|
||||
if (advanced && pollingInterval) {
|
||||
clearInterval(pollingInterval)
|
||||
@@ -105,6 +111,13 @@
|
||||
or send <code>/start {handle}</code> to <code>@{telegramBotUsername}</code> manually.
|
||||
</p>
|
||||
<p class="info-text waiting">Waiting for verification...</p>
|
||||
{:else if isDiscord && discordAppId}
|
||||
{@const handle = flow.account?.handle ?? `${flow.info.handle.trim()}.${flow.state.pdsHostname}`}
|
||||
<p class="info-text">
|
||||
<a href="https://discord.com/users/{discordAppId}" target="_blank" rel="noopener">Open Discord to verify</a>,
|
||||
or send <code>/start {handle}</code> to <strong>{discordBotUsername ?? 'the bot'}</strong> manually.
|
||||
</p>
|
||||
<p class="info-text waiting">Waiting for verification...</p>
|
||||
{:else}
|
||||
<p class="info-text">
|
||||
We've sent a verification code to your {channelLabel(flow.info.verificationChannel)}.
|
||||
|
||||
@@ -56,9 +56,9 @@ export function createRegistrationFlow(
|
||||
didType: "plc",
|
||||
externalDid: "",
|
||||
verificationChannel: "email",
|
||||
discordId: "",
|
||||
discordUsername: "",
|
||||
telegramUsername: "",
|
||||
signalNumber: "",
|
||||
signalUsername: "",
|
||||
},
|
||||
externalDidWeb: {
|
||||
keyMode: "reserved",
|
||||
@@ -252,9 +252,9 @@ export function createRegistrationFlow(
|
||||
? state.externalDidWeb.reservedSigningKey
|
||||
: undefined,
|
||||
verificationChannel: state.info.verificationChannel,
|
||||
discordId: state.info.discordId?.trim() || undefined,
|
||||
discordUsername: state.info.discordUsername?.trim() || undefined,
|
||||
telegramUsername: state.info.telegramUsername?.trim() || undefined,
|
||||
signalNumber: state.info.signalNumber?.trim() || undefined,
|
||||
signalUsername: state.info.signalUsername?.trim() || undefined,
|
||||
}, byodToken);
|
||||
|
||||
state.account = {
|
||||
@@ -305,9 +305,9 @@ export function createRegistrationFlow(
|
||||
? state.externalDidWeb.reservedSigningKey
|
||||
: undefined,
|
||||
verificationChannel: state.info.verificationChannel,
|
||||
discordId: state.info.discordId?.trim() || undefined,
|
||||
discordUsername: state.info.discordUsername?.trim() || undefined,
|
||||
telegramUsername: state.info.telegramUsername?.trim() || undefined,
|
||||
signalNumber: state.info.signalNumber?.trim() || undefined,
|
||||
signalUsername: state.info.signalUsername?.trim() || undefined,
|
||||
}, byodToken);
|
||||
|
||||
state.account = {
|
||||
|
||||
@@ -28,9 +28,9 @@ export interface RegistrationInfo {
|
||||
didType: DidType;
|
||||
externalDid?: string;
|
||||
verificationChannel: VerificationChannel;
|
||||
discordId?: string;
|
||||
discordUsername?: string;
|
||||
telegramUsername?: string;
|
||||
signalNumber?: string;
|
||||
signalUsername?: string;
|
||||
}
|
||||
|
||||
export interface ExternalDidWebState {
|
||||
|
||||
@@ -185,9 +185,9 @@ export interface CreateAccountParams {
|
||||
did?: string;
|
||||
signingKey?: string;
|
||||
verificationChannel?: VerificationChannel;
|
||||
discordId?: string;
|
||||
discordUsername?: string;
|
||||
telegramUsername?: string;
|
||||
signalNumber?: string;
|
||||
signalUsername?: string;
|
||||
}
|
||||
|
||||
export interface CreateAccountResult {
|
||||
@@ -254,11 +254,11 @@ export interface ListReposResponse {
|
||||
export interface NotificationPrefs {
|
||||
preferredChannel: VerificationChannel;
|
||||
email: EmailAddress;
|
||||
discordId: string | null;
|
||||
discordUsername: string | null;
|
||||
discordVerified: boolean;
|
||||
telegramUsername: string | null;
|
||||
telegramVerified: boolean;
|
||||
signalNumber: string | null;
|
||||
signalUsername: string | null;
|
||||
signalVerified: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -173,11 +173,11 @@ export const reauthResponseSchema = z.object({
|
||||
export const notificationPrefsSchema = z.object({
|
||||
preferredChannel: verificationChannel,
|
||||
email: email,
|
||||
discordId: z.string().nullable(),
|
||||
discordUsername: z.string().nullable(),
|
||||
discordVerified: z.boolean(),
|
||||
telegramUsername: z.string().nullable(),
|
||||
telegramVerified: z.boolean(),
|
||||
signalNumber: z.string().nullable(),
|
||||
signalUsername: z.string().nullable(),
|
||||
signalVerified: z.boolean(),
|
||||
});
|
||||
|
||||
|
||||
@@ -84,19 +84,17 @@
|
||||
"emailAddress": "Email Address",
|
||||
"emailPlaceholder": "you@example.com",
|
||||
"discord": "Discord",
|
||||
"discordId": "Discord User ID",
|
||||
"discordIdPlaceholder": "123456789012345678",
|
||||
"discordIdHint": "Enable Developer Mode to find your ID",
|
||||
"discordInUseWarning": "Discord ID in use by another account",
|
||||
"discordUsername": "Discord Username",
|
||||
"discordUsernamePlaceholder": "yourusername",
|
||||
"discordInUseWarning": "Discord username in use by another account",
|
||||
"telegram": "Telegram",
|
||||
"telegramUsername": "Telegram Username",
|
||||
"telegramUsernamePlaceholder": "@yourusername",
|
||||
"telegramInUseWarning": "Telegram username in use by another account",
|
||||
"signal": "Signal",
|
||||
"signalNumber": "Signal Phone Number",
|
||||
"signalNumberPlaceholder": "+1234567890",
|
||||
"signalNumberHint": "Include country code",
|
||||
"signalInUseWarning": "Signal number in use by another account",
|
||||
"signalUsername": "Signal Username",
|
||||
"signalUsernamePlaceholder": "username.01",
|
||||
"signalInUseWarning": "Signal username in use by another account",
|
||||
"notConfigured": "not configured",
|
||||
"inviteCode": "Invite Code",
|
||||
"inviteCodePlaceholder": "Enter your invite code",
|
||||
@@ -118,9 +116,9 @@
|
||||
"externalDidRequired": "External did:web is required",
|
||||
"externalDidFormat": "External DID must start with did:web:",
|
||||
"emailRequired": "Email is required for email verification",
|
||||
"discordIdRequired": "Discord ID is required for Discord verification",
|
||||
"discordUsernameRequired": "Discord username is required for Discord verification",
|
||||
"telegramRequired": "Telegram username is required for Telegram verification",
|
||||
"signalRequired": "Phone number is required for Signal verification"
|
||||
"signalRequired": "Signal username is required for Signal verification"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -437,11 +435,13 @@
|
||||
"verifiedSuccess": "{channel} verified successfully",
|
||||
"messageHistory": "Message History",
|
||||
"noMessages": "No messages found.",
|
||||
"discordInUseWarning": "This Discord ID is already associated with another account.",
|
||||
"discordInUseWarning": "This Discord username is already associated with another account.",
|
||||
"telegramInUseWarning": "This Telegram username is already associated with another account.",
|
||||
"signalInUseWarning": "This Signal number is already associated with another account.",
|
||||
"signalInUseWarning": "This Signal username is already associated with another account.",
|
||||
"telegramStartBot": "Or send /start {handle} to @{botUsername} manually",
|
||||
"telegramOpenLink": "Open Telegram to verify"
|
||||
"telegramOpenLink": "Open Telegram to verify",
|
||||
"discordStartBot": "DM @{botUsername} on Discord and send /start {handle}",
|
||||
"discordOpenLink": "Open Discord to verify"
|
||||
},
|
||||
"repoExplorer": {
|
||||
"collections": "Collections",
|
||||
@@ -779,9 +779,9 @@
|
||||
"externalDidRequired": "External did:web is required",
|
||||
"externalDidFormat": "External DID must start with did:web:",
|
||||
"emailRequired": "Email is required for email verification",
|
||||
"discordRequired": "Discord ID is required for Discord verification",
|
||||
"discordRequired": "Discord username is required for Discord verification",
|
||||
"telegramRequired": "Telegram username is required for Telegram verification",
|
||||
"signalRequired": "Phone number required",
|
||||
"signalRequired": "Signal username required",
|
||||
"passkeysNotSupported": "Passkeys not supported in this browser",
|
||||
"passkeyCancelled": "Cancelled",
|
||||
"passkeyFailed": "Passkey registration failed"
|
||||
|
||||
@@ -84,19 +84,17 @@
|
||||
"emailAddress": "Sähköpostiosoite",
|
||||
"emailPlaceholder": "sinä@esimerkki.fi",
|
||||
"discord": "Discord",
|
||||
"discordId": "Discord-käyttäjätunnus",
|
||||
"discordIdPlaceholder": "Discord-käyttäjätunnuksesi",
|
||||
"discordIdHint": "Numeerinen Discord-käyttäjätunnuksesi (ota Kehittäjätila käyttöön löytääksesi sen)",
|
||||
"discordInUseWarning": "Tämä Discord-tunnus on jo yhdistetty toiseen tiliin.",
|
||||
"discordUsername": "Discord-käyttäjänimi",
|
||||
"discordUsernamePlaceholder": "käyttäjänimesi",
|
||||
"discordInUseWarning": "Tämä Discord-käyttäjänimi on jo yhdistetty toiseen tiliin.",
|
||||
"telegram": "Telegram",
|
||||
"telegramUsername": "Telegram-käyttäjänimi",
|
||||
"telegramUsernamePlaceholder": "@käyttäjänimesi",
|
||||
"telegramInUseWarning": "Tämä Telegram-käyttäjänimi on jo yhdistetty toiseen tiliin.",
|
||||
"signal": "Signal",
|
||||
"signalNumber": "Signal-puhelinnumero",
|
||||
"signalNumberPlaceholder": "+358401234567",
|
||||
"signalNumberHint": "Sisällytä maakoodi (esim. +358 Suomelle)",
|
||||
"signalInUseWarning": "Tämä Signal-numero on jo yhdistetty toiseen tiliin.",
|
||||
"signalUsername": "Signal-käyttäjänimi",
|
||||
"signalUsernamePlaceholder": "käyttäjänimi.01",
|
||||
"signalInUseWarning": "Tämä Signal-käyttäjänimi on jo yhdistetty toiseen tiliin.",
|
||||
"notConfigured": "ei määritetty",
|
||||
"inviteCode": "Kutsukoodi",
|
||||
"inviteCodePlaceholder": "Syötä kutsukoodisi",
|
||||
@@ -118,9 +116,9 @@
|
||||
"externalDidRequired": "Ulkoinen did:web vaaditaan",
|
||||
"externalDidFormat": "Ulkoisen DID:n on alettava did:web:",
|
||||
"emailRequired": "Sähköposti vaaditaan sähköpostivahvistukseen",
|
||||
"discordIdRequired": "Discord-tunnus vaaditaan Discord-vahvistukseen",
|
||||
"discordUsernameRequired": "Discord-käyttäjänimi vaaditaan Discord-vahvistukseen",
|
||||
"telegramRequired": "Telegram-käyttäjänimi vaaditaan Telegram-vahvistukseen",
|
||||
"signalRequired": "Puhelinnumero vaaditaan Signal-vahvistukseen"
|
||||
"signalRequired": "Signal-käyttäjänimi vaaditaan Signal-vahvistukseen"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -433,11 +431,13 @@
|
||||
"verifiedSuccess": "{channel} vahvistettu",
|
||||
"messageHistory": "Viestihistoria",
|
||||
"noMessages": "Viestejä ei löytynyt.",
|
||||
"discordInUseWarning": "Tämä Discord-tunnus on jo yhdistetty toiseen tiliin.",
|
||||
"discordInUseWarning": "Tämä Discord-käyttäjänimi on jo yhdistetty toiseen tiliin.",
|
||||
"telegramInUseWarning": "Tämä Telegram-käyttäjänimi on jo yhdistetty toiseen tiliin.",
|
||||
"signalInUseWarning": "Tämä Signal-numero on jo yhdistetty toiseen tiliin.",
|
||||
"signalInUseWarning": "Tämä Signal-käyttäjänimi on jo yhdistetty toiseen tiliin.",
|
||||
"telegramStartBot": "Tai lähetä /start {handle} käyttäjälle @{botUsername} manuaalisesti",
|
||||
"telegramOpenLink": "Avaa Telegram vahvistaaksesi",
|
||||
"discordStartBot": "Lähetä @{botUsername}-botille viesti /start {handle} Discordissa",
|
||||
"discordOpenLink": "Avaa Discord vahvistaaksesi",
|
||||
"failedToLoad": "Asetusten lataus epäonnistui",
|
||||
"failedToSave": "Asetusten tallennus epäonnistui",
|
||||
"failedToVerify": "Vahvistus epäonnistui",
|
||||
@@ -751,13 +751,13 @@
|
||||
"passkeysNotSupported": "Pääsyavaimia ei tueta tässä selaimessa. Luo salasanapohjainen tili tai käytä selainta, joka tukee pääsyavaimia.",
|
||||
"passkeyCancelled": "Pääsyavaimen luominen peruutettu",
|
||||
"passkeyFailed": "Pääsyavaimen rekisteröinti epäonnistui",
|
||||
"signalRequired": "Puhelinnumero vaaditaan Signal-vahvistukseen",
|
||||
"signalRequired": "Signal-käyttäjänimi vaaditaan Signal-vahvistukseen",
|
||||
"inviteRequired": "Kutsukoodi vaaditaan",
|
||||
"externalDidRequired": "Ulkoinen did:web vaaditaan",
|
||||
"emailRequired": "Sähköposti vaaditaan sähköpostivahvistukseen",
|
||||
"telegramRequired": "Telegram-käyttäjänimi vaaditaan Telegram-vahvistukseen",
|
||||
"externalDidFormat": "Ulkoisen DID:n on alettava did:web:",
|
||||
"discordRequired": "Discord-tunnus vaaditaan Discord-vahvistukseen"
|
||||
"discordRequired": "Discord-käyttäjänimi vaaditaan Discord-vahvistukseen"
|
||||
},
|
||||
"identityType": "Identiteettityyppi",
|
||||
"identityTypeHint": "Valitse, miten hajautettua identiteettiäsi hallitaan.",
|
||||
|
||||
@@ -84,19 +84,17 @@
|
||||
"emailAddress": "メールアドレス",
|
||||
"emailPlaceholder": "you@example.com",
|
||||
"discord": "Discord",
|
||||
"discordId": "Discord ユーザー ID",
|
||||
"discordIdPlaceholder": "Discord ユーザー ID",
|
||||
"discordIdHint": "数値の Discord ユーザー ID(開発者モードを有効にして確認)",
|
||||
"discordInUseWarning": "この Discord ID は既に別のアカウントに関連付けられています。",
|
||||
"discordUsername": "Discord ユーザー名",
|
||||
"discordUsernamePlaceholder": "yourusername",
|
||||
"discordInUseWarning": "この Discord ユーザー名は既に別のアカウントに関連付けられています。",
|
||||
"telegram": "Telegram",
|
||||
"telegramUsername": "Telegram ユーザー名",
|
||||
"telegramUsernamePlaceholder": "@yourusername",
|
||||
"telegramInUseWarning": "この Telegram ユーザー名は既に別のアカウントに関連付けられています。",
|
||||
"signal": "Signal",
|
||||
"signalNumber": "Signal 電話番号",
|
||||
"signalNumberPlaceholder": "+81XXXXXXXXXX",
|
||||
"signalNumberHint": "国番号を含めてください(例: 日本は +81)",
|
||||
"signalInUseWarning": "この Signal 番号は既に別のアカウントに関連付けられています。",
|
||||
"signalUsername": "Signal ユーザー名",
|
||||
"signalUsernamePlaceholder": "username.01",
|
||||
"signalInUseWarning": "この Signal ユーザー名は既に別のアカウントに使用されています。",
|
||||
"notConfigured": "未設定",
|
||||
"inviteCode": "招待コード",
|
||||
"inviteCodePlaceholder": "招待コードを入力",
|
||||
@@ -118,9 +116,9 @@
|
||||
"externalDidRequired": "外部 did:web は必須です",
|
||||
"externalDidFormat": "外部 DID は did:web: で始まる必要があります",
|
||||
"emailRequired": "メール認証にはメールアドレスが必要です",
|
||||
"discordIdRequired": "Discord 認証には Discord ID が必要です",
|
||||
"discordUsernameRequired": "Discord 認証には Discord ユーザー名が必要です",
|
||||
"telegramRequired": "Telegram 認証には Telegram ユーザー名が必要です",
|
||||
"signalRequired": "Signal 認証には電話番号が必要です"
|
||||
"signalRequired": "Signal 認証にはユーザー名が必要です"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -433,11 +431,13 @@
|
||||
"verifiedSuccess": "{channel} を確認しました",
|
||||
"messageHistory": "メッセージ履歴",
|
||||
"noMessages": "メッセージが見つかりません。",
|
||||
"discordInUseWarning": "この Discord ID は既に別のアカウントに関連付けられています。",
|
||||
"discordInUseWarning": "この Discord ユーザー名は既に別のアカウントに関連付けられています。",
|
||||
"telegramInUseWarning": "この Telegram ユーザー名は既に別のアカウントに関連付けられています。",
|
||||
"signalInUseWarning": "この Signal 番号は既に別のアカウントに関連付けられています。",
|
||||
"signalInUseWarning": "この Signal ユーザー名は既に別のアカウントに使用されています。",
|
||||
"telegramStartBot": "または @{botUsername} に /start {handle} を手動で送信",
|
||||
"telegramOpenLink": "Telegram で確認する",
|
||||
"discordStartBot": "Discordで @{botUsername} にDMして /start {handle} を送信",
|
||||
"discordOpenLink": "Discordで認証",
|
||||
"failedToLoad": "設定の読み込みに失敗しました",
|
||||
"failedToSave": "設定の保存に失敗しました",
|
||||
"failedToVerify": "確認に失敗しました",
|
||||
@@ -751,13 +751,13 @@
|
||||
"passkeysNotSupported": "このブラウザではパスキーがサポートされていません。パスワードベースのアカウントを作成するか、パスキーをサポートするブラウザを使用してください。",
|
||||
"passkeyCancelled": "パスキーの作成がキャンセルされました",
|
||||
"passkeyFailed": "パスキーの登録に失敗しました",
|
||||
"signalRequired": "Signal認証には電話番号が必要です",
|
||||
"signalRequired": "Signal認証にはユーザー名が必要です",
|
||||
"inviteRequired": "招待コードが必要です",
|
||||
"externalDidRequired": "外部did:webが必要です",
|
||||
"emailRequired": "メール認証にはメールアドレスが必要です",
|
||||
"telegramRequired": "Telegram認証にはTelegramユーザー名が必要です",
|
||||
"externalDidFormat": "外部DIDはdid:web:で始まる必要があります",
|
||||
"discordRequired": "Discord認証にはDiscord IDが必要です"
|
||||
"discordRequired": "Discord認証にはDiscordユーザー名が必要です"
|
||||
},
|
||||
"identityType": "アイデンティティタイプ",
|
||||
"identityTypeHint": "分散型アイデンティティの管理方法を選択してください。",
|
||||
|
||||
@@ -84,19 +84,17 @@
|
||||
"emailAddress": "이메일 주소",
|
||||
"emailPlaceholder": "you@example.com",
|
||||
"discord": "Discord",
|
||||
"discordId": "Discord 사용자 ID",
|
||||
"discordIdPlaceholder": "Discord 사용자 ID",
|
||||
"discordIdHint": "숫자 Discord 사용자 ID (개발자 모드를 활성화하여 찾기)",
|
||||
"discordInUseWarning": "이 Discord ID는 이미 다른 계정과 연결되어 있습니다.",
|
||||
"discordUsername": "Discord 사용자명",
|
||||
"discordUsernamePlaceholder": "yourusername",
|
||||
"discordInUseWarning": "이 Discord 사용자명은 이미 다른 계정과 연결되어 있습니다.",
|
||||
"telegram": "Telegram",
|
||||
"telegramUsername": "Telegram 사용자 이름",
|
||||
"telegramUsernamePlaceholder": "@yourusername",
|
||||
"telegramInUseWarning": "이 Telegram 사용자 이름은 이미 다른 계정과 연결되어 있습니다.",
|
||||
"signal": "Signal",
|
||||
"signalNumber": "Signal 전화번호",
|
||||
"signalNumberPlaceholder": "+821012345678",
|
||||
"signalNumberHint": "국가 코드 포함 (예: 한국 +82)",
|
||||
"signalInUseWarning": "이 Signal 번호는 이미 다른 계정과 연결되어 있습니다.",
|
||||
"signalUsername": "Signal 사용자명",
|
||||
"signalUsernamePlaceholder": "username.01",
|
||||
"signalInUseWarning": "이 Signal 사용자명은 이미 다른 계정에서 사용 중입니다.",
|
||||
"notConfigured": "구성되지 않음",
|
||||
"inviteCode": "초대 코드",
|
||||
"inviteCodePlaceholder": "초대 코드 입력",
|
||||
@@ -118,9 +116,9 @@
|
||||
"externalDidRequired": "외부 did:web은 필수입니다",
|
||||
"externalDidFormat": "외부 DID는 did:web:으로 시작해야 합니다",
|
||||
"emailRequired": "이메일 인증에는 이메일이 필요합니다",
|
||||
"discordIdRequired": "Discord 인증에는 Discord ID가 필요합니다",
|
||||
"discordUsernameRequired": "Discord 인증에는 Discord 사용자명이 필요합니다",
|
||||
"telegramRequired": "Telegram 인증에는 Telegram 사용자 이름이 필요합니다",
|
||||
"signalRequired": "Signal 인증에는 전화번호가 필요합니다"
|
||||
"signalRequired": "Signal 인증에는 사용자명이 필요합니다"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -433,11 +431,13 @@
|
||||
"verifiedSuccess": "{channel} 인증 완료",
|
||||
"messageHistory": "메시지 기록",
|
||||
"noMessages": "메시지가 없습니다.",
|
||||
"discordInUseWarning": "이 Discord ID는 이미 다른 계정과 연결되어 있습니다.",
|
||||
"discordInUseWarning": "이 Discord 사용자명은 이미 다른 계정과 연결되어 있습니다.",
|
||||
"telegramInUseWarning": "이 Telegram 사용자 이름은 이미 다른 계정과 연결되어 있습니다.",
|
||||
"signalInUseWarning": "이 Signal 번호는 이미 다른 계정과 연결되어 있습니다.",
|
||||
"signalInUseWarning": "이 Signal 사용자명은 이미 다른 계정에서 사용 중입니다.",
|
||||
"telegramStartBot": "또는 @{botUsername}에게 /start {handle}을 직접 보내세요",
|
||||
"telegramOpenLink": "Telegram에서 인증하기",
|
||||
"discordStartBot": "Discord에서 @{botUsername}에게 DM으로 /start {handle} 보내기",
|
||||
"discordOpenLink": "Discord에서 인증",
|
||||
"failedToLoad": "설정 로딩 실패",
|
||||
"failedToSave": "설정 저장 실패",
|
||||
"failedToVerify": "인증 실패",
|
||||
@@ -751,13 +751,13 @@
|
||||
"passkeysNotSupported": "이 브라우저에서 패스키가 지원되지 않습니다. 비밀번호 기반 계정을 만들거나 패스키를 지원하는 브라우저를 사용하세요.",
|
||||
"passkeyCancelled": "패스키 생성이 취소되었습니다",
|
||||
"passkeyFailed": "패스키 등록에 실패했습니다",
|
||||
"signalRequired": "Signal 인증에는 전화번호가 필요합니다",
|
||||
"signalRequired": "Signal 인증에는 사용자명이 필요합니다",
|
||||
"inviteRequired": "초대 코드가 필요합니다",
|
||||
"externalDidRequired": "외부 did:web이 필요합니다",
|
||||
"emailRequired": "이메일 인증에는 이메일이 필요합니다",
|
||||
"telegramRequired": "Telegram 인증에는 Telegram 사용자 이름이 필요합니다",
|
||||
"externalDidFormat": "외부 DID는 did:web:으로 시작해야 합니다",
|
||||
"discordRequired": "Discord 인증에는 Discord ID가 필요합니다"
|
||||
"discordRequired": "Discord 인증에는 Discord 사용자명이 필요합니다"
|
||||
},
|
||||
"identityType": "아이덴티티 유형",
|
||||
"identityTypeHint": "분산 아이덴티티 관리 방법을 선택하세요.",
|
||||
|
||||
@@ -84,19 +84,17 @@
|
||||
"emailAddress": "E-postadress",
|
||||
"emailPlaceholder": "du@exempel.se",
|
||||
"discord": "Discord",
|
||||
"discordId": "Discord användar-ID",
|
||||
"discordIdPlaceholder": "Ditt Discord användar-ID",
|
||||
"discordIdHint": "Ditt numeriska Discord användar-ID (aktivera Utvecklarläge för att hitta det)",
|
||||
"discordInUseWarning": "Detta Discord-ID är redan kopplat till ett annat konto.",
|
||||
"discordUsername": "Discord-användarnamn",
|
||||
"discordUsernamePlaceholder": "dittanvändarnamn",
|
||||
"discordInUseWarning": "Detta Discord-användarnamn är redan kopplat till ett annat konto.",
|
||||
"telegram": "Telegram",
|
||||
"telegramUsername": "Telegram-användarnamn",
|
||||
"telegramUsernamePlaceholder": "@dittanvändarnamn",
|
||||
"telegramInUseWarning": "Detta Telegram-användarnamn är redan kopplat till ett annat konto.",
|
||||
"signal": "Signal",
|
||||
"signalNumber": "Signal-telefonnummer",
|
||||
"signalNumberPlaceholder": "+46701234567",
|
||||
"signalNumberHint": "Inkludera landskod (t.ex. +46 för Sverige)",
|
||||
"signalInUseWarning": "Detta Signal-nummer är redan kopplat till ett annat konto.",
|
||||
"signalUsername": "Signal-användarnamn",
|
||||
"signalUsernamePlaceholder": "användarnamn.01",
|
||||
"signalInUseWarning": "Detta Signal-användarnamn är redan kopplat till ett annat konto.",
|
||||
"notConfigured": "ej konfigurerad",
|
||||
"inviteCode": "Inbjudningskod",
|
||||
"inviteCodePlaceholder": "Ange din inbjudningskod",
|
||||
@@ -118,9 +116,9 @@
|
||||
"externalDidRequired": "Extern did:web krävs",
|
||||
"externalDidFormat": "Extern DID måste börja med did:web:",
|
||||
"emailRequired": "E-post krävs för e-postverifiering",
|
||||
"discordIdRequired": "Discord-ID krävs för Discord-verifiering",
|
||||
"discordUsernameRequired": "Discord-användarnamn krävs för Discord-verifiering",
|
||||
"telegramRequired": "Telegram-användarnamn krävs för Telegram-verifiering",
|
||||
"signalRequired": "Telefonnummer krävs för Signal-verifiering"
|
||||
"signalRequired": "Signal-användarnamn krävs för Signal-verifiering"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -433,11 +431,13 @@
|
||||
"verifiedSuccess": "{channel} verifierad",
|
||||
"messageHistory": "Meddelandehistorik",
|
||||
"noMessages": "Inga meddelanden hittades.",
|
||||
"discordInUseWarning": "Detta Discord-ID är redan kopplat till ett annat konto.",
|
||||
"discordInUseWarning": "Detta Discord-användarnamn är redan kopplat till ett annat konto.",
|
||||
"telegramInUseWarning": "Detta Telegram-användarnamn är redan kopplat till ett annat konto.",
|
||||
"signalInUseWarning": "Detta Signal-nummer är redan kopplat till ett annat konto.",
|
||||
"signalInUseWarning": "Detta Signal-användarnamn är redan kopplat till ett annat konto.",
|
||||
"telegramStartBot": "Eller skicka /start {handle} till @{botUsername} manuellt",
|
||||
"telegramOpenLink": "Öppna Telegram för att verifiera",
|
||||
"discordStartBot": "DM:a @{botUsername} på Discord och skicka /start {handle}",
|
||||
"discordOpenLink": "Öppna Discord för att verifiera",
|
||||
"failedToLoad": "Kunde inte ladda inställningar",
|
||||
"failedToSave": "Kunde inte spara inställningar",
|
||||
"failedToVerify": "Verifiering misslyckades",
|
||||
@@ -751,13 +751,13 @@
|
||||
"passkeysNotSupported": "Nycklar stöds inte i denna webbläsare. Skapa ett lösenordsbaserat konto eller använd en webbläsare som stöder nycklar.",
|
||||
"passkeyCancelled": "Nyckelskapande avbröts",
|
||||
"passkeyFailed": "Nyckelregistrering misslyckades",
|
||||
"signalRequired": "Telefonnummer krävs för Signal-verifiering",
|
||||
"signalRequired": "Signal-användarnamn krävs för Signal-verifiering",
|
||||
"inviteRequired": "Inbjudningskod krävs",
|
||||
"externalDidRequired": "Extern did:web krävs",
|
||||
"emailRequired": "E-post krävs för e-postverifiering",
|
||||
"telegramRequired": "Telegram-användarnamn krävs för Telegram-verifiering",
|
||||
"externalDidFormat": "Extern DID måste börja med did:web:",
|
||||
"discordRequired": "Discord-ID krävs för Discord-verifiering"
|
||||
"discordRequired": "Discord-användarnamn krävs för Discord-verifiering"
|
||||
},
|
||||
"identityType": "Identitetstyp",
|
||||
"identityTypeHint": "Välj hur din decentraliserade identitet ska hanteras.",
|
||||
|
||||
@@ -84,19 +84,17 @@
|
||||
"emailAddress": "电子邮件地址",
|
||||
"emailPlaceholder": "you@example.com",
|
||||
"discord": "Discord",
|
||||
"discordId": "Discord 用户 ID",
|
||||
"discordIdPlaceholder": "您的 Discord 用户 ID",
|
||||
"discordIdHint": "您的 Discord 数字用户 ID(开启开发者模式后可以复制)",
|
||||
"discordInUseWarning": "此 Discord ID 已与另一个账户关联。",
|
||||
"discordUsername": "Discord 用户名",
|
||||
"discordUsernamePlaceholder": "yourusername",
|
||||
"discordInUseWarning": "此 Discord 用户名已与另一个账户关联。",
|
||||
"telegram": "Telegram",
|
||||
"telegramUsername": "Telegram 用户名",
|
||||
"telegramUsernamePlaceholder": "@yourusername",
|
||||
"telegramInUseWarning": "此 Telegram 用户名已与另一个账户关联。",
|
||||
"signal": "Signal",
|
||||
"signalNumber": "Signal 电话号码",
|
||||
"signalNumberPlaceholder": "+1234567890",
|
||||
"signalNumberHint": "包含国家代码(例如中国为 +86)",
|
||||
"signalInUseWarning": "此 Signal 号码已与另一个账户关联。",
|
||||
"signalUsername": "Signal 用户名",
|
||||
"signalUsernamePlaceholder": "username.01",
|
||||
"signalInUseWarning": "此 Signal 用户名已被其他账户使用。",
|
||||
"notConfigured": "未配置",
|
||||
"inviteCode": "邀请码",
|
||||
"inviteCodePlaceholder": "输入您的邀请码",
|
||||
@@ -118,9 +116,9 @@
|
||||
"externalDidRequired": "请输入您的 did:web",
|
||||
"externalDidFormat": "DID 必须以 did:web: 开头",
|
||||
"emailRequired": "使用邮箱验证需要填写邮箱地址",
|
||||
"discordIdRequired": "使用 Discord 验证需要填写 Discord ID",
|
||||
"discordUsernameRequired": "使用 Discord 验证需要填写 Discord 用户名",
|
||||
"telegramRequired": "使用 Telegram 验证需要填写用户名",
|
||||
"signalRequired": "使用 Signal 验证需要填写电话号码"
|
||||
"signalRequired": "使用 Signal 验证需要填写用户名"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -433,11 +431,13 @@
|
||||
"verifiedSuccess": "{channel} 验证成功",
|
||||
"messageHistory": "消息历史",
|
||||
"noMessages": "暂无消息记录",
|
||||
"discordInUseWarning": "此 Discord ID 已与另一个账户关联。",
|
||||
"discordInUseWarning": "此 Discord 用户名已与另一个账户关联。",
|
||||
"telegramInUseWarning": "此 Telegram 用户名已与另一个账户关联。",
|
||||
"signalInUseWarning": "此 Signal 号码已与另一个账户关联。",
|
||||
"signalInUseWarning": "此 Signal 用户名已与另一个账户关联。",
|
||||
"telegramStartBot": "或手动向 @{botUsername} 发送 /start {handle}",
|
||||
"telegramOpenLink": "打开 Telegram 验证",
|
||||
"discordStartBot": "在 Discord 上私信 @{botUsername} 并发送 /start {handle}",
|
||||
"discordOpenLink": "打开 Discord 验证",
|
||||
"failedToLoad": "加载偏好设置失败",
|
||||
"failedToSave": "保存偏好设置失败",
|
||||
"failedToVerify": "验证失败",
|
||||
@@ -774,9 +774,9 @@
|
||||
"externalDidRequired": "请输入您的 did:web",
|
||||
"externalDidFormat": "DID 必须以 did:web: 开头",
|
||||
"emailRequired": "使用邮箱验证需要填写邮箱地址",
|
||||
"discordRequired": "使用 Discord 验证需要填写 Discord ID",
|
||||
"discordRequired": "使用 Discord 验证需要填写 Discord 用户名",
|
||||
"telegramRequired": "使用 Telegram 验证需要填写用户名",
|
||||
"signalRequired": "使用 Signal 验证需要填写电话号码",
|
||||
"signalRequired": "使用 Signal 验证需要填写用户名",
|
||||
"passkeysNotSupported": "此浏览器不支持通行密钥。请使用其他浏览器或使用密码注册。",
|
||||
"passkeyCancelled": "通行密钥创建已取消",
|
||||
"passkeyFailed": "通行密钥注册失败"
|
||||
|
||||
@@ -123,13 +123,13 @@
|
||||
if (!info.email.trim()) return $_('registerPasskey.errors.emailRequired')
|
||||
break
|
||||
case 'discord':
|
||||
if (!info.discordId?.trim()) return $_('registerPasskey.errors.discordRequired')
|
||||
if (!info.discordUsername?.trim()) return $_('registerPasskey.errors.discordRequired')
|
||||
break
|
||||
case 'telegram':
|
||||
if (!info.telegramUsername?.trim()) return $_('registerPasskey.errors.telegramRequired')
|
||||
break
|
||||
case 'signal':
|
||||
if (!info.signalNumber?.trim()) return $_('registerPasskey.errors.signalRequired')
|
||||
if (!info.signalUsername?.trim()) return $_('registerPasskey.errors.signalRequired')
|
||||
break
|
||||
}
|
||||
return null
|
||||
@@ -389,16 +389,15 @@
|
||||
</div>
|
||||
{:else if flow.info.verificationChannel === 'discord'}
|
||||
<div class="field">
|
||||
<label for="discord-id">{$_('register.discordId')}</label>
|
||||
<label for="discord-username">{$_('register.discordUsername')}</label>
|
||||
<input
|
||||
id="discord-id"
|
||||
id="discord-username"
|
||||
type="text"
|
||||
bind:value={flow.info.discordId}
|
||||
placeholder={$_('register.discordIdPlaceholder')}
|
||||
bind:value={flow.info.discordUsername}
|
||||
placeholder={$_('register.discordUsernamePlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
<p class="hint">{$_('register.discordIdHint')}</p>
|
||||
</div>
|
||||
{:else if flow.info.verificationChannel === 'telegram'}
|
||||
<div class="field">
|
||||
@@ -414,16 +413,16 @@
|
||||
</div>
|
||||
{:else if flow.info.verificationChannel === 'signal'}
|
||||
<div class="field">
|
||||
<label for="signal-number">{$_('register.signalNumber')}</label>
|
||||
<label for="signal-number">{$_('register.signalUsername')}</label>
|
||||
<input
|
||||
id="signal-number"
|
||||
type="tel"
|
||||
bind:value={flow.info.signalNumber}
|
||||
placeholder={$_('register.signalNumberPlaceholder')}
|
||||
bind:value={flow.info.signalUsername}
|
||||
placeholder={$_('register.signalUsernamePlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
<p class="hint">{$_('register.signalNumberHint')}</p>
|
||||
<p class="hint">{$_('register.signalUsernameHint')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -30,9 +30,9 @@
|
||||
let providerEmailOriginal = $state<string | null>(null)
|
||||
let inviteCode = $state('')
|
||||
let verificationChannel = $state('email')
|
||||
let discordId = $state('')
|
||||
let discordUsername = $state('')
|
||||
let telegramUsername = $state('')
|
||||
let signalNumber = $state('')
|
||||
let signalUsername = $state('')
|
||||
|
||||
let handleAvailable = $state<boolean | null>(null)
|
||||
let checkingHandle = $state(false)
|
||||
@@ -189,11 +189,11 @@
|
||||
case 'email':
|
||||
return !!email.trim()
|
||||
case 'discord':
|
||||
return !!discordId.trim()
|
||||
return !!discordUsername.trim()
|
||||
case 'telegram':
|
||||
return !!telegramUsername.trim()
|
||||
case 'signal':
|
||||
return !!signalNumber.trim()
|
||||
return !!signalUsername.trim()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -234,9 +234,9 @@
|
||||
email: email || null,
|
||||
invite_code: inviteCode || null,
|
||||
verification_channel: verificationChannel,
|
||||
discord_id: discordId || null,
|
||||
discord_username: discordUsername || null,
|
||||
telegram_username: telegramUsername || null,
|
||||
signal_number: signalNumber || null,
|
||||
signal_username: signalUsername || null,
|
||||
did_type: didType,
|
||||
did: didType === 'web-external' ? externalDid.trim() : null,
|
||||
}),
|
||||
@@ -379,16 +379,15 @@
|
||||
</div>
|
||||
{:else if verificationChannel === 'discord'}
|
||||
<div class="field">
|
||||
<label for="discord-id">{$_('register.discordId')}</label>
|
||||
<label for="discord-username">{$_('register.discordUsername')}</label>
|
||||
<input
|
||||
id="discord-id"
|
||||
id="discord-username"
|
||||
type="text"
|
||||
bind:value={discordId}
|
||||
placeholder={$_('register.discordIdPlaceholder')}
|
||||
bind:value={discordUsername}
|
||||
placeholder={$_('register.discordUsernamePlaceholder')}
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
<p class="hint">{$_('register.discordIdHint')}</p>
|
||||
</div>
|
||||
{:else if verificationChannel === 'telegram'}
|
||||
<div class="field">
|
||||
@@ -404,16 +403,16 @@
|
||||
</div>
|
||||
{:else if verificationChannel === 'signal'}
|
||||
<div class="field">
|
||||
<label for="signal-number">{$_('register.signalNumber')}</label>
|
||||
<label for="signal-number">{$_('register.signalUsername')}</label>
|
||||
<input
|
||||
id="signal-number"
|
||||
type="tel"
|
||||
bind:value={signalNumber}
|
||||
placeholder={$_('register.signalNumberPlaceholder')}
|
||||
bind:value={signalUsername}
|
||||
placeholder={$_('register.signalUsernamePlaceholder')}
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
<p class="hint">{$_('register.signalNumberHint')}</p>
|
||||
<p class="hint">{$_('register.signalUsernameHint')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -136,13 +136,13 @@
|
||||
if (!info.email.trim()) return $_('registerPasskey.errors.emailRequired')
|
||||
break
|
||||
case 'discord':
|
||||
if (!info.discordId?.trim()) return $_('registerPasskey.errors.discordRequired')
|
||||
if (!info.discordUsername?.trim()) return $_('registerPasskey.errors.discordRequired')
|
||||
break
|
||||
case 'telegram':
|
||||
if (!info.telegramUsername?.trim()) return $_('registerPasskey.errors.telegramRequired')
|
||||
break
|
||||
case 'signal':
|
||||
if (!info.signalNumber?.trim()) return $_('registerPasskey.errors.signalRequired')
|
||||
if (!info.signalUsername?.trim()) return $_('registerPasskey.errors.signalRequired')
|
||||
break
|
||||
}
|
||||
return null
|
||||
@@ -409,16 +409,15 @@
|
||||
</div>
|
||||
{:else if flow.info.verificationChannel === 'discord'}
|
||||
<div class="field">
|
||||
<label for="discord-id">{$_('register.discordId')}</label>
|
||||
<label for="discord-username">{$_('register.discordUsername')}</label>
|
||||
<input
|
||||
id="discord-id"
|
||||
id="discord-username"
|
||||
type="text"
|
||||
bind:value={flow.info.discordId}
|
||||
placeholder={$_('register.discordIdPlaceholder')}
|
||||
bind:value={flow.info.discordUsername}
|
||||
placeholder={$_('register.discordUsernamePlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
<p class="hint">{$_('register.discordIdHint')}</p>
|
||||
</div>
|
||||
{:else if flow.info.verificationChannel === 'telegram'}
|
||||
<div class="field">
|
||||
@@ -434,16 +433,16 @@
|
||||
</div>
|
||||
{:else if flow.info.verificationChannel === 'signal'}
|
||||
<div class="field">
|
||||
<label for="signal-number">{$_('register.signalNumber')}</label>
|
||||
<label for="signal-number">{$_('register.signalUsername')}</label>
|
||||
<input
|
||||
id="signal-number"
|
||||
type="tel"
|
||||
bind:value={flow.info.signalNumber}
|
||||
placeholder={$_('register.signalNumberPlaceholder')}
|
||||
bind:value={flow.info.signalUsername}
|
||||
placeholder={$_('register.signalUsernamePlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
<p class="hint">{$_('register.signalNumberHint')}</p>
|
||||
<p class="hint">{$_('register.signalUsernameHint')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -133,13 +133,13 @@
|
||||
if (!info.email.trim()) return $_('register.validation.emailRequired')
|
||||
break
|
||||
case 'discord':
|
||||
if (!info.discordId?.trim()) return $_('register.validation.discordIdRequired')
|
||||
if (!info.discordUsername?.trim()) return $_('register.validation.discordUsernameRequired')
|
||||
break
|
||||
case 'telegram':
|
||||
if (!info.telegramUsername?.trim()) return $_('register.validation.telegramRequired')
|
||||
break
|
||||
case 'signal':
|
||||
if (!info.signalNumber?.trim()) return $_('register.validation.signalRequired')
|
||||
if (!info.signalUsername?.trim()) return $_('register.validation.signalRequired')
|
||||
break
|
||||
}
|
||||
return null
|
||||
@@ -381,17 +381,16 @@
|
||||
</div>
|
||||
{:else if flow.info.verificationChannel === 'discord'}
|
||||
<div class="field">
|
||||
<label for="discord-id">{$_('register.discordId')}</label>
|
||||
<label for="discord-username">{$_('register.discordUsername')}</label>
|
||||
<input
|
||||
id="discord-id"
|
||||
id="discord-username"
|
||||
type="text"
|
||||
bind:value={flow.info.discordId}
|
||||
onblur={() => flow?.checkCommsChannelInUse('discord', flow.info.discordId ?? '')}
|
||||
placeholder={$_('register.discordIdPlaceholder')}
|
||||
bind:value={flow.info.discordUsername}
|
||||
onblur={() => flow?.checkCommsChannelInUse('discord', flow.info.discordUsername ?? '')}
|
||||
placeholder={$_('register.discordUsernamePlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
<p class="hint">{$_('register.discordIdHint')}</p>
|
||||
{#if flow.state.discordInUse}
|
||||
<p class="hint warning">{$_('register.discordInUseWarning')}</p>
|
||||
{/if}
|
||||
@@ -414,17 +413,17 @@
|
||||
</div>
|
||||
{:else if flow.info.verificationChannel === 'signal'}
|
||||
<div class="field">
|
||||
<label for="signal-number">{$_('register.signalNumber')}</label>
|
||||
<label for="signal-number">{$_('register.signalUsername')}</label>
|
||||
<input
|
||||
id="signal-number"
|
||||
type="tel"
|
||||
bind:value={flow.info.signalNumber}
|
||||
onblur={() => flow?.checkCommsChannelInUse('signal', flow.info.signalNumber ?? '')}
|
||||
placeholder={$_('register.signalNumberPlaceholder')}
|
||||
bind:value={flow.info.signalUsername}
|
||||
onblur={() => flow?.checkCommsChannelInUse('signal', flow.info.signalUsername ?? '')}
|
||||
placeholder={$_('register.signalUsernamePlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
<p class="hint">{$_('register.signalNumberHint')}</p>
|
||||
<p class="hint">{$_('register.signalUsernameHint')}</p>
|
||||
{#if flow.state.signalInUse}
|
||||
<p class="hint warning">{$_('register.signalInUseWarning')}</p>
|
||||
{/if}
|
||||
|
||||
@@ -40,9 +40,9 @@
|
||||
let providerEmailOriginal = $state<string | null>(null)
|
||||
let inviteCode = $state('')
|
||||
let verificationChannel = $state('email')
|
||||
let discordId = $state('')
|
||||
let discordUsername = $state('')
|
||||
let telegramUsername = $state('')
|
||||
let signalNumber = $state('')
|
||||
let signalUsername = $state('')
|
||||
|
||||
let handleAvailable = $state<boolean | null>(null)
|
||||
let checkingHandle = $state(false)
|
||||
@@ -204,11 +204,11 @@
|
||||
case 'email':
|
||||
return !!email.trim()
|
||||
case 'discord':
|
||||
return !!discordId.trim()
|
||||
return !!discordUsername.trim()
|
||||
case 'telegram':
|
||||
return !!telegramUsername.trim()
|
||||
case 'signal':
|
||||
return !!signalNumber.trim()
|
||||
return !!signalUsername.trim()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -281,9 +281,9 @@
|
||||
email: email || null,
|
||||
invite_code: inviteCode || null,
|
||||
verification_channel: verificationChannel,
|
||||
discord_id: discordId || null,
|
||||
discord_username: discordUsername || null,
|
||||
telegram_username: telegramUsername || null,
|
||||
signal_number: signalNumber || null,
|
||||
signal_username: signalUsername || null,
|
||||
did_type: didType,
|
||||
did: didType === 'web-external' ? externalDid.trim() : null,
|
||||
}),
|
||||
@@ -452,16 +452,15 @@
|
||||
</div>
|
||||
{:else if verificationChannel === 'discord'}
|
||||
<div class="field">
|
||||
<label for="discord-id">{$_('register.discordId')}</label>
|
||||
<label for="discord-username">{$_('register.discordUsername')}</label>
|
||||
<input
|
||||
id="discord-id"
|
||||
id="discord-username"
|
||||
type="text"
|
||||
bind:value={discordId}
|
||||
placeholder={$_('register.discordIdPlaceholder')}
|
||||
bind:value={discordUsername}
|
||||
placeholder={$_('register.discordUsernamePlaceholder')}
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
<p class="hint">{$_('register.discordIdHint')}</p>
|
||||
</div>
|
||||
{:else if verificationChannel === 'telegram'}
|
||||
<div class="field">
|
||||
@@ -477,16 +476,16 @@
|
||||
</div>
|
||||
{:else if verificationChannel === 'signal'}
|
||||
<div class="field">
|
||||
<label for="signal-number">{$_('register.signalNumber')}</label>
|
||||
<label for="signal-number">{$_('register.signalUsername')}</label>
|
||||
<input
|
||||
id="signal-number"
|
||||
type="tel"
|
||||
bind:value={signalNumber}
|
||||
placeholder={$_('register.signalNumberPlaceholder')}
|
||||
bind:value={signalUsername}
|
||||
placeholder={$_('register.signalUsernamePlaceholder')}
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
<p class="hint">{$_('register.signalNumberHint')}</p>
|
||||
<p class="hint">{$_('register.signalUsernameHint')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
let tokenFromUrl = $state(false)
|
||||
let oauthRequestUri = $state<string | null>(null)
|
||||
let telegramBotUsername = $state<string | undefined>(undefined)
|
||||
let discordBotUsername = $state<string | undefined>(undefined)
|
||||
let discordAppId = $state<string | undefined>(undefined)
|
||||
|
||||
const auth = $derived(getAuthState())
|
||||
|
||||
@@ -42,6 +44,8 @@
|
||||
|
||||
const session = $derived(getSession())
|
||||
const isTelegram = $derived(pendingVerification?.channel === 'telegram')
|
||||
const isDiscord = $derived(pendingVerification?.channel === 'discord')
|
||||
const isBotVerified = $derived(isTelegram || isDiscord)
|
||||
|
||||
function parseQueryParams(): Record<string, string> {
|
||||
return Object.fromEntries(new URLSearchParams(window.location.search))
|
||||
@@ -102,10 +106,12 @@
|
||||
}))
|
||||
}
|
||||
|
||||
if (pendingVerification?.channel === 'telegram') {
|
||||
if (pendingVerification?.channel === 'telegram' || pendingVerification?.channel === 'discord') {
|
||||
try {
|
||||
const serverInfo = await api.describeServer()
|
||||
telegramBotUsername = serverInfo.telegramBotUsername
|
||||
discordBotUsername = serverInfo.discordBotUsername
|
||||
discordAppId = serverInfo.discordAppId
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
@@ -121,10 +127,10 @@
|
||||
|
||||
let pollingVerification = false
|
||||
$effect(() => {
|
||||
if (mode === 'signup' && pendingVerification && (isTelegram || !verificationCode.trim())) {
|
||||
if (mode === 'signup' && pendingVerification && (isBotVerified || !verificationCode.trim())) {
|
||||
const currentPending = pendingVerification
|
||||
const interval = setInterval(async () => {
|
||||
if (pollingVerification || (!isTelegram && verificationCode.trim())) return
|
||||
if (pollingVerification || (!isBotVerified && verificationCode.trim())) return
|
||||
pollingVerification = true
|
||||
try {
|
||||
const result = await api.checkChannelVerified(currentPending.did, currentPending.channel)
|
||||
@@ -447,7 +453,7 @@
|
||||
|
||||
{#if isTelegram && telegramBotUsername}
|
||||
{@const encodedHandle = pendingVerification.handle.replaceAll('.', '_')}
|
||||
<div class="telegram-hint">
|
||||
<div class="bot-hint">
|
||||
<p>
|
||||
<a href="https://t.me/{telegramBotUsername}?start={encodedHandle}" target="_blank" rel="noopener">{$_('comms.telegramOpenLink')}</a>
|
||||
</p>
|
||||
@@ -456,6 +462,16 @@
|
||||
</p>
|
||||
<p class="waiting-text">{$_('verify.pleaseWait')}</p>
|
||||
</div>
|
||||
{:else if isDiscord && discordAppId}
|
||||
<div class="bot-hint">
|
||||
<p>
|
||||
<a href="https://discord.com/users/{discordAppId}" target="_blank" rel="noopener">{$_('comms.discordOpenLink')}</a>
|
||||
</p>
|
||||
<p class="manual-text">
|
||||
{$_('comms.discordStartBot', { values: { botUsername: discordBotUsername ?? 'the bot', handle: pendingVerification.handle } })}
|
||||
</p>
|
||||
<p class="waiting-text">{$_('verify.pleaseWait')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSignupVerification(e); }}>
|
||||
<div class="field">
|
||||
@@ -610,23 +626,23 @@
|
||||
padding: var(--space-4) var(--space-8);
|
||||
}
|
||||
|
||||
.telegram-hint {
|
||||
.bot-hint {
|
||||
padding: var(--space-4);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.telegram-hint p {
|
||||
.bot-hint p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.telegram-hint .manual-text {
|
||||
.bot-hint .manual-text {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.telegram-hint .waiting-text {
|
||||
.bot-hint .waiting-text {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
margin-top: var(--space-2);
|
||||
|
||||
@@ -254,11 +254,11 @@ export const mockData = {
|
||||
notificationPrefs: (overrides?: Record<string, unknown>) => ({
|
||||
preferredChannel: "email",
|
||||
email: "test@example.com",
|
||||
discordId: null,
|
||||
discordUsername: null,
|
||||
discordVerified: false,
|
||||
telegramUsername: null,
|
||||
telegramVerified: false,
|
||||
signalNumber: null,
|
||||
signalUsername: null,
|
||||
signalVerified: false,
|
||||
...overrides,
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE users ADD COLUMN discord_username TEXT;
|
||||
|
||||
UPDATE users SET discord_id = NULL, discord_verified = FALSE WHERE discord_id IS NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE users RENAME COLUMN signal_number TO signal_username;
|
||||
Reference in New Issue
Block a user