Lobby: WebSockets for real-time sync
Server pushes small change notes (messages / lobby / sheet) per lobby; clients fetch data through the normal API so visibility rules stay in one place. Polling stays as slow fallback. nginx WebSocket headers documented. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
+78
-2
@@ -7,13 +7,15 @@ import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, FastAPI, HTTPException, Request, Response
|
||||
import asyncio
|
||||
from fastapi import APIRouter, Body, Depends, FastAPI, HTTPException, Request, Response, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from . import auth
|
||||
from .derive import compute
|
||||
from . import rules_edit
|
||||
from .realtime import hub
|
||||
from .export import to_docx, to_pdf
|
||||
|
||||
BASE = Path(__file__).resolve().parent.parent
|
||||
@@ -38,6 +40,11 @@ CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O/1/I
|
||||
# No public API docs: everything about the API stays behind the login
|
||||
app = FastAPI(title="Arcane Wasteland Character Creator", docs_url=None, redoc_url=None, openapi_url=None)
|
||||
limiter = auth.Limiter()
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _attach_hub():
|
||||
hub.attach(asyncio.get_running_loop())
|
||||
TRUST_PROXY = os.environ.get("AW_TRUST_PROXY", "") == "1" # behind a reverse proxy that sets X-Forwarded-For / -Proto
|
||||
|
||||
|
||||
@@ -167,8 +174,10 @@ async def security(request: Request, call_next):
|
||||
resp.headers["X-Content-Type-Options"] = "nosniff"
|
||||
resp.headers["X-Frame-Options"] = "DENY"
|
||||
resp.headers["Referrer-Policy"] = "same-origin"
|
||||
host = request.headers.get("x-forwarded-host", "") if TRUST_PROXY else ""
|
||||
host = re.sub(r"[^A-Za-z0-9.:\-\[\]]", "", host or request.headers.get("host", ""))
|
||||
resp.headers["Content-Security-Policy"] = ("default-src 'self'; style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data:; frame-ancestors 'none'")
|
||||
f"img-src 'self' data: blob:; connect-src 'self' ws://{host} wss://{host}; frame-ancestors 'none'")
|
||||
if request.url.path.startswith("/api/"):
|
||||
resp.headers["Cache-Control"] = "no-store"
|
||||
return resp
|
||||
@@ -428,6 +437,9 @@ def update_character(cid: str, ch: dict = Body(...), user: dict = Depends(curren
|
||||
with db() as con:
|
||||
_owned(con, "characters", cid, user)
|
||||
con.execute("UPDATE characters SET data=?, updated=? WHERE id=?", (json.dumps(ch), time.time(), cid))
|
||||
lobbies = [r["lobby_id"] for r in con.execute("SELECT lobby_id FROM lobby_members WHERE character_id=?", (cid,))]
|
||||
for lid in lobbies:
|
||||
hub.notify(lid, "sheet", cid=cid)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -501,6 +513,7 @@ def _event(con, lid: str, text: str, uid=None):
|
||||
"""Writes a line into the lobby's event log (shown in the chat feed to everybody)."""
|
||||
con.execute("INSERT INTO lobby_messages (lobby_id, user_id, kind, text, created) VALUES (?,?, 'event', ?, ?)",
|
||||
(lid, uid, text, time.time()))
|
||||
hub.notify(lid, "messages")
|
||||
|
||||
|
||||
def _char_name(con, cid) -> str:
|
||||
@@ -612,6 +625,7 @@ def set_difficulty(lid: str, body: dict = Body(...), user: dict = Depends(curren
|
||||
with db() as con:
|
||||
_dm_only(_lobby_for(con, lid, user), user)
|
||||
con.execute("UPDATE lobbies SET diff_mod=?, diff_label=?, diff_hidden=? WHERE id=?", (mod, label, 1 if body.get("hidden") else 0, lid))
|
||||
hub.notify(lid, "lobby")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -630,6 +644,7 @@ def set_member_mod(lid: str, username: str, body: dict = Body(...), user: dict =
|
||||
(mod, lid, username))
|
||||
if not cur.rowcount:
|
||||
raise HTTPException(404, "No such player in this lobby")
|
||||
hub.notify(lid, "lobby")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -701,6 +716,7 @@ def send_message(lid: str, body: dict = Body(...), user: dict = Depends(current_
|
||||
(lid, user["id"], to_id, "whisper" if to_id else "chat", text, time.time()))
|
||||
con.execute("""DELETE FROM lobby_messages WHERE lobby_id=? AND id <= COALESCE(
|
||||
(SELECT id FROM lobby_messages WHERE lobby_id=? ORDER BY id DESC LIMIT 1 OFFSET ?), 0)""", (lid, lid, KEEP_MESSAGES))
|
||||
hub.notify(lid, "messages", None if to_id is None else {user["id"], to_id})
|
||||
return {"id": cur.lastrowid}
|
||||
|
||||
|
||||
@@ -726,6 +742,7 @@ def _post(con, lid: str, uid: int, to_id, kind: str, payload: dict) -> int:
|
||||
(lid, uid, to_id, kind, json.dumps(payload), time.time()))
|
||||
con.execute("""DELETE FROM lobby_messages WHERE lobby_id=? AND id <= COALESCE(
|
||||
(SELECT id FROM lobby_messages WHERE lobby_id=? ORDER BY id DESC LIMIT 1 OFFSET ?), 0)""", (lid, lid, KEEP_MESSAGES))
|
||||
hub.notify(lid, "messages", None if to_id is None else {uid, to_id})
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
@@ -820,6 +837,7 @@ def delete_lobby(lid: str, user: dict = Depends(current_user)):
|
||||
con.execute("DELETE FROM lobby_members WHERE lobby_id=?", (lid,))
|
||||
con.execute("DELETE FROM lobby_messages WHERE lobby_id=?", (lid,))
|
||||
con.execute("DELETE FROM lobbies WHERE id=?", (lid,))
|
||||
hub.close_lobby(lid)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -850,6 +868,7 @@ def update_my_membership(lid: str, body: dict = Body(...), user: dict = Depends(
|
||||
if con.execute("SELECT 1 FROM lobby_members WHERE lobby_id=? AND color=? AND user_id!=?", (lid, color, user["id"])).fetchone():
|
||||
raise HTTPException(409, "Another player already uses this colour")
|
||||
con.execute("UPDATE lobby_members SET color=? WHERE lobby_id=? AND user_id=?", (color, lid, user["id"]))
|
||||
hub.notify(lid, "lobby")
|
||||
if "character_id" in body:
|
||||
_own_character(con, body.get("character_id"), user)
|
||||
con.execute("UPDATE lobby_members SET character_id=? WHERE lobby_id=? AND user_id=?", (body["character_id"], lid, user["id"]))
|
||||
@@ -864,6 +883,8 @@ def leave_lobby(lid: str, user: dict = Depends(current_user)):
|
||||
gone = con.execute("DELETE FROM lobby_members WHERE lobby_id=? AND user_id=?", (lid, user["id"])).rowcount
|
||||
if gone:
|
||||
_event(con, lid, f"{user['username']} left the lobby", user["id"])
|
||||
if gone:
|
||||
hub.close_user(lid, user["id"])
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -875,6 +896,9 @@ def kick_member(lid: str, username: str, user: dict = Depends(current_user)):
|
||||
(lid, username)).rowcount
|
||||
if gone:
|
||||
_event(con, lid, f"{username} was removed by the DM", user["id"])
|
||||
gone_id = con.execute("SELECT id FROM users WHERE username=?", (username,)).fetchone()["id"]
|
||||
if gone:
|
||||
hub.close_user(lid, gone_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -987,6 +1011,58 @@ def editor_reset(section: str, confirm: bool = False, user: dict = Depends(admin
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.websocket("/api/lobbies/{lid}/ws")
|
||||
async def lobby_socket(websocket: WebSocket, lid: str):
|
||||
"""One socket per open lobby page. Only notes go out ("messages", "lobby", "sheet"), the data is fetched through the API."""
|
||||
origin = websocket.headers.get("origin")
|
||||
hosts = {websocket.headers.get("host", "")}
|
||||
if TRUST_PROXY:
|
||||
hosts.add(websocket.headers.get("x-forwarded-host", ""))
|
||||
if origin and origin.split("://", 1)[-1] not in hosts:
|
||||
await websocket.close(code=4403)
|
||||
return
|
||||
|
||||
def check():
|
||||
with db() as con:
|
||||
user = auth.user_for_token(con, websocket.cookies.get(auth.SESSION_COOKIE))
|
||||
if not user:
|
||||
return None
|
||||
lobby = con.execute("SELECT owner_id FROM lobbies WHERE id=?", (lid,)).fetchone()
|
||||
ok = lobby and (lobby["owner_id"] == user["id"] or con.execute(
|
||||
"SELECT 1 FROM lobby_members WHERE lobby_id=? AND user_id=?", (lid, user["id"])).fetchone())
|
||||
return user if ok else None
|
||||
|
||||
user = await asyncio.to_thread(check)
|
||||
if not user:
|
||||
await websocket.close(code=4401)
|
||||
return
|
||||
await websocket.accept()
|
||||
if not hub.add(lid, websocket, user["id"]):
|
||||
await websocket.close(code=4429)
|
||||
return
|
||||
try:
|
||||
last_check = time.time()
|
||||
while True:
|
||||
try:
|
||||
text = await asyncio.wait_for(websocket.receive_text(), timeout=70)
|
||||
except asyncio.TimeoutError:
|
||||
break # the client pings every 25 seconds, silence means it is gone
|
||||
if text == "ping":
|
||||
await websocket.send_text("pong")
|
||||
if time.time() - last_check > 300: # session or membership may have ended
|
||||
last_check = time.time()
|
||||
if not await asyncio.to_thread(check):
|
||||
break
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
hub.remove(lid, websocket)
|
||||
try:
|
||||
await websocket.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
app.include_router(api)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user