diff --git a/.gitignore b/.gitignore index 48cd56d..02b0197 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ pnpm-debug.log* .vscode/ .idea/ .DS_Store +.vercel diff --git a/backend/.gitignore b/backend/.gitignore index 7992a84..e9d6196 100644 Binary files a/backend/.gitignore and b/backend/.gitignore differ diff --git a/backend/package-lock.json b/backend/package-lock.json index 34abce8..9060552 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -14,7 +14,8 @@ "express-rate-limit": "^7.1.5", "helmet": "^7.1.0", "joi": "^17.11.0", - "nodemailer": "^6.9.7" + "nodemailer": "^6.9.7", + "resend": "^6.0.2" }, "devDependencies": { "nodemon": "^3.0.2" @@ -1044,6 +1045,23 @@ "node": ">=8.10.0" } }, + "node_modules/resend": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.0.2.tgz", + "integrity": "sha512-um08qWpSVvEVqAePEy/bsa7pqtnJK+qTCZ0Et7YE7xuqM46J0C9gnSbIJKR3LIcRVMgO9jUeot8rH0UI84eqMQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@react-email/render": "^1.1.0" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", diff --git a/backend/package.json b/backend/package.json index 1a14e93..55dc00f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,13 +11,14 @@ "test": "echo 'No tests yet'" }, "dependencies": { - "express": "^4.18.2", - "nodemailer": "^6.9.7", "cors": "^2.8.5", - "helmet": "^7.1.0", + "dotenv": "^16.3.1", + "express": "^4.18.2", "express-rate-limit": "^7.1.5", + "helmet": "^7.1.0", "joi": "^17.11.0", - "dotenv": "^16.3.1" + "nodemailer": "^6.9.7", + "resend": "^6.0.2" }, "devDependencies": { "nodemon": "^3.0.2" diff --git a/backend/routes/contact.js b/backend/routes/contact.js index 21ea7cd..f06ed28 100644 --- a/backend/routes/contact.js +++ b/backend/routes/contact.js @@ -1,74 +1,93 @@ import express from 'express'; +import rateLimit from 'express-rate-limit'; +import { body, validationResult } from 'express-validator'; import { sendContactEmail } from '../services/emailService.js'; -import { validateContactForm } from '../middleware/validation.js'; -import { asyncHandler } from '../middleware/asyncHandler.js'; const router = express.Router(); -// POST /api/contact - Send contact form email -router.post( - '/', - validateContactForm, - asyncHandler(async (req, res) => { - const { firstName, lastName, email, subject, message } = req.body; +// GDPR compliant rate limiting +const contactLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 3, // Limit each IP to 3 requests per windowMs + message: { + error: 'Too many contact requests from this IP, please try again later.', + retryAfter: 15 * 60, + }, + standardHeaders: true, + legacyHeaders: false, +}); - // Check for honeypot (if present in body) - if (req.body.website && req.body.website.trim() !== '') { - console.warn('Spam detected: honeypot field filled', { - ip: req.ip, - email, - }); +// GDPR compliant validation +const validateContactForm = [ + body('firstName').trim().isLength({ min: 1, max: 50 }).escape(), + body('lastName').trim().isLength({ min: 1, max: 50 }).escape(), + body('email').trim().isEmail().normalizeEmail().isLength({ max: 254 }), + body('subject').trim().isLength({ min: 1, max: 100 }).escape(), + body('message').trim().isLength({ min: 10, max: 2000 }).escape(), + body('gdprConsent').equals('true').withMessage('GDPR consent required'), + body('website').optional().isEmpty(), // Honeypot +]; + +router.post('/', contactLimiter, validateContactForm, async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { return res.status(400).json({ success: false, - message: 'Invalid form submission detected.', + message: 'Invalid form data.', + errors: errors.array(), }); } - try { - // Send the email - await sendContactEmail({ - firstName, - lastName, - email, - subject: subject || 'New Portfolio Contact', - message, - senderIP: req.ip, - userAgent: req.get('User-Agent'), - }); + const { + firstName, + lastName, + email, + subject, + message, + gdprConsent, + website, + } = req.body; - console.log('Contact email sent successfully', { - from: email, - name: `${firstName} ${lastName}`, - subject: subject || 'New Portfolio Contact', - ip: req.ip, - }); - - res.json({ - success: true, - message: - "Your message has been sent successfully! I'll get back to you soon.", - }); - } catch (error) { - console.error('Failed to send contact email:', error); - - res.status(500).json({ + // Honeypot check + if (website) { + console.warn('🚨 Spam detected:', req.ip); + return res.status(400).json({ success: false, - message: - 'Sorry, there was an error sending your message. Please try again later.', + message: 'Invalid submission detected.', }); } - }) -); -// GET /api/contact/test - Test endpoint (development only) -if (process.env.NODE_ENV === 'development') { - router.get('/test', (req, res) => { - res.json({ - message: 'Contact API is working!', - timestamp: new Date().toISOString(), - environment: process.env.NODE_ENV, + // GDPR consent check + if (gdprConsent !== 'true') { + return res.status(400).json({ + success: false, + message: 'GDPR consent required.', + }); + } + + // Send email with minimal logging for GDPR + await sendContactEmail({ + firstName, + lastName, + email, + subject, + message, + senderIP: req.ip, + userAgent: req.get('User-Agent'), }); - }); -} + + res.json({ + success: true, + message: "Thank you for your message! I'll get back to you soon.", + }); + } catch (error) { + console.error('❌ Contact error:', error.message); // No personal data in logs + res.status(500).json({ + success: false, + message: 'Failed to send message. Please try again later.', + }); + } +}); export { router as contactRouter }; diff --git a/backend/server.js b/backend/server.js index 1870d90..b103b68 100644 --- a/backend/server.js +++ b/backend/server.js @@ -24,6 +24,9 @@ async function startServer() { const app = express(); const PORT = process.env.PORT || 3001; + // Trust proxy (required for Railway, Heroku, etc.) + app.set('trust proxy', 1); + // Security middleware app.use( helmet({ @@ -34,10 +37,14 @@ async function startServer() { // CORS configuration app.use( cors({ - origin: process.env.FRONTEND_URL || 'http://localhost:5173', - methods: ['GET', 'POST'], - allowedHeaders: ['Content-Type', 'Authorization'], + origin: [ + 'http://localhost:5173', + 'http://localhost:3000', + 'https://portfolio-page-zeta-ten.vercel.app', + ], credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'], }) ); @@ -53,6 +60,11 @@ async function startServer() { }, standardHeaders: true, legacyHeaders: false, + // Add this for better proxy support + skip: (req) => { + // Skip rate limiting for health checks + return req.path === '/health'; + }, }); app.use('/api/', limiter); @@ -81,7 +93,7 @@ async function startServer() { app.use(errorHandler); // Start server - app.listen(PORT, () => { + app.listen(PORT, '0.0.0.0', () => { console.log(`🚀 Server is running on port ${PORT}`); console.log( `📧 Email service: ${process.env.EMAIL_SERVICE || 'Not configured'}` diff --git a/backend/services/emailService.js b/backend/services/emailService.js index fe6c480..3ce458e 100644 --- a/backend/services/emailService.js +++ b/backend/services/emailService.js @@ -1,45 +1,100 @@ import nodemailer from 'nodemailer'; -import { createContactEmailTemplate } from '../templates/emailTemplates.js'; -// Create email transporter +// Create email transporter for custom SMTP server const createTransporter = () => { - const config = { - service: process.env.EMAIL_SERVICE, - auth: { - user: process.env.EMAIL_USER, - pass: process.env.EMAIL_PASS, - }, - }; + console.log('🔧 Creating custom SMTP transporter with config:', { + host: process.env.SMTP_HOST, + port: process.env.SMTP_PORT, + secure: process.env.SMTP_SECURE === 'true', + user: process.env.SMTP_USER ? 'Set' : 'Missing', + pass: process.env.SMTP_PASS ? 'Set' : 'Missing', + }); - // If not using a predefined service, use custom SMTP settings - if (!process.env.EMAIL_SERVICE || process.env.EMAIL_SERVICE === 'custom') { - delete config.service; - config.host = process.env.EMAIL_HOST; - config.port = parseInt(process.env.EMAIL_PORT) || 587; - config.secure = process.env.EMAIL_SECURE === 'true'; + if ( + !process.env.SMTP_HOST || + !process.env.SMTP_USER || + !process.env.SMTP_PASS + ) { + throw new Error('Custom SMTP credentials not configured'); } - return nodemailer.createTransport(config); + return nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: parseInt(process.env.SMTP_PORT) || 587, + secure: process.env.SMTP_SECURE === 'true', // true for 465 (SSL), false for 587 (TLS) + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, + tls: { + rejectUnauthorized: false, // Accept self-signed certificates if needed + ciphers: 'SSLv3', // For older servers if needed + }, + timeout: 30000, + connectionTimeout: 30000, + greetingTimeout: 10000, + socketTimeout: 30000, + debug: process.env.NODE_ENV === 'development', + logger: process.env.NODE_ENV === 'development', + }); }; // Verify email configuration export const verifyEmailConfig = async () => { try { - console.log('🔍 Debug - Creating transporter with config:'); - console.log('EMAIL_SERVICE:', process.env.EMAIL_SERVICE); - console.log('EMAIL_USER:', process.env.EMAIL_USER); + console.log('🔍 Verifying custom SMTP configuration...'); + console.log('SMTP_HOST:', process.env.SMTP_HOST || 'Missing'); console.log( - 'EMAIL_PASS length:', - process.env.EMAIL_PASS ? process.env.EMAIL_PASS.length : 'undefined' + 'SMTP_PORT:', + process.env.SMTP_PORT || 'Missing (default: 587)' + ); + console.log( + 'SMTP_SECURE:', + process.env.SMTP_SECURE || 'Missing (default: false)' + ); + console.log('SMTP_USER:', process.env.SMTP_USER ? 'Set' : 'Missing'); + console.log('SMTP_PASS:', process.env.SMTP_PASS ? 'Set' : 'Missing'); + console.log('FROM_EMAIL:', process.env.FROM_EMAIL ? 'Set' : 'Missing'); + console.log( + 'RECIPIENT_EMAIL:', + process.env.RECIPIENT_EMAIL ? 'Set' : 'Missing' ); + if ( + !process.env.SMTP_HOST || + !process.env.SMTP_USER || + !process.env.SMTP_PASS + ) { + console.error('❌ Missing required SMTP credentials'); + return false; + } + + if (!process.env.FROM_EMAIL || !process.env.RECIPIENT_EMAIL) { + console.error('❌ Missing sender or recipient email'); + return false; + } + + // Skip verification in production to avoid Railway timeout issues + if (process.env.NODE_ENV === 'production') { + console.log('🔧 Skipping SMTP verification in production environment'); + return true; + } + + // Test connection in development const transporter = createTransporter(); await transporter.verify(); - console.log('✅ Email configuration verified successfully'); + console.log('✅ SMTP configuration verified successfully'); return true; } catch (error) { - console.error('❌ Email configuration verification failed:', error.message); - console.error('Full error:', error); + console.error('❌ SMTP configuration verification failed:', error.message); + + if (process.env.NODE_ENV === 'production') { + console.log( + '🔧 Continuing in production mode despite verification failure' + ); + return true; + } + return false; } }; @@ -54,66 +109,86 @@ export const sendContactEmail = async ({ senderIP, userAgent, }) => { - const transporter = createTransporter(); + const startTime = Date.now(); + console.log('📧 Starting email send process via custom SMTP...'); - const fullName = `${firstName} ${lastName}`; - const emailSubject = `[Portfolio Contact] ${subject}`; - - // Create email content - const emailTemplate = createContactEmailTemplate({ - fullName, - email, - subject, - message, - senderIP, - userAgent, - timestamp: new Date().toISOString(), - }); - - // Email options - const mailOptions = { - from: { - name: fullName, - address: process.env.EMAIL_USER, // Use your email as the sender - }, - to: { - name: process.env.RECIPIENT_NAME || 'Portfolio Owner', - address: process.env.RECIPIENT_EMAIL, - }, - replyTo: { - name: fullName, - address: email, // This allows you to reply directly to the sender - }, - subject: emailSubject, - html: emailTemplate.html, - text: emailTemplate.text, - headers: { - 'X-Priority': '1', - 'X-MSMail-Priority': 'High', - Importance: 'high', - }, - }; - - // Send the email try { + const transporter = createTransporter(); + const fullName = `${firstName} ${lastName}`; + + const mailOptions = { + from: { + name: 'Portfolio Contact Form', + address: process.env.FROM_EMAIL, // Your verified domain email + }, + to: process.env.RECIPIENT_EMAIL, + replyTo: { + name: fullName, + address: email, + }, + subject: `[Portfolio] ${subject}`, + html: ` +

New Contact Form Submission

+

Name: ${fullName}

+

Email: ${email}

+

Subject: ${subject}

+

Message:

+
+ ${message.replace(/\n/g, '
')} +
+
+

IP: ${senderIP || 'Unknown'}

+

User Agent: ${userAgent || 'Unknown'}

+

Sent: ${new Date().toISOString()}

+ `, + text: ` +New Contact Form Submission + +Name: ${fullName} +Email: ${email} +Subject: ${subject} + +Message: +${message} + +--- +IP: ${senderIP || 'Unknown'} +User Agent: ${userAgent || 'Unknown'} +Sent: ${new Date().toISOString()} + `, + }; + const result = await transporter.sendMail(mailOptions); - console.log('Email sent successfully:', result.messageId); + + const duration = Date.now() - startTime; + console.log( + `✅ Email sent successfully via custom SMTP in ${duration}ms:`, + result.messageId + ); + return result; } catch (error) { - console.error('Failed to send email:', error); + const duration = Date.now() - startTime; + console.error(`❌ Custom SMTP email send failed after ${duration}ms:`, { + message: error.message, + code: error.code, + command: error.command, + }); + throw new Error(`Email sending failed: ${error.message}`); } }; -// Initialize email service (verify configuration on startup) +// Initialize email service export const initializeEmailService = async () => { - console.log('🔧 Initializing email service...'); + console.log('🔧 Initializing custom SMTP email service...'); + const isValid = await verifyEmailConfig(); if (!isValid) { - console.warn( - '⚠️ Email service not properly configured. Check your .env file.' - ); + console.warn('⚠️ Custom SMTP email service not properly configured.'); + } else { + console.log('✅ Custom SMTP email service initialized successfully'); } return isValid; diff --git a/package-lock.json b/package-lock.json index bbb1efe..4e6ce6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@types/react-router-dom": "^5.3.3", + "express-validator": "^7.2.1", "lucide-react": "^0.542.0", "react": "^19.1.1", "react-dom": "^19.1.1", @@ -2603,6 +2604,19 @@ "node": ">=0.10.0" } }, + "node_modules/express-validator": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.1.tgz", + "integrity": "sha512-CjNE6aakfpuwGaHQZ3m8ltCG2Qvivd7RHtVMS/6nVxOM7xVGqr4bhflsm4+N5FP5zI7Zxp+Hae+9RE+o8e3ZOQ==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "validator": "~13.12.0" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2987,6 +3001,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -3716,6 +3736,15 @@ "punycode": "^2.1.0" } }, + "node_modules/validator": { + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", + "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vite": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz", diff --git a/package.json b/package.json index 3ace978..ef56dc2 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@types/react-router-dom": "^5.3.3", + "express-validator": "^7.2.1", "lucide-react": "^0.542.0", "react": "^19.1.1", "react-dom": "^19.1.1", diff --git a/src/components/sections/ContactSection.tsx b/src/components/sections/ContactSection.tsx index ea9a5df..019a871 100644 --- a/src/components/sections/ContactSection.tsx +++ b/src/components/sections/ContactSection.tsx @@ -1,8 +1,8 @@ import { Mail, Github, Linkedin } from 'lucide-react'; -import { useState } from 'react'; +import { useState, useRef } from 'react'; import type { FormEvent } from 'react'; import { personalConfig, getObfuscatedEmail, createEmailLink } from '../../config/personal'; -import { getApiUrl, ENDPOINTS } from '../../config/api'; +import { API_CONFIG } from '../../config/api'; interface ContactSectionProps { title?: string; @@ -30,12 +30,18 @@ export default function ContactSection({ message: string; }>({ type: null, message: '' }); + // GDPR consent state + const [gdprConsent, setGdprConsent] = useState(false); + // Email obfuscation function using config const handleEmailClick = (e: React.MouseEvent) => { e.preventDefault(); window.location.href = createEmailLink(); }; + // Add form ref + const formRef = useRef(null); + const handleSubmit = async (e: FormEvent) => { e.preventDefault(); @@ -50,6 +56,15 @@ export default function ContactSection({ return; } + // GDPR consent validation + if (!gdprConsent) { + setFormStatus({ + type: 'error', + message: 'Please accept the privacy policy to continue.' + }); + return; + } + // Prevent rapid submissions if (isSubmitting) { return; @@ -66,6 +81,8 @@ export default function ContactSection({ email: formData.get('email') as string, subject: formData.get('subject') as string, message: formData.get('message') as string, + gdprConsent: gdprConsent.toString(), // Add this + website: honeypot, // Anti-spam honeypot }; // Basic validation @@ -91,8 +108,7 @@ export default function ContactSection({ try { // Submit to your own backend API - const apiUrl = `${getApiUrl()}${ENDPOINTS.contact}`; - const response = await fetch(apiUrl, { + const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONTACT}`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -107,14 +123,29 @@ export default function ContactSection({ }), }); - const result = await response.json(); + // Check if response is ok before trying to parse JSON + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + // Check if response has content before parsing JSON + const text = await response.text(); + if (!text) { + throw new Error('Empty response from server'); + } + + const result = JSON.parse(text); if (result.success) { setFormStatus({ type: 'success', message: result.message }); - e.currentTarget.reset(); + + // Reset form using ref + if (formRef.current) { + formRef.current.reset(); + } setHoneypot(''); // Reset honeypot // Clear success message after 5 seconds @@ -266,7 +297,11 @@ export default function ContactSection({ )} -
+ {/* Honeypot field - hidden from users but visible to bots */}
+ {/* GDPR Consent Checkbox */} +
+ +
+ diff --git a/src/config/api.ts b/src/config/api.ts index af1dbba..e7133b9 100644 --- a/src/config/api.ts +++ b/src/config/api.ts @@ -1,22 +1,21 @@ -// API configuration for different environments -type Environment = 'development' | 'production'; +const isDevelopment = import.meta.env.DEV; -const API_CONFIG: Record = { - development: { - baseURL: 'http://localhost:3002', - }, - production: { - baseURL: 'https://your-backend-api.herokuapp.com', // Update with your actual backend URL +export const API_CONFIG = { + BASE_URL: isDevelopment + ? 'http://localhost:3002' + : 'https://portfolio-backend-production-39b3.up.railway.app', + ENDPOINTS: { + CONTACT: '/api/contact', }, }; -export const getApiUrl = (): string => { - const isDev = import.meta.env.DEV; - const environment: Environment = isDev ? 'development' : 'production'; - return API_CONFIG[environment].baseURL; +// Export individual items for backward compatibility +export const ENDPOINTS = API_CONFIG.ENDPOINTS; + +// Helper function to get full API URL +export const getApiUrl = (endpoint: string): string => { + return `${API_CONFIG.BASE_URL}${endpoint}`; }; -export const ENDPOINTS = { - contact: '/api/contact', - health: '/health', -}; +// Default export +export default API_CONFIG; diff --git a/src/pages/PrivacyPolicy.tsx b/src/pages/PrivacyPolicy.tsx new file mode 100644 index 0000000..64f95ce --- /dev/null +++ b/src/pages/PrivacyPolicy.tsx @@ -0,0 +1,25 @@ +export default function PrivacyPolicy() { + return ( +
+

Privacy Policy (GDPR/DSGVO)

+

Last updated: {new Date().toLocaleDateString()}

+ +

Data Collection

+

We collect only the information you provide through our contact form:

+
    +
  • Name (first and last)
  • +
  • Email address
  • +
  • Message content
  • +
+ +

Purpose

+

Your data is used solely to respond to your inquiry.

+ +

Data Retention

+

Your data is not stored permanently. Email content is processed and forwarded immediately.

+ +

Your Rights

+

You have the right to access, rectify, or delete your personal data. Contact: info@sascha-bach.de

+
+ ); +} \ No newline at end of file diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..3cdefcc --- /dev/null +++ b/vercel.json @@ -0,0 +1,15 @@ +{ + "buildCommand": "npm run build", + "outputDirectory": "dist", + "framework": "vite", + "rewrites": [ + { + "source": "/privacy-policy", + "destination": "/privacy-policy.html" + }, + { + "source": "/(.*)", + "destination": "/index.html" + } + ] +}