adding spam protection
This commit is contained in:
parent
3dfdb1e47b
commit
75f038488b
3 changed files with 139 additions and 2 deletions
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue