This commit is contained in:
Generated
+2
-1
@@ -61,8 +61,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "amnezia-fellow"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"cot",
|
||||
"curve25519-dalek",
|
||||
|
||||
+4
-3
@@ -1,11 +1,12 @@
|
||||
[package]
|
||||
name = "amnezia-fellow"
|
||||
version = "0.1.5"
|
||||
version = "0.1.6"
|
||||
edition = "2024"
|
||||
description = "Amnezia VPN client manager with SSO, SQLite, and Kubernetes Secret sync"
|
||||
description = "Amnezia VPN client manager with SSO, SQLite/PostgreSQL, and Kubernetes Secret sync"
|
||||
|
||||
[dependencies]
|
||||
cot = { version = "0.6.0", default-features = false, features = ["sqlite", "json", "openapi", "swagger-ui"] }
|
||||
async-trait = "0.1"
|
||||
cot = { version = "0.6.0", default-features = false, features = ["sqlite", "postgres", "json", "openapi", "swagger-ui"] }
|
||||
schemars = { version = "0.9", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
openidconnect = "4.0"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Amnezia VPN client manager written in Rust on top of the public [`cot`](https://cot.rs) framework.
|
||||
|
||||
The app uses SQLite as the source of truth, authenticates users through OIDC/SSO, renders AmneziaWG client peers into a Kubernetes Secret, and avoids updating that Secret when the rendered content is byte-for-byte identical.
|
||||
The app uses SQLite or PostgreSQL as the source of truth, authenticates users through OIDC/SSO, renders AmneziaWG client peers into a Kubernetes Secret, and avoids updating that Secret when the rendered content is byte-for-byte identical.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -53,7 +53,7 @@ The OIDC groups claim is expected to be `groups`.
|
||||
|
||||
## VPN Data Model
|
||||
|
||||
SQLite stores all client data needed to restore configs:
|
||||
The database stores all client data needed to restore configs:
|
||||
|
||||
- owner user id
|
||||
- display name
|
||||
@@ -95,7 +95,8 @@ All settings use `AMNEZIA_FELLOW_` prefix. Priority is:
|
||||
|
||||
| Variable | Description | Default |
|
||||
| --- | --- | --- |
|
||||
| `AMNEZIA_FELLOW_DATABASE_URL` | SQLite connection URL | `sqlite://amnezia-fellow.sqlite3?mode=rwc` |
|
||||
| `AMNEZIA_FELLOW_DATABASE_URL` | SQLite or PostgreSQL connection URL. PostgreSQL URLs must start with `postgresql://`. | `sqlite://amnezia-fellow.sqlite3?mode=rwc` |
|
||||
| `AMNEZIA_FELLOW_MIGRATE_SQLITE` | Optional SQLite path/URL imported into PostgreSQL once on startup | empty |
|
||||
| `AMNEZIA_FELLOW_LOG_LEVEL` | Tracing filter | `info` |
|
||||
| `AMNEZIA_FELLOW_AUTH_PASSWORD_ENABLED` | Enable password login | `true` |
|
||||
| `AMNEZIA_FELLOW_AUTH_SSO_ENABLED` | Enable OIDC login | `false` |
|
||||
@@ -118,6 +119,23 @@ All settings use `AMNEZIA_FELLOW_` prefix. Priority is:
|
||||
| `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 |
|
||||
|
||||
## PostgreSQL Migration
|
||||
|
||||
To move from SQLite to PostgreSQL, start the app with a PostgreSQL database URL
|
||||
and point `AMNEZIA_FELLOW_MIGRATE_SQLITE` at the existing SQLite file:
|
||||
|
||||
```bash
|
||||
AMNEZIA_FELLOW_DATABASE_URL=postgresql://user:pass@postgres:5432/amnezia_fellow
|
||||
AMNEZIA_FELLOW_MIGRATE_SQLITE=/data/amnezia-fellow.sqlite3
|
||||
```
|
||||
|
||||
On startup, the app runs its migrations on PostgreSQL, opens the SQLite source
|
||||
read/write, applies any missing app migrations there, copies config entries,
|
||||
database sessions, users, OIDC links, Telegram link state, and VPN clients,
|
||||
then writes an import marker into PostgreSQL. If the marker already exists, the
|
||||
import is skipped. If PostgreSQL already contains app data but has no marker,
|
||||
startup fails instead of merging two databases implicitly.
|
||||
|
||||
## Telegram Bot
|
||||
|
||||
Configure the bot through the admin Settings page or the matching environment variables:
|
||||
|
||||
@@ -84,6 +84,11 @@ fn config_display_entries(config: &AppConfig, sources: &ConfigSources) -> Vec<Co
|
||||
config.database_url.clone(),
|
||||
defaults.database_url.clone()
|
||||
),
|
||||
entry!(
|
||||
migrate_sqlite,
|
||||
config.migrate_sqlite.clone(),
|
||||
defaults.migrate_sqlite.clone()
|
||||
),
|
||||
entry!(
|
||||
oidc_issuer,
|
||||
config.oidc_issuer.clone(),
|
||||
|
||||
+17
-1
@@ -47,6 +47,15 @@ impl ConfigEntry {
|
||||
pub fn new(key: String, value: String) -> Self {
|
||||
Self { key, value }
|
||||
}
|
||||
|
||||
pub fn key_str(&self) -> &str {
|
||||
&self.key
|
||||
}
|
||||
|
||||
pub async fn get_by_key(db: &Database, key: &str) -> cot::db::Result<Option<Self>> {
|
||||
let key = key.to_owned();
|
||||
cot::db::query!(ConfigEntry, $key == key).get(db).await
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -87,6 +96,7 @@ pub mod db_migrations {
|
||||
|
||||
pub struct ConfigSources {
|
||||
pub database_url: ConfigSource,
|
||||
pub migrate_sqlite: ConfigSource,
|
||||
pub oidc_issuer: ConfigSource,
|
||||
pub oidc_client_id: ConfigSource,
|
||||
pub oidc_client_secret: ConfigSource,
|
||||
@@ -116,6 +126,7 @@ impl Default for ConfigSources {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
database_url: ConfigSource::Default,
|
||||
migrate_sqlite: ConfigSource::Default,
|
||||
oidc_issuer: ConfigSource::Default,
|
||||
oidc_client_id: ConfigSource::Default,
|
||||
oidc_client_secret: ConfigSource::Default,
|
||||
@@ -194,8 +205,10 @@ macro_rules! impl_env_overrides {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
/// SQLite connection URL.
|
||||
/// SQLite or PostgreSQL connection URL.
|
||||
pub database_url: String,
|
||||
/// Optional SQLite database path/URL copied into PostgreSQL on startup.
|
||||
pub migrate_sqlite: String,
|
||||
/// OIDC issuer URL.
|
||||
pub oidc_issuer: String,
|
||||
/// OIDC client ID.
|
||||
@@ -248,6 +261,7 @@ impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
database_url: "sqlite://amnezia-fellow.sqlite3?mode=rwc".into(),
|
||||
migrate_sqlite: String::new(),
|
||||
oidc_issuer: String::new(),
|
||||
oidc_client_id: String::new(),
|
||||
oidc_client_secret: String::new(),
|
||||
@@ -277,6 +291,7 @@ impl Default for AppConfig {
|
||||
|
||||
impl_env_overrides!(
|
||||
database_url,
|
||||
migrate_sqlite,
|
||||
oidc_issuer,
|
||||
oidc_client_id,
|
||||
oidc_client_secret,
|
||||
@@ -355,6 +370,7 @@ impl AppConfig {
|
||||
}
|
||||
|
||||
apply_db_field!(database_url);
|
||||
apply_db_field!(migrate_sqlite);
|
||||
apply_db_field!(oidc_issuer);
|
||||
apply_db_field!(oidc_client_id);
|
||||
apply_db_field!(oidc_client_secret);
|
||||
|
||||
+15
-2
@@ -4,12 +4,14 @@ mod auth;
|
||||
mod config;
|
||||
mod i18n;
|
||||
mod oidc;
|
||||
mod sqlite_migration;
|
||||
mod telegram;
|
||||
mod user;
|
||||
mod vpn;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use cot::auth::PasswordVerificationResult;
|
||||
use cot::cli::CliMetadata;
|
||||
use cot::common_types::Password;
|
||||
@@ -21,7 +23,7 @@ use cot::db::Database;
|
||||
use cot::form::{Form, FormResult};
|
||||
use cot::html::Html;
|
||||
use cot::middleware::SessionMiddleware;
|
||||
use cot::project::RegisterAppsContext;
|
||||
use cot::project::{ProjectContext, RegisterAppsContext};
|
||||
use cot::request::extractors::{RequestForm, UrlQuery};
|
||||
use cot::response::IntoResponse;
|
||||
use cot::router::method::get;
|
||||
@@ -181,11 +183,19 @@ struct AmneziaFellowApp {
|
||||
config: Arc<AppConfig>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl App for AmneziaFellowApp {
|
||||
fn name(&self) -> &'static str {
|
||||
env!("CARGO_PKG_NAME")
|
||||
}
|
||||
|
||||
async fn init(&self, context: &mut ProjectContext) -> cot::Result<()> {
|
||||
if let Some(db) = context.try_database() {
|
||||
sqlite_migration::run_if_requested(&self.config, db).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn router(&self) -> Router {
|
||||
Router::with_urls([
|
||||
Route::with_handler_and_name(
|
||||
@@ -296,8 +306,11 @@ impl Project for AmneziaFellowProject {
|
||||
" Priority: env var > DB override > compiled default.\n",
|
||||
"\n",
|
||||
" Database (required for most features):\n",
|
||||
" AMNEZIA_FELLOW_DATABASE_URL SQLite connection URL\n",
|
||||
" AMNEZIA_FELLOW_DATABASE_URL SQLite or PostgreSQL connection URL\n",
|
||||
" Example: sqlite:///data/amnezia-fellow.sqlite3?mode=rwc\n",
|
||||
" Example: postgresql://user:pass@postgres:5432/amnezia_fellow\n",
|
||||
" AMNEZIA_FELLOW_MIGRATE_SQLITE SQLite path/URL to import into PostgreSQL once\n",
|
||||
" Example: /data/amnezia-fellow.sqlite3\n",
|
||||
"\n",
|
||||
" Server:\n",
|
||||
" AMNEZIA_FELLOW_LOG_LEVEL Tracing filter (default: info)\n",
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use cot::db::migrations::{self, MigrationEngine, SyncDynMigration};
|
||||
use cot::db::{Database, Model};
|
||||
use cot::session::db::Session as CotSession;
|
||||
|
||||
use crate::config::{AppConfig, ConfigEntry};
|
||||
use crate::user::{OidcLink, User};
|
||||
use crate::vpn::VpnClient;
|
||||
|
||||
const MARKER_KEY: &str = "sqlite_migration_completed_at";
|
||||
const SOURCE_KEY: &str = "sqlite_migration_source";
|
||||
|
||||
pub async fn run_if_requested(config: &AppConfig, target_db: &Database) -> cot::Result<()> {
|
||||
let source = config.migrate_sqlite.trim();
|
||||
if source.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !is_postgres_url(&config.database_url) {
|
||||
tracing::warn!(
|
||||
"AMNEZIA_FELLOW_MIGRATE_SQLITE is set but the active database is not PostgreSQL; skipping SQLite import"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if ConfigEntry::get_by_key(target_db, MARKER_KEY)
|
||||
.await
|
||||
.map_err(internal)?
|
||||
.is_some()
|
||||
{
|
||||
tracing::info!("SQLite import marker exists; skipping SQLite import");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if target_has_app_data(target_db).await? {
|
||||
return Err(cot::Error::internal(
|
||||
"PostgreSQL target already contains amnezia-fellow data and has no SQLite import marker; refusing to merge automatically",
|
||||
));
|
||||
}
|
||||
|
||||
let source_url = sqlite_source_url(source)?;
|
||||
tracing::info!("Importing amnezia-fellow data from SQLite into PostgreSQL");
|
||||
|
||||
let source_db = Database::new(source_url)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(format!("failed to open SQLite source: {e}")))?;
|
||||
|
||||
run_app_migrations(&source_db).await?;
|
||||
copy_app_data(&source_db, target_db).await?;
|
||||
reset_postgres_sequences(target_db).await?;
|
||||
write_marker(target_db, source).await?;
|
||||
|
||||
tracing::info!("SQLite import into PostgreSQL completed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_postgres_url(url: &str) -> bool {
|
||||
url.starts_with("postgresql:")
|
||||
}
|
||||
|
||||
fn sqlite_source_url(source: &str) -> cot::Result<String> {
|
||||
if source.starts_with("sqlite:") {
|
||||
return Ok(source.to_owned());
|
||||
}
|
||||
|
||||
if !Path::new(source).exists() {
|
||||
return Err(cot::Error::internal(format!(
|
||||
"SQLite import source does not exist: {source}"
|
||||
)));
|
||||
}
|
||||
|
||||
let prefix = if Path::new(source).is_absolute() {
|
||||
"sqlite://"
|
||||
} else {
|
||||
"sqlite:"
|
||||
};
|
||||
Ok(format!("{prefix}{source}?mode=rw"))
|
||||
}
|
||||
|
||||
async fn run_app_migrations(db: &Database) -> cot::Result<()> {
|
||||
let engine = MigrationEngine::new(app_migrations())
|
||||
.map_err(|e| cot::Error::internal(format!("failed to build migration engine: {e}")))?;
|
||||
engine
|
||||
.run(db)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(format!("failed to migrate SQLite source: {e}")))
|
||||
}
|
||||
|
||||
fn app_migrations() -> Vec<Box<SyncDynMigration>> {
|
||||
let mut all = migrations::wrap_migrations(cot::session::db::migrations::MIGRATIONS);
|
||||
all.extend(migrations::wrap_migrations(
|
||||
crate::config::db_migrations::MIGRATIONS,
|
||||
));
|
||||
all.extend(migrations::wrap_migrations(
|
||||
crate::user::db_migrations::MIGRATIONS,
|
||||
));
|
||||
all.extend(migrations::wrap_migrations(
|
||||
crate::vpn::db_migrations::MIGRATIONS,
|
||||
));
|
||||
all
|
||||
}
|
||||
|
||||
async fn target_has_app_data(db: &Database) -> cot::Result<bool> {
|
||||
let config_entries = ConfigEntry::objects().count(db).await.map_err(internal)?;
|
||||
let users = User::objects().count(db).await.map_err(internal)?;
|
||||
let oidc_links = OidcLink::objects().count(db).await.map_err(internal)?;
|
||||
let clients = VpnClient::objects().count(db).await.map_err(internal)?;
|
||||
Ok(config_entries > 0 || users > 0 || oidc_links > 0 || clients > 0)
|
||||
}
|
||||
|
||||
async fn copy_app_data(source_db: &Database, target_db: &Database) -> cot::Result<()> {
|
||||
for mut entry in ConfigEntry::objects().all(source_db).await.map_err(internal)? {
|
||||
if matches!(
|
||||
entry.key_str(),
|
||||
"database_url" | "migrate_sqlite" | MARKER_KEY | SOURCE_KEY
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
entry.save(target_db).await.map_err(internal)?;
|
||||
}
|
||||
|
||||
for mut session in CotSession::objects()
|
||||
.all(source_db)
|
||||
.await
|
||||
.map_err(internal)?
|
||||
{
|
||||
session.save(target_db).await.map_err(internal)?;
|
||||
}
|
||||
|
||||
for mut user in User::list_all(source_db).await.map_err(internal)? {
|
||||
user.save(target_db).await.map_err(internal)?;
|
||||
}
|
||||
|
||||
for mut link in OidcLink::objects().all(source_db).await.map_err(internal)? {
|
||||
link.save(target_db).await.map_err(internal)?;
|
||||
}
|
||||
|
||||
for mut client in VpnClient::list_all(source_db).await.map_err(internal)? {
|
||||
client.save(target_db).await.map_err(internal)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reset_postgres_sequences(db: &Database) -> cot::Result<()> {
|
||||
for table in [
|
||||
"cot_session__session",
|
||||
"amnezia_fellow__user",
|
||||
"amnezia_fellow__oidc_link",
|
||||
"amnezia_fellow__vpn_client",
|
||||
] {
|
||||
let sql = format!(
|
||||
"SELECT setval(pg_get_serial_sequence('{table}', 'id'), \
|
||||
COALESCE((SELECT MAX(id) FROM {table}), 1), \
|
||||
(SELECT COUNT(*) > 0 FROM {table}))"
|
||||
);
|
||||
db.raw(&sql).await.map_err(internal)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_marker(db: &Database, source: &str) -> cot::Result<()> {
|
||||
let mut marker = ConfigEntry::new(MARKER_KEY.to_owned(), now_unix_seconds().to_string());
|
||||
marker.save(db).await.map_err(internal)?;
|
||||
|
||||
let mut source_entry = ConfigEntry::new(SOURCE_KEY.to_owned(), source.to_owned());
|
||||
source_entry.save(db).await.map_err(internal)?;
|
||||
|
||||
let mut migrate_entry = ConfigEntry::new("migrate_sqlite".to_owned(), String::new());
|
||||
migrate_entry.save(db).await.map_err(internal)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn now_unix_seconds() -> i64 {
|
||||
let seconds = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or_default();
|
||||
i64::try_from(seconds).unwrap_or(i64::MAX)
|
||||
}
|
||||
|
||||
fn internal(error: impl std::fmt::Display) -> cot::Error {
|
||||
cot::Error::internal(error.to_string())
|
||||
}
|
||||
@@ -3,9 +3,11 @@
|
||||
{% block title %}{{ t.configs_heading }} | {{ t.site_name }}{% endblock title %}
|
||||
|
||||
{% block head_extra %}
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
[x-cloak] { display: none !important; }
|
||||
body { margin: 0; min-height: 100vh; background: #f6f7f8; color: #1d252d; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||
a { color: inherit; }
|
||||
.shell { min-height: 100vh; display: grid; grid-template-columns: 220px minmax(0, 1fr); }
|
||||
@@ -17,6 +19,13 @@
|
||||
.topbar { min-height: 48px; display: flex; align-items: center; justify-content: flex-end; gap: 1rem; flex-wrap: wrap; padding: .5rem 1.25rem; border-bottom: 1px solid #dde2e6; background: #fff; }
|
||||
.user-info { font-size: .875rem; color: #53606d; }
|
||||
.app-version { font-size: .75rem; color: #9aa4ae; white-space: nowrap; }
|
||||
.telegram-status-button { min-width: 36px; width: 36px; height: 36px; min-height: 36px; padding: 0; display: inline-grid; place-items: center; position: relative; border-color: #cbd3db; background: #fff; color: #17202a; }
|
||||
.telegram-status-button .telegram-mark { font-size: .7rem; font-weight: 900; letter-spacing: 0; }
|
||||
.telegram-dot { position: absolute; right: 4px; top: 4px; width: .55rem; height: .55rem; border-radius: 999px; border: 2px solid #fff; background: #7b8793; }
|
||||
.telegram-status-button.tone-connected .telegram-dot { background: #2f8f4e; }
|
||||
.telegram-status-button.tone-pending .telegram-dot { background: #d39a00; }
|
||||
.telegram-status-button.tone-empty .telegram-dot { background: #69777f; }
|
||||
.telegram-status-button.tone-disabled .telegram-dot { background: #9d2323; }
|
||||
.logout-link, .lang-switch a { font-size: .875rem; text-decoration: none; color: #53606d; padding: .25rem .45rem; border-radius: 4px; }
|
||||
.logout-link:hover, .lang-switch a:hover { background: #eef1f4; color: #1d252d; }
|
||||
.lang-switch a.active { color: #1d252d; font-weight: 700; }
|
||||
@@ -104,6 +113,13 @@
|
||||
.qr-box { display: grid; place-items: center; padding: .75rem; border: 1px solid #dde2e6; border-radius: 6px; background: #fff; }
|
||||
.qr-box svg { width: min(280px, 100%); height: auto; display: block; }
|
||||
.modal-actions { display: flex; gap: .5rem; justify-content: flex-end; margin-top: .75rem; flex-wrap: wrap; }
|
||||
.telegram-modal { width: min(480px, 100%); display: grid; gap: .8rem; }
|
||||
.telegram-copy { color: #53606d; line-height: 1.45; margin: 0; }
|
||||
.guide-list { margin: 0; padding-left: 1.2rem; color: #34414f; display: grid; gap: .45rem; line-height: 1.4; }
|
||||
.guide-list a { color: #17202a; font-weight: 800; }
|
||||
.guide-list a.disabled { color: #6a7682; pointer-events: none; text-decoration: none; }
|
||||
.secret-box { display: grid; gap: .35rem; border: 1px solid #dde2e6; border-radius: 6px; background: #f8fafb; padding: .7rem; }
|
||||
.secret-box code { display: block; padding: .55rem .65rem; font-size: .92rem; white-space: normal; overflow-wrap: anywhere; }
|
||||
@media (max-width: 760px) {
|
||||
.shell { grid-template-columns: 1fr; }
|
||||
.sidebar { display: flex; align-items: center; gap: .75rem; overflow-x: auto; }
|
||||
@@ -138,6 +154,10 @@
|
||||
<div class="topbar">
|
||||
<span class="app-version">v{{ app_version }}</span>
|
||||
<span class="user-info">{{ user_name }} ({{ user_role }})</span>
|
||||
<button type="button" x-cloak x-show="telegram.enabled" class="telegram-status-button" :class="`tone-${telegramStatusTone()}`" @click="openTelegramManage()" :title="telegramStatusTitle()" :aria-label="telegramStatusTitle()">
|
||||
<span class="telegram-mark">TG</span>
|
||||
<span class="telegram-dot"></span>
|
||||
</button>
|
||||
<div class="lang-switch">
|
||||
<a href="#"{% if t.lang.code() == "en" %} class="active"{% endif %} onclick="location.href='/set-lang?lang=en&next='+encodeURIComponent(location.pathname);return false">EN</a>
|
||||
<a href="#"{% if t.lang.code() == "ru" %} class="active"{% endif %} onclick="location.href='/set-lang?lang=ru&next='+encodeURIComponent(location.pathname);return false">RU</a>
|
||||
@@ -341,6 +361,90 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="telegramModal">
|
||||
<div class="modal-backdrop" @click.self="telegramModal = null">
|
||||
<div class="modal telegram-modal">
|
||||
<div class="modal-head">
|
||||
<div class="modal-title">
|
||||
<h3>{{ t.telegram_link_title }}</h3>
|
||||
<div class="modal-subtitle" x-text="telegramBotLabel()"></div>
|
||||
</div>
|
||||
<button class="secondary" @click="telegramModal = null">{{ t.admin_close }}</button>
|
||||
</div>
|
||||
|
||||
<template x-if="telegramModal === 'manage'">
|
||||
<div>
|
||||
<p class="telegram-copy" x-text="telegramManageMessage()"></p>
|
||||
<template x-if="telegram.linked">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">{{ t.telegram_status_connected }}</span>
|
||||
<span class="detail-value"><code x-text="telegram.telegram_id || ''"></code></span>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="telegram.pending">
|
||||
<div class="secret-box">
|
||||
<span>{{ t.telegram_secret_label }}</span>
|
||||
<code x-text="telegram.pending_secret || ''"></code>
|
||||
</div>
|
||||
</template>
|
||||
<div class="modal-actions">
|
||||
<template x-if="telegram.pending">
|
||||
<button type="button" class="secondary" @click="copyTelegramSecret()" :disabled="telegramBusy || !telegram.pending_secret">{{ t.telegram_copy_secret }}</button>
|
||||
</template>
|
||||
<template x-if="telegram.pending">
|
||||
<button type="button" class="secondary" @click="refreshTelegramStatus()" :disabled="telegramBusy">{{ t.telegram_refresh_status }}</button>
|
||||
</template>
|
||||
<button type="button" class="secondary" @click="startTelegramBotLink()" :disabled="telegramBusy" x-text="telegram.linked || telegram.pending ? '{{ t.telegram_change }}' : '{{ t.telegram_connect }}'"></button>
|
||||
<template x-if="telegram.linked || telegram.pending || telegram.declined">
|
||||
<button type="button" class="danger" @click="deleteTelegramLink()" :disabled="telegramBusy">{{ t.telegram_delete }}</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="telegramModal === 'webapp'">
|
||||
<div>
|
||||
<p class="telegram-copy">{{ t.telegram_webapp_message }}</p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="secondary" @click="declineTelegramLink()" :disabled="telegramBusy">{{ t.telegram_skip }}</button>
|
||||
<button type="button" @click="linkTelegramWebApp()" :disabled="telegramBusy">{{ t.telegram_save }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="telegramModal === 'manualPrompt'">
|
||||
<div>
|
||||
<p class="telegram-copy">{{ t.telegram_manual_message }}</p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="secondary" @click="declineTelegramLink()" :disabled="telegramBusy">{{ t.telegram_skip }}</button>
|
||||
<button type="button" @click="startTelegramBotLink()" :disabled="telegramBusy">{{ t.telegram_connect }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="telegramModal === 'manualGuide'">
|
||||
<div>
|
||||
<p class="telegram-copy">{{ t.telegram_guide_title }}</p>
|
||||
<ol class="guide-list">
|
||||
<li>{{ t.telegram_guide_start }} <a :href="telegramBotUrl()" target="_blank" rel="noreferrer" :class="{ disabled: !telegram.bot_url }">{{ t.telegram_open_bot }}</a></li>
|
||||
<li>{{ t.telegram_guide_get_id }}</li>
|
||||
<li>{{ t.telegram_guide_paste }}</li>
|
||||
</ol>
|
||||
<div class="secret-box">
|
||||
<span>{{ t.telegram_secret_label }}</span>
|
||||
<code x-text="telegram.pending_secret || ''"></code>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="secondary" @click="declineTelegramLink()" :disabled="telegramBusy">{{ t.telegram_skip }}</button>
|
||||
<button type="button" class="secondary" @click="copyTelegramSecret()" :disabled="telegramBusy || !telegram.pending_secret">{{ t.telegram_copy_secret }}</button>
|
||||
<button type="button" @click="refreshTelegramStatus()" :disabled="telegramBusy">{{ t.telegram_refresh_status }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -360,9 +464,27 @@ function configsPage() {
|
||||
serverConfigs: {},
|
||||
serverModal: null,
|
||||
qrModal: null,
|
||||
telegram: {
|
||||
enabled: false,
|
||||
bot_username: '',
|
||||
bot_url: '',
|
||||
linked: false,
|
||||
declined: false,
|
||||
pending: false,
|
||||
pending_secret: null,
|
||||
pending_expires_at: null,
|
||||
telegram_id: null,
|
||||
isWebApp: false,
|
||||
initData: '',
|
||||
},
|
||||
telegramModal: null,
|
||||
telegramBusy: false,
|
||||
telegramPromptChecked: false,
|
||||
isAdmin: {% if is_admin %}true{% else %}false{% endif %},
|
||||
init() {
|
||||
this.initTelegramWebApp();
|
||||
this.load();
|
||||
this.loadTelegramStatus();
|
||||
if (this.isAdmin) {
|
||||
this.loadRolloutStatus();
|
||||
this.rolloutTimer = setInterval(() => this.loadRolloutStatus(), 10000);
|
||||
@@ -384,6 +506,157 @@ function configsPage() {
|
||||
}
|
||||
return data;
|
||||
},
|
||||
initTelegramWebApp() {
|
||||
const webApp = window.Telegram && window.Telegram.WebApp;
|
||||
if (!webApp) return;
|
||||
try {
|
||||
webApp.ready();
|
||||
webApp.expand();
|
||||
} catch (_) {}
|
||||
this.telegram.isWebApp = Boolean(webApp.initData);
|
||||
this.telegram.initData = webApp.initData || '';
|
||||
},
|
||||
async loadTelegramStatus() {
|
||||
try {
|
||||
const status = await this.request('/api/telegram-link/status');
|
||||
this.applyTelegramStatus(status);
|
||||
this.maybePromptTelegram();
|
||||
} catch (e) {
|
||||
console.warn('telegram status failed', e);
|
||||
}
|
||||
},
|
||||
applyTelegramStatus(status) {
|
||||
this.telegram = {
|
||||
...this.telegram,
|
||||
enabled: Boolean(status.enabled),
|
||||
bot_username: status.bot_username || '',
|
||||
bot_url: status.bot_url || '',
|
||||
linked: Boolean(status.linked),
|
||||
declined: Boolean(status.declined),
|
||||
pending: Boolean(status.pending),
|
||||
pending_secret: status.pending_secret || null,
|
||||
pending_expires_at: status.pending_expires_at || null,
|
||||
telegram_id: status.telegram_id || null,
|
||||
};
|
||||
},
|
||||
maybePromptTelegram() {
|
||||
if (this.telegramPromptChecked) return;
|
||||
this.telegramPromptChecked = true;
|
||||
if (!this.telegram.enabled || this.telegram.linked || this.telegram.pending || this.telegram.declined) return;
|
||||
this.telegramModal = (this.telegram.isWebApp && this.telegram.initData) ? 'webapp' : 'manualPrompt';
|
||||
},
|
||||
openTelegramManage() {
|
||||
if (!this.telegram.enabled) return;
|
||||
if (this.telegram.linked || this.telegram.pending || this.telegram.declined) {
|
||||
this.telegramModal = 'manage';
|
||||
return;
|
||||
}
|
||||
this.telegramModal = (this.telegram.isWebApp && this.telegram.initData) ? 'webapp' : 'manualPrompt';
|
||||
},
|
||||
async linkTelegramWebApp() {
|
||||
this.telegramBusy = true;
|
||||
this.clearNotice();
|
||||
try {
|
||||
const data = await this.request('/api/telegram-link/webapp', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ init_data: this.telegram.initData }),
|
||||
});
|
||||
this.applyTelegramStatus(data.status || {});
|
||||
this.telegramModal = null;
|
||||
this.setNotice('success', '{{ t.notice_success_title }}', '{{ t.telegram_saved }}');
|
||||
} catch (e) {
|
||||
this.showError(e);
|
||||
} finally {
|
||||
this.telegramBusy = false;
|
||||
}
|
||||
},
|
||||
async startTelegramBotLink() {
|
||||
this.telegramBusy = true;
|
||||
this.clearNotice();
|
||||
try {
|
||||
const data = await this.request('/api/telegram-link/start', { method: 'POST' });
|
||||
this.applyTelegramStatus(data.status || {});
|
||||
this.telegramModal = 'manualGuide';
|
||||
} catch (e) {
|
||||
this.showError(e);
|
||||
} finally {
|
||||
this.telegramBusy = false;
|
||||
}
|
||||
},
|
||||
async refreshTelegramStatus() {
|
||||
this.telegramBusy = true;
|
||||
try {
|
||||
await this.loadTelegramStatus();
|
||||
if (this.telegram.linked) {
|
||||
this.telegramModal = 'manage';
|
||||
this.setNotice('success', '{{ t.notice_success_title }}', '{{ t.telegram_saved }}');
|
||||
}
|
||||
} finally {
|
||||
this.telegramBusy = false;
|
||||
}
|
||||
},
|
||||
async copyTelegramSecret() {
|
||||
if (!this.telegram.pending_secret) return;
|
||||
this.clearNotice();
|
||||
try {
|
||||
await this.copyText(this.telegram.pending_secret);
|
||||
this.setNotice('success', '{{ t.notice_success_title }}', '{{ t.telegram_secret_copied }}');
|
||||
} catch (e) {
|
||||
this.showError(e);
|
||||
}
|
||||
},
|
||||
async declineTelegramLink() {
|
||||
this.telegramBusy = true;
|
||||
this.clearNotice();
|
||||
try {
|
||||
const data = await this.request('/api/telegram-link/decline', { method: 'POST' });
|
||||
this.applyTelegramStatus(data.status || {});
|
||||
this.telegramModal = null;
|
||||
this.setNotice('success', '{{ t.notice_success_title }}', '{{ t.telegram_declined }}');
|
||||
} catch (e) {
|
||||
this.showError(e);
|
||||
} finally {
|
||||
this.telegramBusy = false;
|
||||
}
|
||||
},
|
||||
async deleteTelegramLink() {
|
||||
if (!confirm('{{ t.telegram_delete_confirm }}')) return;
|
||||
this.telegramBusy = true;
|
||||
this.clearNotice();
|
||||
try {
|
||||
const data = await this.request('/api/telegram-link/unlink', { method: 'POST' });
|
||||
this.applyTelegramStatus(data.status || {});
|
||||
this.telegramModal = null;
|
||||
this.setNotice('success', '{{ t.notice_success_title }}', '{{ t.telegram_unlinked }}');
|
||||
} catch (e) {
|
||||
this.showError(e);
|
||||
} finally {
|
||||
this.telegramBusy = false;
|
||||
}
|
||||
},
|
||||
telegramBotUrl() {
|
||||
return this.telegram.bot_url || '#';
|
||||
},
|
||||
telegramBotLabel() {
|
||||
return this.telegram.bot_username ? `@${this.telegram.bot_username}` : '{{ t.telegram_bot_unconfigured }}';
|
||||
},
|
||||
telegramStatusTone() {
|
||||
if (!this.telegram.enabled) return 'disabled';
|
||||
if (this.telegram.pending) return 'pending';
|
||||
if (this.telegram.linked) return 'connected';
|
||||
return 'empty';
|
||||
},
|
||||
telegramStatusTitle() {
|
||||
if (!this.telegram.enabled) return '{{ t.telegram_status_disabled }}';
|
||||
if (this.telegram.pending) return '{{ t.telegram_status_pending }}';
|
||||
if (this.telegram.linked) return '{{ t.telegram_status_connected }}';
|
||||
return '{{ t.telegram_status_empty }}';
|
||||
},
|
||||
telegramManageMessage() {
|
||||
if (this.telegram.pending) return '{{ t.telegram_pending_message }}';
|
||||
if (this.telegram.linked) return '{{ t.telegram_connected_message }}';
|
||||
return '{{ t.telegram_not_connected_message }}';
|
||||
},
|
||||
async load() {
|
||||
this.error = '';
|
||||
this.busy = true;
|
||||
|
||||
Reference in New Issue
Block a user