feat: hCaptcha replaces Turnstile

One verifier, backend/app/services/captcha.py, and one widget,
components/Captcha.jsx. There were two copies of each and they had
drifted: the register widget loaded the script itself while the landing
one relied on a page-level effect elsewhere in its file, and on the
backend auth failed *open* on an unreachable Turnstile while contact
failed *shut*.

Both failure modes were kept rather than one quietly chosen, as an
explicit `fail_open` argument with the reason written down: 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.

An unconfigured secret still skips verification entirely, as before, so a
site with no keys keeps working.

The keys in .env are empty. The Cloudflare ones there were live and are
now dead, so **there is no captcha on register or contact until hCaptcha
keys are issued** — this is not a state to leave a public site in.

Also corrected on the way: docs/frontend.md still documented
`login(email, password, turnstileToken)`, whose third argument had
already gone from AuthContext.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-12 05:56:44 +02:00
parent dd71bed184
commit 91b8e24d6b
19 changed files with 448 additions and 157 deletions

View file

@ -166,7 +166,7 @@ Key settings in `backend/.env`:
| `MAIL_FROM` | Sender email for verification/reset emails |
| `LITELLM_API_BASE` | LiteLLM proxy URL for AI features |
| `LITELLM_API_KEY` | API key for LiteLLM proxy |
| `TURNSTILE_SITE_KEY` / `TURNSTILE_SECRET_KEY` | Cloudflare Turnstile bot protection |
| `HCAPTCHA_SITE_KEY` / `HCAPTCHA_SECRET_KEY` | hCaptcha bot protection |
| `BBB_SERVER_URL` / `BBB_SECRET` | BigBlueButton integration for live sessions |
| `OIDC_PROVIDER_URL` | OIDC discovery URL (see SSO section below) |
| `OIDC_CLIENT_ID` | OAuth client ID from your identity provider |

View file

