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