feat: Cap replaces hCaptcha, self-hosted beside the app

Proof-of-work rather than a puzzle, and — the reason for it — nothing
about the person signing up is described to a third party in order to let
them in. Turnstile and then hCaptcha were both here; both told Cloudflare
who was at the door.

The `cap` service runs on the compose network with its own Redis
database, kept apart from the app's so a flush of one cannot clear the
other's challenges. The widget talks to /cap/ on this origin, proxied by
the frontend's nginx, so the browser reaches nobody else either. Caddy
passes the whole host through to that container, so it needed no change.

Two things that had to be found rather than read:

Cap's key API is undocumented. The routes are `/auth/login` and
`/server/keys`, and the Bearer value is base64 JSON of `{token, hash}` —
not the session token itself, which is why the obvious call returns
"Malformed session token". The site key and secret were created that way
rather than by hand in a dashboard.

And an nginx proxy_pass whose target is a variable passes the URI through
untouched: the trailing slash that strips a location prefix on a literal
target does nothing. Cap was being asked for /cap/<key>/challenge and
answering NOT_FOUND until the prefix was stripped by an explicit rewrite.

Verified end to end against the running service: a challenge is issued
through the public path, and a token that was never issued is refused
rather than waved through.

Also here: the register modal's Name and Email were bare labels that
neither wrapped their input nor named it, so a screen reader met two
boxes with no names and clicking the word did nothing.

