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 `
+
+ `;
+}
+
+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 `
+
+ `;
+}
+
+/**
+ * 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 `
+
+
+
+ 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"