This commit is contained in:
+97
-1
@@ -180,6 +180,21 @@ fn config_display_entries(config: &AppConfig, sources: &ConfigSources) -> Vec<Co
|
||||
config.swagger_enabled.to_string(),
|
||||
defaults.swagger_enabled.to_string()
|
||||
),
|
||||
entry!(
|
||||
telegram_bot_enabled,
|
||||
config.telegram_bot_enabled.to_string(),
|
||||
defaults.telegram_bot_enabled.to_string()
|
||||
),
|
||||
entry!(
|
||||
telegram_bot_username,
|
||||
config.telegram_bot_username.clone(),
|
||||
defaults.telegram_bot_username.clone()
|
||||
),
|
||||
entry!(
|
||||
telegram_bot_token,
|
||||
config.telegram_bot_token.clone(),
|
||||
defaults.telegram_bot_token.clone()
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -283,6 +298,9 @@ pub struct AdminSettingsRequest {
|
||||
vpn_dns: String,
|
||||
vpn_mtu: u16,
|
||||
swagger_enabled: bool,
|
||||
telegram_bot_enabled: bool,
|
||||
telegram_bot_username: String,
|
||||
telegram_bot_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -749,15 +767,90 @@ fn settings_fields(config: &AppConfig, sources: &ConfigSources) -> Vec<AdminSett
|
||||
config.swagger_enabled.to_string(),
|
||||
defaults.swagger_enabled.to_string()
|
||||
),
|
||||
field!(
|
||||
"telegram",
|
||||
"bool",
|
||||
telegram_bot_enabled,
|
||||
config.telegram_bot_enabled.to_string(),
|
||||
defaults.telegram_bot_enabled.to_string()
|
||||
),
|
||||
field!(
|
||||
"telegram",
|
||||
"text",
|
||||
telegram_bot_username,
|
||||
config.telegram_bot_username.clone(),
|
||||
defaults.telegram_bot_username.clone()
|
||||
),
|
||||
field!(
|
||||
"telegram",
|
||||
"password",
|
||||
telegram_bot_token,
|
||||
config.telegram_bot_token.clone(),
|
||||
defaults.telegram_bot_token.clone()
|
||||
),
|
||||
AdminSettingField {
|
||||
key: "telegram_setup_instructions".into(),
|
||||
env_var: String::new(),
|
||||
value: telegram_setup_instructions(config),
|
||||
default_value: String::new(),
|
||||
source: "default",
|
||||
secret: false,
|
||||
kind: "info",
|
||||
section: "telegram",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn telegram_setup_instructions(config: &AppConfig) -> String {
|
||||
let username = config
|
||||
.telegram_bot_username
|
||||
.trim()
|
||||
.trim_start_matches('@')
|
||||
.trim();
|
||||
let username_value = if username.is_empty() {
|
||||
"<BOT_USERNAME>"
|
||||
} else {
|
||||
username
|
||||
};
|
||||
let token_value = if config.telegram_bot_token.trim().is_empty() {
|
||||
"<BOT_TOKEN>"
|
||||
} else {
|
||||
"<configured bot token>"
|
||||
};
|
||||
let bot_link = if username.is_empty() {
|
||||
"https://t.me/<BOT_USERNAME>".to_owned()
|
||||
} else {
|
||||
format!("https://t.me/{username}")
|
||||
};
|
||||
let current_bot = if username.is_empty() {
|
||||
"<BOT_USERNAME>".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://<APP_HOST>/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());
|
||||
|
||||
+267
-1
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<cot::response::Response> {
|
||||
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<TelegramWebAppLinkRequest>,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
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<TelegramManualLinkRequest>,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
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<cot::response::Response> {
|
||||
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<Result<User, cot::response::Response>> {
|
||||
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<String, cot::response::Response> {
|
||||
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<Option<cot::response::Response>> {
|
||||
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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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." , "Нет зарегистрированных серверов.";
|
||||
|
||||
@@ -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",
|
||||
|
||||
+212
@@ -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<Sha256>;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct TelegramWebAppUser {
|
||||
pub id: i64,
|
||||
pub username: Option<String>,
|
||||
pub first_name: Option<String>,
|
||||
pub last_name: Option<String>,
|
||||
}
|
||||
|
||||
#[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<TelegramWebAppUser, TelegramAuthError> {
|
||||
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<TelegramWebAppUser, TelegramAuthError> {
|
||||
if bot_token.trim().is_empty() {
|
||||
return Err(TelegramAuthError::EmptyBotToken);
|
||||
}
|
||||
|
||||
let mut params = form_urlencoded::parse(init_data.as_bytes())
|
||||
.into_owned()
|
||||
.collect::<Vec<_>>();
|
||||
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::<Vec<_>>()
|
||||
.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::<u64>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
+64
@@ -17,6 +17,7 @@ pub struct User {
|
||||
email: Option<String>,
|
||||
display_name: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
telegram_id: Option<String>,
|
||||
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<Option<Self>> {
|
||||
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<u64> {
|
||||
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,
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user