Initial commit 🚀
This commit is contained in:
commit
fd58a8d32f
235 files changed
+19775
No files matched your search
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* The handler for the site's two forms; nginx proxies their paths here and everything else stays
|
||||
* static. Both send by SMTP.
|
||||
*
|
||||
* Contact (POST /contact). The form posts first_name, last_name, email, message, lang, the honeypot website and altcha, the
|
||||
* answer to a proof-of-work challenge from GET /contact/challenge (ALTCHA, solved in the browser
|
||||
* while the visitor types). The browser asks for JSON and shows the result in place; a post that
|
||||
* asks for HTML is answered with a redirect to /contact/sent or /contact/error (under /de for German).
|
||||
* A post without a solved challenge is refused, so the form needs scripting; the address next
|
||||
* to it does not.
|
||||
*
|
||||
* Alpha sign-up (POST /notify, GET /notify/verify). Double opt-in without a database: the form
|
||||
* posts email, consent=yes, list, lang, website and altcha; the handler emails the address a link
|
||||
* to /notify/verify carrying a signed token (address, language, time of consent, expiry). Opening
|
||||
* the link confirms: the handler checks the signature, sends the confirmed registration to
|
||||
* CONTACT_TO and redirects to /notify/confirmed. The office mailbox is the list.
|
||||
*
|
||||
* Configuration comes from the environment only, see README.md.
|
||||
*/
|
||||
import { createServer } from 'node:http';
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import nodemailer from 'nodemailer';
|
||||
import { createChallenge, randomInt, verifySolution } from 'altcha-lib';
|
||||
import { deriveKey } from 'altcha-lib/algorithms/pbkdf2';
|
||||
import { confirmMail } from './mail/confirm.mjs';
|
||||
|
||||
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);
|
||||
// Confirmation links point here, never at the Host header a request happened to carry.
|
||||
const SITE = (env.SITE_URL || 'https://www.ciphercell.de').replace(/\/$/, '');
|
||||
|
||||
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.
|
||||
}
|
||||
|
||||
/* Challenge secrets. Without them set, each start makes its own; challenges then only outlive a
|
||||
restart if the visitor fetched a new one, which the form does after every send. */
|
||||
const ALTCHA_SECRET = env.ALTCHA_HMAC_SECRET || randomBytes(32).toString('hex');
|
||||
const ALTCHA_KEY_SECRET = env.ALTCHA_HMAC_KEY_SECRET || randomBytes(32).toString('hex');
|
||||
/* Signs alpha confirmation links. Without it set, links sent before a restart stop working. */
|
||||
const NOTIFY_SECRET = env.NOTIFY_SECRET || randomBytes(32).toString('hex');
|
||||
if (!env.NOTIFY_SECRET) console.warn('contact: NOTIFY_SECRET not set; confirmation links will not survive a restart.');
|
||||
|
||||
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 };
|
||||
|
||||
/* Proof of work. About half a second of a laptop's time and one to two of a phone's, spent while
|
||||
the visitor is still typing; a script pays it for every message. The challenge is signed, so
|
||||
the server keeps nothing until a solution comes back. */
|
||||
const POW = { algorithm: 'PBKDF2/SHA-256', cost: 1000, counterMin: 2000, counterMax: 5000 };
|
||||
const CHALLENGE_TTL_MS = 30 * 60 * 1000;
|
||||
// Nobody writes a message in three seconds from first touching the form; a script does.
|
||||
const MIN_FILL_MS = 3000;
|
||||
// Solved challenges, kept until they expire, so one solution sends one message.
|
||||
const spent = new Map();
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [sig, expires] of spent) if (expires < now) spent.delete(sig);
|
||||
}, 60 * 1000).unref();
|
||||
|
||||
function issueChallenge() {
|
||||
const now = Date.now();
|
||||
return createChallenge({
|
||||
algorithm: POW.algorithm,
|
||||
cost: POW.cost,
|
||||
counter: randomInt(POW.counterMin, POW.counterMax),
|
||||
deriveKey,
|
||||
data: { issued: now },
|
||||
expiresAt: new Date(now + CHALLENGE_TTL_MS),
|
||||
hmacSignatureSecret: ALTCHA_SECRET,
|
||||
hmacKeySignatureSecret: ALTCHA_KEY_SECRET,
|
||||
});
|
||||
}
|
||||
|
||||
/** The posted altcha field: base64 JSON of { challenge, solution }. */
|
||||
async function solved(field, now = Date.now()) {
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(Buffer.from(field || '', 'base64').toString('utf8'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const { challenge, solution } = payload || {};
|
||||
if (!challenge?.signature || spent.has(challenge.signature)) return false;
|
||||
let result;
|
||||
try {
|
||||
result = await verifySolution({
|
||||
challenge,
|
||||
solution,
|
||||
deriveKey,
|
||||
hmacSignatureSecret: ALTCHA_SECRET,
|
||||
hmacKeySignatureSecret: ALTCHA_KEY_SECRET,
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!result.verified) return false;
|
||||
// Signed with the challenge, so it is the time the server issued it.
|
||||
const issued = Number(challenge.parameters.data?.issued);
|
||||
if (!(now - issued >= MIN_FILL_MS)) return false;
|
||||
spent.set(challenge.signature, challenge.parameters.expiresAt * 1000);
|
||||
return true;
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
/* Alpha sign-up. */
|
||||
const CONFIRM_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const NOTIFY_LIST = 'cipherverifier-alpha';
|
||||
// The wording the visitor agreed to, recorded with the registration. Same text as
|
||||
// soon.notify.consent in src/i18n/ui.ts; change both together.
|
||||
const CONSENT = {
|
||||
en: 'CipherCell may store my email address to notify me about the CipherVerifier alpha. I can withdraw this at any time.',
|
||||
de: 'CipherCell darf meine E-Mail-Adresse speichern, um mich über die Alpha von CipherVerifier zu benachrichtigen. Ich kann dies jederzeit widerrufen.',
|
||||
};
|
||||
// Confirmed tokens, until they expire, so a second click does not file the address twice.
|
||||
const confirmed = new Map();
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [mac, expires] of confirmed) if (expires < now) confirmed.delete(mac);
|
||||
}, 60 * 60 * 1000).unref();
|
||||
|
||||
const mac = (body) => createHmac('sha256', NOTIFY_SECRET).update(body).digest();
|
||||
function sign(payload) {
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
return `${body}.${mac(body).toString('base64url')}`;
|
||||
}
|
||||
/** The payload of a token this handler signed, or null. */
|
||||
function unsign(token) {
|
||||
const [body, sig] = String(token || '').split('.');
|
||||
if (!body || !sig) return null;
|
||||
const expected = mac(body);
|
||||
const given = Buffer.from(sig, 'base64url');
|
||||
if (given.length !== expected.length || !timingSafeEqual(given, expected)) return null;
|
||||
try {
|
||||
return { sig, ...JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* Answers. A browser with scripting asks for JSON; a plain post is redirected to a page. */
|
||||
const PAGES = {
|
||||
contact: { sent: '/contact/sent', error: '/contact/error' },
|
||||
notify: { sent: '/cipherverifier', error: '/notify/error' },
|
||||
};
|
||||
function reply(req, res, lang, outcome, pages = PAGES.contact) {
|
||||
const status = { sent: 200, invalid: 400, limited: 429, unverified: 403, 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;
|
||||
}
|
||||
redirect(res, lang, outcome === 'sent' ? pages.sent : pages.error);
|
||||
}
|
||||
function redirect(res, lang, path) {
|
||||
res.writeHead(303, { Location: `${lang === 'de' ? '/de' : ''}${path}`, 'Cache-Control': 'no-store' });
|
||||
res.end();
|
||||
}
|
||||
|
||||
async function contact(req, res, form, lang) {
|
||||
const data = validate(form);
|
||||
if (!data) { reply(req, res, lang, 'invalid'); return; }
|
||||
|
||||
// No solved challenge: a script, or a browser without scripting. Checked before the rate
|
||||
// limits, so unsolved posts cannot use up the budget meant for visitors.
|
||||
if (!(await solved(form.get('altcha')))) { reply(req, res, lang, 'unverified'); return; }
|
||||
if (limited(clientIp(req))) { 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');
|
||||
}
|
||||
}
|
||||
|
||||
/** Step one of the sign-up: a confirmation link to the address given. Nothing is kept. */
|
||||
async function notify(req, res, form, lang) {
|
||||
const email = oneLine(form.get('email') || '');
|
||||
const valid = email.length <= MAX.email && EMAIL.test(email)
|
||||
&& form.get('consent') === 'yes' && form.get('list') === NOTIFY_LIST;
|
||||
if (!valid) { reply(req, res, lang, 'invalid', PAGES.notify); return; }
|
||||
|
||||
// The proof of work matters most here: this path sends mail to an address a stranger typed.
|
||||
if (!(await solved(form.get('altcha')))) { reply(req, res, lang, 'unverified', PAGES.notify); return; }
|
||||
if (limited(clientIp(req))) { reply(req, res, lang, 'limited', PAGES.notify); return; }
|
||||
|
||||
const now = Date.now();
|
||||
const token = sign({ email, lang, consented: now, expires: now + CONFIRM_TTL_MS });
|
||||
const link = `${SITE}/notify/verify?t=${token}`;
|
||||
try {
|
||||
await transport.sendMail({
|
||||
from: { name: 'CipherCell', address: FROM },
|
||||
to: email,
|
||||
...confirmMail({ lang, email, link, site: SITE }),
|
||||
});
|
||||
reply(req, res, lang, 'sent', PAGES.notify);
|
||||
} catch (err) {
|
||||
console.error('notify: sending the confirmation failed:', err.message);
|
||||
reply(req, res, lang, 'failed', PAGES.notify);
|
||||
}
|
||||
}
|
||||
|
||||
/** Step two: the link is opened; the confirmed registration goes to the office. */
|
||||
async function verify(req, res, query) {
|
||||
const t = unsign(query.get('t'));
|
||||
const lang = t?.lang === 'de' ? 'de' : 'en';
|
||||
const now = Date.now();
|
||||
if (!t || typeof t.email !== 'string' || !(t.expires > now) || !(t.consented > 0)) {
|
||||
redirect(res, lang, '/notify/error');
|
||||
return;
|
||||
}
|
||||
if (confirmed.has(t.sig)) { redirect(res, lang, '/notify/confirmed'); return; }
|
||||
try {
|
||||
await transport.sendMail({
|
||||
from: { name: 'CipherCell website', address: FROM },
|
||||
to: TO,
|
||||
replyTo: t.email,
|
||||
subject: `Alpha registration: ${t.email}`,
|
||||
text: [
|
||||
`Email: ${t.email}`,
|
||||
`List: ${NOTIFY_LIST}`,
|
||||
`Language: ${lang}`,
|
||||
`Consent given: ${new Date(t.consented).toISOString()}`,
|
||||
`Confirmed: ${new Date(now).toISOString()}`,
|
||||
'',
|
||||
'Consent wording:',
|
||||
CONSENT[lang],
|
||||
'',
|
||||
'-- ',
|
||||
'Confirmed by double opt-in on ciphercell.de. Per the privacy policy, delete this address',
|
||||
'once the alpha notification has been sent, or when the person withdraws consent.',
|
||||
].join('\n'),
|
||||
});
|
||||
confirmed.set(t.sig, t.expires);
|
||||
redirect(res, lang, '/notify/confirmed');
|
||||
} catch (err) {
|
||||
console.error('notify: filing the registration failed:', err.message);
|
||||
redirect(res, lang, '/notify/error');
|
||||
}
|
||||
}
|
||||
|
||||
// nginx sets X-Real-IP from the proxy chain; fall back to the socket for local runs.
|
||||
const clientIp = (req) => String(req.headers['x-real-ip'] || req.socket.remoteAddress || 'unknown');
|
||||
|
||||
const ROUTES = { '/contact': contact, '/notify': notify };
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const [path, search = ''] = (req.url || '').split('?');
|
||||
if (path === '/contact/healthz') { res.writeHead(200).end('ok\n'); return; }
|
||||
if (path === '/contact/challenge' && req.method === 'GET') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify(await issueChallenge()));
|
||||
return;
|
||||
}
|
||||
if (path === '/notify/verify' && req.method === 'GET') {
|
||||
await verify(req, res, new URLSearchParams(search));
|
||||
return;
|
||||
}
|
||||
const route = ROUTES[path];
|
||||
if (req.method !== 'POST' || !route) { 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', path === '/contact' ? PAGES.contact : PAGES.notify); return; }
|
||||
|
||||
await route(req, res, form, lang);
|
||||
});
|
||||
|
||||
server.listen(PORT, HOST, () => console.log(`contact: listening on ${HOST}:${PORT}, delivering to ${TO}`));
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* The alpha sign-up confirmation email, in the site's voice and look: warm paper, warm black,
|
||||
* one neo-grotesque, hairlines, one square button. Email clients ignore most CSS, so the layout
|
||||
* is tables with inline styles, the type falls back from Inter Tight to Helvetica and Arial, and
|
||||
* the logo travels as an inline attachment (cid) rather than a remote image.
|
||||
*
|
||||
* Colours are the tokens from src/styles/tokens.css, written out: email has no custom properties.
|
||||
* The hairline is --line (ink at 14 %) flattened onto paper.
|
||||
*/
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const C = {
|
||||
paper: '#E9E5DF',
|
||||
ink: '#1A1A18',
|
||||
muted: '#57564F',
|
||||
line: '#CCC9C3',
|
||||
};
|
||||
const FONT = "'Inter Tight', Inter, 'Helvetica Neue', Helvetica, Arial, sans-serif";
|
||||
const LOGO_CID = 'logo@ciphercell.de';
|
||||
const LOGO = { path: fileURLToPath(new URL('./logo.png', import.meta.url)), width: 140, height: 32 };
|
||||
|
||||
const COPY = {
|
||||
en: {
|
||||
subject: 'Confirm your CipherVerifier alpha registration',
|
||||
preheader: 'One click confirms your registration for the CipherVerifier alpha.',
|
||||
title: 'Confirm your registration.',
|
||||
body: (email) => `You registered ${email} for the CipherVerifier alpha. Confirm the address, and we will email you once, when the alpha is released. Nothing else.`,
|
||||
button: 'Confirm registration',
|
||||
expiry: 'The link is valid for 7 days. If you did not register, ignore this email. Your address is not stored.',
|
||||
fallback: 'If the button does not work, open this address in your browser:',
|
||||
},
|
||||
de: {
|
||||
subject: 'Bitte bestätigen Sie Ihre Registrierung für die CipherVerifier-Alpha',
|
||||
preheader: 'Ein Klick bestätigt Ihre Registrierung für die Alpha von CipherVerifier.',
|
||||
title: 'Registrierung bestätigen.',
|
||||
body: (email) => `Sie haben ${email} für die Alpha von CipherVerifier registriert. Bestätigen Sie die Adresse, und wir schreiben Ihnen einmal, sobald die Alpha erscheint. Sonst nichts.`,
|
||||
button: 'Registrierung bestätigen',
|
||||
expiry: 'Der Link ist 7 Tage gültig. Wenn Sie sich nicht registriert haben, ignorieren Sie diese E-Mail. Ihre Adresse wird nicht gespeichert.',
|
||||
fallback: 'Falls der Button nicht funktioniert, öffnen Sie diese Adresse im Browser:',
|
||||
},
|
||||
};
|
||||
const ADDRESS = ['CipherCell', 'Innstr. 43, 94032 Passau'];
|
||||
const SITE_LABEL = 'www.ciphercell.de';
|
||||
|
||||
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
||||
|
||||
/** { subject, text, html, attachments } for nodemailer. */
|
||||
export function confirmMail({ lang, email, link, site }) {
|
||||
const t = COPY[lang] ?? COPY.en;
|
||||
const text = [
|
||||
t.title,
|
||||
'',
|
||||
t.body(email),
|
||||
'',
|
||||
`${t.button}:`,
|
||||
link,
|
||||
'',
|
||||
t.expiry,
|
||||
'',
|
||||
'-- ',
|
||||
...ADDRESS,
|
||||
SITE_LABEL,
|
||||
].join('\n');
|
||||
|
||||
const p = (content, { size = 16, color = C.ink, lineHeight = 1.55, extra = '' } = {}) =>
|
||||
`<p style="margin:0;font-family:${FONT};font-size:${size}px;line-height:${lineHeight};color:${color};${extra}">${content}</p>`;
|
||||
const row = (content, padding) => `<tr><td style="padding:${padding};">${content}</td></tr>`;
|
||||
const rule = `<tr><td style="padding:0;border-top:1px solid ${C.line};font-size:0;line-height:0;"> </td></tr>`;
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html lang="${lang}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta name="supported-color-schemes" content="light">
|
||||
<title>${esc(t.subject)}</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:${C.paper};-webkit-text-size-adjust:100%;">
|
||||
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:${C.paper};">${esc(t.preheader)}</div>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:${C.paper};">
|
||||
<tr><td align="left" style="padding:40px 24px 48px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:560px;">
|
||||
${row(`<a href="${esc(site)}" style="text-decoration:none;"><img src="cid:${LOGO_CID}" width="${LOGO.width}" height="${LOGO.height}" alt="CipherCell" style="display:block;border:0;width:${LOGO.width}px;height:${LOGO.height}px;font-family:${FONT};font-size:18px;font-weight:600;color:${C.ink};"></a>`, '0 0 20px')}
|
||||
${rule}
|
||||
${row(p(esc(t.title), { size: 34, lineHeight: 1.08, extra: 'font-weight:500;letter-spacing:-0.02em;' }), '48px 0 20px')}
|
||||
${row(p(esc(t.body(email))), '0 0 32px')}
|
||||
${row(`<table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td bgcolor="${C.ink}" style="background:${C.ink};">
|
||||
<a href="${esc(link)}" style="display:inline-block;padding:15px 26px;font-family:${FONT};font-size:16px;line-height:1;font-weight:500;color:${C.paper};text-decoration:none;">${esc(t.button)}</a>
|
||||
</td></tr></table>`, '0 0 32px')}
|
||||
${row(p(esc(t.expiry), { size: 14, color: C.muted, lineHeight: 1.4 }), '0 0 16px')}
|
||||
${row(p(`${esc(t.fallback)}<br><a href="${esc(link)}" style="color:${C.muted};text-decoration:underline;word-break:break-all;">${esc(link)}</a>`, { size: 14, color: C.muted, lineHeight: 1.4 }), '0 0 48px')}
|
||||
${rule}
|
||||
${row(p(`${ADDRESS.map(esc).join('<br>')}<br><a href="${esc(site)}" style="color:${C.muted};text-decoration:underline;">${SITE_LABEL}</a>`, { size: 14, color: C.muted, lineHeight: 1.4 }), '20px 0 0')}
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return {
|
||||
subject: t.subject,
|
||||
text,
|
||||
html,
|
||||
attachments: [{ filename: 'ciphercell.png', path: LOGO.path, cid: LOGO_CID }],
|
||||
};
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Generated
+95
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"name": "ciphercell-contact",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ciphercell-contact",
|
||||
"dependencies": {
|
||||
"altcha-lib": "^2.6.0",
|
||||
"nodemailer": "^7.0.13"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/altcha-lib": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/altcha-lib/-/altcha-lib-2.6.0.tgz",
|
||||
"integrity": "sha512-mBp/KxMZmQkckWX5iM0gHqCm0qTLGhF9r8hhFjNrBpzP0qHZBadR9oafxVx9cTLHl4h2+f56N4hRIrZzvonYLQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"altcha-lib": "bin/cli.mjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@fastify/cookie": "^11.0.0",
|
||||
"@fastify/cors": "^11.0.0",
|
||||
"@fastify/formbody": "^8.0.0 || ^9.0.0",
|
||||
"@nestjs/common": "^10.0.0 || ^11.0.0 || ^12.0.0",
|
||||
"@nestjs/core": "^10.0.0 || ^11.0.0 || ^12.0.0",
|
||||
"@nestjs/platform-express": "^10.0.0 || ^11.0.0 || ^12.0.0",
|
||||
"@nestjs/platform-fastify": "^10.0.0 || ^11.0.0 || ^12.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"cookie-parser": "^1.0.0",
|
||||
"cors": "^2.0.0",
|
||||
"express": "^4.0.0 || ^5.0.0",
|
||||
"fastify": "^5.0.0",
|
||||
"h3": "^2.0.0",
|
||||
"hono": "^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@fastify/cookie": {
|
||||
"optional": true
|
||||
},
|
||||
"@fastify/cors": {
|
||||
"optional": true
|
||||
},
|
||||
"@fastify/formbody": {
|
||||
"optional": true
|
||||
},
|
||||
"@nestjs/common": {
|
||||
"optional": true
|
||||
},
|
||||
"@nestjs/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@nestjs/platform-express": {
|
||||
"optional": true
|
||||
},
|
||||
"@nestjs/platform-fastify": {
|
||||
"optional": true
|
||||
},
|
||||
"@sveltejs/kit": {
|
||||
"optional": true
|
||||
},
|
||||
"cookie-parser": {
|
||||
"optional": true
|
||||
},
|
||||
"cors": {
|
||||
"optional": true
|
||||
},
|
||||
"express": {
|
||||
"optional": true
|
||||
},
|
||||
"fastify": {
|
||||
"optional": true
|
||||
},
|
||||
"h3": {
|
||||
"optional": true
|
||||
},
|
||||
"hono": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"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": {
|
||||
"altcha-lib": "^2.6.0",
|
||||
"nodemailer": "^7.0.13"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user