This commit is contained in:
+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),
|
||||
|
||||
Reference in New Issue
Block a user