hoàn thành bước3.2. PDF Gallery (Dashboard)

This commit is contained in:
2026-03-31 14:26:48 +07:00
parent ebfa869a26
commit 9450cd3ed4
18 changed files with 766 additions and 1 deletions
+2 -1
View File
@@ -4,7 +4,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .database import Base, engine
from .routers import auth
from .routers import auth, pdfs
@asynccontextmanager
@@ -25,3 +25,4 @@ app.add_middleware(
)
app.include_router(auth.router, prefix="/api")
app.include_router(pdfs.router, prefix="/api")
+135
View File
@@ -0,0 +1,135 @@
import os
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from ..database import get_db
from ..dependencies import get_current_user
from ..models import PDF, User
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
def _user_dir(user_id: int) -> Path:
d = UPLOAD_DIR / str(user_id)
d.mkdir(parents=True, exist_ok=True)
return d
def _own_or_404(db: Session, pdf_id: int, user_id: int) -> PDF:
pdf = db.get(PDF, pdf_id)
if not pdf or pdf.user_id != user_id:
raise HTTPException(status_code=404, detail="PDF not found.")
return pdf
# ── GET /api/pdfs ─────────────────────────────────────────────────────────────
@router.get("", response_model=list[PDFOut])
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()
)
# ── POST /api/pdfs/upload ─────────────────────────────────────────────────────
@router.post("/upload", response_model=list[PDFUploadResult], status_code=status.HTTP_201_CREATED)
async def upload_pdfs(
files: list[UploadFile],
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Accept one or more PDF files in a single multipart request."""
results: list[PDFUploadResult] = []
for file in files:
# ── Validate MIME type ────────────────────────────────────────────────
if file.content_type not in ("application/pdf", "application/octet-stream"):
results.append(PDFUploadResult(filename=file.filename or "", success=False, error="Not a PDF file."))
continue
# ── Read & check magic bytes (PDF header: %PDF) ───────────────────────
header = await file.read(5)
if not header.startswith(b"%PDF-"):
results.append(PDFUploadResult(filename=file.filename or "", success=False, error="File is not a valid PDF."))
continue
# ── 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."))
continue
# ── Save to disk with UUID filename (prevents path traversal) ─────────
safe_name = f"{uuid.uuid4()}.pdf"
dest = _user_dir(current_user.id) / safe_name
dest.write_bytes(body)
# ── Persist metadata ──────────────────────────────────────────────────
original_title = Path(file.filename or "Untitled").stem or "Untitled"
pdf = PDF(
user_id=current_user.id,
title=original_title,
file_path=str(dest),
)
db.add(pdf)
db.commit()
db.refresh(pdf)
results.append(PDFUploadResult(filename=file.filename or "", success=True, pdf=PDFOut.model_validate(pdf)))
return results
# ── DELETE /api/pdfs/{pdf_id} ─────────────────────────────────────────────────
@router.delete("/{pdf_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_pdf(
pdf_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
pdf = _own_or_404(db, pdf_id, current_user.id)
file_path = Path(pdf.file_path)
db.delete(pdf)
db.commit()
if file_path.exists():
file_path.unlink()
# ── GET /api/pdfs/{pdf_id}/file ───────────────────────────────────────────────
@router.get("/{pdf_id}/file")
def serve_pdf(
pdf_id: int,
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)
file_path = Path(pdf.file_path)
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found on disk.")
return FileResponse(
path=str(file_path),
media_type="application/pdf",
filename=f"{pdf.title}.pdf",
)
+18
View File
@@ -45,3 +45,21 @@ class UserOut(BaseModel):
class TokenPayload(BaseModel):
sub: int # user id
exp: int
# ── PDFs ──────────────────────────────────────────────────────────────────────
class PDFOut(BaseModel):
id: int
title: str
total_pages: int | None
created_at: datetime
model_config = {"from_attributes": True}
class PDFUploadResult(BaseModel):
filename: str
success: bool
pdf: PDFOut | None = None
error: str | None = None