Template
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58aad90dee |
@@ -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:
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
+26
-11
@@ -600,10 +600,12 @@
|
||||
</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>
|
||||
<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);">
|
||||
@@ -614,15 +616,18 @@
|
||||
<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');
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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": "کلید عمومی کلاینت:",
|
||||
|
||||
@@ -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 :",
|
||||
|
||||
@@ -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": "Публичный ключ клиента:",
|
||||
|
||||
@@ -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": "客户端公钥:",
|
||||
|
||||
Reference in New Issue
Block a user