fix(settings): guard AI test and regenerate against non-JSON responses

The test and regenerate handlers called res.json() unconditionally. A
proxy-rewritten HTML error page made that throw "Unexpected token '<'",
which leaked into the toast. Both handlers now check the content-type and
wrap the parse, showing a clean message on a non-JSON body. The test
handler also renders the new { ok:false, reason } payload verbatim.
This commit is contained in:
Marc-André Bombeck committed 2026-06-03 08:56:20 +02:00
1 parent 564d7dec77
commit 5b5ab5795c
7 files changed
+67 -4

No files matched your search

+1
View File
@@ -3257,6 +3257,7 @@
"testProvider": "Aktiven Provider testen",
"testSuccess": "OK — {provider} ({model})",
"testFailedShort": "Test fehlgeschlagen: {message}",
"testUnexpectedResponse": "Verbindung zum KI-Anbieter fehlgeschlagen — unerwartete Antwort vom Server.",
"disableCoach": {
"title": "Coach ausblenden",
"description": "Versteckt den Coach-Button und das Coach-Panel auf allen Seiten.",
+1
View File
@@ -3257,6 +3257,7 @@
"testProvider": "Test active provider",
"testSuccess": "OK — {provider} ({model})",
"testFailedShort": "Test failed: {message}",
"testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.",
"disableCoach": {
"title": "Hide Coach",
"description": "Hides the Coach button and drawer everywhere.",
+1
View File
@@ -3257,6 +3257,7 @@
"testProvider": "Test active provider",
"testSuccess": "OK — {provider} ({model})",
"testFailedShort": "Test failed: {message}",
"testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.",
"disableCoach": {
"title": "Ocultar el Coach",
"description": "Oculta el botón del Coach y el panel en todas las páginas.",
+1
View File
@@ -3257,6 +3257,7 @@
"testProvider": "Test active provider",
"testSuccess": "OK — {provider} ({model})",
"testFailedShort": "Test failed: {message}",
"testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.",
"disableCoach": {
"title": "Masquer le Coach",
"description": "Masque le bouton et le panneau du Coach sur toutes les pages.",
+1
View File
@@ -3257,6 +3257,7 @@
"testProvider": "Test active provider",
"testSuccess": "OK — {provider} ({model})",
"testFailedShort": "Test failed: {message}",
"testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.",
"disableCoach": {
"title": "Nascondi il Coach",
"description": "Nasconde il pulsante e il pannello del Coach su tutte le pagine.",
+1
View File
@@ -3257,6 +3257,7 @@
"testProvider": "Test active provider",
"testSuccess": "OK — {provider} ({model})",
"testFailedShort": "Test failed: {message}",
"testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.",
"disableCoach": {
"title": "Ukryj Coacha",
"description": "Ukrywa przycisk Coacha i panel na wszystkich stronach.",
+61 -4
View File
@@ -1736,7 +1736,33 @@ function RuntimeActionsRow({
setTestMsg(null);
try {
const res = await fetch("/api/ai/test", { method: "POST" });
const json = await res.json();
// Guard against a non-JSON body. A reverse proxy / Cloudflare can
// rewrite an origin error to its own HTML page; parsing that as JSON
// throws `Unexpected token '<'` (Safari: "did not match the
// expected pattern"). Show a clean message instead of leaking that.
const contentType = res.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) {
setTestOk(false);
setTestMsg(t("settings.ai.testUnexpectedResponse"));
return;
}
let json: {
data?: {
ok?: boolean;
providerType?: string;
model?: string;
reason?: string;
} | null;
error?: string | null;
};
try {
json = await res.json();
} catch {
setTestOk(false);
setTestMsg(t("settings.ai.testUnexpectedResponse"));
return;
}
// 4xx config errors still arrive via the error envelope.
if (!res.ok) {
setTestOk(false);
setTestMsg(
@@ -1746,11 +1772,22 @@ function RuntimeActionsRow({
);
return;
}
// Provider-call failures now arrive as 200 + { ok:false, reason }
// so the body is never rewritten by a proxy. Show the reason
// verbatim — it is a categorised, secret-free string.
if (json.data && json.data.ok === false) {
setTestOk(false);
setTestMsg(
json.data.reason ??
t("settings.ai.testFailedShort", { message: `HTTP ${res.status}` }),
);
return;
}
setTestOk(true);
setTestMsg(
t("settings.ai.testSuccess", {
provider: json.data.providerType,
model: json.data.model,
provider: json.data?.providerType ?? "",
model: json.data?.model ?? "",
}),
);
} catch (e) {
@@ -1774,7 +1811,27 @@ function RuntimeActionsRow({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ force: true }),
});
const json = await res.json();
// Same defensive guard as the connection test: a proxy can rewrite
// a 5xx (e.g. all-providers-failed) to an HTML page, which would
// crash `res.json()` with `Unexpected token '<'`.
const contentType = res.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) {
setRegenOk(false);
setRegenMsg(
res.status === 429
? t("settings.regenerateRateLimit")
: t("settings.ai.testUnexpectedResponse"),
);
return;
}
let json: { error?: string | null };
try {
json = await res.json();
} catch {
setRegenOk(false);
setRegenMsg(t("settings.ai.testUnexpectedResponse"));
return;
}
if (!res.ok) {
setRegenOk(false);
setRegenMsg(