From 7adce05412236fc8b430049b53ba9b160b0527f9 Mon Sep 17 00:00:00 2001 From: Erik Michelson Date: Fri, 14 Mar 2025 23:33:29 +0100 Subject: [PATCH] wip: refactoring to knex and general chores, starting with User Co-authored-by: Philip Molares Signed-off-by: Philip Molares Signed-off-by: Erik Michelson Signed-off-by: Philip Molares Signed-off-by: Erik Michelson --- backend/package.json | 3 +- backend/src/alias/alias.module.ts | 19 + .../{notes => alias}/alias.service.spec.ts | 44 +- backend/src/alias/alias.service.ts | 276 +++++++++ backend/src/api-token/api-token.guard.ts | 6 +- backend/src/api-token/api-token.module.ts | 5 +- .../src/api-token/api-token.service.spec.ts | 26 +- backend/src/api-token/api-token.service.ts | 322 ++++++----- .../src/api/private/alias/alias.controller.ts | 60 +- .../api-tokens.controller.ts | 27 +- .../src/api/private/auth/auth.controller.ts | 24 +- .../private/auth/guest/guest.controller.ts | 58 ++ .../api/private/auth/ldap/ldap.controller.ts | 2 +- .../private/auth/local/local.controller.ts | 32 +- .../private/me/history/history.controller.ts | 30 +- backend/src/api/private/me/me.controller.ts | 29 +- .../src/api/private/media/media.controller.ts | 71 +-- .../src/api/private/notes/notes.controller.ts | 130 ++--- backend/src/api/private/private-api.module.ts | 12 +- .../src/api/private/users/users.controller.ts | 2 +- .../src/api/public/alias/alias.controller.ts | 38 +- backend/src/api/public/me/me.controller.ts | 108 +--- .../src/api/public/media/media.controller.ts | 48 +- .../src/api/public/notes/notes.controller.ts | 225 +++---- backend/src/api/public/public-api.module.ts | 8 +- .../markdown-body.decorator.ts | 0 .../{ => decorators}/openapi.decorator.ts | 2 +- .../request-note-id.decorator.ts} | 14 +- .../request-user-id.decorator.ts} | 24 +- .../session-authprovider.decorator.ts | 4 +- .../utils/extract-note-from-request.spec.ts | 38 +- .../api/utils/extract-note-from-request.ts | 33 -- .../api/utils/extract-note-id-from-request.ts | 30 + .../api/utils/guards/guests-enabled.guard.ts | 33 ++ .../utils/{ => guards}/login-enabled.guard.ts | 15 +- .../registration-enabled.guard.ts | 15 +- .../get-note-id.interceptor.spec.ts} | 28 +- .../get-note-id.interceptor.ts} | 18 +- .../note-header.interceptor.ts | 10 +- backend/src/api/utils/request.type.ts | 17 +- backend/src/app-init.ts | 24 +- backend/src/app.module.ts | 12 +- backend/src/auth/auth.module.ts | 10 +- backend/src/auth/identity.service.ts | 216 ++++--- backend/src/auth/local/local.service.ts | 103 ++-- backend/src/auth/oidc/oidc.service.ts | 6 +- backend/src/auth/session.guard.ts | 49 +- backend/src/authors/authors.module.ts | 14 - backend/src/config/mock/note.config.mock.ts | 4 +- backend/src/config/note.config.spec.ts | 6 +- backend/src/config/note.config.ts | 8 +- .../migrations/20250312211152_initial.ts | 64 +- backend/src/database/seeds/03_note.ts | 19 +- backend/src/database/types/alias.ts | 1 + backend/src/database/types/api-token.ts | 4 + backend/src/database/types/authorship-info.ts | 4 +- backend/src/database/types/knex.types.ts | 8 +- backend/src/database/types/revision-tag.ts | 4 +- backend/src/database/types/revision.ts | 9 +- backend/src/database/types/user.ts | 5 +- backend/src/errors/error-mapping.ts | 25 +- backend/src/errors/errors.ts | 46 +- backend/src/events.ts | 15 +- .../frontend-config.service.spec.ts | 8 +- .../frontend-config.service.ts | 2 +- backend/src/groups/groups.module.ts | 7 +- backend/src/groups/groups.service.spec.ts | 109 +--- backend/src/groups/groups.service.ts | 102 ++-- .../src/history/history-entry-import.dto.ts | 45 -- .../src/history/history-entry-update.dto.ts | 18 - backend/src/history/history-entry.dto.ts | 66 --- backend/src/history/history.module.ts | 29 - backend/src/history/history.service.spec.ts | 470 --------------- backend/src/history/history.service.ts | 194 ------- backend/src/history/utils.spec.ts | 36 -- backend/src/history/utils.ts | 19 - backend/src/logger/console-logger.service.ts | 50 +- backend/src/media/media.module.ts | 16 +- backend/src/media/media.service.spec.ts | 22 +- backend/src/media/media.service.ts | 208 ++++--- backend/src/monitoring/monitoring.service.ts | 2 +- backend/src/notes/alias.service.ts | 178 ------ backend/src/notes/note.module.ts | 31 + ...s.service.spec.ts => note.service.spec.ts} | 38 +- backend/src/notes/note.service.ts | 369 ++++++++++++ backend/src/notes/notes.module.ts | 45 -- backend/src/notes/notes.service.ts | 455 --------------- backend/src/notes/utils.spec.ts | 12 +- backend/src/notes/utils.ts | 17 +- .../permissions/note-permission.enum.spec.ts | 22 +- .../src/permissions/note-permission.enum.ts | 20 +- backend/src/permissions/permission.service.ts | 474 +++++++++++++++ .../src/permissions/permissions.guard.spec.ts | 52 +- backend/src/permissions/permissions.guard.ts | 32 +- backend/src/permissions/permissions.module.ts | 18 +- .../permissions/permissions.service.spec.ts | 86 +-- .../src/permissions/permissions.service.ts | 363 ------------ ...itability-to-note-permission-level.spec.ts | 20 + ...rt-editability-to-note-permission-level.ts | 12 + ...st-access-to-note-permission-level.spec.ts | 35 ++ ...t-guest-access-to-note-permission-level.ts | 32 + ...rt-guest-access-to-note-permission.spec.ts | 35 -- ...convert-guest-access-to-note-permission.ts | 29 - ...d-highest-note-permission-by-group.spec.ts | 194 ------- .../find-highest-note-permission-by-group.ts | 62 -- ...nd-highest-note-permission-by-user.spec.ts | 65 --- .../find-highest-note-permission-by-user.ts | 35 -- .../realtime-note.service.spec.ts | 16 +- .../realtime-note/realtime-note.service.ts | 14 +- .../utils/extract-note-id-from-request-url.ts | 2 +- .../websocket/websocket.gateway.spec.ts | 28 +- .../realtime/websocket/websocket.gateway.ts | 64 +- .../realtime/websocket/websocket.module.ts | 6 +- backend/src/revisions/edit.service.ts | 24 - backend/src/revisions/revisions.module.ts | 14 +- .../src/revisions/revisions.service.spec.ts | 10 +- backend/src/revisions/revisions.service.ts | 547 +++++++++++------- .../extract-revision-metadata-from-content.ts | 5 +- backend/src/sessions/keyv-session-store.ts | 64 ++ backend/src/sessions/session-state.type.ts | 38 ++ backend/src/sessions/session.entity.ts | 35 -- backend/src/sessions/session.module.ts | 4 +- backend/src/sessions/session.service.spec.ts | 4 +- backend/src/sessions/session.service.ts | 97 +--- backend/src/users/user-relation.enum.ts | 10 - backend/src/users/users.module.ts | 6 +- backend/src/users/users.service.spec.ts | 162 ------ backend/src/users/users.service.ts | 380 ++++++++---- ...licatCheck.ts => array-duplicate-check.ts} | 2 +- backend/src/utils/createSpecialGroups.ts | 34 -- backend/src/utils/detectTsNode.ts | 20 - backend/src/utils/password.spec.ts | 1 - backend/src/utils/password.ts | 21 +- ...Version.spec.ts => server-version.spec.ts} | 2 +- .../{serverVersion.ts => server-version.ts} | 0 backend/src/utils/session.ts | 14 +- backend/src/utils/swagger.ts | 2 +- .../test-utils/mockSelectQueryBuilder.ts | 79 --- backend/test/private-api/alias.e2e-spec.ts | 5 +- backend/test/private-api/groups.e2e-spec.ts | 4 +- backend/test/private-api/history.e2e-spec.ts | 4 +- backend/test/private-api/me.e2e-spec.ts | 8 +- backend/test/private-api/notes.e2e-spec.ts | 58 +- backend/test/public-api/alias.e2e-spec.ts | 1 - backend/test/public-api/me.e2e-spec.ts | 2 +- backend/test/public-api/notes.e2e-spec.ts | 26 +- backend/test/test-setup.ts | 18 +- commons/src/dtos/alias/alias-create.dto.ts | 2 +- commons/src/dtos/alias/alias.dto.ts | 7 +- commons/src/dtos/api-token/api-token.dto.ts | 2 +- ...ype.enum.ts => auth-provider-type.enum.ts} | 3 +- commons/src/dtos/auth/guest-login.dto.ts | 15 + .../auth/guest-registration-response.dto.ts | 16 + commons/src/dtos/auth/index.ts | 4 +- .../auth-provider-with-custom-name.dto.ts | 6 +- .../auth-provider-without-custom-name.dto.ts | 4 +- .../frontend-config/frontend-config.dto.ts | 4 +- commons/src/dtos/note/note-metadata.dto.ts | 4 +- commons/src/dtos/permissions/index.ts | 2 +- ...ccess.enum.ts => permission-level.enum.ts} | 16 +- .../dtos/revision/revision-metadata.dto.ts | 9 +- commons/src/dtos/revision/revision.dto.ts | 28 +- commons/src/dtos/user/login-user-info.dto.ts | 4 +- commons/src/dtos/user/user-info.dto.ts | 5 +- commons/src/message-transporters/message.ts | 4 +- commons/src/y-doc-sync/realtime-doc.spec.ts | 4 +- commons/src/y-doc-sync/realtime-doc.ts | 20 +- .../src/y-doc-sync/y-doc-sync-adapter.spec.ts | 6 +- commons/src/y-doc-sync/y-doc-sync-adapter.ts | 4 +- .../y-doc-sync/y-doc-sync-server-adapter.ts | 2 +- .../new-note-button/new-note-button.tsx | 4 +- .../permission-entry-buttons.tsx | 8 +- .../permission-entry-special-group.tsx | 10 +- .../permission-entry-user.tsx | 4 +- .../permission-section-special-groups.tsx | 14 +- .../login-page/guest/guest-card.tsx | 6 +- .../src/pages/api/private/auth/local/login.ts | 13 - frontend/src/pages/api/private/auth/logout.ts | 15 - frontend/src/pages/api/private/config.ts | 82 --- .../src/pages/api/private/groups/_EVERYONE.ts | 18 - .../pages/api/private/groups/_LOGGED_IN.ts | 18 - .../pages/api/private/groups/hedgedoc-devs.ts | 18 - frontend/src/pages/api/private/me/history.ts | 47 -- frontend/src/pages/api/private/me/index.ts | 26 - frontend/src/pages/api/private/me/media.ts | 29 - frontend/src/pages/api/private/media.ts | 33 -- .../pages/api/private/notes/features/index.ts | 57 -- .../api/private/notes/features/revisions/0.ts | 215 ------- .../api/private/notes/features/revisions/1.ts | 163 ------ .../private/notes/features/revisions/index.ts | 35 -- frontend/src/pages/api/private/notes/index.ts | 58 -- .../api/private/notes/slide-example/index.ts | 65 --- frontend/src/pages/api/private/tokens.ts | 29 - .../pages/api/private/users/profile/erik.ts | 18 - .../pages/api/private/users/profile/mock.ts | 18 - .../pages/api/private/users/profile/molly.ts | 18 - .../pages/api/private/users/profile/tilman.ts | 18 - yarn.lock | 47 +- 198 files changed, 3865 insertions(+), 5899 deletions(-) create mode 100644 backend/src/alias/alias.module.ts rename backend/src/{notes => alias}/alias.service.spec.ts (85%) create mode 100644 backend/src/alias/alias.service.ts rename backend/src/api/private/{tokens => api-tokens}/api-tokens.controller.ts (64%) create mode 100644 backend/src/api/private/auth/guest/guest.controller.ts rename backend/src/api/utils/{ => decorators}/markdown-body.decorator.ts (100%) rename backend/src/api/utils/{ => decorators}/openapi.decorator.ts (99%) rename backend/src/api/utils/{request-note.decorator.ts => decorators/request-note-id.decorator.ts} (64%) rename backend/src/api/utils/{request-user.decorator.ts => decorators/request-user-id.decorator.ts} (55%) rename backend/src/api/utils/{ => decorators}/session-authprovider.decorator.ts (88%) delete mode 100644 backend/src/api/utils/extract-note-from-request.ts create mode 100644 backend/src/api/utils/extract-note-id-from-request.ts create mode 100644 backend/src/api/utils/guards/guests-enabled.guard.ts rename backend/src/api/utils/{ => guards}/login-enabled.guard.ts (54%) rename backend/src/api/utils/{ => guards}/registration-enabled.guard.ts (54%) rename backend/src/api/utils/{get-note.interceptor.spec.ts => interceptors/get-note-id.interceptor.spec.ts} (73%) rename backend/src/api/utils/{get-note.interceptor.ts => interceptors/get-note-id.interceptor.ts} (52%) rename backend/src/api/utils/{ => interceptors}/note-header.interceptor.ts (70%) delete mode 100644 backend/src/authors/authors.module.ts delete mode 100644 backend/src/history/history-entry-import.dto.ts delete mode 100644 backend/src/history/history-entry-update.dto.ts delete mode 100644 backend/src/history/history-entry.dto.ts delete mode 100644 backend/src/history/history.module.ts delete mode 100644 backend/src/history/history.service.spec.ts delete mode 100644 backend/src/history/history.service.ts delete mode 100644 backend/src/history/utils.spec.ts delete mode 100644 backend/src/history/utils.ts delete mode 100644 backend/src/notes/alias.service.ts create mode 100644 backend/src/notes/note.module.ts rename backend/src/notes/{notes.service.spec.ts => note.service.spec.ts} (96%) create mode 100644 backend/src/notes/note.service.ts delete mode 100644 backend/src/notes/notes.module.ts delete mode 100644 backend/src/notes/notes.service.ts create mode 100644 backend/src/permissions/permission.service.ts delete mode 100644 backend/src/permissions/permissions.service.ts create mode 100644 backend/src/permissions/utils/convert-editability-to-note-permission-level.spec.ts create mode 100644 backend/src/permissions/utils/convert-editability-to-note-permission-level.ts create mode 100644 backend/src/permissions/utils/convert-guest-access-to-note-permission-level.spec.ts create mode 100644 backend/src/permissions/utils/convert-guest-access-to-note-permission-level.ts delete mode 100644 backend/src/permissions/utils/convert-guest-access-to-note-permission.spec.ts delete mode 100644 backend/src/permissions/utils/convert-guest-access-to-note-permission.ts delete mode 100644 backend/src/permissions/utils/find-highest-note-permission-by-group.spec.ts delete mode 100644 backend/src/permissions/utils/find-highest-note-permission-by-group.ts delete mode 100644 backend/src/permissions/utils/find-highest-note-permission-by-user.spec.ts delete mode 100644 backend/src/permissions/utils/find-highest-note-permission-by-user.ts delete mode 100644 backend/src/revisions/edit.service.ts create mode 100644 backend/src/sessions/keyv-session-store.ts create mode 100644 backend/src/sessions/session-state.type.ts delete mode 100644 backend/src/sessions/session.entity.ts delete mode 100644 backend/src/users/user-relation.enum.ts rename backend/src/utils/{arrayDuplicatCheck.ts => array-duplicate-check.ts} (71%) delete mode 100644 backend/src/utils/createSpecialGroups.ts delete mode 100644 backend/src/utils/detectTsNode.ts rename backend/src/utils/{serverVersion.spec.ts => server-version.spec.ts} (98%) rename backend/src/utils/{serverVersion.ts => server-version.ts} (100%) delete mode 100644 backend/src/utils/test-utils/mockSelectQueryBuilder.ts rename commons/src/dtos/auth/{provider-type.enum.ts => auth-provider-type.enum.ts} (80%) create mode 100644 commons/src/dtos/auth/guest-login.dto.ts create mode 100644 commons/src/dtos/auth/guest-registration-response.dto.ts rename commons/src/dtos/permissions/{guest-access.enum.ts => permission-level.enum.ts} (53%) delete mode 100644 frontend/src/pages/api/private/auth/local/login.ts delete mode 100644 frontend/src/pages/api/private/auth/logout.ts delete mode 100644 frontend/src/pages/api/private/config.ts delete mode 100644 frontend/src/pages/api/private/groups/_EVERYONE.ts delete mode 100644 frontend/src/pages/api/private/groups/_LOGGED_IN.ts delete mode 100644 frontend/src/pages/api/private/groups/hedgedoc-devs.ts delete mode 100644 frontend/src/pages/api/private/me/history.ts delete mode 100644 frontend/src/pages/api/private/me/index.ts delete mode 100644 frontend/src/pages/api/private/me/media.ts delete mode 100644 frontend/src/pages/api/private/media.ts delete mode 100644 frontend/src/pages/api/private/notes/features/index.ts delete mode 100644 frontend/src/pages/api/private/notes/features/revisions/0.ts delete mode 100644 frontend/src/pages/api/private/notes/features/revisions/1.ts delete mode 100644 frontend/src/pages/api/private/notes/features/revisions/index.ts delete mode 100644 frontend/src/pages/api/private/notes/index.ts delete mode 100644 frontend/src/pages/api/private/notes/slide-example/index.ts delete mode 100644 frontend/src/pages/api/private/tokens.ts delete mode 100644 frontend/src/pages/api/private/users/profile/erik.ts delete mode 100644 frontend/src/pages/api/private/users/profile/mock.ts delete mode 100644 frontend/src/pages/api/private/users/profile/molly.ts delete mode 100644 frontend/src/pages/api/private/users/profile/tilman.ts diff --git a/backend/package.json b/backend/package.json index 5851cb080..514765ac6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -50,12 +50,13 @@ "express-session": "1.18.1", "file-type": "16.5.4", "htmlparser2": "9.1.0", + "keyv": "^5.3.2", "knex": "3.1.0", "ldapauth-fork": "6.1.0", "markdown-it": "13.0.2", "minio": "8.0.4", "mysql": "2.18.1", - "nestjs-knex": "2.0.0", + "nest-knexjs": "0.0.26", "nestjs-zod": "4.3.1", "node-fetch": "2.7.0", "openid-client": "5.7.1", diff --git a/backend/src/alias/alias.module.ts b/backend/src/alias/alias.module.ts new file mode 100644 index 000000000..f78f16ab6 --- /dev/null +++ b/backend/src/alias/alias.module.ts @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { KnexModule } from 'nest-knexjs'; + +import { LoggerModule } from '../logger/logger.module'; +import { AliasService } from './alias.service'; + +@Module({ + imports: [KnexModule, LoggerModule, ConfigModule], + controllers: [], + providers: [AliasService], + exports: [AliasService], +}) +export class AliasModule {} diff --git a/backend/src/notes/alias.service.spec.ts b/backend/src/alias/alias.service.spec.ts similarity index 85% rename from backend/src/notes/alias.service.spec.ts rename to backend/src/alias/alias.service.spec.ts index e93685f84..afa5a9b2a 100644 --- a/backend/src/notes/alias.service.spec.ts +++ b/backend/src/alias/alias.service.spec.ts @@ -1,23 +1,17 @@ /* - * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ import { ConfigModule, ConfigService } from '@nestjs/config'; import { EventEmitterModule } from '@nestjs/event-emitter'; import { Test, TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; import { Mock } from 'ts-mockery'; -import { DataSource, EntityManager, Repository } from 'typeorm'; -import { ApiToken } from '../api-token/api-token.entity'; -import { Identity } from '../auth/identity.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 { User } from '../database/user.entity'; import { AlreadyInDBError, ForbiddenIdError, @@ -25,24 +19,14 @@ import { PrimaryAliasDeletionForbiddenError, } from '../errors/errors'; import { eventModuleConfig } from '../events'; -import { Group } from '../groups/group.entity'; 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 { NoteService } from '../notes/note.service'; 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'; -import { Session } from '../sessions/session.entity'; import { UsersModule } from '../users/users.module'; -import { mockSelectQueryBuilderInRepo } from '../utils/test-utils/mockSelectQueryBuilder'; -import { Alias } from './alias.entity'; +import { AliasModule } from './alias.module'; import { AliasService } from './alias.service'; -import { Note } from './note.entity'; -import { NotesModule } from './notes.module'; -import { NotesService } from './notes.service'; -import { Tag } from './tag.entity'; describe('AliasService', () => { let service: AliasService; @@ -73,7 +57,7 @@ describe('AliasService', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ AliasService, - NotesService, + NoteService, { provide: getRepositoryToken(Note), useValue: noteRepo, @@ -105,7 +89,7 @@ describe('AliasService', () => { UsersModule, GroupsModule, RevisionsModule, - NotesModule, + AliasModule, RealtimeNoteModule, EventEmitterModule.forRoot(eventModuleConfig), ], @@ -149,7 +133,7 @@ describe('AliasService', () => { const alias2 = 'testAlias2'; const user = User.create('hardcoded', 'Testy') as User; describe('creates', () => { - it('an primary alias if no alias is already present', async () => { + it('an primary aliases if no aliases is already present', async () => { const note = Note.create(user) as Note; jest .spyOn(noteRepo, 'save') @@ -160,7 +144,7 @@ describe('AliasService', () => { expect(savedAlias.name).toEqual(alias); expect(savedAlias.primary).toBeTruthy(); }); - it('an non-primary alias if an primary alias is already present', async () => { + it('an non-primary aliases if an primary aliases is already present', async () => { const note = Note.create(user, alias) as Note; jest .spyOn(noteRepo, 'save') @@ -172,7 +156,7 @@ describe('AliasService', () => { expect(savedAlias.primary).toBeFalsy(); }); }); - describe('does not create an alias', () => { + describe('does not create an aliases', () => { const note = Note.create(user, alias2) as Note; it('with an already used name', async () => { jest.spyOn(noteRepo, 'existsBy').mockResolvedValueOnce(false); @@ -193,7 +177,7 @@ describe('AliasService', () => { const alias = 'testAlias'; const alias2 = 'testAlias2'; const user = User.create('hardcoded', 'Testy') as User; - describe('removes one alias correctly', () => { + describe('removes one aliases correctly', () => { let note: Note; beforeAll(async () => { note = Note.create(user, alias) as Note; @@ -214,7 +198,7 @@ describe('AliasService', () => { expect(aliases[0].name).toEqual(alias); expect(aliases[0].primary).toBeTruthy(); }); - it('with one alias, that is primary', async () => { + it('with one aliases, that is primary', async () => { jest .spyOn(noteRepo, 'save') .mockImplementationOnce(async (note: Note): Promise => note); @@ -227,13 +211,13 @@ describe('AliasService', () => { expect(await savedNote.aliases).toHaveLength(0); }); }); - describe('does not remove one alias', () => { + describe('does not remove one aliases', () => { let note: Note; beforeEach(async () => { note = Note.create(user, alias) as Note; (await note.aliases).push(Alias.create(alias2, note, false) as Alias); }); - it('if the alias is unknown', async () => { + it('if the aliases is unknown', async () => { await expect(service.removeAlias(note, 'non existent')).rejects.toThrow( NotInDBError, ); @@ -261,7 +245,7 @@ describe('AliasService', () => { ); }); - it('mark the alias as primary', async () => { + it('mark the aliases as primary', async () => { jest .spyOn(aliasRepo, 'findOneByOrFail') .mockResolvedValueOnce(alias) @@ -293,7 +277,7 @@ describe('AliasService', () => { expect(savedAlias.name).toEqual(alias2.name); expect(savedAlias.primary).toBeTruthy(); }); - it('does not mark the alias as primary, if the alias does not exist', async () => { + it('does not mark the aliases as primary, if the aliases does not exist', async () => { await expect( service.makeAliasPrimary(note, 'i_dont_exist'), ).rejects.toThrow(NotInDBError); diff --git a/backend/src/alias/alias.service.ts b/backend/src/alias/alias.service.ts new file mode 100644 index 000000000..a6eebfd6a --- /dev/null +++ b/backend/src/alias/alias.service.ts @@ -0,0 +1,276 @@ +/* + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { AliasDto } from '@hedgedoc/commons'; +import { Inject, Injectable } from '@nestjs/common'; +import base32Encode from 'base32-encode'; +import { randomBytes } from 'crypto'; +import { Knex } from 'knex'; +import { InjectConnection } from 'nest-knexjs'; + +import noteConfiguration, { NoteConfig } from '../config/note.config'; +import { + Alias, + FieldNameAlias, + FieldNameNote, + Note, + TableAlias, +} from '../database/types'; +import { TypeInsertAlias } from '../database/types/alias'; +import { + AlreadyInDBError, + ForbiddenIdError, + GenericDBError, + NotInDBError, + PrimaryAliasDeletionForbiddenError, +} from '../errors/errors'; +import { ConsoleLoggerService } from '../logger/console-logger.service'; + +@Injectable() +export class AliasService { + constructor( + private readonly logger: ConsoleLoggerService, + + @InjectConnection() + private readonly knex: Knex, + + @Inject(noteConfiguration.KEY) + private noteConfig: NoteConfig, + ) { + this.logger.setContext(AliasService.name); + } + + /** + * Generates a random alias for a note. + * This is a randomly generated 128-bit value encoded with base32-encode using the crockford variant + * and converted to lowercase. + * + * @return The randomly generated alias + */ + generateRandomAlias(): string { + const randomId = randomBytes(16); + return base32Encode(randomId, 'Crockford').toLowerCase(); + } + + /** + * Adds the specified alias to the note + * + * @param noteId The id of the note to add the aliases to + * @param alias The alias to add to the note + * @param transaction The optional transaction to access the db + * @throws {AlreadyInDBError} The alias is already in use. + * @throws {ForbiddenIdError} The requested alias is forbidden + */ + async addAlias( + noteId: Note[FieldNameNote.id], + alias: Alias[FieldNameAlias.alias], + transaction?: Knex, + ): Promise { + const dbActor: Knex = transaction ? transaction : this.knex; + const newAlias: TypeInsertAlias = { + [FieldNameAlias.alias]: alias, + [FieldNameAlias.noteId]: noteId, + [FieldNameAlias.isPrimary]: false, + }; + const oldAliases = await dbActor(TableAlias) + .select(FieldNameAlias.alias) + .where(FieldNameAlias.noteId, noteId); + if (oldAliases.length === 0) { + // The first alias is automatically made the primary aliases + newAlias[FieldNameAlias.isPrimary] = true; + } + await dbActor(TableAlias).insert(newAlias); + } + + /** + * Makes the specified alias the primary alias of the note + * + * @param noteId The id of the note to change the primary alias + * @param alias The alias to be the new primary alias of the note + * @throws {ForbiddenIdError} The requested alias is forbidden + * @throws {NotInDBError} The alias is not assigned to this note + */ + async makeAliasPrimary( + noteId: Note[FieldNameNote.id], + alias: Alias[FieldNameAlias.alias], + ): Promise { + await this.knex.transaction(async (transaction) => { + // First set all existing aliases to not primary + const numberOfUpdatedEntries = await transaction(TableAlias) + .update(FieldNameAlias.isPrimary, null) + .where(FieldNameAlias.noteId, noteId); + if (numberOfUpdatedEntries === 0) { + throw new GenericDBError( + `The note does not exists or has no primary alias. This should never happen`, + this.logger.getContext(), + 'makeAliasPrimary', + ); + } + + // Then set the specified alias to primary + const numberOfUpdatedPrimaryAliases = await transaction(TableAlias) + .update(FieldNameAlias.isPrimary, true) + .where(FieldNameAlias.noteId, noteId) + .andWhere(FieldNameAlias.alias, alias); + + if (numberOfUpdatedPrimaryAliases !== 1) { + throw new NotInDBError( + `The alias '${alias}' is not used by this note.`, + this.logger.getContext(), + 'makeAliasPrimary', + ); + } + }); + } + + /** + * Removes the specified alias from the note + * This method only does not require the noteId since it can be obtained from the alias prior to deletion + * + * @param alias The alias to remove from the note + * @throws {ForbiddenIdError} The requested alias is forbidden + * @throws {NotInDBError} The alias is not assigned to this note + * @throws {PrimaryAliasDeletionForbiddenError} The primary alias cannot be deleted + */ + async removeAlias(alias: Alias[FieldNameAlias.alias]): Promise { + await this.knex.transaction(async (transaction) => { + const aliases = await transaction(TableAlias) + .select() + .where(FieldNameAlias.alias, alias); + if (aliases.length !== 1) { + throw new NotInDBError( + `The alias '${alias}' does not exist.`, + this.logger.getContext(), + 'removeAlias', + ); + } + + const noteId = aliases[0][FieldNameAlias.noteId]; + + const numberOfDeletedAliases = await transaction(TableAlias) + .where(FieldNameAlias.alias, alias) + .andWhere(FieldNameAlias.noteId, noteId) + .andWhere(FieldNameAlias.isPrimary, null) + .delete(); + + if (numberOfDeletedAliases !== 0) { + throw new PrimaryAliasDeletionForbiddenError( + `The alias '${alias}' is the primary alias, which can not be removed.`, + this.logger.getContext(), + 'removeAlias', + ); + } + }); + } + + /** + * Get the primaryAlias of the note specifed by the noteId. + * @param noteId The id of the note to get the primary alias of + * @throws {NotInDBError} The note has no primary alias. + * @return primary alias of the note. + */ + async getPrimaryAliasByNoteId( + noteId: number, + ): Promise { + const aliases = await this.knex(TableAlias) + .select(FieldNameAlias.alias) + .where(FieldNameAlias.noteId, noteId) + .andWhere(FieldNameAlias.isPrimary, true); + if (aliases.length !== 1) { + throw new NotInDBError( + `The noteId '${noteId}' has no primary alias.`, + this.logger.getContext(), + 'removeAlias', + ); + } + return aliases[0][FieldNameAlias.alias]; + } + + /** + * Checks if the provided alias is available for notes + * This method does not return any value but throws an error if it is not successful + * + * @param alias The alias to check + * @param transaction The optional transaction to access the db + * @throws {ForbiddenIdError} The requested alias is not available + * @throws {AlreadyInDBError} The requested alias already exists + */ + async ensureAliasIsAvailable( + alias: Alias[FieldNameAlias.alias], + transaction?: Knex, + ): Promise { + if (this.isAliasForbidden(alias)) { + throw new ForbiddenIdError( + `The alias '${alias}' is forbidden by the administrator.`, + this.logger.getContext(), + 'ensureAliasIsAvailable', + ); + } + const isUsed = await this.isAliasUsed(alias, transaction); + if (isUsed) { + throw new AlreadyInDBError( + `A note with the id or alias '${alias}' already exists.`, + this.logger.getContext(), + 'ensureAliasIsAvailable', + ); + } + } + + /** + * Checks if the provided alias is forbidden by configuration + * + * @param alias The alias to check + * @return {boolean} true if the alias is forbidden, false otherwise + */ + isAliasForbidden(alias: Alias[FieldNameAlias.alias]): boolean { + const forbidden = this.noteConfig.forbiddenNoteIds.includes(alias); + if (forbidden) { + this.logger.warn( + `A note with the alias '${alias}' is forbidden by the administrator.`, + 'isAliasForbidden', + ); + } + return forbidden; + } + + /** + * Checks if the provided alias is already used + * + * @param alias The alias to check + * @param transaction The optional transaction to access the db + * @return {boolean} true if the id or alias is already used, false otherwise + */ + async isAliasUsed( + alias: Alias[FieldNameAlias.alias], + transaction?: Knex, + ): Promise { + const dbActor = transaction ? transaction : this.knex; + const result = await dbActor(TableAlias) + .select(FieldNameAlias.alias) + .where(FieldNameAlias.alias, alias); + if (result.length === 1) { + this.logger.warn( + `A note with the id or alias '${alias}' already exists.`, + 'isAliasUsed', + ); + return true; + } + return false; + } + + /** + * Build the AliasDto from a note. + * @param alias The alias to use + * @param isPrimaryAlias If the alias is the primary alias. + * @throws {NotInDBError} The specified alias does not exist + * @return {AliasDto} The built AliasDto + */ + toAliasDto(alias: string, isPrimaryAlias: boolean): AliasDto { + return { + name: alias, + isPrimaryAlias: isPrimaryAlias, + }; + } +} diff --git a/backend/src/api-token/api-token.guard.ts b/backend/src/api-token/api-token.guard.ts index ebcf89d4c..819ec8294 100644 --- a/backend/src/api-token/api-token.guard.ts +++ b/backend/src/api-token/api-token.guard.ts @@ -3,6 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ +import { AuthProviderType } from '@hedgedoc/commons'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { CompleteRequest } from '../api/utils/request.type'; @@ -30,7 +31,10 @@ export class ApiTokenGuard implements CanActivate { return false; } try { - request.user = await this.apiTokenService.validateToken(token.trim()); + request.userId = await this.apiTokenService.getUserIdForToken( + token.trim(), + ); + request.authProviderType = AuthProviderType.TOKEN; return true; } catch (error) { if ( diff --git a/backend/src/api-token/api-token.module.ts b/backend/src/api-token/api-token.module.ts index 3a84a904c..2881cf252 100644 --- a/backend/src/api-token/api-token.module.ts +++ b/backend/src/api-token/api-token.module.ts @@ -4,17 +4,16 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; +import { KnexModule } from 'nest-knexjs'; import { LoggerModule } from '../logger/logger.module'; import { UsersModule } from '../users/users.module'; -import { ApiToken } from './api-token.entity'; import { ApiTokenGuard } from './api-token.guard'; import { ApiTokenService } from './api-token.service'; import { MockApiTokenGuard } from './mock-api-token.guard'; @Module({ - imports: [UsersModule, LoggerModule, TypeOrmModule.forFeature([ApiToken])], + imports: [UsersModule, LoggerModule, KnexModule], providers: [ApiTokenService, ApiTokenGuard, MockApiTokenGuard], exports: [ApiTokenService, ApiTokenGuard], }) diff --git a/backend/src/api-token/api-token.service.spec.ts b/backend/src/api-token/api-token.service.spec.ts index 4c56491b5..e0fc87ebb 100644 --- a/backend/src/api-token/api-token.service.spec.ts +++ b/backend/src/api-token/api-token.service.spec.ts @@ -104,13 +104,13 @@ describe('ApiTokenService', () => { describe('getTokensByUser', () => { it('works', async () => { createQueryBuilderFunc.getMany = () => [apiToken]; - const tokens = await service.getTokensByUser(user); + const tokens = await service.getTokensOfUserById(user); expect(tokens).toHaveLength(1); expect(tokens).toEqual([apiToken]); }); it('should return empty array if token for user do not exists', async () => { jest.spyOn(apiTokenRepo, 'find').mockImplementationOnce(async () => []); - const tokens = await service.getTokensByUser(user); + const tokens = await service.getTokensOfUserById(user); expect(tokens).toHaveLength(0); expect(tokens).toEqual([]); }); @@ -153,13 +153,13 @@ describe('ApiTokenService', () => { ); expect(() => - service.checkToken(secret, accessToken as ApiToken), + service.ensureTokenIsValid(secret, accessToken as ApiToken), ).not.toThrow(); }); it('AuthToken has wrong hash', () => { const [accessToken] = service.createToken(user, 'TestToken', null); expect(() => - service.checkToken('secret', accessToken as ApiToken), + service.ensureTokenIsValid('secret', accessToken as ApiToken), ).toThrow(TokenNotValidError); }); it('AuthToken has wrong validUntil Date', () => { @@ -168,9 +168,9 @@ describe('ApiTokenService', () => { 'Test', new Date(1549312452000), ); - expect(() => service.checkToken(secret, accessToken as ApiToken)).toThrow( - TokenNotValidError, - ); + expect(() => + service.ensureTokenIsValid(secret, accessToken as ApiToken), + ).toThrow(TokenNotValidError); }); }); @@ -222,7 +222,7 @@ describe('ApiTokenService', () => { .mockImplementationOnce(async (_, __): Promise => { return apiToken; }); - const userByToken = await service.validateToken( + const userByToken = await service.getUserIdForToken( `hd2.${apiToken.keyId}.${testSecret}`, ); expect(userByToken).toEqual({ @@ -233,27 +233,27 @@ describe('ApiTokenService', () => { describe('fails:', () => { it('the prefix is missing', async () => { await expect( - service.validateToken(`${apiToken.keyId}.${'a'.repeat(73)}`), + service.getUserIdForToken(`${apiToken.keyId}.${'a'.repeat(73)}`), ).rejects.toThrow(TokenNotValidError); }); it('the prefix is wrong', async () => { await expect( - service.validateToken(`hd1.${apiToken.keyId}.${'a'.repeat(73)}`), + service.getUserIdForToken(`hd1.${apiToken.keyId}.${'a'.repeat(73)}`), ).rejects.toThrow(TokenNotValidError); }); it('the secret is missing', async () => { await expect( - service.validateToken(`hd2.${apiToken.keyId}`), + service.getUserIdForToken(`hd2.${apiToken.keyId}`), ).rejects.toThrow(TokenNotValidError); }); it('the secret is too long', async () => { await expect( - service.validateToken(`hd2.${apiToken.keyId}.${'a'.repeat(73)}`), + service.getUserIdForToken(`hd2.${apiToken.keyId}.${'a'.repeat(73)}`), ).rejects.toThrow(TokenNotValidError); }); it('the token contains sections after the secret', async () => { await expect( - service.validateToken( + service.getUserIdForToken( `hd2.${apiToken.keyId}.${'a'.repeat(73)}.extra`, ), ).rejects.toThrow(TokenNotValidError); diff --git a/backend/src/api-token/api-token.service.ts b/backend/src/api-token/api-token.service.ts index 23d887409..588056f5d 100644 --- a/backend/src/api-token/api-token.service.ts +++ b/backend/src/api-token/api-token.service.ts @@ -6,19 +6,29 @@ import { ApiTokenDto, ApiTokenWithSecretDto } from '@hedgedoc/commons'; import { Injectable } from '@nestjs/common'; import { Cron, Timeout } from '@nestjs/schedule'; -import { InjectRepository } from '@nestjs/typeorm'; -import { createHash, randomBytes, timingSafeEqual } from 'crypto'; -import { Repository } from 'typeorm'; +import { randomBytes } from 'crypto'; +import { Knex } from 'knex'; +import { InjectConnection } from 'nest-knexjs'; -import { User } from '../database/user.entity'; +import { + ApiToken, + FieldNameApiToken, + FieldNameUser, + TableApiToken, TableUser, + User, +} from '../database/types'; +import { TypeInsertApiToken } from '../database/types/api-token'; import { NotInDBError, TokenNotValidError, TooManyTokensError, } from '../errors/errors'; import { ConsoleLoggerService } from '../logger/console-logger.service'; -import { bufferToBase64Url, checkTokenEquality } from '../utils/password'; -import { ApiToken } from './api-token.entity'; +import { + bufferToBase64Url, + checkTokenEquality, + hashApiToken, +} from '../utils/password'; export const AUTH_TOKEN_PREFIX = 'hd2'; @@ -26,13 +36,22 @@ export const AUTH_TOKEN_PREFIX = 'hd2'; export class ApiTokenService { constructor( private readonly logger: ConsoleLoggerService, - @InjectRepository(ApiToken) - private authTokenRepository: Repository, + + @InjectConnection() + private readonly knex: Knex, ) { this.logger.setContext(ApiTokenService.name); } - async validateToken(tokenString: string): Promise { + /** + * Validates a given token string and returns the userId if the token is valid + * The usage of this token is tracked in the database + * + * @param tokenString The token string to validate and parse + * @return The userId associated with the token + * @throws TokenNotValidError if the token is not valid + */ + async getUserIdForToken(tokenString: string): Promise { const [prefix, keyId, secret, ...rest] = tokenString.split('.'); if (!keyId || !secret || prefix !== AUTH_TOKEN_PREFIX || rest.length > 0) { throw new TokenNotValidError('Invalid API token format'); @@ -44,179 +63,202 @@ export class ApiTokenService { `API token '${tokenString}' has incorrect length`, ); } - const token = await this.getToken(keyId); - this.checkToken(secret, token); - await this.setLastUsedToken(keyId); - return token.user; + return await this.knex.transaction(async (transaction) => { + const token = await transaction(TableApiToken) + .select( + FieldNameApiToken.secretHash, + FieldNameApiToken.userId, + FieldNameApiToken.validUntil, + ) + .where(FieldNameApiToken.id, keyId) + .first(); + if (token === undefined) { + throw new TokenNotValidError('Token not found'); + } + + const tokenHash = token[FieldNameApiToken.secretHash]; + const validUntil = token[FieldNameApiToken.validUntil]; + this.ensureTokenIsValid(secret, tokenHash, validUntil); + + await transaction(TableApiToken) + .update(FieldNameApiToken.lastUsedAt, this.knex.fn.now()) + .where(FieldNameApiToken.id, keyId); + + return token[FieldNameApiToken.userId]; + }); } - createToken( - user: User, - identifier: string, - userDefinedValidUntil: Date | null, - ): [Omit, string] { - const secret = bufferToBase64Url(randomBytes(64)); - const keyId = bufferToBase64Url(randomBytes(8)); - // More about the choice of SHA-512 in the dev docs - const accessTokenHash = createHash('sha512').update(secret).digest('hex'); - // Tokens can only be valid for a maximum of 2 years - const maximumTokenValidity = new Date(); - maximumTokenValidity.setTime( - maximumTokenValidity.getTime() + 2 * 365 * 24 * 60 * 60 * 1000, - ); - const isTokenLimitedToMaximumValidity = - !userDefinedValidUntil || userDefinedValidUntil > maximumTokenValidity; - const validUntil = isTokenLimitedToMaximumValidity - ? maximumTokenValidity - : userDefinedValidUntil; - const token = ApiToken.create( - keyId, - user, - identifier, - accessTokenHash, - new Date(validUntil), - ); - return [token, secret]; - } - - async addToken( - user: User, - identifier: string, - validUntil: Date | null, + /** + * Creates a new API token for the given user + * + * @param userId The id of the user to create the token for + * @param tokenLabel The label of the token + * @param userDefinedValidUntil Maximum date until the token is valid, will be truncated to 2 years + * @throws TooManyTokensError if the user already has 200 tokens + * @returns The created token together with the secret + */ + async createToken( + userId: number, + tokenLabel: string, + userDefinedValidUntil?: Date, ): Promise { - user.apiTokens = this.getTokensByUser(user); + return await this.knex.transaction(async (transaction) => { + const existingTokensForUser = await transaction(TableApiToken) + .select(FieldNameApiToken.id) + .where(FieldNameApiToken.userId, userId); + if (existingTokensForUser.length >= 200) { + // This is a very high ceiling unlikely to hinder legitimate usage, + // but should prevent possible attack vectors + throw new TooManyTokensError( + `User '${userId}' has already 200 API tokens and can't have more`, + ); + } - if ((await user.apiTokens).length >= 200) { - // This is a very high ceiling unlikely to hinder legitimate usage, - // but should prevent possible attack vectors - throw new TooManyTokensError( - `User '${user.username}' has already 200 API tokens and can't have more`, + const secret = bufferToBase64Url(randomBytes(64)); + const keyId = bufferToBase64Url(randomBytes(8)); + const accessTokenHash = hashApiToken(secret); + // Tokens can only be valid for a maximum of 2 years + const maximumTokenValidity = new Date(); + maximumTokenValidity.setTime( + maximumTokenValidity.getTime() + 2 * 365 * 24 * 60 * 60 * 1000, + ); + const isTokenLimitedToMaximumValidity = + !userDefinedValidUntil || userDefinedValidUntil > maximumTokenValidity; + const validUntil = isTokenLimitedToMaximumValidity + ? maximumTokenValidity + : userDefinedValidUntil; + const token: TypeInsertApiToken = { + [FieldNameApiToken.id]: keyId, + [FieldNameApiToken.label]: tokenLabel, + [FieldNameApiToken.userId]: userId, + [FieldNameApiToken.secretHash]: accessTokenHash, + [FieldNameApiToken.validUntil]: validUntil, + [FieldNameApiToken.createdAt]: new Date(), + }; + await this.knex(TableApiToken).insert(token); + return this.toAuthTokenWithSecretDto( + { + ...token, + [FieldNameApiToken.lastUsedAt]: null, + }, + secret, ); - } - const [token, secret] = this.createToken(user, identifier, validUntil); - const createdToken = (await this.authTokenRepository.save( - token, - )) as ApiToken; - return this.toAuthTokenWithSecretDto( - createdToken, - `${AUTH_TOKEN_PREFIX}.${createdToken.keyId}.${secret}`, - ); - } - - async setLastUsedToken(keyId: string): Promise { - const token = await this.authTokenRepository.findOne({ - where: { keyId: keyId }, }); - if (token === null) { - throw new NotInDBError(`API token with id '${keyId}' not found`); - } - token.lastUsedAt = new Date(); - await this.authTokenRepository.save(token); } - async getToken(keyId: string): Promise { - const token = await this.authTokenRepository.findOne({ - where: { keyId: keyId }, - relations: ['user'], - }); - if (token === null) { - throw new NotInDBError(`API token with id '${keyId}' not found`); - } - return token; - } - - checkToken(secret: string, token: ApiToken): void { - if (!checkTokenEquality(secret, token.hash)) { - // hashes are not the same + /** + * Ensures that the given token secret is valid for the given token + * This method does not return any value but throws an error if the token is not valid + * + * @param secret The secret to compare against the hash from the database + * @param tokenHash The hash from the database + * @param validUntil Expiry of the API token + * @throws TokenNotValidError if the token is invalid + */ + ensureTokenIsValid( + secret: string, + tokenHash: string, + validUntil: Date, + ): void { + // First, verify token expiry is not in the past (cheap operation) + if (validUntil.getTime() < new Date().getTime()) { throw new TokenNotValidError( - `Secret does not match Token ${token.label}.`, + `Auth token is not valid since ${validUntil.toISOString()}`, ); } - if (token.validUntil && token.validUntil.getTime() < new Date().getTime()) { - // tokens validUntil Date lies in the past - throw new TokenNotValidError( - `AuthToken '${ - token.label - }' is not valid since ${token.validUntil.toISOString()}.`, - ); + + // Second, verify the secret (costly operation) + if (!checkTokenEquality(secret, tokenHash)) { + throw new TokenNotValidError(`Secret does not match token hash`); } } - async getTokensByUser(user: User): Promise { - const tokens = await this.authTokenRepository.find({ - where: { user: { id: user.id } }, - }); - if (tokens === null) { - return []; - } - return tokens; + /** + * Returns all tokens of a user + * + * @param userId The id of the user to get the tokens for + * @return The tokens of the user + */ + getTokensOfUserById(userId: number): Promise { + return this.knex(TableApiToken) + .select() + .where(FieldNameApiToken.userId, userId); } - async removeToken(keyId: string): Promise { - const token = await this.authTokenRepository.findOne({ - where: { keyId: keyId }, - }); - if (token === null) { - throw new NotInDBError(`API token with id '${keyId}' not found`); + /** + * Removes a token from the database + * + * @param keyId The id of the token to remove + * @param userId The id of the user who owns the token + * @throws NotInDBError if the token is not found + */ + async removeToken(keyId: string, userId: number): Promise { + const numberOfDeletedTokens = await this.knex(TableApiToken) + .where(FieldNameApiToken.id, keyId) + .andWhere(FieldNameApiToken.userId, userId) + .delete(); + if (numberOfDeletedTokens === 0) { + throw new NotInDBError('Token not found'); } - await this.authTokenRepository.remove(token); } - toAuthTokenDto(authToken: ApiToken): ApiTokenDto { - const tokenDto: ApiTokenDto = { - label: authToken.label, - keyId: authToken.keyId, - createdAt: authToken.createdAt.toISOString(), - validUntil: authToken.validUntil.toISOString(), - lastUsedAt: null, + /** + * Converts an ApiToken to an ApiTokenDto + * + * @param apiToken The token to convert + * @return The converted token + */ + toAuthTokenDto(apiToken: ApiToken): ApiTokenDto { + return { + label: apiToken[FieldNameApiToken.label], + keyId: apiToken[FieldNameApiToken.id], + createdAt: apiToken[FieldNameApiToken.createdAt].toISOString(), + validUntil: apiToken[FieldNameApiToken.validUntil].toISOString(), + lastUsedAt: apiToken[FieldNameApiToken.lastUsedAt]?.toISOString() ?? null, }; - - if (authToken.lastUsedAt) { - tokenDto.lastUsedAt = new Date(authToken.lastUsedAt).toISOString(); - } - - return tokenDto; } + /** + * Converts an ApiToken to an ApiTokenWithSecretDto + * + * @param apiToken The token to convert + * @param secret The secret of the token + * @return The converted token + */ toAuthTokenWithSecretDto( - authToken: ApiToken, + apiToken: ApiToken, secret: string, ): ApiTokenWithSecretDto { - const tokenDto = this.toAuthTokenDto(authToken); + const tokenDto = this.toAuthTokenDto(apiToken); + const fullToken = `${AUTH_TOKEN_PREFIX}.${tokenDto.keyId}.${secret}`; return { ...tokenDto, - secret: secret, + secret: fullToken, }; } - // Delete all non valid tokens every sunday on 3:00 AM + // Deletes all invalid tokens every sunday on 3:00 AM @Cron('0 0 3 * * 0') async handleCron(): Promise { return await this.removeInvalidTokens(); } - // Delete all non valid tokens 5 sec after startup + // Delete all invalid tokens 5 sec after startup @Timeout(5000) async handleTimeout(): Promise { return await this.removeInvalidTokens(); } + /** + * Removes all expired tokens from the database + * This method is called by the cron job and the timeout + */ async removeInvalidTokens(): Promise { - const currentTime = new Date().getTime(); - const tokens: ApiToken[] = await this.authTokenRepository.find(); - let removedTokens = 0; - for (const token of tokens) { - if (token.validUntil && token.validUntil.getTime() <= currentTime) { - this.logger.debug( - `AuthToken '${token.keyId}' was removed`, - 'removeInvalidTokens', - ); - await this.authTokenRepository.remove(token); - removedTokens++; - } - } + const numberOfDeletedTokens = await this.knex(TableApiToken) + .where(FieldNameApiToken.validUntil, '<', new Date()) + .delete(); this.logger.log( - `${removedTokens} invalid AuthTokens were purged from the DB.`, + `${numberOfDeletedTokens} invalid AuthTokens were purged from the DB.`, 'removeInvalidTokens', ); } diff --git a/backend/src/api/private/alias/alias.controller.ts b/backend/src/api/private/alias/alias.controller.ts index edfb16817..7d812c5b6 100644 --- a/backend/src/api/private/alias/alias.controller.ts +++ b/backend/src/api/private/alias/alias.controller.ts @@ -5,7 +5,6 @@ */ import { AliasCreateDto } from '@hedgedoc/commons'; import { AliasUpdateDto } from '@hedgedoc/commons'; -import { AliasDto } from '@hedgedoc/commons'; import { BadRequestException, Body, @@ -19,15 +18,13 @@ import { } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { AliasService } from '../../../alias/alias.service'; import { SessionGuard } from '../../../auth/session.guard'; -import { User } from '../../../database/user.entity'; import { ConsoleLoggerService } from '../../../logger/console-logger.service'; -import { AliasService } from '../../../notes/alias.service'; -import { NotesService } from '../../../notes/notes.service'; -import { PermissionsService } from '../../../permissions/permissions.service'; -import { UsersService } from '../../../users/users.service'; +import { NoteService } from '../../../notes/note.service'; +import { PermissionService } from '../../../permissions/permission.service'; +import { RequestUserId } from '../../utils/decorators/request-user-id.decorator'; import { OpenApi } from '../../utils/openapi.decorator'; -import { RequestUser } from '../../utils/request-user.decorator'; @UseGuards(SessionGuard) @OpenApi(401) @@ -37,9 +34,8 @@ export class AliasController { constructor( private readonly logger: ConsoleLoggerService, private aliasService: AliasService, - private noteService: NotesService, - private userService: UsersService, - private permissionsService: PermissionsService, + private noteService: NoteService, + private permissionsService: PermissionService, ) { this.logger.setContext(AliasController.name); } @@ -47,53 +43,53 @@ export class AliasController { @Post() @OpenApi(201, 400, 404, 409) async addAlias( - @RequestUser() user: User, + @RequestUserId() userId: number, @Body() newAliasDto: AliasCreateDto, - ): Promise { - const note = await this.noteService.getNoteByIdOrAlias( - newAliasDto.noteIdOrAlias, + ): Promise { + const noteId = await this.noteService.getNoteIdByAlias( + newAliasDto.noteAlias, ); - if (!(await this.permissionsService.isOwner(user, note))) { + const isUserNoteOwner = await this.permissionsService.isOwner( + userId, + noteId, + ); + if (!isUserNoteOwner) { throw new UnauthorizedException('Reading note denied!'); } - const updatedAlias = await this.aliasService.addAlias( - note, - newAliasDto.newAlias, - ); - return this.aliasService.toAliasDto(updatedAlias, note); + await this.aliasService.ensureAliasIsAvailable(newAliasDto.newAlias); + await this.aliasService.addAlias(noteId, newAliasDto.newAlias); } - @Put(':alias') + @Put(':aliases') @OpenApi(200, 400, 404) async makeAliasPrimary( - @RequestUser() user: User, + @RequestUserId() userId: number, @Param('alias') alias: string, @Body() changeAliasDto: AliasUpdateDto, - ): Promise { + ): Promise { if (!changeAliasDto.primaryAlias) { throw new BadRequestException( `The field 'primaryAlias' must be set to 'true'.`, ); } - const note = await this.noteService.getNoteByIdOrAlias(alias); - if (!(await this.permissionsService.isOwner(user, note))) { + const noteId = await this.noteService.getNoteIdByAlias(alias); + if (!(await this.permissionsService.isOwner(userId, noteId))) { throw new UnauthorizedException('Reading note denied!'); } - const updatedAlias = await this.aliasService.makeAliasPrimary(note, alias); - return this.aliasService.toAliasDto(updatedAlias, note); + await this.aliasService.makeAliasPrimary(noteId, alias); } - @Delete(':alias') + @Delete(':aliases') @OpenApi(204, 400, 404) async removeAlias( - @RequestUser() user: User, + @RequestUserId() userId: number, @Param('alias') alias: string, ): Promise { - const note = await this.noteService.getNoteByIdOrAlias(alias); - if (!(await this.permissionsService.isOwner(user, note))) { + const note = await this.noteService.getNoteIdByAlias(alias); + if (!(await this.permissionsService.isOwner(userId, note))) { throw new UnauthorizedException('Reading note denied!'); } - await this.aliasService.removeAlias(note, alias); + await this.aliasService.removeAlias(alias); return; } } diff --git a/backend/src/api/private/tokens/api-tokens.controller.ts b/backend/src/api/private/api-tokens/api-tokens.controller.ts similarity index 64% rename from backend/src/api/private/tokens/api-tokens.controller.ts rename to backend/src/api/private/api-tokens/api-tokens.controller.ts index 66db25613..2dd1b2ab3 100644 --- a/backend/src/api/private/tokens/api-tokens.controller.ts +++ b/backend/src/api/private/api-tokens/api-tokens.controller.ts @@ -15,17 +15,16 @@ import { Get, Param, Post, - UnauthorizedException, UseGuards, } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { ApiTokenService } from '../../../api-token/api-token.service'; import { SessionGuard } from '../../../auth/session.guard'; -import { User } from '../../../database/user.entity'; +import { FieldNameUser, User } from '../../../database/types'; import { ConsoleLoggerService } from '../../../logger/console-logger.service'; import { OpenApi } from '../../utils/openapi.decorator'; -import { RequestUser } from '../../utils/request-user.decorator'; +import { RequestUserInfo } from '../../utils/request-user-id.decorator'; @UseGuards(SessionGuard) @OpenApi(401) @@ -41,8 +40,8 @@ export class ApiTokensController { @Get() @OpenApi(200) - async getUserTokens(@RequestUser() user: User): Promise { - return (await this.publicAuthTokenService.getTokensByUser(user)).map( + async getUserTokens(@RequestUserInfo() userId: number): Promise { + return (await this.publicAuthTokenService.getTokensOfUserById(userId)).map( (token) => this.publicAuthTokenService.toAuthTokenDto(token), ); } @@ -51,10 +50,10 @@ export class ApiTokensController { @OpenApi(201) async postTokenRequest( @Body() createDto: ApiTokenCreateDto, - @RequestUser() user: User, + @RequestUserInfo() userId: User[FieldNameUser.id], ): Promise { - return await this.publicAuthTokenService.addToken( - user, + return await this.publicAuthTokenService.createToken( + userId, createDto.label, createDto.validUntil, ); @@ -63,17 +62,9 @@ export class ApiTokensController { @Delete('/:keyId') @OpenApi(204, 404) async deleteToken( - @RequestUser() user: User, + @RequestUserInfo() userId: number, @Param('keyId') keyId: string, ): Promise { - const tokens = await this.publicAuthTokenService.getTokensByUser(user); - for (const token of tokens) { - if (token.keyId == keyId) { - return await this.publicAuthTokenService.removeToken(keyId); - } - } - throw new UnauthorizedException( - 'User is not authorized to delete this token', - ); + await this.publicAuthTokenService.removeToken(keyId, userId); } } diff --git a/backend/src/api/private/auth/auth.controller.ts b/backend/src/api/private/auth/auth.controller.ts index 87d9d0698..cf582a8d1 100644 --- a/backend/src/api/private/auth/auth.controller.ts +++ b/backend/src/api/private/auth/auth.controller.ts @@ -23,9 +23,10 @@ import { ApiTags } from '@nestjs/swagger'; import { IdentityService } from '../../../auth/identity.service'; import { OidcService } from '../../../auth/oidc/oidc.service'; -import { RequestWithSession, SessionGuard } from '../../../auth/session.guard'; +import { SessionGuard } from '../../../auth/session.guard'; import { ConsoleLoggerService } from '../../../logger/console-logger.service'; import { OpenApi } from '../../utils/openapi.decorator'; +import { RequestWithSession } from '../../utils/request.type'; @ApiTags('auth') @Controller('auth') @@ -63,7 +64,9 @@ export class AuthController { @Get('pending-user') @OpenApi(200, 400) - getPendingUserData(@Req() request: RequestWithSession): FullUserInfoDto { + getPendingUserData( + @Req() request: RequestWithSession, + ): Partial { if ( !request.session.newUserData || !request.session.authProviderIdentifier || @@ -78,7 +81,7 @@ export class AuthController { @OpenApi(204, 400) async confirmPendingUserData( @Req() request: RequestWithSession, - @Body() updatedUserInfo: PendingUserConfirmationDto, + @Body() pendingUserConfirmationData: PendingUserConfirmationDto, ): Promise { if ( !request.session.newUserData || @@ -88,13 +91,14 @@ export class AuthController { ) { throw new BadRequestException('No pending user data'); } - const identity = await this.identityService.createUserWithIdentity( - request.session.newUserData, - updatedUserInfo, - request.session.authProviderType, - request.session.authProviderIdentifier, - request.session.providerUserId, - ); + const identity = + await this.identityService.createUserWithIdentityFromPendingUserConfirmation( + request.session.newUserData, + pendingUserConfirmationData, + request.session.authProviderType, + request.session.authProviderIdentifier, + request.session.providerUserId, + ); request.session.username = (await identity.user).username; // Cleanup request.session.newUserData = undefined; diff --git a/backend/src/api/private/auth/guest/guest.controller.ts b/backend/src/api/private/auth/guest/guest.controller.ts new file mode 100644 index 000000000..dcdc94156 --- /dev/null +++ b/backend/src/api/private/auth/guest/guest.controller.ts @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { + AuthProviderType, + GuestLoginDto, + GuestRegistrationResponseDto, +} from '@hedgedoc/commons'; +import { Body, Controller, Post, Req, UseGuards } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; + +import { IdentityService } from '../../../../auth/identity.service'; +import { ConsoleLoggerService } from '../../../../logger/console-logger.service'; +import { UsersService } from '../../../../users/users.service'; +import { OpenApi } from '../../../utils/decorators/openapi.decorator'; +import { GuestsEnabledGuard } from '../../../utils/guards/guests-enabled.guard'; +import { RequestWithSession } from '../../../utils/request.type'; + +@ApiTags('auth') +@Controller('/auth/guest') +export class GuestController { + constructor( + private readonly logger: ConsoleLoggerService, + private usersService: UsersService, + private identityService: IdentityService, + ) { + this.logger.setContext(GuestController.name); + } + + @UseGuards(GuestsEnabledGuard) + @Post('register') + @OpenApi(201, 403) + async registerGuestUser( + @Req() request: RequestWithSession, + ): Promise { + const [uuid, userId] = await this.usersService.createGuestUser(); + // Log the user in after registration + request.session.authProviderType = AuthProviderType.GUEST; + request.session.userId = userId; + return { + uuid, + }; + } + + @UseGuards(GuestsEnabledGuard) + @Post('login') + @OpenApi(204, 400) + async loginGuestUser( + @Req() request: RequestWithSession, + @Body() loginDto: GuestLoginDto, + ): Promise { + const userId = await this.usersService.getUserIdByGuestUuid(loginDto.uuid); + request.session.authProviderType = AuthProviderType.GUEST; + request.session.userId = userId; + } +} diff --git a/backend/src/api/private/auth/ldap/ldap.controller.ts b/backend/src/api/private/auth/ldap/ldap.controller.ts index 09707ee0d..bc5cea441 100644 --- a/backend/src/api/private/auth/ldap/ldap.controller.ts +++ b/backend/src/api/private/auth/ldap/ldap.controller.ts @@ -66,7 +66,7 @@ export class LdapController { loginDto.username.toLowerCase(), ); await this.usersService.updateUser( - user, + makeUsernameLowercase(loginDto.username), userInfo.displayName, userInfo.email, userInfo.photoUrl, diff --git a/backend/src/api/private/auth/local/local.controller.ts b/backend/src/api/private/auth/local/local.controller.ts index 679bc8017..8af915a08 100644 --- a/backend/src/api/private/auth/local/local.controller.ts +++ b/backend/src/api/private/auth/local/local.controller.ts @@ -25,13 +25,14 @@ import { RequestWithSession, SessionGuard, } from '../../../../auth/session.guard'; -import { User } from '../../../../database/user.entity'; +import { FieldNameIdentity, FieldNameUser } from '../../../../database/types'; +import { InvalidCredentialsError } from '../../../../errors/errors'; import { ConsoleLoggerService } from '../../../../logger/console-logger.service'; import { UsersService } from '../../../../users/users.service'; import { LoginEnabledGuard } from '../../../utils/login-enabled.guard'; import { OpenApi } from '../../../utils/openapi.decorator'; import { RegistrationEnabledGuard } from '../../../utils/registration-enabled.guard'; -import { RequestUser } from '../../../utils/request-user.decorator'; +import { RequestUserId } from '../../../utils/request-user.decorator'; @ApiTags('auth') @Controller('/auth/local') @@ -52,34 +53,30 @@ export class LocalController { @Body() registerDto: RegisterDto, ): Promise { await this.localIdentityService.checkPasswordStrength(registerDto.password); - const user = await this.usersService.createUser( + const identity = await this.localIdentityService.createLocalIdentity( registerDto.username, - registerDto.displayName, - null, - null, - ); - await this.localIdentityService.createLocalIdentity( - user, registerDto.password, + registerDto.displayName, ); // Log the user in after registration request.session.authProviderType = ProviderType.LOCAL; - request.session.username = registerDto.username; + request.session.userId = identity[FieldNameIdentity.userId]; } @UseGuards(LoginEnabledGuard, SessionGuard) @Put() @OpenApi(200, 400, 401) async updatePassword( - @RequestUser() user: User, + @RequestUserId() userId: number, @Body() changePasswordDto: UpdatePasswordDto, ): Promise { + const user = await this.usersService.getUserById(userId); await this.localIdentityService.checkLocalPassword( - user, + user[FieldNameUser.username], changePasswordDto.currentPassword, ); await this.localIdentityService.updateLocalPassword( - user, + userId, changePasswordDto.newPassword, ); } @@ -93,15 +90,14 @@ export class LocalController { @Body() loginDto: LoginDto, ): Promise { try { - const user = await this.usersService.getUserByUsername(loginDto.username); - await this.localIdentityService.checkLocalPassword( - user, + const identity = await this.localIdentityService.checkLocalPassword( + loginDto.username, loginDto.password, ); - request.session.username = loginDto.username; + request.session.userId = identity[FieldNameIdentity.userId]; request.session.authProviderType = ProviderType.LOCAL; } catch (error) { - this.logger.error(`Failed to log in user: ${String(error)}`); + this.logger.info(`Failed to log in user: ${String(error)}`, 'login'); throw new UnauthorizedException('Invalid username or password'); } } diff --git a/backend/src/api/private/me/history/history.controller.ts b/backend/src/api/private/me/history/history.controller.ts index 5004637da..bd337c6eb 100644 --- a/backend/src/api/private/me/history/history.controller.ts +++ b/backend/src/api/private/me/history/history.controller.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -23,10 +23,10 @@ import { HistoryEntryDto } from '../../../../history/history-entry.dto'; import { HistoryService } from '../../../../history/history.service'; import { ConsoleLoggerService } from '../../../../logger/console-logger.service'; import { Note } from '../../../../notes/note.entity'; -import { GetNoteInterceptor } from '../../../utils/get-note.interceptor'; +import { GetNoteIdInterceptor } from '../../../utils/get-note-id.interceptor'; import { OpenApi } from '../../../utils/openapi.decorator'; -import { RequestNote } from '../../../utils/request-note.decorator'; -import { RequestUser } from '../../../utils/request-user.decorator'; +import { RequestNoteId } from '../../../utils/request-note-id.decorator'; +import { RequestUserId } from '../../../utils/request-user.decorator'; @UseGuards(SessionGuard) @OpenApi(401) @@ -42,7 +42,7 @@ export class HistoryController { @Get() @OpenApi(200, 404) - async getHistory(@RequestUser() user: User): Promise { + async getHistory(@RequestUserId() user: User): Promise { const foundEntries = await this.historyService.getEntriesByUser(user); return await Promise.all( foundEntries.map((entry) => this.historyService.toHistoryEntryDto(entry)), @@ -52,7 +52,7 @@ export class HistoryController { @Post() @OpenApi(201, 404) async setHistory( - @RequestUser() user: User, + @RequestUserId() user: User, @Body() historyImport: HistoryEntryImportListDto, ): Promise { await this.historyService.setHistory(user, historyImport.history); @@ -60,16 +60,16 @@ export class HistoryController { @Delete() @OpenApi(204, 404) - async deleteHistory(@RequestUser() user: User): Promise { + async deleteHistory(@RequestUserId() user: User): Promise { await this.historyService.deleteHistory(user); } - @Put(':noteIdOrAlias') + @Put(':noteAlias') @OpenApi(200, 404) - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) async updateHistoryEntry( - @RequestNote() note: Note, - @RequestUser() user: User, + @RequestNoteId() note: Note, + @RequestUserId() user: User, @Body() entryUpdateDto: HistoryEntryUpdateDto, ): Promise { const newEntry = await this.historyService.updateHistoryEntry( @@ -80,12 +80,12 @@ export class HistoryController { return await this.historyService.toHistoryEntryDto(newEntry); } - @Delete(':noteIdOrAlias') + @Delete(':noteAlias') @OpenApi(204, 404) - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) async deleteHistoryEntry( - @RequestNote() note: Note, - @RequestUser() user: User, + @RequestNoteId() note: Note, + @RequestUserId() user: User, ): Promise { await this.historyService.deleteHistoryEntry(note, user); } diff --git a/backend/src/api/private/me/me.controller.ts b/backend/src/api/private/me/me.controller.ts index 35a9c4da7..19e9d09ca 100644 --- a/backend/src/api/private/me/me.controller.ts +++ b/backend/src/api/private/me/me.controller.ts @@ -12,13 +12,11 @@ import { Body, Controller, Delete, Get, Put, UseGuards } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { SessionGuard } from '../../../auth/session.guard'; -import { User } from '../../../database/user.entity'; import { ConsoleLoggerService } from '../../../logger/console-logger.service'; import { MediaService } from '../../../media/media.service'; -import { User } from '../../../users/user.entity'; import { UsersService } from '../../../users/users.service'; import { OpenApi } from '../../utils/openapi.decorator'; -import { RequestUser } from '../../utils/request-user.decorator'; +import { RequestUserInfo } from '../../utils/request-user-id.decorator'; import { SessionAuthProvider } from '../../utils/session-authprovider.decorator'; @UseGuards(SessionGuard) @@ -37,41 +35,42 @@ export class MeController { @Get() @OpenApi(200) getMe( - @RequestUser() user: User, + @RequestUserInfo() userId: number, @SessionAuthProvider() authProvider: ProviderType, ): LoginUserInfoDto { - return this.userService.toLoginUserInfoDto(user, authProvider); + return this.userService.toLoginUserInfoDto(userId, authProvider); } @Get('media') @OpenApi(200) - async getMyMedia(@RequestUser() user: User): Promise { - const media = await this.mediaService.listUploadsByUser(user); + async getMyMedia(@RequestUserInfo() user: User): Promise { + const media = await this.mediaService.getMediaUploadUuidsByUserId(user); return await Promise.all( - media.map((media) => this.mediaService.toMediaUploadDto(media)), + media.map((media) => this.mediaService.getMediaUploadDtosByUuids(media)), ); } @Delete() @OpenApi(204, 404, 500) - async deleteUser(@RequestUser() user: User): Promise { - const mediaUploads = await this.mediaService.listUploadsByUser(user); + async deleteUser(@RequestUserInfo() userId: number): Promise { + const mediaUploads = + await this.mediaService.getMediaUploadUuidsByUserId(userId); for (const mediaUpload of mediaUploads) { await this.mediaService.deleteFile(mediaUpload); } - this.logger.debug(`Deleted all media uploads of ${user.username}`); - await this.userService.deleteUser(user); - this.logger.debug(`Deleted ${user.username}`); + this.logger.debug(`Deleted all media uploads for user with id ${userId}`); + await this.userService.deleteUser(userId); + this.logger.debug(`Deleted user with id ${userId}`); } @Put('profile') @OpenApi(200) async updateProfile( - @RequestUser() user: User, + @RequestUserInfo() userId: number, @Body('displayName') newDisplayName: string, ): Promise { await this.userService.updateUser( - user, + userId, newDisplayName, undefined, undefined, diff --git a/backend/src/api/private/media/media.controller.ts b/backend/src/api/private/media/media.controller.ts index ba7e452b5..daf2bed35 100644 --- a/backend/src/api/private/media/media.controller.ts +++ b/backend/src/api/private/media/media.controller.ts @@ -4,37 +4,23 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { MediaUploadDto, MediaUploadSchema } from '@hedgedoc/commons'; -import { - BadRequestException, - Controller, - Delete, - Get, - Param, - Post, - Res, - UploadedFile, - UseGuards, - UseInterceptors, -} from '@nestjs/common'; +import { BadRequestException, Controller, Delete, Get, Param, Post, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger'; -import { Response } from 'express'; +import { RequestUserInfo } from 'src/api/utils/request-user-id.decorator'; import { SessionGuard } from '../../../auth/session.guard'; -import { User } from '../../../database/user.entity'; import { PermissionError } from '../../../errors/errors'; import { ConsoleLoggerService } from '../../../logger/console-logger.service'; import { MediaService } from '../../../media/media.service'; import { MulterFile } from '../../../media/multer-file.interface'; -import { Note } from '../../../notes/note.entity'; +import { PermissionService } from '../../../permissions/permission.service'; import { PermissionsGuard } from '../../../permissions/permissions.guard'; -import { PermissionsService } from '../../../permissions/permissions.service'; import { RequirePermission } from '../../../permissions/require-permission.decorator'; import { RequiredPermission } from '../../../permissions/required-permission.enum'; import { NoteHeaderInterceptor } from '../../utils/note-header.interceptor'; import { OpenApi } from '../../utils/openapi.decorator'; -import { RequestNote } from '../../utils/request-note.decorator'; -import { RequestUser } from '../../utils/request-user.decorator'; +import { RequestNoteId } from '../../utils/request-note-id.decorator'; @UseGuards(SessionGuard) @OpenApi(401) @@ -44,7 +30,7 @@ export class MediaController { constructor( private readonly logger: ConsoleLoggerService, private mediaService: MediaService, - private permissionsService: PermissionsService, + private permissionsService: PermissionService, ) { this.logger.setContext(MediaController.name); } @@ -64,7 +50,7 @@ export class MediaController { }) @ApiHeader({ name: 'HedgeDoc-Note', - description: 'ID or alias of the parent note', + description: 'ID or aliases of the parent note', }) @UseGuards(PermissionsGuard) @UseInterceptors(FileInterceptor('file')) @@ -83,71 +69,60 @@ export class MediaController { ) async uploadMedia( @UploadedFile() file: MulterFile | undefined, - @RequestNote() note: Note, - @RequestUser({ guestsAllowed: true }) user: User | null, - ): Promise { + @RequestNoteId() noteId: number, + @RequestUserInfo({ guestsAllowed: true }) userId: number | null, + ): Promise { if (file === undefined) { throw new BadRequestException('Request does not contain a file'); } - if (user) { + if (userId) { this.logger.debug( - `Received filename '${file.originalname}' for note '${note.publicId}' from user '${user.username}'`, + `Received filename '${file.originalname}' for note '${noteId}' from user '${userId}'`, 'uploadMedia', ); } else { this.logger.debug( - `Received filename '${file.originalname}' for note '${note.publicId}' from not logged in user`, + `Received filename '${file.originalname}' for note '${noteId}' from not logged in user`, 'uploadMedia', ); } - const upload = await this.mediaService.saveFile( + const uploadUuid = await this.mediaService.saveFile( file.originalname, file.buffer, - user, - note, + userId, + noteId, ); - return await this.mediaService.toMediaUploadDto(upload); + return uploadUuid; } @Get(':uuid') @OpenApi(200, 404, 500) - async getMedia( - @Param('uuid') uuid: string, - @Res() response: Response, - ): Promise { - const mediaUpload = await this.mediaService.findUploadByUuid(uuid); - const dto = await this.mediaService.toMediaUploadDto(mediaUpload); - response.send(dto); + async getMedia(@Param('uuid') uuid: string): Promise { + return (await this.mediaService.getMediaUploadDtosByUuids([uuid]))[0]; } @Delete(':uuid') @OpenApi(204, 403, 404, 500) async deleteMedia( - @RequestUser() user: User, + @RequestUserInfo() userId: number, @Param('uuid') uuid: string, ): Promise { const mediaUpload = await this.mediaService.findUploadByUuid(uuid); if ( - await this.permissionsService.checkMediaDeletePermission( - user, - mediaUpload, - ) + await this.permissionsService.checkMediaDeletePermission(userId, uuid) ) { this.logger.debug( - `Deleting '${uuid}' for user '${user.username}'`, + `Deleting '${uuid}' for user '${userId}'`, 'deleteMedia', ); await this.mediaService.deleteFile(mediaUpload); } else { this.logger.warn( - `${user.username} tried to delete '${uuid}', but is not the owner of upload or connected note`, + `${userId} tried to delete '${uuid}', but is not the owner of upload or connected note`, 'deleteMedia', ); - const mediaUploadNote = await mediaUpload.note; throw new PermissionError( - `Neither file '${uuid}' nor note '${ - mediaUploadNote?.publicId ?? 'unknown' - }'is owned by '${user.username}'`, + `'${userId}' does neither own the upload '${uuid}' nor the note associacted with this upload'`, ); } } diff --git a/backend/src/api/private/notes/notes.controller.ts b/backend/src/api/private/notes/notes.controller.ts index 84893f7f9..a20f38181 100644 --- a/backend/src/api/private/notes/notes.controller.ts +++ b/backend/src/api/private/notes/notes.controller.ts @@ -39,18 +39,18 @@ import { HistoryService } from '../../../history/history.service'; import { ConsoleLoggerService } from '../../../logger/console-logger.service'; import { MediaService } from '../../../media/media.service'; import { Note } from '../../../notes/note.entity'; -import { NotesService } from '../../../notes/notes.service'; +import { NoteService } from '../../../notes/note.service'; +import { PermissionService } from '../../../permissions/permission.service'; import { PermissionsGuard } from '../../../permissions/permissions.guard'; -import { PermissionsService } from '../../../permissions/permissions.service'; import { RequirePermission } from '../../../permissions/require-permission.decorator'; import { RequiredPermission } from '../../../permissions/required-permission.enum'; import { RevisionsService } from '../../../revisions/revisions.service'; import { UsersService } from '../../../users/users.service'; -import { GetNoteInterceptor } from '../../utils/get-note.interceptor'; +import { GetNoteIdInterceptor } from '../../utils/get-note-id.interceptor'; import { MarkdownBody } from '../../utils/markdown-body.decorator'; import { OpenApi } from '../../utils/openapi.decorator'; -import { RequestNote } from '../../utils/request-note.decorator'; -import { RequestUser } from '../../utils/request-user.decorator'; +import { RequestNoteId } from '../../utils/request-note-id.decorator'; +import { RequestUserId } from '../../utils/request-user.decorator'; @UseGuards(SessionGuard, PermissionsGuard) @OpenApi(401, 403) @@ -59,12 +59,12 @@ import { RequestUser } from '../../utils/request-user.decorator'; export class NotesController { constructor( private readonly logger: ConsoleLoggerService, - private noteService: NotesService, + private noteService: NoteService, private historyService: HistoryService, private userService: UsersService, private mediaService: MediaService, private revisionsService: RevisionsService, - private permissionService: PermissionsService, + private permissionService: PermissionService, private groupService: GroupsService, ) { this.logger.setContext(NotesController.name); @@ -73,10 +73,10 @@ export class NotesController { @Get(':noteIdOrAlias') @OpenApi(200) @RequirePermission(RequiredPermission.READ) - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) async getNote( - @RequestUser({ guestsAllowed: true }) user: User | null, - @RequestNote() note: Note, + @RequestUserId({ guestsAllowed: true }) user: User | null, + @RequestNoteId() note: Note, ): Promise { await this.historyService.updateHistoryEntryTimestamp(note, user); return await this.noteService.toNoteDto(note); @@ -85,11 +85,13 @@ export class NotesController { @Get(':noteIdOrAlias/media') @OpenApi(200) @RequirePermission(RequiredPermission.READ) - @UseInterceptors(GetNoteInterceptor) - async getNotesMedia(@RequestNote() note: Note): Promise { - const media = await this.mediaService.listUploadsByNote(note); + @UseInterceptors(GetNoteIdInterceptor) + async getNotesMedia( + @RequestNoteId() noteId: number, + ): Promise { + const media = await this.mediaService.getMediaUploadUuidsByNoteId(noteId); return await Promise.all( - media.map((media) => this.mediaService.toMediaUploadDto(media)), + media.map((media) => this.mediaService.getMediaUploadDtosByUuids(media)), ); } @@ -97,7 +99,7 @@ export class NotesController { @OpenApi(201, 413) @RequirePermission(RequiredPermission.CREATE) async createNote( - @RequestUser({ guestsAllowed: true }) user: User | null, + @RequestUserId({ guestsAllowed: true }) user: User | null, @MarkdownBody() text: string, ): Promise { this.logger.debug('Got raw markdown:\n' + text, 'createNote'); @@ -110,7 +112,7 @@ export class NotesController { @OpenApi(201, 400, 404, 409, 413) @RequirePermission(RequiredPermission.CREATE) async createNamedNote( - @RequestUser({ guestsAllowed: true }) user: User | null, + @RequestUserId({ guestsAllowed: true }) userId: User | null, @Param('noteAlias') noteAlias: string, @MarkdownBody() text: string, ): Promise { @@ -123,13 +125,14 @@ export class NotesController { @Delete(':noteIdOrAlias') @OpenApi(204, 404, 500) @RequirePermission(RequiredPermission.OWNER) - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) async deleteNote( - @RequestUser() user: User, - @RequestNote() note: Note, + @RequestUserId() user: User, + @RequestNoteId() note: Note, @Body() noteMediaDeletionDto: NoteMediaDeletionDto, ): Promise { - const mediaUploads = await this.mediaService.listUploadsByNote(note); + const mediaUploads = + await this.mediaService.getMediaUploadUuidsByNoteId(note); for (const mediaUpload of mediaUploads) { if (!noteMediaDeletionDto.keepMedia) { await this.mediaService.deleteFile(mediaUpload); @@ -143,12 +146,12 @@ export class NotesController { return; } - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(RequiredPermission.READ) @Get(':noteIdOrAlias/metadata') async getNoteMetadata( - @RequestUser({ guestsAllowed: true }) user: User | null, - @RequestNote() note: Note, + @RequestUserId({ guestsAllowed: true }) user: User | null, + @RequestNoteId() note: Note, ): Promise { return await this.noteService.toNoteMetadataDto(note); } @@ -156,34 +159,29 @@ export class NotesController { @Get(':noteIdOrAlias/revisions') @OpenApi(200, 404) @RequirePermission(RequiredPermission.READ) - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) async getNoteRevisions( - @RequestUser({ guestsAllowed: true }) user: User | null, - @RequestNote() note: Note, + @RequestUserId({ guestsAllowed: true }) user: User | null, + @RequestNoteId() note: Note, ): Promise { - const revisions = await this.revisionsService.getAllRevisions(note); - return await Promise.all( - revisions.map((revision) => - this.revisionsService.toRevisionMetadataDto(revision), - ), - ); + return await this.revisionsService.getAllRevisionMetadataDto(note); } @Delete(':noteIdOrAlias/revisions') @OpenApi(204, 404) @RequirePermission(RequiredPermission.OWNER) - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) async purgeNoteRevisions( - @RequestUser() user: User, - @RequestNote() note: Note, + @RequestUserId() userId: number, + @RequestNoteId() noteId: number, ): Promise { this.logger.debug( - `Purging history of note: ${note.id}`, + `Purging history of note: ${noteId}`, 'purgeNoteRevisions', ); - await this.revisionsService.purgeRevisions(note); + await this.revisionsService.purgeRevisions(noteId); this.logger.debug( - `Successfully purged history of note ${note.id}`, + `Successfully purged history of note ${noteId}`, 'purgeNoteRevisions', ); return; @@ -192,49 +190,44 @@ export class NotesController { @Get(':noteIdOrAlias/revisions/:revisionId') @OpenApi(200, 404) @RequirePermission(RequiredPermission.READ) - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) async getNoteRevision( - @RequestUser({ guestsAllowed: true }) user: User | null, - @RequestNote() note: Note, + @RequestUserId({ guestsAllowed: true }) user: User | null, @Param('revisionId') revisionId: number, ): Promise { - return await this.revisionsService.toRevisionDto( - await this.revisionsService.getRevision(note, revisionId), - ); + return await this.revisionsService.getRevisionDto(revisionId); } @Put(':noteIdOrAlias/metadata/permissions/users/:username') @OpenApi(200, 403, 404) - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(RequiredPermission.OWNER) async setUserPermission( - @RequestUser() user: User, - @RequestNote() note: Note, + @RequestUserId() user: User, + @RequestNoteId() note: Note, @Param('username') username: NoteUserPermissionUpdateDto['username'], @Body('canEdit') canEdit: NoteUserPermissionUpdateDto['canEdit'], ): Promise { - const permissionUser = await this.userService.getUserByUsername(username); const returnedNote = await this.permissionService.setUserPermission( note, - permissionUser, + makeUsernameLowercase(username), canEdit, ); return await this.noteService.toNotePermissionsDto(returnedNote); } - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(RequiredPermission.OWNER) @Delete(':noteIdOrAlias/metadata/permissions/users/:username') async removeUserPermission( - @RequestUser() user: User, - @RequestNote() note: Note, + @RequestUserId() user: User, + @RequestNoteId() note: Note, @Param('username') username: NoteUserPermissionEntryDto['username'], ): Promise { try { - const permissionUser = await this.userService.getUserByUsername(username); const returnedNote = await this.permissionService.removeUserPermission( note, - permissionUser, + username, ); return await this.noteService.toNotePermissionsDto(returnedNote); } catch (e) { @@ -247,54 +240,49 @@ export class NotesController { } } - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(RequiredPermission.OWNER) @Put(':noteIdOrAlias/metadata/permissions/groups/:groupName') async setGroupPermission( - @RequestUser() user: User, - @RequestNote() note: Note, + @RequestUserId() user: User, + @RequestNoteId() note: Note, @Param('groupName') groupName: NoteGroupPermissionUpdateDto['groupName'], @Body('canEdit') canEdit: NoteGroupPermissionUpdateDto['canEdit'], ): Promise { - const permissionGroup = await this.groupService.getGroupByName(groupName); const returnedNote = await this.permissionService.setGroupPermission( note, - permissionGroup, + groupName, canEdit, ); return await this.noteService.toNotePermissionsDto(returnedNote); } - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(RequiredPermission.OWNER) @UseGuards(PermissionsGuard) @Delete(':noteIdOrAlias/metadata/permissions/groups/:groupName') async removeGroupPermission( - @RequestUser() user: User, - @RequestNote() note: Note, + @RequestUserId() user: User, + @RequestNoteId() note: Note, @Param('groupName') groupName: NoteGroupPermissionEntryDto['groupName'], ): Promise { - const permissionGroup = await this.groupService.getGroupByName(groupName); const returnedNote = await this.permissionService.removeGroupPermission( note, - permissionGroup, + groupName, ); return await this.noteService.toNotePermissionsDto(returnedNote); } - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(RequiredPermission.OWNER) @Put(':noteIdOrAlias/metadata/permissions/owner') async changeOwner( - @RequestUser() user: User, - @RequestNote() note: Note, + @RequestUserId() user: User, + @RequestNoteId() note: Note, @Body() changeNoteOwnerDto: ChangeNoteOwnerDto, ): Promise { - const owner = await this.userService.getUserByUsername( - changeNoteOwnerDto.owner, - ); return await this.noteService.toNoteDto( - await this.permissionService.changeOwner(note, owner), + await this.permissionService.changeOwner(note, newOwner), ); } } diff --git a/backend/src/api/private/private-api.module.ts b/backend/src/api/private/private-api.module.ts index 169ec3ec6..c5f08199d 100644 --- a/backend/src/api/private/private-api.module.ts +++ b/backend/src/api/private/private-api.module.ts @@ -1,23 +1,24 @@ /* - * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ import { Module } from '@nestjs/common'; +import { AliasModule } from '../../alias/alias.module'; import { ApiTokenModule } from '../../api-token/api-token.module'; import { AuthModule } from '../../auth/auth.module'; import { FrontendConfigModule } from '../../frontend-config/frontend-config.module'; import { GroupsModule } from '../../groups/groups.module'; -import { HistoryModule } from '../../history/history.module'; import { LoggerModule } from '../../logger/logger.module'; import { MediaModule } from '../../media/media.module'; -import { NotesModule } from '../../notes/notes.module'; import { PermissionsModule } from '../../permissions/permissions.module'; import { RevisionsModule } from '../../revisions/revisions.module'; import { UsersModule } from '../../users/users.module'; import { AliasController } from './alias/alias.controller'; +import { ApiTokensController } from './api-tokens/api-tokens.controller'; import { AuthController } from './auth/auth.controller'; +import { GuestController } from './auth/guest/guest.controller'; import { LdapController } from './auth/ldap/ldap.controller'; import { LocalController } from './auth/local/local.controller'; import { OidcController } from './auth/oidc/oidc.controller'; @@ -27,7 +28,6 @@ import { HistoryController } from './me/history/history.controller'; import { MeController } from './me/me.controller'; import { MediaController } from './media/media.controller'; import { NotesController } from './notes/notes.controller'; -import { ApiTokensController } from './tokens/api-tokens.controller'; import { UsersController } from './users/users.controller'; @Module({ @@ -36,9 +36,8 @@ import { UsersController } from './users/users.controller'; UsersModule, ApiTokenModule, FrontendConfigModule, - HistoryModule, PermissionsModule, - NotesModule, + AliasModule, MediaModule, RevisionsModule, AuthModule, @@ -47,6 +46,7 @@ import { UsersController } from './users/users.controller'; controllers: [ ApiTokensController, ConfigController, + GuestController, MediaController, HistoryController, MeController, diff --git a/backend/src/api/private/users/users.controller.ts b/backend/src/api/private/users/users.controller.ts index d8957ba60..1887cd36c 100644 --- a/backend/src/api/private/users/users.controller.ts +++ b/backend/src/api/private/users/users.controller.ts @@ -31,7 +31,7 @@ export class UsersController { async checkUsername( @Body() usernameCheck: UsernameCheckDto, ): Promise { - const userExists = await this.userService.checkIfUserExists( + const userExists = await this.userService.isUsernameTaken( usernameCheck.username, ); // TODO Check if username is blocked diff --git a/backend/src/api/public/alias/alias.controller.ts b/backend/src/api/public/alias/alias.controller.ts index 1a7ef3ac8..55b863b1b 100644 --- a/backend/src/api/public/alias/alias.controller.ts +++ b/backend/src/api/public/alias/alias.controller.ts @@ -22,14 +22,14 @@ import { } from '@nestjs/common'; import { ApiSecurity, ApiTags } from '@nestjs/swagger'; +import { AliasService } from '../../../alias/alias.service'; import { ApiTokenGuard } from '../../../api-token/api-token.guard'; -import { User } from '../../../database/user.entity'; +import { User } from '../../../database/types'; import { ConsoleLoggerService } from '../../../logger/console-logger.service'; -import { AliasService } from '../../../notes/alias.service'; -import { NotesService } from '../../../notes/notes.service'; -import { PermissionsService } from '../../../permissions/permissions.service'; +import { NoteService } from '../../../notes/note.service'; +import { PermissionService } from '../../../permissions/permission.service'; +import { RequestUserId } from '../../utils/decorator/request-user.decorator'; import { OpenApi } from '../../utils/openapi.decorator'; -import { RequestUser } from '../../utils/request-user.decorator'; @UseGuards(ApiTokenGuard) @OpenApi(401) @@ -40,8 +40,8 @@ export class AliasController { constructor( private readonly logger: ConsoleLoggerService, private aliasService: AliasService, - private noteService: NotesService, - private permissionsService: PermissionsService, + private noteService: NoteService, + private permissionsService: PermissionService, ) { this.logger.setContext(AliasController.name); } @@ -50,20 +50,20 @@ export class AliasController { @OpenApi( { code: 200, - description: 'The new alias', + description: 'The new aliases', schema: AliasSchema, }, 403, 404, ) async addAlias( - @RequestUser() user: User, + @RequestUserId() userId: number, @Body() newAliasDto: AliasCreateDto, ): Promise { - const note = await this.noteService.getNoteByIdOrAlias( + const note = await this.noteService.getNoteIdByAlias( newAliasDto.noteIdOrAlias, ); - if (!(await this.permissionsService.isOwner(user, note))) { + if (!(await this.permissionsService.isOwner(userId, note))) { throw new UnauthorizedException('Reading note denied!'); } const updatedAlias = await this.aliasService.addAlias( @@ -73,18 +73,18 @@ export class AliasController { return this.aliasService.toAliasDto(updatedAlias, note); } - @Put(':alias') + @Put(':aliases') @OpenApi( { code: 200, - description: 'The updated alias', + description: 'The updated aliases', schema: AliasSchema, }, 403, 404, ) async makeAliasPrimary( - @RequestUser() user: User, + @RequestUserId() user: User, @Param('alias') alias: string, @Body() changeAliasDto: AliasUpdateDto, ): Promise { @@ -93,7 +93,7 @@ export class AliasController { `The field 'primaryAlias' must be set to 'true'.`, ); } - const note = await this.noteService.getNoteByIdOrAlias(alias); + const note = await this.noteService.getNoteIdByAlias(alias); if (!(await this.permissionsService.isOwner(user, note))) { throw new UnauthorizedException('Reading note denied!'); } @@ -101,21 +101,21 @@ export class AliasController { return this.aliasService.toAliasDto(updatedAlias, note); } - @Delete(':alias') + @Delete(':aliases') @OpenApi( { code: 204, - description: 'The alias was deleted', + description: 'The aliases was deleted', }, 400, 403, 404, ) async removeAlias( - @RequestUser() user: User, + @RequestUserId() user: User, @Param('alias') alias: AliasDto['name'], ): Promise { - const note = await this.noteService.getNoteByIdOrAlias(alias); + const note = await this.noteService.getNoteIdByAlias(alias); if (!(await this.permissionsService.isOwner(user, note))) { throw new UnauthorizedException('Reading note denied!'); } diff --git a/backend/src/api/public/me/me.controller.ts b/backend/src/api/public/me/me.controller.ts index 189adb353..d62173e32 100644 --- a/backend/src/api/public/me/me.controller.ts +++ b/backend/src/api/public/me/me.controller.ts @@ -4,38 +4,26 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { - FullUserInfoDto, FullUserInfoSchema, + LoginUserInfoDto, MediaUploadDto, MediaUploadSchema, NoteMetadataDto, NoteMetadataSchema, + ProviderType, } from '@hedgedoc/commons'; -import { - Body, - Controller, - Delete, - Get, - Put, - UseGuards, - UseInterceptors, -} from '@nestjs/common'; +import { Controller, Get, UseGuards } from '@nestjs/common'; import { ApiSecurity, ApiTags } from '@nestjs/swagger'; +import { User } from 'src/database/types'; import { ApiTokenGuard } from '../../../api-token/api-token.guard'; -import { User } from '../../../database/user.entity'; -import { HistoryEntryUpdateDto } from '../../../history/history-entry-update.dto'; -import { HistoryEntryDto } from '../../../history/history-entry.dto'; -import { HistoryService } from '../../../history/history.service'; import { ConsoleLoggerService } from '../../../logger/console-logger.service'; import { MediaService } from '../../../media/media.service'; -import { Note } from '../../../notes/note.entity'; -import { NotesService } from '../../../notes/notes.service'; +import { NoteService } from '../../../notes/note.service'; import { UsersService } from '../../../users/users.service'; -import { GetNoteInterceptor } from '../../utils/get-note.interceptor'; import { OpenApi } from '../../utils/openapi.decorator'; -import { RequestNote } from '../../utils/request-note.decorator'; -import { RequestUser } from '../../utils/request-user.decorator'; +import { RequestUserInfo } from '../../utils/request-user-id.decorator'; +import { SessionAuthProvider } from '../../utils/session-authprovider.decorator'; @UseGuards(ApiTokenGuard) @OpenApi(401) @@ -46,8 +34,7 @@ export class MeController { constructor( private readonly logger: ConsoleLoggerService, private usersService: UsersService, - private historyService: HistoryService, - private notesService: NotesService, + private notesService: NoteService, private mediaService: MediaService, ) { this.logger.setContext(MeController.name); @@ -59,67 +46,12 @@ export class MeController { description: 'The user information', schema: FullUserInfoSchema, }) - getMe(@RequestUser() user: User): FullUserInfoDto { - return this.usersService.toFullUserDto(user); - } - - @Get('history') - @OpenApi({ - code: 200, - description: 'The history entries of the user', - isArray: true, - }) - async getUserHistory(@RequestUser() user: User): Promise { - const foundEntries = await this.historyService.getEntriesByUser(user); - return await Promise.all( - foundEntries.map((entry) => this.historyService.toHistoryEntryDto(entry)), - ); - } - - @UseInterceptors(GetNoteInterceptor) - @Get('history/:noteIdOrAlias') - @OpenApi( - { - code: 200, - description: 'The history entry of the user which points to the note', - }, - 404, - ) - async getHistoryEntry( - @RequestUser() user: User, - @RequestNote() note: Note, - ): Promise { - const foundEntry = await this.historyService.getEntryByNote(note, user); - return await this.historyService.toHistoryEntryDto(foundEntry); - } - - @UseInterceptors(GetNoteInterceptor) - @Put('history/:noteIdOrAlias') - @OpenApi( - { - code: 200, - description: 'The updated history entry', - }, - 404, - ) - async updateHistoryEntry( - @RequestUser() user: User, - @RequestNote() note: Note, - @Body() entryUpdateDto: HistoryEntryUpdateDto, - ): Promise { - return await this.historyService.toHistoryEntryDto( - await this.historyService.updateHistoryEntry(note, user, entryUpdateDto), - ); - } - - @UseInterceptors(GetNoteInterceptor) - @Delete('history/:noteIdOrAlias') - @OpenApi(204, 404) - async deleteHistoryEntry( - @RequestUser() user: User, - @RequestNote() note: Note, - ): Promise { - await this.historyService.deleteHistoryEntry(note, user); + async getMe( + @RequestUserInfo() userId: number, + @SessionAuthProvider() authProvider: ProviderType, + ): Promise { + const user: User = await this.usersService.getUserById(userId); + return this.usersService.toLoginUserInfoDto(user, authProvider); } @Get('notes') @@ -129,8 +61,10 @@ export class MeController { isArray: true, schema: NoteMetadataSchema, }) - async getMyNotes(@RequestUser() user: User): Promise { - const notes = this.notesService.getUserNotes(user); + async getMyNotes( + @RequestUserInfo() userId: number, + ): Promise { + const notes = this.notesService.getUserNotes(userId); return await Promise.all( (await notes).map((note) => this.notesService.toNoteMetadataDto(note)), ); @@ -143,10 +77,10 @@ export class MeController { isArray: true, schema: MediaUploadSchema, }) - async getMyMedia(@RequestUser() user: User): Promise { - const media = await this.mediaService.listUploadsByUser(user); + async getMyMedia(@RequestUserInfo() userId: number): Promise { + const media = await this.mediaService.getMediaUploadUuidsByUserId(userId); return await Promise.all( - media.map((media) => this.mediaService.toMediaUploadDto(media)), + media.map((media) => this.mediaService.getMediaUploadDtosByUuids(media)), ); } } diff --git a/backend/src/api/public/media/media.controller.ts b/backend/src/api/public/media/media.controller.ts index 12ac11d43..4654e87b9 100644 --- a/backend/src/api/public/media/media.controller.ts +++ b/backend/src/api/public/media/media.controller.ts @@ -27,20 +27,25 @@ import { import { Response } from 'express'; import { ApiTokenGuard } from '../../../api-token/api-token.guard'; -import { User } from '../../../database/user.entity'; +import { + FieldNameMediaUpload, + FieldNameNote, + FieldNameUser, + Note, + User, +} from '../../../database/types'; import { PermissionError } from '../../../errors/errors'; import { ConsoleLoggerService } from '../../../logger/console-logger.service'; import { MediaService } from '../../../media/media.service'; import { MulterFile } from '../../../media/multer-file.interface'; -import { Note } from '../../../notes/note.entity'; +import { PermissionService } from '../../../permissions/permission.service'; import { PermissionsGuard } from '../../../permissions/permissions.guard'; -import { PermissionsService } from '../../../permissions/permissions.service'; import { RequirePermission } from '../../../permissions/require-permission.decorator'; import { RequiredPermission } from '../../../permissions/required-permission.enum'; import { NoteHeaderInterceptor } from '../../utils/note-header.interceptor'; import { OpenApi } from '../../utils/openapi.decorator'; -import { RequestNote } from '../../utils/request-note.decorator'; -import { RequestUser } from '../../utils/request-user.decorator'; +import { RequestNoteId } from '../../utils/request-note-id.decorator'; +import { RequestUserId } from '../../utils/request-user.decorator'; @UseGuards(ApiTokenGuard) @OpenApi(401) @@ -51,7 +56,7 @@ export class MediaController { constructor( private readonly logger: ConsoleLoggerService, private mediaService: MediaService, - private permissionsService: PermissionsService, + private permissionsService: PermissionService, ) { this.logger.setContext(MediaController.name); } @@ -71,7 +76,7 @@ export class MediaController { }) @ApiHeader({ name: 'HedgeDoc-Note', - description: 'ID or alias of the parent note', + description: 'ID or aliases of the parent note', }) @OpenApi( { @@ -89,41 +94,36 @@ export class MediaController { @UseInterceptors(NoteHeaderInterceptor) @RequirePermission(RequiredPermission.WRITE) async uploadMedia( - @RequestUser() user: User, + @RequestUserId() user: User, @UploadedFile() file: MulterFile, - @RequestNote() note: Note, + @RequestNoteId() note: Note, ): Promise { if (file === undefined) { throw new BadRequestException('Request does not contain a file'); } this.logger.debug( - `Received filename '${file.originalname}' for note '${note.publicId}' from user '${user.username}'`, + `Received filename '${file.originalname}' for note '${note[FieldNameNote.id]}' from user '${user.username}'`, 'uploadMedia', ); - const upload = await this.mediaService.saveFile( + const uploadUuid = await this.mediaService.saveFile( file.originalname, file.buffer, - user, - note, + user[FieldNameUser.id], + note[FieldNameNote.id], ); - return await this.mediaService.toMediaUploadDto(upload); + return await this.mediaService.getMediaUploadDtosByUuids(uploadUuid); } @Get(':uuid') @OpenApi(200, 404, 500) - async getMedia( - @Param('uuid') uuid: string, - @Res() response: Response, - ): Promise { - const mediaUpload = await this.mediaService.findUploadByUuid(uuid); - const dto = await this.mediaService.toMediaUploadDto(mediaUpload); - response.send(dto); + async getMedia(@Param('uuid') uuid: string): Promise { + return await this.mediaService.getMediaUploadDtosByUuids(uuid); } @Delete(':uuid') @OpenApi(204, 403, 404, 500) async deleteMedia( - @RequestUser() user: User, + @RequestUserId() user: User, @Param('uuid') uuid: string, ): Promise { const mediaUpload = await this.mediaService.findUploadByUuid(uuid); @@ -143,10 +143,10 @@ export class MediaController { `${user.username} tried to delete '${uuid}', but is not the owner of upload or connected note`, 'deleteMedia', ); - const mediaUploadNote = await mediaUpload.note; + const mediaUploadNote = mediaUpload[FieldNameMediaUpload.noteId]; throw new PermissionError( `Neither file '${uuid}' nor note '${ - mediaUploadNote?.publicId ?? 'unknown' + mediaUploadNote ?? 'unknown' }'is owned by '${user.username}'`, ); } diff --git a/backend/src/api/public/notes/notes.controller.ts b/backend/src/api/public/notes/notes.controller.ts index 9ff726c56..6db657444 100644 --- a/backend/src/api/public/notes/notes.controller.ts +++ b/backend/src/api/public/notes/notes.controller.ts @@ -12,7 +12,6 @@ import { NoteMetadataSchema, NotePermissionsDto, NotePermissionsSchema, - NotePermissionsUpdateDto, NoteSchema, RevisionDto, RevisionMetadataDto, @@ -20,7 +19,6 @@ import { RevisionSchema, } from '@hedgedoc/commons'; import { - BadRequestException, Body, Controller, Delete, @@ -34,25 +32,21 @@ import { import { ApiSecurity, ApiTags } from '@nestjs/swagger'; import { ApiTokenGuard } from '../../../api-token/api-token.guard'; -import { User } from '../../../database/user.entity'; -import { NotInDBError } from '../../../errors/errors'; import { GroupsService } from '../../../groups/groups.service'; -import { HistoryService } from '../../../history/history.service'; import { ConsoleLoggerService } from '../../../logger/console-logger.service'; import { MediaService } from '../../../media/media.service'; -import { Note } from '../../../notes/note.entity'; -import { NotesService } from '../../../notes/notes.service'; +import { NoteService } from '../../../notes/note.service'; +import { PermissionService } from '../../../permissions/permission.service'; import { PermissionsGuard } from '../../../permissions/permissions.guard'; -import { PermissionsService } from '../../../permissions/permissions.service'; import { RequirePermission } from '../../../permissions/require-permission.decorator'; import { RequiredPermission } from '../../../permissions/required-permission.enum'; import { RevisionsService } from '../../../revisions/revisions.service'; import { UsersService } from '../../../users/users.service'; -import { GetNoteInterceptor } from '../../utils/get-note.interceptor'; -import { MarkdownBody } from '../../utils/markdown-body.decorator'; -import { OpenApi } from '../../utils/openapi.decorator'; -import { RequestNote } from '../../utils/request-note.decorator'; -import { RequestUser } from '../../utils/request-user.decorator'; +import { MarkdownBody } from '../../utils/decorators/markdown-body.decorator'; +import { OpenApi } from '../../utils/decorators/openapi.decorator'; +import { RequestNoteId } from '../../utils/decorators/request-note-id.decorator'; +import { RequestUserId } from '../../utils/decorators/request-user-id.decorator'; +import { GetNoteIdInterceptor } from '../../utils/interceptors/get-note-id.interceptor'; @UseGuards(ApiTokenGuard, PermissionsGuard) @OpenApi(401) @@ -62,13 +56,12 @@ import { RequestUser } from '../../utils/request-user.decorator'; export class NotesController { constructor( private readonly logger: ConsoleLoggerService, - private noteService: NotesService, + private noteService: NoteService, private userService: UsersService, private groupService: GroupsService, private revisionsService: RevisionsService, - private historyService: HistoryService, private mediaService: MediaService, - private permissionService: PermissionsService, + private permissionService: PermissionService, ) { this.logger.setContext(NotesController.name); } @@ -77,16 +70,15 @@ export class NotesController { @Post() @OpenApi(201, 403, 409, 413) async createNote( - @RequestUser() user: User, + @RequestUserId() userId: number, @MarkdownBody() text: string, ): Promise { this.logger.debug('Got raw markdown:\n' + text); - return await this.noteService.toNoteDto( - await this.noteService.createNote(text, user), - ); + const newNote = await this.noteService.createNote(text, userId); + return await this.noteService.toNoteDto(newNote); } - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(RequiredPermission.READ) @Get(':noteIdOrAlias') @OpenApi( @@ -99,11 +91,10 @@ export class NotesController { 404, ) async getNote( - @RequestUser() user: User, - @RequestNote() note: Note, + @RequestUserId() _userId: number, + @RequestNoteId() noteId: number, ): Promise { - await this.historyService.updateHistoryEntryTimestamp(note, user); - return await this.noteService.toNoteDto(note); + return await this.noteService.toNoteDto(noteId); } @RequirePermission(RequiredPermission.CREATE) @@ -120,26 +111,26 @@ export class NotesController { 413, ) async createNamedNote( - @RequestUser() user: User, + @RequestUserId() userId: number, @Param('noteAlias') noteAlias: string, @MarkdownBody() text: string, ): Promise { this.logger.debug('Got raw markdown:\n' + text, 'createNamedNote'); - return await this.noteService.toNoteDto( - await this.noteService.createNote(text, user, noteAlias), - ); + const noteId = await this.noteService.createNote(text, userId, noteAlias); + return await this.noteService.toNoteDto(); } - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(RequiredPermission.OWNER) @Delete(':noteIdOrAlias') @OpenApi(204, 403, 404, 500) async deleteNote( - @RequestUser() user: User, - @RequestNote() note: Note, + @RequestUserInfo() user: User, + @RequestNoteId() note: Note, @Body() noteMediaDeletionDto: NoteMediaDeletionDto, ): Promise { - const mediaUploads = await this.mediaService.listUploadsByNote(note); + const mediaUploads = + await this.mediaService.getMediaUploadUuidsByNoteId(note); for (const mediaUpload of mediaUploads) { if (!noteMediaDeletionDto.keepMedia) { await this.mediaService.deleteFile(mediaUpload); @@ -153,7 +144,7 @@ export class NotesController { return; } - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(RequiredPermission.WRITE) @Put(':noteIdOrAlias') @OpenApi( @@ -166,8 +157,8 @@ export class NotesController { 404, ) async updateNote( - @RequestUser() user: User, - @RequestNote() note: Note, + @RequestUserInfo() user: User, + @RequestNoteId() note: Note, @MarkdownBody() text: string, ): Promise { this.logger.debug('Got raw markdown:\n' + text, 'updateNote'); @@ -176,7 +167,7 @@ export class NotesController { ); } - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(RequiredPermission.READ) @Get(':noteIdOrAlias/content') @OpenApi( @@ -189,13 +180,13 @@ export class NotesController { 404, ) async getNoteContent( - @RequestUser() user: User, - @RequestNote() note: Note, + @RequestUserInfo() user: User, + @RequestNoteId() note: Note, ): Promise { return await this.noteService.getNoteContent(note); } - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(RequiredPermission.READ) @Get(':noteIdOrAlias/metadata') @OpenApi( @@ -208,35 +199,13 @@ export class NotesController { 404, ) async getNoteMetadata( - @RequestUser() user: User, - @RequestNote() note: Note, + @RequestUserInfo() user: User, + @RequestNoteId() note: Note, ): Promise { return await this.noteService.toNoteMetadataDto(note); } - @UseInterceptors(GetNoteInterceptor) - @RequirePermission(RequiredPermission.OWNER) - @Put(':noteIdOrAlias/metadata/permissions') - @OpenApi( - { - code: 200, - description: 'The updated permissions of the note', - schema: NotePermissionsSchema, - }, - 403, - 404, - ) - async updateNotePermissions( - @RequestUser() user: User, - @RequestNote() note: Note, - @Body() updateDto: NotePermissionsUpdateDto, - ): Promise { - return await this.noteService.toNotePermissionsDto( - await this.permissionService.updateNotePermissions(note, updateDto), - ); - } - - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(RequiredPermission.READ) @Get(':noteIdOrAlias/metadata/permissions') @OpenApi( @@ -249,13 +218,13 @@ export class NotesController { 404, ) async getPermissions( - @RequestUser() user: User, - @RequestNote() note: Note, + @RequestUserInfo() userId: number, + @RequestNoteId() noteId: number, ): Promise { - return await this.noteService.toNotePermissionsDto(note); + return await this.permissionService.getPermissionsForNote(noteId); } - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(RequiredPermission.OWNER) @Put(':noteIdOrAlias/metadata/permissions/users/:userName') @OpenApi( @@ -268,21 +237,21 @@ export class NotesController { 404, ) async setUserPermission( - @RequestUser() user: User, - @RequestNote() note: Note, + @RequestUserInfo() userId: number, + @RequestNoteId() noteId: number, @Param('userName') username: string, @Body('canEdit') canEdit: boolean, ): Promise { - const permissionUser = await this.userService.getUserByUsername(username); - const returnedNote = await this.permissionService.setUserPermission( - note, - permissionUser, + const targetUserId = await this.userService.getUserIdByUsername(username); + await this.permissionService.setUserPermission( + noteId, + targetUserId, canEdit, ); - return await this.noteService.toNotePermissionsDto(returnedNote); + return await this.permissionService.getPermissionsForNote(noteId); } - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(RequiredPermission.OWNER) @Delete(':noteIdOrAlias/metadata/permissions/users/:userName') @OpenApi( @@ -295,28 +264,16 @@ export class NotesController { 404, ) async removeUserPermission( - @RequestUser() user: User, - @RequestNote() note: Note, + @RequestUserInfo() userId: number, + @RequestNoteId() noteId: number, @Param('userName') username: string, ): Promise { - try { - const permissionUser = await this.userService.getUserByUsername(username); - const returnedNote = await this.permissionService.removeUserPermission( - note, - permissionUser, - ); - return await this.noteService.toNotePermissionsDto(returnedNote); - } catch (e) { - if (e instanceof NotInDBError) { - throw new BadRequestException( - "Can't remove user from permissions. User not known.", - ); - } - throw e; - } + const targetUserId = await this.userService.getUserIdByUsername(username); + await this.permissionService.removeUserPermission(noteId, targetUserId); + return await this.permissionService.getPermissionsForNote(noteId); } - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(RequiredPermission.OWNER) @Put(':noteIdOrAlias/metadata/permissions/groups/:groupName') @OpenApi( @@ -329,21 +286,17 @@ export class NotesController { 404, ) async setGroupPermission( - @RequestUser() user: User, - @RequestNote() note: Note, + @RequestUserInfo() userId: number, + @RequestNoteId() noteId: number, @Param('groupName') groupName: string, @Body('canEdit') canEdit: boolean, ): Promise { - const permissionGroup = await this.groupService.getGroupByName(groupName); - const returnedNote = await this.permissionService.setGroupPermission( - note, - permissionGroup, - canEdit, - ); - return await this.noteService.toNotePermissionsDto(returnedNote); + const groupId = await this.groupService.getGroupIdByName(groupName); + await this.permissionService.setGroupPermission(noteId, groupId, canEdit); + return await this.permissionService.getPermissionsForNote(noteId); } - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(RequiredPermission.OWNER) @Delete(':noteIdOrAlias/metadata/permissions/groups/:groupName') @OpenApi( @@ -356,19 +309,16 @@ export class NotesController { 404, ) async removeGroupPermission( - @RequestUser() user: User, - @RequestNote() note: Note, + @RequestUserInfo() userId: number, + @RequestNoteId() noteId: number, @Param('groupName') groupName: string, ): Promise { - const permissionGroup = await this.groupService.getGroupByName(groupName); - const returnedNote = await this.permissionService.removeGroupPermission( - note, - permissionGroup, - ); - return await this.noteService.toNotePermissionsDto(returnedNote); + const groupId = await this.groupService.getGroupIdByName(groupName); + await this.permissionService.removeGroupPermission(noteId, groupId); + return await this.permissionService.getPermissionsForNote(noteId); } - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(RequiredPermission.OWNER) @Put(':noteIdOrAlias/metadata/permissions/owner') @OpenApi( @@ -381,17 +331,19 @@ export class NotesController { 404, ) async changeOwner( - @RequestUser() user: User, - @RequestNote() note: Note, + @RequestUserInfo() userId: number, + @RequestNoteId() noteId: number, @Body('newOwner') newOwner: string, - ): Promise { - const owner = await this.userService.getUserByUsername(newOwner); - return await this.noteService.toNoteDto( - await this.permissionService.changeOwner(note, owner), + ): Promise { + const ownerUserId = await this.userService.getUserIdByUsername(newOwner); + await this.permissionService.changeOwner(noteId, ownerUserId); + + return await this.noteService.toNoteMetadataDto( + await this.noteService.getNoteById(), ); } - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(RequiredPermission.READ) @Get(':noteIdOrAlias/revisions') @OpenApi( @@ -405,40 +357,32 @@ export class NotesController { 404, ) async getNoteRevisions( - @RequestUser() user: User, - @RequestNote() note: Note, + @RequestNoteId() noteId: number, ): Promise { - const revisions = await this.revisionsService.getAllRevisions(note); - return await Promise.all( - revisions.map((revision) => - this.revisionsService.toRevisionMetadataDto(revision), - ), - ); + return await this.revisionsService.getAllRevisionMetadataDto(noteId); } - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(RequiredPermission.READ) - @Get(':noteIdOrAlias/revisions/:revisionId') + @Get(':noteIdOrAlias/revisions/:revisionUuid') @OpenApi( { code: 200, - description: 'Revision of the note for the given id or alias', + description: 'Revision of the note for the given id or aliases', schema: RevisionSchema, }, 403, 404, ) async getNoteRevision( - @RequestUser() user: User, - @RequestNote() note: Note, - @Param('revisionId') revisionId: number, + @Param('revisionUuid') revisionUuid: string, ): Promise { return await this.revisionsService.toRevisionDto( - await this.revisionsService.getRevision(note, revisionId), + await this.revisionsService.getRevision(revisionUuid), ); } - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(RequiredPermission.READ) @Get(':noteIdOrAlias/media') @OpenApi({ @@ -448,12 +392,11 @@ export class NotesController { schema: MediaUploadSchema, }) async getNotesMedia( - @RequestUser() user: User, - @RequestNote() note: Note, + @RequestNoteId() noteId: number, ): Promise { - const media = await this.mediaService.listUploadsByNote(note); + const media = await this.mediaService.getMediaUploadUuidsByNoteId(noteId); return await Promise.all( - media.map((media) => this.mediaService.toMediaUploadDto(media)), + media.map((media) => this.mediaService.getMediaUploadDtosByUuids(media)), ); } } diff --git a/backend/src/api/public/public-api.module.ts b/backend/src/api/public/public-api.module.ts index 57bea4a3f..fdb98c2dc 100644 --- a/backend/src/api/public/public-api.module.ts +++ b/backend/src/api/public/public-api.module.ts @@ -1,17 +1,16 @@ /* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ import { Module } from '@nestjs/common'; +import { AliasModule } from '../../alias/alias.module'; import { ApiTokenModule } from '../../api-token/api-token.module'; import { GroupsModule } from '../../groups/groups.module'; -import { HistoryModule } from '../../history/history.module'; import { LoggerModule } from '../../logger/logger.module'; 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 { RevisionsModule } from '../../revisions/revisions.module'; import { UsersModule } from '../../users/users.module'; @@ -26,8 +25,7 @@ import { NotesController } from './notes/notes.controller'; ApiTokenModule, GroupsModule, UsersModule, - HistoryModule, - NotesModule, + AliasModule, RevisionsModule, MonitoringModule, LoggerModule, diff --git a/backend/src/api/utils/markdown-body.decorator.ts b/backend/src/api/utils/decorators/markdown-body.decorator.ts similarity index 100% rename from backend/src/api/utils/markdown-body.decorator.ts rename to backend/src/api/utils/decorators/markdown-body.decorator.ts diff --git a/backend/src/api/utils/openapi.decorator.ts b/backend/src/api/utils/decorators/openapi.decorator.ts similarity index 99% rename from backend/src/api/utils/openapi.decorator.ts rename to backend/src/api/utils/decorators/openapi.decorator.ts index 07859b98c..71476f022 100644 --- a/backend/src/api/utils/openapi.decorator.ts +++ b/backend/src/api/utils/decorators/openapi.decorator.ts @@ -31,7 +31,7 @@ import { okDescription, payloadTooLargeDescription, unauthorizedDescription, -} from './descriptions'; +} from '../descriptions'; export type HttpStatusCodes = | 200 diff --git a/backend/src/api/utils/request-note.decorator.ts b/backend/src/api/utils/decorators/request-note-id.decorator.ts similarity index 64% rename from backend/src/api/utils/request-note.decorator.ts rename to backend/src/api/utils/decorators/request-note-id.decorator.ts index 0c1597337..9e882a7bd 100644 --- a/backend/src/api/utils/request-note.decorator.ts +++ b/backend/src/api/utils/decorators/request-note-id.decorator.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -9,7 +9,7 @@ import { InternalServerErrorException, } from '@nestjs/common'; -import { CompleteRequest } from './request.type'; +import { CompleteRequest } from '../request.type'; /** * Extracts the {@link Note} object from a request @@ -17,15 +17,13 @@ import { CompleteRequest } from './request.type'; * Will throw an {@link InternalServerErrorException} if no note is present */ // eslint-disable-next-line @typescript-eslint/naming-convention -export const RequestNote = createParamDecorator( +export const RequestNoteId = createParamDecorator( (data: unknown, ctx: ExecutionContext) => { const request: CompleteRequest = ctx.switchToHttp().getRequest(); - if (!request.note) { + if (!request.noteId) { // We should have a note here, otherwise something is wrong - throw new InternalServerErrorException( - 'Request is missing a note object', - ); + throw new InternalServerErrorException('Request is missing a noteId'); } - return request.note; + return request.noteId; }, ); diff --git a/backend/src/api/utils/request-user.decorator.ts b/backend/src/api/utils/decorators/request-user-id.decorator.ts similarity index 55% rename from backend/src/api/utils/request-user.decorator.ts rename to backend/src/api/utils/decorators/request-user-id.decorator.ts index 5d53a2de2..e7e6cc35a 100644 --- a/backend/src/api/utils/request-user.decorator.ts +++ b/backend/src/api/utils/decorators/request-user-id.decorator.ts @@ -1,40 +1,42 @@ /* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ +import { AuthProviderType } from '@hedgedoc/commons'; import { createParamDecorator, ExecutionContext, UnauthorizedException, } from '@nestjs/common'; -import { CompleteRequest } from './request.type'; +import { CompleteRequest } from '../request.type'; -type RequestUserParameter = { +type RequestUserIdParameter = { guestsAllowed: boolean; }; /** - * Trys to extract the {@link User} object from a request + * Trys to extract the {@link User.id} object from a request * * If a user is present in the request, returns the user object. * If no user is present and guests are allowed, returns `null`. * If no user is present and guests are not allowed, throws {@link UnauthorizedException}. */ // eslint-disable-next-line @typescript-eslint/naming-convention -export const RequestUser = createParamDecorator( +export const RequestUserId = createParamDecorator( ( - data: RequestUserParameter = { guestsAllowed: false }, + data: RequestUserIdParameter = { guestsAllowed: false }, ctx: ExecutionContext, ) => { const request: CompleteRequest = ctx.switchToHttp().getRequest(); - if (!request.user) { - if (data.guestsAllowed) { - return null; - } + if ( + !request.authProviderType || + (request.authProviderType === AuthProviderType.GUEST && + !data.guestsAllowed) + ) { throw new UnauthorizedException("You're not logged in"); } - return request.user; + return request.userId; }, ); diff --git a/backend/src/api/utils/session-authprovider.decorator.ts b/backend/src/api/utils/decorators/session-authprovider.decorator.ts similarity index 88% rename from backend/src/api/utils/session-authprovider.decorator.ts rename to backend/src/api/utils/decorators/session-authprovider.decorator.ts index e3222ad37..dead59c54 100644 --- a/backend/src/api/utils/session-authprovider.decorator.ts +++ b/backend/src/api/utils/decorators/session-authprovider.decorator.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -9,7 +9,7 @@ import { InternalServerErrorException, } from '@nestjs/common'; -import { CompleteRequest } from './request.type'; +import { CompleteRequest } from '../request.type'; /** * Extracts the auth provider identifier from a session inside a request diff --git a/backend/src/api/utils/extract-note-from-request.spec.ts b/backend/src/api/utils/extract-note-from-request.spec.ts index e9c16613b..615ef1586 100644 --- a/backend/src/api/utils/extract-note-from-request.spec.ts +++ b/backend/src/api/utils/extract-note-from-request.spec.ts @@ -1,13 +1,13 @@ /* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 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 { NotesService } from '../../notes/notes.service'; -import { extractNoteFromRequest } from './extract-note-from-request'; +import { Note } from '../../database/types'; +import { NoteService } from '../../notes/note.service'; +import { extractNoteIdFromRequest } from './extract-note-id-from-request'; import { CompleteRequest } from './request.type'; describe('extract note from request', () => { @@ -17,11 +17,11 @@ describe('extract note from request', () => { const mockNote1 = Mock.of({ id: 1 }); const mockNote2 = Mock.of({ id: 2 }); - let notesService: NotesService; + let notesService: NoteService; beforeEach(() => { - notesService = Mock.of({ - getNoteByIdOrAlias: async (id) => { + notesService = Mock.of({ + getNoteIdByAlias: async (id) => { if (id === mockNoteIdOrAlias1) { return mockNote1; } else if (id === mockNoteIdOrAlias2) { @@ -54,17 +54,23 @@ describe('extract note from request', () => { it('will return undefined if no id is present', async () => { const request = createRequest(undefined, undefined); - expect(await extractNoteFromRequest(request, notesService)).toBe(undefined); + expect(await extractNoteIdFromRequest(request, notesService)).toBe( + undefined, + ); }); it('can extract an id from parameters', async () => { const request = createRequest(mockNoteIdOrAlias1, undefined); - expect(await extractNoteFromRequest(request, notesService)).toBe(mockNote1); + expect(await extractNoteIdFromRequest(request, notesService)).toBe( + mockNote1, + ); }); it('can extract an id from headers if no parameter is given', async () => { const request = createRequest(undefined, mockNoteIdOrAlias1); - expect(await extractNoteFromRequest(request, notesService)).toBe(mockNote1); + expect(await extractNoteIdFromRequest(request, notesService)).toBe( + mockNote1, + ); }); it('can extract the first id from multiple id headers', async () => { @@ -72,16 +78,22 @@ describe('extract note from request', () => { mockNoteIdOrAlias1, mockNoteIdOrAlias2, ]); - expect(await extractNoteFromRequest(request, notesService)).toBe(mockNote1); + expect(await extractNoteIdFromRequest(request, notesService)).toBe( + mockNote1, + ); }); it('will return undefined if no parameter and empty id header array', async () => { const request = createRequest(undefined, []); - expect(await extractNoteFromRequest(request, notesService)).toBe(undefined); + expect(await extractNoteIdFromRequest(request, notesService)).toBe( + undefined, + ); }); it('will prefer the parameter over the header', async () => { const request = createRequest(mockNoteIdOrAlias1, mockNoteIdOrAlias2); - expect(await extractNoteFromRequest(request, notesService)).toBe(mockNote1); + expect(await extractNoteIdFromRequest(request, notesService)).toBe( + mockNote1, + ); }); }); diff --git a/backend/src/api/utils/extract-note-from-request.ts b/backend/src/api/utils/extract-note-from-request.ts deleted file mode 100644 index 3dc2af311..000000000 --- a/backend/src/api/utils/extract-note-from-request.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { isArray } from 'class-validator'; - -import { Note } from '../../notes/note.entity'; -import { NotesService } from '../../notes/notes.service'; -import { CompleteRequest } from './request.type'; - -export async function extractNoteFromRequest( - request: CompleteRequest, - noteService: NotesService, -): Promise { - const noteIdOrAlias = extractNoteIdOrAlias(request); - if (noteIdOrAlias === undefined) { - return undefined; - } - return await noteService.getNoteByIdOrAlias(noteIdOrAlias); -} - -function extractNoteIdOrAlias(request: CompleteRequest): string | undefined { - const noteIdOrAlias = - request.params['noteIdOrAlias'] || request.headers['hedgedoc-note']; - if (noteIdOrAlias === undefined) { - return undefined; - } else if (isArray(noteIdOrAlias)) { - return noteIdOrAlias[0]; - } else { - return noteIdOrAlias; - } -} diff --git a/backend/src/api/utils/extract-note-id-from-request.ts b/backend/src/api/utils/extract-note-id-from-request.ts new file mode 100644 index 000000000..24daf5733 --- /dev/null +++ b/backend/src/api/utils/extract-note-id-from-request.ts @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { isArray } from 'class-validator'; + +import { FieldNameNote, Note } from '../../database/types'; +import { NoteService } from '../../notes/note.service'; +import { CompleteRequest } from './request.type'; + +export async function extractNoteIdFromRequest( + request: CompleteRequest, + noteService: NoteService, +): Promise { + const alias = extractNoteAlias(request); + if (alias === undefined) { + return undefined; + } + return await noteService.getNoteIdByAlias(alias); +} + +function extractNoteAlias(request: CompleteRequest): string | undefined { + const noteAlias = + request.params['noteAlias'] || request.headers['hedgedoc-note']; + if (isArray(noteAlias)) { + return noteAlias[0]; + } + return noteAlias; +} diff --git a/backend/src/api/utils/guards/guests-enabled.guard.ts b/backend/src/api/utils/guards/guests-enabled.guard.ts new file mode 100644 index 000000000..a15932ed5 --- /dev/null +++ b/backend/src/api/utils/guards/guests-enabled.guard.ts @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { PermissionLevel } from '@hedgedoc/commons'; +import { CanActivate, Inject, Injectable } from '@nestjs/common'; + +import noteConfiguration, { NoteConfig } from '../../../config/note.config'; +import { FeatureDisabledError } from '../../../errors/errors'; +import { ConsoleLoggerService } from '../../../logger/console-logger.service'; + +@Injectable() +export class GuestsEnabledGuard implements CanActivate { + constructor( + private readonly logger: ConsoleLoggerService, + @Inject(noteConfiguration.KEY) + private noteConfig: NoteConfig, + ) { + this.logger.setContext(GuestsEnabledGuard.name); + } + + canActivate(): boolean { + if (this.noteConfig.guestAccess === PermissionLevel.DENY) { + throw new FeatureDisabledError( + 'Guest usage is disabled', + this.logger.getContext(), + 'canActivate', + ); + } + return true; + } +} diff --git a/backend/src/api/utils/login-enabled.guard.ts b/backend/src/api/utils/guards/login-enabled.guard.ts similarity index 54% rename from backend/src/api/utils/login-enabled.guard.ts rename to backend/src/api/utils/guards/login-enabled.guard.ts index ce7de6ea5..e74a9b30a 100644 --- a/backend/src/api/utils/login-enabled.guard.ts +++ b/backend/src/api/utils/guards/login-enabled.guard.ts @@ -1,13 +1,13 @@ /* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ import { CanActivate, Inject, Injectable } from '@nestjs/common'; -import authConfiguration, { AuthConfig } from '../../config/auth.config'; -import { FeatureDisabledError } from '../../errors/errors'; -import { ConsoleLoggerService } from '../../logger/console-logger.service'; +import authConfiguration, { AuthConfig } from '../../../config/auth.config'; +import { FeatureDisabledError } from '../../../errors/errors'; +import { ConsoleLoggerService } from '../../../logger/console-logger.service'; @Injectable() export class LoginEnabledGuard implements CanActivate { @@ -21,8 +21,11 @@ export class LoginEnabledGuard implements CanActivate { canActivate(): boolean { if (!this.authConfig.local.enableLogin) { - this.logger.debug('Local auth is disabled.', 'canActivate'); - throw new FeatureDisabledError('Local auth is disabled.'); + throw new FeatureDisabledError( + 'Local auth is disabled.', + this.logger.getContext(), + 'canActivate', + ); } return true; } diff --git a/backend/src/api/utils/registration-enabled.guard.ts b/backend/src/api/utils/guards/registration-enabled.guard.ts similarity index 54% rename from backend/src/api/utils/registration-enabled.guard.ts rename to backend/src/api/utils/guards/registration-enabled.guard.ts index 2158b4bbb..9fc5aae0e 100644 --- a/backend/src/api/utils/registration-enabled.guard.ts +++ b/backend/src/api/utils/guards/registration-enabled.guard.ts @@ -1,13 +1,13 @@ /* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ import { CanActivate, Inject, Injectable } from '@nestjs/common'; -import authConfiguration, { AuthConfig } from '../../config/auth.config'; -import { FeatureDisabledError } from '../../errors/errors'; -import { ConsoleLoggerService } from '../../logger/console-logger.service'; +import authConfiguration, { AuthConfig } from '../../../config/auth.config'; +import { FeatureDisabledError } from '../../../errors/errors'; +import { ConsoleLoggerService } from '../../../logger/console-logger.service'; @Injectable() export class RegistrationEnabledGuard implements CanActivate { @@ -21,8 +21,11 @@ export class RegistrationEnabledGuard implements CanActivate { canActivate(): boolean { if (!this.authConfig.local.enableRegister) { - this.logger.debug('User registration is disabled.', 'canActivate'); - throw new FeatureDisabledError('User registration is disabled'); + throw new FeatureDisabledError( + 'User registration is disabled', + this.logger.getContext(), + 'canActivate', + ); } return true; } diff --git a/backend/src/api/utils/get-note.interceptor.spec.ts b/backend/src/api/utils/interceptors/get-note-id.interceptor.spec.ts similarity index 73% rename from backend/src/api/utils/get-note.interceptor.spec.ts rename to backend/src/api/utils/interceptors/get-note-id.interceptor.spec.ts index 380be9218..16e3b194f 100644 --- a/backend/src/api/utils/get-note.interceptor.spec.ts +++ b/backend/src/api/utils/interceptors/get-note-id.interceptor.spec.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -8,9 +8,9 @@ import { HttpArgumentsHost } from '@nestjs/common/interfaces/features/arguments- import { Observable } from 'rxjs'; import { Mock } from 'ts-mockery'; -import { Note } from '../../notes/note.entity'; -import { NotesService } from '../../notes/notes.service'; -import { GetNoteInterceptor } from './get-note.interceptor'; +import { Note } from '../../database/types'; +import { NoteService } from '../../notes/note.service'; +import { GetNoteIdInterceptor } from './get-note-id.interceptor'; import { CompleteRequest } from './request.type'; describe('get note interceptor', () => { @@ -21,15 +21,15 @@ describe('get note interceptor', () => { handle: () => mockObservable, }); - let notesService: NotesService; + let notesService: NoteService; let noteFetchSpy: jest.SpyInstance; beforeEach(() => { - notesService = Mock.of({ - getNoteByIdOrAlias: (id) => + notesService = Mock.of({ + getNoteIdByAlias: (id) => id === mockNoteId ? Promise.resolve(mockNote) : Promise.reject(), }); - noteFetchSpy = jest.spyOn(notesService, 'getNoteByIdOrAlias'); + noteFetchSpy = jest.spyOn(notesService, 'getNoteIdByAlias'); }); function mockExecutionContext(request: CompleteRequest) { @@ -47,11 +47,11 @@ describe('get note interceptor', () => { headers: { ['hedgedoc-note']: mockNoteId }, }); const context = mockExecutionContext(request); - const sut: GetNoteInterceptor = new GetNoteInterceptor(notesService); + const sut: GetNoteIdInterceptor = new GetNoteIdInterceptor(notesService); const result = await sut.intercept(context, nextCallHandler); expect(result).toBe(mockObservable); - expect(request.note).toBe(mockNote); + expect(request.noteId).toBe(mockNote); expect(noteFetchSpy).toHaveBeenCalledTimes(1); }); @@ -60,11 +60,11 @@ describe('get note interceptor', () => { params: { noteIdOrAlias: mockNoteId }, }); const context = mockExecutionContext(request); - const sut: GetNoteInterceptor = new GetNoteInterceptor(notesService); + const sut: GetNoteIdInterceptor = new GetNoteIdInterceptor(notesService); const result = await sut.intercept(context, nextCallHandler); expect(result).toBe(mockObservable); - expect(request.note).toBe(mockNote); + expect(request.noteId).toBe(mockNote); expect(noteFetchSpy).toHaveBeenCalledTimes(1); }); @@ -75,11 +75,11 @@ describe('get note interceptor', () => { }); const context = mockExecutionContext(request); - const sut: GetNoteInterceptor = new GetNoteInterceptor(notesService); + const sut: GetNoteIdInterceptor = new GetNoteIdInterceptor(notesService); const result = await sut.intercept(context, nextCallHandler); expect(result).toBe(mockObservable); - expect(request.note).toBe(undefined); + expect(request.noteId).toBe(undefined); expect(noteFetchSpy).toHaveBeenCalledTimes(0); }); }); diff --git a/backend/src/api/utils/get-note.interceptor.ts b/backend/src/api/utils/interceptors/get-note-id.interceptor.ts similarity index 52% rename from backend/src/api/utils/get-note.interceptor.ts rename to backend/src/api/utils/interceptors/get-note-id.interceptor.ts index 389f68875..bb331e6da 100644 --- a/backend/src/api/utils/get-note.interceptor.ts +++ b/backend/src/api/utils/interceptors/get-note-id.interceptor.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -11,26 +11,26 @@ import { } from '@nestjs/common'; import { Observable } from 'rxjs'; -import { NotesService } from '../../notes/notes.service'; -import { extractNoteFromRequest } from './extract-note-from-request'; -import { CompleteRequest } from './request.type'; +import { NoteService } from '../../../notes/note.service'; +import { extractNoteIdFromRequest } from '../extract-note-id-from-request'; +import { CompleteRequest } from '../request.type'; /** * Saves the note identified by the `noteIdOrAlias` URL parameter * under the `note` property of the request object. */ @Injectable() -export class GetNoteInterceptor implements NestInterceptor { - constructor(private noteService: NotesService) {} +export class GetNoteIdInterceptor implements NestInterceptor { + constructor(private noteService: NoteService) {} async intercept( context: ExecutionContext, next: CallHandler, ): Promise> { const request: CompleteRequest = context.switchToHttp().getRequest(); - const note = await extractNoteFromRequest(request, this.noteService); - if (note !== undefined) { - request.note = note; + const noteId = await extractNoteIdFromRequest(request, this.noteService); + if (noteId !== undefined) { + request.noteId = noteId; } return next.handle(); } diff --git a/backend/src/api/utils/note-header.interceptor.ts b/backend/src/api/utils/interceptors/note-header.interceptor.ts similarity index 70% rename from backend/src/api/utils/note-header.interceptor.ts rename to backend/src/api/utils/interceptors/note-header.interceptor.ts index 3db590622..c87541306 100644 --- a/backend/src/api/utils/note-header.interceptor.ts +++ b/backend/src/api/utils/interceptors/note-header.interceptor.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -11,8 +11,8 @@ import { } from '@nestjs/common'; import { Observable } from 'rxjs'; -import { NotesService } from '../../notes/notes.service'; -import { CompleteRequest } from './request.type'; +import { NoteService } from '../../../notes/note.service'; +import { CompleteRequest } from '../request.type'; /** * Saves the note identified by the `HedgeDoc-Note` header @@ -20,7 +20,7 @@ import { CompleteRequest } from './request.type'; */ @Injectable() export class NoteHeaderInterceptor implements NestInterceptor { - constructor(private noteService: NotesService) {} + constructor(private noteService: NoteService) {} async intercept( context: ExecutionContext, @@ -28,7 +28,7 @@ export class NoteHeaderInterceptor implements NestInterceptor { ): Promise> { const request: CompleteRequest = context.switchToHttp().getRequest(); const noteId: string = request.headers['hedgedoc-note'] as string; - request.note = await this.noteService.getNoteByIdOrAlias(noteId); + request.noteId = await this.noteService.getNoteIdByAlias(noteId); return next.handle(); } } diff --git a/backend/src/api/utils/request.type.ts b/backend/src/api/utils/request.type.ts index 017227f59..b9374799b 100644 --- a/backend/src/api/utils/request.type.ts +++ b/backend/src/api/utils/request.type.ts @@ -1,16 +1,21 @@ /* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ +import { AuthProviderType } from '@hedgedoc/commons'; import { Request } from 'express'; +import { SessionState } from 'src/sessions/session-state.type'; -import { User } from '../../database/user.entity'; -import { Note } from '../../notes/note.entity'; -import { SessionState } from '../../sessions/session.service'; +import { FieldNameNote, FieldNameUser, Note, User } from '../../database/types'; export type CompleteRequest = Request & { - user?: User; - note?: Note; + userId?: User[FieldNameUser.id]; + authProviderType?: AuthProviderType; + noteId?: Note[FieldNameNote.id]; session?: SessionState; }; + +export type RequestWithSession = Request & { + session: SessionState; +}; diff --git a/backend/src/app-init.ts b/backend/src/app-init.ts index fd1f35347..282e41ac7 100644 --- a/backend/src/app-init.ts +++ b/backend/src/app-init.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -14,7 +14,6 @@ import { ErrorExceptionMapping } from './errors/error-mapping'; import { ConsoleLoggerService } from './logger/console-logger.service'; import { BackendType } from './media/backends/backend-type.enum'; import { SessionService } from './sessions/session.service'; -import { setupSpecialGroups } from './utils/createSpecialGroups'; import { setupSessionMiddleware } from './utils/session'; import { setupValidationPipe } from './utils/setup-pipes'; import { setupPrivateApiDocs, setupPublicApiDocs } from './utils/swagger'; @@ -29,12 +28,12 @@ export async function setupApp( mediaConfig: MediaConfig, logger: ConsoleLoggerService, ): Promise { + // Setup OpenAPI documentation await setupPublicApiDocs(app); logger.log( `Serving OpenAPI docs for public API under '/api/doc/v2'`, 'AppBootstrap', ); - if (process.env.NODE_ENV === 'development') { await setupPrivateApiDocs(app); logger.log( @@ -43,14 +42,14 @@ export async function setupApp( ); } - await setupSpecialGroups(app); - + // Setup session handling setupSessionMiddleware( app, authConfig, - app.get(SessionService).getTypeormStore(), + app.get(SessionService).getSessionStore(), ); + // Enable web security aspects app.enableCors({ origin: appConfig.rendererBaseUrl, }); @@ -58,9 +57,14 @@ export async function setupApp( `Enabling CORS for '${appConfig.rendererBaseUrl}'`, 'AppBootstrap', ); + // TODO Add rate limiting (#442) + // TODO Add CSP (#1309) + // TODO Add common security headers and CSRF (#201) + // Setup class-validator for incoming API request data app.useGlobalPipes(setupValidationPipe(logger)); + // Map URL paths to directories if (mediaConfig.backend.use === BackendType.FILESYSTEM) { logger.log( `Serving the local folder '${mediaConfig.backend.filesystem.uploadPath}' under '/uploads'`, @@ -70,7 +74,6 @@ export async function setupApp( prefix: '/uploads/', }); } - logger.log( `Serving the local folder 'public' under '/public'`, 'AppBootstrap', @@ -78,9 +81,14 @@ export async function setupApp( app.useStaticAssets('public', { prefix: '/public/', }); + // TODO Evaluate whether we really need this folder, + // only use-cases for now are intro.md and motd.md which could be API endpoints as well + // Configure WebSocket and error message handling const { httpAdapter } = app.get(HttpAdapterHost); - app.useGlobalFilters(new ErrorExceptionMapping(httpAdapter)); + app.useGlobalFilters(new ErrorExceptionMapping(logger, httpAdapter)); app.useWebSocketAdapter(new WsAdapter(app)); + + // Enable hooks on app shutdown, like saving notes into the database app.enableShutdownHooks(); } diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 1a8318a7d..3a2427d65 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -8,20 +8,20 @@ import { ConfigModule } from '@nestjs/config'; import { RouterModule, Routes } from '@nestjs/core'; import { EventEmitterModule } from '@nestjs/event-emitter'; import { ScheduleModule } from '@nestjs/schedule'; -import { KnexModule } from 'nestjs-knex'; +import { KnexModule } from 'nest-knexjs'; +import { AliasModule } from './alias/alias.module'; import { ApiTokenModule } from './api-token/api-token.module'; import { PrivateApiModule } from './api/private/private-api.module'; import { PublicApiModule } from './api/public/public-api.module'; import { AuthModule } from './auth/auth.module'; -import { AuthorsModule } from './authors/authors.module'; import appConfig from './config/app.config'; import authConfig from './config/auth.config'; import cspConfig from './config/csp.config'; import customizationConfig from './config/customization.config'; import databaseConfig, { - PostgresDatabaseConfig, getKnexConfig, + PostgresDatabaseConfig, } from './config/database.config'; import externalConfig from './config/external-services.config'; import mediaConfig from './config/media.config'; @@ -30,13 +30,11 @@ import { eventModuleConfig } from './events'; import { FrontendConfigModule } from './frontend-config/frontend-config.module'; import { FrontendConfigService } from './frontend-config/frontend-config.service'; import { GroupsModule } from './groups/groups.module'; -import { HistoryModule } from './history/history.module'; import { KnexLoggerService } from './logger/knex-logger.service'; import { LoggerModule } from './logger/logger.module'; import { MediaRedirectModule } from './media-redirect/media-redirect.module'; 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'; @@ -97,13 +95,11 @@ const routes: Routes = [ }), EventEmitterModule.forRoot(eventModuleConfig), ScheduleModule.forRoot(), - NotesModule, + AliasModule, UsersModule, RevisionsModule, - AuthorsModule, PublicApiModule, PrivateApiModule, - HistoryModule, MonitoringModule, PermissionsModule, GroupsModule, diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index fc0f012cf..e96345d3d 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -4,23 +4,17 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; +import { KnexModule } from 'nest-knexjs'; -import { User } from '../database/user.entity'; import { LoggerModule } from '../logger/logger.module'; import { UsersModule } from '../users/users.module'; -import { Identity } from './identity.entity'; import { IdentityService } from './identity.service'; import { LdapService } from './ldap/ldap.service'; import { LocalService } from './local/local.service'; import { OidcService } from './oidc/oidc.service'; @Module({ - imports: [ - TypeOrmModule.forFeature([Identity, User]), - UsersModule, - LoggerModule, - ], + imports: [UsersModule, LoggerModule, KnexModule], controllers: [], providers: [IdentityService, LdapService, LocalService, OidcService], exports: [IdentityService, LdapService, LocalService, OidcService], diff --git a/backend/src/auth/identity.service.ts b/backend/src/auth/identity.service.ts index ccf4aa6b8..b039722db 100644 --- a/backend/src/auth/identity.service.ts +++ b/backend/src/auth/identity.service.ts @@ -13,27 +13,33 @@ import { Injectable, InternalServerErrorException, } from '@nestjs/common'; -import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; -import { DataSource, Repository } from 'typeorm'; +import { Knex } from 'knex'; +import { InjectConnection } from 'nest-knexjs'; import AuthConfiguration, { AuthConfig } from '../config/auth.config'; -import { User } from '../database/user.entity'; +import { + FieldNameApiToken, + FieldNameIdentity, + FieldNameUser, + Identity, + TableIdentity, + User, +} from '../database/types'; import { NotInDBError } from '../errors/errors'; import { ConsoleLoggerService } from '../logger/console-logger.service'; import { UsersService } from '../users/users.service'; -import { Identity } from './identity.entity'; @Injectable() export class IdentityService { constructor( private readonly logger: ConsoleLoggerService, private usersService: UsersService, - @InjectDataSource() - private dataSource: DataSource, + @Inject(AuthConfiguration.KEY) private authConfig: AuthConfig, - @InjectRepository(Identity) - private identityRepository: Repository, + + @InjectConnection() + private readonly knex: Knex, ) { this.logger.setContext(IdentityService.name); } @@ -49,106 +55,148 @@ export class IdentityService { } /** - * @async - * Retrieve an identity by userId and providerType. - * @param {string} userId - the userId of the wanted identity - * @param {ProviderType} providerType - the providerType of the wanted identity - * @param {string} providerIdentifier - optional name of the provider if multiple exist + * Retrieve an identity from the information received from an auth provider. + * + * @param userId - the userId of the wanted identity + * @param authProviderType - the providerType of the wanted identity + * @param authProviderIdentifier - optional name of the provider if multiple exist + * @return */ async getIdentityFromUserIdAndProviderType( - userId: string, - providerType: ProviderType, - providerIdentifier?: string, + authProviderUserId: string, + authProviderType: ProviderType, + authProviderIdentifier: string | null, ): Promise { - const identity = await this.identityRepository.findOne({ - where: { - providerUserId: userId, - providerType, - providerIdentifier, - }, - relations: ['user'], - }); - if (identity === null) { - throw new NotInDBError(`Identity for user id '${userId}' not found`); + const identity = await this.knex(TableIdentity) + .select() + .where(FieldNameIdentity.providerUserId, authProviderUserId) + .andWhere(FieldNameIdentity.providerType, authProviderType) + .andWhere(FieldNameIdentity.providerIdentifier, authProviderIdentifier) + .first(); + if (identity === undefined) { + throw new NotInDBError( + `Identity for user with authProviderUserId '${authProviderUserId}' in provider ${authProviderType} ${authProviderIdentifier} not found`, + ); } return identity; } /** - * @async - * Create a new generic identity. - * @param {User} user - the user the identity should be added to - * @param {ProviderType} providerType - the providerType of the identity - * @param {string} providerIdentifier - the providerIdentifier of the identity - * @param {string} providerUserId - the userId the identity should have - * @return {Identity} the new local identity + * Creates a new generic identity. + * + * @param userId - the user the identity should be added to + * @param authProviderType - the providerType of the identity + * @param authProviderIdentifier - the providerIdentifier of the identity + * @param authProviderUserId - the userId the identity should have + * @param passwordHash - the password hash if the identiy uses that. + * @param transaction - the database transaction to use if any + * @return the new local identity */ async createIdentity( - user: User, - providerType: ProviderType, - providerIdentifier: string, - providerUserId: string, - ): Promise { - const identity = Identity.create(user, providerType, providerIdentifier); - identity.providerUserId = providerUserId; - return await this.identityRepository.save(identity); + userId: number, + authProviderType: ProviderType, + authProviderIdentifier: string | null, + authProviderUserId: string, + passwordHash?: string, + transaction?: Knex, + ): Promise { + const dbActor = transaction ?? this.knex; + const date = new Date(); + const identity: Identity = { + [FieldNameIdentity.userId]: userId, + [FieldNameIdentity.providerType]: authProviderType, + [FieldNameIdentity.providerIdentifier]: authProviderIdentifier, + [FieldNameIdentity.providerUserId]: authProviderUserId, + [FieldNameIdentity.passwordHash]: passwordHash ?? null, + [FieldNameIdentity.createdAt]: date, + [FieldNameIdentity.updatedAt]: date, + }; + await dbActor(TableIdentity).insert(identity); } /** - * Creates a new user with the given user data and the session data. + * Creates a new user with the given user data. * - * @param {FullUserInfoDto} sessionUserData The user data from the session - * @param {PendingUserConfirmationDto} updatedUserData The updated user data from the API - * @param {ProviderType} authProviderType The type of the auth provider - * @param {string} authProviderIdentifier The identifier of the auth provider - * @param {string} providerUserId The id of the user in the auth system + * @param authProviderType The type of the auth provider + * @param authProviderIdentifier The identifier of the auth provider + * @param authProviderUserId The id of the user in the auth system + * @param username The new username + * @param displayName The dispay name of the new user + * @param email The email address of the new user + * @param photoUrl The URL to the new user's profile picture + * @param passwordHash The optional password hash, only required for local identities + * @return The id of the newly created user */ async createUserWithIdentity( - sessionUserData: FullUserInfoDto, - updatedUserData: PendingUserConfirmationDto, authProviderType: ProviderType, - authProviderIdentifier: string, - providerUserId: string, - ): Promise { + authProviderIdentifier: string | null, + authProviderUserId: string, + username: string, + displayName: string, + email: string | null, + photoUrl: string | null, + passwordHash?: string, + ): Promise { + return await this.knex.transaction(async (transaction) => { + const userId = await this.usersService.createUser( + username, + displayName, + email, + photoUrl, + transaction, + ); + await this.createIdentity( + userId, + authProviderType, + authProviderIdentifier, + authProviderUserId, + passwordHash, + transaction, + ); + return userId; + }); + } + + /** + * Create a user with identity from pending user confirmation data. + * + * @param sessionUserData The data we got from the authProvider itself + * @param pendingUserConfirmationData The data the user entered while confirming their account + * @param authProviderType The type of the auth provider + * @param authProviderIdentifier The identifier of the auth provider + * @param authProviderUserId The id of the user in the auth system + * @return The id of the newly created user + */ + async createUserWithIdentityFromPendingUserConfirmation( + sessionUserData: FullUserInfoDto, + pendingUserConfirmationData: PendingUserConfirmationDto, + authProviderType: ProviderType, + authProviderIdentifier: string | null, + authProviderUserId: string, + ): Promise { const profileEditsAllowed = this.authConfig.common.allowProfileEdits; const chooseUsernameAllowed = this.authConfig.common.allowChooseUsername; - const username = ( - chooseUsernameAllowed - ? updatedUserData.username - : sessionUserData.username - ) as Lowercase; + const username = chooseUsernameAllowed + ? pendingUserConfirmationData.username + : sessionUserData.username; + const displayName = profileEditsAllowed - ? updatedUserData.displayName + ? pendingUserConfirmationData.displayName : sessionUserData.displayName; + const photoUrl = profileEditsAllowed - ? updatedUserData.profilePicture + ? pendingUserConfirmationData.profilePicture : sessionUserData.photoUrl; - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.startTransaction(); - try { - const user = await this.usersService.createUser( - username, - displayName, - sessionUserData.email, - photoUrl, - ); - const identity = await this.createIdentity( - user, - authProviderType, - authProviderIdentifier, - providerUserId, - ); - await queryRunner.commitTransaction(); - return identity; - } catch (error) { - this.logger.error( - 'Error during user creation:' + String(error), - 'createUserWithIdentity', - ); - await queryRunner.rollbackTransaction(); - throw new InternalServerErrorException(); - } + return await this.createUserWithIdentity( + authProviderType, + authProviderIdentifier, + authProviderUserId, + username, + displayName, + sessionUserData.email, + photoUrl, + ); } } diff --git a/backend/src/auth/local/local.service.ts b/backend/src/auth/local/local.service.ts index c186023e9..d3e4f3505 100644 --- a/backend/src/auth/local/local.service.ts +++ b/backend/src/auth/local/local.service.ts @@ -5,7 +5,6 @@ */ import { ProviderType } from '@hedgedoc/commons'; import { Inject, Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; import { OptionsGraph, OptionsType, @@ -20,10 +19,16 @@ import { dictionary as zxcvbnEnDictionary, translations as zxcvbnEnTranslations, } from '@zxcvbn-ts/language-en'; -import { Repository } from 'typeorm'; +import { Knex } from 'knex'; +import { InjectConnection } from 'nest-knexjs'; import authConfiguration, { AuthConfig } from '../../config/auth.config'; -import { User } from '../../database/user.entity'; +import { + FieldNameIdentity, + Identity, + TableIdentity, + User, +} from '../../database/types'; import { InvalidCredentialsError, NoLocalIdentityError, @@ -31,7 +36,6 @@ import { } from '../../errors/errors'; import { ConsoleLoggerService } from '../../logger/console-logger.service'; import { checkPassword, hashPassword } from '../../utils/password'; -import { Identity } from '../identity.entity'; import { IdentityService } from '../identity.service'; @Injectable() @@ -39,8 +43,10 @@ export class LocalService { constructor( private readonly logger: ConsoleLoggerService, private identityService: IdentityService, - @InjectRepository(Identity) - private identityRepository: Repository, + + @InjectConnection() + private readonly knex: Knex, + @Inject(authConfiguration.KEY) private authConfig: AuthConfig, ) { @@ -57,76 +63,83 @@ export class LocalService { } /** - * @async * Create a new identity for internal auth - * @param {User} user - the user the identity should be added to + * + * @param userId - the user the identity should be added to * @param {string} password - the password the identity should have * @return {Identity} the new local identity */ - async createLocalIdentity(user: User, password: string): Promise { - const identity = Identity.create(user, ProviderType.LOCAL, null); - identity.passwordHash = await hashPassword(password); - identity.providerUserId = user.username; - return await this.identityRepository.save(identity); + async createLocalIdentity( + username: string, + password: string, + displayName: string, + ): Promise { + const passwordHash = await hashPassword(password); + return await this.identityService.createUserWithIdentity( + ProviderType.LOCAL, + null, + username, + username, + displayName, + null, + null, + passwordHash, + ); } /** * @async * Update the internal password of the specified the user - * @param {User} user - the user, which identity should be updated + * @param {User} userId - the user, which identity should be updated * @param {string} newPassword - the new password * @throws {NoLocalIdentityError} the specified user has no internal identity * @return {Identity} the changed identity */ async updateLocalPassword( - user: User, + userId: number, newPassword: string, - ): Promise { - const internalIdentity: Identity | undefined = - await this.identityService.getIdentityFromUserIdAndProviderType( - user.username, - ProviderType.LOCAL, - ); - if (internalIdentity === undefined) { - this.logger.debug( - `The user with the username ${user.username} does not have a internal identity.`, - 'updateLocalPassword', - ); - throw new NoLocalIdentityError('This user has no internal identity.'); - } + ): Promise { await this.checkPasswordStrength(newPassword); - internalIdentity.passwordHash = await hashPassword(newPassword); - return await this.identityRepository.save(internalIdentity); + const newPasswordHash = await hashPassword(newPassword); + await this.knex(TableIdentity) + .update({ + [FieldNameIdentity.passwordHash]: newPasswordHash, + }) + .where(FieldNameIdentity.providerType, ProviderType.LOCAL) + .andWhere(FieldNameIdentity.userId, userId); } /** * @async * Checks if the user and password combination matches - * @param {User} user - the user to use + * @param {string} username - the user to use * @param {string} password - the password to use * @throws {InvalidCredentialsError} the password and user do not match * @throws {NoLocalIdentityError} the specified user has no internal identity */ - async checkLocalPassword(user: User, password: string): Promise { - const internalIdentity: Identity | undefined = + async checkLocalPassword( + username: string, + password: string, + ): Promise { + const identity = await this.identityService.getIdentityFromUserIdAndProviderType( - user.username, + username, ProviderType.LOCAL, + null, ); - if (internalIdentity === undefined) { - this.logger.debug( - `The user with the username ${user.username} does not have an internal identity.`, + if ( + !(await checkPassword( + password, + identity[FieldNameIdentity.passwordHash] ?? '', + )) + ) { + throw new InvalidCredentialsError( + 'Username or password is not correct', + this.logger.getContext(), 'checkLocalPassword', ); - throw new NoLocalIdentityError('This user has no internal identity.'); - } - if (!(await checkPassword(password, internalIdentity.passwordHash ?? ''))) { - this.logger.debug( - `Password check for ${user.username} did not succeed.`, - 'checkLocalPassword', - ); - throw new InvalidCredentialsError('Password is not correct'); } + return identity; } /** diff --git a/backend/src/auth/oidc/oidc.service.ts b/backend/src/auth/oidc/oidc.service.ts index 5d3eb4903..85a4db876 100644 --- a/backend/src/auth/oidc/oidc.service.ts +++ b/backend/src/auth/oidc/oidc.service.ts @@ -19,9 +19,9 @@ import authConfiguration, { AuthConfig, OidcConfig, } from '../../config/auth.config'; +import { Identity } from '../../database/types'; import { NotInDBError } from '../../errors/errors'; import { ConsoleLoggerService } from '../../logger/console-logger.service'; -import { Identity } from '../identity.entity'; import { IdentityService } from '../identity.service'; import { RequestWithSession } from '../session.guard'; @@ -169,12 +169,12 @@ export class OidcService { * * @param {string} oidcIdentifier The identifier of the OIDC configuration * @param {RequestWithSession} request The request containing the session - * @returns {FullUserInfoDto} The user information extracted from the callback + * @returns {OwnUserInfoDto} The user information extracted from the callback */ async extractUserInfoFromCallback( oidcIdentifier: string, request: RequestWithSession, - ): Promise { + ): Promise { const clientConfig = this.clientConfigs.get(oidcIdentifier); if (!clientConfig) { throw new NotFoundException( diff --git a/backend/src/auth/session.guard.ts b/backend/src/auth/session.guard.ts index b366e01ba..9bdde4084 100644 --- a/backend/src/auth/session.guard.ts +++ b/backend/src/auth/session.guard.ts @@ -3,71 +3,40 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { ProviderType } from '@hedgedoc/commons'; -import { GuestAccess } from '@hedgedoc/commons'; import { CanActivate, ExecutionContext, - Inject, Injectable, UnauthorizedException, } from '@nestjs/common'; -import { Request } from 'express'; import { CompleteRequest } from '../api/utils/request.type'; -import noteConfiguration, { NoteConfig } from '../config/note.config'; -import { NotInDBError } from '../errors/errors'; import { ConsoleLoggerService } from '../logger/console-logger.service'; -import { SessionState } from '../sessions/session.service'; -import { UsersService } from '../users/users.service'; - -export type RequestWithSession = Request & { - session: SessionState; -}; /** * This guard checks if a session is present. * * If there is a username in `request.session.username` it will try to get this user from the database and put it into `request.user`. See {@link RequestUser}. - * If there is no `request.session.username`, but any GuestAccess is configured, `request.session.authProvider` is set to `guest` to indicate a guest user. + * If there is no `request.session.username`, but any PermissionLevel is configured, `request.session.authProvider` is set to `guest` to indicate a guest user. * * @throws UnauthorizedException */ @Injectable() export class SessionGuard implements CanActivate { - constructor( - private readonly logger: ConsoleLoggerService, - private userService: UsersService, - @Inject(noteConfiguration.KEY) - private noteConfig: NoteConfig, - ) { + constructor(private readonly logger: ConsoleLoggerService) { this.logger.setContext(SessionGuard.name); } - async canActivate(context: ExecutionContext): Promise { + canActivate(context: ExecutionContext): boolean { const request: CompleteRequest = context.switchToHttp().getRequest(); - const username = request.session?.username; - if (!username) { - if (this.noteConfig.guestAccess !== GuestAccess.DENY && request.session) { - if (!request.session.authProviderType) { - request.session.authProviderType = ProviderType.GUEST; - } - return true; - } + const userId = request.session?.userId; + const authProviderType = request.session?.authProviderType; + if (!userId || !authProviderType) { this.logger.debug('The user has no session.'); throw new UnauthorizedException("You're not logged in"); } - try { - request.user = await this.userService.getUserByUsername(username); - return true; - } catch (e) { - if (e instanceof NotInDBError) { - this.logger.debug( - `The user '${username}' does not exist, but has a session.`, - ); - throw new UnauthorizedException("You're not logged in"); - } - throw e; - } + request.userId = userId; + request.authProviderType = authProviderType; + return true; } } diff --git a/backend/src/authors/authors.module.ts b/backend/src/authors/authors.module.ts deleted file mode 100644 index 06c09ba7a..000000000 --- a/backend/src/authors/authors.module.ts +++ /dev/null @@ -1,14 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; - -import { Author } from './author.entity'; - -@Module({ - imports: [TypeOrmModule.forFeature([Author])], -}) -export class AuthorsModule {} diff --git a/backend/src/config/mock/note.config.mock.ts b/backend/src/config/mock/note.config.mock.ts index e0e3e0e65..ab440ce89 100644 --- a/backend/src/config/mock/note.config.mock.ts +++ b/backend/src/config/mock/note.config.mock.ts @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { GuestAccess } from '@hedgedoc/commons'; +import { PermissionLevel } from '@hedgedoc/commons'; import { ConfigFactoryKeyHost, registerAs } from '@nestjs/config'; import { ConfigFactory } from '@nestjs/config/dist/interfaces'; @@ -20,7 +20,7 @@ export function createDefaultMockNoteConfig(): NoteConfig { loggedIn: DefaultAccessLevel.WRITE, }, }, - guestAccess: GuestAccess.CREATE, + guestAccess: PermissionLevel.CREATE, revisionRetentionDays: 0, }; } diff --git a/backend/src/config/note.config.spec.ts b/backend/src/config/note.config.spec.ts index 58b75d712..9e0511bcc 100644 --- a/backend/src/config/note.config.spec.ts +++ b/backend/src/config/note.config.spec.ts @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { GuestAccess } from '@hedgedoc/commons'; +import { PermissionLevel } from '@hedgedoc/commons'; import mockedEnv from 'mocked-env'; import { DefaultAccessLevel } from './default-access-level.enum'; @@ -17,7 +17,7 @@ describe('noteConfig', () => { const negativeMaxDocumentLength = -123; const floatMaxDocumentLength = 2.71; const invalidMaxDocumentLength = 'not-a-max-document-length'; - const guestAccess = GuestAccess.CREATE; + const guestAccess = PermissionLevel.CREATE; const wrongDefaultPermission = 'wrong'; const retentionDays = 30; @@ -221,7 +221,7 @@ describe('noteConfig', () => { DefaultAccessLevel.WRITE, ); - expect(config.guestAccess).toEqual(GuestAccess.WRITE); + expect(config.guestAccess).toEqual(PermissionLevel.WRITE); restore(); }); diff --git a/backend/src/config/note.config.ts b/backend/src/config/note.config.ts index 9be7eb3a4..ff9e79fe3 100644 --- a/backend/src/config/note.config.ts +++ b/backend/src/config/note.config.ts @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { GuestAccess } from '@hedgedoc/commons'; +import { PermissionLevel } from '@hedgedoc/commons'; import { registerAs } from '@nestjs/config'; import z from 'zod'; @@ -31,9 +31,9 @@ const schema = z.object({ .default(100000) .describe('HD_MAX_DOCUMENT_LENGTH'), guestAccess: z - .nativeEnum(GuestAccess) + .nativeEnum(PermissionLevel) .optional() - .default(GuestAccess.WRITE) + .default(PermissionLevel.WRITE) .describe('HD_GUEST_ACCESS'), permissions: z.object({ default: z.object({ @@ -63,7 +63,7 @@ export type NoteConfig = z.infer; function checkEveryoneConfigIsConsistent(config: NoteConfig): void { const everyoneDefaultSet = process.env.HD_PERMISSIONS_DEFAULT_EVERYONE !== undefined; - if (config.guestAccess === GuestAccess.DENY && everyoneDefaultSet) { + if (config.guestAccess === PermissionLevel.DENY && everyoneDefaultSet) { throw new Error( `'HD_GUEST_ACCESS' is set to '${config.guestAccess}', but 'HD_PERMISSIONS_DEFAULT_EVERYONE' is also configured. Please remove 'HD_PERMISSIONS_DEFAULT_EVERYONE'.`, ); diff --git a/backend/src/database/migrations/20250312211152_initial.ts b/backend/src/database/migrations/20250312211152_initial.ts index fbae668ee..7d846164c 100644 --- a/backend/src/database/migrations/20250312211152_initial.ts +++ b/backend/src/database/migrations/20250312211152_initial.ts @@ -3,10 +3,9 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { NoteType } from '@hedgedoc/commons'; +import { AuthProviderType, NoteType } from '@hedgedoc/commons'; import type { Knex } from 'knex'; -import { ProviderType } from '../../auth/provider-type.enum'; import { SpecialGroup } from '../../groups/groups.special'; import { BackendType } from '../../media/backends/backend-type.enum'; import { @@ -45,12 +44,14 @@ export async function up(knex: Knex): Promise { await knex.schema.createTable(TableUser, (table) => { table.increments(FieldNameUser.id).primary(); table.string(FieldNameUser.username).nullable().unique(); - table.string(FieldNameUser.displayName).nullable(); + table.string(FieldNameUser.displayName).notNullable(); table.string(FieldNameUser.photoUrl).nullable(); table.string(FieldNameUser.email).nullable(); table.integer(FieldNameUser.authorStyle).notNullable(); table.uuid(FieldNameUser.guestUuid).nullable().unique(); - table.timestamp(FieldNameUser.createdAt).defaultTo(knex.fn.now()); + table + .timestamp(FieldNameUser.createdAt, { useTz: true }) + .defaultTo(knex.fn.now()); }); // Create group table @@ -79,7 +80,9 @@ export async function up(knex: Knex): Promise { await knex.schema.createTable(TableNote, (table) => { table.increments(FieldNameNote.id).primary(); table.integer(FieldNameNote.version).notNullable().defaultTo(2); - table.timestamp(FieldNameNote.createdAt).defaultTo(knex.fn.now()); + table + .timestamp(FieldNameNote.createdAt, { useTz: true }) + .defaultTo(knex.fn.now()); table .integer(FieldNameNote.ownerId) .unsigned() @@ -88,10 +91,10 @@ export async function up(knex: Knex): Promise { .inTable(TableUser); }); - // Create alias table + // Create aliases table await knex.schema.createTable(TableAlias, (table) => { table.comment( - 'Stores aliases of notes, only on alias per note can be is_primary == true, all other need to have is_primary == null ', + 'Stores aliases of notes, only on aliases per note can be is_primary == true, all other need to have is_primary == null ', ); table.string(FieldNameAlias.alias).primary(); table @@ -118,8 +121,11 @@ export async function up(knex: Knex): Promise { .inTable(TableUser); table.string(FieldNameApiToken.label).notNullable(); table.string(FieldNameApiToken.secretHash).notNullable(); - table.timestamp(FieldNameApiToken.validUntil).notNullable(); - table.timestamp(FieldNameApiToken.lastUsedAt).nullable(); + table + .timestamp(FieldNameApiToken.validUntil, { useTz: true }) + .notNullable(); + table.timestamp(FieldNameApiToken.lastUsedAt, { useTz: true }).nullable(); + table.timestamp(FieldNameApiToken.createdAt, { useTz: true }).notNullable(); }); // Create identity table @@ -132,7 +138,7 @@ export async function up(knex: Knex): Promise { .inTable(TableUser); table.enu( FieldNameIdentity.providerType, - [ProviderType.LDAP, ProviderType.LOCAL, ProviderType.OIDC], // ProviderType.GUEST is not relevant for the DB + [AuthProviderType.LDAP, AuthProviderType.LOCAL, AuthProviderType.OIDC], // ProviderType.GUEST is not relevant for the DB { useNative: true, enumName: FieldNameIdentity.providerType, @@ -141,8 +147,12 @@ export async function up(knex: Knex): Promise { table.string(FieldNameIdentity.providerIdentifier).nullable(); table.string(FieldNameIdentity.providerUserId).nullable(); table.string(FieldNameIdentity.passwordHash).nullable(); - table.timestamp(FieldNameIdentity.createdAt).defaultTo(knex.fn.now()); - table.timestamp(FieldNameIdentity.updatedAt).defaultTo(knex.fn.now()); + table + .timestamp(FieldNameIdentity.createdAt, { useTz: true }) + .defaultTo(knex.fn.now()); + table + .timestamp(FieldNameIdentity.updatedAt, { useTz: true }) + .defaultTo(knex.fn.now()); table.unique( [ FieldNameIdentity.userId, @@ -175,7 +185,7 @@ export async function up(knex: Knex): Promise { // Create revision table await knex.schema.createTable(TableRevision, (table) => { - table.increments(FieldNameRevision.id).primary(); + table.uuid(FieldNameRevision.uuid).primary(); table .integer(FieldNameRevision.noteId) .unsigned() @@ -191,34 +201,42 @@ export async function up(knex: Knex): Promise { useNative: true, enumName: FieldNameRevision.noteType, }); - table.timestamp(FieldNameRevision.createdAt).defaultTo(knex.fn.now()); + table + .timestamp(FieldNameRevision.createdAt, { useTz: true }) + .defaultTo(knex.fn.now()); }); // Create revision_tag table await knex.schema.createTable(TableRevisionTag, (table) => { table - .integer(FieldNameRevisionTag.revisionId) + .uuid(FieldNameRevisionTag.revisionUuid) .unsigned() .notNullable() - .references(FieldNameRevision.id) + .references(FieldNameRevision.uuid) + .onDelete('CASCADE') .inTable(TableRevision); table.string(FieldNameRevisionTag.tag).notNullable(); - table.primary([FieldNameRevisionTag.revisionId, FieldNameRevisionTag.tag]); + table.primary([ + FieldNameRevisionTag.revisionUuid, + FieldNameRevisionTag.tag, + ]); }); // Create authorship_info table await knex.schema.createTable(TableAuthorshipInfo, (table) => { table - .integer(FieldNameAuthorshipInfo.revisionId) + .uuid(FieldNameAuthorshipInfo.revisionUuid) .unsigned() .notNullable() - .references(FieldNameRevision.id) + .references(FieldNameRevision.uuid) + .onDelete('CASCADE') .inTable(TableRevision); table .integer(FieldNameAuthorshipInfo.authorId) .unsigned() .notNullable() .references(FieldNameUser.id) + .onDelete('CASCADE') .inTable(TableUser); table .integer(FieldNameAuthorshipInfo.startPosition) @@ -234,12 +252,14 @@ export async function up(knex: Knex): Promise { .unsigned() .notNullable() .references(FieldNameNote.id) + .onDelete('CASCADE') .inTable(TableNote); table .integer(FieldNameNoteUserPermission.userId) .unsigned() .notNullable() .references(FieldNameUser.id) + .onDelete('CASCADE') .inTable(TableUser); table .boolean(FieldNameNoteUserPermission.canEdit) @@ -258,12 +278,14 @@ export async function up(knex: Knex): Promise { .unsigned() .notNullable() .references(FieldNameNote.id) + .onDelete('CASCADE') .inTable(TableNote); table .integer(FieldNameNoteGroupPermission.groupId) .unsigned() .notNullable() .references(FieldNameGroup.id) + .onDelete('CASCADE') .inTable(TableGroup); table .boolean(FieldNameNoteGroupPermission.canEdit) @@ -308,7 +330,9 @@ export async function up(knex: Knex): Promise { ) .notNullable(); table.text(FieldNameMediaUpload.backendData).nullable(); - table.timestamp(FieldNameMediaUpload.createdAt).defaultTo(knex.fn.now()); + table + .timestamp(FieldNameMediaUpload.createdAt, { useTz: true }) + .defaultTo(knex.fn.now()); }); // Create user_pinned_note table diff --git a/backend/src/database/seeds/03_note.ts b/backend/src/database/seeds/03_note.ts index 28074b357..8e4a4e6b7 100644 --- a/backend/src/database/seeds/03_note.ts +++ b/backend/src/database/seeds/03_note.ts @@ -36,6 +36,10 @@ export async function seed(knex: Knex): Promise { await knex(TableNoteGroupPermission).del(); await knex(TableNoteUserPermission).del(); + const guestNoteRevisionUuid = '0196a6e7-9669-7ef3-9c10-520734c61593'; + const userNoteRevisionUuid = '0196a6e8-f63e-7473-bf58-ea97e937fde2'; + const userSlideRevisionUuid = '0196a6e9-1152-7940-a531-01b9527321c0'; + const guestNoteAlias = 'guest-note'; const userNoteAlias = 'user-note'; const userSlideAlias = 'user-slide'; @@ -94,6 +98,7 @@ export async function seed(knex: Knex): Promise { ]); await knex(TableRevision).insert([ { + [FieldNameRevision.uuid]: guestNoteRevisionUuid, [FieldNameRevision.noteId]: 1, [FieldNameRevision.patch]: createPatch( guestNoteAlias, @@ -107,6 +112,7 @@ export async function seed(knex: Knex): Promise { [FieldNameRevision.description]: guestNoteDescription, }, { + [FieldNameRevision.uuid]: userNoteRevisionUuid, [FieldNameRevision.noteId]: 1, [FieldNameRevision.patch]: createPatch( userNoteAlias, @@ -120,6 +126,7 @@ export async function seed(knex: Knex): Promise { [FieldNameRevision.description]: userNoteDescription, }, { + [FieldNameRevision.uuid]: userSlideRevisionUuid, [FieldNameRevision.noteId]: 1, [FieldNameRevision.patch]: createPatch( userSlideAlias, @@ -135,33 +142,33 @@ export async function seed(knex: Knex): Promise { ]); await knex(TableRevisionTag).insert([ ...guestNoteTags.map((tag) => ({ - [FieldNameRevisionTag.revisionId]: 1, + [FieldNameRevisionTag.revisionUuid]: guestNoteRevisionUuid, [FieldNameRevisionTag.tag]: tag, })), ...userNoteTags.map((tag) => ({ - [FieldNameRevisionTag.revisionId]: 2, + [FieldNameRevisionTag.revisionUuid]: userNoteRevisionUuid, [FieldNameRevisionTag.tag]: tag, })), ...userSlideTags.map((tag) => ({ - [FieldNameRevisionTag.revisionId]: 3, + [FieldNameRevisionTag.revisionUuid]: userSlideRevisionUuid, [FieldNameRevisionTag.tag]: tag, })), ]); await knex(TableAuthorshipInfo).insert([ { - [FieldNameAuthorshipInfo.revisionId]: 1, + [FieldNameAuthorshipInfo.revisionUuid]: guestNoteRevisionUuid, [FieldNameAuthorshipInfo.authorId]: 1, [FieldNameAuthorshipInfo.startPosition]: 0, [FieldNameAuthorshipInfo.endPosition]: guestNoteContent.length, }, { - [FieldNameAuthorshipInfo.revisionId]: 2, + [FieldNameAuthorshipInfo.revisionUuid]: userNoteRevisionUuid, [FieldNameAuthorshipInfo.authorId]: 2, [FieldNameAuthorshipInfo.startPosition]: 0, [FieldNameAuthorshipInfo.endPosition]: userNoteContent.length, }, { - [FieldNameAuthorshipInfo.revisionId]: 3, + [FieldNameAuthorshipInfo.revisionUuid]: userSlideRevisionUuid, [FieldNameAuthorshipInfo.authorId]: 2, [FieldNameAuthorshipInfo.startPosition]: 0, [FieldNameAuthorshipInfo.endPosition]: userSlideContent.length, diff --git a/backend/src/database/types/alias.ts b/backend/src/database/types/alias.ts index 1091a1d6d..b364f5c62 100644 --- a/backend/src/database/types/alias.ts +++ b/backend/src/database/types/alias.ts @@ -29,4 +29,5 @@ export enum FieldNameAlias { export const TableAlias = 'alias'; +export type TypeInsertAlias = Alias; export type TypeUpdateAlias = Pick; diff --git a/backend/src/database/types/api-token.ts b/backend/src/database/types/api-token.ts index 72ff8784d..a84f69e9e 100644 --- a/backend/src/database/types/api-token.ts +++ b/backend/src/database/types/api-token.ts @@ -25,6 +25,9 @@ export interface ApiToken { /** Expiry date of the token */ [FieldNameApiToken.validUntil]: Date; + /** Date when the API token was created */ + [FieldNameApiToken.createdAt]: Date; + /** When the token was last used. When it was never used yet, this field is null */ [FieldNameApiToken.lastUsedAt]: Date | null; } @@ -35,6 +38,7 @@ export enum FieldNameApiToken { label = 'label', secretHash = 'secret_hash', validUntil = 'valid_until', + createdAt = 'created_at', lastUsedAt = 'last_used_at', } diff --git a/backend/src/database/types/authorship-info.ts b/backend/src/database/types/authorship-info.ts index 024932270..5459b48c2 100644 --- a/backend/src/database/types/authorship-info.ts +++ b/backend/src/database/types/authorship-info.ts @@ -11,7 +11,7 @@ */ export interface AuthorshipInfo { /** The id of the {@link Revision} this belongs to. */ - [FieldNameAuthorshipInfo.revisionId]: number; + [FieldNameAuthorshipInfo.revisionUuid]: string; /** The id of the author of the edit. */ [FieldNameAuthorshipInfo.authorId]: number; @@ -24,7 +24,7 @@ export interface AuthorshipInfo { } export enum FieldNameAuthorshipInfo { - revisionId = 'revision_id', + revisionUuid = 'revision_id', authorId = 'author_id', startPosition = 'start_position', endPosition = 'end_position', diff --git a/backend/src/database/types/knex.types.ts b/backend/src/database/types/knex.types.ts index 89b75137e..6a73e9ea4 100644 --- a/backend/src/database/types/knex.types.ts +++ b/backend/src/database/types/knex.types.ts @@ -5,7 +5,7 @@ */ import { Knex } from 'knex'; -import { Alias, TypeUpdateAlias } from './alias'; +import { Alias, TypeInsertAlias, TypeUpdateAlias } from './alias'; import { ApiToken, TypeInsertApiToken, TypeUpdateApiToken } from './api-token'; import { Group, TypeInsertGroup, TypeUpdateGroup } from './group'; import { Identity, TypeInsertIdentity, TypeUpdateIdentity } from './identity'; @@ -49,7 +49,11 @@ import { TypeInsertUser, TypeUpdateUser, User } from './user'; /* eslint-disable @typescript-eslint/naming-convention */ declare module 'knex/types/tables.js' { interface Tables { - [TableAlias]: Knex.CompositeTableType; + [TableAlias]: Knex.CompositeTableType< + Alias, + TypeInsertAlias, + TypeUpdateAlias + >; [TableApiToken]: Knex.CompositeTableType< ApiToken, TypeInsertApiToken, diff --git a/backend/src/database/types/revision-tag.ts b/backend/src/database/types/revision-tag.ts index 4d3525626..f7da2afcd 100644 --- a/backend/src/database/types/revision-tag.ts +++ b/backend/src/database/types/revision-tag.ts @@ -8,14 +8,14 @@ */ export interface RevisionTag { /** The id of {@link Revision} the {@link RevisionTag Tags} are asspcoated with. */ - [FieldNameRevisionTag.revisionId]: number; + [FieldNameRevisionTag.revisionUuid]: string; /** The {@link RevisionTag Tag} text. */ [FieldNameRevisionTag.tag]: string; } export enum FieldNameRevisionTag { - revisionId = 'revision_id', + revisionUuid = 'revision_id', tag = 'tag', } diff --git a/backend/src/database/types/revision.ts b/backend/src/database/types/revision.ts index 9e56af0b3..85c4c016c 100644 --- a/backend/src/database/types/revision.ts +++ b/backend/src/database/types/revision.ts @@ -10,7 +10,7 @@ import { NoteType } from '@hedgedoc/commons'; */ export interface Revision { /** The unique id of the revision for internal referencing */ - [FieldNameRevision.id]: number; + [FieldNameRevision.uuid]: string; /** The id of the note that this revision belongs to */ [FieldNameRevision.noteId]: number; @@ -38,7 +38,7 @@ export interface Revision { } export enum FieldNameRevision { - id = 'id', + uuid = 'uuid', noteId = 'note_id', patch = 'patch', content = 'content', @@ -51,7 +51,4 @@ export enum FieldNameRevision { export const TableRevision = 'revision'; -export type TypeInsertRevision = Omit< - Revision, - FieldNameRevision.createdAt | FieldNameRevision.id ->; +export type TypeInsertRevision = Omit; diff --git a/backend/src/database/types/user.ts b/backend/src/database/types/user.ts index d545071f8..eccc2792f 100644 --- a/backend/src/database/types/user.ts +++ b/backend/src/database/types/user.ts @@ -3,7 +3,6 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import type { Username } from '../../utils/username'; /** * The user object represents either a registered user in the instance or a guest user. @@ -21,13 +20,13 @@ export interface User { [FieldNameUser.id]: number; /** The user's chosen username or null if it is a guest user */ - [FieldNameUser.username]: Username | null; + [FieldNameUser.username]: string | null; /** The guest user's UUID or null if it is a registered user */ [FieldNameUser.guestUuid]: string | null; /** The user's chosen display name */ - [FieldNameUser.displayName]: string | null; + [FieldNameUser.displayName]: string; /** Timestamp when the user was created */ [FieldNameUser.createdAt]: Date; diff --git a/backend/src/errors/error-mapping.ts b/backend/src/errors/error-mapping.ts index d3ab64425..89abf84d1 100644 --- a/backend/src/errors/error-mapping.ts +++ b/backend/src/errors/error-mapping.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -9,6 +9,7 @@ import { Catch, ConflictException, ForbiddenException, + HttpServer, InternalServerErrorException, NotFoundException, PayloadTooLargeException, @@ -17,6 +18,8 @@ import { import { HttpException } from '@nestjs/common/exceptions/http.exception'; import { BaseExceptionFilter } from '@nestjs/core'; +import { ConsoleLoggerService } from '../logger/console-logger.service'; +import { ErrorWithContextDetails } from './errors'; import { buildHttpExceptionObject, HttpExceptionObject, @@ -84,14 +87,28 @@ const mapOfHedgeDocErrorsToHttpErrors: Map = @Catch() export class ErrorExceptionMapping extends BaseExceptionFilter { - catch(error: Error, host: ArgumentsHost): void { - super.catch(ErrorExceptionMapping.transformError(error), host); + private readonly loggerService: ConsoleLoggerService; + constructor(logger: ConsoleLoggerService, applicationRef?: HttpServer) { + super(applicationRef); + this.loggerService = logger; } - private static transformError(error: Error): Error { + catch(error: Error, host: ArgumentsHost): void { + super.catch(this.transformError(error), host); + } + + private transformError(error: Error): Error { const httpExceptionConstructor = mapOfHedgeDocErrorsToHttpErrors.get( error.name, ); + if (error instanceof ErrorWithContextDetails) { + this.loggerService.error( + error.message, + undefined, + error.functionContext, + error.classContext, + ); + } if (httpExceptionConstructor === undefined) { // We don't know how to map this error and just leave it be return error; diff --git a/backend/src/errors/errors.ts b/backend/src/errors/errors.ts index 7e2335c97..3ecd8976a 100644 --- a/backend/src/errors/errors.ts +++ b/backend/src/errors/errors.ts @@ -1,65 +1,79 @@ /* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ -export class NotInDBError extends Error { +export class ErrorWithContextDetails extends Error { + constructor( + message?: string, + public readonly classContext?: string, + public readonly functionContext?: string, + ) { + super(message); + } +} + +export class NotInDBError extends ErrorWithContextDetails { name = 'NotInDBError'; } -export class AlreadyInDBError extends Error { +export class AlreadyInDBError extends ErrorWithContextDetails { name = 'AlreadyInDBError'; } -export class ForbiddenIdError extends Error { +export class GenericDBError extends ErrorWithContextDetails { + name = 'GenericDBError'; +} + +export class ForbiddenIdError extends ErrorWithContextDetails { name = 'ForbiddenIdError'; } -export class ClientError extends Error { +export class ClientError extends ErrorWithContextDetails { name = 'ClientError'; } -export class PermissionError extends Error { +export class PermissionError extends ErrorWithContextDetails { name = 'PermissionError'; } -export class TokenNotValidError extends Error { +export class TokenNotValidError extends ErrorWithContextDetails { name = 'TokenNotValidError'; } -export class TooManyTokensError extends Error { +export class TooManyTokensError extends ErrorWithContextDetails { name = 'TooManyTokensError'; } -export class PermissionsUpdateInconsistentError extends Error { +export class PermissionsUpdateInconsistentError extends ErrorWithContextDetails { name = 'PermissionsUpdateInconsistentError'; } -export class MediaBackendError extends Error { +export class MediaBackendError extends ErrorWithContextDetails { name = 'MediaBackendError'; } -export class PrimaryAliasDeletionForbiddenError extends Error { +export class PrimaryAliasDeletionForbiddenError extends ErrorWithContextDetails { name = 'PrimaryAliasDeletionForbiddenError'; } -export class InvalidCredentialsError extends Error { +export class InvalidCredentialsError extends ErrorWithContextDetails { name = 'InvalidCredentialsError'; } -export class NoLocalIdentityError extends Error { +export class NoLocalIdentityError extends ErrorWithContextDetails { name = 'NoLocalIdentityError'; } -export class PasswordTooWeakError extends Error { +export class PasswordTooWeakError extends ErrorWithContextDetails { name = 'PasswordTooWeakError'; } -export class MaximumDocumentLengthExceededError extends Error { +export class MaximumDocumentLengthExceededError extends ErrorWithContextDetails { name = 'MaximumDocumentLengthExceededError'; } -export class FeatureDisabledError extends Error { +export class FeatureDisabledError extends ErrorWithContextDetails { name = 'FeatureDisabledError'; } diff --git a/backend/src/events.ts b/backend/src/events.ts index 78baa3724..0fcf162c4 100644 --- a/backend/src/events.ts +++ b/backend/src/events.ts @@ -16,8 +16,19 @@ export const eventModuleConfig = { }; export enum NoteEvent { - PERMISSION_CHANGE = 'note.permission_change' /** noteId: The id of the [@link Note], which permissions are changed. **/, - DELETION = 'note.deletion' /** noteId: The id of the [@link Note], which is being deleted. **/, + /** + * Event triggered when a note's permissions are changed. + * Payload: + * noteId: The id of the {@link Note}, for which permissions are changed. + */ + PERMISSION_CHANGE = 'note.permission_change', + + /** + * Event triggered when a note is deleted + * Payload: + * noteId: The id of the {@link Note}, which is being deleted. + */ + DELETION = 'note.deletion', } export interface NoteEventMap extends EventMap { diff --git a/backend/src/frontend-config/frontend-config.service.spec.ts b/backend/src/frontend-config/frontend-config.service.spec.ts index 249e8368f..cb885d0a0 100644 --- a/backend/src/frontend-config/frontend-config.service.spec.ts +++ b/backend/src/frontend-config/frontend-config.service.spec.ts @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { GuestAccess, ProviderType } from '@hedgedoc/commons'; +import { PermissionLevel, ProviderType } from '@hedgedoc/commons'; import { ConfigModule, registerAs } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { URL } from 'url'; @@ -16,7 +16,7 @@ import { ExternalServicesConfig } from '../config/external-services.config'; import { Loglevel } from '../config/loglevel.enum'; import { NoteConfig } from '../config/note.config'; import { LoggerModule } from '../logger/logger.module'; -import { getServerVersionFromPackageJson } from '../utils/serverVersion'; +import { getServerVersionFromPackageJson } from '../utils/server-version'; import { FrontendConfigService } from './frontend-config.service'; /* eslint-disable @@ -108,7 +108,7 @@ describe('FrontendConfigService', () => { return { forbiddenNoteIds: [], maxDocumentLength: 200, - guestAccess: GuestAccess.CREATE, + guestAccess: PermissionLevel.CREATE, permissions: { default: { everyone: DefaultAccessLevel.READ, @@ -213,7 +213,7 @@ describe('FrontendConfigService', () => { const noteConfig: NoteConfig = { forbiddenNoteIds: [], maxDocumentLength: maxDocumentLength, - guestAccess: GuestAccess.CREATE, + guestAccess: PermissionLevel.CREATE, permissions: { default: { everyone: DefaultAccessLevel.READ, diff --git a/backend/src/frontend-config/frontend-config.service.ts b/backend/src/frontend-config/frontend-config.service.ts index aba04ce06..f708fd25b 100644 --- a/backend/src/frontend-config/frontend-config.service.ts +++ b/backend/src/frontend-config/frontend-config.service.ts @@ -23,7 +23,7 @@ import externalServicesConfiguration, { } from '../config/external-services.config'; import noteConfiguration, { NoteConfig } from '../config/note.config'; import { ConsoleLoggerService } from '../logger/console-logger.service'; -import { getServerVersionFromPackageJson } from '../utils/serverVersion'; +import { getServerVersionFromPackageJson } from '../utils/server-version'; @Injectable() export class FrontendConfigService { diff --git a/backend/src/groups/groups.module.ts b/backend/src/groups/groups.module.ts index 6512d5402..f29f2211c 100644 --- a/backend/src/groups/groups.module.ts +++ b/backend/src/groups/groups.module.ts @@ -1,17 +1,16 @@ /* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; +import { KnexModule } from 'nest-knexjs'; import { LoggerModule } from '../logger/logger.module'; -import { Group } from './group.entity'; import { GroupsService } from './groups.service'; @Module({ - imports: [TypeOrmModule.forFeature([Group]), LoggerModule], + imports: [LoggerModule, KnexModule], providers: [GroupsService], exports: [GroupsService], }) diff --git a/backend/src/groups/groups.service.spec.ts b/backend/src/groups/groups.service.spec.ts index da59c6ea5..696758a67 100644 --- a/backend/src/groups/groups.service.spec.ts +++ b/backend/src/groups/groups.service.spec.ts @@ -1,112 +1,5 @@ /* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 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 { Repository } from 'typeorm'; - -import appConfigMock from '../config/mock/app.config.mock'; -import { AlreadyInDBError, NotInDBError } from '../errors/errors'; -import { LoggerModule } from '../logger/logger.module'; -import { Group } from './group.entity'; -import { GroupsService } from './groups.service'; -import { SpecialGroup } from './groups.special'; - -describe('GroupsService', () => { - let service: GroupsService; - let groupRepo: Repository; - let group: Group; - - beforeAll(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - GroupsService, - { - provide: getRepositoryToken(Group), - useClass: Repository, - }, - ], - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - load: [appConfigMock], - }), - LoggerModule, - ], - }).compile(); - - service = module.get(GroupsService); - groupRepo = module.get>(getRepositoryToken(Group)); - group = Group.create('testGroup', 'Superheros', false) as Group; - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - describe('createGroup', () => { - const groupName = 'testGroup'; - const displayname = 'Group Test'; - beforeEach(() => { - jest - .spyOn(groupRepo, 'save') - .mockImplementationOnce(async (group: Group): Promise => group); - }); - it('successfully creates a group', async () => { - const user = await service.createGroup(groupName, displayname); - expect(user.name).toEqual(groupName); - expect(user.displayName).toEqual(displayname); - }); - it('fails if group name is already taken', async () => { - // add additional mock implementation for failure - jest.spyOn(groupRepo, 'save').mockImplementationOnce(() => { - throw new Error(); - }); - // create first group with group name - await service.createGroup(groupName, displayname); - // attempt to create second group with group name - await expect(service.createGroup(groupName, displayname)).rejects.toThrow( - AlreadyInDBError, - ); - }); - }); - - describe('getGroupByName', () => { - it('works', async () => { - jest.spyOn(groupRepo, 'findOne').mockResolvedValueOnce(group); - const foundGroup = await service.getGroupByName(group.name); - expect(foundGroup.name).toEqual(group.name); - expect(foundGroup.displayName).toEqual(group.displayName); - expect(foundGroup.special).toEqual(group.special); - }); - it('fails with non-existing group', async () => { - jest.spyOn(groupRepo, 'findOne').mockResolvedValueOnce(null); - await expect(service.getGroupByName('i_dont_exist')).rejects.toThrow( - NotInDBError, - ); - }); - }); - - it('getEveryoneGroup return EVERYONE group', async () => { - const spy = jest.spyOn(service, 'getGroupByName').mockImplementation(); - await service.getEveryoneGroup(); - expect(spy).toHaveBeenCalledWith(SpecialGroup.EVERYONE); - }); - it('getLoggedInGroup return LOGGED_IN group', async () => { - const spy = jest.spyOn(service, 'getGroupByName').mockImplementation(); - await service.getLoggedInGroup(); - expect(spy).toHaveBeenCalledWith(SpecialGroup.LOGGED_IN); - }); - - describe('toGroupDto', () => { - it('works', () => { - const groupDto = service.toGroupDto(group); - expect(groupDto.displayName).toEqual(group.displayName); - expect(groupDto.name).toEqual(group.name); - expect(groupDto.special).toBeFalsy(); - }); - }); -}); diff --git a/backend/src/groups/groups.service.ts b/backend/src/groups/groups.service.ts index 08c1d3751..018b195ef 100644 --- a/backend/src/groups/groups.service.ts +++ b/backend/src/groups/groups.service.ts @@ -5,94 +5,94 @@ */ import { GroupInfoDto } from '@hedgedoc/commons'; import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Knex } from 'knex'; +import { InjectConnection } from 'nest-knexjs'; +import { FieldNameGroup, Group, TableGroup } from '../database/types'; +import { TypeInsertGroup } from '../database/types/group'; import { AlreadyInDBError, NotInDBError } from '../errors/errors'; import { ConsoleLoggerService } from '../logger/console-logger.service'; -import { Group } from './group.entity'; -import { SpecialGroup } from './groups.special'; @Injectable() export class GroupsService { constructor( private readonly logger: ConsoleLoggerService, - @InjectRepository(Group) private groupRepository: Repository, + + @InjectConnection() + private readonly knex: Knex, ) { this.logger.setContext(GroupsService.name); } /** - * @async * Create a new group with a given name and displayName - * @param name - the group name the new group shall have - * @param displayName - the display name the new group shall have - * @param special - if the group is special or not - * @return {Group} the group - * @throws {AlreadyInDBError} the group name is already taken. + * + * @param name The group name as identifier the new group shall have + * @param displayName The display name the new group shall have + * @throws {AlreadyInDBError} The group name is already taken */ - async createGroup( - name: string, - displayName: string, - special = false, - ): Promise { - const group = Group.create(name, displayName, special); + async createGroup(name: string, displayName: string): Promise { + const group: TypeInsertGroup = { + [FieldNameGroup.name]: name, + [FieldNameGroup.displayName]: displayName, + [FieldNameGroup.isSpecial]: false, + }; try { - return await this.groupRepository.save(group); + await this.knex(TableGroup).insert(group); } catch { - this.logger.debug( - `A group with the name '${name}' already exists.`, - 'createGroup', - ); - throw new AlreadyInDBError( - `A group with the name '${name}' already exists.`, - ); + const message = `A group with the name '${name}' already exists.`; + this.logger.debug(message, 'createGroup'); + throw new AlreadyInDBError(message); } } /** - * @async - * Get a group by their name. - * @param {string} name - the groups name - * @return {Group} the group - * @throws {NotInDBError} there is no group with this name + * Fetches a group by its identifier name + * + * @param name Name of the group to query + * @return The group + * @throws {NotInDBError} if there is no group with this name */ async getGroupByName(name: string): Promise { - const group = await this.groupRepository.findOne({ - where: { name: name }, - }); - if (group === null) { + const group = await this.knex(TableGroup) + .select() + .where(FieldNameGroup.name, name) + .first(); + if (group === undefined) { throw new NotInDBError(`Group with name '${name}' not found`); } return group; } /** - * Get the group object for the everyone special group. - * @return {Group} the EVERYONE group + * Fetches a groupId by its identifier name + * + * @param name Name of the group to query + * @return The groupId + * @throws {NotInDBError} if there is no group with this name */ - getEveryoneGroup(): Promise { - return this.getGroupByName(SpecialGroup.EVERYONE); + async getGroupIdByName(name: string): Promise { + const group = await this.knex(TableGroup) + .select(FieldNameGroup.id) + .where(FieldNameGroup.name, name) + .first(); + if (group === undefined) { + throw new NotInDBError(`Group with name '${name}' not found`); + } + return group[FieldNameGroup.id]; } /** - * Get the group object for the logged-in special group. - * @return {Group} the LOGGED_IN group - */ - getLoggedInGroup(): Promise { - return this.getGroupByName(SpecialGroup.LOGGED_IN); - } - - /** - * Build GroupInfoDto from a group. - * @param {Group} group - the group to use - * @return {GroupInfoDto} the built GroupInfoDto + * Builds the GroupInfoDto from a {@link Group} + * + * @param group the group to use + * @return The built GroupInfoDto */ toGroupDto(group: Group): GroupInfoDto { return { name: group.name, - displayName: group.displayName, - special: group.special, + displayName: group[FieldNameGroup.displayName], + special: group[FieldNameGroup.isSpecial], }; } } diff --git a/backend/src/history/history-entry-import.dto.ts b/backend/src/history/history-entry-import.dto.ts deleted file mode 100644 index f1f5aa6b7..000000000 --- a/backend/src/history/history-entry-import.dto.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { Type } from 'class-transformer'; -import { - IsArray, - IsBoolean, - IsDate, - IsString, - ValidateNested, -} from 'class-validator'; -// This needs to be here because of weird import-behaviour during tests -import 'reflect-metadata'; - -import { BaseDto } from '../utils/base.dto'; - -export class HistoryEntryImportDto extends BaseDto { - /** - * ID or Alias of the note - */ - @IsString() - note: string; - /** - * True if the note should be pinned - * @example true - */ - @IsBoolean() - pinStatus: boolean; - /** - * Datestring of the last time this note was updated - * @example "2020-12-01 12:23:34" - */ - @IsDate() - @Type(() => Date) - lastVisitedAt: Date; -} - -export class HistoryEntryImportListDto extends BaseDto { - @ValidateNested({ each: true }) - @IsArray() - @Type(() => HistoryEntryImportDto) - history: HistoryEntryImportDto[]; -} diff --git a/backend/src/history/history-entry-update.dto.ts b/backend/src/history/history-entry-update.dto.ts deleted file mode 100644 index fd5317307..000000000 --- a/backend/src/history/history-entry-update.dto.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { ApiProperty } from '@nestjs/swagger'; -import { IsBoolean } from 'class-validator'; - -import { BaseDto } from '../utils/base.dto'; - -export class HistoryEntryUpdateDto extends BaseDto { - /** - * True if the note should be pinned - */ - @IsBoolean() - @ApiProperty() - pinStatus: boolean; -} diff --git a/backend/src/history/history-entry.dto.ts b/backend/src/history/history-entry.dto.ts deleted file mode 100644 index c42697d40..000000000 --- a/backend/src/history/history-entry.dto.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { ApiProperty } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; -import { - IsArray, - IsBoolean, - IsDate, - IsOptional, - IsString, -} from 'class-validator'; - -import { BaseDto } from '../utils/base.dto'; - -export class HistoryEntryDto extends BaseDto { - /** - * ID or Alias of the note - */ - @IsString() - @ApiProperty() - identifier: string; - - /** - * Title of the note - * Does not contain any markup but might be empty - * @example "Shopping List" - */ - @IsString() - @ApiProperty() - title: string; - - /** - * The username of the owner of the note - * Might be null for anonymous notes - * @example "alice" - */ - @IsOptional() - @IsString() - @ApiProperty() - owner: string | null; - - /** - * Datestring of the last time this note was updated - * @example "2020-12-01 12:23:34" - */ - @IsDate() - @Type(() => Date) - @ApiProperty() - lastVisitedAt: Date; - - @IsArray() - @IsString({ each: true }) - @ApiProperty({ isArray: true, type: String }) - tags: string[]; - - /** - * True if this note is pinned - * @example false - */ - @IsBoolean() - @ApiProperty() - pinStatus: boolean; -} diff --git a/backend/src/history/history.module.ts b/backend/src/history/history.module.ts deleted file mode 100644 index f1693b49d..000000000 --- a/backend/src/history/history.module.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { TypeOrmModule } from '@nestjs/typeorm'; - -import { LoggerModule } from '../logger/logger.module'; -import { NotesModule } from '../notes/notes.module'; -import { RevisionsModule } from '../revisions/revisions.module'; -import { UsersModule } from '../users/users.module'; -import { HistoryEntry } from './history-entry.entity'; -import { HistoryService } from './history.service'; - -@Module({ - providers: [HistoryService], - exports: [HistoryService], - imports: [ - LoggerModule, - TypeOrmModule.forFeature([HistoryEntry]), - UsersModule, - NotesModule, - ConfigModule, - RevisionsModule, - ], -}) -export class HistoryModule {} diff --git a/backend/src/history/history.service.spec.ts b/backend/src/history/history.service.spec.ts deleted file mode 100644 index adfde2b9b..000000000 --- a/backend/src/history/history.service.spec.ts +++ /dev/null @@ -1,470 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { ConfigModule } from '@nestjs/config'; -import { EventEmitterModule } from '@nestjs/event-emitter'; -import { Test, TestingModule } from '@nestjs/testing'; -import { getDataSourceToken, getRepositoryToken } from '@nestjs/typeorm'; -import assert from 'assert'; -import { Mock } from 'ts-mockery'; -import { DataSource, EntityManager, Repository } from 'typeorm'; - -import { ApiToken } from '../api-token/api-token.entity'; -import { Identity } from '../auth/identity.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 { User } from '../database/user.entity'; -import { NotInDBError } from '../errors/errors'; -import { eventModuleConfig } from '../events'; -import { Group } from '../groups/group.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 { Tag } from '../notes/tag.entity'; -import { NoteGroupPermission } from '../permissions/note-group-permission.entity'; -import { NoteUserPermission } from '../permissions/note-user-permission.entity'; -import { Edit } from '../revisions/edit.entity'; -import { Revision } from '../revisions/revision.entity'; -import { RevisionsModule } from '../revisions/revisions.module'; -import { RevisionsService } from '../revisions/revisions.service'; -import { Session } from '../sessions/session.entity'; -import { UsersModule } from '../users/users.module'; -import { mockSelectQueryBuilderInRepo } from '../utils/test-utils/mockSelectQueryBuilder'; -import { HistoryEntryImportDto } from './history-entry-import.dto'; -import { HistoryEntry } from './history-entry.entity'; -import { HistoryService } from './history.service'; - -describe('HistoryService', () => { - let service: HistoryService; - let revisionsService: RevisionsService; - let historyRepo: Repository; - let noteRepo: Repository; - let mockedTransaction: jest.Mock< - Promise, - [(entityManager: EntityManager) => Promise] - >; - - class CreateQueryBuilderClass { - leftJoinAndSelect: () => CreateQueryBuilderClass; - where: () => CreateQueryBuilderClass; - orWhere: () => CreateQueryBuilderClass; - setParameter: () => CreateQueryBuilderClass; - getOne: () => HistoryEntry; - getMany: () => HistoryEntry[]; - } - - let createQueryBuilderFunc: CreateQueryBuilderClass; - - beforeEach(async () => { - noteRepo = new Repository( - '', - new EntityManager( - new DataSource({ - type: 'sqlite', - database: ':memory:', - }), - ), - undefined, - ); - const module: TestingModule = await Test.createTestingModule({ - providers: [ - HistoryService, - { - provide: getDataSourceToken(), - useFactory: () => { - mockedTransaction = jest.fn(); - return Mock.of({ - transaction: mockedTransaction, - }); - }, - }, - { - provide: getRepositoryToken(HistoryEntry), - useClass: Repository, - }, - { - provide: getRepositoryToken(Note), - useValue: noteRepo, - }, - ], - imports: [ - LoggerModule, - UsersModule, - NotesModule, - RevisionsModule, - ConfigModule.forRoot({ - isGlobal: true, - load: [ - appConfigMock, - databaseConfigMock, - authConfigMock, - noteConfigMock, - ], - }), - EventEmitterModule.forRoot(eventModuleConfig), - ], - }) - .overrideProvider(getRepositoryToken(User)) - .useValue({}) - .overrideProvider(getRepositoryToken(ApiToken)) - .useValue({}) - .overrideProvider(getRepositoryToken(Identity)) - .useValue({}) - .overrideProvider(getRepositoryToken(Edit)) - .useValue({}) - .overrideProvider(getRepositoryToken(Revision)) - .useValue({}) - .overrideProvider(getRepositoryToken(Note)) - .useValue(noteRepo) - .overrideProvider(getRepositoryToken(Tag)) - .useValue({}) - .overrideProvider(getRepositoryToken(NoteGroupPermission)) - .useValue({}) - .overrideProvider(getRepositoryToken(NoteUserPermission)) - .useValue({}) - .overrideProvider(getRepositoryToken(Group)) - .useValue({}) - .overrideProvider(getRepositoryToken(Session)) - .useValue({}) - .overrideProvider(getRepositoryToken(Author)) - .useValue({}) - .overrideProvider(getRepositoryToken(Alias)) - .useClass(Repository) - .compile(); - - service = module.get(HistoryService); - revisionsService = module.get(RevisionsService); - historyRepo = module.get>( - getRepositoryToken(HistoryEntry), - ); - noteRepo = module.get>(getRepositoryToken(Note)); - const historyEntry = new HistoryEntry(); - const createQueryBuilder = { - leftJoinAndSelect: () => createQueryBuilder, - where: () => createQueryBuilder, - orWhere: () => createQueryBuilder, - setParameter: () => createQueryBuilder, - getOne: () => historyEntry, - getMany: () => [historyEntry], - }; - createQueryBuilderFunc = createQueryBuilder as CreateQueryBuilderClass; - jest - .spyOn(historyRepo, 'createQueryBuilder') - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - .mockImplementation(() => createQueryBuilder); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - describe('getEntriesByUser', () => { - describe('works', () => { - it('with an empty list', async () => { - createQueryBuilderFunc.getMany = () => []; - expect(await service.getEntriesByUser({} as User)).toEqual([]); - }); - - it('with an one element list', async () => { - const historyEntry = new HistoryEntry(); - createQueryBuilderFunc.getMany = () => [historyEntry]; - expect(await service.getEntriesByUser({} as User)).toEqual([ - historyEntry, - ]); - }); - - it('with an multiple element list', async () => { - const historyEntry = new HistoryEntry(); - const historyEntry2 = new HistoryEntry(); - createQueryBuilderFunc.getMany = () => [historyEntry, historyEntry2]; - expect(await service.getEntriesByUser({} as User)).toEqual([ - historyEntry, - historyEntry2, - ]); - }); - }); - }); - - describe('updateHistoryEntryTimestamp', () => { - describe('works', () => { - const user = {} as User; - const alias = 'alias'; - const historyEntry = HistoryEntry.create( - user, - Note.create(user, alias) as Note, - ) as HistoryEntry; - it('without an preexisting entry', async () => { - mockSelectQueryBuilderInRepo(historyRepo, null); - jest - .spyOn(historyRepo, 'save') - .mockImplementation( - async (entry): Promise => entry as HistoryEntry, - ); - const createHistoryEntry = await service.updateHistoryEntryTimestamp( - Note.create(user, alias) as Note, - user, - ); - assert(createHistoryEntry != null); - expect(await (await createHistoryEntry.note).aliases).toHaveLength(1); - expect((await (await createHistoryEntry.note).aliases)[0].name).toEqual( - alias, - ); - expect(await (await createHistoryEntry.note).owner).toEqual(user); - expect(await createHistoryEntry.user).toEqual(user); - expect(createHistoryEntry.pinStatus).toEqual(false); - }); - - it('with an preexisting entry', async () => { - mockSelectQueryBuilderInRepo(historyRepo, historyEntry); - jest - .spyOn(historyRepo, 'save') - .mockImplementation( - async (entry): Promise => entry as HistoryEntry, - ); - const createHistoryEntry = await service.updateHistoryEntryTimestamp( - Note.create(user, alias) as Note, - user, - ); - assert(createHistoryEntry != null); - expect(await (await createHistoryEntry.note).aliases).toHaveLength(1); - expect((await (await createHistoryEntry.note).aliases)[0].name).toEqual( - alias, - ); - expect(await (await createHistoryEntry.note).owner).toEqual(user); - expect(await createHistoryEntry.user).toEqual(user); - expect(createHistoryEntry.pinStatus).toEqual(false); - expect(createHistoryEntry.updatedAt.getTime()).toBeGreaterThanOrEqual( - historyEntry.updatedAt.getTime(), - ); - }); - }); - it('returns null if user is null', async () => { - const entry = await service.updateHistoryEntryTimestamp({} as Note, null); - expect(entry).toBeNull(); - }); - }); - - describe('updateHistoryEntry', () => { - const user = {} as User; - const alias = 'alias'; - const note = Note.create(user, alias) as Note; - beforeEach(() => { - mockSelectQueryBuilderInRepo(noteRepo, note); - }); - describe('works', () => { - it('with an entry', async () => { - const historyEntry = HistoryEntry.create(user, note) as HistoryEntry; - mockSelectQueryBuilderInRepo(historyRepo, historyEntry); - jest - .spyOn(historyRepo, 'save') - .mockImplementation( - async (entry): Promise => entry as HistoryEntry, - ); - const updatedHistoryEntry = await service.updateHistoryEntry( - note, - user, - { - pinStatus: true, - }, - ); - expect(await (await updatedHistoryEntry.note).aliases).toHaveLength(1); - expect( - (await (await updatedHistoryEntry.note).aliases)[0].name, - ).toEqual(alias); - expect(await (await updatedHistoryEntry.note).owner).toEqual(user); - expect(await updatedHistoryEntry.user).toEqual(user); - expect(updatedHistoryEntry.pinStatus).toEqual(true); - }); - - it('without an entry', async () => { - mockSelectQueryBuilderInRepo(historyRepo, null); - await expect( - service.updateHistoryEntry(note, user, { - pinStatus: true, - }), - ).rejects.toThrow(NotInDBError); - }); - }); - }); - - describe('deleteHistoryEntry', () => { - describe('works', () => { - const user = {} as User; - const alias = 'alias'; - const note = Note.create(user, alias) as Note; - const historyEntry = HistoryEntry.create(user, note) as HistoryEntry; - it('with an entry', async () => { - createQueryBuilderFunc.getMany = () => [historyEntry]; - jest - .spyOn(historyRepo, 'remove') - .mockImplementationOnce( - async (entry: HistoryEntry): Promise => { - expect(entry).toEqual(historyEntry); - return entry; - }, - ); - await service.deleteHistory(user); - }); - it('with multiple entries', async () => { - const alias2 = 'alias2'; - const note2 = Note.create(user, alias2) as Note; - const historyEntry2 = HistoryEntry.create(user, note2) as HistoryEntry; - createQueryBuilderFunc.getMany = () => [historyEntry, historyEntry2]; - jest - .spyOn(historyRepo, 'remove') - .mockImplementationOnce( - async (entry: HistoryEntry): Promise => { - expect(entry).toEqual(historyEntry); - return entry; - }, - ) - .mockImplementationOnce( - async (entry: HistoryEntry): Promise => { - expect(entry).toEqual(historyEntry2); - return entry; - }, - ); - await service.deleteHistory(user); - }); - it('without an entry', async () => { - createQueryBuilderFunc.getMany = () => []; - await service.deleteHistory(user); - expect(true).toBeTruthy(); - }); - }); - }); - - describe('deleteHistory', () => { - describe('works', () => { - it('with an entry', async () => { - const user = {} as User; - const alias = 'alias'; - const note = Note.create(user, alias) as Note; - const historyEntry = HistoryEntry.create(user, note) as HistoryEntry; - mockSelectQueryBuilderInRepo(historyRepo, historyEntry); - mockSelectQueryBuilderInRepo(noteRepo, note); - jest - .spyOn(historyRepo, 'remove') - .mockImplementation( - async (entry: HistoryEntry): Promise => { - expect(entry).toEqual(historyEntry); - return entry; - }, - ); - await service.deleteHistoryEntry(note, user); - }); - }); - describe('fails', () => { - const user = {} as User; - const alias = 'alias'; - it('without an entry', async () => { - const note = Note.create(user, alias) as Note; - - mockSelectQueryBuilderInRepo(historyRepo, null); - mockSelectQueryBuilderInRepo(noteRepo, note); - await expect(service.deleteHistoryEntry(note, user)).rejects.toThrow( - NotInDBError, - ); - }); - }); - }); - - describe('setHistory', () => { - it('works', async () => { - const user = {} as User; - const alias = 'alias'; - const note = Note.create(user, alias) as Note; - const historyEntry = HistoryEntry.create(user, note); - const historyEntryImport: HistoryEntryImportDto = { - lastVisitedAt: new Date('2020-12-01 12:23:34'), - note: alias, - pinStatus: true, - }; - const newlyCreatedHistoryEntry: HistoryEntry = { - ...historyEntry, - pinStatus: historyEntryImport.pinStatus, - updatedAt: historyEntryImport.lastVisitedAt, - }; - - mockSelectQueryBuilderInRepo(noteRepo, note); - const createQueryBuilderForEntityManager = { - where: () => createQueryBuilderForEntityManager, - getMany: () => [historyEntry], - }; - - const mockedManager = Mock.of({ - createQueryBuilder: jest - .fn() - .mockImplementation(() => createQueryBuilderForEntityManager), - remove: jest - .fn() - .mockImplementationOnce(async (entry: HistoryEntry) => { - expect(await (await entry.note).aliases).toHaveLength(1); - expect((await (await entry.note).aliases)[0].name).toEqual(alias); - expect(entry.pinStatus).toEqual(false); - }), - save: jest.fn().mockImplementationOnce(async (entry: HistoryEntry) => { - expect((await entry.note).aliases).toEqual( - (await newlyCreatedHistoryEntry.note).aliases, - ); - expect(entry.pinStatus).toEqual(newlyCreatedHistoryEntry.pinStatus); - expect(entry.updatedAt).toEqual(newlyCreatedHistoryEntry.updatedAt); - }), - }); - mockedTransaction.mockImplementation((cb) => cb(mockedManager)); - await service.setHistory(user, [historyEntryImport]); - }); - }); - - describe('toHistoryEntryDto', () => { - describe('works', () => { - it('with aliased note', async () => { - const user = {} as User; - const alias = 'alias'; - const title = 'title'; - const tags = ['tag1', 'tag2']; - const note = Note.create(user, alias) as Note; - const revision = Revision.create( - '', - '', - note, - null, - '', - '', - [], - ) as Revision; - revision.title = title; - revision.tags = Promise.resolve( - tags.map((tag) => { - const newTag = new Tag(); - newTag.name = tag; - return newTag; - }), - ); - const historyEntry = HistoryEntry.create(user, note) as HistoryEntry; - historyEntry.pinStatus = true; - - mockSelectQueryBuilderInRepo(noteRepo, note); - jest - .spyOn(revisionsService, 'getLatestRevision') - .mockImplementation((requestedNote) => { - expect(note).toBe(requestedNote); - return Promise.resolve(revision); - }); - - const historyEntryDto = await service.toHistoryEntryDto(historyEntry); - expect(historyEntryDto.pinStatus).toEqual(true); - expect(historyEntryDto.identifier).toEqual(alias); - expect(historyEntryDto.tags).toEqual(tags); - expect(historyEntryDto.title).toEqual(title); - }); - }); - }); -}); diff --git a/backend/src/history/history.service.ts b/backend/src/history/history.service.ts deleted file mode 100644 index 76c45b7ef..000000000 --- a/backend/src/history/history.service.ts +++ /dev/null @@ -1,194 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { Injectable } from '@nestjs/common'; -import { InjectConnection, InjectRepository } from '@nestjs/typeorm'; -import { Connection, Repository } from 'typeorm'; - -import { User } from '../database/user.entity'; -import { NotInDBError } from '../errors/errors'; -import { ConsoleLoggerService } from '../logger/console-logger.service'; -import { Note } from '../notes/note.entity'; -import { NotesService } from '../notes/notes.service'; -import { RevisionsService } from '../revisions/revisions.service'; -import { UsersService } from '../users/users.service'; -import { HistoryEntryImportDto } from './history-entry-import.dto'; -import { HistoryEntryUpdateDto } from './history-entry-update.dto'; -import { HistoryEntryDto } from './history-entry.dto'; -import { HistoryEntry } from './history-entry.entity'; -import { getIdentifier } from './utils'; - -@Injectable() -export class HistoryService { - constructor( - private readonly logger: ConsoleLoggerService, - @InjectConnection() - private connection: Connection, - @InjectRepository(HistoryEntry) - private historyEntryRepository: Repository, - private usersService: UsersService, - private notesService: NotesService, - private revisionsService: RevisionsService, - ) { - this.logger.setContext(HistoryService.name); - } - - /** - * @async - * Get all entries of a user - * @param {User} user - the user the entries should be from - * @return {HistoryEntry[]} an array of history entries of the specified user - */ - async getEntriesByUser(user: User): Promise { - return await this.historyEntryRepository - .createQueryBuilder('entry') - .where('entry.userId = :userId', { userId: user.id }) - .getMany(); - } - - /** - * @async - * Get a history entry by the user and note - * @param {Note} note - the note that the history entry belongs to - * @param {User} user - the user that the history entry belongs to - * @return {HistoryEntry} the requested history entry - */ - async getEntryByNote(note: Note, user: User): Promise { - const entry = await this.historyEntryRepository - .createQueryBuilder('entry') - .where('entry.note = :note', { note: note.id }) - .andWhere('entry.user = :user', { user: user.id }) - .leftJoinAndSelect('entry.note', 'note') - .leftJoinAndSelect('entry.user', 'user') - .getOne(); - if (!entry) { - throw new NotInDBError( - `User '${user.username}' has no HistoryEntry for Note with id '${note.id}'`, - ); - } - return entry; - } - - /** - * @async - * Updates the updatedAt timestamp of a HistoryEntry. - * If no history entry exists, it will be created. - * @param {Note} note - the note that the history entry belongs to - * @param {User | null} user - the user that the history entry belongs to - * @return {HistoryEntry} the requested history entry - */ - async updateHistoryEntryTimestamp( - note: Note, - user: User | null, - ): Promise { - if (user == null) { - return null; - } - try { - const entry = await this.getEntryByNote(note, user); - entry.updatedAt = new Date(); - return await this.historyEntryRepository.save(entry); - } catch (e) { - if (e instanceof NotInDBError) { - const entry = HistoryEntry.create(user, note); - return await this.historyEntryRepository.save(entry); - } - throw e; - } - } - - /** - * @async - * Update a history entry identified by the user and a note id or alias - * @param {Note} note - the note that the history entry belongs to - * @param {User} user - the user that the history entry belongs to - * @param {HistoryEntryUpdateDto} updateDto - the change that should be applied to the history entry - * @return {HistoryEntry} the requested history entry - */ - async updateHistoryEntry( - note: Note, - user: User, - updateDto: HistoryEntryUpdateDto, - ): Promise { - const entry = await this.getEntryByNote(note, user); - entry.pinStatus = updateDto.pinStatus; - return await this.historyEntryRepository.save(entry); - } - - /** - * @async - * Delete the history entry identified by the user and a note id or alias - * @param {Note} note - the note that the history entry belongs to - * @param {User} user - the user that the history entry belongs to - * @throws {NotInDBError} the specified history entry does not exist - */ - async deleteHistoryEntry(note: Note, user: User): Promise { - const entry = await this.getEntryByNote(note, user); - await this.historyEntryRepository.remove(entry); - return; - } - - /** - * @async - * Delete all history entries of a specific user - * @param {User} user - the user that the entry belongs to - */ - async deleteHistory(user: User): Promise { - const entries: HistoryEntry[] = await this.getEntriesByUser(user); - for (const entry of entries) { - await this.historyEntryRepository.remove(entry); - } - } - - /** - * @async - * Replace the user history with the provided history - * @param {User} user - the user that get's their history replaces - * @param {HistoryEntryImportDto[]} history - * @throws {ForbiddenIdError} one of the note ids or alias in the new history are forbidden - */ - async setHistory( - user: User, - history: HistoryEntryImportDto[], - ): Promise { - await this.connection.transaction(async (manager) => { - const currentHistory = await manager - .createQueryBuilder(HistoryEntry, 'entry') - .where('entry.userId = :userId', { userId: user.id }) - .getMany(); - for (const entry of currentHistory) { - await manager.remove(entry); - } - for (const historyEntry of history) { - const note = await this.notesService.getNoteByIdOrAlias( - historyEntry.note, - ); - const entry = HistoryEntry.create(user, note) as HistoryEntry; - entry.pinStatus = historyEntry.pinStatus; - entry.updatedAt = historyEntry.lastVisitedAt; - await manager.save(entry); - } - }); - } - - /** - * Build HistoryEntryDto from a history entry. - * @param {HistoryEntry} entry - the history entry to use - * @return {HistoryEntryDto} the built HistoryEntryDto - */ - async toHistoryEntryDto(entry: HistoryEntry): Promise { - const note = await entry.note; - const owner = await note.owner; - const revision = await this.revisionsService.getLatestRevision(note); - return { - identifier: await getIdentifier(entry), - lastVisitedAt: entry.updatedAt, - tags: (await revision.tags).map((tag) => tag.name), - title: revision.title ?? '', - pinStatus: entry.pinStatus, - owner: owner ? owner.username : null, - }; - } -} diff --git a/backend/src/history/utils.spec.ts b/backend/src/history/utils.spec.ts deleted file mode 100644 index 14cdd8545..000000000 --- a/backend/src/history/utils.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { User } from '../database/user.entity'; -import { Alias } from '../notes/alias.entity'; -import { Note } from '../notes/note.entity'; -import { HistoryEntry } from './history-entry.entity'; -import { getIdentifier } from './utils'; - -describe('getIdentifier', () => { - const alias = 'alias'; - let note: Note; - let entry: HistoryEntry; - beforeEach(() => { - const user = User.create('hardcoded', 'Testy') as User; - note = Note.create(user, alias) as Note; - entry = HistoryEntry.create(user, note) as HistoryEntry; - }); - it('returns the publicId if there are no aliases', async () => { - note.aliases = Promise.resolve(undefined as unknown as Alias[]); - expect(await getIdentifier(entry)).toEqual(note.publicId); - }); - it('returns the publicId, if the alias array is empty', async () => { - note.aliases = Promise.resolve([]); - expect(await getIdentifier(entry)).toEqual(note.publicId); - }); - it('returns the publicId, if the only alias is not primary', async () => { - (await note.aliases)[0].primary = false; - expect(await getIdentifier(entry)).toEqual(note.publicId); - }); - it('returns the primary alias, if one exists', async () => { - expect(await getIdentifier(entry)).toEqual((await note.aliases)[0].name); - }); -}); diff --git a/backend/src/history/utils.ts b/backend/src/history/utils.ts deleted file mode 100644 index c964412eb..000000000 --- a/backend/src/history/utils.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { getPrimaryAlias } from '../notes/utils'; -import { HistoryEntry } from './history-entry.entity'; - -export async function getIdentifier(entry: HistoryEntry): Promise { - const aliases = await (await entry.note).aliases; - if (!aliases || aliases.length === 0) { - return (await entry.note).publicId; - } - const primaryAlias = await getPrimaryAlias(await entry.note); - if (primaryAlias === undefined) { - return (await entry.note).publicId; - } - return primaryAlias; -} diff --git a/backend/src/logger/console-logger.service.ts b/backend/src/logger/console-logger.service.ts index 64bbb1694..6c72898e2 100644 --- a/backend/src/logger/console-logger.service.ts +++ b/backend/src/logger/console-logger.service.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -37,66 +37,90 @@ export class ConsoleLoggerService implements LoggerService { this.classContext = context; } + getContext(): string | undefined { + return this.classContext; + } + setSkipColor(skipColor: boolean): void { this.skipColor = skipColor; } - error(message: unknown, trace = '', functionContext?: string): void { + error( + message: unknown, + trace = '', + functionContext?: string, + classContext?: string, + ): void { this.printMessage( message, red, - this.makeContextString(functionContext), + this.makeContextString(functionContext, classContext), false, ); ConsoleLoggerService.printStackTrace(trace); } - log(message: unknown, functionContext?: string): void { + log(message: unknown, functionContext?: string, classContext?: string): void { if (needToLog(this.appConfig.loglevel, Loglevel.INFO)) { this.printMessage( message, green, - this.makeContextString(functionContext), + this.makeContextString(functionContext, classContext), false, ); } } - warn(message: unknown, functionContext?: string): void { + warn( + message: unknown, + functionContext?: string, + classContext?: string, + ): void { if (needToLog(this.appConfig.loglevel, Loglevel.WARN)) { this.printMessage( message, yellow, - this.makeContextString(functionContext), + this.makeContextString(functionContext, classContext), false, ); } } - debug(message: unknown, functionContext?: string): void { + debug( + message: unknown, + functionContext?: string, + classContext?: string, + ): void { if (needToLog(this.appConfig.loglevel, Loglevel.DEBUG)) { this.printMessage( message, magentaBright, - this.makeContextString(functionContext), + this.makeContextString(functionContext, classContext), false, ); } } - verbose(message: unknown, functionContext?: string): void { + verbose( + message: unknown, + functionContext?: string, + classContext?: string, + ): void { if (needToLog(this.appConfig.loglevel, Loglevel.TRACE)) { this.printMessage( message, cyanBright, - this.makeContextString(functionContext), + this.makeContextString(functionContext, classContext), false, ); } } - private makeContextString(functionContext?: string): string { - let context = this.classContext; + private makeContextString( + functionContext?: string, + classContext?: string, + ): string { + let context = classContext ?? this.classContext; if (!context) { context = 'HedgeDoc'; } diff --git a/backend/src/media/media.module.ts b/backend/src/media/media.module.ts index 1e870a94b..aa91b4cdd 100644 --- a/backend/src/media/media.module.ts +++ b/backend/src/media/media.module.ts @@ -1,31 +1,23 @@ /* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; -import { TypeOrmModule } from '@nestjs/typeorm'; +import { KnexModule } from 'nest-knexjs'; import { LoggerModule } from '../logger/logger.module'; -import { NotesModule } from '../notes/notes.module'; -import { UsersModule } from '../users/users.module'; +import { NoteModule } from '../note/note.module'; import { AzureBackend } from './backends/azure-backend'; import { FilesystemBackend } from './backends/filesystem-backend'; import { ImgurBackend } from './backends/imgur-backend'; import { S3Backend } from './backends/s3-backend'; import { WebdavBackend } from './backends/webdav-backend'; -import { MediaUpload } from './media-upload.entity'; import { MediaService } from './media.service'; @Module({ - imports: [ - TypeOrmModule.forFeature([MediaUpload]), - NotesModule, - UsersModule, - LoggerModule, - ConfigModule, - ], + imports: [NoteModule, LoggerModule, ConfigModule, KnexModule], providers: [ MediaService, FilesystemBackend, diff --git a/backend/src/media/media.service.spec.ts b/backend/src/media/media.service.spec.ts index f5cfc52ce..38e8fcc74 100644 --- a/backend/src/media/media.service.spec.ts +++ b/backend/src/media/media.service.spec.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -11,6 +11,7 @@ import { promises as fs } from 'fs'; import { Repository } from 'typeorm'; import appConfigMock from '../../src/config/mock/app.config.mock'; +import { AliasModule } from '../alias/alias.module'; import { ApiToken } from '../api-token/api-token.entity'; import { Identity } from '../auth/identity.entity'; import { Author } from '../authors/author.entity'; @@ -23,9 +24,8 @@ import { ClientError, NotInDBError } from '../errors/errors'; import { eventModuleConfig } from '../events'; import { Group } from '../groups/group.entity'; import { LoggerModule } from '../logger/logger.module'; -import { Alias } from '../notes/alias.entity'; +import { Alias } from '../notes/aliases.entity'; import { Note } from '../notes/note.entity'; -import { NotesModule } from '../notes/notes.module'; import { Tag } from '../notes/tag.entity'; import { NoteGroupPermission } from '../permissions/note-group-permission.entity'; import { NoteUserPermission } from '../permissions/note-user-permission.entity'; @@ -77,7 +77,7 @@ describe('MediaService', () => { ], }), LoggerModule, - NotesModule, + AliasModule, UsersModule, EventEmitterModule.forRoot(eventModuleConfig), ], @@ -317,20 +317,22 @@ describe('MediaService', () => { } as MediaUpload; createQueryBuilderFunc.getMany = () => [mockMediaUploadEntry]; expect( - await service.listUploadsByUser({ username: 'hardcoded' } as User), + await service.getMediaUploadUuidsByUserId({ + username: 'hardcoded', + } as User), ).toEqual([mockMediaUploadEntry]); }); it('without uploads from user', async () => { createQueryBuilderFunc.getMany = () => []; - const mediaList = await service.listUploadsByUser({ + const mediaList = await service.getMediaUploadUuidsByUserId({ username: username, } as User); expect(mediaList).toEqual([]); }); it('with error (null as return value of find)', async () => { createQueryBuilderFunc.getMany = () => []; - const mediaList = await service.listUploadsByUser({ + const mediaList = await service.getMediaUploadUuidsByUserId({ username: username, } as User); expect(mediaList).toEqual([]); @@ -364,7 +366,7 @@ describe('MediaService', () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore .mockImplementation(() => createQueryBuilder); - const mediaList = await service.listUploadsByNote({ + const mediaList = await service.getMediaUploadUuidsByNoteId({ id: 123, } as Note); expect(mediaList).toEqual([mockMediaUploadEntry]); @@ -382,7 +384,7 @@ describe('MediaService', () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore .mockImplementation(() => createQueryBuilder); - const mediaList = await service.listUploadsByNote({ + const mediaList = await service.getMediaUploadUuidsByNoteId({ id: 123, } as Note); expect(mediaList).toEqual([]); @@ -399,7 +401,7 @@ describe('MediaService', () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore .mockImplementation(() => createQueryBuilder); - const mediaList = await service.listUploadsByNote({ + const mediaList = await service.getMediaUploadUuidsByNoteId({ id: 123, } as Note); expect(mediaList).toEqual([]); diff --git a/backend/src/media/media.service.ts b/backend/src/media/media.service.ts index 0485f3913..a2d342231 100644 --- a/backend/src/media/media.service.ts +++ b/backend/src/media/media.service.ts @@ -6,16 +6,28 @@ import { MediaUploadDto } from '@hedgedoc/commons'; import { Inject, Injectable } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; -import { InjectRepository } from '@nestjs/typeorm'; import * as FileType from 'file-type'; -import { Repository } from 'typeorm'; +import { Knex } from 'knex'; +import { InjectConnection } from 'nest-knexjs'; import { v7 as uuidV7 } from 'uuid'; import mediaConfiguration, { MediaConfig } from '../config/media.config'; -import { User } from '../database/user.entity'; +import { + FieldNameAlias, + FieldNameMediaUpload, + FieldNameNote, + FieldNameUser, + MediaUpload, + Note, + TableAlias, + TableMediaUpload, + TableNote, + TableUser, + User, +} from '../database/types'; import { ClientError, NotInDBError } from '../errors/errors'; import { ConsoleLoggerService } from '../logger/console-logger.service'; -import { Note } from '../notes/note.entity'; +import { NoteService } from '../notes/note.service'; import { AzureBackend } from './backends/azure-backend'; import { BackendType } from './backends/backend-type.enum'; import { FilesystemBackend } from './backends/filesystem-backend'; @@ -23,7 +35,6 @@ import { ImgurBackend } from './backends/imgur-backend'; import { S3Backend } from './backends/s3-backend'; import { WebdavBackend } from './backends/webdav-backend'; import { MediaBackend } from './media-backend.interface'; -import { MediaUpload } from './media-upload.entity'; @Injectable() export class MediaService { @@ -32,9 +43,13 @@ export class MediaService { constructor( private readonly logger: ConsoleLoggerService, - @InjectRepository(MediaUpload) - private mediaUploadRepository: Repository, + private readonly noteService: NoteService, + + @InjectConnection() + private readonly knex: Knex, + private moduleRef: ModuleRef, + @Inject(mediaConfiguration.KEY) private mediaConfig: MediaConfig, ) { @@ -62,34 +77,28 @@ export class MediaService { } /** - * @async - * Save the given buffer to the configured MediaBackend and create a MediaUploadEntity to track where the file is, who uploaded it and to which note. - * @param {string} fileName - the original file name - * @param {Buffer} fileBuffer - the buffer of the file to save. - * @param {User} user - the user who uploaded this file - * @param {Note} note - the note which will be associated with the new file. - * @return {MediaUpload} the created MediaUpload entity - * @throws {ClientError} the MIME type of the file is not supported. - * @throws {NotInDBError} - the note or user is not in the database - * @throws {MediaBackendError} - there was an error saving the file + * Saves the given buffer to the configured MediaBackend and creates a MediaUploadEntity + * to track where the file is, who uploaded it and to which note + * + * @param fileName The original file name + * @param fileBuffer The buffer with the file contents to save + * @param userId Id of the user who uploaded this file + * @param noteId Id of the note which will be associated with the new file + * @return The created MediaUpload entity + * @throws {ClientError} if the MIME type of the file is not supported + * @throws {NotInDBError} if the note or user is not in the database + * @throws {MediaBackendError} if there was an error saving the file */ async saveFile( fileName: string, fileBuffer: Buffer, - user: User | null, - note: Note, - ): Promise { - if (user) { - this.logger.debug( - `Saving file for note '${note.id}' and user '${user.username}'`, - 'saveFile', - ); - } else { - this.logger.debug( - `Saving file for note '${note.id}' and not logged in user`, - 'saveFile', - ); - } + userId: User[FieldNameUser.id], + noteId: Note[FieldNameNote.id], + ): Promise { + this.logger.debug( + `Saving file for note '${noteId}' and user '${userId}'`, + 'saveFile', + ); const fileTypeResult = await FileType.fromBuffer(fileBuffer); if (!fileTypeResult) { throw new ClientError('Could not detect file type.'); @@ -103,29 +112,44 @@ export class MediaService { fileBuffer, fileTypeResult, ); - const mediaUpload = MediaUpload.create( - uuid, - fileName, - note, - user, - this.mediaBackendType, - backendData, + const mediaUploads = await this.knex(TableMediaUpload).insert( + { + [FieldNameMediaUpload.fileName]: fileName, + [FieldNameMediaUpload.userId]: userId, + [FieldNameMediaUpload.noteId]: noteId, + [FieldNameMediaUpload.backendType]: this.mediaBackendType, + [FieldNameMediaUpload.backendData]: backendData, + }, + [FieldNameMediaUpload.uuid], ); - return await this.mediaUploadRepository.save(mediaUpload); + return mediaUploads[0][FieldNameMediaUpload.uuid]; } /** * @async * Try to delete the specified file. - * @param {MediaUpload} mediaUpload - the name of the file to delete. + * @param {uuid} uuid - the name of the file to delete. * @throws {MediaBackendError} - there was an error deleting the file */ - async deleteFile(mediaUpload: MediaUpload): Promise { + async deleteFile(uuid: string): Promise { + const backendData = await this.knex(TableMediaUpload) + .select(FieldNameMediaUpload.backendData) + .where(FieldNameMediaUpload.uuid, uuid) + .first(); + if (backendData == undefined) { + throw new NotInDBError( + `Can't find backend data for '${uuid}'`, + this.logger.getContext(), + 'deleteFile', + ); + } await this.mediaBackend.deleteFile( - mediaUpload.uuid, - mediaUpload.backendData, + uuid, + backendData[FieldNameMediaUpload.backendData], ); - await this.mediaUploadRepository.remove(mediaUpload); + await this.knex(TableMediaUpload) + .where(FieldNameMediaUpload.uuid, uuid) + .delete(); } /** @@ -170,10 +194,9 @@ export class MediaService { * @throws {NotInDBError} - the MediaUpload entity with the provided UUID is not found in the database. */ async findUploadByUuid(uuid: string): Promise { - const mediaUpload = await this.mediaUploadRepository.findOne({ - where: { uuid }, - relations: ['user'], - }); + const mediaUpload = await this.knex(TableMediaUpload) + .select() + .where(FieldNameMediaUpload.uuid, uuid); if (mediaUpload === null) { throw new NotInDBError(`MediaUpload with uuid '${uuid}' not found`); } @@ -183,49 +206,50 @@ export class MediaService { /** * @async * List all uploads by a specific user - * @param {User} user - the specific user + * @param {number} userId - the specific user * @return {MediaUpload[]} arary of media uploads owned by the user */ - async listUploadsByUser(user: User): Promise { - const mediaUploads = await this.mediaUploadRepository - .createQueryBuilder('media') - .where('media.userId = :userId', { userId: user.id }) - .getMany(); - if (mediaUploads === null) { - return []; - } - return mediaUploads; + async getMediaUploadUuidsByUserId( + userId: number, + ): Promise { + const results = await this.knex(TableMediaUpload) + .select(FieldNameMediaUpload.uuid) + .where(FieldNameMediaUpload.userId, userId); + return results.map((result) => result[FieldNameMediaUpload.uuid]); } /** * @async * List all uploads to a specific note - * @param {Note} note - the specific user + * @param {number} noteId - the specific user * @return {MediaUpload[]} array of media uploads owned by the user */ - async listUploadsByNote(note: Note): Promise { - const mediaUploads = await this.mediaUploadRepository - .createQueryBuilder('upload') - .where('upload.note = :note', { note: note.id }) - .getMany(); - if (mediaUploads === null) { - return []; - } - return mediaUploads; + async getMediaUploadUuidsByNoteId( + noteId: number, + ): Promise { + return await this.knex.transaction(async (transaction) => { + const results = await transaction(TableMediaUpload) + .select(FieldNameMediaUpload.uuid) + .where(FieldNameMediaUpload.noteId, noteId); + return results.map((result) => result[FieldNameMediaUpload.uuid]); + }); } /** * @async * Set the note of a mediaUpload to null - * @param {MediaUpload} mediaUpload - the media upload to be changed + * @param {string} uuid - the media upload to be changed */ - async removeNoteFromMediaUpload(mediaUpload: MediaUpload): Promise { + async removeNoteFromMediaUpload(uuid: string): Promise { this.logger.debug( - 'Setting note to null for mediaUpload: ' + mediaUpload.uuid, + 'Setting note to null for mediaUpload: ' + uuid, 'removeNoteFromMediaUpload', ); - mediaUpload.note = Promise.resolve(null); - await this.mediaUploadRepository.save(mediaUpload); + await this.knex(TableMediaUpload) + .update({ + [FieldNameMediaUpload.noteId]: null, + }) + .where(FieldNameMediaUpload.uuid, uuid); } private chooseBackendType(): BackendType { @@ -262,14 +286,34 @@ export class MediaService { } } - async toMediaUploadDto(mediaUpload: MediaUpload): Promise { - const user = await mediaUpload.user; - return { - uuid: mediaUpload.uuid, - fileName: mediaUpload.fileName, - noteId: (await mediaUpload.note)?.publicId ?? null, - createdAt: mediaUpload.createdAt.toISOString(), - username: user?.username ?? null, - }; + async getMediaUploadDtosByUuids(uuids: string[]): Promise { + const notePrimaryAlias = this.knex(TableAlias) + .where( + FieldNameAlias.noteId, + `${TableMediaUpload}.${FieldNameMediaUpload.noteId}`, + ) + .andWhere(FieldNameAlias.isPrimary, true) + .select(FieldNameAlias.alias); + const mediaUploads = await this.knex(TableMediaUpload) + .join( + TableUser, + `${TableUser}.${FieldNameUser.id}`, + `${TableMediaUpload}.${FieldNameMediaUpload.userId}`, + ) + .select( + `${TableMediaUpload}.${FieldNameMediaUpload.uuid}`, + `${TableMediaUpload}.${FieldNameMediaUpload.fileName}`, + `${TableMediaUpload}.${FieldNameMediaUpload.createdAt}`, + `${TableUser}.${FieldNameUser.username}`, + notePrimaryAlias, + ) + .whereIn(FieldNameMediaUpload.uuid, uuids); + return mediaUploads.map((mediaUpload) => ({ + uuid: mediaUpload[FieldNameMediaUpload.uuid], + fileName: mediaUpload[FieldNameMediaUpload.fileName], + noteId: mediaUpload[FieldNameAlias.alias], + createdAt: mediaUpload[FieldNameMediaUpload.createdAt].toISOString(), + username: mediaUpload[FieldNameUser.username], + })); } } diff --git a/backend/src/monitoring/monitoring.service.ts b/backend/src/monitoring/monitoring.service.ts index dcf097623..9bd0981c7 100644 --- a/backend/src/monitoring/monitoring.service.ts +++ b/backend/src/monitoring/monitoring.service.ts @@ -6,7 +6,7 @@ import { ServerStatusDto } from '@hedgedoc/commons'; import { Injectable } from '@nestjs/common'; -import { getServerVersionFromPackageJson } from '../utils/serverVersion'; +import { getServerVersionFromPackageJson } from '../utils/server-version'; @Injectable() export class MonitoringService { diff --git a/backend/src/notes/alias.service.ts b/backend/src/notes/alias.service.ts deleted file mode 100644 index 85bcdb963..000000000 --- a/backend/src/notes/alias.service.ts +++ /dev/null @@ -1,178 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { AliasDto } from '@hedgedoc/commons'; -import { forwardRef, Inject, Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; - -import { - NotInDBError, - PrimaryAliasDeletionForbiddenError, -} from '../errors/errors'; -import { ConsoleLoggerService } from '../logger/console-logger.service'; -import { Alias } from './alias.entity'; -import { Note } from './note.entity'; -import { NotesService } from './notes.service'; -import { getPrimaryAlias } from './utils'; - -@Injectable() -export class AliasService { - constructor( - private readonly logger: ConsoleLoggerService, - @InjectRepository(Note) private noteRepository: Repository, - @InjectRepository(Alias) private aliasRepository: Repository, - @Inject(forwardRef(() => NotesService)) private notesService: NotesService, - ) { - this.logger.setContext(AliasService.name); - } - - /** - * @async - * Add the specified alias to the note. - * @param {Note} note - the note to add the alias to - * @param {string} alias - the alias to add to the note - * @throws {AlreadyInDBError} the alias is already in use. - * @throws {ForbiddenIdError} the requested id or alias is forbidden - * @return {Alias} the new alias - */ - async addAlias(note: Note, alias: string): Promise { - await this.notesService.ensureNoteIdOrAliasIsAvailable(alias); - - let newAlias; - if ((await note.aliases).length === 0) { - // the first alias is automatically made the primary alias - newAlias = Alias.create(alias, note, true); - } else { - newAlias = Alias.create(alias, note, false); - } - (await note.aliases).push(newAlias as Alias); - - await this.noteRepository.save(note); - return newAlias as Alias; - } - - /** - * @async - * Set the specified alias as the primary alias of the note. - * @param {Note} note - the note to change the primary alias - * @param {string} alias - the alias to be the new primary alias of the note - * @throws {ForbiddenIdError} the requested id or alias is forbidden - * @throws {NotInDBError} the alias is not part of this note. - * @return {Alias} the new primary alias - */ - async makeAliasPrimary(note: Note, alias: string): Promise { - let newPrimaryFound = false; - let oldPrimaryId = 0; - let newPrimaryId = 0; - - for (const anAlias of await note.aliases) { - // found old primary - if (anAlias.primary) { - oldPrimaryId = anAlias.id; - } - - // found new primary - if (anAlias.name === alias) { - newPrimaryFound = true; - newPrimaryId = anAlias.id; - } - } - - if (!newPrimaryFound) { - // the provided alias is not already an alias of this note - this.logger.debug( - `The alias '${alias}' is not used by this note.`, - 'makeAliasPrimary', - ); - throw new NotInDBError(`The alias '${alias}' is not used by this note.`); - } - - const oldPrimary = await this.aliasRepository.findOneByOrFail({ - id: oldPrimaryId, - }); - const newPrimary = await this.aliasRepository.findOneByOrFail({ - id: newPrimaryId, - }); - - oldPrimary.primary = false; - newPrimary.primary = true; - - await this.aliasRepository.save(oldPrimary); - await this.aliasRepository.save(newPrimary); - - return newPrimary; - } - - /** - * @async - * Remove the specified alias from the note. - * @param {Note} note - the note to remove the alias from - * @param {string} alias - the alias to remove from the note - * @throws {ForbiddenIdError} the requested id or alias is forbidden - * @throws {NotInDBError} the alias is not part of this note. - * @throws {PrimaryAliasDeletionForbiddenError} the primary alias can only be deleted if it's the only alias - */ - async removeAlias(note: Note, alias: string): Promise { - const primaryAlias = await getPrimaryAlias(note); - const noteAliases = await note.aliases; - - if (primaryAlias === alias && noteAliases.length !== 1) { - this.logger.debug( - `The alias '${alias}' is the primary alias, which can only be removed if it's the only alias.`, - 'removeAlias', - ); - throw new PrimaryAliasDeletionForbiddenError( - `The alias '${alias}' is the primary alias, which can only be removed if it's the only alias.`, - ); - } - - const filteredAliases: Alias[] = []; - let aliasToDelete: Alias | null = null; - let aliasFound = false; - - for (const anAlias of noteAliases) { - if (anAlias.name === alias) { - aliasFound = true; - aliasToDelete = anAlias; - } else { - filteredAliases.push(anAlias); - } - } - - if (!aliasFound) { - this.logger.debug( - `The alias '${alias}' is not used by this note or is the primary alias, which can't be removed.`, - 'removeAlias', - ); - throw new NotInDBError( - `The alias '${alias}' is not used by this note or is the primary alias, which can't be removed.`, - ); - } - - if (aliasToDelete !== null) { - await this.aliasRepository.remove(aliasToDelete); - } - - note.aliases = Promise.resolve(filteredAliases); - return await this.noteRepository.save(note); - } - - /** - * @async - * Build AliasDto from a note. - * @param {Alias} alias - the alias to use - * @param {Note} note - the note to use - * @return {AliasDto} the built AliasDto - * @throws {NotInDBError} the specified alias does not exist - */ - toAliasDto(alias: Alias, note: Note): AliasDto { - return { - name: alias.name, - primaryAlias: alias.primary, - noteId: note.publicId, - }; - } -} diff --git a/backend/src/notes/note.module.ts b/backend/src/notes/note.module.ts new file mode 100644 index 000000000..5475c5e06 --- /dev/null +++ b/backend/src/notes/note.module.ts @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { KnexModule } from 'nest-knexjs'; + +import { GroupsModule } from '../groups/groups.module'; +import { LoggerModule } from '../logger/logger.module'; +import { RealtimeNoteModule } from '../realtime/realtime-note/realtime-note.module'; +import { RevisionsModule } from '../revisions/revisions.module'; +import { UsersModule } from '../users/users.module'; +import { NoteService } from './note.service'; + +@Module({ + imports: [ + RevisionsModule, + UsersModule, + GroupsModule, + LoggerModule, + ConfigModule, + RealtimeNoteModule, + KnexModule, + ], + controllers: [], + providers: [NoteService], + exports: [NoteService], +}) +export class NoteModule {} diff --git a/backend/src/notes/notes.service.spec.ts b/backend/src/notes/note.service.spec.ts similarity index 96% rename from backend/src/notes/notes.service.spec.ts rename to backend/src/notes/note.service.spec.ts index 6b715f16e..2b878fc76 100644 --- a/backend/src/notes/notes.service.spec.ts +++ b/backend/src/notes/note.service.spec.ts @@ -15,6 +15,7 @@ import { Repository, } from 'typeorm'; +import { AliasService } from '../alias/alias.service'; import { ApiToken } from '../api-token/api-token.entity'; import { Identity } from '../auth/identity.entity'; import { Author } from '../authors/author.entity'; @@ -49,16 +50,15 @@ import { RevisionsService } from '../revisions/revisions.service'; import { Session } from '../sessions/session.entity'; import { UsersModule } from '../users/users.module'; import { mockSelectQueryBuilderInRepo } from '../utils/test-utils/mockSelectQueryBuilder'; -import { Alias } from './alias.entity'; -import { AliasService } from './alias.service'; +import { Alias } from './aliases.entity'; import { Note } from './note.entity'; -import { NotesService } from './notes.service'; +import { NoteService } from './note.service'; import { Tag } from './tag.entity'; jest.mock('../revisions/revisions.service'); describe('NotesService', () => { - let service: NotesService; + let service: NoteService; let revisionsService: RevisionsService; const noteMockConfig: NoteConfig = createDefaultMockNoteConfig(); let noteRepo: Repository; @@ -137,7 +137,7 @@ describe('NotesService', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ - NotesService, + NoteService, { provide: RevisionsService, useValue: revisionsService, @@ -219,7 +219,7 @@ describe('NotesService', () => { forbiddenNoteId = noteConfig.forbiddenNoteIds[0]; everyoneDefaultAccessPermission = noteConfig.permissions.default.everyone; loggedinDefaultAccessPermission = noteConfig.permissions.default.loggedIn; - service = module.get(NotesService); + service = module.get(NoteService); noteRepo = module.get>(getRepositoryToken(Note)); aliasRepo = module.get>(getRepositoryToken(Alias)); eventEmitter = module.get(EventEmitter2); @@ -377,7 +377,7 @@ describe('NotesService', () => { mockSelectQueryBuilderInRepo(noteRepo, null); }); - it('without alias, without owner', async () => { + it('without aliases, without owner', async () => { const newNote = await service.createNote(content, null); expect(createRevisionSpy).toHaveBeenCalledWith(newNote, content); @@ -403,7 +403,7 @@ describe('NotesService', () => { expect(await newNote.owner).toBeNull(); expect(await newNote.aliases).toHaveLength(0); }); - it('without alias, with owner', async () => { + it('without aliases, with owner', async () => { const newNote = await service.createNote(content, user); expect(createRevisionSpy).toHaveBeenCalledWith(newNote, content); expect(await newNote.revisions).toStrictEqual([newRevision]); @@ -429,7 +429,7 @@ describe('NotesService', () => { expect(await newNote.owner).toEqual(user); expect(await newNote.aliases).toHaveLength(0); }); - it('with alias, without owner', async () => { + it('with aliases, without owner', async () => { const newNote = await service.createNote(content, null, alias); expect(createRevisionSpy).toHaveBeenCalledWith(newNote, content); expect(await newNote.revisions).toStrictEqual([newRevision]); @@ -454,7 +454,7 @@ describe('NotesService', () => { expect(await newNote.owner).toBeNull(); expect(await newNote.aliases).toHaveLength(1); }); - it('with alias, with owner', async () => { + it('with aliases, with owner', async () => { const newNote = await service.createNote(content, user, alias); expect(createRevisionSpy).toHaveBeenCalledWith(newNote, content); @@ -549,7 +549,7 @@ describe('NotesService', () => { mockSelectQueryBuilderInRepo(noteRepo, null); }); - it('alias is forbidden', async () => { + it('aliases is forbidden', async () => { jest.spyOn(noteRepo, 'existsBy').mockResolvedValueOnce(false); jest.spyOn(aliasRepo, 'existsBy').mockResolvedValueOnce(false); await expect( @@ -557,7 +557,7 @@ describe('NotesService', () => { ).rejects.toThrow(ForbiddenIdError); }); - it('alias is already used (as another alias)', async () => { + it('aliases is already used (as another aliases)', async () => { mockGroupRepo(); jest.spyOn(noteRepo, 'existsBy').mockResolvedValueOnce(false); jest.spyOn(aliasRepo, 'existsBy').mockResolvedValueOnce(true); @@ -569,7 +569,7 @@ describe('NotesService', () => { ); }); - it('alias is already used (as publicId)', async () => { + it('aliases is already used (as publicId)', async () => { mockGroupRepo(); jest.spyOn(noteRepo, 'existsBy').mockResolvedValueOnce(true); jest.spyOn(aliasRepo, 'existsBy').mockResolvedValueOnce(false); @@ -614,20 +614,20 @@ describe('NotesService', () => { const user = User.create('hardcoded', 'Testy') as User; const note = Note.create(user) as Note; mockSelectQueryBuilderInRepo(noteRepo, note); - const foundNote = await service.getNoteByIdOrAlias('noteThatExists'); + const foundNote = await service.getNoteIdByAlias('noteThatExists'); expect(foundNote).toEqual(note); }); describe('fails:', () => { it('no note found', async () => { mockSelectQueryBuilderInRepo(noteRepo, null); await expect( - service.getNoteByIdOrAlias('noteThatDoesNoteExist'), + service.getNoteIdByAlias('noteThatDoesNoteExist'), ).rejects.toThrow(NotInDBError); }); it('id is forbidden', async () => { - await expect( - service.getNoteByIdOrAlias(forbiddenNoteId), - ).rejects.toThrow(ForbiddenIdError); + await expect(service.getNoteIdByAlias(forbiddenNoteId)).rejects.toThrow( + ForbiddenIdError, + ); }); }); }); @@ -704,7 +704,7 @@ describe('NotesService', () => { expect(metadataDto).toMatchSnapshot(); }); - it('returns publicId if no alias exists', async () => { + it('returns publicId if no aliases exists', async () => { const [note, ,] = await getMockData(); const metadataDto = await service.toNoteMetadataDto(note); expect(metadataDto.primaryAddress).toEqual(note.publicId); diff --git a/backend/src/notes/note.service.ts b/backend/src/notes/note.service.ts new file mode 100644 index 000000000..951bb76c8 --- /dev/null +++ b/backend/src/notes/note.service.ts @@ -0,0 +1,369 @@ +/* + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { + NoteDto, + NoteMetadataDto, + NotePermissionsDto, + SpecialGroup, +} from '@hedgedoc/commons'; +import { Inject, Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Knex } from 'knex'; +import { InjectConnection } from 'nest-knexjs'; + +import { AliasService } from '../alias/alias.service'; +import { DefaultAccessLevel } from '../config/default-access-level.enum'; +import noteConfiguration, { NoteConfig } from '../config/note.config'; +import { + FieldNameAlias, + FieldNameNote, + Note, + TableAlias, + TableNote, + User, +} from '../database/types'; +import { + ForbiddenIdError, + GenericDBError, + MaximumDocumentLengthExceededError, + NotInDBError, +} from '../errors/errors'; +import { NoteEventMap } from '../events'; +import { GroupsService } from '../groups/groups.service'; +import { ConsoleLoggerService } from '../logger/console-logger.service'; +import { PermissionService } from '../permissions/permission.service'; +import { RealtimeNoteStore } from '../realtime/realtime-note/realtime-note-store'; +import { RealtimeNoteService } from '../realtime/realtime-note/realtime-note.service'; +import { RevisionsService } from '../revisions/revisions.service'; +import { UsersService } from '../users/users.service'; +import { getPrimaryAlias } from './utils'; + +@Injectable() +export class NoteService { + constructor( + @InjectConnection() + private readonly knex: Knex, + + private readonly logger: ConsoleLoggerService, + @Inject(UsersService) private usersService: UsersService, + @Inject(GroupsService) private groupsService: GroupsService, + private revisionsService: RevisionsService, + @Inject(noteConfiguration.KEY) + private noteConfig: NoteConfig, + @Inject(AliasService) + private aliasService: AliasService, + @Inject(PermissionService) private permissionService: PermissionService, + private realtimeNoteService: RealtimeNoteService, + private realtimeNoteStore: RealtimeNoteStore, + private eventEmitter: EventEmitter2, + ) { + this.logger.setContext(NoteService.name); + } + + /** + * Get all notes owned by a user + * + * @param userId The id of the user who owns the notes + * @return Array of notes owned by the user + */ + async getUserNotes(userId: number): Promise { + // noinspection ES6RedundantAwait + return await this.knex(TableNote) + .select() + .where(FieldNameNote.ownerId, userId); + } + + /** + * Creates a new note + * + * @param noteContent The content of the new note, in most cases an empty string + * @param givenAlias An optional alias the note should have + * @param ownerUserId The owner of the note + * @return The newly created note + * @throws {AlreadyInDBError} a note with the requested id or aliases already exists + * @throws {ForbiddenIdError} the requested id or aliases is forbidden + * @throws {MaximumDocumentLengthExceededError} the noteContent is longer than the maxDocumentLength + * @thorws {GenericDBError} the database returned a non-expected value + */ + async createNote( + noteContent: string, + ownerUserId: number, + givenAlias?: string, + ): Promise { + // Check if new note doesn't violate application constraints + if (noteContent.length > this.noteConfig.maxDocumentLength) { + throw new MaximumDocumentLengthExceededError(); + } + return await this.knex.transaction(async (transaction) => { + // Create note itself in the database + const createdNotes = await transaction(TableNote).insert( + { + [FieldNameNote.ownerId]: ownerUserId, + [FieldNameNote.version]: 2, + }, + [FieldNameNote.id], + ); + + if (createdNotes.length !== 1) { + throw new GenericDBError( + 'The note could not be created in the database', + this.logger.getContext(), + 'createNote', + ); + } + + const noteId = createdNotes[0][FieldNameNote.id]; + + if (givenAlias !== undefined) { + await this.aliasService.ensureAliasIsAvailable(givenAlias, transaction); + } + const newAlias = + givenAlias === undefined + ? this.aliasService.generateRandomAlias() + : givenAlias; + await this.aliasService.addAlias(noteId, newAlias, transaction); + + await this.revisionsService.createRevision( + noteId, + noteContent, + transaction, + ); + + const isUserRegistered = await this.usersService.isRegisteredUser( + ownerUserId, + transaction, + ); + + const everyoneAccessLevel = isUserRegistered + ? // Use the default access level from the config for registered users + this.noteConfig.permissions.default.everyone + : // If the owner is a guest, this is an anonymous note + // Anonymous notes are always writeable by everyone + DefaultAccessLevel.WRITE; + + const loggedInUsersAccessLevel = + this.noteConfig.permissions.default.loggedIn; + + await this.permissionService.setGroupPermission( + noteId, + SpecialGroup.EVERYONE, + everyoneAccessLevel, + transaction, + ); + + await this.permissionService.setGroupPermission( + noteId, + SpecialGroup.LOGGED_IN, + loggedInUsersAccessLevel, + transaction, + ); + + return noteId; + }); + } + + /** + * Get the current content of the note. + * @param noteId the note to use + * @throws {NotInDBError} the note is not in the DB + * @return {string} the content of the note + */ + async getNoteContent(noteId: Note[FieldNameNote.id]): Promise { + const realtimeContent = this.realtimeNoteStore + .find(noteId) + ?.getRealtimeDoc() + .getCurrentContent(); + if (realtimeContent) { + return realtimeContent; + } + + const latestRevision = + await this.revisionsService.getLatestRevision(noteId); + return latestRevision.content; + } + + /** + * Get a note by either their id or aliases. + * @param alias the notes id or aliases + * @throws {NotInDBError} there is no note with this id or aliases + * @throws {ForbiddenIdError} the requested id or aliases is forbidden + * @return the note id + */ + async getNoteIdByAlias(alias: string, transaction?: Knex): Promise { + const dbActor = transaction ?? this.knex; + const isForbidden = this.aliasService.isAliasForbidden(alias); + if (isForbidden) { + throw new ForbiddenIdError( + `The note id or alias '${alias}' is forbidden by the administrator.`, + ); + } + + this.logger.debug(`Trying to find note '${alias}'`, 'getNoteIdByAlias'); + + /** + * This query gets the note's aliases, owner, groupPermissions (and the groups), userPermissions (and the users) and tags and + * then only gets the note, that either has a publicId :noteIdOrAlias or has any aliases with this name. + **/ + const note = await dbActor(TableAlias) + .select>(`${TableNote}.${FieldNameNote.id}`) + .where(FieldNameAlias.alias, alias) + .join( + TableNote, + `${TableAlias}.${FieldNameAlias.noteId}`, + `${TableNote}.${FieldNameNote.id}`, + ) + .first(); + + if (note === undefined) { + const message = `Could not find note '${alias}'`; + this.logger.debug(message, 'getNoteIdByAlias'); + throw new NotInDBError(message); + } + this.logger.debug(`Found note '${alias}'`, 'getNoteIdByAlias'); + return note[FieldNameNote.id]; + } + + /** + * Get all users that ever appeared as an author for the given note + * @param note The note to search authors for + */ + async getAuthorUsers(note: Note): Promise { + // return await this.userRepository + // .createQueryBuilder('user') + // .innerJoin('user.authors', 'author') + // .innerJoin('author.edits', 'edit') + // .innerJoin('edit.revisions', 'revision') + // .innerJoin('revision.note', 'note') + // .where('note.id = :id', { id: note.id }) + // .getMany(); + return []; + } + + /** + * Deletes a note + * + * @param noteId If of the note to delete + * @throws {NotInDBError} if there is no note with this id + */ + async deleteNote(noteId: Note[FieldNameNote.id]): Promise { + const numberOfDeletedNotes = await this.knex(TableNote) + .where(FieldNameNote.id, noteId) + .delete(); + if (numberOfDeletedNotes === 0) { + throw new NotInDBError( + `There is no note with the id '${noteId}' to delete.`, + ); + } + } + + /** + * + * Update the content of a note + * + * @param noteId - the note + * @param noteContent - the new content + * @return the note with a new revision and new content + * @throws {NotInDBError} there is no note with this id or aliases + */ + async updateNote(noteId: number, noteContent: string): Promise { + // TODO Disconnect realtime clients first + await this.revisionsService.createRevision(noteId, noteContent); + // TODO Reload realtime note + } + + /** + * @async + * Calculate the updateUser (for the NoteDto) for a Note. + * @param {Note} noteId - the note to use + * @return {User} user to be used as updateUser in the NoteDto + */ + async getLastUpdatedNoteUser(noteId: number): Promise { + const lastRevision = await this.revisionsService.getLatestRevision(noteId); + // const edits = await lastRevision.edits; + // if (edits.length > 0) { + // // Sort the last Revisions Edits by their updatedAt Date to get the latest one + // // the user of that Edit is the updateUser + // return await ( + // await edits.sort( + // (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime(), + // )[0].author + // ).user; + // } + // // If there are no Edits, the owner is the updateUser + // return await noteId.owner; + return 0; + } + + /** + * Build NotePermissionsDto from a note. + * @param {Note} note - the note to use + * @return {NotePermissionsDto} the built NotePermissionDto + */ + async toNotePermissionsDto(noteId: number): Promise { + const owner = await note.owner; + const userPermissions = await note.userPermissions; + const groupPermissions = await note.groupPermissions; + return { + owner: owner ? owner.username : null, + sharedToUsers: await Promise.all( + userPermissions.map(async (noteUserPermission) => ({ + username: (await noteUserPermission.user).username, + canEdit: noteUserPermission.canEdit, + })), + ), + sharedToGroups: await Promise.all( + groupPermissions.map(async (noteGroupPermission) => ({ + groupName: (await noteGroupPermission.group).name, + canEdit: noteGroupPermission.canEdit, + })), + ), + }; + } + + /** + * @async + * Build NoteMetadataDto from a note. + * @param {Note} note - the note to use + * @return {NoteMetadataDto} the built NoteMetadataDto + */ + async toNoteMetadataDto(note: Note): Promise { + const updateUser = await this.getLastUpdatedNoteUser(note); + const latestRevision = await this.revisionsService.getLatestRevision(note); + return { + id: note.publicId, + aliases: await Promise.all( + (await note.aliases).map((alias) => + this.aliasService.toAliasDto(alias, note), + ), + ), + primaryAddress: (await getPrimaryAlias(note)) ?? note.publicId, + title: latestRevision.title, + description: latestRevision.description, + tags: (await latestRevision.tags).map((tag) => tag.name), + createdAt: note.createdAt, + editedBy: (await this.getAuthorUsers(note)).map((user) => user.username), + permissions: await this.toNotePermissionsDto(note), + version: note.version, + updatedAt: latestRevision.createdAt, + updateUsername: updateUser ? updateUser.username : null, + viewCount: note.viewCount, + }; + } + + /** + * @async + * Build NoteDto from a note. + * @param {Note} note - the note to use + * @return {NoteDto} the built NoteDto + */ + async toNoteDto(note: Note): Promise { + return { + content: await this.getNoteContent(note), + metadata: await this.toNoteMetadataDto(note), + editedByAtPosition: [], + }; + } +} diff --git a/backend/src/notes/notes.module.ts b/backend/src/notes/notes.module.ts deleted file mode 100644 index 68fadeff3..000000000 --- a/backend/src/notes/notes.module.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { TypeOrmModule } from '@nestjs/typeorm'; - -import { User } from '../database/user.entity'; -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 { UsersModule } from '../users/users.module'; -import { Alias } from './alias.entity'; -import { AliasService } from './alias.service'; -import { Note } from './note.entity'; -import { NotesService } from './notes.service'; -import { Tag } from './tag.entity'; - -@Module({ - imports: [ - TypeOrmModule.forFeature([ - Note, - Tag, - NoteGroupPermission, - NoteUserPermission, - User, - Alias, - ]), - RevisionsModule, - UsersModule, - GroupsModule, - LoggerModule, - ConfigModule, - RealtimeNoteModule, - ], - controllers: [], - providers: [NotesService, AliasService], - exports: [NotesService, AliasService], -}) -export class NotesModule {} diff --git a/backend/src/notes/notes.service.ts b/backend/src/notes/notes.service.ts deleted file mode 100644 index af9e9d1be..000000000 --- a/backend/src/notes/notes.service.ts +++ /dev/null @@ -1,455 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { - NoteDto, - NoteMetadataDto, - NotePermissionsDto, -} from '@hedgedoc/commons'; -import { Optional } from '@mrdrogdrog/optional'; -import { forwardRef, Inject, Injectable } from '@nestjs/common'; -import { EventEmitter2 } from '@nestjs/event-emitter'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; - -import { DefaultAccessLevel } from '../config/default-access-level.enum'; -import noteConfiguration, { NoteConfig } from '../config/note.config'; -import { User } from '../database/user.entity'; -import { - AlreadyInDBError, - ForbiddenIdError, - MaximumDocumentLengthExceededError, - NotInDBError, -} from '../errors/errors'; -import { NoteEvent, NoteEventMap } from '../events'; -import { Group } from '../groups/group.entity'; -import { GroupsService } from '../groups/groups.service'; -import { HistoryEntry } from '../history/history-entry.entity'; -import { ConsoleLoggerService } from '../logger/console-logger.service'; -import { NoteGroupPermission } from '../permissions/note-group-permission.entity'; -import { RealtimeNoteStore } from '../realtime/realtime-note/realtime-note-store'; -import { RealtimeNoteService } from '../realtime/realtime-note/realtime-note.service'; -import { RevisionsService } from '../revisions/revisions.service'; -import { UsersService } from '../users/users.service'; -import { Alias } from './alias.entity'; -import { AliasService } from './alias.service'; -import { Note } from './note.entity'; -import { Tag } from './tag.entity'; -import { getPrimaryAlias } from './utils'; - -@Injectable() -export class NotesService { - constructor( - private readonly logger: ConsoleLoggerService, - @InjectRepository(Note) private noteRepository: Repository, - @InjectRepository(Tag) private tagRepository: Repository, - @InjectRepository(Alias) private aliasRepository: Repository, - @InjectRepository(User) private userRepository: Repository, - @Inject(UsersService) private usersService: UsersService, - @Inject(GroupsService) private groupsService: GroupsService, - private revisionsService: RevisionsService, - @Inject(noteConfiguration.KEY) - private noteConfig: NoteConfig, - @Inject(forwardRef(() => AliasService)) private aliasService: AliasService, - private realtimeNoteService: RealtimeNoteService, - private realtimeNoteStore: RealtimeNoteStore, - private eventEmitter: EventEmitter2, - ) { - this.logger.setContext(NotesService.name); - } - - /** - * @async - * Get all notes owned by a user. - * @param {User} user - the user who owns the notes - * @return {Note[]} arary of notes owned by the user - */ - async getUserNotes(user: User): Promise { - const notes = await this.noteRepository - .createQueryBuilder('note') - .where('note.owner = :user', { user: user.id }) - .getMany(); - if (notes === null) { - return []; - } - return notes; - } - - /** - * @async - * Create a new note. - * @param {string} noteContent - the content the new note should have - * @param {string=} alias - an optional alias the note should have - * @param {User=} owner - the owner of the note - * @return {Note} the newly created note - * @throws {AlreadyInDBError} a note with the requested id or alias already exists - * @throws {ForbiddenIdError} the requested id or alias is forbidden - * @throws {MaximumDocumentLengthExceededError} the noteContent is longer than the maxDocumentLength - */ - async createNote( - noteContent: string, - owner: User | null, - alias?: string, - ): Promise { - // Check if new note doesn't violate application constraints - if (alias) { - await this.ensureNoteIdOrAliasIsAvailable(alias); - } - if (noteContent.length > this.noteConfig.maxDocumentLength) { - throw new MaximumDocumentLengthExceededError(); - } - - // We cast to a note early to keep the later code clean - const newNote = Note.create(owner, alias) as Note; - const newRevision = await this.revisionsService.createRevision( - newNote, - noteContent, - ); - newNote.revisions = Promise.resolve( - newRevision === undefined ? [] : [newRevision], - ); - - let everyoneAccessLevel; - - if (owner) { - // When we know an owner, an initial history entry is created - newNote.historyEntries = Promise.resolve([ - HistoryEntry.create(owner, newNote) as HistoryEntry, - ]); - // Use the default access level from the config - everyoneAccessLevel = this.noteConfig.permissions.default.everyone; - } else { - // If there is no owner, this is an anonymous note - // Anonymous notes are always writeable by everyone - everyoneAccessLevel = DefaultAccessLevel.WRITE; - } - - // Create permission object for the 'everyone' group - const everyonePermission = this.createGroupPermission( - newNote, - await this.groupsService.getEveryoneGroup(), - everyoneAccessLevel, - ); - - // Create permission object for logged-in users - const loggedInPermission = this.createGroupPermission( - newNote, - await this.groupsService.getLoggedInGroup(), - this.noteConfig.permissions.default.loggedIn, - ); - - // Merge into permissions array - newNote.groupPermissions = Promise.resolve([ - ...Optional.ofNullable(everyonePermission).wrapInArray(), - ...Optional.ofNullable(loggedInPermission).wrapInArray(), - ]); - - try { - return await this.noteRepository.save(newNote); - } catch (e) { - if (alias) { - this.logger.debug( - `A note with the alias '${alias}' already exists.`, - 'createNote', - ); - throw new AlreadyInDBError( - `A note with the alias '${alias}' already exists.`, - ); - } else { - throw e; - } - } - } - - private createGroupPermission( - note: Note, - group: Group, - accessLevel: DefaultAccessLevel, - ): NoteGroupPermission | null { - if (accessLevel === DefaultAccessLevel.NONE) { - return null; - } - return NoteGroupPermission.create( - group, - note, - accessLevel === DefaultAccessLevel.WRITE, - ); - } - - /** - * @async - * Get the current content of the note. - * @param {Note} note - the note to use - * @return {string} the content of the note - */ - async getNoteContent(note: Note): Promise { - return ( - this.realtimeNoteStore - .find(note.id) - ?.getRealtimeDoc() - .getCurrentContent() ?? - (await this.revisionsService.getLatestRevision(note)).content - ); - } - - /** - * @async - * Get a note by either their id or alias. - * @param {string} noteIdOrAlias - the notes id or alias - * @return {Note} the note - * @throws {NotInDBError} there is no note with this id or alias - * @throws {ForbiddenIdError} the requested id or alias is forbidden - */ - async getNoteByIdOrAlias(noteIdOrAlias: string): Promise { - const isForbidden = this.noteIdOrAliasIsForbidden(noteIdOrAlias); - if (isForbidden) { - throw new ForbiddenIdError( - `The note id or alias '${noteIdOrAlias}' is forbidden by the administrator.`, - ); - } - - this.logger.debug( - `Trying to find note '${noteIdOrAlias}'`, - 'getNoteByIdOrAlias', - ); - - /** - * This query gets the note's aliases, owner, groupPermissions (and the groups), userPermissions (and the users) and tags and - * then only gets the note, that either has a publicId :noteIdOrAlias or has any alias with this name. - **/ - const note = await this.noteRepository - .createQueryBuilder('note') - .leftJoinAndSelect('note.aliases', 'alias') - .leftJoinAndSelect('note.owner', 'owner') - .leftJoinAndSelect('note.groupPermissions', 'group_permission') - .leftJoinAndSelect('group_permission.group', 'group') - .leftJoinAndSelect('note.userPermissions', 'user_permission') - .leftJoinAndSelect('user_permission.user', 'user') - .where('note.publicId = :noteIdOrAlias') - .orWhere((queryBuilder) => { - const subQuery = queryBuilder - .subQuery() - .select('alias.noteId') - .from(Alias, 'alias') - .where('alias.name = :noteIdOrAlias') - .getQuery(); - return 'note.id IN ' + subQuery; - }) - .setParameter('noteIdOrAlias', noteIdOrAlias) - .getOne(); - - if (note === null) { - this.logger.debug( - `Could not find note '${noteIdOrAlias}'`, - 'getNoteByIdOrAlias', - ); - throw new NotInDBError( - `Note with id/alias '${noteIdOrAlias}' not found.`, - ); - } - this.logger.debug(`Found note '${noteIdOrAlias}'`, 'getNoteByIdOrAlias'); - return note; - } - - /** - * @async - * Get all users that ever appeared as an author for the given note - * @param note The note to search authors for - */ - async getAuthorUsers(note: Note): Promise { - return await this.userRepository - .createQueryBuilder('user') - .innerJoin('user.authors', 'author') - .innerJoin('author.edits', 'edit') - .innerJoin('edit.revisions', 'revision') - .innerJoin('revision.note', 'note') - .where('note.id = :id', { id: note.id }) - .getMany(); - } - - /** - * Check if the provided note id or alias is available for notes - * @param noteIdOrAlias - the alias or id in question - * @throws {ForbiddenIdError} the requested id or alias is not available - */ - async ensureNoteIdOrAliasIsAvailable(noteIdOrAlias: string): Promise { - const isForbidden = this.noteIdOrAliasIsForbidden(noteIdOrAlias); - if (isForbidden) { - throw new ForbiddenIdError( - `The note id or alias '${noteIdOrAlias}' is forbidden by the administrator.`, - ); - } - const isUsed = await this.noteIdOrAliasIsUsed(noteIdOrAlias); - if (isUsed) { - throw new AlreadyInDBError( - `A note with the id or alias '${noteIdOrAlias}' already exists.`, - ); - } - } - - /** - * Check if the provided note id or alias is forbidden - * @param noteIdOrAlias - the alias or id in question - * @return {boolean} true if the id or alias is forbidden, false otherwise - */ - noteIdOrAliasIsForbidden(noteIdOrAlias: string): boolean { - const forbidden = this.noteConfig.forbiddenNoteIds.includes(noteIdOrAlias); - if (forbidden) { - this.logger.debug( - `A note with the alias '${noteIdOrAlias}' is forbidden by the administrator.`, - 'noteIdOrAliasIsForbidden', - ); - } - return forbidden; - } - - /** - * @async - * Check if the provided note id or alias is already used - * @param noteIdOrAlias - the alias or id in question - * @return {boolean} true if the id or alias is already used, false otherwise - */ - async noteIdOrAliasIsUsed(noteIdOrAlias: string): Promise { - const noteWithPublicIdExists = await this.noteRepository.existsBy({ - publicId: noteIdOrAlias, - }); - const noteWithAliasExists = await this.aliasRepository.existsBy({ - name: noteIdOrAlias, - }); - if (noteWithPublicIdExists || noteWithAliasExists) { - this.logger.debug( - `A note with the id or alias '${noteIdOrAlias}' already exists.`, - 'noteIdOrAliasIsUsed', - ); - return true; - } - return false; - } - - /** - * @async - * Delete a note - * @param {Note} note - the note to delete - * @return {Note} the note, that was deleted - * @throws {NotInDBError} there is no note with this id or alias - */ - async deleteNote(note: Note): Promise { - this.eventEmitter.emit(NoteEvent.DELETION, note.id); - return await this.noteRepository.remove(note); - } - - /** - * @async - * Update a notes content. - * @param {Note} note - the note - * @param {string} noteContent - the new content - * @return {Note} the note with a new revision and new content - * @throws {NotInDBError} there is no note with this id or alias - */ - async updateNote(note: Note, noteContent: string): Promise { - const revisions = await note.revisions; - const newRevision = await this.revisionsService.createRevision( - note, - noteContent, - ); - if (newRevision !== undefined) { - revisions.push(newRevision); - note.revisions = Promise.resolve(revisions); - } - return await this.noteRepository.save(note); - } - - /** - * @async - * Calculate the updateUser (for the NoteDto) for a Note. - * @param {Note} note - the note to use - * @return {User} user to be used as updateUser in the NoteDto - */ - async calculateUpdateUser(note: Note): Promise { - const lastRevision = await this.revisionsService.getLatestRevision(note); - const edits = await lastRevision.edits; - if (edits.length > 0) { - // Sort the last Revisions Edits by their updatedAt Date to get the latest one - // the user of that Edit is the updateUser - return await ( - await edits.sort( - (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime(), - )[0].author - ).user; - } - // If there are no Edits, the owner is the updateUser - return await note.owner; - } - - /** - * Build NotePermissionsDto from a note. - * @param {Note} note - the note to use - * @return {NotePermissionsDto} the built NotePermissionDto - */ - async toNotePermissionsDto(note: Note): Promise { - const owner = await note.owner; - const userPermissions = await note.userPermissions; - const groupPermissions = await note.groupPermissions; - return { - owner: owner ? owner.username : null, - sharedToUsers: await Promise.all( - userPermissions.map(async (noteUserPermission) => ({ - username: (await noteUserPermission.user).username, - canEdit: noteUserPermission.canEdit, - })), - ), - sharedToGroups: await Promise.all( - groupPermissions.map(async (noteGroupPermission) => ({ - groupName: (await noteGroupPermission.group).name, - canEdit: noteGroupPermission.canEdit, - })), - ), - }; - } - - /** - * @async - * Build NoteMetadataDto from a note. - * @param {Note} note - the note to use - * @return {NoteMetadataDto} the built NoteMetadataDto - */ - async toNoteMetadataDto(note: Note): Promise { - const updateUser = await this.calculateUpdateUser(note); - const latestRevision = await this.revisionsService.getLatestRevision(note); - return { - id: note.publicId, - aliases: await Promise.all( - (await note.aliases).map((alias) => - this.aliasService.toAliasDto(alias, note), - ), - ), - primaryAddress: (await getPrimaryAlias(note)) ?? note.publicId, - title: latestRevision.title, - description: latestRevision.description, - tags: (await latestRevision.tags).map((tag) => tag.name), - createdAt: note.createdAt.toISOString(), - editedBy: (await this.getAuthorUsers(note)).map((user) => user.username), - permissions: await this.toNotePermissionsDto(note), - version: note.version, - updatedAt: latestRevision.createdAt.toISOString(), - updateUsername: updateUser ? updateUser.username : null, - viewCount: note.viewCount, - }; - } - - /** - * @async - * Build NoteDto from a note. - * @param {Note} note - the note to use - * @return {NoteDto} the built NoteDto - */ - async toNoteDto(note: Note): Promise { - return { - content: await this.getNoteContent(note), - metadata: await this.toNoteMetadataDto(note), - editedByAtPosition: [], - }; - } -} diff --git a/backend/src/notes/utils.spec.ts b/backend/src/notes/utils.spec.ts index 4ee601477..9d58758c3 100644 --- a/backend/src/notes/utils.spec.ts +++ b/backend/src/notes/utils.spec.ts @@ -1,14 +1,14 @@ /* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ import { randomBytes } from 'crypto'; import { User } from '../database/user.entity'; -import { Alias } from './alias.entity'; +import { Alias } from './aliases.entity'; import { Note } from './note.entity'; -import { generatePublicId, getPrimaryAlias } from './utils'; +import { generateRandomAlias, getPrimaryAlias } from './utils'; jest.mock('crypto'); const random128bitBuffer = Buffer.from([ @@ -19,7 +19,7 @@ const mockRandomBytes = randomBytes as jest.MockedFunction; mockRandomBytes.mockImplementation((_) => random128bitBuffer); it('generatePublicId', () => { - expect(generatePublicId()).toEqual('w5trddy3zc1tj9mzs7b8rbbvfc'); + expect(generateRandomAlias()).toEqual('w5trddy3zc1tj9mzs7b8rbbvfc'); }); describe('getPrimaryAlias', () => { @@ -29,11 +29,11 @@ describe('getPrimaryAlias', () => { const user = User.create('hardcoded', 'Testy') as User; note = Note.create(user, alias) as Note; }); - it('finds correct primary alias', async () => { + it('finds correct primary aliases', async () => { (await note.aliases).push(Alias.create('annother', note, false) as Alias); expect(await getPrimaryAlias(note)).toEqual(alias); }); - it('returns undefined if there is no alias', async () => { + it('returns undefined if there is no aliases', async () => { (await note.aliases)[0].primary = false; expect(await getPrimaryAlias(note)).toEqual(undefined); }); diff --git a/backend/src/notes/utils.ts b/backend/src/notes/utils.ts index ed87d1cfc..f8e780ead 100644 --- a/backend/src/notes/utils.ts +++ b/backend/src/notes/utils.ts @@ -1,26 +1,17 @@ /* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ import base32Encode from 'base32-encode'; import { randomBytes } from 'crypto'; -import { Alias } from './alias.entity'; +import { Alias } from './aliases.entity'; import { Note } from './note.entity'; /** - * Generate publicId for a note. - * This is a randomly generated 128-bit value encoded with base32-encode using the crockford variant and converted to lowercase. - */ -export function generatePublicId(): string { - const randomId = randomBytes(16); - return base32Encode(randomId, 'Crockford').toLowerCase(); -} - -/** - * Extract the primary alias from a aliases of a note - * @param {Note} note - the note from which the primary alias should be extracted + * Extract the primary aliases from a aliases of a note + * @param {Note} note - the note from which the primary aliases should be extracted */ export async function getPrimaryAlias(note: Note): Promise { const listWithPrimaryAlias = (await note.aliases).filter( diff --git a/backend/src/permissions/note-permission.enum.spec.ts b/backend/src/permissions/note-permission.enum.spec.ts index 303b14832..a317c72d7 100644 --- a/backend/src/permissions/note-permission.enum.spec.ts +++ b/backend/src/permissions/note-permission.enum.spec.ts @@ -1,32 +1,32 @@ /* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ import { - getNotePermissionDisplayName, - NotePermission, + getNotePermissionLevelDisplayName, + NotePermissionLevel, } from './note-permission.enum'; describe('note permission order', () => { it('DENY is less than READ', () => { - expect(NotePermission.DENY < NotePermission.READ).toBeTruthy(); + expect(NotePermissionLevel.DENY < NotePermissionLevel.READ).toBeTruthy(); }); it('READ is less than WRITE', () => { - expect(NotePermission.READ < NotePermission.WRITE).toBeTruthy(); + expect(NotePermissionLevel.READ < NotePermissionLevel.WRITE).toBeTruthy(); }); it('WRITE is less than OWNER', () => { - expect(NotePermission.WRITE < NotePermission.OWNER).toBeTruthy(); + expect(NotePermissionLevel.WRITE < NotePermissionLevel.OWNER).toBeTruthy(); }); }); describe('getNotePermissionDisplayName', () => { it.each([ - ['deny', NotePermission.DENY], - ['read', NotePermission.READ], - ['write', NotePermission.WRITE], - ['owner', NotePermission.OWNER], + ['deny', NotePermissionLevel.DENY], + ['read', NotePermissionLevel.READ], + ['write', NotePermissionLevel.WRITE], + ['owner', NotePermissionLevel.OWNER], ])('displays %s correctly', (displayName, permission) => { - expect(getNotePermissionDisplayName(permission)).toBe(displayName); + expect(getNotePermissionLevelDisplayName(permission)).toBe(displayName); }); }); diff --git a/backend/src/permissions/note-permission.enum.ts b/backend/src/permissions/note-permission.enum.ts index 76ae51307..7165c499d 100644 --- a/backend/src/permissions/note-permission.enum.ts +++ b/backend/src/permissions/note-permission.enum.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -7,7 +7,7 @@ /** * Defines if a user can access a note and if yes how much power they have. */ -export enum NotePermission { +export enum NotePermissionLevel { DENY = 0, READ = 1, WRITE = 2, @@ -15,20 +15,22 @@ export enum NotePermission { } /** - * Returns the display name for the given {@link NotePermission}. + * Returns the display name for the given {@link NotePermissionLevel}. * - * @param {NotePermission} value the note permission to display + * @param {NotePermissionLevel} value the note permission to display * @return {string} The display name */ -export function getNotePermissionDisplayName(value: NotePermission): string { +export function getNotePermissionLevelDisplayName( + value: NotePermissionLevel, +): string { switch (value) { - case NotePermission.DENY: + case NotePermissionLevel.DENY: return 'deny'; - case NotePermission.READ: + case NotePermissionLevel.READ: return 'read'; - case NotePermission.WRITE: + case NotePermissionLevel.WRITE: return 'write'; - case NotePermission.OWNER: + case NotePermissionLevel.OWNER: return 'owner'; } } diff --git a/backend/src/permissions/permission.service.ts b/backend/src/permissions/permission.service.ts new file mode 100644 index 000000000..290ea80aa --- /dev/null +++ b/backend/src/permissions/permission.service.ts @@ -0,0 +1,474 @@ +/* + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { + NotePermissionsDto, + PermissionLevel, + SpecialGroup, +} from '@hedgedoc/commons'; +import { Inject, Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Knex } from 'knex'; +import { InjectConnection } from 'nest-knexjs'; + +import noteConfiguration, { NoteConfig } from '../config/note.config'; +import { + FieldNameGroup, + FieldNameGroupUser, + FieldNameMediaUpload, + FieldNameNote, + FieldNameNoteGroupPermission, + FieldNameNoteUserPermission, + FieldNameUser, + Note, + TableGroup, + TableGroupUser, + TableMediaUpload, + TableNote, + TableNoteGroupPermission, + TableNoteUserPermission, + TableUser, + User, +} from '../database/types'; +import { GenericDBError, NotInDBError } from '../errors/errors'; +import { NoteEvent, NoteEventMap } from '../events'; +import { ConsoleLoggerService } from '../logger/console-logger.service'; +import { NotePermissionLevel } from './note-permission.enum'; +import { convertEditabilityToPermissionLevel } from './utils/convert-editability-to-note-permission-level'; +import { convertPermissionLevelToNotePermissionLevel } from './utils/convert-guest-access-to-note-permission-level'; + +@Injectable() +export class PermissionService { + constructor( + @InjectConnection() + private readonly knex: Knex, + + private readonly logger: ConsoleLoggerService, + + @Inject(noteConfiguration.KEY) + private noteConfig: NoteConfig, + + private eventEmitter: EventEmitter2, + ) {} + + /** + * Checks whether a given user has the permission to remove a given upload + * + * @param userId The id of the user who wants to delete an upload + * @param mediaUploadUuid The uuid of the upload + */ + public async checkMediaDeletePermission( + userId: number, + mediaUploadUuid: string, + ): Promise { + const mediaUploadAndNote = await this.knex(TableMediaUpload) + .join( + TableNote, + `${TableMediaUpload}.${FieldNameMediaUpload.noteId}`, + '=', + `${TableNote}.${FieldNameNote.id}`, + ) + .select(FieldNameMediaUpload.userId, FieldNameNote.ownerId) + .where(FieldNameMediaUpload.uuid, mediaUploadUuid) + .first(); + + if (!mediaUploadAndNote) { + throw new NotInDBError( + `There is no upload with the id ${mediaUploadUuid}`, + this.logger.getContext(), + 'checkMediaDeletePermission', + ); + } + + return ( + mediaUploadAndNote[FieldNameMediaUpload.userId] === userId || + mediaUploadAndNote[FieldNameNote.ownerId] === userId + ); + } + + /** + * Checks if the given {@link User} is allowed to create notes. + * + * @param userId - The user whose permission should be checked. Value is null if guest access should be checked + * @return if the user is allowed to create notes + */ + public mayCreate(userId: number | null): boolean { + return ( + userId !== null || this.noteConfig.guestAccess === PermissionLevel.CREATE + ); + } + + /** + * Checks if the given {@link User} is the owner of a note + * + * @param userId The id of the user + * @param noteId The id of the note + * @param transaction Optional transaction to use + * @return true if the user is the owner of the note + */ + async isOwner( + userId: number | null, + noteId: number, + transaction?: Knex, + ): Promise { + if (userId === null) { + return false; + } + const dbActor = transaction ? transaction : this.knex; + const ownerId = await dbActor(TableNote) + .select(FieldNameNote.ownerId) + .where(FieldNameNote.id, noteId) + .first(); + if (ownerId === undefined) { + throw new NotInDBError( + `There is no note with id ${noteId}`, + this.logger.getContext(), + 'isOwner', + ); + } + return ownerId[FieldNameNote.ownerId] === userId; + } + + /** + * Determines the {@link NotePermission permission} of the user on the given {@link Note}. + * + * @param {number | null} userId The user whose permission should be checked + * @param {number} noteId The note that is accessed by the given user + * @return {Promise} The determined permission + */ + public async determinePermission( + userId: number | null, + noteId: number, + ): Promise { + if (userId === null) { + return await this.determineNotePermissionLevelForGuest(noteId); + } + + return await this.knex.transaction(async (transaction) => { + if (await this.isOwner(userId, noteId, transaction)) { + return NotePermissionLevel.OWNER; + } + const userPermission = await this.determineNotePermissionLevelForUser( + userId, + noteId, + transaction, + ); + if (userPermission === NotePermissionLevel.WRITE) { + return userPermission; + } + const groupPermission = + await this.determineHighestNotePermissionLevelOfGroups( + userId, + noteId, + transaction, + ); + return groupPermission > userPermission + ? groupPermission + : userPermission; + }); + } + + /** + * Determines the access level for a given user to a given note + * + * @param userId The id of the user who wants access + * @param noteId The id of the note for which access is checked + * @param transaction The optional database transaction to use + * @private + */ + private async determineNotePermissionLevelForUser( + userId: number, + noteId: number, + transaction?: Knex, + ): Promise { + const dbActor = transaction ? transaction : this.knex; + const userPermissions = await dbActor(TableNoteUserPermission) + .select(FieldNameNoteUserPermission.canEdit) + .where(FieldNameNoteUserPermission.noteId, noteId) + .andWhere(FieldNameNoteUserPermission.userId, userId) + .first(); + if (userPermissions === undefined) { + return NotePermissionLevel.DENY; + } + return convertEditabilityToPermissionLevel( + userPermissions[FieldNameNoteUserPermission.canEdit], + ); + } + + /** + * Determines the access level for a given user to a given note + * + * @param userId The id of the user who wants access + * @param noteId The id of the note for which access is checked + * @param transaction The optional database transaction to use + * @private + */ + private async determineHighestNotePermissionLevelOfGroups( + userId: number, + noteId: number, + transaction?: Knex, + ): Promise { + const dbActor = transaction ? transaction : this.knex; + + // 1. Get all groups the user is member of + const groupsOfUser = await dbActor(TableGroupUser) + .select(FieldNameGroupUser.groupId) + .where(FieldNameGroupUser.userId, userId); + if (groupsOfUser === undefined) { + return NotePermissionLevel.DENY; + } + const groupIds = groupsOfUser.map( + (groupOfUser) => groupOfUser[FieldNameGroupUser.groupId], + ); + + // 2. Get all permissions on the note for groups the user is member of + const groupPermissions = await dbActor(TableNoteGroupPermission) + .select(FieldNameNoteGroupPermission.canEdit) + .whereIn(FieldNameNoteGroupPermission.groupId, groupIds) + .andWhere(FieldNameNoteGroupPermission.noteId, noteId); + if (groupPermissions === undefined) { + return NotePermissionLevel.DENY; + } + + const permissionLevels = groupPermissions.map((permission) => + convertEditabilityToPermissionLevel( + permission[FieldNameNoteGroupPermission.canEdit], + ), + ); + return Math.max(...permissionLevels); + } + + /** + * Determines whether guests have access to a note or not and if so with which level of permission + * @param noteId The id of the note to check + * @private + */ + private async determineNotePermissionLevelForGuest( + noteId: number, + ): Promise { + if (this.noteConfig.guestAccess === PermissionLevel.DENY) { + return NotePermissionLevel.DENY; + } + + const everyonePermission = await this.knex(TableNoteGroupPermission) + .select(FieldNameNoteGroupPermission.canEdit) + .where(FieldNameNoteGroupPermission.noteId, noteId) + .andWhere(FieldNameNoteGroupPermission.groupId, SpecialGroup.EVERYONE) + .first(); + + if (everyonePermission === undefined) { + return NotePermissionLevel.DENY; + } + const notePermission = everyonePermission[ + FieldNameNoteGroupPermission.canEdit + ] + ? NotePermissionLevel.WRITE + : NotePermissionLevel.READ; + + // Make sure we don't allow more permissions than allowed in the config, even if they come from the DB + const configuredGuestNotePermissionLevel = + convertPermissionLevelToNotePermissionLevel(this.noteConfig.guestAccess); + return configuredGuestNotePermissionLevel < notePermission + ? configuredGuestNotePermissionLevel + : notePermission; + } + + /** + * Broadcasts a permission change event for the given note id + * @param noteId The id of the note for which permissions changed + * @private + */ + private notifyOthers(noteId: number): void { + this.eventEmitter.emit(NoteEvent.PERMISSION_CHANGE, noteId); + } + + /** + * Set permission for a specific user on a note. + * @param noteId the note + * @param userId the user for which the permission should be set + * @param canEdit specifies if the user can edit the note + * @return the note with the new permission + */ + async setUserPermission( + noteId: number, + userId: number, + canEdit: boolean, + ): Promise { + if (await this.isOwner(userId, noteId)) { + return; + } + await this.knex(TableNoteUserPermission) + .insert({ + [FieldNameNoteUserPermission.userId]: userId, + [FieldNameNoteUserPermission.noteId]: noteId, + [FieldNameNoteUserPermission.canEdit]: canEdit, + }) + .onConflict([ + FieldNameNoteUserPermission.noteId, + FieldNameNoteUserPermission.userId, + ]) + .merge(); + this.notifyOthers(noteId); + } + + /** + * Remove permission for a specific user on a note. + * @param noteId the note + * @param userId - the userId for which the permission should be set + * @throws NotInDBError if the user did not have the permission already + */ + async removeUserPermission(noteId: number, userId: number): Promise { + const result = await this.knex(TableNoteUserPermission) + .where(FieldNameNoteUserPermission.noteId, noteId) + .andWhere(FieldNameNoteUserPermission.userId, userId) + .delete(); + if (result !== 0) { + throw new NotInDBError( + `The user does not have a permission on this note.`, + this.logger.getContext(), + 'removeUserPermission', + ); + } + this.notifyOthers(noteId); + } + + /** + * Set permission for a specific group on a note. + * @param noteId - the if of the note + * @param groupId - the name of the group for which the permission should be set + * @param canEdit - specifies if the group can edit the note + */ + async setGroupPermission( + noteId: number, + groupId: number, + canEdit: boolean, + ): Promise { + await this.knex(TableNoteGroupPermission) + .insert({ + [FieldNameNoteGroupPermission.groupId]: groupId, + [FieldNameNoteGroupPermission.noteId]: noteId, + [FieldNameNoteGroupPermission.canEdit]: canEdit, + }) + .onConflict([ + FieldNameNoteGroupPermission.noteId, + FieldNameNoteGroupPermission.groupId, + ]) + .merge(); + this.notifyOthers(noteId); + } + + /** + * Remove permission for a specific group on a note. + * @param noteId - the note + * @param groupId - the group for which the permission should be set + * @return the note with the new permission + */ + async removeGroupPermission(noteId: number, groupId: number): Promise { + const result = await this.knex(TableNoteGroupPermission) + .where(FieldNameNoteGroupPermission.noteId, noteId) + .andWhere(FieldNameNoteGroupPermission.groupId, groupId) + .delete(); + if (result !== 0) { + throw new NotInDBError( + `The group does not have a permission on this note.`, + this.logger.getContext(), + 'removeUserPermission', + ); + } + this.notifyOthers(noteId); + } + + /** + * Updates the owner of a note. + * @param noteId - the note to use + * @param newOwnerId - the new owner + * @return the updated note + */ + async changeOwner(noteId: number, newOwnerId: number): Promise { + const result = await this.knex(TableNote) + .update({ + [FieldNameNote.ownerId]: newOwnerId, + }) + .where(FieldNameNote.id, noteId); + if (result === 0) { + throw new NotInDBError( + 'The user id of the new owner or the note id does not exist', + ); + } + this.notifyOthers(noteId); + } + + async getPermissionsForNote(noteId: number): Promise { + return await this.knex.transaction(async (transaction) => { + const owner = (await transaction(TableNote) + .join( + TableUser, + `${TableUser}.${FieldNameUser.id}`, + `${TableNote}.${FieldNameNote.ownerId}`, + ) + .select(`${TableUser}.${FieldNameUser.username}`) + .where(FieldNameNote.id, noteId) + .first()) as { [FieldNameUser.username]: string } | undefined; + + const userPermissions: + | { + [FieldNameUser.username]: string; + [FieldNameNoteUserPermission.canEdit]: boolean; + }[] + | undefined = await transaction(TableNoteUserPermission) + .join( + TableUser, + `${TableUser}.${FieldNameUser.id}`, + `${TableNoteUserPermission}.${FieldNameNoteUserPermission.userId}`, + ) + .select( + `${TableUser}.${FieldNameUser.username}`, + `${TableNoteUserPermission}.${FieldNameNoteUserPermission.canEdit}`, + ) + .where(FieldNameNoteUserPermission.noteId, noteId); + + const groupPermissions: + | { + [FieldNameGroup.name]: string; + [FieldNameNoteGroupPermission.canEdit]: boolean; + }[] + | undefined = await transaction(TableNoteGroupPermission) + .join( + TableGroup, + `${TableGroup}.${FieldNameGroup.id}`, + `${TableNoteGroupPermission}.${FieldNameNoteGroupPermission.groupId}`, + ) + .select( + `${TableGroup}.${FieldNameGroup.name}`, + `${TableNoteGroupPermission}.${FieldNameNoteGroupPermission.canEdit}`, + ) + .where(FieldNameNoteGroupPermission.noteId, noteId); + + if ( + owner === undefined || + userPermissions === undefined || + groupPermissions === undefined + ) { + throw new GenericDBError( + 'Invalid Database State. This should not happen.', + this.logger.getContext(), + 'getPermissionsForNote', + ); + } + + return { + owner: owner[FieldNameUser.username], + sharedToUsers: userPermissions.map((userPermission) => ({ + username: userPermission[FieldNameUser.username], + canEdit: userPermission[FieldNameNoteUserPermission.canEdit], + })), + sharedToGroups: groupPermissions.map((groupPermission) => ({ + groupName: groupPermission[FieldNameGroup.name], + canEdit: groupPermission[FieldNameNoteGroupPermission.canEdit], + })), + }; + }); + } +} diff --git a/backend/src/permissions/permissions.guard.spec.ts b/backend/src/permissions/permissions.guard.spec.ts index a6ffacee0..5762daca2 100644 --- a/backend/src/permissions/permissions.guard.spec.ts +++ b/backend/src/permissions/permissions.guard.spec.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -7,33 +7,33 @@ import { ExecutionContext } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { Mock } from 'ts-mockery'; -import * as ExtractNoteIdOrAliasModule from '../api/utils/extract-note-from-request'; +import * as ExtractNoteIdOrAliasModule from '../api/utils/extract-note-id-from-request'; import { CompleteRequest } from '../api/utils/request.type'; import { User } from '../database/user.entity'; import { ConsoleLoggerService } from '../logger/console-logger.service'; import { Note } from '../notes/note.entity'; import { - getNotePermissionDisplayName, - NotePermission, + getNotePermissionLevelDisplayName, + NotePermissionLevel, } from './note-permission.enum'; +import { PermissionService } from './permission.service'; import { PermissionsGuard } from './permissions.guard'; -import { PermissionsService } from './permissions.service'; import { PERMISSION_METADATA_KEY } from './require-permission.decorator'; import { RequiredPermission } from './required-permission.enum'; -jest.mock('../api/utils/extract-note-from-request'); +jest.mock('../api/utils/extract-note-id-from-request'); describe('permissions guard', () => { let loggerService: ConsoleLoggerService; let reflector: Reflector; let handler: () => void; - let permissionsService: PermissionsService; + let permissionsService: PermissionService; let requiredPermission: RequiredPermission | undefined; let createAllowed = false; let requestUser: User | undefined; let context: ExecutionContext; let permissionGuard: PermissionsGuard; - let determinedPermission: NotePermission; + let determinedPermission: NotePermissionLevel; let mockedNote: Note; beforeEach(() => { @@ -48,7 +48,7 @@ describe('permissions guard', () => { handler = jest.fn(); - permissionsService = Mock.of({ + permissionsService = Mock.of({ mayCreate: jest.fn(() => createAllowed), determinePermission: jest.fn(() => Promise.resolve(determinedPermission)), }); @@ -68,7 +68,7 @@ describe('permissions guard', () => { }); mockedNote = Mock.of({}); jest - .spyOn(ExtractNoteIdOrAliasModule, 'extractNoteFromRequest') + .spyOn(ExtractNoteIdOrAliasModule, 'extractNoteIdFromRequest') .mockReturnValue(Promise.resolve(mockedNote)); permissionGuard = new PermissionsGuard( @@ -133,9 +133,9 @@ describe('permissions guard', () => { }); }); - it('will deny if no note alias is present', async () => { + it('will deny if no note aliases is present', async () => { jest - .spyOn(ExtractNoteIdOrAliasModule, 'extractNoteFromRequest') + .spyOn(ExtractNoteIdOrAliasModule, 'extractNoteIdFromRequest') .mockReturnValue(Promise.resolve(undefined)); requiredPermission = RequiredPermission.READ; @@ -151,9 +151,21 @@ describe('permissions guard', () => { }); describe.each([ - [RequiredPermission.READ, NotePermission.READ, NotePermission.DENY], - [RequiredPermission.WRITE, NotePermission.WRITE, NotePermission.READ], - [RequiredPermission.OWNER, NotePermission.OWNER, NotePermission.WRITE], + [ + RequiredPermission.READ, + NotePermissionLevel.READ, + NotePermissionLevel.DENY, + ], + [ + RequiredPermission.WRITE, + NotePermissionLevel.WRITE, + NotePermissionLevel.READ, + ], + [ + RequiredPermission.OWNER, + NotePermissionLevel.OWNER, + NotePermissionLevel.WRITE, + ], ])( 'with required permission %s', ( @@ -161,12 +173,10 @@ describe('permissions guard', () => { sufficientNotePermission, notEnoughNotePermission, ) => { - const sufficientNotePermissionDisplayName = getNotePermissionDisplayName( - sufficientNotePermission, - ); - const notEnoughNotePermissionDisplayName = getNotePermissionDisplayName( - notEnoughNotePermission, - ); + const sufficientNotePermissionDisplayName = + getNotePermissionLevelDisplayName(sufficientNotePermission); + const notEnoughNotePermissionDisplayName = + getNotePermissionLevelDisplayName(notEnoughNotePermission); beforeEach(() => { requiredPermission = shouldRequiredPermission; diff --git a/backend/src/permissions/permissions.guard.ts b/backend/src/permissions/permissions.guard.ts index 5952ab826..28f68bb1c 100644 --- a/backend/src/permissions/permissions.guard.ts +++ b/backend/src/permissions/permissions.guard.ts @@ -1,17 +1,17 @@ /* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; -import { extractNoteFromRequest } from '../api/utils/extract-note-from-request'; +import { extractNoteIdFromRequest } from '../api/utils/extract-note-id-from-request'; import { CompleteRequest } from '../api/utils/request.type'; import { ConsoleLoggerService } from '../logger/console-logger.service'; -import { NotesService } from '../notes/notes.service'; -import { NotePermission } from './note-permission.enum'; -import { PermissionsService } from './permissions.service'; +import { NoteService } from '../notes/note.service'; +import { NotePermissionLevel } from './note-permission.enum'; +import { PermissionService } from './permission.service'; import { PERMISSION_METADATA_KEY } from './require-permission.decorator'; import { RequiredPermission } from './required-permission.enum'; @@ -26,8 +26,8 @@ export class PermissionsGuard implements CanActivate { constructor( private readonly logger: ConsoleLoggerService, private readonly reflector: Reflector, - private readonly permissionsService: PermissionsService, - private readonly noteService: NotesService, + private readonly permissionsService: PermissionService, + private readonly noteService: NoteService, ) { this.logger.setContext(PermissionsGuard.name); } @@ -38,15 +38,15 @@ export class PermissionsGuard implements CanActivate { return false; } const request: CompleteRequest = context.switchToHttp().getRequest(); - const user = request.user ?? null; + const userId = request.userId ?? null; // handle CREATE requiredAccessLevel, as this does not need any note if (requiredAccessLevel === RequiredPermission.CREATE) { - return this.permissionsService.mayCreate(user); + return this.permissionsService.mayCreate(userId); } - const note = await extractNoteFromRequest(request, this.noteService); - if (note === undefined) { + const noteId = await extractNoteIdFromRequest(request, this.noteService); + if (noteId === undefined) { this.logger.error( 'Could not find noteIdOrAlias metadata. This should never happen. If you see this, please open an issue at https://github.com/hedgedoc/hedgedoc/issues', ); @@ -55,7 +55,7 @@ export class PermissionsGuard implements CanActivate { return this.isNotePermissionFulfillingRequiredAccessLevel( requiredAccessLevel, - await this.permissionsService.determinePermission(user, note), + await this.permissionsService.determinePermission(userId, noteId), ); } @@ -78,15 +78,15 @@ export class PermissionsGuard implements CanActivate { private isNotePermissionFulfillingRequiredAccessLevel( requiredAccessLevel: Exclude, - actualNotePermission: NotePermission, + actualNotePermission: NotePermissionLevel, ): boolean { switch (requiredAccessLevel) { case RequiredPermission.READ: - return actualNotePermission >= NotePermission.READ; + return actualNotePermission >= NotePermissionLevel.READ; case RequiredPermission.WRITE: - return actualNotePermission >= NotePermission.WRITE; + return actualNotePermission >= NotePermissionLevel.WRITE; case RequiredPermission.OWNER: - return actualNotePermission >= NotePermission.OWNER; + return actualNotePermission >= NotePermissionLevel.OWNER; } } } diff --git a/backend/src/permissions/permissions.module.ts b/backend/src/permissions/permissions.module.ts index 8192ffe23..f2d18e07b 100644 --- a/backend/src/permissions/permissions.module.ts +++ b/backend/src/permissions/permissions.module.ts @@ -1,25 +1,19 @@ /* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; +import { KnexModule } from 'nest-knexjs'; import { GroupsModule } from '../groups/groups.module'; import { LoggerModule } from '../logger/logger.module'; -import { Note } from '../notes/note.entity'; import { UsersModule } from '../users/users.module'; -import { PermissionsService } from './permissions.service'; +import { PermissionService } from './permission.service'; @Module({ - imports: [ - TypeOrmModule.forFeature([Note]), - UsersModule, - GroupsModule, - LoggerModule, - ], - exports: [PermissionsService], - providers: [PermissionsService], + imports: [KnexModule, LoggerModule], + exports: [PermissionService], + providers: [PermissionService], }) export class PermissionsModule {} diff --git a/backend/src/permissions/permissions.service.spec.ts b/backend/src/permissions/permissions.service.spec.ts index 5313562d5..c1055c2f6 100644 --- a/backend/src/permissions/permissions.service.spec.ts +++ b/backend/src/permissions/permissions.service.spec.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { - GuestAccess, NoteGroupPermissionUpdateDto, NoteUserPermissionUpdateDto, + PermissionLevel, } from '@hedgedoc/commons'; import { ConfigModule } from '@nestjs/config'; import { EventEmitter2, EventEmitterModule } from '@nestjs/event-emitter'; @@ -15,6 +15,7 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { Mock } from 'ts-mockery'; import { DataSource, EntityManager, Repository } from 'typeorm'; +import { AliasModule } from '../alias/alias.module'; import { ApiToken } from '../api-token/api-token.entity'; import { Identity } from '../auth/identity.entity'; import { Author } from '../authors/author.entity'; @@ -35,9 +36,8 @@ import { GroupsModule } from '../groups/groups.module'; import { GroupsService } from '../groups/groups.service'; import { LoggerModule } from '../logger/logger.module'; import { MediaUpload } from '../media/media-upload.entity'; -import { Alias } from '../notes/alias.entity'; +import { Alias } from '../notes/aliases.entity'; import { Note } from '../notes/note.entity'; -import { NotesModule } from '../notes/notes.module'; import { Tag } from '../notes/tag.entity'; import { Edit } from '../revisions/edit.entity'; import { Revision } from '../revisions/revision.entity'; @@ -45,13 +45,13 @@ import { Session } from '../sessions/session.entity'; import { UsersModule } from '../users/users.module'; import { NoteGroupPermission } from './note-group-permission.entity'; import { - getNotePermissionDisplayName, - NotePermission, + getNotePermissionLevelDisplayName, + NotePermissionLevel, } from './note-permission.enum'; import { NoteUserPermission } from './note-user-permission.entity'; +import { PermissionService } from './permission.service'; import { PermissionsModule } from './permissions.module'; -import { PermissionsService } from './permissions.service'; -import { convertGuestAccessToNotePermission } from './utils/convert-guest-access-to-note-permission'; +import { convertPermissionLevelToNotePermissionLevel } from './utils/convert-guest-access-to-note-permission-level'; import * as FindHighestNotePermissionByGroupModule from './utils/find-highest-note-permission-by-group'; import * as FindHighestNotePermissionByUserModule from './utils/find-highest-note-permission-by-user'; @@ -89,7 +89,7 @@ function mockNoteRepo(noteRepo: Repository) { } describe('PermissionsService', () => { - let service: PermissionsService; + let service: PermissionService; let groupService: GroupsService; let noteRepo: Repository; let userRepo: Repository; @@ -128,7 +128,7 @@ describe('PermissionsService', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ - PermissionsService, + PermissionService, { provide: getRepositoryToken(Note), useValue: noteRepo, @@ -146,7 +146,7 @@ describe('PermissionsService', () => { LoggerModule, PermissionsModule, UsersModule, - NotesModule, + AliasModule, ConfigModule.forRoot({ isGlobal: true, load: [ @@ -187,7 +187,7 @@ describe('PermissionsService', () => { .overrideProvider(getRepositoryToken(Alias)) .useValue({}) .compile(); - service = module.get(PermissionsService); + service = module.get(PermissionService); groupService = module.get(GroupsService); groupRepo = module.get>(getRepositoryToken(Group)); noteRepo = module.get>(getRepositoryToken(Note)); @@ -229,13 +229,13 @@ describe('PermissionsService', () => { expect(service.mayCreate(user1)).toBeTruthy(); }); it('allows creation of notes for guests with permission', () => { - noteMockConfig.guestAccess = GuestAccess.CREATE; + noteMockConfig.guestAccess = PermissionLevel.CREATE; noteMockConfig.permissions.default.loggedIn = DefaultAccessLevel.WRITE; noteMockConfig.permissions.default.everyone = DefaultAccessLevel.WRITE; expect(service.mayCreate(null)).toBeTruthy(); }); it('denies creation of notes for guests without permission', () => { - noteMockConfig.guestAccess = GuestAccess.WRITE; + noteMockConfig.guestAccess = PermissionLevel.WRITE; noteMockConfig.permissions.default.loggedIn = DefaultAccessLevel.WRITE; noteMockConfig.permissions.default.everyone = DefaultAccessLevel.WRITE; expect(service.mayCreate(null)).toBeFalsy(); @@ -318,34 +318,34 @@ describe('PermissionsService', () => { it(`with no everyone permission will deny`, async () => { const note = mockNote(user1, [], [loggedInReadPermission]); const foundPermission = await service.determinePermission(null, note); - expect(foundPermission).toBe(NotePermission.DENY); + expect(foundPermission).toBe(NotePermissionLevel.DENY); }); describe.each([ - GuestAccess.DENY, - GuestAccess.READ, - GuestAccess.WRITE, - GuestAccess.CREATE, + PermissionLevel.DENY, + PermissionLevel.READ, + PermissionLevel.WRITE, + PermissionLevel.CREATE, ])('with configured guest access %s', (guestAccess) => { beforeEach(() => { noteMockConfig.guestAccess = guestAccess; }); const guestAccessNotePermission = - convertGuestAccessToNotePermission(guestAccess); + convertPermissionLevelToNotePermissionLevel(guestAccess); describe.each([false, true])( 'with everybody group permission with edit set to %s', (canEdit) => { const editPermission = canEdit - ? NotePermission.WRITE - : NotePermission.READ; + ? NotePermissionLevel.WRITE + : NotePermissionLevel.READ; const expectedLimitedPermission = guestAccessNotePermission >= editPermission ? editPermission : guestAccessNotePermission; - const permissionDisplayName = getNotePermissionDisplayName( + const permissionDisplayName = getNotePermissionLevelDisplayName( expectedLimitedPermission, ); it(`will ${permissionDisplayName}`, async () => { @@ -381,7 +381,7 @@ describe('PermissionsService', () => { note, ); - expect(foundPermission).toBe(NotePermission.OWNER); + expect(foundPermission).toBe(NotePermissionLevel.OWNER); }); it('with other lower permissions', async () => { const userPermission = Mock.of({ @@ -407,7 +407,7 @@ describe('PermissionsService', () => { note, ); - expect(foundPermission).toBe(NotePermission.OWNER); + expect(foundPermission).toBe(NotePermissionLevel.OWNER); }); }); describe('as non owner', () => { @@ -417,13 +417,13 @@ describe('PermissionsService', () => { FindHighestNotePermissionByUserModule, 'findHighestNotePermissionByUser', ) - .mockReturnValue(Promise.resolve(NotePermission.DENY)); + .mockReturnValue(Promise.resolve(NotePermissionLevel.DENY)); jest .spyOn( FindHighestNotePermissionByGroupModule, 'findHighestNotePermissionByGroup', ) - .mockReturnValue(Promise.resolve(NotePermission.WRITE)); + .mockReturnValue(Promise.resolve(NotePermissionLevel.WRITE)); const note = mockNote(user2); @@ -431,7 +431,7 @@ describe('PermissionsService', () => { user1, note, ); - expect(foundPermission).toBe(NotePermission.WRITE); + expect(foundPermission).toBe(NotePermissionLevel.WRITE); }); it('with group permission higher than user permission', async () => { @@ -440,13 +440,13 @@ describe('PermissionsService', () => { FindHighestNotePermissionByUserModule, 'findHighestNotePermissionByUser', ) - .mockReturnValue(Promise.resolve(NotePermission.WRITE)); + .mockReturnValue(Promise.resolve(NotePermissionLevel.WRITE)); jest .spyOn( FindHighestNotePermissionByGroupModule, 'findHighestNotePermissionByGroup', ) - .mockReturnValue(Promise.resolve(NotePermission.DENY)); + .mockReturnValue(Promise.resolve(NotePermissionLevel.DENY)); const note = mockNote(user2); @@ -454,7 +454,7 @@ describe('PermissionsService', () => { user1, note, ); - expect(foundPermission).toBe(NotePermission.WRITE); + expect(foundPermission).toBe(NotePermissionLevel.WRITE); }); }); }); @@ -479,7 +479,7 @@ describe('PermissionsService', () => { const note = Note.create(user) as Note; it('emits PERMISSION_CHANGE event', async () => { expect(eventEmitterEmitSpy).not.toHaveBeenCalled(); - await service.updateNotePermissions(note, { + await service.replaceNotePermissions(note, { sharedToUsers: [], sharedToGroups: [], }); @@ -487,7 +487,7 @@ describe('PermissionsService', () => { }); describe('works', () => { it('with empty GroupPermissions and with empty UserPermissions', async () => { - const savedNote = await service.updateNotePermissions(note, { + const savedNote = await service.replaceNotePermissions(note, { sharedToUsers: [], sharedToGroups: [], }); @@ -496,7 +496,7 @@ describe('PermissionsService', () => { }); it('with empty GroupPermissions and with new UserPermissions', async () => { jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce(user); - const savedNote = await service.updateNotePermissions(note, { + const savedNote = await service.replaceNotePermissions(note, { sharedToUsers: [userPermissionUpdate], sharedToGroups: [], }); @@ -521,7 +521,7 @@ describe('PermissionsService', () => { ]); jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce(user); - const savedNote = await service.updateNotePermissions(note, { + const savedNote = await service.replaceNotePermissions(note, { sharedToUsers: [userPermissionUpdate], sharedToGroups: [], }); @@ -536,7 +536,7 @@ describe('PermissionsService', () => { }); it('with new GroupPermissions and with empty UserPermissions', async () => { jest.spyOn(groupRepo, 'findOne').mockResolvedValueOnce(group); - const savedNote = await service.updateNotePermissions(note, { + const savedNote = await service.replaceNotePermissions(note, { sharedToUsers: [], sharedToGroups: [groupPermissionUpdate], }); @@ -551,7 +551,7 @@ describe('PermissionsService', () => { it('with new GroupPermissions and with new UserPermissions', async () => { jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce(user); jest.spyOn(groupRepo, 'findOne').mockResolvedValueOnce(group); - const savedNote = await service.updateNotePermissions(note, { + const savedNote = await service.replaceNotePermissions(note, { sharedToUsers: [userPermissionUpdate], sharedToGroups: [groupPermissionUpdate], }); @@ -581,7 +581,7 @@ describe('PermissionsService', () => { jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce(user); jest.spyOn(groupRepo, 'findOne').mockResolvedValueOnce(group); - const savedNote = await service.updateNotePermissions( + const savedNote = await service.replaceNotePermissions( noteWithUserPermission, { sharedToUsers: [userPermissionUpdate], @@ -612,7 +612,7 @@ describe('PermissionsService', () => { }, ]); jest.spyOn(groupRepo, 'findOne').mockResolvedValueOnce(group); - const savedNote = await service.updateNotePermissions( + const savedNote = await service.replaceNotePermissions( noteWithPreexistingPermissions, { sharedToUsers: [], @@ -640,7 +640,7 @@ describe('PermissionsService', () => { jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce(user); jest.spyOn(groupRepo, 'findOne').mockResolvedValueOnce(group); - const savedNote = await service.updateNotePermissions( + const savedNote = await service.replaceNotePermissions( noteWithPreexistingPermissions, { sharedToUsers: [userPermissionUpdate], @@ -681,7 +681,7 @@ describe('PermissionsService', () => { jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce(user); jest.spyOn(groupRepo, 'findOne').mockResolvedValueOnce(group); - const savedNote = await service.updateNotePermissions( + const savedNote = await service.replaceNotePermissions( noteWithPreexistingPermissions, { sharedToUsers: [userPermissionUpdate], @@ -705,7 +705,7 @@ describe('PermissionsService', () => { describe('fails:', () => { it('userPermissions has duplicate entries', async () => { await expect( - service.updateNotePermissions(note, { + service.replaceNotePermissions(note, { sharedToUsers: [userPermissionUpdate, userPermissionUpdate], sharedToGroups: [], }), @@ -714,7 +714,7 @@ describe('PermissionsService', () => { it('groupPermissions has duplicate entries', async () => { await expect( - service.updateNotePermissions(note, { + service.replaceNotePermissions(note, { sharedToUsers: [], sharedToGroups: [groupPermissionUpdate, groupPermissionUpdate], }), @@ -723,7 +723,7 @@ describe('PermissionsService', () => { it('userPermissions and groupPermissions have duplicate entries', async () => { await expect( - service.updateNotePermissions(note, { + service.replaceNotePermissions(note, { sharedToUsers: [userPermissionUpdate, userPermissionUpdate], sharedToGroups: [groupPermissionUpdate, groupPermissionUpdate], }), diff --git a/backend/src/permissions/permissions.service.ts b/backend/src/permissions/permissions.service.ts deleted file mode 100644 index 88e972f0c..000000000 --- a/backend/src/permissions/permissions.service.ts +++ /dev/null @@ -1,363 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { GuestAccess, NotePermissionsUpdateDto } from '@hedgedoc/commons'; -import { Inject, Injectable } from '@nestjs/common'; -import { EventEmitter2 } from '@nestjs/event-emitter'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; - -import noteConfiguration, { NoteConfig } from '../config/note.config'; -import { User } from '../database/user.entity'; -import { PermissionsUpdateInconsistentError } from '../errors/errors'; -import { NoteEvent, NoteEventMap } from '../events'; -import { Group } from '../groups/group.entity'; -import { GroupsService } from '../groups/groups.service'; -import { ConsoleLoggerService } from '../logger/console-logger.service'; -import { MediaUpload } from '../media/media-upload.entity'; -import { Note } from '../notes/note.entity'; -import { UsersService } from '../users/users.service'; -import { checkArrayForDuplicates } from '../utils/arrayDuplicatCheck'; -import { NoteGroupPermission } from './note-group-permission.entity'; -import { NotePermission } from './note-permission.enum'; -import { NoteUserPermission } from './note-user-permission.entity'; -import { convertGuestAccessToNotePermission } from './utils/convert-guest-access-to-note-permission'; -import { findHighestNotePermissionByGroup } from './utils/find-highest-note-permission-by-group'; -import { findHighestNotePermissionByUser } from './utils/find-highest-note-permission-by-user'; - -@Injectable() -export class PermissionsService { - constructor( - private usersService: UsersService, - private groupsService: GroupsService, - @InjectRepository(Note) private noteRepository: Repository, - private readonly logger: ConsoleLoggerService, - @Inject(noteConfiguration.KEY) - private noteConfig: NoteConfig, - private eventEmitter: EventEmitter2, - ) {} - - public async checkMediaDeletePermission( - user: User, - mediaUpload: MediaUpload, - ): Promise { - const mediaUploadNote = await mediaUpload.note; - const mediaUploadOwner = await mediaUpload.user; - - const owner = - !!mediaUploadNote && (await this.isOwner(user, mediaUploadNote)); - - return mediaUploadOwner?.id === user.id || owner; - } - - /** - * Checks if the given {@link User} is allowed to create notes. - * - * @async - * @param {User} user - The user whose permission should be checked. Value is null if guest access should be checked - * @return if the user is allowed to create notes - */ - public mayCreate(user: User | null): boolean { - return !!user || this.noteConfig.guestAccess === GuestAccess.CREATE; - } - - async isOwner(user: User | null, note: Note): Promise { - if (!user) { - return false; - } - const owner = await note.owner; - if (!owner) { - return false; - } - return owner.id === user.id; - } - - /** - * Determines the {@link NotePermission permission} of the user on the given {@link Note}. - * - * @param {User | null} user The user whose permission should be checked - * @param {Note} note The note that is accessed by the given user - * @return {Promise} The determined permission - */ - public async determinePermission( - user: User | null, - note: Note, - ): Promise { - if (user === null) { - return await this.findGuestNotePermission(await note.groupPermissions); - } - - if (await this.isOwner(user, note)) { - return NotePermission.OWNER; - } - const userPermission = await findHighestNotePermissionByUser( - user, - await note.userPermissions, - ); - if (userPermission === NotePermission.WRITE) { - return userPermission; - } - const groupPermission = await findHighestNotePermissionByGroup( - user, - await note.groupPermissions, - ); - return groupPermission > userPermission ? groupPermission : userPermission; - } - - private async findGuestNotePermission( - groupPermissions: NoteGroupPermission[], - ): Promise { - if (this.noteConfig.guestAccess === GuestAccess.DENY) { - return NotePermission.DENY; - } - - const everyonePermission = await this.findPermissionForGroup( - groupPermissions, - await this.groupsService.getEveryoneGroup(), - ); - if (everyonePermission === undefined) { - return NotePermission.DENY; - } - const notePermission = everyonePermission.canEdit - ? NotePermission.WRITE - : NotePermission.READ; - return this.limitNotePermissionToGuestAccessLevel(notePermission); - } - - private limitNotePermissionToGuestAccessLevel( - notePermission: NotePermission, - ): NotePermission { - const configuredGuestNotePermission = convertGuestAccessToNotePermission( - this.noteConfig.guestAccess, - ); - return configuredGuestNotePermission < notePermission - ? configuredGuestNotePermission - : notePermission; - } - - private notifyOthers(note: Note): void { - this.eventEmitter.emit(NoteEvent.PERMISSION_CHANGE, note); - } - - /** - * @async - * Update a notes permissions. - * @param {Note} note - the note - * @param {NotePermissionsUpdateDto} newPermissions - the permissions that should be applied to the note - * @return {Note} the note with the new permissions - * @throws {NotInDBError} there is no note with this id or alias - * @throws {PermissionsUpdateInconsistentError} the new permissions specify a user or group twice. - */ - async updateNotePermissions( - note: Note, - newPermissions: NotePermissionsUpdateDto, - ): Promise { - const users = newPermissions.sharedToUsers.map( - (userPermission) => userPermission.username, - ); - - const groups = newPermissions.sharedToGroups.map( - (groupPermission) => groupPermission.groupName, - ); - - if (checkArrayForDuplicates(users) || checkArrayForDuplicates(groups)) { - this.logger.debug( - `The PermissionUpdate requested specifies the same user or group multiple times.`, - 'updateNotePermissions', - ); - throw new PermissionsUpdateInconsistentError( - 'The PermissionUpdate requested specifies the same user or group multiple times.', - ); - } - - note.userPermissions = Promise.resolve([]); - note.groupPermissions = Promise.resolve([]); - - // Create new userPermissions - for (const newUserPermission of newPermissions.sharedToUsers) { - const user = await this.usersService.getUserByUsername( - newUserPermission.username, - ); - const createdPermission = NoteUserPermission.create( - user, - note, - newUserPermission.canEdit, - ); - createdPermission.note = Promise.resolve(note); - (await note.userPermissions).push(createdPermission); - } - - // Create groupPermissions - for (const newGroupPermission of newPermissions.sharedToGroups) { - const group = await this.groupsService.getGroupByName( - newGroupPermission.groupName, - ); - const createdPermission = NoteGroupPermission.create( - group, - note, - newGroupPermission.canEdit, - ); - createdPermission.note = Promise.resolve(note); - (await note.groupPermissions).push(createdPermission); - } - this.notifyOthers(note); - return await this.noteRepository.save(note); - } - - /** - * @async - * Set permission for a specific user on a note. - * @param {Note} note - the note - * @param {User} permissionUser - the user for which the permission should be set - * @param {boolean} canEdit - specifies if the user can edit the note - * @return {Note} the note with the new permission - */ - async setUserPermission( - note: Note, - permissionUser: User, - canEdit: boolean, - ): Promise { - if (await this.isOwner(permissionUser, note)) { - return note; - } - const permissions = await note.userPermissions; - const permission = await this.findPermissionForUser( - permissions, - permissionUser, - ); - if (permission !== undefined) { - permission.canEdit = canEdit; - } else { - const noteUserPermission = NoteUserPermission.create( - permissionUser, - note, - canEdit, - ); - (await note.userPermissions).push(noteUserPermission); - } - this.notifyOthers(note); - return await this.noteRepository.save(note); - } - - private async findPermissionForUser( - permissions: NoteUserPermission[], - user: User, - ): Promise { - for (const permission of permissions) { - if ((await permission.user).id == user.id) { - return permission; - } - } - return undefined; - } - - /** - * @async - * Remove permission for a specific user on a note. - * @param {Note} note - the note - * @param {User} permissionUser - the user for which the permission should be set - * @return {Note} the note with the new permission - */ - async removeUserPermission(note: Note, permissionUser: User): Promise { - const permissions = await note.userPermissions; - const newPermissions = []; - for (const permission of permissions) { - if ((await permission.user).id != permissionUser.id) { - newPermissions.push(permission); - } - } - note.userPermissions = Promise.resolve(newPermissions); - this.notifyOthers(note); - return await this.noteRepository.save(note); - } - - /** - * @async - * Set permission for a specific group on a note. - * @param {Note} note - the note - * @param {Group} permissionGroup - the group for which the permission should be set - * @param {boolean} canEdit - specifies if the group can edit the note - * @return {Note} the note with the new permission - */ - async setGroupPermission( - note: Note, - permissionGroup: Group, - canEdit: boolean, - ): Promise { - this.logger.debug( - `Setting group permission for group ${permissionGroup.name} on note ${note.id}`, - 'setGroupPermission', - ); - const permissions = await note.groupPermissions; - const permission = await this.findPermissionForGroup( - permissions, - permissionGroup, - ); - if (permission !== undefined) { - permission.canEdit = canEdit; - } else { - this.logger.debug( - `Permission does not exist yet, creating new one.`, - 'setGroupPermission', - ); - const noteGroupPermission = NoteGroupPermission.create( - permissionGroup, - note, - canEdit, - ); - (await note.groupPermissions).push(noteGroupPermission); - } - this.notifyOthers(note); - return await this.noteRepository.save(note); - } - - private async findPermissionForGroup( - permissions: NoteGroupPermission[], - group: Group, - ): Promise { - for (const permission of permissions) { - if ((await permission.group).id == group.id) { - return permission; - } - } - return undefined; - } - - /** - * @async - * Remove permission for a specific group on a note. - * @param {Note} note - the note - * @param {Group} permissionGroup - the group for which the permission should be set - * @return {Note} the note with the new permission - */ - async removeGroupPermission( - note: Note, - permissionGroup: Group, - ): Promise { - const permissions = await note.groupPermissions; - const newPermissions = []; - for (const permission of permissions) { - if ((await permission.group).id != permissionGroup.id) { - newPermissions.push(permission); - } - } - note.groupPermissions = Promise.resolve(newPermissions); - this.notifyOthers(note); - return await this.noteRepository.save(note); - } - - /** - * @async - * Updates the owner of a note. - * @param {Note} note - the note to use - * @param {User} owner - the new owner - * @return {Note} the updated note - */ - async changeOwner(note: Note, owner: User): Promise { - note.owner = Promise.resolve(owner); - this.notifyOthers(note); - return await this.noteRepository.save(note); - } -} diff --git a/backend/src/permissions/utils/convert-editability-to-note-permission-level.spec.ts b/backend/src/permissions/utils/convert-editability-to-note-permission-level.spec.ts new file mode 100644 index 000000000..d11789183 --- /dev/null +++ b/backend/src/permissions/utils/convert-editability-to-note-permission-level.spec.ts @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { NotePermissionLevel } from '../note-permission.enum'; +import { convertEditabilityToPermissionLevel } from './convert-editability-to-note-permission-level'; + +describe('convert editability to note permission level', () => { + it('canEdit false is converted to read', () => { + expect(convertEditabilityToPermissionLevel(false)).toBe( + NotePermissionLevel.READ, + ); + }); + it('canEdit true is converted to write', () => { + expect(convertEditabilityToPermissionLevel(true)).toBe( + NotePermissionLevel.WRITE, + ); + }); +}); diff --git a/backend/src/permissions/utils/convert-editability-to-note-permission-level.ts b/backend/src/permissions/utils/convert-editability-to-note-permission-level.ts new file mode 100644 index 000000000..64b0bdc3f --- /dev/null +++ b/backend/src/permissions/utils/convert-editability-to-note-permission-level.ts @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { NotePermissionLevel } from '../note-permission.enum'; + +export function convertEditabilityToPermissionLevel( + canEdit: boolean, +): NotePermissionLevel { + return canEdit ? NotePermissionLevel.WRITE : NotePermissionLevel.READ; +} diff --git a/backend/src/permissions/utils/convert-guest-access-to-note-permission-level.spec.ts b/backend/src/permissions/utils/convert-guest-access-to-note-permission-level.spec.ts new file mode 100644 index 000000000..97a8a27a0 --- /dev/null +++ b/backend/src/permissions/utils/convert-guest-access-to-note-permission-level.spec.ts @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { PermissionLevel } from '@hedgedoc/commons'; + +import { NotePermissionLevel } from '../note-permission.enum'; +import { convertPermissionLevelToNotePermissionLevel } from './convert-guest-access-to-note-permission-level'; + +describe('convert guest access to note permission', () => { + it('no guest access means no note access', () => { + expect( + convertPermissionLevelToNotePermissionLevel(PermissionLevel.DENY), + ).toBe(NotePermissionLevel.DENY); + }); + + it('translates read access to read permission', () => { + expect( + convertPermissionLevelToNotePermissionLevel(PermissionLevel.READ), + ).toBe(NotePermissionLevel.READ); + }); + + it('translates write access to write permission', () => { + expect( + convertPermissionLevelToNotePermissionLevel(PermissionLevel.WRITE), + ).toBe(NotePermissionLevel.WRITE); + }); + + it('translates create access to write permission', () => { + expect( + convertPermissionLevelToNotePermissionLevel(PermissionLevel.CREATE), + ).toBe(NotePermissionLevel.WRITE); + }); +}); diff --git a/backend/src/permissions/utils/convert-guest-access-to-note-permission-level.ts b/backend/src/permissions/utils/convert-guest-access-to-note-permission-level.ts new file mode 100644 index 000000000..55cbdd1d9 --- /dev/null +++ b/backend/src/permissions/utils/convert-guest-access-to-note-permission-level.ts @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { PermissionLevel } from '@hedgedoc/commons'; + +import { NotePermissionLevel } from '../note-permission.enum'; + +/** + * Converts the given guest access level to the highest possible {@link NotePermissionLevel}. + * + * @param guestAccess the guest access level to should be converted + * @return the {@link NotePermissionLevel} representation + */ +export function convertPermissionLevelToNotePermissionLevel( + guestAccess: PermissionLevel, +): + | NotePermissionLevel.READ + | NotePermissionLevel.WRITE + | NotePermissionLevel.DENY { + switch (guestAccess) { + case PermissionLevel.DENY: + return NotePermissionLevel.DENY; + case PermissionLevel.READ: + return NotePermissionLevel.READ; + case PermissionLevel.WRITE: + return NotePermissionLevel.WRITE; + case PermissionLevel.CREATE: + return NotePermissionLevel.WRITE; + } +} diff --git a/backend/src/permissions/utils/convert-guest-access-to-note-permission.spec.ts b/backend/src/permissions/utils/convert-guest-access-to-note-permission.spec.ts deleted file mode 100644 index 8dc4eec7d..000000000 --- a/backend/src/permissions/utils/convert-guest-access-to-note-permission.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { GuestAccess } from '@hedgedoc/commons'; - -import { NotePermission } from '../note-permission.enum'; -import { convertGuestAccessToNotePermission } from './convert-guest-access-to-note-permission'; - -describe('convert guest access to note permission', () => { - it('no guest access means no note access', () => { - expect(convertGuestAccessToNotePermission(GuestAccess.DENY)).toBe( - NotePermission.DENY, - ); - }); - - it('translates read access to read permission', () => { - expect(convertGuestAccessToNotePermission(GuestAccess.READ)).toBe( - NotePermission.READ, - ); - }); - - it('translates write access to write permission', () => { - expect(convertGuestAccessToNotePermission(GuestAccess.WRITE)).toBe( - NotePermission.WRITE, - ); - }); - - it('translates create access to write permission', () => { - expect(convertGuestAccessToNotePermission(GuestAccess.CREATE)).toBe( - NotePermission.WRITE, - ); - }); -}); diff --git a/backend/src/permissions/utils/convert-guest-access-to-note-permission.ts b/backend/src/permissions/utils/convert-guest-access-to-note-permission.ts deleted file mode 100644 index f016bf01d..000000000 --- a/backend/src/permissions/utils/convert-guest-access-to-note-permission.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { GuestAccess } from '@hedgedoc/commons'; - -import { NotePermission } from '../note-permission.enum'; - -/** - * Converts the given guest access level to the highest possible {@link NotePermission}. - * - * @param guestAccess the guest access level to should be converted - * @return the {@link NotePermission} representation - */ -export function convertGuestAccessToNotePermission( - guestAccess: GuestAccess, -): NotePermission.READ | NotePermission.WRITE | NotePermission.DENY { - switch (guestAccess) { - case GuestAccess.DENY: - return NotePermission.DENY; - case GuestAccess.READ: - return NotePermission.READ; - case GuestAccess.WRITE: - return NotePermission.WRITE; - case GuestAccess.CREATE: - return NotePermission.WRITE; - } -} diff --git a/backend/src/permissions/utils/find-highest-note-permission-by-group.spec.ts b/backend/src/permissions/utils/find-highest-note-permission-by-group.spec.ts deleted file mode 100644 index df17a5299..000000000 --- a/backend/src/permissions/utils/find-highest-note-permission-by-group.spec.ts +++ /dev/null @@ -1,194 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { Mock } from 'ts-mockery'; - -import { User } from '../../database/user.entity'; -import { Group } from '../../groups/group.entity'; -import { SpecialGroup } from '../../groups/groups.special'; -import { NoteGroupPermission } from '../note-group-permission.entity'; -import { NotePermission } from '../note-permission.enum'; -import { findHighestNotePermissionByGroup } from './find-highest-note-permission-by-group'; - -describe('find highest note permission by group', () => { - const user1 = Mock.of({ id: 0 }); - const user2 = Mock.of({ id: 1 }); - const user3 = Mock.of({ id: 2 }); - const group2 = Mock.of({ - id: 1, - special: false, - members: Promise.resolve([user2]), - }); - const group3 = Mock.of({ - id: 2, - special: false, - members: Promise.resolve([user3]), - }); - - const permissionGroup2Read = Mock.of({ - group: Promise.resolve(group2), - canEdit: false, - }); - - const permissionGroup3Read = Mock.of({ - group: Promise.resolve(group3), - canEdit: false, - }); - - const permissionGroup3Write = Mock.of({ - group: Promise.resolve(group3), - canEdit: true, - }); - - describe('normal groups', () => { - it('will fallback to NONE if no permission for the user could be found', async () => { - const result = await findHighestNotePermissionByGroup(user1, [ - permissionGroup2Read, - permissionGroup3Write, - ]); - expect(result).toBe(NotePermission.DENY); - }); - - it('can extract a READ permission for the correct user', async () => { - const result = await findHighestNotePermissionByGroup(user2, [ - permissionGroup2Read, - permissionGroup3Write, - ]); - expect(result).toBe(NotePermission.READ); - }); - - it('can extract a WRITE permission for the correct user', async () => { - const result = await findHighestNotePermissionByGroup(user3, [ - permissionGroup2Read, - permissionGroup3Write, - ]); - expect(result).toBe(NotePermission.WRITE); - }); - - it('can extract a WRITE permission for the correct user if read and write are defined', async () => { - const result = await findHighestNotePermissionByGroup(user3, [ - permissionGroup2Read, - permissionGroup3Read, - permissionGroup3Write, - ]); - expect(result).toBe(NotePermission.WRITE); - }); - }); - - describe('special group', () => { - const groupEveryone = Mock.of({ - id: 3, - special: true, - name: SpecialGroup.EVERYONE, - }); - const groupLoggedIn = Mock.of({ - id: 4, - special: true, - name: SpecialGroup.LOGGED_IN, - }); - const permissionGroupEveryoneRead = Mock.of({ - group: Promise.resolve(groupEveryone), - canEdit: false, - }); - const permissionGroupLoggedInRead = Mock.of({ - group: Promise.resolve(groupLoggedIn), - canEdit: false, - }); - const permissionGroupEveryoneWrite = Mock.of({ - group: Promise.resolve(groupEveryone), - canEdit: true, - }); - const permissionGroupLoggedInWrite = Mock.of({ - group: Promise.resolve(groupLoggedIn), - canEdit: true, - }); - - it('will ignore unknown special groups', async () => { - const nonsenseSpecialGroup = Mock.of({ - id: 99, - special: true, - name: 'Unknown Special Group', - members: Promise.resolve([]), - }); - - const permissionUnknownSpecialGroup = Mock.of({ - group: Promise.resolve(nonsenseSpecialGroup), - canEdit: false, - }); - - const result = await findHighestNotePermissionByGroup(user1, [ - permissionUnknownSpecialGroup, - ]); - expect(result).toBe(NotePermission.DENY); - }); - - it('can extract the READ permission for logged in users', async () => { - const result = await findHighestNotePermissionByGroup(user1, [ - permissionGroupLoggedInRead, - ]); - expect(result).toBe(NotePermission.READ); - }); - - it('can extract the READ permission for everyone', async () => { - const result = await findHighestNotePermissionByGroup(user1, [ - permissionGroupEveryoneRead, - ]); - expect(result).toBe(NotePermission.READ); - }); - it('can extract the WRITE permission for logged in users', async () => { - const result = await findHighestNotePermissionByGroup(user1, [ - permissionGroupLoggedInWrite, - ]); - expect(result).toBe(NotePermission.WRITE); - }); - - it('can extract the WRITE permission for everyone', async () => { - const result = await findHighestNotePermissionByGroup(user1, [ - permissionGroupEveryoneWrite, - ]); - expect(result).toBe(NotePermission.WRITE); - }); - - it('can prefer everyone over logged in if necessary', async () => { - const result = await findHighestNotePermissionByGroup(user1, [ - permissionGroupEveryoneWrite, - permissionGroupLoggedInRead, - ]); - expect(result).toBe(NotePermission.WRITE); - }); - - it('can prefer normal groups over logged in if necessary', async () => { - const result = await findHighestNotePermissionByGroup(user3, [ - permissionGroup3Write, - permissionGroupLoggedInRead, - ]); - expect(result).toBe(NotePermission.WRITE); - }); - - it('can prefer normal groups over everyone if necessary', async () => { - const result = await findHighestNotePermissionByGroup(user3, [ - permissionGroup3Write, - permissionGroupEveryoneRead, - ]); - expect(result).toBe(NotePermission.WRITE); - }); - - it('can prefer logged in over normal groups if necessary', async () => { - const result = await findHighestNotePermissionByGroup(user3, [ - permissionGroup3Read, - permissionGroupLoggedInWrite, - ]); - expect(result).toBe(NotePermission.WRITE); - }); - - it('can prefer everyone over normal groups if necessary', async () => { - const result = await findHighestNotePermissionByGroup(user3, [ - permissionGroup3Read, - permissionGroupEveryoneWrite, - ]); - expect(result).toBe(NotePermission.WRITE); - }); - }); -}); diff --git a/backend/src/permissions/utils/find-highest-note-permission-by-group.ts b/backend/src/permissions/utils/find-highest-note-permission-by-group.ts deleted file mode 100644 index 8e31a89b4..000000000 --- a/backend/src/permissions/utils/find-highest-note-permission-by-group.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { User } from '../../database/user.entity'; -import { Group } from '../../groups/group.entity'; -import { SpecialGroup } from '../../groups/groups.special'; -import { NoteGroupPermission } from '../note-group-permission.entity'; -import { NotePermission } from '../note-permission.enum'; - -/** - * Inspects the given note permissions and finds the highest {@link NoteGroupPermission} for the given {@link Group}. - * - * @param user The group whose permissions should be determined - * @param groupPermissions The search basis - * @return The found permission or {@link NotePermission.DENY} if no permission could be found. - * @async - */ -export async function findHighestNotePermissionByGroup( - user: User, - groupPermissions: NoteGroupPermission[], -): Promise { - let highestGroupPermission = NotePermission.DENY; - for (const groupPermission of groupPermissions) { - const permission = await findNotePermissionByGroup(user, groupPermission); - if (permission === NotePermission.WRITE) { - return NotePermission.WRITE; - } - highestGroupPermission = - highestGroupPermission > permission ? highestGroupPermission : permission; - } - return highestGroupPermission; -} - -async function findNotePermissionByGroup( - user: User, - groupPermission: NoteGroupPermission, -): Promise { - const group = await groupPermission.group; - if (!isSpecialGroup(group) && !(await isUserInGroup(user, group))) { - return NotePermission.DENY; - } - return groupPermission.canEdit ? NotePermission.WRITE : NotePermission.READ; -} - -function isSpecialGroup(group: Group): boolean { - return ( - group.special && - (group.name === (SpecialGroup.LOGGED_IN as string) || - group.name === (SpecialGroup.EVERYONE as string)) - ); -} - -async function isUserInGroup(user: User, group: Group): Promise { - for (const member of await group.members) { - if (member.id === user.id) { - return true; - } - } - return false; -} diff --git a/backend/src/permissions/utils/find-highest-note-permission-by-user.spec.ts b/backend/src/permissions/utils/find-highest-note-permission-by-user.spec.ts deleted file mode 100644 index 778b24009..000000000 --- a/backend/src/permissions/utils/find-highest-note-permission-by-user.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { Mock } from 'ts-mockery'; - -import { User } from '../../database/user.entity'; -import { NotePermission } from '../note-permission.enum'; -import { NoteUserPermission } from '../note-user-permission.entity'; -import { findHighestNotePermissionByUser } from './find-highest-note-permission-by-user'; - -describe('find highest note permission by user', () => { - const user1 = Mock.of({ id: 0 }); - const user2 = Mock.of({ id: 1 }); - const user3 = Mock.of({ id: 2 }); - - const permissionUser2Read = Mock.of({ - user: Promise.resolve(user2), - canEdit: false, - }); - - const permissionUser3Read = Mock.of({ - user: Promise.resolve(user3), - canEdit: false, - }); - - const permissionUser3Write = Mock.of({ - user: Promise.resolve(user3), - canEdit: true, - }); - - it('will fallback to NONE if no permission for the user could be found', async () => { - const result = await findHighestNotePermissionByUser(user1, [ - permissionUser2Read, - permissionUser3Write, - ]); - expect(result).toBe(NotePermission.DENY); - }); - - it('can extract a READ permission for the correct user', async () => { - const result = await findHighestNotePermissionByUser(user2, [ - permissionUser2Read, - permissionUser3Write, - ]); - expect(result).toBe(NotePermission.READ); - }); - - it('can extract a WRITE permission for the correct user', async () => { - const result = await findHighestNotePermissionByUser(user3, [ - permissionUser2Read, - permissionUser3Write, - ]); - expect(result).toBe(NotePermission.WRITE); - }); - - it('can extract a WRITE permission for the correct user if read and write are defined', async () => { - const result = await findHighestNotePermissionByUser(user3, [ - permissionUser2Read, - permissionUser3Read, - permissionUser3Write, - ]); - expect(result).toBe(NotePermission.WRITE); - }); -}); diff --git a/backend/src/permissions/utils/find-highest-note-permission-by-user.ts b/backend/src/permissions/utils/find-highest-note-permission-by-user.ts deleted file mode 100644 index 840192e5b..000000000 --- a/backend/src/permissions/utils/find-highest-note-permission-by-user.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { User } from '../../database/user.entity'; -import { NotePermission } from '../note-permission.enum'; -import { NoteUserPermission } from '../note-user-permission.entity'; - -/** - * Inspects the given note permissions and finds the highest {@link NoteUserPermission} for the given {@link User}. - * - * @param user The user whose permissions should be determined - * @param userPermissions The search basis - * @return The found permission or {@link NotePermission.DENY} if no permission could be found. - * @async - */ -export async function findHighestNotePermissionByUser( - user: User, - userPermissions: NoteUserPermission[], -): Promise { - let hasReadPermission = false; - for (const userPermission of userPermissions) { - if ((await userPermission.user).id !== user.id) { - continue; - } - - if (userPermission.canEdit) { - return NotePermission.WRITE; - } - - hasReadPermission = true; - } - return hasReadPermission ? NotePermission.READ : NotePermission.DENY; -} diff --git a/backend/src/realtime/realtime-note/realtime-note.service.spec.ts b/backend/src/realtime/realtime-note/realtime-note.service.spec.ts index 958d3771a..875d3eaf3 100644 --- a/backend/src/realtime/realtime-note/realtime-note.service.spec.ts +++ b/backend/src/realtime/realtime-note/realtime-note.service.spec.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -10,8 +10,8 @@ import { AppConfig } from '../../config/app.config'; import { User } from '../../database/user.entity'; import { ConsoleLoggerService } from '../../logger/console-logger.service'; import { Note } from '../../notes/note.entity'; -import { NotePermission } from '../../permissions/note-permission.enum'; -import { PermissionsService } from '../../permissions/permissions.service'; +import { NotePermissionLevel } from '../../permissions/note-permission.enum'; +import { PermissionService } from '../../permissions/permission.service'; import { Revision } from '../../revisions/revision.entity'; import { RevisionsService } from '../../revisions/revisions.service'; import { RealtimeConnection } from './realtime-connection'; @@ -29,7 +29,7 @@ describe('RealtimeNoteService', () => { let realtimeNoteService: RealtimeNoteService; let revisionsService: RevisionsService; let realtimeNoteStore: RealtimeNoteStore; - let mockedPermissionService: PermissionsService; + let mockedPermissionService: PermissionService; let consoleLoggerService: ConsoleLoggerService; let mockedAppConfig: AppConfig; let addIntervalSpy: jest.SpyInstance; @@ -91,14 +91,14 @@ describe('RealtimeNoteService', () => { }); mockedAppConfig = Mock.of({ persistInterval: 0 }); - mockedPermissionService = Mock.of({ + mockedPermissionService = Mock.of({ determinePermission: async (user: User | null) => { if (user?.username === readWriteUsername) { - return NotePermission.WRITE; + return NotePermissionLevel.WRITE; } else if (user?.username === onlyReadUsername) { - return NotePermission.READ; + return NotePermissionLevel.READ; } else { - return NotePermission.DENY; + return NotePermissionLevel.DENY; } }, }); diff --git a/backend/src/realtime/realtime-note/realtime-note.service.ts b/backend/src/realtime/realtime-note/realtime-note.service.ts index 1a5fb9ba6..f368d20ed 100644 --- a/backend/src/realtime/realtime-note/realtime-note.service.ts +++ b/backend/src/realtime/realtime-note/realtime-note.service.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -12,8 +12,8 @@ import appConfiguration, { AppConfig } from '../../config/app.config'; import { NoteEvent } from '../../events'; import { ConsoleLoggerService } from '../../logger/console-logger.service'; import { Note } from '../../notes/note.entity'; -import { NotePermission } from '../../permissions/note-permission.enum'; -import { PermissionsService } from '../../permissions/permissions.service'; +import { NotePermissionLevel } from '../../permissions/note-permission.enum'; +import { PermissionService } from '../../permissions/permission.service'; import { RevisionsService } from '../../revisions/revisions.service'; import { RealtimeConnection } from './realtime-connection'; import { RealtimeNote } from './realtime-note'; @@ -28,7 +28,7 @@ export class RealtimeNoteService implements BeforeApplicationShutdown { private schedulerRegistry: SchedulerRegistry, @Inject(appConfiguration.KEY) private appConfig: AppConfig, - private permissionService: PermissionsService, + private permissionService: PermissionService, ) {} beforeApplicationShutdown(): void { @@ -44,7 +44,7 @@ export class RealtimeNoteService implements BeforeApplicationShutdown { */ public saveRealtimeNote(realtimeNote: RealtimeNote): void { this.revisionsService - .createAndSaveRevision( + .createRevision( realtimeNote.getNote(), realtimeNote.getRealtimeDoc().getCurrentContent(), realtimeNote.getRealtimeDoc().encodeStateAsUpdate(), @@ -134,10 +134,10 @@ export class RealtimeNoteService implements BeforeApplicationShutdown { connection.getUser(), note, ); - if (permission === NotePermission.DENY) { + if (permission === NotePermissionLevel.DENY) { connection.getTransporter().disconnect(); } else { - connection.acceptEdits = permission > NotePermission.READ; + connection.acceptEdits = permission > NotePermissionLevel.READ; } } } diff --git a/backend/src/realtime/websocket/utils/extract-note-id-from-request-url.ts b/backend/src/realtime/websocket/utils/extract-note-id-from-request-url.ts index 93bedb1d2..c42b586c0 100644 --- a/backend/src/realtime/websocket/utils/extract-note-id-from-request-url.ts +++ b/backend/src/realtime/websocket/utils/extract-note-id-from-request-url.ts @@ -17,7 +17,7 @@ export function extractNoteIdFromRequestUrl(request: IncomingMessage): string { 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. + // 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 || noteId === '') { diff --git a/backend/src/realtime/websocket/websocket.gateway.spec.ts b/backend/src/realtime/websocket/websocket.gateway.spec.ts index 38167412a..bf57837ea 100644 --- a/backend/src/realtime/websocket/websocket.gateway.spec.ts +++ b/backend/src/realtime/websocket/websocket.gateway.spec.ts @@ -13,6 +13,7 @@ import { Mock } from 'ts-mockery'; import { Repository } from 'typeorm'; import WebSocket from 'ws'; +import { AliasModule } from '../../alias/alias.module'; import { ApiToken } from '../../api-token/api-token.entity'; import { Identity } from '../../auth/identity.entity'; import { Author } from '../../authors/author.entity'; @@ -24,16 +25,15 @@ import { User } from '../../database/user.entity'; import { eventModuleConfig } from '../../events'; import { Group } from '../../groups/group.entity'; import { LoggerModule } from '../../logger/logger.module'; -import { Alias } from '../../notes/alias.entity'; +import { Alias } from '../../notes/aliases.entity'; import { Note } from '../../notes/note.entity'; -import { NotesModule } from '../../notes/notes.module'; -import { NotesService } from '../../notes/notes.service'; +import { NoteService } from '../../notes/note.service'; import { Tag } from '../../notes/tag.entity'; import { NoteGroupPermission } from '../../permissions/note-group-permission.entity'; -import { NotePermission } from '../../permissions/note-permission.enum'; +import { NotePermissionLevel } from '../../permissions/note-permission.enum'; import { NoteUserPermission } from '../../permissions/note-user-permission.entity'; +import { PermissionService } from '../../permissions/permission.service'; 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 { Session } from '../../sessions/session.entity'; @@ -55,9 +55,9 @@ describe('Websocket gateway', () => { let gateway: WebsocketGateway; let sessionService: SessionService; let usersService: UsersService; - let notesService: NotesService; + let notesService: NoteService; let realtimeNoteService: RealtimeNoteService; - let permissionsService: PermissionsService; + let permissionsService: PermissionService; let mockedWebsocketConnection: RealtimeConnection; let mockedWebsocket: WebSocket; let mockedWebsocketCloseSpy: jest.SpyInstance; @@ -102,7 +102,7 @@ describe('Websocket gateway', () => { ], imports: [ LoggerModule, - NotesModule, + AliasModule, PermissionsModule, RealtimeNoteModule, UsersModule, @@ -150,9 +150,9 @@ describe('Websocket gateway', () => { gateway = module.get(WebsocketGateway); sessionService = module.get(SessionService); usersService = module.get(UsersService); - notesService = module.get(NotesService); + notesService = module.get(NoteService); realtimeNoteService = module.get(RealtimeNoteService); - permissionsService = module.get(PermissionsService); + permissionsService = module.get(PermissionService); jest .spyOn(sessionService, 'extractSessionIdFromRequest') @@ -209,7 +209,7 @@ describe('Websocket gateway', () => { groupPermissions: Promise.resolve([]), }); jest - .spyOn(notesService, 'getNoteByIdOrAlias') + .spyOn(notesService, 'getNoteIdByAlias') .mockImplementation((noteId: string) => { if (noteExistsForNoteId && noteId === mockedValidNoteId) { return Promise.resolve(mockedNote); @@ -224,13 +224,13 @@ describe('Websocket gateway', () => { jest .spyOn(permissionsService, 'determinePermission') .mockImplementation( - async (user: User | null, note: Note): Promise => + async (user: User | null, note: Note): Promise => (user === mockUser && note === mockedNote && userHasReadPermissions) || (user === null && note === mockedGuestNote) - ? NotePermission.READ - : NotePermission.DENY, + ? NotePermissionLevel.READ + : NotePermissionLevel.DENY, ); const mockedRealtimeNote = Mock.of({ diff --git a/backend/src/realtime/websocket/websocket.gateway.ts b/backend/src/realtime/websocket/websocket.gateway.ts index f51607295..845b7a8b8 100644 --- a/backend/src/realtime/websocket/websocket.gateway.ts +++ b/backend/src/realtime/websocket/websocket.gateway.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -10,13 +10,13 @@ import { } from '@hedgedoc/commons'; import { OnGatewayConnection, WebSocketGateway } from '@nestjs/websockets'; import { IncomingMessage } from 'http'; +import { FieldNameUser, User } from 'src/database/types'; import WebSocket from 'ws'; -import { User } from '../../database/user.entity'; import { ConsoleLoggerService } from '../../logger/console-logger.service'; -import { NotesService } from '../../notes/notes.service'; -import { NotePermission } from '../../permissions/note-permission.enum'; -import { PermissionsService } from '../../permissions/permissions.service'; +import { NoteService } from '../../notes/note.service'; +import { NotePermissionLevel } from '../../permissions/note-permission.enum'; +import { PermissionService } from '../../permissions/permission.service'; import { SessionService } from '../../sessions/session.service'; import { UsersService } from '../../users/users.service'; import { RealtimeConnection } from '../realtime-note/realtime-connection'; @@ -31,10 +31,10 @@ import { extractNoteIdFromRequestUrl } from './utils/extract-note-id-from-reques export class WebsocketGateway implements OnGatewayConnection { constructor( private readonly logger: ConsoleLoggerService, - private noteService: NotesService, + private noteService: NoteService, private realtimeNoteService: RealtimeNoteService, private userService: UsersService, - private permissionsService: PermissionsService, + private permissionsService: PermissionService, private sessionService: SessionService, ) { this.logger.setContext(WebsocketGateway.name); @@ -54,20 +54,18 @@ export class WebsocketGateway implements OnGatewayConnection { request: IncomingMessage, ): Promise { try { - const user = await this.findUserByRequestSession(request); - const note = await this.noteService.getNoteByIdOrAlias( + const userId = await this.findUserIdByRequestSession(request); + const noteId = await this.noteService.getNoteIdByAlias( extractNoteIdFromRequestUrl(request), ); - const username = user?.username ?? 'guest'; - const notePermission = await this.permissionsService.determinePermission( - user, - note, + userId, + noteId, ); - if (notePermission < NotePermission.READ) { + if (notePermission < NotePermissionLevel.READ) { this.logger.log( - `Access denied to note '${note.id}' for user '${username}'`, + `Access denied to note '${noteId}' for user '${userId}'`, 'handleConnection', ); clientSocket.close(DisconnectReason.USER_NOT_PERMITTED); @@ -75,27 +73,28 @@ export class WebsocketGateway implements OnGatewayConnection { } this.logger.debug( - `New realtime connection to note '${note.id}' (${ - note.publicId - }) by user '${username}' from ${ + `New realtime connection to note '${noteId}' by user '${userId}' from ${ request.socket.remoteAddress ?? 'unknown' }`, ); const realtimeNote = - await this.realtimeNoteService.getOrCreateRealtimeNote(note); + await this.realtimeNoteService.getOrCreateRealtimeNote(noteId); const websocketTransporter = new MessageTransporter(); websocketTransporter.setAdapter( new BackendWebsocketAdapter(clientSocket), ); - const permissions = await this.noteService.toNotePermissionsDto(note); - const acceptEdits: boolean = userCanEdit(permissions, user?.username); + const permissions = await this.noteService.toNotePermissionsDto(noteId); + const acceptEdits: boolean = userCanEdit( + permissions as NotePermissions, + userId, + ); const connection = new RealtimeConnection( websocketTransporter, - user, + userId, realtimeNote, acceptEdits, ); @@ -114,30 +113,21 @@ export class WebsocketGateway implements OnGatewayConnection { } /** - * Finds the {@link User} whose session cookie is saved in the given {@link IncomingMessage}. + * Finds the user id whose session cookie is saved in the given {@link IncomingMessage}. * * @param request The request that contains the session cookie - * @return The found user + * @return The found user id */ - private async findUserByRequestSession( + private async findUserIdByRequestSession( request: IncomingMessage, - ): Promise { + ): Promise { const sessionId = this.sessionService.extractSessionIdFromRequest(request); - - this.logger.debug( - 'Checking if sessionId is empty', - 'findUserByRequestSession', - ); if (sessionId.isEmpty()) { return null; } - this.logger.debug('sessionId is not empty', 'findUserByRequestSession'); - const username = await this.sessionService.fetchUsernameForSessionId( + const userId = await this.sessionService.getUserIdForSessionId( sessionId.get(), ); - if (username === undefined) { - return null; - } - return await this.userService.getUserByUsername(username); + return userId ?? null; } } diff --git a/backend/src/realtime/websocket/websocket.module.ts b/backend/src/realtime/websocket/websocket.module.ts index 83c3bff01..a9f4867c1 100644 --- a/backend/src/realtime/websocket/websocket.module.ts +++ b/backend/src/realtime/websocket/websocket.module.ts @@ -1,12 +1,12 @@ /* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ import { Module } from '@nestjs/common'; +import { AliasModule } from '../../alias/alias.module'; import { LoggerModule } from '../../logger/logger.module'; -import { NotesModule } from '../../notes/notes.module'; import { PermissionsModule } from '../../permissions/permissions.module'; import { SessionModule } from '../../sessions/session.module'; import { UsersModule } from '../../users/users.module'; @@ -16,7 +16,7 @@ import { WebsocketGateway } from './websocket.gateway'; @Module({ imports: [ LoggerModule, - NotesModule, + AliasModule, RealtimeNoteModule, UsersModule, PermissionsModule, diff --git a/backend/src/revisions/edit.service.ts b/backend/src/revisions/edit.service.ts deleted file mode 100644 index 99882473d..000000000 --- a/backend/src/revisions/edit.service.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { EditDto } from '@hedgedoc/commons'; -import { Injectable } from '@nestjs/common'; - -import { Edit } from './edit.entity'; - -@Injectable() -export class EditService { - async toEditDto(edit: Edit): Promise { - const authorUser = await (await edit.author).user; - - return { - username: authorUser ? authorUser.username : null, - startPosition: edit.startPos, - endPosition: edit.endPos, - createdAt: edit.createdAt.toISOString(), - updatedAt: edit.updatedAt.toISOString(), - }; - } -} diff --git a/backend/src/revisions/revisions.module.ts b/backend/src/revisions/revisions.module.ts index ccafea294..ebdd94ff7 100644 --- a/backend/src/revisions/revisions.module.ts +++ b/backend/src/revisions/revisions.module.ts @@ -5,23 +5,15 @@ */ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; -import { TypeOrmModule } from '@nestjs/typeorm'; +import { KnexModule } from 'nest-knexjs'; -import { AuthorsModule } from '../authors/authors.module'; +import { AliasModule } from '../alias/alias.module'; import { LoggerModule } from '../logger/logger.module'; -import { Note } from '../notes/note.entity'; -import { Edit } from './edit.entity'; import { EditService } from './edit.service'; -import { Revision } from './revision.entity'; import { RevisionsService } from './revisions.service'; @Module({ - imports: [ - TypeOrmModule.forFeature([Revision, Edit, Note]), - LoggerModule, - ConfigModule, - AuthorsModule, - ], + imports: [KnexModule, LoggerModule, ConfigModule, AliasModule], providers: [RevisionsService, EditService], exports: [RevisionsService, EditService], }) diff --git a/backend/src/revisions/revisions.service.spec.ts b/backend/src/revisions/revisions.service.spec.ts index 82d00e663..9bfa484a5 100644 --- a/backend/src/revisions/revisions.service.spec.ts +++ b/backend/src/revisions/revisions.service.spec.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -11,6 +11,7 @@ import { createPatch } from 'diff'; import { Mock } from 'ts-mockery'; import { DataSource, EntityManager, Repository } from 'typeorm'; +import { AliasModule } from '../alias/alias.module'; import { ApiToken } from '../api-token/api-token.entity'; import { Identity } from '../auth/identity.entity'; import { Author } from '../authors/author.entity'; @@ -28,9 +29,8 @@ import { NotInDBError } from '../errors/errors'; import { eventModuleConfig } from '../events'; import { Group } from '../groups/group.entity'; import { LoggerModule } from '../logger/logger.module'; -import { Alias } from '../notes/alias.entity'; +import { Alias } from '../notes/aliases.entity'; import { Note } from '../notes/note.entity'; -import { NotesModule } from '../notes/notes.module'; import { Tag } from '../notes/tag.entity'; import { NoteGroupPermission } from '../permissions/note-group-permission.entity'; import { NoteUserPermission } from '../permissions/note-user-permission.entity'; @@ -72,7 +72,7 @@ describe('RevisionsService', () => { }, ], imports: [ - NotesModule, + AliasModule, LoggerModule, ConfigModule.forRoot({ isGlobal: true, @@ -232,7 +232,7 @@ describe('RevisionsService', () => { const userInfo = await service.getRevisionUserInfo(revision); expect(userInfo.usernames.length).toEqual(1); - expect(userInfo.anonymousUserCount).toEqual(2); + expect(userInfo.guestUserCount).toEqual(2); }); }); diff --git a/backend/src/revisions/revisions.service.ts b/backend/src/revisions/revisions.service.ts index c00a9446f..7a6f3989a 100644 --- a/backend/src/revisions/revisions.service.ts +++ b/backend/src/revisions/revisions.service.ts @@ -6,162 +6,265 @@ import { RevisionDto, RevisionMetadataDto } from '@hedgedoc/commons'; import { Inject, Injectable } from '@nestjs/common'; import { Cron, Timeout } from '@nestjs/schedule'; -import { InjectRepository } from '@nestjs/typeorm'; import { createPatch } from 'diff'; -import { Repository } from 'typeorm'; +import { Knex } from 'knex'; +import { InjectConnection } from 'nest-knexjs'; +import { v7 as uuidv7 } from 'uuid'; +import { AliasService } from '../alias/alias.service'; import noteConfiguration, { NoteConfig } from '../config/note.config'; -import { NotInDBError } from '../errors/errors'; +import { + FieldNameAlias, + FieldNameAuthorshipInfo, + FieldNameNote, + FieldNameRevision, + FieldNameRevisionTag, + FieldNameUser, + Note, + Revision, + RevisionTag, + TableAlias, + TableAuthorshipInfo, + TableRevision, + TableRevisionTag, + TableUser, + User, +} from '../database/types'; +import { GenericDBError, NotInDBError } from '../errors/errors'; import { ConsoleLoggerService } from '../logger/console-logger.service'; -import { Note } from '../notes/note.entity'; -import { Tag } from '../notes/tag.entity'; -import { EditService } from './edit.service'; -import { Revision } from './revision.entity'; import { extractRevisionMetadataFromContent } from './utils/extract-revision-metadata-from-content'; -class RevisionUserInfo { - usernames: string[]; - anonymousUserCount: number; +interface RevisionUserInfo { + usernames: User[FieldNameUser.username][]; + guestUserCount: number; } @Injectable() export class RevisionsService { constructor( private readonly logger: ConsoleLoggerService, - @InjectRepository(Revision) - private revisionRepository: Repository, - @InjectRepository(Note) - private noteRepository: Repository, + private readonly aliasService: AliasService, + @InjectConnection() + private readonly knex: Knex, @Inject(noteConfiguration.KEY) private noteConfig: NoteConfig, - private editService: EditService, ) { this.logger.setContext(RevisionsService.name); } - async getAllRevisions(note: Note): Promise { - this.logger.debug(`Getting all revisions for note ${note.id}`); - return await this.revisionRepository - .createQueryBuilder('revision') - .where('revision.note = :note', { note: note.id }) - .getMany(); + /** + * Returns all revisions of a note + * + * @param noteId The id of the note + * @return The list of revisions + */ + async getAllRevisionMetadataDto( + noteId: number, + ): Promise { + const noteRevisions = await this.knex(TableRevision) + .distinct< + (Pick< + Revision, + | FieldNameRevision.uuid + | FieldNameRevision.createdAt + | FieldNameRevision.content + | FieldNameRevision.title + | FieldNameRevision.description + > & + Pick & + Pick)[] + >(`${TableRevision}.${FieldNameRevision.uuid}`, `${TableRevision}.${FieldNameRevision.createdAt}`, `${TableRevision}.${FieldNameRevision.description}`, `${TableRevision}.${FieldNameRevision.content}`, `${TableRevision}.${FieldNameRevision.title}`, `${TableUser}.${FieldNameUser.username}`, `${TableUser}.${FieldNameUser.guestUuid}`, `${TableRevisionTag}.${FieldNameRevisionTag.tag}`) + .join( + TableRevisionTag, + `${TableRevision}.${FieldNameRevision.uuid}`, + `${TableRevisionTag}.${FieldNameRevisionTag.revisionUuid}`, + ) + .join( + TableAuthorshipInfo, + `${TableRevision}.${FieldNameRevision.uuid}`, + `${TableAuthorshipInfo}.${FieldNameAuthorshipInfo.revisionUuid}`, + ) + .join( + TableUser, + `${TableAuthorshipInfo}.${FieldNameAuthorshipInfo.authorId}`, + `${TableUser}.${FieldNameUser.id}`, + ) + .orderBy(`${TableRevision}.${FieldNameRevision.createdAt}`, 'desc') + .orderBy(`${TableRevision}.${FieldNameRevision.uuid}`) + .where(FieldNameRevision.noteId, noteId); + + const revisionMap = noteRevisions.reduce((recordMap, revision) => { + const currentMappedRevision = recordMap.get( + revision[FieldNameRevision.uuid], + ); + if (currentMappedRevision !== undefined) { + const authorUsernames = currentMappedRevision.authorUsernames; + const authorGuestUuids = currentMappedRevision.authorGuestUuids; + const tags = currentMappedRevision.tags; + if (revision[FieldNameUser.username] !== null) { + if (!authorUsernames.includes(revision[FieldNameUser.username])) { + authorUsernames.push(revision[FieldNameUser.username]); + } + } + if (revision[FieldNameUser.guestUuid] !== null) { + if (!authorGuestUuids.includes(revision[FieldNameUser.guestUuid])) { + authorGuestUuids.push(revision[FieldNameUser.guestUuid]); + } + } + if (revision[FieldNameRevisionTag.tag] !== null) { + if (!tags.includes(revision[FieldNameRevisionTag.tag])) { + tags.push(revision[FieldNameRevisionTag.tag]); + } + } + recordMap.set(revision[FieldNameRevision.uuid], { + ...currentMappedRevision, + authorUsernames, + authorGuestUuids, + tags, + }); + } else { + recordMap.set(revision[FieldNameRevision.uuid], { + uuid: revision[FieldNameRevision.uuid], + length: (revision[FieldNameRevision.content] ?? '').length, + createdAt: revision[FieldNameRevision.createdAt].toISOString(), + authorUsernames: + revision[FieldNameUser.username] !== null + ? [revision[FieldNameUser.username]] + : [], + authorGuestUuids: + revision[FieldNameUser.guestUuid] !== null + ? [revision[FieldNameUser.guestUuid]] + : [], + title: revision[FieldNameRevision.title], + description: revision[FieldNameRevision.description], + tags: + revision[FieldNameRevisionTag.tag] !== null + ? [revision[FieldNameRevisionTag.tag]] + : [], + }); + } + return recordMap; + }, new Map()); + + return [...revisionMap.values()]; } /** - * @async * Purge revision history of a note. - * @param {Note} note - the note to purge the history - * @return {Revision[]} an array of purged revisions + * After this we don't know how anyone came to the content of the note. + * We only know the content of the note. + * + * @param noteId Id of the note to purge the history */ - async purgeRevisions(note: Note): Promise { - const revisions = await this.revisionRepository.find({ - where: { - note: { id: note.id }, - }, - }); - const latestRevision = await this.getLatestRevision(note); - // get all revisions except the latest - const oldRevisions = revisions.filter( - (item) => item.id !== latestRevision.id, - ); - - // update content diff - if (oldRevisions.length > 0) { - latestRevision.patch = createPatch( - note.publicId, + async purgeRevisions(noteId: Note[FieldNameNote.id]): Promise { + await this.knex.transaction(async (transaction) => { + const allRevisions = await transaction(TableRevision) + .select() + .where(FieldNameRevision.noteId, noteId) + .orderBy(FieldNameRevision.createdAt, 'desc'); + if (allRevisions.length === 0) { + this.logger.debug(`No revisions found for note ${noteId}`); + return []; + } + const latestRevision = allRevisions[0]; + const revisionsToDelete = allRevisions.filter( + (revision) => + revision[FieldNameRevision.uuid] !== + latestRevision[FieldNameRevision.uuid], + ); + const idsToDelete = revisionsToDelete.map( + (revision) => revision[FieldNameRevision.uuid], + ); + await transaction(TableRevision) + .whereIn(FieldNameRevision.uuid, idsToDelete) + .delete(); + const notePrimaryAlias = + await this.aliasService.getPrimaryAliasByNoteId(noteId); + const newPatch = createPatch( + notePrimaryAlias, '', - latestRevision.content, + latestRevision[FieldNameRevision.content], ); - await this.revisionRepository.save(latestRevision); - } - - // delete the old revisions - return await this.revisionRepository.remove(oldRevisions); + await transaction(TableRevision) + .update(FieldNameRevision.patch, newPatch) + .where(FieldNameRevision.uuid, latestRevision[FieldNameRevision.uuid]); + }); } - async getRevision(note: Note, revisionId: number): Promise { - const revision = await this.revisionRepository.findOne({ - where: { - id: revisionId, - note: { id: note.id }, - }, - }); - if (revision === null) { + async getRevisionDto(revisionUuid: string): Promise { + const revision = await this.knex(TableRevision) + .select( + FieldNameRevision.uuid, + FieldNameRevision.createdAt, + FieldNameRevision.description, + FieldNameRevision.content, + FieldNameRevision.title, + FieldNameRevision.patch, + ) + .where(FieldNameRevision.uuid, revisionUuid) + .first(); + if (revision === undefined) { throw new NotInDBError( - `Revision with ID ${revisionId} for note ${note.id} not found.`, + `Revision with ID ${revisionUuid} not found.`, + this.logger.getContext(), + 'getRevision', + ); + } + return { + uuid: revision[FieldNameRevision.uuid], + content: revision[FieldNameRevision.content], + length: (revision[FieldNameRevision.content] ?? '').length, + createdAt: revision[FieldNameRevision.createdAt].toISOString(), + title: revision[FieldNameRevision.title], + description: revision[FieldNameRevision.description], + patch: revision.patch, + }; + } + + async getLatestRevision(noteId: number): Promise { + const revision = await this.knex(TableRevision) + .select() + .where(FieldNameRevision.noteId, noteId) + .orderBy(FieldNameRevision.createdAt, 'desc') + .first(); + if (revision === undefined) { + throw new NotInDBError( + `No revisions for note ${noteId} found`, + this.logger.getContext(), + 'getLatestRevision', ); } return revision; } - async getLatestRevision(note: Note): Promise { - const revision = await this.revisionRepository.findOne({ - where: { - note: { id: note.id }, - }, - order: { - createdAt: 'DESC', - id: 'DESC', - }, - }); - if (revision === null) { - throw new NotInDBError(`Revision for note ${note.id} not found.`); + async getRevisionUserInfo(revisionId: number): Promise { + const authorUsernamesAndGuestUuids = (await this.knex(TableAuthorshipInfo) + .join( + TableUser, + `${TableAuthorshipInfo}.${FieldNameAuthorshipInfo.authorId}`, + `${TableUser}.${FieldNameUser.id}`, + ) + .select( + `${TableUser}.${FieldNameUser.username}`, + `${TableUser}.${FieldNameUser.guestUuid}`, + ) + .distinct(`${TableAuthorshipInfo}.${FieldNameAuthorshipInfo.authorId}`) + .where(FieldNameAuthorshipInfo.revisionUuid, revisionId)) as { + username: User[FieldNameUser.username]; + guestUuid: User[FieldNameUser.guestUuid]; + }[]; + const usernames: string[] = []; + let guestUserCount = 0; + for (const author of authorUsernamesAndGuestUuids) { + if (author.guestUuid !== null) { + guestUserCount++; + } + if (author.username !== null) { + usernames.push(author.username); + } } - return revision; - } - - async getRevisionUserInfo(revision: Revision): Promise { - // get a deduplicated list of all authors - let authors = await Promise.all( - (await revision.edits).map(async (edit) => await edit.author), - ); - authors = [...new Set(authors)]; // remove duplicates with Set - - // retrieve user objects of the authors - const users = await Promise.all( - authors.map(async (author) => await author.user), - ); - // collect usernames of the users - const usernames = users.flatMap((user) => (user ? [user.username] : [])); return { - usernames: usernames, - anonymousUserCount: users.length - usernames.length, - }; - } - - async toRevisionMetadataDto( - revision: Revision, - ): Promise { - const revisionUserInfo = await this.getRevisionUserInfo(revision); - return { - id: revision.id, - length: revision.length, - createdAt: revision.createdAt.toISOString(), - authorUsernames: revisionUserInfo.usernames, - anonymousAuthorCount: revisionUserInfo.anonymousUserCount, - title: revision.title, - description: revision.description, - tags: (await revision.tags).map((tag) => tag.name), - }; - } - - async toRevisionDto(revision: Revision): Promise { - const revisionUserInfo = await this.getRevisionUserInfo(revision); - return { - id: revision.id, - content: revision.content, - length: revision.length, - createdAt: revision.createdAt.toISOString(), - title: revision.title, - tags: (await revision.tags).map((tag) => tag.name), - description: revision.description, - authorUsernames: revisionUserInfo.usernames, - anonymousAuthorCount: revisionUserInfo.anonymousUserCount, - patch: revision.patch, - edits: await Promise.all( - (await revision.edits).map( - async (edit) => await this.editService.toEditDto(edit), - ), - ), + usernames, + guestUserCount, }; } @@ -170,69 +273,61 @@ export class RevisionsService { * Useful if the revision is saved together with the note in one action. * * @async - * @param note The note for which the revision should be created + * @param noteId The note for which the revision should be created * @param newContent The new note content * @param yjsStateVector The yjs state vector that describes the new content * @return {Revision} the created revision * @return {undefined} if the revision couldn't be created because e.g. the content hasn't changed */ async createRevision( - note: Note, + noteId: number, newContent: string, - yjsStateVector?: number[], - ): Promise { + yjsStateVector?: ArrayBuffer, + ): Promise { const latestRevision = - note.id === undefined ? undefined : await this.getLatestRevision(note); + noteId === undefined ? null : await this.getLatestRevision(noteId); const oldContent = latestRevision?.content; if (oldContent === newContent) { return undefined; } + const primaryAlias = + await this.aliasService.getPrimaryAliasByNoteId(noteId); const patch = createPatch( - note.publicId, + primaryAlias, latestRevision?.content ?? '', newContent, ); - const { title, description, tags } = + const { title, description, tags, noteType } = extractRevisionMetadataFromContent(newContent); - - const tagEntities = tags.map((tagName) => { - const entity = new Tag(); - entity.name = tagName; - return entity; + await this.knex.transaction(async (transaction) => { + const revisionIds = await transaction(TableRevision).insert( + { + [FieldNameRevision.uuid]: uuidv7(), + [FieldNameRevision.noteId]: noteId, + [FieldNameRevision.noteType]: noteType, + [FieldNameRevision.content]: newContent, + [FieldNameRevision.patch]: patch, + [FieldNameRevision.title]: title, + [FieldNameRevision.description]: description, + [FieldNameRevision.yjsStateVector]: yjsStateVector ?? null, + }, + [FieldNameRevision.uuid], + ); + if (revisionIds.length !== 1) { + throw new GenericDBError( + 'Failed to insert revision', + this.logger.getContext(), + 'createRevision', + ); + } + const revisionId = revisionIds[0][FieldNameRevision.uuid]; + await transaction(TableRevisionTag).insert( + tags.map((tag) => ({ + [FieldNameRevisionTag.tag]: tag, + [FieldNameRevisionTag.revisionUuid]: revisionId, + })), + ); }); - - return Revision.create( - newContent, - patch, - note, - yjsStateVector ?? null, - title, - description, - tagEntities, - ) as Revision; - } - - /** - * Creates and saves a new {@link Revision} for the given {@link Note}. - * - * @async - * @param note The note for which the revision should be created - * @param newContent The new note content - * @param yjsStateVector The yjs state vector that describes the new content - */ - async createAndSaveRevision( - note: Note, - newContent: string, - yjsStateVector?: number[], - ): Promise { - const revision = await this.createRevision( - note, - newContent, - yjsStateVector, - ); - if (revision) { - await this.revisionRepository.save(revision); - } } // Delete all old revisions everyday on 0:00 AM @@ -249,8 +344,6 @@ export class RevisionsService { /** * Delete old {@link Revision}s except the latest one. - * - * @async */ async removeOldRevisions(): Promise { const currentTime = new Date().getTime(); @@ -258,56 +351,82 @@ export class RevisionsService { if (revisionRetentionDays <= 0) { return; } - const revisionRetentionSeconds = + const revisionRetentionMilliSeconds = revisionRetentionDays * 24 * 60 * 60 * 1000; - const notes: Note[] = await this.noteRepository.find(); - for (const note of notes) { - const revisions: Revision[] = await this.revisionRepository.find({ - where: { - note: { id: note.id }, - }, - order: { - createdAt: 'ASC', - }, - }); + await this.knex.transaction(async (transaction) => { + // Delete old revisions + const noteIdsWhereRevisionWereDeleted = await transaction(TableRevision) + .where( + FieldNameRevision.createdAt, + '<=', + currentTime - revisionRetentionMilliSeconds, + ) + .delete(FieldNameRevision.noteId); - const oldRevisions = revisions - .slice(0, -1) // always keep the latest revision - .filter( - (revision) => - new Date(revision.createdAt).getTime() <= - currentTime - revisionRetentionSeconds, - ); - const remainedRevisions = revisions.filter( - (val) => !oldRevisions.includes(val), - ); - - if (!oldRevisions.length) { - continue; - } else if (oldRevisions.length === revisions.length - 1) { - const beUpdatedRevision = revisions.slice(-1)[0]; - beUpdatedRevision.patch = createPatch( - note.publicId, - '', // there is no older revision - beUpdatedRevision.content, - ); - await this.revisionRepository.save(beUpdatedRevision); - } else { - const beUpdatedRevision = remainedRevisions.slice(0)[0]; - beUpdatedRevision.patch = createPatch( - note.publicId, - oldRevisions.slice(-1)[0].content, - beUpdatedRevision.content, - ); - await this.revisionRepository.save(beUpdatedRevision); - } - - await this.revisionRepository.remove(oldRevisions); this.logger.log( - `${oldRevisions.length} old revisions of the note '${note.id}' were removed from the DB`, + `${noteIdsWhereRevisionWereDeleted.length} old revisions were removed from the DB`, 'removeOldRevisions', ); - } + + if (noteIdsWhereRevisionWereDeleted.length === 0) { + return; + } + + const uniqueNoteIds = Array.from( + new Set( + noteIdsWhereRevisionWereDeleted.map( + (entry) => entry[FieldNameRevision.noteId], + ), + ), + ); + + const revisionsToUpdate = await transaction(TableRevision) + .join( + TableAlias, + `${TableAlias}.${FieldNameAlias.noteId}`, + `${TableRevision}.${FieldNameRevision.noteId}`, + ) + .select( + FieldNameRevision.uuid, + FieldNameRevision.noteId, + FieldNameRevision.content, + FieldNameAlias.alias, + ) + .whereIn(FieldNameRevision.noteId, uniqueNoteIds) + .andWhere(FieldNameAlias.isPrimary, true) + .orderBy([ + { column: FieldNameRevision.noteId }, + { column: FieldNameRevision.createdAt, order: 'ASC' }, + ]); + + let lastNoteId = -1; + let lastContent = ''; + + for (const revisionToUpdate of revisionsToUpdate) { + const id = revisionToUpdate[FieldNameRevision.uuid]; + const noteId = revisionToUpdate[FieldNameRevision.noteId]; + const primaryAlias = revisionToUpdate[FieldNameAlias.alias]; + const content = revisionToUpdate[FieldNameRevision.content]; + + let newPatch = ''; + if (noteId !== lastNoteId) { + newPatch = createPatch( + primaryAlias, + '', // There is no older Revision + content, + ); + } else { + newPatch = createPatch(primaryAlias, lastContent, content); + } + + await transaction(TableRevision) + .update(FieldNameRevision.patch, newPatch) + .where(FieldNameRevision.uuid, id); + + lastNoteId = noteId; + lastContent = content; + } + }); } } diff --git a/backend/src/revisions/utils/extract-revision-metadata-from-content.ts b/backend/src/revisions/utils/extract-revision-metadata-from-content.ts index 1d8a9f1e5..2e0b89809 100644 --- a/backend/src/revisions/utils/extract-revision-metadata-from-content.ts +++ b/backend/src/revisions/utils/extract-revision-metadata-from-content.ts @@ -10,6 +10,7 @@ import { extractFrontmatter, generateNoteTitle, NoteFrontmatter, + NoteType, parseRawFrontmatterFromYaml, } from '@hedgedoc/commons'; import { parseDocument } from 'htmlparser2'; @@ -19,6 +20,7 @@ interface FrontmatterExtractionResult { title: string; description: string; tags: string[]; + noteType: NoteType; } interface FrontmatterParserResult { @@ -45,8 +47,9 @@ export function extractRevisionMetadataFromContent( ); const description = frontmatter?.description ?? ''; const tags = frontmatter?.tags ?? []; + const noteType = frontmatter?.type ?? NoteType.DOCUMENT; - return { title, description, tags }; + return { title, description, tags, noteType }; } function generateContentWithoutFrontmatter( diff --git a/backend/src/sessions/keyv-session-store.ts b/backend/src/sessions/keyv-session-store.ts new file mode 100644 index 000000000..57d5fb439 --- /dev/null +++ b/backend/src/sessions/keyv-session-store.ts @@ -0,0 +1,64 @@ +/* + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { SessionData, Store } from 'express-session'; +import Keyv from 'keyv'; + +export interface SessionStoreOptions { + /** The time how long a session lives in seconds */ + ttl?: number; +} + +export class KeyvSessionStore extends Store { + private readonly dataStore: Keyv; + + constructor(options?: SessionStoreOptions) { + super(); + this.dataStore = new Keyv({ + namespace: 'sessions', + ttl: options?.ttl, + // TODO Add support for non-in-memory keyv backends like redis/valkey + }); + } + + destroy(sid: string, callback: (error?: Error) => void): void { + this.dataStore + .delete(sid) + .then(() => callback()) + .catch(callback); + } + + clear(callback: (error?: Error) => void): void { + this.dataStore + .clear() + .then(() => { + callback(undefined); + }) + .catch(callback); + } + + get(sid: string, callback: (error?: Error, session?: T) => void): void { + this.dataStore + .get(sid) + .then((session) => callback(undefined, session)) + .catch((error: Error) => callback(error)); + } + + set(sid: string, session: T, callback: (error?: Error) => void): void { + this.dataStore + .set(sid, session) + .then(() => callback()) + .catch(callback); + } + + touch(sid: string, session: T, callback: (error?: Error) => void): void { + // Keyv does not allow updating the TTL of an existing entry, so we just set it again + this.set(sid, session, callback); + } + + getAsync(sid: string): Promise { + return this.dataStore.get(sid); + } +} diff --git a/backend/src/sessions/session-state.type.ts b/backend/src/sessions/session-state.type.ts new file mode 100644 index 000000000..2913a04b0 --- /dev/null +++ b/backend/src/sessions/session-state.type.ts @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { AuthProviderType, FullUserInfoDto } from '@hedgedoc/commons'; +import { Cookie } from 'express-session'; + +import { FieldNameUser, User } from '../database/types'; + +export interface SessionState { + /** Details about the currently used session cookie */ + cookie: Cookie; + + /** Contains the username if logged in completely, is undefined when not being logged in */ + userId?: User[FieldNameUser.id]; + + /** The auth provider that is used for the current login or pending login */ + authProviderType?: AuthProviderType; + + /** The identifier of the auth provider that is used for the current login or pending login */ + authProviderIdentifier?: string; + + /** The id token to identify a user session with an OIDC auth provider, required for the logout */ + oidcIdToken?: string; + + /** The (random) OIDC code for verifying that OIDC responses match the OIDC requests */ + oidcLoginCode?: string; + + /** The (random) OIDC state for verifying that OIDC responses match the OIDC requests */ + oidcLoginState?: string; + + /** The user id as provided from the external auth provider, required for matching to a HedgeDoc identity */ + providerUserId?: string; + + /** The user data of the user that is currently being created */ + newUserData?: FullUserInfoDto; +} diff --git a/backend/src/sessions/session.entity.ts b/backend/src/sessions/session.entity.ts deleted file mode 100644 index 4396a6ef1..000000000 --- a/backend/src/sessions/session.entity.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { ISession } from 'connect-typeorm'; -import { - Column, - DeleteDateColumn, - Entity, - Index, - ManyToOne, - PrimaryColumn, -} from 'typeorm'; - -import { Author } from '../authors/author.entity'; - -@Entity() -export class Session implements ISession { - @PrimaryColumn('varchar', { length: 255 }) - public id = ''; - - @Index() - @Column('bigint') - public expiredAt = Date.now(); - - @Column('text') - public json = ''; - - @DeleteDateColumn() - public destroyedAt?: Date; - - @ManyToOne(() => Author, (author) => author.sessions) - author: Promise; -} diff --git a/backend/src/sessions/session.module.ts b/backend/src/sessions/session.module.ts index f8f5b1a3c..a226ef8ea 100644 --- a/backend/src/sessions/session.module.ts +++ b/backend/src/sessions/session.module.ts @@ -4,14 +4,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; import { LoggerModule } from '../logger/logger.module'; -import { Session } from './session.entity'; import { SessionService } from './session.service'; @Module({ - imports: [TypeOrmModule.forFeature([Session]), LoggerModule], + imports: [LoggerModule], exports: [SessionService], providers: [SessionService], }) diff --git a/backend/src/sessions/session.service.spec.ts b/backend/src/sessions/session.service.spec.ts index ed50f1100..cdb462647 100644 --- a/backend/src/sessions/session.service.spec.ts +++ b/backend/src/sessions/session.service.spec.ts @@ -86,13 +86,13 @@ describe('SessionService', () => { it('can fetch a username for an existing session', async () => { await expect( - sessionService.fetchUsernameForSessionId(mockedExistingSessionId), + sessionService.getUserIdForSessionId(mockedExistingSessionId), ).resolves.toBe(mockUsername); }); it("can't fetch a username for a non-existing session", async () => { await expect( - sessionService.fetchUsernameForSessionId("doesn't exist"), + sessionService.getUserIdForSessionId("doesn't exist"), ).rejects.toThrow(); }); diff --git a/backend/src/sessions/session.service.ts b/backend/src/sessions/session.service.ts index 6ac7884c9..d4f26a03e 100644 --- a/backend/src/sessions/session.service.ts +++ b/backend/src/sessions/session.service.ts @@ -3,53 +3,18 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { FullUserInfoDto, ProviderType } from '@hedgedoc/commons'; 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 { FieldNameUser, User } from '../database/types'; import { ConsoleLoggerService } from '../logger/console-logger.service'; import { HEDGEDOC_SESSION } from '../utils/session'; -import { Session } from './session.entity'; - -export interface SessionState { - /** Details about the currently used session cookie */ - cookie: unknown; - - /** Contains the username if logged in completely, is undefined when not being logged in */ - username?: string; - - /** The auth provider that is used for the current login or pending login */ - authProviderType?: ProviderType; - - /** The identifier of the auth provider that is used for the current login or pending login */ - authProviderIdentifier?: string; - - /** The id token to identify a user session with an OIDC auth provider, required for the logout */ - oidcIdToken?: string; - - /** The (random) OIDC code for verifying that OIDC responses match the OIDC requests */ - oidcLoginCode?: string; - - /** The (random) OIDC state for verifying that OIDC responses match the OIDC requests */ - oidcLoginState?: string; - - /** The user id as provided from the external auth provider, required for matching to a HedgeDoc identity */ - providerUserId?: string; - - /** The user data of the user that is currently being created */ - newUserData?: FullUserInfoDto; -} +import { KeyvSessionStore } from './keyv-session-store'; +import { SessionState } from './session-state.type'; /** * Finds {@link Session sessions} by session id and verifies session cookies. @@ -57,53 +22,41 @@ export interface SessionState { @Injectable() export class SessionService { private static readonly sessionCookieContentRegex = /^s:(([^.]+)\.(.+))$/; - private readonly typeormStore: TypeormStore; + private readonly sessionStore: KeyvSessionStore; constructor( private readonly logger: ConsoleLoggerService, - @InjectRepository(Session) private sessionRepository: Repository, - @Inject(databaseConfiguration.KEY) - private dbConfig: DatabaseConfig, + @Inject(authConfiguration.KEY) private authConfig: AuthConfig, ) { this.logger.setContext(SessionService.name); - this.typeormStore = new TypeormStore({ - cleanupLimit: 2, - limitSubquery: dbConfig.type !== DatabaseType.MARIADB, - }).connect(sessionRepository); - } - - getTypeormStore(): TypeormStore { - return this.typeormStore; + this.sessionStore = new KeyvSessionStore({ + ttl: authConfig.session.lifetime, + }); } /** - * Finds the username of the user that own the given session id. + * Returns the currently used session store for usage outside of the HTTP session context + * Note that this method is also used for connecting the session store with NestJS initially + * + * @return The used session store + */ + getSessionStore(): KeyvSessionStore { + return this.sessionStore; + } + + /** + * Finds the username of the user that has the given session id * - * @async * @param sessionId The session id for which the owning user should be found * @return A Promise that either resolves with the username or rejects with an error */ - fetchUsernameForSessionId(sessionId: string): Promise { - return new Promise((resolve, reject) => { - this.logger.debug( - `Fetching username for sessionId ${sessionId}`, - 'fetchUsernameForSessionId', - ); - this.typeormStore.get( - sessionId, - (error?: Error, result?: SessionState) => { - this.logger.debug( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `Got error ${error}, result ${result?.username} for sessionId ${sessionId}`, - 'fetchUsernameForSessionId', - ); - if (error) return reject(error); - return resolve(result?.username); - }, - ); - }); + async getUserIdForSessionId( + sessionId: string, + ): Promise { + const session = await this.sessionStore.getAsync(sessionId); + return session?.userId; } /** @@ -123,7 +76,7 @@ export class SessionService { } /** - * Parses the given session cookie content and extracts the session id. + * Parses the given session cookie content and extracts the session id * * @param rawCookie The cookie to parse * @return The extracted session id diff --git a/backend/src/users/user-relation.enum.ts b/backend/src/users/user-relation.enum.ts deleted file mode 100644 index bad202ae5..000000000 --- a/backend/src/users/user-relation.enum.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export enum UserRelationEnum { - AUTHTOKENS = 'authTokens', - IDENTITIES = 'identities', -} diff --git a/backend/src/users/users.module.ts b/backend/src/users/users.module.ts index 027d0f14b..abbebe288 100644 --- a/backend/src/users/users.module.ts +++ b/backend/src/users/users.module.ts @@ -4,16 +4,14 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; +import { KnexModule } from 'nest-knexjs'; -import { Identity } from '../auth/identity.entity'; -import { User } from '../database/user.entity'; import { LoggerModule } from '../logger/logger.module'; import { Session } from '../sessions/session.entity'; import { UsersService } from './users.service'; @Module({ - imports: [TypeOrmModule.forFeature([User, Identity]), LoggerModule, Session], + imports: [KnexModule, LoggerModule, Session], providers: [UsersService], exports: [UsersService], }) diff --git a/backend/src/users/users.service.spec.ts b/backend/src/users/users.service.spec.ts index bd0219ae6..696758a67 100644 --- a/backend/src/users/users.service.spec.ts +++ b/backend/src/users/users.service.spec.ts @@ -3,165 +3,3 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { ConfigModule } from '@nestjs/config'; -import { Test, TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; - -import appConfigMock from '../config/mock/app.config.mock'; -import authConfigMock from '../config/mock/auth.config.mock'; -import { User } from '../database/user.entity'; -import { AlreadyInDBError, NotInDBError } from '../errors/errors'; -import { LoggerModule } from '../logger/logger.module'; -import { UsersService } from './users.service'; - -describe('UsersService', () => { - let service: UsersService; - let userRepo: Repository; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - UsersService, - { - provide: getRepositoryToken(User), - useClass: Repository, - }, - ], - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - load: [appConfigMock, authConfigMock], - }), - LoggerModule, - ], - }).compile(); - - service = module.get(UsersService); - userRepo = module.get>(getRepositoryToken(User)); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - describe('createUser', () => { - const username = 'hardcoded'; - const displayname = 'Testy'; - beforeEach(() => { - jest - .spyOn(userRepo, 'save') - .mockImplementationOnce(async (user: User): Promise => user); - }); - it('successfully creates a user', async () => { - const user = await service.createUser(username, displayname, null, null); - expect(user.username).toEqual(username); - expect(user.displayName).toEqual(displayname); - }); - it('fails if username is already taken', async () => { - // add additional mock implementation for failure - jest.spyOn(userRepo, 'save').mockImplementationOnce(() => { - throw new Error(); - }); - // create first user with username - await service.createUser(username, displayname, null, null); - // attempt to create second user with username - await expect( - service.createUser(username, displayname, null, null), - ).rejects.toThrow(AlreadyInDBError); - }); - }); - - describe('deleteUser', () => { - it('works', async () => { - const username = 'hardcoded'; - const displayname = 'Testy'; - const newUser = User.create(username, displayname) as User; - jest.spyOn(userRepo, 'remove').mockImplementationOnce( - // eslint-disable-next-line @typescript-eslint/require-await - async (user: User): Promise => { - expect(user).toEqual(newUser); - return user; - }, - ); - await service.deleteUser(newUser); - }); - }); - - describe('changedDisplayName', () => { - it('works', async () => { - const username = 'hardcoded'; - const displayname = 'Testy'; - const user = User.create(username, displayname) as User; - const newDisplayName = 'Testy2'; - jest.spyOn(userRepo, 'save').mockImplementationOnce( - // eslint-disable-next-line @typescript-eslint/require-await - async (user: User): Promise => { - expect(user.displayName).toEqual(newDisplayName); - return user; - }, - ); - await service.updateUser(user, newDisplayName, undefined, undefined); - }); - }); - - describe('getUserByUsername', () => { - const username = 'hardcoded'; - const displayname = 'Testy'; - const user = User.create(username, displayname) as User; - it('works', async () => { - jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce(user); - const getUser = await service.getUserByUsername(username); - expect(getUser.username).toEqual(username); - expect(getUser.displayName).toEqual(displayname); - }); - it('fails when user does not exits', async () => { - jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce(null); - await expect(service.getUserByUsername(username)).rejects.toThrow( - NotInDBError, - ); - }); - }); - - describe('getPhotoUrl', () => { - const username = 'hardcoded'; - const displayname = 'Testy'; - const user = User.create(username, displayname) as User; - it('works if a user has a photoUrl', () => { - const photo = 'testPhotoUrl'; - user.photo = photo; - const photoUrl = service.getPhotoUrl(user); - expect(photoUrl).toEqual(photo); - }); - it('works if a user no photoUrl', () => { - user.photo = null; - const photoUrl = service.getPhotoUrl(user); - expect(photoUrl).toEqual(''); - }); - }); - - describe('toUserDto', () => { - const username = 'hardcoded'; - const displayname = 'Testy'; - const user = User.create(username, displayname) as User; - it('works if a user is provided', () => { - const userDto = service.toUserDto(user); - expect(userDto.username).toEqual(username); - expect(userDto.displayName).toEqual(displayname); - expect(userDto.photoUrl).toEqual(''); - }); - }); - - describe('toFullUserDto', () => { - const username = 'hardcoded'; - const displayname = 'Testy'; - const user = User.create(username, displayname) as User; - it('works if a user is provided', () => { - const userDto = service.toFullUserDto(user); - expect(userDto.username).toEqual(username); - expect(userDto.displayName).toEqual(displayname); - expect(userDto.photoUrl).toEqual(''); - expect(userDto.email).toEqual(''); - }); - }); -}); diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index c00fa9d68..ef3272180 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -3,200 +3,344 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { - FullUserInfoDto, - LoginUserInfoDto, - ProviderType, - REGEX_USERNAME, - UserInfoDto, -} from '@hedgedoc/commons'; -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { ProviderType, REGEX_USERNAME, UserInfoDto } from '@hedgedoc/commons'; +import { LoginUserInfoDto } from '@hedgedoc/commons'; +import { BadRequestException, Injectable } from '@nestjs/common'; +import { Knex } from 'knex'; +import { InjectConnection } from 'nest-knexjs'; +import { v4 as uuidv4 } from 'uuid'; -import AuthConfiguration, { AuthConfig } from '../config/auth.config'; -import { User } from '../database/user.entity'; -import { AlreadyInDBError, NotInDBError } from '../errors/errors'; +import { FieldNameUser, TableUser, User } from '../database/types'; +import { TypeUpdateUser } from '../database/types/user'; +import { GenericDBError, NotInDBError } from '../errors/errors'; import { ConsoleLoggerService } from '../logger/console-logger.service'; -import { UserRelationEnum } from './user-relation.enum'; +import { generateRandomName } from '../realtime/realtime-note/random-word-lists/name-randomizer'; @Injectable() export class UsersService { constructor( private readonly logger: ConsoleLoggerService, - @Inject(AuthConfiguration.KEY) - private authConfig: AuthConfig, - @InjectRepository(User) private userRepository: Repository, + + @InjectConnection() + private readonly knex: Knex, ) { this.logger.setContext(UsersService.name); } /** - * @async - * Create a new user with a given username and displayName - * @param {string} username - the username the new user shall have - * @param {string} displayName - the display name the new user shall have - * @param {string} [email] - the email the new user shall have - * @param {string} [photoUrl] - the photoUrl the new user shall have - * @return {User} the user + * Creates a new user with a given username and displayName + * + * @param username New user's username + * @param displayName New user's displayName + * @param [email] New user's email address if exists + * @param [photoUrl] URL of the user's profile picture if exists + * @param transaction The optional transaction to access the db + * @return The id of newly created user * @throws {BadRequestException} if the username contains invalid characters or is too short * @throws {AlreadyInDBError} the username is already taken. + * @thorws {GenericDBError} the database returned a non-expected value */ async createUser( username: string, displayName: string, email: string | null, photoUrl: string | null, - ): Promise { + transaction?: Knex, + ): Promise { if (!REGEX_USERNAME.test(username)) { throw new BadRequestException( `The username '${username}' is not a valid username.`, ); } - const user = User.create( - username, - displayName, - email || undefined, - photoUrl || undefined, - ); + + const dbActor = transaction ? transaction : this.knex; try { - return await this.userRepository.save(user); - } catch { - this.logger.debug( - `A user with the username '${username}' already exists.`, - 'createUser', + const newUsers = await dbActor(TableUser).insert( + { + [FieldNameUser.username]: username, + [FieldNameUser.displayName]: displayName, + [FieldNameUser.email]: email ?? null, + [FieldNameUser.photoUrl]: photoUrl ?? null, + [FieldNameUser.guestUuid]: null, + [FieldNameUser.authorStyle]: 0, + // FIXME Set unique authorStyle per user + }, + [FieldNameUser.id], ); - throw new AlreadyInDBError( - `A user with the username '${username}' already exists.`, + if (newUsers.length !== 1) { + throw new Error(); + } + return newUsers[0][FieldNameUser.id]; + } catch { + throw new GenericDBError( + `Failed to create user '${username}', no user was created.`, + this.logger.getContext(), + 'createUser', ); } } /** - * @async - * Delete the user with the specified username - * @param {User} user - the username of the user to be delete - * @throws {NotInDBError} the username has no user associated with it. + * Creates a new guest user with a random displayName + * + * @return The guest uuid and the id of the newly created user + * @throws {GenericDBError} the database returned a non-expected value */ - async deleteUser(user: User): Promise { - await this.userRepository.remove(user); - this.logger.debug( - `Successfully deleted user with username ${user.username}`, - 'deleteUser', + async createGuestUser(): Promise<[string, number]> { + const randomName = generateRandomName(); + const uuid = uuidv4(); + const createdUserIds = await this.knex(TableUser).insert( + { + [FieldNameUser.username]: null, + [FieldNameUser.displayName]: `Guest ${randomName}`, + [FieldNameUser.email]: null, + [FieldNameUser.photoUrl]: null, + [FieldNameUser.guestUuid]: uuid, + [FieldNameUser.authorStyle]: 0, + // FIXME Set unique authorStyle per user + }, + [FieldNameUser.id], ); + if (createdUserIds.length !== 1) { + throw new GenericDBError( + 'Failed to create guest user', + this.logger.getContext(), + 'createGuestUser', + ); + } + const newUserId = createdUserIds[0][FieldNameUser.id]; + return [uuid, newUserId]; } /** - * @async - * Update the given User with the given information. + * Deletes a user by its id + * + * @param userId id of the user to be deleted + * @throws {NotInDBError} the username has no user associated with it + */ + async deleteUser(userId: number): Promise { + const usersDeleted = await this.knex(TableUser) + .where(FieldNameUser.id, userId) + .delete(); + if (usersDeleted === 0) { + throw new NotInDBError( + `User with id '${userId}' not found`, + this.logger.getContext(), + 'deletUser', + ); + } + if (usersDeleted > 1) { + this.logger.error( + `Deleted multiple (${usersDeleted}) users with the same userId '${userId}'. This should never happen!`, + 'deleteUser', + ); + } + } + + /** + * Updates the given User with new information * Use {@code null} to clear the stored value (email or profilePicture). * Use {@code undefined} to keep the stored value. - * @param {User} user - the User to update - * @param {string | undefined} displayName - the displayName to update the user with - * @param {string | null | undefined} email - the email to update the user with - * @param {string | null | undefined} profilePicture - the profilePicture to update the user with + * + * @param username The username of the user to update + * @param displayName The new display name + * @param email The new email address + * @param profilePicture The new profile picture URL */ async updateUser( - user: User, + username: string, displayName?: string, email?: string | null, profilePicture?: string | null, - ): Promise { - let shouldSave = false; + ): Promise { + const updateData = {} as TypeUpdateUser; if (displayName !== undefined) { - user.displayName = displayName; - shouldSave = true; + updateData[FieldNameUser.displayName] = displayName; } if (email !== undefined) { - user.email = email; - shouldSave = true; + updateData[FieldNameUser.email] = email; } if (profilePicture !== undefined) { - user.photo = profilePicture; - shouldSave = true; - // ToDo: handle LDAP images (https://github.com/hedgedoc/hedgedoc/issues/5032) + updateData[FieldNameUser.photoUrl] = profilePicture; } - if (shouldSave) { - return await this.userRepository.save(user); + if (Object.keys(updateData).length === 0) { + this.logger.debug('No update data provided.', 'updateUser'); + return; + } + const result = await this.knex(TableUser) + .where(FieldNameUser.username, username) + .update(updateData); + if (result !== 1) { + throw new NotInDBError( + `Failed to update user '${username}'.`, + this.logger.getContext(), + 'updateUser', + ); + } + } + + /** + * Checks if a given username is already taken + * + * @param username The username to check + * @return true if the user exists, false otherwise + */ + async isUsernameTaken(username: string): Promise { + const result = await this.knex(TableUser) + .select(FieldNameUser.username) + .where(FieldNameUser.username, username); + return result.length === 1; + } + + /** + * Checks if a given user is a registered user in contrast to a guest user + * + * @param userId The id of the user to check + * @param transaction the optional transaction to access the db + * @return true if the user is registered, false otherwise + */ + async isRegisteredUser( + userId: User[FieldNameUser.id], + transaction?: Knex, + ): Promise { + const dbActor = transaction ? transaction : this.knex; + const username = await dbActor(TableUser) + .select(FieldNameUser.username) + .where(FieldNameUser.id, userId) + .first(); + return username !== null && username !== undefined; + } + + /** + * Fetches the userId for a given username from the database + * + * @param username The username to fetch + * @return The found user object + * @throws {NotInDBError} if the user could not be found + */ + async getUserIdByUsername(username: string): Promise { + const userId = await this.knex(TableUser) + .select(FieldNameUser.id) + .where(FieldNameUser.username, username) + .first(); + if (userId === undefined) { + throw new NotInDBError( + `User with username "${username}" does not exist`, + this.logger.getContext(), + 'getUserIdByUsername', + ); + } + return userId[FieldNameUser.id]; + } + + /** + * Fetches the userId for a given username from the database + * + * @param uuid The uuid to fetch + * @return The found user object + * @throws {NotInDBError} if the user could not be found + */ + async getUserIdByGuestUuid(uuid: string): Promise { + const userId = await this.knex(TableUser) + .select(FieldNameUser.id) + .where(FieldNameUser.guestUuid, uuid) + .first(); + if (userId === undefined) { + throw new NotInDBError( + `User with uuid "${uuid}" does not exist`, + this.logger.getContext(), + 'getUserIdByGuestUuid', + ); + } + return userId[FieldNameUser.id]; + } + + /** + * Fetches the user object for a given username from the database + * + * @param username The username to fetch + * @return The found user object + * @throws {NotInDBError} if the user could not be found + */ + async getUserByUsername(username: string): Promise { + const user = await this.knex(TableUser) + .select() + .where(FieldNameUser.username, username) + .first(); + if (!user) { + throw new NotInDBError(`User with username "${username}" does not exist`); } return user; } /** - * @async - * Checks if the user with the specified username exists - * @param username - the username to check - * @return {boolean} true if the user exists, false otherwise + * Fetches the user object for a given username from the database + * + * @param userId The username to fetch + * @return The found user object + * @throws {NotInDBError} if the user could not be found */ - async checkIfUserExists(username: string): Promise { - const user = await this.userRepository.findOne({ - where: { username: username }, - }); - return user !== null; - } - - /** - * @async - * Get the user specified by the username - * @param {string} username the username by which the user is specified - * @param {UserRelationEnum[]} [withRelations=[]] if the returned user object should contain certain relations - * @return {User} the specified user - */ - async getUserByUsername( - username: string, - withRelations: UserRelationEnum[] = [], - ): Promise { - const user = await this.userRepository.findOne({ - where: { username: username }, - relations: withRelations, - }); - if (user === null) { - throw new NotInDBError(`User with username '${username}' not found`); + async getUserById(userId: number): Promise { + const user = await this.knex(TableUser) + .select() + .where(FieldNameUser.id, userId) + .first(); + if (!user) { + throw new NotInDBError(`User with id "${userId}" does not exist`); } return user; } /** - * Extract the photoUrl of the user or in case no photo url is present generate a deterministic user photo - * @param {User} user - the specified User - * @return the url of the photo + * Extract the photoUrl of the user or falls back to libravatar if enabled + * + * @param user The user of which to get the photo url + * @return A URL to the user's profile picture. If the user has no photo and libravatar support is enabled, + * a URL to that is returned. Otherwise, undefined is returned to indicate that the frontend needs to generate + * a random avatar image based on the username. */ - getPhotoUrl(user: User): string { - if (user.photo) { - return user.photo; + getPhotoUrl(user: User): string | undefined { + if (user[FieldNameUser.photoUrl]) { + return user[FieldNameUser.photoUrl]; } else { - return ''; + // TODO If libravatar is enabled and the user has an email address, use it to fetch the profile picture from there + // Otherwise return undefined to let the frontend generate a random avatar image (#5010) + return undefined; } } /** - * Build UserInfoDto from a user. - * @param {User=} user - the user to use - * @return {(UserInfoDto)} the built UserInfoDto + * Build UserInfoDto from a user object + * + * @param user The user object to transform + * @return The built UserInfoDto */ toUserDto(user: User): UserInfoDto { + if (user[FieldNameUser.username] === null) { + throw new BadRequestException( + `Cannot create UserInfoDto from a guest user.`, + ); + } return { - username: user.username, - displayName: user.displayName, - photoUrl: this.getPhotoUrl(user), + username: user[FieldNameUser.username], + displayName: + user[FieldNameUser.displayName] ?? user[FieldNameUser.username], + photoUrl: this.getPhotoUrl(user) ?? null, }; } /** - * Build FullUserInfoDto from a user. - * @param {User=} user - the user to use - * @return {(UserInfoDto)} the built FullUserInfoDto + * Builds a DTO for the user used when the user requests their own data + * + * @param user The user to fetch their data for + * @param authProvider The auth provider used for the current login session + * @return The built OwnUserInfoDto */ - toFullUserDto(user: User): FullUserInfoDto { + toLoginUserInfoDto(user: User, authProvider: ProviderType): LoginUserInfoDto { return { - username: user.username, - displayName: user.displayName, - photoUrl: this.getPhotoUrl(user), - email: user.email ?? '', + ...this.toUserDto(user), + email: user[FieldNameUser.email] ?? null, + authProvider, }; } - - toLoginUserInfoDto(user: User, authProvider: ProviderType): LoginUserInfoDto { - return { ...this.toFullUserDto(user), authProvider }; - } } diff --git a/backend/src/utils/arrayDuplicatCheck.ts b/backend/src/utils/array-duplicate-check.ts similarity index 71% rename from backend/src/utils/arrayDuplicatCheck.ts rename to backend/src/utils/array-duplicate-check.ts index 6a9880c30..be0927828 100644 --- a/backend/src/utils/arrayDuplicatCheck.ts +++ b/backend/src/utils/array-duplicate-check.ts @@ -4,6 +4,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -export function checkArrayForDuplicates(array: Array): boolean { +export function hasArrayDuplicates(array: Array): boolean { return new Set(array).size !== array.length; } diff --git a/backend/src/utils/createSpecialGroups.ts b/backend/src/utils/createSpecialGroups.ts deleted file mode 100644 index fc2be167c..000000000 --- a/backend/src/utils/createSpecialGroups.ts +++ /dev/null @@ -1,34 +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 { AlreadyInDBError } from '../errors/errors'; -import { GroupsService } from '../groups/groups.service'; -import { SpecialGroup } from '../groups/groups.special'; - -export async function setupSpecialGroups( - app: NestExpressApplication, -): Promise { - const groupService = app.get(GroupsService); - try { - await groupService.createGroup( - SpecialGroup.EVERYONE, - SpecialGroup.EVERYONE, - true, - ); - await groupService.createGroup( - SpecialGroup.LOGGED_IN, - SpecialGroup.LOGGED_IN, - true, - ); - } catch (e) { - if (e instanceof AlreadyInDBError) { - // It's no problem if the special groups already exist - return; - } - throw e; - } -} diff --git a/backend/src/utils/detectTsNode.ts b/backend/src/utils/detectTsNode.ts deleted file mode 100644 index e866ae1fe..000000000 --- a/backend/src/utils/detectTsNode.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2018 Martin Adámek - * - * SPDX-License-Identifier: MIT - */ - -/** - * Stolen from https://github.com/mikro-orm/mikro-orm/blob/20179ec839def5f8144e56f3a6bc89131f7e72a4/packages/core/src/utils/Utils.ts#L689 - */ -export function detectTsNode(): boolean { - return ( - process.argv[0].endsWith('ts-node') || // running via ts-node directly - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore TS7053 - !!process[Symbol.for('ts-node.register.instance')] || // check if internal ts-node symbol exists - !!process.env.TS_JEST || // check if ts-jest is used (works only with v27.0.4+) - process.argv.slice(1).some((arg) => arg.includes('ts-node')) || // registering ts-node runner - (require.extensions && !!require.extensions['.ts']) - ); // check if the extension is registered -} diff --git a/backend/src/utils/password.spec.ts b/backend/src/utils/password.spec.ts index 6e7ebdf8b..2d3da5a3c 100644 --- a/backend/src/utils/password.spec.ts +++ b/backend/src/utils/password.spec.ts @@ -4,7 +4,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import argon2 from '@node-rs/argon2'; -import { randomBytes } from 'crypto'; import { bufferToBase64Url, diff --git a/backend/src/utils/password.ts b/backend/src/utils/password.ts index c613cafdb..e084b3181 100644 --- a/backend/src/utils/password.ts +++ b/backend/src/utils/password.ts @@ -55,7 +55,8 @@ export function bufferToBase64Url(text: Buffer): string { } /** - * Hash an api token. + * Hashes an api token + * More about the choice of SHA-512 in the dev docs * * @param token the token to be hashed * @returns the hashed token @@ -65,23 +66,23 @@ export function hashApiToken(token: string): string { } /** - * Check if the given token is the same as what we have in the database. + * Check if the given token is the same as what we have in the database * * Normally, both hashes have the same length, as they are both SHA512 * This is only defense-in-depth, as timingSafeEqual throws if the buffers are not of the same length * - * @param givenToken The token the user gave us. - * @param databaseToken The token we have saved in the database. + * @param userSecret The secret of the token the user gave us + * @param databaseSecretHash The secret hash we have saved in the database. * @returns Wether or not the tokens are the equal */ export function checkTokenEquality( - givenToken: string, - databaseToken: string, + userSecret: string, + databaseSecretHash: string, ): boolean { - const givenHash = Buffer.from(hashApiToken(givenToken)); - const databaseHash = Buffer.from(databaseToken); + const userSecretHashBuffer = Buffer.from(hashApiToken(userSecret)); + const databaseHashBuffer = Buffer.from(databaseSecretHash); return ( - databaseHash.length === givenHash.length && - timingSafeEqual(givenHash, databaseHash) + databaseHashBuffer.length === userSecretHashBuffer.length && + timingSafeEqual(userSecretHashBuffer, databaseHashBuffer) ); } diff --git a/backend/src/utils/serverVersion.spec.ts b/backend/src/utils/server-version.spec.ts similarity index 98% rename from backend/src/utils/serverVersion.spec.ts rename to backend/src/utils/server-version.spec.ts index 096f3cc85..b9decd3fc 100644 --- a/backend/src/utils/serverVersion.spec.ts +++ b/backend/src/utils/server-version.spec.ts @@ -8,7 +8,7 @@ import { promises as fs } from 'fs'; import { clearCachedVersion, getServerVersionFromPackageJson, -} from './serverVersion'; +} from './server-version'; jest.mock('fs', () => ({ promises: { diff --git a/backend/src/utils/serverVersion.ts b/backend/src/utils/server-version.ts similarity index 100% rename from backend/src/utils/serverVersion.ts rename to backend/src/utils/server-version.ts diff --git a/backend/src/utils/session.ts b/backend/src/utils/session.ts index 676a786af..0aa81dbe1 100644 --- a/backend/src/utils/session.ts +++ b/backend/src/utils/session.ts @@ -4,7 +4,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { INestApplication } from '@nestjs/common'; -import { TypeormStore } from 'connect-typeorm'; import session from 'express-session'; import { AuthConfig } from '../config/auth.config'; @@ -12,15 +11,16 @@ import { AuthConfig } from '../config/auth.config'; export const HEDGEDOC_SESSION = 'hedgedoc-session'; /** - * Set up the session middleware via the given authConfig. - * @param {INestApplication} app - the nest application to configure the middleware for. - * @param {AuthConfig} authConfig - the authConfig to configure the middleware with. - * @param {TypeormStore} typeormStore - the typeormStore to handle session data. + * Set up the session middleware via the given authConfig + * + * @param app The nest application to configure the middleware for + * @param authConfig - The authConfig to configure the middleware with + * @param sessionStore - The storage backend that holds the session data */ export function setupSessionMiddleware( app: INestApplication, authConfig: AuthConfig, - typeormStore: TypeormStore, + sessionStore: session.Store, ): void { app.use( session({ @@ -32,7 +32,7 @@ export function setupSessionMiddleware( }, resave: false, saveUninitialized: false, - store: typeormStore, + store: sessionStore, }), ); } diff --git a/backend/src/utils/swagger.ts b/backend/src/utils/swagger.ts index c370538cc..20095e64f 100644 --- a/backend/src/utils/swagger.ts +++ b/backend/src/utils/swagger.ts @@ -8,7 +8,7 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { PrivateApiModule } from '../api/private/private-api.module'; import { PublicApiModule } from '../api/public/public-api.module'; -import { getServerVersionFromPackageJson } from './serverVersion'; +import { getServerVersionFromPackageJson } from './server-version'; export async function setupPublicApiDocs(app: INestApplication): Promise { const version = await getServerVersionFromPackageJson(); diff --git a/backend/src/utils/test-utils/mockSelectQueryBuilder.ts b/backend/src/utils/test-utils/mockSelectQueryBuilder.ts deleted file mode 100644 index e7019f522..000000000 --- a/backend/src/utils/test-utils/mockSelectQueryBuilder.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { Mock } from 'ts-mockery'; -import { ObjectLiteral, Repository, SelectQueryBuilder } from 'typeorm'; - -/** - * Mocks a {@link SelectQueryBuilder} that returns a given entity. - * - * @param returnValue The entity to return - * @return The mocked query builder - */ -export function mockSelectQueryBuilder( - returnValue: T | T[] | null, -): SelectQueryBuilder { - const mockedQueryBuilder: SelectQueryBuilder = Mock.of< - SelectQueryBuilder - >({ - where: (where) => { - if (typeof where === 'function') { - where(mockedQueryBuilder); - } - return mockedQueryBuilder; - }, - andWhere: (where) => { - if (typeof where === 'function') { - where(mockedQueryBuilder); - } - return mockedQueryBuilder; - }, - subQuery: () => mockedQueryBuilder, - select: () => mockedQueryBuilder, - from: () => mockSelectQueryBuilder(null), - innerJoin: () => mockedQueryBuilder, - leftJoinAndSelect: () => mockedQueryBuilder, - getQuery: () => '', - getOne: () => - Promise.resolve( - Array.isArray(returnValue) ? returnValue[0] : returnValue, - ), - orWhere: (where) => { - if (typeof where === 'function') { - where(mockedQueryBuilder); - } - return mockedQueryBuilder; - }, - setParameter: () => mockedQueryBuilder, - getMany: () => { - if (!returnValue) { - return Promise.resolve([]); - } - return Promise.resolve( - Array.isArray(returnValue) ? returnValue : [returnValue], - ); - }, - }); - return mockedQueryBuilder; -} - -/** - * Mocks an {@link SelectQueryBuilder} and injects it into the given {@link Repository}. - * - * @param repository The repository whose query builder function should be mocked - * @param returnValue The value that should be found by the query builder - * @return The mocked query builder - * @see mockSelectQueryBuilder - */ -export function mockSelectQueryBuilderInRepo( - repository: Repository, - returnValue: T | T[] | null, -): SelectQueryBuilder { - const selectQueryBuilder = mockSelectQueryBuilder(returnValue); - jest - .spyOn(repository, 'createQueryBuilder') - .mockImplementation(() => selectQueryBuilder); - return selectQueryBuilder; -} diff --git a/backend/test/private-api/alias.e2e-spec.ts b/backend/test/private-api/alias.e2e-spec.ts index de4566067..b8f881455 100644 --- a/backend/test/private-api/alias.e2e-spec.ts +++ b/backend/test/private-api/alias.e2e-spec.ts @@ -6,8 +6,9 @@ import { AliasCreateDto, AliasUpdateDto } from '@hedgedoc/commons'; import request from 'supertest'; +import { AliasCreateDto } from '../../src/alias/alias-create.dto'; +import { AliasUpdateDto } from '../../src/alias/alias-update.dto'; import { User } from '../../src/database/user.entity'; -import { Note } from '../../src/notes/note.entity'; import { password1, password2, @@ -55,7 +56,7 @@ describe('Alias', () => { describe('POST /alias', () => { const testAlias = 'aliasTest'; const newAliasDto: AliasCreateDto = { - noteIdOrAlias: testAlias, + alias: testAlias, newAlias: '', }; let publicId = ''; diff --git a/backend/test/private-api/groups.e2e-spec.ts b/backend/test/private-api/groups.e2e-spec.ts index e03f234e0..3cbaad7de 100644 --- a/backend/test/private-api/groups.e2e-spec.ts +++ b/backend/test/private-api/groups.e2e-spec.ts @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { GuestAccess, LoginDto } from '@hedgedoc/commons'; +import { LoginDto, PermissionLevel } from '@hedgedoc/commons'; import request from 'supertest'; import { createDefaultMockNoteConfig } from '../../src/config/mock/note.config.mock'; @@ -66,7 +66,7 @@ describe('Groups', () => { describe('API requires authentication', () => { beforeAll(() => { - noteConfigMock.guestAccess = GuestAccess.DENY; + noteConfigMock.guestAccess = PermissionLevel.DENY; }); test('get group', async () => { const response = await request(testSetup.app.getHttpServer()).get( diff --git a/backend/test/private-api/history.e2e-spec.ts b/backend/test/private-api/history.e2e-spec.ts index 4eb13d530..58807acdc 100644 --- a/backend/test/private-api/history.e2e-spec.ts +++ b/backend/test/private-api/history.e2e-spec.ts @@ -11,7 +11,7 @@ import { HistoryEntryImportDto } from '../../src/history/history-entry-import.dt import { HistoryEntry } from '../../src/history/history-entry.entity'; import { HistoryService } from '../../src/history/history.service'; import { Note } from '../../src/notes/note.entity'; -import { NotesService } from '../../src/notes/notes.service'; +import { NoteService } from '../../src/notes/note.service'; import { UsersService } from '../../src/users/users.service'; import { TestSetup, TestSetupBuilder } from '../test-setup'; @@ -43,7 +43,7 @@ describe('History', () => { localIdentityService = moduleRef.get(LocalService); user = await userService.createUser(username, 'Testy', null, null); await localIdentityService.createLocalIdentity(user, password); - const notesService = moduleRef.get(NotesService); + const notesService = moduleRef.get(NoteService); note = await notesService.createNote(content, user, 'note'); note2 = await notesService.createNote(content, user, 'note2'); agent = request.agent(testSetup.app.getHttpServer()); diff --git a/backend/test/private-api/me.e2e-spec.ts b/backend/test/private-api/me.e2e-spec.ts index 30fefb391..6dab23786 100644 --- a/backend/test/private-api/me.e2e-spec.ts +++ b/backend/test/private-api/me.e2e-spec.ts @@ -127,7 +127,8 @@ describe('Me', () => { expect(imageIds).toContain(response.body[1].uuid); expect(imageIds).toContain(response.body[2].uuid); expect(imageIds).toContain(response.body[3].uuid); - const mediaUploads = await testSetup.mediaService.listUploadsByUser(user); + const mediaUploads = + await testSetup.mediaService.getMediaUploadUuidsByUserId(user); for (const upload of mediaUploads) { await testSetup.mediaService.deleteFile(upload); } @@ -157,7 +158,8 @@ describe('Me', () => { ); const dbUser = await testSetup.userService.getUserByUsername('hardcoded'); expect(dbUser).toBeInstanceOf(User); - const mediaUploads = await testSetup.mediaService.listUploadsByUser(dbUser); + const mediaUploads = + await testSetup.mediaService.getMediaUploadUuidsByUserId(dbUser); expect(mediaUploads).toHaveLength(1); expect(mediaUploads[0].uuid).toEqual(upload.uuid); await agent.delete('/api/private/me').expect(204); @@ -165,7 +167,7 @@ describe('Me', () => { testSetup.userService.getUserByUsername('hardcoded'), ).rejects.toThrow(NotInDBError); const mediaUploadsAfter = - await testSetup.mediaService.listUploadsByNote(note1); + await testSetup.mediaService.getMediaUploadUuidsByNoteId(note1); expect(mediaUploadsAfter).toHaveLength(0); }); }); diff --git a/backend/test/private-api/notes.e2e-spec.ts b/backend/test/private-api/notes.e2e-spec.ts index fabbe03d7..8d3c062e0 100644 --- a/backend/test/private-api/notes.e2e-spec.ts +++ b/backend/test/private-api/notes.e2e-spec.ts @@ -81,7 +81,7 @@ describe('Notes', () => { expect(response.body.metadata?.id).toBeDefined(); expect( await testSetup.notesService.getNoteContent( - await testSetup.notesService.getNoteByIdOrAlias( + await testSetup.notesService.getNoteIdByAlias( response.body.metadata.id, ), ), @@ -108,7 +108,7 @@ describe('Notes', () => { }); describe('POST /notes/{note}', () => { - it('works with a non-existing alias', async () => { + it('works with a non-existing aliases', async () => { const response = await agent .post('/api/private/notes/test2') .set('Content-Type', 'text/markdown') @@ -118,14 +118,14 @@ describe('Notes', () => { expect(response.body.metadata?.id).toBeDefined(); return expect( await testSetup.notesService.getNoteContent( - await testSetup.notesService.getNoteByIdOrAlias( + await testSetup.notesService.getNoteIdByAlias( response.body.metadata?.id, ), ), ).toEqual(content); }); - it('fails with a forbidden alias', async () => { + it('fails with a forbidden aliases', async () => { await agent .post(`/api/private/notes/${forbiddenNoteId}`) .set('Content-Type', 'text/markdown') @@ -134,7 +134,7 @@ describe('Notes', () => { .expect(400); }); - it('fails with a existing alias', async () => { + it('fails with a existing aliases', async () => { await agent .post('/api/private/notes/test2') .set('Content-Type', 'text/markdown') @@ -156,7 +156,7 @@ describe('Notes', () => { .expect(413); }); - it('cannot create an alias equal to a note publicId', async () => { + it('cannot create an aliases equal to a note publicId', async () => { await agent .post(`/api/private/notes/${testSetup.anonymousNotes[0].publicId}`) .set('Content-Type', 'text/markdown') @@ -168,7 +168,7 @@ describe('Notes', () => { describe('DELETE /notes/{note}', () => { describe('works', () => { - it('with an existing alias and keepMedia false', async () => { + it('with an existing aliases and keepMedia false', async () => { const noteId = 'test3'; const note = await testSetup.notesService.createNote( content, @@ -189,16 +189,16 @@ describe('Notes', () => { }) .expect(204); await expect( - testSetup.notesService.getNoteByIdOrAlias(noteId), + testSetup.notesService.getNoteIdByAlias(noteId), ).rejects.toEqual( new NotInDBError(`Note with id/alias '${noteId}' not found.`), ); expect( - await testSetup.mediaService.listUploadsByUser(user1), + await testSetup.mediaService.getMediaUploadUuidsByUserId(user1), ).toHaveLength(0); await fs.rmdir(uploadPath); }); - it('with an existing alias and keepMedia true', async () => { + it('with an existing aliases and keepMedia true', async () => { const noteId = 'test3a'; const note = await testSetup.notesService.createNote( content, @@ -219,22 +219,22 @@ describe('Notes', () => { }) .expect(204); await expect( - testSetup.notesService.getNoteByIdOrAlias(noteId), + testSetup.notesService.getNoteIdByAlias(noteId), ).rejects.toEqual( new NotInDBError(`Note with id/alias '${noteId}' not found.`), ); expect( - await testSetup.mediaService.listUploadsByUser(user1), + await testSetup.mediaService.getMediaUploadUuidsByUserId(user1), ).toHaveLength(1); // delete the file afterwards await fs.unlink(join(uploadPath, upload.uuid + '.png')); await fs.rmdir(uploadPath); }); }); - it('fails with a forbidden alias', async () => { + it('fails with a forbidden aliases', async () => { await agent.delete(`/api/private/notes/${forbiddenNoteId}`).expect(400); }); - it('fails with a non-existing alias', async () => { + it('fails with a non-existing aliases', async () => { await agent.delete('/api/private/notes/i_dont_exist').expect(404); }); }); @@ -264,14 +264,14 @@ describe('Notes', () => { expect(metadata.body.editedBy).toEqual([]); }); - it('fails with a forbidden alias', async () => { + it('fails with a forbidden aliases', async () => { await agent .get(`/api/private/notes/${forbiddenNoteId}/metadata`) .expect('Content-Type', /json/) .expect(400); }); - it('fails with non-existing alias', async () => { + it('fails with non-existing aliases', async () => { // check if a missing note correctly returns 404 await agent .get('/api/private/notes/i_dont_exist/metadata') @@ -305,7 +305,7 @@ describe('Notes', () => { }); describe('GET /notes/{note}/revisions', () => { - it('works with existing alias', async () => { + it('works with existing aliases', async () => { await testSetup.notesService.createNote(content, user1, 'test4'); // create a second note to check for a regression, where typeorm always returned // all revisions in the database @@ -317,13 +317,13 @@ describe('Notes', () => { expect(response.body).toHaveLength(1); }); - it('fails with a forbidden alias', async () => { + it('fails with a forbidden aliases', async () => { await agent .get(`/api/private/notes/${forbiddenNoteId}/revisions`) .expect(400); }); - it('fails with non-existing alias', async () => { + it('fails with non-existing aliases', async () => { // check if a missing note correctly returns 404 await agent .get('/api/private/notes/i_dont_exist/revisions') @@ -333,7 +333,7 @@ describe('Notes', () => { }); describe('DELETE /notes/{note}/revisions', () => { - it('works with an existing alias', async () => { + it('works with an existing aliases', async () => { const noteId = 'test8'; const note = await testSetup.notesService.createNote( content, @@ -356,12 +356,12 @@ describe('Notes', () => { .expect(200); expect(responseAfterDeleting.body).toHaveLength(1); }); - it('fails with a forbidden alias', async () => { + it('fails with a forbidden aliases', async () => { await agent .delete(`/api/private/notes/${forbiddenNoteId}/revisions`) .expect(400); }); - it('fails with non-existing alias', async () => { + it('fails with non-existing aliases', async () => { // check if a missing note correctly returns 404 await agent .delete('/api/private/notes/i_dont_exist/revisions') @@ -371,7 +371,7 @@ describe('Notes', () => { }); describe('GET /notes/{note}/revisions/{revision-id}', () => { - it('works with an existing alias', async () => { + it('works with an existing aliases', async () => { const note = await testSetup.notesService.createNote( content, user1, @@ -384,12 +384,12 @@ describe('Notes', () => { .expect(200); expect(response.body.content).toEqual(content); }); - it('fails with a forbidden alias', async () => { + it('fails with a forbidden aliases', async () => { await agent .get(`/api/private/notes/${forbiddenNoteId}/revisions/1`) .expect(400); }); - it('fails with non-existing alias', async () => { + it('fails with non-existing aliases', async () => { // check if a missing note correctly returns 404 await agent .get('/api/private/notes/i_dont_exist/revisions/1') @@ -459,7 +459,7 @@ describe('Notes', () => { alias, ); // Redact default read permissions - const note = await testSetup.notesService.getNoteByIdOrAlias(alias); + const note = await testSetup.notesService.getNoteIdByAlias(alias); const everyone = await testSetup.groupService.getEveryoneGroup(); const loggedin = await testSetup.groupService.getLoggedInGroup(); await testSetup.permissionsService.removeGroupPermission(note, everyone); @@ -510,7 +510,7 @@ describe('Notes', () => { it("doesn't do anything if the user is the owner", async () => { const note = - await testSetup.notesService.getNoteByIdOrAlias(user1NoteAlias); + await testSetup.notesService.getNoteIdByAlias(user1NoteAlias); await testSetup.permissionsService.removeUserPermission(note, user2); const response = await agent @@ -557,7 +557,7 @@ describe('Notes', () => { it('works', async () => { const note = - await testSetup.notesService.getNoteByIdOrAlias(user1NoteAlias); + await testSetup.notesService.getNoteIdByAlias(user1NoteAlias); await testSetup.permissionsService.setUserPermission( note, user2, @@ -630,7 +630,7 @@ describe('Notes', () => { it('works', async () => { const note = - await testSetup.notesService.getNoteByIdOrAlias(user1NoteAlias); + await testSetup.notesService.getNoteIdByAlias(user1NoteAlias); await testSetup.permissionsService.setGroupPermission( note, group1, diff --git a/backend/test/public-api/alias.e2e-spec.ts b/backend/test/public-api/alias.e2e-spec.ts index 5ad23073e..007128476 100644 --- a/backend/test/public-api/alias.e2e-spec.ts +++ b/backend/test/public-api/alias.e2e-spec.ts @@ -5,7 +5,6 @@ */ import { AliasUpdateDto } from '@hedgedoc/commons'; import request from 'supertest'; - import { TestSetup, TestSetupBuilder } from '../test-setup'; describe('Alias', () => { diff --git a/backend/test/public-api/me.e2e-spec.ts b/backend/test/public-api/me.e2e-spec.ts index c6c82d0ea..d191e4530 100644 --- a/backend/test/public-api/me.e2e-spec.ts +++ b/backend/test/public-api/me.e2e-spec.ts @@ -40,7 +40,7 @@ describe('Me', () => { }); it(`GET /me`, async () => { - const userInfo = testSetup.userService.toFullUserDto(user); + const userInfo = testSetup.userService.toLoginUserInfoDto(user); const response = await request(testSetup.app.getHttpServer()) .get('/api/v2/me') .expect('Content-Type', /json/) diff --git a/backend/test/public-api/notes.e2e-spec.ts b/backend/test/public-api/notes.e2e-spec.ts index 9fcdf6676..be5496c99 100644 --- a/backend/test/public-api/notes.e2e-spec.ts +++ b/backend/test/public-api/notes.e2e-spec.ts @@ -49,7 +49,7 @@ describe('Notes', () => { expect(response.body.metadata?.id).toBeDefined(); expect( await testSetup.notesService.getNoteContent( - await testSetup.notesService.getNoteByIdOrAlias( + await testSetup.notesService.getNoteIdByAlias( response.body.metadata.id, ), ), @@ -96,7 +96,7 @@ describe('Notes', () => { expect(response.body.metadata?.id).toBeDefined(); return expect( await testSetup.notesService.getNoteContent( - await testSetup.notesService.getNoteByIdOrAlias( + await testSetup.notesService.getNoteIdByAlias( response.body.metadata?.id, ), ), @@ -172,12 +172,14 @@ describe('Notes', () => { }) .expect(204); await expect( - testSetup.notesService.getNoteByIdOrAlias(noteId), + testSetup.notesService.getNoteIdByAlias(noteId), ).rejects.toEqual( new NotInDBError(`Note with id/alias '${noteId}' not found.`), ); expect( - await testSetup.mediaService.listUploadsByUser(testSetup.users[0]), + await testSetup.mediaService.getMediaUploadUuidsByUserId( + testSetup.users[0], + ), ).toHaveLength(0); }); it('with an existing alias and keepMedia true', async () => { @@ -202,12 +204,14 @@ describe('Notes', () => { }) .expect(204); await expect( - testSetup.notesService.getNoteByIdOrAlias(noteId), + testSetup.notesService.getNoteIdByAlias(noteId), ).rejects.toEqual( new NotInDBError(`Note with id/alias '${noteId}' not found.`), ); expect( - await testSetup.mediaService.listUploadsByUser(testSetup.users[0]), + await testSetup.mediaService.getMediaUploadUuidsByUserId( + testSetup.users[0], + ), ).toHaveLength(1); // delete the file afterwards await fs.unlink(join(uploadPath, upload.uuid + '.png')); @@ -228,11 +232,11 @@ describe('Notes', () => { ], sharedToGroups: [], }; - await testSetup.permissionsService.updateNotePermissions( + await testSetup.permissionsService.replaceNotePermissions( note, updateNotePermission, ); - const updatedNote = await testSetup.notesService.getNoteByIdOrAlias( + const updatedNote = await testSetup.notesService.getNoteIdByAlias( (await note.aliases).filter((alias) => alias.primary)[0].name, ); expect(await updatedNote.userPermissions).toHaveLength(1); @@ -249,7 +253,7 @@ describe('Notes', () => { .send({ keepMedia: false }) .expect(204); await expect( - testSetup.notesService.getNoteByIdOrAlias('deleteTest3'), + testSetup.notesService.getNoteIdByAlias('deleteTest3'), ).rejects.toEqual( new NotInDBError("Note with id/alias 'deleteTest3' not found."), ); @@ -284,7 +288,7 @@ describe('Notes', () => { .expect(200); expect( await testSetup.notesService.getNoteContent( - await testSetup.notesService.getNoteByIdOrAlias('test4'), + await testSetup.notesService.getNoteIdByAlias('test4'), ), ).toEqual(changedContent); expect(response.body.content).toEqual(changedContent); @@ -530,7 +534,7 @@ describe('Notes', () => { alias, ); // Redact default read permissions - const note = await testSetup.notesService.getNoteByIdOrAlias(alias); + const note = await testSetup.notesService.getNoteIdByAlias(alias); const everyone = await testSetup.groupService.getEveryoneGroup(); const loggedin = await testSetup.groupService.getLoggedInGroup(); await testSetup.permissionsService.removeGroupPermission(note, everyone); diff --git a/backend/test/test-setup.ts b/backend/test/test-setup.ts index 63f6cffd1..3d4931f39 100644 --- a/backend/test/test-setup.ts +++ b/backend/test/test-setup.ts @@ -12,6 +12,8 @@ import { Test, TestingModule, TestingModuleBuilder } from '@nestjs/testing'; import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm'; import { Connection, createConnection } from 'typeorm'; +import { AliasModule } from '../src/alias/alias.module'; +import { AliasService } from '../src/alias/alias.service'; import { ApiTokenGuard } from '../src/api-token/api-token.guard'; import { ApiTokenModule } from '../src/api-token/api-token.module'; import { ApiTokenService } from '../src/api-token/api-token.service'; @@ -73,12 +75,10 @@ import { LoggerModule } from '../src/logger/logger.module'; import { MediaModule } from '../src/media/media.module'; import { MediaService } from '../src/media/media.service'; import { MonitoringModule } from '../src/monitoring/monitoring.module'; -import { AliasService } from '../src/notes/alias.service'; import { Note } from '../src/notes/note.entity'; -import { NotesModule } from '../src/notes/notes.module'; -import { NotesService } from '../src/notes/notes.service'; +import { NoteService } from '../src/notes/note.service'; +import { PermissionService } from '../src/permissions/permission.service'; import { PermissionsModule } from '../src/permissions/permissions.module'; -import { PermissionsService } from '../src/permissions/permissions.service'; import { RevisionsModule } from '../src/revisions/revisions.module'; import { RevisionsService } from '../src/revisions/revisions.service'; import { SessionModule } from '../src/sessions/session.module'; @@ -107,7 +107,7 @@ export class TestSetup { localIdentityService: LocalService; ldapService: LdapService; oidcService: OidcService; - notesService: NotesService; + notesService: NoteService; mediaService: MediaService; historyService: HistoryService; aliasService: AliasService; @@ -119,7 +119,7 @@ export class TestSetup { authTokens: ApiTokenWithSecretDto[] = []; anonymousNotes: Note[] = []; ownedNotes: Note[] = []; - permissionsService: PermissionsService; + permissionsService: PermissionService; /** * Cleans up remnants from a test run from the database @@ -282,7 +282,7 @@ export class TestSetupBuilder { ), ], }), - NotesModule, + AliasModule, UsersModule, RevisionsModule, AuthorsModule, @@ -333,7 +333,7 @@ export class TestSetupBuilder { this.testSetup.localIdentityService = this.testSetup.moduleRef.get(LocalService); this.testSetup.notesService = - this.testSetup.moduleRef.get(NotesService); + this.testSetup.moduleRef.get(NoteService); this.testSetup.mediaService = this.testSetup.moduleRef.get(MediaService); this.testSetup.historyService = @@ -343,7 +343,7 @@ export class TestSetupBuilder { this.testSetup.publicAuthTokenService = this.testSetup.moduleRef.get(ApiTokenService); this.testSetup.permissionsService = - this.testSetup.moduleRef.get(PermissionsService); + this.testSetup.moduleRef.get(PermissionService); this.testSetup.sessionService = this.testSetup.moduleRef.get(SessionService); this.testSetup.revisionsService = diff --git a/commons/src/dtos/alias/alias-create.dto.ts b/commons/src/dtos/alias/alias-create.dto.ts index 1f0560256..999d6b0fe 100644 --- a/commons/src/dtos/alias/alias-create.dto.ts +++ b/commons/src/dtos/alias/alias-create.dto.ts @@ -8,7 +8,7 @@ import { z } from 'zod' export const AliasCreateSchema = z .object({ - noteIdOrAlias: z + noteAlias: z .string() .describe( 'The note id, which identifies the note the alias should be added to', diff --git a/commons/src/dtos/alias/alias.dto.ts b/commons/src/dtos/alias/alias.dto.ts index ff9997ba9..6f5ee42d9 100644 --- a/commons/src/dtos/alias/alias.dto.ts +++ b/commons/src/dtos/alias/alias.dto.ts @@ -9,10 +9,9 @@ import { z } from 'zod' export const AliasSchema = z .object({ name: z.string().describe('The name of the alias'), - primaryAlias: z.boolean().describe('Is the alias the primary alias or not'), - noteId: z - .string() - .describe('The public id of the note the alias is associated with'), + isPrimaryAlias: z + .boolean() + .describe('Is the alias the primary alias or not'), }) .describe( 'The alias of a note. A note can have multiple of these. Only one can be the primary alias.', diff --git a/commons/src/dtos/api-token/api-token.dto.ts b/commons/src/dtos/api-token/api-token.dto.ts index 172d14410..f857cd86a 100644 --- a/commons/src/dtos/api-token/api-token.dto.ts +++ b/commons/src/dtos/api-token/api-token.dto.ts @@ -14,7 +14,7 @@ export const ApiTokenSchema = z validUntil: z .string() .datetime() - .describe('How long this token is valid fro'), + .describe('How long this token is valid for'), lastUsedAt: z .string() .datetime() diff --git a/commons/src/dtos/auth/provider-type.enum.ts b/commons/src/dtos/auth/auth-provider-type.enum.ts similarity index 80% rename from commons/src/dtos/auth/provider-type.enum.ts rename to commons/src/dtos/auth/auth-provider-type.enum.ts index 1ace0aa0e..c8fda0b80 100644 --- a/commons/src/dtos/auth/provider-type.enum.ts +++ b/commons/src/dtos/auth/auth-provider-type.enum.ts @@ -4,8 +4,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -export enum ProviderType { +export enum AuthProviderType { GUEST = 'guest', + TOKEN = 'token', LOCAL = 'local', LDAP = 'ldap', OIDC = 'oidc', diff --git a/commons/src/dtos/auth/guest-login.dto.ts b/commons/src/dtos/auth/guest-login.dto.ts new file mode 100644 index 000000000..b60595592 --- /dev/null +++ b/commons/src/dtos/auth/guest-login.dto.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { z } from 'zod' + +export const GuestLoginSchema = z + .object({ + uuid: z.string().uuid().describe('The uuid of the guest.'), + }) + .describe('DTO to login as a guest user.') + +export type GuestLoginDto = z.infer diff --git a/commons/src/dtos/auth/guest-registration-response.dto.ts b/commons/src/dtos/auth/guest-registration-response.dto.ts new file mode 100644 index 000000000..4413a33fc --- /dev/null +++ b/commons/src/dtos/auth/guest-registration-response.dto.ts @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { z } from 'zod' + +export const GuestRegistrationResponseSchema = z + .object({ + uuid: z.string().uuid().describe('The uuid of the guest.'), + }) + .describe('DTO to login as a guest user.') + +export type GuestRegistrationResponseDto = z.infer< + typeof GuestRegistrationResponseSchema +> diff --git a/commons/src/dtos/auth/index.ts b/commons/src/dtos/auth/index.ts index 6991c1021..69eafb32c 100644 --- a/commons/src/dtos/auth/index.ts +++ b/commons/src/dtos/auth/index.ts @@ -4,12 +4,14 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +export * from './guest-login.dto.js' +export * from './guest-registration-response.dto.js' export * from './ldap-login.dto.js' export * from './ldap-login-response.dto.js' export * from './login.dto.js' export * from './logout-response.dto.js' export * from './pending-user-confirmation.dto.js' -export * from './provider-type.enum.js' +export * from './auth-provider-type.enum.js' export * from './register.dto.js' export * from './update-password.dto.js' export * from './username-check.dto.js' diff --git a/commons/src/dtos/frontend-config/auth-provider-with-custom-name.dto.ts b/commons/src/dtos/frontend-config/auth-provider-with-custom-name.dto.ts index 07bb91b2a..58a4c1b98 100644 --- a/commons/src/dtos/frontend-config/auth-provider-with-custom-name.dto.ts +++ b/commons/src/dtos/frontend-config/auth-provider-with-custom-name.dto.ts @@ -5,13 +5,13 @@ */ import { z } from 'zod' -import { ProviderType } from '../auth/index.js' +import { AuthProviderType } from '../auth/index.js' export const AuthProviderWithCustomNameSchema = z .object({ type: z - .literal(ProviderType.LDAP) - .or(z.literal(ProviderType.OIDC)) + .literal(AuthProviderType.LDAP) + .or(z.literal(AuthProviderType.OIDC)) .describe('The type of the auth provider'), identifier: z .string() diff --git a/commons/src/dtos/frontend-config/auth-provider-without-custom-name.dto.ts b/commons/src/dtos/frontend-config/auth-provider-without-custom-name.dto.ts index e0f021ce2..c0f4cabda 100644 --- a/commons/src/dtos/frontend-config/auth-provider-without-custom-name.dto.ts +++ b/commons/src/dtos/frontend-config/auth-provider-without-custom-name.dto.ts @@ -4,12 +4,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { z } from 'zod' -import { ProviderType } from '../auth/index.js' +import { AuthProviderType } from '../auth/index.js' export const AuthProviderWithoutCustomNameSchema = z .object({ type: z - .literal(ProviderType.LOCAL) + .literal(AuthProviderType.LOCAL) .describe('The type of the auth provider'), }) .describe('Represents the local authentication provider') diff --git a/commons/src/dtos/frontend-config/frontend-config.dto.ts b/commons/src/dtos/frontend-config/frontend-config.dto.ts index 9d42eed29..cd41ea072 100644 --- a/commons/src/dtos/frontend-config/frontend-config.dto.ts +++ b/commons/src/dtos/frontend-config/frontend-config.dto.ts @@ -5,7 +5,7 @@ */ import { z } from 'zod' -import { GuestAccess } from '../permissions/index.js' +import { PermissionLevel } from '../permissions/index.js' import { ServerVersionSchema } from '../monitoring/index.js' import { BrandingSchema } from './branding.dto.js' import { SpecialUrlSchema } from './special-urls.dto.js' @@ -14,7 +14,7 @@ import { AuthProviderSchema } from './auth-provider.dto.js' export const FrontendConfigSchema = z .object({ guestAccess: z - .nativeEnum(GuestAccess) + .nativeEnum(PermissionLevel) .describe('Maximum access level for guest users'), allowRegister: z .boolean() diff --git a/commons/src/dtos/note/note-metadata.dto.ts b/commons/src/dtos/note/note-metadata.dto.ts index c743c615b..2d265db2a 100644 --- a/commons/src/dtos/note/note-metadata.dto.ts +++ b/commons/src/dtos/note/note-metadata.dto.ts @@ -14,9 +14,7 @@ export const NoteMetadataSchema = z aliases: z.array(AliasSchema).describe('All aliases of the note'), primaryAddress: z .string() - .describe( - 'The primary address/alias of the note. If at least one alias is set, this is the primary alias.', - ), + .describe('The primary address/alias of the note.'), title: z .string() .describe( diff --git a/commons/src/dtos/permissions/index.ts b/commons/src/dtos/permissions/index.ts index 8d2eec439..72c0de382 100644 --- a/commons/src/dtos/permissions/index.ts +++ b/commons/src/dtos/permissions/index.ts @@ -5,7 +5,7 @@ */ export * from './change-note-owner.dto.js' -export * from './guest-access.enum.js' +export * from './permission-level.enum.js' export * from './note-group-permission-entry.dto.js' export * from './note-group-permission-update.dto.js' export * from './note-permissions-update.dto.js' diff --git a/commons/src/dtos/permissions/guest-access.enum.ts b/commons/src/dtos/permissions/permission-level.enum.ts similarity index 53% rename from commons/src/dtos/permissions/guest-access.enum.ts rename to commons/src/dtos/permissions/permission-level.enum.ts index c335a77f7..11e7a1b37 100644 --- a/commons/src/dtos/permissions/guest-access.enum.ts +++ b/commons/src/dtos/permissions/permission-level.enum.ts @@ -4,22 +4,24 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -export enum GuestAccess { +export enum PermissionLevel { DENY = 'deny', READ = 'read', WRITE = 'write', CREATE = 'create', } -export const getGuestAccessOrdinal = (guestAccess: GuestAccess): number => { - switch (guestAccess) { - case GuestAccess.DENY: +export const getPermissionLevelValue = ( + permissionLevel: PermissionLevel, +): number => { + switch (permissionLevel) { + case PermissionLevel.DENY: return 0 - case GuestAccess.READ: + case PermissionLevel.READ: return 1 - case GuestAccess.WRITE: + case PermissionLevel.WRITE: return 2 - case GuestAccess.CREATE: + case PermissionLevel.CREATE: return 3 default: throw Error('Unknown permission') diff --git a/commons/src/dtos/revision/revision-metadata.dto.ts b/commons/src/dtos/revision/revision-metadata.dto.ts index 6383068fd..5eac8c6e2 100644 --- a/commons/src/dtos/revision/revision-metadata.dto.ts +++ b/commons/src/dtos/revision/revision-metadata.dto.ts @@ -7,7 +7,7 @@ import { z } from 'zod' export const RevisionMetadataSchema = z .object({ - id: z.number().describe('The id of the revision.'), + uuid: z.string().uuid().describe('The uuid of the revision.'), createdAt: z.string().datetime().describe('When the revision was created.'), length: z .number() @@ -18,10 +18,9 @@ export const RevisionMetadataSchema = z .describe( 'A list of all usernames of the users that worked on the revision.', ), - anonymousAuthorCount: z - .number() - .positive() - .describe('Number of anonymous users that worked on the revision.'), + authorGuestUuids: z + .array(z.string().uuid()) + .describe('A list of all guest UUIDs that worked on the revision.'), title: z .string() .describe( diff --git a/commons/src/dtos/revision/revision.dto.ts b/commons/src/dtos/revision/revision.dto.ts index 0ae066fab..b8969ef13 100644 --- a/commons/src/dtos/revision/revision.dto.ts +++ b/commons/src/dtos/revision/revision.dto.ts @@ -5,19 +5,23 @@ */ import { z } from 'zod' -import { EditSchema } from '../edit/edit.dto.js' import { RevisionMetadataSchema } from './revision-metadata.dto.js' -export const RevisionSchema = RevisionMetadataSchema.merge( - z.object({ - content: z.string().describe('The content of the revision'), - patch: z.string().describe('The patch or diff to the previous revision'), - edits: z - .array(EditSchema) - .describe('A list of users, who created this revision'), - }), -).describe( - 'A revision is the state of a note content at a specific time. This is used to go back to previous version of a note.', -) +export const RevisionSchema = RevisionMetadataSchema.pick({ + uuid: true, + length: true, + createdAt: true, + title: true, + description: true, +}) + .merge( + z.object({ + content: z.string().describe('The content of the revision'), + patch: z.string().describe('The patch or diff to the previous revision'), + }), + ) + .describe( + 'A revision is the state of a note content at a specific time. This is used to go back to previous version of a note.', + ) export type RevisionDto = z.infer diff --git a/commons/src/dtos/user/login-user-info.dto.ts b/commons/src/dtos/user/login-user-info.dto.ts index 093e0d8c3..722c9acc4 100644 --- a/commons/src/dtos/user/login-user-info.dto.ts +++ b/commons/src/dtos/user/login-user-info.dto.ts @@ -5,13 +5,13 @@ */ import { z } from 'zod' -import { ProviderType } from '../auth/index.js' +import { AuthProviderType } from '../auth/index.js' import { FullUserInfoSchema } from './full-user-info.dto.js' export const LoginUserInfoSchema = FullUserInfoSchema.merge( z.object({ authProvider: z - .nativeEnum(ProviderType) + .nativeEnum(AuthProviderType) .describe('The type of login provider used for the current session'), }), ).describe( diff --git a/commons/src/dtos/user/user-info.dto.ts b/commons/src/dtos/user/user-info.dto.ts index f385b53c0..412e3f2d1 100644 --- a/commons/src/dtos/user/user-info.dto.ts +++ b/commons/src/dtos/user/user-info.dto.ts @@ -7,7 +7,10 @@ import { z } from 'zod' export const UserInfoSchema = z .object({ - username: z.string().describe("The user's username"), + username: z + .string() + .nullable() + .describe("The user's username. If null this is a guest."), displayName: z.string().describe('The display name of the user'), photoUrl: z .string() diff --git a/commons/src/message-transporters/message.ts b/commons/src/message-transporters/message.ts index 1fcb53112..b66b2efc4 100644 --- a/commons/src/message-transporters/message.ts +++ b/commons/src/message-transporters/message.ts @@ -29,8 +29,8 @@ export enum ConnectionStateEvent { } export interface MessagePayloads { - [MessageType.NOTE_CONTENT_STATE_REQUEST]: number[] - [MessageType.NOTE_CONTENT_UPDATE]: number[] + [MessageType.NOTE_CONTENT_STATE_REQUEST]: ArrayBuffer + [MessageType.NOTE_CONTENT_UPDATE]: ArrayBuffer [MessageType.REALTIME_USER_STATE_SET]: { users: RealtimeUser[] ownUser: { diff --git a/commons/src/y-doc-sync/realtime-doc.spec.ts b/commons/src/y-doc-sync/realtime-doc.spec.ts index dd00e52ed..7743ffeec 100644 --- a/commons/src/y-doc-sync/realtime-doc.spec.ts +++ b/commons/src/y-doc-sync/realtime-doc.spec.ts @@ -21,12 +21,12 @@ describe('realtime doc', () => { it('restores a yjs state vector update correctly', () => { const realtimeDoc = new RealtimeDoc( 'notTheVectorText', - [ + new Uint8Array([ 1, 1, 221, 208, 165, 230, 3, 0, 4, 1, 15, 109, 97, 114, 107, 100, 111, 119, 110, 67, 111, 110, 116, 101, 110, 116, 32, 116, 101, 120, 116, 67, 111, 110, 116, 101, 110, 116, 70, 114, 111, 109, 83, 116, 97, 116, 101, 86, 101, 99, 116, 111, 114, 85, 112, 100, 97, 116, 101, 0, - ], + ]), ) expect(realtimeDoc.getCurrentContent()).toBe( diff --git a/commons/src/y-doc-sync/realtime-doc.ts b/commons/src/y-doc-sync/realtime-doc.ts index b1741d54e..31db78171 100644 --- a/commons/src/y-doc-sync/realtime-doc.ts +++ b/commons/src/y-doc-sync/realtime-doc.ts @@ -3,8 +3,8 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { EventEmitter2 } from 'eventemitter2' import type { EventMap } from 'eventemitter2' +import { EventEmitter2 } from 'eventemitter2' import { applyUpdate, Doc, @@ -16,7 +16,7 @@ import { const MARKDOWN_CONTENT_CHANNEL_NAME = 'markdownContent' export interface RealtimeDocEvents extends EventMap { - update: (update: number[], origin: unknown) => void + update: (update: ArrayBuffer, origin: unknown) => void } /** @@ -37,7 +37,7 @@ export class RealtimeDoc extends EventEmitter2 { * @param initialTextContent the initial text content of the {@link Doc YDoc} * @param initialYjsState the initial yjs state. If provided this will be used instead of the text content */ - constructor(initialTextContent?: string, initialYjsState?: number[]) { + constructor(initialTextContent?: string, initialYjsState?: ArrayBuffer) { super() if (initialYjsState) { this.applyUpdate(initialYjsState, this) @@ -46,7 +46,7 @@ export class RealtimeDoc extends EventEmitter2 { } this.docUpdateListener = (update, origin) => { - this.emit('update', Array.from(update), origin) + this.emit('update', update, origin) } this.doc.on('update', this.docUpdateListener) } @@ -77,11 +77,13 @@ export class RealtimeDoc extends EventEmitter2 { * * @param encodedTargetStateVector The current state vector of the other y-doc. If provided the update will contain only the differences. */ - public encodeStateAsUpdate(encodedTargetStateVector?: number[]): number[] { + public encodeStateAsUpdate( + encodedTargetStateVector?: ArrayBuffer, + ): ArrayBuffer { const update = encodedTargetStateVector ? new Uint8Array(encodedTargetStateVector) : undefined - return Array.from(encodeStateAsUpdate(this.doc, update)) + return encodeStateAsUpdate(this.doc, update) } public destroy(): void { @@ -95,11 +97,11 @@ export class RealtimeDoc extends EventEmitter2 { * @param payload The update to apply * @param origin A reference that triggered the update */ - public applyUpdate(payload: number[], origin: unknown): void { + public applyUpdate(payload: ArrayBuffer, origin: unknown): void { applyUpdate(this.doc, new Uint8Array(payload), origin) } - public encodeStateVector(): number[] { - return Array.from(encodeStateVector(this.doc)) + public encodeStateVector(): ArrayBuffer { + return encodeStateVector(this.doc) } } diff --git a/commons/src/y-doc-sync/y-doc-sync-adapter.spec.ts b/commons/src/y-doc-sync/y-doc-sync-adapter.spec.ts index d2c6c9d53..baf51f6b6 100644 --- a/commons/src/y-doc-sync/y-doc-sync-adapter.spec.ts +++ b/commons/src/y-doc-sync/y-doc-sync-adapter.spec.ts @@ -104,7 +104,7 @@ describe('y-doc-sync-adapter', () => { console.log('s>2 is connected'), ) - docServer.on('update', (update: number[], origin: unknown) => { + docServer.on('update', (update: ArrayBuffer, origin: unknown) => { const message: Message = { type: MessageType.NOTE_CONTENT_UPDATE, payload: update, @@ -118,12 +118,12 @@ describe('y-doc-sync-adapter', () => { messageTransporterServerTo2.sendMessage(message) } }) - docClient1.on('update', (update: number[], origin: unknown) => { + docClient1.on('update', (update: ArrayBuffer, origin: unknown) => { if (origin !== messageTransporterClient1) { console.log('YDoc on client 1 updated. Sending to Server') } }) - docClient2.on('update', (update: number[], origin: unknown) => { + docClient2.on('update', (update: ArrayBuffer, origin: unknown) => { if (origin !== messageTransporterClient2) { console.log('YDoc on client 2 updated. Sending to Server') } diff --git a/commons/src/y-doc-sync/y-doc-sync-adapter.ts b/commons/src/y-doc-sync/y-doc-sync-adapter.ts index 752692dca..b3c77bd28 100644 --- a/commons/src/y-doc-sync/y-doc-sync-adapter.ts +++ b/commons/src/y-doc-sync/y-doc-sync-adapter.ts @@ -97,11 +97,11 @@ export abstract class YDocSyncAdapter { } } - protected applyIncomingUpdatePayload(update: number[]): void { + protected applyIncomingUpdatePayload(update: ArrayBuffer): void { this.doc.applyUpdate(update, this) } - private distributeDocUpdate(update: number[], origin: unknown): void { + private distributeDocUpdate(update: ArrayBuffer, origin: unknown): void { if (!this.isSynced() || origin === this) { return } diff --git a/commons/src/y-doc-sync/y-doc-sync-server-adapter.ts b/commons/src/y-doc-sync/y-doc-sync-server-adapter.ts index 396bc19a2..aa1ec4bc2 100644 --- a/commons/src/y-doc-sync/y-doc-sync-server-adapter.ts +++ b/commons/src/y-doc-sync/y-doc-sync-server-adapter.ts @@ -17,7 +17,7 @@ export class YDocSyncServerAdapter extends YDocSyncAdapter { this.markAsSynced() } - protected applyIncomingUpdatePayload(update: number[]): void { + protected applyIncomingUpdatePayload(update: ArrayBuffer): void { if (!this.acceptEditsProvider()) { return } diff --git a/frontend/src/components/common/new-note-button/new-note-button.tsx b/frontend/src/components/common/new-note-button/new-note-button.tsx index 8c05e0880..95c5c5732 100644 --- a/frontend/src/components/common/new-note-button/new-note-button.tsx +++ b/frontend/src/components/common/new-note-button/new-note-button.tsx @@ -12,7 +12,7 @@ import React, { useCallback } from 'react' import { FileEarmarkPlus as IconPlus } from 'react-bootstrap-icons' import { Trans } from 'react-i18next' import { useFrontendConfig } from '../frontend-config-context/use-frontend-config' -import { GuestAccess } from '@hedgedoc/commons' +import { PermissionLevel } from '@hedgedoc/commons' import { useIsLoggedIn } from '../../../hooks/common/use-is-logged-in' /** @@ -34,7 +34,7 @@ export const NewNoteButton: React.FC = () => { }) }, [router, showErrorNotification]) - if (!isLoggedIn && guestAccessLevel !== GuestAccess.CREATE) { + if (!isLoggedIn && guestAccessLevel !== PermissionLevel.CREATE) { return null } diff --git a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/permissions-sidebar-entry/permissions-modal/permission-entry-buttons.tsx b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/permissions-sidebar-entry/permissions-modal/permission-entry-buttons.tsx index 17b87429a..87d4ecb33 100644 --- a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/permissions-sidebar-entry/permissions-modal/permission-entry-buttons.tsx +++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/permissions-sidebar-entry/permissions-modal/permission-entry-buttons.tsx @@ -6,7 +6,7 @@ import { useTranslatedText } from '../../../../../../hooks/common/use-translated-text' import { UiIcon } from '../../../../../common/icons/ui-icon' import type { PermissionDisabledProps } from './permission-disabled.prop' -import { GuestAccess } from '@hedgedoc/commons' +import { PermissionLevel } from '@hedgedoc/commons' import React, { useMemo } from 'react' import { Button, ToggleButtonGroup } from 'react-bootstrap' import { Eye as IconEye, Pencil as IconPencil, X as IconX } from 'react-bootstrap-icons' @@ -24,7 +24,7 @@ export enum PermissionType { export interface PermissionEntryButtonsProps { type: PermissionType - currentSetting: GuestAccess + currentSetting: PermissionLevel name: string onSetReadOnly: () => void onSetWriteable: () => void @@ -79,14 +79,14 @@ export const PermissionEntryButtons: React.FC diff --git a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/permissions-sidebar-entry/permissions-modal/permission-entry-special-group.tsx b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/permissions-sidebar-entry/permissions-modal/permission-entry-special-group.tsx index e03f6dbc3..ce0b88536 100644 --- a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/permissions-sidebar-entry/permissions-modal/permission-entry-special-group.tsx +++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/permissions-sidebar-entry/permissions-modal/permission-entry-special-group.tsx @@ -10,7 +10,7 @@ import { setNotePermissionsFromServer } from '../../../../../../redux/note-detai import { IconButton } from '../../../../../common/icon-button/icon-button' import { useUiNotifications } from '../../../../../notifications/ui-notification-boundary' import type { PermissionDisabledProps } from './permission-disabled.prop' -import { GuestAccess, SpecialGroup } from '@hedgedoc/commons' +import { PermissionLevel, SpecialGroup } from '@hedgedoc/commons' import React, { useCallback, useMemo } from 'react' import { ToggleButtonGroup } from 'react-bootstrap' import { Eye as IconEye, Pencil as IconPencil, SlashCircle as IconSlashCircle } from 'react-bootstrap-icons' @@ -19,7 +19,7 @@ import { PermissionInconsistentAlert } from './permission-inconsistent-alert' import { cypressId } from '../../../../../../utils/cypress-attribute' export interface PermissionEntrySpecialGroupProps { - level: GuestAccess + level: PermissionLevel type: SpecialGroup inconsistent?: boolean } @@ -98,7 +98,7 @@ export const PermissionEntrySpecialGroup: React.FC = const specialGroupEntries = useMemo(() => { return { - everyoneLevel: groupEveryone ? (groupEveryone.canEdit ? GuestAccess.WRITE : GuestAccess.READ) : GuestAccess.DENY, - loggedInLevel: groupLoggedIn ? (groupLoggedIn.canEdit ? GuestAccess.WRITE : GuestAccess.READ) : GuestAccess.DENY, + everyoneLevel: groupEveryone + ? groupEveryone.canEdit + ? PermissionLevel.WRITE + : PermissionLevel.READ + : PermissionLevel.DENY, + loggedInLevel: groupLoggedIn + ? groupLoggedIn.canEdit + ? PermissionLevel.WRITE + : PermissionLevel.READ + : PermissionLevel.DENY, loggedInInconsistentAlert: groupEveryone && (!groupLoggedIn || (groupEveryone.canEdit && !groupLoggedIn.canEdit)) } }, [groupEveryone, groupLoggedIn]) diff --git a/frontend/src/components/login-page/guest/guest-card.tsx b/frontend/src/components/login-page/guest/guest-card.tsx index cbcd5926b..5c9b25e3d 100644 --- a/frontend/src/components/login-page/guest/guest-card.tsx +++ b/frontend/src/components/login-page/guest/guest-card.tsx @@ -10,7 +10,7 @@ import { NewNoteButton } from '../../common/new-note-button/new-note-button' import { HistoryButton } from '../../layout/app-bar/app-bar-elements/help-dropdown/history-button' import { useFrontendConfig } from '../../common/frontend-config-context/use-frontend-config' import { Trans, useTranslation } from 'react-i18next' -import { GuestAccess } from '@hedgedoc/commons' +import { PermissionLevel } from '@hedgedoc/commons' /** * Renders the card with the options for not logged-in users. @@ -20,7 +20,7 @@ export const GuestCard: React.FC = () => { useTranslation() - if (guestAccessLevel === GuestAccess.DENY) { + if (guestAccessLevel === PermissionLevel.DENY) { return null } @@ -34,7 +34,7 @@ export const GuestCard: React.FC = () => { - {guestAccessLevel !== GuestAccess.CREATE && ( + {guestAccessLevel !== PermissionLevel.CREATE && (
diff --git a/frontend/src/pages/api/private/auth/local/login.ts b/frontend/src/pages/api/private/auth/local/login.ts deleted file mode 100644 index 40dc07a53..000000000 --- a/frontend/src/pages/api/private/auth/local/login.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { NextApiRequest, NextApiResponse } from 'next' - -const handler = (req: NextApiRequest, res: NextApiResponse) => { - res.status(200).setHeader('Set-Cookie', ['mock-session=1; Path=/']).json({}) -} - -export default handler diff --git a/frontend/src/pages/api/private/auth/logout.ts b/frontend/src/pages/api/private/auth/logout.ts deleted file mode 100644 index a56102c4b..000000000 --- a/frontend/src/pages/api/private/auth/logout.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { NextApiRequest, NextApiResponse } from 'next' - -const handler = (req: NextApiRequest, res: NextApiResponse) => { - res.setHeader('Set-Cookie', 'mock-session=0; Path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT').status(200).json({ - redirect: '/' - }) -} - -export default handler diff --git a/frontend/src/pages/api/private/config.ts b/frontend/src/pages/api/private/config.ts deleted file mode 100644 index d626d22a5..000000000 --- a/frontend/src/pages/api/private/config.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import type { FrontendConfigDto } from '@hedgedoc/commons' -import { ProviderType, GuestAccess } from '@hedgedoc/commons' -import { - HttpMethod, - respondToMatchingRequest, - respondToTestRequest -} from '../../../handler-utils/respond-to-matching-request' -import { isTestMode } from '../../../utils/test-modes' -import type { NextApiRequest, NextApiResponse } from 'next' - -const initialConfig: FrontendConfigDto = { - allowRegister: true, - allowProfileEdits: true, - allowChooseUsername: true, - branding: { - name: 'DEMO Corp', - logo: '/public/img/demo.png' - }, - guestAccess: GuestAccess.WRITE, - useImageProxy: false, - specialUrls: { - privacy: 'https://example.com/privacy', - termsOfUse: 'https://example.com/termsOfUse', - imprint: 'https://example.com/imprint' - }, - version: { - major: isTestMode ? 0 : 2, - minor: 0, - patch: 0, - preRelease: isTestMode ? undefined : '', - commit: 'mock', - fullString: `${isTestMode ? 0 : 2}.0.0` - }, - plantUmlServer: isTestMode ? 'http://mock-plantuml.local' : 'https://www.plantuml.com/plantuml', - maxDocumentLength: isTestMode ? 200 : 1000000, - authProviders: [ - { - type: ProviderType.LOCAL - }, - { - type: ProviderType.LDAP, - identifier: 'test-ldap', - providerName: 'Test LDAP', - theme: null - }, - { - type: ProviderType.OIDC, - identifier: 'test-oidc', - providerName: 'Test OIDC', - theme: null - } - ] -} - -let currentConfig: FrontendConfigDto = initialConfig - -const handler = (req: NextApiRequest, res: NextApiResponse) => { - const responseSuccessful = respondToMatchingRequest( - HttpMethod.GET, - req, - res, - currentConfig, - 200, - false - ) - if (!responseSuccessful) { - respondToTestRequest(req, res, () => { - currentConfig = { - ...initialConfig, - ...(req.body as FrontendConfigDto) - } - return currentConfig - }) - } -} - -export default handler diff --git a/frontend/src/pages/api/private/groups/_EVERYONE.ts b/frontend/src/pages/api/private/groups/_EVERYONE.ts deleted file mode 100644 index d8ecea5f3..000000000 --- a/frontend/src/pages/api/private/groups/_EVERYONE.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { HttpMethod, respondToMatchingRequest } from '../../../../handler-utils/respond-to-matching-request' -import type { NextApiRequest, NextApiResponse } from 'next' -import type { GroupInfoDto } from '@hedgedoc/commons' - -const handler = (req: NextApiRequest, res: NextApiResponse) => { - respondToMatchingRequest(HttpMethod.GET, req, res, { - name: '_EVERYONE', - displayName: 'Everyone', - special: true - }) -} - -export default handler diff --git a/frontend/src/pages/api/private/groups/_LOGGED_IN.ts b/frontend/src/pages/api/private/groups/_LOGGED_IN.ts deleted file mode 100644 index c8812123c..000000000 --- a/frontend/src/pages/api/private/groups/_LOGGED_IN.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { HttpMethod, respondToMatchingRequest } from '../../../../handler-utils/respond-to-matching-request' -import type { NextApiRequest, NextApiResponse } from 'next' -import type { GroupInfoDto } from '@hedgedoc/commons' - -const handler = (req: NextApiRequest, res: NextApiResponse) => { - respondToMatchingRequest(HttpMethod.GET, req, res, { - name: '_LOGGED_IN', - displayName: 'All registered users', - special: true - }) -} - -export default handler diff --git a/frontend/src/pages/api/private/groups/hedgedoc-devs.ts b/frontend/src/pages/api/private/groups/hedgedoc-devs.ts deleted file mode 100644 index 87ffa6f40..000000000 --- a/frontend/src/pages/api/private/groups/hedgedoc-devs.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { HttpMethod, respondToMatchingRequest } from '../../../../handler-utils/respond-to-matching-request' -import type { NextApiRequest, NextApiResponse } from 'next' -import type { GroupInfoDto } from '@hedgedoc/commons' - -const handler = (req: NextApiRequest, res: NextApiResponse) => { - respondToMatchingRequest(HttpMethod.GET, req, res, { - name: 'hedgedoc-devs', - displayName: 'HedgeDoc devs', - special: true - }) -} - -export default handler diff --git a/frontend/src/pages/api/private/me/history.ts b/frontend/src/pages/api/private/me/history.ts deleted file mode 100644 index f9b83b76c..000000000 --- a/frontend/src/pages/api/private/me/history.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import type { HistoryEntry } from '../../../../api/history/types' -import { HttpMethod, respondToMatchingRequest } from '../../../../handler-utils/respond-to-matching-request' -import type { NextApiRequest, NextApiResponse } from 'next' - -const handler = (req: NextApiRequest, res: NextApiResponse) => { - respondToMatchingRequest(HttpMethod.GET, req, res, [ - { - identifier: 'slide-example', - title: 'Slide example', - lastVisitedAt: '2020-05-30T15:20:36.088Z', - pinStatus: true, - tags: ['features', 'cool', 'updated'], - owner: null - }, - { - identifier: 'features', - title: 'Features', - lastVisitedAt: '2020-05-31T15:20:36.088Z', - pinStatus: true, - tags: ['features', 'cool', 'updated'], - owner: null - }, - { - identifier: 'ODakLc2MQkyyFc_Xmb53sg', - title: 'Non existent', - lastVisitedAt: '2020-05-25T19:48:14.025Z', - pinStatus: false, - tags: [], - owner: null - }, - { - identifier: 'l8JuWxApTR6Fqa0LCrpnLg', - title: 'Non existent', - lastVisitedAt: '2020-05-24T16:04:36.433Z', - pinStatus: false, - tags: ['agenda', 'HedgeDoc community', 'community call'], - owner: 'test' - } - ]) -} - -export default handler diff --git a/frontend/src/pages/api/private/me/index.ts b/frontend/src/pages/api/private/me/index.ts deleted file mode 100644 index 08c5f0597..000000000 --- a/frontend/src/pages/api/private/me/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { HttpMethod, respondToMatchingRequest } from '../../../../handler-utils/respond-to-matching-request' -import type { NextApiRequest, NextApiResponse } from 'next' -import type { LoginUserInfoDto } from '@hedgedoc/commons' -import { ProviderType } from '@hedgedoc/commons' - -const handler = (req: NextApiRequest, res: NextApiResponse): void => { - const cookieSet = req.headers?.['cookie']?.split(';').find((value) => value.trim() === 'mock-session=1') !== undefined - if (!cookieSet) { - res.status(403).json({}) - return - } - respondToMatchingRequest(HttpMethod.GET, req, res, { - username: 'mock', - photoUrl: '/public/img/avatar.png', - displayName: 'Mock User', - authProvider: ProviderType.LOCAL, - email: 'mock@hedgedoc.test' - }) -} - -export default handler diff --git a/frontend/src/pages/api/private/me/media.ts b/frontend/src/pages/api/private/me/media.ts deleted file mode 100644 index 35b2217a2..000000000 --- a/frontend/src/pages/api/private/me/media.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { HttpMethod, respondToMatchingRequest } from '../../../../handler-utils/respond-to-matching-request' -import type { NextApiRequest, NextApiResponse } from 'next' -import type { MediaUploadDto } from '@hedgedoc/commons' - -const handler = (req: NextApiRequest, res: NextApiResponse) => { - respondToMatchingRequest(HttpMethod.GET, req, res, [ - { - username: 'tilman', - createdAt: '2022-03-20T20:36:32Z', - uuid: '5355ed83-7e12-4db0-95ed-837e124db08c', - fileName: 'dummy.png', - noteId: 'features' - }, - { - username: 'tilman', - createdAt: '2022-03-20T20:36:57+0000', - uuid: '656745ab-fbf9-47f1-a745-abfbf9a7f10c', - fileName: 'dummy2.png', - noteId: null - } - ]) -} - -export default handler diff --git a/frontend/src/pages/api/private/media.ts b/frontend/src/pages/api/private/media.ts deleted file mode 100644 index c815d6845..000000000 --- a/frontend/src/pages/api/private/media.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { HttpMethod, respondToMatchingRequest } from '../../../handler-utils/respond-to-matching-request' -import { isMockMode, isTestMode } from '../../../utils/test-modes' -import type { NextApiRequest, NextApiResponse } from 'next' -import type { MediaUploadDto } from '@hedgedoc/commons' - -const handler = async (req: NextApiRequest, res: NextApiResponse): Promise => { - if (isMockMode && !isTestMode) { - await new Promise((resolve) => { - setTimeout(resolve, 3000) - }) - } - - respondToMatchingRequest( - HttpMethod.POST, - req, - res, - { - uuid: 'e81f57cd-5866-4253-9f57-cd5866a253ca', - fileName: 'avatar.png', - noteId: null, - username: 'test', - createdAt: '2022-02-27T21:54:23.856Z' - }, - 201 - ) -} - -export default handler diff --git a/frontend/src/pages/api/private/notes/features/index.ts b/frontend/src/pages/api/private/notes/features/index.ts deleted file mode 100644 index f86b865f2..000000000 --- a/frontend/src/pages/api/private/notes/features/index.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { HttpMethod, respondToMatchingRequest } from '../../../../../handler-utils/respond-to-matching-request' -import type { NextApiRequest, NextApiResponse } from 'next' -import type { NoteDto } from '@hedgedoc/commons' - -const handler = (req: NextApiRequest, res: NextApiResponse): void => { - respondToMatchingRequest(HttpMethod.GET, req, res, { - content: - '---\ntitle: Features\ndescription: Many features, such wow!\nrobots: noindex\ntags:\n - hedgedoc\n - demo\n - react\nopengraph:\n title: Features\n---\n# Embedding demo\n[TOC]\n\n## Vega-Lite\n\n```vega-lite\n\n{\n "$schema": "https://vega.github.io/schema/vega-lite/v5.json",\n "description": "Reproducing http://robslink.com/SAS/democd91/pyramid_pie.htm",\n "data": {\n "values": [\n {"category": "Sky", "value": 75, "order": 3},\n {"category": "Shady side of a pyramid", "value": 10, "order": 1},\n {"category": "Sunny side of a pyramid", "value": 15, "order": 2}\n ]\n },\n "mark": {"type": "arc", "outerRadius": 80},\n "encoding": {\n "theta": {\n "field": "value", "type": "quantitative",\n "scale": {"range": [2.35619449, 8.639379797]},\n "stack": true\n },\n "color": {\n "field": "category", "type": "nominal",\n "scale": {\n "domain": ["Sky", "Shady side of a pyramid", "Sunny side of a pyramid"],\n "range": ["#416D9D", "#674028", "#DEAC58"]\n },\n "legend": {\n "orient": "none",\n "title": null,\n "columns": 1,\n "legendX": 200,\n "legendY": 80\n }\n },\n "order": {\n "field": "order"\n }\n },\n "view": {"stroke": null}\n}\n\n\n```\n\n## GraphViz\n\n```graphviz\ngraph {\n a -- b\n a -- b\n b -- a [color=blue]\n}\n```\n\n```graphviz\ndigraph structs {\n node [shape=record];\n struct1 [label=" left| mid\ dle| right"];\n struct2 [label=" one| two"];\n struct3 [label="hello\nworld |{ b |{c| d|e}| f}| g | h"];\n struct1:f1 -> struct2:f0;\n struct1:f2 -> struct3:here;\n}\n```\n\n```graphviz\ndigraph G {\n main -> parse -> execute;\n main -> init;\n main -> cleanup;\n execute -> make_string;\n execute -> printf\n init -> make_string;\n main -> printf;\n execute -> compare;\n}\n```\n\n```graphviz\ndigraph D {\n node [fontname="Arial"];\n node_A [shape=record label="shape=record|{above|middle|below}|right"];\n node_B [shape=plaintext label="shape=plaintext|{curly|braces and|bars without}|effect"];\n}\n```\n\n```graphviz\ndigraph D {\n A -> {B, C, D} -> {F}\n}\n```\n\n## High Res Image\n\n![Wheat Field with Cypresses](/public/img/highres.jpg)\n\n## Sequence Diagram (deprecated)\n\n```sequence\nTitle: Here is a title\nnote over A: asdd\nA->B: Normal line\nB-->C: Dashed line\nC->>D: Open arrow\nD-->>A: Dashed open arrow\nparticipant IOOO\n```\n\n## Mermaid\n\n```mermaid\ngantt\n title A Gantt Diagram\n\n section Section\n A task: a1, 2014-01-01, 30d\n Another task: after a1, 20d\n\n section Another\n Task in sec: 2014-01-12, 12d\n Another task: 24d\n```\n\n## Flowchart\n\n```flow\nst=>start: Start\ne=>end: End\nop=>operation: My Operation\nop2=>operation: lalala\ncond=>condition: Yes or No?\n\nst->op->op2->cond\ncond(yes)->e\ncond(no)->op2\n```\n\n## ABC\n\n```abc\nX:1\nT:Speed the Plough\nM:4/4\nC:Trad.\nK:G\n|:GABc dedB|dedB dedB|c2ec B2dB|c2A2 A2BA|\nGABc dedB|dedB dedB|c2ec B2dB|A2F2 G4:|\n|:g2gf gdBd|g2f2 e2d2|c2ec B2dB|c2A2 A2df|\ng2gf g2Bd|g2f2 e2d2|c2ec B2dB|A2F2 G4:|\n```\n\n## CSV\n\n```csv delimiter=; header\nUsername; Identifier;First name;Last name\n"booker12; rbooker";9012;Rachel;Booker\ngrey07;2070;Laura;Grey\njohnson81;4081;Craig;Johnson\njenkins46;9346;Mary;Jenkins\nsmith79;5079;Jamie;Smith\n```\n\n## some plain text\n\nLorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.\n\n## KaTeX\nYou can render *LaTeX* mathematical expressions using **KaTeX**, as on [math.stackexchange.com](https://math.stackexchange.com/):\n\nThe *Gamma function* satisfying $\\Gamma(n) = (n-1)!\\quad\\forall n\\in\\mathbb N$ is via the Euler integral\n\n$$\nx = {-b \\pm \\sqrt{b^2-4ac} \\over 2a}.\n$$\n\n$$\n\\Gamma(z) = \\int_0^\\infty t^{z-1}e^{-t}dt\\,.\n$$\n\n> More information about **LaTeX** mathematical expressions [here](https://meta.math.stackexchange.com/questions/5020/mathjax-basic-tutorial-and-quick-reference).\n\n## Blockquote\n> Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.\n> Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.\n> [color=red] [name=John Doe] [time=2020-06-21 22:50]\n\n## Slideshare\n{%slideshare mazlan1/internet-of-things-the-tip-of-an-iceberg %}\n\n## Gist\nhttps://gist.github.com/schacon/1\n\n## YouTube\nhttps://www.youtube.com/watch?v=YE7VzlLtp-4\n\n## Vimeo\nhttps://vimeo.com/23237102\n\n## Asciinema\nhttps://asciinema.org/a/117928\n\n## PDF\n{%pdf https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf %}\n\n## Code highlighting\n```js=\nvar s = "JavaScript syntax highlighting";\nalert(s);\nfunction $initHighlight(block, cls) {\n try {\n if (cls.search(/\\bno\\-highlight\\b/) != -1)\n return process(block, true, 0x0F) +\n \' class=""\';\n } catch (e) {\n /* handle exception */\n }\n for (var i = 0 / 2; i < classes.length; i++) {\n if (checkCondition(classes[i]) === undefined)\n return /\\d+[\\s/]/g;\n }\n}\n```\n\n## PlantUML\n```plantuml\n@startuml\nparticipant Alice\nparticipant "The **Famous** Bob" as Bob\n\nAlice -> Bob : hello --there--\n... Some ~~long delay~~ ...\nBob -> Alice : ok\nnote left\n This is **bold**\n This is //italics//\n This is ""monospaced""\n This is --stroked--\n This is __underlined__\n This is ~~waved~~\nend note\n\nAlice -> Bob : A //well formatted// message\nnote right of Alice\n This is displayed\n __left of__ Alice.\nend note\nnote left of Bob\n This is displayed\n **left of Alice Bob**.\nend note\nnote over Alice, Bob\n This is hosted by \nend note\n@enduml\n```\n\n## ToDo List\n\n- [ ] ToDos\n - [X] Buy some salad\n - [ ] Brush teeth\n - [x] Drink some water\n - [ ] **Click my box** and see the source code, if you\'re allowed to edit!\n\n', - metadata: { - id: 'exampleId', - version: 2, - viewCount: 0, - updatedAt: '2021-04-24T09:27:51.000Z', - createdAt: '2021-04-24T09:27:51.000Z', - updateUsername: null, - primaryAddress: 'features', - editedBy: [], - title: 'Features', - tags: ['hedgedoc', 'demo', 'react'], - description: 'Many features, such wow!', - aliases: [ - { - name: 'features', - primaryAlias: true, - noteId: 'exampleId' - } - ], - permissions: { - owner: 'tilman', - sharedToUsers: [ - { - username: 'molly', - canEdit: true - } - ], - sharedToGroups: [ - { - groupName: '_LOGGED_IN', - canEdit: true - }, - { - groupName: '_EVERYONE', - canEdit: false - } - ] - } - }, - editedByAtPosition: [] - } as NoteDto) -} - -export default handler diff --git a/frontend/src/pages/api/private/notes/features/revisions/0.ts b/frontend/src/pages/api/private/notes/features/revisions/0.ts deleted file mode 100644 index 1a7e7955e..000000000 --- a/frontend/src/pages/api/private/notes/features/revisions/0.ts +++ /dev/null @@ -1,215 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import type { RevisionDto } from '@hedgedoc/commons' -import { HttpMethod, respondToMatchingRequest } from '../../../../../../handler-utils/respond-to-matching-request' -import type { NextApiRequest, NextApiResponse } from 'next' - -const handler = (req: NextApiRequest, res: NextApiResponse): void => { - respondToMatchingRequest(HttpMethod.GET, req, res, { - id: 0, - createdAt: '2021-12-21T16:59:42.000Z', - title: 'Features', - description: 'Many features, such wow!', - tags: ['hedgedoc', 'demo', 'react'], - patch: `Index: -=================================================================== ---- -+++ -@@ -0,0 +1,92 @@ -+--- -+title: Features -+description: Many features, such wow! -+robots: noindex -+tags: hedgedoc, demo, react -+opengraph: -+ title: Features -+--- -+# Embedding demo -+[TOC] -+ -+## some plain text -+ -+Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. -+ -+## MathJax -+You can render *LaTeX* mathematical expressions using **MathJax**, as on [math.stackexchange.com](https://math.stackexchange.com/): -+ -+The *Gamma function* satisfying $\\Gamma(n) = (n-1)!\\quad\\forall n\\in\\mathbb N$ is via the Euler integral -+ -+$$ -+x = {-b \\pm \\sqrt{b^2-4ac} \\over 2a}. -+$$ -+ -+$$ -+\\Gamma(z) = \\int_0^\\infty t^{z-1}e^{-t}dt\\,. -+$$ -+ -+> More information about **LaTeX** mathematical expressions [here](https://meta.math.stackexchange.com/questions/5020/mathjax-basic-tutorial-and-quick-reference). -+ -+## Blockquote -+> Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. -+> Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. -+> [color=red] [name=John Doe] [time=2020-06-21 22:50] -+ -+## Slideshare -+{%slideshare mazlan1/internet-of-things-the-tip-of-an-iceberg %} -+ -+## Gist -+https://gist.github.com/schacon/1 -+ -+## YouTube -+https://www.youtube.com/watch?v=KgMpKsp23yY -+ -+## Vimeo -+https://vimeo.com/23237102 -+ -+## Asciinema -+https://asciinema.org/a/117928 -+ -+## PDF -+{%pdf https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf %} -+ -+## Code highlighting -+\`\`\`javascript= -+ -+let a = 1 -+\`\`\` -+ -+## PlantUML -+\`\`\`plantuml -+@startuml -+participant Alice -+participant "The **Famous** Bob" as Bob -+ -+Alice -> Bob : hello --there-- -+... Some ~~long delay~~ ... -+Bob -> Alice : ok -+note left -+ This is **bold** -+ This is //italics// -+ This is ""monospaced"" -+ This is --stroked-- -+ This is __underlined__ -+ This is ~~waved~~ -+end note -+ -+Alice -> Bob : A //well formatted// message -+note right of Alice -+ This is displayed -+ __left of__ Alice. -+end note -+note left of Bob -+ This is displayed -+ **left of Alice Bob**. -+end note -+note over Alice, Bob -+ This is hosted by -+end note -+@enduml -+\`\`\` -+ -`, - edits: [], - length: 2782, - authorUsernames: [], - anonymousAuthorCount: 2, - content: `--- -title: Features -description: Many features, such wow! -robots: noindex -tags: hedgedoc, demo, react -opengraph: - title: Features ---- -# Embedding demo -[TOC] - -## some plain text - -Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. - -## MathJax -You can render *LaTeX* mathematical expressions using **MathJax**, as on [math.stackexchange.com](https://math.stackexchange.com/): - -The *Gamma function* satisfying $\\Gamma(n) = (n-1)!\\quad\\forall n\\in\\mathbb N$ is via the Euler integral - -$$ -x = {-b \\pm \\sqrt{b^2-4ac} \\over 2a}. -$$ - -$$ -\\Gamma(z) = \\int_0^\\infty t^{z-1}e^{-t}dt\\,. -$$ - -> More information about **LaTeX** mathematical expressions [here](https://meta.math.stackexchange.com/questions/5020/mathjax-basic-tutorial-and-quick-reference). - -## Blockquote -> Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. -> Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. -> [color=red] [name=John Doe] [time=2020-06-21 22:50] - -## Slideshare -{%slideshare mazlan1/internet-of-things-the-tip-of-an-iceberg %} - -## Gist -https://gist.github.com/schacon/1 - -## YouTube -https://www.youtube.com/watch?v=KgMpKsp23yY - -## Vimeo -https://vimeo.com/23237102 - -## Asciinema -https://asciinema.org/a/117928 - -## PDF -{%pdf https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf %} - -## Code highlighting -\`\`\`javascript= - -let a = 1 -\`\`\` - -## PlantUML -\`\`\`plantuml -@startuml -participant Alice -participant "The **Famous** Bob" as Bob - -Alice -> Bob : hello --there-- -... Some ~~long delay~~ ... -Bob -> Alice : ok -note left - This is **bold** - This is //italics// - This is ""monospaced"" - This is --stroked-- - This is __underlined__ - This is ~~waved~~ -end note - -Alice -> Bob : A //well formatted// message -note right of Alice - This is displayed - __left of__ Alice. -end note -note left of Bob - This is displayed - **left of Alice Bob**. -end note -note over Alice, Bob - This is hosted by -end note -@enduml -\`\`\` - -` - }) -} - -export default handler diff --git a/frontend/src/pages/api/private/notes/features/revisions/1.ts b/frontend/src/pages/api/private/notes/features/revisions/1.ts deleted file mode 100644 index 53be7be80..000000000 --- a/frontend/src/pages/api/private/notes/features/revisions/1.ts +++ /dev/null @@ -1,163 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import type { RevisionDto } from '@hedgedoc/commons' -import { HttpMethod, respondToMatchingRequest } from '../../../../../../handler-utils/respond-to-matching-request' -import type { NextApiRequest, NextApiResponse } from 'next' - -const handler = (req: NextApiRequest, res: NextApiResponse): void => { - respondToMatchingRequest(HttpMethod.GET, req, res, { - id: 1, - createdAt: '2021-12-29T17:54:11.000Z', - title: 'Features', - description: 'Many more features, such wow!', - tags: ['hedgedoc', 'demo', 'react'], - patch: `Index: -=================================================================== ---- -+++ -@@ -1,7 +1,7 @@ - --- - title: Features --description: Many features, such wow! -+description: Many more features, such wow! - robots: noindex - tags: hedgedoc, demo, react - opengraph: - title: Features -@@ -10,9 +10,9 @@ - [TOC] - - ## some plain text - --Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. -+Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magnus aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetezur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam _et_ justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. - - ## MathJax - You can render *LaTeX* mathematical expressions using **MathJax**, as on [math.stackexchange.com](https://math.stackexchange.com/): - -@@ -39,9 +39,9 @@ - ## Gist - https://gist.github.com/schacon/1 - - ## YouTube --https://www.youtube.com/watch?v=KgMpKsp23yY -+https://www.youtube.com/watch?v=zHAIuE5BQWk - - ## Vimeo - https://vimeo.com/23237102 - -@@ -62,9 +62,9 @@ - @startuml - participant Alice - participant "The **Famous** Bob" as Bob - --Alice -> Bob : hello --there-- -+Alice -> Bob : bye --there-- - ... Some ~~long delay~~ ... - Bob -> Alice : ok - note left - This is **bold**`, - edits: [], - length: 2788, - authorUsernames: [], - anonymousAuthorCount: 4, - content: `--- -title: Features -description: Many more features, such wow! -robots: noindex -tags: hedgedoc, demo, react -opengraph: - title: Features ---- -# Embedding demo -[TOC] - -## some plain text - -Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magnus aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetezur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam _et_ justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. - -## MathJax -You can render *LaTeX* mathematical expressions using **MathJax**, as on [math.stackexchange.com](https://math.stackexchange.com/): - -The *Gamma function* satisfying $\\Gamma(n) = (n-1)!\\quad\\forall n\\in\\mathbb N$ is via the Euler integral - -$$ -x = {-b \\pm \\sqrt{b^2-4ac} \\over 2a}. -$$ - -$$ -\\Gamma(z) = \\int_0^\\infty t^{z-1}e^{-t}dt\\,. -$$ - -> More information about **LaTeX** mathematical expressions [here](https://meta.math.stackexchange.com/questions/5020/mathjax-basic-tutorial-and-quick-reference). - -## Blockquote -> Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. -> Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. -> [color=red] [name=John Doe] [time=2020-06-21 22:50] - -## Slideshare -{%slideshare mazlan1/internet-of-things-the-tip-of-an-iceberg %} - -## Gist -https://gist.github.com/schacon/1 - -## YouTube -https://www.youtube.com/watch?v=zHAIuE5BQWk - -## Vimeo -https://vimeo.com/23237102 - -## Asciinema -https://asciinema.org/a/117928 - -## PDF -{%pdf https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf %} - -## Code highlighting -\`\`\`javascript= - -let a = 1 -\`\`\` - -## PlantUML -\`\`\`plantuml -@startuml -participant Alice -participant "The **Famous** Bob" as Bob - -Alice -> Bob : bye --there-- -... Some ~~long delay~~ ... -Bob -> Alice : ok -note left - This is **bold** - This is //italics// - This is ""monospaced"" - This is --stroked-- - This is __underlined__ - This is ~~waved~~ -end note - -Alice -> Bob : A //well formatted// message -note right of Alice - This is displayed - __left of__ Alice. -end note -note left of Bob - This is displayed - **left of Alice Bob**. -end note -note over Alice, Bob - This is hosted by -end note -@enduml -\`\`\` - -` - }) -} - -export default handler diff --git a/frontend/src/pages/api/private/notes/features/revisions/index.ts b/frontend/src/pages/api/private/notes/features/revisions/index.ts deleted file mode 100644 index 021ec252f..000000000 --- a/frontend/src/pages/api/private/notes/features/revisions/index.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import type { RevisionMetadataDto } from '@hedgedoc/commons' -import { HttpMethod, respondToMatchingRequest } from '../../../../../../handler-utils/respond-to-matching-request' -import type { NextApiRequest, NextApiResponse } from 'next' - -const handler = (req: NextApiRequest, res: NextApiResponse): void => { - respondToMatchingRequest(HttpMethod.GET, req, res, [ - { - id: 1, - createdAt: '2021-12-29T17:54:11.000Z', - length: 2788, - authorUsernames: [], - anonymousAuthorCount: 4, - title: 'Features', - description: 'Many features, such wow!', - tags: ['hedgedoc', 'demo', 'react'] - }, - { - id: 0, - createdAt: '2021-12-21T16:59:42.000Z', - length: 2782, - authorUsernames: [], - anonymousAuthorCount: 2, - title: 'Features', - description: 'Many more features, such wow!', - tags: ['hedgedoc', 'demo', 'react'] - } - ]) -} - -export default handler diff --git a/frontend/src/pages/api/private/notes/index.ts b/frontend/src/pages/api/private/notes/index.ts deleted file mode 100644 index ea6638d6e..000000000 --- a/frontend/src/pages/api/private/notes/index.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { HttpMethod, respondToMatchingRequest } from '../../../../handler-utils/respond-to-matching-request' -import type { NextApiRequest, NextApiResponse } from 'next' -import type { NoteDto } from '@hedgedoc/commons' - -const handler = (req: NextApiRequest, res: NextApiResponse): void => { - respondToMatchingRequest( - HttpMethod.POST, - req, - res, - { - content: 'new note content', - metadata: { - id: 'featuresId', - version: 2, - viewCount: 0, - updatedAt: '2021-04-24T09:27:51.000Z', - createdAt: '2021-04-24T09:27:51.000Z', - updateUsername: null, - primaryAddress: 'features', - editedBy: [], - title: 'New note', - tags: ['hedgedoc', 'demo', 'react'], - description: 'Many features, such wow!', - aliases: [ - { - name: 'features', - primaryAlias: true, - noteId: 'featuresId' - } - ], - permissions: { - owner: 'tilman', - sharedToUsers: [ - { - username: 'molly', - canEdit: true - } - ], - sharedToGroups: [ - { - groupName: '_LOGGED_IN', - canEdit: false - } - ] - } - }, - editedByAtPosition: [] - }, - 201 - ) -} - -export default handler diff --git a/frontend/src/pages/api/private/notes/slide-example/index.ts b/frontend/src/pages/api/private/notes/slide-example/index.ts deleted file mode 100644 index 2f3a26745..000000000 --- a/frontend/src/pages/api/private/notes/slide-example/index.ts +++ /dev/null @@ -1,65 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { HttpMethod, respondToMatchingRequest } from '../../../../../handler-utils/respond-to-matching-request' -import type { NextApiRequest, NextApiResponse } from 'next' -import type { NoteDto } from '@hedgedoc/commons' - -const handler = (req: NextApiRequest, res: NextApiResponse): void => { - respondToMatchingRequest(HttpMethod.GET, req, res, { - content: - '---\ntype: slide\nslideOptions:\n transition: slide\n---\n\n# Slide example\n\nThis feature still in beta, may have some issues.\n\nFor details please visit:\n\n\nYou can use `URL query` or `slideOptions` of the YAML metadata to customize your slides.\n\n---\n\n## First slide\n\n`---`\n\nIs the divider of slides\n\n----\n\n### First branch of first the slide\n\n`----`\n\nIs the divider of branches\n\nUse the *Space* key to navigate through all slides.\n\n----\n\n### Second branch of first the slide\n\nNested slides are useful for adding additional detail underneath a high-level horizontal slide.\n\n---\n\n## Point of View\n\nPress **ESC** to enter the slide overview.\n\n---\n\n## Touch Optimized\n\nPresentations look great on touch devices, like mobile phones and tablets. Simply swipe through your slides.\n\n---\n\n## Fragments\n\n``\n\nIs the fragment syntax\n\nHit the next arrow...\n\n... to step through ...\n\n... a fragmented slide.\n\nNote:\n This slide has fragments which are also stepped through in the notes window.\n\n---\n\n## Fragment Styles\n\nThere are different types of fragments, like:\n\ngrow\n\nshrink\n\nfade-out\n\nfade-up (also down, left and right!)\n\ncurrent-visible\n\nHighlight red blue green\n\n---\n\n\n\n## Transition Styles\nDifferent background transitions are available via the transition option. This one\'s called "zoom".\n\n``\n\nIs the transition syntax\n\nYou can use:\n\nnone/fade/slide/convex/concave/zoom\n\n---\n\n\n\n``\n\nAlso, you can set different in/out transition\n\nYou can use:\n\nnone/fade/slide/convex/concave/zoom\n\npostfix with `-in` or `-out`\n\n---\n\n\n\n``\n\nCustom the transition speed!\n\nYou can use:\n\ndefault/fast/slow\n\n---\n\n## Themes\n\nreveal.js comes with a few themes built in:\n\nBlack (default) - White - League - Sky - Beige - Simple\n\nSerif - Blood - Night - Moon - Solarized\n\nIt can be set in YAML slideOptions\n\n---\n\n\n\n``\n\nIs the background syntax\n\n---\n\n\n\n
\n\n## Image Backgrounds\n\n``\n\n
\n\n----\n\n\n\n
\n\n## Tiled Backgrounds\n\n``\n\n
\n\n----\n\n\n\n
\n\n## Video Backgrounds\n\n``\n\n
\n\n----\n\n\n\n## ... and GIFs!\n\n---\n\n## Pretty Code\n\n``` javascript\nfunction linkify( selector ) {\n if( supports3DTransforms ) {\n\n const nodes = document.querySelectorAll( selector );\n\n for( const i = 0, len = nodes.length; i < len; i++ ) {\n var node = nodes[i];\n\n if( !node.className ) {\n node.className += \' roll\';\n }\n }\n }\n}\n```\nCode syntax highlighting courtesy of [highlight.js](http://softwaremaniacs.org/soft/highlight/en/description/).\n\n---\n\n## Marvelous List\n\n- No order here\n- Or here\n- Or here\n- Or here\n\n---\n\n## Fantastic Ordered List\n\n1. One is smaller than...\n2. Two is smaller than...\n3. Three!\n\n---\n\n## Tabular Tables\n\n| Item | Value | Quantity |\n| ---- | ----- | -------- |\n| Apples | $1 | 7 |\n| Lemonade | $2 | 18 |\n| Bread | $3 | 2 |\n\n---\n\n## Clever Quotes\n\n> “For years there has been a theory that millions of monkeys typing at random on millions of typewriters would reproduce the entire works of Shakespeare. The Internet has proven this theory to be untrue.”\n\n---\n\n## Intergalactic Interconnections\n\nYou can link between slides internally, [like this](#/1/3).\n\n---\n\n## Speaker\n\nThere\'s a [speaker view](https://github.com/hakimel/reveal.js#speaker-notes). It includes a timer, preview of the upcoming slide as well as your speaker notes.\n\nPress the *S* key to try it out.\n\nNote:\n Oh hey, these are some notes. They\'ll be hidden in your presentation, but you can see them if you open the speaker notes window (hit `s` on your keyboard).\n\n---\n\n## Take a Moment\n\nPress `B` or `.` on your keyboard to pause the presentation. This is helpful when you\'re on stage and want to take distracting slides off the screen.\n\n---\n\n## Print your Slides\n\nDown below you can find a print icon.\n\nAfter you click on it, use the print function of your browser (either CTRL+P or cmd+P) to print the slides as PDF. [See official reveal.js instructions for details](https://github.com/hakimel/reveal.js#instructions-1)\n\n---\n\n# The End\n\n', - metadata: { - id: 'slideId', - primaryAddress: 'slide-example', - version: 2, - viewCount: 8, - updatedAt: '2021-04-30T18:38:23.000Z', - updateUsername: null, - createdAt: '2021-04-30T18:38:14.000Z', - editedBy: [], - title: 'Slide example', - tags: [], - description: '', - aliases: [ - { - noteId: 'slideId', - primaryAlias: true, - name: 'slide-example' - } - ], - permissions: { - owner: 'erik', - sharedToUsers: [ - { - username: 'tilman', - canEdit: true - }, - { - username: 'molly', - canEdit: true - } - ], - sharedToGroups: [ - { - groupName: '_LOGGED_IN', - canEdit: true - }, - { - groupName: '_EVERYONE', - canEdit: false - }, - { - groupName: 'hedgedoc-devs', - canEdit: true - } - ] - } - }, - editedByAtPosition: [] - }) -} - -export default handler diff --git a/frontend/src/pages/api/private/tokens.ts b/frontend/src/pages/api/private/tokens.ts deleted file mode 100644 index f471fa41b..000000000 --- a/frontend/src/pages/api/private/tokens.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { HttpMethod, respondToMatchingRequest } from '../../../handler-utils/respond-to-matching-request' -import type { NextApiRequest, NextApiResponse } from 'next' -import type { ApiTokenDto } from '@hedgedoc/commons' - -const handler = (req: NextApiRequest, res: NextApiResponse) => { - respondToMatchingRequest(HttpMethod.GET, req, res, [ - { - label: 'Demo-App', - keyId: 'demo', - createdAt: '2021-11-20T23:54:13+01:00', - lastUsedAt: '2021-11-20T23:54:13+01:00', - validUntil: '2022-11-20' - }, - { - label: 'CLI @ Test-PC', - keyId: 'cli', - createdAt: '2021-11-20T23:54:13+01:00', - lastUsedAt: '2021-11-20T23:54:13+01:00', - validUntil: '2021-11-20' - } - ]) -} - -export default handler diff --git a/frontend/src/pages/api/private/users/profile/erik.ts b/frontend/src/pages/api/private/users/profile/erik.ts deleted file mode 100644 index 38d54ced4..000000000 --- a/frontend/src/pages/api/private/users/profile/erik.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import type { UserInfoDto } from '@hedgedoc/commons' -import { HttpMethod, respondToMatchingRequest } from '../../../../../handler-utils/respond-to-matching-request' -import type { NextApiRequest, NextApiResponse } from 'next' - -const handler = (req: NextApiRequest, res: NextApiResponse): void => { - respondToMatchingRequest(HttpMethod.GET, req, res, { - username: 'erik', - displayName: 'Erik', - photoUrl: '/public/img/avatar.png' - }) -} - -export default handler diff --git a/frontend/src/pages/api/private/users/profile/mock.ts b/frontend/src/pages/api/private/users/profile/mock.ts deleted file mode 100644 index d2965c7a0..000000000 --- a/frontend/src/pages/api/private/users/profile/mock.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import type { UserInfoDto } from '@hedgedoc/commons' -import { HttpMethod, respondToMatchingRequest } from '../../../../../handler-utils/respond-to-matching-request' -import type { NextApiRequest, NextApiResponse } from 'next' - -const handler = (req: NextApiRequest, res: NextApiResponse): void => { - respondToMatchingRequest(HttpMethod.GET, req, res, { - username: 'mock', - displayName: 'Mock User', - photoUrl: '' - }) -} - -export default handler diff --git a/frontend/src/pages/api/private/users/profile/molly.ts b/frontend/src/pages/api/private/users/profile/molly.ts deleted file mode 100644 index 54dce6e00..000000000 --- a/frontend/src/pages/api/private/users/profile/molly.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import type { UserInfoDto } from '@hedgedoc/commons' -import { HttpMethod, respondToMatchingRequest } from '../../../../../handler-utils/respond-to-matching-request' -import type { NextApiRequest, NextApiResponse } from 'next' - -const handler = (req: NextApiRequest, res: NextApiResponse): void => { - respondToMatchingRequest(HttpMethod.GET, req, res, { - username: 'molly', - displayName: 'Molly', - photoUrl: '/public/img/avatar.png' - }) -} - -export default handler diff --git a/frontend/src/pages/api/private/users/profile/tilman.ts b/frontend/src/pages/api/private/users/profile/tilman.ts deleted file mode 100644 index 79b636146..000000000 --- a/frontend/src/pages/api/private/users/profile/tilman.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import type { UserInfoDto } from '@hedgedoc/commons' -import { HttpMethod, respondToMatchingRequest } from '../../../../../handler-utils/respond-to-matching-request' -import type { NextApiRequest, NextApiResponse } from 'next' - -const handler = (req: NextApiRequest, res: NextApiResponse): void => { - respondToMatchingRequest(HttpMethod.GET, req, res, { - username: 'tilman', - displayName: 'Tilman', - photoUrl: '/public/img/avatar.png' - }) -} - -export default handler diff --git a/yarn.lock b/yarn.lock index 381b038ec..79374512e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2764,13 +2764,14 @@ __metadata: file-type: "npm:16.5.4" htmlparser2: "npm:9.1.0" jest: "npm:29.7.0" + keyv: "npm:^5.3.2" knex: "npm:3.1.0" ldapauth-fork: "npm:6.1.0" markdown-it: "npm:13.0.2" minio: "npm:8.0.4" mocked-env: "npm:1.3.5" mysql: "npm:2.18.1" - nestjs-knex: "npm:2.0.0" + nest-knexjs: "npm:0.0.26" nestjs-zod: "npm:4.3.1" node-fetch: "npm:2.7.0" openid-client: "npm:5.7.1" @@ -3587,6 +3588,15 @@ __metadata: languageName: node linkType: hard +"@keyv/serialize@npm:^1.0.3": + version: 1.0.3 + resolution: "@keyv/serialize@npm:1.0.3" + dependencies: + buffer: "npm:^6.0.3" + checksum: 10c0/24a257870b0548cfe430680c2ae1641751e6a6ec90c573eaf51bfe956839b6cfa462b4d2827157363b6d620872d32d69fa2f37210a864ba488f8ec7158436398 + languageName: node + linkType: hard + "@ldapjs/asn1@npm:2.0.0, @ldapjs/asn1@npm:^2.0.0": version: 2.0.0 resolution: "@ldapjs/asn1@npm:2.0.0" @@ -7991,6 +8001,16 @@ __metadata: languageName: node linkType: hard +"buffer@npm:^6.0.3": + version: 6.0.3 + resolution: "buffer@npm:6.0.3" + dependencies: + base64-js: "npm:^1.3.1" + ieee754: "npm:^1.2.1" + checksum: 10c0/2a905fbbcde73cc5d8bd18d1caa23715d5f83a5935867c2329f0ac06104204ba7947be098fe1317fbd8830e26090ff8e764f08cd14fefc977bb248c3487bcbd0 + languageName: node + linkType: hard + "busboy@npm:1.6.0, busboy@npm:^1.0.0": version: 1.6.0 resolution: "busboy@npm:1.6.0" @@ -13906,6 +13926,15 @@ __metadata: languageName: node linkType: hard +"keyv@npm:^5.3.2": + version: 5.3.2 + resolution: "keyv@npm:5.3.2" + dependencies: + "@keyv/serialize": "npm:^1.0.3" + checksum: 10c0/293ebd052e7889685b8b770b7b4c9047aaafd821f5446b5b5ffa1cc6e9b830ee752f7b2d108bd96e1277c644c89f02a39e09c45159a6cb87663e183c4405989a + languageName: node + linkType: hard + "khroma@npm:^2.1.0": version: 2.1.0 resolution: "khroma@npm:2.1.0" @@ -15099,14 +15128,16 @@ __metadata: languageName: node linkType: hard -"nestjs-knex@npm:2.0.0": - version: 2.0.0 - resolution: "nestjs-knex@npm:2.0.0" +"nest-knexjs@npm:0.0.26": + version: 0.0.26 + resolution: "nest-knexjs@npm:0.0.26" peerDependencies: - "@nestjs/common": ">=6.7.0" - "@nestjs/core": ">=6.7.0" - knex: ">=0.95.4" - checksum: 10c0/8ce1e581aecf6f83f63adb82b41f2fd464e71657c9e01937fe026ea14d13672ce3bcc32b9066f2c62ac17a5285c908e049c8984b24d12288c0d82f9635d3703b + "@nestjs/common": ^9.0.0 || ^10.0.0 || ^11.0.0 + "@nestjs/core": ^9.0.0 || ^10.0.0 || ^11.0.0 + knex: ^0.95.0 || ^1.0.0 || ^2.0.0 || ^3.0.0 + reflect-metadata: ^0.1.13 || ^0.2.0 + rxjs: ^6.6.3 || ^7.2.0 + checksum: 10c0/3af89a78c03e5aa8258ef58c24a3a1a65f0de0d6bfa8680bf3429302e9269964551774c433b80700125c2e5123ca258950924fb333b6015a38e7b9c5d5261ef6 languageName: node linkType: hard