Commit Graph
24 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5 6639da130e fix: Google-Rezensionen auf "Places API (New)" umgestellt
Symptom: dauerhaft REQUEST_DENIED mit "The provided API key is invalid",
obwohl der Schluessel keinerlei Einschraenkung traegt.

Die Meldung fuehrt in die Irre. Der Code rief den alten Endpunkt
maps.googleapis.com/maps/api/place/details/json auf, und Google laesst
die dazugehoerige alte "Places API" in seit Maerz 2025 angelegten
Cloud-Projekten gar nicht mehr aktivieren. Der Fehler betrifft also nicht
den Schluessel, sondern die API — wer der Meldung folgt, sucht
zwangslaeufig an der falschen Stelle. Das README hatte diesen Fall als
"Legacy-Hinweis" bereits vorhergesagt.

Jetzt places.googleapis.com/v1 mit X-Goog-Api-Key im Header (statt des
Schluessels in der Adresszeile, wo er in fremden Zugriffsprotokollen
landet) und einer engen X-Goog-FieldMask — die API rechnet nach
angeforderten Feldern ab.

Die Fehlermeldung im Log nennt jetzt die drei Dinge, an denen es
tatsaechlich liegen kann: API nicht aktiviert, keine Abrechnung am
Projekt, oder ein OAuth-Client statt eines API-Schluessels hinterlegt.

Zwei Verhaltensaenderungen, beide unvermeidbar:
  - Die neue API kennt kein reviews_sort=newest; sie liefert bis zu fuenf
    Rezensionen in Googles eigener Reihenfolge.
  - Rezensionen ohne Namen werden uebersprungen (zuvor nur die ohne
    Text): ohne Namen fehlt die von den Nutzungsbedingungen verlangte
    Zuordnung zur Quelle.

README und .env.example nennen jetzt alle vier noetigen Schritte und
weisen ausdruecklich darauf hin, dass eine Referrer-Beschraenkung den
Abruf blockiert — er laeuft serverseitig.

Dazu, mit Blick auf mehrere Instanzen: DATABASE_POOL_SIZE ist jetzt
einstellbar (Vorgabe 20) und in der ConfigMap dokumentiert. Der Wert gilt
je Instanz und multipliziert sich mit "replicas" — drei Instanzen belegen
60 der voreingestellt 100 PostgreSQL-Verbindungen.

tsc: 0 Fehler. vitest: 336/336 gruen. eslint: 0 Warnungen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 06:16:19 +02:00
Nick SzaunigandClaude Opus 5 a3f91b83fc fix: abgebrochener Checkout gibt die reservierte Ware sofort wieder frei
Eine Bestellung entsteht vor der Zahlung und reserviert dabei sofort
Lagerbestand — sonst könnte zwischen Kaufentscheidung und Zahlung jemand
anders dasselbe Glas kaufen. Bricht die Kundschaft bei Stripe ab, passierte
danach: nichts. Die Seite /checkout/cancel war reiner Text, die Bestellung
blieb auf "NEU", und der Bestand kam erst zurück, wenn Stripe die Sitzung für
abgelaufen erklärte — nach Voreinstellung 24 Stunden später.

Das ist nicht nur unschön, es ist ausnutzbar. Zehn Checkouts pro Stunde und
IP, bis zu zwanzig Stück je Position, fünfzig Positionen: damit lässt sich der
Shop leerräumen, ohne einen Cent zu zahlen, und das Lager bleibt einen Tag
lang blockiert.

Drei Schichten dagegen, jede für sich wirksam:

1. Die Stripe-Sitzung läuft nach 32 statt 24 Stunden ab (expires_at). Stripe
   lässt als Untergrenze 30 Minuten zu; die zwei Minuten Aufschlag sind Puffer
   gegen auseinanderlaufende Uhren, sonst kann die Erstellung als "weniger als
   30 Minuten" abgewiesen werden.

2. Der Abbruch-Knopf bucht sofort zurück. Die cancel_url trägt dazu einen
   eigenen Token (Order.cancelToken, 32 Byte), nicht die Bestellnummer: die
   steht in jeder Bestätigungsmail und auf jeder Rechnung, mit ihr ließe sich
   also die offene Zahlung einer fremden Person abbrechen. Der Token wird
   nach Gebrauch aus der Adresszeile entfernt.

   {CHECKOUT_SESSION_ID} wäre der naheliegende Weg gewesen, ist aber nur für
   success_url und return_url dokumentiert, nicht für cancel_url — darauf
   baut hier nichts.

