Merge pull request 'dev01' (#2) from dev01 into main

Reviewed-on: basketballcantho/ten-project#2
This commit is contained in:
2026-04-02 02:23:10 +00:00
34 changed files with 3821 additions and 224 deletions
+5
View File
@@ -17,3 +17,8 @@ ACCESS_TOKEN_EXPIRE_MINUTES=60
# ── Ports ─────────────────────────────────────────────────────────────────────
# Port exposed on the host for the Next.js frontend
FRONTEND_PORT=3000
# ── Docker Hub images (optional — leave blank to build locally) ───────────────
# Set these on the target machine so docker compose pull works without building
# BACKEND_IMAGE=your_dockerhub_username/lms-backend:latest
# FRONTEND_IMAGE=your_dockerhub_username/lms-frontend:latest
+1
View File
@@ -0,0 +1 @@
1.0.3
+4
View File
@@ -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
@@ -0,0 +1,49 @@
"""add role and status to users
Revision ID: 0002_add_role_status
Revises: 0001_initial_schema
Create Date: 2026-04-01
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = "0002_add_role_status"
down_revision = "0001"
branch_labels = None
depends_on = None
user_role = postgresql.ENUM("admin", "teacher", "student", name="user_role")
user_status = postgresql.ENUM("pending", "approved", "rejected", name="user_status")
def upgrade() -> None:
user_role.create(op.get_bind(), checkfirst=True)
user_status.create(op.get_bind(), checkfirst=True)
op.add_column(
"users",
sa.Column(
"role",
sa.Enum("admin", "teacher", "student", name="user_role"),
nullable=False,
server_default="student",
),
)
op.add_column(
"users",
sa.Column(
"status",
sa.Enum("pending", "approved", "rejected", name="user_status"),
nullable=False,
server_default="pending",
),
)
def downgrade() -> None:
op.drop_column("users", "status")
op.drop_column("users", "role")
user_role.drop(op.get_bind(), checkfirst=True)
user_status.drop(op.get_bind(), checkfirst=True)
+32 -1
View File
@@ -3,7 +3,7 @@ from jose import JWTError
from sqlalchemy.orm import Session
from .database import get_db
from .models import User
from .models import User, UserRole, UserStatus
from .security import decode_access_token
@@ -33,4 +33,35 @@ def get_current_user(
if user is None:
raise credentials_exception
# Guard: approved users only (pending/rejected cannot use the API)
if user.status != UserStatus.approved:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Your account is pending admin approval.",
)
return user
def require_role(*roles: UserRole):
"""
Returns a FastAPI dependency that asserts the current user has one of the
given roles. Usage: Depends(require_role(UserRole.admin, UserRole.teacher))
"""
def _check(current_user: User = Depends(get_current_user)) -> User:
if current_user.role not in roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You do not have permission to perform this action.",
)
return current_user
return _check
def require_admin(current_user: User = Depends(get_current_user)) -> User:
if current_user.role != UserRole.admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required.",
)
return current_user
+5 -2
View File
@@ -3,7 +3,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .routers import annotations, auth, pdfs
from .routers import admin, annotations, auth, backup, pdfs, ws
@asynccontextmanager
@@ -17,12 +17,15 @@ app = FastAPI(title="LMS API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"], # Next.js dev server
allow_origins=["http://localhost:3000", "http://localhost:3001"],
allow_credentials=True, # required for cookies
allow_methods=["*"],
allow_headers=["*"],
)
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)
+17
View File
@@ -1,6 +1,9 @@
from datetime import datetime, timezone
import enum
from sqlalchemy import (
Column,
Enum,
Integer,
String,
Text,
@@ -13,6 +16,18 @@ from sqlalchemy.orm import relationship
from .database import Base
class UserRole(str, enum.Enum):
admin = "admin"
teacher = "teacher"
student = "student"
class UserStatus(str, enum.Enum):
pending = "pending"
approved = "approved"
rejected = "rejected"
class User(Base):
__tablename__ = "users"
@@ -20,6 +35,8 @@ class User(Base):
username = Column(String(64), unique=True, nullable=False, index=True)
email = Column(String(255), unique=True, nullable=False, index=True)
password_hash = Column(String(255), nullable=False)
role = Column(Enum(UserRole, name="user_role"), nullable=False, default=UserRole.student)
status = Column(Enum(UserStatus, name="user_status"), nullable=False, default=UserStatus.pending)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
# Relationships
+75
View File
@@ -0,0 +1,75 @@
"""
Redis helper — returns a connected client or None when Redis is unavailable.
Key namespace design (prevents cross-file / cross-user data leakage):
ann:{pdf_id}:{page_number}:{user_id}
Every segment is mandatory, so:
- User A opening file 1 never touches User A's data on file 2
- User A opening file 1 never touches User B's data on file 1
- Data for page 3 never affects page 7
TTL: 24 h — temp entry expires automatically if the user never returns.
On permanent save the entry is deleted immediately.
"""
import logging
import os
from typing import Optional
import redis as redis_lib
logger = logging.getLogger(__name__)
_client: Optional[redis_lib.Redis] = None
_warned = False # log the "unavailable" warning only once
def get_redis() -> Optional[redis_lib.Redis]:
"""Return a live Redis client, or None if Redis is unreachable."""
global _client, _warned
if _client is not None:
try:
_client.ping()
return _client
except Exception:
_client = None # connection dropped — try to reconnect below
url = os.getenv("REDIS_URL", "redis://localhost:6379/0")
try:
r: redis_lib.Redis = redis_lib.from_url(
url,
decode_responses=True,
socket_connect_timeout=2,
socket_timeout=2,
)
r.ping()
_client = r
_warned = False
logger.info("Redis connected: %s", url)
return _client
except Exception as exc:
if not _warned:
logger.warning(
"Redis unavailable (%s) — temp annotation cache disabled. "
"Set REDIS_URL or start a local Redis instance to enable it.",
exc,
)
_warned = True
return None
# Key helpers — centralised so there's one place to change the format.
ANN_TTL = 86_400 # 24 hours
def ann_temp_key(pdf_id: int, page_number: int, user_id: int) -> str:
"""Fully-namespaced Redis key for one user's unsaved canvas on one PDF page."""
return f"ann:{pdf_id}:{page_number}:{user_id}"
def ann_temp_page_pattern(pdf_id: int, page_number: int) -> str:
"""Glob pattern to list all users' temp entries for a given page."""
return f"ann:{pdf_id}:{page_number}:*"
+83
View File
@@ -0,0 +1,83 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..dependencies import require_admin
from ..models import User, UserStatus
from ..schemas import UserApprove, UserRoleUpdate, UserOut
router = APIRouter(prefix="/admin", tags=["admin"])
# ── GET /api/admin/users ──────────────────────────────────────────────────────
@router.get("/users", response_model=list[UserOut])
def list_users(
admin: User = Depends(require_admin),
db: Session = Depends(get_db),
):
"""Return all users (any role / status). Admin only."""
return db.query(User).order_by(User.created_at.desc()).all()
# ── PATCH /api/admin/users/{user_id}/status ───────────────────────────────────
@router.patch("/users/{user_id}/status", response_model=UserOut)
def update_user_status(
user_id: int,
body: UserApprove,
admin: User = Depends(require_admin),
db: Session = Depends(get_db),
):
"""Approve or reject a user account. Admin only."""
user = db.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found.")
if user.id == admin.id:
raise HTTPException(status_code=400, detail="Cannot change your own status.")
user.status = body.status
db.commit()
db.refresh(user)
return user
# ── PATCH /api/admin/users/{user_id}/role ─────────────────────────────────────
@router.patch("/users/{user_id}/role", response_model=UserOut)
def update_user_role(
user_id: int,
body: UserRoleUpdate,
admin: User = Depends(require_admin),
db: Session = Depends(get_db),
):
"""Change a user's role. Admin only."""
user = db.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found.")
if user.id == admin.id:
raise HTTPException(status_code=400, detail="Cannot change your own role.")
user.role = body.role
db.commit()
db.refresh(user)
return user
# ── DELETE /api/admin/users/{user_id} ─────────────────────────────────────────
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(
user_id: int,
admin: User = Depends(require_admin),
db: Session = Depends(get_db),
):
"""Permanently delete a user and all their data. Admin only."""
user = db.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found.")
if user.id == admin.id:
raise HTTPException(status_code=400, detail="Cannot delete your own account.")
db.delete(user)
db.commit()
+219 -4
View File
@@ -1,13 +1,55 @@
from datetime import datetime, timezone
import json
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, status as http_status
from sqlalchemy.orm import Session
from ..database import get_db
from ..dependencies import get_current_user
from ..models import Annotation, PDF, User
from ..models import Annotation, PDF, User, UserRole
from ..redis_client import ANN_TTL, ann_temp_key, ann_temp_page_pattern, get_redis
from ..schemas import AnnotationIn, AnnotationOut
def _do_delete_annotation(ann: Annotation, current_user: User, db: Session) -> None:
"""Shared RBAC + delete logic, used by both delete endpoints."""
from ..models import UserRole # avoid circular at module level
ann_owner = db.get(User, ann.user_id)
if current_user.role == UserRole.admin:
pass # full access
elif current_user.role == UserRole.teacher:
if ann.user_id == current_user.id:
pass # own annotation
elif ann_owner and ann_owner.role == UserRole.admin:
raise HTTPException(
status_code=http_status.HTTP_403_FORBIDDEN,
detail="Teachers cannot delete annotations belonging to an admin.",
)
else:
pass # can delete student annotations (or annotations of deleted users)
else: # student
if ann.user_id != current_user.id:
raise HTTPException(
status_code=http_status.HTTP_403_FORBIDDEN,
detail="You can only delete your own annotations.",
)
pdf_id = ann.pdf_id
page_number = ann.page_number
user_id = ann.user_id
db.delete(ann)
db.commit()
# Remove Redis temp entry so /all doesn't serve stale data
r = get_redis()
if r is not None:
try:
r.delete(ann_temp_key(pdf_id, page_number, user_id))
except Exception:
pass
router = APIRouter(prefix="/annotations", tags=["annotations"])
@@ -18,6 +60,14 @@ def _verify_pdf_ownership(db: Session, pdf_id: int, user_id: int) -> PDF:
return pdf
def _any_pdf_or_404(db: Session, pdf_id: int) -> PDF:
"""Return PDF regardless of owner — any authenticated user may read annotations."""
pdf = db.get(PDF, pdf_id)
if not pdf:
raise HTTPException(status_code=404, detail="PDF not found.")
return pdf
# ── GET /api/annotations/{pdf_id}/{page_number} ───────────────────────────────
@router.get("/{pdf_id}/{page_number}", response_model=AnnotationOut)
@@ -27,7 +77,7 @@ def get_annotation(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
_verify_pdf_ownership(db, pdf_id, current_user.id)
_any_pdf_or_404(db, pdf_id)
ann = (
db.query(Annotation)
@@ -52,6 +102,109 @@ def get_annotation(
return ann
# ── GET /api/annotations/{pdf_id}/{page_number}/all ───────────────────────────
# Returns a merged canvas_data whose "objects" array contains annotations from
# ALL users for the given page. Any authenticated user may call this.
@router.get("/{pdf_id}/{page_number}/all", response_model=AnnotationOut)
def get_all_annotations(
pdf_id: int,
page_number: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
_any_pdf_or_404(db, pdf_id)
rows = (
db.query(Annotation)
.filter(
Annotation.pdf_id == pdf_id,
Annotation.page_number == page_number,
)
.all()
)
# Build user_id → canvas_data from DB (permanent saves)
# Key = pdf_id + page_number + user_id → completely isolated per file/page/user
user_data: dict[int, dict] = {}
for row in rows:
user_data[row.user_id] = dict(row.canvas_data) if row.canvas_data else {}
# Overlay with Redis temp data — unsaved strokes written by path:created / clear.
# If a user has BOTH a DB record and a temp entry, Redis wins (more recent).
r = get_redis()
if r is not None:
try:
pattern = ann_temp_page_pattern(pdf_id, page_number)
temp_keys: list[str] = r.keys(pattern)
for key in temp_keys:
# key format: ann:{pdf_id}:{page_number}:{user_id}
try:
uid = int(key.split(":")[-1])
except ValueError:
continue
raw = r.get(key)
if raw:
try:
user_data[uid] = json.loads(raw)
except Exception:
pass
except Exception:
pass # Redis hiccup — fall through with DB data only
if not user_data:
return AnnotationOut(
id=0,
pdf_id=pdf_id,
page_number=page_number,
canvas_data={},
updated_at=datetime.now(timezone.utc),
)
# Canvas-level metadata (version, background…) comes from the latest DB record
latest = max(rows, key=lambda row: row.updated_at) if rows else None
base: dict = dict(latest.canvas_data) if latest and latest.canvas_data else {}
merged_objects: list = []
for uid, canvas in user_data.items():
for obj in (canvas or {}).get("objects", []):
obj_copy = dict(obj)
obj_copy["_owner_id"] = uid
merged_objects.append(obj_copy)
base["objects"] = merged_objects
return AnnotationOut(
id=latest.id if latest else 0,
pdf_id=pdf_id,
page_number=page_number,
canvas_data=base,
updated_at=latest.updated_at if latest else datetime.now(timezone.utc),
)
# ── PUT /api/annotations/{pdf_id}/{page_number}/temp ──────────────────────────
# Writes unsaved canvas state to Redis so every subsequent /all call includes it.
# Called automatically (debounced) by the frontend after each stroke.
@router.put("/{pdf_id}/{page_number}/temp", status_code=http_status.HTTP_204_NO_CONTENT)
def upsert_temp_annotation(
pdf_id: int,
page_number: int,
body: AnnotationIn,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
_any_pdf_or_404(db, pdf_id)
r = get_redis()
if r is None:
return # Redis unavailable — silently skip
key = ann_temp_key(pdf_id, page_number, current_user.id)
try:
r.setex(key, ANN_TTL, json.dumps(body.canvas_data))
except Exception:
pass # non-critical
# ── PUT /api/annotations/{pdf_id}/{page_number} ───────────────────────────────
@router.put("/{pdf_id}/{page_number}", response_model=AnnotationOut)
@@ -62,7 +215,7 @@ def upsert_annotation(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
_verify_pdf_ownership(db, pdf_id, current_user.id)
_any_pdf_or_404(db, pdf_id)
ann = (
db.query(Annotation)
@@ -88,4 +241,66 @@ def upsert_annotation(
db.commit()
db.refresh(ann)
# Data is now in DB — remove the Redis temp entry so /all doesn't double-count
r = get_redis()
if r is not None:
try:
r.delete(ann_temp_key(pdf_id, page_number, current_user.id))
except Exception:
pass
return ann
# ── DELETE /api/annotations/{annotation_id} ───────────────────────────────────
@router.delete("/{annotation_id}", status_code=http_status.HTTP_204_NO_CONTENT)
def delete_annotation(
annotation_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Delete annotation by primary key (RBAC enforced)."""
ann = db.get(Annotation, annotation_id)
if not ann:
raise HTTPException(status_code=404, detail="Annotation not found.")
_do_delete_annotation(ann, current_user, db)
# ── DELETE /api/annotations/{pdf_id}/{page_number}/user/{target_user_id} ──────
@router.delete("/{pdf_id}/{page_number}/user/{target_user_id}", status_code=http_status.HTTP_204_NO_CONTENT)
def delete_user_page_annotation(
pdf_id: int,
page_number: int,
target_user_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""
Delete a specific user's annotation on a given page (RBAC enforced).
Useful for teachers clearing a student's page without knowing the annotation id.
Returns 204 even when no annotation exists (idempotent).
"""
_any_pdf_or_404(db, pdf_id)
ann = (
db.query(Annotation)
.filter(
Annotation.pdf_id == pdf_id,
Annotation.page_number == page_number,
Annotation.user_id == target_user_id,
)
.first()
)
if ann is None:
# Nothing in DB — still clean up any Redis temp entry
r = get_redis()
if r is not None:
try:
r.delete(ann_temp_key(pdf_id, page_number, target_user_id))
except Exception:
pass
return
_do_delete_annotation(ann, current_user, db)
+54 -5
View File
@@ -1,10 +1,11 @@
from fastapi import APIRouter, Depends, HTTPException, Response, status
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status
from sqlalchemy.orm import Session
import os
from ..database import get_db
from ..dependencies import get_current_user
from ..models import User
from ..schemas import UserLogin, UserOut, UserRegister
from ..models import User, UserRole, UserStatus
from ..schemas import UserLogin, UserOut, UserRegister, UserApprove, ChangePassword
from ..security import (
ACCESS_TOKEN_EXPIRE_MINUTES,
create_access_token,
@@ -16,6 +17,7 @@ router = APIRouter(prefix="/auth", tags=["auth"])
_COOKIE_NAME = "access_token"
_COOKIE_MAX_AGE = ACCESS_TOKEN_EXPIRE_MINUTES * 60 # seconds
_COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() == "true"
def _set_auth_cookie(response: Response, user_id: int) -> None:
@@ -24,7 +26,7 @@ def _set_auth_cookie(response: Response, user_id: int) -> None:
key=_COOKIE_NAME,
value=token,
httponly=True,
secure=True, # send only over HTTPS (Nginx handles TLS in prod)
secure=_COOKIE_SECURE,
samesite="lax",
max_age=_COOKIE_MAX_AGE,
path="/",
@@ -40,16 +42,25 @@ def register(body: UserRegister, response: Response, db: Session = Depends(get_d
if db.query(User).filter(User.email == body.email).first():
raise HTTPException(status_code=400, detail="Email already registered.")
# Admin registers as approved immediately; others start as pending
initial_status = (
UserStatus.approved if body.role == UserRole.admin else UserStatus.pending
)
user = User(
username=body.username,
email=body.email,
password_hash=hash_password(body.password),
role=body.role,
status=initial_status,
)
db.add(user)
db.commit()
db.refresh(user)
_set_auth_cookie(response, user.id)
# Only set auth cookie if immediately approved (admin self-registration)
if user.status == UserStatus.approved:
_set_auth_cookie(response, user.id)
return user
@@ -66,6 +77,17 @@ def login(body: UserLogin, response: Response, db: Session = Depends(get_db)):
if not user or not password_ok:
raise HTTPException(status_code=401, detail="Invalid username or password.")
if user.status == UserStatus.pending:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Your account is pending admin approval.",
)
if user.status == UserStatus.rejected:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Your account has been rejected.",
)
_set_auth_cookie(response, user.id)
return user
@@ -82,3 +104,30 @@ def logout(response: Response):
@router.get("/me", response_model=UserOut)
def me(current_user: User = Depends(get_current_user)):
return current_user
# ── GET /auth/token (return raw JWT for WebSocket auth) ─────────────────────
@router.get("/token")
def get_token(access_token: str | None = Cookie(default=None)):
"""
Returns the current JWT so the frontend can pass it as a WebSocket
query param (browsers can't send cookies on WS upgrade in all cases).
"""
if not access_token:
raise HTTPException(status_code=401, detail="Not authenticated.")
return {"access_token": access_token}
# ── POST /auth/change-password ────────────────────────────────────────────────
@router.post("/change-password", status_code=status.HTTP_204_NO_CONTENT)
def change_password(
body: ChangePassword,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
if not verify_password(body.current_password, current_user.password_hash):
raise HTTPException(status_code=400, detail="Mật khẩu hiện tại không đúng.")
current_user.password_hash = hash_password(body.new_password)
db.commit()
+258
View File
@@ -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"),
}
+30 -11
View File
@@ -14,7 +14,7 @@ from ..schemas import PDFOut, PDFUploadResult
router = APIRouter(prefix="/pdfs", tags=["pdfs"])
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "/uploads"))
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB
def _user_dir(user_id: int) -> Path:
@@ -30,6 +30,29 @@ def _own_or_404(db: Session, pdf_id: int, user_id: int) -> PDF:
return pdf
def _any_or_404(db: Session, pdf_id: int) -> PDF:
"""Return PDF if it exists — any authenticated user may read it."""
pdf = db.get(PDF, pdf_id)
if not pdf:
raise HTTPException(status_code=404, detail="PDF not found.")
return pdf
def _pdf_out(pdf: PDF, db: Session):
"""Build PDFOut including owner_username."""
from ..schemas import PDFOut as _PDFOut
owner = db.get(User, pdf.user_id)
data = {
"id": pdf.id,
"user_id": pdf.user_id,
"owner_username": owner.username if owner else "unknown",
"title": pdf.title,
"total_pages": pdf.total_pages,
"created_at": pdf.created_at,
}
return _PDFOut.model_validate(data)
# ── GET /api/pdfs ─────────────────────────────────────────────────────────────
@router.get("", response_model=list[PDFOut])
@@ -37,12 +60,8 @@ def list_pdfs(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
return (
db.query(PDF)
.filter(PDF.user_id == current_user.id)
.order_by(PDF.created_at.desc())
.all()
)
pdfs = db.query(PDF).order_by(PDF.created_at.desc()).all()
return [_pdf_out(p, db) for p in pdfs]
# ── POST /api/pdfs/upload ─────────────────────────────────────────────────────
@@ -71,7 +90,7 @@ async def upload_pdfs(
# ── Size guard ────────────────────────────────────────────────────────
body = header + await file.read()
if len(body) > MAX_FILE_SIZE:
results.append(PDFUploadResult(filename=file.filename or "", success=False, error="File exceeds 50 MB limit."))
results.append(PDFUploadResult(filename=file.filename or "", success=False, error="File exceeds 500 MB limit."))
continue
# ── Save to disk with UUID filename (prevents path traversal) ─────────
@@ -90,7 +109,7 @@ async def upload_pdfs(
db.commit()
db.refresh(pdf)
results.append(PDFUploadResult(filename=file.filename or "", success=True, pdf=PDFOut.model_validate(pdf)))
results.append(PDFUploadResult(filename=file.filename or "", success=True, pdf=_pdf_out(pdf, db)))
return results
@@ -121,8 +140,8 @@ def serve_pdf(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Serve the raw PDF bytes — only to the owning user."""
pdf = _own_or_404(db, pdf_id, current_user.id)
"""Serve the raw PDF bytes — any authenticated user may read."""
pdf = _any_or_404(db, pdf_id)
file_path = Path(pdf.file_path)
if not file_path.exists():
+172
View File
@@ -0,0 +1,172 @@
"""
WebSocket collaboration endpoint.
URL: ws://.../ws/pdf/{pdf_id}?token=<JWT>
Each PDF has its own "room". When a client connects it broadcasts a `presence`
event to the room. All annotation mutations are forwarded verbatim to every
other client in the room.
Message schema (JSON):
→ client sends:
{ "type": "object_add", "payload": <fabric object JSON> }
{ "type": "object_remove", "payload": { "obj_id": "<id>" } }
{ "type": "clear" }
{ "type": "cursor", "payload": { "x": 0, "y": 0, "page": 1 } }
{ "type": "ping" }
← server sends to ALL others in room:
same messages, with "user_id" / "username" / "color" injected
← server sends to ALL (including sender) on join/leave:
{ "type": "presence", "users": [ { "user_id", "username", "color" } ] }
"""
import asyncio
import json
import logging
from typing import Any
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, status
from jose import JWTError
from ..security import decode_access_token
from ..database import get_db
from ..models import User
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Deterministic colour per user (hue based on user_id) ─────────────────────
def _user_color(user_id: int) -> str:
hue = (user_id * 61) % 360 # spread nicely around the wheel
return f"hsl({hue},70%,50%)"
# ── Room manager ──────────────────────────────────────────────────────────────
class _Room:
def __init__(self) -> None:
# websocket → user info dict
self._clients: dict[WebSocket, dict[str, Any]] = {}
self._lock = asyncio.Lock()
async def join(self, ws: WebSocket, user_info: dict[str, Any]) -> None:
async with self._lock:
self._clients[ws] = user_info
await self._broadcast_presence()
async def leave(self, ws: WebSocket) -> None:
async with self._lock:
self._clients.pop(ws, None)
await self._broadcast_presence()
@property
def user_list(self) -> list[dict[str, Any]]:
"""Deduplicated by user_id — same user may have multiple WS connections."""
seen: set[int] = set()
result: list[dict[str, Any]] = []
for info in self._clients.values():
uid = info["user_id"]
if uid not in seen:
seen.add(uid)
result.append(info)
return result
async def broadcast(self, message: dict[str, Any], exclude: WebSocket | None = None) -> None:
"""Send message to every client except `exclude`."""
async with self._lock:
targets = [ws for ws in self._clients if ws is not exclude]
for ws in targets:
try:
await ws.send_json(message)
except Exception:
pass
async def broadcast_all(self, message: dict[str, Any]) -> None:
"""Send message to every client including sender."""
await self.broadcast(message, exclude=None)
async def _broadcast_presence(self) -> None:
await self.broadcast_all({"type": "presence", "users": self.user_list})
class _RoomManager:
def __init__(self) -> None:
self._rooms: dict[int, _Room] = {}
self._lock = asyncio.Lock()
async def get_or_create(self, pdf_id: int) -> _Room:
async with self._lock:
if pdf_id not in self._rooms:
self._rooms[pdf_id] = _Room()
return self._rooms[pdf_id]
async def cleanup(self, pdf_id: int) -> None:
async with self._lock:
room = self._rooms.get(pdf_id)
if room and not room._clients:
del self._rooms[pdf_id]
manager = _RoomManager()
# ── WebSocket endpoint ────────────────────────────────────────────────────────
@router.websocket("/ws/pdf/{pdf_id}")
async def pdf_collaboration(websocket: WebSocket, pdf_id: int):
# ── Auth: JWT passed as query param (cookies aren't reliably sent on WS) ──
token = websocket.query_params.get("token")
if not token:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
try:
user_id = decode_access_token(token)
except JWTError:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
# ── Fetch username from DB ────────────────────────────────────────────────
db = next(get_db())
try:
user: User | None = db.get(User, user_id)
finally:
db.close()
if user is None:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
color = _user_color(user_id)
user_info = {"user_id": user_id, "username": user.username, "color": color}
await websocket.accept()
room = await manager.get_or_create(pdf_id)
await room.join(websocket, user_info)
try:
while True:
raw = await websocket.receive_text()
try:
msg = json.loads(raw)
except json.JSONDecodeError:
continue
msg_type = msg.get("type")
if msg_type == "ping":
await websocket.send_json({"type": "pong"})
continue
# Inject sender identity and forward to all other clients
if msg_type in {"object_add", "object_remove", "clear", "cursor", "color_sync"}:
msg.update(user_info)
await room.broadcast(msg, exclude=websocket)
except WebSocketDisconnect:
pass
finally:
await room.leave(websocket)
await manager.cleanup(pdf_id)
+27
View File
@@ -2,6 +2,8 @@ from datetime import datetime
from pydantic import BaseModel, EmailStr, field_validator
import re
from .models import UserRole, UserStatus
# ── Auth ─────────────────────────────────────────────────────────────────────
@@ -9,6 +11,7 @@ class UserRegister(BaseModel):
username: str
email: EmailStr
password: str
role: UserRole = UserRole.student
@field_validator("username")
@classmethod
@@ -37,11 +40,33 @@ class UserOut(BaseModel):
id: int
username: str
email: str
role: UserRole
status: UserStatus
created_at: datetime
model_config = {"from_attributes": True}
class UserApprove(BaseModel):
status: UserStatus
class UserRoleUpdate(BaseModel):
role: UserRole
class ChangePassword(BaseModel):
current_password: str
new_password: str
@field_validator("new_password")
@classmethod
def password_strength(cls, v: str) -> str:
if len(v) < 8:
raise ValueError("Password must be at least 8 characters.")
return v
class TokenPayload(BaseModel):
sub: int # user id
exp: int
@@ -51,6 +76,8 @@ class TokenPayload(BaseModel):
class PDFOut(BaseModel):
id: int
user_id: int
owner_username: str
title: str
total_pages: int | None
created_at: datetime
+1
View File
@@ -7,3 +7,4 @@ python-jose[cryptography]==3.3.0
pydantic[email]==2.9.2
python-multipart==0.0.12
alembic==1.14.1
redis>=5.0
Executable
+183
View File
@@ -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
Executable
+42
View File
@@ -0,0 +1,42 @@
#!/bin/bash
# Pull LMS images from GHCR and start the stack on a new machine.
# Usage: ./deploy.sh
# Requires: .env file with BACKEND_IMAGE, FRONTEND_IMAGE, POSTGRES_PASSWORD, JWT_SECRET_KEY
set -e
cd "$(dirname "$0")"
if [ ! -f .env ]; then
echo "❌ .env not found. Copy .env.example → .env and fill in the values."
exit 1
fi
source .env
if [ -z "$POSTGRES_PASSWORD" ] || [ "$POSTGRES_PASSWORD" = "change_me_strong_password" ]; then
echo "❌ Set POSTGRES_PASSWORD in .env"
exit 1
fi
if [ -z "$JWT_SECRET_KEY" ] || [ "$JWT_SECRET_KEY" = "change_me_generate_with_secrets_token_hex_32" ]; then
echo "❌ Set JWT_SECRET_KEY in .env"
echo " Generate: python3 -c \"import secrets; print(secrets.token_hex(32))\""
exit 1
fi
# Login GHCR if token is provided
if [ -n "$GITHUB_TOKEN" ] && [ -n "$GITHUB_USER" ]; then
echo "🔐 Logging in to ghcr.io..."
echo "$GITHUB_TOKEN" | docker login ghcr.io -u "$GITHUB_USER" --password-stdin
fi
echo "📦 Pulling images from GHCR..."
docker compose pull
echo "🚀 Starting stack..."
docker compose up -d
echo ""
echo "✅ Running! Open http://localhost:${FRONTEND_PORT:-3000}"
docker compose ps
+18
View File
@@ -18,8 +18,22 @@ services:
networks:
- lms_net
# ── Redis (temp annotation cache) ────────────────────────────────────────────
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --save "" --appendonly no # in-memory only, no disk writes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
networks:
- lms_net
# ── FastAPI backend ───────────────────────────────────────────────────────────
backend:
image: ${BACKEND_IMAGE:-lms-backend:latest}
build:
context: ./backend
dockerfile: Dockerfile
@@ -27,12 +41,15 @@ services:
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
environment:
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER:-lms_user}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-lms_db}
JWT_SECRET_KEY: ${JWT_SECRET_KEY}
JWT_ALGORITHM: HS256
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
UPLOAD_DIR: /uploads
REDIS_URL: redis://redis:6379/0
volumes:
- pdf_uploads:/uploads
expose:
@@ -42,6 +59,7 @@ services:
# ── Next.js frontend ──────────────────────────────────────────────────────────
frontend:
image: ${FRONTEND_IMAGE:-lms-frontend:latest}
build:
context: ./frontend
dockerfile: Dockerfile
+1
View File
@@ -27,6 +27,7 @@ ENV NODE_ENV=production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/next.config.js ./
EXPOSE 3000
+4
View File
@@ -7,6 +7,10 @@ const nextConfig = {
source: "/api/:path*",
destination: `${process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"}/api/:path*`,
},
{
source: "/ws/:path*",
destination: `${process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"}/ws/:path*`,
},
];
},
};
+474
View File
@@ -0,0 +1,474 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
import type { User, UserRole, UserStatus } from "@/types";
// ── Helpers ───────────────────────────────────────────────────────────────────
const STATUS_LABELS: Record<UserStatus, string> = {
pending: "Chờ duyệt",
approved: "Đã duyệt",
rejected: "Từ chối",
};
const ROLE_LABELS: Record<UserRole, string> = {
admin: "Admin",
teacher: "Giáo viên",
student: "Học viên",
};
const STATUS_COLORS: Record<UserStatus, string> = {
pending: "bg-yellow-100 text-yellow-800",
approved: "bg-green-100 text-green-800",
rejected: "bg-red-100 text-red-800",
};
const ROLE_COLORS: Record<UserRole, string> = {
admin: "bg-purple-100 text-purple-800",
teacher: "bg-blue-100 text-blue-800",
student: "bg-gray-100 text-gray-700",
};
// ── Page ──────────────────────────────────────────────────────────────────────
export default function AdminPage() {
const router = useRouter();
const [me, setMe] = useState<User | null>(null);
const [users, setUsers] = useState<User[]>([]);
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("");
// ── Auth guard: admin only ─────────────────────────────────────────────────
useEffect(() => {
async function init() {
try {
const me = await api.me();
if (me.role !== "admin") { router.replace("/dashboard"); return; }
setMe(me);
const list = await api.adminListUsers();
setUsers(list);
} catch {
router.replace("/login");
} finally {
setLoading(false);
}
}
init();
}, [router]);
// ── Mutate helpers ─────────────────────────────────────────────────────────
const updateUser = useCallback((updated: User) => {
setUsers(prev => prev.map(u => u.id === updated.id ? updated : u));
}, []);
const handleStatusChange = useCallback(async (userId: number, status: UserStatus) => {
setBusy(userId);
try {
const updated = await api.adminUpdateStatus(userId, status);
updateUser(updated);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to update status.");
} finally {
setBusy(null);
}
}, [updateUser]);
const handleRoleChange = useCallback(async (userId: number, role: UserRole) => {
setBusy(userId);
try {
const updated = await api.adminUpdateRole(userId, role);
updateUser(updated);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to update role.");
} finally {
setBusy(null);
}
}, [updateUser]);
const handleDelete = useCallback(async (userId: number, username: string) => {
if (!confirm(`Xoá tài khoản "${username}" và toàn bộ dữ liệu? Không thể hoàn tác.`)) return;
setBusy(userId);
try {
await api.adminDeleteUser(userId);
setUsers(prev => prev.filter(u => u.id !== userId));
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to delete user.");
} finally {
setBusy(null);
}
}, []);
// ── Filtered list ──────────────────────────────────────────────────────────
const filtered = users.filter(u => {
if (filterStatus !== "all" && u.status !== filterStatus) return false;
if (filterRole !== "all" && u.role !== filterRole) return false;
if (search) {
const q = search.toLowerCase();
if (!u.username.toLowerCase().includes(q) && !u.email.toLowerCase().includes(q)) return false;
}
return true;
});
const pendingCount = users.filter(u => u.status === "pending").length;
// ── Loading ────────────────────────────────────────────────────────────────
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<svg className="animate-spin h-8 w-8 text-blue-500" 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>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
{/* ── Navbar ──────────────────────────────────────────────────────────── */}
<header className="bg-white border-b shadow-sm sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => router.push("/dashboard")}
className="text-gray-400 hover:text-blue-600 transition"
title="Về Dashboard"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="font-bold text-lg text-blue-600">PDF LMS</span>
<span className="text-xs bg-purple-100 text-purple-700 font-semibold px-2 py-0.5 rounded-full">
Admin
</span>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-500 hidden sm:block">{me?.username}</span>
<button
onClick={() => router.push("/change-password")}
className="text-sm text-gray-500 hover:text-blue-600 transition"
title="Đổi mật khẩu"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</button>
<button
onClick={async () => { await api.logout(); router.replace("/login"); }}
className="text-sm text-gray-500 hover:text-red-600 transition"
>
Đăng xuất
</button>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 py-8">
{/* ── Page title + stats ─────────────────────────────────────────────── */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">Quản người dùng</h1>
<p className="text-sm text-gray-500 mt-0.5">{users.length} tài khoản tổng cộng</p>
</div>
{pendingCount > 0 && (
<div
className="flex items-center gap-2 bg-yellow-50 border border-yellow-200 text-yellow-800 px-4 py-2 rounded-lg text-sm font-medium cursor-pointer hover:bg-yellow-100 transition"
onClick={() => setFilterStatus("pending")}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
{pendingCount} tài khoản chờ duyệt
</div>
)}
</div>
{/* ── Error banner ──────────────────────────────────────────────────── */}
{error && (
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 text-sm px-4 py-3 rounded-lg flex items-center justify-between">
{error}
<button onClick={() => setError("")} className="ml-3 text-red-400 hover:text-red-600"></button>
</div>
)}
{/* ── Filters ───────────────────────────────────────────────────────── */}
<div className="bg-white border border-gray-200 rounded-xl p-4 mb-4 flex flex-col sm:flex-row gap-3">
<input
type="text"
placeholder="Tìm theo username / email…"
value={search}
onChange={e => setSearch(e.target.value)}
className="flex-1 text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500"
/>
<select
value={filterStatus}
onChange={e => setFilterStatus(e.target.value as UserStatus | "all")}
className="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500"
>
<option value="all">Tất cả trạng thái</option>
<option value="pending">Chờ duyệt</option>
<option value="approved">Đã duyệt</option>
<option value="rejected">Từ chối</option>
</select>
<select
value={filterRole}
onChange={e => setFilterRole(e.target.value as UserRole | "all")}
className="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500"
>
<option value="all">Tất cả vai trò</option>
<option value="admin">Admin</option>
<option value="teacher">Giáo viên</option>
<option value="student">Học viên</option>
</select>
{(filterStatus !== "all" || filterRole !== "all" || search) && (
<button
onClick={() => { setFilterStatus("all"); setFilterRole("all"); setSearch(""); }}
className="text-sm text-gray-500 hover:text-red-600 transition px-2"
>
Xoá lọc
</button>
)}
</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ộ sở dữ liệu (SQL dump) 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 ? (
<div className="text-center text-gray-400 text-sm py-16">Không tìm thấy tài khoản nào.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 border-b border-gray-200 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
<th className="px-4 py-3">ID</th>
<th className="px-4 py-3">Người dùng</th>
<th className="px-4 py-3">Vai trò</th>
<th className="px-4 py-3">Trạng thái</th>
<th className="px-4 py-3">Ngày đăng </th>
<th className="px-4 py-3 text-right">Hành đng</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filtered.map(u => {
const isSelf = u.id === me?.id;
const isLoading = busy === u.id;
return (
<tr key={u.id} className="hover:bg-gray-50 transition">
{/* ID */}
<td className="px-4 py-3 text-gray-400 tabular-nums">{u.id}</td>
{/* User info */}
<td className="px-4 py-3">
<div className="font-medium text-gray-900">{u.username}</div>
<div className="text-xs text-gray-400">{u.email}</div>
</td>
{/* Role selector */}
<td className="px-4 py-3">
{isSelf ? (
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${ROLE_COLORS[u.role]}`}>
{ROLE_LABELS[u.role]}
</span>
) : (
<select
value={u.role}
disabled={isLoading}
onChange={e => handleRoleChange(u.id, e.target.value as UserRole)}
className={`text-xs border rounded-lg px-2 py-1 focus:outline-none focus:border-blue-500 disabled:opacity-50 ${ROLE_COLORS[u.role]} border-transparent`}
>
<option value="student">Học viên</option>
<option value="teacher">Giáo viên</option>
<option value="admin">Admin</option>
</select>
)}
</td>
{/* Status selector */}
<td className="px-4 py-3">
{isSelf ? (
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${STATUS_COLORS[u.status]}`}>
{STATUS_LABELS[u.status]}
</span>
) : (
<select
value={u.status}
disabled={isLoading}
onChange={e => handleStatusChange(u.id, e.target.value as UserStatus)}
className={`text-xs border rounded-lg px-2 py-1 focus:outline-none focus:border-blue-500 disabled:opacity-50 ${STATUS_COLORS[u.status]} border-transparent`}
>
<option value="pending">Chờ duyệt</option>
<option value="approved">Duyệt</option>
<option value="rejected">Từ chối</option>
</select>
)}
</td>
{/* Created at */}
<td className="px-4 py-3 text-gray-400 tabular-nums whitespace-nowrap">
{new Date(u.created_at).toLocaleDateString("vi-VN")}
</td>
{/* Actions */}
<td className="px-4 py-3 text-right">
{isLoading ? (
<svg className="animate-spin h-4 w-4 text-blue-500 ml-auto" 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>
) : isSelf ? (
<span className="text-xs text-gray-300"></span>
) : (
<button
onClick={() => handleDelete(u.id, u.username)}
className="text-xs text-red-400 hover:text-red-600 hover:bg-red-50 px-2 py-1 rounded transition"
>
Xoá
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</main>
</div>
);
}
+200
View File
@@ -0,0 +1,200 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
export default function ChangePasswordPage() {
const router = useRouter();
const [form, setForm] = useState({
current_password: "",
new_password: "",
confirm_password: "",
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const [showCurrent, setShowCurrent] = useState(false);
const [showNew, setShowNew] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
if (form.new_password !== form.confirm_password) {
setError("Mật khẩu mới và xác nhận không khớp.");
return;
}
if (form.new_password.length < 8) {
setError("Mật khẩu mới phải ít nhất 8 ký tự.");
return;
}
setLoading(true);
try {
await api.changePassword({
current_password: form.current_password,
new_password: form.new_password,
});
setSuccess(true);
setForm({ current_password: "", new_password: "", confirm_password: "" });
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Đổi mật khẩu thất bại.");
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen bg-gray-50 flex flex-col items-center justify-center px-4">
<div className="w-full max-w-md bg-white rounded-2xl shadow-md border border-gray-200 p-8">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<button
onClick={() => router.back()}
className="text-gray-400 hover:text-blue-600 transition"
title="Quay lại"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<h1 className="text-xl font-bold text-gray-900">Đi mật khẩu</h1>
</div>
{/* Success */}
{success && (
<div className="mb-5 bg-green-50 border border-green-200 text-green-700 text-sm px-4 py-3 rounded-lg flex items-center gap-2">
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Đi mật khẩu thành công!
</div>
)}
{/* Error */}
{error && (
<div className="mb-5 bg-red-50 border border-red-200 text-red-700 text-sm px-4 py-3 rounded-lg flex items-center justify-between">
{error}
<button onClick={() => setError("")} className="ml-3 text-red-400 hover:text-red-600"></button>
</div>
)}
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
{/* Current password */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
Mật khẩu hiện tại
</label>
<div className="relative">
<input
type={showCurrent ? "text" : "password"}
value={form.current_password}
onChange={e => setForm(f => ({ ...f, current_password: e.target.value }))}
required
className="w-full border border-gray-300 rounded-lg px-3 py-2.5 pr-10 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
placeholder="Nhập mật khẩu hiện tại"
/>
<button
type="button"
onClick={() => setShowCurrent(v => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showCurrent
? <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" /></svg>
: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
}
</button>
</div>
</div>
{/* New password */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
Mật khẩu mới
</label>
<div className="relative">
<input
type={showNew ? "text" : "password"}
value={form.new_password}
onChange={e => setForm(f => ({ ...f, new_password: e.target.value }))}
required
minLength={8}
className="w-full border border-gray-300 rounded-lg px-3 py-2.5 pr-10 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
placeholder="Ít nhất 8 ký tự"
/>
<button
type="button"
onClick={() => setShowNew(v => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showNew
? <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" /></svg>
: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
}
</button>
</div>
{/* Strength indicator */}
{form.new_password && (
<div className="mt-1.5 flex gap-1">
{[1,2,3,4].map(i => {
const len = form.new_password.length;
const hasUpper = /[A-Z]/.test(form.new_password);
const hasSpecial = /[^A-Za-z0-9]/.test(form.new_password);
const score = (len >= 8 ? 1 : 0) + (len >= 12 ? 1 : 0) + (hasUpper ? 1 : 0) + (hasSpecial ? 1 : 0);
const active = i <= score;
const color = score <= 1 ? "bg-red-400" : score === 2 ? "bg-yellow-400" : score === 3 ? "bg-blue-400" : "bg-green-500";
return <div key={i} className={`h-1 flex-1 rounded-full ${active ? color : "bg-gray-200"}`} />;
})}
</div>
)}
</div>
{/* Confirm password */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
Xác nhận mật khẩu mới
</label>
<div className="relative">
<input
type={showConfirm ? "text" : "password"}
value={form.confirm_password}
onChange={e => setForm(f => ({ ...f, confirm_password: e.target.value }))}
required
className={`w-full border rounded-lg px-3 py-2.5 pr-10 text-sm focus:outline-none focus:ring-1 ${
form.confirm_password && form.confirm_password !== form.new_password
? "border-red-400 focus:border-red-400 focus:ring-red-300"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500"
}`}
placeholder="Nhập lại mật khẩu mới"
/>
<button
type="button"
onClick={() => setShowConfirm(v => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showConfirm
? <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" /></svg>
: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
}
</button>
</div>
{form.confirm_password && form.confirm_password !== form.new_password && (
<p className="mt-1 text-xs text-red-500">Mật khẩu không khớp.</p>
)}
</div>
<button
type="submit"
disabled={loading || !form.current_password || !form.new_password || !form.confirm_password}
className="mt-1 w-full bg-blue-600 hover:bg-blue-700 disabled:opacity-60 text-white font-semibold text-sm py-2.5 rounded-lg transition"
>
{loading ? "Đang lưu…" : "Đổi mật khẩu"}
</button>
</form>
</div>
</div>
);
}
+19 -2
View File
@@ -71,6 +71,23 @@ export default function DashboardPage() {
<span className="font-bold text-lg text-blue-600">PDF LMS</span>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-500 hidden sm:block">{user?.username}</span>
{user?.role === "admin" && (
<button
onClick={() => router.push("/admin")}
className="text-xs bg-purple-100 text-purple-700 font-semibold px-2.5 py-1 rounded-full hover:bg-purple-200 transition"
>
Quản trị
</button>
)}
<button
onClick={() => router.push("/change-password")}
className="text-sm text-gray-500 hover:text-blue-600 transition"
title="Đổi mật khẩu"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</button>
<button
onClick={handleLogout}
className="text-sm text-gray-500 hover:text-red-600 transition"
@@ -83,7 +100,7 @@ export default function DashboardPage() {
{/* ── Main content ───────────────────────────────────────────────── */}
<main className="max-w-6xl mx-auto px-4 py-8">
<h2 className="text-xl font-bold mb-6 text-gray-800">My PDFs</h2>
<h2 className="text-xl font-bold mb-6 text-gray-800">All PDFs</h2>
<UploadZone onUploaded={handleUploaded} />
@@ -94,7 +111,7 @@ export default function DashboardPage() {
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{pdfs.map((pdf) => (
<PDFCard key={pdf.id} pdf={pdf} onDelete={handleDelete} />
<PDFCard key={pdf.id} pdf={pdf} currentUserId={user!.id} onDelete={handleDelete} />
))}
</div>
)}
+25 -16
View File
@@ -3,6 +3,7 @@ import type { PDFItem } from "@/types";
interface Props {
pdf: PDFItem;
currentUserId: number;
onDelete: (id: number) => void;
}
@@ -14,8 +15,9 @@ function formatDate(iso: string) {
});
}
export default function PDFCard({ pdf, onDelete }: Props) {
export default function PDFCard({ pdf, currentUserId, onDelete }: Props) {
const router = useRouter();
const isOwner = pdf.user_id === currentUserId;
return (
<div
@@ -39,22 +41,29 @@ export default function PDFCard({ pdf, onDelete }: Props) {
{pdf.total_pages != null && (
<p className="text-xs text-gray-400">{pdf.total_pages} pages</p>
)}
{!isOwner && (
<p className="mt-1.5 text-[10px] text-blue-500 font-medium truncate">
by {pdf.owner_username}
</p>
)}
</div>
{/* Delete button — visible on hover */}
<button
onClick={(e) => {
e.stopPropagation();
onDelete(pdf.id);
}}
title="Delete"
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition bg-white/80 hover:bg-red-50 text-gray-500 hover:text-red-600 rounded-lg p-1.5 shadow"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
{/* Delete button — only for owner, visible on hover */}
{isOwner && (
<button
onClick={(e) => {
e.stopPropagation();
onDelete(pdf.id);
}}
title="Delete"
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition bg-white/80 hover:bg-red-50 text-gray-500 hover:text-red-600 rounded-lg p-1.5 shadow"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
)}
</div>
);
}
}
+1 -1
View File
@@ -102,7 +102,7 @@ export default function UploadZone({ onUploaded }: Props) {
Drag &amp; drop PDFs here, or{" "}
<span className="text-blue-600">click to browse</span>
</p>
<p className="mt-1 text-xs text-gray-400">Multiple files supported · Max 50 MB each</p>
<p className="mt-1 text-xs text-gray-400">Multiple files supported · Max 500 MB each</p>
</>
)}
</div>
File diff suppressed because it is too large Load Diff
+196
View File
@@ -0,0 +1,196 @@
/**
* useCollaboration
*
* Manages a WebSocket connection to the backend collaboration room for a PDF.
* The hook is intentionally "dumb" about canvas internals — callers supply
* callbacks that do the actual Fabric.js work.
*
* Protocol (all messages are JSON):
*
* → we send:
* { type: "object_add", payload: <fabric JSON object>, page: number }
* { type: "object_remove", payload: { obj_id: string }, page: number }
* { type: "clear", page: number }
* { type: "cursor", payload: { x: number, y: number }, page: number }
* { type: "ping" }
*
* ← we receive (same shapes, plus user_id / username / color injected by server):
* { type: "presence", users: CollabUser[] }
* { type: "object_add", ..., user_id, username, color }
* { type: "object_remove", ..., user_id, username, color }
* { type: "clear", ..., user_id, username, color }
* { type: "cursor", ..., user_id, username, color }
* { type: "pong" }
*/
import { useCallback, useEffect, useRef, useState } from "react";
export interface CollabUser {
user_id: number;
username: string;
color: string;
}
export interface RemoteEvent {
type: "object_add" | "object_remove" | "clear" | "cursor" | "color_sync";
payload?: unknown;
page?: number;
user_id: number;
username: string;
color: string;
}
interface Options {
pdfId: number;
/** JWT access token — fetched from /api/auth/me or passed in */
token: string | null;
onEvent: (event: RemoteEvent) => void;
/**
* Called whenever a new peer joins the room (presence list grows).
* The host should respond by re-broadcasting all their current canvas objects
* so late-joining users see pre-existing annotations.
*/
onPeerJoined?: () => void;
}
const WS_BASE =
typeof window !== "undefined"
? (window.location.protocol === "https:" ? "wss" : "ws") +
"://" +
// Replace the port (or add one) to reach the backend directly on 8000
window.location.host.replace(/:\d+$/, "") + ":8000"
: "ws://localhost:8000";
const PING_INTERVAL = 25_000; // 25 s keepalive
const RECONNECT_DELAY = 3_000; // 3 s reconnect on unexpected close
export function useCollaboration({ pdfId, token, onEvent, onPeerJoined }: Options) {
const [users, setUsers] = useState<CollabUser[]>([]);
const [connected, setConnected] = useState(false);
const [peerColors, setPeerColors] = useState<Record<number, string>>({});
const wsRef = useRef<WebSocket | null>(null);
const onEventRef = useRef(onEvent);
onEventRef.current = onEvent;
const onPeerJoinedRef = useRef(onPeerJoined);
onPeerJoinedRef.current = onPeerJoined;
// Track previous user count to detect new peers joining
const prevUserCountRef = useRef(0);
const pingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true);
const connect = useCallback(() => {
if (!token || !mountedRef.current) return;
const url = `${WS_BASE}/ws/pdf/${pdfId}?token=${encodeURIComponent(token)}`;
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
if (!mountedRef.current) { ws.close(); return; }
setConnected(true);
pingTimerRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" }));
}, PING_INTERVAL);
};
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data as string);
if (msg.type === "pong") return;
if (msg.type === "color_sync") {
const { user_id, color } = msg as { user_id: number; color: string };
setPeerColors(prev => ({ ...prev, [user_id]: color }));
return;
}
if (msg.type === "presence") {
const incoming = (msg.users ?? []) as CollabUser[];
setUsers(incoming);
// Update peer color map from presence payload (includes our own entry).
setPeerColors(
incoming.reduce<Record<number, string>>((acc, u) => {
acc[u.user_id] = u.color;
return acc;
}, {})
);
// If someone new joined (count increased) and we're already in the room,
// notify the caller so they can re-broadcast their current canvas objects.
if (
prevUserCountRef.current > 0 &&
incoming.length > prevUserCountRef.current
) {
onPeerJoinedRef.current?.();
}
prevUserCountRef.current = incoming.length;
return;
}
onEventRef.current(msg as RemoteEvent);
} catch {
// ignore malformed messages
}
};
ws.onclose = () => {
setConnected(false);
if (pingTimerRef.current) clearInterval(pingTimerRef.current);
if (mountedRef.current) {
reconnectTimerRef.current = setTimeout(connect, RECONNECT_DELAY);
}
};
ws.onerror = () => ws.close();
}, [pdfId, token]);
useEffect(() => {
mountedRef.current = true;
connect();
return () => {
mountedRef.current = false;
if (pingTimerRef.current) clearInterval(pingTimerRef.current);
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
wsRef.current?.close();
};
}, [connect]);
/** Send an annotation event to all other clients in the room. */
const send = useCallback((msg: Record<string, unknown>) => {
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
}
}, []);
const sendObjectAdd = useCallback(
(fabricObject: object, page: number) =>
send({ type: "object_add", payload: fabricObject, page }),
[send]
);
const sendObjectRemove = useCallback(
(objId: string, page: number) =>
send({ type: "object_remove", payload: { obj_id: objId }, page }),
[send]
);
const sendClear = useCallback(
(page: number) => send({ type: "clear", page }),
[send]
);
const sendCursor = useCallback(
(x: number, y: number, page: number) =>
send({ type: "cursor", payload: { x, y }, page }),
[send]
);
const sendColorSync = useCallback(
(color: string) => send({ type: "color_sync", color }),
[send]
);
return { users, connected, peerColors, sendObjectAdd, sendObjectRemove, sendClear, sendCursor, sendColorSync };
}
+56
View File
@@ -30,6 +30,8 @@ export const api = {
register: (body: { username: string; email: string; password: string }) =>
request<import("@/types").User>("/api/auth/register", { method: "POST", body: JSON.stringify(body) }),
logout: () => request<void>("/api/auth/logout", { method: "POST" }),
changePassword: (body: { current_password: string; new_password: string }) =>
request<void>("/api/auth/change-password", { method: "POST", body: JSON.stringify(body) }),
// PDFs
listPdfs: () => request<import("@/types").PDFItem[]>("/api/pdfs"),
@@ -40,9 +42,63 @@ export const api = {
// Annotations
getAnnotation: (pdfId: number, page: number) =>
request<import("@/types").AnnotationData>(`/api/annotations/${pdfId}/${page}`),
getAllAnnotations: (pdfId: number, page: number) =>
request<import("@/types").AnnotationData>(`/api/annotations/${pdfId}/${page}/all`),
upsertAnnotation: (pdfId: number, page: number, body: { canvas_data: object }) =>
request<import("@/types").AnnotationData>(`/api/annotations/${pdfId}/${page}`, {
method: "PUT",
body: JSON.stringify(body),
}),
upsertTempAnnotation: (pdfId: number, page: number, body: { canvas_data: object }) =>
request<void>(`/api/annotations/${pdfId}/${page}/temp`, {
method: "PUT",
body: JSON.stringify(body),
}),
deleteAnnotation: (annotationId: number) =>
request<void>(`/api/annotations/${annotationId}`, { method: "DELETE" }),
clearUserAnnotation: (pdfId: number, page: number, userId: number) =>
request<void>(`/api/annotations/${pdfId}/${page}/user/${userId}`, { method: "DELETE" }),
// Admin
adminListUsers: () =>
request<import("@/types").User[]>("/api/admin/users"),
adminUpdateStatus: (userId: number, status: import("@/types").UserStatus) =>
request<import("@/types").User>(`/api/admin/users/${userId}/status`, {
method: "PATCH",
body: JSON.stringify({ status }),
}),
adminUpdateRole: (userId: number, role: import("@/types").UserRole) =>
request<import("@/types").User>(`/api/admin/users/${userId}/role`, {
method: "PATCH",
body: JSON.stringify({ role }),
}),
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();
},
};
+7
View File
@@ -1,12 +1,19 @@
export type UserRole = "admin" | "teacher" | "student";
export type UserStatus = "pending" | "approved" | "rejected";
export interface User {
id: number;
username: string;
email: string;
role: UserRole;
status: UserStatus;
created_at: string;
}
export interface PDFItem {
id: number;
user_id: number;
owner_username: string;
title: string;
total_pages: number | null;
created_at: string;
+13
View File
@@ -0,0 +1,13 @@
declare module "fabric" {
export const fabric: any;
}
declare module "pdfjs-dist" {
export const GlobalWorkerOptions: {
workerSrc: string;
};
export const version: string;
export function getDocument(params: any): {
promise: Promise<any>;
};
}
Executable
+145
View File
@@ -0,0 +1,145 @@
#!/bin/bash
# Push LMS images to GitHub Container Registry (GHCR)
# 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 -euo pipefail
cd "$(dirname "$0")"
# ── 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}"; }
# ── 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}"
# ── Read current version ──────────────────────────────────────────────────────
VERSION_FILE="VERSION"
[ -f "$VERSION_FILE" ] || echo "1.0.0" > "$VERSION_FILE"
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
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 ───────────────────────────────────────────────────────────
step "Building images"
[ -n "$NO_CACHE" ] && warn "Building without layer cache"
# shellcheck disable=SC2086
docker compose build $NO_CACHE
ok "Build complete"
# ── 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 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 -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 -e " ${BOLD}📋 Add to .env on target machine:${RESET}"
echo " BACKEND_IMAGE=${BACKEND_VERSIONED}"
echo " FRONTEND_IMAGE=${FRONTEND_VERSIONED}"
echo ""
echo -e " ${BOLD}🚀 Deploy on target machine:${RESET}"
echo " docker compose pull && docker compose up -d"
+99 -1
View File
@@ -51,4 +51,102 @@ Please use the following technologies for this project:
Let's build this step-by-step to avoid context limits.
1. First, analyze this spec and confirm you understand.
2. Provide the database schema models (SQLAlchemy or Prisma).
3. Wait for my confirmation before writing the Backend API routes.
3. Wait for my confirmation before writing the Backend API routes.
---
## 7. CI/CD — Push lên GitHub Container Registry (GHCR)
### 7.1. Yêu cầu
- **Docker** đã cài và đang chạy
- **GitHub Personal Access Token (PAT)** với quyền `write:packages``read:packages`
- Tạo tại: https://github.com/settings/tokens → *Generate new token (classic)*
- File `.env` đã được cấu hình (xem `.env.example`)
### 7.2. Cấu hình `.env`
Thêm các dòng sau vào file `.env` (file này đã được gitignore, **không commit**):
```env
GITHUB_USER=<github_username_của_bạn>
GITHUB_TOKEN=<personal_access_token>
BACKEND_IMAGE=ghcr.io/<github_username>/lms-backend:latest
FRONTEND_IMAGE=ghcr.io/<github_username>/lms-frontend:latest
```
### 7.3. Build & Push lên GHCR
```bash
chmod +x push-ghcr.sh
./push-ghcr.sh
```
Script sẽ tự động:
1. Đọc `GITHUB_USER``GITHUB_TOKEN` từ `.env`
2. Đăng nhập vào `ghcr.io`
3. Build cả hai image (`lms-backend`, `lms-frontend`) bằng `docker compose build`
4. Tag và push lên GHCR
Để push với tag cụ thể (ví dụ: version):
```bash
./push-ghcr.sh v1.0.0
```
### 7.4. Deploy trên máy khác
Trên máy đích (server, VPS, máy tính khác):
```bash
# 1. Copy các file cần thiết
scp docker-compose.yml .env.example deploy.sh user@server:/opt/lms/
ssh user@server
# 2. Tạo .env từ example
cd /opt/lms
cp .env.example .env
# Điền các giá trị: POSTGRES_PASSWORD, JWT_SECRET_KEY, GITHUB_USER, GITHUB_TOKEN,
# BACKEND_IMAGE, FRONTEND_IMAGE
# 3. Chạy deploy
chmod +x deploy.sh
./deploy.sh
```
Script `deploy.sh` sẽ:
1. Kiểm tra `.env` hợp lệ
2. Đăng nhập GHCR (nếu có token)
3. Pull image từ GHCR
4. Khởi động toàn bộ stack: `db`, `backend`, `frontend`
### 7.5. Kiểm tra packages trên GitHub
Sau khi push, image sẽ xuất hiện tại:
```
https://github.com/<github_username>?tab=packages
```
> **Lưu ý:** Mặc định packages ở chế độ **Private**. Để máy khác pull mà không cần token, vào
> GitHub → Packages → tên package → *Package settings* → đổi visibility sang **Public**.
### 7.6. Sinh JWT Secret Key
```bash
python3 -c "import secrets; print(secrets.token_hex(32))"
```
Field Value
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
Executable
+134
View File
@@ -0,0 +1,134 @@
#!/bin/bash
# ─────────────────────────────────────────────────────────────────────────────
# start-dev.sh — Start LMS in local development mode
# Runs Backend (uvicorn) + Frontend (next dev) in parallel
# Usage:
# ./start-dev.sh # start both
# ./start-dev.sh backend # start backend only
# ./start-dev.sh frontend # start frontend only
# ─────────────────────────────────────────────────────────────────────────────
set -e
cd "$(dirname "$0")"
# ── Load .env ─────────────────────────────────────────────────────────────────
if [ ! -f .env ]; then
echo "❌ .env not found. Copy .env.example → .env and fill in values."
exit 1
fi
# Export only the variables we need (skip GHCR secrets)
export $(grep -v '^#' .env | grep -E 'POSTGRES_(DB|USER|PASSWORD)|JWT_SECRET_KEY|ACCESS_TOKEN_EXPIRE_MINUTES|FRONTEND_PORT' | xargs)
# Defaults
POSTGRES_DB="${POSTGRES_DB:-lms_db}"
POSTGRES_USER="${POSTGRES_USER:-lms_user}"
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-lms_test_password_123}"
JWT_SECRET_KEY="${JWT_SECRET_KEY:?JWT_SECRET_KEY is not set in .env}"
FRONTEND_PORT="${FRONTEND_PORT:-3000}"
BACKEND_PORT="${BACKEND_PORT:-8000}"
DATABASE_URL="postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}"
UPLOAD_DIR="./backend/uploads"
# ── Colors ────────────────────────────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'
TARGET="${1:-both}"
# ─────────────────────────────────────────────────────────────────────────────
start_backend() {
echo -e "${CYAN}${BOLD}▶ Starting Backend${NC} (port ${BACKEND_PORT})"
# Check Python venv
VENV=""
if [ -d backend/.venv ]; then
VENV="backend/.venv/bin/"
elif [ -d backend/venv ]; then
VENV="backend/venv/bin/"
fi
# Run Alembic migrations first
echo -e "${YELLOW} Running Alembic migrations...${NC}"
(
cd backend
DATABASE_URL="$DATABASE_URL" \
${VENV}alembic upgrade head 2>&1 | sed 's/^/ [alembic] /'
)
mkdir -p "$UPLOAD_DIR"
echo -e "${GREEN} Backend ready → http://localhost:${BACKEND_PORT}${NC}"
echo -e "${GREEN} API docs → http://localhost:${BACKEND_PORT}/docs${NC}"
echo ""
DATABASE_URL="$DATABASE_URL" \
JWT_SECRET_KEY="$JWT_SECRET_KEY" \
ACCESS_TOKEN_EXPIRE_MINUTES="$ACCESS_TOKEN_EXPIRE_MINUTES" \
UPLOAD_DIR="$UPLOAD_DIR" \
REDIS_URL="${REDIS_URL:-redis://localhost:6379/0}" \
${VENV}uvicorn main:app \
--host 0.0.0.0 \
--port "$BACKEND_PORT" \
--reload \
--app-dir backend
}
# ─────────────────────────────────────────────────────────────────────────────
start_frontend() {
echo -e "${CYAN}${BOLD}▶ Starting Frontend${NC} (port ${FRONTEND_PORT})"
if [ ! -d frontend/node_modules ]; then
echo -e "${YELLOW} node_modules not found — running npm install...${NC}"
(cd frontend && npm install)
fi
echo -e "${GREEN} Frontend ready → http://localhost:${FRONTEND_PORT}${NC}"
echo ""
NEXT_PUBLIC_API_URL="http://localhost:${BACKEND_PORT}" \
PORT="$FRONTEND_PORT" \
npm --prefix frontend run dev
}
# ─────────────────────────────────────────────────────────────────────────────
# Entrypoint
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo -e "${BOLD}╔══════════════════════════════════════╗${NC}"
echo -e "${BOLD}║ LMS — Dev Server ║${NC}"
echo -e "${BOLD}╚══════════════════════════════════════╝${NC}"
echo ""
case "$TARGET" in
backend)
start_backend
;;
frontend)
start_frontend
;;
both)
# Run backend in background, frontend in foreground
# Trap Ctrl+C to kill both
trap 'echo -e "\n${RED}Stopping...${NC}"; kill 0' INT TERM
start_backend &
BACKEND_PID=$!
# Small delay so backend logs don't jumble with frontend boot
sleep 2
start_frontend &
FRONTEND_PID=$!
echo ""
echo -e "${BOLD}Running. Press Ctrl+C to stop both servers.${NC}"
echo ""
wait $BACKEND_PID $FRONTEND_PID
;;
*)
echo "Usage: $0 [both|backend|frontend]"
exit 1
;;
esac