# 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 `` — 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).