Add admin database backup export and import
This commit is contained in:
+173
@@ -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
@@ -2,12 +2,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from urllib.parse import quote, unquote
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from auth import check_admin_credentials, get_current_admin, require_admin_page
|
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 config import get_settings
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from urlutil import public_base_url
|
from urlutil import public_base_url
|
||||||
@@ -121,6 +123,10 @@ async def admin_dashboard(request: Request):
|
|||||||
page = total_pages
|
page = total_pages
|
||||||
|
|
||||||
site_base = public_base_url(request)
|
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(
|
return templates.TemplateResponse(
|
||||||
"admin/dashboard.html",
|
"admin/dashboard.html",
|
||||||
{
|
{
|
||||||
@@ -128,7 +134,8 @@ async def admin_dashboard(request: Request):
|
|||||||
"brand": settings.brand_name,
|
"brand": settings.brand_name,
|
||||||
"links": links,
|
"links": links,
|
||||||
"site_base": site_base,
|
"site_base": site_base,
|
||||||
"flash": request.query_params.get("flash"),
|
"flash": flash,
|
||||||
|
"flash_error": flash_error,
|
||||||
"tab": tab,
|
"tab": tab,
|
||||||
"page": page,
|
"page": page,
|
||||||
"per_page": per_page,
|
"per_page": per_page,
|
||||||
@@ -293,3 +300,47 @@ async def link_detail(request: Request, link_id: int):
|
|||||||
"site_base": public_base_url(request),
|
"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)
|
||||||
|
|||||||
@@ -601,6 +601,25 @@ th {
|
|||||||
font-weight: 600;
|
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 {
|
.login-shell {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
|||||||
@@ -30,7 +30,30 @@
|
|||||||
<div class="flash">Ссылка удалена</div>
|
<div class="flash">Ссылка удалена</div>
|
||||||
{% elif flash == "toggled" %}
|
{% elif flash == "toggled" %}
|
||||||
<div class="flash">Статус ссылки обновлён</div>
|
<div class="flash">Статус ссылки обновлён</div>
|
||||||
|
{% elif flash == "imported" %}
|
||||||
|
<div class="flash">База восстановлена из бэкапа</div>
|
||||||
{% endif %}
|
{% 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>
|
<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">
|
<form class="form-grid" method="post" action="/admin/links">
|
||||||
|
|||||||
Reference in New Issue
Block a user