The incomplete-block warning is the one from the screenshot: a red heading that says the block is incomplete, the count of unanswered items, the sentence about resuming not matching exam day, and End Block against Remain in Block. My version asked the question in my own words and led with the wrong button. Pausing says "Exam Paused" and offers Return to exam. Nothing else — the warning about real exams is somebody else's disclaimer, not ours. Exit session asks "Are you sure you want to end this session?" before it goes, rather than going. Time's Up says what it is and the button says Close, which is the only thing left to do: it is already handed in and marked, and Close lands on the session's analysis. One name for one action: the bottom button read Skip on an unanswered question and Next on an answered one, while the arrow an inch above it said Next for both. And the rail shows stems again once the block is handed in. Numbers while it is being sat — reading ahead is not something the exam being rehearsed allows — but there is nothing left to protect afterwards, so the review reads like study mode. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
66 lines
2.5 KiB
Python
66 lines
2.5 KiB
Python
"""hCaptcha checks on the two forms a stranger can reach.
|
|
|
|
Both the sign-up and the contact form used to carry their own copy of this,
|
|
which is how they drifted apart. It lives here once so that the answer to
|
|
"what happens when the challenge is missing, wrong, or unanswerable" is given
|
|
in a single place.
|
|
|
|
A blank secret means no captcha at all. That is the deliberate default: a
|
|
deployment that has never signed up for hCaptcha must still be able to
|
|
register its first administrator, so an unset key reads as "there is no
|
|
challenge here", never as "reject everybody".
|
|
"""
|
|
import logging
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from app.config import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
VERIFY_URL = "https://api.hcaptcha.com/siteverify"
|
|
|
|
|
|
def configured() -> bool:
|
|
return bool(settings.HCAPTCHA_SECRET_KEY)
|
|
|
|
|
|
async def verify(token: str, *, fail_open: bool) -> bool:
|
|
"""Ask hCaptcha whether this token is a real, unspent solve.
|
|
|
|
`fail_open` decides what an unreachable hCaptcha means, and the two callers
|
|
genuinely want different answers: an outage that stops people creating
|
|
accounts costs the site its users, while an outage that bounces a contact
|
|
message costs the sender one retry.
|
|
"""
|
|
if not configured():
|
|
return True
|
|
try:
|
|
import httpx
|
|
# Awaited rather than blocking: uvicorn runs four workers, and a
|
|
# ten-second stall on a third party would otherwise hold up every
|
|
# other request that worker is serving.
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
resp = await client.post(
|
|
VERIFY_URL,
|
|
data={"secret": settings.HCAPTCHA_SECRET_KEY, "response": token},
|
|
)
|
|
return resp.json().get("success", False)
|
|
except Exception:
|
|
logger.warning("hCaptcha verification unavailable; %s",
|
|
"allowing the request" if fail_open else "rejecting the request",
|
|
exc_info=True)
|
|
return fail_open
|
|
|
|
|
|
async def require_human(token: str | None, *, fail_open: bool) -> None:
|
|
"""Reject the submission unless it carries a solved challenge.
|
|
|
|
Silent when no secret is set, so an unconfigured site keeps working.
|
|
"""
|
|
if not configured():
|
|
return
|
|
if not token:
|
|
raise HTTPException(status_code=400, detail="Bot verification required")
|
|
if not await verify(token, fail_open=fail_open):
|
|
raise HTTPException(status_code=400, detail="Bot verification failed — please try again")
|