From 58aad90dee5c19e77754471cde479965ee923119 Mon Sep 17 00:00:00 2001 From: orohi Date: Sun, 9 Aug 2026 16:22:38 +0300 Subject: [PATCH] Add explicit database import UI and harden SQL restore. --- README.md | 2 +- app.py | 10 ++++++-- db/backup.py | 25 +++++++++++++++++-- templates/settings.html | 55 ++++++++++++++++++++++++++--------------- translations/en.json | 12 ++++++--- translations/fa.json | 12 ++++++--- translations/fr.json | 12 ++++++--- translations/ru.json | 12 ++++++--- translations/zh.json | 12 ++++++--- 9 files changed, 107 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 41259ac..cce9bc5 100644 --- a/README.md +++ b/README.md @@ -408,7 +408,7 @@ Routes are grouped in the docs as: | **Users** | Panel user accounts and the connections assigned to them. | | **Self-service** | Endpoints called by a regular user for their own data (`/api/my/*`). | | **Sharing** | Public, token-protected configuration sharing — no panel session required. | -| **Settings** | Panel-wide settings, Telegram bot, Remnawave sync, JSON backup/restore. | +| **Settings** | Panel-wide settings, Telegram bot, Remnawave sync, SQL/JSON backup export & import. | | **API Tokens** | Create and revoke bearer tokens for external integrations. | **Authentication for external integrations** — both session cookies and `Authorization: Bearer ` are accepted on every admin endpoint. Example: diff --git a/app.py b/app.py index ee42cf1..e6cc963 100644 --- a/app.py +++ b/app.py @@ -103,7 +103,7 @@ else: application_path = os.path.dirname(__file__) DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export -CURRENT_VERSION = "v2.6.9" +CURRENT_VERSION = "v2.7.0" RELEASES_REPO_URL = repo_url() RELEASES_API_LATEST = api_latest_url() BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin')) @@ -6940,7 +6940,9 @@ async def api_backup_download_json(request: Request): @app.post('/api/settings/backup/restore', tags=["Settings"]) +@app.post('/api/settings/backup/import', tags=["Settings"]) async def api_backup_restore(request: Request, file: UploadFile = File(...)): + """Import panel database from a .sql / .sql.gz dump or legacy data.json.""" if not _check_admin(request): return JSONResponse({'error': 'Forbidden'}, status_code=403) try: @@ -6949,7 +6951,11 @@ async def api_backup_restore(request: Request, file: UploadFile = File(...)): return JSONResponse({'error': 'Empty file'}, status_code=400) filename = (file.filename or '').lower() - is_json = filename.endswith('.json') or content.lstrip().startswith(b'{') + is_gzip = filename.endswith('.gz') or content[:2] == b'\x1f\x8b' + is_json = ( + not is_gzip + and (filename.endswith('.json') or content.lstrip()[:1] in (b'{', b'[')) + ) if is_json: try: diff --git a/db/backup.py b/db/backup.py index 66e8a51..0aa01a7 100644 --- a/db/backup.py +++ b/db/backup.py @@ -2,12 +2,13 @@ from __future__ import annotations +import gzip import logging import os import subprocess from datetime import datetime, timezone -from .connection import get_pg_connection_params +from .connection import close_pool, get_pg_connection_params from .store import invalidate_data_cache logger = logging.getLogger(__name__) @@ -25,6 +26,18 @@ def backup_filename() -> str: return f'amnezia_panel_backup_{stamp}.sql' +def _decode_backup_bytes(data: bytes) -> bytes: + """Accept plain .sql or gzip-compressed dumps (.sql.gz / gzip magic).""" + if not data: + return data + if data[:2] == b'\x1f\x8b': + try: + return gzip.decompress(data) + except OSError as e: + raise ValueError(f'Invalid gzip backup: {e}') from e + return data + + def export_database_sql() -> bytes: """Create a plain SQL dump of the panel PostgreSQL database.""" params = get_pg_connection_params() @@ -54,8 +67,16 @@ def export_database_sql() -> bytes: def restore_database_sql(data: bytes) -> None: """Restore panel data from a plain SQL dump produced by pg_dump.""" + data = _decode_backup_bytes(data) if not data or not data.strip(): raise ValueError('Empty backup file') + + # Drop live pool connections so --clean DROP TABLE is not blocked. + try: + close_pool() + except Exception as e: + logger.warning('close_pool before restore failed: %s', e) + params = get_pg_connection_params() proc = subprocess.run( [ @@ -72,9 +93,9 @@ def restore_database_sql(data: bytes) -> None: check=False, env=_pg_cli_env(params['password']), ) + invalidate_data_cache() if proc.returncode != 0: err = proc.stderr.decode('utf-8', errors='replace').strip() out = proc.stdout.decode('utf-8', errors='replace').strip() raise RuntimeError(err or out or 'psql restore failed') - invalidate_data_cache() logger.info('PostgreSQL backup restored successfully') diff --git a/templates/settings.html b/templates/settings.html index 49e4fe4..b4c9024 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -600,29 +600,34 @@ - +

📤 {{ _('backup_title') }}

-
- - ⬇️ {{ _('download_backup') }} - - - 📄 {{ _('download_backup_json') }} - +
+
{{ _('backup_import_label') }}
- - +
@@ -1667,30 +1672,40 @@ const file = e.target.files[0]; if (!file) return; + const nameEl = document.getElementById('backupFileName'); + if (nameEl) nameEl.textContent = file.name; + if (!confirm(_('restore_confirm'))) { e.target.value = ''; + if (nameEl) nameEl.textContent = ''; return; } const btn = document.getElementById('restoreBtn'); btn.disabled = true; const originalText = btn.innerHTML; - btn.innerHTML = `
${_('loading')}`; + btn.innerHTML = `
${_('importing_backup')}`; const formData = new FormData(); formData.append('file', file); try { - const res = await fetch('/api/settings/backup/restore', { + const res = await fetch('/api/settings/backup/import', { method: 'POST', - body: formData + body: formData, + credentials: 'same-origin', }); - const data = await res.json(); - if (data.status === 'success') { + let data = {}; + try { + data = await res.json(); + } catch (_) { + data = {}; + } + if (res.ok && data.status === 'success') { showToast(_('restore_success'), 'success'); setTimeout(() => window.location.reload(), 2000); } else { - showToast(data.error || _('invalid_backup_file'), 'error'); + showToast(data.error || _('invalid_backup_file') + ` (HTTP ${res.status})`, 'error'); } } catch (err) { showToast(`${_('error')}: ` + err.message, 'error'); diff --git a/translations/en.json b/translations/en.json index bff2b5f..31191e6 100644 --- a/translations/en.json +++ b/translations/en.json @@ -362,13 +362,17 @@ "lang_zh": "中文 (Chinese)", "lang_fa": "فارسی (Persian)", "backup_title": "Simple Backup", + "backup_export_label": "Export", + "backup_import_label": "Import", "download_backup": "Download PostgreSQL dump (.sql)", "download_backup_json": "Export JSON (legacy)", - "backup_hint": "Full PostgreSQL database dump of the panel. Use .sql for complete backup; JSON export is compatible with older versions.", + "backup_hint": "Full PostgreSQL database dump of the panel (.sql / .sql.gz). JSON export is compatible with older versions.", "restore_backup": "Restore from .sql or .json", - "restore_confirm": "Restore will overwrite all current panel data in the database.", - "restore_success": "Restore successful! Restarting...", - "invalid_backup_file": "Invalid backup file (.sql dump or legacy data.json)", + "import_backup": "Import database (.sql / .sql.gz / .json)", + "importing_backup": "Importing database...", + "restore_confirm": "Import will overwrite all current panel data in the database. Continue?", + "restore_success": "Import successful! Reloading...", + "invalid_backup_file": "Invalid backup file (.sql, .sql.gz or legacy data.json)", "config_unavailable": "Configuration unavailable", "config_unavailable_desc": "This client was created via the native Amnezia app.\\nThe private key is stored only on the user\u0027s device and cannot be recovered by the server.", "client_public_key": "Client public key:", diff --git a/translations/fa.json b/translations/fa.json index 247af22..1317474 100644 --- a/translations/fa.json +++ b/translations/fa.json @@ -344,13 +344,17 @@ "lang_zh": "中文 (Chinese)", "lang_fa": "فارسی (Persian)", "backup_title": "پشتیبان‌گیری ساده", + "backup_export_label": "خروجی", + "backup_import_label": "واردات", "download_backup": "دانلود dump PostgreSQL (.sql)", "download_backup_json": "خروجی JSON (قدیمی)", - "backup_hint": "Dump کامل پایگاه داده PostgreSQL پنل. برای پشتیبان کامل از .sql استفاده کنید؛ JSON برای نسخه‌های قدیمی.", + "backup_hint": "Dump کامل پایگاه داده PostgreSQL پنل (.sql / .sql.gz). JSON برای نسخه‌های قدیمی.", "restore_backup": "بازیابی از .sql یا .json", - "restore_confirm": "بازیابی تمام داده‌های فعلی پنل در پایگاه داده را بازنویسی می‌کند.", - "restore_success": "بازیابی موفقیت‌آمیز بود! در حال راه‌اندازی مجدد...", - "invalid_backup_file": "فایل نامعتبر (dump .sql یا data.json قدیمی)", + "import_backup": "واردات پایگاه داده (.sql / .sql.gz / .json)", + "importing_backup": "در حال واردات پایگاه داده...", + "restore_confirm": "واردات تمام داده‌های فعلی پنل در پایگاه داده را بازنویسی می‌کند. ادامه؟", + "restore_success": "واردات موفقیت‌آمیز بود! در حال بارگذاری مجدد...", + "invalid_backup_file": "فایل نامعتبر (.sql، .sql.gz یا data.json قدیمی)", "config_unavailable": "پیکربندی در دسترس نیست", "config_unavailable_desc": "این کلاینت از طریق اپلیکیشن اصلی Amnezia ایجاد شده است.\\nکلید خصوصی فقط در دستگاه کاربر ذخیره می‌شود و توسط سرور قابل بازیابی نیست.", "client_public_key": "کلید عمومی کلاینت:", diff --git a/translations/fr.json b/translations/fr.json index ea21a46..fff709c 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -344,13 +344,17 @@ "lang_zh": "中文 (Chinese)", "lang_fa": "فارسی (Persian)", "backup_title": "Sauvegarde Simple", + "backup_export_label": "Export", + "backup_import_label": "Import", "download_backup": "Télécharger le dump PostgreSQL (.sql)", "download_backup_json": "Exporter JSON (ancien)", - "backup_hint": "Dump complet de la base PostgreSQL du panneau. Utilisez .sql pour une sauvegarde complète ; JSON pour l'ancien format.", + "backup_hint": "Dump complet de la base PostgreSQL du panneau (.sql / .sql.gz). JSON pour l'ancien format.", "restore_backup": "Restaurer depuis .sql ou .json", - "restore_confirm": "La restauration écrasera toutes les données actuelles du panneau dans la base.", - "restore_success": "Restauration réussie ! Redémarrage...", - "invalid_backup_file": "Fichier invalide (dump .sql ou ancien data.json)", + "import_backup": "Importer la base (.sql / .sql.gz / .json)", + "importing_backup": "Import de la base...", + "restore_confirm": "L'import écrasera toutes les données actuelles du panneau dans la base. Continuer ?", + "restore_success": "Import réussi ! Rechargement...", + "invalid_backup_file": "Fichier invalide (.sql, .sql.gz ou ancien data.json)", "config_unavailable": "Configuration indisponible", "config_unavailable_desc": "Ce client a été créé via l\u0027application native Amnezia.\\nLa clé privée est stockée uniquement sur l\u0027appareil de l\u0027utilisateur et ne peut pas être récupérée par le serveur.", "client_public_key": "Clé publique du client :", diff --git a/translations/ru.json b/translations/ru.json index 6be4f63..f14e909 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -362,13 +362,17 @@ "lang_zh": "中文 (Chinese)", "lang_fa": "فارسی (Persian)", "backup_title": "Резервное копирование", + "backup_export_label": "Экспорт", + "backup_import_label": "Импорт", "download_backup": "Скачать дамп PostgreSQL (.sql)", "download_backup_json": "Экспорт JSON (устар.)", - "backup_hint": "Полный дамп базы данных панели. Для бэкапа используйте .sql; JSON — для совместимости со старыми версиями.", + "backup_hint": "Полный дамп базы данных панели (.sql / .sql.gz). JSON — для совместимости со старыми версиями.", "restore_backup": "Восстановить из .sql или .json", - "restore_confirm": "Восстановление перезапишет все текущие данные панели в базе.", - "restore_success": "Восстановление успешно! Перезагрузка...", - "invalid_backup_file": "Неверный файл (.sql дамп или устаревший data.json)", + "import_backup": "Импортировать базу (.sql / .sql.gz / .json)", + "importing_backup": "Импорт базы...", + "restore_confirm": "Импорт перезапишет все текущие данные панели в базе. Продолжить?", + "restore_success": "Импорт успешно завершён! Перезагрузка...", + "invalid_backup_file": "Неверный файл (.sql, .sql.gz или устаревший data.json)", "config_unavailable": "Конфигурация недоступна", "config_unavailable_desc": "Этот клиент был создан через нативное приложение Amnezia.\\nПриватный ключ хранится только на устройстве пользователя и не может быть восстановлен сервером.", "client_public_key": "Публичный ключ клиента:", diff --git a/translations/zh.json b/translations/zh.json index 35a5c2f..c1e66bd 100644 --- a/translations/zh.json +++ b/translations/zh.json @@ -344,13 +344,17 @@ "lang_zh": "中文 (Chinese)", "lang_fa": "فارسی (Persian)", "backup_title": "简易备份", + "backup_export_label": "导出", + "backup_import_label": "导入", "download_backup": "下载 PostgreSQL 转储 (.sql)", "download_backup_json": "导出 JSON(旧版)", - "backup_hint": "面板 PostgreSQL 数据库的完整转储。请使用 .sql 进行完整备份;JSON 用于兼容旧版本。", + "backup_hint": "面板 PostgreSQL 数据库的完整转储(.sql / .sql.gz)。JSON 用于兼容旧版本。", "restore_backup": "从 .sql 或 .json 恢复", - "restore_confirm": "恢复将覆盖数据库中所有当前面板数据。", - "restore_success": "恢复成功!正在重启...", - "invalid_backup_file": "无效的备份文件(.sql 转储或旧版 data.json)", + "import_backup": "导入数据库(.sql / .sql.gz / .json)", + "importing_backup": "正在导入数据库...", + "restore_confirm": "导入将覆盖数据库中所有当前面板数据。继续?", + "restore_success": "导入成功!正在重新加载...", + "invalid_backup_file": "无效的备份文件(.sql、.sql.gz 或旧版 data.json)", "config_unavailable": "配置文件不可用", "config_unavailable_desc": "此客户端是通过 Amnezia 原生应用创建的。\\n私钥仅存储在用户设备上,服务器无法恢复。", "client_public_key": "客户端公钥:",