Compare commits

..
7 Commits
Author SHA1 Message Date
Nick Szaunig 72d36f4716 perf: Header-Bild der Imkerei-Seite sofort laden
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 4m1s
Ein Audit ueber alle bildfuehrenden Seiten zeigte /imkerei als einzige
Ausnahme: dort war kein Bild priorisiert. Das Bild steht neben der h1 im
ersten Viewport und ist auf breiten Bildschirmen das groesste Element der
Seite, wurde aber doppelt zurueckgehalten — durch das voreingestellte
Lazy-Loading und durch ScrollReveal, das bei opacity 0 startet und erst
nach der Hydration einblendet.

Es rendert jetzt direkt und mit priority, also loading="eager" und
fetchPriority="high". Die Textspalte daneben behaelt ihre Einblendung, die
kostet nichts. Damit verhaelt sich die Seite wie /herkunft und
/unternehmen, die ihr Header-Bild ohnehin schon ausserhalb von ScrollReveal
rendern.

Stand des Audits danach, im Produktions-Build gemessen: /, /herkunft,
/unternehmen und /imkerei liefern je genau ein Header-Bild mit
loading="eager" und fetchPriority="high", alle uebrigen Bilder lazy, und
auf keiner Seite geht noch ein hochgeladenes Bild am Bildoptimierer vorbei.
2026-09-14 20:59:43 +02:00
Nick Szaunig 0a44fecbe1 perf: Warenkorb-Miniaturen ueber den Bildoptimierer laden
Die letzte Stelle, an der ein hochgeladenes Bild noch als rohes <img>
ausgeliefert wurde. Der Warenkorb lud damit fuer JEDE Position das
vollstaendig gespeicherte Bild — bis MAX_IMAGE_DIMENSION Kantenlaenge, also
durchaus ein paar hundert Kilobyte — nur um es auf 64 bzw. 80 Pixel zu
verkleinern. Bei mehreren Positionen summierte sich das spuerbar, gerade im
Mobilfunknetz, und betraf sowohl die Warenkorb-Schublade als auch die
Warenkorbseite.

Der bisherige eslint-disable-Kommentar begruendete das rohe <img> mit einer
"noch unbekannten externen Bildquelle". Das trifft nicht zu: das Bild kommt
aus Product.images[0] und liegt damit immer same-origin unter /uploads/...
(beim Speichern geprueft von isValidUploadPath in lib/uploads.ts).
next/image ist dafuer zustaendig und liefert jetzt eine 80px-Variante statt
der Originaldatei.

Beide Verwendungsstellen bekommen dieselbe leichte Eckenrundung wie die
uebrigen Bilder; vorher war die Schublade rounded-sm und die Warenkorbseite
gar nicht gerundet.
2026-09-14 20:55:50 +02:00
Nick Szaunig 02d493dede fix: verbleibende Lint-Befunde ausraeumen (Lint jetzt ohne Beanstandung)
Vier offene Befunde standen einem sauberen Produktionslauf im Weg:

