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.
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)
- Deploy the site with correct
WEBAUTHN_RP_IDandWEBAUTHN_ORIGINenv vars. - Visit
https://hiddenden.cafe/admin/loginin your browser. - Since no credentials are registered yet, you will see "Register your security key".
- Insert your YubiKey, click the button, touch the key when it flashes.
- After successful registration, the page reloads to login mode.
- Use the same key to log in — touch when prompted.
- 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
- Visitor submits a message at
/guestbook→ stored asstatus = 'pending' - Admin logs in at
/admin/loginwith YubiKey - Admin sees pending messages at
/admin - Admin clicks approve, reject, or spam
- Optional: add an internal note before moderating
- 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_KEYenv 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=Strictcookies. - 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-ForandX-Real-IPheaders so rate limiting works correctly. - WebAuthn requires that the
Originheader matchesWEBAUTHN_ORIGINexactly. - The Content-Security-Policy should allow
script-src 'self'for the WebAuthn client JS (it's bundled by Astro, served from self).