🏗️(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:
Manuel Raynaud
2025-03-19 15:41:05 +01:00
parent a5b9169eb6
commit e86919fb9a
14 changed files with 358 additions and 128 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 ({

View File

@@ -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;

View File

@@ -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();
}, },
}); });

View File

@@ -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",

View 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;
};

View 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;
};

View File

@@ -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';

View File

@@ -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();
} }
}); });

View File

@@ -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;
} }

View File

@@ -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();
}, },
}); });

View File

@@ -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"