Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72d36f4716 | ||
|
|
0a44fecbe1 | ||
|
|
02d493dede | ||
|
|
107cbadf79 | ||
|
|
2a16b3f962 | ||
|
|
713bf48157 | ||
|
|
b11edd7751 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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) },
|
||||
|
||||
@@ -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,48 +107,89 @@ 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)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Nicht unterstütztes Videoformat. Erlaubt: MP4, WebM." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
let transcoded: Buffer;
|
||||
try {
|
||||
transcoded = await transcodeVideoForWeb(buffer);
|
||||
// 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 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await transcodeVideoForWeb(inputPath, outputPath);
|
||||
} catch (error: unknown) {
|
||||
console.error("Video-Upload: Transcoding fehlgeschlagen.", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Das Video konnte nicht verarbeitet werden." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Ausgabe von transcodeVideoForWeb ist immer MP4 (H.264), unabhaengig vom
|
||||
// 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 moveIntoUploadDir(outputPath, path.join(UPLOAD_DIR, filename));
|
||||
} catch (error: unknown) {
|
||||
// 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." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
await logAdminAction(admin, {
|
||||
action: "media.upload",
|
||||
entityType: "Upload",
|
||||
entityId: filename,
|
||||
detail: `mp4, ${Math.ceil(file.size / 1024)} KB -> ${Math.ceil(outputBytes / 1024)} KB`,
|
||||
});
|
||||
|
||||
return NextResponse.json({ path: `/uploads/${filename}` });
|
||||
} catch (error: unknown) {
|
||||
console.error("Video-Upload: Transcoding fehlgeschlagen.", error);
|
||||
// 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: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Ausgabe von transcodeVideoForWeb ist immer MP4 (H.264), unabhaengig vom
|
||||
// Eingabeformat (MP4 ODER WebM) — deshalb hier fest "mp4" statt der
|
||||
// urspruenglich erkannten Eingabe-Endung.
|
||||
const filename = generateUploadFilename("mp4");
|
||||
try {
|
||||
await mkdir(UPLOAD_DIR, { recursive: true });
|
||||
await writeFile(path.join(UPLOAD_DIR, filename), transcoded);
|
||||
} catch (error: unknown) {
|
||||
// Siehe Kommentar in app/api/admin/upload/route.ts (identisches Muster).
|
||||
console.error("Video-Upload: Datei konnte nicht gespeichert werden.", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Die Datei konnte nicht gespeichert werden. Bitte informiere die Technik." },
|
||||
{ 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 });
|
||||
}
|
||||
|
||||
await logAdminAction(admin, {
|
||||
action: "media.upload",
|
||||
entityType: "Upload",
|
||||
entityId: filename,
|
||||
detail: `mp4, ${Math.ceil(buffer.length / 1024)} KB -> ${Math.ceil(transcoded.length / 1024)} KB`,
|
||||
});
|
||||
|
||||
return NextResponse.json({ path: `/uploads/${filename}` });
|
||||
}
|
||||
+16
-10
@@ -34,16 +34,22 @@ export default async function ImkereiPage() {
|
||||
))}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
<ScrollReveal delay={0.1}>
|
||||
<ContentImage
|
||||
src={image || null}
|
||||
alt={t.imkereiPage.imageLabel}
|
||||
tone="honey"
|
||||
className="aspect-[4/5] w-full"
|
||||
label={t.imkereiPage.imageLabel}
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
/>
|
||||
</ScrollReveal>
|
||||
{/* 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}
|
||||
tone="honey"
|
||||
className="aspect-[4/5] w-full"
|
||||
label={t.imkereiPage.imageLabel}
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
priority
|
||||
/>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
+13
-8
@@ -165,13 +165,18 @@ describe("createDhlShipmentLabel", () => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const { createDhlShipmentLabel, DhlApiError } = await import("./dhl");
|
||||
|
||||
await expect(
|
||||
createDhlShipmentLabel({
|
||||
reference: "WH-2026-0004",
|
||||
weightGrams: 500,
|
||||
shipper: validShipper,
|
||||
receiver: validAddress,
|
||||
})
|
||||
).rejects.toThrow(/Ungültige Anfrage/);
|
||||
const attempt = createDhlShipmentLabel({
|
||||
reference: "WH-2026-0004",
|
||||
weightGrams: 500,
|
||||
shipper: validShipper,
|
||||
receiver: validAddress,
|
||||
});
|
||||
|
||||
// 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/);
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
+32
-40
@@ -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",
|
||||
@@ -78,27 +77,20 @@ export async function transcodeVideoForWeb(buffer: Buffer): Promise<Buffer> {
|
||||
];
|
||||
|
||||
try {
|
||||
await writeFile(inputPath, buffer);
|
||||
try {
|
||||
// Bevorzugt "ffmpeg" ueber PATH — im Produktions-Image per "apk add
|
||||
// ffmpeg" installiert (siehe Dockerfile "runner"-Stage), fest unter
|
||||
// /usr/bin und unabhaengig von jeder Node/pnpm-Verzeichnisstruktur.
|
||||
await runFfmpeg("ffmpeg", args);
|
||||
} 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.
|
||||
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 });
|
||||
// Bevorzugt "ffmpeg" ueber PATH — im Produktions-Image per "apk add
|
||||
// ffmpeg" installiert (siehe Dockerfile "runner"-Stage), fest unter
|
||||
// /usr/bin und unabhaengig von jeder Node/pnpm-Verzeichnisstruktur.
|
||||
await runFfmpeg("ffmpeg", args);
|
||||
} 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 "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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user