Een dating-app op basis van brieven, niet van gezichten!
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
import { Hono } from "hono";
import { hashPassword, hashToken } from "./auth.js";
import { rateLimiter, clientIp, inWords } from "./ratelimit.js";
import { page, esc } from "./huisstijl.js";
import { sendMail } from "./mail.js";
import { brief, letterlijk } from "./mail-html.js";
import { PASSWORD_MIN, PASSWORD_MAX, GENERAL_CONTACT, PUBLIC_URL } from "../src/constants.js";
import * as db from "./queries.js";
/*
* Wachtwoord vergeten. De app kent alleen het begin (een e-mailadres opgeven, POST /api/auth/forgot
* in server/index.js); de rest gebeurt hier, op de webserver: de mail bevat een link naar
* /wachtwoord en dat is een gewone pagina in de huisstijl van de site — geen kale API-tekst,
* want dit is het enige stuk van de app dat iemand in zijn browser te zien krijgt.
*
* De link draagt een token van 256 bits; in de database staat alleen de sha256 ervan, één per
* persoon, een uur geldig en eenmalig (db.resetPasswordByToken verzilvert en zet in één statement).
* Wie de link opent nadat hij verlopen of gebruikt is, krijgt dat gewoon te lezen, met de weg
* terug erbij: vraag in de app een nieuwe aan.
*/
export const RESET_TTL_MS = 60 * 60_000; // een uur — ruim voor wie zijn mail niet naast de app heeft
const FROM = `Liefdesbrieven <${GENERAL_CONTACT}>`;
/*
* De basis van de link, en die staat vast — hij komt niet uit het verzoek.
*
* Hier stond eerder de Host-header (met X-Forwarded-Host ervóór). Dat is een adres dat de bezoeker
* zelf opschrijft, en deze functie bouwt er de herstel-link mee die naar iemand ánders gemaild
* wordt. Wie `POST /api/auth/forgot` deed met het adres van een vreemde en een eigen Host-header,
* liet ons dus een geldig herstel-token naar dat slachtoffer sturen — in een mail die klopt, van het
* juiste afzenderadres, met een link naar de site van de aanvaller. Eén klik is dan het account.
*
* Waar de app staat is geen vondst van het moment maar een gegeven (PUBLIC_URL in
* src/constants.js, hetzelfde domein als de adressen daar). PUBLIC_BASE_URL in de omgeving wint,
* voor een andere installatie of om de herstelmail lokaal een keer helemaal te doorlopen.
*/
export const linkBase = () => (process.env.PUBLIC_BASE_URL || PUBLIC_URL).trim().replace(/\/+$/, "");
/*
* De brief. Tekst is de hoofdzaak (post van een mens); de HTML-versie staat op hetzelfde briefpapier
* als de rest van de post (server/mail-html.js) en bestaat vooral om de link achter woorden te
* zetten in plaats van een kale URL — precies waarvoor hij er is (zie server/mail.js).
*
* Twee versies, met opzet los van elkaar: in de tekstversie hóórt de URL voluit te staan, in de
* HTML-versie hoort hij achter woorden. Een naam is niet van ons, dus die gaat langs `letterlijk`:
* wie zich "[klik](…)" noemt krijgt geen link in zijn eigen brief.
*/
export async function stuurHerstelMail({ to, name, link }) {
const text = `Beste ${name},
Je gaf aan dat je je wachtwoord kwijt bent. Dat geeft niets — zet hier een nieuw:
${link}
De link werkt een uur, en één keer. Vroeg jij dit niet aan, gooi deze brief dan weg; je wachtwoord blijft gewoon staan.
Hartelijke groet,
Liefdesbrieven`;
const html = brief(`Beste ${letterlijk(name)},
Je gaf aan dat je je wachtwoord kwijt bent. Dat geeft niets — [zet hier een nieuw](${link}).
De link werkt een uur, en één keer. Vroeg jij dit niet aan, gooi deze brief dan weg; je wachtwoord blijft gewoon staan.
Hartelijke groet,
Liefdesbrieven`);
await sendMail({ from: FROM, to, subject: "Een nieuw wachtwoord", text, html, stream: "outbound" });
}
/* ---------- de pagina ---------- */
const knopStijl = `display:block;width:100%;padding:14px 0;font:inherit;font-size:16px;font-weight:700;border:none;background:var(--knop-alarm);color:var(--knop-ink);cursor:pointer`;
const veldStijl = `display:block;width:100%;padding:10px 12px;font:inherit;font-size:15px;border:2px solid var(--ink);background:var(--paper);color:var(--ink);margin:6px 0 16px`;
const formHtml = (token, fout = null) =>
page(
"Een nieuw wachtwoord",
`<h1>Een nieuw wachtwoord</h1>
<p class="mute">Voor je account bij Liefdesbrieven. Vul hieronder een nieuw wachtwoord in; daarna log je
ermee in in de app.</p>
${fout ? `<p role="alert" class="mono" style="color:var(--alert)">${esc(fout)}</p>` : ""}
<form method="post" action="/wachtwoord">
<input type="hidden" name="token" value="${esc(token)}">
<label style="font-weight:700;font-size:14px">Nieuw wachtwoord
<input type="password" name="password" minlength="${PASSWORD_MIN}" maxlength="${PASSWORD_MAX}" required autocomplete="new-password"
placeholder="minimaal ${PASSWORD_MIN} tekens" autofocus style="${veldStijl}">
</label>
<button type="submit" style="${knopStijl}">Wachtwoord instellen</button>
</form>`
);
const wegHtml = () =>
page(
"Deze link is verlopen",
`<h1>Deze link is verlopen</h1>
<p>Een herstel-link werkt een uur, en één keer — deze is op. Dat geeft niets: open de app, kies op het
inlogscherm <strong>Wachtwoord vergeten?</strong> en er ligt zo een nieuwe link in je mail.</p>
<p class="mute mono" style="margin-top:32px"><a href="/">← Liefdesbrieven</a></p>`
);
const klaarHtml = (name) =>
page(
"Je wachtwoord is vernieuwd",
`<h1>Je wachtwoord is vernieuwd</h1>
<p>Klaar, ${esc(name)}. Ga terug naar de app en log in met je nieuwe wachtwoord. Waar je nog ingelogd
stond ben je uitgelogd — zo weet je zeker dat alleen jij er nog in kunt.</p>
<p class="mute mono" style="margin-top:32px"><a href="/">← Liefdesbrieven</a></p>`
);
/* Brute force op tokens afremmen; de tokens zelf zijn onraadbaar, dit houdt het ook onbeleefd verkeer af */
const resetByIp = rateLimiter({ windowMs: 15 * 60_000, max: 20 });
const wachtwoord = new Hono();
wachtwoord.get("/", async (c) => {
const token = c.req.query("token") ?? "";
if (!token) return c.html(wegHtml(), 404);
return (await db.peekPasswordReset(hashToken(token))) ? c.html(formHtml(token)) : c.html(wegHtml(), 404);
});
wachtwoord.post("/", async (c) => {
const lim = resetByIp.hit(`ip:${clientIp(c)}`);
if (!lim.ok) return c.html(page("Even wachten", `<h1>Even wachten</h1><p>Te veel pogingen — probeer het ${esc(inWords(lim.retryAfterSec))} opnieuw.</p>`), 429);
const body = await c.req.parseBody();
const token = typeof body.token === "string" ? body.token : "";
const password = typeof body.password === "string" ? body.password : "";
if (!token) return c.html(wegHtml(), 404);
if (password.length < PASSWORD_MIN)
return c.html(formHtml(token, `Een wachtwoord is minimaal ${PASSWORD_MIN} tekens.`), 400);
/* En een dak, want hieronder gaat het door scrypt — zie PASSWORD_MAX in src/constants.js */
if (password.length > PASSWORD_MAX)
return c.html(formHtml(token, `Een wachtwoord is maximaal ${PASSWORD_MAX} tekens.`), 400);
const wie = await db.resetPasswordByToken(hashToken(token), await hashPassword(password));
if (!wie) return c.html(wegHtml(), 404);
return c.html(klaarHtml(wie.name));
});
export default wachtwoord;