Updated security and form logic
Some checks failed
GitHub Actions / build (18) (push) Has been cancelled
GitHub Actions / build (20) (push) Has been cancelled
GitHub Actions / build (22) (push) Has been cancelled
GitHub Actions / check (push) Has been cancelled

This commit is contained in:
2025-05-09 23:25:49 +02:00
parent 583ff0d466
commit b28ffd16d0
4 changed files with 1866 additions and 1066 deletions

View File

@@ -8,7 +8,6 @@ import tailwind from '@astrojs/tailwind';
import mdx from '@astrojs/mdx'; import mdx from '@astrojs/mdx';
import react from '@astrojs/react'; import react from '@astrojs/react';
import partytown from '@astrojs/partytown'; import partytown from '@astrojs/partytown';
import netlify from '@astrojs/netlify';
import icon from 'astro-icon'; import icon from 'astro-icon';
import compress from 'astro-compress'; import compress from 'astro-compress';
import type { AstroIntegration } from 'astro'; import type { AstroIntegration } from 'astro';
@@ -23,21 +22,40 @@ const whenExternalScripts = (items: (() => AstroIntegration) | (() => AstroInteg
hasExternalScripts ? (Array.isArray(items) ? items.map((item) => item()) : [items()]) : []; hasExternalScripts ? (Array.isArray(items) ? items.map((item) => item()) : [items()]) : [];
export default defineConfig({ export default defineConfig({
output: 'server', output: 'static',
adapter: netlify(), site: 'https://www.365devnet.com',
i18n: { i18n: {
locales: ["en", "de", "nl", "fr"], locales: ["en", "de", "nl", "fr"],
defaultLocale: "en", defaultLocale: "en",
routing: {
strategy: 'prefix-always',
},
}, },
integrations: [ integrations: [
react(), react({
include: ['**/react/*'],
exclude: ['**/react/*.test.{js,jsx,ts,tsx}'],
}),
tailwind({ tailwind({
applyBaseStyles: false, 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({ icon({
include: { include: {
tabler: ['*'], tabler: ['*'],
@@ -67,10 +85,25 @@ export default defineConfig({
HTML: { HTML: {
'html-minifier-terser': { 'html-minifier-terser': {
removeAttributeQuotes: false, 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, SVG: false,
Logger: 1, Logger: 1,
}), }),
@@ -82,11 +115,25 @@ export default defineConfig({
image: { image: {
domains: ['cdn.pixabay.com', 'raw.githubusercontent.com'], 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: { markdown: {
remarkPlugins: [readingTimeRemarkPlugin], remarkPlugins: [readingTimeRemarkPlugin],
rehypePlugins: [responsiveTablesRehypePlugin, lazyImagesRehypePlugin], rehypePlugins: [responsiveTablesRehypePlugin, lazyImagesRehypePlugin],
syntaxHighlight: 'prism',
}, },
vite: { vite: {
@@ -95,5 +142,22 @@ export default defineConfig({
'~': path.resolve(__dirname, './src'), '~': 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'],
},
}, },
}); });

2490
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,10 +19,15 @@
"check:prettier": "prettier --check .", "check:prettier": "prettier --check .",
"fix": "npm run fix:eslint && npm run fix:prettier", "fix": "npm run fix:eslint && npm run fix:prettier",
"fix:eslint": "eslint --fix .", "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": { "dependencies": {
"@astrojs/netlify": "^6.1.0",
"@astrojs/react": "^4.2.0", "@astrojs/react": "^4.2.0",
"@astrojs/rss": "^4.0.11", "@astrojs/rss": "^4.0.11",
"@astrojs/sitemap": "^3.2.1", "@astrojs/sitemap": "^3.2.1",
@@ -67,16 +72,28 @@
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-plugin-astro": "^1.3.1", "eslint-plugin-astro": "^1.3.1",
"globals": "^15.14.0", "globals": "^15.14.0",
"husky": "^9.0.11",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"lint-staged": "^15.2.2",
"mdast-util-to-string": "^4.0.0", "mdast-util-to-string": "^4.0.0",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"prettier-plugin-astro": "^0.14.1", "prettier-plugin-astro": "^0.14.1",
"reading-time": "^1.5.0", "reading-time": "^1.5.0",
"rimraf": "^5.0.5",
"sharp": "0.33.5", "sharp": "0.33.5",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"typescript-eslint": "^8.21.0", "typescript-eslint": "^8.21.0",
"unist-util-visit": "^5.0.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"
]
} }
} }

View File

@@ -13,6 +13,42 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
padding: 0.5rem; padding: 0.5rem;
background-color: rgba(239, 68, 68, 0.05); 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> </style>
<form <form
@@ -20,8 +56,6 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
name="contact" name="contact"
method="POST" method="POST"
class="needs-validation" class="needs-validation"
data-netlify="true"
data-netlify-honeypot="bot-field"
novalidate novalidate
> >
<!-- Form status messages --> <!-- 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. There was an error sending your message. Please check all fields and try again.
</div> </div>
<!-- Netlify form name --> <div id="rate-limit-error" class="hidden mb-6 p-4 bg-yellow-100 border border-yellow-200 text-yellow-700 rounded-lg">
<input type="hidden" name="form-name" value="contact" /> 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 &&
inputs.map( inputs.map(
@@ -60,6 +100,9 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
placeholder={placeholder} 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" 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} 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 class="invalid-feedback hidden text-red-600 text-sm mt-1" />
</div> </div>
@@ -81,6 +124,8 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
placeholder={textarea.placeholder} 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" 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
minlength="10"
maxlength="1000"
/> />
<div class="invalid-feedback hidden text-red-600 text-sm mt-1" /> <div class="invalid-feedback hidden text-red-600 text-sm mt-1" />
</div> </div>
@@ -113,7 +158,7 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
{ {
button && ( button && (
<div class="mt-10 grid"> <div class="mt-10 grid">
<Button variant="primary" type="submit"> <Button variant="primary" type="submit" id="submit-button">
{button} {button}
</Button> </Button>
</div> </div>
@@ -130,55 +175,261 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
</form> </form>
<script> <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 form = document.getElementById('contact-form') as HTMLFormElement;
const submitButton = document.getElementById('submit-button') as HTMLButtonElement;
if (form) { 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) => { form.addEventListener('submit', async (event) => {
event.preventDefault(); event.preventDefault();
const formData = new FormData(form); // Disable submit button and show loading state
const data = Object.fromEntries(formData.entries()); if (submitButton) {
submitButton.disabled = true;
submitButton.classList.add('button-loading');
}
try { try {
const response = await fetch('/.netlify/functions/contact', { // Check rate limit
method: 'POST', if (isRateLimited()) {
body: JSON.stringify(data), showRateLimitError();
}); return;
}
if (response.ok) { // Validate form
const result = await response.json(); if (!validateForm()) {
console.log(result.message); // Log success message return;
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');
}
} }
// 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) { } catch (error) {
console.error('Error:', error); console.error('Form submission error:', error);
const errorElement = document.getElementById('form-error'); showError('An unexpected error occurred. Please try again.');
if (errorElement) { } finally {
errorElement.classList.remove('hidden'); // Re-enable submit button and remove loading state
} if (submitButton) {
const successElement = document.getElementById('form-success'); submitButton.disabled = false;
if (successElement) { submitButton.classList.remove('button-loading');
successElement.classList.add('hidden');
} }
} }
}); });
} }
// 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> </script>