Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m12s
Ergaenzt den Abgleich ausc8ddcecum die Pruefung, die frueher anschlaegt. checkUploadFiles() erkennt fehlende Dateien — also erst, NACHDEM sie weg sind. checkUploadVolume() erkennt die Ursache schon vorher: liegt UPLOAD_DIR im Container statt auf einem gemounteten Volume, sind alle Bilder und Videos beim naechsten Redeploy verloren, auch wenn gerade noch jede Datei da ist. Erkennung ueber die Geraete-Kennung (st_dev): ein eigener Mountpunkt hat eine andere als sein uebergeordnetes Verzeichnis. Bewusst eine Heuristik und damit nur ein Hinweis, keine Blockade — ein Bind-Mount innerhalb desselben Dateisystems kann dieselbe Kennung tragen. Ausserhalb von NODE_ENV=production immer "gemountet", weil UPLOAD_DIR lokal als "./uploads" im Projektverzeichnis liegt und dort sonst ein Daueralarm entstuende. Die Funktion liegt in lib/uploads.ts statt in lib/upload-health.ts: Letzteres importiert Prisma und laesst sich ohne DATABASE_URL nicht testen — dieselbe Ueberlegung wie bei lib/form-fields.ts inff365c3. Als reine Funktion neben UPLOAD_DIR ist sie direkt pruefbar, und dort gehoert sie fachlich ohnehin hin. Vier Tests: kein Alarm ausserhalb der Produktion, ein gewoehnliches Verzeichnis wird in Produktion als nicht dauerhaft erkannt, ein fehlendes Verzeichnis erzeugt keinen Fehlalarm, und der gepruefte Pfad kommt mit zurueck, damit die Warnung ihn benennen kann. Das Admin-Dashboard zeigt beide Befunde getrennt: die Vorwarnung als Hinweis, fehlende Dateien als Fehler. Nicht enthalten und auch nicht moeglich: das Anwenden der Manifeste im Cluster. Auf dieser Maschine gibt es weder kubectl noch eine kubeconfig; die beiden kubectl-Befehle aus k8s/README.md muss jemand mit Cluster-Zugriff einmalig ausfuehren. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
358 lines
16 KiB
TypeScript
358 lines
16 KiB
TypeScript
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<string, AllowedImageExtension> = {
|
|
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<AllowedImageExtension | AllowedVideoExtension, string> = {
|
|
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 <img src>/
|
|
* 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<Buffer> {
|
|
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 <video src="/uploads/..."> (siehe
|
|
* components/hero.tsx) laedt sein Material grundsaetzlich per Range-Request.
|
|
* iOS-Safari schickt dafuer zuerst eine Ein-Byte-Probe ("bytes=0-1") und
|
|
* spielt das Video ueberhaupt nicht ab, wenn der Server darauf mit einer
|
|
* vollstaendigen 200-Antwort statt mit "206 Partial Content" antwortet.
|
|
* Ohne Range-Unterstuetzung laesst sich ausserdem nirgends im Video
|
|
* springen, und jeder Abspielversuch laedt die komplette Datei.
|
|
*
|
|
* Rueckgabe:
|
|
* - `null` -> kein (oder kein auswertbarer) Range-Wunsch, ganze Datei
|
|
* ausliefern. Laut RFC 9110 ist das auf JEDEN Range-Request
|
|
* eine zulaessige Antwort, deshalb werden auch mehrteilige
|
|
* und kaputte Bereiche hierher abgebildet statt als Fehler.
|
|
* - `"unsatisfiable"` -> syntaktisch gueltig, aber ausserhalb der Datei:
|
|
* muss mit 416 beantwortet werden.
|
|
* - `ByteRange` -> der auf die Dateigroesse zugeschnittene Bereich.
|
|
*/
|
|
export function parseRangeHeader(header: string | null, size: number): ByteRange | "unsatisfiable" | null {
|
|
if (!header) return null;
|
|
|
|
const match = /^bytes=(.+)$/i.exec(header.trim());
|
|
if (!match) return null;
|
|
|
|
const spec = match[1].trim();
|
|
// Mehrteilige Bereiche braeuchten eine multipart/byteranges-Antwort — fuer
|
|
// Bilder/Videos praktisch nie angefragt, deshalb bewusst nicht unterstuetzt.
|
|
if (spec.includes(",")) return null;
|
|
|
|
const parts = /^(\d*)-(\d*)$/.exec(spec);
|
|
if (!parts) return null;
|
|
const [, rawStart, rawEnd] = parts;
|
|
if (rawStart === "" && rawEnd === "") return null;
|
|
|
|
// Eine leere Datei kann keinen einzigen Bereich erfuellen.
|
|
if (size <= 0) return "unsatisfiable";
|
|
const lastByte = size - 1;
|
|
|
|
// Suffix-Form "bytes=-N": die LETZTEN N Bytes, nicht "ab Byte N".
|
|
if (rawStart === "") {
|
|
const suffixLength = Number(rawEnd);
|
|
if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) return "unsatisfiable";
|
|
return { start: Math.max(0, size - suffixLength), end: lastByte };
|
|
}
|
|
|
|
const start = Number(rawStart);
|
|
if (!Number.isSafeInteger(start) || start > lastByte) return "unsatisfiable";
|
|
|
|
if (rawEnd === "") return { start, end: lastByte };
|
|
|
|
const requestedEnd = Number(rawEnd);
|
|
if (!Number.isSafeInteger(requestedEnd) || requestedEnd < start) return "unsatisfiable";
|
|
return { start, end: Math.min(requestedEnd, lastByte) };
|
|
}
|
|
|
|
/**
|
|
* Validator fuer bedingte Anfragen (If-None-Match). Dateigroesse und
|
|
* Aenderungszeit genuegen als Kennung, weil eine hochgeladene Datei unter
|
|
* ihrem zufaelligen Namen nie ueberschrieben wird (siehe
|
|
* generateUploadFilename) — ein Wechsel beider Werte ist daher ohnehin nur
|
|
* bei einer echten Neuanlage moeglich. Erspart dem Browser bei jedem Reload
|
|
* das erneute Herunterladen des Bildinhalts (304 statt 200).
|
|
*/
|
|
export function uploadETag(size: number, mtimeMs: number): string {
|
|
return `"${size.toString(16)}-${Math.floor(mtimeMs).toString(16)}"`;
|
|
}
|
|
|
|
export interface UploadVolumeReport {
|
|
/** true, wenn UPLOAD_DIR auf einem eigenen Dateisystem liegt (= gemountetes Volume). */
|
|
mounted: boolean;
|
|
uploadDir: string;
|
|
}
|
|
|
|
/**
|
|
* Prueft, ob UPLOAD_DIR ein eigenes, von aussen gemountetes Dateisystem ist
|
|
* oder schlicht ein Verzeichnis im Container.
|
|
*
|
|
* Warum das zaehlt: liegt es im Container, ist es an dessen Lebensdauer
|
|
* gebunden — bei jedem Redeploy sind alle Uploads weg. checkUploadFiles() in
|
|
* lib/upload-health.ts erkennt das ebenfalls, aber erst NACHDEM Dateien verloren gegangen
|
|
* sind. Diese Pruefung schlaegt schon vorher an, solange noch nichts
|
|
* hochgeladen wurde.
|
|
*
|
|
* Erkennung ueber die Geraete-Kennung (st_dev): ein eigener Mountpunkt hat
|
|
* eine andere als sein uebergeordnetes Verzeichnis. Das ist eine Heuristik,
|
|
* kein Beweis — ein Bind-Mount innerhalb desselben Dateisystems kann
|
|
* dieselbe Kennung tragen. Deshalb nur ein Hinweis, keine Blockade.
|
|
*
|
|
* Ausserhalb von NODE_ENV=production bewusst immer "mounted": lokal liegt
|
|
* UPLOAD_DIR als "./uploads" im Projektverzeichnis und soll dort keinen
|
|
* Daueralarm ausloesen.
|
|
*/
|
|
export async function checkUploadVolume(): Promise<UploadVolumeReport> {
|
|
if (process.env.NODE_ENV !== "production") {
|
|
return { mounted: true, uploadDir: UPLOAD_DIR };
|
|
}
|
|
|
|
try {
|
|
const [dir, parent] = await Promise.all([stat(UPLOAD_DIR), stat(path.dirname(UPLOAD_DIR))]);
|
|
return { mounted: dir.dev !== parent.dev, uploadDir: UPLOAD_DIR };
|
|
} catch {
|
|
// Verzeichnis (noch) nicht vorhanden oder nicht lesbar — daraus laesst
|
|
// sich nichts ableiten, also kein Fehlalarm.
|
|
return { mounted: true, uploadDir: UPLOAD_DIR };
|
|
}
|
|
}
|