@ -19,7 +19,7 @@ AI-powered pediatric learning platform. Upload PDF study materials, automaticall
- **Concurrent Quiz Protection**: Redis session locks prevent the same quiz from being resumed on multiple devices simultaneously
- **Landing Page**: Integrated with the app — Sign In / Register open as modal overlays, shared Navbar
- **PWA**: Installable on mobile/desktop (no caching — avoids stale JS issues)
- **Bot Protection**: Cloudflare Turnstile on registration and contact forms
- **Bot Protection**: hCaptcha on registration and contact forms
- **Email Verification**: Required before first login; password reset via email
- **Role System**: Admin / Moderator / User with optional rate-limit exemption (unthrottle)
- **Admin User Management**: Delete users, change roles, toggle unthrottle — all from the admin dashboard
@ -39,7 +39,7 @@ AI-powered pediatric learning platform. Upload PDF study materials, automaticall
| TTS | LiteLLM-routed local TTS, OpenAI (direct), ElevenLabs, Google Cloud TTS |
| Queue | Celery + Redis (4 fork workers) |
| Email | SMTP (smtp2go or any SMTP server) |
| Bot protection | Cloudflare Turnstile (runtime-configurable, no rebuild needed) |
| Bot protection | hCaptcha (runtime-configurable, no rebuild needed) |
For detailed architecture documentation, see [docs/architecture.md](docs/architecture.md).
@ -101,8 +101,8 @@ MAIL_PASSWORD=<smtp2go-password>
MAIL_FROM=noreply@yourdomain.com
MAIL_STARTTLS=true
# Bot protection — Cloudflare Turnstile (backend secret)
TURNSTILE_SECRET_KEY=<cloudflare-turnstile-secret-key>
# Bot protection — hCaptcha (backend secret)
HCAPTCHA_SECRET_KEY=<hcaptcha-secret-key>
# Contact form admin notifications
ADMIN_EMAIL=admin@yourdomain.com
@ -121,8 +121,8 @@ DEFAULT_ADMIN_PASSWORD=
### Frontend (`frontend/.env`)
```env
# Bot protection — Cloudflare Turnstile (public site key)
TURNSTILE_SITE_KEY=<cloudflare-turnstile-site-key>
# Bot protection — hCaptcha (public site key)
HCAPTCHA_SITE_KEY=<hcaptcha-site-key>
```
The frontend env file is **not** baked into the Docker image at build time. Instead, `docker-entrypoint.sh` generates a `/config.js` file from the env vars when the container **starts**. This means:
@ -131,29 +131,31 @@ The frontend env file is **not** baked into the Docker image at build time. Inst
- Switch captcha providers by updating the entrypoint script and the widget component
- Remove bot protection by clearing the key (empty = disabled)
## Cloudflare Turnstile (Bot Protection)
## hCaptcha (Bot Protection)
### How it works
Turnstile protects the **registration** and **contact** forms from bot submissions. It does NOT require Cloudflare DNS/proxy — it works standalone on any domain.
hCaptcha protects the **registration** and **contact** forms from bot submissions. One shared component, `frontend/src/components/Captcha.jsx`, renders every widget; one backend service, `backend/app/services/captcha.py`, verifies every token.
**Flow:**
```
1. Page loads → Turnstile JS loads from challenges.cloudflare.com
2. Widget renders (invisible or interactive depending on risk score)
1. Page loads → Captcha component loads js.hcaptcha.com/1/api.js (once)
2. Widget renders where the component sits on the form
3. User completes challenge → widget calls onVerify(token)
4. Frontend stores token in state → Sign Up button becomes enabled
5. User submits form → token sent as `turnstile_token` in POST body
6. Backend receives token → POSTs to Cloudflare's siteverify API:
POST https://challenges.cloudflare.com/turnstile/v0/siteverify
Body: { secret: TURNSTILE_SECRET_KEY, response: turnstile_token }
7. Cloudflare returns { success: true/false }
5. User submits form → token sent as `captcha_token` in POST body
6. Backend receives token → POSTs to hCaptcha's siteverify API:
POST https://api.hcaptcha.com/siteverify
Body: { secret: HCAPTCHA_SECRET_KEY, response: captcha_token }
7. hCaptcha returns { success: true/false }
8. If false → 400 "Bot verification failed"
9. If true → registration proceeds normally
```
**Where Turnstile is active:**
A solve expires after a couple of minutes; the widget says so, the component clears the stored token, and the submit button goes back to disabled rather than sending something that will be refused.
**Where hCaptcha is active:**
- Register form (modal overlay on landing page)
- Register form (standalone `/register` page)
- Contact form (landing page)
@ -161,31 +163,34 @@ Turnstile protects the **registration** and **contact** forms from bot submissio
**Where it is NOT active (by design):**
- Login — protected by IP-based rate limiting (10 attempts / 15 min) instead
**When hCaptcha itself is unreachable**, registration is let through and the contact form is not: an outage that blocks sign-ups costs the site its users, while a bounced message costs the sender one retry.
### Setup
1. Go to https://dash.cloudflare.com → Turnstile → Add widget
2. Add your domain(s), choose "Managed" widget type
3. Copy the Site Key and Secret Key
1. Go to https://dashboard.hcaptcha.com → Sites → New Site
2. Add your domain(s)
3. Copy the Site Key, and the account's Secret Key from Settings
```bash
# frontend/.env
TURNSTILE_SITE_KEY=0x4AAAAAAA...
HCAPTCHA_SITE_KEY=10000000-ffff-...
# backend/.env
TURNSTILE_SECRET_KEY=0x4AAAAAAA...
HCAPTCHA_SECRET_KEY=ES_...
# Restart (no rebuild needed)
docker compose restart frontend backend
```
The CSP in `frontend/nginx.conf` already allows `hcaptcha.com` and its subdomains for scripts, frames, images and XHR; a provider change means editing that header too.
### Testing
Cloudflare provides test keys for development:
hCaptcha publishes a key pair that always passes:
| Purpose | Site Key | Secret Key |
|---|---|---|
| Always passes | `1x00000000000000000000AA` | `1x0000000000000000000000000000000AA` |
| Always blocks | `2x00000000000000000000AB` | `2x0000000000000000000000000000000AB` |
| Always passes | `10000000-ffff-ffff-ffff-000000000001` | `0x0000000000000000000000000000000000000000` |
### Disabling
@ -198,14 +203,14 @@ docker-compose.yml
└─ frontend service: env_file: ./frontend/.env
Container startup (docker-entrypoint.sh):
└─ Reads $TURNSTILE_SITE_KEY from environment
└─ Reads $HCAPTCHA_SITE_KEY from environment
└─ Writes /usr/share/nginx/html/config.js:
window.__APP_CONFIG__ = { TURNSTILE_SITE_KEY: "0x4AAA..." };
window.__APP_CONFIG__ = { HCAPTCHA_SITE_KEY: "10000000-ffff-..." };
Browser loads index.html:
└─ <script src="/config.js"> sets window.__APP_CONFIG__
└─ <script type="module" src="/src/main.jsx"> React app starts
└─ Components read: window.__APP_CONFIG__?.TURNSTILE_SITE_KEY
└─ Components read: window.__APP_CONFIG__?.HCAPTCHA_SITE_KEY
```
This avoids Vite's `import.meta.env.VITE_*` which bakes values into the JS bundle at build time.
@ -284,7 +289,7 @@ docker compose build && docker compose up -d
docker compose build backend && docker compose up -d backend
docker compose build frontend && docker compose up -d frontend
# Restart without rebuilding (for .env changes, including Turnstile keys)
# Restart without rebuilding (for .env changes, including hCaptcha keys)
docker compose restart frontend backend
# View logs
@ -352,7 +357,7 @@ Nginx (frontend container — serves React SPA + proxies /api to backend)
├─ LiteLLM proxy ← Claude/Gemini/GPT for extraction + teach + classification; embedding models
├─ AWS Bedrock ← Polly TTS; embedding fallback
├─ OpenAI ← TTS (direct, not via proxy)
└─ Cloudflare Turnstile ← bot verification for registration + contact
└─ hCaptcha ← bot verification for registration + contact
```
For deep architecture documentation (database schema, request flow, background tasks, vector search, auth, and more), see [docs/architecture.md](docs/architecture.md).
@ -385,8 +390,8 @@ The landing page at `/home` uses the shared `Navbar` component. When not logged
- **SQL injection**: pgvector queries use parameterized bind variables (`CAST(:vec AS vector)`)
- **API key exposure**: LiteLLM/TTS model search uses POST body, not URL query params
- **Rate limiting**: Redis INCR + TTL keys on login, teach, and TTS endpoints
- **Bot protection**: Cloudflare Turnstile on registration and contact forms
- **CSP headers**: Configured in nginx.conf for fonts, Turnstile, and self
- **Bot protection**: hCaptcha on registration and contact forms
- **CSP headers**: Configured in nginx.conf for fonts, hCaptcha, and self
## Project Structure
@ -397,13 +402,13 @@ The landing page at `/home` uses the shared `Navbar` component. When not logged
│ │ ├── config.py # Settings (pydantic-settings, reads .env)
│ │ ├── models/ # SQLAlchemy ORM models
│ │ ├── routers/
│ │ │ ├── auth.py # Login, register (with Turnstile), verify email, password reset
│ │ │ ├── auth.py # Login, register (with hCaptcha), verify email, password reset
│ │ │ ├── quizzes.py # Quiz CRUD, async extraction jobs
│ │ │ ├── attempts.py # Quiz attempts, progress save/resume, history, stats
│ │ │ ├── teach.py # AI tutor chat (async, with follow-up suggestions)
│ │ │ ├── admin.py # Model management, user roles, settings, classification trigger
│ │ │ ├── tags.py # Tag listing and filtering endpoints
│ │ │ ├── contact.py # Contact form (with Turnstile)
│ │ │ ├── contact.py # Contact form (with hCaptcha)
│ │ │ └── ...
│ │ ├── services/
│ │ │ ├── ai_service.py # LLM calls + _proxy_model() routing
@ -421,13 +426,13 @@ The landing page at `/home` uses the shared `Navbar` component. When not logged
├── frontend/
│ ├── src/
│ │ ├── pages/
│ │ │ ├── LandingPage.jsx # Landing + contact form + auth modal + Turnstile
│ │ │ ├── LandingPage.jsx # Landing + contact form + auth modal + hCaptcha
│ │ │ ├── DashboardPage.jsx # Stats, performance chart, attempt history with delete
│ │ │ ├── QuizzesPage.jsx # Quiz grid, search, past attempts with delete
│ │ │ ├── QuizPage.jsx # Quiz taking (study/exam), TTS, progress save
│ │ │ ├── ResultsPage.jsx # Score card, answer review, delete attempt
│ │ │ ├── DocumentDetailPage.jsx # Sections, extraction mode picker, model selector
│ │ │ ├── RegisterPage.jsx # Standalone register (with Turnstile)
│ │ │ ├── RegisterPage.jsx # Standalone register (with hCaptcha)
│ │ │ └── ...
│ │ ├── components/
│ │ │ ├── Navbar.jsx # Shared navbar (auth-aware, optional modal callbacks)
@ -444,7 +449,7 @@ The landing page at `/home` uses the shared `Navbar` component. When not logged
│ └── Dockerfile
├── docs/
│ └── architecture.md # Deep architecture documentation
├── frontend/.env # Frontend runtime config (Turnstile site key)
├── frontend/.env # Frontend runtime config (hCaptcha site key)
├── backend/.env # Backend config (all secrets)
└── docker-compose.yml
```
@ -456,7 +461,7 @@ The landing page at `/home` uses the shared `Navbar` component. When not logged
- Uploads live in the `uploads_data` volume — includes extracted question images
- Redis data persists in `redis_data` volume — holds runtime settings and job state
- Set `APP_URL` to your public domain so email verification and password reset links work
- Frontend env vars (like Turnstile site key) are injected at container startup, not build time — change and restart, no rebuild needed
- Frontend env vars (like the hCaptcha site key) are injected at container startup, not build time — change and restart, no rebuild needed
## TTS Providers

View file

@ -33,3 +33,8 @@ MAIL_SSL_TLS=false
# File uploads
UPLOAD_DIR=/app/uploads
MAX_UPLOAD_SIZE=524288000
# Bot protection — hCaptcha. Leave the secret blank to disable the challenge;
# the sign-up and contact forms then accept submissions without one.
HCAPTCHA_SECRET_KEY=
HCAPTCHA_SITE_KEY=

View file

@ -0,0 +1,148 @@
"""The hCaptcha gate itself: what a missing secret, a bad solve and an outage each mean.
Real service and real contact route; only the call to hCaptcha is replaced.
"""
import unittest
from unittest.mock import AsyncMock, patch
import httpx
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, text
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.config import settings
from app.database import get_db
from app.routers import contact
from app.services import captcha
class FakeResponse:
def __init__(self, payload): self.payload = payload
def json(self): return self.payload
def fake_hcaptcha(payload=None, error=None):
"""Stand in for httpx.AsyncClient and record what hCaptcha was asked."""
posts = []
class FakeClient:
def __init__(self, **kwargs): self.kwargs = kwargs
async def __aenter__(self): return self
async def __aexit__(self, *exc): return False
async def post(self, url, data=None):
posts.append({'url': url, 'data': data, 'client': self.kwargs})
if error:
raise error
return FakeResponse(payload)
return FakeClient, posts
class VerifyTests(unittest.IsolatedAsyncioTestCase):
def configured(self, secret='synthetic-configured-key'):
patcher = patch.object(settings, 'HCAPTCHA_SECRET_KEY', secret)
patcher.start()
self.addCleanup(patcher.stop)
async def test_an_unset_secret_skips_the_check_without_calling_out(self):
self.configured('')
client, posts = fake_hcaptcha({'success': False})
with patch.object(httpx, 'AsyncClient', client):
self.assertTrue(await captcha.verify('any-token', fail_open=False))
await captcha.require_human(None, fail_open=False)
self.assertFalse(captcha.configured())
self.assertEqual(posts, [])
async def test_a_solve_is_posted_to_hcaptcha_as_secret_and_response(self):
self.configured()
client, posts = fake_hcaptcha({'success': True})
with patch.object(httpx, 'AsyncClient', client):
self.assertTrue(await captcha.verify('solved-test-token', fail_open=False))
self.assertEqual(len(posts), 1)
self.assertEqual(posts[0]['url'], 'https://api.hcaptcha.com/siteverify')
self.assertEqual(posts[0]['data'],
{'secret': 'synthetic-configured-key', 'response': 'solved-test-token'})
self.assertEqual(posts[0]['client']['timeout'], 10)
async def test_hcaptcha_saying_no_is_a_rejection(self):
self.configured()
client, _ = fake_hcaptcha({'success': False, 'error-codes': ['invalid-input-response']})
with patch.object(httpx, 'AsyncClient', client):
self.assertFalse(await captcha.verify('spent-test-token', fail_open=True))
async def test_an_unreachable_hcaptcha_answers_whichever_way_the_caller_asked(self):
self.configured()
client, _ = fake_hcaptcha(error=httpx.ConnectError('synthetic network failure'))
with patch.object(httpx, 'AsyncClient', client):
self.assertTrue(await captcha.verify('any-token', fail_open=True))
self.assertFalse(await captcha.verify('any-token', fail_open=False))
async def test_require_human_reports_a_missing_and_a_bad_solve_differently(self):
self.configured()
with patch.object(captcha, 'verify', new_callable=AsyncMock, return_value=False):
with self.assertRaises(HTTPException) as missing:
await captcha.require_human('', fail_open=True)
with self.assertRaises(HTTPException) as bad:
await captcha.require_human('spent-test-token', fail_open=True)
self.assertEqual((missing.exception.status_code, missing.exception.detail),
(400, 'Bot verification required'))
self.assertEqual((bad.exception.status_code, bad.exception.detail),
(400, 'Bot verification failed — please try again'))
class ContactFormTests(unittest.TestCase):
def setUp(self):
self.engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)
self.db = Session(self.engine)
# Created by raw DDL at startup in production, so there is no model to build it from.
self.db.execute(text("""CREATE TABLE contact_submissions (
id INTEGER PRIMARY KEY, name TEXT, email TEXT, type TEXT, message TEXT,
read INTEGER DEFAULT 0, created_at TIMESTAMP)"""))
self.db.commit()
app = FastAPI()
app.include_router(contact.router, prefix='/contact')
app.dependency_overrides[get_db] = lambda: self.db
self.client = TestClient(app)
for target, value in [('HCAPTCHA_SECRET_KEY', 'synthetic-configured-key'), ('ADMIN_EMAIL', '')]:
patcher = patch.object(settings, target, value)
patcher.start()
self.addCleanup(patcher.stop)
verifier = patch.object(captcha, 'verify', new_callable=AsyncMock, return_value=False)
self.verify = verifier.start()
self.addCleanup(verifier.stop)
def tearDown(self):
self.db.close()
def submit(self, **overrides):
return self.client.post('/contact', json={
'name': 'Asker', 'email': 'asker@example.com', 'type': 'question',
'message': 'Synthetic enquiry', **overrides})
def stored(self):
return self.db.execute(text('SELECT name, message FROM contact_submissions')).fetchall()
def test_an_unsolved_form_is_turned_away_before_anything_is_written(self):
self.assertEqual(self.submit().json()['detail'], 'Bot verification required')
self.assertEqual(self.submit(captcha_token='spent-test-token').status_code, 400)
self.assertEqual(self.stored(), [])
self.verify.assert_awaited_once_with('spent-test-token', fail_open=False)
def test_a_solved_form_is_stored(self):
self.verify.return_value = True
self.assertEqual(self.submit(captcha_token='solved-test-token').status_code, 200)
self.assertEqual(self.stored(), [('Asker', 'Synthetic enquiry')])
# Unreachable hCaptcha must not become a way past the contact gate.
self.verify.assert_awaited_once_with('solved-test-token', fail_open=False)
def test_an_unconfigured_site_accepts_the_form_with_no_challenge(self):
with patch.object(settings, 'HCAPTCHA_SECRET_KEY', ''):
self.assertEqual(self.submit().status_code, 200)
self.assertEqual(self.stored(), [('Asker', 'Synthetic enquiry')])
self.verify.assert_not_awaited()
if __name__ == '__main__':
unittest.main()

View file

@ -1,4 +1,4 @@
"""Login-only Turnstile removal; real auth routes, disposable DB, mocked external boundaries."""
"""Login carries no captcha; real auth routes, disposable DB, mocked external boundaries."""
import sys
import unittest
from datetime import datetime, timedelta
@ -10,6 +10,7 @@ from app.config import settings
from app.models.email_verification import EmailVerification
from app.routers import auth
from app.schemas.auth import LoginRequest, UserCreate
from app.services import captcha
from app.utils.auth import get_password_hash
@ -22,7 +23,7 @@ class MemoryRedis:
def expire(self, *args): return True
class LoginWithoutTurnstileTests(unittest.TestCase):
class LoginWithoutCaptchaTests(unittest.TestCase):
def setUp(self):
self.bank = fixtures.BuilderTests()
self.bank.setUp()
@ -36,9 +37,10 @@ class LoginWithoutTurnstileTests(unittest.TestCase):
module.from_url = lambda *args, **kwargs: self.redis
self.module_patch = patch.dict(sys.modules, {'redis': module})
self.module_patch.start()
self.key_patch = patch.object(settings, 'TURNSTILE_SECRET_KEY', 'synthetic-configured-key')
self.key_patch = patch.object(settings, 'HCAPTCHA_SECRET_KEY', 'synthetic-configured-key')
self.key_patch.start()
self.verify_patch = patch.object(auth, '_verify_turnstile', new_callable=AsyncMock)
# Stops at the network boundary; every routing decision above it is real.
self.verify_patch = patch.object(captcha, 'verify', new_callable=AsyncMock)
self.verify = self.verify_patch.start()
self.verify.return_value = False
@ -51,14 +53,14 @@ class LoginWithoutTurnstileTests(unittest.TestCase):
def login(self, **overrides):
return self.client.post('/auth/login', json={'email': 'owner@example.com', 'password': 'synthetic-password', **overrides})
def test_valid_login_needs_no_token_even_with_turnstile_configured(self):
self.assertNotIn('turnstile_token', LoginRequest.model_fields)
self.assertIn('turnstile_token', UserCreate.model_fields)
def test_valid_login_needs_no_token_even_with_hcaptcha_configured(self):
self.assertNotIn('captcha_token', LoginRequest.model_fields)
self.assertIn('captcha_token', UserCreate.model_fields)
response = self.login()
self.assertEqual(response.status_code, 200, response.text)
self.assertTrue(response.json()['access_token'])
# Old clients sending an extra token remain compatible; it is not verified.
self.assertEqual(self.login(turnstile_token='obsolete-client-field').status_code, 200)
self.assertEqual(self.login(captcha_token='obsolete-client-field').status_code, 200)
self.verify.assert_not_awaited()
def test_password_email_verification_and_sso_rules_remain(self):
@ -81,14 +83,25 @@ class LoginWithoutTurnstileTests(unittest.TestCase):
self.assertIn('Too many login attempts', response.json()['detail'])
self.verify.assert_not_awaited()
def test_registration_still_requires_and_verifies_turnstile(self):
def test_registration_still_requires_and_verifies_the_challenge(self):
payload = {'email': 'new@example.com', 'password': 'synthetic-password', 'name': 'Test'}
response = self.client.post('/auth/register', json=payload)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()['detail'], 'Bot verification required')
response = self.client.post('/auth/register', json={**payload, 'turnstile_token': 'invalid-test-token'})
response = self.client.post('/auth/register', json={**payload, 'captcha_token': 'invalid-test-token'})
self.assertEqual(response.status_code, 400)
self.verify.assert_awaited_once_with('invalid-test-token')
self.assertEqual(response.json()['detail'], 'Bot verification failed — please try again')
self.verify.assert_awaited_once_with('invalid-test-token', fail_open=True)
def test_a_solved_challenge_lets_registration_through(self):
self.verify.return_value = True
with patch.object(auth.email_service, 'send_verification_email'):
response = self.client.post('/auth/register', json={
'email': 'new@example.com', 'password': 'synthetic-password',
'name': 'Test', 'captcha_token': 'solved-test-token'})
self.assertEqual(response.status_code, 200, response.text)
self.assertTrue(response.json()['requires_verification'])
self.verify.assert_awaited_once_with('solved-test-token', fail_open=True)
if __name__ == '__main__':

View file

@ -561,7 +561,8 @@ PREVIOUS / SKIP to move between them.
Qbank beside them.
### Elsewhere
- [ ] **hCaptcha replaces Turnstile**, everywhere Turnstile is wired.
- [x] **hCaptcha replaces Turnstile**, everywhere Turnstile was wired — one
shared `Captcha` component, one `captcha` service, keys blank until issued.
- [ ] **The session rail scrolls** once it holds more than a screenful.
## Done this session

View file

@ -13,14 +13,14 @@ All endpoints return JSON unless noted otherwise. Authentication is via Bearer t
Register a new user account.
- **Auth:** None
- **Rate limit:** Turnstile verification required if configured
- **Rate limit:** hCaptcha verification required if configured
- **Request body:**
```json
{
"email": "string",
"password": "string (min 8 chars)",
"name": "string",
"turnstile_token": "string | null"
"captcha_token": "string | null"
}
```
- **Response:**
@ -33,13 +33,12 @@ Register a new user account.
Authenticate and receive a JWT token.
- **Auth:** None
- **Rate limit:** 10 attempts per IP per 15 minutes (Redis). Turnstile verification if configured.
- **Rate limit:** 10 attempts per IP per 15 minutes (Redis). No captcha on login.
- **Request body:**
```json
{
"email": "string",
"password": "string",
"turnstile_token": "string | null"
"password": "string"
}
```
- **Response:** `{"access_token": "string", "token_type": "bearer"}`
@ -834,11 +833,11 @@ Submit a contact form (public, no auth).
"email": "valid email",
"type": "question | moderator",
"message": "string (max 2000)",
"turnstile_token": "string | null"
"captcha_token": "string | null"
}
```
- **Response:** `{"success": true}`
- **Notes:** Stores submission in DB and emails admin. Turnstile verification if configured.
- **Notes:** Stores submission in DB and emails admin. hCaptcha verification if configured.
### GET `/api/contact/submissions`