- recently-viewed-section und site-chrome riefen setState direkt im
  Effektrumpf auf, um einen Leerzustand herzustellen ("keine angesehenen
  Produkte" bzw. "Eingabe kuerzer als zwei Zeichen"). Beides ergibt sich
  unmittelbar aus vorhandenen Werten und wird jetzt beim Rendern
  abgeleitet — das spart je einen zusaetzlichen Renderdurchlauf. Bei der
  Suche verschwindet die Vorschlagsliste dadurch sofort beim Leeren des
  Feldes statt erst nach dem naechsten Effektlauf.

- STAT_KEYS im Admin-Dashboard wurde ausschliesslich als Typ verwendet,
  landete als `as const`-Array aber trotzdem im Bundle. Ersetzt durch
  einen Typ-Alias.

- Ein DHL-Test importierte DhlApiError, ohne es zu benutzen, und pruefte
  nur den Meldungstext — er haette damit jeden beliebigen anderen Fehler
  mit passendem Text durchgehen lassen. Er prueft jetzt zusaetzlich den
  Fehlertyp, wie seine beiden Geschwistertests.

Stand danach: eslint ohne Beanstandung (vorher 2 Fehler, 2 Warnungen),
tsc ohne Fehler, 201 Tests gruen, Produktions-Build erfolgreich.
2026-09-14 20:37:01 +02:00
Nick Szaunig 107cbadf79 perf: Header-Medien sofort und in passender Groesse laden
Drei Gruende, warum Hero-Bild und -Video spuerbar spaet erschienen:

1. Das Standbild des Hero-Videos steckte im poster-Attribut. poster nimmt
   nur eine fertige URL und laedt die hochgeladene Datei damit in voller
   gespeicherter Groesse — am Bildoptimierer vorbei, ohne Ruecksicht auf
   das Geraet. Es liegt jetzt als eigene ContentImage-Ebene unter dem
   Video, laeuft also durch next/image. Gemessen: auf einem 500px breiten
   Viewport kommt statt der vollen Datei die 640px-Variante als WebP an.
   Das Video darueber ist transparent, bis es eigene Bilddaten hat — der
   Uebergang bleibt unsichtbar.

2. Die aeusserste Hero-Section startete bei opacity 0 und wurde erst per
   Animation nach der Hydration sichtbar. Das groesste Element im ersten
   Viewport wurde damit um die gesamte JS-Ladezeit zurueckgehalten. Die
   Animation dort ist entfernt; der gestaffelte Aufbau der Textspalte
   bleibt unveraendert.

3. next/image erhielt weiterhin die Prop "priority". Die ist seit
   Next.js 16 abgekuendigt (siehe node_modules/next/dist/docs, Changelog
   v16.0.0) und erzeugt nur noch einen <link rel="preload">, setzt am
   <img> selbst aber weder fetchpriority noch loading — im Trace kam das
   Hero-Bild deshalb trotz "priority" mit Prioritaet "Low" an. Die
   Dokumentation nennt dafuer ausdruecklich loading="eager" und
   fetchPriority="high" als Ersatz; ContentImage bildet seine unveraendert
   heissende priority-Prop jetzt darauf ab, die Produktgalerie ebenso.
   Danach bestand die LCP-Discovery-Pruefung von Chrome vollstaendig,
   vorher scheiterte sie an fetchpriority.

Ausserdem bekommen Inhaltsbilder leicht abgerundete Ecken (8px), Platzhalter
und echtes Bild gleichermassen, damit die Form beim Pflegen eines Bildes
nicht umspringt. Randlose Flaechen (Hero ueber die volle Fensterbreite)
bleiben bewusst eckig — dort saesse die Rundung an der Fensterkante.

Geprueft im Produktions-Build gegen Chrome: CLS 0.00, kein horizontaler
Ueberlauf bei schmalem Viewport, Video spielt (readyState 4), Hero-Bild
loading="eager" + fetchpriority="high", uebrige Bilder lazy.
2026-09-14 20:36:53 +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 713bf48157 fix: Video-Upload haelt das Video nicht mehr mehrfach im Arbeitsspeicher
Die Route las ein bis zu 100 MB grosses Video gleich mehrfach vollstaendig
in den Node-Heap: einmal aus file.arrayBuffer(), einmal als Buffer daraus,
und das Transcoding-Ergebnis noch einmal aus readFile(). Zusammen mit dem
Grundverbrauch des Next.js-Servers sprengt das ein Speicherlimit von
512Mi, wie es k8s/05-app-deployment.yaml bisher setzte — der Kernel
beendet den Prozess dann mitten im Upload (OOMKilled). Im Browser kommt
davon nur die HTML-Fehlerseite des Reverse-Proxy an, die vorher als
"Unexpected token '<', "<!DOCTYPE "... is not valid JSON" erschien.

Die Datei wandert jetzt streamend auf die Platte (file.stream() ->
createWriteStream), ffmpeg liest und schreibt ohnehin dort, und das
Ergebnis wird per rename() an seinen Platz verschoben — mit copyFile()
als Rueckfallebene, weil rename() ueber Dateisystemgrenzen hinweg mit
EXDEV scheitert und UPLOAD_DIR in Produktion auf einem eigenen Volume
liegt. Die Formaterkennung liest nur noch die ersten 12 Bytes statt der
ganzen Datei. transcodeVideoForWeb() arbeitet entsprechend auf
Dateipfaden statt auf Buffern.

Ausserdem faengt die Route jetzt jeden verbleibenden unerwarteten Fehler
ab und antwortet auch dann mit JSON: eine durchschlagende Exception
liesse Next.js eine HTML-Fehlerseite ausliefern, aus der der Client keine
Meldung gewinnen kann.

Verifiziert mit einem 83,8-MB-Video: 200 mit JSON-Body, Ergebnis 10,3 MB,
Auslieferung inkl. Range-Anfrage (206) korrekt.
2026-09-14 16:15:17 +02:00
Nick Szaunig b11edd7751 fix: "Unexpected token '<'" bei Uploads durch die echte Ursache ersetzen
Alle drei Admin-Upload-Felder riefen response.json() ungeprueft auf. Kam
statt JSON eine HTML-Seite zurueck, warf der Parser

  Unexpected token '<', "<!DOCTYPE "... is not valid JSON

und genau dieser Text landete im Formular. Damit war die eigentliche
Ursache vollstaendig verdeckt: weder der HTTP-Status noch die Schicht,
die abgebrochen hat, waren daraus ablesbar.

Der Punkt dabei: die Upload-Route-Handler antworten ausnahmslos mit JSON,
auch in jedem Fehlerfall — lokal verifiziert bis hinauf zur
Limit-Ueberschreitung (105 MB -> 413 mit JSON-Body). Trifft im Browser
trotzdem HTML ein, stammt die Antwort gar nicht aus der Anwendung,
sondern aus einer Schicht davor: Reverse-Proxy, CDN oder Gateway, das den
Request wegen zu grossem Body abgewiesen oder wegen Zeitueberschreitung
beim Transcoding abgebrochen hat.

readUploadResponse() in lib/upload-client.ts prueft deshalb zuerst den
Content-Type und reicht andernfalls den HTTP-Status nach oben, statt zu
parsen. Die Felder zeigen daraus eine Meldung, die den Status nennt und
die wahrscheinliche Ursache benennt (413 = vom Proxy abgewiesen,
502/503/504/522/524 = Zeitueberschreitung beim Umrechnen, sonst
unerwartet). Ein Verbindungsabbruch, bei dem fetch() selbst wirft, wird
getrennt gemeldet.

Das behebt nicht die Ursache jenseits der Anwendung — es macht sie zum
ersten Mal ablesbar, statt sie hinter einem Parser-Fehler zu verbergen.
2026-09-14 00:36:10 +02:00
22 changed files with 539 additions and 127 deletions

No files matched your search

+23 -3
View File
@@ -78,12 +78,32 @@ spec:
initialDelaySeconds: 30
periodSeconds: 30
failureThreshold: 5
# Bemessen nach dem teuersten Vorgang der Anwendung, dem
# Admin-Video-Upload: Node laedt den multipart-Body beim
# request.formData() vollstaendig in den Speicher, und die Datei
# darf bis zu 100 MB gross sein (MAX_UPLOAD_VIDEO_BYTES in
# lib/uploads.ts). Zusammen mit dem Grundverbrauch des
# Next.js-Servers sprengte das ein Limit von 512Mi — der Kernel
# beendet den Prozess dann mitten im Upload (OOMKilled), und im
# Browser kommt davon nur die HTML-Fehlerseite des Reverse-Proxy an
# ("Unexpected token '<', "<!DOCTYPE "... is not valid JSON").
#
# Die Route selbst haelt inzwischen nur noch den Body im Speicher
# und streamt alles Weitere ueber die Platte (siehe
# app/api/admin/upload-video/route.ts) — 1Gi gibt darueber hinaus
# Reserve, damit ein zweiter gleichzeitiger Zugriff den Pod nicht
# kippt.
#
# ffmpeg ist CPU-gebunden: mit nur 100m dauert das Umrechnen ein
# Vielfaches und laeuft eher in ein Proxy-Zeitlimit. Bewusst KEIN
# CPU-Limit, nur ein hoeherer Request — ein Limit wuerde den
# Transcode hart drosseln, auch wenn der Knoten gerade frei ist.
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
limits:
memory: 1Gi
volumes:
# Platzhalter — die Betreiberin ergaenzt hier ihre eigene
# Volume-Quelle fuer den Namen "uploads" (z.B. ein PVC, das gegen
+20
View File
@@ -27,6 +27,26 @@ metadata:
# der beiden Limits (100 MB) plus etwas Spielraum fuer den
# multipart/form-data-Overhead ab.
nginx.ingress.kubernetes.io/proxy-body-size: "110m"
# Ohne diese drei Annotationen wartet ingress-nginx nur 60 Sekunden je
# Phase und liefert danach eine eigene HTML-Fehlerseite (504) aus. Der
# Video-Upload braucht regelmaessig laenger: erst muessen bis zu 100 MB
# ueber die Leitung, danach rechnet ffmpeg das Video fuers Web um (siehe
# lib/video-transcode.ts). Im Browser kam davon nur an, dass die Antwort
# kein JSON war ("Unexpected token '<', "<!DOCTYPE "... is not valid
# JSON") — die Ursache war daraus nicht ablesbar.
#
# 600s deckt den langsamsten realistischen Fall ab und liegt bewusst
# oberhalb des harten ffmpeg-Zeitlimits von 5 Minuten
# (TRANSCODE_TIMEOUT_MS in lib/video-transcode.ts): so bricht im Zweifel
# die Anwendung selbst mit einer verwertbaren JSON-Meldung ab, statt dass
# nginx die Verbindung vorher kappt.
#
# Achtung: sitzt zusaetzlich ein CDN davor (z.B. Cloudflare), gilt dessen
# eigenes Origin-Zeitlimit unabhaengig hiervon weiter — bei Cloudflare
# sind das 100 Sekunden (Fehler 524), ebenfalls als HTML-Seite.
nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
nginx.ingress.kubernetes.io/proxy-connect-timeout: "60"
spec:
ingressClassName: nginx
tls:
+17 -12
View File
@@ -52,17 +52,22 @@ function PeopleIcon() {
);
}
const STAT_KEYS = [
"revenueToday",
"revenueMonth",
"openOrders",
"inventory",
"soldGlasses",
"avgCart",
"repeatCustomers",
] as const;
/**
* Die Kennzahlen-Kacheln des Dashboards. Bewusst ein Typ und keine
* Wert-Konstante: die Liste wurde nie zur Laufzeit durchlaufen, sondern
* ausschliesslich benutzt, um die Record-/Array-Typen unten vollstaendig zu
* halten — als `as const`-Array landete sie trotzdem im Bundle.
*/
type StatKey =
| "revenueToday"
| "revenueMonth"
| "openOrders"
| "inventory"
| "soldGlasses"
| "avgCart"
| "repeatCustomers";
const statIcons: Record<(typeof STAT_KEYS)[number], () => ReturnType<typeof CoinIcon>> = {
const statIcons: Record<StatKey, () => ReturnType<typeof CoinIcon>> = {
revenueToday: CoinIcon,
revenueMonth: CoinIcon,
openOrders: OrdersIcon,
@@ -161,7 +166,7 @@ export default async function AdminDashboardPage() {
}
const repeatCustomerCount = [...ordersPerCustomer.values()].filter((count) => count > 1).length;
const statLabels: Record<(typeof STAT_KEYS)[number], string> = {
const statLabels: Record<StatKey, string> = {
revenueToday: t.adminDashboardPage.statRevenueToday,
revenueMonth: t.adminDashboardPage.statRevenueMonth,
openOrders: t.adminDashboardPage.statOpenOrders,
@@ -171,7 +176,7 @@ export default async function AdminDashboardPage() {
repeatCustomers: t.adminDashboardPage.statRepeatCustomers,
};
const stats: { key: (typeof STAT_KEYS)[number]; value: string }[] = [
const stats: { key: StatKey; value: string }[] = [
{ key: "revenueToday", value: formatPrice(revenueToday._sum.totalCents ?? 0) },
{ key: "revenueMonth", value: formatPrice(revenueMonth._sum.totalCents ?? 0) },
{ key: "openOrders", value: String(openOrders) },
+105 -21
View File
@@ -1,6 +1,11 @@
import { NextResponse } from "next/server";
import { mkdir, writeFile } from "node:fs/promises";
import { createWriteStream } from "node:fs";
import { copyFile, mkdir, mkdtemp, open, rename, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import type { ReadableStream as NodeReadableStream } from "node:stream/web";
import { isTrustedOrigin } from "@/lib/csrf";
import { rateLimit } from "@/lib/rate-limit";
import { logAdminAction } from "@/lib/audit-log";
@@ -11,25 +16,63 @@ import { requireAdminForApi } from "../_shared";
// node:fs braucht die Node-Runtime (nicht Edge).
export const runtime = "nodejs";
/**
* Anzahl Bytes vom Dateianfang, die fuer die Formaterkennung genuegen:
* detectVideoExtension() braucht hoechstens die ersten 12 (MP4 traegt seine
* "ftyp"-Kennung ab Byte 4, WebM den EBML-Header ab Byte 0).
*/
const MAGIC_BYTES_LENGTH = 12;
/**
* Verschiebt die fertige Datei aus dem Temp-Verzeichnis an ihren
* endgueltigen Platz. rename() ist ein reiner Verzeichniseintrag-Wechsel und
* damit sofort fertig, funktioniert aber NUR innerhalb desselben
* Dateisystems — in Produktion liegt UPLOAD_DIR auf einem gemounteten
* Volume, das Temp-Verzeichnis dagegen im Container-Dateisystem, und
* rename() scheitert dann mit EXDEV. copyFile() ueberbrueckt das; es kopiert
* im Betriebssystem, nicht ueber den Node-Heap, haelt die Datei also
* ebenfalls nie vollstaendig im Arbeitsspeicher.
*/
async function moveIntoUploadDir(from: string, to: string): Promise<void> {
try {
await rename(from, to);
} catch (error: unknown) {
const isCrossDevice = error instanceof Error && "code" in error && error.code === "EXDEV";
if (!isCrossDevice) throw error;
await copyFile(from, to);
}
}
/**
* Admin-only Upload-Endpunkt fuer das Hero-Hintergrundvideo (siehe
* lib/content-registry.ts "video"-Feldtyp, aktuell nur home.hero.video).
* Eigene Route statt Erweiterung von app/api/admin/upload/route.ts, weil
* Videos anders validiert werden: sharp kann nur Bilder dekodieren, hier
* uebernimmt ffmpeg (siehe lib/video-transcode.ts transcodeVideoForWeb) die
* Neuenkodierung. Ablauf: (1) Magic-Bytes bestaetigen MP4/WebM (nur
* uebernimmt ffmpeg (siehe lib/video-transcode.ts) die Neuenkodierung.
*
* Ablauf: (1) die hochgeladene Datei wird STREAMEND in eine temporaere Datei
* geschrieben, (2) ihre Magic-Bytes bestaetigen MP4/WebM (nur
* Format-Erkennung, sagt noch nichts ueber vollstaendige Dekodierbarkeit),
* (2) ffmpeg enkodiert das Video zu einem web-optimierten MP4 neu (kein Ton,
* begrenzte Aufloesung/Bitrate, "+faststart" fuer sofortigen Wiedergabestart
* ohne die ganze Datei laden zu muessen — das war der Hauptgrund fuer lange
* Ladezeiten bei einem direkt von Handy/Kamera hochgeladenen Rohvideo), (3)
* NUR das transkodierte Ergebnis wird gespeichert, nie die Originaldatei —
* ein erfolgreicher Transcode validiert nebenbei vollstaendig, dass die
* Datei tatsaechlich als Video dekodierbar ist (kein reiner Magic-Byte-
* Vertrauensvorschuss mehr, analog zu reencodeImage() fuer Bilder). Zugriff
* ohnehin nur fuer eingeloggte 2FA-Admins (requireAdminForApi), Auslieferung
* mit festem Content-Type + "X-Content-Type-Options: nosniff" (siehe
* app/uploads/[...path]/route.ts) und zufaelligem Dateinamen.
* (3) ffmpeg enkodiert sie zu einem web-optimierten MP4 neu, (4) NUR das
* transkodierte Ergebnis wird nach UPLOAD_DIR verschoben, nie die
* Originaldatei — ein erfolgreicher Transcode validiert nebenbei
* vollstaendig, dass die Datei tatsaechlich als Video dekodierbar ist
* (analog zu reencodeImage() fuer Bilder).
*
* Warum streamend statt ueber Buffer: ein Video darf bis
* MAX_UPLOAD_VIDEO_BYTES (100 MB) gross sein. Die vorherige Fassung hielt es
* gleich mehrfach vollstaendig im Node-Heap — einmal aus file.arrayBuffer(),
* einmal als Buffer daraus, und das Transcoding-Ergebnis noch einmal aus
* readFile(). Bei dem in k8s/05-app-deployment.yaml gesetzten Speicherlimit
* reicht das aus, um den Prozess mitten im Upload abzuschiessen; im Browser
* kommt davon nur die HTML-Fehlerseite des Reverse-Proxy an, die der Client
* frueher als "Unexpected token '<'" meldete. Jetzt wandert die Datei ueber
* einen Stream auf die Platte und von dort per rename/copyFile an ihr Ziel,
* ohne je vollstaendig im Speicher zu liegen.
*
* Zugriff ohnehin nur fuer eingeloggte 2FA-Admins (requireAdminForApi),
* Auslieferung mit festem Content-Type + "X-Content-Type-Options: nosniff"
* (siehe app/uploads/[...path]/route.ts) und zufaelligem Dateinamen.
*/
export async function POST(request: Request) {
if (!isTrustedOrigin(request)) {
@@ -64,18 +107,39 @@ export async function POST(request: Request) {
);
}
const buffer = Buffer.from(await file.arrayBuffer());
const dir = await mkdtemp(path.join(tmpdir(), "wabenhain-video-"));
const inputPath = path.join(dir, "input");
const outputPath = path.join(dir, "output.mp4");
if (!detectVideoExtension(buffer)) {
try {
// Der Cast ueberbrueckt nur eine Typ-Doppelung, keine Laufzeitgrenze:
// File.stream() ist in den DOM-Typen als ReadableStream deklariert,
// Readable.fromWeb() erwartet die Deklaration aus node:stream/web. Unter
// der Node-Runtime ist beides dieselbe globale Klasse.
const webStream = file.stream() as unknown as NodeReadableStream<Uint8Array>;
await pipeline(Readable.fromWeb(webStream), createWriteStream(inputPath));
// Formaterkennung ausschliesslich anhand der tatsaechlichen Magic-Bytes
// am Dateianfang — nie ueber den Client-Dateinamen oder den vom Client
// gesendeten Content-Type, beide sind frei faelschbar. Gelesen werden nur
// die ersten Bytes, nicht die ganze Datei.
const head = Buffer.alloc(MAGIC_BYTES_LENGTH);
const handle = await open(inputPath, "r");
let bytesRead: number;
try {
({ bytesRead } = await handle.read(head, 0, MAGIC_BYTES_LENGTH, 0));
} finally {
await handle.close();
}
if (!detectVideoExtension(head.subarray(0, bytesRead))) {
return NextResponse.json(
{ error: "Nicht unterstütztes Videoformat. Erlaubt: MP4, WebM." },
{ status: 400 }
);
}
let transcoded: Buffer;
try {
transcoded = await transcodeVideoForWeb(buffer);
await transcodeVideoForWeb(inputPath, outputPath);
} catch (error: unknown) {
console.error("Video-Upload: Transcoding fehlgeschlagen.", error);
return NextResponse.json(
@@ -88,11 +152,16 @@ export async function POST(request: Request) {
// Eingabeformat (MP4 ODER WebM) — deshalb hier fest "mp4" statt der
// urspruenglich erkannten Eingabe-Endung.
const filename = generateUploadFilename("mp4");
let outputBytes: number;
try {
outputBytes = (await stat(outputPath)).size;
await mkdir(UPLOAD_DIR, { recursive: true });
await writeFile(path.join(UPLOAD_DIR, filename), transcoded);
await moveIntoUploadDir(outputPath, path.join(UPLOAD_DIR, filename));
} catch (error: unknown) {
// Siehe Kommentar in app/api/admin/upload/route.ts (identisches Muster).
// Ohne dieses Catch wuerde ein Dateisystemfehler (z.B. fehlende
// Schreibrechte auf UPLOAD_DIR) als unbehandelte Exception
// durchschlagen -> Next.js liefert dafuer eine HTML-Fehlerseite statt
// JSON zurueck, aus der der Client keine Meldung gewinnen kann.
console.error("Video-Upload: Datei konnte nicht gespeichert werden.", error);
return NextResponse.json(
{ error: "Die Datei konnte nicht gespeichert werden. Bitte informiere die Technik." },
@@ -104,8 +173,23 @@ export async function POST(request: Request) {
action: "media.upload",
entityType: "Upload",
entityId: filename,
detail: `mp4, ${Math.ceil(buffer.length / 1024)} KB -> ${Math.ceil(transcoded.length / 1024)} KB`,
detail: `mp4, ${Math.ceil(file.size / 1024)} KB -> ${Math.ceil(outputBytes / 1024)} KB`,
});
return NextResponse.json({ path: `/uploads/${filename}` });
} catch (error: unknown) {
// Letzte Auffanglinie: alles, was oben nicht schon gezielt behandelt ist
// (z.B. ein Abbruch beim Schreiben der temporaeren Datei), muss trotzdem
// als JSON herauskommen — eine HTML-Fehlerseite waere fuer den Client
// nicht auswertbar.
console.error("Video-Upload: unerwarteter Fehler.", error);
return NextResponse.json(
{ error: "Das Video konnte nicht verarbeitet werden." },
{ status: 500 }
);
} finally {
// Temp-Verzeichnis in JEDEM Fall raeumen — auch bei einem Abbruch
// mittendrin, sonst sammeln sich Rohvideos im Container an.
await rm(dir, { recursive: true, force: true });
}
}
+8 -2
View File
@@ -34,7 +34,13 @@ export default async function ImkereiPage() {
))}
</div>
</ScrollReveal>
<ScrollReveal delay={0.1}>
{/* Bewusst NICHT in ScrollReveal und mit priority: dieses Bild steht
neben der h1 im ersten Viewport und ist auf breiten Bildschirmen
das groesste Element der Seite. ScrollReveal startet bei
opacity 0 und blendet erst nach der Hydration ein — zusammen mit
dem sonst greifenden Lazy-Loading erschien das Bild damit
deutlich spaeter als noetig. Die Textspalte daneben behaelt ihre
Einblendung, die kostet nichts. */}
<ContentImage
src={image || null}
alt={t.imkereiPage.imageLabel}
@@ -42,8 +48,8 @@ export default async function ImkereiPage() {
className="aspect-[4/5] w-full"
label={t.imkereiPage.imageLabel}
sizes="(min-width: 768px) 50vw, 100vw"
priority
/>
</ScrollReveal>
</Container>
</div>
);
+1 -1
View File
@@ -80,7 +80,7 @@ export function WarenkorbContent({
const itemStock = stock?.[item.productId];
return (
<div key={item.productId} className="py-6 flex flex-wrap items-center gap-x-6 gap-y-3">
<CartItemThumbnail image={item.image} className="w-20 h-20 flex-shrink-0" />
<CartItemThumbnail image={item.image} className="w-20 h-20 flex-shrink-0 rounded-lg" />
<div className="flex-1 min-w-[160px]">
<Link href={`/honig/${item.slug}`} className="text-lg text-forest">
{item.name}
+16 -5
View File
@@ -3,6 +3,7 @@
import Image from "next/image";
import { useRef, useState, type ChangeEvent } from "react";
import { useI18n } from "@/i18n/i18n-provider";
import { readUploadResponse } from "@/lib/upload-client";
interface AdminImageFieldProps {
/** Formularfeld-Name, unter dem der aktuelle Bildpfad beim Speichern des
@@ -36,12 +37,22 @@ export function AdminImageField({ name, label, initialValue }: AdminImageFieldPr
try {
const body = new FormData();
body.append("file", file);
const response = await fetch("/api/admin/upload", { method: "POST", body });
const data: { path?: string; error?: string } = await response.json();
if (!response.ok || !data.path) {
throw new Error(data.error ?? t.adminImageField.uploadError);
let response: Response;
try {
response = await fetch("/api/admin/upload", { method: "POST", body });
} catch {
// fetch() wirft nur, wenn der Request den Server gar nicht erreicht
// oder die Verbindung abbricht — ein HTTP-Fehlerstatus landet NICHT
// hier, sondern wird unten ausgewertet.
throw new Error(t.adminImageField.uploadErrorNetwork);
}
setValue(data.path);
const result = await readUploadResponse(response);
if (!result.ok) {
throw new Error(
result.kind === "message" ? result.message : t.adminImageField.uploadErrorStatus(result.status)
);
}
setValue(result.path);
setStatus("idle");
} catch (error: unknown) {
setStatus("error");
+16 -5
View File
@@ -3,6 +3,7 @@
import Image from "next/image";
import { useRef, useState, type ChangeEvent } from "react";
import { useI18n } from "@/i18n/i18n-provider";
import { readUploadResponse } from "@/lib/upload-client";
interface AdminImageListFieldProps {
/** Formularfeld-Name, unter dem JEDES Bild als eigenes verstecktes Feld
@@ -37,12 +38,22 @@ export function AdminImageListField({ name, label, initialValues }: AdminImageLi
try {
const body = new FormData();
body.append("file", file);
const response = await fetch("/api/admin/upload", { method: "POST", body });
const data: { path?: string; error?: string } = await response.json();
if (!response.ok || !data.path) {
throw new Error(data.error ?? t.adminImageField.uploadError);
let response: Response;
try {
response = await fetch("/api/admin/upload", { method: "POST", body });
} catch {
// fetch() wirft nur, wenn der Request den Server gar nicht erreicht
// oder die Verbindung abbricht — ein HTTP-Fehlerstatus landet NICHT
// hier, sondern wird unten ausgewertet.
throw new Error(t.adminImageField.uploadErrorNetwork);
}
setImages((prev) => [...prev, data.path!]);
const result = await readUploadResponse(response);
if (!result.ok) {
throw new Error(
result.kind === "message" ? result.message : t.adminImageField.uploadErrorStatus(result.status)
);
}
setImages((prev) => [...prev, result.path]);
setStatus("idle");
} catch (error: unknown) {
setStatus("error");
+16 -5
View File
@@ -2,6 +2,7 @@
import { useRef, useState, type ChangeEvent } from "react";
import { useI18n } from "@/i18n/i18n-provider";
import { readUploadResponse } from "@/lib/upload-client";
interface AdminVideoFieldProps {
/** Formularfeld-Name, unter dem der aktuelle Videopfad beim Speichern des
@@ -34,12 +35,22 @@ export function AdminVideoField({ name, label, initialValue }: AdminVideoFieldPr
try {
const body = new FormData();
body.append("file", file);
const response = await fetch("/api/admin/upload-video", { method: "POST", body });
const data: { path?: string; error?: string } = await response.json();
if (!response.ok || !data.path) {
throw new Error(data.error ?? t.adminVideoField.uploadError);
let response: Response;
try {
response = await fetch("/api/admin/upload-video", { method: "POST", body });
} catch {
// fetch() wirft nur, wenn der Request den Server gar nicht erreicht
// oder die Verbindung abbricht — ein HTTP-Fehlerstatus landet NICHT
// hier, sondern wird unten ausgewertet.
throw new Error(t.adminVideoField.uploadErrorNetwork);
}
setValue(data.path);
const result = await readUploadResponse(response);
if (!result.ok) {
throw new Error(
result.kind === "message" ? result.message : t.adminVideoField.uploadErrorStatus(result.status)
);
}
setValue(result.path);
setStatus("idle");
} catch (error: unknown) {
setStatus("error");
+13 -2
View File
@@ -1,3 +1,4 @@
import Image from "next/image";
import { PlaceholderImage } from "@/components/placeholder-image";
/**
@@ -8,8 +9,18 @@ import { PlaceholderImage } from "@/components/placeholder-image";
export function CartItemThumbnail({ image, className }: { image: string | null; className: string }) {
if (image) {
return (
// eslint-disable-next-line @next/next/no-img-element -- externe Bildquelle noch unbekannt, siehe product-gallery.tsx
<img src={image} alt="" className={`${className} object-cover`} />
// Ueber next/image statt als rohes <img>. Der frueher hier stehende
// Hinweis auf eine "noch unbekannte externe Bildquelle" trifft nicht
// mehr zu: das Bild kommt aus Product.images[0] und liegt damit immer
// same-origin unter /uploads/... (geprueft von isValidUploadPath in
// lib/uploads.ts). Als rohes <img> lud der Warenkorb fuer JEDE Position
// das vollstaendig gespeicherte Bild — bis MAX_IMAGE_DIMENSION
// Kantenlaenge — um es auf 64 bzw. 80 Pixel zu verkleinern. Bei
// mehreren Positionen summierte sich das spuerbar, gerade im
// Mobilfunknetz.
<div className={`relative overflow-hidden ${className}`}>
<Image src={image} alt="" fill sizes="80px" className="object-cover" />
</div>
);
}
return <PlaceholderImage tone="honey" className={className} />;
+30 -3
View File
@@ -14,6 +14,12 @@ interface ContentImageProps {
* true setzen — laedt das Bild mit hoher Prioritaet ohne Lazy-Loading,
* statt es erst beim Scannen weiter unten im Dokument zu entdecken. */
priority?: boolean;
/** Leicht abgerundete Ecken. Standard true — passt fuer Karten, Teaser
* und Inhaltsbilder, die als eigene Flaeche auf dem Seitenhintergrund
* liegen. Fuer randlose Flaechen (Hero ueber die volle Fensterbreite)
* bewusst false: dort saesse die Rundung direkt an der Fensterkante und
* wirkte wie ein Darstellungsfehler. */
rounded?: boolean;
}
/**
@@ -34,9 +40,16 @@ export function ContentImage({
label,
sizes = "100vw",
priority = false,
rounded = true,
}: ContentImageProps) {
// Dieselbe Rundung fuer Platzhalter UND echtes Bild — sonst spraenge die
// Form in dem Moment um, in dem ein Bild gepflegt wird.
const roundedClass = rounded ? "rounded-lg" : "";
if (!src) {
return <PlaceholderImage tone={tone} className={className} label={label} />;
return (
<PlaceholderImage tone={tone} className={`${roundedClass} ${className}`} label={label} />
);
}
// "label" traegt bereits eine kurze inhaltliche Einordnung (Ernte-Jahr/Typ,
// Kategorie, Ort — siehe Aufrufer), die bisher nur auf der Platzhalterkachel
@@ -45,8 +58,22 @@ export function ContentImage({
// Bildersuche als der Produkt-/Ernteame allein.
const descriptiveAlt = label ? `${alt} – ${label}` : alt;
return (
<div className={`relative overflow-hidden ${className}`}>
<Image src={src} alt={descriptiveAlt} fill sizes={sizes} priority={priority} className="object-cover" />
<div className={`relative overflow-hidden ${roundedClass} ${className}`}>
<Image
src={src}
alt={descriptiveAlt}
fill
sizes={sizes}
// Next.js 16 hat die frueher hier verwendete Prop "priority"
// abgekuendigt: sie erzeugt nur noch einen <link rel="preload">, setzt
// am <img> selbst aber weder fetchpriority noch loading. Gemessen kam
// das Hero-Bild damit trotz "priority" mit niedriger Netzwerk-
// Prioritaet an. Die Dokumentation nennt fuer diesen Fall
// ausdruecklich loading="eager" und fetchPriority="high" als Ersatz.
loading={priority ? "eager" : "lazy"}
fetchPriority={priority ? "high" : "auto"}
className="object-cover"
/>
</div>
);
}
+35 -13
View File
@@ -5,11 +5,6 @@ import { Container, LinkButton } from "@/components/ui";
import { ContentImage } from "@/components/content-image";
import { useI18n } from "@/i18n/i18n-provider";
const sectionVariants: Variants = {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { duration: 0.6 } },
};
const itemContainerVariants: Variants = {
hidden: {},
visible: { transition: { delayChildren: 0.3, staggerChildren: 0.12 } },
@@ -41,17 +36,38 @@ export function Hero({
const shouldReduceMotion = useReducedMotion();
return (
<motion.section
className="relative"
initial={shouldReduceMotion ? false : "hidden"}
animate="visible"
variants={sectionVariants}
>
// Bewusst KEINE Einblend-Animation auf der aeussersten Ebene: eine
// Section, die bei opacity 0 startet, wird erst sichtbar, wenn das
// JS-Bundle geladen und die Animation gelaufen ist. Das Hero-Bild ist
// aber das groesste Element im ersten Viewport (LCP) — es so lange
// zurueckzuhalten verschiebt den gemessenen Ladezeitpunkt der Seite um
// die gesamte Hydrationsdauer. Der gestaffelte Aufbau der Textspalte
// weiter unten bleibt unveraendert, der laeuft unabhaengig davon.
<section className="relative">
{video ? (
<div className="relative h-[78vh] min-h-[520px] w-full overflow-hidden">
{/* Standbild als eigene Ebene UNTER dem Video statt im
poster-Attribut. poster nimmt nur eine fertige URL und laedt die
hochgeladene Datei damit in voller gespeicherter Groesse (bis
MAX_IMAGE_DIMENSION, siehe lib/uploads.ts) — am Bildoptimierer
vorbei und ohne Ruecksicht auf das Geraet. Als ContentImage
laeuft dasselbe Bild durch next/image: passende Breite je
Bildschirm, WebP statt JPEG, und mit "priority" als erstes
angefordert. Das Video liegt darueber und ist transparent, bis es
eigene Bilddaten hat — der Uebergang ist deshalb nicht sichtbar. */}
<div className="absolute inset-0">
<ContentImage
src={image}
alt=""
tone="forest"
className="h-full w-full"
rounded={false}
sizes="100vw"
priority
/>
</div>
<video
src={video}
poster={image ?? undefined}
className="absolute inset-0 h-full w-full object-cover"
// Nutzer mit "Bewegung reduzieren" bekommen nur das Standbild
// (poster) statt der automatisch laufenden Videoschleife (WCAG
@@ -60,6 +76,11 @@ export function Hero({
muted
loop
playsInline
// Ohne Autoplay (Einstellung "Bewegung reduzieren") gibt es
// nichts abzuspielen — dann nur die Kopfdaten holen statt die
// ganze Datei. Mit Autoplay ignorieren Browser den Hinweis
// ohnehin und laden so viel, wie zum Starten noetig ist.
preload={shouldReduceMotion ? "metadata" : "auto"}
// JS-Fallback zusaetzlich zum loop-Attribut: manche Browser
// (u.a. aeltere iOS-Safari-Versionen) starten ein Video nach
// "ended" nicht zuverlaessig neu, wenn autoplay/muted/playsinline
@@ -79,6 +100,7 @@ export function Hero({
tone="forest"
className="h-[78vh] min-h-[520px] w-full"
label="Bienenstand im Morgenlicht · Altenmünster"
rounded={false}
priority
/>
)}
@@ -124,6 +146,6 @@ export function Hero({
</motion.div>
</Container>
</div>
</motion.section>
</section>
);
}
+9 -5
View File
@@ -63,7 +63,7 @@ export function ProductGallery({ images, productName, description }: ProductGall
return (
<div>
<div
className="relative aspect-[4/3] overflow-hidden touch-pan-y"
className="relative aspect-[4/3] overflow-hidden rounded-lg touch-pan-y"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
@@ -72,14 +72,18 @@ export function ProductGallery({ images, productName, description }: ProductGall
// /uploads/... (siehe uploads.ts/isValidUploadPath) — next/image
// braucht dafuer KEINEN remotePatterns-Eintrag (nur externe
// Bildquellen brauchen den), siehe auch content-image.tsx. Als
// wahrscheinlichstes LCP-Element der Produktseite mit "priority"
// geladen, statt erst beim Scannen des Bild-Tags entdeckt zu werden.
// wahrscheinlichstes LCP-Element der Produktseite sofort und mit
// hoher Netzwerk-Prioritaet geladen, statt erst beim Scannen des
// Bild-Tags entdeckt zu werden. Bewusst NICHT ueber die Prop
// "priority": die ist seit Next.js 16 abgekuendigt und erzeugt nur
// noch einen Preload-Link, ohne fetchpriority am <img> zu setzen.
<Image
src={active.src}
alt={mainImageAlt}
fill
sizes="(min-width: 768px) 50vw, 100vw"
priority
loading="eager"
fetchPriority="high"
className="object-cover"
/>
) : (
@@ -95,7 +99,7 @@ export function ProductGallery({ images, productName, description }: ProductGall
onClick={() => setActiveIndex(index)}
aria-label={`${isImageSlide(slide) ? `Produktbild ${index + 1}` : slide.label} – Bild ${index + 1} von ${slides.length} anzeigen`}
aria-current={index === activeIndex}
className={`relative aspect-square overflow-hidden border-2 transition-colors ${
className={`relative aspect-square overflow-hidden rounded-lg border-2 transition-colors ${
index === activeIndex ? "border-forest" : "border-transparent hover:border-ink/20"
}`}
>
+16 -9
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useRecentlyViewedStore } from "@/store/recently-viewed-store";
import { getRecentlyViewedProducts } from "@/app/shop/actions";
import { ProductCard } from "@/components/product-card";
@@ -15,22 +15,29 @@ import type { ProductWithRelations } from "@/lib/data";
export function RecentlyViewedSection({ excludeProductId }: { excludeProductId?: string }) {
const { t } = useI18n();
const viewedIds = useRecentlyViewedStore((s) => s.viewedIds);
const [products, setProducts] = useState<ProductWithRelations[]>([]);
const ids = useMemo(
() => viewedIds.filter((id) => id !== excludeProductId),
[viewedIds, excludeProductId]
);
const [fetched, setFetched] = useState<ProductWithRelations[]>([]);
useEffect(() => {
const ids = viewedIds.filter((id) => id !== excludeProductId);
if (ids.length === 0) {
setProducts([]);
return;
}
// Der Leerfall wird bewusst NICHT mehr per setState im Effekt behandelt:
// "keine angesehenen Produkte" ergibt sich unmittelbar aus ids und laesst
// sich beim Rendern ableiten. Ein setState direkt im Effektrumpf loest
// dagegen einen zusaetzlichen Renderdurchlauf aus, nur um einen Zustand
// herzustellen, der ohnehin schon feststand.
if (ids.length === 0) return;
let cancelled = false;
getRecentlyViewedProducts(ids).then((result) => {
if (!cancelled) setProducts(result);
if (!cancelled) setFetched(result);
});
return () => {
cancelled = true;
};
}, [viewedIds, excludeProductId]);
}, [ids]);
const products = ids.length === 0 ? [] : fetched;
if (products.length === 0) return null;
+12 -7
View File
@@ -101,12 +101,17 @@ function SearchButton() {
const [suggestions, setSuggestions] = useState<SearchSuggestion[]>([]);
const toggleButtonRef = useRef<HTMLButtonElement>(null);
// "Eingabe zu kurz" ergibt sich unmittelbar aus query und wird beim
// Rendern abgeleitet, statt im Effekt per setState hergestellt zu werden —
// ein setState direkt im Effektrumpf loest einen zusaetzlichen
// Renderdurchlauf aus, nur um einen ohnehin feststehenden Zustand zu
// erzeugen. Nebeneffekt: beim Leeren des Feldes verschwindet die Liste
// sofort, statt erst nach dem naechsten Effektlauf.
const visibleSuggestions = query.trim().length < 2 ? [] : suggestions;
useEffect(() => {
const trimmed = query.trim();
if (trimmed.length < 2) {
setSuggestions([]);
return;
}
if (trimmed.length < 2) return;
const controller = new AbortController();
const timeout = setTimeout(() => {
fetch(`/api/shop/search-suggestions?q=${encodeURIComponent(trimmed)}`, {
@@ -153,9 +158,9 @@ function SearchButton() {
placeholder={t.nav.searchPlaceholder}
className="w-full border-b border-forest bg-transparent px-2 py-1 text-sm focus:outline-none"
/>
{suggestions.length > 0 ? (
{visibleSuggestions.length > 0 ? (
<ul className="absolute top-full left-0 mt-2 w-72 bg-background border border-ink/10 shadow-[0_16px_32px_-16px_rgba(36,53,43,0.25)] z-50">
{suggestions.map((s) => (
{visibleSuggestions.map((s) => (
<li key={s.slug}>
<Link
href={`/honig/${s.slug}`}
@@ -466,7 +471,7 @@ export function CartDrawer() {
const itemStock = stock?.[item.productId];
return (
<li key={item.productId} className="py-4 flex gap-4">
<CartItemThumbnail image={item.image} className="w-16 h-16 flex-shrink-0 rounded-sm" />
<CartItemThumbnail image={item.image} className="w-16 h-16 flex-shrink-0 rounded-lg" />
<div className="flex-1">
<p className="text-sm font-medium text-ink">{item.name}</p>
<p className="text-xs text-ink/65">{item.weightGrams} g</p>
+14
View File
@@ -1139,6 +1139,13 @@ export const de: Dictionary = {
remove: "Bild entfernen",
removeImageAt: (position) => `Bild ${position} entfernen`,
uploadError: "Der Upload ist fehlgeschlagen.",
uploadErrorStatus: (status) =>
status === 413
? `Die Datei wurde abgewiesen, weil sie zu groß ist (HTTP 413). Sie hat den Shop gar nicht erreicht — diese Grenze setzt der vorgeschaltete Proxy bzw. das CDN.`
: status === 502 || status === 503 || status === 504 || status === 522 || status === 524
? `Der Server hat die Verarbeitung abgebrochen (HTTP ${status}). Das ist fast immer eine Zeitüberschreitung: die Verarbeitung dauert länger, als der vorgeschaltete Proxy auf eine Antwort wartet.`
: `Der Server hat unerwartet geantwortet (HTTP ${status}) — keine verwertbare Rückmeldung. Bitte informiere die Technik und nenne diese Nummer.`,
uploadErrorNetwork: "Die Verbindung zum Server wurde während des Uploads unterbrochen.",
},
adminVideoField: {
none: "Kein Video",
@@ -1146,6 +1153,13 @@ export const de: Dictionary = {
uploading: "Wird hochgeladen…",
remove: "Video entfernen",
uploadError: "Der Upload ist fehlgeschlagen.",
uploadErrorStatus: (status) =>
status === 413
? `Das Video wurde abgewiesen, weil es zu groß ist (HTTP 413). Es hat den Shop gar nicht erreicht — diese Grenze setzt der vorgeschaltete Proxy bzw. das CDN, nicht der Shop selbst.`
: status === 502 || status === 503 || status === 504 || status === 522 || status === 524
? `Der Server hat die Verarbeitung abgebrochen (HTTP ${status}). Bei Videos ist das fast immer eine Zeitüberschreitung: das Umrechnen fürs Web dauert länger, als der vorgeschaltete Proxy auf eine Antwort wartet. Ein kürzeres oder kleineres Video kommt in der Regel durch.`
: `Der Server hat unerwartet geantwortet (HTTP ${status}) — keine verwertbare Rückmeldung. Bitte informiere die Technik und nenne diese Nummer.`,
uploadErrorNetwork: "Die Verbindung zum Server wurde während des Uploads unterbrochen.",
},
adminShell: {
navDashboard: "Dashboard",
+14
View File
@@ -1139,6 +1139,13 @@ export const en: Dictionary = {
remove: "Remove image",
removeImageAt: (position) => `Remove image ${position}`,
uploadError: "The upload failed.",
uploadErrorStatus: (status) =>
status === 413
? `The file was rejected because it is too large (HTTP 413). It never reached the shop — that limit comes from the reverse proxy or CDN in front of it.`
: status === 502 || status === 503 || status === 504 || status === 522 || status === 524
? `The server aborted processing (HTTP ${status}). This is almost always a timeout: processing takes longer than the proxy in front of it waits for a response.`
: `The server responded unexpectedly (HTTP ${status}) — no usable reply. Please report this number to your technical contact.`,
uploadErrorNetwork: "The connection to the server was interrupted during the upload.",
},
adminVideoField: {
none: "No video",
@@ -1146,6 +1153,13 @@ export const en: Dictionary = {
uploading: "Uploading…",
remove: "Remove video",
uploadError: "The upload failed.",
uploadErrorStatus: (status) =>
status === 413
? `The video was rejected because it is too large (HTTP 413). It never reached the shop — that limit comes from the reverse proxy or CDN in front of it, not from the shop itself.`
: status === 502 || status === 503 || status === 504 || status === 522 || status === 524
? `The server aborted processing (HTTP ${status}). For videos this is almost always a timeout: re-encoding for the web takes longer than the proxy in front of it waits for a response. A shorter or smaller video usually gets through.`
: `The server responded unexpectedly (HTTP ${status}) — no usable reply. Please report this number to your technical contact.`,
uploadErrorNetwork: "The connection to the server was interrupted during the upload.",
},
adminShell: {
navDashboard: "Dashboard",
+10
View File
@@ -840,6 +840,12 @@ export interface Dictionary {
remove: string;
removeImageAt: (position: number) => string;
uploadError: string;
/** Meldung, wenn die Antwort gar kein verwertbares JSON war — dann ist
* nur der HTTP-Status bekannt (siehe lib/upload-client.ts). */
uploadErrorStatus: (status: number) => string;
/** Meldung, wenn der Request den Server gar nicht erreicht hat oder die
* Verbindung waehrend des Uploads abbrach. */
uploadErrorNetwork: string;
};
/** Wie adminImageField, aber fuer AdminVideoField (Hero-Hintergrundvideo,
* admin/inhalte). */
@@ -849,6 +855,10 @@ export interface Dictionary {
uploading: string;
remove: string;
uploadError: string;
/** Wie adminImageField.uploadErrorStatus, aber mit dem fuer Videos
* haeufigsten Grund (Zeitueberschreitung beim Umrechnen) im Text. */
uploadErrorStatus: (status: number) => string;
uploadErrorNetwork: string;
};
adminShell: {
navDashboard: string;
+9 -4
View File
@@ -165,13 +165,18 @@ describe("createDhlShipmentLabel", () => {
vi.stubGlobal("fetch", fetchMock);
const { createDhlShipmentLabel, DhlApiError } = await import("./dhl");
await expect(
createDhlShipmentLabel({
const attempt = createDhlShipmentLabel({
reference: "WH-2026-0004",
weightGrams: 500,
shipper: validShipper,
receiver: validAddress,
})
).rejects.toThrow(/Ungültige Anfrage/);
});
// Neben der Meldung auch den Fehlertyp pruefen — wie in den beiden Tests
// darueber. Ohne diese Zusage blieb das bereits importierte DhlApiError
// hier ungenutzt, und der Test haette jeden beliebigen anderen Fehler mit
// passendem Text durchgehen lassen.
await expect(attempt).rejects.toThrow(DhlApiError);
await expect(attempt).rejects.toThrow(/Ungültige Anfrage/);
});
});
+70
View File
@@ -0,0 +1,70 @@
import { describe, test, expect } from "vitest";
import { readUploadResponse } from "./upload-client";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
function htmlResponse(status: number): Response {
return new Response("<!DOCTYPE html><html><body>Gateway Time-out</body></html>", {
status,
headers: { "content-type": "text/html; charset=utf-8" },
});
}
describe("readUploadResponse", () => {
test("returns the path of a successful upload", async () => {
const result = await readUploadResponse(jsonResponse({ path: "/uploads/abc.mp4" }));
expect(result).toEqual({ ok: true, path: "/uploads/abc.mp4" });
});
test("passes through the server's own error message", async () => {
const result = await readUploadResponse(jsonResponse({ error: "Die Datei ist zu groß (max. 100 MB)." }, 413));
expect(result).toEqual({ ok: false, kind: "message", message: "Die Datei ist zu groß (max. 100 MB)." });
});
test("reports an HTML error page by status instead of trying to parse it as JSON", async () => {
// Der eigentliche Bug: response.json() auf einer HTML-Fehlerseite wirft
// "Unexpected token '<', "<!DOCTYPE "... is not valid JSON" und
// verdeckt damit vollstaendig, WAS schiefging. Eine Fehlerseite kommt
// nie von diesem Endpunkt selbst (der antwortet immer mit JSON),
// sondern von einer Schicht davor — Reverse-Proxy, CDN oder Gateway.
const result = await readUploadResponse(htmlResponse(504));
expect(result).toEqual({ ok: false, kind: "status", status: 504 });
});
test("reports a proxy 413 by status (the request never reached the app)", async () => {
const result = await readUploadResponse(htmlResponse(413));
expect(result).toEqual({ ok: false, kind: "status", status: 413 });
});
test("treats a 200 response that is not JSON as a status failure", async () => {
const result = await readUploadResponse(htmlResponse(200));
expect(result).toEqual({ ok: false, kind: "status", status: 200 });
});
test("treats JSON that is not parseable as a status failure", async () => {
const broken = new Response("{ truncated", { status: 200, headers: { "content-type": "application/json" } });
expect(await readUploadResponse(broken)).toEqual({ ok: false, kind: "status", status: 200 });
});
test("treats a JSON success body without a path as a status failure", async () => {
expect(await readUploadResponse(jsonResponse({}))).toEqual({ ok: false, kind: "status", status: 200 });
});
test("treats an empty body with an error status as a status failure", async () => {
const empty = new Response(null, { status: 502 });
expect(await readUploadResponse(empty)).toEqual({ ok: false, kind: "status", status: 502 });
});
test("accepts a JSON content type that carries extra parameters", async () => {
const res = new Response(JSON.stringify({ path: "/uploads/x.jpg" }), {
status: 200,
headers: { "content-type": "application/json; charset=utf-8" },
});
expect(await readUploadResponse(res)).toEqual({ ok: true, path: "/uploads/x.jpg" });
});
});
+63
View File
@@ -0,0 +1,63 @@
/**
* Ergebnis eines Upload-Requests aus Sicht der Admin-Felder.
*
* - `ok: true` -> der Server hat einen Upload-Pfad geliefert.
* - `kind: "message"` -> der Server hat eine eigene, bereits fuer
* Menschen formulierte Fehlermeldung geliefert
* (z.B. "Die Datei ist zu gross (max. 100 MB).").
* Die wird unveraendert angezeigt.
* - `kind: "status"` -> die Antwort kam gar nicht als verwertbares JSON
* zurueck. Dann ist nur der HTTP-Status bekannt,
* und die Meldung muss daraus gebildet werden.
*/
export type UploadResult =
| { ok: true; path: string }
| { ok: false; kind: "message"; message: string }
| { ok: false; kind: "status"; status: number };
/**
* Wertet die Antwort eines Upload-Endpunkts aus, OHNE blind
* response.json() aufzurufen.
*
* Die Upload-Route-Handler (app/api/admin/upload/route.ts und
* upload-video/route.ts) antworten ausnahmslos mit JSON — auch in jedem
* Fehlerfall. Trifft hier trotzdem HTML ein, stammt die Antwort gar nicht
* von der Anwendung, sondern von einer Schicht davor: Reverse-Proxy, CDN
* oder Gateway, das den Request abgewiesen (zu grosser Body) oder
* abgebrochen hat (Zeitueberschreitung beim Transcoding eines grossen
* Videos).
*
* Genau dort lag der eigentliche Schaden: response.json() auf so einer
* HTML-Seite wirft "Unexpected token '<', "<!DOCTYPE "... is not valid
* JSON". Diese Meldung landete wortwoertlich im Admin-Formular und
* verdeckte vollstaendig, was tatsaechlich passiert war — weder der
* HTTP-Status noch die Schicht, die abgebrochen hat, waren daraus
* ablesbar. Der Status wird deshalb erhalten und nach oben gereicht.
*/
export async function readUploadResponse(response: Response): Promise<UploadResult> {
const contentType = response.headers.get("content-type") ?? "";
const isJson = contentType.split(";")[0].trim().toLowerCase() === "application/json";
if (!isJson) {
return { ok: false, kind: "status", status: response.status };
}
let data: { path?: unknown; error?: unknown };
try {
data = (await response.json()) as { path?: unknown; error?: unknown };
} catch {
// JSON angekuendigt, aber abgeschnitten/unvollstaendig — etwa wenn die
// Verbindung mitten in der Antwort abbricht.
return { ok: false, kind: "status", status: response.status };
}
if (typeof data.error === "string" && data.error.length > 0) {
return { ok: false, kind: "message", message: data.error };
}
if (response.ok && typeof data.path === "string" && data.path.length > 0) {
return { ok: true, path: data.path };
}
return { ok: false, kind: "status", status: response.status };
}
+22 -30
View File
@@ -1,7 +1,4 @@
import { spawn } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import ffmpegStaticPath from "ffmpeg-static";
/**
@@ -9,7 +6,7 @@ import ffmpegStaticPath from "ffmpeg-static";
* (versehentlich oder absichtlich) pathologischer Upload einen
* Server-Prozess auf unbestimmte Zeit blockiert. Admin-Uploads sind selten
* (ein Hero-Video), 5 Minuten reichen fuer jedes realistische kurze
* Hintergrundvideo bei "medium"-Preset deutlich.
* Hintergrundvideo bei "veryfast"-Preset deutlich.
*/
const TRANSCODE_TIMEOUT_MS = 5 * 60 * 1000;
@@ -29,20 +26,23 @@ const TRANSCODE_TIMEOUT_MS = 5 * 60 * 1000;
* statt der oft sehr hohen Rohbitrate direkt von Kamera/Handy.
* 4. "+faststart": verschiebt den moov-Atom (Inhaltsverzeichnis der Datei)
* an den Dateianfang. Ohne dieses Flag muss der Browser haeufig die
* GESAMTE Datei laden, bevor er ueberhaupt zu spielen beginnen kann —
* das allein erklaerte einen grossen Teil der langsamen ersten
* Wiedergabe, unabhaengig von der Dateigroesse.
* GESAMTE Datei laden, bevor er ueberhaupt zu spielen beginnen kann.
*
* Nebeneffekt: ein erfolgreicher Transcode beweist zugleich, dass die Datei
* tatsaechlich vollstaendig als Video dekodierbar ist — schliesst damit die
* gleiche Restluecke, die reencodeImage() (lib/uploads.ts) fuer Bilder schon
* abdeckt (volle Neuvalidierung der Pixel-/Frame-Daten, nicht nur der
* Magic-Bytes am Dateianfang).
* abdeckt (volle Neuvalidierung der Frame-Daten, nicht nur der Magic-Bytes).
*
* Arbeitet bewusst auf DATEIPFADEN, nicht auf Buffern: ffmpeg liest und
* schreibt ohnehin von der Platte, und ein Hero-Video darf bis zu
* MAX_UPLOAD_VIDEO_BYTES gross sein. Eingabe und Ergebnis zusaetzlich
* vollstaendig im Node-Heap zu halten, kostete pro Upload ein Vielfaches
* der Dateigroesse an Arbeitsspeicher — bei einem Container mit knappem
* Speicherlimit reicht das aus, um den Prozess abzuschiessen (der Browser
* sieht davon nur den Verbindungsabbruch bzw. die HTML-Fehlerseite des
* Reverse-Proxy).
*/
export async function transcodeVideoForWeb(buffer: Buffer): Promise<Buffer> {
const dir = await mkdtemp(path.join(tmpdir(), "wabenhain-video-"));
const inputPath = path.join(dir, "input");
const outputPath = path.join(dir, "output.mp4");
export async function transcodeVideoForWeb(inputPath: string, outputPath: string): Promise<void> {
const args = [
"-y",
"-i",
@@ -56,12 +56,11 @@ export async function transcodeVideoForWeb(buffer: Buffer): Promise<Buffer> {
// gegen Kompressionseffizienz, nicht gegen das Qualitaetsziel selbst (das
// steuert weiterhin allein -crf) — bei identischem CRF wird die Datei mit
// "veryfast" minimal groesser, aber typischerweise 3-5x schneller fertig.
// Noetig, weil ein Reverse-Proxy/CDN (hier: Cloudflare) vor einem zu
// lange antwortenden Origin-Request selbst abbricht und eine eigene
// HTML-Fehlerseite ausliefert -> im Client "Unexpected token '<' ...
// is not valid JSON", weil response.json() diese HTML-Seite nicht
// parsen kann. Mit "medium" konnte ein 57-MB-Rohvideo laenger brauchen,
// als das CDN auf eine Antwort wartet.
// Noetig, weil ein Reverse-Proxy/CDN vor einem zu lange antwortenden
// Origin-Request selbst abbricht und eine eigene HTML-Fehlerseite
// ausliefert -> im Client "Unexpected token '<' ... is not valid JSON",
// weil die Antwort dann kein JSON mehr ist (siehe lib/upload-client.ts,
// das diesen Fall inzwischen als Status meldet statt zu parsen).
"-preset",
"veryfast",
"-crf",
@@ -77,8 +76,6 @@ export async function transcodeVideoForWeb(buffer: Buffer): Promise<Buffer> {
outputPath,
];
try {
await writeFile(inputPath, buffer);
try {
// Bevorzugt "ffmpeg" ueber PATH — im Produktions-Image per "apk add
// ffmpeg" installiert (siehe Dockerfile "runner"-Stage), fest unter
@@ -87,19 +84,14 @@ export async function transcodeVideoForWeb(buffer: Buffer): Promise<Buffer> {
} catch (error: unknown) {
// ENOENT = Binary ueber PATH nicht gefunden (typischerweise ein
// lokaler Windows-/Mac-Rechner ohne System-ffmpeg) — dann auf die per
// npm mitgelieferte ffmpeg-static-Binary ausweichen, damit "npm run
// dev" ohne manuelle Einrichtung funktioniert. Jeder andere Fehler
// (z.B. eine tatsaechlich nicht dekodierbare Datei) soll dagegen
// unveraendert durchschlagen, nicht stillschweigend erneut versucht
// werden.
// npm mitgelieferte ffmpeg-static-Binary ausweichen, damit "pnpm dev"
// ohne manuelle Einrichtung funktioniert. Jeder andere Fehler (z.B. eine
// tatsaechlich nicht dekodierbare Datei) soll dagegen unveraendert
// durchschlagen, nicht stillschweigend erneut versucht werden.
const isMissingBinary = error instanceof Error && "code" in error && error.code === "ENOENT";
if (!isMissingBinary || !ffmpegStaticPath) throw error;
await runFfmpeg(ffmpegStaticPath, args);
}
return await readFile(outputPath);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
function runFfmpeg(binary: string, args: string[]): Promise<void> {