diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..733373d --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# Required: random secret used to salt IP hashes and sign sessions +# Generate with: openssl rand -hex 32 +SECRET_KEY=replace_me_with_a_random_secret + +# WebAuthn / YubiKey configuration +# rpID must match the domain your site is served from (no scheme, no port) +WEBAUTHN_RP_ID=hiddenden.cafe +# Full origin including scheme (and port if non-standard) +WEBAUTHN_ORIGIN=https://hiddenden.cafe +# Human-readable name shown in the YubiKey prompt +WEBAUTHN_RP_NAME=Cozy Den + +# Database path (Docker mounts /data as a named volume) +DB_PATH=/data/guestbook.db + +# Server binding +HOST=0.0.0.0 +PORT=3000 + +# --- Development overrides --- +# For local dev (npm run dev), override with: +# WEBAUTHN_RP_ID=localhost +# WEBAUTHN_ORIGIN=http://localhost:4321 +# DB_PATH=./data/guestbook.db diff --git a/.gitignore b/.gitignore index 28a0719..5a440b4 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,9 @@ Thumbs.db !.env.example *.pem *.key + +# ---- Guestbook SQLite database (use Docker volume in production) ---- +data/ +*.db +*.db-wal +*.db-shm diff --git a/CLAUDE.md b/CLAUDE.md index cb918fa..c8e5d8d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co **Project:** Cozy Den - Personal landing page for hiddenden.cafe **Owner:** Latte (gay furry developer, values self-hosting and privacy) -**Tech Stack:** Astro 4.x, TypeScript, Vanilla CSS, Docker + Nginx +**Tech Stack:** Astro 4.x (hybrid SSR), TypeScript, Vanilla CSS, SQLite, Docker + Node.js **Aesthetic:** Warm coffee/cappuccino theme, cozy hidden den vibes **Deployment:** Docker containers pushed to Gitea registry at git.hiddenden.cafe @@ -40,14 +40,17 @@ cozy-den/ ## Architecture Notes -This is a simple static site following standard Astro conventions: +Astro **hybrid SSR** site — most pages are statically pre-rendered, but guestbook and admin pages are server-rendered: - Layouts in `src/layouts/` for reusable page templates - Pages in `src/pages/` (routes automatically based on filename) -- All content is on a single page (`index.astro`) with multiple sections -- Custom 404 page with cozy theming -- No client-side JavaScript - pure static HTML/CSS output +- Server-side lib code in `src/lib/` (db, auth, guestbook, webauthn, spam) +- API routes in `src/pages/api/` for form handling, WebAuthn, and admin actions - CSS custom properties centralized in `BaseLayout.astro` for theming -- Accessibility improvements with ARIA labels and semantic HTML +- `output: 'hybrid'` + `@astrojs/node` adapter — Node.js standalone server in production +- SQLite database (better-sqlite3) for guestbook entries, sessions, and credentials +- Docker runtime is now Node.js (not Nginx); see `docs/guestbook.md` for setup + +**Guestbook:** See `docs/guestbook.md` for full setup, YubiKey registration, and deployment notes. ## Commands diff --git a/Dockerfile b/Dockerfile index 5481081..57465fa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,28 +1,44 @@ +# Stage 1: Build the Astro app FROM node:20-alpine AS builder WORKDIR /app -# Copy package files -COPY package*.json ./ +# Install build dependencies for native modules (e.g. better-sqlite3) +RUN apk add --no-cache python3 make g++ -# Install dependencies +COPY package*.json ./ RUN npm ci -# Copy source code COPY . . - -# Build the site RUN npm run build -# Production stage -FROM nginx:alpine +# Stage 2: Install production dependencies only +FROM node:20-alpine AS deps -# Copy built files to nginx -COPY --from=builder /app/dist /usr/share/nginx/html +WORKDIR /app -# Copy nginx config -COPY nginx.conf /etc/nginx/conf.d/default.conf +RUN apk add --no-cache python3 make g++ -EXPOSE 80 +COPY package*.json ./ +RUN npm ci --omit=dev -CMD ["nginx", "-g", "daemon off;"] +# Stage 3: Runtime image +FROM node:20-alpine AS runtime + +WORKDIR /app + +# Data directory for SQLite database +RUN mkdir -p /data + +COPY --from=builder /app/dist ./dist +COPY --from=deps /app/node_modules ./node_modules +COPY package*.json ./ + +ENV NODE_ENV=production +ENV HOST=0.0.0.0 +ENV PORT=3000 +ENV DB_PATH=/data/guestbook.db + +EXPOSE 3000 + +CMD ["node", "dist/server/entry.mjs"] diff --git a/astro.config.mjs b/astro.config.mjs index 4caa244..ae59c5a 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -1,9 +1,14 @@ import { defineConfig } from 'astro/config'; import sitemap from '@astrojs/sitemap'; +import node from '@astrojs/node'; // https://astro.build/config export default defineConfig({ site: 'https://hiddenden.cafe', + output: 'hybrid', + adapter: node({ + mode: 'standalone', + }), integrations: [ sitemap({ changefreq: 'weekly', diff --git a/docker-compose.yml b/docker-compose.yml index 424c72a..3c04222 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,19 @@ services: build: . container_name: cozy-den ports: - - "3000:80" + - "3000:3000" restart: unless-stopped environment: - NODE_ENV=production + - HOST=0.0.0.0 + - PORT=3000 + - DB_PATH=/data/guestbook.db + - SECRET_KEY=${SECRET_KEY} + - WEBAUTHN_RP_ID=${WEBAUTHN_RP_ID:-hiddenden.cafe} + - WEBAUTHN_RP_NAME=${WEBAUTHN_RP_NAME:-Cozy Den} + - WEBAUTHN_ORIGIN=${WEBAUTHN_ORIGIN:-https://hiddenden.cafe} + volumes: + - guestbook_data:/data + +volumes: + guestbook_data: diff --git a/docs/guestbook.md b/docs/guestbook.md new file mode 100644 index 0000000..20e09dd --- /dev/null +++ b/docs/guestbook.md @@ -0,0 +1,177 @@ +# 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). diff --git a/nginx.conf b/nginx.conf index f6da4c9..ae93422 100644 --- a/nginx.conf +++ b/nginx.conf @@ -1,36 +1,31 @@ +# nginx.conf — reverse proxy in front of the Astro Node.js server +# If you run cozy-den behind your own reverse proxy (Caddy, Nginx, etc.), +# this file is for reference / the docker-compose nginx service pattern. +# +# The primary server is now the Node.js process (dist/server/entry.mjs). +# Point your reverse proxy to http://cozy-den:3000 (or localhost:3000). + server { listen 80; - server_name localhost; - root /usr/share/nginx/html; - index index.html; - absolute_redirect off; - - # Gzip compression - gzip on; - gzip_vary on; - gzip_min_length 1024; - gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json image/svg+xml; + server_name hiddenden.cafe; # Security headers add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "DENY" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always; - add_header Content-Security-Policy "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; font-src 'self'; connect-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; - # Cache static assets - location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ { - expires 1y; - add_header Cache-Control "public, immutable"; - add_header X-Content-Type-Options "nosniff" always; - } - - # Main location + # Proxy to Node.js Astro server location / { - try_files $uri $uri/ =404; + proxy_pass http://localhost:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 60s; } - - # Custom error pages - error_page 404 /404.html; } diff --git a/package.json b/package.json index 728e972..d3e92b0 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,15 @@ }, "dependencies": { "astro": "^4.16.18", - "@astrojs/sitemap": "^3.2.2" + "@astrojs/sitemap": "^3.2.2", + "@astrojs/node": "^8.3.4", + "better-sqlite3": "^9.4.3", + "@simplewebauthn/server": "^9.0.3", + "@simplewebauthn/browser": "^9.0.1", + "uuid": "^9.0.1" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.10", + "@types/uuid": "^9.0.7" } } diff --git a/src/components/Nav.astro b/src/components/Nav.astro index 54efdfd..0dc2c0c 100644 --- a/src/components/Nav.astro +++ b/src/components/Nav.astro @@ -13,6 +13,7 @@ const links = [ { href: "/coffee", label: "coffee" }, { href: "/library", label: "library" }, { href: "/links", label: "links" }, + { href: "/guestbook", label: "guestbook" }, { href: "/ai", label: "ai" }, { href: "/changelog", label: "changelog" }, ]; diff --git a/src/layouts/AdminLayout.astro b/src/layouts/AdminLayout.astro new file mode 100644 index 0000000..bf9b0a1 --- /dev/null +++ b/src/layouts/AdminLayout.astro @@ -0,0 +1,200 @@ +--- +interface Props { + title: string; +} +const { title } = Astro.props; +--- + + + + + + + {title} — Cozy Den Admin + + + +
+
+ ~/admin + +
+
+
+ +
+ + + + diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..75e78ab --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,69 @@ +import { createHash, randomBytes } from 'node:crypto'; +import db from './db'; + +function getSecretKey(): string { + const key = process.env.SECRET_KEY; + if (!key) throw new Error('SECRET_KEY environment variable is required'); + return key; +} + +export function hashIP(ip: string): string { + return createHash('sha256').update(getSecretKey() + ':' + ip).digest('hex').slice(0, 16); +} + +export function generateId(): string { + return randomBytes(32).toString('hex'); +} + +export interface AdminSession { + id: string; + user_id: string; + created_at: string; + expires_at: string; +} + +export function createSession(userId: string): string { + const sessionId = generateId(); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + + db.prepare( + `INSERT INTO admin_sessions (id, user_id, expires_at) VALUES (?, ?, ?)` + ).run(sessionId, userId, expiresAt); + + return sessionId; +} + +export function getSession(sessionId: string): AdminSession | undefined { + return db + .prepare( + `SELECT * FROM admin_sessions + WHERE id = ? AND expires_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now')` + ) + .get(sessionId) as AdminSession | undefined; +} + +export function deleteSession(sessionId: string): void { + db.prepare(`DELETE FROM admin_sessions WHERE id = ?`).run(sessionId); +} + +export function cleanExpiredSessions(): void { + db.prepare( + `DELETE FROM admin_sessions WHERE expires_at <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')` + ).run(); + db.prepare( + `DELETE FROM webauthn_challenges WHERE expires_at <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')` + ).run(); +} + +export const SESSION_COOKIE = 'admin_session'; +export const CHALLENGE_COOKIE = 'webauthn_challenge'; + +export function sessionCookieOptions(maxAge: number) { + return { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict' as const, + path: '/', + maxAge, + }; +} diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 0000000..f330b58 --- /dev/null +++ b/src/lib/db.ts @@ -0,0 +1,79 @@ +import Database from 'better-sqlite3'; +import { mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; + +const dbPath = resolve(process.env.DB_PATH ?? './data/guestbook.db'); + +// Ensure directory exists +mkdirSync(dirname(dbPath), { recursive: true }); + +const db = new Database(dbPath); + +// WAL mode improves concurrent read performance +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +db.exec(` + CREATE TABLE IF NOT EXISTS guestbook_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + display_name TEXT NOT NULL, + message TEXT NOT NULL, + website TEXT, + ip_hash TEXT, + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + moderated_at TEXT, + moderation_note TEXT + ); + + CREATE TABLE IF NOT EXISTS webauthn_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + credential_id TEXT NOT NULL UNIQUE, + public_key BLOB NOT NULL, + counter INTEGER NOT NULL DEFAULT 0, + user_id TEXT NOT NULL, + user_name TEXT NOT NULL, + backed_up INTEGER NOT NULL DEFAULT 0, + transports TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + last_used_at TEXT + ); + + CREATE TABLE IF NOT EXISTS admin_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + expires_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS webauthn_challenges ( + id TEXT PRIMARY KEY, + challenge TEXT NOT NULL, + type TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + expires_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS rate_limit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ); + + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + action TEXT NOT NULL, + entry_id INTEGER, + admin_session TEXT, + note TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ); + + CREATE INDEX IF NOT EXISTS idx_entries_status ON guestbook_entries(status); + CREATE INDEX IF NOT EXISTS idx_entries_created ON guestbook_entries(created_at); + CREATE INDEX IF NOT EXISTS idx_rate_limit_ip ON rate_limit(ip_hash, created_at); + CREATE INDEX IF NOT EXISTS idx_sessions_expires ON admin_sessions(expires_at); + CREATE INDEX IF NOT EXISTS idx_challenges_expires ON webauthn_challenges(expires_at); +`); + +export default db; diff --git a/src/lib/guestbook.ts b/src/lib/guestbook.ts new file mode 100644 index 0000000..a7c0612 --- /dev/null +++ b/src/lib/guestbook.ts @@ -0,0 +1,125 @@ +import db from './db'; + +export interface GuestbookEntry { + id: number; + display_name: string; + message: string; + website: string | null; + ip_hash: string | null; + status: 'pending' | 'approved' | 'rejected' | 'spam'; + created_at: string; + moderated_at: string | null; + moderation_note: string | null; +} + +export interface SubmitData { + display_name: string; + message: string; + website: string | null; + ip_hash: string | null; +} + +const PAGE_SIZE = 20; + +export function getApprovedEntries(page = 1): { entries: GuestbookEntry[]; total: number; hasMore: boolean } { + const offset = (page - 1) * PAGE_SIZE; + const entries = db + .prepare( + `SELECT id, display_name, message, website, created_at + FROM guestbook_entries + WHERE status = 'approved' + ORDER BY created_at DESC + LIMIT ? OFFSET ?` + ) + .all(PAGE_SIZE, offset) as GuestbookEntry[]; + + const { total } = db + .prepare(`SELECT COUNT(*) as total FROM guestbook_entries WHERE status = 'approved'`) + .get() as { total: number }; + + return { entries, total, hasMore: offset + PAGE_SIZE < total }; +} + +export function getPendingEntries(): GuestbookEntry[] { + return db + .prepare( + `SELECT * FROM guestbook_entries WHERE status = 'pending' ORDER BY created_at ASC` + ) + .all() as GuestbookEntry[]; +} + +export function getRecentModerated(limit = 30): GuestbookEntry[] { + return db + .prepare( + `SELECT * FROM guestbook_entries + WHERE status IN ('approved', 'rejected', 'spam') + ORDER BY moderated_at DESC + LIMIT ?` + ) + .all(limit) as GuestbookEntry[]; +} + +export function submitEntry(data: SubmitData): number { + const result = db + .prepare( + `INSERT INTO guestbook_entries (display_name, message, website, ip_hash) + VALUES (?, ?, ?, ?)` + ) + .run(data.display_name, data.message, data.website, data.ip_hash); + + return result.lastInsertRowid as number; +} + +export function moderateEntry( + id: number, + status: 'approved' | 'rejected' | 'spam', + note: string | null, + sessionId: string +): boolean { + const result = db + .prepare( + `UPDATE guestbook_entries + SET status = ?, moderated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), moderation_note = ? + WHERE id = ? AND status = 'pending'` + ) + .run(status, note, id); + + if (result.changes > 0) { + db.prepare( + `INSERT INTO audit_log (action, entry_id, admin_session, note) VALUES (?, ?, ?, ?)` + ).run(`moderate:${status}`, id, sessionId, note); + } + + return result.changes > 0; +} + +export function checkRateLimit(ipHash: string): boolean { + // Clean up old entries (>1 hour) + db.prepare( + `DELETE FROM rate_limit WHERE created_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-1 hour')` + ).run(); + + const { count } = db + .prepare(`SELECT COUNT(*) as count FROM rate_limit WHERE ip_hash = ?`) + .get(ipHash) as { count: number }; + + // Allow max 3 submissions per hour per IP hash + return count < 3; +} + +export function recordSubmission(ipHash: string): void { + db.prepare(`INSERT INTO rate_limit (ip_hash) VALUES (?)`).run(ipHash); +} + +export function isDuplicateSubmission(displayName: string, message: string): boolean { + // Check for exact duplicate in last 24 hours (regardless of status) + const row = db + .prepare( + `SELECT id FROM guestbook_entries + WHERE display_name = ? AND message = ? + AND created_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-1 day')` + ) + .get(displayName, message); + + return row !== undefined; +} diff --git a/src/lib/spam.ts b/src/lib/spam.ts new file mode 100644 index 0000000..4a547d9 --- /dev/null +++ b/src/lib/spam.ts @@ -0,0 +1,120 @@ +// Lightweight spam detection and input sanitization + +const MAX_NAME_LENGTH = 60; +const MAX_MESSAGE_LENGTH = 1000; +const MAX_WEBSITE_LENGTH = 200; + +// Patterns that strongly suggest spam +const SPAM_PATTERNS = [ + /\b(viagra|cialis|casino|poker|lottery|bitcoin|crypto|investment|forex)\b/i, + /\b(click here|buy now|free money|earn \$|make money)\b/i, + /(https?:\/\/[^\s]{0,10}){3,}/i, // 3+ URLs in message +]; + +// Basic URL validation for the optional website field +const SAFE_URL_PATTERN = /^https?:\/\/[a-z0-9-]+(\.[a-z0-9-]+)+(\/[^\s]*)?$/i; + +export interface ValidationError { + field: string; + message: string; +} + +export function sanitizeText(input: string): string { + // Normalize whitespace: collapse runs of spaces/tabs, normalize line endings + return input + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n') + .replace(/[ \t]+/g, ' ') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +export function stripHtml(input: string): string { + // Remove anything that looks like an HTML tag + return input.replace(/<[^>]*>/g, '').replace(/&[a-z#0-9]+;/gi, (match) => { + const entities: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + ''': "'", + }; + return entities[match] ?? match; + }); +} + +export function validateWebsite(url: string): string | null { + if (!url || url.trim() === '') return null; + + const cleaned = url.trim(); + + if (cleaned.length > MAX_WEBSITE_LENGTH) return null; + if (!SAFE_URL_PATTERN.test(cleaned)) return null; + + // Block localhost and private ranges in URLs + if (/localhost|127\.|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\./i.test(cleaned)) return null; + + return cleaned; +} + +export function validateEntry(data: { + display_name: string; + message: string; + website: string; + consent: string; + honeypot: string; +}): ValidationError[] { + const errors: ValidationError[] = []; + + // Honeypot check: must be empty (bots fill it in) + if (data.honeypot && data.honeypot.trim() !== '') { + errors.push({ field: 'honeypot', message: 'Spam detected' }); + return errors; + } + + // Consent required + if (!data.consent || data.consent !== 'yes') { + errors.push({ field: 'consent', message: 'You must consent to public posting' }); + } + + // Display name validation + const name = stripHtml(sanitizeText(data.display_name)); + if (!name || name.length < 1) { + errors.push({ field: 'display_name', message: 'Please enter a display name' }); + } else if (name.length > MAX_NAME_LENGTH) { + errors.push({ field: 'display_name', message: `Name must be ${MAX_NAME_LENGTH} characters or less` }); + } + + // Message validation + const message = stripHtml(sanitizeText(data.message)); + if (!message || message.length < 1) { + errors.push({ field: 'message', message: 'Please enter a message' }); + } else if (message.length > MAX_MESSAGE_LENGTH) { + errors.push({ field: 'message', message: `Message must be ${MAX_MESSAGE_LENGTH} characters or less` }); + } + + return errors; +} + +export function scoreSpam(data: { display_name: string; message: string; website: string | null }): number { + let score = 0; + const combined = `${data.display_name} ${data.message} ${data.website ?? ''}`; + + for (const pattern of SPAM_PATTERNS) { + if (pattern.test(combined)) score += 30; + } + + // Lots of URLs in message is suspicious + const urlCount = (data.message.match(/https?:\/\//gi) ?? []).length; + if (urlCount >= 2) score += 20 * urlCount; + + // All-caps message is a mild spam signal + const upperRatio = (data.message.match(/[A-Z]/g) ?? []).length / Math.max(data.message.length, 1); + if (upperRatio > 0.6 && data.message.length > 10) score += 15; + + return score; +} + +export function isLikelySpam(data: { display_name: string; message: string; website: string | null }): boolean { + return scoreSpam(data) >= 50; +} diff --git a/src/lib/webauthn.ts b/src/lib/webauthn.ts new file mode 100644 index 0000000..98ad91b --- /dev/null +++ b/src/lib/webauthn.ts @@ -0,0 +1,205 @@ +import { + generateRegistrationOptions, + verifyRegistrationResponse, + generateAuthenticationOptions, + verifyAuthenticationResponse, + isoBase64URL, +} from '@simplewebauthn/server'; +import type { + RegistrationResponseJSON, + AuthenticationResponseJSON, + AuthenticatorTransportFuture, +} from '@simplewebauthn/server'; +import db from './db'; +import { generateId } from './auth'; + +function getRpConfig() { + return { + rpName: process.env.WEBAUTHN_RP_NAME ?? 'Cozy Den', + rpID: process.env.WEBAUTHN_RP_ID ?? 'localhost', + origin: process.env.WEBAUTHN_ORIGIN ?? 'http://localhost:4321', + }; +} + +export interface StoredCredential { + id: number; + credential_id: string; + public_key: Buffer; + counter: number; + user_id: string; + user_name: string; + backed_up: number; + transports: string | null; + created_at: string; + last_used_at: string | null; +} + +export function hasCredentials(): boolean { + const row = db.prepare(`SELECT id FROM webauthn_credentials LIMIT 1`).get(); + return row !== undefined; +} + +export function getStoredCredentials(): StoredCredential[] { + return db.prepare(`SELECT * FROM webauthn_credentials`).all() as StoredCredential[]; +} + +export function saveChallenge(challengeId: string, challenge: string, type: 'registration' | 'authentication'): void { + const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString(); // 5 min + db.prepare( + `INSERT OR REPLACE INTO webauthn_challenges (id, challenge, type, expires_at) VALUES (?, ?, ?, ?)` + ).run(challengeId, challenge, type, expiresAt); +} + +export function consumeChallenge(challengeId: string, type: 'registration' | 'authentication'): string | null { + const row = db + .prepare( + `SELECT challenge FROM webauthn_challenges + WHERE id = ? AND type = ? AND expires_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now')` + ) + .get(challengeId, type) as { challenge: string } | undefined; + + if (!row) return null; + + db.prepare(`DELETE FROM webauthn_challenges WHERE id = ?`).run(challengeId); + return row.challenge; +} + +export async function createRegistrationOptions(challengeId: string) { + const { rpName, rpID } = getRpConfig(); + const userId = 'admin'; + + const existingCredentials = getStoredCredentials().map((c) => ({ + id: isoBase64URL.toBuffer(c.credential_id), + type: 'public-key' as const, + transports: c.transports ? (JSON.parse(c.transports) as AuthenticatorTransportFuture[]) : undefined, + })); + + const options = await generateRegistrationOptions({ + rpName, + rpID, + userID: Buffer.from(userId), + userName: 'admin', + userDisplayName: 'Cozy Den Admin', + attestationType: 'none', + excludeCredentials: existingCredentials, + authenticatorSelection: { + authenticatorAttachment: 'cross-platform', // security key (YubiKey) + residentKey: 'discouraged', + userVerification: 'discouraged', + }, + timeout: 60000, + }); + + saveChallenge(challengeId, options.challenge, 'registration'); + return options; +} + +export async function verifyRegistration( + credential: RegistrationResponseJSON, + challengeId: string +): Promise<{ verified: boolean; error?: string }> { + const { rpID, origin } = getRpConfig(); + const expectedChallenge = consumeChallenge(challengeId, 'registration'); + + if (!expectedChallenge) { + return { verified: false, error: 'Challenge expired or not found' }; + } + + const verification = await verifyRegistrationResponse({ + response: credential, + expectedChallenge, + expectedOrigin: origin, + expectedRPID: rpID, + requireUserVerification: false, + }); + + if (verification.verified && verification.registrationInfo) { + const { credentialID, credentialPublicKey, counter, credentialDeviceType, credentialBackedUp } = + verification.registrationInfo; + + const transports = credential.response.transports ?? []; + + db.prepare( + `INSERT INTO webauthn_credentials + (credential_id, public_key, counter, user_id, user_name, backed_up, transports) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run( + isoBase64URL.fromBuffer(credentialID), + Buffer.from(credentialPublicKey), + counter, + 'admin', + 'admin', + credentialBackedUp ? 1 : 0, + transports.length > 0 ? JSON.stringify(transports) : null + ); + } + + return { verified: verification.verified }; +} + +export async function createAuthenticationOptions(challengeId: string) { + const { rpID } = getRpConfig(); + + const allowCredentials = getStoredCredentials().map((c) => ({ + id: isoBase64URL.toBuffer(c.credential_id), + type: 'public-key' as const, + transports: c.transports ? (JSON.parse(c.transports) as AuthenticatorTransportFuture[]) : undefined, + })); + + const options = await generateAuthenticationOptions({ + rpID, + allowCredentials, + userVerification: 'discouraged', + timeout: 60000, + }); + + saveChallenge(challengeId, options.challenge, 'authentication'); + return options; +} + +export async function verifyAuthentication( + credential: AuthenticationResponseJSON, + challengeId: string +): Promise<{ verified: boolean; userId?: string; error?: string }> { + const { rpID, origin } = getRpConfig(); + const expectedChallenge = consumeChallenge(challengeId, 'authentication'); + + if (!expectedChallenge) { + return { verified: false, error: 'Challenge expired or not found' }; + } + + const credentialId = credential.id; + const stored = db + .prepare(`SELECT * FROM webauthn_credentials WHERE credential_id = ?`) + .get(credentialId) as StoredCredential | undefined; + + if (!stored) { + return { verified: false, error: 'Credential not found' }; + } + + const verification = await verifyAuthenticationResponse({ + response: credential, + expectedChallenge, + expectedOrigin: origin, + expectedRPID: rpID, + authenticator: { + credentialID: isoBase64URL.toBuffer(stored.credential_id), + credentialPublicKey: Uint8Array.from(stored.public_key), + counter: stored.counter, + transports: stored.transports + ? (JSON.parse(stored.transports) as AuthenticatorTransportFuture[]) + : undefined, + }, + requireUserVerification: false, + }); + + if (verification.verified && verification.authenticationInfo) { + db.prepare( + `UPDATE webauthn_credentials + SET counter = ?, last_used_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + WHERE credential_id = ?` + ).run(verification.authenticationInfo.newCounter, credentialId); + } + + return { verified: verification.verified, userId: stored.user_id }; +} diff --git a/src/pages/admin/index.astro b/src/pages/admin/index.astro new file mode 100644 index 0000000..dc2391f --- /dev/null +++ b/src/pages/admin/index.astro @@ -0,0 +1,282 @@ +--- +export const prerender = false; + +import AdminLayout from '../../layouts/AdminLayout.astro'; +import { getSession, SESSION_COOKIE, cleanExpiredSessions } from '../../lib/auth'; +import { getPendingEntries, getRecentModerated } from '../../lib/guestbook'; + +const sessionId = Astro.cookies.get(SESSION_COOKIE)?.value; +const session = sessionId ? getSession(sessionId) : undefined; + +if (!session) { + return Astro.redirect('/admin/login'); +} + +// Periodic cleanup +cleanExpiredSessions(); + +const pending = getPendingEntries(); +const recent = getRecentModerated(20); + +const errorParam = Astro.url.searchParams.get('error'); +--- + + +

guestbook moderation

+ + {errorParam && ( + + )} + + +
+

+ Pending + {pending.length > 0 && {pending.length}} +

+ + {pending.length === 0 ? ( +
+

No pending messages. All clear.

+
+ ) : ( +
+ {pending.map((entry) => ( +
+ + +
+
+ name: + {entry.display_name} +
+ {entry.website && ( +
+ website: + + {entry.website} + +
+ )} +
+ message: +
+
{entry.message}
+
+ +
+ + +
+ + +
+ +
+ + + +
+
+
+ ))} +
+ )} +
+ + + {recent.length > 0 && ( +
+

Recently moderated

+
+ {recent.map((entry) => ( +
+ +

{entry.message}

+ {entry.moderation_note && ( +

note: {entry.moderation_note}

+ )} +
+ ))} +
+
+ )} +
+ + diff --git a/src/pages/admin/login.astro b/src/pages/admin/login.astro new file mode 100644 index 0000000..31fc027 --- /dev/null +++ b/src/pages/admin/login.astro @@ -0,0 +1,204 @@ +--- +export const prerender = false; + +import AdminLayout from '../../layouts/AdminLayout.astro'; +import { getSession, SESSION_COOKIE } from '../../lib/auth'; +import { hasCredentials } from '../../lib/webauthn'; + +// Redirect if already logged in +const sessionId = Astro.cookies.get(SESSION_COOKIE)?.value; +const session = sessionId ? getSession(sessionId) : undefined; +if (session) { + return Astro.redirect('/admin'); +} + +const registrationMode = !hasCredentials(); +--- + + + + + +{registrationMode ? ( + +) : ( + +)} + + diff --git a/src/pages/api/admin/logout.ts b/src/pages/api/admin/logout.ts new file mode 100644 index 0000000..2eecc5e --- /dev/null +++ b/src/pages/api/admin/logout.ts @@ -0,0 +1,14 @@ +import type { APIRoute } from 'astro'; +import { deleteSession, SESSION_COOKIE } from '../../../lib/auth'; + +export const prerender = false; + +export const POST: APIRoute = async ({ cookies }) => { + const sessionId = cookies.get(SESSION_COOKIE)?.value; + if (sessionId) { + deleteSession(sessionId); + cookies.delete(SESSION_COOKIE, { path: '/' }); + } + + return new Response(null, { status: 303, headers: { Location: '/admin/login' } }); +}; diff --git a/src/pages/api/admin/moderate.ts b/src/pages/api/admin/moderate.ts new file mode 100644 index 0000000..ab88b9a --- /dev/null +++ b/src/pages/api/admin/moderate.ts @@ -0,0 +1,39 @@ +import type { APIRoute } from 'astro'; +import { moderateEntry } from '../../../lib/guestbook'; +import { getSession, SESSION_COOKIE } from '../../../lib/auth'; + +export const prerender = false; + +export const POST: APIRoute = async ({ request, cookies }) => { + const sessionId = cookies.get(SESSION_COOKIE)?.value; + const session = sessionId ? getSession(sessionId) : undefined; + + if (!session) { + return new Response(null, { status: 303, headers: { Location: '/admin/login' } }); + } + + let formData: FormData; + try { + formData = await request.formData(); + } catch { + return new Response(null, { status: 303, headers: { Location: '/admin' } }); + } + + const id = parseInt(String(formData.get('id') ?? ''), 10); + const action = String(formData.get('action') ?? ''); + const note = String(formData.get('note') ?? '').trim() || null; + + if (isNaN(id) || !['approve', 'reject', 'spam'].includes(action)) { + return new Response(null, { status: 303, headers: { Location: '/admin?error=invalid' } }); + } + + const statusMap: Record = { + approve: 'approved', + reject: 'rejected', + spam: 'spam', + }; + + moderateEntry(id, statusMap[action], note, session.id); + + return new Response(null, { status: 303, headers: { Location: '/admin' } }); +}; diff --git a/src/pages/api/admin/webauthn/login-options.ts b/src/pages/api/admin/webauthn/login-options.ts new file mode 100644 index 0000000..b189aa7 --- /dev/null +++ b/src/pages/api/admin/webauthn/login-options.ts @@ -0,0 +1,23 @@ +import type { APIRoute } from 'astro'; +import { createAuthenticationOptions, hasCredentials } from '../../../../lib/webauthn'; +import { generateId, CHALLENGE_COOKIE, sessionCookieOptions } from '../../../../lib/auth'; + +export const prerender = false; + +export const GET: APIRoute = async ({ cookies }) => { + if (!hasCredentials()) { + return new Response(JSON.stringify({ error: 'No credentials registered' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const challengeId = generateId(); + const options = await createAuthenticationOptions(challengeId); + + cookies.set(CHALLENGE_COOKIE, challengeId, sessionCookieOptions(5 * 60)); + + return new Response(JSON.stringify(options), { + headers: { 'Content-Type': 'application/json' }, + }); +}; diff --git a/src/pages/api/admin/webauthn/login-verify.ts b/src/pages/api/admin/webauthn/login-verify.ts new file mode 100644 index 0000000..1997177 --- /dev/null +++ b/src/pages/api/admin/webauthn/login-verify.ts @@ -0,0 +1,49 @@ +import type { APIRoute } from 'astro'; +import { verifyAuthentication } from '../../../../lib/webauthn'; +import { + createSession, + CHALLENGE_COOKIE, + SESSION_COOKIE, + sessionCookieOptions, +} from '../../../../lib/auth'; + +export const prerender = false; + +export const POST: APIRoute = async ({ request, cookies }) => { + const challengeId = cookies.get(CHALLENGE_COOKIE)?.value; + if (!challengeId) { + return new Response(JSON.stringify({ error: 'No challenge found' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + let credential; + try { + credential = await request.json(); + } catch { + return new Response(JSON.stringify({ error: 'Invalid request body' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const result = await verifyAuthentication(credential, challengeId); + + // Clear challenge cookie + cookies.delete(CHALLENGE_COOKIE, { path: '/' }); + + if (result.verified && result.userId) { + const sessionId = createSession(result.userId); + cookies.set(SESSION_COOKIE, sessionId, sessionCookieOptions(24 * 60 * 60)); + + return new Response(JSON.stringify({ verified: true }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + return new Response(JSON.stringify({ verified: false, error: result.error ?? 'Authentication failed' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); +}; diff --git a/src/pages/api/admin/webauthn/register-options.ts b/src/pages/api/admin/webauthn/register-options.ts new file mode 100644 index 0000000..c27b180 --- /dev/null +++ b/src/pages/api/admin/webauthn/register-options.ts @@ -0,0 +1,24 @@ +import type { APIRoute } from 'astro'; +import { createRegistrationOptions, hasCredentials } from '../../../../lib/webauthn'; +import { generateId, CHALLENGE_COOKIE, sessionCookieOptions } from '../../../../lib/auth'; + +export const prerender = false; + +export const GET: APIRoute = async ({ cookies }) => { + // Only allow registration when no credentials exist yet + if (hasCredentials()) { + return new Response(JSON.stringify({ error: 'Admin credential already registered' }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const challengeId = generateId(); + const options = await createRegistrationOptions(challengeId); + + cookies.set(CHALLENGE_COOKIE, challengeId, sessionCookieOptions(5 * 60)); + + return new Response(JSON.stringify(options), { + headers: { 'Content-Type': 'application/json' }, + }); +}; diff --git a/src/pages/api/admin/webauthn/register-verify.ts b/src/pages/api/admin/webauthn/register-verify.ts new file mode 100644 index 0000000..c64ad77 --- /dev/null +++ b/src/pages/api/admin/webauthn/register-verify.ts @@ -0,0 +1,48 @@ +import type { APIRoute } from 'astro'; +import { verifyRegistration, hasCredentials } from '../../../../lib/webauthn'; +import { CHALLENGE_COOKIE, sessionCookieOptions } from '../../../../lib/auth'; + +export const prerender = false; + +export const POST: APIRoute = async ({ request, cookies }) => { + if (hasCredentials()) { + return new Response(JSON.stringify({ error: 'Admin credential already registered' }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const challengeId = cookies.get(CHALLENGE_COOKIE)?.value; + if (!challengeId) { + return new Response(JSON.stringify({ error: 'No challenge found' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + let credential; + try { + credential = await request.json(); + } catch { + return new Response(JSON.stringify({ error: 'Invalid request body' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const result = await verifyRegistration(credential, challengeId); + + // Clear the challenge cookie + cookies.delete(CHALLENGE_COOKIE, { path: '/' }); + + if (result.verified) { + return new Response(JSON.stringify({ verified: true }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + return new Response(JSON.stringify({ verified: false, error: result.error ?? 'Verification failed' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); +}; diff --git a/src/pages/api/guestbook/submit.ts b/src/pages/api/guestbook/submit.ts new file mode 100644 index 0000000..24246d5 --- /dev/null +++ b/src/pages/api/guestbook/submit.ts @@ -0,0 +1,83 @@ +import type { APIRoute } from 'astro'; +import { + submitEntry, + checkRateLimit, + recordSubmission, + isDuplicateSubmission, + moderateEntry, +} from '../../../lib/guestbook'; +import { hashIP } from '../../../lib/auth'; +import { validateEntry, sanitizeText, stripHtml, validateWebsite, isLikelySpam } from '../../../lib/spam'; + +export const prerender = false; + +export const POST: APIRoute = async ({ request }) => { + // Parse form data + let formData: FormData; + try { + formData = await request.formData(); + } catch { + return redirect('/guestbook?error=invalid'); + } + + const raw = { + display_name: String(formData.get('display_name') ?? ''), + message: String(formData.get('message') ?? ''), + website: String(formData.get('website') ?? ''), + consent: String(formData.get('consent') ?? ''), + honeypot: String(formData.get('address') ?? ''), // hidden honeypot field named "address" + }; + + // Validate + const errors = validateEntry(raw); + if (errors.some((e) => e.field === 'honeypot')) { + // Silent reject for bots: appear successful + return redirect('/guestbook?submitted=true'); + } + if (errors.length > 0) { + const msg = encodeURIComponent(errors[0].message); + return redirect(`/guestbook?error=${msg}`); + } + + // Sanitize + const display_name = stripHtml(sanitizeText(raw.display_name)); + const message = stripHtml(sanitizeText(raw.message)); + const website = validateWebsite(raw.website); + + // Rate limit by salted IP hash + const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() + ?? request.headers.get('x-real-ip') + ?? 'unknown'; + const ipHash = hashIP(ip); + + if (!checkRateLimit(ipHash)) { + return redirect('/guestbook?error=rate_limit'); + } + + // Duplicate check + if (isDuplicateSubmission(display_name, message)) { + return redirect('/guestbook?submitted=true'); // silent dedupe + } + + // Heuristic spam scoring: auto-mark as spam if score is high + const spamEntry = isLikelySpam({ display_name, message, website }); + + recordSubmission(ipHash); + + const id = submitEntry({ display_name, message, website, ip_hash: ipHash }); + + // If auto-detected as spam, immediately mark it + if (spamEntry) { + moderateEntry(id, 'spam', 'auto-detected', 'system'); + return redirect('/guestbook?submitted=true'); // still appear successful + } + + return redirect('/guestbook?submitted=true'); +}; + +function redirect(location: string): Response { + return new Response(null, { + status: 303, + headers: { Location: location }, + }); +} diff --git a/src/pages/guestbook.astro b/src/pages/guestbook.astro new file mode 100644 index 0000000..c101264 --- /dev/null +++ b/src/pages/guestbook.astro @@ -0,0 +1,470 @@ +--- +export const prerender = false; + +import BaseLayout from '../layouts/BaseLayout.astro'; +import { getApprovedEntries } from '../lib/guestbook'; + +const pageParam = parseInt(Astro.url.searchParams.get('page') ?? '1', 10); +const page = isNaN(pageParam) || pageParam < 1 ? 1 : pageParam; + +const { entries, total, hasMore } = getApprovedEntries(page); + +const submitted = Astro.url.searchParams.get('submitted') === 'true'; +const errorParam = Astro.url.searchParams.get('error'); + +const errorMessages: Record = { + rate_limit: 'You\'ve submitted too many messages recently. Please wait a while before trying again.', + invalid: 'Your submission could not be processed. Please check all fields and try again.', +}; +const errorMsg = errorParam ? (errorMessages[errorParam] ?? decodeURIComponent(errorParam)) : null; +--- + + +
+ + + +
+

Leave a message

+

+ Messages are reviewed before being published. Please don't include personal or + sensitive information — this is a public guestbook. +

+ + {submitted && ( + + )} + + {errorMsg && ( + + )} + +
+ + + +
+ + +
+ +
+ + + Plain text only. Max 1000 characters. +
+ +
+ + + Only https:// links. Leave blank if you don't have one. +
+ + + +
+ +
+
+
+ + +
+

+ Messages + {total > 0 && ({total})} +

+ + {entries.length === 0 ? ( +
+

No messages yet. Be the first to leave a note!

+
+ ) : ( +
+ {entries.map((entry) => ( +
+
+ + {entry.website ? ( + + {entry.display_name} + + ) : ( + entry.display_name + )} + + +
+

{entry.message}

+
+ ))} +
+ )} + + + {(page > 1 || hasMore) && ( + + )} +
+
+
+ +