fix: hide upstream html payment errors

This commit is contained in:
2569718930@qq.com
2026-06-03 01:58:19 +08:00
parent afffacb529
commit a440f3d2b5
7 changed files with 207 additions and 22 deletions
@@ -66,6 +66,10 @@ export function runTests() {
const paymentFlowSource = fs.existsSync(paymentFlowPath)
? fs.readFileSync(paymentFlowPath, "utf8")
: "";
const paymentUtilsSource = fs.readFileSync(
path.join(projectRoot, "components", "account", "payment-utils.ts"),
"utf8",
);
// The receiver validation now lives in the extracted hook file (called
// from createManualPaymentIntent and createIntentAndPay).
@@ -246,4 +250,34 @@ export function runTests() {
walletBindSource.includes("readPaymentApiErrorMessage"),
"wallet binding errors must show the API error message instead of raw JSON",
);
assert(
paymentUtilsSource.includes("looksLikeHtmlDocument") &&
paymentUtilsSource.includes("Payment service is temporarily unavailable") &&
paymentUtilsSource.includes("支付服务暂时不可用"),
"payment API error parsing must collapse upstream HTML/Cloudflare 50x pages into a user-safe message",
);
assert(
paymentFlowSource.includes("readPaymentApiErrorMessage") &&
!paymentFlowSource.includes("(await submitRes.text()).slice(0, 350)") &&
!paymentFlowSource.includes("(await confirmRes.text()).slice(0, 350)"),
"payment submit/confirm failures must use sanitized API error messages instead of raw response text",
);
const submitRouteSource = fs.readFileSync(
path.join(
projectRoot,
"app",
"api",
"payments",
"intents",
"[intentId]",
"submit",
"route.ts",
),
"utf8",
);
assert(
submitRouteSource.includes("Payment submit upstream failed") &&
!submitRouteSource.includes("error: detail || undefined"),
"payment submit proxy must not copy raw upstream HTML into the public error field",
);
}
+32 -7
View File
@@ -68,27 +68,52 @@ export type NormalizedPaymentError = {
userRejected: boolean;
};
export function looksLikeHtmlDocument(value: string) {
const text = String(value || "").trim().toLowerCase();
return (
text.startsWith("<!doctype html") ||
text.startsWith("<html") ||
/<title>[^<]*(50\d|cloudflare|polyweather\.top)/i.test(String(value || ""))
);
}
function containsCjk(value: string) {
return /[\u3400-\u9fff]/.test(value);
}
function safePaymentServiceUnavailableMessage(fallback: string) {
return containsCjk(fallback)
? "支付服务暂时不可用,请稍后重试;如果已经付款,请保存 Tx Hash 联系管理员。"
: "Payment service is temporarily unavailable. Please retry shortly; if you already paid, keep the Tx Hash and contact support.";
}
export async function readPaymentApiErrorMessage(
response: Response,
fallback = "Request failed",
limit = 300,
) {
const raw = (await response.text()).slice(0, limit);
if (!raw) return fallback;
const raw = await response.text();
const trimmed = String(raw || "").trim();
if (!trimmed) return fallback;
let message = "";
try {
const parsed = JSON.parse(raw) as {
const parsed = JSON.parse(trimmed) as {
error?: unknown;
detail?: unknown;
message?: unknown;
};
const message = [parsed.error, parsed.detail, parsed.message].find(
const parsedMessage = [parsed.error, parsed.detail, parsed.message].find(
(item) => typeof item === "string" && item.trim(),
);
if (typeof message === "string") return message.trim();
if (typeof parsedMessage === "string") message = parsedMessage.trim();
} catch {
// Fall back to the raw response body below.
message = trimmed;
}
return raw;
const candidate = message || trimmed;
if (looksLikeHtmlDocument(candidate)) {
return safePaymentServiceUnavailableMessage(fallback);
}
return candidate.slice(0, limit);
}
export function normalizePaymentError(error: unknown): NormalizedPaymentError {
+41 -8
View File
@@ -24,6 +24,7 @@ import {
buildBalanceOfCalldata,
formatTokenUnits,
normalizePaymentError,
readPaymentApiErrorMessage,
requestWalletWithTimeout,
} from "./payment-utils";
import { trackAppEvent } from "@/lib/app-analytics";
@@ -304,7 +305,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
const headers = authHeaders || (await buildAuthedHeaders(false));
const configRes = await fetch("/api/payments/config", { cache: "no-store", headers });
if (!configRes.ok) {
const raw = (await configRes.text()).slice(0, 350);
const raw = await readPaymentApiErrorMessage(
configRes,
isEn ? "Payment config request failed." : "支付配置请求失败。",
350,
);
throw new Error(copy.loadConfigFailed.replace("{raw}", raw));
}
const configJson = (await configRes.json()) as PaymentConfig;
@@ -373,7 +378,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
await new Promise((resolve) => setTimeout(resolve, pollMs));
continue;
}
const raw = (await statusRes.text()).slice(0, 260);
const raw = await readPaymentApiErrorMessage(
statusRes,
isEn ? "Payment status request failed." : "支付状态请求失败。",
260,
);
throw new Error(copy.queryIntentFailed.replace("{raw}", raw));
}
const statusJson = (await statusRes.json()) as IntentStatusResponse;
@@ -505,7 +514,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
}),
});
if (!createRes.ok) {
const raw = (await createRes.text()).slice(0, 350);
const raw = await readPaymentApiErrorMessage(
createRes,
isEn ? "Payment order request failed." : "支付订单请求失败。",
350,
);
throw new Error(copy.createIntentFailed.replace("{raw}", raw));
}
@@ -584,7 +597,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm, from_address: payingWallet }),
});
if (!submitRes.ok) {
const raw = (await submitRes.text()).slice(0, 350);
const raw = await readPaymentApiErrorMessage(
submitRes,
isEn ? "Payment submit request failed." : "支付提交请求失败。",
350,
);
if (submitRes.status === 409) {
if (handleSubmit409Ref.current) await handleSubmit409Ref.current(intentId, txHashNorm, raw);
return;
@@ -596,7 +613,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }),
});
if (!confirmRes.ok) {
const raw = (await confirmRes.text()).slice(0, 350);
const raw = await readPaymentApiErrorMessage(
confirmRes,
isEn ? "Payment confirm request failed." : "支付确认请求失败。",
350,
);
const lowerRaw = raw.toLowerCase();
const maybePending =
(confirmRes.status === 404 && !lowerRaw.includes("payment intent not found")) ||
@@ -682,7 +703,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
}),
});
if (!createRes.ok) {
const raw = (await createRes.text()).slice(0, 350);
const raw = await readPaymentApiErrorMessage(
createRes,
isEn ? "Manual payment order request failed." : "手动转账订单请求失败。",
350,
);
throw new Error(copy.createManualIntentFailed.replace("{raw}", raw));
}
@@ -740,7 +765,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }),
});
if (!submitRes.ok) {
const raw = (await submitRes.text()).slice(0, 350);
const raw = await readPaymentApiErrorMessage(
submitRes,
isEn ? "Payment submit request failed." : "支付提交请求失败。",
350,
);
if (submitRes.status === 409) {
if (handleSubmit409Ref.current) await handleSubmit409Ref.current(intentIdVal, txHashNorm, raw);
return;
@@ -751,7 +780,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }),
});
if (!confirmRes.ok) {
const raw = (await confirmRes.text()).slice(0, 350);
const raw = await readPaymentApiErrorMessage(
confirmRes,
isEn ? "Payment confirm request failed." : "支付确认请求失败。",
350,
);
const lowerRaw = raw.toLowerCase();
const maybePending =
confirmRes.status === 408 ||