View file

@ -97,7 +97,7 @@ For background jobs (PDF processing, quiz extraction, classification), the FastA
- **Command**: `uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4`
- **Depends on**: postgres (healthy), redis (started)
- **Volumes**: `uploads_data` (PDFs + extracted images), `chroma_data` (ChromaDB persistence)
- **Key env vars**: `DATABASE_URL`, `REDIS_URL`, `SECRET_KEY`, `LITELLM_*`, `OPENAI_API_KEY`, `AWS_*`, `MAIL_*`, `TURNSTILE_SECRET_KEY`
- **Key env vars**: `DATABASE_URL`, `REDIS_URL`, `SECRET_KEY`, `LITELLM_*`, `OPENAI_API_KEY`, `AWS_*`, `MAIL_*`, `HCAPTCHA_SECRET_KEY`
### celery
@ -491,7 +491,7 @@ The frontend Docker image contains only the built React SPA with no environment-
```sh
cat > /usr/share/nginx/html/config.js <<EOF
window.__APP_CONFIG__ = {
TURNSTILE_SITE_KEY: "${TURNSTILE_SITE_KEY:-}"
HCAPTCHA_SITE_KEY: "${HCAPTCHA_SITE_KEY:-}"
};
EOF
exec nginx -g 'daemon off;'
@ -501,7 +501,7 @@ The SPA loads `config.js` before the React bundle via a `<script>` tag in `index
### Benefits
- Change Turnstile keys (or add new config) by editing `frontend/.env` and restarting — no rebuild
- Change the hCaptcha site key (or add new config) by editing `frontend/.env` and restarting — no rebuild
- Same Docker image works across staging/production with different env files
- No risk of secrets leaking into the JS bundle via Vite's `import.meta.env`

