fix: hide upstream html payment errors
This commit is contained in:
@@ -151,6 +151,38 @@ warm_public_route() {
|
|||||||
return 0
|
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}"
|
PUBLIC_SMOKE_RECHECK_DELAY_SEC="${POLYWEATHER_PUBLIC_SMOKE_RECHECK_DELAY_SEC:-20}"
|
||||||
|
|
||||||
run_public_smoke_checks() {
|
run_public_smoke_checks() {
|
||||||
@@ -170,6 +202,8 @@ run_public_smoke_checks() {
|
|||||||
return "$failed"
|
return "$failed"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validate_frontend_api_base_url
|
||||||
|
|
||||||
echo "Updating Redis dependency..."
|
echo "Updating Redis dependency..."
|
||||||
compose_up_retry "redis" -d polyweather_redis
|
compose_up_retry "redis" -d polyweather_redis
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,35 @@ import {
|
|||||||
|
|
||||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
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(
|
export async function POST(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
context: { params: Promise<{ intentId: string }> },
|
context: { params: Promise<{ intentId: string }> },
|
||||||
@@ -40,14 +69,9 @@ export async function POST(
|
|||||||
);
|
);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const raw = await res.text();
|
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, {
|
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||||
detailLimit: 350,
|
detailLimit: 350,
|
||||||
error: detail || undefined,
|
error: submitErrorMessage(raw),
|
||||||
});
|
});
|
||||||
return applyAuthResponseCookies(response, auth.response);
|
return applyAuthResponseCookies(response, auth.response);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ export function runTests() {
|
|||||||
const paymentFlowSource = fs.existsSync(paymentFlowPath)
|
const paymentFlowSource = fs.existsSync(paymentFlowPath)
|
||||||
? fs.readFileSync(paymentFlowPath, "utf8")
|
? 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
|
// The receiver validation now lives in the extracted hook file (called
|
||||||
// from createManualPaymentIntent and createIntentAndPay).
|
// from createManualPaymentIntent and createIntentAndPay).
|
||||||
@@ -246,4 +250,34 @@ export function runTests() {
|
|||||||
walletBindSource.includes("readPaymentApiErrorMessage"),
|
walletBindSource.includes("readPaymentApiErrorMessage"),
|
||||||
"wallet binding errors must show the API error message instead of raw JSON",
|
"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;
|
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(
|
export async function readPaymentApiErrorMessage(
|
||||||
response: Response,
|
response: Response,
|
||||||
fallback = "Request failed",
|
fallback = "Request failed",
|
||||||
limit = 300,
|
limit = 300,
|
||||||
) {
|
) {
|
||||||
const raw = (await response.text()).slice(0, limit);
|
const raw = await response.text();
|
||||||
if (!raw) return fallback;
|
const trimmed = String(raw || "").trim();
|
||||||
|
if (!trimmed) return fallback;
|
||||||
|
let message = "";
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw) as {
|
const parsed = JSON.parse(trimmed) as {
|
||||||
error?: unknown;
|
error?: unknown;
|
||||||
detail?: unknown;
|
detail?: unknown;
|
||||||
message?: 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(),
|
(item) => typeof item === "string" && item.trim(),
|
||||||
);
|
);
|
||||||
if (typeof message === "string") return message.trim();
|
if (typeof parsedMessage === "string") message = parsedMessage.trim();
|
||||||
} catch {
|
} 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 {
|
export function normalizePaymentError(error: unknown): NormalizedPaymentError {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
buildBalanceOfCalldata,
|
buildBalanceOfCalldata,
|
||||||
formatTokenUnits,
|
formatTokenUnits,
|
||||||
normalizePaymentError,
|
normalizePaymentError,
|
||||||
|
readPaymentApiErrorMessage,
|
||||||
requestWalletWithTimeout,
|
requestWalletWithTimeout,
|
||||||
} from "./payment-utils";
|
} from "./payment-utils";
|
||||||
import { trackAppEvent } from "@/lib/app-analytics";
|
import { trackAppEvent } from "@/lib/app-analytics";
|
||||||
@@ -304,7 +305,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
|||||||
const headers = authHeaders || (await buildAuthedHeaders(false));
|
const headers = authHeaders || (await buildAuthedHeaders(false));
|
||||||
const configRes = await fetch("/api/payments/config", { cache: "no-store", headers });
|
const configRes = await fetch("/api/payments/config", { cache: "no-store", headers });
|
||||||
if (!configRes.ok) {
|
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));
|
throw new Error(copy.loadConfigFailed.replace("{raw}", raw));
|
||||||
}
|
}
|
||||||
const configJson = (await configRes.json()) as PaymentConfig;
|
const configJson = (await configRes.json()) as PaymentConfig;
|
||||||
@@ -373,7 +378,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
||||||
continue;
|
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));
|
throw new Error(copy.queryIntentFailed.replace("{raw}", raw));
|
||||||
}
|
}
|
||||||
const statusJson = (await statusRes.json()) as IntentStatusResponse;
|
const statusJson = (await statusRes.json()) as IntentStatusResponse;
|
||||||
@@ -505,7 +514,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!createRes.ok) {
|
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));
|
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 }),
|
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm, from_address: payingWallet }),
|
||||||
});
|
});
|
||||||
if (!submitRes.ok) {
|
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 (submitRes.status === 409) {
|
||||||
if (handleSubmit409Ref.current) await handleSubmit409Ref.current(intentId, txHashNorm, raw);
|
if (handleSubmit409Ref.current) await handleSubmit409Ref.current(intentId, txHashNorm, raw);
|
||||||
return;
|
return;
|
||||||
@@ -596,7 +613,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
|||||||
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }),
|
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }),
|
||||||
});
|
});
|
||||||
if (!confirmRes.ok) {
|
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 lowerRaw = raw.toLowerCase();
|
||||||
const maybePending =
|
const maybePending =
|
||||||
(confirmRes.status === 404 && !lowerRaw.includes("payment intent not found")) ||
|
(confirmRes.status === 404 && !lowerRaw.includes("payment intent not found")) ||
|
||||||
@@ -682,7 +703,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!createRes.ok) {
|
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));
|
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 }),
|
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }),
|
||||||
});
|
});
|
||||||
if (!submitRes.ok) {
|
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 (submitRes.status === 409) {
|
||||||
if (handleSubmit409Ref.current) await handleSubmit409Ref.current(intentIdVal, txHashNorm, raw);
|
if (handleSubmit409Ref.current) await handleSubmit409Ref.current(intentIdVal, txHashNorm, raw);
|
||||||
return;
|
return;
|
||||||
@@ -751,7 +780,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
|||||||
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }),
|
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }),
|
||||||
});
|
});
|
||||||
if (!confirmRes.ok) {
|
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 lowerRaw = raw.toLowerCase();
|
||||||
const maybePending =
|
const maybePending =
|
||||||
confirmRes.status === 408 ||
|
confirmRes.status === 408 ||
|
||||||
|
|||||||
@@ -34,4 +34,11 @@ export function runTests() {
|
|||||||
deployScript.includes("https://polyweather.top/api/auth/me?prefer_snapshot=1"),
|
deployScript.includes("https://polyweather.top/api/auth/me?prefer_snapshot=1"),
|
||||||
"deploy script must warm terminal and auth snapshot routes after container replacement",
|
"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) {
|
export function clientStatusFromUpstream(status: number) {
|
||||||
if (PASSTHROUGH_UPSTREAM_STATUSES.has(status)) {
|
if (PASSTHROUGH_UPSTREAM_STATUSES.has(status)) {
|
||||||
return status;
|
return status;
|
||||||
@@ -44,8 +71,9 @@ export function buildUpstreamErrorResponse(
|
|||||||
extraDebug?: Record<string, unknown>;
|
extraDebug?: Record<string, unknown>;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
const safeJsonMessage = extractSafeUpstreamJsonMessage(rawDetail);
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
error: options?.error || "Upstream request failed",
|
error: options?.error || safeJsonMessage || "Upstream request failed",
|
||||||
upstream_status: upstreamStatus,
|
upstream_status: upstreamStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user