Files
betreuungs-cockpit/src/lib/speicher/dateityp.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

173 lines
6.5 KiB
TypeScript

/**
* 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`
}