Files
LMS/backend/app/routers/backup.py
T

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"),
}