Der Produkt-Sync bildete den Fall gleichnamiger Jahrgaenge nicht ab. In
ApiCore ist "Waldhonig 500 g" EIN Katalogartikel, an dem jedes Jahr eine
weitere Abfuellung haengt; der Sync legte daraus genau ein Wabenhain-Produkt
an. Ab dem zweiten Jahr passierte damit nichts mehr: das Produkt existierte
schon und wurde uebersprungen, und die Charge des neuen Jahrgangs blieb
unverknuepft, weil linkProductsToBatches nur eingesprungen ist, solange
Product.batchId leer war.
In Wabenhain gehoert ein Produkt zu genau einer Charge, und das ist richtig
so: jeder Jahrgang hat eigenen Preis, eigenen Verkaufstext, eigenes
Geschmacksprofil und eigene Bewertungen. Honig ist ein Naturprodukt, die
Bewertung des Waldhonigs von 2026 sagt ueber den von 2027 nichts aus.
Der Sync laeuft deshalb jetzt ueber die ABFUELLUNGEN statt ueber den
Artikelkatalog und legt je Abfuellung einen Jahrgang an, bereits mit Charge
und Ernte verknuepft. Die nachtraegliche Verknuepfung entfaellt ersatzlos.
Wiedererkannt wird ein Jahrgang ueber die neue Spalte
Product.apicoreBottlingId (nullable, unique; manuell angelegte Produkte
tragen null). Bewusst nicht ueber den Slug: der ist die oeffentliche Adresse
und soll lesbar bleiben. Er traegt stattdessen den Jahrgang
("waldhonig-2026") und faellt bei mehrfacher Abfuellung im selben Jahr auf
die Losnummer zurueck - hochgezaehlt wird nicht, "waldhonig-2026-2" sagt
niemandem etwas.
Weitere Entscheidungen, jeweils zugunsten dessen, was tatsaechlich im Glas
ist: das Gewicht kommt aus der Glasgroesse der Abfuellung statt aus dem
Artikel, und der Lagerbestand startet bei 0 statt bei der Abfuellmenge -
wie viele Glaeser in den Webshop gehen und wie viele in Hofladen oder
Wiederverkauf, entscheidet Wabenhain.
Ein in ApiCore ausgelaufener Artikel bekommt keinen neuen Jahrgang mehr,
bereits importierte bleiben unveraendert im Verkauf: solange Glaeser im
Regal stehen, ist "nicht mehr produziert" kein Grund, den Abverkauf
abzubrechen. Fehlt die Charge eines Jahrgangs noch, meldet der Lauf das als
"wartet auf den Ernten-Sync" statt als Fehler.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
01-configmap.yaml und 02-secret.example.yaml sind jetzt so geschrieben, dass
sie sich als Produktivkonfiguration uebernehmen lassen: in drei Abschnitte
gegliedert (ANPASSEN / Vorgaben / optional), jeder Wert mit der Angabe, was
ohne ihn nicht funktioniert. Anzupassen sind genau zwei Werte in der
ConfigMap und die mit "CHANGE-ME" markierten im Secret; alles Uebrige traegt
eine brauchbare Vorgabe oder darf leer bleiben.
Beide Dateien decken zusammen jede Umgebungsvariable ab, die die Anwendung
liest, und jeder Schluessel steht in genau einer der beiden. Bisher war das
Handarbeit und driftete: GOOGLE_PLACES_API_KEY lag in der unverschluesselten
ConfigMap, APICORE_API_URL unnoetig im Secret.
Neu ist deshalb src/lib/k8s-manifest-env.test.ts. Der Test liest die
process.env-Zugriffe unter src/ und die Schluessel der beiden Manifeste und
prueft vier Zusagen: keine gelesene Variable fehlt, kein Schluessel steht
doppelt (sonst entschiede allein die Reihenfolge der envFrom-Eintraege,
welcher Wert gewinnt), kein Schluessel ist tot, und die committete
Secret-Vorlage enthaelt keine ausgefuellten Werte.
Beim ersten Lauf hat der Test zwei Dinge gefunden. ADMIN_EMAIL und
ADMIN_PASSWORD liest nicht die App, sondern prisma/seed.ts — das ist
legitim und steht jetzt begruendet in einer Ausnahmeliste, zusammen mit
DB_PASSWORD fuer das Datenbank-StatefulSet. STRIPE_PUBLISHABLE_KEY dagegen
wird von keiner einzigen Code-Stelle gelesen: der Checkout laeuft
vollstaendig serverseitig ueber eine Stripe-Checkout-Session. Der Schluessel
ist aus Secret-Vorlage, .env.example, docker-compose.yml und README
entfernt, mit Hinweis, wofuer man ihn braeuchte.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Ein Video ueber 50 MB im Admin hochzuladen, liess den Pod mitten im Upload
neu starten. Ursache war nicht die Dateigroesse, sondern dass ffmpeg sich
nach den Kernen des KNOTENS bemessen hat statt nach dem CPU-Anteil des Pods.
x264 waehlt seine Threadzahl standardmaessig als das 1,5-fache der vom
Betriebssystem gemeldeten Kerne. In einem Container sind das die Kerne des
Knotens: auf einer 16-Kern-Maschine rund 24 Encoder-Threads mit je eigenen
Bildpuffern, bei 4K-Material rund 12 MB je Bild. Das hatte zwei Folgen, die
von aussen beide gleich aussehen:
1. Der Speicherbedarf sprengte das Speicherlimit des Pods, der Kernel
beendete den Container (OOMKilled).
2. ffmpeg belegte alle Kerne, der Node-Prozess kam nicht mehr zum Zug und
beantwortete die Liveness-Probe nicht rechtzeitig. Kubelet wertete das
als haengenden Prozess und startete den Container neu.
Dekoder-, Filter- und Encoder-Threads sind jetzt gedeckelt (Vorgabe 2, ueber
VIDEO_TRANSCODE_THREADS auf bis zu 8 anhebbar). Der Verbrauch ist damit
konstant und unabhaengig davon, auf welchem Knoten der Pod laeuft. Der
"-threads"-Deckel steht bewusst auch VOR "-i": ffmpeg ordnet Optionen der
naechsten Datei zu, ein Deckel erst hinter "-i" wuerde nur den Encoder
begrenzen und das Dekodieren eines 4K-Videos unbegrenzt lassen.
Zusaetzlich "-nostdin" und stdio ["ignore","ignore","pipe"]: der
Kindprozess bekam von spawn() eine Standardeingabe, die niemand bedient,
und eine unbeachtete stdout-Pipe, die volllaufen kann. Beides konnte
ffmpeg bis zum Zeitlimit blockieren.
Die Argumentliste ist als buildFfmpegArgs() ausgelagert und getestet, damit
die Begrenzungen pruefbar sind, ohne ffmpeg wirklich zu starten.
Beide Probes bekommen ausserdem timeoutSeconds: 5. Ohne den Eintrag gilt
Kubernetes' Vorgabe von einer Sekunde — zu knapp, solange sich der
Node-Prozess die CPU mit einem laufenden Transcode teilt. k8s/README.md
beschreibt die Diagnose fuer den Fall, dass im Cluster noch eine aeltere,
von Hand gepflegte Fassung des Deployments laeuft.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Der Shop konnte Ernten und Chargen bereits aus ApiCore uebernehmen, den
Produktkatalog aber nicht - jedes Glas musste im Admin ein zweites Mal von
Hand angelegt werden. Neuer Knopf unter /admin/produkte zieht die Produkte
und traegt anschliessend die passende Charge am Produkt nach, wo ApiCore
die Abfuellung einem Produkt zugeordnet hat. Genau diese Verknuepfung
fuettert die Herkunftsseite hinter dem QR-Code auf dem Glas.
ApiCore fuehrt ueberhaupt keine Verkaufspreise. Importierte Produkte
entstehen deshalb als deaktivierte Entwuerfe mit Preis 0, den nur ein
Mensch setzen kann. Damit daraus nie eine Gratis-Bestellung wird, lehnen
die Aktivierung im Admin und der Checkout ein Produkt ohne Preis jetzt
ausdruecklich ab, und die Produktliste kennzeichnet solche Entwuerfe.
Ein bereits vorhandenes Produkt wird uebersprungen, nie ueberschrieben:
genau die drei Felder, die ApiCore liefert (Name, Beschreibung, Gewicht),
zieht der Admin vor der Freischaltung von Hand nach - ein Update wuerde
diese Arbeit bei jedem weiteren Lauf wegwerfen. Ein in ApiCore
ausgelaufenes Produkt wird nicht automatisch deaktiviert, sondern nur
gemeldet, damit der Restbestand abverkauft werden kann.
Nebenbei behoben: die beiden Produktformulare und die Bestelluebersicht
haben den ?error=-Parameter aus fail() nie ausgegeben - abgelehnte
Eingaben landeten wortlos wieder auf dem Formular.
Der Login gegen ApiCore ist jetzt von den Abfragen getrennt
(openApiCoreSession), damit ein Sync-Lauf mit einer einzigen Anmeldung
auskommt statt mit dreien. Die Feld-Abbildung liegt neu in
lib/apicore-mapping.ts und ist damit testbar, statt hinter "use server"
zu verschwinden.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
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]>
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]>
Im Checkout laesst sich unter "Versand" jetzt zwischen der eigenen
Anschrift und einer DHL-Packstation waehlen. Postleitzahl eingeben,
suchen, Packstation aus der Trefferliste anklicken, DHL-Postnummer
eintragen — die Auswahl wird an der Bestellung gespeichert.
Aufbau:
- lib/dhl-locations.ts: Client fuer die DHL Location Finder Unified API
(GET /find-by-address). Bewusst getrennt von lib/dhl.ts — das ist ein
anderer DHL-Dienst mit anderer Authentifizierung: nur der API-Key im
Header, kein OAuth und kein Geschaeftskundenvertrag. Die
Packstation-Auswahl funktioniert damit auch, solange die
Versandmarken-Erstellung noch nicht eingerichtet ist.
- api/shop/packstationen: serverseitiger Zwischenschritt, damit der
DHL-API-Key nicht ins Browser-Bundle geraet. Streng rate-limitiert
(15/Minute statt 60 wie die Produktsuche) und mit 15-Minuten-Cache:
dahinter steht ein FREMDES Kontingent von 500 Aufrufen pro Tag, das ein
ungebremstes Suchfeld an einem Nachmittag aufbrauchen koennte.
- Order/Address: packstationNumber + postNumber (Migration
20260915222938_add_packstation_delivery, beide nullable).
- checkoutSchema: Postnummer als 6-10 Ziffern, plus zwei refine-Regeln —
ohne Postnummer keine Packstation (DHL verlangt sie zwingend), und eine
Packstation nur bei Versand, nicht bei Abholung.
Zwei Entscheidungen, die Folgearbeit ersparen:
- Bei Packstation-Auswahl tragen street/zip/city zusaetzlich die lesbare
Anschrift ("Packstation 145", PLZ, Ort). Dadurch zeigen Rechnung,
Bestaetigungsmail, Sendungsverfolgung und Admin-Ansicht ohne jede
Aenderung etwas Sinnvolles; packstationNumber/postNumber tragen daneben
das, was DHL technisch braucht.
- lib/dhl.ts baut daraus einen Locker-Empfaenger statt einer
Hausanschrift. DHL kennt fuer den Empfaenger zwei sich ausschliessende
Formen; eine Packstation als Strassentext zu schicken ergaebe eine Marke
auf eine Anschrift, die es nicht gibt.
Die Option erscheint nur, wenn DHL_API_KEY gesetzt ist — sonst wuerde
Kundschaft in eine Suche laufen, die immer fehlschlaegt.
Geprueft: sechs Tests fuer das Umsetzen der DHL-Antwort (die
Packstation-Nummer steht in location.keywordId, NICHT in
location.ids[0].locationId — letzteres ist ein Systemcode, mit dem weder
Kundschaft noch DHL etwas anfangen kann). Dazu am laufenden
Produktions-Build: ohne Key liefert der Endpunkt 503 und der Checkout
zeigt die Option gar nicht; mit Key 400 bei ungueltiger PLZ und ein echter
Aufruf an api.dhl.com, der mit 401 quittiert wird — URL, Header und
Anfrageformat stimmen also. Im Browser durchgeklickt: Umschalten blendet
das Strassenfeld aus und Postnummer samt Suche ein, die Suche ruft die
Route und zeigt die Fehlermeldung an.
Feldnamen beider DHL-APIs vorab gegen developer.dhl.com geprueft. Ein
Lauf gegen einen echten Schluessel steht noch aus.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Die Chargenseite (/charge/[code]) zeigt zwei Bildkacheln: links Ernte und
Jahr, rechts den Standort. Gefuellt wurden sie aus einer Bilder-LISTE, in
der allein die Reihenfolge entschied, welches Bild wohin kommt.
Damit liess sich das zweite Bild nicht allein setzen. Wer nur ein Foto vom
Standort pflegen wollte, bekam es auf Position 0 — also in die linke
Kachel, wo Ernte und Jahr stehen sollten. Welche Kachel ein Bild fuellt,
war im Formular ausserdem nirgends erkennbar.
Jetzt zwei benannte Slots statt einer Liste, im Chargen- wie im
Ernten-Formular:
Bild 1 — linke Kachel auf der Chargenseite
Bild 2 — rechte Kachel auf der Chargenseite (Standort)
Jeder Slot hat eine eigene Vorschau, einen eigenen Auswahl- und einen
eigenen Entfernen-Knopf. Beide tragen denselben Feldnamen "images" und
werden in dieser Reihenfolge uebermittelt.
Dafuer brauchte es einen positionserhaltenden Parser: strArray() trimmt
und filtert leere Werte heraus, wodurch ein leerer erster Slot
verschwunden und der zweite nach vorn gerutscht waere — genau der Fehler,
der behoben werden sollte. Neu ist strArraySlots(), das leere Slots als ""
stehen laesst und nur am Ende stehende entfernt (ein leeres Feld ergibt
weiterhin [] statt ["", ""]).
Die Konsumenten mussten entsprechend von "??" auf "||" wechseln: ein im
Admin geleerter Slot kommt als leerer String an, nicht als undefined. Mit
"??" haette dieser leere String als gueltiger Wert gegolten und das
Erntebild darunter verdeckt (charge/[code], ernten, ernten/[year]/[type]).
strArray/strArraySlots sind nach lib/form-fields.ts gewandert und werden
aus _shared.ts re-exportiert — saemtliche bestehenden Importe bleiben
unveraendert. Grund: _shared.ts zieht ueber lib/prisma.ts einen
Datenbank-Adapter nach und liess sich ohne DATABASE_URL nicht testen. Als
reine Funktionen ohne Datenbankbezug sind die beiden jetzt direkt
pruefbar, passend zur bereits dokumentierten Teststrategie des Projekts.
Sieben neue Tests decken den Kernfall ab (leerer erster Slot bleibt
offen), mit Gegenprobe, dass strArray denselben Fall weiterhin
zusammenrutschen laesst.
Produktbilder bleiben bewusst bei der bisherigen Liste: dort ist die
Bilderzahl offen und die Reihenfolge traegt keine Kachel-Bedeutung.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Nachzug zu d3020cd: email-templates.ts nutzte fuer den Bestaetigungs-Link
bereits getBaseUrl(), fuer den Produkt- und den Shop-Link in derselben
Datei aber weiterhin "https://" + siteConfig.domain. Eine
Wiederverfuegbarkeits-Mail konnte dadurch einen Link auf eine andere Domain
enthalten als die Bestellbestaetigung daneben.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Die Adresse der Seite war an zwei Stellen gepflegt: E-Mails nutzten
NEXT_PUBLIC_SITE_URL, alles SEO-Relevante dagegen ein fest verdrahtetes
"https://" + siteConfig.domain (16 Stellen, darunter eine roh eingetippte
"https://wabenhain.com" im Produkt-JSON-LD).
Laeuft der Shop unter einer anderen Domain als wabenhain.com — Staging,
Vorschau-Host, spaeterer Domainwechsel, der im k8s-Ingress hinterlegte Host
— zeigten damit sitemap.xml, robots.txt, saemtliche Canonical-Tags und alle
strukturierten Daten geschlossen auf eine fremde Adresse. Google verwirft
eine Sitemap mit fremden Hosts, und ein Canonical auf eine andere Domain
weist die Indexierung aktiv von der echten Seite weg.
Jetzt eine einzige Quelle: getSiteUrl() in lib/url.ts, mit
siteConfig.domain als Rueckfallebene, damit eine vergessene
Umgebungsvariable nicht "http://localhost:3000" in einer oeffentlichen
Sitemap landen laesst. getBaseUrl() bleibt als Alias fuer den in
E-Mail-Code eingebuergerten Namen und liefert denselben Wert.
robots.ts bekommt dabei "force-dynamic" (wie sitemap.ts es schon hatte):
ohne das rendert Next.js die Datei beim Build statisch vor und backt den
Wert ein, den NEXT_PUBLIC_SITE_URL genau dann hatte. Im Docker-Build ist
die Variable nicht gesetzt — die Sitemap-Zeile waere also dauerhaft auf dem
Rueckfallwert eingefroren. Gegen einen laufenden Produktions-Build
geprueft: robots.txt, sitemap.xml, Canonical-Tags und JSON-LD folgen jetzt
alle dem zur Laufzeit gesetzten Wert.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Der Zielcluster nutzt Traefik, nicht ingress-nginx. Saemtliche Annotationen
der Form nginx.ingress.kubernetes.io/* ignoriert Traefik ersatzlos und
still: sie erzeugen keinen Fehler, wirken aber auch nicht. Das betraf das
Upload-Limit (proxy-body-size) ebenso wie die Zeitlimits, und
ingressClassName zeigte auf "nginx".
Damit aendert sich auch die Diagnose des fehlgeschlagenen Video-Uploads.
Belegt aus Traefiks Quelltext (pkg/config/static/static_config.go,
SetDefaults) und der Referenzdokumentation:
- readTimeout des EntryPoints: Default 60s, beschrieben als "maximum
duration for reading the entire request, including the body". Er umfasst
also die gesamte Uebertragung des Uploads. Bei 20-100 MB ueber eine
normale Upload-Leitung ist das schnell ueberschritten — 80 MB bei
10 Mbit/s sind rund 64 Sekunden. Laeuft er ab, kappt Traefik die
Verbindung und antwortet mit einer eigenen HTML-Fehlerseite. Das ist die
wahrscheinlichste Quelle des "Unexpected token '<'" im Browser.
- responseHeaderTimeout: von SetDefaults NICHT gesetzt, bleibt 0, und
"If zero, no timeout exists". Die Dauer des ffmpeg-Transcodings ist bei
Traefik also NICHT begrenzt — anders als zuvor angenommen.
- Body-Groesse: Traefik erzwingt keine Obergrenze (begrenzt ist nur
maxHeaderBytes mit 1 MB fuer die Header). Ein Gegenstueck zu nginx'
proxy-body-size wird nicht gebraucht.
readTimeout gehoert zur statischen EntryPoint-Konfiguration und laesst sich
nicht per Ingress-Annotation setzen. Das Manifest dokumentiert deshalb
ausfuehrlich, welche Einstellung in der Traefik-Installation noetig ist
(Helm: ports.websecure.transport.respondingTimeouts.readTimeout: 600s).
Weiter angepasst: ingressClassName auf "traefik", HTTPS-Umleitung ueber eine
Traefik-Middleware statt der nginx-Annotation, und der Hinweis zu
X-Forwarded-For nennt jetzt Traefiks forwardedHeaders.trustedIPs — davon
haengt ab, ob lib/rate-limit.ts dem Header trauen darf. Die Kommentare in
lib/uploads.ts und lib/csrf.ts verwiesen auf die entfernten
nginx-Einstellungen und sind entsprechend berichtigt.
Vier offene Befunde standen einem sauberen Produktionslauf im Weg:
- recently-viewed-section und site-chrome riefen setState direkt im
Effektrumpf auf, um einen Leerzustand herzustellen ("keine angesehenen
Produkte" bzw. "Eingabe kuerzer als zwei Zeichen"). Beides ergibt sich
unmittelbar aus vorhandenen Werten und wird jetzt beim Rendern
abgeleitet — das spart je einen zusaetzlichen Renderdurchlauf. Bei der
Suche verschwindet die Vorschlagsliste dadurch sofort beim Leeren des
Feldes statt erst nach dem naechsten Effektlauf.
- STAT_KEYS im Admin-Dashboard wurde ausschliesslich als Typ verwendet,
landete als `as const`-Array aber trotzdem im Bundle. Ersetzt durch
einen Typ-Alias.
- Ein DHL-Test importierte DhlApiError, ohne es zu benutzen, und pruefte
nur den Meldungstext — er haette damit jeden beliebigen anderen Fehler
mit passendem Text durchgehen lassen. Er prueft jetzt zusaetzlich den
Fehlertyp, wie seine beiden Geschwistertests.
Stand danach: eslint ohne Beanstandung (vorher 2 Fehler, 2 Warnungen),
tsc ohne Fehler, 201 Tests gruen, Produktions-Build erfolgreich.
Die Route las ein bis zu 100 MB grosses Video gleich mehrfach vollstaendig
in den Node-Heap: einmal aus file.arrayBuffer(), einmal als Buffer daraus,
und das Transcoding-Ergebnis noch einmal aus readFile(). Zusammen mit dem
Grundverbrauch des Next.js-Servers sprengt das ein Speicherlimit von
512Mi, wie es k8s/05-app-deployment.yaml bisher setzte — der Kernel
beendet den Prozess dann mitten im Upload (OOMKilled). Im Browser kommt
davon nur die HTML-Fehlerseite des Reverse-Proxy an, die vorher als
"Unexpected token '<', "<!DOCTYPE "... is not valid JSON" erschien.
Die Datei wandert jetzt streamend auf die Platte (file.stream() ->
createWriteStream), ffmpeg liest und schreibt ohnehin dort, und das
Ergebnis wird per rename() an seinen Platz verschoben — mit copyFile()
als Rueckfallebene, weil rename() ueber Dateisystemgrenzen hinweg mit
EXDEV scheitert und UPLOAD_DIR in Produktion auf einem eigenen Volume
liegt. Die Formaterkennung liest nur noch die ersten 12 Bytes statt der
ganzen Datei. transcodeVideoForWeb() arbeitet entsprechend auf
Dateipfaden statt auf Buffern.
Ausserdem faengt die Route jetzt jeden verbleibenden unerwarteten Fehler
ab und antwortet auch dann mit JSON: eine durchschlagende Exception
liesse Next.js eine HTML-Fehlerseite ausliefern, aus der der Client keine
Meldung gewinnen kann.
Verifiziert mit einem 83,8-MB-Video: 200 mit JSON-Body, Ergebnis 10,3 MB,
Auslieferung inkl. Range-Anfrage (206) korrekt.
Alle drei Admin-Upload-Felder riefen response.json() ungeprueft auf. Kam
statt JSON eine HTML-Seite zurueck, warf der Parser
Unexpected token '<', "<!DOCTYPE "... is not valid JSON
und genau dieser Text landete im Formular. Damit war die eigentliche
Ursache vollstaendig verdeckt: weder der HTTP-Status noch die Schicht,
die abgebrochen hat, waren daraus ablesbar.
Der Punkt dabei: die Upload-Route-Handler antworten ausnahmslos mit JSON,
auch in jedem Fehlerfall — lokal verifiziert bis hinauf zur
Limit-Ueberschreitung (105 MB -> 413 mit JSON-Body). Trifft im Browser
trotzdem HTML ein, stammt die Antwort gar nicht aus der Anwendung,
sondern aus einer Schicht davor: Reverse-Proxy, CDN oder Gateway, das den
Request wegen zu grossem Body abgewiesen oder wegen Zeitueberschreitung
beim Transcoding abgebrochen hat.
readUploadResponse() in lib/upload-client.ts prueft deshalb zuerst den
Content-Type und reicht andernfalls den HTTP-Status nach oben, statt zu
parsen. Die Felder zeigen daraus eine Meldung, die den Status nennt und
die wahrscheinliche Ursache benennt (413 = vom Proxy abgewiesen,
502/503/504/522/524 = Zeitueberschreitung beim Umrechnen, sonst
unerwartet). Ein Verbindungsabbruch, bei dem fetch() selbst wirft, wird
getrennt gemeldet.
Das behebt nicht die Ursache jenseits der Anwendung — es macht sie zum
ersten Mal ablesbar, statt sie hinter einem Parser-Fehler zu verbergen.
Vier Befunde aus der Pruefung der Upload-Kette, alle am gemeinsamen
Datenpfad lib/uploads.ts und app/uploads/[...path]/route.ts:
1. Kein Range-Support. Die Ausliefer-Route beantwortete auch einen
Range-Request mit einer vollstaendigen 200-Antwort. Ein <video> laedt
sein Material grundsaetzlich per Range; iOS-Safari schickt zuerst die
Probe "bytes=0-1" und spielt bei einer 200-Antwort gar nicht ab.
Springen im Hero-Video war ebenfalls unmoeglich. Die Route beantwortet
Bereichsanfragen jetzt mit 206 samt Content-Range, meldet
Accept-Ranges und antwortet auf einen unerfuellbaren Bereich mit 416.
2. Ganze Datei im Speicher. readFile() las jede Datei vollstaendig in den
RAM — bei einem Hero-Video bis MAX_UPLOAD_VIDEO_BYTES pro
gleichzeitigem Zugriff. Ersetzt durch einen Stream von der Platte,
inklusive Content-Length (vorher chunked, ohne Groessenangabe).
3. Keine bedingten Anfragen. Ohne ETag lud der Browser bei jedem Reload
den vollen Bildinhalt erneut. ETag aus Groesse + Aenderungszeit, 304
auf If-None-Match.
4. Bilder wurden in Originalaufloesung gespeichert. Ein 24-MP-Foto lag
mit 6000x4000 und 9 MB auf dem Volume, und next/image musste dieses
Bild fuer JEDE ausgelieferte Breite komplett neu dekodieren.
reencodeImage() begrenzt jetzt auf MAX_IMAGE_DIMENSION (2560px lange
Kante, Seitenverhaeltnis erhalten, kleinere Bilder werden nie
hochskaliert).
Zusaetzlich MAX_UPLOAD_BYTES von 8 auf 25 MB angehoben: 8 MB lagen unter
dem, was aktuelle Handykameras routinemaessig erzeugen — ein normales
Foto brach mit "Die Datei ist zu gross" ab, obwohl es serverseitig
ohnehin verkleinert wird. Gegen eine Dekompressionsbombe (kleine Datei,
die sich zu Milliarden Pixeln entpackt) bekommt sharp mit
MAX_INPUT_PIXELS eine ausdrueckliche Obergrenze von 100 MP, die die
Byte-Grenze allein nicht abdeckt.
Gemessen an einem 10,17-MB-Foto mit 6000x4000: vorher mit 413 abgelehnt,
jetzt als 2560x1707 / 1,88 MB gespeichert; next/image kalt fuer 640px von
180 ms auf 65 ms.
Der Kommentar zur proxy-body-size in k8s/06-ingress.yaml nennt jetzt das
neue Bild-Limit; die Annotation selbst (110m) deckt es unveraendert ab.
Die Same-Origin-Pruefung verglich den Origin-Header gegen
new URL(request.url).host. Next.js rekonstruiert request.url aber aus der
internen Bind-Adresse des Servers, NICHT aus dem Host-Header, den der
Browser tatsaechlich adressiert hat — reproduzierbar liefert request.url
"localhost:3999", waehrend der Browser "127.0.0.1:3999" aufgerufen hat.
Damit schlug jeder Admin-Upload mit 403 fehl, sobald die Seite unter einem
anderen Namen als dieser internen Adresse erreicht wurde: oeffentliche
Domain hinter dem Reverse-Proxy, LAN-IP, oder schlicht 127.0.0.1 statt
localhost. Der 2025 nachgeruestete Fallback auf NEXT_PUBLIC_SITE_URL half
nur, wenn diese Variable gesetzt ist und exakt passt — in .env fehlt sie.
Der Origin wird jetzt gegen alle plausiblen eigenen Hosts geprueft, in
dieser Reihenfolge: X-Forwarded-Host (vom eigenen Proxy gesetzt, Caddy
verwirft einen vom Client mitgeschickten Wert), Host (was der Browser
adressiert hat — derselbe Vergleich, den Next.js fuer Server Actions
intern anstellt), request.url und zuletzt NEXT_PUBLIC_SITE_URL.
Der CSRF-Schutz bleibt dabei vollstaendig: eine fremde Seite kann bei
einem Cross-Site-Request weder Host noch X-Forwarded-Host bestimmen — den
Host setzt der Browser aus der Ziel-URL, eigene Header liessen sich nur
per fetch() setzen, was einen CORS-Preflight ausloest, den dieser
Endpunkt nicht beantwortet. Ein gefaelschter POST traegt also weiterhin
zwangslaeufig einen fremden Origin und wird abgewiesen (durch Test und
manuelle Gegenprobe belegt).
Chargen hatten bisher kein eigenes Bild-Feld (nur Ernten und Produkte) —
neue Migration ergaenzt Batch.images (Json-Array, gleiches Muster wie
Harvest.images/Product.images). Admin kann Chargenfotos jetzt unter
/admin/chargen/[id] hochladen (AdminImageListField, identisch zum
Ernten-Formular). Die oeffentliche Chargen-Seite (/charge/[code]) zeigt
diese Fotos jetzt bevorzugt, faellt ohne eigene Chargenbilder weiterhin
auf die Erntebilder zurueck.
Hinweis fuer den naechsten Live-Deploy: die generierte Migration hat
nebenbei eine bereits laenger bestehende Schema-Abweichung mitkorrigiert
(harvests.images und products.images/tasteTags werden jetzt als NOT NULL
erzwungen, wie im Schema schon laenger deklariert) - "prisma migrate
deploy" schlaegt fehl, falls dort noch echte NULL-Werte stehen sollten.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Fehlerbild "Unexpected token '<' ... is not valid JSON" nach dem
ffmpeg-Fix: kein Absturz mehr, sondern vermutlich ein Timeout - das
CDN/Reverse-Proxy (Cloudflare) vor der Seite bricht selbst ab, wenn der
Origin-Request zu lange nicht antwortet, und liefert dafuer eine eigene
HTML-Fehlerseite statt der JSON-Antwort. "medium"-Preset fuer ein
57-MB-Rohvideo konnte laenger brauchen, als das CDN wartet. "veryfast"
tauscht nur Encodier-Geschwindigkeit gegen Kompressionseffizienz (nicht
gegen das per -crf gesteuerte Qualitaetsziel), reduziert die
Encodierzeit deutlich.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Live-Log zeigte: spawn .../ffmpeg-static/ffmpeg ENOENT bei jedem
Transcode-Versuch, unabhaengig vom Videoformat (.mov war nur zufaellig
der zuletzt getestete Fall). Die ffmpeg-static-Binary aus der
migrator-Docker-Stage kam nicht zuverlaessig im finalen Image an.
Root-Fix: ffmpeg jetzt per "apk add ffmpeg" als System-Paket in der
runner-Stage installiert - liegt fest unter /usr/bin auf PATH,
unabhaengig von jeder Node/pnpm-Kopiermechanik. lib/video-transcode.ts
versucht zuerst "ffmpeg" ueber PATH und faellt nur bei ENOENT (kein
System-ffmpeg vorhanden, typischerweise lokale Windows-/Mac-Entwicklung)
auf die per npm mitgelieferte ffmpeg-static-Binary zurueck, damit
"npm run dev" weiterhin ohne manuelle Einrichtung funktioniert. Beide
Pfade lokal gegen ein synthetisches Testvideo verifiziert.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Ein direkt von Kamera/Handy hochgeladenes Video (57 MB) lud spuerbar
langsam: keine Kompression, kein Ton entfernt, und ohne "faststart"
musste der Browser oft die gesamte Datei laden, bevor die Wiedergabe
ueberhaupt beginnen konnte.
Neues lib/video-transcode.ts (ffmpeg-static, per pnpm-workspace.yaml
allowBuilds freigegeben) enkodiert jedes hochgeladene Video jetzt zu
einem web-optimierten MP4 neu: Ton entfernt (Hero-Video ist ohnehin
stumm), auf max. 1920px Breite skaliert, CRF 26 mit Bitratendeckel,
"+faststart" fuer sofortigen Wiedergabestart. Nebeneffekt: ein
erfolgreicher Transcode validiert das Video jetzt genauso vollstaendig
wie reencodeImage() das bei Bildern schon tut (nicht mehr nur
Magic-Bytes). ffmpeg-static-Binary wird in der migrator-Docker-Stage
mitinstalliert (Next.js' Standalone-Tracing erkennt den Pfad hinter dem
pnpm-Store-Symlink nicht zuverlaessig), lokal per npm-Postinstall ohne
manuelle Einrichtung.
Getestet mit synthetischem Testvideo: Transcode laeuft durch, Ausgabe
enthaelt keine Audiospur mehr, ist als H.264/MP4 dekodierbar.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
40 MB war fuer ein reales, nicht web-optimiertes Hero-Video (57 MB) zu
knapp. Ingress-Body-Size-Limit entsprechend mitgezogen.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
isTrustedOrigin() verglich den Origin-Header ausschliesslich gegen den von
Next.js aus request.url rekonstruierten Host. Hinter dem eigenen
Caddy/nginx-Reverse-Proxy (docker-compose.yml, k8s/) ist dieser Wert nicht
in jeder Konstellation zuverlaessig derselbe wie der echte, oeffentliche
Host im Origin-Header - das fuehrte live zu "Ungueltige Anfrage." bei
jedem Admin-Upload, obwohl der Request vom eigenen Frontend kam. Prueft
jetzt zusaetzlich gegen getBaseUrl() (NEXT_PUBLIC_SITE_URL), genau wie
lib/url.ts es fuer denselben Zweck bereits vormacht - als reine
ODER-Erweiterung, damit ein falsch gesetztes NEXT_PUBLIC_SITE_URL nicht
umgekehrt bislang funktionierende Endpunkte wie /api/checkout blockiert.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Content-Editor zeigt Bild-/Video-Felder jetzt gebuendelt oben und
Textfelder gebuendelt darunter (je Seite ein eigenes Speichern-Formular),
statt beides pro Bereich gemischt untereinander. Ausserdem alle
Gedankenstrich-Konstruktionen und aehnliche KI-Textmuster aus den
Website-Texten (de/en) entfernt und durch natuerlichere Formulierungen
ersetzt.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Neues admin-editierbares Hintergrundvideo fuer den Startseiten-Hero
(home.hero.video, MP4/WebM) mit eigenem, magic-byte-validiertem
Upload-Endpunkt (app/api/admin/upload-video) analog zur bestehenden
Bild-Upload-Pipeline. Ausserdem: ScrollReveal-Animation von der
Startseite auf Imkerei, Herkunft, Unternehmen, Ernten, Wissen, Kontakt,
Shop sowie die Produkt-/Ernte-/Chargen-Detailseiten ausgerollt (bisher
nur Startseite), priority auf bislang lazy geladene Above-the-fold-Bilder
(Herkunft/Unternehmen) gesetzt, und passende sizes-Hints auf Grid-Bilder
(Produktkarten, Origin/Regional/Business-Teaser, Ernten-Liste) ergaenzt
statt voller Viewport-Breite.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Same gap as /imkerei and /herkunft (previous commit): last remaining
hardcoded PlaceholderImage in the app, now wired to the same
content-registry "pages" group / ContentImage pattern.
Verified: tsc clean, 151/151 tests pass, page still returns 200.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Both pages are the ones most directly meant to give customers "ein
gutes Bild von der Imkerei" (per the active /goal), but their hero
images were still hardcoded PlaceholderImage tiles with no way to
ever swap in a real photo — unlike every homepage section, which
already went through this exact upload flow.
Added imkerei.image / herkunft.image to the content registry (new
"pages" group, since neither belongs under "Homepage") and switched
both pages from PlaceholderImage to ContentImage + getContentValue,
the same pattern already proven on the homepage sections. Falls back
to the identical placeholder tile until a photo is uploaded, so this
is a pure addition with no visual change until the owner uploads one.
Verified: tsc clean, 151/151 tests pass, both pages still render
correctly (curl 200 + screenshot) with the placeholder fallback.
Could not verify the admin upload UI end-to-end live — the local
admin account has 2FA enabled and I don't have the second factor;
confidence instead comes from reusing the identical, already-verified
AdminImageField/ContentImage wiring used for the four home.*.image
fields, plus generic (group-agnostic) admin field loading confirmed
by reading admin/inhalte/page.tsx.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Dispatched a read-only silent-failure audit (silent-failure-hunter
agent) over the checkout/webhook/DHL/mail code paths, then verified
each finding by hand before fixing. Four confirmed, real gaps:
- Stripe webhook (fulfillOrder): Payment/Invoice creation and the
confirmation email ran after the order's status flip to "BEZAHLT",
outside any try/catch. If any of those three failed (DB blip), the
handler threw uncaught -> Stripe retried -> the retry's
status==="NEU" guard now found 0 rows and silently skipped the
retry forever, leaving a paid order with no invoice, no payment
record, and no confirmation email, with nothing logged. Fixed by
allowing "BEZAHLT" as a valid retry entry point (idempotent
upserts) and wrapping the post-transaction block in try/catch that
logs loudly and re-throws (so Stripe still retries).
- DHL label creation: the already-billed tracking number's DB write
sat outside the try/catch that protects the (paid) DHL API call.
A failed write there lost the tracking number with zero record of
it, while the claim sentinel permanently blocked any retry through
the UI. Fixed by wrapping the write, logging the tracking number
for manual reconciliation, and deliberately leaving the claim in
place (not resettable) to avoid triggering a second, separately
billed label.
- Checkout route: a DB write failure for the (currently unread,
write-only) stripeCheckoutSessionId field discarded an already
live, payable Stripe session and showed the customer a generic
error, risking a duplicate order + stock reservation on retry.
Made that specific write best-effort/non-fatal.
- DHL fetch calls had no request timeout, so a hung (non-erroring)
DHL API could block an admin action indefinitely. Added a 20s
AbortSignal.timeout to both.
Verified: tsc clean, 151/151 existing tests still pass. No dedicated
DB-integration test harness exists yet for route/webhook handlers in
this codebase (only pure-function unit tests) — building one is a
separate, larger undertaking than this fix warrants.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
- Apple Pay/Google Pay geprueft (Stripe-Doku via `stripe docs`): bei der hier
verwendeten vollstaendig gehosteten Checkout-Seite (kein payment_method_types,
kein ui_mode: "embedded") bereits automatisch enthalten, sobald Kartenzahlung
aktiv ist - keine Codeaenderung noetig, keine Domain-Registrierung noetig
(die betrifft laut Stripe nur Elements/eingebettete Checkout-Seite). Klaerenden
Kommentar in api/checkout/route.ts und README-Abschnitt "Stripe" ergaenzt.
- versand-zahlung-Seite nannte nur 4 von 6 tatsaechlich akzeptierten
Zahlungsmethoden (SEPA-Lastschrift und Klarna fehlten, obwohl sie laut README
aktiv unterstuetzt werden und auf der Checkout-Seite selbst bereits korrekt
gelistet sind) - Text in beiden Sprachen an die vollstaendige Liste angeglichen.
- Transaktions-E-Mails durchgesehen: Layout/Branding/Track-Order-Button bei
Bestellbestaetigung und Versand-Mail bereits solide, keine Aenderung noetig.
Die Zahlungsfehler-Mail forderte im Fliesstext zur erneuten Bestellung auf,
hatte dafuer aber keinen anklickbaren Link - jetzt ein echter Button zum Shop.
Verifiziert: tsc --noEmit sauber, pnpm test 151/151 gruen, pnpm build erfolgreich.
- Produktdetailseite: dezenter Hinweis direkt bei der Kaufentscheidung, dass
beim Checkout eine persoenliche Geschenknachricht hinterlegt werden kann
(Feature existierte bereits, war aber nur im Checkout-Formular selbst
sichtbar — zu spaet, um Mengen-/Produktwahl noch zu beeinflussen)
- Produktdetailseite (Honig): kontextuelle Links zu passenden
Honigwissen-Artikeln (Kristallisation immer, Fruehtracht/Sommertracht
wenn aus Harvest.type erkennbar) direkt bei den Produktdetails statt
einer isolierten, von der Kaufseite aus unverlinkten Wissenssektion
- Suche (Shop-Uebersicht + Live-Suchvorschlaege) gleicht jetzt deutsche
Umlaute/scharfes S gegen ihre ASCII-Transliteration ab (z.B. "Bluete"
findet "Bluete"-Produkte, "kraeftig" findet "kräftig") — vorher lieferte
eine Suche in der jeweils anderen Schreibweise keine Treffer, obwohl der
Katalog durchgaengig echte Umlaute verwendet; Logik in neuer, reiner
lib/search-match.ts mit eigenen Tests ausgelagert
- Bewusst NICHT angefasst: Newsletter-Rabattanreiz (fehlt, aber Prozentsatz/
Mechanik ist eine Owner-Entscheidung) und Trust-Badges am Kaufabschluss
(bereits vorhanden auf Produktseite/Warenkorb/Checkout)
Verifikation: pnpm exec tsc --noEmit clean, pnpm test 151/151 (15 Dateien,
+9 neue Tests fuer search-match), pnpm build erfolgreich.
- format.ts: formatPrice/formatDate/formatDateTime/formatMonthYear nehmen
jetzt ein optionales Locale-Argument (Default "de" fuer unveraenderte
Aufrufer wie Rechnungen/Admin) und formatieren fuer "en" als en-GB statt
hart codiert als de-DE -- englischsprachige Kundschaft sah bisher trotz
gewaehlter Sprache "9,90 €" und "10. Mai 2026" statt "€9.90"/"10 May 2026".
Alle kundenseitigen Aufrufer (Produktseite, Warenkorb, Checkout, Bestell-
verfolgung, Konto-Bestellungen, Ernten-/Chargenseiten, Live-Suchvorschlaege,
Bestell-/Versand-E-Mails) reichen jetzt die aktive Locale durch.
- en.ts: openCart/shippingWithCount/shippingLabel pluralisieren "item(s)"
jetzt korrekt (vorher z.B. "Open cart, 1 items").
- product-gallery.tsx: Haupt- und Vorschaubilder nutzen jetzt next/image
statt <img> (hochgeladene Produktfotos sind immer same-origin unter
/uploads/..., brauchen also keine remotePatterns-Konfiguration wie der
entfernte Kommentar behauptete) -- Hauptbild mit priority/responsivem
sizes, da es auf der Produktseite das wahrscheinlichste LCP-Element ist.
- content-image.tsx: neue optionale priority-Prop; hero.tsx setzt sie fuer
das Startseiten-Hero-Bild (ebenfalls LCP-Kandidat).
- Sticky-Mobile-CTA-Leiste und useCartStock-Polling gegengeprueft: bereits
korrekt (pb-28-Platzreservierung bzw. Fetch nur bei Warenkorb-Aenderung
ohne Intervall) -- keine Aenderung noetig.
Verifiziert: tsc --noEmit clean, pnpm test (142/142 gruen), pnpm build erfolgreich.
Echter Datei-Upload statt CSS-Platzhaltergrafiken, gespeichert unter
UPLOAD_DIR (Laufzeit-Verzeichnis, weiss nichts von seiner tatsaechlichen
Speicher-Anbindung — in Produktion ein von der Betreiberin selbst per
Kubernetes eingebundenes Volume, z.B. SMB; lokal ein simples ./uploads).
- lib/uploads.ts: Kernlogik. Bildformat wird ausschliesslich aus den
tatsaechlichen Bilddaten erkannt (Magic-Bytes via sharp), nie aus
Client-Dateiname/MIME-Type. Zufaellige, nicht erratbare Dateinamen
(Path-Traversal-/Overwrite-Schutz). Re-Encoding validiert Pixel-Daten
vollstaendig und entfernt eingebettete Metadaten (EXIF/ICC/XMP) als
Nebeneffekt. Content-Type beim Ausliefern kommt ausschliesslich aus
einer festen Endung->MIME-Zuordnung, nie aus Dateiinhalt/-namen.
- app/api/admin/upload/route.ts: Admin-only Upload-Endpunkt (CSRF- +
Auth- + Rate-Limit-Pruefung, max. 8 MB, JPEG/PNG/WebP/AVIF).
- app/uploads/[...path]/route.ts: liefert hochgeladene Bilder aus
(public/ wird zur Build-Zeit fest ins Image gebacken, kann also keine
zur Laufzeit hinzugefuegten Dateien bedienen) — mit
Path-Traversal-Schutz und nosniff-Header.
- content-registry.ts: neuer "image"-Feldtyp fuer Hero-/Startseite-
Grafiken, admin/inhalte hat dafuer ein Upload-Widget.
- Produkt-/Ernten-Admin-Formulare: Mehrfach-Bild-Upload fuer
Product.images/Harvest.images (beide Felder existierten im Schema,
hatten aber bislang keine Admin-Oberflaeche). Oeffentliche Seiten
(Shop, Ernten, Chargen-QR-Seite) zeigen jetzt echte Fotos statt
Platzhalter, sobald vorhanden.
- Kubernetes: nur ein einfacher volumeMount in 05-app-deployment.yaml
(Platzhalter-emptyDir, klar als CHANGE-ME markiert) — die eigentliche
Volume-Quelle (SMB o.ae.) bindet die Betreiberin selbst ein, wie
gewuenscht keine SMB/CSI-spezifischen Manifeste hinzugefuegt.
Verifiziert: tsc --noEmit clean, 139/139 Tests gruen (21 neu), pnpm
build clean. Upload-/Serving-Pipeline live gegen einen laufenden
Server getestet (Auth-/CSRF-Gates, Path-Traversal-Ablehnung,
Format-Ablehnung) — ein vollstaendiger authentifizierter Upload
end-to-end (braucht echten Admin-Login inkl. 2FA) wurde nicht live
durchgespielt, nur per Code-Review verifiziert.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Der bestehende Ernten-Sync (admin/ernten) laedt jetzt zusaetzlich
Chargen aus ApiCore. Fachliche Zuordnung recherchiert direkt im
ApiCore-Backend-Quellcode (../ApiCore/ApiCore-Backend): Wabenhains
"Charge" (model Batch) entspricht ApiCores "BottlingBatch"
(Schleuderung -> HoneyBatch -> Abfuellung -> BottlingBatch), NICHT
"HoneyBatch" selbst — die Felder passen fast 1:1 (lotNumber/code,
date/fillDate, bestBeforeDate/bestBeforeDate, jarCount/glassCount).
- lib/apicore-client.ts: fetchApiCoreBottlingBatches() laedt
GET /bottling-batches (inkl. verknuepfter HoneyBatch) und
GET /extractions (inkl. verknuepfter Ernten) und loest daraus die
zugehoerige ApiCore-Ernten-ID pro Charge auf.
- admin/actions/apicore-sync.ts: Chargen-Sync laeuft nach dem
Ernten-Sync (damit frisch angelegte Ernten schon als harvestId
verfuegbar sind), legt Chargen NUR NEU an (nie Update) — anders als
Harvest hat Batch kein isPublished-Feld, ein Update wuerde
insbesondere remainingGlasses (echter, durch Verkaeufe veraenderter
Lagerbestand) stillschweigend zuruecksetzen. Nicht-fatal: schlaegt
nur der Chargen-Teil fehl, bleiben bereits synchronisierte Ernten
erhalten.
- admin/ernten/page.tsx + i18n: zeigt Chargen-Sync-Ergebnis separat an.
- .env.example: zwei zusaetzliche ApiCore-Berechtigungen dokumentiert
(extractions:read, bottling:read).
Verifiziert: tsc --noEmit clean, 118/118 Tests gruen, pnpm build clean.
Konnte nicht gegen einen echten ApiCore-Server getestet werden (siehe
bereits bestehende Einschraenkung beim Ernten-Sync) — Feldnamen/Shape
direkt aus ApiCore-Backends Prisma-Schema und Service-Code entnommen.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Datenbank (prisma/migrations/20260904120000_add_performance_indexes):
7 fehlende Indizes auf Fremdschluessel-/Filter-Spalten ergaenzt
(Order.customerId+createdAt, Order.status, Order.createdAt,
Order.discountCodeId, OrderItem.orderId, Address.customerId,
AuditLog.createdAt) — bislang volle Tabellenscans bei Bestelluebersicht,
Admin-Dashboard, Checkout-Gutscheinpruefung und Audit-Log.
Bundle-Groesse: Footer (TikTok/YouTube/Instagram-Icons, Copyright-Zeile)
aus dem client-seitigen site-chrome.tsx in eine eigene Server Component
(site-footer.tsx) ausgelagert — brauchte "use client" nur wegen
useI18n(), jetzt getDictionary()-Server-Aufruf wie auf jeder Admin-Seite.
Entfernt ungenutztes clientseitiges JS/Hydration aus jedem Seitenaufruf
(Footer steht im Root-Layout).
UX: app/not-found.tsx und app/error.tsx ergaenzt (Next.js-Konventionen
fuer 404 bzw. Laufzeitfehler) — beide fehlten komplett, unbekannte Routen
und Rendering-Fehler zeigten bisher Next.js' generische, unbrandete
Standardseiten statt einer zur Seite passenden Nutzerfuehrung.
Dictionary-Kollision dabei aufgefallen und aufgeloest: eine bereits
bestehende, admin-interne errorPage-Uebersetzung (src/app/admin/error.tsx,
seit dem urspruenglichen App-Grundgeruest vorhanden) blieb unangetastet,
die neue site-weite Fehlerseite nutzt einen eigenen Schluessel
(siteErrorPage).
Verifiziert: tsc --noEmit clean, 118/118 Tests gruen, pnpm build clean.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Keine der in dieser Session neu hinzugefuegten sicherheitskritischen
Module (lib/csrf.ts, lib/entra.ts, lib/dhl.ts) hatte bisher Tests.
- csrf.test.ts: Same-Origin-Erkennung, Ablehnung gefaelschter
Cross-Origin-Requests, tolerantes Verhalten bei fehlendem/kaputtem
Origin-Header.
- entra.test.ts: PKCE-Paar-Erzeugung (RFC 7636), Authorize-URL-Aufbau,
Extraktion des verifizierten E-Mail-Claims (nie ein unplausibles
Feld raten).
- dhl.test.ts: Adress-Mapping (Strasse/Hausnummer-Trennung,
ISO2->ISO3-Laendercode), Fehlerbehandlung bei nicht unterstuetztem
Land, DHL-Validierungsfehlern und HTTP-Fehlern, statt einen falschen
Erfolg vorzutaeuschen.
Alle 118 Tests (13 Dateien) gruen, tsc --noEmit clean.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
CSRF-Schutz, DHL-Versandmarken, Entra-ID-SSO, ApiCore-Sync und die
Harvest-isPublished-Migration waren gestaged aber nicht im letzten
Commit enthalten — Gitea-Actions-Build brach mit "Module not found"
fuer genau diese Dateien ab.
Co-Authored-By: Claude Sonnet 5 <[email protected]>