Files
Wabenhain/Dockerfile
T
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

118 lines
6.3 KiB
Docker

# syntax=docker/dockerfile:1
FROM node:22.23.2-alpine3.24 AS base
RUN corepack enable
WORKDIR /app
# --- Dependencies -----------------------------------------------------------
FROM base AS deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# --ignore-scripts: postinstall generiert die Prisma-Clients aus
# prisma/schema.prisma + scripts/ — in dieser Stage noch nicht vorhanden
# (erst "COPY . ." im builder-Stage unten). Die build-Stage generiert ueber
# "pnpm build" ohnehin beide Client-Varianten, s.u.
RUN pnpm install --frozen-lockfile --ignore-scripts
# --- Build --------------------------------------------------------------
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# DATABASE_URL wird zur Build-Zeit nicht benoetigt (Prisma generate braucht
# keine Live-Verbindung), muss aber gesetzt sein, damit prisma.config.ts
# (laedt selbst kein .env im Image) nicht wegen einer fehlenden URL abbricht.
ENV DATABASE_URL="postgresql://placeholder:placeholder@localhost:5432/placeholder"
# Generiert intern beide Prisma-Client-Varianten (Postgres + MySQL, siehe
# scripts/generate-prisma-clients.mjs) und baut danach die Next.js-App — ein
# einzelnes Image, das per DATABASE_PROVIDER zur Laufzeit zwischen beiden
# Datenbanken wechseln kann, siehe lib/prisma.ts.
RUN pnpm build
# --- Migrator-Tools -----------------------------------------------------
# Eigenstaendiger, schlanker node_modules-Baum nur mit Prisma-CLI, tsx und
# den paar Laufzeit-Paketen, die prisma/seed.ts importiert — NICHT
# next/react/sharp/stripe/alle devDependencies, die der volle builder-Stage
# oben mitbringt. Wird unten in die runner-Stage kopiert, damit die App sich
# beim Start selbst migrieren/seeden kann (kein separates Migrations-Image/
# kein Kubernetes-Job mehr noetig).
FROM base AS migrator
# pnpm-workspace.yaml zuerst kopieren: pnpm v11 blockt Postinstall-Skripte
# (u.a. @prisma/engines, laedt die Schema-Engine-Binary fuer "migrate
# deploy") standardmaessig, ausser sie sind per "allowBuilds" in dieser
# Datei freigegeben. Ohne die Datei an dieser Stelle liefe "pnpm add" gegen
# ein Verzeichnis ohne jede Allowlist und wuerde die Skripte ignorieren
# ([ERR_PNPM_IGNORED_BUILDS]) -> Schema Engine fehlt -> "migrate deploy"
# schlaegt mit E_CANNOT_RESOLVE_VERSION fehl.
COPY pnpm-workspace.yaml ./
# dotenv: prisma.config.ts importiert "dotenv/config" (Vorlagen-Kommentar
# dort: "assumes you have installed ... dotenv") — im vollen builder-Stage
# oben nur ueber eine transitive Abhaengigkeit erreichbar, hier in der
# isolierten node_modules-Installation nicht automatisch mit dabei.
RUN pnpm add --save-prod \
prisma@^7.9.1 tsx@^4 bcryptjs@^3.0.3 dotenv@^17.4.2 \
@prisma/client@^7.9.1 @prisma/adapter-pg@^7.9.1 \
@prisma/adapter-mariadb@^7.9.1 mariadb@^3.5.3
# --- Runtime --------------------------------------------------------------
FROM base AS runner
ENV NODE_ENV=production
# System-ffmpeg statt des npm-Pakets ffmpeg-static (lib/video-transcode.ts
# nutzt es lokal per "npm run dev" trotzdem als Fallback, siehe dort):
# ffmpeg-static laedt seine Binary in den pnpm-Store, dessen Pfad ueber
# mehrere Docker-Build-Stages hinweg (migrator -> runner) per COPY nicht
# zuverlaessig ankam (spawn ... ffmpeg ENOENT im Betrieb beobachtet). Ein
# per apk installiertes System-Paket liegt dagegen fest unter /usr/bin auf
# PATH, unabhaengig von jeder Node/pnpm-Verzeichnisstruktur.
RUN apk add --no-cache ffmpeg
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma
COPY prisma.config.ts ./
COPY scripts ./scripts
# Das ganze lib-Verzeichnis statt einzelner Dateien: neben prisma/seed.ts
# laeuft im Container auch scripts/release-stale-orders.ts (CronJob, siehe
# k8s/07-backup-cronjob.yaml), das ueber lib/order-release.ts weitere Module
# nachzieht. Einzeln aufgezaehlt faellt so ein Lauf erst im Cluster mit
# "Cannot find module" auf. Die Dateien liegen nur herum — der Next.js-Server
# nutzt ausschliesslich seinen gebuendelten Standalone-Output.
COPY src/lib ./src/lib
COPY --from=builder /app/src/generated ./src/generated
# Migrations-/Seed-Pakete (prisma-CLI/tsx/dotenv) DIREKT ins selbe
# node_modules wie der Next.js-Standalone-Output kopieren (ueberlagert dort
# nur exakt dieselben Paketversionen, die builder bereits fuer next/react
# aufgeloest hat) statt in einen separaten Baum + PATH/NODE_PATH-Trick: der
# generierte Prisma-Client + tsx nutzen Node's ESM-Modulaufloesung, die
# NODE_PATH schlicht ignoriert (nur ein CommonJS-"require()"-Feature) —
# fuehrte zu "Cannot find package '@prisma/client'" beim Seed-Lauf. Die
# normale node_modules-Huelle nach oben (von jeder Datei unter /app/ aus)
# funktioniert dagegen fuer beide Modulsysteme zuverlaessig.
COPY --chown=nextjs:nodejs --from=migrator /app/node_modules ./node_modules
ENV PATH="/app/node_modules/.bin:${PATH}"
# UPLOAD_DIR (Standard "./uploads", siehe .env.example) faellt ohne diesen
# Schritt unter das root-eigene /app-Arbeitsverzeichnis: der Container laeuft
# als non-root "nextjs" (s.u.), dessen mkdir()-Aufruf in
# app/api/admin/upload*/route.ts dort ohne Schreibrecht auf /app selbst mit
# EACCES fehlschlaegt — Admin-Uploads (Bilder UND Hero-Video) scheitern dann
# ausnahmslos. Verzeichnis hier bereits VOR "USER nextjs" anlegen und
# uebereignen; docker-compose.yml mountet ausserdem ein Volume genau hierher,
# damit Uploads auch einen Container-Neustart ueberleben (siehe dort).
RUN mkdir -p /app/uploads && chown nextjs:nodejs /app/uploads
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Migrationen + Seed laufen VOR dem Serverstart, damit Readiness-/Liveness-
# Probes nie gegen eine ungemigrateet Datenbank treffen — dafuer kein
# separater Kubernetes-Job mehr noetig. Beide Schritte sind idempotent
# ("migrate deploy" wendet nur neue Migrationen an, prisma/seed.ts
# legt/erneuert nur das Admin-Konto) und damit bei jedem Neustart
# gefahrlos. KEINE Beispieldaten im Image/Auto-Seed — dafuer separat
# "pnpm db:seed:demo" (prisma/seed-demo.ts), nie automatisch ausgefuehrt.
CMD ["sh", "-c", "node scripts/prisma-provider.mjs && prisma migrate deploy && prisma db seed && exec node server.js"]