From ebfa869a26a25dc0f4d5a87ea39ed74faa83d922 Mon Sep 17 00:00:00 2001 From: hienp Date: Tue, 31 Mar 2026 14:15:32 +0700 Subject: [PATCH] =?UTF-8?q?ho=C3=A0n=20th=C3=A0nh=20ph=E1=BA=A7n=20Authent?= =?UTF-8?q?ication?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/__init__.py | 0 backend/app/database.py | 23 +++++++++ backend/app/dependencies.py | 36 ++++++++++++++ backend/app/main.py | 27 +++++++++++ backend/app/models.py | 66 ++++++++++++++++++++++++++ backend/app/routers/__init__.py | 0 backend/app/routers/auth.py | 84 +++++++++++++++++++++++++++++++++ backend/app/schemas.py | 47 ++++++++++++++++++ backend/app/security.py | 43 +++++++++++++++++ backend/main.py | 1 + backend/requirements.txt | 8 ++++ readme.md | 54 +++++++++++++++++++++ 12 files changed, 389 insertions(+) create mode 100644 backend/app/__init__.py create mode 100644 backend/app/database.py create mode 100644 backend/app/dependencies.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models.py create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/auth.py create mode 100644 backend/app/schemas.py create mode 100644 backend/app/security.py create mode 100644 backend/main.py create mode 100644 backend/requirements.txt create mode 100644 readme.md diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..e06dc0b --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,23 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, sessionmaker +import os + +DATABASE_URL = os.getenv( + "DATABASE_URL", + "postgresql+psycopg2://lms_user:lms_password@db:5432/lms_db", +) + +engine = create_engine(DATABASE_URL, pool_pre_ping=True) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +class Base(DeclarativeBase): + pass + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py new file mode 100644 index 0000000..431de48 --- /dev/null +++ b/backend/app/dependencies.py @@ -0,0 +1,36 @@ +from fastapi import Cookie, Depends, HTTPException, status +from jose import JWTError +from sqlalchemy.orm import Session + +from .database import get_db +from .models import User +from .security import decode_access_token + + +def get_current_user( + access_token: str | None = Cookie(default=None), + db: Session = Depends(get_db), +) -> User: + """ + Reads the JWT from the HttpOnly `access_token` cookie, + validates it, and returns the authenticated User ORM object. + Raises 401 on any failure. + """ + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated.", + ) + + if access_token is None: + raise credentials_exception + + try: + user_id = decode_access_token(access_token) + except JWTError: + raise credentials_exception + + user = db.get(User, user_id) + if user is None: + raise credentials_exception + + return user diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..31dd37a --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,27 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from .database import Base, engine +from .routers import auth + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Create all tables on startup (use Alembic for migrations in production) + Base.metadata.create_all(bind=engine) + yield + + +app = FastAPI(title="LMS API", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:3000"], # Next.js dev server + allow_credentials=True, # required for cookies + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(auth.router, prefix="/api") diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..ba274ce --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,66 @@ +from datetime import datetime, timezone +from sqlalchemy import ( + Column, + Integer, + String, + Text, + ForeignKey, + DateTime, + UniqueConstraint, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import relationship +from .database import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + 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) + created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + + # Relationships + pdfs = relationship("PDF", back_populates="owner", cascade="all, delete-orphan") + annotations = relationship("Annotation", back_populates="owner", cascade="all, delete-orphan") + + +class PDF(Base): + __tablename__ = "pdfs" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + title = Column(String(255), nullable=False) + file_path = Column(Text, nullable=False) # path inside Docker volume + total_pages = Column(Integer, nullable=True) # populated after upload processing + created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + + # Relationships + owner = relationship("User", back_populates="pdfs") + annotations = relationship("Annotation", back_populates="pdf", cascade="all, delete-orphan") + + +class Annotation(Base): + __tablename__ = "annotations" + __table_args__ = ( + # One annotation record per (pdf, user, page) — upsert target + UniqueConstraint("pdf_id", "user_id", "page_number", name="uq_annotation_pdf_user_page"), + ) + + id = Column(Integer, primary_key=True, index=True) + pdf_id = Column(Integer, ForeignKey("pdfs.id", ondelete="CASCADE"), nullable=False, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + page_number = Column(Integer, nullable=False) # 1-based + canvas_data = Column(JSONB, nullable=False, default=dict) # Fabric.js JSON state + updated_at = Column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + nullable=False, + ) + + # Relationships + pdf = relationship("PDF", back_populates="annotations") + owner = relationship("User", back_populates="annotations") diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..e660f2e --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,84 @@ +from fastapi import APIRouter, Depends, HTTPException, Response, status +from sqlalchemy.orm import Session + +from ..database import get_db +from ..dependencies import get_current_user +from ..models import User +from ..schemas import UserLogin, UserOut, UserRegister +from ..security import ( + ACCESS_TOKEN_EXPIRE_MINUTES, + create_access_token, + hash_password, + verify_password, +) + +router = APIRouter(prefix="/auth", tags=["auth"]) + +_COOKIE_NAME = "access_token" +_COOKIE_MAX_AGE = ACCESS_TOKEN_EXPIRE_MINUTES * 60 # seconds + + +def _set_auth_cookie(response: Response, user_id: int) -> None: + token = create_access_token(user_id) + response.set_cookie( + key=_COOKIE_NAME, + value=token, + httponly=True, + secure=True, # send only over HTTPS (Nginx handles TLS in prod) + samesite="lax", + max_age=_COOKIE_MAX_AGE, + path="/", + ) + + +# ── POST /auth/register ─────────────────────────────────────────────────────── + +@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED) +def register(body: UserRegister, response: Response, db: Session = Depends(get_db)): + if db.query(User).filter(User.username == body.username).first(): + raise HTTPException(status_code=400, detail="Username already taken.") + if db.query(User).filter(User.email == body.email).first(): + raise HTTPException(status_code=400, detail="Email already registered.") + + user = User( + username=body.username, + email=body.email, + password_hash=hash_password(body.password), + ) + db.add(user) + db.commit() + db.refresh(user) + + _set_auth_cookie(response, user.id) + return user + + +# ── POST /auth/login ────────────────────────────────────────────────────────── + +@router.post("/login", response_model=UserOut) +def login(body: UserLogin, response: Response, db: Session = Depends(get_db)): + user: User | None = db.query(User).filter(User.username == body.username).first() + + # Constant-time: always verify even when user is None to prevent timing attacks + dummy_hash = "$2b$12$KIXyb/r7MBJ3z32Dc3y8YuNfj7DhpNcaYgU2q8uGiHjJ6YFe6RVxm" + password_ok = verify_password(body.password, user.password_hash if user else dummy_hash) + + if not user or not password_ok: + raise HTTPException(status_code=401, detail="Invalid username or password.") + + _set_auth_cookie(response, user.id) + return user + + +# ── POST /auth/logout ───────────────────────────────────────────────────────── + +@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) +def logout(response: Response): + response.delete_cookie(key=_COOKIE_NAME, path="/") + + +# ── GET /auth/me ────────────────────────────────────────────────────────────── + +@router.get("/me", response_model=UserOut) +def me(current_user: User = Depends(get_current_user)): + return current_user diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..55dea29 --- /dev/null +++ b/backend/app/schemas.py @@ -0,0 +1,47 @@ +from datetime import datetime +from pydantic import BaseModel, EmailStr, field_validator +import re + + +# ── Auth ───────────────────────────────────────────────────────────────────── + +class UserRegister(BaseModel): + username: str + email: EmailStr + password: str + + @field_validator("username") + @classmethod + def username_valid(cls, v: str) -> str: + v = v.strip() + if not re.match(r"^[A-Za-z0-9_]{3,64}$", v): + raise ValueError( + "Username must be 3–64 characters, letters/numbers/underscore only." + ) + return v + + @field_validator("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 UserLogin(BaseModel): + username: str + password: str + + +class UserOut(BaseModel): + id: int + username: str + email: str + created_at: datetime + + model_config = {"from_attributes": True} + + +class TokenPayload(BaseModel): + sub: int # user id + exp: int diff --git a/backend/app/security.py b/backend/app/security.py new file mode 100644 index 0000000..988600e --- /dev/null +++ b/backend/app/security.py @@ -0,0 +1,43 @@ +from datetime import datetime, timedelta, timezone +import os + +from jose import JWTError, jwt +from passlib.context import CryptContext + +# ── Config (override via environment variables) ─────────────────────────────── +SECRET_KEY: str = os.environ["JWT_SECRET_KEY"] # must be set — no default +ALGORITHM: str = os.getenv("JWT_ALGORITHM", "HS256") +ACCESS_TOKEN_EXPIRE_MINUTES: int = int( + os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60") +) + +# ── Password hashing ────────────────────────────────────────────────────────── +_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def hash_password(plain: str) -> str: + return _pwd_context.hash(plain) + + +def verify_password(plain: str, hashed: str) -> bool: + return _pwd_context.verify(plain, hashed) + + +# ── JWT ─────────────────────────────────────────────────────────────────────── + +def create_access_token(user_id: int) -> str: + expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + payload = {"sub": str(user_id), "exp": expire} + return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) + + +def decode_access_token(token: str) -> int: + """ + Decode the JWT and return the user_id (int). + Raises JWTError on any validation failure. + """ + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + sub = payload.get("sub") + if sub is None: + raise JWTError("Missing subject claim.") + return int(sub) diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..7436e63 --- /dev/null +++ b/backend/main.py @@ -0,0 +1 @@ +from app.main import app diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..2518b9f --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.115.6 +uvicorn[standard]==0.30.6 +sqlalchemy==2.0.36 +psycopg2-binary==2.9.10 +passlib[bcrypt]==1.7.4 +python-jose[cryptography]==3.3.0 +pydantic[email]==2.9.2 +python-multipart==0.0.12 diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..10fadba --- /dev/null +++ b/readme.md @@ -0,0 +1,54 @@ +# Project Specification: Personal PDF Learning Management System (LMS) + +## 1. Project Overview +A web application that allows users to securely log in, upload PDF files to a personal gallery, and interact with those PDFs in a dedicated "Workbook" interface. The core feature is the ability to draw, highlight, and add text directly onto the PDF pages from any device (Desktop & Mobile), saving these annotations for future review. + +## 2. Tech Stack Definition +Please use the following technologies for this project: +* **Frontend:** React.js (or Next.js) + Tailwind CSS. +* **PDF Rendering:** PDF.js (Mozilla) to render PDF pages to canvas/DOM. +* **Annotation Layer:** Fabric.js (overlaying the PDF to handle drawing, shapes, and text). +* **Backend:** FastAPI (Python) OR Express (Node.js) - *Copilot: please ask me which one to start with.* +* **Database:** PostgreSQL (using Prisma or SQLAlchemy for ORM). +* **Authentication:** JWT (JSON Web Tokens) with HttpOnly cookies. +* **Storage:** Local file system (Docker volume) for storing uploaded PDFs. + +## 3. Core Features & Requirements + +### 3.1. Authentication (JWT) +* User Registration and Login. +* Protect all API routes. +* Each user can only see and interact with their own uploaded PDFs. + +### 3.2. PDF Gallery (Dashboard) +* **Upload:** Multi-file drag-and-drop upload. +* **Display:** Responsive CSS Grid displaying uploaded PDFs as cards. Include the document title and upload date. +* **Action:** Clicking a card opens the PDF in the "Workbook" view. + +### 3.3. Interactive Workbook (The Core Viewer) +* **Responsive UI:** Must work seamlessly on mobile and desktop. +* **PDF Viewer:** Render the selected PDF file. Support pagination (Next/Prev page). +* **Annotation Tools (Fabric.js):** + * Pen tool (freehand drawing/circling). + * Highlighter tool (semi-transparent freehand or straight line). + * Text tool (click to type text). +* **Mobile Touch UX (Crucial):** Implement a toggle switch between two modes: + 1. *Pan Mode:* Touch and swipe will scroll/zoom the page. Canvas drawing is disabled. + 2. *Draw/Edit Mode:* Scrolling is locked. Touch events are passed to Fabric.js for drawing/annotating. +* **Save Mechanism:** A "Save" button that serializes the current Fabric.js canvas state into JSON and POSTs it to the backend. +* **Load Mechanism:** When opening a PDF, fetch the saved JSON annotation data and load it onto the Fabric.js canvas over the PDF. + +## 4. Database Schema (Draft) + +* `users`: id, username, password_hash, created_at +* `pdfs`: id, user_id (FK), title, file_path, created_at +* `annotations`: id, pdf_id (FK), user_id (FK), page_number, canvas_data (JSON), updated_at + +## 5. Deployment strategy +* Provide a `docker-compose.yml` to spin up the Frontend, Backend, and PostgreSQL database together. + +## 6. Instructions for Copilot +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