diff --git a/app/auth.py b/app/auth.py index 8c8353b..54235be 100644 --- a/app/auth.py +++ b/app/auth.py @@ -14,7 +14,10 @@ from app.config import Settings STUDENT_COOKIE = "qz_student" ADMIN_COOKIE = "qz_admin" -STUDENT_MAX_AGE = 31_536_000 +# 30 days. Long enough to cover a multi-week course where the same QR +# may be re-used (lecture cadence is once a week), short enough that a +# stolen-device cookie doesn't follow a graduate around for a year. +STUDENT_MAX_AGE = 30 * 86_400 ADMIN_MAX_AGE = 86_400 diff --git a/app/csv_export.py b/app/csv_export.py index cad21fe..47dc458 100644 --- a/app/csv_export.py +++ b/app/csv_export.py @@ -11,7 +11,21 @@ from app.db import connect async def export_session_csv(db_path: str, sid: str) -> str: out = StringIO() writer = csv.writer(out) - writer.writerow(["sid", "student_id", "name", "question_idx", "answer", "elapsed_ms", "score", "status"]) + writer.writerow( + [ + "sid", + "student_id", + "name", + "question_idx", + "answer", + "elapsed_ms", + "score", + "status", + "blur_count", + "hidden_count", + "duplicate_join_attempts", + ] + ) async with connect(db_path) as db: cursor = await db.execute( """ @@ -24,7 +38,21 @@ async def export_session_csv(db_path: str, sid: str) -> str: (sid,), ) rows = await cursor.fetchall() + events_cur = await db.execute( + """ + SELECT student_id, kind, COUNT(*) AS c + FROM student_events + WHERE sid = ? AND student_id IS NOT NULL + GROUP BY student_id, kind + """, + (sid,), + ) + events = await events_cur.fetchall() + counts: dict[str, dict[str, int]] = {} + for row in events: + counts.setdefault(row["student_id"], {})[row["kind"]] = int(row["c"]) for row in rows: + per = counts.get(row["student_id"], {}) writer.writerow( [ row["sid"], @@ -35,6 +63,9 @@ async def export_session_csv(db_path: str, sid: str) -> str: "" if row["elapsed_ms"] is None else row["elapsed_ms"], "" if row["score"] is None else row["score"], row["status"] or "", + per.get("blur", 0), + per.get("visibility_hidden", 0), + per.get("duplicate_join", 0), ] ) return out.getvalue() diff --git a/app/db.py b/app/db.py index fa036ae..f142f15 100644 --- a/app/db.py +++ b/app/db.py @@ -60,6 +60,25 @@ CREATE TABLE IF NOT EXISTS submissions ( CREATE INDEX IF NOT EXISTS idx_submissions_sid_qidx ON submissions(sid, question_idx); CREATE INDEX IF NOT EXISTS idx_participants_sid ON participants(sid); + +-- Soft-anti-cheat audit + tab-blur trail. Append-only; the admin panel +-- and CSV export aggregate per-student counts. Kinds in use: +-- 'blur' — window blur during a question_open state +-- 'visibility_hidden' — page tab/window backgrounded +-- 'duplicate_join' — second-claim attempt on an already-claimed +-- student_id; student_id field holds the +-- ATTEMPTED id; detail JSON carries IP/UA/name +CREATE TABLE IF NOT EXISTS student_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sid TEXT NOT NULL, + student_id TEXT, + question_idx INTEGER, + kind TEXT NOT NULL, + detail TEXT, + ts TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_student_events_sid_student ON student_events(sid, student_id); +CREATE INDEX IF NOT EXISTS idx_student_events_sid_kind ON student_events(sid, kind); """ diff --git a/app/models.py b/app/models.py index ffa7687..d60ee1d 100644 --- a/app/models.py +++ b/app/models.py @@ -12,3 +12,10 @@ class JoinRequest(BaseModel): class AdminLoginRequest(BaseModel): password: str + + +class StudentEventRequest(BaseModel): + # Bounded set of event kinds — anything else returns 422 instead of + # silently filling the audit log with junk. + kind: str = Field(pattern=r"^(blur|focus|visibility_hidden|visibility_visible)$") + question_idx: int | None = Field(default=None, ge=0, le=10_000) diff --git a/app/room.py b/app/room.py index 55be922..7ea9d0f 100644 --- a/app/room.py +++ b/app/room.py @@ -3,11 +3,16 @@ from __future__ import annotations import asyncio +import base64 import json from collections import defaultdict from datetime import UTC, datetime +from io import BytesIO from typing import Any +import aiosqlite +import qrcode +import qrcode.image.svg from fastapi import WebSocket, WebSocketDisconnect from app.config import Settings @@ -37,17 +42,36 @@ def parse_ts(value: str) -> datetime: return parsed +def _qr_data_url(value: str) -> str: + image = qrcode.make(value, image_factory=qrcode.image.svg.SvgPathImage) + buf = BytesIO() + image.save(buf) + encoded = base64.b64encode(buf.getvalue()).decode("ascii") + return f"data:image/svg+xml;base64,{encoded}" + + +class DuplicateStudentId(Exception): + """Raised when a join request targets a student_id that is already + claimed by another active participant (first-claim-wins anti-hijack).""" + + class RoomManager: def __init__(self, settings: Settings): self.settings = settings self.student_clients: dict[str, dict[WebSocket, dict[str, Any]]] = defaultdict(dict) self.instructor_clients: dict[str, set[WebSocket]] = defaultdict(set) + # Projector clients are public read-only; no per-client identity. + self.projector_clients: dict[str, set[WebSocket]] = defaultdict(set) self.autoclose_tasks: dict[tuple[str, int], asyncio.Task] = {} self.locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock) # The single canonical session id, set during startup once the pool # has been loaded. Routes use this rather than settings.default_session_id # so that a session_id field in the pool JSON can override the env default. self.canonical_sid: str | None = None + # Volatile presence: presence[sid][student_id] = {"connected": bool, + # "last_seen_ms": int, "ws_count": int}. Rebuilt on each WS connect + # / disconnect; not persisted (presence dies with the process). + self.presence: dict[str, dict[str, dict[str, Any]]] = defaultdict(dict) async def ensure_single_session(self, sid: str, pool: dict[str, Any]) -> None: """Idempotently upsert the canonical single-session row + its quiz row. @@ -125,6 +149,7 @@ class RoomManager: await db.execute("DELETE FROM submissions WHERE sid = ?", (sid,)) await db.execute("DELETE FROM question_events WHERE sid = ?", (sid,)) await db.execute("DELETE FROM participants WHERE sid = ?", (sid,)) + await db.execute("DELETE FROM student_events WHERE sid = ?", (sid,)) await db.execute( "UPDATE quiz_sessions SET state = 'lobby', current_question_idx = NULL, finished_at = NULL WHERE sid = ?", (sid,), @@ -143,8 +168,13 @@ class RoomManager: except Exception: pass self.student_clients.pop(sid, None) + # Presence is volatile — wipe alongside the participant table so + # the next instructor snapshot doesn't show stale ghost rows. + self.presence.pop(sid, None) await self.broadcast_instructors(sid, {"type": "state", "state": "lobby", "current_question_idx": None, "title": (await self.get_session(sid))["title"]}) await self.broadcast_lobby(sid) + await self.broadcast_presence(sid) + await self.broadcast_projectors(sid) async def sessions_active(self) -> int: async with connect(self.settings.db_path) as db: @@ -153,20 +183,28 @@ class RoomManager: return int(row["count"]) def ws_client_count(self) -> int: - return sum(len(clients) for clients in self.student_clients.values()) + sum( - len(clients) for clients in self.instructor_clients.values() + return ( + sum(len(clients) for clients in self.student_clients.values()) + + sum(len(clients) for clients in self.instructor_clients.values()) + + sum(len(clients) for clients in self.projector_clients.values()) ) async def add_participant(self, sid: str, student_id: str, name: str, cookie_id: str) -> None: + """First-claim-wins. Raises DuplicateStudentId if this student_id + is already in the participants table for this sid (an attempt to + hijack another student's id, or a legit student returning after + clearing cookies). The route handler turns the exception into a + 409 + records a `duplicate_join` audit event so the instructor + can see the attempt on the live presence panel.""" async with connect(self.settings.db_path) as db: - await db.execute( - """ - INSERT INTO participants (sid, student_id, name, cookie_id) - VALUES (?, ?, ?, ?) - ON CONFLICT(sid, student_id) DO UPDATE SET name = excluded.name, cookie_id = excluded.cookie_id - """, - (sid, student_id, name, cookie_id), - ) + try: + await db.execute( + "INSERT INTO participants (sid, student_id, name, cookie_id) VALUES (?, ?, ?, ?)", + (sid, student_id, name, cookie_id), + ) + except aiosqlite.IntegrityError as exc: + # PK violation = student_id already claimed in this sid. + raise DuplicateStudentId(student_id) from exc await db.execute( """ INSERT OR IGNORE INTO submissions (sid, student_id, question_idx, status, score) @@ -178,10 +216,75 @@ class RoomManager: ) await db.commit() await self.broadcast_lobby(sid) + await self.broadcast_presence(sid) + await self.broadcast_projectors(sid) + + async def log_event( + self, + sid: str, + student_id: str | None, + kind: str, + question_idx: int | None = None, + detail: dict[str, Any] | None = None, + ) -> None: + async with connect(self.settings.db_path) as db: + await db.execute( + """ + INSERT INTO student_events (sid, student_id, question_idx, kind, detail) + VALUES (?, ?, ?, ?, ?) + """, + (sid, student_id, question_idx, kind, json.dumps(detail) if detail else None), + ) + await db.commit() + + async def clear_student(self, sid: str, student_id: str) -> bool: + """Admin recovery hatch for first-claim-wins: remove a participant + + all their submissions so the legitimate student can re-claim + their id. Returns True if a row was removed.""" + async with connect(self.settings.db_path) as db: + cursor = await db.execute( + "DELETE FROM submissions WHERE sid = ? AND student_id = ?", + (sid, student_id), + ) + cursor = await db.execute( + "DELETE FROM participants WHERE sid = ? AND student_id = ?", + (sid, student_id), + ) + removed = cursor.rowcount > 0 + await db.commit() + if removed: + self.presence.get(sid, {}).pop(student_id, None) + # Kick any active WS for this student_id so a stale cookie can + # no longer drive submissions. /me will 401 (cookie cleared) + # and the page will land on the join form. + for ws, ident in list(self.student_clients.get(sid, {}).items()): + if ident.get("student_id") == student_id: + try: + await ws.send_json({"type": "session_reset"}) + except Exception: + pass + try: + await ws.close(code=4002) + except Exception: + pass + await self.broadcast_lobby(sid) + await self.broadcast_presence(sid) + await self.broadcast_projectors(sid) + return removed async def student_ws(self, websocket: WebSocket, sid: str, identity: dict[str, Any]) -> None: await websocket.accept() self.student_clients[sid][websocket] = identity + student_id = identity["student_id"] + slot = self.presence[sid].setdefault( + student_id, + {"connected": False, "last_seen_ms": now_ms(), "ws_count": 0, "name": identity.get("name", "")}, + ) + slot["ws_count"] += 1 + slot["connected"] = True + slot["last_seen_ms"] = now_ms() + slot["name"] = identity.get("name", slot.get("name", "")) + await self.broadcast_presence(sid) try: await self.send_student_snapshot(websocket, sid, identity) while True: @@ -211,6 +314,12 @@ class RoomManager: pass finally: self.student_clients[sid].pop(websocket, None) + slot = self.presence.get(sid, {}).get(student_id) + if slot: + slot["ws_count"] = max(0, slot.get("ws_count", 1) - 1) + slot["connected"] = slot["ws_count"] > 0 + slot["last_seen_ms"] = now_ms() + await self.broadcast_presence(sid) async def instructor_ws(self, websocket: WebSocket, sid: str) -> None: await websocket.accept() @@ -290,6 +399,7 @@ class RoomManager: } ) await websocket.send_json(await self.lobby_message(sid)) + await websocket.send_json(await self.presence_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..." @@ -337,6 +447,7 @@ class RoomManager: await self.broadcast_students(sid, msg) await self.broadcast_instructors(sid, msg) await self.broadcast_instructors(sid, await self.live_histogram_message(sid, question_idx)) + await self.broadcast_projectors(sid) async def close_question(self, sid: str) -> None: async with self.locks[sid]: @@ -434,6 +545,8 @@ class RoomManager: ) await db.commit() await self.broadcast_instructors(sid, await self.live_histogram_message(sid, qidx)) + await self.broadcast_presence(sid) + await self.broadcast_projectors(sid) return {"type": "submit_ack", "question_idx": qidx, "answer": answer, "score": score, "elapsed_ms": elapsed_ms} def _schedule_autoclose(self, sid: str, question_idx: int, time_limit: int) -> None: @@ -682,6 +795,278 @@ class RoomManager: participants = [dict(row) for row in rows] return {"type": "lobby_update", "participants": participants, "count": len(participants)} + async def presence_message(self, sid: str) -> dict[str, Any]: + """Per-student live presence: connected/idle, last_seen, blur+ + visibility-hidden counts, current-question-answered flag, and + any duplicate-join attempts on that id. Broadcast to the + instructor on every connect / disconnect / join / answer.""" + async with connect(self.settings.db_path) as db: + participants_cur = await db.execute( + "SELECT student_id, name, joined_at FROM participants WHERE sid = ? ORDER BY joined_at, name", + (sid,), + ) + participants = await participants_cur.fetchall() + session_cur = await db.execute( + "SELECT state, current_question_idx FROM quiz_sessions WHERE sid = ?", + (sid,), + ) + session_row = await session_cur.fetchone() + events_cur = await db.execute( + """ + SELECT student_id, kind, COUNT(*) AS count + FROM student_events + WHERE sid = ? AND student_id IS NOT NULL + GROUP BY student_id, kind + """, + (sid,), + ) + event_rows = await events_cur.fetchall() + current_idx = session_row["current_question_idx"] if session_row else None + answered_now: set[str] = set() + if current_idx is not None: + ans_cur = await db.execute( + """ + SELECT student_id FROM submissions + WHERE sid = ? AND question_idx = ? AND status = 'submitted' + """, + (sid, current_idx), + ) + answered_now = {row["student_id"] for row in await ans_cur.fetchall()} + # Duplicate-join attempts (any student_id touched by an + # event whose kind=duplicate_join). For attempts on an + # existing student_id we want to surface to the legit owner. + dup_cur = await db.execute( + """ + SELECT student_id, COUNT(*) AS count, MAX(ts) AS latest_ts, MAX(detail) AS latest_detail + FROM student_events + WHERE sid = ? AND kind = 'duplicate_join' AND student_id IS NOT NULL + GROUP BY student_id + """, + (sid,), + ) + dup_rows = await dup_cur.fetchall() + events_by_student: dict[str, dict[str, int]] = defaultdict(dict) + for row in event_rows: + events_by_student[row["student_id"]][row["kind"]] = int(row["count"]) + dup_by_student = { + row["student_id"]: { + "count": int(row["count"]), + "latest_ts": row["latest_ts"], + "latest_detail": row["latest_detail"], + } + for row in dup_rows + } + rows: list[dict[str, Any]] = [] + for participant in participants: + sid_id = participant["student_id"] + slot = self.presence.get(sid, {}).get(sid_id, {}) + counts = events_by_student.get(sid_id, {}) + rows.append( + { + "student_id": sid_id, + "name": participant["name"], + "joined_at": participant["joined_at"], + "connected": bool(slot.get("connected")), + "ws_count": int(slot.get("ws_count", 0)), + "last_seen_ms": int(slot.get("last_seen_ms", 0)) or None, + "blur_count": int(counts.get("blur", 0)), + "hidden_count": int(counts.get("visibility_hidden", 0)), + "duplicate_join_attempts": dup_by_student.get(sid_id, {"count": 0}), + "answered_current": sid_id in answered_now, + } + ) + # Orphan duplicate-join attempts: an attempt on a student_id that + # has not yet been claimed by a real participant. Surface as a + # separate list so the instructor can see "someone tried to join + # as 12345 but nobody named 12345 has joined yet". + orphan_attempts = [ + {"student_id": sid_id, **info} + for sid_id, info in dup_by_student.items() + if not any(p["student_id"] == sid_id for p in participants) + ] + return { + "type": "presence_update", + "current_question_idx": current_idx, + "rows": rows, + "orphan_duplicate_joins": orphan_attempts, + } + + async def broadcast_presence(self, sid: str) -> None: + await self.broadcast_instructors(sid, await self.presence_message(sid)) + + # ---- Projector (public big-screen view) ------------------------------- + + async def projector_snapshot(self, sid: str) -> dict[str, Any]: + """Self-contained read-only payload for the projector page. No + student_ids; only aggregate distributions and the public top-N + leaderboard. Sent on initial GET + every WS state change.""" + session = await self.get_session(sid) + pool = await self.get_pool_for_session(sid) + state = session["state"] + current_idx = session["current_question_idx"] + title = session["title"] + join_url = f"{self.settings.public_url}/?sid={sid}" + qr_url = _qr_data_url(join_url) + async with connect(self.settings.db_path) as db: + part_cur = await db.execute( + "SELECT COUNT(*) AS count FROM participants WHERE sid = ?", (sid,) + ) + participant_count = int((await part_cur.fetchone())["count"]) + question_block: dict[str, Any] | None = None + live_histogram: dict[str, Any] | None = None + reveal: dict[str, Any] | None = None + response_time_distribution: dict[str, Any] | None = None + if current_idx is not None and state in {"question_open", "question_closed"}: + question = get_question(pool, int(current_idx)) + event = await self.get_question_event(sid, int(current_idx)) + opened_ms = int(parse_ts(event["opened_at"]).timestamp() * 1000) + time_limit_s = int(event["time_limit"]) + remaining_ms = max(0, opened_ms + time_limit_s * 1000 - now_ms()) if state == "question_open" else 0 + question_block = { + "idx": int(current_idx), + "text": question["text"], + "options": question["options"], + "opened_at_server_ts": opened_ms, + "time_limit": time_limit_s, + "remaining_ms": remaining_ms, + "total_questions": question_count(pool), + } + histogram = await self.histogram(sid, int(current_idx), pending=True) + submitted = histogram["A"] + histogram["B"] + histogram["C"] + histogram["D"] + live_histogram = { + "counts": histogram, + "submitted_count": submitted, + "total_count": submitted + histogram["missed"] + histogram.get("pending", 0), + } + response_time_distribution = await self._response_time_buckets(sid, int(current_idx), time_limit_s) + if state == "question_closed": + reveal = { + "correct": question["correct"], + "explanation": question.get("explanation", ""), + } + leaderboard = await self.leaderboard(sid, limit=10) + # Strip student_ids from the public leaderboard. The instructor + # /admin board still has them via include_ids=True. + public_leaderboard = [ + {"rank": row["rank"], "name": row["name"], "score": row["score"]} + for row in leaderboard + ] + score_distribution = await self._score_distribution(sid, question_count(pool)) + return { + "type": "projector_state", + "sid": sid, + "state": state, + "title": title, + "join_url": join_url, + "qr_url": qr_url, + "participant_count": participant_count, + "pool_meta": { + "question_count": question_count(pool), + "time_limit_default": pool["time_limit_default"], + "score_fn": pool["score_fn"], + }, + "question": question_block, + "live_histogram": live_histogram, + "reveal": reveal, + "response_time_distribution": response_time_distribution, + "score_distribution": score_distribution, + "leaderboard": public_leaderboard, + "server_ts": now_ms(), + } + + async def _response_time_buckets(self, sid: str, question_idx: int, time_limit_s: int) -> dict[str, Any]: + # Bucket elapsed-ms into 8 equal-width bins from 0..time_limit_s. + # Bins are {"label": "0-7s", "count": N, "is_correct_avg": 0..1}. + async with connect(self.settings.db_path) as db: + cur = await db.execute( + """ + SELECT s.elapsed_ms, s.answer + FROM submissions s + WHERE s.sid = ? AND s.question_idx = ? AND s.status = 'submitted' AND s.elapsed_ms IS NOT NULL + """, + (sid, question_idx), + ) + rows = await cur.fetchall() + bins = 8 + if time_limit_s <= 0: + time_limit_s = 60 + edge_ms = (time_limit_s * 1000) / bins + buckets = [{"label": "", "count": 0} for _ in range(bins)] + for i in range(bins): + lo = round(edge_ms * i / 1000) + hi = round(edge_ms * (i + 1) / 1000) + buckets[i]["label"] = f"{lo}-{hi}s" + for row in rows: + ms = int(row["elapsed_ms"]) + idx = min(bins - 1, max(0, int(ms // edge_ms))) + buckets[idx]["count"] += 1 + total = sum(b["count"] for b in buckets) + return {"buckets": buckets, "total": total} + + async def _score_distribution(self, sid: str, question_count_total: int) -> dict[str, Any]: + """Histogram of per-student total scores. Bins are 10% of the + max-possible total (so every quiz lands on a 10-bucket axis + regardless of question count).""" + async with connect(self.settings.db_path) as db: + cur = await db.execute( + """ + SELECT p.student_id, COALESCE(SUM(s.score), 0) AS total + FROM participants p + LEFT JOIN submissions s ON s.sid = p.sid AND s.student_id = p.student_id + WHERE p.sid = ? + GROUP BY p.student_id + """, + (sid,), + ) + rows = await cur.fetchall() + max_total = max(1, question_count_total) + bins = 10 + edge = max_total / bins + buckets = [{"label": "", "count": 0} for _ in range(bins)] + for i in range(bins): + lo = round(edge * i, 1) + hi = round(edge * (i + 1), 1) + buckets[i]["label"] = f"{lo}-{hi}" + for row in rows: + total = float(row["total"]) + idx = min(bins - 1, max(0, int(total // edge))) if edge > 0 else 0 + buckets[idx]["count"] += 1 + return {"buckets": buckets, "max_total": max_total, "n": len(rows)} + + async def projector_ws(self, websocket: WebSocket, sid: str) -> None: + await websocket.accept() + self.projector_clients[sid].add(websocket) + try: + await websocket.send_json(await self.projector_snapshot(sid)) + while True: + # Projector is read-only; we just keep the socket open and + # accept ping/keepalive messages so reverse proxies don't + # idle the connection out. + try: + data = await websocket.receive_json() + except json.JSONDecodeError: + continue + if isinstance(data, dict) and data.get("type") == "ping": + try: + await websocket.send_json({"type": "pong"}) + except (WebSocketDisconnect, RuntimeError): + break + except (WebSocketDisconnect, RuntimeError): + pass + finally: + self.projector_clients[sid].discard(websocket) + + async def broadcast_projectors(self, sid: str) -> None: + if not self.projector_clients.get(sid): + return + try: + snapshot = await self.projector_snapshot(sid) + except Exception: + return + for ws in list(self.projector_clients[sid]): + self._queue_send(ws, snapshot) + await asyncio.sleep(0) + async def live_histogram_message(self, sid: str, question_idx: int) -> dict[str, Any]: histogram = await self.histogram(sid, question_idx, pending=True) submitted_count = histogram["A"] + histogram["B"] + histogram["C"] + histogram["D"] @@ -768,6 +1153,7 @@ class RoomManager: self._queue_send(websocket, await self.question_closed_message(sid, question_idx, identity)) await self.broadcast_instructors(sid, await self.question_closed_message(sid, question_idx)) await self.broadcast_instructors(sid, await self.full_leaderboard_message(sid)) + await self.broadcast_projectors(sid) await asyncio.sleep(0) async def broadcast_between_questions(self, sid: str, next_idx: int) -> None: @@ -775,12 +1161,14 @@ class RoomManager: self._queue_send(websocket, await self.between_message(sid, next_idx, identity)) await self.broadcast_instructors(sid, await self.between_message(sid, next_idx)) await self.broadcast_instructors(sid, await self.full_leaderboard_message(sid)) + await self.broadcast_projectors(sid) await asyncio.sleep(0) async def broadcast_session_ended(self, sid: str) -> None: for websocket, identity in list(self.student_clients[sid].items()): self._queue_send(websocket, await self.ended_message(sid, identity)) await self.broadcast_instructors(sid, await self.ended_message(sid)) + await self.broadcast_projectors(sid) await asyncio.sleep(0) async def broadcast_students(self, sid: str, message: dict[str, Any]) -> None: diff --git a/app/routes_admin.py b/app/routes_admin.py index 7d28f10..2057cf1 100644 --- a/app/routes_admin.py +++ b/app/routes_admin.py @@ -9,12 +9,8 @@ single canonical session whose id is `Settings.default_session_id`. from __future__ import annotations -import base64 -from io import BytesIO from pathlib import Path -import qrcode -import qrcode.image.svg from fastapi import APIRouter, HTTPException, Request, Response, WebSocket from fastapi.responses import FileResponse, PlainTextResponse @@ -23,7 +19,7 @@ from app.config import Settings from app.csv_export import export_session_csv from app.models import AdminLoginRequest from app.rate_limit import TokenBucket, client_ip -from app.room import RoomManager +from app.room import RoomManager, _qr_data_url def router(settings: Settings, rooms: RoomManager) -> APIRouter: @@ -94,6 +90,22 @@ def router(settings: Settings, rooms: RoomManager) -> APIRouter: await rooms.reset(sid) return {"ok": True} + @api.delete("/admin/api/students/{student_id}") + async def admin_clear_student(student_id: str, request: Request): + # Recovery hatch for first-claim-wins: if a student lost their + # cookie or their id was hijacked, the instructor can free the + # slot here. Removes the participant + all of their submissions + # and kicks any active WS for that id; the legitimate student + # then re-joins via the normal flow. + require_admin(request) + sid = rooms.canonical_sid or settings.default_session_id + if not await rooms.session_exists(sid): + raise HTTPException(status_code=503, detail="Session is not initialised") + removed = await rooms.clear_student(sid, student_id) + if not removed: + raise HTTPException(status_code=404, detail="No such student in session") + return {"ok": True} + @api.get("/admin/api/csv") async def csv_download(request: Request): require_admin(request) @@ -115,11 +127,3 @@ def router(settings: Settings, rooms: RoomManager) -> APIRouter: await rooms.instructor_ws(websocket, sid) return api - - -def _qr_data_url(value: str) -> str: - image = qrcode.make(value, image_factory=qrcode.image.svg.SvgPathImage) - buf = BytesIO() - image.save(buf) - encoded = base64.b64encode(buf.getvalue()).decode("ascii") - return f"data:image/svg+xml;base64,{encoded}" diff --git a/app/routes_student.py b/app/routes_student.py index 12d81c6..5ab7a2e 100644 --- a/app/routes_student.py +++ b/app/routes_student.py @@ -10,8 +10,9 @@ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Redirect from app import auth from app.config import Settings -from app.models import JoinRequest -from app.room import RoomManager +from app.models import JoinRequest, StudentEventRequest +from app.rate_limit import client_ip +from app.room import DuplicateStudentId, RoomManager def router(settings: Settings, rooms: RoomManager) -> APIRouter: @@ -54,17 +55,66 @@ def router(settings: Settings, rooms: RoomManager) -> APIRouter: } @api.post("/api/session/{sid}/join") - async def join_session(sid: str, body: JoinRequest, response: Response): + async def join_session(sid: str, body: JoinRequest, request: Request, response: Response): if not await rooms.session_exists(sid): raise HTTPException(status_code=404, detail="Session not found") student_id = body.student_id.strip() name = body.name.strip() cookie_id = str(uuid4()) + try: + await rooms.add_participant(sid, student_id, name, cookie_id) + except DuplicateStudentId: + # First-claim-wins anti-hijack: a participant row already + # exists for this student_id. Could be a hijack attempt + # OR a legit student returning after clearing cookies. Log + # the attempt with IP/UA/attempted-name so the instructor + # can surface it on the live presence panel and decide. + await rooms.log_event( + sid, + student_id=student_id, + kind="duplicate_join", + detail={ + "attempted_name": name, + "ip": client_ip(request), + "ua": (request.headers.get("user-agent") or "")[:200], + }, + ) + await rooms.broadcast_presence(sid) + raise HTTPException( + status_code=409, + detail=( + "This student ID is already in use. If this is your ID, " + "ask your instructor to clear it for you." + ), + ) from None cookie_value = auth.sign_student(settings, sid, student_id, name, cookie_id) - await rooms.add_participant(sid, student_id, name, cookie_id) auth.set_student_cookie(settings, response, cookie_value) return {"ok": True, "cookie_id": cookie_id} + @api.post("/api/session/{sid}/event") + async def post_event(sid: str, body: StudentEventRequest, request: Request): + # Audit-only endpoint: the student page POSTs here on tab blur + # / visibility-hidden so the instructor can see engagement + # signals during a live question. No state change. + if not await rooms.session_exists(sid): + raise HTTPException(status_code=404, detail="Session not found") + identity = auth.get_student_identity(settings, request, sid) + if not identity: + raise HTTPException(status_code=401, detail="Student cookie required") + await rooms.log_event( + sid, + student_id=identity["student_id"], + kind=body.kind, + question_idx=body.question_idx, + detail={"ip": client_ip(request)}, + ) + # blur / visibility_hidden are surfaced to the instructor; focus / + # visibility_visible are recorded for completeness but don't need + # an immediate broadcast. + if body.kind in {"blur", "visibility_hidden"}: + await rooms.broadcast_presence(sid) + return {"ok": True} + @api.get("/api/session/{sid}/me") async def me(sid: str, request: Request): identity = auth.get_student_identity(settings, request, sid) @@ -97,4 +147,42 @@ def router(settings: Settings, rooms: RoomManager) -> APIRouter: return await rooms.student_ws(websocket, sid, identity) + # ---- Projector view (public, read-only) ------------------------------- + # The projector page runs at the front of the room on a smart TV / big + # screen. No auth: it shows only aggregate / leaderboard data that + # would already be visible on the student's own screen at reveal + # time. Per-student histograms keep names but redact student_ids + # (the student-id namespace is private). + + @api.get("/projector/") + async def projector_page(sid: str | None = None): + target_sid = resolve_sid(sid) + if not await rooms.session_exists(target_sid): + return HTMLResponse( + "" + "" + "Projector — quiz unavailable" + "
" + "

