You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: CLAUDE.md
+98Lines changed: 98 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -317,6 +317,104 @@ File: `src/web/mod.rs`, templates in `templates/`
317
317
318
318
---
319
319
320
+
## Captcha (trycap / Cap)
321
+
322
+
### What it does
323
+
324
+
Protects booking endpoints against bots with a privacy-first proof-of-work CAPTCHA. The feature is **opt-in**: when no configuration is stored the booking flow works exactly as before — no widget, no server-side check. The captcha is only active on booking form pages (guest-facing), never on registration or dashboard routes.
325
+
326
+
The chosen provider is [Cap](https://trycap.dev) — self-hosted, no GAFAM, no tracking, Docker-deployable. It is API-compatible with the reCAPTCHA/hCaptcha verification protocol, so switching providers in the future only requires updating `src/web/captcha.rs`.
327
+
328
+
### Configuration (admin panel)
329
+
330
+
Configured at `/dashboard/admin` → **Captcha** section. Four fields:
331
+
332
+
| Field | Required | Description |
333
+
|---|---|---|
334
+
| Instance URL | Yes | Base URL of your Cap server, e.g. `https://captcha.example.com`|
335
+
| Site key | Yes | Site key from the Cap dashboard |
336
+
| Secret | Yes | Secret key for server-to-server verification (encrypted at rest with AES-256-GCM, same pattern as OIDC client secret) |
337
+
| Widget script URL | No | Override the JS bundle URL. Defaults to `https://cdn.jsdelivr.net/npm/cap-widget`. Useful for air-gapped deployments. Changes take effect immediately after saving (CSP is rebuilt in memory). |
338
+
339
+
Leaving all three main fields empty disables the captcha. The secret uses the keep-current pattern: leaving the password field empty on save preserves the stored value.
340
+
341
+
### How it works end-to-end
342
+
343
+
**GET (booking form):** The handler reads `state.captcha_config` (a `RwLock<Option<CaptchaConfig>>`). If `Some`, it passes `captcha_enabled = true`, `captcha_api_endpoint`, and `captcha_widget_url` to the template. The `<cap-widget>` element is rendered conditionally in `templates/book.html` with all i18n attributes pre-filled via the Fluent `t()` helper.
344
+
345
+
**POST (booking submit):** The `BookForm` struct has a `captcha_token: Option<String>` field with `#[serde(rename = "cap-token")]` — the field name the Cap widget submits. After the CSRF check, `captcha::verify(&captcha_cfg, form.captcha_token.as_deref()).await` is called:
-`config = Some`, token present → POST to `<instance>/<site-key>/siteverify` with JSON `{"secret": "...", "response": "<token>"}` → parses `{"success": bool}`
349
+
350
+
The verification is implemented in `src/web/captcha.rs` and applies to **3 handlers**: `handle_booking`, `handle_booking_for_user`, `handle_group_booking`. The dynamic group booking path (`username.contains('+')`) is also protected because the check happens before the branch.
|`src/web/mod.rs`|`AppState.captcha_config: RwLock<Option<CaptchaConfig>>`, `build_csp()`, `csp_middleware()`, `admin_update_captcha()` handler, wiring in the 4 GET form handlers and 3 POST booking handlers |
358
+
|`migrations/053_captcha.sql`| Adds `captcha_instance_url`, `captcha_site_key`, `captcha_secret`, `captcha_widget_url` columns to `auth_config`|
|`templates/book.html`| Conditional `<cap-widget>` with all `data-cap-i18n-*` attributes |
361
+
|`templates/base.html`| CSS custom property overrides for `cap-widget` theming |
362
+
363
+
### Content-Security-Policy
364
+
365
+
The CSP is **dynamic** — it is rebuilt whenever the admin saves captcha settings, without a server restart (except for the widget script URL, which controls the `script-src` domain). It is stored as a pre-built string in `AppState.csp: RwLock<String>` and applied by `csp_middleware` (an Axum `from_fn_with_state` layer).
The three extra directives and why they are needed:
387
+
-**`'wasm-unsafe-eval'` in `script-src`** — Cap's proof-of-work solver runs as a WASM module. Without this, browsers block `WebAssembly.instantiate()` under a strict CSP. Falls back to a JS solver if blocked, but WASM is much faster.
388
+
-**`worker-src blob:`** — The Cap widget spawns a Web Worker via a `blob:` URL to run the solver off the main thread. `worker-src` is not inherited from `script-src` in all browsers, so it must be explicit.
389
+
-**`<widget-origin>` in `connect-src`** — The widget's JS fetches the WASM binary from the same CDN origin via `fetch()`. This is controlled by `connect-src`, not `script-src`. Both the Cap server origin (for token verification XHR) and the widget CDN origin (for WASM fetch) must be in `connect-src`.
390
+
391
+
The `csp_middleware` skips setting the header if it is already present, so individual handlers can override it if needed in the future.
392
+
393
+
### Translations
394
+
395
+
All visible strings in the Cap widget are localised via the standard Fluent system. Keys use the `captcha-` prefix and live in each language file:
396
+
397
+
Keys: `captcha-label`, `captcha-initial-state`, `captcha-verifying`, `captcha-solved`, `captcha-error`, `captcha-troubleshooting`, `captcha-wasm-disabled`, plus five `*-aria` keys for accessibility.
398
+
399
+
### Styling
400
+
401
+
The `<cap-widget>` custom element exposes CSS custom properties. They are overridden globally in `templates/base.html` to follow the app's existing theme variables:
402
+
403
+
```css
404
+
cap-widget {
405
+
--cap-background: var(--surface);
406
+
--cap-border-color: var(--border);
407
+
--cap-color: var(--text);
408
+
--cap-checkbox-background: var(--surface-hover);
409
+
--cap-spinner-color: var(--text);
410
+
--cap-spinner-background-color: var(--border);
411
+
}
412
+
```
413
+
414
+
Because `--surface`, `--border`, etc. are already overridden by `html.dark { ... }` (in `base.html`) and by each preset/custom theme (via `theme_css` in `AppState`), the widget automatically adapts to dark mode and all custom themes (Nord, Dracula, Tokyo Night, etc.) with no additional CSS.
Copy file name to clipboardExpand all lines: README.md
+59Lines changed: 59 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -194,6 +194,7 @@ Each team member's CalDAV calendars are checked for conflicts. The availability
194
194
195
195
-**Credential encryption** — CalDAV and SMTP passwords encrypted at rest with AES-256-GCM; secret key auto-generated or provided via `CALRS_SECRET_KEY`
196
196
-**Hidden password input** — passwords never echoed to the terminal
197
+
-**Privacy-first CAPTCHA** — optional [Cap](https://trycap.dev) integration (self-hosted, proof-of-work, no GAFAM). When configured, a `<cap-widget>` is shown on all booking pages and the token is verified server-to-server before any booking is processed. Disabled by default — no widget, no network call if unconfigured
197
198
198
199
### Localization
199
200
@@ -368,6 +369,64 @@ calrs serve --port 3000
368
369
369
370
The login page will show a "Sign in with SSO" button. With `--auto-register true`, users are created automatically on first OIDC login. Existing local users are linked by email.
370
371
372
+
## Captcha setup (Cap / trycap.dev)
373
+
374
+
calrs integrates [Cap](https://trycap.dev), a self-hosted proof-of-work CAPTCHA that requires no third-party service and collects no user data. The widget appears only on booking pages — never on the dashboard or registration form.
375
+
376
+
**The feature is opt-in.** If you do not configure it, the booking flow works exactly as before: no widget, no network call.
377
+
378
+
### 1. Deploy a Cap instance
379
+
380
+
Cap is distributed as a Docker image. Minimal compose example:
381
+
382
+
```yaml
383
+
services:
384
+
cap:
385
+
image: ghcr.io/trycap/cap:latest
386
+
ports:
387
+
- "3001:3001"
388
+
environment:
389
+
CAP_SECRET: your-secret-key
390
+
```
391
+
392
+
Create a site in the Cap dashboard and note the **site key** and **secret**.
393
+
394
+
### 2. Configure calrs
395
+
396
+
Go to **Admin panel → Captcha** and fill in:
397
+
398
+
| Field | Example | Notes |
399
+
|---|---|---|
400
+
| Instance URL | `https://captcha.example.com` | Base URL of your Cap server |
401
+
| Site key | `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` | From the Cap dashboard |
402
+
| Secret | `your-secret-key` | Encrypted at rest with AES-256-GCM |
403
+
| Widget script URL | *(leave empty)* | Defaults to `https://cdn.jsdelivr.net/npm/cap-widget`. Override for air-gapped deployments. |
404
+
405
+
Click **Save captcha settings**. All changes — including the widget script URL — take effect immediately, no restart required.
406
+
407
+
To disable, clear the Instance URL, Site key, and Secret fields and save.
408
+
409
+
### How verification works
410
+
411
+
1. The guest completes the proof-of-work challenge in the browser (the Cap widget handles this automatically, with a WASM solver for speed)
412
+
2. The widget writes the resulting token into a hidden `cap-token` form field
413
+
3. On submit, calrs calls `POST <instance>/<site-key>/siteverify` with the token and the secret (server-to-server, never exposed to the browser)
414
+
4. If verification fails, the guest sees the standard error page — no booking is created
415
+
416
+
### Content-Security-Policy
417
+
418
+
calrs sets a strict CSP on all responses. When Cap is configured, the following directives are added and updated automatically on every save:
419
+
420
+
| Directive | Added value | Why |
421
+
|---|---|---|
422
+
| `script-src` | `'wasm-unsafe-eval' <widget-origin>` | The widget JS loads from an external origin; `wasm-unsafe-eval` allows the WASM proof-of-work solver |
423
+
| `worker-src` | `blob:` | The widget spawns a Web Worker via a blob URL to run the solver off the main thread |
424
+
| `connect-src` | `<instance-origin> <widget-origin>` | The browser fetches the WASM binary from the widget CDN and sends the solved token to the Cap server |
425
+
426
+
When captcha is disabled, none of these directives are present — the CSP reverts to its strict baseline immediately after saving.
427
+
428
+
---
429
+
371
430
## Reverse proxy
372
431
373
432
calrs listens on HTTP (port 3000 by default). Use a reverse proxy for TLS termination.
0 commit comments