feat: support zero-config LAN access for local development
Auto-rewrite loopback API URLs to current page host in browser Allow CORS for private LAN IPs when localhost origin is configured Remove display limit for related Pokémon in detail view
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 不可用。
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -39,7 +39,6 @@ const tradingDraftItems = ref<Array<{ itemId: number; preference: TradingPrefere
|
||||
const tradingActiveItemIndex = ref(0);
|
||||
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
||||
const weathers = ['晴天', '阴天', '雨天'];
|
||||
const relatedPokemonLimit = 6;
|
||||
const pokemonDetailRouteNames = new Set(['pokemon-detail', 'pokemon-edit']);
|
||||
|
||||
const { data: initialPokemon } = useAsyncData<PokemonDetail | null>(
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user