fix: avoid loopback api base on public web origin
This commit is contained in:
@@ -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 基址解析逻辑。
|
||||
|
||||
@@ -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` 通过。
|
||||
|
||||
@@ -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<PingResponse | null>(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<HTMLFormElement>) => {
|
||||
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() {
|
||||
|
||||
<section className="surface-card">
|
||||
<p className="text-sm text-muted">API Base URL</p>
|
||||
<p className="mt-1 font-mono text-sm">{API_BASE_URL}</p>
|
||||
<p className="mt-1 font-mono text-sm">{resolvedApiBaseUrl}</p>
|
||||
</section>
|
||||
|
||||
{user ? (
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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<WSContextValue | undefined>(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();
|
||||
|
||||
+38
-2
@@ -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<string> {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user