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
173 lines
6.5 KiB
TypeScript
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`
|
|
}
|