import express from 'express' import rateLimit from 'express-rate-limit' import { body, validationResult } from 'express-validator' import { sendAuditRequestEmail, sendQuickcheckRequestEmail } from '../services/emailService.js' const router = express.Router() const auditLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 3, // per IP message: { success: false, message: 'Too many audit requests from this IP, please try again later.', }, standardHeaders: true, legacyHeaders: false, }) // Server-side validation is the real gate. The client-side checks exist only // so people are not punished with a round trip for a typo. const validateAuditRequest = [ body('formType').optional().equals('audit'), body('name').trim().isLength({ min: 1, max: 100 }).escape(), body('company').trim().isLength({ min: 1, max: 150 }).escape(), body('email').trim().isEmail().normalizeEmail().isLength({ max: 254 }), // No require_protocol: people paste bare domains ("example.com") too. // protocols still pins an explicit scheme, if given, to http/https. body('url') .trim() .isURL({ protocols: ['http', 'https'] }) .isLength({ max: 2048 }), body('role').isIn(['agency', 'client']), body('motivation').trim().isLength({ min: 1, max: 2000 }).escape(), body('businessConfirmation') .equals('true') .withMessage('Business (B2B) confirmation required'), body('gdprConsent').equals('true').withMessage('GDPR consent required'), body('website').optional().isEmpty(), // Honeypot ] const validateQuickcheckRequest = [ body('formType').equals('quickcheck'), body('name').optional().trim().isLength({ max: 100 }).escape(), body('email').trim().isEmail().normalizeEmail().isLength({ max: 254 }), body('businessConfirmation') .equals('true') .withMessage('Business (B2B) confirmation required'), body('gdprConsent').equals('true').withMessage('GDPR consent required'), body('website').optional().isEmpty(), // Honeypot ] const validateRequest = (req, _res, next) => { const validators = req.body.formType === 'quickcheck' ? validateQuickcheckRequest : validateAuditRequest Promise.all(validators.map((validator) => validator.run(req))).then(() => next()) } router.post('/', auditLimiter, validateRequest, async (req, res) => { try { const errors = validationResult(req) if (!errors.isEmpty()) { // Field names only - never echo the submitted values back into logs. return res.status(400).json({ success: false, message: 'Invalid form data.', fields: errors.array().map((error) => error.path), }) } const { formType = 'audit', name, email, businessConfirmation, gdprConsent, website } = req.body if (website) { console.warn('Honeypot triggered, discarding submission.') // Answer 200 so the bot does not learn anything and does not retry. return res.json({ success: true, message: 'Thank you.' }) } // The offer is B2B only (ยง 14 BGB). Without the explicit confirmation the // request is refused rather than quietly accepted. if (businessConfirmation !== 'true') { return res.status(400).json({ success: false, message: 'This offer is available to businesses only.', }) } if (gdprConsent !== 'true') { return res.status(400).json({ success: false, message: 'GDPR consent required.', }) } if (formType === 'quickcheck') { await sendQuickcheckRequestEmail({ name: name || '', email }) return res.json({ success: true, message: 'Thank you, your quick check request arrived.', }) } const { company, url, role, motivation } = req.body await sendAuditRequestEmail({ name, company, email, url, role, motivation }) res.json({ success: true, message: 'Thank you, your audit request arrived.', }) } catch (error) { // Message only. The submitted data must never end up in server logs. console.error('Audit request failed:', error.message) res.status(500).json({ success: false, message: 'Failed to send the request. Please try again later.', }) } }) export { router as auditRouter }