diff --git a/.reuse/dep5 b/.reuse/dep5 index 367d202a3..517bed088 100644 --- a/.reuse/dep5 +++ b/.reuse/dep5 @@ -58,3 +58,7 @@ License: LicenseRef-DCO Files: docs/content/theme/styles/Roboto/* Copyright: 2011 Christian Robertson License: Apache-2.0 + +Files: public/*.md +Copyright: 2021 The HedgeDoc developers (see AUTHORS file) +License: CC0-1.0 diff --git a/codecov.yml b/codecov.yml index 71728fd93..4950073bb 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,8 +1,9 @@ -# SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) +# SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) # SPDX-License-Identifier: CC0-1.0 ignore: - "src/utils/test-utils" + - "src/realtime/realtime-note/test-utils" codecov: notify: diff --git a/package.json b/package.json index 42ee7db7b..41981577b 100644 --- a/package.json +++ b/package.json @@ -25,14 +25,18 @@ }, "dependencies": { "@azure/storage-blob": "12.11.0", + "@hedgedoc/realtime": "0.1.1", + "@mrdrogdrog/optional": "0.1.0", "@nestjs/common": "8.4.7", "@nestjs/config": "2.1.0", "@nestjs/core": "8.4.7", "@nestjs/passport": "8.2.2", "@nestjs/platform-express": "8.4.7", + "@nestjs/platform-ws": "7.6.17", "@nestjs/schedule": "2.0.1", "@nestjs/swagger": "5.2.1", "@nestjs/typeorm": "8.1.4", + "@nestjs/websockets": "8.4.4", "@types/bcrypt": "5.0.0", "@types/cron": "1.7.3", "@types/minio": "7.0.13", @@ -44,10 +48,12 @@ "class-validator": "0.13.2", "cli-color": "2.0.3", "connect-typeorm": "1.1.4", + "cookie": "0.5.0", "express-session": "1.17.3", "file-type": "16.5.3", "joi": "17.6.0", "ldapauth-fork": "5.0.5", + "lib0": "0.2.51", "minio": "7.0.29", "mysql": "2.18.1", "nest-router": "1.0.9", @@ -63,7 +69,10 @@ "rxjs": "7.5.5", "sqlite3": "5.0.8", "swagger-ui-express": "4.4.0", - "typeorm": "0.3.7" + "typeorm": "0.3.7", + "ws": "8.7.0", + "y-protocols": "1.0.5", + "yjs": "13.5.39" }, "devDependencies": { "@nestjs/cli": "8.2.8", @@ -72,6 +81,8 @@ "@trivago/prettier-plugin-sort-imports": "3.2.0", "@tsconfig/node12": "1.0.11", "@types/cli-color": "2.0.2", + "@types/cookie": "0.5.0", + "@types/cookie-signature": "1.0.4", "@types/express": "4.17.13", "@types/express-session": "1.17.4", "@types/jest": "28.1.4", @@ -81,6 +92,7 @@ "@types/pg": "8.6.5", "@types/source-map-support": "0.5.4", "@types/supertest": "2.0.12", + "@types/ws": "8.5.3", "@typescript-eslint/eslint-plugin": "5.30.5", "@typescript-eslint/parser": "5.30.5", "eslint": "8.19.0", diff --git a/public/intro.md b/public/intro.md new file mode 100644 index 000000000..896fd1362 --- /dev/null +++ b/public/intro.md @@ -0,0 +1,3 @@ +:::success +You're connected to a real backend! :party: +::: diff --git a/public/motd.md b/public/motd.md new file mode 100644 index 000000000..974a3143f --- /dev/null +++ b/public/motd.md @@ -0,0 +1,2 @@ +This is the test motd text +:smile: diff --git a/src/app-init.ts b/src/app-init.ts index 04a4d6853..5cb351f07 100644 --- a/src/app-init.ts +++ b/src/app-init.ts @@ -5,6 +5,7 @@ */ import { HttpAdapterHost } from '@nestjs/core'; import { NestExpressApplication } from '@nestjs/platform-express'; +import { WsAdapter } from '@nestjs/platform-ws'; import { AppConfig } from './config/app.config'; import { AuthConfig } from './config/auth.config'; @@ -14,7 +15,6 @@ import { ConsoleLoggerService } from './logger/console-logger.service'; import { BackendType } from './media/backends/backend-type.enum'; import { SessionService } from './session/session.service'; import { setupSpecialGroups } from './utils/createSpecialGroups'; -import { setupFrontendProxy } from './utils/frontend-integration'; import { setupSessionMiddleware } from './utils/session'; import { setupValidationPipe } from './utils/setup-pipes'; import { setupPrivateApiDocs, setupPublicApiDocs } from './utils/swagger'; @@ -41,8 +41,6 @@ export async function setupApp( `Serving OpenAPI docs for private api under '/private/apidoc'`, 'AppBootstrap', ); - - await setupFrontendProxy(app, logger); } await setupSpecialGroups(app); @@ -80,4 +78,5 @@ export async function setupApp( const { httpAdapter } = app.get(HttpAdapterHost); app.useGlobalFilters(new ErrorExceptionMapping(httpAdapter)); + app.useWebSocketAdapter(new WsAdapter(app)); } diff --git a/src/app.module.ts b/src/app.module.ts index 9ec7596b6..1ccff2a24 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -33,6 +33,7 @@ import { MediaModule } from './media/media.module'; import { MonitoringModule } from './monitoring/monitoring.module'; import { NotesModule } from './notes/notes.module'; import { PermissionsModule } from './permissions/permissions.module'; +import { WebsocketModule } from './realtime/websocket/websocket.module'; import { RevisionsModule } from './revisions/revisions.module'; import { SessionModule } from './session/session.module'; import { UsersModule } from './users/users.module'; @@ -101,6 +102,7 @@ const routes: Routes = [ MediaModule, AuthModule, FrontendConfigModule, + WebsocketModule, IdentityModule, SessionModule, ], diff --git a/src/history/history.service.spec.ts b/src/history/history.service.spec.ts index 2983946fe..aca756a4f 100644 --- a/src/history/history.service.spec.ts +++ b/src/history/history.service.spec.ts @@ -12,6 +12,7 @@ import { DataSource, EntityManager, Repository } from 'typeorm'; import { AuthToken } from '../auth/auth-token.entity'; import { Author } from '../authors/author.entity'; import appConfigMock from '../config/mock/app.config.mock'; +import authConfigMock from '../config/mock/auth.config.mock'; import databaseConfigMock from '../config/mock/database.config.mock'; import noteConfigMock from '../config/mock/note.config.mock'; import { NotInDBError } from '../errors/errors'; @@ -81,7 +82,12 @@ describe('HistoryService', () => { NotesModule, ConfigModule.forRoot({ isGlobal: true, - load: [appConfigMock, databaseConfigMock, noteConfigMock], + load: [ + appConfigMock, + databaseConfigMock, + authConfigMock, + noteConfigMock, + ], }), ], }) diff --git a/src/main.ts b/src/main.ts index 24c594f29..7f25ef785 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,7 +12,6 @@ import { setupApp } from './app-init'; import { AppModule } from './app.module'; import { AppConfig } from './config/app.config'; import { AuthConfig } from './config/auth.config'; -import { DatabaseConfig } from './config/database.config'; import { MediaConfig } from './config/media.config'; import { ConsoleLoggerService } from './logger/console-logger.service'; @@ -33,6 +32,7 @@ async function bootstrap(): Promise { const appConfig = configService.get('appConfig'); const authConfig = configService.get('authConfig'); const mediaConfig = configService.get('mediaConfig'); + if (!appConfig || !authConfig || !mediaConfig) { logger.error('Could not initialize config, aborting.', 'AppBootstrap'); process.exit(1); diff --git a/src/media/media.service.spec.ts b/src/media/media.service.spec.ts index 440d28486..c7e01a4cc 100644 --- a/src/media/media.service.spec.ts +++ b/src/media/media.service.spec.ts @@ -12,6 +12,7 @@ import { Repository } from 'typeorm'; import appConfigMock from '../../src/config/mock/app.config.mock'; import { AuthToken } from '../auth/auth-token.entity'; import { Author } from '../authors/author.entity'; +import authConfigMock from '../config/mock/auth.config.mock'; import databaseConfigMock from '../config/mock/database.config.mock'; import mediaConfigMock from '../config/mock/media.config.mock'; import noteConfigMock from '../config/mock/note.config.mock'; @@ -57,6 +58,7 @@ describe('MediaService', () => { mediaConfigMock, appConfigMock, databaseConfigMock, + authConfigMock, noteConfigMock, ], }), diff --git a/src/notes/alias.service.spec.ts b/src/notes/alias.service.spec.ts index 954a0343a..8138674ff 100644 --- a/src/notes/alias.service.spec.ts +++ b/src/notes/alias.service.spec.ts @@ -11,6 +11,7 @@ import { DataSource, EntityManager, Repository } from 'typeorm'; import { AuthToken } from '../auth/auth-token.entity'; import { Author } from '../authors/author.entity'; import appConfigMock from '../config/mock/app.config.mock'; +import authConfigMock from '../config/mock/auth.config.mock'; import databaseConfigMock from '../config/mock/database.config.mock'; import noteConfigMock from '../config/mock/note.config.mock'; import { @@ -25,6 +26,7 @@ import { Identity } from '../identity/identity.entity'; import { LoggerModule } from '../logger/logger.module'; import { NoteGroupPermission } from '../permissions/note-group-permission.entity'; import { NoteUserPermission } from '../permissions/note-user-permission.entity'; +import { RealtimeNoteModule } from '../realtime/realtime-note/realtime-note.module'; import { Edit } from '../revisions/edit.entity'; import { Revision } from '../revisions/revision.entity'; import { RevisionsModule } from '../revisions/revisions.module'; @@ -78,13 +80,19 @@ describe('AliasService', () => { imports: [ ConfigModule.forRoot({ isGlobal: true, - load: [appConfigMock, databaseConfigMock, noteConfigMock], + load: [ + appConfigMock, + databaseConfigMock, + authConfigMock, + noteConfigMock, + ], }), LoggerModule, UsersModule, GroupsModule, RevisionsModule, NotesModule, + RealtimeNoteModule, ], }) .overrideProvider(getRepositoryToken(Note)) diff --git a/src/notes/notes.module.ts b/src/notes/notes.module.ts index 0647e0475..f07774078 100644 --- a/src/notes/notes.module.ts +++ b/src/notes/notes.module.ts @@ -11,6 +11,7 @@ import { GroupsModule } from '../groups/groups.module'; import { LoggerModule } from '../logger/logger.module'; import { NoteGroupPermission } from '../permissions/note-group-permission.entity'; import { NoteUserPermission } from '../permissions/note-user-permission.entity'; +import { RealtimeNoteModule } from '../realtime/realtime-note/realtime-note.module'; import { RevisionsModule } from '../revisions/revisions.module'; import { User } from '../users/user.entity'; import { UsersModule } from '../users/users.module'; @@ -35,6 +36,7 @@ import { Tag } from './tag.entity'; GroupsModule, LoggerModule, ConfigModule, + RealtimeNoteModule, ], controllers: [], providers: [NotesService, AliasService], diff --git a/src/notes/notes.service.spec.ts b/src/notes/notes.service.spec.ts index bfd53b9f5..07189d81e 100644 --- a/src/notes/notes.service.spec.ts +++ b/src/notes/notes.service.spec.ts @@ -11,6 +11,7 @@ import { DataSource, EntityManager, Repository } from 'typeorm'; import { AuthToken } from '../auth/auth-token.entity'; import { Author } from '../authors/author.entity'; import appConfigMock from '../config/mock/app.config.mock'; +import authConfigMock from '../config/mock/auth.config.mock'; import databaseConfigMock from '../config/mock/database.config.mock'; import noteConfigMock from '../config/mock/note.config.mock'; import { @@ -24,6 +25,7 @@ import { Identity } from '../identity/identity.entity'; import { LoggerModule } from '../logger/logger.module'; import { NoteGroupPermission } from '../permissions/note-group-permission.entity'; import { NoteUserPermission } from '../permissions/note-user-permission.entity'; +import { RealtimeNoteModule } from '../realtime/realtime-note/realtime-note.module'; import { Edit } from '../revisions/edit.entity'; import { Revision } from '../revisions/revision.entity'; import { RevisionsModule } from '../revisions/revisions.module'; @@ -172,9 +174,15 @@ describe('NotesService', () => { UsersModule, GroupsModule, RevisionsModule, + RealtimeNoteModule, ConfigModule.forRoot({ isGlobal: true, - load: [appConfigMock, databaseConfigMock, noteConfigMock], + load: [ + appConfigMock, + databaseConfigMock, + authConfigMock, + noteConfigMock, + ], }), ], }) diff --git a/src/notes/notes.service.ts b/src/notes/notes.service.ts index fa6b5a085..b01e24a5a 100644 --- a/src/notes/notes.service.ts +++ b/src/notes/notes.service.ts @@ -16,6 +16,7 @@ import { import { GroupsService } from '../groups/groups.service'; import { HistoryEntry } from '../history/history-entry.entity'; import { ConsoleLoggerService } from '../logger/console-logger.service'; +import { RealtimeNoteService } from '../realtime/realtime-note/realtime-note.service'; import { Revision } from '../revisions/revision.entity'; import { RevisionsService } from '../revisions/revisions.service'; import { User } from '../users/user.entity'; @@ -43,6 +44,7 @@ export class NotesService { @Inject(noteConfiguration.KEY) private noteConfig: NoteConfig, @Inject(forwardRef(() => AliasService)) private aliasService: AliasService, + private realtimeNoteService: RealtimeNoteService, ) { this.logger.setContext(NotesService.name); } @@ -116,7 +118,13 @@ export class NotesService { * @return {string} the content of the note */ async getNoteContent(note: Note): Promise { - return (await this.revisionsService.getLatestRevision(note)).content; + return ( + this.realtimeNoteService + .getRealtimeNote(note.id) + ?.getYDoc() + .getCurrentContent() ?? + (await this.revisionsService.getLatestRevision(note)).content + ); } /** diff --git a/src/permissions/permissions.service.spec.ts b/src/permissions/permissions.service.spec.ts index 84b25e026..91d60f55b 100644 --- a/src/permissions/permissions.service.spec.ts +++ b/src/permissions/permissions.service.spec.ts @@ -11,6 +11,7 @@ import { DataSource, EntityManager, Repository } from 'typeorm'; import { AuthToken } from '../auth/auth-token.entity'; import { Author } from '../authors/author.entity'; import appConfigMock from '../config/mock/app.config.mock'; +import authConfigMock from '../config/mock/auth.config.mock'; import databaseConfigMock from '../config/mock/database.config.mock'; import noteConfigMock from '../config/mock/note.config.mock'; import { PermissionsUpdateInconsistentError } from '../errors/errors'; @@ -93,7 +94,12 @@ describe('PermissionsService', () => { NotesModule, ConfigModule.forRoot({ isGlobal: true, - load: [appConfigMock, databaseConfigMock, noteConfigMock], + load: [ + appConfigMock, + databaseConfigMock, + authConfigMock, + noteConfigMock, + ], }), GroupsModule, ], diff --git a/src/realtime/realtime-note/realtime-note.module.ts b/src/realtime/realtime-note/realtime-note.module.ts new file mode 100644 index 000000000..e34494570 --- /dev/null +++ b/src/realtime/realtime-note/realtime-note.module.ts @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { Module } from '@nestjs/common'; + +import { LoggerModule } from '../../logger/logger.module'; +import { PermissionsModule } from '../../permissions/permissions.module'; +import { RevisionsModule } from '../../revisions/revisions.module'; +import { SessionModule } from '../../session/session.module'; +import { UsersModule } from '../../users/users.module'; +import { RealtimeNoteService } from './realtime-note.service'; + +@Module({ + imports: [ + LoggerModule, + UsersModule, + PermissionsModule, + SessionModule, + RevisionsModule, + ], + exports: [RealtimeNoteService], + providers: [RealtimeNoteService], +}) +export class RealtimeNoteModule {} diff --git a/src/realtime/realtime-note/realtime-note.service.spec.ts b/src/realtime/realtime-note/realtime-note.service.spec.ts new file mode 100644 index 000000000..08142fb7a --- /dev/null +++ b/src/realtime/realtime-note/realtime-note.service.spec.ts @@ -0,0 +1,103 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { Mock } from 'ts-mockery'; + +import { Note } from '../../notes/note.entity'; +import { Revision } from '../../revisions/revision.entity'; +import { RevisionsService } from '../../revisions/revisions.service'; +import * as realtimeNoteModule from './realtime-note'; +import { RealtimeNote } from './realtime-note'; +import { RealtimeNoteService } from './realtime-note.service'; +import { mockRealtimeNote } from './test-utils/mock-realtime-note'; +import { WebsocketAwareness } from './websocket-awareness'; +import { WebsocketDoc } from './websocket-doc'; + +describe('RealtimeNoteService', () => { + let realtimeNoteService: RealtimeNoteService; + let mockedNote: Note; + let mockedRealtimeNote: RealtimeNote; + let realtimeNoteConstructorSpy: jest.SpyInstance; + let revisionsService: RevisionsService; + const mockedContent = 'mockedContent'; + const mockedNoteId = 'mockedNoteId'; + + function mockGetLatestRevision(latestRevisionExists: boolean) { + jest + .spyOn(revisionsService, 'getLatestRevision') + .mockImplementation((note: Note) => + note === mockedNote && latestRevisionExists + ? Promise.resolve( + Mock.of({ + content: mockedContent, + }), + ) + : Promise.reject('Revision for note mockedNoteId not found.'), + ); + } + + beforeEach(async () => { + jest.resetAllMocks(); + jest.resetModules(); + + revisionsService = Mock.of({ + getLatestRevision: jest.fn(), + }); + + realtimeNoteService = new RealtimeNoteService(revisionsService); + + mockedNote = Mock.of({ id: mockedNoteId }); + mockedRealtimeNote = mockRealtimeNote( + Mock.of(), + Mock.of(), + ); + realtimeNoteConstructorSpy = jest + .spyOn(realtimeNoteModule, 'RealtimeNote') + .mockReturnValue(mockedRealtimeNote); + }); + + it("creates a new realtime note if it doesn't exist yet", async () => { + mockGetLatestRevision(true); + await expect( + realtimeNoteService.getOrCreateRealtimeNote(mockedNote), + ).resolves.toBe(mockedRealtimeNote); + expect(realtimeNoteConstructorSpy).toBeCalledWith( + mockedNoteId, + mockedContent, + ); + expect(realtimeNoteService.getRealtimeNote(mockedNoteId)).toBe( + mockedRealtimeNote, + ); + }); + + it("fails if the requested note doesn't exist", async () => { + mockGetLatestRevision(false); + await expect( + realtimeNoteService.getOrCreateRealtimeNote(mockedNote), + ).rejects.toBe(`Revision for note mockedNoteId not found.`); + expect(realtimeNoteConstructorSpy).not.toBeCalled(); + expect(realtimeNoteService.getRealtimeNote(mockedNoteId)).toBeUndefined(); + }); + + it("doesn't create a new realtime note if there is already one", async () => { + mockGetLatestRevision(true); + await expect( + realtimeNoteService.getOrCreateRealtimeNote(mockedNote), + ).resolves.toBe(mockedRealtimeNote); + await expect( + realtimeNoteService.getOrCreateRealtimeNote(mockedNote), + ).resolves.toBe(mockedRealtimeNote); + expect(realtimeNoteConstructorSpy).toBeCalledTimes(1); + }); + + it('deletes the realtime from the map if the realtime note is destroyed', async () => { + mockGetLatestRevision(true); + await expect( + realtimeNoteService.getOrCreateRealtimeNote(mockedNote), + ).resolves.toBe(mockedRealtimeNote); + mockedRealtimeNote.emit('destroy'); + expect(realtimeNoteService.getRealtimeNote(mockedNoteId)).toBeUndefined(); + }); +}); diff --git a/src/realtime/realtime-note/realtime-note.service.ts b/src/realtime/realtime-note/realtime-note.service.ts new file mode 100644 index 000000000..d63b5ac92 --- /dev/null +++ b/src/realtime/realtime-note/realtime-note.service.ts @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { Injectable } from '@nestjs/common'; + +import { Note } from '../../notes/note.entity'; +import { RevisionsService } from '../../revisions/revisions.service'; +import { RealtimeNote } from './realtime-note'; + +@Injectable() +export class RealtimeNoteService { + constructor(private revisionsService: RevisionsService) {} + + private noteIdToRealtimeNote = new Map(); + + /** + * Creates or reuses a {@link RealtimeNote} that is handling the real time editing of the {@link Note} which is identified by the given note id. + * @param note The for which a {@link RealtimeNote realtime note} should be retrieved. + * @throws NotInDBError if note doesn't exist or has no revisions. + * @return A {@link RealtimeNote} that is linked to the given note. + */ + public async getOrCreateRealtimeNote(note: Note): Promise { + return ( + this.noteIdToRealtimeNote.get(note.id) ?? + (await this.createNewRealtimeNote(note)) + ); + } + + /** + * Creates a new {@link RealtimeNote} for the given {@link Note} and memorizes it. + * + * @param note The note for which the realtime note should be created + * @throws NotInDBError if note doesn't exist or has no revisions. + * @return The created realtime note + */ + private async createNewRealtimeNote(note: Note): Promise { + const initialContent = (await this.revisionsService.getLatestRevision(note)) + .content; + const realtimeNote = new RealtimeNote(note.id, initialContent); + realtimeNote.on('destroy', () => { + this.noteIdToRealtimeNote.delete(note.id); + }); + this.noteIdToRealtimeNote.set(note.id, realtimeNote); + return realtimeNote; + } + + /** + * Retrieves a {@link RealtimeNote} that is linked to the given {@link Note} id. + * @param noteId The id of the {@link Note} + * @return A {@link RealtimeNote} or {@code undefined} if no instance is existing. + */ + public getRealtimeNote(noteId: string): RealtimeNote | undefined { + return this.noteIdToRealtimeNote.get(noteId); + } +} diff --git a/src/realtime/realtime-note/realtime-note.spec.ts b/src/realtime/realtime-note/realtime-note.spec.ts new file mode 100644 index 000000000..b4396d43b --- /dev/null +++ b/src/realtime/realtime-note/realtime-note.spec.ts @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { RealtimeNote } from './realtime-note'; +import { mockAwareness } from './test-utils/mock-awareness'; +import { mockConnection } from './test-utils/mock-connection'; +import { mockWebsocketDoc } from './test-utils/mock-websocket-doc'; +import * as websocketAwarenessModule from './websocket-awareness'; +import { WebsocketAwareness } from './websocket-awareness'; +import * as websocketDocModule from './websocket-doc'; +import { WebsocketDoc } from './websocket-doc'; + +describe('realtime note', () => { + let mockedDoc: WebsocketDoc; + let mockedAwareness: WebsocketAwareness; + + beforeEach(() => { + jest.resetAllMocks(); + jest.resetModules(); + mockedDoc = mockWebsocketDoc(); + mockedAwareness = mockAwareness(); + jest + .spyOn(websocketDocModule, 'WebsocketDoc') + .mockImplementation(() => mockedDoc); + jest + .spyOn(websocketAwarenessModule, 'WebsocketAwareness') + .mockImplementation(() => mockedAwareness); + }); + + afterAll(() => { + jest.resetAllMocks(); + jest.resetModules(); + }); + + it('can connect and disconnect clients', () => { + const sut = new RealtimeNote('mock-note', 'nothing'); + const client1 = mockConnection(true); + sut.addClient(client1); + expect(sut.getConnections()).toStrictEqual([client1]); + expect(sut.hasConnections()).toBeTruthy(); + sut.removeClient(client1); + expect(sut.getConnections()).toStrictEqual([]); + expect(sut.hasConnections()).toBeFalsy(); + }); + + it('creates a y-doc and y-awareness', () => { + const sut = new RealtimeNote('mock-note', 'nothing'); + expect(sut.getYDoc()).toBe(mockedDoc); + expect(sut.getAwareness()).toBe(mockedAwareness); + }); + + it('destroys y-doc and y-awareness on self-destruction', () => { + const sut = new RealtimeNote('mock-note', 'nothing'); + const docDestroy = jest.spyOn(mockedDoc, 'destroy'); + const awarenessDestroy = jest.spyOn(mockedAwareness, 'destroy'); + sut.destroy(); + expect(docDestroy).toBeCalled(); + expect(awarenessDestroy).toBeCalled(); + }); + + it('emits destroy event on destruction', async () => { + const sut = new RealtimeNote('mock-note', 'nothing'); + const destroyPromise = new Promise((resolve) => { + sut.once('destroy', () => { + resolve(); + }); + }); + sut.destroy(); + await expect(destroyPromise).resolves.not.toThrow(); + }); + + it("doesn't destroy a destroyed note", () => { + const sut = new RealtimeNote('mock-note', 'nothing'); + sut.destroy(); + expect(() => sut.destroy()).toThrow(); + }); +}); diff --git a/src/realtime/realtime-note/realtime-note.ts b/src/realtime/realtime-note/realtime-note.ts new file mode 100644 index 000000000..0c6b54ef6 --- /dev/null +++ b/src/realtime/realtime-note/realtime-note.ts @@ -0,0 +1,121 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { Logger } from '@nestjs/common'; +import { EventEmitter } from 'events'; +import TypedEventEmitter, { EventMap } from 'typed-emitter'; +import { Awareness } from 'y-protocols/awareness'; + +import { WebsocketAwareness } from './websocket-awareness'; +import { WebsocketConnection } from './websocket-connection'; +import { WebsocketDoc } from './websocket-doc'; + +export type RealtimeNoteEvents = { + destroy: () => void; +}; + +type TypedEventEmitterConstructor = + new () => TypedEventEmitter; + +/** + * Represents a note currently being edited by a number of clients. + */ +export class RealtimeNote extends (EventEmitter as TypedEventEmitterConstructor) { + protected logger: Logger; + private readonly websocketDoc: WebsocketDoc; + private readonly websocketAwareness: WebsocketAwareness; + private readonly clients = new Set(); + private isClosing = false; + + constructor(private readonly noteId: string, initialContent: string) { + super(); + this.logger = new Logger(`${RealtimeNote.name} ${noteId}`); + this.websocketDoc = new WebsocketDoc(this, initialContent); + this.websocketAwareness = new WebsocketAwareness(this); + this.logger.debug(`New realtime session for note ${noteId} created.`); + } + + /** + * Connects a new client to the note. + * + * For this purpose a {@link WebsocketConnection} is created and added to the client map. + * + * @param client the websocket connection to the client + */ + public addClient(client: WebsocketConnection): void { + this.clients.add(client); + this.logger.debug(`User '${client.getUser().username}' connected`); + } + + /** + * Disconnects the given websocket client while cleaning-up if it was the last user in the realtime note. + * + * @param {WebSocket} client The websocket client that disconnects. + */ + public removeClient(client: WebsocketConnection): void { + this.clients.delete(client); + this.logger.debug( + `User '${client.getUser().username}' disconnected. ${ + this.clients.size + } clients left.`, + ); + if (!this.hasConnections() && !this.isClosing) { + this.destroy(); + } + } + + /** + * Destroys the current realtime note by deleting the y-js doc and disconnecting all clients. + * + * @throws Error if note has already been destroyed + */ + public destroy(): void { + if (this.isClosing) { + throw new Error('Note already destroyed'); + } + this.logger.debug('Destroying realtime note.'); + this.isClosing = true; + this.websocketDoc.destroy(); + this.websocketAwareness.destroy(); + this.clients.forEach((value) => value.disconnect()); + this.emit('destroy'); + } + + /** + * Checks if there's still clients connected to this note. + * + * @return {@code true} if there a still clinets connected, otherwise {@code false} + */ + public hasConnections(): boolean { + return this.clients.size !== 0; + } + + /** + * Returns all {@link WebsocketConnection WebsocketConnections} currently hold by this note. + * + * @return an array of {@link WebsocketConnection WebsocketConnections} + */ + public getConnections(): WebsocketConnection[] { + return [...this.clients]; + } + + /** + * Get the {@link Doc YDoc} of the note. + * + * @return the {@link Doc YDoc} of the note + */ + public getYDoc(): WebsocketDoc { + return this.websocketDoc; + } + + /** + * Get the {@link Awareness YAwareness} of the note. + * + * @return the {@link Awareness YAwareness} of the note + */ + public getAwareness(): Awareness { + return this.websocketAwareness; + } +} diff --git a/src/realtime/realtime-note/test-utils/mock-awareness.ts b/src/realtime/realtime-note/test-utils/mock-awareness.ts new file mode 100644 index 000000000..d67b36e76 --- /dev/null +++ b/src/realtime/realtime-note/test-utils/mock-awareness.ts @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { Observable } from 'lib0/observable'; +import { Mock } from 'ts-mockery'; + +import { WebsocketAwareness } from '../websocket-awareness'; + +class MockAwareness extends Observable { + destroy(): void { + //intentionally left blank + } +} + +/** + * Provides a partial mock for {@link WebsocketAwareness}. + */ +export function mockAwareness(): WebsocketAwareness { + return Mock.from(new MockAwareness()); +} diff --git a/src/realtime/realtime-note/test-utils/mock-connection.ts b/src/realtime/realtime-note/test-utils/mock-connection.ts new file mode 100644 index 000000000..53b9f62d4 --- /dev/null +++ b/src/realtime/realtime-note/test-utils/mock-connection.ts @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { Mock } from 'ts-mockery'; + +import { User } from '../../../users/user.entity'; +import { WebsocketConnection } from '../websocket-connection'; + +/** + * Provides a partial mock for {@link WebsocketConnection}. + * + * @param synced Defines the return value for the `isSynced` function. + */ +export function mockConnection(synced: boolean): WebsocketConnection { + return Mock.of({ + isSynced: jest.fn(() => synced), + send: jest.fn(), + getUser: jest.fn(() => Mock.of({ username: 'mockedUser' })), + }); +} diff --git a/src/realtime/realtime-note/test-utils/mock-realtime-note.ts b/src/realtime/realtime-note/test-utils/mock-realtime-note.ts new file mode 100644 index 000000000..0374ada40 --- /dev/null +++ b/src/realtime/realtime-note/test-utils/mock-realtime-note.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { EventEmitter } from 'events'; +import { Mock } from 'ts-mockery'; +import TypedEmitter from 'typed-emitter'; + +import { RealtimeNote, RealtimeNoteEvents } from '../realtime-note'; +import { WebsocketAwareness } from '../websocket-awareness'; +import { WebsocketDoc } from '../websocket-doc'; + +class MockRealtimeNote extends (EventEmitter as new () => TypedEmitter) { + constructor( + private doc: WebsocketDoc, + private awareness: WebsocketAwareness, + ) { + super(); + } + + public getYDoc(): WebsocketDoc { + return this.doc; + } + + public getAwareness(): WebsocketAwareness { + return this.awareness; + } + + public removeClient(): void { + //left blank for mock + } +} + +/** + * Provides a partial mock for {@link RealtimeNote} + * @param doc Defines the return value for `getYDoc` + * @param awareness Defines the return value for `getAwareness` + */ +export function mockRealtimeNote( + doc: WebsocketDoc, + awareness: WebsocketAwareness, +): RealtimeNote { + return Mock.from(new MockRealtimeNote(doc, awareness)); +} diff --git a/src/realtime/realtime-note/test-utils/mock-websocket-doc.ts b/src/realtime/realtime-note/test-utils/mock-websocket-doc.ts new file mode 100644 index 000000000..820e8de1c --- /dev/null +++ b/src/realtime/realtime-note/test-utils/mock-websocket-doc.ts @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { Mock } from 'ts-mockery'; + +import { WebsocketDoc } from '../websocket-doc'; + +/** + * Provides a partial mock for {@link WebsocketDoc}. + */ +export function mockWebsocketDoc(): WebsocketDoc { + return Mock.of({ + on: jest.fn(), + destroy: jest.fn(), + }); +} diff --git a/src/realtime/realtime-note/test-utils/mock-websocket-transporter.ts b/src/realtime/realtime-note/test-utils/mock-websocket-transporter.ts new file mode 100644 index 000000000..46ef5c887 --- /dev/null +++ b/src/realtime/realtime-note/test-utils/mock-websocket-transporter.ts @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { WebsocketTransporter } from '@hedgedoc/realtime'; +import { MessageTransporterEvents } from '@hedgedoc/realtime/dist/mjs/y-doc-message-transporter'; +import { EventEmitter } from 'events'; +import { Mock } from 'ts-mockery'; +import TypedEmitter from 'typed-emitter'; + +class MockMessageTransporter extends (EventEmitter as new () => TypedEmitter) { + setupWebsocket(): void { + //intentionally left blank + } + + send(): void { + //intentionally left blank + } + + isSynced(): boolean { + return false; + } + + disconnect(): void { + //intentionally left blank + } +} + +/** + * Provides a partial mock for {@link WebsocketTransporter}. + */ +export function mockWebsocketTransporter(): WebsocketTransporter { + return Mock.from(new MockMessageTransporter()); +} diff --git a/src/realtime/realtime-note/websocket-awareness.spec.ts b/src/realtime/realtime-note/websocket-awareness.spec.ts new file mode 100644 index 000000000..86b5803c5 --- /dev/null +++ b/src/realtime/realtime-note/websocket-awareness.spec.ts @@ -0,0 +1,60 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import * as hedgedocRealtimeModule from '@hedgedoc/realtime'; +import { Mock } from 'ts-mockery'; + +import { RealtimeNote } from './realtime-note'; +import { mockConnection } from './test-utils/mock-connection'; +import { ClientIdUpdate, WebsocketAwareness } from './websocket-awareness'; +import { WebsocketConnection } from './websocket-connection'; +import { WebsocketDoc } from './websocket-doc'; + +describe('websocket-awareness', () => { + it('distributes content updates to other synced clients', () => { + const mockEncodedUpdate = new Uint8Array([0, 1, 2, 3]); + const mockedEncodeUpdateFunction = jest.spyOn( + hedgedocRealtimeModule, + 'encodeAwarenessUpdateMessage', + ); + mockedEncodeUpdateFunction.mockReturnValue(mockEncodedUpdate); + + const mockConnection1 = mockConnection(true); + const mockConnection2 = mockConnection(false); + const mockConnection3 = mockConnection(true); + const send1 = jest.spyOn(mockConnection1, 'send'); + const send2 = jest.spyOn(mockConnection2, 'send'); + const send3 = jest.spyOn(mockConnection3, 'send'); + + const realtimeNote = Mock.of({ + getYDoc(): WebsocketDoc { + return Mock.of({ + on() { + //mocked + }, + }); + }, + getConnections(): WebsocketConnection[] { + return [mockConnection1, mockConnection2, mockConnection3]; + }, + }); + + const websocketAwareness = new WebsocketAwareness(realtimeNote); + const mockUpdate: ClientIdUpdate = { + added: [1], + updated: [2], + removed: [3], + }; + websocketAwareness.emit('update', [mockUpdate, mockConnection1]); + expect(send1).not.toBeCalled(); + expect(send2).not.toBeCalled(); + expect(send3).toBeCalledWith(mockEncodedUpdate); + expect(mockedEncodeUpdateFunction).toBeCalledWith( + websocketAwareness, + [1, 2, 3], + ); + websocketAwareness.destroy(); + }); +}); diff --git a/src/realtime/realtime-note/websocket-awareness.ts b/src/realtime/realtime-note/websocket-awareness.ts new file mode 100644 index 000000000..60cc55936 --- /dev/null +++ b/src/realtime/realtime-note/websocket-awareness.ts @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { encodeAwarenessUpdateMessage } from '@hedgedoc/realtime'; +import { Awareness } from 'y-protocols/awareness'; + +import { RealtimeNote } from './realtime-note'; + +export interface ClientIdUpdate { + added: number[]; + updated: number[]; + removed: number[]; +} + +/** + * This is the implementation of {@link Awareness YAwareness} which includes additional handlers for message sending and receiving. + */ +export class WebsocketAwareness extends Awareness { + constructor(private realtimeNote: RealtimeNote) { + super(realtimeNote.getYDoc()); + this.setLocalState(null); + this.on('update', this.distributeAwarenessUpdate.bind(this)); + } + + /** + * Distributes the given awareness changes to all clients. + * + * @param added Properties that were added to the awareness state + * @param updated Properties that were updated in the awareness state + * @param removed Properties that were removed from the awareness state + * @param origin An object that is used as reference for the origin of the update + */ + private distributeAwarenessUpdate( + { added, updated, removed }: ClientIdUpdate, + origin: unknown, + ): void { + const binaryUpdate = encodeAwarenessUpdateMessage(this, [ + ...added, + ...updated, + ...removed, + ]); + this.realtimeNote + .getConnections() + .filter((client) => client !== origin && client.isSynced()) + .forEach((client) => client.send(binaryUpdate)); + } +} diff --git a/src/realtime/realtime-note/websocket-connection.spec.ts b/src/realtime/realtime-note/websocket-connection.spec.ts new file mode 100644 index 000000000..23d9c816f --- /dev/null +++ b/src/realtime/realtime-note/websocket-connection.spec.ts @@ -0,0 +1,190 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import * as hedgedocRealtimeModule from '@hedgedoc/realtime'; +import { WebsocketTransporter } from '@hedgedoc/realtime'; +import { Mock } from 'ts-mockery'; +import WebSocket from 'ws'; +import * as yProtocolsAwarenessModule from 'y-protocols/awareness'; + +import { User } from '../../users/user.entity'; +import * as realtimeNoteModule from './realtime-note'; +import { RealtimeNote } from './realtime-note'; +import { mockAwareness } from './test-utils/mock-awareness'; +import { mockRealtimeNote } from './test-utils/mock-realtime-note'; +import { mockWebsocketDoc } from './test-utils/mock-websocket-doc'; +import { mockWebsocketTransporter } from './test-utils/mock-websocket-transporter'; +import * as websocketAwarenessModule from './websocket-awareness'; +import { ClientIdUpdate, WebsocketAwareness } from './websocket-awareness'; +import { WebsocketConnection } from './websocket-connection'; +import * as websocketDocModule from './websocket-doc'; +import { WebsocketDoc } from './websocket-doc'; + +import SpyInstance = jest.SpyInstance; + +describe('websocket connection', () => { + let mockedDoc: WebsocketDoc; + let mockedAwareness: WebsocketAwareness; + let mockedRealtimeNote: RealtimeNote; + let mockedWebsocket: WebSocket; + let mockedUser: User; + let mockedWebsocketTransporter: WebsocketTransporter; + let removeAwarenessSpy: SpyInstance; + + beforeEach(() => { + jest.resetAllMocks(); + jest.resetModules(); + mockedDoc = mockWebsocketDoc(); + mockedAwareness = mockAwareness(); + mockedRealtimeNote = mockRealtimeNote(mockedDoc, mockedAwareness); + mockedWebsocket = Mock.of({}); + mockedUser = Mock.of({}); + mockedWebsocketTransporter = mockWebsocketTransporter(); + + jest + .spyOn(realtimeNoteModule, 'RealtimeNote') + .mockImplementation(() => mockedRealtimeNote); + jest + .spyOn(websocketDocModule, 'WebsocketDoc') + .mockImplementation(() => mockedDoc); + jest + .spyOn(websocketAwarenessModule, 'WebsocketAwareness') + .mockImplementation(() => mockedAwareness); + jest + .spyOn(hedgedocRealtimeModule, 'WebsocketTransporter') + .mockImplementation(() => mockedWebsocketTransporter); + + removeAwarenessSpy = jest + .spyOn(yProtocolsAwarenessModule, 'removeAwarenessStates') + .mockImplementation(); + }); + + afterAll(() => { + jest.resetAllMocks(); + jest.resetModules(); + }); + + it('sets up the websocket in the constructor', () => { + const setupWebsocketSpy = jest.spyOn( + mockedWebsocketTransporter, + 'setupWebsocket', + ); + + new WebsocketConnection(mockedWebsocket, mockedUser, mockedRealtimeNote); + + expect(setupWebsocketSpy).toBeCalledWith(mockedWebsocket); + }); + + it('forwards sent messages to the transporter', () => { + const sut = new WebsocketConnection( + mockedWebsocket, + mockedUser, + mockedRealtimeNote, + ); + + const sendFunctionSpy = jest.spyOn(mockedWebsocketTransporter, 'send'); + const sendContent = new Uint8Array(); + sut.send(sendContent); + expect(sendFunctionSpy).toBeCalledWith(sendContent); + }); + + it('forwards disconnect calls to the transporter', () => { + const sut = new WebsocketConnection( + mockedWebsocket, + mockedUser, + mockedRealtimeNote, + ); + + const disconnectFunctionSpy = jest.spyOn( + mockedWebsocketTransporter, + 'disconnect', + ); + sut.disconnect(); + expect(disconnectFunctionSpy).toBeCalled(); + }); + + it('forwards isSynced checks to the transporter', () => { + const sut = new WebsocketConnection( + mockedWebsocket, + mockedUser, + mockedRealtimeNote, + ); + + const isSyncedFunctionSpy = jest.spyOn( + mockedWebsocketTransporter, + 'isSynced', + ); + + expect(sut.isSynced()).toBe(false); + + isSyncedFunctionSpy.mockReturnValue(true); + expect(sut.isSynced()).toBe(true); + }); + + it('removes the client from the note on transporter disconnect', () => { + const sut = new WebsocketConnection( + mockedWebsocket, + mockedUser, + mockedRealtimeNote, + ); + + const removeClientSpy = jest.spyOn(mockedRealtimeNote, 'removeClient'); + + mockedWebsocketTransporter.emit('disconnected'); + + expect(removeClientSpy).toBeCalledWith(sut); + }); + + it('remembers the controlled awareness-ids on awareness update', () => { + const sut = new WebsocketConnection( + mockedWebsocket, + mockedUser, + mockedRealtimeNote, + ); + + const update: ClientIdUpdate = { added: [0], removed: [1], updated: [2] }; + mockedAwareness.emit('update', [update, sut]); + + expect(sut.getControlledAwarenessIds()).toEqual(new Set([0])); + }); + + it("doesn't remembers the controlled awareness-ids of other connections on awareness update", () => { + const sut = new WebsocketConnection( + mockedWebsocket, + mockedUser, + mockedRealtimeNote, + ); + + const update: ClientIdUpdate = { added: [0], removed: [1], updated: [2] }; + mockedAwareness.emit('update', [update, Mock.of()]); + + expect(sut.getControlledAwarenessIds()).toEqual(new Set([])); + }); + + it('removes the controlled awareness ids on transport disconnect', () => { + const sut = new WebsocketConnection( + mockedWebsocket, + mockedUser, + mockedRealtimeNote, + ); + + const update: ClientIdUpdate = { added: [0], removed: [1], updated: [2] }; + mockedAwareness.emit('update', [update, sut]); + + mockedWebsocketTransporter.emit('disconnected'); + + expect(removeAwarenessSpy).toBeCalledWith(mockedAwareness, [0], sut); + }); + + it('saves the correct user', () => { + const sut = new WebsocketConnection( + mockedWebsocket, + mockedUser, + mockedRealtimeNote, + ); + + expect(sut.getUser()).toBe(mockedUser); + }); +}); diff --git a/src/realtime/realtime-note/websocket-connection.ts b/src/realtime/realtime-note/websocket-connection.ts new file mode 100644 index 000000000..dca73ff11 --- /dev/null +++ b/src/realtime/realtime-note/websocket-connection.ts @@ -0,0 +1,100 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { WebsocketTransporter } from '@hedgedoc/realtime'; +import { Logger } from '@nestjs/common'; +import WebSocket from 'ws'; +import { Awareness, removeAwarenessStates } from 'y-protocols/awareness'; + +import { User } from '../../users/user.entity'; +import { RealtimeNote } from './realtime-note'; +import { ClientIdUpdate } from './websocket-awareness'; + +/** + * Manages the websocket connection to a specific client. + */ +export class WebsocketConnection { + protected readonly logger = new Logger(WebsocketConnection.name); + private controlledAwarenessIds: Set = new Set(); + private transporter: WebsocketTransporter; + + /** + * Instantiates the websocket connection wrapper for a websocket connection. + * + * @param websocket The client's raw websocket. + * @param user The user of the client + * @param realtimeNote The {@link RealtimeNote} that the client connected to. + * @throws Error if the socket is not open + */ + constructor( + websocket: WebSocket, + private user: User, + realtimeNote: RealtimeNote, + ) { + const awareness = realtimeNote.getAwareness(); + this.transporter = new WebsocketTransporter( + realtimeNote.getYDoc(), + awareness, + ); + this.transporter.on('disconnected', () => { + realtimeNote.removeClient(this); + }); + this.transporter.setupWebsocket(websocket); + this.bindAwarenessMessageEvents(awareness); + } + + /** + * Binds all additional events that are needed for awareness processing. + */ + private bindAwarenessMessageEvents(awareness: Awareness): void { + const callback = this.updateControlledAwarenessIds.bind(this); + awareness.on('update', callback); + this.transporter.on('disconnected', () => { + awareness.off('update', callback); + removeAwarenessStates(awareness, [...this.controlledAwarenessIds], this); + }); + } + + private updateControlledAwarenessIds( + { added, removed }: ClientIdUpdate, + origin: WebsocketConnection, + ): void { + if (origin === this) { + added.forEach((id) => this.controlledAwarenessIds.add(id)); + removed.forEach((id) => this.controlledAwarenessIds.delete(id)); + } + } + + /** + * Defines if the current connection has received at least one full synchronisation. + */ + public isSynced(): boolean { + return this.transporter.isSynced(); + } + + /** + * Sends the given content to the client. + * + * @param content The content to send + */ + public send(content: Uint8Array): void { + this.transporter.send(content); + } + + /** + * Stops the connection + */ + public disconnect(): void { + this.transporter.disconnect(); + } + + public getControlledAwarenessIds(): ReadonlySet { + return this.controlledAwarenessIds; + } + + public getUser(): User { + return this.user; + } +} diff --git a/src/realtime/realtime-note/websocket-doc.spec.ts b/src/realtime/realtime-note/websocket-doc.spec.ts new file mode 100644 index 000000000..a1ed7d428 --- /dev/null +++ b/src/realtime/realtime-note/websocket-doc.spec.ts @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import * as hedgedocRealtimeModule from '@hedgedoc/realtime'; +import { Mock } from 'ts-mockery'; + +import { RealtimeNote } from './realtime-note'; +import { mockConnection } from './test-utils/mock-connection'; +import { WebsocketConnection } from './websocket-connection'; +import { WebsocketDoc } from './websocket-doc'; + +describe('websocket-doc', () => { + it('saves the initial content', () => { + const textContent = 'textContent'; + const websocketDoc = new WebsocketDoc(Mock.of(), textContent); + + expect(websocketDoc.getCurrentContent()).toBe(textContent); + }); + + it('distributes content updates to other synced clients', () => { + const mockEncodedUpdate = new Uint8Array([0, 1, 2, 3]); + const mockedEncodeUpdateFunction = jest.spyOn( + hedgedocRealtimeModule, + 'encodeDocumentUpdateMessage', + ); + mockedEncodeUpdateFunction.mockReturnValue(mockEncodedUpdate); + + const mockConnection1 = mockConnection(true); + const mockConnection2 = mockConnection(false); + const mockConnection3 = mockConnection(true); + + const send1 = jest.spyOn(mockConnection1, 'send'); + const send2 = jest.spyOn(mockConnection2, 'send'); + const send3 = jest.spyOn(mockConnection3, 'send'); + + const realtimeNote = Mock.of({ + getConnections(): WebsocketConnection[] { + return [mockConnection1, mockConnection2, mockConnection3]; + }, + getYDoc(): WebsocketDoc { + return websocketDoc; + }, + }); + + const websocketDoc = new WebsocketDoc(realtimeNote, ''); + const mockUpdate = new Uint8Array([4, 5, 6, 7]); + websocketDoc.emit('update', [mockUpdate, mockConnection1]); + expect(send1).not.toBeCalled(); + expect(send2).not.toBeCalled(); + expect(send3).toBeCalledWith(mockEncodedUpdate); + expect(mockedEncodeUpdateFunction).toBeCalledWith(mockUpdate); + websocketDoc.destroy(); + }); +}); diff --git a/src/realtime/realtime-note/websocket-doc.ts b/src/realtime/realtime-note/websocket-doc.ts new file mode 100644 index 000000000..effb43f20 --- /dev/null +++ b/src/realtime/realtime-note/websocket-doc.ts @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { encodeDocumentUpdateMessage } from '@hedgedoc/realtime'; +import { Doc } from 'yjs'; + +import { RealtimeNote } from './realtime-note'; +import { WebsocketConnection } from './websocket-connection'; + +/** + * This is the implementation of {@link Doc YDoc} which includes additional handlers for message sending and receiving. + */ +export class WebsocketDoc extends Doc { + private static readonly channelName = 'markdownContent'; + + /** + * Creates a new WebsocketDoc instance. + * + * The new instance is filled with the given initial content and an event listener will be registered to handle + * updates to the doc. + * + * @param realtimeNote - the {@link RealtimeNote} handling this {@link Doc YDoc} + * @param initialContent - the initial content of the {@link Doc YDoc} + */ + constructor(private realtimeNote: RealtimeNote, initialContent: string) { + super(); + this.initializeContent(initialContent); + this.bindUpdateEvent(); + } + + /** + * Binds the event that distributes updates in the current {@link Doc y-doc} to all clients. + */ + private bindUpdateEvent(): void { + this.on('update', (update: Uint8Array, origin: WebsocketConnection) => { + const clients = this.realtimeNote + .getConnections() + .filter((client) => client !== origin && client.isSynced()); + if (clients.length > 0) { + clients.forEach((client) => { + client.send(encodeDocumentUpdateMessage(update)); + }); + } + }); + } + + /** + * Sets the {@link YDoc's Doc} content to include the initialContent. + * + * This message should only be called when a new {@link RealtimeNote } is created. + * + * @param initialContent - the initial content to set the {@link Doc YDoc's} content to. + * @private + */ + private initializeContent(initialContent: string): void { + this.getText(WebsocketDoc.channelName).insert(0, initialContent); + } + + /** + * Gets the current content of the note as it's currently edited in realtime. + * + * Please be aware that the return of this method may be very quickly outdated. + * + * @return The current note content. + */ + public getCurrentContent(): string { + return this.getText(WebsocketDoc.channelName).toString(); + } +} diff --git a/src/realtime/websocket/utils/extract-note-id-from-request-url.spec.ts b/src/realtime/websocket/utils/extract-note-id-from-request-url.spec.ts new file mode 100644 index 000000000..b74713ffe --- /dev/null +++ b/src/realtime/websocket/utils/extract-note-id-from-request-url.spec.ts @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { IncomingMessage } from 'http'; +import { Mock } from 'ts-mockery'; + +import { extractNoteIdFromRequestUrl } from './extract-note-id-from-request-url'; + +describe('extract note id from path', () => { + it('fails if no URL is present', () => { + const mockedRequest = Mock.of(); + expect(() => extractNoteIdFromRequestUrl(mockedRequest)).toThrow(); + }); + + it('can find a note id', () => { + const mockedRequest = Mock.of({ + url: '/realtime?noteId=somethingsomething', + }); + expect(extractNoteIdFromRequestUrl(mockedRequest)).toBe( + 'somethingsomething', + ); + }); + + it('fails if no note id is present', () => { + const mockedRequest = Mock.of({ + url: '/realtime?nöteId=somethingsomething', + }); + expect(() => extractNoteIdFromRequestUrl(mockedRequest)).toThrow(); + }); + + it('fails if path is empty', () => { + const mockedRequest = Mock.of({ + url: '', + }); + expect(() => extractNoteIdFromRequestUrl(mockedRequest)).toThrow(); + }); +}); diff --git a/src/realtime/websocket/utils/extract-note-id-from-request-url.ts b/src/realtime/websocket/utils/extract-note-id-from-request-url.ts new file mode 100644 index 000000000..9b87f491a --- /dev/null +++ b/src/realtime/websocket/utils/extract-note-id-from-request-url.ts @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { IncomingMessage } from 'http'; + +/** + * Extracts the note id from the url of the given request. + * + * @param request The request whose URL should be extracted + * @return The extracted note id + * @throws Error if the given string isn't a valid realtime URL path + */ +export function extractNoteIdFromRequestUrl(request: IncomingMessage): string { + if (request.url === undefined) { + throw new Error('No URL found in request'); + } + // A valid domain name is needed for the URL constructor, although not being used here. + // The example.org domain should be safe to use according to RFC 6761 §6.5. + const url = new URL(request.url, 'https://example.org'); + const noteId = url.searchParams.get('noteId'); + if (noteId === null) { + throw new Error("Path doesn't contain parameter noteId"); + } else { + return noteId; + } +} diff --git a/src/realtime/websocket/websocket.gateway.spec.ts b/src/realtime/websocket/websocket.gateway.spec.ts new file mode 100644 index 000000000..ac891426a --- /dev/null +++ b/src/realtime/websocket/websocket.gateway.spec.ts @@ -0,0 +1,364 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { ConfigModule } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { IncomingMessage } from 'http'; +import { Mock } from 'ts-mockery'; +import { Repository } from 'typeorm'; +import WebSocket from 'ws'; + +import { AuthToken } from '../../auth/auth-token.entity'; +import { Author } from '../../authors/author.entity'; +import appConfigMock from '../../config/mock/app.config.mock'; +import authConfigMock from '../../config/mock/auth.config.mock'; +import databaseConfigMock from '../../config/mock/database.config.mock'; +import noteConfigMock from '../../config/mock/note.config.mock'; +import { Group } from '../../groups/group.entity'; +import { Identity } from '../../identity/identity.entity'; +import { LoggerModule } from '../../logger/logger.module'; +import { Alias } from '../../notes/alias.entity'; +import { Note } from '../../notes/note.entity'; +import { NotesModule } from '../../notes/notes.module'; +import { NotesService } from '../../notes/notes.service'; +import { Tag } from '../../notes/tag.entity'; +import { NoteGroupPermission } from '../../permissions/note-group-permission.entity'; +import { NoteUserPermission } from '../../permissions/note-user-permission.entity'; +import { PermissionsModule } from '../../permissions/permissions.module'; +import { PermissionsService } from '../../permissions/permissions.service'; +import { Edit } from '../../revisions/edit.entity'; +import { Revision } from '../../revisions/revision.entity'; +import { SessionModule } from '../../session/session.module'; +import { SessionService } from '../../session/session.service'; +import { Session } from '../../users/session.entity'; +import { User } from '../../users/user.entity'; +import { UsersModule } from '../../users/users.module'; +import { UsersService } from '../../users/users.service'; +import { RealtimeNote } from '../realtime-note/realtime-note'; +import { RealtimeNoteModule } from '../realtime-note/realtime-note.module'; +import { RealtimeNoteService } from '../realtime-note/realtime-note.service'; +import * as websocketConnectionModule from '../realtime-note/websocket-connection'; +import { WebsocketConnection } from '../realtime-note/websocket-connection'; +import * as extractNoteIdFromRequestUrlModule from './utils/extract-note-id-from-request-url'; +import { WebsocketGateway } from './websocket.gateway'; + +import SpyInstance = jest.SpyInstance; + +describe('Websocket gateway', () => { + let gateway: WebsocketGateway; + let sessionService: SessionService; + let usersService: UsersService; + let notesService: NotesService; + let realtimeNoteService: RealtimeNoteService; + let permissionsService: PermissionsService; + let mockedWebsocketConnection: WebsocketConnection; + let mockedWebsocket: WebSocket; + let mockedWebsocketCloseSpy: SpyInstance; + let addClientSpy: SpyInstance; + + const mockedValidSessionCookie = 'mockedValidSessionCookie'; + const mockedSessionIdWithUser = 'mockedSessionIdWithUser'; + const mockedValidUrl = 'mockedValidUrl'; + const mockedValidNoteId = 'mockedValidNoteId'; + + let sessionExistsForUser = true; + let noteExistsForNoteId = true; + let userExistsForUsername = true; + let userHasReadPermissions = true; + + beforeEach(async () => { + jest.resetAllMocks(); + jest.resetModules(); + + sessionExistsForUser = true; + noteExistsForNoteId = true; + userExistsForUsername = true; + userHasReadPermissions = true; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WebsocketGateway, + { + provide: getRepositoryToken(Note), + useClass: Repository, + }, + { + provide: getRepositoryToken(Group), + useClass: Repository, + }, + { + provide: getRepositoryToken(User), + useClass: Repository, + }, + ], + imports: [ + LoggerModule, + NotesModule, + PermissionsModule, + RealtimeNoteModule, + UsersModule, + SessionModule, + ConfigModule.forRoot({ + isGlobal: true, + load: [ + appConfigMock, + databaseConfigMock, + authConfigMock, + noteConfigMock, + ], + }), + ], + }) + .overrideProvider(getRepositoryToken(User)) + .useClass(Repository) + .overrideProvider(getRepositoryToken(AuthToken)) + .useValue({}) + .overrideProvider(getRepositoryToken(Identity)) + .useValue({}) + .overrideProvider(getRepositoryToken(Edit)) + .useValue({}) + .overrideProvider(getRepositoryToken(Revision)) + .useValue({}) + .overrideProvider(getRepositoryToken(Note)) + .useClass(Repository) + .overrideProvider(getRepositoryToken(Tag)) + .useValue({}) + .overrideProvider(getRepositoryToken(NoteGroupPermission)) + .useValue({}) + .overrideProvider(getRepositoryToken(NoteUserPermission)) + .useValue({}) + .overrideProvider(getRepositoryToken(Group)) + .useClass(Repository) + .overrideProvider(getRepositoryToken(Session)) + .useValue({}) + .overrideProvider(getRepositoryToken(Author)) + .useValue({}) + .overrideProvider(getRepositoryToken(Alias)) + .useValue({}) + .compile(); + + gateway = module.get(WebsocketGateway); + sessionService = module.get(SessionService); + usersService = module.get(UsersService); + notesService = module.get(NotesService); + realtimeNoteService = module.get(RealtimeNoteService); + permissionsService = module.get(PermissionsService); + + jest + .spyOn(sessionService, 'extractVerifiedSessionIdFromRequest') + .mockImplementation((request: IncomingMessage): string => { + if (request.headers.cookie === mockedValidSessionCookie) { + return mockedSessionIdWithUser; + } else { + throw new Error('no valid session cookie found'); + } + }); + + const mockUsername = 'mockUsername'; + jest + .spyOn(sessionService, 'fetchUsernameForSessionId') + .mockImplementation((sessionId: string) => + sessionExistsForUser && sessionId === mockedSessionIdWithUser + ? Promise.resolve(mockUsername) + : Promise.reject('no user for session id found'), + ); + + const mockUser = Mock.of({ username: mockUsername }); + jest + .spyOn(usersService, 'getUserByUsername') + .mockImplementation( + (username: string): Promise => + userExistsForUsername && username === mockUsername + ? Promise.resolve(mockUser) + : Promise.reject('user not found'), + ); + + jest + .spyOn(extractNoteIdFromRequestUrlModule, 'extractNoteIdFromRequestUrl') + .mockImplementation((request: IncomingMessage): string => { + if (request.url === mockedValidUrl) { + return mockedValidNoteId; + } else { + throw new Error('no valid note id found'); + } + }); + + const mockedNote = Mock.of({ id: 'mocknote' }); + jest + .spyOn(notesService, 'getNoteByIdOrAlias') + .mockImplementation((noteId: string) => + noteExistsForNoteId && noteId === mockedValidNoteId + ? Promise.resolve(mockedNote) + : Promise.reject('no note found'), + ); + + jest + .spyOn(permissionsService, 'mayRead') + .mockImplementation( + (user: User | null, note: Note): Promise => + Promise.resolve( + user === mockUser && note === mockedNote && userHasReadPermissions, + ), + ); + + const mockedRealtimeNote = Mock.of({ + addClient() { + //intentionally left blank + }, + }); + jest + .spyOn(realtimeNoteService, 'getOrCreateRealtimeNote') + .mockReturnValue(Promise.resolve(mockedRealtimeNote)); + + mockedWebsocketConnection = Mock.of(); + jest + .spyOn(websocketConnectionModule, 'WebsocketConnection') + .mockReturnValue(mockedWebsocketConnection); + + mockedWebsocket = Mock.of({ + close() { + //intentionally left blank + }, + }); + + mockedWebsocketCloseSpy = jest.spyOn(mockedWebsocket, 'close'); + addClientSpy = jest.spyOn(mockedRealtimeNote, 'addClient'); + }); + + it('adds a valid connection request', async () => { + const request = Mock.of({ + socket: { + remoteAddress: 'mockHost', + }, + url: mockedValidUrl, + headers: { + cookie: mockedValidSessionCookie, + }, + }); + + await expect( + gateway.handleConnection(mockedWebsocket, request), + ).resolves.not.toThrow(); + expect(addClientSpy).toBeCalledWith(mockedWebsocketConnection); + expect(mockedWebsocketCloseSpy).not.toBeCalled(); + }); + + it('closes the connection if invalid session cookie', async () => { + const request = Mock.of({ + socket: { + remoteAddress: 'mockHost', + }, + url: mockedValidUrl, + headers: { + cookie: 'invalid session cookie', + }, + }); + + await expect( + gateway.handleConnection(mockedWebsocket, request), + ).resolves.not.toThrow(); + expect(addClientSpy).not.toBeCalled(); + expect(mockedWebsocketCloseSpy).toBeCalled(); + }); + + it("closes the connection if session doesn't exist", async () => { + sessionExistsForUser = false; + + const request = Mock.of({ + socket: { + remoteAddress: 'mockHost', + }, + url: mockedValidUrl, + headers: { + cookie: mockedValidSessionCookie, + }, + }); + + await expect( + gateway.handleConnection(mockedWebsocket, request), + ).resolves.not.toThrow(); + expect(addClientSpy).not.toBeCalled(); + expect(mockedWebsocketCloseSpy).toBeCalled(); + }); + + it("closes the connection if user doesn't exist for username", async () => { + userExistsForUsername = false; + + const request = Mock.of({ + socket: { + remoteAddress: 'mockHost', + }, + url: mockedValidUrl, + headers: { + cookie: mockedValidSessionCookie, + }, + }); + + await expect( + gateway.handleConnection(mockedWebsocket, request), + ).resolves.not.toThrow(); + expect(addClientSpy).not.toBeCalled(); + expect(mockedWebsocketCloseSpy).toBeCalled(); + }); + + it("closes the connection if url doesn't contain a valid note id", async () => { + const request = Mock.of({ + socket: { + remoteAddress: 'mockHost', + }, + url: 'invalid url', + headers: { + cookie: mockedValidSessionCookie, + }, + }); + + await expect( + gateway.handleConnection(mockedWebsocket, request), + ).resolves.not.toThrow(); + expect(addClientSpy).not.toBeCalled(); + expect(mockedWebsocketCloseSpy).toBeCalled(); + }); + + it('closes the connection if url contains an invalid note id', async () => { + noteExistsForNoteId = false; + + const request = Mock.of({ + socket: { + remoteAddress: 'mockHost', + }, + url: mockedValidUrl, + headers: { + cookie: mockedValidSessionCookie, + }, + }); + + await expect( + gateway.handleConnection(mockedWebsocket, request), + ).resolves.not.toThrow(); + expect(addClientSpy).not.toBeCalled(); + expect(mockedWebsocketCloseSpy).toBeCalled(); + }); + + it('closes the connection if user has no read permissions', async () => { + userHasReadPermissions = false; + + const request = Mock.of({ + socket: { + remoteAddress: 'mockHost', + }, + url: mockedValidUrl, + headers: { + cookie: mockedValidSessionCookie, + }, + }); + + await expect( + gateway.handleConnection(mockedWebsocket, request), + ).resolves.not.toThrow(); + expect(addClientSpy).not.toBeCalled(); + expect(mockedWebsocketCloseSpy).toBeCalled(); + }); +}); diff --git a/src/realtime/websocket/websocket.gateway.ts b/src/realtime/websocket/websocket.gateway.ts new file mode 100644 index 000000000..22a92b24c --- /dev/null +++ b/src/realtime/websocket/websocket.gateway.ts @@ -0,0 +1,109 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { OnGatewayConnection, WebSocketGateway } from '@nestjs/websockets'; +import { IncomingMessage } from 'http'; +import WebSocket from 'ws'; + +import { ConsoleLoggerService } from '../../logger/console-logger.service'; +import { NotesService } from '../../notes/notes.service'; +import { PermissionsService } from '../../permissions/permissions.service'; +import { SessionService } from '../../session/session.service'; +import { User } from '../../users/user.entity'; +import { UsersService } from '../../users/users.service'; +import { RealtimeNoteService } from '../realtime-note/realtime-note.service'; +import { WebsocketConnection } from '../realtime-note/websocket-connection'; +import { extractNoteIdFromRequestUrl } from './utils/extract-note-id-from-request-url'; + +/** + * Gateway implementing the realtime logic required for realtime note editing. + */ +@WebSocketGateway({ path: '/realtime' }) +export class WebsocketGateway implements OnGatewayConnection { + constructor( + private readonly logger: ConsoleLoggerService, + private noteService: NotesService, + private realtimeNoteService: RealtimeNoteService, + private userService: UsersService, + private permissionsService: PermissionsService, + private sessionService: SessionService, + ) { + this.logger.setContext(WebsocketGateway.name); + } + + /** + * Handler that is called for each new WebSocket client connection. + * Checks whether the requested URL path is valid, whether the requested note + * exists and whether the requesting user has access to the note. + * Closes the connection to the client if one of the conditions does not apply. + * + * @param clientSocket The WebSocket client object. + * @param request The underlying HTTP request of the WebSocket connection. + */ + async handleConnection( + clientSocket: WebSocket, + request: IncomingMessage, + ): Promise { + try { + const user = await this.findUserByRequestSession(request); + const note = await this.noteService.getNoteByIdOrAlias( + extractNoteIdFromRequestUrl(request), + ); + + if (!(await this.permissionsService.mayRead(user, note))) { + //TODO: [mrdrogdrog] inform client about reason of disconnect. + this.logger.log( + `Access denied to note '${note.id}' for user '${user.username}'`, + 'handleConnection', + ); + clientSocket.close(); + return; + } + + this.logger.debug( + `New realtime connection to note '${note.id}' (${ + note.publicId + }) by user '${user.username}' from ${ + request.socket.remoteAddress ?? 'unknown' + }`, + ); + + const realtimeNote = + await this.realtimeNoteService.getOrCreateRealtimeNote(note); + + const connection = new WebsocketConnection( + clientSocket, + user, + realtimeNote, + ); + + realtimeNote.addClient(connection); + } catch (error: unknown) { + this.logger.error( + `Error occurred while initializing: ${(error as Error).message}`, + (error as Error).stack, + 'handleConnection', + ); + clientSocket.close(); + } + } + + /** + * Finds the {@link User} whose session cookie is saved in the given {@link IncomingMessage}. + * + * @param request The request that contains the session cookie + * @return The found user + */ + private async findUserByRequestSession( + request: IncomingMessage, + ): Promise { + const sessionId = + this.sessionService.extractVerifiedSessionIdFromRequest(request); + const username = await this.sessionService.fetchUsernameForSessionId( + sessionId, + ); + return await this.userService.getUserByUsername(username); + } +} diff --git a/src/realtime/websocket/websocket.module.ts b/src/realtime/websocket/websocket.module.ts new file mode 100644 index 000000000..afec20737 --- /dev/null +++ b/src/realtime/websocket/websocket.module.ts @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { Module } from '@nestjs/common'; + +import { LoggerModule } from '../../logger/logger.module'; +import { NotesModule } from '../../notes/notes.module'; +import { PermissionsModule } from '../../permissions/permissions.module'; +import { SessionModule } from '../../session/session.module'; +import { UsersModule } from '../../users/users.module'; +import { RealtimeNoteModule } from '../realtime-note/realtime-note.module'; +import { WebsocketGateway } from './websocket.gateway'; + +@Module({ + imports: [ + LoggerModule, + NotesModule, + RealtimeNoteModule, + UsersModule, + PermissionsModule, + SessionModule, + ], + exports: [WebsocketGateway], + providers: [WebsocketGateway], +}) +export class WebsocketModule {} diff --git a/src/revisions/revisions.service.spec.ts b/src/revisions/revisions.service.spec.ts index 017c57fd6..e78590a88 100644 --- a/src/revisions/revisions.service.spec.ts +++ b/src/revisions/revisions.service.spec.ts @@ -11,6 +11,7 @@ import { Repository } from 'typeorm'; import { AuthToken } from '../auth/auth-token.entity'; import { Author } from '../authors/author.entity'; import appConfigMock from '../config/mock/app.config.mock'; +import authConfigMock from '../config/mock/auth.config.mock'; import databaseConfigMock from '../config/mock/database.config.mock'; import noteConfigMock from '../config/mock/note.config.mock'; import { NotInDBError } from '../errors/errors'; @@ -49,7 +50,12 @@ describe('RevisionsService', () => { LoggerModule, ConfigModule.forRoot({ isGlobal: true, - load: [appConfigMock, databaseConfigMock, noteConfigMock], + load: [ + appConfigMock, + databaseConfigMock, + authConfigMock, + noteConfigMock, + ], }), ], }) diff --git a/src/seed.ts b/src/seed.ts index 64ece419e..32e6e5a95 100644 --- a/src/seed.ts +++ b/src/seed.ts @@ -66,11 +66,9 @@ dataSource for (let i = 0; i < 3; i++) { const author = (await dataSource.manager.save( - dataSource.manager.create(Author, Author.create(1)), + Author.create(1), )) as Author; - const user = (await dataSource.manager.save( - dataSource.manager.create(User, users[i]), - )) as User; + const user = (await dataSource.manager.save(users[i])) as User; const identity = Identity.create(user, ProviderType.LOCAL, false); identity.passwordHash = await hashPassword(password); dataSource.manager.create(Identity, identity); @@ -95,6 +93,49 @@ dataSource identity, ]); } + const createdUsers = await dataSource.manager.find(User); + const groupEveryone = Group.create('_EVERYONE', 'Everyone', true) as Group; + const groupLoggedIn = Group.create( + '_LOGGED_IN', + 'Logged-in users', + true, + ) as Group; + await dataSource.manager.save([groupEveryone, groupLoggedIn]); + + for (let i = 0; i < 3; i++) { + if (i === 0) { + const permission1 = NoteUserPermission.create( + createdUsers[0], + notes[i], + true, + ); + const permission2 = NoteUserPermission.create( + createdUsers[1], + notes[i], + false, + ); + notes[i].userPermissions = Promise.resolve([permission1, permission2]); + notes[i].groupPermissions = Promise.resolve([]); + await dataSource.manager.save([notes[i], permission1, permission2]); + } + + if (i === 1) { + const readPermission = NoteGroupPermission.create( + groupEveryone, + notes[i], + false, + ); + notes[i].userPermissions = Promise.resolve([]); + notes[i].groupPermissions = Promise.resolve([readPermission]); + await dataSource.manager.save([notes[i], readPermission]); + } + + if (i === 2) { + notes[i].owner = Promise.resolve(createdUsers[0]); + await dataSource.manager.save([notes[i]]); + } + } + const foundUsers = await dataSource.manager.find(User); if (!foundUsers) { throw new Error('Could not find freshly seeded users. Aborting.'); diff --git a/src/session/session.service.spec.ts b/src/session/session.service.spec.ts index d02af232f..322674c3b 100644 --- a/src/session/session.service.spec.ts +++ b/src/session/session.service.spec.ts @@ -5,13 +5,18 @@ */ import * as ConnectTypeormModule from 'connect-typeorm'; import { TypeormStore } from 'connect-typeorm'; +import * as parseCookieModule from 'cookie'; +import * as cookieSignatureModule from 'cookie-signature'; +import { IncomingMessage } from 'http'; import { Mock } from 'ts-mockery'; import { Repository } from 'typeorm'; +import { AuthConfig } from '../config/auth.config'; import { DatabaseType } from '../config/database-type.enum'; import { DatabaseConfig } from '../config/database.config'; import { Session } from '../users/session.entity'; -import { SessionService } from './session.service'; +import { HEDGEDOC_SESSION } from '../utils/session'; +import { SessionService, SessionState } from './session.service'; jest.mock('cookie'); jest.mock('cookie-signature'); @@ -20,19 +25,38 @@ describe('SessionService', () => { let mockedTypeormStore: TypeormStore; let mockedSessionRepository: Repository; let databaseConfigMock: DatabaseConfig; + let authConfigMock: AuthConfig; let typeormStoreConstructorMock: jest.SpyInstance; + const mockedExistingSessionId = 'mockedExistingSessionId'; + const mockUsername = 'mockUser'; + const mockSecret = 'mockSecret'; let sessionService: SessionService; beforeEach(() => { jest.resetModules(); jest.restoreAllMocks(); + const mockedExistingSession = Mock.of({ + user: mockUsername, + }); mockedTypeormStore = Mock.of({ connect: jest.fn(() => mockedTypeormStore), + get: jest.fn(((sessionId, callback) => { + if (sessionId === mockedExistingSessionId) { + callback(undefined, mockedExistingSession); + } else { + callback(new Error("Session doesn't exist"), undefined); + } + }) as TypeormStore['get']), }); mockedSessionRepository = Mock.of>({}); databaseConfigMock = Mock.of({ type: DatabaseType.SQLITE, }); + authConfigMock = Mock.of({ + session: { + secret: mockSecret, + }, + }); typeormStoreConstructorMock = jest .spyOn(ConnectTypeormModule, 'TypeormStore') @@ -41,6 +65,7 @@ describe('SessionService', () => { sessionService = new SessionService( mockedSessionRepository, databaseConfigMock, + authConfigMock, ); }); @@ -52,4 +77,119 @@ describe('SessionService', () => { expect(mockedTypeormStore.connect).toBeCalledWith(mockedSessionRepository); expect(sessionService.getTypeormStore()).toBe(mockedTypeormStore); }); + + it('can fetch a username for an existing session', async () => { + await expect( + sessionService.fetchUsernameForSessionId(mockedExistingSessionId), + ).resolves.toBe(mockUsername); + }); + + it("can't fetch a username for a non-existing session", async () => { + await expect( + sessionService.fetchUsernameForSessionId("doesn't exist"), + ).rejects.toThrow(); + }); + + describe('extract verified session id from request', () => { + const validCookieHeader = 'validCookieHeader'; + const validSessionId = 'validSessionId'; + + function mockParseCookieModule(sessionCookieContent: string): void { + jest + .spyOn(parseCookieModule, 'parse') + .mockImplementation((header: string): Record => { + if (header === validCookieHeader) { + return { + [HEDGEDOC_SESSION]: sessionCookieContent, + }; + } else { + return {}; + } + }); + } + + beforeEach(() => { + jest.spyOn(parseCookieModule, 'parse').mockImplementation(() => { + throw new Error('call not expected!'); + }); + jest + .spyOn(cookieSignatureModule, 'unsign') + .mockImplementation((value, secret) => { + if (value.endsWith('.validSignature') && secret === mockSecret) { + return 'decryptedValue'; + } else { + return false; + } + }); + }); + + it('fails if no cookie header is present', () => { + const mockedRequest = Mock.of({ + headers: {}, + }); + expect(() => + sessionService.extractVerifiedSessionIdFromRequest(mockedRequest), + ).toThrow('No hedgedoc-session cookie found'); + }); + + it("fails if the cookie header isn't valid", () => { + const mockedRequest = Mock.of({ + headers: { cookie: 'no' }, + }); + mockParseCookieModule(`s:anyValidSessionId.validSignature`); + expect(() => + sessionService.extractVerifiedSessionIdFromRequest(mockedRequest), + ).toThrow('No hedgedoc-session cookie found'); + }); + + it("fails if the hedgedoc session cookie isn't marked as signed", () => { + const mockedRequest = Mock.of({ + headers: { cookie: validCookieHeader }, + }); + mockParseCookieModule('sessionId.validSignature'); + expect(() => + sessionService.extractVerifiedSessionIdFromRequest(mockedRequest), + ).toThrow("cookie doesn't look like a signed cookie"); + }); + + it("fails if the hedgedoc session cookie doesn't contain a session id", () => { + const mockedRequest = Mock.of({ + headers: { cookie: validCookieHeader }, + }); + mockParseCookieModule('s:.validSignature'); + expect(() => + sessionService.extractVerifiedSessionIdFromRequest(mockedRequest), + ).toThrow("cookie doesn't look like a signed cookie"); + }); + + it("fails if the hedgedoc session cookie doesn't contain a signature", () => { + const mockedRequest = Mock.of({ + headers: { cookie: validCookieHeader }, + }); + mockParseCookieModule('s:sessionId.'); + expect(() => + sessionService.extractVerifiedSessionIdFromRequest(mockedRequest), + ).toThrow("cookie doesn't look like a signed cookie"); + }); + + it("fails if the hedgedoc session cookie isn't signed correctly", () => { + const mockedRequest = Mock.of({ + headers: { cookie: validCookieHeader }, + }); + mockParseCookieModule('s:sessionId.invalidSignature'); + expect(() => + sessionService.extractVerifiedSessionIdFromRequest(mockedRequest), + ).toThrow("Signature of hedgedoc-session cookie isn't valid."); + }); + + it('can extract a session id from a valid request', () => { + const mockedRequest = Mock.of({ + headers: { cookie: validCookieHeader }, + }); + mockParseCookieModule(`s:${validSessionId}.validSignature`); + expect( + sessionService.extractVerifiedSessionIdFromRequest(mockedRequest), + ).toBe(validSessionId); + }); + }); }); diff --git a/src/session/session.service.ts b/src/session/session.service.ts index 9275d5580..e9515fb94 100644 --- a/src/session/session.service.ts +++ b/src/session/session.service.ts @@ -3,25 +3,43 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ +import { Optional } from '@mrdrogdrog/optional'; import { Inject, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { TypeormStore } from 'connect-typeorm'; +import { parse as parseCookie } from 'cookie'; +import { unsign } from 'cookie-signature'; +import { IncomingMessage } from 'http'; import { Repository } from 'typeorm'; +import authConfiguration, { AuthConfig } from '../config/auth.config'; import { DatabaseType } from '../config/database-type.enum'; import databaseConfiguration, { DatabaseConfig, } from '../config/database.config'; import { Session } from '../users/session.entity'; +import { HEDGEDOC_SESSION } from '../utils/session'; +export interface SessionState { + cookie: unknown; + user: string; + authProvider: string; +} + +/** + * Finds {@link Session sessions} by session id and verifies session cookies. + */ @Injectable() export class SessionService { + private static readonly sessionCookieContentRegex = /^s:(([^.]+)\.(.+))$/; private readonly typeormStore: TypeormStore; constructor( @InjectRepository(Session) private sessionRepository: Repository, @Inject(databaseConfiguration.KEY) private dbConfig: DatabaseConfig, + @Inject(authConfiguration.KEY) + private authConfig: AuthConfig, ) { this.typeormStore = new TypeormStore({ cleanupLimit: 2, @@ -32,4 +50,47 @@ export class SessionService { getTypeormStore(): TypeormStore { return this.typeormStore; } + + /** + * Finds the username of the user that own the given session id. + * + * @async + * @param sessionId The session id for which the owning user should be found + * @return A Promise that either resolves with the username or rejects with an error + */ + fetchUsernameForSessionId(sessionId: string): Promise { + return new Promise((resolve, reject) => { + this.typeormStore.get(sessionId, (error?: Error, result?: SessionState) => + error || !result ? reject(error) : resolve(result.user), + ); + }); + } + + /** + * Extracts the hedgedoc session cookie from the given {@link IncomingMessage request} and checks if the signature is correct. + * + * @param request The http request that contains a session cookie + * @return The extracted session id + * @throws Error if no session cookie was found + * @throws Error if the cookie content is malformed + * @throws Error if the cookie content isn't signed + */ + extractVerifiedSessionIdFromRequest(request: IncomingMessage): string { + return Optional.ofNullable(request.headers.cookie) + .map((cookieHeader) => parseCookie(cookieHeader)[HEDGEDOC_SESSION]) + .orThrow(() => new Error(`No ${HEDGEDOC_SESSION} cookie found`)) + .map((cookie) => SessionService.sessionCookieContentRegex.exec(cookie)) + .orThrow( + () => + new Error( + `${HEDGEDOC_SESSION} cookie doesn't look like a signed cookie`, + ), + ) + .guard( + (cookie) => unsign(cookie[1], this.authConfig.session.secret) !== false, + () => new Error(`Signature of ${HEDGEDOC_SESSION} cookie isn't valid.`), + ) + .map((cookie) => cookie[2]) + .get(); + } } diff --git a/src/utils/frontend-integration.ts b/src/utils/frontend-integration.ts deleted file mode 100644 index 53c776dd8..000000000 --- a/src/utils/frontend-integration.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { NestExpressApplication } from '@nestjs/platform-express'; - -import { ConsoleLoggerService } from '../logger/console-logger.service'; -import { useUnless } from './use-unless'; - -export async function setupFrontendProxy( - app: NestExpressApplication, - logger: ConsoleLoggerService, -): Promise { - logger.log( - `Setting up proxy to frontend dev server on port 3001`, - 'setupFrontendProxy', - ); - const createProxyMiddleware = (await import('http-proxy-middleware')) - .createProxyMiddleware; - const frontendProxy = createProxyMiddleware({ - logProvider: () => { - return { - log: (msg) => logger.log(msg, 'FrontendProxy'), - debug: (msg) => logger.debug(msg, 'FrontendProxy'), - info: (msg) => logger.log(msg, 'FrontendProxy'), - warn: (msg) => logger.warn(msg, 'FrontendProxy'), - error: (msg) => logger.error(msg, 'FrontendProxy'), - }; - }, - target: 'http://localhost:3001', - changeOrigin: true, - ws: true, - }); - app.use(useUnless(['/api', '/public'], frontendProxy)); -} diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 35a5ebf97..1aae4f2e3 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { getConfigToken } from '@nestjs/config'; +import { WsAdapter } from '@nestjs/platform-ws'; import { Test } from '@nestjs/testing'; import request from 'supertest'; @@ -50,6 +51,7 @@ describe('App', () => { * is done. */ const app = moduleRef.createNestApplication(); + app.useWebSocketAdapter(new WsAdapter(app)); await app.init(); await request(app.getHttpServer()).get('/').expect(404); await app.close(); diff --git a/test/test-setup.ts b/test/test-setup.ts index 056c8c6a3..3aac3cafe 100644 --- a/test/test-setup.ts +++ b/test/test-setup.ts @@ -21,7 +21,6 @@ import { TokenAuthGuard } from '../src/auth/token.strategy'; import { AuthorsModule } from '../src/authors/authors.module'; import { AppConfig } from '../src/config/app.config'; import { AuthConfig } from '../src/config/auth.config'; -import { DatabaseConfig } from '../src/config/database.config'; import { MediaConfig } from '../src/config/media.config'; import appConfigMock from '../src/config/mock/app.config.mock'; import authConfigMock from '../src/config/mock/auth.config.mock'; diff --git a/yarn.lock b/yarn.lock index 320031b5c..ab66fd3f6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -694,6 +694,19 @@ __metadata: languageName: node linkType: hard +"@hedgedoc/realtime@npm:0.1.1": + version: 0.1.1 + resolution: "@hedgedoc/realtime@npm:0.1.1" + dependencies: + isomorphic-ws: ^5.0.0 + lib0: ^0.2.51 + typed-emitter: ^2.0.0 + y-protocols: ^1.0.0 + yjs: ^13.0.0 + checksum: 72e83af2d586b08daa13a56d6b2a00ff2fdff4196e9587241dc5bac80fd00fe59d88be6754631a0cc541e74d2059c63824e7c1675c7eaf0bb41ad6f6a0728f46 + languageName: node + linkType: hard + "@humanwhocodes/config-array@npm:^0.9.2": version: 0.9.5 resolution: "@humanwhocodes/config-array@npm:0.9.5" @@ -1054,6 +1067,13 @@ __metadata: languageName: node linkType: hard +"@mrdrogdrog/optional@npm:0.1.0": + version: 0.1.0 + resolution: "@mrdrogdrog/optional@npm:0.1.0" + checksum: 96a4f0779b343ad35eb25c660cd645f604fe37c6ec87565b06626b4732f88c4938fbc98d6ef8c90b2c577c326d313e45235e92b7e52518855dd22276cf5168d2 + languageName: node + linkType: hard + "@nestjs/cli@npm:8.2.8": version: 8.2.8 resolution: "@nestjs/cli@npm:8.2.8" @@ -1199,6 +1219,20 @@ __metadata: languageName: node linkType: hard +"@nestjs/platform-ws@npm:7.6.17": + version: 7.6.17 + resolution: "@nestjs/platform-ws@npm:7.6.17" + dependencies: + tslib: 2.2.0 + ws: 7.4.5 + peerDependencies: + "@nestjs/common": ^7.0.0 + "@nestjs/websockets": ^7.0.0 + rxjs: ^6.0.0 + checksum: 54e4d0194f5aa5212b2f6a7c4e47f92d72cff79dedb084bc6810225e14ee8aa703287db8704097bca81988530d870b6c9cc18730963fc9bd8bcf07060cd95cbf + languageName: node + linkType: hard + "@nestjs/schedule@npm:2.0.1": version: 2.0.1 resolution: "@nestjs/schedule@npm:2.0.1" @@ -1284,6 +1318,26 @@ __metadata: languageName: node linkType: hard +"@nestjs/websockets@npm:8.4.4": + version: 8.4.4 + resolution: "@nestjs/websockets@npm:8.4.4" + dependencies: + iterare: 1.2.1 + object-hash: 3.0.0 + tslib: 2.3.1 + peerDependencies: + "@nestjs/common": ^8.0.0 + "@nestjs/core": ^8.0.0 + "@nestjs/platform-socket.io": ^8.0.0 + reflect-metadata: ^0.1.12 + rxjs: ^7.1.0 + peerDependenciesMeta: + "@nestjs/platform-socket.io": + optional: true + checksum: eb3e2d45a95f571bd15dc0f9cb77c80cf980bce630497b2b79cf75e8cd7459084ad21c4239d676f1ab5e8d8c7ff04ee02ee1183e5b4358889a37bfb5a15eeb86 + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -1584,6 +1638,20 @@ __metadata: languageName: node linkType: hard +"@types/cookie-signature@npm:1.0.4": + version: 1.0.4 + resolution: "@types/cookie-signature@npm:1.0.4" + checksum: e5ad4448e2369fc5447d6840d748f6a2488de4700458bea7902d6650bf1751dc9c98745abe75bffed745bf10ca8a0fa2f4aefe702b92b0bd8307c6e76111a9d5 + languageName: node + linkType: hard + +"@types/cookie@npm:0.5.0": + version: 0.5.0 + resolution: "@types/cookie@npm:0.5.0" + checksum: c0ea731cfe2f08dbc8851fa27212e5b34dadb871c14892d309b0970a158d36bf3d20324847263e0698ce6b1c3a5f151bd4fe45c0f9fc3243d1c8115e41d0e1ce + languageName: node + linkType: hard + "@types/cookiejar@npm:*": version: 2.1.2 resolution: "@types/cookiejar@npm:2.1.2" @@ -1991,6 +2059,15 @@ __metadata: languageName: node linkType: hard +"@types/ws@npm:8.5.3": + version: 8.5.3 + resolution: "@types/ws@npm:8.5.3" + dependencies: + "@types/node": "*" + checksum: 0ce46f850d41383fcdc2149bcacc86d7232fa7a233f903d2246dff86e31701a02f8566f40af5f8b56d1834779255c04ec6ec78660fe0f9b2a69cf3d71937e4ae + languageName: node + linkType: hard + "@types/yargs-parser@npm:*": version: 21.0.0 resolution: "@types/yargs-parser@npm:21.0.0" @@ -5257,21 +5334,27 @@ __metadata: resolution: "hedgedoc@workspace:." dependencies: "@azure/storage-blob": 12.11.0 + "@hedgedoc/realtime": 0.1.1 + "@mrdrogdrog/optional": 0.1.0 "@nestjs/cli": 8.2.8 "@nestjs/common": 8.4.7 "@nestjs/config": 2.1.0 "@nestjs/core": 8.4.7 "@nestjs/passport": 8.2.2 "@nestjs/platform-express": 8.4.7 + "@nestjs/platform-ws": 7.6.17 "@nestjs/schedule": 2.0.1 "@nestjs/schematics": 8.0.11 "@nestjs/swagger": 5.2.1 "@nestjs/testing": 8.4.7 "@nestjs/typeorm": 8.1.4 + "@nestjs/websockets": 8.4.4 "@trivago/prettier-plugin-sort-imports": 3.2.0 "@tsconfig/node12": 1.0.11 "@types/bcrypt": 5.0.0 "@types/cli-color": 2.0.2 + "@types/cookie": 0.5.0 + "@types/cookie-signature": 1.0.4 "@types/cron": 1.7.3 "@types/express": 4.17.13 "@types/express-session": 1.17.4 @@ -5285,6 +5368,7 @@ __metadata: "@types/pg": 8.6.5 "@types/source-map-support": 0.5.4 "@types/supertest": 2.0.12 + "@types/ws": 8.5.3 "@typescript-eslint/eslint-plugin": 5.30.5 "@typescript-eslint/parser": 5.30.5 base32-encode: 1.2.0 @@ -5293,6 +5377,7 @@ __metadata: class-validator: 0.13.2 cli-color: 2.0.3 connect-typeorm: 1.1.4 + cookie: 0.5.0 eslint: 8.19.0 eslint-config-prettier: 8.5.0 eslint-plugin-import: 2.26.0 @@ -5305,6 +5390,7 @@ __metadata: jest: 28.1.2 joi: 17.6.0 ldapauth-fork: 5.0.5 + lib0: 0.2.51 minio: 7.0.29 mocked-env: 1.3.5 mysql: 2.18.1 @@ -5325,11 +5411,14 @@ __metadata: supertest: 6.2.4 swagger-ui-express: 4.4.0 ts-jest: 28.0.5 - ts-mockery: ^1.2.0 + ts-mockery: 1.2.0 ts-node: 10.8.2 tsconfig-paths: 4.0.0 typeorm: 0.3.7 typescript: 4.7.4 + ws: 8.7.0 + y-protocols: 1.0.5 + yjs: 13.5.39 languageName: unknown linkType: soft @@ -5890,6 +5979,22 @@ __metadata: languageName: node linkType: hard +"isomorphic-ws@npm:^5.0.0": + version: 5.0.0 + resolution: "isomorphic-ws@npm:5.0.0" + peerDependencies: + ws: "*" + checksum: e20eb2aee09ba96247465fda40c6d22c1153394c0144fa34fe6609f341af4c8c564f60ea3ba762335a7a9c306809349f9b863c8beedf2beea09b299834ad5398 + languageName: node + linkType: hard + +"isomorphic.js@npm:^0.2.4": + version: 0.2.5 + resolution: "isomorphic.js@npm:0.2.5" + checksum: d8d1b083f05f3c337a06628b982ac3ce6db953bbef14a9de8ad49131250c3592f864b73c12030fdc9ef138ce97b76ef55c7d96a849561ac215b1b4b9d301c8e9 + languageName: node + linkType: hard + "istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.0": version: 3.2.0 resolution: "istanbul-lib-coverage@npm:3.2.0" @@ -6601,6 +6706,15 @@ __metadata: languageName: node linkType: hard +"lib0@npm:0.2.51, lib0@npm:^0.2.42, lib0@npm:^0.2.49, lib0@npm:^0.2.51": + version: 0.2.51 + resolution: "lib0@npm:0.2.51" + dependencies: + isomorphic.js: ^0.2.4 + checksum: bdd00ba42b66d27d048fc169e7d472b9dfe9140e067daeb92db82f40209365d9399aaed679078cc440c496c43d429427b0e231dbaaf171793d98ea6f5476aa3a + languageName: node + linkType: hard + "libphonenumber-js@npm:^1.9.43": version: 1.10.7 resolution: "libphonenumber-js@npm:1.10.7" @@ -8339,6 +8453,15 @@ __metadata: languageName: node linkType: hard +"rxjs@npm:*, rxjs@npm:7.5.5, rxjs@npm:^7.2.0": + version: 7.5.5 + resolution: "rxjs@npm:7.5.5" + dependencies: + tslib: ^2.1.0 + checksum: e034f60805210cce756dd2f49664a8108780b117cf5d0e2281506e9e6387f7b4f1532d974a8c8b09314fa7a16dd2f6cff3462072a5789672b5dcb45c4173f3c6 + languageName: node + linkType: hard + "rxjs@npm:6.6.7, rxjs@npm:^6.6.0": version: 6.6.7 resolution: "rxjs@npm:6.6.7" @@ -8348,15 +8471,6 @@ __metadata: languageName: node linkType: hard -"rxjs@npm:7.5.5, rxjs@npm:^7.2.0": - version: 7.5.5 - resolution: "rxjs@npm:7.5.5" - dependencies: - tslib: ^2.1.0 - checksum: e034f60805210cce756dd2f49664a8108780b117cf5d0e2281506e9e6387f7b4f1532d974a8c8b09314fa7a16dd2f6cff3462072a5789672b5dcb45c4173f3c6 - languageName: node - linkType: hard - "safe-buffer@npm:5.1.2, safe-buffer@npm:~5.1.0, safe-buffer@npm:~5.1.1": version: 5.1.2 resolution: "safe-buffer@npm:5.1.2" @@ -9177,7 +9291,7 @@ __metadata: languageName: node linkType: hard -"ts-mockery@npm:^1.2.0": +"ts-mockery@npm:1.2.0": version: 1.2.0 resolution: "ts-mockery@npm:1.2.0" peerDependencies: @@ -9258,6 +9372,20 @@ __metadata: languageName: node linkType: hard +"tslib@npm:2.2.0": + version: 2.2.0 + resolution: "tslib@npm:2.2.0" + checksum: a48c9639f7496fa701ea8ffe0561070fcb44c104a59632f7f845c0af00825c99b6373575ec59b2b5cdbfd7505875086dbe5dc83312304d8979f22ce571218ca3 + languageName: node + linkType: hard + +"tslib@npm:2.3.1": + version: 2.3.1 + resolution: "tslib@npm:2.3.1" + checksum: de17a98d4614481f7fcb5cd53ffc1aaf8654313be0291e1bfaee4b4bb31a20494b7d218ff2e15017883e8ea9626599b3b0e0229c18383ba9dce89da2adf15cb9 + languageName: node + linkType: hard + "tslib@npm:2.4.0, tslib@npm:^2.1.0, tslib@npm:^2.2.0, tslib@npm:^2.3.1": version: 2.4.0 resolution: "tslib@npm:2.4.0" @@ -9344,6 +9472,18 @@ __metadata: languageName: node linkType: hard +"typed-emitter@npm:^2.0.0": + version: 2.1.0 + resolution: "typed-emitter@npm:2.1.0" + dependencies: + rxjs: "*" + dependenciesMeta: + rxjs: + optional: true + checksum: 95821a9e05784b972cc9d152891fd12a56cb4b1a7c57e768c02bea6a8984da7aff8f19404a7b69eea11fae2a3b6c0c510a4c510f575f50162c759ae9059f2520 + languageName: node + linkType: hard + "typedarray@npm:^0.0.6": version: 0.0.6 resolution: "typedarray@npm:0.0.6" @@ -9843,6 +9983,36 @@ __metadata: languageName: node linkType: hard +"ws@npm:7.4.5": + version: 7.4.5 + resolution: "ws@npm:7.4.5" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 5c7d1527f93ef27f9306aaf52db76315e8ff84174d1df717196527c50334c80bc10307dcaf6674a9aca4bb73aac3f77c23d3d9b1800e8aa810a5ee7f52d67cfb + languageName: node + linkType: hard + +"ws@npm:8.7.0": + version: 8.7.0 + resolution: "ws@npm:8.7.0" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 078fa2dbc06b31a45e0057b19e2930d26c222622e355955afe019c9b9b25f62eb2a8eff7cceabdad04910ecd2bd6ef4fa48e6f3673f2fdddff02a6e4c2459584 + languageName: node + linkType: hard + "xml2js@npm:^0.4.15, xml2js@npm:^0.4.19, xml2js@npm:^0.4.23": version: 0.4.23 resolution: "xml2js@npm:0.4.23" @@ -9874,6 +10044,15 @@ __metadata: languageName: node linkType: hard +"y-protocols@npm:1.0.5, y-protocols@npm:^1.0.0": + version: 1.0.5 + resolution: "y-protocols@npm:1.0.5" + dependencies: + lib0: ^0.2.42 + checksum: d19404a4ebafcf3761c28b881abe8c32ab6e457db0e5ffc7dbb749cbc2c3bb98e003a43f3e8eba7f245b2698c76f2c4cdd1c2db869f8ec0c6ef94736d9a88652 + languageName: node + linkType: hard + "y18n@npm:^5.0.5": version: 5.0.8 resolution: "y18n@npm:5.0.8" @@ -9939,6 +10118,15 @@ __metadata: languageName: node linkType: hard +"yjs@npm:13.5.39, yjs@npm:^13.0.0": + version: 13.5.39 + resolution: "yjs@npm:13.5.39" + dependencies: + lib0: ^0.2.49 + checksum: 59a3a0307425a7fbc03ed1d632d3080383a40dd1d9ce5c6272dd083dd2674dd4b92f3678d43e4701150f1d9f46692e7a3b0fc9ace48d4a9e20cdea270108697b + languageName: node + linkType: hard + "yn@npm:3.1.1": version: 3.1.1 resolution: "yn@npm:3.1.1"