feat: nicht dauerhaftes Upload-Verzeichnis wird erkannt, bevor Daten verloren gehen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m12s

Ergaenzt den Abgleich aus c8ddcec um 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 in ff365c3. 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]>
This commit is contained in:
Nick SzaunigandClaude Opus 5 committed 2026-09-16 04:09:31 +02:00
1 parent c8ddcec04c
commit c61ae92390
6 files changed
+142 -1

No files matched your search

+20 -1
View File
@@ -4,6 +4,7 @@ import { formatPrice } from "@/lib/format";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
import { checkUploadFiles } from "@/lib/upload-health"; import { checkUploadFiles } from "@/lib/upload-health";
import { checkUploadVolume } from "@/lib/uploads";
const DASHBOARD_ICON_PROPS = { const DASHBOARD_ICON_PROPS = {
width: 20, width: 20,
@@ -228,7 +229,10 @@ export default async function AdminDashboardPage() {
// Bewusst NACH den Kennzahlen abgefragt und nicht in deren Promise.all: // Bewusst NACH den Kennzahlen abgefragt und nicht in deren Promise.all:
// schlaegt der Dateiabgleich fehl (etwa weil das Upload-Verzeichnis gar // schlaegt der Dateiabgleich fehl (etwa weil das Upload-Verzeichnis gar
// nicht lesbar ist), soll das Dashboard trotzdem seine Zahlen zeigen. // 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 ( return (
<div> <div>
@@ -239,6 +243,21 @@ export default async function AdminDashboardPage() {
ein "emptyDir" statt eines PersistentVolumeClaim). Ohne diesen ein "emptyDir" statt eines PersistentVolumeClaim). Ohne diesen
Hinweis faellt das erst auf, wenn Kundschaft im Shop leere Hinweis faellt das erst auf, wenn Kundschaft im Shop leere
Produktkacheln sieht — im Log steht nichts. */} 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 ? (
<div className="mb-8 border border-honey/50 bg-honey/10 p-4">
<p className="text-sm text-honey-dark">
{t.adminDashboardPage.uploadsNotPersistentTitle}
</p>
<p className="mt-2 text-xs text-ink/70">
{t.adminDashboardPage.uploadsNotPersistentHint(uploadVolume.uploadDir)}
</p>
</div>
) : null}
{uploadHealth && uploadHealth.missing > 0 ? ( {uploadHealth && uploadHealth.missing > 0 ? (
<div className="mb-8 border border-red-700/40 bg-red-50 p-4"> <div className="mb-8 border border-red-700/40 bg-red-50 p-4">
<p className="text-sm text-red-800"> <p className="text-sm text-red-800">
+4
View File
@@ -1098,6 +1098,10 @@ export const de: Dictionary = {
adminDashboardPage: { adminDashboardPage: {
missingUploadsTitle: (missing: number, checked: number) => missingUploadsTitle: (missing: number, checked: number) =>
`${missing} von ${checked} hinterlegten Bild-/Videodateien fehlen auf dem Server.`, `${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: 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.", "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", title: "Dashboard",
+4
View File
@@ -1098,6 +1098,10 @@ export const en: Dictionary = {
adminDashboardPage: { adminDashboardPage: {
missingUploadsTitle: (missing: number, checked: number) => missingUploadsTitle: (missing: number, checked: number) =>
`${missing} of ${checked} referenced image/video files are missing on the server.`, `${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: 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.", "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", title: "Dashboard",
+2
View File
@@ -798,6 +798,8 @@ export interface Dictionary {
}; };
adminDashboardPage: { adminDashboardPage: {
missingUploadsTitle: (missing: number, checked: number) => string; missingUploadsTitle: (missing: number, checked: number) => string;
uploadsNotPersistentTitle: string;
uploadsNotPersistentHint: (uploadDir: string) => string;
missingUploadsHint: string; missingUploadsHint: string;
title: string; title: string;
statRevenueToday: string; statRevenueToday: string;
+71
View File
@@ -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<string, string | undefined>) {
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 });
});
});
+41
View File
@@ -1,4 +1,5 @@
import path from "node:path"; import path from "node:path";
import { stat } from "node:fs/promises";
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import sharp from "sharp"; 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 { export function uploadETag(size: number, mtimeMs: number): string {
return `"${size.toString(16)}-${Math.floor(mtimeMs).toString(16)}"`; 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 };
}
}