Add guestbook with WebAuthn admin and SQLite
Introduce server-rendered guestbook and moderation portal. Persist data in SQLite (better-sqlite3); add WebAuthn YubiKey admin auth, rate-limiting, spam heuristics, and sanitization. Switch Docker image to run Node/standalone Astro (remove nginx), update docker-compose, Dockerfile, astro.config, and package.json. Add .env.example, docs/guestbook.md, gitignore updates, layouts, API routes, and supporting lib/components/pages for the feature.
This commit is contained in:
@@ -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
|
||||||
@@ -62,3 +62,9 @@ Thumbs.db
|
|||||||
!.env.example
|
!.env.example
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
|
|
||||||
|
# ---- Guestbook SQLite database (use Docker volume in production) ----
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
|||||||
@@ -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
|
**Project:** Cozy Den - Personal landing page for hiddenden.cafe
|
||||||
**Owner:** Latte (gay furry developer, values self-hosting and privacy)
|
**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
|
**Aesthetic:** Warm coffee/cappuccino theme, cozy hidden den vibes
|
||||||
**Deployment:** Docker containers pushed to Gitea registry at git.hiddenden.cafe
|
**Deployment:** Docker containers pushed to Gitea registry at git.hiddenden.cafe
|
||||||
|
|
||||||
@@ -40,14 +40,17 @@ cozy-den/
|
|||||||
|
|
||||||
## Architecture Notes
|
## 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
|
- Layouts in `src/layouts/` for reusable page templates
|
||||||
- Pages in `src/pages/` (routes automatically based on filename)
|
- Pages in `src/pages/` (routes automatically based on filename)
|
||||||
- All content is on a single page (`index.astro`) with multiple sections
|
- Server-side lib code in `src/lib/` (db, auth, guestbook, webauthn, spam)
|
||||||
- Custom 404 page with cozy theming
|
- API routes in `src/pages/api/` for form handling, WebAuthn, and admin actions
|
||||||
- No client-side JavaScript - pure static HTML/CSS output
|
|
||||||
- CSS custom properties centralized in `BaseLayout.astro` for theming
|
- 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
|
## Commands
|
||||||
|
|
||||||
|
|||||||
+30
-14
@@ -1,28 +1,44 @@
|
|||||||
|
# Stage 1: Build the Astro app
|
||||||
FROM node:20-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
# Install build dependencies for native modules (e.g. better-sqlite3)
|
||||||
COPY package*.json ./
|
RUN apk add --no-cache python3 make g++
|
||||||
|
|
||||||
# Install dependencies
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build the site
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Production stage
|
# Stage 2: Install production dependencies only
|
||||||
FROM nginx:alpine
|
FROM node:20-alpine AS deps
|
||||||
|
|
||||||
# Copy built files to nginx
|
WORKDIR /app
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
|
||||||
|
|
||||||
# Copy nginx config
|
RUN apk add --no-cache python3 make g++
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
|
|
||||||
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"]
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { defineConfig } from 'astro/config';
|
import { defineConfig } from 'astro/config';
|
||||||
import sitemap from '@astrojs/sitemap';
|
import sitemap from '@astrojs/sitemap';
|
||||||
|
import node from '@astrojs/node';
|
||||||
|
|
||||||
// https://astro.build/config
|
// https://astro.build/config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
site: 'https://hiddenden.cafe',
|
site: 'https://hiddenden.cafe',
|
||||||
|
output: 'hybrid',
|
||||||
|
adapter: node({
|
||||||
|
mode: 'standalone',
|
||||||
|
}),
|
||||||
integrations: [
|
integrations: [
|
||||||
sitemap({
|
sitemap({
|
||||||
changefreq: 'weekly',
|
changefreq: 'weekly',
|
||||||
|
|||||||
+13
-1
@@ -3,7 +3,19 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
container_name: cozy-den
|
container_name: cozy-den
|
||||||
ports:
|
ports:
|
||||||
- "3000:80"
|
- "3000:3000"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- 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:
|
||||||
|
|||||||
@@ -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 `<input name="address">` — bots fill it, humans don't.
|
||||||
|
- **Rate limiting:** Max 3 submissions per IP hash per hour (stored in SQLite).
|
||||||
|
- **Duplicate detection:** Exact name+message matches within 24 hours are silently deduped.
|
||||||
|
- **Heuristic scoring:** Pattern matching for known spam keywords, URL counts, all-caps messages.
|
||||||
|
- **Server-side validation:** All fields sanitized, HTML stripped, length limits enforced.
|
||||||
|
- **Moderation gate:** No message appears publicly without admin approval.
|
||||||
|
|
||||||
|
## Privacy decisions
|
||||||
|
|
||||||
|
- IP addresses are **never stored**. A salted SHA-256 hash (truncated to 16 hex chars) is stored for rate limiting only.
|
||||||
|
- The `SECRET_KEY` env var is the salt — changing it invalidates all stored hashes (rate limits reset; this is acceptable).
|
||||||
|
- No user accounts, no tracking, no cookies for public visitors.
|
||||||
|
- Admin sessions use `httpOnly`, `SameSite=Strict` cookies.
|
||||||
|
- Guestbook entries are plain text only; HTML is stripped before storage.
|
||||||
|
- The admin portal is `noindex, nofollow` — not discoverable by search engines.
|
||||||
|
|
||||||
|
## Database schema overview
|
||||||
|
|
||||||
|
| Table | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `guestbook_entries` | All submissions (pending/approved/rejected/spam) |
|
||||||
|
| `webauthn_credentials` | Registered YubiKey credentials |
|
||||||
|
| `admin_sessions` | Active admin login sessions (expire after 24h) |
|
||||||
|
| `webauthn_challenges` | Temporary WebAuthn challenges (expire after 5 min) |
|
||||||
|
| `rate_limit` | Per-IP-hash submission timestamps |
|
||||||
|
| `audit_log` | Record of all moderation actions |
|
||||||
|
|
||||||
|
## Deployment note: nginx and CSP
|
||||||
|
|
||||||
|
The original `nginx.conf` served static files directly. Now that the app is a Node.js server,
|
||||||
|
the nginx config has been updated to a **reverse proxy** pattern.
|
||||||
|
|
||||||
|
If you run your own Nginx/Caddy in front:
|
||||||
|
- Pass `X-Forwarded-For` and `X-Real-IP` headers so rate limiting works correctly.
|
||||||
|
- WebAuthn requires that the `Origin` header matches `WEBAUTHN_ORIGIN` exactly.
|
||||||
|
- The Content-Security-Policy should allow `script-src 'self'` for the WebAuthn client JS (it's bundled by Astro, served from self).
|
||||||
+18
-23
@@ -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 {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name localhost;
|
server_name hiddenden.cafe;
|
||||||
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;
|
|
||||||
|
|
||||||
# Security headers
|
# Security headers
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header X-Frame-Options "DENY" always;
|
add_header X-Frame-Options "DENY" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" 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;
|
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
|
||||||
|
|
||||||
# Cache static assets
|
# Proxy to Node.js Astro server
|
||||||
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
|
|
||||||
location / {
|
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -11,6 +11,15 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"astro": "^4.16.18",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const links = [
|
|||||||
{ href: "/coffee", label: "coffee" },
|
{ href: "/coffee", label: "coffee" },
|
||||||
{ href: "/library", label: "library" },
|
{ href: "/library", label: "library" },
|
||||||
{ href: "/links", label: "links" },
|
{ href: "/links", label: "links" },
|
||||||
|
{ href: "/guestbook", label: "guestbook" },
|
||||||
{ href: "/ai", label: "ai" },
|
{ href: "/ai", label: "ai" },
|
||||||
{ href: "/changelog", label: "changelog" },
|
{ href: "/changelog", label: "changelog" },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
const { title } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>{title} — Cozy Den Admin</title>
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div class="header-inner">
|
||||||
|
<span class="site-name">~/admin</span>
|
||||||
|
<nav>
|
||||||
|
<a href="/admin">moderation</a>
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
<a href="/guestbook">public view</a>
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
<form method="post" action="/api/admin/logout" style="display:inline">
|
||||||
|
<button type="submit" class="logout-btn">logout</button>
|
||||||
|
</form>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
<style is:global>
|
||||||
|
:root {
|
||||||
|
--color-bg: #1e1e2e;
|
||||||
|
--color-bg-light: #313244;
|
||||||
|
--color-surface: #45475a;
|
||||||
|
--color-text: #cdd6f4;
|
||||||
|
--color-text-dim: #a6adc8;
|
||||||
|
--color-accent: #cba6f7;
|
||||||
|
--color-accent-bright: #f5c2e7;
|
||||||
|
--color-warm: #f38ba8;
|
||||||
|
--color-green: #a6e3a1;
|
||||||
|
--color-peach: #fab387;
|
||||||
|
--font-body: "JetBrains Mono", "Fira Code", monospace;
|
||||||
|
--space-xs: 0.5rem;
|
||||||
|
--space-sm: 1rem;
|
||||||
|
--space-md: 1.5rem;
|
||||||
|
--space-lg: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
--color-bg: #f6efe6;
|
||||||
|
--color-bg-light: #efe1cf;
|
||||||
|
--color-surface: #d8c2a8;
|
||||||
|
--color-text: #35251a;
|
||||||
|
--color-text-dim: #6b5442;
|
||||||
|
--color-accent: #8b5e3c;
|
||||||
|
--color-accent-bright: #a16c45;
|
||||||
|
--color-warm: #b6794f;
|
||||||
|
--color-green: #3f7c47;
|
||||||
|
--color-peach: #b5693e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body { min-height: 100vh; line-height: 1.6; }
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--color-accent-bright);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
a:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
|
||||||
|
|
||||||
|
header {
|
||||||
|
background: var(--color-bg-light);
|
||||||
|
border-bottom: 1px solid var(--color-surface);
|
||||||
|
padding: var(--space-xs) var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-inner {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-name {
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-warm);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.logout-btn:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-lg) var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3 { color: var(--color-accent-bright); line-height: 1.2; }
|
||||||
|
h1 { font-size: 1.4rem; margin-bottom: var(--space-md); }
|
||||||
|
h2 { font-size: 1.1rem; margin-bottom: var(--space-sm); }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--color-bg-light);
|
||||||
|
border: 1px solid var(--color-surface);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: var(--space-md);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
background: none;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.btn:hover { opacity: 0.8; }
|
||||||
|
|
||||||
|
.btn-approve { color: var(--color-green); }
|
||||||
|
.btn-reject { color: var(--color-text-dim); }
|
||||||
|
.btn-spam { color: var(--color-warm); }
|
||||||
|
.btn-primary { color: var(--color-accent); }
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.badge-pending { background: var(--color-peach); color: #1e1e2e; }
|
||||||
|
.badge-approved { background: var(--color-green); color: #1e1e2e; }
|
||||||
|
.badge-rejected { background: var(--color-surface); color: var(--color-text-dim); }
|
||||||
|
.badge-spam { background: var(--color-warm); color: #1e1e2e; }
|
||||||
|
|
||||||
|
.meta { color: var(--color-text-dim); font-size: 0.82rem; }
|
||||||
|
.message-text { white-space: pre-wrap; word-break: break-word; }
|
||||||
|
.section-gap { margin-top: var(--space-lg); }
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
padding: var(--space-sm);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
border: 1px solid;
|
||||||
|
}
|
||||||
|
.alert-error { border-color: var(--color-warm); color: var(--color-warm); }
|
||||||
|
.alert-success { border-color: var(--color-green); color: var(--color-green); }
|
||||||
|
|
||||||
|
input, textarea, select {
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-surface);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
input:focus, textarea:focus { outline: 2px solid var(--color-accent); outline-offset: 2px; }
|
||||||
|
</style>
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+120
@@ -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<string, string> = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
''': "'",
|
||||||
|
};
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
---
|
||||||
|
|
||||||
|
<AdminLayout title="Moderation">
|
||||||
|
<h1>guestbook moderation</h1>
|
||||||
|
|
||||||
|
{errorParam && (
|
||||||
|
<div class="alert alert-error" role="alert">
|
||||||
|
{errorParam === 'invalid' ? 'Invalid action.' : errorParam}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<!-- Pending entries -->
|
||||||
|
<section aria-labelledby="pending-heading">
|
||||||
|
<h2 id="pending-heading">
|
||||||
|
Pending
|
||||||
|
{pending.length > 0 && <span class="count-badge">{pending.length}</span>}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{pending.length === 0 ? (
|
||||||
|
<div class="card empty">
|
||||||
|
<p>No pending messages. All clear.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div class="entry-list">
|
||||||
|
{pending.map((entry) => (
|
||||||
|
<article class="card entry-card">
|
||||||
|
<div class="entry-meta meta">
|
||||||
|
<span class="badge badge-pending">pending</span>
|
||||||
|
<span>id #{entry.id}</span>
|
||||||
|
<time datetime={entry.created_at}>{entry.created_at.replace('T', ' ').replace('Z', ' UTC')}</time>
|
||||||
|
{entry.ip_hash && <span title="Truncated IP hash (privacy-safe)">ip: {entry.ip_hash}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="entry-fields">
|
||||||
|
<div class="entry-field">
|
||||||
|
<span class="field-label">name:</span>
|
||||||
|
<span class="field-value">{entry.display_name}</span>
|
||||||
|
</div>
|
||||||
|
{entry.website && (
|
||||||
|
<div class="entry-field">
|
||||||
|
<span class="field-label">website:</span>
|
||||||
|
<a
|
||||||
|
href={entry.website}
|
||||||
|
class="field-value"
|
||||||
|
rel="noopener noreferrer nofollow"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{entry.website}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div class="entry-field">
|
||||||
|
<span class="field-label">message:</span>
|
||||||
|
</div>
|
||||||
|
<blockquote class="message-text">{entry.message}</blockquote>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="/api/admin/moderate" class="action-form">
|
||||||
|
<input type="hidden" name="id" value={entry.id} />
|
||||||
|
|
||||||
|
<div class="note-field">
|
||||||
|
<label for={`note-${entry.id}`} class="meta">
|
||||||
|
Note (optional):
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id={`note-${entry.id}`}
|
||||||
|
name="note"
|
||||||
|
maxlength="200"
|
||||||
|
placeholder="internal note..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-btns">
|
||||||
|
<button type="submit" name="action" value="approve" class="btn btn-approve">
|
||||||
|
approve
|
||||||
|
</button>
|
||||||
|
<button type="submit" name="action" value="reject" class="btn btn-reject">
|
||||||
|
reject
|
||||||
|
</button>
|
||||||
|
<button type="submit" name="action" value="spam" class="btn btn-spam">
|
||||||
|
spam
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Recently moderated -->
|
||||||
|
{recent.length > 0 && (
|
||||||
|
<section class="section-gap" aria-labelledby="recent-heading">
|
||||||
|
<h2 id="recent-heading">Recently moderated</h2>
|
||||||
|
<div class="entry-list">
|
||||||
|
{recent.map((entry) => (
|
||||||
|
<article class="card entry-card entry-card--compact">
|
||||||
|
<div class="entry-meta meta">
|
||||||
|
<span class:list={['badge', `badge-${entry.status}`]}>{entry.status}</span>
|
||||||
|
<span>id #{entry.id}</span>
|
||||||
|
<time datetime={entry.created_at}>{entry.created_at.slice(0, 10)}</time>
|
||||||
|
<span class="entry-name">{entry.display_name}</span>
|
||||||
|
</div>
|
||||||
|
<p class="message-text compact-msg">{entry.message}</p>
|
||||||
|
{entry.moderation_note && (
|
||||||
|
<p class="meta mod-note">note: {entry.moderation_note}</p>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</AdminLayout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
h1 {
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-badge {
|
||||||
|
background: var(--color-peach);
|
||||||
|
color: #1e1e2e;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-card {
|
||||||
|
border-left: 3px solid var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-fields {
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-field {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
color: var(--color-text-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-value {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-text {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
border-left: 2px solid var(--color-surface);
|
||||||
|
margin: var(--space-xs) 0;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
border-top: 1px solid var(--color-surface);
|
||||||
|
padding-top: var(--space-sm);
|
||||||
|
margin-top: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-field input {
|
||||||
|
max-width: 400px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btns {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 4px 14px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
background: none;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.btn:hover { opacity: 0.75; }
|
||||||
|
.btn-approve { color: var(--color-green); }
|
||||||
|
.btn-reject { color: var(--color-text-dim); }
|
||||||
|
.btn-spam { color: var(--color-warm); }
|
||||||
|
|
||||||
|
.entry-card--compact {
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-msg {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-dim);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
max-height: 3em;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod-note {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-dim);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-name {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: var(--color-text-dim);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
padding: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-gap {
|
||||||
|
margin-top: var(--space-xl);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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();
|
||||||
|
---
|
||||||
|
|
||||||
|
<AdminLayout title="Login">
|
||||||
|
<div class="login-wrap">
|
||||||
|
<h1>admin access</h1>
|
||||||
|
|
||||||
|
{registrationMode ? (
|
||||||
|
<div class="card">
|
||||||
|
<h2>Register your security key</h2>
|
||||||
|
<p class="info-text">
|
||||||
|
No admin credentials are registered yet. Connect your YubiKey and click the button
|
||||||
|
below to register it as the admin credential for this site.
|
||||||
|
</p>
|
||||||
|
<p class="warning-text">
|
||||||
|
Do this immediately after deployment — once registered, no further registrations
|
||||||
|
will be accepted without resetting the database.
|
||||||
|
</p>
|
||||||
|
<button id="register-btn" class="btn btn-primary" type="button">
|
||||||
|
Register YubiKey
|
||||||
|
</button>
|
||||||
|
<p id="register-status" class="status-text" aria-live="polite"></p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div class="card">
|
||||||
|
<h2>Login with your security key</h2>
|
||||||
|
<p class="info-text">
|
||||||
|
Insert your YubiKey and click the button below to authenticate.
|
||||||
|
</p>
|
||||||
|
<button id="login-btn" class="btn btn-primary" type="button">
|
||||||
|
Authenticate with YubiKey
|
||||||
|
</button>
|
||||||
|
<p id="login-status" class="status-text" aria-live="polite"></p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
|
||||||
|
{registrationMode ? (
|
||||||
|
<script>
|
||||||
|
import { startRegistration } from '@simplewebauthn/browser';
|
||||||
|
|
||||||
|
const btn = document.getElementById('register-btn') as HTMLButtonElement;
|
||||||
|
const status = document.getElementById('register-status') as HTMLParagraphElement;
|
||||||
|
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
btn.disabled = true;
|
||||||
|
status.textContent = 'Requesting registration options...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const optRes = await fetch('/api/admin/webauthn/register-options');
|
||||||
|
if (!optRes.ok) {
|
||||||
|
const err = await optRes.json();
|
||||||
|
status.textContent = `Error: ${err.error ?? 'Could not get options'}`;
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const options = await optRes.json();
|
||||||
|
|
||||||
|
status.textContent = 'Touch your YubiKey when it flashes...';
|
||||||
|
let credential;
|
||||||
|
try {
|
||||||
|
credential = await startRegistration(options);
|
||||||
|
} catch (e: any) {
|
||||||
|
status.textContent = `Cancelled or failed: ${e.message ?? 'Unknown error'}`;
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
status.textContent = 'Verifying credential...';
|
||||||
|
const verifyRes = await fetch('/api/admin/webauthn/register-verify', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(credential),
|
||||||
|
});
|
||||||
|
const result = await verifyRes.json();
|
||||||
|
|
||||||
|
if (result.verified) {
|
||||||
|
status.textContent = 'Success! Redirecting to login...';
|
||||||
|
setTimeout(() => { window.location.href = '/admin/login'; }, 1000);
|
||||||
|
} else {
|
||||||
|
status.textContent = `Registration failed: ${result.error ?? 'Unknown error'}`;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
status.textContent = `Unexpected error: ${e.message ?? 'Unknown error'}`;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
) : (
|
||||||
|
<script>
|
||||||
|
import { startAuthentication } from '@simplewebauthn/browser';
|
||||||
|
|
||||||
|
const btn = document.getElementById('login-btn') as HTMLButtonElement;
|
||||||
|
const status = document.getElementById('login-status') as HTMLParagraphElement;
|
||||||
|
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
btn.disabled = true;
|
||||||
|
status.textContent = 'Requesting authentication options...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const optRes = await fetch('/api/admin/webauthn/login-options');
|
||||||
|
if (!optRes.ok) {
|
||||||
|
const err = await optRes.json();
|
||||||
|
status.textContent = `Error: ${err.error ?? 'Could not get options'}`;
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const options = await optRes.json();
|
||||||
|
|
||||||
|
status.textContent = 'Touch your YubiKey when it flashes...';
|
||||||
|
let credential;
|
||||||
|
try {
|
||||||
|
credential = await startAuthentication(options);
|
||||||
|
} catch (e: any) {
|
||||||
|
status.textContent = `Cancelled or failed: ${e.message ?? 'Unknown error'}`;
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
status.textContent = 'Verifying...';
|
||||||
|
const verifyRes = await fetch('/api/admin/webauthn/login-verify', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(credential),
|
||||||
|
});
|
||||||
|
const result = await verifyRes.json();
|
||||||
|
|
||||||
|
if (result.verified) {
|
||||||
|
status.textContent = 'Authenticated! Redirecting...';
|
||||||
|
window.location.href = '/admin';
|
||||||
|
} else {
|
||||||
|
status.textContent = `Authentication failed: ${result.error ?? 'Unknown error'}`;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
status.textContent = `Unexpected error: ${e.message ?? 'Unknown error'}`;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.login-wrap {
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-text {
|
||||||
|
color: var(--color-text-dim);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-text {
|
||||||
|
color: var(--color-peach);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
border-left: 2px solid var(--color-peach);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
background: none;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.btn:hover { opacity: 0.8; }
|
||||||
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.btn-primary { color: var(--color-accent); }
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-dim);
|
||||||
|
min-height: 1.4em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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' } });
|
||||||
|
};
|
||||||
@@ -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<string, 'approved' | 'rejected' | 'spam'> = {
|
||||||
|
approve: 'approved',
|
||||||
|
reject: 'rejected',
|
||||||
|
spam: 'spam',
|
||||||
|
};
|
||||||
|
|
||||||
|
moderateEntry(id, statusMap[action], note, session.id);
|
||||||
|
|
||||||
|
return new Response(null, { status: 303, headers: { Location: '/admin' } });
|
||||||
|
};
|
||||||
@@ -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' },
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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' },
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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' },
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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' },
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<string, string> = {
|
||||||
|
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;
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout
|
||||||
|
title="Guestbook — Hidden Den Cafe"
|
||||||
|
description="Leave a message in the guestbook for Hidden Den Cafe."
|
||||||
|
>
|
||||||
|
<div class="container fade-in">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1>~/guestbook</h1>
|
||||||
|
<p class="subtitle">
|
||||||
|
Leave a note. Say hello. This is a quiet corner of the internet — be kind.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Submission form -->
|
||||||
|
<section class="card form-section" aria-labelledby="form-heading">
|
||||||
|
<h2 id="form-heading">Leave a message</h2>
|
||||||
|
<p class="form-note">
|
||||||
|
Messages are reviewed before being published. Please don't include personal or
|
||||||
|
sensitive information — this is a public guestbook.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{submitted && (
|
||||||
|
<div class="alert alert-success" role="alert">
|
||||||
|
Thank you for your message! It will appear here once reviewed.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{errorMsg && (
|
||||||
|
<div class="alert alert-error" role="alert">
|
||||||
|
{errorMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="/api/guestbook/submit"
|
||||||
|
class="guestbook-form"
|
||||||
|
novalidate
|
||||||
|
>
|
||||||
|
<!-- Honeypot field: hidden from real users, filled by bots -->
|
||||||
|
<div class="hp-field" aria-hidden="true">
|
||||||
|
<label for="address">Address</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="address"
|
||||||
|
name="address"
|
||||||
|
tabindex="-1"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="display_name">
|
||||||
|
Name or nickname <span class="required" aria-label="required">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="display_name"
|
||||||
|
name="display_name"
|
||||||
|
maxlength="60"
|
||||||
|
required
|
||||||
|
autocomplete="nickname"
|
||||||
|
placeholder="your name or alias"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="message">
|
||||||
|
Message <span class="required" aria-label="required">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="message"
|
||||||
|
name="message"
|
||||||
|
maxlength="1000"
|
||||||
|
required
|
||||||
|
rows="4"
|
||||||
|
placeholder="say hello, share a thought, leave a trail..."
|
||||||
|
></textarea>
|
||||||
|
<span class="field-hint">Plain text only. Max 1000 characters.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="website">
|
||||||
|
Website <span class="optional">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="website"
|
||||||
|
name="website"
|
||||||
|
maxlength="200"
|
||||||
|
autocomplete="url"
|
||||||
|
placeholder="https://your-site.example"
|
||||||
|
/>
|
||||||
|
<span class="field-hint">Only https:// links. Leave blank if you don't have one.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field consent-field">
|
||||||
|
<label class="consent-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="consent"
|
||||||
|
value="yes"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
I understand this message may be published publicly on this guestbook.
|
||||||
|
<span class="required" aria-label="required">*</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn-submit">send message</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Approved entries -->
|
||||||
|
<section class="entries-section" aria-labelledby="entries-heading">
|
||||||
|
<h2 id="entries-heading">
|
||||||
|
Messages
|
||||||
|
{total > 0 && <span class="entry-count">({total})</span>}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<div class="empty-state card">
|
||||||
|
<p>No messages yet. Be the first to leave a note!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div class="entries-list">
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<article class="entry card">
|
||||||
|
<header class="entry-header">
|
||||||
|
<span class="entry-name">
|
||||||
|
{entry.website ? (
|
||||||
|
<a
|
||||||
|
href={entry.website}
|
||||||
|
rel="noopener noreferrer nofollow"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{entry.display_name}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
entry.display_name
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<time
|
||||||
|
class="entry-date"
|
||||||
|
datetime={entry.created_at}
|
||||||
|
title={entry.created_at}
|
||||||
|
>
|
||||||
|
{new Date(entry.created_at).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</time>
|
||||||
|
</header>
|
||||||
|
<p class="entry-message">{entry.message}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{(page > 1 || hasMore) && (
|
||||||
|
<nav class="pagination" aria-label="Guestbook pagination">
|
||||||
|
{page > 1 && (
|
||||||
|
<a href={`/guestbook?page=${page - 1}`} class="page-link">
|
||||||
|
← newer
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<span class="page-info">page {page}</span>
|
||||||
|
{hasMore && (
|
||||||
|
<a href={`/guestbook?page=${page + 1}`} class="page-link">
|
||||||
|
older →
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</BaseLayout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.container {
|
||||||
|
max-width: 700px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: calc(4rem + var(--space-lg)) var(--space-md) var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
color: var(--color-accent-bright);
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--color-accent-bright);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: var(--color-text-dim);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form */
|
||||||
|
.form-section {
|
||||||
|
background: var(--color-bg-light);
|
||||||
|
border: 1px solid var(--color-surface);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: var(--space-md);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-note {
|
||||||
|
color: var(--color-text-dim);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guestbook-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Honeypot: visually hidden but accessible-compatible */
|
||||||
|
.hp-field {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--color-text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: var(--color-warm);
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optional {
|
||||||
|
color: var(--color-surface);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="url"],
|
||||||
|
textarea {
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-surface);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 8px 10px;
|
||||||
|
width: 100%;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-field {
|
||||||
|
margin-top: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--color-text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-label input[type="checkbox"] {
|
||||||
|
margin-top: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
accent-color: var(--color-accent);
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
margin-top: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-bg);
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 20px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alerts */
|
||||||
|
.alert {
|
||||||
|
padding: var(--space-sm);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
border: 1px solid;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
border-color: var(--color-green);
|
||||||
|
color: var(--color-green);
|
||||||
|
background: color-mix(in srgb, var(--color-green) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-error {
|
||||||
|
border-color: var(--color-warm);
|
||||||
|
color: var(--color-warm);
|
||||||
|
background: color-mix(in srgb, var(--color-warm) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Entries */
|
||||||
|
.entries-section {
|
||||||
|
margin-top: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-count {
|
||||||
|
color: var(--color-text-dim);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entries-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry {
|
||||||
|
background: var(--color-bg-light);
|
||||||
|
border: 1px solid var(--color-surface);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-name {
|
||||||
|
color: var(--color-accent-bright);
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-name a {
|
||||||
|
color: var(--color-accent-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-date {
|
||||||
|
color: var(--color-text-dim);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-message {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
background: var(--color-bg-light);
|
||||||
|
border: 1px solid var(--color-surface);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: var(--space-md);
|
||||||
|
color: var(--color-text-dim);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pagination */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-md);
|
||||||
|
margin-top: var(--space-lg);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-link {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-info {
|
||||||
|
color: var(--color-text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.container {
|
||||||
|
padding-top: calc(3.5rem + var(--space-lg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user