diff --git a/src/components/admin-image-field.tsx b/src/components/admin-image-field.tsx index 8382399..0adc9d3 100644 --- a/src/components/admin-image-field.tsx +++ b/src/components/admin-image-field.tsx @@ -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"); diff --git a/src/components/admin-image-list-field.tsx b/src/components/admin-image-list-field.tsx index fff0d13..f92decd 100644 --- a/src/components/admin-image-list-field.tsx +++ b/src/components/admin-image-list-field.tsx @@ -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"); diff --git a/src/components/admin-video-field.tsx b/src/components/admin-video-field.tsx index 034143b..30e797c 100644 --- a/src/components/admin-video-field.tsx +++ b/src/components/admin-video-field.tsx @@ -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"); diff --git a/src/i18n/dictionaries/de.ts b/src/i18n/dictionaries/de.ts index 615f33b..d0e5dea 100644 --- a/src/i18n/dictionaries/de.ts +++ b/src/i18n/dictionaries/de.ts @@ -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", diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index d42fe56..0de2a03 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -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", diff --git a/src/i18n/dictionary-type.ts b/src/i18n/dictionary-type.ts index 923694c..f72f55b 100644 --- a/src/i18n/dictionary-type.ts +++ b/src/i18n/dictionary-type.ts @@ -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; diff --git a/src/lib/upload-client.test.ts b/src/lib/upload-client.test.ts new file mode 100644 index 0000000..df3ca6c --- /dev/null +++ b/src/lib/upload-client.test.ts @@ -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("Gateway Time-out", { + 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 '<', " { + 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" }); + }); +}); diff --git a/src/lib/upload-client.ts b/src/lib/upload-client.ts new file mode 100644 index 0000000..db146d0 --- /dev/null +++ b/src/lib/upload-client.ts @@ -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 '<', " { + 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 }; +}