/** * The contact form handler. nginx proxies POST /contact here; everything else on the site stays static. * * The form posts first_name, last_name, email, message, lang and the honeypot website. A browser * with scripting asks for JSON and shows the result in place; without scripting the answer is a * redirect to /contact/sent or /contact/error (under /de for German). * * Configuration comes from the environment only, see README.md. */ import { createServer } from 'node:http'; import nodemailer from 'nodemailer'; const env = process.env; const PORT = Number(env.CONTACT_PORT || 3000); const HOST = env.CONTACT_HOST || '127.0.0.1'; const TO = env.CONTACT_TO || 'office@ciphercell.de'; const FROM = env.CONTACT_FROM || env.SMTP_USER; const SMTP_PORT = Number(env.SMTP_PORT || 587); if (!env.SMTP_HOST || !FROM) { console.error('contact: SMTP_HOST and CONTACT_FROM (or SMTP_USER) must be set.'); process.exit(78); // EX_CONFIG: the entrypoint does not restart on this one. } const transport = nodemailer.createTransport({ host: env.SMTP_HOST, port: SMTP_PORT, // 465 speaks TLS from the first byte; 587 and 25 upgrade with STARTTLS, which we then require. // SMTP_REQUIRE_TLS=false is for a local mail catcher only. secure: env.SMTP_SECURE ? env.SMTP_SECURE === 'true' : SMTP_PORT === 465, requireTLS: env.SMTP_REQUIRE_TLS !== 'false' && SMTP_PORT !== 465, auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASS } : undefined, }); /* Limits. The body cap is generous for four short fields; the rate caps stop a flood, not a person. */ const MAX_BODY = 32 * 1024; const MAX = { first_name: 100, last_name: 100, email: 254, message: 5000 }; const PER_IP = { count: 5, windowMs: 10 * 60 * 1000 }; const GLOBAL = { count: 60, windowMs: 60 * 60 * 1000 }; const hits = new Map(); const globalHits = []; function limited(ip, now = Date.now()) { while (globalHits.length && now - globalHits[0] > GLOBAL.windowMs) globalHits.shift(); const mine = (hits.get(ip) || []).filter((t) => now - t < PER_IP.windowMs); if (mine.length >= PER_IP.count || globalHits.length >= GLOBAL.count) return true; mine.push(now); hits.set(ip, mine); globalHits.push(now); return false; } setInterval(() => { const now = Date.now(); for (const [ip, list] of hits) if (list.every((t) => now - t >= PER_IP.windowMs)) hits.delete(ip); }, PER_IP.windowMs).unref(); const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const oneLine = (s) => s.replace(/[\r\n\t]+/g, ' ').trim(); function validate(form) { const data = { first_name: oneLine(form.get('first_name') || ''), last_name: oneLine(form.get('last_name') || ''), email: oneLine(form.get('email') || ''), message: (form.get('message') || '').replace(/\r\n/g, '\n').trim(), }; for (const [key, max] of Object.entries(MAX)) { if (!data[key] || data[key].length > max) return null; } return EMAIL.test(data.email) ? data : null; } function readBody(req) { return new Promise((resolve, reject) => { let size = 0; const chunks = []; req.on('data', (chunk) => { size += chunk.length; if (size > MAX_BODY) { reject(new Error('too large')); req.destroy(); return; } chunks.push(chunk); }); req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); req.on('error', reject); }); } function reply(req, res, lang, outcome) { const status = { sent: 200, invalid: 400, limited: 429, failed: 502 }[outcome]; if ((req.headers.accept || '').includes('application/json')) { res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify({ ok: outcome === 'sent', outcome })); return; } const page = outcome === 'sent' ? 'sent' : 'error'; res.writeHead(303, { Location: `${lang === 'de' ? '/de' : ''}/contact/${page}`, 'Cache-Control': 'no-store' }); res.end(); } const server = createServer(async (req, res) => { const path = (req.url || '').split('?')[0]; if (path === '/contact/healthz') { res.writeHead(200).end('ok\n'); return; } if (req.method !== 'POST' || path !== '/contact') { res.writeHead(404).end(); return; } // A cross-site post is not a visitor using the form. const origin = req.headers.origin; const host = req.headers['x-forwarded-host'] || req.headers.host; if (origin && host && new URL(origin).host !== host) { res.writeHead(403).end(); return; } let form; try { form = new URLSearchParams(await readBody(req)); } catch { res.writeHead(413).end(); return; } const lang = form.get('lang') === 'de' ? 'de' : 'en'; // Filled honeypot: answer as if sent, so the bot learns nothing. if (form.get('website')) { reply(req, res, lang, 'sent'); return; } const data = validate(form); if (!data) { reply(req, res, lang, 'invalid'); return; } // nginx sets X-Real-IP from the proxy chain; fall back to the socket for local runs. const ip = req.headers['x-real-ip'] || req.socket.remoteAddress || 'unknown'; if (limited(String(ip))) { reply(req, res, lang, 'limited'); return; } const name = `${data.first_name} ${data.last_name}`; try { await transport.sendMail({ from: { name: 'CipherCell website', address: FROM }, to: TO, replyTo: { name, address: data.email }, subject: `Website enquiry from ${name}`, text: [ `Name: ${name}`, `Email: ${data.email}`, `Language: ${lang}`, '', data.message, '', '-- ', 'Sent from the contact form on ciphercell.de. Reply to this email to answer.', ].join('\n'), }); reply(req, res, lang, 'sent'); } catch (err) { console.error('contact: sending failed:', err.message); reply(req, res, lang, 'failed'); } }); server.listen(PORT, HOST, () => console.log(`contact: listening on ${HOST}:${PORT}, delivering to ${TO}`));