From 2d324add3930dbd85b2a2c028f459997cd7f0438 Mon Sep 17 00:00:00 2001 From: yyh Date: Fri, 29 May 2026 16:50:29 +0800 Subject: [PATCH] fix(web): guard server profile prefetch URL --- .../__tests__/hydration-boundary.spec.tsx | 21 ++++++++++++ web/app/(commonLayout)/hydration-boundary.tsx | 12 ++++++- web/config/server.ts | 9 +++++ web/env.ts | 1 + .../account-profile/__tests__/server.spec.ts | 16 ++++++++- web/features/account-profile/server.ts | 33 ++++++++++++++++++- 6 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 web/config/server.ts diff --git a/web/app/(commonLayout)/__tests__/hydration-boundary.spec.tsx b/web/app/(commonLayout)/__tests__/hydration-boundary.spec.tsx index 95bb612865..3d85179d06 100644 --- a/web/app/(commonLayout)/__tests__/hydration-boundary.spec.tsx +++ b/web/app/(commonLayout)/__tests__/hydration-boundary.spec.tsx @@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({ throw new Error(`NEXT_REDIRECT:${url}`) }), headers: vi.fn(), + resolveServerConsoleApiUrl: vi.fn(), })) vi.mock('@/context/query-client-server', () => ({ @@ -26,6 +27,7 @@ vi.mock('@/next/navigation', () => ({ })) vi.mock('@/features/account-profile/server', () => ({ + resolveServerConsoleApiUrl: (...args: unknown[]) => mocks.resolveServerConsoleApiUrl(...args), serverUserProfileQueryOptions: () => ({ queryKey: ['common', 'user-profile'], queryFn: mocks.profileQueryFn, @@ -49,6 +51,7 @@ describe('CommonLayoutHydrationBoundary', () => { 'x-dify-pathname': '/apps', 'x-dify-search': '?tag=workflow', })) + mocks.resolveServerConsoleApiUrl.mockReturnValue('https://console.example.com/console/api/account/profile') mocks.profileQueryFn.mockResolvedValue({ profile: { id: 'account-id', @@ -100,4 +103,22 @@ describe('CommonLayoutHydrationBoundary', () => { expect(mocks.redirect).toHaveBeenCalledWith('/install') }) + + it('should render children without server prefetch when the server API URL is not resolvable', async () => { + mocks.resolveServerConsoleApiUrl.mockReturnValue(null) + const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary') + + const element = await CommonLayoutHydrationBoundary({ + children:
Common shell
, + }) + + render( + + {element as ReactElement} + , + ) + expect(screen.getByText('Common shell')).toBeInTheDocument() + expect(mocks.profileQueryFn).not.toHaveBeenCalled() + expect(mocks.systemFeaturesQueryFn).not.toHaveBeenCalled() + }) }) diff --git a/web/app/(commonLayout)/hydration-boundary.tsx b/web/app/(commonLayout)/hydration-boundary.tsx index 29323b8403..ed148dcbe5 100644 --- a/web/app/(commonLayout)/hydration-boundary.tsx +++ b/web/app/(commonLayout)/hydration-boundary.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react' import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import { getQueryClientServer } from '@/context/query-client-server' -import { serverUserProfileQueryOptions } from '@/features/account-profile/server' +import { resolveServerConsoleApiUrl, serverUserProfileQueryOptions } from '@/features/account-profile/server' import { headers } from '@/next/headers' import { redirect } from '@/next/navigation' import { systemFeaturesQueryOptions } from '@/service/system-features' @@ -9,6 +9,7 @@ import { basePath } from '@/utils/var' const CURRENT_PATHNAME_HEADER = 'x-dify-pathname' const CURRENT_SEARCH_HEADER = 'x-dify-search' +const ACCOUNT_PROFILE_PATH = '/account/profile' type ConsoleErrorPayload = { code?: string @@ -56,6 +57,15 @@ const handleProfileError = async (error: unknown) => { export async function CommonLayoutHydrationBoundary({ children }: { children: ReactNode }) { const queryClient = getQueryClientServer() + const accountProfileUrl = resolveServerConsoleApiUrl(ACCOUNT_PROFILE_PATH) + + if (!accountProfileUrl) { + return ( + + {children} + + ) + } try { await Promise.all([ diff --git a/web/config/server.ts b/web/config/server.ts new file mode 100644 index 0000000000..c3844ac36d --- /dev/null +++ b/web/config/server.ts @@ -0,0 +1,9 @@ +import { env } from '@/env' + +import 'server-only' + +const withoutTrailingSlash = (value: string) => value.endsWith('/') ? value.slice(0, -1) : value + +export const SERVER_CONSOLE_API_PREFIX = env.CONSOLE_API_URL + ? `${withoutTrailingSlash(env.CONSOLE_API_URL)}/console/api` + : undefined diff --git a/web/env.ts b/web/env.ts index 0c2868be5c..16a0ea2da1 100644 --- a/web/env.ts +++ b/web/env.ts @@ -142,6 +142,7 @@ const clientSchema = { export const env = createEnv({ server: { + CONSOLE_API_URL: z.string().optional(), /** * Maximum length of segmentation tokens for indexing */ diff --git a/web/features/account-profile/__tests__/server.spec.ts b/web/features/account-profile/__tests__/server.spec.ts index 1426e794f9..79ed7fa571 100644 --- a/web/features/account-profile/__tests__/server.spec.ts +++ b/web/features/account-profile/__tests__/server.spec.ts @@ -2,10 +2,15 @@ import type { AccountProfileResponse } from '@/contract/console/account' import { QueryClient } from '@tanstack/react-query' import { beforeEach, describe, expect, it, vi } from 'vitest' import { userProfileQueryOptions } from '../client' +import { resolveServerConsoleApiUrl } from '../server' const headersMock = vi.fn() const cookiesMock = vi.fn() +vi.mock('@/config/server', () => ({ + SERVER_CONSOLE_API_PREFIX: undefined, +})) + vi.mock('@/next/headers', () => ({ headers: () => headersMock(), cookies: () => cookiesMock(), @@ -54,7 +59,7 @@ describe('serverUserProfileQueryOptions', () => { }, }) expect(fetchMock).toHaveBeenCalledWith( - expect.stringContaining('/account/profile'), + 'http://localhost:5001/console/api/account/profile', expect.objectContaining({ method: 'GET', cache: 'no-store', @@ -62,4 +67,13 @@ describe('serverUserProfileQueryOptions', () => { }), ) }) + + it('should skip relative API prefixes unless a server API origin is configured', () => { + expect(resolveServerConsoleApiUrl('/account/profile', undefined, '/console/api')).toBeNull() + expect(resolveServerConsoleApiUrl('/account/profile', 'https://console.example.com/console/api', '/console/api')).toBe('https://console.example.com/console/api/account/profile') + }) + + it('should preserve absolute API prefixes', () => { + expect(resolveServerConsoleApiUrl('/account/profile', undefined, 'https://console.example.com/console/api')).toBe('https://console.example.com/console/api/account/profile') + }) }) diff --git a/web/features/account-profile/server.ts b/web/features/account-profile/server.ts index f270147713..8885f5f15f 100644 --- a/web/features/account-profile/server.ts +++ b/web/features/account-profile/server.ts @@ -2,11 +2,38 @@ import type { UserProfileWithMeta } from './client' import type { AccountProfileResponse } from '@/contract/console/account' import { queryOptions } from '@tanstack/react-query' import { API_PREFIX, CSRF_COOKIE_NAME, CSRF_HEADER_NAME } from '@/config' +import { SERVER_CONSOLE_API_PREFIX } from '@/config/server' import { cookies, headers } from '@/next/headers' import { consoleQuery } from '@/service/client' const ACCOUNT_PROFILE_PATH = '/account/profile' +const withTrailingSlash = (value: string) => value.endsWith('/') ? value : `${value}/` +const withoutLeadingSlash = (value: string) => value.startsWith('/') ? value.slice(1) : value + +const resolveAbsoluteUrlPrefix = (value: string) => { + try { + return new URL(value).toString() + } + catch { + return null + } +} + +export const resolveServerConsoleApiUrl = ( + pathname: string, + serverConsoleApiPrefix = SERVER_CONSOLE_API_PREFIX, + publicApiPrefix = API_PREFIX, +) => { + const requestPath = withoutLeadingSlash(pathname) + const apiPrefix = serverConsoleApiPrefix || resolveAbsoluteUrlPrefix(publicApiPrefix) + + if (!apiPrefix) + return null + + return new URL(requestPath, withTrailingSlash(apiPrefix)).toString() +} + const getServerRequestHeaders = async () => { const requestHeaders = await headers() const cookieStore = await cookies() @@ -26,7 +53,11 @@ export const serverUserProfileQueryOptions = () => queryOptions({ queryKey: consoleQuery.account.profile.get.queryKey(), queryFn: async () => { - const response = await fetch(`${API_PREFIX}${ACCOUNT_PROFILE_PATH}`, { + const profileUrl = resolveServerConsoleApiUrl(ACCOUNT_PROFILE_PATH) + if (!profileUrl) + throw new Error('Server account profile URL is not configured') + + const response = await fetch(profileUrl, { method: 'GET', headers: await getServerRequestHeaders(), cache: 'no-store',