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.
/*
* Eenvoudige rate-limiter in het geheugen (schuivend venster): per sleutel maximaal `max`
* treffers per `windowMs`. Genoeg voor één serverproces; bij meerdere processen zou dit in
* Redis of Postgres moeten. Doel: brute force op inloggen/registreren/wachtwoordbevestiging
* afremmen (AVG art. 32 — passende beveiliging).
*
* const login = rateLimiter({ windowMs: 15 * 60_000, max: 10 });
* const { ok, retryAfterSec } = login.hit(`ip:${ip}`);
*/
export function rateLimiter({ windowMs, max }) {
const hits = new Map(); // key → [timestamps]
const prune = (arr, now) => {
while (arr.length && arr[0] <= now - windowMs) arr.shift();
return arr;
};
/* Registreert een poging en zegt of die nog mag */
const hit = (key) => {
const now = Date.now();
const arr = prune(hits.get(key) ?? [], now);
if (arr.length >= max) {
hits.set(key, arr);
return { ok: false, retryAfterSec: Math.ceil((arr[0] + windowMs - now) / 1000) };
}
arr.push(now);
hits.set(key, arr);
return { ok: true, retryAfterSec: 0 };
};
/* Bij succes (goed wachtwoord) de teller van die sleutel wissen, zodat één vergissing niet blijft hangen */
const reset = (key) => hits.delete(key);
/* Af en toe lege sleutels opruimen, anders groeit de map met elk ooit gezien adres */
const sweep = () => {
const now = Date.now();
for (const [key, arr] of hits) if (!prune(arr, now).length) hits.delete(key);
};
const timer = setInterval(sweep, windowMs);
timer.unref?.();
return { hit, reset };
}
/*
* Het adres van de client, en dit is de gevoeligste regel van dit bestand: hierop hangt elke rem
* die per adres telt (inloggen, registreren, herstel-links, en het slot op /promotieteam).
*
* Hier stond eerder het eerste element van X-Forwarded-For, met de toelichting dat nginx het echte
* adres daar zet. Dat is precies andersom. `$proxy_add_x_forwarded_for` is
* "$http_x_forwarded_for, $remote_addr" — nginx plákt het echte adres achteraan en laat staan wat de
* client zelf meestuurde. Het eerste element is dus door de bezoeker zelf gekozen, en daarmee was
* elke teller met één header leeg te houden: onbeperkt wachtwoorden raden, onbeperkt gratis plekken
* claimen, onbeperkt herstelmail laten versturen.
*
* Nu drie regels, in deze volgorde:
*
* 1. Vertrouwen we de headers? Alleen als het verzoek van onze eigen machine of ons eigen netwerk
* binnenkomt — dat is nginx die proxyt. Komt het rechtstreeks van buiten, dan is er geen proxy
* en is elke Forwarded-header verzonnen; dan telt het socket-adres. Zo is er niets in te stellen
* en staat het toch goed: op de server via nginx (loopback) wél, op een blootgestelde poort niet.
* TRUST_PROXY=1 of 0 in de omgeving overrulet die afweging als een opstelling anders in elkaar zit.
* 2. X-Real-IP als die er staat — daar zet nginx `$remote_addr` in en dus niets van de client.
* Zie de README bij Productie; die regel hoort in de site-configuratie.
* 3. Anders het láátste element van X-Forwarded-For: wat onze eigen proxy erbij schreef.
*/
const kaal = (ip) => String(ip ?? "").trim().replace(/^::ffff:/i, "");
/* Loopback, de private reeksen (RFC 1918), link-local en unique-local IPv6 — "onze eigen kant" */
const EIGEN_NET = /^(?:127\.|10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.|169\.254\.|::1$|f[cd])/i;
const TRUST = (process.env.TRUST_PROXY ?? "").trim().toLowerCase();
const PROXY_ALTIJD = ["1", "true", "ja"].includes(TRUST);
const PROXY_NOOIT = ["0", "false", "nee"].includes(TRUST);
/*
* De keerzijde van "zonder proxy-header telt het socket-adres": staat de app wél achter nginx maar
* zet die geen enkele header, dan komt iedereen op hetzelfde loopback-adres uit en delen alle
* bezoekers samen één emmer — dan is één iemand die zich vertypt genoeg om de hele app op slot te
* zetten. Dat is stil (het lijkt gewoon te werken) en daarom zegt hij het, één keer, met de regel
* die het oplost erbij.
*/
let gewaarschuwd = false;
const eenEmmerVoorIedereen = () => {
if (gewaarschuwd) return;
gewaarschuwd = true;
console.warn(
"rate-limiting: elk verzoek komt binnen op een adres van onszelf en er is geen X-Real-IP of " +
"X-Forwarded-For. Alle bezoekers delen nu één teller. Staat er nginx voor, zet dan " +
"`proxy_set_header X-Real-IP $remote_addr;` in het location-block (zie README, Productie)."
);
};
export function clientIp(c) {
const socket = kaal(c.env?.incoming?.socket?.remoteAddress);
const achterProxy = PROXY_ALTIJD || (!PROXY_NOOIT && (!socket || EIGEN_NET.test(socket)));
if (!achterProxy) return socket || "onbekend";
const real = kaal(c.req.header("x-real-ip"));
if (real) return real;
const xff = (c.req.header("x-forwarded-for") ?? "").split(",").map(kaal).filter(Boolean);
if (xff.length) return xff.at(-1);
eenEmmerVoorIedereen();
return socket || "onbekend";
}
/*
* "over 20 minuten" / "over 3 uur" — een wachttijd zoals je hem tegen iemand zegt. Boven het uur
* naar boven afgerond: te vroeg terugkomen en er weer naast grijpen is vervelender dan wachten.
*/
export const inWords = (sec) => {
const min = Math.max(1, Math.ceil(sec / 60));
if (min < 60) return `over ${min} ${min === 1 ? "minuut" : "minuten"}`;
const uur = Math.ceil(min / 60);
return `over ${uur} ${uur === 1 ? "uur" : "uur"}`;
};
/*
* Antwoord voor een geblokkeerde poging: 429 met Retry-After. De standaardtekst gaat over pogingen
* (inloggen, registreren, bevestigen); wie iets begrenst dat géén poging is — een brief, een
* zoekopdracht — geeft zijn eigen zin mee.
*/
export const tooMany = (c, retryAfterSec, message = null) => {
c.header("Retry-After", String(Math.max(1, Math.ceil(retryAfterSec))));
return c.json({ error: message ?? `Te veel pogingen — probeer het ${inWords(retryAfterSec)} opnieuw` }, 429);
};