import "server-only"; import { redis } from "@/lib/warteschlange"; /** * Zugriffsbegrenzung über Redis. * * Gebraucht für alles, was ohne Anmeldung erreichbar ist — bisher das * Einsatzportal für Fremdwehren. Ein Link, der aus Versehen in einer * WhatsApp-Gruppe landet, soll die Anwendung nicht in die Knie zwingen. * * Verfahren: ein Zähler je Schlüssel und Zeitfenster. INCR legt den Schlüssel * an, wenn es ihn nicht gibt; beim ersten Zugriff bekommt er eine Lebensdauer. * Das ist ein festes Fenster und keine gleitende Rate — ungenau an der * Fenstergrenze, dafür in zwei Redis-Befehlen erledigt und ohne Zustand in der * Anwendung. * * Fällt Redis aus, wird durchgelassen. Eine Zugriffsbegrenzung, die bei * Störung den Betrieb anhält, richtet mehr Schaden an als sie verhindert — * die eigentliche Zugangsprüfung sitzt ohnehin woanders. */ export interface Begrenzung { /** Wurde der Zugriff durchgelassen? */ erlaubt: boolean; /** Wie viele Zugriffe im laufenden Fenster noch übrig sind. */ uebrig: number; /** Sekunden bis zum Zurücksetzen des Fensters. */ zuruecksetztIn: number; } /** * Zählt einen Zugriff und sagt, ob er noch erlaubt ist. * * @param schluessel fachliche Kennung, z. B. `portal:` * @param hoechstzahl erlaubte Zugriffe je Fenster * @param fensterSekunden Länge des Fensters */ export async function zugriffZaehlen( schluessel: string, hoechstzahl: number, fensterSekunden = 3600, ): Promise { const redisSchluessel = `ratelimit:${schluessel}`; try { const verbindung = redis(); const anzahl = await verbindung.incr(redisSchluessel); if (anzahl === 1) { await verbindung.expire(redisSchluessel, fensterSekunden); } const rest = await verbindung.ttl(redisSchluessel); return { erlaubt: anzahl <= hoechstzahl, uebrig: Math.max(0, hoechstzahl - anzahl), zuruecksetztIn: rest > 0 ? rest : fensterSekunden, }; } catch (fehler) { console.error(`[ratelimit] Redis nicht erreichbar (${schluessel}):`, fehler); return { erlaubt: true, uebrig: hoechstzahl, zuruecksetztIn: fensterSekunden }; } } /** Setzt den Zähler zurück — für Tests und für den Widerruf eines Zugangs. */ export async function zaehlerZuruecksetzen(schluessel: string): Promise { try { await redis().del(`ratelimit:${schluessel}`); } catch { // Ein nicht gelöschter Zähler läuft von selbst ab. } }