Files
Cozy-Den/docs/guestbook.md
T
Latte 88e00e5d41 Add guestbook with WebAuthn admin and SQLite
Introduce server-rendered guestbook and moderation portal.
Persist data in SQLite (better-sqlite3); add WebAuthn YubiKey
admin auth, rate-limiting, spam heuristics, and sanitization.
Switch Docker image to run Node/standalone Astro (remove nginx),
update docker-compose, Dockerfile, astro.config, and package.json.
Add .env.example, docs/guestbook.md, gitignore updates, layouts,
API routes, and supporting lib/components/pages for the feature.
2026-03-07 20:21:39 +01:00

178 lines
7.1 KiB
Markdown

# Guestbook — Implementation Notes
## Architecture summary
The guestbook extends the existing Astro site with **hybrid SSR** mode using the `@astrojs/node` standalone adapter.
- All existing pages remain statically pre-rendered (no behavior change).
- New guestbook and admin pages are server-rendered on request (`export const prerender = false`).
- API routes handle form submissions, WebAuthn flows, and moderation actions.
- SQLite (via `better-sqlite3`) provides zero-dependency persistent storage.
- The Docker image now runs a Node.js process instead of Nginx serving static files.
## New files
```
src/
lib/
db.ts — SQLite singleton, schema initialization
guestbook.ts — Entry CRUD, rate limiting, pagination
auth.ts — Session management, IP hashing
webauthn.ts — WebAuthn registration + authentication (server)
spam.ts — Input sanitization, validation, heuristic spam scoring
pages/
guestbook.astro — Public guestbook page (SSR)
admin/
index.astro — Moderation portal (SSR, session-gated)
login.astro — YubiKey login / first-time registration
api/
guestbook/
submit.ts — POST: public submission handler
admin/
webauthn/
register-options.ts — GET: generate registration options
register-verify.ts — POST: verify and store YubiKey credential
login-options.ts — GET: generate authentication challenge
login-verify.ts — POST: verify authentication, create session
moderate.ts — POST: approve / reject / mark spam
logout.ts — POST: end admin session
layouts/
AdminLayout.astro — Minimal admin UI layout
.env.example
docs/guestbook.md — This file
```
## Environment variables
Copy `.env.example` to `.env` and fill in:
| Variable | Required | Description |
|---|---|---|
| `SECRET_KEY` | **Yes** | 32+ char random secret for IP hash salting and sessions |
| `WEBAUTHN_RP_ID` | **Yes** | Domain without scheme (e.g. `hiddenden.cafe`) |
| `WEBAUTHN_ORIGIN` | **Yes** | Full origin (e.g. `https://hiddenden.cafe`) |
| `WEBAUTHN_RP_NAME` | No | Display name in YubiKey prompt (default: `Cozy Den`) |
| `DB_PATH` | No | Path to SQLite file (default: `./data/guestbook.db`) |
| `PORT` | No | Server port (default: `3000`) |
| `HOST` | No | Bind address (default: `0.0.0.0`) |
Generate a `SECRET_KEY`:
```bash
openssl rand -hex 32
```
## First-time admin setup (YubiKey registration)
1. Deploy the site with correct `WEBAUTHN_RP_ID` and `WEBAUTHN_ORIGIN` env vars.
2. Visit `https://hiddenden.cafe/admin/login` in your browser.
3. Since no credentials are registered yet, you will see **"Register your security key"**.
4. Insert your YubiKey, click the button, touch the key when it flashes.
5. After successful registration, the page reloads to login mode.
6. Use the same key to log in — touch when prompted.
7. You are now in the moderation portal at `/admin`.
**Security note:** Registration is only possible when no credentials exist in the database.
Once registered, the registration endpoint returns `403 Forbidden`. To re-register (e.g. after a lost key), you must clear the `webauthn_credentials` table in the SQLite database:
```bash
# Access the database inside Docker
docker exec -it cozy-den sh
sqlite3 /data/guestbook.db "DELETE FROM webauthn_credentials;"
```
Then visit `/admin/login` again to register a new key.
## Local development
```bash
# Install dependencies
npm install
# Create a local .env file
cp .env.example .env
# Edit .env and set:
# SECRET_KEY=any-random-string-here
# WEBAUTHN_RP_ID=localhost
# WEBAUTHN_ORIGIN=http://localhost:4321
# DB_PATH=./data/guestbook.db
# Run dev server
npm run dev
# Visit http://localhost:4321/guestbook
# Admin portal: http://localhost:4321/admin/login
```
**WebAuthn in local dev:** Most browsers require HTTPS for WebAuthn, with one exception:
`localhost` (not `127.0.0.1`) is treated as a secure origin. Make sure `WEBAUTHN_RP_ID=localhost`
and `WEBAUTHN_ORIGIN=http://localhost:4321` when developing locally.
## Docker deployment
```bash
# Build and run
docker compose up -d --build
# The app is available on port 3000
# Point your reverse proxy (Nginx, Caddy) to http://localhost:3000
# Make sure to set X-Forwarded-For and X-Real-IP headers for rate limiting
# View logs
docker compose logs -f cozy-den
# Backup the database
docker run --rm -v cozy_den_guestbook_data:/data -v $(pwd):/backup alpine \
tar czf /backup/guestbook-backup.tar.gz /data
```
The `guestbook_data` Docker volume persists the SQLite database across container restarts and updates.
## Moderation flow
1. Visitor submits a message at `/guestbook` → stored as `status = 'pending'`
2. Admin logs in at `/admin/login` with YubiKey
3. Admin sees pending messages at `/admin`
4. Admin clicks **approve**, **reject**, or **spam**
5. Optional: add an internal note before moderating
6. Approved messages appear on `/guestbook`
Messages auto-marked as spam (by heuristic scoring) skip the pending queue and are silently rejected.
## Spam protection
- **Honeypot field:** Hidden `<input name="address">` — bots fill it, humans don't.
- **Rate limiting:** Max 3 submissions per IP hash per hour (stored in SQLite).
- **Duplicate detection:** Exact name+message matches within 24 hours are silently deduped.
- **Heuristic scoring:** Pattern matching for known spam keywords, URL counts, all-caps messages.
- **Server-side validation:** All fields sanitized, HTML stripped, length limits enforced.
- **Moderation gate:** No message appears publicly without admin approval.
## Privacy decisions
- IP addresses are **never stored**. A salted SHA-256 hash (truncated to 16 hex chars) is stored for rate limiting only.
- The `SECRET_KEY` env var is the salt — changing it invalidates all stored hashes (rate limits reset; this is acceptable).
- No user accounts, no tracking, no cookies for public visitors.
- Admin sessions use `httpOnly`, `SameSite=Strict` cookies.
- Guestbook entries are plain text only; HTML is stripped before storage.
- The admin portal is `noindex, nofollow` — not discoverable by search engines.
## Database schema overview
| Table | Purpose |
|---|---|
| `guestbook_entries` | All submissions (pending/approved/rejected/spam) |
| `webauthn_credentials` | Registered YubiKey credentials |
| `admin_sessions` | Active admin login sessions (expire after 24h) |
| `webauthn_challenges` | Temporary WebAuthn challenges (expire after 5 min) |
| `rate_limit` | Per-IP-hash submission timestamps |
| `audit_log` | Record of all moderation actions |
## Deployment note: nginx and CSP
The original `nginx.conf` served static files directly. Now that the app is a Node.js server,
the nginx config has been updated to a **reverse proxy** pattern.
If you run your own Nginx/Caddy in front:
- Pass `X-Forwarded-For` and `X-Real-IP` headers so rate limiting works correctly.
- WebAuthn requires that the `Origin` header matches `WEBAUTHN_ORIGIN` exactly.
- The Content-Security-Policy should allow `script-src 'self'` for the WebAuthn client JS (it's bundled by Astro, served from self).