diff --git a/.env.example b/.env.example index 7095084..9f257aa 100644 --- a/.env.example +++ b/.env.example @@ -4,9 +4,11 @@ POSTGRES_PASSWORD=pokopia DATABASE_URL=postgres://pokopia:pokopia@localhost:5432/pokopia BACKEND_PORT=3001 TRUST_PROXY=false +# The default localhost frontend origin also allows same-protocol, same-port private LAN aliases in development. FRONTEND_ORIGIN=http://localhost:20015 APP_ORIGIN=http://localhost:20015 BACKEND_PUBLIC_ORIGIN=http://localhost:20016 +# Browser requests rewrite localhost/loopback API hosts to the current page host for LAN access. NUXT_PUBLIC_API_BASE_URL=http://localhost:20016 NUXT_SERVER_API_BASE_URL=http://localhost:3001 NUXT_PUBLIC_SITE_URL=https://pokopiawiki.tootaio.com @@ -24,6 +26,10 @@ AI_MODERATION_API_KEY= # NUXT_PUBLIC_API_BASE_URL=http://localhost:20016 # NUXT_SERVER_API_BASE_URL=http://backend:3001 # NUXT_PUBLIC_SITE_URL=http://localhost:20015 +# +# Optional LAN access example from another device: +# FRONTEND_ORIGIN=http://localhost:20015,http://10.147.20.4:20015 +# NUXT_PUBLIC_API_BASE_URL=http://10.147.20.4:20016 # Cloudflared tunnel deployment example: # FRONTEND_ORIGIN=https://pokopiawiki.tootaio.com,http://localhost:20015 diff --git a/DESIGN.md b/DESIGN.md index 8888642..f7d083c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1208,6 +1208,8 @@ API 暴露边界: - Docker 部署时公开前端端口由 `frontend_gateway` 承载,正常流量代理到 `frontend` 服务。 - 前端浏览器 API base URL 由 `NUXT_PUBLIC_API_BASE_URL` 提供。 +- 当前端浏览器 API base URL 配置为 `localhost`、`127.0.0.1` 或 `[::1]`,但用户通过局域网 IP 或其他非 loopback 主机访问前端时,浏览器端 API 请求会自动使用当前页面主机名并保留配置的协议和端口;该兼容行为只影响浏览器端请求,不改变 Nuxt 服务端 API base URL。 +- 后端 CORS origin 由 `FRONTEND_ORIGIN` 提供;当 `FRONTEND_ORIGIN` 包含本地 loopback 前端地址时,后端允许同协议、同端口的 loopback、私有网段 IP 和常见 Docker 开发主机名作为本地开发别名,并对通过校验的浏览器 origin 回显对应 `Access-Control-Allow-Origin`。 - Nuxt 服务端 API base URL 由 `NUXT_SERVER_API_BASE_URL` 提供;在 Docker 内默认使用 `http://backend:3001`,本地非 Docker 运行可使用 `http://localhost:3001`。服务端公开数据读取使用该内部地址,浏览器请求继续使用 `NUXT_PUBLIC_API_BASE_URL`。 - 前端 Docker 构建使用 Nuxt server output,`frontend` 服务通过 Node 运行 `.output/server/index.mjs`;Nuxt SSR server 监听容器内 `0.0.0.0:20015`,公开流量仍由 `frontend_gateway` 代理。 - `frontend` 因 `docker compose up -d --build` 重建、启动中或临时不可达时,`frontend_gateway` 返回静态升级维护页并保持公开端口可访问;后端 `/health` 不可用时,前端网关也返回同一维护页,避免用户看到静态页面后遇到 API 不可用。 diff --git a/backend/src/server.ts b/backend/src/server.ts index ba1ad9a..d38c3d9 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -197,7 +197,10 @@ const sessionCookieName = 'pokopia_session'; const rememberedSessionDays = 30; const sessionOnlySessionDays = 1; -function configuredCorsOrigin(): true | string | string[] { +type CorsOriginCallback = (error: Error | null, origin: boolean | string) => void; +type CorsOriginResolver = (origin: string | undefined, callback: CorsOriginCallback) => void; + +function configuredCorsOrigin(): true | CorsOriginResolver { const rawOrigin = process.env.FRONTEND_ORIGIN?.trim(); if (!rawOrigin) { return true; @@ -208,7 +211,78 @@ function configuredCorsOrigin(): true | string | string[] { .map((origin) => origin.trim()) .filter(Boolean); - return origins.length <= 1 ? (origins[0] ?? true) : origins; + if (origins.length === 0) { + return true; + } + + return (origin, callback) => { + callback(null, isAllowedCorsOrigin(origin, origins) ? (origin ?? false) : false); + }; +} + +function isAllowedCorsOrigin(origin: string | undefined, configuredOrigins: string[]): boolean { + if (!origin) { + return false; + } + + if (configuredOrigins.includes(origin)) { + return true; + } + + return configuredOrigins.some((configuredOrigin) => isLocalFrontendOriginAlias(origin, configuredOrigin)); +} + +function isLocalFrontendOriginAlias(origin: string, configuredOrigin: string): boolean { + try { + const requestUrl = new URL(origin); + const configuredUrl = new URL(configuredOrigin); + + return ( + requestUrl.protocol === configuredUrl.protocol && + effectivePort(requestUrl) === effectivePort(configuredUrl) && + isLoopbackHost(configuredUrl.hostname) && + isLocalDevelopmentHost(requestUrl.hostname) + ); + } catch { + return false; + } +} + +function effectivePort(url: URL): string { + if (url.port) { + return url.port; + } + + return url.protocol === 'https:' ? '443' : '80'; +} + +function isLoopbackHost(hostname: string): boolean { + return ['localhost', '127.0.0.1', '::1', '[::1]'].includes(hostname); +} + +function isLocalDevelopmentHost(hostname: string): boolean { + return ( + isLoopbackHost(hostname) || + isPrivateIpv4Host(hostname) || + ['host.docker.internal', 'frontend', 'frontend_gateway'].includes(hostname) + ); +} + +function isPrivateIpv4Host(hostname: string): boolean { + const parts = hostname.split('.').map((part) => Number(part)); + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) { + return false; + } + + const [first, second] = parts; + + return ( + first === 10 || + first === 127 || + (first === 172 && second >= 16 && second <= 31) || + (first === 192 && second === 168) || + (first === 169 && second === 254) + ); } await app.register(cors, { diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 377fb3f..0370fc0 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -60,7 +60,32 @@ function normalizeApiBaseUrl(value: unknown): string | null { } function activeApiBaseUrl(): string { - return typeof window === 'undefined' ? serverApiBaseUrl : browserApiBaseUrl; + return typeof window === 'undefined' + ? serverApiBaseUrl + : resolveBrowserApiBaseUrl(browserApiBaseUrl); +} + +function resolveBrowserApiBaseUrl(value: string): string { + if (typeof window === 'undefined') { + return value; + } + + try { + const url = new URL(value, window.location.origin); + + if (isLoopbackHost(url.hostname) && !isLoopbackHost(window.location.hostname)) { + url.hostname = window.location.hostname; + return url.toString().replace(/\/+$/, ''); + } + } catch { + return value; + } + + return value; +} + +function isLoopbackHost(hostname: string): boolean { + return ['localhost', '127.0.0.1', '::1', '[::1]'].includes(hostname); } export function readStoredLocale(): string { diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index d5b3365..3c5989d 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -45,7 +45,32 @@ function normalizeApiBaseUrl(value: unknown): string | null { } function activeApiBaseUrl(): string { - return typeof window === 'undefined' ? serverApiBaseUrl : browserApiBaseUrl; + return typeof window === 'undefined' + ? serverApiBaseUrl + : resolveBrowserApiBaseUrl(browserApiBaseUrl); +} + +function resolveBrowserApiBaseUrl(value: string): string { + if (typeof window === 'undefined') { + return value; + } + + try { + const url = new URL(value, window.location.origin); + + if (isLoopbackHost(url.hostname) && !isLoopbackHost(window.location.hostname)) { + url.hostname = window.location.hostname; + return url.toString().replace(/\/+$/, ''); + } + } catch { + return value; + } + + return value; +} + +function isLoopbackHost(hostname: string): boolean { + return ['localhost', '127.0.0.1', '::1', '[::1]'].includes(hostname); } function apiUrl(path: string): string { diff --git a/frontend/src/views/PokemonDetail.vue b/frontend/src/views/PokemonDetail.vue index 696a4e8..7f23cb8 100644 --- a/frontend/src/views/PokemonDetail.vue +++ b/frontend/src/views/PokemonDetail.vue @@ -39,7 +39,6 @@ const tradingDraftItems = ref( @@ -333,16 +332,14 @@ const relatedPokemonRows = computed(() => { const selectedTab = relatedHabitatTab.value || (pokemon.value ? habitatTabValue(pokemon.value.environment.id) : ''); if (selectedTab === 'all') { - return rows.slice(0, relatedPokemonLimit); + return rows; } if (pokemon.value && selectedTab === habitatTabValue(pokemon.value.environment.id)) { - return rows - .filter((item) => habitatTabValue(item.environment.id) === selectedTab || item.environment.isOpposite) - .slice(0, relatedPokemonLimit); + return rows.filter((item) => habitatTabValue(item.environment.id) === selectedTab || item.environment.isOpposite); } - return rows.filter((item) => habitatTabValue(item.environment.id) === selectedTab).slice(0, relatedPokemonLimit); + return rows.filter((item) => habitatTabValue(item.environment.id) === selectedTab); }); const typeSlotClass = computed(() => ({ 'pokemon-type-slots--single': (pokemon.value?.types.length ?? 0) === 1