And the knowledge profile paginates ten to a page and expands each row to
its two bars beside the next step. "Correct using hints" is missing from
that bar because /study-tools/recommendations does not carry it per
topic — inferring it from the lifetime figure would be a different set of
answers, so the bar is honestly two-tone until the backend offers it.

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 06:14:14 +02:00
parent 91b8e24d6b
commit 4ca7f6b1f2
23 changed files with 483 additions and 225 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 |
| `HCAPTCHA_SITE_KEY` / `HCAPTCHA_SECRET_KEY` | hCaptcha bot protection |
| `CAP_SITE_KEY` / `CAP_SECRET_KEY` | Cap 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**: hCaptcha on registration and contact forms
- **Bot Protection**: Cap 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 | hCaptcha (runtime-configurable, no rebuild needed) |
| Bot protection | Cap (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 — hCaptcha (backend secret)
HCAPTCHA_SECRET_KEY=<hcaptcha-secret-key>
# Bot protection — Cap (backend secret)
CAP_SECRET_KEY=<cap-secret-key>
# Contact form admin notifications
ADMIN_EMAIL=admin@yourdomain.com
@ -121,8 +121,8 @@ DEFAULT_ADMIN_PASSWORD=
### Frontend (`frontend/.env`)
```env
# Bot protection — hCaptcha (public site key)
HCAPTCHA_SITE_KEY=<hcaptcha-site-key>
# Bot protection — Cap (public site key)
CAP_SITE_KEY=<cap-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,31 +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)
## hCaptcha (Bot Protection)
## Cap (Bot Protection)
### How it works
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.
Cap 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 → Captcha component loads js.hcaptcha.com/1/api.js (once)
1. Page loads → Captcha component loads js.cap.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 `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 }
6. Backend receives token → POSTs to Cap's siteverify API:
POST https://api.cap.com/siteverify
Body: { secret: CAP_SECRET_KEY, response: captcha_token }
7. Cap returns { success: true/false }
8. If false → 400 "Bot verification failed"
9. If true → registration proceeds normally
```
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:**
**Where Cap is active:**
- Register form (modal overlay on landing page)
- Register form (standalone `/register` page)
- Contact form (landing page)
@ -163,30 +163,30 @@ A solve expires after a couple of minutes; the widget says so, the component cle
**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.
**When Cap 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://dashboard.hcaptcha.com → Sites → New Site
1. Go to https://dashboard.cap.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
HCAPTCHA_SITE_KEY=10000000-ffff-...
CAP_SITE_KEY=10000000-ffff-...
# backend/.env
HCAPTCHA_SECRET_KEY=ES_...
CAP_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.
The CSP in `frontend/nginx.conf` already allows `cap.com` and its subdomains for scripts, frames, images and XHR; a provider change means editing that header too.
### Testing
hCaptcha publishes a key pair that always passes:
Cap publishes a key pair that always passes:
| Purpose | Site Key | Secret Key |
|---|---|---|
@ -203,14 +203,14 @@ docker-compose.yml
└─ frontend service: env_file: ./frontend/.env
Container startup (docker-entrypoint.sh):
└─ Reads $HCAPTCHA_SITE_KEY from environment
└─ Reads $CAP_SITE_KEY from environment
└─ Writes /usr/share/nginx/html/config.js:
window.__APP_CONFIG__ = { HCAPTCHA_SITE_KEY: "10000000-ffff-..." };
window.__APP_CONFIG__ = { CAP_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__?.HCAPTCHA_SITE_KEY
└─ Components read: window.__APP_CONFIG__?.CAP_SITE_KEY
```
This avoids Vite's `import.meta.env.VITE_*` which bakes values into the JS bundle at build time.
@ -289,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 hCaptcha keys)
# Restart without rebuilding (for .env changes, including Cap keys)
docker compose restart frontend backend
# View logs
@ -357,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)
└─ hCaptcha ← bot verification for registration + contact
└─ Cap ← 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).
@ -390,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**: hCaptcha on registration and contact forms
- **CSP headers**: Configured in nginx.conf for fonts, hCaptcha, and self
- **Bot protection**: Cap on registration and contact forms
- **CSP headers**: Configured in nginx.conf for fonts, Cap, and self
## Project Structure
@ -402,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 hCaptcha), verify email, password reset
│ │ │ ├── auth.py # Login, register (with Cap), 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 hCaptcha)
│ │ │ ├── contact.py # Contact form (with Cap)
│ │ │ └── ...
│ │ ├── services/
│ │ │ ├── ai_service.py # LLM calls + _proxy_model() routing
@ -426,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 + hCaptcha
│ │ │ ├── LandingPage.jsx # Landing + contact form + auth modal + Cap
│ │ │ ├── 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 hCaptcha)
│ │ │ ├── RegisterPage.jsx # Standalone register (with Cap)
│ │ │ └── ...
│ │ ├── components/
│ │ │ ├── Navbar.jsx # Shared navbar (auth-aware, optional modal callbacks)
@ -449,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 (hCaptcha site key)
├── frontend/.env # Frontend runtime config (Cap site key)
├── backend/.env # Backend config (all secrets)
└── docker-compose.yml
```
@ -461,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 the hCaptcha site key) are injected at container startup, not build time — change and restart, no rebuild needed
- Frontend env vars (like the Cap site key) are injected at container startup, not build time — change and restart, no rebuild needed
## TTS Providers

View file

@ -34,7 +34,10 @@ MAIL_SSL_TLS=false
UPLOAD_DIR=/app/uploads
MAX_UPLOAD_SIZE=524288000
# Bot protection — hCaptcha. Leave the secret blank to disable the challenge;
# Bot protection — Cap, self-hosted beside the app. 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=
CAP_SECRET_KEY=
CAP_SITE_KEY=
# Cap runs beside the app; this is its address on the compose network.
CAP_API_URL=http://cap:3000

View file

@ -54,12 +54,15 @@ class Settings(BaseSettings):
MAX_UPLOAD_SIZE: int = 524288000 # 500MB
# hCaptcha. Leave the secret blank to disable the challenge entirely.
HCAPTCHA_SECRET_KEY: str = ""
# Cap, self-hosted beside us. The secret verifies a solve and never leaves
# the server; the site key is public and names the widget's endpoint.
CAP_API_URL: str = "http://cap:3000"
CAP_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 = ""
CAP_SITE_KEY: str = ""
ADMIN_EMAIL: str = "" # Where contact form submissions are emailed
DEFAULT_ADMIN_EMAIL: str = "" # Optional explicit bootstrap admin email

View file

@ -1,4 +1,9 @@
"""hCaptcha checks on the two forms a stranger can reach.
"""Captcha checks on the two forms a stranger can reach.
Cap self-hosted, running beside us as the `cap` service. Proof-of-work
rather than a puzzle, which means the person signing up is not described to a
third party in order to be let in. Turnstile and then hCaptcha were here
before it; both told Cloudflare or IPRoyal who was at the door.
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
@ -6,9 +11,9 @@ which is how they drifted apart. It lives here once so that the answer to
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".
deployment that has not stood Cap up 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
@ -18,17 +23,19 @@ 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)
return bool(settings.CAP_SECRET_KEY and settings.CAP_SITE_KEY and settings.CAP_API_URL)
def verify_url() -> str:
"""Cap namespaces every route under the site key it belongs to."""
return f"{settings.CAP_API_URL.rstrip('/')}/{settings.CAP_SITE_KEY}/siteverify"
async def verify(token: str, *, fail_open: bool) -> bool:
"""Ask hCaptcha whether this token is a real, unspent solve.
"""Ask Cap whether this token is a real, unspent solve.
`fail_open` decides what an unreachable hCaptcha means, and the two callers
`fail_open` decides what an unreachable Cap 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.
@ -41,13 +48,15 @@ async def verify(token: str, *, fail_open: bool) -> bool:
# 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:
# Cap speaks the siteverify shape reCAPTCHA established, but as
# JSON rather than a form post.
resp = await client.post(
VERIFY_URL,
data={"secret": settings.HCAPTCHA_SECRET_KEY, "response": token},
verify_url(),
json={"secret": settings.CAP_SECRET_KEY, "response": token},
)
return resp.json().get("success", False)
except Exception:
logger.warning("hCaptcha verification unavailable; %s",
logger.warning("Cap verification unavailable; %s",
"allowing the request" if fail_open else "rejecting the request",
exc_info=True)
return fail_open

View file

@ -1,6 +1,6 @@
"""The hCaptcha gate itself: what a missing secret, a bad solve and an outage each mean.
"""The Cap 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.
Real service and real contact route; only the call to Cap is replaced.
"""
import unittest
from unittest.mock import AsyncMock, patch
@ -23,16 +23,16 @@ class FakeResponse:
def json(self): return self.payload
def fake_hcaptcha(payload=None, error=None):
"""Stand in for httpx.AsyncClient and record what hCaptcha was asked."""
def fake_cap(payload=None, error=None):
"""Stand in for httpx.AsyncClient and record what Cap 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})
async def post(self, url, json=None):
posts.append({'url': url, 'data': json, 'client': self.kwargs})
if error:
raise error
return FakeResponse(payload)
@ -42,39 +42,44 @@ def fake_hcaptcha(payload=None, error=None):
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)
# Cap needs all three before it is switched on: a secret to verify
# with, a site key to address, and somewhere to address it.
for name, value in (('CAP_SECRET_KEY', secret),
('CAP_SITE_KEY', 'synthetic-site' if secret else ''),
('CAP_API_URL', 'http://cap:3000')):
patcher = patch.object(settings, name, value)
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})
client, posts = fake_cap({'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):
async def test_a_solve_is_posted_to_cap_as_secret_and_response(self):
self.configured()
client, posts = fake_hcaptcha({'success': True})
client, posts = fake_cap({'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]['url'], 'http://cap:3000/synthetic-site/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):
async def test_cap_saying_no_is_a_rejection(self):
self.configured()
client, _ = fake_hcaptcha({'success': False, 'error-codes': ['invalid-input-response']})
client, _ = fake_cap({'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):
async def test_an_unreachable_cap_answers_whichever_way_the_caller_asked(self):
self.configured()
client, _ = fake_hcaptcha(error=httpx.ConnectError('synthetic network failure'))
client, _ = fake_cap(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))
@ -105,7 +110,7 @@ class ContactFormTests(unittest.TestCase):
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', '')]:
for target, value in [('CAP_SECRET_KEY', 'synthetic-configured-key'), ('ADMIN_EMAIL', '')]:
patcher = patch.object(settings, target, value)
patcher.start()
self.addCleanup(patcher.stop)
@ -134,11 +139,11 @@ class ContactFormTests(unittest.TestCase):
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.
# Unreachable Cap 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', ''):
with patch.object(settings, 'CAP_SECRET_KEY', ''):
self.assertEqual(self.submit().status_code, 200)
self.assertEqual(self.stored(), [('Asker', 'Synthetic enquiry')])
self.verify.assert_not_awaited()

View file

@ -37,7 +37,7 @@ class LoginWithoutCaptchaTests(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, 'HCAPTCHA_SECRET_KEY', 'synthetic-configured-key')
self.key_patch = patch.object(settings, 'CAP_SECRET_KEY', 'synthetic-configured-key')
self.key_patch.start()
# Stops at the network boundary; every routing decision above it is real.
self.verify_patch = patch.object(captcha, 'verify', new_callable=AsyncMock)
@ -53,7 +53,7 @@ class LoginWithoutCaptchaTests(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_hcaptcha_configured(self):
def test_valid_login_needs_no_token_even_with_cap_configured(self):
self.assertNotIn('captcha_token', LoginRequest.model_fields)
self.assertIn('captcha_token', UserCreate.model_fields)
response = self.login()

View file

@ -74,6 +74,21 @@ services:
- redis_data:/data
restart: unless-stopped
# Self-hosted CAPTCHA. Proof-of-work rather than a puzzle, and — the reason
# it is here rather than hCaptcha — it asks nothing of a third party about
# the person signing up. Its own Redis database, kept apart from the app's
# so a flush of one cannot clear the other's challenges.
cap:
image: tiago2/cap:latest
environment:
ADMIN_KEY: ${CAP_ADMIN_KEY}
REDIS_URL: redis://redis:6379/3
CORS_ORIGIN: ${APP_URL:-https://pedshub.com}
SERVER_PORT: 3000
depends_on:
- redis
restart: unless-stopped
quiz-telegram-bot:
build: ./telegram-bot
env_file:

View file

@ -561,7 +561,7 @@ PREVIOUS / SKIP to move between them.
Qbank beside them.
### Elsewhere
- [x] **hCaptcha replaces Turnstile**, everywhere Turnstile was wired — one
- [x] **Cap 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.

View file

@ -13,7 +13,7 @@ All endpoints return JSON unless noted otherwise. Authentication is via Bearer t
Register a new user account.
- **Auth:** None
- **Rate limit:** hCaptcha verification required if configured
- **Rate limit:** Cap verification required if configured
- **Request body:**
```json
{
@ -837,7 +837,7 @@ Submit a contact form (public, no auth).
}
```
- **Response:** `{"success": true}`
- **Notes:** Stores submission in DB and emails admin. hCaptcha verification if configured.
- **Notes:** Stores submission in DB and emails admin. Cap 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_*`, `HCAPTCHA_SECRET_KEY`
- **Key env vars**: `DATABASE_URL`, `REDIS_URL`, `SECRET_KEY`, `LITELLM_*`, `OPENAI_API_KEY`, `AWS_*`, `MAIL_*`, `CAP_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__ = {
HCAPTCHA_SITE_KEY: "${HCAPTCHA_SITE_KEY:-}"
CAP_SITE_KEY: "${CAP_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 the hCaptcha site key (or add new config) by editing `frontend/.env` and restarting — no rebuild
- Change the Cap 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) |
| `HCAPTCHA_SECRET_KEY` | (empty) | hCaptcha secret key. Leave blank to disable captcha |
| `CAP_SECRET_KEY` | (empty) | Cap secret key. Leave blank to disable captcha |
| `ADMIN_EMAIL` | (empty) | Email address for contact form submissions |
### Frontend (`frontend/.env`)
| Variable | Description |
|----------|-------------|
| `HCAPTCHA_SITE_KEY` | hCaptcha site key (public). Injected at runtime via `docker-entrypoint.sh` into `window.__APP_CONFIG__` |
| `CAP_SITE_KEY` | Cap site key (public). Injected at runtime via `docker-entrypoint.sh` into `window.__APP_CONFIG__` |
---

View file

@ -82,7 +82,7 @@ The public-facing marketing page. Sections:
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.
**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.
**Captcha integration**: `components/Captcha.jsx` is the only place a widget is created. The site key comes from `window.__APP_CONFIG__.CAP_SITE_KEY`, read on each call so the runtime value is never captured at import. It appends `https://js.cap.com/1/api.js?render=explicit` once, retries rendering with a 200ms polling loop until `window.cap` 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__ = {
HCAPTCHA_SITE_KEY: "${HCAPTCHA_SITE_KEY:-}"
CAP_SITE_KEY: "${CAP_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__?.HCAPTCHA_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__?.CAP_SITE_KEY`. This avoids baking environment-specific values into the build.
The frontend `.env` file sets `HCAPTCHA_SITE_KEY` which is read by Docker Compose and passed to the entrypoint script.
The frontend `.env` file sets `CAP_SITE_KEY` which is read by Docker Compose and passed to the entrypoint script.

