From 4ca7f6b1f233792486b000c93f05f050d7b57e0f Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 12 Sep 2026 06:14:14 +0200 Subject: [PATCH] feat: Cap replaces hCaptcha, self-hosted beside the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//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 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- ADMIN.md | 2 +- README.md | 66 +++++------ backend/.env.example | 9 +- backend/app/config.py | 7 +- backend/app/services/captcha.py | 35 ++++-- backend/tests/test_captcha.py | 45 +++---- backend/tests/test_login_without_captcha.py | 4 +- docker-compose.yml | 15 +++ docs/TODO.md | 2 +- docs/api-reference.md | 4 +- docs/architecture.md | 6 +- docs/deployment.md | 4 +- docs/frontend.md | 8 +- docs/quiz-revamp-progress.md | 8 +- frontend/docker-entrypoint.sh | 2 +- frontend/nginx.conf | 19 ++- frontend/src/components/Captcha.jsx | 79 ++++++------- frontend/src/components/Captcha.test.jsx | 93 +++++++-------- frontend/src/pages/AnalysisPage.css | 51 +++++++- frontend/src/pages/AnalysisPage.jsx | 124 ++++++++++++++++---- frontend/src/pages/AnalysisPage.test.jsx | 86 +++++++++++++- frontend/src/pages/LandingPage.jsx | 13 +- frontend/src/pages/LoginCaptcha.test.jsx | 26 ++-- 23 files changed, 483 insertions(+), 225 deletions(-) diff --git a/ADMIN.md b/ADMIN.md index 548e73f..b0a3db5 100644 --- a/ADMIN.md +++ b/ADMIN.md @@ -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 | diff --git a/README.md b/README.md index a324898..2fdb2a6 100644 --- a/README.md +++ b/README.md @@ -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= MAIL_FROM=noreply@yourdomain.com MAIL_STARTTLS=true -# Bot protection — hCaptcha (backend secret) -HCAPTCHA_SECRET_KEY= +# Bot protection — Cap (backend secret) +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= +# Bot protection — Cap (public 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: └─