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:
Tilman Vatteroth 2022-04-02 23:45:46 +02:00 committed by David Mehren
parent d9ef44766d
commit ce29cc0a2e
44 changed files with 2151 additions and 65 deletions

View file

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

View file

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

View file

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

@ -0,0 +1,3 @@
:::success
You're connected to a real backend! :party:
:::

2
public/motd.md Normal file
View file

@ -0,0 +1,2 @@
This is the test motd text
:smile:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

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

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

View 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' })),
});
}

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

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

View file

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

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

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

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

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

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

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

View file

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

View file

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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