View file

@ -68,14 +68,14 @@ The application runs as 5 services defined in `docker-compose.yml`:
| `UPLOAD_DIR` | `/app/uploads` | Upload storage directory |
| `MAX_UPLOAD_SIZE` | `524288000` | Max upload size in bytes (500MB) |
| `APP_URL` | `https://quiz.danvics.com` | Public URL (used in emails, links) |
| `TURNSTILE_SECRET_KEY` | (empty) | Cloudflare Turnstile secret key. Leave blank to disable captcha |
| `HCAPTCHA_SECRET_KEY` | (empty) | hCaptcha secret key. Leave blank to disable captcha |
| `ADMIN_EMAIL` | (empty) | Email address for contact form submissions |
### Frontend (`frontend/.env`)
| Variable | Description |
|----------|-------------|
| `TURNSTILE_SITE_KEY` | Cloudflare Turnstile site key (public). Injected at runtime via `docker-entrypoint.sh` into `window.__APP_CONFIG__` |
| `HCAPTCHA_SITE_KEY` | hCaptcha site key (public). Injected at runtime via `docker-entrypoint.sh` into `window.__APP_CONFIG__` |
---

View file

@ -57,7 +57,7 @@ Unauthenticated users hitting any protected route are redirected to `/home`. Non
Provides `{ user, loading, login, loginWithToken, logout }`.
- On mount, checks `localStorage.getItem('token')` and calls `GET /auth/me` to hydrate the user object.
- `login(email, password, turnstileToken)` posts to `/auth/login`, stores the JWT, then fetches `/auth/me`.
- `login(email, password)` posts to `/auth/login`, stores the JWT, then fetches `/auth/me`. There is no captcha on login; it is rate limited by IP instead.
- `loginWithToken(token)` stores a token directly (used after registration when verification is not required).
- `logout()` clears the token and sets user to null.
@ -79,10 +79,10 @@ The public-facing marketing page. Sections:
1. **Hero** - Gradient background with tagline "Turn your textbooks into tests". Shows Sign In / Register buttons for guests, or Dashboard / My Quizzes / Question Bank links for logged-in users.
2. **Features** - 6 feature cards (Quiz from Any PDF, AI Tutor, Audio Mode, Question Bank, Track Your Progress, Fast by Design) rendered from a static `FEATURES` array. Cards have hover lift animations.
3. **AI Scribe cross-sell** - Promotes the sibling PedsHub AI Scribe product (links to `https://peds.danvics.com`).
4. **Contact form** - `ContactForm` component with name, email, type selector (Question or Apply as Moderator), and message. Validates required fields. Includes a `TurnstileWidget` for Cloudflare captcha. Posts to `POST /contact`.
5. **Auth modal** - `AuthModal` component rendered as an overlay with Sign In / Register tabs. Handles login, registration, email verification resend, and unverified-email warnings. The Turnstile widget is embedded inside the modal. The Navbar receives `onSignIn` and `onRegister` callbacks to open the modal.
4. **Contact form** - `ContactForm` component with name, email, type selector (Question or Apply as Moderator), and message. Validates required fields. Includes the shared `Captcha` component. Posts to `POST /contact`.
5. **Auth modal** - `AuthModal` component rendered as an overlay with Sign In / Register tabs. Handles login, registration, email verification resend, and unverified-email warnings. The captcha widget is embedded inside the registration tab of the modal. The Navbar receives `onSignIn` and `onRegister` callbacks to open the modal.
**Turnstile integration**: The site key comes from `window.__APP_CONFIG__.TURNSTILE_SITE_KEY`. The Turnstile script is loaded dynamically once. `TurnstileWidget` retries rendering with a 200ms polling loop until `window.turnstile` is available.
**Captcha integration**: `components/Captcha.jsx` is the only place a widget is created. The site key comes from `window.__APP_CONFIG__.HCAPTCHA_SITE_KEY`, read on each call so the runtime value is never captured at import. It appends `https://js.hcaptcha.com/1/api.js?render=explicit` once, retries rendering with a 200ms polling loop until `window.hcaptcha` exists, and hands the caller an empty token when a solve expires. With no site key it renders nothing and loads nothing, and the forms drop their captcha requirement.
### DashboardPage (`/`)
@ -330,12 +330,12 @@ The frontend Docker image uses `docker-entrypoint.sh` to inject runtime config a
```sh
cat > /usr/share/nginx/html/config.js <<EOF
window.__APP_CONFIG__ = {
TURNSTILE_SITE_KEY: "${TURNSTILE_SITE_KEY:-}"
HCAPTCHA_SITE_KEY: "${HCAPTCHA_SITE_KEY:-}"
};
EOF
exec nginx -g 'daemon off;'
```
`config.js` is loaded by `index.html` before the app bundle. Components access it via `window.__APP_CONFIG__?.TURNSTILE_SITE_KEY`. This avoids baking environment-specific values into the build.
`config.js` is loaded by `index.html` before the app bundle. Components access it via `window.__APP_CONFIG__?.HCAPTCHA_SITE_KEY`. This avoids baking environment-specific values into the build.
The frontend `.env` file sets `TURNSTILE_SITE_KEY` which is read by Docker Compose and passed to the entrypoint script.
The frontend `.env` file sets `HCAPTCHA_SITE_KEY` which is read by Docker Compose and passed to the entrypoint script.

