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:
Nick Szaunig committed 2026-09-14 00:36:10 +02:00
1 parent be5fb32a4a
commit b11edd7751
8 files changed
+219 -15

No files matched your search

+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 };
}