This commit is contained in:
Felix Klement committed 2026-10-06 11:33:00 +02:00
1 parent 2b7ac42d6b
commit 76cedf8400
7 files changed
+41 -60

No files matched your search

+15 -12
View File
@@ -9,12 +9,11 @@
* 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, POST /notify/verify). Double opt-in without a database: the form
* 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/confirm carrying a signed token (address, language, time of consent, expiry). That
* page posts the token back to /notify/verify, which checks the signature and sends the confirmed
* registration to CONTACT_TO. The office mailbox is the list. The confirmation is a button, not
* the link itself, because mail scanners open every link in a message.
* 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.
*/
@@ -268,7 +267,7 @@ async function notify(req, res, form, lang) {
const now = Date.now();
const token = sign({ email, lang, consented: now, expires: now + CONFIRM_TTL_MS });
const link = `${SITE}${lang === 'de' ? '/de' : ''}/notify/confirm?t=${token}`;
const link = `${SITE}/notify/verify?t=${token}`;
try {
await transport.sendMail({
from: { name: 'CipherCell', address: FROM },
@@ -282,10 +281,10 @@ async function notify(req, res, form, lang) {
}
}
/** Step two: the link's token comes back; the confirmed registration goes to the office. */
async function verify(req, res, form) {
const t = unsign(form.get('t'));
const lang = t?.lang === 'de' ? 'de' : form.get('lang') === 'de' ? 'de' : 'en';
/** 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');
@@ -324,16 +323,20 @@ async function verify(req, res, form) {
// 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, '/notify/verify': verify };
const ROUTES = { '/contact': contact, '/notify': notify };
const server = createServer(async (req, res) => {
const path = (req.url || '').split('?')[0];
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; }