diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..07c2bfc --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Contact form (server/contact.mjs). Copy to .env for `npm run contact`; set the same in Dokploy. +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=website@ciphercell.de +SMTP_PASS= +CONTACT_FROM=website@ciphercell.de +CONTACT_TO=office@ciphercell.de diff --git a/.gitignore b/.gitignore index a42fcf0..01b1db7 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,8 @@ ds-bundle/ .design-sync/.cache/ .design-sync/learnings/ .design-sync/node_modules -.DS_Store \ No newline at end of file +.DS_Store +# Local secrets for the contact handler +.env +.env.* +!.env.example diff --git a/Dockerfile b/Dockerfile index 9e24cae..021b0d8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,10 +10,21 @@ RUN npm ci --no-audit --no-fund COPY . . RUN npm run build -# Serve dist/ with nginx. +# The contact handler's production dependencies (nodemailer only). +FROM node:22-alpine AS contact +WORKDIR /srv/contact +COPY server/package.json server/package-lock.json ./ +RUN npm ci --omit=dev --no-audit --no-fund +COPY server/contact.mjs ./ + +# Serve dist/ with nginx; POST /contact goes to the handler on 127.0.0.1:3000. FROM nginx:1.27-alpine AS runtime +RUN apk add --no-cache nodejs COPY docker/nginx.conf /etc/nginx/conf.d/default.conf +COPY docker/entrypoint.sh /entrypoint.sh COPY --from=build /app/dist /usr/share/nginx/html +COPY --from=contact /srv/contact /srv/contact EXPOSE 80 +ENTRYPOINT ["/entrypoint.sh"] HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD wget -qO- http://127.0.0.1/healthz || exit 1 diff --git a/README.md b/README.md index 08c2434..11322b6 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ docker run --rm -p 8080:80 ciphercell # http://localhost:8080 In Dokploy: create an Application from this repository, set Build Type to **Dockerfile** (path `Dockerfile`), and under Domains add the domain with container port **80** and HTTPS on. -TLS is handled by Dokploy's Traefik. +TLS is handled by Dokploy's Traefik. The contact form needs the SMTP variables under +Environment; see *Contact form* below. ## Where things live @@ -43,6 +44,7 @@ TLS is handled by Dokploy's Traefik. | `src/components/verifier/*` | The CipherVerifier product page, in page order: Hero (line burst), Intro (image band), HowItWorks (five group cards, one schematic each; on wide screens the heading is sticky and the cards stack under it as you scroll), Cards, Facts, Outro. | | `src/components/verifier/SoonHero.astro`, `ComingSoon.astro` | The coming-soon version of `/cipherverifier`, shown while `verifierComingSoon` in `src/data/flags.ts` is true: hero on a build-time SVG relief of ridge lines in the logo blues, under the floating header, "Coming soon." with the alpha sign-up beside it, a status strip, and the product name set across the full width and cut by the hero's lower edge, release path, spec sheet and the first schematic. Copy in `ui.verifier.soon`. | | `src/components/verifier/diagrams.ts` | The five schematics as data: a hand-placed wide layout and an auto-generated tall spine per group. `Schematic.astro` renders either. Labels come from `ui.verifier.how.groups`; the tuple shape is in `types.ts`. Every element carries the `group` of the node it belongs to, which drives the hover focus and the draw-in order. | +| `server/contact.mjs` | The contact form handler: validates, rate-limits and sends by SMTP. Its own `package.json`, so the image only carries nodemailer. | | `scripts/*.mjs` | Review tooling: `shot.mjs` (full page), `scroll-shots.mjs` (viewport by viewport), `tabs-test.mjs` (keyboard test for the sectors tabs), `contact-sheet.mjs`. Need `npx astro preview --port 4322`. | | `src/pages/_*.astro` | Shared page bodies. `index.astro` and `de/index.astro` render `_Home.astro` with a `lang` prop, and so on. | | `src/assets/images/` | Source images. Astro emits AVIF with a WebP fallback and explicit dimensions. | @@ -56,9 +58,33 @@ TLS is handled by Dokploy's Traefik. ## Things the host has to provide -- **Contact form.** The form posts `first_name`, `last_name`, `email`, `message` and a honeypot - field `website` to `POST /contact`, as the current site does. The handler is server-side and - not part of this static build. +- **Contact form.** The form posts `first_name`, `last_name`, `email`, `message`, `lang` and a + honeypot field `website` to `POST /contact`. That one path is handled by `server/contact.mjs` + (Node, nodemailer), which sends the message by SMTP to `CONTACT_TO` with the sender as + Reply-To. With scripting the form sends in place and shows the result under the button; + without, the handler redirects to `/contact/sent` or `/contact/error` (under `/de` for German). + Both pages are `noindex` and left out of the sitemap. The handler caps size and field lengths, + refuses cross-site posts, and allows 5 messages per address per 10 minutes and 60 per hour + overall. + + In the container, nginx proxies `/contact` to the handler on `127.0.0.1:3000`; both are started + by `docker/entrypoint.sh`, which restarts the handler if it crashes. If the handler is down, + visitors are sent to the error page. Without SMTP settings the handler does not start and the + rest of the site is served as usual. + + | Variable | Default | Purpose | + |---|---|---| + | `SMTP_HOST` | (required) | Mail server. | + | `SMTP_PORT` | `587` | `587` uses STARTTLS (required), `465` TLS from the start. | + | `SMTP_USER`, `SMTP_PASS` | | Login. Leave unset for a server without authentication. | + | `CONTACT_FROM` | `SMTP_USER` | Sender address; must be one the SMTP account may send as. | + | `CONTACT_TO` | `office@ciphercell.de` | Where messages go. | + | `SMTP_SECURE` | by port | Force TLS from the start (`true`) or not (`false`). | + | `SMTP_REQUIRE_TLS` | `true` | `false` only for a local mail catcher. | + + In Dokploy, set these under the application's Environment. Locally, put them in `.env` (see + `.env.example`), run `npm run contact` next to `npm run dev`; the dev server proxies `/contact` + to it. - **Alpha sign-up.** While `verifierComingSoon` in `src/data/flags.ts` is true, `/cipherverifier` shows a coming-soon page whose form posts `email`, `consent=yes`, `lang`, `list=cipherverifier-alpha` and the honeypot `website` to `POST /notify`. The handler is diff --git a/astro.config.mjs b/astro.config.mjs index fa37156..872439f 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -5,11 +5,13 @@ import sitemap from '@astrojs/sitemap'; export default defineConfig({ site: 'https://www.ciphercell.de', trailingSlash: 'never', - integrations: [sitemap({ i18n: { defaultLocale: 'en', locales: { en: 'en-GB', de: 'de-DE' } } })], + integrations: [sitemap({ filter: (page) => !/\/contact\//.test(page), i18n: { defaultLocale: 'en', locales: { en: 'en-GB', de: 'de-DE' } } })], i18n: { defaultLocale: 'en', locales: ['en', 'de'], routing: { prefixDefaultLocale: false }, }, build: { inlineStylesheets: 'auto' }, + // In development the form posts to the contact handler started with `npm run contact`. + vite: { server: { proxy: { '^/contact$': { target: 'http://127.0.0.1:3000', changeOrigin: false } } } }, }); diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..497ebed --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# Runs the contact handler next to nginx. The handler is restarted if it crashes, but not when it +# exits for missing configuration (code 78); the static site is served either way. +( + cd /srv/contact || exit + while :; do + node contact.mjs + code=$? + [ "$code" -eq 78 ] && { echo "contact: not started, SMTP is not configured." >&2; exit; } + echo "contact: exited with $code, restarting in 5 s." >&2 + sleep 5 + done +) & + +exec nginx -g 'daemon off;' diff --git a/docker/nginx.conf b/docker/nginx.conf index e7ed5a4..86097b7 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -7,6 +7,13 @@ server { # TLS terminates at Dokploy's Traefik; keep redirects relative so they stay on https. absolute_redirect off; + + # The client address arrives in X-Forwarded-For from Traefik on the private network. + set_real_ip_from 10.0.0.0/8; + set_real_ip_from 172.16.0.0/12; + set_real_ip_from 192.168.0.0/16; + real_ip_header X-Forwarded-For; + real_ip_recursive on; server_tokens off; gzip on; @@ -24,6 +31,24 @@ server { return 200 "ok\n"; } + # The contact form is the one dynamic path: server/contact.mjs, sending by SMTP. + location = /contact { + limit_except POST { deny all; } + client_max_body_size 64k; + proxy_pass http://127.0.0.1:3000; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 30s; + proxy_intercept_errors on; + error_page 502 503 504 = @contact_down; + } + # Handler not running: send the visitor to the error page in their language. + location @contact_down { + if ($http_referer ~ "/de(/|$|#)") { return 303 /de/contact/error; } + return 303 /contact/error; + } + # trailingSlash: 'never' — /team is served from /team/index.html; /team/ redirects to /team. location ~ ^(.+)/$ { return 301 $1$is_args$args; diff --git a/package.json b/package.json index b7e96de..8bc158c 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "dev": "ASTRO_TELEMETRY_DISABLED=1 astro dev", "build": "ASTRO_TELEMETRY_DISABLED=1 astro build", "preview": "ASTRO_TELEMETRY_DISABLED=1 astro preview", - "check": "ASTRO_TELEMETRY_DISABLED=1 astro check" + "check": "ASTRO_TELEMETRY_DISABLED=1 astro check", + "contact": "node --env-file-if-exists=.env server/contact.mjs" }, "dependencies": { "@fontsource-variable/inter-tight": "^5.3.0", diff --git a/server/contact.mjs b/server/contact.mjs new file mode 100644 index 0000000..29ed805 --- /dev/null +++ b/server/contact.mjs @@ -0,0 +1,153 @@ +/** + * 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}`)); diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..2a45228 --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,25 @@ +{ + "name": "ciphercell-contact", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ciphercell-contact", + "dependencies": { + "nodemailer": "^7.0.13" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/nodemailer": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", + "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..26627aa --- /dev/null +++ b/server/package.json @@ -0,0 +1,15 @@ +{ + "name": "ciphercell-contact", + "private": true, + "type": "module", + "description": "Receives the contact form and sends it on by SMTP. Runs next to nginx; see README.", + "scripts": { + "start": "node contact.mjs" + }, + "engines": { + "node": ">=20" + }, + "dependencies": { + "nodemailer": "^7.0.13" + } +} diff --git a/src/components/home/Contact.astro b/src/components/home/Contact.astro index eff3b96..83e251c 100644 --- a/src/components/home/Contact.astro +++ b/src/components/home/Contact.astro @@ -16,7 +16,17 @@ const f = ui.contact.form; -
+ + ++ +
{item.body}
{r.body}
+ {r.link} +