/* ===================================================================== kontingent.js — eine Anfragebremse pro IP, ohne Fremdpaket WOZU Öffentliche Routen, die in die Datenbank schreiben oder Dateien annehmen, brauchen eine Obergrenze pro Absender. Sonst füllt ein einzelnes Skript die Datenbank mit Müll oder die Platte mit Bildern. Es gab diese Bremse schon — aber nur an einer Stelle (der Bild-Upload in routes/testimonials.js) und dort fest verdrahtet. Zwei weitere öffentliche Schreibrouten (/submit, /testimonials/submit) hatten keine. Statt das Muster ein drittes Mal zu kopieren, steht es nun einmal hier und wird überall gleich benutzt. ZWEI VERBESSERUNGEN GEGENÜBER DEM ORIGINAL 1. Getrennte Töpfe je Zweck: Der Bild-Upload und die Bewerbung teilen sich NICHT dasselbe Kontingent. Wer ein Bild hochlädt, verbraucht damit keinen seiner Bewerbungsversuche. 2. Die Zählung räumt sich selbst auf. Das Original ließ jede IP, die je eine Anfrage geschickt hatte, für immer im Speicher stehen — ein langsames Leck. Hier werden abgelaufene Einträge bei Gelegenheit entfernt. BEWUSSTE GRENZE Der Zähler liegt im Arbeitsspeicher, nicht in der Datenbank. Ein Neustart setzt ihn zurück, und bei mehreren Prozessen zählte jeder für sich. Beides ist hier unkritisch: Es gibt genau einen Prozess, und ein Neustart, der das Kontingent leert, schadet niemandem — im Gegenteil, ein zu Unrecht Gebremster käme dann wieder durch. Für eine echte, prozessübergreifende Bremse wäre die Datenbank der Ort; der Aufwand lohnt hier (noch) nicht. HINTER CADDY Die IP kommt aus req.ip. Das ist nur dann die ECHTE Besucher-IP, wenn express dem Proxy vertraut — app.set("trust proxy", 1) ist in index.js gesetzt (mit Begründung in lib/auth.js). Ohne das wäre req.ip für alle Besucher 127.0.0.1 und die Bremse träfe alle gemeinsam. ===================================================================== */ const STUNDE = 60 * 60 * 1000; /* Ein Topf je Zweck. Schlüssel ist der Zweckname, Wert eine Map von IP -> { anzahl, fensterStart }. */ const toepfe = new Map(); /* Aufräumen ist beiläufig, nicht auf die Uhr getaktet: Bei jedem N-ten Aufruf wird der eigene Topf einmal von abgelaufenen Einträgen befreit. Das kostet nichts extra (kein Timer, der auch im Leerlauf läuft) und hält den Speicher trotzdem klein. */ let aufrufeSeitReinigung = 0; const REINIGEN_ALLE = 500; function reinige(topf, jetzt, fensterMs) { for (const [ip, e] of topf) { if (jetzt - e.fensterStart > fensterMs) topf.delete(ip); } } /** * Fragt die Bremse und zählt den Versuch zugleich. * * @param {string} zweck getrennter Topf, z.B. "bewerbung", "stimme" * @param {string} ip req.ip des Aufrufers * @param {object} [opt] * @param {number} [opt.maxProFenster=20] erlaubte Anfragen je Fenster * @param {number} [opt.fensterMs=1h] Länge des Fensters * @returns {boolean} true = erlaubt (und gezählt), false = Grenze erreicht */ export function kontingentFrei(zweck, ip, opt = {}) { const maxProFenster = opt.maxProFenster ?? 20; const fensterMs = opt.fensterMs ?? STUNDE; const schluessel = ip || "unbekannt"; let topf = toepfe.get(zweck); if (!topf) { topf = new Map(); toepfe.set(zweck, topf); } const jetzt = Date.now(); if (++aufrufeSeitReinigung >= REINIGEN_ALLE) { aufrufeSeitReinigung = 0; reinige(topf, jetzt, fensterMs); } const eintrag = topf.get(schluessel); if (!eintrag || jetzt - eintrag.fensterStart > fensterMs) { /* Neues Fenster: erster Versuch zählt sofort. */ topf.set(schluessel, { anzahl: 1, fensterStart: jetzt }); return true; } if (eintrag.anzahl >= maxProFenster) return false; eintrag.anzahl += 1; return true; } /* Nur für Tests: alle Töpfe leeren, damit ein Testfall nicht den nächsten beeinflusst. */ export function _zuruecksetzen() { toepfe.clear(); aufrufeSeitReinigung = 0; }