diff --git a/docker-compose.yml b/docker-compose.yml index 01d3b06c..03897a5a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -185,11 +185,15 @@ services: context: . dockerfile: ./src/frontend/servers/y-provider/Dockerfile target: y-provider + command: ["yarn", "workspace", "server-y-provider", "run", "dev"] + working_dir: /app/frontend restart: unless-stopped env_file: - env.d/development/common ports: - "4444:4444" + volumes: + - ./src/frontend/:/app/frontend kc_postgresql: image: postgres:14.3 diff --git a/docker/files/etc/nginx/conf.d/default.conf b/docker/files/etc/nginx/conf.d/default.conf index 66bdada1..072fdc28 100644 --- a/docker/files/etc/nginx/conf.d/default.conf +++ b/docker/files/etc/nginx/conf.d/default.conf @@ -4,54 +4,6 @@ server { server_name localhost; charset utf-8; - # Proxy auth for collaboration server - location /collaboration/ws/ { - # Collaboration Auth request configuration - auth_request /collaboration-auth; - auth_request_set $authHeader $upstream_http_authorization; - auth_request_set $canEdit $upstream_http_x_can_edit; - auth_request_set $userId $upstream_http_x_user_id; - - # Pass specific headers from the auth response - proxy_set_header Authorization $authHeader; - proxy_set_header X-Can-Edit $canEdit; - proxy_set_header X-User-Id $userId; - - # Ensure WebSocket upgrade - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "Upgrade"; - - # Collaboration server - proxy_pass http://y-provider:4444; - - # Set appropriate timeout for WebSocket - proxy_read_timeout 86400; - proxy_send_timeout 86400; - - # Preserve original host and additional headers - proxy_set_header Host $host; - } - - location /collaboration-auth { - proxy_pass http://app-dev:8000/api/v1.0/documents/collaboration-auth/; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Original-URL $request_uri; - - # Prevent the body from being passed - proxy_pass_request_body off; - proxy_set_header Content-Length ""; - proxy_set_header X-Original-Method $request_method; - } - - location /collaboration/api/ { - # Collaboration server - proxy_pass http://y-provider:4444; - proxy_set_header Host $host; - } - # Proxy auth for media location /media/ { # Auth request configuration diff --git a/env.d/development/common.dist b/env.d/development/common.dist index e5872514..0ce3a53e 100644 --- a/env.d/development/common.dist +++ b/env.d/development/common.dist @@ -55,10 +55,11 @@ AI_API_KEY=password AI_MODEL=llama # Collaboration -COLLABORATION_API_URL=http://nginx:8083/collaboration/api/ +COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/ +COLLABORATION_BACKEND_BASE_URL=http://app-dev:8000 COLLABORATION_SERVER_ORIGIN=http://localhost:3000 COLLABORATION_SERVER_SECRET=my-secret -COLLABORATION_WS_URL=ws://localhost:8083/collaboration/ws/ +COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/ # Frontend FRONTEND_THEME=default diff --git a/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts index 29a84a70..43896ec1 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts @@ -6,7 +6,7 @@ import { createDoc, verifyDocName } from './common'; const config = { CRISP_WEBSITE_ID: null, - COLLABORATION_WS_URL: 'ws://localhost:8083/collaboration/ws/', + COLLABORATION_WS_URL: 'ws://localhost:4444/collaboration/ws/', ENVIRONMENT: 'development', FRONTEND_THEME: 'default', MEDIA_BASE_URL: 'http://localhost:8083', @@ -99,7 +99,7 @@ test.describe('Config', () => { browserName, }) => { const webSocketPromise = page.waitForEvent('websocket', (webSocket) => { - return webSocket.url().includes('ws://localhost:8083/collaboration/ws/'); + return webSocket.url().includes('ws://localhost:4444/collaboration/ws/'); }); await page.goto('/'); @@ -114,7 +114,7 @@ test.describe('Config', () => { await verifyDocName(page, randomDoc[0]); const webSocket = await webSocketPromise; - expect(webSocket.url()).toContain('ws://localhost:8083/collaboration/ws/'); + expect(webSocket.url()).toContain('ws://localhost:4444/collaboration/ws/'); }); test('it checks that Crisp is trying to init from config endpoint', async ({ diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts index 4efdd71e..d451cd69 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts @@ -65,7 +65,7 @@ test.describe('Doc Editor', () => { let webSocketPromise = page.waitForEvent('websocket', (webSocket) => { return webSocket .url() - .includes('ws://localhost:8083/collaboration/ws/?room='); + .includes('ws://localhost:4444/collaboration/ws/?room='); }); const randomDoc = await createDoc(page, 'doc-editor', browserName, 1); @@ -73,7 +73,7 @@ test.describe('Doc Editor', () => { let webSocket = await webSocketPromise; expect(webSocket.url()).toContain( - 'ws://localhost:8083/collaboration/ws/?room=', + 'ws://localhost:4444/collaboration/ws/?room=', ); // Is connected @@ -103,7 +103,7 @@ test.describe('Doc Editor', () => { webSocketPromise = page.waitForEvent('websocket', (webSocket) => { return webSocket .url() - .includes('ws://localhost:8083/collaboration/ws/?room='); + .includes('ws://localhost:4444/collaboration/ws/?room='); }); webSocket = await webSocketPromise; diff --git a/src/frontend/servers/y-provider/__tests__/hocusPocusWS.test.ts b/src/frontend/servers/y-provider/__tests__/hocusPocusWS.test.ts index 6499c31b..1ee8f9ac 100644 --- a/src/frontend/servers/y-provider/__tests__/hocusPocusWS.test.ts +++ b/src/frontend/servers/y-provider/__tests__/hocusPocusWS.test.ts @@ -13,11 +13,22 @@ jest.mock('../src/env', () => { PORT: port, COLLABORATION_SERVER_ORIGIN: origin, COLLABORATION_SERVER_SECRET: 'test-secret-api-key', + COLLABORATION_BACKEND_BASE_URL: 'http://app-dev:8000', }; }); console.error = jest.fn(); +const mockDocFetch = jest.fn(); +jest.mock('@/api/getDoc', () => ({ + fetchDocument: mockDocFetch, +})); + +const mockGetMe = jest.fn(); +jest.mock('@/api/getMe', () => ({ + getMe: mockGetMe, +})); + import { hocusPocusServer } from '@/servers/hocusPocusServer'; import { promiseDone } from '../src/helpers'; @@ -30,40 +41,15 @@ describe('Server Tests', () => { await hocusPocusServer.configure({ port: portWS }).listen(); }); + afterEach(() => { + jest.clearAllMocks(); + }); + 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(); @@ -84,13 +70,13 @@ describe('Server Tests', () => { return promise; }); - test('WebSocket connection with incorrect API key should be closed', () => { + test('WebSocket connection without cookies header 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, }, }, @@ -121,6 +107,12 @@ describe('Server Tests', () => { quiet: true, preserveConnection: false, onClose: (data) => { + expect(console.error).toHaveBeenCalledWith( + 'Invalid room name - Probable hacking attempt:', + 'hocuspocus-test', + 'my-test', + ); + wsHocus.stopConnectionAttempt(); expect(data.event.reason).toBe('Forbidden'); wsHocus.webSocket?.close(); @@ -134,9 +126,142 @@ describe('Server Tests', () => { return promise; }); - test('WebSocket connection read-only', () => { + test('WebSocket connection fails if user can not access document', () => { const { promise, done } = promiseDone(); + mockDocFetch.mockRejectedValue(''); + + const wsHocus = new HocuspocusProviderWebsocket({ + url: `ws://localhost:${portWS}/?room=my-test`, + WebSocketPolyfill: WebSocket, + maxAttempts: 1, + quiet: true, + }); + + const provider = new HocuspocusProvider({ + websocketProvider: wsHocus, + name: 'my-test', + broadcast: false, + quiet: true, + preserveConnection: false, + onClose: (data) => { + expect(console.error).toHaveBeenCalledWith( + '[onConnect]', + 'Backend error: Unauthorized', + ); + + wsHocus.stopConnectionAttempt(); + expect(data.event.reason).toBe('Forbidden'); + expect(mockDocFetch).toHaveBeenCalledTimes(1); + wsHocus.webSocket?.close(); + wsHocus.disconnect(); + provider.destroy(); + wsHocus.destroy(); + done(); + }, + }); + + return promise; + }); + + test('WebSocket connection fails if user do not have correct retrieve ability', () => { + const { promise, done } = promiseDone(); + + mockDocFetch.mockResolvedValue({ + abilities: { + retrieve: false, + }, + }); + + const wsHocus = new HocuspocusProviderWebsocket({ + url: `ws://localhost:${portWS}/?room=my-test`, + WebSocketPolyfill: WebSocket, + maxAttempts: 1, + quiet: true, + }); + + const provider = new HocuspocusProvider({ + websocketProvider: wsHocus, + name: 'my-test', + broadcast: false, + quiet: true, + preserveConnection: false, + onClose: (data) => { + expect(console.error).toHaveBeenCalledWith( + 'onConnect: Unauthorized to retrieve this document', + 'my-test', + ); + + wsHocus.stopConnectionAttempt(); + expect(data.event.reason).toBe('Forbidden'); + expect(mockDocFetch).toHaveBeenCalledTimes(1); + wsHocus.webSocket?.close(); + wsHocus.disconnect(); + provider.destroy(); + wsHocus.destroy(); + done(); + }, + }); + + return promise; + }); + + [true, false].forEach((canEdit) => { + test(`WebSocket connection ${canEdit ? 'can' : 'can not'} edit document`, () => { + const { promise, done } = promiseDone(); + + mockDocFetch.mockResolvedValue({ + abilities: { + retrieve: true, + update: canEdit, + }, + }); + + 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(!canEdit); + }); + + void connection.disconnect(); + + provider.destroy(); + wsHocus.destroy(); + done(); + }); + }, + }); + + return promise; + }); + }); + + test('Add request header x-user-id if found', () => { + const { promise, done } = promiseDone(); + + mockDocFetch.mockResolvedValue({ + abilities: { + retrieve: true, + update: true, + }, + }); + + mockGetMe.mockResolvedValue({ + id: 'test-user-id', + }); + const wsHocus = new HocuspocusProviderWebsocket({ url: `ws://localhost:${portWS}/?room=hocuspocus-test`, WebSocketPolyfill: WebSocket, @@ -152,15 +277,14 @@ describe('Server Tests', () => { .openDirectConnection('hocuspocus-test') .then((connection) => { connection.document?.getConnections().forEach((connection) => { - expect(connection.readOnly).toBe(true); + expect(connection.context.userId).toBe('test-user-id'); }); void connection.disconnect(); + provider.destroy(); + wsHocus.destroy(); + done(); }); - - provider.destroy(); - wsHocus.destroy(); - done(); }, }); diff --git a/src/frontend/servers/y-provider/package.json b/src/frontend/servers/y-provider/package.json index 3ec776cd..415726e0 100644 --- a/src/frontend/servers/y-provider/package.json +++ b/src/frontend/servers/y-provider/package.json @@ -20,6 +20,7 @@ "@hocuspocus/server": "2.15.2", "@sentry/node": "9.3.0", "@sentry/profiling-node": "9.3.0", + "axios": "1.8.2", "cors": "2.8.5", "express": "4.21.2", "express-ws": "5.0.2", diff --git a/src/frontend/servers/y-provider/src/api/getDoc.ts b/src/frontend/servers/y-provider/src/api/getDoc.ts new file mode 100644 index 00000000..6c712f6d --- /dev/null +++ b/src/frontend/servers/y-provider/src/api/getDoc.ts @@ -0,0 +1,76 @@ +import { IncomingHttpHeaders } from 'http'; + +import axios from 'axios'; + +import { COLLABORATION_BACKEND_BASE_URL } from '@/env'; + +enum LinkReach { + RESTRICTED = 'restricted', + PUBLIC = 'public', + AUTHENTICATED = 'authenticated', +} + +enum LinkRole { + READER = 'reader', + EDITOR = 'editor', +} + +type Base64 = string; + +interface Doc { + id: string; + title?: string; + content: Base64; + creator: string; + is_favorite: boolean; + link_reach: LinkReach; + link_role: LinkRole; + nb_accesses_ancestors: number; + nb_accesses_direct: number; + created_at: string; + updated_at: string; + abilities: { + accesses_manage: boolean; + accesses_view: boolean; + ai_transform: boolean; + ai_translate: boolean; + attachment_upload: boolean; + children_create: boolean; + children_list: boolean; + collaboration_auth: boolean; + destroy: boolean; + favorite: boolean; + invite_owner: boolean; + link_configuration: boolean; + media_auth: boolean; + move: boolean; + partial_update: boolean; + restore: boolean; + retrieve: boolean; + update: boolean; + versions_destroy: boolean; + versions_list: boolean; + versions_retrieve: boolean; + }; +} + +export const fetchDocument = async ( + documentName: string, + requestHeaders: IncomingHttpHeaders, +) => { + const response = await axios.get( + `${COLLABORATION_BACKEND_BASE_URL}/api/v1.0/documents/${documentName}/`, + { + headers: { + Cookie: requestHeaders['cookie'], + Origin: requestHeaders['origin'], + }, + }, + ); + + if (response.status !== 200) { + throw new Error(`Failed to fetch document: ${response.statusText}`); + } + + return response.data; +}; diff --git a/src/frontend/servers/y-provider/src/api/getMe.ts b/src/frontend/servers/y-provider/src/api/getMe.ts new file mode 100644 index 00000000..910380d5 --- /dev/null +++ b/src/frontend/servers/y-provider/src/api/getMe.ts @@ -0,0 +1,31 @@ +import { IncomingHttpHeaders } from 'http'; + +import axios from 'axios'; + +import { COLLABORATION_BACKEND_BASE_URL } from '@/env'; + +export interface User { + id: string; + email: string; + full_name: string; + short_name: string; + language: string; +} + +export const getMe = async (requestHeaders: IncomingHttpHeaders) => { + const response = await axios.get( + `${COLLABORATION_BACKEND_BASE_URL}/api/v1.0/users/me/`, + { + headers: { + Cookie: requestHeaders['cookie'], + Origin: requestHeaders['origin'], + }, + }, + ); + + if (response.status !== 200) { + throw new Error(`Failed to fetch user: ${response.statusText}`); + } + + return response.data; +}; diff --git a/src/frontend/servers/y-provider/src/env.ts b/src/frontend/servers/y-provider/src/env.ts index 7f23bfc5..fe281930 100644 --- a/src/frontend/servers/y-provider/src/env.ts +++ b/src/frontend/servers/y-provider/src/env.ts @@ -8,3 +8,5 @@ export const Y_PROVIDER_API_KEY = process.env.Y_PROVIDER_API_KEY || 'yprovider-api-key'; export const PORT = Number(process.env.PORT || 4444); export const SENTRY_DSN = process.env.SENTRY_DSN || ''; +export const COLLABORATION_BACKEND_BASE_URL = + process.env.COLLABORATION_BACKEND_BASE_URL || 'http://app-dev:8000'; diff --git a/src/frontend/servers/y-provider/src/handlers/collaborationResetConnectionsHandler.ts b/src/frontend/servers/y-provider/src/handlers/collaborationResetConnectionsHandler.ts index 4af44096..3e69597f 100644 --- a/src/frontend/servers/y-provider/src/handlers/collaborationResetConnectionsHandler.ts +++ b/src/frontend/servers/y-provider/src/handlers/collaborationResetConnectionsHandler.ts @@ -14,14 +14,7 @@ export const collaborationResetConnectionsHandler = ( const room = req.query.room; const userId = req.headers['x-user-id']; - logger( - 'Resetting connections in room:', - room, - 'for user:', - userId, - 'room:', - room, - ); + logger('Resetting connections in room:', room, 'for user:', userId); if (!room) { res.status(400).json({ error: 'Room name not provided' }); @@ -43,8 +36,8 @@ export const collaborationResetConnectionsHandler = ( } doc.getConnections().forEach((connection) => { - const connectionUserId = connection.request.headers['x-user-id']; - if (connectionUserId === userId) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (connection.context.userId === userId) { connection.close(); } }); diff --git a/src/frontend/servers/y-provider/src/middlewares.ts b/src/frontend/servers/y-provider/src/middlewares.ts index 30470eca..36a18d4a 100644 --- a/src/frontend/servers/y-provider/src/middlewares.ts +++ b/src/frontend/servers/y-provider/src/middlewares.ts @@ -40,17 +40,16 @@ export const wsSecurity = ( ): void => { // Origin check const origin = req.headers['origin']; - if (origin && !allowedOrigins.includes(origin)) { + if (!origin || !allowedOrigins.includes(origin)) { ws.close(4001, 'Origin not allowed'); console.error('CORS policy violation: Invalid Origin', origin); return; } - // Secret API Key check - const apiKey = req.headers['authorization']; - if (apiKey !== COLLABORATION_SERVER_SECRET) { - console.error('Forbidden: Invalid API Key'); - ws.close(); + const cookies = req.headers['cookie']; + if (!cookies) { + ws.close(4001, 'No cookies'); + console.error('CORS policy violation: No cookies'); return; } diff --git a/src/frontend/servers/y-provider/src/servers/hocusPocusServer.ts b/src/frontend/servers/y-provider/src/servers/hocusPocusServer.ts index dc159a8e..70c3eb2d 100644 --- a/src/frontend/servers/y-provider/src/servers/hocusPocusServer.ts +++ b/src/frontend/servers/y-provider/src/servers/hocusPocusServer.ts @@ -1,41 +1,74 @@ import { Server } from '@hocuspocus/server'; +import { fetchDocument } from '@/api/getDoc'; +import { getMe } from '@/api/getMe'; import { logger } from '@/utils'; export const hocusPocusServer = Server.configure({ name: 'docs-collaboration', timeout: 30000, quiet: true, - onConnect({ requestHeaders, connection, documentName, requestParameters }) { + async onConnect({ + requestHeaders, + connection, + documentName, + requestParameters, + context, + }) { 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.reject(new Error('Wrong room name: Unauthorized')); } + let can_edit = false; + + try { + const document = await fetchDocument(documentName, requestHeaders); + + if (!document.abilities.retrieve) { + console.error( + 'onConnect: Unauthorized to retrieve this document', + roomParam, + ); + return Promise.reject(new Error('Wrong abilities:Unauthorized')); + } + + can_edit = document.abilities.update; + } catch (error: unknown) { + if (error instanceof Error) { + console.error('onConnect: backend error', error.message); + } + + return Promise.reject(new Error('Backend error: Unauthorized')); + } + + connection.readOnly = !can_edit; + + /* + * Unauthenticated users can be allowed to connect + * so we flag only authenticated users + */ + try { + const user = await getMe(requestHeaders); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + context.userId = user.id; + } catch {} + + logger( + 'Connection established:', + documentName, + 'canEdit:', + can_edit, + 'room:', + requestParameters.get('room'), + ); return Promise.resolve(); }, }); diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 5927e8e6..5328c165 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -7107,6 +7107,15 @@ axe-core@^4.10.0: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.10.2.tgz#85228e3e1d8b8532a27659b332e39b7fa0e022df" integrity sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w== +axios@1.8.2: + version "1.8.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.8.2.tgz#fabe06e241dfe83071d4edfbcaa7b1c3a40f7979" + integrity sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg== + dependencies: + follow-redirects "^1.15.6" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + axobject-query@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-4.1.0.tgz#28768c76d0e3cff21bc62a9e2d0b6ac30042a1ee" @@ -9180,6 +9189,11 @@ flatted@^3.3.3: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358" integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== +follow-redirects@^1.15.6: + version "1.15.9" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1" + integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ== + fontkit@^2.0.2: version "2.0.4" resolved "https://registry.yarnpkg.com/fontkit/-/fontkit-2.0.4.tgz#4765d664c68b49b5d6feb6bd1051ee49d8ec5ab0"