diff --git a/admin.html b/admin.html index a0cbf9d..4120cbb 100644 --- a/admin.html +++ b/admin.html @@ -535,6 +535,23 @@

Панель сама проверит токен у Telegram (запрос getMe) перед применением и подставит настоящий юзернейм бота — придумывать не нужно. После смены перезапустится только бот; если уведомления от api (например об оплате) продолжат идти со старого бота, выполни на сервере mbs restart.

+ +
+

Бэкап и восстановление

+

Бэкап — это база (юзеры, подписки, ноды, платежи) и .env одним файлом. Держи копии где-то отдельно от сервера.

+
+ +
+
+ +
+ +
+
+

⚠ Заменяет текущую базу целиком. Перед заменой панель сама делает копию текущей базы на сервере (файл .before-restore-...), но проверь, что заливаешь именно тот файл, что нужно.

+
+
+
@@ -867,6 +884,31 @@ async function saveBotSettings() { } } +function downloadBackup() { + window.location.href = "/admin/api/backup"; +} + +async function restoreBackup() { + const input = document.getElementById("restore-file-input"); + const result = document.getElementById("backup-result"); + const file = input.files[0]; + if (!file) return; + if (!confirm("Заменить текущую базу файлом " + file.name + "? Текущая база сохранится в файл .before-restore-... на сервере, но действие лучше не отменять просто так.")) return; + result.innerHTML = '

Восстанавливаю…

'; + try { + const form = new FormData(); + form.append("file", file); + const res = await fetch("/admin/api/backup/restore", { method: "POST", body: form }); + if (res.status === 401) { showLogin(); return; } + if (!res.ok) throw new Error(await res.text()); + const data = await res.json(); + result.innerHTML = `

Готово. Копия старой базы: ${esc(data.safety_copy)}.${data.restored_env ? (data.restarted_bot ? " Бот перезапущен с новым .env." : " .env восстановлен, но бот сам не перезапустился — выполни mbs restart.") : ""}

`; + input.value = ""; + } catch (e) { + result.innerHTML = '

Не получилось: ' + esc(e.message) + '

'; + } +} + function fmtDate(iso) { return iso ? iso.slice(0, 10) : "—"; } function statusBadge(active, daysLeft) { if (!active) return 'истекла'; diff --git a/api.py b/api.py index 9f5cac3..7301389 100644 --- a/api.py +++ b/api.py @@ -5,11 +5,12 @@ import os import re import subprocess import urllib.request -from fastapi import FastAPI, HTTPException, Request, Response +from fastapi import FastAPI, HTTPException, Request, Response, File, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi import Body from fastapi.responses import HTMLResponse, PlainTextResponse, FileResponse +import backup import db import links import nodeprov @@ -490,6 +491,35 @@ def admin_set_bot_settings(request: Request, body: dict = Body(...)): return {"ok": True, "username": username, "restarted": restarted} +@app.get("/admin/api/backup") +def admin_download_backup(request: Request): + require_admin(request) + data = backup.create_backup() + filename = f"mbs-backup-{datetime.datetime.utcnow().strftime('%Y%m%d-%H%M%S')}.tar.gz" + return Response( + content=data, media_type="application/gzip", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@app.post("/admin/api/backup/restore") +async def admin_restore_backup(request: Request, file: UploadFile = File(...)): + require_admin(request) + data = await file.read() + try: + result = backup.restore_backup(data) + except backup.RestoreError as e: + raise HTTPException(400, str(e)) + restarted_bot = False + if result["restored_env"]: + try: + subprocess.run(["systemctl", "restart", "mbs-bot"], check=True, timeout=15) + restarted_bot = True + except Exception: + restarted_bot = False + return {"ok": True, **result, "restarted_bot": restarted_bot} + + @app.get("/admin/api/stats") def admin_stats(request: Request): require_admin(request) diff --git a/backup.py b/backup.py new file mode 100644 index 0000000..dc5c224 --- /dev/null +++ b/backup.py @@ -0,0 +1,114 @@ +import io +import json +import os +import shutil +import sqlite3 +import tarfile +import datetime + +from config import DB_PATH, BASE_DIR + +ENV_PATH = os.path.join(BASE_DIR, ".env") +MAX_RESTORE_SIZE = 200 * 1024 * 1024 + + +class RestoreError(Exception): + pass + + +def create_backup() -> bytes: + buf = io.BytesIO() + db_tmp = DB_PATH + ".backup_snapshot.tmp" + src = sqlite3.connect(DB_PATH) + dst = sqlite3.connect(db_tmp) + try: + with dst: + src.backup(dst) + finally: + src.close() + dst.close() + + try: + files = ["mbs.db"] + if os.path.exists(ENV_PATH): + files.append(".env") + manifest = { + "created_at": datetime.datetime.utcnow().isoformat(), + "files": files, + } + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + tar.add(db_tmp, arcname="mbs.db") + if os.path.exists(ENV_PATH): + tar.add(ENV_PATH, arcname=".env") + manifest_bytes = json.dumps(manifest, indent=2).encode() + info = tarfile.TarInfo(name="manifest.json") + info.size = len(manifest_bytes) + tar.addfile(info, io.BytesIO(manifest_bytes)) + finally: + try: + os.remove(db_tmp) + except OSError: + pass + return buf.getvalue() + + +def restore_backup(data: bytes) -> dict: + if len(data) > MAX_RESTORE_SIZE: + raise RestoreError("backup file too large") + + try: + tar = tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") + except Exception as e: + raise RestoreError(f"not a valid backup archive: {e}") + + members = {m.name: m for m in tar.getmembers()} + if "mbs.db" not in members: + raise RestoreError("archive has no mbs.db") + + tmp_db_path = DB_PATH + ".restore_candidate.tmp" + db_member = tar.extractfile(members["mbs.db"]) + with open(tmp_db_path, "wb") as f: + shutil.copyfileobj(db_member, f) + + try: + check_conn = sqlite3.connect(tmp_db_path) + try: + tables = {r[0] for r in check_conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall()} + finally: + check_conn.close() + except sqlite3.DatabaseError as e: + os.remove(tmp_db_path) + raise RestoreError(f"archive's mbs.db is not a valid sqlite database: {e}") + + required = {"users", "subscriptions", "nodes", "payments"} + if not required.issubset(tables): + os.remove(tmp_db_path) + raise RestoreError("archive's mbs.db is missing expected tables") + + stamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S") + safety_copy = f"{DB_PATH}.before-restore-{stamp}" + shutil.copy2(DB_PATH, safety_copy) + + restored_env = False + if ".env" in members and os.path.exists(ENV_PATH): + env_safety = f"{ENV_PATH}.before-restore-{stamp}" + shutil.copy2(ENV_PATH, env_safety) + env_member = tar.extractfile(members[".env"]) + env_tmp = ENV_PATH + ".restore.tmp" + with open(env_tmp, "wb") as f: + shutil.copyfileobj(env_member, f) + os.chmod(env_tmp, 0o600) + os.replace(env_tmp, ENV_PATH) + restored_env = True + + for suffix in ("-wal", "-shm"): + try: + os.remove(DB_PATH + suffix) + except OSError: + pass + os.chmod(tmp_db_path, 0o600) + os.replace(tmp_db_path, DB_PATH) + + return {"restored_env": restored_env, "safety_copy": safety_copy} diff --git a/requirements.txt b/requirements.txt index 6903ff9..82962e8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ aiogram==3.15.0 fastapi==0.115.6 uvicorn[standard]==0.32.1 paramiko==3.5.0 +python-multipart==0.0.20