mirror of
https://github.com/langgenius/dify.git
synced 2026-06-07 16:32:01 +08:00
fix(web): prevent local cloud analytics script errors (#36420)
This commit is contained in:
parent
77333e57a7
commit
468cc19e68
@ -1023,14 +1023,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/base/ga/index.tsx": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
},
|
||||
"react-refresh/only-export-components": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/icons/src/public/avatar/index.ts": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 2
|
||||
|
||||
@ -3,7 +3,7 @@ import * as React from 'react'
|
||||
import { AppInitializer } from '@/app/components/app-initializer'
|
||||
import InSiteMessageNotification from '@/app/components/app/in-site-message/notification'
|
||||
import AmplitudeProvider from '@/app/components/base/amplitude'
|
||||
import GA, { GaType } from '@/app/components/base/ga'
|
||||
import { GoogleAnalyticsScripts } from '@/app/components/base/ga'
|
||||
import Zendesk from '@/app/components/base/zendesk'
|
||||
import { GotoAnything } from '@/app/components/goto-anything'
|
||||
import Header from '@/app/components/header'
|
||||
@ -19,7 +19,7 @@ import RoleRouteGuard from './role-route-guard'
|
||||
const Layout = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<>
|
||||
<GA gaType={GaType.admin} />
|
||||
<GoogleAnalyticsScripts />
|
||||
<AmplitudeProvider />
|
||||
<AppInitializer>
|
||||
<AppContextProvider>
|
||||
|
||||
@ -2,7 +2,7 @@ import type { ReactNode } from 'react'
|
||||
import * as React from 'react'
|
||||
import { AppInitializer } from '@/app/components/app-initializer'
|
||||
import AmplitudeProvider from '@/app/components/base/amplitude'
|
||||
import GA, { GaType } from '@/app/components/base/ga'
|
||||
import { GoogleAnalyticsScripts } from '@/app/components/base/ga'
|
||||
import HeaderWrapper from '@/app/components/header/header-wrapper'
|
||||
import { AppContextProvider } from '@/context/app-context-provider'
|
||||
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
|
||||
@ -13,7 +13,7 @@ import Header from './header'
|
||||
const Layout = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<>
|
||||
<GA gaType={GaType.admin} />
|
||||
<GoogleAnalyticsScripts />
|
||||
<AmplitudeProvider />
|
||||
<AppInitializer>
|
||||
<AppContextProvider>
|
||||
|
||||
@ -0,0 +1,141 @@
|
||||
import type { WorkflowOnlineUser, WorkflowOnlineUsersResponse } from '@/models/app'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useWorkflowOnlineUsers } from '../use-workflow-online-users'
|
||||
|
||||
type QueryOptions = {
|
||||
input: {
|
||||
body: {
|
||||
app_ids: string[]
|
||||
}
|
||||
}
|
||||
enabled: boolean
|
||||
select: (response?: WorkflowOnlineUsersResponse) => Record<string, WorkflowOnlineUser[]>
|
||||
refetchInterval: false | number
|
||||
}
|
||||
|
||||
const { mockUseQuery, mockQueryOptions } = vi.hoisted(() => ({
|
||||
mockUseQuery: vi.fn(),
|
||||
mockQueryOptions: vi.fn((options: QueryOptions) => options),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQuery: mockUseQuery,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
apps: {
|
||||
workflowOnlineUsers: {
|
||||
queryOptions: mockQueryOptions,
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const getLastQueryOptions = () => {
|
||||
const lastCall = mockQueryOptions.mock.lastCall
|
||||
if (!lastCall)
|
||||
throw new Error('workflowOnlineUsers.queryOptions was not called')
|
||||
return lastCall[0] as QueryOptions
|
||||
}
|
||||
|
||||
describe('useWorkflowOnlineUsers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseQuery.mockReturnValue({ data: undefined })
|
||||
})
|
||||
|
||||
describe('Query Options', () => {
|
||||
it('should disable query with a valid empty input when app ids are empty', () => {
|
||||
renderHook(() => useWorkflowOnlineUsers({
|
||||
appIds: [],
|
||||
enabled: true,
|
||||
}))
|
||||
|
||||
expect(mockQueryOptions).toHaveBeenCalledWith(expect.objectContaining({
|
||||
input: { body: { app_ids: [] } },
|
||||
enabled: false,
|
||||
refetchInterval: false,
|
||||
}))
|
||||
expect(mockUseQuery).toHaveBeenCalledWith(expect.objectContaining({
|
||||
input: { body: { app_ids: [] } },
|
||||
enabled: false,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should enable query and polling when collaboration is enabled with app ids', () => {
|
||||
renderHook(() => useWorkflowOnlineUsers({
|
||||
appIds: ['app-1', 'app-2'],
|
||||
enabled: true,
|
||||
}))
|
||||
|
||||
expect(mockQueryOptions).toHaveBeenCalledWith(expect.objectContaining({
|
||||
input: { body: { app_ids: ['app-1', 'app-2'] } },
|
||||
enabled: true,
|
||||
refetchInterval: 10000,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should disable query while preserving valid input when collaboration is disabled', () => {
|
||||
renderHook(() => useWorkflowOnlineUsers({
|
||||
appIds: ['app-1'],
|
||||
enabled: false,
|
||||
}))
|
||||
|
||||
expect(mockQueryOptions).toHaveBeenCalledWith(expect.objectContaining({
|
||||
input: { body: { app_ids: ['app-1'] } },
|
||||
enabled: false,
|
||||
refetchInterval: false,
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Response Mapping', () => {
|
||||
it('should normalize array response data by app id', () => {
|
||||
renderHook(() => useWorkflowOnlineUsers({
|
||||
appIds: ['app-1'],
|
||||
enabled: true,
|
||||
}))
|
||||
|
||||
const options = getLastQueryOptions()
|
||||
const user = { user_id: 'user-1', username: 'Alice' }
|
||||
|
||||
expect(options.select({
|
||||
data: [
|
||||
{ app_id: 'app-1', users: [user] },
|
||||
],
|
||||
})).toEqual({
|
||||
'app-1': [user],
|
||||
})
|
||||
})
|
||||
|
||||
it('should normalize record response data without changing user arrays', () => {
|
||||
renderHook(() => useWorkflowOnlineUsers({
|
||||
appIds: ['app-1'],
|
||||
enabled: true,
|
||||
}))
|
||||
|
||||
const options = getLastQueryOptions()
|
||||
const users = [{ user_id: 'user-1', username: 'Alice' }]
|
||||
|
||||
expect(options.select({
|
||||
data: {
|
||||
'app-1': users,
|
||||
},
|
||||
})).toEqual({
|
||||
'app-1': users,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Return Value', () => {
|
||||
it('should return an empty map when query data is unavailable', () => {
|
||||
const { result } = renderHook(() => useWorkflowOnlineUsers({
|
||||
appIds: ['app-1'],
|
||||
enabled: true,
|
||||
}))
|
||||
|
||||
expect(result.current.onlineUsersMap).toEqual({})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,5 +1,5 @@
|
||||
import type { WorkflowOnlineUser, WorkflowOnlineUsersResponse } from '@/models/app'
|
||||
import { skipToken, useQuery } from '@tanstack/react-query'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
type WorkflowOnlineUsersMap = Record<string, WorkflowOnlineUser[]>
|
||||
@ -36,9 +36,8 @@ export const useWorkflowOnlineUsers = ({
|
||||
}: UseWorkflowOnlineUsersParams) => {
|
||||
const shouldFetch = enabled && appIds.length > 0
|
||||
const { data: onlineUsersMap = {} } = useQuery(consoleQuery.apps.workflowOnlineUsers.queryOptions({
|
||||
input: shouldFetch
|
||||
? { body: { app_ids: appIds } }
|
||||
: skipToken,
|
||||
input: { body: { app_ids: appIds } },
|
||||
enabled: shouldFetch,
|
||||
select: normalizeWorkflowOnlineUsers,
|
||||
refetchInterval: shouldFetch ? 10000 : false,
|
||||
}))
|
||||
|
||||
@ -2,29 +2,24 @@ import type { ReactElement, ReactNode } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
type ConfigState = {
|
||||
isCeEdition: boolean
|
||||
isCloudEdition: boolean
|
||||
isProd: boolean
|
||||
}
|
||||
|
||||
type GaProps = {
|
||||
gaType: string
|
||||
}
|
||||
|
||||
type GaRenderFn = (props: GaProps) => Promise<ReactNode>
|
||||
type GaTypeValue = 'admin' | 'webapp'
|
||||
type GoogleAnalyticsScriptsRenderFn = () => Promise<ReactNode>
|
||||
|
||||
const { mockHeaders, mockHeadersGet, configState } = vi.hoisted(() => ({
|
||||
mockHeaders: vi.fn(),
|
||||
mockHeadersGet: vi.fn(),
|
||||
configState: ({
|
||||
isCeEdition: false,
|
||||
isCloudEdition: true,
|
||||
isProd: true,
|
||||
}) as ConfigState,
|
||||
}))
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
get IS_CE_EDITION() {
|
||||
return configState.isCeEdition
|
||||
get IS_CLOUD_EDITION() {
|
||||
return configState.isCloudEdition
|
||||
},
|
||||
get IS_PROD() {
|
||||
return configState.isProd
|
||||
@ -41,18 +36,18 @@ vi.mock('@/next/script', () => ({
|
||||
strategy,
|
||||
src,
|
||||
nonce,
|
||||
dangerouslySetInnerHTML,
|
||||
children,
|
||||
}: {
|
||||
id?: string
|
||||
strategy?: string
|
||||
src?: string
|
||||
nonce?: string
|
||||
dangerouslySetInnerHTML?: { __html?: string }
|
||||
children?: ReactNode
|
||||
}) => (
|
||||
<script
|
||||
data-testid="mock-next-script"
|
||||
data-id={id ?? ''}
|
||||
data-inline={dangerouslySetInnerHTML?.__html ?? ''}
|
||||
data-inline={typeof children === 'string' ? children : ''}
|
||||
data-nonce={nonce ?? ''}
|
||||
data-src={src ?? ''}
|
||||
data-strategy={strategy ?? ''}
|
||||
@ -62,24 +57,15 @@ vi.mock('@/next/script', () => ({
|
||||
|
||||
const loadComponent = async () => {
|
||||
const mod = await import('../index')
|
||||
// mod.default is either an async function (server component) or
|
||||
// a React.memo object whose .type is the async function.
|
||||
const rawExport = mod.default as unknown
|
||||
const renderer: GaRenderFn | undefined
|
||||
= typeof rawExport === 'function' ? (rawExport as GaRenderFn) : (rawExport as { type?: GaRenderFn }).type
|
||||
|
||||
if (!renderer)
|
||||
throw new Error('GA component is not callable in tests')
|
||||
|
||||
return {
|
||||
renderer,
|
||||
GaType: mod.GaType,
|
||||
renderer: mod.GoogleAnalyticsScripts as GoogleAnalyticsScriptsRenderFn,
|
||||
}
|
||||
}
|
||||
|
||||
const renderGA = async (gaType: GaTypeValue) => {
|
||||
const renderGoogleAnalyticsScripts = async () => {
|
||||
const { renderer } = await loadComponent()
|
||||
const element = await renderer({ gaType })
|
||||
const element = await renderer()
|
||||
if (!element)
|
||||
return { element }
|
||||
|
||||
@ -92,43 +78,57 @@ describe('GA', () => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetModules()
|
||||
|
||||
configState.isCeEdition = false
|
||||
configState.isCloudEdition = true
|
||||
configState.isProd = true
|
||||
|
||||
mockHeadersGet.mockReturnValue(`default-src 'self'; script-src 'self' 'nonce-test-nonce'`)
|
||||
mockHeadersGet.mockImplementation((name: string) => name === 'x-nonce' ? 'test-nonce' : null)
|
||||
mockHeaders.mockResolvedValue({
|
||||
get: mockHeadersGet,
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should return null when CE edition is enabled', async () => {
|
||||
configState.isCeEdition = true
|
||||
const { element } = await renderGA('admin')
|
||||
it('should return null when cloud edition is disabled', async () => {
|
||||
configState.isCloudEdition = false
|
||||
const { element } = await renderGoogleAnalyticsScripts()
|
||||
|
||||
expect(element).toBeNull()
|
||||
expect(mockHeaders).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render three script tags with admin GA id in production', async () => {
|
||||
await renderGA('admin')
|
||||
it('should return null when not in production', async () => {
|
||||
configState.isProd = false
|
||||
const { element } = await renderGoogleAnalyticsScripts()
|
||||
|
||||
expect(element).toBeNull()
|
||||
expect(mockHeaders).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render consent, CookieYes, and Google Analytics scripts in production', async () => {
|
||||
await renderGoogleAnalyticsScripts()
|
||||
|
||||
const scripts = screen.getAllByTestId('mock-next-script')
|
||||
expect(scripts).toHaveLength(3)
|
||||
expect(scripts).toHaveLength(4)
|
||||
|
||||
expect(mockHeaders).toHaveBeenCalledTimes(1)
|
||||
expect(mockHeadersGet).toHaveBeenCalledWith('content-security-policy')
|
||||
expect(mockHeadersGet).toHaveBeenCalledWith('x-nonce')
|
||||
|
||||
expect(scripts[0]).toHaveAttribute('data-id', 'ga-init')
|
||||
expect(scripts[0]).toHaveAttribute('data-id', 'google-consent-defaults')
|
||||
expect(scripts[0]).toHaveAttribute('data-strategy', 'afterInteractive')
|
||||
expect(scripts[0]).toHaveAttribute('data-inline', expect.stringContaining(`window.gtag('config', 'G-DM9497FN4V');`))
|
||||
expect(scripts[0]).toHaveAttribute('data-inline', expect.stringContaining(`window.gtag('consent', 'default'`))
|
||||
expect(scripts[0]).toHaveAttribute('data-inline', expect.stringContaining(`analytics_storage: 'denied'`))
|
||||
|
||||
expect(scripts[1]).toHaveAttribute('data-id', 'cookieyes')
|
||||
expect(scripts[1]).toHaveAttribute('data-strategy', 'afterInteractive')
|
||||
expect(scripts[1]).toHaveAttribute('data-src', 'https://www.googletagmanager.com/gtag/js?id=G-DM9497FN4V')
|
||||
expect(scripts[1]).toHaveAttribute('data-src', 'https://cdn-cookieyes.com/client_data/2a645945fcae53f8e025a2b1/script.js')
|
||||
|
||||
expect(scripts[2]).toHaveAttribute('data-id', 'cookieyes')
|
||||
expect(scripts[2]).toHaveAttribute('data-strategy', 'lazyOnload')
|
||||
expect(scripts[2]).toHaveAttribute('data-src', 'https://cdn-cookieyes.com/client_data/2a645945fcae53f8e025a2b1/script.js')
|
||||
expect(scripts[2]).toHaveAttribute('data-id', 'google-analytics')
|
||||
expect(scripts[2]).toHaveAttribute('data-strategy', 'afterInteractive')
|
||||
expect(scripts[2]).toHaveAttribute('data-src', 'https://www.googletagmanager.com/gtag/js?id=G-DM9497FN4V')
|
||||
|
||||
expect(scripts[3]).toHaveAttribute('data-id', 'google-analytics-init')
|
||||
expect(scripts[3]).toHaveAttribute('data-strategy', 'afterInteractive')
|
||||
expect(scripts[3]).toHaveAttribute('data-inline', expect.stringContaining(`window.gtag('config', 'G-DM9497FN4V');`))
|
||||
|
||||
scripts.forEach((script) => {
|
||||
expect(script).toHaveAttribute('data-nonce', 'test-nonce')
|
||||
@ -136,45 +136,10 @@ describe('GA', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should use webapp GA id when gaType is webapp', async () => {
|
||||
await renderGA('webapp')
|
||||
|
||||
const scripts = screen.getAllByTestId('mock-next-script')
|
||||
|
||||
expect(scripts[0]).toHaveAttribute('data-inline', expect.stringContaining(`window.gtag('config', 'G-2MFWXK7WYT');`))
|
||||
expect(scripts[1]).toHaveAttribute('data-src', 'https://www.googletagmanager.com/gtag/js?id=G-2MFWXK7WYT')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should not read headers and should omit nonce when not in production', async () => {
|
||||
configState.isProd = false
|
||||
await renderGA('admin')
|
||||
|
||||
const scripts = screen.getAllByTestId('mock-next-script')
|
||||
|
||||
expect(mockHeaders).not.toHaveBeenCalled()
|
||||
scripts.forEach((script) => {
|
||||
expect(script).toHaveAttribute('data-nonce', '')
|
||||
})
|
||||
})
|
||||
|
||||
it('should omit nonce when CSP header does not contain nonce token', async () => {
|
||||
mockHeadersGet.mockReturnValue(`default-src 'self'; script-src 'self'`)
|
||||
await renderGA('admin')
|
||||
|
||||
const scripts = screen.getAllByTestId('mock-next-script')
|
||||
|
||||
expect(mockHeaders).toHaveBeenCalledTimes(1)
|
||||
scripts.forEach((script) => {
|
||||
expect(script).toHaveAttribute('data-nonce', '')
|
||||
})
|
||||
})
|
||||
|
||||
it('should omit nonce when CSP header is null', async () => {
|
||||
it('should omit nonce when x-nonce header is missing', async () => {
|
||||
mockHeadersGet.mockReturnValue(null)
|
||||
await renderGA('admin')
|
||||
await renderGoogleAnalyticsScripts()
|
||||
|
||||
const scripts = screen.getAllByTestId('mock-next-script')
|
||||
|
||||
|
||||
@ -1,75 +1,57 @@
|
||||
import type { FC } from 'react'
|
||||
import * as React from 'react'
|
||||
import { IS_CE_EDITION, IS_PROD } from '@/config'
|
||||
import { IS_CLOUD_EDITION, IS_PROD } from '@/config'
|
||||
import { headers } from '@/next/headers'
|
||||
import Script from '@/next/script'
|
||||
|
||||
export enum GaType {
|
||||
admin = 'admin',
|
||||
webapp = 'webapp',
|
||||
}
|
||||
|
||||
const GA_MEASUREMENT_ID_ADMIN = 'G-DM9497FN4V'
|
||||
const GA_MEASUREMENT_ID_WEBAPP = 'G-2MFWXK7WYT'
|
||||
const GOOGLE_ANALYTICS_ID = 'G-DM9497FN4V'
|
||||
const GOOGLE_TAG_SCRIPT_SRC = `https://www.googletagmanager.com/gtag/js?id=${GOOGLE_ANALYTICS_ID}`
|
||||
const COOKIEYES_SCRIPT_SRC = 'https://cdn-cookieyes.com/client_data/2a645945fcae53f8e025a2b1/script.js'
|
||||
|
||||
const gaIdMaps = {
|
||||
[GaType.admin]: GA_MEASUREMENT_ID_ADMIN,
|
||||
[GaType.webapp]: GA_MEASUREMENT_ID_WEBAPP,
|
||||
}
|
||||
|
||||
type IGAProps = {
|
||||
gaType: GaType
|
||||
}
|
||||
|
||||
const extractNonceFromCSP = (cspHeader: string | null): string | undefined => {
|
||||
if (!cspHeader)
|
||||
return undefined
|
||||
const nonceMatch = /'nonce-([^']+)'/.exec(cspHeader)
|
||||
return nonceMatch ? nonceMatch[1] : undefined
|
||||
}
|
||||
|
||||
const GA: FC<IGAProps> = async ({
|
||||
gaType,
|
||||
}) => {
|
||||
if (IS_CE_EDITION)
|
||||
export async function GoogleAnalyticsScripts() {
|
||||
if (!IS_CLOUD_EDITION || !IS_PROD)
|
||||
return null
|
||||
|
||||
const cspHeader = IS_PROD
|
||||
? (await headers()).get('content-security-policy')
|
||||
: null
|
||||
const nonce = extractNonceFromCSP(cspHeader)
|
||||
const nonce = (await headers()).get('x-nonce') ?? undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Initialize dataLayer first */}
|
||||
<Script
|
||||
id="ga-init"
|
||||
id="google-consent-defaults"
|
||||
strategy="afterInteractive"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
window.gtag = function gtag(){window.dataLayer.push(arguments);};
|
||||
window.gtag('js', new Date());
|
||||
window.gtag('config', '${gaIdMaps[gaType]}');
|
||||
`,
|
||||
}}
|
||||
nonce={nonce}
|
||||
/>
|
||||
{/* Load GA script */}
|
||||
<Script
|
||||
strategy="afterInteractive"
|
||||
src={`https://www.googletagmanager.com/gtag/js?id=${gaIdMaps[gaType]}`}
|
||||
nonce={nonce}
|
||||
/>
|
||||
{/* Cookie banner */}
|
||||
>
|
||||
{`
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
window.gtag = window.gtag || function gtag(){window.dataLayer.push(arguments);};
|
||||
window.gtag('consent', 'default', {
|
||||
ad_storage: 'denied',
|
||||
ad_user_data: 'denied',
|
||||
ad_personalization: 'denied',
|
||||
analytics_storage: 'denied',
|
||||
});
|
||||
`}
|
||||
</Script>
|
||||
<Script
|
||||
id="cookieyes"
|
||||
strategy="lazyOnload"
|
||||
strategy="afterInteractive"
|
||||
src={COOKIEYES_SCRIPT_SRC}
|
||||
nonce={nonce}
|
||||
/>
|
||||
<Script
|
||||
id="google-analytics"
|
||||
strategy="afterInteractive"
|
||||
src={GOOGLE_TAG_SCRIPT_SRC}
|
||||
nonce={nonce}
|
||||
/>
|
||||
<Script
|
||||
id="google-analytics-init"
|
||||
strategy="afterInteractive"
|
||||
nonce={nonce}
|
||||
>
|
||||
{`
|
||||
window.gtag('js', new Date());
|
||||
window.gtag('config', '${GOOGLE_ANALYTICS_ID}');
|
||||
`}
|
||||
</Script>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default React.memo(GA)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user