diff --git a/app/components/ContactForm.vue b/app/components/ContactForm.vue new file mode 100644 index 0000000..e2e1a05 --- /dev/null +++ b/app/components/ContactForm.vue @@ -0,0 +1,66 @@ + + + diff --git a/server/api/contact.post.ts b/server/api/contact.post.ts new file mode 100644 index 0000000..1eec936 --- /dev/null +++ b/server/api/contact.post.ts @@ -0,0 +1,48 @@ +import nodemailer from 'nodemailer' + +export default defineEventHandler(async (event) => { + const body = await readBody(event) + const config = useRuntimeConfig(event) + + // Server-side validation (T-03-01) + const { name, email, message } = body + if (!name || typeof name !== 'string' || name.length < 2 || name.length > 100) { + throw createError({ statusCode: 400, message: 'Invalid name' }) + } + if (!email || typeof email !== 'string' || !email.includes('@') || email.length > 200) { + throw createError({ statusCode: 400, message: 'Invalid email' }) + } + if (!message || typeof message !== 'string' || message.length < 10 || message.length > 5000) { + throw createError({ statusCode: 400, message: 'Invalid message' }) + } + + const transporter = nodemailer.createTransport({ + host: config.smtpHost, + port: 465, + secure: true, + auth: { + user: config.smtpUser, + pass: config.smtpPass, + }, + }) + + // Escape HTML to prevent XSS in email body (T-03-02) + const escapedName = name.replace(/[&<>"']/g, (c: string) => { + const map: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } + return map[c] ?? c + }) + const escapedMessage = message.replace(/[&<>"']/g, (c: string) => { + const map: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } + return map[c] ?? c + }) + + await transporter.sendMail({ + from: `"Portfolio" <${config.smtpUser}>`, + to: config.smtpTo, + subject: `Contact portfolio - ${name}`, + text: `De: ${name} <${email}>\n\n${message}`, + html: `

De: ${escapedName} <${email}>

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

`, + }) + + return { success: true } +})