88e00e5d41
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.
178 lines
7.1 KiB
Markdown
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).
|