Guard payments to allowed hosts and show checkout context
This commit is contained in:
@@ -29,5 +29,6 @@ POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
|||||||
# 可选:钱包支付 / Telegram 入口
|
# 可选:钱包支付 / Telegram 入口
|
||||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
|
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
|
||||||
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
|
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
|
||||||
|
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather-pro.vercel.app
|
||||||
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/your_group
|
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/your_group
|
||||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/WeatherQuant_bot
|
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/WeatherQuant_bot
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
|||||||
# 钱包支付
|
# 钱包支付
|
||||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
|
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
|
||||||
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
|
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
|
||||||
|
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather-pro.vercel.app
|
||||||
|
|
||||||
# 社群入口
|
# 社群入口
|
||||||
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/<your_group>
|
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/<your_group>
|
||||||
@@ -138,6 +139,8 @@ Ops:
|
|||||||
1. 点击支付前重新请求 `/api/payments/config`
|
1. 点击支付前重新请求 `/api/payments/config`
|
||||||
2. 若 `receiver_contract` 已更新,先切到最新地址
|
2. 若 `receiver_contract` 已更新,先切到最新地址
|
||||||
3. 若后端返回的 `tx_payload.to` 与最新地址不一致,直接阻断支付
|
3. 若后端返回的 `tx_payload.to` 与最新地址不一致,直接阻断支付
|
||||||
|
4. 仅允许在 `NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS` 白名单域名上创建 payment intent
|
||||||
|
5. 支付区会明确显示当前账号、付款钱包和收款合约,避免账号/钱包/地址混淆
|
||||||
|
|
||||||
这意味着:
|
这意味着:
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
applyAuthResponseCookies,
|
applyAuthResponseCookies,
|
||||||
buildBackendRequestHeaders,
|
buildBackendRequestHeaders,
|
||||||
} from "@/lib/backend-auth";
|
} from "@/lib/backend-auth";
|
||||||
|
import { isPaymentHostAllowed } from "@/lib/payment-host";
|
||||||
|
|
||||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||||
|
|
||||||
@@ -13,6 +14,20 @@ export async function POST(req: NextRequest) {
|
|||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const requestHost =
|
||||||
|
req.headers.get("x-forwarded-host") ||
|
||||||
|
req.headers.get("host") ||
|
||||||
|
req.nextUrl.hostname;
|
||||||
|
if (!isPaymentHostAllowed(requestHost)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"Payments are disabled on this host. Please return to the main production site and retry.",
|
||||||
|
host: requestHost,
|
||||||
|
},
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const auth = await buildBackendRequestHeaders(req);
|
const auth = await buildBackendRequestHeaders(req);
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ import {
|
|||||||
getSupabaseBrowserClient,
|
getSupabaseBrowserClient,
|
||||||
hasSupabasePublicEnv,
|
hasSupabasePublicEnv,
|
||||||
} from "@/lib/supabase/client";
|
} from "@/lib/supabase/client";
|
||||||
|
import {
|
||||||
|
getAllowedPaymentHosts,
|
||||||
|
getCurrentPaymentHost,
|
||||||
|
isPaymentHostAllowed,
|
||||||
|
} from "@/lib/payment-host";
|
||||||
import { useI18n } from "@/hooks/useI18n";
|
import { useI18n } from "@/hooks/useI18n";
|
||||||
|
|
||||||
const UnlockProOverlay = dynamic(
|
const UnlockProOverlay = dynamic(
|
||||||
@@ -683,6 +688,10 @@ export function AccountCenter() {
|
|||||||
copyCommand: isEn ? "Copy command" : "复制命令",
|
copyCommand: isEn ? "Copy command" : "复制命令",
|
||||||
paymentMgmt: isEn ? "Payment Management" : "支付管理",
|
paymentMgmt: isEn ? "Payment Management" : "支付管理",
|
||||||
paymentToken: isEn ? "Payment Token" : "支付币种",
|
paymentToken: isEn ? "Payment Token" : "支付币种",
|
||||||
|
paymentAccount: isEn ? "Subscription Account" : "订阅归属账号",
|
||||||
|
paymentWallet: isEn ? "Paying Wallet" : "付款钱包",
|
||||||
|
paymentReceiver: isEn ? "Receiver Contract" : "当前收款合约",
|
||||||
|
paymentHost: isEn ? "Payment Host" : "支付域名",
|
||||||
primary: "Primary",
|
primary: "Primary",
|
||||||
polygonChain: "Polygon Chain",
|
polygonChain: "Polygon Chain",
|
||||||
noWallet: isEn ? "No payout wallet bound yet." : "未绑定任何收件钱包",
|
noWallet: isEn ? "No payout wallet bound yet." : "未绑定任何收件钱包",
|
||||||
@@ -736,6 +745,12 @@ export function AccountCenter() {
|
|||||||
payNotReady: isEn
|
payNotReady: isEn
|
||||||
? "Payment service is not fully configured."
|
? "Payment service is not fully configured."
|
||||||
: "支付服务未配置完成。",
|
: "支付服务未配置完成。",
|
||||||
|
paymentHostBlocked: isEn
|
||||||
|
? "Payments are disabled on this host. Please return to the production site: {host}"
|
||||||
|
: "当前域名不允许发起支付,请回到主站后重试:{host}",
|
||||||
|
paymentGuardHint: isEn
|
||||||
|
? "Payment will be credited to the current account and bound wallet shown below."
|
||||||
|
: "支付将记入下方显示的当前账号和绑定钱包,请先核对。",
|
||||||
openBindFlow: isEn
|
openBindFlow: isEn
|
||||||
? "Please bind a wallet first. Opening bind flow..."
|
? "Please bind a wallet first. Opening bind flow..."
|
||||||
: "请先完成钱包绑定,正在拉起绑定流程...",
|
: "请先完成钱包绑定,正在拉起绑定流程...",
|
||||||
@@ -788,6 +803,12 @@ export function AccountCenter() {
|
|||||||
const paymentReadyForRecovery = Boolean(
|
const paymentReadyForRecovery = Boolean(
|
||||||
paymentConfig?.enabled && paymentConfig?.configured,
|
paymentConfig?.enabled && paymentConfig?.configured,
|
||||||
);
|
);
|
||||||
|
const allowedPaymentHosts = useMemo(() => getAllowedPaymentHosts(), []);
|
||||||
|
const currentPaymentHost = useMemo(() => getCurrentPaymentHost(), []);
|
||||||
|
const paymentHostAllowed = useMemo(
|
||||||
|
() => isPaymentHostAllowed(currentPaymentHost),
|
||||||
|
[currentPaymentHost],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let canceled = false;
|
let canceled = false;
|
||||||
@@ -1445,6 +1466,18 @@ export function AccountCenter() {
|
|||||||
const paymentFeatureReady = Boolean(
|
const paymentFeatureReady = Boolean(
|
||||||
paymentConfig?.enabled && paymentConfig?.configured,
|
paymentConfig?.enabled && paymentConfig?.configured,
|
||||||
);
|
);
|
||||||
|
const paymentReceiverAddress = String(
|
||||||
|
selectedPaymentToken?.receiver_contract ||
|
||||||
|
paymentConfig?.receiver_contract ||
|
||||||
|
"",
|
||||||
|
).toLowerCase();
|
||||||
|
const paymentWalletLabel = String(
|
||||||
|
selectedWallet ||
|
||||||
|
walletAddress ||
|
||||||
|
boundWallets.find((row) => row.is_primary)?.address ||
|
||||||
|
boundWallets[0]?.address ||
|
||||||
|
"",
|
||||||
|
).toLowerCase();
|
||||||
const hasPayingWallet = Boolean(
|
const hasPayingWallet = Boolean(
|
||||||
String(
|
String(
|
||||||
selectedWallet || walletAddress || boundWallets[0]?.address || "",
|
selectedWallet || walletAddress || boundWallets[0]?.address || "",
|
||||||
@@ -1838,6 +1871,15 @@ export function AccountCenter() {
|
|||||||
setPaymentError("");
|
setPaymentError("");
|
||||||
setPaymentInfo("");
|
setPaymentInfo("");
|
||||||
setLastTxHash("");
|
setLastTxHash("");
|
||||||
|
if (!paymentHostAllowed) {
|
||||||
|
setPaymentError(
|
||||||
|
copy.paymentHostBlocked.replace(
|
||||||
|
"{host}",
|
||||||
|
allowedPaymentHosts[0] || "polyweather-pro.vercel.app",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
setPaymentError(copy.loginBeforePay);
|
setPaymentError(copy.loginBeforePay);
|
||||||
return;
|
return;
|
||||||
@@ -1934,7 +1976,11 @@ export function AccountCenter() {
|
|||||||
use_points: billing.canRedeem && usePoints,
|
use_points: billing.canRedeem && usePoints,
|
||||||
points_to_consume:
|
points_to_consume:
|
||||||
billing.canRedeem && usePoints ? billing.pointsUsed : 0,
|
billing.canRedeem && usePoints ? billing.pointsUsed : 0,
|
||||||
metadata: { source: "account_center" },
|
metadata: {
|
||||||
|
source: "account_center",
|
||||||
|
frontend_host: currentPaymentHost || null,
|
||||||
|
account_email: email || null,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!createRes.ok) {
|
if (!createRes.ok) {
|
||||||
@@ -2109,6 +2155,15 @@ export function AccountCenter() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleOverlayCheckout = async () => {
|
const handleOverlayCheckout = async () => {
|
||||||
|
if (!paymentHostAllowed) {
|
||||||
|
setPaymentError(
|
||||||
|
copy.paymentHostBlocked.replace(
|
||||||
|
"{host}",
|
||||||
|
allowedPaymentHosts[0] || "polyweather-pro.vercel.app",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
setPaymentError(copy.loginBeforePay);
|
setPaymentError(copy.loginBeforePay);
|
||||||
return;
|
return;
|
||||||
@@ -2498,6 +2553,40 @@ export function AccountCenter() {
|
|||||||
{paymentInfo}
|
{paymentInfo}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{!paymentHostAllowed ? (
|
||||||
|
<div className="mb-4 rounded-xl border border-amber-400/40 bg-amber-500/10 px-3 py-2 text-[11px] text-amber-200">
|
||||||
|
{copy.paymentHostBlocked.replace(
|
||||||
|
"{host}",
|
||||||
|
allowedPaymentHosts[0] || "polyweather-pro.vercel.app",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="mb-5 space-y-3">
|
||||||
|
<InfoRow
|
||||||
|
icon={Mail}
|
||||||
|
label={copy.paymentAccount}
|
||||||
|
value={email || "--"}
|
||||||
|
isPrimary
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
icon={Wallet}
|
||||||
|
label={copy.paymentWallet}
|
||||||
|
value={shortAddress(paymentWalletLabel) || "--"}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
icon={ShieldCheck}
|
||||||
|
label={copy.paymentReceiver}
|
||||||
|
value={shortAddress(paymentReceiverAddress) || "--"}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
icon={ExternalLink}
|
||||||
|
label={copy.paymentHost}
|
||||||
|
value={currentPaymentHost || "--"}
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-slate-500">
|
||||||
|
{copy.paymentGuardHint}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
{availableTokenList.length > 0 && (
|
{availableTokenList.length > 0 && (
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<p className="text-[11px] uppercase tracking-widest text-slate-500 mb-2">
|
<p className="text-[11px] uppercase tracking-widest text-slate-500 mb-2">
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
const DEFAULT_ALLOWED_PAYMENT_HOSTS = [
|
||||||
|
"polyweather-pro.vercel.app",
|
||||||
|
"localhost",
|
||||||
|
"127.0.0.1",
|
||||||
|
];
|
||||||
|
|
||||||
|
function normalizeHost(raw: string | undefined | null): string {
|
||||||
|
return String(raw || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/^https?:\/\//, "")
|
||||||
|
.replace(/\/.*$/, "")
|
||||||
|
.replace(/:\d+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllowedPaymentHosts(): string[] {
|
||||||
|
const configured = String(
|
||||||
|
process.env.NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS ||
|
||||||
|
process.env.POLYWEATHER_PAYMENT_ALLOWED_HOSTS ||
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.split(",")
|
||||||
|
.map((item) => normalizeHost(item))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (!configured.length) {
|
||||||
|
return DEFAULT_ALLOWED_PAYMENT_HOSTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(new Set([...configured, "localhost", "127.0.0.1"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPaymentHostAllowed(hostname: string | undefined | null): boolean {
|
||||||
|
const normalized = normalizeHost(hostname);
|
||||||
|
if (!normalized) return false;
|
||||||
|
return getAllowedPaymentHosts().includes(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentPaymentHost(): string {
|
||||||
|
if (typeof window === "undefined") return "";
|
||||||
|
return normalizeHost(window.location.hostname || window.location.host);
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user