feat: anti-cheat + presence panel + projector view

Refinement pass on top of the validated v1.2 base.

Hardening / quick fixes
- Caddyfile.tpl: CSP / HSTS / X-Frame / Referrer-Policy / Permissions-Policy,
  1 MiB request body cap, X-Forwarded-For pass-through; stock-Caddy compatible.
- auth.py: STUDENT_MAX_AGE 1y -> 30d.
- bootstrap.sh: stage 5 prepends `git config --add safe.directory` so the
  re-bootstrap path no longer 'detected dubious ownership' faults.
- admin.js: drop the misleading `closedPayload?.state || session.state`
  shim; state derives from session only.

Anti-cheat
- New `student_events` audit table; new POST /api/session/{sid}/event for
  blur / visibility_hidden / focus / visibility_visible. quiz.js debounces
  events at 1.5s and uses sendBeacon for visibility_hidden so the event
  survives a navigation. Counts surface in admin presence + CSV export.
- First-claim-wins on join: add_participant raises DuplicateStudentId on
  PK violation; route returns 409 + records a duplicate_join audit event
  with attempted name + IP + UA. Admin dashboard surfaces a per-row red
  badge for hits on real participants and a top-of-page alert for orphan
  attempts.
- DELETE /admin/api/students/{id} as the recovery hatch: clears the
  participant + submissions, kicks active WS sockets so a stale cookie
  cannot continue submitting. quiz.js surfaces the FastAPI detail message
  in the join form so users see the 'already in use' guidance.

Presence panel
- New presence_update WS message; in-process presence map keyed on
  student_id tracks ws_count + last_seen_ms. Admin dashboard renders
  per-student rows: connected/idle/dropped dot, blur+hidden+duplicate
  badges, 'answered current Q' tick, and a clear-student button.

Projector view (public, read-only)
- /projector/?sid=..., GET /api/session/{sid}/projector, WS
  /ws/projector/{sid}. Single self-contained projector_state snapshot
  pushed on every state change. Public leaderboard strips student_id;
  QR rendered server-side as data: URL (CSP-compliant).
- Includes per-Q answer histogram, 8-bucket response-time distribution,
  10-bucket score distribution.
- static/projector.{html,css,js}: editorial-broadside design — masthead,
  registration crosses, conic-gradient countdown ring, SVG stepped-area
  score distribution with median tick, leaderboard row-stagger. Inherits
  light/dark tokens from style.css; honours prefers-reduced-motion. No
  scroll at 1366x768 / 1920x1080 / 3440x1440.

Tests
- tests/test_anti_cheat.py: blur logging + CSV count, unknown-kind 422,
  unauthenticated event 401, duplicate-join 409 + audit, admin
  clear-student happy + 404, server-side submit lockout regression.
- tests/test_projector.py: snapshot shape, leaderboard student_id
  redaction, WS push on state change, 404 for unknown sid, page redirect
  when no sid.
- Existing tests updated for the new presence_update snapshot frame +
  CSV header columns + first-claim-wins refusal of re-key.

57/57 pytest green; smoke-tested locally end-to-end.
This commit is contained in:
ameer
2026-05-04 16:08:59 +08:00
parent f38722ed66
commit 9ea0a8b039
20 changed files with 3029 additions and 72 deletions

View File

@@ -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

View File

@@ -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()

View File

