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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user