3. Ein CronJob prüft alle 30 Minuten nach, was liegengeblieben ist. Er greift
   genau dann, wenn Schicht 2 nicht greifen kann: checkout.session.expired
   ist im Dashboard nicht abonniert, der Endpunkt war nicht erreichbar, die
   Zustellversuche sind abgelaufen. Ohne ihn bliebe der Bestand dauerhaft
   blockiert, und zwar unsichtbar — eine solche Bestellung sieht im Admin
   aus wie jede andere offene.

Gefragt wird an jeder Stelle zuerst Stripe, nie die Uhr allein. Ist die
Sitzung dort "complete", war der Aufruf kein Abbruch, sondern ein
Zurück-Klick nach erfolgreicher Zahlung — dann bleibt alles unangetastet.
Umgekehrt vorzugehen hieße, eine bezahlte Bestellung zu stornieren und ihre
Ware ein zweites Mal zu verkaufen.

Findet der Aufräumlauf eine Bestellung, die bei Stripe bezahlt ist, hier aber
noch auf "NEU" steht, rührt er sie nicht an: das nachträglich selbst zu
erledigen hieße, Rechnung, Bestätigungsmail und Gutscheinzählung zu
überspringen. Er meldet den Fall und endet mit Exit-Code 1, damit er als
fehlgeschlagener Job auffällt statt in einer Logzeile zu verschwinden.

