Files
betreuungs-cockpit/src/lib/speicher/datei.ts
T
Florian Wilde 92c78a92ff Speicherfehler erklaeren und die Ablage von selbst pruefen
Beim Ablegen eines Schreibens stand in der Oberflaeche "Das Schreiben
konnte nicht abgelegt werden." Bei einer Fehlersuche am 31.08.2026 lagen
hinter genau diesem Satz nacheinander DREI verschiedene Ursachen: ein
fehlender Ablagebereich, ein Endpunkt, der auf einen fremden Anbieter
zeigte, und eine Verschluesselung, die der Speicher nicht anbietet. Ohne
Zugang zum Serverlog waere die Suche aussichtslos gewesen.

Jetzt uebersetzt src/lib/speicher/fehler.ts die bekannten Faelle in
Saetze, die sagen, was zu tun ist - fehlender Ablagebereich mit Verweis
auf Schritt 6 der Installation, abgelehnte Zugangsdaten mit den Namen der
beiden Variablen, nicht unterstuetzte Verschluesselung mit dem Hinweis auf
S3_SSE, und nicht erreichbarer Speicher mit der Zusicherung, dass die
Eingaben bleiben.

Durchgereicht wird dabei nichts. Fremde Fehlertexte tragen Hostnamen,
Eimernamen und Anfragekennungen - das gehoert ins Log, nicht auf den
Bildschirm eines Betreuers. Uebersetzt werden nur die bekannten Faelle;
alles andere bleibt bei der allgemeinen Meldung.

Dazu eine Bereitschaftspruefung: Sie schreibt eine winzige Datei, liest
sie zurueck und raeumt sie weg - und beantwortet damit erreichbar,
Ablagebereich vorhanden und Schreibrecht auf einmal. Das Ergebnis steht in
/api/gesundheit und als Zeile im Log.

## Warum nicht instrumentation.ts

Der naheliegende Ort waere der Starthaken von Next. Der wird aber auch
fuer die Edge-Laufzeit gebuendelt, und ueber die Treiber kaeme node:path
in ein Buendel, in dem es das nicht gibt - der Build schlaegt fehl. Das
war ausprobiert, nicht vermutet.

Die Pruefung haengt deshalb an der Gesundheitsabfrage. Der Healthcheck
ruft sie ab 40 Sekunden nach dem Start alle 30 Sekunden auf; die erste
Pruefung passiert also von selbst kurz nach dem Hochfahren. Eine
Schonfrist von fuenf Minuten verhindert, dass dabei alle 30 Sekunden eine
Probedatei in den Objektspeicher geht, und gemeldet wird nur bei einer
Aenderung.

Ein gestoerter Speicher fuehrt ausdruecklich NICHT zu 503. Sonst startete
der Healthcheck den Behaelter neu und machte die ganze Akte unerreichbar -
wegen eines Teils, den ein Neustart nicht repariert.

Gegen drei Mutationen geprueft: NotImplemented ohne Textpruefung, alte
Reihenfolge bei der Codeermittlung, Erklaerung nicht benutzt - jede wird
rot.

Refs #44
2026-09-01 16:31:03 +02:00

135 lines
4.9 KiB
TypeScript

