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

7.1 KiB

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:

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:

# 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

# 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

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