Updated security and form logic
This commit is contained in:
@@ -13,6 +13,42 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
|
||||
padding: 0.5rem;
|
||||
background-color: rgba(239, 68, 68, 0.05);
|
||||
}
|
||||
|
||||
/* Honeypot field styling */
|
||||
.hidden-field {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Rate limit message styling */
|
||||
.rate-limit-message {
|
||||
color: #ef4444;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.button-loading {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.button-loading::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-left: 0.5rem;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 50%;
|
||||
border-top-color: transparent;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<form
|
||||
@@ -20,8 +56,6 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
|
||||
name="contact"
|
||||
method="POST"
|
||||
class="needs-validation"
|
||||
data-netlify="true"
|
||||
data-netlify-honeypot="bot-field"
|
||||
novalidate
|
||||
>
|
||||
<!-- Form status messages -->
|
||||
@@ -33,13 +67,19 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
|
||||
There was an error sending your message. Please check all fields and try again.
|
||||
</div>
|
||||
|
||||
<!-- Netlify form name -->
|
||||
<input type="hidden" name="form-name" value="contact" />
|
||||
<div id="rate-limit-error" class="hidden mb-6 p-4 bg-yellow-100 border border-yellow-200 text-yellow-700 rounded-lg">
|
||||
Too many attempts. Please try again later.
|
||||
</div>
|
||||
|
||||
<!-- Honeypot fields -->
|
||||
<div class="hidden-field">
|
||||
<input type="text" name="website" tabindex="-1" autocomplete="off" />
|
||||
<input type="email" name="email_confirm" tabindex="-1" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<!-- Timestamp field for rate limiting -->
|
||||
<input type="hidden" name="timestamp" id="form-timestamp" />
|
||||
|
||||
<!-- Honeypot field to prevent spam -->
|
||||
<p class="hidden">
|
||||
<label>Don't fill this out if you're human: <input name="bot-field" /></label>
|
||||
</p>
|
||||
{
|
||||
inputs &&
|
||||
inputs.map(
|
||||
@@ -60,6 +100,9 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
|
||||
placeholder={placeholder}
|
||||
class="py-3 px-4 block w-full text-md rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900"
|
||||
required={required}
|
||||
pattern={type === 'email' ? '[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$' : undefined}
|
||||
minlength={type === 'text' ? '2' : undefined}
|
||||
maxlength={type === 'text' ? '100' : undefined}
|
||||
/>
|
||||
<div class="invalid-feedback hidden text-red-600 text-sm mt-1" />
|
||||
</div>
|
||||
@@ -81,6 +124,8 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
|
||||
placeholder={textarea.placeholder}
|
||||
class="py-3 px-4 block w-full text-md rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900"
|
||||
required
|
||||
minlength="10"
|
||||
maxlength="1000"
|
||||
/>
|
||||
<div class="invalid-feedback hidden text-red-600 text-sm mt-1" />
|
||||
</div>
|
||||
@@ -113,7 +158,7 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
|
||||
{
|
||||
button && (
|
||||
<div class="mt-10 grid">
|
||||
<Button variant="primary" type="submit">
|
||||
<Button variant="primary" type="submit" id="submit-button">
|
||||
{button}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -130,55 +175,261 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
|
||||
</form>
|
||||
|
||||
<script>
|
||||
// Rate limiting configuration
|
||||
const RATE_LIMIT_WINDOW = 3600000; // 1 hour in milliseconds
|
||||
const MAX_SUBMISSIONS = 3; // Maximum submissions per hour
|
||||
const STORAGE_KEY = 'form_submissions';
|
||||
|
||||
// Form validation and submission handling
|
||||
const form = document.getElementById('contact-form') as HTMLFormElement;
|
||||
const submitButton = document.getElementById('submit-button') as HTMLButtonElement;
|
||||
|
||||
if (form) {
|
||||
// Set initial timestamp
|
||||
const timestampInput = document.getElementById('form-timestamp') as HTMLInputElement;
|
||||
if (timestampInput) {
|
||||
timestampInput.value = Date.now().toString();
|
||||
}
|
||||
|
||||
// Validate form on submit
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const formData = new FormData(form);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
// Disable submit button and show loading state
|
||||
if (submitButton) {
|
||||
submitButton.disabled = true;
|
||||
submitButton.classList.add('button-loading');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/.netlify/functions/contact', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
// Check rate limit
|
||||
if (isRateLimited()) {
|
||||
showRateLimitError();
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
console.log(result.message); // Log success message
|
||||
const successElement = document.getElementById('form-success');
|
||||
if (successElement) {
|
||||
successElement.classList.remove('hidden');
|
||||
}
|
||||
const errorElement = document.getElementById('form-error');
|
||||
if (errorElement) {
|
||||
errorElement.classList.add('hidden');
|
||||
}
|
||||
form.reset(); // Clear the form
|
||||
} else {
|
||||
console.error('Error:', response.status);
|
||||
const errorElement = document.getElementById('form-error');
|
||||
if (errorElement) {
|
||||
errorElement.classList.remove('hidden');
|
||||
}
|
||||
const successElement = document.getElementById('form-success');
|
||||
if (successElement) {
|
||||
successElement.classList.add('hidden');
|
||||
}
|
||||
// Validate form
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get form data
|
||||
const formData = new FormData(form);
|
||||
const name = formData.get('name') as string;
|
||||
const email = formData.get('email') as string;
|
||||
const message = formData.get('message') as string;
|
||||
|
||||
// Check honeypot fields
|
||||
if (formData.get('website') || formData.get('email_confirm')) {
|
||||
showError('Invalid form submission');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for spam
|
||||
if (isSpam(name, email, message)) {
|
||||
showError('Your message was flagged as potential spam. Please revise and try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create email body
|
||||
const subject = `Contact Form Submission from ${name}`;
|
||||
const body = `
|
||||
Name: ${name}
|
||||
Email: ${email}
|
||||
|
||||
Message:
|
||||
${message}
|
||||
`.trim();
|
||||
|
||||
// Create mailto link
|
||||
const mailtoLink = `mailto:info@365devnet.eu?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
|
||||
|
||||
// Open default email client
|
||||
window.location.href = mailtoLink;
|
||||
|
||||
// Record submission
|
||||
recordSubmission();
|
||||
|
||||
// Show success message
|
||||
showSuccess();
|
||||
|
||||
// Reset form
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
const errorElement = document.getElementById('form-error');
|
||||
if (errorElement) {
|
||||
errorElement.classList.remove('hidden');
|
||||
}
|
||||
const successElement = document.getElementById('form-success');
|
||||
if (successElement) {
|
||||
successElement.classList.add('hidden');
|
||||
console.error('Form submission error:', error);
|
||||
showError('An unexpected error occurred. Please try again.');
|
||||
} finally {
|
||||
// Re-enable submit button and remove loading state
|
||||
if (submitButton) {
|
||||
submitButton.disabled = false;
|
||||
submitButton.classList.remove('button-loading');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Rate limiting functions
|
||||
function isRateLimited() {
|
||||
const submissions = getSubmissions();
|
||||
const now = Date.now();
|
||||
const recentSubmissions = submissions.filter(time => now - time < RATE_LIMIT_WINDOW);
|
||||
return recentSubmissions.length >= MAX_SUBMISSIONS;
|
||||
}
|
||||
|
||||
function recordSubmission() {
|
||||
const submissions = getSubmissions();
|
||||
submissions.push(Date.now());
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(submissions));
|
||||
}
|
||||
|
||||
function getSubmissions() {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
}
|
||||
|
||||
// Form validation
|
||||
function validateForm() {
|
||||
const name = form.querySelector('input[name="name"]') as HTMLInputElement;
|
||||
const email = form.querySelector('input[name="email"]') as HTMLInputElement;
|
||||
const message = form.querySelector('textarea[name="message"]') as HTMLTextAreaElement;
|
||||
const disclaimer = form.querySelector('input[name="disclaimer"]') as HTMLInputElement;
|
||||
|
||||
let isValid = true;
|
||||
|
||||
// Validate name
|
||||
if (!name.value || name.value.length < 2) {
|
||||
showFieldError(name, 'Name must be at least 2 characters long');
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Validate email
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!email.value || !emailRegex.test(email.value)) {
|
||||
showFieldError(email, 'Please enter a valid email address');
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Validate message
|
||||
if (!message.value || message.value.length < 10) {
|
||||
showFieldError(message, 'Message must be at least 10 characters long');
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Validate disclaimer
|
||||
if (!disclaimer.checked) {
|
||||
showFieldError(disclaimer, 'Please accept the terms');
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
// Enhanced spam detection
|
||||
function isSpam(name: string, email: string, message: string) {
|
||||
const spamPatterns = [
|
||||
'viagra', 'cialis', 'casino', 'lottery', 'prize', 'winner',
|
||||
'free money', 'buy now', 'click here', 'earn money', 'make money',
|
||||
'investment opportunity', 'bitcoin', 'cryptocurrency', 'forex',
|
||||
'weight loss', 'diet pill', 'enlargement', 'cheap medication',
|
||||
'urgent', 'congratulations', 'winner', 'selected', 'lottery',
|
||||
'inheritance', 'bank transfer', 'bank account', 'wire transfer',
|
||||
'nigerian', 'prince', 'royal', 'dear friend', 'dear sir',
|
||||
'dear madam', 'dear beloved', 'dear winner', 'dear beneficiary'
|
||||
];
|
||||
|
||||
const content = `${name} ${email} ${message}`.toLowerCase();
|
||||
|
||||
// Check for spam keywords
|
||||
if (spamPatterns.some(pattern => content.includes(pattern))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for excessive capitalization
|
||||
const uppercaseRatio = (message.match(/[A-Z]/g) || []).length / message.length;
|
||||
if (uppercaseRatio > 0.5 && message.length > 20) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for excessive special characters
|
||||
const specialChars = '!@#$%^&*()_+-=[]{}\\|;:\'",.<>/?';
|
||||
const specialCharCount = [...message].filter(char => specialChars.includes(char)).length;
|
||||
const specialCharRatio = specialCharCount / message.length;
|
||||
if (specialCharRatio > 0.3 && message.length > 20) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for excessive URLs
|
||||
const urlCount = message.split('http').length - 1;
|
||||
if (urlCount > 2) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for suspicious email patterns
|
||||
const suspiciousEmailPatterns = [
|
||||
/[0-9]{10,}@/, // Numbers in email
|
||||
/[a-z]{1,2}[0-9]{5,}@/, // Short name with many numbers
|
||||
/[a-z]{20,}@/, // Very long username
|
||||
/@[a-z]{1,2}\.[a-z]{1,2}$/, // Short domain
|
||||
/@[0-9]+\.[a-z]+$/, // IP-like domain
|
||||
];
|
||||
|
||||
if (suspiciousEmailPatterns.some(pattern => pattern.test(email))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for suspicious name patterns
|
||||
const suspiciousNamePatterns = [
|
||||
/^[a-z]{1,2}[0-9]{5,}$/, // Short name with many numbers
|
||||
/^[0-9]{10,}$/, // All numbers
|
||||
/^[a-z]{20,}$/, // Very long name
|
||||
];
|
||||
|
||||
if (suspiciousNamePatterns.some(pattern => pattern.test(name.toLowerCase()))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// UI helper functions
|
||||
function showSuccess() {
|
||||
const successElement = document.getElementById('form-success');
|
||||
const errorElement = document.getElementById('form-error');
|
||||
const rateLimitElement = document.getElementById('rate-limit-error');
|
||||
|
||||
if (successElement) successElement.classList.remove('hidden');
|
||||
if (errorElement) errorElement.classList.add('hidden');
|
||||
if (rateLimitElement) rateLimitElement.classList.add('hidden');
|
||||
}
|
||||
|
||||
function showError(message: string) {
|
||||
const errorElement = document.getElementById('form-error');
|
||||
const successElement = document.getElementById('form-success');
|
||||
const rateLimitElement = document.getElementById('rate-limit-error');
|
||||
|
||||
if (errorElement) {
|
||||
errorElement.textContent = message;
|
||||
errorElement.classList.remove('hidden');
|
||||
}
|
||||
if (successElement) successElement.classList.add('hidden');
|
||||
if (rateLimitElement) rateLimitElement.classList.add('hidden');
|
||||
}
|
||||
|
||||
function showRateLimitError() {
|
||||
const rateLimitElement = document.getElementById('rate-limit-error');
|
||||
const successElement = document.getElementById('form-success');
|
||||
const errorElement = document.getElementById('form-error');
|
||||
|
||||
if (rateLimitElement) rateLimitElement.classList.remove('hidden');
|
||||
if (successElement) successElement.classList.add('hidden');
|
||||
if (errorElement) errorElement.classList.add('hidden');
|
||||
}
|
||||
|
||||
function showFieldError(element: HTMLElement, message: string) {
|
||||
const feedbackElement = element.nextElementSibling as HTMLElement;
|
||||
if (feedbackElement) {
|
||||
feedbackElement.textContent = message;
|
||||
feedbackElement.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
Reference in New Issue
Block a user