import 'server-only'
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { dirname, join, resolve, sep } from 'node:path'
import { env } from '@/lib/env'
import { entschluesselnBinaer, verschluesselnBinaer } from '@/lib/speicher/umschlag'
import {
dateiRoutePfad,
schluesselErfordern,
zugriffTokenErzeugen,
TOKEN_STANDARD_SEKUNDEN,
} from '@/lib/speicher/schluessel'
/**
* Treiber "datei": Ablage in einem Verzeichnis auf dem Server.
*
* Standard in der Entwicklung und fuer kleine Installationen, bei denen
* kein Objektspeicher gewuenscht ist - die Dateien liegen dann auf der
* verschluesselten Platte des eigenen Servers und verlassen sie nie.
*
* Zusaetzlich wird jede Datei anwendungsseitig mit AES-256-GCM
* verschluesselt. Grund ist derselbe wie bei der Feldverschluesselung: ein
* kopiertes Backup oder ein versehentlich falsch gerechtetes Verzeichnis
* gibt sonst Bescheide, Arztbriefe und Kontoauszuege im Klartext preis.
* Der Schluessel liegt in der Umgebung, nicht neben den Daten.
*
* Der Umschlag selbst steht in src/lib/speicher/umschlag.ts - der
* Objektspeicher benutzt denselben.
*/
function wurzel(): string {
return resolve(process.cwd(), env.dateiSpeicherPfad)
}
/**
* Baut den Dateipfad - und prueft danach noch einmal, dass er wirklich
* unterhalb des Ablageverzeichnisses liegt. Der Schluessel ist zwar bereits
* streng geprueft, aber diese zweite Sperre kostet nichts.
*
* Was sie NICHT abfaengt, obwohl hier lange das Gegenteil stand: Symlinks.
* resolve() rechnet nur mit Zeichenketten und folgt keinem Link - dafuer
* braeuchte es realpath(). Ein untergeschobener Symlink im Ablageverzeichnis
* wuerde also nicht auffallen.
*
* Nachgeruestet wird das trotzdem nicht, und zwar aus einem Grund und nicht
* aus Bequemlichkeit: Wer dort einen Symlink anlegen kann, hat bereits
* Schreibrechte im Datenverzeichnis des Servers - dann ist der Umweg ueber
* einen Link die Sorge, die am wenigsten draengt. Der Satz musste
* verschwinden, weil er eine Sicherheit versprach, die es nie gab.
*/
function pfadFuer(schluessel: string): string {
const geprueft = schluesselErfordern(schluessel)
const basis = wurzel()
const ziel = resolve(basis, ...geprueft.split('/'))
if (ziel !== basis && !ziel.startsWith(basis + sep)) {
throw new Error('Der Ablagepfad läge außerhalb des Dokumentenverzeichnisses.')
}
return ziel
}
export async function schreiben(schluessel: string, inhalt: Buffer): Promise<void> {
const pfad = pfadFuer(schluessel)
await mkdir(dirname(pfad), { recursive: true })
await writeFile(pfad, verschluesselnBinaer(inhalt, schluessel), { mode: 0o600 })
}
export async function lesen(schluessel: string): Promise<Buffer> {
const roh = await readFile(pfadFuer(schluessel))
return entschluesselnBinaer(roh, schluessel)
}
export async function entfernen(schluessel: string): Promise<void> {
await rm(pfadFuer(schluessel), { force: true })
}
export async function existiert(schluessel: string): Promise<boolean> {
try {
const angaben = await stat(pfadFuer(schluessel))
return angaben.isFile()
} catch {
return false
}
}
/**
* Zeigt auf die eigene, ebenfalls signierte Route. Diese Route prueft
* zusaetzlich die Berechtigung des angemeldeten Benutzers und schreibt
* einen Audit-Eintrag - ein Link allein oeffnet also nichts.
*/
export async function signierteUrl(
schluessel: string,
sekunden: number = TOKEN_STANDARD_SEKUNDEN,
): Promise<string> {
return dateiRoutePfad(zugriffTokenErzeugen(schluessel, sekunden))
}
// -----------------------------------------------------------------------------
// Bereitschaftspruefung
// -----------------------------------------------------------------------------
//
// Ohne Umschlag und ohne die strenge Schluesselpruefung: Der Probeschluessel
// gehoert zu keiner betreuten Person und faellt durch das Muster.
function probepfad(schluessel: string): string {
const basis = wurzel()
const ziel = resolve(basis, ...schluessel.split('/'))
if (ziel !== basis && !ziel.startsWith(basis + sep)) {
throw new Error('Der Ablagepfad läge außerhalb des Dokumentenverzeichnisses.')
}
return ziel
}
export async function rohSchreiben(schluessel: string, inhalt: Buffer): Promise<void> {
const pfad = probepfad(schluessel)
await mkdir(dirname(pfad), { recursive: true })
await writeFile(pfad, inhalt, { mode: 0o600 })
}
export async function rohLesen(schluessel: string): Promise<Buffer> {
return readFile(probepfad(schluessel))
}
export async function rohEntfernen(schluessel: string): Promise<void> {
await rm(probepfad(schluessel), { force: true })
}
/** Nur fuer Wartungsskripte: das Ablageverzeichnis. */
export function ablageverzeichnis(): string {
return wurzel()
}
/** Sorgt dafuer, dass das Verzeichnis existiert (Start, Tests, Skripte). */
export async function verzeichnisAnlegen(): Promise<void> {
await mkdir(join(wurzel(), 'betreute'), { recursive: true })
}