diff --git a/backend/app/config.py b/backend/app/config.py
index 4b9fd7d..e33c136 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -53,7 +53,13 @@ class Settings(BaseSettings):
S3_REGION: str = "us-east-1"
MAX_UPLOAD_SIZE: int = 524288000 # 500MB
- TURNSTILE_SECRET_KEY: str = "" # Cloudflare Turnstile — leave blank to disable captcha
+ # hCaptcha. Leave the secret blank to disable the challenge entirely.
+ HCAPTCHA_SECRET_KEY: str = ""
+ # The browser gets its own copy of the site key from the frontend
+ # container, which is a separate image with a separate .env. This one is
+ # here so an operator can keep both halves of the pair together and see
+ # at a glance which widget the secret belongs to.
+ HCAPTCHA_SITE_KEY: str = ""
ADMIN_EMAIL: str = "" # Where contact form submissions are emailed
DEFAULT_ADMIN_EMAIL: str = "" # Optional explicit bootstrap admin email
diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py
index c285b82..b09f575 100644
--- a/backend/app/routers/auth.py
+++ b/backend/app/routers/auth.py
@@ -4,7 +4,7 @@ from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status, Request
from sqlalchemy.orm import Session
-from app.services import invites, site_settings
+from app.services import captcha, invites, site_settings
from app.database import get_db
from app.models.user import User
from app.models.email_verification import EmailVerification
@@ -61,25 +61,6 @@ def _check_reset_rate_limit(db: Session, email: str):
)
-async def _verify_turnstile(token: str) -> bool:
- """Verify Cloudflare Turnstile token. Returns True if valid or if not configured."""
- from app.config import settings as cfg
- secret = cfg.TURNSTILE_SECRET_KEY
- if not secret:
- return True
- try:
- import httpx
- resp = httpx.post(
- "https://challenges.cloudflare.com/turnstile/v0/siteverify",
- data={"secret": secret, "response": token},
- timeout=10,
- )
- return resp.json().get("success", False)
- except Exception as e:
- import logging; logging.getLogger(__name__).warning(f"Turnstile verification failed (failing open): {e}")
- return True
-
-
@router.get("/signup-policy")
def signup_policy(db: Session = Depends(get_db)):
"""What a would-be member needs, before they are anybody.
@@ -98,13 +79,9 @@ def signup_policy(db: Session = Depends(get_db)):
@router.post("/register")
async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
- # Verify Turnstile if configured
- from app.config import settings as cfg
- if cfg.TURNSTILE_SECRET_KEY:
- if not user_data.turnstile_token:
- raise HTTPException(status_code=400, detail="Bot verification required")
- if not await _verify_turnstile(user_data.turnstile_token):
- raise HTTPException(status_code=400, detail="Bot verification failed — please try again")
+ # Failing open: an hCaptcha outage should cost the site a little spam, not
+ # every account that would have been created while it lasted.
+ await captcha.require_human(user_data.captcha_token, fail_open=True)
# Check if registration is enabled (unless this is the first user - always allow admin creation)
is_first_user = db.query(User).count() == 0
diff --git a/backend/app/routers/contact.py b/backend/app/routers/contact.py
index b16d27b..c48a6e3 100644
--- a/backend/app/routers/contact.py
+++ b/backend/app/routers/contact.py
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
from app.database import get_db
from app.config import settings
+from app.services import captcha
router = APIRouter()
log = logging.getLogger(__name__)
@@ -20,25 +21,7 @@ class ContactRequest(BaseModel):
email: EmailStr
type: str # "question" | "moderator"
message: str
- turnstile_token: str | None = None
-
-
-async def _verify_turnstile(token: str) -> bool:
- """Verify Cloudflare Turnstile token. Returns True if valid or if Turnstile not configured."""
- secret = getattr(settings, "TURNSTILE_SECRET_KEY", None)
- if not secret:
- return True # Not configured — allow in dev/no-captcha mode
- try:
- import httpx
- resp = httpx.post(
- "https://challenges.cloudflare.com/turnstile/v0/siteverify",
- data={"secret": secret, "response": token},
- timeout=10,
- )
- return resp.json().get("success", False)
- except Exception as e:
- log.warning(f"Turnstile verification failed: {e}")
- return False
+ captcha_token: str | None = None
@router.post("")
@@ -52,13 +35,10 @@ async def submit_contact(req: ContactRequest, db: Session = Depends(get_db)):
if not name or not message:
raise HTTPException(status_code=400, detail="Name and message are required")
- # Verify Turnstile if configured
- secret = getattr(settings, "TURNSTILE_SECRET_KEY", None)
- if secret:
- if not req.turnstile_token:
- raise HTTPException(status_code=400, detail="Bot verification required")
- if not await _verify_turnstile(req.turnstile_token):
- raise HTTPException(status_code=400, detail="Bot verification failed — please try again")
+ # Failing shut, unlike registration: if hCaptcha cannot be reached, the
+ # cost of turning this form away is one retry, and the inbox behind it has
+ # no other defence.
+ await captcha.require_human(req.captcha_token, fail_open=False)
db.execute(text(
"INSERT INTO contact_submissions (name, email, type, message) VALUES (:name, :email, :type, :message)"
diff --git a/backend/app/routers/mobile.py b/backend/app/routers/mobile.py
index 661ab81..903f80d 100644
--- a/backend/app/routers/mobile.py
+++ b/backend/app/routers/mobile.py
@@ -138,7 +138,7 @@ def _quiz_payload(db: Session, quiz: Quiz, include_questions: bool = True):
@router.post("/auth/login")
def mobile_login(data: MobileLoginRequest, request: Request, db: Session = Depends(get_db)):
- """Password login for native apps. Uses rate limiting instead of browser Turnstile."""
+ """Password login for native apps. Uses rate limiting instead of a browser captcha."""
client_ip = request.client.host if request and request.client else "unknown"
_mobile_login_rate_limit(client_ip)
diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py
index eb78866..407839f 100644
--- a/backend/app/schemas/auth.py
+++ b/backend/app/schemas/auth.py
@@ -7,7 +7,8 @@ class UserCreate(BaseModel):
email: EmailStr
password: str
name: str
- turnstile_token: str | None = None
+ #: Solved hCaptcha challenge. Absent when the site has no secret configured.
+ captcha_token: str | None = None
#: Required only while the site is invite-only.
invite_code: str | None = None
diff --git a/backend/app/services/captcha.py b/backend/app/services/captcha.py
new file mode 100644
index 0000000..5c61d0d
--- /dev/null
+++ b/backend/app/services/captcha.py
@@ -0,0 +1,66 @@
+"""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")
diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx
index 85c70a1..09a9aa9 100644
--- a/frontend/src/pages/QuizPage.jsx
+++ b/frontend/src/pages/QuizPage.jsx
@@ -433,6 +433,10 @@ export default function QuizPage() {
const [sessionSeconds, setSessionSeconds] = useState(0)
const [questionSeconds, setQuestionSeconds] = useState(0)
const [clockPaused, setClockPaused] = useState(false)
+ // Asked before the session closes. Leaving is not destructive — the answers
+ // are saved and it can be resumed — but it is not what a mis-aimed thumb
+ // should do either.
+ const [leaving, setLeaving] = useState(false)
// The clock ran out: the answers are in, and the analysis waits behind an
// acknowledgement rather than replacing the exam without a word.
const [timeUp, setTimeUp] = useState(false)
@@ -1250,12 +1254,12 @@ const timerStarted = timeLeft !== null
{isStudy ? 'Finish session' : 'End block'}
) : (
- /* Skip, when nothing has been chosen. Moving on from a question you
- have not answered is a decision, and the button should say which
- decision it is rather than calling both of them Next. */
+ /* One name. It used to read Skip on an unanswered question and Next on
+ an answered one, while the arrow at the top of the screen said Next
+ for both — two words for one action, an inch apart. */
)}
@@ -1286,6 +1290,9 @@ const timerStarted = timeLeft !== null
}
}
+ // Handed in: the block is over, so the rail may say what each question was.
+ const reviewing = answeredCount >= totalCount && totalCount > 0
+
const QuestionRailItem = ({ q, i }) => {
const isActive = i === currentIdx
const isDone = !!answers[q.id]
@@ -1293,10 +1300,11 @@ const timerStarted = timeLeft !== null
// Only questions the learner has reached show their text. Previewing one
// they have not opened would give away the case before they read it.
//
- // In exam mode none of them do. A real paper's status rail is a column of
- // numbers, and reading the stems of the questions still to come is not
- // something the exam being rehearsed would allow.
- const seen = !isStudy ? false : seenIndexes.has(i)
+ // While an exam is being sat, none of them do: a real paper's status rail
+ // is a column of numbers, and reading the stems still to come is not
+ // something the exam being rehearsed would allow. Once it is handed in
+ // there is nothing left to protect, so the review reads like study mode.
+ const seen = (isStudy || reviewing) ? seenIndexes.has(i) : false
const excerpt = seen ? questionStem(q).replace(/\s+/g, ' ').trim() : ''
return (