import { Betriebsmodus } from "@/generated/prisma/enums"; import { prisma } from "@/lib/db"; import { namenEindeutigBilden, NamensregelnSchema, type Namensregeln, type Namenswerte, type Zielformat, } from "@/lib/namensregeln"; import { PASSWORT_HINWEIS } from "@/lib/passwort"; import { platzhalterErsetzen, type ErsetzungsErgebnis } from "@/lib/platzhalter-werte"; import { platzhalterAusserhalbAnfuehrungszeichen, ShellFehler, skriptBauen, wertMaskieren, type Shell, type SkriptSchritt, } from "@/lib/shell"; import { STANDARD_SHELL } from "@/lib/editor-aufbau"; import { betriebsmodus, kundeErmitteln, type HandelnderBenutzer } from "@/server/mandant"; import { platzhalterWerteDesLaufs, type LaufKontext, type PlatzhalterWerte } from "./platzhalter"; /** * Fertige Befehle und Namensvorschlaege (#012). * * Hier laufen die drei Teile zusammen: die Werte eines Laufs * (`services/platzhalter.ts`), das Ersetzen (`lib/platzhalter-werte.ts`) und die * Maskierung je Shell (`lib/shell.ts`). Das Ergebnis ist der Text, den ein * Techniker kopiert — und alles, was ihn verfaelscht, wird hier verfaelscht. * * Die Reihenfolge ist nicht beliebig: **erst ersetzen, dann maskieren** waere * falsch. Maskiert wird der **eingesetzte Wert**, nicht der fertige Befehl — * sonst maskierte man die Anfuehrungszeichen des Befehls gleich mit. * `platzhalterErsetzen` bekommt die Maskierung deshalb als Funktion und wendet * sie auf jeden Wert einzeln an. */ export type BefehlsSchritt = { readonly titel: string; readonly shell: Shell; readonly befehl: string; }; /** Ein Schritt mit eingesetzten Werten, bereit zum Kopieren. */ export type FertigerBefehl = { readonly titel: string; readonly shell: Shell; readonly befehl: string; /** Platzhalter, die niemand kennt – sie stehen im Befehl noch als `{{name}}`. */ readonly unbekannt: readonly string[]; /** Kam `{{generiertes_passwort}}` vor? Dann gehoert der Hinweis daneben. */ readonly mitPasswort: boolean; /** * Der Satz, der neben einem Passwort stehen muss. Steht hier und nicht in der * Oberflaeche, damit jede Anzeige denselben bekommt — ein Passwort ohne diesen * Hinweis ist ein verlorenes Passwort. */ readonly hinweis?: string; /** * Gesetzt, wenn sich ein Wert fuer diese Shell **nicht sicher** maskieren * laesst. Dann steht in `befehl` der Befehl mit stehen gebliebenen * Platzhaltern, und die Oberflaeche bietet kein Kopieren an. */ readonly fehler?: string; }; /** * Setzt die Werte in einen einzelnen Befehl ein. * * Der Titel wird **ohne** Shell-Maskierung ersetzt: er wird gelesen, nicht * ausgefuehrt. Ein maskierter Titel stuende mit Backticks auf dem Bildschirm. */ export function befehlFuellen(schritt: BefehlsSchritt, werte: PlatzhalterWerte): FertigerBefehl { const titel = platzhalterErsetzen(schritt.titel, werte).text; /* * Erst die Annahme pruefen, auf der die ganze Maskierung ruht: steht der * Platzhalter nicht in Anfuehrungszeichen, kann keine Maskierung daraus noch * **ein** Argument machen. Ein Leerzeichen im Wert zerlegt ihn, ein `;` * beendet den Befehl. */ const nackt = platzhalterAusserhalbAnfuehrungszeichen(schritt.befehl, schritt.shell); if (nackt.length > 0) { const zeichen = schritt.shell === "bash" ? "einfache" : "doppelte"; return { titel, shell: schritt.shell, befehl: schritt.befehl, unbekannt: [], mitPasswort: false, fehler: `${nackt.map((name) => `„{{${name}}}“`).join(", ")} steht nicht in Anführungszeichen. ` + `Ein eingesetzter Wert lässt sich dann nicht sicher maskieren — bitte ${zeichen} ` + "Anführungszeichen um den Platzhalter setzen.", }; } try { const befehl = platzhalterErsetzen(schritt.befehl, werte, { art: "befehl", maskieren: (wert) => wertMaskieren(wert, schritt.shell), }); return { titel, shell: schritt.shell, befehl: befehl.text, unbekannt: befehl.unbekannt, mitPasswort: befehl.mitPasswort, ...(befehl.mitPasswort ? { hinweis: PASSWORT_HINWEIS } : {}), }; } catch (fehler) { /* * `wertMaskieren` **wirft** fuer `cmd`, wenn ein Wert ein * Anfuehrungszeichen oder einen Umbruch enthaelt: dort laesst sich das nicht * sicher maskieren, und ein `"` beendet das Argument — alles danach liest * cmd.exe als Befehl. * * Ein ungefangener Wurf risse hier die ganze Abarbeitungsansicht ab. Statt * dessen bleibt der Befehl mit seinen Platzhaltern stehen und traegt die * Begruendung: der Techniker sieht, was gemeint war, und dass er es von Hand * einsetzen muss. Einen halb maskierten Befehl zum Kopieren anzubieten waere * die gefaehrlichste aller Antworten. */ if (fehler instanceof ShellFehler) { return { titel, shell: schritt.shell, befehl: schritt.befehl, unbekannt: [], mitPasswort: false, fehler: fehler.message, }; } throw fehler; } } /** * Alle Befehle eines Abschnitts als ein Skript zum Kopieren. * * **Die Passwoerter darin entstehen genau jetzt.** Wer das Skript zweimal * kopiert, bekommt zweimal verschiedene — das ist die ehrliche Folge der Regel * „bei jeder Anzeige neu“ und der Grund, warum der Hinweis daneben steht. */ /** * Ein Skript aus **bereits gefuellten** Schritten. * * Entscheidend, sobald ein `{{generiertes_passwort}}` im Spiel ist: es entsteht * bei jedem Fuellen neu. Wer dieselben Schritte zweimal fuellt — einmal fuer die * Anzeige, einmal fuer das Skript —, zeigt dem Techniker Passwort X und gibt ihm * Passwort Y zum Kopieren. Er legt das Konto mit Y an und nennt dem Benutzer X, * und weil nichts gespeichert wird, ist Y danach unwiederbringlich. */ export function skriptAusFertigen(fertige: readonly FertigerBefehl[]): { readonly skript: string; readonly mitPasswort: boolean; readonly unbekannt: readonly string[]; readonly nichtMaskierbar: readonly string[]; } { /* * Ein Schritt, der sich nicht sicher maskieren laesst, faellt aus dem Skript * heraus — mit seinen Platzhaltern drin waere es ein Skript, das etwas anderes * tut, als es liest. Gemeldet wird er trotzdem, sonst fehlte er stillschweigend. */ const alsSkript: SkriptSchritt[] = fertige .filter((schritt) => schritt.fehler === undefined) .map((schritt) => ({ titel: schritt.titel, shell: schritt.shell, befehl: schritt.befehl })); const unbekannt = new Set(); for (const schritt of fertige) for (const name of schritt.unbekannt) unbekannt.add(name); return { skript: skriptBauen(alsSkript), mitPasswort: fertige.some((schritt) => schritt.mitPasswort), unbekannt: [...unbekannt], nichtMaskierbar: fertige .filter((schritt) => schritt.fehler !== undefined) .map((schritt) => schritt.titel), }; } /** Fuellt die Schritte und baut daraus das Skript – in **einem** Durchgang. */ export function skriptFuerAbschnitt( schritte: readonly BefehlsSchritt[], werte: PlatzhalterWerte, ): { readonly skript: string; readonly mitPasswort: boolean; readonly unbekannt: readonly string[]; /** Schritte, deren Werte sich nicht sicher maskieren liessen. */ readonly nichtMaskierbar: readonly string[]; } { return skriptAusFertigen(schritte.map((schritt) => befehlFuellen(schritt, werte))); } /** * Die Befehle eines Laufs, abschnittsweise. * * Liest die Version, an der der Lauf haengt — nicht die neueste. Ein Lauf zeigt * fuer immer die Schritte, mit denen er gestartet wurde (#009). */ export async function befehleDesLaufs(kontext: LaufKontext): Promise< readonly { readonly abschnitt: string; readonly schritte: readonly FertigerBefehl[]; readonly skript: string; /** Platzhalter, die niemand kennt – sie stehen noch als `{{name}}` im Befehl. */ readonly unbekannt: readonly string[]; /** Schritte, die nicht ins Skript konnten, weil ihre Werte nicht maskierbar sind. */ readonly nichtMaskierbar: readonly string[]; }[] > { const lauf = await prisma.lauf.findUnique({ where: { id: kontext.laufId }, select: { vorlagenVersion: { select: { abschnitte: { orderBy: { position: "asc" }, select: { titel: true, schritte: { where: { typ: "BEFEHL" }, orderBy: { position: "asc" }, select: { titel: true, konfig: true }, }, }, }, }, }, }, }); if (!lauf) return []; const werte = await platzhalterWerteDesLaufs(kontext); return lauf.vorlagenVersion.abschnitte .map((abschnitt) => { const schritte: BefehlsSchritt[] = abschnitt.schritte.map((schritt) => ({ titel: schritt.titel, shell: shellAus(schritt.konfig), befehl: befehlAus(schritt.konfig), })); // **Einmal** fuellen und daraus beides bauen: ein zweiter Durchgang // erzeugte ein zweites Passwort (siehe skriptAusFertigen). const gefuellt = schritte.map((schritt) => befehlFuellen(schritt, werte)); const skript = skriptAusFertigen(gefuellt); return { abschnitt: abschnitt.titel, schritte: gefuellt, skript: skript.skript, unbekannt: skript.unbekannt, nichtMaskierbar: skript.nichtMaskierbar, }; }) .filter((abschnitt) => abschnitt.schritte.length > 0); } /** * Shell und Befehl aus dem `konfig`-Json. * * Das Feld ist ein Json und kann alles enthalten — eine aeltere Fassung, ein * Import, ein Eingriff per psql. Was nicht passt, wird zum Standard bzw. leer; * ein leerer Befehl faellt aus dem Skript heraus, statt es zu sprengen. */ function shellAus(konfig: unknown): Shell { const wert = lesen(konfig, "shell"); return wert === "powershell" || wert === "bash" || wert === "cmd" ? wert : STANDARD_SHELL; } function befehlAus(konfig: unknown): string { return lesen(konfig, "befehl") ?? ""; } function lesen(konfig: unknown, schluessel: string): string | undefined { if (typeof konfig !== "object" || konfig === null || Array.isArray(konfig)) return undefined; const wert = (konfig as Record)[schluessel]; return typeof wert === "string" ? wert : undefined; } /** * Die geltenden Namensregeln. * * Global aus `Einstellung`, im Modus DIENSTLEISTUNG je Kunde ueberlagert: eine * gesetzte Kundenregel gewinnt, eine fehlende faellt auf die globale zurueck. * **Feld fuer Feld**, nicht als Ganzes — ein Kunde, der nur eine eigene * Mail-Domain hat, soll nicht auch seine Hostname-Regel neu schreiben muessen. */ export async function namensregelnFuer(kundeId: string | null): Promise { const [einstellung, kunde] = await Promise.all([ prisma.einstellung.findUnique({ where: { id: "global" }, select: { namensregeln: true } }), kundeId ? prisma.kunde.findUnique({ where: { id: kundeId }, select: { namensregeln: true } }) : Promise.resolve(null), ]); const global = regelnLesen(einstellung?.namensregeln); if ((await betriebsmodus()) !== Betriebsmodus.DIENSTLEISTUNG) return global; const eigene = regelnLesen(kunde?.namensregeln); return { ...global, ...Object.fromEntries(Object.entries(eigene).filter(([, wert]) => (wert ?? "").trim() !== "")), }; } /** Ein unbrauchbarer Json-Inhalt darf keinen Namensvorschlag sprengen. */ function regelnLesen(roh: unknown): Namensregeln { const gelesen = NamensregelnSchema.safeParse(roh); return gelesen.success ? gelesen.data : {}; } export type NamensVorschlaege = Partial>; /** * Vorschlaege fuer `upn`, `sam`, `mail` und `hostname`. * * Eindeutigkeit wird gegen die Datenbank geprueft: `sam` und `upn` gegen die * Mitarbeiter desselben Kunden, `hostname` gegen dessen Geraete. Verbindlich ist * sie trotzdem erst beim Schreiben — zwischen Frage und Anlegen kann ein * zweiter Lauf denselben Namen belegen. Ein Vorschlag ist ein Vorschlag. */ export async function namenVorschlagen( kundeIdAusEingabe: string | null, werte: Namenswerte, benutzer: HandelnderBenutzer, ): Promise { /* * Ueber `kundeErmitteln`, nicht ueber die mitgeschickte Kennung: im Modus * INTERN wird sie verworfen, im Modus DIENSTLEISTUNG gegen die sichtbaren * Kunden geprueft (CLAUDE.md, Regel „Mandant“). Ohne das waere `istVergeben` * ein Orakel ueber Kundengrenzen hinweg — „gibt es bei euch einen mhuber?“. */ const kundeId = await kundeErmitteln(kundeIdAusEingabe, benutzer); const regeln = await namensregelnFuer(kundeId); const vorschlaege: NamensVorschlaege = {}; for (const zielformat of ["upn", "sam", "mail", "hostname"] as const) { const regel = regeln[zielformat]; if (!regel) continue; const name = await namenEindeutigBilden(zielformat, regel, werte, (kandidat) => istVergeben(zielformat, kandidat, kundeId), ); if (name) vorschlaege[zielformat] = name; } return vorschlaege; } async function istVergeben(zielformat: Zielformat, kandidat: string, kundeId: string): Promise { if (zielformat === "hostname") { const geraet = await prisma.geraet.findFirst({ where: { kundeId, hostname: { equals: kandidat, mode: "insensitive" } }, select: { id: true }, }); return geraet !== null; } const feld = zielformat === "mail" ? "mail" : zielformat; const person = await prisma.mitarbeiter.findFirst({ where: { kundeId, [feld]: { equals: kandidat, mode: "insensitive" } }, select: { id: true }, }); return person !== null; } export type { ErsetzungsErgebnis };