Das Stornieren samt Rückbuchung liegt jetzt einmal in lib/order-release.ts
statt verteilt. Der Statuswechsel ist ein einzelnes bedingtes UPDATE ("nur
wenn noch NEU"), damit Abbruch-Klick und Webhook im selben Moment den Bestand
nicht doppelt gutschreiben.

Weiter in diesem Zug:

- Betragsabgleich im Webhook: weicht session.amount_total von der eigenen
  Summe ab, hat etwas außerhalb dieses Codes mitgerechnet (am ehesten eine im
  Dashboard eingeschaltete automatische Steuer). Die Bestellung bleibt
  bezahlt — das Geld ist geflossen —, aber die Abweichung wird laut geloggt.
  Sonst wird bei zu wenig eingezogenem Geld stillschweigend mit Verlust
  versendet, und bei zu viel weicht die Rechnung vom belasteten Betrag ab.

- preflight prüft, ob der Webhook-Endpunkt alle vier Ereignisse abonniert
  hat. Ein eingeschränkter Schlüssel darf die Liste womöglich nicht lesen;
  dann meldet die Prüfung "ungeprüft" statt "in Ordnung".

- Packstation-Bestellungen waren vollständig kaputt: die Muster für
  Packstation- und Postnummer lauteten /^d{1,10}$/ und /^d{6,10}$/. Ohne
  Backslash trifft das nicht "Ziffer", sondern den Buchstaben d — jede echte
  Nummer wurde abgewiesen, gültig wären nur Eingaben wie "dddddd" gewesen.
  Der Fehler war beim Lesen kaum zu sehen, weil die Fehlermeldung daneben das
  Richtige sagte. Drei Tests halten das jetzt fest.

- Das Laufzeit-Image bekommt src/lib vollständig statt zweier einzelner
  Dateien: der CronJob zieht über order-release.ts weitere Module nach, und
  einzeln aufgezählt fällt so etwas erst im Cluster mit "Cannot find module"
  auf.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-18 02:07:22 +02:00
Nick SzaunigandClaude Opus 5 dc13bd071b fix: abgebrochene Video-Uploads werden als das gemeldet, was sie sind
"Video-Upload: unerwarteter Fehler. Error: aborted { code: ECONNRESET }"
verdeckte die eigentliche Aussage: nicht die Verarbeitung ist gescheitert,
die Datei ist gar nicht erst vollstaendig angekommen. Der Fall landete in
der allgemeinen Auffanglinie und sah damit aus wie ein Programmfehler.

Ein Abbruch der Verbindung wird jetzt erkannt und mit Mengenangabe geloggt:

  [upload-video] Verbindung abgebrochen nach 41943040 von angekuendigten
  78643200 Bytes.

Genau diese Zahlen trennen die beiden moeglichen Ursachen. Bricht es immer
bei derselben BYTEZAHL ab, liegt eine Groessengrenze auf dem Weg. Bricht es
nach derselben ZEIT ab, ist es eine Zeitgrenze — und davon liegen mehrere
hintereinander, allen voran Node selbst: dessen requestTimeout beendet einen
Request nach fuenf Minuten und laesst sich nicht konfigurieren. Das ist die
harte Obergrenze fuer die Dauer eines Uploads, unabhaengig davon, dass
MAX_UPLOAD_VIDEO_BYTES 100 MB erlaubt.

Statt eines 500ers antwortet die Route mit 408 und einer Meldung, die zum
Wiederholen und zum vorherigen Verkleinern raet. Die Antwort erreicht die
Kundschaft meist nicht mehr — die Verbindung ist ja weg —, ist aber richtig
fuer den Fall, dass nur eine Zwischenstation aufgegeben hat.

k8s/README.md bekommt dazu einen Abschnitt mit der Tabelle der Zeitgrenzen
und dem ffmpeg-Aufruf, mit dem sich ein Hero-Video vorab auf genau das Mass
bringen laesst, auf das der Server es ohnehin umrechnet. Was darueber
hinausgeht, wird nur uebertragen, um anschliessend weggerechnet zu werden.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 17:38:25 +02:00
Nick SzaunigandClaude Opus 5 425fd76bc1 docs: fsGroup wirkt bei SMB/NFS nicht — Rechte gehoeren in die mountOptions
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m8s
Der Kommentar am fsGroup-Feld behauptete, Kubernetes ordne damit JEDES
gemountete Volume der Gruppe 1001 zu. Das stimmt nur fuer blockbasierten
Speicher.

Bei SMB/CIFS und NFS wird fsGroup ignoriert: diese Protokolle kennen keinen
per-Datei-Unix-Besitz, den der Knoten nachtraeglich aendern koennte. Ohne
uid/gid in den mountOptions erscheint dort alles als root:root, und der als
uid 1001 laufende Container scheitert bei jedem Upload mit EACCES — also
genau die Meldung "Die Datei konnte nicht gespeichert werden", ohne dass am
Deployment etwas falsch aussieht.

k8s/README.md bekommt dafuer einen eigenen Abschnitt: Diagnose im laufenden
Pod, die noetigen mountOptions (uid=1001, gid=1001 zusaetzlich zu
dir_mode/file_mode) und der Hinweis, dass Aenderungen daran erst beim
naechsten Einhaengen greifen, ein Rollout-Restart also dazugehoert.

Ebenfalls vermerkt: noperm umgeht die Rechtepruefung, statt den Besitz
richtigzustellen — es ersetzt uid/gid nicht.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 06:59:03 +02:00
Nick SzaunigandClaude Opus 5 625b73a8c8 feat: Steuersatz je Kategorie und je Produkt, Rechnung weist mehrere Saetze aus
Ein einziger Satz fuer den ganzen Shop war fachlich falsch: der Steuersatz
haengt in Deutschland an der Art der Ware, nicht am Betrieb. Honig ist ein
Lebensmittel und ermaessigt besteuert (Anlage 2 zum UStG), eine
Bienenwachskerze nicht.

Der Satz wird jetzt in drei Stufen bestimmt, die erste gewinnt:

1. Produkt — neues Feld "Umsatzsteuersatz" unter Admin -> Produkte, fuer
   Ausnahmen, die sich nicht aus der Kategorie ergeben.
2. Kategorie — VAT_RATE_PERCENT_HONIG / _KERZEN / _PROPOLIS.
3. VAT_RATE_PERCENT als gemeinsamer Rueckfallwert.

Die Kategorie-Stufe ist der Normalfall: aendern sich die gesetzlichen
Saetze, genuegt eine Aenderung an der Konfiguration statt an jedem Produkt.
Leer am Produkt heisst ausdruecklich "Satz der Kategorie" und NICHT 0 — 0
bedeutet steuerfrei nach § 19 UStG. Das Formularfeld hat dafuer einen
eigenen Zweig im Validierungsschema, sonst haette z.coerce.number() aus dem
leeren Feld eine 0 gemacht.

Damit kann eine Rechnung mehrere Saetze tragen, sobald Honig und Kerze im
selben Paket liegen. Sie weist deshalb je Satz eine eigene Netto- und
Steuerzeile aus. Versand und Rabatt verteilen sich im Verhaeltnis der
Warenwerte: der Versand ist umsatzsteuerlich eine Nebenleistung und teilt
das Schicksal der Hauptleistung — gibt es mehrere, teilt er sich anteilig
auf. Die Verteilung nutzt Hare-Niemeyer, damit dabei keine Cent
verschwinden oder entstehen; zugesichert ist, dass die Summe aller Zeilen
exakt den Rechnungsbetrag ergibt.

Der Satz wandert von der Rechnung an die BESTELLPOSITION
(OrderItem.vatRatePercent) und gehoert damit zum Snapshot wie der Preis:
wird ein Produkt spaeter umkategorisiert, bleiben bestehende Rechnungen
unveraendert. Invoice.vatRatePercent entfaellt wieder — ein einzelner Satz
je Rechnung ist bei gemischten Saetzen nicht darstellbar. Die Spalte
entstand in der vorigen Migration und wurde nie in Betrieb beschrieben.

VAT_RATE_PERCENT_PROPOLIS steht auf 19, weil der volle Satz im Zweifel der
sicherere ist — als Lebensmittel waeren 7 richtig, als
Nahrungsergaenzungs- oder Kosmetikprodukt 19. Im README als zu pruefen
vermerkt.

Der Manifest-Test hat die neuen Variablen zunaechst als "liest niemand"
gemeldet: lib/vat.ts bekommt die Umgebung als Parameter, damit sich die
Saetze ohne globalen Zustand testen lassen, und greift auf die
Kategorie-Saetze ueber einen berechneten Schluessel zu — beides kann eine
Regex nicht sehen. Dafuer gibt es jetzt eine begruendete Ausnahmeliste.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 06:54:26 +02:00
Nick SzaunigandClaude Opus 5 7c59283ede feat: Umsatzsteuer ueber VAT_RATE_PERCENT konfigurierbar und auf der Rechnung ausgewiesen
Die Rechnung wies bisher gar keine Steuer aus — weder Satz und Betrag noch
den Pflichthinweis nach § 19 UStG. Der Satz kommt jetzt aus
VAT_RATE_PERCENT und steht auf PDF und HTML-Ansicht: 7 (ermaessigt, Honig
als Lebensmittel), 19 (voll) oder 0 fuer die Kleinunternehmerregelung, bei
der anstelle der Steuerzeilen der vorgeschriebene Hinweis erscheint.

DIE SHOP-PREISE SIND BRUTTOPREISE. Gegenueber Verbrauchern ist der
Gesamtpreis inklusive Umsatzsteuer anzugeben (§ 1 PAngV), und genau dieser
Betrag steht in Product.priceCents und Order.totalCents. Die Steuer wird
deshalb herausgerechnet, nicht aufgeschlagen — ein Aufschlag ergaebe einen
anderen Endpreis als den, den die Kundschaft im Warenkorb gesehen und
bezahlt hat.

Gerechnet wird auf der Gesamtsumme statt je Position, damit sich Rundungen
einzelner Zeilen nicht aufsummieren, und der Steuerbetrag ergibt sich als
Rest statt aus einer zweiten Rundung: nur so gilt immer netto + steuer ===
brutto. Eine Rechnung, die sich nicht aufaddiert, ist keine brauchbare
Rechnung. Der Versand traegt den Satz der Ware, er ist umsatzsteuerlich
eine Nebenleistung.

Der Satz wird als Invoice.vatRatePercent FESTGESCHRIEBEN statt bei jeder
Anzeige aus der Umgebung gelesen. Wechselt der Steuerstatus des Betriebs,
muessen alte Rechnungen weiterhin den damals ausgewiesenen Satz zeigen — ein
Beleg ist die Aufzeichnung eines vergangenen Vorgangs, keine Ansicht auf die
heutige Konfiguration. Bestehende Rechnungen tragen null und zeigen wie
bisher keine Steuerzeilen, weil rueckwirkend nicht feststellbar ist, welcher
Satz gegolten haette.

Ein unbrauchbarer Wert faellt auf 7% zurueck und wird laut geloggt, statt
eine bereits bezahlte Bestellung scheitern zu lassen. Damit das nicht
unbemerkt bleibt, prueft "pnpm preflight" den Wert jetzt als Blocker und
warnt zusaetzlich bei einem in Deutschland unueblichen Satz.

Der bisherige legalNote war ein Platzhalter ("Kleinunternehmerregelung /
Umsatzsteuerausweis gemaess tatsaechlichem Status ergaenzen") und ist
ersetzt, da die Angabe nun tatsaechlich erfolgt.

Bekannte Grenze, im README vermerkt: es gibt genau EINEN Satz. Honig ist
ermaessigt besteuert, Kerzen nicht — werden beide verkauft, braucht es einen
Satz je ProductCategory.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 06:32:12 +02:00
Nick SzaunigandClaude Opus 5 70ede77dde feat: Rechnungen werden abgelegt, gesichert und mitversendet
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m12s
Die Rechnung entstand bisher nur als Datenbankzeile; das PDF wurde bei jedem
Abruf neu gerendert und tauchte in keiner E-Mail auf. Das hatte drei
Nachteile: Gastbestellungen haben kein Konto, in dem sie den Beleg finden
koennten; eine Sicherung der Datenbank sicherte den Beleg nicht mit; und ein
spaeter erneut gerendertes PDF konnte nach einer Aenderung an Firmendaten
oder Layout anders aussehen als das, was die Kundschaft in Haenden hielt.

Ablage jetzt unter INVOICE_DIR, nach Jahr und Kunde sortiert:

  invoices/2026/mustermann-erika/RE-2026-A3F9C1B2.pdf

Das Jahr zuerst, weil sich die Aufbewahrungsfrist danach richtet — ein
abgelaufener Jahrgang laesst sich als ganzes Verzeichnis archivieren. Der
relative Pfad steht als Invoice.pdfPath in der Datenbank, damit eine spaetere
Aenderung an der Pfadbildung keine bereits abgelegten Belege unauffindbar
macht. Dafuer wurde die nie gelesene Spalte "pdfUrl" umbenannt; eine URL war
ohnehin das falsche Konzept.

INVOICE_DIR ist bewusst ein ANDERES Verzeichnis als UPLOAD_DIR:
app/uploads/[...path] liefert jede Datei unterhalb von UPLOAD_DIR ohne
Anmeldung aus. Laegen Rechnungen dort, koennte jede Person, die den Pfad
erraet, fremde Rechnungen samt Anschrift und Bestellinhalt herunterladen.
Auf das Rechnungsverzeichnis zeigt keine oeffentliche Route; der Abruf laeuft
weiter ueber /rechnung/<nummer>/pdf mit Besitzpruefung.

Der Abruf liefert jetzt die abgelegte Datei aus und rendert nur neu, wenn
keine da ist — also bei Rechnungen aus der Zeit davor.

Versand: das PDF haengt an der Bestellbestaetigung. sendMail nimmt dafuer
optionale Anhaenge entgegen, alle zehn bestehenden Aufrufe bleiben
unveraendert. Schlaegt das Erzeugen oder Ablegen fehl, geht die Bestaetigung
trotzdem raus und die Rechnung bleibt abrufbar: ein 500er an Stripe waere
die falsche Antwort auf eine volle Platte, denn die Zahlung ist erfolgt und
ein Wiederholungslauf aenderte daran nichts.

Sicherung: der taegliche CronJob packt das Verzeichnis als
rechnungen-<Zeitstempel>.tar.gz neben den Datenbank-Dump und haengt das
Volume nur lesend ein. Vermerkt ist dort auch die Einschraenkung, dass
wabenhain-invoices als ReadWriteOnce nur von Pods auf demselben Knoten
einhaengbar ist — bei mehreren Knoten braucht es ReadWriteMany, sonst bleibt
der Sicherungs-Pod lautlos in "Pending".

Weiterhin offen und im README vermerkt: die Rechnungsnummer ist eindeutig,
aber nicht fortlaufend (§ 14 Abs. 4 Nr. 4 UStG), und das PDF weist keine
Umsatzsteuer aus.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 04:12:44 +02:00
Nick SzaunigandClaude Opus 5 479ace1b88 docs: Konfigurationsvorlagen vollstaendig und uebernahmefaehig machen
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]>
2026-09-17 03:06:18 +02:00
Nick SzaunigandClaude Opus 5 990a16e654 chore: Manifeste an den laufenden Cluster angleichen
Die Manifeste in k8s/ trugen noch Platzhalter, waehrend im Cluster laengst
die echten Werte stehen. Wer sie unveraendert anwendet, ueberschreibt damit
funktionierende Einstellungen.

- Domain: shop.example.com -> wabenhain.com, in 06-ingress.yaml (host und
  tls.hosts) und in 01-configmap.yaml (NEXT_PUBLIC_SITE_URL). Beide Stellen
  muessen uebereinstimmen; aus NEXT_PUBLIC_SITE_URL entstehen Sitemap,
  Canonical-Tags und jeder Link in jeder Bestellbestaetigung.

- GOOGLE_PLACES_API_KEY von der ConfigMap ins Secret. Das ist ein
  abrechenbarer Zugangsschluessel, und eine ConfigMap ist unverschluesselt
  und in der Cluster-Oberflaeche im Klartext lesbar. Die zugehoerige
  GOOGLE_PLACE_ID ist oeffentlich und bleibt in der ConfigMap. Der Cluster
  handhabt das bereits so.

- APICORE_API_URL vom Secret in die ConfigMap: eine Adresse ist kein
  Geheimnis. Auch das entspricht dem Cluster.

- VIDEO_TRANSCODE_THREADS in die ConfigMap aufgenommen, damit die
  Threadbegrenzung des Video-Transcodes (siehe vorheriger Commit) auch ueber
  das Deployment einstellbar ist.

Gegenprobe: die Vereinigung aller Schluessel aus ConfigMap und Secret deckt
jetzt genau die Umgebungsvariablen ab, die der Code ueber process.env liest
- keine fehlt. Die beiden Ueberhaenge sind beabsichtigt: DB_PASSWORD liest
der Postgres-StatefulSet, STRIPE_PUBLISHABLE_KEY haelt die Konfiguration
vollstaendig fuer eine kuenftige clientseitige Stripe-Einbindung.

Der Hinweistext der Go-Live-Pruefung nannte shop.example.com als
ausgelieferte Vorgabe; das stimmt nicht mehr. Die Pruefung selbst ist
unveraendert.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 02:20:34 +02:00
Nick SzaunigandClaude Opus 5 b1384c3695 fix: Video-Upload startet den Pod nicht mehr neu
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]>
2026-09-17 01:01:37 +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 979d39e677 feat: taegliche Datenbanksicherung mit dokumentierter Wiederherstellung
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m57s
Es gab bisher keinerlei Sicherung: Bestellungen, Rechnungen,
Kundenkonten und Chargendaten lagen in einem einzelnen
PersistentVolume ohne Netz. Ein Speicherfehler, ein versehentliches
"kubectl delete pvc" oder ein misslungenes Cluster-Update haetten alles
davon unwiederbringlich geloescht. Rechnungen unterliegen in
Deutschland zusaetzlich einer zehnjaehrigen Aufbewahrungspflicht
(§ 147 AO) — ein Datenverlust waere hier nicht nur aergerlich, sondern
ein Rechtsproblem.

