fix: hide upstream html payment errors
This commit is contained in:
@@ -151,6 +151,38 @@ warm_public_route() {
|
||||
return 0
|
||||
}
|
||||
|
||||
read_env_file_value() {
|
||||
local key="$1"
|
||||
if [ ! -f ".env" ]; then
|
||||
return 0
|
||||
fi
|
||||
awk -F= -v key="$key" '
|
||||
$0 ~ "^[[:space:]]*" key "[[:space:]]*=" {
|
||||
value=$0
|
||||
sub("^[^=]*=", "", value)
|
||||
gsub("^[[:space:]]+|[[:space:]]+$", "", value)
|
||||
gsub(/^["'"'"']|["'"'"']$/, "", value)
|
||||
print value
|
||||
}
|
||||
' .env | tail -n 1
|
||||
}
|
||||
|
||||
validate_frontend_api_base_url() {
|
||||
local api_base="${POLYWEATHER_API_BASE_URL:-}"
|
||||
if [ -z "$api_base" ]; then
|
||||
api_base="$(read_env_file_value "POLYWEATHER_API_BASE_URL")"
|
||||
fi
|
||||
local normalized
|
||||
normalized="$(printf '%s' "$api_base" | tr '[:upper:]' '[:lower:]' | sed 's/[[:space:]]//g; s#/*$##')"
|
||||
case "$normalized" in
|
||||
http://polyweather.top|https://polyweather.top|http://www.polyweather.top|https://www.polyweather.top)
|
||||
echo "❌ POLYWEATHER_API_BASE_URL must not point at the frontend site: $api_base"
|
||||
echo " Use the internal backend URL http://polyweather_web:8000 or the backend API host https://api.polyweather.top."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
PUBLIC_SMOKE_RECHECK_DELAY_SEC="${POLYWEATHER_PUBLIC_SMOKE_RECHECK_DELAY_SEC:-20}"
|
||||
|
||||
run_public_smoke_checks() {
|
||||
@@ -170,6 +202,8 @@ run_public_smoke_checks() {
|
||||
return "$failed"
|
||||
}
|
||||
|
||||
validate_frontend_api_base_url
|
||||
|
||||
echo "Updating Redis dependency..."
|
||||
compose_up_retry "redis" -d polyweather_redis
|
||||
|
||||
|
||||
@@ -11,6 +11,35 @@ import {
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
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 submitErrorMessage(raw: string) {
|
||||
try {
|
||||
const parsed = JSON.parse(String(raw || "")) as {
|
||||
detail?: unknown;
|
||||
error?: unknown;
|
||||
message?: unknown;
|
||||
};
|
||||
const message = [parsed.detail, parsed.error, parsed.message].find(
|
||||
(item) => typeof item === "string" && item.trim(),
|
||||
);
|
||||
if (typeof message === "string") {
|
||||
const trimmed = message.trim();
|
||||
if (!looksLikeHtmlDocument(trimmed)) return trimmed.slice(0, 350);
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON upstream errors are commonly HTML 50x pages; do not expose them.
|
||||
}
|
||||
return "Payment submit upstream failed";
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ intentId: string }> },
|
||||
@@ -40,14 +69,9 @@ export async function POST(
|
||||
);
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
let detail = raw.slice(0, 350);
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed.detail) detail = String(parsed.detail).slice(0, 350);
|
||||
} catch {}
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 350,
|
||||
error: detail || undefined,
|
||||
error: submitErrorMessage(raw),
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -34,4 +34,11 @@ export function runTests() {
|
||||
deployScript.includes("https://polyweather.top/api/auth/me?prefer_snapshot=1"),
|
||||
"deploy script must warm terminal and auth snapshot routes after container replacement",
|
||||
);
|
||||
assert(
|
||||
deployScript.includes("validate_frontend_api_base_url") &&
|
||||
deployScript.includes("POLYWEATHER_API_BASE_URL must not point at the frontend site") &&
|
||||
deployScript.indexOf("validate_frontend_api_base_url") <
|
||||
deployScript.indexOf('echo "Updating Redis dependency..."'),
|
||||
"deploy script must reject frontend POLYWEATHER_API_BASE_URL values that point at polyweather.top and would recurse through the frontend proxy",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,33 @@ function shouldExposeProxyErrorDetail() {
|
||||
);
|
||||
}
|
||||
|
||||
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 extractSafeUpstreamJsonMessage(rawDetail: string) {
|
||||
try {
|
||||
const parsed = JSON.parse(String(rawDetail || "")) as {
|
||||
detail?: unknown;
|
||||
error?: unknown;
|
||||
message?: unknown;
|
||||
};
|
||||
const message = [parsed.detail, parsed.error, parsed.message].find(
|
||||
(item) => typeof item === "string" && item.trim(),
|
||||
);
|
||||
if (typeof message !== "string") return "";
|
||||
const trimmed = message.trim();
|
||||
return looksLikeHtmlDocument(trimmed) ? "" : trimmed;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function clientStatusFromUpstream(status: number) {
|
||||
if (PASSTHROUGH_UPSTREAM_STATUSES.has(status)) {
|
||||
return status;
|
||||
@@ -44,8 +71,9 @@ export function buildUpstreamErrorResponse(
|
||||
extraDebug?: Record<string, unknown>;
|
||||
},
|
||||
) {
|
||||
const safeJsonMessage = extractSafeUpstreamJsonMessage(rawDetail);
|
||||
const body: Record<string, unknown> = {
|
||||
error: options?.error || "Upstream request failed",
|
||||
error: options?.error || safeJsonMessage || "Upstream request failed",
|
||||
upstream_status: upstreamStatus,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user