Files
remna/app/backup.py
T

174 lines
5.8 KiB
Python

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)