|
@@ -1,7 +1,10 @@
|
|
|
import json
|
|
import json
|
|
|
|
|
+import secrets
|
|
|
|
|
+from uuid import UUID
|
|
|
|
|
|
|
|
-from fastapi import APIRouter, Depends, Request
|
|
|
|
|
|
|
+from fastapi import APIRouter, Depends, HTTPException, Request
|
|
|
from fastapi.responses import StreamingResponse
|
|
from fastapi.responses import StreamingResponse
|
|
|
|
|
+from starlette import status
|
|
|
|
|
|
|
|
from app.api import deps
|
|
from app.api import deps
|
|
|
from app.core.events import redis_client
|
|
from app.core.events import redis_client
|
|
@@ -9,38 +12,54 @@ from app.models import OrgRole, User
|
|
|
|
|
|
|
|
router = APIRouter()
|
|
router = APIRouter()
|
|
|
|
|
|
|
|
|
|
+TICKET_TTL_SECONDS = 30
|
|
|
|
|
|
|
|
-@router.get("/project/{project_id}/stream", summary="Real-Time SSE Stream")
|
|
|
|
|
-async def project_stream(
|
|
|
|
|
- project_id: str,
|
|
|
|
|
- client_id: str,
|
|
|
|
|
- request: Request,
|
|
|
|
|
- token: str,
|
|
|
|
|
|
|
+
|
|
|
|
|
+@router.post("/project/{project_id}/stream-ticket")
|
|
|
|
|
+async def issue_stream_ticket(
|
|
|
|
|
+ project_id: UUID,
|
|
|
current_user: User = Depends(
|
|
current_user: User = Depends(
|
|
|
- deps.require_org_role_sse(
|
|
|
|
|
- OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ deps.require_org_role(OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION)
|
|
|
),
|
|
),
|
|
|
):
|
|
):
|
|
|
|
|
+ ticket = secrets.token_urlsafe(32)
|
|
|
|
|
+ key = f"sse_ticket:{ticket}"
|
|
|
|
|
+ await redis_client.set(key, str(project_id), ex=TICKET_TTL_SECONDS)
|
|
|
|
|
+ return {"ticket": ticket}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _consume_ticket(ticket: str) -> str:
|
|
|
|
|
+ key = f"sse_ticket:{ticket}"
|
|
|
|
|
+ user_id = await redis_client.getdel(key)
|
|
|
|
|
+ if not user_id:
|
|
|
|
|
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid or expired ticket")
|
|
|
|
|
+ return user_id
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@router.get("/project/{project_id}/stream", summary="Real-Time SSE Stream")
|
|
|
|
|
+async def project_stream(project_id: str, client_id: str, ticket: str, request: Request):
|
|
|
"""
|
|
"""
|
|
|
Connect to this endpoint to receive real-time updates.
|
|
Connect to this endpoint to receive real-time updates.
|
|
|
|
|
|
|
|
**Required Query Parameters:**
|
|
**Required Query Parameters:**
|
|
|
- `client_id`: A unique UUID generated by the frontend to prevent echoing.
|
|
- `client_id`: A unique UUID generated by the frontend to prevent echoing.
|
|
|
|
|
+ - `ticket`: A unique ticket generated by the issue_stream_ticket endpoint.
|
|
|
"""
|
|
"""
|
|
|
- print(f"create event generator for {client_id}")
|
|
|
|
|
|
|
+ ticket_project_id = await _consume_ticket(ticket)
|
|
|
|
|
+ if ticket_project_id != project_id:
|
|
|
|
|
+ raise HTTPException(
|
|
|
|
|
+ status.HTTP_403_FORBIDDEN, "Insufficient permissions for this organization"
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
async def event_generator():
|
|
async def event_generator():
|
|
|
channel = f"project_{project_id}"
|
|
channel = f"project_{project_id}"
|
|
|
pubsub = redis_client.pubsub()
|
|
pubsub = redis_client.pubsub()
|
|
|
await pubsub.subscribe(channel)
|
|
await pubsub.subscribe(channel)
|
|
|
- print(f"subscribed to {channel}")
|
|
|
|
|
try:
|
|
try:
|
|
|
async for event in pubsub.listen():
|
|
async for event in pubsub.listen():
|
|
|
if event["type"] != "message":
|
|
if event["type"] != "message":
|
|
|
continue
|
|
continue
|
|
|
if await request.is_disconnected():
|
|
if await request.is_disconnected():
|
|
|
- print("disconnected")
|
|
|
|
|
break
|
|
break
|
|
|
message = json.loads(event["data"])
|
|
message = json.loads(event["data"])
|
|
|
if message.get("client_id") == client_id:
|
|
if message.get("client_id") == client_id:
|
|
@@ -49,8 +68,5 @@ async def project_stream(
|
|
|
finally:
|
|
finally:
|
|
|
await pubsub.unsubscribe(channel)
|
|
await pubsub.unsubscribe(channel)
|
|
|
await pubsub.aclose()
|
|
await pubsub.aclose()
|
|
|
- print(f"unsubscribed from {channel}")
|
|
|
|
|
|
|
|
|
|
- print(f"return event generator for {client_id}")
|
|
|
|
|
- # Return as text/event-stream so the browser knows it's an ongoing connection
|
|
|
|
|
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
|
return StreamingResponse(event_generator(), media_type="text/event-stream")
|