Neu: 07-backup-cronjob.yaml — naechtlicher pg_dump auf ein eigenes
Volume, gzip-komprimiert, 30 Tage Aufbewahrung, concurrencyPolicy
Forbid. Fehlgeschlagene Laeufe werden laenger aufgehoben als
erfolgreiche: nur an ihnen laesst sich per "kubectl logs"
nachvollziehen, warum eine Sicherung ausblieb.

Zwei Details, an denen Sicherungsskripte typischerweise scheitern:

- "set -o pipefail". Ohne das liefert "pg_dump | gzip" den Exit-Code von
  gzip, und gzip gelingt auch dann, wenn pg_dump vorher abgebrochen ist.
  Der Job galte als erfolgreich, im Verzeichnis laege eine
  abgeschnittene Datei, und das faellt erst im Ernstfall auf. Beide
  Varianten mit einem absichtlich abbrechenden Dump geprueft: ohne
  pipefail Exit 0 samt vollstaendig aussehender .sql.gz, mit pipefail
  Exit 1 und keine .sql.gz.
- Schreiben unter ".partial" und erst danach umbenennen, damit ein
  mitten im Lauf abgebrochener Job keine Datei hinterlaesst, die wie
  eine vollstaendige Sicherung aussieht. Liegengebliebene .partial-Reste
  raeumt derselbe Lauf mit auf.

