fix(auth+room): bytes-encode password compare; replay reconnect snapshot

Two issues from the first user test pass:

1. POST /admin/login was 500'ing on any password attempt that contained
   non-ASCII characters (e.g. a smart-quote autofill from the browser
   password manager). secrets.compare_digest(str, str) requires both
   sides to be bytes or ASCII-only str; otherwise it raises TypeError.
   Encoding both sides to UTF-8 bytes before the constant-time compare
   makes the route degrade cleanly to 401 instead of 500.

2. Reconnecting an instructor while the session is in question_closed
   left the dashboard stuck on "Reveal pending..." because
   send_instructor_snapshot only replayed state + lobby_update +
   full_leaderboard for closed sessions, not the question_open and
   question_closed payloads needed to render the reveal card.
   Now we replay question_open + question_closed + full_leaderboard
   for the question_closed branch, so the SPA renders the full reveal
   immediately on reconnect without waiting for the next event.
This commit is contained in:
ameer
2026-05-02 22:55:03 +08:00
parent cfbda260fa
commit 22d109647e
2 changed files with 19 additions and 2 deletions

View File

@@ -80,7 +80,16 @@ def is_admin_ws(settings: Settings, websocket: WebSocket) -> bool:
def verify_admin_password(settings: Settings, password: str) -> bool:
if not settings.admin_password:
return False
return secrets.compare_digest(password, settings.admin_password)
# Encode to bytes before constant-time compare. Without this,
# secrets.compare_digest(str, str) raises TypeError if either side
# contains non-ASCII (e.g., a smart-quote autofill from the browser
# password manager) and the route would 500 instead of 401.
try:
pw = password.encode("utf-8") if isinstance(password, str) else password
stored = settings.admin_password.encode("utf-8") if isinstance(settings.admin_password, str) else settings.admin_password
except (AttributeError, UnicodeEncodeError):
return False
return secrets.compare_digest(pw, stored)
def set_student_cookie(settings: Settings, response: Response, value: str) -> None:

View File

@@ -279,10 +279,18 @@ class RoomManager:
}
)
await websocket.send_json(await self.lobby_message(sid))
# When an instructor reconnects mid-session, replay enough payloads
# for the SPA to render the current state without waiting for the
# next event. Otherwise the dashboard sits on a "Reveal pending..."
# placeholder forever.
if session["state"] == "question_open":
await websocket.send_json(await self.question_open_message(sid, session["current_question_idx"]))
await websocket.send_json(await self.live_histogram_message(sid, session["current_question_idx"]))
if session["state"] in {"question_closed", "between_questions", "finished"}:
elif session["state"] == "question_closed":
await websocket.send_json(await self.question_open_message(sid, session["current_question_idx"]))
await websocket.send_json(await self.question_closed_message(sid, session["current_question_idx"]))
await websocket.send_json(await self.full_leaderboard_message(sid))
elif session["state"] in {"between_questions", "finished"}:
await websocket.send_json(await self.full_leaderboard_message(sid))
async def open_question(self, sid: str, question_idx: int, time_limit: int | None = None) -> None: