mirror of
https://git.victorphan.net/basketballcantho/ten-project.git
synced 2026-08-05 10:43:11 +07:00
hoàn thành bước3.2. PDF Gallery (Dashboard)
This commit is contained in:
+2
-1
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user