feat(platzhalter): Aufloesung, Namensregeln und Shell-Maskierung (#26)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #26.
This commit is contained in:
2026-09-25 13:30:31 +00:00
parent f85bc87324
commit eec5de404a
21 changed files with 4069 additions and 166 deletions
+550
View File
@@ -0,0 +1,550 @@
import { z } from "@/lib/validierung";
/**
* Namensregeln: aus einem Personennamen werden upn, sam, mail und hostname (#012).
*
* Aus diesen Werten entstehen die PowerShell-Befehle, die ein Techniker auf einem
* Domaenencontroller ausfuehrt. Ein Name, der hier krumm herauskommt, legt dort
* ein Konto mit krummem Namen an — und zwar unbemerkt, weil niemand mehr
* hinsieht, wenn der Befehl einmal kopiert ist. Deshalb gilt durchgehend:
*
* 1. **Fehlt etwas, kommt nichts heraus.** Keine Regel, ein unbekannter
* Platzhalter darin, ein leerer Vorname — jedes Mal `undefined`. Ein
* erfundener oder halber Anmeldename ist schlimmer als gar keiner, weil er
* ausgefuehrt wird.
* 2. **Die Grenzen des Zielsystems werden eingehalten, nicht gemeldet.** Das
* Active Directory kuerzt einen zu langen `sAMAccountName` nicht, es lehnt ihn
* ab; der NetBIOS-Name bricht bei 15 Zeichen.
* 3. **Framework- und datenbankfrei.** Ob ein Kandidat schon vergeben ist, weiss
* nur der Aufrufer; er reicht die Pruefung herein.
*/
export class NamensregelFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "NamensregelFehler";
}
}
/**
* Vorlagen fuer abgeleitete Werte, wie sie in `Einstellung.namensregeln` und
* `Kunde.namensregeln` liegen.
*
* Steht hier und nicht mehr im Einstellungs-Service: das Aufloesen gehoert in
* `src/lib`, und Schema und Aufloesung durften nicht in verschiedenen Schichten
* liegen — sonst haette der Service das Schema und die lib die Regeln, und
* niemand haette gemerkt, wenn beide unterschiedliche Platzhalter kennen.
*/
export const NamensregelnSchema = z.object({
upn: z.string().trim().max(200).optional(),
sam: z.string().trim().max(200).optional(),
mail: z.string().trim().max(200).optional(),
hostname: z.string().trim().max(200).optional(),
});
export type Namensregeln = z.infer<typeof NamensregelnSchema>;
export const ZIELFORMATE = ["upn", "sam", "mail", "hostname"] as const;
export type Zielformat = (typeof ZIELFORMATE)[number];
/** Grenze des Active Directory fuer `sAMAccountName`. Laenger wird nicht gekuerzt, sondern abgelehnt. */
export const SAM_HOECHSTLAENGE = 20;
/** Grenze des NetBIOS-Namens. Windows zeigt und registriert nur die ersten 15 Zeichen. */
export const HOSTNAME_HOECHSTLAENGE = 15;
/**
* Stellen, die eine Regel **ohne** eigenen Zaehler fuer die Unterscheidung frei haelt.
*
* Drei Stellen erlauben 999 Träger desselben Namensstamms. Der Vorrat wird immer
* zurueckgehalten, auch wenn der Name gerade noch frei ist: sonst hiesse
* derselbe Mensch `jmuellerluedenscheid`, solange er der erste ist, und
* `jmuellerluedensch2`, sobald ein zweiter dazukommt — und dann stimmt kein
* einmal notierter Name mehr mit dem ueberein, was die Regel heute liefert.
* Enthaelt die Regel einen Zaehler `{nnn}`, ist der die Unterscheidung und die
* volle Laenge steht zur Verfuegung.
*/
export const UNTERSCHEIDUNG_STELLEN = 3;
/** Hoechster Wert, der als Unterscheidung angehaengt wird — er passt in `UNTERSCHEIDUNG_STELLEN`. */
export const UNTERSCHEIDUNG_HOECHSTWERT = 10 ** UNTERSCHEIDUNG_STELLEN - 1;
/**
* Zeichen, die zwei Buchstaben ergeben oder nach Unicode keine Zerlegung haben.
*
* Alles andere erledigt die Zerlegung nach NFD: `é`, `ç`, `å` und ihre
* Verwandten verlieren dort einfach ihr Beizeichen. Hier stehen nur die beiden
* Sonderfaelle: die deutschen Umlaute und das scharfe S, die zu **zwei**
* Buchstaben werden statt einen zu verlieren, und die Buchstaben, die Unicode
* gar nicht zerlegt (`ø`, `ł`, `æ`, `þ`) und die deshalb ohne diese Tabelle als
* Nicht-ASCII ganz wegfielen — aus „Kowalski“ mit `ł` wuerde „Kowaski“.
*/
const FALTUNG: readonly (readonly [string, string])[] = [
["ä", "ae"],
["ö", "oe"],
["ü", "ue"],
["Ä", "Ae"],
["Ö", "Oe"],
["Ü", "Ue"],
["ß", "ss"],
["ẞ", "Ss"],
["æ", "ae"],
["Æ", "Ae"],
["œ", "oe"],
["Œ", "Oe"],
["ø", "o"],
["Ø", "O"],
["ł", "l"],
["Ł", "L"],
["đ", "d"],
["Đ", "D"],
["ð", "d"],
["Ð", "D"],
["þ", "th"],
["Þ", "Th"],
["ı", "i"],
];
/**
* Faltet einen Text auf druckbares ASCII.
*
* Drei Stufen, und die Reihenfolge ist der ganze Trick: erst die Zeichen, die
* **zwei** Buchstaben ergeben (`ä` → `ae`), dann die Zerlegung nach NFD samt
* Wegwerfen der Akzentzeichen (`é` → `e` + Akut → `e`), dann alles uebrige
* Nicht-ASCII. Umgekehrt waere `ä` schon in der zweiten Stufe zu `a` geworden,
* und „Jörg“ hiesse „Jorg“.
*
* Die Gross- und Kleinschreibung bleibt, wie sie ist — welche das Zielformat
* braucht, entscheidet das Zielformat. Aus „MÜLLER“ wird deshalb „MUeLLER“;
* sichtbar wird das nie, weil jedes der vier Formate die Schreibweise
* anschliessend vereinheitlicht.
*/
export function transliterieren(text: string): string {
// Erst zusammensetzen: „ü“ kann als ein Zeichen oder als `u` + Trema
// ankommen — aus einer CSV-Datei eines Mac regelmaessig als zweites. Ohne NFC
// griffe die Tabelle unten nicht, und aus „Müller“ wuerde „Muller“ statt
// „Mueller“: ein Anmeldename, der beinahe stimmt und deshalb erst im Betrieb
// auffaellt.
let gefaltet = text.normalize("NFC");
for (const [von, nach] of FALTUNG) {
gefaltet = gefaltet.replaceAll(von, nach);
}
return (
gefaltet
.normalize("NFD")
// Alle kombinierenden Zeichen: Akut, Gravis, Cedille, Ring, Haken.
.replace(/\p{M}+/gu, "")
// Nur druckbares ASCII bleibt — der Bereich vom Leerzeichen bis zur Tilde.
// Steuerzeichen und Zeilenumbrueche fallen weg, statt zu Leerzeichen zu
// werden: in einem Namen haben sie nichts zu suchen, und ein Umbruch
// mitten in einem Befehl waere gefaehrlich.
.replace(/[^ -~]/g, "")
);
}
/** Derselbe Text klein geschrieben — die Form, in der Namensteile in eine Regel gehen. */
export function kleinGefaltet(text: string): string {
return transliterieren(text).toLowerCase();
}
export type Namenswerte = {
readonly vorname?: string | null;
readonly nachname?: string | null;
/** Nur im Modus `DIENSTLEISTUNG` gesetzt; im Modus `INTERN` gibt es kein Kuerzel. */
readonly kundenkuerzel?: string | null;
};
/**
* Ein Namensteil fuer die Regel: gefaltet, klein, ohne Leerzeichen.
*
* „Anna Lena“ wird zu `annalena`. Der Bindestrich bleibt — er gehoert zum Namen
* und ist in Adressen wie in Anmeldenamen erlaubt.
*/
function namensteil(roh: string | null | undefined): string | undefined {
if (!roh) return undefined;
return kleinGefaltet(roh).replace(/\s+/g, "") || undefined;
}
/** Platzhalter einer Regel. `{nnn}` wird getrennt behandelt, weil die Zahl der `n` zaehlt. */
const REGEL_PLATZHALTER = /\{([a-z]+)\}/g;
type Regelteil =
{ readonly art: "text"; readonly text: string } | { readonly art: "zaehler"; readonly stellen: number };
function platzhalterWert(name: string, werte: Namenswerte): string | undefined {
switch (name) {
case "vorname":
return namensteil(werte.vorname);
case "nachname":
return namensteil(werte.nachname);
case "v": {
// Der erste **Buchstabe**, nicht das erste Zeichen: ein Vorname wie
// „'Abd al-Rahman“ faengt nach der Faltung mit einem Apostroph an, und ein
// Anmeldename `'abdalrahman` hilft niemandem.
const vorname = namensteil(werte.vorname);
return vorname?.match(/[a-z0-9]/)?.[0];
}
case "kundenkuerzel":
return namensteil(werte.kundenkuerzel);
default:
return undefined;
}
}
/**
* Zerlegt eine Regel in aufgeloeste Textstuecke und hoechstens einen Zaehler.
*
* `undefined` heisst „diese Regel ergibt hier keinen Namen“: ein Platzhalter, den
* es nicht gibt, eine uebrig gebliebene geschweifte Klammer, ein zweiter Zaehler
* oder ein Wert, der fehlt. Die ersten drei sind Fehler in der Einstellung, der
* letzte einer in den Stammdaten. Keiner davon laesst sich hier beheben, und
* geraten wird nicht.
*/
function regelZerlegen(regel: string, werte: Namenswerte): readonly Regelteil[] | undefined {
const teile: Regelteil[] = [];
let zaehlerGesehen = false;
let gelesenBis = 0;
/**
* Eine geschweifte Klammer im **festen** Teil der Regel ist ein verunglueckter
* Platzhalter: `{Nachname}`, `{NACHNAME}` oder ein fehlendes `}`. Unbemerkt
* wuerde daraus ein Anmeldename `nachname@firma.de` — die Klammern fallen beim
* Normalisieren weg, der Text bleibt. Also lieber kein Name.
*/
const festerText = (text: string): boolean => {
if (/[{}]/.test(text)) return false;
teile.push({ art: "text", text });
return true;
};
for (const treffer of regel.matchAll(REGEL_PLATZHALTER)) {
const name = treffer[1] ?? "";
const beginn = treffer.index;
if (beginn > gelesenBis && !festerText(regel.slice(gelesenBis, beginn))) {
return undefined;
}
gelesenBis = beginn + treffer[0].length;
if (/^n+$/.test(name)) {
if (zaehlerGesehen) return undefined;
zaehlerGesehen = true;
teile.push({ art: "zaehler", stellen: name.length });
continue;
}
const wert = platzhalterWert(name, werte);
if (wert === undefined) return undefined;
teile.push({ art: "text", text: wert });
}
if (gelesenBis < regel.length && !festerText(regel.slice(gelesenBis))) {
return undefined;
}
return teile;
}
function textVon(teil: Regelteil): string {
return teil.art === "text" ? teil.text : "";
}
/**
* Adressform fuer `upn` und `mail`.
*
* Klein geschrieben, genau ein `@`, beide Haelften gefuellt, keine doppelten
* Punkte und kein Punkt unmittelbar vor dem `@` — das lehnen Exchange und das
* Active Directory ab. Passt die Form nicht, kommt `undefined` heraus: eine
* Adresse ohne `@` ist keine, und sie in einen `New-ADUser`-Befehl zu setzen,
* hiesse dem Techniker eine Zusage zu machen, die nicht stimmt.
*/
function adresseNormalisieren(roh: string): string | undefined {
const teile = roh.toLowerCase().split("@");
if (teile.length !== 2) return undefined;
const lokal = (teile[0] ?? "")
.replace(/[^a-z0-9._+-]/g, "")
.replace(/\.{2,}/g, ".")
.replace(/^\.+/, "")
.replace(/\.+$/, "");
const bereich = (teile[1] ?? "")
.replace(/[^a-z0-9.-]/g, "")
.replace(/\.{2,}/g, ".")
.replace(/^[.-]+/, "")
.replace(/[.-]+$/, "");
if (!lokal || !bereich) return undefined;
// Am Rand raeumen die Schritte oben auf: aus `-firma.de-` wird `firma.de`.
// Innen geht das nicht, ohne den Namen zu erfinden — `firma.-de` bleibt also
// falsch und wird abgelehnt.
if (bereich.split(".").some((stufe) => !stufe || stufe.startsWith("-") || stufe.endsWith("-"))) {
return undefined;
}
return `${lokal}@${bereich}`;
}
/**
* Form des `sAMAccountName`.
*
* Erlaubt bleiben Buchstaben, Ziffern, Punkt und Unterstrich. Der Bindestrich
* faellt weg, obwohl das Active Directory ihn zuliesse: der Anmeldename wird
* abgetippt und am Telefon buchstabiert, und `mueller-luedenscheid` kostet dabei
* ein Zeichen von zwanzig fuer eine Information, die im Anzeigenamen ohnehin
* steht. Ein Punkt am Ende ist unzulaessig und fliegt auch nach dem Kuerzen
* wieder heraus, weil gekuerzt wird, bevor diese Form hergestellt ist.
*/
function samNormalisieren(roh: string): string | undefined {
return (
roh
.toLowerCase()
.replace(/[^a-z0-9._]/g, "")
.replace(/\.{2,}/g, ".")
.replace(/^\.+/, "")
.replace(/\.+$/, "") || undefined
);
}
/**
* Form des Hostnamens.
*
* **Grossbuchstaben**, weil Windows den NetBIOS-Namen gross fuehrt und gross
* anzeigt: steht auf dem Aufkleber `knd-pc-007` und meldet das Geraet
* `KND-PC-007`, sieht der naechste Techniker zwei Namen und sucht nach einem
* Fehler, den es nicht gibt. DNS unterscheidet die Schreibweise ohnehin nicht.
*
* Unterstrich und Punkt fallen weg — der Unterstrich ist in Hostnamen nach
* RFC 1123 nicht erlaubt und bringt Windows-DNS zum Stolpern, der Punkt trennt
* den Namen von der Domaene und hat im NetBIOS-Namen nichts zu suchen. Ein
* fuehrender oder abschliessender Bindestrich faellt ebenfalls weg; er entsteht
* beim Kuerzen von selbst.
*/
/**
* Namen, die Windows als Computernamen ablehnt.
*
* Es sind die alten Geraetenamen von MS-DOS. `CON` ist die Konsole, `PRN` der
* Drucker, `NUL` der Papierkorb — ein Rechner darf so nicht heissen, und ein
* Nachname „Con“ oder eine Abteilung „Aux“ reicht, damit die Regel es
* vorschlaegt. Gefunden im Review zu #012.
*/
const HOSTNAME_RESERVIERT: ReadonlySet<string> = new Set([
"CON",
"PRN",
"AUX",
"NUL",
...Array.from({ length: 9 }, (_, i) => `COM${i + 1}`),
...Array.from({ length: 9 }, (_, i) => `LPT${i + 1}`),
]);
function hostnameNormalisieren(roh: string): string | undefined {
const name =
roh
.toUpperCase()
.replace(/[^A-Z0-9-]/g, "")
.replace(/^-+/, "")
.replace(/-+$/, "") || undefined;
if (name === undefined) return undefined;
// Beides lehnt Windows ab: einen reservierten Geraetenamen und einen rein
// numerischen Namen (er waere von einer IP-Adresse nicht zu unterscheiden).
if (HOSTNAME_RESERVIERT.has(name)) return undefined;
if (/^[0-9]+$/.test(name)) return undefined;
return name;
}
function normalisieren(zielformat: Zielformat, roh: string): string | undefined {
switch (zielformat) {
case "upn":
case "mail":
return adresseNormalisieren(roh);
case "sam":
return samNormalisieren(roh);
case "hostname":
return hostnameNormalisieren(roh);
}
}
/**
* Wie lang darf das Ergebnis werden?
*
* `undefined` heisst „keine Grenze“: Adressen kuerzt niemand, eine gekuerzte
* Mail-Adresse waere schlicht falsch. `mitUnterscheidung` sagt, ob die
* Unterscheidung schon im Namen steckt — dann ist die volle Laenge nutzbar,
* sonst bleibt der Vorrat fuer die Unterscheidung frei.
*/
function grenzeFuer(zielformat: Zielformat, mitUnterscheidung: boolean): number | undefined {
const voll =
zielformat === "sam" ? SAM_HOECHSTLAENGE : zielformat === "hostname" ? HOSTNAME_HOECHSTLAENGE : undefined;
if (voll === undefined) return undefined;
return mitUnterscheidung ? voll : voll - UNTERSCHEIDUNG_STELLEN;
}
/**
* Setzt Text, Zahl und Text zusammen und haelt dabei die Grenze ein.
*
* Gekuerzt wird am Ende — aber **nie in der Zahl**. Genau daran waere sonst die
* Eindeutigkeit gestorben: `{nachname}-{nnn}` liefert fuer einen langen Namen
* zweimal denselben Hostnamen, wenn die 15 Zeichen mitten in der Nummer
* abschneiden. Deshalb wird Stueck fuer Stueck erst hinter, dann vor der Zahl
* weggenommen und jedes Mal neu normalisiert: Normalisieren kann die Laenge
* veraendern (ein Bindestrich am neuen Ende faellt weg), Kuerzen und Pruefen
* muessen also in derselben Schleife sitzen.
*/
function zusammensetzen(
zielformat: Zielformat,
vorText: string,
zahlText: string,
nachText: string,
grenze: number | undefined,
): string | undefined {
for (let weg = 0; weg <= vorText.length + nachText.length; weg += 1) {
const wegNach = Math.min(weg, nachText.length);
const wegVor = weg - wegNach;
const roh =
vorText.slice(0, vorText.length - wegVor) + zahlText + nachText.slice(0, nachText.length - wegNach);
const kandidat = normalisieren(zielformat, roh);
// Wird das Ergebnis beim Kuerzen unbrauchbar (leer, oder eine Adresse ohne
// `@`), hilft weiteres Kuerzen nicht mehr.
if (kandidat === undefined) return undefined;
if (grenze === undefined || kandidat.length <= grenze) return kandidat;
}
return undefined;
}
function zaehlerStellen(teile: readonly Regelteil[]): number | undefined {
for (const teil of teile) {
if (teil.art === "zaehler") return teil.stellen;
}
return undefined;
}
/** Enthaelt die Regel einen Zaehler `{n}`, `{nn}`, `{nnn}`, …? */
export function hatZaehler(regel: string): boolean {
for (const treffer of regel.matchAll(REGEL_PLATZHALTER)) {
if (/^n+$/.test(treffer[1] ?? "")) return true;
}
return false;
}
/**
* Bildet einen Namen aus einer Regel.
*
* `zaehler` fuellt ein `{nnn}` in der Regel; die Zahl der Stellen kommt aus der
* Zahl der `n`, also `{nn}` → `07`. Enthaelt die Regel einen Zaehler, aber es
* wird keiner mitgegeben, kommt `undefined` heraus — nicht `001`. Welche Nummer
* frei ist, weiss diese Schicht nicht, und die erste zu raten hiesse einen
* Hostnamen zu liefern, der schon an einem anderen Geraet haengt. Wer einen
* Zaehler braucht, ruft `namenEindeutigBilden`.
*/
export function namenBilden(
zielformat: Zielformat,
regel: string | undefined | null,
werte: Namenswerte,
zaehler?: number,
): string | undefined {
const bereinigt = regel?.trim();
if (!bereinigt) return undefined;
const teile = regelZerlegen(bereinigt, werte);
if (!teile) return undefined;
const stellen = zaehlerStellen(teile);
const index = teile.findIndex((teil) => teil.art === "zaehler");
const vorText = teile
.slice(0, index < 0 ? teile.length : index)
.map(textVon)
.join("");
const nachText =
index < 0
? ""
: teile
.slice(index + 1)
.map(textVon)
.join("");
if (stellen === undefined) {
return zusammensetzen(zielformat, vorText, "", "", grenzeFuer(zielformat, false));
}
if (zaehler === undefined || !Number.isInteger(zaehler) || zaehler < 0) return undefined;
const zahlText = String(zaehler).padStart(stellen, "0");
// Passt die Nummer nicht in ihre Stellen, waere das Ergebnis ein Zeichen zu
// lang und die Regel damit ueberdehnt — `{nn}` kann keine 100 tragen.
if (zahlText.length > stellen) return undefined;
return zusammensetzen(zielformat, vorText, zahlText, nachText, grenzeFuer(zielformat, true));
}
/**
* Haengt eine Unterscheidung an einen fertigen Namen.
*
* Bei Adressen steht sie vor dem `@` — `joerg.mueller2@firma.de`, nicht
* `joerg.mueller@firma.de2`. Bei `sam` und `hostname` steht sie am Ende und
* darf die Grenze ausschoepfen, weil sie selbst die Unterscheidung ist.
*/
function variante(zielformat: Zielformat, basis: string, nummer: number): string | undefined {
const nummerText = String(nummer);
if (zielformat === "upn" || zielformat === "mail") {
const trenner = basis.lastIndexOf("@");
if (trenner < 0) return undefined;
return zusammensetzen(zielformat, basis.slice(0, trenner), nummerText, basis.slice(trenner), undefined);
}
return zusammensetzen(zielformat, basis, nummerText, "", grenzeFuer(zielformat, true));
}
/**
* Bildet einen Namen, der noch frei ist.
*
* Die Vergabepruefung wird **hereingereicht**, nicht als Liste vergebener Werte
* entgegengenommen: schon fuer den Hostnamen hiesse eine Liste, alle Geraete
* eines Kunden zu laden, um eine einzige Nummer zu finden, und fuer `upn` und
* `mail` alle Mitarbeiter. Eine Funktion fragt genau einen Kandidaten und
* uebersetzt sich in ein `findFirst` mit Index; im Test ist sie ein `vi.fn`,
* dessen Aufrufe sich einzeln nachlesen lassen. Denselben Weg geht
* `tagCodeEindeutigErzeugen` in `src/lib/tagcode.ts`.
*
* **Was diese Schleife nicht leistet:** Die Datenbank sichert die Eindeutigkeit
* hier nicht ab — auf `Mitarbeiter.upn`, `.sam`, `.mail` und `Geraet.hostname`
* liegen Suchindizes, keine Unique-Indizes (anders als beim `tagCode`). Zwischen
* der Frage und dem Schreiben kann also ein zweiter Lauf denselben Namen
* belegen. Diese Schleife nimmt dem Techniker den absehbaren Fall ab, nicht das
* Wettrennen; die letzte Instanz ist das Zielsystem, das ein doppeltes
* `sAMAccountName` selbst ablehnt.
*/
export async function namenEindeutigBilden(
zielformat: Zielformat,
regel: string | undefined | null,
werte: Namenswerte,
istVergeben: (kandidat: string) => Promise<boolean>,
): Promise<string | undefined> {
const bereinigt = regel?.trim();
if (!bereinigt) return undefined;
const teile = regelZerlegen(bereinigt, werte);
if (!teile) return undefined;
const stellen = zaehlerStellen(teile);
if (stellen !== undefined) {
const hoechster = 10 ** stellen - 1;
for (let zaehler = 1; zaehler <= hoechster; zaehler += 1) {
const kandidat = namenBilden(zielformat, bereinigt, werte, zaehler);
if (kandidat === undefined) return undefined;
if (!(await istVergeben(kandidat))) return kandidat;
}
throw new NamensregelFehler(
`Die Regel „${bereinigt}“ gibt nur ${hoechster} Nummern her, und alle sind vergeben. ` +
"Die Regel braucht eine Stelle mehr.",
);
}
const basis = namenBilden(zielformat, bereinigt, werte);
if (basis === undefined) return undefined;
if (!(await istVergeben(basis))) return basis;
for (let nummer = 2; nummer <= UNTERSCHEIDUNG_HOECHSTWERT; nummer += 1) {
const kandidat = variante(zielformat, basis, nummer);
if (kandidat === undefined) return undefined;
if (!(await istVergeben(kandidat))) return kandidat;
}
throw new NamensregelFehler(
`„${basis}“ ist vergeben, und auch mit den Zusätzen 2 bis ${UNTERSCHEIDUNG_HOECHSTWERT} ` +
"war kein freier Name zu finden. Das deutet auf eine defekte Vergabeprüfung hin.",
);
}
+117
View File
@@ -0,0 +1,117 @@
import { randomInt } from "node:crypto";
/**
* Das generierte Passwort (#012).
*
* `{{generiertes_passwort}}` wird **bei jeder Anzeige neu erzeugt und nie
* gespeichert** — nicht im `Schrittergebnis`, nicht im `Kommentar`, nicht im
* `Protokoll` und nicht in der Druckansicht. Das ist keine Bequemlichkeit,
* sondern die Zusage: ein archivierter Lauf ist ein Dokument, das Jahre liegt
* und von vielen gelesen wird. Ein Anfangspasswort darin waere ein Fundstueck.
*
* Die Folge muss man aushalten: wer den Befehl ausfuehrt und das Passwort nicht
* sofort weiterreicht, hat es verloren. Genau deshalb steht der Hinweis
* „Passwort sofort in den Passwortmanager übernehmen“ daneben, und genau deshalb
* ist ein zweiter Aufruf ein **anderes** Passwort — nicht dasselbe noch einmal.
*/
/**
* Zeichen ohne Verwechslungsgefahr.
*
* Weggelassen sind `I l 1 |`, `O 0` und `B 8`: das Passwort wird von einem
* Bildschirm abgelesen und in ein anderes Fenster getippt, oft von der Person,
* der das Konto gehoert. Ein Zeichen, das man falsch liest, kostet einen
* Anruf — und der Sicherheitsgewinn dieser paar Zeichen ist dagegen nichts.
*/
export const PASSWORT_GROSS = "ACDEFGHJKLMNPQRSTUVWXYZ";
export const PASSWORT_KLEIN = "abcdefghijkmnopqrstuvwxyz";
export const PASSWORT_ZIFFERN = "23456789";
/**
* Sonderzeichen, die in keiner der drei Shells etwas anrichten.
*
* Bewusst ohne `" ' $ \` \\ ; & | < > %`: das Passwort landet in einem Befehl,
* den jemand kopiert. Die Maskierung in `src/lib/shell.ts` faengt das zwar ab —
* aber ein Passwort, das nur wegen korrekter Maskierung funktioniert, ist eine
* Wette auf jeden kuenftigen Aufrufer. Was gar nicht erst drin ist, kann auch
* nichts zerbrechen.
*
* Auch **ohne `!`**: bei eingeschalteter verzoegerter Ersetzung
* (`setlocal enabledelayedexpansion`) frisst `cmd.exe` es still, und das Konto
* bekaeme ein anderes Passwort als angezeigt. Gefunden im Review zu #012 — der
* Kommentar behauptete vorher Sicherheit in einer CSV-Zelle, und `= + - @`
* stehen dort am Zellenanfang fuer eine Formel. Deshalb faengt ein Passwort
* auch nie mit einem Sonderzeichen an (siehe `passwortErzeugen`).
*/
export const PASSWORT_SONDER = "#*+-=?@_~";
const ALPHABET = PASSWORT_GROSS + PASSWORT_KLEIN + PASSWORT_ZIFFERN + PASSWORT_SONDER;
export const PASSWORT_LAENGE = 20;
/** Steht in der Druckansicht statt des Passworts. */
export const PASSWORT_IM_DRUCK = "[Passwort – nicht gespeichert]";
/** Der Satz, der neben jedem angezeigten Passwort steht. */
export const PASSWORT_HINWEIS = "Passwort sofort in den Passwortmanager übernehmen";
function zeichenAus(vorrat: string): string {
// `randomInt` aus node:crypto statt Math.random: gleichverteilt und aus einer
// Quelle, die fuer Geheimnisse gedacht ist.
return vorrat.charAt(randomInt(vorrat.length));
}
/**
* Erzeugt ein Passwort mit 20 Zeichen.
*
* Aus jeder der vier Gruppen kommt mindestens ein Zeichen — nicht aus
* Aberglauben, sondern weil die Passwortrichtlinie eines Active Directory
* ueblicherweise drei von vier verlangt und ein abgewiesenes `New-ADUser` den
* Techniker vor dem Kunden stehen laesst. Danach wird gemischt, damit die
* Gruppen nicht an festen Stellen stehen.
*/
export function passwortErzeugen(laenge: number = PASSWORT_LAENGE): string {
if (laenge < 4) {
throw new Error("Ein Passwort unter vier Zeichen kann nicht aus vier Gruppen bestehen.");
}
const zeichen = [
zeichenAus(PASSWORT_GROSS),
zeichenAus(PASSWORT_KLEIN),
zeichenAus(PASSWORT_ZIFFERN),
zeichenAus(PASSWORT_SONDER),
];
while (zeichen.length < laenge) {
zeichen.push(zeichenAus(ALPHABET));
}
// Fisher-Yates mit `randomInt`: ein `sort(() => Math.random() - 0.5)` mischt
// nachweislich ungleich und wuerde die vier Pflichtzeichen bevorzugt vorn
// lassen.
for (let i = zeichen.length - 1; i > 0; i -= 1) {
const j = randomInt(i + 1);
[zeichen[i], zeichen[j]] = [zeichen[j] as string, zeichen[i] as string];
}
/*
* Das erste Zeichen ist nie ein Sonderzeichen.
*
* `= + - @` am Anfang einer Zelle liest eine Tabellenkalkulation als Formel,
* und ein Passwort geht seinen Weg oft ueber Zwischenablage und Tabelle. Statt
* die vier zu streichen — sie erhoehen den Vorrat spuerbar — wandert ein
* fuehrendes Sonderzeichen nach hinten. Die Laenge und die vier Gruppen
* bleiben unberuehrt.
*/
const erstes = zeichen[0] as string;
if (PASSWORT_SONDER.includes(erstes)) {
const tausch = zeichen.findIndex((zeichenAnStelle, index) => {
return index > 0 && !PASSWORT_SONDER.includes(zeichenAnStelle as string);
});
if (tausch > 0) {
zeichen[0] = zeichen[tausch] as string;
zeichen[tausch] = erstes;
}
}
return zeichen.join("");
}
+136
View File
@@ -0,0 +1,136 @@
import { istEingebaut } from "@/lib/platzhalter";
import { PASSWORT_IM_DRUCK, passwortErzeugen } from "@/lib/passwort";
/**
* Das Ersetzen der Platzhalter (#012).
*
* Framework- und datenbankfrei: was ein Platzhalter bedeutet, entscheidet der
* Aufrufer, indem er die Werte hereinreicht. Hier steht nur, **wie** ersetzt
* wird — und das ist die Stelle, an der ein Fehler einen PowerShell-Befehl
* verfaelscht, den jemand auf einem Domaenencontroller ausfuehrt.
*
* Drei Regeln tragen das:
*
* 1. **Ein unbekannter Platzhalter bleibt stehen.** `{{tipfehler}}` wird nicht
* zu einer leeren Stelle, sondern bleibt sichtbar — im Befehl faellt das auf,
* ein stillschweigend leerer Parameter nicht.
* 2. **Ein bekannter Platzhalter ohne Wert wird leer.** `{{kunde}}` im Modus
* Intern ist nicht unbekannt, er ist leer. Der Unterschied ist der zwischen
* „falsch geschrieben“ und „hier gibt es das nicht“.
* 3. **Das generierte Passwort entsteht erst beim Ersetzen** und wird nie
* zurueckgegeben. Es gibt keinen Weg, es nachtraeglich zu erfragen.
*/
/**
* Wie der Text weiterverwendet wird – das entscheidet ueber Maskierung und Passwort.
*
* **Warum `anzeige` nicht maskiert:** der Text landet als React-Kind im Baum,
* und React maskiert Textknoten selbst. Eine zweite Maskierung hier machte aus
* „Forschung & Entwicklung“ sichtbar „Forschung &amp;amp; Entwicklung“ — der
* Versuch, sicherer zu sein, waere ein Anzeigefehler in jedem zweiten
* Abteilungsnamen. `tests/platzhalter-anzeige.test.tsx` haelt fest, dass ein
* `<script>` im Wert wirklich maskiert ankommt; bewiesen wird das am gerenderten
* HTML und nicht an einer Behauptung.
*
* Wer den Text je **als rohes HTML** einsetzt, maskiert selbst — und begruendet
* im selben Atemzug, warum er rohes HTML einsetzt.
*/
export type Verwendung =
/** Anzeige im Browser. React maskiert selbst; hier geschieht nichts. */
| { readonly art: "anzeige" }
/** In einem Befehl. Werte werden fuer die Shell maskiert. */
| { readonly art: "befehl"; readonly maskieren: (wert: string) => string }
/**
* Druckansicht. Wie `anzeige`, aber das generierte Passwort erscheint als
* `[Passwort – nicht gespeichert]`: ein Ausdruck liegt herum.
*/
| { readonly art: "druck" };
export type ErsetzungsErgebnis = {
readonly text: string;
/** Platzhalter, die niemand kennt – die Oberflaeche macht daraus eine Warnung. */
readonly unbekannt: readonly string[];
/**
* Kam `{{generiertes_passwort}}` vor? Dann gehoert der Hinweis daneben.
* Das Passwort selbst steht **nicht** hier; es existiert nur im Text.
*/
readonly mitPasswort: boolean;
};
const PASSWORT_SCHLUESSEL = "generiertes_passwort";
/**
* Ersetzt die Platzhalter in einem Text.
*
* `werte` ist die Zuordnung Schluessel → Wert. Ein Schluessel, der dort fehlt,
* aber laut `istEingebaut` existiert, wird leer; alles andere bleibt stehen.
*
* Die Ersatzfunktion bekommt den Wert als **Funktion** und nicht als
* Zeichenkette: `String.replace` deutet `$&`, `` $` ``, `$'` und `$1` im
* Ersatztext als Rueckverweise. Ein Nachname `A$&B` haette sonst den gefundenen
* Platzhalter wieder eingesetzt.
*/
export function platzhalterErsetzen(
text: string,
werte: Readonly<Record<string, string>>,
verwendung: Verwendung = { art: "anzeige" },
): ErsetzungsErgebnis {
const unbekannt = new Set<string>();
let mitPasswort = false;
/*
* Erkannt wird **jede** doppelte Klammer, nicht nur die mit gueltigem
* Schluessel. `{{Hostname}}` blieb sonst stehen **und** wurde nicht gemeldet —
* der Redakteur bekam keine Warnung fuer einen Platzhalter, der nie ersetzt
* wird. Ein falsch geschriebener ist gefaehrlicher als ein unbekannter: er
* sieht richtig aus.
*/
const ergebnis = text.replace(/\{\{\s*([^}]*?)\s*\}\}/gu, (ganzes, roh: string) => {
const schluessel = roh;
if (!/^[a-z][a-z0-9_]*$/u.test(schluessel)) {
// Kein gueltiger Schluessel – stehen lassen und melden.
unbekannt.add(schluessel);
return ganzes as string;
}
if (schluessel === PASSWORT_SCHLUESSEL) {
mitPasswort = true;
// Im Druck steht ein Hinweis statt des Passworts: ein Ausdruck liegt
// herum, und das Passwort ist nirgends gespeichert, also auch nicht
// nachvollziehbar, wenn es dort stuende.
if (verwendung.art === "druck") return PASSWORT_IM_DRUCK;
return aufbereiten(passwortErzeugen(), verwendung);
}
const wert = werte[schluessel];
if (wert !== undefined) return aufbereiten(wert, verwendung);
// Bekannt, aber ohne Wert: leer. Unbekannt: stehen lassen.
if (istEingebaut(schluessel)) return "";
unbekannt.add(schluessel);
return ganzes as string;
});
return { text: ergebnis, unbekannt: [...unbekannt], mitPasswort };
}
function aufbereiten(wert: string, verwendung: Verwendung): string {
switch (verwendung.art) {
case "befehl":
return verwendung.maskieren(wert);
case "anzeige":
case "druck":
// Unveraendert: React maskiert Textknoten selbst, siehe `Verwendung`.
return wert;
}
}
/**
* Wie `platzhalterErsetzen` mit `anzeige` – als sprechender Name fuer Texte, die
* weder in HTML noch in einen Befehl gehen, etwa einen vorgeschlagenen Lauftitel.
*/
export function platzhalterErsetzenRoh(
text: string,
werte: Readonly<Record<string, string>>,
): ErsetzungsErgebnis {
return platzhalterErsetzen(text, werte, { art: "anzeige" });
}
+31
View File
@@ -89,6 +89,37 @@ export function istEingebaut(schluessel: string): boolean {
return EINGEBAUTE_SCHLUESSEL.has(schluessel);
}
/**
* Eingebaute Platzhalter, die eine Vorlagenvariable **nicht** belegen darf.
*
* Es sind die fuenf, die eine Aussage ueber **wer, wann und fuer wen** machen:
*
* - `techniker` und `datum` unterschreiben einen Befehl. Eine Variable dafuer
* hiesse, dass in einem fertigen Befehl ein fremder Name und ein falsches
* Datum stehen koennen, ohne dass es auffaellt.
* - `kunde` und `kundenkuerzel` sind im Modus INTERN ausdruecklich leer (#008).
* Eine Variable haette die Kundenebene in den Befehl zurueckgeholt, die es
* dort nicht gibt.
* - `generiertes_passwort` wird ohnehin vor jeder Zuordnung abgefangen; hier
* steht es, damit niemand eine Variable anlegt, die nie wirkt.
*
* Alles andere bleibt erlaubt, und das ist wichtig: beim Onboarding gibt es die
* Person im Verzeichnis noch nicht, die Vorlage fragt `upn`, `sam` und `mail`
* also selbst ab. Genau dafuer sind Variablen da.
*/
export const GESPERRTE_VARIABLENNAMEN: ReadonlySet<string> = new Set([
"techniker",
"datum",
"kunde",
"kundenkuerzel",
"generiertes_passwort",
]);
/** Darf eine Vorlagenvariable so heissen? */
export function istAlsVariableErlaubt(schluessel: string): boolean {
return !GESPERRTE_VARIABLENNAMEN.has(schluessel);
}
/**
* Die Platzhalter, die weder eingebaut noch als Variable definiert sind.
*
+341
View File
@@ -0,0 +1,341 @@
import { SHELLS } from "@/lib/vorlagen-format";
/**
* Maskieren von Werten fuer eine Shell und die Skript-Kopie (#012).
*
* Hier endet der Weg eines Werts aus dem Verzeichnis oder aus einem
* Eingabefeld: als Teil eines Befehls, den ein Techniker kopiert und auf einem
* Domaenencontroller ausfuehrt. Ein Wert, der die Anfuehrungszeichen des Befehls
* sprengt, fuehrt dort fremden Code aus — mit den Rechten, die der Techniker
* gerade hat, und das sind die hoechsten im Haus.
*
* **Die tragende Annahme, ausgesprochen:** `wertMaskieren` maskiert fuer die
* Einbettung **innerhalb** der Anfuehrungszeichen, die schon in der Vorlage
* stehen — bei PowerShell und Cmd doppelte, bei Bash einfache:
*
* ```
* New-ADUser -Name "{{vorname}} {{nachname}}" → ... -Name "Jörg Müller"
* useradd -c '{{mitarbeiter}}' → ... -c 'O'\''Brien'
* ```
*
* Diese Funktion setzt **keine** Anfuehrungszeichen dazu, weil sie sonst die
* schon vorhandenen verdoppeln wuerde. Steht der Platzhalter in der Vorlage
* nackt da (`-Name {{vorname}}`), kann keine Maskierung der Welt daraus ein
* einzelnes Argument machen; dann schuetzt nur, wer die Vorlage schreibt. Das ist
* eine Annahme und keine Zusage, und sie steht hier, damit niemand sie fuer eine
* Zusage haelt.
*/
export type Shell = (typeof SHELLS)[number];
export class ShellFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "ShellFehler";
}
}
/**
* Zeilenumbruch je Shell.
*
* PowerShell und Cmd leben in der CRLF-Welt: eine `.cmd`-Datei mit reinen
* Zeilenvorschueben bringt `cmd.exe` bei Sprungmarken zum Stolpern. Bash lebt in
* der LF-Welt, und zwar unversoehnlich: ein Wagenruecklauf am Zeilenende landet
* als Zeichen im letzten Argument und macht aus `fi` ein `fi\r`.
*/
export const UMBRUCH: Record<Shell, string> = {
powershell: "\r\n",
bash: "\n",
cmd: "\r\n",
};
/** Wie eine Kommentarzeile beginnt. Cmd kennt kein `#`. */
export const KOMMENTAR_ZEICHEN: Record<Shell, string> = {
powershell: "#",
bash: "#",
cmd: "REM",
};
/**
* Zeichen, die Cmd innerhalb doppelter Anfuehrungszeichen nicht tragen kann.
*
* Siehe `cmdMaskieren` — diese vier werden abgelehnt, statt sie unsicher
* durchzulassen.
*/
const CMD_UNMOEGLICH: readonly (readonly [RegExp, string])[] = [
[/"/, "ein doppeltes Anführungszeichen"],
[/\r/, "einen Wagenrücklauf"],
[/\n/, "einen Zeilenumbruch"],
[/\u0000/, "ein Null-Byte"],
];
/**
* Zeichen, die PowerShell als doppeltes Anfuehrungszeichen liest.
*
* Nicht nur `U+0022`: `about_Quoting_Rules` nennt ausdruecklich auch die
* typografischen Varianten. Gegen PowerShell 7 nachgemessen — `“`, `”` und `„`
* **beenden eine `"…"`-Zeichenkette**, alles danach ist Code.
*
* Das ist kein erfundener Angriff: `„` ist das deutsche Anfuehrungszeichen, und
* Word wie Outlook wandeln ein getipptes `"` von selbst in `“`/`”`. Ein
* Abteilungsname, den jemand aus Word in ein Formular kopiert, reicht.
*/
const PS_DOPPELTE_ANFUEHRUNG = /[\u201C\u201D\u201E]/gu;
/**
* Dieselbe Familie fuer das einfache Anfuehrungszeichen.
*
* Innerhalb von `"…"` sind sie harmlos und werden **nicht** verdoppelt — das
* verfaelschte jeden Namen wie „O’Neill“. Angeglichen werden sie trotzdem:
* benutzt eine Vorlage die andere Form (`'{{vorname}}'`), beenden sie dort die
* Zeichenkette (gemessen gegen PowerShell 7). Dass ein gewoehnliches `'` dieselbe
* Vorlage genauso bricht, faengt `platzhalterAusserhalbAnfuehrungszeichen` ab.
*/
const PS_EINFACHE_ANFUEHRUNG = /[\u2018\u2019\u201A\u201B]/gu;
/**
* Maskiert fuer eine PowerShell-Zeichenkette in doppelten Anfuehrungszeichen.
*
* Innerhalb von `"…"` sind genau drei Zeichen besonders, und alle drei werden
* hier entschaerft:
*
* - `` ` `` ist das Fluchtzeichen und wird verdoppelt. Zuerst, sonst wuerden die
* Fluchtzeichen der naechsten Schritte gleich wieder mitverdoppelt.
* - `"` beendet die Zeichenkette und wird verdoppelt (`""` ist innerhalb von
* `"…"` das literale Anfuehrungszeichen).
* - `$` fuehrt die Ersetzung ein. Das schliesst `$(…)` ein, also die Ausfuehrung:
* aus `$(rm -rf /)` wird `` `$(rm -rf /) `` und damit sichtbarer Text statt
* eines Unterausdrucks.
*
* **Typografische Anfuehrungszeichen werden vorher auf ihre ASCII-Form
* gebracht** und danach wie diese behandelt. Das aendert das Zeichen — aus `”`
* wird `"` —, und das ist Absicht: PowerShell liest es ohnehin als Begrenzer,
* es laesst sich also gar nicht als Text durchreichen. Lieber ein Zeichen, das
* anders aussieht als gedacht, als ein Befehl, der etwas anderes tut als er
* liest.
*
* Zeilenumbrueche werden zu `` `r `` und `` `n ``. Innerhalb von `"…"` waeren sie
* auch unmaskiert unschaedlich — die Zeichenkette laeuft ueber Zeilen weiter —,
* aber der Wert bleibt so in **einer** Zeile, und was in einer Zeile steht,
* ueberlebt jedes Kopieren, jedes Einfuegen in eine Konsole und jede
* Skript-Kopie, die Zeilen einzeln behandelt.
*/
function powershellMaskieren(wert: string): string {
return wert
.replace(PS_DOPPELTE_ANFUEHRUNG, '"')
.replace(PS_EINFACHE_ANFUEHRUNG, "'")
.replaceAll("`", "``")
.replaceAll('"', '""')
.replaceAll("$", "`$")
.replaceAll("\r", "`r")
.replaceAll("\n", "`n");
}
/**
* Maskiert fuer eine Bash-Zeichenkette in einfachen Anfuehrungszeichen.
*
* In `'…'` ist **kein** Zeichen besonders ausser dem einfachen
* Anfuehrungszeichen selbst — kein `$`, kein Backtick, kein `\`, kein
* Zeilenumbruch. Also genuegt der bekannte Kunstgriff: das Anfuehrungszeichen
* schliesst die Zeichenkette, setzt ein maskiertes `\'` daneben und oeffnet
* wieder. Fuer die Shell bleibt das ein zusammenhaengendes Wort.
*/
function bashMaskieren(wert: string): string {
/*
* Zeilenumbrueche bekommen eine eigene Behandlung: `'…'` traegt sie zwar
* woertlich, aber `skriptBauen` bringt danach alle Umbrueche des fertigen
* Befehls auf die Form der Shell — und kann dabei nicht wissen, welcher zum
* Befehl gehoert und welcher aus einem Wert stammt. Aus einem `\r` im Wert
* wurde so ein `\n`; bei CRLF-Werten aus CSV oder LDAP der Normalfall.
*
* `'…'$'\r''…'` ist Bashs eigene Schreibweise dafuer: die Zeichenkette wird
* geschlossen, das Zeichen als Fluchtfolge angehaengt und wieder geoeffnet.
* Fuer die Shell bleibt es ein Wort, und im fertigen Befehl steht kein
* wirklicher Umbruch mehr.
*/
return (
wert
.replaceAll("'", `'\\''`)
/*
* Ersatz**funktionen**, keine Zeichenketten: `$'` ist in `String.replace`
* ein Rueckverweis auf alles hinter dem Treffer. Aus `erste\nzweite` wurde
* damit `erste'zweite\n''zweite` — ein Befehl, der nicht mehr zugeht.
* Derselbe Fehler steckte in `mitBeispielwerten`, gefunden im Review zu #010.
*/
.replaceAll("\r", () => `'$'\\r''`)
.replaceAll("\n", () => `'$'\\n''`)
);
}
/**
* Maskiert fuer eine Cmd-Zeichenkette in doppelten Anfuehrungszeichen — soweit das geht.
*
* **Was hier nicht geht, und zwar grundsaetzlich:**
*
* - Ein `"` im Wert laesst sich nicht maskieren. `\"` ist keine Regel von
* `cmd.exe`, sondern eine Verabredung der C-Laufzeit des aufgerufenen
* Programms; `""` deuten verschiedene Programme verschieden, und `^"` wirkt
* innerhalb von Anfuehrungszeichen nicht. Ein `"` beendet das Argument, und
* alles danach liest `cmd.exe` als Befehl — inklusive `&`. Deshalb wird ein
* solcher Wert **abgelehnt**, mit CR, LF und dem Null-Byte zusammen, die eine
* Cmd-Befehlszeile ebenfalls nicht tragen kann.
* - Ein `%` bleibt stehen und wird von `cmd.exe` **ersetzt**, wenn er einen
* Variablennamen einschliesst: aus `%PATH%` wird der Suchpfad. Innerhalb von
* Anfuehrungszeichen aendert das nichts daran. Verdoppeln waere nur in einer
* Batch-Datei richtig und in einer interaktiven Eingabe falsch, und dieselbe
* Zeichenkette dient hier beidem — es gibt also keine Maskierung, die in
* beiden Faellen stimmt.
*
* **Das ist nicht harmlos**, auch wenn Cmd keine Befehlsersetzung kennt: in
* einer Batch-Datei wird `%VAR%` **vor** der Zerlegung in Befehl und
* Trennzeichen ersetzt. Enthaelt die Variable ein `&` oder ein `"`, wird das
* danach als Trennzeichen bzw. Anfuehrungszeichen gelesen — genau deshalb gibt
* es die verzoegerte Ersetzung `!VAR!` ueberhaupt. Wer einen Wert mit `%`
* erwartet, stellt den Schritt besser auf PowerShell um.
* - Ein `!` bleibt stehen. Er wird nur ersetzt, wenn die verzoegerte Ersetzung
* eingeschaltet ist (`setlocal enabledelayedexpansion`). Die Skript-Kopie
* dieses Moduls schaltet sie nicht ein; eine fremde Umgebung kann es.
*
* Harmlos sind dagegen `&`, `|`, `<`, `>` und `^`: `cmd.exe` behandelt
* Anfuehrungszeichen vor den Trennzeichen, innerhalb von `"…"` sind sie
* gewoehnliche Zeichen. Genau deshalb ist das `"` das einzige, was zaehlt — und
* genau deshalb wird es abgelehnt.
*/
function cmdMaskieren(wert: string): string {
for (const [muster, benennung] of CMD_UNMOEGLICH) {
if (muster.test(wert)) {
throw new ShellFehler(
`Der Wert enthält ${benennung}. In einem Cmd-Befehl lässt sich das nicht sicher maskieren; ` +
"bitte den Schritt auf PowerShell umstellen oder den Wert bereinigen.",
);
}
}
return wert;
}
/**
* Maskiert einen Wert fuer die angegebene Shell.
*
* Erwartet, dass der Platzhalter in der Vorlage in Anfuehrungszeichen steht —
* doppelte bei `powershell` und `cmd`, einfache bei `bash`. Siehe die Anmerkung
* am Kopf dieser Datei; sie ist die einzige Annahme, die dieses Modul nicht
* selbst pruefen kann.
*
* Wirft `ShellFehler`, wenn ein Wert in einem Cmd-Befehl nicht sicher
* darstellbar ist. Ein Fehler an der Stelle ist gewollt: der Techniker sieht
* dann, dass hier etwas nicht geht, statt einen Befehl zu kopieren, der etwas
* anderes tut als er liest.
*/
export function wertMaskieren(wert: string, shell: Shell): string {
switch (shell) {
case "powershell":
return powershellMaskieren(wert);
case "bash":
return bashMaskieren(wert);
case "cmd":
return cmdMaskieren(wert);
}
}
/**
* Platzhalter, die **nicht** zwischen den erwarteten Anfuehrungszeichen stehen.
*
* Die ganze Maskierung ruht auf einer Annahme: `{{name}}` steht in der Vorlage
* in Anfuehrungszeichen — doppelten bei `powershell` und `cmd`, einfachen bei
* `bash`. Steht er nackt da (`Write-Host {{vorname}}`), kann keine Maskierung der
* Welt daraus noch **ein** Argument machen: ein Leerzeichen im Wert zerlegt ihn,
* ein `;` beendet den Befehl. Bis zum Review von #012 hat das niemand geprueft —
* die Annahme stand als Kommentar da und wurde nirgends eingeloest.
*
* Geprueft wird durch schlichtes Durchzaehlen: ein Anfuehrungszeichen oeffnet,
* das naechste gleicher Art schliesst. Das ist grob — Fluchtfolgen innerhalb
* einer Zeichenkette kann es nicht auseinanderhalten — aber es faellt in die
* **sichere** Richtung: im Zweifel meldet es einen Platzhalter, der in Ordnung
* ist, statt einen durchzulassen, der es nicht ist.
*/
export function platzhalterAusserhalbAnfuehrungszeichen(befehl: string, shell: Shell): readonly string[] {
const zeichen = shell === "bash" ? "'" : '"';
const gefunden: string[] = [];
let offen = false;
for (let pos = 0; pos < befehl.length; pos += 1) {
if (befehl.charAt(pos) === zeichen) {
offen = !offen;
continue;
}
if (befehl.startsWith("{{", pos)) {
const ende = befehl.indexOf("}}", pos);
if (ende < 0) break;
const name = befehl.slice(pos + 2, ende).trim();
if (!offen && /^[a-z][a-z0-9_]*$/u.test(name)) gefunden.push(name);
pos = ende + 1;
}
}
return gefunden;
}
export type SkriptSchritt = {
readonly titel: string;
readonly shell: Shell;
readonly befehl: string;
};
/**
* Macht aus einem Titel eine Kommentarzeile, die nicht aufbrechen kann.
*
* Ein Titel mit Zeilenumbruch schiebt seine zweite Haelfte aus dem Kommentar
* heraus und laesst sie als Befehl stehen — aus einem Titel „Konto anlegen“ mit
* angehaengtem Umbruch und `format c:` wuerde eine ausfuehrbare Zeile. Alle
* Steuerzeichen werden deshalb zu Leerzeichen und mehrere Leerzeichen zu einem.
*/
function kommentarTitel(titel: string): string {
const eineZeile = titel
.replace(/[\u0000-\u001F\u007F]+/g, " ")
.replace(/\s{2,}/g, " ")
.trim();
return eineZeile || "(ohne Titel)";
}
/**
* Der Umbruch fuer ein ganzes Skript.
*
* Bei einheitlicher Shell der ihre. Eine gemischte Liste laesst sich ohnehin von
* keinem Interpreter als Ganzes ausfuehren — dort gibt es keine richtige Wahl,
* nur eine weniger schaedliche: LF, weil ein Wagenruecklauf jede Bash-Zeile
* sicher beschaedigt, waehrend PowerShell LF ohne weiteres liest. Fuer Cmd bleibt
* es ein Zugestaendnis.
*/
function umbruchFuer(schritte: readonly SkriptSchritt[]): string {
const ersteShell = schritte[0]?.shell;
if (ersteShell === undefined) return UMBRUCH.bash;
return schritte.every((schritt) => schritt.shell === ersteShell) ? UMBRUCH[ersteShell] : "\n";
}
/**
* Baut aus den Befehlsschritten eines Abschnitts ein Skript zum Kopieren.
*
* Alle Befehle in der uebergebenen Reihenfolge, je mit `# Schritt: <Titel>`
* davor (`REM Schritt: <Titel>` bei Cmd), getrennt durch eine Leerzeile. Die
* Umbrueche **innerhalb** eines mehrzeiligen Befehls werden auf denselben Umbruch
* gebracht: eine CRLF-Datei mit einzelnen Zeilenvorschuechen darin ist keine
* CRLF-Datei.
*
* Schritte ohne Befehl fallen heraus: eine Kommentarzeile mit einer Leerzeile
* darunter und nichts dazwischen hilft niemandem.
*
* Das Skript endet mit einem Umbruch, damit die letzte Zeile auch dann
* ausgefuehrt wird, wenn sie als Datei gespeichert wird.
*/
export function skriptBauen(schritte: readonly SkriptSchritt[]): string {
const mitBefehl = schritte.filter((schritt) => schritt.befehl.trim() !== "");
if (mitBefehl.length === 0) return "";
const umbruch = umbruchFuer(mitBefehl);
const bloecke = mitBefehl.map((schritt) => {
const kopf = `${KOMMENTAR_ZEICHEN[schritt.shell]} Schritt: ${kommentarTitel(schritt.titel)}`;
const befehl = schritt.befehl.trim().replace(/\r\n|\r|\n/g, umbruch);
return kopf + umbruch + befehl;
});
return bloecke.join(umbruch + umbruch) + umbruch;
}
+14 -1
View File
@@ -1,3 +1,4 @@
import { istAlsVariableErlaubt } from "@/lib/platzhalter";
import { z } from "@/lib/validierung";
/**
@@ -119,7 +120,19 @@ export const VariableSchema = z.object({
.regex(
/^[a-z][a-z0-9_]{0,39}$/,
"Ein Variablenschlüssel beginnt mit einem Kleinbuchstaben und besteht aus Kleinbuchstaben, Ziffern und Unterstrich.",
),
)
/*
* Ein eingebauter Name ist als Variable gesperrt – mit einer Ausnahme,
* `hostname` (siehe `UEBERSCHREIBBARE_PLATZHALTER`). Sonst hiesse eine
* Variable `techniker`, und der fertige Befehl unterschriebe mit einem
* Namen, der nicht stimmt. Die Aufloesung wehrt das noch einmal ab; hier
* faellt es dem Redakteur auf, **bevor** er die Vorlage veroeffentlicht.
*/
.refine(istAlsVariableErlaubt, {
message:
"Diesen Namen gibt es schon als eingebauten Platzhalter. Bitte einen anderen wählen — " +
"sonst stünde im fertigen Befehl etwas anderes, als der eingebaute Wert sagt.",
}),
beschriftung: z.string().trim().min(1, "Eine Variable braucht eine Beschriftung.").max(GRENZEN.text),
typ: z.enum(VARIABLEN_TYPEN).default("TEXT"),
optionen: z.array(z.string().trim().min(1).max(GRENZEN.text)).max(GRENZEN.optionen).default([]),
+367
View File
@@ -0,0 +1,367 @@
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 };
+7 -12
View File
@@ -1,7 +1,7 @@
import { z } from "@/lib/validierung";
import type { Prisma } from "@/generated/prisma/client";
import { Betriebsmodus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { NamensregelnSchema, type Namensregeln } from "@/lib/namensregeln";
import { betriebsmodus, eigeneOrganisation } from "@/server/mandant";
import { kuerzelPruefen } from "@/server/services/kunden";
@@ -21,18 +21,13 @@ export class EinstellungsFehler extends Error {
}
/**
* Vorlagen fuer abgeleitete Werte. Die Platzhalter darin (`{vorname}`,
* `{nachname}`, `{v}`, `{nnn}`, `{kundenkuerzel}`) loest #012 auf; hier werden
* sie nur gespeichert und bearbeitet.
* Schema und Typ der Namensregeln liegen seit #012 in `src/lib/namensregeln.ts`,
* zusammen mit der Aufloesung der Platzhalter darin (`{vorname}`, `{nachname}`,
* `{v}`, `{nnn}`, `{kundenkuerzel}`). Hier werden sie nur gespeichert und
* bearbeitet — weitergereicht werden sie, damit die Server Action und das
* Formular ihren Import nicht aendern muessen.
*/
export const NamensregelnSchema = z.object({
upn: z.string().trim().max(200).optional(),
sam: z.string().trim().max(200).optional(),
mail: z.string().trim().max(200).optional(),
hostname: z.string().trim().max(200).optional(),
});
export type Namensregeln = z.infer<typeof NamensregelnSchema>;
export { NamensregelnSchema, type Namensregeln };
export type Einstellungen = {
readonly modus: Betriebsmodus;
+221
View File
@@ -0,0 +1,221 @@
import { Betriebsmodus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { istAlsVariableErlaubt } from "@/lib/platzhalter";
import { betriebsmodus } from "@/server/mandant";
/**
* Die Werte hinter den Platzhaltern (#012).
*
* Diese Datei traegt zusammen, was ein Lauf ueber sich weiss: Geraet,
* Mitarbeiter, Abteilung, Kunde, Vorlagenvariablen. Das **Ersetzen** selbst
* steht framework-frei in `src/lib/platzhalter-werte.ts`, die Liste der
* eingebauten Namen in `src/lib/platzhalter.ts`. Drei Dateien, drei Aufgaben —
* und nur diese eine kennt die Datenbank.
*
* Eine Regel zieht sich durch: **ein fehlender Wert ist leer, kein Fehler.** Ein
* Lauf ohne Mitarbeiter ist gueltig (Server, Firewall, Patchday), und ein
* Befehl mit einem leeren `-Department` faellt beim Lesen auf. Abzubrechen
* hiesse, dem Techniker die Arbeit zu verweigern, weil ein Feld fehlt, das er
* vielleicht gar nicht braucht.
*/
export type PlatzhalterWerte = Readonly<Record<string, string>>;
/**
* Der Eltern-Container eines `dn`.
*
* Aus `CN=Maria Huber,OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local` wird
* `OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local` — alles nach dem ersten Komma.
* Das ist der Ort, an dem das Konto liegt, und genau den braucht ein
* `New-ADUser -Path`.
*
* Ein Komma **innerhalb** eines Namens ist im DN maskiert (`CN=Huber\, Maria`),
* deshalb wird ein Komma hinter einem Backslash nicht als Trenner gelesen.
*/
export function ouAusDn(dn: string): string {
/*
* Gezaehlt statt geraten: `(?<!\\),` uebersieht, dass der Backslash selbst
* maskiert sein kann. In `CN=X\\,OU=Y` ist `\\` ein literaler Backslash im
* Wert (RFC 4514) und das Komma **ist** der Trenner — die Vorausschau hielt
* es faelschlich fuer maskiert, lieferte nichts, und der Aufrufer fiel still
* auf die Ziel-OU der Abteilung zurueck. Das Konto landete in der falschen OU.
*
* Ein Komma ist maskiert, wenn davor **ungerade** viele Backslashes stehen.
*/
for (let pos = 0; pos < dn.length; pos += 1) {
if (dn.charAt(pos) !== ",") continue;
let schraegstriche = 0;
while (dn.charAt(pos - 1 - schraegstriche) === "\\") schraegstriche += 1;
if (schraegstriche % 2 === 0) return dn.slice(pos + 1).trim();
}
return "";
}
function alsText(wert: string | null | undefined): string {
return wert?.trim() ?? "";
}
/** Nur gesetzte Werte aufnehmen – ein leerer Eintrag und ein fehlender sind dasselbe. */
function setzen(ziel: Record<string, string>, schluessel: string, wert: string): void {
if (wert !== "") ziel[schluessel] = wert;
}
export type LaufKontext = {
readonly laufId: string;
/** Wer den Lauf gerade abarbeitet – speist `{{techniker}}`. */
readonly technikerName?: string | null;
/** Fuer `{{datum}}`; hereingereicht, damit Tests nicht an der Uhr haengen. */
readonly jetzt?: Date;
};
const DATUM = new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" });
/**
* Traegt alle Werte eines Laufs zusammen.
*
* Die Reihenfolge ist Absicht: erst Geraet und Lauf, dann Mitarbeiter, dann
* Abteilung, zuletzt die Vorlagenvariablen. Wer weiter hinten steht, gewinnt —
* eine Variable, die der Redakteur `hostname` nennt, ueberschreibt also den
* Hostnamen des Geraets. Das ist richtig so: sie ist die ausdrueckliche Angabe
* dieses Laufs, der Geraetewert nur die Voreinstellung.
*
* `{{ou}}` ist der einzige Wert mit zwei Quellen: der Eltern-Container des `dn`
* gewinnt, weil er sagt, wo das Konto **wirklich** liegt; die Ziel-OU der
* Abteilung ist die Absicht fuer ein Konto, das es noch nicht gibt.
*/
export async function platzhalterWerteDesLaufs(kontext: LaufKontext): Promise<PlatzhalterWerte> {
const lauf = await prisma.lauf.findUnique({
where: { id: kontext.laufId },
select: {
ticket: true,
variablen: true,
mitarbeiterName: true,
kunde: { select: { name: true, kuerzel: true } },
geraet: {
select: { hostname: true, bezeichnung: true, seriennummer: true, inventarnummer: true },
},
mitarbeiter: {
select: {
anzeigename: true,
vorname: true,
nachname: true,
upn: true,
sam: true,
mail: true,
abteilung: true,
titel: true,
dn: true,
quelleId: true,
vorgesetzterExterneId: true,
},
},
abteilung: { select: { name: true, zielOu: true, standardGruppen: true } },
},
});
if (!lauf) return {};
const werte: Record<string, string> = {};
const jetzt = kontext.jetzt ?? new Date();
// --- Geraet und Lauf ---
setzen(werte, "hostname", alsText(lauf.geraet?.hostname ?? lauf.geraet?.bezeichnung));
setzen(werte, "seriennummer", alsText(lauf.geraet?.seriennummer));
setzen(werte, "inventarnummer", alsText(lauf.geraet?.inventarnummer));
setzen(werte, "ticket", alsText(lauf.ticket));
setzen(werte, "datum", DATUM.format(jetzt));
setzen(werte, "techniker", alsText(kontext.technikerName));
/*
* Kunde und Kuerzel gibt es nur im Modus Dienstleistung. Im Modus Intern ist
* die eigene Organisation fuer den Benutzer unsichtbar (#008) — ihren Namen in
* einen Befehl zu schreiben waere eine Auskunft ueber eine Ebene, die es dort
* gar nicht gibt. Die Platzhalter bleiben deshalb leer, nicht unbekannt.
*/
if ((await betriebsmodus()) === Betriebsmodus.DIENSTLEISTUNG) {
setzen(werte, "kunde", alsText(lauf.kunde.name));
setzen(werte, "kundenkuerzel", alsText(lauf.kunde.kuerzel));
}
// --- Mitarbeiter ---
const person = lauf.mitarbeiter;
if (person) {
setzen(werte, "mitarbeiter", alsText(person.anzeigename));
setzen(werte, "benutzer", alsText(person.anzeigename));
setzen(werte, "vorname", alsText(person.vorname));
setzen(werte, "nachname", alsText(person.nachname));
setzen(werte, "upn", alsText(person.upn));
setzen(werte, "sam", alsText(person.sam));
setzen(werte, "mail", alsText(person.mail));
setzen(werte, "abteilung", alsText(person.abteilung));
setzen(werte, "titel", alsText(person.titel));
setzen(werte, "dn", alsText(person.dn));
if (person.dn) setzen(werte, "ou", ouAusDn(person.dn));
const vorgesetzter = await vorgesetztenLesen(person);
if (vorgesetzter) {
setzen(werte, "vorgesetzter", alsText(vorgesetzter.anzeigename));
setzen(werte, "vorgesetzter_sam", alsText(vorgesetzter.sam));
}
} else {
// Beim Onboarding steht die Person noch nicht im Verzeichnis; dann traegt
// der Lauf ihren Namen als Freitext.
setzen(werte, "mitarbeiter", alsText(lauf.mitarbeiterName));
setzen(werte, "benutzer", alsText(lauf.mitarbeiterName));
}
// --- Abteilung ---
if (lauf.abteilung) {
setzen(werte, "abteilung", alsText(lauf.abteilung.name));
setzen(werte, "gruppen", lauf.abteilung.standardGruppen.join(", "));
// Nur, wenn der Mitarbeiter keine bessere Auskunft hatte.
if (werte["ou"] === undefined) setzen(werte, "ou", alsText(lauf.abteilung.zielOu));
}
/*
* --- Vorlagenvariablen ---
*
* Sie kommen zuletzt und gewinnen — aber nur, wo sie duerfen. Eine Variable
* `techniker` oder `datum` liesse sonst einen Befehl etwas unterschreiben, das
* nicht stimmt, und eine Variable `kunde` haette im Modus INTERN die
* Kundenebene zurueck in den Befehl geholt, die es dort nicht gibt.
* Gesperrt sind genau fuenf Namen (siehe `GESPERRTE_VARIABLENNAMEN`); alles
* andere darf eine Vorlage belegen — beim Onboarding gibt es die Person im
* Verzeichnis noch nicht, und `upn`, `sam` und `mail` kommen dann aus dem Lauf.
*/
for (const [schluessel, wert] of Object.entries(variablenLesen(lauf.variablen))) {
if (!istAlsVariableErlaubt(schluessel)) continue;
setzen(werte, schluessel, wert);
}
return werte;
}
/**
* Der Vorgesetzte, ueber `vorgesetzterExterneId` **derselben** Verzeichnisquelle.
*
* Die Kennung ist nur innerhalb einer Quelle eindeutig; ohne diese
* Einschraenkung zoege ein zweites Verzeichnis fremde Treffer herbei.
*/
async function vorgesetztenLesen(person: {
readonly quelleId: string | null;
readonly vorgesetzterExterneId: string | null;
}): Promise<{ anzeigename: string; sam: string | null } | null> {
if (!person.vorgesetzterExterneId || !person.quelleId) return null;
return prisma.mitarbeiter.findFirst({
where: { quelleId: person.quelleId, externeId: person.vorgesetzterExterneId },
select: { anzeigename: true, sam: true },
});
}
/** `Lauf.variablen` ist ein Json-Feld und kann alles enthalten. */
function variablenLesen(roh: unknown): Record<string, string> {
if (typeof roh !== "object" || roh === null || Array.isArray(roh)) return {};
const werte: Record<string, string> = {};
for (const [schluessel, wert] of Object.entries(roh as Record<string, unknown>)) {
// Zahlen und Wahrheitswerte kommen aus den Variablentypen ZAHL und HAKEN;
// alles andere waere ein verunglueckter Eintrag und bleibt draussen.
if (typeof wert === "string") werte[schluessel] = wert;
else if (typeof wert === "number" || typeof wert === "boolean") werte[schluessel] = String(wert);
}
return werte;
}