diff --git a/MEMORY.md b/MEMORY.md index aa22ea4..07d9485 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -55,3 +55,8 @@ - 标题:`Space Grotesk` - 正文:`Manrope` - 等宽:`JetBrains Mono` + +## 前端联调口径(2026-04-12) + +- `NEXT_PUBLIC_API_BASE_URL` 若误配为 loopback(`127.0.0.1/localhost`),前端运行时会在浏览器端自动改写为“当前页面主机 + 同端口(默认 8000)”,避免公网页面触发 PNA(Private Network Access)阻断。 +- 认证请求与 WebSocket 连接均统一复用该运行时 API 基址解析逻辑。 diff --git a/memory/2026-04-12.md b/memory/2026-04-12.md index 3169717..f63a19f 100644 --- a/memory/2026-04-12.md +++ b/memory/2026-04-12.md @@ -214,3 +214,20 @@ - 注册显示 `注册` - 验证: - 复跑 `docker compose build web`,构建成功,路由静态生成完成,最终输出 `fquiz-web Built`。 + +## 追加修复(浏览器 PNA 阻断导致登录失败) + +- 触发问题: + - 浏览器报错:从 `http://223.109.142.84:3000` 访问 `http://127.0.0.1:8000` 被拦截,提示 + `The request client is not a secure context and the resource is in more-private address space loopback`。 +- 根因: + - 前端构建时注入 `NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8000`,线上浏览器会把 `127.0.0.1` 解释为“访问者本机回环地址”,触发 PNA 安全策略阻断。 +- 处理: + - 新增前端 API 基址运行时解析(`web/src/lib/api.ts`): + - 浏览器端若发现配置为 loopback(`127.0.0.1/localhost`),且当前页面不在 loopback 主机,则自动改写为当前页面主机 + 原端口(默认 `8000`)。 + - 认证请求统一走运行时 API 基址(`web/src/components/auth-provider.tsx`)。 + - WS 连接统一走运行时 API 基址(`web/src/components/ws-provider.tsx`)。 + - 首页 `Ping` 与 API Base 展示改为运行时解析值(`web/src/app/page.tsx`)。 +- 验证: + - `cd web && npx eslint src/lib/api.ts src/components/auth-provider.tsx src/components/ws-provider.tsx src/app/page.tsx` 通过。 + - `cd web && npx tsc --noEmit` 通过。 diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 9213e93..67a5dbf 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -4,7 +4,7 @@ import Link from "next/link"; import { FormEvent, useEffect, useState } from "react"; import { useAuth } from "@/components/auth-provider"; -import { API_BASE_URL, readApiError } from "@/lib/api"; +import { API_BASE_URL, getApiBaseUrl, readApiError } from "@/lib/api"; type Mode = "login" | "register"; type PingResponse = { message: string }; @@ -27,6 +27,7 @@ export default function Home() { const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [pingResult, setPingResult] = useState(null); + const [resolvedApiBaseUrl, setResolvedApiBaseUrl] = useState(API_BASE_URL); useEffect(() => { try { @@ -47,6 +48,10 @@ export default function Home() { } }, []); + useEffect(() => { + setResolvedApiBaseUrl(getApiBaseUrl()); + }, []); + const handleSubmit = async (event: FormEvent) => { event.preventDefault(); setError(""); @@ -78,7 +83,7 @@ export default function Home() { const handlePing = async () => { setError(""); - const response = await fetch(`${API_BASE_URL}/api/v1/ping`, { + const response = await fetch(`${getApiBaseUrl()}/api/v1/ping`, { method: "GET", credentials: "include", }); @@ -106,7 +111,7 @@ export default function Home() {

API Base URL

-

{API_BASE_URL}

+

{resolvedApiBaseUrl}

{user ? ( diff --git a/web/src/components/auth-provider.tsx b/web/src/components/auth-provider.tsx index 83e7a78..14c1e62 100644 --- a/web/src/components/auth-provider.tsx +++ b/web/src/components/auth-provider.tsx @@ -10,7 +10,7 @@ import { useState, } from "react"; -import { API_BASE_URL, readApiError } from "@/lib/api"; +import { getApiBaseUrl, readApiError } from "@/lib/api"; import type { AuthTokenResponse, UserPublic } from "@/types/auth"; type AuthContextValue = { @@ -36,7 +36,7 @@ function withApiPath(path: string): string { if (path.startsWith("http://") || path.startsWith("https://")) { return path; } - return `${API_BASE_URL}${path}`; + return `${getApiBaseUrl()}${path}`; } export function AuthProvider({ children }: { children: React.ReactNode }) { diff --git a/web/src/components/ws-provider.tsx b/web/src/components/ws-provider.tsx index b27e84e..e08ba76 100644 --- a/web/src/components/ws-provider.tsx +++ b/web/src/components/ws-provider.tsx @@ -12,6 +12,7 @@ import { } from "react"; import { useAuth } from "@/components/auth-provider"; +import { getApiBaseUrl } from "@/lib/api"; import type { WsEventEnvelope, WsServerMessage, WsTicketResponse } from "@/types/ws"; type TopicHandler = (event: WsEventEnvelope) => void; @@ -25,7 +26,7 @@ type WSContextValue = { const WSContext = createContext(undefined); function toWebSocketUrl(path: string): string { - const base = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000"; + const base = getApiBaseUrl(); const url = new URL(path, base); url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; return url.toString(); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 020967b..e2d4dff 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,5 +1,41 @@ -export const API_BASE_URL = - process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://127.0.0.1:8000"; +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]); + +function trimTrailingSlash(value: string): string { + return value.replace(/\/+$/, ""); +} + +function isLoopbackHost(hostname: string): boolean { + return LOOPBACK_HOSTS.has(hostname.toLowerCase()); +} + +export function getApiBaseUrl(): string { + const configured = + process.env.NEXT_PUBLIC_API_BASE_URL?.trim() ?? "http://127.0.0.1:8000"; + + if (typeof window === "undefined") { + return trimTrailingSlash(configured); + } + + let parsed: URL; + try { + parsed = new URL(configured); + } catch { + return trimTrailingSlash(configured); + } + + const browserHost = window.location.hostname; + const shouldRewriteLoopback = + isLoopbackHost(parsed.hostname) && !isLoopbackHost(browserHost); + if (!shouldRewriteLoopback) { + return trimTrailingSlash(configured); + } + + const port = parsed.port || "8000"; + const pathname = parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/+$/, ""); + return `${parsed.protocol}//${browserHost}:${port}${pathname}`; +} + +export const API_BASE_URL = getApiBaseUrl(); export async function readApiError(response: Response): Promise { try {