feat(anhaenge): Fotos und Dateien an einem Schritt (#52) (#59)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #59.
This commit is contained in:
2026-10-05 20:49:26 +00:00
parent 855164066c
commit 3ccf3aa2d9
25 changed files with 1989 additions and 47 deletions
+111
View File
@@ -0,0 +1,111 @@
/**
* Grenzen und Formen der Anhaenge (#032).
*
* Framework-frei, weil beide Seiten davon lesen: der Dienst prueft damit, und
* die Oberflaeche sagt damit **vorher**, was erlaubt ist. Stuenden die Grenzen
* nur im Dienst, waere jede Abweisung eine Ueberraschung.
*/
/**
* Was sich anhaengen laesst: Bilder und PDF.
*
* Bewusst eine Liste und kein Praefix-Vergleich wie `image/*`: ein Browser
* schickt auch `image/svg+xml`, und SVG ist ein Dokument mit Skripten, das ein
* Betrachter ausfuehrt. Ein Anhang, den jemand oeffnet, darf kein Angriffsweg
* sein.
*/
export const ERLAUBTE_MEDIENTYPEN = [
"image/jpeg",
"image/png",
"image/webp",
"image/heic",
"image/heif",
"application/pdf",
] as const;
export type Medientyp = (typeof ERLAUBTE_MEDIENTYPEN)[number];
/** Die Endung, unter der eine Datei auf der Platte liegt. */
export const ENDUNG_JE_TYP: Readonly<Record<Medientyp, string>> = {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/heic": "heic",
"image/heif": "heif",
"application/pdf": "pdf",
};
export function istErlaubterMedientyp(typ: string): typ is Medientyp {
return (ERLAUBTE_MEDIENTYPEN as readonly string[]).includes(typ);
}
/** Bilder werden als Miniatur gezeigt, ein PDF nur als Zeile. */
export function istBild(medientyp: string): boolean {
return medientyp !== "application/pdf" && istErlaubterMedientyp(medientyp);
}
const STANDARD_MAX_MB = 10;
const STANDARD_JE_LAUF = 20;
/**
* Hoechstgroesse je Datei in Bytes.
*
* Ein Handyfoto hat heute 3–6 MB. Zehn als Vorgabe lassen Luft, ohne dass eine
* einzelne Datei eine Sicherung sprengt. Keine serverseitige Verkleinerung:
* sie verliert Metadaten und zieht eine Bildbibliothek ins Projekt — eine
* ehrliche Obergrenze ist weniger Magie.
*/
export function maxGroesseBytes(): number {
const roh = Number.parseInt(process.env["ANHANG_MAX_MB"]?.trim() ?? "", 10);
const mb = Number.isFinite(roh) && roh > 0 ? roh : STANDARD_MAX_MB;
return mb * 1024 * 1024;
}
/** Hoechstzahl Anhaenge je Lauf. */
export function maxJeLauf(): number {
const roh = Number.parseInt(process.env["ANHAENGE_JE_LAUF"]?.trim() ?? "", 10);
return Number.isFinite(roh) && roh > 0 ? roh : STANDARD_JE_LAUF;
}
/** Wo die Dateien liegen. Ohne Angabe ein Verzeichnis im Arbeitsbaum. */
export function anhaengePfad(): string {
return process.env["ANHAENGE_PFAD"]?.trim() || "./daten/anhaenge";
}
/**
* Die eine gewaehlte Datei aus mehreren Formularfeldern desselben Namens.
*
* Der Schritt hat **zwei** Felder `datei` in einem Formular: „Foto“ mit
* `capture="environment"` und „Datei“ mit dem Dateiwaehler. Abgeschickt wird
* immer das ganze Formular, also kommen beide Felder mit — das leere als
* `File` mit `size === 0`. `FormData.get("datei")` liefert das **erste**, und
* das war in #032 das leere: wer „Datei“ nutzte, bekam „Keine Datei gewählt“,
* waehrend „Foto“ ging. Gefunden erst in der Klickprobe am gebauten Stand, weil
* kein Dienst-Test zwei Felder schickt.
*
* Darum hier die Regel, und zwar als reine Funktion: das **erste nicht leere**
* Feld gewinnt, unabhaengig von der Reihenfolge im Formular.
*/
export function gewaehlteDatei(eintraege: readonly unknown[]): File | undefined {
return eintraege.find((eintrag): eintrag is File => eintrag instanceof File && eintrag.size > 0);
}
/** `4,2 MB` – fuer Meldungen und die Anzeige. */
export function groesseText(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1).replace(".", ",")} MB`;
}
/**
* Ein Dateiname, der sich anzeigen laesst.
*
* Der Name vom Browser wird **nie** zum Pfad — der entsteht aus der `id`.
* Trotzdem wird er beschnitten und von Steuerzeichen befreit: er landet in einer
* Antwortkopfzeile und in der Oberflaeche.
*/
export function dateinameSaeubern(roh: string): string {
const ohnePfad = roh.split(/[\\/]/).pop() ?? "";
const sauber = ohnePfad.replace(/[\u0000-\u001f\u007f"]/g, "").trim();
return (sauber || "anhang").slice(0, 200);
}