77 lines
2.1 KiB
TypeScript
77 lines
2.1 KiB
TypeScript
/**
|
|
* 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<string, number[]>();
|
|
|
|
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 };
|
|
}
|