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:
126
static/admin.js
126
static/admin.js
@@ -15,6 +15,9 @@ const store = {
|
||||
session: null, // /admin/api/state response
|
||||
ws: null,
|
||||
roster: [],
|
||||
presence: [], // presence_update.rows — richer than roster
|
||||
orphanDuplicates: [], // presence_update.orphan_duplicate_joins
|
||||
currentQIdx: null, // tracked for "answered current?" rendering
|
||||
currentQuestion: null,
|
||||
histogram: null,
|
||||
totalCount: 0,
|
||||
@@ -130,7 +133,10 @@ function renderLogin(error = null) {
|
||||
function renderDashboard() {
|
||||
const session = store.session;
|
||||
if (!session) return;
|
||||
const state = store.endedPayload ? "finished" : (store.closedPayload?.state || session.state);
|
||||
// state derives from session (server-authoritative); endedPayload short-
|
||||
// circuits to "finished" for the post-final render where we may not
|
||||
// have re-fetched session.state yet.
|
||||
const state = store.endedPayload ? "finished" : session.state;
|
||||
app.innerHTML = `
|
||||
<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;
|
||||
|
||||
Reference in New Issue
Block a user