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>
74 lines
2.4 KiB
TypeScript
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.
|
|
}
|
|
}
|