641afa1ae4ad8c20387923ceb333b51935787c45
31
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
3bc0693e4d |
refactor(apicore-sync): API key and shop feed only, no password login
The sync now talks to ApiCore exclusively through the secured shop interface: GET /shop-sync with an API key as bearer token. Removed: - the email/password service-account login (and its MFA handling), - the fallback to ApiCore's general read endpoints (/harvests, /products, /extractions, /bottling-batches, /apiaries). A missing interface (404) or a rejected key (401/403) now stops the run with a specific message instead of falling back. APICORE_SYNC_EMAIL and APICORE_SYNC_PASSWORD are gone from .env.example, README and the k8s secret template; the system status needs APICORE_API_KEY. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> |
||
|
|
55be1fe881 |
feat(apicore-sync): authenticate with an ApiCore API key
APICORE_API_KEY replaces the service-account login: the key is sent as the bearer token directly, needs no password and no MFA exemption, may only read the shop feed and can be revoked in ApiCore at any time. A malformed key is rejected with a clear message before any request. Email/password stays as fallback for ApiCore versions without keys. The system status treats the key or the legacy login as configured. README, .env.example and the k8s secret template describe the key. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> |
||
|
|
12675f381a |
docs: step-by-step setup guide for secrets, SSO and Microsoft 365 mail
docs/EINRICHTUNG.md walks from nothing to a running shop: tools and access, generating database/session/admin values, the Entra ID app registration used for both sign-in and mail (redirect URI, client secret, SMTP.SendAsApp with admin consent, enterprise app object id), unlocking the shared mailbox in Exchange Online, Stripe keys and webhook events, optional services, a complete secret/config map table, rollout, verification, maintenance (secret expiry) and troubleshooting keyed to the messages of the admin email test. Linked from README and k8s/README. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> |
||
|
|
11910dd6dd |
refactor(mail): send via the SSO app registration, drop SMTP basic auth
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m25s
Mail now uses the same Entra ID app registration as "Sign in with Microsoft" (ENTRA_TENANT_ID, ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET), so only one enterprise application is needed. The app additionally gets the SMTP.SendAsApp permission; Exchange still limits it to the mailbox granted via Add-MailboxPermission. Password (basic auth) sending is removed entirely: SMTP_HOST, SMTP_PORT, SMTP_PASSWORD and the separate SMTP_OAUTH_* settings are gone. The transport is fixed to smtp.office365.com:587 with required STARTTLS. SMTP_USER (sending mailbox) and SMTP_FROM remain. - lib/entra.ts exposes readEntraAppCredentials(), shared by SSO and mail - diagnosis drops basic-auth-only causes; hints name the ENTRA_* values and warn that an expired secret breaks sign-in and mail together - admin e-mail test, .env.example, docker-compose, k8s manifests, preflight and README updated Verified against Entra ID with fake values: token error classified, client secret never exposed, missing ENTRA_CLIENT_SECRET reported. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> |
||
|
|
0620e9d79a |
feat(mail): Microsoft 365 sending via OAuth 2.0 (client credentials)
Sending no longer needs a user password for Microsoft 365. When SMTP_OAUTH_TENANT_ID, SMTP_OAUTH_CLIENT_ID and SMTP_OAUTH_CLIENT_SECRET are set, the shop fetches an app-only token from Entra ID and signs in to smtp.office365.com with XOAUTH2. This works with security defaults and MFA, survives the end of SMTP basic auth (default off from end of December 2026) and limits the app to the one mailbox Exchange grants it (SMTP.SendAsApp + Add-MailboxPermission). Password login stays available for other providers; a partial OAuth setup never silently falls back. - lib/ms365-oauth.ts: token cached until 5 minutes before expiry, concurrent requests share one fetch, forced refresh after a server rejection, 15 s timeout, tenant id validated before it enters the URL, client secret redacted from every error. - lib/smtp-diagnose.ts: explains AADSTS7000215 (secret ID instead of value), 7000222 (expired), 700016 (unknown app), 90002/900023 (tenant) and maps a rejected XOAUTH2 login to missing mailbox access. - Admin e-mail test shows the auth mode and lists missing settings. - k8s manifests, preflight check and README (step-by-step Entra and Exchange setup) updated; misplaced SMTP_PORT comment moved. Verified against Entra ID and smtp.office365.com with fake values: AADSTS90002 is classified as tenant error without leaking the secret, an invalid token yields 535 5.7.3 and is classified as mailbox access. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> |
||
|
|
4ee5bafe20 |
feat: Stripe Embedded Checkout statt Weiterleitung zu checkout.stripe.com
Bezahlt wird jetzt direkt auf /checkout: die Checkout-Session laeuft mit ui_mode "embedded_page", das Zahlungsformular ist ein Stripe-iframe (PCI SAQ A bleibt). PayPal, Karte, SEPA und Klarna kommen weiterhin dynamisch aus dem Dashboard. - Zahlungsschritt ersetzt das Formular; "Zurueck zu deinen Angaben" gibt Sitzung und Bestand sofort frei (POST /api/checkout/cancel) und behaelt alle Eingaben, Turnstile wird neu geladen - Rueckkehrseite prueft serverseitig bei Stripe und gleicht die Sitzung mit der eigenen Bestellung ab; bei abgebrochener PayPal-Zahlung wird dieselbe Sitzung erneut eingebettet - Erneuter Versuch und Abbruch sind per HttpOnly-Cookie an den bestellenden Browser gebunden, die session_id in der URL allein reicht nicht - Alte /checkout/cancel?ref= Links aendern per GET nichts mehr - CSP um die Stripe-Hosts fuer Embedded Checkout und 3-D-Secure erweitert - STRIPE_PUBLISHABLE_KEY zur Laufzeit gelesen (kein NEXT_PUBLIC_), mit Preflight-Pruefung auf pk_live und Schutz gegen versehentliche sk_/rk_ Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> |
||
|
|
01eaca39dc |
feat: DHL-Sendungsverlauf direkt auf der Tracking-Seite anzeigen
/bestellung-verfolgen holt den Sendungsverlauf serverseitig ueber die DHL Shipment Tracking - Unified API und zeigt ihn im eigenen Layout an, statt Kundschaft auf dhl.de weiterzuleiten. - lib/dhl-tracking.ts: Abfrage mit API-Key, Zod-Validierung, Timeout, Cache pro Sendung (unterwegs 10 min, zugestellt 12 h, Fehler 2 min) - Abfrage erst nach erfolgreicher Pruefung von Bestellnummer + E-Mail, damit die Seite nicht als offener Proxy zu DHL dient - Ohne freigeschalteten Key bleibt die Seite unveraendert (Nummer + Link) - Optional DHL_TRACKING_API_KEY, falls das Tracking in eigener DHL-App liegt - Datenschutzerklaerung um die Statusabfrage bei DHL ergaenzt Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> |
||
|
|
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]>
|
||
|
|
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]>
|
||
|
|
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]>
|
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]>
|
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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
|
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]>
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
4d3100e9ba |
feat: Security CSRF Fix, Logout optimierung, Footer Anpassung, Rechtstexteanpassung
Docker Image bauen und veroeffentlichen / build-and-push (push) Failing after 1m33s
|
||
|
|
8587265155 | SQL and Seed into App | ||
|
|
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. |
||
|
|
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. |