diff --git a/.env.example b/.env.example
index 8c89096..db46c22 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000..21e8796
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+1.0.3
diff --git a/backend/Dockerfile b/backend/Dockerfile
index 5d19b4a..b7125ef 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -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
diff --git a/backend/alembic/versions/0002_add_role_status.py b/backend/alembic/versions/0002_add_role_status.py
new file mode 100644
index 0000000..b53c933
--- /dev/null
+++ b/backend/alembic/versions/0002_add_role_status.py
@@ -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)
diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py
index 431de48..e17a225 100644
--- a/backend/app/dependencies.py
+++ b/backend/app/dependencies.py
@@ -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
diff --git a/backend/app/main.py b/backend/app/main.py
index adc6c21..b3e65ca 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -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)
diff --git a/backend/app/models.py b/backend/app/models.py
index ba274ce..5a174ad 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -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
diff --git a/backend/app/redis_client.py b/backend/app/redis_client.py
new file mode 100644
index 0000000..589db3e
--- /dev/null
+++ b/backend/app/redis_client.py
@@ -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}:*"
diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py
new file mode 100644
index 0000000..d12748b
--- /dev/null
+++ b/backend/app/routers/admin.py
@@ -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()
diff --git a/backend/app/routers/annotations.py b/backend/app/routers/annotations.py
index fa580e4..05d3336 100644
--- a/backend/app/routers/annotations.py
+++ b/backend/app/routers/annotations.py
@@ -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)
+
diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py
index e660f2e..317812f 100644
--- a/backend/app/routers/auth.py
+++ b/backend/app/routers/auth.py
@@ -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()
diff --git a/backend/app/routers/backup.py b/backend/app/routers/backup.py
new file mode 100644
index 0000000..0328fd7
--- /dev/null
+++ b/backend/app/routers/backup.py
@@ -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"),
+ }
diff --git a/backend/app/routers/pdfs.py b/backend/app/routers/pdfs.py
index 82fa67d..200a5b5 100644
--- a/backend/app/routers/pdfs.py
+++ b/backend/app/routers/pdfs.py
@@ -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():
diff --git a/backend/app/routers/ws.py b/backend/app/routers/ws.py
new file mode 100644
index 0000000..79b5f42
--- /dev/null
+++ b/backend/app/routers/ws.py
@@ -0,0 +1,172 @@
+"""
+WebSocket collaboration endpoint.
+
+URL: ws://.../ws/pdf/{pdf_id}?token=
+
+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": }
+ { "type": "object_remove", "payload": { "obj_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)
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index 6eea75a..b5e77cf 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -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
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 35f9bce..4ac8e78 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -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
diff --git a/build.sh b/build.sh
new file mode 100755
index 0000000..2e9c69d
--- /dev/null
+++ b/build.sh
@@ -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
diff --git a/deploy.sh b/deploy.sh
new file mode 100755
index 0000000..45eab80
--- /dev/null
+++ b/deploy.sh
@@ -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
diff --git a/docker-compose.yml b/docker-compose.yml
index 1112add..0f92a57 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index 9e80154..0e1deff 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -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
diff --git a/frontend/next.config.js b/frontend/next.config.js
index 6eb445f..7bb50db 100644
--- a/frontend/next.config.js
+++ b/frontend/next.config.js
@@ -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*`,
+ },
];
},
};
diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx
new file mode 100644
index 0000000..146d693
--- /dev/null
+++ b/frontend/src/app/admin/page.tsx
@@ -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 = {
+ pending: "Chờ duyệt",
+ approved: "Đã duyệt",
+ rejected: "Từ chối",
+};
+
+const ROLE_LABELS: Record = {
+ admin: "Admin",
+ teacher: "Giáo viên",
+ student: "Học viên",
+};
+
+const STATUS_COLORS: Record = {
+ 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 = {
+ 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(null);
+ const [users, setUsers] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [busy, setBusy] = useState(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(null);
+
+ const [filterStatus, setFilterStatus] = useState("all");
+ const [filterRole, setFilterRole] = useState("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 (
+
+ );
+ }
+
+ return (
+
+ {/* ── Navbar ──────────────────────────────────────────────────────────── */}
+
+
+
+
+
PDF LMS
+
+ Admin
+
+
+
+
{me?.username}
+
+
+
+
+
+
+
+ {/* ── Page title + stats ─────────────────────────────────────────────── */}
+
+
+
Quản lý người dùng
+
{users.length} tài khoản tổng cộng
+
+ {pendingCount > 0 && (
+
setFilterStatus("pending")}
+ >
+
+ {pendingCount} tài khoản chờ duyệt
+
+ )}
+
+
+ {/* ── Error banner ──────────────────────────────────────────────────── */}
+ {error && (
+
+ {error}
+
+
+ )}
+
+ {/* ── Filters ───────────────────────────────────────────────────────── */}
+
+ 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"
+ />
+
+
+ {(filterStatus !== "all" || filterRole !== "all" || search) && (
+
+ )}
+
+
+ {/* ── Backup & Restore ─────────────────────────────────────────────── */}
+
+
Sao lưu & Khôi phục
+
+ File ZIP chứa toàn bộ cơ sở dữ liệu (SQL dump) và các file PDF đã tải lên.
+ Dùng để di chuyển sang server khác.
+
+
+ {backupMsg && (
+
+ {backupMsg.text}
+
+
+ )}
+
+
+ {/* Download backup */}
+
+
+ {/* Restore */}
+
+
+
+ ⚠️ 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.
+
+
+
+ {/* ── Table ────────────────────────────────────────────────────────── */}
+
+ {filtered.length === 0 ? (
+
Không tìm thấy tài khoản nào.
+ ) : (
+
+
+
+
+ | ID |
+ Người dùng |
+ Vai trò |
+ Trạng thái |
+ Ngày đăng ký |
+ Hành động |
+
+
+
+ {filtered.map(u => {
+ const isSelf = u.id === me?.id;
+ const isLoading = busy === u.id;
+ return (
+
+ {/* ID */}
+ | {u.id} |
+
+ {/* User info */}
+
+ {u.username}
+ {u.email}
+ |
+
+ {/* Role selector */}
+
+ {isSelf ? (
+
+ {ROLE_LABELS[u.role]}
+
+ ) : (
+
+ )}
+ |
+
+ {/* Status selector */}
+
+ {isSelf ? (
+
+ {STATUS_LABELS[u.status]}
+
+ ) : (
+
+ )}
+ |
+
+ {/* Created at */}
+
+ {new Date(u.created_at).toLocaleDateString("vi-VN")}
+ |
+
+ {/* Actions */}
+
+ {isLoading ? (
+
+ ) : isSelf ? (
+ —
+ ) : (
+
+ )}
+ |
+
+ );
+ })}
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/app/change-password/page.tsx b/frontend/src/app/change-password/page.tsx
new file mode 100644
index 0000000..7031de2
--- /dev/null
+++ b/frontend/src/app/change-password/page.tsx
@@ -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 (
+
+
+ {/* Header */}
+
+
+
Đổi mật khẩu
+
+
+ {/* Success */}
+ {success && (
+
+
+ Đổi mật khẩu thành công!
+
+ )}
+
+ {/* Error */}
+ {error && (
+
+ {error}
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx
index 7dc668f..87bd68c 100644
--- a/frontend/src/app/dashboard/page.tsx
+++ b/frontend/src/app/dashboard/page.tsx
@@ -71,6 +71,23 @@ export default function DashboardPage() {
PDF LMS
{user?.username}
+ {user?.role === "admin" && (
+
+ )}
+
);
-}
+}
\ No newline at end of file
diff --git a/frontend/src/components/UploadZone.tsx b/frontend/src/components/UploadZone.tsx
index 4b5ed45..0e73d37 100644
--- a/frontend/src/components/UploadZone.tsx
+++ b/frontend/src/components/UploadZone.tsx
@@ -102,7 +102,7 @@ export default function UploadZone({ onUploaded }: Props) {
Drag & drop PDFs here, or{" "}
click to browse
- Multiple files supported · Max 50 MB each
+ Multiple files supported · Max 500 MB each
>
)}
diff --git a/frontend/src/components/WorkbookViewer.tsx b/frontend/src/components/WorkbookViewer.tsx
index 7a6cbb1..6f89e9a 100644
--- a/frontend/src/components/WorkbookViewer.tsx
+++ b/frontend/src/components/WorkbookViewer.tsx
@@ -1,15 +1,41 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
+import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
+import { useCollaboration, type RemoteEvent } from "@/hooks/useCollaboration";
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Preset color palette (30 hues, 3 brightness levels × 10 hue families)
+// Each color is visually distinct to avoid confusion between collaborators.
+// ─────────────────────────────────────────────────────────────────────────────
+
+const PRESET_COLORS = [
+ "#e63946", "#9d0208", "#ff6b6b", // Red
+ "#f4572a", "#a23b17", "#ff9472", // Orange-red
+ "#f7b731", "#a07800", "#ffe066", // Yellow
+ "#80b918", "#4a6c0a", "#b5e853", // Yellow-green
+ "#2dc653", "#0a7a2e", "#6ee08a", // Green
+ "#00b4d8", "#005f73", "#48cae4", // Cyan
+ "#4361ee", "#1a237e", "#7b9cff", // Blue
+ "#7209b7", "#3a0068", "#b56aff", // Purple
+ "#e040fb", "#880e62", "#f48fff", // Magenta
+ "#ff6392", "#a3003f", "#ffadc5", // Rose
+] as const;
+
+function userIdToPresetColor(id: number): string {
+ return PRESET_COLORS[id % PRESET_COLORS.length];
+}
// ─────────────────────────────────────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────────────────────────────────────
-type Tool = "select" | "pen" | "highlighter" | "text";
+type Tool = "select" | "pen" | "highlighter" | "text" | "eraser";
type ViewMode = "pan" | "draw";
+type FitMode = "width" | "height";
+type ScrollMode = "single" | "continuous";
interface Props {
pdfId: number;
@@ -106,11 +132,299 @@ export default function WorkbookViewer({ pdfId }: Props) {
const [pdfTitle, setPdfTitle] = useState("PDF");
const [loadError, setLoadError] = useState("");
const [rendering, setRendering] = useState(false);
+ const [showThumbnails, setShowThumbnails] = useState(true);
+ const [thumbnails, setThumbnails] = useState([]);
+ const [fitMode, setFitMode] = useState("width");
+ const [scrollMode, setScrollMode] = useState("single");
+
+ // Collaboration
+ const [collabToken, setCollabToken] = useState(null);
+ const currentPageRef = useRef(1); // mutable copy for collab callbacks
+ const skipRemoteRef = useRef(false); // prevent echo-back when applying remote events
+ const currentUserIdRef = useRef(null); // populated from api.me(); used to identify own vs remote objects
+ const [userRole, setUserRole] = useState<"admin" | "teacher" | "student">("student");
+ const userRoleRef = useRef<"admin" | "teacher" | "student">("student");
+ const [currentUsername, setCurrentUsername] = useState("");
+ const currentUsernameRef = useRef("");
+ const userMapRef = useRef