k8s/README.md: neuer Abschnitt "Sicherung und Wiederherstellung" mit
Ansehen, manuellem Ausloesen, Herausziehen aus dem Cluster und dem
vollstaendigen Wiederherstellungsweg (App auf 0 skalieren, Dump
einspielen, wieder hochskalieren) — samt dem Hinweis, dass eine nie
getestete Sicherung keine Sicherung ist. Der Abschnitt "Bekannte
Grenzen" nannte "ohne Replikation/Backup-Automation"; das stimmt jetzt
nur noch zur Haelfte und ist entsprechend praezisiert: die Sicherung
liegt weiterhin im selben Cluster und ersetzt keine Replikation.

Alle Manifeste gegen einen YAML-Parser geprueft.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-15 10:21:19 +02:00
Nick SzaunigandClaude Opus 5 a78c1cd235 fix: hochgeladene Bilder ueberleben einen Redeploy
Das uploads-Volume im App-Deployment war ein emptyDir-Platzhalter. Der ist
an die Lebensdauer des Pods gebunden: bei jedem Neustart, jedem Redeploy
und jeder Node-Verlagerung waren saemtliche hochgeladenen Produkt-,
Ernten- und Inhaltsfotos weg. Die Datenbank behaelt dabei ihre Bildpfade —
die Seite haette danach ueberall kaputte Bilder gezeigt, ohne dass
irgendwo ein Fehler aufgetaucht waere.

