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,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 },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user