From f399197364bc816bed7b2e095bebcc7f646b2bcd Mon Sep 17 00:00:00 2001 From: Arvin Xu Date: Sun, 9 Feb 2025 13:40:57 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix:=20rewrite=20to=20local=20co?= =?UTF-8?q?ntainer=20in=20docker=20deployment=20mode=20(#5910)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * make rewrite with local * try longitude * refactor the isMobile condition * support use longitude as theme detector * improve dockerfile * improve geo * improve geo * improve geo * vercel functions * clean log * skip api request at first * turn back status in rewrite --- Dockerfile | 4 ++ Dockerfile.database | 4 ++ package.json | 2 + .../(main)/(mobile)/me/data/page.tsx | 5 +-- .../[variants]/(main)/settings/about/page.tsx | 5 +-- .../[variants]/(main)/settings/sync/page.tsx | 6 +-- src/config/app.ts | 3 ++ src/middleware.ts | 45 +++++++++---------- src/utils/server/geo.ts | 39 ++++++++++++++++ src/utils/server/responsive.ts | 2 +- 10 files changed, 80 insertions(+), 35 deletions(-) create mode 100644 src/utils/server/geo.ts diff --git a/Dockerfile b/Dockerfile index f4137cdc9b688..8d838ba4b5136 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,6 +47,10 @@ ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID ENV NEXT_PUBLIC_BASE_PATH="${NEXT_PUBLIC_BASE_PATH}" +# Make the middleware rewrite through local as default +# refs: https://github.com/lobehub/lobe-chat/issues/5876 +ENV MIDDLEWARE_REWRITE_THROUGH_LOCAL="1" + # Sentry ENV NEXT_PUBLIC_SENTRY_DSN="${NEXT_PUBLIC_SENTRY_DSN}" \ SENTRY_ORG="" \ diff --git a/Dockerfile.database b/Dockerfile.database index fc265363dd5b7..5fe4bf4f56577 100644 --- a/Dockerfile.database +++ b/Dockerfile.database @@ -49,6 +49,10 @@ ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID ENV NEXT_PUBLIC_BASE_PATH="${NEXT_PUBLIC_BASE_PATH}" +# Make the middleware rewrite through local as default +# refs: https://github.com/lobehub/lobe-chat/issues/5876 +ENV MIDDLEWARE_REWRITE_THROUGH_LOCAL="1" + ENV NEXT_PUBLIC_SERVICE_MODE="${NEXT_PUBLIC_SERVICE_MODE:-server}" \ NEXT_PUBLIC_ENABLE_NEXT_AUTH="${NEXT_PUBLIC_ENABLE_NEXT_AUTH:-1}" \ APP_URL="http://app.com" \ diff --git a/package.json b/package.json index 595cd5a176a1e..e891349c56ce0 100644 --- a/package.json +++ b/package.json @@ -142,6 +142,7 @@ "@trpc/server": "next", "@vercel/analytics": "^1.4.1", "@vercel/edge-config": "^1.4.0", + "@vercel/functions": "^2", "@vercel/speed-insights": "^1.1.0", "ahooks": "^3.8.4", "ai": "^3.4.33", @@ -149,6 +150,7 @@ "antd-style": "^3.7.1", "brotli-wasm": "^3.0.1", "chroma-js": "^3.1.2", + "countries-and-timezones": "^3.7.2", "dayjs": "^1.11.13", "debug": "^4.4.0", "dexie": "^3.2.7", diff --git a/src/app/[variants]/(main)/(mobile)/me/data/page.tsx b/src/app/[variants]/(main)/(mobile)/me/data/page.tsx index 0a9579427b953..e990c3b119ed2 100644 --- a/src/app/[variants]/(main)/(mobile)/me/data/page.tsx +++ b/src/app/[variants]/(main)/(mobile)/me/data/page.tsx @@ -3,7 +3,6 @@ import { redirect } from 'next/navigation'; import { metadataModule } from '@/server/metadata'; import { translation } from '@/server/translation'; import { DynamicLayoutProps } from '@/types/next'; -import { isMobileDevice } from '@/utils/server/responsive'; import { RouteVariants } from '@/utils/server/routeVariants'; import Category from './features/Category'; @@ -17,8 +16,8 @@ export const generateMetadata = async (props: DynamicLayoutProps) => { }); }; -const Page = async () => { - const mobile = await isMobileDevice(); +const Page = async (props: DynamicLayoutProps) => { + const mobile = await RouteVariants.getIsMobile(props); if (!mobile) return redirect('/chat'); diff --git a/src/app/[variants]/(main)/settings/about/page.tsx b/src/app/[variants]/(main)/settings/about/page.tsx index cad543e89316a..c0a1ba09ef4cf 100644 --- a/src/app/[variants]/(main)/settings/about/page.tsx +++ b/src/app/[variants]/(main)/settings/about/page.tsx @@ -1,7 +1,6 @@ import { metadataModule } from '@/server/metadata'; import { translation } from '@/server/translation'; import { DynamicLayoutProps } from '@/types/next'; -import { isMobileDevice } from '@/utils/server/responsive'; import { RouteVariants } from '@/utils/server/routeVariants'; import Page from './index'; @@ -16,8 +15,8 @@ export const generateMetadata = async (props: DynamicLayoutProps) => { }); }; -export default async () => { - const isMobile = await isMobileDevice(); +export default async (props: DynamicLayoutProps) => { + const isMobile = await RouteVariants.getIsMobile(props); return ; }; diff --git a/src/app/[variants]/(main)/settings/sync/page.tsx b/src/app/[variants]/(main)/settings/sync/page.tsx index 6f5c655152ac1..72ab30e11d934 100644 --- a/src/app/[variants]/(main)/settings/sync/page.tsx +++ b/src/app/[variants]/(main)/settings/sync/page.tsx @@ -4,7 +4,7 @@ import { serverFeatureFlags } from '@/config/featureFlags'; import { metadataModule } from '@/server/metadata'; import { translation } from '@/server/translation'; import { DynamicLayoutProps } from '@/types/next'; -import { gerServerDeviceInfo, isMobileDevice } from '@/utils/server/responsive'; +import { gerServerDeviceInfo } from '@/utils/server/responsive'; import { RouteVariants } from '@/utils/server/routeVariants'; import Page from './index'; @@ -18,11 +18,11 @@ export const generateMetadata = async (props: DynamicLayoutProps) => { url: '/settings/sync', }); }; -export default async () => { +export default async (props: DynamicLayoutProps) => { const enableWebrtc = serverFeatureFlags().enableWebrtc; if (!enableWebrtc) return notFound(); - const isMobile = await isMobileDevice(); + const isMobile = await RouteVariants.getIsMobile(props); const { os, browser } = await gerServerDeviceInfo(); return ; diff --git a/src/config/app.ts b/src/config/app.ts index 242a01151396d..f455ecc41e893 100644 --- a/src/config/app.ts +++ b/src/config/app.ts @@ -48,6 +48,7 @@ export const getAppConfig = () => { APP_URL: z.string().optional(), VERCEL_EDGE_CONFIG: z.string().optional(), + MIDDLEWARE_REWRITE_THROUGH_LOCAL: z.boolean().optional(), CDN_USE_GLOBAL: z.boolean().optional(), CUSTOM_FONT_FAMILY: z.string().optional(), @@ -80,6 +81,8 @@ export const getAppConfig = () => { VERCEL_EDGE_CONFIG: process.env.VERCEL_EDGE_CONFIG, APP_URL, + MIDDLEWARE_REWRITE_THROUGH_LOCAL: process.env.MIDDLEWARE_REWRITE_THROUGH_LOCAL === '1', + CUSTOM_FONT_FAMILY: process.env.CUSTOM_FONT_FAMILY, CUSTOM_FONT_URL: process.env.CUSTOM_FONT_URL, CDN_USE_GLOBAL: process.env.CDN_USE_GLOBAL === '1', diff --git a/src/middleware.ts b/src/middleware.ts index f1293bce65976..4d7020d0d1a96 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,12 +1,15 @@ import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'; import { NextRequest, NextResponse } from 'next/server'; import { UAParser } from 'ua-parser-js'; +import urlJoin from 'url-join'; +import { appEnv } from '@/config/app'; import { authEnv } from '@/config/auth'; import { LOBE_THEME_APPEARANCE } from '@/const/theme'; import NextAuthEdge from '@/libs/next-auth/edge'; import { Locales } from '@/locales/resources'; import { parseBrowserLanguage } from '@/utils/locale'; +import { parseDefaultThemeFromCountry } from '@/utils/server/geo'; import { RouteVariants } from '@/utils/server/routeVariants'; import { OAUTH_AUTHORIZED } from './const/auth'; @@ -37,30 +40,17 @@ export const config = { ], }; -const parseDefaultThemeFromTime = (request: NextRequest) => { - // 获取经度信息,Next.js 会自动解析 geo 信息到请求对象中 - const longitude = 'geo' in request && (request.geo as any)?.longitude; - - if (typeof longitude === 'number') { - // 计算时区偏移(每15度经度对应1小时) - // 东经为正,西经为负 - const offsetHours = Math.round(longitude / 15); - - // 计算当地时间 - const localHour = (new Date().getUTCHours() + offsetHours + 24) % 24; - console.log(`[theme] localHour: ${localHour}`); +const defaultMiddleware = (request: NextRequest) => { + const url = new URL(request.url); - // 6点到18点之间返回 light 主题 - return localHour >= 6 && localHour < 18 ? 'light' : 'dark'; + // skip all api requests + if (['/api', '/trpc', '/webapi'].some((path) => url.pathname.startsWith(path))) { + return NextResponse.next(); } - return 'light'; -}; - -const defaultMiddleware = (request: NextRequest) => { // 1. 从 cookie 中读取用户偏好 const theme = - request.cookies.get(LOBE_THEME_APPEARANCE)?.value || parseDefaultThemeFromTime(request); + request.cookies.get(LOBE_THEME_APPEARANCE)?.value || parseDefaultThemeFromCountry(request); // if it's a new user, there's no cookie // So we need to use the fallback language parsed by accept-language @@ -80,11 +70,12 @@ const defaultMiddleware = (request: NextRequest) => { theme, }); - const url = new URL(request.url); - - // skip all api requests - if (['/api', '/trpc', '/webapi'].some((path) => url.pathname.startsWith(path))) { - return NextResponse.next(); + // if app is in docker, rewrite to self container + // https://github.com/lobehub/lobe-chat/issues/5876 + if (appEnv.MIDDLEWARE_REWRITE_THROUGH_LOCAL) { + url.protocol = 'http'; + url.host = '127.0.0.1'; + url.port = process.env.PORT || '3210'; } // refs: https://github.com/lobehub/lobe-chat/pull/5866 @@ -92,7 +83,11 @@ const defaultMiddleware = (request: NextRequest) => { // / -> /zh-CN__0__dark // /discover -> /zh-CN__0__dark/discover const nextPathname = `/${route}` + (url.pathname === '/' ? '' : url.pathname); - console.log(`[rewrite] ${url.pathname} -> ${nextPathname}`); + const nextURL = appEnv.MIDDLEWARE_REWRITE_THROUGH_LOCAL + ? urlJoin(url.origin, nextPathname) + : nextPathname; + + console.log(`[rewrite] ${url.pathname} -> ${nextURL}`); url.pathname = nextPathname; diff --git a/src/utils/server/geo.ts b/src/utils/server/geo.ts new file mode 100644 index 0000000000000..d321849574d49 --- /dev/null +++ b/src/utils/server/geo.ts @@ -0,0 +1,39 @@ +import { geolocation } from '@vercel/functions'; +import { getCountry } from 'countries-and-timezones'; +import { NextRequest } from 'next/server'; + +export const parseDefaultThemeFromCountry = (request: NextRequest) => { + // 1. 从请求头中获取国家代码 + const geo = geolocation(request); + + const countryCode = + geo?.country || + request.headers.get('x-vercel-ip-country') || // Vercel + request.headers.get('cf-ipcountry') || // Cloudflare + request.headers.get('x-zeabur-ip-country') || // Zeabur + request.headers.get('x-country-code'); // Netlify + + // 如果没有获取到国家代码,直接返回 light 主题 + if (!countryCode) return 'light'; + + // 2. 获取国家的时区信息 + const country = getCountry(countryCode); + + // 如果找不到国家信息或该国家没有时区信息,返回 light 主题 + if (!country?.timezones?.length) return 'light'; + + // 3. 获取该国家的第一个 时区下的当前时间 + const localTime = new Date().toLocaleString('en-US', { + hour: 'numeric', + hour12: false, + timeZone: country.timezones[0], + }); + + // 4. 解析小时数并确定主题 + const localHour = parseInt(localTime); + // console.log( + // `[theme] Country: ${countryCode}, Timezone: ${country.timezones[0]}, LocalHour: ${localHour}`, + // ); + + return localHour >= 6 && localHour < 18 ? 'light' : 'dark'; +}; diff --git a/src/utils/server/responsive.ts b/src/utils/server/responsive.ts index 7f6d5c929ed4e..ec7e51eb4fafc 100644 --- a/src/utils/server/responsive.ts +++ b/src/utils/server/responsive.ts @@ -4,7 +4,7 @@ import { UAParser } from 'ua-parser-js'; /** * check mobile device in server */ -export const isMobileDevice = async () => { +const isMobileDevice = async () => { if (typeof process === 'undefined') { throw new Error('[Server method] you are importing a server-only module outside of server'); }