Updated security and form logic
This commit is contained in:
@@ -8,7 +8,6 @@ import tailwind from '@astrojs/tailwind';
|
||||
import mdx from '@astrojs/mdx';
|
||||
import react from '@astrojs/react';
|
||||
import partytown from '@astrojs/partytown';
|
||||
import netlify from '@astrojs/netlify';
|
||||
import icon from 'astro-icon';
|
||||
import compress from 'astro-compress';
|
||||
import type { AstroIntegration } from 'astro';
|
||||
@@ -23,21 +22,40 @@ const whenExternalScripts = (items: (() => AstroIntegration) | (() => AstroInteg
|
||||
hasExternalScripts ? (Array.isArray(items) ? items.map((item) => item()) : [items()]) : [];
|
||||
|
||||
export default defineConfig({
|
||||
output: 'server',
|
||||
adapter: netlify(),
|
||||
output: 'static',
|
||||
site: 'https://www.365devnet.com',
|
||||
|
||||
i18n: {
|
||||
locales: ["en", "de", "nl", "fr"],
|
||||
defaultLocale: "en",
|
||||
routing: {
|
||||
strategy: 'prefix-always',
|
||||
},
|
||||
},
|
||||
|
||||
integrations: [
|
||||
react(),
|
||||
react({
|
||||
include: ['**/react/*'],
|
||||
exclude: ['**/react/*.test.{js,jsx,ts,tsx}'],
|
||||
}),
|
||||
tailwind({
|
||||
applyBaseStyles: false,
|
||||
config: {
|
||||
future: {
|
||||
hoverOnlyWhenSupported: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
sitemap({
|
||||
changefreq: 'weekly',
|
||||
priority: 0.7,
|
||||
lastmod: new Date(),
|
||||
}),
|
||||
mdx({
|
||||
drafts: false,
|
||||
syntaxHighlight: 'prism',
|
||||
rehypePlugins: [responsiveTablesRehypePlugin, lazyImagesRehypePlugin],
|
||||
}),
|
||||
sitemap(),
|
||||
mdx(),
|
||||
icon({
|
||||
include: {
|
||||
tabler: ['*'],
|
||||
@@ -67,10 +85,25 @@ export default defineConfig({
|
||||
HTML: {
|
||||
'html-minifier-terser': {
|
||||
removeAttributeQuotes: false,
|
||||
collapseWhitespace: true,
|
||||
removeComments: true,
|
||||
minifyCSS: true,
|
||||
minifyJS: true,
|
||||
},
|
||||
},
|
||||
Image: {
|
||||
sharp: {
|
||||
quality: 80,
|
||||
progressive: true,
|
||||
},
|
||||
},
|
||||
JavaScript: {
|
||||
compress: true,
|
||||
mangle: true,
|
||||
format: {
|
||||
comments: false,
|
||||
},
|
||||
},
|
||||
Image: true,
|
||||
JavaScript: true,
|
||||
SVG: false,
|
||||
Logger: 1,
|
||||
}),
|
||||
@@ -82,11 +115,25 @@ export default defineConfig({
|
||||
|
||||
image: {
|
||||
domains: ['cdn.pixabay.com', 'raw.githubusercontent.com'],
|
||||
service: {
|
||||
entrypoint: 'astro/assets/services/sharp',
|
||||
},
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'cdn.pixabay.com',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'raw.githubusercontent.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
markdown: {
|
||||
remarkPlugins: [readingTimeRemarkPlugin],
|
||||
rehypePlugins: [responsiveTablesRehypePlugin, lazyImagesRehypePlugin],
|
||||
syntaxHighlight: 'prism',
|
||||
},
|
||||
|
||||
vite: {
|
||||
@@ -95,5 +142,22 @@ export default defineConfig({
|
||||
'~': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
target: 'esnext',
|
||||
minify: 'terser',
|
||||
cssMinify: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
'react-vendor': ['react', 'react-dom'],
|
||||
'ui-vendor': ['@astrojs/react', '@astrojs/tailwind'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['react', 'react-dom'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
2488
package-lock.json
generated
2488
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@@ -19,10 +19,15 @@
|
||||
"check:prettier": "prettier --check .",
|
||||
"fix": "npm run fix:eslint && npm run fix:prettier",
|
||||
"fix:eslint": "eslint --fix .",
|
||||
"fix:prettier": "prettier -w ."
|
||||
"fix:prettier": "prettier -w .",
|
||||
"analyze": "astro build && astro-compress --analyze",
|
||||
"clean": "rimraf dist .astro",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx,.astro",
|
||||
"format": "prettier --write .",
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/netlify": "^6.1.0",
|
||||
"@astrojs/react": "^4.2.0",
|
||||
"@astrojs/rss": "^4.0.11",
|
||||
"@astrojs/sitemap": "^3.2.1",
|
||||
@@ -67,16 +72,28 @@
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-plugin-astro": "^1.3.1",
|
||||
"globals": "^15.14.0",
|
||||
"husky": "^9.0.11",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lint-staged": "^15.2.2",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-astro": "^0.14.1",
|
||||
"reading-time": "^1.5.0",
|
||||
"rimraf": "^5.0.5",
|
||||
"sharp": "0.33.5",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.21.0",
|
||||
"unist-util-visit": "^5.0.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx,astro}": [
|
||||
"eslint --fix",
|
||||
"prettier --write"
|
||||
],
|
||||
"*.{json,md,mdx,yaml,yml}": [
|
||||
"prettier --write"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
@@ -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');
|
||||
// Validate form
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
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');
|
||||
|
||||
// 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