diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c7a7c2e5..5e168ea54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ and this project adheres to 🔧(helm) add option to disable default tls setting by @dominikkaminski #519 +## Changed + +- 🏗️(yjs-server) organize yjs server #528 +- ♻️(frontend) better separation collaboration process #528 + ## [1.10.0] - 2024-12-17 diff --git a/src/frontend/apps/e2e/__tests__/app-impress/common.ts b/src/frontend/apps/e2e/__tests__/app-impress/common.ts index 900d32651..8408e38d7 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/common.ts @@ -41,7 +41,7 @@ export const createDoc = async ( .click(); await page.getByRole('heading', { name: 'Untitled document' }).click(); - await page.keyboard.type(randomDocs[i]); + await page.keyboard.type(randomDocs[i], { delay: 100 }); await page.getByText('Created at ').click(); } diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx index 935e193ac..bd165df1e 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx @@ -10,7 +10,7 @@ import { DocHeader } from '@/features/docs/doc-header'; import { Doc, base64ToBlocknoteXmlFragment, - useDocStore, + useProviderStore, } from '@/features/docs/doc-management'; import { Versions, useDocVersion } from '@/features/docs/doc-versioning/'; import { useResponsiveStore } from '@/stores'; @@ -33,8 +33,7 @@ export const DocEditor = ({ doc }: DocEditorProps) => { const { colorsTokens } = useCunninghamTheme(); - const { providers } = useDocStore(); - const provider = providers?.[doc.id]; + const { provider } = useProviderStore(); if (!provider) { return null; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/index.ts b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/index.ts index 7697d915a..b352e1890 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/index.ts @@ -1 +1,2 @@ +export * from './useCollaboration'; export * from './useTrans'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useCollaboration.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useCollaboration.tsx new file mode 100644 index 000000000..848ccd005 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useCollaboration.tsx @@ -0,0 +1,35 @@ +import { useEffect } from 'react'; + +import { useCollaborationUrl } from '@/core/config'; +import { useBroadcastStore } from '@/stores'; + +import { useProviderStore } from '../stores/useProviderStore'; +import { Base64 } from '../types'; + +export const useCollaboration = (room?: string, initialContent?: Base64) => { + const collaborationUrl = useCollaborationUrl(room); + const { setBroadcastProvider } = useBroadcastStore(); + const { provider, createProvider, destroyProvider } = useProviderStore(); + + useEffect(() => { + if (!room || !collaborationUrl || provider) { + return; + } + + const newProvider = createProvider(collaborationUrl, room, initialContent); + setBroadcastProvider(newProvider); + }, [ + provider, + collaborationUrl, + room, + initialContent, + createProvider, + setBroadcastProvider, + ]); + + useEffect(() => { + return () => { + destroyProvider(); + }; + }, [destroyProvider]); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/stores/index.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/stores/index.tsx index e742cba56..9926e4e32 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/stores/index.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/stores/index.tsx @@ -1 +1,2 @@ export * from './useDocStore'; +export * from './useProviderStore'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/stores/useDocStore.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/stores/useDocStore.tsx index fbc4de4cc..449995a75 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/stores/useDocStore.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/stores/useDocStore.tsx @@ -1,53 +1,14 @@ -import { HocuspocusProvider } from '@hocuspocus/provider'; -import * as Y from 'yjs'; import { create } from 'zustand'; -import { Base64, Doc } from '@/features/docs/doc-management'; +import { Doc } from '@/features/docs/doc-management'; export interface UseDocStore { currentDoc?: Doc; - providers: { - [storeId: string]: HocuspocusProvider; - }; - createProvider: ( - providerUrl: string, - storeId: string, - initialDoc: Base64, - ) => HocuspocusProvider; - setProviders: (storeId: string, providers: HocuspocusProvider) => void; setCurrentDoc: (doc: Doc | undefined) => void; } -export const useDocStore = create<UseDocStore>((set, get) => ({ +export const useDocStore = create<UseDocStore>((set) => ({ currentDoc: undefined, - providers: {}, - createProvider: (providerUrl, storeId, initialDoc) => { - const doc = new Y.Doc({ - guid: storeId, - }); - - if (initialDoc) { - Y.applyUpdate(doc, Buffer.from(initialDoc, 'base64')); - } - - const provider = new HocuspocusProvider({ - url: providerUrl, - name: storeId, - document: doc, - }); - - get().setProviders(storeId, provider); - - return provider; - }, - setProviders: (storeId, provider) => { - set(({ providers }) => ({ - providers: { - ...providers, - [storeId]: provider, - }, - })); - }, setCurrentDoc: (doc) => { set({ currentDoc: doc }); }, diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/stores/useProviderStore.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/stores/useProviderStore.tsx new file mode 100644 index 000000000..a638045a1 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-management/stores/useProviderStore.tsx @@ -0,0 +1,52 @@ +import { HocuspocusProvider } from '@hocuspocus/provider'; +import * as Y from 'yjs'; +import { create } from 'zustand'; + +import { Base64 } from '@/features/docs/doc-management'; + +export interface UseCollaborationStore { + createProvider: ( + providerUrl: string, + storeId: string, + initialDoc?: Base64, + ) => HocuspocusProvider; + destroyProvider: () => void; + provider: HocuspocusProvider | undefined; +} + +const defaultValues = { + provider: undefined, +}; + +export const useProviderStore = create<UseCollaborationStore>((set, get) => ({ + ...defaultValues, + createProvider: (wsUrl, storeId, initialDoc) => { + const doc = new Y.Doc({ + guid: storeId, + }); + + if (initialDoc) { + Y.applyUpdate(doc, Buffer.from(initialDoc, 'base64')); + } + + const provider = new HocuspocusProvider({ + url: wsUrl, + name: storeId, + document: doc, + }); + + set({ + provider, + }); + + return provider; + }, + destroyProvider: () => { + const provider = get().provider; + if (provider) { + provider.destroy(); + } + + set(defaultValues); + }, +})); diff --git a/src/frontend/apps/impress/src/features/docs/doc-versioning/components/ModalVersion.tsx b/src/frontend/apps/impress/src/features/docs/doc-versioning/components/ModalVersion.tsx index 8ffba2db3..d1f6588c0 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-versioning/components/ModalVersion.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-versioning/components/ModalVersion.tsx @@ -13,9 +13,9 @@ import { Box, Text } from '@/components'; import { Doc, base64ToYDoc, - useDocStore, + useProviderStore, useUpdateDoc, -} from '@/features/docs/doc-management'; +} from '@/features/docs/doc-management/'; import { useDocVersion } from '../api'; import { KEY_LIST_DOC_VERSIONS } from '../api/useDocVersions'; @@ -40,7 +40,7 @@ export const ModalVersion = ({ const { t } = useTranslation(); const { toast } = useToastProvider(); const { push } = useRouter(); - const { providers } = useDocStore(); + const { provider } = useProviderStore(); const { mutate: updateDoc } = useUpdateDoc({ listInvalideQueries: [KEY_LIST_DOC_VERSIONS], onSuccess: () => { @@ -49,14 +49,14 @@ export const ModalVersion = ({ void push(`/docs/${docId}`); }; - if (!providers?.[docId] || !version?.content) { + if (!provider || !version?.content) { onDisplaySuccess(); return; } revertUpdate( - providers[docId].document, - providers[docId].document, + provider.document, + provider.document, base64ToYDoc(version.content), ); diff --git a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx index a51c2bca4..a84119ca5 100644 --- a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx +++ b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx @@ -6,10 +6,15 @@ import { useEffect, useState } from 'react'; import { Box, Text } from '@/components'; import { TextErrors } from '@/components/TextErrors'; -import { useCollaborationUrl } from '@/core'; import { useAuthStore } from '@/core/auth'; import { DocEditor } from '@/features/docs/doc-editor'; -import { KEY_DOC, useDoc, useDocStore } from '@/features/docs/doc-management'; +import { + Doc, + KEY_DOC, + useCollaboration, + useDoc, + useDocStore, +} from '@/features/docs/doc-management/'; import { MainLayout } from '@/layouts'; import { useBroadcastStore } from '@/stores'; import { NextPageWithLayout } from '@/types/next'; @@ -41,14 +46,25 @@ interface DocProps { const DocPage = ({ id }: DocProps) => { const { login } = useAuthStore(); - const { data: docQuery, isError, error } = useDoc({ id }); - const [doc, setDoc] = useState(docQuery); - const { setCurrentDoc, createProvider, providers } = useDocStore(); - const { setBroadcastProvider, addTask } = useBroadcastStore(); + const { + data: docQuery, + isError, + isFetching, + error, + } = useDoc( + { id }, + { + staleTime: 0, + queryKey: [KEY_DOC, { id }], + }, + ); + + const [doc, setDoc] = useState<Doc>(); + const { setCurrentDoc } = useDocStore(); + const { addTask } = useBroadcastStore(); const queryClient = useQueryClient(); const { replace } = useRouter(); - const provider = providers?.[id]; - const collaborationUrl = useCollaborationUrl(doc?.id); + useCollaboration(doc?.id, doc?.content); useEffect(() => { if (doc?.title) { @@ -59,26 +75,13 @@ const DocPage = ({ id }: DocProps) => { }, [doc?.title]); useEffect(() => { - if (!docQuery) { + if (!docQuery || isFetching) { return; } setDoc(docQuery); setCurrentDoc(docQuery); - }, [docQuery, setCurrentDoc]); - - useEffect(() => { - if (!doc?.id || !collaborationUrl) { - return; - } - - let newProvider = provider; - if (!provider || provider.document.guid !== doc.id) { - newProvider = createProvider(collaborationUrl, doc.id, doc.content); - } - - setBroadcastProvider(newProvider); - }, [createProvider, doc, provider, setBroadcastProvider, collaborationUrl]); + }, [docQuery, setCurrentDoc, isFetching]); /** * We add a broadcast task to reset the query cache diff --git a/src/frontend/apps/impress/src/stores/useBroadcastStore.tsx b/src/frontend/apps/impress/src/stores/useBroadcastStore.tsx index a6230ea6c..7e8812f76 100644 --- a/src/frontend/apps/impress/src/stores/useBroadcastStore.tsx +++ b/src/frontend/apps/impress/src/stores/useBroadcastStore.tsx @@ -8,7 +8,20 @@ interface BroadcastState { getBroadcastProvider: () => HocuspocusProvider | undefined; provider?: HocuspocusProvider; setBroadcastProvider: (provider: HocuspocusProvider) => void; - tasks: { [taskLabel: string]: Y.Array<string> }; + setTask: ( + taskLabel: string, + task: Y.Array<string>, + action: () => void, + ) => void; + tasks: { + [taskLabel: string]: { + task: Y.Array<string>; + observer: ( + event: Y.YArrayEvent<string>, + transaction: Y.Transaction, + ) => void; + }; + }; } export const useBroadcastStore = create<BroadcastState>((set, get) => ({ @@ -25,26 +38,47 @@ export const useBroadcastStore = create<BroadcastState>((set, get) => ({ return provider; }, addTask: (taskLabel, action) => { - const taskExistAlready = get().tasks[taskLabel]; const provider = get().getBroadcastProvider(); - if (taskExistAlready || !provider) { + if (!provider) { + return; + } + + const existingTask = get().tasks[taskLabel]; + if (existingTask) { + existingTask.task.unobserve(existingTask.observer); + get().setTask(taskLabel, existingTask.task, action); return; } const task = provider.document.getArray<string>(taskLabel); - task.observe(() => { - action(); - }); + get().setTask(taskLabel, task, action); + }, + setTask: (taskLabel: string, task: Y.Array<string>, action: () => void) => { + let isInitializing = true; + const observer = () => { + if (!isInitializing) { + action(); + } + }; + + task.observe(observer); + + setTimeout(() => { + isInitializing = false; + }, 1000); set((state) => ({ tasks: { ...state.tasks, - [taskLabel]: task, + [taskLabel]: { + task, + observer, + }, }, })); }, broadcast: (taskLabel) => { - const task = get().tasks[taskLabel]; + const { task } = get().tasks[taskLabel]; if (!task) { console.warn(`Task ${taskLabel} is not defined`); return; diff --git a/src/frontend/servers/y-provider/__tests__/collaborationResetConnections.test.ts b/src/frontend/servers/y-provider/__tests__/collaborationResetConnections.test.ts new file mode 100644 index 000000000..023c09782 --- /dev/null +++ b/src/frontend/servers/y-provider/__tests__/collaborationResetConnections.test.ts @@ -0,0 +1,66 @@ +import request from 'supertest'; + +const port = 5555; +const origin = 'http://localhost:3000'; + +jest.mock('../src/env', () => { + return { + PORT: port, + COLLABORATION_SERVER_ORIGIN: origin, + COLLABORATION_SERVER_SECRET: 'test-secret-api-key', + }; +}); + +console.error = jest.fn(); + +import { hocusPocusServer } from '@/servers/hocusPocusServer'; + +import { initServer } from '../src/servers/appServer'; + +const { app, server } = initServer(); + +describe('Server Tests', () => { + afterAll(() => { + server.close(); + }); + + test('POST /collaboration/api/reset-connections?room=[ROOM_ID] with incorrect API key should return 403', async () => { + const response = await request(app as any) + .post('/collaboration/api/reset-connections/?room=test-room') + .set('Origin', origin) + .set('Authorization', 'wrong-api-key'); + + expect(response.status).toBe(403); + expect(response.body.error).toBe('Forbidden: Invalid API Key'); + }); + + test('POST /collaboration/api/reset-connections?room=[ROOM_ID] failed if room not indicated', async () => { + const response = await request(app as any) + .post('/collaboration/api/reset-connections/') + .set('Origin', origin) + .set('Authorization', 'test-secret-api-key') + .send({ document_id: 'test-document' }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Room name not provided'); + }); + + test('POST /collaboration/api/reset-connections?room=[ROOM_ID] with correct API key should reset connections', async () => { + // eslint-disable-next-line jest/unbound-method + const { closeConnections } = hocusPocusServer; + const mockHandleConnection = jest.fn(); + (hocusPocusServer.closeConnections as jest.Mock) = mockHandleConnection; + + const response = await request(app as any) + .post('/collaboration/api/reset-connections?room=test-room') + .set('Origin', origin) + .set('Authorization', 'test-secret-api-key'); + + expect(response.status).toBe(200); + expect(response.body.message).toBe('Connections reset'); + + expect(mockHandleConnection).toHaveBeenCalled(); + mockHandleConnection.mockClear(); + hocusPocusServer.closeConnections = closeConnections; + }); +}); diff --git a/src/frontend/servers/y-provider/__tests__/convertMarkdown.test.ts b/src/frontend/servers/y-provider/__tests__/convertMarkdown.test.ts new file mode 100644 index 000000000..ed4765b2e --- /dev/null +++ b/src/frontend/servers/y-provider/__tests__/convertMarkdown.test.ts @@ -0,0 +1,66 @@ +import request from 'supertest'; + +const port = 5556; +const origin = 'http://localhost:3000'; + +jest.mock('../src/env', () => { + return { + PORT: port, + COLLABORATION_SERVER_ORIGIN: origin, + Y_PROVIDER_API_KEY: 'yprovider-api-key', + }; +}); + +import { initServer } from '../src/servers/appServer'; + +console.error = jest.fn(); +const { app, server } = initServer(); + +describe('Server Tests', () => { + afterAll(() => { + server.close(); + }); + + test('POST /api/convert-markdown with incorrect API key should return 403', async () => { + const response = await request(app as any) + .post('/api/convert-markdown') + .set('Origin', origin) + .set('Authorization', 'wrong-api-key'); + + expect(response.status).toBe(403); + expect(response.body.error).toBe('Forbidden: Invalid API Key'); + }); + + test('POST /api/convert-markdown with a Bearer token', async () => { + const response = await request(app as any) + .post('/api/convert-markdown') + .set('Origin', origin) + .set('Authorization', 'Bearer test-secret-api-key'); + + // Warning: Changing the authorization header to Bearer token format will break backend compatibility with this microservice. + expect(response.status).toBe(403); + }); + + test('POST /api/convert-markdown with missing body param content', async () => { + const response = await request(app as any) + .post('/api/convert-markdown') + .set('Origin', origin) + .set('Authorization', 'yprovider-api-key'); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Invalid request: missing content'); + }); + + test('POST /api/convert-markdown with body param content being an empty string', async () => { + const response = await request(app as any) + .post('/api/convert-markdown') + .set('Origin', origin) + .set('Authorization', 'yprovider-api-key') + .send({ + content: '', + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Invalid request: missing content'); + }); +}); diff --git a/src/frontend/servers/y-provider/__tests__/hocusPocusWS.test.ts b/src/frontend/servers/y-provider/__tests__/hocusPocusWS.test.ts new file mode 100644 index 000000000..6499c31bd --- /dev/null +++ b/src/frontend/servers/y-provider/__tests__/hocusPocusWS.test.ts @@ -0,0 +1,169 @@ +import { + HocuspocusProvider, + HocuspocusProviderWebsocket, +} from '@hocuspocus/provider'; +import WebSocket from 'ws'; + +const port = 5559; +const portWS = 6666; +const origin = 'http://localhost:3000'; + +jest.mock('../src/env', () => { + return { + PORT: port, + COLLABORATION_SERVER_ORIGIN: origin, + COLLABORATION_SERVER_SECRET: 'test-secret-api-key', + }; +}); + +console.error = jest.fn(); + +import { hocusPocusServer } from '@/servers/hocusPocusServer'; + +import { promiseDone } from '../src/helpers'; +import { initServer } from '../src/servers/appServer'; + +const { server } = initServer(); + +describe('Server Tests', () => { + beforeAll(async () => { + await hocusPocusServer.configure({ port: portWS }).listen(); + }); + + afterAll(() => { + server.close(); + void hocusPocusServer.destroy(); + }); + + test('WebSocket connection with correct API key can connect', () => { + const { promise, done } = promiseDone(); + + // eslint-disable-next-line jest/unbound-method + const { handleConnection } = hocusPocusServer; + const mockHandleConnection = jest.fn(); + (hocusPocusServer.handleConnection as jest.Mock) = mockHandleConnection; + + const clientWS = new WebSocket( + `ws://localhost:${port}/collaboration/ws/?room=test-room`, + { + headers: { + authorization: 'test-secret-api-key', + Origin: origin, + }, + }, + ); + + clientWS.on('open', () => { + expect(mockHandleConnection).toHaveBeenCalled(); + clientWS.close(); + mockHandleConnection.mockClear(); + hocusPocusServer.handleConnection = handleConnection; + done(); + }); + + return promise; + }); + + test('WebSocket connection with bad origin should be closed', () => { + const { promise, done } = promiseDone(); + + const ws = new WebSocket( + `ws://localhost:${port}/collaboration/ws/?room=test-room`, + { + headers: { + Origin: 'http://bad-origin.com', + }, + }, + ); + + ws.onclose = () => { + expect(ws.readyState).toBe(ws.CLOSED); + done(); + }; + + return promise; + }); + + test('WebSocket connection with incorrect API key should be closed', () => { + const { promise, done } = promiseDone(); + const ws = new WebSocket( + `ws://localhost:${port}/collaboration/ws/?room=test-room`, + { + headers: { + Authorization: 'wrong-api-key', + Origin: origin, + }, + }, + ); + + ws.onclose = () => { + expect(ws.readyState).toBe(ws.CLOSED); + done(); + }; + + return promise; + }); + + test('WebSocket connection not allowed if room not matching provider name', () => { + const { promise, done } = promiseDone(); + + const wsHocus = new HocuspocusProviderWebsocket({ + url: `ws://localhost:${portWS}/?room=my-test`, + WebSocketPolyfill: WebSocket, + maxAttempts: 1, + quiet: true, + }); + + const provider = new HocuspocusProvider({ + websocketProvider: wsHocus, + name: 'hocuspocus-test', + broadcast: false, + quiet: true, + preserveConnection: false, + onClose: (data) => { + wsHocus.stopConnectionAttempt(); + expect(data.event.reason).toBe('Forbidden'); + wsHocus.webSocket?.close(); + wsHocus.disconnect(); + provider.destroy(); + wsHocus.destroy(); + done(); + }, + }); + + return promise; + }); + + test('WebSocket connection read-only', () => { + const { promise, done } = promiseDone(); + + const wsHocus = new HocuspocusProviderWebsocket({ + url: `ws://localhost:${portWS}/?room=hocuspocus-test`, + WebSocketPolyfill: WebSocket, + }); + + const provider = new HocuspocusProvider({ + websocketProvider: wsHocus, + name: 'hocuspocus-test', + broadcast: false, + quiet: true, + onConnect: () => { + void hocusPocusServer + .openDirectConnection('hocuspocus-test') + .then((connection) => { + connection.document?.getConnections().forEach((connection) => { + expect(connection.readOnly).toBe(true); + }); + + void connection.disconnect(); + }); + + provider.destroy(); + wsHocus.destroy(); + done(); + }, + }); + + return promise; + }); +}); diff --git a/src/frontend/servers/y-provider/__tests__/server.test.ts b/src/frontend/servers/y-provider/__tests__/server.test.ts index c1ff43935..2f048bff2 100644 --- a/src/frontend/servers/y-provider/__tests__/server.test.ts +++ b/src/frontend/servers/y-provider/__tests__/server.test.ts @@ -1,38 +1,24 @@ -import { - HocuspocusProvider, - HocuspocusProviderWebsocket, -} from '@hocuspocus/provider'; import request from 'supertest'; -import WebSocket from 'ws'; -const port = 5555; -const portWS = 6666; +const port = 5557; const origin = 'http://localhost:3000'; jest.mock('../src/env', () => { return { PORT: port, COLLABORATION_SERVER_ORIGIN: origin, - COLLABORATION_SERVER_SECRET: 'test-secret-api-key', - Y_PROVIDER_API_KEY: 'yprovider-api-key', }; }); console.error = jest.fn(); -import { promiseDone } from '../src/helpers'; -import { hocuspocusServer, initServer } from '../src/server'; // Adjust the path to your server file +import { initServer } from '../src/servers/appServer'; const { app, server } = initServer(); describe('Server Tests', () => { - beforeAll(async () => { - await hocuspocusServer.configure({ port: portWS }).listen(); - }); - afterAll(() => { server.close(); - void hocuspocusServer.destroy(); }); test('Ping Pong', async () => { @@ -42,99 +28,6 @@ describe('Server Tests', () => { expect(response.body.message).toBe('pong'); }); - test('POST /collaboration/api/reset-connections?room=[ROOM_ID] invalid origin', async () => { - const response = await request(app as any) - .post('/collaboration/api/reset-connections/?room=test-room') - .set('Origin', 'http://invalid-origin.com') - .send({ document_id: 'test-document' }); - - expect(response.status).toBe(403); - expect(response.body.error).toBe('CORS policy violation: Invalid Origin'); - }); - - test('POST /collaboration/api/reset-connections?room=[ROOM_ID] with incorrect API key should return 403', async () => { - const response = await request(app as any) - .post('/collaboration/api/reset-connections/?room=test-room') - .set('Origin', origin) - .set('Authorization', 'wrong-api-key'); - - expect(response.status).toBe(403); - expect(response.body.error).toBe('Forbidden: Invalid API Key'); - }); - - test('POST /collaboration/api/reset-connections?room=[ROOM_ID] failed if room not indicated', async () => { - const response = await request(app as any) - .post('/collaboration/api/reset-connections/') - .set('Origin', origin) - .set('Authorization', 'test-secret-api-key') - .send({ document_id: 'test-document' }); - - expect(response.status).toBe(400); - expect(response.body.error).toBe('Room name not provided'); - }); - - test('POST /collaboration/api/reset-connections?room=[ROOM_ID] with correct API key should reset connections', async () => { - // eslint-disable-next-line jest/unbound-method - const { closeConnections } = hocuspocusServer; - const mockHandleConnection = jest.fn(); - (hocuspocusServer.closeConnections as jest.Mock) = mockHandleConnection; - - const response = await request(app as any) - .post('/collaboration/api/reset-connections?room=test-room') - .set('Origin', origin) - .set('Authorization', 'test-secret-api-key'); - - expect(response.status).toBe(200); - expect(response.body.message).toBe('Connections reset'); - - expect(mockHandleConnection).toHaveBeenCalled(); - mockHandleConnection.mockClear(); - hocuspocusServer.closeConnections = closeConnections; - }); - - test('POST /api/convert-markdown with incorrect API key should return 403', async () => { - const response = await request(app as any) - .post('/api/convert-markdown') - .set('Origin', origin) - .set('Authorization', 'wrong-api-key'); - - expect(response.status).toBe(403); - expect(response.body.error).toBe('Forbidden: Invalid API Key'); - }); - - test('POST /api/convert-markdown with a Bearer token', async () => { - const response = await request(app as any) - .post('/api/convert-markdown') - .set('Origin', origin) - .set('Authorization', 'Bearer test-secret-api-key'); - - // Warning: Changing the authorization header to Bearer token format will break backend compatibility with this microservice. - expect(response.status).toBe(403); - }); - - test('POST /api/convert-markdown with missing body param content', async () => { - const response = await request(app as any) - .post('/api/convert-markdown') - .set('Origin', origin) - .set('Authorization', 'yprovider-api-key'); - - expect(response.status).toBe(400); - expect(response.body.error).toBe('Invalid request: missing content'); - }); - - test('POST /api/convert-markdown with body param content being an empty string', async () => { - const response = await request(app as any) - .post('/api/convert-markdown') - .set('Origin', origin) - .set('Authorization', 'yprovider-api-key') - .send({ - content: '', - }); - - expect(response.status).toBe(400); - expect(response.body.error).toBe('Invalid request: missing content'); - }); - ['/collaboration/api/anything/', '/', '/anything'].forEach((path) => { test(`"${path}" endpoint should be forbidden`, async () => { const response = await request(app as any).post(path); @@ -143,136 +36,4 @@ describe('Server Tests', () => { expect(response.body.error).toBe('Forbidden'); }); }); - - test('WebSocket connection with correct API key can connect', () => { - const { promise, done } = promiseDone(); - - // eslint-disable-next-line jest/unbound-method - const { handleConnection } = hocuspocusServer; - const mockHandleConnection = jest.fn(); - (hocuspocusServer.handleConnection as jest.Mock) = mockHandleConnection; - - const clientWS = new WebSocket( - `ws://localhost:${port}/collaboration/ws/?room=test-room`, - { - headers: { - authorization: 'test-secret-api-key', - Origin: origin, - }, - }, - ); - - clientWS.on('open', () => { - expect(mockHandleConnection).toHaveBeenCalled(); - clientWS.close(); - mockHandleConnection.mockClear(); - hocuspocusServer.handleConnection = handleConnection; - done(); - }); - - return promise; - }); - - test('WebSocket connection with bad origin should be closed', () => { - const { promise, done } = promiseDone(); - - const ws = new WebSocket( - `ws://localhost:${port}/collaboration/ws/?room=test-room`, - { - headers: { - Origin: 'http://bad-origin.com', - }, - }, - ); - - ws.onclose = () => { - expect(ws.readyState).toBe(ws.CLOSED); - done(); - }; - - return promise; - }); - - test('WebSocket connection with incorrect API key should be closed', () => { - const { promise, done } = promiseDone(); - const ws = new WebSocket( - `ws://localhost:${port}/collaboration/ws/?room=test-room`, - { - headers: { - Authorization: 'wrong-api-key', - Origin: origin, - }, - }, - ); - - ws.onclose = () => { - expect(ws.readyState).toBe(ws.CLOSED); - done(); - }; - - return promise; - }); - - test('WebSocket connection not allowed if room not matching provider name', () => { - const { promise, done } = promiseDone(); - - const wsHocus = new HocuspocusProviderWebsocket({ - url: `ws://localhost:${portWS}/?room=my-test`, - WebSocketPolyfill: WebSocket, - maxAttempts: 1, - quiet: true, - }); - - const provider = new HocuspocusProvider({ - websocketProvider: wsHocus, - name: 'hocuspocus-test', - broadcast: false, - quiet: true, - preserveConnection: false, - onClose: (data) => { - wsHocus.stopConnectionAttempt(); - expect(data.event.reason).toBe('Forbidden'); - wsHocus.webSocket?.close(); - wsHocus.disconnect(); - provider.destroy(); - wsHocus.destroy(); - done(); - }, - }); - - return promise; - }); - - test('WebSocket connection read-only', () => { - const { promise, done } = promiseDone(); - - const wsHocus = new HocuspocusProviderWebsocket({ - url: `ws://localhost:${portWS}/?room=hocuspocus-test`, - WebSocketPolyfill: WebSocket, - }); - - const provider = new HocuspocusProvider({ - websocketProvider: wsHocus, - name: 'hocuspocus-test', - broadcast: false, - quiet: true, - onConnect: () => { - void hocuspocusServer - .openDirectConnection('hocuspocus-test') - .then((connection) => { - connection.document?.getConnections().forEach((connection) => { - expect(connection.readOnly).toBe(true); - }); - - void connection.disconnect(); - }); - - provider.destroy(); - wsHocus.destroy(); - done(); - }, - }); - - return promise; - }); }); diff --git a/src/frontend/servers/y-provider/nodemon.json b/src/frontend/servers/y-provider/nodemon.json index e9329fd9b..0c61bfb3f 100644 --- a/src/frontend/servers/y-provider/nodemon.json +++ b/src/frontend/servers/y-provider/nodemon.json @@ -1,5 +1,5 @@ { "watch": ["src"], "ext": "ts", - "exec": "yarn build" + "exec": "yarn build && yarn start" } diff --git a/src/frontend/servers/y-provider/package.json b/src/frontend/servers/y-provider/package.json index dff606b7c..c32d2e78a 100644 --- a/src/frontend/servers/y-provider/package.json +++ b/src/frontend/servers/y-provider/package.json @@ -7,7 +7,7 @@ "type": "module", "scripts": { "build": "tsc -p tsconfig.build.json && tsc-alias", - "dev": "nodemon --config nodemon.json", + "dev": "cross-env COLLABORATION_LOGGING=true && nodemon --config nodemon.json", "start": "node ./dist/start-server.js", "lint": "eslint . --ext .ts", "test": "jest" @@ -20,6 +20,7 @@ "@hocuspocus/server": "2.15.0", "@sentry/node": "8.45.1", "@sentry/profiling-node": "8.45.1", + "cors": "2.8.5", "express": "4.21.2", "express-ws": "5.0.2", "y-protocols": "1.0.6", @@ -27,6 +28,7 @@ }, "devDependencies": { "@hocuspocus/provider": "2.15.0", + "@types/cors": "2.8.17", "@types/express": "5.0.0", "@types/express-ws": "3.0.5", "@types/jest": "29.5.14", @@ -34,6 +36,7 @@ "@types/supertest": "6.0.2", "@types/ws": "8.5.13", "eslint-config-impress": "*", + "cross-env": "*", "jest": "29.7.0", "nodemon": "3.1.9", "supertest": "7.0.0", diff --git a/src/frontend/servers/y-provider/src/handlers/collaborationResetConnectionsHandler.ts b/src/frontend/servers/y-provider/src/handlers/collaborationResetConnectionsHandler.ts new file mode 100644 index 000000000..4af440967 --- /dev/null +++ b/src/frontend/servers/y-provider/src/handlers/collaborationResetConnectionsHandler.ts @@ -0,0 +1,55 @@ +import { Request, Response } from 'express'; + +import { hocusPocusServer } from '@/servers/hocusPocusServer'; +import { logger } from '@/utils'; + +type ResetConnectionsRequestQuery = { + room?: string; +}; + +export const collaborationResetConnectionsHandler = ( + req: Request<object, object, object, ResetConnectionsRequestQuery>, + res: Response, +) => { + const room = req.query.room; + const userId = req.headers['x-user-id']; + + logger( + 'Resetting connections in room:', + room, + 'for user:', + userId, + 'room:', + room, + ); + + if (!room) { + res.status(400).json({ error: 'Room name not provided' }); + return; + } + + /** + * If no user ID is provided, close all connections in the room + */ + if (!userId) { + hocusPocusServer.closeConnections(room); + } else { + /** + * Close connections for the user in the room + */ + hocusPocusServer.documents.forEach((doc) => { + if (doc.name !== room) { + return; + } + + doc.getConnections().forEach((connection) => { + const connectionUserId = connection.request.headers['x-user-id']; + if (connectionUserId === userId) { + connection.close(); + } + }); + }); + } + + res.status(200).json({ message: 'Connections reset' }); +}; diff --git a/src/frontend/servers/y-provider/src/handlers/collaborationWSHandler.ts b/src/frontend/servers/y-provider/src/handlers/collaborationWSHandler.ts new file mode 100644 index 000000000..f76ef88c6 --- /dev/null +++ b/src/frontend/servers/y-provider/src/handlers/collaborationWSHandler.ts @@ -0,0 +1,16 @@ +import { Request } from 'express'; +import * as ws from 'ws'; + +import { hocusPocusServer } from '@/servers/hocusPocusServer'; +import { logger } from '@/utils'; + +export const collaborationWSHandler = (ws: ws.WebSocket, req: Request) => { + logger('Incoming Origin:', req.headers['origin']); + + try { + hocusPocusServer.handleConnection(ws, req); + } catch (error) { + console.error('Failed to handle WebSocket connection:', error); + ws.close(); + } +}; diff --git a/src/frontend/servers/y-provider/src/handlers/convertMarkdownHandler.ts b/src/frontend/servers/y-provider/src/handlers/convertMarkdownHandler.ts new file mode 100644 index 000000000..9fc09134a --- /dev/null +++ b/src/frontend/servers/y-provider/src/handlers/convertMarkdownHandler.ts @@ -0,0 +1,55 @@ +import { ServerBlockNoteEditor } from '@blocknote/server-util'; +import { Request, Response } from 'express'; +import * as Y from 'yjs'; + +import { logger, toBase64 } from '@/utils'; + +interface ConversionRequest { + content: string; +} + +interface ConversionResponse { + content: string; +} + +interface ErrorResponse { + error: string; +} + +export const convertMarkdownHandler = async ( + req: Request< + object, + ConversionResponse | ErrorResponse, + ConversionRequest, + object + >, + res: Response<ConversionResponse | ErrorResponse>, +) => { + const content = req.body?.content; + + if (!content) { + res.status(400).json({ error: 'Invalid request: missing content' }); + return; + } + + try { + const editor = ServerBlockNoteEditor.create(); + + // Perform the conversion from markdown to Blocknote.js blocks + const blocks = await editor.tryParseMarkdownToBlocks(content); + + if (!blocks || blocks.length === 0) { + res.status(500).json({ error: 'No valid blocks were generated' }); + return; + } + + // Create a Yjs Document from blocks, and encode it as a base64 string + const yDocument = editor.blocksToYDoc(blocks, 'document-store'); + const documentContent = toBase64(Y.encodeStateAsUpdate(yDocument)); + + res.status(200).json({ content: documentContent }); + } catch (e) { + logger('conversion failed:', e); + res.status(500).json({ error: 'An error occurred' }); + } +}; diff --git a/src/frontend/servers/y-provider/src/handlers/index.ts b/src/frontend/servers/y-provider/src/handlers/index.ts new file mode 100644 index 000000000..75bd7f7bb --- /dev/null +++ b/src/frontend/servers/y-provider/src/handlers/index.ts @@ -0,0 +1,3 @@ +export * from './collaborationResetConnectionsHandler'; +export * from './collaborationWSHandler'; +export * from './convertMarkdownHandler'; diff --git a/src/frontend/servers/y-provider/src/middlewares.ts b/src/frontend/servers/y-provider/src/middlewares.ts index 48e56895d..30470ecaf 100644 --- a/src/frontend/servers/y-provider/src/middlewares.ts +++ b/src/frontend/servers/y-provider/src/middlewares.ts @@ -1,3 +1,4 @@ +import cors from 'cors'; import { NextFunction, Request, Response } from 'express'; import * as ws from 'ws'; @@ -7,26 +8,20 @@ import { Y_PROVIDER_API_KEY, } from '@/env'; -import { logger } from './utils'; - const VALID_API_KEYS = [COLLABORATION_SERVER_SECRET, Y_PROVIDER_API_KEY]; +const allowedOrigins = COLLABORATION_SERVER_ORIGIN.split(','); + +export const corsMiddleware = cors({ + origin: allowedOrigins, + methods: ['GET', 'POST'], + credentials: true, +}); export const httpSecurity = ( req: Request, res: Response, next: NextFunction, ): void => { - // Origin check - const origin = req.headers['origin']; - if (origin && COLLABORATION_SERVER_ORIGIN !== origin) { - logger('CORS policy violation: Invalid Origin', origin); - - res - .status(403) - .json({ error: 'CORS policy violation: Invalid Origin', origin }); - return; - } - // Secret API Key check // Note: Changing this header to Bearer token format will break backend compatibility with this microservice. const apiKey = req.headers['authorization']; @@ -45,9 +40,9 @@ export const wsSecurity = ( ): void => { // Origin check const origin = req.headers['origin']; - if (COLLABORATION_SERVER_ORIGIN !== origin) { + if (origin && !allowedOrigins.includes(origin)) { + ws.close(4001, 'Origin not allowed'); console.error('CORS policy violation: Invalid Origin', origin); - ws.close(); return; } diff --git a/src/frontend/servers/y-provider/src/server.ts b/src/frontend/servers/y-provider/src/server.ts deleted file mode 100644 index 1b229950c..000000000 --- a/src/frontend/servers/y-provider/src/server.ts +++ /dev/null @@ -1,211 +0,0 @@ -// eslint-disable-next-line import/order -import './services/sentry'; -import { ServerBlockNoteEditor } from '@blocknote/server-util'; -import { Server } from '@hocuspocus/server'; -import * as Sentry from '@sentry/node'; -import express, { Request, Response } from 'express'; -import expressWebsockets from 'express-ws'; -import * as Y from 'yjs'; - -import { PORT } from './env'; -import { httpSecurity, wsSecurity } from './middlewares'; -import { routes } from './routes'; -import { logger, toBase64 } from './utils'; - -export const hocuspocusServer = Server.configure({ - name: 'docs-y-server', - timeout: 30000, - quiet: true, - onConnect({ requestHeaders, connection, documentName, requestParameters }) { - const roomParam = requestParameters.get('room'); - const canEdit = requestHeaders['x-can-edit'] === 'True'; - - if (!canEdit) { - connection.readOnly = true; - } - - logger( - 'Connection established:', - documentName, - 'userId:', - requestHeaders['x-user-id'], - 'canEdit:', - canEdit, - 'room:', - requestParameters.get('room'), - ); - - if (documentName !== roomParam) { - console.error( - 'Invalid room name - Probable hacking attempt:', - documentName, - requestParameters.get('room'), - requestHeaders['x-user-id'], - ); - - return Promise.reject(new Error('Unauthorized')); - } - - return Promise.resolve(); - }, -}); - -/** - * init the collaboration server. - * - * @param port - The port on which the server listens. - * @param serverSecret - The secret key for API authentication. - * @returns An object containing the Express app, Hocuspocus server, and HTTP server instance. - */ -export const initServer = () => { - const { app } = expressWebsockets(express()); - app.use(express.json()); - - /** - * Route to handle WebSocket connections - */ - app.ws(routes.COLLABORATION_WS, wsSecurity, (ws, req) => { - logger('Incoming Origin:', req.headers['origin']); - - try { - hocuspocusServer.handleConnection(ws, req); - } catch (error) { - console.error('Failed to handle WebSocket connection:', error); - ws.close(); - } - }); - - type ResetConnectionsRequestQuery = { - room?: string; - }; - - /** - * Route to reset connections in a room: - * - If no user ID is provided, close all connections in the room - * - If a user ID is provided, close connections for the user in the room - */ - app.post( - routes.COLLABORATION_RESET_CONNECTIONS, - httpSecurity, - ( - req: Request<object, object, object, ResetConnectionsRequestQuery>, - res: Response, - ) => { - const room = req.query.room; - const userId = req.headers['x-user-id']; - - logger( - 'Resetting connections in room:', - room, - 'for user:', - userId, - 'room:', - room, - ); - - if (!room) { - res.status(400).json({ error: 'Room name not provided' }); - return; - } - - /** - * If no user ID is provided, close all connections in the room - */ - if (!userId) { - hocuspocusServer.closeConnections(room); - } else { - /** - * Close connections for the user in the room - */ - hocuspocusServer.documents.forEach((doc) => { - if (doc.name !== room) { - return; - } - - doc.getConnections().forEach((connection) => { - const connectionUserId = connection.request.headers['x-user-id']; - if (connectionUserId === userId) { - connection.close(); - } - }); - }); - } - - res.status(200).json({ message: 'Connections reset' }); - }, - ); - - interface ConversionRequest { - content: string; - } - - interface ConversionResponse { - content: string; - } - - interface ErrorResponse { - error: string; - } - - /** - * Route to convert markdown - */ - app.post( - routes.CONVERT_MARKDOWN, - httpSecurity, - async ( - req: Request< - object, - ConversionResponse | ErrorResponse, - ConversionRequest, - object - >, - res: Response<ConversionResponse | ErrorResponse>, - ) => { - const content = req.body?.content; - - if (!content) { - res.status(400).json({ error: 'Invalid request: missing content' }); - return; - } - - try { - const editor = ServerBlockNoteEditor.create(); - - // Perform the conversion from markdown to Blocknote.js blocks - const blocks = await editor.tryParseMarkdownToBlocks(content); - - if (!blocks || blocks.length === 0) { - res.status(500).json({ error: 'No valid blocks were generated' }); - return; - } - - // Create a Yjs Document from blocks, and encode it as a base64 string - const yDocument = editor.blocksToYDoc(blocks, 'document-store'); - const documentContent = toBase64(Y.encodeStateAsUpdate(yDocument)); - - res.status(200).json({ content: documentContent }); - } catch (e) { - logger('conversion failed:', e); - res.status(500).json({ error: 'An error occurred' }); - } - }, - ); - - Sentry.setupExpressErrorHandler(app); - - app.get('/ping', (req, res) => { - res.status(200).json({ message: 'pong' }); - }); - - app.use((req, res) => { - logger('Invalid route:', req.url); - res.status(403).json({ error: 'Forbidden' }); - }); - - const server = app.listen(PORT, () => - console.log('Listening on port :', PORT), - ); - - return { app, server }; -}; diff --git a/src/frontend/servers/y-provider/src/servers/appServer.ts b/src/frontend/servers/y-provider/src/servers/appServer.ts new file mode 100644 index 000000000..80077bb8e --- /dev/null +++ b/src/frontend/servers/y-provider/src/servers/appServer.ts @@ -0,0 +1,66 @@ +// eslint-disable-next-line import/order +import '../services/sentry'; +import * as Sentry from '@sentry/node'; +import express from 'express'; +import expressWebsockets from 'express-ws'; + +import { PORT } from '../env'; +import { + collaborationResetConnectionsHandler, + collaborationWSHandler, + convertMarkdownHandler, +} from '../handlers'; +import { corsMiddleware, httpSecurity, wsSecurity } from '../middlewares'; +import { routes } from '../routes'; +import { logger } from '../utils'; + +/** + * init the collaboration server. + * + * @param port - The port on which the server listens. + * @param serverSecret - The secret key for API authentication. + * @returns An object containing the Express app, Hocuspocus server, and HTTP server instance. + */ +export const initServer = () => { + const { app } = expressWebsockets(express()); + app.use(express.json()); + app.use(corsMiddleware); + + /** + * Route to handle WebSocket connections + */ + app.ws(routes.COLLABORATION_WS, wsSecurity, collaborationWSHandler); + + /** + * Route to reset connections in a room: + * - If no user ID is provided, close all connections in the room + * - If a user ID is provided, close connections for the user in the room + */ + app.post( + routes.COLLABORATION_RESET_CONNECTIONS, + httpSecurity, + collaborationResetConnectionsHandler, + ); + + /** + * Route to convert markdown + */ + app.post(routes.CONVERT_MARKDOWN, httpSecurity, convertMarkdownHandler); + + Sentry.setupExpressErrorHandler(app); + + app.get('/ping', (req, res) => { + res.status(200).json({ message: 'pong' }); + }); + + app.use((req, res) => { + logger('Invalid route:', req.url); + res.status(403).json({ error: 'Forbidden' }); + }); + + const server = app.listen(PORT, () => + console.log('App listening on port :', PORT), + ); + + return { app, server }; +}; diff --git a/src/frontend/servers/y-provider/src/servers/hocusPocusServer.ts b/src/frontend/servers/y-provider/src/servers/hocusPocusServer.ts new file mode 100644 index 000000000..dc159a8e7 --- /dev/null +++ b/src/frontend/servers/y-provider/src/servers/hocusPocusServer.ts @@ -0,0 +1,41 @@ +import { Server } from '@hocuspocus/server'; + +import { logger } from '@/utils'; + +export const hocusPocusServer = Server.configure({ + name: 'docs-collaboration', + timeout: 30000, + quiet: true, + onConnect({ requestHeaders, connection, documentName, requestParameters }) { + const roomParam = requestParameters.get('room'); + const canEdit = requestHeaders['x-can-edit'] === 'True'; + + if (!canEdit) { + connection.readOnly = true; + } + + logger( + 'Connection established:', + documentName, + 'userId:', + requestHeaders['x-user-id'], + 'canEdit:', + canEdit, + 'room:', + requestParameters.get('room'), + ); + + if (documentName !== roomParam) { + console.error( + 'Invalid room name - Probable hacking attempt:', + documentName, + requestParameters.get('room'), + requestHeaders['x-user-id'], + ); + + return Promise.reject(new Error('Unauthorized')); + } + + return Promise.resolve(); + }, +}); diff --git a/src/frontend/servers/y-provider/src/servers/index.ts b/src/frontend/servers/y-provider/src/servers/index.ts new file mode 100644 index 000000000..0dfe0852a --- /dev/null +++ b/src/frontend/servers/y-provider/src/servers/index.ts @@ -0,0 +1,2 @@ +export * from './appServer'; +export * from './hocusPocusServer'; diff --git a/src/frontend/servers/y-provider/src/start-server.ts b/src/frontend/servers/y-provider/src/start-server.ts index e3d305198..c1dbfa214 100644 --- a/src/frontend/servers/y-provider/src/start-server.ts +++ b/src/frontend/servers/y-provider/src/start-server.ts @@ -1,3 +1,3 @@ -import { initServer } from './server'; +import { initServer } from './servers/appServer'; initServer(); diff --git a/src/frontend/servers/y-provider/src/utils.ts b/src/frontend/servers/y-provider/src/utils.ts index 4eb9e30cd..847c55689 100644 --- a/src/frontend/servers/y-provider/src/utils.ts +++ b/src/frontend/servers/y-provider/src/utils.ts @@ -1,9 +1,9 @@ -/* eslint-disable @typescript-eslint/no-unsafe-argument */ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { COLLABORATION_LOGGING } from './env'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function logger(...args: any[]) { if (COLLABORATION_LOGGING === 'true') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument console.log(...args); } } diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 6301d5776..3b8e24d1e 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -4495,6 +4495,13 @@ resolved "https://registry.yarnpkg.com/@types/cookiejar/-/cookiejar-2.1.5.tgz#14a3e83fa641beb169a2dd8422d91c3c345a9a78" integrity sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q== +"@types/cors@2.8.17": + version "2.8.17" + resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.17.tgz#5d718a5e494a8166f569d986794e49c48b216b2b" + integrity sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA== + dependencies: + "@types/node" "*" + "@types/debug@^4.0.0": version "4.1.12" resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917" @@ -6064,6 +6071,14 @@ core-util-is@~1.0.0: resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== +cors@2.8.5: + version "2.8.5" + resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" + integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== + dependencies: + object-assign "^4" + vary "^1" + cosmiconfig@^7.0.0: version "7.1.0" resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-7.1.0.tgz#1443b9afa596b670082ea46cbd8f6a62b84635f6" @@ -10224,7 +10239,7 @@ nwsapi@^2.2.12, nwsapi@^2.2.2: resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.16.tgz#177760bba02c351df1d2644e220c31dfec8cdb43" integrity sha512-F1I/bimDpj3ncaNDhfyMWuFqmQDBwDB0Fogc2qpL3BWvkQteFD/8BzWuIRl83rq0DXfm8SGt/HFhLXZyljTXcQ== -object-assign@^4.1.0, object-assign@^4.1.1: +object-assign@^4, object-assign@^4.1.0, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== @@ -13187,7 +13202,7 @@ value-or-function@^4.0.0: resolved "https://registry.yarnpkg.com/value-or-function/-/value-or-function-4.0.0.tgz#70836b6a876a010dc3a2b884e7902e9db064378d" integrity sha512-aeVK81SIuT6aMJfNo9Vte8Dw0/FZINGBV8BfCraGtqVxIeLAEhJyoWs8SmvRVmXfGss2PmmOwZCuBPbZR+IYWg== -vary@~1.1.2: +vary@^1, vary@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==