mirror of
https://git.victorphan.net/basketballcantho/ten-project.git
synced 2026-08-05 08:03:11 +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"),
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
#!/bin/bash
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# LMS — Docker Compose Build & Run Script
|
||||
# Usage:
|
||||
# ./build.sh # build + start (production)
|
||||
# ./build.sh --no-cache # force full rebuild (no Docker layer cache)
|
||||
# ./build.sh --down # stop and remove containers
|
||||
# ./build.sh --restart # stop, rebuild, and start
|
||||
# ./build.sh --logs # tail logs after starting
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Colour helpers ────────────────────────────────────────────────────────────
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
|
||||
ok() { echo -e "${GREEN}✔${RESET} $*"; }
|
||||
info() { echo -e "${CYAN}▶${RESET} $*"; }
|
||||
warn() { echo -e "${YELLOW}⚠${RESET} $*"; }
|
||||
fail() { echo -e "${RED}✘${RESET} $*" >&2; exit 1; }
|
||||
step() { echo -e "\n${BOLD}${CYAN}══ $* ══${RESET}"; }
|
||||
|
||||
# ── Parse flags ───────────────────────────────────────────────────────────────
|
||||
NO_CACHE=false
|
||||
DO_DOWN=false
|
||||
DO_RESTART=false
|
||||
DO_LOGS=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--no-cache) NO_CACHE=true ;;
|
||||
--down) DO_DOWN=true ;;
|
||||
--restart) DO_RESTART=true ;;
|
||||
--logs) DO_LOGS=true ;;
|
||||
--help|-h)
|
||||
sed -n '/^# Usage:/,/^# ─/p' "$0" | grep '^#' | sed 's/^# \?//'
|
||||
exit 0 ;;
|
||||
*) fail "Unknown option: $arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Change to script directory ────────────────────────────────────────────────
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# ── Helper: check required commands ──────────────────────────────────────────
|
||||
require_cmd() {
|
||||
command -v "$1" &>/dev/null || fail "'$1' is not installed or not in PATH."
|
||||
}
|
||||
require_cmd docker
|
||||
require_cmd docker compose 2>/dev/null || {
|
||||
# Older Docker installs use "docker-compose" instead of "docker compose"
|
||||
require_cmd docker-compose
|
||||
# Shim so the rest of the script uses the right command
|
||||
docker() {
|
||||
if [ "$1" = "compose" ]; then shift; docker-compose "$@"; else command docker "$@"; fi
|
||||
}
|
||||
export -f docker
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# --down: stop and remove containers
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
if $DO_DOWN; then
|
||||
step "Stopping & removing containers"
|
||||
docker compose down --remove-orphans
|
||||
ok "All containers stopped."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Validate .env
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
step "Checking .env"
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
warn ".env not found — copying from .env.example"
|
||||
if [ ! -f .env.example ]; then
|
||||
fail ".env.example not found either. Cannot continue."
|
||||
fi
|
||||
cp .env.example .env
|
||||
echo ""
|
||||
warn "Please edit .env and set POSTGRES_PASSWORD and JWT_SECRET_KEY, then re-run this script."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source .env
|
||||
|
||||
ERRORS=0
|
||||
check_var() {
|
||||
local name=$1 val=${!1:-} placeholder=${2:-""}
|
||||
if [ -z "$val" ] || { [ -n "$placeholder" ] && [ "$val" = "$placeholder" ]; }; then
|
||||
warn "Missing or placeholder value for ${BOLD}${name}${RESET} in .env"
|
||||
ERRORS=$((ERRORS+1))
|
||||
fi
|
||||
}
|
||||
|
||||
check_var POSTGRES_PASSWORD "change_me_strong_password"
|
||||
check_var JWT_SECRET_KEY "change_me_generate_with_secrets_token_hex_32"
|
||||
|
||||
if [ $ERRORS -gt 0 ]; then
|
||||
echo ""
|
||||
echo -e " ${YELLOW}Tip — generate a JWT secret:${RESET}"
|
||||
echo " python3 -c \"import secrets; print(secrets.token_hex(32))\""
|
||||
echo ""
|
||||
fail "Fix the above values in .env and re-run."
|
||||
fi
|
||||
|
||||
ok ".env looks good"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# --restart: tear down first
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
if $DO_RESTART; then
|
||||
step "Stopping existing containers"
|
||||
docker compose down --remove-orphans
|
||||
ok "Stopped."
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Build images
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
step "Building Docker images"
|
||||
|
||||
BUILD_ARGS=""
|
||||
$NO_CACHE && BUILD_ARGS="--no-cache" && warn "Building without layer cache (--no-cache)"
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
docker compose build $BUILD_ARGS
|
||||
|
||||
ok "Images built successfully"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Start stack
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
step "Starting services"
|
||||
docker compose up -d --remove-orphans
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Wait for backend health
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
step "Waiting for backend to become healthy"
|
||||
|
||||
MAX_WAIT=60
|
||||
ELAPSED=0
|
||||
INTERVAL=3
|
||||
printf " "
|
||||
while true; do
|
||||
STATUS=$(docker compose ps --format json backend 2>/dev/null \
|
||||
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('Health','') or d.get('State',''))" 2>/dev/null || echo "")
|
||||
case "$STATUS" in
|
||||
healthy) echo ""; ok "Backend is healthy"; break ;;
|
||||
running) printf "."; sleep $INTERVAL; ELAPSED=$((ELAPSED+INTERVAL)) ;;
|
||||
*) printf "."; sleep $INTERVAL; ELAPSED=$((ELAPSED+INTERVAL)) ;;
|
||||
esac
|
||||
if [ $ELAPSED -ge $MAX_WAIT ]; then
|
||||
echo ""
|
||||
warn "Timed out waiting for backend health check — checking logs:"
|
||||
docker compose logs --tail=20 backend
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Summary
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
PORT="${FRONTEND_PORT:-3000}"
|
||||
echo ""
|
||||
echo -e "${BOLD}${GREEN}══════════════════════════════════════════${RESET}"
|
||||
echo -e "${BOLD}${GREEN} ✅ LMS is up!${RESET}"
|
||||
echo -e "${BOLD}${GREEN}══════════════════════════════════════════${RESET}"
|
||||
echo -e " ${BOLD}App URL :${RESET} http://localhost:${PORT}"
|
||||
echo -e " ${BOLD}Backend :${RESET} http://localhost:${PORT}/api/docs (via proxy)"
|
||||
echo ""
|
||||
docker compose ps
|
||||
echo ""
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Tail logs if requested
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
if $DO_LOGS; then
|
||||
step "Tailing logs (Ctrl+C to stop)"
|
||||
docker compose logs -f
|
||||
fi
|
||||
@@ -40,6 +40,13 @@ export default function AdminPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState<number | null>(null); // userId being mutated
|
||||
|
||||
// Backup / Restore state
|
||||
const [backupLoading, setBackupLoading] = useState(false);
|
||||
const [restoreLoading, setRestoreLoading] = useState(false);
|
||||
const [backupMsg, setBackupMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
const restoreInputRef = useState<HTMLInputElement | null>(null);
|
||||
|
||||
const [filterStatus, setFilterStatus] = useState<UserStatus | "all">("all");
|
||||
const [filterRole, setFilterRole] = useState<UserRole | "all">("all");
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -238,6 +245,126 @@ export default function AdminPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Backup & Restore ─────────────────────────────────────────────── */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5 mb-6 shadow-sm">
|
||||
<h2 className="text-sm font-bold text-gray-800 mb-1">Sao lưu & Khôi phục</h2>
|
||||
<p className="text-xs text-gray-400 mb-4">
|
||||
File ZIP chứa toàn bộ cơ sở dữ liệu (SQL dump) và các file PDF đã tải lên.
|
||||
Dùng để di chuyển sang server khác.
|
||||
</p>
|
||||
|
||||
{backupMsg && (
|
||||
<div className={`mb-4 text-xs px-3 py-2 rounded-lg flex items-center justify-between ${
|
||||
backupMsg.type === "ok"
|
||||
? "bg-green-50 border border-green-200 text-green-700"
|
||||
: "bg-red-50 border border-red-200 text-red-700"
|
||||
}`}>
|
||||
{backupMsg.text}
|
||||
<button onClick={() => setBackupMsg(null)} className="ml-3 opacity-60 hover:opacity-100">✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
{/* Download backup */}
|
||||
<button
|
||||
disabled={backupLoading}
|
||||
onClick={async () => {
|
||||
setBackupLoading(true);
|
||||
setBackupMsg(null);
|
||||
try {
|
||||
const blob = await api.adminDownloadBackup();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
a.href = url;
|
||||
a.download = `lms_backup_${ts}.zip`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setBackupMsg({ type: "ok", text: "Tải backup thành công." });
|
||||
} catch (e: unknown) {
|
||||
setBackupMsg({ type: "err", text: e instanceof Error ? e.message : "Backup thất bại." });
|
||||
} finally {
|
||||
setBackupLoading(false);
|
||||
}
|
||||
}}
|
||||
className="flex items-center justify-center gap-2 text-sm font-medium bg-blue-600 hover:bg-blue-700 disabled:opacity-60 text-white px-4 py-2 rounded-lg transition"
|
||||
>
|
||||
{backupLoading ? (
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
)}
|
||||
{backupLoading ? "Đang tạo backup…" : "Tải backup (.zip)"}
|
||||
</button>
|
||||
|
||||
{/* Restore */}
|
||||
<label
|
||||
className={`flex items-center justify-center gap-2 text-sm font-medium border-2 border-dashed px-4 py-2 rounded-lg transition cursor-pointer ${
|
||||
restoreLoading
|
||||
? "opacity-60 cursor-not-allowed border-gray-300 text-gray-400"
|
||||
: "border-orange-300 text-orange-600 hover:bg-orange-50"
|
||||
}`}
|
||||
>
|
||||
{restoreLoading ? (
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l4-4m0 0l4 4m-4-4v12" />
|
||||
</svg>
|
||||
)}
|
||||
{restoreLoading ? "Đang khôi phục…" : "Khôi phục từ backup (.zip)"}
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip"
|
||||
className="hidden"
|
||||
disabled={restoreLoading}
|
||||
ref={el => { restoreInputRef[1](el); }}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (!confirm(
|
||||
`⚠️ Khôi phục từ "${file.name}" sẽ XOÁ TOÀN BỘ dữ liệu hiện tại và thay bằng backup.\n\nBạn có chắc chắn không?`
|
||||
)) {
|
||||
e.target.value = "";
|
||||
return;
|
||||
}
|
||||
setRestoreLoading(true);
|
||||
setBackupMsg(null);
|
||||
try {
|
||||
const result = await api.adminRestoreBackup(file);
|
||||
const ts = result.backup_created_at
|
||||
? new Date(result.backup_created_at).toLocaleString("vi-VN")
|
||||
: "";
|
||||
setBackupMsg({
|
||||
type: "ok",
|
||||
text: `Khôi phục thành công${ts ? " (backup ngày " + ts + ")" : ""}. Trang sẽ tải lại sau 3 giây.`,
|
||||
});
|
||||
setTimeout(() => window.location.reload(), 3000);
|
||||
} catch (err: unknown) {
|
||||
setBackupMsg({ type: "err", text: err instanceof Error ? err.message : "Khôi phục thất bại." });
|
||||
} finally {
|
||||
setRestoreLoading(false);
|
||||
e.target.value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-300 mt-3">
|
||||
⚠️ Khôi phục sẽ ghi đè toàn bộ dữ liệu hiện tại (người dùng, PDF, annotation). Hãy tải backup trước khi khôi phục.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Table ────────────────────────────────────────────────────────── */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
|
||||
{filtered.length === 0 ? (
|
||||
|
||||
@@ -74,4 +74,31 @@ export const api = {
|
||||
}),
|
||||
adminDeleteUser: (userId: number) =>
|
||||
request<void>(`/api/admin/users/${userId}`, { method: "DELETE" }),
|
||||
|
||||
// Backup / Restore
|
||||
/** Triggers pg_dump + file pack; returns a Blob for the browser to download. */
|
||||
adminDownloadBackup: async (): Promise<Blob> => {
|
||||
const res = await fetch("/api/admin/backup/download", { credentials: "include" });
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(detail?.detail ?? "Backup failed");
|
||||
}
|
||||
return res.blob();
|
||||
},
|
||||
|
||||
/** Upload a backup ZIP to restore the system. */
|
||||
adminRestoreBackup: async (file: File): Promise<{ message: string; backup_created_at: string }> => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const res = await fetch("/api/admin/backup/restore", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(detail?.detail ?? "Restore failed");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
};
|
||||
|
||||
+122
-39
@@ -1,62 +1,145 @@
|
||||
#!/bin/bash
|
||||
# Push LMS images to GitHub Container Registry (GHCR)
|
||||
# Usage: ./push-ghcr.sh [tag]
|
||||
# Auto-bumps the patch version in VERSION file on every successful push.
|
||||
#
|
||||
# Usage:
|
||||
# ./push-ghcr.sh # auto-bump patch: 1.2.3 → 1.2.4
|
||||
# ./push-ghcr.sh minor # bump minor: 1.2.3 → 1.3.0
|
||||
# ./push-ghcr.sh major # bump major: 1.2.3 → 2.0.0
|
||||
# ./push-ghcr.sh 2.5.1 # use exact version (no auto-bump)
|
||||
# ./push-ghcr.sh --no-cache # force full Docker rebuild
|
||||
#
|
||||
# Reads GITHUB_USER and GITHUB_TOKEN from .env
|
||||
# Version is stored in ./VERSION
|
||||
|
||||
set -e
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# Load .env
|
||||
if [ -f .env ]; then
|
||||
export $(grep -v '^#' .env | grep -E 'GITHUB_USER|GITHUB_TOKEN' | xargs)
|
||||
fi
|
||||
# ── Colour helpers ────────────────────────────────────────────────────────────
|
||||
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
|
||||
ok() { echo -e "${GREEN}✔${RESET} $*"; }
|
||||
info() { echo -e "${CYAN}▶${RESET} $*"; }
|
||||
warn() { echo -e "${YELLOW}⚠${RESET} $*"; }
|
||||
fail() { echo -e "\033[0;31m✘${RESET} $*" >&2; exit 1; }
|
||||
step() { echo -e "\n${BOLD}${CYAN}══ $* ══${RESET}"; }
|
||||
|
||||
TAG="${1:-latest}"
|
||||
# ── Parse arguments ───────────────────────────────────────────────────────────
|
||||
BUMP_TYPE="patch"
|
||||
EXPLICIT_TAG=""
|
||||
NO_CACHE=""
|
||||
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
major|minor|patch) BUMP_TYPE=$arg ;;
|
||||
--no-cache) NO_CACHE="--no-cache" ;;
|
||||
[0-9]*.[0-9]*.[0-9]*) EXPLICIT_TAG=$arg ;; # exact semver passed
|
||||
*) fail "Unknown argument: $arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Load .env ─────────────────────────────────────────────────────────────────
|
||||
[ -f .env ] || fail ".env not found. Copy .env.example → .env first."
|
||||
# shellcheck disable=SC2046
|
||||
export $(grep -v '^#' .env | grep -E 'GITHUB_USER|GITHUB_TOKEN' | xargs)
|
||||
|
||||
GITHUB_USER="${GITHUB_USER:?GITHUB_USER not set in .env}"
|
||||
GITHUB_TOKEN="${GITHUB_TOKEN:?GITHUB_TOKEN not set in .env}"
|
||||
|
||||
BACKEND_IMAGE="ghcr.io/${GITHUB_USER}/lms-backend:${TAG}"
|
||||
FRONTEND_IMAGE="ghcr.io/${GITHUB_USER}/lms-frontend:${TAG}"
|
||||
# ── Read current version ──────────────────────────────────────────────────────
|
||||
VERSION_FILE="VERSION"
|
||||
[ -f "$VERSION_FILE" ] || echo "1.0.0" > "$VERSION_FILE"
|
||||
|
||||
echo "========================================"
|
||||
echo " LMS → GHCR Push"
|
||||
echo " Backend : $BACKEND_IMAGE"
|
||||
echo " Frontend: $FRONTEND_IMAGE"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
CURRENT=$(cat "$VERSION_FILE" | tr -d '[:space:]')
|
||||
if ! [[ $CURRENT =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
fail "VERSION file contains invalid semver: '$CURRENT'. Expected format: X.Y.Z"
|
||||
fi
|
||||
|
||||
# ── 1. Login to GHCR ──────────────────────────────────────────────────────────
|
||||
echo "🔐 Logging in to ghcr.io ..."
|
||||
IFS='.' read -r V_MAJOR V_MINOR V_PATCH <<< "$CURRENT"
|
||||
|
||||
# ── Compute new version ───────────────────────────────────────────────────────
|
||||
if [ -n "$EXPLICIT_TAG" ]; then
|
||||
NEW_VERSION="$EXPLICIT_TAG"
|
||||
info "Using explicit version: ${BOLD}$NEW_VERSION${RESET}"
|
||||
else
|
||||
case $BUMP_TYPE in
|
||||
major) NEW_VERSION="$((V_MAJOR+1)).0.0" ;;
|
||||
minor) NEW_VERSION="${V_MAJOR}.$((V_MINOR+1)).0" ;;
|
||||
patch) NEW_VERSION="${V_MAJOR}.${V_MINOR}.$((V_PATCH+1))" ;;
|
||||
esac
|
||||
info "Bumping ${BUMP_TYPE}: ${BOLD}${CURRENT}${RESET} → ${BOLD}${NEW_VERSION}${RESET}"
|
||||
fi
|
||||
|
||||
# ── Image names ───────────────────────────────────────────────────────────────
|
||||
REGISTRY="ghcr.io/${GITHUB_USER}"
|
||||
BACKEND_VERSIONED="${REGISTRY}/lms-backend:${NEW_VERSION}"
|
||||
FRONTEND_VERSIONED="${REGISTRY}/lms-frontend:${NEW_VERSION}"
|
||||
BACKEND_LATEST="${REGISTRY}/lms-backend:latest"
|
||||
FRONTEND_LATEST="${REGISTRY}/lms-frontend:latest"
|
||||
|
||||
step "LMS → GHCR Push"
|
||||
echo -e " Version : ${BOLD}${NEW_VERSION}${RESET}"
|
||||
echo -e " Backend : ${BACKEND_VERSIONED}"
|
||||
echo -e " Frontend : ${FRONTEND_VERSIONED}"
|
||||
|
||||
# ── 1. Login to GHCR ─────────────────────────────────────────────────────────
|
||||
step "Login to ghcr.io"
|
||||
echo "$GITHUB_TOKEN" | docker login ghcr.io -u "$GITHUB_USER" --password-stdin
|
||||
ok "Logged in"
|
||||
|
||||
# ── 2. Build images ───────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "🔨 Building images..."
|
||||
cd "$(dirname "$0")"
|
||||
docker compose build
|
||||
step "Building images"
|
||||
[ -n "$NO_CACHE" ] && warn "Building without layer cache"
|
||||
# shellcheck disable=SC2086
|
||||
docker compose build $NO_CACHE
|
||||
ok "Build complete"
|
||||
|
||||
# ── 3. Tag for GHCR ───────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "🏷 Tagging images..."
|
||||
docker tag lms-backend:latest "$BACKEND_IMAGE"
|
||||
docker tag lms-frontend:latest "$FRONTEND_IMAGE"
|
||||
# ── 3. Tag versioned + latest ─────────────────────────────────────────────────
|
||||
step "Tagging images"
|
||||
docker tag lms-backend:latest "$BACKEND_VERSIONED"
|
||||
docker tag lms-frontend:latest "$FRONTEND_VERSIONED"
|
||||
docker tag lms-backend:latest "$BACKEND_LATEST"
|
||||
docker tag lms-frontend:latest "$FRONTEND_LATEST"
|
||||
ok "Tagged ${NEW_VERSION} + latest"
|
||||
|
||||
# ── 4. Push ───────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "⬆ Pushing to GHCR..."
|
||||
docker push "$BACKEND_IMAGE"
|
||||
docker push "$FRONTEND_IMAGE"
|
||||
# ── 4. Push versioned + latest ────────────────────────────────────────────────
|
||||
step "Pushing to GHCR"
|
||||
docker push "$BACKEND_VERSIONED"
|
||||
docker push "$FRONTEND_VERSIONED"
|
||||
docker push "$BACKEND_LATEST"
|
||||
docker push "$FRONTEND_LATEST"
|
||||
ok "Push complete"
|
||||
|
||||
# ── 5. Save new version (only after successful push) ─────────────────────────
|
||||
if [ -z "$EXPLICIT_TAG" ]; then
|
||||
echo "$NEW_VERSION" > "$VERSION_FILE"
|
||||
|
||||
# Commit VERSION bump if inside a git repo
|
||||
if git rev-parse --git-dir &>/dev/null; then
|
||||
git add "$VERSION_FILE"
|
||||
git commit -m "chore: bump version ${CURRENT} → ${NEW_VERSION}" --no-verify 2>/dev/null \
|
||||
&& ok "Committed VERSION bump to git" \
|
||||
|| warn "VERSION updated locally but git commit skipped (nothing to commit or no git user configured)"
|
||||
else
|
||||
ok "VERSION file updated to ${NEW_VERSION}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "✅ Done! Images pushed:"
|
||||
echo " $BACKEND_IMAGE"
|
||||
echo " $FRONTEND_IMAGE"
|
||||
echo -e "${BOLD}${GREEN}══════════════════════════════════════════════════════${RESET}"
|
||||
echo -e "${BOLD}${GREEN} ✅ Images pushed successfully — v${NEW_VERSION}${RESET}"
|
||||
echo -e "${BOLD}${GREEN}══════════════════════════════════════════════════════${RESET}"
|
||||
echo -e " ${BOLD}Versioned tags:${RESET}"
|
||||
echo " $BACKEND_VERSIONED"
|
||||
echo " $FRONTEND_VERSIONED"
|
||||
echo -e " ${BOLD}Latest tags also updated:${RESET}"
|
||||
echo " $BACKEND_LATEST"
|
||||
echo " $FRONTEND_LATEST"
|
||||
echo ""
|
||||
echo "📋 Add these to .env on the target machine:"
|
||||
echo " BACKEND_IMAGE=$BACKEND_IMAGE"
|
||||
echo " FRONTEND_IMAGE=$FRONTEND_IMAGE"
|
||||
echo -e " ${BOLD}📋 Add to .env on target machine:${RESET}"
|
||||
echo " BACKEND_IMAGE=${BACKEND_VERSIONED}"
|
||||
echo " FRONTEND_IMAGE=${FRONTEND_VERSIONED}"
|
||||
echo ""
|
||||
echo "🚀 On the target machine:"
|
||||
echo " docker compose pull && docker compose up -d"
|
||||
echo -e " ${BOLD}🚀 Deploy on target machine:${RESET}"
|
||||
echo " docker compose pull && docker compose up -d"
|
||||
|
||||
@@ -141,3 +141,12 @@ username admin
|
||||
password Admin@12345
|
||||
role admin
|
||||
status approved
|
||||
|
||||
|
||||
Lệnh Kết quả tag
|
||||
./push-ghcr.sh 1.0.0 → 1.0.1 (tự động tăng patch)
|
||||
./push-ghcr.sh minor 1.0.0 → 1.1.0
|
||||
./push-ghcr.sh major 1.0.0 → 2.0.0
|
||||
./push-ghcr.sh 1.5.0 đúng 1.5.0, không auto-bump
|
||||
./push-ghcr.sh --no-cache rebuild + auto-bump patch
|
||||
./push-ghcr.sh minor --no-cache bump minor + rebuild từ đầu
|
||||
Reference in New Issue
Block a user