🏗️(y-provider) manage auth in y-provider app
The way to connect to the hocuspocus server needs to be proxified in nginx to query a dedicated route in the django application and then follow the request to the express server with the additionnal headers. The auth can be done in the express server by querying the backend on the document retrieve endpoint. If the response status code is 200, the user has access to the document, otherwise it is not the case. Then we can check the abilities to determine what the user can do or not.
This commit is contained in:
@@ -185,11 +185,15 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: ./src/frontend/servers/y-provider/Dockerfile
|
dockerfile: ./src/frontend/servers/y-provider/Dockerfile
|
||||||
target: y-provider
|
target: y-provider
|
||||||
|
command: ["yarn", "workspace", "server-y-provider", "run", "dev"]
|
||||||
|
working_dir: /app/frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/common
|
- env.d/development/common
|
||||||
ports:
|
ports:
|
||||||
- "4444:4444"
|
- "4444:4444"
|
||||||
|
volumes:
|
||||||
|
- ./src/frontend/:/app/frontend
|
||||||
|
|
||||||
kc_postgresql:
|
kc_postgresql:
|
||||||
image: postgres:14.3
|
image: postgres:14.3
|
||||||
|
|||||||
@@ -4,54 +4,6 @@ server {
|
|||||||
server_name localhost;
|
server_name localhost;
|
||||||
charset utf-8;
|
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
|
# Proxy auth for media
|
||||||
location /media/ {
|
location /media/ {
|
||||||
# Auth request configuration
|
# Auth request configuration
|
||||||
|
|||||||
@@ -55,10 +55,11 @@ AI_API_KEY=password
|
|||||||
AI_MODEL=llama
|
AI_MODEL=llama
|
||||||
|
|
||||||
# Collaboration
|
# 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_ORIGIN=http://localhost:3000
|
||||||
COLLABORATION_SERVER_SECRET=my-secret
|
COLLABORATION_SERVER_SECRET=my-secret
|
||||||
COLLABORATION_WS_URL=ws://localhost:8083/collaboration/ws/
|
COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
|
||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
FRONTEND_THEME=default
|
FRONTEND_THEME=default
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { createDoc, verifyDocName } from './common';
|
|||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
CRISP_WEBSITE_ID: null,
|
CRISP_WEBSITE_ID: null,
|
||||||
COLLABORATION_WS_URL: 'ws://localhost:8083/collaboration/ws/',
|
COLLABORATION_WS_URL: 'ws://localhost:4444/collaboration/ws/',
|
||||||
ENVIRONMENT: 'development',
|
ENVIRONMENT: 'development',
|
||||||
FRONTEND_THEME: 'default',
|
FRONTEND_THEME: 'default',
|
||||||
MEDIA_BASE_URL: 'http://localhost:8083',
|
MEDIA_BASE_URL: 'http://localhost:8083',
|
||||||
@@ -99,7 +99,7 @@ test.describe('Config', () => {
|
|||||||
browserName,
|
browserName,
|
||||||
}) => {
|
}) => {
|
||||||
const webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
|
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('/');
|
await page.goto('/');
|
||||||
@@ -114,7 +114,7 @@ test.describe('Config', () => {
|
|||||||
await verifyDocName(page, randomDoc[0]);
|
await verifyDocName(page, randomDoc[0]);
|
||||||
|
|
||||||
const webSocket = await webSocketPromise;
|
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 ({
|
test('it checks that Crisp is trying to init from config endpoint', async ({
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ test.describe('Doc Editor', () => {
|
|||||||
let webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
|
let webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
|
||||||
return webSocket
|
return webSocket
|
||||||
.url()
|
.url()
|
||||||
.includes('ws://localhost:8083/collaboration/ws/?room=');
|
.includes('ws://localhost:4444/collaboration/ws/?room=');
|
||||||
});
|
});
|
||||||
|
|
||||||
const randomDoc = await createDoc(page, 'doc-editor', browserName, 1);
|
const randomDoc = await createDoc(page, 'doc-editor', browserName, 1);
|
||||||
@@ -73,7 +73,7 @@ test.describe('Doc Editor', () => {
|
|||||||
|
|
||||||
let webSocket = await webSocketPromise;
|
let webSocket = await webSocketPromise;
|
||||||
expect(webSocket.url()).toContain(
|
expect(webSocket.url()).toContain(
|
||||||
'ws://localhost:8083/collaboration/ws/?room=',
|
'ws://localhost:4444/collaboration/ws/?room=',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Is connected
|
// Is connected
|
||||||
@@ -103,7 +103,7 @@ test.describe('Doc Editor', () => {
|
|||||||
webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
|
webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
|
||||||
return webSocket
|
return webSocket
|
||||||
.url()
|
.url()
|
||||||
.includes('ws://localhost:8083/collaboration/ws/?room=');
|
.includes('ws://localhost:4444/collaboration/ws/?room=');
|
||||||
});
|
});
|
||||||
|
|
||||||
webSocket = await webSocketPromise;
|
webSocket = await webSocketPromise;
|
||||||
|
|||||||
@@ -13,11 +13,22 @@ jest.mock('../src/env', () => {
|
|||||||
PORT: port,
|
PORT: port,
|
||||||
COLLABORATION_SERVER_ORIGIN: origin,
|
COLLABORATION_SERVER_ORIGIN: origin,
|
||||||
COLLABORATION_SERVER_SECRET: 'test-secret-api-key',
|
COLLABORATION_SERVER_SECRET: 'test-secret-api-key',
|
||||||
|
COLLABORATION_BACKEND_BASE_URL: 'http://app-dev:8000',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
console.error = jest.fn();
|
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 { hocusPocusServer } from '@/servers/hocusPocusServer';
|
||||||
|
|
||||||
import { promiseDone } from '../src/helpers';
|
import { promiseDone } from '../src/helpers';
|
||||||
@@ -30,40 +41,15 @@ describe('Server Tests', () => {
|
|||||||
await hocusPocusServer.configure({ port: portWS }).listen();
|
await hocusPocusServer.configure({ port: portWS }).listen();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
server.close();
|
server.close();
|
||||||
void hocusPocusServer.destroy();
|
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', () => {
|
test('WebSocket connection with bad origin should be closed', () => {
|
||||||
const { promise, done } = promiseDone();
|
const { promise, done } = promiseDone();
|
||||||
|
|
||||||
@@ -84,13 +70,13 @@ describe('Server Tests', () => {
|
|||||||
return promise;
|
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 { promise, done } = promiseDone();
|
||||||
|
|
||||||
const ws = new WebSocket(
|
const ws = new WebSocket(
|
||||||
`ws://localhost:${port}/collaboration/ws/?room=test-room`,
|
`ws://localhost:${port}/collaboration/ws/?room=test-room`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: 'wrong-api-key',
|
|
||||||
Origin: origin,
|
Origin: origin,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -121,6 +107,12 @@ describe('Server Tests', () => {
|
|||||||
quiet: true,
|
quiet: true,
|
||||||
preserveConnection: false,
|
preserveConnection: false,
|
||||||
onClose: (data) => {
|
onClose: (data) => {
|
||||||
|
expect(console.error).toHaveBeenCalledWith(
|
||||||
|
'Invalid room name - Probable hacking attempt:',
|
||||||
|
'hocuspocus-test',
|
||||||
|
'my-test',
|
||||||
|
);
|
||||||
|
|
||||||
wsHocus.stopConnectionAttempt();
|
wsHocus.stopConnectionAttempt();
|
||||||
expect(data.event.reason).toBe('Forbidden');
|
expect(data.event.reason).toBe('Forbidden');
|
||||||
wsHocus.webSocket?.close();
|
wsHocus.webSocket?.close();
|
||||||
@@ -134,9 +126,142 @@ describe('Server Tests', () => {
|
|||||||
return promise;
|
return promise;
|
||||||
});
|
});
|
||||||
|
|
||||||
test('WebSocket connection read-only', () => {
|
test('WebSocket connection fails if user can not access document', () => {
|
||||||
const { promise, done } = promiseDone();
|
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({
|
const wsHocus = new HocuspocusProviderWebsocket({
|
||||||
url: `ws://localhost:${portWS}/?room=hocuspocus-test`,
|
url: `ws://localhost:${portWS}/?room=hocuspocus-test`,
|
||||||
WebSocketPolyfill: WebSocket,
|
WebSocketPolyfill: WebSocket,
|
||||||
@@ -152,15 +277,14 @@ describe('Server Tests', () => {
|
|||||||
.openDirectConnection('hocuspocus-test')
|
.openDirectConnection('hocuspocus-test')
|
||||||
.then((connection) => {
|
.then((connection) => {
|
||||||
connection.document?.getConnections().forEach((connection) => {
|
connection.document?.getConnections().forEach((connection) => {
|
||||||
expect(connection.readOnly).toBe(true);
|
expect(connection.context.userId).toBe('test-user-id');
|
||||||
});
|
});
|
||||||
|
|
||||||
void connection.disconnect();
|
void connection.disconnect();
|
||||||
|
provider.destroy();
|
||||||
|
wsHocus.destroy();
|
||||||
|
done();
|
||||||
});
|
});
|
||||||
|
|
||||||
provider.destroy();
|
|
||||||
wsHocus.destroy();
|
|
||||||
done();
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"@hocuspocus/server": "2.15.2",
|
"@hocuspocus/server": "2.15.2",
|
||||||
"@sentry/node": "9.3.0",
|
"@sentry/node": "9.3.0",
|
||||||
"@sentry/profiling-node": "9.3.0",
|
"@sentry/profiling-node": "9.3.0",
|
||||||
|
"axios": "1.8.2",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
"express": "4.21.2",
|
"express": "4.21.2",
|
||||||
"express-ws": "5.0.2",
|
"express-ws": "5.0.2",
|
||||||
|
|||||||
76
src/frontend/servers/y-provider/src/api/getDoc.ts
Normal file
76
src/frontend/servers/y-provider/src/api/getDoc.ts
Normal file
@@ -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<Doc>(
|
||||||
|
`${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;
|
||||||
|
};
|
||||||
31
src/frontend/servers/y-provider/src/api/getMe.ts
Normal file
31
src/frontend/servers/y-provider/src/api/getMe.ts
Normal file
@@ -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<User>(
|
||||||
|
`${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;
|
||||||
|
};
|
||||||
@@ -8,3 +8,5 @@ export const Y_PROVIDER_API_KEY =
|
|||||||
process.env.Y_PROVIDER_API_KEY || 'yprovider-api-key';
|
process.env.Y_PROVIDER_API_KEY || 'yprovider-api-key';
|
||||||
export const PORT = Number(process.env.PORT || 4444);
|
export const PORT = Number(process.env.PORT || 4444);
|
||||||
export const SENTRY_DSN = process.env.SENTRY_DSN || '';
|
export const SENTRY_DSN = process.env.SENTRY_DSN || '';
|
||||||
|
export const COLLABORATION_BACKEND_BASE_URL =
|
||||||
|
process.env.COLLABORATION_BACKEND_BASE_URL || 'http://app-dev:8000';
|
||||||
|
|||||||
@@ -14,14 +14,7 @@ export const collaborationResetConnectionsHandler = (
|
|||||||
const room = req.query.room;
|
const room = req.query.room;
|
||||||
const userId = req.headers['x-user-id'];
|
const userId = req.headers['x-user-id'];
|
||||||
|
|
||||||
logger(
|
logger('Resetting connections in room:', room, 'for user:', userId);
|
||||||
'Resetting connections in room:',
|
|
||||||
room,
|
|
||||||
'for user:',
|
|
||||||
userId,
|
|
||||||
'room:',
|
|
||||||
room,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!room) {
|
if (!room) {
|
||||||
res.status(400).json({ error: 'Room name not provided' });
|
res.status(400).json({ error: 'Room name not provided' });
|
||||||
@@ -43,8 +36,8 @@ export const collaborationResetConnectionsHandler = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
doc.getConnections().forEach((connection) => {
|
doc.getConnections().forEach((connection) => {
|
||||||
const connectionUserId = connection.request.headers['x-user-id'];
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||||
if (connectionUserId === userId) {
|
if (connection.context.userId === userId) {
|
||||||
connection.close();
|
connection.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,17 +40,16 @@ export const wsSecurity = (
|
|||||||
): void => {
|
): void => {
|
||||||
// Origin check
|
// Origin check
|
||||||
const origin = req.headers['origin'];
|
const origin = req.headers['origin'];
|
||||||
if (origin && !allowedOrigins.includes(origin)) {
|
if (!origin || !allowedOrigins.includes(origin)) {
|
||||||
ws.close(4001, 'Origin not allowed');
|
ws.close(4001, 'Origin not allowed');
|
||||||
console.error('CORS policy violation: Invalid Origin', origin);
|
console.error('CORS policy violation: Invalid Origin', origin);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Secret API Key check
|
const cookies = req.headers['cookie'];
|
||||||
const apiKey = req.headers['authorization'];
|
if (!cookies) {
|
||||||
if (apiKey !== COLLABORATION_SERVER_SECRET) {
|
ws.close(4001, 'No cookies');
|
||||||
console.error('Forbidden: Invalid API Key');
|
console.error('CORS policy violation: No cookies');
|
||||||
ws.close();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +1,74 @@
|
|||||||
import { Server } from '@hocuspocus/server';
|
import { Server } from '@hocuspocus/server';
|
||||||
|
|
||||||
|
import { fetchDocument } from '@/api/getDoc';
|
||||||
|
import { getMe } from '@/api/getMe';
|
||||||
import { logger } from '@/utils';
|
import { logger } from '@/utils';
|
||||||
|
|
||||||
export const hocusPocusServer = Server.configure({
|
export const hocusPocusServer = Server.configure({
|
||||||
name: 'docs-collaboration',
|
name: 'docs-collaboration',
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
quiet: true,
|
quiet: true,
|
||||||
onConnect({ requestHeaders, connection, documentName, requestParameters }) {
|
async onConnect({
|
||||||
|
requestHeaders,
|
||||||
|
connection,
|
||||||
|
documentName,
|
||||||
|
requestParameters,
|
||||||
|
context,
|
||||||
|
}) {
|
||||||
const roomParam = requestParameters.get('room');
|
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) {
|
if (documentName !== roomParam) {
|
||||||
console.error(
|
console.error(
|
||||||
'Invalid room name - Probable hacking attempt:',
|
'Invalid room name - Probable hacking attempt:',
|
||||||
documentName,
|
documentName,
|
||||||
requestParameters.get('room'),
|
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();
|
return Promise.resolve();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7107,6 +7107,15 @@ axe-core@^4.10.0:
|
|||||||
resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.10.2.tgz#85228e3e1d8b8532a27659b332e39b7fa0e022df"
|
resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.10.2.tgz#85228e3e1d8b8532a27659b332e39b7fa0e022df"
|
||||||
integrity sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w==
|
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:
|
axobject-query@^4.1.0:
|
||||||
version "4.1.0"
|
version "4.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-4.1.0.tgz#28768c76d0e3cff21bc62a9e2d0b6ac30042a1ee"
|
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"
|
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358"
|
||||||
integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==
|
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:
|
fontkit@^2.0.2:
|
||||||
version "2.0.4"
|
version "2.0.4"
|
||||||
resolved "https://registry.yarnpkg.com/fontkit/-/fontkit-2.0.4.tgz#4765d664c68b49b5d6feb6bd1051ee49d8ec5ab0"
|
resolved "https://registry.yarnpkg.com/fontkit/-/fontkit-2.0.4.tgz#4765d664c68b49b5d6feb6bd1051ee49d8ec5ab0"
|
||||||
|
|||||||
Reference in New Issue
Block a user