Files
Laufzettel/src/server/services/befehle.ts
T
florian.wilde eec5de404a
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(platzhalter): Aufloesung, Namensregeln und Shell-Maskierung (#26)
2026-09-25 13:30:31 +00:00

368 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string>();
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<string, unknown>)[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<Namensregeln> {
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<Record<Zielformat, string>>;
/**
* 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<NamensVorschlaege> {
/*
* 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<boolean> {
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 };