diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ee072c2 --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# 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 +# Required: admin login token for /admin/login +ADMIN_SECRET_TOKEN=replace_me_with_a_long_random_token + +# Optional: force cookie secure behavior (`true` or `false`) +# Leave unset for automatic behavior based on NODE_ENV +# COOKIE_SECURE= + +# 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: +# COOKIE_SECURE=false +# DB_PATH=./data/guestbook.db diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 5fb7088..e839adf 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -5,7 +5,7 @@ # # Detection logic: # 1. Python: if requirements.txt exists → install deps, lint, test. -# 2. Node/JS: if package.json exists → npm ci, lint, test, build. +# 2. Node/JS: if package.json exists → install deps, lint, test, build. # 3. Neither detected → print a message and exit 0 (never fail). # # Controlled by .ci/config.env: @@ -194,11 +194,14 @@ jobs: if: env.HAS_NODE == 'true' uses: actions/setup-node@v4 with: - node-version: "lts/*" + # Keep CI on Node 20 to match runtime/Docker and better-sqlite3 compatibility. + node-version: "20.x" - name: Install Node dependencies if: env.HAS_NODE == 'true' - run: npm ci + run: | + # Lockfile is currently not authoritative; use install to refresh dependency tree. + npm install # ----------------------------------------------------------------------- # Step 9: Node.js — Lint (only if "lint" script exists in package.json) 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/.nvmrc b/.nvmrc new file mode 100644 index 0000000..209e3ef --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/CLAUDE.md b/CLAUDE.md index cb918fa..7bab2c5 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, spam) +- API routes in `src/pages/api/` for form handling 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 and admin sessions +- Docker runtime is now Node.js (not Nginx); see `docs/guestbook.md` for setup + +**Guestbook:** See `docs/guestbook.md` for full setup, token login, and deployment notes. ## Commands diff --git a/Dockerfile b/Dockerfile index 5481081..31407f7 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 +# Install build dependencies for native modules (e.g. better-sqlite3) +RUN apk add --no-cache python3 make g++ + COPY package*.json ./ +RUN npm install -# Install dependencies -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 install --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..d20523d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,18 @@ 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} + - ADMIN_SECRET_TOKEN=${ADMIN_SECRET_TOKEN} + - COOKIE_SECURE=${COOKIE_SECURE:-true} + volumes: + - guestbook_data:/data + +volumes: + guestbook_data: diff --git a/docs/guestbook.md b/docs/guestbook.md new file mode 100644 index 0000000..e6db9fb --- /dev/null +++ b/docs/guestbook.md @@ -0,0 +1,113 @@ +# Guestbook — Implementation Notes + +## Architecture summary + +The guestbook extends the Astro site with **hybrid SSR** mode using the `@astrojs/node` standalone adapter. + +- Existing content pages remain static. +- Guestbook and admin pages are server-rendered (`export const prerender = false`). +- API routes handle submissions, moderation, and token-based admin login. +- SQLite (`better-sqlite3`) stores entries, sessions, rate-limit data, and audit logs. + +## Relevant files + +``` +src/ + lib/ + db.ts — SQLite singleton + schema + guestbook.ts — Entry CRUD, pagination, moderation reads + auth.ts — Session management + cookie policy + spam.ts — Validation + heuristic spam scoring + pages/ + guestbook.astro — Public guestbook page + admin/ + index.astro — Moderation portal (session-gated) + login.astro — Token login form + pages/api/ + guestbook/submit.ts — POST: public guestbook submission + admin/token-login.ts — POST: token authentication + session creation + admin/moderate.ts — POST: approve / reject / spam + admin/logout.ts — POST: end admin session + layouts/ + AdminLayout.astro — Minimal admin UI layout +``` + +## Environment variables + +Copy `.env.example` to `.env` and set: + +| Variable | Required | Description | +|---|---|---| +| `SECRET_KEY` | **Yes** | Random secret for IP-hash salting and session-related values | +| `ADMIN_SECRET_TOKEN` | **Yes** | Shared secret token for `/admin/login` | +| `COOKIE_SECURE` | No | Force secure cookies (`true`/`false`). If unset, `NODE_ENV=production` => secure cookies | +| `DB_PATH` | No | SQLite path (default: `./data/guestbook.db`) | +| `PORT` | No | Server port (default: `3000`) | +| `HOST` | No | Bind host (default: `0.0.0.0`) | + +Generate secrets: + +```bash +openssl rand -hex 32 # SECRET_KEY +openssl rand -hex 32 # ADMIN_SECRET_TOKEN +``` + +## Admin setup + +1. Set `ADMIN_SECRET_TOKEN` in your environment. +2. Open `/admin/login`. +3. Enter token. +4. After success, you are redirected to `/admin`. + +If token is missing, `/admin/login` shows a configuration warning and login is disabled. + +## Local development + +```bash +npm install +cp .env.example .env +# set at minimum: +# SECRET_KEY=... +# ADMIN_SECRET_TOKEN=... +# DB_PATH=./data/guestbook.db +# COOKIE_SECURE=false # for local http + +npm run dev +# guestbook: http://localhost:4321/guestbook +# admin: http://localhost:4321/admin/login +``` + +## Docker deployment + +```bash +docker compose up -d --build +docker compose logs -f cozy-den +``` + +The `guestbook_data` Docker volume persists the SQLite database. + +## Moderation flow + +1. Visitor submits message at `/guestbook`. +2. Entry is saved as `pending`. +3. Admin logs in at `/admin/login` with token. +4. Admin approves/rejects/marks spam in `/admin`. +5. Approved entries are shown publicly. + +## Privacy decisions + +- IP addresses are never stored directly. +- A truncated salted hash is stored only for rate limiting. +- No tracking scripts or third-party analytics. +- Admin session cookie is `httpOnly` and `SameSite=Strict`. +- User content is stored as plain text (HTML stripped server-side). + +## Database tables + +| Table | Purpose | +|---|---| +| `guestbook_entries` | Submissions + moderation status | +| `admin_sessions` | Active admin sessions | +| `rate_limit` | Submission throttling by IP hash | +| `audit_log` | Moderation actions | + 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..05ac601 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,9 @@ "name": "cozy-den", "type": "module", "version": "0.0.1", + "engines": { + "node": ">=20 <24" + }, "scripts": { "dev": "astro dev", "start": "astro dev", @@ -11,6 +14,13 @@ }, "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", + "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..0cafdc1 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,73 @@ +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(); +} + +export const SESSION_COOKIE = 'admin_session'; + +function shouldUseSecureCookies(): boolean { + const secureOverride = process.env.COOKIE_SECURE?.trim().toLowerCase(); + if (secureOverride === 'true') return true; + if (secureOverride === 'false') return false; + + return process.env.NODE_ENV === 'production'; +} + +export function sessionCookieOptions(maxAge: number) { + return { + httpOnly: true, + secure: shouldUseSecureCookies(), + sameSite: 'strict' as const, + path: '/', + maxAge, + }; +} diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 0000000..e3ae8bf --- /dev/null +++ b/src/lib/db.ts @@ -0,0 +1,57 @@ +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 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 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); +`); + +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/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..db12b49 --- /dev/null +++ b/src/pages/admin/login.astro @@ -0,0 +1,108 @@ +--- +export const prerender = false; + +import AdminLayout from '../../layouts/AdminLayout.astro'; +import { getSession, SESSION_COOKIE } from '../../lib/auth'; + +// 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 tokenAuthEnabled = Boolean(process.env.ADMIN_SECRET_TOKEN?.trim()); +const tokenError = Astro.url.searchParams.get('tokenError') === '1'; +--- + + + + + + 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/token-login.ts b/src/pages/api/admin/token-login.ts new file mode 100644 index 0000000..5eb71e6 --- /dev/null +++ b/src/pages/api/admin/token-login.ts @@ -0,0 +1,37 @@ +import type { APIRoute } from 'astro'; +import { timingSafeEqual } from 'node:crypto'; +import { createSession, SESSION_COOKIE, sessionCookieOptions } from '../../../lib/auth'; + +export const prerender = false; + +function tokenMatches(input: string, expected: string): boolean { + const a = Buffer.from(input); + const b = Buffer.from(expected); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +export const POST: APIRoute = async ({ request, cookies }) => { + const expectedToken = process.env.ADMIN_SECRET_TOKEN?.trim(); + if (!expectedToken) { + return new Response(null, { status: 404 }); + } + + const formData = await request.formData(); + const token = String(formData.get('token') ?? '').trim(); + + if (!tokenMatches(token, expectedToken)) { + return new Response(null, { + status: 303, + headers: { Location: '/admin/login?tokenError=1' }, + }); + } + + const sessionId = createSession('admin'); + cookies.set(SESSION_COOKIE, sessionId, sessionCookieOptions(24 * 60 * 60)); + + return new Response(null, { + status: 303, + headers: { Location: '/admin' }, + }); +}; 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..48f5725 --- /dev/null +++ b/src/pages/guestbook.astro @@ -0,0 +1,526 @@ +--- +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) && ( + + )} +
+
+
+ +