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
206 lines
7.3 KiB
TypeScript
206 lines
7.3 KiB
TypeScript
import 'server-only'
|
|
import {
|
|
DeleteObjectCommand,
|
|
GetObjectCommand,
|
|
HeadObjectCommand,
|
|
ListObjectsV2Command,
|
|
PutObjectCommand,
|
|
S3Client,
|
|
type ServerSideEncryption,
|
|
} from '@aws-sdk/client-s3'
|
|
import { env } from '@/lib/env'
|
|
import {
|
|
dateiRoutePfad,
|
|
schluesselErfordern,
|
|
zugriffTokenErzeugen,
|
|
TOKEN_STANDARD_SEKUNDEN,
|
|
} from '@/lib/speicher/schluessel'
|
|
import { entschluesselnBinaer, verschluesselnBinaer } from '@/lib/speicher/umschlag'
|
|
|
|
/**
|
|
* Treiber "s3": S3-kompatibler Objektspeicher, betrieben in der EU.
|
|
*
|
|
* Voraussetzung fuer den Einsatz ist ein Auftragsverarbeitungsvertrag nach
|
|
* Art. 28 DSGVO mit dem Anbieter - hier liegen Gesundheits- und
|
|
* Finanzdaten. Ohne diesen Vertrag bleibt es beim Dateitreiber.
|
|
*
|
|
* ## Anwendungsseitige Verschluesselung
|
|
*
|
|
* Frueher stand hier nur ServerSideEncryption: 'AES256'. Das ist
|
|
* Verschluesselung mit dem Schluessel des Anbieters: Er entschluesselt beim
|
|
* Ausliefern selbst. Gegen eine verlorene Festplatte im Rechenzentrum hilft
|
|
* das, gegen den Anbieter und gegen gestohlene Zugangsdaten zum Eimer
|
|
* nicht - und in diesem Eimer liegen Bescheide, Arztbriefe und
|
|
* Kontoauszuege.
|
|
*
|
|
* Deshalb liegt jetzt derselbe Umschlag darum wie beim Dateitreiber, mit
|
|
* einem Schluessel aus der Umgebung.
|
|
*
|
|
* Die serverseitige Verschluesselung ist damit ueberfluessig und wird nur
|
|
* noch gesendet, wenn S3_SSE gesetzt ist. Im Kommentar stand hier einmal,
|
|
* sie "kostet nichts" - das war falsch: Das mitgelieferte MinIO laeuft
|
|
* ohne KMS und lehnte jedes PutObject mit "NotImplemented" ab. Sie kostete
|
|
* die gesamte Funktion.
|
|
*
|
|
* ## Und deshalb keine presigned URLs mehr
|
|
*
|
|
* Eine presigned URL laesst den Browser direkt beim Objektspeicher laden.
|
|
* Was dort liegt, ist jetzt aber Geheimtext - der Browser bekaeme
|
|
* unlesbare Bytes. Die Auslieferung geht darum ueber dieselbe eigene Route
|
|
* wie beim Dateitreiber: Sie prueft zusaetzlich die Berechtigung des
|
|
* angemeldeten Benutzers und schreibt einen Audit-Eintrag. Dass jeder
|
|
* Abruf durch die Anwendung laeuft, ist bei einer Handvoll Dokumenten am
|
|
* Tag kein Thema.
|
|
*/
|
|
|
|
let klient: S3Client | null = null
|
|
|
|
function s3(): S3Client {
|
|
if (klient) return klient
|
|
const k = env.s3
|
|
klient = new S3Client({
|
|
region: k.region,
|
|
endpoint: k.endpoint,
|
|
// Viele S3-kompatible Anbieter (MinIO, Hetzner) brauchen Pfad-Stil.
|
|
forcePathStyle: true,
|
|
credentials: { accessKeyId: k.accessKeyId, secretAccessKey: k.secretAccessKey },
|
|
})
|
|
return klient
|
|
}
|
|
|
|
function eimer(): string {
|
|
return env.s3.bucket
|
|
}
|
|
|
|
/**
|
|
* Legt ein Objekt ab - verschluesselt.
|
|
*
|
|
* Der gemeldete MIME-Typ wird nicht mehr gebraucht: Was hier landet, ist
|
|
* Geheimtext und wird als application/octet-stream ausgezeichnet. Der
|
|
* Parameter bleibt in der Signatur, damit die Fassade beide Treiber gleich
|
|
* aufrufen kann.
|
|
*/
|
|
export async function schreiben(
|
|
schluessel: string,
|
|
inhalt: Buffer,
|
|
_mimeTyp: string,
|
|
): Promise<void> {
|
|
const geprueft = schluesselErfordern(schluessel)
|
|
const sse = env.s3.serverseitigeVerschluesselung
|
|
await s3().send(
|
|
new PutObjectCommand({
|
|
Bucket: eimer(),
|
|
Key: geprueft,
|
|
Body: verschluesselnBinaer(inhalt, geprueft),
|
|
// Bewusst nicht der echte Typ: Was hier liegt, ist Geheimtext. Ein
|
|
// "application/pdf" darauf waere eine Falschauskunft, und der einzige
|
|
// Weg zum Inhalt fuehrt ohnehin durch die Anwendung.
|
|
ContentType: 'application/octet-stream',
|
|
// Auch beim direkten Abruf niemals im Browser darstellen.
|
|
ContentDisposition: 'attachment',
|
|
// Nur mitschicken, wenn konfiguriert - siehe env.s3.
|
|
...(sse ? { ServerSideEncryption: sse as ServerSideEncryption } : {}),
|
|
}),
|
|
)
|
|
}
|
|
|
|
export async function lesen(schluessel: string): Promise<Buffer> {
|
|
const geprueft = schluesselErfordern(schluessel)
|
|
const antwort = await s3().send(new GetObjectCommand({ Bucket: eimer(), Key: geprueft }))
|
|
if (!antwort.Body) throw new Error(`Objekt ${geprueft} ist leer.`)
|
|
const bytes = await antwort.Body.transformToByteArray()
|
|
// Objekte, die vor dieser Aenderung abgelegt wurden, tragen keinen
|
|
// Umschlag und kommen unveraendert zurueck. Der Migrationslauf
|
|
// (npm run speicher:verschluesseln) schreibt sie nach und nach um.
|
|
return entschluesselnBinaer(Buffer.from(bytes), geprueft)
|
|
}
|
|
|
|
export async function entfernen(schluessel: string): Promise<void> {
|
|
const geprueft = schluesselErfordern(schluessel)
|
|
await s3().send(new DeleteObjectCommand({ Bucket: eimer(), Key: geprueft }))
|
|
}
|
|
|
|
export async function existiert(schluessel: string): Promise<boolean> {
|
|
const geprueft = schluesselErfordern(schluessel)
|
|
try {
|
|
await s3().send(new HeadObjectCommand({ Bucket: eimer(), Key: geprueft }))
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Zeigt auf die eigene, signierte Route - nicht mehr auf den
|
|
* Objektspeicher.
|
|
*
|
|
* Der direkte Weg zum Anbieter lieferte den Rohinhalt aus. Solange der
|
|
* Klartext war, ging das; jetzt ist es Geheimtext, und entschluesseln kann
|
|
* nur die Anwendung. Der Nebeneffekt ist erwuenscht: Die Route prueft die
|
|
* Berechtigung des angemeldeten Benutzers und schreibt einen Audit-Eintrag.
|
|
* Ein weitergeleiteter Link allein oeffnet also nichts mehr.
|
|
*/
|
|
export async function signierteUrl(
|
|
schluessel: string,
|
|
sekunden: number = TOKEN_STANDARD_SEKUNDEN,
|
|
): Promise<string> {
|
|
const geprueft = schluesselErfordern(schluessel)
|
|
return dateiRoutePfad(zugriffTokenErzeugen(geprueft, sekunden))
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Wartung
|
|
// -----------------------------------------------------------------------------
|
|
//
|
|
// Nur fuer den Migrationslauf, der bestehende Objekte nachtraeglich in den
|
|
// Umschlag steckt. Bewusst hier und nicht im Skript: Der Zugang zum Eimer
|
|
// wird an genau einer Stelle aufgebaut.
|
|
|
|
/** Alle Ablageschluessel im Eimer, seitenweise geholt. */
|
|
export async function objekteAuflisten(): Promise<string[]> {
|
|
const gefunden: string[] = []
|
|
let weiter: string | undefined
|
|
|
|
do {
|
|
const antwort = await s3().send(
|
|
new ListObjectsV2Command({ Bucket: eimer(), ContinuationToken: weiter }),
|
|
)
|
|
for (const eintrag of antwort.Contents ?? []) {
|
|
if (eintrag.Key) gefunden.push(eintrag.Key)
|
|
}
|
|
weiter = antwort.IsTruncated ? antwort.NextContinuationToken : undefined
|
|
} while (weiter)
|
|
|
|
return gefunden
|
|
}
|
|
|
|
/**
|
|
* Legt Bytes ohne Umschlag ab. Nur fuer die Bereitschaftspruefung.
|
|
*
|
|
* Bewusst ohne schluesselErfordern(): Der Probeschluessel gehoert zu
|
|
* keiner betreuten Person und faellt durch das Muster.
|
|
*/
|
|
export async function rohSchreiben(schluessel: string, inhalt: Buffer): Promise<void> {
|
|
await s3().send(
|
|
new PutObjectCommand({
|
|
Bucket: eimer(),
|
|
Key: schluessel,
|
|
Body: inhalt,
|
|
ContentType: 'application/octet-stream',
|
|
ContentDisposition: 'attachment',
|
|
}),
|
|
)
|
|
}
|
|
|
|
/** Raeumt die Probedatei wieder weg. */
|
|
export async function rohEntfernen(schluessel: string): Promise<void> {
|
|
await s3().send(new DeleteObjectCommand({ Bucket: eimer(), Key: schluessel }))
|
|
}
|
|
|
|
/** Der Inhalt, wie er im Eimer liegt - ohne den Umschlag abzunehmen. */
|
|
export async function rohLesen(schluessel: string): Promise<Buffer> {
|
|
const antwort = await s3().send(new GetObjectCommand({ Bucket: eimer(), Key: schluessel }))
|
|
if (!antwort.Body) throw new Error(`Objekt ${schluessel} ist leer.`)
|
|
return Buffer.from(await antwort.Body.transformToByteArray())
|
|
}
|