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)