174 lines
5.8 KiB
Python
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)
|