mirror of
https://git.victorphan.net/basketballcantho/ten-project.git
synced 2026-08-05 09:53:10 +07:00
hoàn thành chức năng backup v2 restore
This commit is contained in:
@@ -10,6 +10,10 @@ FROM python:3.12-slim AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# postgresql-client provides pg_dump / psql used by the backup/restore API
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends postgresql-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy installed packages from deps stage
|
||||
COPY --from=deps /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
|
||||
COPY --from=deps /usr/local/bin /usr/local/bin
|
||||
|
||||
+2
-1
@@ -3,7 +3,7 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .routers import admin, annotations, auth, pdfs, ws
|
||||
from .routers import admin, annotations, auth, backup, pdfs, ws
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -25,6 +25,7 @@ app.add_middleware(
|
||||
|
||||
app.include_router(auth.router, prefix="/api")
|
||||
app.include_router(admin.router, prefix="/api")
|
||||
app.include_router(backup.router, prefix="/api")
|
||||
app.include_router(pdfs.router, prefix="/api")
|
||||
app.include_router(annotations.router, prefix="/api")
|
||||
app.include_router(ws.router) # WebSocket — no /api prefix (ws:// path)
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
"""
|
||||
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"),
|
||||
}
|
||||
Reference in New Issue
Block a user