adding spam protection

This commit is contained in:
saiminh 2026-02-16 13:29:06 +01:00
parent 3dfdb1e47b
commit 75f038488b
3 changed files with 139 additions and 2 deletions

View file

@ -7,16 +7,123 @@ const BREVO_ENDPOINT = 'https://api.brevo.com/v3/smtp/email';
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// Spam detection patterns
const spamPatterns = [
/(?:viagra|cialis|casino|poker|loan|mortgage|credit|debt|free money|make money|work from home)/i,
/(?:click here|buy now|limited time|act now|urgent|asap)/i,
/(?:[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}){2,}/i, // Multiple email addresses
];
// Common spam keywords
const spamKeywords = [
'seo', 'backlink', 'guest post', 'increase traffic', 'boost sales',
'crypto', 'bitcoin', 'investment opportunity', 'get rich quick'
];
// Rate limiting: track submissions per IP (in-memory, resets on server restart)
const submissionTracker = new Map<string, number[]>();
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour
const MAX_SUBMISSIONS_PER_HOUR = 3;
function checkRateLimit(ip: string): boolean {
const now = Date.now();
const submissions = submissionTracker.get(ip) || [];
// Remove old submissions outside the window
const recentSubmissions = submissions.filter(time => now - time < RATE_LIMIT_WINDOW);
if (recentSubmissions.length >= MAX_SUBMISSIONS_PER_HOUR) {
return false; // Rate limit exceeded
}
// Add current submission
recentSubmissions.push(now);
submissionTracker.set(ip, recentSubmissions);
return true; // Within rate limit
}
function containsSpam(content: string): boolean {
const lowerContent = content.toLowerCase();
// Check against spam patterns
for (const pattern of spamPatterns) {
if (pattern.test(content)) {
return true;
}
}
// Check for spam keywords
for (const keyword of spamKeywords) {
if (lowerContent.includes(keyword)) {
return true;
}
}
// Check for excessive links (more than 3 links is suspicious)
const linkCount = (content.match(/https?:\/\//gi) || []).length;
if (linkCount > 3) {
return true;
}
// Check for excessive repetition (common in spam)
const words = content.split(/\s+/);
const wordCounts = new Map<string, number>();
for (const word of words) {
const count = wordCounts.get(word) || 0;
wordCounts.set(word, count + 1);
if (count > 5 && word.length > 3) {
return true; // Same word repeated too many times
}
}
return false;
}
export const prerender = false; export const prerender = false;
export const actions: Actions = { export const actions: Actions = {
default: async ({ request, fetch }) => { default: async ({ request, fetch, getClientAddress }) => {
const formData = await request.formData(); const formData = await request.formData();
const name = (formData.get('name') ?? '').toString().trim(); const name = (formData.get('name') ?? '').toString().trim();
const email = (formData.get('email') ?? '').toString().trim(); const email = (formData.get('email') ?? '').toString().trim();
const message = (formData.get('contact') ?? '').toString().trim(); const message = (formData.get('contact') ?? '').toString().trim();
const honeypot = (formData.get('website') ?? '').toString().trim();
const formLoadTimeStr = formData.get('form_load_time')?.toString();
const fields = { name, email, contact: message }; const fields = { name, email, contact: message };
// SPAM PROTECTION CHECKS
// 1. Honeypot check - if filled, it's a bot
if (honeypot) {
console.warn('Spam detected: Honeypot field filled', { ip: getClientAddress() });
return fail(400, { error: 'Invalid submission. Please try again.', fields });
}
// 2. Rate limiting check
const clientIP = getClientAddress();
if (!checkRateLimit(clientIP)) {
console.warn('Spam detected: Rate limit exceeded', { ip: clientIP });
return fail(429, { error: 'Too many submissions. Please try again later.', fields });
}
// 3. Time-based validation - check if form was filled too quickly (< 3 seconds)
if (formLoadTimeStr) {
const formLoadTime = parseInt(formLoadTimeStr, 10);
const submitTime = Date.now();
const timeSpent = submitTime - formLoadTime;
// If form was submitted in less than 3 seconds, it's likely a bot
if (timeSpent < 3000) {
console.warn('Spam detected: Form submitted too quickly', {
ip: clientIP,
timeSpent: `${timeSpent}ms`
});
return fail(400, { error: 'Please take your time filling out the form.', fields });
}
}
// 4. Basic validation
if (!email || !message) { if (!email || !message) {
return fail(400, { error: 'Please provide your email and message.', fields }); return fail(400, { error: 'Please provide your email and message.', fields });
} }
@ -25,6 +132,22 @@ export const actions: Actions = {
return fail(400, { error: 'Please provide a valid email address.', fields }); return fail(400, { error: 'Please provide a valid email address.', fields });
} }
// 5. Content spam detection
const fullContent = `${name} ${email} ${message}`.toLowerCase();
if (containsSpam(fullContent)) {
console.warn('Spam detected: Content analysis flagged submission', { ip: clientIP });
return fail(400, { error: 'Your message contains content that appears to be spam. Please revise and try again.', fields });
}
// 6. Message length check (too short might be spam, too long might be spam)
if (message.length < 10) {
return fail(400, { error: 'Please provide a more detailed message.', fields });
}
if (message.length > 5000) {
return fail(400, { error: 'Message is too long. Please keep it under 5000 characters.', fields });
}
if (!BREVO_API_KEY) { if (!BREVO_API_KEY) {
console.error('Missing BREVO_API_KEY in environment'); console.error('Missing BREVO_API_KEY in environment');
return fail(500, { error: 'Email service is not configured. Please try again later.', fields }); return fail(500, { error: 'Email service is not configured. Please try again later.', fields });

View file

@ -10,6 +10,16 @@
let canvasTexts: Array<HTMLElement> = []; let canvasTexts: Array<HTMLElement> = [];
let contactFormVisible = false; let contactFormVisible = false;
let formLoadTime = 0;
// Track when form becomes visible for time-based spam detection
$: if (contactFormVisible && formLoadTime === 0) {
formLoadTime = Date.now();
} else if (!contactFormVisible) {
// Reset timestamp when form is closed
formLoadTime = 0;
}
let contactFormClickHandler = (e: Event) => { let contactFormClickHandler = (e: Event) => {
if (contactFormVisible) { if (contactFormVisible) {
gsap.to('.wordChildren', { autoAlpha: 1, yPercent: 0, duration: 1, ease: 'power4.inOut' }) gsap.to('.wordChildren', { autoAlpha: 1, yPercent: 0, duration: 1, ease: 'power4.inOut' })
@ -103,6 +113,10 @@
<label for="contact" class="contact-label"> <label for="contact" class="contact-label">
<textarea rows="8" name="contact" id="contact" placeholder="Your business propositions, praise, complaints and/or threats" required class="contact-input">{form?.fields?.contact ?? ''}</textarea> <textarea rows="8" name="contact" id="contact" placeholder="Your business propositions, praise, complaints and/or threats" required class="contact-input">{form?.fields?.contact ?? ''}</textarea>
</label> </label>
<!-- Honeypot field - hidden from users but bots will fill it -->
<input type="text" name="website" id="website" tabindex="-1" autocomplete="off" style="position: absolute; left: -9999px; opacity: 0; pointer-events: none;" aria-hidden="true">
<!-- Timestamp for time-based validation -->
<input type="hidden" name="form_load_time" value={formLoadTime}>
<div class="disclaimer max-w-(--form-maxwidth) my-4 mx-auto text-base"> <div class="disclaimer max-w-(--form-maxwidth) my-4 mx-auto text-base">
<p>Disclaimer: I will only use the data you submit here (name, email, message) to respond. I will not pass it on to any third party. If I don't hear from you I will delete the data and keep no records of it.</p> <p>Disclaimer: I will only use the data you submit here (name, email, message) to respond. I will not pass it on to any third party. If I don't hear from you I will delete the data and keep no records of it.</p>
</div> </div>

View file

@ -94,7 +94,7 @@
h-screen h-screen
w-full max-w-[1200px] landscape:max-w-[74vw] landscape:mx-auto w-full max-w-[1200px] landscape:max-w-[74vw] landscape:mx-auto
mx-auto mx-auto
p-(--spacing-outer) pb-[90px] landscape:pt-[90px] landscape:pb-[0px] p-(--spacing-outer) pb-[90px] landscape:pt-[90px] landscape:pb-0
"> ">
<h1 class="text-[2em] md:text-[3em] leading-[0.9] mt-8 md:mt-4 mb-4 text-(--color-highlight)"> <h1 class="text-[2em] md:text-[3em] leading-[0.9] mt-8 md:mt-4 mb-4 text-(--color-highlight)">
Your message is underway. Your message is underway.