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..23e7e2858 --- /dev/null +++ b/backend/src/alias/alias.service.ts @@ -0,0 +1,308 @@ +/* + * 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. + * + * @returns 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} when the requested alias is forbidden + * @throws {NotInDBError} when the alias is not assigned to this note + * @throws {GenericDBError} when the database has an inconsistent state + */ + 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) + // This needs to be NULL in the database, as the constraints forbid multiple "false" values for the same note. + // These are the same constraints that also ensure only one alias is primary ("true"). + .update(FieldNameAlias.isPrimary, null) + .where(FieldNameAlias.noteId, noteId); + if (numberOfUpdatedEntries === 0) { + throw new GenericDBError( + `The note does not exist 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: string): 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 !== 1) { + throw new PrimaryAliasDeletionForbiddenError( + `The alias '${alias}' is the primary alias, which can not be removed.`, + this.logger.getContext(), + 'removeAlias', + ); + } + }); + } + + /** + * Gets the primary alias of the note specified by the noteId + * + * @param noteId The id of the note to get the primary alias of + * @returns The primary alias of the note + * @throws {NotInDBError} The note has no primary alias which should mean that the note does not exist + */ + async getPrimaryAliasByNoteId( + noteId: number, + transaction?: Knex, + ): Promise { + const dbActor = transaction ?? this.knex; + const primaryAlias = await dbActor(TableAlias) + .select(FieldNameAlias.alias) + .where(FieldNameAlias.noteId, noteId) + .andWhere(FieldNameAlias.isPrimary, true) + .first(); + if (primaryAlias === undefined) { + throw new NotInDBError( + `The noteId '${noteId}' has no primary alias.`, + this.logger.getContext(), + 'getPrimaryAliasByNoteId', + ); + } + return primaryAlias[FieldNameAlias.alias]; + } + + /** + * Gets all aliases of the note specified by the noteId + * + * @param noteId The id of the note to get the primary alias of + * @returns The primary alias of the note + * @throws {NotInDBError} The note has no primary alias which should mean that the note does not exist + */ + async getAllAliases( + noteId: number, + transaction?: Knex, + ): Promise[]> { + const dbActor = transaction ?? this.knex; + const aliases = await dbActor(TableAlias) + .select(FieldNameAlias.alias, FieldNameAlias.isPrimary) + .where(FieldNameAlias.noteId, noteId); + if (aliases.length === 0) { + throw new NotInDBError( + `The noteId '${noteId}' has no aliases. This should never happen.`, + this.logger.getContext(), + 'getAllAliases', + ); + } + return aliases; + } + + /** + * 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.log( + `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.module.ts b/backend/src/api-token/api-token.module.ts index 3a84a904c..38b6fec8e 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 { ApiTokenGuard } from '../api/utils/guards/api-token.guard'; +import { MockApiTokenGuard } from '../api/utils/guards/mock-api-token.guard'; 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..752d669ec 100644 --- a/backend/src/api-token/api-token.service.ts +++ b/backend/src/api-token/api-token.service.ts @@ -6,19 +6,23 @@ 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, TableApiToken } 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 +30,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 +57,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 - @Timeout(5000) + // Delete all invalid tokens 60 sec after startup + @Timeout(60 * 1000) 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..71fd0b9b3 100644 --- a/backend/src/api/private/alias/alias.controller.ts +++ b/backend/src/api/private/alias/alias.controller.ts @@ -3,9 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { AliasCreateDto } from '@hedgedoc/commons'; -import { AliasUpdateDto } from '@hedgedoc/commons'; -import { AliasDto } from '@hedgedoc/commons'; +import { AliasCreateDto, AliasUpdateDto } from '@hedgedoc/commons'; import { BadRequestException, Body, @@ -19,15 +17,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 { OpenApi } from '../../utils/openapi.decorator'; -import { RequestUser } from '../../utils/request-user.decorator'; +import { NoteService } from '../../../notes/note.service'; +import { PermissionService } from '../../../permissions/permission.service'; +import { OpenApi } from '../../utils/decorators/openapi.decorator'; +import { RequestUserId } from '../../utils/decorators/request-user-id.decorator'; @UseGuards(SessionGuard) @OpenApi(401) @@ -37,63 +33,74 @@ 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); } + /** + * Checks if the user has permission to modify the note with the given alias and returns the note ID. + * + * @param userId The ID of the user + * @param existingAlias The alias of the note + * @returns The ID of the note + * @throws UnauthorizedException if the user does not have permission to modify the note + */ + private async getNoteIdWithPermissionCheck( + userId: number, + existingAlias: string, + ): Promise { + const noteId = await this.noteService.getNoteIdByAlias(existingAlias); + const isUserNoteOwner = await this.permissionsService.isOwner( + userId, + noteId, + ); + if (!isUserNoteOwner) { + throw new UnauthorizedException( + 'Modifying aliases requires note ownership permissions', + ); + } + return noteId; + } + @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.getNoteIdWithPermissionCheck( + userId, + newAliasDto.noteAlias, ); - if (!(await this.permissionsService.isOwner(user, note))) { - 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') @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'.`, + `This endpoint can only set an alias as primary, therefore the field 'primaryAlias' must be set to 'true'.`, ); } - const note = await this.noteService.getNoteByIdOrAlias(alias); - if (!(await this.permissionsService.isOwner(user, note))) { - throw new UnauthorizedException('Reading note denied!'); - } - const updatedAlias = await this.aliasService.makeAliasPrimary(note, alias); - return this.aliasService.toAliasDto(updatedAlias, note); + const noteId = await this.getNoteIdWithPermissionCheck(userId, alias); + await this.aliasService.makeAliasPrimary(noteId, alias); } @Delete(':alias') @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))) { - throw new UnauthorizedException('Reading note denied!'); - } - await this.aliasService.removeAlias(note, alias); - return; + await this.getNoteIdWithPermissionCheck(userId, alias); + await this.aliasService.removeAlias(alias); } } 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 56% 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..d583afdd4 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 { OpenApi } from '../../utils/decorators/openapi.decorator'; +import { RequestUserId } from '../../utils/decorators/request-user-id.decorator'; @UseGuards(SessionGuard) @OpenApi(401) @@ -34,16 +33,16 @@ import { RequestUser } from '../../utils/request-user.decorator'; export class ApiTokensController { constructor( private readonly logger: ConsoleLoggerService, - private publicAuthTokenService: ApiTokenService, + private apiTokenService: ApiTokenService, ) { this.logger.setContext(ApiTokensController.name); } @Get() @OpenApi(200) - async getUserTokens(@RequestUser() user: User): Promise { - return (await this.publicAuthTokenService.getTokensByUser(user)).map( - (token) => this.publicAuthTokenService.toAuthTokenDto(token), + async getUserTokens(@RequestUserId() userId: number): Promise { + return (await this.apiTokenService.getTokensOfUserById(userId)).map( + (token) => this.apiTokenService.toAuthTokenDto(token), ); } @@ -51,10 +50,10 @@ export class ApiTokensController { @OpenApi(201) async postTokenRequest( @Body() createDto: ApiTokenCreateDto, - @RequestUser() user: User, + @RequestUserId() userId: User[FieldNameUser.id], ): Promise { - return await this.publicAuthTokenService.addToken( - user, + return await this.apiTokenService.createToken( + userId, createDto.label, createDto.validUntil, ); @@ -63,17 +62,9 @@ export class ApiTokensController { @Delete('/:keyId') @OpenApi(204, 404) async deleteToken( - @RequestUser() user: User, + @RequestUserId() 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.apiTokenService.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..0479d497e 100644 --- a/backend/src/api/private/auth/auth.controller.ts +++ b/backend/src/api/private/auth/auth.controller.ts @@ -4,10 +4,10 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { - FullUserInfoDto, + AuthProviderType, LogoutResponseDto, PendingUserConfirmationDto, - ProviderType, + PendingUserInfoDto, } from '@hedgedoc/commons'; import { BadRequestException, @@ -15,6 +15,7 @@ import { Controller, Delete, Get, + InternalServerErrorException, Put, Req, UseGuards, @@ -23,9 +24,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 { OpenApi } from '../../utils/decorators/openapi.decorator'; +import { RequestWithSession } from '../../utils/request.type'; @ApiTags('auth') @Controller('auth') @@ -43,7 +45,7 @@ export class AuthController { @OpenApi(200, 400, 401) logout(@Req() request: RequestWithSession): LogoutResponseDto { let logoutUrl: string | null = null; - if (request.session.authProviderType === ProviderType.OIDC) { + if (request.session.authProviderType === AuthProviderType.OIDC) { logoutUrl = this.oidcService.getLogoutUrl(request); } request.session.destroy((err) => { @@ -53,7 +55,7 @@ export class AuthController { undefined, 'logout', ); - throw new BadRequestException('Unable to log out'); + throw new InternalServerErrorException('Unable to log out'); } }); return { @@ -63,7 +65,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 +82,7 @@ export class AuthController { @OpenApi(204, 400) async confirmPendingUserData( @Req() request: RequestWithSession, - @Body() updatedUserInfo: PendingUserConfirmationDto, + @Body() pendingUserConfirmationData: PendingUserConfirmationDto, ): Promise { if ( !request.session.newUserData || @@ -88,14 +92,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, - ); - request.session.username = (await identity.user).username; + request.session.userId = + await this.identityService.createUserWithIdentityFromPendingUserConfirmation( + request.session.newUserData, + pendingUserConfirmationData, + request.session.authProviderType, + request.session.authProviderIdentifier, + request.session.providerUserId, + ); // 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..b0dd53ae7 --- /dev/null +++ b/backend/src/api/private/auth/guest/guest.controller.ts @@ -0,0 +1,56 @@ +/* + * 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 { 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, + ) { + 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..60eb6020a 100644 --- a/backend/src/api/private/auth/ldap/ldap.controller.ts +++ b/backend/src/api/private/auth/ldap/ldap.controller.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { + AuthProviderType, LdapLoginDto, LdapLoginResponseDto, - ProviderType, } from '@hedgedoc/commons'; import { Body, @@ -20,11 +20,12 @@ import { ApiTags } from '@nestjs/swagger'; import { IdentityService } from '../../../../auth/identity.service'; import { LdapService } from '../../../../auth/ldap/ldap.service'; -import { RequestWithSession } from '../../../../auth/session.guard'; +import { FieldNameIdentity } from '../../../../database/types'; import { NotInDBError } from '../../../../errors/errors'; import { ConsoleLoggerService } from '../../../../logger/console-logger.service'; import { UsersService } from '../../../../users/users.service'; -import { OpenApi } from '../../../utils/openapi.decorator'; +import { OpenApi } from '../../../utils/decorators/openapi.decorator'; +import { RequestWithSession } from '../../../utils/request.type'; @ApiTags('auth') @Controller('/auth/ldap') @@ -53,26 +54,24 @@ export class LdapController { loginDto.password, ); try { - request.session.authProviderType = ProviderType.LDAP; + request.session.authProviderType = AuthProviderType.LDAP; request.session.authProviderIdentifier = ldapIdentifier; request.session.providerUserId = userInfo.id; - await this.identityService.getIdentityFromUserIdAndProviderType( - userInfo.id, - ProviderType.LDAP, - ldapIdentifier, - ); - if (this.identityService.mayUpdateIdentity(ldapIdentifier)) { - const user = await this.usersService.getUserByUsername( - loginDto.username.toLowerCase(), + const identity = + await this.identityService.getIdentityFromUserIdAndProviderType( + userInfo.id, + AuthProviderType.LDAP, + ldapIdentifier, ); + if (this.identityService.mayUpdateIdentity(ldapIdentifier)) { await this.usersService.updateUser( - user, + identity[FieldNameIdentity.userId], userInfo.displayName, userInfo.email, userInfo.photoUrl, ); } - request.session.username = loginDto.username; + request.session.userId = identity[FieldNameIdentity.userId]; return { newUser: false }; } catch (error) { if (error instanceof NotInDBError) { diff --git a/backend/src/api/private/auth/local/local.controller.ts b/backend/src/api/private/auth/local/local.controller.ts index 679bc8017..c6f434d1e 100644 --- a/backend/src/api/private/auth/local/local.controller.ts +++ b/backend/src/api/private/auth/local/local.controller.ts @@ -4,8 +4,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { + AuthProviderType, LoginDto, - ProviderType, RegisterDto, UpdatePasswordDto, } from '@hedgedoc/commons'; @@ -21,17 +21,16 @@ import { import { ApiTags } from '@nestjs/swagger'; import { LocalService } from '../../../../auth/local/local.service'; -import { - RequestWithSession, - SessionGuard, -} from '../../../../auth/session.guard'; -import { User } from '../../../../database/user.entity'; +import { SessionGuard } from '../../../../auth/session.guard'; +import { FieldNameIdentity, FieldNameUser } from '../../../../database/types'; +import { NoLocalIdentityError } 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 { OpenApi } from '../../../utils/decorators/openapi.decorator'; +import { RequestUserId } from '../../../utils/decorators/request-user-id.decorator'; +import { LoginEnabledGuard } from '../../../utils/guards/login-enabled.guard'; +import { RegistrationEnabledGuard } from '../../../utils/guards/registration-enabled.guard'; +import { RequestWithSession } from '../../../utils/request.type'; @ApiTags('auth') @Controller('/auth/local') @@ -52,34 +51,34 @@ export class LocalController { @Body() registerDto: RegisterDto, ): Promise { await this.localIdentityService.checkPasswordStrength(registerDto.password); - const user = await this.usersService.createUser( + const userId = 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.authProviderType = AuthProviderType.LOCAL; + request.session.userId = 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); + const username = user[FieldNameUser.username]; + if (username === null) { + throw new NoLocalIdentityError('User has no username assigned'); + } await this.localIdentityService.checkLocalPassword( - user, + username, changePasswordDto.currentPassword, ); await this.localIdentityService.updateLocalPassword( - user, + userId, changePasswordDto.newPassword, ); } @@ -93,15 +92,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.authProviderType = ProviderType.LOCAL; + request.session.userId = identity[FieldNameIdentity.userId]; + request.session.authProviderType = AuthProviderType.LOCAL; } catch (error) { - this.logger.error(`Failed to log in user: ${String(error)}`); + this.logger.log(`Failed to log in user: ${String(error)}`, 'login'); throw new UnauthorizedException('Invalid username or password'); } } diff --git a/backend/src/api/private/auth/oidc/oidc.controller.ts b/backend/src/api/private/auth/oidc/oidc.controller.ts index a07be8a2c..4f072dcd5 100644 --- a/backend/src/api/private/auth/oidc/oidc.controller.ts +++ b/backend/src/api/private/auth/oidc/oidc.controller.ts @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { ProviderType } from '@hedgedoc/commons'; +import { AuthProviderType } from '@hedgedoc/commons'; import { Controller, Get, @@ -18,10 +18,11 @@ import { ApiTags } from '@nestjs/swagger'; import { IdentityService } from '../../../../auth/identity.service'; import { OidcService } from '../../../../auth/oidc/oidc.service'; -import { RequestWithSession } from '../../../../auth/session.guard'; +import { FieldNameIdentity } from '../../../../database/types'; import { ConsoleLoggerService } from '../../../../logger/console-logger.service'; import { UsersService } from '../../../../users/users.service'; -import { OpenApi } from '../../../utils/openapi.decorator'; +import { OpenApi } from '../../../utils/decorators/openapi.decorator'; +import { RequestWithSession } from '../../../utils/request.type'; @ApiTags('auth') @Controller('/auth/oidc') @@ -46,7 +47,7 @@ export class OidcController { const state = this.oidcService.generateState(); request.session.oidcLoginCode = code; request.session.oidcLoginState = state; - request.session.authProviderType = ProviderType.OIDC; + request.session.authProviderType = AuthProviderType.OIDC; request.session.authProviderIdentifier = oidcIdentifier; const authorizationUrl = this.oidcService.getAuthorizationUrl( oidcIdentifier, @@ -73,7 +74,7 @@ export class OidcController { this.logger.log('No OIDC user identifier in callback', 'callback'); throw new UnauthorizedException('No OIDC user identifier found'); } - request.session.authProviderType = ProviderType.OIDC; + request.session.authProviderType = AuthProviderType.OIDC; const identity = await this.oidcService.getExistingOidcIdentity( oidcIdentifier, oidcUserIdentifier, @@ -85,17 +86,17 @@ export class OidcController { return { url: '/new-user' }; } - const user = await identity.user; + const userId = identity[FieldNameIdentity.userId]; if (mayUpdate) { await this.usersService.updateUser( - user, + userId, userInfo.displayName, userInfo.email, userInfo.photoUrl, ); } - request.session.username = user.username; + request.session.userId = userId; return { url: '/' }; } catch (error) { if (error instanceof HttpException) { diff --git a/backend/src/api/private/config/config.controller.ts b/backend/src/api/private/config/config.controller.ts index 3ebb0ca96..558a08202 100644 --- a/backend/src/api/private/config/config.controller.ts +++ b/backend/src/api/private/config/config.controller.ts @@ -9,7 +9,7 @@ import { ApiTags } from '@nestjs/swagger'; import { FrontendConfigService } from '../../../frontend-config/frontend-config.service'; import { ConsoleLoggerService } from '../../../logger/console-logger.service'; -import { OpenApi } from '../../utils/openapi.decorator'; +import { OpenApi } from '../../utils/decorators/openapi.decorator'; @ApiTags('config') @Controller('config') diff --git a/backend/src/api/private/groups/groups.controller.ts b/backend/src/api/private/groups/groups.controller.ts index 98bf6fe40..22511b5dc 100644 --- a/backend/src/api/private/groups/groups.controller.ts +++ b/backend/src/api/private/groups/groups.controller.ts @@ -10,7 +10,7 @@ import { ApiTags } from '@nestjs/swagger'; import { SessionGuard } from '../../../auth/session.guard'; import { GroupsService } from '../../../groups/groups.service'; import { ConsoleLoggerService } from '../../../logger/console-logger.service'; -import { OpenApi } from '../../utils/openapi.decorator'; +import { OpenApi } from '../../utils/decorators/openapi.decorator'; @UseGuards(SessionGuard) @OpenApi(401, 403) @@ -27,8 +27,6 @@ export class GroupsController { @Get(':groupName') @OpenApi(200) async getGroup(@Param('groupName') groupName: string): Promise { - return this.groupService.toGroupDto( - await this.groupService.getGroupByName(groupName), - ); + return await this.groupService.getGroupInfoDtoByName(groupName); } } diff --git a/backend/src/api/private/me/history/history.controller.ts b/backend/src/api/private/me/history/history.controller.ts deleted file mode 100644 index 5004637da..000000000 --- a/backend/src/api/private/me/history/history.controller.ts +++ /dev/null @@ -1,92 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { - Body, - Controller, - Delete, - Get, - Post, - Put, - UseGuards, - UseInterceptors, -} from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; - -import { SessionGuard } from '../../../../auth/session.guard'; -import { User } from '../../../../database/user.entity'; -import { HistoryEntryImportListDto } from '../../../../history/history-entry-import.dto'; -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 { Note } from '../../../../notes/note.entity'; -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'; - -@UseGuards(SessionGuard) -@OpenApi(401) -@ApiTags('history') -@Controller('/me/history') -export class HistoryController { - constructor( - private readonly logger: ConsoleLoggerService, - private historyService: HistoryService, - ) { - this.logger.setContext(HistoryController.name); - } - - @Get() - @OpenApi(200, 404) - async getHistory(@RequestUser() user: User): Promise { - const foundEntries = await this.historyService.getEntriesByUser(user); - return await Promise.all( - foundEntries.map((entry) => this.historyService.toHistoryEntryDto(entry)), - ); - } - - @Post() - @OpenApi(201, 404) - async setHistory( - @RequestUser() user: User, - @Body() historyImport: HistoryEntryImportListDto, - ): Promise { - await this.historyService.setHistory(user, historyImport.history); - } - - @Delete() - @OpenApi(204, 404) - async deleteHistory(@RequestUser() user: User): Promise { - await this.historyService.deleteHistory(user); - } - - @Put(':noteIdOrAlias') - @OpenApi(200, 404) - @UseInterceptors(GetNoteInterceptor) - async updateHistoryEntry( - @RequestNote() note: Note, - @RequestUser() user: User, - @Body() entryUpdateDto: HistoryEntryUpdateDto, - ): Promise { - const newEntry = await this.historyService.updateHistoryEntry( - note, - user, - entryUpdateDto, - ); - return await this.historyService.toHistoryEntryDto(newEntry); - } - - @Delete(':noteIdOrAlias') - @OpenApi(204, 404) - @UseInterceptors(GetNoteInterceptor) - async deleteHistoryEntry( - @RequestNote() note: Note, - @RequestUser() 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..9e750a251 100644 --- a/backend/src/api/private/me/me.controller.ts +++ b/backend/src/api/private/me/me.controller.ts @@ -4,22 +4,20 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { + AuthProviderType, LoginUserInfoDto, MediaUploadDto, - ProviderType, } from '@hedgedoc/commons'; 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 { SessionAuthProvider } from '../../utils/session-authprovider.decorator'; +import { OpenApi } from '../../utils/decorators/openapi.decorator'; +import { RequestUserId } from '../../utils/decorators/request-user-id.decorator'; +import { SessionAuthProvider } from '../../utils/decorators/session-authprovider.decorator'; @UseGuards(SessionGuard) @OpenApi(401) @@ -36,42 +34,43 @@ export class MeController { @Get() @OpenApi(200) - getMe( - @RequestUser() user: User, - @SessionAuthProvider() authProvider: ProviderType, - ): LoginUserInfoDto { + async getMe( + @RequestUserId() userId: number, + @SessionAuthProvider() authProvider: AuthProviderType, + ): Promise { + const user = await this.userService.getUserById(userId); return this.userService.toLoginUserInfoDto(user, authProvider); } @Get('media') @OpenApi(200) - async getMyMedia(@RequestUser() user: User): Promise { - const media = await this.mediaService.listUploadsByUser(user); - return await Promise.all( - media.map((media) => this.mediaService.toMediaUploadDto(media)), - ); + async getMyMedia(@RequestUserId() userId: number): Promise { + const mediaUuids = + await this.mediaService.getMediaUploadUuidsByUserId(userId); + return await this.mediaService.getMediaUploadDtosByUuids(mediaUuids); } @Delete() @OpenApi(204, 404, 500) - async deleteUser(@RequestUser() user: User): Promise { - const mediaUploads = await this.mediaService.listUploadsByUser(user); + async deleteUser(@RequestUserId() 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, + @RequestUserId() 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..c6bfd5c90 100644 --- a/backend/src/api/private/media/media.controller.ts +++ b/backend/src/api/private/media/media.controller.ts @@ -11,30 +11,26 @@ import { Get, Param, Post, - Res, 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 { 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 { 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 { NoteHeaderInterceptor } from '../../utils/interceptors/note-header.interceptor'; @UseGuards(SessionGuard) @OpenApi(401) @@ -44,7 +40,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 +60,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,72 +79,48 @@ export class MediaController { ) async uploadMedia( @UploadedFile() file: MulterFile | undefined, - @RequestNote() note: Note, - @RequestUser({ guestsAllowed: true }) user: User | null, - ): Promise { + @RequestNoteId() noteId: number, + @RequestUserId() userId: number, + ): Promise { if (file === undefined) { throw new BadRequestException('Request does not contain a file'); } - if (user) { - this.logger.debug( - `Received filename '${file.originalname}' for note '${note.publicId}' from user '${user.username}'`, - 'uploadMedia', - ); - } else { - this.logger.debug( - `Received filename '${file.originalname}' for note '${note.publicId}' from not logged in user`, - 'uploadMedia', - ); - } - const upload = await this.mediaService.saveFile( + this.logger.debug( + `Received filename '${file.originalname}' for note '${noteId}' from user '${userId}'`, + 'uploadMedia', + ); + return await this.mediaService.saveFile( file.originalname, file.buffer, - user, - note, + userId, + noteId, ); - return await this.mediaService.toMediaUploadDto(upload); } @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, + @RequestUserId() userId: number, @Param('uuid') uuid: string, ): Promise { - const mediaUpload = await this.mediaService.findUploadByUuid(uuid); - if ( - await this.permissionsService.checkMediaDeletePermission( - user, - mediaUpload, - ) - ) { - this.logger.debug( - `Deleting '${uuid}' for user '${user.username}'`, - 'deleteMedia', - ); - await this.mediaService.deleteFile(mediaUpload); - } else { + const hasUserMediaDeletePermission = + await this.permissionsService.checkMediaDeletePermission(userId, uuid); + if (!hasUserMediaDeletePermission) { 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'`, ); } + this.logger.debug(`Deleting '${uuid}' for user '${userId}'`, 'deleteMedia'); + await this.mediaService.deleteFile(uuid); } } diff --git a/backend/src/api/private/notes/notes.controller.ts b/backend/src/api/private/notes/notes.controller.ts index 84893f7f9..9f970b262 100644 --- a/backend/src/api/private/notes/notes.controller.ts +++ b/backend/src/api/private/notes/notes.controller.ts @@ -32,25 +32,22 @@ import { import { ApiTags } from '@nestjs/swagger'; import { SessionGuard } from '../../../auth/session.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(SessionGuard, PermissionsGuard) @OpenApi(401, 403) @@ -59,77 +56,76 @@ import { RequestUser } from '../../utils/request-user.decorator'; export class NotesController { constructor( private readonly logger: ConsoleLoggerService, - private noteService: NotesService, - private historyService: HistoryService, + private noteService: NoteService, private userService: UsersService, private mediaService: MediaService, private revisionsService: RevisionsService, - private permissionService: PermissionsService, + private permissionService: PermissionService, private groupService: GroupsService, ) { this.logger.setContext(NotesController.name); } - @Get(':noteIdOrAlias') + @Get(':noteAlias') @OpenApi(200) @RequirePermission(RequiredPermission.READ) - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) async getNote( - @RequestUser({ guestsAllowed: true }) user: User | null, - @RequestNote() note: Note, + @RequestUserId({ guestsAllowed: true }) userId: number, + @RequestNoteId() noteId: number, ): Promise { - await this.historyService.updateHistoryEntryTimestamp(note, user); - return await this.noteService.toNoteDto(note); + return await this.noteService.toNoteDto(noteId); } - @Get(':noteIdOrAlias/media') + @Get(':noteAlias/media') @OpenApi(200) @RequirePermission(RequiredPermission.READ) - @UseInterceptors(GetNoteInterceptor) - async getNotesMedia(@RequestNote() note: Note): Promise { - const media = await this.mediaService.listUploadsByNote(note); - return await Promise.all( - media.map((media) => this.mediaService.toMediaUploadDto(media)), - ); + @UseInterceptors(GetNoteIdInterceptor) + async getNotesMedia( + @RequestNoteId() noteId: number, + ): Promise { + const media = await this.mediaService.getMediaUploadUuidsByNoteId(noteId); + return await this.mediaService.getMediaUploadDtosByUuids(media); } @Post() @OpenApi(201, 413) @RequirePermission(RequiredPermission.CREATE) async createNote( - @RequestUser({ guestsAllowed: true }) user: User | null, + @RequestUserId({ guestsAllowed: true }) userId: number, @MarkdownBody() text: string, ): Promise { - this.logger.debug('Got raw markdown:\n' + text, 'createNote'); - return await this.noteService.toNoteDto( - await this.noteService.createNote(text, user), - ); + const createdNoteId = await this.noteService.createNote(text, userId); + return await this.noteService.toNoteDto(createdNoteId); } @Post(':noteAlias') @OpenApi(201, 400, 404, 409, 413) @RequirePermission(RequiredPermission.CREATE) async createNamedNote( - @RequestUser({ guestsAllowed: true }) user: User | null, + @RequestUserId({ guestsAllowed: true }) 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 createdNoteId = await this.noteService.createNote( + text, + userId, + noteAlias, ); + return await this.noteService.toNoteDto(createdNoteId); } - @Delete(':noteIdOrAlias') + @Delete(':noteAlias') @OpenApi(204, 404, 500) @RequirePermission(RequiredPermission.OWNER) - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) async deleteNote( - @RequestUser() user: User, - @RequestNote() note: Note, + @RequestUserId() userId: number, + @RequestNoteId() noteId: number, @Body() noteMediaDeletionDto: NoteMediaDeletionDto, ): Promise { - const mediaUploads = await this.mediaService.listUploadsByNote(note); + const mediaUploads = + await this.mediaService.getMediaUploadUuidsByNoteId(noteId); for (const mediaUpload of mediaUploads) { if (!noteMediaDeletionDto.keepMedia) { await this.mediaService.deleteFile(mediaUpload); @@ -137,106 +133,78 @@ export class NotesController { await this.mediaService.removeNoteFromMediaUpload(mediaUpload); } } - this.logger.debug(`Deleting note: ${note.id}`, 'deleteNote'); - await this.noteService.deleteNote(note); - this.logger.debug(`Successfully deleted ${note.id}`, 'deleteNote'); - return; + await this.noteService.deleteNote(noteId); + this.logger.debug(`Successfully deleted ${noteId}`, 'deleteNote'); } - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(RequiredPermission.READ) - @Get(':noteIdOrAlias/metadata') + @Get(':noteAlias/metadata') async getNoteMetadata( - @RequestUser({ guestsAllowed: true }) user: User | null, - @RequestNote() note: Note, + @RequestUserId({ guestsAllowed: true }) userId: number, + @RequestNoteId() noteId: number, ): Promise { - return await this.noteService.toNoteMetadataDto(note); + return await this.noteService.toNoteMetadataDto(noteId); } - @Get(':noteIdOrAlias/revisions') + @Get(':noteAlias/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 }) userId: number, + @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); } - @Delete(':noteIdOrAlias/revisions') + @Delete(':noteAlias/revisions') @OpenApi(204, 404) @RequirePermission(RequiredPermission.OWNER) - @UseInterceptors(GetNoteInterceptor) - async purgeNoteRevisions( - @RequestUser() user: User, - @RequestNote() note: Note, - ): Promise { + @UseInterceptors(GetNoteIdInterceptor) + async purgeNoteRevisions(@RequestNoteId() noteId: number): Promise { + await this.revisionsService.purgeRevisions(noteId); this.logger.debug( - `Purging history of note: ${note.id}`, + `Successfully purged history of note ${noteId}`, 'purgeNoteRevisions', ); - await this.revisionsService.purgeRevisions(note); - this.logger.debug( - `Successfully purged history of note ${note.id}`, - 'purgeNoteRevisions', - ); - return; } - @Get(':noteIdOrAlias/revisions/:revisionId') + @Get(':noteAlias/revisions/:revisionUuid') @OpenApi(200, 404) @RequirePermission(RequiredPermission.READ) - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) async getNoteRevision( - @RequestUser({ guestsAllowed: true }) user: User | null, - @RequestNote() note: Note, - @Param('revisionId') revisionId: number, + @Param('revisionUuid') revisionUuid: string, ): Promise { - return await this.revisionsService.toRevisionDto( - await this.revisionsService.getRevision(note, revisionId), - ); + return await this.revisionsService.getRevisionDto(revisionUuid); } - @Put(':noteIdOrAlias/metadata/permissions/users/:username') + @Put(':noteAlias/metadata/permissions/users/:username') @OpenApi(200, 403, 404) - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(RequiredPermission.OWNER) async setUserPermission( - @RequestUser() user: User, - @RequestNote() note: Note, + @RequestUserId() userId: number, + @RequestNoteId() noteId: number, @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, - canEdit, - ); - return await this.noteService.toNotePermissionsDto(returnedNote); + await this.permissionService.setUserPermission(noteId, userId, canEdit); + return await this.noteService.toNotePermissionsDto(noteId); } - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(RequiredPermission.OWNER) - @Delete(':noteIdOrAlias/metadata/permissions/users/:username') + @Delete(':noteAlias/metadata/permissions/users/:username') async removeUserPermission( - @RequestUser() user: User, - @RequestNote() note: Note, + @RequestUserId() userId: number, + @RequestNoteId() noteId: number, @Param('username') username: NoteUserPermissionEntryDto['username'], ): Promise { try { - const permissionUser = await this.userService.getUserByUsername(username); - const returnedNote = await this.permissionService.removeUserPermission( - note, - permissionUser, - ); - return await this.noteService.toNotePermissionsDto(returnedNote); + await this.permissionService.removeUserPermission(noteId, userId); + return await this.noteService.toNotePermissionsDto(noteId); } catch (e) { if (e instanceof NotInDBError) { throw new BadRequestException( @@ -247,54 +215,43 @@ export class NotesController { } } - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(RequiredPermission.OWNER) - @Put(':noteIdOrAlias/metadata/permissions/groups/:groupName') + @Put(':noteAlias/metadata/permissions/groups/:groupName') async setGroupPermission( - @RequestUser() user: User, - @RequestNote() note: Note, + @RequestNoteId() noteId: number, @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, - canEdit, - ); - return await this.noteService.toNotePermissionsDto(returnedNote); + const groupId = await this.groupService.getGroupIdByName(groupName); + await this.permissionService.setGroupPermission(noteId, groupId, canEdit); + return await this.noteService.toNotePermissionsDto(noteId); } - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(RequiredPermission.OWNER) @UseGuards(PermissionsGuard) - @Delete(':noteIdOrAlias/metadata/permissions/groups/:groupName') + @Delete(':noteAlias/metadata/permissions/groups/:groupName') async removeGroupPermission( - @RequestUser() user: User, - @RequestNote() note: Note, + @RequestNoteId() noteId: number, @Param('groupName') groupName: NoteGroupPermissionEntryDto['groupName'], ): 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.noteService.toNotePermissionsDto(noteId); } - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(RequiredPermission.OWNER) - @Put(':noteIdOrAlias/metadata/permissions/owner') + @Put(':noteAlias/metadata/permissions/owner') async changeOwner( - @RequestUser() user: User, - @RequestNote() note: Note, + @RequestNoteId() noteId: number, @Body() changeNoteOwnerDto: ChangeNoteOwnerDto, ): Promise { - const owner = await this.userService.getUserByUsername( + const newOwnerId = await this.userService.getUserIdByUsername( changeNoteOwnerDto.owner, ); - return await this.noteService.toNoteDto( - await this.permissionService.changeOwner(note, owner), - ); + await this.permissionService.changeOwner(noteId, newOwnerId); + return await this.noteService.toNoteDto(noteId); } } diff --git a/backend/src/api/private/private-api.module.ts b/backend/src/api/private/private-api.module.ts index 169ec3ec6..5ee3662af 100644 --- a/backend/src/api/private/private-api.module.ts +++ b/backend/src/api/private/private-api.module.ts @@ -1,33 +1,33 @@ /* - * 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 { NoteModule } from '../../notes/note.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'; import { ConfigController } from './config/config.controller'; import { GroupsController } from './groups/groups.controller'; -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,19 +36,19 @@ import { UsersController } from './users/users.controller'; UsersModule, ApiTokenModule, FrontendConfigModule, - HistoryModule, PermissionsModule, - NotesModule, + AliasModule, MediaModule, RevisionsModule, AuthModule, GroupsModule, + NoteModule, ], controllers: [ ApiTokensController, ConfigController, + GuestController, MediaController, - HistoryController, MeController, NotesController, AliasController, diff --git a/backend/src/api/private/users/users.controller.ts b/backend/src/api/private/users/users.controller.ts index d8957ba60..24aabe6f9 100644 --- a/backend/src/api/private/users/users.controller.ts +++ b/backend/src/api/private/users/users.controller.ts @@ -13,7 +13,7 @@ import { ApiTags } from '@nestjs/swagger'; import { ConsoleLoggerService } from '../../../logger/console-logger.service'; import { UsersService } from '../../../users/users.service'; -import { OpenApi } from '../../utils/openapi.decorator'; +import { OpenApi } from '../../utils/decorators/openapi.decorator'; @ApiTags('users') @Controller('users') @@ -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 @@ -41,8 +41,6 @@ export class UsersController { @Get('profile/:username') @OpenApi(200) async getUser(@Param('username') username: string): Promise { - return this.userService.toUserDto( - await this.userService.getUserByUsername(username), - ); + return await this.userService.getUserDtoByUsername(username); } } diff --git a/backend/src/api/public/alias/alias.controller.ts b/backend/src/api/public/alias/alias.controller.ts index 1a7ef3ac8..0571d0d34 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 { ApiTokenGuard } from '../../../api-token/api-token.guard'; -import { User } from '../../../database/user.entity'; +import { AliasService } from '../../../alias/alias.service'; +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 { OpenApi } from '../../utils/openapi.decorator'; -import { RequestUser } from '../../utils/request-user.decorator'; +import { NoteService } from '../../../notes/note.service'; +import { PermissionService } from '../../../permissions/permission.service'; +import { OpenApi } from '../../utils/decorators/openapi.decorator'; +import { RequestUserId } from '../../utils/decorators/request-user-id.decorator'; +import { ApiTokenGuard } from '../../utils/guards/api-token.guard'; @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); } @@ -57,68 +57,71 @@ export class AliasController { 404, ) async addAlias( - @RequestUser() user: User, + @RequestUserId() userId: number, @Body() newAliasDto: AliasCreateDto, ): Promise { - const note = await this.noteService.getNoteByIdOrAlias( - newAliasDto.noteIdOrAlias, + const noteId = await this.noteService.getNoteIdByAlias( + newAliasDto.noteAlias, ); - if (!(await this.permissionsService.isOwner(user, note))) { - throw new UnauthorizedException('Reading note denied!'); + const isUserOwner = await this.permissionsService.isOwner(userId, noteId); + if (!isUserOwner) { + throw new UnauthorizedException( + 'Only the owner of a note can modify its aliases', + ); } - const updatedAlias = await this.aliasService.addAlias( - note, - newAliasDto.newAlias, - ); - return this.aliasService.toAliasDto(updatedAlias, note); + await this.aliasService.addAlias(noteId, newAliasDto.newAlias); + return { + name: newAliasDto.newAlias, + isPrimaryAlias: false, + }; } @Put(':alias') @OpenApi( { code: 200, - description: 'The updated alias', + description: 'The updated aliases', schema: AliasSchema, }, 403, 404, ) async makeAliasPrimary( - @RequestUser() user: User, + @RequestUserId() userId: number, @Param('alias') alias: string, @Body() changeAliasDto: AliasUpdateDto, ): Promise { if (!changeAliasDto.primaryAlias) { throw new BadRequestException( - `The field 'primaryAlias' must be set to 'true'.`, + `This endpoint can only set an alias as primary, therefore 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); + return this.aliasService.toAliasDto(alias, changeAliasDto.primaryAlias); } @Delete(':alias') @OpenApi( { code: 204, - description: 'The alias was deleted', + description: 'The aliases was deleted', }, 400, 403, 404, ) async removeAlias( - @RequestUser() user: User, - @Param('alias') alias: AliasDto['name'], + @RequestUserId() user: number, + @Param('alias') alias: string, ): 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!'); } - await this.aliasService.removeAlias(note, alias); + await this.aliasService.removeAlias(alias); } } diff --git a/backend/src/api/public/me/me.controller.ts b/backend/src/api/public/me/me.controller.ts index 189adb353..dc17298d5 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, + AuthProviderType, + LoginUserInfoDto, + LoginUserInfoSchema, MediaUploadDto, MediaUploadSchema, NoteMetadataDto, NoteMetadataSchema, } 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 { OpenApi } from '../../utils/decorators/openapi.decorator'; +import { RequestUserId } from '../../utils/decorators/request-user-id.decorator'; +import { SessionAuthProvider } from '../../utils/decorators/session-authprovider.decorator'; +import { ApiTokenGuard } from '../../utils/guards/api-token.guard'; @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); @@ -57,69 +44,14 @@ export class MeController { @OpenApi({ code: 200, description: 'The user information', - schema: FullUserInfoSchema, + schema: LoginUserInfoSchema, }) - 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( + @RequestUserId() userId: number, + @SessionAuthProvider() authProvider: AuthProviderType, + ): Promise { + const user: User = await this.usersService.getUserById(userId); + return this.usersService.toLoginUserInfoDto(user, authProvider); } @Get('notes') @@ -129,10 +61,12 @@ export class MeController { isArray: true, schema: NoteMetadataSchema, }) - async getMyNotes(@RequestUser() user: User): Promise { - const notes = this.notesService.getUserNotes(user); + async getMyNotes( + @RequestUserId() userId: number, + ): Promise { + const noteIds = await this.notesService.getUserNoteIds(userId); return await Promise.all( - (await notes).map((note) => this.notesService.toNoteMetadataDto(note)), + noteIds.map((note) => this.notesService.toNoteMetadataDto(note)), ); } @@ -143,10 +77,8 @@ export class MeController { isArray: true, schema: MediaUploadSchema, }) - async getMyMedia(@RequestUser() user: User): Promise { - const media = await this.mediaService.listUploadsByUser(user); - return await Promise.all( - media.map((media) => this.mediaService.toMediaUploadDto(media)), - ); + async getMyMedia(@RequestUserId() userId: number): Promise { + const media = await this.mediaService.getMediaUploadUuidsByUserId(userId); + return await 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..99631f02c 100644 --- a/backend/src/api/public/media/media.controller.ts +++ b/backend/src/api/public/media/media.controller.ts @@ -11,7 +11,6 @@ import { Get, Param, Post, - Res, UploadedFile, UseGuards, UseInterceptors, @@ -24,23 +23,27 @@ import { ApiSecurity, ApiTags, } from '@nestjs/swagger'; -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 { 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 { ApiTokenGuard } from '../../utils/guards/api-token.guard'; +import { NoteHeaderInterceptor } from '../../utils/interceptors/note-header.interceptor'; @UseGuards(ApiTokenGuard) @OpenApi(401) @@ -51,7 +54,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 +74,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,65 +92,57 @@ 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]))[0]; } @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, + @RequestUserId() 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); + await this.mediaService.deleteFile(uuid); } 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; + const mediaUploadNote = mediaUpload[FieldNameMediaUpload.noteId]; throw new PermissionError( `Neither file '${uuid}' nor note '${ - mediaUploadNote?.publicId ?? 'unknown' - }'is owned by '${user.username}'`, + mediaUploadNote ?? 'unknown' + }'is owned by '${userId}'`, ); } } diff --git a/backend/src/api/public/monitoring/monitoring.controller.ts b/backend/src/api/public/monitoring/monitoring.controller.ts index df91653f5..1ec33fcbe 100644 --- a/backend/src/api/public/monitoring/monitoring.controller.ts +++ b/backend/src/api/public/monitoring/monitoring.controller.ts @@ -7,9 +7,9 @@ import { ServerStatusDto, ServerStatusSchema } from '@hedgedoc/commons'; import { Controller, Get, UseGuards } from '@nestjs/common'; import { ApiSecurity, ApiTags } from '@nestjs/swagger'; -import { ApiTokenGuard } from '../../../api-token/api-token.guard'; import { MonitoringService } from '../../../monitoring/monitoring.service'; -import { OpenApi } from '../../utils/openapi.decorator'; +import { OpenApi } from '../../utils/decorators/openapi.decorator'; +import { ApiTokenGuard } from '../../utils/guards/api-token.guard'; @UseGuards(ApiTokenGuard) @OpenApi(401) diff --git a/backend/src/api/public/notes/notes.controller.ts b/backend/src/api/public/notes/notes.controller.ts index 9ff726c56..e06fd8a82 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, @@ -33,26 +31,22 @@ import { } from '@nestjs/common'; 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 { ApiTokenGuard } from '../../utils/guards/api-token.guard'; +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(noteId); } - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(RequiredPermission.OWNER) @Delete(':noteIdOrAlias') @OpenApi(204, 403, 404, 500) async deleteNote( - @RequestUser() user: User, - @RequestNote() note: Note, + @RequestUserId() userId: number, + @RequestNoteId() noteId: number, @Body() noteMediaDeletionDto: NoteMediaDeletionDto, ): Promise { - const mediaUploads = await this.mediaService.listUploadsByNote(note); + const mediaUploads = + await this.mediaService.getMediaUploadUuidsByNoteId(noteId); for (const mediaUpload of mediaUploads) { if (!noteMediaDeletionDto.keepMedia) { await this.mediaService.deleteFile(mediaUpload); @@ -147,13 +138,13 @@ export class NotesController { await this.mediaService.removeNoteFromMediaUpload(mediaUpload); } } - this.logger.debug(`Deleting note: ${note.id}`, 'deleteNote'); - await this.noteService.deleteNote(note); - this.logger.debug(`Successfully deleted ${note.id}`, 'deleteNote'); + this.logger.debug(`Deleting note: ${noteId}`, 'deleteNote'); + await this.noteService.deleteNote(noteId); + this.logger.debug(`Successfully deleted ${noteId}`, 'deleteNote'); return; } - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(RequiredPermission.WRITE) @Put(':noteIdOrAlias') @OpenApi( @@ -166,17 +157,16 @@ export class NotesController { 404, ) async updateNote( - @RequestUser() user: User, - @RequestNote() note: Note, + @RequestUserId() userId: number, + @RequestNoteId() noteId: number, @MarkdownBody() text: string, ): Promise { this.logger.debug('Got raw markdown:\n' + text, 'updateNote'); - return await this.noteService.toNoteDto( - await this.noteService.updateNote(note, text), - ); + await this.noteService.updateNote(noteId, text); + return await this.noteService.toNoteDto(noteId); } - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(RequiredPermission.READ) @Get(':noteIdOrAlias/content') @OpenApi( @@ -189,13 +179,13 @@ export class NotesController { 404, ) async getNoteContent( - @RequestUser() user: User, - @RequestNote() note: Note, + @RequestUserId() userId: number, + @RequestNoteId() noteId: number, ): Promise { - return await this.noteService.getNoteContent(note); + return await this.noteService.getNoteContent(noteId); } - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(RequiredPermission.READ) @Get(':noteIdOrAlias/metadata') @OpenApi( @@ -208,35 +198,12 @@ export class NotesController { 404, ) async getNoteMetadata( - @RequestUser() user: User, - @RequestNote() note: Note, + @RequestNoteId() noteId: number, ): Promise { - return await this.noteService.toNoteMetadataDto(note); + return await this.noteService.toNoteMetadataDto(noteId); } - @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 +216,12 @@ export class NotesController { 404, ) async getPermissions( - @RequestUser() user: User, - @RequestNote() note: Note, + @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 +234,21 @@ export class NotesController { 404, ) async setUserPermission( - @RequestUser() user: User, - @RequestNote() note: Note, + @RequestUserId() 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 +261,16 @@ export class NotesController { 404, ) async removeUserPermission( - @RequestUser() user: User, - @RequestNote() note: Note, + @RequestUserId() 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 +283,17 @@ export class NotesController { 404, ) async setGroupPermission( - @RequestUser() user: User, - @RequestNote() note: Note, + @RequestUserId() 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 +306,15 @@ export class NotesController { 404, ) async removeGroupPermission( - @RequestUser() user: User, - @RequestNote() note: Note, + @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 +327,16 @@ export class NotesController { 404, ) async changeOwner( - @RequestUser() user: User, - @RequestNote() note: Note, + @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(noteId); } - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(RequiredPermission.READ) @Get(':noteIdOrAlias/revisions') @OpenApi( @@ -405,40 +350,30 @@ 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), - ); + return await this.revisionsService.getRevisionDto(revisionUuid); } - @UseInterceptors(GetNoteInterceptor) + @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(RequiredPermission.READ) @Get(':noteIdOrAlias/media') @OpenApi({ @@ -448,12 +383,10 @@ export class NotesController { schema: MediaUploadSchema, }) async getNotesMedia( - @RequestUser() user: User, - @RequestNote() note: Note, + @RequestNoteId() noteId: number, ): Promise { - const media = await this.mediaService.listUploadsByNote(note); - return await Promise.all( - media.map((media) => this.mediaService.toMediaUploadDto(media)), - ); + const mediaUuids = + await this.mediaService.getMediaUploadUuidsByNoteId(noteId); + return await this.mediaService.getMediaUploadDtosByUuids(mediaUuids); } } diff --git a/backend/src/api/public/public-api.module.ts b/backend/src/api/public/public-api.module.ts index 57bea4a3f..df29e5df8 100644 --- a/backend/src/api/public/public-api.module.ts +++ b/backend/src/api/public/public-api.module.ts @@ -1,17 +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 { 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 { NoteModule } from '../../notes/note.module'; import { PermissionsModule } from '../../permissions/permissions.module'; import { RevisionsModule } from '../../revisions/revisions.module'; import { UsersModule } from '../../users/users.module'; @@ -26,13 +26,13 @@ import { NotesController } from './notes/notes.controller'; ApiTokenModule, GroupsModule, UsersModule, - HistoryModule, - NotesModule, + AliasModule, RevisionsModule, MonitoringModule, LoggerModule, MediaModule, PermissionsModule, + NoteModule, ], controllers: [ AliasController, 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-token/api-token.guard.ts b/backend/src/api/utils/guards/api-token.guard.ts similarity index 65% rename from backend/src/api-token/api-token.guard.ts rename to backend/src/api/utils/guards/api-token.guard.ts index ebcf89d4c..b76fa343e 100644 --- a/backend/src/api-token/api-token.guard.ts +++ b/backend/src/api/utils/guards/api-token.guard.ts @@ -1,14 +1,15 @@ /* - * 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 { AuthProviderType } from '@hedgedoc/commons'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; -import { CompleteRequest } from '../api/utils/request.type'; -import { NotInDBError, TokenNotValidError } from '../errors/errors'; -import { ConsoleLoggerService } from '../logger/console-logger.service'; -import { ApiTokenService } from './api-token.service'; +import { ApiTokenService } from '../../../api-token/api-token.service'; +import { NotInDBError, TokenNotValidError } from '../../../errors/errors'; +import { ConsoleLoggerService } from '../../../logger/console-logger.service'; +import { CompleteRequest } from '../request.type'; @Injectable() export class ApiTokenGuard implements CanActivate { @@ -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/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-token/mock-api-token.guard.ts b/backend/src/api/utils/guards/mock-api-token.guard.ts similarity index 67% rename from backend/src/api-token/mock-api-token.guard.ts rename to backend/src/api/utils/guards/mock-api-token.guard.ts index daf998cdc..8bfde612d 100644 --- a/backend/src/api-token/mock-api-token.guard.ts +++ b/backend/src/api/utils/guards/mock-api-token.guard.ts @@ -5,25 +5,24 @@ */ import { ExecutionContext, Injectable } from '@nestjs/common'; -import { CompleteRequest } from '../api/utils/request.type'; -import { User } from '../database/user.entity'; -import { UsersService } from '../users/users.service'; +import { UsersService } from '../../../users/users.service'; +import { CompleteRequest } from '../request.type'; @Injectable() export class MockApiTokenGuard { - private user: User; + private userId: number; constructor(private usersService: UsersService) {} async canActivate(context: ExecutionContext): Promise { const req: CompleteRequest = context.switchToHttp().getRequest(); - if (!this.user) { + if (!this.userId) { // this assures that we can create the user 'hardcoded', if we need them before any calls are made or // create them on the fly when the first call to the api is made try { - this.user = await this.usersService.getUserByUsername('hardcoded'); + this.userId = await this.usersService.getUserIdByUsername('hardcoded'); } catch (e) { - this.user = await this.usersService.createUser( + this.userId = await this.usersService.createUser( 'hardcoded', 'Testy', null, @@ -31,7 +30,7 @@ export class MockApiTokenGuard { ); } } - req.user = this.user; + req.userId = this.userId; 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..ef5c113d6 100644 --- a/backend/src/auth/identity.service.ts +++ b/backend/src/auth/identity.service.ts @@ -4,36 +4,37 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { - FullUserInfoDto, + AuthProviderType, PendingUserConfirmationDto, - ProviderType, + PendingUserInfoDto, } from '@hedgedoc/commons'; -import { - Inject, - Injectable, - InternalServerErrorException, -} from '@nestjs/common'; -import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; -import { DataSource, Repository } from 'typeorm'; +import { Inject, Injectable } from '@nestjs/common'; +import { Knex } from 'knex'; +import { InjectConnection } from 'nest-knexjs'; import AuthConfiguration, { AuthConfig } from '../config/auth.config'; -import { User } from '../database/user.entity'; +import { + 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 +50,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: AuthProviderType, + 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: AuthProviderType, + 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, + authProviderType: AuthProviderType, + 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: PendingUserInfoDto, + pendingUserConfirmationData: PendingUserConfirmationDto, + authProviderType: AuthProviderType, authProviderIdentifier: string, - providerUserId: string, - ): Promise { + 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/ldap/ldap.service.ts b/backend/src/auth/ldap/ldap.service.ts index e05cadb95..4603868c4 100644 --- a/backend/src/auth/ldap/ldap.service.ts +++ b/backend/src/auth/ldap/ldap.service.ts @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { FullUserInfoWithIdDto } from '@hedgedoc/commons'; +import { PendingLdapUserInfoDto } from '@hedgedoc/commons'; import { Inject, Injectable, @@ -50,7 +50,7 @@ export class LdapService { * @param ldapConfig {LdapConfig} - the ldap config to use * @param username {string} - the username to log in with * @param password {string} - the password to log in with - * @returns {FullUserInfoWithIdDto} - the user info of the user that logged in + * @returns The user info of the user that logged in * @throws {UnauthorizedException} - the user has given us incorrect credentials * @throws {InternalServerErrorException} - if there are errors that we can't assign to wrong credentials * @private @@ -59,8 +59,8 @@ export class LdapService { ldapConfig: LdapConfig, username: string, // This is not of type Username, because LDAP server may use mixed case usernames password: string, - ): Promise { - return new Promise((resolve, reject) => { + ): Promise { + return new Promise((resolve, reject) => { const auth = new LdapAuth({ url: ldapConfig.url, searchBase: ldapConfig.searchBase, diff --git a/backend/src/auth/local/local.service.ts b/backend/src/auth/local/local.service.ts index c186023e9..0e69aeade 100644 --- a/backend/src/auth/local/local.service.ts +++ b/backend/src/auth/local/local.service.ts @@ -3,9 +3,8 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { ProviderType } from '@hedgedoc/commons'; +import { AuthProviderType } from '@hedgedoc/commons'; import { Inject, Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; import { OptionsGraph, OptionsType, @@ -20,18 +19,23 @@ 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, + FieldNameUser, + Identity, + TableIdentity, + User, +} from '../../database/types'; import { InvalidCredentialsError, - NoLocalIdentityError, PasswordTooWeakError, } 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,85 @@ export class LocalService { } /** - * @async * Create a new identity for internal auth - * @param {User} user - the user the identity should be added to - * @param {string} password - the password the identity should have - * @return {Identity} the new local identity + * + * @param username The username of the new identity + * @param password The password the identity should have + * @param displayName The display name of the new identity + * @returns {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( + AuthProviderType.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, + [FieldNameIdentity.updatedAt]: new Date(), + }) + .where(FieldNameIdentity.providerType, AuthProviderType.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, - ProviderType.LOCAL, + username, + AuthProviderType.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..c8fce28e6 100644 --- a/backend/src/auth/oidc/oidc.service.ts +++ b/backend/src/auth/oidc/oidc.service.ts @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { FullUserInfoDto, ProviderType } from '@hedgedoc/commons'; +import { AuthProviderType, PendingUserInfoDto } from '@hedgedoc/commons'; import { ForbiddenException, Inject, @@ -14,16 +14,16 @@ import { import { Cron } from '@nestjs/schedule'; import { Client, generators, Issuer, UserinfoResponse } from 'openid-client'; +import { RequestWithSession } from '../../api/utils/request.type'; import appConfiguration, { AppConfig } from '../../config/app.config'; 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'; interface OidcClientConfigEntry { client: Client; @@ -167,14 +167,14 @@ export class OidcService { * Extracts the user information from the callback and stores them in the session. * Afterward, the user information is returned. * - * @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 + * @param oidcIdentifier The identifier of the OIDC configuration + * @param request The request containing the session + * @returns 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( @@ -259,7 +259,7 @@ export class OidcService { try { return await this.identityService.getIdentityFromUserIdAndProviderType( oidcUserId, - ProviderType.OIDC, + AuthProviderType.OIDC, oidcIdentifier, ); } catch (e) { 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/database.config.ts b/backend/src/config/database.config.ts index d7be8933c..290f4ebf2 100644 --- a/backend/src/config/database.config.ts +++ b/backend/src/config/database.config.ts @@ -4,8 +4,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { registerAs } from '@nestjs/config'; -import z from 'zod'; import { Knex } from 'knex'; +import z from 'zod'; import { DatabaseType } from './database-type.enum'; import { parseOptionalNumber } from './utils'; @@ -47,31 +47,15 @@ const mariaDbSchema = z.object({ .describe('HD_DATABASE_PORT'), }); -const mysqlDbSchema = z.object({ - type: z.literal(DatabaseType.MYSQL).describe('HD_DATABASE_TYPE'), - name: z.string().describe('HD_DATABASE_NAME'), - username: z.string().describe('HD_DATABASE_USERNAME'), - password: z.string().describe('HD_DATABASE_PASSWORD'), - host: z.string().describe('HD_DATABASE_HOST'), - port: z - .number() - .positive() - .max(65535) - .default(3306) - .describe('HD_DATABASE_PORT'), -}); - const dbSchema = z.discriminatedUnion('type', [ sqliteDbSchema, mariaDbSchema, - mysqlDbSchema, postgresDbSchema, ]); export type SqliteDatabaseConfig = z.infer; export type PostgresDatabaseConfig = z.infer; export type MariadbDatabaseConfig = z.infer; -export type MySQLDatabaseConfig = z.infer; export type DatabaseConfig = z.infer; export default registerAs('databaseConfig', () => { @@ -98,7 +82,7 @@ export function getKnexConfig(databaseConfig: DatabaseConfig): Knex.Config { return { client: 'better-sqlite3', connection: { - filename: databaseConfig.database, + filename: databaseConfig.name, }, }; case DatabaseType.POSTGRES: @@ -108,7 +92,7 @@ export function getKnexConfig(databaseConfig: DatabaseConfig): Knex.Config { host: databaseConfig.host, port: databaseConfig.port, user: databaseConfig.username, - database: databaseConfig.database, + database: databaseConfig.name, password: databaseConfig.password, // eslint-disable-next-line @typescript-eslint/naming-convention application_name: 'HedgeDoc', @@ -116,12 +100,13 @@ export function getKnexConfig(databaseConfig: DatabaseConfig): Knex.Config { }; case DatabaseType.MARIADB: return { + // Knex recommends using the mysql driver for MariaDB database instances client: 'mysql', connection: { host: databaseConfig.host, port: databaseConfig.port, user: databaseConfig.username, - database: databaseConfig.database, + database: databaseConfig.name, password: databaseConfig.password, }, }; 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..873a01fb6 100644 --- a/backend/src/database/migrations/20250312211152_initial.ts +++ b/backend/src/database/migrations/20250312211152_initial.ts @@ -3,11 +3,9 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { NoteType } from '@hedgedoc/commons'; +import { AuthProviderType, NoteType, SpecialGroup } 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 { FieldNameAlias, @@ -41,16 +39,18 @@ import { } from '../types'; export async function up(knex: Knex): Promise { - // Create user table first as it's referenced by other tables + // Create the user table first as it's referenced by other tables 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 +79,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 +90,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 +120,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 +137,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], // AuthProviderType.GUEST is not relevant for the DB { useNative: true, enumName: FieldNameIdentity.providerType, @@ -141,8 +146,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 +184,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,40 +200,51 @@ 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) .unsigned() .notNullable(); table.integer(FieldNameAuthorshipInfo.endPosition).unsigned().notNullable(); + table + .timestamp(FieldNameAuthorshipInfo.createdAt, { useTz: true }) + .defaultTo(knex.fn.now()); }); // Create note_user_permission table @@ -234,12 +254,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 +280,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 +332,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/01_user.ts b/backend/src/database/seeds/01_user.ts index 80c23e5f2..bdbb81b8c 100644 --- a/backend/src/database/seeds/01_user.ts +++ b/backend/src/database/seeds/01_user.ts @@ -3,9 +3,9 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ +import { AuthProviderType } from '@hedgedoc/commons'; import { Knex } from 'knex'; -import { ProviderType } from '../../auth/provider-type.enum'; import { hashPassword } from '../../utils/password'; import { FieldNameIdentity, @@ -24,7 +24,7 @@ export async function seed(knex: Knex): Promise { { [FieldNameUser.username]: null, [FieldNameUser.guestUuid]: '55b4618a-d5f3-4320-93d3-f3501c73d72b', - [FieldNameUser.displayName]: null, + [FieldNameUser.displayName]: 'Gast 1', [FieldNameUser.photoUrl]: null, [FieldNameUser.email]: null, [FieldNameUser.authorStyle]: 1, @@ -40,7 +40,7 @@ export async function seed(knex: Knex): Promise { ]); await knex(TableIdentity).insert({ [FieldNameIdentity.userId]: 2, - [FieldNameIdentity.providerType]: ProviderType.LOCAL, + [FieldNameIdentity.providerType]: AuthProviderType.LOCAL, [FieldNameIdentity.providerIdentifier]: null, [FieldNameIdentity.providerUserId]: null, [FieldNameIdentity.passwordHash]: await hashPassword('test123'), diff --git a/backend/src/database/seeds/02_api_token.ts b/backend/src/database/seeds/02_api_token.ts index 435e0ecb4..535b8492b 100644 --- a/backend/src/database/seeds/02_api_token.ts +++ b/backend/src/database/seeds/02_api_token.ts @@ -26,5 +26,6 @@ export async function seed(knex: Knex): Promise { [FieldNameApiToken.validUntil]: new Date( new Date().getTime() + 2 * 365 * 24 * 60 * 60 * 1000, ), + [FieldNameApiToken.createdAt]: new Date(), }); } 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..3efc4f07b 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; @@ -21,13 +21,17 @@ export interface AuthorshipInfo { /** The end position of the change in the note as a positive index. */ [FieldNameAuthorshipInfo.endPosition]: number; + + /** The timestamp when the authorship entry was created. */ + [FieldNameAuthorshipInfo.createdAt]: Date; } export enum FieldNameAuthorshipInfo { - revisionId = 'revision_id', + revisionUuid = 'revision_id', authorId = 'author_id', startPosition = 'start_position', endPosition = 'end_position', + createdAt = 'created_at', } export const TableAuthorshipInfo = 'authorship_info'; diff --git a/backend/src/database/types/identity.ts b/backend/src/database/types/identity.ts index 88c19b3bf..926c31cbb 100644 --- a/backend/src/database/types/identity.ts +++ b/backend/src/database/types/identity.ts @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { ProviderType } from '../../auth/provider-type.enum'; +import { AuthProviderType } from '@hedgedoc/commons'; /** * An auth identity holds the information how a {@link User} can authenticate themselves using a certain auth provider @@ -13,7 +13,7 @@ export interface Identity { [FieldNameIdentity.userId]: number; /** The type of the auth provider */ - [FieldNameIdentity.providerType]: ProviderType; + [FieldNameIdentity.providerType]: AuthProviderType; /** The identifier of the auth provider, e.g. gitlab */ [FieldNameIdentity.providerIdentifier]: string | null; 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..016cd4837 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 { AuthProviderType, PermissionLevel } 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, @@ -128,26 +128,26 @@ describe('FrontendConfigService', () => { const config = await service.getFrontendConfig(); if (authConfig.local.enableLogin) { expect(config.authProviders).toContainEqual({ - type: ProviderType.LOCAL, + type: AuthProviderType.LOCAL, }); } expect( config.authProviders.filter( - (provider) => provider.type === ProviderType.LDAP, + (provider) => provider.type === AuthProviderType.LDAP, ).length, ).toEqual(authConfig.ldap.length); expect( config.authProviders.filter( - (provider) => provider.type === ProviderType.OIDC, + (provider) => provider.type === AuthProviderType.OIDC, ).length, ).toEqual(authConfig.oidc.length); if (authConfig.ldap.length > 0) { expect( config.authProviders.find( - (provider) => provider.type === ProviderType.LDAP, + (provider) => provider.type === AuthProviderType.LDAP, ), ).toEqual({ - type: ProviderType.LDAP, + type: AuthProviderType.LDAP, providerName: authConfig.ldap[0].providerName, identifier: authConfig.ldap[0].identifier, }); @@ -155,10 +155,10 @@ describe('FrontendConfigService', () => { if (authConfig.oidc.length > 0) { expect( config.authProviders.find( - (provider) => provider.type === ProviderType.OIDC, + (provider) => provider.type === AuthProviderType.OIDC, ), ).toEqual({ - type: ProviderType.OIDC, + type: AuthProviderType.OIDC, providerName: authConfig.oidc[0].providerName, identifier: authConfig.oidc[0].identifier, }); @@ -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..8517fc403 100644 --- a/backend/src/frontend-config/frontend-config.service.ts +++ b/backend/src/frontend-config/frontend-config.service.ts @@ -4,16 +4,15 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { + AuthProviderType, BrandingDto, FrontendConfigDto, - ProviderType, SpecialUrlDto, } from '@hedgedoc/commons'; import { AuthProviderDto } from '@hedgedoc/commons'; import { Inject, Injectable } from '@nestjs/common'; import { URL } from 'url'; -import appConfiguration, { AppConfig } from '../config/app.config'; import authConfiguration, { AuthConfig } from '../config/auth.config'; import customizationConfiguration, { CustomizationConfig, @@ -23,14 +22,12 @@ 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 { constructor( private readonly logger: ConsoleLoggerService, - @Inject(appConfiguration.KEY) - private appConfig: AppConfig, @Inject(noteConfiguration.KEY) private noteConfig: NoteConfig, @Inject(authConfiguration.KEY) @@ -65,12 +62,12 @@ export class FrontendConfigService { const providers: AuthProviderDto[] = []; if (this.authConfig.local.enableLogin) { providers.push({ - type: ProviderType.LOCAL, + type: AuthProviderType.LOCAL, }); } this.authConfig.ldap.forEach((ldapEntry) => { providers.push({ - type: ProviderType.LDAP, + type: AuthProviderType.LDAP, providerName: ldapEntry.providerName, identifier: ldapEntry.identifier, theme: null, @@ -78,7 +75,7 @@ export class FrontendConfigService { }); this.authConfig.oidc.forEach((openidConnectEntry) => { providers.push({ - type: ProviderType.OIDC, + type: AuthProviderType.OIDC, providerName: openidConnectEntry.providerName, identifier: openidConnectEntry.identifier, theme: openidConnectEntry.theme ?? null, 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..96bb27286 100644 --- a/backend/src/groups/groups.service.ts +++ b/backend/src/groups/groups.service.ts @@ -5,94 +5,84 @@ */ 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, 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) { + async getGroupInfoDtoByName(name: string): Promise { + 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 - */ - getEveryoneGroup(): Promise { - return this.getGroupByName(SpecialGroup.EVERYONE); - } - - /** - * 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 - */ - toGroupDto(group: Group): GroupInfoDto { return { - name: group.name, - displayName: group.displayName, - special: group.special, + name: group[FieldNameGroup.name], + displayName: group[FieldNameGroup.displayName], + special: group[FieldNameGroup.isSpecial], }; } + + /** + * 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 + */ + 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]; + } } diff --git a/backend/src/groups/groups.special.ts b/backend/src/groups/groups.special.ts deleted file mode 100644 index f0ca6c79c..000000000 --- a/backend/src/groups/groups.special.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 SpecialGroup { - LOGGED_IN = '_LOGGED_IN', - EVERYONE = '_EVERYONE', -} 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-redirect/media-redirect.controller.ts b/backend/src/media-redirect/media-redirect.controller.ts index d74de6225..9353767dd 100644 --- a/backend/src/media-redirect/media-redirect.controller.ts +++ b/backend/src/media-redirect/media-redirect.controller.ts @@ -7,7 +7,7 @@ import { Controller, Get, Param, Res } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Response } from 'express'; -import { OpenApi } from '../api/utils/openapi.decorator'; +import { OpenApi } from '../api/utils/decorators/openapi.decorator'; import { ConsoleLoggerService } from '../logger/console-logger.service'; import { MediaService } from '../media/media.service'; @@ -28,8 +28,7 @@ export class MediaRedirectController { @Param('uuid') uuid: string, @Res() response: Response, ): Promise { - const mediaUpload = await this.mediaService.findUploadByUuid(uuid); - const url = await this.mediaService.getFileUrl(mediaUpload); + const url = await this.mediaService.getFileUrl(uuid); response.redirect(url); } } diff --git a/backend/src/media/media.module.ts b/backend/src/media/media.module.ts index 1e870a94b..92686664d 100644 --- a/backend/src/media/media.module.ts +++ b/backend/src/media/media.module.ts @@ -1,31 +1,22 @@ /* - * 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 { 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: [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..696758a67 100644 --- a/backend/src/media/media.service.spec.ts +++ b/backend/src/media/media.service.spec.ts @@ -1,434 +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 */ -import { ConfigModule } from '@nestjs/config'; -import { EventEmitterModule } from '@nestjs/event-emitter'; -import { Test, TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { promises as fs } from 'fs'; -import { Repository } from 'typeorm'; - -import appConfigMock from '../../src/config/mock/app.config.mock'; -import { ApiToken } from '../api-token/api-token.entity'; -import { Identity } from '../auth/identity.entity'; -import { Author } from '../authors/author.entity'; -import authConfigMock from '../config/mock/auth.config.mock'; -import databaseConfigMock from '../config/mock/database.config.mock'; -import mediaConfigMock from '../config/mock/media.config.mock'; -import noteConfigMock from '../config/mock/note.config.mock'; -import { User } from '../database/user.entity'; -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 { 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 { Session } from '../sessions/session.entity'; -import { UsersModule } from '../users/users.module'; -import { BackendType } from './backends/backend-type.enum'; -import { FilesystemBackend } from './backends/filesystem-backend'; -import { MediaUpload } from './media-upload.entity'; -import { MediaService } from './media.service'; - -describe('MediaService', () => { - let service: MediaService; - let noteRepo: Repository; - let userRepo: Repository; - let mediaRepo: Repository; - - class CreateQueryBuilderClass { - leftJoinAndSelect: () => CreateQueryBuilderClass; - where: () => CreateQueryBuilderClass; - orWhere: () => CreateQueryBuilderClass; - setParameter: () => CreateQueryBuilderClass; - getOne: () => MediaUpload; - getMany: () => MediaUpload[]; - } - - let createQueryBuilderFunc: CreateQueryBuilderClass; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - MediaService, - { - provide: getRepositoryToken(MediaUpload), - useClass: Repository, - }, - FilesystemBackend, - ], - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - load: [ - mediaConfigMock, - appConfigMock, - databaseConfigMock, - authConfigMock, - noteConfigMock, - ], - }), - LoggerModule, - NotesModule, - UsersModule, - EventEmitterModule.forRoot(eventModuleConfig), - ], - }) - .overrideProvider(getRepositoryToken(Edit)) - .useValue({}) - .overrideProvider(getRepositoryToken(ApiToken)) - .useValue({}) - .overrideProvider(getRepositoryToken(Identity)) - .useValue({}) - .overrideProvider(getRepositoryToken(Note)) - .useClass(Repository) - .overrideProvider(getRepositoryToken(Revision)) - .useValue({}) - .overrideProvider(getRepositoryToken(User)) - .useClass(Repository) - .overrideProvider(getRepositoryToken(Tag)) - .useValue({}) - .overrideProvider(getRepositoryToken(NoteGroupPermission)) - .useValue({}) - .overrideProvider(getRepositoryToken(NoteUserPermission)) - .useValue({}) - .overrideProvider(getRepositoryToken(MediaUpload)) - .useClass(Repository) - .overrideProvider(getRepositoryToken(Group)) - .useValue({}) - .overrideProvider(getRepositoryToken(Session)) - .useValue({}) - .overrideProvider(getRepositoryToken(Author)) - .useValue({}) - .overrideProvider(getRepositoryToken(Alias)) - .useValue({}) - .compile(); - - service = module.get(MediaService); - noteRepo = module.get>(getRepositoryToken(Note)); - userRepo = module.get>(getRepositoryToken(User)); - mediaRepo = module.get>( - getRepositoryToken(MediaUpload), - ); - - const user = User.create('test123', 'Test 123') as User; - const uuid = 'f7d334bb-6bb6-451b-9334-bb6bb6d51b5a'; - const filename = 'test.jpg'; - const note = Note.create(user) as Note; - const mediaUpload = MediaUpload.create( - uuid, - filename, - note, - user, - BackendType.FILESYSTEM, - null, - ) as MediaUpload; - - const createQueryBuilder = { - leftJoinAndSelect: () => createQueryBuilder, - where: () => createQueryBuilder, - orWhere: () => createQueryBuilder, - setParameter: () => createQueryBuilder, - getOne: () => mediaUpload, - getMany: () => [mediaUpload], - }; - createQueryBuilderFunc = createQueryBuilder; - jest - .spyOn(mediaRepo, 'createQueryBuilder') - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - .mockImplementation(() => createQueryBuilder); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - describe('saveFile', () => { - let user: User; - let note: Note; - beforeEach(() => { - user = User.create('hardcoded', 'Testy') as User; - const alias = 'alias'; - note = Note.create(user, alias) as Note; - jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce(user); - const createQueryBuilder = { - leftJoinAndSelect: () => createQueryBuilder, - where: () => createQueryBuilder, - orWhere: () => createQueryBuilder, - setParameter: () => createQueryBuilder, - getOne: () => note, - }; - jest - .spyOn(noteRepo, 'createQueryBuilder') - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - .mockImplementation(() => createQueryBuilder); - }); - - it('works', async () => { - const testImage = await fs.readFile('test/public-api/fixtures/test.png'); - let givenUuid = ''; - jest.spyOn(mediaRepo, 'save').mockImplementation(); - jest - .spyOn(service.mediaBackend, 'saveFile') - .mockImplementationOnce( - async (uuid: string, buffer: Buffer): Promise => { - expect(buffer).toEqual(testImage); - givenUuid = uuid; - return null; - }, - ); - jest.spyOn(mediaRepo, 'save').mockImplementationOnce(async (entry) => { - expect(entry.uuid).toEqual(givenUuid); - return entry as MediaUpload; - }); - const upload = await service.saveFile('test.jpg', testImage, user, note); - expect(upload.fileName).toEqual('test.jpg'); - expect(upload.uuid).toEqual(givenUuid); - await expect(upload.note).resolves.toEqual(note); - await expect(upload.user).resolves.toEqual(user); - }); - - describe('fails:', () => { - it('MIME type not identifiable', async () => { - await expect( - service.saveFile('fail.png', Buffer.alloc(1), user, note), - ).rejects.toThrow(ClientError); - }); - - it('MIME type not supported', async () => { - const testText = await fs.readFile('test/public-api/fixtures/test.zip'); - await expect( - service.saveFile('fail.zip', testText, user, note), - ).rejects.toThrow(ClientError); - }); - }); - }); - - describe('deleteFile', () => { - it('works', async () => { - const mockMediaUploadEntry = { - uuid: '64f260cc-e0d0-47e7-b260-cce0d097e767', - fileName: 'testFileName', - note: Promise.resolve({ - id: 123, - } as Note), - backendType: BackendType.FILESYSTEM, - backendData: 'testBackendData', - user: Promise.resolve({ - username: 'hardcoded', - } as User), - } as MediaUpload; - jest - .spyOn(service.mediaBackend, 'deleteFile') - .mockImplementationOnce( - async (uuid: string, backendData: string | null): Promise => { - expect(uuid).toEqual(mockMediaUploadEntry.uuid); - expect(backendData).toEqual(mockMediaUploadEntry.backendData); - }, - ); - jest - .spyOn(mediaRepo, 'remove') - .mockImplementationOnce(async (entry, _) => { - expect(entry).toEqual(mockMediaUploadEntry); - return entry; - }); - await service.deleteFile(mockMediaUploadEntry); - }); - }); - - describe('getFileUrl', () => { - it('works', async () => { - const mockMediaUploadEntry = { - uuid: '64f260cc-e0d0-47e7-b260-cce0d097e767', - fileName: 'testFileName', - note: Promise.resolve({ - id: 123, - } as Note), - backendType: BackendType.FILESYSTEM, - backendData: '{"ext": "png"}', - user: Promise.resolve({ - username: 'hardcoded', - } as User), - } as MediaUpload; - await expect(service.getFileUrl(mockMediaUploadEntry)).resolves.toEqual( - '/uploads/64f260cc-e0d0-47e7-b260-cce0d097e767.png', - ); - }); - }); - - describe('findUploadByFilename', () => { - it('works', async () => { - const testFileName = 'testFilename'; - const username = 'hardcoded'; - const backendData = 'testBackendData'; - const mockMediaUploadEntry = { - uuid: '64f260cc-e0d0-47e7-b260-cce0d097e767', - fileName: testFileName, - note: Promise.resolve({ - id: 123, - } as Note), - backendType: BackendType.FILESYSTEM, - backendData, - user: Promise.resolve({ - username, - } as User), - } as MediaUpload; - jest - .spyOn(mediaRepo, 'findOne') - .mockResolvedValueOnce(mockMediaUploadEntry); - const mediaUpload = await service.findUploadByFilename(testFileName); - expect((await mediaUpload.user)?.username).toEqual(username); - expect(mediaUpload.backendData).toEqual(backendData); - }); - it("fails: can't find mediaUpload", async () => { - const testFileName = 'testFilename'; - jest.spyOn(mediaRepo, 'findOne').mockResolvedValueOnce(null); - await expect(service.findUploadByFilename(testFileName)).rejects.toThrow( - NotInDBError, - ); - }); - }); - - describe('listUploadsByUser', () => { - describe('works', () => { - const username = 'hardcoded'; - it('with one upload from user', async () => { - const mockMediaUploadEntry = { - uuid: '64f260cc-e0d0-47e7-b260-cce0d097e767', - fileName: 'testFileName', - note: Promise.resolve({ - id: 123, - } as Note), - backendType: BackendType.FILESYSTEM, - backendData: null, - user: Promise.resolve({ - username, - } as User), - } as MediaUpload; - createQueryBuilderFunc.getMany = () => [mockMediaUploadEntry]; - expect( - await service.listUploadsByUser({ username: 'hardcoded' } as User), - ).toEqual([mockMediaUploadEntry]); - }); - - it('without uploads from user', async () => { - createQueryBuilderFunc.getMany = () => []; - const mediaList = await service.listUploadsByUser({ - username: username, - } as User); - expect(mediaList).toEqual([]); - }); - it('with error (null as return value of find)', async () => { - createQueryBuilderFunc.getMany = () => []; - const mediaList = await service.listUploadsByUser({ - username: username, - } as User); - expect(mediaList).toEqual([]); - }); - }); - }); - - describe('listUploadsByNote', () => { - describe('works', () => { - it('with one upload to note', async () => { - const mockMediaUploadEntry = { - uuid: '64f260cc-e0d0-47e7-b260-cce0d097e767', - fileName: 'testFileName', - note: Promise.resolve({ - id: 123, - } as Note), - backendType: BackendType.FILESYSTEM, - backendData: null, - user: Promise.resolve({ - username: 'mockUser', - } as User), - } as MediaUpload; - const createQueryBuilder = { - where: () => createQueryBuilder, - getMany: async () => { - return [mockMediaUploadEntry]; - }, - }; - jest - .spyOn(mediaRepo, 'createQueryBuilder') - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - .mockImplementation(() => createQueryBuilder); - const mediaList = await service.listUploadsByNote({ - id: 123, - } as Note); - expect(mediaList).toEqual([mockMediaUploadEntry]); - }); - - it('without uploads to note', async () => { - const createQueryBuilder = { - where: () => createQueryBuilder, - getMany: async () => { - return []; - }, - }; - jest - .spyOn(mediaRepo, 'createQueryBuilder') - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - .mockImplementation(() => createQueryBuilder); - const mediaList = await service.listUploadsByNote({ - id: 123, - } as Note); - expect(mediaList).toEqual([]); - }); - it('with error (null as return value of find)', async () => { - const createQueryBuilder = { - where: () => createQueryBuilder, - getMany: async () => { - return null; - }, - }; - jest - .spyOn(mediaRepo, 'createQueryBuilder') - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - .mockImplementation(() => createQueryBuilder); - const mediaList = await service.listUploadsByNote({ - id: 123, - } as Note); - expect(mediaList).toEqual([]); - }); - }); - }); - - describe('removeNoteFromMediaUpload', () => { - it('works', async () => { - const mockNote = {} as Note; - mockNote.aliases = Promise.resolve([ - Alias.create('test', mockNote, true) as Alias, - ]); - const mockMediaUploadEntry = { - uuid: '64f260cc-e0d0-47e7-b260-cce0d097e767', - fileName: 'testFileName', - note: mockNote, - backendType: BackendType.FILESYSTEM, - backendData: null, - user: Promise.resolve({ - username: 'mockUser', - } as User), - } as unknown as MediaUpload; - jest.spyOn(mediaRepo, 'save').mockImplementationOnce(async (entry) => { - expect(await entry.note).toBeNull(); - return entry as MediaUpload; - }); - await service.removeNoteFromMediaUpload(mockMediaUploadEntry); - expect(mediaRepo.save).toHaveBeenCalled(); - }); - }); -}); diff --git a/backend/src/media/media.service.ts b/backend/src/media/media.service.ts index 0485f3913..a96457ea3 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 { + Alias, + FieldNameAlias, + FieldNameMediaUpload, + FieldNameNote, + FieldNameUser, + MediaUpload, + Note, + TableAlias, + TableMediaUpload, + 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,12 @@ export class MediaService { constructor( private readonly logger: ConsoleLoggerService, - @InjectRepository(MediaUpload) - private mediaUploadRepository: Repository, + + @InjectConnection() + private readonly knex: Knex, + private moduleRef: ModuleRef, + @Inject(mediaConfiguration.KEY) private mediaConfig: MediaConfig, ) { @@ -62,34 +76,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,63 +111,72 @@ 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(); } /** * @async * Get the URL of the file. - * @param {MediaUpload} mediaUpload - the file to get the URL for. + * @param {string} uuid - the uuid of the file to get the URL for. * @return {string} the URL of the file. * @throws {MediaBackendError} - there was an error retrieving the url */ - async getFileUrl(mediaUpload: MediaUpload): Promise { - const backendName = mediaUpload.backendType as BackendType; - const backend = this.getBackendFromType(backendName); - return await backend.getFileUrl(mediaUpload.uuid, mediaUpload.backendData); - } - - /** - * @async - * Find a file entry by its filename. - * @param {string} filename - the name of the file entry to find - * @return {MediaUpload} the file entry, that was searched for - * @throws {NotInDBError} - the file entry specified is not in the database - * @throws {MediaBackendError} - there was an error retrieving the url - */ - async findUploadByFilename(filename: string): Promise { - const mediaUpload = await this.mediaUploadRepository.findOne({ - where: { fileName: filename }, - relations: ['user'], - }); - if (mediaUpload === null) { + async getFileUrl(uuid: string): Promise { + const mediaUpload = await this.knex(TableMediaUpload) + .select( + FieldNameMediaUpload.backendType, + FieldNameMediaUpload.backendData, + ) + .where(FieldNameMediaUpload.uuid, uuid) + .first(); + if (mediaUpload === undefined) { throw new NotInDBError( - `MediaUpload with filename '${filename}' not found`, + `Can't find backend data for '${uuid}'`, + this.logger.getContext(), + 'getFileUrl', ); } - return mediaUpload; + const backendName = mediaUpload[FieldNameMediaUpload.backendType]; + const backend = this.getBackendFromType(backendName); + const backendData = mediaUpload[FieldNameMediaUpload.backendData]; + return await backend.getFileUrl(uuid, backendData); } /** @@ -170,11 +187,11 @@ 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'], - }); - if (mediaUpload === null) { + const mediaUpload = await this.knex(TableMediaUpload) + .select() + .where(FieldNameMediaUpload.uuid, uuid) + .first(); + if (mediaUpload === undefined) { throw new NotInDBError(`MediaUpload with uuid '${uuid}' not found`); } return mediaUpload; @@ -183,49 +200,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 +280,37 @@ 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 mediaUploads = await this.knex(TableMediaUpload) + .select< + (Pick< + MediaUpload, + | FieldNameMediaUpload.uuid + | FieldNameMediaUpload.fileName + | FieldNameMediaUpload.createdAt + > & + Pick & + Pick)[] + >(`${TableMediaUpload}.${FieldNameMediaUpload.uuid}`, `${TableMediaUpload}.${FieldNameMediaUpload.fileName}`, `${TableMediaUpload}.${FieldNameMediaUpload.createdAt}`, `${TableUser}.${FieldNameUser.username}`, `${TableAlias}.${FieldNameAlias.alias}`) + .join( + TableAlias, + `${TableAlias}.${FieldNameAlias.noteId}`, + `${TableMediaUpload}.${FieldNameMediaUpload.noteId}`, + ) + .join( + TableUser, + `${TableUser}.${FieldNameUser.id}`, + `${TableMediaUpload}.${FieldNameMediaUpload.userId}`, + ) + .whereIn(FieldNameMediaUpload.uuid, uuids) + .andWhere(FieldNameAlias.isPrimary, true); + + 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/__snapshots__/notes.service.spec.ts.snap b/backend/src/notes/__snapshots__/notes.service.spec.ts.snap index f8c4c6036..a5139d1b5 100644 --- a/backend/src/notes/__snapshots__/notes.service.spec.ts.snap +++ b/backend/src/notes/__snapshots__/notes.service.spec.ts.snap @@ -33,12 +33,12 @@ exports[`NotesService toNoteDto works 1`] = ` }, ], }, - "primaryAddress": "testAlias", + "primaryAlias": "testAlias", "tags": [ "tag1", ], "title": "mockTitle", - "updateUsername": "hardcoded", + "lastUpdatedBy": "hardcoded", "updatedAt": "2019-02-04T20:34:12.000Z", "version": undefined, "viewCount": 1337, @@ -76,12 +76,12 @@ exports[`NotesService toNoteMetadataDto works 1`] = ` }, ], }, - "primaryAddress": "testAlias", + "primaryAlias": "testAlias", "tags": [ "tag1", ], "title": "mockTitle", - "updateUsername": "hardcoded", + "lastUpdatedBy": "hardcoded", "updatedAt": "2019-02-04T20:34:12.000Z", "version": undefined, "viewCount": 1337, 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..c1301148d --- /dev/null +++ b/backend/src/notes/note.module.ts @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { forwardRef, Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { KnexModule } from 'nest-knexjs'; + +import { AliasModule } from '../alias/alias.module'; +import { GroupsModule } from '../groups/groups.module'; +import { LoggerModule } from '../logger/logger.module'; +import { PermissionsModule } from '../permissions/permissions.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: [ + AliasModule, + RevisionsModule, + UsersModule, + GroupsModule, + LoggerModule, + forwardRef(() => PermissionsModule), + 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 95% rename from backend/src/notes/notes.service.spec.ts rename to backend/src/notes/note.service.spec.ts index 6b715f16e..e2d02dabf 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); @@ -335,19 +335,19 @@ describe('NotesService', () => { it('with no note', async () => { mockSelectQueryBuilderInRepo(noteRepo, null); - const notes = await service.getUserNotes(user); + const notes = await service.getUserNoteIds(user); expect(notes).toEqual([]); }); it('with one note', async () => { mockSelectQueryBuilderInRepo(noteRepo, note); - const notes = await service.getUserNotes(user); + const notes = await service.getUserNoteIds(user); expect(notes).toEqual([note]); }); it('with multiple note', async () => { mockSelectQueryBuilderInRepo(noteRepo, [note, note]); - const notes = await service.getUserNotes(user); + const notes = await service.getUserNoteIds(user); expect(notes).toEqual([note, note]); }); }); @@ -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,10 +704,10 @@ 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); + expect(metadataDto.primaryAlias).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..77e3af1e6 --- /dev/null +++ b/backend/src/notes/note.service.ts @@ -0,0 +1,466 @@ +/* + * 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 { forwardRef, 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, + FieldNameGroup, + FieldNameNote, + FieldNameNoteGroupPermission, + FieldNameNoteUserPermission, + FieldNameRevision, + FieldNameUser, + Group, + Note, + NoteGroupPermission, + NoteUserPermission, + TableAlias, + TableGroup, + TableNote, + TableNoteGroupPermission, + TableNoteUserPermission, + TableUser, + 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'; + +@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(forwardRef(() => 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 getUserNoteIds(userId: number): Promise { + const result = await this.knex(TableNote) + .select(FieldNameNote.id) + .where(FieldNameNote.ownerId, userId); + return result.map((row) => row[FieldNameNote.id]); + } + + /** + * 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; + + if (everyoneAccessLevel !== DefaultAccessLevel.NONE) { + const everyoneAccessGroupId = await this.groupsService.getGroupIdByName( + SpecialGroup.EVERYONE, + ); + await this.permissionService.setGroupPermission( + noteId, + everyoneAccessGroupId, + everyoneAccessLevel === DefaultAccessLevel.WRITE, + transaction, + ); + } + + if (loggedInUsersAccessLevel !== DefaultAccessLevel.NONE) { + const loggedInUsersAccessGroupId = + await this.groupsService.getGroupIdByName(SpecialGroup.LOGGED_IN); + await this.permissionService.setGroupPermission( + noteId, + loggedInUsersAccessGroupId, + loggedInUsersAccessLevel === DefaultAccessLevel.WRITE, + transaction, + ); + } + + return noteId; + }); + } + + /** + * Get the current content of the note + * + * @param noteId the note to use + * @param transaction The optional database transaction to use + * @throws {NotInDBError} the note is not in the DB + * @return {string} the content of the note + */ + async getNoteContent(noteId: number, transaction?: Knex): Promise { + const realtimeContent = this.realtimeNoteStore + .find(noteId) + ?.getRealtimeDoc() + .getCurrentContent(); + if (realtimeContent) { + return realtimeContent; + } + + const latestRevision = await this.revisionsService.getLatestRevision( + noteId, + transaction, + ); + return latestRevision.content; + } + + /** + * Get a note by either their id or aliases + * + * @param alias the notes id or aliases + * @param transaction The optional database transaction to use + * @return the note id + * @throws {NotInDBError} there is no note with this id or aliases + * @throws {ForbiddenIdError} the requested id or aliases is forbidden + */ + 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 selects the note, that has a alias 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]; + } + + /** + * 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 { + // TODO Disconnect realtime clients first + const numberOfDeletedNotes = await this.knex(TableNote) + .where(FieldNameNote.id, noteId) + .delete(); + if (numberOfDeletedNotes === 0) { + throw new NotInDBError(`There is no note with the to delete.`); + } + // TODO Message realtime clients + } + + /** + * + * 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 + } + + /** + * Build NotePermissionsDto from a note. + * @param noteId The id of the ntoe to get the permissions for + * @param transaction The optional database transaction to use + * @return The built NotePermissionDto + */ + async toNotePermissionsDto( + noteId: number, + transaction?: Knex, + ): Promise { + if (transaction === undefined) { + return await this.knex.transaction(async (newTransaction) => { + return await this.innerToNotePermissionsDto(noteId, newTransaction); + }); + } + return await this.innerToNotePermissionsDto(noteId, transaction); + } + + async innerToNotePermissionsDto( + noteId: number, + transaction: Knex, + ): Promise { + const ownerUsername = await transaction(TableNote) + .join( + TableUser, + `${TableNote}.${FieldNameNote.ownerId}`, + `${TableUser}.${FieldNameUser.id}`, + ) + .select< + Pick + >(`${TableUser}.${FieldNameUser.username}`) + .where(`${TableNote}.${FieldNameNote.id}`, noteId) + .first(); + if (ownerUsername === undefined) { + throw new NotInDBError( + `The note does not exist.`, + this.logger.getContext(), + 'toNotePermissionsDto', + ); + } + const userPermissions = await transaction(TableNoteUserPermission) + .join( + TableUser, + `${TableNoteUserPermission}.${FieldNameNoteUserPermission.userId}`, + `${TableUser}.${FieldNameUser.id}`, + ) + .select< + ({ [FieldNameUser.username]: string } & Pick< + NoteUserPermission, + FieldNameNoteUserPermission.canEdit + >)[] + >(`${TableUser}.${FieldNameUser.username}`, `${TableNoteUserPermission}.${FieldNameNoteUserPermission.canEdit}`) + .whereNotNull(`${TableUser}.${FieldNameUser.username}`) + .andWhere( + `${TableNoteUserPermission}.${FieldNameNoteUserPermission.noteId}`, + noteId, + ); + const groupPermissions = await transaction(TableNoteGroupPermission) + .join( + TableGroup, + `${TableNoteGroupPermission}.${FieldNameNoteGroupPermission.groupId}`, + `${TableGroup}.${FieldNameGroup.id}`, + ) + .select< + (Pick & + Pick)[] + >(`${TableGroup}.${FieldNameGroup.name}`, `${TableNoteGroupPermission}.${FieldNameNoteGroupPermission.canEdit}`) + .where( + `${TableNoteGroupPermission}.${FieldNameNoteGroupPermission.noteId}`, + noteId, + ); + + return { + owner: ownerUsername[FieldNameUser.username], + sharedToUsers: userPermissions.map((noteUserPermission) => ({ + username: noteUserPermission[FieldNameUser.username], + canEdit: noteUserPermission[FieldNameNoteUserPermission.canEdit], + })), + sharedToGroups: groupPermissions.map((noteGroupPermission) => ({ + groupName: noteGroupPermission[FieldNameGroup.name], + canEdit: noteGroupPermission[FieldNameNoteGroupPermission.canEdit], + })), + }; + } + + /** + * @async + * Build NoteMetadataDto from a note. + * @param noteId The if of the note to get the metadata for + * @param transaction The optional database transaction to use + * @return The built NoteMetadataDto + */ + async toNoteMetadataDto( + noteId: number, + transaction?: Knex, + ): Promise { + if (transaction === undefined) { + return await this.knex.transaction(async (newTransaction) => { + return await this.innerToNoteMetadataDto(noteId, newTransaction); + }); + } + return await this.innerToNoteMetadataDto(noteId, transaction); + } + + private async innerToNoteMetadataDto( + noteId: number, + transaction: Knex, + ): Promise { + const aliases = await this.aliasService.getAllAliases(noteId, transaction); + const primaryAlias = aliases.find( + (alias) => alias[FieldNameAlias.isPrimary], + ); + if (primaryAlias === undefined) { + throw new NotInDBError( + 'The note has no primary alias.', + this.logger.getContext(), + 'toNoteMetadataDto', + ); + } + const note = await transaction(TableNote) + .select(FieldNameNote.createdAt, FieldNameNote.version) + .where(FieldNameNote.id, noteId) + .first(); + if (note === undefined) { + throw new NotInDBError( + `The note '${primaryAlias[FieldNameAlias.alias]}' does not exist.`, + this.logger.getContext(), + 'toNoteMetadataDto', + ); + } + const latestRevision = await this.revisionsService.getLatestRevision( + noteId, + transaction, + ); + const tags = await this.revisionsService.getTagsByRevisionUuid( + latestRevision[FieldNameRevision.uuid], + transaction, + ); + const updateUsers = await this.revisionsService.getRevisionUserInfo( + latestRevision[FieldNameRevision.uuid], + ); + updateUsers.users.sort( + (userA, userB) => userB.createdAt.getTime() - userA.createdAt.getTime(), + ); + const lastEdit = updateUsers.users[0]; + const editedBy = updateUsers.users.map((user) => user.username); + const permissions = await this.toNotePermissionsDto(noteId, transaction); + + return { + aliases: aliases.map((alias) => alias[FieldNameAlias.alias]), + primaryAlias: primaryAlias[FieldNameAlias.alias], + title: latestRevision.title, + description: latestRevision.description, + tags: tags, + createdAt: note[FieldNameNote.createdAt].toISOString(), + editedBy: editedBy, + permissions: permissions, + version: note[FieldNameNote.version], + updatedAt: lastEdit.createdAt.toISOString(), + lastUpdatedBy: lastEdit.username, + }; + } + + /** + * Gets the note data for the note DTO + * + * @param noteId The id of the note to transform + * @return {NoteDto} the built NoteDto + */ + async toNoteDto(noteId: number): Promise { + return await this.knex.transaction(async (transaction) => { + return { + content: await this.getNoteContent(noteId, transaction), + metadata: await this.toNoteMetadataDto(noteId, transaction), + 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/primary.value-transformer.ts b/backend/src/notes/primary.value-transformer.ts deleted file mode 100644 index 21ff8f505..000000000 --- a/backend/src/notes/primary.value-transformer.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { ValueTransformer } from 'typeorm'; - -export class PrimaryValueTransformer implements ValueTransformer { - from(value: boolean | null): boolean { - if (value === null) { - return false; - } - return value; - } - - to(value: boolean): boolean | null { - if (!value) { - return null; - } - return value; - } -} diff --git a/backend/src/notes/utils.spec.ts b/backend/src/notes/utils.spec.ts deleted file mode 100644 index 4ee601477..000000000 --- a/backend/src/notes/utils.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 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 { Note } from './note.entity'; -import { generatePublicId, getPrimaryAlias } from './utils'; - -jest.mock('crypto'); -const random128bitBuffer = Buffer.from([ - 0xe1, 0x75, 0x86, 0xb7, 0xc3, 0xfb, 0x03, 0xa9, 0x26, 0x9f, 0xc9, 0xd6, 0x8c, - 0x2d, 0x7b, 0x7b, -]); -const mockRandomBytes = randomBytes as jest.MockedFunction; -mockRandomBytes.mockImplementation((_) => random128bitBuffer); - -it('generatePublicId', () => { - expect(generatePublicId()).toEqual('w5trddy3zc1tj9mzs7b8rbbvfc'); -}); - -describe('getPrimaryAlias', () => { - const alias = 'alias'; - let note: Note; - beforeEach(() => { - const user = User.create('hardcoded', 'Testy') as User; - note = Note.create(user, alias) as Note; - }); - it('finds correct primary alias', 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 () => { - (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 deleted file mode 100644 index ed87d1cfc..000000000 --- a/backend/src/notes/utils.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 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 { 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 - */ -export async function getPrimaryAlias(note: Note): Promise { - const listWithPrimaryAlias = (await note.aliases).filter( - (alias: Alias) => alias.primary, - ); - if (listWithPrimaryAlias.length !== 1) { - return undefined; - } - return listWithPrimaryAlias[0].name; -} 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..6e90615c0 --- /dev/null +++ b/backend/src/permissions/permission.service.ts @@ -0,0 +1,471 @@ +/* + * 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, + TableGroup, + TableGroupUser, + TableMediaUpload, + TableNote, + TableNoteGroupPermission, + TableNoteUserPermission, + TableUser, +} 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 username - 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(username: string | null): boolean { + return ( + username !== 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, + noteId: number, + ): Promise { + 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, + transaction?: Knex, + ): Promise { + const dbActor = transaction ?? this.knex; + await dbActor(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..021e9fc33 100644 --- a/backend/src/permissions/permissions.guard.ts +++ b/backend/src/permissions/permissions.guard.ts @@ -1,17 +1,25 @@ /* - * 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 { + CanActivate, + ExecutionContext, + forwardRef, + Inject, + 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 { FieldNameUser } from '../database/types'; 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 { UsersService } from '../users/users.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 +34,10 @@ 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 userService: UsersService, + @Inject(forwardRef(() => NoteService)) + private readonly noteService: NoteService, ) { this.logger.setContext(PermissionsGuard.name); } @@ -38,15 +48,21 @@ export class PermissionsGuard implements CanActivate { return false; } const request: CompleteRequest = context.switchToHttp().getRequest(); - const user = request.user ?? null; + const userId = request.userId; + if (userId === undefined) { + return false; + } // handle CREATE requiredAccessLevel, as this does not need any note if (requiredAccessLevel === RequiredPermission.CREATE) { - return this.permissionsService.mayCreate(user); + const username = (await this.userService.getUserById(userId))[ + FieldNameUser.username + ]; + return this.permissionsService.mayCreate(username); } - 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 +71,7 @@ export class PermissionsGuard implements CanActivate { return this.isNotePermissionFulfillingRequiredAccessLevel( requiredAccessLevel, - await this.permissionsService.determinePermission(user, note), + await this.permissionsService.determinePermission(userId, noteId), ); } @@ -78,15 +94,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..dce2e0058 100644 --- a/backend/src/permissions/permissions.module.ts +++ b/backend/src/permissions/permissions.module.ts @@ -1,25 +1,18 @@ /* - * 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 { forwardRef, Module } from '@nestjs/common'; +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 { NoteModule } from '../notes/note.module'; +import { PermissionService } from './permission.service'; @Module({ - imports: [ - TypeOrmModule.forFeature([Note]), - UsersModule, - GroupsModule, - LoggerModule, - ], - exports: [PermissionsService], - providers: [PermissionsService], + imports: [KnexModule, LoggerModule, forwardRef(() => NoteModule)], + 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-connection.spec.ts b/backend/src/realtime/realtime-note/realtime-connection.spec.ts index 348b59a16..b666707fa 100644 --- a/backend/src/realtime/realtime-note/realtime-connection.spec.ts +++ b/backend/src/realtime/realtime-note/realtime-connection.spec.ts @@ -11,8 +11,7 @@ import { import * as HedgeDocCommonsModule from '@hedgedoc/commons'; import { Mock } from 'ts-mockery'; -import { User } from '../../database/user.entity'; -import { Note } from '../../notes/note.entity'; +import { FieldNameUser, User } from '../../database/types'; import * as NameRandomizerModule from './random-word-lists/name-randomizer'; import { RealtimeConnection } from './realtime-connection'; import { RealtimeNote } from './realtime-note'; @@ -41,12 +40,15 @@ describe('websocket connection', () => { const mockedUserName: string = 'mocked-user-name'; const mockedDisplayName = 'mockedDisplayName'; + const mockedAuthorStyle = 42; beforeEach(() => { - mockedRealtimeNote = new RealtimeNote(Mock.of({}), ''); + mockedRealtimeNote = new RealtimeNote(1, ''); mockedUser = Mock.of({ - username: mockedUserName, - displayName: mockedDisplayName, + [FieldNameUser.id]: 0, + [FieldNameUser.username]: mockedUserName, + [FieldNameUser.displayName]: mockedDisplayName, + [FieldNameUser.authorStyle]: mockedAuthorStyle, }); mockedMessageTransporter = new MessageTransporter(); @@ -61,7 +63,10 @@ describe('websocket connection', () => { it('returns the correct transporter', () => { const sut = new RealtimeConnection( mockedMessageTransporter, - mockedUser, + mockedUser[FieldNameUser.id], + mockedUser[FieldNameUser.username], + mockedUser[FieldNameUser.displayName], + mockedUser[FieldNameUser.authorStyle], mockedRealtimeNote, true, ); @@ -71,7 +76,10 @@ describe('websocket connection', () => { it('returns the correct realtime note', () => { const sut = new RealtimeConnection( mockedMessageTransporter, - mockedUser, + mockedUser[FieldNameUser.id], + mockedUser[FieldNameUser.username], + mockedUser[FieldNameUser.displayName], + mockedUser[FieldNameUser.authorStyle], mockedRealtimeNote, true, ); @@ -107,12 +115,14 @@ describe('websocket connection', () => { ( username, displayName, + authorStyle, otherAdapterCollector: OtherAdapterCollector, messageTransporter, acceptCursorUpdateProvider, ) => { expect(username).toBe(mockedUserName); expect(displayName).toBe(mockedDisplayName); + expect(authorStyle).toBe(mockedAuthorStyle); expect(otherAdapterCollector()).toStrictEqual([ realtimeUserStatus1, realtimeUserStatus2, @@ -126,7 +136,10 @@ describe('websocket connection', () => { const sut = new RealtimeConnection( mockedMessageTransporter, - mockedUser, + mockedUser[FieldNameUser.id], + mockedUser[FieldNameUser.username], + mockedUser[FieldNameUser.displayName], + mockedUser[FieldNameUser.authorStyle], mockedRealtimeNote, acceptEdits, ); @@ -157,7 +170,10 @@ describe('websocket connection', () => { const sut = new RealtimeConnection( mockedMessageTransporter, - mockedUser, + mockedUser[FieldNameUser.id], + mockedUser[FieldNameUser.username], + mockedUser[FieldNameUser.displayName], + mockedUser[FieldNameUser.authorStyle], mockedRealtimeNote, acceptEdits, ); @@ -169,7 +185,10 @@ describe('websocket connection', () => { it('removes the client from the note on transporter disconnect', () => { const sut = new RealtimeConnection( mockedMessageTransporter, - mockedUser, + mockedUser[FieldNameUser.id], + mockedUser[FieldNameUser.username], + mockedUser[FieldNameUser.displayName], + mockedUser[FieldNameUser.authorStyle], mockedRealtimeNote, true, ); @@ -181,46 +200,59 @@ describe('websocket connection', () => { expect(removeClientSpy).toHaveBeenCalledWith(sut); }); - it('saves the correct user', () => { + it('correctly return user id', () => { const sut = new RealtimeConnection( mockedMessageTransporter, - mockedUser, + mockedUser[FieldNameUser.id], + mockedUser[FieldNameUser.username], + mockedUser[FieldNameUser.displayName], + mockedUser[FieldNameUser.authorStyle], mockedRealtimeNote, true, ); - expect(sut.getUser()).toBe(mockedUser); + expect(sut.getUserId()).toBe(mockedUser[FieldNameUser.id]); }); - it('returns the correct username', () => { - const mockedUserWithUsername = Mock.of({ displayName: 'MockUser' }); - + it('correctly return username', () => { const sut = new RealtimeConnection( mockedMessageTransporter, - mockedUserWithUsername, + mockedUser[FieldNameUser.id], + mockedUser[FieldNameUser.username], + mockedUser[FieldNameUser.displayName], + mockedUser[FieldNameUser.authorStyle], mockedRealtimeNote, true, ); - expect(sut.getDisplayName()).toBe('MockUser'); + expect(sut.getUsername()).toBe(mockedUser[FieldNameUser.username]); }); - it('returns a random fallback display name if the provided user has no display name', () => { - const randomName = 'I am a random name'; - - jest - .spyOn(NameRandomizerModule, 'generateRandomName') - .mockReturnValue(randomName); - - mockedUser = Mock.of({}); - + it('correctly return displayName', () => { const sut = new RealtimeConnection( mockedMessageTransporter, - mockedUser, + mockedUser[FieldNameUser.id], + mockedUser[FieldNameUser.username], + mockedUser[FieldNameUser.displayName], + mockedUser[FieldNameUser.authorStyle], mockedRealtimeNote, true, ); - expect(sut.getDisplayName()).toBe(randomName); + expect(sut.getDisplayName()).toBe(mockedUser[FieldNameUser.displayName]); + }); + + it('correctly return authorStyle', () => { + const sut = new RealtimeConnection( + mockedMessageTransporter, + mockedUser[FieldNameUser.id], + mockedUser[FieldNameUser.username], + mockedUser[FieldNameUser.displayName], + mockedUser[FieldNameUser.authorStyle], + mockedRealtimeNote, + true, + ); + + expect(sut.getAuthorStyle()).toBe(mockedUser[FieldNameUser.authorStyle]); }); }); diff --git a/backend/src/realtime/realtime-note/realtime-connection.ts b/backend/src/realtime/realtime-note/realtime-connection.ts index a8d71d8cb..651e59e34 100644 --- a/backend/src/realtime/realtime-note/realtime-connection.ts +++ b/backend/src/realtime/realtime-note/realtime-connection.ts @@ -6,8 +6,6 @@ import { MessageTransporter, YDocSyncServerAdapter } from '@hedgedoc/commons'; import { Logger } from '@nestjs/common'; -import { User } from '../../database/user.entity'; -import { generateRandomName } from './random-word-lists/name-randomizer'; import { RealtimeNote } from './realtime-note'; import { RealtimeUserStatusAdapter } from './realtime-user-status-adapter'; @@ -19,24 +17,28 @@ export class RealtimeConnection { private readonly transporter: MessageTransporter; private readonly yDocSyncAdapter: YDocSyncServerAdapter; private readonly realtimeUserStateAdapter: RealtimeUserStatusAdapter; - private readonly displayName: string; /** * Instantiates the connection wrapper. * * @param messageTransporter The message transporter that handles the communication with the client. - * @param user The user of the client + * @param userId The id of the user of the client + * @param username The username of the user of the client + * @param displayName The displayName of the user of the client + * @param authorStyle The authorStyle of the user of the client * @param realtimeNote The {@link RealtimeNote} that the client connected to. * @param acceptEdits If edits by this connection should be accepted. * @throws Error if the socket is not open */ constructor( messageTransporter: MessageTransporter, - private user: User | null, + private userId: number, + private username: string | null, + private displayName: string, + private authorStyle: number, private realtimeNote: RealtimeNote, public acceptEdits: boolean, ) { - this.displayName = user?.displayName ?? generateRandomName(); this.transporter = messageTransporter; this.transporter.on('disconnected', () => { @@ -48,8 +50,9 @@ export class RealtimeConnection { () => acceptEdits, ); this.realtimeUserStateAdapter = new RealtimeUserStatusAdapter( - this.user?.username ?? null, - this.getDisplayName(), + this.username ?? null, + this.displayName, + this.authorStyle, () => this.realtimeNote .getConnections() @@ -67,18 +70,26 @@ export class RealtimeConnection { return this.transporter; } - public getUser(): User | null { - return this.user; - } - public getSyncAdapter(): YDocSyncServerAdapter { return this.yDocSyncAdapter; } + public getUserId(): number { + return this.userId; + } + public getDisplayName(): string { return this.displayName; } + public getUsername(): string | null { + return this.username; + } + + public getAuthorStyle(): number { + return this.authorStyle; + } + public getRealtimeNote(): RealtimeNote { return this.realtimeNote; } diff --git a/backend/src/realtime/realtime-note/realtime-note-store.ts b/backend/src/realtime/realtime-note/realtime-note-store.ts index ed26f7eb7..8236e29de 100644 --- a/backend/src/realtime/realtime-note/realtime-note-store.ts +++ b/backend/src/realtime/realtime-note/realtime-note-store.ts @@ -5,7 +5,6 @@ */ import { Injectable } from '@nestjs/common'; -import { Note } from '../../notes/note.entity'; import { RealtimeNote } from './realtime-note'; @Injectable() @@ -15,29 +14,29 @@ export class RealtimeNoteStore { /** * Creates a new {@link RealtimeNote} for the given {@link Note} and memorizes it. * - * @param note The note for which the realtime note should be created + * @param noteId The note for which the realtime note should be created * @param initialTextContent the initial text content of realtime doc * @param initialYjsState the initial yjs state. If provided this will be used instead of the text content * @throws Error if there is already an realtime note for the given note. * @return The created realtime note */ public create( - note: Note, + noteId: number, initialTextContent: string, - initialYjsState?: number[], + initialYjsState?: ArrayBuffer, ): RealtimeNote { - if (this.noteIdToRealtimeNote.has(note.id)) { - throw new Error(`Realtime note for note ${note.id} already exists.`); + if (this.noteIdToRealtimeNote.has(noteId)) { + throw new Error(`Realtime note for note ${noteId} already exists.`); } const realtimeNote = new RealtimeNote( - note, + noteId, initialTextContent, initialYjsState, ); realtimeNote.on('destroy', () => { - this.noteIdToRealtimeNote.delete(note.id); + this.noteIdToRealtimeNote.delete(noteId); }); - this.noteIdToRealtimeNote.set(note.id, realtimeNote); + this.noteIdToRealtimeNote.set(noteId, realtimeNote); return realtimeNote; } diff --git a/backend/src/realtime/realtime-note/realtime-note.module.ts b/backend/src/realtime/realtime-note/realtime-note.module.ts index 087fb2bd0..d4ac32d5a 100644 --- a/backend/src/realtime/realtime-note/realtime-note.module.ts +++ b/backend/src/realtime/realtime-note/realtime-note.module.ts @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { ScheduleModule } from '@nestjs/schedule'; import { LoggerModule } from '../../logger/logger.module'; @@ -18,7 +18,7 @@ import { RealtimeNoteService } from './realtime-note.service'; imports: [ LoggerModule, UsersModule, - PermissionsModule, + forwardRef(() => PermissionsModule), SessionModule, RevisionsModule, ScheduleModule.forRoot(), 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..521a42374 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 */ @@ -9,11 +9,11 @@ import { OnEvent } from '@nestjs/event-emitter'; import { SchedulerRegistry } from '@nestjs/schedule'; import appConfiguration, { AppConfig } from '../../config/app.config'; +import { FieldNameRevision } from '../../database/types'; 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,9 +44,10 @@ export class RealtimeNoteService implements BeforeApplicationShutdown { */ public saveRealtimeNote(realtimeNote: RealtimeNote): void { this.revisionsService - .createAndSaveRevision( - realtimeNote.getNote(), + .createRevision( + realtimeNote.getNoteId(), realtimeNote.getRealtimeDoc().getCurrentContent(), + undefined, realtimeNote.getRealtimeDoc().encodeStateAsUpdate(), ) .then(() => { @@ -57,30 +58,30 @@ export class RealtimeNoteService implements BeforeApplicationShutdown { /** * Creates or reuses a {@link RealtimeNote} that is handling the real time editing of the {@link Note} which is identified by the given note id. - * @param note The {@link Note} for which a {@link RealtimeNote realtime note} should be retrieved. + * @param noteId The {@link Note} for which a {@link RealtimeNote realtime note} should be retrieved. * @throws NotInDBError if note doesn't exist or has no revisions. * @return A {@link RealtimeNote} that is linked to the given note. */ - public async getOrCreateRealtimeNote(note: Note): Promise { + public async getOrCreateRealtimeNote(noteId: number): Promise { return ( - this.realtimeNoteStore.find(note.id) ?? - (await this.createNewRealtimeNote(note)) + this.realtimeNoteStore.find(noteId) ?? + (await this.createNewRealtimeNote(noteId)) ); } /** * Creates a new {@link RealtimeNote} for the given {@link Note}. * - * @param note The note for which the realtime note should be created + * @param noteId The note for which the realtime note should be created * @throws NotInDBError if note doesn't exist or has no revisions. * @return The created realtime note */ - private async createNewRealtimeNote(note: Note): Promise { - const lastRevision = await this.revisionsService.getLatestRevision(note); + private async createNewRealtimeNote(noteId: number): Promise { + const lastRevision = await this.revisionsService.getLatestRevision(noteId); const realtimeNote = this.realtimeNoteStore.create( - note, + noteId, lastRevision.content, - lastRevision.yjsStateVector ?? undefined, + lastRevision[FieldNameRevision.yjsStateVector] ?? undefined, ); realtimeNote.on('beforeDestroy', () => { this.saveRealtimeNote(realtimeNote); @@ -103,47 +104,47 @@ export class RealtimeNoteService implements BeforeApplicationShutdown { persistInterval * 60 * 1000, ); this.schedulerRegistry.addInterval( - `periodic-persist-${realtimeNote.getNote().id}`, + `periodic-persist-${realtimeNote.getNoteId()}`, intervalId, ); realtimeNote.on('destroy', () => { clearInterval(intervalId); this.schedulerRegistry.deleteInterval( - `periodic-persist-${realtimeNote.getNote().id}`, + `periodic-persist-${realtimeNote.getNoteId()}`, ); }); }); } @OnEvent(NoteEvent.PERMISSION_CHANGE) - public async handleNotePermissionChanged(note: Note): Promise { - const realtimeNote = this.realtimeNoteStore.find(note.id); + public async handleNotePermissionChanged(noteId: number): Promise { + const realtimeNote = this.realtimeNoteStore.find(noteId); if (!realtimeNote) return; realtimeNote.announceMetadataUpdate(); const allConnections = realtimeNote.getConnections(); - await this.updateOrCloseConnection(allConnections, note); + await this.updateOrCloseConnection(allConnections, noteId); } private async updateOrCloseConnection( connections: RealtimeConnection[], - note: Note, + noteId: number, ): Promise { for (const connection of connections) { const permission = await this.permissionService.determinePermission( - connection.getUser(), - note, + connection.getUserId(), + noteId, ); - if (permission === NotePermission.DENY) { + if (permission === NotePermissionLevel.DENY) { connection.getTransporter().disconnect(); } else { - connection.acceptEdits = permission > NotePermission.READ; + connection.acceptEdits = permission > NotePermissionLevel.READ; } } } @OnEvent(NoteEvent.DELETION) - public handleNoteDeleted(noteId: Note['id']): void { + public handleNoteDeleted(noteId: number): void { const realtimeNote = this.realtimeNoteStore.find(noteId); if (realtimeNote) { realtimeNote.announceNoteDeletion(); diff --git a/backend/src/realtime/realtime-note/realtime-note.spec.ts b/backend/src/realtime/realtime-note/realtime-note.spec.ts index 809f0687f..09ad742fc 100644 --- a/backend/src/realtime/realtime-note/realtime-note.spec.ts +++ b/backend/src/realtime/realtime-note/realtime-note.spec.ts @@ -29,7 +29,7 @@ describe('realtime note', () => { it('can return the given note', () => { const sut = new RealtimeNote(mockedNote, 'nothing'); - expect(sut.getNote()).toBe(mockedNote); + expect(sut.getNoteId()).toBe(mockedNote); }); it('can connect and disconnect clients', () => { diff --git a/backend/src/realtime/realtime-note/realtime-note.ts b/backend/src/realtime/realtime-note/realtime-note.ts index ed8399549..f97f85d49 100644 --- a/backend/src/realtime/realtime-note/realtime-note.ts +++ b/backend/src/realtime/realtime-note/realtime-note.ts @@ -7,7 +7,6 @@ import { Message, MessageType, RealtimeDoc } from '@hedgedoc/commons'; import { Logger } from '@nestjs/common'; import { EventEmitter2, EventMap, Listener } from 'eventemitter2'; -import { Note } from '../../notes/note.entity'; import { RealtimeConnection } from './realtime-connection'; export interface RealtimeNoteEventMap extends EventMap { @@ -33,16 +32,16 @@ export class RealtimeNote extends EventEmitter2 { private destroyEventTimer: NodeJS.Timeout | null = null; constructor( - private readonly note: Note, + private readonly noteId: number, initialTextContent: string, - initialYjsState?: number[], + initialYjsState?: ArrayBuffer, ) { super(); - this.logger = new Logger(`${RealtimeNote.name} ${note.id}`); + this.logger = new Logger(`${RealtimeNote.name} ${noteId}`); this.doc = new RealtimeDoc(initialTextContent, initialYjsState); const length = this.doc.getCurrentContent().length; this.logger.debug( - `New realtime session for note ${note.id} created. Length of initial content: ${length} characters`, + `New realtime session for note ${noteId} created. Length of initial content: ${length} characters`, ); this.clientAddedListener = this.on( 'clientAdded', @@ -74,7 +73,7 @@ export class RealtimeNote extends EventEmitter2 { /** * Disconnects the given websocket client while cleaning-up if it was the last user in the realtime note. * - * @param {WebSocket} client The websocket client that disconnects. + * @param client The websocket client that disconnects. */ public removeClient(client: RealtimeConnection): void { this.clients.delete(client); @@ -144,8 +143,8 @@ export class RealtimeNote extends EventEmitter2 { * * @return the {@link Note note} */ - public getNote(): Note { - return this.note; + public getNoteId(): number { + return this.noteId; } /** diff --git a/backend/src/realtime/realtime-note/realtime-user-status-adapter.ts b/backend/src/realtime/realtime-note/realtime-user-status-adapter.ts index e0ea326fb..6d142b1d5 100644 --- a/backend/src/realtime/realtime-note/realtime-user-status-adapter.ts +++ b/backend/src/realtime/realtime-note/realtime-user-status-adapter.ts @@ -22,6 +22,7 @@ export class RealtimeUserStatusAdapter { constructor( private readonly username: string | null, private readonly displayName: string, + private readonly authorStyle: number, private collectOtherAdapters: OtherAdapterCollector, private messageTransporter: MessageTransporter, private acceptCursorUpdateProvider: () => boolean, @@ -35,9 +36,7 @@ export class RealtimeUserStatusAdapter { username: this.username, displayName: this.displayName, active: true, - styleIndex: this.findLeastUsedStyleIndex( - this.createStyleIndexToCountMap(), - ), + styleIndex: this.authorStyle, cursor: !this.acceptCursorUpdateProvider() ? null : { diff --git a/backend/src/realtime/realtime-note/test-utils/mock-connection.ts b/backend/src/realtime/realtime-note/test-utils/mock-connection.ts index f25cc427e..b18011542 100644 --- a/backend/src/realtime/realtime-note/test-utils/mock-connection.ts +++ b/backend/src/realtime/realtime-note/test-utils/mock-connection.ts @@ -9,7 +9,7 @@ import { } from '@hedgedoc/commons'; import { Mock } from 'ts-mockery'; -import { User } from '../../../database/user.entity'; +import { FieldNameUser, User } from '../../../database/types'; import { RealtimeConnection } from '../realtime-connection'; import { RealtimeNote } from '../realtime-note'; import { RealtimeUserStatusAdapter } from '../realtime-user-status-adapter'; @@ -21,14 +21,14 @@ enum RealtimeUserState { WITH_READONLY, } -const MOCK_FALLBACK_USERNAME: string = 'mock'; - /** * Creates a mocked {@link RealtimeConnection realtime connection}. */ export class MockConnectionBuilder { + private userId: number; private username: string | null; - private displayName: string | undefined; + private displayName: string; + private authorStyle: number; private includeRealtimeUserStatus: RealtimeUserState = RealtimeUserState.WITHOUT; @@ -42,6 +42,8 @@ export class MockConnectionBuilder { public withGuestUser(displayName: string): this { this.username = null; this.displayName = displayName; + this.authorStyle = 8; + this.userId = 1000; return this; } @@ -50,10 +52,11 @@ export class MockConnectionBuilder { * * @param username the username of the mocked user. If this value is omitted then the builder will user a {@link MOCK_FALLBACK_USERNAME fallback}. */ - public withLoggedInUser(username?: string): this { - const newUsername = username ?? MOCK_FALLBACK_USERNAME; - this.username = newUsername; - this.displayName = newUsername; + public withLoggedInUser(username: string): this { + this.username = username; + this.displayName = username; + this.userId = 1001; + this.authorStyle = 1; return this; } @@ -80,16 +83,15 @@ export class MockConnectionBuilder { * @throws Error if neither withGuestUser nor withLoggedInUser has been called. */ public build(): RealtimeConnection { - const displayName = this.deriveDisplayName(); - const transporter = new MockMessageTransporter(); transporter.setAdapter(new MockedBackendTransportAdapter('')); const realtimeUserStateAdapter: RealtimeUserStatusAdapter = this.includeRealtimeUserStatus === RealtimeUserState.WITHOUT ? Mock.of({}) : new RealtimeUserStatusAdapter( - this.username ?? null, - displayName, + this.username, + this.displayName, + this.authorStyle, () => this.realtimeNote .getConnections() @@ -100,18 +102,19 @@ export class MockConnectionBuilder { RealtimeUserState.WITH_READWRITE, ); - const mockUser = - this.username === null - ? null - : Mock.of({ - username: this.username, - displayName: this.displayName, - }); + const mockUser = Mock.of({ + [FieldNameUser.username]: this.username, + [FieldNameUser.displayName]: this.displayName, + [FieldNameUser.id]: this.userId, + [FieldNameUser.authorStyle]: this.authorStyle, + }); const yDocSyncServerAdapter = Mock.of({}); const connection = Mock.of({ - getUser: jest.fn(() => mockUser), - getDisplayName: jest.fn(() => displayName), + getUserId: jest.fn(() => mockUser[FieldNameUser.id]), + getUsername: jest.fn(() => mockUser[FieldNameUser.username]), + getAuthorStyle: jest.fn(() => mockUser[FieldNameUser.authorStyle]), + getDisplayName: jest.fn(() => mockUser[FieldNameUser.displayName]), getSyncAdapter: jest.fn(() => yDocSyncServerAdapter), getTransporter: jest.fn(() => transporter), getRealtimeUserStateAdapter: () => realtimeUserStateAdapter, @@ -129,14 +132,4 @@ export class MockConnectionBuilder { return connection; } - - private deriveDisplayName(): string { - if (this.displayName === undefined) { - throw new Error( - 'Neither withGuestUser nor withLoggedInUser has been called.', - ); - } - - return this.displayName; - } } diff --git a/backend/src/realtime/websocket/utils/extract-note-id-from-request-url.spec.ts b/backend/src/realtime/websocket/utils/extract-note-id-from-request-url.spec.ts index 1e7c7af4e..c96592789 100644 --- a/backend/src/realtime/websocket/utils/extract-note-id-from-request-url.spec.ts +++ b/backend/src/realtime/websocket/utils/extract-note-id-from-request-url.spec.ts @@ -6,19 +6,19 @@ import { IncomingMessage } from 'http'; import { Mock } from 'ts-mockery'; -import { extractNoteIdFromRequestUrl } from './extract-note-id-from-request-url'; +import { extractNoteAliasFromRequestUrl } from './extract-note-id-from-request-url'; describe('extract note id from path', () => { it('fails if no URL is present', () => { const mockedRequest = Mock.of(); - expect(() => extractNoteIdFromRequestUrl(mockedRequest)).toThrow(); + expect(() => extractNoteAliasFromRequestUrl(mockedRequest)).toThrow(); }); it('can find a note id', () => { const mockedRequest = Mock.of({ url: '/realtime?noteId=somethingsomething', }); - expect(extractNoteIdFromRequestUrl(mockedRequest)).toBe( + expect(extractNoteAliasFromRequestUrl(mockedRequest)).toBe( 'somethingsomething', ); }); @@ -27,20 +27,20 @@ describe('extract note id from path', () => { const mockedRequest = Mock.of({ url: '/realtime?nöteId=somethingsomething', }); - expect(() => extractNoteIdFromRequestUrl(mockedRequest)).toThrow(); + expect(() => extractNoteAliasFromRequestUrl(mockedRequest)).toThrow(); }); it('fails if note id is empty', () => { const mockedRequest = Mock.of({ url: '/realtime?noteId=', }); - expect(() => extractNoteIdFromRequestUrl(mockedRequest)).toThrow(); + expect(() => extractNoteAliasFromRequestUrl(mockedRequest)).toThrow(); }); it('fails if path is empty', () => { const mockedRequest = Mock.of({ url: '', }); - expect(() => extractNoteIdFromRequestUrl(mockedRequest)).toThrow(); + expect(() => extractNoteAliasFromRequestUrl(mockedRequest)).toThrow(); }); }); 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..85df61dc8 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 @@ -12,17 +12,19 @@ import { IncomingMessage } from 'http'; * @return The extracted note id * @throws Error if the given string isn't a valid realtime URL path */ -export function extractNoteIdFromRequestUrl(request: IncomingMessage): string { +export function extractNoteAliasFromRequestUrl( + request: IncomingMessage, +): string { if (request.url === undefined) { throw new Error('No URL found in request'); } // A valid domain name is needed for the URL constructor, although not being used here. - // The example.org domain should be safe to use according to RFC 6761 §6.5. + // 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 === '') { - throw new Error("Path doesn't contain parameter noteId"); + const noteAlias = url.searchParams.get('noteAlias'); + if (noteAlias === null || noteAlias === '') { + throw new Error("Path doesn't contain parameter noteAlias"); } else { - return noteId; + return noteAlias; } } 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..cfe6ea9ff 100644 --- a/backend/src/realtime/websocket/websocket.gateway.ts +++ b/backend/src/realtime/websocket/websocket.gateway.ts @@ -1,28 +1,24 @@ /* - * 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 { - DisconnectReason, - MessageTransporter, - userCanEdit, -} from '@hedgedoc/commons'; +import { DisconnectReason, MessageTransporter } from '@hedgedoc/commons'; import { OnGatewayConnection, WebSocketGateway } from '@nestjs/websockets'; import { IncomingMessage } from 'http'; import WebSocket from 'ws'; -import { User } from '../../database/user.entity'; +import { FieldNameUser } from '../../database/types'; 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'; import { RealtimeNoteService } from '../realtime-note/realtime-note.service'; import { BackendWebsocketAdapter } from './backend-websocket-adapter'; -import { extractNoteIdFromRequestUrl } from './utils/extract-note-id-from-request-url'; +import { extractNoteAliasFromRequestUrl } from './utils/extract-note-id-from-request-url'; /** * Gateway implementing the realtime logic required for realtime note editing. @@ -31,10 +27,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,48 +50,53 @@ export class WebsocketGateway implements OnGatewayConnection { request: IncomingMessage, ): Promise { try { - const user = await this.findUserByRequestSession(request); - const note = await this.noteService.getNoteByIdOrAlias( - extractNoteIdFromRequestUrl(request), + const userId = await this.findUserIdByRequestSession(request); + if (userId === undefined) { + clientSocket.close(DisconnectReason.SESSION_NOT_FOUND); + return; + } + const noteId = await this.noteService.getNoteIdByAlias( + extractNoteAliasFromRequestUrl(request), ); - - const username = user?.username ?? 'guest'; + const user = await this.userService.getUserById(userId); + const username = user[FieldNameUser.username]; + const displayName = user[FieldNameUser.displayName]; + const authorStyle = user[FieldNameUser.authorStyle]; 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); return; } + const acceptEdits: boolean = notePermission >= NotePermissionLevel.WRITE; 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 connection = new RealtimeConnection( websocketTransporter, - user, + userId, + username, + displayName, + authorStyle, realtimeNote, acceptEdits, ); @@ -114,30 +115,18 @@ 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; + return undefined; } - this.logger.debug('sessionId is not empty', 'findUserByRequestSession'); - const username = await this.sessionService.fetchUsernameForSessionId( - sessionId.get(), - ); - if (username === undefined) { - return null; - } - return await this.userService.getUserByUsername(username); + return await this.sessionService.getUserIdForSessionId(sessionId.get()); } } diff --git a/backend/src/realtime/websocket/websocket.module.ts b/backend/src/realtime/websocket/websocket.module.ts index 83c3bff01..df086956e 100644 --- a/backend/src/realtime/websocket/websocket.module.ts +++ b/backend/src/realtime/websocket/websocket.module.ts @@ -1,12 +1,13 @@ /* - * 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 { NoteModule } from '../../notes/note.module'; import { PermissionsModule } from '../../permissions/permissions.module'; import { SessionModule } from '../../sessions/session.module'; import { UsersModule } from '../../users/users.module'; @@ -16,8 +17,9 @@ import { WebsocketGateway } from './websocket.gateway'; @Module({ imports: [ LoggerModule, - NotesModule, + AliasModule, RealtimeNoteModule, + NoteModule, UsersModule, PermissionsModule, SessionModule, 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..2463f7327 100644 --- a/backend/src/revisions/revisions.module.ts +++ b/backend/src/revisions/revisions.module.ts @@ -5,24 +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, - ], - providers: [RevisionsService, EditService], - exports: [RevisionsService, EditService], + imports: [KnexModule, LoggerModule, ConfigModule, AliasModule], + providers: [RevisionsService], + exports: [RevisionsService], }) export class RevisionsModule {} diff --git a/backend/src/revisions/revisions.service.spec.ts b/backend/src/revisions/revisions.service.spec.ts index 82d00e663..696758a67 100644 --- a/backend/src/revisions/revisions.service.spec.ts +++ b/backend/src/revisions/revisions.service.spec.ts @@ -1,548 +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 */ -import { ConfigModule } from '@nestjs/config'; -import { EventEmitterModule } from '@nestjs/event-emitter'; -import { Test, TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { createPatch } from 'diff'; -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 { - createDefaultMockNoteConfig, - registerNoteConfig, -} from '../config/mock/note.config.mock'; -import { NoteConfig } from '../config/note.config'; -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 { Session } from '../sessions/session.entity'; -import { Edit } from './edit.entity'; -import { EditService } from './edit.service'; -import { Revision } from './revision.entity'; -import { RevisionsService } from './revisions.service'; - -describe('RevisionsService', () => { - let service: RevisionsService; - let revisionRepo: Repository; - let noteRepo: Repository; - const noteConfig: NoteConfig = createDefaultMockNoteConfig(); - - beforeEach(async () => { - noteRepo = new Repository( - '', - new EntityManager( - new DataSource({ - type: 'sqlite', - database: ':memory:', - }), - ), - undefined, - ); - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - RevisionsService, - EditService, - { - provide: getRepositoryToken(Revision), - useClass: Repository, - }, - { - provide: getRepositoryToken(Note), - useClass: Repository, - }, - ], - imports: [ - NotesModule, - LoggerModule, - ConfigModule.forRoot({ - isGlobal: true, - load: [ - appConfigMock, - databaseConfigMock, - authConfigMock, - noteConfigMock, - registerNoteConfig(noteConfig), - ], - }), - EventEmitterModule.forRoot(eventModuleConfig), - ], - }) - .overrideProvider(getRepositoryToken(Edit)) - .useValue({}) - .overrideProvider(getRepositoryToken(User)) - .useValue({}) - .overrideProvider(getRepositoryToken(ApiToken)) - .useValue({}) - .overrideProvider(getRepositoryToken(Identity)) - .useValue({}) - .overrideProvider(getRepositoryToken(Note)) - .useValue(noteRepo) - .overrideProvider(getRepositoryToken(Revision)) - .useClass(Repository) - .overrideProvider(getRepositoryToken(Tag)) - .useValue({}) - .overrideProvider(getRepositoryToken(NoteGroupPermission)) - .useValue({}) - .overrideProvider(getRepositoryToken(NoteUserPermission)) - .useValue({}) - .overrideProvider(getRepositoryToken(Group)) - .useValue({}) - .overrideProvider(getRepositoryToken(Alias)) - .useValue({}) - .overrideProvider(getRepositoryToken(Session)) - .useValue({}) - .overrideProvider(getRepositoryToken(Author)) - .useValue({}) - .compile(); - - service = module.get(RevisionsService); - revisionRepo = module.get>( - getRepositoryToken(Revision), - ); - noteRepo = module.get>(getRepositoryToken(Note)); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - describe('getRevision', () => { - it('returns a revision', async () => { - const note = Mock.of({}); - const revision = Mock.of({}); - jest.spyOn(revisionRepo, 'findOne').mockResolvedValueOnce(revision); - expect(await service.getRevision(note, 1)).toBe(revision); - }); - it('throws if the revision is not in the databse', async () => { - jest.spyOn(revisionRepo, 'findOne').mockResolvedValueOnce(null); - await expect(service.getRevision({} as Note, 1)).rejects.toThrow( - NotInDBError, - ); - }); - }); - - describe('purgeRevisions', () => { - let revisions: Revision[]; - let note: Note; - - beforeEach(() => { - note = Mock.of({ publicId: 'test-note', id: 1 }); - revisions = []; - - jest - .spyOn(revisionRepo, 'remove') - .mockImplementation( - (deleteEntities: T): Promise => { - const newRevisions = revisions.filter((item: Revision) => - Array.isArray(deleteEntities) - ? !deleteEntities.includes(item) - : deleteEntities !== item, - ); - revisions = newRevisions; - note.revisions = Promise.resolve(newRevisions); - return Promise.resolve(deleteEntities); - }, - ); - }); - - it('purges the revision history', async () => { - const revision1 = Mock.of({ - id: 1, - note: Promise.resolve(note), - }); - const revision2 = Mock.of({ - id: 2, - note: Promise.resolve(note), - }); - const revision3 = Mock.of({ - id: 3, - note: Promise.resolve(note), - content: - '---\ntitle: new title\ndescription: new description\ntags: [ "tag1" ]\n---\nnew content\n', - }); - revisions = [revision1, revision2, revision3]; - note.revisions = Promise.resolve(revisions); - - jest.spyOn(revisionRepo, 'find').mockResolvedValueOnce(revisions); - jest.spyOn(service, 'getLatestRevision').mockResolvedValueOnce(revision3); - - jest.spyOn(revisionRepo, 'save').mockResolvedValue(Mock.of()); - - // expected to return all the purged revisions - expect(await service.purgeRevisions(note)).toStrictEqual([ - revision1, - revision2, - ]); - - expect(revisions).toStrictEqual([revision3]); - expect(revision3.patch).toMatchSnapshot(); - }); - it('has no effect on revision history when a single revision is present', async () => { - const revision1 = Mock.of({ id: 1 }); - revisions = [revision1]; - note.revisions = Promise.resolve(revisions); - - jest.spyOn(revisionRepo, 'find').mockResolvedValueOnce(revisions); - jest.spyOn(service, 'getLatestRevision').mockResolvedValueOnce(revision1); - - // expected to return all the purged revisions - expect(await service.purgeRevisions(note)).toHaveLength(0); - - // expected to have only the latest revision - const updatedRevisions: Revision[] = [revision1]; - expect(revisions).toEqual(updatedRevisions); - }); - }); - - describe('getRevisionUserInfo', () => { - it('counts users correctly', async () => { - const user = User.create('test', 'test') as User; - const author = Author.create(123) as Author; - author.user = Promise.resolve(user); - const anonAuthor = Author.create(123) as Author; - const anonAuthor2 = Author.create(123) as Author; - const edits = [Edit.create(author, 12, 15) as Edit]; - edits.push(Edit.create(author, 16, 18) as Edit); - edits.push(Edit.create(author, 29, 20) as Edit); - edits.push(Edit.create(anonAuthor, 29, 20) as Edit); - edits.push(Edit.create(anonAuthor, 29, 20) as Edit); - edits.push(Edit.create(anonAuthor2, 29, 20) as Edit); - const revision = Mock.of({}); - revision.edits = Promise.resolve(edits); - - const userInfo = await service.getRevisionUserInfo(revision); - expect(userInfo.usernames.length).toEqual(1); - expect(userInfo.anonymousUserCount).toEqual(2); - }); - }); - - describe('toRevisionMetadataDto', () => { - it('converts a revision', async () => { - const revision = Mock.of({ - id: 3246, - content: 'mockContent', - length: 1854, - createdAt: new Date('2020-05-20T09:58:00.000Z'), - title: 'mockTitle', - tags: Promise.resolve([Mock.of({ name: 'mockTag' })]), - description: 'mockDescription', - patch: 'mockPatch', - edits: Promise.resolve([ - Mock.of({ - endPos: 93, - startPos: 34, - createdAt: new Date('2020-03-04T20:12:00.000Z'), - updatedAt: new Date('2021-12-10T09:45:00.000Z'), - author: Promise.resolve( - Mock.of({ - user: Promise.resolve( - Mock.of({ - username: 'mockusername', - }), - ), - }), - ), - }), - ]), - }); - expect(await service.toRevisionMetadataDto(revision)).toMatchSnapshot(); - }); - }); - - describe('toRevisionDto', () => { - it('converts a revision', async () => { - const revision = Mock.of({ - id: 3246, - content: 'mockContent', - length: 1854, - createdAt: new Date('2020-05-20T09:58:00.000Z'), - title: 'mockTitle', - tags: Promise.resolve([Mock.of({ name: 'mockTag' })]), - description: 'mockDescription', - patch: 'mockPatch', - edits: Promise.resolve([ - Mock.of({ - endPos: 93, - startPos: 34, - createdAt: new Date('2020-03-04T22:32:00.000Z'), - updatedAt: new Date('2021-02-10T12:23:00.000Z'), - author: Promise.resolve( - Mock.of({ - user: Promise.resolve( - Mock.of({ - username: 'mockusername', - }), - ), - }), - ), - }), - ]), - }); - expect(await service.toRevisionDto(revision)).toMatchSnapshot(); - }); - }); - - describe('createRevision', () => { - it('creates a new revision', async () => { - const note = Mock.of({ publicId: 'test-note', id: 1 }); - const oldContent = 'old content\n'; - const newContent = - '---\ntitle: new title\ndescription: new description\ntags: [ "tag1" ]\n---\nnew content\n'; - - const oldRevision = Mock.of({ content: oldContent, id: 1 }); - jest.spyOn(revisionRepo, 'findOne').mockResolvedValueOnce(oldRevision); - jest - .spyOn(revisionRepo, 'save') - .mockImplementation((revision) => - Promise.resolve(revision as Revision), - ); - - const createdRevision = await service.createRevision(note, newContent); - expect(createdRevision).not.toBeUndefined(); - expect(createdRevision?.content).toBe(newContent); - await expect(createdRevision?.tags).resolves.toMatchSnapshot(); - expect(createdRevision?.title).toBe('new title'); - expect(createdRevision?.description).toBe('new description'); - await expect(createdRevision?.note).resolves.toBe(note); - expect(createdRevision?.patch).toMatchSnapshot(); - }); - - it("won't create a revision if content is unchanged", async () => { - const note = Mock.of({ id: 1 }); - const oldContent = 'old content\n'; - - const oldRevision = Mock.of({ content: oldContent, id: 1 }); - jest.spyOn(revisionRepo, 'findOne').mockResolvedValueOnce(oldRevision); - const saveSpy = jest.spyOn(revisionRepo, 'save').mockImplementation(); - - const createdRevision = await service.createRevision(note, oldContent); - expect(createdRevision).toBeUndefined(); - expect(saveSpy).not.toHaveBeenCalled(); - }); - }); - - describe('createAndSaveRevision', () => { - it('creates and saves a new revision', async () => { - const newRevision = Mock.of(); - const createRevisionSpy = jest - .spyOn(service, 'createRevision') - .mockResolvedValue(newRevision); - const repoSaveSpy = jest - .spyOn(revisionRepo, 'save') - .mockResolvedValue(newRevision); - - const note = Mock.of({}); - const newContent = 'MockContent'; - - const yjsState = [0, 1, 2, 3, 4, 5]; - - await service.createAndSaveRevision(note, newContent, yjsState); - expect(createRevisionSpy).toHaveBeenCalledWith( - note, - newContent, - yjsState, - ); - expect(repoSaveSpy).toHaveBeenCalledWith(newRevision); - }); - - it("doesn't save if no revision has been created", async () => { - const createRevisionSpy = jest - .spyOn(service, 'createRevision') - .mockResolvedValue(undefined); - const repoSaveSpy = jest - .spyOn(revisionRepo, 'save') - .mockRejectedValue(new Error("shouldn't have been called")); - - const note = Mock.of({}); - const newContent = 'MockContent'; - const yjsState = [0, 1, 2, 3, 4, 5]; - - await service.createAndSaveRevision(note, newContent, yjsState); - expect(createRevisionSpy).toHaveBeenCalledWith( - note, - newContent, - yjsState, - ); - expect(repoSaveSpy).not.toHaveBeenCalled(); - }); - }); - - describe('auto remove old revisions', () => { - beforeEach(() => { - jest.spyOn(service, 'removeOldRevisions'); - }); - - it('handleCron should call removeOldRevisions', async () => { - await service.handleRevisionCleanup(); - expect(service.removeOldRevisions).toHaveBeenCalledTimes(1); - }); - - it('handleTimeout should call removeOldRevisions', async () => { - await service.handleRevisionCleanupTimeout(); - expect(service.removeOldRevisions).toHaveBeenCalledTimes(1); - }); - }); - - describe('removeOldRevisions', () => { - let note: Note; - let notes: Note[]; - let revisions: Revision[]; - let oldRevisions: Revision[]; - const retentionDays = 30; - - beforeEach(() => { - noteConfig.revisionRetentionDays = retentionDays; - - note = Mock.of({ publicId: 'test-note', id: 1 }); - notes = [note]; - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('remove all revisions except latest revision', async () => { - const date1 = new Date(); - const date2 = new Date(); - const date3 = new Date(); - date1.setDate(date1.getDate() - retentionDays - 2); - date2.setDate(date2.getDate() - retentionDays - 1); - - const revision1 = Mock.of({ - id: 1, - createdAt: date1, - note: Promise.resolve(note), - }); - const revision2 = Mock.of({ - id: 2, - createdAt: date2, - note: Promise.resolve(note), - content: 'old content\n', - }); - const revision3 = Mock.of({ - id: 3, - createdAt: date3, - note: Promise.resolve(note), - content: - '---\ntitle: new title\ndescription: new description\ntags: [ "tag1" ]\n---\nnew content\n', - }); - revision3.patch = createPatch( - note.publicId, - revision2.content, - revision3.content, - ); - - revisions = [revision1, revision2, revision3]; - oldRevisions = [revision1, revision2]; - - jest.spyOn(noteRepo, 'find').mockResolvedValueOnce(notes); - jest.spyOn(revisionRepo, 'find').mockResolvedValueOnce(revisions); - jest - .spyOn(revisionRepo, 'remove') - .mockImplementationOnce(async (entry, _) => { - expect(entry).toEqual(oldRevisions); - return entry; - }); - jest.spyOn(revisionRepo, 'save').mockResolvedValue(revision3); - - await service.removeOldRevisions(); - expect(revision3.patch).toMatchSnapshot(); - }); - - it('remove a part of old revisions', async () => { - const date1 = new Date(); - const date2 = new Date(); - const date3 = new Date(); - date1.setDate(date1.getDate() - retentionDays); - date2.setDate(date2.getDate() - retentionDays + 1); - - const revision1 = Mock.of({ - id: 1, - createdAt: date1, - note: Promise.resolve(note), - content: 'old content\n', - }); - const revision2 = Mock.of({ - id: 2, - createdAt: date2, - note: Promise.resolve(note), - content: - '---\ntitle: new title\ndescription: new description\ntags: [ "tag1" ]\n---\nnew content\n', - }); - const revision3 = Mock.of({ - id: 3, - createdAt: date3, - note: Promise.resolve(note), - }); - revision2.patch = createPatch( - note.publicId, - revision1.content, - revision2.content, - ); - - revisions = [revision1, revision2, revision3]; - oldRevisions = [revision1]; - - jest.spyOn(noteRepo, 'find').mockResolvedValueOnce(notes); - jest.spyOn(revisionRepo, 'find').mockResolvedValueOnce(revisions); - jest - .spyOn(revisionRepo, 'remove') - .mockImplementationOnce(async (entry, _) => { - expect(entry).toEqual(oldRevisions); - return entry; - }); - jest.spyOn(revisionRepo, 'save').mockResolvedValue(revision2); - - await service.removeOldRevisions(); - expect(revision2.patch).toMatchSnapshot(); - }); - - it('do nothing when only one revision', async () => { - const date = new Date(); - date.setDate(date.getDate() - retentionDays * 2); - - const revision1 = Mock.of({ - id: 1, - createdAt: date, - note: Promise.resolve(note), - }); - revisions = [revision1]; - oldRevisions = []; - - jest.spyOn(noteRepo, 'find').mockResolvedValueOnce(notes); - jest.spyOn(revisionRepo, 'find').mockResolvedValueOnce(revisions); - const spyOnRemove = jest.spyOn(revisionRepo, 'remove'); - - await service.removeOldRevisions(); - expect(spyOnRemove).toHaveBeenCalledTimes(0); - }); - - it('do nothing when retention days config is zero', async () => { - noteConfig.revisionRetentionDays = 0; - const spyOnRemove = jest.spyOn(revisionRepo, 'remove'); - - await service.removeOldRevisions(); - expect(spyOnRemove).toHaveBeenCalledTimes(0); - }); - }); -}); diff --git a/backend/src/revisions/revisions.service.ts b/backend/src/revisions/revisions.service.ts index c00a9446f..83e43eaa2 100644 --- a/backend/src/revisions/revisions.service.ts +++ b/backend/src/revisions/revisions.service.ts @@ -6,162 +6,283 @@ 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 { + AuthorshipInfo, + 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 { + users: { + username: string; + createdAt: AuthorshipInfo[FieldNameAuthorshipInfo.createdAt]; + }[]; + 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, + }; + } + + /** + * Get the latest + * @param noteId + * @param transaction + */ + async getLatestRevision( + noteId: number, + transaction?: Knex, + ): Promise { + const dbActor = transaction ?? this.knex; + const revision = await dbActor(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(revisionUuid: string): Promise { + const authorUsernamesAndGuestUuids = (await this.knex(TableAuthorshipInfo) + .join( + TableUser, + `${TableAuthorshipInfo}.${FieldNameAuthorshipInfo.authorId}`, + `${TableUser}.${FieldNameUser.id}`, + ) + .select( + `${TableUser}.${FieldNameUser.username}`, + `${TableUser}.${FieldNameUser.guestUuid}`, + `${TableAuthorshipInfo}.${FieldNameAuthorshipInfo.createdAt}`, + ) + .distinct(`${TableAuthorshipInfo}.${FieldNameAuthorshipInfo.authorId}`) + .where(FieldNameAuthorshipInfo.revisionUuid, revisionUuid)) as { + username: User[FieldNameUser.username]; + guestUuid: User[FieldNameUser.guestUuid]; + createdAt: AuthorshipInfo[FieldNameAuthorshipInfo.createdAt]; + }[]; + const users: RevisionUserInfo['users'] = []; + let guestUserCount = 0; + for (const author of authorUsernamesAndGuestUuids) { + if (author.guestUuid !== null) { + guestUserCount++; + } + if (author.username !== null) { + users.push({ + username: author.username, + createdAt: author.createdAt, + }); + } } - 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), - ), - ), + users, + guestUserCount, }; } @@ -170,69 +291,101 @@ 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 transaction The optional pre-existing database transaction to use * @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 { + transaction?: Knex, + yjsStateVector?: ArrayBuffer, + ): Promise { + if (!transaction) { + await this.knex.transaction(async (newTransaction) => { + await this.innerCreateRevision( + noteId, + newContent, + newTransaction, + yjsStateVector, + ); + }); + return; + } + await this.innerCreateRevision( + noteId, + newContent, + transaction, + yjsStateVector, + ); + } + + private async innerCreateRevision( + noteId: number, + newContent: string, + transaction: Knex, + yjsStateVector?: ArrayBuffer, + ): Promise { const latestRevision = - note.id === undefined ? undefined : await this.getLatestRevision(note); + noteId === undefined + ? null + : await this.getLatestRevision(noteId, transaction); const oldContent = latestRevision?.content; if (oldContent === newContent) { return undefined; } + const primaryAlias = await this.aliasService.getPrimaryAliasByNoteId( + noteId, + transaction, + ); 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; - }); - - return Revision.create( - newContent, - patch, - note, - yjsStateVector ?? null, - title, - description, - tagEntities, - ) as Revision; + 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, + })), + ); } - /** - * 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); - } + async getTagsByRevisionUuid( + revisionUuid: string, + transaction?: Knex, + ): Promise { + const dbActor = transaction ?? this.knex; + const tags = await dbActor(TableRevisionTag) + .select(FieldNameRevisionTag.tag) + .where(FieldNameRevisionTag.revisionUuid, revisionUuid); + return tags.map((tag) => tag[FieldNameRevisionTag.tag]); } // Delete all old revisions everyday on 0:00 AM @@ -241,16 +394,14 @@ export class RevisionsService { return await this.removeOldRevisions(); } - // Delete all old revisions 5 sec after startup - @Timeout(5000) + // Delete all old revisions 90 sec after startup + @Timeout(90 * 1000) async handleRevisionCleanupTimeout(): Promise { return await this.removeOldRevisions(); } /** * Delete old {@link Revision}s except the latest one. - * - * @async */ async removeOldRevisions(): Promise { const currentTime = new Date().getTime(); @@ -258,56 +409,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..46769237f --- /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, PendingUserInfoDto } 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?: PendingUserInfoDto; +} 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..639b93ee0 100644 --- a/backend/src/users/users.module.ts +++ b/backend/src/users/users.module.ts @@ -4,16 +4,13 @@ * 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], 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..0e6d76b6c 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -4,199 +4,339 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { - FullUserInfoDto, - LoginUserInfoDto, - ProviderType, + AuthProviderType, REGEX_USERNAME, UserInfoDto, } from '@hedgedoc/commons'; -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +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, + // TODO Use generatePhotoUrl method to generate a random avatar image + [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 userId 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, + userId: number, 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.id, userId) + .update(updateData); + if (result !== 1) { + throw new NotInDBError( + `Failed to update user '${userId}'.`, + 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 getUserDtoByUsername(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 { + username: user[FieldNameUser.username], + displayName: + user[FieldNameUser.displayName] ?? user[FieldNameUser.username], + photoUrl: user[FieldNameUser.photoUrl], + }; + } + + /** + * 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 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; } /** - * @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 + * 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. */ - 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`); - } - 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 - */ - 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 + * 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 */ - toUserDto(user: User): UserInfoDto { + toLoginUserInfoDto( + user: User, + authProvider: AuthProviderType, + ): LoginUserInfoDto { return { - username: user.username, - displayName: user.displayName, - photoUrl: this.getPhotoUrl(user), + username: user[FieldNameUser.username], + displayName: + user[FieldNameUser.displayName] ?? user[FieldNameUser.username], + photoUrl: user[FieldNameUser.photoUrl], + email: user[FieldNameUser.email] ?? null, + authProvider, }; } - - /** - * Build FullUserInfoDto from a user. - * @param {User=} user - the user to use - * @return {(UserInfoDto)} the built FullUserInfoDto - */ - toFullUserDto(user: User): FullUserInfoDto { - return { - username: user.username, - displayName: user.displayName, - photoUrl: this.getPhotoUrl(user), - email: user.email ?? '', - }; - } - - 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..06d84ff67 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 = ''; @@ -87,7 +88,7 @@ describe('Alias', () => { primaryAlias: false, noteId: publicId, }); - expect(note.body.metadata.primaryAddress).toEqual(testAlias); + expect(note.body.metadata.primaryAlias).toEqual(testAlias); expect(note.body.metadata.id).toEqual(publicId); }); @@ -158,7 +159,7 @@ describe('Alias', () => { primaryAlias: true, noteId: publicId, }); - expect(note.body.metadata.primaryAddress).toEqual(newAlias); + expect(note.body.metadata.primaryAlias).toEqual(newAlias); expect(note.body.metadata.id).toEqual(publicId); }); diff --git a/backend/test/private-api/auth.e2e-spec.ts b/backend/test/private-api/auth.e2e-spec.ts index 76c244c87..6c9fed0a7 100644 --- a/backend/test/private-api/auth.e2e-spec.ts +++ b/backend/test/private-api/auth.e2e-spec.ts @@ -53,9 +53,10 @@ describe('Auth', () => { .set('Content-Type', 'application/json') .send(JSON.stringify(registrationDto)) .expect(201); - const newUser = await testSetup.userService.getUserByUsername(username, [ - UserRelationEnum.IDENTITIES, - ]); + const newUser = await testSetup.userService.getUserDtoByUsername( + username, + [UserRelationEnum.IDENTITIES], + ); expect(newUser.displayName).toEqual(displayName); await expect(newUser.identities).resolves.toHaveLength(1); await expect( @@ -115,7 +116,7 @@ describe('Auth', () => { .expect(400); expect(response.text).toContain('PasswordTooWeakError'); await expect(() => - testSetup.userService.getUserByUsername(username, [ + testSetup.userService.getUserDtoByUsername(username, [ UserRelationEnum.IDENTITIES, ]), ).rejects.toThrow(NotInDBError); 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 deleted file mode 100644 index 4eb13d530..000000000 --- a/backend/test/private-api/history.e2e-spec.ts +++ /dev/null @@ -1,226 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import request from 'supertest'; - -import { LocalService } from '../../src/auth/local/local.service'; -import { User } from '../../src/database/user.entity'; -import { HistoryEntryImportDto } from '../../src/history/history-entry-import.dto'; -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 { UsersService } from '../../src/users/users.service'; -import { TestSetup, TestSetupBuilder } from '../test-setup'; - -describe('History', () => { - let testSetup: TestSetup; - let historyService: HistoryService; - let localIdentityService: LocalService; - let user: User; - let note: Note; - let note2: Note; - let forbiddenNoteId: string; - let content: string; - let agent: request.SuperAgentTest; - - beforeAll(async () => { - testSetup = await TestSetupBuilder.create().build(); - - forbiddenNoteId = - testSetup.configService.get('noteConfig').forbiddenNoteIds[0]; - - const moduleRef = testSetup.moduleRef; - const username = 'hardcoded'; - const password = 'AHardcodedStrongP@ssword123'; - - await testSetup.app.init(); - content = 'This is a test note.'; - historyService = moduleRef.get(HistoryService); - const userService = moduleRef.get(UsersService); - localIdentityService = moduleRef.get(LocalService); - user = await userService.createUser(username, 'Testy', null, null); - await localIdentityService.createLocalIdentity(user, password); - const notesService = moduleRef.get(NotesService); - note = await notesService.createNote(content, user, 'note'); - note2 = await notesService.createNote(content, user, 'note2'); - agent = request.agent(testSetup.app.getHttpServer()); - await agent - .post('/api/private/auth/local/login') - .send({ username: username, password: password }) - .expect(201); - }); - - afterAll(async () => { - await testSetup.app.close(); - await testSetup.cleanup(); - }); - - it('GET /me/history', async () => { - const emptyResponse = await agent - .get('/api/private/me/history') - .expect('Content-Type', /json/) - .expect(200); - expect(emptyResponse.body.length).toEqual(0); - const entry = await testSetup.historyService.updateHistoryEntryTimestamp( - note, - user, - ); - const entryDto = await testSetup.historyService.toHistoryEntryDto(entry); - const response = await agent - .get('/api/private/me/history') - .expect('Content-Type', /json/) - .expect(200); - expect(response.body.length).toEqual(1); - expect(response.body[0].identifier).toEqual(entryDto.identifier); - expect(response.body[0].title).toEqual(entryDto.title); - expect(response.body[0].tags).toEqual(entryDto.tags); - expect(response.body[0].pinStatus).toEqual(entryDto.pinStatus); - expect(response.body[0].lastVisitedAt).toEqual( - entryDto.lastVisitedAt.toISOString(), - ); - }); - - describe('POST /me/history', () => { - it('works', async () => { - expect( - await testSetup.historyService.getEntriesByUser(user), - ).toHaveLength(1); - const pinStatus = true; - const lastVisited = new Date('2020-12-01 12:23:34'); - const postEntryDto = new HistoryEntryImportDto(); - postEntryDto.note = (await note2.aliases).filter( - (alias) => alias.primary, - )[0].name; - postEntryDto.pinStatus = pinStatus; - postEntryDto.lastVisitedAt = lastVisited; - await agent - .post('/api/private/me/history') - .set('Content-Type', 'application/json') - .send(JSON.stringify({ history: [postEntryDto] })) - .expect(201); - const userEntries = await testSetup.historyService.getEntriesByUser(user); - expect(userEntries.length).toEqual(1); - expect((await (await userEntries[0].note).aliases)[0].name).toEqual( - (await note2.aliases)[0].name, - ); - expect((await (await userEntries[0].note).aliases)[0].primary).toEqual( - (await note2.aliases)[0].primary, - ); - expect((await (await userEntries[0].note).aliases)[0].id).toEqual( - (await note2.aliases)[0].id, - ); - expect((await userEntries[0].user).username).toEqual(user.username); - expect(userEntries[0].pinStatus).toEqual(pinStatus); - expect(userEntries[0].updatedAt).toEqual(lastVisited); - }); - describe('fails', () => { - let pinStatus: boolean; - let lastVisited: Date; - let postEntryDto: HistoryEntryImportDto; - let prevEntry: HistoryEntry; - beforeAll(async () => { - const previousHistory = - await testSetup.historyService.getEntriesByUser(user); - expect(previousHistory).toHaveLength(1); - prevEntry = previousHistory[0]; - pinStatus = !previousHistory[0].pinStatus; - lastVisited = new Date('2020-12-01 23:34:45'); - postEntryDto = new HistoryEntryImportDto(); - postEntryDto.note = (await note2.aliases).filter( - (alias) => alias.primary, - )[0].name; - postEntryDto.pinStatus = pinStatus; - postEntryDto.lastVisitedAt = lastVisited; - }); - it('with forbiddenId', async () => { - const brokenEntryDto = new HistoryEntryImportDto(); - brokenEntryDto.note = forbiddenNoteId; - brokenEntryDto.pinStatus = pinStatus; - brokenEntryDto.lastVisitedAt = lastVisited; - await agent - .post('/api/private/me/history') - .set('Content-Type', 'application/json') - .send(JSON.stringify({ history: [brokenEntryDto] })) - .expect(400); - }); - it('with non-existing note', async () => { - const brokenEntryDto = new HistoryEntryImportDto(); - brokenEntryDto.note = 'i_dont_exist'; - brokenEntryDto.pinStatus = pinStatus; - brokenEntryDto.lastVisitedAt = lastVisited; - await agent - .post('/api/private/me/history') - .set('Content-Type', 'application/json') - .send(JSON.stringify({ history: [brokenEntryDto] })) - .expect(404); - }); - afterEach(async () => { - const historyEntries = - await testSetup.historyService.getEntriesByUser(user); - expect(historyEntries).toHaveLength(1); - expect(await (await historyEntries[0].note).aliases).toEqual( - await ( - await prevEntry.note - ).aliases, - ); - expect((await historyEntries[0].user).username).toEqual( - (await prevEntry.user).username, - ); - expect(historyEntries[0].pinStatus).toEqual(prevEntry.pinStatus); - expect(historyEntries[0].updatedAt).toEqual(prevEntry.updatedAt); - }); - }); - }); - - it('DELETE /me/history', async () => { - expect( - (await testSetup.historyService.getEntriesByUser(user)).length, - ).toEqual(1); - await agent.delete('/api/private/me/history').expect(204); - expect( - (await testSetup.historyService.getEntriesByUser(user)).length, - ).toEqual(0); - }); - - it('PUT /me/history/:note', async () => { - const entry = await testSetup.historyService.updateHistoryEntryTimestamp( - note2, - user, - ); - expect(entry.pinStatus).toBeFalsy(); - const alias = (await (await entry.note).aliases).filter( - (alias) => alias.primary, - )[0].name; - await agent - .put(`/api/private/me/history/${alias || 'null'}`) - .send({ pinStatus: true }) - .expect(200); - const userEntries = await testSetup.historyService.getEntriesByUser(user); - expect(userEntries.length).toEqual(1); - expect(userEntries[0].pinStatus).toBeTruthy(); - await testSetup.historyService.deleteHistoryEntry(note2, user); - }); - - it('DELETE /me/history/:note', async () => { - const entry = await historyService.updateHistoryEntryTimestamp(note2, user); - const alias = (await (await entry.note).aliases).filter( - (alias) => alias.primary, - )[0].name; - const entry2 = await historyService.updateHistoryEntryTimestamp(note, user); - const entryDto = await historyService.toHistoryEntryDto(entry2); - await agent - .delete(`/api/private/me/history/${alias || 'null'}`) - .expect(204); - const userEntries = await historyService.getEntriesByUser(user); - expect(userEntries.length).toEqual(1); - const userEntryDto = await historyService.toHistoryEntryDto(userEntries[0]); - expect(userEntryDto.identifier).toEqual(entryDto.identifier); - expect(userEntryDto.title).toEqual(entryDto.title); - expect(userEntryDto.tags).toEqual(entryDto.tags); - expect(userEntryDto.pinStatus).toEqual(entryDto.pinStatus); - expect(userEntryDto.lastVisitedAt).toEqual(entryDto.lastVisitedAt); - }); -}); diff --git a/backend/test/private-api/me.e2e-spec.ts b/backend/test/private-api/me.e2e-spec.ts index 30fefb391..dacc8b613 100644 --- a/backend/test/private-api/me.e2e-spec.ts +++ b/backend/test/private-api/me.e2e-spec.ts @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { LoginUserInfoDto, ProviderType } from '@hedgedoc/commons'; +import { AuthProviderType, LoginUserInfoDto } from '@hedgedoc/commons'; import { promises as fs } from 'fs'; import request from 'supertest'; @@ -58,7 +58,7 @@ describe('Me', () => { it('GET /me', async () => { const userInfo = testSetup.userService.toLoginUserInfoDto( user, - ProviderType.LOCAL, + AuthProviderType.LOCAL, ); const response = await agent .get('/api/private/me') @@ -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); } @@ -143,7 +144,8 @@ describe('Me', () => { displayName: newDisplayName, }) .expect(200); - const dbUser = await testSetup.userService.getUserByUsername('hardcoded'); + const dbUser = + await testSetup.userService.getUserDtoByUsername('hardcoded'); expect(dbUser.displayName).toEqual(newDisplayName); }); @@ -155,17 +157,19 @@ describe('Me', () => { user, note1, ); - const dbUser = await testSetup.userService.getUserByUsername('hardcoded'); + const dbUser = + await testSetup.userService.getUserDtoByUsername('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); await expect( - testSetup.userService.getUserByUsername('hardcoded'), + testSetup.userService.getUserDtoByUsername('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/media.e2e-spec.ts b/backend/test/private-api/media.e2e-spec.ts index 082b331dd..7e27bdb9b 100644 --- a/backend/test/private-api/media.e2e-spec.ts +++ b/backend/test/private-api/media.e2e-spec.ts @@ -187,7 +187,7 @@ describe('Media', () => { // upload a file with the default test user const testNote = await testSetup.notesService.createNote( 'test content', - await testSetup.userService.getUserByUsername(username2), + await testSetup.userService.getUserDtoByUsername(username2), 'test_delete_media_note', ); const testImage = await fs.readFile('test/private-api/fixtures/test.png'); diff --git a/backend/test/private-api/notes.e2e-spec.ts b/backend/test/private-api/notes.e2e-spec.ts index fabbe03d7..3cedfa003 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); }); }); @@ -249,7 +249,7 @@ describe('Notes', () => { .expect(200); expect(typeof metadata.body.id).toEqual('string'); expect(metadata.body.aliases[0].name).toEqual(noteAlias); - expect(metadata.body.primaryAddress).toEqual(noteAlias); + expect(metadata.body.primaryAlias).toEqual(noteAlias); expect(metadata.body.title).toEqual(''); expect(metadata.body.description).toEqual(''); expect(typeof metadata.body.createdAt).toEqual('string'); @@ -259,19 +259,19 @@ describe('Notes', () => { expect(metadata.body.permissions.sharedToUsers).toEqual([]); expect(metadata.body.tags).toEqual([]); expect(typeof metadata.body.updatedAt).toEqual('string'); - expect(typeof metadata.body.updateUsername).toEqual('string'); + expect(typeof metadata.body.lastUpdatedBy).toEqual('string'); expect(typeof metadata.body.viewCount).toEqual('number'); 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..bb7f5aff4 100644 --- a/backend/test/public-api/alias.e2e-spec.ts +++ b/backend/test/public-api/alias.e2e-spec.ts @@ -57,7 +57,7 @@ describe('Alias', () => { primaryAlias: false, noteId: publicId, }); - expect(note.body.metadata.primaryAddress).toEqual(testAlias); + expect(note.body.metadata.primaryAlias).toEqual(testAlias); expect(note.body.metadata.id).toEqual(publicId); }); @@ -136,7 +136,7 @@ describe('Alias', () => { primaryAlias: true, noteId: publicId, }); - expect(note.body.metadata.primaryAddress).toEqual(testAlias); + expect(note.body.metadata.primaryAlias).toEqual(testAlias); expect(note.body.metadata.id).toEqual(publicId); }); diff --git a/backend/test/public-api/me.e2e-spec.ts b/backend/test/public-api/me.e2e-spec.ts index c6c82d0ea..b5027d7e7 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/) @@ -181,19 +181,19 @@ describe('Me', () => { .expect(200); const noteMetaDtos = response.body as NoteMetadataDto[]; expect(noteMetaDtos).toHaveLength(1); - expect(noteMetaDtos[0].primaryAddress).toEqual(noteName); - expect(noteMetaDtos[0].updateUsername).toEqual(user.username); + expect(noteMetaDtos[0].primaryAlias).toEqual(noteName); + expect(noteMetaDtos[0].lastUpdatedBy).toEqual(user.username); }); it('GET /me/media', async () => { const note1 = await testSetup.notesService.createNote( 'This is a test note.', - await testSetup.userService.getUserByUsername('hardcoded'), + await testSetup.userService.getUserDtoByUsername('hardcoded'), 'test8', ); const note2 = await testSetup.notesService.createNote( 'This is a test note.', - await testSetup.userService.getUserByUsername('hardcoded'), + await testSetup.userService.getUserDtoByUsername('hardcoded'), 'test9', ); const httpServer = testSetup.app.getHttpServer(); diff --git a/backend/test/public-api/notes.e2e-spec.ts b/backend/test/public-api/notes.e2e-spec.ts index 9fcdf6676..400f93c00 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); @@ -320,7 +324,7 @@ describe('Notes', () => { .expect(200); expect(typeof metadata.body.id).toEqual('string'); expect(metadata.body.aliases[0].name).toEqual('test5'); - expect(metadata.body.primaryAddress).toEqual('test5'); + expect(metadata.body.primaryAlias).toEqual('test5'); expect(metadata.body.title).toEqual(''); expect(metadata.body.description).toEqual(''); expect(typeof metadata.body.createdAt).toEqual('string'); @@ -329,7 +333,7 @@ describe('Notes', () => { expect(metadata.body.permissions.sharedToUsers).toEqual([]); expect(metadata.body.tags).toEqual([]); expect(typeof metadata.body.updatedAt).toEqual('string'); - expect(typeof metadata.body.updateUsername).toEqual('string'); + expect(typeof metadata.body.lastUpdatedBy).toEqual('string'); expect(typeof metadata.body.viewCount).toEqual('number'); expect(metadata.body.editedBy).toEqual([]); }); @@ -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..91a9faf86 100644 --- a/backend/test/test-setup.ts +++ b/backend/test/test-setup.ts @@ -12,12 +12,14 @@ import { Test, TestingModule, TestingModuleBuilder } from '@nestjs/testing'; import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm'; import { Connection, createConnection } from 'typeorm'; -import { ApiTokenGuard } from '../src/api-token/api-token.guard'; +import { AliasModule } from '../src/alias/alias.module'; +import { AliasService } from '../src/alias/alias.service'; import { ApiTokenModule } from '../src/api-token/api-token.module'; import { ApiTokenService } from '../src/api-token/api-token.service'; -import { MockApiTokenGuard } from '../src/api-token/mock-api-token.guard'; import { PrivateApiModule } from '../src/api/private/private-api.module'; import { PublicApiModule } from '../src/api/public/public-api.module'; +import { ApiTokenGuard } from '../src/api/utils/guards/api-token.guard'; +import { MockApiTokenGuard } from '../src/api/utils/guards/mock-api-token.guard'; import { setupApp } from '../src/app-init'; import { AuthModule } from '../src/auth/auth.module'; import { IdentityService } from '../src/auth/identity.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..66ce1fcd2 100644 --- a/commons/src/dtos/note/note-metadata.dto.ts +++ b/commons/src/dtos/note/note-metadata.dto.ts @@ -5,18 +5,12 @@ */ import { z } from 'zod' -import { AliasSchema } from '../alias/index.js' import { NotePermissionsSchema } from '../permissions/index.js' export const NoteMetadataSchema = z .object({ - id: z.string().describe('The id of the note'), - 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.', - ), + aliases: z.array(z.string()).describe('All aliases of the note'), + primaryAlias: z.string().describe('The primary address/alias of the note.'), title: z .string() .describe( @@ -35,13 +29,10 @@ export const NoteMetadataSchema = z .string() .datetime() .describe('The timestamp when the note was last updated'), - updateUsername: z + lastUpdatedBy: z .string() .nullable() .describe('The user that last updated the note'), - viewCount: z - .number() - .describe('Counts how many times the note has been viewed'), createdAt: z .string() .datetime() 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/index.ts b/commons/src/dtos/user/index.ts index 99f8e5a8a..57fd61c50 100644 --- a/commons/src/dtos/user/index.ts +++ b/commons/src/dtos/user/index.ts @@ -3,8 +3,8 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -export * from './full-user-info.dto.js' -export * from './full-user-info-with-id.dto.js' +export * from './pending-user-info.dto.js' +export * from './pending-ldap-user-info.dto.js' export * from './login-user-info.dto.js' export * from './update-user-info.dto.js' export * from './user-info.dto.js' diff --git a/commons/src/dtos/user/login-user-info.dto.ts b/commons/src/dtos/user/login-user-info.dto.ts index 093e0d8c3..e3be12d5a 100644 --- a/commons/src/dtos/user/login-user-info.dto.ts +++ b/commons/src/dtos/user/login-user-info.dto.ts @@ -5,14 +5,19 @@ */ import { z } from 'zod' -import { ProviderType } from '../auth/index.js' -import { FullUserInfoSchema } from './full-user-info.dto.js' +import { AuthProviderType } from '../auth/index.js' +import { UserInfoSchema } from './user-info.dto.js' -export const LoginUserInfoSchema = FullUserInfoSchema.merge( +export const LoginUserInfoSchema = UserInfoSchema.merge( z.object({ authProvider: z - .nativeEnum(ProviderType) + .nativeEnum(AuthProviderType) .describe('The type of login provider used for the current session'), + email: z + .string() + .email() + .nullable() + .describe('The email address of the user if known'), }), ).describe( 'Information about the user and their auth method for the current session', diff --git a/commons/src/dtos/user/full-user-info-with-id.dto.ts b/commons/src/dtos/user/pending-ldap-user-info.dto.ts similarity index 60% rename from commons/src/dtos/user/full-user-info-with-id.dto.ts rename to commons/src/dtos/user/pending-ldap-user-info.dto.ts index 685d737ea..e15ab8092 100644 --- a/commons/src/dtos/user/full-user-info-with-id.dto.ts +++ b/commons/src/dtos/user/pending-ldap-user-info.dto.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { z } from 'zod' -import { FullUserInfoSchema } from './full-user-info.dto.js' +import { PendingUserInfoSchema } from './pending-user-info.dto.js' -export const FullUserInfoWithIdSchema = FullUserInfoSchema.merge( +export const PendingLdapUserInfoSchema = PendingUserInfoSchema.merge( z.object({ id: z.string().describe('The id from the LDAP server'), }), @@ -14,4 +14,4 @@ export const FullUserInfoWithIdSchema = FullUserInfoSchema.merge( 'The full user information with id is only used during the LDAP login process', ) -export type FullUserInfoWithIdDto = z.infer +export type PendingLdapUserInfoDto = z.infer diff --git a/commons/src/dtos/user/full-user-info.dto.ts b/commons/src/dtos/user/pending-user-info.dto.ts similarity index 71% rename from commons/src/dtos/user/full-user-info.dto.ts rename to commons/src/dtos/user/pending-user-info.dto.ts index 05b209def..f9aaf9b4e 100644 --- a/commons/src/dtos/user/full-user-info.dto.ts +++ b/commons/src/dtos/user/pending-user-info.dto.ts @@ -6,16 +6,17 @@ import { z } from 'zod' import { UserInfoSchema } from './user-info.dto.js' -export const FullUserInfoSchema = UserInfoSchema.merge( +export const PendingUserInfoSchema = UserInfoSchema.merge( z.object({ email: z .string() .email() .nullable() .describe('The email address of the user if known'), + username: z.string().describe('The username of the user'), }), ).describe( 'The full user information is only presented to the logged in user itself. For privacy reasons the email address is only here', ) -export type FullUserInfoDto = z.infer +export type PendingUserInfoDto = z.infer 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/disconnect_reason.ts b/commons/src/message-transporters/disconnect_reason.ts index 5ff98cf1e..46c280948 100644 --- a/commons/src/message-transporters/disconnect_reason.ts +++ b/commons/src/message-transporters/disconnect_reason.ts @@ -6,4 +6,5 @@ export enum DisconnectReason { USER_NOT_PERMITTED = 4000, + SESSION_NOT_FOUND = 4001, } 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/permissions/permissions.ts b/commons/src/permissions/permissions.ts index 23544e9ec..524a0b632 100644 --- a/commons/src/permissions/permissions.ts +++ b/commons/src/permissions/permissions.ts @@ -14,28 +14,28 @@ import { NotePermissionsDto, SpecialGroup } from '../dtos/index.js' */ export const userIsOwner = ( permissions: NotePermissionsDto, - user?: string, + username: string | null | undefined, ): boolean => { - return !!user && permissions.owner === user + return !!username && permissions.owner === username } /** * Checks if the given user may edit a note. * * @param permissions The permissions of the note to check - * @param user The username of the user + * @param username The username of the user * @return True if the user has the permission to edit the note */ export const userCanEdit = ( permissions: NotePermissionsDto, - user?: string, + username: string | null | undefined, ): boolean => { - const isOwner = userIsOwner(permissions, user) + const isOwner = userIsOwner(permissions, username) const mayWriteViaUserPermission = permissions.sharedToUsers.some( - (value) => value.canEdit && value.username === user, + (value) => value.canEdit && value.username === username, ) const mayWriteViaGroupPermission = - !!user && + !!username && permissions.sharedToGroups.some( (value) => value.groupName === (SpecialGroup.LOGGED_IN as string) && value.canEdit, 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/cypress/e2e/signInButton.spec.ts b/frontend/cypress/e2e/signInButton.spec.ts index 5d3675c9e..2c317cc3c 100644 --- a/frontend/cypress/e2e/signInButton.spec.ts +++ b/frontend/cypress/e2e/signInButton.spec.ts @@ -3,10 +3,10 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import type { ProviderType } from '../../src/api/config/types' -import { ProviderType } from '../../src/api/config/types' +import type { AuthProviderType } from '../../src/api/config/types' +import { AuthProviderType } from '../../src/api/config/types' -const initLoggedOutTestWithCustomAuthProviders = (cy: Cypress.cy, enabledProviders: ProviderType[]) => { +const initLoggedOutTestWithCustomAuthProviders = (cy: Cypress.cy, enabledProviders: AuthProviderType[]) => { cy.logOut() cy.loadConfig({ authProviders: enabledProviders @@ -48,7 +48,7 @@ describe('When logged-out ', () => { it('sign-in button points to login route: internal', () => { initLoggedOutTestWithCustomAuthProviders(cy, [ { - type: ProviderType.LOCAL + type: AuthProviderType.LOCAL } ]) cy.getByCypressId('sign-in-button') @@ -60,7 +60,7 @@ describe('When logged-out ', () => { it('sign-in button points to login route: ldap', () => { initLoggedOutTestWithCustomAuthProviders(cy, [ { - type: ProviderType.LDAP, + type: AuthProviderType.LDAP, identifier: 'cy-ldap', providerName: 'cy LDAP' } @@ -76,7 +76,7 @@ describe('When logged-out ', () => { it('sign-in button points to auth-provider', () => { initLoggedOutTestWithCustomAuthProviders(cy, [ { - type: ProviderType.OIDC, + type: AuthProviderType.OIDC, identifier: 'github', providerName: 'GitHub', theme: 'github' @@ -94,13 +94,13 @@ describe('When logged-out ', () => { it('sign-in button points to login route', () => { initLoggedOutTestWithCustomAuthProviders(cy, [ { - type: ProviderType.OIDC, + type: AuthProviderType.OIDC, identifier: 'github', providerName: 'GitHub', theme: 'github' }, { - type: ProviderType.OIDC, + type: AuthProviderType.OIDC, identifier: 'gitlab', providerName: 'GitLab', theme: 'gitlab' @@ -117,13 +117,13 @@ describe('When logged-out ', () => { it('sign-in button points to login route', () => { initLoggedOutTestWithCustomAuthProviders(cy, [ { - type: ProviderType.OIDC, + type: AuthProviderType.OIDC, identifier: 'github', providerName: 'GitHub', theme: 'github' }, { - type: ProviderType.LOCAL + type: AuthProviderType.LOCAL } ]) cy.getByCypressId('sign-in-button') diff --git a/frontend/cypress/support/config.ts b/frontend/cypress/support/config.ts index 72e83211d..5ed04db12 100644 --- a/frontend/cypress/support/config.ts +++ b/frontend/cypress/support/config.ts @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { ProviderType } from '@hedgedoc/commons' +import { AuthProviderType } from '@hedgedoc/commons' import { HttpMethod } from '../../src/handler-utils/respond-to-matching-request' import { IGNORE_MOTD, MOTD_LOCAL_STORAGE_KEY } from '../../src/components/global-dialogs/motd-modal/local-storage-keys' @@ -22,15 +22,15 @@ export const branding = { export const authProviders = [ { - type: ProviderType.LOCAL + type: AuthProviderType.LOCAL }, { - type: ProviderType.LDAP, + type: AuthProviderType.LDAP, identifier: 'test-ldap', providerName: 'Test LDAP' }, { - type: ProviderType.OIDC, + type: AuthProviderType.OIDC, identifier: 'test-oidc', providerName: 'Test OIDC' } diff --git a/frontend/cypress/support/visit-test-editor.ts b/frontend/cypress/support/visit-test-editor.ts index dfa2ef40b..420239e89 100644 --- a/frontend/cypress/support/visit-test-editor.ts +++ b/frontend/cypress/support/visit-test-editor.ts @@ -15,12 +15,12 @@ const mockMetadata = { noteId: testNoteId } ], - primaryAddress: 'mock-note', + primaryAlias: 'mock-note', title: 'Mock Note', description: 'Mocked note for testing', tags: ['test', 'mock', 'cypress'], updatedAt: '2021-04-24T09:27:51.000Z', - updateUsername: null, + lastUpdatedBy: null, viewCount: 0, version: 2, createdAt: '2021-04-24T09:27:51.000Z', diff --git a/frontend/src/api/alias/index.ts b/frontend/src/api/alias/index.ts index 258e5751b..31f94652b 100644 --- a/frontend/src/api/alias/index.ts +++ b/frontend/src/api/alias/index.ts @@ -11,15 +11,15 @@ import type { AliasDto, AliasCreateDto, AliasUpdateDto } from '@hedgedoc/commons /** * Adds an alias to an existing note. * - * @param noteIdOrAlias The note id or an existing alias for a note. + * @param noteAlias The note id or an existing alias for a note. * @param newAlias The new alias. * @return Information about the newly created alias. * @throws {Error} when the api request wasn't successful */ -export const addAlias = async (noteIdOrAlias: string, newAlias: string): Promise => { +export const addAlias = async (noteAlias: string, newAlias: string): Promise => { const response = await new PostApiRequestBuilder('alias') .withJsonBody({ - noteIdOrAlias, + noteAlias, newAlias }) .sendRequest() diff --git a/frontend/src/api/auth/pending-user.ts b/frontend/src/api/auth/pending-user.ts index 5b9a92681..e954a8fb8 100644 --- a/frontend/src/api/auth/pending-user.ts +++ b/frontend/src/api/auth/pending-user.ts @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import type { FullUserInfoDto } from '@hedgedoc/commons' +import type { PendingUserInfoDto } from '@hedgedoc/commons' import { GetApiRequestBuilder } from '../common/api-request-builder/get-api-request-builder' import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder' import type { PendingUserConfirmationDto } from '@hedgedoc/commons' @@ -13,8 +13,8 @@ import { PutApiRequestBuilder } from '../common/api-request-builder/put-api-requ * Fetches the pending user information. * @returns The pending user information. */ -export const getPendingUserInfo = async (): Promise => { - const response = await new GetApiRequestBuilder('auth/pending-user').sendRequest() +export const getPendingUserInfo = async (): Promise => { + const response = await new GetApiRequestBuilder('auth/pending-user').sendRequest() return response.asParsedJsonObject() } diff --git a/frontend/src/api/revisions/index.ts b/frontend/src/api/revisions/index.ts index d5acc4f28..fc8639341 100644 --- a/frontend/src/api/revisions/index.ts +++ b/frontend/src/api/revisions/index.ts @@ -15,7 +15,7 @@ import type { RevisionDto, RevisionMetadataDto } from '@hedgedoc/commons' * @return The revision. * @throws {Error} when the api request wasn't successful. */ -export const getRevision = async (noteId: string, revisionId: number): Promise => { +export const getRevision = async (noteId: string, revisionId: string): Promise => { const response = await new GetApiRequestBuilder(`notes/${noteId}/revisions/${revisionId}`).sendRequest() return response.asParsedJsonObject() } diff --git a/frontend/src/app/(editor)/new/page.tsx b/frontend/src/app/(editor)/new/page.tsx index 579fbb323..df9c17068 100644 --- a/frontend/src/app/(editor)/new/page.tsx +++ b/frontend/src/app/(editor)/new/page.tsx @@ -36,7 +36,7 @@ const NewNotePage: NextPage = () => { descriptionI18nKey={'errors.noteCreationFailed.description'} /> }> - {value !== undefined && } + {value !== undefined && } ) } diff --git a/frontend/src/app/(editor)/profile/page.tsx b/frontend/src/app/(editor)/profile/page.tsx index 01d67ba46..02b36a6b1 100644 --- a/frontend/src/app/(editor)/profile/page.tsx +++ b/frontend/src/app/(editor)/profile/page.tsx @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only */ -import { ProviderType } from '@hedgedoc/commons' +import { AuthProviderType } from '@hedgedoc/commons' import { Redirect } from '../../../components/common/redirect' import { LandingLayout } from '../../../components/landing-layout/landing-layout' import { ProfileAccessTokens } from '../../../components/profile-page/access-tokens/profile-access-tokens' @@ -40,7 +40,7 @@ const ProfilePage: NextPage = () => { - {userProvider === ProviderType.LOCAL && } + {userProvider === AuthProviderType.LOCAL && } diff --git a/frontend/src/components/common/html-to-react/html-to-react.tsx b/frontend/src/components/common/html-to-react/html-to-react.tsx index e73486793..2c84447fd 100644 --- a/frontend/src/components/common/html-to-react/html-to-react.tsx +++ b/frontend/src/components/common/html-to-react/html-to-react.tsx @@ -6,8 +6,10 @@ import { measurePerformance } from '../../../utils/measure-performance' import type { ParserOptions } from '@hedgedoc/html-to-react' import { convertHtmlToReact } from '@hedgedoc/html-to-react' -import type DOMPurify from 'dompurify' -import { sanitize } from 'dompurify' +import DOMPurify from 'dompurify' +// see https://github.com/cure53/DOMPurify/issues/1034#issuecomment-2493211056 +// eslint-disable-next-line @typescript-eslint/unbound-method +const { sanitize } = DOMPurify import React, { Fragment, useMemo } from 'react' export interface HtmlToReactProps { 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..97c33909d 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' /** @@ -27,14 +27,14 @@ export const NewNoteButton: React.FC = () => { const createNewNoteAndRedirect = useCallback((): void => { createNote('') .then((note) => { - router?.push(`/n/${note.metadata.primaryAddress}`) + router?.push(`/n/${note.metadata.primaryAlias}`) }) .catch((error: Error) => { showErrorNotification(error.message) }) }, [router, showErrorNotification]) - if (!isLoggedIn && guestAccessLevel !== GuestAccess.CREATE) { + if (!isLoggedIn && guestAccessLevel !== PermissionLevel.CREATE) { return null } diff --git a/frontend/src/components/common/note-loading-boundary/create-non-existing-note-hint.spec.tsx b/frontend/src/components/common/note-loading-boundary/create-non-existing-note-hint.spec.tsx index cc4765981..d3f00dc23 100644 --- a/frontend/src/components/common/note-loading-boundary/create-non-existing-note-hint.spec.tsx +++ b/frontend/src/components/common/note-loading-boundary/create-non-existing-note-hint.spec.tsx @@ -23,7 +23,7 @@ describe('create non existing note hint', () => { .mockImplementation(async (markdown, primaryAlias): Promise => { expect(markdown).toBe('') expect(primaryAlias).toBe(mockedNoteId) - const metadata: NoteMetadataDto = Mock.of({ primaryAddress: 'mockedPrimaryAlias' }) + const metadata: NoteMetadataDto = Mock.of({ primaryAlias: 'mockedPrimaryAlias' }) await new Promise((resolve) => setTimeout(resolve, 100)) await waitForOtherPromisesToFinish() return Mock.of({ metadata }) diff --git a/frontend/src/components/document-read-only-page/document-infobar.tsx b/frontend/src/components/document-read-only-page/document-infobar.tsx index 4c96d3919..90e5fdb2b 100644 --- a/frontend/src/components/document-read-only-page/document-infobar.tsx +++ b/frontend/src/components/document-read-only-page/document-infobar.tsx @@ -11,7 +11,6 @@ import { NoteInfoLineUpdatedBy } from '../editor-page/sidebar/specific-sidebar-e import styles from './document-infobar.module.scss' import React from 'react' import { Pencil as IconPencil } from 'react-bootstrap-icons' -import { Trans } from 'react-i18next' /** * Renders an info bar with metadata about the current note. @@ -34,10 +33,9 @@ export const DocumentInfobar: React.FC = () => {
- {noteDetails.viewCount} { : t('editor.upload.uploadFile.withoutDescription', { fileName: file.name }) const uploadPlaceholder = `![${uploadFileInfo}](upload-${randomId}${additionalUrlText ?? ''})` - const noteId = getGlobalState().noteDetails?.id - if (noteId === undefined) { + const noteAlias = getGlobalState().noteDetails?.primaryAlias + if (noteAlias === undefined) { return } changeContent(({ currentSelection }) => { return replaceSelection(cursorSelection ?? currentSelection, uploadPlaceholder, false) }) - uploadFile(noteId, file) + uploadFile(noteAlias, file) .then(({ uuid }) => { const fullUrl = `${baseUrl}media/${uuid}` const replacement = `![${description ?? file.name ?? ''}](${fullUrl}${additionalUrlText ?? ''})` diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-websocket-url.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-websocket-url.ts index 0f514e8f8..2aa2a9dec 100644 --- a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-websocket-url.ts +++ b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-websocket-url.ts @@ -14,7 +14,7 @@ const LOCAL_FALLBACK_URL = 'ws://localhost:8080/realtime/' * Provides the URL for the realtime endpoint. */ export const useWebsocketUrl = (): URL | null => { - const noteId = useApplicationState((state) => state.noteDetails?.id) + const noteAlias = useApplicationState((state) => state.noteDetails?.primaryAlias) const baseUrl = useBaseUrl() const websocketUrl = useMemo(() => { @@ -33,11 +33,11 @@ export const useWebsocketUrl = (): URL | null => { }, [baseUrl]) return useMemo(() => { - if (noteId === '' || noteId === undefined) { + if (noteAlias === '' || noteAlias === undefined) { return null } const url = new URL(websocketUrl) - url.search = `?noteId=${noteId}` + url.search = `?noteAlias=${noteAlias}` return url - }, [noteId, websocketUrl]) + }, [noteAlias, websocketUrl]) } diff --git a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/media-browser-sidebar-menu/media-browser-sidebar-menu.tsx b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/media-browser-sidebar-menu/media-browser-sidebar-menu.tsx index 6ea95f924..95744726f 100644 --- a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/media-browser-sidebar-menu/media-browser-sidebar-menu.tsx +++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/media-browser-sidebar-menu/media-browser-sidebar-menu.tsx @@ -34,7 +34,7 @@ export const MediaBrowserSidebarMenu: React.FC = ({ selectedMenuId }) => { useTranslation() - const noteId = useApplicationState((state) => state.noteDetails?.id ?? '') + const noteAlias = useApplicationState((state) => state.noteDetails?.primaryAlias ?? '') const [mediaEntryForDeletion, setMediaEntryForDeletion] = useState(null) const hide = selectedMenuId !== DocumentSidebarMenuSelection.NONE && selectedMenuId !== menuId @@ -43,7 +43,7 @@ export const MediaBrowserSidebarMenu: React.FC = ({ onClick(menuId) }, [menuId, onClick]) - const { value, loading, error } = useAsync(() => getMediaForNote(noteId), [expand, noteId]) + const { value, loading, error } = useAsync(() => getMediaForNote(noteAlias), [expand, noteAlias]) const mediaEntries = useMemo(() => { if (loading || error || !value) { diff --git a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/note-info-sidebar-menu/note-info-line/note-info-line-updated-by.tsx b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/note-info-sidebar-menu/note-info-line/note-info-line-updated-by.tsx index 8a88588d9..ff061c84d 100644 --- a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/note-info-sidebar-menu/note-info-line/note-info-line-updated-by.tsx +++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/note-info-sidebar-menu/note-info-line/note-info-line-updated-by.tsx @@ -15,7 +15,7 @@ import { Trans, useTranslation } from 'react-i18next' */ export const NoteInfoLineUpdatedBy: React.FC = () => { useTranslation() - const noteUpdateUser = useApplicationState((state) => state.noteDetails?.updateUsername) + const noteUpdateUser = useApplicationState((state) => state.noteDetails?.lastUpdatedBy) const userBlock = useMemo(() => { if (!noteUpdateUser) { 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..2d9b8f049 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 } @@ -38,7 +38,7 @@ export const PermissionEntrySpecialGroup: React.FC { - const noteId = useApplicationState((state) => state.noteDetails?.primaryAddress) + const noteId = useApplicationState((state) => state.noteDetails?.primaryAlias) const { t } = useTranslation() const { showErrorNotification } = useUiNotifications() @@ -98,7 +98,7 @@ export const PermissionEntrySpecialGroup: React.FC { - const noteId = useApplicationState((state) => state.noteDetails?.primaryAddress) + const noteId = useApplicationState((state) => state.noteDetails?.primaryAlias) const { showErrorNotification } = useUiNotifications() const { [SpecialGroup.EVERYONE]: everyonePermission, [SpecialGroup.LOGGED_IN]: loggedInPermission } = useGetSpecialPermissions() @@ -94,7 +94,7 @@ export const PermissionEntryUser: 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/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/revision-list.tsx b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/revision-list.tsx index 71898b16c..23d523a1d 100644 --- a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/revision-list.tsx +++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/revision-list.tsx @@ -12,11 +12,11 @@ import React, { useMemo } from 'react' import { ListGroup } from 'react-bootstrap' interface RevisionListProps { - selectedRevisionId?: number + selectedRevisionId?: string revisions?: RevisionMetadataDto[] loadingRevisions: boolean error?: Error | boolean - onRevisionSelect: (selectedRevisionId: number) => void + onRevisionSelect: (selectedRevisionId: string) => void } /** @@ -47,10 +47,10 @@ export const RevisionList: React.FC = ({ }) .map((revisionListEntry) => ( onRevisionSelect(revisionListEntry.id)} + active={selectedRevisionId === revisionListEntry.uuid} + onSelect={() => onRevisionSelect(revisionListEntry.uuid)} revision={revisionListEntry} - key={revisionListEntry.id} + key={revisionListEntry.uuid} /> )) }, [loadingRevisions, onRevisionSelect, revisions, selectedRevisionId]) diff --git a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/revision-modal-body.tsx b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/revision-modal-body.tsx index f7844a953..acedbd5b1 100644 --- a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/revision-modal-body.tsx +++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/revision-modal-body.tsx @@ -26,18 +26,18 @@ import { useAsync } from 'react-use' export const RevisionModalBody = ({ onShowDeleteModal, onHide }: RevisionModal) => { useTranslation() const isOwner = useIsOwner() - const [selectedRevisionId, setSelectedRevisionId] = useState() - const noteId = useApplicationState((state) => state.noteDetails?.id) + const [selectedRevisionId, setSelectedRevisionId] = useState() + const noteAlias = useApplicationState((state) => state.noteDetails?.primaryAlias) const { value: revisions, error, loading } = useAsync(async () => { - if (!noteId) { + if (!noteAlias) { return [] } - return getAllRevisions(noteId) - }, [noteId]) + return getAllRevisions(noteAlias) + }, [noteAlias]) const revisionLength = revisions?.length ?? 0 const enableDeleteRevisions = revisionLength > 1 && isOwner diff --git a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/revision-modal-footer.tsx b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/revision-modal-footer.tsx index 610929d45..a3f76fcb2 100644 --- a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/revision-modal-footer.tsx +++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/revision-modal-footer.tsx @@ -15,7 +15,7 @@ import { Button, Modal } from 'react-bootstrap' import { Trans, useTranslation } from 'react-i18next' interface RevisionModalFooter { - selectedRevisionId?: number + selectedRevisionId?: string disableDeleteRevisions: boolean } @@ -37,7 +37,7 @@ export const RevisionModalFooter: React.FC = ({ disableDeleteRevisions }) => { useTranslation() - const noteId = useApplicationState((state) => state.noteDetails?.id) + const noteAlias = useApplicationState((state) => state.noteDetails?.primaryAlias) const { showErrorNotification } = useUiNotifications() const onRevertToRevision = useCallback(() => { @@ -47,15 +47,15 @@ export const RevisionModalFooter: React.FC = ({ }, []) const onDownloadRevision = useCallback(() => { - if (selectedRevisionId === undefined || noteId === undefined) { + if (selectedRevisionId === undefined || noteAlias === undefined) { return } - getRevision(noteId, selectedRevisionId) + getRevision(noteAlias, selectedRevisionId) .then((revision) => { - downloadRevision(noteId, revision) + downloadRevision(noteAlias, revision) }) .catch(showErrorNotification('')) - }, [noteId, selectedRevisionId, showErrorNotification]) + }, [noteAlias, selectedRevisionId, showErrorNotification]) const openDeleteModal = useCallback(() => { onHide?.() diff --git a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/revision-viewer.tsx b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/revision-viewer.tsx index e314f8505..03ed784d5 100644 --- a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/revision-viewer.tsx +++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/revision-viewer.tsx @@ -14,7 +14,7 @@ import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer' import { useAsync } from 'react-use' export interface RevisionViewerProps { - selectedRevisionId?: number + selectedRevisionId?: string } /** @@ -24,7 +24,7 @@ export interface RevisionViewerProps { * @param allRevisions List of metadata for all available revisions. */ export const RevisionViewer: React.FC = ({ selectedRevisionId }) => { - const noteId = useApplicationState((state) => state.noteDetails?.id) + const noteAlias = useApplicationState((state) => state.noteDetails?.primaryAlias) const darkModeEnabled = useDarkModeState() const { @@ -32,10 +32,10 @@ export const RevisionViewer: React.FC = ({ selectedRevision error, loading } = useAsync(async () => { - if (noteId && selectedRevisionId !== undefined) { - return await getRevision(noteId, selectedRevisionId) + if (noteAlias && selectedRevisionId !== undefined) { + return await getRevision(noteAlias, selectedRevisionId) } - }, [selectedRevisionId, noteId]) + }, [selectedRevisionId, noteAlias]) const previousRevisionContent = useMemo(() => { if (revision === undefined) { @@ -49,7 +49,7 @@ export const RevisionViewer: React.FC = ({ selectedRevision return applyPatch(revision.content, inversePatch) || '' }, [revision]) - if (!noteId || selectedRevisionId === undefined) { + if (!noteAlias || selectedRevisionId === undefined) { return } diff --git a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/share-note-sidebar-entry/share-modal/note-url-field.tsx b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/share-note-sidebar-entry/share-modal/note-url-field.tsx index 1150791ba..1cf59072e 100644 --- a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/share-note-sidebar-entry/share-modal/note-url-field.tsx +++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/share-note-sidebar-entry/share-modal/note-url-field.tsx @@ -24,16 +24,16 @@ export interface LinkFieldProps { */ export const NoteUrlField: React.FC = ({ type }) => { const baseUrl = useBaseUrl() - const noteId = useApplicationState((state) => state.noteDetails?.id) + const noteAlias = useApplicationState((state) => state.noteDetails?.primaryAlias) const url = useMemo(() => { - if (noteId === undefined) { + if (noteAlias === undefined) { return null } const url = new URL(baseUrl) - url.pathname += `${type}/${noteId}` + url.pathname += `${type}/${noteAlias}` return url.toString() - }, [baseUrl, noteId, type]) + }, [baseUrl, noteAlias, type]) return !url ? null : } 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/components/login-page/ldap/ldap-login-cards.tsx b/frontend/src/components/login-page/ldap/ldap-login-cards.tsx index f8c6fde78..b3ba4a8f5 100644 --- a/frontend/src/components/login-page/ldap/ldap-login-cards.tsx +++ b/frontend/src/components/login-page/ldap/ldap-login-cards.tsx @@ -7,7 +7,7 @@ import React, { Fragment, useMemo } from 'react' import { useFrontendConfig } from '../../common/frontend-config-context/use-frontend-config' import type { AuthProviderWithCustomNameDto } from '@hedgedoc/commons' -import { ProviderType } from '@hedgedoc/commons' +import { AuthProviderType } from '@hedgedoc/commons' import { LdapLoginCard } from './ldap-login-card' /** @@ -18,7 +18,7 @@ export const LdapLoginCards: React.FC = () => { const ldapProviders = useMemo(() => { return authProviders - .filter((provider) => provider.type === ProviderType.LDAP) + .filter((provider) => provider.type === AuthProviderType.LDAP) .map((provider) => { const ldapProvider = provider as AuthProviderWithCustomNameDto return ( diff --git a/frontend/src/components/login-page/local-login/local-login-card.tsx b/frontend/src/components/login-page/local-login/local-login-card.tsx index fca5c1700..df32e6d9d 100644 --- a/frontend/src/components/login-page/local-login/local-login-card.tsx +++ b/frontend/src/components/login-page/local-login/local-login-card.tsx @@ -7,7 +7,7 @@ import React, { useMemo } from 'react' import { Card } from 'react-bootstrap' import { useFrontendConfig } from '../../common/frontend-config-context/use-frontend-config' -import { ProviderType } from '@hedgedoc/commons' +import { AuthProviderType } from '@hedgedoc/commons' import { LocalLoginCardBody } from './local-login-card-body' import { LocalRegisterCardBody } from './register/local-register-card-body' @@ -18,7 +18,7 @@ export const LocalLoginCard: React.FC = () => { const frontendConfig = useFrontendConfig() const localLoginEnabled = useMemo(() => { - return frontendConfig.authProviders.some((provider) => provider.type === ProviderType.LOCAL) + return frontendConfig.authProviders.some((provider) => provider.type === AuthProviderType.LOCAL) }, [frontendConfig]) if (!localLoginEnabled) { diff --git a/frontend/src/components/login-page/one-click/get-one-click-provider-metadata.ts b/frontend/src/components/login-page/one-click/get-one-click-provider-metadata.ts index f06377d89..0817c3d72 100644 --- a/frontend/src/components/login-page/one-click/get-one-click-provider-metadata.ts +++ b/frontend/src/components/login-page/one-click/get-one-click-provider-metadata.ts @@ -17,7 +17,7 @@ import { } from 'react-bootstrap-icons' import { Logger } from '../../../utils/logger' import type { AuthProviderDto } from '@hedgedoc/commons' -import { ProviderType } from '@hedgedoc/commons' +import { AuthProviderType } from '@hedgedoc/commons' import { IconGitlab } from '../../common/icons/additional/icon-gitlab' import styles from './one-click-login-button.module.scss' @@ -37,7 +37,7 @@ const logger = new Logger('GetOneClickProviderMetadata') * @return Name, icon, URL and CSS class of the given provider for rendering a login button. */ export const getOneClickProviderMetadata = (provider: AuthProviderDto): OneClickMetadata => { - if (provider.type !== ProviderType.OIDC) { + if (provider.type !== AuthProviderType.OIDC) { logger.warn('Metadata for one-click-provider does not exist', provider) return { name: '', diff --git a/frontend/src/components/login-page/utils/filter-one-click-providers.ts b/frontend/src/components/login-page/utils/filter-one-click-providers.ts index 0eb7baa57..280af1955 100644 --- a/frontend/src/components/login-page/utils/filter-one-click-providers.ts +++ b/frontend/src/components/login-page/utils/filter-one-click-providers.ts @@ -5,7 +5,7 @@ */ import type { AuthProviderDto } from '@hedgedoc/commons' -import { ProviderType } from '@hedgedoc/commons' +import { AuthProviderType } from '@hedgedoc/commons' /** * Filters the given auth providers to one-click providers only. @@ -13,5 +13,5 @@ import { ProviderType } from '@hedgedoc/commons' * @return only one click auth providers */ export const filterOneClickProviders = (authProviders: AuthProviderDto[]) => { - return authProviders.filter((provider: AuthProviderDto): boolean => provider.type === ProviderType.OIDC) + return authProviders.filter((provider: AuthProviderDto): boolean => provider.type === AuthProviderType.OIDC) } diff --git a/frontend/src/pages/api/private/config.ts b/frontend/src/pages/api/private/config.ts index d626d22a5..0cd3ae83e 100644 --- a/frontend/src/pages/api/private/config.ts +++ b/frontend/src/pages/api/private/config.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import type { FrontendConfigDto } from '@hedgedoc/commons' -import { ProviderType, GuestAccess } from '@hedgedoc/commons' +import { AuthProviderType, GuestAccess } from '@hedgedoc/commons' import { HttpMethod, respondToMatchingRequest, @@ -40,16 +40,16 @@ const initialConfig: FrontendConfigDto = { maxDocumentLength: isTestMode ? 200 : 1000000, authProviders: [ { - type: ProviderType.LOCAL + type: AuthProviderType.LOCAL }, { - type: ProviderType.LDAP, + type: AuthProviderType.LDAP, identifier: 'test-ldap', providerName: 'Test LDAP', theme: null }, { - type: ProviderType.OIDC, + type: AuthProviderType.OIDC, identifier: 'test-oidc', providerName: 'Test OIDC', theme: null diff --git a/frontend/src/pages/api/private/me/index.ts b/frontend/src/pages/api/private/me/index.ts index 08c5f0597..1fe22818b 100644 --- a/frontend/src/pages/api/private/me/index.ts +++ b/frontend/src/pages/api/private/me/index.ts @@ -6,7 +6,7 @@ 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' +import { AuthProviderType } from '@hedgedoc/commons' const handler = (req: NextApiRequest, res: NextApiResponse): void => { const cookieSet = req.headers?.['cookie']?.split(';').find((value) => value.trim() === 'mock-session=1') !== undefined @@ -18,7 +18,7 @@ const handler = (req: NextApiRequest, res: NextApiResponse): void => { username: 'mock', photoUrl: '/public/img/avatar.png', displayName: 'Mock User', - authProvider: ProviderType.LOCAL, + authProvider: AuthProviderType.LOCAL, email: 'mock@hedgedoc.test' }) } diff --git a/frontend/src/pages/api/private/notes/features/index.ts b/frontend/src/pages/api/private/notes/features/index.ts index f86b865f2..f5ea3841c 100644 --- a/frontend/src/pages/api/private/notes/features/index.ts +++ b/frontend/src/pages/api/private/notes/features/index.ts @@ -17,8 +17,8 @@ const handler = (req: NextApiRequest, res: NextApiResponse): void => { viewCount: 0, updatedAt: '2021-04-24T09:27:51.000Z', createdAt: '2021-04-24T09:27:51.000Z', - updateUsername: null, - primaryAddress: 'features', + lastUpdatedBy: null, + primaryAlias: 'features', editedBy: [], title: 'Features', tags: ['hedgedoc', 'demo', 'react'], diff --git a/frontend/src/pages/api/private/notes/index.ts b/frontend/src/pages/api/private/notes/index.ts index ea6638d6e..bb62b63c9 100644 --- a/frontend/src/pages/api/private/notes/index.ts +++ b/frontend/src/pages/api/private/notes/index.ts @@ -20,8 +20,8 @@ const handler = (req: NextApiRequest, res: NextApiResponse): void => { viewCount: 0, updatedAt: '2021-04-24T09:27:51.000Z', createdAt: '2021-04-24T09:27:51.000Z', - updateUsername: null, - primaryAddress: 'features', + lastUpdatedBy: null, + primaryAlias: 'features', editedBy: [], title: 'New note', tags: ['hedgedoc', 'demo', 'react'], diff --git a/frontend/src/pages/api/private/notes/slide-example/index.ts b/frontend/src/pages/api/private/notes/slide-example/index.ts index 2f3a26745..beb9abc78 100644 --- a/frontend/src/pages/api/private/notes/slide-example/index.ts +++ b/frontend/src/pages/api/private/notes/slide-example/index.ts @@ -13,11 +13,11 @@ const handler = (req: NextApiRequest, res: NextApiResponse): void => { '---\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', + primaryAlias: 'slide-example', version: 2, viewCount: 8, updatedAt: '2021-04-30T18:38:23.000Z', - updateUsername: null, + lastUpdatedBy: null, createdAt: '2021-04-30T18:38:14.000Z', editedBy: [], title: 'Slide example', diff --git a/frontend/src/redux/note-details/initial-state.ts b/frontend/src/redux/note-details/initial-state.ts index e4aa751fc..91f5fa312 100644 --- a/frontend/src/redux/note-details/initial-state.ts +++ b/frontend/src/redux/note-details/initial-state.ts @@ -7,7 +7,7 @@ import type { NoteDetails } from './types' import { defaultNoteFrontmatter } from '@hedgedoc/commons' export const initialState: NoteDetails = { - updateUsername: null, + lastUpdatedBy: null, version: 0, markdownContent: { plain: '', @@ -17,17 +17,15 @@ export const initialState: NoteDetails = { selection: { from: 0 }, rawFrontmatter: '', startOfContentLineOffset: 0, - id: '', createdAt: 0, updatedAt: 0, aliases: [], - primaryAddress: '', + primaryAlias: '', permissions: { owner: null, sharedToGroups: [], sharedToUsers: [] }, - viewCount: 0, editedBy: [], title: '', firstHeading: '', diff --git a/frontend/src/redux/note-details/methods.ts b/frontend/src/redux/note-details/methods.ts index 6469216cf..174a5c260 100644 --- a/frontend/src/redux/note-details/methods.ts +++ b/frontend/src/redux/note-details/methods.ts @@ -58,7 +58,7 @@ export const updateMetadata = async (): Promise => { if (!noteDetails) { return } - const updatedMetadata = await getNoteMetadata(noteDetails.id) + const updatedMetadata = await getNoteMetadata(noteDetails.primaryAlias) const action = noteDetailsActionsCreator.updateMetadata(updatedMetadata) store.dispatch(action) } diff --git a/frontend/src/redux/note-details/reducers/build-state-from-metadata-update.spec.ts b/frontend/src/redux/note-details/reducers/build-state-from-metadata-update.spec.ts index 89fd829da..f039d33d0 100644 --- a/frontend/src/redux/note-details/reducers/build-state-from-metadata-update.spec.ts +++ b/frontend/src/redux/note-details/reducers/build-state-from-metadata-update.spec.ts @@ -12,14 +12,14 @@ describe('build state from server permissions', () => { it('creates a new state with the given permissions', () => { const state: NoteDetails = { ...initialState } const metadata: NoteMetadataDto = { - updateUsername: 'test', + lastUpdatedBy: 'test', permissions: { owner: null, sharedToGroups: [], sharedToUsers: [] }, editedBy: [], - primaryAddress: 'test-id', + primaryAlias: 'test-id', tags: ['test'], description: 'test', id: 'test-id', @@ -32,14 +32,14 @@ describe('build state from server permissions', () => { } expect(buildStateFromMetadataUpdate(state, metadata)).toStrictEqual({ ...state, - updateUsername: 'test', + lastUpdatedBy: 'test', permissions: { owner: null, sharedToGroups: [], sharedToUsers: [] }, editedBy: [], - primaryAddress: 'test-id', + primaryAlias: 'test-id', id: 'test-id', aliases: [], title: 'test', diff --git a/frontend/src/redux/note-details/reducers/build-state-from-metadata-update.ts b/frontend/src/redux/note-details/reducers/build-state-from-metadata-update.ts index 8b9f2308e..5744fe7f2 100644 --- a/frontend/src/redux/note-details/reducers/build-state-from-metadata-update.ts +++ b/frontend/src/redux/note-details/reducers/build-state-from-metadata-update.ts @@ -16,15 +16,13 @@ import type { NoteMetadataDto } from '@hedgedoc/commons' export const buildStateFromMetadataUpdate = (state: NoteDetails, noteMetadata: NoteMetadataDto): NoteDetails => { return { ...state, - updateUsername: noteMetadata.updateUsername, + lastUpdatedBy: noteMetadata.lastUpdatedBy, permissions: noteMetadata.permissions, editedBy: noteMetadata.editedBy, - primaryAddress: noteMetadata.primaryAddress, - id: noteMetadata.id, + primaryAlias: noteMetadata.primaryAlias, aliases: noteMetadata.aliases, title: noteMetadata.title, version: noteMetadata.version, - viewCount: noteMetadata.viewCount, createdAt: DateTime.fromISO(noteMetadata.createdAt).toSeconds(), updatedAt: DateTime.fromISO(noteMetadata.updatedAt).toSeconds() } diff --git a/frontend/src/redux/note-details/reducers/build-state-from-set-note-data-from-server.spec.ts b/frontend/src/redux/note-details/reducers/build-state-from-set-note-data-from-server.spec.ts index 7501cef1f..3d2e5c183 100644 --- a/frontend/src/redux/note-details/reducers/build-state-from-set-note-data-from-server.spec.ts +++ b/frontend/src/redux/note-details/reducers/build-state-from-set-note-data-from-server.spec.ts @@ -32,7 +32,7 @@ describe('build state from set note data from server', () => { const noteDto: NoteDto = { content: 'line1\nline2', metadata: { - primaryAddress: 'alias', + primaryAlias: 'alias', version: 5678, aliases: [ { @@ -64,7 +64,7 @@ describe('build state from set note data from server', () => { tags: ['tag'], title: 'title', updatedAt: '2020-05-25T09:08:34.123', - updateUsername: 'updateusername' + lastUpdatedBy: 'updateusername' }, editedByAtPosition: [ { @@ -110,7 +110,7 @@ describe('build state from set note data from server', () => { id: 'id', createdAt: DateTime.fromISO('2012-05-25T09:08:34.123').toSeconds(), updatedAt: DateTime.fromISO('2020-05-25T09:08:34.123').toSeconds(), - updateUsername: 'updateusername', + lastUpdatedBy: 'updateusername', viewCount: 987, aliases: [ { @@ -119,7 +119,7 @@ describe('build state from set note data from server', () => { primaryAlias: true } ], - primaryAddress: 'alias', + primaryAlias: 'alias', version: 5678, editedBy: ['editedBy'], permissions: { diff --git a/frontend/src/test-utils/mock-app-state.ts b/frontend/src/test-utils/mock-app-state.ts index 07a0c1031..989ea26b8 100644 --- a/frontend/src/test-utils/mock-app-state.ts +++ b/frontend/src/test-utils/mock-app-state.ts @@ -10,7 +10,7 @@ import { initialState as initialStateEditorConfig } from '../redux/editor-config import { initialState as initialStateNoteDetails } from '../redux/note-details/initial-state' import { initialState as initialStateRealtimeStatus } from '../redux/realtime/initial-state' import { initialState as initialStateRendererStatus } from '../redux/renderer-status/initial-state' -import { type DeepPartial, ProviderType } from '@hedgedoc/commons' +import { type DeepPartial, AuthProviderType } from '@hedgedoc/commons' jest.mock('../redux/editor-config/methods', () => ({ loadFromLocalStorage: jest.fn().mockReturnValue(undefined) @@ -54,7 +54,7 @@ export const mockAppState = (state?: DeepPartial) => { email: state.user.email ?? null, displayName: state.user.displayName ?? '', photoUrl: state.user.photoUrl ?? null, - authProvider: state.user.authProvider ?? ProviderType.LOCAL + authProvider: state.user.authProvider ?? AuthProviderType.LOCAL } : null }) 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