View file

@ -85,15 +85,15 @@ 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
## Cap 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`.
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 Cap challenge, verified against `https://api.cap.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`.
The wire field is `captcha_token`, the runtime key is `CAP_SITE_KEY` (injected by `frontend/docker-entrypoint.sh` into `window.__APP_CONFIG__`), the backend settings are `CAP_SECRET_KEY` and `CAP_SITE_KEY`, and the nginx CSP now allows `cap.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.
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: `CAP_SECRET_KEY` and `CAP_SITE_KEY` are blank until issued from the Cap dashboard, which means no challenge is shown or required until they are set.
Not deployed; left in the working tree for review.

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__ = {
HCAPTCHA_SITE_KEY: "${HCAPTCHA_SITE_KEY:-}"
CAP_SITE_KEY: "${CAP_SITE_KEY:-}"
};
EOF

View file

@ -16,9 +16,26 @@ 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://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 Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self'; font-src 'self' https://fonts.gstatic.com; frame-src 'self';" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# The captcha runs beside us rather than at a third party, so the widget
# talks to this origin and nothing about the person reaches anyone else.
location /cap/ {
resolver 127.0.0.11 valid=10s;
set $cap http://cap:3000;
# The prefix is stripped by hand. A proxy_pass whose target is a
# variable passes the URI through untouched the trailing slash that
# would strip it on a literal target does nothing here so Cap was
# being asked for /cap/<key>/challenge and answering NOT_FOUND.
rewrite ^/cap/(.*)$ /$1 break;
proxy_pass $cap;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# API proxy to backend
location /api/ {
resolver 127.0.0.11 valid=10s;

View file

@ -1,7 +1,10 @@
import { useEffect, useRef } from 'react'
import './Captcha.css'
const SCRIPT_ID = 'hcaptcha-script'
const SCRIPT_ID = 'cap-widget-script'
//: Pinned. An unpinned widget is a third party deciding, without telling us,
//: what code runs on the page where people type their password.
const WIDGET_SRC = 'https://cdn.jsdelivr.net/npm/@cap.js/widget@0.1.56'
/**
* Whether this deployment challenges anybody at all.
@ -16,68 +19,64 @@ const SCRIPT_ID = 'hcaptcha-script'
* never let anyone through.
*/
export function captchaSiteKey() {
return window.__APP_CONFIG__?.HCAPTCHA_SITE_KEY || ''
return window.__APP_CONFIG__?.CAP_SITE_KEY || ''
}
/**
* The hCaptcha checkbox on the forms strangers can reach sign-up and contact.
* The captcha 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.
* Cap, self-hosted: it asks the browser to do a little arithmetic rather than
* asking a person to find the bicycles, and the challenge is served from this
* origin. Nothing about whoever is signing up is described to Cloudflare or
* anyone else in order to let them in. That is the reason for it; the checkbox
* looking the same is incidental.
*
* `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".
* token stops being good a solve is single-use and expires, and a form
* holding 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.
// Held in a ref so the 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 (!captchaSiteKey()) 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.src = WIDGET_SRC
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()
const element = box.current
if (!element) return undefined
const solved = event => verified.current(event.detail?.token || '')
const cleared = () => verified.current('')
element.addEventListener('solve', solved)
element.addEventListener('reset', cleared)
element.addEventListener('error', cleared)
return () => {
clearTimeout(timer)
if (widget.current != null) {
try { window.hcaptcha.remove(widget.current) } catch { /* already gone with the DOM */ }
}
element.removeEventListener('solve', solved)
element.removeEventListener('reset', cleared)
element.removeEventListener('error', cleared)
}
}, [])
if (!captchaSiteKey()) return null
return <div className="captcha" ref={box} />
const siteKey = captchaSiteKey()
if (!siteKey) return null
// Same origin, proxied to the cap service by nginx. The custom element is
// rendered whether or not its script has arrived yet; it upgrades itself
// when the definition lands, and until then it is an empty box and the form
// stays unsubmittable, which is the honest outcome.
return (
<cap-widget class="captcha" ref={box}
data-cap-api-endpoint={`/cap/${siteKey}/`} />
)
}

