/** * Server-side anti-spam guard for contact form submissions. * Pure, dependency-free, in-memory rate limiting (sufficient for a single app instance). */ export const MIN_FILL_TIME_MS = 3_000; export const RATE_LIMIT_MAX_SUBMISSIONS = 3; export const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1_000; export const MAX_MESSAGE_LINKS = 5; const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; const LINK_PATTERN = /(https?:\/\/|www\.)\S+/gi; export type SpamReason = 'honeypot' | 'too_fast' | 'invalid_email' | 'too_many_links' | 'rate_limited'; export interface ContactSubmissionInput { honeypot: string | null; formLoadedAt: number | null; now: number; ip: string | null; email: string | null; message: string | null; } export interface ContactSubmissionVerdict { allowed: boolean; reason?: SpamReason; } const submissionTimestampsByIp = new Map(); export function resetRateLimiter(): void { submissionTimestampsByIp.clear(); } function isRateLimited(ip: string, now: number): boolean { const timestamps = (submissionTimestampsByIp.get(ip) ?? []).filter( (ts) => now - ts < RATE_LIMIT_WINDOW_MS, ); if (timestamps.length >= RATE_LIMIT_MAX_SUBMISSIONS) { submissionTimestampsByIp.set(ip, timestamps); return true; } timestamps.push(now); submissionTimestampsByIp.set(ip, timestamps); return false; } export function checkContactSubmission(input: ContactSubmissionInput): ContactSubmissionVerdict { if (input.honeypot) { return { allowed: false, reason: 'honeypot' }; } if ( input.formLoadedAt === null || !Number.isFinite(input.formLoadedAt) || input.now - input.formLoadedAt < MIN_FILL_TIME_MS ) { return { allowed: false, reason: 'too_fast' }; } if (!input.email || !EMAIL_PATTERN.test(input.email)) { return { allowed: false, reason: 'invalid_email' }; } const message = input.message ?? ''; if ((message.match(LINK_PATTERN) ?? []).length > MAX_MESSAGE_LINKS) { return { allowed: false, reason: 'too_many_links' }; } if (input.ip && isRateLimited(input.ip, input.now)) { return { allowed: false, reason: 'rate_limited' }; } return { allowed: true }; }