Add admin database backup export and import

This commit is contained in:
orohi
2026-07-25 01:22:30 +03:00
parent 7842ddf1b8
commit 06599e58f1
4 changed files with 269 additions and 3 deletions
+173
View File
@@ -0,0 +1,173 @@
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any
from database import get_db, init_db
BACKUP_VERSION = 1
TABLES = ("guest_links", "guest_claims", "traffic_grants")
def _now_iso() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
async def export_backup() -> dict[str, Any]:
db = await get_db()
try:
payload: dict[str, Any] = {
"format": "remna-site-backup",
"version": BACKUP_VERSION,
"exported_at": _now_iso(),
}
for table in TABLES:
cursor = await db.execute(f"SELECT * FROM {table} ORDER BY id ASC")
rows = await cursor.fetchall()
payload[table] = [dict(row) for row in rows]
return payload
finally:
await db.close()
def validate_backup(data: Any) -> dict[str, Any]:
if not isinstance(data, dict):
raise ValueError("Файл должен быть JSON-объектом")
if data.get("format") != "remna-site-backup":
raise ValueError("Неверный формат бэкапа (ожидается remna-site-backup)")
version = data.get("version")
if version != BACKUP_VERSION:
raise ValueError(f"Неподдерживаемая версия бэкапа: {version}")
for table in TABLES:
if table not in data or not isinstance(data[table], list):
raise ValueError(f"В бэкапе нет таблицы {table}")
return data
async def import_backup(data: dict[str, Any]) -> dict[str, int]:
validate_backup(data)
await init_db()
links = data["guest_links"]
claims = data["guest_claims"]
grants = data["traffic_grants"]
db = await get_db()
try:
await db.execute("PRAGMA foreign_keys = OFF")
await db.execute("BEGIN IMMEDIATE")
await db.execute("DELETE FROM traffic_grants")
await db.execute("DELETE FROM guest_claims")
await db.execute("DELETE FROM guest_links")
for row in links:
cols = [
"id",
"token",
"link_type",
"title",
"value",
"max_uses",
"used_count",
"is_active",
"expires_at",
"created_at",
"note",
"duration_days",
"plan_traffic_gb",
]
values = [row.get(c) for c in cols]
# defaults for older backups
if values[cols.index("duration_days")] is None:
values[cols.index("duration_days")] = 0
if values[cols.index("plan_traffic_gb")] is None:
values[cols.index("plan_traffic_gb")] = 0
placeholders = ", ".join("?" for _ in cols)
await db.execute(
f"INSERT INTO guest_links ({', '.join(cols)}) VALUES ({placeholders})",
values,
)
for row in claims:
cols = ["id", "link_id", "username", "user_uuid", "detail", "claimed_at"]
values = [row.get(c) for c in cols]
placeholders = ", ".join("?" for _ in cols)
await db.execute(
f"INSERT INTO guest_claims ({', '.join(cols)}) VALUES ({placeholders})",
values,
)
for row in grants:
cols = [
"id",
"claim_id",
"link_id",
"user_uuid",
"username",
"bytes_added",
"expires_at",
"reverted_at",
"status",
"last_error",
"created_at",
]
values = [row.get(c) for c in cols]
if values[cols.index("status")] is None:
values[cols.index("status")] = "active"
if values[cols.index("last_error")] is None:
values[cols.index("last_error")] = ""
if values[cols.index("username")] is None:
values[cols.index("username")] = ""
placeholders = ", ".join("?" for _ in cols)
await db.execute(
f"INSERT INTO traffic_grants ({', '.join(cols)}) VALUES ({placeholders})",
values,
)
await db.commit()
# восстановить autoincrement после вставки с явными id
for table in TABLES:
cursor = await db.execute(f"SELECT MAX(id) AS m FROM {table}")
max_id = (await cursor.fetchone())["m"] or 0
try:
await db.execute("DELETE FROM sqlite_sequence WHERE name = ?", (table,))
if max_id:
await db.execute(
"INSERT INTO sqlite_sequence(name, seq) VALUES (?, ?)",
(table, max_id),
)
except Exception:
# sqlite_sequence ещё нет — не критично
pass
await db.commit()
await db.execute("PRAGMA foreign_keys = ON")
except Exception:
await db.execute("ROLLBACK")
await db.execute("PRAGMA foreign_keys = ON")
raise
finally:
await db.close()
return {
"guest_links": len(links),
"guest_claims": len(claims),
"traffic_grants": len(grants),
}
def dumps_backup(payload: dict[str, Any]) -> bytes:
return json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
def loads_backup(raw: bytes) -> dict[str, Any]:
try:
text = raw.decode("utf-8-sig")
except UnicodeDecodeError as exc:
raise ValueError("Файл должен быть в UTF-8") from exc
try:
data = json.loads(text)
except json.JSONDecodeError as exc:
raise ValueError(f"Некорректный JSON: {exc}") from exc
return validate_backup(data)
+54 -3
View File
@@ -2,12 +2,14 @@ from __future__ import annotations
import secrets
from datetime import datetime, timezone
from urllib.parse import quote, unquote
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from auth import check_admin_credentials, get_current_admin, require_admin_page
from backup import dumps_backup, export_backup, import_backup, loads_backup
from config import get_settings
from database import get_db
from urlutil import public_base_url
@@ -121,6 +123,10 @@ async def admin_dashboard(request: Request):
page = total_pages
site_base = public_base_url(request)
flash = request.query_params.get("flash")
flash_error = request.query_params.get("flash_error")
if flash_error:
flash_error = unquote(flash_error)
return templates.TemplateResponse(
"admin/dashboard.html",
{
@@ -128,7 +134,8 @@ async def admin_dashboard(request: Request):
"brand": settings.brand_name,
"links": links,
"site_base": site_base,
"flash": request.query_params.get("flash"),
"flash": flash,
"flash_error": flash_error,
"tab": tab,
"page": page,
"per_page": per_page,
@@ -293,3 +300,47 @@ async def link_detail(request: Request, link_id: int):
"site_base": public_base_url(request),
},
)
@router.get("/backup/export")
async def backup_export(_admin: str = Depends(get_current_admin)):
payload = await export_backup()
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
filename = f"remna-backup-{stamp}.json"
return Response(
content=dumps_backup(payload),
media_type="application/json; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/backup/import")
async def backup_import(
file: UploadFile = File(...),
_admin: str = Depends(get_current_admin),
):
raw = await file.read()
if not raw:
return RedirectResponse(
"/admin?type=all&flash_error=" + quote("пустой файл"),
status_code=303,
)
if len(raw) > 20 * 1024 * 1024:
return RedirectResponse(
"/admin?type=all&flash_error=" + quote("файл слишком большой (макс 20 МБ)"),
status_code=303,
)
try:
data = loads_backup(raw)
await import_backup(data)
except ValueError as exc:
return RedirectResponse(
"/admin?type=all&flash_error=" + quote(str(exc)[:200]),
status_code=303,
)
except Exception as exc:
return RedirectResponse(
"/admin?type=all&flash_error=" + quote(str(exc)[:200]),
status_code=303,
)
return RedirectResponse("/admin?type=all&flash=imported", status_code=303)
+19
View File
@@ -601,6 +601,25 @@ th {
font-weight: 600;
}
.backup-box {
margin: 0 0 2rem;
padding: 1.25rem 0;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
.backup-import {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 0.85rem;
margin-top: 1rem;
}
.backup-import label {
flex: 1 1 240px;
}
.login-shell {
min-height: 100vh;
+23
View File
@@ -30,7 +30,30 @@
<div class="flash">Ссылка удалена</div>
{% elif flash == "toggled" %}
<div class="flash">Статус ссылки обновлён</div>
{% elif flash == "imported" %}
<div class="flash">База восстановлена из бэкапа</div>
{% endif %}
{% if flash_error %}
<div class="alert-error">Импорт не удался: {{ flash_error }}</div>
{% endif %}
<section class="backup-box">
<h2 style="font-family:var(--font-display);font-size:1.15rem;margin:0 0 0.5rem;">Бэкап базы</h2>
<p class="muted" style="margin:0 0 1rem;font-size:0.9rem;">
Экспорт / импорт всех ссылок, активаций и временного трафика (JSON).
Импорт <strong>полностью заменяет</strong> текущие данные.
</p>
<div class="row-actions">
<a class="btn btn-signal" href="/admin/backup/export">Скачать бэкап</a>
</div>
<form class="backup-import" method="post" action="/admin/backup/import" enctype="multipart/form-data" onsubmit="return confirm('Импорт заменит ВСЕ текущие ссылки и историю. Продолжить?')">
<label>
Восстановить из файла
<input type="file" name="file" accept=".json,application/json" required />
</label>
<button class="btn btn-ghost" type="submit">Импортировать</button>
</form>
</section>
<h2 style="font-family:var(--font-display);font-size:1.25rem;margin:0 0 1rem;">Создать ссылку</h2>
<form class="form-grid" method="post" action="/admin/links">