feat: Stammdaten, Ersteinrichtung, Zweitfaktor und Protokollansicht
Damit ist Etappe 1 fachlich vollstaendig. Ersteinrichtung in drei Schritten, bewusst ausserhalb des Anwendungsrahmens: der setzt Benutzer, zweiten Faktor UND eine betreute Person voraus - beim allerersten Start gibt es nichts davon. Die Aktion fuer den ersten Zugang sperrt sich selbst, sobald ein Benutzer existiert. Zweiter Faktor: Das Geheimnis wird zuerst nur abgelegt, ohne totpAktiviertAm. Erst ein echter Code aus der App schaltet ihn scharf. Wer sich beim Abtippen vertut oder die falsche App nimmt, wuerde sich sonst dauerhaft aussperren. Erst danach gibt es die Wiederherstellungscodes - einmal angezeigt, danach nur noch als Hash. Betreuungsjahr (src/lib/stammdaten/betreuungsjahr.ts): laeuft ab dem Tag der Bestellung, nicht ab dem Kalenderjahr. Schrittweise vorwaerts gerechnet statt ueber eine Differenz, damit Schaltjahre und der 29. Februar von selbst stimmen. Daran haengen Jahresbericht, Rechnungslegung und die Ausschlussfrist der Aufwandspauschale. Aendert sich das Bestellungsdatum, werden die Pflichtfristen neu abgeleitet - ueber einen dynamischen Import, damit die Stammdaten auch dann speicherbar bleiben, wenn das Fristenmodul fehlt. Protokollansicht zeigt je Eintrag die geaenderten Felder als alt -> neu. Die Aenderungen liegen verschluesselt, weil dort Diagnosen und Kontostaende stehen koennen. Seed legt bewusst keine echten Personendaten an, nur den ersten Zugang. Mit SEED_DEMO=true kommt ein vollstaendiger Beispielfall dazu - Erika Musterfrau, COPD GOLD III, Pflegegrad 3, Heimeinzug Januar 2026, leerstehendes Haus, zwei Versicherungen, laufender Antrag beim Bezirk mit acht Nachforderungen. Nur ausserhalb der Produktion. Docker Compose ohne Reverse Proxy - der ist auf dem Zielserver vorhanden. Die Datenbank hat keine Portfreigabe und ist nur ueber das interne Netz erreichbar; die Anwendung horcht auf 127.0.0.1. Etappe: 1 Phase: Fundament
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
import 'server-only'
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
|
||||
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join, resolve, sep } from 'node:path'
|
||||
import { env } from '@/lib/env'
|
||||
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.
|
||||
*
|
||||
* Umschlag (binaer):
|
||||
* "BCD1" | Schluesselversion (uint32 BE) | IV (12) | AuthTag (16) | Geheimtext
|
||||
*/
|
||||
|
||||
const MAGIE = Buffer.from('BCD1', 'ascii')
|
||||
const IV_LAENGE = 12
|
||||
const TAG_LAENGE = 16
|
||||
const KOPF_LAENGE = MAGIE.length + 4 + IV_LAENGE + TAG_LAENGE
|
||||
|
||||
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 und faengt auch
|
||||
* Symlink-Tricks und Windows-Eigenheiten ab.
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
function verschluesselnBinaer(inhalt: Buffer): Buffer {
|
||||
const version = env.aktiveSchluesselversion
|
||||
const schluessel = env.verschluesselungsschluessel.get(version)
|
||||
if (!schluessel) throw new Error(`Verschlüsselungsschlüssel Version ${version} fehlt.`)
|
||||
|
||||
const iv = randomBytes(IV_LAENGE)
|
||||
const cipher = createCipheriv('aes-256-gcm', schluessel, iv, { authTagLength: TAG_LAENGE })
|
||||
cipher.setAAD(Buffer.from(`bcd:${version}`, 'utf8'))
|
||||
const geheim = Buffer.concat([cipher.update(inhalt), cipher.final()])
|
||||
|
||||
const versionBytes = Buffer.alloc(4)
|
||||
versionBytes.writeUInt32BE(version, 0)
|
||||
|
||||
return Buffer.concat([MAGIE, versionBytes, iv, cipher.getAuthTag(), geheim])
|
||||
}
|
||||
|
||||
function entschluesselnBinaer(roh: Buffer): Buffer {
|
||||
// Dateien ohne Umschlag (von Hand hineingelegt, aus einer aelteren
|
||||
// Installation uebernommen) werden unveraendert durchgereicht.
|
||||
if (roh.length < KOPF_LAENGE || !roh.subarray(0, MAGIE.length).equals(MAGIE)) return roh
|
||||
|
||||
const version = roh.readUInt32BE(MAGIE.length)
|
||||
const schluessel = env.verschluesselungsschluessel.get(version)
|
||||
if (!schluessel) {
|
||||
throw new Error(
|
||||
`Zum Entschlüsseln dieser Datei fehlt ENC_KEY_V${version}. Alte Schlüssel dürfen beim ` +
|
||||
`Rotieren nicht aus der Umgebung entfernt werden.`,
|
||||
)
|
||||
}
|
||||
|
||||
const ivVon = MAGIE.length + 4
|
||||
const iv = roh.subarray(ivVon, ivVon + IV_LAENGE)
|
||||
const tag = roh.subarray(ivVon + IV_LAENGE, ivVon + IV_LAENGE + TAG_LAENGE)
|
||||
const geheim = roh.subarray(KOPF_LAENGE)
|
||||
|
||||
const decipher = createDecipheriv('aes-256-gcm', schluessel, iv, { authTagLength: TAG_LAENGE })
|
||||
decipher.setAAD(Buffer.from(`bcd:${version}`, 'utf8'))
|
||||
decipher.setAuthTag(tag)
|
||||
|
||||
return Buffer.concat([decipher.update(geheim), decipher.final()])
|
||||
}
|
||||
|
||||
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), { mode: 0o600 })
|
||||
}
|
||||
|
||||
export async function lesen(schluessel: string): Promise<Buffer> {
|
||||
const roh = await readFile(pfadFuer(schluessel))
|
||||
return entschluesselnBinaer(roh)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
/** 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 })
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Dateityp-Erkennung an den Magic Bytes.
|
||||
*
|
||||
* Warum nicht am gemeldeten MIME-Typ oder an der Endung? Weil beides vom
|
||||
* Browser kommt und damit vom Angreifer bestimmt wird. Eine Datei, die
|
||||
* "rechnung.pdf" heisst und `application/pdf` meldet, kann eine HTML-Seite
|
||||
* mit Skript sein. Liefert der Server sie spaeter aus, laeuft das Skript im
|
||||
* Kontext dieser Anwendung - und diese Anwendung sieht Gesundheits- und
|
||||
* Finanzdaten einer Person, die sich nicht wehren kann.
|
||||
*
|
||||
* Deshalb: Der Inhalt entscheidet. Gemeldeter Typ und Endung sind Indizien,
|
||||
* mehr nicht. Bewusst ohne zusaetzliches Paket - die Signaturen der sechs
|
||||
* erlaubten Formate sind kurz und aendern sich nicht.
|
||||
*
|
||||
* Rein und ohne Seiteneffekte, damit es sich testen laesst.
|
||||
*/
|
||||
|
||||
export type ErlaubterMimeTyp =
|
||||
| 'application/pdf'
|
||||
| 'image/jpeg'
|
||||
| 'image/png'
|
||||
| 'image/webp'
|
||||
| 'image/tiff'
|
||||
| 'image/heic'
|
||||
|
||||
export type Dateityp = {
|
||||
mimeTyp: ErlaubterMimeTyp
|
||||
/** Endung ohne Punkt, kleingeschrieben. Wird an den Schluessel gehaengt. */
|
||||
endung: string
|
||||
bezeichnung: string
|
||||
/** Kann Tesseract das direkt lesen, oder muss vorher gewandelt werden? */
|
||||
istBild: boolean
|
||||
}
|
||||
|
||||
export const ERLAUBTE_TYPEN: readonly Dateityp[] = [
|
||||
{ mimeTyp: 'application/pdf', endung: 'pdf', bezeichnung: 'PDF', istBild: false },
|
||||
{ mimeTyp: 'image/jpeg', endung: 'jpg', bezeichnung: 'JPEG-Bild', istBild: true },
|
||||
{ mimeTyp: 'image/png', endung: 'png', bezeichnung: 'PNG-Bild', istBild: true },
|
||||
{ mimeTyp: 'image/webp', endung: 'webp', bezeichnung: 'WEBP-Bild', istBild: true },
|
||||
{ mimeTyp: 'image/tiff', endung: 'tif', bezeichnung: 'TIFF-Bild', istBild: true },
|
||||
{ mimeTyp: 'image/heic', endung: 'heic', bezeichnung: 'HEIC-Foto', istBild: true },
|
||||
]
|
||||
|
||||
/** Fuer Oberflaechentexte und das accept-Attribut im Datei-Auswahlfeld. */
|
||||
export const ERLAUBTE_ENDUNGEN = ERLAUBTE_TYPEN.map((t) => `.${t.endung}`).concat(['.jpeg', '.tiff'])
|
||||
|
||||
export const ACCEPT_ATTRIBUT = [
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/tiff',
|
||||
'image/heic',
|
||||
...ERLAUBTE_ENDUNGEN,
|
||||
].join(',')
|
||||
|
||||
function typ(mimeTyp: ErlaubterMimeTyp): Dateityp {
|
||||
const treffer = ERLAUBTE_TYPEN.find((t) => t.mimeTyp === mimeTyp)
|
||||
if (!treffer) throw new Error(`Unbekannter Typ ${mimeTyp}`)
|
||||
return treffer
|
||||
}
|
||||
|
||||
function beginntMit(inhalt: Buffer, bytes: readonly number[], versatz = 0): boolean {
|
||||
if (inhalt.length < versatz + bytes.length) return false
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
if (inhalt[versatz + i] !== bytes[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function textAn(inhalt: Buffer, von: number, bis: number): string {
|
||||
if (inhalt.length < bis) return ''
|
||||
return inhalt.subarray(von, bis).toString('latin1')
|
||||
}
|
||||
|
||||
/**
|
||||
* HEIC/HEIF steckt in einem ISO-BMFF-Container: vier Byte Boxlaenge, dann
|
||||
* "ftyp", dann die Marke. Nur die Bildmarken sind erlaubt - "mp42" waere
|
||||
* ein Video und hat hier nichts verloren.
|
||||
*/
|
||||
const HEIC_MARKEN = new Set([
|
||||
'heic',
|
||||
'heix',
|
||||
'heim',
|
||||
'heis',
|
||||
'hevc',
|
||||
'hevx',
|
||||
'hevm',
|
||||
'hevs',
|
||||
'mif1',
|
||||
'msf1',
|
||||
])
|
||||
|
||||
/**
|
||||
* Erkennt den Typ am Inhalt. Gibt null zurueck, wenn es keiner der sechs
|
||||
* erlaubten Typen ist - im Zweifel abgelehnt, nie "wird schon passen".
|
||||
*/
|
||||
export function typErkennen(inhalt: Buffer): Dateityp | null {
|
||||
if (inhalt.length < 12) return null
|
||||
|
||||
// PDF: "%PDF-" muss ganz am Anfang stehen. Toleranz waere hier gefaehrlich:
|
||||
// Dateien, die erst nach ein paar Bytes mit %PDF beginnen, sind der
|
||||
// klassische Polyglot-Trick (HTML davor, PDF dahinter).
|
||||
if (beginntMit(inhalt, [0x25, 0x50, 0x44, 0x46, 0x2d])) return typ('application/pdf')
|
||||
|
||||
// JPEG: FF D8 FF
|
||||
if (beginntMit(inhalt, [0xff, 0xd8, 0xff])) return typ('image/jpeg')
|
||||
|
||||
// PNG: 89 "PNG" CR LF 1A LF
|
||||
if (beginntMit(inhalt, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return typ('image/png')
|
||||
|
||||
// WEBP: "RIFF" ....(Groesse).... "WEBP"
|
||||
if (textAn(inhalt, 0, 4) === 'RIFF' && textAn(inhalt, 8, 12) === 'WEBP') return typ('image/webp')
|
||||
|
||||
// TIFF: "II*\0" (little endian) oder "MM\0*" (big endian)
|
||||
if (beginntMit(inhalt, [0x49, 0x49, 0x2a, 0x00])) return typ('image/tiff')
|
||||
if (beginntMit(inhalt, [0x4d, 0x4d, 0x00, 0x2a])) return typ('image/tiff')
|
||||
|
||||
// HEIC: ftyp-Box mit Bildmarke
|
||||
if (textAn(inhalt, 4, 8) === 'ftyp' && HEIC_MARKEN.has(textAn(inhalt, 8, 12))) {
|
||||
return typ('image/heic')
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Beschreibt, was stattdessen erkannt wurde - damit die Fehlermeldung nicht
|
||||
* bei "Dateityp nicht erlaubt" stehen bleibt, sondern sagt, was los ist.
|
||||
* Nur fuer Meldungen, nie als Grundlage einer Entscheidung.
|
||||
*/
|
||||
export function inhaltBeschreiben(inhalt: Buffer): string {
|
||||
const anfang = inhalt.subarray(0, 512).toString('latin1').trimStart().toLowerCase()
|
||||
|
||||
if (anfang.startsWith('<!doctype html') || anfang.startsWith('<html') || anfang.includes('<script'))
|
||||
return 'eine HTML-Seite'
|
||||
if (anfang.startsWith('<?xml') && anfang.includes('<svg')) return 'eine SVG-Grafik'
|
||||
if (anfang.startsWith('<svg')) return 'eine SVG-Grafik'
|
||||
if (anfang.startsWith('<?xml')) return 'eine XML-Datei'
|
||||
if (anfang.startsWith('{') || anfang.startsWith('[')) return 'eine JSON-Datei'
|
||||
if (beginntMit(inhalt, [0x50, 0x4b, 0x03, 0x04])) return 'ein ZIP-Archiv (auch DOCX/XLSX/ODT)'
|
||||
if (beginntMit(inhalt, [0xd0, 0xcf, 0x11, 0xe0])) return 'ein altes Office-Dokument (DOC/XLS)'
|
||||
if (beginntMit(inhalt, [0x4d, 0x5a])) return 'ein Windows-Programm'
|
||||
if (beginntMit(inhalt, [0x7f, 0x45, 0x4c, 0x46])) return 'ein Linux-Programm'
|
||||
if (beginntMit(inhalt, [0x1f, 0x8b])) return 'ein GZIP-Archiv'
|
||||
if (beginntMit(inhalt, [0x47, 0x49, 0x46, 0x38])) return 'ein GIF-Bild'
|
||||
if (inhalt.length === 0) return 'eine leere Datei'
|
||||
|
||||
return 'ein unbekanntes Format'
|
||||
}
|
||||
|
||||
/**
|
||||
* Passt die vom Browser gemeldete Endung zum erkannten Inhalt? Nur ein
|
||||
* Hinweis fuer die Oberflaeche ("die Datei heisst .pdf, ist aber ein Foto"),
|
||||
* kein Ablehnungsgrund - abgelehnt wird nur, was inhaltlich nicht passt.
|
||||
*/
|
||||
export function endungPasst(dateiname: string, erkannt: Dateityp): boolean {
|
||||
const punkt = dateiname.lastIndexOf('.')
|
||||
if (punkt < 0) return false
|
||||
const endung = dateiname.slice(punkt + 1).toLowerCase()
|
||||
if (endung === erkannt.endung) return true
|
||||
if (erkannt.endung === 'jpg' && endung === 'jpeg') return true
|
||||
if (erkannt.endung === 'tif' && endung === 'tiff') return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** Menschenlesbare Groesse fuer Meldungen und Listen. */
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import 'server-only'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { env } from '@/lib/env'
|
||||
import {
|
||||
ERLAUBTE_TYPEN,
|
||||
formatBytes,
|
||||
inhaltBeschreiben,
|
||||
typErkennen,
|
||||
type Dateityp,
|
||||
} from '@/lib/speicher/dateityp'
|
||||
import {
|
||||
betreuteIdAusSchluessel,
|
||||
schluesselErfordern,
|
||||
schluesselErzeugen,
|
||||
schluesselGueltig,
|
||||
TOKEN_STANDARD_SEKUNDEN,
|
||||
type Bereich,
|
||||
} from '@/lib/speicher/schluessel'
|
||||
import * as dateiTreiber from '@/lib/speicher/datei'
|
||||
|
||||
/**
|
||||
* Dateiablage - die Fassade, hinter der die beiden Treiber stecken.
|
||||
*
|
||||
* Vertrag: docs/SCHNITTSTELLEN.md, Abschnitt 1. Andere Module (Berichte,
|
||||
* Exporte, Belegmappen) rufen ausschliesslich diese Funktionen auf.
|
||||
*
|
||||
* Die drei Zusagen, auf die sich alles verlaesst:
|
||||
* - Der Schluessel wird erzeugt, nie vom Nutzer bestimmt.
|
||||
* - Der Typ wird am Inhalt geprueft, nicht am gemeldeten MIME-Typ.
|
||||
* - Jede Auslieferung ist ein Anhang, niemals HTML.
|
||||
*/
|
||||
|
||||
export type Ablageziel = {
|
||||
/** Schluessel im Speicher, z.B. "betreute/<id>/dokumente/<uuid>.pdf" */
|
||||
schluessel: string
|
||||
groesseBytes: number
|
||||
/** SHA-256 hex - Nachweis der Unveraendertheit gegenueber Gericht. */
|
||||
pruefsumme: string
|
||||
}
|
||||
|
||||
/** Zusatzangaben, die der Aufrufer nach dem Ablegen meist braucht. */
|
||||
export type AblageErgebnis = Ablageziel & {
|
||||
/** Der tatsaechlich erkannte Typ - nicht der gemeldete. */
|
||||
typ: Dateityp
|
||||
/** Bereinigter Originalname, nur zur Anzeige. */
|
||||
dateiname: string
|
||||
}
|
||||
|
||||
export class DateiAbgelehnt extends Error {
|
||||
readonly grund: 'zu_gross' | 'leer' | 'typ'
|
||||
constructor(grund: 'zu_gross' | 'leer' | 'typ', nachricht: string) {
|
||||
super(nachricht)
|
||||
this.name = 'DateiAbgelehnt'
|
||||
this.grund = grund
|
||||
}
|
||||
}
|
||||
|
||||
function treiber() {
|
||||
return env.speicherTreiber
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Anzeigename. Er landet nur in der Datenbank, nie im Pfad - trotzdem
|
||||
* werden Pfadanteile und Steuerzeichen entfernt, damit er sich gefahrlos
|
||||
* in einem Content-Disposition-Kopf und in der Oberflaeche darstellen laesst.
|
||||
*/
|
||||
export function dateinamenBereinigen(roh: string): string {
|
||||
const ohnePfad = roh.split(/[\\/]/).pop() ?? roh
|
||||
const sauber = ohnePfad
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[\u0000-\u001f\u007f"\\]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
return sauber.slice(0, 180) || 'dokument'
|
||||
}
|
||||
|
||||
/** SHA-256 hex ueber den Klartextinhalt - der Nachweis fuer das Gericht. */
|
||||
export function pruefsummeBilden(inhalt: Buffer): string {
|
||||
return createHash('sha256').update(inhalt).digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft Groesse und Typ, ohne etwas abzulegen. Wird auch von der
|
||||
* Sammel-Upload-Vorschau benutzt, damit der Nutzer die Ablehnung sieht,
|
||||
* bevor zehn Fotos hochgeladen sind.
|
||||
*/
|
||||
export function dateiPruefen(inhalt: Buffer, dateiname: string): Dateityp {
|
||||
if (inhalt.length === 0) {
|
||||
throw new DateiAbgelehnt('leer', `„${dateiname}“ ist leer und wurde nicht übernommen.`)
|
||||
}
|
||||
if (inhalt.length > env.uploadMaxBytes) {
|
||||
throw new DateiAbgelehnt(
|
||||
'zu_gross',
|
||||
`„${dateiname}“ ist ${formatBytes(inhalt.length)} groß. Erlaubt sind höchstens ` +
|
||||
`${formatBytes(env.uploadMaxBytes)}. Fotografiere das Blatt mit weniger Auflösung ` +
|
||||
`oder lade das PDF seitenweise hoch.`,
|
||||
)
|
||||
}
|
||||
|
||||
const erkannt = typErkennen(inhalt)
|
||||
if (!erkannt) {
|
||||
throw new DateiAbgelehnt(
|
||||
'typ',
|
||||
`„${dateiname}“ ist ${inhaltBeschreiben(inhalt)}, kein Dokument. Erlaubt sind ` +
|
||||
`${ERLAUBTE_TYPEN.map((t) => t.bezeichnung).join(', ')}.`,
|
||||
)
|
||||
}
|
||||
return erkannt
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt eine Datei ab. Der Schluessel wird erzeugt, nie vom Nutzer bestimmt.
|
||||
*/
|
||||
export async function ablegen(
|
||||
betreuteId: string,
|
||||
bereich: Bereich,
|
||||
dateiname: string,
|
||||
inhalt: Buffer,
|
||||
mimeTyp: string,
|
||||
): Promise<Ablageziel> {
|
||||
return ablegenMitTyp(betreuteId, bereich, dateiname, inhalt, mimeTyp)
|
||||
}
|
||||
|
||||
/** Wie ablegen(), gibt aber zusaetzlich den erkannten Typ zurueck. */
|
||||
export async function ablegenMitTyp(
|
||||
betreuteId: string,
|
||||
bereich: Bereich,
|
||||
dateiname: string,
|
||||
inhalt: Buffer,
|
||||
_gemeldeterMimeTyp: string,
|
||||
): Promise<AblageErgebnis> {
|
||||
// Der gemeldete MIME-Typ wird bewusst ignoriert: er kommt vom Browser.
|
||||
const typ = dateiPruefen(inhalt, dateiname)
|
||||
const schluessel = schluesselErzeugen(betreuteId, bereich, typ.endung)
|
||||
|
||||
if (treiber() === 's3') {
|
||||
const s3 = await import('@/lib/speicher/s3')
|
||||
await s3.schreiben(schluessel, inhalt, typ.mimeTyp)
|
||||
} else {
|
||||
await dateiTreiber.schreiben(schluessel, inhalt)
|
||||
}
|
||||
|
||||
return {
|
||||
schluessel,
|
||||
groesseBytes: inhalt.length,
|
||||
pruefsumme: pruefsummeBilden(inhalt),
|
||||
typ,
|
||||
dateiname: dateinamenBereinigen(dateiname),
|
||||
}
|
||||
}
|
||||
|
||||
/** Holt eine Datei zurueck in den Speicher des Servers. */
|
||||
export async function lesen(schluessel: string): Promise<Buffer> {
|
||||
schluesselErfordern(schluessel)
|
||||
if (treiber() === 's3') {
|
||||
const s3 = await import('@/lib/speicher/s3')
|
||||
return s3.lesen(schluessel)
|
||||
}
|
||||
return dateiTreiber.lesen(schluessel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Kurzlebige, signierte URL. Gueltigkeit in Sekunden, Standard 300.
|
||||
* Beim Dateitreiber zeigt sie auf eine eigene, ebenfalls signierte Route.
|
||||
*/
|
||||
export async function signierteUrl(
|
||||
schluessel: string,
|
||||
sekunden: number = TOKEN_STANDARD_SEKUNDEN,
|
||||
): Promise<string> {
|
||||
schluesselErfordern(schluessel)
|
||||
if (treiber() === 's3') {
|
||||
const s3 = await import('@/lib/speicher/s3')
|
||||
return s3.signierteUrl(schluessel, sekunden)
|
||||
}
|
||||
return dateiTreiber.signierteUrl(schluessel, sekunden)
|
||||
}
|
||||
|
||||
export async function entfernen(schluessel: string): Promise<void> {
|
||||
schluesselErfordern(schluessel)
|
||||
if (treiber() === 's3') {
|
||||
const s3 = await import('@/lib/speicher/s3')
|
||||
return s3.entfernen(schluessel)
|
||||
}
|
||||
return dateiTreiber.entfernen(schluessel)
|
||||
}
|
||||
|
||||
export async function existiert(schluessel: string): Promise<boolean> {
|
||||
if (!schluesselGueltig(schluessel)) return false
|
||||
if (treiber() === 's3') {
|
||||
const s3 = await import('@/lib/speicher/s3')
|
||||
return s3.existiert(schluessel)
|
||||
}
|
||||
return dateiTreiber.existiert(schluessel)
|
||||
}
|
||||
|
||||
export {
|
||||
betreuteIdAusSchluessel,
|
||||
schluesselGueltig,
|
||||
schluesselErzeugen,
|
||||
schluesselErfordern,
|
||||
type Bereich,
|
||||
}
|
||||
export { ERLAUBTE_TYPEN, typErkennen, formatBytes, type Dateityp } from '@/lib/speicher/dateityp'
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'server-only'
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3'
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
|
||||
import { env } from '@/lib/env'
|
||||
import { schluesselErfordern, TOKEN_STANDARD_SEKUNDEN } from '@/lib/speicher/schluessel'
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Ausgeliefert wird nur ueber kurzlebige presigned URLs. Sie tragen
|
||||
* Content-Disposition: attachment und einen festen Content-Type, damit der
|
||||
* Objektspeicher eine hochgeladene Datei niemals als HTML im Browser
|
||||
* rendern kann.
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export async function schreiben(
|
||||
schluessel: string,
|
||||
inhalt: Buffer,
|
||||
mimeTyp: string,
|
||||
): Promise<void> {
|
||||
const geprueft = schluesselErfordern(schluessel)
|
||||
await s3().send(
|
||||
new PutObjectCommand({
|
||||
Bucket: eimer(),
|
||||
Key: geprueft,
|
||||
Body: inhalt,
|
||||
ContentType: mimeTyp,
|
||||
// Auch beim direkten Abruf niemals im Browser darstellen.
|
||||
ContentDisposition: 'attachment',
|
||||
// Serverseitige Verschluesselung, wo der Anbieter sie unterstuetzt.
|
||||
ServerSideEncryption: 'AES256',
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
return Buffer.from(bytes)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
export async function signierteUrl(
|
||||
schluessel: string,
|
||||
sekunden: number = TOKEN_STANDARD_SEKUNDEN,
|
||||
): Promise<string> {
|
||||
const geprueft = schluesselErfordern(schluessel)
|
||||
return getSignedUrl(
|
||||
s3(),
|
||||
new GetObjectCommand({
|
||||
Bucket: eimer(),
|
||||
Key: geprueft,
|
||||
// Die Antwort des Objektspeichers wird hier festgenagelt: Anhang,
|
||||
// niemals HTML, egal was beim Hochladen gemeldet wurde.
|
||||
ResponseContentDisposition: 'attachment',
|
||||
}),
|
||||
{ expiresIn: Math.min(Math.max(Math.floor(sekunden), 1), 60 * 60) },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto'
|
||||
|
||||
/**
|
||||
* Schluessel im Objektspeicher und kurzlebige Zugriffs-Token.
|
||||
*
|
||||
* Zwei Regeln, die hier hart durchgesetzt werden:
|
||||
*
|
||||
* 1. Der Schluessel wird **erzeugt**, nie vom Nutzer bestimmt. Ein
|
||||
* hochgeladener Dateiname landet niemals im Pfad - sonst genuegt
|
||||
* "../../../etc/passwd" oder "..%2f", um aus dem Ablageverzeichnis
|
||||
* auszubrechen. Der Originalname wird in der Datenbank gemerkt, nicht
|
||||
* im Dateisystem.
|
||||
*
|
||||
* 2. Das zweite Segment ist immer die betreuteId. Daraus liest jede
|
||||
* Leseoperation, gegen wen sie den Zugriff pruefen muss.
|
||||
*
|
||||
* Bewusst ohne "server-only": die reinen Funktionen werden getestet.
|
||||
*/
|
||||
|
||||
export const BEREICHE = ['dokumente', 'berichte', 'exporte', 'vorschau'] as const
|
||||
export type Bereich = (typeof BEREICHE)[number]
|
||||
|
||||
/** cuid/uuid-Format: nur harmlose Zeichen, kein Punkt, kein Schraegstrich. */
|
||||
const ID_MUSTER = /^[A-Za-z0-9_-]{1,64}$/
|
||||
const UUID_MUSTER = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||
const ENDUNG_MUSTER = /^[a-z0-9]{1,5}$/
|
||||
|
||||
export class UngueltigerSchluessel extends Error {
|
||||
constructor(nachricht: string) {
|
||||
super(nachricht)
|
||||
this.name = 'UngueltigerSchluessel'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt einen frischen Schluessel:
|
||||
* betreute/<betreuteId>/<bereich>/<uuid>.<endung>
|
||||
*
|
||||
* Die Endung kommt aus der Magic-Byte-Erkennung, nicht aus dem Dateinamen.
|
||||
*/
|
||||
export function schluesselErzeugen(betreuteId: string, bereich: Bereich, endung: string): string {
|
||||
if (!ID_MUSTER.test(betreuteId)) {
|
||||
throw new UngueltigerSchluessel(
|
||||
'Die Kennung der betreuten Person enthält Zeichen, die im Ablagepfad nichts zu suchen haben.',
|
||||
)
|
||||
}
|
||||
if (!BEREICHE.includes(bereich)) {
|
||||
throw new UngueltigerSchluessel(`Unbekannter Ablagebereich "${bereich}".`)
|
||||
}
|
||||
const sauber = endung.toLowerCase().replace(/[^a-z0-9]/g, '')
|
||||
if (!ENDUNG_MUSTER.test(sauber)) {
|
||||
throw new UngueltigerSchluessel(`Unbrauchbare Dateiendung "${endung}".`)
|
||||
}
|
||||
return `betreute/${betreuteId}/${bereich}/${randomUUID()}.${sauber}`
|
||||
}
|
||||
|
||||
const SCHLUESSEL_MUSTER = new RegExp(
|
||||
`^betreute/[A-Za-z0-9_-]{1,64}/(${BEREICHE.join('|')})/` +
|
||||
`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\.[a-z0-9]{1,5}$`,
|
||||
)
|
||||
|
||||
/**
|
||||
* Ist das ein Schluessel, den diese Anwendung selbst erzeugt hat?
|
||||
*
|
||||
* Wird vor jedem Dateizugriff aufgerufen. Alles, was nicht exakt dem
|
||||
* erzeugten Muster entspricht - auch "betreute/x/dokumente/../../geheim" -
|
||||
* faellt hier durch, bevor irgendein Pfad zusammengebaut wird.
|
||||
*/
|
||||
export function schluesselGueltig(schluessel: unknown): schluessel is string {
|
||||
if (typeof schluessel !== 'string') return false
|
||||
if (schluessel.length > 200) return false
|
||||
// Doppelte Sicherung gegen kodierte Punkte und Backslashes unter Windows.
|
||||
if (schluessel.includes('..') || schluessel.includes('\\') || schluessel.includes('%')) return false
|
||||
if (schluessel.includes('\0')) return false
|
||||
return SCHLUESSEL_MUSTER.test(schluessel)
|
||||
}
|
||||
|
||||
export function schluesselErfordern(schluessel: unknown): string {
|
||||
if (!schluesselGueltig(schluessel)) {
|
||||
throw new UngueltigerSchluessel('Dieser Ablageschlüssel stammt nicht aus dieser Anwendung.')
|
||||
}
|
||||
return schluessel
|
||||
}
|
||||
|
||||
/** Die betreuteId aus dem Schluessel - Grundlage jeder Zugriffspruefung. */
|
||||
export function betreuteIdAusSchluessel(schluessel: string): string | null {
|
||||
if (!schluesselGueltig(schluessel)) return null
|
||||
return schluessel.split('/')[1] ?? null
|
||||
}
|
||||
|
||||
export function bereichAusSchluessel(schluessel: string): Bereich | null {
|
||||
if (!schluesselGueltig(schluessel)) return null
|
||||
const teil = schluessel.split('/')[2]
|
||||
return BEREICHE.includes(teil as Bereich) ? (teil as Bereich) : null
|
||||
}
|
||||
|
||||
export function endungAusSchluessel(schluessel: string): string | null {
|
||||
if (!schluesselGueltig(schluessel)) return null
|
||||
const punkt = schluessel.lastIndexOf('.')
|
||||
return punkt < 0 ? null : schluessel.slice(punkt + 1)
|
||||
}
|
||||
|
||||
/** Nur fuer Tests und Migrationen: baut einen Schluessel mit fester UUID. */
|
||||
export function schluesselBauen(
|
||||
betreuteId: string,
|
||||
bereich: Bereich,
|
||||
uuid: string,
|
||||
endung: string,
|
||||
): string {
|
||||
if (!UUID_MUSTER.test(uuid)) throw new UngueltigerSchluessel('Keine gültige UUID.')
|
||||
const roh = `betreute/${betreuteId}/${bereich}/${uuid}.${endung.toLowerCase()}`
|
||||
return schluesselErfordern(roh)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Kurzlebige Zugriffs-Token fuer den Dateitreiber
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Beim S3-Treiber signiert der Objektspeicher die URL selbst. Beim
|
||||
* Dateitreiber gibt es keinen solchen Dienst - also signieren wir selbst:
|
||||
*
|
||||
* <base64url(schluessel)>.<ablauf in Sekunden>.<HMAC-SHA256>
|
||||
*
|
||||
* Der HMAC laeuft ueber "schluessel.ablauf" mit AUTH_SECRET. Ohne das
|
||||
* Geheimnis laesst sich weder ein fremder Schluessel unterschieben noch der
|
||||
* Ablauf verlaengern.
|
||||
*
|
||||
* Das Token ersetzt die Zugriffspruefung NICHT. Die Route prueft
|
||||
* zusaetzlich, dass der angemeldete Benutzer auf die betreuteId im
|
||||
* Schluessel Zugriff hat - ein geteiltes Token darf keine Tuer sein.
|
||||
*/
|
||||
|
||||
export const TOKEN_STANDARD_SEKUNDEN = 300
|
||||
const TOKEN_MAX_SEKUNDEN = 60 * 60
|
||||
|
||||
function geheimnis(): string {
|
||||
const wert = process.env.AUTH_SECRET
|
||||
if (!wert || wert.trim() === '') {
|
||||
throw new Error(
|
||||
'AUTH_SECRET fehlt. Ohne dieses Geheimnis lassen sich keine Dateilinks signieren - ' +
|
||||
'erzeuge es mit "npm run schluessel".',
|
||||
)
|
||||
}
|
||||
return wert
|
||||
}
|
||||
|
||||
function signieren(schluessel: string, ablauf: number): string {
|
||||
return createHmac('sha256', geheimnis()).update(`${schluessel}.${ablauf}`).digest('base64url')
|
||||
}
|
||||
|
||||
export function zugriffTokenErzeugen(
|
||||
schluessel: string,
|
||||
sekunden: number = TOKEN_STANDARD_SEKUNDEN,
|
||||
jetzt: Date = new Date(),
|
||||
): string {
|
||||
schluesselErfordern(schluessel)
|
||||
const dauer = Math.min(Math.max(Math.floor(sekunden), 1), TOKEN_MAX_SEKUNDEN)
|
||||
const ablauf = Math.floor(jetzt.getTime() / 1000) + dauer
|
||||
const kodiert = Buffer.from(schluessel, 'utf8').toString('base64url')
|
||||
return `${kodiert}.${ablauf}.${signieren(schluessel, ablauf)}`
|
||||
}
|
||||
|
||||
export type TokenPruefung =
|
||||
| { gueltig: true; schluessel: string; ablauf: Date }
|
||||
| { gueltig: false; grund: 'format' | 'signatur' | 'abgelaufen' }
|
||||
|
||||
export function zugriffTokenPruefen(token: unknown, jetzt: Date = new Date()): TokenPruefung {
|
||||
if (typeof token !== 'string' || token.length > 500) return { gueltig: false, grund: 'format' }
|
||||
|
||||
const teile = token.split('.')
|
||||
if (teile.length !== 3) return { gueltig: false, grund: 'format' }
|
||||
|
||||
const [kodiert, ablaufText, signatur] = teile as [string, string, string]
|
||||
|
||||
const ablauf = Number.parseInt(ablaufText, 10)
|
||||
if (!/^\d{1,12}$/.test(ablaufText) || Number.isNaN(ablauf)) {
|
||||
return { gueltig: false, grund: 'format' }
|
||||
}
|
||||
|
||||
let schluessel: string
|
||||
try {
|
||||
schluessel = Buffer.from(kodiert, 'base64url').toString('utf8')
|
||||
} catch {
|
||||
return { gueltig: false, grund: 'format' }
|
||||
}
|
||||
if (!schluesselGueltig(schluessel)) return { gueltig: false, grund: 'format' }
|
||||
|
||||
// Erst die Signatur, dann der Ablauf: sonst verraet die Antwortzeit,
|
||||
// ob ein Schluessel ueberhaupt existiert.
|
||||
const erwartet = Buffer.from(signieren(schluessel, ablauf), 'utf8')
|
||||
const vorhanden = Buffer.from(signatur, 'utf8')
|
||||
if (erwartet.length !== vorhanden.length) return { gueltig: false, grund: 'signatur' }
|
||||
if (!timingSafeEqual(erwartet, vorhanden)) return { gueltig: false, grund: 'signatur' }
|
||||
|
||||
if (ablauf * 1000 <= jetzt.getTime()) return { gueltig: false, grund: 'abgelaufen' }
|
||||
|
||||
return { gueltig: true, schluessel, ablauf: new Date(ablauf * 1000) }
|
||||
}
|
||||
|
||||
/** Der Pfad, auf den signierteUrl() beim Dateitreiber zeigt. */
|
||||
export function dateiRoutePfad(token: string): string {
|
||||
return `/api/dokumente/datei/${encodeURIComponent(token)}`
|
||||
}
|
||||
Reference in New Issue
Block a user