import { tageBis } from "@/lib/format"; /** * Regeln der Benachrichtigungen (#028), rein und ohne Datenbank – damit Stufen, * Fingerabdruecke und Wiederholungen als Unit-Test festgehalten werden koennen. */ /** Die fuenf Anlaesse; je Anlass in den Einstellungen ein- und ausschaltbar. */ export const ANLAESSE = ["drift", "ablauf", "abgelaufen", "unerreichbar", "hostkey"] as const; export type AnlassArt = (typeof ANLAESSE)[number]; export const ANLASS_TEXT: Readonly> = { drift: { titel: "Neue Abweichungen (Drift)", erklaerung: "Nach dem Abgleich neu gefundene Befunde, je Host zusammengefasst.", }, ablauf: { titel: "Ablaufende Schlüssel", erklaerung: "30, 14 und 7 Tage vor dem Ablauf – je Stufe genau eine Meldung.", }, abgelaufen: { titel: "Abgelaufene, noch ausgerollte Schlüssel", erklaerung: "Abgelaufen, aber noch auf mindestens einem Host hinterlegt.", }, unerreichbar: { titel: "Host nicht erreichbar", erklaerung: "Einmal je Ausfall, bis wieder ein Abgleich gelingt.", }, hostkey: { titel: "Geänderter Host-Key", erklaerung: "Dringend: wird sofort gemeldet, nicht erst in der Zusammenfassung.", }, }; /** Ablaufstufen in Tagen, absteigend. */ export const ABLAUF_STUFEN = [30, 14, 7] as const; export type AblaufStufe = (typeof ABLAUF_STUFEN)[number]; /** * Die Stufe, in der ein Schluessel gerade steht: die kleinste Stufe, deren Frist schon * erreicht ist. Wer erst mit 10 Tagen Restlaufzeit angelegt wird, bekommt also die * 14-Tage-Meldung und spaeter die 7-Tage-Meldung, aber nie nachtraeglich die 30-Tage-Meldung. * Gezaehlt wird in Kalendertagen (Europe/Berlin) wie ueberall in der Oberflaeche. */ export function ablaufStufe(ablaufAm: Date, jetzt: Date = new Date()): AblaufStufe | null { if (ablaufAm.getTime() <= jetzt.getTime()) return null; const tage = tageBis(ablaufAm, jetzt); for (const stufe of [...ABLAUF_STUFEN].reverse()) { if (tage <= stufe) return stufe; } return null; } function tag(wert: Date): string { return wert.toISOString().slice(0, 10); } /** * Fingerabdruecke der Anlaesse – Schluessel des Versandprotokolls. Was hier gleich ist, wird * nur einmal gemeldet. Beim Ablauf steht das Ablaufdatum mit drin: wird ein Schluessel * verlaengert, beginnen die Stufen fuer das neue Datum von vorn. */ export const fingerabdruck = { drift: (abweichungId: string) => `drift:${abweichungId}`, hostkey: (abweichungId: string) => `hostkey:${abweichungId}`, ablauf: (schluesselId: string, stufe: AblaufStufe, ablaufAm: Date) => `ablauf:${schluesselId}:${stufe}:${tag(ablaufAm)}`, abgelaufen: (schluesselId: string, ablaufAm: Date) => `abgelaufen:${schluesselId}:${tag(ablaufAm)}`, // `letzterAbgleichAm` aendert sich nur bei einem gelungenen Abgleich – so entsteht genau eine // Meldung je Ausfall, und nach der Erholung kann der naechste Ausfall wieder gemeldet werden. unerreichbar: (hostId: string, letzterAbgleichAm: Date | null) => `unerreichbar:${hostId}:${letzterAbgleichAm ? letzterAbgleichAm.toISOString() : "nie"}`, test: (sendung: string) => `test:${sendung}`, } as const; // ---------------------------------------------------------------- Wiederholungen /** Nach so vielen Fehlversuchen gibt sshkv eine Zustellung auf. */ export const MAX_VERSUCHE = 5; /** 5, 10, 20, 40 Minuten – kurz genug fuer einen kurzen Ausfall des Mailservers. */ export function naechsterVersuch(versuche: number, jetzt: Date = new Date()): Date | null { if (versuche >= MAX_VERSUCHE) return null; return new Date(jetzt.getTime() + 5 * 60_000 * 2 ** (versuche - 1)); } export type ProtokollStand = { readonly ergebnis: "GESENDET" | "FEHLGESCHLAGEN"; readonly versuche: number; readonly naechsterVersuchAm: Date | null; }; /** * Darf ein Anlass ueber einen Kanal (erneut) gemeldet werden? * - noch nie versucht: ja, ausser es laeuft nur eine Wiederholungsrunde; * - einmal erfolgreich: nie wieder; * - fehlgeschlagen: wenn die Wartezeit um ist und noch Versuche uebrig sind. */ export function anlassFaellig( stand: ProtokollStand | undefined, jetzt: Date = new Date(), nurWiederholungen = false, ): boolean { if (!stand) return !nurWiederholungen; if (stand.ergebnis === "GESENDET") return false; if (stand.versuche >= MAX_VERSUCHE || !stand.naechsterVersuchAm) return false; return stand.naechsterVersuchAm.getTime() <= jetzt.getTime(); } // ---------------------------------------------------------------- Webhook-Ziel /** * Nur https, ausser `http` ist ausdruecklich erlaubt (internes Ziel). Liefert eine Meldung * fuer das Formular oder null. */ export function webhookUrlPruefen(url: string, httpErlaubt: boolean): string | null { let ziel: URL; try { ziel = new URL(url); } catch { return "Keine gültige Adresse."; } if (ziel.username || ziel.password) { return "Zugangsdaten gehören nicht in die Adresse – bitte das Geheimnis-Feld nutzen."; } if (ziel.protocol === "https:") return null; if (ziel.protocol === "http:") { return httpErlaubt ? null : "Nur https:// – http:// nur mit dem Häkchen für interne Ziele."; } return "Nur https:// (oder ausdrücklich erlaubtes http://)."; } /** Fuer Protokoll und Versandliste: nur Schema und Host, nie Pfad oder Query (Tokens). */ export function webhookZielKurz(url: string): string { try { const ziel = new URL(url); return `${ziel.protocol}//${ziel.host}`; } catch { return "(ungültige Adresse)"; } } // ---------------------------------------------------------------- Zeitplaene /** * Lesbare Fassung der gaengigen cron-Formen fuer die Einstellungsseite; bei allem anderen * null – dann zeigt die Seite nur den Ausdruck. */ export function cronBeschreiben(ausdruck: string): string | null { const teile = ausdruck.trim().split(/\s+/); if (teile.length !== 5) return null; const [minute, stunde, tagImMonat, monat, wochentag] = teile as [string, string, string, string, string]; const zahl = (wert: string, max: number) => /^\d{1,2}$/.test(wert) && Number(wert) <= max; const intervall = /^\*\/(\d{1,2})$/.exec(minute); if (intervall && stunde === "*" && tagImMonat === "*" && monat === "*" && wochentag === "*") { return `alle ${Number(intervall[1])} Minuten`; } if (!zahl(minute, 59) || !zahl(stunde, 23) || tagImMonat !== "*" || monat !== "*") return null; const uhr = `${stunde.padStart(2, "0")}:${minute.padStart(2, "0")} Uhr`; if (wochentag === "*") return `täglich um ${uhr}`; if (wochentag === "1-5") return `werktags um ${uhr}`; return null; }