Neu: k8s/04-uploads-pvc.yaml (die Nummer war in der Reihenfolge noch frei)
mit einem PersistentVolumeClaim, den das Deployment jetzt bindet.
storageClassName ist bewusst auskommentiert, damit das Manifest ohne
Aenderung gegen die Default-StorageClass des Clusters anwendbar bleibt.

Damit einher geht zwingend "strategy: Recreate": Kubernetes' Standard
"RollingUpdate" startet den neuen Pod, BEVOR der alte beendet ist. Ein
ReadWriteOnce-Claim kann aber nur an einen Pod gebunden sein — der neue
Pod bliebe mit "Multi-Attach error for volume" in ContainerCreating
haengen, und das im k8s/README.md dokumentierte
"kubectl rollout restart" liefe schlicht in den Timeout. Recreate kostet
wenige Sekunden Ausfall pro Redeploy und sorgt nebenbei dafuer, dass nie
zwei Pods gleichzeitig laufen, die sich sonst die im Prozessspeicher
gehaltenen Rate-Limits teilen wuerden.

Alle Manifeste gegen einen YAML-Parser geprueft, zusaetzlich strukturell:
claimName trifft den PVC-Namen, PVC liegt im selben Namespace, und der
mountPath stimmt mit UPLOAD_DIR aus 01-configmap.yaml ueberein.

