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
204 lines
6.1 KiB
TypeScript
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'
|