88e00e5d41
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.
84 lines
2.5 KiB
TypeScript
84 lines
2.5 KiB
TypeScript
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 },
|
|
});
|
|
}
|