From a94e6ecaf96cecd97905346116b5cab5c9a90605 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Tue, 16 Jun 2026 03:49:49 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4=20Turnstile=20=E9=9B=86?= =?UTF-8?q?=E6=88=90=E6=BA=90=E6=96=87=E4=BB=B6=E6=94=B9=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/api/feedback/route.ts | 8 +++- .../intents/[intentId]/submit/route.ts | 10 ++++- frontend/app/api/payments/intents/route.ts | 8 +++- frontend/components/account/AccountCenter.tsx | 26 +++++++++++++ .../components/account/useAccountPayment.ts | 9 +++++ frontend/components/account/usePaymentFlow.ts | 39 ++++++++++++++++++- frontend/components/auth/LoginClient.tsx | 30 +++++++++++++- .../scan-terminal/UserFeedbackModal.tsx | 24 +++++++++++- 8 files changed, 147 insertions(+), 7 deletions(-) diff --git a/frontend/app/api/feedback/route.ts b/frontend/app/api/feedback/route.ts index b86a40ba..3ae47f14 100644 --- a/frontend/app/api/feedback/route.ts +++ b/frontend/app/api/feedback/route.ts @@ -7,6 +7,10 @@ import { buildProxyExceptionResponse, buildUpstreamErrorResponse, } from "@/lib/api-proxy"; +import { + requireTurnstileForRequest, + stripTurnstileToken, +} from "@/lib/turnstile"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -89,6 +93,8 @@ export async function POST(req: NextRequest) { let auth: Awaited> | null = null; try { const body = await req.json(); + const turnstileError = await requireTurnstileForRequest(req, "feedback_submit", body); + if (turnstileError) return turnstileError; auth = await buildBackendRequestHeaders(req); const headers = new Headers(auth.headers); headers.set("Content-Type", "application/json"); @@ -96,7 +102,7 @@ export async function POST(req: NextRequest) { const res = await fetch(`${API_BASE}/api/feedback`, { method: "POST", headers, - body: JSON.stringify(body), + body: JSON.stringify(stripTurnstileToken(body)), cache: "no-store", }); const raw = await res.text(); diff --git a/frontend/app/api/payments/intents/[intentId]/submit/route.ts b/frontend/app/api/payments/intents/[intentId]/submit/route.ts index c3092db0..c2bfa128 100644 --- a/frontend/app/api/payments/intents/[intentId]/submit/route.ts +++ b/frontend/app/api/payments/intents/[intentId]/submit/route.ts @@ -8,6 +8,10 @@ import { buildProxyExceptionResponse, buildUpstreamErrorResponse, } from "@/lib/api-proxy"; +import { + requireTurnstileForRequest, + stripTurnstileToken, +} from "@/lib/turnstile"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -53,6 +57,10 @@ export async function POST( const { intentId } = await context.params; try { const body = await req.json(); + if (process.env.POLYWEATHER_TURNSTILE_REQUIRE_PAYMENT_SUBMIT === "true") { + const turnstileError = await requireTurnstileForRequest(req, "payment_tx_submit", body); + if (turnstileError) return turnstileError; + } const auth = await buildBackendRequestHeaders(req); const authError = requireBackendPaymentAuth(auth); if (authError) return authError; @@ -63,7 +71,7 @@ export async function POST( { method: "POST", headers: proxiedHeaders, - body: JSON.stringify(body ?? {}), + body: JSON.stringify(stripTurnstileToken(body ?? {})), cache: "no-store", }, ); diff --git a/frontend/app/api/payments/intents/route.ts b/frontend/app/api/payments/intents/route.ts index 5a7d955d..076f8983 100644 --- a/frontend/app/api/payments/intents/route.ts +++ b/frontend/app/api/payments/intents/route.ts @@ -9,6 +9,10 @@ import { buildUpstreamErrorResponse, } from "@/lib/api-proxy"; import { isPaymentHostAllowed } from "@/lib/payment-host"; +import { + requireTurnstileForRequest, + stripTurnstileToken, +} from "@/lib/turnstile"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -35,6 +39,8 @@ export async function POST(req: NextRequest) { } try { const body = await req.json(); + const turnstileError = await requireTurnstileForRequest(req, "payment_intent_create", body); + if (turnstileError) return turnstileError; const auth = await buildBackendRequestHeaders(req); const authError = requireBackendPaymentAuth(auth); if (authError) return authError; @@ -43,7 +49,7 @@ export async function POST(req: NextRequest) { const res = await fetch(`${API_BASE}/api/payments/intents`, { method: "POST", headers: proxiedHeaders, - body: JSON.stringify(body ?? {}), + body: JSON.stringify(stripTurnstileToken(body ?? {})), cache: "no-store", }); if (!res.ok) { diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx index fe0c7729..11351885 100644 --- a/frontend/components/account/AccountCenter.tsx +++ b/frontend/components/account/AccountCenter.tsx @@ -51,6 +51,7 @@ import { } from "./constants"; import { InfoRow, PlusIcon } from "./AccountInfoRow"; import { AccountFeedbackPanel } from "./AccountFeedbackPanel"; +import { TurnstileWidget } from "@/components/security/TurnstileWidget"; import { chainIdToDisplayName, clearStoredPaymentRecovery, @@ -65,6 +66,7 @@ import { buildTrialValueReplaySummary, readTrialValueReplay, } from "@/lib/trial-value-replay"; +import { getTurnstileTokenForAction } from "@/lib/turnstile-client"; // --- Main Component --- @@ -90,9 +92,25 @@ export function AccountCenter() { const [backend, setBackend] = useState(null); const [referralCodeInput, setReferralCodeInput] = useState(""); const [referralApplying, setReferralApplying] = useState(false); + const [paymentTurnstileToken, setPaymentTurnstileToken] = useState(""); + const [paymentTurnstileResetKey, setPaymentTurnstileResetKey] = useState(0); const supabaseReady = hasSupabasePublicEnv(); const walletConnectEnabled = Boolean(WALLETCONNECT_PROJECT_ID); + const resetPaymentTurnstile = useCallback(() => { + setPaymentTurnstileToken(""); + setPaymentTurnstileResetKey((value) => value + 1); + }, []); + const getPaymentTurnstileToken = useCallback( + ( + action: "payment_intent_create" | "payment_tx_submit", + options?: { optional?: boolean }, + ) => { + if (options?.optional && !paymentTurnstileToken) return undefined; + return getTurnstileTokenForAction(paymentTurnstileToken, action); + }, + [paymentTurnstileToken], + ); // ── Hook ──────────────────────────────────────────────── const { @@ -189,6 +207,8 @@ export function AccountCenter() { setUpdatedAt, usePoints, setUsePoints, + getPaymentTurnstileToken, + resetPaymentTurnstile, }); // ── Auth analytics effect ────────────────────────────── @@ -1370,6 +1390,12 @@ export function AccountCenter() { )} + + {/* Payment Method Tabs */}

diff --git a/frontend/components/account/useAccountPayment.ts b/frontend/components/account/useAccountPayment.ts index 7f756bab..76f7bee1 100644 --- a/frontend/components/account/useAccountPayment.ts +++ b/frontend/components/account/useAccountPayment.ts @@ -56,6 +56,11 @@ export interface UseAccountPaymentParams { setUpdatedAt: (text: string) => void; usePoints: boolean; setUsePoints: (v: boolean) => void; + getPaymentTurnstileToken?: ( + action: "payment_intent_create" | "payment_tx_submit", + options?: { optional?: boolean }, + ) => string | undefined; + resetPaymentTurnstile?: () => void; } // ============================================================ @@ -73,6 +78,8 @@ export function useAccountPayment(params: UseAccountPaymentParams) { setUpdatedAt, usePoints, setUsePoints, + getPaymentTurnstileToken, + resetPaymentTurnstile, } = params; // ── Base payment state (from usePaymentState) ────────────── @@ -532,6 +539,8 @@ export function useAccountPayment(params: UseAccountPaymentParams) { allowedPaymentHosts: billing.allowedPaymentHosts, authIsAuthenticated, hasPayingWallet: walletBind.hasPayingWallet, + getPaymentTurnstileToken, + resetPaymentTurnstile, getValidAccessToken, buildAuthedHeaders, loadSnapshot, diff --git a/frontend/components/account/usePaymentFlow.ts b/frontend/components/account/usePaymentFlow.ts index 6db2464a..27f64c38 100644 --- a/frontend/components/account/usePaymentFlow.ts +++ b/frontend/components/account/usePaymentFlow.ts @@ -32,9 +32,12 @@ import { assertExpectedPaymentReceiver, EXPECTED_PAYMENT_RECEIVER_ADDRESS, } from "@/lib/payment-receiver"; +import { getTurnstileTokenForAction } from "@/lib/turnstile-client"; import type { PaymentTxValidationState } from "./usePaymentState"; // ============================================================ +type PaymentTurnstileAction = "payment_intent_create" | "payment_tx_submit"; + export interface UsePaymentFlowParams { isEn: boolean; copy: Record; @@ -97,6 +100,11 @@ export interface UsePaymentFlowParams { allowedPaymentHosts: string[]; authIsAuthenticated: boolean; hasPayingWallet: boolean; + getPaymentTurnstileToken?: ( + action: PaymentTurnstileAction, + options?: { optional?: boolean }, + ) => string | undefined; + resetPaymentTurnstile?: () => void; // Callbacks from master getValidAccessToken: () => Promise; @@ -159,6 +167,8 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { allowedPaymentHosts, authIsAuthenticated, hasPayingWallet, + getPaymentTurnstileToken, + resetPaymentTurnstile, getValidAccessToken, buildAuthedHeaders, loadSnapshot, @@ -184,6 +194,16 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { trackAppEvent("payment_success", payload); }, []); + const buildPaymentTurnstilePayload = useCallback( + (action: PaymentTurnstileAction, optional = false) => { + const turnstile_token = + getPaymentTurnstileToken?.(action, { optional }) || + (optional ? undefined : getTurnstileTokenForAction("", action)); + return turnstile_token ? { turnstile_token } : {}; + }, + [getPaymentTurnstileToken], + ); + const verifyPaymentAuthReady = useCallback(async () => { const accessToken = await getValidAccessToken(); const authHeaders: Record = { @@ -499,6 +519,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { method: "POST", headers: authHeaders, body: JSON.stringify({ + ...buildPaymentTurnstilePayload("payment_intent_create"), plan_code: selectedPlan?.plan_code || "pro_monthly", payment_mode: "strict", allowed_wallet: payingWallet, @@ -514,6 +535,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { }, }), }); + resetPaymentTurnstile?.(); if (!createRes.ok) { const raw = await readPaymentApiErrorMessage( createRes, @@ -596,7 +618,13 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { await waitForReceipt(txHashNorm, eth); const submitRes = await fetch(`/api/payments/intents/${intentId}/submit`, { - method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm, from_address: payingWallet }), + method: "POST", + headers: authHeaders, + body: JSON.stringify({ + ...buildPaymentTurnstilePayload("payment_tx_submit", true), + tx_hash: txHashNorm, + from_address: payingWallet, + }), }); if (!submitRes.ok) { const raw = await readPaymentApiErrorMessage( @@ -692,6 +720,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { method: "POST", headers: authHeaders, body: JSON.stringify({ + ...buildPaymentTurnstilePayload("payment_intent_create"), plan_code: selectedPlan?.plan_code || "pro_monthly", payment_mode: "direct", chain_id: effectivePaymentChainId, @@ -705,6 +734,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { }, }), }); + resetPaymentTurnstile?.(); if (!createRes.ok) { const raw = await readPaymentApiErrorMessage( createRes, @@ -765,7 +795,12 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { try { const authHeaders = await buildAuthedHeaders(true, true); const submitRes = await fetch(`/api/payments/intents/${intentIdVal}/submit`, { - method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }), + method: "POST", + headers: authHeaders, + body: JSON.stringify({ + ...buildPaymentTurnstilePayload("payment_tx_submit", true), + tx_hash: txHashNorm, + }), }); if (!submitRes.ok) { const raw = await readPaymentApiErrorMessage( diff --git a/frontend/components/auth/LoginClient.tsx b/frontend/components/auth/LoginClient.tsx index ba5a10c7..c031586d 100644 --- a/frontend/components/auth/LoginClient.tsx +++ b/frontend/components/auth/LoginClient.tsx @@ -1,6 +1,6 @@ "use client"; -import { FormEvent, useEffect, useState } from "react"; +import { FormEvent, useCallback, useEffect, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { @@ -18,6 +18,8 @@ import { } from "@/lib/supabase/client"; import { getConfiguredSiteUrl, PRODUCTION_SITE_URL } from "@/lib/site-url"; import { useI18n } from "@/hooks/useI18n"; +import { TurnstileWidget } from "@/components/security/TurnstileWidget"; +import { getTurnstileTokenForAction } from "@/lib/turnstile-client"; type Mode = "login" | "signup"; @@ -72,6 +74,8 @@ export function LoginClient({ nextPath, initialError, initialMode }: LoginClient const [infoText, setInfoText] = useState(""); const [resetSent, setResetSent] = useState(false); const [showPassword, setShowPassword] = useState(false); + const [turnstileToken, setTurnstileToken] = useState(""); + const [turnstileResetKey, setTurnstileResetKey] = useState(0); const supabaseReady = hasSupabasePublicEnv(); const isLogin = mode === "login"; @@ -173,6 +177,11 @@ export function LoginClient({ nextPath, initialError, initialMode }: LoginClient setErrorText(initialError || ""); }, [initialError]); + const resetTurnstile = useCallback(() => { + setTurnstileToken(""); + setTurnstileResetKey((value) => value + 1); + }, []); + const onResetPassword = async () => { setErrorText(""); setInfoText(""); @@ -247,13 +256,19 @@ export function LoginClient({ nextPath, initialError, initialMode }: LoginClient setLoading(true); try { const supabase = getSupabaseBrowserClient(); + const captchaToken = getTurnstileTokenForAction( + turnstileToken, + isLogin ? "login" : "signup", + ); if (mode === "login") { const { data, error } = await supabase.auth.signInWithPassword({ email: email.trim(), password, + options: captchaToken ? { captchaToken } : undefined, }); if (error) { setErrorText(error.message); + resetTurnstile(); return; } const redirectPath = await resolvePostLoginRedirect({ @@ -272,10 +287,12 @@ export function LoginClient({ nextPath, initialError, initialMode }: LoginClient password, options: { emailRedirectTo, + ...(captchaToken ? { captchaToken } : {}), }, }); if (error) { setErrorText(error.message); + resetTurnstile(); return; } if (data.session?.user) { @@ -287,6 +304,10 @@ export function LoginClient({ nextPath, initialError, initialMode }: LoginClient return; } setInfoText(copy.signupCheckEmail); + resetTurnstile(); + } catch (error) { + setErrorText(error instanceof Error ? error.message : String(error)); + resetTurnstile(); } finally { setLoading(false); } @@ -407,6 +428,7 @@ export function LoginClient({ nextPath, initialError, initialMode }: LoginClient setErrorText(""); setInfoText(""); setMode(isLogin ? "signup" : "login"); + resetTurnstile(); }} className="whitespace-nowrap rounded-xl border border-slate-300 bg-white px-4 py-2 text-xs font-bold text-slate-700 shadow-sm transition hover:border-blue-300 hover:text-blue-700 active:scale-[0.98]" > @@ -495,6 +517,12 @@ export function LoginClient({ nextPath, initialError, initialMode }: LoginClient

+ + {!isLogin ? (

{copy.termsAgreement} diff --git a/frontend/components/dashboard/scan-terminal/UserFeedbackModal.tsx b/frontend/components/dashboard/scan-terminal/UserFeedbackModal.tsx index 7e33eaf9..78687e89 100644 --- a/frontend/components/dashboard/scan-terminal/UserFeedbackModal.tsx +++ b/frontend/components/dashboard/scan-terminal/UserFeedbackModal.tsx @@ -11,6 +11,8 @@ import { getSupabaseBrowserClient, hasSupabasePublicEnv, } from "@/lib/supabase/client"; +import { TurnstileWidget } from "@/components/security/TurnstileWidget"; +import { getTurnstileTokenForAction, isTurnstileEnabled } from "@/lib/turnstile-client"; import type { UserFeedbackEntry } from "@/types/ops"; export type FeedbackCategory = "bug" | "data" | "idea" | "payment" | "account" | "other"; @@ -111,6 +113,8 @@ export function UserFeedbackModal({ const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(""); const [submitted, setSubmitted] = useState(false); + const [turnstileToken, setTurnstileToken] = useState(""); + const [turnstileResetKey, setTurnstileResetKey] = useState(0); useEffect(() => { if (!open) return; @@ -120,6 +124,8 @@ export function UserFeedbackModal({ setLoginEmailContact(""); setError(""); setSubmitted(false); + setTurnstileToken(""); + setTurnstileResetKey((value) => value + 1); }, [open, draft?.category]); useEffect(() => { @@ -158,13 +164,20 @@ export function UserFeedbackModal({ if (!open) return null; - const canSubmit = message.trim().length >= 3 && !submitting; + const canSubmit = + message.trim().length >= 3 && + !submitting && + (!isTurnstileEnabled() || Boolean(turnstileToken)); const submit = async () => { if (!canSubmit) return; setSubmitting(true); setError(""); try { + const turnstile_token = getTurnstileTokenForAction( + turnstileToken, + "feedback_submit", + ); const res = await fetch("/api/feedback", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -174,6 +187,7 @@ export function UserFeedbackModal({ contact: contact.trim() || undefined, source: draft?.source || "terminal", context: buildRuntimeContext(draft?.context || {}), + turnstile_token, }), }); if (!res.ok) { @@ -188,6 +202,8 @@ export function UserFeedbackModal({ } catch (err) { const message = err instanceof Error ? err.message : String(err); setError(message.slice(0, 220)); + setTurnstileToken(""); + setTurnstileResetKey((value) => value + 1); } finally { setSubmitting(false); } @@ -296,6 +312,12 @@ export function UserFeedbackModal({ : "会自动附带终端上下文:城市、槽位、数据源状态、浏览器和会话诊断信息。"} + + {error && (

{isEn ? "Submit failed: " : "提交失败:"}{error}