全局配置更新:OAuth 回调修复、支付安全加固、站点 URL 工具

- 新增 NEXT_PUBLIC_SITE_URL 支持及 site-url.ts 工具模块
- 修复 OAuth 回调域名:import.meta.env 统一读取站点 URL
- 支付 API 路由新增收款地址校验
- 后端支付服务更新
- middleware 清理
- 新增 paymentSecurity 测试
This commit is contained in:
2569718930@qq.com
2026-05-24 18:33:47 +08:00
parent 2be0b71018
commit 20c8395c0b
22 changed files with 259 additions and 88 deletions
+5
View File
@@ -15,6 +15,11 @@ NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
# 必填:生产环境站点 URL(OAuth 回调强制使用此域名)
# 设置后,所有登录回调将始终跳转到此域名,而非当前浏览器地址。
# 生产环境必须设为 https://polyweather-pro.vercel.app
NEXT_PUBLIC_SITE_URL=https://polyweather-pro.vercel.app
# 常用:前端鉴权开关
# true: 启用 Supabase 登录
# false: 关闭登录能力,访客模式
+1 -1
View File
@@ -18,7 +18,7 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=
# 必填:生产环境站点 URL(OAuth 回调强制使用此域名)
# 设置后,所有登录回调将始终跳转到此域名,而非当前浏览器地址。
# 生产环境必须设为 https://polyweather-pro.vercel.app
NEXT_PUBLIC_SITE_URL=
NEXT_PUBLIC_SITE_URL=https://polyweather-pro.vercel.app
# 常用:前端鉴权开关
# true: 启用 Supabase 登录
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendAuthUser,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
@@ -24,6 +25,8 @@ export async function POST(
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth);
if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendAuthUser,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
@@ -24,6 +25,8 @@ export async function POST(
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth);
if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendAuthUser,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
@@ -24,6 +25,8 @@ export async function POST(
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth);
if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendAuthUser,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
@@ -35,6 +36,8 @@ export async function POST(req: NextRequest) {
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth);
if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/payments/intents`, {
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendAuthUser,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
@@ -17,6 +18,8 @@ export async function POST(req: NextRequest) {
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth);
if (authError) return authError;
const res = await fetch(`${API_BASE}/api/payments/reconcile-latest`, {
method: "POST",
headers: auth.headers,
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendAuthUser,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
@@ -20,6 +21,8 @@ export async function POST(req: NextRequest) {
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth);
if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/payments/wallets/challenge`, {
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendAuthUser,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
@@ -44,6 +45,8 @@ export async function DELETE(req: NextRequest) {
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth);
if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/payments/wallets`, {
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendAuthUser,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
@@ -20,6 +21,8 @@ export async function POST(req: NextRequest) {
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth);
if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/payments/wallets/verify`, {
+11 -1
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { createSupabaseRouteClient, hasSupabaseServerEnv } from "@/lib/supabase/server";
import { getConfiguredSiteUrl } from "@/lib/site-url";
function normalizeNextPath(input: string | null) {
const fallback = "/";
@@ -11,6 +12,16 @@ function normalizeNextPath(input: string | null) {
}
export async function GET(request: NextRequest) {
const configuredSiteUrl = getConfiguredSiteUrl();
if (configuredSiteUrl) {
const canonicalOrigin = new URL(configuredSiteUrl).origin;
if (request.nextUrl.origin !== canonicalOrigin) {
const canonicalCallbackUrl = new URL(request.nextUrl.pathname, canonicalOrigin);
canonicalCallbackUrl.search = request.nextUrl.search;
return NextResponse.redirect(canonicalCallbackUrl);
}
}
const nextPath = normalizeNextPath(request.nextUrl.searchParams.get("next"));
const redirectUrl = request.nextUrl.clone();
redirectUrl.pathname = nextPath;
@@ -29,4 +40,3 @@ export async function GET(request: NextRequest) {
return response;
}
+19 -8
View File
@@ -44,6 +44,10 @@ import {
getCurrentPaymentHost,
isPaymentHostAllowed,
} from "@/lib/payment-host";
import {
assertExpectedPaymentReceiver,
EXPECTED_PAYMENT_RECEIVER_ADDRESS,
} from "@/lib/payment-receiver";
import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics";
import { useI18n } from "@/hooks/useI18n";
import { UnlockProOverlay } from "@/components/subscription/UnlockProOverlay";
@@ -1062,11 +1066,7 @@ export function AccountCenter() {
(resolvedSelectedTokenAddress.startsWith("0x")
? shortAddress(resolvedSelectedTokenAddress)
: "USDC");
const paymentReceiverAddress = String(
selectedPaymentToken?.receiver_contract ||
paymentConfig?.receiver_contract ||
"",
).toLowerCase();
const paymentReceiverAddress = EXPECTED_PAYMENT_RECEIVER_ADDRESS;
const paymentWalletLabel = String(
selectedWallet ||
walletAddress ||
@@ -1348,6 +1348,15 @@ export function AccountCenter() {
options: ConnectBindOptions = {},
): Promise<boolean> => {
clearPaymentMessages();
if (!paymentHostAllowed) {
setPaymentError(
copy.paymentHostBlocked.replace(
"{host}",
allowedPaymentHosts[0] || "polyweather-pro.vercel.app",
),
);
return false;
}
if (!isAuthenticated) {
setPaymentError(copy.loginBeforeBind);
return false;
@@ -1621,9 +1630,7 @@ export function AccountCenter() {
const expectedReceiver = String(
latestConfig.receiver_contract || "",
).toLowerCase();
if (!expectedReceiver.startsWith("0x")) {
throw new Error("payment receiver contract is not configured");
}
assertExpectedPaymentReceiver(expectedReceiver, "payment receiver contract");
if (
paymentConfig?.receiver_contract &&
String(paymentConfig.receiver_contract).toLowerCase() !==
@@ -1920,6 +1927,10 @@ export function AccountCenter() {
if (!intentId || !direct?.receiver_address || !direct?.amount_usdc) {
throw new Error("manual payment payload invalid");
}
assertExpectedPaymentReceiver(
direct.receiver_address,
"manual payment receiver",
);
setLastIntentId(intentId);
setManualPayment(direct);
setPaymentMethodTab("manual");
@@ -0,0 +1,76 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
const EXPECTED_RECEIVER = "0x351a1bca5f49dd0046a7cf0bafa7e12fa6441c3a";
export function runTests() {
const projectRoot = process.cwd();
const receiverModulePath = path.join(projectRoot, "lib", "payment-receiver.ts");
const backendAuthPath = path.join(projectRoot, "lib", "backend-auth.ts");
const middlewarePath = path.join(projectRoot, "middleware.ts");
const accountCenterPath = path.join(
projectRoot,
"components",
"account",
"AccountCenter.tsx",
);
const paymentRoutes = [
"app/api/payments/wallets/challenge/route.ts",
"app/api/payments/wallets/verify/route.ts",
"app/api/payments/wallets/route.ts",
"app/api/payments/intents/route.ts",
"app/api/payments/intents/[intentId]/submit/route.ts",
"app/api/payments/intents/[intentId]/confirm/route.ts",
"app/api/payments/intents/[intentId]/validate/route.ts",
"app/api/payments/reconcile-latest/route.ts",
];
assert(
fs.existsSync(receiverModulePath),
"payment receiver guard module must exist",
);
const receiverSource = fs.readFileSync(receiverModulePath, "utf8");
assert(
receiverSource.includes(EXPECTED_RECEIVER),
"payment receiver guard must pin the production receiver address",
);
assert(
receiverSource.includes("assertExpectedPaymentReceiver"),
"payment receiver guard must expose an assertion helper",
);
const accountCenterSource = fs.readFileSync(accountCenterPath, "utf8");
assert(
accountCenterSource.includes("assertExpectedPaymentReceiver"),
"AccountCenter must validate backend-returned manual payment receiver before displaying it",
);
assert(
accountCenterSource.includes("EXPECTED_PAYMENT_RECEIVER_ADDRESS"),
"AccountCenter must show the pinned payment receiver address in its payment guard",
);
const backendAuthSource = fs.readFileSync(backendAuthPath, "utf8");
assert(
backendAuthSource.includes("requireBackendAuthUser"),
"backend auth helper must expose a real-user requirement for payment mutations",
);
const middlewareSource = fs.readFileSync(middlewarePath, "utf8");
assert(
!middlewareSource.includes("/^bearer\\s+\\S+/i.test(authHeader)") &&
!middlewareSource.includes("return NextResponse.next();\n }\n }\n\n const response = NextResponse.next"),
"middleware must not treat the mere presence of a bearer token as authenticated",
);
for (const route of paymentRoutes) {
const routeSource = fs.readFileSync(path.join(projectRoot, route), "utf8");
assert(
routeSource.includes("requireBackendAuthUser"),
`${route} must reject payment mutations without a real Supabase user`,
);
}
}
+8 -3
View File
@@ -17,6 +17,7 @@ import {
getSupabaseBrowserClient,
hasSupabasePublicEnv,
} from "@/lib/supabase/client";
import { getConfiguredSiteUrl, PRODUCTION_SITE_URL } from "@/lib/site-url";
import { useI18n } from "@/hooks/useI18n";
type Mode = "login" | "signup";
@@ -38,8 +39,8 @@ export function LoginClient({ nextPath }: LoginClientProps) {
const supabaseReady = hasSupabasePublicEnv();
const siteOrigin =
process.env.NEXT_PUBLIC_SITE_URL?.trim() ||
(typeof window !== "undefined" ? window.location.origin : "");
getConfiguredSiteUrl() ||
(typeof window !== "undefined" ? window.location.origin : PRODUCTION_SITE_URL);
const isEn = locale === "en-US";
const copy = {
backHome: isEn ? "Back to Home" : "返回首页",
@@ -102,7 +103,11 @@ export function LoginClient({ nextPath }: LoginClientProps) {
setLoading(true);
try {
const supabase = getSupabaseBrowserClient();
const { error } = await supabase.auth.resetPasswordForEmail(email.trim());
const { error } = await supabase.auth.resetPasswordForEmail(email.trim(), {
redirectTo: `${siteOrigin}/auth/callback?next=${encodeURIComponent(
"/account",
)}`,
});
if (error) {
setErrorText(error.message);
return;
+10 -1
View File
@@ -94,4 +94,13 @@ export function applyAuthResponseCookies(
return target;
}
export function requireBackendAuthUser(auth: HeaderBuildResult) {
if (auth.authUserId) return null;
return applyAuthResponseCookies(
NextResponse.json(
{ error: "Authentication required", detail: "Supabase user required" },
{ status: 401 },
),
auth.response,
);
}
+19
View File
@@ -0,0 +1,19 @@
export const EXPECTED_PAYMENT_RECEIVER_ADDRESS =
"0x351a1bca5f49dd0046a7cf0bafa7e12fa6441c3a";
export function normalizePaymentReceiver(address: string | null | undefined) {
return String(address || "").trim().toLowerCase();
}
export function assertExpectedPaymentReceiver(
address: string | null | undefined,
label = "payment receiver",
) {
const normalized = normalizePaymentReceiver(address);
if (normalized !== EXPECTED_PAYMENT_RECEIVER_ADDRESS) {
throw new Error(
`${label} mismatch: expected ${EXPECTED_PAYMENT_RECEIVER_ADDRESS}, got ${normalized || "empty"}`,
);
}
return normalized;
}
+7
View File
@@ -0,0 +1,7 @@
export const PRODUCTION_SITE_URL = "https://polyweather-pro.vercel.app";
export function getConfiguredSiteUrl() {
const configured = process.env.NEXT_PUBLIC_SITE_URL?.trim();
if (configured) return configured;
return process.env.NODE_ENV === "production" ? PRODUCTION_SITE_URL : "";
}
-6
View File
@@ -106,12 +106,6 @@ async function handleSupabaseAuthGate(request: NextRequest) {
if (isPublicPage(pathname) || isPublicApi(pathname)) {
return NextResponse.next();
}
if (pathname.startsWith("/api/")) {
const authHeader = String(request.headers.get("authorization") || "").trim();
if (/^bearer\s+\S+/i.test(authHeader)) {
return NextResponse.next();
}
}
const response = NextResponse.next({
request: {
-1
View File
@@ -206,7 +206,6 @@ class BotIOLayer:
"🔗 机器人: <a href=\"https://t.me/polyyuanbot\">@polyyuanbot</a>\n"
"👥 社群: <a href=\"https://t.me/+Io5H9oVHFmVjOTQ5\">加入 Telegram 群组</a>\n\n"
"📌 <i>私有频道用于接收自动推送;手动查看市场概览请私聊机器人发送 <code>/markets</code>。</i>\n\n"
"🔐 <i>/city 与 /deb 仅限官方群成员使用。</i>\n\n"
"示例: <code>/city 伦敦</code> 或 <code>/pwcity 伦敦</code>\n"
f"💡 <i>提示: 群内有效发言(满 {MESSAGE_MIN_LENGTH} 字)获得 <b>{MESSAGE_POINTS}</b> 积分,"
f"每日上限 {MESSAGE_DAILY_CAP} 分。"
+3 -3
View File
@@ -81,8 +81,8 @@ def start_bot() -> None:
startup_coordinator = StartupCoordinator(
bot=bot,
config=config,
command_access_mode="group_member_only",
protected_commands=["/city", "/deb"],
command_access_mode="public",
protected_commands=[],
required_group_chat_id=",".join(get_telegram_chat_ids_from_env()),
)
@@ -99,7 +99,7 @@ def start_bot() -> None:
started_count = sum(1 for loop in runtime_status.loops if loop.started)
logger.info(
"🤖 Bot 启动中... access=group-member-only protected_commands=/city,/deb loops_started={}/{}",
"🤖 Bot 启动中... access=public protected_commands=none loops_started={}/{}",
started_count,
len(runtime_status.loops),
)
+71 -63
View File
@@ -14,29 +14,23 @@ from web.services.city_api import (
router = APIRouter(tags=["city"])
_MODEL_RANGE_CITIES: List[str] = [
"beijing",
"shanghai",
"guangzhou",
"chengdu",
"chongqing",
"qingdao",
"wuhan",
"seoul",
"busan",
]
def _all_city_keys() -> List[str]:
from src.data_collection.city_registry import CITY_REGISTRY
_MODEL_RANGE_NAMES: Dict[str, str] = {
"beijing": "北京 (ZBAA)",
"shanghai": "上海 (ZSPD)",
"guangzhou": "广州 (ZGGG)",
"chengdu": "成都 (ZUUU)",
"chongqing": "重庆 (ZUCK)",
"qingdao": "青岛 (ZSQD)",
"wuhan": "武汉 (ZHHH)",
"seoul": "首尔 (RKSI)",
"busan": "釜山 (RKPK)",
}
return sorted(CITY_REGISTRY.keys())
def _city_display_name(city: str) -> str:
from src.data_collection.city_registry import CITY_REGISTRY
meta = CITY_REGISTRY.get(city) or {}
icao = str(meta.get("icao") or "").strip()
display = str(meta.get("display_name") or city).strip()
return f"{display} ({icao})" if icao else display
_MODEL_RANGE_CITIES: List[str] = _all_city_keys()
_MODEL_RANGE_NAMES: Dict[str, str] = {c: _city_display_name(c) for c in _MODEL_RANGE_CITIES}
@router.get("/api/cities")
@@ -44,55 +38,69 @@ async def list_cities(request: Request):
return await list_cities_payload(request)
def _extract_city_model_range(city: str, _force_refresh: bool) -> Optional[Dict[str, Any]]:
"""Extract cached model range data without triggering fresh analysis."""
from web.analysis_service import _cache, _analysis_cache_key
for detail_mode in ("full", "panel", "nearby", "market"):
cache_key = _analysis_cache_key(city, detail_mode)
cached = _cache.get(cache_key)
if cached and isinstance(cached.get("d"), dict):
result = cached["d"]
if isinstance(result.get("multi_model"), dict) and result["multi_model"]:
break
else:
return None
if not isinstance(result, dict):
return None
deb = result.get("deb") if isinstance(result, dict) else None
deb_pred = deb.get("prediction") if isinstance(deb, dict) else None
models = result.get("multi_model") if isinstance(result, dict) else {}
model_min: Optional[float] = None
model_max: Optional[float] = None
spread: Optional[float] = None
spread_label: str = ""
if isinstance(models, dict):
vals = sorted([v for v in models.values() if isinstance(v, (int, float))])
if len(vals) >= 2:
model_min = vals[0]
model_max = vals[-1]
spread = model_max - model_min
if spread <= 2.0:
spread_label = "低分歧"
elif spread <= 4.0:
spread_label = "中等分歧"
else:
spread_label = "高分歧"
return {
"id": city,
"name": _MODEL_RANGE_NAMES.get(city, city),
"deb": round(deb_pred, 1) if deb_pred is not None else None,
"model_min": round(model_min, 1) if model_min is not None else None,
"model_max": round(model_max, 1) if model_max is not None else None,
"spread": round(spread, 1) if spread is not None else None,
"spread_label": spread_label,
}
@router.get("/api/cities/model-range")
async def cities_model_range(
request: Request,
force_refresh: bool = Query(False),
):
"""Return DEB prediction and model range for monitored cities (CN + KR)."""
from web.app import _analyze
"""Return DEB prediction and model range for all monitored cities."""
rows: List[Dict[str, Any]] = []
for city in _MODEL_RANGE_CITIES:
try:
result = _analyze(city, force_refresh=force_refresh)
except Exception:
continue
deb = result.get("deb") if isinstance(result, dict) else None
deb_pred = deb.get("prediction") if isinstance(deb, dict) else None
models = result.get("multi_model") if isinstance(result, dict) else {}
model_min: Optional[float] = None
model_max: Optional[float] = None
spread: Optional[float] = None
spread_label: str = ""
if isinstance(models, dict):
vals = sorted([v for v in models.values() if isinstance(v, (int, float))])
if len(vals) >= 2:
model_min = vals[0]
model_max = vals[-1]
spread = model_max - model_min
if spread <= 2.0:
spread_label = "低分歧"
elif spread <= 4.0:
spread_label = "中等分歧"
else:
spread_label = "高分歧"
rows.append(
{
"id": city,
"name": _MODEL_RANGE_NAMES.get(city, city),
"deb": round(deb_pred, 1) if deb_pred is not None else None,
"model_min": round(model_min, 1) if model_min is not None else None,
"model_max": round(model_max, 1) if model_max is not None else None,
"spread": round(spread, 1) if spread is not None else None,
"spread_label": spread_label,
}
)
row = _extract_city_model_range(city, force_refresh)
if row is not None:
rows.append(row)
rows.sort(key=lambda r: str(r.get("id") or ""))
return {"cities": rows}
+5 -1
View File
@@ -28,7 +28,11 @@ def _raise_payment_error(exc: Exception) -> None:
def _require_payment_identity(request: Request) -> Dict[str, Any]:
legacy_routes._assert_entitlement(request)
return legacy_routes._require_supabase_identity(request)
identity = legacy_routes._require_supabase_identity(request)
user_id = str(identity.get("user_id") or "").strip()
if not user_id or user_id == "entitlement" or user_id.startswith("admin:"):
raise HTTPException(status_code=401, detail="Supabase user required")
return identity
def get_payment_config(request: Request) -> Dict[str, Any]: