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:
2026-03-07 20:21:39 +01:00
parent 915594e83e
commit 88e00e5d41
26 changed files with 2327 additions and 45 deletions
+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 },
});
}