mirror of
https://github.com/anten-ka/gotelegram_pro.git
synced 2026-05-19 13:26:02 +00:00
v2.5.0: add QR import and backup scheduling
This commit is contained in:
@@ -16,6 +16,7 @@ import mimetypes
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shlex
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
@@ -42,6 +43,8 @@ BOT_DIR = Path(os.getenv("GOTELEGRAM_BOT_DIR", "/opt/gotelegram-bot"))
|
||||
DISABLED_USERS_FILE = Path(os.getenv("GOTELEGRAM_DISABLED_USERS", "/opt/gotelegram/disabled_users.json"))
|
||||
USER_LOCK_FILE = Path(os.getenv("GOTELEGRAM_USER_LOCK", "/run/gotelegram/admin-users.lock"))
|
||||
SHARED_443_CONFIG = Path(os.getenv("GOTELEGRAM_SHARED_443", "/opt/gotelegram/shared-443.json"))
|
||||
BACKUP_SCHEDULE_FILE = Path(os.getenv("GOTELEGRAM_BACKUP_SCHEDULE", "/opt/gotelegram/backup_schedule.json"))
|
||||
BACKUP_RESTORE_LOG = Path(os.getenv("GOTELEGRAM_BACKUP_RESTORE_LOG", "/var/log/gotelegram-restore.log"))
|
||||
|
||||
HOST = os.getenv("GOTELEGRAM_ADMIN_HOST", "127.0.0.1")
|
||||
PORT = int(os.getenv("GOTELEGRAM_ADMIN_PORT", "1984"))
|
||||
@@ -49,6 +52,7 @@ VERSION = "2.5.0"
|
||||
USER_RE = re.compile(r"^[A-Za-z0-9_.-]{1,48}$")
|
||||
LANG_RE = re.compile(r"^(en|ru)$")
|
||||
SENSITIVE_CONFIG_KEYS = {"secret"}
|
||||
BACKUP_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+\.tar\.gz(\.enc)?$")
|
||||
TRAFFIC_WINDOWS = {
|
||||
"15m": 15 * 60,
|
||||
"1h": 60 * 60,
|
||||
@@ -75,6 +79,19 @@ def run(cmd: list[str], timeout: int = 8) -> tuple[int, str, str]:
|
||||
return 125, "", str(exc)
|
||||
|
||||
|
||||
def run_bytes(cmd: list[str], timeout: int = 8) -> tuple[int, bytes, str]:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
return proc.returncode, proc.stdout, proc.stderr.decode("utf-8", errors="replace")
|
||||
except Exception as exc: # pragma: no cover - system dependent
|
||||
return 125, b"", str(exc)
|
||||
|
||||
|
||||
class FileLock:
|
||||
def __init__(self, path: Path):
|
||||
self.path = path
|
||||
@@ -909,6 +926,55 @@ def list_backups() -> list[dict[str, Any]]:
|
||||
return items[:30]
|
||||
|
||||
|
||||
def backup_schedule_calendar(frequency: str) -> str | None:
|
||||
calendars = {
|
||||
"off": None,
|
||||
"daily": "*-*-* 03:20:00",
|
||||
"weekly": "Sun 03:20:00",
|
||||
"monthly": "*-*-01 03:20:00",
|
||||
}
|
||||
if frequency not in calendars:
|
||||
raise ValueError("unsupported backup schedule")
|
||||
return calendars[frequency]
|
||||
|
||||
|
||||
def backup_schedule_status() -> dict[str, Any]:
|
||||
raw = load_json(BACKUP_SCHEDULE_FILE, {}) or {}
|
||||
if not isinstance(raw, dict):
|
||||
raw = {}
|
||||
frequency = str(raw.get("frequency") or "off")
|
||||
try:
|
||||
calendar = backup_schedule_calendar(frequency)
|
||||
except ValueError:
|
||||
frequency = "off"
|
||||
calendar = None
|
||||
active_code, active, _ = run(["systemctl", "is-active", "gotelegram-backup.timer"], timeout=5)
|
||||
enabled_code, enabled, _ = run(["systemctl", "is-enabled", "gotelegram-backup.timer"], timeout=5)
|
||||
_, next_run, _ = run(["systemctl", "show", "gotelegram-backup.timer", "--property=NextElapseUSecRealtime", "--value"], timeout=5)
|
||||
return {
|
||||
"frequency": frequency,
|
||||
"calendar": calendar,
|
||||
"enabled": enabled_code == 0 and enabled.strip() == "enabled",
|
||||
"active": active_code == 0 and active.strip() == "active",
|
||||
"next": next_run.strip(),
|
||||
"updated_at": raw.get("updated_at") or "",
|
||||
}
|
||||
|
||||
|
||||
def set_backup_schedule(frequency: str) -> tuple[bool, str, dict[str, Any]]:
|
||||
backup_schedule_calendar(frequency)
|
||||
script = (
|
||||
"source /opt/gotelegram/lib/common.sh; "
|
||||
"source /opt/gotelegram/lib/i18n.sh; "
|
||||
"source /opt/gotelegram/lib/backup.sh; "
|
||||
"load_language \"$(detect_language 2>/dev/null || echo en)\"; "
|
||||
f"set_backup_schedule {shlex.quote(frequency)}"
|
||||
)
|
||||
code, stdout, stderr = run(["bash", "-lc", script], timeout=120)
|
||||
message = (stdout.strip().splitlines()[-1:] or stderr.strip().splitlines()[-1:] or [""])[0]
|
||||
return code == 0, message, backup_schedule_status()
|
||||
|
||||
|
||||
def create_backup() -> tuple[bool, str]:
|
||||
script = (
|
||||
"source /opt/gotelegram/lib/common.sh; "
|
||||
@@ -917,13 +983,71 @@ def create_backup() -> tuple[bool, str]:
|
||||
"source /opt/gotelegram/lib/website.sh; "
|
||||
"source /opt/gotelegram/lib/backup.sh; "
|
||||
"load_language \"$(detect_language 2>/dev/null || echo en)\"; "
|
||||
"create_backup \"\""
|
||||
"create_backup \"\"; "
|
||||
"cleanup_old_backups 30"
|
||||
)
|
||||
code, stdout, stderr = run(["bash", "-lc", script], timeout=180)
|
||||
text = (stdout.strip().splitlines()[-1:] or stderr.strip().splitlines()[-1:] or [""])[0]
|
||||
return code == 0, text
|
||||
|
||||
|
||||
def safe_backup_path(name: str) -> Path:
|
||||
raw = str(name or "").strip()
|
||||
if not raw or raw != os.path.basename(raw) or not BACKUP_NAME_RE.match(raw) or raw.endswith(".sha256"):
|
||||
raise ValueError("invalid backup name")
|
||||
candidate = (BACKUP_DIR / raw).resolve()
|
||||
base = BACKUP_DIR.resolve()
|
||||
if base != candidate.parent:
|
||||
raise ValueError("invalid backup path")
|
||||
if not candidate.exists():
|
||||
raise FileNotFoundError("backup not found")
|
||||
return candidate
|
||||
|
||||
|
||||
def launch_restore_backup(name: str, password: str = "") -> dict[str, Any]:
|
||||
backup_path = safe_backup_path(name)
|
||||
if backup_path.name.endswith(".enc") and not password:
|
||||
raise ValueError("password required for encrypted backup")
|
||||
BACKUP_RESTORE_LOG.parent.mkdir(parents=True, exist_ok=True)
|
||||
quoted_path = shlex.quote(str(backup_path))
|
||||
quoted_password = shlex.quote(password)
|
||||
quoted_log = shlex.quote(str(BACKUP_RESTORE_LOG))
|
||||
script = (
|
||||
"sleep 1; "
|
||||
"source /opt/gotelegram/lib/common.sh; "
|
||||
"source /opt/gotelegram/lib/i18n.sh; "
|
||||
"source /opt/gotelegram/lib/telemt.sh; "
|
||||
"source /opt/gotelegram/lib/website.sh; "
|
||||
"source /opt/gotelegram/lib/backup.sh; "
|
||||
"load_language \"$(detect_language 2>/dev/null || echo en)\"; "
|
||||
"create_backup \"\" >/dev/null 2>&1 || true; "
|
||||
f"restore_backup {quoted_path} {quoted_password} yes; "
|
||||
"cleanup_old_backups 30"
|
||||
)
|
||||
with BACKUP_RESTORE_LOG.open("ab") as log:
|
||||
log.write(f"\n[{utc_now()}] restore requested for {backup_path.name}\n".encode("utf-8"))
|
||||
subprocess.Popen(
|
||||
["bash", "-lc", f"{script} >> {quoted_log} 2>&1"],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
return {"name": backup_path.name, "started": True, "log": str(BACKUP_RESTORE_LOG)}
|
||||
|
||||
|
||||
def user_qr_png(name: str) -> tuple[bytes, str]:
|
||||
users = read_user_records()
|
||||
record = users.get(name)
|
||||
if not record:
|
||||
raise FileNotFoundError("user not found")
|
||||
link = proxy_link(str(record.get("secret", "")))
|
||||
code, image, error = run_bytes(["qrencode", "-t", "PNG", "-s", "8", "-m", "2", "-o", "-", link], timeout=8)
|
||||
if code != 0 or not image:
|
||||
raise RuntimeError(error.strip() or "qrencode is not installed")
|
||||
return image, link
|
||||
|
||||
|
||||
def read_log_payload(service: str) -> dict[str, Any]:
|
||||
allowed = {"telemt", "nginx", "gotelegram-bot", "gotelegram-stats", "gotelegram-admin"}
|
||||
if service not in allowed:
|
||||
@@ -999,6 +1123,7 @@ def overview_payload() -> dict[str, Any]:
|
||||
"stats_status": stats_status(current, history),
|
||||
"runtime_summary": summary,
|
||||
"backups": list_backups(),
|
||||
"backup_schedule": backup_schedule_status(),
|
||||
}
|
||||
|
||||
|
||||
@@ -1017,6 +1142,14 @@ class AdminHandler(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def send_bytes(self, body: bytes, content_type: str, status: int = 200) -> None:
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def send_error_json(self, status: int, message: str) -> None:
|
||||
self.send_json({"ok": False, "error": message}, status)
|
||||
|
||||
@@ -1046,6 +1179,23 @@ class AdminHandler(BaseHTTPRequestHandler):
|
||||
record = users[name]
|
||||
items.append(user_payload(name, record["secret"], record["enabled"], traffic_snapshot=latest.get(name)))
|
||||
self.send_json({"ok": True, "data": items})
|
||||
elif path.startswith("/api/users/") and path.endswith("/qr"):
|
||||
name = urllib.parse.unquote(path[len("/api/users/"):-len("/qr")])
|
||||
try:
|
||||
png, link = user_qr_png(name)
|
||||
except FileNotFoundError:
|
||||
self.send_error_json(404, "user not found")
|
||||
return
|
||||
except Exception as exc:
|
||||
self.send_error_json(503, str(exc))
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "image/png")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Proxy-Link", urllib.parse.quote(link, safe=""))
|
||||
self.send_header("Content-Length", str(len(png)))
|
||||
self.end_headers()
|
||||
self.wfile.write(png)
|
||||
elif path.startswith("/api/users/") and path.endswith("/traffic"):
|
||||
name = urllib.parse.unquote(path[len("/api/users/"):-len("/traffic")])
|
||||
users = read_user_records()
|
||||
@@ -1084,6 +1234,8 @@ class AdminHandler(BaseHTTPRequestHandler):
|
||||
self.send_json({"ok": True, "data": user_payload(name, record["secret"], record["enabled"], include_runtime=True, traffic_snapshot=latest_user_stats().get(name))})
|
||||
elif path == "/api/backups":
|
||||
self.send_json({"ok": True, "data": list_backups()})
|
||||
elif path == "/api/backups/schedule":
|
||||
self.send_json({"ok": True, "data": backup_schedule_status()})
|
||||
elif path == "/api/stats":
|
||||
qs = urllib.parse.parse_qs(parsed.query)
|
||||
range_key = normalize_range(qs.get("range", ["1h"])[0])
|
||||
@@ -1179,6 +1331,27 @@ class AdminHandler(BaseHTTPRequestHandler):
|
||||
elif path == "/api/backups":
|
||||
ok, result = create_backup()
|
||||
self.send_json({"ok": ok, "data": {"path": result, "backups": list_backups()}}, 200 if ok else 500)
|
||||
elif path == "/api/backups/schedule":
|
||||
try:
|
||||
frequency = str(body.get("frequency") or "off").strip().lower()
|
||||
ok, message, status = set_backup_schedule(frequency)
|
||||
except ValueError as exc:
|
||||
self.send_error_json(400, str(exc))
|
||||
return
|
||||
self.send_json({"ok": ok, "data": {"message": message, "schedule": status}}, 200 if ok else 500)
|
||||
elif path == "/api/backups/restore":
|
||||
try:
|
||||
payload = launch_restore_backup(str(body.get("name") or ""), str(body.get("password") or ""))
|
||||
except FileNotFoundError:
|
||||
self.send_error_json(404, "backup not found")
|
||||
return
|
||||
except ValueError as exc:
|
||||
self.send_error_json(400, str(exc))
|
||||
return
|
||||
except Exception as exc:
|
||||
self.send_error_json(500, str(exc))
|
||||
return
|
||||
self.send_json({"ok": True, "data": payload}, 202)
|
||||
elif path == "/api/stats/collect":
|
||||
ok, message, payload = run_stats_action("collect")
|
||||
payload["message"] = message
|
||||
|
||||
@@ -64,6 +64,7 @@ const i18n = {
|
||||
addKey: "Add key",
|
||||
copyLink: "Copy link",
|
||||
copySecret: "Copy secret",
|
||||
showQr: "QR",
|
||||
delete: "Delete",
|
||||
enabled: "Enabled",
|
||||
disabled: "Disabled",
|
||||
@@ -73,6 +74,20 @@ const i18n = {
|
||||
enableKey: "Enable key",
|
||||
main: "main",
|
||||
createBackup: "Create backup",
|
||||
restoreBackup: "Restore",
|
||||
backupScheduleTitle: "Automatic backups",
|
||||
backupScheduleLoading: "Loading schedule...",
|
||||
backupIncludesTitle: "Backup contents",
|
||||
backupIncludesText: "telemt config, goTelegram settings, keys, disabled keys, site, templates, SSL certificates, bot, admin panel and traffic history.",
|
||||
scheduleOff: "Off",
|
||||
scheduleDaily: "Daily",
|
||||
scheduleWeekly: "Weekly",
|
||||
scheduleMonthly: "Monthly",
|
||||
scheduleSaved: "Schedule saved",
|
||||
scheduleNext: "Next run: {value}",
|
||||
scheduleDisabled: "Automatic backups are disabled",
|
||||
backupRestoreStarted: "Restore started",
|
||||
confirmRestoreBackup: "Restore backup",
|
||||
loadLogs: "Load",
|
||||
panelLanguage: "Panel language",
|
||||
theme: "Theme",
|
||||
@@ -122,6 +137,7 @@ const i18n = {
|
||||
keyCreated: "Key created",
|
||||
keyDeleted: "Key deleted",
|
||||
backupCreated: "Backup created",
|
||||
qrUnavailable: "QR code is unavailable",
|
||||
serviceRestarted: "Service restarted",
|
||||
statsRepaired: "Collector restarted",
|
||||
statsCollected: "Statistics collected",
|
||||
@@ -189,6 +205,8 @@ const i18n = {
|
||||
promoHosting1: "Hosting #1",
|
||||
promoHosting2: "Hosting #2",
|
||||
promoTips: "Tips",
|
||||
qrEyebrow: "QR import",
|
||||
qrTitle: "Scan Telegram proxy",
|
||||
pageDashboardTitle: "Dashboard",
|
||||
pageDashboardKicker: "Local Admin",
|
||||
pageTrafficTitle: "Traffic",
|
||||
@@ -264,6 +282,7 @@ const i18n = {
|
||||
addKey: "Добавить ключ",
|
||||
copyLink: "Копировать ссылку",
|
||||
copySecret: "Копировать секрет",
|
||||
showQr: "QR",
|
||||
delete: "Удалить",
|
||||
enabled: "Включён",
|
||||
disabled: "Отключён",
|
||||
@@ -273,6 +292,20 @@ const i18n = {
|
||||
enableKey: "Включить ключ",
|
||||
main: "основной",
|
||||
createBackup: "Создать бекап",
|
||||
restoreBackup: "Восстановить",
|
||||
backupScheduleTitle: "Автобекапы",
|
||||
backupScheduleLoading: "Загрузка расписания...",
|
||||
backupIncludesTitle: "Что входит в бекап",
|
||||
backupIncludesText: "конфиг telemt, настройки goTelegram, ключи, отключённые ключи, сайт, шаблоны, SSL-сертификаты, бот, админка и история трафика.",
|
||||
scheduleOff: "Выкл",
|
||||
scheduleDaily: "Каждый день",
|
||||
scheduleWeekly: "Каждую неделю",
|
||||
scheduleMonthly: "Каждый месяц",
|
||||
scheduleSaved: "Расписание сохранено",
|
||||
scheduleNext: "Следующий запуск: {value}",
|
||||
scheduleDisabled: "Автобекапы отключены",
|
||||
backupRestoreStarted: "Восстановление запущено",
|
||||
confirmRestoreBackup: "Восстановить бекап",
|
||||
loadLogs: "Загрузить",
|
||||
panelLanguage: "Язык панели",
|
||||
theme: "Тема",
|
||||
@@ -322,6 +355,7 @@ const i18n = {
|
||||
keyCreated: "Ключ создан",
|
||||
keyDeleted: "Ключ удалён",
|
||||
backupCreated: "Бекап создан",
|
||||
qrUnavailable: "QR-код недоступен",
|
||||
serviceRestarted: "Сервис перезапущен",
|
||||
statsRepaired: "Сборщик перезапущен",
|
||||
statsCollected: "Статистика собрана",
|
||||
@@ -389,6 +423,8 @@ const i18n = {
|
||||
promoHosting1: "Хостинг #1",
|
||||
promoHosting2: "Хостинг #2",
|
||||
promoTips: "Чаевые",
|
||||
qrEyebrow: "QR-импорт",
|
||||
qrTitle: "Сканирование прокси Telegram",
|
||||
pageDashboardTitle: "Обзор",
|
||||
pageDashboardKicker: "Локальная админка",
|
||||
pageTrafficTitle: "Трафик",
|
||||
@@ -420,6 +456,8 @@ const state = {
|
||||
userTrafficView: "chart",
|
||||
userTraffic: null,
|
||||
userTrafficLoading: false,
|
||||
backupSchedule: null,
|
||||
qrLink: "",
|
||||
pendingUsers: new Set(),
|
||||
};
|
||||
|
||||
@@ -514,6 +552,7 @@ function applyI18n() {
|
||||
$("#visualText").textContent = t("visualText");
|
||||
updateTrafficControls();
|
||||
updateUserTrafficControls();
|
||||
renderBackupSchedule();
|
||||
updatePageTitle();
|
||||
}
|
||||
|
||||
@@ -1091,7 +1130,12 @@ function renderUsers() {
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="${escapeAttr(t("tableSecret"))}"><code title="${escapeAttr(user.secret)}">${escapeHtml(user.secret)}</code></td>
|
||||
<td data-label="${escapeAttr(t("tableLink"))}"><button class="soft" data-copy="${escapeAttr(user.link)}" ${user.enabled ? "" : "disabled"}>${escapeHtml(t("copyLink"))}</button></td>
|
||||
<td data-label="${escapeAttr(t("tableLink"))}">
|
||||
<div class="mini-actions">
|
||||
<button class="soft" data-copy="${escapeAttr(user.link)}" ${user.enabled ? "" : "disabled"}>${escapeHtml(t("copyLink"))}</button>
|
||||
<button class="soft" data-user-qr="${escapeAttr(user.name)}">${escapeHtml(t("showQr"))}</button>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="${escapeAttr(t("tableTraffic"))}">
|
||||
<div class="traffic-cell">
|
||||
<strong>${escapeHtml(trafficTotal)}</strong>
|
||||
@@ -1109,6 +1153,7 @@ function renderUsers() {
|
||||
|
||||
function renderBackups(backups) {
|
||||
const box = $("#backupsList");
|
||||
renderBackupSchedule();
|
||||
if (!backups.length) {
|
||||
box.innerHTML = `<div class="empty">${escapeHtml(t("noBackups"))}</div>`;
|
||||
return;
|
||||
@@ -1119,11 +1164,26 @@ function renderBackups(backups) {
|
||||
<strong>${escapeHtml(item.name)}</strong>
|
||||
<span>${escapeHtml(item.path)} · ${escapeHtml(fmtDate(item.mtime))}</span>
|
||||
</div>
|
||||
<div>${escapeHtml(fmtBytes(item.size))}${item.encrypted ? ` · ${escapeHtml(t("encrypted"))}` : ""}</div>
|
||||
<div class="backup-actions">
|
||||
<span>${escapeHtml(fmtBytes(item.size))}${item.encrypted ? ` · ${escapeHtml(t("encrypted"))}` : ""}</span>
|
||||
<button class="soft" data-restore-backup="${escapeAttr(item.name)}">${escapeHtml(t("restoreBackup"))}</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderBackupSchedule() {
|
||||
const schedule = state.backupSchedule || state.overview?.backup_schedule || { frequency: "off" };
|
||||
const frequency = schedule.frequency || "off";
|
||||
$$("[data-backup-schedule]").forEach((btn) => {
|
||||
btn.classList.toggle("active", btn.dataset.backupSchedule === frequency);
|
||||
});
|
||||
const next = schedule.next && schedule.next !== "n/a" ? schedule.next : "";
|
||||
$("#backupScheduleMeta").textContent = frequency === "off"
|
||||
? t("scheduleDisabled")
|
||||
: t("scheduleNext").replace("{value}", next || (schedule.calendar || "--"));
|
||||
}
|
||||
|
||||
function renderEvents() {
|
||||
const box = $("#events");
|
||||
if (!state.events.length) {
|
||||
@@ -1162,6 +1222,7 @@ async function refreshAll() {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
state.overview = await api("/api/overview");
|
||||
state.backupSchedule = state.overview.backup_schedule || state.backupSchedule;
|
||||
updateLanguageFromOverview(state.overview);
|
||||
state.users = await api("/api/users");
|
||||
if (!state.stats) {
|
||||
@@ -1324,6 +1385,53 @@ async function createBackup() {
|
||||
}
|
||||
}
|
||||
|
||||
async function setBackupSchedule(frequency) {
|
||||
$$("[data-backup-schedule]").forEach((btn) => { btn.disabled = true; });
|
||||
try {
|
||||
const data = await api("/api/backups/schedule", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ frequency }),
|
||||
});
|
||||
state.backupSchedule = data.schedule || data;
|
||||
renderBackupSchedule();
|
||||
addEvent(t("scheduleSaved"), frequency);
|
||||
toast(t("scheduleSaved"));
|
||||
} catch (err) {
|
||||
toast(err.message);
|
||||
} finally {
|
||||
$$("[data-backup-schedule]").forEach((btn) => { btn.disabled = false; });
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreBackup(name) {
|
||||
const data = await api("/api/backups/restore", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
addEvent(t("backupRestoreStarted"), data.name || name);
|
||||
toast(t("backupRestoreStarted"));
|
||||
setTimeout(() => refreshAll().catch((err) => toast(err.message)), 4000);
|
||||
}
|
||||
|
||||
function showUserQr(name) {
|
||||
const user = state.users.find((item) => item.name === name);
|
||||
if (!user) {
|
||||
toast(t("qrUnavailable"));
|
||||
return;
|
||||
}
|
||||
state.qrLink = user.link || "";
|
||||
$("#qrTitle").textContent = `${t("qrTitle")} · ${user.name}`;
|
||||
$("#qrMeta").textContent = user.link || "";
|
||||
const img = $("#qrImage");
|
||||
img.alt = `${user.name} Telegram proxy QR`;
|
||||
img.onerror = () => {
|
||||
img.removeAttribute("src");
|
||||
toast(t("qrUnavailable"));
|
||||
};
|
||||
img.src = `/api/users/${encodeURIComponent(user.name)}/qr?ts=${Date.now()}`;
|
||||
$("#qrModal").hidden = false;
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
const service = $("#logService").value;
|
||||
const btn = $("#loadLogsBtn");
|
||||
@@ -1436,11 +1544,18 @@ document.addEventListener("click", async (eventObj) => {
|
||||
} else if (button.dataset.userTraffic) {
|
||||
state.userTrafficUser = button.dataset.userTraffic;
|
||||
refreshUserTraffic({ showLoading: true }).catch((err) => toast(err.message));
|
||||
} else if (button.dataset.userQr) {
|
||||
showUserQr(button.dataset.userQr);
|
||||
} else if (button.dataset.userTrafficRange) {
|
||||
changeUserTrafficRange(button.dataset.userTrafficRange);
|
||||
} else if (button.dataset.userTrafficView) {
|
||||
state.userTrafficView = button.dataset.userTrafficView === "table" ? "table" : "chart";
|
||||
renderUserTraffic();
|
||||
} else if (button.dataset.backupSchedule) {
|
||||
setBackupSchedule(button.dataset.backupSchedule);
|
||||
} else if (button.dataset.restoreBackup) {
|
||||
const name = button.dataset.restoreBackup;
|
||||
if (confirm(`${t("confirmRestoreBackup")} ${name}?`)) restoreBackup(name).catch((err) => toast(err.message));
|
||||
} else if (button.dataset.copy) {
|
||||
await copyText(button.dataset.copy);
|
||||
} else if (button.dataset.delete) {
|
||||
@@ -1480,6 +1595,12 @@ $("#languageSelect").addEventListener("change", (eventObj) => setLanguage(eventO
|
||||
$("#promoClose").addEventListener("click", () => {
|
||||
$("#promoModal").hidden = true;
|
||||
});
|
||||
$("#qrClose").addEventListener("click", () => {
|
||||
$("#qrModal").hidden = true;
|
||||
});
|
||||
$("#qrCopyBtn").addEventListener("click", () => {
|
||||
if (state.qrLink) copyText(state.qrLink);
|
||||
});
|
||||
$("#createBackupBtn").addEventListener("click", createBackup);
|
||||
$("#loadLogsBtn").addEventListener("click", loadLogs);
|
||||
$("#repairStatsBtn").addEventListener("click", repairStats);
|
||||
|
||||
@@ -272,6 +272,22 @@
|
||||
</div>
|
||||
<button id="createBackupBtn" type="button" data-i18n="createBackup">Create backup</button>
|
||||
</div>
|
||||
<div class="backup-schedule">
|
||||
<div>
|
||||
<strong data-i18n="backupScheduleTitle">Automatic backups</strong>
|
||||
<span id="backupScheduleMeta" data-i18n="backupScheduleLoading">Loading schedule...</span>
|
||||
</div>
|
||||
<div class="segmented compact" role="group" aria-label="Backup schedule">
|
||||
<button type="button" data-backup-schedule="off" data-i18n="scheduleOff">Off</button>
|
||||
<button type="button" data-backup-schedule="daily" data-i18n="scheduleDaily">Daily</button>
|
||||
<button type="button" data-backup-schedule="weekly" data-i18n="scheduleWeekly">Weekly</button>
|
||||
<button type="button" data-backup-schedule="monthly" data-i18n="scheduleMonthly">Monthly</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="backup-includes">
|
||||
<strong data-i18n="backupIncludesTitle">Backup contents</strong>
|
||||
<span data-i18n="backupIncludesText">telemt config, goTelegram settings, keys, disabled keys, site, templates, SSL certificates, bot, admin panel and traffic history.</span>
|
||||
</div>
|
||||
<div id="backupsList" class="backup-list"></div>
|
||||
</section>
|
||||
|
||||
@@ -351,6 +367,18 @@
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
<div id="qrModal" class="promo-modal" hidden>
|
||||
<div class="promo-card qr-card" role="dialog" aria-modal="true" aria-labelledby="qrTitle">
|
||||
<button id="qrClose" class="icon-btn ghost" type="button" aria-label="Close" data-i18n-aria-label="ariaClose">×</button>
|
||||
<p class="eyebrow" data-i18n="qrEyebrow">QR import</p>
|
||||
<h2 id="qrTitle" data-i18n="qrTitle">Scan Telegram proxy</h2>
|
||||
<div class="qr-frame">
|
||||
<img id="qrImage" alt="Telegram proxy QR">
|
||||
</div>
|
||||
<p id="qrMeta" class="modal-note"></p>
|
||||
<button id="qrCopyBtn" type="button" class="soft" data-i18n="copyLink">Copy link</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="promoModal" class="promo-modal" hidden>
|
||||
<div class="promo-card" role="dialog" aria-modal="true" aria-labelledby="promoTitle">
|
||||
<button id="promoClose" class="icon-btn ghost" type="button" aria-label="Close" data-i18n-aria-label="ariaClose">×</button>
|
||||
@@ -372,6 +400,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/app.js?v=2.5.0-admin8" type="module"></script>
|
||||
<script src="/app.js?v=2.5.0-admin9" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -923,6 +923,39 @@ td small {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mini-actions,
|
||||
.backup-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.backup-schedule,
|
||||
.backup-includes {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel-soft);
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.backup-schedule {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.backup-schedule span,
|
||||
.backup-includes span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.backup-item,
|
||||
.event,
|
||||
.settings-list > div {
|
||||
@@ -965,6 +998,36 @@ td small {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.qr-card {
|
||||
width: min(440px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.qr-frame {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin: 12px 0;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.qr-frame img {
|
||||
display: block;
|
||||
width: min(280px, 70vw);
|
||||
aspect-ratio: 1;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.modal-note {
|
||||
max-height: 92px;
|
||||
overflow: auto;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
right: 22px;
|
||||
@@ -1215,12 +1278,23 @@ td small {
|
||||
}
|
||||
|
||||
.backup-item,
|
||||
.backup-schedule,
|
||||
.event,
|
||||
.settings-list > div,
|
||||
.port-listener,
|
||||
.port-empty {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.mini-actions,
|
||||
.backup-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.mini-actions button,
|
||||
.backup-actions button {
|
||||
flex: 1 1 130px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 460px) {
|
||||
|
||||
Reference in New Issue
Block a user