Files
betreuungs-cockpit/src/lib/speicher/index.ts
T
Florian Wilde 22b3a9b9e9 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
2026-08-22 02:16:22 +02:00

204 lines
6.1 KiB
TypeScript

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'