mirror of
https://github.com/langgenius/dify.git
synced 2026-05-30 13:47:52 +08:00
fix(web): guard server profile prefetch URL
This commit is contained in:
@ -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: <div>Common shell</div>,
|
||||
})
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{element as ReactElement}
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
expect(screen.getByText('Common shell')).toBeInTheDocument()
|
||||
expect(mocks.profileQueryFn).not.toHaveBeenCalled()
|
||||
expect(mocks.systemFeaturesQueryFn).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
{children}
|
||||
</HydrationBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
|
||||
9
web/config/server.ts
Normal file
9
web/config/server.ts
Normal file
@ -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
|
||||
@ -142,6 +142,7 @@ const clientSchema = {
|
||||
|
||||
export const env = createEnv({
|
||||
server: {
|
||||
CONSOLE_API_URL: z.string().optional(),
|
||||
/**
|
||||
* Maximum length of segmentation tokens for indexing
|
||||
*/
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<UserProfileWithMeta>({
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user