import path from "node:path"; import { stat } from "node:fs/promises"; import { randomBytes } from "node:crypto"; import sharp from "sharp"; /** * Root-Verzeichnis fuer alle hochgeladenen Bilder (Inhalte-Bilder, Produkt-/ * Erntenfotos). In Produktion zeigt UPLOAD_DIR auf ein von aussen gemountetes * Volume (siehe k8s/05-app-deployment.yaml, volumeMounts "uploads") — diese * Datei und die Upload-/Serving-Route-Handler wissen nicht, WAS dieses * Volume ist (SMB/NFS/hostPath/...), sie lesen/schreiben ganz normal per * node:fs unter diesem Pfad. Lokal reicht der Default "./uploads" (relativ * zum Arbeitsverzeichnis, per .gitignore ausgeschlossen). * * turbopackIgnore: UPLOAD_DIR haengt von einer Laufzeit-Env-Var ab, die zur * Build-Zeit weder existiert noch existieren soll — ohne den Kommentar * versucht Turbopacks statische Datei-Tracing-Analyse (fuer * next.config.ts output:"standalone") vorsichtshalber, das GESAMTE * Projektverzeichnis in den Server-Output aufzunehmen, weil sie den * dynamischen Pfad nicht statisch aufloesen kann (siehe Build-Warnung ohne * diesen Kommentar). UPLOAD_DIR zeigt in Produktion ohnehin auf ein * gemountetes Volume ausserhalb des Projektbaums, das nie Teil des * Docker-Images sein soll. */ export const UPLOAD_DIR = path.resolve(/*turbopackIgnore: true*/ process.env.UPLOAD_DIR || "./uploads"); /** * 25 MB fuer die HOCHGELADENE Rohdatei. Die vorherigen 8 MB lagen unter der * Groesse, die aktuelle Handykameras (48 MP, HEIC-Export nach JPEG) * routinemaessig erzeugen — ein voellig normales Foto brach damit mit "Die * Datei ist zu gross" ab. Was tatsaechlich gespeichert wird, ist ohnehin * nicht diese Rohdatei, sondern das von reencodeImage() auf * MAX_IMAGE_DIMENSION verkleinerte und neu enkodierte Ergebnis (in aller * Regel ein Bruchteil davon). * * Die Datei wird zur Validierung vollstaendig in den Speicher gelesen (kein * Streaming); gegen ein kleines File, das sich beim Dekodieren zu absurd * vielen Pixeln aufblaeht, schuetzt zusaetzlich MAX_INPUT_PIXELS. */ export const MAX_UPLOAD_BYTES = 25 * 1024 * 1024; /** * Laengste Kante, mit der ein Bild gespeichert wird. 2560px deckt jede * Darstellung dieser Seite mit Reserve ab — das groesste Layout ist ein * vollbreiter Hero, und selbst ein Display mit doppelter Pixeldichte * verlangt davon keine hoehere Aufloesung. Alles darueber kostete nur Platz * auf dem Upload-Volume und zwang next/image, fuer JEDE ausgelieferte * Breite das komplette Originalbild neu zu dekodieren (ein 6000x4000-Foto * sind 24 Megapixel pro Variante). */ export const MAX_IMAGE_DIMENSION = 2560; /** * Obergrenze fuer die Pixelzahl, die sharp aus einer hochgeladenen Datei * ueberhaupt dekodieren darf (100 MP — mehr als jede Kamera liefert, die * hier realistisch zum Einsatz kommt). Schuetzt vor einer "Dekompressions- * bombe": einer wenige Kilobyte grossen Datei, die sich zu Milliarden * Pixeln entpackt und damit den Arbeitsspeicher des Servers fuellt — die * Byte-Grenze MAX_UPLOAD_BYTES allein faengt das nicht ab, weil sie nur die * komprimierte Groesse kennt. */ export const MAX_INPUT_PIXELS = 100_000_000; /** * 100 MB: deckt auch ein laengeres oder nicht web-optimiertes Hero-Video * (z.B. direkt von einer Kamera/einem Handy exportiert, oft 40-80 MB) ab. * Grosszuegiger als MAX_UPLOAD_BYTES (Bilder), weil Video-Dateien inhaerent * groesser sind. Diese Grenze gilt fuer die ROHDATEI vor dem Transcoding * (siehe lib/video-transcode.ts transcodeVideoForWeb, aufgerufen in * app/api/admin/upload-video/route.ts) — das tatsaechlich gespeicherte, * web-optimierte Ergebnis ist danach in aller Regel deutlich kleiner. * * Der vorgeschaltete Proxy muss eine Datei dieser Groesse durchlassen. Bei * Traefik (dem hier eingesetzten Ingress-Controller) gibt es keine Grenze * fuer die Body-Groesse, wohl aber eine fuer die Uebertragungsdauer: * "readTimeout" des EntryPoints umfasst das Lesen des gesamten Requests * inklusive Body und steht per Default auf 60s — zu wenig, um 100 MB ueber * eine normale Upload-Leitung zu schicken. Siehe die ausfuehrliche Notiz in * k8s/06-ingress.yaml. */ export const MAX_UPLOAD_VIDEO_BYTES = 100 * 1024 * 1024; export type AllowedImageExtension = "jpg" | "png" | "webp" | "avif"; export type AllowedVideoExtension = "mp4" | "webm"; /** * Kanonische Dateiendung je von sharp erkanntem Bildformat (Magic-Bytes, * siehe extensionForFormat) — das ist die einzige Quelle der Wahrheit fuer * erlaubte Formate. Der vom Client gesendete Dateiname/MIME-Type wird dafuer * NIE herangezogen, beides ist frei faelschbar. */ const EXTENSION_BY_FORMAT: Record = { jpeg: "jpg", png: "png", webp: "webp", avif: "avif", }; export function extensionForFormat(format: string | undefined): AllowedImageExtension | null { if (!format) return null; return EXTENSION_BY_FORMAT[format] ?? null; } /** * Erkennt MP4/WebM ausschliesslich anhand der tatsaechlichen Magic-Bytes am * Dateianfang (analog zu extensionForFormat fuer Bilder via sharp) — nie * ueber den Client-Dateinamen oder den vom Client gesendeten Content-Type, * beide sind frei faelschbar. MP4 traegt seine "ftyp"-Box-Kennung ab Byte 4 * (die ersten 4 Bytes sind eine variable Box-Groesse), WebM beginnt mit dem * fixen EBML-Header 0x1A45DFA3. */ export function detectVideoExtension(buffer: Buffer): AllowedVideoExtension | null { if (buffer.length >= 12 && buffer.toString("ascii", 4, 8) === "ftyp") { return "mp4"; } if ( buffer.length >= 4 && buffer[0] === 0x1a && buffer[1] === 0x45 && buffer[2] === 0xdf && buffer[3] === 0xa3 ) { return "webm"; } return null; } /** * Content-Type ausschliesslich aus dieser festen Zuordnung nach (bereits * validierter) Dateiendung — nie aus dem Dateiinhalt oder einem Client- * Header. Verhindert, dass eine ausgelieferte Datei je als text/html o.ae. * interpretiert werden koennte (gespeicherter XSS ueber Bild-Uploads), siehe * app/uploads/[...path]/route.ts. */ const CONTENT_TYPE_BY_EXTENSION: Record = { jpg: "image/jpeg", png: "image/png", webp: "image/webp", avif: "image/avif", mp4: "video/mp4", webm: "video/webm", }; export function contentTypeForExtension(extension: string): string | null { return CONTENT_TYPE_BY_EXTENSION[extension as AllowedImageExtension | AllowedVideoExtension] ?? null; } /** * Erzeugt einen zufaelligen, nicht erratbaren Dateinamen — nie der vom * Client gesendete Originalname wird verwendet oder auch nur einbezogen * (Path-Traversal-/Overwrite-Risiko). 16 zufaellige Bytes (32 Hex-Zeichen) * sind praktisch kollisionsfrei fuer die Upload-Frequenz dieser Seite. */ export function generateUploadFilename(extension: AllowedImageExtension | AllowedVideoExtension): string { return `${randomBytes(16).toString("hex")}.${extension}`; } const UPLOAD_PATH_PATTERN = /^\/uploads\/[a-f0-9]{32}\.(jpg|png|webp|avif)$/; const VIDEO_UPLOAD_PATH_PATTERN = /^\/uploads\/[a-f0-9]{32}\.(mp4|webm)$/; /** * Prueft, ob ein gespeicherter Bildpfad exakt der Form entspricht, die * app/api/admin/upload/route.ts erzeugt. Wird beim Speichern von Admin- * Formularfeldern verwendet, die einen Upload-Pfad entgegennehmen (Inhalte- * Bild, Produkt-/Erntenbilder) — schuetzt davor, dass ein manipuliertes * Hidden-Feld einen beliebigen String (z.B. "javascript:..." oder eine * externe URL) ungeprueft in die Datenbank und damit in ein / * next/image auf einer oeffentlichen Seite schreibt. */ export function isValidUploadPath(value: string): boolean { return UPLOAD_PATH_PATTERN.test(value); } /** Wie isValidUploadPath, aber fuer Video-Feldwerte (siehe home.hero.video * in lib/content-registry.ts) — eigenes Endungs-Set, damit die Bild- * Validierung oben nicht mit-gelockert wird. */ export function isValidVideoUploadPath(value: string): boolean { return VIDEO_UPLOAD_PATH_PATTERN.test(value); } /** * Loest Catch-all-Segmente aus app/uploads/[...path]/route.ts sicher gegen * UPLOAD_DIR auf. path.join() allein wuerde ".." zwar rechnerisch weg- * normalisieren, kennt dabei aber keine Grenze — ein Segment wie "..","..", * "etc","passwd" koennte sich so bis vor UPLOAD_DIR heraus navigieren. Der * abschliessende Praefix-Vergleich gegen den aufgeloesten UPLOAD_DIR-Pfad * ist die eigentliche Schranke. Gibt null zurueck, wenn der aufgeloeste Pfad * UPLOAD_DIR verlaesst. */ export function resolveUploadPath(segments: string[]): string | null { if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) { return null; } // turbopackIgnore: siehe Kommentar bei UPLOAD_DIR oben — derselbe Grund // (Laufzeit-abhaengiger Pfad, soll nicht ins Server-Bundle getraced werden). const resolved = path.resolve(path.join(/*turbopackIgnore: true*/ UPLOAD_DIR, ...segments)); const withSep = UPLOAD_DIR.endsWith(path.sep) ? UPLOAD_DIR : UPLOAD_DIR + path.sep; if (resolved !== UPLOAD_DIR && !resolved.startsWith(withSep)) { return null; } return resolved; } /** * Re-enkodiert ein Bild in sein Zielformat. Dient zwei Zwecken: (1) validiert * die tatsaechlichen Pixel-Daten ein zweites Mal vollstaendig (nicht nur den * Header/die Magic-Bytes wie die metadata()-Pruefung im Upload-Handler), und * (2) entfernt als Nebeneffekt eingebettete Metadaten (EXIF/ICC/XMP) — sharp * uebernimmt sie beim Enkodieren nur, wenn withMetadata() explizit * aufgerufen wird, was hier bewusst NICHT geschieht. .rotate() ohne * Argumente richtet das Bild einmalig anhand des EXIF-Orientation-Tags aus, * BEVOR dieser Tag mit den uebrigen Metadaten verworfen wird — sonst * erschiene ein am Handy "hochkant" aufgenommenes Foto nach dem Entfernen * der Metadaten seitlich verdreht. Zusaetzlich (3) wird das Bild auf * MAX_IMAGE_DIMENSION begrenzt, damit nie mehr Aufloesung gespeichert wird, * als die Seite je ausliefert. */ export async function reencodeImage(buffer: Buffer, extension: AllowedImageExtension): Promise { const image = sharp(buffer, { limitInputPixels: MAX_INPUT_PIXELS }) .rotate() // "inside" skaliert so, dass BEIDE Kanten in das Quadrat passen — bei // einem Querformat greift damit die Breite, bei einem Hochformat die // Hoehe, das Seitenverhaeltnis bleibt in beiden Faellen erhalten. // "withoutEnlargement" stellt sicher, dass ein ohnehin kleineres Bild // nie hochskaliert (und damit unscharf) wird. .resize({ width: MAX_IMAGE_DIMENSION, height: MAX_IMAGE_DIMENSION, fit: "inside", withoutEnlargement: true, }); switch (extension) { case "jpg": return image.jpeg({ quality: 85 }).toBuffer(); case "png": return image.png().toBuffer(); case "webp": return image.webp({ quality: 85 }).toBuffer(); case "avif": return image.avif({ quality: 60 }).toBuffer(); } } /** Ein halboffenes Byte-Intervall, beide Grenzen inklusive (wie im HTTP-Header). */ export interface ByteRange { start: number; end: number; } /** * Wertet einen HTTP-Range-Header gegen die bekannte Dateigroesse aus. * * Warum das gebraucht wird: ein