Compare commits

...
3 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5 c61ae92390 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]>
2026-09-16 04:09:31 +02:00
Nick SzaunigandClaude Opus 5 c8ddcec04c feat: fehlende Upload-Dateien werden im Admin sofort gemeldet
Nach einem Redeploy waren alle hochgeladenen Bilder und Videos weg. Die
Ursache war bereits in a78c1cd behoben — das Volume "uploads" im
App-Deployment ist dort vom Platzhalter "emptyDir" auf einen
PersistentVolumeClaim umgestellt. Der Commit liegt auch auf origin/main.

Was fehlte, ist der Schritt danach: die Gitea-Pipeline baut nur das Image
und wendet KEINE Kubernetes-Manifeste an. Ein "kubectl rollout restart"
holt das neue Image, laesst die Deployment-Definition im Cluster aber
unveraendert — dort steht also weiterhin emptyDir. k8s/README.md bekommt
dafuer einen eigenen Abschnitt mit dem genauen Ablauf: erst pruefen, was
im Cluster tatsaechlich als Volume eingetragen ist, dann PVC und
Deployment anwenden, dann gegenpruefen.

Zusaetzlich das eigentliche Problem an diesem Ausfall: er ist unsichtbar.
Die Datenbank behaelt ihre Bildpfade, die Dateien dahinter fehlen, und im
Shop erscheinen einfach leere Flaechen — keine Fehlermeldung, kein
Log-Eintrag. Gemerkt hat man es erst, wenn jemand hingeschaut hat.

lib/upload-health.ts gleicht deshalb ab, ob die in Product/Harvest/Batch
und in den Inhaltsfeldern hinterlegten Pfade als Dateien vorliegen. Das
Admin-Dashboard zeigt bei Abweichung eine Warnung mit Anzahl und
Beispielpfaden samt Hinweis auf die Ursache. Der Abgleich laeuft bewusst
nach den Kennzahlen und mit eigenem catch: schlaegt er fehl, zeigt das
Dashboard trotzdem seine Zahlen. Obergrenze 300 gepruefte Pfade, damit die
Seite nicht an Dateisystemzugriffen haengt.

Bilder und Videos nutzen denselben UPLOAD_DIR (lib/uploads.ts), auch der
Video-Upload ueber den Umweg ueber ein Temp-Verzeichnis — es gibt also
keinen zweiten Speicherort, der getrennt behandelt werden muesste.

Gegen die lokale Datenbank geprueft: ohne Referenzen meldet der Abgleich
0/0, mit einem absichtlich auf eine nicht vorhandene Datei gesetzten
Produktbild 1 von 1 fehlend samt Pfad als Beleg, nach dem Zuruecksetzen
wieder 0/0.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-16 04:06:33 +02:00
Nick SzaunigandClaude Opus 5 bdce9b6a0a feat: Impressum mit den echten Inhaberdaten ausgefuellt
Inhaber Nick Rosenloecher, Rechtsform Einzelunternehmen,
Umsatzsteuer-Identifikationsnummer DE366479158 — in beiden
Sprachfassungen. Der Abschnitt "Redaktionell verantwortlich" trug
"[Name wie oben]" und nennt jetzt ebenfalls den Namen.

Der Hinweis zur Kleinunternehmerregelung nach § 19 UStG entfaellt: er war
eine Handlungsanweisung fuer den Fall OHNE Umsatzsteuer-ID. Mit einer
vorhandenen ID nebeneinander stehen gelassen, haette das Impressum sich
selbst widersprochen.

Eingetragen in die Vorlagen unter i18n/dictionaries/ statt nur in die
Datenbank: so gelten die Angaben in jeder Umgebung und ueberleben eine
frisch aufgesetzte Datenbank. Ueber /admin/inhalte bleiben sie weiterhin
aenderbar, ohne dass dafuer ein Deploy noetig waere.

