diff --git a/src/routes/contact/+page.server.ts b/src/routes/contact/+page.server.ts index 763e386..d15d9ab 100644 --- a/src/routes/contact/+page.server.ts +++ b/src/routes/contact/+page.server.ts @@ -7,16 +7,123 @@ const BREVO_ENDPOINT = 'https://api.brevo.com/v3/smtp/email'; 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(); +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(); + 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 actions: Actions = { - default: async ({ request, fetch }) => { + default: async ({ request, fetch, getClientAddress }) => { const formData = await request.formData(); const name = (formData.get('name') ?? '').toString().trim(); const email = (formData.get('email') ?? '').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 }; + // 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) { 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 }); } + // 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) { console.error('Missing BREVO_API_KEY in environment'); return fail(500, { error: 'Email service is not configured. Please try again later.', fields }); diff --git a/src/routes/contact/+page.svelte b/src/routes/contact/+page.svelte index 868401b..6bf0bc2 100644 --- a/src/routes/contact/+page.svelte +++ b/src/routes/contact/+page.svelte @@ -10,6 +10,16 @@ let canvasTexts: Array = []; 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) => { if (contactFormVisible) { gsap.to('.wordChildren', { autoAlpha: 1, yPercent: 0, duration: 1, ease: 'power4.inOut' }) @@ -103,6 +113,10 @@ + + + +

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.

diff --git a/src/routes/success/+page.svelte b/src/routes/success/+page.svelte index f1405d1..b080724 100644 --- a/src/routes/success/+page.svelte +++ b/src/routes/success/+page.svelte @@ -94,7 +94,7 @@ h-screen w-full max-w-[1200px] landscape:max-w-[74vw] landscape: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 ">

Your message is underway.