mirror of
https://git.victorphan.net/basketballcantho/ten-project.git
synced 2026-08-06 03:53:11 +07:00
259 lines
9.4 KiB
Python
259 lines
9.4 KiB
Python
"""
|
|
Backup / Restore endpoints — Admin only.
|
|
|
|
Backup : GET /api/admin/backup/download
|
|
Streams a ZIP containing:
|
|
- dump.sql (pg_dump plain-text SQL of the entire database)
|
|
- uploads/ (all uploaded PDF files from the volume)
|
|
- meta.json (LMS version, timestamp, db name)
|
|
|
|
Restore : POST /api/admin/backup/restore
|
|
Accepts the same ZIP, drops & recreates the schema via
|
|
psql, then copies files back into the uploads directory.
|
|
The running app is re-migrated automatically by entrypoint
|
|
on next restart, but this endpoint also runs alembic upgrade head.
|
|
"""
|
|
|
|
import io
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import zipfile
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
|
|
from fastapi.responses import StreamingResponse
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ..database import get_db, engine
|
|
from ..dependencies import require_admin
|
|
from ..models import User
|
|
|
|
router = APIRouter(prefix="/admin", tags=["backup"])
|
|
|
|
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "/uploads"))
|
|
DATABASE_URL = os.getenv("DATABASE_URL", "")
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
def _parse_db_url(url: str) -> dict:
|
|
"""Parse DATABASE_URL into components for pg_dump / psql CLI."""
|
|
p = urlparse(url)
|
|
return {
|
|
"host": p.hostname or "db",
|
|
"port": str(p.port or 5432),
|
|
"user": p.username or "lms_user",
|
|
"password": p.password or "",
|
|
"dbname": p.path.lstrip("/") or "lms_db",
|
|
}
|
|
|
|
|
|
def _pg_env(db_params: dict) -> dict:
|
|
"""Return env dict that passes PGPASSWORD so no password prompt."""
|
|
env = os.environ.copy()
|
|
env["PGPASSWORD"] = db_params["password"]
|
|
return env
|
|
|
|
|
|
# ── GET /api/admin/backup/download ────────────────────────────────────────────
|
|
|
|
@router.get("/backup/download")
|
|
def download_backup(
|
|
_admin: User = Depends(require_admin),
|
|
):
|
|
"""
|
|
Create an in-memory ZIP with pg_dump SQL + all uploaded files
|
|
and stream it back to the browser.
|
|
"""
|
|
db_params = _parse_db_url(DATABASE_URL)
|
|
|
|
# 1. Run pg_dump → SQL text
|
|
try:
|
|
result = subprocess.run(
|
|
[
|
|
"pg_dump",
|
|
"-h", db_params["host"],
|
|
"-p", db_params["port"],
|
|
"-U", db_params["user"],
|
|
"-d", db_params["dbname"],
|
|
"--no-password",
|
|
"--format=plain",
|
|
"--no-owner",
|
|
"--no-acl",
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
env=_pg_env(db_params),
|
|
timeout=120,
|
|
)
|
|
except FileNotFoundError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="pg_dump not found in container. Add postgresql-client to backend Dockerfile.",
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"pg_dump failed: {result.stderr[:500]}",
|
|
)
|
|
|
|
sql_bytes = result.stdout.encode("utf-8")
|
|
|
|
# 2. Build ZIP in memory
|
|
buf = io.BytesIO()
|
|
with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
|
|
|
|
# meta.json
|
|
meta = {
|
|
"lms_backup_version": 1,
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
"db_name": db_params["dbname"],
|
|
}
|
|
zf.writestr("meta.json", json.dumps(meta, indent=2))
|
|
|
|
# Database dump
|
|
zf.writestr("dump.sql", sql_bytes)
|
|
|
|
# Upload files
|
|
if UPLOAD_DIR.exists():
|
|
for filepath in UPLOAD_DIR.rglob("*"):
|
|
if filepath.is_file():
|
|
arcname = "uploads/" + filepath.relative_to(UPLOAD_DIR).as_posix()
|
|
zf.write(filepath, arcname)
|
|
|
|
buf.seek(0)
|
|
|
|
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
|
filename = f"lms_backup_{timestamp}.zip"
|
|
|
|
return StreamingResponse(
|
|
buf,
|
|
media_type="application/zip",
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|
|
|
|
|
|
# ── POST /api/admin/backup/restore ────────────────────────────────────────────
|
|
|
|
@router.post("/backup/restore", status_code=status.HTTP_200_OK)
|
|
async def restore_backup(
|
|
file: UploadFile,
|
|
_admin: User = Depends(require_admin),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Restore from a backup ZIP created by /backup/download.
|
|
⚠️ THIS OVERWRITES all current data.
|
|
Steps:
|
|
1. Validate ZIP contains meta.json + dump.sql
|
|
2. Drop all tables (via SQLAlchemy) and recreate via psql
|
|
3. Re-run alembic upgrade head
|
|
4. Restore upload files
|
|
"""
|
|
if not file.filename or not file.filename.endswith(".zip"):
|
|
raise HTTPException(status_code=400, detail="File must be a .zip backup.")
|
|
|
|
contents = await file.read()
|
|
if len(contents) < 22: # minimum valid ZIP size
|
|
raise HTTPException(status_code=400, detail="Invalid or empty ZIP file.")
|
|
|
|
db_params = _parse_db_url(DATABASE_URL)
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
tmp = Path(tmpdir)
|
|
|
|
# ── Unzip ─────────────────────────────────────────────────────────
|
|
try:
|
|
with zipfile.ZipFile(io.BytesIO(contents)) as zf:
|
|
names = zf.namelist()
|
|
if "dump.sql" not in names:
|
|
raise HTTPException(status_code=400, detail="ZIP missing dump.sql — not a valid LMS backup.")
|
|
if "meta.json" not in names:
|
|
raise HTTPException(status_code=400, detail="ZIP missing meta.json — not a valid LMS backup.")
|
|
zf.extractall(tmp)
|
|
except zipfile.BadZipFile:
|
|
raise HTTPException(status_code=400, detail="Corrupted ZIP file.")
|
|
|
|
# ── Validate meta ─────────────────────────────────────────────────
|
|
meta = json.loads((tmp / "meta.json").read_text())
|
|
if meta.get("lms_backup_version") != 1:
|
|
raise HTTPException(status_code=400, detail="Unsupported backup version.")
|
|
|
|
# ── Close all active DB connections to allow DROP ─────────────────
|
|
db.close()
|
|
engine.dispose()
|
|
|
|
pg_env = _pg_env(db_params)
|
|
|
|
# ── Drop and recreate the public schema ───────────────────────────
|
|
drop_sql = (
|
|
"DROP SCHEMA public CASCADE; "
|
|
"CREATE SCHEMA public; "
|
|
"GRANT ALL ON SCHEMA public TO PUBLIC;"
|
|
)
|
|
drop_result = subprocess.run(
|
|
[
|
|
"psql",
|
|
"-h", db_params["host"],
|
|
"-p", db_params["port"],
|
|
"-U", db_params["user"],
|
|
"-d", db_params["dbname"],
|
|
"--no-password",
|
|
"-c", drop_sql,
|
|
],
|
|
capture_output=True, text=True,
|
|
env=pg_env, timeout=30,
|
|
)
|
|
if drop_result.returncode != 0:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Failed to reset schema: {drop_result.stderr[:400]}",
|
|
)
|
|
|
|
# ── Restore SQL dump ──────────────────────────────────────────────
|
|
sql_file = str(tmp / "dump.sql")
|
|
restore_result = subprocess.run(
|
|
[
|
|
"psql",
|
|
"-h", db_params["host"],
|
|
"-p", db_params["port"],
|
|
"-U", db_params["user"],
|
|
"-d", db_params["dbname"],
|
|
"--no-password",
|
|
"-f", sql_file,
|
|
],
|
|
capture_output=True, text=True,
|
|
env=pg_env, timeout=120,
|
|
)
|
|
if restore_result.returncode != 0:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"psql restore failed: {restore_result.stderr[:400]}",
|
|
)
|
|
|
|
# ── Re-run Alembic migrations (idempotent) ────────────────────────
|
|
subprocess.run(
|
|
["alembic", "upgrade", "head"],
|
|
capture_output=True, text=True,
|
|
)
|
|
|
|
# ── Restore uploaded files ────────────────────────────────────────
|
|
uploads_src = tmp / "uploads"
|
|
if uploads_src.exists():
|
|
if UPLOAD_DIR.exists():
|
|
shutil.rmtree(UPLOAD_DIR)
|
|
shutil.copytree(uploads_src, UPLOAD_DIR)
|
|
else:
|
|
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
return {
|
|
"ok": True,
|
|
"message": "Restore complete. Please refresh the page.",
|
|
"backup_created_at": meta.get("created_at"),
|
|
}
|