Damit meldet "pnpm preflight" die Rechtstexte erstmals als vollstaendig —
der letzte Inhalts-Blocker vor dem Live-Gang ist zu. Die anwaltliche
Pruefung der uebrigen Rechtstexte bleibt davon unberuehrt.

Ausserdem auf "Versand und Zahlung" entfernt: "Die aktuellen Versandkosten
werden dir vor Abschluss der Bestellung transparent angezeigt." Das war
der Ersatztext (tierFallback), der nur erschien, solange keine
Versandstaffel gepflegt ist. Ohne Staffel bleibt die Liste jetzt schlicht
leer, statt einen Satz zu zeigen, der nichts aussagt. Schluessel samt Typ
und Verwendung entfernt, nicht nur geleert.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-16 04:02:45 +02:00
9 changed files with 335 additions and 14 deletions

No files matched your search

+44
View File
@@ -121,6 +121,50 @@ Redeploy.
`02-secret.yaml` einfach auf den externen Endpunkt zeigen lassen, `02-secret.yaml` einfach auf den externen Endpunkt zeigen lassen,
`03-postgres.yaml`/`03-mysql.yaml` dann nicht anwenden. `03-postgres.yaml`/`03-mysql.yaml` dann nicht anwenden.
## Bilder/Videos verschwinden nach jedem Redeploy
Symptom: nach einem Redeploy sind alle hochgeladenen Produkt-, Ernten- und
Inhaltsbilder weg und muessen neu hochgeladen werden.
Ursache: das Volume `uploads` im App-Deployment ist noch ein `emptyDir`.
Das ist an die Lebensdauer des Pods gebunden — jeder Neustart loescht es.
Die Datenbank behaelt dabei ihre Bildpfade, die Dateien dahinter sind aber
fort; im Shop erscheinen deshalb leere Flaechen statt einer Fehlermeldung.
Wichtig: Die Gitea-Pipeline baut nur das Image. Sie wendet **keine**
Kubernetes-Manifeste an. Ein `kubectl rollout restart` holt zwar das neue
Image, laesst die Deployment-Definition im Cluster aber unveraendert — die
Korrektur unten muss deshalb einmal von Hand angewendet werden:
```bash
# 1. Aktuellen Zustand pruefen — steht hier "emptyDir", ist genau das die Ursache:
kubectl get deployment wabenhain-app -n wabenhain -o jsonpath='{.spec.template.spec.volumes[?(@.name=="uploads")]}{"
"}'
# 2. Dauerhaften Speicher anlegen und Deployment aktualisieren:
kubectl apply -f k8s/04-uploads-pvc.yaml
kubectl apply -f k8s/05-app-deployment.yaml
# 3. Gegenprobe — jetzt muss dort ein persistentVolumeClaim stehen:
kubectl get deployment wabenhain-app -n wabenhain -o jsonpath='{.spec.template.spec.volumes[?(@.name=="uploads")]}{"
"}'
kubectl get pvc wabenhain-uploads -n wabenhain
```
Danach ueberstehen Uploads jeden Redeploy. Bereits verlorene Dateien lassen
sich nicht wiederherstellen — sie muessen einmalig neu hochgeladen werden.
Das Deployment nutzt dabei `strategy: Recreate` (siehe Abschnitt oben):
ohne das bliebe der neue Pod beim Redeploy an einem
`Multi-Attach error for volume` haengen, weil ein
`ReadWriteOnce`-Claim nur an einen Pod gebunden sein kann.
**Kontrolle im laufenden Betrieb:** Das Admin-Dashboard gleicht bei jedem
Aufruf ab, ob die in der Datenbank hinterlegten Bild-/Videopfade als Dateien
vorliegen, und zeigt sonst eine Warnung mit Anzahl und Beispielpfaden
(`lib/upload-health.ts`). Dieser Fall faellt damit sofort auf, statt erst
dann, wenn Kundschaft im Shop leere Flaechen sieht.
## Sicherung und Wiederherstellung ## Sicherung und Wiederherstellung
`07-backup-cronjob.yaml` legt jede Nacht um 03:15 Uhr eine `07-backup-cronjob.yaml` legt jede Nacht um 03:15 Uhr eine
+48
View File
@@ -3,6 +3,8 @@ import { prisma } from "@/lib/prisma";
import { formatPrice } from "@/lib/format"; 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 { checkUploadVolume } from "@/lib/uploads";
const DASHBOARD_ICON_PROPS = { const DASHBOARD_ICON_PROPS = {
width: 20, width: 20,
@@ -224,9 +226,55 @@ export default async function AdminDashboardPage() {
const revenueTrend = [...revenueByDay.entries()]; const revenueTrend = [...revenueByDay.entries()];
const maxDailyRevenue = Math.max(1, ...revenueTrend.map(([, v]) => v)); const maxDailyRevenue = Math.max(1, ...revenueTrend.map(([, v]) => v));
// 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, uploadVolume] = await Promise.all([
checkUploadFiles().catch(() => null),
checkUploadVolume().catch(() => null),
]);
return ( return (
<div> <div>
<h1 className="text-2xl text-forest mb-8">{t.adminDashboardPage.title}</h1> <h1 className="text-2xl text-forest mb-8">{t.adminDashboardPage.title}</h1>
{/* Fehlen Dateien, zu denen die Datenbank noch Pfade haelt, ist das
fast immer ein nicht persistentes Upload-Verzeichnis (in Kubernetes
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 ? (
<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 ? (
<div className="mb-8 border border-red-700/40 bg-red-50 p-4">
<p className="text-sm text-red-800">
{t.adminDashboardPage.missingUploadsTitle(uploadHealth.missing, uploadHealth.checked)}
</p>
<p className="mt-2 text-xs text-red-800/80">
{t.adminDashboardPage.missingUploadsHint}
</p>
{uploadHealth.examples.length > 0 ? (
<ul className="mt-2 text-xs text-red-800/70">
{uploadHealth.examples.map((example) => (
<li key={example}>{example}</li>
))}
</ul>
) : null}
</div>
) : null}
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
{stats.map((stat) => { {stats.map((stat) => {
const Icon = statIcons[stat.key]; const Icon = statIcons[stat.key];
+4 -1
View File
@@ -27,7 +27,10 @@ export default async function VersandZahlungPage() {
formatPrice(tier.priceCents, "EUR", locale) formatPrice(tier.priceCents, "EUR", locale)
) )
) )
: [t.versandZahlungPage.tierFallback]; // Ohne gepflegte Versandstaffel bleibt die Liste leer, statt einen
// Ersatzsatz zu zeigen — so aendert sich der Abschnitt nur, wenn
// tatsaechlich Staffeln hinterlegt sind.
: [];
return ( return (
<LegalPage <LegalPage
+12 -6
View File
@@ -182,21 +182,20 @@ export const de: Dictionary = {
{ {
heading: "Angaben gemäß § 5 TMG", heading: "Angaben gemäß § 5 TMG",
paragraphs: [ paragraphs: [
"[PLATZHALTER: Name der Inhaberin/des Inhabers bzw. der Geschäftsführung eintragen]", "Inhaber: Nick Rosenlöcher",
"[PLATZHALTER: Rechtsform eintragen, z.B. Einzelunternehmen/e.K./GmbH. Sofern im Handelsregister eingetragen: Registergericht und Handelsregisternummer ergänzen.]", "Rechtsform: Einzelunternehmen",
], ],
}, },
{ {
heading: "Umsatzsteuer-ID", heading: "Umsatzsteuer-ID",
paragraphs: [ paragraphs: [
"[PLATZHALTER] Umsatzsteuer-Identifikationsnummer gemäß § 27a Umsatzsteuergesetz.", "Umsatzsteuer-Identifikationsnummer gemäß § 27a Umsatzsteuergesetz: DE366479158",
"Sofern stattdessen die Kleinunternehmerregelung nach § 19 UStG angewendet wird, entfällt eine Umsatzsteuer-ID: Dies ist an dieser Stelle entsprechend zu vermerken (z.B. \"Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.\").",
], ],
}, },
{ {
heading: "Redaktionell verantwortlich", heading: "Redaktionell verantwortlich",
paragraphs: [ paragraphs: [
"Verantwortlich für den redaktionellen Inhalt gemäß § 18 Abs. 2 MStV (u.a. die Beiträge im Bereich \"Honigwissen\"): [Name wie oben].", "Verantwortlich für den redaktionellen Inhalt gemäß § 18 Abs. 2 MStV (u.a. die Beiträge im Bereich \"Honigwissen\"): Nick Rosenlöcher.",
], ],
}, },
{ {
@@ -385,7 +384,6 @@ export const de: Dictionary = {
"Wir versenden innerhalb Deutschlands. Die Versandkosten richten sich nach der Gesamtmenge aller Artikel deiner Bestellung (mehrere Produkte werden zusammengezählt) und werden dir vor Abschluss der Bestellung transparent angezeigt:", "Wir versenden innerhalb Deutschlands. Die Versandkosten richten sich nach der Gesamtmenge aller Artikel deiner Bestellung (mehrere Produkte werden zusammengezählt) und werden dir vor Abschluss der Bestellung transparent angezeigt:",
tierRange: (min, max, price) => `${min}–${max} Gläser/Artikel: ${price}`, tierRange: (min, max, price) => `${min}–${max} Gläser/Artikel: ${price}`,
tierMinimum: (min, price) => `Ab ${min} Gläsern/Artikeln: ${price}`, tierMinimum: (min, price) => `Ab ${min} Gläsern/Artikeln: ${price}`,
tierFallback: "Die aktuellen Versandkosten werden dir vor Abschluss der Bestellung transparent angezeigt.",
pickupNote: "Alternativ kannst du deine Bestellung nach Absprache direkt bei uns in Altenmünster abholen. Dabei fallen keine Versandkosten an.", pickupNote: "Alternativ kannst du deine Bestellung nach Absprache direkt bei uns in Altenmünster abholen. Dabei fallen keine Versandkosten an.",
paymentHeading: "Zahlungsmethoden", paymentHeading: "Zahlungsmethoden",
paymentText: "Wir bieten Kreditkarte, SEPA-Lastschrift, Klarna, PayPal, Apple Pay und Google Pay über unseren Zahlungsdienstleister Stripe an. Eine Zahlung per Überweisung ist auf Anfrage möglich.", paymentText: "Wir bieten Kreditkarte, SEPA-Lastschrift, Klarna, PayPal, Apple Pay und Google Pay über unseren Zahlungsdienstleister Stripe an. Eine Zahlung per Überweisung ist auf Anfrage möglich.",
@@ -1098,6 +1096,14 @@ export const de: Dictionary = {
noOrdersYet: "Noch keine Bestellungen vorhanden.", noOrdersYet: "Noch keine Bestellungen vorhanden.",
}, },
adminDashboardPage: { 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", title: "Dashboard",
statRevenueToday: "Umsatz heute", statRevenueToday: "Umsatz heute",
statRevenueMonth: "Umsatz Monat", statRevenueMonth: "Umsatz Monat",
+12 -6
View File
@@ -182,21 +182,20 @@ export const en: Dictionary = {
{ {
heading: "Information pursuant to § 5 TMG (German Telemedia Act)", heading: "Information pursuant to § 5 TMG (German Telemedia Act)",
paragraphs: [ paragraphs: [
"[PLACEHOLDER: enter the name of the owner / managing director]", "Owner: Nick Rosenlöcher",
"[PLACEHOLDER: enter the legal form, e.g. sole proprietorship/GmbH. If registered in the commercial register: add the register court and registration number.]", "Legal form: sole proprietorship (Einzelunternehmen)",
], ],
}, },
{ {
heading: "VAT ID", heading: "VAT ID",
paragraphs: [ paragraphs: [
"[PLACEHOLDER] VAT identification number pursuant to § 27a of the German VAT Act (UStG).", "VAT identification number pursuant to § 27a of the German VAT Act (UStG): DE366479158",
"If the small business regulation under § 19 UStG applies instead, no VAT ID is required: this should be noted here instead (e.g. \"No VAT is charged pursuant to § 19 UStG.\").",
], ],
}, },
{ {
heading: "Editorially Responsible", heading: "Editorially Responsible",
paragraphs: [ paragraphs: [
"Responsible for editorial content pursuant to § 18(2) MStV (German Interstate Media Treaty), including the articles in our \"Honey Knowledge\" section: [name as above].", "Responsible for editorial content pursuant to § 18(2) MStV (German Interstate Media Treaty), including the articles in our \"Honey Knowledge\" section: Nick Rosenlöcher.",
], ],
}, },
{ {
@@ -385,7 +384,6 @@ export const en: Dictionary = {
"We ship within Germany. Shipping costs depend on the total quantity of all items in your order (multiple products are added together) and are shown to you transparently before you complete your order:", "We ship within Germany. Shipping costs depend on the total quantity of all items in your order (multiple products are added together) and are shown to you transparently before you complete your order:",
tierRange: (min, max, price) => `${min}–${max} jars/items: ${price}`, tierRange: (min, max, price) => `${min}–${max} jars/items: ${price}`,
tierMinimum: (min, price) => `From ${min} jars/items: ${price}`, tierMinimum: (min, price) => `From ${min} jars/items: ${price}`,
tierFallback: "The current shipping costs will be shown to you transparently before you complete your order.",
pickupNote: "Alternatively, you can pick up your order directly from us in Altenmünster by arrangement; no shipping costs apply in that case.", pickupNote: "Alternatively, you can pick up your order directly from us in Altenmünster by arrangement; no shipping costs apply in that case.",
paymentHeading: "Payment Methods", paymentHeading: "Payment Methods",
paymentText: "We offer credit card, SEPA direct debit, Klarna, PayPal, Apple Pay, and Google Pay via our payment provider Stripe. Payment by bank transfer is available on request.", paymentText: "We offer credit card, SEPA direct debit, Klarna, PayPal, Apple Pay, and Google Pay via our payment provider Stripe. Payment by bank transfer is available on request.",
@@ -1098,6 +1096,14 @@ export const en: Dictionary = {
noOrdersYet: "No orders yet.", noOrdersYet: "No orders yet.",
}, },
adminDashboardPage: { 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", title: "Dashboard",
statRevenueToday: "Revenue today", statRevenueToday: "Revenue today",
statRevenueMonth: "Revenue this month", statRevenueMonth: "Revenue this month",
+4 -1
View File
@@ -220,7 +220,6 @@ export interface Dictionary {
shippingIntro: string; shippingIntro: string;
tierRange: (min: number, max: number, price: string) => string; tierRange: (min: number, max: number, price: string) => string;
tierMinimum: (min: number, price: string) => string; tierMinimum: (min: number, price: string) => string;
tierFallback: string;
pickupNote: string; pickupNote: string;
paymentHeading: string; paymentHeading: string;
paymentText: string; paymentText: string;
@@ -798,6 +797,10 @@ export interface Dictionary {
noOrdersYet: string; noOrdersYet: string;
}; };
adminDashboardPage: { adminDashboardPage: {
missingUploadsTitle: (missing: number, checked: number) => string;
uploadsNotPersistentTitle: string;
uploadsNotPersistentHint: (uploadDir: string) => string;
missingUploadsHint: string;
title: string; title: string;
statRevenueToday: string; statRevenueToday: string;
statRevenueMonth: string; statRevenueMonth: string;
+99
View File
@@ -0,0 +1,99 @@
import { access } from "node:fs/promises";
import path from "node:path";
import { prisma } from "@/lib/prisma";
import { asStringArray } from "@/lib/json-array";
import { UPLOAD_DIR } from "@/lib/uploads";
/**
* Gleicht ab, ob die in der Datenbank hinterlegten Bild-/Videopfade auch
* tatsaechlich als Dateien vorliegen.
*
* Hintergrund: Datenbank und Uploads liegen an getrennten Orten. Geht das
* Upload-Verzeichnis verloren — etwa weil es in Kubernetes als "emptyDir"
* statt als PersistentVolumeClaim gemountet ist und damit an die Lebensdauer
* des Pods gebunden bleibt —, behaelt die Datenbank ihre Pfade, die Dateien
* dahinter fehlen aber. Nach aussen sieht das nicht wie ein Fehler aus: es
* erscheint schlicht ueberall ein kaputtes Bild, ohne Fehlermeldung und ohne
* Eintrag im Log.
*
* Genau diese Luecke schliesst der Abgleich: das Admin-Dashboard zeigt den
* Befund beim naechsten Aufruf an, statt dass er erst auffaellt, wenn
* Kundschaft im Shop leere Produktkacheln sieht.
*/
/** Obergrenze fuer die Dateipruefungen — das Dashboard soll nicht an ihnen haengen. */
const MAX_CHECKED_PATHS = 300;
/** Anzahl der Beispielpfade im Befund, damit die Meldung nachpruefbar bleibt. */
const EXAMPLE_COUNT = 3;
const UPLOAD_PATH_PREFIX = "/uploads/";
export interface UploadHealthReport {
/** Wie viele verschiedene Upload-Pfade die Datenbank referenziert. */
referenced: number;
/** Wie viele davon geprueft wurden (durch MAX_CHECKED_PATHS begrenzt). */
checked: number;
/** Wie viele der geprueften Dateien fehlen. */
missing: number;
/** Einige fehlende Pfade als Beleg. */
examples: string[];
}
/** Sammelt alle in Inhalten referenzierten Upload-Pfade, ohne Dubletten. */
async function collectReferencedPaths(): Promise<string[]> {
const [products, harvests, batches, content] = await Promise.all([
prisma.product.findMany({ select: { images: true } }),
prisma.harvest.findMany({ select: { images: true } }),
prisma.batch.findMany({ select: { images: true } }),
prisma.siteContent.findMany({ select: { valueDe: true, valueEn: true } }),
]);
const paths = new Set<string>();
for (const row of [...products, ...harvests, ...batches]) {
for (const image of asStringArray(row.images)) {
if (image.startsWith(UPLOAD_PATH_PREFIX)) paths.add(image);
}
}
// Inhaltsfelder halten einen einzelnen Pfad als kompletten Wert (Bild- und
// Videofelder der Content-Registry) — alles andere ist Fliesstext und wird
// hier nicht durchsucht.
for (const row of content) {
for (const value of [row.valueDe, row.valueEn]) {
const trimmed = value?.trim();
if (trimmed?.startsWith(UPLOAD_PATH_PREFIX)) paths.add(trimmed);
}
}
return [...paths];
}
export async function checkUploadFiles(): Promise<UploadHealthReport> {
const referencedPaths = await collectReferencedPaths();
const toCheck = referencedPaths.slice(0, MAX_CHECKED_PATHS);
const results = await Promise.all(
toCheck.map(async (uploadPath) => {
// Der Pfad in der Datenbank ist die oeffentliche URL ("/uploads/<datei>"),
// auf der Platte liegt die Datei direkt unter UPLOAD_DIR.
const fileName = uploadPath.slice(UPLOAD_PATH_PREFIX.length);
try {
await access(path.join(UPLOAD_DIR, fileName));
return null;
} catch {
return uploadPath;
}
})
);
const missing = results.filter((result): result is string => result !== null);
return {
referenced: referencedPaths.length,
checked: toCheck.length,
missing: missing.length,
examples: missing.slice(0, EXAMPLE_COUNT),
};
}
+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 };
}
}