Merge pull request 'feat/moderation' (#58) from feat/moderation into dev
CI / ci (push) Successful in 32s

Reviewed-on: #58
This commit was merged in pull request #58.
This commit is contained in:
2026-03-07 20:39:19 +00:00
24 changed files with 1898 additions and 49 deletions
+21
View File
@@ -0,0 +1,21 @@
# Required: random secret used to salt IP hashes and sign sessions
# Generate with: openssl rand -hex 32
SECRET_KEY=replace_me_with_a_random_secret
# Required: admin login token for /admin/login
ADMIN_SECRET_TOKEN=replace_me_with_a_long_random_token
# Optional: force cookie secure behavior (`true` or `false`)
# Leave unset for automatic behavior based on NODE_ENV
# COOKIE_SECURE=
# Database path (Docker mounts /data as a named volume)
DB_PATH=/data/guestbook.db
# Server binding
HOST=0.0.0.0
PORT=3000
# --- Development overrides ---
# For local dev (npm run dev), override with:
# COOKIE_SECURE=false
# DB_PATH=./data/guestbook.db
+6 -3
View File
@@ -5,7 +5,7 @@
#
# Detection logic:
# 1. Python: if requirements.txt exists → install deps, lint, test.
# 2. Node/JS: if package.json exists → npm ci, lint, test, build.
# 2. Node/JS: if package.json exists → install deps, lint, test, build.
# 3. Neither detected → print a message and exit 0 (never fail).
#
# Controlled by .ci/config.env:
@@ -194,11 +194,14 @@ jobs:
if: env.HAS_NODE == 'true'
uses: actions/setup-node@v4
with:
node-version: "lts/*"
# Keep CI on Node 20 to match runtime/Docker and better-sqlite3 compatibility.
node-version: "20.x"
- name: Install Node dependencies
if: env.HAS_NODE == 'true'
run: npm ci
run: |
# Lockfile is currently not authoritative; use install to refresh dependency tree.
npm install
# -----------------------------------------------------------------------
# Step 9: Node.js — Lint (only if "lint" script exists in package.json)
+6
View File
@@ -62,3 +62,9 @@ Thumbs.db
!.env.example
*.pem
*.key
# ---- Guestbook SQLite database (use Docker volume in production) ----
data/
*.db
*.db-wal
*.db-shm
+1
View File
@@ -0,0 +1 @@
20
+9 -6
View File
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
**Project:** Cozy Den - Personal landing page for hiddenden.cafe
**Owner:** Latte (gay furry developer, values self-hosting and privacy)
**Tech Stack:** Astro 4.x, TypeScript, Vanilla CSS, Docker + Nginx
**Tech Stack:** Astro 4.x (hybrid SSR), TypeScript, Vanilla CSS, SQLite, Docker + Node.js
**Aesthetic:** Warm coffee/cappuccino theme, cozy hidden den vibes
**Deployment:** Docker containers pushed to Gitea registry at git.hiddenden.cafe
@@ -40,14 +40,17 @@ cozy-den/
## Architecture Notes
This is a simple static site following standard Astro conventions:
Astro **hybrid SSR** site — most pages are statically pre-rendered, but guestbook and admin pages are server-rendered:
- Layouts in `src/layouts/` for reusable page templates
- Pages in `src/pages/` (routes automatically based on filename)
- All content is on a single page (`index.astro`) with multiple sections
- Custom 404 page with cozy theming
- No client-side JavaScript - pure static HTML/CSS output
- Server-side lib code in `src/lib/` (db, auth, guestbook, spam)
- API routes in `src/pages/api/` for form handling and admin actions
- CSS custom properties centralized in `BaseLayout.astro` for theming
- Accessibility improvements with ARIA labels and semantic HTML
- `output: 'hybrid'` + `@astrojs/node` adapter — Node.js standalone server in production
- SQLite database (better-sqlite3) for guestbook entries and admin sessions
- Docker runtime is now Node.js (not Nginx); see `docs/guestbook.md` for setup
**Guestbook:** See `docs/guestbook.md` for full setup, token login, and deployment notes.
## Commands
+31 -15
View File
@@ -1,28 +1,44 @@
# Stage 1: Build the Astro app
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
# Install build dependencies for native modules (e.g. better-sqlite3)
RUN apk add --no-cache python3 make g++
COPY package*.json ./
RUN npm install
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build the site
RUN npm run build
# Production stage
FROM nginx:alpine
# Stage 2: Install production dependencies only
FROM node:20-alpine AS deps
# Copy built files to nginx
COPY --from=builder /app/dist /usr/share/nginx/html
WORKDIR /app
# Copy nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
RUN apk add --no-cache python3 make g++
EXPOSE 80
COPY package*.json ./
RUN npm install --omit=dev
CMD ["nginx", "-g", "daemon off;"]
# Stage 3: Runtime image
FROM node:20-alpine AS runtime
WORKDIR /app
# Data directory for SQLite database
RUN mkdir -p /data
COPY --from=builder /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
COPY package*.json ./
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
ENV DB_PATH=/data/guestbook.db
EXPOSE 3000
CMD ["node", "dist/server/entry.mjs"]
+5
View File
@@ -1,9 +1,14 @@
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
import node from '@astrojs/node';
// https://astro.build/config
export default defineConfig({
site: 'https://hiddenden.cafe',
output: 'hybrid',
adapter: node({
mode: 'standalone',
}),
integrations: [
sitemap({
changefreq: 'weekly',
+12 -1
View File
@@ -3,7 +3,18 @@ services:
build: .
container_name: cozy-den
ports:
- "3000:80"
- "3000:3000"
restart: unless-stopped
environment:
- NODE_ENV=production
- HOST=0.0.0.0
- PORT=3000
- DB_PATH=/data/guestbook.db
- SECRET_KEY=${SECRET_KEY}
- ADMIN_SECRET_TOKEN=${ADMIN_SECRET_TOKEN}
- COOKIE_SECURE=${COOKIE_SECURE:-true}
volumes:
- guestbook_data:/data
volumes:
guestbook_data:
+113
View File
@@ -0,0 +1,113 @@
# Guestbook — Implementation Notes
## Architecture summary
The guestbook extends the Astro site with **hybrid SSR** mode using the `@astrojs/node` standalone adapter.
- Existing content pages remain static.
- Guestbook and admin pages are server-rendered (`export const prerender = false`).
- API routes handle submissions, moderation, and token-based admin login.
- SQLite (`better-sqlite3`) stores entries, sessions, rate-limit data, and audit logs.
## Relevant files
```
src/
lib/
db.ts — SQLite singleton + schema
guestbook.ts — Entry CRUD, pagination, moderation reads
auth.ts — Session management + cookie policy
spam.ts — Validation + heuristic spam scoring
pages/
guestbook.astro — Public guestbook page
admin/
index.astro — Moderation portal (session-gated)
login.astro — Token login form
pages/api/
guestbook/submit.ts — POST: public guestbook submission
admin/token-login.ts — POST: token authentication + session creation
admin/moderate.ts — POST: approve / reject / spam
admin/logout.ts — POST: end admin session
layouts/
AdminLayout.astro — Minimal admin UI layout
```
## Environment variables
Copy `.env.example` to `.env` and set:
| Variable | Required | Description |
|---|---|---|
| `SECRET_KEY` | **Yes** | Random secret for IP-hash salting and session-related values |
| `ADMIN_SECRET_TOKEN` | **Yes** | Shared secret token for `/admin/login` |
| `COOKIE_SECURE` | No | Force secure cookies (`true`/`false`). If unset, `NODE_ENV=production` => secure cookies |
| `DB_PATH` | No | SQLite path (default: `./data/guestbook.db`) |
| `PORT` | No | Server port (default: `3000`) |
| `HOST` | No | Bind host (default: `0.0.0.0`) |
Generate secrets:
```bash
openssl rand -hex 32 # SECRET_KEY
openssl rand -hex 32 # ADMIN_SECRET_TOKEN
```
## Admin setup
1. Set `ADMIN_SECRET_TOKEN` in your environment.
2. Open `/admin/login`.
3. Enter token.
4. After success, you are redirected to `/admin`.
If token is missing, `/admin/login` shows a configuration warning and login is disabled.
## Local development
```bash
npm install
cp .env.example .env
# set at minimum:
# SECRET_KEY=...
# ADMIN_SECRET_TOKEN=...
# DB_PATH=./data/guestbook.db
# COOKIE_SECURE=false # for local http
npm run dev
# guestbook: http://localhost:4321/guestbook
# admin: http://localhost:4321/admin/login
```
## Docker deployment
```bash
docker compose up -d --build
docker compose logs -f cozy-den
```
The `guestbook_data` Docker volume persists the SQLite database.
## Moderation flow
1. Visitor submits message at `/guestbook`.
2. Entry is saved as `pending`.
3. Admin logs in at `/admin/login` with token.
4. Admin approves/rejects/marks spam in `/admin`.
5. Approved entries are shown publicly.
## Privacy decisions
- IP addresses are never stored directly.
- A truncated salted hash is stored only for rate limiting.
- No tracking scripts or third-party analytics.
- Admin session cookie is `httpOnly` and `SameSite=Strict`.
- User content is stored as plain text (HTML stripped server-side).
## Database tables
| Table | Purpose |
|---|---|
| `guestbook_entries` | Submissions + moderation status |
| `admin_sessions` | Active admin sessions |
| `rate_limit` | Submission throttling by IP hash |
| `audit_log` | Moderation actions |
+18 -23
View File
@@ -1,36 +1,31 @@
# nginx.conf — reverse proxy in front of the Astro Node.js server
# If you run cozy-den behind your own reverse proxy (Caddy, Nginx, etc.),
# this file is for reference / the docker-compose nginx service pattern.
#
# The primary server is now the Node.js process (dist/server/entry.mjs).
# Point your reverse proxy to http://cozy-den:3000 (or localhost:3000).
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
absolute_redirect off;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json image/svg+xml;
server_name hiddenden.cafe;
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
add_header Content-Security-Policy "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; font-src 'self'; connect-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# Cache static assets
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header X-Content-Type-Options "nosniff" always;
}
# Main location
# Proxy to Node.js Astro server
location / {
try_files $uri $uri/ =404;
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 60s;
}
# Custom error pages
error_page 404 /404.html;
}
+11 -1
View File
@@ -2,6 +2,9 @@
"name": "cozy-den",
"type": "module",
"version": "0.0.1",
"engines": {
"node": ">=20 <24"
},
"scripts": {
"dev": "astro dev",
"start": "astro dev",
@@ -11,6 +14,13 @@
},
"dependencies": {
"astro": "^4.16.18",
"@astrojs/sitemap": "^3.2.2"
"@astrojs/sitemap": "^3.2.2",
"@astrojs/node": "^8.3.4",
"better-sqlite3": "^9.4.3",
"uuid": "^9.0.1"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.10",
"@types/uuid": "^9.0.7"
}
}
+1
View File
@@ -13,6 +13,7 @@ const links = [
{ href: "/coffee", label: "coffee" },
{ href: "/library", label: "library" },
{ href: "/links", label: "links" },
{ href: "/guestbook", label: "guestbook" },
{ href: "/ai", label: "ai" },
{ href: "/changelog", label: "changelog" },
];
+200
View File
@@ -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>
+73
View File
@@ -0,0 +1,73 @@
import { createHash, randomBytes } from 'node:crypto';
import db from './db';
function getSecretKey(): string {
const key = process.env.SECRET_KEY;
if (!key) throw new Error('SECRET_KEY environment variable is required');
return key;
}
export function hashIP(ip: string): string {
return createHash('sha256').update(getSecretKey() + ':' + ip).digest('hex').slice(0, 16);
}
export function generateId(): string {
return randomBytes(32).toString('hex');
}
export interface AdminSession {
id: string;
user_id: string;
created_at: string;
expires_at: string;
}
export function createSession(userId: string): string {
const sessionId = generateId();
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
db.prepare(
`INSERT INTO admin_sessions (id, user_id, expires_at) VALUES (?, ?, ?)`
).run(sessionId, userId, expiresAt);
return sessionId;
}
export function getSession(sessionId: string): AdminSession | undefined {
return db
.prepare(
`SELECT * FROM admin_sessions
WHERE id = ? AND expires_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now')`
)
.get(sessionId) as AdminSession | undefined;
}
export function deleteSession(sessionId: string): void {
db.prepare(`DELETE FROM admin_sessions WHERE id = ?`).run(sessionId);
}
export function cleanExpiredSessions(): void {
db.prepare(
`DELETE FROM admin_sessions WHERE expires_at <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')`
).run();
}
export const SESSION_COOKIE = 'admin_session';
function shouldUseSecureCookies(): boolean {
const secureOverride = process.env.COOKIE_SECURE?.trim().toLowerCase();
if (secureOverride === 'true') return true;
if (secureOverride === 'false') return false;
return process.env.NODE_ENV === 'production';
}
export function sessionCookieOptions(maxAge: number) {
return {
httpOnly: true,
secure: shouldUseSecureCookies(),
sameSite: 'strict' as const,
path: '/',
maxAge,
};
}
+57
View File
@@ -0,0 +1,57 @@
import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
const dbPath = resolve(process.env.DB_PATH ?? './data/guestbook.db');
// Ensure directory exists
mkdirSync(dirname(dbPath), { recursive: true });
const db = new Database(dbPath);
// WAL mode improves concurrent read performance
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS guestbook_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
display_name TEXT NOT NULL,
message TEXT NOT NULL,
website TEXT,
ip_hash TEXT,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
moderated_at TEXT,
moderation_note TEXT
);
CREATE TABLE IF NOT EXISTS admin_sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
expires_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS rate_limit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL,
entry_id INTEGER,
admin_session TEXT,
note TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_entries_status ON guestbook_entries(status);
CREATE INDEX IF NOT EXISTS idx_entries_created ON guestbook_entries(created_at);
CREATE INDEX IF NOT EXISTS idx_rate_limit_ip ON rate_limit(ip_hash, created_at);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON admin_sessions(expires_at);
`);
export default db;
+125
View File
@@ -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
View File
@@ -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> = {
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#39;': "'",
};
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;
}
+282
View File
@@ -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>
+108
View File
@@ -0,0 +1,108 @@
---
export const prerender = false;
import AdminLayout from '../../layouts/AdminLayout.astro';
import { getSession, SESSION_COOKIE } from '../../lib/auth';
// Redirect if already logged in
const sessionId = Astro.cookies.get(SESSION_COOKIE)?.value;
const session = sessionId ? getSession(sessionId) : undefined;
if (session) {
return Astro.redirect('/admin');
}
const tokenAuthEnabled = Boolean(process.env.ADMIN_SECRET_TOKEN?.trim());
const tokenError = Astro.url.searchParams.get('tokenError') === '1';
---
<AdminLayout title="Login">
<div class="login-wrap">
<h1>admin access</h1>
{tokenAuthEnabled ? (
<div class="card">
<h2>Token login</h2>
<p class="info-text">
Enter your admin token to access moderation.
</p>
{tokenError && (
<p class="warning-text">Invalid token. Try again.</p>
)}
<form method="post" action="/api/admin/token-login" class="token-form">
<label for="token-input" class="token-label">Admin token</label>
<input
id="token-input"
name="token"
type="password"
autocomplete="current-password"
required
class="token-input"
/>
<button type="submit" class="btn btn-primary">Sign in with token</button>
</form>
</div>
) : (
<div class="card">
<h2>Token not configured</h2>
<p class="warning-text">
<code>ADMIN_SECRET_TOKEN</code> is not set. Configure it in your environment, then reload this page.
</p>
</div>
)}
</div>
<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); }
.token-form {
display: grid;
gap: var(--space-xs);
}
.token-label {
color: var(--color-text-dim);
font-size: 0.82rem;
}
.token-input {
width: 100%;
max-width: 360px;
}
</style>
</AdminLayout>
+14
View File
@@ -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' } });
};
+39
View File
@@ -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' } });
};
+37
View File
@@ -0,0 +1,37 @@
import type { APIRoute } from 'astro';
import { timingSafeEqual } from 'node:crypto';
import { createSession, SESSION_COOKIE, sessionCookieOptions } from '../../../lib/auth';
export const prerender = false;
function tokenMatches(input: string, expected: string): boolean {
const a = Buffer.from(input);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
export const POST: APIRoute = async ({ request, cookies }) => {
const expectedToken = process.env.ADMIN_SECRET_TOKEN?.trim();
if (!expectedToken) {
return new Response(null, { status: 404 });
}
const formData = await request.formData();
const token = String(formData.get('token') ?? '').trim();
if (!tokenMatches(token, expectedToken)) {
return new Response(null, {
status: 303,
headers: { Location: '/admin/login?tokenError=1' },
});
}
const sessionId = createSession('admin');
cookies.set(SESSION_COOKIE, sessionId, sessionCookieOptions(24 * 60 * 60));
return new Response(null, {
status: 303,
headers: { Location: '/admin' },
});
};
+83
View File
@@ -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 },
});
}
+526
View File
@@ -0,0 +1,526 @@
---
export const prerender = false;
import BaseLayout from '../layouts/BaseLayout.astro';
import { getApprovedEntries } from '../lib/guestbook';
const pageParam = parseInt(Astro.url.searchParams.get('page') ?? '1', 10);
const page = isNaN(pageParam) || pageParam < 1 ? 1 : pageParam;
const { entries, total, hasMore } = getApprovedEntries(page);
const submitted = Astro.url.searchParams.get('submitted') === 'true';
const errorParam = Astro.url.searchParams.get('error');
const errorMessages: Record<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-label="Leave a message">
<details class="compose" open={submitted || Boolean(errorMsg)}>
<summary class="compose-toggle">
<span class="compose-toggle-text">Leave a message</span>
</summary>
<div class="compose-body">
<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>
</div>
</details>
</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);
}
.compose {
width: 100%;
}
.compose-toggle {
list-style: none;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
color: var(--color-accent-bright);
font-size: 1.1rem;
font-weight: bold;
margin-bottom: var(--space-sm);
user-select: none;
}
.compose-toggle::-webkit-details-marker {
display: none;
}
.compose-toggle:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
border-radius: 4px;
}
.compose-toggle::before {
content: '+';
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.2em;
height: 1.2em;
border: 1px solid var(--color-accent);
border-radius: 4px;
color: var(--color-accent);
font-weight: normal;
line-height: 1;
}
.compose[open] .compose-toggle::before {
content: '';
}
.compose-body {
margin-top: var(--space-sm);
}
.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>