Compare commits

..
Author SHA1 Message Date
orohi 58aad90dee Add explicit database import UI and harden SQL restore. 2026-08-09 16:22:38 +03:00
9 changed files with 107 additions and 45 deletions
+1 -1
View File
@@ -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 <token>` are accepted on every admin endpoint. Example:
+8 -2
View File
@@ -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:
+23 -2
View File
@@ -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')
+35 -20
View File
@@ -600,29 +600,34 @@
</div>
</div>
<!-- BLOCK: Simple Backup -->
<!-- BLOCK: Backup / Import -->
<div class="card" style="margin-top: var(--space-lg);">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">📤 {{ _('backup_title') }}</h3>
<div style="display: flex; flex-direction: column; gap: var(--space-md);">
<div style="display: flex; gap: var(--space-sm); flex-wrap: wrap;">
<a href="/api/settings/backup/download" class="btn btn-secondary"
style="flex:1; min-width:200px; text-decoration:none; display:flex; align-items:center; justify-content:center; gap:var(--space-sm);">
<span>⬇️</span> {{ _('download_backup') }}
</a>
<a href="/api/settings/backup/download/json" class="btn btn-secondary"
style="flex:1; min-width:200px; text-decoration:none; display:flex; align-items:center; justify-content:center; gap:var(--space-sm);">
<span>📄</span> {{ _('download_backup_json') }}
</a>
<div>
<div class="form-label" style="margin-bottom: var(--space-sm);">{{ _('backup_export_label') }}</div>
<div style="display: flex; gap: var(--space-sm); flex-wrap: wrap;">
<a href="/api/settings/backup/download" class="btn btn-secondary"
style="flex:1; min-width:200px; text-decoration:none; display:flex; align-items:center; justify-content:center; gap:var(--space-sm);">
<span>⬇️</span> {{ _('download_backup') }}
</a>
<a href="/api/settings/backup/download/json" class="btn btn-secondary"
style="flex:1; min-width:200px; text-decoration:none; display:flex; align-items:center; justify-content:center; gap:var(--space-sm);">
<span>📄</span> {{ _('download_backup_json') }}
</a>
</div>
</div>
<div style="border-top: 1px solid var(--border-color); padding-top: var(--space-md);">
<div class="form-label" style="margin-bottom: var(--space-sm);">{{ _('backup_import_label') }}</div>
<div style="display: flex; flex-direction: column; gap: var(--space-sm);">
<input type="file" id="backupFile" accept=".sql,.json" style="display: none;"
onchange="handleRestore(event)">
<button type="button" class="btn btn-secondary"
<input type="file" id="backupFile" accept=".sql,.sql.gz,.json,application/sql,application/gzip,application/json"
style="display: none;" onchange="handleRestore(event)">
<button type="button" class="btn btn-primary"
onclick="document.getElementById('backupFile').click()" id="restoreBtn"
style="gap:var(--space-sm);">
<span>⬆️</span> {{ _('restore_backup') }}
<span>⬆️</span> {{ _('import_backup') }}
</button>
<div class="form-hint" id="backupFileName"></div>
</div>
</div>
</div>
@@ -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 = `<div class="spinner" style="width:14px; height:14px;"></div> ${_('loading')}`;
btn.innerHTML = `<div class="spinner" style="width:14px; height:14px;"></div> ${_('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');
+8 -4
View File
@@ -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:",
+8 -4
View File
@@ -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": "کلید عمومی کلاینت:",
+8 -4
View File
@@ -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 :",
+8 -4
View File
@@ -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": "Публичный ключ клиента:",
+8 -4
View File
@@ -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": "客户端公钥:",