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.
+ ) : ( +
+ + + + + + + + + + + + + {filtered.map(u => { + const isSelf = u.id === me?.id; + const isLoading = busy === u.id; + return ( + + {/* ID */} + + + {/* User info */} + + + {/* Role selector */} + + + {/* Status selector */} + + + {/* Created at */} + + + {/* Actions */} + + + ); + })} + +
IDNgười dùngVai tròTrạng tháiNgày đăng kýHành động
{u.id} +
{u.username}
+
{u.email}
+
+ {isSelf ? ( + + {ROLE_LABELS[u.role]} + + ) : ( + + )} + + {isSelf ? ( + + {STATUS_LABELS[u.status]} + + ) : ( + + )} + + {new Date(u.created_at).toLocaleDateString("vi-VN")} + + {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} + +
+ )} + +
+ {/* Current password */} +
+ +
+ 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" + /> + +
+
+ + {/* New password */} +
+ +
+ 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ự" + /> + +
+ {/* Strength indicator */} + {form.new_password && ( +
+ {[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
; + })} +
+ )} +
+ + {/* Confirm password */} +
+ +
+ 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" + /> + +
+ {form.confirm_password && form.confirm_password !== form.new_password && ( +

Mật khẩu không khớp.

+ )} +
+ + + +
+
+ ); +} 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" && ( + + )} + + {/* Delete button — only for owner, visible on hover */} + {isOwner && ( + + )}
); -} +} \ 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>(new Map()); + const [annotationTooltip, setAnnotationTooltip] = useState<{ x: number; y: number; text: string } | null>(null); + // Buffer for remote events that arrive while renderPageWithAnnotations is in progress + const renderingRef = useRef(false); + const pendingRemoteEvents = useRef([]); + // True once api.me() has resolved and pen color has been set from user id + const [userColorReady, setUserColorReady] = useState(false); + + // Color picker + const [colorPickerOpen, setColorPickerOpen] = useState(false); + const [colorPickerPos, setColorPickerPos] = useState<{ top: number; left: number } | null>(null); + const colorBtnRef = useRef(null); + const colorPickerRef = useRef(null); + + // Ref for auto-scrolling thumbnail panel + const thumbRefs = useRef<(HTMLButtonElement | null)[]>([]); + const fitModeRef = useRef("width"); + const scrollModeRef = useRef("single"); + // Per-page canvas refs for continuous mode + const pageCanvasRefs = useRef<(HTMLCanvasElement | null)[]>([]); + const pageFabricRefs = useRef([]); + // Active PDF.js render tasks — cancel before re-rendering + const renderTasksRef = useRef([]); + // Generation counter — incremented on each renderAllPages call to abort stale runs + const renderAllPagesGenRef = useRef(0); + + // Collab: stable ref to send functions (set after hook initializes) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const collabSendRef = useRef<{ objectAdd: any; objectRemove: any; clear: any; colorSync: (c: string) => void } | null>(null); + + // Temp-cache sync: debounce unsaved canvas state to Redis (1.5 s after last stroke) + const tempSyncTimerRef = useRef | null>(null); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const syncTempCanvasRef = useRef<(page: number) => void>(() => {}); + + // Keep currentPageRef in sync; clear any lingering hover tooltip on page navigation + useEffect(() => { currentPageRef.current = currentPage; setAnnotationTooltip(null); }, [currentPage]); // Keep mutable refs in sync useEffect(() => { currentToolRef.current = tool; }, [tool]); useEffect(() => { penColorRef.current = penColor; }, [penColor]); useEffect(() => { strokeWidthRef.current = strokeWidth; }, [strokeWidth]); + useEffect(() => { fitModeRef.current = fitMode; }, [fitMode]); + useEffect(() => { scrollModeRef.current = scrollMode; }, [scrollMode]); + useEffect(() => { currentUsernameRef.current = currentUsername; }, [currentUsername]); + + // Keep syncTempCanvasRef current so closures inside Fabric events always see latest pdfId + useEffect(() => { + syncTempCanvasRef.current = (page: number) => { + const fc = fabricRef.current; + if (!fc) return; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fullData: any = fc.toJSON(["_owner_id", "_owner_username"]); + const uid = currentUserIdRef.current; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ownObjects = ((fullData.objects as any[]) ?? []).filter( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (o: any) => o._owner_id == null || o._owner_id === uid + ); + const canvasData = { ...fullData, objects: ownObjects }; + if (tempSyncTimerRef.current) clearTimeout(tempSyncTimerRef.current); + tempSyncTimerRef.current = setTimeout(() => { + api.upsertTempAnnotation(pdfId, page, { canvas_data: canvasData }).catch(() => {}); + }, 1500); + }; + }, [pdfId]); + + // Auto-scroll thumbnail sidebar to keep current page visible + useEffect(() => { + if (showThumbnails) { + thumbRefs.current[currentPage - 1]?.scrollIntoView({ block: "nearest", behavior: "smooth" }); + } + }, [currentPage, showThumbnails]); + + // Fetch JWT token for WebSocket auth (cookie not sent on WS upgrade) + useEffect(() => { + fetch("/api/auth/token", { credentials: "include" }) + .then(r => r.ok ? r.json() : null) + .then((d: { access_token?: string } | null) => { if (d?.access_token) setCollabToken(d.access_token); }) + .catch(() => {}); + }, []); + + // Fetch current user id — used to distinguish own from remote objects when merging /all + useEffect(() => { + api.me().then((u) => { + currentUserIdRef.current = u.id; + setUserRole(u.role); + userRoleRef.current = u.role; + setCurrentUsername(u.username); + currentUsernameRef.current = u.username; + userMapRef.current.set(u.id, u.username); + // Assign deterministic color from preset palette based on user id + const assigned = userIdToPresetColor(u.id); + setPenColor(assigned); + penColorRef.current = assigned; + setUserColorReady(true); // unblocks the color-sync broadcast + }).catch(() => {}); + }, []); + + // Close color picker when clicking outside + useEffect(() => { + if (!colorPickerOpen) return; + const handler = (e: MouseEvent) => { + if ( + colorPickerRef.current && !colorPickerRef.current.contains(e.target as Node) && + colorBtnRef.current && !colorBtnRef.current.contains(e.target as Node) + ) { + setColorPickerOpen(false); + setColorPickerPos(null); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [colorPickerOpen]); + + // Handle incoming remote annotation events + const onRemoteEvent = useCallback((event: RemoteEvent) => { + const fabric = fabricNSRef.current; + if (!fabric) return; + + // Resolve the right fabric canvas for the event's page + const getCanvas = (page: number) => { + if (scrollModeRef.current === "continuous") { + return pageFabricRefs.current[page - 1] ?? null; + } + return currentPageRef.current === page ? fabricRef.current : null; + }; + + if (event.type === "clear") { + const fc = getCanvas(event.page ?? currentPageRef.current); + if (!fc) return; + skipRemoteRef.current = true; + skipObjectTracking.current = true; + fc.clear(); fc.renderAll(); + skipRemoteRef.current = false; + skipObjectTracking.current = false; + return; + } + + if (event.type === "object_remove") { + const fc = getCanvas(event.page ?? currentPageRef.current); + if (!fc) return; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const collab_id = (event.payload as any)?.obj_id; + if (!collab_id) return; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const target = fc.getObjects().find((o: any) => o.collab_id === collab_id); + if (target) { skipRemoteRef.current = true; fc.remove(target); fc.renderAll(); skipRemoteRef.current = false; } + return; + } + + if (event.type === "object_add") { + // If a page render is in progress, the canvas is about to be cleared + reloaded. + // Buffer this event and replay it after renderPageWithAnnotations finishes. + if (renderingRef.current && scrollModeRef.current === "single") { + pendingRemoteEvents.current.push(event); + return; + } + const page = event.page ?? currentPageRef.current; + const fc = getCanvas(page); + if (!fc) return; + const objJson = event.payload as Record; + skipRemoteRef.current = true; + skipObjectTracking.current = true; + fabric.util.enlivenObjects([objJson], (objects: any[]) => { + const canInteract = userRoleRef.current !== "student"; + objects.forEach((obj: any) => { + obj.collab_id = objJson.collab_id; + obj._isRemote = true; // don't save other users' live strokes under our account + if ((objJson as any)._owner_username) obj._owner_username = (objJson as any)._owner_username; + obj.selectable = canInteract; + obj.evented = canInteract; + fc.add(obj); + }); + fc.renderAll(); + skipRemoteRef.current = false; + skipObjectTracking.current = false; + }); + return; + } + }, []);; + + // When a new peer joins the room, broadcast all our own (non-remote) canvas + // objects so they receive our pre-existing annotations immediately. + const handlePeerJoined = useCallback(() => { + const sendAdd = collabSendRef.current?.objectAdd; + if (!sendAdd) return; + + const broadcastCanvas = (fc: any, page: number) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fc.getObjects().forEach((obj: any) => { + // Skip objects from other users — they already have their own + if (obj._isRemote) return; + if (!obj.collab_id) obj.collab_id = crypto.randomUUID(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const json = (obj as any).toJSON(["collab_id"]); + sendAdd(json, page); + }); + }; + + if (scrollModeRef.current === "continuous") { + pageFabricRefs.current.forEach((fc, idx) => { + if (fc) broadcastCanvas(fc, idx + 1); + }); + } else { + const fc = fabricRef.current; + if (fc) broadcastCanvas(fc, currentPageRef.current); + } + // Also broadcast our current pen color so the new peer can disable our swatch + collabSendRef.current?.colorSync(penColorRef.current); + }, []); + + // Collaboration hook + const { users: collabUsers, connected: collabConnected, peerColors, sendObjectAdd, sendObjectRemove, sendClear, sendColorSync } = + useCollaboration({ pdfId, token: collabToken, onEvent: onRemoteEvent, onPeerJoined: handlePeerJoined }); + + // Keep send functions accessible in stable refs (used in Fabric event handlers) + useEffect(() => { + collabSendRef.current = { objectAdd: sendObjectAdd, objectRemove: sendObjectRemove, clear: sendClear, colorSync: sendColorSync }; + }, [sendObjectAdd, sendObjectRemove, sendClear, sendColorSync]); + + // Keep user id→name map updated as peers connect/disconnect + useEffect(() => { + collabUsers.forEach(u => userMapRef.current.set(u.user_id, u.username)); + }, [collabUsers]); + + // Broadcast pen color only when BOTH WS is connected AND user color has been fetched. + // This prevents both users broadcasting the same default "#e63946" before api.me() resolves. + // Also re-broadcasts whenever the user manually picks a new color. + useEffect(() => { + if (collabConnected && userColorReady) { + sendColorSync(penColor); + } + }, [collabConnected, userColorReady, penColor, sendColorSync]); + + // If our color collides with a peer's color, pick the first free preset color and rebroadcast. + useEffect(() => { + if (!collabConnected || !userColorReady) return; + const uid = currentUserIdRef.current; + if (uid == null) return; + + const taken = new Set( + Object.entries(peerColors) + .filter(([id]) => Number(id) !== uid) + .map(([, c]) => c.toLowerCase()) + ); + const current = penColor.toLowerCase(); + if (!taken.has(current)) return; + + const replacement = PRESET_COLORS.find(c => !taken.has(c.toLowerCase())) ?? penColor; + if (replacement.toLowerCase() === current) return; + + setPenColor(replacement); + penColorRef.current = replacement; + sendColorSync(replacement); + }, [collabConnected, userColorReady, peerColors, penColor, sendColorSync]); + + // Re-render current page when fit mode changes — moved below renderAllPages definition + + const generateThumbnails = useCallback(async () => { + const doc = pdfDocRef.current; + if (!doc) return; + for (let i = 1; i <= doc.numPages; i++) { + const page = await doc.getPage(i); + const vp1 = page.getViewport({ scale: 1 }); + const scale = 120 / vp1.width; + const vp = page.getViewport({ scale }); + const cvs = document.createElement("canvas"); + cvs.width = Math.floor(vp.width); + cvs.height = Math.floor(vp.height); + await page.render({ canvasContext: cvs.getContext("2d")!, viewport: vp }).promise; + const dataUrl = cvs.toDataURL("image/jpeg", 0.7); + setThumbnails(prev => { + const next = [...prev]; + next[i - 1] = dataUrl; + return next; + }); + } + }, []); // ───────────────────────────────────────────────────────────────────────── // renderPageWithAnnotations @@ -123,24 +437,37 @@ export default function WorkbookViewer({ pdfId }: Props) { if (!fc || !doc || !pdfCanvasRef.current || !scrollContainerRef.current) return; setRendering(true); + renderingRef.current = true; + pendingRemoteEvents.current = []; try { + // Cancel any in-progress render on the same canvas + renderTasksRef.current.forEach(t => { try { t.cancel(); } catch { /* ignore */ } }); + renderTasksRef.current = []; + // ── 1. Render PDF page ───────────────────────────────────────────── const page = await doc.getPage(pageNum); - const containerWidth = Math.max(scrollContainerRef.current.clientWidth - 32, 300); const viewport1 = page.getViewport({ scale: 1 }); - const scale = containerWidth / viewport1.width; + let scale: number; + if (fitModeRef.current === "height") { + const containerHeight = Math.max(scrollContainerRef.current.clientHeight - 48, 400); + scale = containerHeight / viewport1.height; + } else { + const containerWidth = Math.max(scrollContainerRef.current.clientWidth - 32, 300); + scale = containerWidth / viewport1.width; + } const viewport = page.getViewport({ scale }); const pdfCvs = pdfCanvasRef.current; pdfCvs.width = Math.floor(viewport.width); pdfCvs.height = Math.floor(viewport.height); - await page.render({ + const singleRenderTask = page.render({ canvasContext: pdfCvs.getContext("2d")!, viewport, - }).promise; - - // ── 2. Resize Fabric canvas to match ────────────────────────────── + }); + renderTasksRef.current.push(singleRenderTask); + await singleRenderTask.promise; + // ── 2. Resize Fabric canvas to match fc.setWidth(pdfCvs.width); fc.setHeight(pdfCvs.height); if (fc.wrapperEl) { @@ -154,7 +481,7 @@ export default function WorkbookViewer({ pdfId }: Props) { if (!annotationData) { try { - const ann = await api.getAnnotation(pdfId, pageNum); + const ann = await api.getAllAnnotations(pdfId, pageNum); if (ann.canvas_data && Object.keys(ann.canvas_data).length > 0) { annotationData = ann.canvas_data; localAnnotations.current[pageNum] = annotationData; @@ -168,8 +495,31 @@ export default function WorkbookViewer({ pdfId }: Props) { addedObjects.current = []; if (annotationData) { + const rawObjs: any[] = ((annotationData as any).objects ?? []); await new Promise((resolve) => { fc.loadFromJSON(annotationData, () => { + // Enforce custom properties — Fabric may not restore underscore-prefixed props + fc.getObjects().forEach((obj: any, i: number) => { + const raw = rawObjs[i]; + if (raw) { + if (obj._owner_id === undefined) obj._owner_id = raw._owner_id ?? null; + if (obj._owner_username === undefined) obj._owner_username = raw._owner_username ?? null; + } + }); + // Mark objects from other users as remote — prevents saving them under current user + const uid = currentUserIdRef.current; + if (uid !== null) { + const canInteract = userRoleRef.current !== "student"; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fc.getObjects().forEach((obj: any) => { + if (obj._owner_id != null && obj._owner_id !== uid) { + obj._isRemote = true; + obj.selectable = canInteract; + obj.evented = canInteract; + if (!obj._owner_username) obj._owner_username = userMapRef.current.get(obj._owner_id) ?? null; + } + }); + } fc.renderAll(); skipObjectTracking.current = false; resolve(); @@ -179,13 +529,167 @@ export default function WorkbookViewer({ pdfId }: Props) { fc.renderAll(); skipObjectTracking.current = false; } + + // Replay any remote events that arrived while the canvas was being loaded + renderingRef.current = false; + const queued = pendingRemoteEvents.current.splice(0); + const fabric = fabricNSRef.current; + if (fabric && queued.length > 0) { + for (const evt of queued) { + if (evt.type !== "object_add") continue; + if ((evt.page ?? currentPageRef.current) !== pageNum) continue; + const objJson = evt.payload as Record; + skipRemoteRef.current = true; + skipObjectTracking.current = true; + fabric.util.enlivenObjects([objJson], (objects: any[]) => { + const canInteract = userRoleRef.current !== "student"; + objects.forEach((obj: any) => { + obj.collab_id = objJson.collab_id; + obj._isRemote = true; + if ((objJson as any)._owner_username) obj._owner_username = (objJson as any)._owner_username; + obj.selectable = canInteract; + obj.evented = canInteract; + fc.add(obj); + }); + fc.renderAll(); + skipRemoteRef.current = false; + skipObjectTracking.current = false; + }); + } + } } finally { + renderingRef.current = false; setRendering(false); } }, [pdfId] ); + // ───────────────────────────────────────────────────────────────────────── + // renderAllPages (continuous scroll mode) + // ───────────────────────────────────────────────────────────────────────── + + const renderAllPages = useCallback(async () => { + const gen = ++renderAllPagesGenRef.current; + const doc = pdfDocRef.current; + const container = scrollContainerRef.current; + if (!doc || !container) return; + + // Dispose old per-page fabric instances + pageFabricRefs.current.forEach(fc => { try { fc?.dispose(); } catch { /* ignore */ } }); + pageFabricRefs.current = []; + + // Cancel any in-progress render tasks + renderTasksRef.current.forEach(t => { try { t.cancel(); } catch { /* ignore */ } }); + renderTasksRef.current = []; + + const fabricModule = await import("fabric"); + if (renderAllPagesGenRef.current !== gen) return; + const fabric = fabricModule.fabric; + const containerWidth = Math.max(container.clientWidth - 32, 300); + + for (let i = 1; i <= doc.numPages; i++) { + if (renderAllPagesGenRef.current !== gen) return; + const pdfCvs = pageCanvasRefs.current[i - 1]; + const fabricEl = document.getElementById(`fabric-continuous-${i}`) as HTMLCanvasElement | null; + if (!pdfCvs || !fabricEl) continue; + + const page = await doc.getPage(i); + const vp1 = page.getViewport({ scale: 1 }); + let scale: number; + if (fitModeRef.current === "height") { + const containerHeight = Math.max(container.clientHeight - 48, 400); + scale = containerHeight / vp1.height; + } else { + scale = containerWidth / vp1.width; + } + const vp = page.getViewport({ scale }); + pdfCvs.width = Math.floor(vp.width); + pdfCvs.height = Math.floor(vp.height); + const contRenderTask = page.render({ canvasContext: pdfCvs.getContext("2d")!, viewport: vp }); + renderTasksRef.current.push(contRenderTask); + try { + await contRenderTask.promise; + } catch (e: any) { + if (e?.name === "RenderingCancelledException") return; + throw e; + } + if (renderAllPagesGenRef.current !== gen) return; + + // Create Fabric canvas overlay + const fc = new fabric.Canvas(fabricEl, { + isDrawingMode: false, + selection: false, + enableRetinaScaling: false, + }); + fc.setWidth(pdfCvs.width); + fc.setHeight(pdfCvs.height); + if (fc.wrapperEl) { + fc.wrapperEl.style.position = "absolute"; + fc.wrapperEl.style.top = "0"; + fc.wrapperEl.style.left = "0"; + fc.wrapperEl.style.pointerEvents = "none"; + } + pageFabricRefs.current[i - 1] = fc; + + // Load saved annotations + const cached = localAnnotations.current[i]; + let annotationData: object | null = cached ?? null; + if (!annotationData) { + try { + const ann = await api.getAllAnnotations(pdfId, i); + if (ann.canvas_data && Object.keys(ann.canvas_data).length > 0) { + annotationData = ann.canvas_data; + localAnnotations.current[i] = annotationData; + } + } catch { /* no annotation */ } + } + if (annotationData) { + const rawObjsCont: any[] = ((annotationData as any).objects ?? []); + await new Promise((resolve) => { + fc.loadFromJSON(annotationData, () => { + // Enforce custom properties — Fabric may not restore underscore-prefixed props + fc.getObjects().forEach((obj: any, i: number) => { + const raw = rawObjsCont[i]; + if (raw) { + if (obj._owner_id === undefined) obj._owner_id = raw._owner_id ?? null; + if (obj._owner_username === undefined) obj._owner_username = raw._owner_username ?? null; + } + }); + // Mark objects from other users as remote + const uid = currentUserIdRef.current; + if (uid !== null) { + const canInteract = userRoleRef.current !== "student"; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fc.getObjects().forEach((obj: any) => { + if (obj._owner_id != null && obj._owner_id !== uid) { + obj._isRemote = true; + obj.selectable = canInteract; + obj.evented = canInteract; + if (!obj._owner_username) obj._owner_username = userMapRef.current.get(obj._owner_id) ?? null; + } + }); + } + fc.renderAll(); + resolve(); + }); + }); + } + } + + }, [pdfId]); + + // Re-render when fit mode changes + useEffect(() => { + if (!isReady) return; + if (scrollMode === "continuous") { + renderAllPages(); + } else { + renderPageWithAnnotations(currentPage); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fitMode]); + // ───────────────────────────────────────────────────────────────────────── // Initialization (mount) // ───────────────────────────────────────────────────────────────────────── @@ -226,11 +730,22 @@ export default function WorkbookViewer({ pdfId }: Props) { fc.wrapperEl.style.pointerEvents = "none"; // start in pan mode } - // Track added objects for undo (skip objects loaded from JSON) + // Single object:added handler: undo tracking + collab broadcast for text // eslint-disable-next-line @typescript-eslint/no-explicit-any fc.on("object:added", (e: any) => { - if (!skipObjectTracking.current) { - addedObjects.current.push(e.target); + if (skipObjectTracking.current) return; + addedObjects.current.push(e.target); + if (skipRemoteRef.current) return; + const obj = e.target; + // Text objects: broadcast final content when editing ends, not the placeholder + if (obj.type === "i-text" || obj.type === "text") { + obj.once("editing:exited", () => { + if (skipRemoteRef.current) return; + if (!obj.collab_id) obj.collab_id = crypto.randomUUID(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const json = (obj as any).toJSON(["collab_id", "_owner_username", "_owner_id"]); + collabSendRef.current?.objectAdd(json, currentPageRef.current); + }); } }); @@ -241,6 +756,24 @@ export default function WorkbookViewer({ pdfId }: Props) { options.path.set({ opacity: 0.42 }); fc.renderAll(); } + // Tag with owner info for tooltip display + options.path._owner_id = currentUserIdRef.current; + options.path._owner_username = currentUsernameRef.current; + // Broadcast stroke to collaborators + if (!skipRemoteRef.current) { + options.path.collab_id = crypto.randomUUID(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const obj = (options.path as any).toJSON(["collab_id", "_owner_username", "_owner_id"]); + collabSendRef.current?.objectAdd(obj, currentPageRef.current); + } + // Sync unsaved canvas to Redis so late-joining users see this stroke + syncTempCanvasRef.current(currentPageRef.current); + }); + + // Object removed (eraser / undo) — sync temp cache + fc.on("object:removed", () => { + if (skipObjectTracking.current || skipRemoteRef.current) return; + syncTempCanvasRef.current(currentPageRef.current); }); // ── Init PDF.js ─────────────────────────────────────────────────── @@ -263,6 +796,10 @@ export default function WorkbookViewer({ pdfId }: Props) { await renderPageWithAnnotations(1); setCurrentPage(1); + // Generate thumbnails in background (non-blocking) + setThumbnails([]); + generateThumbnails(); + } catch (err: unknown) { if (!cancelled) { setLoadError( @@ -278,10 +815,110 @@ export default function WorkbookViewer({ pdfId }: Props) { cancelled = true; fabricRef.current?.dispose(); fabricRef.current = null; + pageFabricRefs.current.forEach(fc => { try { fc?.dispose(); } catch { /* ignore */ } }); + pageFabricRefs.current = []; pdfDocRef.current?.destroy(); pdfDocRef.current = null; }; - }, [pdfId, renderPageWithAnnotations]); + }, [pdfId, renderPageWithAnnotations, generateThumbnails, renderAllPages]); + + // ── Annotation hover tooltip (works in all modes including pan) ──────── + // Uses scroll-container mousemove + Fabric findTarget() so pointer-events:none + // on the canvas wrapper doesn't block tooltip detection. + useEffect(() => { + if (!isReady) return; + const container = scrollContainerRef.current; + if (!container) return; + + const resolveOwnerName = (obj: any): string | null => { + if (obj._owner_username) return obj._owner_username as string; + const ownerId: number | null = obj._owner_id ?? null; + if (ownerId === null) return null; // no owner info — don't attribute to current user + const uid = currentUserIdRef.current; + if (ownerId === uid) return currentUsernameRef.current || null; + return userMapRef.current.get(ownerId) ?? `User #${ownerId}`; + }; + + let lastTarget: any = null; + const handleMouseMove = (e: MouseEvent) => { + let found: any = null; + if (scrollModeRef.current === "single") { + const fc = fabricRef.current; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (fc) found = (fc as any).findTarget(e, false) ?? null; + } else { + for (const fc of pageFabricRefs.current) { + if (!fc) continue; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const hit = (fc as any).findTarget(e, false); + if (hit) { found = hit; break; } + } + } + if (found === lastTarget) return; // avoid redundant state updates + lastTarget = found; + if (found) { + const name = resolveOwnerName(found); + if (name) { + setAnnotationTooltip({ x: e.clientX, y: e.clientY, text: name }); + } else { + setAnnotationTooltip(null); + } + } else { + setAnnotationTooltip(null); + } + }; + const handleMouseLeave = () => { lastTarget = null; setAnnotationTooltip(null); }; + + container.addEventListener("mousemove", handleMouseMove); + container.addEventListener("mouseleave", handleMouseLeave); + return () => { + container.removeEventListener("mousemove", handleMouseMove); + container.removeEventListener("mouseleave", handleMouseLeave); + }; + }, [isReady]); + + // Re-render when switching scroll modes + useEffect(() => { + if (!isReady) return; + if (scrollMode === "continuous") { + // small delay so DOM mounts the continuous page elements first + setTimeout(() => renderAllPages(), 50); + } else { + // Dispose continuous fabric instances + pageFabricRefs.current.forEach(fc => { try { fc?.dispose(); } catch { /* ignore */ } }); + pageFabricRefs.current = []; + renderPageWithAnnotations(currentPage); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scrollMode]); + + // Scroll-based page tracking for continuous mode + useEffect(() => { + if (!isReady || scrollMode !== "continuous") return; + const container = scrollContainerRef.current; + if (!container) return; + + const updateCurrentPage = () => { + const containerRect = container.getBoundingClientRect(); + let bestPage = 1; + let bestOverlap = -1; + pageCanvasRefs.current.forEach((cvs, idx) => { + if (!cvs || cvs.height === 0) return; + const rect = cvs.getBoundingClientRect(); + const top = Math.max(rect.top, containerRect.top); + const bottom = Math.min(rect.bottom, containerRect.bottom); + const overlap = Math.max(0, bottom - top); + if (overlap > bestOverlap) { + bestOverlap = overlap; + bestPage = idx + 1; + } + }); + if (bestOverlap > 0) setCurrentPage(bestPage); + }; + + container.addEventListener("scroll", updateCurrentPage, { passive: true }); + return () => container.removeEventListener("scroll", updateCurrentPage); + }, [isReady, scrollMode]); // ───────────────────────────────────────────────────────────────────────── // Apply tool / mode to Fabric canvas @@ -349,6 +986,8 @@ export default function WorkbookViewer({ pdfId }: Props) { fontFamily: "Arial, sans-serif", padding: 4, }); + textObj._owner_id = currentUserIdRef.current; + textObj._owner_username = currentUsernameRef.current; fc.add(textObj); fc.setActiveObject(textObj); textObj.enterEditing(); @@ -356,25 +995,104 @@ export default function WorkbookViewer({ pdfId }: Props) { fc.renderAll(); }); break; + + case "eraser": + fc.isDrawingMode = false; + fc.selection = false; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fc.on("mouse:down", (options: any) => { + if (!options.target) return; + const obj = options.target as any; + const ownerId: number | undefined = obj._owner_id; + const uid = currentUserIdRef.current; + const role = userRoleRef.current; + // Students can only erase their own annotations + if (role === "student" && (obj._isRemote || (ownerId != null && ownerId !== uid))) return; + const collab_id = obj.collab_id as string | undefined; + fc.remove(obj); + addedObjects.current = addedObjects.current.filter(o => o !== obj); + fc.renderAll(); + if (collab_id) collabSendRef.current?.objectRemove(collab_id, currentPageRef.current); + }); + break; } }, [isReady, mode, tool, penColor, strokeWidth]); + // Apply tool/mode to continuous-mode per-page fabric canvases too + useEffect(() => { + if (!isReady || scrollMode !== "continuous") return; + const fabric = fabricNSRef.current; + if (!fabric) return; + pageFabricRefs.current.forEach((fc) => { + if (!fc) return; + fc.off("mouse:down"); + if (mode === "pan") { + fc.isDrawingMode = false; + fc.selection = false; + if (fc.wrapperEl) fc.wrapperEl.style.pointerEvents = "none"; + return; + } + if (fc.wrapperEl) fc.wrapperEl.style.pointerEvents = "all"; + switch (tool) { + case "select": fc.isDrawingMode = false; fc.selection = true; break; + case "pen": { + fc.isDrawingMode = true; fc.selection = false; + const b = new fabric.PencilBrush(fc); b.color = penColor; b.width = strokeWidth; fc.freeDrawingBrush = b; break; + } + case "highlighter": { + fc.isDrawingMode = true; fc.selection = false; + const hb = new fabric.PencilBrush(fc); hb.color = hexToRgba(penColor, 0.99); hb.width = strokeWidth * 7; + (hb as any).strokeLineCap = "square"; fc.freeDrawingBrush = hb; break; + } + case "text": + fc.isDrawingMode = false; fc.selection = false; + fc.on("mouse:down", (options: any) => { + if (options.target) return; + const pointer = fc.getPointer(options.e); + const t = new fabric.IText("Text", { left: pointer.x, top: pointer.y, fontSize: 20, fill: penColorRef.current, fontFamily: "Arial, sans-serif", padding: 4 }); + fc.add(t); fc.setActiveObject(t); t.enterEditing(); t.selectAll(); fc.renderAll(); + }); break; + case "eraser": + fc.isDrawingMode = false; fc.selection = false; + fc.on("mouse:down", (options: any) => { + if (!options.target) return; + const obj = options.target as any; + const ownerId: number | undefined = obj._owner_id; + const uid = currentUserIdRef.current; + const role = userRoleRef.current; + // Students can only erase their own annotations + if (role === "student" && (obj._isRemote || (ownerId != null && ownerId !== uid))) return; + const collab_id = obj.collab_id as string | undefined; + fc.remove(obj); fc.renderAll(); + if (collab_id) collabSendRef.current?.objectRemove(collab_id, currentPageRef.current); + }); break; + } + }); + }, [isReady, scrollMode, mode, tool, penColor, strokeWidth]); + // ───────────────────────────────────────────────────────────────────────── - // Page navigation - // ───────────────────────────────────────────────────────────────────────── + // goToPage — in continuous mode, scroll to the page element const goToPage = useCallback( async (newPage: number) => { + if (newPage < 1 || newPage > totalPages || rendering) return; + + if (scrollMode === "continuous") { + // Scroll the page wrapper into view + const el = pageCanvasRefs.current[newPage - 1]?.parentElement; + el?.scrollIntoView({ behavior: "smooth", block: "start" }); + setCurrentPage(newPage); + return; + } + const fc = fabricRef.current; - if (!fc || newPage < 1 || newPage > totalPages || rendering) return; - - // Cache current page before leaving - localAnnotations.current[currentPage] = fc.toJSON(); - + if (!fc) return; + // Preserve _owner_id so remote-object detection works on cache hits + localAnnotations.current[currentPage] = fc.toJSON(["_owner_id", "_owner_username"]); await renderPageWithAnnotations(newPage); setCurrentPage(newPage); }, - [currentPage, totalPages, rendering, renderPageWithAnnotations] + [currentPage, totalPages, rendering, scrollMode, renderPageWithAnnotations] ); // ───────────────────────────────────────────────────────────────────────── @@ -382,15 +1100,29 @@ export default function WorkbookViewer({ pdfId }: Props) { // ───────────────────────────────────────────────────────────────────────── const handleSave = useCallback(async () => { - const fc = fabricRef.current; + const fc = scrollMode === "continuous" + ? pageFabricRefs.current[currentPage - 1] + : fabricRef.current; if (!fc || !isReady || saving) return; setSaving(true); - const canvasData = fc.toJSON(); + // Include _owner_id and _owner_username in serialization + const fullCanvasData = fc.toJSON(["_owner_id", "_owner_username"]); + // Only save objects that belong to the current user (no _owner_id = own; _owner_id === uid = own) + const uid = currentUserIdRef.current; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ownObjects = (fullCanvasData.objects as any[] ?? []).filter( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (o: any) => o._owner_id == null || o._owner_id === uid + ); + const canvasData = { ...fullCanvasData, objects: ownObjects }; try { await api.upsertAnnotation(pdfId, currentPage, { canvas_data: canvasData }); - localAnnotations.current[currentPage] = canvasData; + // Data is now in DB — cancel any pending debounce (backend deletes Redis key too) + if (tempSyncTimerRef.current) { clearTimeout(tempSyncTimerRef.current); tempSyncTimerRef.current = null; } + // Cache the full display state (including remote objects) so the page looks correct on revisit + localAnnotations.current[currentPage] = fullCanvasData; setSaveNotice(true); setTimeout(() => setSaveNotice(false), 2500); } catch (err: unknown) { @@ -414,10 +1146,33 @@ export default function WorkbookViewer({ pdfId }: Props) { const handleClear = useCallback(() => { const fc = fabricRef.current; if (!fc) return; - if (!confirm("Clear all annotations on this page?")) return; - fc.clear(); - addedObjects.current = []; - fc.renderAll(); + + const role = userRoleRef.current; + const uid = currentUserIdRef.current; + + if (role === "student") { + // Students: only remove their own objects, leave others untouched + if (!confirm("Xoá annotation của bạn trên trang này?")) return; + const toRemove = fc.getObjects().filter((o: any) => !o._isRemote && (o._owner_id == null || o._owner_id === uid)); + toRemove.forEach((o: any) => { + fc.remove(o); + // Broadcast removal so teacher canvas updates in real time + const collab_id = o.collab_id as string | undefined; + if (collab_id) collabSendRef.current?.objectRemove(collab_id, currentPageRef.current); + }); + addedObjects.current = addedObjects.current.filter(o => toRemove.indexOf(o) === -1); + fc.renderAll(); + // Sync own cleared state to Redis + syncTempCanvasRef.current(currentPageRef.current); + } else { + // Teacher / Admin: clear everything + if (!confirm("Xoá tất cả annotation trên trang này?")) return; + fc.clear(); + addedObjects.current = []; + fc.renderAll(); + collabSendRef.current?.clear(currentPageRef.current); + syncTempCanvasRef.current(currentPageRef.current); + } }, []); // ───────────────────────────────────────────────────────────────────────── @@ -441,163 +1196,328 @@ export default function WorkbookViewer({ pdfId }: Props) { } return ( -
+
{/* ── Sticky Toolbar ──────────────────────────────────────────────────── */}
-
+
- {/* Back button + title */} - - - {pdfTitle} - + {/* Scrollable tools strip */} +
-
- - {/* Page navigation */} -
+ {/* Back button + title */} - - {isReady ? `${currentPage} / ${totalPages}` : "…"} + + {pdfTitle} + + {/* Current user badge */} + {currentUsername && ( + + + {currentUsername} + + )} + +
+ + {/* Thumbnail toggle */} -
-
+
- {/* Pan / Draw mode toggle */} -
- - -
- - {/* Drawing tools — only shown in Draw mode */} - {mode === "draw" && ( - <> -
- - {/* Tool buttons */} -
- {/* Select */} - - - - - {/* Pen */} - - - - - {/* Highlighter */} - - - - - {/* Text */} - - - -
- - {/* Color picker */} - setPenColor(e.target.value)} - title="Color" - className="w-7 h-7 rounded cursor-pointer border border-gray-300 p-0.5 bg-white" - /> - - {/* Stroke width slider */} - setStrokeWidth(Number(e.target.value))} - className="w-16 accent-blue-600" - title={`Stroke width: ${strokeWidth}`} - /> - -
- - {/* Undo */} + {/* Fit mode */} +
+ +
+ +
+ + {/* Scroll mode */} +
+ + +
+ +
+ + {/* Page navigation */} +
+ - - {/* Clear page */} + {isReady ? ( + + { + const v = Number(e.target.value); + if (v >= 1 && v <= totalPages) goToPage(v); + }} + onFocus={(e) => e.target.select()} + className="w-10 text-center text-xs border border-gray-300 rounded px-1 py-0.5 focus:outline-none focus:border-blue-500 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + /> + / {totalPages} + + ) : ( + + )} - - )} +
+ +
+ + {/* Pan / Draw mode toggle */} +
+ + +
+ + {/* Drawing tools — inline, visible only in draw mode */} + {mode === "draw" && ( + <> +
+ +
+ + + + + + + + + + + + + + + +
+ + {/* Color picker button */} + + + + + )} +
+ + {/* Save — fixed right, never scrolls away */} +
+ + {/* Collaboration presence */} +
+ + {collabUsers.length > 0 && ( +
+ {collabUsers.slice(0, 5).map(u => { + const isSelf = u.user_id === currentUserIdRef.current; + const canClear = !isSelf && (userRole === "admin" || userRole === "teacher"); + return ( +
+ + {u.username[0]} + + {canClear && ( + + )} +
+ ); + })} + {collabUsers.length > 5 && ( + + +{collabUsers.length - 5} + + )} +
+ )} +
- {/* Save button — far right */} -
{saveNotice && ( - - ✓ Saved - + ✓ Saved )}
+ {/* ── Body: thumbnail sidebar + viewer ─────────────────────────────────── */} +
+ + {/* Thumbnail sidebar */} + {showThumbnails && ( + + )} + {/* ── Viewer area ─────────────────────────────────────────────────────── */}
{/* PDF.js renders here */} - - {/* - Fabric.js is initialised on this element. - After init, Fabric wraps it in a div (wrapperEl) that our useEffect - repositions to position:absolute / top:0 / left:0 — sitting above the PDF. - */}
- {/* Bottom page controls for mobile convenience */} - {isReady && ( -
- - - {currentPage} / {totalPages} - - + {/* Continuous scroll: one wrapper per page */} + {isReady && scrollMode === "continuous" && ( +
+ {Array.from({ length: totalPages }).map((_, idx) => ( +
+ { pageCanvasRefs.current[idx] = el; }} + data-page={idx + 1} + className="block shadow-lg rounded" + /> + +
+ ))}
)} + +
+ + {/* ── Color picker portal \u2014 rendered into document.body to escape overflow:hidden ── */} + {/* Annotation owner tooltip */} + {annotationTooltip && ( +
+ + + + {annotationTooltip.text} +
+ )} + + {colorPickerOpen && colorPickerPos && typeof document !== "undefined" && createPortal( +
+

Chọn màu bút

+
+ {PRESET_COLORS.map((c) => { + const takenByPeer = Object.entries(peerColors) + .filter(([uid]) => Number(uid) !== currentUserIdRef.current) + .some(([, pc]) => pc.toLowerCase() === c.toLowerCase()); + const isSelected = penColor.toLowerCase() === c.toLowerCase(); + return ( +
+
, + document.body + )}
); } diff --git a/frontend/src/hooks/useCollaboration.ts b/frontend/src/hooks/useCollaboration.ts new file mode 100644 index 0000000..df9ab01 --- /dev/null +++ b/frontend/src/hooks/useCollaboration.ts @@ -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: , 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([]); + const [connected, setConnected] = useState(false); + const [peerColors, setPeerColors] = useState>({}); + + const wsRef = useRef(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 | null>(null); + const reconnectTimerRef = useRef | 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>((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) => { + 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 }; +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 9af923b..aa6fce1 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -30,6 +30,8 @@ export const api = { register: (body: { username: string; email: string; password: string }) => request("/api/auth/register", { method: "POST", body: JSON.stringify(body) }), logout: () => request("/api/auth/logout", { method: "POST" }), + changePassword: (body: { current_password: string; new_password: string }) => + request("/api/auth/change-password", { method: "POST", body: JSON.stringify(body) }), // PDFs listPdfs: () => request("/api/pdfs"), @@ -40,9 +42,63 @@ export const api = { // Annotations getAnnotation: (pdfId: number, page: number) => request(`/api/annotations/${pdfId}/${page}`), + getAllAnnotations: (pdfId: number, page: number) => + request(`/api/annotations/${pdfId}/${page}/all`), upsertAnnotation: (pdfId: number, page: number, body: { canvas_data: object }) => request(`/api/annotations/${pdfId}/${page}`, { method: "PUT", body: JSON.stringify(body), }), + upsertTempAnnotation: (pdfId: number, page: number, body: { canvas_data: object }) => + request(`/api/annotations/${pdfId}/${page}/temp`, { + method: "PUT", + body: JSON.stringify(body), + }), + deleteAnnotation: (annotationId: number) => + request(`/api/annotations/${annotationId}`, { method: "DELETE" }), + clearUserAnnotation: (pdfId: number, page: number, userId: number) => + request(`/api/annotations/${pdfId}/${page}/user/${userId}`, { method: "DELETE" }), + + // Admin + adminListUsers: () => + request("/api/admin/users"), + adminUpdateStatus: (userId: number, status: import("@/types").UserStatus) => + request(`/api/admin/users/${userId}/status`, { + method: "PATCH", + body: JSON.stringify({ status }), + }), + adminUpdateRole: (userId: number, role: import("@/types").UserRole) => + request(`/api/admin/users/${userId}/role`, { + method: "PATCH", + body: JSON.stringify({ role }), + }), + adminDeleteUser: (userId: number) => + request(`/api/admin/users/${userId}`, { method: "DELETE" }), + + // Backup / Restore + /** Triggers pg_dump + file pack; returns a Blob for the browser to download. */ + adminDownloadBackup: async (): Promise => { + 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(); + }, }; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 6ee2241..6ec0905 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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; diff --git a/frontend/src/types/modules.d.ts b/frontend/src/types/modules.d.ts new file mode 100644 index 0000000..eb152f0 --- /dev/null +++ b/frontend/src/types/modules.d.ts @@ -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; + }; +} diff --git a/push-ghcr.sh b/push-ghcr.sh new file mode 100755 index 0000000..dfebc77 --- /dev/null +++ b/push-ghcr.sh @@ -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" diff --git a/readme.md b/readme.md index 10fadba..61036b0 100644 --- a/readme.md +++ b/readme.md @@ -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. \ No newline at end of file +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` và `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_TOKEN= +BACKEND_IMAGE=ghcr.io//lms-backend:latest +FRONTEND_IMAGE=ghcr.io//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` và `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/?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 \ No newline at end of file diff --git a/start-dev.sh b/start-dev.sh new file mode 100755 index 0000000..4bd10cf --- /dev/null +++ b/start-dev.sh @@ -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