Drift, ablaufende und abgelaufene Schluessel, unerreichbare Hosts und geaenderte Host-Keys sollen auffallen, ohne dass jemand taeglich in die Oberflaeche schaut. Versand per SMTP (Microsoft 365, STARTTLS) und per Webhook mit HMAC-Signatur, eingestellt unter /einstellungen. Die Anlaesse liest der Worker aus der Datenbank, statt sie im Abgleich auszuloesen: der Abgleich-Ablauf bleibt unberuehrt, und ein verpasster Lauf holt sich der naechste. Ein Versandprotokoll (Zustellung) mit Anlass-Fingerabdruck verhindert Doppelmeldungen und haelt Fehlschlaege fuer begrenzte Wiederholungen fest. Taeglich eine Zusammenfassung, geaenderte Host-Keys im Fuenf-Minuten-Takt sofort. Versand und Testnachricht laufen im Worker, damit der Webprozess keinen Weg zu Mailserver oder Webhook-Ziel braucht. Passwort und Geheimnis liegen nur verschluesselt in der Datenbank; die Seite zeigt "gesetzt". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
142 lines
5.4 KiB
TypeScript
142 lines
5.4 KiB
TypeScript
import { z } from "@/lib/validierung";
|
||
import { ANLAESSE, webhookUrlPruefen, type AnlassArt } from "./regeln";
|
||
|
||
/**
|
||
* Form der Benachrichtigungs-Einstellungen (#028). Gespeichert als JSON in `Einstellung`
|
||
* unter EINSTELLUNG_BENACHRICHTIGUNG; Passwort und Webhook-Geheimnis liegen dort nur
|
||
* verschluesselt (`verschluesseln()` mit SSHKV_APP_SECRET).
|
||
*/
|
||
export const EINSTELLUNG_BENACHRICHTIGUNG = "benachrichtigungen";
|
||
|
||
/** STARTTLS ist der Weg von Microsoft 365 (Port 587); KEINE nur fuer interne Relays. */
|
||
export const SMTP_SICHERHEIT = ["STARTTLS", "TLS", "KEINE"] as const;
|
||
export type SmtpSicherheit = (typeof SMTP_SICHERHEIT)[number];
|
||
|
||
export type GespeicherteBenachrichtigung = {
|
||
email: {
|
||
aktiv: boolean;
|
||
server: string;
|
||
port: number;
|
||
sicherheit: SmtpSicherheit;
|
||
absender: string;
|
||
benutzer: string;
|
||
/** Chiffrat aus `verschluesseln()` oder null. */
|
||
passwort: string | null;
|
||
empfaenger: string[];
|
||
};
|
||
webhook: {
|
||
aktiv: boolean;
|
||
url: string;
|
||
httpErlaubt: boolean;
|
||
/** Chiffrat aus `verschluesseln()` oder null. */
|
||
geheimnis: string | null;
|
||
};
|
||
anlaesse: Record<AnlassArt, boolean>;
|
||
};
|
||
|
||
/** Vorbelegung fuer Microsoft 365 – alles aus, bis ein Admin es einschaltet. */
|
||
export const STANDARD_BENACHRICHTIGUNG: GespeicherteBenachrichtigung = {
|
||
email: {
|
||
aktiv: false,
|
||
server: "smtp.office365.com",
|
||
port: 587,
|
||
sicherheit: "STARTTLS",
|
||
absender: "",
|
||
benutzer: "",
|
||
passwort: null,
|
||
empfaenger: [],
|
||
},
|
||
webhook: { aktiv: false, url: "", httpErlaubt: false, geheimnis: null },
|
||
anlaesse: { drift: true, ablauf: true, abgelaufen: true, unerreichbar: true, hostkey: true },
|
||
};
|
||
|
||
/**
|
||
* Was die Oberflaeche sehen darf: statt der Geheimnisse nur, ob sie gesetzt sind. Auch das
|
||
* Chiffrat geht nicht in Client-Props – es waere mit SSHKV_APP_SECRET lesbar.
|
||
*/
|
||
export type BenachrichtigungAnsicht = {
|
||
email: Omit<GespeicherteBenachrichtigung["email"], "passwort"> & { passwortGesetzt: boolean };
|
||
webhook: Omit<GespeicherteBenachrichtigung["webhook"], "geheimnis"> & { geheimnisGesetzt: boolean };
|
||
anlaesse: Record<AnlassArt, boolean>;
|
||
};
|
||
|
||
export function alsAnsicht(gespeichert: GespeicherteBenachrichtigung): BenachrichtigungAnsicht {
|
||
const { passwort, ...email } = gespeichert.email;
|
||
const { geheimnis, ...webhook } = gespeichert.webhook;
|
||
return {
|
||
email: { ...email, passwortGesetzt: passwort !== null },
|
||
webhook: { ...webhook, geheimnisGesetzt: geheimnis !== null },
|
||
anlaesse: { ...gespeichert.anlaesse },
|
||
};
|
||
}
|
||
|
||
/** Liest gespeichertes JSON tolerant: fehlende Felder bekommen die Vorbelegung. */
|
||
export function gespeichertLesen(wert: unknown): GespeicherteBenachrichtigung {
|
||
const roh = (
|
||
typeof wert === "object" && wert !== null ? wert : {}
|
||
) as Partial<GespeicherteBenachrichtigung>;
|
||
const s = STANDARD_BENACHRICHTIGUNG;
|
||
return {
|
||
email: { ...s.email, ...(roh.email ?? {}) },
|
||
webhook: { ...s.webhook, ...(roh.webhook ?? {}) },
|
||
anlaesse: { ...s.anlaesse, ...(roh.anlaesse ?? {}) },
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Formular
|
||
|
||
/** Empfaengerliste: Komma, Semikolon, Leerzeichen oder Zeilenumbruch trennen. */
|
||
export function empfaengerAus(text: string): string[] {
|
||
return [
|
||
...new Set(
|
||
text
|
||
.split(/[\s,;]+/)
|
||
.map((e) => e.trim())
|
||
.filter(Boolean),
|
||
),
|
||
];
|
||
}
|
||
|
||
const email = z.email({ error: "Keine gültige E-Mail-Adresse." });
|
||
|
||
export const benachrichtigungSchema = z
|
||
.object({
|
||
emailAktiv: z.boolean(),
|
||
emailServer: z.string().trim().max(253),
|
||
emailPort: z.coerce.number().int().min(1, "Port 1–65535.").max(65535, "Port 1–65535."),
|
||
emailSicherheit: z.enum(SMTP_SICHERHEIT),
|
||
emailAbsender: z.string().trim().max(254),
|
||
emailBenutzer: z.string().trim().max(254),
|
||
/** Leer = unveraendert lassen. */
|
||
emailPasswort: z.string().max(1024).optional(),
|
||
emailPasswortEntfernen: z.boolean(),
|
||
emailEmpfaenger: z.array(z.string()).max(50, "Höchstens 50 Empfänger."),
|
||
webhookAktiv: z.boolean(),
|
||
webhookUrl: z.string().trim().max(2048),
|
||
webhookHttpErlaubt: z.boolean(),
|
||
webhookGeheimnis: z.string().max(1024).optional(),
|
||
webhookGeheimnisEntfernen: z.boolean(),
|
||
anlaesse: z.array(z.enum(ANLAESSE)),
|
||
})
|
||
.superRefine((e, ctx) => {
|
||
const fehler = (pfad: string, message: string) => ctx.addIssue({ code: "custom", path: [pfad], message });
|
||
if (e.emailAktiv && !e.emailServer) fehler("emailServer", "Server angeben.");
|
||
if (e.emailServer && !/^[A-Za-z0-9.-]+$/.test(e.emailServer))
|
||
fehler("emailServer", "Nur Hostname oder IP.");
|
||
if (e.emailAktiv && !e.emailAbsender) fehler("emailAbsender", "Absender angeben.");
|
||
if (e.emailAbsender && !email.safeParse(e.emailAbsender).success)
|
||
fehler("emailAbsender", "Keine gültige E-Mail-Adresse.");
|
||
if (e.emailAktiv && e.emailEmpfaenger.length === 0)
|
||
fehler("emailEmpfaenger", "Mindestens ein Empfänger.");
|
||
const falsch = e.emailEmpfaenger.find((a) => !email.safeParse(a).success);
|
||
if (falsch) fehler("emailEmpfaenger", `„${falsch.slice(0, 80)}“ ist keine gültige E-Mail-Adresse.`);
|
||
if (e.webhookAktiv && !e.webhookUrl) fehler("webhookUrl", "Adresse angeben.");
|
||
if (e.webhookUrl) {
|
||
const meldung = webhookUrlPruefen(e.webhookUrl, e.webhookHttpErlaubt);
|
||
if (meldung) fehler("webhookUrl", meldung);
|
||
}
|
||
});
|
||
|
||
export type BenachrichtigungEingabe = z.input<typeof benachrichtigungSchema>;
|
||
export type BenachrichtigungDaten = z.output<typeof benachrichtigungSchema>;
|