import 'server-only' /** * Zugriff auf die Umgebungsvariablen - mit Pruefung, aber bewusst faul. * * Warum faul? `next build` importiert Servermodule, ohne dass eine .env mit * echten Schluesseln vorliegen muss (z.B. im Docker-Build). Wuerden wir beim * Import validieren, schluege jeder Build ohne Produktivschluessel fehl. * Stattdessen prueft jeder Zugriff beim ersten Mal und merkt sich das * Ergebnis. Fehlt etwas, kommt eine Meldung, die sagt, was zu tun ist. */ function pflicht(name: string): string { const wert = process.env[name] if (!wert || wert.trim() === '') { throw new Error( `Umgebungsvariable ${name} fehlt. Lege sie in der .env an - ` + `mit "npm run schluessel" erzeugst du passende Werte.`, ) } return wert } function zahl(name: string, standard: number): number { const wert = process.env[name] if (!wert) return standard const n = Number.parseInt(wert, 10) if (Number.isNaN(n)) { throw new Error(`Umgebungsvariable ${name} muss eine Zahl sein, ist aber "${wert}".`) } return n } function schluessel32(name: string): Buffer { const roh = pflicht(name) const buf = Buffer.from(roh, 'base64') if (buf.length !== 32) { throw new Error( `Umgebungsvariable ${name} muss 32 Bytes base64-kodiert sein ` + `(ist ${buf.length} Bytes). Erzeuge sie mit "npm run schluessel".`, ) } return buf } /** Merkt sich das Ergebnis, damit nicht bei jedem Zugriff neu geparst wird. */ function einmal(fn: () => T): () => T { let wert: T let geladen = false return () => { if (!geladen) { wert = fn() geladen = true } return wert } } const holeVerschluesselungsschluessel = einmal((): Map => { const schluessel = new Map() // ENC_KEY_V1, ENC_KEY_V2, ... - alte Versionen bleiben zum Entschluesseln // stehen, verschluesselt wird immer mit der aktiven Version. for (const [name, wert] of Object.entries(process.env)) { const treffer = name.match(/^ENC_KEY_V(\d+)$/) if (!treffer) continue // Eine LEERE Variable ist kein Schluessel. // // docker compose reicht ENC_KEY_V2 immer durch - mit leerem Wert, wenn // in der .env keiner steht. Wuerde hier allein der Name zaehlen, waere // jede Installation ohne zweiten Schluessel kaputt, und zwar mit der // irrefuehrenden Meldung "ENC_KEY_V2 fehlt" - obwohl niemand einen // zweiten Schluessel wollte. if (!wert || wert.trim() === '') continue schluessel.set(Number.parseInt(treffer[1]!, 10), schluessel32(name)) } if (schluessel.size === 0) { throw new Error( 'Kein Verschluesselungsschluessel gefunden. Es muss mindestens ENC_KEY_V1 ' + 'gesetzt sein. Erzeuge ihn mit "npm run schluessel".', ) } return schluessel }) const holeAktiveVersion = einmal((): number => { const version = zahl('ENC_KEY_AKTIV', 1) if (!holeVerschluesselungsschluessel().has(version)) { const vorhanden = [...holeVerschluesselungsschluessel().keys()].sort().join(', ') throw new Error( `ENC_KEY_AKTIV=${version}, aber ENC_KEY_V${version} ist nicht gesetzt oder leer. ` + `Vorhanden sind die Versionen: ${vorhanden}. Beim Rotieren zuerst den ` + `neuen Schluessel eintragen, danach ENC_KEY_AKTIV umstellen.`, ) } return version }) const holeIndexSchluessel = einmal(() => schluessel32('INDEX_KEY')) export const env = { /** Alle bekannten Verschluesselungsschluessel nach Version. */ get verschluesselungsschluessel() { return holeVerschluesselungsschluessel() }, /** Version, mit der neu verschluesselt wird. */ get aktiveSchluesselversion() { return holeAktiveVersion() }, /** Separater HMAC-Schluessel fuer Blindindizes. */ get indexSchluessel() { return holeIndexSchluessel() }, get datenbankUrl() { return pflicht('DATABASE_URL') }, // --- Sitzung und Anmeldung ------------------------------------------------ get sitzungMaxAlterSekunden() { return zahl('SESSION_MAX_AGE', 30 * 60) }, get reauthMaxAlterSekunden() { return zahl('REAUTH_MAX_AGE', 5 * 60) }, get loginMaxVersuche() { return zahl('LOGIN_MAX_VERSUCHE', 5) }, get loginFensterSekunden() { return zahl('LOGIN_FENSTER_SEKUNDEN', 15 * 60) }, get loginSperreSekunden() { return zahl('LOGIN_SPERRE_SEKUNDEN', 15 * 60) }, // --- Objektspeicher ------------------------------------------------------- get speicherTreiber(): 'datei' | 's3' { const wert = process.env.SPEICHER_TREIBER ?? (process.env.S3_ACCESS_KEY_ID ? 's3' : 'datei') return wert === 's3' ? 's3' : 'datei' }, get dateiSpeicherPfad() { return process.env.DATEI_SPEICHER_PFAD ?? './daten/dokumente' }, get s3() { return { endpoint: pflicht('S3_ENDPOINT'), region: process.env.S3_REGION ?? 'eu-central', bucket: pflicht('S3_BUCKET'), accessKeyId: pflicht('S3_ACCESS_KEY_ID'), secretAccessKey: pflicht('S3_SECRET_ACCESS_KEY'), } }, // --- OCR ------------------------------------------------------------------ get tesseractBefehl() { return process.env.TESSERACT_BEFEHL ?? 'tesseract' }, get ocrAktiv() { return process.env.OCR_AKTIV !== 'false' }, // --- Uploads -------------------------------------------------------------- get uploadMaxBytes() { return zahl('UPLOAD_MAX_BYTES', 25 * 1024 * 1024) }, // --- E-Mail --------------------------------------------------------------- get smtpKonfiguriert() { return Boolean(process.env.SMTP_HOST) }, get smtp() { return { host: pflicht('SMTP_HOST'), port: zahl('SMTP_PORT', 587), user: process.env.SMTP_USER, passwort: process.env.SMTP_PASSWORD, absender: process.env.SMTP_FROM ?? 'Betreuungs-Cockpit ', } }, get basisUrl() { return (process.env.AUTH_URL ?? 'http://localhost:3000').replace(/\/$/, '') }, /** * Herkunft und Bereichskennung fuer Passkeys (WebAuthn). * * Beides wird aus AUTH_URL abgeleitet und nicht getrennt konfiguriert - * eine zweite Quelle waere eine zweite Gelegenheit, sie auseinanderlaufen * zu lassen. * * Die "rpId" ist der nackte Hostname ohne Schema und ohne Port. An sie * bindet der Authenticator den Schluessel: Ein Passkey fuer * cockpit.example.de funktioniert auf keiner anderen Adresse - genau das * macht ihn gegen Phishing immun. Umgekehrt heisst das: Wer die Domain * wechselt, muss alle Passkeys neu einrichten. * * Der Browser gibt WebAuthn ausserdem nur in einem sicheren Kontext frei - * also unter https oder auf localhost. Hinter einem Proxy, der auf http * weiterreicht, muss AUTH_URL trotzdem die oeffentliche https-Adresse * nennen; verglichen wird gegen das, was im Browser in der Adresszeile * stand. */ get passkey() { const url = new URL(process.env.AUTH_URL ?? 'http://localhost:3000') return { rpId: url.hostname, rpName: 'Betreuungs-Cockpit', herkunft: url.origin, sicherAufrufbar: url.protocol === 'https:' || url.hostname === 'localhost', } }, get istProduktion() { return process.env.NODE_ENV === 'production' }, } as const