Files
quiz/static/quiz.js
ameer 9ea0a8b039 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.
2026-05-04 16:08:59 +08:00

548 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Student quiz SPA.
*
* Visit /?sid=<id>. If no cookie, render the join form. If cookie, open
* the student WS and follow server messages through the lifecycle:
* lobby → question_open → submitted → question_closed → … → session_ended
*
* The server is authoritative for state transitions and scoring. The
* client only animates the UI for whatever message the server sent.
*/
const app = document.querySelector("#app");
const params = new URLSearchParams(window.location.search);
const sid = params.get("sid");
const store = {
me: null,
ws: null,
currentQuestion: null,
submitted: null,
pickedAnswer: null,
deadlineMs: null,
};
// WS reconnect with exponential backoff. Total budget is ~27s across 8
// attempts (500ms, 1s, 2s, 4s, then 5s × 4), which covers typical mobile
// hand-off and Aliyun-edge TLS hiccups without giving up too quickly.
const RECONNECT = {
attempt: 0,
maxAttempts: 8,
baseDelayMs: 500,
maxDelayMs: 5000,
timer: null,
};
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
// "0.8500000000000001" when float math drifts in the leaderboard sum.
const n = Number(value || 0);
return n.toFixed(2);
}
function escapeText(value) {
return String(value ?? "").replace(/[&<>"']/g, (c) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
})[c]);
}
function setView(markup) {
app.innerHTML = `<section class="centered-shell">${markup}</section>`;
}
async function api(path, options = {}) {
const headers = options.body ? { "Content-Type": "application/json", ...(options.headers || {}) } : options.headers;
const response = await fetch(path, { credentials: "same-origin", ...options, headers });
if (!response.ok) {
const error = new Error(await response.text());
error.status = response.status;
throw error;
}
return response.json();
}
function showAskInstructor() {
setView(`
<div class="card narrow">
<h1>Ask your instructor for the link</h1>
<p class="muted">This quiz link is missing or no longer valid.</p>
</div>
`);
}
async function boot() {
if (!sid) {
showAskInstructor();
return;
}
try {
await api(`/api/session/${sid}`);
} catch {
showAskInstructor();
return;
}
try {
store.me = await api(`/api/session/${sid}/me`);
} catch (err) {
if (err.status === 401) {
renderJoin();
return;
}
showAskInstructor();
return;
}
connect();
}
function renderJoin(error = null) {
setView(`
<form id="join-form" class="card narrow stack">
<header class="card-header">
<h1>Join the quiz</h1>
<p class="muted">Enter your student ID and name. The cookie is per-device; clear it to switch.</p>
</header>
<label class="field">
<span>Student ID</span>
<input name="student_id" autocomplete="username" required autofocus>
</label>
<label class="field">
<span>Name</span>
<input name="name" autocomplete="name" required>
</label>
${error ? `<p class="alert error">${escapeText(error)}</p>` : ""}
<button type="submit" class="btn primary block">Join</button>
</form>
`);
document.querySelector("#join-form").addEventListener("submit", async (event) => {
event.preventDefault();
const submit = event.submitter || event.currentTarget.querySelector("button");
submit.disabled = true;
const data = new FormData(event.currentTarget);
try {
await api(`/api/session/${sid}/join`, {
method: "POST",
body: JSON.stringify({
student_id: data.get("student_id"),
name: data.get("name"),
}),
});
store.me = await api(`/api/session/${sid}/me`);
connect();
} catch (err) {
submit.disabled = false;
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);
}
});
}
function connect() {
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${protocol}://${window.location.host}/ws/student/${sid}`);
store.ws = ws;
ws.addEventListener("open", () => {
clearReconnectState();
});
ws.addEventListener("message", (event) => {
try { handleMessage(JSON.parse(event.data)); } catch (e) { console.warn("bad ws msg", e); }
});
ws.addEventListener("close", (event) => {
// session_reset already drove a re-boot; suppress the reconnect path
// so it doesn't fight with the "Re-joining…" interstitial.
if (store.resetting) return;
stopCountdown();
// 1008 = policy violation (server rejected the cookie / session).
// Retrying won't help; reload so /me re-checks auth and we land on
// the join form (or "ask your instructor") cleanly.
if (event.code === 1008) {
window.location.reload();
return;
}
scheduleReconnect();
});
ws.addEventListener("error", () => {
// The "close" event will fire next; reconnect handling lives there.
});
}
function scheduleReconnect() {
if (store.resetting) return;
if (RECONNECT.attempt >= RECONNECT.maxAttempts) {
showReconnectFailed();
return;
}
RECONNECT.attempt += 1;
const delay = Math.min(
RECONNECT.baseDelayMs * Math.pow(2, RECONNECT.attempt - 1),
RECONNECT.maxDelayMs
);
showReconnectingBanner(`Reconnecting… (${RECONNECT.attempt}/${RECONNECT.maxAttempts})`);
RECONNECT.timer = setTimeout(connect, delay);
}
function clearReconnectState() {
if (RECONNECT.timer) {
clearTimeout(RECONNECT.timer);
RECONNECT.timer = null;
}
RECONNECT.attempt = 0;
hideReconnectingBanner();
}
function showReconnectingBanner(text) {
let el = document.querySelector("#reconnect-banner");
if (!el) {
el = document.createElement("div");
el.id = "reconnect-banner";
el.className = "reconnect-banner";
el.setAttribute("role", "status");
el.setAttribute("aria-live", "polite");
document.body.appendChild(el);
}
el.textContent = text;
el.hidden = false;
}
function hideReconnectingBanner() {
const el = document.querySelector("#reconnect-banner");
if (el) el.hidden = true;
}
function showReconnectFailed() {
hideReconnectingBanner();
setView(`
<div class="card narrow center">
<h1>Disconnected</h1>
<p class="muted">We couldn't reconnect after several tries. Reload to try again.</p>
<button class="btn primary block" onclick="window.location.reload()">Reload</button>
</div>
`);
}
function handleMessage(message) {
switch (message.type) {
case "state": return renderState(message);
case "question_open": return renderQuestion(message);
case "submit_ack": return renderSubmitted(message);
case "question_closed": return renderReveal(message);
case "between_questions": return renderBetween(message);
case "session_ended": return renderFinished(message);
case "session_reset": return handleSessionReset();
case "error": return renderError(message);
}
}
function handleSessionReset() {
// Instructor cleared everyone out. Tear local state down and re-boot;
// /api/session/<sid>/me will return 401 (with cookie cleared by the
// server) and we'll land cleanly on the join form.
store.resetting = true;
stopCountdown();
clearReconnectState();
store.me = null;
store.currentQuestion = null;
store.submitted = null;
store.pickedAnswer = null;
if (store.ws) { try { store.ws.close(); } catch {} store.ws = null; }
setView(`
<div class="card narrow center">
<h1>Session reset</h1>
<p class="muted">Your instructor reset the session. Re-joining…</p>
<div class="spinner" aria-hidden="true"></div>
</div>
`);
setTimeout(() => { store.resetting = false; boot(); }, 600);
}
function renderState(message) {
store.currentQuestion = null;
store.submitted = null;
store.pickedAnswer = null;
stopCountdown();
if (message.state === "lobby") {
setView(`
<div class="card narrow center">
<p class="eyebrow">${escapeText(message.title || "Live quiz")}</p>
<h1>You're in.</h1>
<p class="muted">Hi <b>${escapeText(store.me?.name || "")}</b>. Waiting for your instructor to start.</p>
<div class="spinner" aria-hidden="true"></div>
</div>
`);
} else if (message.state === "finished") {
// Edge case: rejoin after the quiz already ended. Render a friendly
// placeholder and wait for a session_ended payload.
setView(`
<div class="card narrow center">
<h1>Quiz finished</h1>
<p class="muted">Final results coming through…</p>
</div>
`);
}
}
function renderQuestion(message) {
store.currentQuestion = message;
store.submitted = null;
store.pickedAnswer = null;
store.deadlineMs = Date.now() + (message.remaining_ms ?? message.time_limit * 1000);
setView(`
<article class="card quiz-card">
<div class="question-head">
<span class="qnum">Question ${message.question_idx + 1}</span>
<span id="countdown" class="countdown">—</span>
</div>
<div class="qbar"><span id="qbar-fill"></span></div>
<h1 class="question-text">${escapeText(message.text)}</h1>
<div class="answer-grid">
${["A","B","C","D"].map((k) => `
<button class="answer-btn" data-answer="${k}">
<span class="answer-key">${k}</span>
<span class="answer-text">${escapeText(message.options[k] || "")}</span>
</button>
`).join("")}
</div>
</article>
`);
document.querySelectorAll("[data-answer]").forEach((btn) => {
btn.addEventListener("click", () => submitAnswer(btn.dataset.answer));
});
startCountdown();
}
function submitAnswer(answer) {
if (!store.currentQuestion || store.submitted || store.pickedAnswer) return;
// Drop the click silently if the WS isn't open right now (mid-reconnect
// or already torn down). On reconnect the server replays question_open
// for the same qidx, which re-renders the card with buttons re-enabled,
// so the student just clicks again.
if (!store.ws || store.ws.readyState !== WebSocket.OPEN) return;
store.pickedAnswer = answer;
document.querySelectorAll("[data-answer]").forEach((btn) => {
btn.disabled = true;
if (btn.dataset.answer === answer) btn.classList.add("picked");
});
store.ws.send(JSON.stringify({
type: "submit",
question_idx: store.currentQuestion.question_idx,
answer,
}));
}
function renderSubmitted(message) {
store.submitted = message;
const seconds = (message.elapsed_ms / 1000).toFixed(1);
setView(`
<div class="card narrow center">
<p class="eyebrow">Question ${message.question_idx + 1}</p>
<h1 class="big-score">+${fmtScore(message.score)}</h1>
<p class="muted">submitted in ${seconds}s</p>
<p class="muted small">Waiting for the reveal…</p>
<div class="spinner" aria-hidden="true"></div>
</div>
`);
}
function renderReveal(message) {
stopCountdown();
const q = store.currentQuestion;
const yourAnswer = message.your_answer ?? null;
const correct = message.correct;
const won = yourAnswer === correct;
const totalSubmitters = ["A","B","C","D"].reduce((a, k) => a + (message.histogram[k] || 0), 0) + (message.histogram.missed || 0);
const denom = Math.max(1, totalSubmitters);
setView(`
<article class="card reveal-card">
<div class="question-head">
<span class="qnum">Q${message.question_idx + 1}</span>
<span class="state-badge ${won ? "state-correct" : "state-wrong"}">${won ? "Correct" : (yourAnswer ? "Wrong" : "Missed")}</span>
</div>
${q?.text ? `<h2 class="question-text small">${escapeText(q.text)}</h2>` : ""}
<ol class="options reveal student-reveal">
${["A","B","C","D"].map((k) => {
const isCorrect = k === correct;
const isYours = k === yourAnswer;
let cls = "";
if (isCorrect) cls += " correct";
if (isYours && !isCorrect) cls += " wrong-pick";
if (isYours) cls += " yours";
return `
<li class="${cls}">
<span class="key">${k}${isCorrect ? " ✓" : ""}${isYours && !isCorrect ? " ✗" : ""}</span>
<span class="opt-text">${escapeText(q?.options?.[k] || "")}</span>
<span class="opt-count muted">${message.histogram[k] || 0} (${Math.round(100 * (message.histogram[k] || 0) / denom)}%)</span>
</li>
`;
}).join("")}
</ol>
${message.explanation ? `<p class="explanation">${escapeText(message.explanation)}</p>` : ""}
<div class="reveal-stats">
<div class="stat"><span class="muted">Your score</span><b>+${fmtScore(message.your_score || 0)}</b></div>
<div class="stat"><span class="muted">Total</span><b>${fmtScore(message.your_total)}</b></div>
<div class="stat"><span class="muted">Rank</span><b>${message.your_rank ?? "—"}</b></div>
</div>
<h3>Top 5</h3>
${renderBoard(message.top5)}
</article>
`);
}
function renderBetween(message) {
setView(`
<div class="card narrow center">
<p class="eyebrow">Up next</p>
<h1>Question ${(message.next_idx ?? 0) + 1}</h1>
<div class="reveal-stats">
<div class="stat"><span class="muted">Total</span><b>${fmtScore(message.your_total)}</b></div>
<div class="stat"><span class="muted">Rank</span><b>${message.your_rank ?? "—"}</b></div>
</div>
${renderBoard(message.top5)}
<div class="spinner" aria-hidden="true"></div>
</div>
`);
}
function renderFinished(message) {
stopCountdown();
setView(`
<article class="card celebration-card">
<div class="celebration-banner">Quiz complete</div>
<div class="reveal-stats">
<div class="stat big"><span class="muted">Your total</span><b>${fmtScore(message.your_total)}</b></div>
<div class="stat"><span class="muted">Rank</span><b>${message.your_rank ?? "—"}</b></div>
<div class="stat"><span class="muted">Correct</span><b>${message.questions_correct ?? 0} / ${message.questions_answered ?? 0}</b></div>
</div>
<h3>Final top 5</h3>
${renderBoard(message.final_top5)}
<p class="muted small">Thanks for playing.</p>
</article>
`);
}
function renderBoard(rows = []) {
if (!rows || !rows.length) return `<p class="muted small">No scores yet.</p>`;
// The server marks the requesting student's row with `is_you: true` so
// we can highlight by id without other students' ids ever crossing the
// wire. Falls back to name match only if the server didn't mark anything
// (older payloads pre-migration).
const anyMarked = rows.some((r) => r.is_you);
const myName = store.me?.name;
return `
<ol class="leaderboard">
${rows.map((r) => {
const isYou = anyMarked
? !!r.is_you
: (myName && r.name && r.name === myName);
return `
<li class="${isYou ? "is-you" : ""}">
<span class="rank">${r.rank}</span>
<span class="who"><b>${escapeText(r.name)}</b></span>
<span class="score">${fmtScore(r.score)}</span>
</li>
`;
}).join("")}
</ol>
`;
}
function renderError(message) {
setView(`
<div class="card narrow center">
<h1>Server message</h1>
<p class="muted">${escapeText(message.message || message.code || "Something went wrong.")}</p>
</div>
`);
}
function startCountdown() {
stopCountdown();
countdownTimer = setInterval(tickCountdown, 250);
tickCountdown();
}
function stopCountdown() {
if (countdownTimer) clearInterval(countdownTimer);
countdownTimer = null;
}
function tickCountdown() {
const el = document.querySelector("#countdown");
const fill = document.querySelector("#qbar-fill");
if (!el || !fill || !store.deadlineMs) return;
const remaining = Math.max(0, store.deadlineMs - Date.now());
const limit = (store.currentQuestion?.time_limit ?? 60) * 1000;
el.textContent = `${Math.ceil(remaining / 1000)}s`;
el.classList.toggle("urgent", remaining > 0 && remaining <= 10000);
fill.style.width = `${Math.max(0, Math.min(100, remaining / limit * 100))}%`;
if (remaining <= 0) stopCountdown();
}
boot();