preflight: NEXT_PUBLIC_SITE_URL wird jetzt auch gegen "example.com"
geprueft. Die ausgelieferte Vorgabe ist "shop.example.com" — blieb sie
stehen, zeigten Sitemap, Canonicals und jeder Link in jeder
Bestellbestaetigung auf eine fremde Domain, ohne dass die bisherigen
HTTPS-/localhost-Pruefungen angeschlagen haetten.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-15 10:13:06 +02:00
Nick SzaunigandClaude Opus 5 4b76247648 fix: Health-Endpunkte trennen Liveness von Readiness
Readiness- und Liveness-Probe zeigten beide auf "/" — eine
force-dynamic-Seite samt Datenbankabfragen und vollem React-Rendering, alle
10 bzw. 30 Sekunden.

Der teure Teil ist dabei nicht das eigentliche Problem. Entscheidend ist,
dass damit auch die LIVENESS-Probe an der Datenbank haengt: faellt die DB
voruebergehend aus, wertet Kubernetes den Container als tot und startet ihn
neu — woraufhin der neue Container laut Dockerfile-CMD zuerst
"prisma migrate deploy" und "prisma db seed" gegen genau die gestoerte
Datenbank faehrt. Aus einer voruebergehenden Stoerung wird so eine
Neustart-Schleife.

Jetzt zwei Endpunkte mit klar getrennter Aufgabe:

- /api/health       — nur "Prozess antwortet", ohne Datenbankzugriff.
                      Liveness-Probe.
- /api/health/ready — zusaetzlich "SELECT 1" gegen die Datenbank, sonst 503.
                      Readiness-Probe und Docker-Healthcheck.

Faellt die Readiness-Probe aus, nimmt Kubernetes den Pod nur aus dem
Load-Balancing und startet ihn nicht neu — genau das richtige Verhalten.
"SELECT 1" laeuft unveraendert auf PostgreSQL und MySQL/MariaDB und
beruehrt keine Tabelle, ist also unabhaengig vom Migrationsstand. Die
Fehlerdetails gehen nur ins Server-Log, nicht in die oeffentliche Antwort.

docker-compose.yml bekommt denselben Readiness-Endpunkt als Healthcheck,
mit start_period fuer den Migrations-/Seed-Lauf vor dem Serverstart.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-15 10:01:06 +02:00
Nick Szaunig e8f0bd4f55 fix: Ingress auf Traefik umstellen — die nginx-Annotationen waren wirkungslos
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m6s
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.
2026-09-14 22:48:13 +02:00
Nick Szaunig 2a16b3f962 fix: Ingress-Zeitlimits und Speicherbudget fuer den Video-Upload setzen
Zwei Einstellungen im Deployment liessen einen Video-Upload scheitern,
bevor die Anwendung ueberhaupt antworten konnte — beide Male mit einer
HTML-Fehlerseite, die im Browser als "Unexpected token '<'" ankam:

1. Der Ingress trug keinerlei Timeout-Annotation. ingress-nginx wartet
   dann nur 60 Sekunden je Phase und liefert danach eine eigene
   HTML-504-Seite. Ein Video-Upload braucht regelmaessig laenger: erst
   muessen bis zu 100 MB ueber die Leitung, danach rechnet ffmpeg das
   Video fuers Web um. proxy-read-timeout und proxy-send-timeout stehen
   jetzt auf 600s — bewusst oberhalb des harten ffmpeg-Zeitlimits von 5
   Minuten, damit im Zweifel die Anwendung selbst mit einer verwertbaren
   JSON-Meldung abbricht statt nginx die Verbindung zu kappen.

