diff --git a/web/app/(commonLayout)/__tests__/hydration-boundary.spec.tsx b/web/app/(commonLayout)/__tests__/hydration-boundary.spec.tsx index 20dea97243..b20a612c3c 100644 --- a/web/app/(commonLayout)/__tests__/hydration-boundary.spec.tsx +++ b/web/app/(commonLayout)/__tests__/hydration-boundary.spec.tsx @@ -27,7 +27,6 @@ vi.mock('@/next/navigation', () => ({ })) vi.mock('@/features/account-profile/server', () => ({ - resolveServerConsoleApiUrl: (...args: unknown[]) => mocks.resolveServerConsoleApiUrl(...args), serverUserProfileQueryOptions: () => ({ queryKey: ['common', 'user-profile'], queryFn: mocks.profileQueryFn, @@ -35,8 +34,12 @@ vi.mock('@/features/account-profile/server', () => ({ }), })) -vi.mock('@/service/system-features', () => ({ - systemFeaturesQueryOptions: () => ({ +vi.mock('@/service/server', () => ({ + resolveServerConsoleApiUrl: (...args: unknown[]) => mocks.resolveServerConsoleApiUrl(...args), +})) + +vi.mock('@/service/server-system-features', () => ({ + serverSystemFeaturesQueryOptions: () => ({ queryKey: ['console', 'system-features'], queryFn: mocks.systemFeaturesQueryFn, retry: false, diff --git a/web/app/(commonLayout)/hydration-boundary.tsx b/web/app/(commonLayout)/hydration-boundary.tsx index fe4cf49420..35d35ea9e7 100644 --- a/web/app/(commonLayout)/hydration-boundary.tsx +++ b/web/app/(commonLayout)/hydration-boundary.tsx @@ -1,10 +1,11 @@ import type { ReactNode } from 'react' import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import { getQueryClientServer } from '@/context/query-client-server' -import { resolveServerConsoleApiUrl, serverUserProfileQueryOptions } from '@/features/account-profile/server' +import { serverUserProfileQueryOptions } from '@/features/account-profile/server' import { headers } from '@/next/headers' import { redirect } from '@/next/navigation' -import { systemFeaturesQueryOptions } from '@/service/system-features' +import { resolveServerConsoleApiUrl } from '@/service/server' +import { serverSystemFeaturesQueryOptions } from '@/service/server-system-features' import { basePath } from '@/utils/var' const CURRENT_PATHNAME_HEADER = 'x-dify-pathname' @@ -71,7 +72,7 @@ export async function CommonLayoutHydrationBoundary({ children }: { children: Re try { await Promise.all([ queryClient.fetchQuery(serverUserProfileQueryOptions()), - queryClient.prefetchQuery(systemFeaturesQueryOptions()), + queryClient.prefetchQuery(serverSystemFeaturesQueryOptions()), ]) } catch (error) { diff --git a/web/app/auth/refresh/__tests__/route.spec.ts b/web/app/auth/refresh/__tests__/route.spec.ts index 9503b63f7e..63fe540fc3 100644 --- a/web/app/auth/refresh/__tests__/route.spec.ts +++ b/web/app/auth/refresh/__tests__/route.spec.ts @@ -4,8 +4,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/config', () => ({ API_PREFIX: 'http://localhost:5001/console/api', + CSRF_COOKIE_NAME: () => 'csrf_token', + CSRF_HEADER_NAME: 'X-CSRF-Token', })) +vi.mock('server-only', () => ({})) + vi.mock('@/config/server', () => ({ SERVER_CONSOLE_API_PREFIX: undefined, })) diff --git a/web/app/auth/refresh/route.ts b/web/app/auth/refresh/route.ts index 7dafee480f..e3bc4ee6b4 100644 --- a/web/app/auth/refresh/route.ts +++ b/web/app/auth/refresh/route.ts @@ -1,34 +1,10 @@ -import { API_PREFIX } from '@/config' -import { SERVER_CONSOLE_API_PREFIX } from '@/config/server' +import { resolveServerConsoleApiUrl } from '@/service/server' import { basePath } from '@/utils/var' const REFRESH_TOKEN_PATH = '/refresh-token' const AUTH_REFRESH_PATH = `${basePath}/auth/refresh` const DEFAULT_REDIRECT_PATH = `${basePath}/apps` -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 - } -} - -const resolveServerConsoleApiUrl = (pathname: string) => { - const requestPath = withoutLeadingSlash(pathname) - const apiPrefix = SERVER_CONSOLE_API_PREFIX - || resolveAbsoluteUrlPrefix(API_PREFIX) - - if (!apiPrefix) - return null - - return new URL(requestPath, withTrailingSlash(apiPrefix)).toString() -} - const resolveSafeRedirectPath = (request: Request) => { const requestUrl = new URL(request.url) const redirectUrl = requestUrl.searchParams.get('redirect_url') diff --git a/web/features/account-profile/__tests__/server.spec.ts b/web/features/account-profile/__tests__/server.spec.ts index 79ed7fa571..e411ff71f4 100644 --- a/web/features/account-profile/__tests__/server.spec.ts +++ b/web/features/account-profile/__tests__/server.spec.ts @@ -1,12 +1,14 @@ import type { AccountProfileResponse } from '@/contract/console/account' import { QueryClient } from '@tanstack/react-query' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resolveServerConsoleApiUrl } from '@/service/server' import { userProfileQueryOptions } from '../client' -import { resolveServerConsoleApiUrl } from '../server' const headersMock = vi.fn() const cookiesMock = vi.fn() +vi.mock('server-only', () => ({})) + vi.mock('@/config/server', () => ({ SERVER_CONSOLE_API_PREFIX: undefined, })) diff --git a/web/features/account-profile/server.ts b/web/features/account-profile/server.ts index ddd25246ef..77adbcf2e5 100644 --- a/web/features/account-profile/server.ts +++ b/web/features/account-profile/server.ts @@ -1,57 +1,13 @@ 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' +import { getServerConsoleRequestHeaders, resolveServerConsoleApiUrl, serverConsoleQuery } from '@/service/server' 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() - const outgoingHeaders = new Headers({ - 'Content-Type': 'application/json', - }) - const cookie = requestHeaders.get('cookie') - if (cookie) - outgoingHeaders.set('cookie', cookie) - const csrfToken = cookieStore.get(CSRF_COOKIE_NAME())?.value - if (csrfToken) - outgoingHeaders.set(CSRF_HEADER_NAME, csrfToken) - return outgoingHeaders -} - export const serverUserProfileQueryOptions = () => queryOptions({ - queryKey: consoleQuery.account.profile.get.queryKey(), + queryKey: serverConsoleQuery.account.profile.get.queryKey(), queryFn: async () => { const profileUrl = resolveServerConsoleApiUrl(ACCOUNT_PROFILE_PATH) if (!profileUrl) @@ -59,7 +15,7 @@ export const serverUserProfileQueryOptions = () => const response = await fetch(profileUrl, { method: 'GET', - headers: await getServerRequestHeaders(), + headers: await getServerConsoleRequestHeaders(), cache: 'no-store', }) diff --git a/web/service/__tests__/server.spec.ts b/web/service/__tests__/server.spec.ts new file mode 100644 index 0000000000..cd1502f53c --- /dev/null +++ b/web/service/__tests__/server.spec.ts @@ -0,0 +1,92 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + headers: vi.fn(), + cookies: vi.fn(), + serverConsoleApiPrefix: undefined as string | undefined, +})) + +vi.mock('server-only', () => ({})) + +vi.mock('@/config', () => ({ + API_PREFIX: 'http://localhost:5001/console/api', + CSRF_COOKIE_NAME: () => 'csrf_token', + CSRF_HEADER_NAME: 'X-CSRF-Token', +})) + +vi.mock('@/config/server', () => ({ + get SERVER_CONSOLE_API_PREFIX() { + return mocks.serverConsoleApiPrefix + }, +})) + +vi.mock('@/next/headers', () => ({ + headers: () => mocks.headers(), + cookies: () => mocks.cookies(), +})) + +describe('server console oRPC client', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.unstubAllGlobals() + mocks.serverConsoleApiPrefix = undefined + mocks.headers.mockResolvedValue(new Headers({ cookie: 'access_token=abc; csrf_token=csrf-token' })) + mocks.cookies.mockResolvedValue({ + get: vi.fn(() => ({ value: 'csrf-token' })), + }) + }) + + it('should resolve server console API URLs only from configured or absolute prefixes', async () => { + const { resolveServerConsoleApiPrefix, resolveServerConsoleApiUrl } = await import('../server') + + expect(resolveServerConsoleApiPrefix(undefined, '/console/api')).toBeNull() + expect(resolveServerConsoleApiUrl('/account/profile', undefined, '/console/api')).toBeNull() + expect( + resolveServerConsoleApiUrl('/account/profile', 'https://api.example.com/console/api', '/console/api'), + ).toBe('https://api.example.com/console/api/account/profile') + expect( + resolveServerConsoleApiUrl('/account/profile', undefined, 'https://public.example.com/console/api'), + ).toBe('https://public.example.com/console/api/account/profile') + }) + + it('should build per-request context from Next headers and cookies', async () => { + const { getServerConsoleClientContext } = await import('../server') + + await expect(getServerConsoleClientContext()).resolves.toEqual({ + cookie: 'access_token=abc; csrf_token=csrf-token', + csrfToken: 'csrf-token', + }) + }) + + it('should call contracts with forwarded cookies, csrf header, and no-store cache', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ feature: { billing: false } }), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + })) + vi.stubGlobal('fetch', fetchMock) + const { getServerConsoleClientContext, serverConsoleClient } = await import('../server') + + await serverConsoleClient.systemFeatures(undefined, { + context: await getServerConsoleClientContext(), + }) + + expect(fetchMock).toHaveBeenCalledWith( + expect.any(Request), + expect.objectContaining({ + cache: 'no-store', + redirect: 'manual', + }), + ) + const request = fetchMock.mock.calls[0]?.[0] as Request + expect(request.url).toBe('http://localhost:5001/console/api/system-features') + expect(request.method).toBe('GET') + expect(request.headers.get('accept')).toBe('application/json') + expect(request.headers.get('content-type')).toBeNull() + expect(request.headers.get('cookie')).toBe('access_token=abc; csrf_token=csrf-token') + expect(request.headers.get('X-CSRF-Token')).toBe('csrf-token') + }) +}) diff --git a/web/service/server-system-features.ts b/web/service/server-system-features.ts new file mode 100644 index 0000000000..f67f6ee2d2 --- /dev/null +++ b/web/service/server-system-features.ts @@ -0,0 +1,24 @@ +import type { SystemFeatures } from '@/types/feature' +import { queryOptions } from '@tanstack/react-query' +import { defaultSystemFeatures } from '@/types/feature' +import { + getServerConsoleClientContext, + serverConsoleClient, + serverConsoleQuery, +} from './server' + +export const serverSystemFeaturesQueryOptions = () => + queryOptions({ + queryKey: serverConsoleQuery.systemFeatures.queryKey(), + queryFn: async () => { + try { + return await serverConsoleClient.systemFeatures(undefined, { + context: await getServerConsoleClientContext(), + }) + } + catch (err) { + console.error('[systemFeatures] server fetch failed', err) + return defaultSystemFeatures + } + }, + }) diff --git a/web/service/server.ts b/web/service/server.ts new file mode 100644 index 0000000000..22614236f3 --- /dev/null +++ b/web/service/server.ts @@ -0,0 +1,108 @@ +import type { ContractRouterClient } from '@orpc/contract' +import type { JsonifiedClient } from '@orpc/openapi-client' +import { createORPCClient, onError } from '@orpc/client' +import { OpenAPILink } from '@orpc/openapi-client/fetch' +import { createTanstackQueryUtils } from '@orpc/tanstack-query' +import { + API_PREFIX, + CSRF_COOKIE_NAME, + CSRF_HEADER_NAME, +} from '@/config' +import { SERVER_CONSOLE_API_PREFIX } from '@/config/server' +import { consoleRouterContract } from '@/contract/router' + +import 'server-only' + +export type ServerConsoleClientContext = { + cookie?: string + csrfToken?: string +} + +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 resolveServerConsoleApiPrefix = ( + serverConsoleApiPrefix = SERVER_CONSOLE_API_PREFIX, + publicApiPrefix = API_PREFIX, +) => serverConsoleApiPrefix || resolveAbsoluteUrlPrefix(publicApiPrefix) + +export const resolveServerConsoleApiUrl = ( + pathname: string, + serverConsoleApiPrefix = SERVER_CONSOLE_API_PREFIX, + publicApiPrefix = API_PREFIX, +) => { + const apiPrefix = resolveServerConsoleApiPrefix(serverConsoleApiPrefix, publicApiPrefix) + if (!apiPrefix) + return null + + return new URL(withoutLeadingSlash(pathname), withTrailingSlash(apiPrefix)).toString() +} + +const getServerConsoleApiPrefix = () => { + const apiPrefix = resolveServerConsoleApiPrefix() + if (!apiPrefix) + throw new Error('Server console API URL is not configured') + + return apiPrefix +} + +const createServerConsoleRequestHeaders = (context: ServerConsoleClientContext | undefined) => { + const requestHeaders = new Headers({ + Accept: 'application/json', + }) + + if (context?.cookie) + requestHeaders.set('cookie', context.cookie) + if (context?.csrfToken) + requestHeaders.set(CSRF_HEADER_NAME, context.csrfToken) + + return requestHeaders +} + +export const getServerConsoleClientContext = async (): Promise => { + const { cookies, headers } = await import('@/next/headers') + const requestHeaders = await headers() + const cookieStore = await cookies() + + return { + cookie: requestHeaders.get('cookie') || undefined, + csrfToken: cookieStore.get(CSRF_COOKIE_NAME())?.value, + } +} + +export const getServerConsoleRequestHeaders = async () => + createServerConsoleRequestHeaders(await getServerConsoleClientContext()) + +const serverConsoleLink = new OpenAPILink(consoleRouterContract, { + url: getServerConsoleApiPrefix, + headers: ({ context }) => createServerConsoleRequestHeaders(context), + fetch: (request, init) => { + if (request.body && !request.headers.has('content-type')) + request.headers.set('Content-Type', 'application/json') + + return globalThis.fetch(request, { + ...init, + cache: 'no-store', + }) + }, + interceptors: [ + onError((error) => { + console.error(error) + }), + ], +}) + +export const serverConsoleClient: JsonifiedClient> = createORPCClient(serverConsoleLink) + +export const serverConsoleQuery = createTanstackQueryUtils(serverConsoleClient, { + path: ['console'], +})