Hoàn thành chức năng collaborative

This commit is contained in:
2026-04-01 14:58:17 +07:00
parent 89db1c7b5c
commit 9335e56322
13 changed files with 751 additions and 41 deletions
+63 -2
View File
@@ -18,6 +18,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 +35,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 +60,59 @@ 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()
)
if not rows:
return AnnotationOut(
id=0,
pdf_id=pdf_id,
page_number=page_number,
canvas_data={},
updated_at=datetime.now(timezone.utc),
)
# Merge all objects arrays; use canvas metadata (background etc.) from latest record
latest = max(rows, key=lambda r: r.updated_at)
base: dict = dict(latest.canvas_data) if latest.canvas_data else {}
merged_objects: list = []
for row in rows:
for obj in (row.canvas_data or {}).get("objects", []):
# Tag each object with its owner so the frontend can avoid re-saving
# other users' objects under the current user's record (prevents duplicates).
obj_copy = dict(obj)
obj_copy["_owner_id"] = row.user_id
merged_objects.append(obj_copy)
base["objects"] = merged_objects
return AnnotationOut(
id=latest.id,
pdf_id=pdf_id,
page_number=page_number,
canvas_data=base,
updated_at=latest.updated_at,
)
# ── PUT /api/annotations/{pdf_id}/{page_number} ───────────────────────────────
@router.put("/{pdf_id}/{page_number}", response_model=AnnotationOut)
@@ -62,7 +123,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)
+14 -1
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, Response, status
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status
from sqlalchemy.orm import Session
import os
@@ -84,3 +84,16 @@ 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}
+28 -9
View File
@@ -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 ─────────────────────────────────────────────────────
@@ -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():
+172
View File
@@ -0,0 +1,172 @@
"""
WebSocket collaboration endpoint.
URL: ws://.../ws/pdf/{pdf_id}?token=<JWT>
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": <fabric object JSON> }
{ "type": "object_remove", "payload": { "obj_id": "<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"}:
msg.update(user_info)
await room.broadcast(msg, exclude=websocket)
except WebSocketDisconnect:
pass
finally:
await room.leave(websocket)
await manager.cleanup(pdf_id)