View file

@ -85,6 +85,18 @@ Educator AI authoring: article drafts and refinement (drafts never auto-publish;
Independent review found no blockers; the refine-content medium finding and minor notes were fixed (`d3663fd`). Verification: 50 backend tests in the deployed image, 85 frontend tests across 15 suites and production build; migrations `f2a1c9d4e801` and `g4b7e2f5a903` applied; services healthy.
## hCaptcha replaces Turnstile
Cloudflare Turnstile is gone from the two places a stranger can reach: the registration form (standalone page and landing-page modal) and the contact form. Both now solve an hCaptcha challenge, verified against `https://api.hcaptcha.com/siteverify` with `secret` and `response`.
The duplication went with it. `frontend/src/components/Captcha.jsx` is the only widget — three near-copies of `TurnstileWidget` had drifted, one of them loading the script and one assuming somebody else had — and `backend/app/services/captcha.py` is the only verifier, where the two router copies had already diverged on what an unreachable provider means. That difference is now deliberate and named: registration fails open, because an outage that blocks sign-ups costs the site its users; the contact form fails shut, because a bounced message costs the sender one retry. A blank secret still skips the check entirely, so an unconfigured install keeps working.
The wire field is `captcha_token`, the runtime key is `HCAPTCHA_SITE_KEY` (injected by `frontend/docker-entrypoint.sh` into `window.__APP_CONFIG__`), the backend settings are `HCAPTCHA_SECRET_KEY` and `HCAPTCHA_SITE_KEY`, and the nginx CSP now allows `hcaptcha.com` and its subdomains instead of `challenges.cloudflare.com`.
Verification: 404 backend tests passed in the rebuilt image, 13 of them on this change — the service's four answers (unset secret, solve accepted, solve refused, provider unreachable both ways), the two 400s the gate distinguishes, the contact form's three, and the retained login rules. 451 frontend tests passed across 64 suites, 10 of them on this change. Live keys were not migrated: `HCAPTCHA_SECRET_KEY` and `HCAPTCHA_SITE_KEY` are blank until issued from the hCaptcha dashboard, which means no challenge is shown or required until they are set.
Not deployed; left in the working tree for review.
## Release state
The revamp is deployed incrementally from `feat/orthobullets-quiz-revamp` (`d3663fd`, local == remote): category tests/hierarchy, runner/study tools, articles/cards, tutor/media privacy, login Turnstile removal, share links, moderated comments and AI authoring. Live database is migrated through `g4b7e2f5a903`; backend/Celery/frontend images match the branch; pre-deploy database dumps exist under `backups/` (manual and daily). Hard-refresh (Ctrl+Shift+R) to load the new bundle.

2
frontend/docker-entrypoint.sh Normal file → Executable file
View file

@ -3,7 +3,7 @@
# This avoids baking secrets/keys into the Docker image at build time.
cat > /usr/share/nginx/html/config.js <<EOF
window.__APP_CONFIG__ = {
TURNSTILE_SITE_KEY: "${TURNSTILE_SITE_KEY:-}"
HCAPTCHA_SITE_KEY: "${HCAPTCHA_SITE_KEY:-}"
};
EOF

View file

@ -16,7 +16,7 @@ server {
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https://challenges.cloudflare.com; media-src 'self' blob:; connect-src 'self' https://challenges.cloudflare.com; font-src 'self' https://fonts.gstatic.com; frame-src 'self' https://challenges.cloudflare.com;" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://hcaptcha.com https://*.hcaptcha.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https://hcaptcha.com https://*.hcaptcha.com; media-src 'self' blob:; connect-src 'self' https://hcaptcha.com https://*.hcaptcha.com; font-src 'self' https://fonts.gstatic.com; frame-src 'self' https://hcaptcha.com https://*.hcaptcha.com;" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# API proxy to backend

View file

@ -0,0 +1,3 @@
/* Space kept even before the widget arrives, so a form does not jump under the
cursor when a third-party script finishes loading. */
.captcha { margin: 8px 0; min-height: 78px; }

View file

@ -0,0 +1,83 @@
import { useEffect, useRef } from 'react'
import './Captcha.css'
const SCRIPT_ID = 'hcaptcha-script'
/**
* Whether this deployment challenges anybody at all.
*
* Read from `window` on every call rather than captured once at import, because
* the key arrives at runtime from the frontend container's entrypoint and a
* module-level snapshot would freeze whatever happened to be there when the
* bundle first loaded.
*
* Callers need this to decide whether their submit button may be enabled: with
* no key there is no widget and so no token, and a form waiting for one would
* never let anyone through.
*/
export function captchaSiteKey() {
return window.__APP_CONFIG__?.HCAPTCHA_SITE_KEY || ''
}
/**
* The hCaptcha checkbox on the forms strangers can reach sign-up and contact.
*
* Sign-up and contact each grew their own copy of this and they had already
* drifted: one loaded the script, one assumed somebody else had. There is one
* now, and the script tag is its business alone.
*
* `onVerify` is handed the solved token, and an empty string whenever that
* token stops being good hCaptcha solves expire after a couple of minutes,
* and a form that keeps a stale one submits it and is turned away with a
* failure the person has no way to read as "tick the box again".
*/
export default function Captcha({ onVerify }) {
const box = useRef(null)
const widget = useRef(null)
// Held in a ref so the render effect can stay dependency-free: the callers
// pass an inline setter, which is a new function on every keystroke, and
// depending on it would tear the widget down mid-challenge.
const verified = useRef(onVerify)
verified.current = onVerify
useEffect(() => {
const sitekey = captchaSiteKey()
if (!sitekey) return undefined
if (!document.getElementById(SCRIPT_ID)) {
const script = document.createElement('script')
script.id = SCRIPT_ID
// Explicit rendering: the widget goes where this component puts it, and
// without the flag the script also hunts the page for elements to fill
// in by itself.
script.src = 'https://js.hcaptcha.com/1/api.js?render=explicit'
script.async = true
document.head.appendChild(script)
}
let timer
const render = () => {
// The script is fetched from a third party, so the API may not exist yet
// or ever, if the network is against us. Polling costs nothing and the
// form simply stays unsubmittable, which is the honest outcome.
if (!window.hcaptcha || !box.current) { timer = setTimeout(render, 200); return }
widget.current = window.hcaptcha.render(box.current, {
sitekey,
callback: token => verified.current(token),
'expired-callback': () => verified.current(''),
'error-callback': () => verified.current(''),
})
}
render()
return () => {
clearTimeout(timer)
if (widget.current != null) {
try { window.hcaptcha.remove(widget.current) } catch { /* already gone with the DOM */ }
}
}
}, [])
if (!captchaSiteKey()) return null
return <div className="captcha" ref={box} />
}

View file

@ -0,0 +1,71 @@
import { useState } from 'react'
import { act, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import Captcha, { captchaSiteKey } from './Captcha'
const script = () => document.getElementById('hcaptcha-script')
beforeEach(() => {
window.__APP_CONFIG__ = { HCAPTCHA_SITE_KEY: 'configured-test-site-key' }
window.hcaptcha = { render: vi.fn().mockReturnValue('synthetic-widget'), remove: vi.fn() }
})
afterEach(() => { script()?.remove(); delete window.__APP_CONFIG__ })
it('renders nothing and fetches nothing when no site key was injected', () => {
window.__APP_CONFIG__ = {}
const { container } = render(<Captcha onVerify={vi.fn()} />)
expect(captchaSiteKey()).toBe('')
expect(container).toBeEmptyDOMElement()
expect(script()).not.toBeInTheDocument()
expect(window.hcaptcha.render).not.toHaveBeenCalled()
})
it('loads the hCaptcha script once however many widgets are on the page', () => {
render(<><Captcha onVerify={vi.fn()} /><Captcha onVerify={vi.fn()} /></>)
expect(document.querySelectorAll('#hcaptcha-script')).toHaveLength(1)
expect(script().src).toBe('https://js.hcaptcha.com/1/api.js?render=explicit')
expect(window.hcaptcha.render).toHaveBeenCalledTimes(2)
expect(window.hcaptcha.render.mock.calls[0][1].sitekey).toBe('configured-test-site-key')
})
it('reports the solved token, and withdraws it when the solve expires or errors', () => {
const onVerify = vi.fn()
render(<Captcha onVerify={onVerify} />)
const options = window.hcaptcha.render.mock.calls[0][1]
act(() => options.callback('synthetic-valid-token'))
expect(onVerify).toHaveBeenLastCalledWith('synthetic-valid-token')
act(() => options['expired-callback']())
expect(onVerify).toHaveBeenLastCalledWith('')
act(() => options['error-callback']())
expect(onVerify).toHaveBeenLastCalledWith('')
})
it('keeps the same widget while the form around it is retyped', async () => {
function Form() {
const [token, setToken] = useState('')
return <><Captcha onVerify={setToken} /><output>{token || 'unsolved'}</output></>
}
render(<Form />)
act(() => window.hcaptcha.render.mock.calls[0][1].callback('synthetic-valid-token'))
expect(await screen.findByText('synthetic-valid-token')).toBeInTheDocument()
// A re-render must not tear down a challenge the person is halfway through.
expect(window.hcaptcha.render).toHaveBeenCalledTimes(1)
})
it('removes its widget on unmount so a remount is not left orphaned', () => {
const { unmount } = render(<Captcha onVerify={vi.fn()} />)
unmount()
expect(window.hcaptcha.remove).toHaveBeenCalledWith('synthetic-widget')
})
it('waits for a slow script rather than rendering into nothing', () => {
vi.useFakeTimers()
const pending = window.hcaptcha
delete window.hcaptcha
render(<Captcha onVerify={vi.fn()} />)
window.hcaptcha = pending
act(() => vi.advanceTimersByTime(200))
expect(window.hcaptcha.render).toHaveBeenCalledTimes(1)
vi.useRealTimers()
})

View file

@ -1,31 +1,9 @@
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import api from '../api/client'
import { useAuth } from '../context/AuthContext'
import Navbar from '../components/Navbar'
// Turnstile widget
const TURNSTILE_SITE_KEY = window.__APP_CONFIG__?.TURNSTILE_SITE_KEY || ''
function TurnstileWidget({ onVerify }) {
const ref = useRef(null)
const widgetId = useRef(null)
useEffect(() => {
if (!TURNSTILE_SITE_KEY) return
let timer
const tryRender = () => {
if (!window.turnstile || !ref.current) { timer = setTimeout(tryRender, 200); return }
widgetId.current = window.turnstile.render(ref.current, {
sitekey: TURNSTILE_SITE_KEY,
callback: onVerify,
})
}
tryRender()
return () => { clearTimeout(timer); if (widgetId.current != null) try { window.turnstile.remove(widgetId.current) } catch {} }
}, [])
if (!TURNSTILE_SITE_KEY) return null
return <div ref={ref} style={{ margin: '8px 0' }} />
}
import Captcha, { captchaSiteKey } from '../components/Captcha'
// Feature cards data
const FEATURES = [
@ -69,20 +47,19 @@ const FEATURES = [
// Contact form
function ContactForm() {
const [form, setForm] = useState({ name: '', email: '', type: 'question', message: '' })
const [turnstileToken, setTurnstileToken] = useState('')
const [captchaToken, setCaptchaToken] = useState('')
const [loading, setLoading] = useState(false)
const [sent, setSent] = useState(false)
const [error, setError] = useState('')
const hasTurnstile = !!TURNSTILE_SITE_KEY
const canSubmit = form.name && form.email && form.message && (!hasTurnstile || turnstileToken)
const canSubmit = form.name && form.email && form.message && (!captchaSiteKey() || captchaToken)
const submit = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await api.post('/contact', { ...form, turnstile_token: turnstileToken || null })
await api.post('/contact', { ...form, captcha_token: captchaToken || null })
setSent(true)
} catch (err) {
setError(err.response?.data?.detail || 'Something went wrong. Please try again.')
@ -156,7 +133,7 @@ function ContactForm() {
style={{ width: '100%', padding: '9px 12px', borderRadius: 8, border: '1px solid var(--border)', background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.9rem', resize: 'vertical', fontFamily: 'inherit', boxSizing: 'border-box' }}
/>
</div>
<TurnstileWidget onVerify={setTurnstileToken} />
<Captcha onVerify={setCaptchaToken} />
{error && <div style={{ color: 'var(--wrong-fg)', fontSize: '0.85rem', background: 'var(--wrong-bg)', padding: '8px 12px', borderRadius: 6 }}>{error}</div>}
<button
type="submit" disabled={loading || !canSubmit}
@ -179,7 +156,7 @@ function AuthModal({ mode, onClose, onSwitch }) {
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [unverified, setUnverified] = useState(false)
const [turnstileToken, setTurnstileToken] = useState('')
const [captchaToken, setCaptchaToken] = useState('')
const [resendSent, setResendSent] = useState(false)
const [resending, setResending] = useState(false)
const [registered, setRegistered] = useState(false)
@ -225,7 +202,7 @@ function AuthModal({ mode, onClose, onSwitch }) {
try {
const res = await api.post('/auth/register', {
email, password, name,
turnstile_token: turnstileToken || null,
captcha_token: captchaToken || null,
invite_code: inviteCode.trim() || null,
})
if (res.data.requires_verification) {
@ -371,10 +348,10 @@ function AuthModal({ mode, onClose, onSwitch }) {
</small>
</div>
)}
<TurnstileWidget onVerify={setTurnstileToken} />
<Captcha onVerify={setCaptchaToken} />
<button className="btn btn-primary" style={{ width: '100%' }}
disabled={loading || !password || password !== confirm
|| (TURNSTILE_SITE_KEY && !turnstileToken)
|| (captchaSiteKey() && !captchaToken)
|| (inviteRequired && !inviteCode.trim())}>
{loading ? 'Creating account…' : 'Sign Up'}
</button>
@ -392,16 +369,6 @@ export default function LandingPage() {
const { user } = useAuth()
const [authModal, setAuthModal] = useState(null) // null | 'login' | 'register'
// Load Turnstile script once
useEffect(() => {
if (!TURNSTILE_SITE_KEY || document.getElementById('cf-turnstile-script')) return
const s = document.createElement('script')
s.id = 'cf-turnstile-script'
s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'
s.async = true
document.head.appendChild(s)
}, [])
return (
<div style={{ minHeight: '100dvh', background: 'var(--bg)' }}>

View file

@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { beforeEach, afterEach, expect, it, vi } from 'vitest'
vi.hoisted(() => { window.__APP_CONFIG__ = { TURNSTILE_SITE_KEY: 'configured-test-site-key' } })
vi.hoisted(() => { window.__APP_CONFIG__ = { HCAPTCHA_SITE_KEY: 'configured-test-site-key' } })
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn() } }))
vi.mock('../components/Navbar', () => ({ default: ({ onSignIn, onRegister }) => <nav><button onClick={onSignIn}>Open login</button><button onClick={onRegister}>Open registration</button></nav> }))
@ -15,11 +15,11 @@ import api from '../api/client'
beforeEach(() => {
vi.resetAllMocks()
localStorage.clear()
window.turnstile = { render: vi.fn().mockReturnValue('synthetic-widget'), remove: vi.fn() }
window.hcaptcha = { render: vi.fn().mockReturnValue('synthetic-widget'), remove: vi.fn() }
api.get.mockImplementation(url => Promise.resolve({ data: url === '/auth/sso/config' ? { sso_enabled: false } : { id: 1, name: 'Test', role: 'user' } }))
api.post.mockResolvedValue({ data: { access_token: 'synthetic-login-token' } })
})
afterEach(() => { document.getElementById('cf-turnstile-script')?.remove() })
afterEach(() => { document.getElementById('hcaptcha-script')?.remove() })
function mount(Component) {
render(<MemoryRouter initialEntries={['/entry']}><AuthProvider><Routes><Route path="/entry" element={<Component />} /><Route path="/" element={<div>Signed in successfully</div>} /></Routes></AuthProvider></MemoryRouter>)
@ -34,33 +34,42 @@ async function submitLogin() {
expect(api.post).toHaveBeenCalledWith('/auth/login', { email: 'owner@example.test', password: 'synthetic-password' })
}
it('logs in on the standalone page without rendering, loading or submitting Turnstile', async () => {
it('logs in on the standalone page without rendering, loading or submitting a captcha', async () => {
mount(LoginPage)
await submitLogin()
expect(window.turnstile.render).not.toHaveBeenCalled()
expect(document.getElementById('cf-turnstile-script')).not.toBeInTheDocument()
expect(window.hcaptcha.render).not.toHaveBeenCalled()
expect(document.getElementById('hcaptcha-script')).not.toBeInTheDocument()
})
it('removes the landing login challenge while preserving the contact widget', async () => {
it('leaves the landing login free of a challenge while the contact widget stays', async () => {
mount(LandingPage)
expect(window.turnstile.render).toHaveBeenCalledTimes(1)
expect(window.hcaptcha.render).toHaveBeenCalledTimes(1)
await userEvent.click(screen.getByRole('button', { name: 'Open login' }))
expect(window.turnstile.render).toHaveBeenCalledTimes(1)
expect(window.hcaptcha.render).toHaveBeenCalledTimes(1)
await submitLogin()
})
it('keeps registration protected on the landing page', async () => {
mount(LandingPage)
await userEvent.click(screen.getByRole('button', { name: 'Open registration' }))
expect(window.turnstile.render).toHaveBeenCalledTimes(2)
expect(window.hcaptcha.render).toHaveBeenCalledTimes(2)
const signup = screen.getByRole('button', { name: 'Sign Up', exact: true })
expect(signup).toBeDisabled()
act(() => window.turnstile.render.mock.calls[1][1].callback('synthetic-valid-token'))
act(() => window.hcaptcha.render.mock.calls[1][1].callback('synthetic-valid-token'))
// Still not enough: the password has to be typed twice and agree.
expect(signup).toBeDisabled()
await userEvent.type(screen.getByLabelText('Password'), 'longenough1')
await userEvent.type(screen.getByLabelText('Confirm password'), 'longenough1')
expect(signup).toBeEnabled()
// Selected through the form rather than by label: the modal's name and email
// labels are unassociated, and the contact form below carries the same two.
const form = signup.closest('form')
await userEvent.type(form.querySelector('input[type="text"]'), 'Test Person')
await userEvent.type(form.querySelector('input[type="email"]'), 'new@example.test')
await userEvent.click(signup)
expect(api.post).toHaveBeenCalledWith('/auth/register', expect.objectContaining({
captcha_token: 'synthetic-valid-token',
}))
})
it('retains SSO-only mode on the standalone login page', async () => {
@ -68,5 +77,5 @@ it('retains SSO-only mode on the standalone login page', async () => {
mount(LoginPage)
expect(await screen.findByRole('link', { name: 'Sign in with Test SSO' })).toHaveAttribute('href', '/api/auth/sso/login')
expect(screen.queryByRole('form', { name: 'Sign in' })).not.toBeInTheDocument()
expect(window.turnstile.render).not.toHaveBeenCalled()
expect(window.hcaptcha.render).not.toHaveBeenCalled()
})

View file

@ -1,33 +1,8 @@
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
const TURNSTILE_SITE_KEY = window.__APP_CONFIG__?.TURNSTILE_SITE_KEY || ''
function TurnstileWidget({ onVerify }) {
const ref = useRef(null)
useEffect(() => {
if (!TURNSTILE_SITE_KEY) return
// Load script if needed
if (!document.getElementById('cf-turnstile-script')) {
const s = document.createElement('script')
s.id = 'cf-turnstile-script'
s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'
s.async = true
document.head.appendChild(s)
}
const tryRender = () => {
if (!window.turnstile || !ref.current) return setTimeout(tryRender, 200)
const id = window.turnstile.render(ref.current, { sitekey: TURNSTILE_SITE_KEY, callback: onVerify })
return () => { try { window.turnstile.remove(id) } catch {} }
}
const cleanup = tryRender()
return () => { if (typeof cleanup === 'function') cleanup() }
}, [])
if (!TURNSTILE_SITE_KEY) return null
return <div ref={ref} style={{ margin: '8px 0' }} />
}
import Captcha, { captchaSiteKey } from '../components/Captcha'
export default function RegisterPage() {
const [name, setName] = useState('')
@ -39,7 +14,7 @@ export default function RegisterPage() {
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [done, setDone] = useState(false)
const [turnstileToken, setTurnstileToken] = useState('')
const [captchaToken, setCaptchaToken] = useState('')
// Whether this site is invite-only. Asked before there is an account to ask
// with, so the form knows whether to want a code.
const [inviteRequired, setInviteRequired] = useState(false)
@ -62,7 +37,7 @@ export default function RegisterPage() {
try {
const res = await api.post('/auth/register', {
email, password, name,
turnstile_token: turnstileToken || null,
captcha_token: captchaToken || null,
invite_code: inviteCode.trim() || null,
})
if (res.data.requires_verification) {
@ -149,10 +124,10 @@ export default function RegisterPage() {
</small>
</div>
)}
<TurnstileWidget onVerify={setTurnstileToken} />
<Captcha onVerify={setCaptchaToken} />
<button className="btn btn-primary" style={{ width: '100%' }}
disabled={loading || !password || password !== confirm
|| (TURNSTILE_SITE_KEY && !turnstileToken) || (inviteRequired && !inviteCode.trim())}>
|| (captchaSiteKey() && !captchaToken) || (inviteRequired && !inviteCode.trim())}>
{loading ? 'Creating account...' : 'Sign Up'}
</button>
</form>