mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2024-11-24 18:56:32 -05:00
feat: add base implementation for realtime communication
Signed-off-by: Philip Molares <philip.molares@udo.edu> Signed-off-by: Erik Michelson <github@erik.michelson.eu> Co-authored-by: Erik Michelson <github@erik.michelson.eu> Co-authored-by: Philip Molares <philip.molares@udo.edu> Co-authored-by: Tilman Vatteroth <git@tilmanvatteroth.de> Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
d9ef44766d
commit
ce29cc0a2e
44 changed files with 2151 additions and 65 deletions
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
14
package.json
14
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",
|
||||
|
|
3
public/intro.md
Normal file
3
public/intro.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
:::success
|
||||
You're connected to a real backend! :party:
|
||||
:::
|
2
public/motd.md
Normal file
2
public/motd.md
Normal file
|
@ -0,0 +1,2 @@
|
|||
This is the test motd text
|
||||
:smile:
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
],
|
||||
|
|
|
@ -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,
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
|
|
@ -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<void> {
|
|||
const appConfig = configService.get<AppConfig>('appConfig');
|
||||
const authConfig = configService.get<AuthConfig>('authConfig');
|
||||
const mediaConfig = configService.get<MediaConfig>('mediaConfig');
|
||||
|
||||
if (!appConfig || !authConfig || !mediaConfig) {
|
||||
logger.error('Could not initialize config, aborting.', 'AppBootstrap');
|
||||
process.exit(1);
|
||||
|
|
|
@ -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,
|
||||
],
|
||||
}),
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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,
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
|
|
@ -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<string> {
|
||||
return (await this.revisionsService.getLatestRevision(note)).content;
|
||||
return (
|
||||
this.realtimeNoteService
|
||||
.getRealtimeNote(note.id)
|
||||
?.getYDoc()
|
||||
.getCurrentContent() ??
|
||||
(await this.revisionsService.getLatestRevision(note)).content
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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,
|
||||
],
|
||||
|
|
26
src/realtime/realtime-note/realtime-note.module.ts
Normal file
26
src/realtime/realtime-note/realtime-note.module.ts
Normal file
|
@ -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 {}
|
103
src/realtime/realtime-note/realtime-note.service.spec.ts
Normal file
103
src/realtime/realtime-note/realtime-note.service.spec.ts
Normal file
|
@ -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<Revision>({
|
||||
content: mockedContent,
|
||||
}),
|
||||
)
|
||||
: Promise.reject('Revision for note mockedNoteId not found.'),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.resetAllMocks();
|
||||
jest.resetModules();
|
||||
|
||||
revisionsService = Mock.of<RevisionsService>({
|
||||
getLatestRevision: jest.fn(),
|
||||
});
|
||||
|
||||
realtimeNoteService = new RealtimeNoteService(revisionsService);
|
||||
|
||||
mockedNote = Mock.of<Note>({ id: mockedNoteId });
|
||||
mockedRealtimeNote = mockRealtimeNote(
|
||||
Mock.of<WebsocketDoc>(),
|
||||
Mock.of<WebsocketAwareness>(),
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
57
src/realtime/realtime-note/realtime-note.service.ts
Normal file
57
src/realtime/realtime-note/realtime-note.service.ts
Normal file
|
@ -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<string, RealtimeNote>();
|
||||
|
||||
/**
|
||||
* 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<RealtimeNote> {
|
||||
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<RealtimeNote> {
|
||||
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);
|
||||
}
|
||||
}
|
79
src/realtime/realtime-note/realtime-note.spec.ts
Normal file
79
src/realtime/realtime-note/realtime-note.spec.ts
Normal file
|
@ -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<void>((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();
|
||||
});
|
||||
});
|
121
src/realtime/realtime-note/realtime-note.ts
Normal file
121
src/realtime/realtime-note/realtime-note.ts
Normal file
|
@ -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<T extends EventMap> =
|
||||
new () => TypedEventEmitter<T>;
|
||||
|
||||
/**
|
||||
* Represents a note currently being edited by a number of clients.
|
||||
*/
|
||||
export class RealtimeNote extends (EventEmitter as TypedEventEmitterConstructor<RealtimeNoteEvents>) {
|
||||
protected logger: Logger;
|
||||
private readonly websocketDoc: WebsocketDoc;
|
||||
private readonly websocketAwareness: WebsocketAwareness;
|
||||
private readonly clients = new Set<WebsocketConnection>();
|
||||
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;
|
||||
}
|
||||
}
|
22
src/realtime/realtime-note/test-utils/mock-awareness.ts
Normal file
22
src/realtime/realtime-note/test-utils/mock-awareness.ts
Normal file
|
@ -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<string> {
|
||||
destroy(): void {
|
||||
//intentionally left blank
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a partial mock for {@link WebsocketAwareness}.
|
||||
*/
|
||||
export function mockAwareness(): WebsocketAwareness {
|
||||
return Mock.from<WebsocketAwareness>(new MockAwareness());
|
||||
}
|
22
src/realtime/realtime-note/test-utils/mock-connection.ts
Normal file
22
src/realtime/realtime-note/test-utils/mock-connection.ts
Normal file
|
@ -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<WebsocketConnection>({
|
||||
isSynced: jest.fn(() => synced),
|
||||
send: jest.fn(),
|
||||
getUser: jest.fn(() => Mock.of<User>({ username: 'mockedUser' })),
|
||||
});
|
||||
}
|
45
src/realtime/realtime-note/test-utils/mock-realtime-note.ts
Normal file
45
src/realtime/realtime-note/test-utils/mock-realtime-note.ts
Normal file
|
@ -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<RealtimeNoteEvents>) {
|
||||
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<RealtimeNote>(new MockRealtimeNote(doc, awareness));
|
||||
}
|
18
src/realtime/realtime-note/test-utils/mock-websocket-doc.ts
Normal file
18
src/realtime/realtime-note/test-utils/mock-websocket-doc.ts
Normal file
|
@ -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<WebsocketDoc>({
|
||||
on: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
});
|
||||
}
|
|
@ -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<MessageTransporterEvents>) {
|
||||
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<WebsocketTransporter>(new MockMessageTransporter());
|
||||
}
|
60
src/realtime/realtime-note/websocket-awareness.spec.ts
Normal file
60
src/realtime/realtime-note/websocket-awareness.spec.ts
Normal file
|
@ -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<RealtimeNote>({
|
||||
getYDoc(): WebsocketDoc {
|
||||
return Mock.of<WebsocketDoc>({
|
||||
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();
|
||||
});
|
||||
});
|
49
src/realtime/realtime-note/websocket-awareness.ts
Normal file
49
src/realtime/realtime-note/websocket-awareness.ts
Normal file
|
@ -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));
|
||||
}
|
||||
}
|
190
src/realtime/realtime-note/websocket-connection.spec.ts
Normal file
190
src/realtime/realtime-note/websocket-connection.spec.ts
Normal file
|
@ -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<WebSocket>({});
|
||||
mockedUser = Mock.of<User>({});
|
||||
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<WebsocketConnection>()]);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
100
src/realtime/realtime-note/websocket-connection.ts
Normal file
100
src/realtime/realtime-note/websocket-connection.ts
Normal file
|
@ -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<number> = 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<number> {
|
||||
return this.controlledAwarenessIds;
|
||||
}
|
||||
|
||||
public getUser(): User {
|
||||
return this.user;
|
||||
}
|
||||
}
|
56
src/realtime/realtime-note/websocket-doc.spec.ts
Normal file
56
src/realtime/realtime-note/websocket-doc.spec.ts
Normal file
|
@ -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<RealtimeNote>(), 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<RealtimeNote>({
|
||||
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();
|
||||
});
|
||||
});
|
71
src/realtime/realtime-note/websocket-doc.ts
Normal file
71
src/realtime/realtime-note/websocket-doc.ts
Normal file
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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<IncomingMessage>();
|
||||
expect(() => extractNoteIdFromRequestUrl(mockedRequest)).toThrow();
|
||||
});
|
||||
|
||||
it('can find a note id', () => {
|
||||
const mockedRequest = Mock.of<IncomingMessage>({
|
||||
url: '/realtime?noteId=somethingsomething',
|
||||
});
|
||||
expect(extractNoteIdFromRequestUrl(mockedRequest)).toBe(
|
||||
'somethingsomething',
|
||||
);
|
||||
});
|
||||
|
||||
it('fails if no note id is present', () => {
|
||||
const mockedRequest = Mock.of<IncomingMessage>({
|
||||
url: '/realtime?nöteId=somethingsomething',
|
||||
});
|
||||
expect(() => extractNoteIdFromRequestUrl(mockedRequest)).toThrow();
|
||||
});
|
||||
|
||||
it('fails if path is empty', () => {
|
||||
const mockedRequest = Mock.of<IncomingMessage>({
|
||||
url: '',
|
||||
});
|
||||
expect(() => extractNoteIdFromRequestUrl(mockedRequest)).toThrow();
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
364
src/realtime/websocket/websocket.gateway.spec.ts
Normal file
364
src/realtime/websocket/websocket.gateway.spec.ts
Normal file
|
@ -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>(WebsocketGateway);
|
||||
sessionService = module.get<SessionService>(SessionService);
|
||||
usersService = module.get<UsersService>(UsersService);
|
||||
notesService = module.get<NotesService>(NotesService);
|
||||
realtimeNoteService = module.get<RealtimeNoteService>(RealtimeNoteService);
|
||||
permissionsService = module.get<PermissionsService>(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<User>({ username: mockUsername });
|
||||
jest
|
||||
.spyOn(usersService, 'getUserByUsername')
|
||||
.mockImplementation(
|
||||
(username: string): Promise<User> =>
|
||||
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<Note>({ 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<boolean> =>
|
||||
Promise.resolve(
|
||||
user === mockUser && note === mockedNote && userHasReadPermissions,
|
||||
),
|
||||
);
|
||||
|
||||
const mockedRealtimeNote = Mock.of<RealtimeNote>({
|
||||
addClient() {
|
||||
//intentionally left blank
|
||||
},
|
||||
});
|
||||
jest
|
||||
.spyOn(realtimeNoteService, 'getOrCreateRealtimeNote')
|
||||
.mockReturnValue(Promise.resolve(mockedRealtimeNote));
|
||||
|
||||
mockedWebsocketConnection = Mock.of<WebsocketConnection>();
|
||||
jest
|
||||
.spyOn(websocketConnectionModule, 'WebsocketConnection')
|
||||
.mockReturnValue(mockedWebsocketConnection);
|
||||
|
||||
mockedWebsocket = Mock.of<WebSocket>({
|
||||
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<IncomingMessage>({
|
||||
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<IncomingMessage>({
|
||||
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<IncomingMessage>({
|
||||
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<IncomingMessage>({
|
||||
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<IncomingMessage>({
|
||||
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<IncomingMessage>({
|
||||
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<IncomingMessage>({
|
||||
socket: {
|
||||
remoteAddress: 'mockHost',
|
||||
},
|
||||
url: mockedValidUrl,
|
||||
headers: {
|
||||
cookie: mockedValidSessionCookie,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
gateway.handleConnection(mockedWebsocket, request),
|
||||
).resolves.not.toThrow();
|
||||
expect(addClientSpy).not.toBeCalled();
|
||||
expect(mockedWebsocketCloseSpy).toBeCalled();
|
||||
});
|
||||
});
|
109
src/realtime/websocket/websocket.gateway.ts
Normal file
109
src/realtime/websocket/websocket.gateway.ts
Normal file
|
@ -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<void> {
|
||||
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<User> {
|
||||
const sessionId =
|
||||
this.sessionService.extractVerifiedSessionIdFromRequest(request);
|
||||
const username = await this.sessionService.fetchUsernameForSessionId(
|
||||
sessionId,
|
||||
);
|
||||
return await this.userService.getUserByUsername(username);
|
||||
}
|
||||
}
|
28
src/realtime/websocket/websocket.module.ts
Normal file
28
src/realtime/websocket/websocket.module.ts
Normal file
|
@ -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 {}
|
|
@ -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,
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
|
49
src/seed.ts
49
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.');
|
||||
|
|
|
@ -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<Session>;
|
||||
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<SessionState>({
|
||||
user: mockUsername,
|
||||
});
|
||||
mockedTypeormStore = Mock.of<TypeormStore>({
|
||||
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<Repository<Session>>({});
|
||||
databaseConfigMock = Mock.of<DatabaseConfig>({
|
||||
type: DatabaseType.SQLITE,
|
||||
});
|
||||
authConfigMock = Mock.of<AuthConfig>({
|
||||
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<string, string> => {
|
||||
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<IncomingMessage>({
|
||||
headers: {},
|
||||
});
|
||||
expect(() =>
|
||||
sessionService.extractVerifiedSessionIdFromRequest(mockedRequest),
|
||||
).toThrow('No hedgedoc-session cookie found');
|
||||
});
|
||||
|
||||
it("fails if the cookie header isn't valid", () => {
|
||||
const mockedRequest = Mock.of<IncomingMessage>({
|
||||
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<IncomingMessage>({
|
||||
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<IncomingMessage>({
|
||||
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<IncomingMessage>({
|
||||
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<IncomingMessage>({
|
||||
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<IncomingMessage>({
|
||||
headers: { cookie: validCookieHeader },
|
||||
});
|
||||
mockParseCookieModule(`s:${validSessionId}.validSignature`);
|
||||
expect(
|
||||
sessionService.extractVerifiedSessionIdFromRequest(mockedRequest),
|
||||
).toBe(validSessionId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<Session>,
|
||||
@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<string> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<void> {
|
||||
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));
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -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';
|
||||
|
|
210
yarn.lock
210
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"
|
||||
|
|
Loading…
Reference in a new issue