Projector — no live session

" + "

Start the quiz from the admin dashboard.

" + "
", + status_code=404, + ) + if not sid: + return RedirectResponse(url=f"/projector/?sid={target_sid}", status_code=302) + return FileResponse(Path("static/projector.html")) + + @api.get("/api/session/{sid}/projector") + async def projector_state(sid: str): + if not await rooms.session_exists(sid): + raise HTTPException(status_code=404, detail="Session not found") + return await rooms.projector_snapshot(sid) + + @api.websocket("/ws/projector/{sid}") + async def projector_socket(websocket: WebSocket, sid: str): + if not await rooms.session_exists(sid): + await websocket.close(code=4001) + return + await rooms.projector_ws(websocket, sid) + return api diff --git a/deploy/Caddyfile.tpl b/deploy/Caddyfile.tpl index fa7a61f..ff98e45 100644 --- a/deploy/Caddyfile.tpl +++ b/deploy/Caddyfile.tpl @@ -1,4 +1,40 @@ __DOMAIN__ { encode gzip - reverse_proxy 127.0.0.1:8001 + + # Cap request bodies. Pool JSON is the largest legitimate payload and + # tops out well under 1 MiB; cap at 1 MiB so abusive uploads (large + # blobs to /admin/api/* or pathological websocket frames pretending to + # be HTTP) get rejected at the edge before reaching uvicorn. + request_body { + max_size 1MB + } + + # /admin/login is rate-limited at the app layer (rate_limit.py: + # 10/min/IP). A Caddy-edge limiter would be defense in depth, but + # would require the non-stock `caddy-ratelimit` plugin; we keep this + # bootstrap stock-Caddy-compatible. + + # Security headers. CSP allows Google Fonts (used by style.css) and + # WebSocket back to the same origin; everything else is self-only. + # X-Frame-Options DENY prevents clickjacking the admin into an iframe. + # HSTS pin (1y, includeSubDomains, preload) so once a browser has + # talked HTTPS to this host it refuses HTTP downgrades; safe because + # the host is HTTPS-only. + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + Referrer-Policy "strict-origin-when-cross-origin" + Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" + Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; script-src 'self'; connect-src 'self' wss://__DOMAIN__ ws://__DOMAIN__; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" + # Server header leaks Caddy version; strip it. + -Server + } + + reverse_proxy 127.0.0.1:8001 { + # Pass real client IP downstream so app-layer rate-limit + audit + # logs see the actual student IP (not 127.0.0.1). + header_up X-Forwarded-For {http.request.remote.host} + header_up X-Real-IP {http.request.remote.host} + } } diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index 91db80a..3dd7ecc 100755 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -63,6 +63,10 @@ if ! id "$APP_USER" >/dev/null 2>&1; then fi stage "5/10: clone or update repo into $APP_DIR" +# safe.directory mark for the root-run git ops: on a re-bootstrap the +# repo is owned by $APP_USER (set on the previous run), and modern git +# refuses cross-user operations without this marker. +git config --global --add safe.directory "$APP_DIR" 2>/dev/null || true if [ -d "$APP_DIR/.git" ]; then git -C "$APP_DIR" fetch origin git -C "$APP_DIR" reset --hard "origin/$BRANCH" diff --git a/static/admin.js b/static/admin.js index 0473be0..ad0e71a 100644 --- a/static/admin.js +++ b/static/admin.js @@ -15,6 +15,9 @@ const store = { session: null, // /admin/api/state response ws: null, roster: [], + presence: [], // presence_update.rows — richer than roster + orphanDuplicates: [], // presence_update.orphan_duplicate_joins + currentQIdx: null, // tracked for "answered current?" rendering currentQuestion: null, histogram: null, totalCount: 0, @@ -130,7 +133,10 @@ function renderLogin(error = null) { function renderDashboard() { const session = store.session; if (!session) return; - const state = store.endedPayload ? "finished" : (store.closedPayload?.state || session.state); + // state derives from session (server-authoritative); endedPayload short- + // circuits to "finished" for the post-final render where we may not + // have re-fetched session.state yet. + const state = store.endedPayload ? "finished" : session.state; app.innerHTML = `
@@ -143,10 +149,11 @@ function renderDashboard() {
${store.notice ? `
${escapeText(store.notice)}
` : ""} + ${renderDuplicateJoinAlerts()}
${renderStatePanel(state)} @@ -155,6 +162,7 @@ function renderDashboard() { `; document.querySelector("#logout-btn").addEventListener("click", logout); bindStateActions(); + bindPresenceActions(); if (state === "question_open") startCountdown(); } @@ -183,20 +191,84 @@ function renderJoinPanel() { `; } -function renderRosterPanel() { - const r = store.roster || []; - // Newest-first so late joiners are visible at the top of the list. The - // first three are tagged so the CSS can warm their dot — gives the - // operator a quick "yes the room is live" cue without an explicit log. - const ordered = r.slice().reverse(); +function renderPresencePanel() { + const presence = store.presence || []; + const rosterCount = (store.roster || []).length; + const connected = presence.filter((p) => p.connected).length; + const idleStaleMs = 30_000; + const now = Date.now(); + // Newest-first so late joiners stay visible at the top. + const ordered = presence.slice().reverse(); + if (!ordered.length) { + return ` +
+

Joined ${rosterCount}

+

No students have joined yet. Share the QR or URL.

+
+ `; + } + const currentQIdx = store.currentQIdx ?? store.session?.current_question_idx; + const isQuestionOpen = currentQIdx != null && store.session?.state === "question_open"; return ` -
-

Joined ${r.length}

- ${ordered.length - ? `
    ${ordered.map((p, i) => - `
  • ${escapeText(p.name)}${escapeText(p.student_id)}
  • ` - ).join("")}
` - : `

No students have joined yet. Share the QR or URL.

`} +
+

Presence ${connected}/${presence.length}

+
    + ${ordered.map((row, i) => { + const lastSeen = row.last_seen_ms || 0; + const stale = !row.connected && lastSeen && (now - lastSeen) > idleStaleMs; + const dotState = row.connected ? "is-online" : (stale ? "is-stale" : "is-offline"); + const blur = row.blur_count || 0; + const hidden = row.hidden_count || 0; + const dupCount = row.duplicate_join_attempts?.count || 0; + const answered = row.answered_current; + const fresh = i < 3 && row.connected ? "is-fresh" : ""; + return ` +
  • + + + ${escapeText(row.name)} + ${escapeText(row.student_id)} + + + ${isQuestionOpen + ? `${answered ? "✓" : "·"}` + : ""} + ${blur > 0 ? `${blur}↗` : ""} + ${hidden > 0 ? `${hidden}◌` : ""} + ${dupCount > 0 ? `!${dupCount}` : ""} + + +
  • + `; + }).join("")} +
+

+ connected + idle + dropped +

+
+ `; +} + +function renderDuplicateJoinAlerts() { + const orphans = store.orphanDuplicates || []; + if (!orphans.length) return ""; + // An orphan attempt is a duplicate-join on a student_id that no real + // participant currently holds — surface separately because it suggests + // someone is probing student_ids that aren't even claimed yet. + return ` +
+

Suspicious join attempts

+
    + ${orphans.map((o) => ` +
  • + ${escapeText(o.student_id)} + ${o.count}× attempted${o.latest_ts ? ` · last ${escapeText(o.latest_ts)}` : ""} +
  • + `).join("")} +
+

No real participant holds these IDs yet. If a student claims one of them and asks for help, you can clear it from the presence list.

`; } @@ -399,6 +471,24 @@ function bindStateActions() { if (copy) copy.addEventListener("click", copyJoinUrl); } +function bindPresenceActions() { + document.querySelectorAll("[data-clear-student]").forEach((btn) => { + btn.addEventListener("click", async () => { + const studentId = btn.dataset.clearStudent; + if (!studentId) return; + if (!confirm(`Clear ${studentId}? Their submissions and presence row will be removed; they can then re-join with the same ID.`)) return; + btn.disabled = true; + try { + await api(`/admin/api/students/${encodeURIComponent(studentId)}`, { method: "DELETE" }); + } catch (err) { + alert(err.message || "Could not clear student."); + btn.disabled = false; + } + // Server pushes presence_update so the row will disappear naturally. + }); + }); +} + async function onAction(action, btn) { if (action === "reset") { if (!confirm("Reset clears all participants and submissions. Continue?")) return; @@ -502,6 +592,12 @@ function handleWSMessage(message) { store.roster = message.participants || []; renderDashboard(); break; + case "presence_update": + store.presence = message.rows || []; + store.orphanDuplicates = message.orphan_duplicate_joins || []; + store.currentQIdx = message.current_question_idx ?? null; + renderDashboard(); + break; case "question_open": store.session.state = "question_open"; store.session.current_question_idx = message.question_idx; diff --git a/static/projector.css b/static/projector.css new file mode 100644 index 0000000..d974baa --- /dev/null +++ b/static/projector.css @@ -0,0 +1,1204 @@ +/* ============================================================ + * Projector view — front-of-room, single-screen, no-scroll. + * + * Aesthetic: an editorial broadside / front page hung at the + * front of a lecture hall. Shared style.css supplies the type + * system and color tokens; this file lays out the page like a + * printed gazette: ruled sections, folio numerals, marginalia, + * registration crosses, no chrome of any kind. + * + * Hard rules enforced here: + * - 100vh × 100vw, no scroll, at 1366×768 / 1920×1080 / 3440×1440 + * - Question prose >= 3vw, options >= 1.5vw, leaderboard >= 1.4vw + * - All color via tokens (light & dark via prefers-color-scheme) + * - Honors prefers-reduced-motion (handled in style.css globally) + * ============================================================ */ + + +/* ---------- Page shell ---------- */ + +.projector-body { + margin: 0; + padding: 0; + height: 100vh; + height: 100dvh; + width: 100vw; + overflow: hidden; + background: + /* faint diagonal weave that reads as paper grain on a projector */ + repeating-linear-gradient( + 135deg, + transparent 0 6px, + var(--bg-grain, rgba(31,29,24,0.018)) 6px 7px + ), + radial-gradient(1400px 800px at 100% -10%, color-mix(in srgb, var(--primary) 6%, transparent) 0%, transparent 60%), + radial-gradient(1100px 600px at -10% 110%, color-mix(in srgb, var(--primary) 4%, transparent) 0%, transparent 55%), + var(--bg); +} + +#projector-app { + height: 100vh; + height: 100dvh; + width: 100vw; +} + +.projector-shell { + position: relative; + display: grid; + grid-template-rows: auto 1fr auto; + height: 100vh; + height: 100dvh; + padding: clamp(14px, 1.8vh, 24px) clamp(22px, 2.2vw, 38px) clamp(10px, 1.4vh, 18px); + gap: clamp(10px, 1.4vh, 18px); + box-sizing: border-box; + isolation: isolate; +} + +/* Registration crosses in the four corners — the kind printers use for + * plate alignment. Pure decoration, hidden from a/y. */ +.projector-shell::before, +.projector-shell::after { + content: ""; + position: absolute; + width: 14px; + height: 14px; + pointer-events: none; + background: + linear-gradient(var(--rule), var(--rule)) center / 1px 100% no-repeat, + linear-gradient(var(--rule), var(--rule)) center / 100% 1px no-repeat; + opacity: 0.55; +} +.projector-shell::before { top: 6px; left: 6px; } +.projector-shell::after { bottom: 6px; right: 6px; } + +/* The two opposite corners get separate elements so we don't need extra DOM */ +.projector-shell > .reg-tr, +.projector-shell > .reg-bl { + position: absolute; + width: 14px; + height: 14px; + pointer-events: none; + background: + linear-gradient(var(--rule), var(--rule)) center / 1px 100% no-repeat, + linear-gradient(var(--rule), var(--rule)) center / 100% 1px no-repeat; + opacity: 0.55; +} +.projector-shell > .reg-tr { top: 6px; right: 6px; } +.projector-shell > .reg-bl { bottom: 6px; left: 6px; } + + +/* ---------- Topbar (masthead) ---------- */ + +.projector-topbar { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: end; + gap: clamp(16px, 2.4vw, 36px); + padding-bottom: clamp(8px, 1.2vh, 14px); + border-bottom: 1.5px solid var(--rule); + position: relative; +} +/* under-rule (double rule like a masthead) */ +.projector-topbar::after { + content: ""; + position: absolute; + left: 0; right: 0; bottom: -4px; + border-bottom: 1px solid var(--rule); + opacity: 0.45; +} + +.topbar-left { display: grid; gap: 4px; justify-items: start; } +.topbar-mid { display: grid; gap: 4px; justify-items: center; text-align: center; } +.topbar-right { display: grid; gap: 4px; justify-items: end; text-align: right; } + +.brand { + font-family: var(--font-sans); + font-size: clamp(0.62rem, 0.82vw, 0.78rem); + font-weight: 700; + letter-spacing: 0.34em; + text-transform: uppercase; + color: var(--muted-2); + display: inline-flex; + align-items: center; + gap: 10px; +} +.brand::before, +.brand::after { + content: ""; + display: inline-block; + width: clamp(18px, 2vw, 28px); + height: 1px; + background: currentColor; + opacity: 0.55; +} + +.topbar-title { + font-family: var(--font-display); + font-size: clamp(1.4rem, 2.2vw, 2.1rem); + font-weight: 600; + letter-spacing: -0.014em; + margin: 0; + color: var(--text); + line-height: 1.05; + font-feature-settings: "ss01"; +} + +.folio { + font-family: var(--font-mono); + font-size: clamp(0.68rem, 0.95vw, 0.92rem); + font-weight: 500; + font-variant-numeric: tabular-nums; + letter-spacing: 0.08em; + color: var(--muted); + text-transform: uppercase; +} +.folio b { + font-weight: 600; + color: var(--text); + letter-spacing: -0.01em; +} + +/* Override the global state-badge sizing for projector scale */ +.projector-shell .state-badge { + font-size: clamp(0.7rem, 0.9vw, 0.86rem); + padding: 8px 14px; + letter-spacing: 0.22em; +} + + +/* ---------- Main grid (one row, varies per state) ---------- */ + +.projector-grid { + display: grid; + gap: clamp(12px, 1.6vw, 24px); + height: 100%; + min-height: 0; +} + +.projector-grid.lobby { grid-template-columns: minmax(380px, 0.9fr) 1.1fr; } +.projector-grid.question { grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr); } +.projector-grid.between, +.projector-grid.finished { grid-template-columns: 1.05fr 1fr; } + + +/* ---------- Card primitive ---------- */ + +.projector-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + box-shadow: var(--shadow); + padding: clamp(16px, 2vw, 28px); + display: grid; + align-content: start; + gap: clamp(8px, 1.2vh, 14px); + min-height: 0; + overflow: hidden; + position: relative; +} + +/* Eyebrows / section labels — small caps with rule */ +.card-eyebrow, +.lobby-eyebrow { + font-family: var(--font-sans); + font-size: clamp(0.66rem, 0.86vw, 0.8rem); + font-weight: 700; + letter-spacing: 0.26em; + text-transform: uppercase; + color: var(--muted); + margin: 0; + display: flex; + align-items: center; + gap: 12px; +} +.card-eyebrow::after { + content: ""; + flex: 1; + height: 1px; + background: var(--border); +} + + +/* ============================================================ + * STATE: lobby + * ============================================================ */ + +.join-card { + align-content: start; + justify-items: stretch; + gap: clamp(12px, 1.6vh, 18px); + grid-template-rows: auto 1fr auto; +} + +.lobby-headline { + font-family: var(--font-display); + font-size: clamp(1.6rem, 2.4vw, 2.4rem); + font-weight: 600; + font-style: italic; + letter-spacing: -0.012em; + line-height: 1.1; + color: var(--text); + margin: 0; +} +.lobby-sub { + font-family: var(--font-sans); + font-size: clamp(0.85rem, 1.05vw, 1rem); + color: var(--muted); + margin: 0; + letter-spacing: 0.01em; +} + +.qr-frame { + position: relative; + display: grid; + place-items: center; + width: 100%; + min-height: 0; + align-self: center; +} +.qr-big { + background: #ffffff; + padding: clamp(12px, 1.4vw, 18px); + border: 1px solid var(--border-strong); + border-radius: 2px; + width: clamp(280px, 28vw, 460px); + aspect-ratio: 1 / 1; + display: grid; + place-items: center; + box-shadow: + 0 1px 0 rgba(20, 22, 28, 0.06), + 0 24px 56px -28px rgba(20, 22, 28, 0.32); + position: relative; +} +.qr-big img { + width: 100%; + height: 100%; + display: block; + image-rendering: pixelated; + image-rendering: crisp-edges; +} +/* corner braces */ +.qr-frame::before, +.qr-frame::after { + content: ""; + position: absolute; + width: 22px; + height: 22px; + border: 1.5px solid var(--text); + pointer-events: none; +} +.qr-frame::before { top: -10px; left: 50%; transform: translateX(calc(-50% - clamp(140px, 14vw, 230px))); border-right: 0; border-bottom: 0; } +.qr-frame::after { bottom: -10px; left: 50%; transform: translateX(calc(-50% + clamp(140px, 14vw, 230px))); border-left: 0; border-top: 0; } + +.lobby-url { + font-family: var(--font-mono); + font-size: clamp(0.95rem, 1.25vw, 1.25rem); + font-weight: 500; + letter-spacing: 0.02em; + color: var(--text); + background: var(--surface-2); + padding: 10px 16px; + border: 1px solid var(--border); + border-radius: 2px; + word-break: break-all; + text-align: center; + font-variant-numeric: tabular-nums; +} + +/* lobby-status (right column) */ +.lobby-status { + align-content: stretch; + grid-template-rows: auto auto 1fr auto; + gap: clamp(10px, 1.4vh, 16px); +} + +.participant-count { + display: grid; + grid-template-columns: auto 1fr; + align-items: end; + gap: clamp(12px, 1.6vw, 22px); + padding: clamp(8px, 1.2vh, 14px) 0; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} +.participant-count b { + font-family: var(--font-mono); + font-size: clamp(4rem, 8.6vw, 8.4rem); + font-weight: 500; + font-variant-numeric: tabular-nums; + letter-spacing: -0.05em; + line-height: 0.92; + color: var(--text); + display: block; + transform-origin: left bottom; +} +.participant-count.bump b { + animation: count-bump 520ms cubic-bezier(0.22, 0.61, 0.36, 1); +} +@keyframes count-bump { + 0% { transform: scale(1); color: var(--text); } + 35% { transform: scale(1.08); color: var(--primary); } + 100% { transform: scale(1); color: var(--text); } +} +.participant-count .label { + display: grid; + gap: 2px; + padding-bottom: clamp(8px, 1.2vh, 14px); +} +.participant-count .label .word { + font-family: var(--font-display); + font-style: italic; + font-size: clamp(1.1rem, 1.6vw, 1.7rem); + color: var(--text-soft); + line-height: 1.05; +} +.participant-count .label .meta { + font-family: var(--font-sans); + font-size: clamp(0.7rem, 0.9vw, 0.85rem); + color: var(--muted); + letter-spacing: 0.18em; + text-transform: uppercase; + font-weight: 600; +} + +.constellation { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(clamp(11px, 1.1vw, 16px), 1fr)); + gap: clamp(5px, 0.6vw, 9px); + align-content: start; + min-height: 0; + overflow: hidden; + align-self: stretch; + padding: 4px 0; +} +.constellation li { + width: 100%; + aspect-ratio: 1 / 1; + border-radius: 999px; + background: var(--primary); + opacity: 0.78; + animation: dot-in 0.5s cubic-bezier(0.22, 0.61, 0.36, 1) both; + animation-delay: var(--d, 0ms); +} +@keyframes dot-in { + from { opacity: 0; transform: scale(0.4); } + to { opacity: 0.78; transform: scale(1); } +} + +.lobby-rule { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 12px; + font-family: var(--font-sans); + font-size: clamp(0.7rem, 0.92vw, 0.86rem); + letter-spacing: 0.22em; + text-transform: uppercase; + font-weight: 600; + color: var(--muted); +} +.lobby-rule::before, +.lobby-rule::after { + content: ""; + display: block; + height: 1px; + background: var(--border); +} + +.lobby-meta-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: clamp(8px, 1vw, 14px); +} +.lobby-meta-grid .cell { + display: grid; + gap: 2px; + padding: clamp(8px, 1vh, 12px) clamp(10px, 1vw, 14px); + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 2px; +} +.lobby-meta-grid .cell .v { + font-family: var(--font-mono); + font-size: clamp(1.2rem, 1.7vw, 1.7rem); + font-weight: 500; + font-variant-numeric: tabular-nums; + letter-spacing: -0.02em; + color: var(--text); + line-height: 1.0; +} +.lobby-meta-grid .cell .k { + font-family: var(--font-sans); + font-size: clamp(0.62rem, 0.82vw, 0.74rem); + font-weight: 600; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--muted); +} + + +/* ============================================================ + * STATE: question (open + closed/reveal) + * ============================================================ */ + +.question-card { + gap: clamp(10px, 1.4vh, 18px); + grid-template-rows: auto 1fr auto; +} + +.question-head { + display: grid; + grid-template-columns: 1fr auto; + align-items: start; + gap: clamp(14px, 2vw, 28px); +} + +.big-question { + font-family: var(--font-display); + /* Per spec: question prose >= ~3vw — go just above to be safe at 1366×768 */ + font-size: clamp(1.7rem, 3vw, 3rem); + font-weight: 500; + line-height: 1.18; + letter-spacing: -0.014em; + margin: 0; + color: var(--text); + font-feature-settings: "ss01"; + hyphens: auto; +} + +/* Countdown ring — far more legible across a hall than a number alone. + * Uses CSS conic-gradient for the arc; text sits in the middle. */ +.countdown-ring { + --size: clamp(90px, 8vw, 140px); + --stroke: clamp(6px, 0.7vw, 10px); + --pct: 100; /* 0..100 */ + --hue: var(--primary); + width: var(--size); + height: var(--size); + position: relative; + flex-shrink: 0; + display: grid; + place-items: center; + background: + conic-gradient(var(--hue) calc(var(--pct) * 1%), var(--border) 0); + border-radius: 999px; +} +.countdown-ring::before { + content: ""; + position: absolute; + inset: var(--stroke); + background: var(--surface); + border-radius: 999px; +} +.countdown-ring .num { + position: relative; + font-family: var(--font-mono); + font-size: clamp(1.7rem, 2.6vw, 2.6rem); + font-weight: 500; + font-variant-numeric: tabular-nums; + letter-spacing: -0.04em; + color: var(--text); + line-height: 1; +} +.countdown-ring.urgent { + --hue: var(--danger); + animation: ring-urgent 0.9s ease-in-out infinite; +} +.countdown-ring.urgent .num { + color: var(--danger); +} +@keyframes ring-urgent { + 0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--danger) 28%, transparent); } + 50% { box-shadow: 0 0 0 8px color-mix(in srgb, var(--danger) 0%, transparent); } +} +.countdown-ring.spent { --hue: var(--muted); } + +/* Options */ +.big-options { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: clamp(7px, 0.95vh, 12px); + align-content: start; +} +.big-options li { + display: grid; + grid-template-columns: clamp(46px, 4.4vw, 72px) 1fr clamp(110px, 14vw, 200px) clamp(74px, 9vw, 110px); + gap: clamp(10px, 1.2vw, 18px); + align-items: center; + padding: clamp(10px, 1.4vh, 16px) clamp(14px, 1.6vw, 22px); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 2px; + position: relative; + transition: + border-color 280ms ease, + background 280ms ease, + opacity 280ms ease, + color 280ms ease; +} +.big-options li::before { + /* tiny "this is a row" tick on the left, like an editorial bullet */ + content: ""; + position: absolute; + left: -1px; + top: 50%; + transform: translateY(-50%); + width: 3px; + height: 38%; + background: var(--border); + transition: background 280ms ease, height 280ms ease; +} +.big-options.revealed li.correct { + background: var(--correct-bg); + border-color: var(--correct-border); + border-width: 1.5px; +} +.big-options.revealed li.correct::before { + background: var(--correct-border); + height: 70%; +} +.big-options.revealed li.incorrect { + opacity: 0.45; +} +.big-options.revealed li.incorrect .opt-text { + text-decoration: line-through; + text-decoration-color: var(--muted-2); + text-decoration-thickness: 1px; +} + +.opt-key { + font-family: var(--font-display); + font-weight: 600; + font-size: clamp(1.6rem, 2.4vw, 2.3rem); + color: var(--muted); + border-right: 1px solid var(--border); + padding-right: clamp(10px, 1.1vw, 16px); + letter-spacing: -0.01em; + text-align: center; + line-height: 1; + font-feature-settings: "ss01"; +} +.big-options li.correct .opt-key { color: var(--correct-border); border-right-color: var(--correct-border); } + +.opt-text { + font-family: var(--font-display); + /* Per spec: option text >= ~1.5vw */ + font-size: clamp(1.05rem, 1.6vw, 1.6rem); + line-height: 1.28; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.opt-bar { + display: block; + height: clamp(10px, 1.5vh, 16px); + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 0; + overflow: hidden; + position: relative; +} +/* tick marks at 25/50/75% — subtle, like graph paper */ +.opt-bar::before { + content: ""; + position: absolute; + inset: 0; + background: + linear-gradient(to right, transparent 0 calc(25% - 0.5px), var(--border) calc(25% - 0.5px) calc(25% + 0.5px), transparent calc(25% + 0.5px)), + linear-gradient(to right, transparent 0 calc(50% - 0.5px), var(--border) calc(50% - 0.5px) calc(50% + 0.5px), transparent calc(50% + 0.5px)), + linear-gradient(to right, transparent 0 calc(75% - 0.5px), var(--border) calc(75% - 0.5px) calc(75% + 0.5px), transparent calc(75% + 0.5px)); + opacity: 0.6; + pointer-events: none; +} +.opt-bar-fill { + display: block; + height: 100%; + background: var(--primary); + width: 0%; + transition: width 600ms cubic-bezier(0.22, 0.61, 0.36, 1), background 280ms ease; + position: relative; + z-index: 1; +} +.big-options li.correct .opt-bar-fill { background: var(--correct-border); } +.big-options.revealed li.incorrect .opt-bar-fill { background: var(--muted-2); } + +.opt-count { + font-family: var(--font-mono); + font-size: clamp(1.15rem, 1.55vw, 1.55rem); + font-weight: 500; + font-variant-numeric: tabular-nums; + letter-spacing: -0.02em; + text-align: right; + color: var(--text); + display: grid; + gap: 0; + line-height: 1.05; +} +.opt-count small { + display: block; + font-size: 0.55em; + color: var(--muted); + font-weight: 500; + font-family: var(--font-sans); + letter-spacing: 0.05em; +} + +/* Pre-question state: when histogram is empty, hide bars to keep layout clean */ +.big-options.pre-vote .opt-bar, +.big-options.pre-vote .opt-count { + visibility: hidden; +} + +.big-explanation { + font-family: var(--font-display); + font-style: italic; + font-size: clamp(1rem, 1.35vw, 1.3rem); + line-height: 1.5; + color: var(--text-soft); + border-left: 3px solid var(--correct-border); + padding: 6px 0 6px clamp(10px, 1.2vw, 18px); + margin: 0; + animation: fade-up 600ms cubic-bezier(0.22, 0.61, 0.36, 1) both; +} +@keyframes fade-up { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Submission progress strip at the bottom of the question card */ +.submission-strip { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: clamp(10px, 1.2vw, 16px); + padding-top: clamp(8px, 1vh, 12px); + border-top: 1px solid var(--border); +} +.submission-strip .label { + font-family: var(--font-sans); + font-size: clamp(0.7rem, 0.9vw, 0.84rem); + font-weight: 600; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--muted); +} +.submission-strip .track { + height: clamp(6px, 0.8vh, 9px); + background: var(--surface-2); + border: 1px solid var(--border); + position: relative; + overflow: hidden; +} +.submission-strip .track .fill { + position: absolute; + inset: 0 auto 0 0; + width: var(--p, 0%); + background: var(--primary); + transition: width 600ms cubic-bezier(0.22, 0.61, 0.36, 1); +} +.submission-strip .nums { + font-family: var(--font-mono); + font-size: clamp(0.95rem, 1.2vw, 1.2rem); + font-weight: 500; + font-variant-numeric: tabular-nums; + color: var(--text); + letter-spacing: -0.01em; +} +.submission-strip .nums small { + font-size: 0.66em; + color: var(--muted); + font-family: var(--font-sans); + letter-spacing: 0.06em; + margin-left: 4px; +} + + +/* ---------- Side card (response time + top-5) ---------- */ + +.side-card { + gap: clamp(8px, 1.2vh, 14px); + align-content: start; + grid-template-rows: auto auto auto 1fr; +} +.side-meta { + margin: 0; + text-align: right; + font-family: var(--font-mono); + font-size: clamp(0.78rem, 0.95vw, 0.95rem); + color: var(--muted); + font-variant-numeric: tabular-nums; +} + + +/* ============================================================ + * Bar charts (response time, score histogram) + * ============================================================ */ + +.bar-chart { + --baseline: 1px; + display: grid; + grid-template-rows: 1fr auto auto; + align-items: end; + gap: 6px; + padding: 4px 0 0; + height: clamp(140px, 22vh, 240px); + position: relative; +} +.bar-chart.small { height: clamp(110px, 16vh, 180px); } + +.bar-chart .bars { + display: grid; + grid-auto-flow: column; + grid-auto-columns: 1fr; + gap: clamp(4px, 0.6vw, 8px); + align-items: end; + position: relative; + height: 100%; + border-bottom: 1px solid var(--rule); +} +/* Faint horizontal gridlines at 25 / 50 / 75 / 100 % */ +.bar-chart .bars::before { + content: ""; + position: absolute; + inset: 0; + background-image: repeating-linear-gradient( + to top, + transparent 0 calc(25% - 0.5px), + var(--border) calc(25% - 0.5px) calc(25% + 0.5px), + transparent calc(25% + 0.5px) 25% + ); + opacity: 0.4; + pointer-events: none; +} +.bar-chart .bars > * { z-index: 1; position: relative; } + +.bar-cell { + display: grid; + grid-template-rows: 1fr; + height: 100%; + align-items: end; + text-align: center; + min-width: 0; +} +.bar-fill { + display: block; + background: var(--primary); + height: var(--h, 2%); + width: 100%; + border-radius: 1px 1px 0 0; + transition: height 600ms cubic-bezier(0.22, 0.61, 0.36, 1); + align-self: end; + position: relative; +} +.bar-fill::after { + /* thin top-cap, like a printed almanac bar */ + content: ""; + position: absolute; + top: 0; left: 0; right: 0; + height: 2px; + background: color-mix(in srgb, var(--text) 55%, var(--primary)); + opacity: 0.85; +} +.bar-fill[data-empty="true"] { background: transparent; border-top: 1px dashed var(--border); } +.bar-fill[data-empty="true"]::after { display: none; } + +.bar-num { + font-family: var(--font-mono); + font-size: clamp(0.7rem, 0.9vw, 0.92rem); + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--text); + line-height: 1; +} +.bar-label { + font-family: var(--font-mono); + font-size: clamp(0.58rem, 0.74vw, 0.74rem); + color: var(--muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + letter-spacing: 0; + line-height: 1; +} +.bar-chart .nums, +.bar-chart .labels { + display: grid; + grid-auto-flow: column; + grid-auto-columns: 1fr; + gap: clamp(4px, 0.6vw, 8px); + text-align: center; +} + + +/* ============================================================ + * Score-distribution area chart (between + finished) + * ============================================================ */ + +.area-chart { + position: relative; + width: 100%; + height: clamp(180px, 28vh, 320px); + display: grid; + grid-template-rows: 1fr auto; + gap: 4px; +} +.area-chart svg { + width: 100%; + height: 100%; + display: block; + overflow: visible; +} +.area-chart .grid-line { + stroke: var(--border); + stroke-width: 1; + vector-effect: non-scaling-stroke; + opacity: 0.55; + stroke-dasharray: 2 4; +} +.area-chart .axis { + stroke: var(--rule); + stroke-width: 1; + vector-effect: non-scaling-stroke; +} +.area-chart .area-fill { + fill: var(--primary); + fill-opacity: 0.14; + transition: d 700ms cubic-bezier(0.22, 0.61, 0.36, 1); +} +.area-chart .area-line { + fill: none; + stroke: var(--primary); + stroke-width: 3; + stroke-linejoin: round; + stroke-linecap: round; + transition: d 700ms cubic-bezier(0.22, 0.61, 0.36, 1); + /* tell the renderer not to scale the stroke when SVG is stretched + non-uniformly; the perceived width stays consistent */ + vector-effect: non-scaling-stroke; +} +.area-chart .data-point { + fill: var(--surface); + stroke: var(--primary); + stroke-width: 2.5; + vector-effect: non-scaling-stroke; + transition: cx 700ms cubic-bezier(0.22, 0.61, 0.36, 1), cy 700ms cubic-bezier(0.22, 0.61, 0.36, 1); +} +.area-chart .data-label { + font-family: var(--font-mono); + font-size: 18px; + font-weight: 600; + font-variant-numeric: tabular-nums; + fill: var(--text); + text-anchor: middle; + dominant-baseline: hanging; +} +.area-chart .x-tick-label { + font-family: var(--font-mono); + font-size: 16px; + fill: var(--muted); + text-anchor: middle; +} +.area-chart .y-tick-label { + font-family: var(--font-mono); + font-size: 14px; + fill: var(--muted); + text-anchor: end; + dominant-baseline: middle; +} +.area-chart .axis-title { + font-family: var(--font-sans); + font-size: 14px; + font-weight: 600; + fill: var(--muted); + text-transform: uppercase; + letter-spacing: 0.18em; +} +.area-chart .median-line { + stroke: var(--text); + stroke-width: 1.5; + stroke-dasharray: 4 3; + opacity: 0.6; + vector-effect: non-scaling-stroke; +} +.area-chart .median-tag { + font-family: var(--font-mono); + font-size: 16px; + font-weight: 600; + fill: var(--text); +} + +.chart-legend { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + font-family: var(--font-sans); + font-size: clamp(0.68rem, 0.86vw, 0.82rem); + color: var(--muted); + letter-spacing: 0.14em; + text-transform: uppercase; + font-weight: 600; + padding-top: 4px; +} +.chart-legend .stat { + font-family: var(--font-mono); + letter-spacing: -0.01em; + text-transform: none; + color: var(--text); +} +.chart-legend .stat b { + font-weight: 600; +} + + +/* ============================================================ + * Big leaderboard + * ============================================================ */ + +.big-leaderboard { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: clamp(2px, 0.4vh, 6px); + align-content: start; +} +.big-leaderboard li { + display: grid; + grid-template-columns: clamp(40px, 3.4vw, 64px) 1fr auto; + align-items: baseline; + gap: clamp(10px, 1vw, 18px); + padding: clamp(7px, 0.95vh, 11px) clamp(10px, 1vw, 16px); + border-bottom: 1px solid var(--border); + border-left: 3px solid transparent; + position: relative; + animation: row-in 500ms cubic-bezier(0.22, 0.61, 0.36, 1) both; + animation-delay: var(--d, 0ms); +} +.big-leaderboard li:last-child { border-bottom: 0; } +@keyframes row-in { + from { opacity: 0; transform: translateX(-8px); } + to { opacity: 1; transform: translateX(0); } +} + +.big-leaderboard .rank { + font-family: var(--font-mono); + font-size: clamp(1.05rem, 1.4vw, 1.45rem); + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--muted); + letter-spacing: -0.02em; + text-align: right; +} +.big-leaderboard .name { + font-family: var(--font-display); + /* Per spec: leaderboard names >= 1.4vw */ + font-size: clamp(1.05rem, 1.45vw, 1.55rem); + font-weight: 500; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-feature-settings: "ss01"; +} +.big-leaderboard .score { + font-family: var(--font-mono); + font-size: clamp(1.05rem, 1.5vw, 1.5rem); + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--text); + letter-spacing: -0.02em; +} +.big-leaderboard li:nth-child(1) { + background: color-mix(in srgb, var(--medal-gold) 10%, var(--surface)); + border-left-color: var(--medal-gold); +} +.big-leaderboard li:nth-child(2) { + background: color-mix(in srgb, var(--medal-silver) 10%, var(--surface)); + border-left-color: var(--medal-silver); +} +.big-leaderboard li:nth-child(3) { + background: color-mix(in srgb, var(--medal-bronze) 10%, var(--surface)); + border-left-color: var(--medal-bronze); +} +.big-leaderboard li:nth-child(1) .rank, +.big-leaderboard li:nth-child(1) .score { color: var(--medal-gold); } +.big-leaderboard li:nth-child(2) .rank, +.big-leaderboard li:nth-child(2) .score { color: var(--medal-silver); } +.big-leaderboard li:nth-child(3) .rank, +.big-leaderboard li:nth-child(3) .score { color: var(--medal-bronze); } + + +/* ============================================================ + * Finished: hero banner + * ============================================================ */ + +.finished-banner { + display: grid; + align-content: center; + justify-items: center; + gap: clamp(8px, 1.2vh, 14px); + padding: clamp(16px, 2vh, 28px) clamp(20px, 2vw, 32px); + border: 1px solid var(--border); + background: + linear-gradient(180deg, color-mix(in srgb, var(--primary) 5%, transparent), transparent 60%), + var(--surface); + border-radius: 4px; + position: relative; +} +.finished-banner .kicker { + font-family: var(--font-sans); + font-size: clamp(0.7rem, 0.92vw, 0.86rem); + font-weight: 700; + letter-spacing: 0.32em; + text-transform: uppercase; + color: var(--primary); +} +.finished-banner h2 { + margin: 0; + font-family: var(--font-display); + font-style: italic; + font-size: clamp(2rem, 3.4vw, 3.4rem); + font-weight: 600; + letter-spacing: -0.018em; + color: var(--text); + line-height: 1.05; + border: 0; + padding: 0; + text-transform: none; + display: block; +} +.finished-banner .summary { + font-family: var(--font-display); + font-size: clamp(1rem, 1.3vw, 1.3rem); + color: var(--text-soft); + font-style: italic; + margin: 0; + text-align: center; + line-height: 1.4; +} + +.finished-grid { + grid-template-rows: auto 1fr; +} + + +/* ============================================================ + * Misc: error / empty states + * ============================================================ */ + +.empty-state { + display: grid; + place-items: center; + height: 100%; + text-align: center; + gap: 8px; + color: var(--muted); +} +.empty-state .glyph { + font-family: var(--font-display); + font-style: italic; + font-size: clamp(1.4rem, 2vw, 2rem); + color: var(--muted-2); +} +.empty-state p { + font-family: var(--font-sans); + font-size: clamp(0.78rem, 1vw, 0.95rem); + letter-spacing: 0.08em; + margin: 0; +} + +.fatal-card { + align-content: center; + justify-items: center; + text-align: center; + gap: 12px; + height: 60vh; + align-self: center; + margin: auto; +} + + +/* ============================================================ + * Footer (tickerline) + * ============================================================ */ + +.projector-foot { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: clamp(10px, 1.4vw, 18px); + padding-top: clamp(6px, 0.8vh, 10px); + border-top: 1px solid var(--rule); + font-family: var(--font-mono); + font-size: clamp(0.66rem, 0.84vw, 0.78rem); + font-variant-numeric: tabular-nums; + letter-spacing: 0.04em; + color: var(--muted); +} +.projector-foot .left, +.projector-foot .right { + display: inline-flex; + align-items: center; + gap: 10px; +} +.projector-foot .dot { + width: 7px; + height: 7px; + border-radius: 999px; + background: var(--correct-border); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--correct-border) 18%, transparent); +} +.projector-foot .dot.dim { background: var(--muted-2); box-shadow: none; } +.projector-foot .center { + text-align: center; + font-family: var(--font-sans); + letter-spacing: 0.18em; + text-transform: uppercase; + font-weight: 600; +} + + +/* ============================================================ + * Responsive — keep everything visible on 1366×768 and below + * ============================================================ */ + +@media (max-width: 1280px) { + .projector-grid.lobby { grid-template-columns: 0.95fr 1fr; } + .projector-grid.question { grid-template-columns: 1.5fr 1fr; } +} + +@media (max-width: 1100px) { + .projector-grid.question { grid-template-columns: 1fr; grid-template-rows: 1fr auto; } + .side-card { display: none; } + .topbar-mid { display: none; } +} + +@media (max-aspect-ratio: 5/4) { + /* Boxy aspect: stack lobby vertically */ + .projector-grid.lobby { grid-template-columns: 1fr; grid-template-rows: 1fr auto; } + .qr-big { width: clamp(220px, 32vw, 380px); } +} + + +/* ============================================================ + * Reduced motion — defer to global rule, but explicitly silence + * the ones we introduce here + * ============================================================ */ + +@media (prefers-reduced-motion: reduce) { + .countdown-ring.urgent { animation: none; } + .participant-count.bump b { animation: none; } + .constellation li { animation: none; opacity: 0.78; } + .big-leaderboard li { animation: none; } + .big-explanation { animation: none; } +} diff --git a/static/projector.html b/static/projector.html new file mode 100644 index 0000000..1158eb6 --- /dev/null +++ b/static/projector.html @@ -0,0 +1,16 @@ + + + + + + Quiz — Projector + + + + +
+
Loading projector
+
+ + + diff --git a/static/projector.js b/static/projector.js new file mode 100644 index 0000000..92276f3 --- /dev/null +++ b/static/projector.js @@ -0,0 +1,676 @@ +/* ============================================================ + * Projector view — front-of-room display. + * + * Read-only public WS client. The server is authoritative; we only + * receive `projector_state` snapshots and render them. There are no + * outbound mutations, no auth, no cookies. + * + * The projector is intentionally one-shot per state change: a render + * blows away `#projector-app` and re-builds it, except for two hot + * paths that need partial updates: + * 1) the countdown ring ticks at 4Hz (computed from deadline), + * 2) the lobby participant counter bumps on increment without + * rebuilding the whole lobby. + * + * Layout intent: one screen, no scroll, big-screen typography. + * ============================================================ */ + +const app = document.querySelector("#projector-app"); +const params = new URLSearchParams(window.location.search); +const sid = params.get("sid"); + +const store = { + ws: null, + snapshot: null, + prevSnapshot: null, + countdownTimer: null, + connected: false, +}; + +// -------------------------------------------------------------- +// Helpers +// -------------------------------------------------------------- + +function escapeText(value) { + return String(value ?? "").replace(/[&<>"']/g, (c) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[c]); +} +const escapeAttr = escapeText; + +function fmtScore(value) { + return Number(value || 0).toFixed(2); +} + +function clamp(n, lo, hi) { + return Math.max(lo, Math.min(hi, n)); +} + +// -------------------------------------------------------------- +// Boot + WS +// -------------------------------------------------------------- + +async function boot() { + if (!sid) { + app.innerHTML = ` +
+ +
+
Live Quiz
+
+
+
+
+

Projector view

+

Open /projector/?sid=<your-sid>

+
+
+ offline + + no session +
+
`; + return; + } + try { + const r = await fetch(`/api/session/${sid}/projector`); + if (!r.ok) throw new Error("not found"); + store.snapshot = await r.json(); + render(); + } catch { + app.innerHTML = ` +
+ +
+
Live Quiz
+
+
+
+
+

Quiz unavailable

+

No live session at ${escapeText(sid)}.

+
+
+ offline + + ${escapeText(sid)} +
+
`; + return; + } + connect(); +} + +function connect() { + const protocol = window.location.protocol === "https:" ? "wss" : "ws"; + const ws = new WebSocket(`${protocol}://${window.location.host}/ws/projector/${sid}`); + store.ws = ws; + ws.addEventListener("open", () => { + store.connected = true; + refreshConnDot(); + }); + ws.addEventListener("message", (event) => { + try { + const msg = JSON.parse(event.data); + if (msg.type === "projector_state") { + store.prevSnapshot = store.snapshot; + store.snapshot = msg; + render(); + } + } catch {} + }); + ws.addEventListener("close", () => { + store.connected = false; + refreshConnDot(); + setTimeout(connect, 2000); + }); + // Periodic ping to keep proxies from idling the socket out. + setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + try { ws.send(JSON.stringify({ type: "ping" })); } catch {} + } + }, 25_000); +} + +function refreshConnDot() { + const dot = document.querySelector(".projector-foot .dot"); + if (!dot) return; + dot.classList.toggle("dim", !store.connected); + const left = dot.parentElement; + if (left) { + const text = store.connected ? "live" : "reconnecting"; + // last text node holds status word + const nodes = Array.from(left.childNodes); + const t = nodes.reverse().find((n) => n.nodeType === 3); + if (t) t.nodeValue = " " + text; + } +} + +// -------------------------------------------------------------- +// Top-level render +// -------------------------------------------------------------- + +function render() { + const s = store.snapshot; + if (!s) return; + stopCountdown(); + + const view = + s.state === "lobby" ? renderLobby(s) + : s.state === "question_open" ? renderQuestion(s, false) + : s.state === "question_closed" ? renderQuestion(s, true) + : s.state === "between_questions" ? renderBetween(s) + : s.state === "finished" ? renderFinished(s) + : `

State: ${escapeText(s.state)}

`; + + app.innerHTML = ` +
+ + ${renderTopbar(s)} + ${view} + ${renderFoot(s)} +
+ `; + + // Lobby counter bump animation (post-mount): if the count went up + // since the previous snapshot, briefly mark .bump on the counter. + if (s.state === "lobby") { + const prev = store.prevSnapshot?.participant_count ?? -1; + if (prev >= 0 && s.participant_count > prev) { + const el = document.querySelector(".participant-count"); + if (el) { + el.classList.remove("bump"); + // force reflow then re-add to restart animation + // eslint-disable-next-line no-unused-expressions + void el.offsetWidth; + el.classList.add("bump"); + } + } + } + + // Start the countdown ticker for the question_open state + if (s.state === "question_open" && s.question) { + startCountdown( + Date.now() + (s.question.remaining_ms ?? 0), + s.question.time_limit ?? s.pool_meta?.time_limit_default ?? 60 + ); + } else if (s.state === "question_closed" && s.question) { + // freeze the ring at "spent" + const ring = document.querySelector(".countdown-ring"); + if (ring) { + ring.style.setProperty("--pct", "0"); + ring.classList.remove("urgent"); + ring.classList.add("spent"); + const num = ring.querySelector(".num"); + if (num) num.textContent = "0s"; + } + } + + refreshConnDot(); +} + +// -------------------------------------------------------------- +// Topbar (masthead) +// -------------------------------------------------------------- + +function renderTopbar(s) { + const idx = s.question?.idx ?? null; + const total = s.pool_meta?.question_count ?? s.question?.total_questions ?? 0; + const showQ = idx != null; + const stateLabel = ({ + lobby: "Lobby", + question_open: "Live", + question_closed: "Reveal", + between_questions: "Between", + finished: "Finished", + })[s.state] || s.state; + + return ` +
+
+ Live Quiz +

${escapeText(s.title || "Quiz")}

+
+
+ ${showQ + ? `Question ${idx + 1} of ${total}` + : (total ? `${total} questions` : "") + } + ${escapeText(stateLabel)} +
+
+ ${s.sid ? `SID ${escapeText(s.sid)}` : ""} + ${formatClock(s.server_ts)} +
+
+ `; +} + +function formatClock(ts) { + if (!ts) return ""; + const d = new Date(ts); + const hh = String(d.getHours()).padStart(2, "0"); + const mm = String(d.getMinutes()).padStart(2, "0"); + return `${hh}:${mm}`; +} + +// -------------------------------------------------------------- +// Footer +// -------------------------------------------------------------- + +function renderFoot(s) { + const dotClass = store.connected ? "dot" : "dot dim"; + const status = store.connected ? "live" : "reconnecting"; + const right = (() => { + if (s.state === "lobby") return `awaiting start`; + if (s.state === "finished") return `quiz complete`; + if (s.state === "between_questions") return `interlude`; + if (s.state === "question_closed") return `answers revealed`; + if (s.state === "question_open" && s.live_histogram) { + const c = s.live_histogram; + return `${c.submitted_count}/${c.total_count} submitted`; + } + return ""; + })(); + return ` +
+ ${status} + ${escapeText(s.title || "")} + ${escapeText(right)} +
+ `; +} + +// -------------------------------------------------------------- +// State: LOBBY +// -------------------------------------------------------------- + +function renderLobby(s) { + const n = s.participant_count || 0; + const dotMax = 96; + const dots = Math.min(n, dotMax); + const time = s.pool_meta?.time_limit_default ?? 60; + const qcount = s.pool_meta?.question_count ?? 0; + const scoreFn = (s.pool_meta?.score_fn || "linear").replace(/_/g, " "); + + return ` +
+
+
+

Scan to join

+

Open the quiz on your phone.

+

Point your camera at the code, or type the address below into a browser.

+
+
+
Join QR code
+
+
${escapeText(s.join_url || "")}
+
+ +
+

Joined so far

+
+ ${n} +
+ student${n === 1 ? "" : "s"} ready, + ↳ waiting on instructor +
+
+ +
    + ${Array.from({ length: dots }).map((_, i) => { + const d = (i % 24) * 18; + return `
  1. `; + }).join("")} +
+ +
+
— how it runs —
+
+
${qcount}Questions
+
${time}sPer question
+
${escapeText(scoreFn)}Scoring
+
+
+
+
+ `; +} + +// -------------------------------------------------------------- +// State: QUESTION (open + closed/reveal) +// -------------------------------------------------------------- + +function renderQuestion(s, revealed) { + const q = s.question; + if (!q) return `

Loading question…

`; + + const hist = s.live_histogram?.counts || { A: 0, B: 0, C: 0, D: 0 }; + const submitted = s.live_histogram?.submitted_count || 0; + const total = Math.max(1, s.live_histogram?.total_count || 1); + const reveal = s.reveal; + const correct = reveal?.correct; + + // Pre-vote state: nobody has submitted yet AND we're not revealed. + // Hide the bars to keep the layout calm during reading time. + const hasVotes = ["A", "B", "C", "D"].some((k) => (hist[k] || 0) > 0); + const preVote = !revealed && !hasVotes; + + const limit = q.time_limit || s.pool_meta?.time_limit_default || 60; + const remainingMs = q.remaining_ms ?? 0; + const initialPct = revealed ? 0 : clamp(100 * (remainingMs / 1000) / limit, 0, 100); + const initialSec = Math.ceil(remainingMs / 1000); + const ringClass = + revealed ? "countdown-ring spent" + : (initialSec <= 10 && initialSec > 0) ? "countdown-ring urgent" + : "countdown-ring"; + + const submittedPct = clamp(100 * submitted / Math.max(1, s.live_histogram?.total_count || 1), 0, 100); + + return ` +
+
+
+

${escapeText(q.text)}

+
+ ${revealed ? "0s" : initialSec + "s"} +
+
+ +
    + ${["A","B","C","D"].map((k) => { + const v = hist[k] || 0; + const pct = Math.round(100 * v / total); + const isCorrect = revealed && k === correct; + const isIncorrect = revealed && k !== correct; + const cls = [ + isCorrect ? "correct" : "", + isIncorrect ? "incorrect" : "", + ].filter(Boolean).join(" "); + return ` +
  1. + ${k} + ${escapeText(q.options?.[k] || "")} + + ${v}${pct}% +
  2. + `; + }).join("")} +
+ + ${revealed && reveal?.explanation + ? `

${escapeText(reveal.explanation)}

` + : `
+ Submissions + + ${submitted}of ${s.live_histogram?.total_count || s.participant_count || 0} +
` + } +
+ +
+

Response time

+ ${renderResponseTime(s.response_time_distribution)} +

Top 5

+ ${renderLeaderboard((s.leaderboard || []).slice(0, 5))} +

${submitted} of ${s.live_histogram?.total_count || s.participant_count || 0} answered

+
+
+ `; +} + +// -------------------------------------------------------------- +// State: BETWEEN +// -------------------------------------------------------------- + +function renderBetween(s) { + const next = (s.question?.idx ?? -1) >= 0 + ? `Next: question ${s.question.idx + 2} of ${s.pool_meta?.question_count ?? "?"}` + : ""; + return ` +
+
+

Score distribution

+ ${renderScoreArea(s.score_distribution)} +

${escapeText(next)}

+
+
+

Standings

+ ${renderLeaderboard((s.leaderboard || []).slice(0, 10))} +
+
+ `; +} + +// -------------------------------------------------------------- +// State: FINISHED +// -------------------------------------------------------------- + +function renderFinished(s) { + const dist = s.score_distribution; + const top = (s.leaderboard || [])[0]; + const headline = top + ? `${escapeText(top.name)} took the broadside.` + : `The quiz is complete.`; + return ` +
+
+
+ — The Final Tally — +

${headline}

+

${dist?.n ?? 0} student${(dist?.n ?? 0) === 1 ? "" : "s"} answered · max possible ${(dist?.max_total ?? 0).toFixed(1)} points

+
+ ${renderScoreArea(dist)} +
+
+

Final leaderboard

+ ${renderLeaderboard(s.leaderboard || [])} +
+
+ `; +} + +// -------------------------------------------------------------- +// Leaderboard +// -------------------------------------------------------------- + +function renderLeaderboard(rows) { + if (!rows || !rows.length) { + return `
— no scores yet —

Standings appear after the first question is scored.

`; + } + return ` +
    + ${rows.map((r, i) => ` +
  1. + ${r.rank} + ${escapeText(r.name)} + ${fmtScore(r.score)} +
  2. + `).join("")} +
+ `; +} + +// -------------------------------------------------------------- +// Charts +// -------------------------------------------------------------- + +/** Vertical bar chart with axis baseline + gridlines (CSS-driven). */ +function renderResponseTime(dist) { + if (!dist || !dist.total) { + return `
— awaiting submissions —
`; + } + const max = Math.max(1, ...dist.buckets.map((b) => b.count)); + const cells = dist.buckets.map((b) => { + const h = Math.max(2, Math.round(100 * b.count / max)); + const empty = b.count === 0; + return ` +
+ +
`; + }).join(""); + const nums = dist.buckets.map((b) => `${b.count}`).join(""); + const labels = dist.buckets.map((b) => `${escapeText(b.label)}`).join(""); + return ` +
+
${cells}
+
${nums}
+
${labels}
+
+ `; +} + +/** + * Score distribution as a smoothed step-area chart. Gives a feel for the + * shape of the class result rather than 10 detached bars; reads well at + * lecture-hall distance because the silhouette is unambiguous. + * + * The SVG is intentionally drawn in a fixed 1000×360 box and stretched. + * We use a stepped path so each x-bucket looks like a flat top (since the + * bucket is a range, not a point), then close it down to the axis to fill. + */ +function renderScoreArea(dist) { + if (!dist || !dist.buckets || !dist.buckets.length) { + return `
— scores not yet tallied —

The distribution appears after the first question is scored.

`; + } + const W = 1000, H = 360; + const padL = 56, padR = 16, padT = 22, padB = 44; + const innerW = W - padL - padR; + const innerH = H - padT - padB; + const buckets = dist.buckets; + const n = buckets.length; + const total = dist.n || buckets.reduce((a, b) => a + b.count, 0) || 0; + const max = Math.max(1, ...buckets.map((b) => b.count)); + + // X coords for the *edges* between buckets (n+1 edges) + const xEdge = (i) => padL + (innerW * i) / n; + const yFor = (count) => padT + innerH * (1 - count / max); + + // Stepped polyline: for each bucket draw flat top from xEdge(i) to xEdge(i+1) + const linePath = []; + buckets.forEach((b, i) => { + const x0 = xEdge(i), x1 = xEdge(i + 1), y = yFor(b.count); + if (i === 0) linePath.push(`M ${x0} ${y}`); + else linePath.push(`L ${x0} ${y}`); + linePath.push(`L ${x1} ${y}`); + }); + const fillPath = [ + ...linePath, + `L ${xEdge(n)} ${padT + innerH}`, + `L ${xEdge(0)} ${padT + innerH}`, + `Z`, + ]; + + // Y gridlines at 0, .25, .5, .75, 1 + const yGrid = [0, 0.25, 0.5, 0.75, 1].map((t) => { + const y = padT + innerH * t; + const v = Math.round(max * (1 - t)); + return ` + + ${v} + `; + }).join(""); + + // X-axis tick labels at each bucket centre + const xLabels = buckets.map((b, i) => { + const cx = (xEdge(i) + xEdge(i + 1)) / 2; + return `${escapeText(b.label)}`; + }).join(""); + + // Per-bucket count labels above each top, only if non-zero + const dataLabels = buckets.map((b, i) => { + if (b.count === 0) return ""; + const cx = (xEdge(i) + xEdge(i + 1)) / 2; + const cy = yFor(b.count) - 8; + return `${b.count} + `; + }).join(""); + + // Median tag — find the bucket containing the cumulative midpoint + let medianIdx = -1; + if (total > 0) { + let acc = 0; + for (let i = 0; i < buckets.length; i++) { + acc += buckets[i].count; + if (acc >= total / 2) { medianIdx = i; break; } + } + } + let medianMarks = ""; + if (medianIdx >= 0) { + const mx = (xEdge(medianIdx) + xEdge(medianIdx + 1)) / 2; + medianMarks = ` + + median + `; + } + + // Summary stats + const mean = total ? buckets.reduce((acc, b, i) => { + // approximate bucket midpoint as i+0.5 normalized to max_total + const mid = ((i + 0.5) / n) * (dist.max_total || n); + return acc + b.count * mid; + }, 0) / total : 0; + + return ` +
+ + ${yGrid} + + + Score band (out of ${(dist.max_total || 0).toFixed(1)}) + Students + + + ${dataLabels} + ${medianMarks} + +
+ 10 score bands · ${n} buckets + n = ${total} · mean ${mean.toFixed(2)} · max ${(dist.max_total || 0).toFixed(1)} +
+
+ `; +} + +// -------------------------------------------------------------- +// Countdown ring (partial update, runs at 4Hz) +// -------------------------------------------------------------- + +function startCountdown(deadlineMs, totalSec) { + stopCountdown(); + const tick = () => { + const ring = document.querySelector("#big-countdown"); + if (!ring) return stopCountdown(); + const remaining = Math.max(0, deadlineMs - Date.now()); + const sec = Math.ceil(remaining / 1000); + const pct = clamp(100 * (remaining / 1000) / Math.max(1, totalSec), 0, 100); + ring.style.setProperty("--pct", pct.toFixed(2)); + const num = ring.querySelector(".num"); + if (num) num.textContent = `${sec}s`; + const isUrgent = remaining > 0 && remaining <= 10000; + ring.classList.toggle("urgent", isUrgent); + if (remaining <= 0) { + ring.classList.add("spent"); + stopCountdown(); + } + }; + tick(); + store.countdownTimer = setInterval(tick, 250); +} + +function stopCountdown() { + if (store.countdownTimer) clearInterval(store.countdownTimer); + store.countdownTimer = null; +} + +// -------------------------------------------------------------- +// Boot +// -------------------------------------------------------------- + +boot(); diff --git a/static/quiz.js b/static/quiz.js index 4b26ee4..ed54fbd 100644 --- a/static/quiz.js +++ b/static/quiz.js @@ -34,6 +34,61 @@ const RECONNECT = { let countdownTimer = null; +/* Tab-blur audit. We POST a server event whenever the student + * backgrounds the page (visibilitychange) or moves focus away from the + * window (blur). Both are debounced so a rapid alt-tab roundtrip + * doesn't spam events. The server records each event in `student_events` + * and surfaces a count to the instructor presence panel. + * + * We only ping during a question_open state — switching tabs between + * questions is fine and we don't want to noise the audit. */ +const FOCUS = { + lastBlur: 0, + lastHidden: 0, + debounceMs: 1500, +}; + +function postEvent(kind) { + if (!sid || !store.currentQuestion || store.submitted) return; + // Use sendBeacon when leaving the page so the event survives the + // navigation; otherwise fetch with credentials so the cookie rides. + const body = JSON.stringify({ kind, question_idx: store.currentQuestion.question_idx }); + const url = `/api/session/${sid}/event`; + if (kind === "visibility_hidden" && navigator.sendBeacon) { + const blob = new Blob([body], { type: "application/json" }); + navigator.sendBeacon(url, blob); + return; + } + fetch(url, { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body, + keepalive: true, + }).catch(() => {}); +} + +function onBlur() { + const now = Date.now(); + if (now - FOCUS.lastBlur < FOCUS.debounceMs) return; + FOCUS.lastBlur = now; + postEvent("blur"); +} + +function onVisibility() { + const now = Date.now(); + if (document.visibilityState === "hidden") { + if (now - FOCUS.lastHidden < FOCUS.debounceMs) return; + FOCUS.lastHidden = now; + postEvent("visibility_hidden"); + } else if (document.visibilityState === "visible") { + postEvent("visibility_visible"); + } +} + +window.addEventListener("blur", onBlur); +document.addEventListener("visibilitychange", onVisibility); + function fmtScore(value) { // Scores are floats on a 0.05 grid in [0, 1]. Display as a fixed // two-decimal string so users see e.g. "0.85" instead of @@ -136,7 +191,15 @@ function renderJoin(error = null) { connect(); } catch (err) { submit.disabled = false; - renderJoin(err.message || "Could not join."); + let msg = err.message || "Could not join."; + // The /join endpoint returns the FastAPI default JSON error envelope + // ({"detail": "..."}) — surface the human-readable detail rather + // than the raw JSON blob in the alert. + try { + const parsed = JSON.parse(msg); + if (parsed && parsed.detail) msg = parsed.detail; + } catch {} + renderJoin(msg); } }); } diff --git a/static/style.css b/static/style.css index 59adab3..34b6023 100644 --- a/static/style.css +++ b/static/style.css @@ -1256,6 +1256,145 @@ h2.question-text.small { .options.student-reveal li.yours.correct::before { color: var(--correct-border); } .options.student-reveal li.yours.wrong-pick::before { color: var(--wrong-border); } +/* ---------- Live presence panel (admin) ---------- */ + +.presence-panel { padding: 18px 20px 16px; } + +.presence-list { + list-style: none; + margin: 0 0 12px; + padding: 0; + display: grid; + gap: 0; + max-height: 420px; + overflow-y: auto; +} +.presence-row { + display: grid; + grid-template-columns: 14px 1fr auto auto; + gap: 10px; + align-items: center; + padding: 9px 0; + border-bottom: 1px dotted var(--border); +} +.presence-row:last-child { border-bottom: 0; } +.presence-row .dot { + width: 8px; + height: 8px; + border-radius: 999px; + background: var(--muted-2); + flex-shrink: 0; + transition: background 0.4s ease, box-shadow 0.4s ease; +} +.presence-row.is-online .dot { + background: var(--correct-border); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--correct-border) 18%, transparent); +} +.presence-row.is-online.is-fresh .dot { + animation: rosterDotPulse 2.2s ease-in-out infinite; +} +.presence-row.is-stale .dot { background: var(--warn); } +.presence-row.is-offline .dot { background: var(--muted-2); } + +.presence-row .who { display: grid; line-height: 1.2; min-width: 0; } +.presence-row .who b { + font-weight: 500; + font-size: 0.92rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.presence-row .who small { + color: var(--muted); + font-size: 0.7rem; + font-family: var(--font-mono); + letter-spacing: 0; +} + +.presence-flags { + display: inline-flex; + gap: 4px; + align-items: center; + flex-wrap: wrap; +} +.flag { + font-family: var(--font-mono); + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0; + padding: 2px 7px; + border-radius: 0; + border: 1px solid var(--border); + color: var(--muted); + background: var(--surface); + font-variant-numeric: tabular-nums; + min-width: 18px; + text-align: center; +} +.flag-ok { color: var(--correct-border); border-color: var(--correct-border); } +.flag-pending { color: var(--muted-2); } +.flag-warn { color: var(--warn); border-color: var(--warn); } +.flag-danger { + color: var(--danger-text); + background: var(--danger); + border-color: var(--danger); +} + +.btn.xtiny { + padding: 2px 8px; + font-size: 0.78rem; + min-height: 0; + letter-spacing: 0; + text-transform: none; + border-radius: 2px; +} + +.legend-dot { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 999px; + margin-right: 4px; + margin-left: 8px; + vertical-align: middle; + background: var(--muted-2); +} +.legend-dot:first-child { margin-left: 0; } +.legend-dot.is-online { background: var(--correct-border); } +.legend-dot.is-stale { background: var(--warn); } +.legend-dot.is-offline { background: var(--muted-2); } + +.xsmall { font-size: 0.72rem; letter-spacing: 0.04em; } + +.duplicate-alerts { + margin: 0 32px 16px; +} +.duplicate-alerts .alert-title { + font-family: var(--font-sans); + font-size: 0.72rem; + letter-spacing: 0.18em; + text-transform: uppercase; + font-weight: 700; + color: var(--danger); + margin: 0 0 6px; + padding: 0; + border: 0; +} +.dup-list { + list-style: none; + padding: 0; + margin: 0 0 6px; + display: grid; + gap: 4px; + font-family: var(--font-mono); + font-size: 0.85rem; +} +.dup-list li { display: flex; gap: 12px; align-items: baseline; } + +@media (max-width: 900px) { + .duplicate-alerts { margin: 0 18px 12px; } +} + /* ---------- Responsive: mobile student view ---------- */ @media (max-width: 480px) { diff --git a/tests/test_anti_cheat.py b/tests/test_anti_cheat.py new file mode 100644 index 0000000..468d031 --- /dev/null +++ b/tests/test_anti_cheat.py @@ -0,0 +1,89 @@ +"""Anti-cheat / audit-event coverage: + - tab-blur events are recorded and surfaced in CSV + presence + - duplicate-join attempts are 409 + audited + - admin clear-student removes the participant + submissions + - submit lockout (one answer per Q per student) is server-enforced +""" + +from __future__ import annotations + +from conftest import admin_login, join_student + + +def test_blur_event_is_logged_and_counted(client, sid): + join_student(client, sid, "s1", "Alice") + response = client.post(f"/api/session/{sid}/event", json={"kind": "blur", "question_idx": 0}) + assert response.status_code == 200 + response = client.post(f"/api/session/{sid}/event", json={"kind": "blur", "question_idx": 0}) + assert response.status_code == 200 + response = client.post(f"/api/session/{sid}/event", json={"kind": "visibility_hidden"}) + assert response.status_code == 200 + + # The event count is exposed via CSV export. Two blur events + one + # visibility_hidden event should land on the s1 row. + admin_login(client) + csv_text = client.get("/admin/api/csv").text + s1_row = next(line for line in csv_text.splitlines() if ",s1," in line) + # Trailing fields are blur_count, hidden_count, duplicate_join_attempts. + assert s1_row.endswith(",2,1,0"), s1_row + + +def test_event_endpoint_rejects_unknown_kind(client, sid): + join_student(client, sid, "s1", "Alice") + response = client.post(f"/api/session/{sid}/event", json={"kind": "screenshot"}) + assert response.status_code == 422 + + +def test_event_endpoint_requires_student_cookie(client, sid): + response = client.post(f"/api/session/{sid}/event", json={"kind": "blur"}) + assert response.status_code == 401 + + +def test_duplicate_join_is_logged_in_csv(client, sid): + """A 409 join attempt records a `duplicate_join` audit row whose + count rolls up into CSV + presence_update.""" + join_student(client, sid, "s1", "Alice") + # Second client tries to claim s1 from a fresh cookie jar. + fresh = client.__class__(client.app) + response = fresh.post(f"/api/session/{sid}/join", json={"student_id": "s1", "name": "Hijacker"}) + assert response.status_code == 409 + + admin_login(client) + csv_text = client.get("/admin/api/csv").text + s1_row = next(line for line in csv_text.splitlines() if ",s1," in line) + assert s1_row.endswith(",0,0,1"), s1_row + + +def test_admin_clear_student_frees_id(client, sid): + """First-claim-wins recovery: admin can clear a participant so the + legitimate student (or anyone, since there's no further identity + check) can re-join with that id.""" + join_student(client, sid, "s1", "Alice") + admin_login(client) + response = client.delete("/admin/api/students/s1") + assert response.status_code == 200 + # The slot is now free; the same id can be re-claimed from a fresh + # cookie jar. + fresh = client.__class__(client.app) + response = fresh.post(f"/api/session/{sid}/join", json={"student_id": "s1", "name": "Alice Again"}) + assert response.status_code == 200 + + +def test_admin_clear_student_404s_when_no_match(client, sid): + admin_login(client) + assert client.delete("/admin/api/students/nobody").status_code == 404 + + +def test_submit_lockout_is_server_enforced(client, sid): + """Server-side: a second submit for the same (sid, student_id, qidx) + returns the *original* ack rather than overwriting the answer. The + PK constraint + existing_submit_ack early-return guarantees this.""" + join_student(client, sid, "s1", "Alice") + rooms = client.app.state.rooms + client.portal.call(rooms.open_question, sid, 0, 5) + first = client.portal.call(rooms.submit_answer, sid, "s1", 0, "B") + second = client.portal.call(rooms.submit_answer, sid, "s1", 0, "C") + assert first["type"] == "submit_ack" + assert second["type"] == "submit_ack" + assert second["answer"] == first["answer"] == "B" + assert second["score"] == first["score"] diff --git a/tests/test_api_student.py b/tests/test_api_student.py index 88c4698..2259c73 100644 --- a/tests/test_api_student.py +++ b/tests/test_api_student.py @@ -11,14 +11,25 @@ def test_session_metadata_join_me_and_stats(client, sid): assert join["ok"] is True assert "qz_student" in client.cookies - join_student(client, sid, "s1", "Updated Name") me = client.get(f"/api/session/{sid}/me") assert me.status_code == 200 - assert me.json()["name"] == "Updated Name" + assert me.json()["name"] == "First Name" stats = client.get(f"/api/session/{sid}/stats").json() assert stats["question_idx"] is None - assert stats["top5"][0]["name"] == "Updated Name" + assert stats["top5"][0]["name"] == "First Name" + + +def test_duplicate_student_id_join_is_rejected(client, sid): + """First-claim-wins anti-hijack: a second join attempting the same + student_id must 409 (without overwriting name or rotating the cookie). + The original cookie keeps working; recovery is via admin clear-student.""" + join_student(client, sid, "s1", "First Name") + response = client.post(f"/api/session/{sid}/join", json={"student_id": "s1", "name": "Hijacker"}) + assert response.status_code == 409 + assert "already in use" in response.text.lower() + me = client.get(f"/api/session/{sid}/me").json() + assert me["name"] == "First Name" def test_root_without_sid_redirects_to_canonical(client, sid): diff --git a/tests/test_csv_export.py b/tests/test_csv_export.py index 4d95a08..1a86f4f 100644 --- a/tests/test_csv_export.py +++ b/tests/test_csv_export.py @@ -12,6 +12,9 @@ def test_csv_export_contains_one_row_per_submission(client, sid): response = client.get("/admin/api/csv") lines = response.text.strip().splitlines() - assert lines[0] == "sid,student_id,name,question_idx,answer,elapsed_ms,score,status" + assert lines[0] == "sid,student_id,name,question_idx,answer,elapsed_ms,score,status,blur_count,hidden_count,duplicate_join_attempts" assert len(lines) == 2 assert ",s1,Student One,0,B," in lines[1] + # Default audit-event counts are 0 for a clean run (no blur events, + # no duplicate-join attempts). + assert lines[1].endswith(",0,0,0") diff --git a/tests/test_projector.py b/tests/test_projector.py new file mode 100644 index 0000000..749cfd9 --- /dev/null +++ b/tests/test_projector.py @@ -0,0 +1,76 @@ +"""Projector view (public read-only): + - snapshot endpoint returns the expected shape + - leaderboard never carries student_ids (privacy) + - WS client receives a projector_state message on connect + - state changes (open question, submit, close) push fresh snapshots +""" + +from __future__ import annotations + +import pytest +from starlette.websockets import WebSocketDisconnect + +from conftest import admin_login, join_student + + +def test_projector_snapshot_includes_required_fields(client, sid): + join_student(client, sid, "s1", "Alice") + response = client.get(f"/api/session/{sid}/projector") + assert response.status_code == 200 + body = response.json() + assert body["type"] == "projector_state" + assert body["state"] == "lobby" + assert body["sid"] == sid + assert body["participant_count"] == 1 + assert "qr_url" in body and body["qr_url"].startswith("data:image/svg+xml") + assert "join_url" in body + assert body["pool_meta"]["question_count"] >= 1 + assert "score_distribution" in body + assert "leaderboard" in body + + +def test_projector_leaderboard_redacts_student_ids(client, sid): + """The /admin board carries student_ids; the public projector + leaderboard must NOT — student_id namespace is private.""" + join_student(client, sid, "s1", "Alice") + join_student(client, sid, "s2", "Bob") + rooms = client.app.state.rooms + client.portal.call(rooms.open_question, sid, 0, 5) + client.portal.call(rooms.submit_answer, sid, "s1", 0, "B") + client.portal.call(rooms.close_question, sid) + + snapshot = client.get(f"/api/session/{sid}/projector").json() + for row in snapshot["leaderboard"]: + assert "student_id" not in row, "projector leaderboard leaks student_ids" + + +def test_projector_ws_pushes_snapshot_on_state_change(client, sid): + join_student(client, sid, "s1", "Alice") + admin_login(client) + with client.websocket_connect(f"/ws/projector/{sid}") as ws: + initial = ws.receive_json() + assert initial["type"] == "projector_state" + assert initial["state"] == "lobby" + + # Trigger a state change via the room manager directly. + rooms = client.app.state.rooms + client.portal.call(rooms.open_question, sid, 0, 5) + push = ws.receive_json() + assert push["type"] == "projector_state" + assert push["state"] == "question_open" + assert push["question"] is not None + assert push["question"]["idx"] == 0 + + +def test_projector_404_for_unknown_sid(client): + assert client.get("/api/session/UNKNOWN/projector").status_code == 404 + with pytest.raises(WebSocketDisconnect) as exc: + with client.websocket_connect("/ws/projector/UNKNOWN"): + pass + assert exc.value.code == 4001 + + +def test_projector_page_redirects_when_no_sid(client, sid): + response = client.get("/projector/", follow_redirects=False) + assert response.status_code == 302 + assert response.headers["location"].endswith(f"sid={sid}") diff --git a/tests/test_ws_admin.py b/tests/test_ws_admin.py index ec8d846..354b9dd 100644 --- a/tests/test_ws_admin.py +++ b/tests/test_ws_admin.py @@ -11,6 +11,17 @@ def test_instructor_ws_requires_admin_cookie(client, sid): assert exc.value.code == 4001 +def _drain_until(ws, target_type, max_msgs=12): + """Helper: pull messages off `ws` until one matches `target_type`. Lets + tests skip auxiliary state-tracking messages (presence_update, + full_leaderboard) that fire as side-effects of state changes.""" + for _ in range(max_msgs): + msg = ws.receive_json() + if msg["type"] == target_type: + return msg + raise AssertionError(f"did not see message {target_type!r} after {max_msgs} attempts") + + def test_instructor_next_command_drives_full_loop(client, sid): """The 'next' WS message drives the entire lifecycle: lobby → opens Q0 → closes Q0 + opens Q1 → ... → closes last + ends.""" @@ -19,16 +30,14 @@ def test_instructor_next_command_drives_full_loop(client, sid): with client.websocket_connect(f"/ws/student/{sid}") as student_ws: assert student_ws.receive_json()["type"] == "state" with client.websocket_connect(f"/ws/instructor/{sid}") as admin_ws: - # Drain lobby snapshot. - assert admin_ws.receive_json()["type"] == "state" - assert admin_ws.receive_json()["type"] == "lobby_update" + # Drain lobby snapshot (state + lobby_update + presence_update). + _drain_until(admin_ws, "presence_update") # First "next" opens Q0 from lobby. admin_ws.send_json({"type": "next"}) assert student_ws.receive_json()["type"] == "question_open" - admin_open = admin_ws.receive_json() - assert admin_open["type"] == "question_open" - assert admin_ws.receive_json()["type"] == "live_histogram" + _drain_until(admin_ws, "question_open") + _drain_until(admin_ws, "live_histogram") # Second "next" closes Q0 and opens Q1. admin_ws.send_json({"type": "next"}) @@ -42,17 +51,16 @@ def test_instructor_close_then_next_emits_clean_open(client, sid): with client.websocket_connect(f"/ws/student/{sid}") as student_ws: assert student_ws.receive_json()["type"] == "state" with client.websocket_connect(f"/ws/instructor/{sid}") as admin_ws: - assert admin_ws.receive_json()["type"] == "state" - assert admin_ws.receive_json()["type"] == "lobby_update" + _drain_until(admin_ws, "presence_update") admin_ws.send_json({"type": "open_question", "question_idx": 0, "time_limit": 2}) assert student_ws.receive_json()["type"] == "question_open" - assert admin_ws.receive_json()["type"] == "question_open" - assert admin_ws.receive_json()["type"] == "live_histogram" + _drain_until(admin_ws, "question_open") + _drain_until(admin_ws, "live_histogram") admin_ws.send_json({"type": "close_question"}) assert student_ws.receive_json()["type"] == "question_closed" - admin_msgs = [admin_ws.receive_json(), admin_ws.receive_json()] - assert {m["type"] for m in admin_msgs} == {"question_closed", "full_leaderboard"} + _drain_until(admin_ws, "question_closed") + _drain_until(admin_ws, "full_leaderboard") admin_ws.send_json({"type": "next"}) assert student_ws.receive_json()["type"] == "question_open" @@ -62,16 +70,12 @@ def test_reset_command_returns_session_to_lobby(client, sid): join_student(client, sid, "s1", "Student One") admin_login(client) with client.websocket_connect(f"/ws/instructor/{sid}") as admin_ws: - assert admin_ws.receive_json()["type"] == "state" - assert admin_ws.receive_json()["type"] == "lobby_update" + _drain_until(admin_ws, "presence_update") admin_ws.send_json({"type": "open_question", "question_idx": 0, "time_limit": 2}) - assert admin_ws.receive_json()["type"] == "question_open" - assert admin_ws.receive_json()["type"] == "live_histogram" + _drain_until(admin_ws, "question_open") + _drain_until(admin_ws, "live_histogram") admin_ws.send_json({"type": "reset"}) - # After reset, the instructor receives a state=lobby snapshot + lobby_update. - msgs = [] - while len(msgs) < 2: - msgs.append(admin_ws.receive_json()) - types = [m["type"] for m in msgs] - assert "state" in types + # After reset, instructor receives a state=lobby snapshot. + msg = _drain_until(admin_ws, "state") + assert msg["state"] == "lobby"