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.
This commit is contained in:
1 parent
be5fb32a4a
commit
b11edd7751
8 files changed
+219
-15
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user