Files
FireAdministration/src/lib/ratelimit.ts
T
Florian WildeandClaude Opus 5 91954c402b Phase 3: Einsatz- und Uebungserfassung
Schnellerfassung im Alarmfall, sechs Zeitstempel mit Ausrueckzeit, Anfahrt
und Hilfsfrist, Staerke nach FwDV 3, Personalvorschlag mit
Qualifikationsstand, Einsatzbericht als PDF, Jahresexport fuer die Gemeinde,
Statistik und Uebungen mit Anwesenheitsliste.

Dazu das Einsatzportal fuer Fremdwehren: ein signierter Link ohne Anmeldung,
zeitlich und mengenbegrenzt, der Stichwort, Ort, Zeiten und Lagemeldungen
zeigt und Meldungen mit Foto annimmt. Es sind die ersten oeffentlich
erreichbaren Endpunkte in FLORIAN.

Acht neue Tabellen, alle mit organisationId und Row Level Security. Ein
freigegebener Einsatzbericht ist unveraenderlich; ein Datenbanktrigger setzt
das durch. Koordinaten liegen doppelt — als Dezimalgrad und als
PostGIS-Punkt, zusammengehalten von einem Trigger.

57 neue Tests (32 Unit, 14 gegen eine echte Datenbank, 11 E2E).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 13:50:58 +02:00

74 lines
2.4 KiB
TypeScript

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:<zugangId>`
* @param hoechstzahl erlaubte Zugriffe je Fenster
* @param fensterSekunden Länge des Fensters
*/
export async function zugriffZaehlen(
schluessel: string,
hoechstzahl: number,
fensterSekunden = 3600,
): Promise<Begrenzung> {
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<void> {
try {
await redis().del(`ratelimit:${schluessel}`);
} catch {
// Ein nicht gelöschter Zähler läuft von selbst ab.
}
}