From 22d109647e46172bc86568628d236121d6e9a7cb Mon Sep 17 00:00:00 2001 From: ameer Date: Sat, 2 May 2026 22:55:03 +0800 Subject: [PATCH] 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. --- app/auth.py | 11 ++++++++++- app/room.py | 10 +++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/app/auth.py b/app/auth.py index f09636d..8c8353b 100644 --- a/app/auth.py +++ b/app/auth.py @@ -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: diff --git a/app/room.py b/app/room.py index e4bf249..0f2863a 100644 --- a/app/room.py +++ b/app/room.py @@ -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: