mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-20 08:16:04 +00:00
fix: telegram comms ux improvements
This commit is contained in:
+2
-1
@@ -96,7 +96,8 @@ BACKUP_STORAGE_PATH=/var/lib/tranquil/backups
|
||||
# Discord notifications (via webhook)
|
||||
# DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
|
||||
# Telegram notifications (via bot)
|
||||
# TELEGRAM_BOT_TOKEN=your-bot-token
|
||||
# TELEGRAM_BOT_TOKEN=bot-token
|
||||
# TELEGRAM_WEBHOOK_SECRET=random-secret
|
||||
# Signal notifications (via signal-cli)
|
||||
# SIGNAL_CLI_PATH=/usr/local/bin/signal-cli
|
||||
# SIGNAL_SENDER_NUMBER=+1234567890
|
||||
|
||||
+2
-1
@@ -44,7 +44,8 @@
|
||||
"channel_verification",
|
||||
"passkey_recovery",
|
||||
"legacy_login_alert",
|
||||
"migration_verification"
|
||||
"migration_verification",
|
||||
"channel_verified"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -32,7 +32,8 @@
|
||||
"channel_verification",
|
||||
"passkey_recovery",
|
||||
"legacy_login_alert",
|
||||
"migration_verification"
|
||||
"migration_verification",
|
||||
"channel_verified"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+20
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT email, handle, preferred_comms_channel as \"preferred_channel!: CommsChannel\", preferred_locale\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_number\n FROM users WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -34,6 +34,21 @@
|
||||
"ordinal": 3,
|
||||
"name": "preferred_locale",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "telegram_chat_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "discord_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "signal_number",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -45,8 +60,11 @@
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "d8fd97c8be3211b2509669dd859245b14e15f81a42d7e0c4c428b65f466af5ee"
|
||||
"hash": "63c2a9079c147be6d04bf02c63ea7a3d0b0db3f35438380c0fe7e4c60b420c67"
|
||||
}
|
||||
+2
-1
@@ -49,7 +49,8 @@
|
||||
"channel_verification",
|
||||
"passkey_recovery",
|
||||
"legacy_login_alert",
|
||||
"migration_verification"
|
||||
"migration_verification",
|
||||
"channel_verified"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE users SET telegram_chat_id = $2, telegram_verified = TRUE, updated_at = NOW()\n WHERE id = (\n SELECT id FROM users\n WHERE LOWER(telegram_username) = LOWER($1) AND telegram_username IS NOT NULL AND deactivated_at IS NULL\n LIMIT 1\n ) RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "9d17e25776c67f96022010840c1d04bdd542b5bcc511b1778bab0159a4566e9c"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT telegram_chat_id FROM users WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "telegram_chat_id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "a71a76724a3a7406e30a998c03d52554efaf649bb10b35b6e5d64ac59a479023"
|
||||
}
|
||||
+9
-3
@@ -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 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_verified,\n telegram_username,\n telegram_verified,\n telegram_chat_id,\n signal_number,\n signal_verified\n FROM users WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -47,11 +47,16 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "telegram_chat_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "signal_number",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"ordinal": 8,
|
||||
"name": "signal_verified",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
@@ -69,8 +74,9 @@
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "247470d26a90617e7dc9b5b3a2146ee3f54448e3c24943f7005e3a8e28820d43"
|
||||
"hash": "c48c9af71d8dab70ea2f11df30d2cc4976732a47430bd471f5aa47afc16e5740"
|
||||
}
|
||||
+2
-1
@@ -41,7 +41,8 @@
|
||||
"channel_verification",
|
||||
"passkey_recovery",
|
||||
"legacy_login_alert",
|
||||
"migration_verification"
|
||||
"migration_verification",
|
||||
"channel_verified"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE users SET\n telegram_username = $1,\n telegram_verified = CASE WHEN LOWER(telegram_username) = LOWER($1) THEN telegram_verified ELSE FALSE END,\n telegram_chat_id = CASE WHEN LOWER(telegram_username) = LOWER($1) THEN telegram_chat_id ELSE NULL END,\n updated_at = NOW()\n WHERE id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d7f32a31b4edeebbbf54a1878dfa05f2fcb3c57fe063be4e6a78fe5e74fb9dc3"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE users SET telegram_username = NULL, telegram_verified = FALSE, updated_at = NOW() WHERE id = $1",
|
||||
"query": "UPDATE users SET telegram_username = NULL, telegram_verified = FALSE, telegram_chat_id = NULL, updated_at = NOW() WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -10,5 +10,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c7ebbeca2ba26ef7b5a7c00441f51f68eb47f1421fa0a937eaa5a79aec75001d"
|
||||
"hash": "e49cbb17eb279cd12874fc3e7f5cbdbf0072c42127e225b79c0fe90de78670bd"
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE users SET telegram_chat_id = $2, telegram_verified = TRUE, updated_at = NOW() WHERE LOWER(telegram_username) = LOWER($1) AND telegram_username IS NOT NULL AND handle = $3 RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "fc1ef8c0979206bf95cccee7c39614f7c882860c39cfe94e411cf6e1d55b63f4"
|
||||
}
|
||||
@@ -31,6 +31,8 @@ pub struct NotificationStrings {
|
||||
pub legacy_login_body: &'static str,
|
||||
pub migration_verification_subject: &'static str,
|
||||
pub migration_verification_body: &'static str,
|
||||
pub channel_verified_subject: &'static str,
|
||||
pub channel_verified_body: &'static str,
|
||||
}
|
||||
|
||||
pub fn get_strings(locale: &str) -> &'static NotificationStrings {
|
||||
@@ -66,6 +68,8 @@ static STRINGS_EN: NotificationStrings = NotificationStrings {
|
||||
legacy_login_body: "Hello @{handle},\n\nA login to your account was detected using a legacy app (like Bluesky) that doesn't support TOTP verification.\n\nDetails:\n- Time: {timestamp}\n- IP Address: {ip}\n\nYour TOTP protection was bypassed for this login. The session has limited permissions for sensitive operations.\n\nIf this wasn't you, please:\n1. Change your password immediately\n2. Review your active sessions\n3. Consider disabling legacy app logins in your security settings\n\nStay safe,\n{hostname}",
|
||||
migration_verification_subject: "Verify your email - {hostname}",
|
||||
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}.",
|
||||
};
|
||||
|
||||
static STRINGS_ZH: NotificationStrings = NotificationStrings {
|
||||
@@ -90,6 +94,8 @@ static STRINGS_ZH: NotificationStrings = NotificationStrings {
|
||||
legacy_login_body: "您好 @{handle},\n\n检测到使用不支持 TOTP 验证的传统应用(如 Bluesky)登录您的账户。\n\n详细信息:\n- 时间:{timestamp}\n- IP 地址:{ip}\n\n此次登录绕过了 TOTP 保护。该会话对敏感操作的权限有限。\n\n如果这不是您的操作,请:\n1. 立即更改密码\n2. 检查您的活跃会话\n3. 考虑在安全设置中禁用传统应用登录\n\n请注意安全,\n{hostname}",
|
||||
migration_verification_subject: "验证您的邮箱 - {hostname}",
|
||||
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} 上的通知渠道。",
|
||||
};
|
||||
|
||||
static STRINGS_JA: NotificationStrings = NotificationStrings {
|
||||
@@ -114,6 +120,8 @@ static STRINGS_JA: NotificationStrings = NotificationStrings {
|
||||
legacy_login_body: "@{handle} 様\n\nTOTP 認証に対応していないレガシーアプリ(Bluesky など)からのログインが検出されました。\n\n詳細:\n- 時刻:{timestamp}\n- IP アドレス:{ip}\n\nこのログインでは TOTP 保護がバイパスされました。このセッションは機密操作に対する権限が制限されています。\n\n心当たりがない場合は:\n1. 直ちにパスワードを変更してください\n2. アクティブなセッションを確認してください\n3. セキュリティ設定でレガシーアプリのログインを無効にすることを検討してください\n\nご注意ください。\n{hostname}",
|
||||
migration_verification_subject: "メールアドレスの認証 - {hostname}",
|
||||
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} の通知チャンネルとして認証されました。",
|
||||
};
|
||||
|
||||
static STRINGS_KO: NotificationStrings = NotificationStrings {
|
||||
@@ -138,6 +146,8 @@ static STRINGS_KO: NotificationStrings = NotificationStrings {
|
||||
legacy_login_body: "안녕하세요 @{handle}님,\n\nTOTP 인증을 지원하지 않는 레거시 앱(예: Bluesky)을 사용한 로그인이 감지되었습니다.\n\n세부 정보:\n- 시간: {timestamp}\n- IP 주소: {ip}\n\n이 로그인에서 TOTP 보호가 우회되었습니다. 이 세션은 민감한 작업에 대한 권한이 제한됩니다.\n\n본인이 아닌 경우:\n1. 즉시 비밀번호를 변경하세요\n2. 활성 세션을 검토하세요\n3. 보안 설정에서 레거시 앱 로그인 비활성화를 고려하세요\n\n{hostname} 드림",
|
||||
migration_verification_subject: "이메일 인증 - {hostname}",
|
||||
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}의 알림 채널로 인증되었습니다.",
|
||||
};
|
||||
|
||||
static STRINGS_SV: NotificationStrings = NotificationStrings {
|
||||
@@ -162,6 +172,8 @@ static STRINGS_SV: NotificationStrings = NotificationStrings {
|
||||
legacy_login_body: "Hej @{handle},\n\nEn inloggning till ditt konto upptäcktes med en äldre app (som Bluesky) som inte stöder TOTP-verifiering.\n\nDetaljer:\n- Tid: {timestamp}\n- IP-adress: {ip}\n\nDitt TOTP-skydd kringgicks för denna inloggning. Sessionen har begränsade behörigheter för känsliga operationer.\n\nOm detta inte var du:\n1. Ändra ditt lösenord omedelbart\n2. Granska dina aktiva sessioner\n3. Överväg att inaktivera äldre appinloggningar i dina säkerhetsinställningar\n\nVar försiktig,\n{hostname}",
|
||||
migration_verification_subject: "Verifiera din e-post - {hostname}",
|
||||
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}.",
|
||||
};
|
||||
|
||||
static STRINGS_FI: NotificationStrings = NotificationStrings {
|
||||
@@ -186,6 +198,8 @@ static STRINGS_FI: NotificationStrings = NotificationStrings {
|
||||
legacy_login_body: "Hei @{handle},\n\nTilillesi havaittiin kirjautuminen vanhalla sovelluksella (kuten Bluesky), joka ei tue TOTP-vahvistusta.\n\nTiedot:\n- Aika: {timestamp}\n- IP-osoite: {ip}\n\nTOTP-suojauksesi ohitettiin tässä kirjautumisessa. Istunnolla on rajoitetut oikeudet arkaluontoisiin toimintoihin.\n\nJos tämä et ollut sinä:\n1. Vaihda salasanasi välittömästi\n2. Tarkista aktiiviset istuntosi\n3. Harkitse vanhojen sovellusten kirjautumisen poistamista käytöstä turvallisuusasetuksissa\n\nOle varovainen,\n{hostname}",
|
||||
migration_verification_subject: "Vahvista sähköpostisi - {hostname}",
|
||||
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}.",
|
||||
};
|
||||
|
||||
pub fn format_message(template: &str, vars: &[(&str, &str)]) -> String {
|
||||
|
||||
@@ -67,6 +67,12 @@ pub fn mime_encode_header(value: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn escape_html(text: &str) -> String {
|
||||
text.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
pub fn is_valid_phone_number(number: &str) -> bool {
|
||||
if number.len() < 2 || number.len() > 20 {
|
||||
return false;
|
||||
@@ -244,6 +250,60 @@ impl TelegramSender {
|
||||
let bot_token = std::env::var("TELEGRAM_BOT_TOKEN").ok()?;
|
||||
Some(Self::new(bot_token))
|
||||
}
|
||||
|
||||
pub async fn set_webhook(
|
||||
&self,
|
||||
webhook_url: &str,
|
||||
secret_token: Option<&str>,
|
||||
) -> Result<(), SendError> {
|
||||
let url = format!("https://api.telegram.org/bot{}/setWebhook", self.bot_token);
|
||||
let mut payload = json!({ "url": webhook_url });
|
||||
if let Some(secret) = secret_token {
|
||||
payload["secret_token"] = json!(secret);
|
||||
}
|
||||
let response = self
|
||||
.http_client
|
||||
.post(&url)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SendError::ExternalService(format!("setWebhook request failed: {}", e)))?;
|
||||
if !response.status().is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(SendError::ExternalService(format!(
|
||||
"setWebhook returned error: {}",
|
||||
body
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn resolve_bot_username(&self) -> Result<String, SendError> {
|
||||
let url = format!("https://api.telegram.org/bot{}/getMe", self.bot_token);
|
||||
let response = self.http_client.get(&url).send().await.map_err(|e| {
|
||||
SendError::ExternalService(format!("Telegram getMe request failed: {}", e))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(SendError::ExternalService(format!(
|
||||
"Telegram getMe returned error: {}",
|
||||
body
|
||||
)));
|
||||
}
|
||||
|
||||
let data: serde_json::Value = response.json().await.map_err(|e| {
|
||||
SendError::ExternalService(format!("Failed to parse getMe response: {}", e))
|
||||
})?;
|
||||
|
||||
data.get("result")
|
||||
.and_then(|r| r.get("username"))
|
||||
.and_then(|u| u.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| {
|
||||
SendError::ExternalService("getMe response missing username".to_string())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -254,13 +314,14 @@ impl CommsSender for TelegramSender {
|
||||
|
||||
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
|
||||
let chat_id = ¬ification.recipient;
|
||||
let subject = notification.subject.as_deref().unwrap_or("Notification");
|
||||
let text = format!("*{}*\n\n{}", subject, notification.body);
|
||||
let subject = escape_html(notification.subject.as_deref().unwrap_or("Notification"));
|
||||
let body = escape_html(¬ification.body);
|
||||
let text = format!("<b>{}</b>\n\n{}", subject, body);
|
||||
let url = format!("https://api.telegram.org/bot{}/sendMessage", self.bot_token);
|
||||
let payload = json!({
|
||||
"chat_id": chat_id,
|
||||
"text": text,
|
||||
"parse_mode": "Markdown"
|
||||
"parse_mode": "HTML"
|
||||
});
|
||||
let mut last_error = None;
|
||||
for attempt in 0..MAX_RETRIES {
|
||||
|
||||
@@ -78,6 +78,7 @@ pub enum CommsType {
|
||||
LegacyLoginAlert,
|
||||
MigrationVerification,
|
||||
ChannelVerification,
|
||||
ChannelVerified,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
|
||||
|
||||
@@ -196,6 +196,12 @@ pub trait UserRepository: Send + Sync {
|
||||
identifier: &str,
|
||||
) -> Result<Option<bool>, DbError>;
|
||||
|
||||
async fn check_channel_verified_by_did(
|
||||
&self,
|
||||
did: &Did,
|
||||
channel: CommsChannel,
|
||||
) -> Result<Option<bool>, DbError>;
|
||||
|
||||
async fn admin_update_email(&self, did: &Did, email: &str) -> Result<u64, DbError>;
|
||||
|
||||
async fn admin_update_handle(&self, did: &Did, handle: &Handle) -> Result<u64, DbError>;
|
||||
@@ -222,6 +228,21 @@ pub trait UserRepository: Send + Sync {
|
||||
|
||||
async fn clear_signal(&self, user_id: Uuid) -> Result<(), DbError>;
|
||||
|
||||
async fn set_unverified_telegram(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
telegram_username: &str,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
async fn store_telegram_chat_id(
|
||||
&self,
|
||||
telegram_username: &str,
|
||||
chat_id: i64,
|
||||
handle: Option<&str>,
|
||||
) -> Result<Option<Uuid>, DbError>;
|
||||
|
||||
async fn get_telegram_chat_id(&self, user_id: Uuid) -> Result<Option<i64>, DbError>;
|
||||
|
||||
async fn get_verification_info(
|
||||
&self,
|
||||
did: &Did,
|
||||
@@ -575,6 +596,9 @@ pub struct UserCommsPrefs {
|
||||
pub handle: Handle,
|
||||
pub preferred_channel: CommsChannel,
|
||||
pub preferred_locale: Option<String>,
|
||||
pub telegram_chat_id: Option<i64>,
|
||||
pub discord_id: Option<String>,
|
||||
pub signal_number: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -635,6 +659,7 @@ pub struct NotificationPrefs {
|
||||
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_verified: bool,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
r#"SELECT email, handle, preferred_comms_channel as "preferred_channel!: CommsChannel", preferred_locale, telegram_chat_id, discord_id, signal_number
|
||||
FROM users WHERE id = $1"#,
|
||||
user_id
|
||||
)
|
||||
@@ -323,6 +323,9 @@ impl UserRepository for PostgresUserRepository {
|
||||
handle: Handle::from(r.handle),
|
||||
preferred_channel: r.preferred_channel,
|
||||
preferred_locale: r.preferred_locale,
|
||||
telegram_chat_id: r.telegram_chat_id,
|
||||
discord_id: r.discord_id,
|
||||
signal_number: r.signal_number,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -561,6 +564,33 @@ impl UserRepository for PostgresUserRepository {
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
async fn check_channel_verified_by_did(
|
||||
&self,
|
||||
did: &Did,
|
||||
channel: CommsChannel,
|
||||
) -> Result<Option<bool>, DbError> {
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT
|
||||
email_verified,
|
||||
discord_verified,
|
||||
telegram_verified,
|
||||
signal_verified
|
||||
FROM users
|
||||
WHERE did = $1"#,
|
||||
did.as_str()
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| match channel {
|
||||
CommsChannel::Email => r.email_verified,
|
||||
CommsChannel::Discord => r.discord_verified,
|
||||
CommsChannel::Telegram => r.telegram_verified,
|
||||
CommsChannel::Signal => r.signal_verified,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn admin_update_email(&self, did: &Did, email: &str) -> Result<u64, DbError> {
|
||||
let result = sqlx::query!(
|
||||
"UPDATE users SET email = $1 WHERE did = $2",
|
||||
@@ -609,6 +639,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
discord_verified,
|
||||
telegram_username,
|
||||
telegram_verified,
|
||||
telegram_chat_id,
|
||||
signal_number,
|
||||
signal_verified
|
||||
FROM users WHERE did = $1"#,
|
||||
@@ -624,6 +655,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
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_verified: r.signal_verified,
|
||||
}))
|
||||
@@ -676,7 +708,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
|
||||
async fn clear_telegram(&self, user_id: Uuid) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
"UPDATE users SET telegram_username = NULL, telegram_verified = FALSE, updated_at = NOW() WHERE id = $1",
|
||||
"UPDATE users SET telegram_username = NULL, telegram_verified = FALSE, telegram_chat_id = NULL, updated_at = NOW() WHERE id = $1",
|
||||
user_id
|
||||
)
|
||||
.execute(&self.pool)
|
||||
@@ -3136,4 +3168,66 @@ impl UserRepository for PostgresUserRepository {
|
||||
passkeys_deleted: deleted.rows_affected(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn set_unverified_telegram(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
telegram_username: &str,
|
||||
) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
r#"UPDATE users SET
|
||||
telegram_username = $1,
|
||||
telegram_verified = CASE WHEN LOWER(telegram_username) = LOWER($1) THEN telegram_verified ELSE FALSE END,
|
||||
telegram_chat_id = CASE WHEN LOWER(telegram_username) = LOWER($1) THEN telegram_chat_id ELSE NULL END,
|
||||
updated_at = NOW()
|
||||
WHERE id = $2"#,
|
||||
telegram_username,
|
||||
user_id
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn store_telegram_chat_id(
|
||||
&self,
|
||||
telegram_username: &str,
|
||||
chat_id: i64,
|
||||
handle: Option<&str>,
|
||||
) -> Result<Option<Uuid>, DbError> {
|
||||
let result = match handle {
|
||||
Some(h) => sqlx::query_scalar!(
|
||||
"UPDATE users SET telegram_chat_id = $2, telegram_verified = TRUE, updated_at = NOW() WHERE LOWER(telegram_username) = LOWER($1) AND telegram_username IS NOT NULL AND handle = $3 RETURNING id",
|
||||
telegram_username,
|
||||
chat_id,
|
||||
h
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?,
|
||||
None => sqlx::query_scalar!(
|
||||
r#"UPDATE users SET telegram_chat_id = $2, telegram_verified = TRUE, updated_at = NOW()
|
||||
WHERE id = (
|
||||
SELECT id FROM users
|
||||
WHERE LOWER(telegram_username) = LOWER($1) AND telegram_username IS NOT NULL AND deactivated_at IS NULL
|
||||
LIMIT 1
|
||||
) RETURNING id"#,
|
||||
telegram_username,
|
||||
chat_id
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?,
|
||||
};
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn get_telegram_chat_id(&self, user_id: Uuid) -> Result<Option<i64>, DbError> {
|
||||
let row = sqlx::query_scalar!("SELECT telegram_chat_id FROM users WHERE id = $1", user_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(row.flatten())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,7 +208,15 @@ pub async fn create_account(
|
||||
_ => return ApiError::MissingDiscordId.into_response(),
|
||||
},
|
||||
"telegram" => match &input.telegram_username {
|
||||
Some(username) if !username.trim().is_empty() => username.trim().to_string(),
|
||||
Some(username) if !username.trim().is_empty() => {
|
||||
let clean = username.trim().trim_start_matches('@');
|
||||
if !crate::api::validation::is_valid_telegram_username(clean) {
|
||||
return ApiError::InvalidRequest(
|
||||
"Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(),
|
||||
).into_response();
|
||||
}
|
||||
clean.to_string()
|
||||
}
|
||||
_ => return ApiError::MissingTelegramUsername.into_response(),
|
||||
},
|
||||
"signal" => match &input.signal_number {
|
||||
@@ -634,7 +642,7 @@ pub async fn create_account(
|
||||
telegram_username: input
|
||||
.telegram_username
|
||||
.as_deref()
|
||||
.map(|s| s.trim())
|
||||
.map(|s| s.trim().trim_start_matches('@'))
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from),
|
||||
signal_number: input
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod proxy_client;
|
||||
pub mod repo;
|
||||
pub mod responses;
|
||||
pub mod server;
|
||||
pub mod telegram_webhook;
|
||||
pub mod temp;
|
||||
pub mod validation;
|
||||
pub mod verification;
|
||||
|
||||
@@ -165,15 +165,37 @@ pub async fn request_channel_verification(
|
||||
"signal" => tranquil_db_traits::CommsChannel::Signal,
|
||||
_ => return Err("Invalid channel".to_string()),
|
||||
};
|
||||
let hostname = pds_hostname();
|
||||
let encoded_token = urlencoding::encode(&formatted_token);
|
||||
let encoded_identifier = urlencoding::encode(identifier);
|
||||
let verify_link = format!(
|
||||
"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 recipient = match comms_channel {
|
||||
tranquil_db_traits::CommsChannel::Telegram => state
|
||||
.user_repo
|
||||
.get_telegram_chat_id(user_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|id| id.to_string())
|
||||
.unwrap_or_else(|| identifier.to_string()),
|
||||
_ => identifier.to_string(),
|
||||
};
|
||||
state
|
||||
.infra_repo
|
||||
.enqueue_comms(
|
||||
Some(user_id),
|
||||
comms_channel,
|
||||
tranquil_db_traits::CommsType::ChannelVerification,
|
||||
identifier,
|
||||
&recipient,
|
||||
Some("Verify your channel"),
|
||||
&format!("Your verification code is: {}", formatted_token),
|
||||
&body,
|
||||
Some(json!({"code": formatted_token})),
|
||||
)
|
||||
.await
|
||||
@@ -199,6 +221,28 @@ pub async fn update_notification_prefs(
|
||||
let handle = user_row.handle;
|
||||
let current_email = user_row.email;
|
||||
|
||||
let current_prefs = state
|
||||
.user_repo
|
||||
.get_notification_prefs(&auth.did)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
|
||||
let effective_channel = input
|
||||
.preferred_channel
|
||||
.as_deref()
|
||||
.map(|ch| match ch {
|
||||
"email" => Ok(CommsChannel::Email),
|
||||
"discord" => Ok(CommsChannel::Discord),
|
||||
"telegram" => Ok(CommsChannel::Telegram),
|
||||
"signal" => Ok(CommsChannel::Signal),
|
||||
_ => Err(ApiError::InvalidRequest(
|
||||
"Invalid channel. Must be one of: email, discord, telegram, signal".into(),
|
||||
)),
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or(current_prefs.preferred_channel);
|
||||
|
||||
let mut verification_required: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(ref channel_str) = input.preferred_channel {
|
||||
@@ -249,6 +293,11 @@ pub async fn update_notification_prefs(
|
||||
|
||||
if let Some(ref discord_id) = input.discord_id {
|
||||
if discord_id.is_empty() {
|
||||
if effective_channel == CommsChannel::Discord {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Cannot remove Discord while it is the preferred notification channel".into(),
|
||||
));
|
||||
}
|
||||
state
|
||||
.user_repo
|
||||
.clear_discord(user_id)
|
||||
@@ -267,30 +316,40 @@ pub async fn update_notification_prefs(
|
||||
if let Some(ref telegram) = input.telegram_username {
|
||||
let telegram_clean = telegram.trim_start_matches('@');
|
||||
if telegram_clean.is_empty() {
|
||||
if effective_channel == CommsChannel::Telegram {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Cannot remove Telegram while it is the preferred notification channel".into(),
|
||||
));
|
||||
}
|
||||
state
|
||||
.user_repo
|
||||
.clear_telegram(user_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
|
||||
info!(did = %auth.did, "Cleared Telegram username");
|
||||
} else if !crate::api::validation::is_valid_telegram_username(telegram_clean) {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore"
|
||||
.into(),
|
||||
));
|
||||
} else {
|
||||
request_channel_verification(
|
||||
&state,
|
||||
user_id,
|
||||
&auth.did,
|
||||
"telegram",
|
||||
telegram_clean,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalError(Some(e)))?;
|
||||
state
|
||||
.user_repo
|
||||
.set_unverified_telegram(user_id, telegram_clean)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
|
||||
verification_required.push("telegram".to_string());
|
||||
info!(did = %auth.did, "Requested Telegram verification");
|
||||
info!(did = %auth.did, telegram_username = %telegram_clean, "Stored unverified Telegram username");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref signal) = input.signal_number {
|
||||
if signal.is_empty() {
|
||||
if effective_channel == CommsChannel::Signal {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Cannot remove Signal while it is the preferred notification channel".into(),
|
||||
));
|
||||
}
|
||||
state
|
||||
.user_repo
|
||||
.clear_signal(user_id)
|
||||
|
||||
@@ -419,6 +419,45 @@ pub async fn check_email_verified(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CheckChannelVerifiedInput {
|
||||
pub did: String,
|
||||
pub channel: String,
|
||||
}
|
||||
|
||||
pub async fn check_channel_verified(
|
||||
State(state): State<AppState>,
|
||||
_rate_limit: RateLimited<VerificationCheckLimit>,
|
||||
Json(input): Json<CheckChannelVerifiedInput>,
|
||||
) -> Response {
|
||||
let channel = match input.channel.to_lowercase().as_str() {
|
||||
"email" => CommsChannel::Email,
|
||||
"discord" => CommsChannel::Discord,
|
||||
"telegram" => CommsChannel::Telegram,
|
||||
"signal" => CommsChannel::Signal,
|
||||
_ => {
|
||||
return ApiError::InvalidRequest("invalid channel".into()).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let did = match crate::Did::new(input.did) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return ApiError::InvalidRequest("invalid did".into()).into_response(),
|
||||
};
|
||||
match state
|
||||
.user_repo
|
||||
.check_channel_verified_by_did(&did, channel)
|
||||
.await
|
||||
{
|
||||
Ok(Some(verified)) => VerifiedResponse::response(verified).into_response(),
|
||||
Ok(None) => ApiError::AccountNotFound.into_response(),
|
||||
Err(e) => {
|
||||
error!("DB error checking channel verified: {:?}", e);
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AuthorizeEmailUpdateQuery {
|
||||
pub token: String,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::state::AppState;
|
||||
use crate::util::pds_hostname;
|
||||
use crate::util::{pds_hostname, telegram_bot_username};
|
||||
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -52,7 +52,7 @@ pub async fn describe_server() -> impl IntoResponse {
|
||||
if let Some(email) = contact_email {
|
||||
contact.insert("email".to_string(), json!(email));
|
||||
}
|
||||
Json(json!({
|
||||
let mut response = json!({
|
||||
"availableUserDomains": domains,
|
||||
"inviteCodeRequired": invite_code_required,
|
||||
"did": format!("did:web:{}", pds_hostname),
|
||||
@@ -61,7 +61,11 @@ pub async fn describe_server() -> impl IntoResponse {
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"availableCommsChannels": get_available_comms_channels(),
|
||||
"selfHostedDidWebEnabled": is_self_hosted_did_web_enabled()
|
||||
}))
|
||||
});
|
||||
if let Some(bot_username) = telegram_bot_username() {
|
||||
response["telegramBotUsername"] = json!(bot_username);
|
||||
}
|
||||
Json(response)
|
||||
}
|
||||
pub async fn health(State(state): State<AppState>) -> impl IntoResponse {
|
||||
match state.infra_repo.health_check().await {
|
||||
|
||||
@@ -23,7 +23,7 @@ pub use account_status::{
|
||||
};
|
||||
pub use app_password::{create_app_password, list_app_passwords, revoke_app_password};
|
||||
pub use email::{
|
||||
authorize_email_update, check_comms_channel_in_use, check_email_in_use,
|
||||
authorize_email_update, check_channel_verified, check_comms_channel_in_use, check_email_in_use,
|
||||
check_email_update_status, check_email_verified, confirm_email, request_email_update,
|
||||
update_email,
|
||||
};
|
||||
|
||||
@@ -172,7 +172,15 @@ pub async fn create_passkey_account(
|
||||
_ => return ApiError::MissingDiscordId.into_response(),
|
||||
},
|
||||
"telegram" => match &input.telegram_username {
|
||||
Some(username) if !username.trim().is_empty() => username.trim().to_string(),
|
||||
Some(username) if !username.trim().is_empty() => {
|
||||
let clean = username.trim().trim_start_matches('@');
|
||||
if !crate::api::validation::is_valid_telegram_username(clean) {
|
||||
return ApiError::InvalidRequest(
|
||||
"Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(),
|
||||
).into_response();
|
||||
}
|
||||
clean.to_string()
|
||||
}
|
||||
_ => return ApiError::MissingTelegramUsername.into_response(),
|
||||
},
|
||||
"signal" => match &input.signal_number {
|
||||
@@ -410,7 +418,7 @@ pub async fn create_passkey_account(
|
||||
telegram_username: input
|
||||
.telegram_username
|
||||
.as_deref()
|
||||
.map(|s| s.trim())
|
||||
.map(|s| s.trim().trim_start_matches('@'))
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from),
|
||||
signal_number: input
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use crate::api::error::{ApiError, DbResultExt};
|
||||
use crate::comms::comms_repo;
|
||||
use crate::types::Did;
|
||||
use crate::util::pds_hostname;
|
||||
use axum::{Json, extract::State};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{info, warn};
|
||||
@@ -161,6 +163,20 @@ async fn handle_channel_update(
|
||||
|
||||
info!(did = %did, channel = %channel, "Channel verified successfully");
|
||||
|
||||
let recipient = resolve_verified_recipient(state, user_id, channel, identifier).await;
|
||||
if let Err(e) = comms_repo::enqueue_channel_verified(
|
||||
state.user_repo.as_ref(),
|
||||
state.infra_repo.as_ref(),
|
||||
user_id,
|
||||
channel,
|
||||
&recipient,
|
||||
pds_hostname(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(error = %e, "Failed to enqueue channel verified notification");
|
||||
}
|
||||
|
||||
Ok(Json(VerifyTokenOutput {
|
||||
success: true,
|
||||
did: did.to_string().into(),
|
||||
@@ -169,11 +185,30 @@ async fn handle_channel_update(
|
||||
}))
|
||||
}
|
||||
|
||||
async fn resolve_verified_recipient(
|
||||
state: &AppState,
|
||||
user_id: uuid::Uuid,
|
||||
channel: &str,
|
||||
identifier: &str,
|
||||
) -> String {
|
||||
match channel {
|
||||
"telegram" => state
|
||||
.user_repo
|
||||
.get_telegram_chat_id(user_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|id| id.to_string())
|
||||
.unwrap_or_else(|| identifier.to_string()),
|
||||
_ => identifier.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_signup_verification(
|
||||
state: &AppState,
|
||||
did: &str,
|
||||
channel: &str,
|
||||
_identifier: &str,
|
||||
identifier: &str,
|
||||
) -> Result<Json<VerifyTokenOutput>, ApiError> {
|
||||
let did_typed: Did = did
|
||||
.parse()
|
||||
@@ -232,6 +267,20 @@ async fn handle_signup_verification(
|
||||
|
||||
info!(did = %did, channel = %channel, "Signup verified successfully");
|
||||
|
||||
let recipient = resolve_verified_recipient(state, user.id, channel, identifier).await;
|
||||
if let Err(e) = comms_repo::enqueue_channel_verified(
|
||||
state.user_repo.as_ref(),
|
||||
state.infra_repo.as_ref(),
|
||||
user.id,
|
||||
channel,
|
||||
&recipient,
|
||||
pds_hostname(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(error = %e, "Failed to enqueue channel verified notification");
|
||||
}
|
||||
|
||||
Ok(Json(VerifyTokenOutput {
|
||||
success: true,
|
||||
did: did.to_string().into(),
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::comms::comms_repo;
|
||||
use crate::state::AppState;
|
||||
use crate::util::pds_hostname;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TelegramUpdate {
|
||||
message: Option<TelegramMessage>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TelegramMessage {
|
||||
text: Option<String>,
|
||||
from: Option<TelegramUser>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TelegramUser {
|
||||
id: i64,
|
||||
username: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn handle_telegram_webhook(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: String,
|
||||
) -> impl IntoResponse {
|
||||
let expected_secret = match std::env::var("TELEGRAM_WEBHOOK_SECRET") {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
warn!("Telegram webhook called but TELEGRAM_WEBHOOK_SECRET is not configured");
|
||||
return StatusCode::FORBIDDEN;
|
||||
}
|
||||
};
|
||||
let provided = headers
|
||||
.get("x-telegram-bot-api-secret-token")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or_default();
|
||||
if provided != expected_secret {
|
||||
warn!("Telegram webhook received with invalid secret token");
|
||||
return StatusCode::UNAUTHORIZED;
|
||||
}
|
||||
|
||||
let update: TelegramUpdate = match serde_json::from_str(&body) {
|
||||
Ok(u) => u,
|
||||
Err(_) => return StatusCode::OK,
|
||||
};
|
||||
|
||||
if let Some(message) = update.message {
|
||||
let is_start = message
|
||||
.text
|
||||
.as_deref()
|
||||
.is_some_and(|t| t.starts_with("/start"));
|
||||
|
||||
if is_start
|
||||
&& let Some(from) = message.from
|
||||
&& let Some(username) = from.username
|
||||
{
|
||||
let handle = parse_start_handle(message.text.as_deref());
|
||||
|
||||
debug!(
|
||||
telegram_username = %username,
|
||||
chat_id = from.id,
|
||||
handle = ?handle,
|
||||
"Received /start from Telegram user"
|
||||
);
|
||||
match state
|
||||
.user_repo
|
||||
.store_telegram_chat_id(&username, from.id, handle.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(Some(user_id)) => {
|
||||
info!(
|
||||
telegram_username = %username,
|
||||
chat_id = from.id,
|
||||
"Verified Telegram user and stored chat_id"
|
||||
);
|
||||
if let Err(e) = comms_repo::enqueue_channel_verified(
|
||||
state.user_repo.as_ref(),
|
||||
state.infra_repo.as_ref(),
|
||||
user_id,
|
||||
"telegram",
|
||||
&from.id.to_string(),
|
||||
pds_hostname(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(error = %e, "Failed to enqueue channel verified notification");
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
debug!(
|
||||
telegram_username = %username,
|
||||
"No matching user found for Telegram username"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
telegram_username = %username,
|
||||
error = %e,
|
||||
"Failed to store Telegram chat_id"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StatusCode::OK
|
||||
}
|
||||
|
||||
fn parse_start_handle(text: Option<&str>) -> Option<String> {
|
||||
text.and_then(|t| t.strip_prefix("/start "))
|
||||
.map(|payload| payload.trim())
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|payload| payload.replace('_', "."))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deep_link_underscores_decoded_to_dots() {
|
||||
assert_eq!(
|
||||
parse_start_handle(Some("/start lewis_buttercup_wizardry_systems")),
|
||||
Some("lewis.buttercup.wizardry.systems".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_handle_with_dots_passes_through() {
|
||||
assert_eq!(
|
||||
parse_start_handle(Some("/start lewis.buttercup.wizardry.systems")),
|
||||
Some("lewis.buttercup.wizardry.systems".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_start_returns_none() {
|
||||
assert_eq!(parse_start_handle(Some("/start")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_with_trailing_space_returns_none() {
|
||||
assert_eq!(parse_start_handle(Some("/start ")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_text_returns_none() {
|
||||
assert_eq!(parse_start_handle(None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_start_command_returns_none() {
|
||||
assert_eq!(parse_start_handle(Some("/help")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_with_extra_whitespace_trimmed() {
|
||||
assert_eq!(
|
||||
parse_start_handle(Some("/start alice_example_com ")),
|
||||
Some("alice.example.com".to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -349,6 +349,14 @@ 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
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -10,7 +10,7 @@ use tranquil_comms::{
|
||||
CommsChannel, CommsSender, CommsStatus, CommsType, NewComms, SendError, format_message,
|
||||
get_strings,
|
||||
};
|
||||
use tranquil_db_traits::{InfraRepository, QueuedComms, UserRepository};
|
||||
use tranquil_db_traits::{InfraRepository, QueuedComms, UserCommsPrefs, UserRepository};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct CommsService {
|
||||
@@ -75,6 +75,7 @@ impl CommsService {
|
||||
tranquil_db_traits::CommsType::MigrationVerification
|
||||
}
|
||||
CommsType::ChannelVerification => tranquil_db_traits::CommsType::ChannelVerification,
|
||||
CommsType::ChannelVerified => tranquil_db_traits::CommsType::ChannelVerified,
|
||||
};
|
||||
let id = self
|
||||
.infra_repo
|
||||
@@ -170,6 +171,7 @@ impl CommsService {
|
||||
tranquil_db_traits::CommsType::ChannelVerification => {
|
||||
CommsType::ChannelVerification
|
||||
}
|
||||
tranquil_db_traits::CommsType::ChannelVerified => CommsType::ChannelVerified,
|
||||
},
|
||||
status: match item.status {
|
||||
tranquil_db_traits::CommsStatus::Pending => CommsStatus::Pending,
|
||||
@@ -247,6 +249,49 @@ pub fn channel_display_name(channel: CommsChannel) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
struct ResolvedRecipient {
|
||||
channel: tranquil_db_traits::CommsChannel,
|
||||
recipient: String,
|
||||
}
|
||||
|
||||
fn resolve_recipient(
|
||||
prefs: &UserCommsPrefs,
|
||||
channel: tranquil_db_traits::CommsChannel,
|
||||
) -> ResolvedRecipient {
|
||||
let email_fallback = || ResolvedRecipient {
|
||||
channel: tranquil_db_traits::CommsChannel::Email,
|
||||
recipient: prefs.email.clone().unwrap_or_default(),
|
||||
};
|
||||
match channel {
|
||||
tranquil_db_traits::CommsChannel::Email => email_fallback(),
|
||||
tranquil_db_traits::CommsChannel::Telegram => prefs
|
||||
.telegram_chat_id
|
||||
.map(|id| ResolvedRecipient {
|
||||
channel,
|
||||
recipient: id.to_string(),
|
||||
})
|
||||
.unwrap_or_else(email_fallback),
|
||||
tranquil_db_traits::CommsChannel::Discord => prefs
|
||||
.discord_id
|
||||
.as_ref()
|
||||
.filter(|id| !id.is_empty())
|
||||
.map(|id| ResolvedRecipient {
|
||||
channel,
|
||||
recipient: id.clone(),
|
||||
})
|
||||
.unwrap_or_else(email_fallback),
|
||||
tranquil_db_traits::CommsChannel::Signal => prefs
|
||||
.signal_number
|
||||
.as_ref()
|
||||
.filter(|n| !n.is_empty())
|
||||
.map(|n| ResolvedRecipient {
|
||||
channel,
|
||||
recipient: n.clone(),
|
||||
})
|
||||
.unwrap_or_else(email_fallback),
|
||||
}
|
||||
}
|
||||
|
||||
fn channel_from_str(s: &str) -> tranquil_db_traits::CommsChannel {
|
||||
match s {
|
||||
"discord" => tranquil_db_traits::CommsChannel::Discord,
|
||||
@@ -276,13 +321,13 @@ pub mod repo {
|
||||
&[("hostname", hostname), ("handle", &prefs.handle)],
|
||||
);
|
||||
let subject = format_message(strings.welcome_subject, &[("hostname", hostname)]);
|
||||
let channel = prefs.preferred_channel;
|
||||
let resolved = resolve_recipient(&prefs, prefs.preferred_channel);
|
||||
infra_repo
|
||||
.enqueue_comms(
|
||||
Some(user_id),
|
||||
channel,
|
||||
resolved.channel,
|
||||
CommsType::Welcome,
|
||||
&prefs.email.unwrap_or_default(),
|
||||
&resolved.recipient,
|
||||
Some(&subject),
|
||||
&body,
|
||||
None,
|
||||
@@ -307,13 +352,13 @@ pub mod repo {
|
||||
&[("handle", &prefs.handle), ("code", code)],
|
||||
);
|
||||
let subject = format_message(strings.password_reset_subject, &[("hostname", hostname)]);
|
||||
let channel = prefs.preferred_channel;
|
||||
let resolved = resolve_recipient(&prefs, prefs.preferred_channel);
|
||||
infra_repo
|
||||
.enqueue_comms(
|
||||
Some(user_id),
|
||||
channel,
|
||||
resolved.channel,
|
||||
CommsType::PasswordReset,
|
||||
&prefs.email.unwrap_or_default(),
|
||||
&resolved.recipient,
|
||||
Some(&subject),
|
||||
&body,
|
||||
None,
|
||||
@@ -471,13 +516,13 @@ pub mod repo {
|
||||
&[("handle", &prefs.handle), ("code", code)],
|
||||
);
|
||||
let subject = format_message(strings.account_deletion_subject, &[("hostname", hostname)]);
|
||||
let channel = prefs.preferred_channel;
|
||||
let resolved = resolve_recipient(&prefs, prefs.preferred_channel);
|
||||
infra_repo
|
||||
.enqueue_comms(
|
||||
Some(user_id),
|
||||
channel,
|
||||
resolved.channel,
|
||||
CommsType::AccountDeletion,
|
||||
&prefs.email.unwrap_or_default(),
|
||||
&resolved.recipient,
|
||||
Some(&subject),
|
||||
&body,
|
||||
None,
|
||||
@@ -502,13 +547,13 @@ pub mod repo {
|
||||
&[("handle", &prefs.handle), ("token", token)],
|
||||
);
|
||||
let subject = format_message(strings.plc_operation_subject, &[("hostname", hostname)]);
|
||||
let channel = prefs.preferred_channel;
|
||||
let resolved = resolve_recipient(&prefs, prefs.preferred_channel);
|
||||
infra_repo
|
||||
.enqueue_comms(
|
||||
Some(user_id),
|
||||
channel,
|
||||
resolved.channel,
|
||||
CommsType::PlcOperation,
|
||||
&prefs.email.unwrap_or_default(),
|
||||
&resolved.recipient,
|
||||
Some(&subject),
|
||||
&body,
|
||||
None,
|
||||
@@ -533,13 +578,13 @@ pub mod repo {
|
||||
&[("handle", &prefs.handle), ("url", recovery_url)],
|
||||
);
|
||||
let subject = format_message(strings.passkey_recovery_subject, &[("hostname", hostname)]);
|
||||
let channel = prefs.preferred_channel;
|
||||
let resolved = resolve_recipient(&prefs, prefs.preferred_channel);
|
||||
infra_repo
|
||||
.enqueue_comms(
|
||||
Some(user_id),
|
||||
channel,
|
||||
resolved.channel,
|
||||
CommsType::PasskeyRecovery,
|
||||
&prefs.email.unwrap_or_default(),
|
||||
&resolved.recipient,
|
||||
Some(&subject),
|
||||
&body,
|
||||
None,
|
||||
@@ -663,13 +708,13 @@ pub mod repo {
|
||||
&[("handle", &prefs.handle), ("code", code)],
|
||||
);
|
||||
let subject = format_message(strings.two_factor_code_subject, &[("hostname", hostname)]);
|
||||
let channel = prefs.preferred_channel;
|
||||
let resolved = resolve_recipient(&prefs, prefs.preferred_channel);
|
||||
infra_repo
|
||||
.enqueue_comms(
|
||||
Some(user_id),
|
||||
channel,
|
||||
resolved.channel,
|
||||
CommsType::TwoFactorCode,
|
||||
&prefs.email.unwrap_or_default(),
|
||||
&resolved.recipient,
|
||||
Some(&subject),
|
||||
&body,
|
||||
None,
|
||||
@@ -703,12 +748,56 @@ pub mod repo {
|
||||
],
|
||||
);
|
||||
let subject = format_message(strings.legacy_login_subject, &[("hostname", hostname)]);
|
||||
let resolved = resolve_recipient(&prefs, channel);
|
||||
infra_repo
|
||||
.enqueue_comms(
|
||||
Some(user_id),
|
||||
channel,
|
||||
resolved.channel,
|
||||
CommsType::LegacyLoginAlert,
|
||||
&prefs.email.unwrap_or_default(),
|
||||
&resolved.recipient,
|
||||
Some(&subject),
|
||||
&body,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn enqueue_channel_verified(
|
||||
user_repo: &dyn UserRepository,
|
||||
infra_repo: &dyn InfraRepository,
|
||||
user_id: Uuid,
|
||||
channel_name: &str,
|
||||
recipient: &str,
|
||||
hostname: &str,
|
||||
) -> Result<Uuid, DbError> {
|
||||
let prefs = user_repo
|
||||
.get_comms_prefs(user_id)
|
||||
.await?
|
||||
.ok_or(DbError::NotFound)?;
|
||||
let strings = get_strings(prefs.preferred_locale.as_deref().unwrap_or("en"));
|
||||
let display_name = match channel_name {
|
||||
"email" => "Email",
|
||||
"discord" => "Discord",
|
||||
"telegram" => "Telegram",
|
||||
"signal" => "Signal",
|
||||
other => other,
|
||||
};
|
||||
let body = format_message(
|
||||
strings.channel_verified_body,
|
||||
&[
|
||||
("handle", &prefs.handle),
|
||||
("channel", display_name),
|
||||
("hostname", hostname),
|
||||
],
|
||||
);
|
||||
let subject = format_message(strings.channel_verified_subject, &[("hostname", hostname)]);
|
||||
let comms_channel = channel_from_str(channel_name);
|
||||
infra_repo
|
||||
.enqueue_comms(
|
||||
Some(user_id),
|
||||
comms_channel,
|
||||
CommsType::ChannelVerified,
|
||||
recipient,
|
||||
Some(&subject),
|
||||
&body,
|
||||
None,
|
||||
|
||||
@@ -281,6 +281,10 @@ pub fn app(state: AppState) -> Router {
|
||||
"/_checkEmailVerified",
|
||||
post(api::server::check_email_verified),
|
||||
)
|
||||
.route(
|
||||
"/_checkChannelVerified",
|
||||
post(api::server::check_channel_verified),
|
||||
)
|
||||
.route(
|
||||
"/com.atproto.server.confirmEmail",
|
||||
post(api::server::confirm_email),
|
||||
@@ -639,6 +643,10 @@ pub fn app(state: AppState) -> Router {
|
||||
.route("/robots.txt", get(api::server::robots_txt))
|
||||
.route("/logo", get(api::server::get_logo))
|
||||
.route("/u/{handle}/did.json", get(api::identity::user_did_doc))
|
||||
.route(
|
||||
"/webhook/telegram",
|
||||
post(api::telegram_webhook::handle_telegram_webhook),
|
||||
)
|
||||
.layer(DefaultBodyLimit::max(util::get_max_blob_size()))
|
||||
.layer(middleware::from_fn(metrics::metrics_middleware))
|
||||
.layer(
|
||||
|
||||
@@ -78,7 +78,34 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
if let Some(telegram_sender) = TelegramSender::from_env() {
|
||||
let secret_token = match std::env::var("TELEGRAM_WEBHOOK_SECRET") {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
return Err(
|
||||
"TELEGRAM_BOT_TOKEN is set but TELEGRAM_WEBHOOK_SECRET is missing. Both are required for secure Telegram integration.".into()
|
||||
);
|
||||
}
|
||||
};
|
||||
info!("Telegram comms enabled");
|
||||
match telegram_sender.resolve_bot_username().await {
|
||||
Ok(username) => {
|
||||
info!(bot_username = %username, "Resolved Telegram bot username");
|
||||
tranquil_pds::util::set_telegram_bot_username(username);
|
||||
let hostname =
|
||||
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let webhook_url = format!("https://{}/webhook/telegram", hostname);
|
||||
match telegram_sender
|
||||
.set_webhook(&webhook_url, Some(&secret_token))
|
||||
.await
|
||||
{
|
||||
Ok(()) => info!(url = %webhook_url, "Telegram webhook registered"),
|
||||
Err(e) => warn!("Failed to register Telegram webhook: {}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to resolve Telegram bot username: {}", e);
|
||||
}
|
||||
}
|
||||
comms_service = comms_service.register_sender(telegram_sender);
|
||||
}
|
||||
|
||||
|
||||
@@ -119,8 +119,8 @@ pub async fn sso_initiate(
|
||||
let auth_header = headers
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok());
|
||||
let extracted = extract_auth_token_from_header(auth_header)
|
||||
.ok_or(ApiError::SsoNotAuthenticated)?;
|
||||
let extracted =
|
||||
extract_auth_token_from_header(auth_header).ok_or(ApiError::SsoNotAuthenticated)?;
|
||||
let auth_user = validate_bearer_token_cached(
|
||||
state.user_repo.as_ref(),
|
||||
state.cache.as_ref(),
|
||||
@@ -899,7 +899,15 @@ pub async fn complete_registration(
|
||||
_ => return Err(ApiError::MissingDiscordId),
|
||||
},
|
||||
"telegram" => match &input.telegram_username {
|
||||
Some(username) if !username.trim().is_empty() => username.trim().to_string(),
|
||||
Some(username) if !username.trim().is_empty() => {
|
||||
let clean = username.trim().trim_start_matches('@');
|
||||
if !crate::api::validation::is_valid_telegram_username(clean) {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(),
|
||||
));
|
||||
}
|
||||
clean.to_string()
|
||||
}
|
||||
_ => return Err(ApiError::MissingTelegramUsername),
|
||||
},
|
||||
"signal" => match &input.signal_number {
|
||||
@@ -1104,7 +1112,7 @@ pub async fn complete_registration(
|
||||
telegram_username: input
|
||||
.telegram_username
|
||||
.clone()
|
||||
.map(|s| s.trim().to_string())
|
||||
.map(|s| s.trim().trim_start_matches('@').to_string())
|
||||
.filter(|s| !s.is_empty()),
|
||||
signal_number: input
|
||||
.signal_number
|
||||
|
||||
@@ -14,6 +14,7 @@ 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 TELEGRAM_BOT_USERNAME: OnceLock<String> = OnceLock::new();
|
||||
|
||||
pub fn get_max_blob_size() -> usize {
|
||||
*MAX_BLOB_SIZE.get_or_init(|| {
|
||||
@@ -104,6 +105,14 @@ pub fn pds_hostname_without_port() -> &'static str {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_telegram_bot_username(username: String) {
|
||||
TELEGRAM_BOT_USERNAME.set(username).ok();
|
||||
}
|
||||
|
||||
pub fn telegram_bot_username() -> Option<&'static str> {
|
||||
TELEGRAM_BOT_USERNAME.get().map(|s| s.as_str())
|
||||
}
|
||||
|
||||
pub fn pds_public_url() -> String {
|
||||
format!("https://{}", pds_hostname())
|
||||
}
|
||||
|
||||
@@ -4,6 +4,87 @@ use serde_json::{Value, json};
|
||||
|
||||
pub use crate::common::*;
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn paginate_records(
|
||||
client: &reqwest::Client,
|
||||
base: &str,
|
||||
jwt: &str,
|
||||
did: &str,
|
||||
collection: &str,
|
||||
limit: usize,
|
||||
) -> Vec<Value> {
|
||||
paginate_records_inner(client, base, jwt, did, collection, limit, None, Vec::new()).await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn paginate_records_inner(
|
||||
client: &reqwest::Client,
|
||||
base: &str,
|
||||
jwt: &str,
|
||||
did: &str,
|
||||
collection: &str,
|
||||
limit: usize,
|
||||
cursor: Option<String>,
|
||||
mut acc: Vec<Value>,
|
||||
) -> Vec<Value> {
|
||||
let limit_str = limit.to_string();
|
||||
let mut query: Vec<(&str, &str)> = vec![
|
||||
("repo", did),
|
||||
("collection", collection),
|
||||
("limit", &limit_str),
|
||||
];
|
||||
if let Some(ref c) = cursor {
|
||||
query.push(("cursor", c.as_str()));
|
||||
}
|
||||
|
||||
let res = client
|
||||
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base))
|
||||
.bearer_auth(jwt)
|
||||
.query(&query)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let Ok(response) = res else { return acc };
|
||||
let Ok(body) = response.json::<Value>().await else {
|
||||
return acc;
|
||||
};
|
||||
|
||||
let Some(records) = body["records"].as_array() else {
|
||||
return acc;
|
||||
};
|
||||
acc.extend(records.iter().cloned());
|
||||
|
||||
match body["cursor"].as_str() {
|
||||
Some(next) => {
|
||||
Box::pin(paginate_records_inner(
|
||||
client,
|
||||
base,
|
||||
jwt,
|
||||
did,
|
||||
collection,
|
||||
limit,
|
||||
Some(next.to_string()),
|
||||
acc,
|
||||
))
|
||||
.await
|
||||
}
|
||||
None => acc,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn count_records(
|
||||
client: &reqwest::Client,
|
||||
base: &str,
|
||||
jwt: &str,
|
||||
did: &str,
|
||||
collection: &str,
|
||||
) -> usize {
|
||||
paginate_records(client, base, jwt, did, collection, 100)
|
||||
.await
|
||||
.len()
|
||||
}
|
||||
|
||||
fn unique_id() -> String {
|
||||
uuid::Uuid::new_v4().simple().to_string()[..12].to_string()
|
||||
}
|
||||
@@ -246,3 +327,150 @@ pub async fn set_account_deactivated(did: &str, deactivated: bool) {
|
||||
.await
|
||||
.expect("Failed to update deactivated_at");
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn make_cid(data: &[u8]) -> cid::Cid {
|
||||
use sha2::{Digest, Sha256};
|
||||
let hash = Sha256::digest(data);
|
||||
let multihash = multihash::Multihash::wrap(0x12, &hash).unwrap();
|
||||
cid::Cid::new_v1(0x71, multihash)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn write_varint(buf: &mut Vec<u8>, value: u64) {
|
||||
buf.extend(encode_varint_bytes(value));
|
||||
}
|
||||
|
||||
fn encode_varint_bytes(value: u64) -> Vec<u8> {
|
||||
match value < 0x80 {
|
||||
true => vec![value as u8],
|
||||
false => {
|
||||
let mut rest = encode_varint_bytes(value >> 7);
|
||||
rest.insert(0, ((value & 0x7F) as u8) | 0x80);
|
||||
rest
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn encode_car_block(cid: &cid::Cid, data: &[u8]) -> Vec<u8> {
|
||||
let cid_bytes = cid.to_bytes();
|
||||
let mut result = Vec::new();
|
||||
write_varint(&mut result, (cid_bytes.len() + data.len()) as u64);
|
||||
result.extend_from_slice(&cid_bytes);
|
||||
result.extend_from_slice(data);
|
||||
result
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn create_test_record() -> (Vec<u8>, cid::Cid) {
|
||||
use ipld_core::ipld::Ipld;
|
||||
use std::collections::BTreeMap;
|
||||
let record = Ipld::Map(BTreeMap::from([
|
||||
(
|
||||
"$type".to_string(),
|
||||
Ipld::String("app.bsky.feed.post".to_string()),
|
||||
),
|
||||
(
|
||||
"text".to_string(),
|
||||
Ipld::String("Test post for verification".to_string()),
|
||||
),
|
||||
(
|
||||
"createdAt".to_string(),
|
||||
Ipld::String("2024-01-01T00:00:00Z".to_string()),
|
||||
),
|
||||
]));
|
||||
let bytes = serde_ipld_dagcbor::to_vec(&record).unwrap();
|
||||
let cid = make_cid(&bytes);
|
||||
(bytes, cid)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn create_mst_node(entries: Vec<(String, cid::Cid)>) -> (Vec<u8>, cid::Cid) {
|
||||
use ipld_core::ipld::Ipld;
|
||||
use std::collections::BTreeMap;
|
||||
let ipld_entries: Vec<Ipld> = entries
|
||||
.into_iter()
|
||||
.map(|(key, value_cid)| {
|
||||
Ipld::Map(BTreeMap::from([
|
||||
("k".to_string(), Ipld::Bytes(key.into_bytes())),
|
||||
("v".to_string(), Ipld::Link(value_cid)),
|
||||
("p".to_string(), Ipld::Integer(0)),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
let node = Ipld::Map(BTreeMap::from([(
|
||||
"e".to_string(),
|
||||
Ipld::List(ipld_entries),
|
||||
)]));
|
||||
let bytes = serde_ipld_dagcbor::to_vec(&node).unwrap();
|
||||
let cid = make_cid(&bytes);
|
||||
(bytes, cid)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn create_car_signed_commit(
|
||||
did: &str,
|
||||
data_cid: &cid::Cid,
|
||||
signing_key: &k256::ecdsa::SigningKey,
|
||||
) -> (Vec<u8>, cid::Cid) {
|
||||
use jacquard_common::types::{integer::LimitedU32, string::Tid};
|
||||
use jacquard_repo::commit::Commit;
|
||||
let rev = Tid::now(LimitedU32::MIN);
|
||||
let did = jacquard_common::types::string::Did::new(did).expect("valid DID");
|
||||
let unsigned = Commit::new_unsigned(did, *data_cid, rev, None);
|
||||
let signed = unsigned.sign(signing_key).expect("signing failed");
|
||||
let signed_bytes = signed.to_cbor().expect("serialization failed");
|
||||
let cid = make_cid(&signed_bytes);
|
||||
(signed_bytes, cid)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn build_car_with_signature(
|
||||
did: &str,
|
||||
signing_key: &k256::ecdsa::SigningKey,
|
||||
) -> (Vec<u8>, cid::Cid) {
|
||||
let (record_bytes, record_cid) = create_test_record();
|
||||
let (mst_bytes, mst_cid) =
|
||||
create_mst_node(vec![("app.bsky.feed.post/test123".to_string(), record_cid)]);
|
||||
let (commit_bytes, commit_cid) = create_car_signed_commit(did, &mst_cid, signing_key);
|
||||
let header = iroh_car::CarHeader::new_v1(vec![commit_cid]);
|
||||
let header_bytes = header.encode().unwrap();
|
||||
let mut car = Vec::new();
|
||||
write_varint(&mut car, header_bytes.len() as u64);
|
||||
car.extend_from_slice(&header_bytes);
|
||||
car.extend(encode_car_block(&commit_cid, &commit_bytes));
|
||||
car.extend(encode_car_block(&mst_cid, &mst_bytes));
|
||||
car.extend(encode_car_block(&record_cid, &record_bytes));
|
||||
(car, commit_cid)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn get_multikey_from_signing_key(signing_key: &k256::ecdsa::SigningKey) -> String {
|
||||
let public_key = signing_key.verifying_key();
|
||||
let compressed = public_key.to_sec1_bytes();
|
||||
let buf: Vec<u8> = encode_varint_bytes(0xE7)
|
||||
.into_iter()
|
||||
.chain(compressed.iter().copied())
|
||||
.collect();
|
||||
multibase::encode(multibase::Base::Base58Btc, buf)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn get_user_signing_key(did: &str) -> Option<Vec<u8>> {
|
||||
let db_url = get_db_connection_string().await;
|
||||
let pool = sqlx::PgPool::connect(&db_url).await.ok()?;
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
SELECT k.key_bytes, k.encryption_version
|
||||
FROM user_keys k
|
||||
JOIN users u ON k.user_id = u.id
|
||||
WHERE u.did = $1
|
||||
"#,
|
||||
did
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.ok()??;
|
||||
tranquil_pds::config::decrypt_key(&row.key_bytes, row.encryption_version).ok()
|
||||
}
|
||||
|
||||
@@ -1,66 +1,13 @@
|
||||
mod common;
|
||||
use cid::Cid;
|
||||
mod helpers;
|
||||
use common::*;
|
||||
use ipld_core::ipld::Ipld;
|
||||
use jacquard_common::types::{integer::LimitedU32, string::Tid};
|
||||
use jacquard_repo::commit::Commit;
|
||||
use helpers::*;
|
||||
use k256::ecdsa::SigningKey;
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::json;
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::PgPool;
|
||||
use std::collections::BTreeMap;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
fn make_cid(data: &[u8]) -> Cid {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(data);
|
||||
let hash = hasher.finalize();
|
||||
let multihash = multihash::Multihash::wrap(0x12, &hash).unwrap();
|
||||
Cid::new_v1(0x71, multihash)
|
||||
}
|
||||
|
||||
fn write_varint(buf: &mut Vec<u8>, mut value: u64) {
|
||||
loop {
|
||||
let mut byte = (value & 0x7F) as u8;
|
||||
value >>= 7;
|
||||
if value != 0 {
|
||||
byte |= 0x80;
|
||||
}
|
||||
buf.push(byte);
|
||||
if value == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_car_block(cid: &Cid, data: &[u8]) -> Vec<u8> {
|
||||
let cid_bytes = cid.to_bytes();
|
||||
let mut result = Vec::new();
|
||||
write_varint(&mut result, (cid_bytes.len() + data.len()) as u64);
|
||||
result.extend_from_slice(&cid_bytes);
|
||||
result.extend_from_slice(data);
|
||||
result
|
||||
}
|
||||
|
||||
fn get_multikey_from_signing_key(signing_key: &SigningKey) -> String {
|
||||
let public_key = signing_key.verifying_key();
|
||||
let compressed = public_key.to_sec1_bytes();
|
||||
fn encode_uvarint(mut x: u64) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
while x >= 0x80 {
|
||||
out.push(((x as u8) & 0x7F) | 0x80);
|
||||
x >>= 7;
|
||||
}
|
||||
out.push(x as u8);
|
||||
out
|
||||
}
|
||||
let mut buf = encode_uvarint(0xE7);
|
||||
buf.extend_from_slice(&compressed);
|
||||
multibase::encode(multibase::Base::Base58Btc, buf)
|
||||
}
|
||||
|
||||
fn create_did_document(
|
||||
did: &str,
|
||||
handle: &str,
|
||||
@@ -89,70 +36,6 @@ fn create_did_document(
|
||||
})
|
||||
}
|
||||
|
||||
fn create_signed_commit(did: &str, data_cid: &Cid, signing_key: &SigningKey) -> (Vec<u8>, Cid) {
|
||||
let rev = Tid::now(LimitedU32::MIN);
|
||||
let did = jacquard_common::types::string::Did::new(did).expect("valid DID");
|
||||
let unsigned = Commit::new_unsigned(did, *data_cid, rev, None);
|
||||
let signed = unsigned.sign(signing_key).expect("signing failed");
|
||||
let signed_bytes = signed.to_cbor().expect("serialization failed");
|
||||
let cid = make_cid(&signed_bytes);
|
||||
(signed_bytes, cid)
|
||||
}
|
||||
|
||||
fn create_mst_node(entries: Vec<(String, Cid)>) -> (Vec<u8>, Cid) {
|
||||
let ipld_entries: Vec<Ipld> = entries
|
||||
.into_iter()
|
||||
.map(|(key, value_cid)| {
|
||||
Ipld::Map(BTreeMap::from([
|
||||
("k".to_string(), Ipld::Bytes(key.into_bytes())),
|
||||
("v".to_string(), Ipld::Link(value_cid)),
|
||||
("p".to_string(), Ipld::Integer(0)),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
let node = Ipld::Map(BTreeMap::from([(
|
||||
"e".to_string(),
|
||||
Ipld::List(ipld_entries),
|
||||
)]));
|
||||
let bytes = serde_ipld_dagcbor::to_vec(&node).unwrap();
|
||||
let cid = make_cid(&bytes);
|
||||
(bytes, cid)
|
||||
}
|
||||
|
||||
fn create_record() -> (Vec<u8>, Cid) {
|
||||
let record = Ipld::Map(BTreeMap::from([
|
||||
(
|
||||
"$type".to_string(),
|
||||
Ipld::String("app.bsky.feed.post".to_string()),
|
||||
),
|
||||
(
|
||||
"text".to_string(),
|
||||
Ipld::String("Test post for verification".to_string()),
|
||||
),
|
||||
(
|
||||
"createdAt".to_string(),
|
||||
Ipld::String("2024-01-01T00:00:00Z".to_string()),
|
||||
),
|
||||
]));
|
||||
let bytes = serde_ipld_dagcbor::to_vec(&record).unwrap();
|
||||
let cid = make_cid(&bytes);
|
||||
(bytes, cid)
|
||||
}
|
||||
fn build_car_with_signature(did: &str, signing_key: &SigningKey) -> (Vec<u8>, Cid) {
|
||||
let (record_bytes, record_cid) = create_record();
|
||||
let (mst_bytes, mst_cid) =
|
||||
create_mst_node(vec![("app.bsky.feed.post/test123".to_string(), record_cid)]);
|
||||
let (commit_bytes, commit_cid) = create_signed_commit(did, &mst_cid, signing_key);
|
||||
let header = iroh_car::CarHeader::new_v1(vec![commit_cid]);
|
||||
let header_bytes = header.encode().unwrap();
|
||||
let mut car = Vec::new();
|
||||
write_varint(&mut car, header_bytes.len() as u64);
|
||||
car.extend_from_slice(&header_bytes);
|
||||
car.extend(encode_car_block(&commit_cid, &commit_bytes));
|
||||
car.extend(encode_car_block(&mst_cid, &mst_bytes));
|
||||
car.extend(encode_car_block(&record_cid, &record_bytes));
|
||||
(car, commit_cid)
|
||||
}
|
||||
async fn setup_mock_plc_directory(did: &str, did_doc: serde_json::Value) -> MockServer {
|
||||
let mock_server = MockServer::start().await;
|
||||
let did_encoded = urlencoding::encode(did);
|
||||
@@ -164,23 +47,7 @@ async fn setup_mock_plc_directory(did: &str, did_doc: serde_json::Value) -> Mock
|
||||
.await;
|
||||
mock_server
|
||||
}
|
||||
async fn get_user_signing_key(did: &str) -> Option<Vec<u8>> {
|
||||
let db_url = get_db_connection_string().await;
|
||||
let pool = PgPool::connect(&db_url).await.ok()?;
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
SELECT k.key_bytes, k.encryption_version
|
||||
FROM user_keys k
|
||||
JOIN users u ON k.user_id = u.id
|
||||
WHERE u.did = $1
|
||||
"#,
|
||||
did
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.ok()??;
|
||||
tranquil_pds::config::decrypt_key(&row.key_bytes, row.encryption_version).ok()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires exclusive env var access; run with: cargo test test_import_with_valid_signature_and_mock_plc -- --ignored --test-threads=1"]
|
||||
async fn test_import_with_valid_signature_and_mock_plc() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -92,6 +92,15 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /webhook/ {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /metrics {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -203,6 +203,15 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /webhook/ {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /metrics {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
let saving = $state(false)
|
||||
let preferredChannel = $state('email')
|
||||
let availableCommsChannels = $state<string[]>(['email'])
|
||||
let telegramBotUsername = $state<string | undefined>(undefined)
|
||||
let email = $state('')
|
||||
let discordId = $state('')
|
||||
let discordVerified = $state(false)
|
||||
@@ -24,6 +25,9 @@
|
||||
let telegramVerified = $state(false)
|
||||
let signalNumber = $state('')
|
||||
let signalVerified = $state(false)
|
||||
let savedDiscordId = $state('')
|
||||
let savedTelegramUsername = $state('')
|
||||
let savedSignalNumber = $state('')
|
||||
let verifyingChannel = $state<string | null>(null)
|
||||
let verificationCode = $state('')
|
||||
let historyLoading = $state(true)
|
||||
@@ -59,7 +63,11 @@
|
||||
telegramVerified = prefs.telegramVerified
|
||||
signalNumber = prefs.signalNumber ?? ''
|
||||
signalVerified = prefs.signalVerified
|
||||
savedDiscordId = discordId
|
||||
savedTelegramUsername = telegramUsername
|
||||
savedSignalNumber = signalNumber
|
||||
availableCommsChannels = serverInfo.availableCommsChannels ?? ['email']
|
||||
telegramBotUsername = serverInfo.telegramBotUsername
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : $_('comms.failedToLoad'))
|
||||
} finally {
|
||||
@@ -71,15 +79,24 @@
|
||||
e.preventDefault()
|
||||
saving = true
|
||||
try {
|
||||
await api.updateNotificationPrefs(session.accessJwt, {
|
||||
const result = await api.updateNotificationPrefs(session.accessJwt, {
|
||||
preferredChannel,
|
||||
discordId: discordId || undefined,
|
||||
telegramUsername: telegramUsername || undefined,
|
||||
signalNumber: signalNumber || undefined,
|
||||
discordId: discordId !== savedDiscordId ? discordId : undefined,
|
||||
telegramUsername: telegramUsername !== savedTelegramUsername ? telegramUsername : undefined,
|
||||
signalNumber: signalNumber !== savedSignalNumber ? signalNumber : undefined,
|
||||
})
|
||||
await refreshSession()
|
||||
toast.success($_('comms.preferencesSaved'))
|
||||
await loadPrefs()
|
||||
savedDiscordId = discordId
|
||||
savedTelegramUsername = telegramUsername
|
||||
savedSignalNumber = signalNumber
|
||||
const channelToVerify = result.verificationRequired?.find(
|
||||
(ch: string) => ch === 'discord' || ch === 'telegram' || ch === 'signal'
|
||||
)
|
||||
if (channelToVerify) {
|
||||
verifyingChannel = channelToVerify
|
||||
verificationCode = ''
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : $_('comms.failedToSave'))
|
||||
} finally {
|
||||
@@ -218,7 +235,9 @@
|
||||
<div class="config-item">
|
||||
<div class="config-header">
|
||||
<label for="email">{$_('register.email')}</label>
|
||||
<span class="status verified">{$_('comms.primary')}</span>
|
||||
<span class="status verified">
|
||||
{preferredChannel === 'email' ? $_('comms.primary') : $_('comms.verified')}
|
||||
</span>
|
||||
</div>
|
||||
<input id="email" type="email" value={email} disabled class="readonly" />
|
||||
</div>
|
||||
@@ -229,7 +248,7 @@
|
||||
<label for="discord">{$_('register.discordId')}</label>
|
||||
{#if discordId}
|
||||
<span class="status" class:verified={discordVerified} class:unverified={!discordVerified}>
|
||||
{discordVerified ? $_('comms.verified') : $_('comms.notVerified')}
|
||||
{preferredChannel === 'discord' && discordVerified ? $_('comms.primary') : discordVerified ? $_('comms.verified') : $_('comms.notVerified')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -242,7 +261,7 @@
|
||||
placeholder={$_('register.discordIdPlaceholder')}
|
||||
disabled={saving}
|
||||
/>
|
||||
{#if discordId && !discordVerified}
|
||||
{#if discordId && discordId === savedDiscordId && !discordVerified}
|
||||
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'discord'}>{$_('comms.verifyButton')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -251,7 +270,7 @@
|
||||
{/if}
|
||||
{#if verifyingChannel === 'discord'}
|
||||
<div class="verify-form">
|
||||
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" />
|
||||
<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>
|
||||
</div>
|
||||
@@ -265,7 +284,7 @@
|
||||
<label for="telegram">{$_('register.telegramUsername')}</label>
|
||||
{#if telegramUsername}
|
||||
<span class="status" class:verified={telegramVerified} class:unverified={!telegramVerified}>
|
||||
{telegramVerified ? $_('comms.verified') : $_('comms.notVerified')}
|
||||
{preferredChannel === 'telegram' && telegramVerified ? $_('comms.primary') : telegramVerified ? $_('comms.verified') : $_('comms.notVerified')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -278,18 +297,15 @@
|
||||
placeholder={$_('register.telegramUsernamePlaceholder')}
|
||||
disabled={saving}
|
||||
/>
|
||||
{#if telegramUsername && !telegramVerified}
|
||||
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'telegram'}>{$_('comms.verifyButton')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if telegramInUse}
|
||||
<p class="hint warning">{$_('comms.telegramInUseWarning')}</p>
|
||||
{/if}
|
||||
{#if verifyingChannel === 'telegram'}
|
||||
<div class="verify-form">
|
||||
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" />
|
||||
<button type="button" onclick={() => handleVerify('telegram')}>{$_('comms.submit')}</button>
|
||||
<button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button>
|
||||
{#if telegramUsername && telegramUsername === savedTelegramUsername && !telegramVerified && telegramBotUsername}
|
||||
{@const encodedHandle = session.handle.replaceAll('.', '_')}
|
||||
<div class="telegram-verify-prompt">
|
||||
<a href="https://t.me/{telegramBotUsername}?start={encodedHandle}" target="_blank" rel="noopener">{$_('comms.telegramOpenLink')}</a>
|
||||
<span class="manual-hint">{$_('comms.telegramStartBot', { values: { botUsername: telegramBotUsername, handle: session.handle } })}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -301,7 +317,7 @@
|
||||
<label for="signal">{$_('register.signalNumber')}</label>
|
||||
{#if signalNumber}
|
||||
<span class="status" class:verified={signalVerified} class:unverified={!signalVerified}>
|
||||
{signalVerified ? $_('comms.verified') : $_('comms.notVerified')}
|
||||
{preferredChannel === 'signal' && signalVerified ? $_('comms.primary') : signalVerified ? $_('comms.verified') : $_('comms.notVerified')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -314,7 +330,7 @@
|
||||
placeholder={$_('register.signalNumberPlaceholder')}
|
||||
disabled={saving}
|
||||
/>
|
||||
{#if signalNumber && !signalVerified}
|
||||
{#if signalNumber && signalNumber === savedSignalNumber && !signalVerified}
|
||||
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'signal'}>{$_('comms.verifyButton')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -323,7 +339,7 @@
|
||||
{/if}
|
||||
{#if verifyingChannel === 'signal'}
|
||||
<div class="verify-form">
|
||||
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" />
|
||||
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="128" />
|
||||
<button type="button" onclick={() => handleVerify('signal')}>{$_('comms.submit')}</button>
|
||||
<button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button>
|
||||
</div>
|
||||
@@ -505,6 +521,23 @@
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.telegram-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);
|
||||
}
|
||||
|
||||
.verify-btn {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
@@ -517,7 +550,8 @@
|
||||
}
|
||||
|
||||
.verify-form input {
|
||||
width: 120px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.verify-form button {
|
||||
|
||||
+13
-2
@@ -80,6 +80,7 @@ import type {
|
||||
TotpStatus,
|
||||
UpdateLegacyLoginResponse,
|
||||
UpdateLocaleResponse,
|
||||
UpdateNotificationPrefsResponse,
|
||||
UploadBlobResponse,
|
||||
VerificationChannel,
|
||||
VerifyMigrationEmailResponse,
|
||||
@@ -479,6 +480,16 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
checkChannelVerified(
|
||||
did: string,
|
||||
channel: string,
|
||||
): Promise<{ verified: boolean }> {
|
||||
return xrpc("_checkChannelVerified", {
|
||||
method: "POST",
|
||||
body: { did, channel },
|
||||
});
|
||||
},
|
||||
|
||||
checkEmailInUse(email: string): Promise<{ inUse: boolean }> {
|
||||
return xrpc("_account.checkEmailInUse", {
|
||||
method: "POST",
|
||||
@@ -648,7 +659,7 @@ export const api = {
|
||||
discordId?: string;
|
||||
telegramUsername?: string;
|
||||
signalNumber?: string;
|
||||
}): Promise<SuccessResponse> {
|
||||
}): Promise<UpdateNotificationPrefsResponse> {
|
||||
return xrpc("_account.updateNotificationPrefs", {
|
||||
method: "POST",
|
||||
token,
|
||||
@@ -1847,7 +1858,7 @@ export const typedApi = {
|
||||
telegramUsername?: string;
|
||||
signalNumber?: string;
|
||||
},
|
||||
): Promise<Result<SuccessResponse, ApiError>> {
|
||||
): Promise<Result<UpdateNotificationPrefsResponse, ApiError>> {
|
||||
return xrpcResult("_account.updateNotificationPrefs", {
|
||||
method: "POST",
|
||||
token,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import { api, ApiError } from '../api'
|
||||
import { resendVerification } from '../auth.svelte'
|
||||
import type { RegistrationFlow } from './flow.svelte'
|
||||
@@ -13,13 +13,26 @@
|
||||
let verificationCode = $state('')
|
||||
let resending = $state(false)
|
||||
let resendMessage = $state<string | null>(null)
|
||||
let telegramBotUsername = $state<string | undefined>(undefined)
|
||||
|
||||
let pollingInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const isTelegram = $derived(flow.info.verificationChannel === 'telegram')
|
||||
|
||||
onMount(async () => {
|
||||
if (isTelegram) {
|
||||
try {
|
||||
const serverInfo = await api.describeServer()
|
||||
telegramBotUsername = serverInfo.telegramBotUsername
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (flow.state.step === 'verify' && flow.account && !verificationCode.trim()) {
|
||||
if (flow.state.step === 'verify' && flow.account && (isTelegram || !verificationCode.trim())) {
|
||||
pollingInterval = setInterval(async () => {
|
||||
if (verificationCode.trim()) return
|
||||
if (!isTelegram && verificationCode.trim()) return
|
||||
const advanced = await flow.checkAndAdvanceIfVerified()
|
||||
if (advanced && pollingInterval) {
|
||||
clearInterval(pollingInterval)
|
||||
@@ -84,39 +97,49 @@
|
||||
</script>
|
||||
|
||||
<div class="verification-step">
|
||||
<p class="info-text">
|
||||
We've sent a verification code to your {channelLabel(flow.info.verificationChannel)}.
|
||||
Enter it below to continue.
|
||||
</p>
|
||||
{#if isTelegram && telegramBotUsername}
|
||||
{@const handle = flow.account?.handle ?? `${flow.info.handle.trim()}.${flow.state.pdsHostname}`}
|
||||
{@const encodedHandle = handle.replaceAll('.', '_')}
|
||||
<p class="info-text">
|
||||
<a href="https://t.me/{telegramBotUsername}?start={encodedHandle}" target="_blank" rel="noopener">Open Telegram to verify</a>,
|
||||
or send <code>/start {handle}</code> to <code>@{telegramBotUsername}</code> 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)}.
|
||||
Enter it below to continue.
|
||||
</p>
|
||||
|
||||
{#if resendMessage}
|
||||
<div class="message success">{resendMessage}</div>
|
||||
{#if resendMessage}
|
||||
<div class="message success">{resendMessage}</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSubmit}>
|
||||
<div class="field">
|
||||
<label for="verification-code">Verification Code</label>
|
||||
<input
|
||||
id="verification-code"
|
||||
type="text"
|
||||
bind:value={verificationCode}
|
||||
placeholder="XXXX-XXXX-XXXX-XXXX"
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
autocomplete="one-time-code"
|
||||
class="code-input"
|
||||
/>
|
||||
<span class="hint">Copy the entire code from your message, including dashes.</span>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={flow.state.submitting || !verificationCode.trim()}>
|
||||
{flow.state.submitting ? 'Verifying...' : 'Verify'}
|
||||
</button>
|
||||
|
||||
<button type="button" class="secondary" onclick={handleResend} disabled={resending}>
|
||||
{resending ? 'Resending...' : 'Resend Code'}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSubmit}>
|
||||
<div class="field">
|
||||
<label for="verification-code">Verification Code</label>
|
||||
<input
|
||||
id="verification-code"
|
||||
type="text"
|
||||
bind:value={verificationCode}
|
||||
placeholder="XXXX-XXXX-XXXX-XXXX"
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
autocomplete="one-time-code"
|
||||
class="code-input"
|
||||
/>
|
||||
<span class="hint">Copy the entire code from your message, including dashes.</span>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={flow.state.submitting || !verificationCode.trim()}>
|
||||
{flow.state.submitting ? 'Verifying...' : 'Verify'}
|
||||
</button>
|
||||
|
||||
<button type="button" class="secondary" onclick={handleResend} disabled={resending}>
|
||||
{resending ? 'Resending...' : 'Resend Code'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -131,6 +154,17 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.info-text.waiting {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.info-text code {
|
||||
font-family: var(--font-mono, monospace);
|
||||
background: var(--bg-secondary);
|
||||
padding: 0.1em 0.3em;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.code-input {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: var(--text-base);
|
||||
|
||||
@@ -421,7 +421,10 @@ export function createRegistrationFlow(
|
||||
|
||||
checkingVerification = true;
|
||||
try {
|
||||
const result = await api.checkEmailVerified(state.account.did);
|
||||
const result = await api.checkChannelVerified(
|
||||
state.account.did,
|
||||
state.info.verificationChannel,
|
||||
);
|
||||
if (!result.verified) return false;
|
||||
|
||||
if (state.info.didType === "web-external") {
|
||||
|
||||
@@ -232,6 +232,12 @@ export interface ServerDescription {
|
||||
version?: string;
|
||||
availableCommsChannels?: VerificationChannel[];
|
||||
selfHostedDidWebEnabled?: boolean;
|
||||
telegramBotUsername?: string;
|
||||
}
|
||||
|
||||
export interface UpdateNotificationPrefsResponse {
|
||||
success: boolean;
|
||||
verificationRequired: string[];
|
||||
}
|
||||
|
||||
export interface RepoInfo {
|
||||
|
||||
@@ -439,7 +439,9 @@
|
||||
"noMessages": "No messages found.",
|
||||
"discordInUseWarning": "This Discord ID 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 number is already associated with another account.",
|
||||
"telegramStartBot": "Or send /start {handle} to @{botUsername} manually",
|
||||
"telegramOpenLink": "Open Telegram to verify"
|
||||
},
|
||||
"repoExplorer": {
|
||||
"collections": "Collections",
|
||||
|
||||
@@ -436,6 +436,8 @@
|
||||
"discordInUseWarning": "Tämä Discord-tunnus 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.",
|
||||
"telegramStartBot": "Tai lähetä /start {handle} käyttäjälle @{botUsername} manuaalisesti",
|
||||
"telegramOpenLink": "Avaa Telegram vahvistaaksesi",
|
||||
"failedToLoad": "Asetusten lataus epäonnistui",
|
||||
"failedToSave": "Asetusten tallennus epäonnistui",
|
||||
"failedToVerify": "Vahvistus epäonnistui",
|
||||
|
||||
@@ -436,6 +436,8 @@
|
||||
"discordInUseWarning": "この Discord ID は既に別のアカウントに関連付けられています。",
|
||||
"telegramInUseWarning": "この Telegram ユーザー名は既に別のアカウントに関連付けられています。",
|
||||
"signalInUseWarning": "この Signal 番号は既に別のアカウントに関連付けられています。",
|
||||
"telegramStartBot": "または @{botUsername} に /start {handle} を手動で送信",
|
||||
"telegramOpenLink": "Telegram で確認する",
|
||||
"failedToLoad": "設定の読み込みに失敗しました",
|
||||
"failedToSave": "設定の保存に失敗しました",
|
||||
"failedToVerify": "確認に失敗しました",
|
||||
|
||||
@@ -436,6 +436,8 @@
|
||||
"discordInUseWarning": "이 Discord ID는 이미 다른 계정과 연결되어 있습니다.",
|
||||
"telegramInUseWarning": "이 Telegram 사용자 이름은 이미 다른 계정과 연결되어 있습니다.",
|
||||
"signalInUseWarning": "이 Signal 번호는 이미 다른 계정과 연결되어 있습니다.",
|
||||
"telegramStartBot": "또는 @{botUsername}에게 /start {handle}을 직접 보내세요",
|
||||
"telegramOpenLink": "Telegram에서 인증하기",
|
||||
"failedToLoad": "설정 로딩 실패",
|
||||
"failedToSave": "설정 저장 실패",
|
||||
"failedToVerify": "인증 실패",
|
||||
|
||||
@@ -436,6 +436,8 @@
|
||||
"discordInUseWarning": "Detta Discord-ID ä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.",
|
||||
"telegramStartBot": "Eller skicka /start {handle} till @{botUsername} manuellt",
|
||||
"telegramOpenLink": "Öppna Telegram för att verifiera",
|
||||
"failedToLoad": "Kunde inte ladda inställningar",
|
||||
"failedToSave": "Kunde inte spara inställningar",
|
||||
"failedToVerify": "Verifiering misslyckades",
|
||||
|
||||
@@ -436,6 +436,8 @@
|
||||
"discordInUseWarning": "此 Discord ID 已与另一个账户关联。",
|
||||
"telegramInUseWarning": "此 Telegram 用户名已与另一个账户关联。",
|
||||
"signalInUseWarning": "此 Signal 号码已与另一个账户关联。",
|
||||
"telegramStartBot": "或手动向 @{botUsername} 发送 /start {handle}",
|
||||
"telegramOpenLink": "打开 Telegram 验证",
|
||||
"failedToLoad": "加载偏好设置失败",
|
||||
"failedToSave": "保存偏好设置失败",
|
||||
"failedToVerify": "验证失败",
|
||||
|
||||
@@ -99,13 +99,12 @@
|
||||
inviteCodeRequired: data.inviteCodeRequired ?? false,
|
||||
selfHostedDidWebEnabled: data.selfHostedDidWebEnabled ?? false,
|
||||
}
|
||||
if (data.commsChannels) {
|
||||
commsChannels = {
|
||||
email: data.commsChannels.email ?? true,
|
||||
discord: data.commsChannels.discord ?? false,
|
||||
telegram: data.commsChannels.telegram ?? false,
|
||||
signal: data.commsChannels.signal ?? false,
|
||||
}
|
||||
const available: string[] = data.availableCommsChannels ?? ['email']
|
||||
commsChannels = {
|
||||
email: available.includes('email'),
|
||||
discord: available.includes('discord'),
|
||||
telegram: available.includes('telegram'),
|
||||
signal: available.includes('signal'),
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -114,13 +114,12 @@
|
||||
inviteCodeRequired: data.inviteCodeRequired ?? false,
|
||||
selfHostedDidWebEnabled: data.selfHostedDidWebEnabled ?? false,
|
||||
}
|
||||
if (data.commsChannels) {
|
||||
commsChannels = {
|
||||
email: data.commsChannels.email ?? true,
|
||||
discord: data.commsChannels.discord ?? false,
|
||||
telegram: data.commsChannels.telegram ?? false,
|
||||
signal: data.commsChannels.signal ?? false,
|
||||
}
|
||||
const available: string[] = data.availableCommsChannels ?? ['email']
|
||||
commsChannels = {
|
||||
email: available.includes('email'),
|
||||
discord: available.includes('discord'),
|
||||
telegram: available.includes('telegram'),
|
||||
signal: available.includes('signal'),
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
let successChannel = $state<string | null>(null)
|
||||
let tokenFromUrl = $state(false)
|
||||
let oauthRequestUri = $state<string | null>(null)
|
||||
let telegramBotUsername = $state<string | undefined>(undefined)
|
||||
|
||||
const auth = $derived(getAuthState())
|
||||
|
||||
@@ -40,6 +41,7 @@
|
||||
}
|
||||
|
||||
const session = $derived(getSession())
|
||||
const isTelegram = $derived(pendingVerification?.channel === 'telegram')
|
||||
|
||||
function parseQueryParams(): Record<string, string> {
|
||||
return Object.fromEntries(new URLSearchParams(window.location.search))
|
||||
@@ -99,6 +101,14 @@
|
||||
channel: params.channel,
|
||||
}))
|
||||
}
|
||||
|
||||
if (pendingVerification?.channel === 'telegram') {
|
||||
try {
|
||||
const serverInfo = await api.describeServer()
|
||||
telegramBotUsername = serverInfo.telegramBotUsername
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -111,13 +121,13 @@
|
||||
|
||||
let pollingVerification = false
|
||||
$effect(() => {
|
||||
if (mode === 'signup' && pendingVerification && !verificationCode.trim()) {
|
||||
if (mode === 'signup' && pendingVerification && (isTelegram || !verificationCode.trim())) {
|
||||
const currentPending = pendingVerification
|
||||
const interval = setInterval(async () => {
|
||||
if (pollingVerification || verificationCode.trim()) return
|
||||
if (pollingVerification || (!isTelegram && verificationCode.trim())) return
|
||||
pollingVerification = true
|
||||
try {
|
||||
const result = await api.checkEmailVerified(currentPending.did)
|
||||
const result = await api.checkChannelVerified(currentPending.did, currentPending.channel)
|
||||
if (result.verified) {
|
||||
clearInterval(interval)
|
||||
clearPendingVerification()
|
||||
@@ -435,31 +445,44 @@
|
||||
<div class="message success">{resendMessage}</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSignupVerification(e); }}>
|
||||
<div class="field">
|
||||
<label for="verification-code">{$_('verify.codeLabel')}</label>
|
||||
<input
|
||||
id="verification-code"
|
||||
type="text"
|
||||
bind:value={verificationCode}
|
||||
placeholder={$_('verify.codePlaceholder')}
|
||||
disabled={submitting}
|
||||
required
|
||||
autocomplete="off"
|
||||
class="token-input"
|
||||
/>
|
||||
<p class="field-help">{$_('verify.codeHelp')}</p>
|
||||
{#if isTelegram && telegramBotUsername}
|
||||
{@const encodedHandle = pendingVerification.handle.replaceAll('.', '_')}
|
||||
<div class="telegram-hint">
|
||||
<p>
|
||||
<a href="https://t.me/{telegramBotUsername}?start={encodedHandle}" target="_blank" rel="noopener">{$_('comms.telegramOpenLink')}</a>
|
||||
</p>
|
||||
<p class="manual-text">
|
||||
{$_('comms.telegramStartBot', { values: { botUsername: telegramBotUsername, handle: pendingVerification.handle } })}
|
||||
</p>
|
||||
<p class="waiting-text">{$_('verify.pleaseWait')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSignupVerification(e); }}>
|
||||
<div class="field">
|
||||
<label for="verification-code">{$_('verify.codeLabel')}</label>
|
||||
<input
|
||||
id="verification-code"
|
||||
type="text"
|
||||
bind:value={verificationCode}
|
||||
placeholder={$_('verify.codePlaceholder')}
|
||||
disabled={submitting}
|
||||
required
|
||||
autocomplete="off"
|
||||
class="token-input"
|
||||
/>
|
||||
<p class="field-help">{$_('verify.codeHelp')}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}>
|
||||
{resendingCode ? $_('common.sending') : $_('common.resendCode')}
|
||||
</button>
|
||||
<button type="submit" disabled={submitting || !verificationCode.trim()}>
|
||||
{submitting ? $_('common.verifying') : $_('common.verify')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}>
|
||||
{resendingCode ? $_('common.sending') : $_('common.resendCode')}
|
||||
</button>
|
||||
<button type="submit" disabled={submitting || !verificationCode.trim()}>
|
||||
{submitting ? $_('common.verifying') : $_('common.verify')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<p class="link-text">
|
||||
<a href="/app/register" onclick={() => clearPendingVerification()}>{$_('verify.startOver')}</a>
|
||||
@@ -586,4 +609,26 @@
|
||||
flex: none;
|
||||
padding: var(--space-4) var(--space-8);
|
||||
}
|
||||
|
||||
.telegram-hint {
|
||||
padding: var(--space-4);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.telegram-hint p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.telegram-hint .manual-text {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.telegram-hint .waiting-text {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE users ADD COLUMN telegram_chat_id BIGINT;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TYPE comms_type ADD VALUE IF NOT EXISTS 'channel_verified';
|
||||
@@ -122,6 +122,15 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /webhook/ {
|
||||
proxy_pass http://backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /metrics {
|
||||
proxy_pass http://backend;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -18,4 +18,5 @@ sqlx migrate run --source "$PROJECT_DIR/migrations"
|
||||
echo ""
|
||||
echo "Running tests..."
|
||||
echo ""
|
||||
ulimit -n 65536
|
||||
cargo nextest run "$@"
|
||||
|
||||
Reference in New Issue
Block a user