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
+
+
+
+
+
+
+
+
+
+
+
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 && (
+
+ {errorParam === 'invalid' ? 'Invalid action.' : errorParam}
+
+ )}
+
+
+
+
+ Pending
+ {pending.length > 0 && {pending.length}}
+
+
+ {pending.length === 0 ? (
+
+
No pending messages. All clear.
+
+ ) : (
+
+ {pending.map((entry) => (
+
+
+ pending
+ id #{entry.id}
+
+ {entry.ip_hash && ip: {entry.ip_hash}}
+
+
+
+
+ name:
+ {entry.display_name}
+
+ {entry.website && (
+
+ )}
+
+ message:
+
+
{entry.message}
+
+
+
+
+ ))}
+
+ )}
+
+
+
+ {recent.length > 0 && (
+
+ Recently moderated
+
+ {recent.map((entry) => (
+
+
+ {entry.status}
+ id #{entry.id}
+
+ {entry.display_name}
+
+ {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();
+---
+
+
+
+
admin access
+
+ {registrationMode ? (
+
+
Register your security key
+
+ No admin credentials are registered yet. Connect your YubiKey and click the button
+ below to register it as the admin credential for this site.
+
+
+ Do this immediately after deployment — once registered, no further registrations
+ will be accepted without resetting the database.
+
+
+
+
+ ) : (
+
+
Login with your security key
+
+ Insert your YubiKey and click the button below to authenticate.
+
+
+
+
+ )}
+
+
+
+{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;
+---
+
+
+
+
+
+
+
+
+
+
+
+ Messages
+ {total > 0 && ({total})}
+
+
+ {entries.length === 0 ? (
+
+
No messages yet. Be the first to leave a note!
+
+ ) : (
+
+ {entries.map((entry) => (
+
+
+ {entry.message}
+
+ ))}
+
+ )}
+
+
+ {(page > 1 || hasMore) && (
+
+ )}
+
+
+
+
+