diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 92bb541..1ee0aa3 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -4,6 +4,7 @@ import { formatPrice } from "@/lib/format"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; import { checkUploadFiles } from "@/lib/upload-health"; +import { checkUploadVolume } from "@/lib/uploads"; const DASHBOARD_ICON_PROPS = { width: 20, @@ -228,7 +229,10 @@ export default async function AdminDashboardPage() { // Bewusst NACH den Kennzahlen abgefragt und nicht in deren Promise.all: // schlaegt der Dateiabgleich fehl (etwa weil das Upload-Verzeichnis gar // nicht lesbar ist), soll das Dashboard trotzdem seine Zahlen zeigen. - const uploadHealth = await checkUploadFiles().catch(() => null); + const [uploadHealth, uploadVolume] = await Promise.all([ + checkUploadFiles().catch(() => null), + checkUploadVolume().catch(() => null), + ]); return (
@@ -239,6 +243,21 @@ export default async function AdminDashboardPage() { ein "emptyDir" statt eines PersistentVolumeClaim). Ohne diesen Hinweis faellt das erst auf, wenn Kundschaft im Shop leere Produktkacheln sieht — im Log steht nichts. */} + {/* Schlaegt an, BEVOR etwas verloren geht: liegt das + Upload-Verzeichnis im Container statt auf einem gemounteten + Volume, sind die Dateien beim naechsten Redeploy weg — auch wenn + gerade noch alle vorhanden sind. */} + {uploadVolume && !uploadVolume.mounted ? ( +
+

+ {t.adminDashboardPage.uploadsNotPersistentTitle} +

+

+ {t.adminDashboardPage.uploadsNotPersistentHint(uploadVolume.uploadDir)} +

+
+ ) : null} + {uploadHealth && uploadHealth.missing > 0 ? (

diff --git a/src/i18n/dictionaries/de.ts b/src/i18n/dictionaries/de.ts index 52de3f8..46172f7 100644 --- a/src/i18n/dictionaries/de.ts +++ b/src/i18n/dictionaries/de.ts @@ -1098,6 +1098,10 @@ export const de: Dictionary = { adminDashboardPage: { missingUploadsTitle: (missing: number, checked: number) => `${missing} von ${checked} hinterlegten Bild-/Videodateien fehlen auf dem Server.`, + uploadsNotPersistentTitle: + "Das Upload-Verzeichnis liegt im Container und ist nicht dauerhaft.", + uploadsNotPersistentHint: (uploadDir: string) => + `Alle Bilder und Videos unter ${uploadDir} gehen beim nächsten Redeploy verloren. In Kubernetes muss das Volume "uploads" ein PersistentVolumeClaim sein (kubectl apply -f k8s/04-uploads-pvc.yaml und k8s/05-app-deployment.yaml), im Docker-Betrieb ein benanntes Volume. Details in k8s/README.md, Abschnitt "Bilder/Videos verschwinden nach jedem Redeploy".`, missingUploadsHint: "Die Datenbank kennt die Pfade noch, die Dateien selbst sind weg — im Shop erscheinen dort leere Flächen. Fast immer liegt das an einem nicht dauerhaften Upload-Verzeichnis: In Kubernetes muss das Volume \"uploads\" ein PersistentVolumeClaim sein (k8s/04-uploads-pvc.yaml), kein emptyDir, sonst geht es bei jedem Redeploy verloren. Nach dem Umstellen müssen die betroffenen Dateien einmalig neu hochgeladen werden.", title: "Dashboard", diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index 908ce32..9accfd3 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -1098,6 +1098,10 @@ export const en: Dictionary = { adminDashboardPage: { missingUploadsTitle: (missing: number, checked: number) => `${missing} of ${checked} referenced image/video files are missing on the server.`, + uploadsNotPersistentTitle: + "The upload directory lives inside the container and is not persistent.", + uploadsNotPersistentHint: (uploadDir: string) => + `All images and videos under ${uploadDir} will be lost on the next redeploy. In Kubernetes the "uploads" volume must be a PersistentVolumeClaim (kubectl apply -f k8s/04-uploads-pvc.yaml and k8s/05-app-deployment.yaml); with Docker it must be a named volume. See k8s/README.md.`, missingUploadsHint: "The database still holds the paths, but the files themselves are gone — the shop shows empty areas there. This is almost always a non-persistent upload directory: in Kubernetes the \"uploads\" volume must be a PersistentVolumeClaim (k8s/04-uploads-pvc.yaml), not an emptyDir, otherwise it is lost on every redeploy. After fixing it, the affected files have to be uploaded once more.", title: "Dashboard", diff --git a/src/i18n/dictionary-type.ts b/src/i18n/dictionary-type.ts index 5d3d4d0..076361d 100644 --- a/src/i18n/dictionary-type.ts +++ b/src/i18n/dictionary-type.ts @@ -798,6 +798,8 @@ export interface Dictionary { }; adminDashboardPage: { missingUploadsTitle: (missing: number, checked: number) => string; + uploadsNotPersistentTitle: string; + uploadsNotPersistentHint: (uploadDir: string) => string; missingUploadsHint: string; title: string; statRevenueToday: string; diff --git a/src/lib/uploads-volume.test.ts b/src/lib/uploads-volume.test.ts new file mode 100644 index 0000000..9911b5c --- /dev/null +++ b/src/lib/uploads-volume.test.ts @@ -0,0 +1,71 @@ +import { describe, test, expect, vi, afterEach } from "vitest"; + +/** + * checkUploadVolume() erkennt, ob UPLOAD_DIR ein eigenes, von aussen + * gemountetes Dateisystem ist. Liegt es stattdessen im Container, sind alle + * Bilder und Videos beim naechsten Redeploy weg. + * + * UPLOAD_DIR wird beim Import des Moduls aus der Umgebung gelesen, deshalb + * wird das Modul je Fall frisch geladen (resetModules + dynamischer Import) + * statt einmal oben. + */ +async function loadWithEnv(env: Record) { + vi.resetModules(); + for (const [key, value] of Object.entries(env)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + return import("./uploads"); +} + +const originalEnv = { ...process.env }; + +afterEach(() => { + process.env = { ...originalEnv }; + vi.resetModules(); +}); + +describe("checkUploadVolume", () => { + test("meldet ausserhalb der Produktion immer 'gemountet'", async () => { + // Lokal liegt UPLOAD_DIR als "./uploads" im Projektverzeichnis — dort + // waere die Warnung ein Daueralarm ohne Aussagewert. + const { checkUploadVolume } = await loadWithEnv({ + NODE_ENV: "development", + UPLOAD_DIR: "./uploads", + }); + + await expect(checkUploadVolume()).resolves.toMatchObject({ mounted: true }); + }); + + test("erkennt in Produktion ein gewoehnliches Verzeichnis als NICHT dauerhaft", async () => { + // Ein Unterverzeichnis des Projekts liegt auf demselben Dateisystem wie + // sein uebergeordnetes Verzeichnis — genau das Merkmal eines + // Container-Verzeichnisses ohne gemountetes Volume. + const { checkUploadVolume } = await loadWithEnv({ + NODE_ENV: "production", + UPLOAD_DIR: "./src", + }); + + await expect(checkUploadVolume()).resolves.toMatchObject({ mounted: false }); + }); + + test("meldet kein Problem, wenn das Verzeichnis gar nicht existiert", async () => { + // Aus einem fehlenden Verzeichnis laesst sich nichts ableiten — ein + // Fehlalarm waere hier schlimmer als keine Meldung. + const { checkUploadVolume } = await loadWithEnv({ + NODE_ENV: "production", + UPLOAD_DIR: "./gibt-es-garantiert-nicht-xyz", + }); + + await expect(checkUploadVolume()).resolves.toMatchObject({ mounted: true }); + }); + + test("gibt den geprueften Pfad mit zurueck, damit die Warnung ihn nennen kann", async () => { + const { checkUploadVolume, UPLOAD_DIR } = await loadWithEnv({ + NODE_ENV: "production", + UPLOAD_DIR: "./src", + }); + + await expect(checkUploadVolume()).resolves.toMatchObject({ uploadDir: UPLOAD_DIR }); + }); +}); diff --git a/src/lib/uploads.ts b/src/lib/uploads.ts index 0b71d4a..158fbb9 100644 --- a/src/lib/uploads.ts +++ b/src/lib/uploads.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { stat } from "node:fs/promises"; import { randomBytes } from "node:crypto"; import sharp from "sharp"; @@ -314,3 +315,43 @@ export function parseRangeHeader(header: string | null, size: number): ByteRange 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 { + 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 }; + } +}