pdf-quiz-generator/backend/app/services/captcha.py
Daniel 7ba37561e4 feat: the exam player as the reference has it
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
2026-09-12 05:43:25 +02:00

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