diff --git a/app/backup.py b/app/backup.py new file mode 100644 index 0000000..0996e4f --- /dev/null +++ b/app/backup.py @@ -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) diff --git a/app/routers/admin.py b/app/routers/admin.py index 869fbbc..41ec1bc 100644 --- a/app/routers/admin.py +++ b/app/routers/admin.py @@ -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) diff --git a/app/static/css/style.css b/app/static/css/style.css index 54e4e68..990906c 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -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; diff --git a/app/templates/admin/dashboard.html b/app/templates/admin/dashboard.html index d75626b..2163969 100644 --- a/app/templates/admin/dashboard.html +++ b/app/templates/admin/dashboard.html @@ -30,7 +30,30 @@
Ссылка удалена
{% elif flash == "toggled" %}
Статус ссылки обновлён
+ {% elif flash == "imported" %} +
База восстановлена из бэкапа
{% endif %} + {% if flash_error %} +
Импорт не удался: {{ flash_error }}
+ {% endif %} + +
+

Бэкап базы

+

+ Экспорт / импорт всех ссылок, активаций и временного трафика (JSON). + Импорт полностью заменяет текущие данные. +

+
+ Скачать бэкап +
+
+ + +
+

Создать ссылку