@@ -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);
"""

View File

@@ -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)

View File

@@ -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:

View File

@@ -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}"

View File

@@ -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(
"<!doctype html><meta charset='utf-8'>"
"<link rel='stylesheet' href='/static/style.css'>"
"<title>Projector — quiz unavailable</title>"
"<main class='centered-shell'><div class='card narrow'>"
"<h1>Projector — no live session</h1>"
"<p class='muted'>Start the quiz from the admin dashboard.</p>"
"</div></main>",
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

View File

@@ -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}
}
}

View File

@@ -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"

View File

@@ -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 = `
<header class="topbar">
<div class="topbar-title">
@@ -143,10 +149,11 @@ function renderDashboard() {
</div>
</header>
${store.notice ? `<div class="alert info">${escapeText(store.notice)}</div>` : ""}
${renderDuplicateJoinAlerts()}
<section class="dashboard">
<aside class="dashboard-side">
${renderJoinPanel()}
${renderRosterPanel()}
${renderPresencePanel()}
</aside>
<main class="dashboard-main">
${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 `
<div class="card panel">
<h2>Joined <span class="count">${rosterCount}</span></h2>
<p class="muted">No students have joined yet. Share the QR or URL.</p>
</div>
`;
}
const currentQIdx = store.currentQIdx ?? store.session?.current_question_idx;
const isQuestionOpen = currentQIdx != null && store.session?.state === "question_open";
return `
<div class="card panel">
<h2>Joined <span class="count">${r.length}</span></h2>
${ordered.length
? `<ul class="roster">${ordered.map((p, i) =>
`<li class="${i < 3 ? "is-fresh" : ""}"><span class="dot"></span><span class="who"><b>${escapeText(p.name)}</b><small>${escapeText(p.student_id)}</small></span></li>`
).join("")}</ul>`
: `<p class="muted">No students have joined yet. Share the QR or URL.</p>`}
<div class="card panel presence-panel">
<h2>Presence <span class="count">${connected}/${presence.length}</span></h2>
<ul class="presence-list">
${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 `
<li class="presence-row ${dotState} ${fresh}" data-student-id="${escapeText(row.student_id)}">
<span class="dot" title="${row.connected ? "Connected" : "Disconnected"}"></span>
<span class="who">
<b>${escapeText(row.name)}</b>
<small>${escapeText(row.student_id)}</small>
</span>
<span class="presence-flags">
${isQuestionOpen
? `<span class="flag ${answered ? "flag-ok" : "flag-pending"}" title="${answered ? "Answered current question" : "Has not answered current question"}">${answered ? "✓" : "·"}</span>`
: ""}
${blur > 0 ? `<span class="flag flag-warn" title="Tab blur events">${blur}↗</span>` : ""}
${hidden > 0 ? `<span class="flag flag-warn" title="Tab hidden events">${hidden}◌</span>` : ""}
${dupCount > 0 ? `<span class="flag flag-danger" title="Duplicate-join attempts">!${dupCount}</span>` : ""}
</span>
<button class="btn ghost xtiny" data-clear-student="${escapeText(row.student_id)}" title="Remove this student so they can re-join (recovery for hijack / lost cookie)">×</button>
</li>
`;
}).join("")}
</ul>
<p class="muted xsmall">
<span class="legend-dot is-online"></span> connected
<span class="legend-dot is-stale"></span> idle
<span class="legend-dot is-offline"></span> dropped
</p>
</div>
`;
}
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 `
<div class="alert error duplicate-alerts">
<h2 class="alert-title">Suspicious join attempts</h2>
<ul class="dup-list">
${orphans.map((o) => `
<li>
<code>${escapeText(o.student_id)}</code>
<span class="muted small">${o.count}× attempted${o.latest_ts ? ` · last ${escapeText(o.latest_ts)}` : ""}</span>
</li>
`).join("")}
</ul>
<p class="muted small">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.</p>
</div>
`;
}
@@ -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;

1204
static/projector.css Normal file

File diff suppressed because it is too large Load Diff

16
static/projector.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Quiz — Projector</title>
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/projector.css">
</head>
<body class="projector-body">
<main id="projector-app" aria-live="polite">
<div class="bootstrap-loading">Loading projector</div>
</main>
<script type="module" src="/static/projector.js"></script>
</body>
</html>

676
static/projector.js Normal file
View File

@@ -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) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
})[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 = `
<section class="projector-shell">
<span class="reg-tr"></span><span class="reg-bl"></span>
<header class="projector-topbar">
<div class="topbar-left"><span class="brand">Live Quiz</span></div>
<div class="topbar-mid"></div>
<div class="topbar-right"></div>
</header>
<div class="projector-card fatal-card">
<h1 class="lobby-headline">Projector view</h1>
<p class="lobby-sub">Open <code>/projector/?sid=&lt;your-sid&gt;</code></p>
</div>
<footer class="projector-foot">
<span class="left"><span class="dot dim"></span> offline</span>
<span class="center"></span>
<span class="right">no session</span>
</footer>
</section>`;
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 = `
<section class="projector-shell">
<span class="reg-tr"></span><span class="reg-bl"></span>
<header class="projector-topbar">
<div class="topbar-left"><span class="brand">Live Quiz</span></div>
<div class="topbar-mid"></div>
<div class="topbar-right"></div>
</header>
<div class="projector-card fatal-card">
<h1 class="lobby-headline">Quiz unavailable</h1>
<p class="lobby-sub">No live session at <code>${escapeText(sid)}</code>.</p>
</div>
<footer class="projector-foot">
<span class="left"><span class="dot dim"></span> offline</span>
<span class="center"></span>
<span class="right">${escapeText(sid)}</span>
</footer>
</section>`;
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)
: `<div class="projector-card"><p class="muted">State: ${escapeText(s.state)}</p></div>`;
app.innerHTML = `
<section class="projector-shell" data-state="${escapeText(s.state)}">
<span class="reg-tr"></span><span class="reg-bl"></span>
${renderTopbar(s)}
${view}
${renderFoot(s)}
</section>
`;
// 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 `
<header class="projector-topbar">
<div class="topbar-left">
<span class="brand">Live Quiz</span>
<h1 class="topbar-title">${escapeText(s.title || "Quiz")}</h1>
</div>
<div class="topbar-mid">
${showQ
? `<span class="folio">Question <b>${idx + 1}</b> of <b>${total}</b></span>`
: (total ? `<span class="folio"><b>${total}</b> questions</span>` : "")
}
<span class="state-badge state-${escapeText(s.state)}">${escapeText(stateLabel)}</span>
</div>
<div class="topbar-right">
${s.sid ? `<span class="folio">SID <b>${escapeText(s.sid)}</b></span>` : ""}
<span class="folio">${formatClock(s.server_ts)}</span>
</div>
</header>
`;
}
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 `
<footer class="projector-foot">
<span class="left"><span class="${dotClass}"></span> ${status}</span>
<span class="center">${escapeText(s.title || "")}</span>
<span class="right">${escapeText(right)}</span>
</footer>
`;
}
// --------------------------------------------------------------
// 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 `
<div class="projector-grid lobby">
<div class="projector-card join-card">
<div>
<p class="lobby-eyebrow">Scan to join</p>
<h2 class="lobby-headline">Open the quiz on your phone.</h2>
<p class="lobby-sub">Point your camera at the code, or type the address below into a browser.</p>
</div>
<div class="qr-frame">
<div class="qr-big"><img src="${escapeAttr(s.qr_url || "")}" alt="Join QR code"></div>
</div>
<div class="lobby-url">${escapeText(s.join_url || "")}</div>
</div>
<div class="projector-card lobby-status">
<p class="lobby-eyebrow">Joined so far</p>
<div class="participant-count">
<b>${n}</b>
<div class="label">
<span class="word">student${n === 1 ? "" : "s"} ready,</span>
<span class="meta">↳ waiting on instructor</span>
</div>
</div>
<ol class="constellation" aria-label="${n} participants joined">
${Array.from({ length: dots }).map((_, i) => {
const d = (i % 24) * 18;
return `<li style="--d:${d}ms"></li>`;
}).join("")}
</ol>
<div>
<div class="lobby-rule"> how it runs </div>
<div class="lobby-meta-grid">
<div class="cell"><span class="v">${qcount}</span><span class="k">Questions</span></div>
<div class="cell"><span class="v">${time}s</span><span class="k">Per question</span></div>
<div class="cell"><span class="v">${escapeText(scoreFn)}</span><span class="k">Scoring</span></div>
</div>
</div>
</div>
</div>
`;
}
// --------------------------------------------------------------
// State: QUESTION (open + closed/reveal)
// --------------------------------------------------------------
function renderQuestion(s, revealed) {
const q = s.question;
if (!q) return `<div class="projector-card"><p class="muted">Loading question</p></div>`;
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 `
<div class="projector-grid question">
<div class="projector-card question-card">
<div class="question-head">
<h2 class="big-question">${escapeText(q.text)}</h2>
<div class="${ringClass}" id="big-countdown"
style="--pct:${initialPct}"
role="timer" aria-label="time remaining">
<span class="num">${revealed ? "0s" : initialSec + "s"}</span>
</div>
</div>
<ol class="big-options ${revealed ? "revealed" : ""} ${preVote ? "pre-vote" : ""}">
${["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 `
<li class="${cls}">
<span class="opt-key">${k}</span>
<span class="opt-text">${escapeText(q.options?.[k] || "")}</span>
<span class="opt-bar"><span class="opt-bar-fill" style="width:${pct}%"></span></span>
<span class="opt-count">${v}<small>${pct}%</small></span>
</li>
`;
}).join("")}
</ol>
${revealed && reveal?.explanation
? `<p class="big-explanation">${escapeText(reveal.explanation)}</p>`
: `<div class="submission-strip">
<span class="label">Submissions</span>
<span class="track"><span class="fill" style="--p:${submittedPct.toFixed(1)}%"></span></span>
<span class="nums">${submitted}<small>of ${s.live_histogram?.total_count || s.participant_count || 0}</small></span>
</div>`
}
</div>
<div class="projector-card side-card">
<p class="card-eyebrow">Response time</p>
${renderResponseTime(s.response_time_distribution)}
<p class="card-eyebrow">Top 5</p>
${renderLeaderboard((s.leaderboard || []).slice(0, 5))}
<p class="side-meta">${submitted} of ${s.live_histogram?.total_count || s.participant_count || 0} answered</p>
</div>
</div>
`;
}
// --------------------------------------------------------------
// 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 `
<div class="projector-grid between">
<div class="projector-card">
<p class="card-eyebrow">Score distribution</p>
${renderScoreArea(s.score_distribution)}
<p class="side-meta">${escapeText(next)}</p>
</div>
<div class="projector-card">
<p class="card-eyebrow">Standings</p>
${renderLeaderboard((s.leaderboard || []).slice(0, 10))}
</div>
</div>
`;
}
// --------------------------------------------------------------
// 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 `
<div class="projector-grid finished">
<div class="projector-card finished-grid">
<div class="finished-banner">
<span class="kicker">— The Final Tally —</span>
<h2>${headline}</h2>
<p class="summary">${dist?.n ?? 0} student${(dist?.n ?? 0) === 1 ? "" : "s"} answered &middot; max possible ${(dist?.max_total ?? 0).toFixed(1)} points</p>
</div>
${renderScoreArea(dist)}
</div>
<div class="projector-card">
<p class="card-eyebrow">Final leaderboard</p>
${renderLeaderboard(s.leaderboard || [])}
</div>
</div>
`;
}
// --------------------------------------------------------------
// Leaderboard
// --------------------------------------------------------------
function renderLeaderboard(rows) {
if (!rows || !rows.length) {
return `<div class="empty-state"><span class="glyph">— no scores yet —</span><p>Standings appear after the first question is scored.</p></div>`;
}
return `
<ol class="big-leaderboard">
${rows.map((r, i) => `
<li style="--d:${i * 35}ms">
<span class="rank">${r.rank}</span>
<span class="name">${escapeText(r.name)}</span>
<span class="score">${fmtScore(r.score)}</span>
</li>
`).join("")}
</ol>
`;
}
// --------------------------------------------------------------
// Charts
// --------------------------------------------------------------
/** Vertical bar chart with axis baseline + gridlines (CSS-driven). */
function renderResponseTime(dist) {
if (!dist || !dist.total) {
return `<div class="empty-state"><span class="glyph">— awaiting submissions —</span></div>`;
}
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 `
<div class="bar-cell">
<span class="bar-fill" style="--h:${h}%" data-empty="${empty}"></span>
</div>`;
}).join("");
const nums = dist.buckets.map((b) => `<span class="bar-num">${b.count}</span>`).join("");
const labels = dist.buckets.map((b) => `<span class="bar-label">${escapeText(b.label)}</span>`).join("");
return `
<div class="bar-chart small">
<div class="bars">${cells}</div>
<div class="nums">${nums}</div>
<div class="labels">${labels}</div>
</div>
`;
}
/**
* 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 `<div class="empty-state"><span class="glyph">— scores not yet tallied —</span><p>The distribution appears after the first question is scored.</p></div>`;
}
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 `
<line class="grid-line" x1="${padL}" x2="${padL + innerW}" y1="${y}" y2="${y}"></line>
<text class="y-tick-label" x="${padL - 8}" y="${y}">${v}</text>
`;
}).join("");
// X-axis tick labels at each bucket centre
const xLabels = buckets.map((b, i) => {
const cx = (xEdge(i) + xEdge(i + 1)) / 2;
return `<text class="x-tick-label" x="${cx}" y="${H - padB + 16}">${escapeText(b.label)}</text>`;
}).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 `<text class="data-label" x="${cx}" y="${cy - 12}">${b.count}</text>
<circle class="data-point" cx="${cx}" cy="${yFor(b.count)}" r="5.5"></circle>`;
}).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 = `
<line class="median-line" x1="${mx}" x2="${mx}" y1="${padT}" y2="${padT + innerH}"></line>
<text class="median-tag" x="${mx}" y="${padT - 6}" text-anchor="middle">median</text>
`;
}
// 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 `
<div class="area-chart">
<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img" aria-label="Score distribution">
${yGrid}
<line class="axis" x1="${padL}" x2="${padL + innerW}" y1="${padT + innerH}" y2="${padT + innerH}"></line>
<line class="axis" x1="${padL}" x2="${padL}" y1="${padT}" y2="${padT + innerH}"></line>
<text class="axis-title" x="${padL + innerW / 2}" y="${H - 6}" text-anchor="middle">Score band (out of ${(dist.max_total || 0).toFixed(1)})</text>
<text class="axis-title" x="${padL - 36}" y="${padT + innerH / 2}" transform="rotate(-90 ${padL - 36} ${padT + innerH / 2})" text-anchor="middle">Students</text>
<path class="area-fill" d="${fillPath.join(" ")}"></path>
<path class="area-line" d="${linePath.join(" ")}"></path>
${dataLabels}
${medianMarks}
</svg>
<div class="chart-legend">
<span>10 score bands &middot; ${n} buckets</span>
<span class="stat">n = <b>${total}</b> &middot; mean <b>${mean.toFixed(2)}</b> &middot; max <b>${(dist.max_total || 0).toFixed(1)}</b></span>
</div>
</div>
`;
}
// --------------------------------------------------------------
// 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();

View File

@@ -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);
}
});
}

View File

@@ -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) {

89
tests/test_anti_cheat.py Normal file
View File

@@ -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"]

View File

@@ -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):

View File

@@ -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")

76
tests/test_projector.py Normal file
View File

@@ -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}")

View File

@@ -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"