From 3a4bc23a5883387dd88e445cf32c14e4384d00a2 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Tue, 30 Jun 2026 18:36:53 +0300 Subject: [PATCH] Added telegram bot --- Cargo.lock | 4 + Cargo.toml | 6 +- README.md | 18 +++ src/admin/views.rs | 98 ++++++++++++- src/api/mod.rs | 268 ++++++++++++++++++++++++++++++++++- src/config.rs | 21 +++ src/i18n/phrases.rs | 21 +++ src/main.rs | 6 + src/telegram.rs | 212 +++++++++++++++++++++++++++ src/user.rs | 64 +++++++++ templates/admin/app.html | 53 ++++--- templates/client_portal.html | 163 +++++++++++++++++++++ 12 files changed, 915 insertions(+), 19 deletions(-) create mode 100644 src/telegram.rs diff --git a/Cargo.lock b/Cargo.lock index fd5c653..12cf73a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -67,6 +67,8 @@ dependencies = [ "cot", "curve25519-dalek", "getrandom 0.3.4", + "hex", + "hmac", "k8s-openapi", "kube", "miniz_oxide", @@ -76,9 +78,11 @@ dependencies = [ "schemars 0.9.0", "serde", "serde_json", + "sha2", "tokio", "tracing", "tracing-subscriber", + "url", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 33386a6..960805d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "amnezia-fellow" -version = "0.1.3" +version = "0.1.4" edition = "2024" description = "Amnezia VPN client manager with SSO, SQLite, and Kubernetes Secret sync" @@ -19,5 +19,9 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } curve25519-dalek = "4.1" getrandom = "0.3" +hex = "0.4" +hmac = "0.12" kube = { version = "3.1.0", default-features = false, features = ["client", "rustls-tls", "ring"] } k8s-openapi = { version = "0.27.1", features = ["v1_32"] } +sha2 = "0.10" +url = "2" diff --git a/README.md b/README.md index 91d6cf2..aad44ae 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,24 @@ All settings use `AMNEZIA_FELLOW_` prefix. Priority is: | `AMNEZIA_FELLOW_VPN_DNS` | DNS servers in generated configs | `1.1.1.1, 8.8.8.8` | | `AMNEZIA_FELLOW_VPN_MTU` | MTU in generated configs | `1376` | | `AMNEZIA_FELLOW_SWAGGER_ENABLED` | Serve Swagger UI at `/swagger/` | `false` | +| `AMNEZIA_FELLOW_TELEGRAM_BOT_ENABLED` | Enable Telegram bot/Web App integration | `false` | +| `AMNEZIA_FELLOW_TELEGRAM_BOT_USERNAME` | Telegram bot username, with or without `@` | empty | +| `AMNEZIA_FELLOW_TELEGRAM_BOT_TOKEN` | Telegram bot token used to verify Web App `initData` | empty | + +## Telegram Bot + +Configure the bot through the admin Settings page or the matching environment variables: + +```bash +AMNEZIA_FELLOW_TELEGRAM_BOT_ENABLED=true +AMNEZIA_FELLOW_TELEGRAM_BOT_USERNAME= +AMNEZIA_FELLOW_TELEGRAM_BOT_TOKEN= +``` + +In BotFather, create or reuse the same bot and set its Web App URL to +`https:///configs`. The bot should reply with the sender's numeric +Telegram ID, and users must open the bot and send `/start` once before +notifications can be delivered. ## API diff --git a/src/admin/views.rs b/src/admin/views.rs index 8376671..a2a660a 100644 --- a/src/admin/views.rs +++ b/src/admin/views.rs @@ -180,6 +180,21 @@ fn config_display_entries(config: &AppConfig, sources: &ConfigSources) -> Vec Vec String { + let username = config + .telegram_bot_username + .trim() + .trim_start_matches('@') + .trim(); + let username_value = if username.is_empty() { + "" + } else { + username + }; + let token_value = if config.telegram_bot_token.trim().is_empty() { + "" + } else { + "" + }; + let bot_link = if username.is_empty() { + "https://t.me/".to_owned() + } else { + format!("https://t.me/{username}") + }; + let current_bot = if username.is_empty() { + "".to_owned() + } else if config.telegram_bot_enabled { + format!("@{username}") + } else { + format!("@{username} (disabled)") + }; + + format!( + "Current bot: {current_bot}\n\ + Bot link: {bot_link}\n\n\ + Config:\n\ + AMNEZIA_FELLOW_TELEGRAM_BOT_ENABLED=true\n\ + AMNEZIA_FELLOW_TELEGRAM_BOT_USERNAME={username_value}\n\ + AMNEZIA_FELLOW_TELEGRAM_BOT_TOKEN={token_value}\n\n\ + BotFather:\n\ + 1. Create or reuse this bot and set the Web App URL to https:///configs.\n\ + 2. Make the bot reply with the sender numeric Telegram ID.\n\ + 3. Users must open the bot and send /start once before notifications can work." + ) +} + async fn save_settings_request(db: &Database, data: &AdminSettingsRequest) -> cot::Result<()> { let vpn_mtu = data.vpn_mtu.to_string(); let auth_password_enabled = data.auth_password_enabled.to_string(); let auth_sso_enabled = data.auth_sso_enabled.to_string(); let swagger_enabled = data.swagger_enabled.to_string(); - let fields: [(&str, &str); 19] = [ + let telegram_bot_enabled = data.telegram_bot_enabled.to_string(); + let fields: [(&str, &str); 22] = [ ("auth_password_enabled", &auth_password_enabled), ("auth_sso_enabled", &auth_sso_enabled), ("oidc_button_text", &data.oidc_button_text), @@ -780,6 +873,9 @@ async fn save_settings_request(db: &Database, data: &AdminSettingsRequest) -> co ("vpn_dns", &data.vpn_dns), ("vpn_mtu", &vpn_mtu), ("swagger_enabled", &swagger_enabled), + ("telegram_bot_enabled", &telegram_bot_enabled), + ("telegram_bot_username", &data.telegram_bot_username), + ("telegram_bot_token", &data.telegram_bot_token), ]; for (key, value) in fields { let mut entry = ConfigEntry::new(key.to_owned(), value.to_owned()); diff --git a/src/api/mod.rs b/src/api/mod.rs index b6b4590..704c6ed 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::time::Duration; use cot::db::Database; use cot::json::Json; @@ -15,7 +16,7 @@ use serde::{Deserialize, Serialize}; use crate::config::AppConfig; use crate::user::User; -use crate::{auth, vpn}; +use crate::{auth, telegram, vpn}; // --------------------------------------------------------------------------- // JSON error helper @@ -143,6 +144,33 @@ struct ClientPath { id: i64, } +#[derive(Debug, Serialize, JsonSchema)] +struct TelegramLinkStatusResponse { + enabled: bool, + bot_username: String, + bot_url: String, + linked: bool, + declined: bool, + telegram_id: Option, +} + +#[derive(Debug, Serialize, JsonSchema)] +struct TelegramLinkResponse { + status: TelegramLinkStatusResponse, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct TelegramWebAppLinkRequest { + init_data: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct TelegramManualLinkRequest { + telegram_id: String, +} + +const TELEGRAM_INIT_DATA_MAX_AGE: Duration = Duration::from_secs(86_400); + async fn vpn_clients_handler( session: Session, db: Database, @@ -206,6 +234,129 @@ async fn vpn_status_handler( Json(status).into_response() } +async fn telegram_link_status_handler( + session: Session, + db: Database, +) -> cot::Result { + let user = match api_user_record(&session, &db).await? { + Ok(user) => user, + Err(response) => return Ok(response), + }; + let (config, _) = AppConfig::load_with_db(&db).await; + + Json(telegram_status_response(&config, &user)).into_response() +} + +async fn telegram_link_webapp_handler( + session: Session, + db: Database, + Json(request): Json, +) -> cot::Result { + let mut user = match api_user_record(&session, &db).await? { + Ok(user) => user, + Err(response) => return Ok(response), + }; + let (config, _) = AppConfig::load_with_db(&db).await; + if !config.telegram_bot_enabled { + return Ok(json_error_typed( + cot::http::StatusCode::CONFLICT, + "telegram_disabled", + "Telegram is disabled", + "Telegram integration is disabled by the administrator.", + "", + )); + } + + let telegram_user = match telegram::validate_web_app_init_data( + &request.init_data, + &config.telegram_bot_token, + TELEGRAM_INIT_DATA_MAX_AGE, + ) { + Ok(user) => user, + Err(e) => { + return Ok(json_error_typed( + cot::http::StatusCode::BAD_REQUEST, + "telegram_webapp_auth_failed", + "Telegram verification failed", + "Could not verify Telegram Web App data.", + &e.to_string(), + )); + } + }; + let telegram_id = telegram_user.id.to_string(); + if let Some(response) = link_telegram_id(&db, &mut user, &telegram_id).await? { + return Ok(response); + } + + Json(TelegramLinkResponse { + status: telegram_status_response(&config, &user), + }) + .into_response() +} + +async fn telegram_link_manual_handler( + session: Session, + db: Database, + Json(request): Json, +) -> cot::Result { + let mut user = match api_user_record(&session, &db).await? { + Ok(user) => user, + Err(response) => return Ok(response), + }; + let (config, _) = AppConfig::load_with_db(&db).await; + if !config.telegram_bot_enabled { + return Ok(json_error_typed( + cot::http::StatusCode::CONFLICT, + "telegram_disabled", + "Telegram is disabled", + "Telegram integration is disabled by the administrator.", + "", + )); + } + + let telegram_id = match normalize_manual_telegram_id(&request.telegram_id) { + Ok(value) => value, + Err(response) => return Ok(response), + }; + if let Some(response) = link_telegram_id(&db, &mut user, &telegram_id).await? { + return Ok(response); + } + + Json(TelegramLinkResponse { + status: telegram_status_response(&config, &user), + }) + .into_response() +} + +async fn telegram_link_decline_handler( + session: Session, + db: Database, +) -> cot::Result { + let mut user = match api_user_record(&session, &db).await? { + Ok(user) => user, + Err(response) => return Ok(response), + }; + let (config, _) = AppConfig::load_with_db(&db).await; + if !config.telegram_bot_enabled { + return Ok(json_error_typed( + cot::http::StatusCode::CONFLICT, + "telegram_disabled", + "Telegram is disabled", + "Telegram integration is disabled by the administrator.", + "", + )); + } + + user.set_telegram_id(&db, Some("")) + .await + .map_err(|e| cot::Error::internal(format!("failed to save Telegram preference: {e}")))?; + + Json(TelegramLinkResponse { + status: telegram_status_response(&config, &user), + }) + .into_response() +} + async fn create_vpn_client_handler( session: Session, db: Database, @@ -446,6 +597,101 @@ fn client_view_with_owner( view } +async fn api_user_record( + session: &Session, + db: &Database, +) -> cot::Result> { + let Some(auth_user) = auth::get_session_user(session, db).await else { + return Ok(Err(json_error( + cot::http::StatusCode::UNAUTHORIZED, + "not authenticated", + ))); + }; + let Some(user) = User::get_by_id(db, auth_user.id) + .await + .map_err(|e| cot::Error::internal(format!("failed to load user: {e}")))? + else { + return Ok(Err(json_error( + cot::http::StatusCode::UNAUTHORIZED, + "not authenticated", + ))); + }; + Ok(Ok(user)) +} + +fn telegram_status_response(config: &AppConfig, user: &User) -> TelegramLinkStatusResponse { + let bot_username = normalized_telegram_bot_username(&config.telegram_bot_username); + let telegram_id = user.telegram_id().map(str::to_owned); + let linked = telegram_id + .as_deref() + .is_some_and(|value| !value.is_empty()); + let declined = telegram_id.as_deref() == Some(""); + + TelegramLinkStatusResponse { + enabled: config.telegram_bot_enabled, + bot_url: telegram_bot_url(&bot_username), + bot_username, + linked, + declined, + telegram_id: linked.then_some(telegram_id).flatten(), + } +} + +fn normalized_telegram_bot_username(username: &str) -> String { + username.trim().trim_start_matches('@').trim().to_owned() +} + +fn telegram_bot_url(username: &str) -> String { + if username.is_empty() { + String::new() + } else { + format!("https://t.me/{username}") + } +} + +fn normalize_manual_telegram_id(telegram_id: &str) -> Result { + let telegram_id = telegram_id.trim(); + if telegram_id.is_empty() + || telegram_id.len() > 32 + || !telegram_id.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err(json_error_typed( + cot::http::StatusCode::BAD_REQUEST, + "telegram_id_invalid", + "Telegram ID is invalid", + "Paste the numeric Telegram ID returned by the bot.", + "", + )); + } + Ok(telegram_id.to_owned()) +} + +async fn link_telegram_id( + db: &Database, + user: &mut User, + telegram_id: &str, +) -> cot::Result> { + if let Some(existing) = User::get_by_telegram_id(db, telegram_id) + .await + .map_err(|e| cot::Error::internal(format!("failed to check Telegram ID: {e}")))? + { + if existing.id_val() != user.id_val() { + return Ok(Some(json_error_typed( + cot::http::StatusCode::CONFLICT, + "telegram_id_taken", + "Telegram ID is already linked", + "This Telegram ID is already connected to another account.", + "", + ))); + } + } + + user.set_telegram_id(db, Some(telegram_id)) + .await + .map_err(|e| cot::Error::internal(format!("failed to save Telegram ID: {e}")))?; + Ok(None) +} + async fn sync_after_client_mutation( db: &Database, config: &AppConfig, @@ -541,6 +787,26 @@ impl App for ApiApp { api_get(vpn_status_handler), "api_vpn_status", ), + Route::with_api_handler_and_name( + "/telegram-link/status", + api_get(telegram_link_status_handler), + "api_telegram_link_status", + ), + Route::with_api_handler_and_name( + "/telegram-link/webapp", + api_post(telegram_link_webapp_handler), + "api_telegram_link_webapp", + ), + Route::with_api_handler_and_name( + "/telegram-link/manual", + api_post(telegram_link_manual_handler), + "api_telegram_link_manual", + ), + Route::with_api_handler_and_name( + "/telegram-link/decline", + api_post(telegram_link_decline_handler), + "api_telegram_link_decline", + ), Route::with_api_handler_and_name( "/vpn-clients/sync", api_post(sync_vpn_clients_handler), diff --git a/src/config.rs b/src/config.rs index 24959d0..f2e713d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -107,6 +107,9 @@ pub struct ConfigSources { pub vpn_dns: ConfigSource, pub vpn_mtu: ConfigSource, pub swagger_enabled: ConfigSource, + pub telegram_bot_enabled: ConfigSource, + pub telegram_bot_username: ConfigSource, + pub telegram_bot_token: ConfigSource, } impl Default for ConfigSources { @@ -133,6 +136,9 @@ impl Default for ConfigSources { vpn_dns: ConfigSource::Default, vpn_mtu: ConfigSource::Default, swagger_enabled: ConfigSource::Default, + telegram_bot_enabled: ConfigSource::Default, + telegram_bot_username: ConfigSource::Default, + telegram_bot_token: ConfigSource::Default, } } } @@ -230,6 +236,12 @@ pub struct AppConfig { pub vpn_mtu: u16, /// Whether the Swagger UI is served at /swagger/. pub swagger_enabled: bool, + /// Whether Telegram bot/Web App integration is enabled. + pub telegram_bot_enabled: bool, + /// Public bot username, without or with the leading @. + pub telegram_bot_username: String, + /// Bot token used to verify Telegram Web App initData. + pub telegram_bot_token: String, } impl Default for AppConfig { @@ -256,6 +268,9 @@ impl Default for AppConfig { vpn_dns: "1.1.1.1, 8.8.8.8".into(), vpn_mtu: 1376, swagger_enabled: false, + telegram_bot_enabled: false, + telegram_bot_username: String::new(), + telegram_bot_token: String::new(), } } } @@ -282,6 +297,9 @@ impl_env_overrides!( vpn_dns, vpn_mtu, swagger_enabled, + telegram_bot_enabled, + telegram_bot_username, + telegram_bot_token, ); impl AppConfig { @@ -357,6 +375,9 @@ impl AppConfig { apply_db_field!(vpn_dns); apply_db_field!(vpn_mtu); apply_db_field!(swagger_enabled); + apply_db_field!(telegram_bot_enabled); + apply_db_field!(telegram_bot_username); + apply_db_field!(telegram_bot_token); } } diff --git a/src/i18n/phrases.rs b/src/i18n/phrases.rs index 5010597..d4960a6 100644 --- a/src/i18n/phrases.rs +++ b/src/i18n/phrases.rs @@ -61,6 +61,11 @@ translations! { // Kubernetes / VPN settings settings_kubernetes: "Kubernetes" , "Kubernetes"; settings_vpn: "VPN" , "VPN"; + settings_telegram: "Telegram" , "Telegram"; + settings_telegram_enabled: "Telegram bot enabled" , "Telegram-бот включён"; + settings_telegram_username: "Bot username" , "Username бота"; + settings_telegram_token: "Bot token" , "Токен бота"; + settings_telegram_setup_instructions: "Bot setup guide" , "Инструкция настройки бота"; // User management nav_users: "Users" , "Пользователи"; @@ -155,6 +160,22 @@ translations! { notice_server_list_error_title: "Server list unavailable" , "Список серверов недоступен"; notice_server_list_error_message: "Could not load VPN servers." , "Не удалось загрузить VPN-серверы."; notice_detail: "Detail" , "Детали"; + telegram_link_title: "Connect Telegram" , "Подключить Telegram"; + telegram_webapp_message: "Save this Telegram account for VPN notifications and bot control." , "Сохранить этот Telegram-аккаунт для уведомлений и управления через бота."; + telegram_manual_message: "You can connect Telegram once and manage VPN keys from the bot." , "Можно один раз подключить Telegram и управлять VPN-ключами через бота."; + telegram_connect: "Connect" , "Подключить"; + telegram_skip: "Do not ask again" , "Больше не предлагать"; + telegram_guide_title: "How to get your Telegram ID" , "Как узнать Telegram ID"; + telegram_open_bot: "Open bot" , "Открыть бота"; + telegram_guide_start: "Open the bot and send /start once." , "Откройте бота и один раз отправьте /start."; + telegram_guide_get_id: "Ask the bot for your ID; it will reply with only the number." , "Напишите боту, он ответит только номером вашего ID."; + telegram_guide_paste: "Paste that number here and save it." , "Вставьте этот номер сюда и сохраните."; + telegram_id_label: "Telegram ID" , "Telegram ID"; + telegram_id_placeholder: "Only digits" , "Только цифры"; + telegram_save: "Save Telegram" , "Сохранить Telegram"; + telegram_saved: "Telegram connected." , "Telegram подключён."; + telegram_declined: "Telegram prompt disabled." , "Предложение Telegram отключено."; + telegram_bot_unconfigured: "bot is not configured" , "бот не настроен"; // VPN server management servers_empty: "No registered servers." , "Нет зарегистрированных серверов."; diff --git a/src/main.rs b/src/main.rs index 6aa15f3..5a7ebf6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod auth; mod config; mod i18n; mod oidc; +mod telegram; mod user; mod vpn; @@ -319,6 +320,11 @@ impl Project for AmneziaFellowProject { " API:\n", " AMNEZIA_FELLOW_SWAGGER_ENABLED Enable Swagger UI at /swagger/ (default: false)\n", "\n", + " Telegram:\n", + " AMNEZIA_FELLOW_TELEGRAM_BOT_ENABLED Enable Telegram bot/Web App integration (default: false)\n", + " AMNEZIA_FELLOW_TELEGRAM_BOT_USERNAME Telegram bot username, with or without @\n", + " AMNEZIA_FELLOW_TELEGRAM_BOT_TOKEN Telegram bot token for Web App initData verification\n", + "\n", "QUICK START\n", " export AMNEZIA_FELLOW_DATABASE_URL=sqlite://amnezia-fellow.sqlite3?mode=rwc\n", " amnezia-fellow --listen 127.0.0.1:8000", diff --git a/src/telegram.rs b/src/telegram.rs new file mode 100644 index 0000000..83e3ea0 --- /dev/null +++ b/src/telegram.rs @@ -0,0 +1,212 @@ +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use hmac::{Hmac, Mac}; +use serde::Deserialize; +use sha2::Sha256; +use url::form_urlencoded; + +type HmacSha256 = Hmac; + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct TelegramWebAppUser { + pub id: i64, + pub username: Option, + pub first_name: Option, + pub last_name: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TelegramAuthError { + MissingHash, + MissingAuthDate, + MissingUser, + InvalidHash, + InvalidSignature, + InvalidAuthDate, + Expired, + FromFuture, + InvalidUser, + EmptyBotToken, +} + +impl std::fmt::Display for TelegramAuthError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let message = match self { + Self::MissingHash => "Telegram initData is missing hash", + Self::MissingAuthDate => "Telegram initData is missing auth_date", + Self::MissingUser => "Telegram initData is missing user", + Self::InvalidHash => "Telegram initData hash is invalid", + Self::InvalidSignature => "Telegram initData signature does not match bot token", + Self::InvalidAuthDate => "Telegram initData auth_date is invalid", + Self::Expired => "Telegram initData is too old", + Self::FromFuture => "Telegram initData auth_date is in the future", + Self::InvalidUser => "Telegram initData user payload is invalid", + Self::EmptyBotToken => "Telegram bot token is not configured", + }; + f.write_str(message) + } +} + +impl std::error::Error for TelegramAuthError {} + +pub fn validate_web_app_init_data( + init_data: &str, + bot_token: &str, + max_age: Duration, +) -> Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| TelegramAuthError::InvalidAuthDate)? + .as_secs(); + validate_web_app_init_data_at(init_data, bot_token, max_age, now) +} + +fn validate_web_app_init_data_at( + init_data: &str, + bot_token: &str, + max_age: Duration, + now: u64, +) -> Result { + if bot_token.trim().is_empty() { + return Err(TelegramAuthError::EmptyBotToken); + } + + let mut params = form_urlencoded::parse(init_data.as_bytes()) + .into_owned() + .collect::>(); + let hash = params + .iter() + .find_map(|(key, value)| (key == "hash").then_some(value.as_str())) + .ok_or(TelegramAuthError::MissingHash)?; + let expected_hash = hex::decode(hash).map_err(|_| TelegramAuthError::InvalidHash)?; + + params.retain(|(key, _)| key != "hash"); + params.sort_by(|left, right| left.0.cmp(&right.0)); + let data_check_string = params + .iter() + .map(|(key, value)| format!("{key}={value}")) + .collect::>() + .join("\n"); + + verify_signature(bot_token, &data_check_string, &expected_hash)?; + + let auth_date = params + .iter() + .find_map(|(key, value)| (key == "auth_date").then_some(value.as_str())) + .ok_or(TelegramAuthError::MissingAuthDate)? + .parse::() + .map_err(|_| TelegramAuthError::InvalidAuthDate)?; + if auth_date > now.saturating_add(300) { + return Err(TelegramAuthError::FromFuture); + } + if now.saturating_sub(auth_date) > max_age.as_secs() { + return Err(TelegramAuthError::Expired); + } + + let user_json = params + .iter() + .find_map(|(key, value)| (key == "user").then_some(value.as_str())) + .ok_or(TelegramAuthError::MissingUser)?; + serde_json::from_str(user_json).map_err(|_| TelegramAuthError::InvalidUser) +} + +fn verify_signature( + bot_token: &str, + data_check_string: &str, + expected_hash: &[u8], +) -> Result<(), TelegramAuthError> { + let mut secret_mac = + HmacSha256::new_from_slice(b"WebAppData").expect("HMAC accepts any key length"); + secret_mac.update(bot_token.as_bytes()); + let secret_key = secret_mac.finalize().into_bytes(); + + let mut data_mac = + HmacSha256::new_from_slice(&secret_key).expect("HMAC accepts any key length"); + data_mac.update(data_check_string.as_bytes()); + data_mac + .verify_slice(expected_hash) + .map_err(|_| TelegramAuthError::InvalidSignature) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn signed_init_data(bot_token: &str, auth_date: u64, user_json: &str) -> String { + let data_check_string = + format!("auth_date={auth_date}\nquery_id=AAEAAAE\nuser={user_json}"); + + let mut secret_mac = + HmacSha256::new_from_slice(b"WebAppData").expect("HMAC accepts any key length"); + secret_mac.update(bot_token.as_bytes()); + let secret_key = secret_mac.finalize().into_bytes(); + + let mut data_mac = + HmacSha256::new_from_slice(&secret_key).expect("HMAC accepts any key length"); + data_mac.update(data_check_string.as_bytes()); + let hash = hex::encode(data_mac.finalize().into_bytes()); + + let mut serializer = form_urlencoded::Serializer::new(String::new()); + serializer.append_pair("query_id", "AAEAAAE"); + serializer.append_pair("user", user_json); + serializer.append_pair("auth_date", &auth_date.to_string()); + serializer.append_pair("hash", &hash); + serializer.finish() + } + + #[test] + fn validates_signed_web_app_init_data() { + let token = "123456:fake-token"; + let init_data = signed_init_data( + token, + 1_700_000_000, + r#"{"id":42,"first_name":"Alice","username":"alice"}"#, + ); + + let user = validate_web_app_init_data_at( + &init_data, + token, + Duration::from_secs(86_400), + 1_700_000_001, + ) + .unwrap(); + + assert_eq!(user.id, 42); + assert_eq!(user.username.as_deref(), Some("alice")); + } + + #[test] + fn rejects_wrong_bot_token() { + let init_data = signed_init_data( + "123456:fake-token", + 1_700_000_000, + r#"{"id":42,"first_name":"Alice"}"#, + ); + + let err = validate_web_app_init_data_at( + &init_data, + "123456:other-token", + Duration::from_secs(86_400), + 1_700_000_001, + ) + .unwrap_err(); + + assert_eq!(err, TelegramAuthError::InvalidSignature); + } + + #[test] + fn rejects_expired_init_data() { + let token = "123456:fake-token"; + let init_data = signed_init_data(token, 1_700_000_000, r#"{"id":42}"#); + + let err = validate_web_app_init_data_at( + &init_data, + token, + Duration::from_secs(60), + 1_700_000_061, + ) + .unwrap_err(); + + assert_eq!(err, TelegramAuthError::Expired); + } +} diff --git a/src/user.rs b/src/user.rs index e711692..00ed04a 100644 --- a/src/user.rs +++ b/src/user.rs @@ -17,6 +17,7 @@ pub struct User { email: Option, display_name: Option, avatar_url: Option, + telegram_id: Option, role: LimitedString<32>, is_active: bool, } @@ -53,6 +54,7 @@ impl User { email: email.map(str::to_owned), display_name: display_name.map(str::to_owned), avatar_url: None, + telegram_id: None, role: LimitedString::new(role).unwrap(), is_active: true, }; @@ -75,6 +77,7 @@ impl User { email: email.map(str::to_owned), display_name: display_name.map(str::to_owned), avatar_url: None, + telegram_id: None, role: LimitedString::new(role).unwrap(), is_active: true, }; @@ -117,6 +120,21 @@ impl User { cot::db::query!(User, $email == Some(email)).get(db).await } + /// Find a user linked to a non-empty Telegram ID. + pub async fn get_by_telegram_id( + db: &Database, + telegram_id: &str, + ) -> cot::db::Result> { + let telegram_id = telegram_id.trim(); + if telegram_id.is_empty() { + return Ok(None); + } + let telegram_id = telegram_id.to_owned(); + cot::db::query!(User, $telegram_id == Some(telegram_id)) + .get(db) + .await + } + /// Count all users in the database. pub async fn count_all(db: &Database) -> cot::db::Result { Self::objects().count(db).await @@ -140,6 +158,16 @@ impl User { self.save(db).await } + /// Store a Telegram link state. `Some("")` means the user declined linking. + pub async fn set_telegram_id( + &mut self, + db: &Database, + telegram_id: Option<&str>, + ) -> cot::db::Result<()> { + self.telegram_id = telegram_id.map(str::to_owned); + self.save(db).await + } + /// Delete this user by primary key. pub async fn delete_by_id(db: &Database, user_id: i64) -> cot::db::Result<()> { cot::db::query!(User, $id == Auto::Fixed(user_id)) @@ -165,6 +193,10 @@ impl User { self.display_name.clone().unwrap_or_default() } + pub fn telegram_id(&self) -> Option<&str> { + self.telegram_id.as_deref() + } + pub fn role_str(&self) -> &str { &self.role } @@ -391,9 +423,41 @@ pub mod db_migrations { &[Operation::custom(create_oidc_link_indexes).build()]; } + // -- M0007: Telegram link state on amnezia_fellow__user ---------------- + + #[cot::db::migrations::migration_op] + async fn add_user_telegram_id(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> { + ctx.db + .raw("ALTER TABLE amnezia_fellow__user ADD COLUMN telegram_id TEXT") + .await?; + ctx.db + .raw( + "CREATE UNIQUE INDEX idx_amnezia_fellow_user_telegram_id \ + ON amnezia_fellow__user (telegram_id) \ + WHERE telegram_id IS NOT NULL AND telegram_id != ''", + ) + .await?; + Ok(()) + } + + #[derive(Debug, Copy, Clone)] + pub struct M0007UserTelegramId; + + impl migrations::Migration for M0007UserTelegramId { + const APP_NAME: &'static str = "amnezia_fellow"; + const MIGRATION_NAME: &'static str = "m_0007_user_telegram_id"; + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( + "amnezia_fellow", + "m_0004_oidc_link_indexes", + )]; + const OPERATIONS: &'static [Operation] = &[Operation::custom(add_user_telegram_id).build()]; + } + pub const MIGRATIONS: &[&SyncDynMigration] = &[ &M0002CreateUser, &M0003CreateOidcLink, &M0004OidcLinkIndexes, + &M0007UserTelegramId, ]; } diff --git a/templates/admin/app.html b/templates/admin/app.html index c840cca..c784824 100644 --- a/templates/admin/app.html +++ b/templates/admin/app.html @@ -47,6 +47,9 @@ .section-tabs button.active { background: #17202a; color: #fff; border-color: #17202a; } .settings-grid { display: grid; grid-template-columns: minmax(180px, .7fr) minmax(240px, 1fr) auto; gap: .55rem .75rem; align-items: center; background: #fff; border: 1px solid #dde2e6; border-radius: 6px; padding: .75rem; } .settings-row { display: contents; } + .settings-info-row { grid-column: 1 / -1; border: 1px solid #dde2e6; border-left: 4px solid #2d6cdf; border-radius: 6px; background: #f8fafc; padding: .75rem; display: grid; gap: .45rem; } + .settings-info-title { color: #1d2733; font-weight: 750; } + .settings-info-text { margin: 0; color: #34414f; white-space: pre-wrap; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .84rem; line-height: 1.45; } .field-name { font-weight: 650; color: #34414f; overflow-wrap: anywhere; } .field-help { color: #53606d; font-size: .78rem; margin-top: .15rem; overflow-wrap: anywhere; } .badge { display: inline-block; padding: .15rem .55rem; border-radius: 4px; font-size: .8rem; font-weight: 650; } @@ -191,21 +194,33 @@