2. Das Speicherlimit von 512Mi war fuer einen 100-MB-Upload zu knapp:
   request.formData() laedt den multipart-Body vollstaendig in den
   Speicher, und der Kernel beendet den Prozess bei Ueberschreitung
   (OOMKilled). Limit auf 1Gi, Request auf 512Mi. Der CPU-Request steigt
   von 100m auf 500m, weil ffmpeg CPU-gebunden ist und mit 100m ein
   Vielfaches der Zeit braucht — bewusst weiterhin ohne CPU-Limit, das
   wuerde den Transcode auch auf einem freien Knoten drosseln.

Der Kommentar nennt zusaetzlich das davon unabhaengige Origin-Zeitlimit
eines vorgeschalteten CDN (bei Cloudflare 100 Sekunden, Fehler 524), das
sich hier nicht beeinflussen laesst.
2026-09-14 16:15:25 +02:00
Nick Szaunig cd8e307faf perf: hochgeladene Bilder und Videos schnell und vollstaendig ausliefern
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.
2026-09-13 23:16:23 +02:00
Nick SzaunigandClaude Sonnet 5 6a815e85fe fix: Video-Upload-Limit auf 100 MB anheben
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]>
2026-09-11 23:13:04 +02:00
Nick SzaunigandClaude Sonnet 5 d0c1d0840c fix: Admin-Uploads auf dem Live-Server (Berechtigung, Persistenz, Body-Size-Limit)
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m29s
Uploads (Bilder + Hero-Video) schlugen live fehl, lokal aber nicht: der
Container laeuft als non-root "nextjs", /app/uploads existierte nirgends
vorbereitet und gehoert root -> mkdir() scheiterte mit EACCES, unbehandelt
als HTML-500 statt JSON, der Client zeigte nur einen haengenden
"wird hochgeladen"-Zustand ohne Fehlermeldung.

- Dockerfile legt /app/uploads jetzt vor dem User-Wechsel an und
  uebereignet es dem nextjs-Nutzer
- docker-compose.yml persistiert Uploads jetzt ueber ein eigenes Volume
  (vorher gingen sie bei jedem Redeploy verloren) und setzt UPLOAD_DIR
  explizit
- Beide Upload-Routen fangen Dateisystemfehler jetzt ab und liefern eine
  echte JSON-Fehlermeldung statt eine unbehandelte Exception
- k8s-Manifeste vorsorglich mitgefixt (fsGroup fuer den Volume-Mount,
  Ingress-Body-Size-Limit) fuer den Fall eines spaeteren
  Kubernetes-Wechsels

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-11 15:48:14 +02:00
Nick SzaunigandClaude Sonnet 5 54919d82ed feat: Bild-Upload fuer Inhalte, Produkte und Ernten
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m34s
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]>
2026-09-04 19:59:42 +02:00
Nick Szaunig 4d3100e9ba feat: Security CSRF Fix, Logout optimierung, Footer Anpassung, Rechtstexteanpassung
Docker Image bauen und veroeffentlichen / build-and-push (push) Failing after 1m33s
2026-09-03 13:51:50 +02:00
Nick Szaunig 8587265155 SQL and Seed into App 2026-08-19 07:26:47 +02:00
Nick Szaunig 04da561380 fix: Gitea-Registry-URL nicht aus gitea.server_url ableiten
Docker Image bauen und veroeffentlichen / build-and-push (push) Failing after 3m2s
gitea.server_url ist in manchen Gitea-Versionen leer (go-gitea/gitea#23276)
und liefert selbst im Erfolgsfall eine volle URL mit https://-Schema statt
des fuer docker login/Image-Tags benoetigten reinen Hostnamens. Registry
jetzt fest als git.encry.de hinterlegt, k8s-Manifeste entsprechend
vorausgefuellt.
2026-08-18 01:36:03 +02:00
Nick Szaunig 5889f58da7 feat: Docker-, Kubernetes- und Gitea-Actions-Deployment-Pipeline
Docker Image bauen und veroeffentlichen / build-and-push (push) Canceled after 0s
Dockerfile (Multi-Stage, "output: standalone"), docker-compose.yml mit
Caddy als einzigem nach aussen offenem Reverse-Proxy (verhindert
X-Forwarded-For-Faelschung fuer das App-eigene Rate-Limiting), Gitea
Actions Workflow der bei jedem Push zwei Image-Tags in die Gitea-
Container-Registry baut (schlankes Laufzeit-Image + ein Migrations-
Image mit Prisma-CLI), sowie rohe Kubernetes-Manifeste (Namespace,
ConfigMap, Secret-Vorlage, Postgres-/MySQL-StatefulSet, Migrations-Job,
App-Deployment, Ingress) fuer den Cluster-Betrieb. Siehe k8s/README.md
fuer den vollstaendigen Einrichtungs-/Deployment-Ablauf.
2026-08-17 14:30:46 +02:00