This commit is contained in:
Generated
+1
-1
@@ -61,7 +61,7 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "amnezia-fellow"
|
||||
version = "0.1.6"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "amnezia-fellow"
|
||||
version = "0.1.7"
|
||||
version = "0.1.8"
|
||||
edition = "2024"
|
||||
description = "Amnezia VPN client manager with SSO, SQLite/PostgreSQL, and Kubernetes Secret sync"
|
||||
|
||||
|
||||
@@ -156,12 +156,13 @@ delivered.
|
||||
|
||||
## API
|
||||
|
||||
The JSON API is session-authenticated:
|
||||
The JSON API is session-authenticated unless noted:
|
||||
|
||||
- `GET /api/me`
|
||||
- `GET /api/vpn-clients`
|
||||
- `GET /api/vpn-status`
|
||||
- `GET /api/telegram-link/status`
|
||||
- `POST /api/telegram-login/webapp` (public Telegram `initData` login)
|
||||
- `POST /api/telegram-link/webapp`
|
||||
- `POST /api/telegram-link/start`
|
||||
- `POST /api/telegram-link/decline`
|
||||
|
||||
@@ -162,6 +162,11 @@ struct TelegramLinkResponse {
|
||||
status: TelegramLinkStatusResponse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
struct TelegramLoginResponse {
|
||||
redirect_to: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct TelegramWebAppLinkRequest {
|
||||
init_data: String,
|
||||
@@ -292,6 +297,71 @@ async fn telegram_link_webapp_handler(
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn telegram_login_webapp_handler(
|
||||
session: Session,
|
||||
db: Database,
|
||||
Json(request): Json<TelegramWebAppLinkRequest>,
|
||||
) -> cot::Result<cot::response::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();
|
||||
let Some(user) = User::get_by_telegram_id(&db, &telegram_id)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(format!("failed to load Telegram user: {e}")))?
|
||||
else {
|
||||
return Ok(json_error_typed(
|
||||
cot::http::StatusCode::UNAUTHORIZED,
|
||||
"telegram_account_not_linked",
|
||||
"Telegram is not linked",
|
||||
"This Telegram account is not connected to any VPN account.",
|
||||
"",
|
||||
));
|
||||
};
|
||||
|
||||
if !user.is_active() {
|
||||
return Ok(json_error_typed(
|
||||
cot::http::StatusCode::FORBIDDEN,
|
||||
"user_inactive",
|
||||
"Account is inactive",
|
||||
"This account is inactive.",
|
||||
"",
|
||||
));
|
||||
}
|
||||
|
||||
auth::login(&session, user.id_val()).await?;
|
||||
|
||||
Json(TelegramLoginResponse {
|
||||
redirect_to: "/configs".to_owned(),
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn telegram_link_start_handler(
|
||||
session: Session,
|
||||
db: Database,
|
||||
@@ -831,6 +901,11 @@ impl App for ApiApp {
|
||||
api_post(telegram_link_webapp_handler),
|
||||
"api_telegram_link_webapp",
|
||||
),
|
||||
Route::with_api_handler_and_name(
|
||||
"/telegram-login/webapp",
|
||||
api_post(telegram_login_webapp_handler),
|
||||
"api_telegram_login_webapp",
|
||||
),
|
||||
Route::with_api_handler_and_name(
|
||||
"/telegram-link/start",
|
||||
api_post(telegram_link_start_handler),
|
||||
|
||||
+4
-1
@@ -42,6 +42,9 @@ translations! {
|
||||
login_submit: "Sign in" , "Войти";
|
||||
login_disabled: "Login is currently disabled." , "Вход сейчас отключён.";
|
||||
login_invalid: "Invalid username or password." , "Неверное имя пользователя или пароль.";
|
||||
login_telegram_wait: "Signing in with Telegram..." , "Входим через Telegram...";
|
||||
login_telegram_fallback: "Open the regular login page" , "Открыть обычный вход";
|
||||
login_telegram_failed: "Telegram login failed. Use regular login." , "Не удалось войти через Telegram. Используйте обычный вход.";
|
||||
|
||||
// Logout
|
||||
nav_logout: "Logout" , "Выход";
|
||||
@@ -169,7 +172,7 @@ translations! {
|
||||
telegram_open_bot: "Open bot" , "Открыть бота";
|
||||
telegram_guide_start: "Open the bot and send /start once if you have not done it before." , "Откройте бота и один раз отправьте /start, если ещё не делали этого.";
|
||||
telegram_guide_get_id: "Send the secret code below to the bot." , "Отправьте боту секретный код ниже.";
|
||||
telegram_guide_paste: "Return here and refresh the status." , "Вернитесь сюда и обновите статус.";
|
||||
telegram_guide_paste: "Return here; the status updates automatically." , "Вернитесь сюда; статус обновится автоматически.";
|
||||
telegram_secret_label: "Secret code" , "Секретный код";
|
||||
telegram_save: "Save Telegram" , "Сохранить Telegram";
|
||||
telegram_saved: "Telegram connected." , "Telegram подключён.";
|
||||
|
||||
+11
-3
@@ -67,14 +67,22 @@ struct ClientPortalTemplate {
|
||||
app_version: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
#[template(path = "telegram_login.html")]
|
||||
struct TelegramLoginTemplate {
|
||||
t: &'static Translations,
|
||||
}
|
||||
|
||||
async fn configs_page(
|
||||
session: Session,
|
||||
db: Database,
|
||||
i18n: I18n,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
let user = match auth::require_user_or_redirect(&session, &db).await {
|
||||
Ok(user) => user,
|
||||
Err(response) => return Ok(response),
|
||||
let user = match auth::get_session_user(&session, &db).await {
|
||||
Some(user) => user,
|
||||
None => {
|
||||
return Html::new(TelegramLoginTemplate { t: i18n.t }.render()?).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let is_admin = user.role == auth::Role::Admin;
|
||||
|
||||
@@ -461,12 +461,15 @@ function clientPortal() {
|
||||
telegramModal: null,
|
||||
telegramBusy: false,
|
||||
telegramPromptChecked: false,
|
||||
telegramStatusTimer: null,
|
||||
telegramStatusPolling: false,
|
||||
init() {
|
||||
this.initTelegramWebApp();
|
||||
this.load();
|
||||
this.loadServerStatus();
|
||||
this.loadTelegramStatus();
|
||||
this.serverStatusTimer = setInterval(() => this.loadServerStatus(true), 30000);
|
||||
this.telegramStatusTimer = setInterval(() => this.pollTelegramStatus(), 2500);
|
||||
},
|
||||
async request(url, options = {}) {
|
||||
const response = await fetch(url, {
|
||||
@@ -517,11 +520,11 @@ function clientPortal() {
|
||||
this.telegram.isWebApp = Boolean(webApp.initData);
|
||||
this.telegram.initData = webApp.initData || '';
|
||||
},
|
||||
async loadTelegramStatus() {
|
||||
async loadTelegramStatus(options = {}) {
|
||||
try {
|
||||
const status = await this.request('/api/telegram-link/status');
|
||||
this.applyTelegramStatus(status);
|
||||
this.maybePromptTelegram();
|
||||
if (options.prompt !== false) this.maybePromptTelegram();
|
||||
} catch (e) {
|
||||
console.warn('telegram status failed', e);
|
||||
}
|
||||
@@ -596,6 +599,25 @@ function clientPortal() {
|
||||
this.telegramBusy = false;
|
||||
}
|
||||
},
|
||||
async pollTelegramStatus() {
|
||||
if (this.telegramStatusPolling || this.telegramBusy) return;
|
||||
if (!this.telegram.enabled || !this.telegram.pending) return;
|
||||
if (this.telegramModal !== 'manualGuide' && this.telegramModal !== 'manage') return;
|
||||
|
||||
this.telegramStatusPolling = true;
|
||||
const wasPending = this.telegram.pending;
|
||||
try {
|
||||
await this.loadTelegramStatus({ prompt: false });
|
||||
if (wasPending && this.telegram.linked) {
|
||||
this.telegramModal = 'manage';
|
||||
this.setNotice('success', '{{ t.notice_success_title }}', '{{ t.telegram_saved }}');
|
||||
} else if (wasPending && !this.telegram.pending) {
|
||||
this.telegramModal = 'manage';
|
||||
}
|
||||
} finally {
|
||||
this.telegramStatusPolling = false;
|
||||
}
|
||||
},
|
||||
async copyTelegramSecret() {
|
||||
if (!this.telegram.pending_secret) return;
|
||||
this.clearNotice();
|
||||
|
||||
+24
-2
@@ -480,6 +480,8 @@ function configsPage() {
|
||||
telegramModal: null,
|
||||
telegramBusy: false,
|
||||
telegramPromptChecked: false,
|
||||
telegramStatusTimer: null,
|
||||
telegramStatusPolling: false,
|
||||
isAdmin: {% if is_admin %}true{% else %}false{% endif %},
|
||||
init() {
|
||||
this.initTelegramWebApp();
|
||||
@@ -489,6 +491,7 @@ function configsPage() {
|
||||
this.loadRolloutStatus();
|
||||
this.rolloutTimer = setInterval(() => this.loadRolloutStatus(), 10000);
|
||||
}
|
||||
this.telegramStatusTimer = setInterval(() => this.pollTelegramStatus(), 2500);
|
||||
},
|
||||
async request(url, options = {}) {
|
||||
const response = await fetch(url, {
|
||||
@@ -516,11 +519,11 @@ function configsPage() {
|
||||
this.telegram.isWebApp = Boolean(webApp.initData);
|
||||
this.telegram.initData = webApp.initData || '';
|
||||
},
|
||||
async loadTelegramStatus() {
|
||||
async loadTelegramStatus(options = {}) {
|
||||
try {
|
||||
const status = await this.request('/api/telegram-link/status');
|
||||
this.applyTelegramStatus(status);
|
||||
this.maybePromptTelegram();
|
||||
if (options.prompt !== false) this.maybePromptTelegram();
|
||||
} catch (e) {
|
||||
console.warn('telegram status failed', e);
|
||||
}
|
||||
@@ -595,6 +598,25 @@ function configsPage() {
|
||||
this.telegramBusy = false;
|
||||
}
|
||||
},
|
||||
async pollTelegramStatus() {
|
||||
if (this.telegramStatusPolling || this.telegramBusy) return;
|
||||
if (!this.telegram.enabled || !this.telegram.pending) return;
|
||||
if (this.telegramModal !== 'manualGuide' && this.telegramModal !== 'manage') return;
|
||||
|
||||
this.telegramStatusPolling = true;
|
||||
const wasPending = this.telegram.pending;
|
||||
try {
|
||||
await this.loadTelegramStatus({ prompt: false });
|
||||
if (wasPending && this.telegram.linked) {
|
||||
this.telegramModal = 'manage';
|
||||
this.setNotice('success', '{{ t.notice_success_title }}', '{{ t.telegram_saved }}');
|
||||
} else if (wasPending && !this.telegram.pending) {
|
||||
this.telegramModal = 'manage';
|
||||
}
|
||||
} finally {
|
||||
this.telegramStatusPolling = false;
|
||||
}
|
||||
},
|
||||
async copyTelegramSecret() {
|
||||
if (!this.telegram.pending_secret) return;
|
||||
this.clearNotice();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% block title %}{{ t.login_heading }} | {{ t.site_name }}{% endblock title %}
|
||||
|
||||
{% block head_extra %}
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: system-ui, -apple-system, sans-serif; background: #f5f5f5; color: #333; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
|
||||
@@ -30,6 +31,7 @@
|
||||
{% if !message.is_empty() %}
|
||||
<div class="flash">{{ message }}</div>
|
||||
{% endif %}
|
||||
<div id="telegram-login-flash" class="flash" style="display: none;"></div>
|
||||
|
||||
{% if !auth_password_enabled && !auth_sso_enabled %}
|
||||
<p class="message">{{ t.login_disabled }}</p>
|
||||
@@ -53,4 +55,37 @@
|
||||
<a class="sso-btn" href="/auth/oidc/start">{{ oidc_button_text }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(async () => {
|
||||
const webApp = window.Telegram && window.Telegram.WebApp;
|
||||
if (!webApp || !webApp.initData) return;
|
||||
|
||||
try {
|
||||
webApp.ready();
|
||||
webApp.expand();
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/telegram-login/webapp', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ init_data: webApp.initData }),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (response.ok) {
|
||||
window.location.replace(data.redirect_to || '/configs');
|
||||
return;
|
||||
}
|
||||
const flash = document.getElementById('telegram-login-flash');
|
||||
flash.textContent = data.error || data.detail || '{{ t.login_telegram_failed }}';
|
||||
flash.style.display = 'block';
|
||||
} catch (_) {
|
||||
const flash = document.getElementById('telegram-login-flash');
|
||||
flash.textContent = '{{ t.login_telegram_failed }}';
|
||||
flash.style.display = 'block';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock body %}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ t.login_heading }} | {{ t.site_name }}{% endblock title %}
|
||||
|
||||
{% block head_extra %}
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 1rem; background: #f5f5f5; color: #333; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||
.telegram-login-card { width: min(380px, 100%); border-radius: 8px; background: #fff; box-shadow: 0 2px 8px rgba(0,0,0,.12); padding: 2rem; text-align: center; display: grid; gap: .8rem; }
|
||||
.telegram-login-card h1 { margin: 0; font-size: 1.35rem; }
|
||||
.telegram-login-card p { margin: 0; color: #666; line-height: 1.45; }
|
||||
.telegram-login-card a { color: #1a1a2e; font-weight: 700; }
|
||||
</style>
|
||||
{% endblock head_extra %}
|
||||
|
||||
{% block body %}
|
||||
<div class="telegram-login-card">
|
||||
<h1>{{ t.login_heading }}</h1>
|
||||
<p id="telegram-login-status">{{ t.login_telegram_wait }}</p>
|
||||
<a href="/login">{{ t.login_telegram_fallback }}</a>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(async () => {
|
||||
const status = document.getElementById('telegram-login-status');
|
||||
const fallback = () => window.location.replace('/login');
|
||||
const webApp = window.Telegram && window.Telegram.WebApp;
|
||||
|
||||
try {
|
||||
if (webApp) {
|
||||
webApp.ready();
|
||||
webApp.expand();
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
if (!webApp || !webApp.initData) {
|
||||
fallback();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/telegram-login/webapp', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ init_data: webApp.initData }),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
status.textContent = data.error || data.detail || '{{ t.login_telegram_failed }}';
|
||||
setTimeout(fallback, 1800);
|
||||
return;
|
||||
}
|
||||
window.location.replace(data.redirect_to || '/configs');
|
||||
} catch (_) {
|
||||
status.textContent = '{{ t.login_telegram_failed }}';
|
||||
setTimeout(fallback, 1800);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock body %}
|
||||
Reference in New Issue
Block a user