mirror of
https://github.com/langgenius/dify.git
synced 2026-03-17 13:32:02 +00:00
Compare commits
1 Commits
yanli/phas
...
3-18-dev-w
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dacba93e00 |
@@ -12,7 +12,7 @@ NEXT_PUBLIC_API_PREFIX=http://localhost:5001/console/api
|
||||
# console or api domain.
|
||||
# example: http://udify.app/api
|
||||
NEXT_PUBLIC_PUBLIC_API_PREFIX=http://localhost:5001/api
|
||||
# Dev-only Hono proxy targets. The frontend keeps requesting http://localhost:5001 directly.
|
||||
# Dev-only Hono proxy targets. Set the api prefixes above to https://localhost:5001/... to start the proxy with HTTPS.
|
||||
HONO_PROXY_HOST=127.0.0.1
|
||||
HONO_PROXY_PORT=5001
|
||||
HONO_CONSOLE_API_PROXY_TARGET=
|
||||
|
||||
@@ -210,6 +210,7 @@
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@typescript-eslint/parser": "8.57.0",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260312.1",
|
||||
"@vitejs/plugin-basic-ssl": "2.2.0",
|
||||
"@vitejs/plugin-react": "6.0.0",
|
||||
"@vitejs/plugin-rsc": "0.5.21",
|
||||
"@vitest/coverage-v8": "4.1.0",
|
||||
|
||||
@@ -34,7 +34,16 @@ const toUpstreamCookieName = (cookieName: string) => {
|
||||
return `__Host-${cookieName}`
|
||||
}
|
||||
|
||||
const toLocalCookieName = (cookieName: string) => cookieName.replace(SECURE_COOKIE_PREFIX_PATTERN, '')
|
||||
const toLocalCookieName = (cookieName: string, options: LocalCookieRewriteOptions) => {
|
||||
if (options.localSecure)
|
||||
return cookieName
|
||||
|
||||
return cookieName.replace(SECURE_COOKIE_PREFIX_PATTERN, '')
|
||||
}
|
||||
|
||||
type LocalCookieRewriteOptions = {
|
||||
localSecure: boolean
|
||||
}
|
||||
|
||||
export const rewriteCookieHeaderForUpstream = (cookieHeader?: string) => {
|
||||
if (!cookieHeader)
|
||||
@@ -55,7 +64,10 @@ export const rewriteCookieHeaderForUpstream = (cookieHeader?: string) => {
|
||||
.join('; ')
|
||||
}
|
||||
|
||||
const rewriteSetCookieValueForLocal = (setCookieValue: string) => {
|
||||
const rewriteSetCookieValueForLocal = (
|
||||
setCookieValue: string,
|
||||
options: LocalCookieRewriteOptions,
|
||||
) => {
|
||||
const [rawCookiePair, ...rawAttributes] = setCookieValue.split(';')
|
||||
const separatorIndex = rawCookiePair.indexOf('=')
|
||||
|
||||
@@ -68,11 +80,11 @@ const rewriteSetCookieValueForLocal = (setCookieValue: string) => {
|
||||
.map(attribute => attribute.trim())
|
||||
.filter(attribute =>
|
||||
!COOKIE_DOMAIN_PATTERN.test(attribute)
|
||||
&& !COOKIE_SECURE_PATTERN.test(attribute)
|
||||
&& !COOKIE_PARTITIONED_PATTERN.test(attribute),
|
||||
&& (options.localSecure || !COOKIE_SECURE_PATTERN.test(attribute))
|
||||
&& (options.localSecure || !COOKIE_PARTITIONED_PATTERN.test(attribute)),
|
||||
)
|
||||
.map((attribute) => {
|
||||
if (SAME_SITE_NONE_PATTERN.test(attribute))
|
||||
if (!options.localSecure && SAME_SITE_NONE_PATTERN.test(attribute))
|
||||
return 'SameSite=Lax'
|
||||
|
||||
if (COOKIE_PATH_PATTERN.test(attribute))
|
||||
@@ -81,10 +93,13 @@ const rewriteSetCookieValueForLocal = (setCookieValue: string) => {
|
||||
return attribute
|
||||
})
|
||||
|
||||
return [`${toLocalCookieName(cookieName)}=${cookieValue}`, ...rewrittenAttributes].join('; ')
|
||||
return [`${toLocalCookieName(cookieName, options)}=${cookieValue}`, ...rewrittenAttributes].join('; ')
|
||||
}
|
||||
|
||||
export const rewriteSetCookieHeadersForLocal = (setCookieHeaders?: string | string[]): string[] | undefined => {
|
||||
export const rewriteSetCookieHeadersForLocal = (
|
||||
setCookieHeaders: string | string[] | undefined,
|
||||
options: LocalCookieRewriteOptions,
|
||||
): string[] | undefined => {
|
||||
if (!setCookieHeaders)
|
||||
return undefined
|
||||
|
||||
@@ -92,7 +107,7 @@ export const rewriteSetCookieHeadersForLocal = (setCookieHeaders?: string | stri
|
||||
? setCookieHeaders
|
||||
: [setCookieHeaders]
|
||||
|
||||
return normalizedHeaders.map(rewriteSetCookieValueForLocal)
|
||||
return normalizedHeaders.map(setCookieValue => rewriteSetCookieValueForLocal(setCookieValue, options))
|
||||
}
|
||||
|
||||
export { DEFAULT_PROXY_TARGET }
|
||||
|
||||
21
web/plugins/dev-proxy/protocol.ts
Normal file
21
web/plugins/dev-proxy/protocol.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export type DevProxyProtocolEnv = Partial<Record<
|
||||
| 'NEXT_PUBLIC_API_PREFIX'
|
||||
| 'NEXT_PUBLIC_PUBLIC_API_PREFIX',
|
||||
string
|
||||
>>
|
||||
|
||||
const isHttpsUrl = (value?: string) => {
|
||||
if (!value)
|
||||
return false
|
||||
|
||||
try {
|
||||
return new URL(value).protocol === 'https:'
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const shouldUseHttpsForDevProxy = (env: DevProxyProtocolEnv = {}) => {
|
||||
return isHttpsUrl(env.NEXT_PUBLIC_API_PREFIX) || isHttpsUrl(env.NEXT_PUBLIC_PUBLIC_API_PREFIX)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildUpstreamUrl, createDevProxyApp, isAllowedDevOrigin, resolveDevProxyTargets } from './server'
|
||||
import { buildUpstreamUrl, createDevProxyApp, isAllowedDevOrigin, resolveDevProxyTargets, shouldUseHttpsForDevProxy } from './server'
|
||||
|
||||
describe('dev proxy server', () => {
|
||||
beforeEach(() => {
|
||||
@@ -19,6 +19,21 @@ describe('dev proxy server', () => {
|
||||
expect(targets.publicApiTarget).toBe('https://public.example.com')
|
||||
})
|
||||
|
||||
// Scenario: the local dev proxy should switch to https when api prefixes are configured with https.
|
||||
it('should enable https for the local dev proxy when api prefixes use https', () => {
|
||||
// Assert
|
||||
expect(shouldUseHttpsForDevProxy({
|
||||
NEXT_PUBLIC_API_PREFIX: 'https://localhost:5001/console/api',
|
||||
})).toBe(true)
|
||||
expect(shouldUseHttpsForDevProxy({
|
||||
NEXT_PUBLIC_PUBLIC_API_PREFIX: 'https://localhost:5001/api',
|
||||
})).toBe(true)
|
||||
expect(shouldUseHttpsForDevProxy({
|
||||
NEXT_PUBLIC_API_PREFIX: 'http://localhost:5001/console/api',
|
||||
NEXT_PUBLIC_PUBLIC_API_PREFIX: 'http://localhost:5001/api',
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
// Scenario: target paths should not be duplicated when the incoming route already includes them.
|
||||
it('should preserve prefixed targets when building upstream URLs', () => {
|
||||
// Act
|
||||
@@ -32,6 +47,7 @@ describe('dev proxy server', () => {
|
||||
it('should only allow local development origins', () => {
|
||||
// Assert
|
||||
expect(isAllowedDevOrigin('http://localhost:3000')).toBe(true)
|
||||
expect(isAllowedDevOrigin('https://localhost:3000')).toBe(true)
|
||||
expect(isAllowedDevOrigin('http://127.0.0.1:3000')).toBe(true)
|
||||
expect(isAllowedDevOrigin('https://example.com')).toBe(false)
|
||||
})
|
||||
@@ -86,6 +102,39 @@ describe('dev proxy server', () => {
|
||||
])
|
||||
})
|
||||
|
||||
// Scenario: secure local proxy responses should keep secure cross-site cookie attributes intact.
|
||||
it('should preserve secure cookie attributes when the local proxy is https', async () => {
|
||||
// Arrange
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response('ok', {
|
||||
status: 200,
|
||||
headers: [
|
||||
['set-cookie', '__Host-access_token=abc; Path=/console/api; Domain=cloud.dify.ai; Secure; SameSite=None; Partitioned'],
|
||||
['set-cookie', '__Host-csrf_token=csrf; Path=/console/api; Domain=cloud.dify.ai; Secure; SameSite=None'],
|
||||
],
|
||||
}))
|
||||
const app = createDevProxyApp({
|
||||
consoleApiTarget: 'https://cloud.dify.ai',
|
||||
publicApiTarget: 'https://public.dify.ai',
|
||||
fetchImpl,
|
||||
})
|
||||
|
||||
// Act
|
||||
const response = await app.request('https://127.0.0.1:5001/console/api/apps?page=1', {
|
||||
headers: {
|
||||
Origin: 'https://localhost:3000',
|
||||
Cookie: 'access_token=abc',
|
||||
},
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(response.headers.getSetCookie()).toEqual([
|
||||
'__Host-access_token=abc; Path=/; Secure; SameSite=None; Partitioned',
|
||||
'__Host-csrf_token=csrf; Path=/; Secure; SameSite=None',
|
||||
])
|
||||
expect(response.headers.get('access-control-allow-origin')).toBe('https://localhost:3000')
|
||||
expect(response.headers.get('access-control-allow-credentials')).toBe('true')
|
||||
})
|
||||
|
||||
// Scenario: preflight requests should advertise allowed headers for credentialed cross-origin calls.
|
||||
it('should answer CORS preflight requests', async () => {
|
||||
// Arrange
|
||||
|
||||
@@ -2,10 +2,16 @@ import type { Context, Hono } from 'hono'
|
||||
import { Hono as HonoApp } from 'hono'
|
||||
import { DEFAULT_PROXY_TARGET, rewriteCookieHeaderForUpstream, rewriteSetCookieHeadersForLocal } from './cookies'
|
||||
|
||||
export { shouldUseHttpsForDevProxy } from './protocol'
|
||||
|
||||
type DevProxyEnv = Partial<Record<
|
||||
| 'HONO_CONSOLE_API_PROXY_TARGET'
|
||||
| 'HONO_PUBLIC_API_PROXY_TARGET',
|
||||
string
|
||||
> & Record<
|
||||
| 'NEXT_PUBLIC_API_PREFIX'
|
||||
| 'NEXT_PUBLIC_PUBLIC_API_PREFIX',
|
||||
string | undefined
|
||||
>>
|
||||
|
||||
export type DevProxyTargets = {
|
||||
@@ -93,11 +99,15 @@ const createProxyRequestHeaders = (request: Request, targetUrl: URL) => {
|
||||
return headers
|
||||
}
|
||||
|
||||
const createUpstreamResponseHeaders = (response: Response, requestOrigin?: string | null) => {
|
||||
const createUpstreamResponseHeaders = (
|
||||
response: Response,
|
||||
requestOrigin: string | null | undefined,
|
||||
localSecure: boolean,
|
||||
) => {
|
||||
const headers = new Headers(response.headers)
|
||||
RESPONSE_HEADERS_TO_DROP.forEach(header => headers.delete(header))
|
||||
|
||||
const rewrittenSetCookies = rewriteSetCookieHeadersForLocal(response.headers.getSetCookie())
|
||||
const rewrittenSetCookies = rewriteSetCookieHeadersForLocal(response.headers.getSetCookie(), { localSecure })
|
||||
rewrittenSetCookies?.forEach((cookie) => {
|
||||
headers.append('set-cookie', cookie)
|
||||
})
|
||||
@@ -126,7 +136,11 @@ const proxyRequest = async (
|
||||
}
|
||||
|
||||
const upstreamResponse = await fetchImpl(targetUrl, requestInit)
|
||||
const responseHeaders = createUpstreamResponseHeaders(upstreamResponse, context.req.header('origin'))
|
||||
const responseHeaders = createUpstreamResponseHeaders(
|
||||
upstreamResponse,
|
||||
context.req.header('origin'),
|
||||
requestUrl.protocol === 'https:',
|
||||
)
|
||||
|
||||
return new Response(upstreamResponse.body, {
|
||||
status: upstreamResponse.status,
|
||||
|
||||
13
web/pnpm-lock.yaml
generated
13
web/pnpm-lock.yaml
generated
@@ -512,6 +512,9 @@ importers:
|
||||
'@typescript/native-preview':
|
||||
specifier: 7.0.0-dev.20260312.1
|
||||
version: 7.0.0-dev.20260312.1
|
||||
'@vitejs/plugin-basic-ssl':
|
||||
specifier: 2.2.0
|
||||
version: 2.2.0(@voidzero-dev/vite-plus-core@0.1.11(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))
|
||||
'@vitejs/plugin-react':
|
||||
specifier: 6.0.0
|
||||
version: 6.0.0(@voidzero-dev/vite-plus-core@0.1.11(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))
|
||||
@@ -3603,6 +3606,12 @@ packages:
|
||||
resolution: {integrity: sha512-hBcWIOppZV14bi+eAmCZj8Elj8hVSUZJTpf1lgGBhVD85pervzQ1poM/qYfFUlPraYSZYP+ASg6To5BwYmUSGQ==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
'@vitejs/plugin-basic-ssl@2.2.0':
|
||||
resolution: {integrity: sha512-nmyQ1HGRkfUxjsv3jw0+hMhEdZdrtkvMTdkzRUaRWfiO6PCWw2V2Pz3gldCq96Tn9S8htcgdTxw/gmbLLEbfYw==}
|
||||
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||
peerDependencies:
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
|
||||
'@vitejs/plugin-react@5.2.0':
|
||||
resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -11030,6 +11039,10 @@ snapshots:
|
||||
'@resvg/resvg-wasm': 2.4.0
|
||||
satori: 0.16.0
|
||||
|
||||
'@vitejs/plugin-basic-ssl@2.2.0(@voidzero-dev/vite-plus-core@0.1.11(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
vite: '@voidzero-dev/vite-plus-core@0.1.11(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)'
|
||||
|
||||
'@vitejs/plugin-react@5.2.0(@voidzero-dev/vite-plus-core@0.1.11(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { createSecureServer } from 'node:http2'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { serve } from '@hono/node-server'
|
||||
import { getCertificate } from '@vitejs/plugin-basic-ssl'
|
||||
import { loadEnv } from 'vite'
|
||||
import { createDevProxyApp, resolveDevProxyTargets } from '../plugins/dev-proxy/server'
|
||||
import { createDevProxyApp, resolveDevProxyTargets, shouldUseHttpsForDevProxy } from '../plugins/dev-proxy/server'
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const mode = process.env.MODE || process.env.NODE_ENV || 'development'
|
||||
@@ -11,11 +13,33 @@ const env = loadEnv(mode, projectRoot, '')
|
||||
const host = env.HONO_PROXY_HOST || '127.0.0.1'
|
||||
const port = Number(env.HONO_PROXY_PORT || 5001)
|
||||
const app = createDevProxyApp(resolveDevProxyTargets(env))
|
||||
const useHttps = shouldUseHttpsForDevProxy(env)
|
||||
|
||||
serve({
|
||||
fetch: app.fetch,
|
||||
hostname: host,
|
||||
port,
|
||||
})
|
||||
if (useHttps) {
|
||||
const certificate = await getCertificate(
|
||||
path.join(projectRoot, 'node_modules/.vite/basic-ssl'),
|
||||
'localhost',
|
||||
Array.from(new Set(['localhost', '127.0.0.1', host])),
|
||||
)
|
||||
|
||||
console.log(`[dev-hono-proxy] listening on http://${host}:${port}`)
|
||||
serve({
|
||||
fetch: app.fetch,
|
||||
hostname: host,
|
||||
port,
|
||||
createServer: createSecureServer,
|
||||
serverOptions: {
|
||||
allowHTTP1: true,
|
||||
cert: certificate,
|
||||
key: certificate,
|
||||
},
|
||||
})
|
||||
}
|
||||
else {
|
||||
serve({
|
||||
fetch: app.fetch,
|
||||
hostname: host,
|
||||
port,
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`[dev-hono-proxy] listening on ${useHttps ? 'https' : 'http'}://${host}:${port}`)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import basicSsl from '@vitejs/plugin-basic-ssl'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import vinext from 'vinext'
|
||||
import { loadEnv } from 'vite'
|
||||
import Inspect from 'vite-plugin-inspect'
|
||||
import { defineConfig } from 'vite-plus'
|
||||
import { shouldUseHttpsForDevProxy } from './plugins/dev-proxy/protocol'
|
||||
import { createCodeInspectorPlugin, createForceInspectorClientInjectionPlugin } from './plugins/vite/code-inspector'
|
||||
import { customI18nHmrPlugin } from './plugins/vite/custom-i18n-hmr'
|
||||
import { nextStaticImageTestPlugin } from './plugins/vite/next-static-image-test'
|
||||
@@ -21,6 +24,8 @@ export default defineConfig(({ mode }) => {
|
||||
const isTest = mode === 'test'
|
||||
const isStorybook = process.env.STORYBOOK === 'true'
|
||||
|| process.argv.some(arg => arg.toLowerCase().includes('storybook'))
|
||||
const env = loadEnv(mode, projectRoot, '')
|
||||
const useHttpsForDevServer = shouldUseHttpsForDevProxy(env)
|
||||
const isAppComponentsCoverage = coverageScope === 'app-components'
|
||||
const excludedComponentCoverageFiles = isAppComponentsCoverage
|
||||
? collectComponentCoverageExcludedFiles(path.join(projectRoot, 'app/components'), { pathPrefix: 'app/components' })
|
||||
@@ -57,6 +62,7 @@ export default defineConfig(({ mode }) => {
|
||||
react(),
|
||||
vinext({ react: false }),
|
||||
customI18nHmrPlugin({ injectTarget: browserInitializerInjectTarget }),
|
||||
...(useHttpsForDevServer ? [basicSsl()] : []),
|
||||
// reactGrabOpenFilePlugin({
|
||||
// injectTarget: browserInitializerInjectTarget,
|
||||
// projectRoot,
|
||||
|
||||
Reference in New Issue
Block a user