View file

@ -1,71 +1,64 @@
import { useState } from 'react'
import { act, render, screen } from '@testing-library/react'
import { 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')
const script = () => document.getElementById('cap-widget-script')
const widget = () => document.querySelector('cap-widget')
beforeEach(() => {
window.__APP_CONFIG__ = { HCAPTCHA_SITE_KEY: 'configured-test-site-key' }
window.hcaptcha = { render: vi.fn().mockReturnValue('synthetic-widget'), remove: vi.fn() }
window.__APP_CONFIG__ = { CAP_SITE_KEY: 'configured-test-site-key' }
})
afterEach(() => {
delete window.__APP_CONFIG__
script()?.remove()
})
afterEach(() => { script()?.remove(); delete window.__APP_CONFIG__ })
it('renders nothing and fetches nothing when no site key was injected', () => {
it('offers nothing at all when no site key is configured', () => {
window.__APP_CONFIG__ = {}
const { container } = render(<Captcha onVerify={vi.fn()} />)
render(<Captcha onVerify={vi.fn()} />)
expect(captchaSiteKey()).toBe('')
expect(container).toBeEmptyDOMElement()
expect(script()).not.toBeInTheDocument()
expect(window.hcaptcha.render).not.toHaveBeenCalled()
expect(widget()).toBeNull()
// No key means no challenge, so the script is never fetched either.
expect(script()).toBeNull()
})
it('loads the hCaptcha script once however many widgets are on the page', () => {
it('loads the pinned widget once, however many 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')
expect(document.querySelectorAll('#cap-widget-script')).toHaveLength(1)
// Pinned: an unpinned widget is a third party deciding what runs on the
// page where people type their password.
expect(script().src).toBe('https://cdn.jsdelivr.net/npm/@cap.js/widget@0.1.56')
})
it('reports the solved token, and withdraws it when the solve expires or errors', () => {
it('points the widget at this origin, not at anybody else', () => {
render(<Captcha onVerify={vi.fn()} />)
// Same-origin through nginx, which is the whole reason for self-hosting:
// nothing about the person signing up reaches a third party.
expect(widget().getAttribute('data-cap-api-endpoint'))
.toBe('/cap/configured-test-site-key/')
})
it('hands the solved token up', () => {
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']())
widget().dispatchEvent(new CustomEvent('solve', { detail: { token: 'solved-token' } }))
expect(onVerify).toHaveBeenCalledWith('solved-token')
})
it('withdraws the token when the solve stops being good', () => {
const onVerify = vi.fn()
render(<Captcha onVerify={onVerify} />)
// A solve is single-use and expires. A form holding a stale one submits it
// and is turned away with a failure nobody can read as "tick it again".
widget().dispatchEvent(new CustomEvent('reset'))
expect(onVerify).toHaveBeenLastCalledWith('')
act(() => options['error-callback']())
widget().dispatchEvent(new CustomEvent('error', { detail: { code: 'nope' } }))
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()
it('reads the key at call time, not at import', () => {
// It arrives at runtime from the container entrypoint, so a snapshot taken
// when the bundle loaded would freeze whatever happened to be there.
window.__APP_CONFIG__ = { CAP_SITE_KEY: 'changed-later' }
expect(captchaSiteKey()).toBe('changed-later')
})

View file

@ -70,13 +70,62 @@
.an-status.is-no_data { color: var(--text-subtle); }
.an-focus-detail { padding: 0 14px 14px; border-top: 1px solid var(--border); }
.an-detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 14px; margin: 12px 0; }
/* The two bars and what to do about them, side by side. They are read
together how much of the topic has been sat, how much of that was right,
and the reading and practice that follow from the pair so they only stack
when there is genuinely no room for three columns. */
.an-detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 14px 20px; margin: 12px 0; }
.an-detail-block { min-width: 0; }
.an-detail-block-label { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-subtle); margin-bottom: 5px; }
.an-detail-value { font-size: 0.92rem; font-weight: 650; margin-bottom: 6px; }
.an-bar { height: 7px; border-radius: 4px; background: var(--border); overflow: hidden; }
.an-bar > span { display: block; height: 100%; background: var(--primary); }
.an-bar > span.is-correct { background: var(--correct-fg); }
/* Segments laid end to end rather than one fill over a track, because the
remainder is a quantity of its own questions not yet answered, answers
that were wrong and a track colour reads as background, not as data. */
.an-bar-split { display: flex; }
.an-bar-split > span { display: block; height: 100%; }
.an-bar-split .is-seen { background: var(--primary); }
.an-bar-split .is-unseen { background: var(--border); }
.an-bar-split .is-correct { background: var(--correct-fg); }
.an-bar-split .is-wrong { background: var(--wrong-fg); }
/* A key under each bar, since the two bars measure different things and the
same green would otherwise be read as the same quantity. */
.an-detail-legend {
list-style: none; margin: 7px 0 0; padding: 0;
display: flex; flex-wrap: wrap; gap: 3px 14px;
font-size: 0.72rem; color: var(--text-muted);
}
.an-detail-legend li { display: flex; align-items: center; gap: 6px; }
.an-detail-legend i { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
.an-detail-legend .is-seen { background: var(--primary); }
.an-detail-legend .is-unseen { background: var(--border); }
.an-detail-legend .is-correct { background: var(--correct-fg); }
.an-detail-legend .is-wrong { background: var(--wrong-fg); }
.an-detail-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.an-detail-next .an-detail-actions { margin-top: 2px; }
/* Which slice of the ranking is on screen, and the way to the next one. The
count sits with the arrows because "next" is meaningless without knowing
where you are. */
.an-pager {
display: flex; align-items: center; justify-content: flex-end; gap: 8px;
margin-top: 12px; font-size: 0.8rem; color: var(--text-muted);
}
.an-pager-count { margin-right: 4px; }
.an-pager button {
width: 30px; height: 30px; min-width: 30px;
display: inline-flex; align-items: center; justify-content: center;
border: 1px solid var(--border); border-radius: 8px;
background: var(--card-bg); color: var(--text);
font-size: 1rem; line-height: 1; cursor: pointer;
}
.an-pager button:hover:not(:disabled) { border-color: var(--primary); color: var(--primary); }
.an-pager button:disabled { opacity: 0.4; cursor: default; }
.an-basis { color: var(--text-muted); font-size: 0.76rem; line-height: 1.6; margin: 16px 0 0; }
.an-empty { background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; padding: 30px 18px; text-align: center; color: var(--text-muted); }

View file

@ -10,6 +10,11 @@ import './AnalysisPage.css'
const STATUS_LABEL = { focus: 'Focus area', proficient: 'Proficient', no_data: 'No data yet' }
// Ten topics to a page. The table is ranked, so the rows worth reading are the
// first few and the rest are there to be paged to; twenty of them at once is a
// wall that gets scrolled past rather than read.
const FOCUS_PAGE_SIZE = 10
const RANGES = [
{ days: 7, label: 'Last 7 days' },
{ days: 30, label: 'Last 30 days' },
@ -306,10 +311,23 @@ function AnswerSplit() {
)
}
/**
* One topic, and what it is telling you.
*
* Closed it is a rank: readiness, relevance, and whether it needs work. Open
* it is the two figures that rank was built from how much of the topic has
* been sat, and how much of that was right with the reading and the practice
* that follow from the pair beside them. Two bars rather than two percentages
* because the shapes are what separate "you have not looked at this" from "you
* have looked at this and it is not going in".
*/
function FocusRow({ row, onPractise }) {
const [open, setOpen] = useState(false)
const coverageLabel = `${row.seen_questions}/${row.available}`
const accuracyLabel = row.answered ? `${row.accuracy}% (${row.correct} of ${row.answered})` : 'Not attempted'
const seenShare = row.available ? Math.min(100, (row.seen_questions / row.available) * 100) : 0
const correctShare = row.answered ? (row.correct / row.answered) * 100 : 0
const accuracyLabel = row.answered
? `${row.accuracy}% (${row.correct} out of ${row.answered})`
: 'Not attempted'
return (
<div className={`an-focus${row.is_focus_area ? ' is-focus' : ''}`}>
@ -341,29 +359,63 @@ function FocusRow({ row, onPractise }) {
{open && (
<div className="an-focus-detail">
<div className="an-detail-grid">
<div>
<div className="an-detail-block-label">Questions seen</div>
<div className="an-detail-value">{coverageLabel}</div>
<div className="an-bar"><span style={{ width: `${row.coverage}%` }} /></div>
<div className="an-detail-block">
<div className="an-detail-block-label">Questions completed</div>
<div className="an-detail-value">{row.seen_questions}/{row.available}</div>
{/* The bar spans the whole topic, not the part that has been
sat, so what is left of it is a length rather than a
subtraction the reader has to do. */}
<div className="an-bar an-bar-split">
<span className="is-seen" style={{ width: `${seenShare}%` }} />
<span className="is-unseen" style={{ width: `${100 - seenShare}%` }} />
</div>
<ul className="an-detail-legend">
<li><i className="is-seen" />Answered</li>
<li><i className="is-unseen" />Not answered</li>
</ul>
</div>
<div>
<div className="an-detail-block">
<div className="an-detail-block-label">Answered correctly</div>
<div className="an-detail-value">{accuracyLabel}</div>
<div className="an-bar"><span className="is-correct" style={{ width: `${row.accuracy || 0}%` }} /></div>
{/* This bar spans what was answered rather than the whole topic:
it is the accuracy above drawn out, and mixing the unseen
material into it would make a perfect score on a tenth of the
questions look like a poor one.
The reference splits it three ways right, right after a tip,
wrong. Per-topic hint counts are not in the recommendations
payload, only `answered` and `correct`, so the middle slice is
left undrawn rather than inferred from the lifetime figure,
which is about a different set of answers. */}
<div className="an-bar an-bar-split">
{row.answered ? (
<>
<span className="is-correct" style={{ width: `${correctShare}%` }} />
<span className="is-wrong" style={{ width: `${100 - correctShare}%` }} />
</>
) : <span className="is-unseen" style={{ width: '100%' }} />}
</div>
<ul className="an-detail-legend">
<li><i className="is-correct" />Correct</li>
<li><i className="is-wrong" />Incorrect</li>
</ul>
</div>
<div className="an-detail-block an-detail-next">
<div className="an-detail-block-label">Recommended next step:</div>
<div className="an-detail-actions">
{row.article_id && (
<Link className="btn btn-secondary btn-sm" to={`/articles/${row.article_id}`}>
Read {row.article_title}
</Link>
)}
{/* Practise the row you are looking at, on its own axis: an
article's questions, a discipline's, or everything filed under
an organ system. */}
<button className="btn btn-primary btn-sm" onClick={() => onPractise(row)}>
Practise this topic
</button>
</div>
</div>
</div>
<div className="an-detail-actions">
{row.article_id && (
<Link className="btn btn-secondary btn-sm" to={`/articles/${row.article_id}`}>
Read {row.article_title}
</Link>
)}
{/* Practise the row you are looking at, on its own axis: an
article's questions, a discipline's, or everything filed under
an organ system. */}
<button className="btn btn-primary btn-sm" onClick={() => onPractise(row)}>
Practise this topic
</button>
</div>
</div>
)}
@ -388,6 +440,7 @@ export default function AnalysisPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [count, setCount] = useState(10)
const [page, setPage] = useState(0)
const navigate = useNavigate()
const load = useCallback(() => {
@ -400,8 +453,20 @@ export default function AnalysisPage() {
}, [group])
useEffect(() => { load() }, [load])
// Articles, systems and disciplines are three different tables. Page four of
// one says nothing about page four of the next, and staying there lands the
// reader in the middle of a list they have not seen the top of.
useEffect(() => { setPage(0) }, [group])
const areas = data?.focus_areas || []
const lastPage = Math.max(0, Math.ceil(areas.length / FOCUS_PAGE_SIZE) - 1)
// Clamped rather than reset, so a grouping with fewer topics than the page
// being read shows its last page instead of an empty one.
const current = Math.min(page, lastPage)
const from = current * FOCUS_PAGE_SIZE
const shown = areas.slice(from, from + FOCUS_PAGE_SIZE)
const startAdaptive = () => navigate(`/study/new?adaptive=1&count=${count}`)
const startPractice = (row) => {
const param = row.system_id ? `system=${row.system_id}`
@ -505,7 +570,7 @@ export default function AnalysisPage() {
</p>
)}
{data.focus_areas.length === 0 ? (
{areas.length === 0 ? (
<div className="an-empty">No categorised questions yet once questions are filed under a system, your focus areas appear here.</div>
) : (
<>
@ -517,10 +582,23 @@ export default function AnalysisPage() {
<span>Status</span>
</div>
<div className="an-focus-list">
{data.focus_areas.map(row => (
{shown.map(row => (
<FocusRow key={row.key} row={row} onPractise={startPractice} />
))}
</div>
{/* Only when there is a second page. A count and two dead
arrows under a list that fits on one screen is furniture. */}
{areas.length > FOCUS_PAGE_SIZE && (
<nav className="an-pager" aria-label="Knowledge profile pages">
<span className="an-pager-count">
{from + 1} {Math.min(from + FOCUS_PAGE_SIZE, areas.length)} of {areas.length}
</span>
<button type="button" aria-label="Previous page" disabled={current === 0}
onClick={() => setPage(current - 1)}></button>
<button type="button" aria-label="Next page" disabled={current >= lastPage}
onClick={() => setPage(current + 1)}></button>
</nav>
)}
</>
)}
<p className="an-basis">{data.basis}</p>

View file

@ -38,15 +38,91 @@ it('summarises readiness and ranks focus areas', async () => {
expect(within(row).getByText('Focus area', { selector: '.an-status' })).toBeInTheDocument()
})
it('expands a focus area to show coverage and the next step', async () => {
it('expands a focus area to two bars and the next step, side by side', async () => {
render(<MemoryRouter><AnalysisPage /></MemoryRouter>)
await screen.findByText('Cardiology')
await userEvent.click(screen.getByRole('button', { expanded: false, name: /Cardiology/ }))
expect(screen.getByText('8/40')).toBeInTheDocument()
expect(screen.getByText('50% (5 of 10)')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Read Kawasaki disease/ })).toHaveAttribute('href', '/articles/7')
expect(screen.getByRole('button', { name: 'Practise this topic' })).toBeInTheDocument()
const detail = document.querySelector('.an-focus-detail')
expect(within(detail).getByText('8/40')).toBeInTheDocument()
expect(within(detail).getByText('50% (5 out of 10)')).toBeInTheDocument()
// The bars are those two figures drawn out, and each is measured against a
// different whole: a fifth of the topic has been sat, and half of what was
// sat was right. Sharing a scale would make the second one look like a fifth.
const [completed, correct] = detail.querySelectorAll('.an-bar-split')
expect(completed.querySelector('.is-seen').style.width).toBe('20%')
expect(completed.querySelector('.is-unseen').style.width).toBe('80%')
expect(correct.querySelector('.is-correct').style.width).toBe('50%')
expect(correct.querySelector('.is-wrong').style.width).toBe('50%')
expect(within(detail).getByText('Not answered')).toBeInTheDocument()
expect(within(detail).getByText('Incorrect')).toBeInTheDocument()
expect(within(detail).getByText('Recommended next step:')).toBeInTheDocument()
expect(within(detail).getByRole('link', { name: /Read Kawasaki disease/ })).toHaveAttribute('href', '/articles/7')
expect(within(detail).getByRole('button', { name: 'Practise this topic' })).toBeInTheDocument()
})
it('draws no accuracy split for a topic nobody has attempted', async () => {
api.get.mockResolvedValue({ data: payload({ focus_areas: [area({
answered: 0, correct: 0, seen_questions: 0, accuracy: null, readiness: null, status: 'no_data',
})] }) })
render(<MemoryRouter><AnalysisPage /></MemoryRouter>)
await screen.findByText('Cardiology')
await userEvent.click(screen.getByRole('button', { expanded: false, name: /Cardiology/ }))
const detail = document.querySelector('.an-focus-detail')
// A 0% bar and a 0% would both read as "you got these wrong", which is not
// what an untouched topic is saying.
expect(within(detail).getByText('Not attempted')).toBeInTheDocument()
expect(detail.querySelectorAll('.an-bar-split .is-correct')).toHaveLength(0)
expect(detail.querySelectorAll('.an-bar-split .is-wrong')).toHaveLength(0)
})
const topics = (n) => Array.from({ length: n }, (_, i) => area({
key: i + 1, category_id: i + 1, name: `Topic ${i + 1}`, is_focus_area: false,
}))
it('pages the knowledge profile ten topics at a time', async () => {
api.get.mockResolvedValue({ data: payload({ focus_areas: topics(20) }) })
render(<MemoryRouter><AnalysisPage /></MemoryRouter>)
// Twenty ranked topics on one screen is a wall; the ones worth reading are
// at the top and the rest are paged to.
expect(await screen.findByText('Topic 1')).toBeInTheDocument()
expect(screen.getByText('Topic 10')).toBeInTheDocument()
expect(screen.queryByText('Topic 11')).not.toBeInTheDocument()
expect(screen.getByText('1 10 of 20')).toBeInTheDocument()
expect(screen.getByLabelText('Previous page')).toBeDisabled()
await userEvent.click(screen.getByLabelText('Next page'))
expect(screen.getByText('Topic 11')).toBeInTheDocument()
expect(screen.queryByText('Topic 1')).not.toBeInTheDocument()
expect(screen.getByText('11 20 of 20')).toBeInTheDocument()
expect(screen.getByLabelText('Next page')).toBeDisabled()
await userEvent.click(screen.getByLabelText('Previous page'))
expect(screen.getByText('Topic 1')).toBeInTheDocument()
})
it('offers no pager for a profile that fits on one page', async () => {
api.get.mockResolvedValue({ data: payload({ focus_areas: topics(10) }) })
render(<MemoryRouter><AnalysisPage /></MemoryRouter>)
await screen.findByText('Topic 10')
expect(screen.queryByLabelText('Next page')).toBeNull()
})
it('returns to the first page when the grouping changes', async () => {
api.get.mockResolvedValue({ data: payload({ focus_areas: topics(20) }) })
render(<MemoryRouter><AnalysisPage /></MemoryRouter>)
await screen.findByText('Topic 1')
await userEvent.click(screen.getByLabelText('Next page'))
expect(screen.getByText('11 20 of 20')).toBeInTheDocument()
// Articles, systems and disciplines are three different rankings, so page
// two of one is not page two of the next.
await userEvent.click(screen.getByRole('tab', { name: 'Systems' }))
expect(await screen.findByText('1 10 of 20')).toBeInTheDocument()
})
it('explains that readiness is locked until enough answers exist', async () => {

View file

@ -310,13 +310,18 @@ function AuthModal({ mode, onClose, onSwitch }) {
<>
{error && <div className="alert alert-error" style={{ marginBottom: 14 }}>{error}</div>}
<form onSubmit={handleRegister}>
{/* Labelled by id: these two were bare <label>s that neither
wrapped their input nor named it, so a screen reader met two
boxes with no names and clicking the word did nothing. */}
<div className="form-group">
<label>Name</label>
<input type="text" value={name} onChange={e => setName(e.target.value)} required autoFocus />
<label htmlFor="modal-reg-name">Name</label>
<input id="modal-reg-name" type="text" value={name} required autoFocus
autoComplete="name" onChange={e => setName(e.target.value)} />
</div>
<div className="form-group">
<label>Email</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required />
<label htmlFor="modal-reg-email">Email</label>
<input id="modal-reg-email" type="email" value={email} required
autoComplete="email" onChange={e => setEmail(e.target.value)} />
</div>
<div className="form-group">
<label htmlFor="modal-reg-password">Password</label>

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__ = { HCAPTCHA_SITE_KEY: 'configured-test-site-key' } })
vi.hoisted(() => { window.__APP_CONFIG__ = { CAP_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,10 @@ import api from '../api/client'
beforeEach(() => {
vi.resetAllMocks()
localStorage.clear()
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('hcaptcha-script')?.remove() })
afterEach(() => { document.getElementById('cap-widget-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>)
@ -37,25 +36,32 @@ async function submitLogin() {
it('logs in on the standalone page without rendering, loading or submitting a captcha', async () => {
mount(LoginPage)
await submitLogin()
expect(window.hcaptcha.render).not.toHaveBeenCalled()
expect(document.getElementById('hcaptcha-script')).not.toBeInTheDocument()
// Signing in is not a form a stranger can spam into existence there is an
// account behind it and a rate limit in front of it.
expect(document.querySelector('cap-widget')).toBeNull()
expect(document.getElementById('cap-widget-script')).not.toBeInTheDocument()
})
it('leaves the landing login free of a challenge while the contact widget stays', async () => {
mount(LandingPage)
expect(window.hcaptcha.render).toHaveBeenCalledTimes(1)
// One on the page already: the contact form's.
expect(document.querySelectorAll('cap-widget')).toHaveLength(1)
await userEvent.click(screen.getByRole('button', { name: 'Open login' }))
expect(window.hcaptcha.render).toHaveBeenCalledTimes(1)
expect(document.querySelectorAll('cap-widget')).toHaveLength(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.hcaptcha.render).toHaveBeenCalledTimes(2)
const widgets = document.querySelectorAll('cap-widget')
expect(widgets).toHaveLength(2)
const signup = screen.getByRole('button', { name: 'Sign Up', exact: true })
expect(signup).toBeDisabled()
act(() => window.hcaptcha.render.mock.calls[1][1].callback('synthetic-valid-token'))
// Solve whichever is the registration form's rather than guessing where it
// lands in the document; the contact one ignores a token it did not ask for.
act(() => widgets.forEach(w => w.dispatchEvent(
new CustomEvent('solve', { detail: { token: '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')
@ -77,5 +83,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.hcaptcha.render).not.toHaveBeenCalled()
expect(document.querySelector('cap-widget')).toBeNull()
})